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(