feat(M03-B): 完成装修广告与媒体管理

This commit is contained in:
Codex
2026-06-18 15:06:16 +08:00
parent 7e9b53487b
commit f74624296d
19 changed files with 1241 additions and 13 deletions
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import sharp from 'sharp';
import { MediaStorage, MediaValidationError } from '../dist/content/media-storage.js';
import { ContentError, ContentRepository } from '../dist/content/content-repository.js';
const root = await mkdtemp(join(tmpdir(), 'qipai-media-'));
try {
const png = await sharp({
create: { width: 2400, height: 1200, channels: 3, background: '#336699' }
}).png().toBuffer();
const storage = new MediaStorage(root);
const image = await storage.storeImage({
tenantId: '7', storeId: '11', originalName: 'banner.png',
contentType: 'image/png', body: png
});
assert.equal(image.mimeType, 'image/webp');
assert.ok(image.width <= 1920);
assert.match(image.storagePath, /^tenants\/7\/stores\/11\/.+\.webp$/);
assert.ok((await readFile(join(root, ...image.storagePath.split('/')))).length > 0);
await assert.rejects(
() => storage.storeImage({
tenantId: '7', originalName: 'bad.txt', contentType: 'text/plain',
body: Buffer.from('not an image')
}),
(error) => error instanceof MediaValidationError && error.code === 'IMAGE_TYPE_INVALID'
);
} finally {
await rm(root, { recursive: true, force: true });
}
const storeActor = {
tenantId: '7', userId: '21',
access: {
roles: ['STORE_ADMIN'], capabilities: ['store.operation.write'], storeIds: ['11']
},
traceId: 'trace', ip: '127.0.0.1', userAgent: 'test'
};
const repository = new ContentRepository({ async execute() { return [[], []]; } });
await assert.rejects(
() => repository.saveAdvertisement(storeActor, {
scopeType: 'PLATFORM', title: 'x', imageAssetId: '1',
targetType: 'NONE', targetValue: '', status: 'DRAFT', sortOrder: 0
}),
(error) => error instanceof ContentError && error.code === 'PLATFORM_AD_FORBIDDEN'
);
console.log('PASS: M03-B image compression, tenant paths and advertisement scope validation are present.');
+14 -1
View File
@@ -30,6 +30,9 @@ const userManagementVerifySql = read('database/migrations/2026061806_m02d_user_m
const storeRoomUpSql = read('database/migrations/2026061807_m03a_store_room_domain.up.sql');
const storeRoomDownSql = read('database/migrations/2026061807_m03a_store_room_domain.down.sql');
const storeRoomVerifySql = read('database/migrations/2026061807_m03a_store_room_domain.verify.sql');
const contentUpSql = read('database/migrations/2026061808_m03b_decoration_ads_media.up.sql');
const contentDownSql = read('database/migrations/2026061808_m03b_decoration_ads_media.down.sql');
const contentVerifySql = read('database/migrations/2026061808_m03b_decoration_ads_media.verify.sql');
const coreTables = [
'qipai_schema_migrations',
@@ -147,5 +150,15 @@ assert.match(storeRoomUpSql, /configuration_status VARCHAR/);
assert.match(storeRoomUpSql, /operational_status VARCHAR/);
assert.match(storeRoomUpSql, /weekday_price_cents INT UNSIGNED/);
assert.match(storeRoomUpSql, /CHECK \(ends_at > starts_at\)/);
for (const table of [
'qipai_media_assets', 'qipai_store_decorations', 'qipai_advertisements'
]) {
assert.match(contentUpSql, new RegExp(`CREATE TABLE IF NOT EXISTS ${table}`));
assert.match(contentDownSql, new RegExp(`DROP TABLE IF EXISTS ${table}`));
assert.match(contentVerifySql, new RegExp(`'${table}'`));
}
assert.match(contentUpSql, /schema_version INT UNSIGNED/);
assert.match(contentUpSql, /scope_type VARCHAR/);
assert.match(contentUpSql, /checksum_sha256 CHAR\(64\)/);
console.log('PASS: M01-B through M03-A migration contracts are present.');
console.log('PASS: M01-B through M03-B migration contracts are present.');
+2 -1
View File
@@ -18,7 +18,8 @@ assert.match(plan.file, /2026061803_m02a_tenant_apps\.up\.sql/);
assert.match(plan.file, /2026061804_m02b_wechat_auth\.up\.sql/);
assert.match(plan.file, /2026061805_m02c_rbac\.up\.sql/);
assert.match(plan.file, /2026061806_m02d_user_management\.up\.sql/);
assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql$/);
assert.match(plan.file, /2026061807_m03a_store_room_domain\.up\.sql/);
assert.match(plan.file, /2026061808_m03b_decoration_ads_media\.up\.sql$/);
assert.match(plan.checksum, /^[a-f0-9]{64}$/);
assert.ok(plan.statements.length >= 11);
@@ -14,6 +14,7 @@ import { AuthRepository } from '../dist/auth/auth-repository.js';
import { RbacRepository } from '../dist/auth/rbac-repository.js';
import { UserManagementRepository } from '../dist/auth/user-management-repository.js';
import { StoreRoomRepository, StoreRoomError } from '../dist/stores/store-room-repository.js';
import { ContentRepository, ContentError } from '../dist/content/content-repository.js';
import {
executeMigrationPlan,
loadMigrationPlan,
@@ -21,11 +22,13 @@ import {
} from '../dist/db/migration-runner.js';
const expectedTables = [
'qipai_advertisements',
'qipai_async_tasks',
'qipai_audit_logs',
'qipai_auth_sessions',
'qipai_devices',
'qipai_legacy_table_mappings',
'qipai_media_assets',
'qipai_members',
'qipai_orders',
'qipai_outbox_events',
@@ -39,6 +42,7 @@ const expectedTables = [
'qipai_rooms',
'qipai_schema_migrations',
'qipai_store_business_hours',
'qipai_store_decorations',
'qipai_stores',
'qipai_tenant_apps',
'qipai_tenant_configs',
@@ -68,10 +72,10 @@ 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']
'2026061805', '2026061806', '2026061807', '2026061808']
);
return rows;
}
@@ -389,6 +393,70 @@ async function assertStoreRoomDomain(pool, context) {
]);
}
async function assertContentManagement(pool, context) {
const [adminRows] = await pool.query(
`SELECT u.id FROM qipai_users u
INNER JOIN qipai_user_roles ur ON ur.tenant_id = u.tenant_id AND ur.user_id = u.id
INNER JOIN qipai_roles r ON r.id = ur.role_id AND r.tenant_id = ur.tenant_id
WHERE u.tenant_id = ? AND r.code = 'TENANT_ADMIN' LIMIT 1`,
[context.tenantId]
);
const [storeRows] = await pool.query(
`SELECT id FROM qipai_stores WHERE tenant_id = ? AND name = 'M03A Store' LIMIT 1`,
[context.tenantId]
);
const adminId = String(adminRows[0].id);
const storeId = String(storeRows[0].id);
const rbac = new RbacRepository(pool);
const access = await rbac.getAccessProfile(context.tenantId, adminId);
const actor = {
tenantId: context.tenantId, userId: adminId, access,
traceId: 'm03b-live-test', ip: '127.0.0.1', userAgent: 'M03-B live test'
};
const repository = new ContentRepository(pool);
const asset = await repository.registerAsset(actor, storeId, {
storagePath: `tenants/${context.tenantId}/stores/${storeId}/sanitized.webp`,
publicUrl: `https://api.txyundm.cn/uploads/tenants/${context.tenantId}/stores/${storeId}/sanitized.webp`,
mimeType: 'image/webp', byteSize: 1024, width: 1200, height: 600,
checksumSha256: 'a'.repeat(64)
});
const draft1 = await repository.saveDecoration(actor, {
storeId, templateCode: 'classic', schemaVersion: 1,
content: { components: [{ type: 'HERO', props: { assetId: asset.assetId } }] }
});
const draft2 = await repository.saveDecoration(actor, {
storeId, templateCode: 'modern', schemaVersion: 1,
content: { components: [{ type: 'ROOM_LIST', props: {} }] }
});
assert.equal(draft1.version, 1);
assert.equal(draft2.version, 2);
await repository.publishDecoration(actor, draft1.decorationId, storeId);
await repository.publishDecoration(actor, draft2.decorationId, storeId);
const [decorationRows] = await pool.query(
`SELECT version, status FROM qipai_store_decorations
WHERE tenant_id = ? AND store_id = ? ORDER BY version`,
[context.tenantId, storeId]
);
assert.deepEqual(decorationRows, [
{ version: 1, status: 'ARCHIVED' },
{ version: 2, status: 'PUBLISHED' }
]);
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 assert.rejects(
() => repository.saveAdvertisement(actor, {
scopeType: 'PLATFORM', title: 'forbidden', imageAssetId: asset.assetId,
targetType: 'NONE', targetValue: '', status: 'DRAFT', sortOrder: 0
}),
(error) => error instanceof ContentError && error.code === 'PLATFORM_AD_FORBIDDEN'
);
}
const config = loadConfig();
assert.equal(config.mysql.passwordConfigured, true, 'Live migration test requires a temporary password.');
assert.match(
@@ -419,13 +487,15 @@ try {
{ version: '2026061804', name: 'm02b_wechat_auth' },
{ version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' },
{ version: '2026061807', name: 'm03a_store_room_domain' }
{ version: '2026061807', name: 'm03a_store_room_domain' },
{ version: '2026061808', name: 'm03b_decoration_ads_media' }
]);
await assertTaskDurability(pool);
const loginContext = await assertPlatformTenantIsolation(pool);
await assertRevocableAuthSession(pool, loginContext);
await assertUserManagement(pool, loginContext);
await assertStoreRoomDomain(pool, loginContext);
await assertContentManagement(pool, loginContext);
await assertLegacyCompatibility(pool);
console.log('PASS: first up, verify, tenant isolation and revocable auth checks completed.');
@@ -444,7 +514,8 @@ try {
{ version: '2026061804', name: 'm02b_wechat_auth' },
{ version: '2026061805', name: 'm02c_rbac' },
{ version: '2026061806', name: 'm02d_user_management' },
{ version: '2026061807', name: 'm03a_store_room_domain' }
{ version: '2026061807', name: 'm03a_store_room_domain' },
{ version: '2026061808', name: 'm03b_decoration_ads_media' }
]);
await assertLegacyCompatibility(pool);
console.log('PASS: second up and verify restored the schema.');
@@ -485,7 +556,11 @@ try {
'room category and integer-cent pricing',
'configuration and operational status separation',
'room disabled period',
'cross-store management rejection'
'cross-store management rejection',
'tenant-isolated media asset',
'versioned decoration publish and archive',
'store advertisement delivery scope',
'platform advertisement rejection'
]
}, null, 2));
} finally {