大修改

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

+4
View File
@@ -3,6 +3,10 @@
# 宝塔默认账号没有该权限,生产环境请用 prisma migrate deploy。
DATABASE_URL="mysql://用户名:密码@127.0.0.1:3306/yelaymaths"
# 开发库连接串。配置后管理后台会出现「生产库 / 开发库」切换按钮;留空则按钮置灰。
# 只影响管理后台的数据查看,小程序接口永远走 DATABASE_URL。
DATABASE_URL_DEVELOP="mysql://用户名:密码@127.0.0.1:3306/yelaymaths_develop"
# 旧库(只读,仅迁移脚本使用)
LEGACY_DATABASE_URL="mysql://用户名:密码@127.0.0.1:3306/api_yeomai_site"
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE `users` ADD COLUMN `is_login` TINYINT NOT NULL DEFAULT 0;
+3
View File
@@ -30,6 +30,9 @@ model User {
session_key_updated_at DateTime? @map("session_key_updated_at")
/// 1 正常 / 0 封禁
status Int @default(1) @db.TinyInt
/// 1 = 用户主动使用过登录功能(即认可/接受这个小程序),0 = 未登录或已退出。
/// 和静默登录无关:静默登录只建立身份,不改这一位。购买前必须为 1。
is_login Int @default(0) @map("is_login") @db.TinyInt
registered_at DateTime @map("registered_at")
last_login_at DateTime? @map("last_login_at")
created_at DateTime @default(now()) @map("created_at")
+23
View File
@@ -0,0 +1,23 @@
import { createParamDecorator, type ExecutionContext } from '@nestjs/common';
import type { Request } from 'express';
/** 管理后台用来选择「查看哪个库」的请求头。只影响管理端自己的读写。 */
export const ADMIN_ENV_HEADER = 'x-yelay-env';
export type DataSourceEnv = 'production' | 'develop';
/**
* 从请求头取出管理端当前查看的数据源。
*
* 这个状态存在浏览器本地(localStorage),不落库也不写文件——
* 因为「看哪个库」是每个管理员各自的视角,不该影响别人,更不该影响线上小程序。
*
* 默认 production,任何无法识别的值都按 production 处理(失败时偏向安全的一侧)。
*/
export const AdminEnvParam = createParamDecorator((_data: unknown, ctx: ExecutionContext): DataSourceEnv => {
const req = ctx.switchToHttp().getRequest<Request>();
const raw = req.headers[ADMIN_ENV_HEADER];
const value = Array.isArray(raw) ? raw[0] : raw;
return value === 'develop' ? 'develop' : 'production';
});
+6
View File
@@ -17,6 +17,12 @@ const envSchema = z.object({
DATABASE_URL: z.string().min(1, '缺少数据库连接串'),
/**
* 开发库连接串。留空则管理后台的多库切换不可用。
* 仅在管理后台按请求头切换数据源时使用,小程序接口永远走 DATABASE_URL。
*/
DATABASE_URL_DEVELOP: z.string().default(''),
/** 对外域名 */
API_BASE_URL: z.string().url(),
STATIC_BASE_URL: z.string().url(),
+2 -1
View File
@@ -12,7 +12,8 @@ export class HealthController {
async health() {
let db = 'ok';
try {
await this.prisma.$queryRaw`SELECT 1`;
// 固定查生产库:探活关心的是主库是否可用
await this.prisma.main.$queryRaw`SELECT 1`;
} catch {
db = 'down';
}
@@ -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;
}
}
+127 -8
View File
@@ -1,21 +1,140 @@
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
import { Inject, Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';
import type { DataSourceEnv } from '../common/admin-env';
import { APP_CONFIG, type AppConfig } from '../config/config';
/** 当前数据源开关存放的配置键。 */
export const DATA_SOURCE_SETTING_KEY = 'system.active_datasource';
export type { DataSourceEnv };
/** 开关同步间隔。改完最多 5 秒在所有实例生效。 */
const REFRESH_INTERVAL_MS = 5000;
/**
* 数据库连接管理。**刻意不继承 PrismaClient,而是持有连接实例。**
*
* 原因(踩过一次,会导致所有接口 500):
* `PrismaClient` 的构造函数返回的是 **Proxy**,模型委托(user / setting / order …)
* 只存在于那个 Proxy 上。若写成 `class PrismaService extends PrismaClient`,
* 子类方法里的 `this` 指向的是**原始对象**,`return this` 拿到的对象上没有模型委托,
* 于是 `this.prisma.active.setting` 变成 undefined,接口直接抛
* `Cannot read properties of undefined (reading 'findMany')`。
*
* `new PrismaClient()` 出来的才是带委托的 Proxy,所以这里把它存成字段返回。
*
* 数据源开关存在**生产库的 settings 表**——这个开关本身不能存在被切换的那个库中,
* 否则切过去之后就再也读不到它了。
*/
@Injectable()
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
export class PrismaService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PrismaService.name);
async onModuleInit(): Promise<void> {
// 延迟连接:即使数据库暂时不可用也允许进程启动,由具体请求暴露问题
/** 生产库连接(也是管理员账号与开关本身的存放处)。 */
readonly main: PrismaClient;
/** 开发库连接;未配置 DATABASE_URL_DEVELOP 时为 null。 */
readonly dev: PrismaClient | null;
private current: DataSourceEnv = 'production';
private timer: NodeJS.Timeout | null = null;
constructor(@Inject(APP_CONFIG) cfg: AppConfig) {
this.main = new PrismaClient();
this.dev = cfg.DATABASE_URL_DEVELOP
? new PrismaClient({ datasources: { db: { url: cfg.DATABASE_URL_DEVELOP } } })
: null;
}
/** 是否配置了开发库。没配置时小程序数据源开关不可用。 */
get hasDev(): boolean {
return this.dev !== null;
}
get activeEnv(): DataSourceEnv {
return this.current;
}
/**
* 小程序当前使用的连接,由全局开关决定。
* 小程序侧业务代码统一用 `this.prisma.active.xxx`。
*/
get active(): PrismaClient {
return this.current === 'develop' && this.dev ? this.dev : this.main;
}
/**
* 按指定数据源取连接。管理后台用它实现「各看各的」,
* 与全局开关互不影响。
*/
clientFor(env: DataSourceEnv): PrismaClient {
if (env === 'develop') {
if (!this.dev) throw new Error('服务端未配置开发库(DATABASE_URL_DEVELOP)');
return this.dev;
}
return this.main;
}
/**
* 切换**小程序**的数据源。写入生产库的开关,并立即在本进程生效。
* 管理后台的「查看哪个库」不走这里,那是每个人各自的视角。
*/
async setMiniProgramEnv(env: DataSourceEnv): Promise<DataSourceEnv> {
if (env === 'develop' && !this.dev) {
throw new Error('服务端未配置开发库(DATABASE_URL_DEVELOP)');
}
await this.main.setting.upsert({
where: { key: DATA_SOURCE_SETTING_KEY },
create: { key: DATA_SOURCE_SETTING_KEY, value: env, remark: '小程序当前使用的数据源' },
update: { value: env },
});
this.current = env;
this.logger.log(`小程序数据源已切换为 ${env}`);
return env;
}
/** 从生产库读取开关。读失败时保持当前值,避免数据库抖动导致意外切库。 */
private async refreshActiveEnv(): Promise<void> {
try {
await this.$connect();
} catch (err) {
this.logger.error(`数据库连接失败:${err instanceof Error ? err.message : String(err)}`);
const row = await this.main.setting.findUnique({ where: { key: DATA_SOURCE_SETTING_KEY } });
const next: DataSourceEnv = row?.value === 'develop' && this.dev ? 'develop' : 'production';
if (next !== this.current) {
this.logger.log(`小程序数据源变更:${this.current} → ${next}`);
this.current = next;
}
} catch {
// 保持现状
}
}
async onModuleInit(): Promise<void> {
// 延迟连接:数据库暂时不可用也允许进程启动,由具体请求暴露问题
try {
await this.main.$connect();
} catch (err) {
this.logger.error(`主库连接失败:${err instanceof Error ? err.message : String(err)}`);
}
if (this.dev) {
try {
await this.dev.$connect();
this.logger.log('开发库已连接,可切换小程序数据源');
} catch (err) {
this.logger.error(`开发库连接失败:${err instanceof Error ? err.message : String(err)}`);
}
}
await this.refreshActiveEnv();
this.logger.log(`小程序当前数据源:${this.current}`);
this.timer = setInterval(() => void this.refreshActiveEnv(), REFRESH_INTERVAL_MS);
this.timer.unref();
}
async onModuleDestroy(): Promise<void> {
await this.$disconnect();
if (this.timer) clearInterval(this.timer);
await this.main.$disconnect();
if (this.dev) await this.dev.$disconnect();
}
}
+6 -1
View File
@@ -115,7 +115,12 @@ export function parseTupleList(payload: string): SqlValue[][] {
/** 取出某张表全部 INSERT 行。表不存在时返回空数组。 */
export function readTable(sql: string, table: string): SqlValue[][] {
const re = new RegExp('INSERT INTO `' + table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '` VALUES (.*?);\\n', 'gs');
// 结尾同时兼容 LF 与 CRLF:Windows 上导出的 dump 是 CRLF,
// 只写 \n 会全部匹配失败,表现为「所有表都是空数组」这种静默错误。
const re = new RegExp(
'INSERT INTO `' + table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '` VALUES (.*?);(?:\\r?\\n|$)',
'gs',
);
const rows: SqlValue[][] = [];
for (const m of sql.matchAll(re)) {
rows.push(...parseTupleList(m[1]!));
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest';
import { buildDataset } from './build';
import { DEFAULT_DUMP_PATH, loadLegacyDump } from './load';
import { readTable } from './parser';
import {
HOME_COURSE_TITLE_MAP,
SETTINGS_KEY_MAP,
@@ -153,3 +154,21 @@ describe('buildDataset(真实旧库数据)', () => {
expect(all).toContain('https://data.example.com');
});
});
describe('readTable 换行兼容', () => {
const lf = "INSERT INTO `t` VALUES (1,'a');\n";
const crlf = "INSERT INTO `t` VALUES (1,'a');\r\nINSERT INTO `t` VALUES (2,'b');\r\n";
it('LF 换行的 dump 能解析', () => {
expect(readTable(lf, 't')).toEqual([[1, 'a']]);
});
// 回归:只匹配 \n 时,Windows 导出的 CRLF dump 会全部读成空数组,
// 迁移会静默产出空数据(真实踩过)
it('CRLF 换行的 dump 能解析(Windows 导出的格式)', () => {
expect(readTable(crlf, 't')).toEqual([
[1, 'a'],
[2, 'b'],
]);
});
});
+3
View File
@@ -78,6 +78,9 @@ export function transformUser(row: Row) {
nickname: strOrNull(row.用户名)?.slice(0, 32) ?? null,
headicon_id: int(row.HeadiconID),
status: 1,
// 旧表「是否登录」:'1' 表示用户主动使用过登录功能。新版静默登录不会改这一位,
// 所以迁移时必须原样搬过来,否则老用户会被当成没登录过。
is_login: str(row.是否登录) === '1' ? 1 : 0,
registered_at: registeredAt ?? new Date(0),
last_login_at: parseLegacyDate(row.最后登录日期),
};