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:
@@ -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) {
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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;如需恢复无限,要显式改订阅或目录。
|
||||
Reference in New Issue
Block a user