This commit is contained in:
Alex-larget
2026-03-24 18:45:32 +08:00
parent dcb7961945
commit f3d74ce94a
68 changed files with 2461 additions and 2535 deletions

View File

@@ -9,3 +9,8 @@
### 当前状态
- 2026-03-20 需求(提现、我的收益、推广设置等)已与实现对齐。
- 主需求、落地推进表已基本同步;项目索引已补齐至 2026-03-24。
## 需求与进度及三端闭环评审
- 区分「用户主路径闭环」与「规则/风控闭环」;后者缺口见《产品意图与功能闭环分析》,用清单驱动排期而非推翻里程碑。
- 待确认VIP 支付前是否强制完善头像昵称(与当前 vip.js 策略二选一)。

View File

@@ -15,3 +15,8 @@
### 待办
- router 补齐users/rfm、users/journey-stats、shensheshou 共 5 个。
- 确认 /api/admin/settings 是否支持 ossConfig。
## 需求与进度及三端闭环评审
- 三端路由分组未被破坏;缺口为业务校验:提现 wechat_id、CKBJoin investor 付费、MatchUsers 发起者资料。
- 失败响应需带 `needBindWechat` / `errorCode` 等稳定字段,配合小程序 `err.response` 约定(详见团队 evolution 与 meeting 纪要)。

View File

@@ -10,3 +10,13 @@
### 落地建议
- 各角色在完成功能开发或吸收经验时,主动更新项目索引。
- 橙子收尾时统一检查并补齐索引。
## 需求与进度及三端闭环评审(跨角色)
### 结论摘要
- **主业务闭环**:阅读/付费/代付/分销/提现主链路在文档与里程碑上成立;**规则闭环**有已知缺口,以《产品意图与功能闭环分析》《需求未补齐清单》为准。
- **三端配套**:小程序页面域与管理端路由域整体对齐;**miniprogram/admin/db 边界未被破坏**。
- **工程契约**:统一「失败时 body 可解析」约定(`err.response`),否则后端 errorCode 设计无法在前端落地。
### 详见
- `.cursor/meeting/2026-03-24_需求与进度及三端闭环评审.md`

View File

@@ -8,3 +8,16 @@
### 技术债
- 富文本渲染rich-text待实施需确认 DB 格式后再推进。
## 需求与进度及三端闭环评审
- `app.request` 在 success:false 时应在 reject 前挂载 `err.response = data`,否则 needBindWechat/errorCode 分支失效。
- 提现my 与 referral 需统一微信号预检与 catch 处理。
## 代码优化落地2026-03-24
- **已实现**`app.js` `_requestOnce``success:false` 与非 401 错误 `reject` 时挂载 `err.response``request` 外层 catch 重抛时保留 `response`
- **获客**`soulBridge.submitCkbLead` 支持 `targetMemberId`/`targetMemberName``read.js` @某人`member-detail` 全局/人物留资统一走 soulBridge`targetNickname` 全局留资允许空串。
- **分销**:阅读页余额/微信下单 `referralCode` 统一 `getReferralCodeForPay`
- **提现**`referral.js` / `my.js``catch` 中识别 `e.response.needBindWechat`(原 success:false 分支实际不可达)。
- **文档**`miniprogram-dev/SKILL.md` 已补充 `err.response` 说明。

View File

@@ -27,6 +27,7 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
| 2026-03-18 | 文档归档整理:以《以界面定需求》为基准,重整需求口径/验收点/分享 singlePage 约束,写入产品经验库 | 已完成 |
| 2026-03-18 | 会议:超级个体开通后自动创建@人与支付前资料引导(头像+昵称) | 已完成 |
| 2026-03-24 | 会议:开发进度同步;项目索引补齐 2026-03-19/20 及之后记录;文档同步原则确认 | 已完成 |
| 2026-03-24 | 会议:需求与进度及三端闭环评审;规则/风控缺口清单化VIP 支付前资料规则待拍板 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置

View File

@@ -40,6 +40,7 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-03-18 | 会议:超级个体开通后自动创建@人Person 绑定 userId 幂等)与资料完善 flags 方案 | 已完成 |
| 2026-03-20 | 提现审批逻辑修复、fail_reason/error_message 落库、referral_config withdrawFee/enableAutoWithdraw | 已完成 |
| 2026-03-24 | 会议开发进度同步项目索引补齐router/ossConfig 待确认 | 已完成 |
| 2026-03-24 | 会议:三端闭环评审;提现/CKB/匹配后端校验与 errorCode 为 P0/P1 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置

View File

@@ -31,9 +31,10 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
| 2026-03-17 | 性能优化与 Redis 缓存方案落地Redis 容灾回退 DB、OSS 上传容灾;/health 返回 database/redis 状态 | 已完成 |
| 2026-03-18 | 吸收经验:分享进入链路需兼容朋友圈 singlePage单页模式不执行支付/自动领取等强动作并引导“前往小程序” | 已完成 |
| 2026-03-18 | 会议:超级个体开通后自动创建@人统一走 Person幂等键绑定 userId默认资料 flags 后端输出 | 已完成 |
| 2026-03-24 | 会议:需求与进度及三端闭环评审;主路径闭环成立、规则闭环待加固;失败响应与 err.response 契约 | 已完成 |
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
---
**最后更新**2026-03-18
**最后更新**2026-03-24

View File

@@ -44,6 +44,8 @@
| 2026-03-19 | 原生按钮覆盖定位cover-view经验入库 | 已完成 |
| 2026-03-20 | 手机号一键登录、login-modal 公用组件read/my/gift-pay 引入);我的收益取 availableEarnings | 已完成 |
| 2026-03-24 | 会议:开发进度同步;项目索引补齐;富文本 rich-text 技术债待续 | 已完成 |
| 2026-03-24 | 会议三端闭环评审app.request err.response提现双入口与后端契约对齐 | 已完成 |
| 2026-03-24 | 落地app.request 挂 err.responsesoulBridge 留资扩展;阅读页带码与 referral/my 提现 catch 对齐 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置

View File

@@ -33,6 +33,7 @@
| 2026-03-18 | 文档归档整理:按界面驱动口径统一验收;补充分享 singlePage 降级与引导为必测项 | 已完成 |
| 2026-03-18 | 会议新增用例资料默认阻断支付、Person 自动创建幂等、昵称变更同步回归) | 已完成 |
| 2026-03-24 | 会议:开发进度同步;项目索引补齐;提现/登录/收益/推广设置用例补充singlePage、getPhoneNumber 边界 | 已完成 |
| 2026-03-24 | 会议三端闭环评审API 绕过负例与双入口提现回归待契约落地后执行 | 已完成 |
---

View File

@@ -45,6 +45,7 @@
| 2026-03-18 | 会议:超级个体开通后自动创建@人;管理端可选展示 userId/来源以便排查重名 | 已完成 |
| 2026-03-20 | 提现审核备注列fail_reason/error_message、自动审批开关推广设置提现手续费 | 已完成 |
| 2026-03-24 | 会议开发进度同步项目索引补齐DistributionPage Order.description 待修 | 已完成 |
| 2026-03-24 | 会议:三端闭环评审;管理端与小程序配套整体齐全;类型债择机修 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置

View File

@@ -8,3 +8,8 @@
### 待办
- DistributionPage Order.description 类型错误待修(与本次迁移无关,有空即修)。
## 需求与进度及三端闭环评审
- 管理端路由域与小程序 Tab/子业务整体配套(审核、配置、导师、匹配、存客宝相关)。
- C 端绕过接口的风险主要靠后端 + 小程序请求层闭环,非管理端菜单能单独解决。

View File

@@ -6,3 +6,8 @@
- 提现审批、手机号登录、我的收益、推广设置、提现审核等新增功能需补充/更新用例。
- 边界必测singlePage 分享进入、getPhoneNumber 隐私协议同意流程。
- 下次回归前完成用例更新。
## 需求与进度及三端闭环评审
- 补齐「仅调 API、绕过前端」负例提现、CKBJoin、MatchUsers。
- 后端与 err.response 契约落地后,做双入口提现(我的 / 推广)回归。

View File

