206 lines
8.0 KiB
TypeScript
206 lines
8.0 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 { AdminEnvParam, type DataSourceEnv } from '../../common/admin-env';
|
|
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) });
|
|
const dataSourceSchema = z.object({ env: z.enum(['production', 'develop']) });
|
|
|
|
/** 管理员登录。必须公开,否则登录接口本身会被守卫拦住。 */
|
|
@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(@AdminEnvParam() env: DataSourceEnv) {
|
|
return this.admin.stats(env);
|
|
}
|
|
|
|
/**
|
|
* 切换**线上小程序**的数据源。
|
|
*
|
|
* 与管理端左侧的「查看哪个库」完全不同:那个只改自己的视角,
|
|
* 这个会真实影响所有用户在小程序里看到的内容,所以前端必须二次确认。
|
|
*
|
|
* 开关存在生产库,因此切到开发库后依然能切回来。
|
|
*/
|
|
@Put('data-source')
|
|
switchMiniProgramDataSource(@Body() body: unknown) {
|
|
const parsed = dataSourceSchema.safeParse(body);
|
|
if (!parsed.success) throw AppError.badRequest('参数错误:env 只能是 production 或 develop');
|
|
return this.admin.switchMiniProgramDataSource(parsed.data.env);
|
|
}
|
|
|
|
// ── 用户 ───────────────────────────────────────────────────────────────
|
|
|
|
@Get('users')
|
|
listUsers(
|
|
@AdminEnvParam() env: DataSourceEnv,
|
|
@Query('page') page?: string,
|
|
@Query('pageSize') pageSize?: string,
|
|
@Query('keyword') keyword?: string,
|
|
) {
|
|
return this.admin.listUsers(env, Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
|
|
}
|
|
|
|
@Patch('users/:id')
|
|
updateUser(@AdminEnvParam() env: DataSourceEnv, @Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
|
|
const parsed = updateUserSchema.safeParse(body);
|
|
if (!parsed.success) throw AppError.badRequest('参数错误');
|
|
return this.admin.updateUser(env, id, parsed.data);
|
|
}
|
|
|
|
// ── 通用内容 CRUD ──────────────────────────────────────────────────────
|
|
|
|
@Get('data/:resource')
|
|
listResource(
|
|
@AdminEnvParam() env: DataSourceEnv,
|
|
@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(env, resource, Number(page) || 1, Number(pageSize) || 20, keyword ?? '', filter);
|
|
}
|
|
|
|
@Post('data/:resource')
|
|
createResource(@AdminEnvParam() env: DataSourceEnv, @Param('resource') resource: string, @Body() body: unknown) {
|
|
return this.admin.createResource(env, resource, body);
|
|
}
|
|
|
|
@Patch('data/:resource/:id')
|
|
updateResource(
|
|
@AdminEnvParam() env: DataSourceEnv,
|
|
@Param('resource') resource: string,
|
|
@Param('id', ParseIntPipe) id: number,
|
|
@Body() body: unknown,
|
|
) {
|
|
return this.admin.updateResource(env, resource, id, body);
|
|
}
|
|
|
|
@Delete('data/:resource/:id')
|
|
deleteResource(
|
|
@AdminEnvParam() env: DataSourceEnv,
|
|
@Param('resource') resource: string,
|
|
@Param('id', ParseIntPipe) id: number,
|
|
) {
|
|
return this.admin.deleteResource(env, resource, id);
|
|
}
|
|
|
|
// ── 配置 ───────────────────────────────────────────────────────────────
|
|
|
|
@Get('settings')
|
|
listSettings(@AdminEnvParam() env: DataSourceEnv) {
|
|
return this.admin.listSettings(env);
|
|
}
|
|
|
|
@Put('settings')
|
|
upsertSetting(@AdminEnvParam() env: DataSourceEnv, @Body() body: unknown) {
|
|
const parsed = settingSchema.safeParse(body);
|
|
if (!parsed.success) throw AppError.badRequest('参数错误');
|
|
return this.admin.upsertSetting(env, parsed.data.key, parsed.data.value, parsed.data.remark);
|
|
}
|
|
|
|
// ── 订单 / 权益 ────────────────────────────────────────────────────────
|
|
|
|
@Get('orders')
|
|
listOrders(
|
|
@AdminEnvParam() env: DataSourceEnv,
|
|
@Query('page') page?: string,
|
|
@Query('pageSize') pageSize?: string,
|
|
@Query('status') status?: string,
|
|
) {
|
|
const s = status === undefined || status === '' ? undefined : Number(status);
|
|
return this.admin.listOrders(env, Number(page) || 1, Number(pageSize) || 20, Number.isFinite(s) ? s : undefined);
|
|
}
|
|
|
|
@Post('orders/:id/refund')
|
|
refundOrder(@AdminEnvParam() env: DataSourceEnv, @Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
|
|
const parsed = refundSchema.safeParse(body);
|
|
if (!parsed.success) throw AppError.badRequest('请填写退款原因');
|
|
return this.admin.refundOrder(env, id, parsed.data.reason);
|
|
}
|
|
|
|
@Get('entitlements')
|
|
listEntitlements(@AdminEnvParam() env: DataSourceEnv, @Query('userId', ParseIntPipe) userId: number) {
|
|
return this.admin.listEntitlements(env, userId);
|
|
}
|
|
|
|
@Post('entitlements')
|
|
grantEntitlement(@AdminEnvParam() env: DataSourceEnv, @Body() body: unknown) {
|
|
const parsed = grantSchema.safeParse(body);
|
|
if (!parsed.success) throw AppError.badRequest('参数错误');
|
|
return this.admin.grantEntitlement(env, {
|
|
userId: parsed.data.userId,
|
|
courseId: parsed.data.courseId,
|
|
expireAt: parsed.data.expireAt ?? null,
|
|
note: parsed.data.note,
|
|
});
|
|
}
|
|
|
|
@Delete('entitlements')
|
|
revokeEntitlement(
|
|
@AdminEnvParam() env: DataSourceEnv,
|
|
@Query('userId', ParseIntPipe) userId: number,
|
|
@Query('courseId', ParseIntPipe) courseId: number,
|
|
) {
|
|
return this.admin.revokeEntitlement(env, userId, courseId);
|
|
}
|
|
|
|
@Get('me')
|
|
me(@CurrentUser() admin: AuthPrincipal) {
|
|
return { id: admin.id, audience: admin.audience };
|
|
}
|
|
}
|