feat(M08-D): 补广告与装修管理
This commit is contained in:
@@ -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.');
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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.');
|
||||
|
||||
Reference in New Issue
Block a user