feat(embed-slots): soft-delete slots — admin entry, ov shutdown via enabled=false snapshot
- 契约 deleteEmbedSlot(DELETE /admin/embed-slots/{id} → 204 幂等;admin 专属)
- cn schema:embed_slots.deletedAt(迁移 20260923120000,本地已应用)
- softDelete:乐观锁 claim + outbox slot.updated(payload enabled 强制 false
——ov 副本无墓碑,enabled=false 快照即公开端 404 下线;priority=high)+ audit
- 列表/加载/更新过滤 deletedAt → 同码 404
- Admin 列表操作「删除」(确认:已接入网站将停止展示、不可恢复)
- API 56/56、admin 87/87 + build 全绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,8 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
HttpCode,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
@@ -14,6 +16,7 @@ import {
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { userOf } from '../albums/albums.controller';
|
||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||
@@ -53,6 +56,15 @@ export class EmbedSlotsController {
|
||||
}
|
||||
|
||||
/** 部分更新(任一管理面变更递增 configVersion 并触发 slot-state 同步)。 */
|
||||
@Delete(':id')
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
deleteEmbedSlot(
|
||||
@Param('id') id: string,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<void> {
|
||||
return this.embedSlotsService.softDelete(userOf(req), id);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
updateEmbedSlot(
|
||||
@Param('id') id: string,
|
||||
|
||||
@@ -193,7 +193,10 @@ export class EmbedSlotsService {
|
||||
}
|
||||
}
|
||||
const rows = await this.prisma.embed_slots.findMany({
|
||||
where: decoded === null ? {} : keysetWhereDesc('createdAt', 'id', decoded),
|
||||
where: {
|
||||
deletedAt: null,
|
||||
...(decoded === null ? {} : keysetWhereDesc('createdAt', 'id', decoded)),
|
||||
},
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1, // 多取一条探测 hasMore(避免额外 count)
|
||||
});
|
||||
@@ -298,7 +301,7 @@ export class EmbedSlotsService {
|
||||
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) {
|
||||
if (slot === null || slot.deletedAt !== null) {
|
||||
throw slotNotFound();
|
||||
}
|
||||
|
||||
@@ -379,11 +382,72 @@ export class EmbedSlotsService {
|
||||
}
|
||||
|
||||
/** 组装 EmbedSlot 响应(albumTitle 按绑定相册 defaultLocale 解析)。 */
|
||||
/**
|
||||
* 软删除(契约 deleteEmbedSlot):deletedAt 标记 + entityVersion 自增 +
|
||||
* outbox slot.updated(enabled=false 快照 → ov 公开端 404 下线)+ audit。
|
||||
* 管理列表隐藏;数据保留,无恢复端点。幂等:重复删除 204。
|
||||
*/
|
||||
async softDelete(user: AuthUser, slotId: string): Promise<void> {
|
||||
this.assertAdmin(user);
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
const done = await this.prisma.$transaction(async (tx) => {
|
||||
const slot = await tx.embed_slots.findUnique({ where: { id: slotId } });
|
||||
if (slot === null) {
|
||||
throw slotNotFound();
|
||||
}
|
||||
if (slot.deletedAt !== null) {
|
||||
return true as const; // 幂等
|
||||
}
|
||||
const claimed = await tx.embed_slots.updateMany({
|
||||
where: { id: slotId, entityVersion: slot.entityVersion },
|
||||
data: {
|
||||
deletedAt: new Date(),
|
||||
entityVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
return false as const;
|
||||
}
|
||||
const updated = await tx.embed_slots.findUniqueOrThrow({
|
||||
where: { id: slotId },
|
||||
});
|
||||
await tx.outbox_events.create({
|
||||
data: {
|
||||
type: 'slot.updated',
|
||||
albumId: updated.albumId ?? '',
|
||||
entityVersion: updated.entityVersion,
|
||||
// 公开可见性下线 → high(嵌入端尽快 404)
|
||||
priority: 'high',
|
||||
payload: buildSlotUpdatedPayload(
|
||||
updated,
|
||||
null,
|
||||
) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await tx.audit_logs.create({
|
||||
data: {
|
||||
actorId: user.id,
|
||||
action: 'embed_slot.delete',
|
||||
resourceId: slotId,
|
||||
},
|
||||
});
|
||||
return true as const;
|
||||
});
|
||||
if (done) return;
|
||||
}
|
||||
throw new ApiException(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Internal server error',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
|
||||
private async loadSlot(slotId: string): Promise<EmbedSlotResponse> {
|
||||
const slot = await this.prisma.embed_slots.findUnique({
|
||||
where: { id: slotId },
|
||||
});
|
||||
if (slot === null) {
|
||||
if (slot === null || slot.deletedAt !== null) {
|
||||
// 已删除与不存在同码 404(软删除语义,deleteEmbedSlot)。
|
||||
throw slotNotFound();
|
||||
}
|
||||
const title = slot.albumId === null
|
||||
|
||||
@@ -62,6 +62,7 @@ type EmbedSlotRow = Pick<
|
||||
| 'configVersion'
|
||||
| 'settings'
|
||||
| 'entityVersion'
|
||||
| 'deletedAt'
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -157,7 +158,9 @@ export function buildSlotUpdatedPayload(
|
||||
return {
|
||||
slotId: slot.id,
|
||||
publicToken: slot.publicToken,
|
||||
enabled: slot.enabled,
|
||||
// 软删除(deleteEmbedSlot)行以 enabled=false 快照下线 ov 副本
|
||||
// (embed_slots_ov 无墓碑,enabled=false 即公开端 404)。
|
||||
enabled: slot.deletedAt !== null ? false : slot.enabled,
|
||||
albumId: slot.albumId,
|
||||
albumPublicToken,
|
||||
configVersion: slot.configVersion,
|
||||
|
||||
Reference in New Issue
Block a user