性能优化

This commit is contained in:
Alex-larget
2026-03-17 14:02:09 +08:00
parent 2f35520670
commit c24caf63c5
46 changed files with 1387 additions and 1879 deletions

View File

@@ -1,8 +1,13 @@
# 产品经理 经验记录 - 2026-03-17
## 新版管理端迁移到稳定版(会议:实施方案确认
## 稳定版源码质量优化会议2026-03-17
- **需求基准**以稳定版小程序为准管理端支撑余额、代付、VIP、规则引擎等
- **内容管理**:以稳定版为主,不采纳新版
- **新版独有**API 文档、OSS、编辑禁用、鉴权 → 全部吸纳(用户决议「新版有的就迁移」)
- **待确认**RFM、用户旅程、神射手是否继续使用
- **验收标准**优化后现有功能行为不变,三端联调通过
- **优先级**:高优(安全)→ 中优(可维护)→ 低优(性能/结构)
- **原则**:源码质量优化按安全→可维护→性能分批,用户无感知
---
## 会议收尾2026-03-17
- 10 项优化全部完成;测试流程与报告模板已定稿;开发文档已同步

View File

@@ -33,3 +33,17 @@
- **router 补齐**:迁移前注册 5 个路由:`db.GET("/users/rfm")``db.GET("/users/journey-stats")``admin.GET("/shensheshou/query")``admin.POST("/shensheshou/enrich")``admin.POST("/shensheshou/ingest")`
- **待确认**/api/admin/settings 是否已支持 ossConfig若不支持需补充
---
## 稳定版源码质量优化会议2026-03-17
- **敏感配置**生产环境MODE=release强制校验缺敏感 env 则 Fatal
- **user/track 鉴权**:新增 GET /api/admin/user/track + AdminAuth原 /api/user/track 保留给小程序 POST 埋点
- **AdminWithdrawTest**:非 develop 环境返回 404 或拒绝
---
## 会议收尾2026-03-17
- 源码优化 10 项全部完成;开发环境测试 10 通过 2 跳过

View File

@@ -35,3 +35,16 @@
- 管理端开发工程师:主导迁移
- 后端开发router 补齐、ossConfig 确认
- 测试人员:迁移后验收
---
## 稳定版源码质量优化会议2026-03-17
- **原则**:增量修复、不改功能逻辑;高优安全项优先,中优可维护项次之,低优可后续迭代
- **影响角色**:后端、管理端、小程序、测试
---
## 会议收尾2026-03-17
- 源码优化 10 项全部完成;功能测试流程定稿;开发环境测试 10 通过 2 跳过

View File

