四端上传
This commit is contained in:
1 parent
25bbb5f9c8
commit
c6e2cb4d5b
276 files changed
+17812
-3
No files matched your search
@@ -0,0 +1,39 @@
|
||||
# 新库(MySQL 5.7)
|
||||
# 注意:Prisma 的 migrate dev 需要 shadow database,要求账号有 CREATE DATABASE 权限;
|
||||
# 宝塔默认账号没有该权限,生产环境请用 prisma migrate deploy。
|
||||
DATABASE_URL="mysql://用户名:密码@127.0.0.1:3306/yelaymaths"
|
||||
|
||||
# 旧库(只读,仅迁移脚本使用)
|
||||
LEGACY_DATABASE_URL="mysql://用户名:密码@127.0.0.1:3306/api_yeomai_site"
|
||||
|
||||
# 运行环境
|
||||
NODE_ENV=development
|
||||
PORT=3000
|
||||
TZ=Asia/Shanghai
|
||||
|
||||
# 对外域名
|
||||
API_BASE_URL="https://yelaymaths-api.yeomai.site"
|
||||
STATIC_BASE_URL="https://yelaymaths-data.yeomai.site"
|
||||
# 管理后台域名,用于跨域白名单
|
||||
ADMIN_ORIGIN="https://yelaymaths-admin.yeomai.site"
|
||||
|
||||
# 密钥(部署时替换,勿使用示例值)
|
||||
JWT_ACCESS_SECRET="change-me-access"
|
||||
JWT_REFRESH_SECRET="change-me-refresh"
|
||||
SESSION_KEY_ENC_SECRET="change-me-32-bytes-min"
|
||||
|
||||
# 播放地址签名密钥(与上面几个不要复用)
|
||||
MEDIA_SIGN_SECRET="change-me-media-sign"
|
||||
# 播放地址有效期(秒)
|
||||
PLAY_URL_TTL_SECONDS=1800
|
||||
|
||||
# 课程数据端本地路径(媒体文件根目录)
|
||||
DATA_ROOT="../yelaymaths_data.yeomai.site"
|
||||
|
||||
# 微信小程序(沿用现有 AppID)
|
||||
WX_APPID="wx3fbaf897825bf04f"
|
||||
WX_APPSECRET="change-me"
|
||||
|
||||
# 虚拟支付(开通后填写)
|
||||
WX_OFFER_ID=""
|
||||
WX_PAY_APPKEY=""
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true,
|
||||
"tsConfigPath": "tsconfig.build.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "yelaymaths_api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "叶雷讲数学 — 服务端 API",
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "node dist/main.js",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:validate": "prisma validate",
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"prisma:diff": "prisma migrate diff --from-empty --to-schema-datamodel prisma/schema.prisma --script",
|
||||
"legacy:check": "tsx tools/legacy/check.ts",
|
||||
"legacy:migrate": "tsx tools/legacy/migrate.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/jwt": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@nestjs/schedule": "^5.0.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"fast-xml-parser": "^4.5.1",
|
||||
"mysql2": "^3.11.5",
|
||||
"nestjs-pino": "^4.1.0",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.0",
|
||||
"prisma": "^6.0.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2",
|
||||
"vitest": "^2.1.8"
|
||||
}
|
||||
}
|
||||
Generated
+4557
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,10 @@
|
||||
# pnpm 11 起,此类设置只能写在 pnpm-workspace.yaml(package.json 的 pnpm 字段已不再读取)。
|
||||
# 本项目为单包工程,省略 packages 即默认当前目录。
|
||||
#
|
||||
# pnpm 默认拦截依赖的安装脚本,以下为确需执行安装脚本的包:
|
||||
# Prisma 需要下载查询引擎,esbuild 需要下载平台二进制。
|
||||
allowBuilds:
|
||||
'@prisma/client': true
|
||||
'@prisma/engines': true
|
||||
esbuild: true
|
||||
prisma: true
|
||||
@@ -0,0 +1,270 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE `users` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`openid` VARCHAR(64) NOT NULL,
|
||||
`nickname` VARCHAR(32) NULL,
|
||||
`headicon_id` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`wx_session_key` VARCHAR(255) NULL,
|
||||
`session_key_updated_at` DATETIME(3) NULL,
|
||||
`status` TINYINT NOT NULL DEFAULT 1,
|
||||
`registered_at` DATETIME(3) NOT NULL,
|
||||
`last_login_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `users_openid_key`(`openid`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `courses` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`code` VARCHAR(64) NOT NULL,
|
||||
`title` VARCHAR(128) NOT NULL,
|
||||
`intro` VARCHAR(255) NULL,
|
||||
`cover_image` VARCHAR(255) NULL,
|
||||
`price_fen` INTEGER UNSIGNED NOT NULL DEFAULT 0,
|
||||
`status` TINYINT NOT NULL DEFAULT 1,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `courses_code_key`(`code`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `lessons` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`course_id` INTEGER UNSIGNED NOT NULL,
|
||||
`play_id` VARCHAR(64) NOT NULL,
|
||||
`chapter_id` VARCHAR(64) NOT NULL,
|
||||
`chapter_title` VARCHAR(255) NULL,
|
||||
`lesson_title` VARCHAR(255) NULL,
|
||||
`duration` VARCHAR(16) NULL,
|
||||
`published_at` DATETIME(3) NULL,
|
||||
`cover_image` VARCHAR(255) NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `lessons_course_id_sort_order_idx`(`course_id`, `sort_order`),
|
||||
UNIQUE INDEX `lessons_course_id_chapter_id_play_id_key`(`course_id`, `chapter_id`, `play_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `free_lessons` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`series_no` INTEGER NOT NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`course_name` VARCHAR(255) NOT NULL,
|
||||
`video_title` VARCHAR(255) NULL,
|
||||
`intro` VARCHAR(255) NULL,
|
||||
`publisher` VARCHAR(255) NULL,
|
||||
`published_at` DATETIME(3) NULL,
|
||||
`duration` VARCHAR(16) NULL,
|
||||
`video_m3u8` VARCHAR(255) NULL,
|
||||
`cover_image` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `free_lessons_series_no_sort_order_idx`(`series_no`, `sort_order`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `home_courses` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`course_name` VARCHAR(255) NOT NULL,
|
||||
`intro` VARCHAR(255) NULL,
|
||||
`published_at` DATETIME(3) NULL,
|
||||
`cover_image` VARCHAR(255) NULL,
|
||||
`content` LONGTEXT NULL,
|
||||
`course_id` INTEGER UNSIGNED NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`status` TINYINT NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
INDEX `home_courses_status_sort_order_idx`(`status`, `sort_order`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `home_free_courses` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`series_no` INTEGER NOT NULL,
|
||||
`course_name` VARCHAR(255) NOT NULL,
|
||||
`intro` VARCHAR(255) NULL,
|
||||
`published_at` DATETIME(3) NULL,
|
||||
`cover_image` VARCHAR(255) NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`status` TINYINT NOT NULL DEFAULT 1,
|
||||
|
||||
INDEX `home_free_courses_status_sort_order_idx`(`status`, `sort_order`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `banners` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`filename` VARCHAR(255) NOT NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`status` TINYINT NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
INDEX `banners_status_sort_order_idx`(`status`, `sort_order`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `articles` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`legacy_page_id` VARCHAR(32) NULL,
|
||||
`title` VARCHAR(255) NOT NULL,
|
||||
`intro` VARCHAR(255) NULL,
|
||||
`publisher` VARCHAR(255) NULL,
|
||||
`published_at` DATETIME(3) NULL,
|
||||
`content` LONGTEXT NULL,
|
||||
`sort_order` INTEGER NOT NULL DEFAULT 0,
|
||||
`status` TINYINT NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
INDEX `articles_status_published_at_idx`(`status`, `published_at`),
|
||||
INDEX `articles_legacy_page_id_idx`(`legacy_page_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `course_tips` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`tip_key` INTEGER NOT NULL,
|
||||
`content` TEXT NULL,
|
||||
`title` TINYTEXT NULL,
|
||||
|
||||
UNIQUE INDEX `course_tips_tip_key_key`(`tip_key`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `settings` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`key` VARCHAR(64) NOT NULL,
|
||||
`value` VARCHAR(255) NULL,
|
||||
`remark` VARCHAR(255) NULL,
|
||||
|
||||
UNIQUE INDEX `settings_key_key`(`key`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `entitlements` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`user_id` INTEGER UNSIGNED NOT NULL,
|
||||
`course_id` INTEGER UNSIGNED NOT NULL,
|
||||
`source` TINYINT NOT NULL,
|
||||
`ref_id` INTEGER UNSIGNED NULL,
|
||||
`granted_at` DATETIME(3) NOT NULL,
|
||||
`expire_at` DATETIME(3) NULL,
|
||||
`note` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `entitlements_course_id_idx`(`course_id`),
|
||||
UNIQUE INDEX `entitlements_user_id_course_id_key`(`user_id`, `course_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `orders` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`out_trade_no` VARCHAR(32) NOT NULL,
|
||||
`user_id` INTEGER UNSIGNED NOT NULL,
|
||||
`course_id` INTEGER UNSIGNED NOT NULL,
|
||||
`product_id` VARCHAR(64) NOT NULL,
|
||||
`quantity` INTEGER UNSIGNED NOT NULL DEFAULT 1,
|
||||
`amount_fen` INTEGER UNSIGNED NOT NULL,
|
||||
`status` TINYINT NOT NULL,
|
||||
`env` TINYINT NOT NULL DEFAULT 0,
|
||||
`wx_order_id` VARCHAR(64) NULL,
|
||||
`attach` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`paid_at` DATETIME(3) NULL,
|
||||
`delivered_at` DATETIME(3) NULL,
|
||||
`refunded_at` DATETIME(3) NULL,
|
||||
|
||||
UNIQUE INDEX `orders_out_trade_no_key`(`out_trade_no`),
|
||||
UNIQUE INDEX `orders_wx_order_id_key`(`wx_order_id`),
|
||||
INDEX `orders_user_id_created_at_idx`(`user_id`, `created_at`),
|
||||
INDEX `orders_status_created_at_idx`(`status`, `created_at`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `payment_notify_log` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`wx_order_id` VARCHAR(64) NULL,
|
||||
`out_trade_no` VARCHAR(32) NULL,
|
||||
`event` VARCHAR(64) NULL,
|
||||
`raw_body` MEDIUMTEXT NULL,
|
||||
`processed` TINYINT NOT NULL DEFAULT 0,
|
||||
`err_msg` VARCHAR(255) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
|
||||
INDEX `payment_notify_log_wx_order_id_idx`(`wx_order_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `products` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`course_id` INTEGER UNSIGNED NOT NULL,
|
||||
`wx_product_id` VARCHAR(64) NOT NULL,
|
||||
`name` VARCHAR(128) NOT NULL,
|
||||
`price_fen` INTEGER UNSIGNED NOT NULL,
|
||||
`status` TINYINT NOT NULL DEFAULT 1,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `products_wx_product_id_key`(`wx_product_id`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `admin_users` (
|
||||
`id` INTEGER UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
`username` VARCHAR(64) NOT NULL,
|
||||
`password_hash` VARCHAR(255) NOT NULL,
|
||||
`display_name` VARCHAR(64) NULL,
|
||||
`role` TINYINT NOT NULL DEFAULT 2,
|
||||
`totp_secret` VARCHAR(64) NULL,
|
||||
`status` TINYINT NOT NULL DEFAULT 1,
|
||||
`last_login_at` DATETIME(3) NULL,
|
||||
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
|
||||
`updated_at` DATETIME(3) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `admin_users_username_key`(`username`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `lessons` ADD CONSTRAINT `lessons_course_id_fkey` FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `home_courses` ADD CONSTRAINT `home_courses_course_id_fkey` FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `entitlements` ADD CONSTRAINT `entitlements_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `entitlements` ADD CONSTRAINT `entitlements_course_id_fkey` FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `orders` ADD CONSTRAINT `orders_user_id_fkey` FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `orders` ADD CONSTRAINT `orders_course_id_fkey` FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE `products` ADD CONSTRAINT `products_course_id_fkey` FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
provider = "mysql"
|
||||
@@ -0,0 +1,306 @@
|
||||
// 叶雷讲数学 — 数据模型
|
||||
//
|
||||
// 约定:
|
||||
// - 表名与字段名统一英文 snake_case,模型名用 PascalCase 并 @@map 到表名
|
||||
// - 主键统一 id INT UNSIGNED AUTO_INCREMENT(本量级无需 BIGINT,且避免 JS BigInt 序列化问题)
|
||||
// - 时间统一 DATETIME,存 UTC,展示层转 Asia/Shanghai
|
||||
// - 金额统一 INT UNSIGNED,单位「分」,全程不换算
|
||||
// - 状态字段统一 TINYINT + 代码内枚举(不使用数据库 ENUM,便于演进)
|
||||
// - 不建外键约束,关系由应用层保证
|
||||
//
|
||||
// 数据库:MySQL 5.7
|
||||
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "mysql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
/// 用户(对应旧表「用户信息」)
|
||||
model User {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
openid String @unique @db.VarChar(64)
|
||||
nickname String? @db.VarChar(32)
|
||||
headicon_id Int @default(0) @map("headicon_id") @db.UnsignedInt
|
||||
/// 微信 sessionKey,AES-256-GCM 加密后存储;虚拟支付用户态签名必需
|
||||
wx_session_key String? @map("wx_session_key") @db.VarChar(255)
|
||||
session_key_updated_at DateTime? @map("session_key_updated_at")
|
||||
/// 1 正常 / 0 封禁
|
||||
status Int @default(1) @db.TinyInt
|
||||
registered_at DateTime @map("registered_at")
|
||||
last_login_at DateTime? @map("last_login_at")
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @updatedAt @map("updated_at")
|
||||
|
||||
entitlements Entitlement[]
|
||||
orders Order[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
/// 付费课程(由旧表「课程口令码读取信息」升级而来)
|
||||
model Course {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
/// 旧 64 位 sha256 串,原值保留。
|
||||
/// 同时是视频目录名 Course/{code}/... 与封面名 Course/{code}.png,绝对不可改写。
|
||||
code String @unique @db.VarChar(64)
|
||||
title String @db.VarChar(128)
|
||||
intro String? @db.VarChar(255)
|
||||
cover_image String? @map("cover_image") @db.VarChar(255)
|
||||
price_fen Int @default(0) @map("price_fen") @db.UnsignedInt
|
||||
/// 0 下架 / 1 上架
|
||||
status Int @default(1) @db.TinyInt
|
||||
sort_order Int @default(0) @map("sort_order")
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @updatedAt @map("updated_at")
|
||||
|
||||
lessons Lesson[]
|
||||
entitlements Entitlement[]
|
||||
orders Order[]
|
||||
products Product[]
|
||||
home_courses HomeCourse[]
|
||||
|
||||
@@map("courses")
|
||||
}
|
||||
|
||||
/// 付费课时(对应旧表「视频课程数据」)
|
||||
model Lesson {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
course_id Int @map("course_id") @db.UnsignedInt
|
||||
/// 原值保留,参与视频路径 Course/{code}/{chapter_id}/{play_id}/playlist.m3u8
|
||||
play_id String @map("play_id") @db.VarChar(64)
|
||||
chapter_id String @map("chapter_id") @db.VarChar(64)
|
||||
chapter_title String? @map("chapter_title") @db.VarChar(255)
|
||||
lesson_title String? @map("lesson_title") @db.VarChar(255)
|
||||
duration String? @db.VarChar(16)
|
||||
published_at DateTime? @map("published_at")
|
||||
cover_image String? @map("cover_image") @db.VarChar(255)
|
||||
sort_order Int @default(0) @map("sort_order")
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
|
||||
course Course @relation(fields: [course_id], references: [id])
|
||||
|
||||
@@unique([course_id, chapter_id, play_id])
|
||||
@@index([course_id, sort_order])
|
||||
@@map("lessons")
|
||||
}
|
||||
|
||||
/// 免费试听课时(对应旧表「免费视频课程数据」)
|
||||
model FreeLesson {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
/// 旧「序号」字段,对应首页试听入口
|
||||
series_no Int @map("series_no")
|
||||
sort_order Int @default(0) @map("sort_order")
|
||||
course_name String @map("course_name") @db.VarChar(255)
|
||||
video_title String? @map("video_title") @db.VarChar(255)
|
||||
intro String? @db.VarChar(255)
|
||||
publisher String? @db.VarChar(255)
|
||||
published_at DateTime? @map("published_at")
|
||||
duration String? @db.VarChar(16)
|
||||
/// 相对路径,如 FreeVideos/711/playlist.m3u8
|
||||
video_m3u8 String? @map("video_m3u8") @db.VarChar(255)
|
||||
cover_image String? @map("cover_image") @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([series_no, sort_order])
|
||||
@@map("free_lessons")
|
||||
}
|
||||
|
||||
/// 首页「新课上线」长图页(对应旧表「首页课程列表」)
|
||||
model HomeCourse {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
/// 原「课程名称」原值保留:首页图片路径为 Images/NewCourse/{course_name}.png
|
||||
course_name String @map("course_name") @db.VarChar(255)
|
||||
intro String? @db.VarChar(255)
|
||||
published_at DateTime? @map("published_at")
|
||||
cover_image String? @map("cover_image") @db.VarChar(255)
|
||||
/// 长图 HTML。注意:内容中含绝对 URL,迁移时需做域名改写。
|
||||
content String? @db.LongText
|
||||
/// 关联可购买课程,用于挂载购买入口(旧数据可能为空)
|
||||
course_id Int? @map("course_id") @db.UnsignedInt
|
||||
sort_order Int @default(0) @map("sort_order")
|
||||
status Int @default(1) @db.TinyInt
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @updatedAt @map("updated_at")
|
||||
|
||||
course Course? @relation(fields: [course_id], references: [id])
|
||||
|
||||
@@index([status, sort_order])
|
||||
@@map("home_courses")
|
||||
}
|
||||
|
||||
/// 首页免费试听入口(对应旧表「首页免费课程列表」)
|
||||
model HomeFreeCourse {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
/// 旧「序号」,前端按此跳转 /v1/free-lessons?series_no=
|
||||
series_no Int @map("series_no")
|
||||
/// 原值保留:图片路径为 Images/FreeCourse/{course_name}.png
|
||||
course_name String @map("course_name") @db.VarChar(255)
|
||||
intro String? @db.VarChar(255)
|
||||
published_at DateTime? @map("published_at")
|
||||
cover_image String? @map("cover_image") @db.VarChar(255)
|
||||
sort_order Int @default(0) @map("sort_order")
|
||||
status Int @default(1) @db.TinyInt
|
||||
|
||||
@@index([status, sort_order])
|
||||
@@map("home_free_courses")
|
||||
}
|
||||
|
||||
/// 首页轮播图(对应旧表「首页横幅图片」)
|
||||
model Banner {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
/// 原「图片名」原值保留:图片路径为 Images/Banner/{filename}
|
||||
filename String @db.VarChar(255)
|
||||
sort_order Int @default(0) @map("sort_order")
|
||||
status Int @default(1) @db.TinyInt
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([status, sort_order])
|
||||
@@map("banners")
|
||||
}
|
||||
|
||||
/// 知识速通文章(对应旧表「网页列表」)
|
||||
model Article {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
/// 旧「网页id」原值保留:首页缩略图路径为 WebPages/{legacy_page_id}.png
|
||||
legacy_page_id String? @map("legacy_page_id") @db.VarChar(32)
|
||||
title String @db.VarChar(255)
|
||||
intro String? @db.VarChar(255)
|
||||
publisher String? @db.VarChar(255)
|
||||
published_at DateTime? @map("published_at")
|
||||
/// 正文 HTML。注意:内容中含绝对 URL,迁移时需做域名改写。
|
||||
content String? @db.LongText
|
||||
sort_order Int @default(0) @map("sort_order")
|
||||
status Int @default(1) @db.TinyInt
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@index([status, published_at])
|
||||
@@index([legacy_page_id])
|
||||
@@map("articles")
|
||||
}
|
||||
|
||||
/// 课程页文案(对应旧表「课程页面Tips」)
|
||||
model CourseTip {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
tip_key Int @unique @map("tip_key")
|
||||
content String? @db.Text
|
||||
title String? @db.TinyText
|
||||
|
||||
@@map("course_tips")
|
||||
}
|
||||
|
||||
/// 站点配置(对应旧表 cloudconfig,数字 ID 改为字符串 key)
|
||||
model Setting {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
key String @unique @db.VarChar(64)
|
||||
value String? @db.VarChar(255)
|
||||
remark String? @db.VarChar(255)
|
||||
|
||||
@@map("settings")
|
||||
}
|
||||
|
||||
/// 权益 — 播放鉴权的唯一判断入口
|
||||
model Entitlement {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
user_id Int @map("user_id") @db.UnsignedInt
|
||||
course_id Int @map("course_id") @db.UnsignedInt
|
||||
/// 1 订单 / 2 老数据(历史已购) / 3 后台发放
|
||||
source Int @db.TinyInt
|
||||
ref_id Int? @map("ref_id") @db.UnsignedInt
|
||||
granted_at DateTime @map("granted_at")
|
||||
expire_at DateTime? @map("expire_at")
|
||||
note String? @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
|
||||
user User @relation(fields: [user_id], references: [id])
|
||||
course Course @relation(fields: [course_id], references: [id])
|
||||
|
||||
@@unique([user_id, course_id])
|
||||
@@index([course_id])
|
||||
@@map("entitlements")
|
||||
}
|
||||
|
||||
/// 订单
|
||||
model Order {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
out_trade_no String @unique @map("out_trade_no") @db.VarChar(32)
|
||||
user_id Int @map("user_id") @db.UnsignedInt
|
||||
course_id Int @map("course_id") @db.UnsignedInt
|
||||
product_id String @map("product_id") @db.VarChar(64)
|
||||
quantity Int @default(1) @db.UnsignedInt
|
||||
amount_fen Int @map("amount_fen") @db.UnsignedInt
|
||||
/// 0 待支付 / 1 已支付 / 2 已发货 / 3 已退款 / 4 已关闭
|
||||
status Int @db.TinyInt
|
||||
/// 固定 0(正式环境)
|
||||
env Int @default(0) @db.TinyInt
|
||||
/// 微信平台单号,发货幂等依据
|
||||
wx_order_id String? @unique @map("wx_order_id") @db.VarChar(64)
|
||||
/// 下单时透传字段,发货通知原样带回
|
||||
attach String? @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
paid_at DateTime? @map("paid_at")
|
||||
delivered_at DateTime? @map("delivered_at")
|
||||
refunded_at DateTime? @map("refunded_at")
|
||||
|
||||
user User @relation(fields: [user_id], references: [id])
|
||||
course Course @relation(fields: [course_id], references: [id])
|
||||
|
||||
@@index([user_id, created_at])
|
||||
@@index([status, created_at])
|
||||
@@map("orders")
|
||||
}
|
||||
|
||||
/// 发货推送原始记录,用于幂等、重放与排查
|
||||
model PaymentNotifyLog {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
wx_order_id String? @map("wx_order_id") @db.VarChar(64)
|
||||
out_trade_no String? @map("out_trade_no") @db.VarChar(32)
|
||||
event String? @db.VarChar(64)
|
||||
raw_body String? @map("raw_body") @db.MediumText
|
||||
processed Int @default(0) @db.TinyInt
|
||||
err_msg String? @map("err_msg") @db.VarChar(255)
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([wx_order_id])
|
||||
@@map("payment_notify_log")
|
||||
}
|
||||
|
||||
/// 虚拟支付道具映射(course ↔ MP 后台道具)
|
||||
model Product {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
course_id Int @map("course_id") @db.UnsignedInt
|
||||
wx_product_id String @unique @map("wx_product_id") @db.VarChar(64)
|
||||
name String @db.VarChar(128)
|
||||
/// 必须与 MP 后台道具价格一致,单位「分」
|
||||
price_fen Int @map("price_fen") @db.UnsignedInt
|
||||
status Int @default(1) @db.TinyInt
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @updatedAt @map("updated_at")
|
||||
|
||||
course Course @relation(fields: [course_id], references: [id])
|
||||
|
||||
@@map("products")
|
||||
}
|
||||
|
||||
/// 管理员(与微信用户体系完全隔离)
|
||||
model AdminUser {
|
||||
id Int @id @default(autoincrement()) @db.UnsignedInt
|
||||
username String @unique @db.VarChar(64)
|
||||
password_hash String @map("password_hash") @db.VarChar(255)
|
||||
display_name String? @map("display_name") @db.VarChar(64)
|
||||
/// 1 超管 / 2 运营
|
||||
role Int @default(2) @db.TinyInt
|
||||
totp_secret String? @map("totp_secret") @db.VarChar(64)
|
||||
status Int @default(1) @db.TinyInt
|
||||
last_login_at DateTime? @map("last_login_at")
|
||||
created_at DateTime @default(now()) @map("created_at")
|
||||
updated_at DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@map("admin_users")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_FILTER, APP_GUARD, APP_INTERCEPTOR, Reflector } from '@nestjs/core';
|
||||
import { JwtModule, JwtService } from '@nestjs/jwt';
|
||||
import { ScheduleModule } from '@nestjs/schedule';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { APP_CONFIG, AppConfigModule, type AppConfig } from './config/config';
|
||||
import { AuthGuard } from './common/auth';
|
||||
import { AllExceptionsFilter, ResponseInterceptor } from './common/http';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { AdminModule } from './modules/admin/admin.module';
|
||||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { CoursesModule } from './modules/courses/courses.module';
|
||||
import { HomeModule } from './modules/home/home.module';
|
||||
import { MediaModule } from './modules/media/media.module';
|
||||
import { OrdersModule } from './modules/orders/orders.module';
|
||||
import { PaymentsModule } from './modules/payments/payments.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
AppConfigModule,
|
||||
PrismaModule,
|
||||
ScheduleModule.forRoot(),
|
||||
// 不设默认密钥:所有签发与校验都显式传密钥,避免用户态与管理端密钥混用
|
||||
JwtModule.register({ global: true }),
|
||||
LoggerModule.forRoot({
|
||||
pinoHttp: {
|
||||
level: process.env.NODE_ENV === 'production' ? 'info' : 'debug',
|
||||
// 脱敏:授权头与 Cookie 一律不落日志
|
||||
redact: ['req.headers.authorization', 'req.headers.cookie'],
|
||||
autoLogging: { ignore: (req: { url?: string }) => req.url === '/healthz' },
|
||||
},
|
||||
}),
|
||||
|
||||
AuthModule,
|
||||
UsersModule,
|
||||
HomeModule,
|
||||
CoursesModule,
|
||||
MediaModule,
|
||||
OrdersModule,
|
||||
PaymentsModule,
|
||||
AdminModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
{
|
||||
provide: APP_GUARD,
|
||||
useFactory: (reflector: Reflector, jwt: JwtService, cfg: AppConfig) =>
|
||||
new AuthGuard(reflector, jwt, cfg.JWT_ACCESS_SECRET),
|
||||
inject: [Reflector, JwtService, APP_CONFIG],
|
||||
},
|
||||
{ provide: APP_INTERCEPTOR, useClass: ResponseInterceptor },
|
||||
{ provide: APP_FILTER, useClass: AllExceptionsFilter },
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
type CanActivate,
|
||||
createParamDecorator,
|
||||
type ExecutionContext,
|
||||
Injectable,
|
||||
SetMetadata,
|
||||
} from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import type { Request } from 'express';
|
||||
import { AppError } from './errors';
|
||||
|
||||
export type Audience = 'user' | 'admin';
|
||||
|
||||
export interface AuthPrincipal {
|
||||
id: number;
|
||||
audience: Audience;
|
||||
}
|
||||
|
||||
const PUBLIC_KEY = 'isPublic';
|
||||
const AUDIENCE_KEY = 'audience';
|
||||
|
||||
/** 标记无需登录的接口。 */
|
||||
export const Public = (): MethodDecorator & ClassDecorator => SetMetadata(PUBLIC_KEY, true);
|
||||
|
||||
/** 标记该路由要求的身份类型,默认 user。 */
|
||||
export const RequiresAudience = (audience: Audience): MethodDecorator & ClassDecorator =>
|
||||
SetMetadata(AUDIENCE_KEY, audience);
|
||||
|
||||
export const IsPublic = (reflector: Reflector, ctx: ExecutionContext): boolean =>
|
||||
reflector.getAllAndOverride<boolean>(PUBLIC_KEY, [ctx.getHandler(), ctx.getClass()]) === true;
|
||||
|
||||
export const CurrentUser = createParamDecorator((_data: unknown, ctx: ExecutionContext): AuthPrincipal => {
|
||||
const req = ctx.switchToHttp().getRequest<Request & { principal?: AuthPrincipal }>();
|
||||
if (!req.principal) throw AppError.unauthorized();
|
||||
return req.principal;
|
||||
});
|
||||
|
||||
/**
|
||||
* 全局鉴权守卫。
|
||||
*
|
||||
* - 默认所有接口都需要登录
|
||||
* - 用 @Public() 放行
|
||||
* - 用 @RequiresAudience('admin') 要求管理员身份
|
||||
* - 小程序 token 与管理后台 token 使用不同密钥与不同 audience,互不通用
|
||||
*/
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
constructor(
|
||||
private readonly reflector: Reflector,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly accessSecret: string,
|
||||
) {}
|
||||
|
||||
async canActivate(ctx: ExecutionContext): Promise<boolean> {
|
||||
if (IsPublic(this.reflector, ctx)) return true;
|
||||
|
||||
const req = ctx.switchToHttp().getRequest<Request & { principal?: AuthPrincipal }>();
|
||||
const header = req.headers.authorization ?? '';
|
||||
const token = header.startsWith('Bearer ') ? header.slice(7).trim() : '';
|
||||
if (!token) throw AppError.unauthorized();
|
||||
|
||||
const required =
|
||||
this.reflector.getAllAndOverride<Audience>(AUDIENCE_KEY, [ctx.getHandler(), ctx.getClass()]) ?? 'user';
|
||||
|
||||
let payload: { sub: number | string; aud: Audience };
|
||||
try {
|
||||
payload = await this.jwt.verifyAsync(token, { secret: this.accessSecret, audience: required });
|
||||
} catch {
|
||||
throw AppError.unauthorized('登录已过期,请重新进入小程序');
|
||||
}
|
||||
|
||||
req.principal = { id: Number(payload.sub), audience: payload.aud };
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes, scryptSync, timingSafeEqual } from 'node:crypto';
|
||||
|
||||
/**
|
||||
* 用 AES-256-GCM 加密短字符串(当前用于 wx_session_key)。
|
||||
* 输出格式:v1.<iv b64url>.<tag b64url>.<ciphertext b64url>
|
||||
*/
|
||||
export function encryptSecret(plain: string, secret: string): string {
|
||||
const key = createHash('sha256').update(secret).digest();
|
||||
const iv = randomBytes(12);
|
||||
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
||||
const enc = Buffer.concat([cipher.update(plain, 'utf8'), cipher.final()]);
|
||||
return ['v1', iv.toString('base64url'), cipher.getAuthTag().toString('base64url'), enc.toString('base64url')].join('.');
|
||||
}
|
||||
|
||||
export function decryptSecret(payload: string, secret: string): string | null {
|
||||
const parts = payload.split('.');
|
||||
if (parts.length !== 4 || parts[0] !== 'v1') return null;
|
||||
try {
|
||||
const key = createHash('sha256').update(secret).digest();
|
||||
const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(parts[1]!, 'base64url'));
|
||||
decipher.setAuthTag(Buffer.from(parts[2]!, 'base64url'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(parts[3]!, 'base64url')), decipher.final()]).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理员口令哈希。用 Node 内置 scrypt,避免引入需要本地编译的 argon2。
|
||||
* 格式:scrypt.<salt b64url>.<hash b64url>
|
||||
*/
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(password, salt, 64);
|
||||
return ['scrypt', salt.toString('base64url'), hash.toString('base64url')].join('.');
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string): boolean {
|
||||
const parts = stored.split('.');
|
||||
if (parts.length !== 3 || parts[0] !== 'scrypt') return false;
|
||||
try {
|
||||
const expected = Buffer.from(parts[2]!, 'base64url');
|
||||
const actual = scryptSync(password, Buffer.from(parts[1]!, 'base64url'), expected.length);
|
||||
return timingSafeEqual(expected, actual);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { HttpException } from '@nestjs/common';
|
||||
|
||||
/**
|
||||
* 业务错误码分段。HTTP 状态码独立设置,两者互不替代。
|
||||
* 0 成功
|
||||
* 1000-1999 通用
|
||||
* 2000-2999 鉴权
|
||||
* 3000-3999 业务
|
||||
* 4000-4999 支付
|
||||
* 5000+ 服务端
|
||||
*/
|
||||
export const ErrorCode = {
|
||||
OK: 0,
|
||||
|
||||
BAD_REQUEST: 1000,
|
||||
NOT_FOUND: 1001,
|
||||
CONFLICT: 1002,
|
||||
|
||||
UNAUTHORIZED: 2000,
|
||||
TOKEN_EXPIRED: 2001,
|
||||
FORBIDDEN: 2002,
|
||||
|
||||
NO_ENTITLEMENT: 3000,
|
||||
COURSE_OFFLINE: 3001,
|
||||
LOGIN_FAILED: 3002,
|
||||
|
||||
ORDER_INVALID_STATE: 4000,
|
||||
PAY_SIGN_FAILED: 4001,
|
||||
PAY_NOT_ENABLED: 4002,
|
||||
|
||||
INTERNAL: 5000,
|
||||
} as const;
|
||||
|
||||
export type ErrorCodeValue = (typeof ErrorCode)[keyof typeof ErrorCode];
|
||||
|
||||
/** 业务异常。被全局异常过滤器转换为统一响应体。 */
|
||||
export class AppError extends HttpException {
|
||||
constructor(
|
||||
readonly code: ErrorCodeValue,
|
||||
message: string,
|
||||
httpStatus: number,
|
||||
) {
|
||||
super(message, httpStatus);
|
||||
}
|
||||
|
||||
static badRequest(message: string): AppError {
|
||||
return new AppError(ErrorCode.BAD_REQUEST, message, 422);
|
||||
}
|
||||
|
||||
static notFound(message = '资源不存在'): AppError {
|
||||
return new AppError(ErrorCode.NOT_FOUND, message, 404);
|
||||
}
|
||||
|
||||
static unauthorized(message = '请先登录'): AppError {
|
||||
return new AppError(ErrorCode.UNAUTHORIZED, message, 401);
|
||||
}
|
||||
|
||||
static forbidden(message = '无权访问'): AppError {
|
||||
return new AppError(ErrorCode.FORBIDDEN, message, 403);
|
||||
}
|
||||
|
||||
static noEntitlement(message = '尚未拥有该课程'): AppError {
|
||||
return new AppError(ErrorCode.NO_ENTITLEMENT, message, 403);
|
||||
}
|
||||
|
||||
static offline(message = '课程已下架'): AppError {
|
||||
return new AppError(ErrorCode.COURSE_OFFLINE, message, 403);
|
||||
}
|
||||
|
||||
static conflict(message: string): AppError {
|
||||
return new AppError(ErrorCode.CONFLICT, message, 409);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import {
|
||||
type ArgumentsHost,
|
||||
Catch,
|
||||
type CallHandler,
|
||||
type ExceptionFilter,
|
||||
type ExecutionContext,
|
||||
HttpException,
|
||||
Injectable,
|
||||
Logger,
|
||||
type NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import type { Observable } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
import { AppError, ErrorCode } from './errors';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
requestId: string;
|
||||
}
|
||||
|
||||
export function getRequestId(req: Request): string {
|
||||
return (req as Request & { requestId?: string }).requestId ?? randomUUID();
|
||||
}
|
||||
|
||||
/**
|
||||
* 为每个请求分配 requestId,并统一包装成功响应。
|
||||
*
|
||||
* 这里刻意不写成泛型:响应体的 data 由各控制器决定,
|
||||
* 用 unknown 承接可以避免 Nest 的类型推导在每个控制器上打架。
|
||||
*/
|
||||
@Injectable()
|
||||
export class ResponseInterceptor implements NestInterceptor<unknown, ApiResponse<unknown>> {
|
||||
intercept(context: ExecutionContext, next: CallHandler<unknown>): Observable<ApiResponse<unknown>> {
|
||||
const req = context.switchToHttp().getRequest<Request>();
|
||||
const requestId = randomUUID();
|
||||
(req as Request & { requestId?: string }).requestId = requestId;
|
||||
context.switchToHttp().getResponse<Response>().setHeader('x-request-id', requestId);
|
||||
|
||||
return next.handle().pipe(
|
||||
map((data: unknown): ApiResponse<unknown> => ({
|
||||
code: ErrorCode.OK,
|
||||
message: 'ok',
|
||||
data: data === undefined ? null : data,
|
||||
requestId,
|
||||
})),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** 把异常统一成同一响应体。 */
|
||||
@Catch()
|
||||
export class AllExceptionsFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger('Exception');
|
||||
|
||||
catch(exception: unknown, host: ArgumentsHost): void {
|
||||
const ctx = host.switchToHttp();
|
||||
const res = ctx.getResponse<Response>();
|
||||
const req = ctx.getRequest<Request>();
|
||||
const requestId = getRequestId(req);
|
||||
|
||||
let code: number = ErrorCode.INTERNAL;
|
||||
let message = '服务端异常';
|
||||
let status = 500;
|
||||
|
||||
if (exception instanceof AppError) {
|
||||
code = exception.code;
|
||||
message = exception.message;
|
||||
status = exception.getStatus();
|
||||
} else if (exception instanceof HttpException) {
|
||||
status = exception.getStatus();
|
||||
const body = exception.getResponse();
|
||||
message =
|
||||
typeof body === 'string'
|
||||
? body
|
||||
: ((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 {
|
||||
this.logger.error(
|
||||
`未处理异常 [${requestId}] ${req.method} ${req.url}: ${
|
||||
exception instanceof Error ? exception.stack : String(exception)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
res.status(status).json({ code, message, data: null, requestId });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* 静态资源 URL 构造。
|
||||
*
|
||||
* 这些路径由小程序直接拼接使用,**目录名与文件名规则不可更改**,
|
||||
* 否则线上资源全部 404。所有拼接集中在此处,便于审查。
|
||||
*/
|
||||
|
||||
const join = (base: string, ...parts: string[]): string =>
|
||||
[base.replace(/\/+$/, ''), ...parts.map((p) => String(p).replace(/^\/+/, ''))].join('/');
|
||||
|
||||
export const staticUrls = {
|
||||
banner: (base: string, filename: string): string => join(base, 'Images/Banner', filename),
|
||||
newCourseCover: (base: string, courseName: string): string =>
|
||||
join(base, 'Images/NewCourse', `${courseName}.png`),
|
||||
freeCourseCover: (base: string, courseName: string): string =>
|
||||
join(base, 'Images/FreeCourse', `${courseName}.png`),
|
||||
articleThumb: (base: string, legacyPageId: string): string => join(base, 'WebPages', `${legacyPageId}.png`),
|
||||
courseCover: (base: string, courseCode: string): string => join(base, 'Course', `${courseCode}.png`),
|
||||
headicon: (base: string, headiconId: number): string =>
|
||||
join(base, 'Images/Headicons', `Headicon${headiconId}.png`),
|
||||
wechatCode: (base: string, filename: string): string => join(base, 'Images/WXCode', `${filename}.png`),
|
||||
};
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { Global, Inject, Module } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
/** 进程启动时加载 .env(Node 20.12+ 内置,无需 dotenv)。 */
|
||||
export function loadEnvFile(path = '.env'): void {
|
||||
try {
|
||||
process.loadEnvFile(path);
|
||||
} catch {
|
||||
// 文件不存在时忽略:生产环境通常由进程管理器注入环境变量
|
||||
}
|
||||
}
|
||||
|
||||
const envSchema = z.object({
|
||||
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
|
||||
PORT: z.coerce.number().int().positive().default(3000),
|
||||
TZ: z.string().default('Asia/Shanghai'),
|
||||
|
||||
DATABASE_URL: z.string().min(1, '缺少数据库连接串'),
|
||||
|
||||
/** 对外域名 */
|
||||
API_BASE_URL: z.string().url(),
|
||||
STATIC_BASE_URL: z.string().url(),
|
||||
ADMIN_ORIGIN: z.string().url(),
|
||||
|
||||
/** 密钥:生产环境必须替换为随机值 */
|
||||
JWT_ACCESS_SECRET: z.string().min(16),
|
||||
JWT_REFRESH_SECRET: z.string().min(16),
|
||||
/** 用于 AES-256-GCM 加密存储用户 sessionKey,长度至少 32 */
|
||||
SESSION_KEY_ENC_SECRET: z.string().min(32),
|
||||
|
||||
/** 微信小程序 */
|
||||
WX_APPID: z.string().min(1),
|
||||
WX_APPSECRET: z.string().min(1),
|
||||
|
||||
/** 虚拟支付,未开通时留空 */
|
||||
WX_OFFER_ID: z.string().default(''),
|
||||
WX_PAY_APPKEY: z.string().default(''),
|
||||
|
||||
/** 播放地址签名有效期(秒) */
|
||||
PLAY_URL_TTL_SECONDS: z.coerce.number().int().positive().default(1800),
|
||||
|
||||
/** 播放地址签名密钥 */
|
||||
MEDIA_SIGN_SECRET: z.string().min(16),
|
||||
|
||||
/** 课程数据端根目录(媒体文件所在目录) */
|
||||
DATA_ROOT: z.string().min(1).default('../yelaymaths_data.yeomai.site'),
|
||||
});
|
||||
|
||||
export type AppConfig = z.infer<typeof envSchema>;
|
||||
|
||||
export const APP_CONFIG = Symbol('APP_CONFIG');
|
||||
|
||||
/** 供 NestJS 之外的工具脚本使用。 */
|
||||
export function readConfig(): AppConfig {
|
||||
const parsed = envSchema.safeParse(process.env);
|
||||
if (!parsed.success) {
|
||||
const detail = parsed.error.issues
|
||||
.map((i) => ` ${i.path.join('.') || '(root)'}: ${i.message}`)
|
||||
.join('\n');
|
||||
throw new Error(`环境变量校验失败:\n${detail}`);
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [{ provide: APP_CONFIG, useFactory: readConfig }],
|
||||
exports: [APP_CONFIG],
|
||||
})
|
||||
export class AppConfigModule {}
|
||||
|
||||
/** 注入配置的类型安全简写。 */
|
||||
export const InjectConfig = (): ParameterDecorator => Inject(APP_CONFIG);
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Public } from './common/auth';
|
||||
import { PrismaService } from './prisma/prisma.service';
|
||||
|
||||
@Controller()
|
||||
export class HealthController {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** 探活。数据库不可用时不返回 500,只标记 db 状态,便于区分进程挂掉与数据库挂掉。 */
|
||||
@Public()
|
||||
@Get('healthz')
|
||||
async health() {
|
||||
let db = 'ok';
|
||||
try {
|
||||
await this.prisma.$queryRaw`SELECT 1`;
|
||||
} catch {
|
||||
db = 'down';
|
||||
}
|
||||
return { status: 'ok', db, time: new Date().toISOString() };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { Logger } from 'nestjs-pino';
|
||||
import { AppModule } from './app.module';
|
||||
import { loadEnvFile, readConfig } from './config/config';
|
||||
|
||||
async function bootstrap(): Promise<void> {
|
||||
loadEnvFile();
|
||||
const cfg = readConfig();
|
||||
|
||||
const app = await NestFactory.create(AppModule, {
|
||||
// 虚拟支付回调是 XML,需要拿到原始报文
|
||||
rawBody: true,
|
||||
bufferLogs: true,
|
||||
});
|
||||
|
||||
app.useLogger(app.get(Logger));
|
||||
app.getHttpAdapter().getInstance().set('trust proxy', 1);
|
||||
|
||||
// 管理后台跨域;小程序不走 CORS
|
||||
app.enableCors({
|
||||
origin: [cfg.ADMIN_ORIGIN],
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
app.enableShutdownHooks();
|
||||
|
||||
await app.listen(cfg.PORT, '0.0.0.0');
|
||||
app.get(Logger).log(`API 已启动:${cfg.API_BASE_URL} 端口 ${cfg.PORT}`);
|
||||
}
|
||||
|
||||
void bootstrap();
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
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 { AppError } from '../../common/errors';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
const loginSchema = z.object({ username: z.string().min(1), password: z.string().min(1) });
|
||||
const bootstrapSchema = z.object({ username: z.string().min(3), password: z.string().min(8) });
|
||||
const updateUserSchema = z
|
||||
.object({
|
||||
nickname: z.string().max(32).optional(),
|
||||
headicon_id: z.coerce.number().int().min(0).optional(),
|
||||
status: z.coerce.number().int().min(0).max(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
const settingSchema = z.object({ key: z.string().min(1).max(64), value: z.string(), remark: z.string().optional() });
|
||||
const grantSchema = z.object({
|
||||
userId: z.coerce.number().int().positive(),
|
||||
courseId: z.coerce.number().int().positive(),
|
||||
expireAt: z.coerce.date().nullish(),
|
||||
note: z.string().max(255).optional(),
|
||||
});
|
||||
const refundSchema = z.object({ reason: z.string().min(1).max(100) });
|
||||
|
||||
/** 管理员登录。必须公开,否则登录接口本身会被守卫拦住。 */
|
||||
@Public()
|
||||
@Controller('admin/v1/auth')
|
||||
export class AdminAuthController {
|
||||
constructor(
|
||||
private readonly admin: AdminService,
|
||||
private readonly auth: AuthService,
|
||||
) {}
|
||||
|
||||
@Post('login')
|
||||
async login(@Body() body: unknown) {
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('请输入账号与密码');
|
||||
const admin = await this.admin.verifyAdmin(parsed.data.username, parsed.data.password);
|
||||
return { accessToken: await this.auth.signAdminToken(admin.id), admin };
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次部署创建超管。仅当系统中尚无任何管理员时生效,
|
||||
* 因此可以安全地保留在接口里作为初始化入口。
|
||||
*/
|
||||
@Post('bootstrap')
|
||||
bootstrap(@Body() body: unknown) {
|
||||
const parsed = bootstrapSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('用户名至少 3 位,密码至少 8 位');
|
||||
return this.admin.bootstrapAdmin(parsed.data.username, parsed.data.password);
|
||||
}
|
||||
}
|
||||
|
||||
/** 管理后台主接口。全部要求管理员身份。 */
|
||||
@RequiresAudience('admin')
|
||||
@Controller('admin/v1')
|
||||
export class AdminController {
|
||||
constructor(private readonly admin: AdminService) {}
|
||||
|
||||
@Get('stats')
|
||||
stats() {
|
||||
return this.admin.stats();
|
||||
}
|
||||
|
||||
// ── 用户 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Get('users')
|
||||
listUsers(
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
) {
|
||||
return this.admin.listUsers(Number(page) || 1, Number(pageSize) || 20, keyword ?? '');
|
||||
}
|
||||
|
||||
@Patch('users/:id')
|
||||
updateUser(@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);
|
||||
}
|
||||
|
||||
// ── 通用内容 CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
@Get('data/:resource')
|
||||
listResource(
|
||||
@Param('resource') resource: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('courseId') courseId?: string,
|
||||
) {
|
||||
const filter = courseId ? { course_id: Number(courseId) } : {};
|
||||
return this.admin.listResource(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);
|
||||
}
|
||||
|
||||
@Patch('data/:resource/:id')
|
||||
updateResource(
|
||||
@Param('resource') resource: string,
|
||||
@Param('id', ParseIntPipe) id: number,
|
||||
@Body() body: unknown,
|
||||
) {
|
||||
return this.admin.updateResource(resource, id, body);
|
||||
}
|
||||
|
||||
@Delete('data/:resource/:id')
|
||||
deleteResource(@Param('resource') resource: string, @Param('id', ParseIntPipe) id: number) {
|
||||
return this.admin.deleteResource(resource, id);
|
||||
}
|
||||
|
||||
// ── 配置 ───────────────────────────────────────────────────────────────
|
||||
|
||||
@Get('settings')
|
||||
listSettings() {
|
||||
return this.admin.listSettings();
|
||||
}
|
||||
|
||||
@Put('settings')
|
||||
upsertSetting(@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);
|
||||
}
|
||||
|
||||
// ── 订单 / 权益 ────────────────────────────────────────────────────────
|
||||
|
||||
@Get('orders')
|
||||
listOrders(@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);
|
||||
}
|
||||
|
||||
@Post('orders/:id/refund')
|
||||
refundOrder(@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);
|
||||
}
|
||||
|
||||
@Get('entitlements')
|
||||
listEntitlements(@Query('userId', ParseIntPipe) userId: number) {
|
||||
return this.admin.listEntitlements(userId);
|
||||
}
|
||||
|
||||
@Post('entitlements')
|
||||
grantEntitlement(@Body() body: unknown) {
|
||||
const parsed = grantSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误');
|
||||
return this.admin.grantEntitlement({
|
||||
userId: parsed.data.userId,
|
||||
courseId: parsed.data.courseId,
|
||||
expireAt: parsed.data.expireAt ?? null,
|
||||
note: parsed.data.note,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('entitlements')
|
||||
revokeEntitlement(@Query('userId', ParseIntPipe) userId: number, @Query('courseId', ParseIntPipe) courseId: number) {
|
||||
return this.admin.revokeEntitlement(userId, courseId);
|
||||
}
|
||||
|
||||
@Get('me')
|
||||
me(@CurrentUser() admin: AuthPrincipal) {
|
||||
return { id: admin.id, audience: admin.audience };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { AdminAuthController, AdminController } from './admin.controller';
|
||||
import { AdminService } from './admin.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, PaymentsModule],
|
||||
controllers: [AdminAuthController, AdminController],
|
||||
providers: [AdminService],
|
||||
})
|
||||
export class AdminModule {}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { hashPassword, verifyPassword } from '../../common/crypto';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { OrderStatus, PaymentsService } from '../payments/payments.service';
|
||||
import { WxPayService } from '../payments/wxpay.service';
|
||||
import { READONLY_RESOURCES, RESOURCES, type ResourceDef } from './resource-registry';
|
||||
|
||||
/** 通用委托接口。用最小结构约束代替 any,保持 strict 校验通过。 */
|
||||
interface Delegate {
|
||||
findMany: (args?: unknown) => Promise<unknown[]>;
|
||||
count: (args?: unknown) => Promise<number>;
|
||||
findUnique: (args: { where: { id: number } }) => Promise<Record<string, unknown> | null>;
|
||||
create: (args: { data: Record<string, unknown> }) => Promise<unknown>;
|
||||
update: (args: { where: { id: number }; data: Record<string, unknown> }) => Promise<unknown>;
|
||||
delete: (args: { where: { id: number } }) => Promise<unknown>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AdminService {
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly payments: PaymentsService,
|
||||
private readonly wxpay: WxPayService,
|
||||
) {}
|
||||
|
||||
private delegate(name: string): Delegate {
|
||||
const d = (this.prisma as unknown as Record<string, Delegate>)[name];
|
||||
if (!d) throw AppError.notFound(`未知资源:${name}`);
|
||||
return d;
|
||||
}
|
||||
|
||||
// ── 登录 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async verifyAdmin(username: string, password: string): Promise<{ id: number; role: number; displayName: string | null }> {
|
||||
const admin = await this.prisma.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() } });
|
||||
return { id: admin.id, role: admin.role, displayName: admin.display_name };
|
||||
}
|
||||
|
||||
/**
|
||||
* 首次部署时用于创建超管账号。已存在任何管理员时拒绝执行。
|
||||
* 避免在生产环境留下一个可被随意调用的建号后门。
|
||||
*/
|
||||
async bootstrapAdmin(username: string, password: string): Promise<{ created: boolean }> {
|
||||
const count = await this.prisma.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({
|
||||
data: { username, password_hash: hashPassword(password), display_name: '超级管理员', role: 1 },
|
||||
});
|
||||
return { created: true };
|
||||
}
|
||||
|
||||
// ── 看板 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async stats() {
|
||||
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({
|
||||
where: { status: OrderStatus.DELIVERED },
|
||||
_sum: { amount_fen: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
users,
|
||||
courses,
|
||||
orders,
|
||||
delivered,
|
||||
refunded,
|
||||
revenueFen: revenue._sum.amount_fen ?? 0,
|
||||
payEnabled: this.wxpay.enabled,
|
||||
};
|
||||
}
|
||||
|
||||
// ── 用户 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async listUsers(page = 1, pageSize = 20, keyword = '') {
|
||||
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({
|
||||
where,
|
||||
orderBy: { id: 'desc' },
|
||||
skip: (current - 1) * size,
|
||||
take: size,
|
||||
include: { _count: { select: { entitlements: true } } },
|
||||
}),
|
||||
this.prisma.user.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((u) => ({
|
||||
id: u.id,
|
||||
openid: u.openid,
|
||||
nickname: u.nickname,
|
||||
headicon_id: u.headicon_id,
|
||||
status: u.status,
|
||||
registered_at: u.registered_at,
|
||||
last_login_at: u.last_login_at,
|
||||
entitlement_count: u._count.entitlements,
|
||||
})),
|
||||
total,
|
||||
page: current,
|
||||
pageSize: size,
|
||||
};
|
||||
}
|
||||
|
||||
async updateUser(id: number, input: { nickname?: string; headicon_id?: number; status?: number }) {
|
||||
return this.prisma.user.update({ where: { id }, data: input });
|
||||
}
|
||||
|
||||
// ── 通用内容 CRUD ──────────────────────────────────────────────────────
|
||||
|
||||
async listResource(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}`);
|
||||
|
||||
const size = Math.min(Math.max(pageSize, 1), 100);
|
||||
const current = Math.max(page, 1);
|
||||
const searchField = def?.searchField;
|
||||
const where = {
|
||||
...filter,
|
||||
...(keyword && searchField ? { [searchField]: { contains: keyword } } : {}),
|
||||
};
|
||||
|
||||
const d = this.delegate(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 }),
|
||||
]);
|
||||
|
||||
return { items, total, page: current, pageSize: size };
|
||||
}
|
||||
|
||||
async createResource(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>) } });
|
||||
}
|
||||
|
||||
async updateResource(name: string, id: number, payload: unknown) {
|
||||
const def = RESOURCES[name];
|
||||
if (!def) throw AppError.notFound(`资源不可修改:${name}`);
|
||||
|
||||
// 更新时所有字段可选;不可变字段随后被剔除
|
||||
const partialSchema = (def.create as unknown as z.ZodObject<z.ZodRawShape>).partial();
|
||||
const parsed = partialSchema.safeParse(payload);
|
||||
if (!parsed.success) throw AppError.badRequest(formatZodError(parsed.error));
|
||||
|
||||
const data = { ...(parsed.data as Record<string, unknown>) };
|
||||
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 });
|
||||
}
|
||||
|
||||
async deleteResource(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 } });
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// ── 配置 ───────────────────────────────────────────────────────────────
|
||||
|
||||
async listSettings() {
|
||||
return this.prisma.setting.findMany({ orderBy: { id: 'asc' } });
|
||||
}
|
||||
|
||||
async upsertSetting(key: string, value: string, remark?: string) {
|
||||
return this.prisma.setting.upsert({
|
||||
where: { key },
|
||||
create: { key, value, remark: remark ?? null },
|
||||
update: { value, remark: remark ?? undefined },
|
||||
});
|
||||
}
|
||||
|
||||
// ── 订单与权益 ─────────────────────────────────────────────────────────
|
||||
|
||||
async listOrders(page = 1, pageSize = 20, status?: number) {
|
||||
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({
|
||||
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 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((o) => ({
|
||||
id: o.id,
|
||||
out_trade_no: o.out_trade_no,
|
||||
wx_order_id: o.wx_order_id,
|
||||
status: o.status,
|
||||
amount_fen: o.amount_fen,
|
||||
nickname: o.user.nickname,
|
||||
openid: o.user.openid,
|
||||
course_title: o.course.title,
|
||||
created_at: o.created_at,
|
||||
paid_at: o.paid_at,
|
||||
delivered_at: o.delivered_at,
|
||||
refunded_at: o.refunded_at,
|
||||
})),
|
||||
total,
|
||||
page: current,
|
||||
pageSize: size,
|
||||
};
|
||||
}
|
||||
|
||||
/** 发起退款(仅非 iOS 终端)。退款成功由退款推送回写订单状态。 */
|
||||
async refundOrder(orderId: number, reason: string) {
|
||||
const order = await this.prisma.order.findUnique({ where: { id: orderId }, include: { user: true } });
|
||||
if (!order) throw AppError.notFound('订单不存在');
|
||||
if (order.status !== OrderStatus.DELIVERED) throw AppError.conflict('只有已开通的订单可以退款');
|
||||
|
||||
const result = await this.wxpay.refundOrder({
|
||||
outTradeNo: order.out_trade_no,
|
||||
openid: order.user.openid,
|
||||
refundOrderId: `R${order.out_trade_no}`,
|
||||
refundAmountFen: order.amount_fen,
|
||||
reason: reason.slice(0, 100),
|
||||
});
|
||||
return { submitted: true, providerResponse: result };
|
||||
}
|
||||
|
||||
async listEntitlements(userId: number) {
|
||||
const rows = await this.prisma.entitlement.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { granted_at: 'desc' },
|
||||
include: { course: { select: { title: true, code: true } } },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
course_id: r.course_id,
|
||||
course_title: r.course.title,
|
||||
course_code: r.course.code,
|
||||
source: r.source,
|
||||
granted_at: r.granted_at,
|
||||
expire_at: r.expire_at,
|
||||
note: r.note,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 手动发放权益(赠课、补发、老用户补救)。 */
|
||||
async grantEntitlement(input: { userId: number; courseId: number; expireAt?: Date | null; note?: string }) {
|
||||
const [user, course] = await Promise.all([
|
||||
this.prisma.user.findUnique({ where: { id: input.userId } }),
|
||||
this.prisma.course.findUnique({ where: { id: input.courseId } }),
|
||||
]);
|
||||
if (!user) throw AppError.notFound('用户不存在');
|
||||
if (!course) throw AppError.notFound('课程不存在');
|
||||
|
||||
return this.prisma.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: input.userId, course_id: input.courseId } },
|
||||
create: {
|
||||
user_id: input.userId,
|
||||
course_id: input.courseId,
|
||||
source: 3,
|
||||
granted_at: new Date(),
|
||||
expire_at: input.expireAt ?? null,
|
||||
note: input.note ?? '后台手动发放',
|
||||
},
|
||||
update: {
|
||||
expire_at: input.expireAt ?? null,
|
||||
note: input.note ?? '后台手动发放',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async revokeEntitlement(userId: number, courseId: number): Promise<{ deleted: true }> {
|
||||
await this.prisma.entitlement.delete({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
});
|
||||
return { deleted: true };
|
||||
}
|
||||
}
|
||||
|
||||
function formatZodError(error: z.ZodError): string {
|
||||
return error.issues.map((i) => `${i.path.join('.') || '(root)'}: ${i.message}`).join(';');
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
/**
|
||||
* 可 CRUD 的资源注册表。
|
||||
*
|
||||
* 字段名与 Prisma 模型一致(snake_case),因此校验通过后可直接透传给 Prisma,
|
||||
* 不需要额外的字段映射层。
|
||||
*
|
||||
* 一个通用控制器 + 这张表覆盖了管理后台的绝大部分内容管理功能。
|
||||
*/
|
||||
|
||||
const dateish = z.union([z.string(), z.date(), z.null()]);
|
||||
const optionalDate = dateish.optional();
|
||||
|
||||
export interface ResourceDef {
|
||||
/** Prisma 委托名,如 'course' → prisma.course */
|
||||
delegate: string;
|
||||
create: z.ZodTypeAny;
|
||||
/** 创建后不可修改的字段(如 courses.code,改动会让视频路径失效) */
|
||||
immutable?: string[];
|
||||
orderBy: Record<string, 'asc' | 'desc'>[];
|
||||
/** 列表模糊搜索字段 */
|
||||
searchField?: string;
|
||||
/** 创建时需要自动补齐的字段 */
|
||||
defaults?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export const RESOURCES: Record<string, ResourceDef> = {
|
||||
courses: {
|
||||
delegate: 'course',
|
||||
create: z.object({
|
||||
code: z.string().min(1).max(64),
|
||||
title: z.string().min(1).max(128),
|
||||
intro: z.string().max(255).nullish(),
|
||||
cover_image: z.string().max(255).nullish(),
|
||||
price_fen: z.coerce.number().int().min(0).default(0),
|
||||
status: z.coerce.number().int().min(0).max(1).default(1),
|
||||
sort_order: z.coerce.number().int().default(0),
|
||||
}),
|
||||
// code 是视频目录名与封面名,改动即失效,只允许创建时设置
|
||||
immutable: ['code'],
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
searchField: 'title',
|
||||
},
|
||||
|
||||
lessons: {
|
||||
delegate: 'lesson',
|
||||
create: z.object({
|
||||
course_id: z.coerce.number().int().positive(),
|
||||
play_id: z.string().min(1).max(64),
|
||||
chapter_id: z.string().min(1).max(64),
|
||||
chapter_title: z.string().max(255).nullish(),
|
||||
lesson_title: z.string().max(255).nullish(),
|
||||
duration: z.string().max(16).nullish(),
|
||||
published_at: optionalDate,
|
||||
cover_image: z.string().max(255).nullish(),
|
||||
sort_order: z.coerce.number().int().default(0),
|
||||
}),
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
searchField: 'lesson_title',
|
||||
},
|
||||
|
||||
'home-courses': {
|
||||
delegate: 'homeCourse',
|
||||
create: z.object({
|
||||
course_name: z.string().min(1).max(255),
|
||||
intro: z.string().max(255).nullish(),
|
||||
published_at: optionalDate,
|
||||
cover_image: z.string().max(255).nullish(),
|
||||
content: z.string().nullish(),
|
||||
course_id: z.coerce.number().int().positive().nullish(),
|
||||
sort_order: z.coerce.number().int().default(0),
|
||||
status: z.coerce.number().int().min(0).max(1).default(1),
|
||||
}),
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
searchField: 'course_name',
|
||||
},
|
||||
|
||||
'home-free-courses': {
|
||||
delegate: 'homeFreeCourse',
|
||||
create: z.object({
|
||||
series_no: z.coerce.number().int(),
|
||||
course_name: z.string().min(1).max(255),
|
||||
intro: z.string().max(255).nullish(),
|
||||
published_at: optionalDate,
|
||||
cover_image: z.string().max(255).nullish(),
|
||||
sort_order: z.coerce.number().int().default(0),
|
||||
status: z.coerce.number().int().min(0).max(1).default(1),
|
||||
}),
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
searchField: 'course_name',
|
||||
},
|
||||
|
||||
banners: {
|
||||
delegate: 'banner',
|
||||
create: z.object({
|
||||
filename: z.string().min(1).max(255),
|
||||
sort_order: z.coerce.number().int().default(0),
|
||||
status: z.coerce.number().int().min(0).max(1).default(1),
|
||||
}),
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
searchField: 'filename',
|
||||
},
|
||||
|
||||
articles: {
|
||||
delegate: 'article',
|
||||
create: z.object({
|
||||
legacy_page_id: z.string().max(32).nullish(),
|
||||
title: z.string().min(1).max(255),
|
||||
intro: z.string().max(255).nullish(),
|
||||
publisher: z.string().max(255).nullish(),
|
||||
published_at: optionalDate,
|
||||
content: z.string().nullish(),
|
||||
sort_order: z.coerce.number().int().default(0),
|
||||
status: z.coerce.number().int().min(0).max(1).default(1),
|
||||
}),
|
||||
orderBy: [{ published_at: 'desc' }, { id: 'desc' }],
|
||||
searchField: 'title',
|
||||
},
|
||||
|
||||
products: {
|
||||
delegate: 'product',
|
||||
create: z.object({
|
||||
course_id: z.coerce.number().int().positive(),
|
||||
wx_product_id: z.string().min(1).max(64),
|
||||
name: z.string().min(1).max(128),
|
||||
price_fen: z.coerce.number().int().min(0),
|
||||
status: z.coerce.number().int().min(0).max(1).default(1),
|
||||
}),
|
||||
orderBy: [{ id: 'asc' }],
|
||||
searchField: 'name',
|
||||
},
|
||||
};
|
||||
|
||||
/** 免费试听课时(只读展示用,实际内容由数据端维护) */
|
||||
export const READONLY_RESOURCES: Record<string, { delegate: string; orderBy: Record<string, 'asc' | 'desc'>[] }> = {
|
||||
'free-lessons': { delegate: 'freeLesson', orderBy: [{ series_no: 'asc' }, { sort_order: 'asc' }] },
|
||||
};
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { CurrentUser, Public, type AuthPrincipal } from '../../common/auth';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { AuthService, type LoginResult } from './auth.service';
|
||||
|
||||
const loginSchema = z.object({ code: z.string().min(1) });
|
||||
const refreshSchema = z.object({ refreshToken: z.string().min(1) });
|
||||
|
||||
@Controller('v1/auth')
|
||||
export class AuthController {
|
||||
constructor(private readonly auth: AuthService) {}
|
||||
|
||||
@Public()
|
||||
@Post('wx-login')
|
||||
async wxLogin(@Body() body: unknown): Promise<LoginResult> {
|
||||
const parsed = loginSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误:缺少 code');
|
||||
return this.auth.loginWithCode(parsed.data.code);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refresh')
|
||||
refresh(@Body() body: unknown): Promise<{ accessToken: string }> {
|
||||
const parsed = refreshSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误:缺少 refreshToken');
|
||||
return this.auth.refresh(parsed.data.refreshToken);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
logout(@CurrentUser() user: AuthPrincipal): Promise<null> {
|
||||
return this.auth.logOut(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { WechatService } from './wechat.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AuthController],
|
||||
providers: [AuthService, WechatService],
|
||||
exports: [AuthService],
|
||||
})
|
||||
export class AuthModule {}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { encryptSecret } from '../../common/crypto';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WechatService } from './wechat.service';
|
||||
|
||||
// 用字面量类型,匹配 jsonwebtoken 的 expiresIn 签名
|
||||
export const ACCESS_TTL = '2h' as const;
|
||||
export const REFRESH_TTL = '30d' as const;
|
||||
|
||||
export interface LoginResult {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user: { id: number; nickname: string | null; headiconId: number; isNew: boolean };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly jwt: JwtService,
|
||||
private readonly wechat: WechatService,
|
||||
) {}
|
||||
|
||||
async loginWithCode(code: string): Promise<LoginResult> {
|
||||
if (!code) throw AppError.badRequest('缺少 code');
|
||||
|
||||
const session = await this.wechat.code2Session(code);
|
||||
const existing = await this.prisma.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({
|
||||
where: { id: existing.id },
|
||||
data: { wx_session_key: sessionKeyEnc, session_key_updated_at: now, last_login_at: now },
|
||||
})
|
||||
: await this.prisma.user.create({
|
||||
data: {
|
||||
openid: session.openid,
|
||||
wx_session_key: sessionKeyEnc,
|
||||
session_key_updated_at: now,
|
||||
registered_at: now,
|
||||
last_login_at: now,
|
||||
},
|
||||
});
|
||||
|
||||
if (user.status !== 1) throw AppError.forbidden('账号已被封禁');
|
||||
|
||||
return {
|
||||
accessToken: await this.sign(user.id, 'user', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET),
|
||||
refreshToken: await this.sign(user.id, 'user', REFRESH_TTL, this.cfg.JWT_REFRESH_SECRET),
|
||||
user: { id: user.id, nickname: user.nickname, headiconId: user.headicon_id, isNew: !existing },
|
||||
};
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string): Promise<{ accessToken: string }> {
|
||||
if (!refreshToken) throw AppError.badRequest('缺少 refreshToken');
|
||||
let sub: number;
|
||||
try {
|
||||
const payload = await this.jwt.verifyAsync<{ sub: number }>(refreshToken, {
|
||||
secret: this.cfg.JWT_REFRESH_SECRET,
|
||||
audience: 'user',
|
||||
});
|
||||
sub = Number(payload.sub);
|
||||
} catch {
|
||||
throw AppError.unauthorized('登录已过期,请重新进入小程序');
|
||||
}
|
||||
const user = await this.prisma.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({
|
||||
where: { id: userId },
|
||||
data: { wx_session_key: null, session_key_updated_at: null },
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 管理员登录签发,供管理端模块复用。 */
|
||||
signAdminToken(adminId: number): Promise<string> {
|
||||
return this.sign(adminId, 'admin', ACCESS_TTL, this.cfg.JWT_ACCESS_SECRET);
|
||||
}
|
||||
|
||||
private sign(
|
||||
sub: number,
|
||||
aud: 'user' | 'admin',
|
||||
expiresIn: ExpiresIn,
|
||||
secret: string,
|
||||
): Promise<string> {
|
||||
return this.jwt.signAsync({ sub }, { audience: aud, expiresIn, secret });
|
||||
}
|
||||
}
|
||||
/** jsonwebtoken 接受的过期时间写法(避免直接依赖其类型包) */
|
||||
type ExpiresIn = number | `${number}${'s' | 'm' | 'h' | 'd' | 'w' | 'y'}`;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { AppError } from '../../common/errors';
|
||||
|
||||
interface Code2SessionResult {
|
||||
openid?: string;
|
||||
session_key?: string;
|
||||
unionid?: string;
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
}
|
||||
|
||||
export interface WxSession {
|
||||
openid: string;
|
||||
sessionKey: string;
|
||||
unionid: string | null;
|
||||
}
|
||||
|
||||
/** 微信开放接口封装。仅 code2Session 一个方法,用 Node 内置 fetch,不引第三方 SDK。 */
|
||||
@Injectable()
|
||||
export class WechatService {
|
||||
private readonly logger = new Logger(WechatService.name);
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
|
||||
|
||||
async code2Session(code: string): Promise<WxSession> {
|
||||
const url =
|
||||
'https://api.weixin.qq.com/sns/jscode2session' +
|
||||
`?appid=${encodeURIComponent(this.cfg.WX_APPID)}` +
|
||||
`&secret=${encodeURIComponent(this.cfg.WX_APPSECRET)}` +
|
||||
`&js_code=${encodeURIComponent(code)}` +
|
||||
'&grant_type=authorization_code';
|
||||
|
||||
const res = await fetch(url);
|
||||
const body = (await res.json()) as Code2SessionResult;
|
||||
|
||||
if (!body.openid || !body.session_key) {
|
||||
this.logger.warn(`code2Session 失败:errcode=${body.errcode ?? '-'} errmsg=${body.errmsg ?? ''}`);
|
||||
throw new AppError(3002, '微信登录失败,请重试', 401);
|
||||
}
|
||||
return { openid: body.openid, sessionKey: body.session_key, unionid: body.unionid ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { CurrentUser, Public, type AuthPrincipal } from '../../common/auth';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { CoursesService } from './courses.service';
|
||||
|
||||
@Controller('v1')
|
||||
export class CoursesController {
|
||||
constructor(private readonly courses: CoursesService) {}
|
||||
|
||||
@Get('courses')
|
||||
list(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.courses.list(user.id);
|
||||
}
|
||||
|
||||
@Get('courses/:code')
|
||||
detail(@CurrentUser() user: AuthPrincipal, @Param('code') code: string) {
|
||||
return this.courses.detail(user.id, code);
|
||||
}
|
||||
|
||||
@Get('courses/:code/lessons')
|
||||
lessons(@CurrentUser() user: AuthPrincipal, @Param('code') code: string) {
|
||||
return this.courses.lessons(user.id, code);
|
||||
}
|
||||
|
||||
@Get('courses/:code/play')
|
||||
play(
|
||||
@CurrentUser() user: AuthPrincipal,
|
||||
@Param('code') code: string,
|
||||
@Query('chapterId') chapterId?: string,
|
||||
@Query('playId') playId?: string,
|
||||
) {
|
||||
if (!chapterId || !playId) throw AppError.badRequest('参数错误:缺少 chapterId 或 playId');
|
||||
return this.courses.playUrl(user.id, code, chapterId, playId);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('free-lessons')
|
||||
freeLessons(@Query('seriesNo') seriesNo?: string) {
|
||||
if (seriesNo === undefined || seriesNo === '') return this.courses.freeLessons();
|
||||
const n = Number(seriesNo);
|
||||
return this.courses.freeLessons(Number.isFinite(n) ? n : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
import { CoursesController } from './courses.controller';
|
||||
import { CoursesService } from './courses.service';
|
||||
|
||||
@Module({
|
||||
imports: [MediaModule],
|
||||
controllers: [CoursesController],
|
||||
providers: [CoursesService],
|
||||
exports: [CoursesService],
|
||||
})
|
||||
export class CoursesModule {}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { staticUrls } from '../../common/urls';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { MediaService } from '../media/media.service';
|
||||
|
||||
@Injectable()
|
||||
export class CoursesService {
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly media: MediaService,
|
||||
) {}
|
||||
|
||||
private staticBase(): string {
|
||||
return this.cfg.STATIC_BASE_URL;
|
||||
}
|
||||
|
||||
private async ownedCourseIds(userId: number): Promise<Set<number>> {
|
||||
const rows = await this.prisma.entitlement.findMany({
|
||||
where: { user_id: userId },
|
||||
select: { course_id: true },
|
||||
});
|
||||
return new Set(rows.map((r) => r.course_id));
|
||||
}
|
||||
|
||||
/** 课程列表。含未购买课程,供小程序做购买入口。 */
|
||||
async list(userId: number) {
|
||||
const [courses, owned] = await Promise.all([
|
||||
this.prisma.course.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
include: { _count: { select: { lessons: true } } },
|
||||
}),
|
||||
this.ownedCourseIds(userId),
|
||||
]);
|
||||
|
||||
return courses.map((c) => ({
|
||||
id: c.id,
|
||||
code: c.code,
|
||||
title: c.title,
|
||||
intro: c.intro,
|
||||
priceFen: c.price_fen,
|
||||
lessonCount: c._count.lessons,
|
||||
coverUrl: staticUrls.courseCover(this.staticBase(), c.code),
|
||||
owned: owned.has(c.id),
|
||||
}));
|
||||
}
|
||||
|
||||
async detail(userId: number, code: string) {
|
||||
const course = await this.prisma.course.findUnique({ where: { code } });
|
||||
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
||||
|
||||
const owned = await this.prisma.entitlement.findUnique({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
||||
});
|
||||
|
||||
return {
|
||||
id: course.id,
|
||||
code: course.code,
|
||||
title: course.title,
|
||||
intro: course.intro,
|
||||
priceFen: course.price_fen,
|
||||
coverUrl: staticUrls.courseCover(this.staticBase(), course.code),
|
||||
owned: owned !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 课时列表。必须拥有该课程才能取到。 */
|
||||
async lessons(userId: number, code: string) {
|
||||
const course = await this.requireEntitlement(userId, code);
|
||||
|
||||
const rows = await this.prisma.lesson.findMany({
|
||||
where: { course_id: course.id },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
|
||||
return rows.map((l) => ({
|
||||
id: l.id,
|
||||
chapterId: l.chapter_id,
|
||||
playId: l.play_id,
|
||||
chapterTitle: l.chapter_title,
|
||||
lessonTitle: l.lesson_title,
|
||||
duration: l.duration,
|
||||
publishedAt: l.published_at,
|
||||
coverUrl: staticUrls.courseCover(this.staticBase(), course.code),
|
||||
}));
|
||||
}
|
||||
|
||||
/** 下发带签名的播放地址。视频不做校验拿不到,防盗链的关键在这里。 */
|
||||
async playUrl(userId: number, code: string, chapterId: string, playId: string) {
|
||||
const course = await this.requireEntitlement(userId, code);
|
||||
|
||||
const lesson = await this.prisma.lesson.findFirst({
|
||||
where: { course_id: course.id, chapter_id: chapterId, play_id: playId },
|
||||
});
|
||||
if (!lesson) throw AppError.notFound('课时不存在');
|
||||
|
||||
const relative = `Course/${course.code}/${lesson.chapter_id}/${lesson.play_id}/playlist.m3u8`;
|
||||
return {
|
||||
url: this.media.buildSignedUrl(relative),
|
||||
expiresIn: this.cfg.PLAY_URL_TTL_SECONDS,
|
||||
title: lesson.lesson_title,
|
||||
};
|
||||
}
|
||||
|
||||
/** 免费试听课。公开内容,直接给静态地址,不签名。 */
|
||||
async freeLessons(seriesNo?: number) {
|
||||
const rows = await this.prisma.freeLesson.findMany({
|
||||
where: seriesNo === undefined ? undefined : { series_no: seriesNo },
|
||||
orderBy: [{ series_no: 'asc' }, { sort_order: 'asc' }],
|
||||
});
|
||||
const base = this.staticBase().replace(/\/+$/, '');
|
||||
return rows.map((l) => ({
|
||||
id: l.id,
|
||||
seriesNo: l.series_no,
|
||||
courseName: l.course_name,
|
||||
videoTitle: l.video_title,
|
||||
intro: l.intro,
|
||||
publisher: l.publisher,
|
||||
publishedAt: l.published_at,
|
||||
duration: l.duration,
|
||||
coverUrl: l.cover_image ? `${base}/${l.cover_image.replace(/^\/+/, '')}` : null,
|
||||
videoUrl: l.video_m3u8 ? `${base}/${l.video_m3u8.replace(/^\/+/, '')}` : null,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 取课程并校验权益,是唯一的鉴权入口。 */
|
||||
private async requireEntitlement(userId: number, code: string) {
|
||||
const course = await this.prisma.course.findUnique({ where: { code } });
|
||||
if (!course) throw AppError.notFound('课程不存在');
|
||||
if (course.status !== 1) throw AppError.offline();
|
||||
|
||||
const owned = await this.prisma.entitlement.findUnique({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: course.id } },
|
||||
});
|
||||
if (!owned) throw AppError.noEntitlement();
|
||||
if (owned.expire_at && owned.expire_at.getTime() < Date.now()) {
|
||||
throw AppError.noEntitlement('课程已过期');
|
||||
}
|
||||
|
||||
return course;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
|
||||
import { Public } from '../../common/auth';
|
||||
import { HomeService } from './home.service';
|
||||
|
||||
@Controller('v1')
|
||||
export class HomeController {
|
||||
constructor(private readonly home: HomeService) {}
|
||||
|
||||
@Public()
|
||||
@Get('config')
|
||||
getConfig(): Promise<Record<string, string>> {
|
||||
return this.home.getConfig();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('banners')
|
||||
getBanners() {
|
||||
return this.home.getBanners();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('home/courses')
|
||||
getHomeCourses() {
|
||||
return this.home.getHomeCourses();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('home/free-courses')
|
||||
getHomeFreeCourses() {
|
||||
return this.home.getHomeFreeCourses();
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('articles')
|
||||
getArticles(@Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return this.home.getArticles(Number(page) || 1, Number(pageSize) || 10);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('articles/:id')
|
||||
getArticle(@Param('id', ParseIntPipe) id: number) {
|
||||
return this.home.getArticle(id);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Get('tips')
|
||||
getTips() {
|
||||
return this.home.getTips();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { HomeController } from './home.controller';
|
||||
import { HomeService } from './home.service';
|
||||
|
||||
@Module({
|
||||
controllers: [HomeController],
|
||||
providers: [HomeService],
|
||||
})
|
||||
export class HomeModule {}
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { staticUrls } from '../../common/urls';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
const PAGE_SIZE_MAX = 50;
|
||||
|
||||
@Injectable()
|
||||
export class HomeService {
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
private get staticBase(): string {
|
||||
return this.cfg.STATIC_BASE_URL;
|
||||
}
|
||||
|
||||
/** 站点配置,返回 key → value 的扁平对象。 */
|
||||
async getConfig(): Promise<Record<string, string>> {
|
||||
const rows = await this.prisma.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({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
filename: r.filename,
|
||||
imageUrl: staticUrls.banner(this.staticBase, r.filename),
|
||||
}));
|
||||
}
|
||||
|
||||
async getHomeCourses() {
|
||||
const rows = await this.prisma.homeCourse.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
include: { course: { select: { code: true, title: true } } },
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
courseName: r.course_name,
|
||||
intro: r.intro,
|
||||
publishedAt: r.published_at,
|
||||
coverUrl: staticUrls.newCourseCover(this.staticBase, r.course_name),
|
||||
// 长图正文里的绝对地址已在迁移时改写到新静态域名
|
||||
content: r.content,
|
||||
courseCode: r.course?.code ?? null,
|
||||
courseTitle: r.course?.title ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
async getHomeFreeCourses() {
|
||||
const rows = await this.prisma.homeFreeCourse.findMany({
|
||||
where: { status: 1 },
|
||||
orderBy: [{ sort_order: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
seriesNo: r.series_no,
|
||||
courseName: r.course_name,
|
||||
intro: r.intro,
|
||||
coverUrl: staticUrls.freeCourseCover(this.staticBase, r.course_name),
|
||||
}));
|
||||
}
|
||||
|
||||
async getArticles(page = 1, pageSize = 10) {
|
||||
const size = Math.min(Math.max(pageSize, 1), PAGE_SIZE_MAX);
|
||||
const current = Math.max(page, 1);
|
||||
const where = { status: 1 };
|
||||
|
||||
const [items, total] = await this.prisma.$transaction([
|
||||
this.prisma.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 }),
|
||||
]);
|
||||
|
||||
return {
|
||||
items: items.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
intro: r.intro,
|
||||
publisher: r.publisher,
|
||||
publishedAt: r.published_at,
|
||||
thumbUrl: r.legacy_page_id ? staticUrls.articleThumb(this.staticBase, r.legacy_page_id) : null,
|
||||
})),
|
||||
total,
|
||||
page: current,
|
||||
pageSize: size,
|
||||
};
|
||||
}
|
||||
|
||||
async getArticle(id: number) {
|
||||
const row = await this.prisma.article.findFirst({ where: { id, status: 1 } });
|
||||
if (!row) return null;
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
intro: row.intro,
|
||||
publisher: row.publisher,
|
||||
publishedAt: row.published_at,
|
||||
content: row.content,
|
||||
thumbUrl: row.legacy_page_id ? staticUrls.articleThumb(this.staticBase, row.legacy_page_id) : null,
|
||||
};
|
||||
}
|
||||
|
||||
async getTips(): Promise<{ content: string; title: string | null }> {
|
||||
// 旧前端读的是 tip_key=0 那条
|
||||
const row = await this.prisma.courseTip.findUnique({ where: { tip_key: 0 } });
|
||||
return { content: row?.content ?? '', title: row?.title ?? null };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Controller, Get, Param, Query, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Public } from '../../common/auth';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@Controller('media')
|
||||
export class MediaController {
|
||||
constructor(private readonly media: MediaService) {}
|
||||
|
||||
/**
|
||||
* 签名媒体入口。签名由 /v1/courses/:code/play 下发,
|
||||
* 有效期由 PLAY_URL_TTL_SECONDS 控制。
|
||||
*
|
||||
* 用通配路由承接多级路径(HLS 是 {code}/{chapter}/{play}/playlist.m3u8 这种层级)。
|
||||
*/
|
||||
@Public()
|
||||
@Get('*path')
|
||||
async serve(
|
||||
@Req() req: Request,
|
||||
@Res() res: Response,
|
||||
@Query('t') t?: string,
|
||||
@Query('s') s?: string,
|
||||
): Promise<void> {
|
||||
const wildcard = (req.params as Record<string, string | string[]>).path;
|
||||
const relativePath = Array.isArray(wildcard) ? wildcard.join('/') : (wildcard ?? '');
|
||||
|
||||
if (!t || !s || !this.media.verify(relativePath, Number(t), s)) {
|
||||
res.status(403).json({ code: 2002, message: '播放地址无效或已过期', data: null });
|
||||
return;
|
||||
}
|
||||
await this.media.send(res, relativePath);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MediaController } from './media.controller';
|
||||
import { MediaService } from './media.service';
|
||||
|
||||
@Module({
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import { createReadStream, existsSync, statSync } from 'node:fs';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import type { Response } from 'express';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
|
||||
const MIME: Record<string, string> = {
|
||||
'.m3u8': 'application/vnd.apple.mpegurl',
|
||||
'.ts': 'video/mp2t',
|
||||
'.mp4': 'video/mp4',
|
||||
'.key': 'application/octet-stream',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.webp': 'image/webp',
|
||||
};
|
||||
|
||||
/** 播放列表里需要带签名的资源类型 */
|
||||
const REWRITABLE = /\.(ts|m3u8|key|mp4|jpg|jpeg|png|webp)(\?.*)?$/i;
|
||||
|
||||
@Injectable()
|
||||
export class MediaService {
|
||||
private readonly root: string;
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {
|
||||
this.root = path.resolve(process.cwd(), cfg.DATA_ROOT);
|
||||
}
|
||||
|
||||
private sign(relativePath: string, expiresAt: number): string {
|
||||
return createHmac('sha256', this.cfg.MEDIA_SIGN_SECRET)
|
||||
.update(`${relativePath}|${expiresAt}`)
|
||||
.digest('base64url');
|
||||
}
|
||||
|
||||
/** 生成带有效期的播放地址。relativePath 形如 Course/{code}/{chapter}/{play}/playlist.m3u8 */
|
||||
buildSignedUrl(relativePath: string): string {
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + this.cfg.PLAY_URL_TTL_SECONDS;
|
||||
const sig = this.sign(relativePath, expiresAt);
|
||||
const clean = relativePath.split('/').map(encodeURIComponent).join('/');
|
||||
return `${this.cfg.API_BASE_URL.replace(/\/+$/, '')}/media/${clean}?t=${expiresAt}&s=${sig}`;
|
||||
}
|
||||
|
||||
verify(relativePath: string, expiresAt: number, sig: string): boolean {
|
||||
if (!Number.isFinite(expiresAt) || expiresAt * 1000 < Date.now()) return false;
|
||||
const expected = Buffer.from(this.sign(relativePath, expiresAt));
|
||||
const given = Buffer.from(sig);
|
||||
return expected.length === given.length && timingSafeEqual(expected, given);
|
||||
}
|
||||
|
||||
/**
|
||||
* 把请求路径解析到数据端根目录内的真实文件。
|
||||
* 越界(路径穿越)一律拒绝。
|
||||
*/
|
||||
resolve(relativePath: string): string | null {
|
||||
const normalized = path
|
||||
.normalize(decodeURIComponent(relativePath))
|
||||
.replace(/^([/\\])+/, '');
|
||||
const abs = path.resolve(this.root, normalized);
|
||||
if (abs !== this.root && !abs.startsWith(this.root + path.sep)) return null;
|
||||
if (!existsSync(abs) || !statSync(abs).isFile()) return null;
|
||||
return abs;
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放列表重写:HLS 的 .ts 分片与子播放列表是相对引用,
|
||||
* 只给主 m3u8 签名保护不了分片,必须把签名透传到每一行引用上。
|
||||
*/
|
||||
private rewritePlaylist(content: string, relativePath: string): string {
|
||||
const dir = path.posix.dirname(relativePath);
|
||||
const expiresAt = Math.floor(Date.now() / 1000) + this.cfg.PLAY_URL_TTL_SECONDS;
|
||||
|
||||
const withToken = (ref: string): string => {
|
||||
if (/^[a-z]+:\/\//i.test(ref) || ref.startsWith('data:')) return ref;
|
||||
const target = ref.startsWith('/') ? ref.slice(1) : path.posix.normalize(path.posix.join(dir, ref));
|
||||
const sig = this.sign(target, expiresAt);
|
||||
const sep = ref.includes('?') ? '&' : '?';
|
||||
return `${ref}${sep}t=${expiresAt}&s=${sig}`;
|
||||
};
|
||||
|
||||
return content
|
||||
.split('\n')
|
||||
.map((line) => {
|
||||
const trimmed = line.trim();
|
||||
if (trimmed === '') return line;
|
||||
// URI="..." 形式的属性(EXT-X-KEY / EXT-X-MAP)
|
||||
if (trimmed.startsWith('#') ) {
|
||||
return line.replace(/URI="([^"]+)"/g, (_m, uri: string) => `URI="${withToken(uri)}"`);
|
||||
}
|
||||
return REWRITABLE.test(trimmed) ? withToken(trimmed) : line;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
/** 把文件写入响应。播放列表会先重写。 */
|
||||
async send(res: Response, relativePath: string): Promise<void> {
|
||||
const abs = this.resolve(relativePath);
|
||||
if (!abs) {
|
||||
res.status(404).json({ code: 1001, message: '资源不存在', data: null });
|
||||
return;
|
||||
}
|
||||
|
||||
const ext = path.extname(abs).toLowerCase();
|
||||
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
|
||||
res.setHeader('Cache-Control', 'private, max-age=300');
|
||||
|
||||
if (ext === '.m3u8') {
|
||||
const content = await readFile(abs, 'utf8');
|
||||
res.send(this.rewritePlaylist(content, relativePath));
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const stream = createReadStream(abs);
|
||||
stream.on('error', reject);
|
||||
stream.on('end', resolve);
|
||||
stream.pipe(res);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { CurrentUser, type AuthPrincipal } from '../../common/auth';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
const createSchema = z.object({
|
||||
courseCode: z.string().min(1),
|
||||
quantity: z.coerce.number().int().min(1).max(10).optional(),
|
||||
});
|
||||
|
||||
@Controller('v1/orders')
|
||||
export class OrdersController {
|
||||
constructor(private readonly orders: OrdersService) {}
|
||||
|
||||
@Post()
|
||||
create(@CurrentUser() user: AuthPrincipal, @Body() body: unknown) {
|
||||
const parsed = createSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误:缺少 courseCode');
|
||||
return this.orders.create(user.id, parsed.data.courseCode, parsed.data.quantity ?? 1);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.orders.list(user.id);
|
||||
}
|
||||
|
||||
@Get(':outTradeNo')
|
||||
status(@CurrentUser() user: AuthPrincipal, @Param('outTradeNo') outTradeNo: string) {
|
||||
return this.orders.status(user.id, outTradeNo);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PaymentsModule } from '../payments/payments.module';
|
||||
import { OrdersController } from './orders.controller';
|
||||
import { OrdersService } from './orders.service';
|
||||
|
||||
@Module({
|
||||
imports: [PaymentsModule],
|
||||
controllers: [OrdersController],
|
||||
providers: [OrdersService],
|
||||
})
|
||||
export class OrdersModule {}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AppError, ErrorCode } from '../../common/errors';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { OrderStatus, PaymentsService } from '../payments/payments.service';
|
||||
import { WxPayService } from '../payments/wxpay.service';
|
||||
|
||||
export interface PayData {
|
||||
signData: string;
|
||||
mode: 'short_series_goods';
|
||||
paySig: string;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OrdersService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wxpay: WxPayService,
|
||||
private readonly payments: PaymentsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 下单并返回支付参数。
|
||||
*
|
||||
* 关键点:
|
||||
* - out_trade_no 每次重新生成且唯一,8-32 位,不能以下划线开头
|
||||
* - signData 是要原样传给客户端的字符串,签名基于同一份字符串
|
||||
* - goodsPrice 单位是分,必须与 MP 后台道具价格一致
|
||||
*/
|
||||
async create(userId: number, courseCode: string, quantity = 1): Promise<{ outTradeNo: string; payData: PayData }> {
|
||||
if (!this.wxpay.enabled) {
|
||||
throw new AppError(ErrorCode.PAY_NOT_ENABLED, '支付尚未开通,请联系管理员', 503);
|
||||
}
|
||||
if (!Number.isInteger(quantity) || quantity < 1 || quantity > 10) {
|
||||
throw AppError.badRequest('购买数量不合法');
|
||||
}
|
||||
|
||||
const course = await this.prisma.course.findUnique({ where: { code: courseCode } });
|
||||
if (!course || course.status !== 1) throw AppError.notFound('课程不存在或已下架');
|
||||
|
||||
const owned = await this.prisma.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({
|
||||
where: { course_id: course.id, status: 1 },
|
||||
});
|
||||
if (!product) throw AppError.notFound('该课程尚未配置支付商品');
|
||||
|
||||
const outTradeNo = this.newOutTradeNo();
|
||||
const amountFen = product.price_fen * quantity;
|
||||
|
||||
await this.prisma.order.create({
|
||||
data: {
|
||||
out_trade_no: outTradeNo,
|
||||
user_id: userId,
|
||||
course_id: course.id,
|
||||
product_id: product.wx_product_id,
|
||||
quantity,
|
||||
amount_fen: amountFen,
|
||||
status: OrderStatus.CREATED,
|
||||
env: 0,
|
||||
attach: `course:${course.code}`,
|
||||
},
|
||||
});
|
||||
|
||||
const sessionKey = await this.payments.requireSessionKey(userId);
|
||||
const signData = JSON.stringify({
|
||||
offerId: this.wxpay.offerId,
|
||||
buyQuantity: quantity,
|
||||
env: 0,
|
||||
currencyType: 'CNY',
|
||||
productId: product.wx_product_id,
|
||||
goodsPrice: product.price_fen,
|
||||
outTradeNo,
|
||||
attach: `course:${course.code}`,
|
||||
});
|
||||
|
||||
return {
|
||||
outTradeNo,
|
||||
payData: {
|
||||
signData,
|
||||
mode: 'short_series_goods',
|
||||
paySig: this.wxpay.calcPaySig('requestVirtualPayment', signData),
|
||||
signature: this.wxpay.calcSignature(signData, sessionKey),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** 查询自己的订单状态。前端支付返回后用它确认是否已发货。 */
|
||||
async status(userId: number, outTradeNo: string) {
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
include: { course: { select: { code: true, title: true } } },
|
||||
});
|
||||
if (!order || order.user_id !== userId) throw AppError.notFound('订单不存在');
|
||||
|
||||
return {
|
||||
outTradeNo: order.out_trade_no,
|
||||
status: order.status,
|
||||
statusText: STATUS_TEXT[order.status] ?? '未知',
|
||||
amountFen: order.amount_fen,
|
||||
courseCode: order.course.code,
|
||||
courseTitle: order.course.title,
|
||||
createdAt: order.created_at,
|
||||
paidAt: order.paid_at,
|
||||
deliveredAt: order.delivered_at,
|
||||
refundedAt: order.refunded_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** 我的订单列表。 */
|
||||
async list(userId: number) {
|
||||
const rows = await this.prisma.order.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { created_at: 'desc' },
|
||||
take: 100,
|
||||
include: { course: { select: { code: true, title: true } } },
|
||||
});
|
||||
return rows.map((o) => ({
|
||||
outTradeNo: o.out_trade_no,
|
||||
status: o.status,
|
||||
statusText: STATUS_TEXT[o.status] ?? '未知',
|
||||
amountFen: o.amount_fen,
|
||||
courseCode: o.course.code,
|
||||
courseTitle: o.course.title,
|
||||
createdAt: o.created_at,
|
||||
}));
|
||||
}
|
||||
|
||||
private newOutTradeNo(): string {
|
||||
// 13 位时间戳 + 4 位随机,共 18 位,满足 8-32 位且不以 _ 开头
|
||||
return `T${Date.now()}${String(randomInt(1000, 10000))}`;
|
||||
}
|
||||
}
|
||||
|
||||
export const STATUS_TEXT: Record<number, string> = {
|
||||
[OrderStatus.CREATED]: '待支付',
|
||||
[OrderStatus.PAID]: '已支付',
|
||||
[OrderStatus.DELIVERED]: '已开通',
|
||||
[OrderStatus.REFUNDED]: '已退款',
|
||||
[OrderStatus.CLOSED]: '已关闭',
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Controller, Post, Req, Res } from '@nestjs/common';
|
||||
import type { Request, Response } from 'express';
|
||||
import { Public } from '../../common/auth';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
/** 取原始请求体。虚拟支付回调是 XML,必须拿到未经解析的字节。 */
|
||||
function rawBodyOf(req: Request): string {
|
||||
const withRaw = req as Request & { rawBody?: Buffer };
|
||||
if (withRaw.rawBody) return withRaw.rawBody.toString('utf8');
|
||||
return typeof req.body === 'string' ? req.body : String(req.body ?? '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 虚拟支付回调。由微信服务器调用,不带小程序身份,靠签名与业务校验保证安全。
|
||||
* 请求体是 XML,应答也必须是 XML。
|
||||
*/
|
||||
@Controller('v1/payments')
|
||||
export class PaymentsController {
|
||||
constructor(private readonly payments: PaymentsService) {}
|
||||
|
||||
@Public()
|
||||
@Post('notify')
|
||||
async notify(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
const xml = await this.payments.handleDeliverNotify(rawBodyOf(req));
|
||||
res.type('application/xml').send(xml);
|
||||
}
|
||||
|
||||
@Public()
|
||||
@Post('refund-notify')
|
||||
async refundNotify(@Req() req: Request, @Res() res: Response): Promise<void> {
|
||||
const xml = await this.payments.handleRefundNotify(rawBodyOf(req));
|
||||
res.type('application/xml').send(xml);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PaymentsController } from './payments.controller';
|
||||
import { PaymentsScheduler } from './payments.scheduler';
|
||||
import { PaymentsService } from './payments.service';
|
||||
import { WxPayService } from './wxpay.service';
|
||||
|
||||
@Module({
|
||||
controllers: [PaymentsController],
|
||||
providers: [PaymentsService, WxPayService, PaymentsScheduler],
|
||||
exports: [PaymentsService, WxPayService],
|
||||
})
|
||||
export class PaymentsModule {}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PaymentsService } from './payments.service';
|
||||
|
||||
/**
|
||||
* 发货推送可能丢失(用户中途退出等情况),必须有一条兜底路径。
|
||||
* 进程内调度,不依赖系统 cron。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PaymentsScheduler {
|
||||
private readonly logger = new Logger(PaymentsScheduler.name);
|
||||
|
||||
constructor(private readonly payments: PaymentsService) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_5_MINUTES)
|
||||
async reconcile(): Promise<void> {
|
||||
try {
|
||||
const count = await this.payments.reconcilePendingOrders();
|
||||
if (count > 0) this.logger.log(`兜底补发 ${count} 笔订单`);
|
||||
} catch (err) {
|
||||
this.logger.error(`兜底查单异常:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { decryptSecret } from '../../common/crypto';
|
||||
import { AppError, ErrorCode } from '../../common/errors';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { WxPayService } from './wxpay.service';
|
||||
|
||||
export const OrderStatus = {
|
||||
CREATED: 0,
|
||||
PAID: 1,
|
||||
DELIVERED: 2,
|
||||
REFUNDED: 3,
|
||||
CLOSED: 4,
|
||||
} as const;
|
||||
|
||||
interface DeliverNotify {
|
||||
Event?: string;
|
||||
OpenId?: string;
|
||||
OutTradeNo?: string;
|
||||
WeChatPayInfo?: { MchOrderNo?: string };
|
||||
GoodsInfo?: { ProductId?: string; Quantity?: number };
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PaymentsService {
|
||||
private readonly logger = new Logger(PaymentsService.name);
|
||||
private readonly parser = new XMLParser({ ignoreAttributes: true, trimValues: true });
|
||||
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly wxpay: WxPayService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 处理发货推送。
|
||||
*
|
||||
* 纪律:
|
||||
* 1. 发货只认推送与查单,前端支付成功回调不作数
|
||||
* 2. 幂等键是平台单号 wx_order_id,不是自己的 out_trade_no
|
||||
* 3. 无论如何都要返回 XML,非 0 会被平台重试(最多 15 次)
|
||||
*/
|
||||
async handleDeliverNotify(rawXml: string): Promise<string> {
|
||||
const logId = await this.logNotify(rawXml);
|
||||
|
||||
try {
|
||||
const parsed = this.parser.parse(rawXml) as { xml?: DeliverNotify };
|
||||
const notify = parsed.xml ?? {};
|
||||
|
||||
if (notify.Event !== 'xpay_goods_deliver_notify') {
|
||||
await this.markNotify(logId, 1, `忽略事件 ${notify.Event ?? '未知'}`);
|
||||
return this.okXml();
|
||||
}
|
||||
|
||||
const outTradeNo = notify.OutTradeNo ?? '';
|
||||
const wxOrderId = notify.WeChatPayInfo?.MchOrderNo ?? '';
|
||||
const openid = notify.OpenId ?? '';
|
||||
if (!outTradeNo || !wxOrderId) throw new Error('推送缺少 OutTradeNo 或 MchOrderNo');
|
||||
|
||||
const order = await this.prisma.order.findUnique({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
include: { user: true },
|
||||
});
|
||||
if (!order) throw new Error(`订单不存在:${outTradeNo}`);
|
||||
if (order.user.openid !== openid) throw new Error(`推送 openid 与订单不符:${outTradeNo}`);
|
||||
|
||||
if (order.status === OrderStatus.DELIVERED || order.status === OrderStatus.REFUNDED) {
|
||||
await this.markNotify(logId, 1, '重复推送,已忽略');
|
||||
return this.okXml();
|
||||
}
|
||||
|
||||
await this.grantEntitlement(order.user_id, order.course_id, order.id, order.out_trade_no, wxOrderId);
|
||||
await this.markNotify(logId, 1, '发货成功');
|
||||
this.logger.log(`发货成功 order=${outTradeNo} wx=${wxOrderId}`);
|
||||
return this.okXml();
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.logger.error(`发货失败:${message}`);
|
||||
await this.markNotify(logId, 2, message);
|
||||
return `<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[${message}]]></ErrMsg></xml>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 处理退款推送。 */
|
||||
async handleRefundNotify(rawXml: string): Promise<string> {
|
||||
const logId = await this.logNotify(rawXml);
|
||||
try {
|
||||
const parsed = this.parser.parse(rawXml) as { xml?: { OutTradeNo?: string } };
|
||||
const outTradeNo = parsed.xml?.OutTradeNo ?? '';
|
||||
if (outTradeNo) {
|
||||
await this.prisma.order.updateMany({
|
||||
where: { out_trade_no: outTradeNo },
|
||||
data: { status: OrderStatus.REFUNDED, refunded_at: new Date() },
|
||||
});
|
||||
}
|
||||
await this.markNotify(logId, 1, '退款已记录');
|
||||
return this.okXml();
|
||||
} catch (err) {
|
||||
await this.markNotify(logId, 2, err instanceof Error ? err.message : String(err));
|
||||
return '<xml><ErrCode>1</ErrCode><ErrMsg><![CDATA[fail]]></ErrMsg></xml>';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底查单:补发「已支付但没收到推送」的订单。
|
||||
* 由定时任务每 5 分钟调用一次。
|
||||
*/
|
||||
async reconcilePendingOrders(): Promise<number> {
|
||||
if (!this.wxpay.enabled) return 0;
|
||||
|
||||
const pending = await this.prisma.order.findMany({
|
||||
where: { status: { in: [OrderStatus.CREATED, OrderStatus.PAID] } },
|
||||
include: { user: true },
|
||||
orderBy: { created_at: 'asc' },
|
||||
take: 100,
|
||||
});
|
||||
|
||||
let delivered = 0;
|
||||
for (const order of pending) {
|
||||
try {
|
||||
const result = await this.wxpay.queryOrder(order.out_trade_no, order.user.openid);
|
||||
const wxOrderId = result.wx_order_id ?? result.order?.wx_order_id;
|
||||
const isPaid = result.status === 2 || result.order?.status === 2 || Boolean(wxOrderId);
|
||||
if (!isPaid) continue;
|
||||
|
||||
await this.grantEntitlement(
|
||||
order.user_id,
|
||||
order.course_id,
|
||||
order.id,
|
||||
order.out_trade_no,
|
||||
wxOrderId ?? `reconciled-${order.out_trade_no}`,
|
||||
);
|
||||
delivered++;
|
||||
this.logger.log(`兜底补发成功 order=${order.out_trade_no}`);
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`兜底查单失败 order=${order.out_trade_no}: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return delivered;
|
||||
}
|
||||
|
||||
/** 取用户 sessionKey(明文)。不存在时提示重新登录。 */
|
||||
async requireSessionKey(userId: number): Promise<string> {
|
||||
const user = await this.prisma.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);
|
||||
return key;
|
||||
}
|
||||
|
||||
/** 标记订单已发货并发放权益。两个写操作必须同事务。 */
|
||||
private async grantEntitlement(
|
||||
userId: number,
|
||||
courseId: number,
|
||||
orderId: number,
|
||||
outTradeNo: string,
|
||||
wxOrderId: string,
|
||||
): Promise<void> {
|
||||
const now = new Date();
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.order.update({
|
||||
where: { id: orderId },
|
||||
data: {
|
||||
status: OrderStatus.DELIVERED,
|
||||
wx_order_id: wxOrderId,
|
||||
paid_at: now,
|
||||
delivered_at: now,
|
||||
},
|
||||
}),
|
||||
this.prisma.entitlement.upsert({
|
||||
where: { user_id_course_id: { user_id: userId, course_id: courseId } },
|
||||
create: {
|
||||
user_id: userId,
|
||||
course_id: courseId,
|
||||
source: 1,
|
||||
ref_id: orderId,
|
||||
granted_at: now,
|
||||
note: `订单 ${outTradeNo}`,
|
||||
},
|
||||
update: {},
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
private async logNotify(rawXml: string): Promise<number> {
|
||||
const row = await this.prisma.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({
|
||||
where: { id },
|
||||
data: { processed, err_msg: errMsg.slice(0, 255) },
|
||||
});
|
||||
}
|
||||
|
||||
private okXml(): string {
|
||||
return '<xml><ErrCode>0</ErrCode><ErrMsg><![CDATA[success]]></ErrMsg></xml>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { AppError } from '../../common/errors';
|
||||
|
||||
const XPAY_BASE = 'https://api.weixin.qq.com';
|
||||
|
||||
/**
|
||||
* 微信小程序虚拟支付客户端。
|
||||
*
|
||||
* 签名规则(两套,缺一不可):
|
||||
* paySig 服务端身份,密钥是后台的「现网 AppKey」:HMAC_SHA256(appkey, uri + '&' + postBody)
|
||||
* signature 用户态身份,密钥是该用户的 sessionKey:HMAC_SHA256(sessionKey, postBody)
|
||||
*
|
||||
* 注意:postBody 必须与实际发出的字符串逐字节一致,不能格式化、不能改键顺序。
|
||||
*/
|
||||
@Injectable()
|
||||
export class WxPayService {
|
||||
private readonly logger = new Logger(WxPayService.name);
|
||||
|
||||
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {}
|
||||
|
||||
get enabled(): boolean {
|
||||
return this.cfg.WX_OFFER_ID !== '' && this.cfg.WX_PAY_APPKEY !== '';
|
||||
}
|
||||
|
||||
get offerId(): string {
|
||||
return this.cfg.WX_OFFER_ID;
|
||||
}
|
||||
|
||||
/** 服务端身份签名。uri 不带问号参数。 */
|
||||
calcPaySig(uri: string, postBody: string): string {
|
||||
return createHmac('sha256', this.cfg.WX_PAY_APPKEY).update(`${uri}&${postBody}`).digest('hex');
|
||||
}
|
||||
|
||||
/** 用户态签名。 */
|
||||
calcSignature(postBody: string, sessionKey: string): string {
|
||||
return createHmac('sha256', sessionKey).update(postBody).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查单。用于发货推送丢失时兜底补发。
|
||||
* 参数名是 order_id(传自己的 outTradeNo),不是 out_trade_no。
|
||||
*/
|
||||
async queryOrder(outTradeNo: string, openid: string): Promise<XpayQueryResult> {
|
||||
const uri = '/xpay/query_order';
|
||||
const body = JSON.stringify({ openid, env: 0, order_id: outTradeNo });
|
||||
const paySig = this.calcPaySig(uri, body);
|
||||
|
||||
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
const json = (await res.json()) as XpayQueryResult;
|
||||
if (json.errcode && json.errcode !== 0) {
|
||||
this.logger.warn(`查单失败 ${outTradeNo}: ${json.errcode} ${json.errmsg ?? ''}`);
|
||||
}
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* 主动退款(仅 Android 等非 iOS 终端可用;iOS 需用户向 App Store 申请,开发者无法发起)。
|
||||
*
|
||||
* 注意:参数集合需以官方《虚拟支付 - 退款》文档核对后再上线启用,
|
||||
* 当前按已知字段拼装,失败时抛错而非静默。
|
||||
*/
|
||||
async refundOrder(input: {
|
||||
outTradeNo: string;
|
||||
openid: string;
|
||||
refundOrderId: string;
|
||||
refundAmountFen: number;
|
||||
reason: string;
|
||||
}): Promise<Record<string, unknown>> {
|
||||
if (!this.enabled) throw new AppError(4002, '虚拟支付未配置', 503);
|
||||
|
||||
const uri = '/xpay/refund_order';
|
||||
const body = JSON.stringify({
|
||||
openid: input.openid,
|
||||
env: 0,
|
||||
order_id: input.outTradeNo,
|
||||
refund_order_id: input.refundOrderId,
|
||||
refund_amount: input.refundAmountFen,
|
||||
refund_reason: input.reason,
|
||||
});
|
||||
const paySig = this.calcPaySig(uri, body);
|
||||
|
||||
const res = await fetch(`${XPAY_BASE}${uri}?pay_sig=${paySig}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body,
|
||||
});
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
export interface XpayQueryResult {
|
||||
errcode?: number;
|
||||
errmsg?: string;
|
||||
order?: {
|
||||
order_id?: string;
|
||||
wx_order_id?: string;
|
||||
status?: number;
|
||||
paid_time?: number;
|
||||
};
|
||||
status?: number;
|
||||
wx_order_id?: string;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Body, Controller, Get, Patch } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
import { CurrentUser, type AuthPrincipal } from '../../common/auth';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
const updateSchema = z
|
||||
.object({
|
||||
nickname: z.string().optional(),
|
||||
headiconId: z.coerce.number().int().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
@Controller('v1/me')
|
||||
export class UsersController {
|
||||
constructor(private readonly users: UsersService) {}
|
||||
|
||||
@Get()
|
||||
me(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.users.me(user.id);
|
||||
}
|
||||
|
||||
@Patch()
|
||||
update(@CurrentUser() user: AuthPrincipal, @Body() body: unknown) {
|
||||
const parsed = updateSchema.safeParse(body);
|
||||
if (!parsed.success) throw AppError.badRequest('参数错误');
|
||||
return this.users.updateProfile(user.id, parsed.data);
|
||||
}
|
||||
|
||||
@Get('entitlements')
|
||||
entitlements(@CurrentUser() user: AuthPrincipal) {
|
||||
return this.users.entitlements(user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { UsersController } from './users.controller';
|
||||
import { UsersService } from './users.service';
|
||||
|
||||
@Module({
|
||||
controllers: [UsersController],
|
||||
providers: [UsersService],
|
||||
exports: [UsersService],
|
||||
})
|
||||
export class UsersModule {}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { APP_CONFIG, type AppConfig } from '../../config/config';
|
||||
import { AppError } from '../../common/errors';
|
||||
import { staticUrls } from '../../common/urls';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
|
||||
@Injectable()
|
||||
export class UsersService {
|
||||
constructor(
|
||||
@Inject(APP_CONFIG) private readonly cfg: AppConfig,
|
||||
private readonly prisma: PrismaService,
|
||||
) {}
|
||||
|
||||
async me(userId: number) {
|
||||
const user = await this.prisma.user.findUnique({ where: { id: userId } });
|
||||
if (!user || user.status !== 1) throw AppError.unauthorized();
|
||||
|
||||
const base = this.cfg.STATIC_BASE_URL;
|
||||
return {
|
||||
id: user.id,
|
||||
nickname: user.nickname,
|
||||
headiconId: user.headicon_id,
|
||||
// headiconId 为 0 表示未设置,小程序用本地占位图
|
||||
headiconUrl: user.headicon_id > 0 ? staticUrls.headicon(base, user.headicon_id) : null,
|
||||
registeredAt: user.registered_at,
|
||||
lastLoginAt: user.last_login_at,
|
||||
};
|
||||
}
|
||||
|
||||
/** 修改昵称 / 头像。昵称与头像序号的取值范围在此校验。 */
|
||||
async updateProfile(userId: number, input: { nickname?: string; headiconId?: number }) {
|
||||
const data: { nickname?: string; headicon_id?: number } = {};
|
||||
|
||||
if (input.nickname !== undefined) {
|
||||
const nickname = input.nickname.trim();
|
||||
if (nickname.length === 0) throw AppError.badRequest('昵称不能为空');
|
||||
if (nickname.length > 8) throw AppError.badRequest('昵称最多 8 个字符');
|
||||
data.nickname = nickname;
|
||||
}
|
||||
|
||||
if (input.headiconId !== undefined) {
|
||||
const max = await this.getHeadiconMax();
|
||||
if (!Number.isInteger(input.headiconId) || input.headiconId < 0 || input.headiconId > max) {
|
||||
throw AppError.badRequest(`头像序号需在 0 到 ${max} 之间`);
|
||||
}
|
||||
data.headicon_id = input.headiconId;
|
||||
}
|
||||
|
||||
if (Object.keys(data).length === 0) throw AppError.badRequest('没有需要修改的内容');
|
||||
|
||||
await this.prisma.user.update({ where: { id: userId }, data });
|
||||
return this.me(userId);
|
||||
}
|
||||
|
||||
/** 我的课程。 */
|
||||
async entitlements(userId: number) {
|
||||
const rows = await this.prisma.entitlement.findMany({
|
||||
where: { user_id: userId },
|
||||
orderBy: { granted_at: 'desc' },
|
||||
include: { course: true },
|
||||
});
|
||||
|
||||
return rows
|
||||
.filter((r) => r.course.status === 1)
|
||||
.map((r) => ({
|
||||
courseId: r.course_id,
|
||||
code: r.course.code,
|
||||
title: r.course.title,
|
||||
intro: r.course.intro,
|
||||
coverUrl: staticUrls.courseCover(this.cfg.STATIC_BASE_URL, r.course.code),
|
||||
grantedAt: r.granted_at,
|
||||
expireAt: r.expire_at,
|
||||
}));
|
||||
}
|
||||
|
||||
/** 头像总数取自 settings,缺省 50。 */
|
||||
private async getHeadiconMax(): Promise<number> {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: 'profile.headicon_count' } });
|
||||
const n = Number(row?.value);
|
||||
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 50;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { Injectable, Logger, type OnModuleDestroy, type OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService extends PrismaClient implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(PrismaService.name);
|
||||
|
||||
async onModuleInit(): Promise<void> {
|
||||
// 延迟连接:即使数据库暂时不可用也允许进程启动,由具体请求暴露问题
|
||||
try {
|
||||
await this.$connect();
|
||||
} catch (err) {
|
||||
this.logger.error(`数据库连接失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy(): Promise<void> {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# 旧库数据迁移
|
||||
|
||||
把旧系统(`api_yeomai_site`)的数据迁到新库,供新服务端使用。
|
||||
|
||||
## 前置条件
|
||||
|
||||
1. 新库已建好,`DATABASE_URL` 指向它,且**已执行建表**(`pnpm prisma:migrate`)。
|
||||
2. 旧库 dump 文件位于仓库根目录 `数据库/api_yeomai_site_mysql.sql`
|
||||
(默认路径;可用环境变量 `LEGACY_DUMP` 覆盖)。
|
||||
3. `STATIC_BASE_URL` 已配置为新静态资源域名,用于改写内容字段里的旧域名。
|
||||
|
||||
## 三条命令
|
||||
|
||||
```bash
|
||||
# 1. 体检:确认旧数据能无损映射(不连数据库,纯读 dump)
|
||||
pnpm legacy:check
|
||||
|
||||
# 2. 试运行:完成全部转换,但不写库,用来核对行数
|
||||
pnpm legacy:migrate -- --dry-run
|
||||
|
||||
# 3. 正式迁移
|
||||
pnpm legacy:migrate
|
||||
```
|
||||
|
||||
如希望直连旧库读取而不是读 dump:
|
||||
|
||||
```bash
|
||||
pnpm legacy:migrate -- --from-db # 需配置 LEGACY_DATABASE_URL
|
||||
```
|
||||
|
||||
## 迁移做了什么
|
||||
|
||||
| 旧表 | 新表 | 说明 |
|
||||
|---|---|---|
|
||||
| 用户信息 | `users` | 284 行 |
|
||||
| 用户已购课程 | `entitlements` | 48 行,`source=2`(老数据),**不走支付直接标记为已拥有** |
|
||||
| 课程口令码读取信息 | `courses` | 10 行,`code` 原值保留 |
|
||||
| 视频课程数据 | `lessons` | 841 行,`章节id`/`播放id` 原值保留 |
|
||||
| 免费视频课程数据 | `free_lessons` | 12 行 |
|
||||
| 首页课程列表 | `home_courses` | 5 行,并自动关联到对应付费课程 |
|
||||
| 首页免费课程列表 | `home_free_courses` | 3 行 |
|
||||
| 首页横幅图片 | `banners` | 3 行 |
|
||||
| 网页列表 | `articles` | 2 行 |
|
||||
| 课程页面Tips | `course_tips` | 2 行 |
|
||||
| cloudconfig | `settings` | 13 行 → 12 行(ID 0 占位丢弃),数字 ID 改字符串 key |
|
||||
| 课程口令码 | — | **作废,不迁移** |
|
||||
| 各类 `xxx2` 备份表、`网页数据`、`验证码发送` | — | 废弃 |
|
||||
|
||||
## 必须知道的四件事
|
||||
|
||||
### 1. 内容字段里的旧域名会被改写
|
||||
|
||||
旧库的 `首页课程列表.内容`(44 处)和 `网页列表.网页内容`(4 处)里存的是**绝对 URL**,
|
||||
形如 `https://api.yeomai.site/Images/...`。旧域名退役后这些图片/视频会全部 404。
|
||||
|
||||
迁移时按 `STATIC_BASE_URL` 自动改写。**执行前务必确认这个变量填的是新静态域名。**
|
||||
|
||||
### 2. 时间按 +08:00 换算
|
||||
|
||||
旧库时间由服务器本地 `NOW()` 写入,是北京时间墙钟值。新库统一存 UTC,
|
||||
迁移时按 `+08:00` 换算(可用 `LEGACY_UTC_OFFSET` 覆盖)。
|
||||
|
||||
注意旧库中确实存在 `2024-02-01` 这种**只有日期没有时间**的值,已单独处理。
|
||||
|
||||
### 3. `courses.code` 绝对不能被改写
|
||||
|
||||
它同时是视频目录名(`Course/{code}/…/playlist.m3u8`)与封面名(`Course/{code}.png`)。
|
||||
迁移脚本对它逐字透传,测试中有断言保护。
|
||||
|
||||
### 4. 迁移会清空新库业务表
|
||||
|
||||
脚本采用「先清空再写入」实现幂等,失败可原样重跑。
|
||||
|
||||
**安全阀**:若新库已存在订单数据(说明已上线),脚本会拒绝执行,必须显式加 `--force`。
|
||||
|
||||
## 迁移后核对
|
||||
|
||||
脚本会自动逐表比对行数。此外建议人工抽查:
|
||||
|
||||
- 随机选几个老用户,确认其 `entitlements` 里的课程与升级前一致。
|
||||
- 打开首页,确认轮播、新课长图、试听入口图片都能加载(验证域名改写生效)。
|
||||
|
||||
## 测试
|
||||
|
||||
迁移的转换逻辑由 `transform.test.ts` 覆盖(18 项),跑的是**真实的旧库 dump**:
|
||||
|
||||
```bash
|
||||
pnpm test
|
||||
```
|
||||
|
||||
其中包含行数比对、唯一性约束、域名改写、时间换算、首页长图与课程的关联完整性。
|
||||
改动迁移逻辑后请先跑测试。
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 把旧库数据编排成新库可直接写入的数据集。
|
||||
*
|
||||
* 逻辑集中在这里,migrate.ts(写库)与 transform.test.ts(校验)共用同一份实现,
|
||||
* 避免两边各写一遍导致漂移。
|
||||
*/
|
||||
|
||||
import { asString, type LegacyRows } from './load';
|
||||
import {
|
||||
HOME_COURSE_TITLE_MAP,
|
||||
transformArticle,
|
||||
transformBanner,
|
||||
transformCourse,
|
||||
transformCourseTip,
|
||||
transformEntitlement,
|
||||
transformFreeLesson,
|
||||
transformHomeCourse,
|
||||
transformHomeFreeCourse,
|
||||
transformLesson,
|
||||
transformSetting,
|
||||
transformUser,
|
||||
} from './transform';
|
||||
|
||||
export type Dataset = ReturnType<typeof buildDataset>;
|
||||
|
||||
export function buildDataset(db: LegacyRows, staticBase: string) {
|
||||
// 先建课程,得到 code → 自增 id 的映射(新库 id 从 1 开始,与插入顺序一致)
|
||||
const courses = db.课程口令码读取信息.map(transformCourse);
|
||||
const courseIdByCode = new Map(courses.map((c, i) => [c.code, i + 1]));
|
||||
const courseIdByTitle = new Map(courses.map((c, i) => [c.title, i + 1]));
|
||||
|
||||
const users = db.用户信息.map(transformUser);
|
||||
const userIdByOpenid = new Map(users.map((u, i) => [u.openid, i + 1]));
|
||||
|
||||
const skippedLessonOrphans: string[] = [];
|
||||
const lessons = db.视频课程数据.flatMap((row) => {
|
||||
const courseId = courseIdByCode.get(asString(row.课程id));
|
||||
if (!courseId) {
|
||||
skippedLessonOrphans.push(asString(row.课程id));
|
||||
return [];
|
||||
}
|
||||
return [transformLesson(row, courseId)];
|
||||
});
|
||||
|
||||
// 权益:按 (user, course) 去重,重复的只保留第一条
|
||||
const seen = new Set<string>();
|
||||
const duplicateEntitlements: string[] = [];
|
||||
const entitlements = db.用户已购课程.flatMap((row) => {
|
||||
const userId = userIdByOpenid.get(asString(row.OpenID));
|
||||
const courseId = courseIdByCode.get(asString(row.课程id));
|
||||
if (!userId || !courseId) return [];
|
||||
const key = `${userId}::${courseId}`;
|
||||
if (seen.has(key)) {
|
||||
duplicateEntitlements.push(key);
|
||||
return [];
|
||||
}
|
||||
seen.add(key);
|
||||
return [transformEntitlement(row, userId, courseId)];
|
||||
});
|
||||
|
||||
const unmappedHomeCourses: string[] = [];
|
||||
const homeCourses = db.首页课程列表.map((row) => {
|
||||
const courseName = asString(row.课程名称);
|
||||
const title = HOME_COURSE_TITLE_MAP[courseName];
|
||||
const courseId = title ? courseIdByTitle.get(title) ?? null : null;
|
||||
if (courseId === null) unmappedHomeCourses.push(courseName);
|
||||
return transformHomeCourse(row, courseId, staticBase);
|
||||
});
|
||||
|
||||
return {
|
||||
courses,
|
||||
users,
|
||||
lessons,
|
||||
entitlements,
|
||||
homeCourses,
|
||||
freeLessons: db.免费视频课程数据.map(transformFreeLesson),
|
||||
homeFreeCourses: db.首页免费课程列表.map(transformHomeFreeCourse),
|
||||
banners: db.首页横幅图片.map(transformBanner),
|
||||
articles: db.网页列表.map((row) => transformArticle(row, staticBase)),
|
||||
courseTips: db.课程页面Tips.map(transformCourseTip),
|
||||
settings: db.cloudconfig
|
||||
.map(transformSetting)
|
||||
.filter((v): v is NonNullable<typeof v> => v !== null),
|
||||
diagnostics: { skippedLessonOrphans, duplicateEntitlements, unmappedHomeCourses },
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 旧库数据体检。
|
||||
*
|
||||
* 目的:在动任何数据库之前,确认旧数据能否无损映射到新模型。
|
||||
* 特别检查那些会直接导致迁移失败或用户丢课的约束冲突。
|
||||
*
|
||||
* 用法:pnpm legacy:check [--dump <path>]
|
||||
*/
|
||||
|
||||
import { asString, loadLegacyDump, DEFAULT_DUMP_PATH } from './load';
|
||||
import { COURSE_CODE_RE } from './columns';
|
||||
|
||||
type Issue = { level: 'BLOCKER' | 'WARN'; message: string };
|
||||
|
||||
function count<T>(items: T[]): Map<string, T[]> {
|
||||
const map = new Map<string, T[]>();
|
||||
for (const item of items) {
|
||||
const key = String(item);
|
||||
const bucket = map.get(key);
|
||||
if (bucket) bucket.push(item);
|
||||
else map.set(key, [item]);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
function duplicates<T>(items: T[]): Map<string, T[]> {
|
||||
return new Map([...count(items)].filter(([, v]) => v.length > 1));
|
||||
}
|
||||
|
||||
function main(): void {
|
||||
const dumpPath = process.argv.includes('--dump')
|
||||
? process.argv[process.argv.indexOf('--dump') + 1]!
|
||||
: DEFAULT_DUMP_PATH;
|
||||
|
||||
console.log(`旧库体检\n数据源: ${dumpPath}\n`);
|
||||
const db = loadLegacyDump(dumpPath);
|
||||
const issues: Issue[] = [];
|
||||
const line = (s: string): void => console.log(s);
|
||||
|
||||
line('── 1. 行数 ─────────────────────────────');
|
||||
for (const [table, rows] of Object.entries(db)) {
|
||||
line(` ${table.padEnd(22)} ${String(rows.length).padStart(5)}`);
|
||||
}
|
||||
|
||||
const users = db.用户信息;
|
||||
const owned = db.用户已购课程;
|
||||
const catalog = db.课程口令码读取信息;
|
||||
const lessons = db.视频课程数据;
|
||||
|
||||
line('\n── 2. users ────────────────────────────');
|
||||
const openids = users.map((r) => asString(r.OpenID));
|
||||
const emptyOpenid = openids.filter((v) => v === '').length;
|
||||
const dupOpenid = duplicates(openids);
|
||||
line(` 用户数 ${users.length}`);
|
||||
line(` 空 openid ${emptyOpenid}`);
|
||||
line(` 重复 openid ${dupOpenid.size}`);
|
||||
line(` 已设置昵称 ${users.filter((r) => asString(r.用户名) !== '').length}`);
|
||||
line(` 管理员 ${users.filter((r) => asString(r.是否管理员) === '1').length}`);
|
||||
if (emptyOpenid > 0) issues.push({ level: 'BLOCKER', message: `存在 ${emptyOpenid} 条空 openid,违反 users.openid NOT NULL` });
|
||||
if (dupOpenid.size > 0) {
|
||||
issues.push({ level: 'BLOCKER', message: `存在 ${dupOpenid.size} 个重复 openid,违反 users.openid UNIQUE` });
|
||||
for (const [k, v] of dupOpenid) line(` ${k} × ${v.length}`);
|
||||
}
|
||||
|
||||
line('\n── 3. courses(来源:课程口令码读取信息)');
|
||||
const codes = catalog.map((r) => asString(r.课程id));
|
||||
const badCodes = codes.filter((c) => !COURSE_CODE_RE.test(c));
|
||||
const dupCodes = duplicates(codes);
|
||||
line(` 课程数 ${catalog.length}`);
|
||||
line(` 非 64 位十六进制 code ${badCodes.length}`);
|
||||
line(` 重复 code ${dupCodes.size}`);
|
||||
if (badCodes.length > 0) issues.push({ level: 'BLOCKER', message: `有 ${badCodes.length} 个课程 code 不符合 64 位 sha256 格式` });
|
||||
if (dupCodes.size > 0) issues.push({ level: 'BLOCKER', message: `有 ${dupCodes.size} 个重复课程 code,违反 courses.code UNIQUE` });
|
||||
|
||||
line('\n── 4. entitlements(来源:用户已购课程)');
|
||||
const pairs = owned.map((r) => `${asString(r.OpenID)}::${asString(r.课程id)}`);
|
||||
const dupPairs = duplicates(pairs);
|
||||
const catalogSet = new Set(codes);
|
||||
const orphanOwned = owned.filter((r) => !catalogSet.has(asString(r.课程id)));
|
||||
const unknownUser = owned.filter((r) => !openids.includes(asString(r.OpenID)));
|
||||
line(` 购买记录数 ${owned.length}`);
|
||||
line(` 重复 (openid,课程) ${dupPairs.size}`);
|
||||
line(` 指向未知课程 ${orphanOwned.length}`);
|
||||
line(` openid 不在用户表 ${unknownUser.length}`);
|
||||
if (dupPairs.size > 0) {
|
||||
issues.push({ level: 'BLOCKER', message: `有 ${dupPairs.size} 组重复权益,违反 entitlements UNIQUE(user_id, course_id)` });
|
||||
for (const [k, v] of dupPairs) line(` ${k} × ${v.length}`);
|
||||
}
|
||||
if (orphanOwned.length > 0) issues.push({ level: 'BLOCKER', message: `有 ${orphanOwned.length} 条购买记录指向不存在的课程` });
|
||||
if (unknownUser.length > 0) issues.push({ level: 'BLOCKER', message: `有 ${unknownUser.length} 条购买记录的 openid 不在用户表中` });
|
||||
|
||||
line('\n── 5. lessons(来源:视频课程数据)');
|
||||
const lessonKeys = lessons.map((r) => `${asString(r.课程id)}::${asString(r.章节id)}::${asString(r.播放id)}`);
|
||||
const dupLessons = duplicates(lessonKeys);
|
||||
const orphanLessons = lessons.filter((r) => !catalogSet.has(asString(r.课程id)));
|
||||
line(` 课时数 ${lessons.length}`);
|
||||
line(` 重复 (课程,章节,播放) ${dupLessons.size}`);
|
||||
line(` 指向未知课程 ${orphanLessons.length}`);
|
||||
if (dupLessons.size > 0) {
|
||||
issues.push({ level: 'BLOCKER', message: `有 ${dupLessons.size} 组重复课时,违反 lessons UNIQUE(course_id, chapter_id, play_id)` });
|
||||
for (const [k, v] of dupLessons) line(` ${k} × ${v.length}`);
|
||||
}
|
||||
if (orphanLessons.length > 0) issues.push({ level: 'WARN', message: `有 ${orphanLessons.length} 条课时指向不存在的课程,迁移时会被跳过` });
|
||||
|
||||
line('\n── 6. 内容字段中的绝对 URL(域名退役后必须改写)');
|
||||
for (const table of ['首页课程列表', '网页列表'] as const) {
|
||||
const hits = db[table].filter((r) => JSON.stringify(r).includes('api.yeomai.site'));
|
||||
line(` ${table.padEnd(14)} ${hits.length} 行含 api.yeomai.site`);
|
||||
}
|
||||
|
||||
line('\n── 7. 不迁移的表 ───────────────────────');
|
||||
line(` 课程口令码 ${db.课程口令码.length} 行(作废,不迁移)`);
|
||||
line(` cloudconfig ${db.cloudconfig.length} 行(转 settings)`);
|
||||
|
||||
line('\n════════════════════════════════════════');
|
||||
const blockers = issues.filter((i) => i.level === 'BLOCKER');
|
||||
if (blockers.length === 0) {
|
||||
line(`✅ 通过:${issues.length} 条提示,0 个阻断项。数据可安全迁移。`);
|
||||
} else {
|
||||
line(`❌ 发现 ${blockers.length} 个阻断项:`);
|
||||
for (const i of blockers) line(` [${i.level}] ${i.message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
const warns = issues.filter((i) => i.level === 'WARN');
|
||||
for (const w of warns) line(` [${w.level}] ${w.message}`);
|
||||
}
|
||||
|
||||
main();
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
/**
|
||||
* 旧库表结构(来自 mysqldump 的 CREATE TABLE 顺序)。
|
||||
* 迁移与体检脚本共用,避免两处各写一份列顺序。
|
||||
*/
|
||||
|
||||
export const LEGACY_COLUMNS = {
|
||||
用户信息: [
|
||||
'OpenID',
|
||||
'YelayMaths_ID',
|
||||
'用户名',
|
||||
'是否登录',
|
||||
'是否管理员',
|
||||
'注册日期',
|
||||
'最后登录日期',
|
||||
'手机号',
|
||||
'HeadiconID',
|
||||
],
|
||||
用户已购课程: ['OpenID', '课程id', '课程', '课程介绍', '购课日期', '最后观看日期', '观看时长'],
|
||||
课程口令码读取信息: ['课程id', '课程名称', '课程介绍'],
|
||||
视频课程数据: [
|
||||
'课程id',
|
||||
'播放id',
|
||||
'章节id',
|
||||
'视频id',
|
||||
'课程名称',
|
||||
'章节名称',
|
||||
'视频名称',
|
||||
'发布日期',
|
||||
'时长',
|
||||
'视频m3u8',
|
||||
'封面图片',
|
||||
],
|
||||
免费视频课程数据: [
|
||||
'排序',
|
||||
'序号',
|
||||
'课程名称',
|
||||
'视频标题',
|
||||
'详细介绍',
|
||||
'发布人',
|
||||
'发布日期',
|
||||
'时长',
|
||||
'视频m3u8',
|
||||
'封面图片',
|
||||
],
|
||||
首页课程列表: ['课程名称', '课程介绍', '课程日期', '课程封面图片', '内容'],
|
||||
首页免费课程列表: ['序号', '课程名称', '课程介绍', '课程日期', '课程封面图片'],
|
||||
首页横幅图片: ['序号', '图片名'],
|
||||
网页列表: ['网页id', '网页标题', '发布日期', '发布人', '详细介绍', '网页内容'],
|
||||
课程页面Tips: ['id', 'content', 'title'],
|
||||
cloudconfig: ['ID', 'value', '注释'],
|
||||
课程口令码: ['课程id', '课程', '口令码', '口令起始日期', '口令结束日期', '课程介绍', '剩余有效次数'],
|
||||
} as const;
|
||||
|
||||
export type LegacyTable = keyof typeof LEGACY_COLUMNS;
|
||||
|
||||
/** 含绝对 URL 的内容字段,域名退役时必须批量改写。 */
|
||||
export const DOMAIN_BEARING_TABLES: LegacyTable[] = ['首页课程列表', '网页列表'];
|
||||
|
||||
/** 新库中 entitlements.course_id 的来源课程标识字段。 */
|
||||
export const COURSE_CODE_RE = /^[0-9a-f]{64}$/;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 从旧库直连读取数据,返回与 loadLegacyDump 一致的结构。
|
||||
* 仅 migrate.ts 的 --from-db 模式使用。
|
||||
*/
|
||||
|
||||
import mysql from 'mysql2/promise';
|
||||
import { LEGACY_COLUMNS, type LegacyTable } from './columns';
|
||||
import type { LegacyRows } from './load';
|
||||
import type { SqlValue } from './parser';
|
||||
|
||||
export async function loadLegacyDb(url: string): Promise<LegacyRows> {
|
||||
if (!url) throw new Error('缺少 LEGACY_DATABASE_URL');
|
||||
const conn = await mysql.createConnection(url);
|
||||
try {
|
||||
const result = {} as LegacyRows;
|
||||
for (const table of Object.keys(LEGACY_COLUMNS) as LegacyTable[]) {
|
||||
const [rows] = await conn.query(`SELECT * FROM \`${table}\``);
|
||||
result[table] = (rows as Record<string, unknown>[]).map((row) => {
|
||||
const obj: Record<string, SqlValue> = {};
|
||||
for (const col of LEGACY_COLUMNS[table]) {
|
||||
const v = row[col];
|
||||
obj[col] = v === null || v === undefined ? null : (v as SqlValue);
|
||||
}
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
await conn.end();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { LEGACY_COLUMNS, type LegacyTable } from './columns';
|
||||
import { readTable, toObjects, type SqlValue } from './parser';
|
||||
|
||||
/**
|
||||
* 旧库 dump 的默认位置。
|
||||
* 兼顾两种运行方式:`pnpm legacy:check`(cwd 为包根)与 vitest(cwd 为包根)。
|
||||
* 可用 LEGACY_DUMP 环境变量覆盖。
|
||||
*/
|
||||
function resolveDefaultDumpPath(): string {
|
||||
const candidates = [
|
||||
path.resolve(process.cwd(), '../数据库/api_yeomai_site_mysql.sql'),
|
||||
path.resolve(process.cwd(), '数据库/api_yeomai_site_mysql.sql'),
|
||||
];
|
||||
return candidates.find((p) => existsSync(p)) ?? candidates[0]!;
|
||||
}
|
||||
|
||||
export const DEFAULT_DUMP_PATH = process.env.LEGACY_DUMP ?? resolveDefaultDumpPath();
|
||||
|
||||
export type LegacyRows = Record<LegacyTable, Record<string, SqlValue>[]>;
|
||||
|
||||
export function loadLegacyDump(dumpPath: string = process.env.LEGACY_DUMP ?? DEFAULT_DUMP_PATH): LegacyRows {
|
||||
const sql = readFileSync(dumpPath, 'utf8');
|
||||
const result = {} as LegacyRows;
|
||||
for (const table of Object.keys(LEGACY_COLUMNS) as LegacyTable[]) {
|
||||
result[table] = toObjects(readTable(sql, table), [...LEGACY_COLUMNS[table]]);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function asString(v: SqlValue): string {
|
||||
return v === null ? '' : String(v);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
/**
|
||||
* 旧库 → 新库 数据迁移。
|
||||
*
|
||||
* 数据源二选一:
|
||||
* 默认 读取 mysqldump 导出的 .sql(无需连旧库)
|
||||
* --from-db 直连旧库读取(需 LEGACY_DATABASE_URL)
|
||||
*
|
||||
* 目标库为 DATABASE_URL 指向的新库。
|
||||
*
|
||||
* 幂等策略:先清空新库业务表再写入,失败可原样重跑。
|
||||
* 安全阀:新库若已存在订单(说明已上线),必须显式加 --force 才会执行。
|
||||
*
|
||||
* 用法:
|
||||
* pnpm legacy:migrate 从 dump 迁移
|
||||
* pnpm legacy:migrate -- --from-db 从旧库直连迁移
|
||||
* pnpm legacy:migrate -- --dry-run 只转换与校验,不写库
|
||||
*/
|
||||
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import { buildDataset } from './build';
|
||||
import { loadLegacyDump, type LegacyRows } from './load';
|
||||
import { loadLegacyDb } from './load-db';
|
||||
import { SOURCE_LEGACY } from './transform';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
const flag = (name: string): boolean => process.argv.includes(`--${name}`);
|
||||
|
||||
/** 清空顺序:先子后父。 */
|
||||
const CLEAR_ORDER = [
|
||||
'paymentNotifyLog',
|
||||
'order',
|
||||
'entitlement',
|
||||
'lesson',
|
||||
'homeCourse',
|
||||
'freeLesson',
|
||||
'homeFreeCourse',
|
||||
'banner',
|
||||
'article',
|
||||
'courseTip',
|
||||
'setting',
|
||||
'product',
|
||||
'course',
|
||||
'user',
|
||||
] as const;
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const dryRun = flag('dry-run');
|
||||
const fromDb = flag('from-db');
|
||||
const force = flag('force');
|
||||
const staticBase = process.env.STATIC_BASE_URL ?? 'https://api.yeomai.site';
|
||||
|
||||
console.log('旧库 → 新库 数据迁移');
|
||||
console.log(`数据源 : ${fromDb ? '直连旧库' : 'dump 文件'}`);
|
||||
console.log(`域名改写 : api.yeomai.site → ${staticBase}`);
|
||||
console.log(`模式 : ${dryRun ? '试运行(不写库)' : '正式写入'}\n`);
|
||||
|
||||
const db: LegacyRows = fromDb
|
||||
? await loadLegacyDb(process.env.LEGACY_DATABASE_URL ?? '')
|
||||
: loadLegacyDump();
|
||||
|
||||
const ds = buildDataset(db, staticBase);
|
||||
const { diagnostics } = ds;
|
||||
|
||||
console.log('── 转换结果 ────────────────────────────');
|
||||
const rows: Array<[string, number]> = [
|
||||
['users', ds.users.length],
|
||||
['courses', ds.courses.length],
|
||||
['lessons', ds.lessons.length],
|
||||
['entitlements', ds.entitlements.length],
|
||||
['home_courses', ds.homeCourses.length],
|
||||
['free_lessons', ds.freeLessons.length],
|
||||
['home_free_courses', ds.homeFreeCourses.length],
|
||||
['banners', ds.banners.length],
|
||||
['articles', ds.articles.length],
|
||||
['course_tips', ds.courseTips.length],
|
||||
['settings', ds.settings.length],
|
||||
];
|
||||
for (const [name, n] of rows) console.log(` ${name.padEnd(18)} ${String(n).padStart(5)}`);
|
||||
|
||||
const mapped = ds.homeCourses.filter((h) => h.course_id !== null).length;
|
||||
console.log(`\n 首页长图已关联课程 ${mapped}/${ds.homeCourses.length}`);
|
||||
console.log(` 重复权益已跳过 ${diagnostics.duplicateEntitlements.length}`);
|
||||
console.log(` 孤儿课时已跳过 ${diagnostics.skippedLessonOrphans.length}`);
|
||||
if (diagnostics.unmappedHomeCourses.length > 0) {
|
||||
console.log(` ⚠ 未关联课程的首页长图: ${diagnostics.unmappedHomeCourses.join('、')}`);
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
console.log('\n试运行结束,未写入数据库。');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 安全阀 ─────────────────────────────────────────────────────────────
|
||||
const existingOrders = await prisma.order.count();
|
||||
if (existingOrders > 0 && !force) {
|
||||
throw new Error(
|
||||
`新库已存在 ${existingOrders} 条订单,疑似已上线;迁移会清空业务表。确认覆盖请加 --force。`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── 写库 ───────────────────────────────────────────────────────────────
|
||||
console.log('\n── 写入新库 ────────────────────────────');
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
type Delegates = Record<string, { deleteMany: () => Promise<unknown> }>;
|
||||
const delegates = tx as unknown as Delegates;
|
||||
for (const model of CLEAR_ORDER) await delegates[model]!.deleteMany();
|
||||
|
||||
await tx.course.createMany({ data: ds.courses });
|
||||
await tx.user.createMany({ data: ds.users });
|
||||
await tx.lesson.createMany({ data: ds.lessons });
|
||||
await tx.entitlement.createMany({ data: ds.entitlements });
|
||||
await tx.homeCourse.createMany({ data: ds.homeCourses });
|
||||
await tx.freeLesson.createMany({ data: ds.freeLessons });
|
||||
await tx.homeFreeCourse.createMany({ data: ds.homeFreeCourses });
|
||||
await tx.banner.createMany({ data: ds.banners });
|
||||
await tx.article.createMany({ data: ds.articles });
|
||||
await tx.courseTip.createMany({ data: ds.courseTips });
|
||||
await tx.setting.createMany({ data: ds.settings });
|
||||
},
|
||||
{ timeout: 180_000 },
|
||||
);
|
||||
|
||||
// ── 写后校验 ───────────────────────────────────────────────────────────
|
||||
console.log('\n── 写后校验 ────────────────────────────');
|
||||
const checks: Array<[string, number, Promise<number>]> = [
|
||||
['users', ds.users.length, prisma.user.count()],
|
||||
['courses', ds.courses.length, prisma.course.count()],
|
||||
['lessons', ds.lessons.length, prisma.lesson.count()],
|
||||
['entitlements', ds.entitlements.length, prisma.entitlement.count()],
|
||||
['home_courses', ds.homeCourses.length, prisma.homeCourse.count()],
|
||||
['free_lessons', ds.freeLessons.length, prisma.freeLesson.count()],
|
||||
['banners', ds.banners.length, prisma.banner.count()],
|
||||
['articles', ds.articles.length, prisma.article.count()],
|
||||
['settings', ds.settings.length, prisma.setting.count()],
|
||||
];
|
||||
|
||||
let ok = true;
|
||||
for (const [name, want, pending] of checks) {
|
||||
const got = await pending;
|
||||
if (want !== got) ok = false;
|
||||
console.log(` ${want === got ? '✓' : '✗'} ${name.padEnd(16)} 期望 ${want} 实际 ${got}`);
|
||||
}
|
||||
if (!ok) throw new Error('行数校验不通过,迁移结果不可信。');
|
||||
|
||||
console.log(`\n✅ 迁移完成。老数据权益标记为 source=${SOURCE_LEGACY}。`);
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err: unknown) => {
|
||||
console.error(`\n❌ 迁移失败:${err instanceof Error ? err.message : String(err)}`);
|
||||
process.exitCode = 1;
|
||||
})
|
||||
.finally(() => void prisma.$disconnect());
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* 极简 mysqldump 解析器。
|
||||
*
|
||||
* 只做一件事:从 mysqldump 导出的 .sql 文本里取出 `INSERT INTO `表名` VALUES (...),(...)` 的行数据。
|
||||
* 不解析 DDL,不处理多语句逻辑。
|
||||
*/
|
||||
|
||||
export type SqlValue = string | number | null;
|
||||
|
||||
/** 解码 MySQL 字符串中的反斜杠转义。未识别的转义按 MySQL 语义保留反斜杠。 */
|
||||
function decodeEscape(e: string): string {
|
||||
switch (e) {
|
||||
case '0':
|
||||
return '\0';
|
||||
case 'b':
|
||||
return '\b';
|
||||
case 'n':
|
||||
return '\n';
|
||||
case 'r':
|
||||
return '\r';
|
||||
case 't':
|
||||
return '\t';
|
||||
case 'Z':
|
||||
return '\x1a';
|
||||
case '\\':
|
||||
return '\\';
|
||||
case "'":
|
||||
return "'";
|
||||
case '"':
|
||||
return '"';
|
||||
default:
|
||||
return '\\' + e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 把 `VALUES` 之后的载荷解析成二维数组。
|
||||
* 每个字段的三种形态:带引号的字符串 / NULL / 数字。
|
||||
*/
|
||||
export function parseTupleList(payload: string): SqlValue[][] {
|
||||
const rows: SqlValue[][] = [];
|
||||
const n = payload.length;
|
||||
let i = 0;
|
||||
|
||||
const skipSeparators = (): void => {
|
||||
while (i < n && (payload[i] === ',' || payload[i]! <= ' ')) i++;
|
||||
};
|
||||
|
||||
skipSeparators();
|
||||
while (i < n) {
|
||||
if (payload[i] !== '(') {
|
||||
throw new Error(`位置 ${i} 处期望 '(',实际是 ${JSON.stringify(payload[i])}`);
|
||||
}
|
||||
i++;
|
||||
|
||||
const row: SqlValue[] = [];
|
||||
let closed = false;
|
||||
while (!closed) {
|
||||
while (i < n && payload[i]! <= ' ') i++;
|
||||
|
||||
if (payload[i] === "'") {
|
||||
i++;
|
||||
let s = '';
|
||||
for (;;) {
|
||||
const c = payload[i];
|
||||
if (c === undefined) throw new Error('字符串未闭合');
|
||||
if (c === '\\') {
|
||||
s += decodeEscape(payload[i + 1] ?? '');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (c === "'") {
|
||||
if (payload[i + 1] === "'") {
|
||||
s += "'";
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
i++;
|
||||
break;
|
||||
}
|
||||
s += c;
|
||||
i++;
|
||||
}
|
||||
row.push(s);
|
||||
} else {
|
||||
let token = '';
|
||||
while (i < n && payload[i] !== ',' && payload[i] !== ')') {
|
||||
token += payload[i];
|
||||
i++;
|
||||
}
|
||||
token = token.trim();
|
||||
row.push(token === '' || token.toUpperCase() === 'NULL' ? null : Number(token));
|
||||
}
|
||||
|
||||
while (i < n && payload[i]! <= ' ' && payload[i] !== ',' && payload[i] !== ')') i++;
|
||||
const c = payload[i];
|
||||
if (c === ',') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
if (c === ')') {
|
||||
i++;
|
||||
closed = true;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`位置 ${i} 处期望 ',' 或 ')',实际是 ${JSON.stringify(c)}`);
|
||||
}
|
||||
|
||||
rows.push(row);
|
||||
skipSeparators();
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 取出某张表全部 INSERT 行。表不存在时返回空数组。 */
|
||||
export function readTable(sql: string, table: string): SqlValue[][] {
|
||||
const re = new RegExp('INSERT INTO `' + table.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '` VALUES (.*?);\\n', 'gs');
|
||||
const rows: SqlValue[][] = [];
|
||||
for (const m of sql.matchAll(re)) {
|
||||
rows.push(...parseTupleList(m[1]!));
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/** 转成对象数组,便于按字段名取值。 */
|
||||
export function toObjects(rows: SqlValue[][], columns: string[]): Record<string, SqlValue>[] {
|
||||
return rows.map((row) => {
|
||||
const obj: Record<string, SqlValue> = {};
|
||||
columns.forEach((col, idx) => {
|
||||
obj[col] = row[idx] ?? null;
|
||||
});
|
||||
return obj;
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { buildDataset } from './build';
|
||||
import { DEFAULT_DUMP_PATH, loadLegacyDump } from './load';
|
||||
import {
|
||||
HOME_COURSE_TITLE_MAP,
|
||||
SETTINGS_KEY_MAP,
|
||||
SOURCE_LEGACY,
|
||||
parseLegacyDate,
|
||||
rewriteDomain,
|
||||
transformCourse,
|
||||
transformSetting,
|
||||
} from './transform';
|
||||
|
||||
describe('parseLegacyDate', () => {
|
||||
it('把旧库的本地墙钟时间按 +08:00 换算为 UTC', () => {
|
||||
// 2024-04-11 00:00:39(北京时间)= 2024-04-10 16:00:39 UTC
|
||||
expect(parseLegacyDate('2024-04-11 00:00:39')?.toISOString()).toBe('2024-04-10T16:00:39.000Z');
|
||||
});
|
||||
|
||||
it('空值与零日期返回 null', () => {
|
||||
expect(parseLegacyDate(null)).toBeNull();
|
||||
expect(parseLegacyDate('')).toBeNull();
|
||||
expect(parseLegacyDate('0000-00-00 00:00:00')).toBeNull();
|
||||
});
|
||||
|
||||
it('只有日期没有时间的旧值也能解析(旧库确实存在)', () => {
|
||||
expect(parseLegacyDate('2024-02-01')?.toISOString()).toBe('2024-01-31T16:00:00.000Z');
|
||||
});
|
||||
|
||||
it('无法解析的值直接抛错,而不是静默变成 Invalid Date', () => {
|
||||
expect(() => parseLegacyDate('不是时间')).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformCourse', () => {
|
||||
it('清理标题首尾空白(旧数据存在带 \\r\\n 的课程名)', () => {
|
||||
const out = transformCourse({
|
||||
课程id: 'a'.repeat(64),
|
||||
课程名称: '7年级同步课程\r\n',
|
||||
课程介绍: '夯实数学基础有妙招!',
|
||||
});
|
||||
expect(out.title).toBe('7年级同步课程');
|
||||
});
|
||||
});
|
||||
|
||||
describe('rewriteDomain', () => {
|
||||
it('把旧域名换成新静态域名(http 与 https 都覆盖)', () => {
|
||||
const html = '<img src="https://api.yeomai.site/Images/a.png"/><img src="http://api.yeomai.site/b.png"/>';
|
||||
expect(rewriteDomain(html, 'https://data.example.com')).toBe(
|
||||
'<img src="https://data.example.com/Images/a.png"/><img src="https://data.example.com/b.png"/>',
|
||||
);
|
||||
});
|
||||
|
||||
it('新域名结尾的斜杠不会产生双斜杠', () => {
|
||||
expect(rewriteDomain('https://api.yeomai.site/x', 'https://data.example.com/')).toBe(
|
||||
'https://data.example.com/x',
|
||||
);
|
||||
});
|
||||
|
||||
it('null 原样返回', () => {
|
||||
expect(rewriteDomain(null, 'https://data.example.com')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('transformSetting', () => {
|
||||
it('按映射表转换 key', () => {
|
||||
expect(transformSetting({ ID: 2, value: '付费课程', 注释: '标题' })).toEqual({
|
||||
key: 'home.title.paid',
|
||||
value: '付费课程',
|
||||
remark: '标题',
|
||||
});
|
||||
});
|
||||
|
||||
it('无意义占位(ID 0)被丢弃', () => {
|
||||
expect(transformSetting({ ID: 0, value: '占空', 注释: '占空' })).toBeNull();
|
||||
});
|
||||
|
||||
it('映射表覆盖 cloudconfig 的全部有效 ID', () => {
|
||||
expect(Object.keys(SETTINGS_KEY_MAP).map(Number).sort((a, b) => a - b)).toEqual([
|
||||
1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 以下用真实旧库 dump 做端到端校验(迁移逻辑的落点检查)──────────────
|
||||
|
||||
describe('buildDataset(真实旧库数据)', () => {
|
||||
const ds = buildDataset(loadLegacyDump(DEFAULT_DUMP_PATH), 'https://data.example.com');
|
||||
|
||||
it('行数与旧库一致', () => {
|
||||
expect(ds.users).toHaveLength(284);
|
||||
expect(ds.courses).toHaveLength(10);
|
||||
expect(ds.lessons).toHaveLength(841);
|
||||
expect(ds.entitlements).toHaveLength(48);
|
||||
expect(ds.homeCourses).toHaveLength(5);
|
||||
expect(ds.freeLessons).toHaveLength(12);
|
||||
expect(ds.homeFreeCourses).toHaveLength(3);
|
||||
expect(ds.banners).toHaveLength(3);
|
||||
expect(ds.articles).toHaveLength(2);
|
||||
expect(ds.courseTips).toHaveLength(2);
|
||||
expect(ds.settings).toHaveLength(12); // cloudconfig 13 行,ID 0 占位被丢弃
|
||||
});
|
||||
|
||||
it('没有任何数据被静默丢弃', () => {
|
||||
expect(ds.diagnostics.skippedLessonOrphans).toEqual([]);
|
||||
expect(ds.diagnostics.duplicateEntitlements).toEqual([]);
|
||||
expect(ds.diagnostics.unmappedHomeCourses).toEqual([]);
|
||||
});
|
||||
|
||||
it('课程 code 是 64 位十六进制且无重复(视频路径依赖它)', () => {
|
||||
const codes = ds.courses.map((c) => c.code);
|
||||
expect(new Set(codes).size).toBe(codes.length);
|
||||
for (const code of codes) expect(code).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it('课程标题已清理首尾空白', () => {
|
||||
for (const c of ds.courses) expect(c.title).toBe(c.title.trim());
|
||||
expect(ds.courses.map((c) => c.title)).toContain('7年级同步课程');
|
||||
});
|
||||
|
||||
it('权益无重复(满足 entitlements UNIQUE(user_id, course_id))', () => {
|
||||
const keys = ds.entitlements.map((e) => `${e.user_id}::${e.course_id}`);
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
for (const e of ds.entitlements) expect(e.source).toBe(SOURCE_LEGACY);
|
||||
});
|
||||
|
||||
it('首页 5 条长图全部关联到可购买课程', () => {
|
||||
expect(Object.keys(HOME_COURSE_TITLE_MAP)).toHaveLength(5);
|
||||
for (const h of ds.homeCourses) expect(h.course_id).not.toBeNull();
|
||||
});
|
||||
|
||||
it('内容字段中的旧域名已全部改写', () => {
|
||||
const all = [...ds.articles, ...ds.homeCourses].map((r) => r.content ?? '').join('');
|
||||
expect(all).not.toContain('api.yeomai.site');
|
||||
expect(all).toContain('https://data.example.com');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,240 @@
|
||||
/**
|
||||
* 旧行 → 新行 的纯转换函数。
|
||||
*
|
||||
* 本文件不碰数据库、不做 IO,所有输入输出都是普通对象,便于用测试覆盖。
|
||||
* 数据库读写与顺序编排在 migrate.ts。
|
||||
*/
|
||||
|
||||
import type { SqlValue } from './parser';
|
||||
|
||||
export const SOURCE_ORDER = 1;
|
||||
export const SOURCE_LEGACY = 2;
|
||||
export const SOURCE_MANUAL = 3;
|
||||
|
||||
/** 旧库时间的时区。旧数据由服务器本地 NOW() 写入,服务器在中国大陆。 */
|
||||
const LEGACY_UTC_OFFSET = process.env.LEGACY_UTC_OFFSET ?? '+08:00';
|
||||
|
||||
function str(v: SqlValue | undefined): string {
|
||||
return v === null || v === undefined ? '' : String(v);
|
||||
}
|
||||
|
||||
function strOrNull(v: SqlValue | undefined): string | null {
|
||||
const s = str(v).trim();
|
||||
return s === '' ? null : s;
|
||||
}
|
||||
|
||||
function int(v: SqlValue | undefined): number {
|
||||
const n = Number(str(v));
|
||||
return Number.isFinite(n) ? Math.trunc(n) : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析旧库时间字符串。
|
||||
*
|
||||
* 旧值有两种形态,都是服务器本地墙钟时间:
|
||||
* `2024-04-11 00:00:39` 日期 + 时间
|
||||
* `2024-02-01` 仅日期(旧库中确实存在,之前未处理会解析失败)
|
||||
*
|
||||
* 需按 +08:00 换算为 UTC 存储。若值本身已带时区信息则直接采用。
|
||||
*/
|
||||
export function parseLegacyDate(v: SqlValue | undefined): Date | null {
|
||||
const raw = str(v).trim();
|
||||
if (raw === '' || raw.startsWith('0000-00-00')) return null;
|
||||
|
||||
let normalized = raw.replace(' ', 'T');
|
||||
if (/^\d{4}-\d{2}-\d{2}$/.test(normalized)) normalized += 'T00:00:00';
|
||||
|
||||
const hasZone = /(?:Z|[+-]\d{2}:?\d{2})$/.test(normalized);
|
||||
const date = new Date(hasZone ? normalized : `${normalized}${LEGACY_UTC_OFFSET}`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new Error(`无法解析时间:${JSON.stringify(raw)}`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
/** 把内容字段里的旧域名替换为新静态资源域名。 */
|
||||
export function rewriteDomain(html: string | null, newBase: string): string | null {
|
||||
if (html === null) return null;
|
||||
return html.replace(/https?:\/\/api\.yeomai\.site/gi, newBase.replace(/\/+$/, ''));
|
||||
}
|
||||
|
||||
export type Row = Record<string, SqlValue>;
|
||||
|
||||
// ── users ────────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformUser(row: Row) {
|
||||
const registeredAt = parseLegacyDate(row.注册日期);
|
||||
return {
|
||||
openid: str(row.OpenID),
|
||||
nickname: strOrNull(row.用户名)?.slice(0, 32) ?? null,
|
||||
headicon_id: int(row.HeadiconID),
|
||||
status: 1,
|
||||
registered_at: registeredAt ?? new Date(0),
|
||||
last_login_at: parseLegacyDate(row.最后登录日期),
|
||||
};
|
||||
}
|
||||
|
||||
// ── courses ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformCourse(row: Row) {
|
||||
return {
|
||||
code: str(row.课程id),
|
||||
// 旧数据存在 '7年级同步课程\r\n' 这类首尾换行,必须清理
|
||||
title: str(row.课程名称).trim().slice(0, 128),
|
||||
intro: strOrNull(row.课程介绍)?.slice(0, 255) ?? null,
|
||||
cover_image: null,
|
||||
price_fen: 0,
|
||||
status: 1,
|
||||
sort_order: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── lessons ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformLesson(row: Row, courseId: number) {
|
||||
return {
|
||||
course_id: courseId,
|
||||
play_id: str(row.播放id),
|
||||
chapter_id: str(row.章节id),
|
||||
chapter_title: strOrNull(row.章节名称),
|
||||
lesson_title: strOrNull(row.视频名称),
|
||||
duration: strOrNull(row.时长),
|
||||
published_at: parseLegacyDate(row.发布日期),
|
||||
cover_image: strOrNull(row.封面图片),
|
||||
sort_order: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── entitlements ─────────────────────────────────────────────────────────
|
||||
|
||||
export function transformEntitlement(row: Row, userId: number, courseId: number) {
|
||||
const grantedAt = parseLegacyDate(row.购课日期);
|
||||
return {
|
||||
user_id: userId,
|
||||
course_id: courseId,
|
||||
source: SOURCE_LEGACY,
|
||||
ref_id: null,
|
||||
granted_at: grantedAt ?? new Date(0),
|
||||
expire_at: null,
|
||||
note: '旧版课程码兑换/历史购买记录',
|
||||
};
|
||||
}
|
||||
|
||||
// ── free_lessons ─────────────────────────────────────────────────────────
|
||||
|
||||
export function transformFreeLesson(row: Row) {
|
||||
return {
|
||||
series_no: int(row.序号),
|
||||
sort_order: int(row.排序),
|
||||
course_name: str(row.课程名称),
|
||||
video_title: strOrNull(row.视频标题),
|
||||
intro: strOrNull(row.详细介绍),
|
||||
publisher: strOrNull(row.发布人),
|
||||
published_at: parseLegacyDate(row.发布日期),
|
||||
duration: strOrNull(row.时长),
|
||||
video_m3u8: strOrNull(row.视频m3u8),
|
||||
cover_image: strOrNull(row.封面图片),
|
||||
};
|
||||
}
|
||||
|
||||
// ── home_courses ─────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 首页长图条目 → 可购买课程 的对应关系。
|
||||
*
|
||||
* 首页 5 条「新课上线」与 10 门付费课程的命名并不一致(七/7、封面/封面2 等后缀),
|
||||
* 无法用规则推导,这里显式列出,且用课程标题做键以便阅读。
|
||||
*/
|
||||
export const HOME_COURSE_TITLE_MAP: Record<string, string> = {
|
||||
几何模型封面: '几何模型',
|
||||
中考数学压轴大招封面: '中考数学压轴大招',
|
||||
七年级同步课程封面2: '7年级同步课程',
|
||||
八年级同步课程: '8年级同步课程',
|
||||
九年级同步课程: '9年级同步课程',
|
||||
};
|
||||
|
||||
export function transformHomeCourse(row: Row, courseId: number | null, staticBase: string) {
|
||||
return {
|
||||
course_name: str(row.课程名称),
|
||||
intro: strOrNull(row.课程介绍),
|
||||
published_at: parseLegacyDate(row.课程日期),
|
||||
cover_image: strOrNull(row.课程封面图片),
|
||||
content: rewriteDomain(strOrNull(row.内容), staticBase),
|
||||
course_id: courseId,
|
||||
sort_order: 0,
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── home_free_courses ────────────────────────────────────────────────────
|
||||
|
||||
export function transformHomeFreeCourse(row: Row) {
|
||||
return {
|
||||
series_no: int(row.序号),
|
||||
course_name: str(row.课程名称),
|
||||
intro: strOrNull(row.课程介绍),
|
||||
published_at: parseLegacyDate(row.课程日期),
|
||||
cover_image: strOrNull(row.课程封面图片),
|
||||
sort_order: int(row.序号),
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── banners ──────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformBanner(row: Row) {
|
||||
return {
|
||||
filename: str(row.图片名),
|
||||
sort_order: int(row.序号),
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── articles ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function transformArticle(row: Row, staticBase: string) {
|
||||
return {
|
||||
legacy_page_id: strOrNull(row.网页id),
|
||||
title: str(row.网页标题),
|
||||
intro: strOrNull(row.详细介绍),
|
||||
publisher: strOrNull(row.发布人),
|
||||
published_at: parseLegacyDate(row.发布日期),
|
||||
content: rewriteDomain(strOrNull(row.网页内容), staticBase),
|
||||
sort_order: int(row.网页id),
|
||||
status: 1,
|
||||
};
|
||||
}
|
||||
|
||||
// ── course_tips ──────────────────────────────────────────────────────────
|
||||
|
||||
export function transformCourseTip(row: Row) {
|
||||
return {
|
||||
tip_key: int(row.id),
|
||||
content: str(row.content),
|
||||
title: strOrNull(row.title),
|
||||
};
|
||||
}
|
||||
|
||||
// ── settings ─────────────────────────────────────────────────────────────
|
||||
|
||||
/** cloudconfig 数字 ID → settings 字符串 key。ID 0「占空」为无意义占位,不迁移。 */
|
||||
export const SETTINGS_KEY_MAP: Record<number, string> = {
|
||||
1: 'site.version',
|
||||
2: 'home.title.paid',
|
||||
3: 'home.title.trial',
|
||||
4: 'home.title.public',
|
||||
5: 'profile.qrcode_image',
|
||||
6: 'profile.headicon_count',
|
||||
7: 'profile.menu.change_info',
|
||||
8: 'profile.menu.share',
|
||||
9: 'profile.menu.contact',
|
||||
10: 'profile.menu.wechat',
|
||||
11: 'home.footer_text',
|
||||
12: 'profile.qrcode_caption',
|
||||
};
|
||||
|
||||
export function transformSetting(row: Row): { key: string; value: string | null; remark: string | null } | null {
|
||||
const key = SETTINGS_KEY_MAP[int(row.ID)];
|
||||
if (!key) return null;
|
||||
return { key, value: strOrNull(row.value), remark: strOrNull(row.注释) };
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist", "tools", "test", "**/*.test.ts"]
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022"],
|
||||
"moduleResolution": "node",
|
||||
"declaration": false,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"esModuleInterop": true,
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*", "tools/**/*", "test/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user