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
+160 -6
View File
@@ -31,11 +31,27 @@ export interface AdvertisementInput {
interface IdRow extends RowDataPacket { id: string }
interface VersionRow extends RowDataPacket { nextVersion: number }
interface ContentRow extends RowDataPacket {
id: string; scopeType: string; storeId: string | null; title: string; imageUrl: string;
id: string; scopeType: string; storeId: string | null; title: string;
imageAssetId: string; imageUrl: string;
targetType: string; targetValue: string; startsAt: Date | null; endsAt: Date | null;
status: string; sortOrder: number;
}
interface AssetRow extends RowDataPacket {
id: string; storeId: string | null; url: string; mimeType: string; byteSize: number;
width: number; height: number; createdAt: Date;
}
interface DecorationRow extends RowDataPacket {
id: string; storeId: string; templateCode: string; schemaVersion: number;
content: string | Record<string, unknown>; status: string; version: number;
publishedAt: Date | null; createdAt: Date;
}
interface AdvertisementScopeRow extends RowDataPacket {
scopeType: AdvertisementInput['scopeType']; storeId: string | null;
}
export class ContentError extends Error {
constructor(public readonly code: string) { super(code); }
}
@@ -44,7 +60,7 @@ export class ContentRepository {
constructor(private readonly pool: MySqlPool) {}
async registerAsset(actor: ManagementActor, storeId: string | undefined, image: StoredImage) {
if (storeId) this.assertStoreScope(actor, storeId);
this.assertAssetWriteScope(actor, storeId);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_media_assets
@@ -61,6 +77,24 @@ export class ContentRepository {
});
}
async listAssets(actor: ManagementActor, storeId?: string) {
if (storeId) this.assertStoreScope(actor, storeId);
const scope = this.assetReadScope(actor, storeId);
const [rows] = await this.pool.execute<AssetRow[]>(
`SELECT id, store_id AS storeId, public_url AS url, mime_type AS mimeType,
byte_size AS byteSize, width, height, created_at AS createdAt
FROM qipai_media_assets
WHERE tenant_id = ? AND deleted_at IS NULL AND ${scope.sql}
ORDER BY id DESC LIMIT 200`,
[actor.tenantId, ...scope.params]
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: row.storeId === null ? null : String(row.storeId)
}));
}
async saveDecoration(actor: ManagementActor, input: DecorationInput) {
this.assertStoreScope(actor, input.storeId);
return this.transaction(async (connection) => {
@@ -85,6 +119,25 @@ export class ContentRepository {
});
}
async listDecorations(actor: ManagementActor, storeId: string) {
this.assertStoreScope(actor, storeId);
const [rows] = await this.pool.execute<DecorationRow[]>(
`SELECT id, store_id AS storeId, template_code AS templateCode,
schema_version AS schemaVersion, content_json AS content,
status, version, published_at AS publishedAt, created_at AS createdAt
FROM qipai_store_decorations
WHERE tenant_id = ? AND store_id = ? AND deleted_at IS NULL
ORDER BY version DESC LIMIT 100`,
[actor.tenantId, storeId]
);
return rows.map((row) => ({
...row,
id: String(row.id),
storeId: String(row.storeId),
content: parseJson(row.content)
}));
}
async publishDecoration(actor: ManagementActor, decorationId: string, storeId: string) {
this.assertStoreScope(actor, storeId);
return this.transaction(async (connection) => {
@@ -114,7 +167,8 @@ export class ContentRepository {
const scope = this.adScope(actor);
const [rows] = await this.pool.execute<ContentRow[]>(
`SELECT a.id, a.scope_type AS scopeType, a.store_id AS storeId, a.title,
m.public_url AS imageUrl, a.target_type AS targetType,
a.image_asset_id AS imageAssetId, m.public_url AS imageUrl,
a.target_type AS targetType,
a.target_value AS targetValue, a.starts_at AS startsAt, a.ends_at AS endsAt,
a.status, a.sort_order AS sortOrder
FROM qipai_advertisements a
@@ -123,11 +177,17 @@ export class ContentRepository {
ORDER BY a.sort_order, a.id DESC`,
[actor.tenantId, ...scope.params]
);
return rows.map((row) => ({ ...row, id: String(row.id), storeId: row.storeId && String(row.storeId) }));
return rows.map((row) => ({
...row,
id: String(row.id),
imageAssetId: String(row.imageAssetId),
storeId: row.storeId === null ? null : String(row.storeId)
}));
}
async saveAdvertisement(actor: ManagementActor, input: AdvertisementInput) {
this.assertAdScope(actor, input);
const assetScope = this.adAssetScope(input);
return this.transaction(async (connection) => {
const [result] = await connection.execute<ResultSetHeader>(
`INSERT INTO qipai_advertisements
@@ -135,10 +195,12 @@ export class ContentRepository {
target_value, starts_at, ends_at, status, sort_order, created_by)
SELECT ?, ?, ?, ?, m.id, ?, ?, ?, ?, ?, ?, ?
FROM qipai_media_assets m
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL`,
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL
AND ${assetScope.sql}`,
[actor.tenantId, input.scopeType, input.storeId ?? null, input.title,
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
input.status, input.sortOrder, actor.userId, actor.tenantId, input.imageAssetId]
input.status, input.sortOrder, actor.userId, actor.tenantId, input.imageAssetId,
...assetScope.params]
);
if (result.affectedRows !== 1) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
const advertisementId = String(result.insertId);
@@ -147,6 +209,45 @@ export class ContentRepository {
});
}
async updateAdvertisement(
actor: ManagementActor, advertisementId: string, input: AdvertisementInput
) {
this.assertAdScope(actor, input);
const assetScope = this.adAssetScope(input);
return this.transaction(async (connection) => {
const [advertisements] = await connection.execute<AdvertisementScopeRow[]>(
`SELECT scope_type AS scopeType, store_id AS storeId
FROM qipai_advertisements
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL FOR UPDATE`,
[actor.tenantId, advertisementId]
);
const existing = advertisements[0];
if (!existing) throw new ContentError('ADVERTISEMENT_NOT_FOUND');
this.assertExistingAdScope(actor, existing);
const [assets] = await connection.execute<IdRow[]>(
`SELECT m.id FROM qipai_media_assets m
WHERE m.tenant_id = ? AND m.id = ? AND m.deleted_at IS NULL
AND ${assetScope.sql}`,
[actor.tenantId, input.imageAssetId, ...assetScope.params]
);
if (!assets[0]) throw new ContentError('IMAGE_ASSET_NOT_FOUND');
await connection.execute(
`UPDATE qipai_advertisements
SET scope_type = ?, store_id = ?, title = ?, image_asset_id = ?,
target_type = ?, target_value = ?, starts_at = ?, ends_at = ?,
status = ?, sort_order = ?
WHERE tenant_id = ? AND id = ? AND deleted_at IS NULL`,
[input.scopeType, input.storeId ?? null, input.title, input.imageAssetId,
input.targetType, input.targetValue, input.startsAt ?? null, input.endsAt ?? null,
input.status, input.sortOrder, actor.tenantId, advertisementId]
);
await this.audit(connection, actor, 'ADVERTISEMENT_UPDATED', 'ADVERTISEMENT', advertisementId);
return { advertisementId };
});
}
private assertAdScope(actor: ManagementActor, input: AdvertisementInput) {
const tenantManager = actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN');
@@ -160,6 +261,26 @@ export class ContentRepository {
}
}
private assertExistingAdScope(actor: ManagementActor, advertisement: AdvertisementScopeRow) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
if (advertisement.scopeType !== 'STORE' || !advertisement.storeId
|| !actor.access.storeIds.includes(String(advertisement.storeId))) {
throw new ContentError('ADVERTISEMENT_SCOPE_FORBIDDEN');
}
}
private assertAssetWriteScope(actor: ManagementActor, storeId?: string) {
if (storeId) {
this.assertStoreScope(actor, storeId);
return;
}
if (!actor.access.capabilities.includes('tenant.manage')
&& !actor.access.roles.includes('PLATFORM_ADMIN')) {
throw new ContentError('GLOBAL_ASSET_FORBIDDEN');
}
}
private assertStoreScope(actor: ManagementActor, storeId: string) {
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) return;
@@ -180,6 +301,30 @@ export class ContentRepository {
};
}
private assetReadScope(actor: ManagementActor, storeId?: string) {
if (storeId) {
return { sql: '(store_id IS NULL OR store_id = ?)', params: [storeId] };
}
if (actor.access.capabilities.includes('tenant.manage')
|| actor.access.roles.includes('PLATFORM_ADMIN')) {
return { sql: '1 = 1', params: [] as string[] };
}
if (actor.access.storeIds.length === 0) {
return { sql: 'store_id IS NULL', params: [] as string[] };
}
return {
sql: `(store_id IS NULL OR store_id IN (${actor.access.storeIds.map(() => '?').join(',')}))`,
params: actor.access.storeIds
};
}
private adAssetScope(input: AdvertisementInput) {
if (input.scopeType === 'STORE' && input.storeId) {
return { sql: '(m.store_id IS NULL OR m.store_id = ?)', params: [input.storeId] };
}
return { sql: 'm.store_id IS NULL', params: [] as string[] };
}
private async lockStore(connection: PoolConnection, tenantId: string, storeId: string) {
const [rows] = await connection.execute<IdRow[]>(
`SELECT id FROM qipai_stores
@@ -218,3 +363,12 @@ export class ContentRepository {
}
}
}
function parseJson(value: string | Record<string, unknown>) {
if (typeof value !== 'string') return value;
try {
return JSON.parse(value) as Record<string, unknown>;
} catch {
throw new ContentError('INVALID_DECORATION_CONTENT');
}
}
+7 -3
View File
@@ -50,7 +50,8 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062627_m08b_cleaning_collaboration.up.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.up.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.up.sql',
'database/migrations/2026081001_m08c_staff_management_access.up.sql'
'database/migrations/2026081001_m08c_staff_management_access.up.sql',
'database/migrations/2026081002_m08d_content_asset_scope.up.sql'
],
verify: [
'database/migrations/2026061601_m01b_core_schema.verify.sql',
@@ -82,9 +83,11 @@ const migrationFiles: Record<MigrationDirection, readonly string[]> = {
'database/migrations/2026062627_m08b_cleaning_collaboration.verify.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.verify.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.verify.sql',
'database/migrations/2026081001_m08c_staff_management_access.verify.sql'
'database/migrations/2026081001_m08c_staff_management_access.verify.sql',
'database/migrations/2026081002_m08d_content_asset_scope.verify.sql'
],
down: [
'database/migrations/2026081002_m08d_content_asset_scope.down.sql',
'database/migrations/2026081001_m08c_staff_management_access.down.sql',
'database/migrations/2026062729_m08b_cleaning_transfer_state.down.sql',
'database/migrations/2026062728_m08b_cleaning_payouts.down.sql',
@@ -259,7 +262,8 @@ export async function executeMigrationPlan(
2, 9, 5, 2, 1,
1, 7, 5, 1,
4, 1, 1, 1,
2, 1, 1
2, 1, 1,
1, 1, 1
][index] ?? 1;
if (!Array.isArray(result) || result.length < minimumRows) {
throw new Error(
+39 -2
View File
@@ -9,6 +9,9 @@ import { MediaStorage, MediaValidationError } from '../content/media-storage.js'
const idSchema = z.object({ id: z.string().regex(/^[1-9]\d{0,19}$/) });
const storeQuerySchema = z.object({ storeId: z.string().regex(/^[1-9]\d{0,19}$/) });
const optionalStoreQuerySchema = z.object({
storeId: z.string().regex(/^[1-9]\d{0,19}$/).optional()
});
const componentSchema = z.object({
type: z.enum(['HERO', 'NOTICE', 'GALLERY', 'CONTACT', 'ROOM_LIST']),
props: z.record(z.unknown())
@@ -34,8 +37,9 @@ const adSchema = z.object({
export interface ContentRouteOptions {
repository: Pick<ContentRepository,
'registerAsset' | 'saveDecoration' | 'publishDecoration'
| 'listAdvertisements' | 'saveAdvertisement'>;
'registerAsset' | 'listAssets' | 'saveDecoration' | 'listDecorations'
| 'publishDecoration' | 'listAdvertisements' | 'saveAdvertisement'
| 'updateAdvertisement'>;
mediaStorage: MediaStorage;
authRepository: Pick<AuthRepository, 'validateSession'>;
accessControl: { getAccessProfile(tenantId: string, userId: string): Promise<AccessProfile> };
@@ -50,6 +54,16 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
(_request, body, done) => done(null, body)
);
}
app.get('/admin-api/media/images', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const query = optionalStoreQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listAssets(actor, query.data.storeId),
traceId: request.traceId
}));
});
app.post('/admin-api/media/images', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
if (!actor) return;
@@ -72,6 +86,16 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
});
});
});
app.get('/admin-api/decorations', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const query = storeQuerySchema.safeParse(request.query);
if (!actor || !query.success) return actor ? invalid(reply, request.traceId) : undefined;
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.listDecorations(actor, query.data.storeId),
traceId: request.traceId
}));
});
app.post('/admin-api/decorations', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const body = decorationSchema.safeParse(request.body);
@@ -109,6 +133,19 @@ export async function registerContentRoutes(app: FastifyInstance, options: Conte
traceId: request.traceId
}));
});
app.put('/admin-api/advertisements/:id', async (request, reply) => {
const actor = await requireContentManager(request, reply, options);
const params = idSchema.safeParse(request.params);
const body = adSchema.safeParse(request.body);
if (!actor || !params.success || !body.success) {
return actor ? invalid(reply, request.traceId) : undefined;
}
return handle(reply, request.traceId, async () => ({
code: 0,
data: await options.repository.updateAdvertisement(actor, params.data.id, body.data),
traceId: request.traceId
}));
});
}
async function requireContentManager(
+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.');