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:
@@ -23,3 +23,8 @@ export function fetchCreateEmbedSlot(data: Api.EmbedSlot.CreatePayload) {
|
|||||||
export function fetchUpdateEmbedSlot(id: string, data: Api.EmbedSlot.UpdatePayload) {
|
export function fetchUpdateEmbedSlot(id: string, data: Api.EmbedSlot.UpdatePayload) {
|
||||||
return request.patch<Api.EmbedSlot.Item>({ url: `/v1/admin/embed-slots/${id}`, data })
|
return request.patch<Api.EmbedSlot.Item>({ url: `/v1/admin/embed-slots/${id}`, data })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 软删除展示位(deletedAt 标记:列表隐藏、ov 快照下线;幂等 204) */
|
||||||
|
export function fetchDeleteEmbedSlot(id: string) {
|
||||||
|
return request.del<void>({ url: `/v1/admin/embed-slots/${id}` })
|
||||||
|
}
|
||||||
|
|||||||
@@ -820,7 +820,10 @@
|
|||||||
"actionBind": "Bind Album",
|
"actionBind": "Bind Album",
|
||||||
"actionCode": "Embed Code",
|
"actionCode": "Embed Code",
|
||||||
"subtitle": "Manage floating photo widgets: album binding, toggles and embed code.",
|
"subtitle": "Manage floating photo widgets: album binding, toggles and embed code.",
|
||||||
"tableTitle": "Slots"
|
"tableTitle": "Slots",
|
||||||
|
"actionDelete": "Delete",
|
||||||
|
"deleteConfirm": "Delete slot \"{name}\"? Sites using it will stop showing it; this cannot be undone.",
|
||||||
|
"deleted": "Slot deleted"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"nameLabel": "Name",
|
"nameLabel": "Name",
|
||||||
|
|||||||
@@ -820,7 +820,10 @@
|
|||||||
"actionBind": "绑定相册",
|
"actionBind": "绑定相册",
|
||||||
"actionCode": "嵌入代码",
|
"actionCode": "嵌入代码",
|
||||||
"subtitle": "管理官网悬浮照片展示位:绑定相册、启停与接入代码。",
|
"subtitle": "管理官网悬浮照片展示位:绑定相册、启停与接入代码。",
|
||||||
"tableTitle": "展示位列表"
|
"tableTitle": "展示位列表",
|
||||||
|
"actionDelete": "删除",
|
||||||
|
"deleteConfirm": "确定删除展示位「{name}」?删除后已接入的网站将停止展示,且不可恢复。",
|
||||||
|
"deleted": "展示位已删除"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
"nameLabel": "名称",
|
"nameLabel": "名称",
|
||||||
|
|||||||
@@ -89,6 +89,9 @@
|
|||||||
<ElButton link type="primary" data-test="slot-code-btn" @click="openCode(row)">
|
<ElButton link type="primary" data-test="slot-code-btn" @click="openCode(row)">
|
||||||
{{ $t('embedSlotsManage.list.actionCode') }}
|
{{ $t('embedSlotsManage.list.actionCode') }}
|
||||||
</ElButton>
|
</ElButton>
|
||||||
|
<ElButton link type="danger" data-test="slot-delete-btn" @click="remove(row)">
|
||||||
|
{{ $t('embedSlotsManage.list.actionDelete') }}
|
||||||
|
</ElButton>
|
||||||
</template>
|
</template>
|
||||||
</ElTableColumn>
|
</ElTableColumn>
|
||||||
|
|
||||||
@@ -124,11 +127,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, ref } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import { useI18n } from 'vue-i18n'
|
import { useI18n } from 'vue-i18n'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import SlotFormDialog from './SlotFormDialog.vue'
|
import SlotFormDialog from './SlotFormDialog.vue'
|
||||||
import SlotBindDialog from './SlotBindDialog.vue'
|
import SlotBindDialog from './SlotBindDialog.vue'
|
||||||
import EmbedCodeDialog from './EmbedCodeDialog.vue'
|
import EmbedCodeDialog from './EmbedCodeDialog.vue'
|
||||||
import { fetchEmbedSlots, fetchUpdateEmbedSlot } from '@/api/embedSlots'
|
import { fetchDeleteEmbedSlot, fetchEmbedSlots, fetchUpdateEmbedSlot } from '@/api/embedSlots'
|
||||||
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
import { useCursorList } from '@/views/albums/composables/useCursorList'
|
||||||
import { formatDateTime } from '@/utils/albums/format'
|
import { formatDateTime } from '@/utils/albums/format'
|
||||||
|
|
||||||
@@ -207,6 +210,26 @@
|
|||||||
codeVisible.value = true
|
codeVisible.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 软删除:确认(已接入网站将停止展示)→ DELETE → 重载列表 */
|
||||||
|
async function remove(row: Api.EmbedSlot.Item): Promise<void> {
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm(
|
||||||
|
t('embedSlotsManage.list.deleteConfirm', { name: row.name }),
|
||||||
|
t('common.tips'),
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: t('common.confirm'),
|
||||||
|
cancelButtonText: t('common.cancel')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch {
|
||||||
|
return // 用户取消
|
||||||
|
}
|
||||||
|
await fetchDeleteEmbedSlot(row.id)
|
||||||
|
ElMessage.success(t('embedSlotsManage.list.deleted'))
|
||||||
|
loadFirst()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
void loadFirst()
|
void loadFirst()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -274,10 +274,6 @@ function onStageClick(event: MouseEvent): void {
|
|||||||
function onKeydown(event: KeyboardEvent): void {
|
function onKeydown(event: KeyboardEvent): void {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (moreMenuOpen.value) {
|
|
||||||
moreMenuOpen.value = false;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
emit('close');
|
emit('close');
|
||||||
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
} else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') {
|
||||||
if (inMenu(event.target)) return;
|
if (inMenu(event.target)) return;
|
||||||
@@ -440,17 +436,6 @@ function shareCurrent(): void {
|
|||||||
shareSheetOpen.value = true;
|
shareSheetOpen.value = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// —— 顶栏「⋯」更多菜单(§5.1:必须有实际功能——减少动画开关) ——
|
|
||||||
|
|
||||||
const moreMenuOpen = ref(false);
|
|
||||||
const moreRootEl = ref<HTMLElement | null>(null);
|
|
||||||
|
|
||||||
function onOuterPointerDown(event: PointerEvent): void {
|
|
||||||
if (moreMenuOpen.value && moreRootEl.value !== null && !moreRootEl.value.contains(event.target as Node)) {
|
|
||||||
moreMenuOpen.value = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// —— 签名过期/下线处理 ——
|
// —— 签名过期/下线处理 ——
|
||||||
|
|
||||||
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
|
/** 大图加载失败:静默重取单图接口换新签名 URL;404 → 通知父级移除;其他失败可重试。 */
|
||||||
@@ -501,14 +486,12 @@ const thumbItems = computed<ThumbnailItem[]>(() =>
|
|||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('keydown', onKeydown);
|
window.addEventListener('keydown', onKeydown);
|
||||||
document.addEventListener('pointerdown', onOuterPointerDown, true);
|
|
||||||
savedBodyOverflow = document.body.style.overflow;
|
savedBodyOverflow = document.body.style.overflow;
|
||||||
document.body.style.overflow = 'hidden'; // 锁背景滚动(§5.1)
|
document.body.style.overflow = 'hidden'; // 锁背景滚动(§5.1)
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
window.removeEventListener('keydown', onKeydown);
|
window.removeEventListener('keydown', onKeydown);
|
||||||
document.removeEventListener('pointerdown', onOuterPointerDown, true);
|
|
||||||
document.body.style.overflow = savedBodyOverflow;
|
document.body.style.overflow = savedBodyOverflow;
|
||||||
stopLocalLikeListener?.(); // 粒子订阅随大图卸载解除(useLikes 侧不残留)
|
stopLocalLikeListener?.(); // 粒子订阅随大图卸载解除(useLikes 侧不残留)
|
||||||
// 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。
|
// 焦点归还触发元素(瀑布流单元格;深链直达时为 body——无可归还则不动)。
|
||||||
@@ -543,34 +526,6 @@ onBeforeUnmount(() => {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<p class="position-indicator">{{ positionText }}</p>
|
<p class="position-indicator">{{ positionText }}</p>
|
||||||
<div ref="moreRootEl" class="more-menu">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="viewer-more"
|
|
||||||
:aria-label="t('viewer.more')"
|
|
||||||
aria-haspopup="menu"
|
|
||||||
:aria-expanded="moreMenuOpen"
|
|
||||||
@click="moreMenuOpen = !moreMenuOpen"
|
|
||||||
>
|
|
||||||
<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
|
|
||||||
<circle cx="5" cy="12" r="2" fill="currentColor" />
|
|
||||||
<circle cx="12" cy="12" r="2" fill="currentColor" />
|
|
||||||
<circle cx="19" cy="12" r="2" fill="currentColor" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
|
||||||
<div v-if="moreMenuOpen" class="more-popover" role="menu" :aria-label="t('viewer.more')">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="more-item"
|
|
||||||
role="menuitemcheckbox"
|
|
||||||
:aria-checked="reducedMotion"
|
|
||||||
@click="toggleReducedMotion"
|
|
||||||
>
|
|
||||||
<span class="more-check" aria-hidden="true">{{ reducedMotion ? '✓' : '' }}</span>
|
|
||||||
{{ t('viewer.reduceMotion') }}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<!-- 图片舞台(flex:1):contain 大图 + 边缘切换 + 粒子/互动占位层 -->
|
<!-- 图片舞台(flex:1):contain 大图 + 边缘切换 + 粒子/互动占位层 -->
|
||||||
@@ -721,85 +676,6 @@ onBeforeUnmount(() => {
|
|||||||
background: rgba(14, 18, 24, 0.72);
|
background: rgba(14, 18, 24, 0.72);
|
||||||
}
|
}
|
||||||
.viewer-close,
|
.viewer-close,
|
||||||
.viewer-more {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: var(--viewer-touch-target, 44px);
|
|
||||||
height: var(--viewer-touch-target, 44px);
|
|
||||||
border: none;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: transparent;
|
|
||||||
color: #ffffff;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.viewer-close:hover,
|
|
||||||
.viewer-more:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
}
|
|
||||||
.viewer-close:focus-visible,
|
|
||||||
.viewer-more:focus-visible {
|
|
||||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
|
||||||
}
|
|
||||||
.viewer-close svg,
|
|
||||||
.viewer-more svg {
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
fill: none;
|
|
||||||
stroke: currentColor;
|
|
||||||
stroke-width: 2;
|
|
||||||
stroke-linecap: round;
|
|
||||||
}
|
|
||||||
.position-indicator {
|
|
||||||
margin: 0;
|
|
||||||
text-align: center;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: rgba(255, 255, 255, 0.92);
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
.more-menu {
|
|
||||||
position: relative;
|
|
||||||
justify-self: end;
|
|
||||||
}
|
|
||||||
.more-popover {
|
|
||||||
position: absolute;
|
|
||||||
top: calc(100% + 2px);
|
|
||||||
right: 0;
|
|
||||||
z-index: 6;
|
|
||||||
display: grid;
|
|
||||||
min-width: 176px;
|
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: rgba(22, 28, 36, 0.96);
|
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
||||||
padding: 4px;
|
|
||||||
}
|
|
||||||
.more-item {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
min-height: var(--viewer-touch-target, 44px);
|
|
||||||
border: none;
|
|
||||||
border-radius: 8px;
|
|
||||||
background: transparent;
|
|
||||||
color: #ffffff;
|
|
||||||
padding: 0 12px;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
text-align: left;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
.more-item:hover {
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
}
|
|
||||||
.more-item:focus-visible {
|
|
||||||
outline: 2px solid var(--viewer-primary, #1a73e8);
|
|
||||||
}
|
|
||||||
.more-check {
|
|
||||||
display: inline-block;
|
|
||||||
width: 1em;
|
|
||||||
color: var(--viewer-primary, #1a73e8);
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 舞台:占剩余空间;contain 完整大图(不裁切);手势区 touch-action none。 */
|
/* 舞台:占剩余空间;contain 完整大图(不裁切);手势区 touch-action none。 */
|
||||||
.viewer-stage {
|
.viewer-stage {
|
||||||
|
|||||||
@@ -1816,6 +1816,33 @@ paths:
|
|||||||
/admin/embed-slots/{id}:
|
/admin/embed-slots/{id}:
|
||||||
parameters:
|
parameters:
|
||||||
- $ref: '#/components/parameters/EmbedSlotId'
|
- $ref: '#/components/parameters/EmbedSlotId'
|
||||||
|
delete:
|
||||||
|
operationId: deleteEmbedSlot
|
||||||
|
tags: [embed-slots]
|
||||||
|
summary: 软删除展示位
|
||||||
|
description: >-
|
||||||
|
标记 deletedAt(软删除):管理列表隐藏、公开拉取经 enabled=false 快照
|
||||||
|
同步 ov 后 404(已接入网站停止展示)。数据保留,无恢复端点。
|
||||||
|
admin 专属;幂等(重复删除 204)。
|
||||||
|
security:
|
||||||
|
- bearerAuth: []
|
||||||
|
responses:
|
||||||
|
'204':
|
||||||
|
description: 已删除(幂等)。
|
||||||
|
'401':
|
||||||
|
$ref: '#/components/responses/Unauthorized'
|
||||||
|
'403':
|
||||||
|
description: 非管理员(AUTH_FORBIDDEN)。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
|
'404':
|
||||||
|
description: 展示位不存在(EMBED_SLOT_NOT_FOUND,含已删除)。
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/Error'
|
||||||
patch:
|
patch:
|
||||||
operationId: updateEmbedSlot
|
operationId: updateEmbedSlot
|
||||||
tags: [embed-slots]
|
tags: [embed-slots]
|
||||||
|
|||||||
+45
-1
@@ -813,7 +813,11 @@ export interface paths {
|
|||||||
get?: never;
|
get?: never;
|
||||||
put?: never;
|
put?: never;
|
||||||
post?: never;
|
post?: never;
|
||||||
delete?: never;
|
/**
|
||||||
|
* 软删除展示位
|
||||||
|
* @description 标记 deletedAt(软删除):管理列表隐藏、公开拉取经 enabled=false 快照 同步 ov 后 404(已接入网站停止展示)。数据保留,无恢复端点。 admin 专属;幂等(重复删除 204)。
|
||||||
|
*/
|
||||||
|
delete: operations["deleteEmbedSlot"];
|
||||||
options?: never;
|
options?: never;
|
||||||
head?: never;
|
head?: never;
|
||||||
/**
|
/**
|
||||||
@@ -4659,6 +4663,46 @@ export interface operations {
|
|||||||
500: components["responses"]["InternalError"];
|
500: components["responses"]["InternalError"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
deleteEmbedSlot: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description 展示位 ID。 */
|
||||||
|
id: components["parameters"]["EmbedSlotId"];
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
/** @description 已删除(幂等)。 */
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
401: components["responses"]["Unauthorized"];
|
||||||
|
/** @description 非管理员(AUTH_FORBIDDEN)。 */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description 展示位不存在(EMBED_SLOT_NOT_FOUND,含已删除)。 */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["Error"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
updateEmbedSlot: {
|
updateEmbedSlot: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
-- 展示位软删除(deleteEmbedSlot):列表隐藏;ov 经 enabled=false 快照下线。
|
||||||
|
ALTER TABLE `embed_slots` ADD COLUMN `deletedAt` DATETIME(6) NULL;
|
||||||
@@ -350,6 +350,7 @@ model embed_slots {
|
|||||||
configVersion Int @default(1) // 任一管理面变更递增(嵌入端整体重置渲染依据)
|
configVersion Int @default(1) // 任一管理面变更递增(嵌入端整体重置渲染依据)
|
||||||
settings Json // EmbedSlotSettings 归一化全集(字段恒齐全)
|
settings Json // EmbedSlotSettings 归一化全集(字段恒齐全)
|
||||||
entityVersion Int @default(1) // slot.updated 事件版本(同 photos 模式)
|
entityVersion Int @default(1) // slot.updated 事件版本(同 photos 模式)
|
||||||
|
deletedAt DateTime? @db.DateTime(6) // 软删除(deleteEmbedSlot):列表隐藏;ov 经 enabled=false 快照下线
|
||||||
createdAt DateTime @default(now()) @db.DateTime(6)
|
createdAt DateTime @default(now()) @db.DateTime(6)
|
||||||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,8 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
|
HttpCode,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -14,6 +16,7 @@ import {
|
|||||||
Query,
|
Query,
|
||||||
Req,
|
Req,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
|
HttpStatus,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { userOf } from '../albums/albums.controller';
|
import { userOf } from '../albums/albums.controller';
|
||||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||||
@@ -53,6 +56,15 @@ export class EmbedSlotsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 部分更新(任一管理面变更递增 configVersion 并触发 slot-state 同步)。 */
|
/** 部分更新(任一管理面变更递增 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')
|
@Patch(':id')
|
||||||
updateEmbedSlot(
|
updateEmbedSlot(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
|
|||||||
@@ -193,7 +193,10 @@ export class EmbedSlotsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
const rows = await this.prisma.embed_slots.findMany({
|
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' }],
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||||
take: limit + 1, // 多取一条探测 hasMore(避免额外 count)
|
take: limit + 1, // 多取一条探测 hasMore(避免额外 count)
|
||||||
});
|
});
|
||||||
@@ -298,7 +301,7 @@ export class EmbedSlotsService {
|
|||||||
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
|
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
|
||||||
outcome = await this.prisma.$transaction(async (tx) => {
|
outcome = await this.prisma.$transaction(async (tx) => {
|
||||||
const slot = await tx.embed_slots.findUnique({ where: { id: slotId } });
|
const slot = await tx.embed_slots.findUnique({ where: { id: slotId } });
|
||||||
if (slot === null) {
|
if (slot === null || slot.deletedAt !== null) {
|
||||||
throw slotNotFound();
|
throw slotNotFound();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -379,11 +382,72 @@ export class EmbedSlotsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** 组装 EmbedSlot 响应(albumTitle 按绑定相册 defaultLocale 解析)。 */
|
/** 组装 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> {
|
private async loadSlot(slotId: string): Promise<EmbedSlotResponse> {
|
||||||
const slot = await this.prisma.embed_slots.findUnique({
|
const slot = await this.prisma.embed_slots.findUnique({
|
||||||
where: { id: slotId },
|
where: { id: slotId },
|
||||||
});
|
});
|
||||||
if (slot === null) {
|
if (slot === null || slot.deletedAt !== null) {
|
||||||
|
// 已删除与不存在同码 404(软删除语义,deleteEmbedSlot)。
|
||||||
throw slotNotFound();
|
throw slotNotFound();
|
||||||
}
|
}
|
||||||
const title = slot.albumId === null
|
const title = slot.albumId === null
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ type EmbedSlotRow = Pick<
|
|||||||
| 'configVersion'
|
| 'configVersion'
|
||||||
| 'settings'
|
| 'settings'
|
||||||
| 'entityVersion'
|
| 'entityVersion'
|
||||||
|
| 'deletedAt'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -157,7 +158,9 @@ export function buildSlotUpdatedPayload(
|
|||||||
return {
|
return {
|
||||||
slotId: slot.id,
|
slotId: slot.id,
|
||||||
publicToken: slot.publicToken,
|
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,
|
albumId: slot.albumId,
|
||||||
albumPublicToken,
|
albumPublicToken,
|
||||||
configVersion: slot.configVersion,
|
configVersion: slot.configVersion,
|
||||||
|
|||||||
Reference in New Issue
Block a user