feat(billing): enforce image generation quota with admin API and user display
Memind CI / Test, build, and release guards (push) Failing after 3s
Memind CI / Test, build, and release guards (push) Failing after 3s
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 <cursoragent@cursor.com>
This commit is contained in:
@@ -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`。
|
||||
|
||||
+11
-2
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+255
-13
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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']]);
|
||||
});
|
||||
|
||||
@@ -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 共享模块。
|
||||
+16
@@ -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,
|
||||
|
||||
@@ -428,6 +428,7 @@ async function bootstrapUserAuth() {
|
||||
apiSecret: API_SECRET,
|
||||
userAuth,
|
||||
sessionAccess,
|
||||
subscriptionService,
|
||||
mindSpaceRuntimeAdapter,
|
||||
mindSpaceAssets,
|
||||
resolveUserIdByDirKey,
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
<p className="balance-popover-hint">按人民币结算,每次对话按实际用量扣费</p>
|
||||
</div>
|
||||
|
||||
{showImageQuota && imageQuota ? (
|
||||
<div className="balance-popover-section">
|
||||
<h5>图片生成</h5>
|
||||
{imageQuota.unlimited ? (
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label balance-popover-label-muted">本月额度</span>
|
||||
<span>不限量</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label">
|
||||
<span className="balance-dot balance-dot-remain" aria-hidden="true" />
|
||||
剩余张数
|
||||
</span>
|
||||
<strong>{imageQuota.remaining ?? 0} 张</strong>
|
||||
</div>
|
||||
<div className="balance-popover-row">
|
||||
<span className="balance-popover-label">
|
||||
<span className="balance-dot balance-dot-spent" aria-hidden="true" />
|
||||
已使用
|
||||
</span>
|
||||
<strong>{imageQuota.used} 张</strong>
|
||||
</div>
|
||||
{(subscription?.periodImagesBonus ?? 0) > 0 ? (
|
||||
<p className="balance-popover-hint">
|
||||
含套餐 {subscription!.periodImagesLimit ?? 0} 张 + 额外充值 {subscription!.periodImagesBonus} 张
|
||||
</p>
|
||||
) : null}
|
||||
<div className="balance-popover-bar" aria-hidden="true">
|
||||
<div
|
||||
className="balance-popover-bar-spent"
|
||||
style={{
|
||||
width: `${imageQuota.total ? (imageQuota.used / imageQuota.total) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="balance-popover-bar-remain"
|
||||
style={{
|
||||
width: `${imageQuota.total ? ((imageQuota.remaining ?? 0) / imageQuota.total) * 100 : 0}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(imageEmpty || imageLow) && (
|
||||
<p className="balance-popover-warning">
|
||||
{imageEmpty ? '本月图片额度已用完,暂无法 AI 生图' : '图片额度即将耗尽'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{onSubscribe && (
|
||||
<button
|
||||
|
||||
@@ -304,6 +304,9 @@ export type ActiveSubscription = {
|
||||
status: 'active' | 'expired' | 'cancelled';
|
||||
periodTokensLimit: number;
|
||||
periodTokensUsed: number;
|
||||
periodImagesLimit: number;
|
||||
periodImagesUsed: number;
|
||||
periodImagesBonus?: number;
|
||||
periodStart: number;
|
||||
periodEnd: number;
|
||||
expiresAt: number;
|
||||
@@ -317,6 +320,7 @@ export type PlanDefinition = {
|
||||
priceCents: number;
|
||||
periodDays: number;
|
||||
periodTokens: number;
|
||||
periodImages?: number;
|
||||
modelTier: string;
|
||||
overageRate: number;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user