文案配置化 + 支付开关
小程序 - 页面固定文案全部改读后台配置(购买、登录、资料、首页、分享、介绍页),代码里不再硬编码 - 已拥有课程时在当前页提示,不再跳购买页再提示 - 介绍页正文由代码常量改为读 settings.page.formula.content 服务端 - 课程相关报错文案(不存在 / 已下架 / 课时不存在 / 未购买 / 已过期 / 重复购买 / 未配置支付商品)改读配置,代码保留默认值兜底 - 新增支付总开关 pay.blocked:打开即拦截下单并下发配置里的提示;开关存在数据库里,开发库与生产库各一份(开发库已开,生产库未设) 后台 - 系统配置页按页面分组折叠,默认收起;认不出来的配置归入「其他」 配套数据(不在代码里):对应的 settings 行已写入生产库与开发库
This commit is contained in:
1 parent
c45025fc86
commit
879e74ebda
21 files changed
+325
-103
No files matched your search
@@ -9,16 +9,66 @@ import type { PrismaService } from '../prisma/prisma.service';
|
||||
export const SITE_FLAG_KEYS = {
|
||||
/** 使用开发库时是否显示已购课程。开发库里带着老数据,容易让人误以为自己买过课。 */
|
||||
showOwnedOnDevelop: 'develop.show_owned_courses',
|
||||
/**
|
||||
* 是否暂停支付(拦截下单)。
|
||||
*
|
||||
* 打开后所有下单直接返回「支付暂不可用」,不产生任何订单——临时停售或维护时用。
|
||||
* 默认关(等于正常收款)。开关存在数据库里,所以每个库一份:
|
||||
* 开发库通常是开的(防止误下单),生产库通常是关的。
|
||||
*/
|
||||
payBlocked: 'pay.blocked',
|
||||
} as const;
|
||||
|
||||
export type SiteFlagKey = (typeof SITE_FLAG_KEYS)[keyof typeof SITE_FLAG_KEYS];
|
||||
|
||||
/**
|
||||
* 课程相关的报错文案 key(都存在 settings 表,后台可改)。
|
||||
*
|
||||
* 代码里同时保留默认值:配置被删或改空时仍然给用户一句能看懂的话。
|
||||
*/
|
||||
export const COURSE_TIP_KEYS = {
|
||||
/** 课程不存在或已下架(详情页 / 下单) */
|
||||
gone: 'course.tip.notFoundOrOffline',
|
||||
/** 课程不存在(播放鉴权);小程序购买页也用它做兜底 */
|
||||
notFound: 'course.tip.notFound',
|
||||
/** 课程已下架 */
|
||||
offline: 'course.tip.offline',
|
||||
/** 课时不存在 */
|
||||
lessonNotFound: 'course.tip.lessonNotFound',
|
||||
/** 尚未拥有该课程 */
|
||||
noEntitlement: 'course.tip.noEntitlement',
|
||||
/** 课程已过期 */
|
||||
expired: 'course.tip.expired',
|
||||
/** 你已拥有该课程(下单时的兜底,小程序一般先在本地拦掉) */
|
||||
alreadyOwned: 'course.tip.alreadyOwned',
|
||||
/** 该课程尚未配置支付商品 */
|
||||
noProduct: 'course.tip.noProduct',
|
||||
/** 小程序切到开发库时不收钱,下单直接给这句 */
|
||||
payUnavailable: 'course.tip.payUnavailable',
|
||||
} as const;
|
||||
|
||||
/** 读一个布尔开关,缺省为 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 readText(
|
||||
prisma: PrismaService,
|
||||
key: string,
|
||||
fallback: string,
|
||||
): Promise<string> {
|
||||
const row = await prisma.active.setting.findUnique({ where: { key } });
|
||||
const value = (row?.value ?? '').trim();
|
||||
return value || fallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* 「已购课程」现在该不该对外显示。
|
||||
*
|
||||
|
||||
@@ -1,7 +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 { COURSE_TIP_KEYS, readText, showOwnedCourses } from '../../common/site-flags';
|
||||
import { staticUrls } from '../../common/urls';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { MediaService } from '../media/media.service';
|
||||
@@ -53,7 +53,10 @@ export class CoursesService {
|
||||
|
||||
async detail(userId: number, code: string) {
|
||||
const course = await this.prisma.active.course.findUnique({ where: { code } });
|
||||
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
||||
if (!course || course.status !== 1) {
|
||||
// 文案放后台配置里,改提示不用发版
|
||||
throw AppError.notFound(await readText(this.prisma, COURSE_TIP_KEYS.gone, '课程不存在或已下架'));
|
||||
}
|
||||
|
||||
const owned =
|
||||
(await showOwnedCourses(this.prisma)) &&
|
||||
@@ -100,7 +103,9 @@ export class CoursesService {
|
||||
const lesson = await this.prisma.active.lesson.findFirst({
|
||||
where: { course_id: course.id, chapter_id: chapterId, play_id: playId },
|
||||
});
|
||||
if (!lesson) throw AppError.notFound('课时不存在');
|
||||
if (!lesson) {
|
||||
throw AppError.notFound(await readText(this.prisma, COURSE_TIP_KEYS.lessonNotFound, '课时不存在'));
|
||||
}
|
||||
|
||||
const relative = `Course/${course.code}/${lesson.chapter_id}/${lesson.play_id}/playlist.m3u8`;
|
||||
return {
|
||||
@@ -134,15 +139,23 @@ export class CoursesService {
|
||||
/** 取课程并校验权益,是唯一的鉴权入口。 */
|
||||
private async requireEntitlement(userId: number, code: string) {
|
||||
const course = await this.prisma.active.course.findUnique({ where: { code } });
|
||||
if (!course) throw AppError.notFound('课程不存在');
|
||||
if (course.status !== 1) throw AppError.offline();
|
||||
if (!course) {
|
||||
throw AppError.notFound(await readText(this.prisma, COURSE_TIP_KEYS.notFound, '课程不存在'));
|
||||
}
|
||||
if (course.status !== 1) {
|
||||
throw AppError.offline(await readText(this.prisma, COURSE_TIP_KEYS.offline, '课程已下架'));
|
||||
}
|
||||
|
||||
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();
|
||||
if (!owned) {
|
||||
throw AppError.noEntitlement(
|
||||
await readText(this.prisma, COURSE_TIP_KEYS.noEntitlement, '尚未拥有该课程'),
|
||||
);
|
||||
}
|
||||
if (owned.expire_at && owned.expire_at.getTime() < Date.now()) {
|
||||
throw AppError.noEntitlement('课程已过期');
|
||||
throw AppError.noEntitlement(await readText(this.prisma, COURSE_TIP_KEYS.expired, '课程已过期'));
|
||||
}
|
||||
|
||||
return course;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AppError, ErrorCode } from '../../common/errors';
|
||||
import { COURSE_TIP_KEYS, readFlag, readText, SITE_FLAG_KEYS } from '../../common/site-flags';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { OrderStatus, PaymentsService } from '../payments/payments.service';
|
||||
import { WxPayService } from '../payments/wxpay.service';
|
||||
@@ -34,9 +35,21 @@ export class OrdersService {
|
||||
quantity = 1,
|
||||
terminal: string | null = null,
|
||||
): Promise<{ outTradeNo: string; payData: PayData }> {
|
||||
// 支付配置跟着「小程序当前数据源」走:切到开发库就用开发那套(默认沙箱),不会误收真钱
|
||||
// 支付配置跟着「小程序当前数据源」走:生产库用现网那套
|
||||
const source = this.prisma.activeEnv;
|
||||
const pay = this.wxpay.configFor(source);
|
||||
|
||||
// 支付总开关:打开就拦下单(临时停售、维护、或开发库防止误下单)。
|
||||
// 开关存在数据库里,小程序读哪个库就受哪个库的开关控制,所以开发库和生产库可以各设各的。
|
||||
// 放在「支付是否配置」之前判断,保证暂停期间永远是同一句提示。
|
||||
if (await readFlag(this.prisma, SITE_FLAG_KEYS.payBlocked)) {
|
||||
throw new AppError(
|
||||
ErrorCode.PAY_NOT_ENABLED,
|
||||
await readText(this.prisma, COURSE_TIP_KEYS.payUnavailable, '支付暂不可用'),
|
||||
503,
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.wxpay.enabledFor(source)) {
|
||||
throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503);
|
||||
}
|
||||
@@ -55,17 +68,26 @@ export class OrdersService {
|
||||
}
|
||||
|
||||
const course = await this.prisma.active.course.findUnique({ where: { code: courseCode } });
|
||||
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
||||
if (!course || course.status !== 1) {
|
||||
// 与课程详情用同一条配置,两处提示保持一致
|
||||
throw AppError.notFound(await readText(this.prisma, COURSE_TIP_KEYS.gone, '课程不存在或已下架'));
|
||||
}
|
||||
|
||||
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('你已拥有该课程');
|
||||
if (owned) {
|
||||
throw AppError.conflict(await readText(this.prisma, COURSE_TIP_KEYS.alreadyOwned, '你已拥有该课程'));
|
||||
}
|
||||
|
||||
const product = await this.prisma.active.product.findFirst({
|
||||
where: { course_id: course.id, status: 1 },
|
||||
});
|
||||
if (!product) throw AppError.notFound('该课程尚未配置支付商品');
|
||||
if (!product) {
|
||||
throw AppError.notFound(
|
||||
await readText(this.prisma, COURSE_TIP_KEYS.noProduct, '该课程尚未配置支付商品'),
|
||||
);
|
||||
}
|
||||
|
||||
const outTradeNo = this.newOutTradeNo();
|
||||
const amountFen = product.price_fen * quantity;
|
||||
|
||||
Reference in new issue
Block a user