feat(api): embed slots with ov replica and public feed
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,92 @@
|
||||
// services/api/src/embed/dto.ts —— 展示位管理端点入参 DTO(P4b Task 2)。
|
||||
// 校验约束对齐契约 EmbedSlotCreateRequest / EmbedSlotUpdateRequest /
|
||||
// EmbedSlotSettings:
|
||||
// · name 1..64(VarChar(64) 列宽);
|
||||
// · settings 整体提交语义:创建可整体缺省(各字段按缺省值落库,服务层
|
||||
// 归一),提供时字段齐全——嵌套 DTO 无 @IsOptional,缺字段 400;
|
||||
// · albumId [string,'null']:非 null 绑定/换绑(≤64,绑定存在性校验在
|
||||
// 服务层事务内),null 解绑。@IsOptional 对 undefined/null 均跳过校验
|
||||
// 且 null 值保留在 DTO 上——服务层以 `!== undefined` 区分「未提交」与
|
||||
// 「显式 null」(@ValidateIf 回调收到的是整个 DTO 对象而非属性值,
|
||||
// 不能用于按值判空);
|
||||
// · 更新请求 minProperties:1(空体 400)在服务层裁决(DTO 层无法表达,
|
||||
// 同相册 update 口径)。
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** 契约 EmbedSlotLang 枚举(auto=按相册 defaultLocale)。 */
|
||||
export const EMBED_SLOT_LANGS = ['auto', 'zh-CN', 'en'] as const;
|
||||
export type EmbedSlotLang = (typeof EMBED_SLOT_LANGS)[number];
|
||||
|
||||
/** 契约 EmbedSlotSettings 字段枚举与边界(intervalMs 3000..30000)。 */
|
||||
const EMBED_SLOT_POSITIONS = ['left', 'right'] as const;
|
||||
const EMBED_SLOT_SIZES = ['sm', 'md'] as const;
|
||||
export const EMBED_INTERVAL_MS_MIN = 3000;
|
||||
export const EMBED_INTERVAL_MS_MAX = 30000;
|
||||
|
||||
export class EmbedSlotSettingsDto {
|
||||
@IsIn(EMBED_SLOT_POSITIONS)
|
||||
position!: (typeof EMBED_SLOT_POSITIONS)[number];
|
||||
|
||||
@IsIn(EMBED_SLOT_SIZES)
|
||||
size!: (typeof EMBED_SLOT_SIZES)[number];
|
||||
|
||||
@IsInt()
|
||||
@Min(EMBED_INTERVAL_MS_MIN)
|
||||
@Max(EMBED_INTERVAL_MS_MAX)
|
||||
intervalMs!: number;
|
||||
|
||||
@IsIn(EMBED_SLOT_LANGS)
|
||||
lang!: EmbedSlotLang;
|
||||
|
||||
@IsBoolean()
|
||||
collapsedDefault!: boolean;
|
||||
}
|
||||
|
||||
export class EmbedSlotCreateRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
name!: string;
|
||||
|
||||
/** 缺省整体落库默认值;提供时字段齐全(嵌套必填)。null 视同缺省(服务层)。 */
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => EmbedSlotSettingsDto)
|
||||
settings?: EmbedSlotSettingsDto | null;
|
||||
}
|
||||
|
||||
export class EmbedSlotUpdateRequestDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
/** 整体替换;null 视同未提交(服务层以 !== undefined 判定提交与否)。 */
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => EmbedSlotSettingsDto)
|
||||
settings?: EmbedSlotSettingsDto | null;
|
||||
|
||||
/** 非字符串给值时拒绝(null 走 @IsOptional 放行 = 解绑语义)。 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
albumId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// services/api/src/embed/embed.controller.ts —— /admin/embed-slots 端点
|
||||
// (P4b Task 2,仅 cn 入口装配)。方法名与契约 operationId 一一对应
|
||||
// (listEmbedSlots/createEmbedSlot/updateEmbedSlot,tag [embed-slots])。
|
||||
// 控制器级 SessionGuard:三端点全需认证(契约 bearerAuth);admin 判定在
|
||||
// 服务层(AuthUser.role,403 AUTH_FORBIDDEN——同 admin-users 口径)。查询参数
|
||||
// 规整复用 shared/pagination(缺省 20、1..100,与契约 limit bounds 一致)。
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { userOf } from '../albums/albums.controller';
|
||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||
import { parseCursor, parseLimit } from '../shared/pagination';
|
||||
import {
|
||||
EmbedSlotCreateRequestDto,
|
||||
EmbedSlotUpdateRequestDto,
|
||||
} from './dto';
|
||||
import {
|
||||
EmbedSlotsService,
|
||||
type EmbedSlotListResponse,
|
||||
type EmbedSlotResponse,
|
||||
} from './embed.service';
|
||||
|
||||
@Controller('admin/embed-slots')
|
||||
@UseGuards(SessionGuard)
|
||||
export class EmbedSlotsController {
|
||||
constructor(private readonly embedSlotsService: EmbedSlotsService) {}
|
||||
|
||||
/** 展示位列表(createdAt+id 稳定倒序键集游标)。 */
|
||||
@Get()
|
||||
listEmbedSlots(
|
||||
@Query('cursor') cursor: string | undefined,
|
||||
@Query('limit') limit: string | undefined,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<EmbedSlotListResponse> {
|
||||
return this.embedSlotsService.list(userOf(req), parseCursor(cursor), parseLimit(limit));
|
||||
}
|
||||
|
||||
/** 创建展示位(publicToken 服务端生成;初始未绑定、configVersion=1)。 */
|
||||
@Post()
|
||||
createEmbedSlot(
|
||||
@Body() dto: EmbedSlotCreateRequestDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<EmbedSlotResponse> {
|
||||
return this.embedSlotsService.create(userOf(req), dto);
|
||||
}
|
||||
|
||||
/** 部分更新(任一管理面变更递增 configVersion 并触发 slot-state 同步)。 */
|
||||
@Patch(':id')
|
||||
updateEmbedSlot(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: EmbedSlotUpdateRequestDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<EmbedSlotResponse> {
|
||||
return this.embedSlotsService.update(userOf(req), id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// services/api/src/embed/ —— 展示位管理模块(P4b Task 2,仅 cn 入口装配:
|
||||
// 展示位是 cn 权威实体,ov 侧只承接 slot-state 副本与公开读端点)。
|
||||
// PrismaModule 为 @Global,直接注入;无 Redis/存储依赖(管理面纯 DB 写)。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmbedSlotsController } from './embed.controller';
|
||||
import { EmbedSlotsService } from './embed.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EmbedSlotsController],
|
||||
providers: [EmbedSlotsService],
|
||||
})
|
||||
export class EmbedModule {}
|
||||
@@ -0,0 +1,406 @@
|
||||
// services/api/src/embed/embed.service.ts —— 展示位管理核心逻辑(P4b Task 2)。
|
||||
// 契约权威:packages/contracts/openapi.yaml tags [embed-slots]
|
||||
// (listEmbedSlots/createEmbedSlot/updateEmbedSlot)。要点:
|
||||
// · admin-only(AuthUser.role;P4b 权限简化:embed_slot_members 建而未用,
|
||||
// 被授权面 P5 按需补——计划 Task 1 登记偏差),非 admin 403 AUTH_FORBIDDEN;
|
||||
// · publicToken:24 字符 [0-9a-z](randomBytes 均匀映射,同 albums 口径),
|
||||
// P2002 撞唯一索引时整事务重试一次;
|
||||
// · create:单事务 embed_slots + outbox slot.updated(v1 全量快照——创建即
|
||||
// 未绑定态也同步,ov 副本与 sync_state 从第一版起对齐)+ audit_logs;
|
||||
// · update:条件更新(WHERE entityVersion=读取版本,同 albums 乐观重试
|
||||
// 模式)。任一管理面字段提交即 configVersion+1 **且** entityVersion+1
|
||||
// (双版本位,同一 updateMany 原子推进),事务内写 outbox slot.updated
|
||||
// 载荷=更新后全量。albumId 绑定/换绑校验相册存在(404 ALBUM_NOT_FOUND,
|
||||
// 相册行顺带取 publicToken 供载荷),null 解绑(albumPublicToken null)。
|
||||
// 停用迁移(enabled true→false)高优先级(ov 观众侧尽快 404,同相册
|
||||
// publicEnabled 口径);
|
||||
// · settings 整体替换;存储值恒为归一化全集(normalizeSettings);
|
||||
// · 列表:createdAt+id 稳定倒序键集游标(shared/cursor),albumTitle 按绑定
|
||||
// 相册 defaultLocale 解析(album_translations 二次查询,应用层组装——
|
||||
// 扁平字符串外键无 relation,同相册域约定)。
|
||||
// 无删除端点:enabled=false 即下线(软停用,契约语义)。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import type { components } from '@photo/contracts';
|
||||
import type { Prisma } from '@photo/db-cn';
|
||||
import { ApiException } from '../api-exception';
|
||||
import type { AuthUser } from '../auth/session.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
keysetWhereDesc,
|
||||
} from '../shared/cursor';
|
||||
import { buildSlotUpdatedPayload } from '../sync/payloads';
|
||||
import type {
|
||||
EmbedSlotCreateRequestDto,
|
||||
EmbedSlotSettingsDto,
|
||||
EmbedSlotUpdateRequestDto,
|
||||
} from './dto';
|
||||
import {
|
||||
albumNotFound,
|
||||
isUniqueViolationOn,
|
||||
validationFailed,
|
||||
} from '../albums/support';
|
||||
|
||||
export type EmbedSlotResponse = components['schemas']['EmbedSlot'];
|
||||
export type EmbedSlotListResponse = components['schemas']['EmbedSlotList'];
|
||||
export type EmbedSlotSettingsResponse =
|
||||
components['schemas']['EmbedSlotSettings'];
|
||||
|
||||
const PUBLIC_TOKEN_LENGTH = 24;
|
||||
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态)。 */
|
||||
export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* updateEmbedSlot 审计 metadata.fields 的字段名序列(契约 EmbedSlotUpdateRequest
|
||||
* 属性序,同 albums UPDATE_FIELD_ORDER 约定:固定顺序保证同形请求同形审计)。
|
||||
*/
|
||||
const UPDATE_FIELD_ORDER = [
|
||||
'name',
|
||||
'enabled',
|
||||
'settings',
|
||||
'albumId',
|
||||
] as const satisfies readonly (keyof EmbedSlotUpdateRequestDto)[];
|
||||
|
||||
function generatePublicToken(): string {
|
||||
const bytes = randomBytes(PUBLIC_TOKEN_LENGTH);
|
||||
let token = '';
|
||||
for (let i = 0; i < PUBLIC_TOKEN_LENGTH; i++) {
|
||||
// %36 模偏差对 124bit 令牌熵无实际影响(同 albums 口径)。
|
||||
token += PUBLIC_TOKEN_ALPHABET[bytes[i] % PUBLIC_TOKEN_ALPHABET.length];
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 入参 settings → 归一化存储值(null/undefined=整体缺省 → 契约缺省值)。 */
|
||||
function normalizeSettings(
|
||||
settings: EmbedSlotSettingsDto | null | undefined,
|
||||
): EmbedSlotSettingsResponse {
|
||||
if (settings === null || settings === undefined) {
|
||||
return { ...DEFAULT_EMBED_SLOT_SETTINGS };
|
||||
}
|
||||
return {
|
||||
position: settings.position,
|
||||
size: settings.size,
|
||||
intervalMs: settings.intervalMs,
|
||||
lang: settings.lang,
|
||||
collapsedDefault: settings.collapsedDefault,
|
||||
};
|
||||
}
|
||||
|
||||
function forbidden(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.AUTH_FORBIDDEN,
|
||||
'仅管理员可管理展示位',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
function slotNotFound(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'展示位不存在',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
type EmbedSlotRow = Prisma.embed_slotsGetPayload<Prisma.embed_slotsDefaultArgs>;
|
||||
|
||||
@Injectable()
|
||||
export class EmbedSlotsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** P4b 权限简化:仅公司 admin 可管理展示位(被授权面 P5)。 */
|
||||
private assertAdmin(user: AuthUser): void {
|
||||
if (user.role !== 'admin') {
|
||||
throw forbidden();
|
||||
}
|
||||
}
|
||||
|
||||
/** 展示位列表:createdAt+id 稳定倒序键集游标(同 albums.list 模式)。 */
|
||||
async list(
|
||||
user: AuthUser,
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<EmbedSlotListResponse> {
|
||||
this.assertAdmin(user);
|
||||
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
||||
if (cursor !== undefined && cursor !== '') {
|
||||
decoded = decodeKeysetCursor(cursor);
|
||||
if (decoded === null) {
|
||||
throw validationFailed('cursor 不合法');
|
||||
}
|
||||
}
|
||||
const rows = await this.prisma.embed_slots.findMany({
|
||||
where: decoded === null ? {} : keysetWhereDesc('createdAt', 'id', decoded),
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1, // 多取一条探测 hasMore(避免额外 count)
|
||||
});
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const titles = await this.albumTitlesByDefaultLocale(
|
||||
this.prisma,
|
||||
page.map((s) => s.albumId),
|
||||
);
|
||||
const last = page.at(-1);
|
||||
const nextCursor =
|
||||
hasMore && last !== undefined
|
||||
? encodeKeysetCursor({
|
||||
sort: last.createdAt.toISOString(),
|
||||
id: last.id,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
items: page.map((row) => this.toResponse(row, titles.get(row.albumId ?? '') ?? null)),
|
||||
nextCursor,
|
||||
hasMore: nextCursor !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建展示位:事务三写(embed_slots/outbox slot.updated v1/audit)。 */
|
||||
async create(
|
||||
user: AuthUser,
|
||||
dto: EmbedSlotCreateRequestDto,
|
||||
): Promise<EmbedSlotResponse> {
|
||||
this.assertAdmin(user);
|
||||
const settings = normalizeSettings(dto.settings);
|
||||
|
||||
let slotId: string;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
slotId = await this.prisma.$transaction(async (tx) => {
|
||||
const slot = await tx.embed_slots.create({
|
||||
data: {
|
||||
publicToken: generatePublicToken(),
|
||||
name: dto.name,
|
||||
settings: settings as unknown as Prisma.InputJsonValue,
|
||||
// enabled/configVersion/entityVersion 走 schema 默认。
|
||||
},
|
||||
});
|
||||
// 创建即同步未绑定态(albumPublicToken null):ov 副本行与 sync_state
|
||||
// 版本账本从 v1 起对齐,后续换绑/停用事件有单调基准。outbox.albumId
|
||||
// NOT NULL,未绑定以空串占位(ov 侧 slot-state 不做相册依赖暂存)。
|
||||
await tx.outbox_events.create({
|
||||
data: {
|
||||
type: 'slot.updated',
|
||||
albumId: slot.albumId ?? '',
|
||||
entityVersion: slot.entityVersion,
|
||||
payload: buildSlotUpdatedPayload(
|
||||
slot,
|
||||
null,
|
||||
) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await tx.audit_logs.create({
|
||||
data: {
|
||||
actorId: user.id,
|
||||
action: 'embed_slot.create',
|
||||
resourceId: slot.id,
|
||||
},
|
||||
});
|
||||
return slot.id;
|
||||
});
|
||||
break;
|
||||
} catch (err) {
|
||||
// publicToken 撞唯一索引:换令牌整事务重来一次(同 albums 口径)。
|
||||
if (attempt === 0 && isUniqueViolationOn(err, 'publicToken')) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return this.loadSlot(slotId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 部分更新(契约 updateEmbedSlot):仅提交的字段被修改;任一管理面字段
|
||||
* 提交即 configVersion+1 且 entityVersion+1(同一条件 updateMany 原子推进,
|
||||
* WHERE entityVersion=读取版本,并发败方整事务重跑——同 albums.update)。
|
||||
* albumId 非空校验相册存在(404 ALBUM_NOT_FOUND)并取 publicToken 入载荷;
|
||||
* null 解绑。settings 整体替换。事务内写 outbox slot.updated(载荷=更新后
|
||||
* 全量快照)+ audit(metadata.fields=提交字段名)。
|
||||
*/
|
||||
async update(
|
||||
user: AuthUser,
|
||||
slotId: string,
|
||||
dto: EmbedSlotUpdateRequestDto,
|
||||
): Promise<EmbedSlotResponse> {
|
||||
this.assertAdmin(user);
|
||||
const fields = UPDATE_FIELD_ORDER.filter(
|
||||
(key) => dto[key] !== undefined,
|
||||
) as string[];
|
||||
if (fields.length === 0) {
|
||||
throw validationFailed('请求体至少需要包含一个待更新字段');
|
||||
}
|
||||
|
||||
let outcome: 'done' | 'retry' = 'retry';
|
||||
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
|
||||
outcome = await this.prisma.$transaction(async (tx) => {
|
||||
const slot = await tx.embed_slots.findUnique({ where: { id: slotId } });
|
||||
if (slot === null) {
|
||||
throw slotNotFound();
|
||||
}
|
||||
|
||||
// albumId 裁决:显式 null=解绑;字符串=绑定/换绑(相册须存在,顺带取
|
||||
// publicToken 供事件载荷);未提交=保持(载荷仍需现绑相册的 token)。
|
||||
let nextAlbumId = slot.albumId;
|
||||
let nextAlbumPublicToken: string | null = null;
|
||||
if (dto.albumId !== undefined) {
|
||||
nextAlbumId = dto.albumId;
|
||||
}
|
||||
if (nextAlbumId !== null) {
|
||||
const album = await tx.albums.findUnique({
|
||||
where: { id: nextAlbumId },
|
||||
select: { publicToken: true },
|
||||
});
|
||||
if (album === null) {
|
||||
throw albumNotFound();
|
||||
}
|
||||
nextAlbumPublicToken = album.publicToken;
|
||||
}
|
||||
|
||||
const claimed = await tx.embed_slots.updateMany({
|
||||
where: { id: slotId, entityVersion: slot.entityVersion },
|
||||
data: {
|
||||
name: dto.name,
|
||||
enabled: dto.enabled,
|
||||
settings:
|
||||
dto.settings === undefined || dto.settings === null
|
||||
? undefined
|
||||
: (normalizeSettings(dto.settings) as unknown as Prisma.InputJsonValue),
|
||||
albumId: dto.albumId === undefined ? undefined : dto.albumId,
|
||||
configVersion: { increment: 1 },
|
||||
entityVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
return 'retry' as const;
|
||||
}
|
||||
|
||||
const updated = await tx.embed_slots.findUniqueOrThrow({
|
||||
where: { id: slotId },
|
||||
});
|
||||
// 停用迁移(true→false)高优先级:ov 观众侧须尽快 404(同相册
|
||||
// publicEnabled 关闭口径);已在停用态的常规更新保持 normal。
|
||||
await tx.outbox_events.create({
|
||||
data: {
|
||||
type: 'slot.updated',
|
||||
albumId: updated.albumId ?? '',
|
||||
entityVersion: updated.entityVersion,
|
||||
priority:
|
||||
dto.enabled === false && slot.enabled === true ? 'high' : 'normal',
|
||||
payload: buildSlotUpdatedPayload(
|
||||
updated,
|
||||
nextAlbumPublicToken,
|
||||
) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await tx.audit_logs.create({
|
||||
data: {
|
||||
actorId: user.id,
|
||||
action: 'embed_slot.update',
|
||||
resourceId: slotId,
|
||||
metadata: { fields },
|
||||
},
|
||||
});
|
||||
return 'done' as const;
|
||||
});
|
||||
}
|
||||
if (outcome !== 'done') {
|
||||
throw new ApiException(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Internal server error',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
return this.loadSlot(slotId);
|
||||
}
|
||||
|
||||
/** 组装 EmbedSlot 响应(albumTitle 按绑定相册 defaultLocale 解析)。 */
|
||||
private async loadSlot(slotId: string): Promise<EmbedSlotResponse> {
|
||||
const slot = await this.prisma.embed_slots.findUnique({
|
||||
where: { id: slotId },
|
||||
});
|
||||
if (slot === null) {
|
||||
throw slotNotFound();
|
||||
}
|
||||
const title = slot.albumId === null
|
||||
? null
|
||||
: (await this.albumTitlesByDefaultLocale(this.prisma, [slot.albumId])).get(
|
||||
slot.albumId,
|
||||
) ?? null;
|
||||
return this.toResponse(slot, title);
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
slot: EmbedSlotRow,
|
||||
albumTitle: string | null,
|
||||
): EmbedSlotResponse {
|
||||
return {
|
||||
id: slot.id,
|
||||
publicToken: slot.publicToken,
|
||||
name: slot.name,
|
||||
enabled: slot.enabled,
|
||||
albumId: slot.albumId,
|
||||
albumTitle,
|
||||
configVersion: slot.configVersion,
|
||||
settings: slot.settings as EmbedSlotSettingsResponse,
|
||||
updatedAt: slot.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册 id 集 → albumTitle(按各相册 defaultLocale 解析)。绑定相册的
|
||||
* defaultLocale 翻译行由相册创建/更新路径保证必在;缺行(防御)回退该相册
|
||||
* 任意翻译行(locale 升序首个,确定性),再缺则为 null(与未绑定同形——
|
||||
* 管理列表展示字段,不因脏数据 5xx)。
|
||||
*/
|
||||
private async albumTitlesByDefaultLocale(
|
||||
client: PrismaService | Prisma.TransactionClient,
|
||||
albumIds: Array<string | null>,
|
||||
): Promise<Map<string, string>> {
|
||||
const ids = [...new Set(albumIds.filter((id): id is string => id !== null))];
|
||||
const result = new Map<string, string>();
|
||||
if (ids.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const albums = await client.albums.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, defaultLocale: true },
|
||||
});
|
||||
const localesByAlbum = new Map(albums.map((a) => [a.id, a.defaultLocale]));
|
||||
const translations = await client.album_translations.findMany({
|
||||
where: { albumId: { in: ids } },
|
||||
orderBy: { locale: 'asc' },
|
||||
});
|
||||
const byAlbum = new Map<string, typeof translations>();
|
||||
for (const row of translations) {
|
||||
const bucket = byAlbum.get(row.albumId);
|
||||
if (bucket === undefined) {
|
||||
byAlbum.set(row.albumId, [row]);
|
||||
} else {
|
||||
bucket.push(row);
|
||||
}
|
||||
}
|
||||
for (const [albumId, defaultLocale] of localesByAlbum) {
|
||||
const rows = byAlbum.get(albumId) ?? [];
|
||||
const title =
|
||||
rows.find((r) => r.locale === defaultLocale)?.title ??
|
||||
rows[0]?.title;
|
||||
if (title !== undefined) {
|
||||
result.set(albumId, title);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user