178 lines
6.6 KiB
TypeScript
178 lines
6.6 KiB
TypeScript
|
|
// services/api/src/admin/usage.service.ts —— 用量汇总与阈值管理(P4a Task 5,
|
|||
|
|
// A23 后台侧)。契约权威:openapi /admin/usage/summary|thresholds
|
|||
|
|
// (getUsageSummary/updateUsageThresholds)。仅 admin(AuthUser.role,403
|
|||
|
|
// AUTH_FORBIDDEN)。
|
|||
|
|
// 聚合/评估核心在 @photo/db-cn usage.ts(worker 检查点共用);本服务只做:
|
|||
|
|
// · summary:聚合 + 相册标题(defaultLocale 解析)+ 阈值 + 告警列表
|
|||
|
|
// (活跃在前、其后近期已解除,合计上限 100——契约「createdAt 倒序,含已
|
|||
|
|
// 解除」在「未解决优先呈现」运营语义下的落地);
|
|||
|
|
// · updateThresholds:单行 upsert(缺省字段保持不变、显式 null 清除)+ 契约
|
|||
|
|
// 「修改后即刻重新评估」——评估在 upsert 提交后自开事务执行(失败上抛:
|
|||
|
|
// 阈值已落库是事实,500 提示管理员重试;重试幂等)。
|
|||
|
|
import { HttpStatus, Injectable } from '@nestjs/common';
|
|||
|
|
import { ErrorCodes, type components } from '@photo/contracts';
|
|||
|
|
import {
|
|||
|
|
computeUsageAggregates,
|
|||
|
|
evaluateUsage,
|
|||
|
|
USAGE_ALERTS_LIST_LIMIT,
|
|||
|
|
USAGE_THRESHOLDS_SINGLETON_ID,
|
|||
|
|
} from '@photo/db-cn';
|
|||
|
|
import { ApiException } from '../api-exception';
|
|||
|
|
import type { AuthUser } from '../auth/session.guard';
|
|||
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|||
|
|
import type { UsageThresholdsUpdateDto } from './usage.dto';
|
|||
|
|
|
|||
|
|
export type UsageSummaryResponse = components['schemas']['UsageSummary'];
|
|||
|
|
export type UsageThresholdsResponse = components['schemas']['UsageThresholds'];
|
|||
|
|
export type UsageAlbumItem = components['schemas']['UsageAlbum'];
|
|||
|
|
export type UsageAlertItem = components['schemas']['UsageAlert'];
|
|||
|
|
|
|||
|
|
function requireAdmin(user: AuthUser): void {
|
|||
|
|
if (user.role !== 'admin') {
|
|||
|
|
throw new ApiException(
|
|||
|
|
ErrorCodes.AUTH_FORBIDDEN,
|
|||
|
|
'仅管理员可管理用量阈值与查看用量统计',
|
|||
|
|
HttpStatus.FORBIDDEN,
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@Injectable()
|
|||
|
|
export class UsageService {
|
|||
|
|
constructor(private readonly prisma: PrismaService) {}
|
|||
|
|
|
|||
|
|
/** 用量汇总(实时 SUM + 阈值 + 告警;相册按 totalBytes 降序,同值按 id 稳定)。 */
|
|||
|
|
async summary(user: AuthUser): Promise<UsageSummaryResponse> {
|
|||
|
|
requireAdmin(user);
|
|||
|
|
const [albums, translations, thresholds, aggregates] = await Promise.all([
|
|||
|
|
this.prisma.albums.findMany({
|
|||
|
|
select: { id: true, defaultLocale: true },
|
|||
|
|
orderBy: { id: 'asc' },
|
|||
|
|
}),
|
|||
|
|
this.prisma.album_translations.findMany({
|
|||
|
|
orderBy: { locale: 'asc' }, // 标题回退取序稳定
|
|||
|
|
}),
|
|||
|
|
this.prisma.usage_thresholds.findUnique({
|
|||
|
|
where: { id: USAGE_THRESHOLDS_SINGLETON_ID },
|
|||
|
|
}),
|
|||
|
|
computeUsageAggregates(this.prisma),
|
|||
|
|
]);
|
|||
|
|
|
|||
|
|
const titlesByAlbum = new Map<string, Map<string, string>>();
|
|||
|
|
for (const t of translations) {
|
|||
|
|
let titles = titlesByAlbum.get(t.albumId);
|
|||
|
|
if (titles === undefined) {
|
|||
|
|
titles = new Map();
|
|||
|
|
titlesByAlbum.set(t.albumId, titles);
|
|||
|
|
}
|
|||
|
|
titles.set(t.locale, t.title);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const albumItems: UsageAlbumItem[] = albums.map((album) => {
|
|||
|
|
const agg = aggregates.perAlbum.get(album.id);
|
|||
|
|
const titles = titlesByAlbum.get(album.id);
|
|||
|
|
return {
|
|||
|
|
albumId: album.id,
|
|||
|
|
// defaultLocale 翻译缺失(脏数据)回退任一翻译,再回退空串——单册脏
|
|||
|
|
// 数据不拖垮整页(同成员/照片列表先例)。
|
|||
|
|
title: titles?.get(album.defaultLocale) ?? [...(titles?.values() ?? [])][0] ?? '',
|
|||
|
|
photoCount: agg?.photoCount ?? 0,
|
|||
|
|
originalBytes: agg?.originalBytes ?? 0,
|
|||
|
|
variantBytes: agg?.variantBytes ?? 0,
|
|||
|
|
totalBytes: agg?.totalBytes ?? 0,
|
|||
|
|
};
|
|||
|
|
});
|
|||
|
|
albumItems.sort((a, b) => b.totalBytes - a.totalBytes || (a.albumId < b.albumId ? -1 : 1));
|
|||
|
|
|
|||
|
|
const activeAlerts = await this.prisma.usage_alerts.findMany({
|
|||
|
|
where: { resolvedAt: null },
|
|||
|
|
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
|||
|
|
take: USAGE_ALERTS_LIST_LIMIT,
|
|||
|
|
});
|
|||
|
|
// 已解除告警按「近期解除优先」补位(resolvedAt 倒序,合计不超上限)。
|
|||
|
|
const resolvedAlerts =
|
|||
|
|
activeAlerts.length >= USAGE_ALERTS_LIST_LIMIT
|
|||
|
|
? []
|
|||
|
|
: await this.prisma.usage_alerts.findMany({
|
|||
|
|
where: { resolvedAt: { not: null } },
|
|||
|
|
orderBy: [{ resolvedAt: 'desc' }, { id: 'desc' }],
|
|||
|
|
take: USAGE_ALERTS_LIST_LIMIT - activeAlerts.length,
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
albums: albumItems,
|
|||
|
|
totals: {
|
|||
|
|
photoCount: aggregates.totals.photoCount,
|
|||
|
|
totalBytes: aggregates.totals.totalBytes,
|
|||
|
|
},
|
|||
|
|
thresholds: {
|
|||
|
|
storageBytes:
|
|||
|
|
thresholds?.storageBytes == null ? null : Number(thresholds.storageBytes),
|
|||
|
|
perAlbumBytes:
|
|||
|
|
thresholds?.perAlbumBytes == null ? null : Number(thresholds.perAlbumBytes),
|
|||
|
|
},
|
|||
|
|
alerts: [...activeAlerts, ...resolvedAlerts].map(toUsageAlertItem),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/**
|
|||
|
|
* 阈值更新(部分更新语义)+ 即刻重新评估(超阈产生新告警/阈值上调解除旧
|
|||
|
|
* 告警)。评估事务在 upsert 提交后执行。
|
|||
|
|
*/
|
|||
|
|
async updateThresholds(
|
|||
|
|
user: AuthUser,
|
|||
|
|
dto: UsageThresholdsUpdateDto,
|
|||
|
|
): Promise<UsageThresholdsResponse> {
|
|||
|
|
requireAdmin(user);
|
|||
|
|
const current = await this.prisma.usage_thresholds.findUnique({
|
|||
|
|
where: { id: USAGE_THRESHOLDS_SINGLETON_ID },
|
|||
|
|
});
|
|||
|
|
const nextStorage =
|
|||
|
|
dto.storageBytes === undefined
|
|||
|
|
? (current?.storageBytes ?? null)
|
|||
|
|
: dto.storageBytes === null
|
|||
|
|
? null
|
|||
|
|
: BigInt(dto.storageBytes);
|
|||
|
|
const nextPerAlbum =
|
|||
|
|
dto.perAlbumBytes === undefined
|
|||
|
|
? (current?.perAlbumBytes ?? null)
|
|||
|
|
: dto.perAlbumBytes === null
|
|||
|
|
? null
|
|||
|
|
: BigInt(dto.perAlbumBytes);
|
|||
|
|
|
|||
|
|
await this.prisma.usage_thresholds.upsert({
|
|||
|
|
where: { id: USAGE_THRESHOLDS_SINGLETON_ID },
|
|||
|
|
update: { storageBytes: nextStorage, perAlbumBytes: nextPerAlbum },
|
|||
|
|
create: {
|
|||
|
|
id: USAGE_THRESHOLDS_SINGLETON_ID,
|
|||
|
|
storageBytes: nextStorage,
|
|||
|
|
perAlbumBytes: nextPerAlbum,
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
await this.prisma.$transaction((tx) => evaluateUsage(tx));
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
storageBytes: nextStorage === null ? null : Number(nextStorage),
|
|||
|
|
perAlbumBytes: nextPerAlbum === null ? null : Number(nextPerAlbum),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function toUsageAlertItem(row: {
|
|||
|
|
id: string;
|
|||
|
|
kind: string;
|
|||
|
|
albumId: string | null;
|
|||
|
|
message: string;
|
|||
|
|
createdAt: Date;
|
|||
|
|
resolvedAt: Date | null;
|
|||
|
|
}): UsageAlertItem {
|
|||
|
|
return {
|
|||
|
|
id: row.id,
|
|||
|
|
kind: row.kind as UsageAlertItem['kind'],
|
|||
|
|
albumId: row.albumId,
|
|||
|
|
message: row.message,
|
|||
|
|
createdAt: row.createdAt.toISOString(),
|
|||
|
|
resolvedAt: row.resolvedAt === null ? null : row.resolvedAt.toISOString(),
|
|||
|
|
};
|
|||
|
|
}
|