feat(M08-D): 补广告与装修管理
This commit is contained in:
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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