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:
6
.cursor/agent/产品经理/evolution/2026-04-13.md
Normal file
6
.cursor/agent/产品经理/evolution/2026-04-13.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 产品经理 经验记录 - 2026-04-13
|
||||
|
||||
## 会议:按功能同步开发文档
|
||||
|
||||
- **要点**:以可验收功能点为最小同步单位;主需求与《以界面定需求》为验收真源;跨端规则变更须同时更新需求清单状态与优先级。
|
||||
- **待办**:核对 2026-04-02 以来实现与文档差异;vip-members/融合一期验收口径在需求文档中单点定稿。
|
||||
7
.cursor/agent/产品经理/evolution/2026-04-14.md
Normal file
7
.cursor/agent/产品经理/evolution/2026-04-14.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 产品经理 经验记录 - 2026-04-14
|
||||
|
||||
## 吸收沉淀:同步开发需求
|
||||
|
||||
- **需求清单**:在 `开发文档/1、需求/需求汇总.md` 维护编号行(SYNC / LOOP / MAINT 等),状态与责任角色与纪要待办一致。
|
||||
- **验收绑定**:功能完成 = 实现可合并 + **需求表或项目索引当日一行**;跨端规则须在需求或运营与变更中单点写清优先级。
|
||||
- **管理端必要性**:沿用产品 Skill — 纯文案/纯视觉可不建管理端;**配置/开关/审核/统计** 必须显式在需求表标「管理端入口」避免壳功能。
|
||||
@@ -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) |
|
||||
|
||||
6
.cursor/agent/后端工程师/evolution/2026-04-13.md
Normal file
6
.cursor/agent/后端工程师/evolution/2026-04-13.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 后端工程师 经验记录 - 2026-04-13
|
||||
|
||||
## 会议:按功能同步开发文档
|
||||
|
||||
- **要点**:接口变更须写明路由组(miniprogram/admin/db)、路径、字段与错误码;表结构/迁移在部署文档或临时需求池留痕;历史待办按 P0/P1 标注与依赖方。
|
||||
- **待办**:router/ossConfig、vip-members 等待办在文档中优先级化。
|
||||
6
.cursor/agent/后端工程师/evolution/2026-04-14.md
Normal file
6
.cursor/agent/后端工程师/evolution/2026-04-14.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 后端工程师 经验记录 - 2026-04-14
|
||||
|
||||
## 吸收沉淀:接口与需求文档同步
|
||||
|
||||
- 新增/变更接口须在需求或运营摘要中写明 **路由组(miniprogram/admin/db)**、路径、关键字段与错误码;迁移写在 `开发文档/8、部署/` 或临时需求池(若存在)。
|
||||
- **P0/P1 待办**(router、ossConfig、vip-members 等)在 `需求汇总.md` 已单列 BACKEND-P0 行,合并前更新状态与依赖方。
|
||||
@@ -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 缺失四 handler:BookRanking、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) |
|
||||
|
||||
6
.cursor/agent/团队/evolution/2026-04-13.md
Normal file
6
.cursor/agent/团队/evolution/2026-04-13.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 团队 经验记录 - 2026-04-13
|
||||
|
||||
## 会议:按功能同步开发文档(跨角色)
|
||||
|
||||
- **决议**:功能完成定义包含 **代码可合并状态 + 当日文档/项目索引一行摘要(含日期)**;跨端字段或接口变更除各角色文档外,须在团队 evolution 或会议纪要留契约摘要。
|
||||
- **关联**:详见 `agent/产品经理|后端工程师|管理端开发工程师|小程序开发工程师|软件测试/evolution/2026-04-13.md` 与 `.cursor/meeting/2026-04-13_按功能同步开发文档.md`。
|
||||
19
.cursor/agent/团队/evolution/2026-04-14.md
Normal file
19
.cursor/agent/团队/evolution/2026-04-14.md
Normal 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 交叉引用。
|
||||
|
||||
### 同时影响
|
||||
|
||||
小程序、管理端、后端、测试、产品(文档与验收)。
|
||||
50
.cursor/agent/团队/evolution/2026-04-15.md
Normal file
50
.cursor/agent/团队/evolution/2026-04-15.md
Normal 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 编码与多智能体:验证优先
|
||||
|
||||
- **上下文优先**:先给目的、约束、边界、失败模式,再让生成代码(多篇 2024–2025 *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
|
||||
@@ -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) |
|
||||
|
||||
6
.cursor/agent/小程序开发工程师/evolution/2026-04-13.md
Normal file
6
.cursor/agent/小程序开发工程师/evolution/2026-04-13.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 小程序开发工程师 经验记录 - 2026-04-13
|
||||
|
||||
## 会议:按功能同步开发文档
|
||||
|
||||
- **要点**:页面级记录 `/api/miniprogram/*`、scene、支付/登录、分享 singlePage 等行为变更;纯 UI 无接口变更时也注明,便于测试裁剪回归。
|
||||
- **待办**:主路径未记入文档的改动补索引一行。
|
||||
6
.cursor/agent/小程序开发工程师/evolution/2026-04-14.md
Normal file
6
.cursor/agent/小程序开发工程师/evolution/2026-04-14.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 小程序开发工程师 经验记录 - 2026-04-14
|
||||
|
||||
## 吸收沉淀:闭环与硬编码自检
|
||||
|
||||
- 新功能自检:**页面 → 数据从哪来**;若仅为写死展示,在索引注明「无接口/无配置」以免误判完成度。
|
||||
- 涉及 **scene、支付、登录、分享 singlePage** 的改动必须在 `需求汇总` 或项目索引可追溯,并评估对 **我的/阅读/代付** 等邻页的副作用。
|
||||
@@ -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) |
|
||||
|
||||
6
.cursor/agent/开发助理/evolution/2026-04-14.md
Normal file
6
.cursor/agent/开发助理/evolution/2026-04-14.md
Normal 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(小程序逆推闭环)。
|
||||
7
.cursor/agent/开发助理/evolution/2026-04-15.md
Normal file
7
.cursor/agent/开发助理/evolution/2026-04-15.md
Normal 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 为准。
|
||||
@@ -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 |
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
| 2026-03-17 | 后端、团队 | 架构/最佳实践 | api-dev SKILL | Redis 缓存:parts/hot/recommended/stats/config/章节 content;容灾回退 DB;OSS 上传;/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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -43,9 +43,12 @@ soul-api(Go + 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 | 吸收沉淀:逆推闭环自检写入 evolution;LOOP 需求行 | 进行中 |
|
||||
| 2026-04-15 | 业务补齐:`app.js` 挂 `err.response`;导师详情微信支付;找伙伴 join 失败展示后端文案 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-04-02
|
||||
**最后更新**:2026-04-15
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 类型错误(与本次迁移无关),待修复。
|
||||
|
||||
@@ -7,25 +7,37 @@
|
||||
## 1. 经验索引(按角色)
|
||||
|
||||
- 助理橙子:`agent/开发助理/evolution/索引.md`
|
||||
- 2026-04-15:联网编程思维沉淀 + role-flow §1.3
|
||||
- 2026-04-14:各智能体吸收沉淀 + 开发文档骨架与 role-flow §1.2
|
||||
- 2026-03-21:MBTI 头像小程序全链路兜底 + 匹配接口回填
|
||||
- 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-24:router 缺失四 handler 补齐(BookRanking 等)
|
||||
- 2026-03-17:PayNotify 权益归属发起人
|
||||
- 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-16:scripts/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-16:TipTap 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 迁移通知
|
||||
|
||||
---
|
||||
|
||||
|
||||
6
.cursor/agent/管理端开发工程师/evolution/2026-04-13.md
Normal file
6
.cursor/agent/管理端开发工程师/evolution/2026-04-13.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 管理端开发工程师 经验记录 - 2026-04-13
|
||||
|
||||
## 会议:按功能同步开发文档
|
||||
|
||||
- **要点**:以页面/菜单为粒度同步至运营与变更或项目索引;标注所用 `/api/admin/*`、`/api/db/*`;与小程序共用字段写明来源。
|
||||
- **待办**:DistributionPage 类型债是否纳入排期,在文档或索引中闭环。
|
||||
6
.cursor/agent/管理端开发工程师/evolution/2026-04-14.md
Normal file
6
.cursor/agent/管理端开发工程师/evolution/2026-04-14.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 管理端开发工程师 经验记录 - 2026-04-14
|
||||
|
||||
## 吸收沉淀:配置入口与需求表
|
||||
|
||||
- 以 **页面/菜单** 为粒度在 `运营与变更.md` 或项目索引记录所用 `/api/admin/*`、`/api/db/*`;与小程序对齐的字段写明来源。
|
||||
- **MAINT-ADMIN-TS**:`DistributionPage` 等类型债已进 `需求汇总.md`,排期修复后更新状态为已完成。
|
||||
@@ -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) |
|
||||
|
||||
6
.cursor/agent/软件测试/evolution/2026-04-13.md
Normal file
6
.cursor/agent/软件测试/evolution/2026-04-13.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 软件测试 经验记录 - 2026-04-13
|
||||
|
||||
## 会议:按功能同步开发文档
|
||||
|
||||
- **要点**:以更新后的开发文档与各角色项目索引为真源维护回归与联调清单;契约未入文档可标阻塞。
|
||||
- **待办**:依据各端索引更新融合与主路径用例;deploy/health/smoke 与项目管理文档交叉引用。
|
||||
6
.cursor/agent/软件测试/evolution/2026-04-14.md
Normal file
6
.cursor/agent/软件测试/evolution/2026-04-14.md
Normal file
@@ -0,0 +1,6 @@
|
||||
# 软件测试 经验记录 - 2026-04-14
|
||||
|
||||
## 吸收沉淀:以文档为真源的回归
|
||||
|
||||
- **LOOP-20260414**:按「小程序优先逆推」清单编写/裁剪用例;文档与索引未更新的契约项可标 **阻塞—待文档** 并指向纪要问题区。
|
||||
- 主路径冒烟与 deploy/health/smoke 要求见 `运营与变更.md` 与历史纪要交叉引用。
|
||||
@@ -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) |
|
||||
|
||||
108
.cursor/meeting/2026-04-13_按功能同步开发文档.md
Normal file
108
.cursor/meeting/2026-04-13_按功能同步开发文档.md
Normal 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`*
|
||||
@@ -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) |
|
||||
|
||||
@@ -157,13 +157,13 @@ description: Trigger when 小橙/橙子/讨论完毕/记录一下/同步文档/
|
||||
## 文档结构速查
|
||||
|
||||
```
|
||||
开发文档/
|
||||
开发文档/ # 仓库根目录下;若尚未克隆外部大文档,可先维护本骨架与摘要
|
||||
├── 1、需求/需求汇总.md # 需求清单、业务需求
|
||||
├── 8、部署/ # 技术方案、部署说明
|
||||
├── 8、部署/ # 技术方案、部署说明(按需创建)
|
||||
├── 10、项目管理/
|
||||
│ ├── 项目落地推进表.md # 里程碑、永平落地
|
||||
│ ├── 项目落地推进表.md # 里程碑、永平落地(按需创建)
|
||||
│ └── 运营与变更.md # 近期更新、变更记录
|
||||
临时需求池/ # 需求分析、技术分析、待办
|
||||
临时需求池/ # 需求分析、技术分析、待办(按需创建)
|
||||
.cursor/agent/ # 每角色 evolution 经验池
|
||||
├── 开发助理/evolution/、项目索引/{小程序,管理端,后端,产品,测试,助理橙子,团队}.md
|
||||
├── 小程序开发工程师/evolution/
|
||||
|
||||
@@ -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》交叉引用;项目索引助理橙子/团队追加进度
|
||||
|
||||
112
.cursor/skills/feishu-webhook/SKILL.md
Normal file
112
.cursor/skills/feishu-webhook/SKILL.md
Normal 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 订单/获客逻辑对齐,便于抽到任意后端项目。
|
||||
@@ -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. 协同流程总览(线框图)
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 + path,path 上带 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 }
|
||||
|
||||
@@ -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="编辑资料">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 管理端 TipTap:div.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
|
||||
|
||||
191
miniprogram/utils/linkedMiniprogramNavigate.js
Normal file
191
miniprogram/utils/linkedMiniprogramNavigate.js
Normal 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,
|
||||
}
|
||||
@@ -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 → dist1,dist2 → dist ====================
|
||||
|
||||
|
||||
def remote_swap_dist(cfg):
|
||||
"""服务器上:dist→dist1,dist2→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"))
|
||||
|
||||
@@ -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 → dist1,dist2 → dist ====================
|
||||
if client:
|
||||
client.close()
|
||||
|
||||
|
||||
def remote_swap_dist(cfg):
|
||||
"""服务器上:dist→dist1,dist2→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
|
||||
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.pagePopupItems(memberDetailPage/readPage 已废弃,由迁移合并)
|
||||
"pagePopupItems": []interface{}{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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{}
|
||||
|
||||
@@ -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": "缺少预约单 ID(productId)"})
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user