@@ -0,0 +1,117 @@
# 会议纪要 - 2026-03-24 | 需求与进度及三端闭环评审
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-03-24
- **议题**:重新评估需求与开发进度;业务流程是否闭环;核心基础功能是否完整、有无被破坏;小程序与管理端是否配套齐全
- **触发方式**:开个会议 + 分析报告
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
- **主持人**:乘风(老板分身)
---
## 各角色发言
### 【产品经理】
- **需求基线**:《以界面定需求》《项目落地推进表》与代码大体一致;里程碑上阅读付费、代付、分销提现、分润配置等标记为已完成,与当前推进表描述一致。
- **用户价值闭环**C 端「看内容—买章节/VIP—推广收益—提现」主链路在文档层面已闭环**风控与规则闭环**仍有缺口(见《产品意图与功能闭环分析》):提现微信号、资源对接购买校验、匹配发起者资料等依赖后端与请求层配合,否则存在「前端拦得住、接口拦不住」的灰区。
- **待产品拍板**2026-03-18 会议曾提「VIP 支付前需完善头像昵称」与当前 `vip.js`「购买前不拦截」不一致,需明确最终规则并同步三端。
### 【后端开发】
- **路由与分组**`/api/miniprogram/*``/api/admin/*``/api/db/*` 分工清晰导师、余额、提现确认收款、存客宝线索、dashboard 等能力在代码注释与 handler 中可核对,**三端隔离未被破坏**。
- **闭环缺口(实现层)**`WithdrawPost` 未强制校验 `wechat_id``CKBJoin`investor未校验付费`MatchUsers` 未强制发起者联系方式;与《需求未补齐清单》《产品意图与功能闭环分析》一致。
- **建议**:优先 P0 提现校验 + **统一失败体**`errorCode` / `needBindWechat`),便于小程序在 `app.request` reject 后仍能从 `err.response` 分支引导(见文档 6.1 节)。
### 【管理端开发工程师】
- **配套情况**`App.tsx` 路由覆盖 dashboard、orders、users、distribution、withdrawals、content、referral-settings、vip-roles、mentors、mentor-consultations、settings、payment、find-partner、match、match-records 等,与小程序 Tab首页/目录/找伙伴/我的及子业务VIP、导师、代付、推广、提现审核**总体配套齐全**。
- **运营侧闭环**:提现审核、推广设置、内容/用户/订单管理支撑 C 端主链路;存客宝相关能力在管理端有配置入口(如 find-partner / CKB
- **已知技术债**:索引中曾记 `DistributionPage Order.description` 类型问题,与本次「功能被破坏」无直接等价关系,但影响类型安全与维护。
### 【小程序开发工程师】
- **页面与能力**`app.json` 注册页覆盖阅读、章节、找伙伴、我的、推广、代付、VIP、导师、资料、钱包、提现记录等与后端 miniprogram 接口及管理端运营能力**方向一致**。
- **闭环断裂点(工程层)**`app.request``success: false` 时直接 reject导致 `needBindWechat``errorCode` 等字段在部分页面成为**死分支**`my``referral` 提现前对微信号要求不一致。
- **技术债**TipTap HTML → `rich-text`@mention 仍为待确认/待实现项(需求未补齐清单)。
### 【测试人员】
- **回归重点**:在补齐后端校验与 `err.response` 后,需增加「绕过前端直接调 API」的负例提现含我的页与推广页两条入口、资源对接、找伙伴提交各一条闭环用例。
- **三端联调**:管理端改推广/手续费/自动提现开关后,小程序侧分润展示与提现流程需对照 `scripts/test/功能测试流程.md` 做一轮回归。
---
## 讨论过程
- **【管理端开发工程师】回复【产品经理】**:管理端菜单已覆盖审核与配置,不单独挡 C 端绕过接口的风险;规则闭环必须后端 + 小程序请求层一致。
- **【后端开发】回复【小程序开发工程师】**:仅加后端校验不够,若 reject 不携带 body前端无法引导建议约定 `err.response` 与文档 6.16.4 的调整一致后再测。
- **【产品经理】回复【全员】**:「核心基础完整」指主链路可用;「未被破坏」指三端边界与里程碑仍在;当前缺口属**加固与一致性**,不推翻已完成里程碑,但应列入下一迭代 P0/P1。
---
## 会议决议
1. **总评**:核心业务闭环在「产品—运营—用户主路径」上成立;**安全与规则闭环**存在已知缺口,以《产品意图与功能闭环分析》《需求未补齐清单》为执行清单,**不构成「核心功能整段被删」类事故**。
2. **三端配套**:小程序主要场景与管理端审核/配置模块**整体配套**;后端 miniprogram/admin/db 分组保持,**无互窜型破坏**。
3. **优先动作**P0 提现 `wechat_id` 后端校验 + 小程序 `app.request` 失败时挂载 `err.response`P1 CKBJoin / MatchUsers 校验与 `errorCode`;产品确认 VIP 支付前是否拦截头像昵称。
4. **文档**:本评审结论与同日《开发进度同步会议》互补;详细技术项仍以 `开发文档/10、项目管理/` 下两份分析文档为准。
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 后端开发 | WithdrawPost 无 wechat_id 返回 needBindWechatCKBJoin/MatchUsers 按文档补校验与 errorCode | P0/P1 | 下一迭代首周 |
| 小程序开发工程师 | app.request reject 前设置 err.response统一 my/referral 提现微信号与 catch 分支 | P0 | 与后端同学对齐后 |
| 产品经理 | 确认 VIP 支付前是否必须完善头像昵称,并更新需求口径 | 中 | 本周 |
| 测试人员 | 补齐绕过前端的 API 负例与双入口提现用例 | 中 | 联调后 |
| 管理端开发工程师 | DistributionPage 类型债择机修复 | 低 | 排期内 |
---
## 问题与作答区
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | VIP超级个体支付前是否强制完善头像+昵称? | 产品经理 | (待补充) |
| 2 | 富文本正文在 DB 中的存储格式是否已冻结HTML 片段 / JSON | 产品经理 + 后端开发 | (待补充) |
| 3 | ossConfig、router 扩展项是否仍阻塞环境切换? | 后端开发 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 闭环分两层:用户动线闭环 vs 规则/风控闭环;当前缺口集中在后者,应用清单文档驱动排期。
### 后端开发
- 失败响应需兼顾「HTTP 层」与「业务体字段」,便于小程序在统一 request 封装下做引导。
### 管理端开发工程师
- 配套评估以路由与业务域对照小程序页面域,存客宝/找伙伴/匹配记录等已覆盖运营侧。
### 小程序开发工程师
- reject 若不携带 body所有「按字段分支」的后端设计都会落空与后端约定同一错误契约。
### 测试人员
- 闭环测试需覆盖「仅调 API」场景与纯 UI 点击路径同等重要。
### 团队共享
- 三端边界未被破坏;当前工作重点是**契约加固**(校验 + errorCode + err.response与产品规则二选一VIP 前资料)。
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-24.md`*

View File

@@ -81,3 +81,4 @@ YYYY-MM-DD_会议主题.md
| 2026-03-17 | 性能优化与 Redis 缓存方案落地 | 后端、管理端、小程序、测试、助理橙子 | [2026-03-17_性能优化与Redis缓存方案落地.md](2026-03-17_性能优化与Redis缓存方案落地.md) |
| 2026-03-18 | 超级个体开通后自动创建@人与资料引导 | 产品、后端、管理端、小程序、测试 | [2026-03-18_超级个体开通后自动创建@人与资料引导.md](2026-03-18_超级个体开通后自动创建@人与资料引导.md) |
| 2026-03-24 | 开发进度同步会议(查看代码与开发文档对齐) | 产品、后端、管理端、小程序、测试 | [2026-03-24_开发进度同步会议.md](2026-03-24_开发进度同步会议.md) |
| 2026-03-24 | 需求与进度及三端闭环评审(业务闭环、核心功能、小程序与管理端配套) | 产品、后端、管理端、小程序、测试 | [2026-03-24_需求与进度及三端闭环评审.md](2026-03-24_需求与进度及三端闭环评审.md) |

View File

@@ -8,6 +8,12 @@ alwaysApply: false
在 **miniprogram/**、**soul-admin/** 或 **soul-api/** 下做任何**修改、优化、新增**后,必须按下列项过一遍,确认关联层已同步,避免只改一端导致数据不一致或功能缺管理入口。
## 零漏改原则(强制)
- 任一端出现**新功能/新字段/新文案/新状态/新交互**,必须联想另外两端是否需要补齐。
- 验收至少满足四项闭环:**DB 可存 + API 可读写 + 管理端可配置/可管理 + 小程序可渲染/可使用**。
- 禁止只在小程序硬编码可运营内容(如角标、标签、按钮文案);默认应有管理端入口或配置来源。
## 一、按「你改了什么」对表检查
| 你改的是… | 必须同时检查/修改的关联 |

View File

@@ -10,3 +10,5 @@
- Skill 优化assistant-doc-sync 新增 sync-log 记忆机制、assets 模板、Skill 撰写原则
- 会议收尾:开发进度同步会议;项目索引五角色补齐;纪要 `.cursor/meeting/2026-03-24_开发进度同步会议.md`
- 会议收尾:需求与进度及三端闭环评审;纪要 `.cursor/meeting/2026-03-24_需求与进度及三端闭环评审.md`;经验追加至各角色 evolution 与项目索引
- 小程序app.request err.response 落地soulBridge 留资统一 read/member-detail阅读页 getReferralCodeForPayreferral/my 提现 catchminiprogram-dev SKILL 更新

View File

@@ -22,6 +22,16 @@ description: Soul 创业派对变更关联检查。miniprogram/soul-admin/soul-a
## 2. 核心思路:按「改动点」扫关联层
### 2.0 强制补齐原则(新增)
- 任一端(小程序 / 管理端 / 后端)出现**新展示、新字段、新交互、新文案、新状态**,必须主动联想另外两端是否需要补齐。
- **禁止只修当前端**就结束:必须补到「可运营、可配置、可联调」闭环。
- 典型漏项(必须防):
- 小程序新增角标/标签文案,但管理端没有输入入口;
- 管理端新增配置项,但小程序未读取该配置;
- 后端新增字段,但前端未映射、未展示、未提交。
- 验收口径:至少满足 **数据可存DB+ 接口可读写API+ 管理端可改Admin+ 小程序可渲染MP** 四项。
### 2.1 改了前端(小程序或 soul-admin
- **新增/改了展示或提交的字段**
@@ -32,6 +42,7 @@ description: Soul 创业派对变更关联检查。miniprogram/soul-admin/soul-a
- **小程序**新增了一个**完整功能**(如新活动、新入口)
- soul-api 的 **miniprogram** 下是否需要新接口?需要则加。
- **管理端**是否需要**配置、开关、审核、统计**?需要则在 soul-admin 加页面/菜单,并在 soul-api 的 **admin/db** 下加对应接口。
- 若涉及 UI 文案(如角标、按钮文案、标题),优先补到管理端可配置项;不可硬编码在小程序。
### 2.2 改了 soul-api接口、model、表

View File

@@ -24,7 +24,7 @@ description: Soul 创业派对小程序开发规范。在 miniprogram/ 下编辑
- 正确:`app.request('/api/miniprogram/book/all-chapters')``app.request('/api/miniprogram/login', { method: 'POST', data: { code } })`
- 错误:`app.request('/api/book/all-chapters')``app.request('/api/admin/xxx')``/api/vip/status`(若 soul-api 未提供 miniprogram 下等价接口则视为错误,需统一走 miniprogram 组)。
- **静默请求**:不弹窗的请求(如推荐访问、统计)传 `silent: true``app.request(url, { ..., silent: true })`
- **错误处理**`request` 已统一处理 200 且 `success: false`、401、4xx/5xx 与网络错误;页面只需 `try/catch` `.then/.catch`,用 `_getApiErrorMsg` 的文案即可,勿重复造轮子
- **错误处理**`request` 已统一处理 200 且 `success: false`、401、4xx/5xx 与网络错误;失败时 `reject` `Error` 上会挂 **`err.response`**(即接口返回的 JSON 体),便于页面按 `needBindWechat``errorCode` 等字段分支。页面 `catch (e)` 中优先读 `e.response` 再回退 `e.message`
---

View File

@@ -18,6 +18,15 @@ description: Soul 创业派对开发协同流程。小程序开发工程师、
**开发风格约定**:各角色在各自端内开发时,**必须**按上表对应的 Skill 与 boundary 执行,保持代码风格、目录结构、命名与接口约定一致。不得跨路径调用(详见 soul-project-boundary
### 1.1 跨端补齐责任(新增)
- 三方工程师(小程序/管理端/后端)对同一功能负有**联想补齐**责任,不得只完成自己这一端。
- 任一端新增「用户可见能力」(角标、文案、状态、入口、规则)时,需主动检查并补齐:
- 后端:是否有可持久化字段与读写接口;
- 管理端:是否有可运营输入或管理入口;
- 小程序:是否按接口/配置渲染而非硬编码。
- 默认交付口径:**可配置、可运营、可联调、可验收**,否则视为未完成。
---
## 2. 协同流程总览(线框图)
@@ -189,7 +198,7 @@ sequenceDiagram
| 阶段 | 动作 |
|------|------|
| **需求分析(关键)** | 分析该功能在管理端是否需要:<br/>• 字段调整(列表/表单新增或修改)<br/>• 配置页、开关、审核、统计<br/>• 输出「需要」或「不需要」及具体项 |
| **需求分析(关键)** | 分析该功能在管理端是否需要:<br/>• 字段调整(列表/表单新增或修改)<br/>• 配置页、开关、审核、统计<br/>• 输出「需要」或「不需要」及具体项(如角标文案需有输入入口) |
| 并行开发 | **暂不开发**,等待小程序完成 |
| 管理端启动 | 小程序变更完成后,若需要则开始管理端调整 |
| 收尾 | 参与联调,过 soul-change-checklist |

View File

@@ -9,7 +9,9 @@ const { checkAndExecute } = require('./utils/ruleEngine.js')
const DEFAULT_APP_ID = 'wxb8bbb2b10dec74aa'
const DEFAULT_MCH_ID = '1318592501'
const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
const PRODUCTION_BASE_URL = 'https://soulapi.quwanzhi.com'
// baseUrl 手动切换(注释方式):
const API_BASE_URL = 'http://localhost:8080'
// const API_BASE_URL = 'https://soulapi.quwanzhi.com'
const CONFIG_CACHE_KEY = 'mpConfigCacheV1'
// 与上传版本号对齐;设置页展示优先用 wx.getAccountInfoSync().miniProgram.version正式版否则用本字段
const APP_DISPLAY_VERSION = '1.7.2'
@@ -19,8 +21,8 @@ App({
// 与微信后台上传版本号一致,供设置页等展示(避免与线上 version 字段混淆)
appDisplayVersion: APP_DISPLAY_VERSION,
// API仓库默认生产release 强制生产develop/trial 可读 storage「apiBaseUrl」或用 env-switch
baseUrl: 'https://soulapi.quwanzhi.com',
// API仅使用代码常量手动切换,不再使用运行时开关
baseUrl: API_BASE_URL,
// 小程序配置 - 真实AppID
appId: DEFAULT_APP_ID,
@@ -98,39 +100,17 @@ App({
lastVipContactCheck: 0,
// 头像昵称检测:上次检测时间戳(与 VIP 检测同周期刷新)
lastAvatarNicknameCheck: 0,
// 登录后规则引擎触发时间(用于与本地引导去重,避免短时间二次弹窗)
lastAfterLoginRuleCheck: 0,
/** MBTI → 默认头像 URL/api/miniprogram/config/mbti-avatars供推广海报等 */
mbtiAvatarsMap: {},
mbtiAvatarsExpires: 0,
},
/** 正式版强制生产 API避免误传 localhost 导致审核/线上全挂 */
initApiBaseUrl() {
const KEY = 'apiBaseUrl'
try {
const info = wx.getAccountInfoSync?.()
const env = info?.miniProgram?.envVersion || 'release'
if (env === 'release') {
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
const saved = wx.getStorageSync(KEY)
if (saved && saved !== PRODUCTION_BASE_URL) wx.removeStorageSync(KEY)
} catch (_) {}
return
}
const saved = wx.getStorageSync(KEY)
if (saved && typeof saved === 'string' && /^https?:\/\//.test(saved)) {
this.globalData.baseUrl = String(saved).replace(/\/$/, '')
} else {
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
} catch (_) {
this.globalData.baseUrl = PRODUCTION_BASE_URL
}
},
onLaunch(options) {
this.initApiBaseUrl()
// baseUrl 固定取 API_BASE_URL通过注释切换
this.globalData.baseUrl = API_BASE_URL
// 昵称等隐私组件需先授权input type="nickname" 不会主动触发,需配合 wx.requirePrivacyAuthorize 使用
if (typeof wx.onNeedPrivacyAuthorization === 'function') {
wx.onNeedPrivacyAuthorization((resolve) => {
@@ -616,7 +596,12 @@ App({
const isVip = vipRes?.data?.isVip || this.globalData.isVip || false
this.globalData.isVip = isVip
if (!isVip) {
this.checkAvatarNicknameAndGuide()
const now = Date.now()
const lastRuleCheck = Number(this.globalData.lastAfterLoginRuleCheck || 0)
// 登录后若规则引擎刚触发过 after_login引导由规则引擎负责避免与本地提示连弹
if (!lastRuleCheck || now - lastRuleCheck > 5000) {
this.checkAvatarNicknameAndGuide()
}
return
}
@@ -933,27 +918,6 @@ App({
return (msg && String(msg).trim()) ? String(msg).trim() : defaultMsg
},
_shouldFallbackToProduction(err) {
const msg = String((err && err.errMsg) || (err && err.message) || '').toLowerCase()
return (
msg.includes('connection_failed') ||
msg.includes('err_proxy_connection_failed') ||
msg.includes('dns') ||
msg.includes('name not resolved') ||
msg.includes('failed to fetch') ||
msg.includes('econnrefused') ||
msg.includes('network') ||
msg.includes('timeout')
)
},
_switchBaseUrlToProduction() {
this.globalData.baseUrl = PRODUCTION_BASE_URL
try {
wx.setStorageSync('apiBaseUrl', PRODUCTION_BASE_URL)
} catch (_) {}
},
_requestOnce(url, options = {}, silent = false) {
const showError = (msg) => {
if (!silent && msg) wx.showToast({ title: msg, icon: 'none', duration: 2500 })
@@ -972,6 +936,11 @@ App({
},
success: (res) => {
const data = res.data
const rejectWithBody = (message, body) => {
const err = new Error(message)
if (body != null && typeof body === 'object') err.response = body
reject(err)
}
if (res.statusCode === 200) {
if (data && data.success === false) {
const msg = this._getApiErrorMsg(data, '操作失败')
@@ -979,7 +948,7 @@ App({
this.logout()
}
showError(msg)
reject(new Error(msg))
rejectWithBody(msg, data)
return
}
resolve(data)
@@ -988,12 +957,14 @@ App({
if (res.statusCode === 401) {
this.logout()
showError('未授权,请重新登录')
reject(new Error('未授权'))
const err = new Error('未授权')
if (data != null && typeof data === 'object') err.response = data
reject(err)
return
}
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
reject(new Error(msg))
rejectWithBody(msg, data && typeof data === 'object' ? data : undefined)
},
fail: (err) => {
const msg = (err && err.errMsg)
@@ -1033,13 +1004,10 @@ App({
}
const promise = this._requestOnce(url, options, silent).catch(async (err) => {
const currentBase = String(this.globalData.baseUrl || '').replace(/\/$/, '')
if (currentBase !== PRODUCTION_BASE_URL && this._shouldFallbackToProduction(err)) {
this._switchBaseUrlToProduction()
return this._requestOnce(url, options, silent)
}
const msg = (err && err.message) ? err.message : '网络异常,请重试'
throw new Error(msg)
const next = new Error(msg)
if (err && err.response != null) next.response = err.response
throw next
})
if (method === 'GET') {
@@ -1102,8 +1070,9 @@ App({
this.globalData.vipExpireDate = user.vipExpireDate || ''
// 首次登录注册:强制跳转 avatar-nickname 修改头像昵称(不弹窗)
if (res.isNewUser === true && this._needsAvatarNickname(user)) {
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000)
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname?from=new_user' }), 1000)
} else {
this.globalData.lastAfterLoginRuleCheck = Date.now()
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
setTimeout(() => this.connectWsHeartbeat(), 2000)
@@ -1173,8 +1142,9 @@ App({
this.globalData.vipExpireDate = user.vipExpireDate || ''
// 首次登录注册:强制跳转 avatar-nickname
if (res.isNewUser === true && this._needsAvatarNickname(user)) {
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000)
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname?from=new_user' }), 1000)
} else {
this.globalData.lastAfterLoginRuleCheck = Date.now()
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
}
@@ -1234,8 +1204,9 @@ App({
// 首次登录注册:强制跳转 avatar-nickname
if (res.isNewUser === true && this._needsAvatarNickname(user)) {
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname' }), 1000)
setTimeout(() => wx.redirectTo({ url: '/pages/avatar-nickname/avatar-nickname?from=new_user' }), 1000)
} else {
this.globalData.lastAfterLoginRuleCheck = Date.now()
checkAndExecute('after_login', null)
setTimeout(() => this.checkVipContactRequiredAndGuide(), 1200)
}

View File

@@ -1,82 +0,0 @@
/**
* 开发环境专用:可拖拽的 baseURL 切换悬浮按钮
* 正式环境release不显示
*/
const PRODUCTION_URL = 'https://soulapi.quwanzhi.com'
const STORAGE_KEY = 'apiBaseUrl'
const POSITION_KEY = 'envSwitchPosition'
const URL_OPTIONS = [
{ label: '生产', url: PRODUCTION_URL },
{ label: '本地', url: 'http://localhost:8080' },
{ label: '测试', url: 'https://souldev.quwanzhi.com' },
]
Component({
data: {
visible: false,
x: 20,
y: 120,
currentLabel: '生产',
areaWidth: 375,
areaHeight: 812,
},
lifetimes: {
attached() {
try {
const accountInfo = wx.getAccountInfoSync?.()
const envVersion = accountInfo?.miniProgram?.envVersion || 'release'
if (envVersion === 'release') {
return
}
const sys = wx.getSystemInfoSync?.() || {}
const areaWidth = sys.windowWidth || 375
const areaHeight = sys.windowHeight || 812
const saved = wx.getStorageSync(POSITION_KEY)
const pos = saved ? JSON.parse(saved) : { x: 20, y: 120 }
// 与 app.js 一致storage 优先,否则用 globalData已按 env 自动切换)
const current = wx.getStorageSync(STORAGE_KEY) || getApp().globalData?.baseUrl || PRODUCTION_URL
const opt = URL_OPTIONS.find(o => o.url === current) || URL_OPTIONS[0]
this.setData({
visible: true,
x: pos.x ?? 20,
y: pos.y ?? 120,
currentLabel: opt.label,
areaWidth,
areaHeight,
})
} catch (_) {
this.setData({ visible: false })
}
},
},
methods: {
onTap() {
const items = URL_OPTIONS.map(o => o.label)
const current = wx.getStorageSync(STORAGE_KEY) || PRODUCTION_URL
const idx = URL_OPTIONS.findIndex(o => o.url === current)
wx.showActionSheet({
itemList: items,
success: (res) => {
const opt = URL_OPTIONS[res.tapIndex]
wx.setStorageSync(STORAGE_KEY, opt.url)
const app = getApp()
if (app && app.globalData) {
app.globalData.baseUrl = opt.url
}
this.setData({ currentLabel: opt.label })
wx.showToast({ title: `已切到${opt.label}`, icon: 'none', duration: 1500 })
},
})
},
onMovableChange(e) {
const { x, y } = e.detail
if (typeof x === 'number' && typeof y === 'number') {
wx.setStorageSync(POSITION_KEY, JSON.stringify({ x, y }))
}
},
},
})

View File

@@ -1,3 +0,0 @@
{
"component": true
}

View File

@@ -1,13 +0,0 @@
<movable-area wx:if="{{visible}}" class="env-area" style="width:{{areaWidth}}px;height:{{areaHeight}}px;">
<movable-view
class="env-btn"
direction="all"
inertia
x="{{x}}"
y="{{y}}"
bindchange="onMovableChange"
bindtap="onTap"
>
<view class="env-btn-inner">{{currentLabel}}</view>
</movable-view>
</movable-area>

View File

@@ -1,30 +0,0 @@
.env-area {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
pointer-events: none;
z-index: 9999;
}
.env-btn {
width: 72rpx;
height: 72rpx;
pointer-events: auto;
}
.env-btn-inner {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #22c55e 0%, #16a34a 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 22rpx;
font-weight: 600;
color: #fff;
box-shadow: 0 4rpx 12rpx rgba(34, 197, 94, 0.4);
border: 2rpx solid rgba(255, 255, 255, 0.3);
}

View File

@@ -3,6 +3,7 @@
* 登录后若仍为默认头像/昵称,在此修改;仅头像与昵称两项
*/
const app = getApp()
const { trackClick } = require('../../utils/trackClick')
Page({
data: {
@@ -14,15 +15,21 @@ Page({
nicknameInputFocus: false,
/** 规则引擎传入avatar | nickname用于高亮对应区块 */
uiFocus: '',
fromNewUser: false,
},
onLoad(options) {
this.setData({ statusBarHeight: app.globalData.statusBarHeight || 44 })
const fromNewUser = String(options.from || '').toLowerCase() === 'new_user'
const focus = String(options.focus || '').toLowerCase()
if (focus === 'avatar' || focus === 'nickname') {
this.setData({ uiFocus: focus })
}
this.setData({ fromNewUser })
this.loadFromUser()
if (fromNewUser) {
trackClick('avatar_nickname', 'page_view', '新注册引导页')
}
if (focus === 'nickname') {
setTimeout(() => {
if (typeof wx.requirePrivacyAuthorize === 'function') {
@@ -92,6 +99,7 @@ Page({
async onChooseAvatar(e) {
const tempAvatarUrl = e.detail?.avatarUrl
if (!tempAvatarUrl) return
trackClick('avatar_nickname', 'btn_click', '选择头像')
await this.uploadAndSaveAvatar(tempAvatarUrl)
},
@@ -132,6 +140,9 @@ Page({
}
wx.hideLoading()
wx.showToast({ title: '头像已更新', icon: 'success' })
if (this.data.fromNewUser) {
trackClick('avatar_nickname', 'form_step_done', '头像更新完成')
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '上传失败', icon: 'none' })
@@ -150,6 +161,7 @@ Page({
wx.showToast({ title: '请输入昵称', icon: 'none' })
return
}
trackClick('avatar_nickname', 'btn_click', '完成保存')
this.setData({ saving: true })
try {
await app.request({
@@ -162,6 +174,13 @@ Page({
if (avatar) app.globalData.userInfo.avatar = avatar
wx.setStorageSync('userInfo', app.globalData.userInfo)
}
if (this.data.fromNewUser) {
wx.setStorageSync('new_user_guide_done_at', Date.now())
trackClick('avatar_nickname', 'form_submit', '新注册引导完成', {
hasAvatar: !!avatar,
nicknameLen: nickname.length,
})
}
wx.showToast({ title: '保存成功', icon: 'success' })
setTimeout(() => getApp().goBackOrToHome(), 800)
} catch (e) {
@@ -171,6 +190,7 @@ Page({
},
goToFullProfile() {
trackClick('avatar_nickname', 'nav_click', '编辑完整档案')
wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
},
})

View File

@@ -10,10 +10,12 @@
<view class="content">
<view class="guide-card">
<icon name="handshake" size="64" color="#00CED1" customClass="guide-icon"></icon>
<text class="guide-title">设置对外展示信息</text>
<text class="guide-title">{{fromNewUser ? '欢迎加入,先完成基础信息' : '设置对外展示信息'}}</text>
<text class="guide-badge" wx:if="{{fromNewUser}}">新用户引导</text>
<text class="guide-desc" wx:if="{{uiFocus === 'avatar'}}">请先换一张清晰头像,伙伴更容易认出你。</text>
<text class="guide-desc" wx:elif="{{uiFocus === 'nickname'}}">请改一个真实好记的昵称,方便伙伴称呼你。</text>
<text class="guide-desc" wx:else>头像与昵称会出现在名片与匹配卡片上,方便伙伴认出你。</text>
<text class="guide-desc guide-desc-sub" wx:if="{{fromNewUser}}">完成后可继续编辑完整档案手机号、行业、MBTI 等)。</text>
</view>
<!-- 头像:点击直接弹出微信原生选择器;头像与文字水平对齐 -->

View File

@@ -64,11 +64,25 @@
color: #5EEAD4;
margin-bottom: 12rpx;
}
.guide-badge {
margin-bottom: 10rpx;
padding: 6rpx 16rpx;
font-size: 20rpx;
color: #22d3ee;
border-radius: 999rpx;
border: 1rpx solid rgba(34, 211, 238, 0.4);
background: rgba(34, 211, 238, 0.1);
}
.guide-desc {
font-size: 26rpx;
color: rgba(148, 163, 184, 0.95);
line-height: 1.5;
}
.guide-desc-sub {
margin-top: 8rpx;
color: rgba(148, 163, 184, 0.85);
font-size: 24rpx;
}
.avatar-section {
display: flex;

View File

@@ -54,7 +54,8 @@ Page({
// mp_config.mpUi.chaptersPage
chaptersBookTitle: '一场SOUL的创业实验场',
chaptersBookSubtitle: '来自Soul派对房的真实商业故事'
chaptersBookSubtitle: '来自Soul派对房的真实商业故事',
chaptersNewBadgeText: 'NEW'
},
onLoad() {
@@ -71,13 +72,20 @@ Page({
_applyChaptersMpUi() {
const c = app.globalData.configCache?.mpConfig?.mpUi?.chaptersPage || {}
const h = app.globalData.configCache?.mpConfig?.mpUi?.homePage || {}
const newBadgeText = String(c.newBadgeText || c.sectionNewBadgeText || h.latestSectionTitle || 'NEW').trim() || 'NEW'
this.setData({
chaptersBookTitle: String(c.bookTitle || '一场SOUL的创业实验场').trim() || '一场SOUL的创业实验场',
chaptersBookSubtitle: String(c.bookSubtitle || '来自Soul派对房的真实商业故事').trim() ||
'来自Soul派对房的真实商业故事'
'来自Soul派对房的真实商业故事',
chaptersNewBadgeText: newBadgeText
})
},
_normalizeBadgeText(v) {
return String(v || '').trim().slice(0, 8)
},
async loadFeatureConfig() {
try {
if (app.globalData.features && typeof app.globalData.features.searchEnabled === 'boolean') {
@@ -122,10 +130,14 @@ Page({
let icon = String(p.icon || '').trim()
if (icon && !isSafeImageSrc(icon)) icon = ''
const iconEmoji = icon ? '' : partEmojiForBodyIndex(idx)
const partBadgeText = this._normalizeBadgeText(
p.badgeText || p.badge_text || p.partBadgeText || p.part_badge_text
)
return {
id: p.id,
icon,
iconEmoji,
iconText: partBadgeText,
title: p.title,
subtitle: p.subtitle || '',
chapterCount: p.chapterCount || 0,
@@ -176,6 +188,9 @@ Page({
isFree: r.isFree === true || (r.price !== undefined && r.price === 0),
price: r.price ?? 1,
isNew: r.isNew === true || r.is_new === true,
newBadgeText: this._normalizeBadgeText(
r.newBadgeText || r.new_badge_text || r.sectionBadgeText || r.section_badge_text || r.badgeText || r.badge_text
),
isPremium
})
})

View File

@@ -73,6 +73,7 @@
<view class="part-header" bindtap="togglePart" data-id="{{item.id}}">
<view class="part-left">
<image wx:if="{{item.icon}}" class="part-icon-img" src="{{item.icon}}" mode="aspectFill"/>
<view wx:elif="{{item.iconText}}" class="part-icon part-icon-text">{{item.iconText}}</view>
<view wx:elif="{{item.iconEmoji}}" class="part-icon part-icon-emoji">{{item.iconEmoji}}</view>
<view wx:else class="part-icon">{{item.title[0] || '篇'}}</view>
<view class="part-info">
@@ -101,7 +102,7 @@
<icon wx:else name="lock" size="24" color="rgba(255,255,255,0.3)" customClass="section-lock lock-closed"></icon>
</view>
<text class="section-title {{section.isFree || isVip || (!section.isPremium && hasFullBook) || purchasedSections.indexOf(section.id) > -1 ? '' : 'text-muted'}}">{{section.id}} {{section.title}}</text>
<text wx:if="{{section.isNew}}" class="tag tag-new">NEW</text>
<text wx:if="{{section.isNew || section.newBadgeText}}" class="tag tag-new">{{section.newBadgeText || chaptersNewBadgeText}}</text>
</view>
<view class="section-right">
<text wx:if="{{section.isFree}}" class="tag tag-free">免费</text>

View File

@@ -380,6 +380,13 @@
color: #ffffff;
}
.part-icon-text {
font-size: 22rpx;
font-weight: 700;
letter-spacing: 1rpx;
line-height: 1;
}
.part-subtitle {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.4);

View File

@@ -8,6 +8,7 @@
* 点头像:登录后依次校验本人头像(非默认)、微信号、绑定手机号,再弹「链接「昵称」」;有 ckbLeadToken 走人物获客计划,否则走全局留资
*/
const app = getApp()
const soulBridge = require('../../utils/soulBridge.js')
const { trackClick } = require('../../utils/trackClick')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { resolveAvatarWithMbti } = require('../../utils/mbtiAvatar.js')
@@ -334,45 +335,13 @@ Page({
/** 无人物 token 时:全局留资,便于运营侧主动加好友并协助链接该会员 */
async _doGlobalMemberLeadSubmit(member) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) return
const myUserId = app.globalData.userInfo.id
let { phone, wechatId } = await this._resolveLeadPhoneWechat(myUserId)
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于工作人员联系您、协助链接该超级个体。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetNickname: '',
targetMemberId: member.id || undefined,
targetMemberName: (member.name || '').trim() || undefined,
source: 'member_detail_global',
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,请留意工作人员联系', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
await soulBridge.submitCkbLead(app, {
targetMemberId: String(member.id || ''),
targetMemberName: (member.name || '').trim(),
targetNickname: '',
source: 'member_detail_global',
phoneModalContent: '请填写手机号(必填),便于工作人员联系您、协助链接该超级个体。',
})
},
async _resolveLeadPhoneWechat(myUserId) {
@@ -390,58 +359,16 @@ Page({
return { phone, wechatId }
},
/** 与 read 页 _doMentionAddFriend 一致:targetUserId = Person.token可选带超级个体 userId 写入留资 params */
/** targetUserId = Person.token可选带超级个体 id/name 写入留资 params(与 read 页 @ 同 soulBridge */
async _doCkbLeadSubmit(targetUserId, targetNickname, targetMemberId, targetMemberName) {
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.switchTab({ url: '/pages/my/my' }) }
})
return
}
const myUserId = app.globalData.userInfo.id
let { phone, wechatId } = await this._resolveLeadPhoneWechat(myUserId)
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于对方通过获客计划联系您。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => { if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' }) }
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
targetMemberId: targetMemberId || undefined,
targetMemberName: targetMemberName || undefined,
source: 'member_detail_avatar'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
await soulBridge.submitCkbLead(app, {
targetUserId,
targetNickname,
targetMemberId: targetMemberId || undefined,
targetMemberName: targetMemberName || undefined,
source: 'member_detail_avatar',
phoneModalContent: '请填写手机号(必填),便于对方通过获客计划联系您。',
})
},
_ensureUnlockedForLink(field) {

View File

@@ -1054,7 +1054,17 @@ Page({
this.loadWalletBalance()
} catch (e) {
wx.hideLoading()
wx.showToast({ title: e.message || '提现失败', icon: 'none' })
const resp = e && e.response
if (resp && (resp.needBind || resp.needBindWechat)) {
wx.showModal({
title: resp.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
content: resp.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (r) => { if (r.confirm) wx.navigateTo({ url: '/pages/settings/settings' }) }
})
return
}
wx.showToast({ title: (e && e.message) || '提现失败', icon: 'none' })
}
}
})

View File

@@ -140,6 +140,15 @@
.save-btn[disabled] { opacity: 0.6; }
.bottom-space { height: 120rpx; }
/* 分享图绘制用 canvas仅用于离屏生成不在页面可见 */
.share-card-canvas {
position: fixed;
left: -9999px;
top: -9999px;
opacity: 0;
pointer-events: none;
}
/* 昵称提示文案 */
.input-tip {
margin-top: 8rpx;
@@ -234,93 +243,7 @@
color: #9CA3AF;
}
/* 昵称隐私弹窗 */
.privacy-mask {
position: fixed;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.privacy-modal {
width: 560rpx;
padding: 48rpx 40rpx;
background: #1E293B;
border-radius: 24rpx;
text-align: center;
}
.privacy-modal .privacy-title { font-size: 34rpx; font-weight: 600; color: #fff; margin-bottom: 24rpx; }
.privacy-modal .privacy-desc { font-size: 28rpx; color: #94A3B8; line-height: 1.6; margin-bottom: 40rpx; }
.privacy-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: #5EEAD4;
color: #0F172A;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
border: none;
}
/* 隐私授权弹窗 */
.privacy-mask {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.6);
z-index: 9999;
display: flex;
align-items: center;
justify-content: center;
}
.privacy-modal {
width: 580rpx;
background: #1E293B;
border-radius: 24rpx;
padding: 40rpx 32rpx 32rpx;
}
.privacy-modal-title {
font-size: 34rpx;
font-weight: 600;
color: #F8FAFC;
margin-bottom: 24rpx;
text-align: center;
}
.privacy-modal-desc {
font-size: 28rpx;
color: #94A3B8;
line-height: 1.5;
margin-bottom: 32rpx;
}
.privacy-btn {
width: 100%;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: #5EEAD4;
color: #0F172A;
font-size: 32rpx;
font-weight: 600;
border-radius: 44rpx;
margin-bottom: 16rpx;
}
.privacy-btn:last-of-type {
margin-bottom: 0;
background: transparent;
color: #94A3B8;
border: 2rpx solid #475569;
}
/* 昵称隐私授权弹窗(解决 errno:104 */
/* 昵称隐私授权弹窗(单一定义,避免样式冲突) */
.privacy-mask {
position: fixed; inset: 0; background: rgba(0,0,0,0.7); z-index: 9999;
display: flex; align-items: center; justify-content: center; padding: 48rpx;

View File

@@ -19,6 +19,7 @@ const { parseScene } = require('../../utils/scene.js')
const contentParser = require('../../utils/contentParser.js')
const { trackClick } = require('../../utils/trackClick')
const { checkAndExecute } = require('../../utils/ruleEngine')
const soulBridge = require('../../utils/soulBridge.js')
const app = getApp()
@@ -729,71 +730,14 @@ Page({
})
},
// 边界:未登录→去登录;无手机/微信号→去资料编辑;重复同一人→本地 key 去重
// 正文 @:统一走 soulBridge登录/手机号校验/提交与错误提示一处维护)
async _doMentionAddFriend(targetUserId, targetNickname) {
const app = getApp()
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
wx.showModal({
title: '提示',
content: '请先登录后再添加好友',
confirmText: '去登录',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.switchTab({ url: '/pages/my/my' })
}
})
return
}
const myUserId = app.globalData.userInfo.id
let phone = (app.globalData.userInfo.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
let wechatId = (app.globalData.userInfo.wechatId || app.globalData.userInfo.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
try {
const profileRes = await app.request({ url: `/api/miniprogram/user/profile?userId=${myUserId}`, silent: true })
if (profileRes?.success && profileRes.data) {
phone = (profileRes.data.phone || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
wechatId = (profileRes.data.wechatId || profileRes.data.wechat_id || wx.getStorageSync('user_wechat') || '').trim()
}
} catch (e) {}
}
if (!phone || !/^1[3-9]\d{9}$/.test(phone)) {
wx.showModal({
title: '补全手机号',
content: '请填写手机号(必填),便于对方联系您。',
confirmText: '去填写',
cancelText: '取消',
success: (res) => {
if (res.confirm) wx.navigateTo({ url: '/pages/profile-edit/profile-edit' })
}
})
return
}
wx.showLoading({ title: '提交中...', mask: true })
try {
const res = await app.request({
url: '/api/miniprogram/ckb/lead',
method: 'POST',
data: {
userId: myUserId,
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
source: 'article_mention'
}
})
wx.hideLoading()
if (res && res.success) {
wx.setStorageSync('lead_last_submit_ts', Date.now())
wx.showToast({ title: res.message || '提交成功,对方会尽快联系您', icon: 'success' })
} else {
wx.showToast({ title: (res && res.message) || '提交失败', icon: 'none' })
}
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
}
await soulBridge.submitCkbLead(getApp(), {
targetUserId,
targetNickname,
source: 'article_mention',
phoneModalContent: '请填写手机号(必填),便于对方联系您。',
})
},
// 分享弹窗
@@ -1245,7 +1189,7 @@ Page({
try {
// 0. 尝试余额支付(若余额足够)
const userId = app.globalData.userInfo?.id
const referralCode = wx.getStorageSync('referral_code') || ''
const referralCode = soulBridge.getReferralCodeForPay(app)
if (userId) {
try {
const balanceRes = await app.request({ url: `/api/miniprogram/balance?userId=${userId}`, silent: true })
@@ -1315,8 +1259,8 @@ Page({
? '《一场Soul的创业实验》全书'
: `章节${sectionId}-${sectionTitle.length > 20 ? sectionTitle.slice(0, 20) + '...' : sectionTitle}`
// 邀请码:谁邀请了我(从落地页 ref 或 storage 带入),会写入订单 referrer_id / referral_code 便于分销与对账
const referralCode = wx.getStorageSync('referral_code') || ''
// 邀请码:与 VIP/钱包一致,优先 storage 落地 ref否则回落本人推荐码便于自购归因
const referralCode = soulBridge.getReferralCodeForPay(app)
const res = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {

View File

@@ -677,7 +677,7 @@ Page({
wx.navigateTo({ url: '/pages/withdraw-records/withdraw-records' })
},
// 执行提现
// 执行提现success:false 时 app.request 会 reject 并挂 e.response需 catch 里处理 needBindWechat
async doWithdraw(amount) {
wx.showLoading({ title: '提现中...' })
@@ -706,24 +706,23 @@ Page({
// 刷新数据(此时待审核金额会增加,可提现金额会减少)
this.initData()
} else {
if (res.needBind || res.needBindWechat) {
wx.showModal({
title: res.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
content: res.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (modalRes) => {
if (modalRes.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
}
})
} else {
wx.showToast({ title: res.message || res.error || '提现失败', icon: 'none', duration: 3000 })
}
}
} catch (e) {
wx.hideLoading()
console.error('[Referral] 提现失败:', e)
wx.showToast({ title: '提现失败,请重试', icon: 'none' })
const resp = e && e.response
if (resp && (resp.needBind || resp.needBindWechat)) {
wx.showModal({
title: resp.needBindWechat ? '请先绑定微信号' : '需要绑定微信',
content: resp.needBindWechat ? '请到「设置」中绑定微信号后再提现,便于到账核对。' : '请先在设置中绑定微信账号后再提现',
confirmText: '去绑定',
success: (modalRes) => {
if (modalRes.confirm) wx.navigateTo({ url: '/pages/settings/settings' })
}
})
return
}
wx.showToast({ title: (e && e.message) || '提现失败,请重试', icon: 'none' })
}
},

View File

@@ -54,8 +54,14 @@ function stripTrailingAtForMention(before) {
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
*/
function parseBlockToSegments(block) {
function parseBlockToSegments(block, config) {
const segs = []
const normalize = s => (s || '').trim().toLowerCase()
const personTokenSet = new Set()
for (const p of ((config && config.persons) || [])) {
const token = normalize((p && p.token) || '')
if (token) personTokenSet.add(token)
}
// 合并匹配所有内联元素
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|<img[^>]*\/?>/gi
let lastEnd = 0
@@ -78,8 +84,12 @@ function parseBlockToSegments(block) {
const userId = idMatch ? idMatch[1].trim() : ''
let nickname = labelMatch ? labelMatch[1] : innerText.replace(/^[@]\s*/, '')
nickname = cleanMentionNickname((nickname || '').trim())
if (userId || nickname) {
const userExists = !!normalize(userId) && personTokenSet.has(normalize(userId))
if (userExists && nickname) {
segs.push({ type: 'mention', userId, nickname, mentionDisplay: '@' + nickname })
} else if (nickname) {
// 被 @ 人物不存在时降级为普通文本,保持“静态 @某人”展示
segs.push({ type: 'text', text: '@' + nickname })
}
} else if (/data-type="linkTag"/i.test(tag)) {
@@ -155,7 +165,7 @@ function parseHtmlToSegments(html, config) {
for (const block of blocks) {
if (!block.trim()) continue
let blockSegs = parseBlockToSegments(block)
let blockSegs = parseBlockToSegments(block, config)
if (!blockSegs.length) continue
// 纯图片行独立成段

View File

@@ -59,16 +59,20 @@ function syncOrderStatusQuery(app, orderSn) {
/**
* 提交存客宝 lead与阅读页 @、会员详情点头像同接口)
* @param {object} app getApp()
* @param {{ targetUserId: string, targetNickname?: string, source: string, phoneModalContent?: string }} opts
* @param {{ targetUserId?: string, targetNickname?: string, targetMemberId?: string, targetMemberName?: string, source: string, phoneModalContent?: string }} opts
* @returns {Promise<boolean>} 是否提交成功
*/
async function submitCkbLead(app, opts) {
const targetUserId = (opts && opts.targetUserId) || ''
const targetNickname = ((opts && opts.targetNickname) || 'TA').trim() || 'TA'
const targetUserId = ((opts && opts.targetUserId) || '').trim()
const targetMemberId = ((opts && opts.targetMemberId) || '').trim()
let targetNickname = (opts && opts.targetNickname != null) ? String(opts.targetNickname).trim() : ''
if (targetUserId && !targetNickname) targetNickname = 'TA'
const targetMemberName = ((opts && opts.targetMemberName) || '').trim()
const source = (opts && opts.source) || 'article_mention'
const phoneModalContent = (opts && opts.phoneModalContent) || '请先填写手机号(必填),以便对方联系您'
if (!targetUserId) return false
// 文章 @ 为 token会员详情无 token 时用 targetMemberId 走全局获客计划(与后端 CKBLead 一致)
if (!targetUserId && !targetMemberId) return false
if (!app.globalData.isLoggedIn || !app.globalData.userInfo) {
return await new Promise((resolve) => {
@@ -124,8 +128,10 @@ async function submitCkbLead(app, opts) {
phone: phone || undefined,
wechatId: wechatId || undefined,
name: (app.globalData.userInfo.nickname || '').trim() || undefined,
targetUserId,
targetNickname: targetNickname || undefined,
targetUserId: targetUserId || undefined,
targetNickname: targetNickname !== '' ? targetNickname : undefined,
targetMemberId: targetMemberId || undefined,
targetMemberName: targetMemberName || undefined,
source
}
})
@@ -141,7 +147,9 @@ async function submitCkbLead(app, opts) {
return false
} catch (e) {
wx.hideLoading()
wx.showToast({ title: (e && e.message) || '提交失败', icon: 'none' })
const resp = e && e.response
const hint = (resp && (resp.message || resp.error)) || (e && e.message) || '提交失败'
wx.showToast({ title: String(hint), icon: 'none' })
return false
}
}

View File

@@ -0,0 +1,165 @@
# 测试报告 - 2026-03-24本地核心功能场景
## 1. 测试概览
| 项目 | 内容 |
|------|------|
| 执行日期 | 2026-03-24 |
| 测试环境 | local |
| API 地址 | `http://localhost:8080` |
| 执行人 | 测试工程师AI |
| 覆盖范围 | 分销、支付、获客(@某人/CKB)、匹配;管理端/小程序/API 核心链路 |
---
## 2. 执行结果总览
| 测试集 | 结果 | 结论 |
|------|------|------|
| `pytest -q` 全量 | 18 total / 16 passed / 2 skipped / 0 failed | 通过 |
| 管理端路由冒烟(鉴权) | 119 路由404/500/异常=0写接口成功=16 | 有风险项 |
| 管理端路由冒烟(未鉴权) | 404/500/异常=0非 401/403=1 | 有风险项 |
---
## 3. 自动化执行明细
### 3.1 全量 pytest
```powershell
$env:SOUL_TEST_ENV='local'; pytest -q
```
- 总计18
- 通过16
- 跳过2
- 失败0
覆盖模块:
- 小程序配置/登录miniapp
- 管理端鉴权与上传web
- 流程测试:健康检查、文章 @ 某人流程、人物 key 回填(部分 skip
### 3.2 管理端路由冒烟(鉴权)
```powershell
$env:SOUL_TEST_ENV='local'; python web/admin_routes_smoke.py
```
- 路由扫描119
- 404/500/异常0
- 需关注:`Unexpected success on write calls: 16`
- 涉及:`/api/admin/content``/api/admin/payment``/api/admin/referral``/api/db/chapters``/api/db/init``/api/db/migrate`
判读:
- 非直接功能 bug但暴露“写接口防误触”偏弱空 payload 也可能 success
### 3.3 管理端路由冒烟(未鉴权)
```powershell
$env:SOUL_TEST_ENV='local'; python web/admin_routes_smoke_authless.py
```
- 404/500/异常0
- 非预期(非 401/4031
- `POST /api/admin/logout` 返回 200未鉴权可调用
判读:
- 风险较低,但权限策略不一致,建议明确“公开接口”与“鉴权接口”边界。
---
## 4. 核心功能场景评估
### 4.1 分销
- 已验证
- 推荐码链路相关接口和流程用例可运行,自动化无失败。
- 本地联调可用(见 pytest 全量)。
- 待补手工
- 小程序扫码/分享带 `ref` 后,到订单归因展示的 UI 端到端确认。
### 4.2 支付
- 已验证
- 基础链路可用:`/health`、上传等依赖接口正常。
- 流程层(文章 @ 相关)可执行通过。
- 待补手工
- 真支付、余额支付、代付的真机端到端验证(本次未覆盖真实支付动作)。
### 4.3 获客(@某人 / CKB
- 已验证
- `process/test_article_mention_ckb_flow.py` 通过。
- 已补齐规则:人物不存在时,阅读页 mention 降级为静态 `@某人`
- 超级个体开通后自动创建 `Person` 的链路存在并已被流程用例覆盖。
### 4.4 匹配
- 已验证
- 相关后端路由可达(冒烟无 404/500
- 待补手工
- 小程序找伙伴完整流程:次数扣减、购买增次、资料门槛、匹配结果、加入流程。
---
## 5. 风险清单(按优先级)
| 优先级 | 风险项 | 现象 | 建议 |
|------|------|------|------|
| P1 | 写接口防误触偏弱 | 冒烟请求下 16 个写接口 success=true | 增加必填参数校验、鉴权校验、幂等保护 |
| P2 | 鉴权策略不一致 | `POST /api/admin/logout` 未鉴权返回 200 | 明确公开策略或统一纳入鉴权组 |
| P1 | 覆盖缺口UI E2E | 自动化未覆盖真机支付/分享/匹配 UI | 补充手工用例并归档结果 |
---
## 6. 复测计划(建议)
### 6.1 手工复测清单(本地)
- 小程序
- [ ] 分享带 `ref` → 下单归因展示正确
- [ ] 真机微信支付(章节/全书/VIP成功后权益生效
- [ ] 代付分享领取链路完整
- [ ] 匹配全流程(免费次数、增次购买、资料门槛、加入)
- 管理端
- [ ] 内容写接口空 payload 时返回合理错误
- [ ] 关键写接口鉴权策略一致
### 6.2 通过标准
- P1 风险项有明确处理结果(修复或接受并记录)
- 手工高风险链路全部通过
- 无新增中高严重问题
---
## 7. 最终结论
- 自动化结果:**通过16 passed / 0 failed / 2 skipped**
- 综合判定:**有条件通过**
- 当前可继续本地开发联调;
- 上线前需完成手工高风险场景复测,并处理/确认 P1 风险项。
---
## 8. 整改跟踪(执行版)
| 序号 | 问题/任务 | 优先级 | 责任角色 | 截止日期 | 当前状态 | 备注 |
|------|-----------|--------|----------|----------|----------|------|
| 1 | 写接口防误触:为空 payload 增加参数校验/防误触策略 | P1 | 后端工程师 | 2026-03-26 | 待开始 | 涉及 `/api/admin/content``/api/admin/payment``/api/admin/referral``/api/db/*` 部分接口 |
| 2 | 明确 `POST /api/admin/logout` 鉴权策略并与规范对齐 | P2 | 后端工程师 | 2026-03-26 | 待开始 | 可选“公开接口说明”或“纳入鉴权组” |
| 3 | 小程序分享带 `ref` → 订单归因 UI 端到端手工验证 | P1 | 测试人员 + 小程序工程师 | 2026-03-25 | 待开始 | 需真机/开发者工具联测 |
| 4 | 真机支付链路回归:章节/全书/VIP/代付 | P1 | 测试人员 + 小程序工程师 + 后端工程师 | 2026-03-25 | 待开始 | 含回调后权益生效校验 |
| 5 | 匹配完整链路手工回归(次数/增次/门槛/加入) | P1 | 测试人员 + 小程序工程师 | 2026-03-25 | 待开始 | 建议录屏留档 |
状态说明:`待开始 / 进行中 / 已完成 / 已阻塞`
---
## 9. 复测记录
| 复测日期 | 复测范围 | 结果 | 剩余问题 | 结论 |
|----------|----------|------|----------|------|
| (待填) | (待填) | (待填) | (待填) | (待填) |

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1010
soul-admin/dist/assets/index-l3L5iIYa.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,13 +1,13 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<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-Dk6CvQRe.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-qjssBjc3.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<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-l3L5iIYa.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D2vksFq_.css">
</head>
<body>
<div id="root"></div>
</body>

View File

@@ -31,6 +31,7 @@ export interface ChapterItem {
export interface PartItem {
id: string
title: string
badgeText?: string
chapters: ChapterItem[]
}
@@ -226,28 +227,28 @@ export function ChapterTree({
if (!toContext) return
const { partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle } = toContext
let toIdx: number
if (toType === 'section') {
toIdx = sections.findIndex((s) => s.id === toId)
} else if (toType === 'chapter') {
const lastInCh = sections.filter((s) => s.chapterId === toId).pop()
toIdx = lastInCh ? sections.findIndex((s) => s.id === lastInCh.id) + 1 : sections.length
} else {
const part = parts.find((p) => p.id === toId)
if (part?.chapters[0]) {
const firstChSections = sections.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
const last = firstChSections[firstChSections.length - 1]
toIdx = last ? sections.findIndex((s) => s.id === last.id) + 1 : sections.length
} else {
toIdx = sections.length
}
}
const fromIdx = sections.findIndex((s) => s.id === from.id)
if (fromIdx === -1) return
const next = sections.filter((s) => s.id !== from.id)
const insertIdx = fromIdx < toIdx ? toIdx - 1 : toIdx
// 用移除源项后的 next 计算插入点,避免“上往下拖拽相邻项不生效”
let insertIdx: number
if (toType === 'section') {
const targetIdxInNext = next.findIndex((s) => s.id === toId)
insertIdx = targetIdxInNext >= 0 ? targetIdxInNext + 1 : next.length
} else if (toType === 'chapter') {
const lastInCh = next.filter((s) => s.chapterId === toId).pop()
insertIdx = lastInCh ? next.findIndex((s) => s.id === lastInCh.id) + 1 : next.length
} else {
const part = parts.find((p) => p.id === toId)
if (part?.chapters[0]) {
const firstChSections = next.filter((s) => s.partId === part.id && s.chapterId === part.chapters[0].id)
const last = firstChSections[firstChSections.length - 1]
insertIdx = last ? next.findIndex((s) => s.id === last.id) + 1 : next.length
} else {
insertIdx = next.length
}
}
const moved = sections[fromIdx]
const newItem = { ...moved, partId: targetPartId, partTitle: targetPartTitle, chapterId: targetChapterId, chapterTitle: targetChapterTitle }
next.splice(insertIdx, 0, newItem)
@@ -417,7 +418,7 @@ export function ChapterTree({
<div className="flex items-center gap-3 min-w-0">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
<div className="w-10 h-10 rounded-xl bg-[#38bdac]/80 flex items-center justify-center text-white font-bold shrink-0">
{part.badgeText || '派'}
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>
@@ -791,7 +792,7 @@ export function ChapterTree({
<div className="flex items-center gap-3 min-w-0">
<GripVertical className="w-5 h-5 text-gray-500 shrink-0 opacity-60" />
<div className="w-10 h-10 rounded-xl bg-[#38bdac] flex items-center justify-center text-lg shadow-lg shadow-[#38bdac]/30 shrink-0">
{partIcon(bodyPartOrdinal(partIndex))}
{part.badgeText || partIcon(bodyPartOrdinal(partIndex))}
</div>
<div>
<h3 className="font-bold text-white text-base">{part.title}</h3>

View File

@@ -99,6 +99,7 @@ interface Chapter {
interface Part {
id: string
title: string
badgeText?: string
chapters: Chapter[]
}
@@ -129,10 +130,10 @@ interface EditingSection {
editionPremium?: boolean
}
function buildTree(sections: SectionListItem[], hotRankMap: Map<string, number>): Part[] {
function buildTree(sections: SectionListItem[], hotRankMap: Map<string, number>, partBadgeMap: Record<string, string>): Part[] {
const partMap = new Map<
string,
{ id: string; title: string; chapters: Map<string, { id: string; title: string; sections: Section[] }> }
{ id: string; title: string; badgeText?: string; chapters: Map<string, { id: string; title: string; sections: Section[] }> }
>()
for (const s of sections) {
const partId = s.partId || 'part-1'
@@ -140,7 +141,7 @@ function buildTree(sections: SectionListItem[], hotRankMap: Map<string, number>)
const chapterId = s.chapterId || 'chapter-1'
const chapterTitle = s.chapterTitle || '未分类'
if (!partMap.has(partId)) {
partMap.set(partId, { id: partId, title: partTitle, chapters: new Map() })
partMap.set(partId, { id: partId, title: partTitle, badgeText: partBadgeMap[partId] || '', chapters: new Map() })
}
const part = partMap.get(partId)!
if (!part.chapters.has(chapterId)) {
@@ -208,7 +209,7 @@ export function ContentPage() {
hotScore: 0,
})
const [editingPart, setEditingPart] = useState<{ id: string; title: string } | null>(null)
const [editingPart, setEditingPart] = useState<{ id: string; title: string; badgeText?: string } | null>(null)
const [isSavingPartTitle, setIsSavingPartTitle] = useState(false)
const [showNewPartModal, setShowNewPartModal] = useState(false)
const [editingChapter, setEditingChapter] = useState<{
@@ -308,6 +309,9 @@ export function ContentPage() {
[uploadSectionAsset],
)
const [partBadgeMap, setPartBadgeMap] = useState<Record<string, string>>({})
const normalizePartBadgeText = (v: unknown) => String(v || '').trim().slice(0, 8)
const hotRankMap = useMemo(() => {
const m = new Map<string, number>()
rankedSectionsList.forEach((s, idx) => {
@@ -317,7 +321,7 @@ export function ContentPage() {
return m
}, [rankedSectionsList])
const tree = buildTree(sectionsList, hotRankMap)
const tree = buildTree(sectionsList, hotRankMap, partBadgeMap)
const totalSections = sectionsList.length
// 内容排行榜:排序与置顶由后端 API 统一计算,前端只展示
@@ -341,6 +345,33 @@ export function ContentPage() {
}
}
const loadPartBadges = async () => {
try {
const res = await get<{ success?: boolean; data?: Record<string, unknown> | Array<{ configKey?: string; configValue?: unknown }> }>(
'/api/db/config?key=book_part_badges',
{ cache: 'no-store' as RequestCache },
)
let raw: Record<string, unknown> = {}
if (res && Array.isArray(res.data)) {
const row = res.data.find((x) => x && x.configKey === 'book_part_badges')
if (row && row.configValue && typeof row.configValue === 'object' && !Array.isArray(row.configValue)) {
raw = row.configValue as Record<string, unknown>
}
} else if (res && res.data && typeof res.data === 'object' && !Array.isArray(res.data)) {
raw = res.data as Record<string, unknown>
}
const next: Record<string, string> = {}
Object.keys(raw).forEach((k) => {
const txt = normalizePartBadgeText(raw[k])
if (txt) next[k] = txt
})
setPartBadgeMap(next)
} catch (e) {
console.error(e)
setPartBadgeMap({})
}
}
const loadRanking = async () => {
setRankingLoading(true)
try {
@@ -364,6 +395,7 @@ export function ContentPage() {
useEffect(() => {
loadList()
loadRanking()
loadPartBadges()
}, [])
@@ -938,7 +970,7 @@ export function ContentPage() {
}
const handleEditPartTitle = (part: Part) => {
setEditingPart({ id: part.id, title: part.title })
setEditingPart({ id: part.id, title: part.title, badgeText: normalizePartBadgeText(part.badgeText) })
}
const handleSavePartTitle = async () => {
@@ -958,6 +990,20 @@ export function ContentPage() {
})
if (res && (res as { success?: boolean }).success !== false) {
const newTitle = editingPart.title.trim()
const nextBadgeMap: Record<string, string> = { ...partBadgeMap }
const badgeText = normalizePartBadgeText(editingPart.badgeText)
if (badgeText) nextBadgeMap[editingPart.id] = badgeText
else delete nextBadgeMap[editingPart.id]
const badgeRes = await post<{ success?: boolean; error?: string }>('/api/db/config', {
key: 'book_part_badges',
value: nextBadgeMap,
description: '目录篇名角标key=part_id, value=角标文案)',
})
if (badgeRes && (badgeRes as { success?: boolean }).success === false) {
toast.error('更新篇名角标失败: ' + (((badgeRes as { error?: string }).error) || '未知错误'))
return
}
setPartBadgeMap(nextBadgeMap)
setSectionsList((prev) =>
prev.map((s) =>
s.partId === editingPart.id ? { ...s, partTitle: newTitle } : s
@@ -1564,6 +1610,17 @@ export function ContentPage() {
placeholder="输入篇名"
/>
</div>
<div className="space-y-2">
<Label className="text-gray-300"></Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
value={editingPart.badgeText || ''}
onChange={(e) => setEditingPart({ ...editingPart, badgeText: normalizePartBadgeText(e.target.value) })}
placeholder="例如:派 / 新 / 热"
maxLength={8}
/>
<p className="text-xs text-gray-500"></p>
</div>
</div>
)}
<DialogFooter>

View File

@@ -125,6 +125,7 @@ const MP_UI_TEMPLATE_OBJECT: Record<string, Record<string, string>> = {
chaptersPage: {
bookTitle: '一场SOUL的创业实验场',
bookSubtitle: '来自Soul派对房的真实商业故事',
newBadgeText: 'NEW',
},
// homePage.linkKaruoAvatar首页「链接卡若」头像 HTTPS空则小程序用「卡」字占位
homePage: {
@@ -173,6 +174,7 @@ export function SettingsPage() {
const [featureConfig, setFeatureConfig] = useState<FeatureConfig>(defaultFeatures)
const [mpConfig, setMpConfig] = useState<MpConfig>(defaultMpConfig)
const [mpUiJson, setMpUiJson] = useState('{}')
const [chaptersNewBadgeText, setChaptersNewBadgeText] = useState('NEW')
const [ossConfig, setOssConfig] = useState<OssConfig>({})
const [isSaving, setIsSaving] = useState(false)
const [loading, setLoading] = useState(true)
@@ -206,9 +208,17 @@ export function SettingsPage() {
const merged = { ...res.mpConfig } as MpConfig
setMpConfig((prev) => ({ ...prev, ...merged }))
const raw = merged.mpUi
const rawObj =
raw != null && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}
const chaptersPage =
rawObj.chaptersPage && typeof rawObj.chaptersPage === 'object' && !Array.isArray(rawObj.chaptersPage)
? (rawObj.chaptersPage as Record<string, unknown>)
: {}
const badgeRaw = chaptersPage.newBadgeText ?? chaptersPage.sectionNewBadgeText
setChaptersNewBadgeText(typeof badgeRaw === 'string' && badgeRaw.trim() ? badgeRaw.trim() : 'NEW')
setMpUiJson(
JSON.stringify(
raw != null && typeof raw === 'object' && !Array.isArray(raw) ? raw : {},
rawObj,
null,
2,
),
@@ -312,6 +322,16 @@ export function SettingsPage() {
setIsSaving(false)
return
}
const chaptersPageRaw = mpUi.chaptersPage
const chaptersPage =
chaptersPageRaw && typeof chaptersPageRaw === 'object' && !Array.isArray(chaptersPageRaw)
? { ...(chaptersPageRaw as Record<string, unknown>) }
: {}
const badgeText = chaptersNewBadgeText.trim()
if (badgeText) chaptersPage.newBadgeText = badgeText
else delete chaptersPage.newBadgeText
delete chaptersPage.sectionNewBadgeText
mpUi.chaptersPage = chaptersPage
const res = await post<{ success?: boolean; error?: string }>('/api/admin/settings', {
featureConfig,
@@ -613,7 +633,7 @@ export function SettingsPage() {
<div className="mt-4 p-4 rounded-xl bg-[#0a1628] border border-[#38bdac]/30">
<p className="text-xs text-gray-500 mb-2"></p>
<div className="flex items-center gap-3">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center text-xl font-bold text-white">
<div className="w-12 h-12 rounded-full bg-linear-to-br from-[#00CED1] to-[#20B2AA] flex items-center justify-center text-xl font-bold text-white">
{(localSettings.authorInfo.name ?? 'K').charAt(0)}
</div>
<div>
@@ -736,6 +756,21 @@ export function SettingsPage() {
</div>
<div className="space-y-2 pt-2 border-t border-gray-700/50">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label className="text-gray-300"> NEW </Label>
<Input
className="bg-[#0a1628] border-gray-700 text-white"
placeholder="例如NEW / 最新 / 刚更新"
value={chaptersNewBadgeText}
maxLength={8}
onChange={(e) => setChaptersNewBadgeText(e.target.value)}
/>
<p className="text-xs text-gray-500">
5 mpUi JSON
</p>
</div>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Label className="text-gray-300"> mpUiJSON</Label>
<Button

View File

@@ -263,6 +263,11 @@ export function UsersPage() {
source?: string
personName?: string
ckbPlanId?: number
pushStatus?: 'pending' | 'success' | 'failed' | string
retryCount?: number
ckbError?: string
lastPushAt?: string
nextRetryAt?: string
createdAt?: string
}[]>([])
const [leadsTotal, setLeadsTotal] = useState(0)
@@ -273,7 +278,10 @@ export function UsersPage() {
const [leadsSearchTerm, setLeadsSearchTerm] = useState('')
const debouncedLeadsSearch = useDebounce(leadsSearchTerm, 300)
const [leadsSourceFilter, setLeadsSourceFilter] = useState('')
const [leadsPushStatusFilter, setLeadsPushStatusFilter] = useState('')
const [leadsStats, setLeadsStats] = useState<{ uniqueUsers?: number; sourceStats?: { source: string; cnt: number }[] }>({})
const [retryingLeadId, setRetryingLeadId] = useState<number | null>(null)
const [batchRetrying, setBatchRetrying] = useState(false)
const loadLeads = useCallback(async (searchVal?: string, sourceVal?: string) => {
setLeadsLoading(true)
setLeadsError(null)
@@ -287,6 +295,7 @@ export function UsersPage() {
if (s) params.set('search', s)
const src = sourceVal ?? leadsSourceFilter
if (src) params.set('source', src)
if (leadsPushStatusFilter) params.set('pushStatus', leadsPushStatusFilter)
const data = await get<{
success?: boolean; records?: unknown[]; total?: number;
stats?: { uniqueUsers?: number; sourceStats?: { source: string; cnt: number }[] }
@@ -312,7 +321,83 @@ export function UsersPage() {
} finally {
setLeadsLoading(false)
}
}, [leadsPage, leadsPageSize, debouncedLeadsSearch, leadsSourceFilter])
}, [leadsPage, leadsPageSize, debouncedLeadsSearch, leadsSourceFilter, leadsPushStatusFilter])
async function retryLeadPush(recordId: number) {
if (!recordId) return
setRetryingLeadId(recordId)
try {
const data = await post<{ success?: boolean; pushed?: boolean; error?: string }>('/api/db/ckb-leads/retry', { id: recordId })
if (data?.success) {
toast.success(data.pushed ? '重推成功' : '已发起重推,请刷新查看状态')
} else {
toast.error(data?.error || '重推失败')
}
} catch (e) {
toast.error(e instanceof Error ? e.message : '重推请求失败')
} finally {
setRetryingLeadId(null)
loadLeads()
}
}
async function retryFailedLeadsInPage() {
const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed')
if (failedRows.length === 0) {
toast.info('当前页无失败记录')
return
}
setBatchRetrying(true)
let successCount = 0
for (const row of failedRows) {
try {
const data = await post<{ success?: boolean; pushed?: boolean }>('/api/db/ckb-leads/retry', { id: row.id })
if (data?.success && data.pushed) successCount++
} catch {
// 单条失败继续下一条,避免中断整批重推
}
}
setBatchRetrying(false)
const total = failedRows.length
toast.success(`批量重推完成:成功 ${successCount} / ${total}`)
loadLeads()
}
function exportFailedLeadsCsv() {
const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed')
if (failedRows.length === 0) {
toast.info('当前筛选下无失败记录可导出')
return
}
const esc = (v: unknown) => `"${String(v ?? '').replace(/"/g, '""')}"`
const headers = ['ID', '昵称', '手机号', '微信号', '对应@人', '来源', '推送状态', '重试次数', '失败原因', '下次重试时间', '创建时间']
const lines = [headers.join(',')]
for (const r of failedRows) {
lines.push([
esc(r.id),
esc(r.userNickname || r.name || ''),
esc(r.phone || ''),
esc(r.wechatId || ''),
esc(r.personName || ''),
esc(r.source || ''),
esc(r.pushStatus || ''),
esc(typeof r.retryCount === 'number' ? r.retryCount : ''),
esc(r.ckbError || ''),
esc(r.nextRetryAt ? new Date(r.nextRetryAt).toLocaleString() : ''),
esc(r.createdAt ? new Date(r.createdAt).toLocaleString() : ''),
].join(','))
}
const blob = new Blob(['\ufeff' + lines.join('\n')], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `获客失败清单-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.csv`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast.success(`已导出失败清单(${failedRows.length} 条)`)
}
const loadMbtiAvatarsMap = useCallback(async () => {
try {
@@ -327,6 +412,14 @@ export function UsersPage() {
if (searchParams.get('tab') === 'leads') loadLeads()
}, [searchParams.get('tab'), leadsPage, loadLeads])
useEffect(() => {
if (tabParam !== 'leads') return
const timer = window.setInterval(() => {
loadLeads()
}, 30000)
return () => window.clearInterval(timer)
}, [tabParam, loadLeads])
useEffect(() => {
loadMbtiAvatarsMap()
}, [loadMbtiAvatarsMap])
@@ -848,6 +941,54 @@ export function UsersPage() {
return { leadsRows: out, leadsRawCount: rows.length, leadsDeduped: rows.length - out.length }
}, [leadsRecords, debouncedLeadsSearch])
const pushStatusBadge = (status?: string) => {
if (status === 'success') return <Badge className="bg-emerald-500/20 text-emerald-300 border-0 text-xs"></Badge>
if (status === 'failed') return <Badge className="bg-red-500/20 text-red-300 border-0 text-xs"></Badge>
return <Badge className="bg-amber-500/20 text-amber-300 border-0 text-xs"></Badge>
}
const failedReasonStats = useMemo(() => {
const map = new Map<string, number>()
for (const r of leadsRows) {
if (r.pushStatus !== 'failed') continue
const reason = (r.ckbError || '未知错误').trim() || '未知错误'
map.set(reason, (map.get(reason) || 0) + 1)
}
return Array.from(map.entries())
.map(([reason, count]) => ({ reason, count }))
.sort((a, b) => b.count - a.count)
}, [leadsRows])
async function copyFailedDebugInfo() {
const failedRows = leadsRows.filter((r) => r.pushStatus === 'failed')
if (failedRows.length === 0) {
toast.info('当前页无失败记录')
return
}
const topReasons = failedReasonStats.slice(0, 8).map((x) => `- ${x.reason}${x.count}`).join('\n')
const recentIds = failedRows.slice(0, 30).map((r) => r.id).join(', ')
const detailRows = failedRows.slice(0, 20).map((r) => (
`#${r.id} | ${r.userNickname || r.name || '-'} | 手机:${r.phone || '-'} | 来源:${r.source || '-'} | 重试:${r.retryCount ?? 0} | 错误:${r.ckbError || '-'}`
)).join('\n')
const text = [
`【获客失败排障信息】`,
`时间:${new Date().toLocaleString()}`,
`当前页失败总数:${failedRows.length}`,
`主要失败原因:`,
topReasons || '- 无',
`最近失败记录ID最多30条${recentIds || '无'}`,
'',
`失败记录明细最多20条`,
detailRows || '无',
].join('\n')
try {
await navigator.clipboard.writeText(text)
toast.success('已复制排障信息')
} catch {
toast.error('复制失败,请检查浏览器剪贴板权限')
}
}
return (
<div className="p-8 w-full">
{error && (
@@ -1214,6 +1355,33 @@ export function UsersPage() {
))}
</div>
)}
{!leadsLoading && failedReasonStats.length > 0 && (
<Card className="bg-[#3a1010]/35 border-red-900/60 shadow-lg mb-4">
<CardContent className="p-4">
<div className="flex flex-wrap items-center justify-between gap-3 mb-3">
<div>
<p className="text-red-200 font-medium"></p>
<p className="text-red-300/70 text-xs"></p>
</div>
<Button
type="button"
variant="outline"
onClick={copyFailedDebugInfo}
className="border-red-600/70 text-red-200 hover:bg-red-500/10 bg-transparent"
>
</Button>
</div>
<div className="flex flex-wrap gap-2">
{failedReasonStats.slice(0, 8).map((s) => (
<Badge key={s.reason} className="bg-red-500/15 text-red-200 border border-red-600/40">
{s.reason} · {s.count}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
<div className="flex flex-wrap items-center justify-between gap-3 mb-4">
<div className="flex flex-wrap items-center gap-2 flex-1 min-w-[200px]">
<div className="relative flex-1 max-w-xs">
@@ -1237,13 +1405,50 @@ export function UsersPage() {
))}
</select>
)}
<select
value={leadsPushStatusFilter}
onChange={(e) => { setLeadsPushStatusFilter(e.target.value); setLeadsPage(1) }}
className="bg-[#0f2137] border border-gray-700 text-white rounded-lg px-3 py-2 text-sm"
>
<option value=""></option>
<option value="pending"></option>
<option value="success"></option>
<option value="failed"></option>
</select>
<Button
type="button"
variant="outline"
onClick={() => { setLeadsPushStatusFilter('failed'); setLeadsPage(1) }}
className="border-red-600/60 text-red-300 hover:bg-red-500/10 bg-transparent text-xs h-9"
>
</Button>
<span className="text-xs text-gray-500 whitespace-nowrap max-w-[min(100%,20rem)]" title="同一页内:相同手机号或相同用户 ID含微信侧标识只保留最近一条">
{leadsRawCount} {leadsDeduped > 0 ? ` · 已合并 ${leadsDeduped} 条重复` : ''}
</span>
</div>
<Button variant="outline" onClick={() => loadLeads()} disabled={leadsLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent shrink-0">
<RefreshCw className={`w-4 h-4 mr-2 ${leadsLoading ? 'animate-spin' : ''}`} />
</Button>
<div className="flex items-center gap-2 shrink-0">
<Button
variant="outline"
onClick={exportFailedLeadsCsv}
disabled={leadsLoading}
className="border-cyan-600/60 text-cyan-300 hover:bg-cyan-500/10 bg-transparent"
>
</Button>
<Button
variant="outline"
onClick={retryFailedLeadsInPage}
disabled={batchRetrying || leadsLoading}
className="border-amber-600/60 text-amber-300 hover:bg-amber-500/10 bg-transparent"
>
<RefreshCw className={`w-4 h-4 mr-2 ${batchRetrying ? 'animate-spin' : ''}`} />
</Button>
<Button variant="outline" onClick={() => loadLeads()} disabled={leadsLoading} className="border-gray-600 text-gray-300 hover:bg-gray-700/50 bg-transparent">
<RefreshCw className={`w-4 h-4 mr-2 ${leadsLoading ? 'animate-spin' : ''}`} />
</Button>
</div>
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
<CardContent className="p-0">
@@ -1263,6 +1468,8 @@ export function UsersPage() {
<TableHead className="text-gray-400"> @人</TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
<TableHead className="text-gray-400"></TableHead>
</TableRow>
</TableHeader>
@@ -1277,12 +1484,37 @@ export function UsersPage() {
<TableCell>
<Badge variant="outline" className="text-gray-400 border-gray-600 text-xs">{r.source || '未知'}</Badge>
</TableCell>
<TableCell>
<div className="space-y-1">
{pushStatusBadge(r.pushStatus)}
{!!r.ckbError && (
<p className="text-[11px] text-red-300 max-w-[220px] truncate" title={r.ckbError}>
{r.ckbError}
</p>
)}
</div>
</TableCell>
<TableCell className="text-gray-400 text-xs">
<div className="space-y-1">
<p>{typeof r.retryCount === 'number' ? `${r.retryCount}` : '-'}</p>
<Button
size="sm"
variant="outline"
disabled={retryingLeadId === r.id}
onClick={() => retryLeadPush(r.id)}
className="h-7 px-2 text-[11px] border-gray-600 text-gray-200 hover:bg-gray-700/50 bg-transparent"
>
<RefreshCw className={`w-3 h-3 mr-1 ${retryingLeadId === r.id ? 'animate-spin' : ''}`} />
</Button>
</div>
</TableCell>
<TableCell className="text-gray-400">{r.createdAt ? new Date(r.createdAt).toLocaleString() : '-'}</TableCell>
</TableRow>
))}
{leadsRows.length === 0 && (
<TableRow>
<TableCell colSpan={7} className="p-0 align-top">
<TableCell colSpan={9} className="p-0 align-top">
<div className="py-16 px-6 text-center border-t border-gray-700/40 bg-[#0a1628]/30">
<LeadIcon className="w-14 h-14 text-[#38bdac]/20 mx-auto mb-4" aria-hidden />
<p className="text-gray-200 font-medium mb-1">线</p>

View File

@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dialog'
import { Check, X, RefreshCw, Wallet, DollarSign } from 'lucide-react'
import { Pagination } from '@/components/ui/Pagination'
import { get, put } from '@/api/client'
import { get, put, post } from '@/api/client'
interface Withdrawal {
id: string
@@ -64,7 +64,9 @@ export function WithdrawalsPage() {
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [filter, setFilter] = useState<'all' | 'pending' | 'success' | 'failed'>('all')
const [filter, setFilter] = useState<
'all' | 'pending' | 'processing' | 'pending_confirm' | 'success' | 'failed'
>('all')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
@@ -152,6 +154,27 @@ export function WithdrawalsPage() {
}
}
async function handleCancel(id: string) {
if (!confirm('确认撤回该笔打款?仅在用户未确认收款前可撤回。')) return
setProcessing(id)
try {
const data = await post<{ success?: boolean; error?: string }>(
'/api/admin/withdrawals/cancel',
{ id },
)
if (data?.success) {
toast.success('已撤回打款')
loadWithdrawals()
} else {
toast.error('撤回失败: ' + (data?.error ?? '未知错误'))
}
} catch {
toast.error('撤回失败')
} finally {
setProcessing(null)
}
}
function openRejectDialog(id: string) {
setRejectWithdrawalId(id)
setRejectReason('')
@@ -321,7 +344,8 @@ export function WithdrawalsPage() {
</div>
<div className="flex gap-2 mb-4">
{(['all', 'pending', 'success', 'failed'] as const).map((f) => (
{(['all', 'pending', 'processing', 'pending_confirm', 'success', 'failed'] as const).map(
(f) => (
<Button
key={f}
variant={filter === f ? 'default' : 'outline'}
@@ -337,11 +361,16 @@ export function WithdrawalsPage() {
? '全部'
: f === 'pending'
? '待处理'
: f === 'processing'
? '处理中'
: f === 'pending_confirm'
? '待确认收款'
: f === 'success'
? '已完成'
: '已拒绝'}
</Button>
))}
),
)}
</div>
<Card className="bg-[#0f2137] border-gray-700/50 shadow-xl">
@@ -496,6 +525,19 @@ export function WithdrawalsPage() {
</Button>
</div>
)}
{(w.status === 'processing' || w.status === 'pending_confirm') && (
<div className="mt-2 flex items-center justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={() => handleCancel(w.id)}
disabled={processing === w.id}
className="border-amber-500/50 text-amber-400 hover:bg-amber-500/10 bg-transparent"
>
</Button>
</div>
)}
{(w.status === 'success' || w.status === 'completed') &&
w.transactionId && (
<span className="text-xs text-gray-500 font-mono">

View File

@@ -43,6 +43,9 @@ func Init(dsn string) error {
skipMigrate := strings.ToLower(strings.TrimSpace(os.Getenv("SKIP_AUTO_MIGRATE")))
if skipMigrate == "1" || skipMigrate == "true" || skipMigrate == "yes" {
log.Println("database: SKIP_AUTO_MIGRATE enabled, skipping schema migration")
// 即使跳过 AutoMigrate也补齐关键运行时字段避免新功能因历史库缺列直接报错。
ensurePersonSchema(db)
ensureCkbLeadSchema(db)
log.Println("database: connected")
return nil
}
@@ -89,6 +92,7 @@ func Init(dsn string) error {
if err := db.AutoMigrate(&model.CkbLeadRecord{}); err != nil {
log.Printf("database: ckb_lead_records migrate warning: %v", err)
}
ensureCkbLeadSchema(db)
if err := db.AutoMigrate(&model.Person{}); err != nil {
log.Printf("database: persons migrate warning: %v", err)
}
@@ -146,3 +150,32 @@ func ensurePersonSchema(db *gorm.DB) {
}
}
}
func ensureCkbLeadSchema(db *gorm.DB) {
m := db.Migrator()
if !m.HasColumn(&model.CkbLeadRecord{}, "push_status") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN push_status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '推送状态: pending/success/failed'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add push_status", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "retry_count") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN retry_count INT NOT NULL DEFAULT 0 COMMENT '重试次数'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add retry_count", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "last_push_at") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN last_push_at DATETIME NULL COMMENT '最后推送时间'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add last_push_at", err)
}
}
if !m.HasColumn(&model.CkbLeadRecord{}, "next_retry_at") {
if err := db.Exec("ALTER TABLE ckb_lead_records ADD COLUMN next_retry_at DATETIME NULL COMMENT '下次重试时间'").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=add next_retry_at", err)
}
}
if !m.HasIndex(&model.CkbLeadRecord{}, "idx_ckb_lead_push_status") {
if err := db.Exec("CREATE INDEX idx_ckb_lead_push_status ON ckb_lead_records(push_status)").Error; err != nil {
log.Printf("database: ckb_lead_records schema ensure warning: %v; action=create idx_ckb_lead_push_status", err)
}
}
}

View File

@@ -83,7 +83,11 @@ func AdminWithdrawalsList(c *gin.Context) {
db := database.DB()
q := db.Model(&model.Withdrawal{})
if statusFilter != "" && statusFilter != "all" {
q = q.Where("status = ?", statusFilter)
if statusFilter == "pending" {
q = q.Where("status IN ?", []string{"pending", "processing", "pending_confirm"})
} else {
q = q.Where("status = ?", statusFilter)
}
}
var total int64
q.Count(&total)
@@ -91,7 +95,11 @@ func AdminWithdrawalsList(c *gin.Context) {
var list []model.Withdrawal
query := db.Order("created_at DESC")
if statusFilter != "" && statusFilter != "all" {
query = query.Where("status = ?", statusFilter)
if statusFilter == "pending" {
query = query.Where("status IN ?", []string{"pending", "processing", "pending_confirm"})
} else {
query = query.Where("status = ?", statusFilter)
}
}
if err := query.Offset((page - 1) * pageSize).Limit(pageSize).Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "withdrawals": []interface{}{}, "stats": gin.H{"total": 0}})

View File

@@ -86,7 +86,8 @@ type cachedPartRow struct {
ChapterCount int `json:"chapterCount"`
MinSortOrder int `json:"minSortOrder"`
// Icon 可选system_config.book_part_icons JSON 中按 part_id 配置的封面图 URL
Icon string `json:"icon,omitempty"`
Icon string `json:"icon,omitempty"`
BadgeText string `json:"badgeText,omitempty"`
}
type cachedFixedItem struct {
ID string `json:"id"`
@@ -137,19 +138,46 @@ func loadBookPartIconURLs() map[string]string {
return out
}
// mergeBookPartIcons 将配置中的篇封面 URL 写入 parts每次接口响应前调用避免 Redis 旧缓存缺 icon
// loadBookPartBadgeTexts 读取 system_config.book_part_badges{"part-1":"新"}key 与 chapters.part_id 一致
func loadBookPartBadgeTexts() map[string]string {
out := map[string]string{}
var row model.SystemConfig
if err := database.DB().Where("config_key = ?", "book_part_badges").First(&row).Error; err != nil {
return out
}
var raw map[string]interface{}
if err := json.Unmarshal(row.ConfigValue, &raw); err != nil {
return out
}
for k, v := range raw {
k = strings.TrimSpace(k)
if k == "" {
continue
}
if s, ok := v.(string); ok {
s = strings.TrimSpace(s)
if s != "" {
out[k] = s
}
}
}
return out
}
// mergeBookPartIcons 将配置中的篇封面 URL/角标写入 parts每次接口响应前调用避免 Redis 旧缓存缺字段)
func mergeBookPartIcons(parts []cachedPartRow) {
if len(parts) == 0 {
return
}
m := loadBookPartIconURLs()
if len(m) == 0 {
return
}
bm := loadBookPartBadgeTexts()
for i := range parts {
if u := strings.TrimSpace(m[parts[i].PartID]); u != "" {
parts[i].Icon = u
}
if b := strings.TrimSpace(bm[parts[i].PartID]); b != "" {
parts[i].BadgeText = b
}
}
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"bytes"
"context"
"crypto/md5"
"encoding/hex"
"encoding/json"
@@ -113,6 +114,216 @@ func getCkbLeadApiKey() string {
return ckbAPIKey
}
func markLeadPushSuccess(db *gorm.DB, recordID int64) {
if db == nil || recordID <= 0 {
return
}
now := time.Now()
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(map[string]interface{}{
"push_status": "success",
"ckb_error": "",
"last_push_at": now,
"next_retry_at": nil,
}).Error
}
func markLeadPushFailed(db *gorm.DB, recordID int64, errMsg string, incRetry bool) {
if db == nil || recordID <= 0 {
return
}
now := time.Now()
updates := map[string]interface{}{
"push_status": "failed",
"ckb_error": strings.TrimSpace(errMsg),
"last_push_at": now,
"next_retry_at": now.Add(5 * time.Minute),
}
if incRetry {
updates["retry_count"] = gorm.Expr("retry_count + 1")
}
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", recordID).Updates(updates).Error
}
type ckbLeadPushResult struct {
Code int
Message string
Raw string
}
func pushLeadToCKB(name, phone, wechatId, leadKey string) (ckbLeadPushResult, error) {
ts := time.Now().Unix()
params := map[string]interface{}{
"name": name,
"timestamp": ts,
"apiKey": leadKey,
}
if strings.TrimSpace(phone) != "" {
params["phone"] = strings.TrimSpace(phone)
}
if strings.TrimSpace(wechatId) != "" {
params["wechatId"] = strings.TrimSpace(wechatId)
}
params["sign"] = ckbSign(params, leadKey)
q := url.Values{}
q.Set("name", name)
q.Set("timestamp", strconv.FormatInt(ts, 10))
q.Set("apiKey", leadKey)
if v, ok := params["phone"].(string); ok && v != "" {
q.Set("phone", v)
}
if v, ok := params["wechatId"].(string); ok && v != "" {
q.Set("wechatId", v)
}
q.Set("sign", params["sign"].(string))
reqURL := ckbAPIURL + "?" + q.Encode()
resp, err := http.Get(reqURL)
if err != nil {
return ckbLeadPushResult{}, err
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
var result struct {
Code int `json:"code"`
Message string `json:"message"`
Msg string `json:"msg"`
}
_ = json.Unmarshal(b, &result)
msg := strings.TrimSpace(result.Message)
if msg == "" {
msg = strings.TrimSpace(result.Msg)
}
return ckbLeadPushResult{
Code: result.Code,
Message: msg,
Raw: string(b),
}, nil
}
func retryOneLeadRecord(ctx context.Context, db *gorm.DB, r model.CkbLeadRecord) bool {
select {
case <-ctx.Done():
return false
default:
}
_ = db.Model(&model.CkbLeadRecord{}).Where("id = ?", r.ID).Update("push_status", "pending").Error
var p map[string]interface{}
_ = json.Unmarshal([]byte(r.Params), &p)
source := strings.TrimSpace(r.Source)
name := strings.TrimSpace(r.Nickname)
if name == "" {
if v, ok := p["name"].(string); ok {
name = strings.TrimSpace(v)
}
}
if name == "" {
name = "小程序用户"
}
phone := strings.TrimSpace(r.Phone)
wechatId := strings.TrimSpace(r.WechatID)
if phone == "" {
if v, ok := p["phone"].(string); ok {
phone = strings.TrimSpace(v)
}
}
if wechatId == "" {
if v, ok := p["wechatId"].(string); ok {
wechatId = strings.TrimSpace(v)
}
}
leadKey := getCkbLeadApiKey()
targetName := ""
targetMemberID := ""
targetMemberName := ""
leadUserID := strings.TrimSpace(r.UserID)
if v, ok := p["userId"].(string); ok && leadUserID == "" {
leadUserID = strings.TrimSpace(v)
}
if source != "index_link_button" {
if v, ok := p["targetUserId"].(string); ok && strings.TrimSpace(v) != "" {
var person model.Person
if db.Where("token = ?", strings.TrimSpace(v)).First(&person).Error == nil && strings.TrimSpace(person.CkbApiKey) != "" {
leadKey = strings.TrimSpace(person.CkbApiKey)
targetName = strings.TrimSpace(person.Name)
if person.UserID != nil {
targetMemberID = strings.TrimSpace(*person.UserID)
}
}
}
if v, ok := p["targetNickname"].(string); ok && strings.TrimSpace(v) != "" {
targetName = strings.TrimSpace(v)
}
if v, ok := p["targetMemberId"].(string); ok && strings.TrimSpace(v) != "" {
targetMemberID = strings.TrimSpace(v)
}
if v, ok := p["targetMemberName"].(string); ok && strings.TrimSpace(v) != "" {
targetMemberName = strings.TrimSpace(v)
}
}
res, perr := pushLeadToCKB(name, phone, wechatId, leadKey)
if perr != nil {
markLeadPushFailed(db, r.ID, perr.Error(), true)
return false
}
if res.Code == 200 {
markLeadPushSuccess(db, r.ID)
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: targetName,
MemberName: targetMemberName,
TargetMemberID: targetMemberID,
Source: source,
Repeated: true,
LeadUserID: leadUserID,
})
return true
}
msg := res.Message
if msg == "" {
msg = "重推失败"
}
markLeadPushFailed(db, r.ID, msg, true)
return false
}
func RetryCkbLeadByID(ctx context.Context, recordID int64) (bool, error) {
if recordID <= 0 {
return false, fmt.Errorf("recordID 无效")
}
db := database.DB()
var r model.CkbLeadRecord
if err := db.Where("id = ?", recordID).First(&r).Error; err != nil {
return false, err
}
return retryOneLeadRecord(ctx, db, r), nil
}
// RetryFailedCkbLeads 重推存客宝失败留资记录(供定时任务调用)
func RetryFailedCkbLeads(ctx context.Context, limit int) (retried, success int, err error) {
if limit < 1 {
limit = 100
}
if limit > 1000 {
limit = 1000
}
db := database.DB()
now := time.Now()
var rows []model.CkbLeadRecord
if err := db.Where("push_status = ? AND (next_retry_at IS NULL OR next_retry_at <= ?)", "failed", now).
Order("id ASC").Limit(limit).Find(&rows).Error; err != nil {
return 0, 0, err
}
retried = len(rows)
for i := range rows {
if retryOneLeadRecord(ctx, db, rows[i]) {
success++
}
}
return retried, success, nil
}
// CKBJoin POST /api/ckb/join
func CKBJoin(c *gin.Context) {
var body struct {
@@ -388,15 +599,17 @@ func CKBIndexLead(c *gin.Context) {
"userId": body.UserID, "phone": phone, "wechatId": wechatId, "name": body.Name,
"source": source,
})
_ = db.Create(&model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Source: source,
Params: string(paramsJSON),
}).Error
rec := model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
WechatID: wechatId,
Name: strings.TrimSpace(body.Name),
Source: source,
Params: string(paramsJSON),
PushStatus: "pending",
}
_ = db.Create(&rec).Error
ts := time.Now().Unix()
params := map[string]interface{}{
@@ -417,7 +630,8 @@ func CKBIndexLead(c *gin.Context) {
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBIndexLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
markLeadPushFailed(db, rec.ID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
return
}
defer resp.Body.Close()
@@ -429,6 +643,7 @@ func CKBIndexLead(c *gin.Context) {
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
markLeadPushSuccess(db, rec.ID)
var msg string
var defaultPerson model.Person
if db.Where("ckb_api_key = ? AND ckb_api_key != ''", leadKey).First(&defaultPerson).Error == nil && strings.TrimSpace(defaultPerson.Tips) != "" {
@@ -451,14 +666,14 @@ func CKBIndexLead(c *gin.Context) {
personName = defaultPerson.Name
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: personName,
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: personName,
TargetMemberID: "",
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
@@ -478,12 +693,8 @@ func CKBIndexLead(c *gin.Context) {
}
}
fmt.Printf("[CKBIndexLead] 存客宝返回异常 code=%d message=%s raw=%s leadKey=%s\n", result.Code, result.Message, string(b), leadKey)
c.JSON(http.StatusOK, gin.H{
"success": false,
"message": errMsg,
"ckbCode": result.Code,
"ckbMessage": result.Message,
})
markLeadPushFailed(db, rec.ID, errMsg, true)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
}
// CKBLead POST /api/miniprogram/ckb/lead 小程序留资加好友:链接卡若(首页)、文章@某人、超级个体详情点头像
@@ -496,9 +707,9 @@ func CKBLead(c *gin.Context) {
Name string `json:"name"`
TargetUserID string `json:"targetUserId"` // 被@的 personId文章 mention / 超级个体人物 token
TargetNickname string `json:"targetNickname"` // 被@的人显示名(用于文案)
TargetMemberID string `json:"targetMemberId"` // 超级个体用户 id无 person token 时全局留资,写入 params 便于运营)
TargetMemberName string `json:"targetMemberName"` // 超级个体展示名(仅入 params不误导读为「对方会联系您」
Source string `json:"source"` // index_lead / article_mention / member_detail_global
TargetMemberID string `json:"targetMemberId"` // 超级个体用户 id无 person token 时全局留资,写入 params 便于运营)
TargetMemberName string `json:"targetMemberName"` // 超级个体展示名(仅入 params不误导读为「对方会联系您」
Source string `json:"source"` // index_lead / article_mention / member_detail_global
}
_ = c.ShouldBindJSON(&body)
phone := strings.TrimSpace(body.Phone)
@@ -529,21 +740,19 @@ func CKBLead(c *gin.Context) {
if body.TargetUserID != "" {
var p model.Person
if db.Where("token = ?", body.TargetUserID).First(&p).Error != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "未找到该人物配置,请稍后重试"})
return
}
if strings.TrimSpace(p.CkbApiKey) == "" {
c.JSON(http.StatusOK, gin.H{"success": false, "message": "该人物尚未配置存客宝密钥,请联系管理员"})
return
}
leadKey = p.CkbApiKey
personTips = strings.TrimSpace(p.Tips)
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
fmt.Printf("[CKBLead] 未找到人物 token=%s回退全局获客池\n", body.TargetUserID)
} else {
if strings.TrimSpace(p.CkbApiKey) != "" {
leadKey = p.CkbApiKey
}
personTips = strings.TrimSpace(p.Tips)
if targetName == "" {
targetName = p.Name
}
if targetMemberID == "" {
if p.UserID != nil {
targetMemberID = strings.TrimSpace(*p.UserID)
}
}
}
}
@@ -565,7 +774,7 @@ func CKBLead(c *gin.Context) {
"targetUserId": body.TargetUserID, "targetMemberId": strings.TrimSpace(body.TargetMemberID),
"targetMemberName": strings.TrimSpace(body.TargetMemberName), "source": source,
})
_ = db.Create(&model.CkbLeadRecord{
rec := model.CkbLeadRecord{
UserID: body.UserID,
Nickname: name,
Phone: phone,
@@ -574,7 +783,9 @@ func CKBLead(c *gin.Context) {
TargetPersonID: body.TargetUserID,
Source: source,
Params: string(paramsJSON),
}).Error
PushStatus: "pending",
}
_ = db.Create(&rec).Error
ts := time.Now().Unix()
params := map[string]interface{}{
@@ -605,7 +816,8 @@ func CKBLead(c *gin.Context) {
resp, err := http.Get(reqURL)
if err != nil {
fmt.Printf("[CKBLead] 请求存客宝失败: %v\n", err)
c.JSON(http.StatusOK, gin.H{"success": false, "message": "网络异常,请稍后重试"})
markLeadPushFailed(db, rec.ID, err.Error(), true)
c.JSON(http.StatusOK, gin.H{"success": true, "message": "添加成功,我们正在为您安排对接"})
return
}
defer resp.Body.Close()
@@ -618,6 +830,7 @@ func CKBLead(c *gin.Context) {
}
_ = json.Unmarshal(b, &result)
if result.Code == 200 {
markLeadPushSuccess(db, rec.ID)
who := targetName
if who == "" {
who = "对方"
@@ -639,15 +852,15 @@ func CKBLead(c *gin.Context) {
data["repeatedSubmit"] = repeatedSubmit
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg, "data": data})
@@ -713,16 +926,17 @@ func CKBLead(c *gin.Context) {
msg = fmt.Sprintf("提交成功,%s 会尽快联系您", who)
}
go sendLeadWebhook(db, leadWebhookPayload{
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
LeadName: name,
Phone: phone,
Wechat: wechatId,
PersonName: who,
MemberName: strings.TrimSpace(body.TargetMemberName),
TargetMemberID: targetMemberID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
Source: source,
Repeated: repeatedSubmit,
LeadUserID: body.UserID,
})
markLeadPushSuccess(db, rec.ID)
c.JSON(http.StatusOK, gin.H{"success": true, "message": msg})
return
}
@@ -737,9 +951,10 @@ func CKBLead(c *gin.Context) {
}
}
fmt.Printf("[CKBLead] 存客宝返回异常 code=%d msg=%s raw=%s leadKey=%s\n", result.Code, ckbMsg, string(b), leadKey)
markLeadPushFailed(db, rec.ID, errMsg, true)
respObj := gin.H{
"success": false,
"message": errMsg,
"success": true,
"message": "添加成功,我们正在为您安排对接",
"ckbCode": result.Code,
"ckbMessage": ckbMsg,
}
@@ -750,15 +965,15 @@ func CKBLead(c *gin.Context) {
}
type leadWebhookPayload struct {
LeadName string // 留资客户姓名
Phone string
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
LeadName string // 留资客户姓名
Phone string
Wechat string
PersonName string // 对接人Person 表 name / targetNickname
MemberName string // 超级个体名称targetMemberName
TargetMemberID string // 超级个体 userId用于按人路由 webhook
Source string // 技术来源标识
Repeated bool
LeadUserID string // 留资用户ID用于查询行为轨迹
Source string // 技术来源标识
Repeated bool
LeadUserID string // 留资用户ID用于查询行为轨迹
}
func leadSourceLabel(source string) string {
@@ -783,7 +998,7 @@ func leadSourceLabel(source string) string {
var _webhookDedupCache = struct {
sync.Mutex
m map[string]string
m map[string]string
}{m: make(map[string]string)}
func webhookShouldSkip(userId string, targetMemberID string) bool {

View File

@@ -233,6 +233,28 @@ func CronRetryOrderWebhooks(c *gin.Context) {
})
}
// CronRetryCkbLeads GET/POST /api/cron/retry-ckb-leads
// 重推存客宝失败留资记录,并更新 ckb_lead_records.push_status。
func CronRetryCkbLeads(c *gin.Context) {
limit := 100
if s := strings.TrimSpace(c.Query("limit")); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
retried, success, err := RetryFailedCkbLeads(c.Request.Context(), limit)
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"retried": retried,
"pushed": success,
"limit": limit,
})
}
// CronUnbindExpired GET/POST /api/cron/unbind-expired
func CronUnbindExpired(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true})

View File

@@ -142,6 +142,7 @@ func defaultMpUi() gin.H {
"chaptersPage": gin.H{
"bookTitle": "一场SOUL的创业实验场",
"bookSubtitle": "来自Soul派对房的真实商业故事",
"newBadgeText": "NEW",
},
"homePage": gin.H{
"logoTitle": "卡若创业派对", "logoSubtitle": "来自派对房的真实故事",
@@ -549,12 +550,13 @@ func DBConfigGet(c *gin.Context) {
q := db.Table("system_config")
if key != "" {
q = q.Where("config_key = ?", key)
q = q.Order("updated_at DESC")
}
if err := q.Find(&list).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if key != "" && len(list) == 1 {
if key != "" && len(list) > 0 {
var val interface{}
_ = json.Unmarshal(list[0].ConfigValue, &val)
c.JSON(http.StatusOK, gin.H{"success": true, "data": val})
@@ -956,6 +958,11 @@ func DBConfigPost(c *gin.Context) {
if body.Key == "article_ranking_weights" || body.Key == "pinned_section_ids" {
cache.InvalidateBookCache()
}
// 目录篇图标/角标变更后,立即使目录缓存失效,避免前台看到旧值
if body.Key == "book_part_icons" || body.Key == "book_part_badges" {
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
}
c.JSON(http.StatusOK, gin.H{"success": true, "message": "配置保存成功"})
}

View File

@@ -523,6 +523,7 @@ func DBBookAction(c *gin.Context) {
TargetPartTitle string `json:"targetPartTitle"`
TargetChapterTitle string `json:"targetChapterTitle"`
ID string `json:"id"`
NewID string `json:"newId"`
Title string `json:"title"`
Content string `json:"content"`
Price *float64 `json:"price"`
@@ -762,12 +763,46 @@ func DBBookAction(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
err = db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
newID := strings.TrimSpace(body.NewID)
idChanged := newID != "" && newID != body.ID
if idChanged {
var existed int64
if err := db.Model(&model.Chapter{}).Where("id = ? AND id <> ?", newID, body.ID).Count(&existed).Error; err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
if existed > 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "章节ID已存在请换一个"})
return
}
updates["id"] = newID
}
if idChanged {
err = db.Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error; err != nil {
return err
}
// 同步历史关联数据,避免改 ID 后订单/阅读记录断链
if err := tx.Model(&model.Order{}).
Where("product_type = ? AND product_id = ?", "section", body.ID).
Update("product_id", newID).Error; err != nil {
return err
}
if err := tx.Table("reading_progress").
Where("section_id = ?", body.ID).
Update("section_id", newID).Error; err != nil {
return err
}
return nil
})
} else {
err = db.Model(&model.Chapter{}).Where("id = ?", body.ID).Updates(updates).Error
}
if err != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error()})
return
}
cache.InvalidateChapterContentByID(body.ID)
cache.InvalidateChapterContent(existing.MID)
cache.InvalidateBookParts()
InvalidateChaptersByPartCache()
cache.InvalidateBookCache()

View File

@@ -3,6 +3,7 @@ package handler
import (
"net/http"
"strconv"
"strings"
"soul-api/internal/database"
"soul-api/internal/model"
@@ -29,6 +30,7 @@ func DBCKBLeadList(c *gin.Context) {
if mode == "contact" {
search := c.Query("search")
source := c.Query("source")
pushStatus := strings.TrimSpace(c.Query("pushStatus"))
q := db.Model(&model.CkbLeadRecord{})
if search != "" {
q = q.Where("nickname LIKE ? OR phone LIKE ? OR wechat_id LIKE ? OR name LIKE ?",
@@ -37,6 +39,9 @@ func DBCKBLeadList(c *gin.Context) {
if source != "" {
q = q.Where("source = ?", source)
}
if pushStatus != "" {
q = q.Where("push_status = ?", pushStatus)
}
var total int64
q.Count(&total)
var records []model.CkbLeadRecord
@@ -80,6 +85,11 @@ func DBCKBLeadList(c *gin.Context) {
"targetPersonId": r.TargetPersonID,
"personName": personName,
"ckbPlanId": ckbPlanId,
"pushStatus": r.PushStatus,
"retryCount": r.RetryCount,
"ckbError": r.CkbError,
"lastPushAt": r.LastPushAt,
"nextRetryAt": r.NextRetryAt,
"createdAt": r.CreatedAt,
})
}
@@ -96,8 +106,8 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{
"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize,
"stats": gin.H{
"uniqueUsers": uniqueUsers,
"sourceStats": sourceStats,
"uniqueUsers": uniqueUsers,
"sourceStats": sourceStats,
},
})
return
@@ -158,6 +168,27 @@ func DBCKBLeadList(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": true, "records": out, "total": total, "page": page, "pageSize": pageSize})
}
// DBCKBLeadRetry POST /api/db/ckb-leads/retry 管理端-手动重推单条失败线索
func DBCKBLeadRetry(c *gin.Context) {
var body struct {
ID int64 `json:"id" binding:"required"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.ID <= 0 {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "缺少有效 id"})
return
}
ok, err := RetryCkbLeadByID(c.Request.Context(), body.ID)
if err != nil {
msg := strings.TrimSpace(err.Error())
if msg == "" {
msg = "重推失败"
}
c.JSON(http.StatusOK, gin.H{"success": false, "error": msg})
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "pushed": ok})
}
// CKBPlanStats GET /api/db/ckb-plan-stats 存客宝获客计划统计(基于 ckb_submit_records + ckb_lead_records
func CKBPlanStats(c *gin.Context) {
db := database.DB()

View File

@@ -621,7 +621,7 @@ func CKBPinnedPerson(c *gin.Context) {
return
}
nickname := strings.TrimSpace(p.Name)
avatar := strings.TrimSpace(p.Avatar)
avatar := ""
if p.UserID != nil && *p.UserID != "" {
var u model.User
if db.Select("nickname", "avatar").Where("id = ?", *p.UserID).First(&u).Error == nil {

View File

@@ -4,17 +4,21 @@ import "time"
// CkbLeadRecord 链接卡若留资记录(独立表,便于后续链接其他用户等扩展)
type CkbLeadRecord struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
Phone string `gorm:"column:phone;size:20" json:"phone"`
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
TargetPersonID string `gorm:"column:target_person_id;size:100" json:"targetPersonId"` // 被@的人物 personId
Source string `gorm:"column:source;size:50" json:"source"` // 来源index_lead / article_mention
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
CkbError string `gorm:"column:ckb_error;size:500" json:"ckbError"` // 存客宝请求失败时写入错误信息,便于运营排查
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
ID int64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
UserID string `gorm:"column:user_id;size:50;index" json:"userId"`
Nickname string `gorm:"column:nickname;size:100" json:"nickname"`
Phone string `gorm:"column:phone;size:20" json:"phone"`
WechatID string `gorm:"column:wechat_id;size:100" json:"wechatId"`
Name string `gorm:"column:name;size:100" json:"name"` // 用户填的姓名/昵称
TargetPersonID string `gorm:"column:target_person_id;size:100" json:"targetPersonId"` // 被@的人物 personId
Source string `gorm:"column:source;size:50" json:"source"` // 来源index_lead / article_mention
Params string `gorm:"column:params;type:json" json:"params"` // 完整传参 JSON
PushStatus string `gorm:"column:push_status;size:20;default:'pending';index" json:"pushStatus"` // pending/success/failed
RetryCount int `gorm:"column:retry_count;default:0" json:"retryCount"`
LastPushAt *time.Time `gorm:"column:last_push_at" json:"lastPushAt,omitempty"`
NextRetryAt *time.Time `gorm:"column:next_retry_at" json:"nextRetryAt,omitempty"`
CkbError string `gorm:"column:ckb_error;size:500" json:"ckbError"` // 存客宝请求失败时写入错误信息,便于运营排查
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
}
func (CkbLeadRecord) TableName() string { return "ckb_lead_records" }

View File

@@ -166,6 +166,8 @@ func Setup(cfg *config.Config) *gin.Engine {
cron.POST("/sync-orders", handler.CronSyncOrders)
cron.GET("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.POST("/retry-order-webhooks", handler.CronRetryOrderWebhooks)
cron.GET("/retry-ckb-leads", handler.CronRetryCkbLeads)
cron.POST("/retry-ckb-leads", handler.CronRetryCkbLeads)
cron.GET("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.POST("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
cron.GET("/unbind-expired", handler.CronUnbindExpired)
@@ -223,6 +225,7 @@ func Setup(cfg *config.Config) *gin.Engine {
db.DELETE("/link-tags", handler.DBLinkTagDelete)
db.GET("/persons/pinned", handler.DBPersonPinnedList)
db.GET("/ckb-leads", handler.DBCKBLeadList)
db.POST("/ckb-leads/retry", handler.DBCKBLeadRetry)
db.GET("/ckb-person-leads", handler.DBCKBPersonLeads)
db.GET("/ckb-plan-stats", handler.CKBPlanStats)
db.GET("/user-rules", handler.DBUserRulesList)