feat(billing): enforce image generation quota with admin API and user display
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:
john
2026-08-03 14:58:38 +08:00
parent 36d8e74784
commit 946d8756c8
14 changed files with 639 additions and 23 deletions
+31
View File
@@ -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,