fix(billing): backfill finite image quota after free-plan rebuild

Expired paid users were rebuilt as free with period_images_limit=0, which the quota system treats as unlimited. Write the catalog quota on rebuild and repair existing finite plans on schema ensure.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-08-14 21:39:55 +08:00
parent 41f245c88a
commit 8cf1c772a7
4 changed files with 113 additions and 3 deletions
+30
View File
@@ -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) {
+14 -2
View File
@@ -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/);
});
});
+26 -1
View File
@@ -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'
@@ -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;如需恢复无限,要显式改订阅或目录。