diff --git a/billing-subscription.mjs b/billing-subscription.mjs index 3ae91c1..1ed7d18 100644 --- a/billing-subscription.mjs +++ b/billing-subscription.mjs @@ -1000,6 +1000,36 @@ export async function ensurePlanCatalogSchema(pool) { [nextTokens, now, planType, previousTokens], ); } + + // Repair active subscriptions that inherited DEFAULT 0 (unlimited) from the + // pre-image-quota free-plan backfill, but whose catalog quota is finite. + // True unlimited plans (catalog period_images = 0) are left unchanged. + await pool.query( + `INSERT INTO h5_image_quota_ledger + (id, user_id, delta, balance_after, reason, ref_id, operator_id, note, created_at) + SELECT UUID(), s.user_id, p.period_images, + GREATEST(0, p.period_images + s.period_images_bonus - s.period_images_used), + 'plan_change', s.id, NULL, + '回填套餐默认图片额度(补建漏写)', ? + FROM h5_subscriptions s + INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type + WHERE s.status = 'active' + AND s.expires_at > ? + AND s.period_images_limit = 0 + AND p.period_images > 0`, + [now, now], + ); + await pool.query( + `UPDATE h5_subscriptions s + INNER JOIN h5_plan_catalog p ON p.plan_type = s.plan_type + SET s.period_images_limit = p.period_images, + s.updated_at = ? + WHERE s.status = 'active' + AND s.expires_at > ? + AND s.period_images_limit = 0 + AND p.period_images > 0`, + [now, now], + ); } export function createPlanCatalogService(pool) { diff --git a/billing-subscription.test.mjs b/billing-subscription.test.mjs index d9198d1..809facf 100644 --- a/billing-subscription.test.mjs +++ b/billing-subscription.test.mjs @@ -74,12 +74,24 @@ describe('ensurePlanCatalogSchema', () => { await ensurePlanCatalogSchema(pool); const planUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_plan_catalog')); - const subUpdates = queries.filter(({ sql }) => sql.includes('UPDATE h5_subscriptions')); + const tokenSubUpdates = queries.filter(({ sql }) => ( + sql.includes('UPDATE h5_subscriptions') && sql.includes('period_tokens_limit') + )); + const imageSubUpdates = queries.filter(({ sql }) => ( + sql.includes('UPDATE h5_subscriptions s') && sql.includes('period_images_limit') + )); + const imageLedgerInserts = queries.filter(({ sql }) => ( + sql.includes('INSERT INTO h5_image_quota_ledger') && sql.includes('补建漏写') + )); assert.ok(planUpdates.some(({ params }) => params[0] === 450_000 && params[2] === 'free' && params[3] === 150_000)); assert.ok(planUpdates.some(({ params }) => params[0] === 3_600_000 && params[2] === 'lite' && params[3] === 1_200_000)); assert.ok(planUpdates.some(({ params }) => params[0] === 13_500_000 && params[2] === 'standard' && params[3] === 4_500_000)); - assert.equal(subUpdates.length, 3); + assert.equal(tokenSubUpdates.length, 3); + assert.equal(imageSubUpdates.length, 1); + assert.equal(imageLedgerInserts.length, 1); + assert.match(imageSubUpdates[0].sql, /p\.period_images > 0/); + assert.match(imageSubUpdates[0].sql, /s\.period_images_limit = 0/); }); }); diff --git a/db.mjs b/db.mjs index 7e02fe3..054dbe5 100644 --- a/db.mjs +++ b/db.mjs @@ -57,6 +57,16 @@ async function columnExists(pool, table, column) { return rows.length > 0; } +async function tableExists(pool, table) { + const [rows] = await pool.query( + `SELECT 1 FROM information_schema.TABLES + WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? + LIMIT 1`, + [table], + ); + return rows.length > 0; +} + async function indexExists(pool, table, index) { const [rows] = await pool.query( `SELECT 1 FROM information_schema.STATISTICS @@ -943,10 +953,23 @@ export async function migrateSchema(pool) { // Back-fill free subscriptions for existing users who pre-date the subscription system. // Idempotent: only inserts where no active subscription exists. + // period_images_limit defaults to 0 (= unlimited). Always write the catalog + // quota when the column exists, otherwise expired paid users become unlimited + // on the next Portal/Plaza start. + const hasImageLimit = await columnExists(pool, 'h5_subscriptions', 'period_images_limit'); + const hasCatalog = await tableExists(pool, 'h5_plan_catalog'); + const tokenSelect = hasCatalog ? 'COALESCE(c.period_tokens, 150000)' : '150000'; + const imageSelect = hasCatalog ? 'COALESCE(c.period_images, 10)' : '10'; + const catalogJoin = hasCatalog ? "LEFT JOIN h5_plan_catalog c ON c.plan_type = 'free'" : ''; + const imageCols = hasImageLimit + ? 'period_images_limit, period_images_used, period_images_bonus,' + : ''; + const imageVals = hasImageLimit ? `${imageSelect}, 0, 0,` : ''; await pool.query(` INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, + ${imageCols} period_start, period_end, expires_at, overage_rate, operator_id, note, created_at, updated_at) SELECT @@ -954,7 +977,8 @@ export async function migrateSchema(pool) { u.id, 'free', 'active', - 150000, 0, + ${tokenSelect}, 0, + ${imageVals} UNIX_TIMESTAMP() * 1000, (UNIX_TIMESTAMP() + 30 * 86400) * 1000, (UNIX_TIMESTAMP() + 30 * 86400) * 1000, @@ -964,6 +988,7 @@ export async function migrateSchema(pool) { UNIX_TIMESTAMP() * 1000, UNIX_TIMESTAMP() * 1000 FROM h5_users u + ${catalogJoin} WHERE NOT EXISTS ( SELECT 1 FROM h5_subscriptions s WHERE s.user_id = u.id AND s.status = 'active' diff --git a/docs/incidents/2026-08-14-free-plan-image-quota-unlimited.md b/docs/incidents/2026-08-14-free-plan-image-quota-unlimited.md new file mode 100644 index 0000000..c011b1c --- /dev/null +++ b/docs/incidents/2026-08-14-free-plan-image-quota-unlimited.md @@ -0,0 +1,43 @@ +# 2026-08-14 过期后补建免费套餐把图片额度写成无限 + +## 现象 + +生产管理后台用户详情里,部分普通用户的图片额度显示「无限」,输入框和「保存额度」被禁用。典型用户:柯彤(`wx_1ugymxba`),当时有效套餐为免费版,备注「系统迁移补建免费套餐」,`period_images_limit = 0`,已用 4 张。 + +后台把 `period_images_limit === 0` 视为无限,用户详情和 `setImageQuota` 都拒绝调整。这些用户可以无限制调用 `image_make`。 + +## 根因 + +1. `initSchema` / `migrateSchema` 在用户没有有效订阅时会补建免费套餐,INSERT 只写了 token 额度,没有写 `period_images_limit`。 +2. 该列 `DEFAULT 0`,而计费把 `0` 设计成「无限」,不是「没有额度」。 +3. 付费套餐过期后,Portal / Plaza 下次启动会给该用户补建免费套餐,于是变成无限生图。 + +生产套餐目录里免费版是 10 张/周期,不是无限。事故发生时有 33 条有效免费订阅命中该补建备注且上限为 0;另有 86 条正常免费订阅上限为 10。 + +## 修复 + +- 补建免费套餐时写入套餐目录的 `period_images`(无目录时回退 10)。 +- `ensurePlanCatalogSchema` 启动时把「有效订阅上限为 0,但目录额度 > 0」的记录回填为目录值,并记一条 `plan_change` 流水。真正无限套餐(目录 `period_images = 0`)不改。 +- 生产已对这 33 条有效订阅做一次数据回填。代码修复需随 Memind 发布后才会阻止再次补建漏写。 + +## 排查入口 + +```sql +SELECT u.username, u.display_name, s.plan_type, s.note, + s.period_images_limit, s.period_images_used, s.period_images_bonus +FROM h5_subscriptions s +JOIN h5_users u ON u.id = s.user_id +WHERE s.status = 'active' + AND s.expires_at > UNIX_TIMESTAMP() * 1000 + AND s.period_images_limit = 0; + +SELECT plan_type, name, period_images FROM h5_plan_catalog; +``` + +管理后台:用户详情「图片生成额度」显示无限且无法保存,同时计费里该用户是免费套餐、备注含「系统迁移补建」。 + +## 影响边界与回滚 + +- 目录里本身就是无限的套餐(`period_images = 0`)不会被回填。 +- 已用量超过目录额度的用户回填后剩余为 0,不能再继续按无限额度生图。 +- 回滚代码不会自动把已回填的上限改回 0;如需恢复无限,要显式改订阅或目录。