@@ -1,40 +1,15 @@
# 小程序 - 2026-03-17
# 小程序开发工程师 经验记录 - 2026-03-17
## 代付美团式流程改造
## 稳定版源码质量优化会议2026-03-17
### 场景
用户希望代付流程类似美团:先进入代付页面,再分享给好友,而非在阅读页弹窗分享。
### 实现
1. **读页「找好友代付」**:创建请求后 `wx.navigateTo` 跳转 `/pages/gift-pay/detail?requestSn=xxx`,移除弹窗
2. **代付详情页双态**
- 发起人(`detail.initiatorUserId === 当前用户ID`):标题「找朋友代付」,主按钮「分享给好友」(`open-type="share"`
- 好友:标题「帮他付款」,主按钮「帮他付款」
3. **后端**detail 接口返回 `initiatorUserId` 供前端区分
### 经验
- 代付分享入口:优先「进入代付页再分享」而非「弹窗分享」,符合用户心智
- 同一页面根据 `initiatorUserId` 与当前用户比对,展示不同 UI
- **payment.js**确认无引用可删除read.js 直接调 /api/miniprogram/pay
- **goToMatch**my.js 重复定义,删除一个
- **备份文件**:删除 read.js.backup、referral.wxss.backup
- **appId 等**:优先从 config 的 mpConfig 读取,保留兜底
- **totalSections**:从 book/stats 或 all-chapters 动态获取,保留 62 兜底
---
## 代付统一到代付页(禁止阅读页代付
## 会议收尾2026-03-17
### 规则
代付必须在代付页完成,禁止在阅读页代付。代付页可显示部分文章信息。
### 实现
- 读页:`gift=1&ref=requestSn` 打开时,`redirectTo` 到 gift-pay/detail不展示阅读页
- 代付页:已展示 description章节标题等、金额、发起人昵称
---
## 目录页 loading、首页最新新增
- 目录页:`book/parts` 加载时 `partsLoading: true`,展示旋转圈 +「加载目录中...」
- 首页最新新增:默认 5 条、折叠状态,`>5` 时显示「展开更多」
- 源码优化 5 项全部完成;开发环境测试通过

View File

@@ -0,0 +1,9 @@
# 开发助理 经验记录 - 2026-03-17
## 会议收尾2026-03-17
- **纪要**2026-03-17_会议收尾-源码优化完成与测试流程定稿.md
- **经验入库**:各角色 evolution 已追加收尾经验
- **项目索引**:后端、管理端、小程序、测试、助理橙子已更新
- **会议索引**README.md 已追加
- **开发文档**:运营与变更第十七部分已追加

View File

@@ -50,6 +50,7 @@
| 2026-03-16 | 开发助理 | 交互习惯分析 | - | 乘风读取 agent-transcripts 抽样分析角色触发词、表达方式、工作流程、沟通风格、技术偏好、Agent 响应建议 |
| 2026-03-17 | 小程序、后端、团队 | 业务规则/bug 修复 | - | 代付美团式读页→代付页→分享PayNotify beneficiaryUserID 权益归发起人detail 返回 initiatorUserId目录 loading、最新新增 5 条折叠 |
| 2026-03-17 | 小程序 | 业务规则 | - | 代付统一到代付页gift=1&ref 打开 read 时 redirectTo 代付页,禁止在阅读页代付 |
| 2026-03-17 | 软件测试 | 流程定稿 | testing SKILL | 功能测试流程:成功 ☑、失败列问题、最终报告scripts/test/功能测试流程.md、测试报告-环境与用例清单.md |
---
@@ -60,4 +61,4 @@
---
**最后更新**2026-03-17代付美团式与 PayNotify 完善
**最后更新**2026-03-17会议收尾:源码优化完成与测试流程定稿

View File

@@ -23,9 +23,10 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
| 2026-03-11 | 以界面定需求文档建立;需求基准以《以界面定需求》为准 | 已完成 |
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析 | 已完成 |
| 2026-03-17 | 会议:稳定版源码质量优化;验收标准功能不变、三端联调通过 | 待续 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-16
**最后更新**2026-03-17

View File

@@ -23,9 +23,10 @@
| 2026-03-17 | 乘风吸收经验与交互:迁移完成度与待办清单、运营与变更第十二部分 | 已完成 |
| 2026-03-17 | 吸收新需求代付统一到代付页gift=1&ref redirectTo→ 需求汇总、找朋友代付流程、运营与变更 | 已完成 |
| 2026-03-17 | 会议收尾:新版管理端迁移到稳定版实施方案确认;纪要、各角色经验入库、项目索引更新 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化完成与测试流程定稿;纪要、经验入库、开发文档同步 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-03-17会议收尾新版管理端迁移实施方案
**最后更新**2026-03-17会议收尾源码优化完成与测试流程定稿

View File

@@ -33,6 +33,8 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析content_upload.py 与 chapters 一致性待核对 | 待续 |
| 2026-03-17 | 代付 PayNotify 权益归属修复beneficiaryUserID代付=发起人gift-pay detail 返回 initiatorUserId | 已完成 |
| 2026-03-17 | 会议新版管理端迁移router 补齐 users/rfm、journey-stats、shensheshou 共 5 个;确认 ossConfig | 进行中 |
| 2026-03-17 | 会议:稳定版源码质量优化;敏感配置生产强制校验、新增 /api/admin/user/track、AdminWithdrawTest 环境限制 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化 10 项全部完成;开发环境测试 10 通过 2 跳过 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置

View File

@@ -37,6 +37,8 @@
| 2026-03-17 | 代付美团式:读页→创建请求→跳转代付详情页;详情页双态(发起人分享/好友帮他付款);目录 loading、首页最新新增 5 条折叠 | 已完成 |
| 2026-03-17 | 代付统一到代付页gift=1&ref 打开 read 时 redirectTo 代付页,禁止在阅读页代付 | 已完成 |
| 2026-03-17 | 代付页营销:章节标题+20%内容预览;我的代付列表点击进详情;页面协调 | 已完成 |
| 2026-03-17 | 会议:稳定版源码质量优化;删除 payment.js、goToMatch 重复、备份文件config 读取、totalSections 动态化 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化 5 项全部完成;开发环境测试通过 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置

View File

@@ -27,7 +27,9 @@
| 2026-03-16 | pytest 架构、配置从项目读取、运行前显示测试环境 | 已完成 |
| 2026-03-16 | 文章 @某人 自动创建存客宝:用例编写、执行、报告;归档规则 | 已完成 |
| 2026-03-16 | 会议new-soul 新需求与当前项目差异分析引入派对AI 时回归文章上传/飞书推送/小程序展示 | 已完成 |
| 2026-03-17 | 会议:稳定版源码质量优化;每项小回归、全部完成后完整三端联调 | 待续 |
| 2026-03-17 | 会议收尾:功能测试流程定稿、测试报告模板、开发环境 10 通过 2 跳过 | 已完成 |
---
**最后更新**2026-03-16
**最后更新**2026-03-17

View File

@@ -38,6 +38,8 @@
| 2026-03-17 | 会议:新版管理端迁移到稳定版实施方案确认;新版独有全部吸纳,内容管理以稳定版为主 | 已完成 |
| 2026-03-17 | 吸收新版管理端定义new-soul/soul-admin迁移 ApiDocsPage 完整版、OSS region、鉴权失败 clearAdminToken | 已完成 |
| 2026-03-17 | 修复 DistributionPage Order.description用户余额人工调整后端 adjust API + 用户详情入口);代付列表页(后端 gift-pay-requests + 推广中心 Tab | 已完成 |
| 2026-03-17 | 会议稳定版源码质量优化UserDetailModal 改 /api/admin/user/track、RichEditor HTML 转义 | 已完成 |
| 2026-03-17 | 会议收尾:源码优化已落地;开发环境测试通过 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置

View File

@@ -1,15 +1,12 @@
# 管理端开发工程师 经验记录 - 2026-03-17
## 新版管理端迁移到稳定版(会议:实施方案确认
## 稳定版源码质量优化会议2026-03-17
### 迁移策略
- **UserDetailModal**:改为调用 `/api/admin/user/track`(后端新增后同步改)
- **RichEditor**@mention 弹窗对 `item.name``item.label` 做 HTML 转义,防 XSS不改 @mention 行为
- **内容管理**以稳定版为主不采纳新版ContentPage 及关联组件不覆盖
- **新版独有全部吸纳**API 文档 Tab、api-docs 独立页、OSS 配置、编辑时手机号禁用、鉴权逻辑
- **必须保留**:用户详情余额、订单支付方式/代付、RechargeAlert、LinkedMp、用户规则、超级个体
---
### 实施任务
## 会议收尾2026-03-17
1. 从 new-soul/soul-admin 迁移ApiDocsPage、OSS 配置、api-docs 路由、编辑时手机号禁用、鉴权逻辑
2. 以稳定版为基准合并,内容管理不覆盖
3. 按模块分批Layout/Dashboard/设置 → 用户/订单/推广/提现/找伙伴
- 源码优化已落地;开发环境测试通过

View File

@@ -6,3 +6,18 @@
- **回归范围**:提现、分销、找伙伴、导师、设置等
- **风险**:合并时避免误覆盖稳定版独有逻辑,建议 diff 逐模块核对
- **三端联调**:管理端 ↔ soul-api 重点验证;用户规则、订单、余额展示
---
## 稳定版源码质量优化会议2026-03-17
- **回归重点**:支付流程、管理端用户详情行为轨迹、我的页找伙伴/推广/搜索、首页目录搜索
- **策略**:每项优化完成后小回归,全部完成后完整三端联调
---
## 会议收尾2026-03-17
- **功能测试流程定稿**`scripts/test/功能测试流程.md` — 成功 ☑️、失败列问题、最终报告
- **测试报告模板**`scripts/test/测试报告-环境与用例清单.md` — 环境、用例、结果记录
- **开发环境测试**10 通过、2 跳过、0 失败

View File

@@ -0,0 +1,90 @@
# 会议收尾 - 2026-03-17 | 源码优化完成与测试流程定稿
> 本文件由**助理橙子**在会议结束后自动生成。对应会议2026-03-17 稳定版源码质量优化方案讨论与开发安排。
---
## 基本信息
- **时间**2026-03-17
- **议题**:会议收尾 — 源码质量优化完成、开发环境测试、功能测试流程定稿、开发文档同步
- **触发方式**:结束会议、沉淀经验、开发部门同步需求
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员、助理橙子
---
## 收尾结论
### 1. 源码质量优化10 项全部完成)
| 端 | 任务 | 状态 |
|----|------|------|
| 后端 | 敏感配置生产环境强制校验config.go | ☑️ 已完成 |
| 后端 | 新增 GET /api/admin/user/track + AdminAuth | ☑️ 已完成 |
| 后端 | AdminWithdrawTest 环境限制 | ☑️ 已完成 |
| 管理端 | UserDetailModal 改为 /api/admin/user/track | ☑️ 已完成 |
| 管理端 | RichEditor name/label HTML 转义 | ☑️ 已完成 |
| 小程序 | 删除 payment.js | ☑️ 已完成 |
| 小程序 | 删除 goToMatch 重复定义 | ☑️ 已完成 |
| 小程序 | 删除 read.js.backup、referral.wxss.backup | ☑️ 已完成 |
| 小程序 | appId 等从 config 读取 | ☑️ 已完成 |
| 小程序 | totalSections 动态获取 | ☑️ 已完成 |
### 2. 开发环境测试
- **环境**local (http://localhost:8080)
- **结果**10 通过、2 跳过、0 失败
- **跳过**test_dev_login_as需 SOUL_MINIPROGRAM_DEV_USER_ID、test_backfill_persons_ckb_api_key需 CKB 配置)
### 3. 测试流程与文档
| 产出 | 路径 | 说明 |
|------|------|------|
| 功能测试流程 | scripts/test/功能测试流程.md | 环境准备→自动化→手工验证→问题汇总→报告;成功 ☑️,失败列问题 |
| 测试报告模板 | scripts/test/测试报告-环境与用例清单.md | 环境、用例清单、结果记录、归档说明 |
### 4. 开发文档同步
- **运营与变更**:新增第十七部分「源码优化完成与测试流程定稿」
- **需求汇总**:源码优化为内部质量项,无新增需求条目
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 生产环境判断:是否以 MODE=release 为准? | 后端开发 | (待补充) |
| 2 | dev/login-as 后端限制方案:环境变量 or IP 白名单? | 后端开发 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 源码质量优化验收10 项全部完成,功能不变;测试流程与报告模板已定稿
### 后端开发
- 源码优化已落地config 生产校验、admin/user/track、AdminWithdrawTest 环境限制
### 管理端开发工程师
- UserDetailModal、RichEditor 优化已落地;开发环境测试通过
### 小程序开发工程师
- payment.js 删除、goToMatch 去重、备份清理、config 读取、totalSections 动态化已落地
### 测试人员
- 功能测试流程定稿:☑️ 成功、失败列问题、最终报告;开发环境 10 通过 2 跳过
### 助理橙子
- 会议收尾:纪要、经验入库、项目索引、会议索引、开发文档同步
---
*会议收尾由助理橙子执行 | 各角色经验已同步至 agent/{角色}/evolution/2026-03-17.md*

View File

@@ -0,0 +1,113 @@
# 会议纪要 - 2026-03-17 | 稳定版源码质量优化方案讨论与开发安排
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-17
- **议题**:稳定版源码质量优化方案讨论与开发安排(基于源码质量分析报告,不影响现有功能)
- **触发方式**:开会讨论
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
---
## 各角色发言
### 【产品经理】
从需求与业务角度,本次优化聚焦**安全与可维护性**,不涉及新功能,用户无感知。建议按优先级分批处理:高优(安全)→ 中优(代码质量)→ 低优(性能/结构)。验收标准:优化后现有功能行为不变,三端联调通过。
### 【后端开发】
高优1敏感配置在 `config.go`生产环境MODE=release强制校验缺则 Fatal2新增 `GET /api/admin/user/track` 并加 AdminAuth`/api/user/track` 保留给小程序 POST 埋点。中优AdminWithdrawTest 加环境限制。低优DBUsersList 拆分可后续做。
### 【管理端开发工程师】
中优1后端提供 `/api/admin/user/track`UserDetailModal 改为调用新路径2RichEditor 对 name/label 做 HTML 转义防 XSS。低优上传逻辑抽公共方法可后续做。
### 【小程序开发工程师】
高优1payment.js 确认无引用可删除2goToMatch 重复定义删除一个3删除 read.js.backup、referral.wxss.backup。中优appId 等从 config 读取、totalSections 动态获取。dev/login-as 由后端限制即可。
### 【测试人员】
回归重点:支付流程、管理端用户详情行为轨迹、我的页找伙伴/推广/搜索、首页目录搜索。建议每项完成后小回归,全部完成后完整三端联调。
---
## 讨论过程
- 后端确认 `/api/admin/user/track` 参数与现 `/api/user/track` 一致userId、phone、limit管理端仅改 URL 即可
- 小程序确认 payment.js 无 require 引用,可安全删除
- 产品确认按优先级分批,低优可放入后续迭代
---
## 会议决议
1. **高优(本周完成)**:后端敏感配置生产强制校验、新增 `/api/admin/user/track`;管理端 UserDetailModal 改路径;小程序删除 payment.js、goToMatch 重复、备份文件
2. **中优(下周完成)**:后端 AdminWithdrawTest 环境限制;管理端 RichEditor 转义;小程序 config 读取、totalSections 动态化
3. **低优(后续迭代)**DBUsersList 拆分、上传逻辑抽公共、config 缓存
4. **原则**:所有改动为增量修复,不改现有功能逻辑;每项完成后小回归,全部完成后完整联调
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 后端开发 | 敏感配置生产环境强制校验config.go | 高 | 本周 |
| 后端开发 | 新增 GET /api/admin/user/track + AdminAuth | 高 | 本周 |
| 后端开发 | AdminWithdrawTest 环境限制 | 中 | 下周 |
| 管理端开发工程师 | UserDetailModal 改为 /api/admin/user/track | 高 | 本周 |
| 管理端开发工程师 | RichEditor name/label HTML 转义 | 中 | 下周 |
| 小程序开发工程师 | 删除 payment.js | 高 | 本周 |
| 小程序开发工程师 | 删除 goToMatch 重复定义 | 高 | 本周 |
| 小程序开发工程师 | 删除 read.js.backup、referral.wxss.backup | 高 | 本周 |
| 小程序开发工程师 | appId 等从 config 读取 | 中 | 下周 |
| 小程序开发工程师 | totalSections 动态获取 | 中 | 下周 |
| 测试人员 | 每项完成后小回归 | - | 持续 |
| 测试人员 | 全部完成后三端联调 | - | 下周 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 生产环境判断:是否以 MODE=release 为准? | 后端开发 | (待补充) |
| 2 | dev/login-as 后端限制方案:环境变量 or IP 白名单? | 后端开发 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 源码质量优化按安全→可维护→性能分批,验收标准为功能不变、三端联调通过
### 后端开发
- 敏感配置生产环境缺则 Fataluser/track 查询迁至 admin 组并加鉴权AdminWithdrawTest 非 develop 拒绝
### 管理端开发工程师
- UserDetailModal 调用 /api/admin/user/trackRichEditor @mention 需对 name/label 做 HTML 转义防 XSS
### 小程序开发工程师
- payment.js 废弃可删goToMatch 去重备份文件清理appId 等优先从 config 读取totalSections 动态获取
### 测试人员
- 源码优化类改动需每项小回归 + 全部完成后完整三端联调,重点覆盖支付、用户详情、我的页、搜索
### 团队共享
- 源码质量优化原则:增量修复、不改功能逻辑;高优安全项优先,中优可维护项次之,低优可后续迭代
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-17.md`*

View File

@@ -76,3 +76,5 @@ YYYY-MM-DD_会议主题.md
| 2026-03-16 | 链接人与事与存客宝对接优化 | 管理端、后端、团队 | [2026-03-16_链接人与事与存客宝对接优化.md](2026-03-16_链接人与事与存客宝对接优化.md) |
| 2026-03-16 | new-soul 新需求与当前项目差异分析 | 产品、后端、管理端、小程序、测试 | [2026-03-16_new-soul新需求与当前项目差异分析.md](2026-03-16_new-soul新需求与当前项目差异分析.md) |
| 2026-03-17 | 新版管理端迁移到稳定版实施方案确认 | 产品、后端、管理端、小程序、测试 | [2026-03-17_新版管理端迁移到稳定版实施方案确认.md](2026-03-17_新版管理端迁移到稳定版实施方案确认.md) |
| 2026-03-17 | 稳定版源码质量优化方案讨论与开发安排 | 产品、后端、管理端、小程序、测试 | [2026-03-17_稳定版源码质量优化方案讨论与开发安排.md](2026-03-17_稳定版源码质量优化方案讨论与开发安排.md) |
| 2026-03-17 | 会议收尾:源码优化完成与测试流程定稿 | 产品、后端、管理端、小程序、测试、助理橙子 | [2026-03-17_会议收尾-源码优化完成与测试流程定稿.md](2026-03-17_会议收尾-源码优化完成与测试流程定稿.md) |

View File

@@ -35,8 +35,7 @@ miniprogram/
│ ├── purchases/ # 订单页
│ └── settings/ # 设置页
├── utils/
── util.js # 工具函数
│ └── payment.js # 支付工具
── util.js # 工具函数
├── assets/
│ └── icons/ # 图标资源
├── project.config.json # 项目配置

View File

@@ -92,6 +92,8 @@ App({
// 加载书籍数据
this.loadBookData()
// 加载 mpConfigappId、mchId、withdrawSubscribeTmplId 等,失败时保留默认值)
this.loadMpConfig()
// 检查更新
this.checkUpdate()
@@ -320,6 +322,7 @@ App({
if (res && (res.data || res.chapters)) {
const chapters = res.data || res.chapters || []
this.globalData.bookData = chapters
this.globalData.totalSections = (chapters && chapters.length) ? chapters.length : 62
wx.setStorageSync('bookData', chapters)
}
} catch (e) {
@@ -327,6 +330,21 @@ App({
}
},
// 加载 mpConfigappId、mchId、withdrawSubscribeTmplId 等),失败时保留 globalData 默认值
async loadMpConfig() {
try {
const res = await this.request({ url: '/api/miniprogram/config', silent: true })
const mp = (res && res.mpConfig) || (res && res.configs && res.configs.mp_config)
if (mp && typeof mp === 'object') {
if (mp.appId) this.globalData.appId = mp.appId
if (mp.mchId) this.globalData.mchId = mp.mchId
if (mp.withdrawSubscribeTmplId) this.globalData.withdrawSubscribeTmplId = mp.withdrawSubscribeTmplId
}
} catch (e) {
console.warn('[App] loadMpConfig 失败,使用默认值:', e?.message || e)
}
},
/**
* 小程序更新检测(基于 wx.getUpdateManager
* - 启动时检测;从后台切回前台时也检测(间隔至少 5 分钟,避免频繁请求)

View File

@@ -812,11 +812,6 @@ Page({
wx.navigateTo({ url: '/pages/referral/referral' })
},
// 跳转到找伙伴
goToMatch() {
wx.switchTab({ url: '/pages/match/match' })
},
// 退出登录
handleLogout() {
wx.showModal({

File diff suppressed because it is too large Load Diff

View File

@@ -1,379 +0,0 @@
/* ???????? - 1:1??Web?? */
.page { min-height: 100vh; background: #000; padding-bottom: 64rpx; }
/* ??? */
.nav-bar { position: fixed; top: 0; left: 0; right: 0; z-index: 100; background: rgba(0,0,0,0.9); backdrop-filter: blur(40rpx); display: flex; align-items: center; justify-content: space-between; padding: 0 32rpx; height: 88rpx; }
.nav-left { display: flex; gap: 16rpx; align-items: center; }
.nav-back { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.nav-icon { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.nav-title { font-size: 34rpx; font-weight: 600; color: #fff; flex: 1; text-align: center; }
.nav-right-placeholder { width: 144rpx; }
.nav-btn { width: 64rpx; height: 64rpx; background: #1c1c1e; border-radius: 50%; display: flex; align-items: center; justify-content: center; }
.content { padding: 24rpx; width: 100%; box-sizing: border-box; }
/* ?????? */
.expiring-banner { display: flex; align-items: center; gap: 24rpx; padding: 24rpx; background: rgba(255,165,0,0.1); border: 2rpx solid rgba(255,165,0,0.3); border-radius: 24rpx; margin-bottom: 24rpx; }
.banner-icon { width: 80rpx; height: 80rpx; background: rgba(255,165,0,0.2); border-radius: 50%; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.icon-bell-warning { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(64%) sepia(89%) saturate(1363%) hue-rotate(4deg) brightness(101%) contrast(102%); }
.banner-content { flex: 1; }
.banner-title { font-size: 28rpx; font-weight: 500; color: #fff; display: block; }
.banner-desc { font-size: 24rpx; color: rgba(255,165,0,0.8); margin-top: 4rpx; display: block; }
/* ???? - ?? Next.js */
.earnings-card { position: relative; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 48rpx; margin-bottom: 24rpx; overflow: hidden; width: 100%; box-sizing: border-box; }
.earnings-bg { position: absolute; top: 0; right: 0; width: 256rpx; height: 256rpx; background: rgba(0,206,209,0.15); border-radius: 50%; filter: blur(100rpx); }
.earnings-main { position: relative; }
.earnings-header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 32rpx; }
.earnings-left { display: flex; align-items: center; gap: 16rpx; }
.wallet-icon { width: 80rpx; height: 80rpx; background: rgba(0,206,209,0.2); border-radius: 20rpx; display: flex; align-items: center; justify-content: center; }
.icon-wallet { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.earnings-info { display: flex; flex-direction: column; gap: 8rpx; }
.earnings-label { font-size: 24rpx; color: rgba(255,255,255,0.6); }
.commission-rate { font-size: 24rpx; color: #00CED1; font-weight: 500; }
.earnings-right { text-align: right; }
.earnings-value { font-size: 60rpx; font-weight: 700; color: #fff; display: block; line-height: 1; }
.pending-text { font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; display: block; }
.withdraw-btn { padding: 28rpx; background: linear-gradient(135deg, #00CED1 0%, #20B2AA 100%); color: #fff; font-size: 32rpx; font-weight: 600; text-align: center; border-radius: 24rpx; box-shadow: 0 8rpx 24rpx rgba(0,206,209,0.3); }
.withdraw-btn.btn-disabled { background: rgba(0,206,209,0.2); color: rgba(255,255,255,0.3); box-shadow: none; }
/* ???? - ?? Next.js 4??? */
.stats-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.stat-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 24rpx 12rpx; text-align: center; }
.stat-value { font-size: 40rpx; font-weight: 700; color: #fff; display: block; }
.stat-value.orange { color: #FFA500; }
.stat-label { font-size: 20rpx; color: rgba(255,255,255,0.6); margin-top: 8rpx; display: block; }
/* ????? */
.visit-stat { display: flex; align-items: center; justify-content: center; gap: 12rpx; padding: 20rpx 32rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; margin-bottom: 24rpx; }
.visit-label { font-size: 24rpx; color: rgba(255,255,255,0.5); }
.visit-value { font-size: 32rpx; font-weight: 700; color: #00CED1; }
.visit-tip { font-size: 24rpx; color: rgba(255,255,255,0.5); }
/* ???? - ?? Next.js */
.rules-card { background: rgba(0,206,209,0.05); border: 2rpx solid rgba(0,206,209,0.2); border-radius: 24rpx; padding: 32rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.rules-header { display: flex; align-items: center; gap: 16rpx; margin-bottom: 16rpx; }
.rules-icon { width: 64rpx; height: 64rpx; background: rgba(0,206,209,0.2); border-radius: 16rpx; display: flex; align-items: center; justify-content: center; }
.icon-alert { width: 32rpx; height: 32rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.rules-title { font-size: 28rpx; font-weight: 500; color: #fff; }
.rules-list { padding-left: 8rpx; }
.rule-item { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 2; display: block; margin-bottom: 4rpx; }
.rule-item .gold { color: #FFD700; font-weight: 500; }
.rule-item .brand { color: #00CED1; font-weight: 500; }
.rule-item .orange { color: #FFA500; font-weight: 500; }
/* ?????? - ?? Next.js */
.binding-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.binding-header { display: flex; align-items: center; justify-content: space-between; padding: 28rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.binding-title { display: flex; align-items: center; gap: 12rpx; }
.binding-icon-img { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.title-text { font-size: 30rpx; font-weight: 600; color: #fff; }
.binding-count { font-size: 26rpx; color: rgba(255,255,255,0.5); }
.toggle-icon { font-size: 24rpx; color: rgba(255,255,255,0.5); }
/* Tab?? */
.binding-tabs { display: flex; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.tab-item { flex: 1; padding: 24rpx 0; text-align: center; font-size: 26rpx; color: rgba(255,255,255,0.5); position: relative; }
.tab-item.tab-active { color: #00CED1; }
.tab-item.tab-active::after { content: ''; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); width: 80rpx; height: 4rpx; background: #00CED1; border-radius: 4rpx; }
/* ???? */
.binding-list { max-height: 640rpx; overflow-y: auto; }
.empty-state { padding: 80rpx 0; text-align: center; }
.empty-icon { font-size: 64rpx; display: block; margin-bottom: 16rpx; }
.empty-text { font-size: 26rpx; color: rgba(255,255,255,0.5); }
.binding-item { display: flex; align-items: center; padding: 24rpx 32rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.binding-item:last-child { border-bottom: none; }
.user-avatar { width: 80rpx; height: 80rpx; border-radius: 50%; background: rgba(0,206,209,0.2); display: flex; align-items: center; justify-content: center; font-size: 28rpx; font-weight: 600; color: #00CED1; margin-right: 24rpx; flex-shrink: 0; }
.user-avatar.avatar-converted { background: rgba(76,175,80,0.2); color: #4CAF50; }
.user-avatar.avatar-expired { background: rgba(158,158,158,0.2); color: #9E9E9E; }
.user-info { flex: 1; }
.user-name { font-size: 28rpx; color: #fff; font-weight: 500; display: block; }
.user-time { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; }
.user-status { text-align: right; }
.status-amount { font-size: 28rpx; color: #4CAF50; font-weight: 600; display: block; }
.status-order { font-size: 22rpx; color: rgba(255,255,255,0.5); }
.status-tag { font-size: 22rpx; padding: 8rpx 16rpx; border-radius: 16rpx; }
.status-tag.tag-green { background: rgba(76,175,80,0.2); color: #4CAF50; }
.status-tag.tag-orange { background: rgba(255,165,0,0.2); color: #FFA500; }
.status-tag.tag-red { background: rgba(244,67,54,0.2); color: #F44336; }
/* ????? - ?? Next.js */
.invite-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 40rpx; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.invite-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 24rpx; }
.invite-title { font-size: 30rpx; font-weight: 600; color: #fff; }
.invite-code-box { background: rgba(0,206,209,0.2); padding: 12rpx 24rpx; border-radius: 16rpx; }
.invite-code { font-size: 28rpx; font-weight: 600; color: #00CED1; font-family: 'Courier New', monospace; letter-spacing: 2rpx; }
.invite-tip { font-size: 24rpx; color: rgba(255,255,255,0.6); line-height: 1.5; display: block; }
.invite-tip .gold { color: #FFD700; }
.invite-tip .brand { color: #00CED1; }
/* ?????? - ?? Next.js */
.earnings-detail-card { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; overflow: hidden; margin-bottom: 24rpx; width: 100%; box-sizing: border-box; }
.detail-header { padding: 40rpx 40rpx 24rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.detail-title { font-size: 30rpx; font-weight: 600; color: #fff; }
.detail-list { max-height: 480rpx; overflow-y: auto; }
.detail-item { display: flex; align-items: center; justify-content: space-between; padding: 32rpx 40rpx; border-bottom: 2rpx solid rgba(255,255,255,0.05); }
.detail-item:last-child { border-bottom: none; }
.detail-left { display: flex; align-items: center; gap: 24rpx; flex: 1; }
.detail-icon { width: 80rpx; height: 80rpx; border-radius: 20rpx; background: rgba(0,206,209,0.2); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.icon-gift { width: 40rpx; height: 40rpx; display: block; filter: brightness(0) saturate(100%) invert(72%) sepia(54%) saturate(2933%) hue-rotate(134deg) brightness(101%) contrast(101%); }
.detail-info { flex: 1; }
.detail-type { font-size: 28rpx; color: #fff; font-weight: 500; display: block; }
.detail-time { font-size: 24rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; }
.detail-amount { font-size: 30rpx; font-weight: 600; color: #00CED1; }
/* ???? - ?? Next.js */
.share-section { display: flex; flex-direction: column; gap: 12rpx; width: 100%; margin-bottom: 24rpx; }
.share-item { display: flex; align-items: center; background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 24rpx; padding: 32rpx; border: none; margin: 0; text-align: left; width: 100%; box-sizing: border-box; }
.share-item::after { border: none; }
.share-icon { width: 96rpx; height: 96rpx; border-radius: 20rpx; display: flex; align-items: center; justify-content: center; margin-right: 24rpx; flex-shrink: 0; }
.share-icon.poster { background: rgba(103,58,183,0.2); }
.share-icon.wechat { background: rgba(7,193,96,0.2); }
.share-icon.link { background: rgba(158,158,158,0.2); }
.icon-share-btn { width: 48rpx; height: 48rpx; display: block; }
.share-icon.poster .icon-share-btn { filter: brightness(0) saturate(100%) invert(37%) sepia(73%) saturate(2296%) hue-rotate(252deg) brightness(96%) contrast(92%); }
.share-icon.wechat .icon-share-btn { filter: brightness(0) saturate(100%) invert(58%) sepia(91%) saturate(1255%) hue-rotate(105deg) brightness(96%) contrast(97%); }
.share-icon.link .icon-share-btn { filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.share-info { flex: 1; text-align: left; }
.share-title { font-size: 28rpx; color: #fff; font-weight: 500; display: block; text-align: left; }
.share-desc { font-size: 22rpx; color: rgba(255,255,255,0.5); margin-top: 4rpx; display: block; text-align: left; }
.share-arrow-icon { width: 40rpx; height: 40rpx; display: block; flex-shrink: 0; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.share-btn-wechat { line-height: normal; font-size: inherit; padding: 24rpx 32rpx !important; margin: 0 !important; width: 100% !important; }
/* ?????????????? + ???? + ???????? */
/* ???????? backdrop-filter??????????????? */
.modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.8); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 32rpx; box-sizing: border-box; }
.poster-dialog { width: 686rpx; border-radius: 24rpx; overflow: hidden; position: relative; background: transparent; }
.poster-close { position: absolute; top: 20rpx; right: 20rpx; width: 56rpx; height: 56rpx; border-radius: 28rpx; background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); display: flex; align-items: center; justify-content: center; z-index: 5; font-size: 28rpx; }
/* ???? */
.poster-card { position: relative; background: linear-gradient(135deg, #0a1628 0%, #0f2137 50%, #1a3a5c 100%); color: #fff; padding: 44rpx 40rpx 36rpx; }
.poster-inner { position: relative; z-index: 2; display: flex; flex-direction: column; align-items: center; text-align: center; }
/* ???? */
/* ???????? filter: blur ??????????????? + ???? */
.poster-glow { position: absolute; width: 320rpx; height: 320rpx; border-radius: 50%; opacity: 0.6; z-index: 1; }
.poster-glow-left { top: -120rpx; left: -160rpx; background: rgba(0,206,209,0.12); box-shadow: 0 0 140rpx 40rpx rgba(0,206,209,0.18); }
.poster-glow-right { bottom: -140rpx; right: -160rpx; background: rgba(255,215,0,0.10); box-shadow: 0 0 160rpx 50rpx rgba(255,215,0,0.14); }
.poster-ring { position: absolute; width: 520rpx; height: 520rpx; border-radius: 50%; border: 2rpx solid rgba(0,206,209,0.06); left: 50%; top: 50%; transform: translate(-50%, -50%); z-index: 1; }
/* ???? */
.poster-badges { display: flex; gap: 16rpx; margin-bottom: 24rpx; }
.poster-badge { padding: 10rpx 22rpx; font-size: 20rpx; font-weight: 700; border-radius: 999rpx; border: 2rpx solid transparent; }
.poster-badge-gold { background: rgba(255,215,0,0.18); color: #FFD700; border-color: rgba(255,215,0,0.28); }
.poster-badge-brand { background: rgba(0,206,209,0.18); color: #00CED1; border-color: rgba(0,206,209,0.28); }
/* ?? */
.poster-title { margin-bottom: 8rpx; }
.poster-title-line1 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #00CED1; }
.poster-title-line2 { display: block; font-size: 44rpx; font-weight: 900; line-height: 1.1; color: #fff; margin-top: 6rpx; }
.poster-subtitle { display: block; font-size: 22rpx; color: rgba(255,255,255,0.6); margin-bottom: 28rpx; }
/* ???? */
.poster-stats { width: 100%; display: flex; gap: 16rpx; justify-content: center; margin-bottom: 24rpx; }
.poster-stat { flex: 1; max-width: 190rpx; background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 16rpx; padding: 18rpx 10rpx; }
.poster-stat-value { display: block; font-size: 44rpx; font-weight: 900; line-height: 1; }
.poster-stat-label { display: block; font-size: 20rpx; color: rgba(255,255,255,0.5); margin-top: 8rpx; }
.poster-stat-gold { color: #FFD700; }
.poster-stat-brand { color: #00CED1; }
.poster-stat-pink { color: #E91E63; }
/* ?? */
.poster-tags { display: flex; flex-wrap: wrap; justify-content: center; gap: 10rpx; margin: 0 24rpx 26rpx; }
.poster-tag { font-size: 20rpx; color: rgba(255,255,255,0.7); background: rgba(255,255,255,0.05); border: 2rpx solid rgba(255,255,255,0.10); border-radius: 12rpx; padding: 6rpx 14rpx; }
/* ??? */
.poster-recommender { display: flex; align-items: center; gap: 12rpx; background: rgba(0,206,209,0.10); border: 2rpx solid rgba(0,206,209,0.20); border-radius: 999rpx; padding: 12rpx 22rpx; margin-bottom: 22rpx; }
.poster-avatar { width: 44rpx; height: 44rpx; border-radius: 22rpx; background: rgba(0,206,209,0.30); display: flex; align-items: center; justify-content: center; }
.poster-avatar-text { font-size: 20rpx; font-weight: 800; color: #00CED1; }
.poster-recommender-text { font-size: 22rpx; color: #00CED1; }
/* ??? */
.poster-discount { width: 100%; background: linear-gradient(90deg, rgba(255,215,0,0.10) 0%, rgba(233,30,99,0.10) 100%); border: 2rpx solid rgba(255,215,0,0.20); border-radius: 18rpx; padding: 18rpx 18rpx; margin-bottom: 26rpx; }
.poster-discount-text { font-size: 22rpx; color: rgba(255,255,255,0.80); }
.poster-discount-highlight { color: #00CED1; font-weight: 800; }
/* ??? */
.poster-qr-wrap { background: #fff; padding: 14rpx; border-radius: 16rpx; margin-bottom: 12rpx; box-shadow: 0 16rpx 40rpx rgba(0,0,0,0.35); }
.poster-qr-img { width: 240rpx; height: 240rpx; display: block; }
.poster-qr-tip { font-size: 20rpx; color: rgba(255,255,255,0.40); margin-bottom: 8rpx; }
.poster-code { font-size: 22rpx; font-family: monospace; letter-spacing: 2rpx; color: rgba(0,206,209,0.80); }
/* ??????? */
.poster-footer { background: #fff; padding: 28rpx 28rpx 32rpx; display: flex; flex-direction: column; gap: 18rpx; }
.poster-footer-tip { font-size: 22rpx; color: rgba(0,0,0,0.55); text-align: center; }
.poster-footer-btn { height: 72rpx; border-radius: 18rpx; border: 2rpx solid rgba(0,0,0,0.15); display: flex; align-items: center; justify-content: center; font-size: 28rpx; color: rgba(0,0,0,0.75); background: #fff; }
/* ????- ?? Next.js */
.empty-earnings { background: rgba(28, 28, 30, 0.8); backdrop-filter: blur(40rpx); border: 2rpx solid rgba(255,255,255,0.1); border-radius: 32rpx; padding: 64rpx 40rpx; text-align: center; margin-bottom: 24rpx; }
.empty-icon-wrapper { width: 128rpx; height: 128rpx; border-radius: 50%; background: rgba(28, 28, 30, 0.8); display: flex; align-items: center; justify-content: center; margin: 0 auto 32rpx; }
.empty-gift-icon { width: 64rpx; height: 64rpx; display: block; filter: brightness(0) saturate(100%) invert(60%) sepia(0%) saturate(0%) hue-rotate(0deg) brightness(95%) contrast(85%); }
.empty-title { font-size: 30rpx; font-weight: 500; color: #fff; display: block; margin-bottom: 16rpx; }
.empty-desc { font-size: 26rpx; color: rgba(255,255,255,0.6); display: block; line-height: 1.5; }
/* ===== T<><54>rGm<18>5<EFBFBD><35> ?===== */
.loading-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
backdrop-filter: blur(10rpx);
z-index: 999;
display: flex;
align-items: center;
justify-content: center;
}
.loading-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 24rpx;
}
.loading-spinner {
width: 80rpx;
height: 80rpx;
border: 6rpx solid rgba(56, 189, 172, 0.2);
border-top-color: #38bdac;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 28rpx;
color: rgba(255, 255, 255, 0.8);
font-weight: 500;
}
.content-loading {
opacity: 0.3;
pointer-events: none;
}
/* ===== <00><>AR<41><6C>^<5E>|<7C>o<EFBFBD>p<EFBFBD><>\!} ===== */
.detail-item {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx;
background: rgba(255, 255, 255, 0.02);
border-radius: 16rpx;
margin-bottom: 16rpx;
transition: all 0.3s;
}
.detail-item:active {
background: rgba(255, 255, 255, 0.05);
}
.detail-avatar-wrap {
flex-shrink: 0;
}
.detail-avatar {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
border: 2rpx solid rgba(56, 189, 172, 0.2);
}
.detail-avatar-text {
width: 88rpx;
height: 88rpx;
border-radius: 50%;
background: linear-gradient(135deg, #38bdac 0%, #2da396 100%);

View File

@@ -1,211 +0,0 @@
// miniprogram/utils/payment.js
// 微信支付工具类
const app = getApp()
/**
* 发起微信支付
* @param {Object} options - 支付选项
* @param {String} options.orderId - 订单ID
* @param {Number} options.amount - 支付金额(元)
* @param {String} options.description - 商品描述
* @param {Function} options.success - 成功回调
* @param {Function} options.fail - 失败回调
*/
function wxPay(options) {
const { orderId, amount, description, success, fail } = options
wx.showLoading({
title: '正在支付...',
mask: true
})
// 1. 调用后端创建支付订单
wx.request({
url: `${app.globalData.apiBase}/payment/create`,
method: 'POST',
header: {
'Authorization': `Bearer ${wx.getStorageSync('token')}`
},
data: {
orderId,
amount,
description,
paymentMethod: 'wechat'
},
success: (res) => {
wx.hideLoading()
if (res.statusCode === 200) {
const paymentData = res.data
// 2. 调起微信支付
wx.requestPayment({
timeStamp: paymentData.timeStamp,
nonceStr: paymentData.nonceStr,
package: paymentData.package,
signType: paymentData.signType || 'RSA',
paySign: paymentData.paySign,
success: (payRes) => {
console.log('支付成功', payRes)
// 3. 通知后端支付成功
notifyPaymentSuccess(orderId, paymentData.prepayId)
wx.showToast({
title: '支付成功',
icon: 'success',
duration: 2000
})
success && success(payRes)
},
fail: (payErr) => {
console.error('支付失败', payErr)
if (payErr.errMsg.indexOf('cancel') !== -1) {
wx.showToast({
title: '支付已取消',
icon: 'none'
})
} else {
wx.showToast({
title: '支付失败',
icon: 'none'
})
}
fail && fail(payErr)
}
})
} else {
wx.showToast({
title: res.data.message || '创建订单失败',
icon: 'none'
})
fail && fail(res)
}
},
fail: (err) => {
wx.hideLoading()
console.error('请求失败', err)
wx.showToast({
title: '网络请求失败',
icon: 'none'
})
fail && fail(err)
}
})
}
/**
* 通知后端支付成功
* @param {String} orderId
* @param {String} prepayId
*/
function notifyPaymentSuccess(orderId, prepayId) {
wx.request({
url: `${app.globalData.apiBase}/payment/notify`,
method: 'POST',
header: {
'Authorization': `Bearer ${wx.getStorageSync('token')}`
},
data: {
orderId,
prepayId,
status: 'success'
},
success: (res) => {
console.log('支付通知成功', res)
},
fail: (err) => {
console.error('支付通知失败', err)
}
})
}
/**
* 查询订单状态
* @param {String} orderId
* @param {Function} callback
*/
function queryOrderStatus(orderId, callback) {
wx.request({
url: `${app.globalData.apiBase}/payment/query`,
method: 'GET',
header: {
'Authorization': `Bearer ${wx.getStorageSync('token')}`
},
data: { orderId },
success: (res) => {
if (res.statusCode === 200) {
callback && callback(true, res.data)
} else {
callback && callback(false, null)
}
},
fail: () => {
callback && callback(false, null)
}
})
}
/**
* 购买完整电子书
* @param {Function} success
* @param {Function} fail
*/
function purchaseFullBook(success, fail) {
// 计算动态价格9.9 + (天数 * 1元)
const basePrice = 9.9
const startDate = new Date('2025-01-01') // 书籍上架日期
const today = new Date()
const daysPassed = Math.floor((today - startDate) / (1000 * 60 * 60 * 24))
const currentPrice = basePrice + daysPassed
const orderId = `ORDER_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
wxPay({
orderId,
amount: currentPrice,
description: 'Soul派对·创业实验 完整版',
success: (res) => {
// 更新本地购买状态
updatePurchaseStatus(true)
success && success(res)
},
fail
})
}
/**
* 更新购买状态
* @param {Boolean} isPurchased
*/
function updatePurchaseStatus(isPurchased) {
const userInfo = app.getUserInfo()
if (userInfo) {
userInfo.isPurchased = isPurchased
wx.setStorageSync('userInfo', userInfo)
app.globalData.userInfo = userInfo
}
}
/**
* 检查是否已购买
* @returns {Boolean}
*/
function checkPurchaseStatus() {
const userInfo = app.getUserInfo()
return userInfo ? userInfo.isPurchased : false
}
module.exports = {
wxPay,
queryOrderStatus,
purchaseFullBook,
checkPurchaseStatus,
updatePurchaseStatus
}

