From 946d8756c8d34229ca61c6129440d69bc6bf4115 Mon Sep 17 00:00:00 2001 From: john Date: Mon, 3 Aug 2026 14:58:38 +0800 Subject: [PATCH] feat(billing): enforce image generation quota with admin API and user display Add period_images_bonus and ledger tracking, gate image_make on remaining quota, expose admin image-quota routes, show remaining image quota in the balance popover, and document that admin UI must live in memind_adm (5174) not ops. Co-authored-by: Cursor --- AGENTS.md | 19 ++ admin-bootstrap.mjs | 13 +- admin-routes.mjs | 85 +++++++ billing-subscription.mjs | 268 ++++++++++++++++++++- billing-subscription.test.mjs | 55 ++++- mindspace-image-generation-routes.mjs | 3 + mindspace-image-generation.mjs | 31 +++ mindspace-image-generation.test.mjs | 66 +++++ ops/README.md | 17 ++ schema.sql | 16 ++ server.mjs | 1 + server/portal-agent-services-bootstrap.mjs | 2 + src/components/BalanceRing.tsx | 82 ++++++- src/types.ts | 4 + 14 files changed, 639 insertions(+), 23 deletions(-) create mode 100644 ops/README.md diff --git a/AGENTS.md b/AGENTS.md index 7c99d77..a455ed9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -100,3 +100,22 @@ Cursor 额外加载:`.cursor/rules/mindspace-publish-chat-finish-guards.mdc` - Page Data API:见 [docs/page-data-api-usage.md](docs/page-data-api-usage.md);改动相关路径后执行 `npm run verify:page-data` - 生产隔离:[docs/service-isolation-runbook.md](docs/service-isolation-runbook.md) - 发版须 Git commit,禁止本机直 `rsync` 到 `103/105` + +## 必读:管理后台 UI 边界(memind_adm 5174) + +**平台管理后台的前端 UI 只能在独立仓库 `memind_adm`(本地 5174 / 生产 gadm)开发,禁止在 Memind 本仓库的 `ops/`(约 3002)新增管理功能。** + +| 层级 | 位置 | 说明 | +|------|------|------| +| 管理后台 UI | `memind_adm` · **5174** | 用户、计费、图片额度、策略、模型中心等 **唯一合法入口** | +| 管理后台 API | `memind_adm/server` · 8085 | 从 Memind 导入共享业务模块并暴露 `/admin-api/*` | +| Memind `ops/` | 本仓库 · ~3002 | **遗留**:Plaza 运营 + 旧超级管理路由;**不得扩展**新页面或导航 | +| Memind 后端 | 本仓库 · 8081 / 8082 | Portal、Admin API、计费/额度等业务逻辑可实现于此,但 UI 必须在 memind_adm | + +新增管理功能时的正确流程: + +1. 在 **Memind** 实现或扩展共享业务模块与 API(如 `billing-subscription.mjs`、`admin-routes.mjs`)。 +2. 在 **memind_adm** 添加页面、路由、API 客户端与导航。 +3. **不要**在 `ops/src/pages/admin/`、`ops/src/components/AdminLayout.tsx` 等处添加新功能。 + +详见 [ops/README.md](ops/README.md) 与 `memind_adm/AGENTS.md`。 diff --git a/admin-bootstrap.mjs b/admin-bootstrap.mjs index 2e1ad3e..7e519d8 100644 --- a/admin-bootstrap.mjs +++ b/admin-bootstrap.mjs @@ -12,7 +12,11 @@ // The same factory functions imported here are the ones server.mjs uses, so the // domain logic has a single source of truth; only the wiring differs. import path from 'node:path'; -import { createSubscriptionService } from './billing-subscription.mjs'; +import { + createPlanCatalogService, + createSubscriptionService, + ensurePlanCatalogSchema, +} from './billing-subscription.mjs'; import { createDbPool, ensureAssetGatewaySchema, isDatabaseConfigured } from './db.mjs'; import { createUserAuth } from './user-auth.mjs'; import { createLlmProviderService } from './llm-providers.mjs'; @@ -69,6 +73,7 @@ export async function createAdminServices(env = {}) { // The back-office process can boot before the public Portal. Create only the // optional control-plane tables here instead of requiring the public boot path. await ensureAssetGatewaySchema(pool); + await ensurePlanCatalogSchema(pool); // --- plaza graph (review queue, reports, featured, analytics, creators) --- const plazaRedis = createNoopPlazaRedis(); @@ -96,7 +101,11 @@ export async function createAdminServices(env = {}) { }); // --- platform super-admin services --- - const subscriptionService = createSubscriptionService(pool); + const planCatalogService = createPlanCatalogService(pool); + const subscriptionService = createSubscriptionService(pool, { + getPlanAsync: (planType) => planCatalogService.getPlan(planType), + }); + subscriptionService._planCatalogService = planCatalogService; const userAuth = createUserAuth(pool, { usersRoot, h5Root, diff --git a/admin-routes.mjs b/admin-routes.mjs index 3bec14a..8250c16 100644 --- a/admin-routes.mjs +++ b/admin-routes.mjs @@ -1068,6 +1068,91 @@ export function createAdminApi({ res.json({ expiredCount: count }); }); + // ── Image generation quota ──────────────────────────────────────────────── + + adminApi.get('/image-quota/config', requireAdmin, async (_req, res) => { + const planCatalogService = subscriptionService?._planCatalogService; + if (!planCatalogService?.listPlans) { + return res.status(503).json({ message: '套餐目录服务未启用' }); + } + const plans = await planCatalogService.listPlans(); + res.json({ + plans: plans.map((plan) => ({ + ...plan, + approxCalls: tokensToCallsApprox(plan.periodTokens), + })), + }); + }); + + adminApi.patch('/image-quota/config/:planType', requireAdmin, async (req, res) => { + const planCatalogService = subscriptionService?._planCatalogService; + if (!planCatalogService?.upsertPlan) { + return res.status(503).json({ message: '套餐目录服务未启用' }); + } + const periodImages = Number(req.body?.periodImages); + if (!Number.isFinite(periodImages) || periodImages < 0) { + return res.status(400).json({ message: 'periodImages 必须是非负整数;0 表示无限' }); + } + const current = await planCatalogService.getPlan(req.params.planType); + if (!current) return res.status(404).json({ message: '套餐不存在' }); + const result = await planCatalogService.upsertPlan(req.params.planType, { + ...current, + periodImages: Math.floor(periodImages), + }); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/users/:userId/image-quota', requireAdmin, async (req, res) => { + if (!subscriptionService?.getImageQuota) { + return res.status(503).json({ message: '图片额度服务未启用' }); + } + const quotaResult = await subscriptionService.getImageQuota(req.params.userId); + if (!quotaResult.ok) return res.status(404).json({ message: quotaResult.message }); + const ledger = subscriptionService.listImageQuotaLedger + ? await subscriptionService.listImageQuotaLedger({ + userId: req.params.userId, + page: 1, + pageSize: 20, + }) + : { entries: [] }; + res.json({ + subscription: quotaResult.subscription, + quota: quotaResult.quota, + ledger: ledger.entries, + }); + }); + + adminApi.post('/users/:userId/image-quota/grant', requireAdmin, async (req, res) => { + if (!subscriptionService?.grantImageQuota) { + return res.status(503).json({ message: '图片额度服务未启用' }); + } + const delta = Number(req.body?.delta); + if (!Number.isFinite(delta) || delta === 0) { + return res.status(400).json({ message: 'delta 必须是非零整数' }); + } + const note = String(req.body?.note ?? '').trim(); + const result = await subscriptionService.grantImageQuota( + req.params.userId, + Math.floor(delta), + { operatorId: req.currentUser.id, note }, + ); + if (!result.ok) return res.status(400).json({ message: result.message }); + res.json(result); + }); + + adminApi.get('/image-quota/ledger', requireAdmin, async (req, res) => { + if (!subscriptionService?.listImageQuotaLedger) { + return res.status(503).json({ message: '图片额度服务未启用' }); + } + const result = await subscriptionService.listImageQuotaLedger({ + userId: req.query.userId ? String(req.query.userId) : null, + page: req.query.page, + pageSize: req.query.pageSize, + }); + res.json(result); + }); + return adminApi; } diff --git a/billing-subscription.mjs b/billing-subscription.mjs index d3ccb74..584e343 100644 --- a/billing-subscription.mjs +++ b/billing-subscription.mjs @@ -79,6 +79,7 @@ function mapSubRow(row) { periodTokensUsed: Number(row.period_tokens_used ?? 0), periodImagesLimit: Number(row.period_images_limit ?? 0), periodImagesUsed: Number(row.period_images_used ?? 0), + periodImagesBonus: Number(row.period_images_bonus ?? 0), periodStart: Number(row.period_start), periodEnd: Number(row.period_end), expiresAt: Number(row.expires_at), @@ -91,6 +92,35 @@ function mapSubRow(row) { }; } +export function computeImageQuotaView(sub) { + if (!sub) { + return { + limit: 0, + bonus: 0, + used: 0, + total: 0, + remaining: 0, + unlimited: false, + }; + } + const limit = Number(sub.periodImagesLimit ?? 0); + const bonus = Number(sub.periodImagesBonus ?? 0); + const used = Number(sub.periodImagesUsed ?? 0); + const unlimited = limit === 0; + const total = unlimited ? null : limit + bonus; + const remaining = unlimited ? null : Math.max(0, total - used); + return { + limit, + bonus, + used, + total, + remaining, + unlimited, + periodEnd: sub.periodEnd ?? null, + planType: sub.planType ?? null, + }; +} + export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { // Resolve plan definition: prefer DB-backed loader, fall back to hardcoded catalog. const resolvePlan = async (planType) => { @@ -137,10 +167,10 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, - period_images_limit, period_images_used, + period_images_limit, period_images_used, period_images_bonus, period_start, period_end, expires_at, overage_rate, auto_renew, operator_id, note, created_at, updated_at) - VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, 0, ?, ?, ?, ?, 0, ?, ?, ?, ?)`, [ id, userId, planType, plan.periodTokens, plan.periodImages ?? 0, @@ -233,13 +263,14 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, - period_images_limit, period_images_used, + period_images_limit, period_images_used, period_images_bonus, period_start, period_end, expires_at, overage_rate, auto_renew, operator_id, note, created_at, updated_at) VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ newId, sub.userId, sub.planType, plan.periodTokens, plan.periodImages ?? 0, + sub.periodImagesBonus ?? 0, now, newPeriodEnd, newPeriodEnd, Number(plan.overageRate.toFixed(2)), sub.autoRenew ? 1 : 0, @@ -401,10 +432,10 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, - period_images_limit, period_images_used, + period_images_limit, period_images_used, period_images_bonus, period_start, period_end, expires_at, overage_rate, auto_renew, operator_id, note, created_at, updated_at) - VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, NULL, '用户自助购买', ?, ?)`, + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, 0, ?, ?, ?, ?, ?, NULL, '用户自助购买', ?, ?)`, [ id, userId, planType, plan.periodTokens, plan.periodImages ?? 0, @@ -510,13 +541,14 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { await conn.query( `INSERT INTO h5_subscriptions (id, user_id, plan_type, status, period_tokens_limit, period_tokens_used, - period_images_limit, period_images_used, + period_images_limit, period_images_used, period_images_bonus, period_start, period_end, expires_at, overage_rate, auto_renew, operator_id, note, created_at, updated_at) - VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`, + VALUES (?, ?, ?, 'active', ?, 0, ?, 0, ?, ?, ?, ?, ?, 1, NULL, '自动续费', ?, ?)`, [ newId, sub.userId, sub.planType, plan.periodTokens, plan.periodImages ?? 0, + sub.periodImagesBonus ?? 0, now, newPeriodEnd, newPeriodEnd, Number(plan.overageRate.toFixed(2)), now, now, @@ -544,9 +576,76 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { return { renewed, failed }; }; - const consumeImageQuotaTx = async (userId, count, conn) => { + const appendImageQuotaLedgerTx = async (conn, { + userId, + delta, + balanceAfter, + reason, + refId = null, + operatorId = null, + note = null, + }) => { + await conn.query( + `INSERT INTO h5_image_quota_ledger + (id, user_id, delta, balance_after, reason, ref_id, operator_id, note, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + [ + crypto.randomUUID(), + userId, + delta, + balanceAfter, + reason, + refId ? String(refId).slice(0, 128) : null, + operatorId, + note ? String(note).slice(0, 512) : null, + Date.now(), + ], + ); + }; + + const getImageQuota = async (userId) => { + const sub = await getActiveSubscription(userId); + if (!sub) { + return { + ok: false, + code: 'no_subscription', + message: '当前没有有效订阅,无法使用图片生成额度', + }; + } + return { ok: true, subscription: sub, quota: computeImageQuotaView(sub) }; + }; + + const checkImageQuota = async (userId, count = 1) => { + const safeCount = Math.max(1, Math.floor(Number(count) || 1)); + const result = await getImageQuota(userId); + if (!result.ok) return result; + const { quota } = result; + if (quota.unlimited || (quota.remaining ?? 0) >= safeCount) { + return { ok: true, ...result, requested: safeCount }; + } + return { + ok: false, + code: 'image_quota_exceeded', + message: '图片生成额度不足', + subscription: result.subscription, + quota, + requested: safeCount, + }; + }; + + const consumeImageQuotaTx = async (userId, count, conn, options = {}) => { if (!count || count <= 0) return { fullyCovers: true }; + const refId = String(options.refId ?? '').trim() || null; const now = Date.now(); + if (refId) { + const [existing] = await conn.query( + `SELECT id FROM h5_image_quota_ledger + WHERE user_id = ? AND ref_id = ? AND reason = 'consume' + LIMIT 1`, + [userId, refId], + ); + if (existing.length) return { fullyCovers: true, duplicate: true }; + } const [rows] = await conn.query( `SELECT * FROM h5_subscriptions WHERE user_id = ? AND status = 'active' AND expires_at > ? @@ -557,7 +656,8 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { if (!sub) return { fullyCovers: false }; const unlimited = sub.periodImagesLimit === 0; - const remaining = unlimited ? Infinity : sub.periodImagesLimit - sub.periodImagesUsed; + const capacity = unlimited ? Infinity : sub.periodImagesLimit + sub.periodImagesBonus; + const remaining = unlimited ? Infinity : capacity - sub.periodImagesUsed; if (unlimited || remaining >= count) { await conn.query( @@ -566,20 +666,34 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { WHERE id = ?`, [count, now, sub.id], ); - return { fullyCovers: true }; + const balanceAfter = unlimited + ? null + : Math.max(0, capacity - (sub.periodImagesUsed + count)); + if (refId) { + await appendImageQuotaLedgerTx(conn, { + userId, + delta: -count, + balanceAfter, + reason: 'consume', + refId, + operatorId: options.operatorId ?? null, + note: options.note ?? null, + }); + } + return { fullyCovers: true, balanceAfter }; } return { fullyCovers: false }; }; // Deduct image count from the active subscription quota. // If conn is omitted, this method manages its own short transaction. - const consumeImageQuota = async (userId, count, conn = null) => { - if (conn) return consumeImageQuotaTx(userId, count, conn); + const consumeImageQuota = async (userId, count, conn = null, options = {}) => { + if (conn) return consumeImageQuotaTx(userId, count, conn, options); const ownConn = await pool.getConnection(); try { await ownConn.beginTransaction(); - const result = await consumeImageQuotaTx(userId, count, ownConn); + const result = await consumeImageQuotaTx(userId, count, ownConn, options); await ownConn.commit(); return result; } catch (err) { @@ -590,6 +704,112 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { } }; + const grantImageQuota = async (userId, delta, { operatorId = null, note = '' } = {}) => { + const safeDelta = Math.floor(Number(delta)); + if (!Number.isFinite(safeDelta) || safeDelta === 0) { + return { ok: false, message: '充值额度必须是非零整数' }; + } + const now = Date.now(); + const conn = await pool.getConnection(); + try { + await conn.beginTransaction(); + const [rows] = await conn.query( + `SELECT * FROM h5_subscriptions + WHERE user_id = ? AND status = 'active' AND expires_at > ? + ORDER BY expires_at DESC LIMIT 1 FOR UPDATE`, + [userId, now], + ); + const sub = mapSubRow(rows[0]); + if (!sub) { + await conn.rollback(); + return { ok: false, message: '用户没有有效订阅,无法充值图片额度' }; + } + if (safeDelta < 0 && sub.periodImagesLimit !== 0) { + const capacity = sub.periodImagesLimit + sub.periodImagesBonus; + const remaining = capacity - sub.periodImagesUsed; + if (remaining + safeDelta < 0) { + await conn.rollback(); + return { ok: false, message: '扣减后图片额度不能为负数' }; + } + } + await conn.query( + `UPDATE h5_subscriptions + SET period_images_bonus = GREATEST(0, period_images_bonus + ?), updated_at = ? + WHERE id = ?`, + [safeDelta, now, sub.id], + ); + const updatedSub = { + ...sub, + periodImagesBonus: Math.max(0, sub.periodImagesBonus + safeDelta), + }; + const quota = computeImageQuotaView(updatedSub); + await appendImageQuotaLedgerTx(conn, { + userId, + delta: safeDelta, + balanceAfter: quota.unlimited ? null : quota.remaining, + reason: 'admin_grant', + operatorId, + note, + }); + await conn.commit(); + return { ok: true, subscription: updatedSub, quota }; + } catch (err) { + await conn.rollback(); + throw err; + } finally { + conn.release(); + } + }; + + const listImageQuotaLedger = async ({ + userId = null, + page = 1, + pageSize = 20, + } = {}) => { + const safePageSize = Math.min(Math.max(Number(pageSize) || 20, 1), 100); + const safePage = Math.max(Number(page) || 1, 1); + const offset = (safePage - 1) * safePageSize; + const params = []; + const clauses = []; + if (userId) { + clauses.push('l.user_id = ?'); + params.push(userId); + } + const where = clauses.length ? `WHERE ${clauses.join(' AND ')}` : ''; + const [[{ total }]] = await pool.query( + `SELECT COUNT(*) AS total FROM h5_image_quota_ledger l ${where}`, + params, + ); + const [rows] = await pool.query( + `SELECT l.*, u.username, u.display_name + FROM h5_image_quota_ledger l + JOIN h5_users u ON u.id = l.user_id + ${where} + ORDER BY l.created_at DESC + LIMIT ${safePageSize} OFFSET ${offset}`, + params, + ); + return { + entries: rows.map((row) => ({ + id: row.id, + userId: row.user_id, + username: row.username, + displayName: row.display_name, + delta: Number(row.delta), + balanceAfter: row.balance_after == null ? null : Number(row.balance_after), + reason: row.reason, + refId: row.ref_id, + operatorId: row.operator_id, + note: row.note, + createdAt: Number(row.created_at), + })), + total: Number(total), + page: safePage, + pageSize: safePageSize, + totalPages: Math.max(1, Math.ceil(Number(total) / safePageSize)), + }; + }; + return { getActiveSubscription, grantSubscription, @@ -598,6 +818,10 @@ export function createSubscriptionService(pool, { getPlanAsync = null } = {}) { processAutoRenewals, consumeQuota, consumeImageQuota, + getImageQuota, + checkImageQuota, + grantImageQuota, + listImageQuotaLedger, renewSubscription, expireStaleSubscriptions, cancelSubscription, @@ -632,10 +856,28 @@ export async function ensurePlanCatalogSchema(pool) { for (const col of [ 'ALTER TABLE h5_subscriptions ADD COLUMN period_images_limit INT NOT NULL DEFAULT 0', 'ALTER TABLE h5_subscriptions ADD COLUMN period_images_used INT NOT NULL DEFAULT 0', + 'ALTER TABLE h5_subscriptions ADD COLUMN period_images_bonus INT NOT NULL DEFAULT 0', ]) { try { await pool.query(col); } catch (_) { /* column already exists */ } } + await pool.query(` + CREATE TABLE IF NOT EXISTS h5_image_quota_ledger ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + delta INT NOT NULL, + balance_after INT NULL, + reason ENUM('admin_grant', 'admin_adjust', 'consume', 'period_reset', 'plan_change') NOT NULL, + ref_id VARCHAR(128) NULL, + operator_id CHAR(36) NULL, + note VARCHAR(512) NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_image_quota_user_created (user_id, created_at), + KEY idx_h5_image_quota_ref (user_id, ref_id), + CONSTRAINT fk_h5_image_quota_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + `); + // Seed defaults if catalog is empty. const [[{ cnt }]] = await pool.query(`SELECT COUNT(*) AS cnt FROM h5_plan_catalog`); if (Number(cnt) === 0) { diff --git a/billing-subscription.test.mjs b/billing-subscription.test.mjs index 674ccde..59992ca 100644 --- a/billing-subscription.test.mjs +++ b/billing-subscription.test.mjs @@ -2,6 +2,7 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; import { PLAN_CATALOG, + computeImageQuotaView, createSubscriptionService, ensurePlanCatalogSchema, getPlanDef, @@ -129,6 +130,7 @@ function makeSubRow(overrides = {}) { period_tokens_used: 0, period_images_limit: 50, period_images_used: 0, + period_images_bonus: 0, period_start: now, period_end: now + 30 * 24 * 60 * 60 * 1000, expires_at: now + 30 * 24 * 60 * 60 * 1000, @@ -141,6 +143,31 @@ function makeSubRow(overrides = {}) { }; } +describe('computeImageQuotaView', () => { + it('computes remaining quota with bonus', () => { + const view = computeImageQuotaView({ + periodImagesLimit: 10, + periodImagesBonus: 5, + periodImagesUsed: 3, + planType: 'free', + }); + assert.equal(view.total, 15); + assert.equal(view.remaining, 12); + assert.equal(view.unlimited, false); + }); + + it('treats limit=0 as unlimited', () => { + const view = computeImageQuotaView({ + periodImagesLimit: 0, + periodImagesBonus: 0, + periodImagesUsed: 99, + planType: 'pro', + }); + assert.equal(view.unlimited, true); + assert.equal(view.remaining, null); + }); +}); + describe('createSubscriptionService', () => { describe('getActiveSubscription', () => { it('returns null when no active subscription', async () => { @@ -219,6 +246,31 @@ describe('createSubscriptionService', () => { ), ); }); + + it('includes bonus in remaining capacity', async () => { + const subRow = makeSubRow({ + period_images_limit: 1, + period_images_bonus: 2, + period_images_used: 2, + }); + const pool = makePool(subRow); + const svc = createSubscriptionService(pool); + const check = await svc.checkImageQuota('user-1', 1); + assert.equal(check.ok, true); + }); + + it('blocks when quota is exhausted', async () => { + const subRow = makeSubRow({ + period_images_limit: 1, + period_images_bonus: 0, + period_images_used: 1, + }); + const pool = makePool(subRow); + const svc = createSubscriptionService(pool); + const check = await svc.checkImageQuota('user-1', 1); + assert.equal(check.ok, false); + assert.equal(check.code, 'image_quota_exceeded'); + }); }); describe('cancelSubscription', () => { @@ -294,7 +346,8 @@ describe('createSubscriptionService', () => { const insert = conn.queries.find(({ sql }) => sql.includes('INSERT INTO h5_subscriptions')); assert.ok(insert); assert.equal(insert.params[3], 9_999_000); - assert.equal(insert.params[8], 0.45); + assert.equal(insert.params[5], 0); + assert.equal(insert.params[9], 0.45); }); }); }); diff --git a/mindspace-image-generation-routes.mjs b/mindspace-image-generation-routes.mjs index 51d2af4..e66a551 100644 --- a/mindspace-image-generation-routes.mjs +++ b/mindspace-image-generation-routes.mjs @@ -38,6 +38,9 @@ async function handleGenerate(req, res, { service, userId, consumerPrefix }) { 'IMAGE_MAKE_HTTP_ERROR', 'IMAGE_MAKE_TIMEOUT', ]).has(result.code); + if (result.code === 'image_quota_exceeded') { + return res.status(402).json(result); + } return res.status(unavailable ? 503 : 409).json(result); } return res.status(201).json({ data: result }); diff --git a/mindspace-image-generation.mjs b/mindspace-image-generation.mjs index 46e9204..f9fb8ac 100644 --- a/mindspace-image-generation.mjs +++ b/mindspace-image-generation.mjs @@ -1,4 +1,5 @@ import crypto from 'node:crypto'; +import { IMAGE_MAKE_PURPOSE_CATALOG } from './asset-gateway.mjs'; const PURPOSES = { inline_image: { presetId: 'memind_square_illustration', filename: 'inline-image.webp', envPrefix: 'INLINE_IMAGE' }, @@ -58,9 +59,15 @@ export function createMindSpaceImageGenerationService({ assetService, imageMakeClient, imageReviewService, + subscriptionService = null, env = process.env, logger = console, } = {}) { + function shouldBillImageGeneration(purpose) { + const purposeConfig = IMAGE_MAKE_PURPOSE_CATALOG.find((item) => item.id === purpose); + return purposeConfig?.strategy !== 'derive'; + } + async function generate({ userId, purpose, @@ -78,6 +85,18 @@ export function createMindSpaceImageGenerationService({ } const gate = await configService.resolveImageGenerationPurpose(purpose); if (!gate.ok) return { ...gate, fallback: true }; + if (shouldBillImageGeneration(purpose) && subscriptionService?.checkImageQuota) { + const quotaCheck = await subscriptionService.checkImageQuota(userId, 1); + if (!quotaCheck.ok) { + return { + ok: false, + fallback: false, + code: quotaCheck.code ?? 'image_quota_exceeded', + message: quotaCheck.message ?? '图片生成额度不足', + quota: quotaCheck.quota ?? null, + }; + } + } if (!imageMakeClient || !assetService?.createChatAsset) { return { ok: false, fallback: true, code: 'runtime_unavailable', message: '图片生成服务未配置' }; } @@ -162,6 +181,18 @@ export function createMindSpaceImageGenerationService({ await imageMakeClient.acknowledge(generated.jobId, asset.id).catch((error) => { logger.warn?.('[image_make] acknowledge failed; TTL cleanup will apply:', error?.message ?? error); }); + if (shouldBillImageGeneration(purpose) && subscriptionService?.consumeImageQuota) { + const consumed = await subscriptionService.consumeImageQuota(userId, 1, null, { + refId: generated.jobId, + note: `image_make:${purpose}`, + }); + if (!consumed?.fullyCovers) { + logger.warn?.('[image_make] quota consume failed after successful generation', { + userId, + jobId: generated.jobId, + }); + } + } return { ok: true, purpose, diff --git a/mindspace-image-generation.test.mjs b/mindspace-image-generation.test.mjs index 4d88de4..fd8b255 100644 --- a/mindspace-image-generation.test.mjs +++ b/mindspace-image-generation.test.mjs @@ -323,3 +323,69 @@ test('MindSpace image generation fails closed when semantic review is unavailabl assert.equal(result.code, 'IMAGE_REVIEW_UNAVAILABLE'); assert.equal(storeCount, 0); }); + +test('MindSpace image generation blocks when user image quota is exhausted', async () => { + const service = createMindSpaceImageGenerationService({ + configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'hero' }; } }, + subscriptionService: { + async checkImageQuota() { + return { + ok: false, + code: 'image_quota_exceeded', + message: '图片生成额度不足', + quota: { remaining: 0 }, + }; + }, + }, + imageMakeClient: { + async generateImage() { + throw new Error('should not call image_make when quota is exhausted'); + }, + }, + }); + + const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: 'test' }); + assert.equal(result.ok, false); + assert.equal(result.code, 'image_quota_exceeded'); + assert.equal(result.fallback, false); +}); + +test('MindSpace image generation consumes quota after a successful hero generation', async () => { + const events = []; + const service = createMindSpaceImageGenerationService({ + configService: { async resolveImageGenerationPurpose() { return { ok: true, presetId: 'hero' }; } }, + subscriptionService: { + async checkImageQuota() { + events.push('check'); + return { ok: true, quota: { remaining: 5 } }; + }, + async consumeImageQuota(userId, count, conn, options) { + events.push(['consume', userId, count, options?.refId]); + return { fullyCovers: true }; + }, + }, + imageMakeClient: { + async generateImage() { + return { + jobId: 'job-quota-1', buffer: Buffer.from('image'), mimeType: 'image/webp', + sha256: 'abc', width: 768, height: 432, + }; + }, + async acknowledge() {}, + }, + imageReviewService: passingReviewService(), + assetService: { + async createChatAsset() { + return { + id: 'asset-1', + publicUrl: 'https://example.test/image.webp', + workspaceRelativePath: 'public/images/hero.webp', + }; + }, + }, + }); + + const result = await service.generate({ userId: 'user-1', purpose: 'hero', prompt: 'test' }); + assert.equal(result.ok, true); + assert.deepEqual(events, ['check', ['consume', 'user-1', 1, 'job-quota-1']]); +}); diff --git a/ops/README.md b/ops/README.md new file mode 100644 index 0000000..5f816c1 --- /dev/null +++ b/ops/README.md @@ -0,0 +1,17 @@ +# Memind `ops/` — 遗留运营控制台(禁止新增管理功能) + +> **重要:本目录不再接受新的管理后台功能开发。** +> +> 所有平台管理后台 UI(用户、计费、图片额度、策略、模型中心等)必须在独立仓库 **`memind_adm`** 中实现,本地开发端口 **5174**(生产 gadm),API 端口 **8085**。 +> +> 本目录(Memind `ops/`,本地约 **3002**)仅保留 **Plaza 运营**相关页面(审核、精选、创作者等)及历史遗留的超级管理路由;**不得**在此新增页面、导航项或 API 客户端。 + +## 分工 + +| 组件 | 仓库 / 端口 | 用途 | +|------|-------------|------| +| **memind_adm** | `memind_adm` · **5174** / 8085 | **唯一合法的管理后台 UI** | +| Memind `ops/` | 本仓库 · ~3002 | Plaza 运营 + 遗留 admin(只读维护,不扩展) | +| Memind 后端 | 本仓库 · 8081 / 8082 | Portal 与 Admin API;业务逻辑可在此实现,UI 须在 memind_adm | + +新增管理功能时:在 `memind_adm` 添加页面与路由;如需新 API,优先在 `memind_adm/server/` 挂载或复用 Memind 共享模块。 diff --git a/schema.sql b/schema.sql index a575b72..f6c1cb3 100644 --- a/schema.sql +++ b/schema.sql @@ -1181,6 +1181,7 @@ CREATE TABLE IF NOT EXISTS h5_subscriptions ( period_tokens_used BIGINT NOT NULL DEFAULT 0, period_images_limit INT NOT NULL DEFAULT 0, period_images_used INT NOT NULL DEFAULT 0, + period_images_bonus INT NOT NULL DEFAULT 0, period_start BIGINT NOT NULL, period_end BIGINT NOT NULL, expires_at BIGINT NOT NULL, @@ -1195,6 +1196,21 @@ CREATE TABLE IF NOT EXISTS h5_subscriptions ( CONSTRAINT fk_h5_sub_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE IF NOT EXISTS h5_image_quota_ledger ( + id CHAR(36) PRIMARY KEY, + user_id CHAR(36) NOT NULL, + delta INT NOT NULL, + balance_after INT NULL, + reason ENUM('admin_grant', 'admin_adjust', 'consume', 'period_reset', 'plan_change') NOT NULL, + ref_id VARCHAR(128) NULL, + operator_id CHAR(36) NULL, + note VARCHAR(512) NULL, + created_at BIGINT NOT NULL, + KEY idx_h5_image_quota_user_created (user_id, created_at), + KEY idx_h5_image_quota_ref (user_id, ref_id), + CONSTRAINT fk_h5_image_quota_user FOREIGN KEY (user_id) REFERENCES h5_users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS h5_blocked_words ( id CHAR(36) PRIMARY KEY, word VARCHAR(200) NOT NULL, diff --git a/server.mjs b/server.mjs index 79b8184..86d7f1c 100644 --- a/server.mjs +++ b/server.mjs @@ -428,6 +428,7 @@ async function bootstrapUserAuth() { apiSecret: API_SECRET, userAuth, sessionAccess, + subscriptionService, mindSpaceRuntimeAdapter, mindSpaceAssets, resolveUserIdByDirKey, diff --git a/server/portal-agent-services-bootstrap.mjs b/server/portal-agent-services-bootstrap.mjs index 6071046..9296658 100644 --- a/server/portal-agent-services-bootstrap.mjs +++ b/server/portal-agent-services-bootstrap.mjs @@ -26,6 +26,7 @@ export async function bootstrapPortalAgentServices({ apiSecret, userAuth, sessionAccess, + subscriptionService = null, mindSpaceRuntimeAdapter, mindSpaceAssets, resolveUserIdByDirKey, @@ -184,6 +185,7 @@ export async function bootstrapPortalAgentServices({ assetService: mindSpaceAssets, imageMakeClient, imageReviewService, + subscriptionService, env, logger, }); diff --git a/src/components/BalanceRing.tsx b/src/components/BalanceRing.tsx index f6f7f97..09ff773 100644 --- a/src/components/BalanceRing.tsx +++ b/src/components/BalanceRing.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState, type CSSProperties } from 'react'; +import type { ActiveSubscription } from '../types'; const RING_R = 16; const CIRC = 2 * Math.PI * RING_R; @@ -28,13 +29,15 @@ function formatCallsApprox(tokens: number) { return `约 ${calls} 次`; } -type ActiveSubscription = { - planType: string; - periodTokensLimit: number; - periodTokensUsed: number; - periodEnd: number; - overageRate: number; -}; +function formatImageQuota(sub: ActiveSubscription) { + const limit = sub.periodImagesLimit ?? 0; + const bonus = sub.periodImagesBonus ?? 0; + const used = sub.periodImagesUsed ?? 0; + if (limit === 0) return { unlimited: true, remaining: null, total: null, used }; + const total = limit + bonus; + const remaining = Math.max(0, total - used); + return { unlimited: false, remaining, total, used }; +} type BalanceRingProps = { balanceCents: number; @@ -76,6 +79,18 @@ export function BalanceRing({ const subLow = hasSub && !subUnlimited && subPct <= 15; const subEmpty = hasSub && !subUnlimited && subRemaining <= 0; + const imageQuota = subscription ? formatImageQuota(subscription) : null; + const showImageQuota = Boolean( + subscription && (imageQuota?.unlimited || (imageQuota?.total ?? 0) > 0 || (subscription.periodImagesBonus ?? 0) > 0), + ); + const imageLow = Boolean( + imageQuota && !imageQuota.unlimited && imageQuota.remaining !== null && imageQuota.total + && imageQuota.remaining / imageQuota.total <= 0.15, + ); + const imageEmpty = Boolean( + imageQuota && !imageQuota.unlimited && imageQuota.remaining === 0, + ); + // Balance mode (used when no active subscription or overage). const total = Math.max(totalCreditCents ?? balanceCents, balanceCents, 0); const spent = Math.max(0, total - balanceCents); @@ -329,6 +344,59 @@ export function BalanceRing({

按人民币结算,每次对话按实际用量扣费

+ {showImageQuota && imageQuota ? ( +
+
图片生成
+ {imageQuota.unlimited ? ( +
+ 本月额度 + 不限量 +
+ ) : ( + <> +
+ + + {imageQuota.remaining ?? 0} 张 +
+
+ + + {imageQuota.used} 张 +
+ {(subscription?.periodImagesBonus ?? 0) > 0 ? ( +

+ 含套餐 {subscription!.periodImagesLimit ?? 0} 张 + 额外充值 {subscription!.periodImagesBonus} 张 +

+ ) : null} +