小程序修正

This commit is contained in:
tianmo committed 2026-09-29 20:51:39 +08:00
1 parent 5a92bab835
commit 6ae4c109ce
12 files changed
+232 -62

No files matched your search

+20 -2
View File
@@ -166,14 +166,31 @@ Images/Placeholder/weixin.png
付费视频的鉴权由服务端 `/media/*` 签名通道提供(地址有效期 30 分钟,HLS 的 `.ts` 分片也逐行带签名)。
但如果 `Course/` 目录同时被数据域名公开暴露,任何人猜到路径就能直接下载,这套防护等于没有。
**在数据端站点的 Nginx 配置里加一条:**
注意 `Course/` 目录里有两类东西,规则要区分对待:
| 路径 | 内容 | 处理 |
|---|---|---|
| `Course/{课程code}.png` | 课程封面图 | **必须公开**,小程序课程列表要用 |
| `Course/{code}/{章节}/{播放}/playlist.m3u8` | 付费视频 | **必须挡住** |
**在数据端站点的 Nginx 配置里加这两条(顺序不能反):**
```nginx
location ^~ /Course/ {
# Course 目录下的图片(课程封面)放行
location ~* ^/Course/.*\.(png|jpe?g|webp|gif)$ {
try_files $uri =404;
}
# Course 目录下的其它内容(视频切片、播放列表)一律拒绝
location ~ ^/Course/ {
deny all;
}
```
> 两条都用正则 `location`,放行规则写在前面。Nginx 对正则 location 按书写顺序匹配,
> 第一条命中就不再往下走。**不能用 `location ^~ /Course/`** —— `^~` 优先级高于正则,
> 会跳过放行规则,导致课程封面也变成 403。
免费内容(`Images/`、`WebPages/`、`FreeVideos/`)保持公开即可。
> 若后续改用 CDN,把 `MediaService.buildSignedUrl` 换成 CDN 厂商的签名方式,并在 CDN 侧对 `Course/` 开鉴权。
@@ -241,6 +258,7 @@ location ^~ /Course/ {
- [ ] 未购买课程看不到课时列表(直接请求接口返回 3000 错误码)
- [ ] 支付一笔小额订单,课程正确开通
- [ ] 管理后台能看到这笔订单,状态为「已开通」
- [ ] **数据端已加 `Course/` 防盗链规则**,且「知识」页课程封面正常显示(200)
---
+21
View File
@@ -0,0 +1,21 @@
{
"name": "yelaymaths",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "yelaymaths",
"version": "1.0.0",
"dependencies": {
"@vant/weapp": "^1.11.7"
}
},
"node_modules/@vant/weapp": {
"version": "1.11.7",
"resolved": "https://registry.npmjs.org/@vant/weapp/-/weapp-1.11.7.tgz",
"integrity": "sha512-Rwn9BBnb4kHSV4XmvBicwtd42J+amEUfnFDcXJsGNPNX4a9c/DoT6YLsm4X1wB2+sQbdiQsbFBLAvGRBxCkD8g==",
"license": "MIT"
}
}
}
@@ -41,21 +41,37 @@ Page({
});
},
/**
* 登录 / 注册。
*
* 关键:不能凭本地缓存决定昵称。
* 退出登录会清空本地缓存,此时若直接生成随机昵称并提交,
* 会把老用户在服务端的真实昵称覆盖掉。正确做法是先取服务端资料:
* - 已有昵称 → 老用户,原样保留,不提交任何修改
* - 没有昵称 → 首次注册,生成一个随机昵称
*/
onLoginBtnClick() {
const nickname = this.data.userInfo.nickName || randomNickname();
wx.showLoading({ title: '请稍候', mask: true });
// getMe 在未登录时会自动完成静默登录,无需手动判断 token
api
.updateMe({ nickname: nickname })
.getMe()
.then((user) => {
store.setUser(user);
if (user.nickname) return user;
return api.updateMe({ nickname: randomNickname() }).then((created) => {
store.setUser(created);
return created;
});
})
.then(() => {
wx.hideLoading();
wx.showToast({ title: '成功', icon: 'success', mask: true, duration: 1500 });
setTimeout(() => wx.switchTab({ url: '/pages/wode/wode' }), 1500);
})
.catch((err) => {
wx.hideLoading();
wx.showToast({ title: err.message || '登录失败', icon: 'none', mask: true });
wx.showToast({ title: (err && err.message) || '登录失败', icon: 'none', mask: true });
});
},
@@ -76,4 +92,3 @@ Page({
});
},
});
+31 -18
View File
@@ -11,8 +11,8 @@
const config = require('../config/index');
const store = require('./store');
/** 进行中的刷新,避免并发 401 触发多次刷新 */
let refreshing = null;
/** 进行中的会话恢复,避免并发 401 触发多次恢复流程 */
let recovering = null;
function staticUrl(path) {
if (!path) return '';
@@ -52,14 +52,14 @@ function rawRequest(options) {
});
}
/** 带鉴权的请求。token 失效时刷新一次并重试。 */
/** 带鉴权的请求。token 失效时自动恢复会话并重试一次。 */
function request(options) {
return rawRequest(options).then(({ statusCode, body }) => {
if (body.code === 0) return body.data;
const needLogin = statusCode === 401 || body.code === 2000 || body.code === 2001;
if (needLogin && options.auth !== false && options.retried !== true) {
return refreshAccessToken()
return recoverSession()
.then(() => request(Object.assign({}, options, { retried: true })))
.catch(() => {
store.reset();
@@ -71,27 +71,41 @@ function request(options) {
});
}
/**
* 恢复会话。两级兜底:
* 1. 用 refreshToken 换新 accessToken(快,不打扰用户)
* 2. refreshToken 也没有或已失效时,重新走一次微信静默登录
*
* 第 2 级是必需的:「退出登录」会清空全部 token,
* 此后任何需要鉴权的操作(例如再次点「登录注册」改昵称)都必须先重新登录,
* 否则必然 401。
*/
function recoverSession() {
if (recovering) return recovering;
recovering = refreshAccessToken()
.catch(() => login())
.finally(() => {
recovering = null;
});
return recovering;
}
function refreshAccessToken() {
if (refreshing) return refreshing;
const refreshToken = store.getRefreshToken();
if (!refreshToken) return Promise.reject({ code: 2000, message: '未登录' });
refreshing = rawRequest({
return rawRequest({
url: '/v1/auth/refresh',
method: 'POST',
data: { refreshToken },
auth: false,
})
.then(({ body }) => {
if (body.code !== 0) throw { code: 2000, message: '登录已过期' };
store.setTokens(body.data.accessToken);
return body.data.accessToken;
})
.finally(() => {
refreshing = null;
});
return refreshing;
}).then(({ body }) => {
if (body.code !== 0) throw { code: 2000, message: '登录已过期' };
store.setTokens(body.data.accessToken);
return body.data.accessToken;
});
}
// ── 登录 ──────────────────────────────────────────────────────────────────
@@ -195,4 +209,3 @@ function requestVirtualPayment(payData) {
}
module.exports = Object.assign(api, { requestVirtualPayment });
+1 -2
View File
@@ -108,9 +108,9 @@ const store = {
reset() {
memory.user = null;
memory.siteConfig = null;
memory.entitlements = [];
store.clearTokens();
// 站点配置与登录态无关,保留;否则退出登录后页面文案会退回默认值
wx.removeStorageSync(config.STORAGE.USER);
wx.removeStorageSync(config.STORAGE.ENTITLEMENTS);
notify();
@@ -118,4 +118,3 @@ const store = {
};
module.exports = store;
+4
View File
@@ -30,6 +30,10 @@ PLAY_URL_TTL_SECONDS=1800
# 课程数据端本地路径(媒体文件根目录)
DATA_ROOT="../yelaymaths_data.yeomai.site"
# 媒体上游地址。媒体文件不在本机时填写,留空则读 DATA_ROOT 本地磁盘。
# 例:MEDIA_UPSTREAM="http://127.0.0.1:2525"
MEDIA_UPSTREAM=""
# 微信小程序(沿用现有 AppID)
WX_APPID="wx3fbaf897825bf04f"
WX_APPSECRET="change-me"
+1
View File
@@ -24,6 +24,7 @@
"@nestjs/platform-express": "^11.0.0",
"@nestjs/schedule": "^5.0.0",
"@prisma/client": "^6.0.0",
"express": "^5.2.1",
"fast-xml-parser": "^4.5.1",
"mysql2": "^3.11.5",
"nestjs-pino": "^4.1.0",
+3
View File
@@ -29,6 +29,9 @@ importers:
'@prisma/client':
specifier: ^6.0.0
version: 6.19.3(prisma@6.19.3(typescript@5.9.3))(typescript@5.9.3)
express:
specifier: ^5.2.1
version: 5.2.1(supports-color@8.1.1)
fast-xml-parser:
specifier: ^4.5.1
version: 4.5.7
+7
View File
@@ -49,6 +49,13 @@ const envSchema = z.object({
/** 课程数据端根目录(媒体文件所在目录) */
DATA_ROOT: z.string().min(1).default('../yelaymaths_data.yeomai.site'),
/**
* 媒体上游地址。媒体文件不在本机时填写,例如通过隧道映射到本机的地址:
* MEDIA_UPSTREAM="http://127.0.0.1:2525"
* 留空则从 DATA_ROOT 读本地磁盘。
*/
MEDIA_UPSTREAM: z.string().default(''),
});
export type AppConfig = z.infer<typeof envSchema>;
@@ -2,6 +2,8 @@ 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 { Readable } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import path from 'node:path';
import type { Response } from 'express';
import { APP_CONFIG, type AppConfig } from '../../config/config';
@@ -23,9 +25,45 @@ const REWRITABLE = /\.(ts|m3u8|key|mp4|jpg|jpeg|png|webp)(\?.*)?$/i;
@Injectable()
export class MediaService {
private readonly root: string;
private readonly upstream: string;
constructor(@Inject(APP_CONFIG) private readonly cfg: AppConfig) {
this.root = path.resolve(process.cwd(), cfg.DATA_ROOT);
this.upstream = (cfg.MEDIA_UPSTREAM ?? '').replace(/\/+$/, '');
}
/**
* 媒体文件不一定在本机。
* 配了 MEDIA_UPSTREAM 就走上游(例如另一台服务器经隧道映射到本机的端口),
* 否则读 DATA_ROOT 下的本地文件。
*/
private upstreamUrl(relativePath: string): string {
const encoded = relativePath.split('/').map(encodeURIComponent).join('/');
return `${this.upstream}/${encoded}`;
}
/** 取文本内容(播放列表需要整体读入后重写)。 */
private async readText(relativePath: string): Promise<string | null> {
if (this.upstream) {
const res = await fetch(this.upstreamUrl(relativePath));
if (!res.ok) return null;
return res.text();
}
const abs = this.resolve(relativePath);
return abs ? readFile(abs, 'utf8') : null;
}
private notFound(res: Response, detail = '资源不存在'): void {
res.status(404).json({ code: 1001, message: detail, data: null });
}
/**
* 路径合法性检查(纵深防御)。
* 签名本身已经能挡住伪造请求,这里再拦一道目录穿越,
* 避免上游服务器被 `../` 探到数据目录之外。
*/
private isSafePath(relativePath: string): boolean {
return relativePath.split('/').every((seg) => seg !== '' && seg !== '.' && seg !== '..');
}
private sign(relativePath: string, expiresAt: number): string {
@@ -95,28 +133,47 @@ export class MediaService {
/** 把文件写入响应。播放列表会先重写。 */
async send(res: Response, relativePath: string): Promise<void> {
const abs = this.resolve(relativePath);
if (!abs) {
res.status(404).json({ code: 1001, message: '资源不存在', data: null });
if (!this.isSafePath(relativePath)) {
this.notFound(res, '非法路径');
return;
}
const ext = path.extname(abs).toLowerCase();
res.setHeader('Content-Type', MIME[ext] ?? 'application/octet-stream');
res.setHeader('Cache-Control', 'private, max-age=300');
const ext = path.extname(relativePath).toLowerCase();
const mime = MIME[ext] ?? 'application/octet-stream';
// 播放列表:必须整体读入,因为要把签名透传到每一行分片引用上
if (ext === '.m3u8') {
const content = await readFile(abs, 'utf8');
const content = await this.readText(relativePath);
if (content === null) {
this.notFound(res);
return;
}
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'private, max-age=300');
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);
});
// 其余资源(分片、密钥、封面图):按字节透传,不整块读入内存
if (this.upstream) {
const upstreamRes = await fetch(this.upstreamUrl(relativePath));
if (!upstreamRes.ok || !upstreamRes.body) {
this.notFound(res, `上游返回 ${upstreamRes.status}`);
return;
}
res.setHeader('Content-Type', upstreamRes.headers.get('content-type') ?? mime);
res.setHeader('Cache-Control', 'private, max-age=300');
await pipeline(Readable.fromWeb(upstreamRes.body as Parameters<typeof Readable.fromWeb>[0]), res);
return;
}
const abs = this.resolve(relativePath);
if (!abs) {
this.notFound(res);
return;
}
res.setHeader('Content-Type', mime);
res.setHeader('Cache-Control', 'private, max-age=300');
await pipeline(createReadStream(abs), res);
}
}
@@ -34,7 +34,10 @@ export class UsersService {
if (input.nickname !== undefined) {
const nickname = input.nickname.trim();
if (nickname.length === 0) throw AppError.badRequest('昵称不能为空');
if (nickname.length > 8) throw AppError.badRequest('昵称最多 8 个字符');
// 上限取 20:旧数据里自动生成的昵称是「用户 + 8 位随机」共 10 字符,
// 而小程序手输框限制 8 字符。服务端要容纳两者,取一个有余量的值。
// 数据库列是 VARCHAR(32),不会溢出。
if (nickname.length > 20) throw AppError.badRequest('昵称最多 20 个字符');
data.nickname = nickname;
}
@@ -80,4 +83,3 @@ export class UsersService {
return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 50;
}
}
+50 -20
View File
@@ -28,23 +28,26 @@ 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;
/**
* 清空顺序:先子后父(有外键,顺序不能乱)。
* 同时给出真实表名,用于重置自增。
*/
const CLEAR_ORDER: Array<{ model: string; table: string }> = [
{ model: 'paymentNotifyLog', table: 'payment_notify_log' },
{ model: 'order', table: 'orders' },
{ model: 'entitlement', table: 'entitlements' },
{ model: 'lesson', table: 'lessons' },
{ model: 'homeCourse', table: 'home_courses' },
{ model: 'freeLesson', table: 'free_lessons' },
{ model: 'homeFreeCourse', table: 'home_free_courses' },
{ model: 'banner', table: 'banners' },
{ model: 'article', table: 'articles' },
{ model: 'courseTip', table: 'course_tips' },
{ model: 'setting', table: 'settings' },
{ model: 'product', table: 'products' },
{ model: 'course', table: 'courses' },
{ model: 'user', table: 'users' },
];
async function main(): Promise<void> {
const dryRun = flag('dry-run');
@@ -108,14 +111,41 @@ async function main(): Promise<void> {
// ── 写库 ───────────────────────────────────────────────────────────────
console.log('\n── 写入新库 ────────────────────────────');
type Delegates = Record<string, { deleteMany: () => Promise<unknown> }>;
const delegates = prisma as unknown as Delegates;
// 清空与重置自增必须放在事务外:MySQL 的 ALTER TABLE 会隐式提交事务。
for (const { model } of CLEAR_ORDER) await delegates[model]!.deleteMany();
// 关键:deleteMany 不会重置自增计数器。
// 上一次失败的迁移虽然回滚了数据,但计数器已经前进,
// 导致再次插入时 id 不是从 1 开始,与 buildDataset 里按顺序推导的映射对不上,
// 进而触发外键约束失败。
for (const { table } of CLEAR_ORDER) {
await prisma.$executeRawUnsafe(`ALTER TABLE \`${table}\` AUTO_INCREMENT = 1`);
}
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 });
// 校验实际自增 id 与推导出的映射一致。
// 若不一致,lessons / entitlements 会引用到错误的课程——这类错误外键查不出来,
// 但会让用户点开 A 课程看到 B 课程的内容,所以必须显式拦住。
const createdCourses = await tx.course.findMany({ select: { id: true, code: true } });
const idByCode = new Map(createdCourses.map((c) => [c.code, c.id]));
ds.courses.forEach((course, index) => {
const actual = idByCode.get(course.code);
if (actual !== index + 1) {
throw new Error(
`课程 id 与预期不符:${course.title} 期望 id=${index + 1},实际 id=${actual ?? '缺失'}。` +
'为避免课程数据错位,迁移已中止。请清空新库后重跑。',
);
}
});
await tx.lesson.createMany({ data: ds.lessons });
await tx.entitlement.createMany({ data: ds.entitlements });
await tx.homeCourse.createMany({ data: ds.homeCourses });