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
+66
View File
@@ -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']]);
});