Merge branch 'yongpxu-dev' into devlop

# Conflicts:
#	miniprogram/pages/read/read.wxml   resolved by yongpxu-dev version
#	miniprogram/utils/contentParser.js   resolved by yongpxu-dev version
This commit is contained in:
乘风
2026-04-20 11:27:42 +08:00
61 changed files with 1336 additions and 1548 deletions

View File

@@ -0,0 +1,6 @@
# 产品经理 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**:以可验收功能点为最小同步单位;主需求与《以界面定需求》为验收真源;跨端规则变更须同时更新需求清单状态与优先级。
- **待办**:核对 2026-04-02 以来实现与文档差异vip-members/融合一期验收口径在需求文档中单点定稿。

View File

@@ -0,0 +1,7 @@
# 产品经理 经验记录 - 2026-04-14
## 吸收沉淀:同步开发需求
- **需求清单**:在 `开发文档/1、需求/需求汇总.md` 维护编号行SYNC / LOOP / MAINT 等),状态与责任角色与纪要待办一致。
- **验收绑定**:功能完成 = 实现可合并 + **需求表或项目索引当日一行**;跨端规则须在需求或运营与变更中单点写清优先级。
- **管理端必要性**:沿用产品 Skill — 纯文案/纯视觉可不建管理端;**配置/开关/审核/统计** 必须显式在需求表标「管理端入口」避免壳功能。

View File

@@ -6,3 +6,5 @@
| 2026-03-05 | 文章详情@某人高亮与一键加好友验收标准与待确认 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp主导航收敛与隐藏页面入口承载策略 | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-24 | 开发进度同步会议:文档同步原则、项目索引补齐 | [2026-03-24.md](./2026-03-24.md) |
| 2026-04-13 | 按功能同步开发文档会议:验收与需求真源、功能完成定义 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀:需求汇总表维护、验收与管理端必要性 | [2026-04-14.md](./2026-04-14.md) |

View File

@@ -0,0 +1,6 @@
# 后端工程师 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**接口变更须写明路由组miniprogram/admin/db、路径、字段与错误码表结构/迁移在部署文档或临时需求池留痕;历史待办按 P0/P1 标注与依赖方。
- **待办**router/ossConfig、vip-members 等待办在文档中优先级化。

View File

@@ -0,0 +1,6 @@
# 后端工程师 经验记录 - 2026-04-14
## 吸收沉淀:接口与需求文档同步
- 新增/变更接口须在需求或运营摘要中写明 **路由组miniprogram/admin/db**、路径、关键字段与错误码;迁移写在 `开发文档/8、部署/` 或临时需求池(若存在)。
- **P0/P1 待办**router、ossConfig、vip-members 等)在 `需求汇总.md` 已单列 BACKEND-P0 行,合并前更新状态与依赖方。

View File

@@ -10,3 +10,5 @@
| 2026-03-16 | ParseAutoLinkContent data-label存客宝 create planType/sceneId/status | [2026-03-16.md](./2026-03-16.md) |
| 2026-03-17 | 代付 PayNotify beneficiaryUserID 权益归发起人gift-pay detail 返回 initiatorUserId | [2026-03-17.md](./2026-03-17.md) |
| 2026-03-24 | router 缺失四 handlerBookRanking、DBPersonPinnedToken、CKBPinnedPerson、AdminDashboardLeads | [2026-03-24.md](./2026-03-24.md) |
| 2026-04-13 | 按功能同步开发文档:接口/迁移文档化与 P0/P1 标注 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀接口与需求表同步、BACKEND-P0 行 | [2026-04-14.md](./2026-04-14.md) |

View File

@@ -0,0 +1,6 @@
# 团队 经验记录 - 2026-04-13
## 会议:按功能同步开发文档(跨角色)
- **决议**:功能完成定义包含 **代码可合并状态 + 当日文档/项目索引一行摘要(含日期)**;跨端字段或接口变更除各角色文档外,须在团队 evolution 或会议纪要留契约摘要。
- **关联**:详见 `agent/产品经理|后端工程师|管理端开发工程师|小程序开发工程师|软件测试/evolution/2026-04-13.md``.cursor/meeting/2026-04-13_按功能同步开发文档.md`

View File

@@ -0,0 +1,19 @@
# 团队 经验记录 - 2026-04-14
## 吸收沉淀:小程序优先闭环 + 需求同步
### 场景
评审「功能是否闭环」、区分界面写死与配置化、识别仅有样式无管理端入口、评估对其它模块的影响。
### 结论(团队约定)
1. **逆推顺序**:从小程序用户路径出发 → 数据来源(常量 / 本地 / `app.request`)→ `/api/miniprogram/*` 是否存在与错误语义 → 是否需要 **管理端或远程配置**(运营是否会改、是否与钱/风控相关)→ 是否影响支付/登录/分享/其它页 `onShow`
2. **写死 vs 配置化**:按 **变更频率、责任主体(运营/研发)、风险** 决策;需运行时开关/灰度时采用 **Feature Toggle** 思路(见业界 Martin Fowler *Feature Toggles*),避免永久堆砌 `if(flag)` 无退役计划。
3. **壳功能信号**:有入口但无 API、仅有样式、管理端无对应配置且业务上明显需运营改的标为 **技术债或假完成**,需求表状态不得标「已完成」。
4. **跨模块同步**:契约线(字段/枚举/缓存)、流程线(支付/分销/scene、权限配置线变更须在 **需求汇总 / 运营与变更 / 纪要** 之一可追溯,并双写本文件或 `2026-04-13` 纪要中的契约摘要。
5. **仓库需求真源**:本仓库已建 `开发文档/1、需求/需求汇总.md``开发文档/10、项目管理/运营与变更.md`,与 `.cursor/meeting/`、各角色 evolution 交叉引用。
### 同时影响
小程序、管理端、后端、测试、产品(文档与验收)。

View File

@@ -0,0 +1,50 @@
# 团队 经验记录 - 2026-04-15
## 联网吸收:优秀编程思维 → 智能体执行约束
> 以下为公开资料的**提炼与落地映射**,便于 Soul 多智能体Cursor 角色)统一行为;**非**替代项目 boundary / Skill 全文。
### 1. 垂直切片Vertical slicing
- **含义**:按「用户可感知的一小条价值」贯穿 **展示 → 接口 → 数据/配置**,而不是长期只做「全后端再全前端」的水平分层半成品。
- **对智能体**:拆任务时优先 **end-to-end 最小可演示**(例如:一条只读列表从真实 API 出数),再迭代字段与交互;避免单次会话产出大量未接线 UI。
**参考**:敏捷社区对 vertical slicing 的讨论(如 *The Art of the Vertical Slice* 等文章,关键词 *vertical slice user story*)。
### 2. 追踪弹Tracer bullets
- **出处思想**《The Pragmatic Programmer》——用一发「能穿过各层的薄路径」验证架构是否通畅。
- **与原型区别**:原型可丢;追踪弹是**进主分支的骨架**,后续在其上增厚。
- **对智能体**:强制 **小步、可编译、可运行、可测** 的增量;先打通再美化,降低大模型「一次写太多未验证代码」的风险。延伸阅读:*Tracer Bullets: Keeping AI Slop Under Control*`https://www.aihero.dev/tracer-bullets`)。
### 3. AI 编码与多智能体:验证优先
- **上下文优先**:先给目的、约束、边界、失败模式,再让生成代码(多篇 20242025 *AI coding best practices* 共性)。
- **分块生成 + 人审合并**:大纲 → 分文件小改 → 跑测试/静态检查;不把未读 diff 直接合并。
- **评估维度(借鉴 Agent 质量讨论)**:任务是否完成、工具/接口调用是否准确、是否引入多余依赖(参见 AWS 等「Agent 质量评估」类文章中的 TCR、工具准确率等提法
### 4. 工程文化:无责复盘与简单性
- **Blameless postmortem**:复盘关注**系统与流程**如何允许失误、行动项是否可验证避免归罪个人利于真实写入经验库Google SRE Book — Postmortem Culture`https://sre.google/sre-book/postmortem-culture/`)。
- **简单性**:偏好无聊但清晰的实现;与 Feature Toggle 一样,**实验性分支要有退役计划**。
### 5. 映射到本仓库Soul
| 外部思维 | 本仓库落地 |
|----------|------------|
| 垂直切片 / 追踪弹 | 跨端任务走 `role-flow-control`:先契约与最小联调,再扩功能;纪要/需求表可追溯 |
| 验证优先 | `change-checklist`、测试 Skill、`app.request` 与三端路由自检 |
| 无责复盘 | `.cursor/meeting/*` 问题与作答区 + 各角色 `evolution` 按日沉淀 |
| 简单性 | 禁止跨端乱调路由;避免无退役计划的永久 feature flag |
---
## 业务补齐(同日追加)
- **`app.request`**`success:false` 与部分非 200 响应在 `reject``Error` 上挂载 **`response`**,与 `miniprogram-dev` Skill 及 `referral/my` 提现分支一致。
- **导师咨询支付**`productType=mentor_consultation``getStandardPrice` 校验预约单归属与金额;`activateOrderBenefits` + `PayNotify``mentor_consultations.status``paid`**分销佣金**对导师单显式跳过(待产品另定规则)。
- **资源对接 `investor`**`CKBJoin` 未购章节时 **`success:false` + `errorCode: CONTENT_PURCHASE_REQUIRED`**,小程序 `match.js``e.response` 展示文案。
---
**最后更新**2026-04-15

View File

@@ -11,3 +11,6 @@
| 2026-03-16 | TipTap Mention 需 data-label否则显示 token | [2026-03-16.md](./2026-03-16.md) |
| 2026-03-17 | 代付美团式流程与权益归属约定:读页→代付页→分享;权益/分佣归发起人 | [2026-03-17.md](./2026-03-17.md) |
| 2026-03-24 | 文档同步原则:实现变更后同步需求/运营与变更/项目索引 | [2026-03-24.md](./2026-03-24.md) |
| 2026-04-13 | 按功能同步开发文档:功能完成=实现+索引;跨端契约双写 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 小程序优先闭环审计、写死vs配置化、需求表骨架与 role-flow §1.2 | [2026-04-14.md](./2026-04-14.md) |
| 2026-04-15 | 联网垂直切片、追踪弹、AI验证优先、无责复盘role-flow §1.3 | [2026-04-15.md](./2026-04-15.md) |

View File

@@ -0,0 +1,6 @@
# 小程序开发工程师 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**:页面级记录 `/api/miniprogram/*`、scene、支付/登录、分享 singlePage 等行为变更;纯 UI 无接口变更时也注明,便于测试裁剪回归。
- **待办**:主路径未记入文档的改动补索引一行。

View File

@@ -0,0 +1,6 @@
# 小程序开发工程师 经验记录 - 2026-04-14
## 吸收沉淀:闭环与硬编码自检
- 新功能自检:**页面 → 数据从哪来**;若仅为写死展示,在索引注明「无接口/无配置」以免误判完成度。
- 涉及 **scene、支付、登录、分享 singlePage** 的改动必须在 `需求汇总` 或项目索引可追溯,并评估对 **我的/阅读/代付** 等邻页的副作用。

View File

@@ -15,3 +15,5 @@
| 2026-03-17 | 代付美团式:读页→代付页→分享;详情页双态(发起人/好友);目录 loading、最新新增 5 条折叠 | [2026-03-17.md](./2026-03-17.md) |
| 2026-03-30 | 阅读页 `navigateToMiniProgram` 时 path 追加 `phone`(已登录且已绑定) | [2026-03-30.md](./2026-03-30.md) |
| 2026-04-11 | 阅读付费墙三态框体统一、`shareTipLine` 与文末分享区一致、需求文档闭环 | [2026-04-11-阅读付费墙三态与营销句闭环.md](./2026-04-11-阅读付费墙三态与营销句闭环.md) |
| 2026-04-13 | 按功能同步开发文档:页面级接口与分享行为、无接口变更亦标注 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀:闭环与硬编码自检、邻页副作用 | [2026-04-14.md](./2026-04-14.md) |

View File

@@ -0,0 +1,6 @@
# 开发助理(橙子)经验记录 - 2026-04-14
## 吸收沉淀:各智能体同步开发需求
- 触发:用户「各智能体吸收经验沉淀,同步开发需求」→ 执行 `assistant-doc-sync` §5 + 文档同步:补齐仓库根 `开发文档/` 骨架,写入 `需求汇总.md``运营与变更.md`
- 同步 `经验清单.md``智能体-技能-经验总索引.md`、各角色 `evolution/索引.md`、项目索引一行、`sync-log`;轻量升级 `role-flow-control` §1.2(小程序逆推闭环)。

View File

@@ -0,0 +1,7 @@
# 开发助理 经验记录 - 2026-04-15
## 联网吸收:编程思维写入智能体
- **触发**:用户要求联网吸收优秀编程思维并沉淀到智能体。
- **动作**检索垂直切片、追踪弹、AI 编码验证、SRE 无责复盘等公开论述;写入 `agent/团队/evolution/2026-04-15.md`;在 `role-flow-control/SKILL.md` 增加 **§1.3 智能体协作提要**;更新 `经验清单``智能体-技能-经验总索引`、团队/助理橙子项目索引、`sync-log`
- **注意**:外链为思想参考;执行顺序仍以本仓库 `.cursor/rules` 与各主 Skill 为准。

View File

@@ -6,3 +6,5 @@
|------|------|------|
| 2026-03-21 | MBTI 头像小程序全链路兜底 + 匹配接口回填 | 2026-03-21-MBTI头像C端全链路兜底.md |
| 2026-03-16 | 用户交互习惯分析(基于 agent-transcripts | 2026-03-16-交互习惯分析.md |
| 2026-04-14 | 各智能体吸收经验沉淀开发文档骨架、经验清单、role-flow §1.2 | 2026-04-14.md |
| 2026-04-15 | 联网吸收编程思维:团队 evolution、role-flow §1.3、索引与 sync-log | 2026-04-15.md |

View File

@@ -53,6 +53,8 @@
| 2026-03-17 | 后端、团队 | 架构/最佳实践 | api-dev SKILL | Redis 缓存parts/hot/recommended/stats/config/章节 content容灾回退 DBOSS 上传;/health 返回 database/redis 状态 |
| 2026-03-18 | 小程序、团队 | 业务规则/最佳实践 | - | 分享链路兼容好友/朋友圈 singlePage单页模式能力降级不支付/不自动领取),引导点击底部“前往小程序”进入完整版 |
| 2026-03-18 | 产品、后端、管理端、测试 | 文档归档/需求口径 | - | 文档归档整理:以《以界面定需求》为基准,各角色重整“功能需求+验收口径+风险点”并写入各自经验库;补齐《项目落地推进表》 |
| 2026-04-14 | 团队、产品、后端、管理端、小程序、测试、助理橙子 | 方法论/需求同步 | role-flow-control SKILL §1.2、assistant-doc-sync 文档树说明 | 吸收沉淀小程序逆推闭环、写死vs配置、需求汇总+运营与变更骨架;各角色 evolution/2026-04-14.md |
| 2026-04-15 | 团队、助理橙子 | 方法论(联网) | role-flow-control SKILL §1.3 | 垂直切片、追踪弹与 AI slop 控制、验证优先、Google SRE 无责复盘;详 `agent/团队/evolution/2026-04-15.md` |
---
@@ -63,4 +65,4 @@
---
**最后更新**2026-03-18
**最后更新**2026-04-15

View File

