feat(M03-B): 完成装修广告与媒体管理
This commit is contained in:
@@ -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.');
|
||||
@@ -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.');
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user