feat(assets): album asset status — show current banner/cover/logo in admin branding tab

- 契约 listAlbumAssetStatus(GET /albums/{albumId}/assets):每 (kind,locale)
  最新 effective 行 + 现签 URL;权限同详情(可见即读);deleted → 404
- 服务端 AssetsService.status(createdAt,id DESC 组内取首;presignGet 现签)
- Admin 品牌资产页签改造:进入即加载现状——三种 kind 卡片(预览图/未设置
  空态/设置时间),banner 分全局/zh/en 槽位;上传成功自动刷新(替换语义)
- API 56/56、admin 87/87 + build 全绿

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-23 10:51:53 +08:00
parent d678c8d38d
commit 2618ad3163
14 changed files with 711 additions and 28 deletions
@@ -9,11 +9,13 @@ import {
Controller,
HttpCode,
HttpStatus,
Get,
Param,
Post,
Req,
UseGuards,
} from '@nestjs/common';
import type { components } from '@photo/contracts';
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
import { userOf } from './albums.controller';
import { AssetInitRequestDto } from './dto';
@@ -29,6 +31,14 @@ export class AssetsController {
constructor(private readonly assetsService: AssetsService) {}
/** 契约 201:会话行已建(每次 init 新行新键,无幂等键)。 */
@Get()
listStatus(
@Param('albumId') albumId: string,
@Req() req: AuthenticatedRequest,
): Promise<components['schemas']['AssetStatusResponse']> {
return this.assetsService.status(userOf(req), albumId);
}
@Post()
initAlbumAssetUpload(
@Param('albumId') albumId: string,
+44
View File
@@ -63,6 +63,50 @@ export class AssetsService {
@Inject(STORAGE_PORT) private readonly storage: StoragePort,
) {}
/**
* 各 (kind, locale) 组合最新生效资产(契约 listAlbumAssetStatus):管理端
* 现状展示。权限同相册详情(可见即可读);deleted → 404。签名现签
* (SigV4 本地计算);同组取 createdAt,id DESC 首行(替换语义)。
*/
async status(
user: AuthUser,
albumId: string,
): Promise<components['schemas']['AssetStatusResponse']> {
if (!(await this.permissions.canViewAlbum(user, albumId))) {
throw albumNotFound();
}
const album = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true, status: true },
});
if (album === null || album.status === 'deleted') {
throw albumNotFound();
}
const rows = await this.prisma.album_assets.findMany({
where: { albumId, state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
// 每 (kind, locale|null) 组合取首行(排序已保证最新在前)
const seen = new Set<string>();
const ttl = signedUrlTtlSeconds();
const items: components['schemas']['AssetStatusItem'][] = [];
for (const row of rows) {
const key = `${row.kind}|${row.locale ?? ''}`;
if (seen.has(key)) continue;
seen.add(key);
const url = await this.storage.presignGet(row.objectKey, ttl);
items.push({
kind: row.kind as components['schemas']['AssetStatusItem']['kind'],
locale: (row.locale ?? null) as string | null,
url,
expiresAt: new Date(Date.now() + ttl * 1000).toISOString(),
contentType: row.contentType,
createdAt: row.createdAt.toISOString(),
});
}
return { items };
}
/**
* 初始化:权限/参数校验 → 建行(state=pending)→ presign。
* 每次 init 都是新行新键——无幂等裁决,天然无并发冲突锚。