@@ -6,7 +6,7 @@
## 项目总结
Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/2026-03-20-需求.md`(主需求清单),闭环结论见 `开发文档/10、项目管理/闭环与文档同步-2026-04-07.md`,临时需求/分析在 `临时需求池/`
Soul 创业派对产品定位:面向创业者的社区/工具型小程序。核心需求文档在 `开发文档/1、需求/2026-03-20-需求.md`(主需求清单,若未入库见同目录 `需求汇总.md`),闭环结论见 `开发文档/10、项目管理/闭环与文档同步-2026-04-07.md`(若存在),临时需求/分析在 `临时需求池/`(按需创建)
---
@@ -31,9 +31,11 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
| 2026-03-31 | 会议:超级个体列表与 @ 列表融合方案(先数据互链再单页);一期验收口径待拍板 | 待续 |
| 2026-04-02 | 会议:工作进度与需求同步;一期融合验收口径澄清、各端索引与需求 delta 跟进 | 进行中 |
| 2026-04-07 | 开发文档与主需求交叉同步;闭环分析落盘(主路径成立、规则/风控与纪要问题区待闭环) | 已完成 |
| 2026-04-13 | 会议:按功能同步开发文档;功能完成=实现+文档/索引可追溯;待办见纪要问题区 | 进行中 |
| 2026-04-14 | 吸收沉淀:新建 `开发文档/1、需求/需求汇总.md``开发文档/10、项目管理/运营与变更.md`;需求清单 SYNC/LOOP/MAINT/BACKEND 行 | 进行中 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-07
**最后更新**2026-04-14

View File

@@ -26,9 +26,11 @@
| 2026-03-17 | 会议收尾:源码优化完成与测试流程定稿;纪要、经验入库、开发文档同步 | 已完成 |
| 2026-04-02 | 会议收尾:工作进度与需求同步会;纪要、各角色 evolution、项目索引与会议索引更新 | 已完成 |
| 2026-04-07 | 同步开发文档与主需求;新增《闭环与文档同步-2026-04-07》并更新索引、sync-log | 已完成 |
| 2026-04-14 | 吸收经验:各角色 evolution/2026-04-14、开发文档骨架、经验清单、总索引、role-flow §1.2 | 已完成 |
| 2026-04-15 | 联网吸收编程思维:团队 evolution/2026-04-15、role-flow §1.3、经验清单与 sync-log | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-07文档同步与闭环分析
**最后更新**2026-04-15

View File

@@ -43,9 +43,12 @@ soul-apiGo + Gin + GORM + MySQL提供三组路由`/api/miniprogram/*`
| 2026-03-24 | 会议:三端闭环评审;提现/CKB/匹配后端校验与 errorCode 为 P0/P1 | 已完成 |
| 2026-03-31 | 会议:超级个体与 @ 列表融合;优先扩展 vip-members 聚合 Person 关联字段 | 待续 |
| 2026-04-02 | 会议工作进度与需求同步deploy 验证后提交vip-members 聚合优先router/ossConfig 历史待办跟进 | 进行中 |
| 2026-04-13 | 会议:按功能同步开发文档;接口/迁移变更须标路由组与 P0/P1文档待补齐 | 进行中 |
| 2026-04-14 | 吸收沉淀:`需求汇总.md` 增加 BACKEND-P0 行;接口说明与迁移落文档 | 进行中 |
| 2026-04-15 | 业务补齐:`mentor_consultation` 支付计价/回调/权益;`CKBJoin` investor 显式失败;`getStandardPrice` 增加 buyerUserID | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-02
**最后更新**2026-04-15

View File

@@ -34,9 +34,12 @@ Soul 创业派对全项目架构与约定路由隔离miniprogram/admin/db
| 2026-03-31 | 会议:超级个体与 @ 列表融合;先数据与入口融合、路由边界不变 | 已完成 |
| 2026-04-02 | 会议:工作进度与需求同步;融合落地顺序与 deploy 验证门禁、问题与作答区待闭环 | 已完成 |
| 2026-04-07 | 文档同步:需求与开发文档索引对齐;闭环结论:主路径成立、规则/风控待加固项见《闭环与文档同步-2026-04-07》 | 已完成 |
| 2026-04-13 | 会议:按功能同步开发文档;功能完成=实现+索引一行;跨端契约双写团队/纪要 | 进行中 |
| 2026-04-14 | 吸收沉淀:小程序逆推闭环方法论;`role-flow-control` §1.2;开发文档需求真源 | 进行中 |
| 2026-04-15 | 联网:垂直切片/追踪弹/验证优先/无责复盘 → `role-flow-control` §1.3、`团队/evolution/2026-04-15.md` | 已完成 |
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
---
**最后更新**2026-04-07
**最后更新**2026-04-15

View File

@@ -49,9 +49,12 @@
| 2026-03-30 | 会议:文章详情 # 跳转关联小程序(如 MBTI时 path 追加 `phone`read.js 已实现 | 已完成 |
| 2026-03-31 | 会议:超级个体与 @ 列表融合C 端默认不改接口,仅回归 | 已完成 |
| 2026-04-02 | 会议:工作进度与需求同步;全局 app/request 类改动需主路径冒烟;融合仍以回归为主 | 已完成 |
| 2026-04-13 | 会议:按功能同步开发文档;页面级记 miniprogram 接口与分享行为;无接口变更亦标注 | 进行中 |
| 2026-04-14 | 吸收沉淀:逆推闭环自检写入 evolutionLOOP 需求行 | 进行中 |
| 2026-04-15 | 业务补齐:`app.js``err.response`;导师详情微信支付;找伙伴 join 失败展示后端文案 | 已完成 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-02
**最后更新**2026-04-15

View File

@@ -36,7 +36,9 @@
| 2026-03-24 | 会议三端闭环评审API 绕过负例与双入口提现回归待契约落地后执行 | 已完成 |
| 2026-03-31 | 会议:超级个体与 @ 列表融合;回归清单含排序/@/绑定/Webhook 边界 | 待续 |
| 2026-04-02 | 会议工作进度与需求同步融合清单待联调后执行deploy PR 增加 compose/health/smoke | 待续 |
| 2026-04-13 | 会议:按功能同步开发文档;以文档与索引为真源更新回归;契约未入文档可阻塞 | 进行中 |
| 2026-04-14 | 吸收沉淀LOOP 需求与逆推清单纳入回归裁剪依据 | 进行中 |
---
**最后更新**2026-04-02
**最后更新**2026-04-14

View File

@@ -48,11 +48,13 @@
| 2026-03-24 | 会议:三端闭环评审;管理端与小程序配套整体齐全;类型债择机修 | 已完成 |
| 2026-03-31 | 会议:超级个体与 @ 列表融合UsersPage 表格加 @ 状态与跳转内容页 | 待续 |
| 2026-04-02 | 会议工作进度与需求同步UsersPage 待接口字段就绪后联调deploy 文档基址与后端对齐 | 进行中 |
| 2026-04-13 | 会议:按功能同步开发文档;页面粒度记 admin/db 依赖;类型债排期入文档 | 进行中 |
| 2026-04-14 | 吸收沉淀MAINT-ADMIN-TS 入需求表;配置入口与运营与变更联动 | 进行中 |
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD状态用已完成 / 进行中 / 待续 / 搁置
---
**最后更新**2026-04-02
**最后更新**2026-04-14
> soul-admin 构建仍有 DistributionPage Order.description 类型错误(与本次迁移无关),待修复。

View File

@@ -7,25 +7,37 @@
## 1. 经验索引(按角色)
- 助理橙子:`agent/开发助理/evolution/索引.md`
- 2026-04-15联网编程思维沉淀 + role-flow §1.3
- 2026-04-14各智能体吸收沉淀 + 开发文档骨架与 role-flow §1.2
- 2026-03-21MBTI 头像小程序全链路兜底 + 匹配接口回填
- 2026-03-16用户交互习惯分析基于 agent-transcripts
- 小程序开发工程师:`agent/小程序开发工程师/evolution/索引.md`
- 2026-04-14吸收沉淀闭环自检、邻页副作用
- 2026-04-13按功能同步开发文档页面级接口与分享、无接口变更标注
- 2026-03-30阅读页 `navigateToMiniProgram` 时 path 追加 `phone`
- 2026-03-17代付美团式流程与目录 loading/折叠
- 2026-03-16编辑资料页分享名片Canvas 封面)
- 管理端开发工程师:`agent/管理端开发工程师/evolution/索引.md`
- 2026-04-14吸收沉淀配置入口、MAINT-ADMIN-TS
- 2026-04-13按功能同步开发文档页面粒度 admin/db、字段对齐
- 2026-03-16链接人与事复制/删除 Dialog、planId/apiKey 列)
- 2026-03-10迁移 Mycontent-temp主导航收敛/Settings 承载)
- 2026-03-05文章详情@某人(编辑页插入 @ 用户)
- 后端工程师:`agent/后端工程师/evolution/索引.md`
- 2026-04-14吸收沉淀接口与需求表、BACKEND-P0
- 2026-04-13按功能同步开发文档接口/迁移文档化、P0/P1
- 2026-03-24router 缺失四 handler 补齐BookRanking 等)
- 2026-03-17PayNotify 权益归属发起人
- 2026-03-14内容排名算法修正rank 分公式)
- 产品经理:`agent/产品经理/evolution/索引.md`
- 2026-04-14吸收沉淀需求汇总表、管理端必要性
- 2026-04-13按功能同步开发文档会议功能完成定义/验收真源)
- 2026-03-24开发进度同步会议文档同步原则/项目索引补齐)
- 2026-03-10需求同步管理端迁移入口策略
- 2026-03-05文章详情@某人(验收标准与待确认)
- 软件测试:`agent/软件测试/evolution/索引.md`
- 2026-04-14吸收沉淀LOOP 逆推清单回归)
- 2026-04-13按功能同步开发文档文档为真源、契约缺失可阻塞
- 2026-03-16scripts/test/process 流程测试目录
- 2026-03-05文章详情@某人(用例、联调与回归点)
- 2026-03-10管理端迁移回归鉴权/跳转可达性)
@@ -33,6 +45,9 @@
- 2026-03-20挖矿病毒排查经验转化为 Skills
- 2026-03-20部署 API / 部署触发词(环境与脚本映射)
- 团队(跨角色共享):`agent/团队/evolution/索引.md`
- 2026-04-15联网编程思维垂直切片、追踪弹、验证优先、无责复盘
- 2026-04-14小程序优先闭环、写死vs配置化、需求真源路径
- 2026-04-13按功能同步开发文档实现+索引、跨端契约双写)
- 2026-03-16TipTap Mention 需 data-label
- 2026-03-17代付美团式流程与权益归属约定
- 2026-03-24文档同步原则实现变更后同步需求/运营/索引)
@@ -58,7 +73,7 @@
- 触发:开个会、团队会议、需求评审、方案讨论
- 变更关联检查(防漏改,改完必过):`skills/change-checklist/SKILL.md`
- 触发:变更完成、检查一下、准备提交
- 跨端协同流程(职责分工/联想补齐):`skills/role-flow-control/SKILL.md`
- 跨端协同流程(职责分工/联想补齐/小程序逆推闭环/智能体协作提要`skills/role-flow-control/SKILL.md`
- 触发:跨端功能开发、三端联动优化
- 三端架构分析/迁移拆解约束:`skills/three-tier-arch/SKILL.md`
- 触发:拆解、迁移、框架选择、三端架构
@@ -76,6 +91,8 @@
- 触发部署、服务器操作、SSH、devloy、master
- 卡若创业派对运营(报表/切片/分发/飞书视频文字下载):`skills/karuo-party/SKILL.md`
- 触发:运营报表、视频切片、多平台分发、飞书视频下载、派对运营
- 飞书/Lark 群自定义机器人 Webhook与 Soul 订单·获客推送契约一致,可迁移):`skills/feishu-webhook/SKILL.md`
- 触发:飞书 webhook、自定义机器人、群通知、Lark bot、从 Soul 迁移通知
---

View File

@@ -0,0 +1,6 @@
# 管理端开发工程师 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**:以页面/菜单为粒度同步至运营与变更或项目索引;标注所用 `/api/admin/*``/api/db/*`;与小程序共用字段写明来源。
- **待办**DistributionPage 类型债是否纳入排期,在文档或索引中闭环。

View File

@@ -0,0 +1,6 @@
# 管理端开发工程师 经验记录 - 2026-04-14
## 吸收沉淀:配置入口与需求表
-**页面/菜单** 为粒度在 `运营与变更.md` 或项目索引记录所用 `/api/admin/*``/api/db/*`;与小程序对齐的字段写明来源。
- **MAINT-ADMIN-TS**`DistributionPage` 等类型债已进 `需求汇总.md`,排期修复后更新状态为已完成。

View File

@@ -8,3 +8,5 @@
| 2026-03-05 | 文章详情@某人:编辑页插入 @用户、保存约定 content 格式 | [2026-03-05.md](./2026-03-05.md) |
| 2026-03-10 | 管理端迁移 Mycontent-temp 菜单/布局主导航收敛、Settings Tab 承载 author/admin | [2026-03-10.md](./2026-03-10.md) |
| 2026-03-16 | 链接人与事table 布局、planId/apiKey 列、复制图标、删除 Dialog 弹窗 | [2026-03-16.md](./2026-03-16.md) |
| 2026-04-13 | 按功能同步开发文档:页面粒度 admin/db 与字段对齐说明 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀:配置入口与 MAINT-ADMIN-TS 需求行 | [2026-04-14.md](./2026-04-14.md) |

View File

@@ -0,0 +1,6 @@
# 软件测试 经验记录 - 2026-04-13
## 会议:按功能同步开发文档
- **要点**:以更新后的开发文档与各角色项目索引为真源维护回归与联调清单;契约未入文档可标阻塞。
- **待办**依据各端索引更新融合与主路径用例deploy/health/smoke 与项目管理文档交叉引用。

View File

@@ -0,0 +1,6 @@
# 软件测试 经验记录 - 2026-04-14
## 吸收沉淀:以文档为真源的回归
- **LOOP-20260414**:按「小程序优先逆推」清单编写/裁剪用例;文档与索引未更新的契约项可标 **阻塞—待文档** 并指向纪要问题区。
- 主路径冒烟与 deploy/health/smoke 要求见 `运营与变更.md` 与历史纪要交叉引用。

View File

@@ -10,3 +10,5 @@
| 2026-03-16 | scripts/test/process 流程测试:跨端多接口串联 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-03-16 | pytest 架构、配置从项目读取、运行前显示测试环境 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-03-16 | 测试用例归档规则混合→process、纯端→miniapp/web需求变更时更新 | [2026-03-16-scripts目录与测试关联.md](./2026-03-16-scripts目录与测试关联.md) |
| 2026-04-13 | 按功能同步开发文档:以文档与索引为真源、契约缺失可阻塞 | [2026-04-13.md](./2026-04-13.md) |
| 2026-04-14 | 吸收沉淀LOOP 需求行与逆推清单回归 | [2026-04-14.md](./2026-04-14.md) |

View File

@@ -0,0 +1,108 @@
# 会议纪要 - 2026-04-13 | 按功能同步开发文档
> 本文件由**助理橙子**在会议结束后自动生成。
---
## 基本信息
- **时间**2026-04-13 10:00
- **议题**:各角色依据本人负责的开发功能,同步开发文档(需求汇总、运营与变更、项目索引、临时需求池与接口契约说明)
- **触发方式**:开个会 / 团队会议(文档同步专项)
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员、乘风(主持)、助理橙子(书记)
---
## 各角色发言
### 【产品经理】
需求侧以 `开发文档/1、需求/` 主需求与《以界面定需求》为验收基准。各端每完成一块可交付功能,须在 **需求清单/验收口径** 中对应更新状态;跨端规则(如 VIP、提现、@ 人)变更必须写清业务规则与优先级,避免「代码已上、文档仍旧」。
### 【后端开发】
每新增或变更接口,须在实现合并前同步:`路由组miniprogram/admin/db`、路径、请求/响应关键字段、错误码约定;涉及表结构或迁移的,在 `开发文档/8、部署/` 或临时需求池技术分析中留痕。历史待办(如 router/ossConfig、vip-members 聚合)在文档中标注 **P0/P1** 与依赖方。
### 【管理端开发工程师】
管理端功能以页面/菜单为粒度,在 `开发文档/10、项目管理/运营与变更.md` 或需求汇总中追加「做了什么、调了哪些 `/api/admin/*` `/api/db/*`」;与小程序字段对齐处(列表、详情、配置项)写明字段来源,便于测试写用例。
### 【小程序开发工程师】
C 端以页面与核心流程为粒度同步:涉及 `/api/miniprogram/*` 的变更、scene、支付/登录分支、分享 singlePage 行为,须在开发文档或角色项目索引中可追溯;仅改 UI 也需注明「无接口变更」以便测试裁剪回归范围。
### 【测试人员】
文档同步后,测试以 **文档与索引为真源** 更新回归清单与联调顺序;三端契约变更若文档未更新,测试有权将项标为「阻塞—待文档」并在问题与作答区登记。
---
## 讨论过程
- **【管理端开发工程师】回复【产品经理】**:界面定需求已存在,建议「功能完成」定义包含:代码合并 + 文档/索引一行摘要,避免遗漏。
- **【后端开发】回复【小程序开发工程师】**miniprogram 与 admin 路由隔离不变;文档中写清「仅供某端调用」可避免联调误用。
- **【测试人员】回复【全员】**deploy/health/smoke 与关键路径用例在 `开发文档/10、项目管理` 与测试索引中保持交叉引用,便于发版前自检。
---
## 会议决议
1. **按功能粒度同步**:以「可验收功能点」为最小单位,责任角色在合并前或合并当日更新:对应 `开发文档` 章节、`.cursor/agent/开发助理/项目索引/{角色}.md` 开发进度表一行(**必须含日期**)。
2. **跨端变更双写**:影响两端的字段或接口,除本角色文档外,须在 `agent/团队/evolution/` 或会议纪要中留架构/契约摘要,并在相关角色索引中各记一行。
3. **待确认项进表**:接口路径、错误码、验收口径未定的,一律落入本次会议「问题与作答区」,由责任角色补充作答后闭环。
---
## 待办事项
| 责任角色 | 任务 | 优先级 | 截止建议 |
|---------|------|--------|---------|
| 产品经理 | 核对主需求与《以界面定需求》与各端近期实现差异,更新需求汇总状态 | 高 | 2026-04-20 |
| 后端开发 | 将 router/ossConfig、vip-members 等待办在文档中标注优先级与依赖 | 中 | 2026-04-20 |
| 管理端开发工程师 | soul-admin 近期页面/接口变更写入运营与变更或索引 | 中 | 2026-04-20 |
| 小程序开发工程师 | 阅读/支付/分享等主路径若有未记文档的改动,补索引与说明 | 中 | 2026-04-20 |
| 测试人员 | 依据更新后的文档刷新融合与主路径回归清单 | 中 | 2026-04-20 |
---
## 问题与作答区
> 会议中提出的待确认问题在此列出;作答区域供后续补充答案,便于追溯闭环。
| # | 问题 | 责任角色 | 作答 |
|---|------|---------|------|
| 1 | 各端「最近一次合并到主分支」的功能清单是否与项目索引 2026-04-02 之后实际代码一致? | 各角色负责人 | (待补充) |
| 2 | vip-members 聚合与超级个体/@ 融合一期验收口径是否已在需求文档中单点定稿? | 产品经理 | (待补充) |
| 3 | DistributionPage `Order.description` 类型债是否纳入本迭代文档与修复排期? | 管理端开发工程师 | (待补充) |
---
## 各角色经验与业务理解更新
### 产品经理
- 文档同步与验收基准绑定:功能完成 = 实现 + 文档/索引可追溯。
### 后端开发
- 接口与迁移变更必须标明路由组与优先级,避免管理端/小程序误调。
### 管理端开发工程师
- 以页面为粒度记录 admin/db 依赖,字段与小程序对齐处显式写出。
### 小程序开发工程师
- 无接口变更的纯 UI 迭代也应在索引中标注,便于测试裁剪范围。
### 测试人员
- 以更新后的文档与索引为真源维护回归清单;文档缺失可阻塞验收。
### 团队共享
- 延续 2026-03-24 文档同步原则:实现变更后同步需求、运营与变更、各角色项目索引;跨端契约写入团队 evolution 或会议纪要。
---
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-04-13.md`*

View File

@@ -85,3 +85,4 @@ YYYY-MM-DD_会议主题.md
| 2026-03-30 | 文章详情 # 标签跳转 MBTI 小程序传手机号 | 产品、后端、管理端、小程序、测试 | [2026-03-30_文章详情跳转MBTI小程序传手机号.md](2026-03-30_文章详情跳转MBTI小程序传手机号.md) |
| 2026-03-31 | 超级个体列表与 @ 列表融合方案 | 产品、后端、管理端、小程序、测试、团队 | [2026-03-31_超级个体与@列表融合.md](2026-03-31_超级个体与@列表融合.md) |
| 2026-04-02 | 工作进度与需求同步会 | 产品、后端、管理端、小程序、测试、团队 | [2026-04-02_工作进度与需求同步会.md](2026-04-02_工作进度与需求同步会.md) |
| 2026-04-13 | 按功能同步开发文档 | 产品、后端、管理端、小程序、测试、团队 | [2026-04-13_按功能同步开发文档.md](2026-04-13_按功能同步开发文档.md) |

View File

@@ -157,13 +157,13 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
## 文档结构速查
```
开发文档/
开发文档/ # 仓库根目录下;若尚未克隆外部大文档,可先维护本骨架与摘要
├── 1、需求/需求汇总.md # 需求清单、业务需求
├── 8、部署/ # 技术方案、部署说明
├── 8、部署/ # 技术方案、部署说明(按需创建)
├── 10、项目管理/
│ ├── 项目落地推进表.md # 里程碑、永平落地
│ ├── 项目落地推进表.md # 里程碑、永平落地(按需创建)
│ └── 运营与变更.md # 近期更新、变更记录
临时需求池/ # 需求分析、技术分析、待办
临时需求池/ # 需求分析、技术分析、待办(按需创建)
.cursor/agent/ # 每角色 evolution 经验池
├── 开发助理/evolution/、项目索引/{小程序,管理端,后端,产品,测试,助理橙子,团队}.md
├── 小程序开发工程师/evolution/

View File

@@ -6,6 +6,19 @@
---
## 2026-04-15
- 吸收经验(联网):编程思维沉淀至 `agent/团队/evolution/2026-04-15.md`;升级 `role-flow-control` §1.3`经验清单`、总索引、团队/助理橙子项目索引、sync-log 已更新
- 业务补齐:`app.request` 失败体挂 `err.response`;导师咨询 `mentor_consultation` 支付全链路;`CKBJoin` investor 假成功改为显式失败;`需求汇总` 更新
## 2026-04-14
- 吸收经验:各智能体沉淀 + 同步开发需求;新建 `开发文档/1、需求/需求汇总.md``开发文档/10、项目管理/运营与变更.md``开发文档/README.md`;各角色 `evolution/2026-04-14.md` 与索引;`经验清单``智能体-技能-经验总索引`、项目索引七处;升级 `role-flow-control` §1.2、`assistant-doc-sync` 文档树说明
## 2026-04-13
- 会议收尾:按功能同步开发文档;纪要 `.cursor/meeting/2026-04-13_按功能同步开发文档.md`;各角色 evolution、项目索引产品/后端/管理端/小程序/测试/团队、meeting/README 已更新
## 2026-04-07
- 文档同步:开发文档索引、主需求 2026-03-20、需求索引、10 项目管理 README 与《闭环与文档同步-2026-04-07》交叉引用项目索引助理橙子/团队追加进度

View File

@@ -0,0 +1,112 @@
---
name: feishu-custom-bot-webhook
description: 飞书(及 Lark 国际版)群自定义机器人 Webhook 对接。与 Soul 订单/获客推送同契约;迁移到其他项目时复用 JSON 格式与分支规则。Use when 飞书 webhook、自定义机器人、群通知、Lark bot、从 Soul 迁移通知.
---
# 飞书自定义机器人 Webhook可迁移 Skill
本 Skill 描述 **HTTP 自定义机器人** 向飞书群发消息的契约与落地方式。Soul 后端在「非企业微信 URL」时统一走 **飞书/Lark 文本消息** 格式;复制到其他语言或项目时,只需保留 **URL 判别 + JSON 结构 + POST application/json**
**与 Open API 的区别**:自定义机器人只需 Webhook 地址与固定 JSON**不需要** `app_id`/`app_secret`/tenant token。若你要调飞书开放平台 API发卡片、@人、多维表格),不在本 Skill 范围。
---
## 1. 消息体契约(文本)
### 1.1 飞书 / Lark 自定义机器人
- **Content-Type**`application/json`
- **结构**
```json
{
"msg_type": "text",
"content": {
"text": "第一行\n第二行"
}
}
```
- **Webhook URL 特征**:常见为 `https://open.feishu.cn/open-apis/bot/v2/hook/...``https://open.larksuite.com/open-apis/bot/v2/hook/...`(以飞书控制台生成的为准)。
### 1.2 企业微信群机器人Soul 并存)
若 URL 包含 `qyapi.weixin.qq.com`Soul 使用 **企业微信** 格式(字段名不同):
```json
{
"msgtype": "text",
"text": {
"content": "第一行\n第二行"
}
}
```
**迁移时的分支规则**:用 URL 是否包含 `qyapi.weixin.qq.com` 区分两套 payload其余一律按飞书格式发送与 Soul `order_webhook.go``ckb.go` 一致)。
---
## 2. Soul 项目中的参考实现(迁移源码锚点)
| 能力 | 文件 | 说明 |
|------|------|------|
| 支付成功订单推送 + 失败重试 | `soul-api/internal/handler/order_webhook.go` | `pushPaidOrderWebhook``RetryPendingPaidOrderWebhooks`;订单表字段 `webhook_push_*` |
| 获客线索推送 | `soul-api/internal/handler/ckb.go` | `sendLeadWebhook``loadLeadWebhookURL`1h 内同用户去重 |
| 定时补偿 | `soul-api/internal/handler/cron.go` | `CronRetryOrderWebhooks``/api/cron/retry-order-webhooks` |
| 按超级个体配置 Webhook | `soul-api/internal/handler/vip_members_admin.go` | `DBVipMemberWebhookSet`;配置键 `super_individual_webhook_map` |
### 2.1 配置存储(`system_config`
| `config_key` | 用途 |
|--------------|------|
| `order_paid_webhook_url` | 支付成功推送主地址JSON 字符串存 URL |
| `ckb_lead_webhook_url` | 全局获客 Webhook订单推送无主地址时的 fallback |
| `super_individual_webhook_map` | `userId → webhookUrl` 的 JSON 对象,获客按对接人路由 |
### 2.2 小程序配置 JSON上麦打赏等
- 路径:`system_config``mp_config``mpUi.homePage.liveMicSchedule.paidWebhookUrl`
- 仅当订单类型为 `link_karuo_tip``product_id` 形如 `live_mic|...` 时,**优先**使用该地址(见 `loadLiveMicTipPaidWebhookURL`)。
---
## 3. 迁移到其他项目的检查清单
1. **在飞书群添加自定义机器人**,复制 Webhook URL仅保存在服务端配置或密钥管理**不要**写进前端或公开仓库。
2. **实现分支**`qyapi` → 企微 JSON否则 → 飞书 `msg_type` + `content.text`
3. **HTTP**`POST``Content-Type: application/json`body 为 UTF-8 JSON检查响应状态码Soul 对 `>=400` 视为失败)。
4. **可靠性(可选)**:对关键业务(如支付通知)做「失败落库 + 定时重试」,避免仅 `go` 异步发送不感知失败。
5. **限流与重复**:飞书对机器人有频率限制;获客类通知可按「用户 + 业务键」做短时间去重Soul 为内存缓存 1h进程重启会清空——迁移时可改为 Redis
---
## 4. 最小验证curl
`YOUR_HOOK_URL` 换成真实 Webhook勿提交到 git
```bash
curl -sS -X POST "YOUR_HOOK_URL" \
-H "Content-Type: application/json" \
-d "{\"msg_type\":\"text\",\"content\":{\"text\":\"迁移验证 $(date +%Y-%m-%d\\ %H:%M)\"}}"
```
企微机器人请改用 `msgtype` / `text.content` 字段。
---
## 5. Gotchas踩坑
| 陷阱 | 后果 | 正确做法 |
|------|------|----------|
| 把企微的 `msgtype` 发到飞书 | 飞书返回错误,群无消息 | 严格按 URL 分支选 JSON |
| 把飞书的 `msg_type` 发到企微 | 同上 | 同上 |
| 误以为要飞书 Open API 凭证 | 过度设计 | 自定义机器人只需 Webhook |
| 日志打印完整 Webhook URL | 泄露群推送密钥 | 只打域名或脱敏 |
| 正文过长 | 可能被截断或拒收 | 控制单行长度;必要时分多条或改用卡片(需查飞书卡片文档,格式不同) |
| 仅依赖异步 `go func` 且无重试 | 支付通知丢失 | 关键路径参考 Soul`markOrderWebhookResult` + cron 补偿 |
---
## 6. 与 `karuo-party` Skill 的关系
`karuo-party` 侧重运营侧(报表、飞书**云文档/视频**下载等,多依赖 Token。本 Skill 仅覆盖 **群机器人 Webhook 文本推送**,与 Soul 订单/获客逻辑对齐,便于抽到任意后端项目。

View File

@@ -1,6 +1,6 @@
---
name: soul-role-workflow
description: Soul 创业派对开发协同流程。小程序开发工程师、管理端开发工程师、后端开发职责与协同。跨端功能开发、新增/优化时使用。Use when 跨端协同, 角色流程, 功能开发协作.
description: Soul 创业派对开发协同流程。小程序/管理端/后端职责、垂直切片与追踪弹、智能体协作提要。跨端功能开发、新增/优化、AI 多角色执行时使用。Use when 跨端协同, 角色流程, 功能开发协作, 智能体编程思维.
---
# Soul 创业派对 - 角色流程控制 Skill
@@ -27,6 +27,30 @@ description: Soul 创业派对开发协同流程。小程序开发工程师、
- 小程序:是否按接口/配置渲染而非硬编码。
- 默认交付口径:**可配置、可运营、可联调、可验收**,否则视为未完成。
### 1.2 从小程序逆推闭环(文档 / 需求同步)
评审或收尾时优先从 **C 端页面** 逆推,避免「只有样式、无接口/无配置」被误判为完成:
1. **用户路径**:入口、前置条件(登录 / VIP / 资料)、失败提示是否可验收。
2. **数据来源**:写死常量、`app.request`、本地缓存;若无后端/配置真源,须在 `开发文档/1、需求/需求汇总.md` 或当日项目索引标注「无接口/待配置」。
3. **路由与契约**:仅调 `/api/miniprogram/*`;跨端字段变更须同步 **后端契约 + 管理端(若需运营配置)+ 需求表一行**
4. **配置化判断**:运营是否可能在线变更、是否涉款/风控/合规;需要运行时灰度/熔断的采用 **Feature Toggle** 思路并规划退役,避免永久 `if(flag)`
5. **回归影响**支付、scene、分享 singlePage、邻页 `onShow`;测试以 **文档与索引为真源**,契约未入库可标阻塞。
详见:`agent/团队/evolution/2026-04-14.md`,需求清单:`开发文档/1、需求/需求汇总.md`
### 1.3 智能体协作:联网沉淀的编程思维(提要)
> 外部资料作**思想库**;执行仍须遵守本仓库 **boundary** 与 **miniprogram / admin / db** 路由隔离。
1. **垂直切片vertical slice**:以「可演示的用户价值」为单位打通 UI→API→持久化/配置,避免长期在单一层级堆半成品(敏捷实践中对 vertical slicing 的共识:更快用事实替代猜测)。
2. **追踪弹tracer bullets《The Pragmatic Programmer》**:先落地**薄而端到端**、可进主分支的骨架路径,用真实反馈校准架构;与「用完即扔」的原型不同,追踪弹是后续扩展的基座。用于约束多智能体/大模型:**小步打通再扩面**,控制一次性大 diff业界亦用该比喻讨论 AI 生成代码的验证节奏,如 aihero.dev 对 tracer bullets 与 AI slop 的论述)。
3. **先验证再堆量verify before build**:上下文与约束写清 → 分块实现 → **测试或最小复现** 再合并;可用 AI 辅助生成边界/对抗用例人做最终把关多篇「AI coding best practices」强调 context-first 与系统化验证)。
4. **无责复盘blameless postmortem**:故障与重大返工后,复盘聚焦**系统为何允许失败**、时间线与可验证改进行动,而非个人追责(参见 Google *Site Reliability Engineering*Postmortem Culture — `https://sre.google/sre-book/postmortem-culture/`)。
5. **简单性**:优先可读、可预测实现;临时开关/实验分支需**退役计划**,避免永久 `if(flag)` 分叉。
团队详述与引用:`agent/团队/evolution/2026-04-15.md`
---
## 2. 协同流程总览(线框图)

View File

@@ -1026,8 +1026,11 @@ App({
},
success: (res) => {
const data = res.data
const rejectWithBody = (message) => {
reject(new Error(message))
/** 业务失败时挂 err.response供页面读取 needBindWechat / errorCode 等(与 miniprogram-dev Skill 一致) */
const rejectWithBody = (message, body) => {
const err = new Error(message)
if (body && typeof body === 'object') err.response = body
reject(err)
}
if (res.statusCode === 200) {
if (data && data.success === false) {
@@ -1036,7 +1039,7 @@ App({
this.logout()
}
showError(msg)
rejectWithBody(msg)
rejectWithBody(msg, data)
return
}
resolve(data)
@@ -1050,7 +1053,7 @@ App({
}
const msg = this._getApiErrorMsg(data, res.statusCode >= 500 ? '服务器异常,请稍后重试' : '请求失败')
showError(msg)
rejectWithBody(msg)
rejectWithBody(msg, data && typeof data === 'object' ? data : undefined)
},
fail: (err) => {
const msg = (err && err.errMsg)
@@ -1090,8 +1093,8 @@ App({
}
const promise = this._requestOnce(url, options, silent).catch((err) => {
const msg = (err && err.message) ? err.message : '网络异常,请重试'
throw new Error(msg)
if (err && err.message) throw err
throw new Error('网络异常,请重试')
})
if (method === 'GET') {

View File

@@ -692,7 +692,11 @@ Page({
wx.showToast({ title: res.error || '加入失败', icon: 'none' })
}
} catch (e) {
wx.showToast({ title: '网络异常,请重试', icon: 'none' })
const resp = e && e.response
wx.showToast({
title: (resp && (resp.message || resp.error)) || (e && e.message) || '网络异常,请重试',
icon: 'none',
})
} finally {
this.setData({ isJoining: false })
}

View File

@@ -3,6 +3,7 @@
* 联系导师按钮 → 弹出 v2 弹窗(选择咨询项目)
*/
const app = getApp()
const soulBridge = require('../../utils/soulBridge')
Page({
data: {
@@ -79,6 +80,11 @@ Page({
this.setData({ selectedType: item.type, selectedAmount: item.price })
},
_consultationTypeLabel(t) {
const m = { single: '单次咨询', half_year: '半年咨询', year: '年度咨询' }
return m[t] || '咨询'
},
async onConfirmConsult() {
const { mentor, selectedType } = this.data
const userId = app.globalData.userInfo?.id
@@ -94,22 +100,57 @@ Page({
method: 'POST',
data: { userId, consultationType: selectedType },
})
if (res?.success && res.data) {
this.setData({ showConsultModal: false, creating: false })
wx.showToast({ title: '预约创建成功', icon: 'success' })
// TODO: 调起支付 productType: mentor_consultation, productId: res.data.id
if (!res?.success || !res.data) {
wx.showToast({ title: res?.error || '创建失败', icon: 'none' })
return
}
this.setData({ showConsultModal: false })
const consult = res.data
const openId = await app.ensurePayOpenId()
if (!openId) {
wx.showModal({
title: '预约成功',
content: '请联系客服完成后续对接',
title: '无法拉起支付',
content: '未获取到微信支付标识,请完全关闭小程序后重新进入,或退出账号重新登录。',
showCancel: false,
})
} else {
wx.showToast({ title: res?.error || '创建失败', icon: 'none' })
return
}
wx.showLoading({ title: '正在拉起支付...', mask: true })
try {
const label = this._consultationTypeLabel(selectedType)
const payRes = await app.request('/api/miniprogram/pay', {
method: 'POST',
data: {
openId,
userId,
productType: 'mentor_consultation',
productId: String(consult.id),
amount: Number(consult.amount),
description: `导师咨询 · ${(mentor && mentor.name) || '导师'}${label}`,
referralCode: soulBridge.getReferralCodeForPay(app) || undefined,
},
})
if (payRes?.success && payRes.data?.payParams) {
try {
await soulBridge.requestWxJsapiPayment(payRes.data.payParams)
await soulBridge.syncOrderStatusQuery(app, payRes.data.orderSn)
wx.showToast({ title: '支付成功', icon: 'success' })
} catch (pe) {
const msg = pe && pe.errMsg ? String(pe.errMsg) : ''
if (msg.indexOf('cancel') !== -1) wx.showToast({ title: '支付取消', icon: 'none' })
else wx.showToast({ title: '支付未完成', icon: 'none' })
}
} else {
wx.showToast({ title: payRes?.error || '获取支付参数失败', icon: 'none' })
}
} finally {
wx.hideLoading()
}
} catch (e) {
wx.showToast({ title: '创建失败', icon: 'none' })
wx.showToast({ title: (e && e.message) || '操作失败', icon: 'none' })
} finally {
this.setData({ creating: false })
}
this.setData({ creating: false })
},
goBack() {

View File

@@ -10,6 +10,11 @@ const { trackClick } = require('../../utils/trackClick')
const { cleanSingleLineField } = require('../../utils/contentParser.js')
const { navigateMpPath } = require('../../utils/mpNavigate.js')
const { isSafeImageSrc } = require('../../utils/imageUrl.js')
const { appendQueryToPath, ensureLoggedInUserPhone } = require('../../utils/linkedMiniprogramNavigate.js')
/** MBTI 测试小程序(写死;须与 app.json navigateToMiniProgramAppIdList 一致) */
const MBTI_MP_APP_ID = 'wx3d15ed02e98b04e3'
const MBTI_MP_PATH = 'pages/index/index'
Page({
data: {
@@ -95,6 +100,8 @@ Page({
mpUiVipLabelGuest: '成为会员',
mpUiReadStatLabel: '已读章节',
mpUiRecentTitle: '最近阅读',
/** 资料区 MBTI 标签文案已测为四字母大写未测为「MBTI」 */
mbtiTagText: '',
},
onLoad() {
@@ -147,6 +154,12 @@ Page({
})
},
_formatMbtiTagText(user) {
const raw = String(user && user.mbti != null ? user.mbti : '').trim()
if (!raw) return 'MBTI'
return raw.toUpperCase()
},
async _refreshMyAvatarDisplay(safeUser) {
if (!safeUser || !app.globalData.isLoggedIn) return
try {
@@ -198,6 +211,7 @@ Page({
isLoggedIn: true,
userInfo: safeUser,
profileAvatarDisplay: '',
mbtiTagText: this._formatMbtiTagText(safeUser),
userIdShort,
userWechat,
readCount: 0,
@@ -227,6 +241,7 @@ Page({
isLoggedIn: false,
userInfo: null,
profileAvatarDisplay: '',
mbtiTagText: '',
userIdShort: '',
readCount: guestReadCount,
readCountText: formatStatNum(guestReadCount),
@@ -1065,6 +1080,26 @@ Page({
wx.navigateTo({ url: `/pages/member-detail/member-detail?id=${encodeURIComponent(uid)}` })
},
/** MBTI 测试小程序:写死 appId + pathpath 上带 phone有则传与阅读页一致 */
async onMbtiTagTap() {
trackClick('my', 'btn_click', 'MBTI标签')
if (!this.data.isLoggedIn) {
this.showLogin()
return
}
const phone = await ensureLoggedInUserPhone()
let path = MBTI_MP_PATH.trim().replace(/^\/+/, '')
if (phone) path = appendQueryToPath(path, 'phone', phone)
wx.navigateToMiniProgram({
appId: MBTI_MP_APP_ID,
path,
envVersion: 'release',
fail: (err) => {
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
},
})
},
goToProfileEdit() {
trackClick('my', 'nav_click', '资料编辑')
if (!this.data.isLoggedIn) { this.showLogin(); return }

View File

@@ -36,6 +36,7 @@
<view class="profile-actions-row profile-actions-under-name" wx:if="{{!auditMode}}">
<view class="profile-action-btn" catchtap="goToMySuperCard">{{mpUiCardLabel}}</view>
<view class="profile-action-btn" catchtap="goToVip">{{isVip ? mpUiVipLabelVip : mpUiVipLabelGuest}}</view>
<view class="profile-mbti-tag" catchtap="onMbtiTagTap">{{mbtiTagText}}</view>
</view>
</view>
<view class="profile-settings-hit" catchtap="goToProfileEdit" hover-class="profile-settings-hit-active" aria-label="编辑资料">

View File

@@ -105,6 +105,26 @@
font-size: 24rpx; font-weight: 500; border-radius: 40rpx; white-space: nowrap; flex-shrink: 0;
}
.profile-action-btn:active { opacity: 0.75; }
/* MBTI高亮胶囊实心青渐变 + 白字 + 外发光),与描边按钮区分 */
.profile-mbti-tag {
padding: 12rpx 26rpx;
border-radius: 40rpx;
font-size: 24rpx;
font-weight: 700;
letter-spacing: 0.06em;
color: #fff;
background: linear-gradient(135deg, #5eead4 0%, #4FD1C5 45%, #2dd4bf 100%);
border: 1rpx solid rgba(255, 255, 255, 0.35);
box-shadow:
0 4rpx 16rpx rgba(79, 209, 197, 0.45),
0 0 0 1rpx rgba(79, 209, 197, 0.25) inset;
white-space: nowrap;
flex-shrink: 0;
}
.profile-mbti-tag:active {
opacity: 0.88;
transform: scale(0.98);
}
.user-wechat { font-size: 26rpx; color: #6B7280; }
.super-card-entry {
position: relative;

View File

@@ -79,13 +79,13 @@
<image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image>
</view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap">
<video class="content-video" src="{{item[0].src}}" controls show-center-play-btn object-fit="contain"></video>
<video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video>
</view>
<view wx:else class="paragraph">
<text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text>
<block wx:for="{{item}}" wx:key="index" wx:for-item="seg">
<image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image>
<video wx:elif="{{seg.type === 'video'}}" class="content-video" src="{{seg.src}}" controls show-center-play-btn object-fit="contain"></video>
<video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video>
</block>
</view>
</block>
@@ -157,8 +157,8 @@
<scroll-view wx:elif="{{item.length === 1 && item[0].type === 'table'}}" scroll-x class="table-scroll"><view class="content-table"><view class="table-row table-header-row" wx:if="{{item[0].headers.length}}"><text class="table-cell table-header-cell" wx:for="{{item[0].headers}}" wx:key="index" wx:for-item="hdr">{{hdr}}</text></view><view class="table-row" wx:for="{{item[0].rows}}" wx:key="index" wx:for-item="row"><text class="table-cell" wx:for="{{row}}" wx:key="index" wx:for-item="cell">{{cell}}</text></view></view></scroll-view>
<view wx:elif="{{item.length === 1 && item[0].type === 'listItem'}}" class="seg-list-item"><text class="seg-list-marker" wx:if="{{item[0].ordered}}">{{item[0].number}}.</text><text class="seg-list-marker" wx:else>•</text><text class="seg-list-text" user-select>{{item[0].text}}</text></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph"><image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video" src="{{seg.src}}" controls show-center-play-btn object-fit="contain"></video></block></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
</block>
</view>
<view class="fade-mask"></view>
@@ -250,8 +250,8 @@
<scroll-view wx:elif="{{item.length === 1 && item[0].type === 'table'}}" scroll-x class="table-scroll"><view class="content-table"><view class="table-row table-header-row" wx:if="{{item[0].headers.length}}"><text class="table-cell table-header-cell" wx:for="{{item[0].headers}}" wx:key="index" wx:for-item="hdr">{{hdr}}</text></view><view class="table-row" wx:for="{{item[0].rows}}" wx:key="index" wx:for-item="row"><text class="table-cell" wx:for="{{row}}" wx:key="index" wx:for-item="cell">{{cell}}</text></view></view></scroll-view>
<view wx:elif="{{item.length === 1 && item[0].type === 'listItem'}}" class="seg-list-item"><text class="seg-list-marker" wx:if="{{item[0].ordered}}">{{item[0].number}}.</text><text class="seg-list-marker" wx:else>•</text><text class="seg-list-text" user-select>{{item[0].text}}</text></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'image'}}" class="paragraph"><image class="content-image" src="{{item[0].src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{item[0].src}}"></image></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video" src="{{seg.src}}" controls show-center-play-btn object-fit="contain"></video></block></view>
<view wx:elif="{{item.length === 1 && item[0].type === 'video'}}" class="paragraph content-video-wrap"><video class="content-video" src="{{item[0].src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></view>
<view wx:else class="paragraph"><text user-select><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><text wx:if="{{seg.type === 'text'}}">{{seg.text}}</text><text wx:elif="{{seg.type === 'mention'}}" class="mention" bindtap="onMentionTap" data-user-id="{{seg.userId}}" data-nickname="{{seg.nickname}}">{{seg.mentionDisplay}}</text><text wx:elif="{{seg.type === 'linkTag'}}" class="link-tag" bindtap="onLinkTagTap" data-url="{{seg.url}}" data-label="{{seg.label}}" data-tag-type="{{seg.tagType}}" data-page-path="{{seg.pagePath}}" data-tag-id="{{seg.tagId}}" data-app-id="{{seg.appId}}" data-mp-key="{{seg.mpKey}}">#{{seg.label}}</text></block></text><block wx:for="{{item}}" wx:key="index" wx:for-item="seg"><image wx:if="{{seg.type === 'image'}}" class="content-image" src="{{seg.src}}" mode="widthFix" show-menu-by-longpress bindtap="onImageTap" data-src="{{seg.src}}"></image><video wx:elif="{{seg.type === 'video'}}" class="content-video content-video--inline" src="{{seg.src}}" controls show-center-play-btn enable-play-gesture object-fit="contain"></video></block></view>
</block>
</view>
<view class="fade-mask"></view>

View File

@@ -290,6 +290,22 @@
display: block;
}
.content-video-wrap {
width: 100%;
margin: 24rpx 0;
}
.content-video {
width: 100%;
display: block;
border-radius: 12rpx;
background: #111;
}
.content-video--inline {
margin: 24rpx 0;
}
/* 正文内表格 */
.table-scroll {
margin: 24rpx 0 36rpx;

View File

@@ -7,7 +7,7 @@
* { type: 'mention', userId, nickname } — @某人,点击加好友(提交存客宝见 utils/soulBridge.submitCkbLead
* { type: 'linkTag', label, url, ... } — #链接标签,点击跳转(阅读页 onLinkTagTap外链→link-preview、小程序→navigateToMiniProgram
* { type: 'image', src, alt } — 图片
* { type: 'video', src } — 正文内嵌视频(管理端 rich-video-wrap / <video>
* { type: 'video', src } — 内嵌视频(管理端 rich-video-wrap / <video>
*/
/** 判断内容是否为 HTML */
@@ -57,6 +57,24 @@ function extractImgSrcFromTag(tag) {
return ''
}
/** 从 <video ...> 或包含 video 的 HTML 片段取出首个 src */
function extractVideoSrcFromTag(tag) {
const vm = tag.match(/<video[^>]*>/i)
const chunk = vm ? vm[0] : tag
return extractImgSrcFromTag(chunk)
}
function pushResolvedVideoSrc(videos, rawSrc, config) {
if (!rawSrc || typeof rawSrc !== 'string') return
const decoded = decodeEntities(rawSrc.trim())
if (!decoded) return
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
videos.push({ src })
}
/**
* 单行展示用:昵称、#标签文案、章节外标题类字段 — 合并换行、<br>、连续空白(避免 TipTap/粘贴带入异常断行)
*/
@@ -82,7 +100,7 @@ function stripTrailingAtForMention(before) {
/**
* 将一个 HTML block 字符串解析为 segments 数组
* 处理三种内联元素mention / linkTag(span) / linkTag(a) / img
* 处理内联元素mention / linkTag(span) / linkTag(a) / img / video
*/
function parseBlockToSegments(block, config) {
const segs = []
@@ -93,7 +111,7 @@ function parseBlockToSegments(block, config) {
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
const tokenRe = /<span[^>]*data-type="mention"[^>]*>[\s\S]*?<\/span>|<span[^>]*data-type="linkTag"[^>]*>[\s\S]*?<\/span>|<a[^>]*href="([^"]*)"[^>]*>(#[^<]*)<\/a>|<video[^>]*>[\s\S]*?<\/video>|<video[^>]*\/>|<img[^>]*\/?>/gi
let lastEnd = 0
let m
@@ -151,6 +169,16 @@ function parseBlockToSegments(block, config) {
// 旧格式没有 tagType在 onLinkTagTap 中会按 label 匹配缓存的 linkTags 配置降级处理
segs.push({ type: 'linkTag', label: label || '#', url, tagType: '', pagePath: '', tagId: '' })
} else if (/^<video/i.test(tag)) {
const rawSrc = extractVideoSrcFromTag(tag)
if (rawSrc) {
const decoded = decodeEntities(rawSrc)
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
segs.push({ type: 'video', src })
}
} else if (/^<img /i.test(tag)) {
// 图片src 可能为相对路径,需结合 config.assetBase
const rawSrc = extractImgSrcFromTag(tag)
@@ -234,6 +262,33 @@ function parseHtmlToSegments(html, config) {
return '\n__TABLE_' + idx + '__\n'
})
// 0.5 管理端 TipTapdiv.rich-video-wrap > video +可选caption须在剥离 div 前整体替换
const videos = []
const videoWrapRe =
/<div[^>]*(?:class="[^"]*rich-video-wrap[^"]*"|class='[^']*rich-video-wrap[^']*')[^>]*>\s*<video[^>]*>\s*<\/video>\s*(?:<div[^>]*(?:class="[^"]*rich-video-caption[^"]*"|class='[^']*rich-video-caption[^']*')[^>]*>[\s\S]*?<\/div>)?\s*<\/div>/gi
text = text.replace(videoWrapRe, (match) => {
const rawSrc = extractVideoSrcFromTag(match)
if (!rawSrc) return match
const idx = videos.length
pushResolvedVideoSrc(videos, rawSrc, config)
return '\n__VIDEO_' + idx + '__\n'
})
// 未包在 rich-video-wrap 内的裸 <video>(兼容粘贴或其它导出)
text = text.replace(/<video[^>]*\/>/gi, (match) => {
const rawSrc = extractVideoSrcFromTag(match)
if (!rawSrc) return match
const idx = videos.length
pushResolvedVideoSrc(videos, rawSrc, config)
return '\n__VIDEO_' + idx + '__\n'
})
text = text.replace(/<video[^>]*>\s*<\/video>/gi, (match) => {
const rawSrc = extractVideoSrcFromTag(match)
if (!rawSrc) return match
const idx = videos.length
pushResolvedVideoSrc(videos, rawSrc, config)
return '\n__VIDEO_' + idx + '__\n'
})
// 1. 提取 <h2>/<h3> → heading 占位
const headings = []
text = text.replace(/<h([2-6])[^>]*>([\s\S]*?)<\/h\1>/gi, function (_, lvl, inner) {
@@ -250,24 +305,6 @@ function parseHtmlToSegments(html, config) {
return '\n__Q_' + idx + '__\n'
})
// 2.5 内嵌视频去掉管理端「rich-video-caption」说明仅编辑器用整段 <video> 抽成占位
// 须在列表拆解之前执行,否则 li/段落内 video 会在 parseBlockToSegments 里被 strip 成纯文本
const videos = []
text = text.replace(/<div[^>]*\brich-video-caption\b[^>]*>[\s\S]*?<\/div>/gi, '')
text = text.replace(/<video\b[^>]*>[\s\S]*?<\/video>/gi, function (match) {
const sm = match.match(/\bsrc\s*=\s*"([^"]*)"/i) || match.match(/\bsrc\s*=\s*'([^']*)'/i)
const rawSrc = sm ? String(sm[1] || '').trim() : ''
if (!rawSrc) return match
const decoded = decodeEntities(rawSrc)
const src =
config && config.assetBase
? resolveArticleImageSrc(decoded, config.assetBase)
: resolveArticleImageSrc(decoded, '')
const idx = videos.length
videos.push({ src })
return '\n__VIDEO_' + idx + '__\n'
})
// 3. 列表 → listItem 占位(保留 ordered 标记)
var olDepth = 0
var olCounter = 0
@@ -278,11 +315,6 @@ function parseHtmlToSegments(html, config) {
text = text.replace(/<li[^>]*>([\s\S]*?)<\/li>/gi, function (_, inner) {
var cleaned = inner.replace(/<p[^>]*>/gi, '').replace(/<\/p>/gi, '').trim()
if (!cleaned) return '\n'
// 列表项内仅有视频占位时,提升为独立视频块(避免 __VIDEO_n__ 当正文展示)
var onlyVid = cleaned.match(/^__VIDEO_(\d+)__$/)
if (onlyVid) {
return '\n__VIDEO_' + onlyVid[1] + '__\n'
}
if (olDepth > 0) {
olCounter++
return '\n__LI_O_' + olCounter + '__ ' + cleaned + '\n'
@@ -316,13 +348,13 @@ function parseHtmlToSegments(html, config) {
continue
}
// video
// video(整块占位)
var vidM = block.trim().match(/^__VIDEO_(\d+)__$/)
if (vidM) {
var vid = videos[parseInt(vidM[1], 10)]
if (vid && vid.src) {
var v = videos[parseInt(vidM[1], 10)]
if (v && v.src) {
lines.push('')
segments.push([{ type: 'video', src: vid.src }])
segments.push([{ type: 'video', src: v.src }])
}
continue
}
@@ -380,7 +412,10 @@ function parseHtmlToSegments(html, config) {
var blockSegs = parseBlockToSegments(block, config)
if (!blockSegs.length) continue
if (blockSegs.length === 1 && blockSegs[0].type === 'image') {
if (
blockSegs.length === 1 &&
(blockSegs[0].type === 'image' || blockSegs[0].type === 'video')
) {
lines.push('')
segments.push(blockSegs)
continue

View File

@@ -0,0 +1,191 @@
/**
* Soul 创业派对 - 关联小程序跳转(与阅读页 onLinkTagTap miniprogram 分支一致)
*/
const app = getApp()
function normalizeLinkTagLabel(raw) {
return String(raw || '')
.replace(/^[#\s\u00a0\u200b\u3000]+/u, '')
.replace(/[\s\u00a0\u200b\u3000]+$/u, '')
.trim()
.toLowerCase()
}
function resolveLinkTagByLabel(label) {
const normalized = normalizeLinkTagLabel(label)
if (!normalized) return null
const tags = Array.isArray(app.globalData.linkTagsConfig) ? app.globalData.linkTagsConfig : []
for (const t of tags) {
if (!t) continue
const candidates = [t.label]
if (typeof t.aliases === 'string' && t.aliases.trim()) {
candidates.push(...t.aliases.split(','))
}
for (const c of candidates) {
if (normalizeLinkTagLabel(c) === normalized) return t
}
}
return null
}
function pickLinkTagField(tag, keys, defaultValue = '') {
if (!tag || typeof tag !== 'object') return defaultValue
for (const key of keys) {
const v = tag[key]
if (v == null) continue
const s = String(v).trim()
if (s) return s
}
return defaultValue
}
function parseDatasetBool(v) {
if (typeof v === 'boolean') return v
const s = String(v || '').trim().toLowerCase()
return s === '1' || s === 'true' || s === 'yes' || s === 'on'
}
function normalizeQueryKey(raw, defaultKey = 'phone') {
const s = String(raw || '').trim()
if (!s) return defaultKey
if (!/^[A-Za-z][A-Za-z0-9_]{0,63}$/.test(s)) return defaultKey
return s
}
/** 跳转其他小程序时在 path 上追加/覆盖 query保留已有 ?…&…,避免重复同名 key */
function appendQueryToPath(path, key, value) {
if (value == null || value === '') return (path || '').trim()
const base = (path || '').trim()
const hashIdx = base.indexOf('#')
const hashPart = hashIdx >= 0 ? base.slice(hashIdx) : ''
const noHash = hashIdx >= 0 ? base.slice(0, hashIdx) : base
const qIdx = noHash.indexOf('?')
const pathname = qIdx >= 0 ? noHash.slice(0, qIdx) : noHash
const query = qIdx >= 0 ? noHash.slice(qIdx + 1) : ''
const params = []
if (query) {
query.split('&').forEach((pair) => {
if (!pair) return
const [k = '', v = ''] = pair.split('=')
const dk = decodeURIComponent(k || '')
if (dk && dk !== key) params.push(`${encodeURIComponent(dk)}=${v}`)
})
}
params.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
return `${pathname}${params.length ? '?' + params.join('&') : ''}${hashPart}`
}
function getLoggedInUserPhone() {
const u = app.globalData.userInfo || {}
return (u.phone || u.phoneNumber || wx.getStorageSync('user_phone') || '').trim().replace(/\s/g, '')
}
async function ensureLoggedInUserPhone() {
let phone = getLoggedInUserPhone()
if (phone) return phone
try {
const profileRes = await app.request({ url: '/api/miniprogram/user/profile', silent: true, timeout: 3000 })
const p = (profileRes && profileRes.data) ? profileRes.data : {}
phone = String(p.phone || p.phoneNumber || '').trim().replace(/\s/g, '')
if (phone) {
const merged = { ...(app.globalData.userInfo || {}), ...p, phone }
app.globalData.userInfo = merged
try {
wx.setStorageSync('userInfo', merged)
wx.setStorageSync('user_phone', phone)
} catch (_) {}
}
} catch (e) {}
return phone
}
function resolveMiniprogramLinkFromTag(tag) {
if (!tag || typeof tag !== 'object') return null
const tagType = pickLinkTagField(tag, ['type', 'tagType'], '').toLowerCase()
if (tagType !== 'miniprogram') return null
const mpKey = pickLinkTagField(tag, ['mpKey', 'mp_key', 'appId', 'app_id'], '')
const pagePath = pickLinkTagField(tag, ['pagePath', 'page_path'], '')
let passPhone = true
const hasCachedPassPhone = !(tag.passPhone == null || tag.passPhone === '')
if (hasCachedPassPhone) passPhone = parseDatasetBool(tag.passPhone)
const phoneParamName = normalizeQueryKey(tag.phoneParamName, 'phone')
return { mpKey, pagePath, passPhone, phoneParamName }
}
/**
* @param {{ mpKey: string, pagePath?: string, passPhone?: boolean, phoneParamName?: string }} opts
*/
async function navigateLinkedMiniprogram(opts) {
const mpKey = String(opts.mpKey || '').trim()
if (!mpKey) {
wx.showToast({ title: '未配置小程序', icon: 'none' })
return
}
await app.getReadExtras()
const linked = (app.globalData.linkedMiniprograms || []).find((m) => m.key === mpKey)
if (!linked || !linked.appId) {
wx.showToast({ title: '未找到关联小程序配置', icon: 'none' })
return
}
let targetPath = (opts.pagePath != null && String(opts.pagePath).trim())
? String(opts.pagePath).trim()
: (linked.path || '')
const phone = await ensureLoggedInUserPhone()
const passPhone = opts.passPhone !== false
const phoneParamName = normalizeQueryKey(opts.phoneParamName, 'phone')
if (passPhone && phone) {
targetPath = appendQueryToPath(targetPath, phoneParamName, phone)
}
wx.navigateToMiniProgram({
appId: linked.appId,
path: targetPath || '',
envVersion: 'release',
success: () => {},
fail: (err) => {
wx.showToast({ title: err.errMsg || '跳转失败', icon: 'none' })
},
})
}
async function navigateMiniprogramFromLinkTagConfig(tag) {
const r = resolveMiniprogramLinkFromTag(tag)
if (!r || !r.mpKey) return false
await navigateLinkedMiniprogram(r)
return true
}
/**
* @param {Record<string, unknown>} myPageUi mp_config.mpUi.myPage
*/
function findMbtiLinkTagInConfig(myPageUi) {
const tags = Array.isArray(app.globalData.linkTagsConfig) ? app.globalData.linkTagsConfig : []
const preferred = myPageUi && String(myPageUi.mbtiLinkLabel || '').trim()
if (preferred) {
const t = resolveLinkTagByLabel(preferred)
if (t) {
const r = resolveMiniprogramLinkFromTag(t)
if (r && r.mpKey) return t
}
}
for (const t of tags) {
const r = resolveMiniprogramLinkFromTag(t)
if (!r || !r.mpKey) continue
const lbl = pickLinkTagField(t, ['label'], '').toLowerCase()
const key = String(r.mpKey || '').toLowerCase()
const path = String(r.pagePath || '').toLowerCase()
if (lbl.includes('mbti') || key.includes('mbti') || path.includes('mbti')) return t
}
return null
}
module.exports = {
appendQueryToPath,
getLoggedInUserPhone,
ensureLoggedInUserPhone,
navigateLinkedMiniprogram,
navigateMiniprogramFromLinkTagConfig,
findMbtiLinkTagInConfig,
resolveLinkTagByLabel,
resolveMiniprogramLinkFromTag,
normalizeQueryKey,
}

View File

@@ -1,318 +1,17 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soul-admin-dev 静态站点部署:打包 dist → 上传 → 解压到 dist2 → dist/dist2 互换实现无缝切换
不安装依赖、不重启、不调用宝塔 API。
兼容入口:默认走 soul-admin-dev 配置档
实际部署逻辑复用 master.py避免与正式环境脚本长期双份维护。
"""
from __future__ import print_function
import os
import sys
import shlex
import tempfile
import argparse
import zipfile
try:
import paramiko
except ImportError:
print("错误: 请先安装 paramiko")
print(" pip install paramiko")
sys.exit(1)
# ==================== 配置 ====================
# 站点根目录Nginx 等指向的目录的上一级,即 dist 的父目录)
DEPLOY_BASE_PATH = "/www/wwwroot/self/soul-admin-dev"
# 切换后 chown 的属主,宝塔一般为 www:www空则跳过
DEPLOY_WWW_USER = os.environ.get("DEPLOY_WWW_USER", "www:www")
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
def get_cfg():
base = os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/")
return {
"host": os.environ.get("DEPLOY_HOST", "43.139.27.93"),
"user": os.environ.get("DEPLOY_USER", "root"),
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
"ssh_key": os.environ.get("DEPLOY_SSH_KEY", ""),
"base_path": base,
"dist_path": base + "/dist",
"dist2_path": base + "/dist2",
"www_user": os.environ.get("DEPLOY_WWW_USER", DEPLOY_WWW_USER).strip(),
}
# ==================== 本地构建 ====================
def run_build(root):
"""执行本地 pnpm build使用 .env.development 测试环境配置)"""
use_shell = sys.platform == "win32"
dist_dir = os.path.join(root, "dist")
index_html = os.path.join(dist_dir, "index.html")
try:
r = __import__("subprocess").run(
["pnpm", "run", "build:dev"],
cwd=root,
shell=use_shell,
timeout=300,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
)
if r.returncode != 0:
print(" [失败] 构建失败,退出码:", r.returncode)
for line in (r.stdout or "").strip().split("\n")[-10:]:
print(" " + line)
return False
except __import__("subprocess").TimeoutExpired:
print(" [失败] 构建超时")
return False
except FileNotFoundError:
print(" [失败] 未找到 pnpm请安装: npm install -g pnpm")
return False
except Exception as e:
print(" [失败] 构建异常:", str(e))
return False
if not os.path.isfile(index_html):
print(" [失败] 未找到 dist/index.html")
return False
print(" [成功] 构建完成")
return True
# ==================== 打包 dist 为 zip ====================
def pack_dist_zip(root):
"""将本地 dist 目录打包为 zip解压到 dist2 后即为站点根内容)"""
print("[2/4] 打包 dist 为 zip ...")
dist_dir = os.path.join(root, "dist")
if not os.path.isdir(dist_dir):
print(" [失败] 未找到 dist 目录")
return None
index_html = os.path.join(dist_dir, "index.html")
if not os.path.isfile(index_html):
print(" [失败] 未找到 dist/index.html请先执行 pnpm build")
return None
zip_path = os.path.join(tempfile.gettempdir(), "soul_admin_deploy.zip")
try:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for dirpath, _dirs, filenames in os.walk(dist_dir):
for f in filenames:
full = os.path.join(dirpath, f)
arcname = os.path.relpath(full, dist_dir).replace("\\", "/")
zf.write(full, arcname)
print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
return zip_path
except Exception as e:
print(" [失败] 打包异常:", str(e))
return None
# ==================== SSH 上传并解压到 dist2 ====================
def upload_zip_and_extract_to_dist2(cfg, zip_path):
"""上传 zip 到服务器并解压到 dist2"""
print("[3/4] SSH 上传 zip 并解压到 dist2 ...")
sys.stdout.flush()
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
sys.stdout.flush()
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
key_filename=cfg["ssh_key"],
timeout=30,
banner_timeout=30,
)
else:
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
password=cfg["password"],
timeout=30,
banner_timeout=30,
)
print(" [OK] SSH 已连接,正在上传 zip%.1f MB..." % zip_size_mb)
sys.stdout.flush()
remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
sftp = client.open_sftp()
chunk_mb = 5.0
last_reported = [0]
def _progress(transferred, total):
if total and total > 0:
now_mb = transferred / (1024 * 1024)
if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
last_reported[0] = now_mb
print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
sys.stdout.flush()
sftp.put(zip_path, remote_zip, callback=_progress)
if zip_size_mb >= chunk_mb:
print("")
print(" [OK] zip 已上传,正在服务器解压到 dist2 ...")
sys.stdout.flush()
sftp.close()
dist2 = cfg["dist2_path"]
cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (
dist2,
dist2,
remote_zip,
dist2,
remote_zip,
)
stdin, stdout, stderr = client.exec_command(cmd, timeout=120)
out = stdout.read().decode("utf-8", errors="replace").strip()
err = stderr.read().decode("utf-8", errors="replace").strip()
if err:
print(" 服务器 stderr: %s" % err[:500])
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0 or "OK" not in out:
print(" [失败] 解压失败,退出码: %s" % exit_status)
if out:
print(" stdout: %s" % out[:300])
return False
print(" [成功] 已解压到: %s" % dist2)
return True
except Exception as e:
print(" [失败] SSH 错误: %s" % str(e))
import traceback
traceback.print_exc()
return False
finally:
client.close()
# ==================== 服务器目录切换dist → dist1dist2 → dist ====================
def remote_swap_dist(cfg):
"""服务器上dist→dist1dist2→dist删除 dist1实现无缝切换"""
print("[4/4] 服务器切换目录: dist→dist1, dist2→dist ...")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
try:
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
key_filename=cfg["ssh_key"],
timeout=15,
)
else:
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
password=cfg["password"],
timeout=15,
)
base = cfg["base_path"]
# 若当前没有 dist首次部署则 dist2 直接改名为 dist若有 dist 则先备份再替换
cmd = "cd %s && (test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) && echo OK" % base
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
out = stdout.read().decode("utf-8", errors="replace").strip()
err = stderr.read().decode("utf-8", errors="replace").strip()
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0 or "OK" not in out:
print(" [失败] 切换失败 (退出码: %s)" % exit_status)
if err:
print(" 服务器 stderr: %s" % err)
if out and "OK" not in out:
print(" 服务器 stdout: %s" % out)
return False
print(" [成功] 新版本已切换至: %s" % cfg["dist_path"])
# 切换后设置 www 访问权限,否则 Nginx 无法读文件导致无法访问
www_user = cfg.get("www_user")
if www_user:
dist_path = cfg["dist_path"]
chown_cmd = "chown -R %s %s && echo OK" % (www_user, shlex.quote(dist_path))
stdin, stdout, stderr = client.exec_command(chown_cmd, timeout=60)
chown_out = stdout.read().decode("utf-8", errors="replace").strip()
chown_err = stderr.read().decode("utf-8", errors="replace").strip()
if stdout.channel.recv_exit_status() != 0 or "OK" not in chown_out:
print(" [警告] chown 失败,站点可能无法访问: %s" % (chown_err or chown_out))
else:
print(" [成功] 已设置属主: %s" % www_user)
return True
except Exception as e:
print(" [失败] SSH 错误: %s" % str(e))
return False
finally:
client.close()
# ==================== 主函数 ====================
def main():
parser = argparse.ArgumentParser(
description="soul-admin-dev 静态站点部署dist2 解压后目录切换,无缝更新)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="不安装依赖、不重启、不调用宝塔 API。站点路径: " + DEPLOY_BASE_PATH + "/dist",
)
parser.add_argument("--no-build", action="store_true", help="跳过本地 pnpm build")
args = parser.parse_args()
script_dir = os.path.dirname(os.path.abspath(__file__))
if os.path.isfile(os.path.join(script_dir, "package.json")):
root = script_dir
else:
root = os.path.dirname(script_dir)
cfg = get_cfg()
print("=" * 60)
print(" soul-admin-dev 部署dist/dist2 无缝切换)")
print("=" * 60)
print(" 服务器: %s@%s 站点目录: %s" % (cfg["user"], cfg["host"], cfg["dist_path"]))
print("=" * 60)
if not args.no_build:
print("[1/4] 本地构建 pnpm build ...")
if not run_build(root):
return 1
else:
if not os.path.isdir(os.path.join(root, "dist")) or not os.path.isfile(
os.path.join(root, "dist", "index.html")
):
print("[错误] 未找到 dist/index.html请先执行 pnpm build 或去掉 --no-build")
return 1
print("[1/4] 跳过本地构建")
zip_path = pack_dist_zip(root)
if not zip_path:
return 1
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
return 1
try:
os.remove(zip_path)
except Exception:
pass
if not remote_swap_dist(cfg):
return 1
print("")
print(" 部署完成!站点目录: %s" % cfg["dist_path"])
return 0
from master import main as master_main
if __name__ == "__main__":
sys.exit(main())
sys.exit(master_main(default_profile="dev"))

View File

@@ -1,17 +1,20 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
soul-admin 静态站点部署:打包 dist → 上传 → 解压到 dist2 → dist/dist2 互换实现无缝切换。
不安装依赖、不重启、不调用宝塔 API。
soul-admin 静态站点部署(统一主入口)
流程:本地构建 dist -> 上传 zip -> 服务器解压到 dist2 -> dist/dist2 无缝切换。
默认 profile=prod正式环境deploy.py 作为 dev 包装入口调用本脚本。
"""
from __future__ import print_function
import os
import sys
import shlex
import tempfile
import argparse
import os
import shlex
import subprocess
import sys
import tempfile
import zipfile
try:
@@ -21,18 +24,55 @@ except ImportError:
print(" pip install paramiko")
sys.exit(1)
# ==================== 配置 ====================
# 站点根目录Nginx 等指向的目录的上一级,即 dist 的父目录)
DEPLOY_BASE_PATH = "/www/wwwroot/self/soul-admin"
# 切换后 chown 的属主,宝塔一般为 www:www空则跳过
DEPLOY_WWW_USER = os.environ.get("DEPLOY_WWW_USER", "www:www")
DEFAULT_SSH_PORT = int(os.environ.get("DEPLOY_SSH_PORT", "22022"))
DEFAULT_WWW_USER = "www:www"
PROFILE_PRESETS = {
"prod": {
"title": "soul-admin",
"default_base_path": "/www/wwwroot/self/soul-admin",
"build_cmd": ["pnpm", "build"],
"build_desc": "pnpm build正式环境",
},
"dev": {
"title": "soul-admin-dev",
"default_base_path": "/www/wwwroot/self/soul-admin-dev",
"build_cmd": ["pnpm", "run", "build:dev"],
"build_desc": "pnpm run build:dev测试环境",
},
}
def get_cfg():
base = os.environ.get("DEPLOY_BASE_PATH", DEPLOY_BASE_PATH).rstrip("/")
def resolve_profile(profile_name):
profile = (profile_name or "prod").strip().lower()
if profile not in PROFILE_PRESETS:
raise ValueError("不支持的 profile: %s" % profile_name)
return profile
def _resolve_base_path(profile, cli_base_path=None):
preset = PROFILE_PRESETS[profile]
if cli_base_path:
return cli_base_path.rstrip("/")
profile_env_key = "DEPLOY_BASE_PATH_%s" % profile.upper()
base = (os.environ.get(profile_env_key) or "").strip()
if not base:
base = (os.environ.get("DEPLOY_BASE_PATH") or "").strip()
if not base:
base = preset["default_base_path"]
return base.rstrip("/")
def get_cfg(profile, cli_base_path=None):
base = _resolve_base_path(profile, cli_base_path=cli_base_path)
www_user = (os.environ.get("DEPLOY_WWW_USER") or DEFAULT_WWW_USER).strip()
return {
"profile": profile,
"title": PROFILE_PRESETS[profile]["title"],
"build_cmd": list(PROFILE_PRESETS[profile]["build_cmd"]),
"build_desc": PROFILE_PRESETS[profile]["build_desc"],
"host": os.environ.get("DEPLOY_HOST", "43.139.27.93"),
"user": os.environ.get("DEPLOY_USER", "root"),
"password": os.environ.get("DEPLOY_PASSWORD", "Zhiqun1984"),
@@ -40,22 +80,53 @@ def get_cfg():
"base_path": base,
"dist_path": base + "/dist",
"dist2_path": base + "/dist2",
"www_user": os.environ.get("DEPLOY_WWW_USER", DEPLOY_WWW_USER).strip(),
"www_user": www_user,
}
# ==================== 本地构建 ====================
def resolve_project_root():
script_dir = os.path.dirname(os.path.abspath(__file__))
if os.path.isfile(os.path.join(script_dir, "package.json")):
return script_dir
return os.path.dirname(script_dir)
def run_build(root):
"""执行本地 pnpm build使用 .env.production 正式环境配置)"""
def connect_ssh(cfg, timeout=30):
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
key_filename=cfg["ssh_key"],
timeout=timeout,
banner_timeout=timeout,
)
else:
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
password=cfg["password"],
timeout=timeout,
banner_timeout=timeout,
)
return client
def ensure_dist_ready(root):
return os.path.isdir(os.path.join(root, "dist")) and os.path.isfile(
os.path.join(root, "dist", "index.html")
)
def run_build(root, cfg):
print("[1/4] 本地构建 %s ..." % cfg["build_desc"])
use_shell = sys.platform == "win32"
dist_dir = os.path.join(root, "dist")
index_html = os.path.join(dist_dir, "index.html")
try:
r = __import__("subprocess").run(
["pnpm", "build"],
r = subprocess.run(
cfg["build_cmd"],
cwd=root,
shell=use_shell,
timeout=300,
@@ -67,9 +138,13 @@ def run_build(root):
if r.returncode != 0:
print(" [失败] 构建失败,退出码:", r.returncode)
for line in (r.stdout or "").strip().split("\n")[-10:]:
print(" " + line)
if line:
print(" " + line)
for line in (r.stderr or "").strip().split("\n")[-10:]:
if line:
print(" " + line)
return False
except __import__("subprocess").TimeoutExpired:
except subprocess.TimeoutExpired:
print(" [失败] 构建超时")
return False
except FileNotFoundError:
@@ -79,112 +154,75 @@ def run_build(root):
print(" [失败] 构建异常:", str(e))
return False
if not os.path.isfile(index_html):
if not ensure_dist_ready(root):
print(" [失败] 未找到 dist/index.html")
return False
print(" [成功] 构建完成")
return True
# ==================== 打包 dist 为 zip ====================
def pack_dist_zip(root):
"""将本地 dist 目录打包为 zip解压到 dist2 后即为站点根内容)"""
def pack_dist_zip(root, profile):
print("[2/4] 打包 dist 为 zip ...")
dist_dir = os.path.join(root, "dist")
if not os.path.isdir(dist_dir):
print(" [失败] 未找到 dist 目录")
return None
index_html = os.path.join(dist_dir, "index.html")
if not os.path.isfile(index_html):
print(" [失败] 未找到 dist/index.html请先执行 pnpm build")
print(" [失败] 未找到 dist/index.html请先执行构建")
return None
zip_path = os.path.join(tempfile.gettempdir(), "soul_admin_deploy.zip")
zip_name = "soul_admin_%s_deploy.zip" % profile
zip_path = os.path.join(tempfile.gettempdir(), zip_name)
try:
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
for dirpath, _dirs, filenames in os.walk(dist_dir):
for f in filenames:
full = os.path.join(dirpath, f)
for name in filenames:
full = os.path.join(dirpath, name)
arcname = os.path.relpath(full, dist_dir).replace("\\", "/")
zf.write(full, arcname)
print(" [成功] 打包完成: %s (%.2f MB)" % (zip_path, os.path.getsize(zip_path) / 1024 / 1024))
print(
" [成功] 打包完成: %s (%.2f MB)"
% (zip_path, os.path.getsize(zip_path) / 1024 / 1024)
)
return zip_path
except Exception as e:
print(" [失败] 打包异常:", str(e))
return None
# ==================== SSH 上传并解压到 dist2 ====================
def upload_zip_and_extract_to_dist2(cfg, zip_path):
"""上传 zip 到服务器并解压到 dist2"""
print("[3/4] SSH 上传 zip 并解压到 dist2 ...")
sys.stdout.flush()
if not cfg.get("password") and not cfg.get("ssh_key"):
print(" [失败] 请设置 DEPLOY_PASSWORD 或 DEPLOY_SSH_KEY")
return False
zip_size_mb = os.path.getsize(zip_path) / (1024 * 1024)
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
client = None
try:
print(" 正在连接 %s@%s:%s ..." % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
sys.stdout.flush()
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
key_filename=cfg["ssh_key"],
timeout=30,
banner_timeout=30,
)
else:
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
password=cfg["password"],
timeout=30,
banner_timeout=30,
)
client = connect_ssh(cfg, timeout=30)
print(" [OK] SSH 已连接,正在上传 zip%.1f MB..." % zip_size_mb)
sys.stdout.flush()
remote_zip = cfg["base_path"] + "/soul_admin_deploy.zip"
sftp = client.open_sftp()
chunk_mb = 5.0
last_reported = [0]
def _progress(transferred, total):
if total and total > 0:
now_mb = transferred / (1024 * 1024)
if now_mb - last_reported[0] >= chunk_mb or transferred >= total:
last_reported[0] = now_mb
print("\r 上传进度: %.1f / %.1f MB" % (now_mb, total / (1024 * 1024)), end="")
sys.stdout.flush()
sftp.put(zip_path, remote_zip, callback=_progress)
if zip_size_mb >= chunk_mb:
print("")
print(" [OK] zip 已上传,正在服务器解压到 dist2 ...")
sys.stdout.flush()
sftp.put(zip_path, remote_zip)
sftp.close()
dist2 = cfg["dist2_path"]
cmd = "rm -rf %s && mkdir -p %s && unzip -o -q %s -d %s && rm -f %s && echo OK" % (
dist2,
dist2,
remote_zip,
dist2,
remote_zip,
cmd = (
"rm -rf {dist2} && mkdir -p {dist2} && "
"unzip -o -q {remote_zip} -d {dist2} && rm -f {remote_zip} && echo OK"
).format(
dist2=shlex.quote(dist2),
remote_zip=shlex.quote(remote_zip),
)
stdin, stdout, stderr = client.exec_command(cmd, timeout=120)
out = stdout.read().decode("utf-8", errors="replace").strip()
err = stderr.read().decode("utf-8", errors="replace").strip()
exit_status = stdout.channel.recv_exit_status()
if err:
print(" 服务器 stderr: %s" % err[:500])
exit_status = stdout.channel.recv_exit_status()
if exit_status != 0 or "OK" not in out:
print(" [失败] 解压失败,退出码: %s" % exit_status)
if out:
@@ -194,41 +232,23 @@ def upload_zip_and_extract_to_dist2(cfg, zip_path):
return True
except Exception as e:
print(" [失败] SSH 错误: %s" % str(e))
import traceback
traceback.print_exc()
return False
finally:
client.close()
# ==================== 服务器目录切换dist → dist1dist2 → dist ====================
if client:
client.close()
def remote_swap_dist(cfg):
"""服务器上dist→dist1dist2→dist删除 dist1实现无缝切换"""
print("[4/4] 服务器切换目录: dist→dist1, dist2→dist ...")
client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client = None
try:
if cfg.get("ssh_key") and os.path.isfile(cfg["ssh_key"]):
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
key_filename=cfg["ssh_key"],
timeout=15,
)
else:
client.connect(
cfg["host"],
port=DEFAULT_SSH_PORT,
username=cfg["user"],
password=cfg["password"],
timeout=15,
)
base = cfg["base_path"]
# 若当前没有 dist首次部署则 dist2 直接改名为 dist若有 dist 则先备份再替换
cmd = "cd %s && (test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) && echo OK" % base
client = connect_ssh(cfg, timeout=15)
base = shlex.quote(cfg["base_path"])
cmd = (
"cd {base} && "
"(test -d dist && (mv dist dist1 && mv dist2 dist && rm -rf dist1) || mv dist2 dist) "
"&& echo OK"
).format(base=base)
stdin, stdout, stderr = client.exec_command(cmd, timeout=60)
out = stdout.read().decode("utf-8", errors="replace").strip()
err = stderr.read().decode("utf-8", errors="replace").strip()
@@ -236,16 +256,18 @@ def remote_swap_dist(cfg):
if exit_status != 0 or "OK" not in out:
print(" [失败] 切换失败 (退出码: %s)" % exit_status)
if err:
print(" 服务器 stderr: %s" % err)
print(" 服务器 stderr: %s" % err[:300])
if out and "OK" not in out:
print(" 服务器 stdout: %s" % out)
print(" 服务器 stdout: %s" % out[:300])
return False
print(" [成功] 新版本已切换至: %s" % cfg["dist_path"])
# 切换后设置 www 访问权限,否则 Nginx 无法读文件导致无法访问
www_user = cfg.get("www_user")
if www_user:
dist_path = cfg["dist_path"]
chown_cmd = "chown -R %s %s && echo OK" % (www_user, shlex.quote(dist_path))
chown_cmd = "chown -R {user} {dist} && echo OK".format(
user=shlex.quote(www_user),
dist=shlex.quote(cfg["dist_path"]),
)
stdin, stdout, stderr = client.exec_command(chown_cmd, timeout=60)
chown_out = stdout.read().decode("utf-8", errors="replace").strip()
chown_err = stderr.read().decode("utf-8", errors="replace").strip()
@@ -258,57 +280,73 @@ def remote_swap_dist(cfg):
print(" [失败] SSH 错误: %s" % str(e))
return False
finally:
client.close()
if client:
client.close()
# ==================== 主函数 ====================
def main():
def create_parser(default_profile):
parser = argparse.ArgumentParser(
description="soul-admin 静态站点部署dist2 解压后目录切换,无缝更新)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="不安装依赖、不重启、不调用宝塔 API。站点路径: " + DEPLOY_BASE_PATH + "/dist",
)
parser.add_argument("--no-build", action="store_true", help="跳过本地 pnpm build")
args = parser.parse_args()
parser.add_argument(
"--profile",
choices=tuple(PROFILE_PRESETS.keys()),
default=default_profile,
help="部署档位prod=正式环境dev=测试环境",
)
parser.add_argument(
"--base-path",
default="",
help="覆盖部署目录(默认按 profile 取 DEPLOY_BASE_PATH 或预设路径)",
)
parser.add_argument("--no-build", action="store_true", help="跳过本地构建,直接上传现有 dist")
return parser
script_dir = os.path.dirname(os.path.abspath(__file__))
if os.path.isfile(os.path.join(script_dir, "package.json")):
root = script_dir
else:
root = os.path.dirname(script_dir)
cfg = get_cfg()
def main(argv=None, default_profile="prod"):
default_profile = resolve_profile(default_profile)
parser = create_parser(default_profile=default_profile)
args = parser.parse_args(argv)
profile = resolve_profile(args.profile)
root = resolve_project_root()
cfg = get_cfg(profile=profile, cli_base_path=(args.base_path or "").strip())
print("=" * 60)
print(" soul-admin 部署dist/dist2 无缝切换)")
print(" %s 部署dist/dist2 无缝切换)" % cfg["title"])
print("=" * 60)
print(" 服务器: %s@%s 站点目录: %s" % (cfg["user"], cfg["host"], cfg["dist_path"]))
print(" profile: %s" % cfg["profile"])
print(" 服务器: %s@%s:%s" % (cfg["user"], cfg["host"], DEFAULT_SSH_PORT))
print(" 站点目录: %s" % cfg["dist_path"])
print("=" * 60)
if not args.no_build:
print("[1/4] 本地构建 pnpm build ...")
if not run_build(root):
if not run_build(root, cfg):
return 1
else:
if not os.path.isdir(os.path.join(root, "dist")) or not os.path.isfile(
os.path.join(root, "dist", "index.html")
):
print("[错误] 未找到 dist/index.html请先执行 pnpm build 或去掉 --no-build")
if not ensure_dist_ready(root):
print("[错误] 未找到 dist/index.html请先执行构建或去掉 --no-build")
return 1
print("[1/4] 跳过本地构建")
zip_path = pack_dist_zip(root)
zip_path = pack_dist_zip(root, profile=profile)
if not zip_path:
return 1
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
return 1
try:
os.remove(zip_path)
except Exception:
pass
if not upload_zip_and_extract_to_dist2(cfg, zip_path):
return 1
finally:
try:
if zip_path and os.path.isfile(zip_path):
os.remove(zip_path)
except Exception:
pass
if not remote_swap_dist(cfg):
return 1
print("")
print(" 部署完成!站点目录: %s" % cfg["dist_path"])
return 0

File diff suppressed because it is too large Load Diff

View File

@@ -150,10 +150,16 @@ func ensurePersonByName(db *gorm.DB, name string, aliasToken map[string]string)
return tok, nil
}
}
created, err := createPersonMinimal(db, clean, "")
// 无匹配人物:先建占位 users无 open_id再建 Person 并绑定 user_id便于超级个体/会员体系与 @ 一致
u, err := createUserPlaceholderForArticleMention(db, clean)
if err != nil {
return "", err
}
created, err := createPersonMinimal(db, clean, u.ID)
if err != nil {
_ = db.Unscoped().Where("id = ?", u.ID).Delete(&model.User{})
return "", err
}
return created.Token, nil
}

View File

@@ -4,6 +4,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"time"
"soul-api/internal/database"
@@ -160,7 +161,7 @@ func BalanceConsumePost(c *gin.Context) {
}
db := database.DB()
// 后端价格校验
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID)
standardPrice, priceErr := getStandardPrice(db, req.ProductType, req.ProductID, strings.TrimSpace(req.UserID))
if priceErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": priceErr.Error()})
return
@@ -188,6 +189,9 @@ func BalanceConsumePost(c *gin.Context) {
referrerID = &binding.ReferrerID
}
}
if referrerID != nil && isSelfReferralBuyer(req.UserID, *referrerID) {
referrerID = nil
}
productID := req.ProductID
if productID == "" {

View File

@@ -512,10 +512,10 @@ func CKBJoin(c *gin.Context) {
}
if body.Type == "investor" && body.UserID != "" {
if !userHasContentPurchase(database.DB(), body.UserID) {
// 交互原则:用户侧友好提示(不暴露存客宝/规则细节);后台可通过规则引导再完善
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "提交成功,我们会尽快联系您",
"success": false,
"error": "请先购买任意章节后再申请资源对接",
"errorCode": "CONTENT_PURCHASE_REQUIRED",
})
return
}

View File

@@ -180,6 +180,8 @@ func defaultMpUi() gin.H {
"readStatLabel": "已读章节", "recentReadTitle": "最近阅读",
"readStatPath": "/pages/reading-records/reading-records?focus=all",
"recentReadPath": "/pages/reading-records/reading-records?focus=recent",
// 我的页 MBTI 标签跳转:与 read-extras linkTags 某条 label 一致时优先匹配;空则自动匹配 label/mpKey/pagePath 含 mbti 的「小程序」类标签
"mbtiLinkLabel": "",
},
// 弹窗文案:管理端按 pagePath + key 维护,见 mpUi.pagePopupItemsmemberDetailPage/readPage 已废弃,由迁移合并)
"pagePopupItems": []interface{}{

View File

@@ -4,6 +4,7 @@ import (
"context"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"log"
@@ -514,6 +515,67 @@ func DBPersonSave(c *gin.Context) {
c.JSON(http.StatusOK, resp)
}
// UserSourceArticleMentionAuto users.source文章保存时 @ 无匹配人物自动创建的占位账号(无 open_id与 persons.user_id 绑定)
const UserSourceArticleMentionAuto = "article_mention_auto"
func genMentionAutoReferralCode() string {
b := make([]byte, 9)
if _, err := rand.Read(b); err != nil {
return "M" + fmt.Sprintf("%016X", time.Now().UnixNano())[:19]
}
s := "M" + hex.EncodeToString(b)
if len(s) > 20 {
return s[:20]
}
return s
}
// createUserPlaceholderForArticleMention 创建无微信 open_id 的占位用户,供 ParseAutoLink 新建 @ 人物时与 Person 绑定
func createUserPlaceholderForArticleMention(db *gorm.DB, displayName string) (*model.User, error) {
displayName = strings.TrimSpace(displayName)
if displayName == "" {
return nil, fmt.Errorf("displayName 必填")
}
runes := []rune(displayName)
if len(runes) > 80 {
displayName = string(runes[:80])
}
nick := displayName
hasFullBook := false
earnings := 0.0
pendingEarnings := 0.0
referralCount := 0
purchasedSections := "[]"
avatar := ""
src := UserSourceArticleMentionAuto
for attempt := 0; attempt < 8; attempt++ {
userID := "user_mention_" + randomSuffix()
referralCode := genMentionAutoReferralCode()
u := model.User{
ID: userID,
OpenID: nil,
Nickname: &nick,
Avatar: &avatar,
ReferralCode: &referralCode,
HasFullBook: &hasFullBook,
PurchasedSections: &purchasedSections,
Earnings: &earnings,
PendingEarnings: &pendingEarnings,
ReferralCount: &referralCount,
Source: &src,
}
if err := db.Create(&u).Error; err != nil {
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
continue
}
return nil, err
}
return &u, nil
}
return nil, fmt.Errorf("创建占位用户失败,请重试")
}
// createPersonMinimal 仅按 name 创建 Person含存客宝计划供 autolink 复用
// userID 可为空;用于“绑定用户 → 幂等创建”的场景
func createPersonMinimal(db *gorm.DB, name string, userID string) (*model.Person, error) {

View File

@@ -102,7 +102,7 @@ func GiftPayCreate(c *gin.Context) {
productID = "fullbook"
}
}
unitPrice, priceErr := getStandardPrice(db, req.ProductType, productID)
unitPrice, priceErr := getStandardPrice(db, req.ProductType, productID, strings.TrimSpace(req.UserID))
if priceErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
return
@@ -120,7 +120,7 @@ func GiftPayCreate(c *gin.Context) {
SELECT referrer_id FROM referral_bindings
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
ORDER BY binding_date DESC LIMIT 1
`, req.UserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" {
`, req.UserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" && !isSelfReferralBuyer(req.UserID, binding.ReferrerID) {
referrerID = &binding.ReferrerID
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
@@ -323,7 +323,7 @@ func GiftPayDetail(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"success": false, "error": "请先登录"})
return
}
unitPrice, priceErr := getStandardPrice(db, "section", sectionId)
unitPrice, priceErr := getStandardPrice(db, "section", sectionId, callerUserID)
if priceErr != nil {
c.JSON(http.StatusOK, gin.H{"success": false, "error": priceErr.Error()})
return
@@ -336,7 +336,7 @@ func GiftPayDetail(c *gin.Context) {
SELECT referrer_id FROM referral_bindings
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
ORDER BY binding_date DESC LIMIT 1
`, callerUserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" {
`, callerUserID).Scan(&binding).Error; err == nil && binding.ReferrerID != "" && !isSelfReferralBuyer(callerUserID, binding.ReferrerID) {
var cfg model.SystemConfig
if err := db.Where("config_key = ?", "referral_config").First(&cfg).Error; err == nil {
var config map[string]interface{}

View File

@@ -432,6 +432,15 @@ func miniprogramPayPost(c *gin.Context) {
db := database.DB()
// 尽早解析 userId便于 mentor_consultation 等按用户校验预约单与标准价
userIDForPrice := strings.TrimSpace(req.UserID)
if userIDForPrice == "" && strings.TrimSpace(req.OpenID) != "" {
var u model.User
if err := db.Where("open_id = ?", strings.TrimSpace(req.OpenID)).First(&u).Error; err == nil {
userIDForPrice = u.ID
}
}
productID := strings.TrimSpace(req.ProductID)
if req.ProductType == "link_karuo_tip" && strings.TrimSpace(req.TipSource) == "live_mic" && productID != "" && !strings.HasPrefix(productID, "live_mic|") {
productID = "live_mic|" + productID
@@ -440,6 +449,8 @@ func miniprogramPayPost(c *gin.Context) {
var finalAmount float64
var orderSn string
var referrerID *string
// 非充值单记录标准价:若最终发现自推,需撤销误用的好友折扣
var standardPriceSnapshot float64
if req.ProductType == "balance_recharge" {
// 充值从已创建的订单取金额productId=orderSn
@@ -460,7 +471,7 @@ func miniprogramPayPost(c *gin.Context) {
if pricePID == "" {
pricePID = strings.TrimSpace(req.ProductID)
}
standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID, req.TipSource)
standardPrice, priceErr := getStandardPrice(db, req.ProductType, pricePID, userIDForPrice, req.TipSource)
if priceErr != nil && req.ProductType == "link_karuo_tip" {
if fb, _, canonID, ferr := linkKaruoTipFallbackByAmount(db, pricePID, req.Amount, req.TipSource); ferr == nil {
standardPrice = fb
@@ -481,6 +492,7 @@ func miniprogramPayPost(c *gin.Context) {
return
}
finalAmount = standardPrice
standardPriceSnapshot = standardPrice
// 打赏不参与分销好友折扣(实付=标准价)
if req.ProductType != "link_karuo_tip" {
@@ -528,8 +540,6 @@ func miniprogramPayPost(c *gin.Context) {
orderSn = wechat.GenerateOrderSn()
}
totalFee := int(finalAmount * 100) // 转为分
// 获取客户端 IP
clientIP := c.ClientIP()
if clientIP == "" {
@@ -537,7 +547,10 @@ func miniprogramPayPost(c *gin.Context) {
}
// userID优先用客户端传入为空时按 openid 查用户(排除软删除,避免订单归属到旧账号)
userID := req.UserID
userID := strings.TrimSpace(req.UserID)
if userID == "" && userIDForPrice != "" {
userID = userIDForPrice
}
if userID == "" && req.OpenID != "" {
var u model.User
if err := db.Where("open_id = ?", req.OpenID).First(&u).Error; err == nil {
@@ -548,6 +561,18 @@ func miniprogramPayPost(c *gin.Context) {
}
}
// 自购自推:不写 referrer、不按好友价兼容脏绑定或误传自己的推荐码userID 以最终解析为准)
if req.ProductType != "balance_recharge" && req.ProductType != "link_karuo_tip" && referrerID != nil && userID != "" &&
isSelfReferralBuyer(userID, *referrerID) {
fmt.Printf("[MiniprogramPay] 自推无效: userId=%s 忽略 referrer\n", userID)
referrerID = nil
if standardPriceSnapshot > 0 {
finalAmount = standardPriceSnapshot
}
}
totalFee := int(finalAmount * 100) // 转为分(须在自推纠正 finalAmount 之后)
if req.ProductType != "balance_recharge" {
if productID == "" {
switch req.ProductType {
@@ -558,6 +583,9 @@ func miniprogramPayPost(c *gin.Context) {
case "link_karuo_tip":
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "打赏缺少礼物标识"})
return
case "mentor_consultation":
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "缺少预约单 IDproductId"})
return
default:
productID = "fullbook"
}
@@ -575,6 +603,8 @@ func miniprogramPayPost(c *gin.Context) {
description = "卡若创业派对VIP年度会员365天"
case "match":
description = "购买匹配次数"
case "mentor_consultation":
description = "导师咨询服务"
case "link_karuo_tip":
giftID := productID
if strings.HasPrefix(giftID, "live_mic|") {
@@ -912,6 +942,13 @@ func MiniprogramPayNotify(c *gin.Context) {
}
} else if attach.ProductType == "link_karuo_tip" {
fmt.Printf("[PayNotify] 打赏/上麦礼遇订单完成: user=%s order=%s\n", beneficiaryUserID, orderSn)
} else if attach.ProductType == "mentor_consultation" && attach.ProductID != "" {
if cid, err := strconv.Atoi(strings.TrimSpace(attach.ProductID)); err == nil && cid > 0 {
_ = db.Model(&model.MentorConsultation{}).
Where("id = ? AND user_id = ?", cid, beneficiaryUserID).
Updates(map[string]interface{}{"status": "paid", "updated_at": time.Now()})
fmt.Printf("[PayNotify] 导师预约已支付: user=%s consultId=%d order=%s\n", beneficiaryUserID, cid, orderSn)
}
}
productID := attach.ProductID
if productID == "" {
@@ -953,8 +990,12 @@ func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64,
fmt.Printf("[PayNotify] 打赏订单跳过分销佣金: orderSn=%s\n", orderSn)
return
}
if order != nil && order.ProductType == "mentor_consultation" {
fmt.Printf("[PayNotify] 导师咨询订单跳过分销佣金: orderSn=%s\n", orderSn)
return
}
type Binding struct {
ID int `gorm:"column:id"`
ID string `gorm:"column:id"`
ReferrerID string `gorm:"column:referrer_id"`
ExpiryDate time.Time `gorm:"column:expiry_date"`
PurchaseCount int `gorm:"column:purchase_count"`
@@ -972,6 +1013,10 @@ func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64,
fmt.Printf("[PayNotify] 用户无有效推广绑定,跳过分佣: %s\n", buyerUserID)
return
}
if isSelfReferralBuyer(buyerUserID, binding.ReferrerID) {
fmt.Printf("[PayNotify] 自购自推,跳过分佣: buyer=%s\n", buyerUserID)
return
}
if time.Now().After(binding.ExpiryDate) {
fmt.Printf("[PayNotify] 绑定已过期,跳过分佣: %s\n", buyerUserID)
return
@@ -1326,6 +1371,17 @@ func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) {
ConfirmBalanceRechargeByOrder(db, order)
case "link_karuo_tip":
// 首页打赏 / 上麦礼遇:仅收款,无额外会员或章节权益
case "mentor_consultation":
if order.ProductID == nil || strings.TrimSpace(*order.ProductID) == "" {
return
}
cid, err := strconv.Atoi(strings.TrimSpace(*order.ProductID))
if err != nil || cid <= 0 {
return
}
_ = db.Model(&model.MentorConsultation{}).
Where("id = ? AND user_id = ?", cid, userID).
Updates(map[string]interface{}{"status": "paid", "updated_at": payTime})
}
}
@@ -1671,10 +1727,11 @@ func linkKaruoTipPriceTotal(db *gorm.DB, productID string, tipSource string) (fl
}
// getStandardPrice 从 DB 读取商品标准价(后端校验用),防止客户端篡改金额
// productType: fullbook / vip / section / match / link_karuo_tip
// productId: 章节购买时为章节 ID打赏为 giftId|qty 或 live_mic|giftId|qty
// productType: fullbook / vip / section / match / link_karuo_tip / mentor_consultation
// productId: 章节购买时为章节 ID打赏为 giftId|qty 或 live_mic|giftId|qty;导师咨询为 mentor_consultations.id
// buyerUserID: mentor_consultation 时校验预约归属;其他类型可传空字符串
// tipSource 可选live_mic / home_reward 时 link_karuo_tip 计价与置顶人物礼物配置合并
func getStandardPrice(db *gorm.DB, productType, productID string, tipSource ...string) (float64, error) {
func getStandardPrice(db *gorm.DB, productType, productID, buyerUserID string, tipSource ...string) (float64, error) {
productType = normalizePayProductType(productType)
ts := ""
if len(tipSource) > 0 {
@@ -1767,6 +1824,29 @@ func getStandardPrice(db *gorm.DB, productType, productID string, tipSource ...s
}
return *ch.Price, nil
case "mentor_consultation":
if productID == "" {
return 0, fmt.Errorf("导师咨询缺少预约单 ID")
}
cid, err := strconv.Atoi(strings.TrimSpace(productID))
if err != nil || cid <= 0 {
return 0, fmt.Errorf("无效的预约单 ID")
}
var mc model.MentorConsultation
if err := db.Where("id = ?", cid).First(&mc).Error; err != nil {
return 0, fmt.Errorf("预约单不存在")
}
if mc.Status != "" && mc.Status != "created" {
return 0, fmt.Errorf("预约单状态不可支付")
}
if strings.TrimSpace(buyerUserID) != "" && mc.UserID != buyerUserID {
return 0, fmt.Errorf("预约单与当前用户不匹配")
}
if mc.Amount <= 0 {
return 0, fmt.Errorf("预约金额无效")
}
return mc.Amount, nil
default:
return 0, fmt.Errorf("未知商品类型: %s", productType)
}

View File

@@ -65,10 +65,6 @@ func ReferralBind(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "推荐码无效"})
return
}
if referrer.ID == effectiveUserID {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能使用自己的推荐码"})
return
}
var user model.User
if err := db.Where("id = ?", effectiveUserID).First(&user).Error; err != nil {
@@ -82,6 +78,11 @@ func ReferralBind(c *gin.Context) {
return
}
}
// 以解析后的用户主键为准effectiveUserId 可能与真实 user.id 不一致,曾导致可绑到自己)
if referrer.ID == user.ID {
c.JSON(http.StatusBadRequest, gin.H{"success": false, "error": "不能使用自己的推荐码"})
return
}
expiryDate := time.Now().AddDate(0, 0, bindingDays)
var existing model.ReferralBinding

View File

@@ -2,6 +2,7 @@ package handler
import (
"encoding/json"
"strings"
"time"
"soul-api/internal/model"
@@ -9,6 +10,13 @@ import (
"gorm.io/gorm"
)
// isSelfReferralBuyer 买方与推广者为同一用户:不得享受好友价、不得分佣(防自绑脏数据、支付参数误传自己的推荐码)
func isSelfReferralBuyer(buyerUserID, referrerUserID string) bool {
b := strings.TrimSpace(buyerUserID)
r := strings.TrimSpace(referrerUserID)
return b != "" && r != "" && b == r
}
// computeOrderCommission 按订单计算应付给推广者的佣金
// 会员订单:推广者会员 20%、非会员 10%内容订单90%(好友优惠 5% 仅针对内容)
// order: 已支付订单,需有 product_type、amount、referrer_id
@@ -18,9 +26,15 @@ func computeOrderCommission(db *gorm.DB, order *model.Order, referrerUser *model
if order == nil || order.ReferrerID == nil || *order.ReferrerID == "" {
return 0
}
if order.UserID != "" && isSelfReferralBuyer(order.UserID, *order.ReferrerID) {
return 0
}
if order.ProductType == "link_karuo_tip" {
return 0
}
if order.ProductType == "mentor_consultation" {
return 0
}
// 读取推广配置
distributorShare := 0.9
userDiscount := 0.0

View File

@@ -1,6 +1,7 @@
package handler
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -11,6 +12,7 @@ import (
"soul-api/internal/wechat"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// WechatLogin POST /api/wechat/login
@@ -51,13 +53,25 @@ func WechatPhoneLogin(c *gin.Context) {
db := database.DB()
var user model.User
result := db.Where("open_id = ?", openID).First(&user)
isNewUser := result.Error != nil
isNewUser := errors.Is(result.Error, gorm.ErrRecordNotFound)
if result.Error != nil && !isNewUser {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "查询用户失败"})
return
}
if isNewUser {
// 软删除后再次登录:旧记录 id=openid 仍存在,需用新 id 避免主键冲突
userID := "user_" + randomSuffix()
referralCode := "SOUL" + strings.ToUpper(openID[len(openID)-6:])
nickname := "微信用户" + openID[len(openID)-4:]
// openID 可能为空/短值(微信异常回包),统一走尾部安全截断避免 panic
openIDLast6 := tail(openID, 6)
openIDLast4 := tail(openID, 4)
if openIDLast6 == "" {
openIDLast6 = strings.ToUpper(randomSuffix())
}
if openIDLast4 == "" {
openIDLast4 = strings.ToUpper(randomSuffix())
}
referralCode := "SOUL" + strings.ToUpper(openIDLast6)
nickname := "微信用户" + openIDLast4
avatar := ""
hasFullBook := false
earnings := 0.0
@@ -68,22 +82,48 @@ func WechatPhoneLogin(c *gin.Context) {
if countryCode != "" && countryCode != "86" {
phone = "+" + countryCode + " " + phoneNumber
}
user = model.User{
ID: userID,
OpenID: &openID,
SessionKey: &sessionKey,
Nickname: &nickname,
Avatar: &avatar,
Phone: &phone,
ReferralCode: &referralCode,
HasFullBook: &hasFullBook,
PurchasedSections: &purchasedSections,
Earnings: &earnings,
PendingEarnings: &pendingEarnings,
ReferralCount: &referralCount,
created := false
for i := 0; i < 5; i++ {
userID := "user_" + randomSuffix()
curReferralCode := referralCode
if i > 0 {
suffix := strings.ToUpper(tail(randomSuffix(), 3))
curReferralCode = "SOUL" + strings.ToUpper(tail(openIDLast6+suffix, 6))
}
user = model.User{
ID: userID,
OpenID: &openID,
SessionKey: &sessionKey,
Nickname: &nickname,
Avatar: &avatar,
Phone: &phone,
ReferralCode: &curReferralCode,
HasFullBook: &hasFullBook,
PurchasedSections: &purchasedSections,
Earnings: &earnings,
PendingEarnings: &pendingEarnings,
ReferralCount: &referralCount,
}
if err := db.Create(&user).Error; err != nil {
// 并发场景:若另一请求已创建同 open_id直接回查复用避免 500
if strings.Contains(strings.ToLower(err.Error()), "duplicate") {
var existed model.User
if qErr := db.Where("open_id = ?", openID).First(&existed).Error; qErr == nil {
user = existed
isNewUser = false
created = true
break
}
continue
}
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
return
}
created = true
break
}
if err := db.Create(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败"})
if !created {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "创建用户失败,请稍后重试"})
return
}
} else {
@@ -91,7 +131,10 @@ func WechatPhoneLogin(c *gin.Context) {
if countryCode != "" && countryCode != "86" {
phone = "+" + countryCode + " " + phoneNumber
}
db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone})
if err := db.Model(&user).Updates(map[string]interface{}{"session_key": sessionKey, "phone": phone}).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"success": false, "error": "更新用户失败"})
return
}
user.Phone = &phone
}
@@ -130,7 +173,11 @@ func WechatPhoneLogin(c *gin.Context) {
if user.VipExpireDate != nil {
responseUser["vipExpireDate"] = user.VipExpireDate.Format("2006-01-02")
}
token := fmt.Sprintf("tk_%s_%d", openID[len(openID)-8:], time.Now().Unix())
tokenSeed := tail(openID, 8)
if tokenSeed == "" {
tokenSeed = tail(randomSuffix(), 8)
}
token := fmt.Sprintf("tk_%s_%d", tokenSeed, time.Now().Unix())
c.JSON(http.StatusOK, gin.H{
"success": true,
@@ -143,6 +190,17 @@ func WechatPhoneLogin(c *gin.Context) {
})
}
func tail(s string, n int) string {
if n <= 0 {
return ""
}
r := []rune(strings.TrimSpace(s))
if len(r) <= n {
return string(r)
}
return string(r[len(r)-n:])
}
func strVal(p *string) string {
if p == nil {
return ""