View File

@@ -0,0 +1,121 @@
# -*- coding: utf-8 -*-
"""
文件上传测试。POST /api/upload 上传图片DELETE /api/upload 删除。
验证本地存储OSS 未配置时)、响应格式、删除流程。
"""
import io
import pytest
import requests
# 最小有效 JPEG1x1 像素,约 100 字节)
_MIN_JPEG = (
b"\xff\xd8\xff\xe0\x00\x10JFIF\x00\x01\x01\x00\x00\x01\x00\x01\x00\x00"
b"\xff\xdb\x00C\x00\x08\x06\x06\x07\x06\x05\x08\x07\x07\x07\t\t\x08\n\x0c\x14\r\x0c\x0b\x0b\x0c\x19\x12\x13\x0f\x14\x1d\x1a\x1f\x1e\x1d\x1a\x1c\x1c"
b" $.' \",#\x1c\x1c(7),01444\x1f'9=82<.342\xff\xc0\x00\x0b\x08\x00\x01\x00\x01\x01\x01\x11\x00\xff\xc4\x00\x1f\x00\x00\x01\x05\x01\x01\x01\x01\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x01\x02\x03\x04\x05\x06\x07\x08\t\n\x0b\xff\xda\x00\x08\x01\x01\x00\x00\x00?\x00\xfe\x02\x1f\xff\xd9"
)
def test_upload_post_success(base_url):
"""POST /api/upload 上传图片成功,返回 url、fileName、size、type"""
files = {"file": ("test_upload.jpg", io.BytesIO(_MIN_JPEG), "image/jpeg")}
data = {"folder": "test"}
r = requests.post(
f"{base_url}/api/upload",
files=files,
data=data,
timeout=10,
)
assert r.status_code == 200, f"期望 200实际 {r.status_code}: {r.text}"
body = r.json()
assert body.get("success") is True, body
assert "url" in body, body
assert body["url"], "url 不应为空"
data_out = body.get("data", {})
assert "url" in data_out
assert "fileName" in data_out
assert "size" in data_out
assert data_out["size"] == len(_MIN_JPEG)
assert "type" in data_out
assert "image" in str(data_out.get("type", "")).lower()
def test_upload_post_with_admin_token(base_url, admin_token):
"""POST /api/upload 带管理端 token 也可上传(接口不强制鉴权)"""
if not admin_token:
pytest.skip("admin 登录失败")
files = {"file": ("avatar.jpg", io.BytesIO(_MIN_JPEG), "image/jpeg")}
# multipart 上传不设 Content-Type让 requests 自动带 boundary
headers = {"Authorization": f"Bearer {admin_token}"}
r = requests.post(
f"{base_url}/api/upload",
files=files,
headers=headers,
timeout=10,
)
assert r.status_code == 200
body = r.json()
assert body.get("success") is True
assert body.get("url")
def test_upload_post_no_file(base_url):
"""POST /api/upload 无 file 返回 400"""
r = requests.post(
f"{base_url}/api/upload",
data={"folder": "test"},
timeout=10,
)
assert r.status_code == 400
body = r.json()
assert body.get("success") is False
assert "error" in body or "请选择" in body.get("error", "")
def test_upload_post_invalid_type(base_url):
"""POST /api/upload 非图片格式返回 400"""
files = {"file": ("test.txt", io.BytesIO(b"hello"), "text/plain")}
r = requests.post(
f"{base_url}/api/upload",
files=files,
timeout=10,
)
assert r.status_code == 400
body = r.json()
assert body.get("success") is False
def test_upload_delete_local(base_url):
"""DELETE /api/upload 删除本地文件:先上传再删除"""
# 1. 上传
files = {"file": ("del_test.jpg", io.BytesIO(_MIN_JPEG), "image/jpeg")}
r1 = requests.post(
f"{base_url}/api/upload",
files=files,
data={"folder": "test"},
timeout=10,
)
assert r1.status_code == 200
url = r1.json().get("url")
assert url, "上传应返回 url"
# path 支持 /uploads/xxx 或含 /uploads/ 的完整 URL
path = url
# 2. 删除
r2 = requests.delete(
f"{base_url}/api/upload",
params={"path": path},
timeout=10,
)
assert r2.status_code == 200
body = r2.json()
assert body.get("success") is True
assert "删除成功" in body.get("message", "")
def test_upload_delete_no_path(base_url):
"""DELETE /api/upload 无 path 返回 400"""
r = requests.delete(f"{base_url}/api/upload", timeout=10)
assert r.status_code == 400
body = r.json()
assert body.get("success") is False

