chore: 新增 .gitignore 排除开发文档,同步代码与构建产物
Made-with: Cursor
This commit is contained in:
44
.cursor/agent/软件测试/evolution/2026-03-15-全站深度测试42问题.md
Normal file
44
.cursor/agent/软件测试/evolution/2026-03-15-全站深度测试42问题.md
Normal file
@@ -0,0 +1,44 @@
|
||||
# 2026-03-15 Soul 创业派对全站深度测试
|
||||
|
||||
## 问题背景
|
||||
|
||||
对 Soul 创业派对项目进行首次全站深度测试(只检测不修改),覆盖管理端 20+ 页面、35 个 API 端点、25 个小程序页面、25 张数据库表。
|
||||
|
||||
## 解决过程
|
||||
|
||||
### 测试方法
|
||||
1. 环境检查:确认后端/前端/数据库运行状态
|
||||
2. 管理端浏览器测试:逐页逐按钮截图检查
|
||||
3. API 端点测试:curl 逐个测试 35 个端点,含安全边界测试
|
||||
4. 小程序代码审查:25 个页面 + 8 个工具文件全量代码阅读
|
||||
5. 数据库一致性检查:交叉验证各 API 数据
|
||||
|
||||
### 关键发现(42 个问题)
|
||||
- 严重 11 个:OSS 密钥泄露、登录守卫缺失、小程序模块混用、废弃 API
|
||||
- 高 13 个:硬编码、API 失败伪装成功、分页缺失
|
||||
- 中 12 个:调试日志残留、模拟数据未清理
|
||||
- 低 6 个:版本号未设置等
|
||||
|
||||
## 可提炼规则
|
||||
|
||||
### 安全测试
|
||||
1. **密钥脱敏是硬性规则**:任何返回配置的 API,密钥类字段必须脱敏
|
||||
2. **SPA 路由守卫必查**:直接访问后台路径测试
|
||||
3. **上传接口安全测试**:非图片文件、超大文件、空文件
|
||||
|
||||
### API 测试
|
||||
4. **响应字段名不能假设**:先 print keys() 再解析,不同端点可能用 data/results/orders/records
|
||||
5. **分页必须翻页验证**:测第一页也测 page=2
|
||||
6. **交叉验证**:stats 的总数 vs list API 的实际条数
|
||||
|
||||
### 小程序测试
|
||||
7. **废弃 API 年检制度**:每年核对微信基础库废弃列表
|
||||
8. **模块语法统一检查**:grep -rn "export default" 快速排查
|
||||
9. **死代码扫描**:utils/ 每个文件是否被 pages/ 引用
|
||||
|
||||
### 数据库测试
|
||||
10. **空表不代表无问题**:空表可能是同步逻辑失效
|
||||
|
||||
## 适用角色
|
||||
|
||||
- target_roles: ["软件测试", "团队"]
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
| 日期 | 摘要 | 文件 |
|
||||
|------|------|------|
|
||||
| 2026-03-15 | **全站深度测试(42个问题)**:管理端20+页面截图测试、35个API端点、25个小程序页面代码审查、25张数据库表;严重11/高13/中12/低6;沉淀安全测试/API测试/小程序测试/数据库测试方法论 | [2026-03-15-全站深度测试42问题.md](./2026-03-15-全站深度测试42问题.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:菜单一致性、隐藏路由可达性、鉴权与跳转回归 | [2026-03-10.md](./2026-03-10.md) |
|
||||
| 2026-03-05 | 分支合并后回归清单制定;三端联调验证 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-05 | 文章详情@某人:@ 展示与添加好友用例、联调与回归点 | [2026-03-05.md](./2026-03-05.md) |
|
||||
| 2026-03-10 | 管理端迁移 Mycontent-temp:菜单一致性、隐藏路由可达性、鉴权与跳转回归 | [2026-03-10.md](./2026-03-10.md) |
|
||||
|
||||
@@ -1,72 +1,225 @@
|
||||
---
|
||||
name: soul-tester
|
||||
description: Soul 创业派对测试人员。功能测试、回归测试、三端(小程序、管理端、API)联调验证。Use when 测试, 测试用例, 回归测试, 功能测试, QA.
|
||||
description: Soul 创业派对测试人员。全站深度测试、功能测试、回归测试、三端联调验证、安全审计。Use when 测试, 测试用例, 回归测试, 功能测试, QA, 全站测试, 深度测试.
|
||||
version: "2.0"
|
||||
updated: "2026-03-15"
|
||||
---
|
||||
|
||||
# Soul 创业派对 - 测试人员 Skill
|
||||
# Soul 创业派对 - 测试 Skill v2.0
|
||||
|
||||
当你在**功能测试、回归测试、三端联调验证**时,使用本 Skill。测试人员负责小程序、管理端、API 的功能与集成测试,不参与源码编写。
|
||||
> **定位**:Soul 创业派对项目的专业测试规范。基于 2026-03-15 全站深度测试经验沉淀,覆盖管理端、小程序、API、数据库四个维度。
|
||||
>
|
||||
> **核心原则**:逐页逐按钮逐功能,三端隔离验证,数据交叉校验,安全必查。
|
||||
|
||||
---
|
||||
|
||||
## 1. 职责范围
|
||||
## 1. 测试范围
|
||||
|
||||
| 职责 | 说明 | 产出 |
|
||||
|------|------|------|
|
||||
| 功能测试 | 按需求验证功能正确性 | 测试用例、通过/失败记录 |
|
||||
| 回归测试 | 变更后验证原有功能未破坏 | 回归清单、测试报告 |
|
||||
| 三端联调 | 小程序↔API、管理端↔API 数据流验证 | 联调记录 |
|
||||
| Bug 反馈 | 复现步骤、环境、期望 vs 实际 | Bug 列表、复现说明 |
|
||||
| 端 | 目录 | API 路径 | 数据表 |
|
||||
|----|------|----------|--------|
|
||||
| 管理端 | soul-admin/ | /api/admin/*, /api/db/* | 25 张表 |
|
||||
| 小程序 | miniprogram/ | /api/miniprogram/* | 同上 |
|
||||
| API 后端 | soul-api/ | 全部 | 同上 |
|
||||
| 数据库 | MySQL | — | users, chapters, orders 等 25 张 |
|
||||
|
||||
---
|
||||
|
||||
## 2. 测试范围
|
||||
## 2. 测试执行检查清单(每次必做)
|
||||
|
||||
| 端 | 目录 | API 路径 | 重点 |
|
||||
|----|------|----------|------|
|
||||
| 小程序 | miniprogram/ | /api/miniprogram/* | 登录、支付、推荐码、VIP、阅读、分享 |
|
||||
| 管理端 | soul-admin/ | /api/admin/*、/api/db/* | 内容管理、用户、订单、提现、VIP 角色、推广设置 |
|
||||
| API 后端 | soul-api/ | 全部 | 接口契约、鉴权、分润、支付回调 |
|
||||
### 2.1 环境检查(Step 0)
|
||||
- [ ] 后端 API 运行(`curl localhost:8080/health`)
|
||||
- [ ] 管理端前端运行(`curl localhost:5174`)
|
||||
- [ ] 数据库连接正常(通过 /api/book/stats 间接验证)
|
||||
- [ ] OSS 配置状态(/api/admin/settings → ossConfig 有值)
|
||||
|
||||
### 2.2 管理端页面检查(24 个页面/子Tab)
|
||||
|
||||
**登录**
|
||||
- [ ] 登录页 UI 完整(表单、按钮、品牌标识)
|
||||
- [ ] 错误凭据有明确错误提示
|
||||
- [ ] 正确凭据登录成功跳转
|
||||
- [ ] **路由守卫**:未登录直接访问 /dashboard 应跳转 /login
|
||||
|
||||
**仪表盘**
|
||||
- [ ] 4 个统计卡片数据正确(用户数、收入、订单数、转化率)
|
||||
- [ ] 最近订单列表有数据
|
||||
- [ ] 新注册用户列表**用户名不应显示为"-"**
|
||||
- [ ] 分类标签点击统计有数据或合理空状态提示
|
||||
|
||||
**内容管理**(5 个 Tab)
|
||||
- [ ] 章节管理:树结构完整,篇/章/节层级
|
||||
- [ ] 内容排行榜:排名、浏览量、付费数、热度完整
|
||||
- [ ] 内容搜索:搜索框可用
|
||||
- [ ] 链接人与事:AI 列表展示
|
||||
- [ ] 链接标签:标签 CRUD
|
||||
|
||||
**用户管理**(2 个 Tab)
|
||||
- [ ] 用户列表:昵称、手机号、付费状态、分销、分页
|
||||
- [ ] 用户旅程:8 阶段漏斗数据
|
||||
|
||||
**找伙伴**(5 个 Tab)
|
||||
- [ ] 数据统计:匹配次数、用户数、AI 获客
|
||||
- [ ] 匹配记录:列表展示
|
||||
- [ ] 匹配池设置:来源池、基础设置
|
||||
- [ ] 导师管理:导师列表和价格
|
||||
- [ ] 团队招募:招募记录
|
||||
|
||||
**推广中心**(5 个 Tab)
|
||||
- [ ] 数据概览:今日/本月/累计统计
|
||||
- [ ] 订单管理:订单列表、搜索、分页
|
||||
- [ ] 绑定管理:绑定关系列表
|
||||
- [ ] 提现审核:提现记录和审核功能
|
||||
- [ ] 推广设置:收益率、提现规则
|
||||
|
||||
**系统设置**(4 个 Tab)
|
||||
- [ ] 系统设置:功能开关、价格、OSS、小程序配置
|
||||
- [ ] 作者详情:基本信息、统计、亮点
|
||||
- [ ] 管理员:管理员列表
|
||||
- [ ] API 文档:接口文档完整
|
||||
|
||||
### 2.3 API 端点检查(35+ 端点)
|
||||
|
||||
**公开 API(无需 Token)**
|
||||
- [ ] GET /health → 200
|
||||
- [ ] GET /api/config → 200,配置完整
|
||||
- [ ] GET /api/book/all-chapters → 200,章节数与 DB 一致
|
||||
- [ ] GET /api/book/hot → 200,热度排行有数据
|
||||
- [ ] GET /api/book/recommended → 200
|
||||
- [ ] GET /api/book/search?q=创业 → 200,有结果(注意返回字段是 `results` 不是 `data`)
|
||||
- [ ] GET /api/book/search?q= → 200,空结果
|
||||
- [ ] GET /api/book/stats → 200,交叉验证章节数
|
||||
|
||||
**小程序 API(无需 Token)**
|
||||
- [ ] GET /api/miniprogram/config → 200
|
||||
- [ ] GET /api/miniprogram/book/hot → 200
|
||||
- [ ] GET /api/miniprogram/book/stats → 200
|
||||
- [ ] GET /api/miniprogram/mentors → 200
|
||||
- [ ] GET /api/miniprogram/vip/members → 200
|
||||
|
||||
**管理端 API(需 Token)**
|
||||
- [ ] POST /api/admin → 登录获取 token
|
||||
- [ ] GET /api/admin/dashboard/stats → 200
|
||||
- [ ] GET /api/admin/chapters → 200
|
||||
- [ ] GET /api/admin/users → 200
|
||||
- [ ] GET /api/admin/orders → 200(注意返回字段 `orders` 不是 `data`)
|
||||
- [ ] GET /api/admin/track/stats → 200
|
||||
- [ ] GET /api/admin/settings → 200(**检查 OSS 密钥是否脱敏**)
|
||||
- [ ] GET /api/admin/referral-settings → 200
|
||||
- [ ] GET /api/admin/author-settings → 200
|
||||
|
||||
**DB API(需 Token)**
|
||||
- [ ] GET /api/db/book?action=list → 200
|
||||
- [ ] GET /api/db/ckb-leads → 200
|
||||
- [ ] GET /api/db/ckb-plan-stats → 200
|
||||
- [ ] GET /api/db/vip-roles → 200
|
||||
- [ ] GET /api/db/mentors → 200
|
||||
- [ ] GET /api/db/persons → 200
|
||||
|
||||
**安全测试**
|
||||
- [ ] 无 Token → /api/admin/settings → 401
|
||||
- [ ] 错误 Token → /api/admin/settings → 401
|
||||
- [ ] POST /api/admin 空 body → 错误提示
|
||||
- [ ] POST /api/upload 非图片 → 拒绝
|
||||
|
||||
### 2.4 数据库检查
|
||||
|
||||
- [ ] 25 张表结构完整(AutoMigrate 无报错)
|
||||
- [ ] 关键数据量核对:chapters/users/orders 与 API 一致
|
||||
- [ ] 无重复订单号
|
||||
- [ ] stats vs all-chapters 章节数交叉验证
|
||||
- [ ] orders vs dashboard 收入交叉验证
|
||||
|
||||
### 2.5 小程序代码审查(25 个页面)
|
||||
|
||||
- [ ] 所有页面 API 路径遵循 `/api/miniprogram/*`(不混调 admin/db)
|
||||
- [ ] 无废弃 API 使用(wx.getUserProfile / wx.createCanvasContext)
|
||||
- [ ] 模块导入方式统一(不混用 import/require 和 export default/module.exports)
|
||||
- [ ] 无未使用工具文件(检查 utils/ 下每个文件是否被引用)
|
||||
- [ ] 无硬编码(baseUrl/appId/mchId/微信号/日期)
|
||||
- [ ] 无 mock/test/debug 代码残留
|
||||
- [ ] 无过多 console.log 调试日志
|
||||
- [ ] 核心流程完整:登录→阅读→购买→VIP→分销→提现→匹配
|
||||
|
||||
---
|
||||
|
||||
## 3. 测试原则
|
||||
## 3. 安全必查项(每次必做)
|
||||
|
||||
- **路径隔离**:小程序只调 miniprogram;管理端只调 admin/db;不得混用。
|
||||
- **鉴权**:需登录接口需带 token;401 时正确跳转登录。
|
||||
- **数据流**:下单→支付→回调→分润;推荐码绑定→访问记录;VIP 资料保存→排行展示。
|
||||
- **变更检查**:开发完成变更后,可参考 soul-change-checklist 做关联检查,避免遗漏。
|
||||
| 检查项 | 方法 | 标准 |
|
||||
|--------|------|------|
|
||||
| OSS 密钥脱敏 | GET /api/admin/settings 查 ossConfig | accessKeySecret 不应返回明文 |
|
||||
| 管理端登录守卫 | 直接访问 /dashboard | 应跳转 /login |
|
||||
| Token 有效性 | 过期/错误 Token 访问管理 API | 返回 401 |
|
||||
| 上传文件类型限制 | POST /api/upload 上传 .txt | 应拒绝 |
|
||||
| 支付参数来源 | 审查小程序支付代码 | 参数必须从后端获取 |
|
||||
| 小程序敏感信息 | 审查 app.js | appId/mchId 不应硬编码 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 常用测试场景
|
||||
## 4. 经验库(持续沉淀)
|
||||
|
||||
| 场景 | 验证点 |
|
||||
|------|--------|
|
||||
| 小程序登录 | 微信登录、手机号、token 持久化 |
|
||||
| 购买与支付 | 下单、微信支付、回调更新、购买状态 |
|
||||
| 推荐与分润 | 扫码/分享带 ref、绑定、分润计算 |
|
||||
| VIP 功能 | 开通、资料填写、头像上传、保存、排行展示 |
|
||||
| 管理端 CRUD | 列表、搜索、分页、新增、编辑、删除 |
|
||||
| 提现 | 申请、审核、状态流转、到账确认 |
|
||||
### 4.1 API 响应字段陷阱
|
||||
|
||||
不同端点返回字段名不一致,测试脚本解析前先确认结构:
|
||||
|
||||
| 端点 | 数据字段 | 总数字段 |
|
||||
|------|----------|----------|
|
||||
| /api/book/search | `results` | `total` |
|
||||
| /api/admin/orders | `orders` | `total` |
|
||||
| /api/admin/users | `records` | `total` |
|
||||
| /api/admin/settings | 直接在根层 | — |
|
||||
| /api/book/hot | `data` | — |
|
||||
| /api/book/stats | `data` | — |
|
||||
|
||||
### 4.2 OSS 上传测试四步法
|
||||
|
||||
1. **配置验证**:GET /api/admin/settings → ossConfig 有值
|
||||
2. **上传测试**:POST /api/upload -F file=@test.png → 返回 storage=oss
|
||||
3. **URL 可访问**:curl 返回的 URL → HTTP 200
|
||||
4. **ACL 策略**:阿里云新账号默认禁止公共访问,需用签名 URL
|
||||
|
||||
### 4.3 小程序代码审查 grep 命令
|
||||
|
||||
```bash
|
||||
# 废弃 API 检查
|
||||
grep -rn "getUserProfile\|createCanvasContext" miniprogram/
|
||||
|
||||
# 模块语法混用
|
||||
grep -rn "export default" miniprogram/utils/
|
||||
|
||||
# 硬编码检查
|
||||
grep -rn "apiBase\|hardcode\|28533368\|2025-01-01" miniprogram/
|
||||
|
||||
# mock/test 残留
|
||||
grep -rn "mock\|Mock\|测试模式\|test mode" miniprogram/pages/
|
||||
|
||||
# console.log 统计
|
||||
grep -rc "console.log" miniprogram/pages/ | grep -v ":0$"
|
||||
```
|
||||
|
||||
### 4.4 通用经验(2026-03-15 沉淀)
|
||||
|
||||
| 经验 | 详情 |
|
||||
|------|------|
|
||||
| 密钥返回前端必须脱敏 | 后端 settings API 返回 ossConfig/apiKey 等时,secret 类字段只返回 `****` |
|
||||
| SPA 管理端必须有路由守卫 | 未登录用户访问任何管理页面必须跳转 /login |
|
||||
| API 失败绝不伪装成功 | catch 中不可设置 success=true,必须真实反馈 |
|
||||
| 上线前清理 mock/test 代码 | `grep -r "mock\|test mode\|测试模式"` |
|
||||
| 上线前清理 console.log | `grep -rc "console.log"` |
|
||||
| 微信 API 每年检查废弃 | wx.getUserProfile(2022)、wx.createCanvasContext(即将) |
|
||||
| 三端路径隔离是底线 | 小程序只调 miniprogram,管理端只调 admin/db |
|
||||
| 聚合统计必须交叉验证 | stats 的数字要与 list API 返回的实际条数对比 |
|
||||
| 分页必须实际翻页验证 | 不只测第一页,要测 page=2 和超出范围的 page |
|
||||
| 签名 URL 有有效期 | OSS 私有 bucket 用签名 URL,注意设置足够长的过期时间 |
|
||||
|
||||
---
|
||||
|
||||
## 5. 产出与协同
|
||||
|
||||
| 产出 | 说明 |
|
||||
| 产出 | 路径 |
|
||||
|------|------|
|
||||
| 测试用例 | 场景、步骤、期望结果 |
|
||||
| 测试报告 | 通过率、失败用例、环境信息 |
|
||||
| Bug 列表 | 复现步骤、关联端、严重程度 |
|
||||
| 测试报告 | `开发文档/全站测试报告_YYYYMMDD.md` |
|
||||
| 截图存档 | 浏览器测试自动截图 |
|
||||
| 飞书通知 | 测试完成后发飞书开发群 |
|
||||
| 经验沉淀 | 本文件 §4 + `.cursor/agent/软件测试/evolution/` |
|
||||
|
||||
**协同**:发现 Bug 时与对应开发角色(小程序/管理端/后端)对接;验收前完成测试并输出报告。
|
||||
|
||||
---
|
||||
|
||||
## 6. 何时使用本 Skill
|
||||
|
||||
- 编写或执行测试用例时
|
||||
- 做回归测试、功能验证时
|
||||
- 三端联调、接口契约验证时
|
||||
- 说「测试」「测试用例」「回归测试」「功能测试」「QA」时
|
||||
**协同**:发现 Bug 时与对应角色对接(小程序/管理端/后端),验收前完成测试并输出报告。
|
||||
|
||||
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
# 开发文档不上传 GitHub
|
||||
开发文档/
|
||||
|
||||
# 常见忽略
|
||||
node_modules/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env
|
||||
.env.local
|
||||
@@ -5,22 +5,43 @@
|
||||
|
||||
const { parseScene } = require('./utils/scene.js')
|
||||
|
||||
const DEFAULT_BASE_URL = 'https://soulapi.quwanzhi.com'
|
||||
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
|
||||
const DEFAULT_MCH_ID = '1318592501'
|
||||
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
|
||||
|
||||
function getRuntimeBootstrapConfig() {
|
||||
try {
|
||||
const extCfg = wx.getExtConfigSync ? (wx.getExtConfigSync() || {}) : {}
|
||||
return {
|
||||
baseUrl: extCfg.apiBaseUrl || wx.getStorageSync('apiBaseUrl') || DEFAULT_BASE_URL,
|
||||
appId: extCfg.appId || DEFAULT_APP_ID,
|
||||
mchId: extCfg.mchId || DEFAULT_MCH_ID,
|
||||
withdrawSubscribeTmplId: extCfg.withdrawSubscribeTmplId || DEFAULT_WITHDRAW_TMPL_ID
|
||||
}
|
||||
} catch (_) {
|
||||
return {
|
||||
baseUrl: DEFAULT_BASE_URL,
|
||||
appId: DEFAULT_APP_ID,
|
||||
mchId: DEFAULT_MCH_ID,
|
||||
withdrawSubscribeTmplId: DEFAULT_WITHDRAW_TMPL_ID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bootstrapConfig = getRuntimeBootstrapConfig()
|
||||
|
||||
App({
|
||||
globalData: {
|
||||
// API基础地址 - 连接真实后端
|
||||
baseUrl: 'https://soulapi.quwanzhi.com',
|
||||
// baseUrl: 'https://souldev.quwanzhi.com',
|
||||
// baseUrl: 'http://localhost:8080',
|
||||
|
||||
|
||||
// 小程序配置 - 真实AppID
|
||||
appId: 'wxb8bbb2b10dec74aa',
|
||||
// 运行配置:优先外部配置/缓存,其次默认值
|
||||
baseUrl: bootstrapConfig.baseUrl,
|
||||
appId: bootstrapConfig.appId,
|
||||
|
||||
// 订阅消息:用户点击「申请提现」→「立即提现」时会先弹出订阅授权窗
|
||||
withdrawSubscribeTmplId: 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE',
|
||||
withdrawSubscribeTmplId: bootstrapConfig.withdrawSubscribeTmplId,
|
||||
|
||||
// 微信支付配置
|
||||
mchId: '1318592501', // 商户号
|
||||
mchId: bootstrapConfig.mchId,
|
||||
|
||||
// 用户信息
|
||||
userInfo: null,
|
||||
@@ -29,7 +50,8 @@ App({
|
||||
|
||||
// 书籍数据
|
||||
bookData: null,
|
||||
totalSections: 62,
|
||||
totalSections: 0,
|
||||
supportWechat: '',
|
||||
|
||||
// 购买记录
|
||||
purchasedSections: [],
|
||||
@@ -86,6 +108,7 @@ App({
|
||||
|
||||
// 检查登录状态
|
||||
this.checkLoginStatus()
|
||||
this.loadRuntimeConfig()
|
||||
|
||||
// 加载书籍数据
|
||||
this.loadBookData()
|
||||
@@ -328,6 +351,23 @@ App({
|
||||
}
|
||||
},
|
||||
|
||||
async loadRuntimeConfig() {
|
||||
try {
|
||||
const res = await this.request({ url: '/api/miniprogram/config', silent: true, timeout: 5000 })
|
||||
const mpConfig = res?.mpConfig || {}
|
||||
this.globalData.baseUrl = mpConfig.apiDomain || this.globalData.baseUrl
|
||||
this.globalData.appId = mpConfig.appId || this.globalData.appId
|
||||
this.globalData.mchId = mpConfig.mchId || this.globalData.mchId
|
||||
this.globalData.withdrawSubscribeTmplId = mpConfig.withdrawSubscribeTmplId || this.globalData.withdrawSubscribeTmplId
|
||||
this.globalData.supportWechat = mpConfig.supportWechat || mpConfig.customerWechat || mpConfig.serviceWechat || ''
|
||||
try {
|
||||
wx.setStorageSync('apiBaseUrl', this.globalData.baseUrl)
|
||||
} catch (_) {}
|
||||
} catch (e) {
|
||||
console.warn('[App] 加载运行配置失败,继续使用默认配置:', e)
|
||||
}
|
||||
},
|
||||
|
||||
// 加载书籍数据
|
||||
async loadBookData() {
|
||||
try {
|
||||
@@ -342,6 +382,7 @@ App({
|
||||
if (res && (res.data || res.chapters)) {
|
||||
const chapters = res.data || res.chapters || []
|
||||
this.globalData.bookData = chapters
|
||||
this.globalData.totalSections = res.total || chapters.length || 0
|
||||
wx.setStorageSync('bookData', chapters)
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -595,13 +636,6 @@ App({
|
||||
return null
|
||||
},
|
||||
|
||||
// 模拟登录已废弃 - 不再使用
|
||||
// 现在必须使用真实的微信登录获取openId作为唯一标识
|
||||
mockLogin() {
|
||||
console.warn('[App] mockLogin已废弃,请使用真实登录')
|
||||
return null
|
||||
},
|
||||
|
||||
// 手机号登录:需同时传 wx.login 的 code 与 getPhoneNumber 的 phoneCode
|
||||
async loginWithPhone(phoneCode) {
|
||||
if (!this.ensureFullAppForAuth()) {
|
||||
|
||||
@@ -28,11 +28,7 @@ Page({
|
||||
expandedPart: null,
|
||||
|
||||
// 附录
|
||||
appendixList: [
|
||||
{ id: 'appendix-1', title: '附录1|Soul派对房精选对话' },
|
||||
{ id: 'appendix-2', title: '附录2|创业者自检清单' },
|
||||
{ id: 'appendix-3', title: '附录3|本书提到的工具和资源' }
|
||||
],
|
||||
appendixList: [],
|
||||
|
||||
// 每日新增章节
|
||||
dailyChapters: []
|
||||
@@ -77,6 +73,7 @@ Page({
|
||||
|
||||
const totalSections = res.total ?? rows.length
|
||||
app.globalData.bookData = rows
|
||||
app.globalData.totalSections = totalSections
|
||||
wx.setStorageSync('bookData', rows)
|
||||
|
||||
// bookData:过滤序言/尾声/附录,按 part 聚合,篇章顺序按 sort_order 与后台一致(含「2026每日派对干货」等)
|
||||
@@ -134,6 +131,16 @@ Page({
|
||||
}))
|
||||
|
||||
const baseSort = 62
|
||||
const appendixList = rows
|
||||
.filter(r => {
|
||||
const partTitle = String(r.partTitle || r.part_title || '')
|
||||
return partTitle.includes('附录')
|
||||
})
|
||||
.sort((a, b) => (a.sort_order ?? a.sectionOrder ?? 999999) - (b.sort_order ?? b.sectionOrder ?? 999999))
|
||||
.map(c => ({
|
||||
id: c.id,
|
||||
title: c.section_title || c.sectionTitle || c.title || c.chapterTitle || '附录'
|
||||
}))
|
||||
const daily = rows
|
||||
.filter(r => (r.sectionOrder ?? r.sort_order ?? 0) > baseSort)
|
||||
.sort((a, b) => new Date(b.updatedAt || b.updated_at || 0) - new Date(a.updatedAt || a.updated_at || 0))
|
||||
@@ -152,6 +159,7 @@ Page({
|
||||
this.setData({
|
||||
bookData,
|
||||
totalSections,
|
||||
appendixList,
|
||||
dailyChapters: daily,
|
||||
expandedPart: this.data.expandedPart
|
||||
})
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* 技术支持: 存客宝
|
||||
*/
|
||||
|
||||
console.log('[Index] ===== 首页文件开始加载 =====')
|
||||
|
||||
const app = getApp()
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
@@ -22,7 +20,7 @@ Page({
|
||||
readCount: 0,
|
||||
|
||||
// 书籍数据
|
||||
totalSections: 62,
|
||||
totalSections: 0,
|
||||
bookData: [],
|
||||
|
||||
// 精选推荐(按热度排行,默认显示3篇,可展开更多)
|
||||
@@ -64,8 +62,6 @@ Page({
|
||||
},
|
||||
|
||||
onLoad(options) {
|
||||
console.log('[Index] ===== onLoad 触发 =====')
|
||||
|
||||
// 获取系统信息
|
||||
this.setData({
|
||||
statusBarHeight: app.globalData.statusBarHeight,
|
||||
@@ -74,7 +70,6 @@ Page({
|
||||
|
||||
// 处理分享参数(推荐码绑定)
|
||||
if (options && options.ref) {
|
||||
console.log('[Index] 检测到推荐码:', options.ref)
|
||||
app.handleReferralCode({ query: options })
|
||||
}
|
||||
|
||||
@@ -83,16 +78,11 @@ Page({
|
||||
},
|
||||
|
||||
onShow() {
|
||||
console.log('[Index] onShow 触发')
|
||||
|
||||
// 设置TabBar选中状态
|
||||
if (typeof this.getTabBar === 'function' && this.getTabBar()) {
|
||||
const tabBar = this.getTabBar()
|
||||
console.log('[Index] TabBar 组件:', tabBar ? '已找到' : '未找到')
|
||||
|
||||
// 主动触发配置加载
|
||||
if (tabBar && tabBar.loadFeatureConfig) {
|
||||
console.log('[Index] 主动调用 TabBar.loadFeatureConfig()')
|
||||
tabBar.loadFeatureConfig()
|
||||
}
|
||||
|
||||
@@ -102,8 +92,6 @@ Page({
|
||||
} else if (tabBar) {
|
||||
tabBar.setData({ selected: 0 })
|
||||
}
|
||||
} else {
|
||||
console.log('[Index] TabBar 组件未找到或 getTabBar 方法不存在')
|
||||
}
|
||||
|
||||
// 更新用户状态
|
||||
@@ -139,13 +127,8 @@ Page({
|
||||
avatar: u.avatar || '',
|
||||
isVip: true
|
||||
}))
|
||||
if (members.length > 0) {
|
||||
console.log('[Index] 超级个体加载成功:', members.length, '人')
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Index] vip/members 请求失败:', e)
|
||||
}
|
||||
} catch (e) {}
|
||||
// 不足 4 个则用有头像的普通用户补充
|
||||
if (members.length < 4) {
|
||||
try {
|
||||
@@ -162,7 +145,6 @@ Page({
|
||||
}
|
||||
this.setData({ superMembers: members, superMembersLoading: false })
|
||||
} catch (e) {
|
||||
console.log('[Index] 加载超级个体失败:', e)
|
||||
this.setData({ superMembersLoading: false })
|
||||
}
|
||||
},
|
||||
@@ -192,7 +174,7 @@ Page({
|
||||
featuredExpanded: false,
|
||||
})
|
||||
}
|
||||
} catch (e) { console.log('[Index] book/hot 失败:', e) }
|
||||
} catch (e) {}
|
||||
|
||||
// 2. 最新更新:用 book/latest-chapters 取第1条(排除「序言」「尾声」「附录」)
|
||||
try {
|
||||
@@ -233,9 +215,7 @@ Page({
|
||||
})
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Index] 从服务端加载推荐失败:', e)
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadBookData() {
|
||||
@@ -246,7 +226,7 @@ Page({
|
||||
const partIds = new Set(chapters.map(c => c.partId || c.part_id || '').filter(Boolean))
|
||||
this.setData({
|
||||
bookData: chapters,
|
||||
totalSections: res.total || chapters.length || 62,
|
||||
totalSections: res.total || chapters.length || app.globalData.totalSections || 0,
|
||||
partCount: partIds.size || 5
|
||||
})
|
||||
}
|
||||
@@ -258,7 +238,7 @@ Page({
|
||||
// 更新用户状态(已读数 = 用户实际打开过的章节数,仅统计有权限阅读的)
|
||||
updateUserStatus() {
|
||||
const { isLoggedIn, hasFullBook, purchasedSections } = app.globalData
|
||||
const readCount = Math.min(app.getReadCount(), this.data.totalSections || 62)
|
||||
const readCount = Math.min(app.getReadCount(), this.data.totalSections || app.globalData.totalSections || 0)
|
||||
this.setData({
|
||||
isLoggedIn,
|
||||
hasFullBook,
|
||||
@@ -317,12 +297,6 @@ Page({
|
||||
return
|
||||
}
|
||||
const userId = app.globalData.userInfo.id
|
||||
// 2 分钟内只能点一次(与后端限频一致)
|
||||
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
|
||||
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
|
||||
wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
let phone = (app.globalData.userInfo.phone || '').trim()
|
||||
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
|
||||
let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim()
|
||||
@@ -363,8 +337,12 @@ Page({
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (res && res.success) {
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
wx.showToast({ title: res.message || '提交成功', icon: 'success' })
|
||||
wx.showModal({
|
||||
title: '提交成功',
|
||||
content: '卡若会主动添加你微信,请注意你的微信消息',
|
||||
showCancel: false,
|
||||
confirmText: '好的'
|
||||
})
|
||||
} else {
|
||||
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
@@ -427,11 +405,6 @@ Page({
|
||||
wx.showToast({ title: '请输入正确的手机号', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
|
||||
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
|
||||
wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const app = getApp()
|
||||
const userId = app.globalData.userInfo?.id
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
@@ -448,7 +421,6 @@ Page({
|
||||
wx.hideLoading()
|
||||
this.setData({ showLeadModal: false, leadPhone: '' })
|
||||
if (res && res.success) {
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
// 同步手机号到用户资料
|
||||
try {
|
||||
if (userId) {
|
||||
@@ -492,8 +464,11 @@ Page({
|
||||
|
||||
async loadLatestChapters() {
|
||||
try {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
const chapters = (res && res.data) || (res && res.chapters) || []
|
||||
let chapters = app.globalData.bookData || []
|
||||
if (!Array.isArray(chapters) || chapters.length === 0) {
|
||||
const res = await app.request({ url: '/api/miniprogram/book/all-chapters', silent: true })
|
||||
chapters = (res && res.data) || (res && res.chapters) || []
|
||||
}
|
||||
const pt = (c) => (c.partTitle || c.part_title || '').toLowerCase()
|
||||
const exclude = c => !pt(c).includes('序言') && !pt(c).includes('尾声') && !pt(c).includes('附录')
|
||||
let candidates = chapters.filter(c => (c.isNew || c.is_new) === true && exclude(c))
|
||||
@@ -538,7 +513,7 @@ Page({
|
||||
latestChapters: latestAll.slice(0, 5),
|
||||
latestChaptersExpanded: false,
|
||||
})
|
||||
} catch (e) { console.log('[Index] 加载最新新增失败:', e) }
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
toggleLatestExpand() {
|
||||
|
||||
@@ -309,7 +309,7 @@ Page({
|
||||
confirmText: '去购买',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
wx.switchTab({ url: '/pages/catalog/catalog' })
|
||||
wx.switchTab({ url: '/pages/chapters/chapters' })
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -466,35 +466,6 @@ Page({
|
||||
}, delay)
|
||||
},
|
||||
|
||||
// 生成模拟匹配数据
|
||||
generateMockMatch() {
|
||||
const nicknames = ['创业先锋', '资源整合者', '私域专家', '导师顾问', '连续创业者']
|
||||
const concepts = [
|
||||
'专注私域流量运营5年,帮助100+品牌实现从0到1的增长。',
|
||||
'连续创业者,擅长商业模式设计和资源整合。',
|
||||
'在Soul分享真实创业故事,希望找到志同道合的合作伙伴。'
|
||||
]
|
||||
const wechats = ['soul_partner_1', 'soul_business_2024', 'soul_startup_fan']
|
||||
|
||||
const index = Math.floor(Math.random() * nicknames.length)
|
||||
const currentType = MATCH_TYPES.find(t => t.id === this.data.selectedType)
|
||||
|
||||
return {
|
||||
id: `user_${Date.now()}`,
|
||||
nickname: nicknames[index],
|
||||
avatar: `https://picsum.photos/200/200?random=${Date.now()}`,
|
||||
tags: ['创业者', '私域运营', currentType?.label || '创业合伙'],
|
||||
matchScore: Math.floor(Math.random() * 20) + 80,
|
||||
concept: concepts[index % concepts.length],
|
||||
wechat: wechats[index % wechats.length],
|
||||
commonInterests: [
|
||||
{ icon: '📚', text: '都在读《创业派对》' },
|
||||
{ icon: '💼', text: '对私域运营感兴趣' },
|
||||
{ icon: '🎯', text: '相似的创业方向' }
|
||||
]
|
||||
}
|
||||
},
|
||||
|
||||
// 上报匹配行为
|
||||
async reportMatch(matchedUser) {
|
||||
try {
|
||||
@@ -648,18 +619,16 @@ Page({
|
||||
this.setData({ showJoinModal: false, joinSuccess: false })
|
||||
}, 2000)
|
||||
} else {
|
||||
// 即使API返回失败,也模拟成功(因为已保存本地)
|
||||
this.setData({ joinSuccess: true })
|
||||
setTimeout(() => {
|
||||
this.setData({ showJoinModal: false, joinSuccess: false })
|
||||
}, 2000)
|
||||
this.setData({
|
||||
joinSuccess: false,
|
||||
joinError: res.error || '提交失败,请稍后重试'
|
||||
})
|
||||
}
|
||||
} catch (e) {
|
||||
// 网络错误时也模拟成功
|
||||
this.setData({ joinSuccess: true })
|
||||
setTimeout(() => {
|
||||
this.setData({ showJoinModal: false, joinSuccess: false })
|
||||
}, 2000)
|
||||
this.setData({
|
||||
joinSuccess: false,
|
||||
joinError: e.message || '网络异常,请稍后重试'
|
||||
})
|
||||
} finally {
|
||||
this.setData({ isJoining: false })
|
||||
}
|
||||
@@ -737,19 +706,7 @@ Page({
|
||||
if (e.errMsg && e.errMsg.includes('cancel')) {
|
||||
wx.showToast({ title: '已取消', icon: 'none' })
|
||||
} else {
|
||||
// 测试模式
|
||||
wx.showModal({
|
||||
title: '支付服务暂不可用',
|
||||
content: '是否使用测试模式购买?',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
const extraMatches = (wx.getStorageSync('extra_match_count') || 0) + 1
|
||||
wx.setStorageSync('extra_match_count', extraMatches)
|
||||
wx.showToast({ title: '测试购买成功', icon: 'success' })
|
||||
this.initUserStatus()
|
||||
}
|
||||
}
|
||||
})
|
||||
wx.showToast({ title: e.message || '支付失败,请稍后重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -19,7 +19,7 @@ Page({
|
||||
userInfo: null,
|
||||
|
||||
// 统计数据
|
||||
totalSections: 62,
|
||||
totalSections: 0,
|
||||
readCount: 0,
|
||||
referralCount: 0,
|
||||
earnings: '-',
|
||||
@@ -74,6 +74,9 @@ Page({
|
||||
|
||||
// 我的余额(wallet 页入口展示)
|
||||
walletBalance: 0,
|
||||
|
||||
// 我的代付链接
|
||||
giftList: [],
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
@@ -142,6 +145,7 @@ Page({
|
||||
this.loadPendingConfirm()
|
||||
this.loadVipStatus()
|
||||
this.loadWalletBalance()
|
||||
this.loadGiftList()
|
||||
} else {
|
||||
this.setData({
|
||||
isLoggedIn: false,
|
||||
@@ -797,12 +801,6 @@ Page({
|
||||
wx.navigateTo({ url: '/pages/referral/referral' })
|
||||
},
|
||||
|
||||
// 跳转到找伙伴
|
||||
goToMatch() {
|
||||
trackClick('my', 'nav_click', '匹配')
|
||||
wx.switchTab({ url: '/pages/match/match' })
|
||||
},
|
||||
|
||||
// 退出登录
|
||||
handleLogout() {
|
||||
wx.showModal({
|
||||
@@ -829,6 +827,35 @@ Page({
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
async loadGiftList() {
|
||||
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
|
||||
const userId = app.globalData.userInfo.id
|
||||
try {
|
||||
const res = await app.request({ url: `/api/miniprogram/balance/gifts?userId=${userId}`, silent: true })
|
||||
if (res?.success && res.data?.gifts) {
|
||||
this.setData({ giftList: res.data.gifts })
|
||||
}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
onGiftShareTap(e) {
|
||||
const giftCode = e.currentTarget.dataset.code
|
||||
const title = e.currentTarget.dataset.title || '精选文章'
|
||||
const sectionId = e.currentTarget.dataset.sectionId
|
||||
this._pendingGiftShare = { giftCode, title, sectionId }
|
||||
wx.showModal({
|
||||
title: '分享代付链接',
|
||||
content: `将「${title}」的免费阅读链接分享给好友`,
|
||||
confirmText: '立即分享',
|
||||
cancelText: '取消',
|
||||
success: (r) => {
|
||||
if (r.confirm) {
|
||||
wx.shareAppMessage()
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
// VIP状态查询(注意:hasFullBook=9.9 买断,不等同 VIP)
|
||||
async loadVipStatus() {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
@@ -1020,6 +1047,17 @@ Page({
|
||||
stopPropagation() {},
|
||||
|
||||
onShareAppMessage() {
|
||||
if (this._pendingGiftShare) {
|
||||
const { giftCode, title, sectionId } = this._pendingGiftShare
|
||||
this._pendingGiftShare = null
|
||||
const ref = app.getMyReferralCode()
|
||||
let path = `/pages/read/read?id=${sectionId}&gift=${giftCode}`
|
||||
if (ref) path += `&ref=${ref}`
|
||||
return {
|
||||
title: `🎁 好友已为你解锁:${title}`,
|
||||
path
|
||||
}
|
||||
}
|
||||
const ref = app.getMyReferralCode()
|
||||
return {
|
||||
title: 'Soul创业派对 - 我的',
|
||||
|
||||
@@ -142,6 +142,26 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的代付链接 -->
|
||||
<view class="card gift-card" wx:if="{{giftList.length > 0}}">
|
||||
<view class="card-header">
|
||||
<image class="card-icon-img" src="/assets/icons/wallet.svg" mode="aspectFit"/>
|
||||
<text class="card-title">我的代付链接</text>
|
||||
</view>
|
||||
<view class="gift-list">
|
||||
<view class="gift-item" wx:for="{{giftList}}" wx:key="giftCode">
|
||||
<view class="gift-left">
|
||||
<text class="gift-title">{{item.sectionTitle}}</text>
|
||||
<text class="gift-meta">¥{{item.amount}} · {{item.status === 'pending' ? '待领取' : '已领取'}} · {{item.createdAt}}</text>
|
||||
</view>
|
||||
<view class="gift-action" wx:if="{{item.status === 'pending'}}" bindtap="onGiftShareTap" data-code="{{item.giftCode}}" data-title="{{item.sectionTitle}}" data-section-id="{{item.sectionId}}">
|
||||
<text class="gift-share-btn">分享</text>
|
||||
</view>
|
||||
<text class="gift-done" wx:else>已送出</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的订单 + 关于作者 + 设置 -->
|
||||
<view class="card menu-card">
|
||||
<view class="menu-item" bindtap="handleMenuTap" data-id="orders">
|
||||
|
||||
@@ -251,5 +251,14 @@
|
||||
.modal-btn-cancel { background: rgba(255,255,255,0.1); color: #fff; }
|
||||
.modal-btn-confirm { background: #4FD1C5; color: #000; font-weight: 600; }
|
||||
|
||||
/* 代付链接卡片 */
|
||||
.gift-list { display: flex; flex-direction: column; gap: 16rpx; }
|
||||
.gift-item { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; background: rgba(255,255,255,0.05); border-radius: 16rpx; }
|
||||
.gift-left { flex: 1; min-width: 0; }
|
||||
.gift-title { display: block; font-size: 28rpx; color: #fff; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.gift-meta { display: block; font-size: 22rpx; color: #9CA3AF; margin-top: 6rpx; }
|
||||
.gift-share-btn { display: inline-block; padding: 8rpx 28rpx; background: #4FD1C5; color: #000; font-size: 24rpx; font-weight: 600; border-radius: 20rpx; }
|
||||
.gift-done { font-size: 24rpx; color: #6B7280; }
|
||||
|
||||
/* 底部留白:配合 page padding-bottom,避免内容被 TabBar 遮挡 */
|
||||
.bottom-space { height: calc(80rpx + env(safe-area-inset-bottom, 0px)); }
|
||||
|
||||
@@ -13,8 +13,8 @@
|
||||
* - contentSegments 解析每行,mention 高亮可点;点击→确认→登录/资料校验→POST /api/miniprogram/ckb/lead
|
||||
*/
|
||||
|
||||
import accessManager from '../../utils/chapterAccessManager'
|
||||
import readingTracker from '../../utils/readingTracker'
|
||||
const accessManager = require('../../utils/chapterAccessManager')
|
||||
const readingTracker = require('../../utils/readingTracker')
|
||||
const { parseScene } = require('../../utils/scene.js')
|
||||
const contentParser = require('../../utils/contentParser.js')
|
||||
|
||||
@@ -63,7 +63,7 @@ Page({
|
||||
// 价格
|
||||
sectionPrice: 1,
|
||||
fullBookPrice: 9.9,
|
||||
totalSections: 62,
|
||||
totalSections: 0,
|
||||
|
||||
// 弹窗
|
||||
showShareModal: false,
|
||||
@@ -321,49 +321,31 @@ Page({
|
||||
|
||||
// 获取章节信息
|
||||
getSectionInfo(id) {
|
||||
// 特殊章节
|
||||
if (id === 'preface') {
|
||||
return { id: 'preface', title: '为什么我每天早上6点在Soul开播?', isFree: true, price: 0 }
|
||||
}
|
||||
if (id === 'epilogue') {
|
||||
return { id: 'epilogue', title: '这本书的真实目的', isFree: true, price: 0 }
|
||||
}
|
||||
if (id.startsWith('appendix')) {
|
||||
const appendixTitles = {
|
||||
'appendix-1': 'Soul派对房精选对话',
|
||||
'appendix-2': '创业者自检清单',
|
||||
'appendix-3': '本书提到的工具和资源'
|
||||
const cachedSection = (app.globalData.bookData || []).find((item) => item.id === id)
|
||||
if (cachedSection) {
|
||||
return {
|
||||
id,
|
||||
title: cachedSection.sectionTitle || cachedSection.section_title || cachedSection.title || cachedSection.chapterTitle || `章节 ${id}`,
|
||||
isFree: cachedSection.isFree === true || cachedSection.is_free === true || cachedSection.price === 0,
|
||||
price: cachedSection.price ?? 1
|
||||
}
|
||||
return { id, title: appendixTitles[id] || '附录', isFree: true, price: 0 }
|
||||
}
|
||||
|
||||
// 普通章节
|
||||
|
||||
return {
|
||||
id: id,
|
||||
id,
|
||||
title: this.getSectionTitle(id),
|
||||
isFree: id === '1.1',
|
||||
isFree: false,
|
||||
price: 1
|
||||
}
|
||||
},
|
||||
|
||||
// 获取章节标题
|
||||
getSectionTitle(id) {
|
||||
const titles = {
|
||||
'1.1': '荷包:电动车出租的被动收入模式',
|
||||
'1.2': '老墨:资源整合高手的社交方法',
|
||||
'1.3': '笑声背后的MBTI',
|
||||
'1.4': '人性的三角结构:利益、情感、价值观',
|
||||
'1.5': '沟通差的问题:为什么你说的别人听不懂',
|
||||
'2.1': '相亲故事:你以为找的是人,实际是在找模式',
|
||||
'2.2': '找工作迷茫者:为什么简历解决不了人生',
|
||||
'2.3': '撸运费险:小钱困住大脑的真实心理',
|
||||
'2.4': '游戏上瘾的年轻人:不是游戏吸引他,是生活没吸引力',
|
||||
'2.5': '健康焦虑(我的糖尿病经历):疾病是人生的第一次清醒',
|
||||
'3.1': '3000万流水如何跑出来(退税模式解析)',
|
||||
'8.1': '流量杠杆:抖音、Soul、飞书',
|
||||
'9.14': '大健康私域:一个月150万的70后'
|
||||
const cachedSection = (app.globalData.bookData || []).find((item) => item.id === id)
|
||||
if (cachedSection) {
|
||||
return cachedSection.sectionTitle || cachedSection.section_title || cachedSection.title || cachedSection.chapterTitle || `章节 ${id}`
|
||||
}
|
||||
return titles[id] || `章节 ${id}`
|
||||
return `章节 ${id}`
|
||||
},
|
||||
|
||||
// 根据 id/mid 构造章节接口路径(优先使用 mid)。必须带 userId 才能让后端正确判断付费用户并返回完整内容
|
||||
@@ -679,12 +661,6 @@ Page({
|
||||
})
|
||||
return
|
||||
}
|
||||
// 2 分钟内只能点一次(与后端限频一致,与首页链接卡若共用)
|
||||
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
|
||||
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
|
||||
wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.showLoading({ title: '提交中...', mask: true })
|
||||
try {
|
||||
const res = await app.request({
|
||||
@@ -702,8 +678,13 @@ Page({
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (res && res.success) {
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
|
||||
const who = targetNickname || '对方'
|
||||
wx.showModal({
|
||||
title: '提交成功',
|
||||
content: `${who} 会主动添加你微信,请注意你的微信消息`,
|
||||
showCancel: false,
|
||||
confirmText: '好的'
|
||||
})
|
||||
} else {
|
||||
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
@@ -728,11 +709,6 @@ Page({
|
||||
return
|
||||
}
|
||||
const userId = app.globalData.userInfo.id
|
||||
const leadLastTs = wx.getStorageSync('lead_last_submit_ts') || 0
|
||||
if (Date.now() - leadLastTs < 2 * 60 * 1000) {
|
||||
wx.showToast({ title: '操作太频繁,请2分钟后再试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
let phone = (app.globalData.userInfo.phone || '').trim()
|
||||
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || '').trim()
|
||||
let avatar = (app.globalData.userInfo.avatar || app.globalData.userInfo.avatarUrl || '').trim()
|
||||
@@ -774,8 +750,12 @@ Page({
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (res && res.success) {
|
||||
wx.setStorageSync('lead_last_submit_ts', Date.now())
|
||||
wx.showToast({ title: res.message || '提交成功', icon: 'success' })
|
||||
wx.showModal({
|
||||
title: '提交成功',
|
||||
content: '卡若会主动添加你微信,请注意你的微信消息',
|
||||
showCancel: false,
|
||||
confirmText: '好的'
|
||||
})
|
||||
} else {
|
||||
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
|
||||
}
|
||||
@@ -811,16 +791,15 @@ Page({
|
||||
|
||||
// 复制分享文案(朋友圈风格)
|
||||
copyShareText() {
|
||||
const { section } = this.data
|
||||
|
||||
const shareText = `🔥 刚看完这篇《${section?.title || 'Soul创业派对'}》,太上头了!
|
||||
|
||||
62个真实商业案例,每个都是从0到1的实战经验。私域运营、资源整合、商业变现,干货满满。
|
||||
|
||||
推荐给正在创业或想创业的朋友,搜"Soul创业派对"小程序就能看!
|
||||
|
||||
#创业派对 #私域运营 #商业案例`
|
||||
|
||||
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
|
||||
const raw = (this.data.content || '')
|
||||
.replace(/<[^>]+>/g, '\n')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"')
|
||||
.replace(/[#@]\S+/g, '')
|
||||
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
|
||||
const picked = sentences.slice(0, 5)
|
||||
const shareText = title + '\n\n' + picked.join('\n\n')
|
||||
wx.setClipboardData({
|
||||
data: shareText,
|
||||
success: () => {
|
||||
@@ -864,13 +843,23 @@ Page({
|
||||
|
||||
shareToMoments() {
|
||||
const title = this.data.section?.title || this.data.chapterTitle || '好文推荐'
|
||||
const raw = (this.data.content || '').replace(/[#@]\S+/g, '').replace(/\s+/g, ' ').trim()
|
||||
const excerpt = raw.length > 200 ? raw.slice(0, 200) + '……' : raw.length > 100 ? raw + '……' : raw
|
||||
const copyText = `${title}\n\n${excerpt}\n\n👉 来自「Soul创业派对」`
|
||||
const raw = (this.data.content || '')
|
||||
.replace(/<[^>]+>/g, '\n')
|
||||
.replace(/ /g, ' ')
|
||||
.replace(/</g, '<').replace(/>/g, '>').replace(/&/g, '&').replace(/"/g, '"')
|
||||
.replace(/[#@]\S+/g, '')
|
||||
const sentences = raw.split(/[。!?\n]+/).map(s => s.trim()).filter(s => s.length > 4)
|
||||
const picked = sentences.slice(0, 5)
|
||||
const copyText = title + '\n\n' + picked.join('\n\n')
|
||||
wx.setClipboardData({
|
||||
data: copyText,
|
||||
success: () => {
|
||||
wx.showToast({ title: '文案已复制,去朋友圈粘贴发布', icon: 'none', duration: 2500 })
|
||||
wx.showModal({
|
||||
title: '文案已复制',
|
||||
content: '请点击右上角「···」菜单,选择「分享到朋友圈」即可发布',
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
},
|
||||
fail: () => {
|
||||
wx.showToast({ title: '复制失败,请手动复制', icon: 'none' })
|
||||
@@ -1154,15 +1143,18 @@ Page({
|
||||
console.error('[Pay] API创建订单失败:', apiError)
|
||||
wx.hideLoading()
|
||||
// 支付接口失败时,显示客服联系方式
|
||||
const supportWechat = app.globalData.supportWechat || ''
|
||||
wx.showModal({
|
||||
title: '支付通道维护中',
|
||||
content: '微信支付正在审核中,请添加客服微信(28533368)手动购买,感谢理解!',
|
||||
confirmText: '复制微信号',
|
||||
content: supportWechat
|
||||
? `微信支付正在审核中,请添加客服微信(${supportWechat})手动购买,感谢理解!`
|
||||
: '微信支付正在审核中,请联系管理员手动购买,感谢理解!',
|
||||
confirmText: supportWechat ? '复制微信号' : '我知道了',
|
||||
cancelText: '稍后再说',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
if (res.confirm && supportWechat) {
|
||||
wx.setClipboardData({
|
||||
data: '28533368',
|
||||
data: supportWechat,
|
||||
success: () => {
|
||||
wx.showToast({ title: '微信号已复制', icon: 'success' })
|
||||
}
|
||||
@@ -1202,15 +1194,18 @@ Page({
|
||||
wx.showToast({ title: '已取消支付', icon: 'none' })
|
||||
} else if (payErr.errMsg && payErr.errMsg.includes('requestPayment:fail')) {
|
||||
// 支付失败,可能是参数错误或权限问题
|
||||
const supportWechat = app.globalData.supportWechat || ''
|
||||
wx.showModal({
|
||||
title: '支付失败',
|
||||
content: '微信支付暂不可用,请添加客服微信(28533368)手动购买',
|
||||
confirmText: '复制微信号',
|
||||
content: supportWechat
|
||||
? `微信支付暂不可用,请添加客服微信(${supportWechat})手动购买`
|
||||
: '微信支付暂不可用,请稍后重试或联系管理员',
|
||||
confirmText: supportWechat ? '复制微信号' : '我知道了',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
if (res.confirm && supportWechat) {
|
||||
wx.setClipboardData({
|
||||
data: '28533368',
|
||||
data: supportWechat,
|
||||
success: () => wx.showToast({ title: '微信号已复制', icon: 'success' })
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,9 +63,8 @@ Page({
|
||||
posterReferralLink: '',
|
||||
posterNickname: '',
|
||||
posterNicknameInitial: '',
|
||||
posterCaseCount: 62,
|
||||
|
||||
},
|
||||
posterCaseCount: 62
|
||||
},
|
||||
|
||||
onLoad() {
|
||||
this.setData({ statusBarHeight: app.globalData.statusBarHeight })
|
||||
@@ -94,28 +93,17 @@ Page({
|
||||
// 生成邀请码
|
||||
const referralCode = userInfo.referralCode || 'SOUL' + (userInfo.id || Date.now().toString(36)).toUpperCase().slice(-6)
|
||||
|
||||
console.log('[Referral] 开始加载分销数据,userId:', userInfo.id)
|
||||
|
||||
// 从API获取真实数据
|
||||
let realData = null
|
||||
try {
|
||||
// app.request 第一个参数是 URL 字符串(会自动拼接 baseUrl)
|
||||
const res = await app.request('/api/miniprogram/referral/data?userId=' + userInfo.id)
|
||||
console.log('[Referral] API返回:', JSON.stringify(res).substring(0, 200))
|
||||
|
||||
if (res && res.success && res.data) {
|
||||
realData = res.data
|
||||
console.log('[Referral] ✅ 获取推广数据成功')
|
||||
console.log('[Referral] - bindingCount:', realData.bindingCount)
|
||||
console.log('[Referral] - paidCount:', realData.paidCount)
|
||||
console.log('[Referral] - earnings:', realData.earnings)
|
||||
console.log('[Referral] - expiringCount:', realData.stats?.expiringCount)
|
||||
} else {
|
||||
console.log('[Referral] ❌ API返回格式错误:', res?.error || 'unknown')
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('[Referral] ❌ API调用失败:', e.message || e)
|
||||
console.log('[Referral] 错误详情:', e)
|
||||
console.warn('[Referral] 加载分销数据失败:', e && e.message ? e.message : e)
|
||||
}
|
||||
|
||||
// 使用真实数据或默认值
|
||||
@@ -123,15 +111,9 @@ Page({
|
||||
let convertedBindings = realData?.convertedUsers || []
|
||||
let expiredBindings = realData?.expiredUsers || []
|
||||
|
||||
console.log('[Referral] activeBindings:', activeBindings.length)
|
||||
console.log('[Referral] convertedBindings:', convertedBindings.length)
|
||||
console.log('[Referral] expiredBindings:', expiredBindings.length)
|
||||
|
||||
// 计算即将过期的数量(7天内)
|
||||
const expiringCount = realData?.stats?.expiringCount || activeBindings.filter(b => b.daysRemaining <= 7 && b.daysRemaining > 0).length
|
||||
|
||||
console.log('[Referral] expiringCount:', expiringCount)
|
||||
|
||||
// 计算各类统计
|
||||
const bindingCount = realData?.bindingCount || activeBindings.length
|
||||
const paidCount = realData?.paidCount || convertedBindings.length
|
||||
@@ -153,7 +135,6 @@ Page({
|
||||
purchaseCount: user.purchaseCount || 0,
|
||||
conversionDate: user.conversionDate ? this.formatDate(user.conversionDate) : '--'
|
||||
}
|
||||
console.log('[Referral] 格式化用户:', formatted.nickname, formatted.status, formatted.daysRemaining + '天')
|
||||
return formatted
|
||||
}
|
||||
|
||||
@@ -169,15 +150,6 @@ Page({
|
||||
const availableEarningsNum = Math.max(0, totalCommissionNum - withdrawnNum - pendingWithdrawNum)
|
||||
const minWithdrawAmount = realData?.minWithdrawAmount || 10
|
||||
|
||||
console.log('=== [Referral] 收益计算(完整版)===')
|
||||
console.log('累计佣金 (totalCommission):', totalCommissionNum)
|
||||
console.log('已提现金额 (withdrawnEarnings):', withdrawnNum)
|
||||
console.log('待审核金额 (pendingWithdrawAmount):', pendingWithdrawNum)
|
||||
console.log('可提现金额 = 累计 - 已提现 - 待审核 =', totalCommissionNum, '-', withdrawnNum, '-', pendingWithdrawNum, '=', availableEarningsNum)
|
||||
console.log('最低提现金额 (minWithdrawAmount):', minWithdrawAmount)
|
||||
console.log('按钮判断:', availableEarningsNum, '>=', minWithdrawAmount, '=', availableEarningsNum >= minWithdrawAmount)
|
||||
console.log('✅ 按钮应该:', availableEarningsNum >= minWithdrawAmount ? '🟢 启用(绿色)' : '⚫ 禁用(灰色)')
|
||||
|
||||
const hasWechatId = !!(userInfo?.wechat || userInfo?.wechatId || wx.getStorageSync('user_wechat'))
|
||||
this.setData({
|
||||
isLoggedIn: true,
|
||||
@@ -233,21 +205,6 @@ Page({
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
console.log('[Referral] ✅ 数据设置完成')
|
||||
console.log('[Referral] - 绑定中:', this.data.bindingCount)
|
||||
console.log('[Referral] - 即将过期:', this.data.expiringCount)
|
||||
console.log('[Referral] - 收益:', this.data.earnings)
|
||||
|
||||
console.log('=== [Referral] 按钮状态验证 ===')
|
||||
console.log('累计佣金 (totalCommission):', this.data.totalCommission)
|
||||
console.log('待审核金额 (pendingWithdrawAmount):', this.data.pendingWithdrawAmount)
|
||||
console.log('可提现金额 (availableEarnings 显示):', this.data.availableEarnings)
|
||||
console.log('可提现金额 (availableEarningsNum 判断):', this.data.availableEarningsNum, typeof this.data.availableEarningsNum)
|
||||
console.log('最低提现金额 (minWithdrawAmount):', this.data.minWithdrawAmount, typeof this.data.minWithdrawAmount)
|
||||
console.log('按钮启用条件:', this.data.availableEarningsNum, '>=', this.data.minWithdrawAmount, '=', this.data.availableEarningsNum >= this.data.minWithdrawAmount)
|
||||
console.log('✅ 最终结果: 按钮应该', this.data.availableEarningsNum >= this.data.minWithdrawAmount ? '🟢 启用' : '⚫ 禁用')
|
||||
|
||||
// 隐藏加载提示
|
||||
wx.hideLoading()
|
||||
} else {
|
||||
|
||||
@@ -13,8 +13,8 @@ Page({
|
||||
loading: false,
|
||||
searched: false,
|
||||
total: 0,
|
||||
// 热门搜索关键词
|
||||
hotKeywords: ['私域', '电商', '流量', '赚钱', '创业', 'Soul', '抖音', '变现'],
|
||||
// 热门搜索关键词(运行时根据热门章节/目录动态生成)
|
||||
hotKeywords: [],
|
||||
// 热门章节推荐
|
||||
hotChapters: [
|
||||
{ id: '1.1', title: '荷包:电动车出租的被动收入模式', tag: '免费', part: '真实的人' },
|
||||
@@ -47,13 +47,36 @@ Page({
|
||||
part: c.part_title || c.partTitle || c.part || '',
|
||||
tag: ['免费', '热门', '推荐', '最新'][i % 4] || '热门'
|
||||
}))
|
||||
this.setData({ hotChapters })
|
||||
this.setData({
|
||||
hotChapters,
|
||||
hotKeywords: this.buildHotKeywords(hotChapters)
|
||||
})
|
||||
} else {
|
||||
this.setData({ hotKeywords: this.buildHotKeywords(app.globalData.bookData || []) })
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('加载热门章节失败,使用默认数据')
|
||||
this.setData({ hotKeywords: this.buildHotKeywords(app.globalData.bookData || []) })
|
||||
}
|
||||
},
|
||||
|
||||
buildHotKeywords(sourceList) {
|
||||
const words = []
|
||||
const pushWord = (word) => {
|
||||
const w = (word || '').trim()
|
||||
if (!w || w.length < 2 || words.includes(w)) return
|
||||
words.push(w)
|
||||
}
|
||||
|
||||
;(sourceList || []).forEach((item) => {
|
||||
const title = String(item.title || '').replace(/[||::,.,。!!??]/g, ' ')
|
||||
const part = String(item.part || '').replace(/[||::,.,。!!??]/g, ' ')
|
||||
title.split(/\s+/).forEach(pushWord)
|
||||
part.split(/\s+/).forEach(pushWord)
|
||||
})
|
||||
|
||||
return words.slice(0, 8)
|
||||
},
|
||||
|
||||
// 输入关键词
|
||||
onInput(e) {
|
||||
this.setData({ keyword: e.detail.value })
|
||||
@@ -99,7 +122,6 @@ Page({
|
||||
this.setData({ results: [], total: 0 })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('搜索失败:', e)
|
||||
wx.showToast({ title: '搜索失败', icon: 'none' })
|
||||
this.setData({ results: [], total: 0 })
|
||||
} finally {
|
||||
|
||||
@@ -245,85 +245,66 @@ Page({
|
||||
}
|
||||
},
|
||||
|
||||
// 获取微信头像(新版授权)
|
||||
async getWechatAvatar() {
|
||||
// 微信原生 chooseAvatar 回调
|
||||
async onChooseAvatar(e) {
|
||||
const tempAvatarUrl = e.detail?.avatarUrl
|
||||
if (!tempAvatarUrl) return
|
||||
wx.showLoading({ title: '上传中...', mask: true })
|
||||
try {
|
||||
const res = await wx.getUserProfile({
|
||||
desc: '用于完善会员资料'
|
||||
})
|
||||
|
||||
if (res.userInfo) {
|
||||
const { nickName, avatarUrl: tempAvatarUrl } = res.userInfo
|
||||
|
||||
wx.showLoading({ title: '上传中...', mask: true })
|
||||
|
||||
// 1. 先上传图片到服务器
|
||||
console.log('[Settings] 开始上传头像:', tempAvatarUrl)
|
||||
|
||||
const uploadRes = await new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: app.globalData.baseUrl + '/api/miniprogram/upload',
|
||||
filePath: tempAvatarUrl,
|
||||
name: 'file',
|
||||
formData: {
|
||||
folder: 'avatars'
|
||||
},
|
||||
success: (uploadResult) => {
|
||||
try {
|
||||
const data = JSON.parse(uploadResult.data)
|
||||
if (data.success) {
|
||||
resolve(data)
|
||||
} else {
|
||||
reject(new Error(data.error || '上传失败'))
|
||||
}
|
||||
} catch (err) {
|
||||
reject(new Error('解析响应失败'))
|
||||
}
|
||||
},
|
||||
fail: (err) => {
|
||||
reject(err)
|
||||
const uploadRes = await new Promise((resolve, reject) => {
|
||||
wx.uploadFile({
|
||||
url: app.globalData.baseUrl + '/api/miniprogram/upload',
|
||||
filePath: tempAvatarUrl,
|
||||
name: 'file',
|
||||
formData: { folder: 'avatars' },
|
||||
success: (uploadResult) => {
|
||||
try {
|
||||
const data = JSON.parse(uploadResult.data)
|
||||
if (data.success) resolve(data)
|
||||
else reject(new Error(data.error || '上传失败'))
|
||||
} catch (err) {
|
||||
reject(new Error('解析响应失败'))
|
||||
}
|
||||
})
|
||||
},
|
||||
fail: reject
|
||||
})
|
||||
|
||||
// 2. 获取上传后的完整URL
|
||||
const avatarUrl = app.globalData.baseUrl + uploadRes.data.url
|
||||
console.log('[Settings] 头像上传成功:', avatarUrl)
|
||||
|
||||
// 3. 更新本地
|
||||
this.setData({
|
||||
userInfo: {
|
||||
...this.data.userInfo,
|
||||
nickname: nickName,
|
||||
avatar: avatarUrl
|
||||
}
|
||||
})
|
||||
|
||||
const rawUrl = uploadRes.data.url || ''
|
||||
const avatarUrl = rawUrl.startsWith('http://') || rawUrl.startsWith('https://')
|
||||
? rawUrl
|
||||
: app.globalData.baseUrl + rawUrl
|
||||
const nickname = this.data.userInfo?.nickname || app.globalData.userInfo?.nickname || ''
|
||||
|
||||
this.setData({
|
||||
userInfo: {
|
||||
...this.data.userInfo,
|
||||
nickname,
|
||||
avatar: avatarUrl
|
||||
}
|
||||
})
|
||||
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (userId) {
|
||||
await app.request('/api/miniprogram/user/profile', {
|
||||
method: 'POST',
|
||||
data: { userId, nickname, avatar: avatarUrl }
|
||||
})
|
||||
|
||||
// 4. 同步到服务器数据库
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (userId) {
|
||||
await app.request('/api/miniprogram/user/profile', {
|
||||
method: 'POST',
|
||||
data: { userId, nickname: nickName, avatar: avatarUrl }
|
||||
})
|
||||
}
|
||||
|
||||
// 5. 更新全局
|
||||
if (app.globalData.userInfo) {
|
||||
app.globalData.userInfo.nickname = nickName
|
||||
app.globalData.userInfo.avatar = avatarUrl
|
||||
wx.setStorageSync('userInfo', app.globalData.userInfo)
|
||||
}
|
||||
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '头像更新成功', icon: 'success' })
|
||||
}
|
||||
|
||||
if (app.globalData.userInfo) {
|
||||
app.globalData.userInfo.avatar = avatarUrl
|
||||
wx.setStorageSync('userInfo', app.globalData.userInfo)
|
||||
}
|
||||
|
||||
wx.hideLoading()
|
||||
wx.showToast({ title: '头像更新成功', icon: 'success' })
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
console.error('[Settings] 获取头像失败:', e)
|
||||
wx.showToast({
|
||||
title: e.message || '获取头像失败',
|
||||
icon: 'none'
|
||||
console.error('[Settings] 更新头像失败:', e)
|
||||
wx.showToast({
|
||||
title: e.message || '上传失败,请重试',
|
||||
icon: 'none'
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import accessManager from '../../utils/chapterAccessManager'
|
||||
const accessManager = require('../../utils/chapterAccessManager')
|
||||
const app = getApp()
|
||||
const { trackClick } = require('../../utils/trackClick')
|
||||
const { checkAndExecute } = require('../../utils/ruleEngine')
|
||||
|
||||
@@ -206,4 +206,4 @@ class ChapterAccessManager {
|
||||
|
||||
// 导出单例
|
||||
const accessManager = new ChapterAccessManager()
|
||||
export default accessManager
|
||||
module.exports = accessManager
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -246,4 +246,4 @@ class ReadingTracker {
|
||||
|
||||
// 导出单例
|
||||
const readingTracker = new ReadingTracker()
|
||||
export default readingTracker
|
||||
module.exports = readingTracker
|
||||
|
||||
@@ -42,7 +42,10 @@ function isInCooldown(ruleId) {
|
||||
const ts = map[ruleId]
|
||||
if (!ts) return false
|
||||
return Date.now() - ts < COOLDOWN_MS
|
||||
} catch { return false }
|
||||
} catch (e) {
|
||||
console.warn('[RuleEngine] 读取冷却状态失败:', e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function setCooldown(ruleId) {
|
||||
@@ -50,7 +53,9 @@ function setCooldown(ruleId) {
|
||||
const map = wx.getStorageSync(RULE_COOLDOWN_KEY) || {}
|
||||
map[ruleId] = Date.now()
|
||||
wx.setStorageSync(RULE_COOLDOWN_KEY, map)
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
console.warn('[RuleEngine] 写入冷却状态失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function getUserInfo() {
|
||||
@@ -66,7 +71,9 @@ async function loadRules() {
|
||||
_cacheTs = Date.now()
|
||||
return _cachedRules
|
||||
}
|
||||
} catch {}
|
||||
} catch (e) {
|
||||
console.warn('[RuleEngine] 加载规则失败,继续使用缓存:', e)
|
||||
}
|
||||
return _cachedRules || []
|
||||
}
|
||||
|
||||
|
||||
1
soul-admin/dist/assets/index-BHf8KXmF.css
vendored
1
soul-admin/dist/assets/index-BHf8KXmF.css
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-DpIZ55qK.css
vendored
Normal file
1
soul-admin/dist/assets/index-DpIZ55qK.css
vendored
Normal file
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<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-A95wVqFr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BHf8KXmF.css">
|
||||
<script type="module" crossorigin src="/assets/index-BI5eNUOf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DpIZ55qK.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
GitMerge,
|
||||
} from 'lucide-react'
|
||||
import { get, post } from '@/api/client'
|
||||
import { clearAdminToken } from '@/api/auth'
|
||||
import { clearAdminToken, getAdminToken } from '@/api/auth'
|
||||
|
||||
// 主菜单(5 项平铺,按 Mycontent-temp 新规范)
|
||||
const primaryMenuItems = [
|
||||
@@ -35,22 +35,33 @@ export function AdminLayout() {
|
||||
if (!mounted) return
|
||||
setAuthChecked(false)
|
||||
let cancelled = false
|
||||
const token = getAdminToken()
|
||||
if (!token) {
|
||||
navigate('/login', { replace: true, state: { from: location.pathname } })
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}
|
||||
get<{ success?: boolean }>('/api/admin')
|
||||
.then((data) => {
|
||||
if (cancelled) return
|
||||
if (data && (data as { success?: boolean }).success !== false) {
|
||||
if (data?.success === true) {
|
||||
setAuthChecked(true)
|
||||
} else {
|
||||
clearAdminToken()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) navigate('/login', { replace: true })
|
||||
if (!cancelled) {
|
||||
clearAdminToken()
|
||||
navigate('/login', { replace: true })
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [mounted, navigate])
|
||||
}, [location.pathname, mounted, navigate])
|
||||
|
||||
const handleLogout = async () => {
|
||||
clearAdminToken()
|
||||
|
||||
@@ -72,7 +72,7 @@ export function AdminUsersPage() {
|
||||
pageSize: String(pageSize),
|
||||
})
|
||||
if (debouncedSearch.trim()) params.set('search', debouncedSearch.trim())
|
||||
const data = await get<ListRes>(`/api/admin/users?${params}`)
|
||||
const data = await get<ListRes>(`/api/admin/admin-users?${params}`)
|
||||
if (data?.success) {
|
||||
setRecords((data as ListRes).records || [])
|
||||
setTotal((data as ListRes).total ?? 0)
|
||||
@@ -130,7 +130,7 @@ export function AdminUsersPage() {
|
||||
setSaving(true)
|
||||
try {
|
||||
if (editingUser) {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/admin/users', {
|
||||
const data = await put<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
|
||||
id: editingUser.id,
|
||||
password: formPassword || undefined,
|
||||
name: formName.trim(),
|
||||
@@ -144,7 +144,7 @@ export function AdminUsersPage() {
|
||||
setError(data?.error || '保存失败')
|
||||
}
|
||||
} else {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/admin/users', {
|
||||
const data = await post<{ success?: boolean; error?: string }>('/api/admin/admin-users', {
|
||||
username: formUsername.trim(),
|
||||
password: formPassword,
|
||||
name: formName.trim(),
|
||||
@@ -168,7 +168,7 @@ export function AdminUsersPage() {
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm('确定删除该管理员?')) return
|
||||
try {
|
||||
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/users?id=${id}`)
|
||||
const data = await del<{ success?: boolean; error?: string }>(`/api/admin/admin-users?id=${id}`)
|
||||
if (data?.success) loadList()
|
||||
else setError(data?.error || '删除失败')
|
||||
} catch (e: unknown) {
|
||||
|
||||
@@ -62,6 +62,13 @@ interface OrdersRes {
|
||||
total?: number
|
||||
}
|
||||
|
||||
function maskPhone(phone?: string) {
|
||||
if (!phone) return ''
|
||||
const digits = phone.replace(/\s+/g, '')
|
||||
if (digits.length < 7) return digits
|
||||
return `${digits.slice(0, 3)}****${digits.slice(-4)}`
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate()
|
||||
const [statsLoading, setStatsLoading] = useState(true)
|
||||
@@ -355,7 +362,7 @@ export function DashboardPage() {
|
||||
) : (
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
)}
|
||||
刷新(每 30 秒自动更新)
|
||||
刷新
|
||||
</button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
@@ -382,6 +389,7 @@ export function DashboardPage() {
|
||||
const buyer =
|
||||
p.userNickname ||
|
||||
users.find((u) => u.id === p.userId)?.nickname ||
|
||||
maskPhone(users.find((u) => u.id === p.userId)?.phone) ||
|
||||
'匿名用户'
|
||||
|
||||
return (
|
||||
@@ -394,7 +402,7 @@ export function DashboardPage() {
|
||||
<img
|
||||
src={normalizeImageUrl(p.userAvatar)}
|
||||
alt={buyer}
|
||||
className="w-9 h-9 rounded-full object-cover flex-shrink-0 mt-0.5"
|
||||
className="w-9 h-9 rounded-full object-cover shrink-0 mt-0.5"
|
||||
onError={(e) => {
|
||||
e.currentTarget.style.display = 'none'
|
||||
const next = e.currentTarget.nextElementSibling as HTMLElement
|
||||
@@ -403,7 +411,7 @@ export function DashboardPage() {
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] flex-shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
|
||||
className={`w-9 h-9 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac] shrink-0 mt-0.5 ${p.userAvatar ? 'hidden' : ''}`}
|
||||
>
|
||||
{buyer.charAt(0)}
|
||||
</div>
|
||||
@@ -418,7 +426,7 @@ export function DashboardPage() {
|
||||
{buyer}
|
||||
</button>
|
||||
<span className="text-gray-600">·</span>
|
||||
<span className="text-sm font-medium text-white truncate">
|
||||
<span className="text-sm font-medium text-white truncate" title={product.title}>
|
||||
{product.title}
|
||||
</span>
|
||||
</div>
|
||||
@@ -443,7 +451,7 @@ export function DashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right ml-4 flex-shrink-0">
|
||||
<div className="text-right ml-4 shrink-0">
|
||||
<p className="text-sm font-bold text-[#38bdac]">
|
||||
+¥{Number(p.amount).toFixed(2)}
|
||||
</p>
|
||||
@@ -482,13 +490,13 @@ export function DashboardPage() {
|
||||
{users
|
||||
.slice(0, 5)
|
||||
.map((u) => (
|
||||
<div
|
||||
<div
|
||||
key={u.id}
|
||||
className="flex items-center justify-between p-4 bg-[#0a1628] rounded-lg border border-gray-700/30"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-[#38bdac]/20 flex items-center justify-center text-sm font-medium text-[#38bdac]">
|
||||
{u.nickname?.charAt(0) || '?'}
|
||||
{(u.nickname || maskPhone(u.phone) || '?').charAt(0)}
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
@@ -496,9 +504,9 @@ export function DashboardPage() {
|
||||
onClick={() => { setDetailUserId(u.id); setShowDetailModal(true) }}
|
||||
className="text-sm font-medium text-[#38bdac] hover:text-[#2da396] hover:underline text-left"
|
||||
>
|
||||
{u.nickname || '匿名用户'}
|
||||
{u.nickname || maskPhone(u.phone) || '匿名用户'}
|
||||
</button>
|
||||
<p className="text-xs text-gray-500">{u.phone || '-'}</p>
|
||||
<p className="text-xs text-gray-500">{maskPhone(u.phone) || '未填写手机号'}</p>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-xs text-gray-400">
|
||||
@@ -580,7 +588,7 @@ export function DashboardPage() {
|
||||
<span className="text-gray-300 truncate mr-2" title={`${item.action}: ${item.target}`}>
|
||||
{item.target || item.action}
|
||||
</span>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<div className="w-16 h-1.5 bg-gray-700 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-[#38bdac] rounded-full"
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Lock, User, ShieldCheck } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { post } from '@/api/client'
|
||||
import { setAdminToken } from '@/api/auth'
|
||||
import { getAdminToken, setAdminToken } from '@/api/auth'
|
||||
|
||||
export function LoginPage() {
|
||||
const navigate = useNavigate()
|
||||
@@ -13,6 +13,12 @@ export function LoginPage() {
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (getAdminToken()) {
|
||||
navigate('/dashboard', { replace: true })
|
||||
}
|
||||
}, [navigate])
|
||||
|
||||
const handleLogin = async () => {
|
||||
setError('')
|
||||
setLoading(true)
|
||||
@@ -66,7 +72,10 @@ export function LoginPage() {
|
||||
<Input
|
||||
type="text"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder="请输入用户名"
|
||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 focus:border-[#38bdac]"
|
||||
/>
|
||||
@@ -80,7 +89,10 @@ export function LoginPage() {
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder="请输入密码"
|
||||
className="pl-10 bg-[#0a1628] border-gray-700 text-white placeholder:text-gray-500 focus:border-[#38bdac]"
|
||||
onKeyDown={(e) => e.key === 'Enter' && handleLogin()}
|
||||
|
||||
@@ -161,7 +161,7 @@ func Load() (*Config, error) {
|
||||
|
||||
version := os.Getenv("APP_VERSION")
|
||||
if version == "" {
|
||||
version = "0.0.0"
|
||||
version = "dev"
|
||||
}
|
||||
|
||||
// 微信配置
|
||||
|
||||
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"soul-api/internal/database"
|
||||
"soul-api/internal/model"
|
||||
@@ -11,9 +12,29 @@ import (
|
||||
|
||||
// AdminChaptersList GET /api/admin/chapters 从 chapters 表组树:part -> chapters -> sections
|
||||
func AdminChaptersList(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageSize, _ := strconv.Atoi(c.DefaultQuery("pageSize", "20"))
|
||||
if pageSize < 1 {
|
||||
pageSize = 20
|
||||
}
|
||||
if pageSize > 200 {
|
||||
pageSize = 200
|
||||
}
|
||||
|
||||
var list []model.Chapter
|
||||
if err := database.DB().Order("sort_order ASC, id ASC").Find(&list).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"structure": []interface{}{}, "stats": nil}})
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
"structure": []interface{}{},
|
||||
"records": []interface{}{},
|
||||
"stats": nil,
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"totalPages": 0,
|
||||
"total": 0,
|
||||
}})
|
||||
return
|
||||
}
|
||||
type section struct {
|
||||
@@ -25,6 +46,19 @@ func AdminChaptersList(c *gin.Context) {
|
||||
EditionStandard *bool `json:"editionStandard,omitempty"`
|
||||
EditionPremium *bool `json:"editionPremium,omitempty"`
|
||||
}
|
||||
type sectionRecord struct {
|
||||
ID string `json:"id"`
|
||||
PartID string `json:"partId"`
|
||||
PartTitle string `json:"partTitle"`
|
||||
ChapterID string `json:"chapterId"`
|
||||
ChapterTitle string `json:"chapterTitle"`
|
||||
Title string `json:"title"`
|
||||
Price float64 `json:"price"`
|
||||
IsFree bool `json:"isFree"`
|
||||
Status string `json:"status"`
|
||||
EditionStandard *bool `json:"editionStandard,omitempty"`
|
||||
EditionPremium *bool `json:"editionPremium,omitempty"`
|
||||
}
|
||||
type chapter struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@@ -38,6 +72,7 @@ func AdminChaptersList(c *gin.Context) {
|
||||
}
|
||||
partMap := make(map[string]*part)
|
||||
chapterMap := make(map[string]map[string]*chapter)
|
||||
records := make([]sectionRecord, 0, len(list))
|
||||
for _, row := range list {
|
||||
if partMap[row.PartID] == nil {
|
||||
partMap[row.PartID] = &part{ID: row.PartID, Title: row.PartTitle, Type: "part", Chapters: []chapter{}}
|
||||
@@ -66,6 +101,19 @@ func AdminChaptersList(c *gin.Context) {
|
||||
ID: row.ID, Title: row.SectionTitle, Price: price, IsFree: isFree, Status: st,
|
||||
EditionStandard: row.EditionStandard, EditionPremium: row.EditionPremium,
|
||||
})
|
||||
records = append(records, sectionRecord{
|
||||
ID: row.ID,
|
||||
PartID: row.PartID,
|
||||
PartTitle: row.PartTitle,
|
||||
ChapterID: row.ChapterID,
|
||||
ChapterTitle: row.ChapterTitle,
|
||||
Title: row.SectionTitle,
|
||||
Price: price,
|
||||
IsFree: isFree,
|
||||
Status: st,
|
||||
EditionStandard: row.EditionStandard,
|
||||
EditionPremium: row.EditionPremium,
|
||||
})
|
||||
}
|
||||
structure := make([]part, 0, len(partMap))
|
||||
for _, p := range partMap {
|
||||
@@ -73,9 +121,29 @@ func AdminChaptersList(c *gin.Context) {
|
||||
}
|
||||
var total int64
|
||||
database.DB().Model(&model.Chapter{}).Count(&total)
|
||||
totalPages := 0
|
||||
if pageSize > 0 {
|
||||
totalPages = (int(total) + pageSize - 1) / pageSize
|
||||
}
|
||||
start := (page - 1) * pageSize
|
||||
if start > len(records) {
|
||||
start = len(records)
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(records) {
|
||||
end = len(records)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"success": true,
|
||||
"data": gin.H{"structure": structure, "stats": gin.H{"totalSections": total}},
|
||||
"data": gin.H{
|
||||
"structure": structure,
|
||||
"records": records[start:end],
|
||||
"stats": gin.H{"totalSections": total},
|
||||
"page": page,
|
||||
"pageSize": pageSize,
|
||||
"totalPages": totalPages,
|
||||
"total": total,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,11 @@ import (
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// AdminAppUsersList GET /api/admin/users 普通用户列表兼容入口(转发到 DBUsersList)
|
||||
func AdminAppUsersList(c *gin.Context) {
|
||||
DBUsersList(c)
|
||||
}
|
||||
|
||||
// AdminUsersList GET /api/admin/users 管理员用户列表(仅 super_admin)
|
||||
func AdminUsersList(c *gin.Context) {
|
||||
claims := middleware.GetAdminClaims(c)
|
||||
|
||||
@@ -125,8 +125,9 @@ func BalanceRechargeConfirm(c *gin.Context) {
|
||||
// POST /api/miniprogram/balance/gift 小程序-代付解锁(用余额帮他人解锁章节)
|
||||
func BalanceGift(c *gin.Context) {
|
||||
var body struct {
|
||||
GiverID string `json:"giverId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
GiverID string `json:"giverId" binding:"required"`
|
||||
SectionID string `json:"sectionId" binding:"required"`
|
||||
PaidViaWechat bool `json:"paidViaWechat"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "参数错误"})
|
||||
@@ -150,46 +151,56 @@ func BalanceGift(c *gin.Context) {
|
||||
}
|
||||
|
||||
var giftCode string
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var bal model.UserBalance
|
||||
if err := tx.Where("user_id = ?", body.GiverID).First(&bal).Error; err != nil || bal.Balance < price {
|
||||
return fmt.Errorf("余额不足,当前 ¥%.2f,需要 ¥%.2f", bal.Balance, price)
|
||||
}
|
||||
code := make([]byte, 16)
|
||||
rand.Read(code)
|
||||
giftCode = hex.EncodeToString(code)
|
||||
|
||||
if err := tx.Model(&bal).Updates(map[string]interface{}{
|
||||
"balance": gorm.Expr("balance - ?", price),
|
||||
"total_gifted": gorm.Expr("total_gifted + ?", price),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
code := make([]byte, 16)
|
||||
rand.Read(code)
|
||||
giftCode = hex.EncodeToString(code)
|
||||
|
||||
tx.Create(&model.GiftUnlock{
|
||||
if body.PaidViaWechat {
|
||||
db.Create(&model.GiftUnlock{
|
||||
GiftCode: giftCode,
|
||||
GiverID: body.GiverID,
|
||||
SectionID: body.SectionID,
|
||||
Amount: price,
|
||||
Status: "pending",
|
||||
})
|
||||
} else {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
var bal model.UserBalance
|
||||
if err := tx.Where("user_id = ?", body.GiverID).First(&bal).Error; err != nil || bal.Balance < price {
|
||||
return fmt.Errorf("余额不足,当前 ¥%.2f,需要 ¥%.2f", bal.Balance, price)
|
||||
}
|
||||
|
||||
tx.Create(&model.BalanceTransaction{
|
||||
UserID: body.GiverID,
|
||||
Type: "gift",
|
||||
Amount: -price,
|
||||
BalanceAfter: bal.Balance - price,
|
||||
SectionID: &body.SectionID,
|
||||
Description: fmt.Sprintf("代付章节 %s (¥%.2f)", body.SectionID, price),
|
||||
if err := tx.Model(&bal).Updates(map[string]interface{}{
|
||||
"balance": gorm.Expr("balance - ?", price),
|
||||
"total_gifted": gorm.Expr("total_gifted + ?", price),
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tx.Create(&model.GiftUnlock{
|
||||
GiftCode: giftCode,
|
||||
GiverID: body.GiverID,
|
||||
SectionID: body.SectionID,
|
||||
Amount: price,
|
||||
Status: "pending",
|
||||
})
|
||||
|
||||
tx.Create(&model.BalanceTransaction{
|
||||
UserID: body.GiverID,
|
||||
Type: "gift",
|
||||
Amount: -price,
|
||||
BalanceAfter: bal.Balance - price,
|
||||
SectionID: &body.SectionID,
|
||||
Description: fmt.Sprintf("代付章节 %s (¥%.2f)", body.SectionID, price),
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{
|
||||
@@ -418,5 +429,47 @@ func BalanceGiftInfo(c *gin.Context) {
|
||||
"amount": gift.Amount,
|
||||
"status": gift.Status,
|
||||
"giverId": gift.GiverID,
|
||||
"mid": chapter.MID,
|
||||
}})
|
||||
}
|
||||
|
||||
// GET /api/miniprogram/balance/gifts?userId=xxx 我的代付列表
|
||||
func BalanceGiftList(c *gin.Context) {
|
||||
userId := c.Query("userId")
|
||||
if userId == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少 userId"})
|
||||
return
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
var gifts []model.GiftUnlock
|
||||
db.Where("giver_id = ?", userId).Order("created_at DESC").Limit(50).Find(&gifts)
|
||||
|
||||
type giftItem struct {
|
||||
GiftCode string `json:"giftCode"`
|
||||
SectionID string `json:"sectionId"`
|
||||
SectionTitle string `json:"sectionTitle"`
|
||||
Amount float64 `json:"amount"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
}
|
||||
|
||||
var result []giftItem
|
||||
for _, g := range gifts {
|
||||
var ch model.Chapter
|
||||
title := g.SectionID
|
||||
if db.Where("id = ?", g.SectionID).First(&ch).Error == nil && ch.SectionTitle != "" {
|
||||
title = ch.SectionTitle
|
||||
}
|
||||
result = append(result, giftItem{
|
||||
GiftCode: g.GiftCode,
|
||||
SectionID: g.SectionID,
|
||||
SectionTitle: title,
|
||||
Amount: g.Amount,
|
||||
Status: g.Status,
|
||||
CreatedAt: g.CreatedAt.Format("2006-01-02 15:04"),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"gifts": result}})
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@ func BookAllChapters(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": []interface{}{}})
|
||||
return
|
||||
}
|
||||
freeIDs := getFreeChapterIDs(db)
|
||||
freeIDs := getEffectiveFreeChapterIDs(db)
|
||||
for i := range list {
|
||||
if freeIDs[list[i].ID] {
|
||||
t := true
|
||||
@@ -112,6 +112,24 @@ func getFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
return ids
|
||||
}
|
||||
|
||||
func getEffectiveFreeChapterIDs(db *gorm.DB) map[string]bool {
|
||||
ids := getFreeChapterIDs(db)
|
||||
var rows []struct {
|
||||
ID string `gorm:"column:id"`
|
||||
}
|
||||
if err := db.Model(&model.Chapter{}).
|
||||
Select("id").
|
||||
Where("is_free = ? OR price = 0", true).
|
||||
Find(&rows).Error; err == nil {
|
||||
for _, row := range rows {
|
||||
if row.ID != "" {
|
||||
ids[row.ID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
// checkUserChapterAccess 判断 userId 是否有权读取 chapterID(VIP / 全书购买 / 单章购买)
|
||||
// isPremium=true 表示增值版,fullbook 买断不含增值版
|
||||
func checkUserChapterAccess(db *gorm.DB, userID, chapterID string, isPremium bool) bool {
|
||||
@@ -587,8 +605,7 @@ func BookStats(c *gin.Context) {
|
||||
db := database.DB()
|
||||
var total int64
|
||||
db.Model(&model.Chapter{}).Count(&total)
|
||||
var freeCount int64
|
||||
db.Model(&model.Chapter{}).Where("is_free = ?", true).Count(&freeCount)
|
||||
freeCount := len(getEffectiveFreeChapterIDs(db))
|
||||
var totalWords struct{ S int64 }
|
||||
db.Model(&model.Chapter{}).Select("COALESCE(SUM(word_count),0) as s").Scan(&totalWords)
|
||||
var userCount int64
|
||||
|
||||
@@ -340,24 +340,13 @@ func CKBIndexLead(c *gin.Context) {
|
||||
// 首页固定使用全局密钥:system_config > .env > 代码内置
|
||||
leadKey := getCkbLeadApiKey()
|
||||
|
||||
// 去重限频:2 分钟内同一用户/手机/微信只能提交一次
|
||||
var cond []string
|
||||
var args []interface{}
|
||||
if body.UserID != "" {
|
||||
cond = append(cond, "user_id = ?")
|
||||
args = append(args, body.UserID)
|
||||
}
|
||||
cond = append(cond, "phone = ?")
|
||||
args = append(args, phone)
|
||||
cutoff := time.Now().Add(-2 * time.Minute)
|
||||
var recentCount int64
|
||||
if db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Where("created_at > ?", cutoff).Count(&recentCount) == nil && recentCount > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "您操作太频繁,请2分钟后再试"})
|
||||
return
|
||||
}
|
||||
// 去重:同一用户只记录一次(首页链接卡若)
|
||||
repeatedSubmit := false
|
||||
var existCount int64
|
||||
repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0
|
||||
if body.UserID != "" {
|
||||
var existCount int64
|
||||
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND source = ?", body.UserID, "index_link_button").Count(&existCount)
|
||||
repeatedSubmit = existCount > 0
|
||||
}
|
||||
|
||||
source := "index_link_button"
|
||||
paramsJSON, _ := json.Marshal(map[string]interface{}{
|
||||
@@ -478,33 +467,12 @@ func CKBLead(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 去重限频:2 分钟内同一用户/手机/微信只能提交一次
|
||||
var cond []string
|
||||
var args []interface{}
|
||||
if body.UserID != "" {
|
||||
cond = append(cond, "user_id = ?")
|
||||
args = append(args, body.UserID)
|
||||
}
|
||||
if phone != "" {
|
||||
cond = append(cond, "phone = ?")
|
||||
args = append(args, phone)
|
||||
}
|
||||
if wechatId != "" {
|
||||
cond = append(cond, "wechat_id = ?")
|
||||
args = append(args, wechatId)
|
||||
}
|
||||
if len(cond) > 0 {
|
||||
cutoff := time.Now().Add(-2 * time.Minute)
|
||||
var recentCount int64
|
||||
if db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Where("created_at > ?", cutoff).Count(&recentCount) == nil && recentCount > 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "message": "您操作太频繁,请2分钟后再试"})
|
||||
return
|
||||
}
|
||||
}
|
||||
// 去重:同一用户对同一目标人物只记录一次(不再限制时间间隔,允许对不同人物立即提交)
|
||||
repeatedSubmit := false
|
||||
if len(cond) > 0 {
|
||||
if body.UserID != "" && body.TargetUserID != "" {
|
||||
var existCount int64
|
||||
repeatedSubmit = db.Model(&model.CkbLeadRecord{}).Where(strings.Join(cond, " OR "), args...).Count(&existCount) == nil && existCount > 0
|
||||
db.Model(&model.CkbLeadRecord{}).Where("user_id = ? AND target_person_id = ?", body.UserID, body.TargetUserID).Count(&existCount)
|
||||
repeatedSubmit = existCount > 0
|
||||
}
|
||||
|
||||
source := strings.TrimSpace(body.Source)
|
||||
|
||||
@@ -323,7 +323,7 @@ func AdminCKBPlans(c *gin.Context) {
|
||||
if keyword != "" {
|
||||
values.Set("keyword", keyword)
|
||||
}
|
||||
planURL := ckbOpenBaseURL + "/v1/plans?" + values.Encode()
|
||||
planURL := ckbOpenBaseURL + "/v1/plan/list?" + values.Encode()
|
||||
req, err := http.NewRequest(http.MethodGet, planURL, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "构造请求失败"})
|
||||
@@ -344,6 +344,16 @@ func AdminCKBPlans(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
code, _ := parsed["code"].(float64)
|
||||
if int(code) != 200 {
|
||||
msg, _ := parsed["msg"].(string)
|
||||
if msg == "" {
|
||||
msg = "存客宝返回异常"
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": msg})
|
||||
return
|
||||
}
|
||||
|
||||
var listAny interface{}
|
||||
if dataVal, ok := parsed["data"].(map[string]interface{}); ok {
|
||||
listAny = dataVal["list"]
|
||||
@@ -359,42 +369,113 @@ func AdminCKBPlans(c *gin.Context) {
|
||||
plans := make([]map[string]interface{}, 0)
|
||||
if arr, ok := listAny.([]interface{}); ok {
|
||||
for _, item := range arr {
|
||||
if m, ok := item.(map[string]interface{}); ok {
|
||||
plans = append(plans, map[string]interface{}{
|
||||
"id": m["id"],
|
||||
"name": m["name"],
|
||||
"apiKey": m["apiKey"],
|
||||
"sceneId": m["sceneId"],
|
||||
"scenario": m["scenario"],
|
||||
"enabled": m["enabled"],
|
||||
"greeting": m["greeting"],
|
||||
"tips": m["tips"],
|
||||
"remarkType": m["remarkType"],
|
||||
"remarkFormat": m["remarkFormat"],
|
||||
"addInterval": m["addInterval"],
|
||||
"startTime": m["startTime"],
|
||||
"endTime": m["endTime"],
|
||||
"deviceGroups": m["deviceGroups"],
|
||||
})
|
||||
m, ok := item.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
reqConf, _ := m["reqConf"].(map[string]interface{})
|
||||
sceneConf, _ := m["sceneConf"].(map[string]interface{})
|
||||
|
||||
enabled := false
|
||||
if sceneConf != nil {
|
||||
if v, ok := sceneConf["enabled"].(bool); ok {
|
||||
enabled = v
|
||||
}
|
||||
}
|
||||
if m["status"] != nil {
|
||||
if s, ok := m["status"].(float64); ok {
|
||||
enabled = int(s) == 1
|
||||
}
|
||||
}
|
||||
|
||||
greeting, _ := mapStr(reqConf, "greeting")
|
||||
tips, _ := mapStr(sceneConf, "tips")
|
||||
remarkType, _ := mapStr(reqConf, "remarkType")
|
||||
remarkFormat, _ := mapStr(reqConf, "remarkFormat")
|
||||
startTime, _ := mapStr(reqConf, "startTime")
|
||||
endTime, _ := mapStr(reqConf, "endTime")
|
||||
addInterval := mapFloat(reqConf, "addFriendInterval")
|
||||
if addInterval == 0 {
|
||||
addInterval = mapFloat(sceneConf, "addInterval")
|
||||
}
|
||||
|
||||
var deviceGroups interface{}
|
||||
if reqConf != nil {
|
||||
deviceGroups = reqConf["device"]
|
||||
}
|
||||
if deviceGroups == nil && sceneConf != nil {
|
||||
deviceGroups = sceneConf["deviceGroups"]
|
||||
}
|
||||
|
||||
plans = append(plans, map[string]interface{}{
|
||||
"id": m["id"],
|
||||
"name": m["name"],
|
||||
"apiKey": m["apiKey"],
|
||||
"sceneId": m["sceneId"],
|
||||
"scenario": m["sceneId"],
|
||||
"enabled": enabled,
|
||||
"greeting": greeting,
|
||||
"tips": tips,
|
||||
"remarkType": remarkType,
|
||||
"remarkFormat": remarkFormat,
|
||||
"addInterval": addInterval,
|
||||
"startTime": startTime,
|
||||
"endTime": endTime,
|
||||
"deviceGroups": deviceGroups,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
total := 0
|
||||
switch tv := parsed["total"].(type) {
|
||||
case float64:
|
||||
total = int(tv)
|
||||
case int:
|
||||
total = tv
|
||||
case string:
|
||||
if n, err := strconv.Atoi(tv); err == nil {
|
||||
total = n
|
||||
if dataVal, ok := parsed["data"].(map[string]interface{}); ok {
|
||||
switch tv := dataVal["total"].(type) {
|
||||
case float64:
|
||||
total = int(tv)
|
||||
case int:
|
||||
total = tv
|
||||
case string:
|
||||
if n, err := strconv.Atoi(tv); err == nil {
|
||||
total = n
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "plans": plans, "total": total})
|
||||
}
|
||||
|
||||
func mapStr(m map[string]interface{}, key string) (string, bool) {
|
||||
if m == nil {
|
||||
return "", false
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return "", false
|
||||
}
|
||||
s, ok := v.(string)
|
||||
return s, ok
|
||||
}
|
||||
|
||||
func mapFloat(m map[string]interface{}, key string) float64 {
|
||||
if m == nil {
|
||||
return 0
|
||||
}
|
||||
v, ok := m[key]
|
||||
if !ok || v == nil {
|
||||
return 0
|
||||
}
|
||||
switch val := v.(type) {
|
||||
case float64:
|
||||
return val
|
||||
case int:
|
||||
return float64(val)
|
||||
case string:
|
||||
if n, err := strconv.ParseFloat(val, 64); err == nil {
|
||||
return n
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// AdminCKBPlanDetail GET /api/admin/ckb/plan-detail?planId=xxx 管理端-存客宝获客计划详情
|
||||
func AdminCKBPlanDetail(c *gin.Context) {
|
||||
planIDStr := c.Query("planId")
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"soul-api/internal/model"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetPublicDBConfig GET /api/miniprogram/config 公开接口,供小程序获取完整配置(与 next-project 对齐)
|
||||
@@ -228,7 +229,7 @@ func AdminSettingsGet(c *gin.Context) {
|
||||
}
|
||||
case "oss_config":
|
||||
if m, ok := val.(map[string]interface{}); ok && len(m) > 0 {
|
||||
out["ossConfig"] = m
|
||||
out["ossConfig"] = sanitizeOSSConfig(m)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -284,6 +285,7 @@ func AdminSettingsPost(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
if body.OssConfig != nil {
|
||||
body.OssConfig = mergeOSSConfigSecret(db, body.OssConfig)
|
||||
if err := saveKey("oss_config", "阿里云 OSS 配置", body.OssConfig); err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "保存 OSS 配置失败: " + err.Error()})
|
||||
return
|
||||
@@ -1206,6 +1208,49 @@ func DBConfigDelete(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
func sanitizeOSSConfig(cfg map[string]interface{}) gin.H {
|
||||
out := gin.H{}
|
||||
for k, v := range cfg {
|
||||
out[k] = v
|
||||
}
|
||||
if secret, ok := out["accessKeySecret"].(string); ok && secret != "" {
|
||||
out["accessKeySecret"] = "****"
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func mergeOSSConfigSecret(db *gorm.DB, incoming map[string]interface{}) map[string]interface{} {
|
||||
if incoming == nil {
|
||||
return incoming
|
||||
}
|
||||
secret, _ := incoming["accessKeySecret"].(string)
|
||||
if secret != "" && secret != "****" {
|
||||
return incoming
|
||||
}
|
||||
|
||||
var row model.SystemConfig
|
||||
if err := db.Where("config_key = ?", "oss_config").First(&row).Error; err != nil {
|
||||
return incoming
|
||||
}
|
||||
|
||||
var existing map[string]interface{}
|
||||
if err := json.Unmarshal(row.ConfigValue, &existing); err != nil {
|
||||
return incoming
|
||||
}
|
||||
|
||||
existingSecret, _ := existing["accessKeySecret"].(string)
|
||||
if existingSecret == "" {
|
||||
return incoming
|
||||
}
|
||||
|
||||
merged := map[string]interface{}{}
|
||||
for k, v := range incoming {
|
||||
merged[k] = v
|
||||
}
|
||||
merged["accessKeySecret"] = existingSecret
|
||||
return merged
|
||||
}
|
||||
|
||||
// DBInitGet GET /api/db/init
|
||||
func DBInitGet(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "data": gin.H{"message": "ok"}})
|
||||
|
||||
@@ -74,7 +74,9 @@ func DBPersonSave(c *gin.Context) {
|
||||
existing.Name = body.Name
|
||||
existing.Aliases = body.Aliases
|
||||
existing.Label = body.Label
|
||||
existing.CkbApiKey = body.CkbApiKey
|
||||
if strings.TrimSpace(body.CkbApiKey) != "" {
|
||||
existing.CkbApiKey = body.CkbApiKey
|
||||
}
|
||||
existing.Greeting = body.Greeting
|
||||
existing.Tips = body.Tips
|
||||
existing.RemarkType = body.RemarkType
|
||||
@@ -99,27 +101,20 @@ func DBPersonSave(c *gin.Context) {
|
||||
} else {
|
||||
existing.DeviceGroups = ""
|
||||
}
|
||||
db.Save(&existing)
|
||||
if err := db.Save(&existing).Error; err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "person": existing})
|
||||
return
|
||||
}
|
||||
|
||||
// 新增:创建本地 Person 记录前,先在存客宝创建获客计划并获取 planId + apiKey
|
||||
// 新增:先创建本地人物记录,存客宝同步失败不阻断 @人物 与内容管理主链路
|
||||
tok, err := genPersonToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "生成 token 失败"})
|
||||
return
|
||||
}
|
||||
|
||||
// 1. 获取开放 API token
|
||||
openToken, err := ckbOpenGetToken()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 构造创建计划请求体
|
||||
// 参考 Cunkebao createPlan:name, sceneId, scenario, remarkType, greeting, addInterval, startTime, endTime, enabled, tips, deviceGroups
|
||||
name := fmt.Sprintf("SOUL链接人与事-%s", body.Name)
|
||||
addInterval := 1
|
||||
if body.AddFriendInterval != nil && *body.AddFriendInterval > 0 {
|
||||
@@ -139,52 +134,18 @@ func DBPersonSave(c *gin.Context) {
|
||||
deviceIDs = append(deviceIDs, id)
|
||||
}
|
||||
}
|
||||
planPayload := map[string]interface{}{
|
||||
"name": name,
|
||||
"sceneId": 11,
|
||||
"scenario": 11,
|
||||
"remarkType": body.RemarkType,
|
||||
"greeting": body.Greeting,
|
||||
"addInterval": addInterval,
|
||||
"startTime": startTime,
|
||||
"endTime": endTime,
|
||||
"enabled": true,
|
||||
"tips": body.Tips,
|
||||
"distributionEnabled": false,
|
||||
}
|
||||
if len(deviceIDs) > 0 {
|
||||
planPayload["deviceGroups"] = deviceIDs
|
||||
}
|
||||
|
||||
planID, ckbCreateData, ckbResponse, err := ckbOpenCreatePlan(openToken, planPayload)
|
||||
if err != nil {
|
||||
out := gin.H{"success": false, "error": "创建存客宝计划失败: " + err.Error()}
|
||||
if ckbResponse != nil {
|
||||
out["ckbResponse"] = ckbResponse
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 用 planId 拉计划详情,获取 apiKey
|
||||
apiKey, err := ckbOpenGetPlanDetail(openToken, planID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "创建成功但获取计划密钥失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
newPerson := model.Person{
|
||||
PersonID: body.PersonID,
|
||||
Token: tok,
|
||||
Name: body.Name,
|
||||
Aliases: body.Aliases,
|
||||
Label: body.Label,
|
||||
CkbApiKey: apiKey,
|
||||
CkbPlanID: planID,
|
||||
Greeting: body.Greeting,
|
||||
Tips: body.Tips,
|
||||
RemarkType: body.RemarkType,
|
||||
RemarkFormat: body.RemarkFormat,
|
||||
PersonID: body.PersonID,
|
||||
Token: tok,
|
||||
Name: body.Name,
|
||||
Aliases: body.Aliases,
|
||||
Label: body.Label,
|
||||
CkbApiKey: strings.TrimSpace(body.CkbApiKey),
|
||||
Greeting: body.Greeting,
|
||||
Tips: body.Tips,
|
||||
RemarkType: body.RemarkType,
|
||||
RemarkFormat: body.RemarkFormat,
|
||||
AddFriendInterval: addInterval,
|
||||
StartTime: startTime,
|
||||
EndTime: endTime,
|
||||
@@ -201,7 +162,67 @@ func DBPersonSave(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
resp := gin.H{"success": true, "person": newPerson}
|
||||
|
||||
planPayload := map[string]interface{}{
|
||||
"name": name,
|
||||
"sceneId": 11,
|
||||
"scenario": 11,
|
||||
"remarkType": body.RemarkType,
|
||||
"greeting": body.Greeting,
|
||||
"addInterval": addInterval,
|
||||
"startTime": startTime,
|
||||
"endTime": endTime,
|
||||
"enabled": true,
|
||||
"tips": body.Tips,
|
||||
"distributionEnabled": false,
|
||||
}
|
||||
if len(deviceIDs) > 0 {
|
||||
planPayload["deviceGroups"] = deviceIDs
|
||||
}
|
||||
|
||||
openToken, tokenErr := ckbOpenGetToken()
|
||||
if tokenErr != nil {
|
||||
resp["ckbSyncError"] = tokenErr.Error()
|
||||
resp["message"] = "人物已保存,存客宝同步失败,可稍后补同步"
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
planID, ckbCreateData, ckbResponse, planErr := ckbOpenCreatePlan(openToken, planPayload)
|
||||
if planErr != nil {
|
||||
resp["ckbSyncError"] = "创建存客宝计划失败: " + planErr.Error()
|
||||
if ckbResponse != nil {
|
||||
resp["ckbResponse"] = ckbResponse
|
||||
}
|
||||
resp["message"] = "人物已保存,存客宝同步失败,可稍后补同步"
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
apiKey, detailErr := ckbOpenGetPlanDetail(openToken, planID)
|
||||
if detailErr != nil {
|
||||
db.Model(&model.Person{}).Where("person_id = ?", newPerson.PersonID).Update("ckb_plan_id", planID)
|
||||
newPerson.CkbPlanID = planID
|
||||
resp["person"] = newPerson
|
||||
resp["ckbSyncError"] = "创建成功但获取计划密钥失败: " + detailErr.Error()
|
||||
resp["message"] = "人物已保存,存客宝部分同步成功"
|
||||
if len(ckbCreateData) > 0 {
|
||||
resp["ckbCreateResult"] = ckbCreateData
|
||||
}
|
||||
c.JSON(http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
newPerson.CkbPlanID = planID
|
||||
newPerson.CkbApiKey = apiKey
|
||||
if err := db.Model(&model.Person{}).Where("person_id = ?", newPerson.PersonID).Updates(map[string]interface{}{
|
||||
"ckb_api_key": apiKey,
|
||||
"ckb_plan_id": planID,
|
||||
}).Error; err == nil {
|
||||
resp["person"] = newPerson
|
||||
}
|
||||
if len(ckbCreateData) > 0 {
|
||||
resp["ckbCreateResult"] = ckbCreateData
|
||||
}
|
||||
|
||||
@@ -83,10 +83,11 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
admin.POST("/author-settings", handler.AdminAuthorSettingsPost)
|
||||
admin.PUT("/orders/refund", handler.AdminOrderRefund)
|
||||
admin.POST("/content/upload", handler.AdminContentUpload)
|
||||
admin.GET("/users", handler.AdminUsersList)
|
||||
admin.POST("/users", handler.AdminUsersAction)
|
||||
admin.PUT("/users", handler.AdminUsersAction)
|
||||
admin.DELETE("/users", handler.AdminUsersAction)
|
||||
admin.GET("/users", handler.AdminAppUsersList)
|
||||
admin.GET("/admin-users", handler.AdminUsersList)
|
||||
admin.POST("/admin-users", handler.AdminUsersAction)
|
||||
admin.PUT("/admin-users", handler.AdminUsersAction)
|
||||
admin.DELETE("/admin-users", handler.AdminUsersAction)
|
||||
admin.GET("/orders", handler.OrdersList)
|
||||
admin.GET("/balance/summary", handler.BalanceSummary)
|
||||
admin.GET("/shensheshou/query", handler.AdminShensheShouQuery)
|
||||
@@ -341,6 +342,7 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
miniprogram.POST("/balance/gift", handler.BalanceGift)
|
||||
miniprogram.POST("/balance/gift/redeem", handler.BalanceGiftRedeem)
|
||||
miniprogram.GET("/balance/gift/info", handler.BalanceGiftInfo)
|
||||
miniprogram.GET("/balance/gifts", handler.BalanceGiftList)
|
||||
miniprogram.POST("/balance/refund", handler.BalanceRefund)
|
||||
miniprogram.GET("/balance/transactions", handler.BalanceTransactions)
|
||||
}
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user