Files
YelayMaths/yelaymaths_api/src/modules/admin/admin.controller.ts
T
2026-09-29 15:25:40 +08:00

173 lines
6.6 KiB
TypeScript

import { Body, Controller, Delete, Get, Param, ParseIntPipe, Patch, Post, Put, Query } from '@nestjs/common';
import { z } from 'zod';
import { CurrentUser, Public, RequiresAudience, type AuthPrincipal } from '../../common/auth';
import { AppError } from '../../common/errors';
import { AuthService } from '../auth/auth.service';
import { AdminService } from './admin.service';
const loginSchema = z.object({ username: z.string().min(1), password: z.string().min(1) });
const bootstrapSchema = z.object({ username: z.string().min(3), password: z.string().min(8) });
const updateUserSchema = z
.object({
nickname: z.string().max(32).optional(),
headicon_id: z.coerce.number().int().min(0).optional(),
status: z.coerce.number().int().min(0).max(1).optional(),
})
.strict();
const settingSchema = z.object({ key: z.string().min(1).max(64), value: z.string(), remark: z.string().optional() });
const grantSchema = z.object({
userId: z.coerce.number().int().positive(),
courseId: z.coerce.number().int().positive(),
expireAt: z.coerce.date().nullish(),
note: z.string().max(255).optional(),
});
const refundSchema = z.object({ reason: z.string().min(1).max(100) });
/** 管理员登录。必须公开,否则登录接口本身会被守卫拦住。 */
@Public()
@Controller('admin/v1/auth')
export class AdminAuthController {
constructor(
private readonly admin: AdminService,
private readonly auth: AuthService,
) {}
@Post('login')
async login(@Body() body: unknown) {
const parsed = loginSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('请输入账号与密码');
const admin = await this.admin.verifyAdmin(parsed.data.username, parsed.data.password);
return { accessToken: await this.auth.signAdminToken(admin.id), admin };
}
/**
* 首次部署创建超管。仅当系统中尚无任何管理员时生效,
* 因此可以安全地保留在接口里作为初始化入口。
*/
@Post('bootstrap')
bootstrap(@Body() body: unknown) {
const parsed = bootstrapSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('用户名至少 3 位,密码至少 8 位');
return this.admin.bootstrapAdmin(parsed.data.username, parsed.data.password);
}
}
/** 管理后台主接口。全部要求管理员身份。 */
@RequiresAudience('admin')
@Controller('admin/v1')
export class AdminController {
constructor(private readonly admin: AdminService) {}
@Get('stats')
stats() {
return this.admin.stats();
}
// ── 用户 ───────────────────────────────────────────────────────────────
@Get('users')
listUsers(
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
) {
return this.admin.listUsers(Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
}
@Patch('users/:id')
updateUser(@Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
const parsed = updateUserSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误');
return this.admin.updateUser(id, parsed.data);
}
// ── 通用内容 CRUD ──────────────────────────────────────────────────────
@Get('data/:resource')
listResource(
@Param('resource') resource: string,
@Query('page') page?: string,
@Query('pageSize') pageSize?: string,
@Query('keyword') keyword?: string,
@Query('courseId') courseId?: string,
) {
const filter = courseId ? { course_id: Number(courseId) } : {};
return this.admin.listResource(resource, Number(page) || 1, Number(pageSize) || 20, keyword ?? '', filter);
}
@Post('data/:resource')
createResource(@Param('resource') resource: string, @Body() body: unknown) {
return this.admin.createResource(resource, body);
}
@Patch('data/:resource/:id')
updateResource(
@Param('resource') resource: string,
@Param('id', ParseIntPipe) id: number,
@Body() body: unknown,
) {
return this.admin.updateResource(resource, id, body);
}
@Delete('data/:resource/:id')
deleteResource(@Param('resource') resource: string, @Param('id', ParseIntPipe) id: number) {
return this.admin.deleteResource(resource, id);
}
// ── 配置 ───────────────────────────────────────────────────────────────
@Get('settings')
listSettings() {
return this.admin.listSettings();
}
@Put('settings')
upsertSetting(@Body() body: unknown) {
const parsed = settingSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误');
return this.admin.upsertSetting(parsed.data.key, parsed.data.value, parsed.data.remark);
}
// ── 订单 / 权益 ────────────────────────────────────────────────────────
@Get('orders')
listOrders(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('status') status?: string) {
const s = status === undefined || status === '' ? undefined : Number(status);
return this.admin.listOrders(Number(page) || 1, Number(pageSize) || 20, Number.isFinite(s) ? s : undefined);
}
@Post('orders/:id/refund')
refundOrder(@Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
const parsed = refundSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('请填写退款原因');
return this.admin.refundOrder(id, parsed.data.reason);
}
@Get('entitlements')
listEntitlements(@Query('userId', ParseIntPipe) userId: number) {
return this.admin.listEntitlements(userId);
}
@Post('entitlements')
grantEntitlement(@Body() body: unknown) {
const parsed = grantSchema.safeParse(body);
if (!parsed.success) throw AppError.badRequest('参数错误');
return this.admin.grantEntitlement({
userId: parsed.data.userId,
courseId: parsed.data.courseId,
expireAt: parsed.data.expireAt ?? null,
note: parsed.data.note,
});
}
@Delete('entitlements')
revokeEntitlement(@Query('userId', ParseIntPipe) userId: number, @Query('courseId', ParseIntPipe) courseId: number) {
return this.admin.revokeEntitlement(userId, courseId);
}
@Get('me')
me(@CurrentUser() admin: AuthPrincipal) {
return { id: admin.id, audience: admin.audience };
}
}