大修改

This commit is contained in:
Yeomai committed 2026-09-30 03:38:10 +08:00
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;
}
}