大修改
This commit is contained in:
1 parent
a1cbc9404a
commit
e81481623c
47 files changed
+1453
-285
No files matched your search
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -22,6 +23,7 @@ const grantSchema = z.object({
|
||||
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()
|
||||
@@ -59,32 +61,49 @@ export class AdminController {
|
||||
constructor(private readonly admin: AdminService) {}
|
||||
|
||||
@Get('stats')
|
||||
stats() {
|
||||
return this.admin.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(Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
|
||||
return this.admin.listUsers(env, Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
updateUser(@Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
|
||||
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(id, parsed.data);
|
||||
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,
|
||||
@@ -92,67 +111,77 @@ export class AdminController {
|
||||
@Query('courseId') courseId?: string,
|
||||
) {
|
||||
const filter = courseId ? { course_id: Number(courseId) } : {};
|
||||
return this.admin.listResource(resource, Number(page) || 1, Number(pageSize) || 20, keyword ?? '', filter);
|
||||
return this.admin.listResource(env, 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);
|
||||
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(resource, id, body);
|
||||
return this.admin.updateResource(env, resource, id, body);
|
||||
}
|
||||
|
||||
@Delete('data/:resource/:id')
|
||||
deleteResource(@Param('resource') resource: string, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.admin.deleteResource(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() {
|
||||
return this.admin.listSettings();
|
||||
listSettings(@AdminEnvParam() env: DataSourceEnv) {
|
||||
return this.admin.listSettings(env);
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
upsertSetting(@Body() body: unknown) {
|
||||
upsertSetting(@AdminEnvParam() env: DataSourceEnv, @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);
|
||||
return this.admin.upsertSetting(env, parsed.data.key, parsed.data.value, parsed.data.remark);
|
||||
}
|
||||
|
||||
// ── 订单 / 权益 ────────────────────────────────────────────────────────
|
||||
|
||||
@Get('orders')
|
||||
listOrders(@Query('page') page?: string, @Query('pageSize') pageSize?: string, @Query('status') status?: string) {
|
||||
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(Number(page) || 1, Number(pageSize) || 20, Number.isFinite(s) ? s : undefined);
|
||||
return this.admin.listOrders(env, Number(page) || 1, Number(pageSize) || 20, Number.isFinite(s) ? s : undefined);
|
||||
}
|
||||
|
||||
@Post('orders/:id/refund')
|
||||
refundOrder(@Param('id', ParseIntPipe) id: number, @Body() body: unknown) {
|
||||
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(id, parsed.data.reason);
|
||||
return this.admin.refundOrder(env, id, parsed.data.reason);
|
||||
}
|
||||
|
||||
@Get('entitlements')
|
||||
listEntitlements(@Query('userId', ParseIntPipe) userId: number) {
|
||||
return this.admin.listEntitlements(userId);
|
||||
listEntitlements(@AdminEnvParam() env: DataSourceEnv, @Query('userId', ParseIntPipe) userId: number) {
|
||||
return this.admin.listEntitlements(env, userId);
|
||||
}
|
||||
|
||||
@Post('entitlements')
|
||||
grantEntitlement(@Body() body: unknown) {
|
||||
grantEntitlement(@AdminEnvParam() env: DataSourceEnv, @Body() body: unknown) {
|
||||
const parsed = grantSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误');
|
||||
return this.admin.grantEntitlement({
|
||||
return this.admin.grantEntitlement(env, {
|
||||
userId: parsed.data.userId,
|
||||
courseId: parsed.data.courseId,
|
||||
expireAt: parsed.data.expireAt ?? null,
|
||||
@@ -161,8 +190,12 @@ export class AdminController {
|
||||
}
|
||||
|
||||
@Delete('entitlements')
|
||||
revokeEntitlement(@Query('userId', ParseIntPipe) userId: number, @Query('courseId', ParseIntPipe) courseId: number) {
|
||||
return this.admin.revokeEntitlement(userId, courseId);
|
||||
revokeEntitlement(
|
||||
@AdminEnvParam() env: DataSourceEnv,
|
||||
@Query('userId', ParseIntPipe) userId: number,
|
||||
@Query('courseId', ParseIntPipe) courseId: number,
|
||||
) {
|
||||
return this.admin.revokeEntitlement(env, userId, courseId);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { PrismaClient } from '@prisma/client';
|
||||
import { z } from 'zod';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import type { DataSourceEnv } from '../../common/admin-env';
|
||||
import { hashPassword, verifyPassword } from '../../common/crypto';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
@@ -27,8 +29,29 @@ export class AdminService {
|
||||
private readonly wxpay: WxPayService,
|
||||
) {}
|
||||
|
||||
private delegate(name: string): Delegate {
|
||||
const d = (this.prisma as unknown as Record<string, Delegate>)[name];
|
||||
/** 开发库是否可用(由 DATABASE_URL_DEVELOP 是否配置决定)。 */
|
||||
get developAvailable(): boolean {
|
||||
return this.prisma.hasDev;
|
||||
}
|
||||
|
||||
/** 小程序当前读的是哪个库。由「小程序数据源」那个独立开关决定。 */
|
||||
get miniProgramEnv(): string {
|
||||
return this.prisma.activeEnv;
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换**小程序**的数据源。
|
||||
*
|
||||
* 与管理端左侧的「查看哪个库」是两回事:那个只影响管理端自己看的数据,
|
||||
* 这个才真正改变线上小程序读哪个库,所以界面上必须二次确认。
|
||||
*/
|
||||
async switchMiniProgramDataSource(env: DataSourceEnv): Promise<{ miniProgramEnv: string }> {
|
||||
await this.prisma.setMiniProgramEnv(env);
|
||||
return { miniProgramEnv: env };
|
||||
}
|
||||
|
||||
private delegate(client: PrismaClient, name: string): Delegate {
|
||||
const d = (client as unknown as Record<string, Delegate>)[name];
|
||||
if (!d) throw AppError.notFound(`未知资源:${name}`);
|
||||
return d;
|
||||
}
|
||||
@@ -36,11 +59,12 @@ export class AdminService {
|
||||
// ── 登录 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async verifyAdmin(username: string, password: string): Promise<{ id: number; role: number; displayName: string | null }> {
|
||||
const admin = await this.prisma.adminUser.findUnique({ where: { username } });
|
||||
// 管理员账号固定在生产库:否则切到开发库后没有账号能登进来,就永远切不回去了
|
||||
const admin = await this.prisma.main.adminUser.findUnique({ where: { username } });
|
||||
if (!admin || admin.status !== 1) throw AppError.unauthorized('账号或密码错误');
|
||||
if (!verifyPassword(password, admin.password_hash)) throw AppError.unauthorized('账号或密码错误');
|
||||
|
||||
await this.prisma.adminUser.update({ where: { id: admin.id }, data: { last_login_at: new Date() } });
|
||||
await this.prisma.main.adminUser.update({ where: { id: admin.id }, data: { last_login_at: new Date() } });
|
||||
return { id: admin.id, role: admin.role, displayName: admin.display_name };
|
||||
}
|
||||
|
||||
@@ -49,12 +73,12 @@ export class AdminService {
|
||||
* 避免在生产环境留下一个可被随意调用的建号后门。
|
||||
*/
|
||||
async bootstrapAdmin(username: string, password: string): Promise<{ created: boolean }> {
|
||||
const count = await this.prisma.adminUser.count();
|
||||
const count = await this.prisma.main.adminUser.count();
|
||||
if (count > 0) return { created: false };
|
||||
if (username.length < 3 || password.length < 8) {
|
||||
throw AppError.badRequest('用户名至少 3 位,密码至少 8 位');
|
||||
}
|
||||
await this.prisma.adminUser.create({
|
||||
await this.prisma.main.adminUser.create({
|
||||
data: { username, password_hash: hashPassword(password), display_name: '超级管理员', role: 1 },
|
||||
});
|
||||
return { created: true };
|
||||
@@ -62,14 +86,15 @@ export class AdminService {
|
||||
|
||||
// ── 看板 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async stats() {
|
||||
async stats(env: DataSourceEnv) {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const [users, courses, orders, delivered, refunded, revenue] = await Promise.all([
|
||||
this.prisma.user.count(),
|
||||
this.prisma.course.count({ where: { status: 1 } }),
|
||||
this.prisma.order.count(),
|
||||
this.prisma.order.count({ where: { status: OrderStatus.DELIVERED } }),
|
||||
this.prisma.order.count({ where: { status: OrderStatus.REFUNDED } }),
|
||||
this.prisma.order.aggregate({
|
||||
db.user.count(),
|
||||
db.course.count({ where: { status: 1 } }),
|
||||
db.order.count(),
|
||||
db.order.count({ where: { status: OrderStatus.DELIVERED } }),
|
||||
db.order.count({ where: { status: OrderStatus.REFUNDED } }),
|
||||
db.order.aggregate({
|
||||
where: { status: OrderStatus.DELIVERED },
|
||||
_sum: { amount_fen: true },
|
||||
}),
|
||||
@@ -84,27 +109,33 @@ export class AdminService {
|
||||
revenueFen: revenue._sum.amount_fen ?? 0,
|
||||
payEnabled: this.wxpay.enabled,
|
||||
payEnv: this.wxpay.env,
|
||||
/** 管理端当前正在查看的库 */
|
||||
dataEnv: env,
|
||||
/** 线上小程序当前读的库(由独立开关控制,与 dataEnv 无关) */
|
||||
miniProgramEnv: this.prisma.activeEnv,
|
||||
developAvailable: this.developAvailable,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async listUsers(page = 1, pageSize = 20, keyword = '') {
|
||||
async listUsers(env: DataSourceEnv, page = 1, pageSize = 20, keyword = '') {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const size = Math.min(Math.max(pageSize, 1), 100);
|
||||
const current = Math.max(page, 1);
|
||||
const where = keyword
|
||||
? { OR: [{ nickname: { contains: keyword } }, { openid: { contains: keyword } }] }
|
||||
: {};
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.user.findMany({
|
||||
const [items, total] = await db.$transaction([
|
||||
db.user.findMany({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
skip: (current - 1) * size,
|
||||
take: size,
|
||||
include: { _count: { select: { entitlements: true } } },
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
db.user.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -124,13 +155,13 @@ export class AdminService {
|
||||
};
|
||||
}
|
||||
|
||||
async updateUser(id: number, input: { nickname?: string; headicon_id?: number; status?: number }) {
|
||||
return this.prisma.user.update({ where: { id }, data: input });
|
||||
async updateUser(env: DataSourceEnv, id: number, input: { nickname?: string; headicon_id?: number; status?: number }) {
|
||||
return this.prisma.clientFor(env).user.update({ where: { id }, data: input });
|
||||
}
|
||||
|
||||
// ── 通用内容 CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
async listResource(name: string, page = 1, pageSize = 20, keyword = '', filter: Record<string, unknown> = {}) {
|
||||
async listResource(env: DataSourceEnv, name: string, page = 1, pageSize = 20, keyword = '', filter: Record<string, unknown> = {}) {
|
||||
const readonly = READONLY_RESOURCES[name];
|
||||
const def: ResourceDef | undefined = RESOURCES[name];
|
||||
if (!readonly && !def) throw AppError.notFound(`未知资源:${name}`);
|
||||
@@ -143,7 +174,7 @@ export class AdminService {
|
||||
...(keyword && searchField ? { [searchField]: { contains: keyword } } : {}),
|
||||
};
|
||||
|
||||
const d = this.delegate(readonly ? readonly.delegate : def!.delegate);
|
||||
const d = this.delegate(this.prisma.clientFor(env), readonly ? readonly.delegate : def!.delegate);
|
||||
const [items, total] = await Promise.all([
|
||||
d.findMany({ where, orderBy: readonly ? readonly.orderBy : def!.orderBy, skip: (current - 1) * size, take: size }),
|
||||
d.count({ where }),
|
||||
@@ -152,15 +183,17 @@ export class AdminService {
|
||||
return { items, total, page: current, pageSize: size };
|
||||
}
|
||||
|
||||
async createResource(name: string, payload: unknown) {
|
||||
async createResource(env: DataSourceEnv, name: string, payload: unknown) {
|
||||
const def = RESOURCES[name];
|
||||
if (!def) throw AppError.notFound(`资源不可创建:${name}`);
|
||||
const parsed = def.create.safeParse(payload);
|
||||
if (!parsed.success) throw AppError.badRequest(formatZodError(parsed.error));
|
||||
return this.delegate(def.delegate).create({ data: { ...def.defaults, ...(parsed.data as Record<string, unknown>) } });
|
||||
return this.delegate(this.prisma.clientFor(env), def.delegate).create({
|
||||
data: { ...def.defaults, ...(parsed.data as Record<string, unknown>) },
|
||||
});
|
||||
}
|
||||
|
||||
async updateResource(name: string, id: number, payload: unknown) {
|
||||
async updateResource(env: DataSourceEnv, name: string, id: number, payload: unknown) {
|
||||
const def = RESOURCES[name];
|
||||
if (!def) throw AppError.notFound(`资源不可修改:${name}`);
|
||||
|
||||
@@ -173,24 +206,24 @@ export class AdminService {
|
||||
for (const field of def.immutable ?? []) delete data[field];
|
||||
if (Object.keys(data).length === 0) throw AppError.badRequest('没有需要修改的内容');
|
||||
|
||||
return this.delegate(def.delegate).update({ where: { id }, data });
|
||||
return this.delegate(this.prisma.clientFor(env), def.delegate).update({ where: { id }, data });
|
||||
}
|
||||
|
||||
async deleteResource(name: string, id: number): Promise<{ deleted: true }> {
|
||||
async deleteResource(env: DataSourceEnv, name: string, id: number): Promise<{ deleted: true }> {
|
||||
const def = RESOURCES[name];
|
||||
if (!def) throw AppError.notFound(`资源不可删除:${name}`);
|
||||
await this.delegate(def.delegate).delete({ where: { id } });
|
||||
await this.delegate(this.prisma.clientFor(env), def.delegate).delete({ where: { id } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// ── 配置 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async listSettings() {
|
||||
return this.prisma.setting.findMany({ orderBy: { id: 'asc' } });
|
||||
async listSettings(env: DataSourceEnv) {
|
||||
return this.prisma.clientFor(env).setting.findMany({ orderBy: { id: 'asc' } });
|
||||
}
|
||||
|
||||
async upsertSetting(key: string, value: string, remark?: string) {
|
||||
return this.prisma.setting.upsert({
|
||||
async upsertSetting(env: DataSourceEnv, key: string, value: string, remark?: string) {
|
||||
return this.prisma.clientFor(env).setting.upsert({
|
||||
where: { key },
|
||||
create: { key, value, remark: remark ?? null },
|
||||
update: { value, remark: remark ?? undefined },
|
||||
@@ -199,20 +232,21 @@ export class AdminService {
|
||||
|
||||
// ── 订单与权益 ─────────────────────────────────────────────────────────
|
||||
|
||||
async listOrders(page = 1, pageSize = 20, status?: number) {
|
||||
async listOrders(env: DataSourceEnv, page = 1, pageSize = 20, status?: number) {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const size = Math.min(Math.max(pageSize, 1), 100);
|
||||
const current = Math.max(page, 1);
|
||||
const where = status === undefined ? {} : { status };
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.order.findMany({
|
||||
const [items, total] = await db.$transaction([
|
||||
db.order.findMany({
|
||||
where,
|
||||
orderBy: { created_at: 'desc' },
|
||||
skip: (current - 1) * size,
|
||||
take: size,
|
||||
include: { user: { select: { nickname: true, openid: true } }, course: { select: { title: true } } },
|
||||
}),
|
||||
this.prisma.order.count({ where }),
|
||||
db.order.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -237,8 +271,13 @@ export class AdminService {
|
||||
}
|
||||
|
||||
/** 发起退款(仅非 iOS 终端)。退款成功由退款推送回写订单状态。 */
|
||||
async refundOrder(orderId: number, reason: string) {
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { user: true } });
|
||||
async refundOrder(env: DataSourceEnv, orderId: number, reason: string) {
|
||||
// 开发库的订单在微信侧不存在,发退款请求必然失败,直接拦住
|
||||
if (env === 'develop') {
|
||||
throw AppError.badRequest('当前数据源是开发库,订单在微信侧不存在,无法退款');
|
||||
}
|
||||
|
||||
const order = await this.prisma.clientFor(env).order.findUnique({ where: { id: orderId }, include: { user: true } });
|
||||
if (!order) throw AppError.notFound('订单不存在');
|
||||
if (order.status !== OrderStatus.DELIVERED) throw AppError.conflict('只有已开通的订单可以退款');
|
||||
|
||||
@@ -252,8 +291,8 @@ export class AdminService {
|
||||
return { submitted: true, providerResponse: result };
|
||||
}
|
||||
|
||||
async listEntitlements(userId: number) {
|
||||
const rows = await this.prisma.entitlement.findMany({
|
||||
async listEntitlements(env: DataSourceEnv, userId: number) {
|
||||
const rows = await this.prisma.clientFor(env).entitlement.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { granted_at: 'desc' },
|
||||
include: { course: { select: { title: true, code: true } } },
|
||||
@@ -271,15 +310,19 @@ export class AdminService {
|
||||
}
|
||||
|
||||
/** 手动发放权益(赠课、补发、老用户补救)。 */
|
||||
async grantEntitlement(input: { userId: number; courseId: number; expireAt?: Date | null; note?: string }) {
|
||||
async grantEntitlement(
|
||||
env: DataSourceEnv,
|
||||
input: { userId: number; courseId: number; expireAt?: Date | null; note?: string },
|
||||
) {
|
||||
const db = this.prisma.clientFor(env);
|
||||
const [user, course] = await Promise.all([
|
||||
this.prisma.user.findUnique({ where: { id: input.userId } }),
|
||||
this.prisma.course.findUnique({ where: { id: input.courseId } }),
|
||||
db.user.findUnique({ where: { id: input.userId } }),
|
||||
db.course.findUnique({ where: { id: input.courseId } }),
|
||||
]);
|
||||
if (!user) throw AppError.notFound('用户不存在');
|
||||
if (!course) throw AppError.notFound('课程不存在');
|
||||
|
||||
return this.prisma.entitlement.upsert({
|
||||
return db.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: input.userId, course_id: input.courseId } },
|
||||
create: {
|
||||
user_id: input.userId,
|
||||
@@ -296,8 +339,8 @@ export class AdminService {
|
||||
});
|
||||
}
|
||||
|
||||
async revokeEntitlement(userId: number, courseId: number): Promise<{ deleted: true }> {
|
||||
await this.prisma.entitlement.delete({
|
||||
async revokeEntitlement(env: DataSourceEnv, userId: number, courseId: number): Promise<{ deleted: true }> {
|
||||
await this.prisma.clientFor(env).entitlement.delete({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
});
|
||||
return { deleted: true };
|
||||
|
||||
@@ -27,6 +27,17 @@ export class AuthController {
|
||||
return this.auth.refresh(parsed.data.refreshToken);
|
||||
}
|
||||
|
||||
/**
|
||||
* 用户主动登录。
|
||||
*
|
||||
* wx-login 是静默登录,只建立身份、不动「已登录」标记;
|
||||
* 用户在小程序里点「一键登录」才会走这里,这一位同时也是购买的前置条件。
|
||||
*/
|
||||
@Post('login')
|
||||
activeLogin(@CurrentUser() user: AuthPrincipal): Promise<{ isLogin: number }> {
|
||||
return this.auth.setActiveLogin(user.id);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
logout(@CurrentUser() user: AuthPrincipal): Promise<null> {
|
||||
return this.auth.logOut(user.id);
|
||||
|
||||
@@ -27,6 +27,8 @@ export interface LoginResult {
|
||||
headiconId: number;
|
||||
headiconUrl: string | null;
|
||||
isNew: boolean;
|
||||
/** 1 = 用户主动使用过登录功能;静默登录不会改这一位 */
|
||||
isLogin: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -43,18 +45,18 @@ export class AuthService {
|
||||
if (!code) throw AppError.badRequest('缺少 code');
|
||||
|
||||
const session = await this.wechat.code2Session(code);
|
||||
const existing = await this.prisma.user.findUnique({ where: { openid: session.openid } });
|
||||
const existing = await this.prisma.active.user.findUnique({ where: { openid: session.openid } });
|
||||
|
||||
// sessionKey 必须托管:虚拟支付的用户态签名依赖它,且只在登录时能拿到
|
||||
const now = new Date();
|
||||
const sessionKeyEnc = encryptSecret(session.sessionKey, this.cfg.SESSION_KEY_ENC_SECRET);
|
||||
|
||||
const user = existing
|
||||
? await this.prisma.user.update({
|
||||
? await this.prisma.active.user.update({
|
||||
where: { id: existing.id },
|
||||
data: { wx_session_key: sessionKeyEnc, session_key_updated_at: now, last_login_at: now },
|
||||
})
|
||||
: await this.prisma.user.create({
|
||||
: await this.prisma.active.user.create({
|
||||
data: {
|
||||
openid: session.openid,
|
||||
wx_session_key: sessionKeyEnc,
|
||||
@@ -77,6 +79,7 @@ export class AuthService {
|
||||
headiconUrl:
|
||||
user.headicon_id > 0 ? staticUrls.headicon(this.cfg.STATIC_BASE_URL, user.headicon_id) : null,
|
||||
isNew: !existing,
|
||||
isLogin: user.is_login,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -93,16 +96,31 @@ export class AuthService {
|
||||
} catch {
|
||||
throw AppError.unauthorized('登录已过期,请重新进入小程序');
|
||||
}
|
||||
const user = await this.prisma.user.findUnique({ where: { id: sub } });
|
||||
const user = await this.prisma.active.user.findUnique({ where: { id: sub } });
|
||||
if (!user || user.status !== 1) throw AppError.unauthorized();
|
||||
return { accessToken: await this.sign(user.id, 'user', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET) };
|
||||
}
|
||||
|
||||
/** 退出:清掉服务端托管的 sessionKey,前端丢弃 token。 */
|
||||
async logOut(userId: number): Promise<null> {
|
||||
await this.prisma.user.updateMany({
|
||||
/**
|
||||
* 用户主动登录:把「已登录」标记置 1。
|
||||
*
|
||||
* 只有用户在小程序里亲自点过登录才会走这里。这一位代表用户主动使用过登录功能
|
||||
* (也就是认可了这个小程序),跟技术上有没有 token 是两回事——静默登录永远
|
||||
* 不改它。购买的前置条件就是这一位为 1。
|
||||
*/
|
||||
async setActiveLogin(userId: number): Promise<{ isLogin: number }> {
|
||||
await this.prisma.active.user.updateMany({
|
||||
where: { id: userId },
|
||||
data: { wx_session_key: null, session_key_updated_at: null },
|
||||
data: { is_login: 1 },
|
||||
});
|
||||
return { isLogin: 1 };
|
||||
}
|
||||
|
||||
/** 退出:清掉服务端托管的 sessionKey,并把「已登录」标记置 0。 */
|
||||
async logOut(userId: number): Promise<null> {
|
||||
await this.prisma.active.user.updateMany({
|
||||
where: { id: userId },
|
||||
data: { is_login: 0, wx_session_key: null, session_key_updated_at: null },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export class CoursesService {
|
||||
}
|
||||
|
||||
private async ownedCourseIds(userId: number): Promise<Set<number>> {
|
||||
const rows = await this.prisma.entitlement.findMany({
|
||||
const rows = await this.prisma.active.entitlement.findMany({
|
||||
where: { user_id: userId },
|
||||
select: { course_id: true },
|
||||
});
|
||||
@@ -28,7 +28,7 @@ export class CoursesService {
|
||||
/** 课程列表。含未购买课程,供小程序做购买入口。 */
|
||||
async list(userId: number) {
|
||||
const [courses, owned] = await Promise.all([
|
||||
this.prisma.course.findMany({
|
||||
this.prisma.active.course.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
include: { _count: { select: { lessons: true } } },
|
||||
@@ -49,10 +49,10 @@ export class CoursesService {
|
||||
}
|
||||
|
||||
async detail(userId: number, code: string) {
|
||||
const course = await this.prisma.course.findUnique({ where: { code } });
|
||||
const course = await this.prisma.active.course.findUnique({ where: { code } });
|
||||
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
||||
|
||||
const owned = await this.prisma.entitlement.findUnique({
|
||||
const owned = await this.prisma.active.entitlement.findUnique({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
||||
});
|
||||
|
||||
@@ -71,7 +71,7 @@ export class CoursesService {
|
||||
async lessons(userId: number, code: string) {
|
||||
const course = await this.requireEntitlement(userId, code);
|
||||
|
||||
const rows = await this.prisma.lesson.findMany({
|
||||
const rows = await this.prisma.active.lesson.findMany({
|
||||
where: { course_id: course.id },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
@@ -92,7 +92,7 @@ export class CoursesService {
|
||||
async playUrl(userId: number, code: string, chapterId: string, playId: string) {
|
||||
const course = await this.requireEntitlement(userId, code);
|
||||
|
||||
const lesson = await this.prisma.lesson.findFirst({
|
||||
const lesson = await this.prisma.active.lesson.findFirst({
|
||||
where: { course_id: course.id, chapter_id: chapterId, play_id: playId },
|
||||
});
|
||||
if (!lesson) throw AppError.notFound('课时不存在');
|
||||
@@ -107,7 +107,7 @@ export class CoursesService {
|
||||
|
||||
/** 免费试听课。公开内容,直接给静态地址,不签名。 */
|
||||
async freeLessons(seriesNo?: number) {
|
||||
const rows = await this.prisma.freeLesson.findMany({
|
||||
const rows = await this.prisma.active.freeLesson.findMany({
|
||||
where: seriesNo === undefined ? undefined : { series_no: seriesNo },
|
||||
orderBy: [{ series_no: 'asc' }, { sort_order: 'asc' }],
|
||||
});
|
||||
@@ -128,11 +128,11 @@ export class CoursesService {
|
||||
|
||||
/** 取课程并校验权益,是唯一的鉴权入口。 */
|
||||
private async requireEntitlement(userId: number, code: string) {
|
||||
const course = await this.prisma.course.findUnique({ where: { code } });
|
||||
const course = await this.prisma.active.course.findUnique({ where: { code } });
|
||||
if (!course) throw AppError.notFound('课程不存在');
|
||||
if (course.status !== 1) throw AppError.offline();
|
||||
|
||||
const owned = await this.prisma.entitlement.findUnique({
|
||||
const owned = await this.prisma.active.entitlement.findUnique({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
||||
});
|
||||
if (!owned) throw AppError.noEntitlement();
|
||||
|
||||
@@ -18,14 +18,14 @@ export class HomeService {
|
||||
|
||||
/** 站点配置,返回 key → value 的扁平对象。 */
|
||||
async getConfig(): Promise<Record<string, string>> {
|
||||
const rows = await this.prisma.setting.findMany();
|
||||
const rows = await this.prisma.active.setting.findMany();
|
||||
const map: Record<string, string> = {};
|
||||
for (const row of rows) map[row.key] = row.value ?? '';
|
||||
return map;
|
||||
}
|
||||
|
||||
async getBanners() {
|
||||
const rows = await this.prisma.banner.findMany({
|
||||
const rows = await this.prisma.active.banner.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
@@ -37,7 +37,7 @@ export class HomeService {
|
||||
}
|
||||
|
||||
async getHomeCourses() {
|
||||
const rows = await this.prisma.homeCourse.findMany({
|
||||
const rows = await this.prisma.active.homeCourse.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
include: { course: { select: { code: true, title: true } } },
|
||||
@@ -56,7 +56,7 @@ export class HomeService {
|
||||
}
|
||||
|
||||
async getHomeFreeCourses() {
|
||||
const rows = await this.prisma.homeFreeCourse.findMany({
|
||||
const rows = await this.prisma.active.homeFreeCourse.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
@@ -74,15 +74,15 @@ export class HomeService {
|
||||
const current = Math.max(page, 1);
|
||||
const where = { status: 1 };
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.article.findMany({
|
||||
const [items, total] = await this.prisma.active.$transaction([
|
||||
this.prisma.active.article.findMany({
|
||||
where,
|
||||
// 用 published_at + id 排序,替代旧库对 varchar 网页id 的字符串排序
|
||||
orderBy: [{ published_at: 'desc' }, { id: 'desc' }],
|
||||
skip: (current - 1) * size,
|
||||
take: size,
|
||||
}),
|
||||
this.prisma.article.count({ where }),
|
||||
this.prisma.active.article.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -101,7 +101,7 @@ export class HomeService {
|
||||
}
|
||||
|
||||
async getArticle(id: number) {
|
||||
const row = await this.prisma.article.findFirst({ where: { id, status: 1 } });
|
||||
const row = await this.prisma.active.article.findFirst({ where: { id, status: 1 } });
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
@@ -116,7 +116,7 @@ export class HomeService {
|
||||
|
||||
async getTips(): Promise<{ content: string; title: string | null }> {
|
||||
// 旧前端读的是 tip_key=0 那条
|
||||
const row = await this.prisma.courseTip.findUnique({ where: { tip_key: 0 } });
|
||||
const row = await this.prisma.active.courseTip.findUnique({ where: { tip_key: 0 } });
|
||||
return { content: row?.content ?? '', title: row?.title ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,15 +36,25 @@ export class OrdersService {
|
||||
throw AppError.badRequest('购买数量不合法');
|
||||
}
|
||||
|
||||
const course = await this.prisma.course.findUnique({ where: { code: courseCode } });
|
||||
// 购买的硬性前置条件:用户必须主动使用过登录功能。
|
||||
// 静默登录只建立身份,不算「登录」,所以这里查的是数据库里的标记而不是 token。
|
||||
const buyer = await this.prisma.active.user.findUnique({
|
||||
where: { id: userId },
|
||||
select: { is_login: true },
|
||||
});
|
||||
if (!buyer || buyer.is_login !== 1) {
|
||||
throw AppError.forbidden('请先使用微信登录后再购买');
|
||||
}
|
||||
|
||||
const course = await this.prisma.active.course.findUnique({ where: { code: courseCode } });
|
||||
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
||||
|
||||
const owned = await this.prisma.entitlement.findUnique({
|
||||
const owned = await this.prisma.active.entitlement.findUnique({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
||||
});
|
||||
if (owned) throw AppError.conflict('你已拥有该课程');
|
||||
|
||||
const product = await this.prisma.product.findFirst({
|
||||
const product = await this.prisma.active.product.findFirst({
|
||||
where: { course_id: course.id, status: 1 },
|
||||
});
|
||||
if (!product) throw AppError.notFound('该课程尚未配置支付商品');
|
||||
@@ -52,7 +62,7 @@ export class OrdersService {
|
||||
const outTradeNo = this.newOutTradeNo();
|
||||
const amountFen = product.price_fen * quantity;
|
||||
|
||||
await this.prisma.order.create({
|
||||
await this.prisma.active.order.create({
|
||||
data: {
|
||||
out_trade_no: outTradeNo,
|
||||
user_id: userId,
|
||||
@@ -91,7 +101,7 @@ export class OrdersService {
|
||||
|
||||
/** 查询自己的订单状态。前端支付返回后用它确认是否已发货。 */
|
||||
async status(userId: number, outTradeNo: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
const order = await this.prisma.active.order.findUnique({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
include: { course: { select: { code: true, title: true } } },
|
||||
});
|
||||
@@ -113,7 +123,7 @@ export class OrdersService {
|
||||
|
||||
/** 我的订单列表。 */
|
||||
async list(userId: number) {
|
||||
const rows = await this.prisma.order.findMany({
|
||||
const rows = await this.prisma.active.order.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 100,
|
||||
|
||||
@@ -58,7 +58,7 @@ export class PaymentsService {
|
||||
const openid = notify.OpenId ?? '';
|
||||
if (!outTradeNo || !wxOrderId) throw new Error('推送缺少 OutTradeNo 或 MchOrderNo');
|
||||
|
||||
const order = await this.prisma.order.findUnique({
|
||||
const order = await this.prisma.active.order.findUnique({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
include: { user: true },
|
||||
});
|
||||
@@ -89,7 +89,7 @@ export class PaymentsService {
|
||||
const parsed = this.parser.parse(rawXml) as { xml?: { OutTradeNo?: string } };
|
||||
const outTradeNo = parsed.xml?.OutTradeNo ?? '';
|
||||
if (outTradeNo) {
|
||||
await this.prisma.order.updateMany({
|
||||
await this.prisma.active.order.updateMany({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
|
||||
});
|
||||
@@ -109,7 +109,7 @@ export class PaymentsService {
|
||||
async reconcilePendingOrders(): Promise<number> {
|
||||
if (!this.wxpay.enabled) return 0;
|
||||
|
||||
const pending = await this.prisma.order.findMany({
|
||||
const pending = await this.prisma.active.order.findMany({
|
||||
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
|
||||
include: { user: true },
|
||||
orderBy: { created_at: 'asc' },
|
||||
@@ -144,7 +144,7 @@ export class PaymentsService {
|
||||
|
||||
/** 取用户 sessionKey(明文)。不存在时提示重新登录。 */
|
||||
async requireSessionKey(userId: number): Promise<string> {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const user = await this.prisma.active.user.findUnique({ where: { id: userId } });
|
||||
if (!user?.wx_session_key) throw AppError.unauthorized('请重新进入小程序后再试');
|
||||
const key = decryptSecret(user.wx_session_key, this.cfg.SESSION_KEY_ENC_SECRET);
|
||||
if (!key) throw new AppError(ErrorCode.INTERNAL, 'sessionKey 解密失败', 500);
|
||||
@@ -160,8 +160,8 @@ export class PaymentsService {
|
||||
wxOrderId: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.order.update({
|
||||
await this.prisma.active.$transaction([
|
||||
this.prisma.active.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: OrderStatus.DELIVERED,
|
||||
@@ -170,7 +170,7 @@ export class PaymentsService {
|
||||
delivered_at: now,
|
||||
},
|
||||
}),
|
||||
this.prisma.entitlement.upsert({
|
||||
this.prisma.active.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
create: {
|
||||
user_id: userId,
|
||||
@@ -186,12 +186,12 @@ export class PaymentsService {
|
||||
}
|
||||
|
||||
private async logNotify(rawXml: string): Promise<number> {
|
||||
const row = await this.prisma.paymentNotifyLog.create({ data: { raw_body: rawXml } });
|
||||
const row = await this.prisma.active.paymentNotifyLog.create({ data: { raw_body: rawXml } });
|
||||
return row.id;
|
||||
}
|
||||
|
||||
private async markNotify(id: number, processed: number, errMsg: string): Promise<void> {
|
||||
await this.prisma.paymentNotifyLog.update({
|
||||
await this.prisma.active.paymentNotifyLog.update({
|
||||
where: { id },
|
||||
data: { processed, err_msg: errMsg.slice(0, 255) },
|
||||
});
|
||||
|
||||
@@ -12,7 +12,7 @@ export class UsersService {
|
||||
) {}
|
||||
|
||||
async me(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
const user = await this.prisma.active.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.status !== 1) throw AppError.unauthorized();
|
||||
|
||||
const base = this.cfg.STATIC_BASE_URL;
|
||||
@@ -24,6 +24,8 @@ export class UsersService {
|
||||
headiconUrl: user.headicon_id > 0 ? staticUrls.headicon(base, user.headicon_id) : null,
|
||||
registeredAt: user.registered_at,
|
||||
lastLoginAt: user.last_login_at,
|
||||
/** 1 = 用户主动使用过登录功能;购买前置条件 */
|
||||
isLogin: user.is_login,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -51,13 +53,13 @@ export class UsersService {
|
||||
|
||||
if (Object.keys(data).length === 0) throw AppError.badRequest('没有需要修改的内容');
|
||||
|
||||
await this.prisma.user.update({ where: { id: userId }, data });
|
||||
await this.prisma.active.user.update({ where: { id: userId }, data });
|
||||
return this.me(userId);
|
||||
}
|
||||
|
||||
/** 我的课程。 */
|
||||
async entitlements(userId: number) {
|
||||
const rows = await this.prisma.entitlement.findMany({
|
||||
const rows = await this.prisma.active.entitlement.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { granted_at: 'desc' },
|
||||
include: { course: true },
|
||||
@@ -76,10 +78,10 @@ export class UsersService {
|
||||
}));
|
||||
}
|
||||
|
||||
/** 头像总数取自 settings,缺省 50。 */
|
||||
/** 头像总数取自 settings。没配就是 0,不兜底。 */
|
||||
private async getHeadiconMax(): Promise<number> {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: 'profile.headicon_count' } });
|
||||
const row = await this.prisma.active.setting.findUnique({ where: { key: 'profile.headicon_count' } });
|
||||
const n = Number(row?.value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 50;
|
||||
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user