feat(M08-D): 补广告与装修管理

This commit is contained in:
Codex
2026-08-10 12:57:05 +08:00
parent ec4219ae31
commit c1c02421a1
17 changed files with 913 additions and 21 deletions
+64 -1
View File
@@ -3,6 +3,8 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import { buildApp } from '../dist/app.js';
import { signAccessToken } from '../dist/auth/jwt.js';
import { MediaStorage, MediaValidationError } from '../dist/content/media-storage.js';
import { ContentError, ContentRepository } from '../dist/content/content-repository.js';
@@ -47,4 +49,65 @@ await assert.rejects(
(error) => error instanceof ContentError && error.code === 'PLATFORM_AD_FORBIDDEN'
);
console.log('PASS: M03-B image compression, tenant paths and advertisement scope validation are present.');
await assert.rejects(
() => repository.registerAsset(storeActor, undefined, {
storagePath: 'asset.webp', publicUrl: '/asset.webp', mimeType: 'image/webp',
byteSize: 100, width: 10, height: 10, checksumSha256: '0'.repeat(64)
}),
(error) => error instanceof ContentError && error.code === 'GLOBAL_ASSET_FORBIDDEN'
);
const secret = 'test-only-content-management-secret-32';
const token = signAccessToken({
sub: '21', sid: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tid: '7', aid: '9', rv: 1
}, secret, 900);
let listedAssetStoreId;
let listedDecorationStoreId;
let updatedAdvertisement;
const app = await buildApp({
content: {
jwtSecret: secret,
authRepository: {
async validateSession() {
return {
id: '5c4d3af8-c63c-4edb-bf95-b84127bb3f6e', tenantId: '7', platformAppId: '9',
expiresAt: new Date(Date.now() + 60000),
user: { id: '21', tenantId: '7', userType: 'STAFF', status: 'ACTIVE', roleVersion: 1,
nickname: '', avatarUrl: '', phone: '' }
};
}
},
accessControl: { async getAccessProfile() { return storeActor.access; } },
mediaStorage: { async storeImage() { throw new Error('not used'); } },
repository: {
async listAssets(_actor, storeId) { listedAssetStoreId = storeId; return [{ id: '31' }]; },
async listDecorations(_actor, storeId) { listedDecorationStoreId = storeId; return [{ id: '41' }]; },
async updateAdvertisement(_actor, id, input) { updatedAdvertisement = { id, input }; return { advertisementId: id }; },
async listAdvertisements() { return []; },
async registerAsset() {}, async saveDecoration() {}, async publishDecoration() {},
async saveAdvertisement() {}
}
}
});
const authorization = { authorization: `Bearer ${token}` };
const assets = await app.inject({ method: 'GET', url: '/admin-api/media/images?storeId=11', headers: authorization });
assert.equal(assets.statusCode, 200);
assert.equal(listedAssetStoreId, '11');
const decorations = await app.inject({ method: 'GET', url: '/admin-api/decorations?storeId=11', headers: authorization });
assert.equal(decorations.statusCode, 200);
assert.equal(listedDecorationStoreId, '11');
const updated = await app.inject({
method: 'PUT', url: '/admin-api/advertisements/51', headers: authorization,
payload: {
scopeType: 'STORE', storeId: '11', title: '门店轮播', imageAssetId: '31',
targetType: 'PAGE', targetValue: '/pages/booking/index', status: 'ACTIVE', sortOrder: 10
}
});
assert.equal(updated.statusCode, 200);
assert.equal(updatedAdvertisement.id, '51');
assert.equal(updatedAdvertisement.input.storeId, '11');
const missingStore = await app.inject({ method: 'GET', url: '/admin-api/decorations', headers: authorization });
assert.equal(missingStore.statusCode, 400);
await app.close();
console.log('PASS: content media, decoration and advertisement management contracts are scoped and editable.');
+11 -1
View File
@@ -99,6 +99,9 @@ const cleaningTransferStateVerifySql = read('database/migrations/2026062729_m08b
const staffManagementUpSql = read('database/migrations/2026081001_m08c_staff_management_access.up.sql');
const staffManagementDownSql = read('database/migrations/2026081001_m08c_staff_management_access.down.sql');
const staffManagementVerifySql = read('database/migrations/2026081001_m08c_staff_management_access.verify.sql');
const contentAssetScopeUpSql = read('database/migrations/2026081002_m08d_content_asset_scope.up.sql');
const contentAssetScopeDownSql = read('database/migrations/2026081002_m08d_content_asset_scope.down.sql');
const contentAssetScopeVerifySql = read('database/migrations/2026081002_m08d_content_asset_scope.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -463,4 +466,11 @@ assert.match(staffManagementDownSql, /DELETE rp FROM qipai_role_permissions/);
assert.match(staffManagementVerifySql, /fully_granted_staff_roles/);
assert.match(staffManagementVerifySql, /HAVING COUNT\(DISTINCT p\.code\) = 2/);
console.log('PASS: M01-B through M08-C migration contracts are present.');
assert.match(contentAssetScopeUpSql, /scope_store_id BIGINT UNSIGNED/);
assert.match(contentAssetScopeUpSql, /uq_qipai_media_scope_checksum/);
assert.match(contentAssetScopeUpSql, /'2026081002'/);
assert.match(contentAssetScopeDownSql, /SET a\.image_asset_id = duplicate_group\.retained_id/);
assert.match(contentAssetScopeDownSql, /uq_qipai_media_tenant_checksum/);
assert.match(contentAssetScopeVerifySql, /generation_expression/);
console.log('PASS: M01-B through M08-D migration contracts are present.');
+3 -1
View File
@@ -40,13 +40,15 @@ assert.match(plan.file, /2026062626_m08b_cleaning_settlements\.up\.sql/);
assert.match(plan.file, /2026062627_m08b_cleaning_collaboration\.up\.sql/);
assert.match(plan.file, /2026062728_m08b_cleaning_payouts\.up\.sql/);
assert.match(plan.file, /2026062729_m08b_cleaning_transfer_state\.up\.sql/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql$/);
assert.match(plan.file, /2026081001_m08c_staff_management_access\.up\.sql/);
assert.match(plan.file, /2026081002_m08d_content_asset_scope\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
const verifyPlan = await loadMigrationPlan('verify');
assert.match(verifyPlan.statements[90], /^SELECT column_name/);
assert.match(verifyPlan.statements[91], /^SELECT index_name/);
assert.match(verifyPlan.file, /2026081002_m08d_content_asset_scope\.verify\.sql$/);
const calls = [];
const fakePool = {
@@ -129,13 +129,13 @@ async function readMigrationVersions(pool) {
const [rows] = await pool.query(
`SELECT version, name
FROM qipai_schema_migrations
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
WHERE version IN (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ORDER BY version`,
['2026061601', '2026061802', '2026061803', '2026061804',
'2026061805', '2026061806', '2026061807', '2026061808', '2026061809',
'2026061810', '2026061811', '2026062012', '2026062013', '2026062014',
'2026062015', '2026062216', '2026062217', '2026062218', '2026062219',
'2026062220']
'2026062220', '2026081002']
);
return rows;
}
@@ -1684,6 +1684,24 @@ async function assertContentManagement(pool, context) {
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
checksumSha256: 'a'.repeat(64)
});
const duplicateAsset = await repository.registerAsset(actor, storeId, {
storagePath: `tenants/${context.tenantId}/stores/${storeId}/duplicate.webp`,
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/duplicate.webp`,
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
checksumSha256: 'a'.repeat(64)
});
assert.equal(duplicateAsset.assetId, asset.assetId);
const globalAsset = await repository.registerAsset(actor, undefined, {
storagePath: `tenants/${context.tenantId}/global/sanitized.webp`,
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/global/sanitized.webp`,
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
checksumSha256: 'a'.repeat(64)
});
assert.notEqual(globalAsset.assetId, asset.assetId);
assert.deepEqual(
(await repository.listAssets(actor, storeId)).map((item) => item.id).sort(),
[asset.assetId, globalAsset.assetId].sort()
);
const draft1 = await repository.saveDecoration(actor, {
storeId, templateCode: 'classic', schemaVersion: 1,
content: { components: [{ type: 'HERO', props: { assetId: asset.assetId } }] }
@@ -1705,13 +1723,22 @@ async function assertContentManagement(pool, context) {
{ version: 1, status: 'ARCHIVED' },
{ version: 2, status: 'PUBLISHED' }
]);
assert.equal((await repository.listDecorations(actor, storeId)).length, 2);
const ad = await repository.saveAdvertisement(actor, {
scopeType: 'STORE', storeId, title: 'Store banner', imageAssetId: asset.assetId,
targetType: 'PAGE', targetValue: '/pages/index/index',
startsAt: null, endsAt: null, status: 'ACTIVE', sortOrder: 1
});
assert.match(ad.advertisementId, /^[1-9]\d*$/);
assert.equal((await repository.listAdvertisements(actor))[0].scopeType, 'STORE');
await repository.updateAdvertisement(actor, ad.advertisementId, {
scopeType: 'STORE', storeId, title: 'Updated store banner', imageAssetId: asset.assetId,
targetType: 'NONE', targetValue: '', startsAt: null, endsAt: null,
status: 'INACTIVE', sortOrder: 2
});
const savedAd = (await repository.listAdvertisements(actor))[0];
assert.equal(savedAd.scopeType, 'STORE');
assert.equal(savedAd.title, 'Updated store banner');
assert.equal(savedAd.imageAssetId, asset.assetId);
await assert.rejects(
() => repository.saveAdvertisement(actor, {
scopeType: 'PLATFORM', title: 'forbidden', imageAssetId: asset.assetId,
@@ -1998,7 +2025,8 @@ try {
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
@@ -2048,7 +2076,8 @@ try {
{ version: '2026062217', name: 'm05c_third_party' },
{ version: '2026062218', name: 'm05d_profit_sharing' },
{ version: '2026062219', name: 'm06b_device_topology' },
{ version: '2026062220', name: 'm06c_iot_messages' }
{ version: '2026062220', name: 'm06c_iot_messages' },
{ version: '2026081002', name: 'm08d_content_asset_scope' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');