View File

@@ -0,0 +1,155 @@
# Soul 创业派对 - 功能测试流程
> 测试工程师产出。按流程执行,成功项打 ☑️,失败项列出问题,最后输出报告。
---
## 一、测试流程总览
```
环境准备 → 自动化用例执行 → 手工功能验证 → 问题汇总 → 输出报告
```
---
## 二、环境准备
| 步骤 | 内容 | 结果 |
|------|------|------|
| 1 | soul-api 已启动local 时)或测试服已部署 | ☐ / 问题_____ |
| 2 | 配置 `.env.test`SOUL_TEST_ENV 或 SOUL_API_BASE | ☐ / 问题_____ |
| 3 | 管理端可访问,默认账号 admin/admin123 可登录 | ☐ / 问题_____ |
| 4 | 小程序可访问(真机或开发者工具) | ☐ / 问题_____ |
**环境信息记录**
- 执行日期__________
- 测试环境local / souldev / soulapi
- API 地址__________
---
## 三、自动化用例执行
`scripts/test` 下执行:
```bash
cd scripts/test
$env:SOUL_TEST_ENV="local" # 或 souldev
pytest -v
```
### 3.1 小程序接口miniapp
| 用例 | 验证点 | 结果 | 问题(若失败) |
|------|--------|------|----------------|
| test_config_public | GET /api/miniprogram/config 返回 prices、features、mpConfig | ☐ | |
| test_dev_login_as | POST /api/miniprogram/dev/login-as 按 userId 登录 | ☐ | |
### 3.2 管理端接口web
| 用例 | 验证点 | 结果 | 问题(若失败) |
|------|--------|------|----------------|
| test_admin_login | POST /api/admin 登录成功 | ☐ | |
| test_admin_check_with_token | GET /api/admin 带 token 鉴权通过 | ☐ | |
| test_admin_check_without_token | GET /api/admin 无 token 返回 401 | ☐ | |
### 3.3 流程测试process
| 用例 | 验证点 | 结果 | 问题(若失败) |
|------|--------|------|----------------|
| test_health | GET /health 返回 200 | ☐ | |
| test_person_ensure_* | @某人 创建 Person + 存客宝计划 | ☐ | |
| test_article_mention_* | 文章保存自动创建 Person | ☐ | |
| test_backfill_persons_ckb_api_key | 从存客宝补齐 ckb_api_key | ☐ | |
---
## 四、手工功能验证
### 4.1 小程序端
| 场景 | 验证点 | 结果 | 问题(若失败) |
|------|--------|------|----------------|
| 登录 | 微信登录、手机号授权、token 持久化 | ☐ | |
| 首页 | 书籍列表、搜索栏searchEnabled 控制) | ☐ | |
| 目录 | 章节列表、购买状态展示 | ☐ | |
| 阅读 | 阅读页、购买章节/全书、支付流程 | ☐ | |
| 我的 | 找伙伴、推广、搜索入口featureConfig 控制) | ☐ | |
| VIP | 开通、资料填写、头像上传、保存 | ☐ | |
| 推荐 | 扫码/分享带 ref、绑定、分润展示 | ☐ | |
### 4.2 管理端
| 场景 | 验证点 | 结果 | 问题(若失败) |
|------|--------|------|----------------|
| 登录 | 账号密码登录、token 持久化 | ☐ | |
| 仪表盘 | 数据统计展示 | ☐ | |
| 用户管理 | 列表、搜索、分页、详情、行为轨迹 Tab | ☐ | |
| 订单管理 | 列表、状态、筛选 | ☐ | |
| 内容管理 | 书籍/章节 CRUD | ☐ | |
| 提现 | 申请列表、审核、状态流转 | ☐ | |
| 提现测试接口 | GIN_MODE=release 时返回 404 | ☐ | |
### 4.3 接口与数据流
| 场景 | 验证点 | 结果 | 问题(若失败) |
|------|--------|------|----------------|
| 路径隔离 | 小程序只调 /api/miniprogram/* | ☐ | |
| 鉴权 | 需登录接口带 token401 正确跳转 | ☐ | |
| 支付回调 | 下单→支付→回调→分润 | ☐ | |
---
## 五、问题汇总(失败项)
将上述所有**未打 ☑️** 的项在此列出:
| 序号 | 模块 | 场景/用例 | 问题描述 | 严重程度 |
|------|------|-----------|----------|----------|
| 1 | | | | 高/中/低 |
| 2 | | | | |
| … | | | | |
---
## 六、测试报告(最终输出)
### 6.1 执行摘要
| 项目 | 内容 |
|------|------|
| 执行日期 | |
| 测试环境 | |
| 执行人 | |
### 6.2 结果统计
| 类型 | 总数 | 通过 ☑️ | 失败 | 通过率 |
|------|------|---------|------|--------|
| 自动化用例 | 12 | | | |
| 手工-小程序 | 7 | | | |
| 手工-管理端 | 7 | | | |
| 手工-接口 | 3 | | | |
| **合计** | **29** | | | |
### 6.3 结论
- [ ] **通过**:所有关键项 ☑️,无高/中严重问题
- [ ] **有条件通过**:存在低严重问题,可上线但需跟进
- [ ] **不通过**:存在高/中严重问题,需修复后复测
### 6.4 问题清单(若有)
| 序号 | 问题 | 严重程度 | 建议 |
|------|------|----------|------|
| 1 | | | |
| 2 | | | |
---
## 七、使用说明
1. **执行前**:复制本文档或新建一份,填写环境信息。
2. **执行中**:逐项验证,成功打 ☑️,失败在「问题」列填写。
3. **执行后**:汇总失败项到「五、问题汇总」,填写「六、测试报告」。
4. **归档**:报告保存为 `scripts/test/测试报告-YYYY-MM-DD.md`,便于追溯。

View File

@@ -0,0 +1,102 @@
# Soul 创业派对 - 测试报告(环境与用例清单)
> 测试人员产出。环境就绪后执行 `pytest -v` 并填写结果。
---
## 一、测试环境
| 环境 | API 地址 | 说明 |
|------|----------|------|
| **local** | http://localhost:8080 | 本地开发,需先启动 soul-api |
| **souldev** | https://souldev.quwanzhi.com | 测试服,需已部署 |
| **soulapi** | https://soulapi.quwanzhi.com | 正式服,慎用 |
**配置方式**
- 复制 `scripts/test/.env.test.example``.env.test`
- 设置 `SOUL_TEST_ENV=local|souldev|soulapi``SOUL_API_BASE=<地址>`
**运行命令**
```bash
cd scripts/test
# Windows PowerShell
$env:SOUL_TEST_ENV="local" # 或 souldev
pytest -v
```
---
## 二、用例清单(共 18 个)
### 2.1 小程序接口miniapp/
| 用例 | 验证点 | 前置条件 |
|------|--------|----------|
| **test_config_public** | GET /api/miniprogram/config 返回 200含 prices、features、mpConfig | 无 |
| **test_dev_login_as** | POST /api/miniprogram/dev/login-as 按 userId 登录成功 | SOUL_MINIPROGRAM_DEV_USER_ID 已配置APP_ENV=development |
### 2.2 管理端接口web/
| 用例 | 验证点 | 前置条件 |
|------|--------|----------|
| **test_admin_login** | POST /api/admin 登录成功,返回 token、user | 无 |
| **test_admin_check_with_token** | GET /api/admin 带 token 鉴权通过 | admin 登录成功 |
| **test_admin_check_without_token** | GET /api/admin 无 token 返回 401 | 无 |
| **test_upload_post_success** | POST /api/upload 上传图片成功,返回 url、data | 无 |
| **test_upload_post_with_admin_token** | POST /api/upload 带 token 可上传 | admin 登录 |
| **test_upload_post_no_file** | POST /api/upload 无 file 返回 400 | 无 |
| **test_upload_post_invalid_type** | POST /api/upload 非图片格式返回 400 | 无 |
| **test_upload_delete_local** | DELETE /api/upload 删除本地文件(先上传再删) | 无 |
| **test_upload_delete_no_path** | DELETE /api/upload 无 path 返回 400 | 无 |
### 2.3 流程测试process/
| 用例 | 验证点 | 前置条件 |
|------|--------|----------|
| **test_health** | GET /health 返回 200 | soul-api 已启动 |
| **test_person_ensure_creates_ckb_plan_when_not_exists** | @某人 不存在时 POST /api/db/persons 创建 Person + 存客宝计划 | admin 登录CKB 配置正确 |
| **test_person_ensure_returns_existing_when_name_exists** | 相同 name 返回已有 Person不重复创建 | 同上 |
| **test_person_ensure_rejects_empty_name** | 空 name 被拒绝 | 同上 |
| **test_article_mention_flow_persons_list_contains_new** | 文章保存后 persons 列表含新人物 | 同上 |
| **test_new_article_save_auto_creates_person_and_ckb** | 新文章保存自动创建 Person 与存客宝计划 | 同上 |
| **test_backfill_persons_ckb_api_key** | 从存客宝补齐 persons.ckb_api_key | admin 登录CKB 配置persons 有 ckb_plan_id |
---
## 三、源码质量优化相关回归点(手工验证)
以下为 2026-03-17 源码质量优化后的建议回归项,自动化用例未覆盖:
| 场景 | 验证点 |
|------|--------|
| **支付流程** | read 页购买章节/全书 → 微信支付 → 回调更新购买状态 |
| **管理端用户详情** | 用户列表点击详情 → 行为轨迹 Tab 展示(调用 /api/admin/user/track |
| **我的页** | 找伙伴、推广、搜索入口根据 featureConfig 显示/隐藏 |
| **首页/目录** | 搜索栏根据 searchEnabled 显示/隐藏 |
| **提现测试接口** | GIN_MODE=release 时 GET/POST /api/admin/withdraw-test 返回 404 |
---
## 四、测试结果记录
| 执行日期 | 环境 | 通过 | 失败 | 跳过 | 备注 |
|----------|------|------|------|------|------|
| 2026-03-17 | local | 16 | 0 | 2 | 含新增文件上传 6 用例2 跳过dev_login、backfill 需配置) |
| (待填写) | local / souldev | - | - | - | - |
### 失败用例详情(若有)
| 用例 | 错误信息 | 处理 |
|------|----------|------|
| - | - | - |
---
## 五、环境就绪检查
执行测试前请确认:
- [ ] soul-api 已启动local 时 `go run cmd/server/main.go``scripts/本地启动.sh`
- [ ] 测试环境变量已设置SOUL_TEST_ENV 或 SOUL_API_BASE
- [ ] 管理端账号可登录(默认 admin/admin123
- [ ] 流程测试需 CKB 配置时soul-api/.env 已配置 CKB_OPEN_API_KEY、CKB_OPEN_ACCOUNT

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>管理后台 - Soul创业派对</title>
<script type="module" crossorigin src="/assets/index-BC7lp6bO.js"></script>
<script type="module" crossorigin src="/assets/index-D5RkA1Qc.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BcWuvM_a.css">
</head>
<body>

View File

@@ -162,6 +162,12 @@ const LinkTagExtension = Node.create({
})
// eslint-disable-next-line @typescript-eslint/no-explicit-any
function escapeHtml(s: string): string {
const div = document.createElement('div')
div.textContent = s
return div.innerHTML
}
const MentionSuggestion = (persons: PersonItem[]): any => ({
items: ({ query }: { query: string }) =>
persons.filter(p => p.name.toLowerCase().includes(query.toLowerCase()) || p.id.includes(query)).slice(0, 8),
@@ -176,8 +182,8 @@ const MentionSuggestion = (persons: PersonItem[]): any => ({
if (!popup) return
popup.innerHTML = items.map((item, i) =>
`<div class="mention-item ${i === selectedIndex ? 'is-selected' : ''}" data-index="${i}">
<span class="mention-name">@${item.name}</span>
<span class="mention-id">${item.label || item.id}</span>
<span class="mention-name">@${escapeHtml(item.name)}</span>
<span class="mention-id">${escapeHtml(item.label || item.id)}</span>
</div>`
).join('')
popup.querySelectorAll('.mention-item').forEach(el => {

View File

@@ -189,7 +189,7 @@ export function UserDetailModal({
// 行为轨迹(用户旅程)
try {
const trackData = await get<{ success?: boolean; tracks?: UserTrack[] }>(
`/api/user/track?userId=${encodeURIComponent(userId)}&limit=50`,
`/api/admin/user/track?userId=${encodeURIComponent(userId)}&limit=50`,
)
if (trackData?.success && trackData.tracks) setTracks(trackData.tracks)
} catch { setTracks([]) }

View File

@@ -16,6 +16,8 @@ require (
gorm.io/gorm v1.25.12
)
require github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible // indirect
require (
github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect

View File

@@ -2,6 +2,8 @@ github.com/ArtisanCloud/PowerLibs/v3 v3.3.2 h1:IInr1YWwkhwOykxDqux1Goym0uFhrYwBj
github.com/ArtisanCloud/PowerLibs/v3 v3.3.2/go.mod h1:xFGsskCnzAu+6rFEJbGVAlwhrwZPXAny6m7j71S/B5k=
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38 h1:yu4A7WhPXfs/RSYFL2UdHFRQYAXbrpiBOT3kJ5hjepU=
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38/go.mod h1:boWl2cwbgXt1AbrYTWMXs9Ebby6ecbJ1CyNVRaNVqUY=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible h1:8psS8a+wKfiLt1iVDX79F7Y6wUM49Lcha2FMXt4UM8g=
github.com/aliyun/aliyun-oss-go-sdk v3.0.2+incompatible/go.mod h1:T/Aws4fEfogEE9v+HPhhw+CntffsBHJ8nXQCwKr0/g8=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=

171
soul-api/internal/cache/cache.go vendored Normal file
View File

@@ -0,0 +1,171 @@
package cache
import (
"context"
"encoding/json"
"fmt"
"log"
"time"
"soul-api/internal/database"
"soul-api/internal/model"
"soul-api/internal/redis"
)
const defaultTimeout = 2 * time.Second
// KeyBookParts 目录接口缓存 key后台更新章节/内容时需 Del
const KeyBookParts = "soul:book:parts"
// KeyBookHot 热门章节,格式 soul:book:hot:{limit}
func KeyBookHot(limit int) string { return "soul:book:hot:" + fmt.Sprint(limit) }
const KeyBookRecommended = "soul:book:recommended"
const KeyBookStats = "soul:book:stats"
const KeyConfigMiniprogram = "soul:config:miniprogram"
// Get 从 Redis 读取,未配置或失败返回 nil调用方回退 DB
func Get(ctx context.Context, key string, dest interface{}) bool {
client := redis.Client()
if client == nil {
return false
}
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
val, err := client.Get(ctx, key).Bytes()
if err != nil {
return false
}
if dest != nil && len(val) > 0 {
_ = json.Unmarshal(val, dest)
}
return true
}
// Set 写入 Redis失败仅打日志不阻塞
func Set(ctx context.Context, key string, val interface{}, ttl time.Duration) {
client := redis.Client()
if client == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
data, err := json.Marshal(val)
if err != nil {
log.Printf("cache.Set marshal %s: %v", key, err)
return
}
if err := client.Set(ctx, key, data, ttl).Err(); err != nil {
log.Printf("cache.Set %s: %v (非致命)", key, err)
}
}
// Del 删除 key失败仅打日志
func Del(ctx context.Context, key string) {
client := redis.Client()
if client == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
if err := client.Del(ctx, key).Err(); err != nil {
log.Printf("cache.Del %s: %v (非致命)", key, err)
}
}
// BookPartsTTL 目录接口缓存 TTL后台更新时主动 Del此为兜底时长
const BookPartsTTL = 10 * time.Minute
// InvalidateBookParts 后台更新章节/内容时调用,使目录接口缓存失效
func InvalidateBookParts() {
Del(context.Background(), KeyBookParts)
}
// InvalidateBookCache 使热门、推荐、统计等书籍相关缓存失效(与 InvalidateBookParts 同时调用)
func InvalidateBookCache() {
ctx := context.Background()
Del(ctx, KeyBookRecommended)
Del(ctx, KeyBookStats)
for _, limit := range []int{3, 10, 20, 50} {
Del(ctx, KeyBookHot(limit))
}
}
// InvalidateConfig 配置变更时调用,使小程序 config 缓存失效
func InvalidateConfig() {
Del(context.Background(), KeyConfigMiniprogram)
}
// BookRelatedTTL 书籍相关接口 TTLhot/recommended/stats
const BookRelatedTTL = 5 * time.Minute
// ConfigTTL 配置接口 TTL
const ConfigTTL = 10 * time.Minute
// KeyChapterContent 章节正文缓存,格式 soul:chapter:content:{mid},存原始 HTML 字符串
func KeyChapterContent(mid int) string { return "soul:chapter:content:" + fmt.Sprint(mid) }
// ChapterContentTTL 章节正文 TTL后台更新时主动 Del
const ChapterContentTTL = 30 * time.Minute
// GetString 读取字符串(不经过 JSON适合大文本 content
func GetString(ctx context.Context, key string) (string, bool) {
client := redis.Client()
if client == nil {
return "", false
}
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
val, err := client.Get(ctx, key).Result()
if err != nil {
return "", false
}
return val, true
}
// SetString 写入字符串(不经过 JSON适合大文本 content
func SetString(ctx context.Context, key string, val string, ttl time.Duration) {
client := redis.Client()
if client == nil {
return
}
if ctx == nil {
ctx = context.Background()
}
ctx, cancel := context.WithTimeout(ctx, defaultTimeout)
defer cancel()
if err := client.Set(ctx, key, val, ttl).Err(); err != nil {
log.Printf("cache.SetString %s: %v (非致命)", key, err)
}
}
// InvalidateChapterContent 章节内容更新时调用mid<=0 时忽略
func InvalidateChapterContent(mid int) {
if mid <= 0 {
return
}
Del(context.Background(), KeyChapterContent(mid))
}
// InvalidateChapterContentByID 按业务 id 使章节内容缓存失效(内部查 mid 后调用 InvalidateChapterContent
func InvalidateChapterContentByID(id string) {
if id == "" {
return
}
var mid int
if err := database.DB().Model(&model.Chapter{}).Where("id = ?", id).Pluck("mid", &mid).Error; err != nil || mid <= 0 {
return
}
InvalidateChapterContent(mid)
}

View File

@@ -256,7 +256,7 @@ func Load() (*Config, error) {
// RedisREDIS_URL 配置后启用;不配置则跳过。本地开发可设 REDIS_URL=redis://localhost:6379/0
redisURL := strings.TrimSpace(os.Getenv("REDIS_URL"))
return &Config{
cfg := &Config{
Port: port,
Mode: mode,
DBDSN: dsn,
@@ -284,7 +284,31 @@ func Load() (*Config, error) {
SyncOrdersIntervalMinutes: syncOrdersInterval,
UploadDir: uploadDir,
RedisURL: redisURL,
}, nil
}
// 生产环境GIN_MODE=release强制校验敏感配置禁止使用默认值
if cfg.Mode == "release" {
sensitive := []struct {
name string
val string
}{
{"WECHAT_APPSECRET", cfg.WechatAppSecret},
{"WECHAT_MCH_KEY", cfg.WechatMchKey},
{"WECHAT_APIV3_KEY", cfg.WechatAPIv3Key},
{"ADMIN_PASSWORD", cfg.AdminPassword},
{"ADMIN_SESSION_SECRET", cfg.AdminSessionSecret},
}
for _, s := range sensitive {
if s.val == "" ||
strings.HasPrefix(s.val, "wx3e31b068") ||
s.val == "admin123" ||
s.val == "soul-admin-secret-change-in-prod" {
log.Fatalf("生产环境必须配置 %s禁止使用默认值", s.name)
}
}
}
return cfg, nil
}
// resolveUploadDir 解析上传目录绝对路径air 运行时 exe 在 tmp/,需用项目根)

View File

@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -131,6 +132,7 @@ func AdminChaptersAction(c *gin.Context) {
if body.Action == "delete" {
id := resolveID()
if id != "" {
cache.InvalidateChapterContentByID(id)
db.Where("id = ?", id).Delete(&model.Chapter{})
}
}
@@ -156,5 +158,7 @@ func AdminChaptersAction(c *gin.Context) {
}
}
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -7,6 +7,7 @@ import (
"net/http"
"strings"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -94,6 +95,7 @@ func AdminLinkedMpCreate(c *gin.Context) {
return
}
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "data": item})
}
@@ -148,6 +150,7 @@ func AdminLinkedMpUpdate(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存失败: " + err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true})
}
@@ -181,6 +184,7 @@ func AdminLinkedMpDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "删除失败: " + err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"sort"
@@ -10,6 +11,7 @@ import (
"time"
"unicode/utf8"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -44,6 +46,13 @@ var allChaptersSelectCols = []string{
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
}
// chapterMetaCols 章节详情元数据(不含 content用于 content 缓存命中时的轻量查询
var chapterMetaCols = []string{
"mid", "id", "part_id", "part_title", "chapter_id", "chapter_title",
"section_title", "word_count", "is_free", "price", "sort_order", "status",
"is_new", "edition_standard", "edition_premium", "hot_score", "created_at", "updated_at",
}
// allChaptersCache 内存缓存,减轻 DB 压力30 秒 TTL
var allChaptersCache struct {
mu sync.RWMutex
@@ -68,6 +77,13 @@ type cachedFixedItem struct {
SectionTitle string `json:"title"`
}
// bookPartsRedisPayload Redis 缓存结构,与 BookParts 响应一致
type bookPartsRedisPayload struct {
Parts []cachedPartRow `json:"parts"`
TotalSections int64 `json:"totalSections"`
FixedSections []cachedFixedItem `json:"fixedSections"`
}
var bookPartsCache struct {
mu sync.RWMutex
parts []cachedPartRow
@@ -178,9 +194,11 @@ func fetchAndCacheBookParts() (parts []cachedPartRow, total int64, fixed []cache
return parts, total, fixed
}
// WarmBookPartsCache 启动时预热目录缓存,避免首请求慢
// WarmBookPartsCache 启动时预热目录缓存(内存+Redis,避免首请求慢
func WarmBookPartsCache() {
fetchAndCacheBookParts()
parts, total, fixed := fetchAndCacheBookParts()
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
}
// BookAllChapters GET /api/book/all-chapters 返回所有章节(列表,来自 chapters 表)
@@ -248,8 +266,21 @@ func BookChapterByID(c *gin.Context) {
// BookParts GET /api/miniprogram/book/parts 目录懒加载:仅返回篇章列表,不含章节详情
// 返回 parts排除序言/尾声/附录、totalSections、fixedSectionsid, mid, title 供序言/尾声/附录跳转用 mid
// 带 30 秒内存缓存,固定模块合并为 1 次查询,三路并行执行
// 缓存优先级Redis10min后台更新时失效> 内存30s> DBRedis 不可用时回退内存+DB
func BookParts(c *gin.Context) {
// 1. 优先 Redis后台无更新时长期有效
var redisPayload bookPartsRedisPayload
if cache.Get(context.Background(), cache.KeyBookParts, &redisPayload) && len(redisPayload.Parts) > 0 {
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": redisPayload.Parts,
"totalSections": redisPayload.TotalSections,
"fixedSections": redisPayload.FixedSections,
})
return
}
// 2. 内存缓存30sRedis 不可用时的容灾)
bookPartsCache.mu.RLock()
if time.Now().Before(bookPartsCache.expires) {
parts := bookPartsCache.parts
@@ -266,7 +297,11 @@ func BookParts(c *gin.Context) {
}
bookPartsCache.mu.RUnlock()
// 3. DB 查询并更新 Redis + 内存
parts, total, fixed := fetchAndCacheBookParts()
payload := bookPartsRedisPayload{Parts: parts, TotalSections: total, FixedSections: fixed}
cache.Set(context.Background(), cache.KeyBookParts, payload, cache.BookPartsTTL)
c.JSON(http.StatusOK, gin.H{
"success": true,
"parts": parts,
@@ -453,10 +488,12 @@ func previewContent(content string, percent int) string {
// findChapterAndRespond 按条件查章节并返回统一格式
// 免费判断优先级system_config.free_chapters / chapter_config.freeChapters > chapters.is_free/price
// 付费章节:若请求携带 userId 且有购买权限则返回完整 content否则返回 previewContent
// content 缓存:优先 Redis命中时仅查元数据不含 LONGTEXT未命中时查全量并回填缓存
func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
var ch model.Chapter
db := database.DB()
if err := whereFn(db).First(&ch).Error; err != nil {
// 1. 先查元数据(不含 content轻量
if err := whereFn(db).Select(chapterMetaCols).First(&ch).Error; err != nil {
if err == gorm.ErrRecordNotFound {
c.JSON(http.StatusNotFound, gin.H{"success": false, "error": "章节不存在"})
return
@@ -465,6 +502,17 @@ func findChapterAndRespond(c *gin.Context, whereFn func(*gorm.DB) *gorm.DB) {
return
}
// 2. 取 content优先 Redis未命中再查 DB
if content, ok := cache.GetString(context.Background(), cache.KeyChapterContent(ch.MID)); ok && content != "" {
ch.Content = content
} else {
if err := db.Model(&model.Chapter{}).Where("mid = ?", ch.MID).Pluck("content", &ch.Content).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.SetString(context.Background(), cache.KeyChapterContent(ch.MID), ch.Content, cache.ChapterContentTTL)
}
isFreeFromConfig := getFreeChapterIDs(db)[ch.ID]
isFree := isFreeFromConfig
if !isFree && ch.IsFree != nil && *ch.IsFree {
@@ -656,6 +704,7 @@ func bookHotChaptersSorted(db *gorm.DB, limit int) []model.Chapter {
}
// BookHot GET /api/book/hot 热门章节(按阅读量排序,排除序言/尾声/附录;支持 ?limit=,最大 50
// Redis 缓存 5min章节更新时失效
func BookHot(c *gin.Context) {
limit := 10
if l := c.Query("limit"); l != "" {
@@ -663,9 +712,14 @@ func BookHot(c *gin.Context) {
limit = n
}
}
// 优先 Redis
var cached []model.Chapter
if cache.Get(context.Background(), cache.KeyBookHot(limit), &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": cached})
return
}
list := bookHotChaptersSorted(database.DB(), limit)
if len(list) == 0 {
// 兜底:按 sort_order 取前 10同样排除序言/尾声/附录
q := database.DB().Model(&model.Chapter{})
for _, p := range excludeParts {
q = q.Where("part_title NOT LIKE ?", "%"+p+"%")
@@ -673,12 +727,18 @@ func BookHot(c *gin.Context) {
q.Order("sort_order ASC, id ASC").Limit(10).Find(&list)
sortChaptersByNaturalID(list)
}
cache.Set(context.Background(), cache.KeyBookHot(limit), list, cache.BookRelatedTTL)
c.JSON(http.StatusOK, gin.H{"success": true, "data": list})
}
// BookRecommended GET /api/book/recommended 精选推荐(首页「为你推荐」前 3 章)
// 与内容排行榜完全同源:使用 computeArticleRankingSections取前 3 条,保证顺序一致
// Redis 缓存 5min章节更新时失效
func BookRecommended(c *gin.Context) {
var cached []gin.H
if cache.Get(context.Background(), cache.KeyBookRecommended, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": cached})
return
}
sections, err := computeArticleRankingSections(database.DB())
if err != nil || len(sections) == 0 {
c.JSON(http.StatusOK, gin.H{"success": true, "data": []gin.H{}})
@@ -708,6 +768,7 @@ func BookRecommended(c *gin.Context) {
"isNew": s.IsNew,
})
}
cache.Set(context.Background(), cache.KeyBookRecommended, out, cache.BookRelatedTTL)
c.JSON(http.StatusOK, gin.H{"success": true, "data": out})
}
@@ -783,9 +844,18 @@ func BookSearch(c *gin.Context) {
}
// BookStats GET /api/book/stats
// Redis 缓存 5min章节更新时失效
func BookStats(c *gin.Context) {
var cached struct {
TotalChapters int64 `json:"totalChapters"`
}
if cache.Get(context.Background(), cache.KeyBookStats, &cached) {
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": cached.TotalChapters}})
return
}
var total int64
database.DB().Model(&model.Chapter{}).Count(&total)
cache.Set(context.Background(), cache.KeyBookStats, gin.H{"totalChapters": total}, cache.BookRelatedTTL)
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"totalChapters": total}})
}

View File

@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"fmt"
"net/http"
@@ -8,6 +9,7 @@ import (
"strings"
"time"
"soul-api/internal/cache"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -16,8 +18,13 @@ import (
)
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
// 从 system_config 读取 chapter_config、feature_config、mp_config合并后返回免费以章节 is_free/price 为准)
// Redis 缓存 10min配置变更时失效
func GetPublicDBConfig(c *gin.Context) {
var cached map[string]interface{}
if cache.Get(context.Background(), cache.KeyConfigMiniprogram, &cached) && len(cached) > 0 {
c.JSON(http.StatusOK, cached)
return
}
defaultPrices := gin.H{"section": float64(1), "fullbook": 9.9}
defaultFeatures := gin.H{"matchEnabled": true, "referralEnabled": true, "searchEnabled": true}
apiDomain := "https://soulapi.quwanzhi.com"
@@ -127,6 +134,7 @@ func GetPublicDBConfig(c *gin.Context) {
if _, has := out["linkedMiniprograms"]; !has {
out["linkedMiniprograms"] = []gin.H{}
}
cache.Set(context.Background(), cache.KeyConfigMiniprogram, out, cache.ConfigTTL)
c.JSON(http.StatusOK, out)
}
@@ -272,6 +280,7 @@ func AdminSettingsPost(c *gin.Context) {
return
}
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "设置已保存"})
}
@@ -355,6 +364,7 @@ func AdminReferralSettingsPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "推广设置已保存"})
}
@@ -526,6 +536,7 @@ func DBConfigPost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
}

View File

@@ -9,6 +9,7 @@ import (
"strings"
"time"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -440,6 +441,8 @@ func DBBookAction(c *gin.Context) {
}
switch body.Action {
case "sync":
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "同步完成Gin 无文件源时可从 DB 已存在数据视为已同步)"})
return
case "import":
@@ -488,8 +491,11 @@ func DBBookAction(c *gin.Context) {
failed++
continue
}
cache.InvalidateChapterContentByID(item.ID)
imported++
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "导入完成", "imported": imported, "failed": failed})
return
default:
@@ -553,6 +559,8 @@ func DBBookAction(c *gin.Context) {
}
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", it.ID).Updates(up).Error
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
}()
return
}
@@ -567,6 +575,8 @@ func DBBookAction(c *gin.Context) {
_ = db.WithContext(context.Background()).Model(&model.Chapter{}).Where("id = ?", id).Update("sort_order", i).Error
}
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
}()
return
}
@@ -590,6 +600,8 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true, "message": "已移动", "count": len(body.SectionIds)})
return
}
@@ -696,6 +708,9 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateChapterContent(ch.MID)
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
@@ -708,6 +723,9 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateChapterContentByID(body.ID)
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
return
}
@@ -748,9 +766,12 @@ func DBBookDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少 id"})
return
}
cache.InvalidateChapterContentByID(id)
if err := database.DB().Where("id = ?", id).Delete(&model.Chapter{}).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateBookParts()
cache.InvalidateBookCache()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -4,6 +4,7 @@ import (
"net/http"
"strings"
"soul-api/internal/cache"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -66,6 +67,7 @@ func DBLinkTagSave(c *gin.Context) {
existing.AppID = body.AppID
existing.PagePath = body.PagePath
db.Save(&existing)
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": existing})
return
}
@@ -75,6 +77,7 @@ func DBLinkTagSave(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true, "linkTag": t})
}
@@ -89,5 +92,6 @@ func DBLinkTagDelete(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateConfig()
c.JSON(http.StatusOK, gin.H{"success": true})
}

View File

@@ -10,6 +10,7 @@ import (
"time"
"soul-api/internal/config"
"soul-api/internal/oss"
"github.com/gin-gonic/gin"
)
@@ -17,6 +18,7 @@ const maxUploadBytes = 5 * 1024 * 1024 // 5MB
var allowedTypes = map[string]bool{"image/jpeg": true, "image/png": true, "image/gif": true, "image/webp": true}
// UploadPost POST /api/upload 上传图片(表单 file
// 若管理端已配置 OSS优先上传到 OSSOSS 失败或未配置时回退本地磁盘(容灾)
func UploadPost(c *gin.Context) {
file, err := c.FormFile("file")
if err != nil {
@@ -40,13 +42,30 @@ func UploadPost(c *gin.Context) {
if folder == "" {
folder = "avatars"
}
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
objectKey := filepath.ToSlash(filepath.Join("uploads", folder, name))
// 优先尝试 OSS已配置时
if oss.IsEnabled() {
f, err := file.Open()
if err == nil {
url, uploadErr := oss.Upload(objectKey, f)
_ = f.Close()
if uploadErr == nil && url != "" {
c.JSON(http.StatusOK, gin.H{"success": true, "url": url, "data": gin.H{"url": url, "fileName": name, "size": file.Size, "type": ct}})
return
}
// OSS 失败,回退本地(容灾)
}
}
// 本地磁盘存储OSS 未配置或失败时)
uploadDir := config.Get().UploadDir
if uploadDir == "" {
uploadDir = "uploads"
}
dir := filepath.Join(uploadDir, folder)
_ = os.MkdirAll(dir, 0755)
name := fmt.Sprintf("%d_%s%s", time.Now().UnixNano(), randomStrUpload(6), ext)
dst := filepath.Join(dir, name)
if err := c.SaveUploadedFile(file, dst); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "保存失败"})
@@ -70,18 +89,35 @@ func randomStrUpload(n int) string {
}
// UploadDelete DELETE /api/upload
// path 支持:/uploads/xxx本地或 https://bucket.oss-xxx.aliyuncs.com/uploads/xxxOSS
func UploadDelete(c *gin.Context) {
path := c.Query("path")
if path == "" {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "请指定 path"})
return
}
if !strings.HasPrefix(path, "/uploads/") && !strings.HasPrefix(path, "uploads/") {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
return
// OSS 公网 URL从 OSS 删除
if oss.IsOSSURL(path) {
objectKey := oss.ParseObjectKeyFromURL(path)
if objectKey != "" {
if err := oss.Delete(objectKey); err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "OSS 删除失败"})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "删除成功"})
return
}
}
// 本地路径:支持 /uploads/xxx、uploads/xxx 或含 /uploads/ 的完整 URL
if idx := strings.Index(path, "/uploads/"); idx >= 0 {
path = path[idx+1:] // 从 uploads/ 开始
}
rel := strings.TrimPrefix(path, "/uploads/")
rel = strings.TrimPrefix(rel, "uploads/")
if rel == "" || strings.Contains(rel, "..") {
c.JSON(http.StatusForbidden, gin.H{"success": false, "error": "无权限删除此文件"})
return
}
uploadDir := config.Get().UploadDir
if uploadDir == "" {
uploadDir = "uploads"

View File

@@ -8,6 +8,7 @@ import (
"os"
"time"
"soul-api/internal/config"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -133,7 +134,12 @@ func WithdrawPost(c *gin.Context) {
// AdminWithdrawTest GET/POST /api/admin/withdraw-test 提现测试接口,供 curl 等调试用
// 参数userId默认 ogpTW5fmXRGNpoUbXB3UEqnVe5Tg、amount默认 1
// 测试时忽略最低提现额限制,仅校验可提现余额与用户存在
// 生产环境禁用,避免误用
func AdminWithdrawTest(c *gin.Context) {
if cfg := config.Get(); cfg != nil && cfg.Mode == "release" {
c.JSON(http.StatusNotFound, gin.H{"success": false, "message": "生产环境禁用"})
return
}
userID := c.DefaultQuery("userId", "ogpTW5fmXRGNpoUbXB3UEqnVe5Tg")
amountStr := c.DefaultQuery("amount", "1")
var amount float64

View File

@@ -0,0 +1,124 @@
package oss
import (
"encoding/json"
"io"
"log"
"net/url"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
alioss "github.com/aliyun/aliyun-oss-go-sdk/oss"
)
// Config 阿里云 OSS 配置,与管理端 ossConfig 字段对应
type Config struct {
Endpoint string `json:"endpoint"`
Bucket string `json:"bucket"`
Region string `json:"region"`
AccessKeyID string `json:"accessKeyId"`
AccessKeySecret string `json:"accessKeySecret"`
}
// LoadConfig 从 system_config 读取 oss_config配置不完整时返回 nil
func LoadConfig() *Config {
var row model.SystemConfig
if err := database.DB().Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
return nil
}
var m map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &m); err != nil {
return nil
}
var cfg Config
if v, ok := m["endpoint"].(string); ok && v != "" {
cfg.Endpoint = strings.TrimSpace(v)
}
if v, ok := m["bucket"].(string); ok && v != "" {
cfg.Bucket = strings.TrimSpace(v)
}
if v, ok := m["accessKeyId"].(string); ok && v != "" {
cfg.AccessKeyID = v
}
if v, ok := m["accessKeySecret"].(string); ok && v != "" {
cfg.AccessKeySecret = v
}
if cfg.Endpoint == "" || cfg.Bucket == "" || cfg.AccessKeyID == "" || cfg.AccessKeySecret == "" {
return nil
}
// endpoint 去掉 schemeSDK 需要
cfg.Endpoint = strings.TrimPrefix(strings.TrimPrefix(cfg.Endpoint, "https://"), "http://")
return &cfg
}
// IsEnabled 是否已配置 OSS 且可用
func IsEnabled() bool {
return LoadConfig() != nil
}
// Upload 上传文件到 OSSobjectKey 如 "uploads/avatars/xxx.jpg"
// 返回公网访问 URL如 https://bucket.oss-cn-hangzhou.aliyuncs.com/uploads/avatars/xxx.jpg
func Upload(objectKey string, reader io.Reader, options ...alioss.Option) (string, error) {
cfg := LoadConfig()
if cfg == nil {
return "", nil // 未配置,调用方需回退本地
}
client, err := alioss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
log.Printf("oss: client init failed: %v", err)
return "", err
}
bucket, err := client.Bucket(cfg.Bucket)
if err != nil {
log.Printf("oss: bucket %s failed: %v", cfg.Bucket, err)
return "", err
}
if err := bucket.PutObject(objectKey, reader, options...); err != nil {
log.Printf("oss: PutObject %s failed: %v", objectKey, err)
return "", err
}
// 公网 URLhttps://{bucket}.{endpoint}/{objectKey}
u := "https://" + cfg.Bucket + "." + cfg.Endpoint + "/" + objectKey
return u, nil
}
// Delete 从 OSS 删除对象objectKey 如 "uploads/avatars/xxx.jpg"
func Delete(objectKey string) error {
cfg := LoadConfig()
if cfg == nil {
return nil
}
client, err := alioss.New(cfg.Endpoint, cfg.AccessKeyID, cfg.AccessKeySecret)
if err != nil {
return err
}
bucket, err := client.Bucket(cfg.Bucket)
if err != nil {
return err
}
return bucket.DeleteObject(objectKey)
}
// ParseObjectKeyFromURL 从 OSS 公网 URL 解析出 objectKey
// 格式: https://bucket.oss-cn-xxx.aliyuncs.com/uploads/avatars/xxx.jpg
func ParseObjectKeyFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
path := strings.TrimPrefix(u.Path, "/")
return path
}
// IsOSSURL 判断是否为 OSS 公网 URL用于删除时区分本地/OSS
func IsOSSURL(rawURL string) bool {
cfg := LoadConfig()
if cfg == nil {
return false
}
// 格式: https://{bucket}.{endpoint}/...
prefix := "https://" + cfg.Bucket + "." + cfg.Endpoint + "/"
return strings.HasPrefix(rawURL, prefix)
}

View File

@@ -95,6 +95,7 @@ func Setup(cfg *config.Config) *gin.Engine {
admin.DELETE("/users", handler.AdminUsersAction)
admin.GET("/orders", handler.OrdersList)
admin.GET("/gift-pay-requests", handler.AdminGiftPayRequestsList)
admin.GET("/user/track", handler.UserTrackGet)
}
// ----- 鉴权 -----

View File

@@ -410,3 +410,35 @@ Mycontent-temp/miniprogram 为样式预览分支miniprogram 为线上主线
- `开发文档/新版管理端迁移到稳定版-需求评估.md`
- `.cursor/meeting/2026-03-17_新版管理端迁移到稳定版实施方案确认.md`
---
# 第十七部分稳定版源码质量优化完成与测试流程定稿2026-03-17 橙子同步)
## 源码质量优化10 项全部完成)
| 端 | 任务 | 状态 |
|----|------|------|
| 后端 | 敏感配置生产环境强制校验config.go | ☑️ 已完成 |
| 后端 | 新增 GET /api/admin/user/track + AdminAuth | ☑️ 已完成 |
| 后端 | AdminWithdrawTest 环境限制 | ☑️ 已完成 |
| 管理端 | UserDetailModal 改为 /api/admin/user/track | ☑️ 已完成 |
| 管理端 | RichEditor name/label HTML 转义 | ☑️ 已完成 |
| 小程序 | 删除 payment.js、goToMatch 重复、备份文件 | ☑️ 已完成 |
| 小程序 | appId 等从 config 读取、totalSections 动态获取 | ☑️ 已完成 |
## 开发环境测试
- **环境**local (http://localhost:8080)
- **结果**10 通过、2 跳过、0 失败
## 测试流程与文档
| 产出 | 路径 | 说明 |
|------|------|------|
| 功能测试流程 | scripts/test/功能测试流程.md | 环境准备→自动化→手工验证→问题汇总→报告;成功 ☑️,失败列问题 |
| 测试报告模板 | scripts/test/测试报告-环境与用例清单.md | 环境、用例清单、结果记录、归档说明 |
## 相关文档
- `.cursor/meeting/2026-03-17_会议收尾-源码优化完成与测试流程定稿.md`

View File

@@ -24,6 +24,7 @@
| [10、项目管理/项目落地推进表](10、项目管理/项目落地推进表.md) | 里程碑、永平落地 |
| [10、项目管理/运营与变更](10、项目管理/运营与变更.md) | 近期讨论、变更记录 |
| [迁移完成度与待办清单](迁移完成度与待办清单.md) | 迁移完成度、剩余待办、搁置项 |
| [功能测试流程](../scripts/test/功能测试流程.md) | 功能测试流程、测试报告模板、自动化用例 |
### 架构与规范