同步
This commit is contained in:
@@ -9,3 +9,8 @@
|
||||
### 当前状态
|
||||
- 2026-03-20 需求(提现、我的收益、推广设置等)已与实现对齐。
|
||||
- 主需求、落地推进表已基本同步;项目索引已补齐至 2026-03-24。
|
||||
|
||||
## 需求与进度及三端闭环评审
|
||||
|
||||
- 区分「用户主路径闭环」与「规则/风控闭环」;后者缺口见《产品意图与功能闭环分析》,用清单驱动排期而非推翻里程碑。
|
||||
- 待确认:VIP 支付前是否强制完善头像昵称(与当前 vip.js 策略二选一)。
|
||||
|
||||
@@ -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 纪要)。
|
||||
|
||||
@@ -10,3 +10,13 @@
|
||||
### 落地建议
|
||||
- 各角色在完成功能开发或吸收经验时,主动更新项目索引。
|
||||
- 橙子收尾时统一检查并补齐索引。
|
||||
|
||||
## 需求与进度及三端闭环评审(跨角色)
|
||||
|
||||
### 结论摘要
|
||||
- **主业务闭环**:阅读/付费/代付/分销/提现主链路在文档与里程碑上成立;**规则闭环**有已知缺口,以《产品意图与功能闭环分析》《需求未补齐清单》为准。
|
||||
- **三端配套**:小程序页面域与管理端路由域整体对齐;**miniprogram/admin/db 边界未被破坏**。
|
||||
- **工程契约**:统一「失败时 body 可解析」约定(`err.response`),否则后端 errorCode 设计无法在前端落地。
|
||||
|
||||
### 详见
|
||||
- `.cursor/meeting/2026-03-24_需求与进度及三端闭环评审.md`
|
||||
|
||||
@@ -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` 说明。
|
||||
|
||||
@@ -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,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ soul-api(Go + 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,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.response;soulBridge 留资扩展;阅读页带码与 referral/my 提现 catch 对齐 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@
|
||||
| 2026-03-18 | 文档归档整理:按界面驱动口径统一验收;补充分享 singlePage 降级与引导为必测项 | 已完成 |
|
||||
| 2026-03-18 | 会议:新增用例(资料默认阻断支付、Person 自动创建幂等、昵称变更同步回归) | 已完成 |
|
||||
| 2026-03-24 | 会议:开发进度同步;项目索引补齐;提现/登录/收益/推广设置用例补充;singlePage、getPhoneNumber 边界 | 已完成 |
|
||||
| 2026-03-24 | 会议:三端闭环评审;API 绕过负例与双入口提现回归待契约落地后执行 | 已完成 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
|
||||
@@ -8,3 +8,8 @@
|
||||
|
||||
### 待办
|
||||
- DistributionPage Order.description 类型错误待修(与本次迁移无关,有空即修)。
|
||||
|
||||
## 需求与进度及三端闭环评审
|
||||
|
||||
- 管理端路由域与小程序 Tab/子业务整体配套(审核、配置、导师、匹配、存客宝相关)。
|
||||
- C 端绕过接口的风险主要靠后端 + 小程序请求层闭环,非管理端菜单能单独解决。
|
||||
|
||||
@@ -6,3 +6,8 @@
|
||||
- 提现审批、手机号登录、我的收益、推广设置、提现审核等新增功能需补充/更新用例。
|
||||
- 边界必测:singlePage 分享进入、getPhoneNumber 隐私协议同意流程。
|
||||
- 下次回归前完成用例更新。
|
||||
|
||||
## 需求与进度及三端闭环评审
|
||||
|
||||
- 补齐「仅调 API、绕过前端」负例:提现、CKBJoin、MatchUsers。
|
||||
- 后端与 err.response 契约落地后,做双入口提现(我的 / 推广)回归。
|
||||
|
||||
117
.cursor/meeting/2026-03-24_需求与进度及三端闭环评审.md
Normal file
117
.cursor/meeting/2026-03-24_需求与进度及三端闭环评审.md
Normal 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.1~6.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 返回 needBindWechat;CKBJoin/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`*
|
||||
@@ -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) |
|
||||
|
||||
@@ -8,6 +8,12 @@ alwaysApply: false
|
||||
|
||||
在 **miniprogram/**、**soul-admin/** 或 **soul-api/** 下做任何**修改、优化、新增**后,必须按下列项过一遍,确认关联层已同步,避免只改一端导致数据不一致或功能缺管理入口。
|
||||
|
||||
## 零漏改原则(强制)
|
||||
|
||||
- 任一端出现**新功能/新字段/新文案/新状态/新交互**,必须联想另外两端是否需要补齐。
|
||||
- 验收至少满足四项闭环:**DB 可存 + API 可读写 + 管理端可配置/可管理 + 小程序可渲染/可使用**。
|
||||
- 禁止只在小程序硬编码可运营内容(如角标、标签、按钮文案);默认应有管理端入口或配置来源。
|
||||
|
||||
## 一、按「你改了什么」对表检查
|
||||
|
||||
| 你改的是… | 必须同时检查/修改的关联 |
|
||||
|
||||
@@ -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;阅读页 getReferralCodeForPay;referral/my 提现 catch;miniprogram-dev SKILL 更新
|
||||
|
||||
@@ -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、表)
|
||||
|
||||
|
||||
@@ -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`。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 }))
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"component": true
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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' })
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
<!-- 头像:点击直接弹出微信原生选择器;头像与文字水平对齐 -->
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
// 纯图片行独立成段
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
165
scripts/test/测试报告-2026-03-24-本地核心功能场景.md
Normal file
165
scripts/test/测试报告-2026-03-24-本地核心功能场景.md
Normal 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/403):1
|
||||
- `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. 复测记录
|
||||
|
||||
| 复测日期 | 复测范围 | 结果 | 剩余问题 | 结论 |
|
||||
|----------|----------|------|----------|------|
|
||||
| (待填) | (待填) | (待填) | (待填) | (待填) |
|
||||
|
||||
1
soul-admin/dist/assets/index-D2vksFq_.css
vendored
Normal file
1
soul-admin/dist/assets/index-D2vksFq_.css
vendored
Normal file
File diff suppressed because one or more lines are too long
914
soul-admin/dist/assets/index-DQUjJh41.js
vendored
914
soul-admin/dist/assets/index-DQUjJh41.js
vendored
File diff suppressed because one or more lines are too long
1006
soul-admin/dist/assets/index-Dk6CvQRe.js
vendored
1006
soul-admin/dist/assets/index-Dk6CvQRe.js
vendored
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-akpRWHlb.css
vendored
1
soul-admin/dist/assets/index-akpRWHlb.css
vendored
File diff suppressed because one or more lines are too long
1010
soul-admin/dist/assets/index-l3L5iIYa.js
vendored
Normal file
1010
soul-admin/dist/assets/index-l3L5iIYa.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
soul-admin/dist/assets/index-qjssBjc3.css
vendored
1
soul-admin/dist/assets/index-qjssBjc3.css
vendored
File diff suppressed because one or more lines are too long
26
soul-admin/dist/index.html
vendored
26
soul-admin/dist/index.html
vendored
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">小程序界面文案 mpUi(JSON)</Label>
|
||||
<Button
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}})
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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})
|
||||
|
||||
@@ -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": "配置保存成功"})
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user