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');
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user