增加广播栏

This commit is contained in:
tianmo committed 2026-09-30 16:47:27 +08:00
1 parent 85b0e70907
commit d8786eb28a
21 files changed
+516 -59

No files matched your search

+7
View File
@@ -54,3 +54,10 @@ WX_PAY_APPKEY=""
WX_PAY_APPKEY_SANDBOX=""
# 0 现网 / 1 沙箱
WX_PAY_ENV=0
# 开发库(后台把小程序数据源切到 develop 时)用的支付配置
# 留空时 = 同一 OfferID + 沙箱 AppKey + 沙箱环境,开发环境天然不收真钱
WX_OFFER_ID_DEVELOP=""
WX_PAY_APPKEY_DEVELOP=""
# 0 现网 / 1 沙箱
WX_PAY_ENV_DEVELOP=1
@@ -0,0 +1,4 @@
-- settings.value 从 VARCHAR(255) 改成 TEXT。
-- 起因:首页广播的文案长度不固定,255 个字符不够放。
-- 只放宽长度,不改变可空性,已有数据原样保留。
ALTER TABLE `settings` MODIFY `value` TEXT NULL;
+2 -1
View File
@@ -201,7 +201,8 @@ model CourseTip {
model Setting {
id Int @id @default(autoincrement()) @db.UnsignedInt
key String @unique @db.VarChar(64)
value String? @db.VarChar(255)
/// TEXT:首页广播这类长文案要放进来,255 不够用
value String? @db.Text
remark String? @db.VarChar(255)
@@map("settings")
+34
View File
@@ -26,6 +26,18 @@ export function getRequestId(req: Request): string {
return (req as Request & { requestId?: string }).requestId ?? randomUUID();
}
/**
* 取 Prisma 的错误码(形如 P2003)。
* 不 import Prisma 的错误类,避免为一次判断把整包类型拉进来。
*/
function prismaErrorCode(error: unknown): string | null {
if (error && typeof error === 'object' && 'code' in error) {
const value = (error as { code?: unknown }).code;
if (typeof value === 'string' && /^P\d{4}$/.test(value)) return value;
}
return null;
}
/**
* 为每个请求分配 requestId,并统一包装成功响应。
*
@@ -79,6 +91,28 @@ export class AllExceptionsFilter implements ExceptionFilter {
: ((body as { message?: string | string[] }).message as string) ?? exception.message;
if (Array.isArray(message)) message = message.join(';');
code = status === 401 ? ErrorCode.UNAUTHORIZED : status === 403 ? ErrorCode.FORBIDDEN : ErrorCode.BAD_REQUEST;
} else if (prismaErrorCode(exception)) {
// Prisma 的原始报错又长又难懂(还常被截断),翻成人话再返回。
// 最常见的是删除被外键挡住——以前这里直接返回 500「服务端异常」,看起来就像"点了没反应"。
const prismaCode = prismaErrorCode(exception)!;
if (prismaCode === 'P2003') {
status = 409;
code = ErrorCode.BAD_REQUEST;
message = '该记录被其他数据引用,不能直接删除。请先清理关联数据,或改用「停用」。';
} else if (prismaCode === 'P2025') {
status = 404;
code = ErrorCode.BAD_REQUEST;
message = '记录不存在,可能已被删除';
} else if (prismaCode === 'P2002') {
status = 409;
code = ErrorCode.BAD_REQUEST;
message = '该值已存在,请换一个';
}
this.logger.warn(
`数据库约束错误 [${requestId}] ${req.method} ${req.url}: ${prismaCode} ${
exception instanceof Error ? exception.message.split('\n')[0] : ''
}`,
);
} else {
this.logger.error(
`未处理异常 [${requestId}] ${req.method} ${req.url}: ${
+30
View File
@@ -0,0 +1,30 @@
import type { PrismaService } from '../prisma/prisma.service';
/**
* 站点开关(存在 settings 表里的 key/value)。
*
* 规定:**只在数据库里存"开"的值**。没这行、或值不是 '1',一律按关处理,
* 所以新增开关不需要往库里插数据,默认值天然就是关闭。
*/
export const SITE_FLAG_KEYS = {
/** 使用开发库时是否显示已购课程。开发库里带着老数据,容易让人误以为自己买过课。 */
showOwnedOnDevelop: 'develop.show_owned_courses',
} as const;
export type SiteFlagKey = (typeof SITE_FLAG_KEYS)[keyof typeof SITE_FLAG_KEYS];
/** 读一个布尔开关,缺省为 false。 */
export async function readFlag(prisma: PrismaService, key: SiteFlagKey): Promise<boolean> {
const row = await prisma.active.setting.findUnique({ where: { key } });
return row?.value === '1';
}
/**
* 「已购课程」现在该不该对外显示。
*
* 只在**小程序读开发库**时受开关约束——生产库永远正常显示,免得开关被误开影响真实用户。
*/
export async function showOwnedCourses(prisma: PrismaService): Promise<boolean> {
if (prisma.activeEnv !== 'develop') return true;
return readFlag(prisma, SITE_FLAG_KEYS.showOwnedOnDevelop);
}
+12
View File
@@ -55,6 +55,18 @@ const envSchema = z.object({
/** 虚拟支付环境:0 现网 / 1 沙箱。切换后需重启服务端生效。 */
WX_PAY_ENV: z.coerce.number().int().min(0).max(1).default(0),
/**
* 开发库(数据源开关切到 develop 时)用的支付配置。
*
* 为什么要分开:小程序的数据源可以切到开发库,但支付配置以前是全局的,
* 切了库仍然用现网 OfferID 和现网密钥下单——那是在真收钱。
* 留空时按「同一 OfferID + 沙箱 AppKey + 沙箱环境」处理,开发环境天然不收真钱。
*/
WX_OFFER_ID_DEVELOP: z.string().default(''),
WX_PAY_APPKEY_DEVELOP: z.string().default(''),
/** 开发库的支付环境:0 现网 / 1 沙箱。默认沙箱。 */
WX_PAY_ENV_DEVELOP: z.coerce.number().int().min(0).max(1).default(1),
/** 播放地址签名有效期(秒) */
PLAY_URL_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
@@ -118,8 +118,10 @@ export class AdminService {
delivered,
refunded,
revenueFen: revenue._sum.amount_fen ?? 0,
payEnabled: this.wxpay.enabled,
payEnv: this.wxpay.env,
// 支付配置跟着数据源走:切到开发库显示的就是开发那套(默认沙箱)
payEnabled: this.wxpay.enabledFor(env),
payEnv: this.wxpay.configFor(env).env,
payOfferId: this.wxpay.configFor(env).offerId,
/** 管理端当前正在查看的库 */
dataEnv: env,
/** 线上小程序当前读的库(由独立开关控制,与 dataEnv 无关) */
@@ -223,7 +225,40 @@ export class AdminService {
async deleteResource(env: DataSourceEnv, name: string, id: number): Promise<{ deleted: true }> {
const def = RESOURCES[name];
if (!def) throw AppError.notFound(`资源不可删除:${name}`);
await this.delegate(this.prisma.clientFor(env), def.delegate).delete({ where: { id } });
const db = this.prisma.clientFor(env);
// 课程不连带删除任何东西——课时是实打实的内容,删了不好恢复,要删得自己去「课时」列表里删。
// 订单和权益是交易记录,更加不能跟着删。
if (name === 'courses') {
const [lessons, products, orders, entitlements] = await Promise.all([
db.lesson.count({ where: { course_id: id } }),
db.product.count({ where: { course_id: id } }),
db.order.count({ where: { course_id: id } }),
db.entitlement.count({ where: { course_id: id } }),
]);
const content: string[] = [];
if (lessons > 0) content.push(`${lessons} 个课时`);
if (products > 0) content.push(`${products} 个支付商品`);
if (content.length > 0) {
throw AppError.conflict(
`该课程下还有 ${content.join('、')},不会被连带删除。` +
'请先到对应的列表里删掉它们,再回来删课程。',
);
}
if (orders > 0 || entitlements > 0) {
throw AppError.conflict(
`该课程下还有 ${orders} 笔订单、${entitlements} 条权益,属于交易记录,不能删除。` +
'请把课程状态改成「停用」把它下架。',
);
}
await db.course.delete({ where: { id } });
return { deleted: true };
}
await this.delegate(db, def.delegate).delete({ where: { id } });
return { deleted: true };
}
@@ -417,10 +452,11 @@ export class AdminService {
if (!order) throw AppError.notFound('订单不存在');
if (order.status !== OrderStatus.DELIVERED) throw AppError.conflict('只有已开通的订单可以退款');
// 沙箱单在微信侧是另一套账,用现网的密钥查不到、也退不了
if (order.env !== this.wxpay.env) {
// 订单创建时的环境必须和当前数据源对应的环境一致,否则微信侧是两套账,查不到也退不了
const payEnv = this.wxpay.configFor(env).env;
if (order.env !== payEnv) {
throw AppError.conflict(
`该订单是${order.env === 1 ? '沙箱' : '现网'}环境创建的,当前服务端是${this.wxpay.env === 1 ? '沙箱' : '现网'}环境,无法退款`,
`该订单是${order.env === 1 ? '沙箱' : '现网'}环境创建的,当前数据源对应${payEnv === 1 ? '沙箱' : '现网'}环境,无法退款`,
);
}
@@ -432,7 +468,7 @@ export class AdminService {
reasonCode,
// 微信只要枚举值,管理员写的文字放进 biz_meta,事后能查回来
bizMeta: JSON.stringify({ reason: reason.slice(0, 200), source: 'admin' }),
});
}, env);
// 退款是异步的:微信只是受理了,钱到账后才会推 xpay_refund_notify 回写「已退款」。
// 先记下发起时间,后台据此显示「退款中」,也避免管理员重复点。
@@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { AppError } from '../../common/errors';
import { showOwnedCourses } from '../../common/site-flags';
import { staticUrls } from '../../common/urls';
import { PrismaService } from '../../prisma/prisma.service';
import { MediaService } from '../media/media.service';
@@ -27,14 +28,16 @@ export class CoursesService {
/** 课程列表。含未购买课程,供小程序做购买入口。 */
async list(userId: number) {
const [courses, owned] = await Promise.all([
// 小程序读开发库且开关关闭时,不显示「已购」状态(开发库里的老数据会误导人)
const [courses, canShowOwned] = await Promise.all([
this.prisma.active.course.findMany({
where: { status: 1 },
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
include: { _count: { select: { lessons: true } } },
}),
this.ownedCourseIds(userId),
showOwnedCourses(this.prisma),
]);
const owned = canShowOwned ? await this.ownedCourseIds(userId) : new Set<number>();
return courses.map((c) => ({
id: c.id,
@@ -52,9 +55,11 @@ export class CoursesService {
const course = await this.prisma.active.course.findUnique({ where: { code } });
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
const owned = await this.prisma.active.entitlement.findUnique({
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
});
const owned =
(await showOwnedCourses(this.prisma)) &&
(await this.prisma.active.entitlement.findUnique({
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
})) !== null;
return {
id: course.id,
@@ -63,7 +68,7 @@ export class CoursesService {
intro: course.intro,
priceFen: course.price_fen,
coverUrl: staticUrls.courseCover(this.staticBase(), course.code),
owned: owned !== null,
owned,
};
}
@@ -143,4 +148,3 @@ export class CoursesService {
return course;
}
}
@@ -34,7 +34,10 @@ export class OrdersService {
quantity = 1,
terminal: string | null = null,
): Promise<{ outTradeNo: string; payData: PayData }> {
if (!this.wxpay.enabled) {
// 支付配置跟着「小程序当前数据源」走:切到开发库就用开发那套(默认沙箱),不会误收真钱
const source = this.prisma.activeEnv;
const pay = this.wxpay.configFor(source);
if (!this.wxpay.enabledFor(source)) {
throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503);
}
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
@@ -76,7 +79,7 @@ export class OrdersService {
quantity,
amount_fen: amountFen,
status: OrderStatus.CREATED,
env: this.wxpay.env,
env: pay.env,
terminal,
attach: `course:${course.code}`,
},
@@ -84,9 +87,9 @@ export class OrdersService {
const sessionKey = await this.payments.requireSessionKey(userId);
const signData = JSON.stringify({
offerId: this.wxpay.offerId,
offerId: pay.offerId,
buyQuantity: quantity,
env: this.wxpay.env,
env: pay.env,
currencyType: 'CNY',
productId: product.wx_product_id,
goodsPrice: product.price_fen,
@@ -99,7 +102,7 @@ export class OrdersService {
payData: {
signData,
mode: 'short_series_goods',
paySig: this.wxpay.calcPaySig('requestVirtualPayment', signData),
paySig: this.wxpay.calcPaySig('requestVirtualPayment', signData, pay.appkey),
signature: this.wxpay.calcSignature(signData, sessionKey),
},
};
@@ -289,7 +289,8 @@ export class PaymentsService {
* 由定时任务每 5 分钟调用一次。
*/
async reconcilePendingOrders(): Promise<number> {
if (!this.wxpay.enabled) return 0;
const source = this.prisma.activeEnv;
if (!this.wxpay.enabledFor(source)) return 0;
const pending = await this.prisma.active.order.findMany({
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
@@ -301,7 +302,7 @@ export class PaymentsService {
let delivered = 0;
for (const order of pending) {
try {
const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid);
const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid, source);
const wxOrderId = result.wx_order_id ?? result.order?.wx_order_id;
// 只认「已支付待发货 / 发货中 / 已发货」这三种状态。
// 不能用「有没有 wx_order_id」当判据——已关闭(6)和已退款(5)的单也有单号,
@@ -1,6 +1,7 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { createHmac } from 'node:crypto';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import type { DataSourceEnv } from '../../common/admin-env';
import { AppError } from '../../common/errors';
const XPAY_BASE = 'https://api.weixin.qq.com';
@@ -39,27 +40,37 @@ export class WxPayService {
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
/** 当前支付环境:0 现网 / 1 沙箱。 */
get env(): number {
return this.cfg.WX_PAY_ENV;
/**
* 取某个数据源用的支付配置。
*
* 生产库走现网 AppKey(WX_PAY_ENV 决定现网/沙箱),
* 开发库走沙箱 AppKey,OfferID 默认与生产共用——微信本来就只有一个 OfferID,
* 沙箱与现网的区别是 AppKey 加 env 值。
*/
configFor(source: DataSourceEnv): WxPayConfig {
if (source === 'develop') {
return {
offerId: this.cfg.WX_OFFER_ID_DEVELOP || this.cfg.WX_OFFER_ID,
appkey: this.cfg.WX_PAY_APPKEY_DEVELOP || this.cfg.WX_PAY_APPKEY_SANDBOX,
env: this.cfg.WX_PAY_ENV_DEVELOP,
};
}
return {
offerId: this.cfg.WX_OFFER_ID,
appkey: this.cfg.WX_PAY_ENV === 1 ? this.cfg.WX_PAY_APPKEY_SANDBOX : this.cfg.WX_PAY_APPKEY,
env: this.cfg.WX_PAY_ENV,
};
}
/** 当前环境对应的支付密钥。 */
private get appkey(): string {
return this.env === 1 ? this.cfg.WX_PAY_APPKEY_SANDBOX : this.cfg.WX_PAY_APPKEY;
}
get enabled(): boolean {
return this.cfg.WX_OFFER_ID !== '' && this.appkey !== '';
}
get offerId(): string {
return this.cfg.WX_OFFER_ID;
/** 该数据源是否配好了支付。 */
enabledFor(source: DataSourceEnv): boolean {
const c = this.configFor(source);
return c.offerId !== '' && c.appkey !== '';
}
/** 服务端身份签名。uri 不带问号参数。 */
calcPaySig(uri: string, postBody: string): string {
return createHmac('sha256', this.appkey).update(`${uri}&${postBody}`).digest('hex');
calcPaySig(uri: string, postBody: string, appkey: string): string {
return createHmac('sha256', appkey).update(`${uri}&${postBody}`).digest('hex');
}
/** 用户态签名。 */
@@ -102,20 +113,21 @@ export class WxPayService {
}
/** 拼出带 access_token 与 pay_sig 的接口地址。 */
private async signedUrl(uri: string, postBody: string): Promise<string> {
private async signedUrl(uri: string, postBody: string, cfg: WxPayConfig): Promise<string> {
const token = await this.accessToken();
return `${XPAY_BASE}${uri}?access_token=${encodeURIComponent(token)}&pay_sig=${this.calcPaySig(uri, postBody)}`;
return `${XPAY_BASE}${uri}?access_token=${encodeURIComponent(token)}&pay_sig=${this.calcPaySig(uri, postBody, cfg.appkey)}`;
}
/**
* 查单。用于发货推送丢失时兜底补发。
* 参数名是 order_id(传自己的 outTradeNo),不是 out_trade_no。
*/
async queryOrder(outTradeNo: string, openid: string): Promise<XpayQueryResult> {
async queryOrder(outTradeNo: string, openid: string, source: DataSourceEnv): Promise<XpayQueryResult> {
const cfg = this.configFor(source);
const uri = '/xpay/query_order';
const body = JSON.stringify({ openid, env: this.env, order_id: outTradeNo });
const body = JSON.stringify({ openid, env: cfg.env, order_id: outTradeNo });
const res = await fetch(await this.signedUrl(uri, body), {
const res = await fetch(await this.signedUrl(uri, body, cfg), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
@@ -142,13 +154,14 @@ export class WxPayService {
reasonCode: string;
/** 商家自定义数据,必填,退款原因的文字记在这里,query_order 可原样查回 */
bizMeta: string;
}): Promise<Record<string, unknown>> {
if (!this.enabled) throw new AppError(4002, '虚拟支付未配置', 503);
}, source: DataSourceEnv): Promise<Record<string, unknown>> {
const cfg = this.configFor(source);
if (!cfg.offerId || !cfg.appkey) throw new AppError(4002, '虚拟支付未配置', 503);
// left_fee 是「当前单剩余可退金额」,接口必填,只能先查单拿到。
// 千万不要拿订单金额兜底——那等于编一个数发给微信,会被 268490016 打回来,
// 而且把「这单根本不能退」这个真实原因盖掉了。
const queried = await this.queryOrder(input.outTradeNo, input.openid);
const queried = await this.queryOrder(input.outTradeNo, input.openid, source);
const status = queried.order?.status;
const statusText = status === undefined ? '未知' : XPAY_STATUS[status] ?? String(status);
const leftFee = queried.order?.left_fee;
@@ -183,10 +196,10 @@ export class WxPayService {
refund_reason: input.reasonCode,
/** 1-人工客服退款:后台管理员发起的都属于这一类 */
req_from: '1',
env: this.env,
env: cfg.env,
});
const res = await fetch(await this.signedUrl(uri, body), {
const res = await fetch(await this.signedUrl(uri, body, cfg), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
@@ -215,3 +228,10 @@ export interface XpayQueryResult {
status?: number;
wx_order_id?: string;
}
/** 某个数据源实际使用的支付配置。 */
export interface WxPayConfig {
offerId: string;
appkey: string;
/** 0 现网 / 1 沙箱 */
env: number;
}
@@ -1,6 +1,7 @@
import { Inject, Injectable } from '@nestjs/common';
import { APP_CONFIG, type AppConfig } from '../../config/config';
import { AppError } from '../../common/errors';
import { showOwnedCourses } from '../../common/site-flags';
import { staticUrls } from '../../common/urls';
import { PrismaService } from '../../prisma/prisma.service';
@@ -59,6 +60,9 @@ export class UsersService {
/** 我的课程。 */
async entitlements(userId: number) {
// 小程序读开发库且开关关闭时,已购课程列表直接返回空
if (!(await showOwnedCourses(this.prisma))) return [];
const rows = await this.prisma.active.entitlement.findMany({
where: { user_id: userId },
orderBy: { granted_at: 'desc' },
@@ -0,0 +1,31 @@
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
/**
* 「禁止连带删除」的护栏。
*
* 起因:删课程时把课程下面 55 个课时一起删掉了(当时写的是连带删除),
* 而需求是「删课程只删课程,其余的我手动删」。
*
* 这条测试扫所有迁移文件,一旦有人写下 ON DELETE CASCADE 就会失败。
* 表结构的删除行为只允许 RESTRICT(有引用就不让删)或 SET NULL(只解引用不删行)。
*/
describe('数据库外键', () => {
it('任何迁移都不允许出现级联删除', () => {
const dir = join(process.cwd(), 'prisma', 'migrations');
const offenders: string[] = [];
for (const name of readdirSync(dir)) {
let sql: string;
try {
sql = readFileSync(join(dir, name, 'migration.sql'), 'utf8');
} catch {
continue; // migration_lock.toml 之类的非目录项
}
if (/ON DELETE CASCADE/i.test(sql)) offenders.push(name);
}
expect(offenders).toEqual([]);
});
});