feat: admin cost management — CRUD + monthly summary + expiry
All checks were successful
Deploy API Server / build-and-deploy (push) Successful in 39s

This commit is contained in:
WangDL 2026-05-22 15:40:24 +08:00
parent c6aa4cf88a
commit 997b3c0cdb
6 changed files with 145 additions and 0 deletions

View File

@ -0,0 +1,16 @@
CREATE TABLE `AdminCostItem` (
`id` VARCHAR(191) NOT NULL,
`name` VARCHAR(100) NOT NULL,
`category` VARCHAR(32) NOT NULL DEFAULT 'other',
`amount` DOUBLE NOT NULL,
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY',
`purchaseDate` DATETIME(3) NOT NULL,
`expiryDate` DATETIME(3) NULL,
`billingCycle` VARCHAR(16) NOT NULL DEFAULT 'once',
`note` VARCHAR(255) NULL,
`createdAt` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updatedAt` DATETIME(3) NOT NULL,
INDEX `AdminCostItem_category_idx`(`category`),
INDEX `AdminCostItem_expiryDate_idx`(`expiryDate`),
PRIMARY KEY (`id`)
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;

View File

@ -823,3 +823,20 @@ model AdminMessage {
@@index([conversationId])
@@index([createdAt])
}
model AdminCostItem {
id String @id @default(cuid())
name String @db.VarChar(100)
category String @default("other") @db.VarChar(32)
amount Float
currency String @default("CNY") @db.VarChar(8)
purchaseDate DateTime
expiryDate DateTime?
billingCycle String @default("once") @db.VarChar(16)
note String? @db.VarChar(255)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([category])
@@index([expiryDate])
}

View File

@ -15,6 +15,7 @@ import { AuthModule } from './modules/auth/auth.module';
import { AdminAuthModule } from './modules/admin-auth/admin-auth.module';
import { AdminDashboardModule } from './modules/admin-dashboard/admin-dashboard.module';
import { AdminUsersModule } from './modules/admin-users/admin-users.module';
import { AdminCostsModule } from './modules/admin-costs/admin-costs.module';
import { AdminBillingModule } from './modules/admin-billing/admin-billing.module';
import { AdminServersModule } from './modules/admin-servers/admin-servers.module';
import { AdminConversationModule } from './modules/admin-conversation/admin-conversation.module';
@ -91,6 +92,7 @@ import appleConfig from './config/apple.config';
AdminAuthModule,
AdminDashboardModule,
AdminUsersModule,
AdminCostsModule,
AdminBillingModule,
AdminServersModule,
AdminConversationModule,

View File

@ -0,0 +1,20 @@
import { Controller, Get, Post, Patch, Delete, Body, Param, UseGuards } from '@nestjs/common';
import { ApiTags, ApiBearerAuth } from '@nestjs/swagger';
import { AdminCostsService } from './admin-costs.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
import { AdminRoles } from '../../common/decorators/admin-roles.decorator';
import type { AdminRole } from '../../common/types/admin-role.enum';
@ApiTags('admin-costs')
@Controller('admin-api/costs')
@UseGuards(AdminAuthGuard)
@ApiBearerAuth()
export class AdminCostsController {
constructor(private readonly svc: AdminCostsService) {}
@Get() @AdminRoles('SUPER_ADMIN' as AdminRole) async list() { return this.svc.list(); }
@Get('summary') @AdminRoles('SUPER_ADMIN' as AdminRole) async summary() { return this.svc.summary(); }
@Post() @AdminRoles('SUPER_ADMIN' as AdminRole) async create(@Body() d: any) { return this.svc.create(d); }
@Patch(':id') @AdminRoles('SUPER_ADMIN' as AdminRole) async update(@Param('id') id: string, @Body() d: any) { return this.svc.update(id, d); }
@Delete(':id') @AdminRoles('SUPER_ADMIN' as AdminRole) async delete(@Param('id') id: string) { return this.svc.delete(id); }
}

View File

@ -0,0 +1,7 @@
import { Module } from '@nestjs/common';
import { AdminCostsController } from './admin-costs.controller';
import { AdminCostsService } from './admin-costs.service';
import { PrismaService } from '../../infrastructure/database/prisma.service';
import { AdminAuthGuard } from '../../common/guards/admin-auth.guard';
@Module({ controllers: [AdminCostsController], providers: [AdminCostsService, PrismaService, AdminAuthGuard] })
export class AdminCostsModule {}

View File

@ -0,0 +1,83 @@
import { Injectable } from '@nestjs/common';
import { PrismaService } from '../../infrastructure/database/prisma.service';
export interface CostSummary {
totalMonthly: number;
totalYearly: number;
totalOneTime: number;
items: any[];
byMonth: { month: string; total: number; items: { name: string; amount: number }[] }[];
expiringSoon: any[];
}
@Injectable()
export class AdminCostsService {
constructor(private readonly prisma: PrismaService) {}
async list() {
return this.prisma.adminCostItem.findMany({ orderBy: { createdAt: 'desc' } });
}
async create(data: { name: string; category: string; amount: number; currency?: string; purchaseDate: string; expiryDate?: string; billingCycle?: string; note?: string }) {
return this.prisma.adminCostItem.create({
data: {
name: data.name, category: data.category || 'other', amount: data.amount,
currency: data.currency || 'CNY', purchaseDate: new Date(data.purchaseDate),
expiryDate: data.expiryDate ? new Date(data.expiryDate) : null,
billingCycle: data.billingCycle || 'once', note: data.note,
},
});
}
async update(id: string, data: any) {
return this.prisma.adminCostItem.update({ where: { id }, data });
}
async delete(id: string) {
return this.prisma.adminCostItem.delete({ where: { id } });
}
async summary(): Promise<CostSummary> {
const items = await this.prisma.adminCostItem.findMany({ orderBy: { purchaseDate: 'desc' } });
const now = new Date();
// Calculate monthly equivalent
let totalMonthly = 0, totalYearly = 0, totalOneTime = 0;
const byMonth: Record<string, { total: number; items: { name: string; amount: number }[] }> = {};
const expiringSoon: any[] = [];
for (const item of items) {
// Monthly cost
let monthly = 0;
if (item.billingCycle === 'monthly') monthly = item.amount;
else if (item.billingCycle === 'yearly') monthly = item.amount / 12;
else totalOneTime += item.amount;
totalMonthly += monthly;
if (item.billingCycle === 'yearly') totalYearly += item.amount;
// Group by purchase month
const month = item.purchaseDate.toISOString().slice(0, 7);
if (!byMonth[month]) byMonth[month] = { total: 0, items: [] };
byMonth[month].total += item.amount;
byMonth[month].items.push({ name: item.name, amount: item.amount });
// Expiring within 30 days
if (item.expiryDate) {
const daysLeft = Math.ceil((new Date(item.expiryDate).getTime() - now.getTime()) / 86400000);
if (daysLeft <= 30 && daysLeft >= 0) {
expiringSoon.push({ ...item, daysLeft });
}
}
}
return {
totalMonthly: Math.round(totalMonthly * 100) / 100,
totalYearly: Math.round(totalYearly * 100) / 100,
totalOneTime: Math.round(totalOneTime * 100) / 100,
items,
byMonth: Object.entries(byMonth).map(([month, data]) => ({ month, ...data })).sort((a, b) => b.month.localeCompare(a.month)),
expiringSoon: expiringSoon.sort((a: any, b: any) => a.daysLeft - b.daysLeft),
};
}
}