删除不再使用的文件,包括开放 API 鉴权规范文档、数据库迁移脚本和旧版图标组件,优化项目结构和资源管理。更新小程序代码以支持代付功能,增加代付分享弹窗和支付逻辑,提升用户体验。
This commit is contained in:
38
.cursor/agent/产品经理/evolution/2026-03-18.md
Normal file
38
.cursor/agent/产品经理/evolution/2026-03-18.md
Normal file
@@ -0,0 +1,38 @@
|
||||
# 产品经理 经验记录 - 2026-03-18
|
||||
|
||||
## 文档归档与需求口径(界面驱动)
|
||||
|
||||
### 需求基准(验收口径)
|
||||
- **需求以界面为准**:以 `开发文档/1、需求/以界面定需求.md` 的“界面清单 + 业务逻辑对齐”为验收基准。
|
||||
- **需求清单与变更记录**:
|
||||
- 需求清单:`开发文档/1、需求/需求汇总.md`
|
||||
- 近期讨论/决议:`开发文档/10、项目管理/运营与变更.md`
|
||||
- 里程碑执行层:`开发文档/10、项目管理/项目落地推进表.md`
|
||||
|
||||
### 归档原则(避免文档发散)
|
||||
- 新增/改版功能必须同步更新:
|
||||
- 《以界面定需求》(界面与主要接口)
|
||||
- 《需求汇总》(需求清单条目:日期/描述/状态/备注)
|
||||
- 《运营与变更》(决议与实现摘要)
|
||||
- 旧方案文档若已过时:在 `开发文档/README.md` 的“已移除文档”里登记清理原因,避免重复讨论。
|
||||
|
||||
### 功能需求整理(按产品域)
|
||||
- **内容阅读与付费**:预览与解锁规则、VIP 全章免费、余额支付与微信支付链路、阅读统计与埋点。
|
||||
- **代付分享**:发起人支付后分享;好友打开阅读页自动领取并解锁;发起人可查看领取进度与明细。
|
||||
- **推广分销与提现**:分润规则可配置(会员 20%/非会员 10%、内容 90%)、提现闭环(申请→审核/打款→回写→订阅消息)。
|
||||
- **找伙伴/存客宝**:@mention/#标签自动创建与同步;留资与匹配流程;限频与风控边界。
|
||||
|
||||
### 分享场景强约束(验收必测)
|
||||
- **好友分享 vs 朋友圈分享(singlePage)**:
|
||||
- 朋友圈进入可能是单页模式,页面能力不完整
|
||||
- 验收必须覆盖:单页模式不触发支付/自动领取等强动作,且明确引导“前往小程序”进入完整版
|
||||
|
||||
## 超级个体开通后自动创建@人与资料引导(会议决议)
|
||||
|
||||
### 业务目标与规则
|
||||
- **自动创建 @人**:用户开通超级个体后,管理端「链接人与事」自动创建一条 Person 记录,展示名与用户**当前昵称一致**。
|
||||
- **资料完善拦截**:支付超级个体前若昵称/头像为默认值,必须引导至仅头像+昵称的引导页完成修改;开通后进入权益/成功页也需再次检测兜底。
|
||||
|
||||
### 待确认
|
||||
- 昵称变更后的同步规则:是否强制同步更新 Person.name,是否需要保留历史别名/展示区分。
|
||||
|
||||
41
.cursor/agent/后端工程师/evolution/2026-03-18.md
Normal file
41
.cursor/agent/后端工程师/evolution/2026-03-18.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# 后端工程师 经验记录 - 2026-03-18
|
||||
|
||||
## 功能需求口径整理(按接口契约与风险)
|
||||
|
||||
### 需求基准(后端视角)
|
||||
- 以 `开发文档/1、需求/以界面定需求.md` 的“界面→接口”映射为准:
|
||||
- 小程序只用 `/api/miniprogram/*`
|
||||
- 管理端只用 `/api/admin/*`、`/api/db/*`、`/api/orders` 等
|
||||
|
||||
### 核心功能域(必须稳定)
|
||||
- **阅读与权限**:
|
||||
- 未授权只返回预览;授权返回全文
|
||||
- VIP 全章免费信号必须后端折叠输出,前端只认统一字段(避免各端各自判断)
|
||||
- **支付链路**:
|
||||
- 下单→支付→回调→解锁/分润,必须具备幂等与可追溯(订单号、来源、日志)
|
||||
- **代付分享(发起人支付,好友领取)**:
|
||||
- 发起人支付后产生可分享 requestSn
|
||||
- 好友领取必须并发安全(名额扣减原子、重复领取幂等)
|
||||
- 权益归属必须正确(代付场景 beneficiaryUserID=发起人)
|
||||
- **推广/分润/提现**:
|
||||
- 分润规则可配置,计算口径一致
|
||||
- 提现流转:申请→审核/打款→回写状态→订阅消息
|
||||
|
||||
### 分享场景风险点(联调/验收必测)
|
||||
- **朋友圈 singlePage**:属于前端能力限制,但后端要做到:
|
||||
- 接口幂等(前端重试/重复进入会更频繁)
|
||||
- 错误码与提示文案清晰(便于前端引导“前往小程序”)
|
||||
|
||||
### 文档归档(后端相关)
|
||||
- 里程碑推进表:`开发文档/10、项目管理/项目落地推进表.md`
|
||||
- 测试流程与回归口径:`scripts/test/功能测试流程.md`
|
||||
|
||||
## 超级个体开通后自动创建@人(Person)与资料完善 flags
|
||||
|
||||
### 幂等与建模建议
|
||||
- 自动创建 Person 建议以业务主键(`userId`)作为**幂等键**,避免仅依赖昵称导致重名/改名混乱。
|
||||
- 倾向在 `persons` 增加 `user_id`(并做唯一索引/约束),后续昵称变更时可按 `user_id` 同步更新 `name`。
|
||||
|
||||
### 端上资料完善判断
|
||||
- 默认头像/昵称判定不建议只靠前端字符串规则;后端可在用户资料/登录态接口返回明确布尔值(如 `profileNeedComplete` / `isDefaultAvatar` / `isDefaultNickname`),小程序仅消费并跳转引导页。
|
||||
|
||||
24
.cursor/agent/团队/evolution/2026-03-18.md
Normal file
24
.cursor/agent/团队/evolution/2026-03-18.md
Normal file
@@ -0,0 +1,24 @@
|
||||
# 团队 经验记录 - 2026-03-18
|
||||
|
||||
## 分享链路统一规则:兼容朋友圈 singlePage(单页模式)
|
||||
|
||||
### 背景
|
||||
微信“朋友圈分享”点进小程序页可能是 **singlePage**,能力不完整;如果不做判断,容易出现支付/登录/领取等链路断裂,引发转化损失与投诉。
|
||||
|
||||
### 团队级决议(跨端共识)
|
||||
- **任何“分享进入”的关键流程**(支付、代付、领取、绑定等)都要:
|
||||
- **识别 singlePage**:`wx.getSystemInfoSync().mode === 'singlePage'`(并允许通过 `app.globalData.isSinglePageMode` 兜底标记)
|
||||
- **做能力降级**:单页模式不执行强动作(支付/自动领取/自动登录等)
|
||||
- **给出明确引导**:提示用户点击底部 **「前往小程序」** 进入完整版后再操作
|
||||
|
||||
### 落地建议
|
||||
- UI/交互层:统一封装 `ensureFullAppMode()`(或页面内统一判断),避免每个按钮散落实现导致漏判
|
||||
- 测试层:新增用例覆盖“朋友圈 singlePage 打开 + 点击关键按钮”应出现引导而非报错/卡死
|
||||
|
||||
## 超级个体开通后自动创建@人与资料完善拦截(跨端共识)
|
||||
|
||||
### 团队级决议
|
||||
- “可被 @ 的人”统一走 Person 体系,避免为超级个体另建一套 mention 逻辑。
|
||||
- 幂等键应绑定业务主键(优先 `userId`),展示名同步为昵称(具体同步规则由产品确认)。
|
||||
- 默认资料判定尽量由后端提供明确 flags,前端仅做跳转与阻断,并保留兜底规则。
|
||||
|
||||
34
.cursor/agent/小程序开发工程师/evolution/2026-03-18.md
Normal file
34
.cursor/agent/小程序开发工程师/evolution/2026-03-18.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 小程序开发工程师 经验记录 - 2026-03-18
|
||||
|
||||
## 分享链路:好友/朋友圈单页模式兼容(强约束)
|
||||
|
||||
### 场景
|
||||
- **好友分享**:从会话/好友点进来,页面能力完整,可登录、可支付、可领取。
|
||||
- **朋友圈分享**:点进来可能是 **singlePage(单页模式)**,页面能力不完整(常见限制:登录/支付/部分交互不可靠)。
|
||||
|
||||
### 结论(必须执行)
|
||||
- **凡涉及分享链路的功能**(购买、代付、领取、登录等),必须先判断是否处于单页模式:
|
||||
- `const isSinglePage = (wx.getSystemInfoSync()?.mode === 'singlePage') || app.globalData.isSinglePageMode`
|
||||
- **单页模式下能力降级**:
|
||||
- 禁止直接发起支付、禁止隐式自动领取等强动作
|
||||
- 通过弹窗/提示文案 **引导用户点击底部「前往小程序」进入完整版**后再操作
|
||||
- **非单页模式**:正常执行登录/支付/领取等完整链路
|
||||
|
||||
### 推荐实现点位(模板)
|
||||
- 打开页:若参数表示“代付/领取/支付”等强动作,单页模式直接 `wx.showModal` 提示并 return
|
||||
- 按钮点击:支付/领取/登录等入口,统一在 handler 最前面做单页模式拦截
|
||||
|
||||
### 关联模块
|
||||
- 阅读页 `pages/read/read`、代付/支付相关页面
|
||||
|
||||
## 超级个体支付前资料完善拦截(头像+昵称引导)
|
||||
|
||||
### 结论
|
||||
- 复用 `pages/avatar-nickname`(仅头像+昵称)作为“资料完善引导页”。
|
||||
- 在两处做强校验并跳转:
|
||||
- **支付超级个体之前**:在“去支付/确认支付”按钮入口先校验默认头像/昵称,不通过则 `navigateTo('/pages/avatar-nickname/avatar-nickname')` 并 return。
|
||||
- **开通成功后**:在权益页/成功回调再校验一次兜底,确保用户最终资料不为默认。
|
||||
|
||||
### 口径
|
||||
- 默认判断优先使用后端返回 flags(如 `profileNeedComplete`),前端仅做兜底规则(空昵称、昵称以“微信用户”开头、头像为空/命中默认头像域名等)。
|
||||
|
||||
15
.cursor/agent/开发助理/evolution/2026-03-18.md
Normal file
15
.cursor/agent/开发助理/evolution/2026-03-18.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# 开发助理 经验记录 - 2026-03-18
|
||||
|
||||
## 开发文档归档整理(统一入口与索引一致性)
|
||||
|
||||
### 发现与修复
|
||||
- `开发文档/README.md` 索引引用了 `开发文档/10、项目管理/项目落地推进表.md`,但该文件缺失。
|
||||
- 已补齐:新建 `开发文档/10、项目管理/项目落地推进表.md`,用于记录里程碑、风险与下一步。
|
||||
|
||||
### 归档建议(持续维护规则)
|
||||
- 需求基准:`以界面定需求.md`
|
||||
- 需求清单:`需求汇总.md`
|
||||
- 决议与变更:`运营与变更.md`
|
||||
- 执行层推进:`项目落地推进表.md`
|
||||
- 每次变更后按上述 4 份文档联动更新,避免“清单/决议/执行层”脱节。
|
||||
|
||||
@@ -51,6 +51,8 @@
|
||||
| 2026-03-17 | 小程序 | 业务规则 | - | 代付统一到代付页:gift=1&ref 打开 read 时 redirectTo 代付页,禁止在阅读页代付 |
|
||||
| 2026-03-17 | 软件测试 | 流程定稿 | testing SKILL | 功能测试流程:成功 ☑️、失败列问题、最终报告;scripts/test/功能测试流程.md、测试报告-环境与用例清单.md |
|
||||
| 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 | 产品、后端、管理端、测试 | 文档归档/需求口径 | - | 文档归档整理:以《以界面定需求》为基准,各角色重整“功能需求+验收口径+风险点”并写入各自经验库;补齐《项目落地推进表》 |
|
||||
|
||||
---
|
||||
|
||||
@@ -61,4 +63,4 @@
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-17(会议收尾:源码优化完成与测试流程定稿)
|
||||
**最后更新**:2026-03-18
|
||||
|
||||
@@ -24,9 +24,11 @@ Soul 创业派对产品定位:面向创业者的社区/工具型小程序。
|
||||
| 2026-03-16 | 乘风发起例行开发进度同步 | 已完成 |
|
||||
| 2026-03-16 | 会议:new-soul 新需求与当前项目差异分析 | 已完成 |
|
||||
| 2026-03-17 | 会议:稳定版源码质量优化;验收标准功能不变、三端联调通过 | 待续 |
|
||||
| 2026-03-18 | 文档归档整理:以《以界面定需求》为基准,重整需求口径/验收点/分享 singlePage 约束,写入产品经验库 | 已完成 |
|
||||
| 2026-03-18 | 会议:超级个体开通后自动创建@人与支付前资料引导(头像+昵称) | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-17
|
||||
**最后更新**:2026-03-18
|
||||
|
||||
@@ -36,9 +36,11 @@ soul-api(Go + Gin + GORM + MySQL)提供三组路由:`/api/miniprogram/*`
|
||||
| 2026-03-17 | 会议:稳定版源码质量优化;敏感配置生产强制校验、新增 /api/admin/user/track、AdminWithdrawTest 环境限制 | 已完成 |
|
||||
| 2026-03-17 | 会议收尾:源码优化 10 项全部完成;开发环境测试 10 通过 2 跳过 | 已完成 |
|
||||
| 2026-03-17 | 性能优化会议:Redis 缓存接入(parts/hot/recommended/stats/config/章节 content)、容灾回退 DB;OSS 上传接入;/health 返回 database/redis 状态 | 已完成 |
|
||||
| 2026-03-18 | 文档归档整理:按界面→接口→规则口径重整后端功能需求与风险点,写入角色经验库 | 已完成 |
|
||||
| 2026-03-18 | 会议:超级个体开通后自动创建@人(Person 绑定 userId 幂等)与资料完善 flags 方案 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-17
|
||||
**最后更新**:2026-03-18
|
||||
|
||||
@@ -29,9 +29,11 @@ Soul 创业派对全项目架构与约定:路由隔离(miniprogram/admin/db
|
||||
| 2026-03-16 | TipTap Mention 需 data-label 规则;链接人与事与存客宝对接优化会议收尾 | 已完成 |
|
||||
| 2026-03-17 | 代付美团式流程与权益归属约定:读页→代付页→分享;权益/分佣归发起人;PayNotify beneficiaryUserID | 已完成 |
|
||||
| 2026-03-17 | 性能优化与 Redis 缓存方案落地:Redis 容灾回退 DB、OSS 上传容灾;/health 返回 database/redis 状态 | 已完成 |
|
||||
| 2026-03-18 | 吸收经验:分享进入链路需兼容朋友圈 singlePage;单页模式不执行支付/自动领取等强动作并引导“前往小程序” | 已完成 |
|
||||
| 2026-03-18 | 会议:超级个体开通后自动创建@人统一走 Person;幂等键绑定 userId;默认资料 flags 后端输出 | 已完成 |
|
||||
|
||||
> **格式说明**:每次架构级讨论后在此追加一行,日期格式 YYYY-MM-DD
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-17
|
||||
**最后更新**:2026-03-18
|
||||
|
||||
@@ -39,9 +39,11 @@
|
||||
| 2026-03-17 | 代付页营销:章节标题+20%内容预览;我的代付列表点击进详情;页面协调 | 已完成 |
|
||||
| 2026-03-17 | 会议:稳定版源码质量优化;删除 payment.js、goToMatch 重复、备份文件;config 读取、totalSections 动态化 | 已完成 |
|
||||
| 2026-03-17 | 会议收尾:源码优化 5 项全部完成;开发环境测试通过 | 已完成 |
|
||||
| 2026-03-18 | 吸收经验:分享链路需兼容好友/朋友圈 singlePage;单页模式能力降级并引导“前往小程序”进入完整版 | 已完成 |
|
||||
| 2026-03-18 | 会议:支付超级个体前/开通后资料默认校验,跳转 avatar-nickname 引导页(仅头像+昵称) | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-17
|
||||
**最后更新**:2026-03-18
|
||||
|
||||
@@ -30,7 +30,9 @@
|
||||
| 2026-03-17 | 会议:稳定版源码质量优化;每项小回归、全部完成后完整三端联调 | 待续 |
|
||||
| 2026-03-17 | 会议收尾:功能测试流程定稿、测试报告模板、开发环境 10 通过 2 跳过 | 已完成 |
|
||||
| 2026-03-17 | 性能优化会议:test_upload.py 6 用例;/health 可验证 database/redis;部署后回归缓存接口 | 已完成 |
|
||||
| 2026-03-18 | 文档归档整理:按界面驱动口径统一验收;补充分享 singlePage 降级与引导为必测项 | 已完成 |
|
||||
| 2026-03-18 | 会议:新增用例(资料默认阻断支付、Person 自动创建幂等、昵称变更同步回归) | 已完成 |
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-17
|
||||
**最后更新**:2026-03-18
|
||||
|
||||
@@ -41,11 +41,13 @@
|
||||
| 2026-03-17 | 会议:稳定版源码质量优化;UserDetailModal 改 /api/admin/user/track、RichEditor HTML 转义 | 已完成 |
|
||||
| 2026-03-17 | 会议收尾:源码优化已落地;开发环境测试通过 | 已完成 |
|
||||
| 2026-03-17 | 性能优化会议:OSS 配置后上传自动优先 OSS,失败回退本地;无需前端改动 | 已完成 |
|
||||
| 2026-03-18 | 文档归档整理:按《以界面定需求》重整管理端功能需求与验收口径,写入角色经验库 | 已完成 |
|
||||
| 2026-03-18 | 会议:超级个体开通后自动创建@人;管理端可选展示 userId/来源以便排查重名 | 已完成 |
|
||||
|
||||
> **格式说明**:每次开发后在此追加一行,日期格式 YYYY-MM-DD,状态用:已完成 / 进行中 / 待续 / 搁置
|
||||
|
||||
---
|
||||
|
||||
**最后更新**:2026-03-17
|
||||
**最后更新**:2026-03-18
|
||||
|
||||
> 注:soul-admin 构建仍有 DistributionPage Order.description 类型错误(与本次迁移无关),待修复。
|
||||
|
||||
29
.cursor/agent/管理端开发工程师/evolution/2026-03-18.md
Normal file
29
.cursor/agent/管理端开发工程师/evolution/2026-03-18.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# 管理端开发工程师 经验记录 - 2026-03-18
|
||||
|
||||
## 功能需求整理(以界面定需求 → 管理端任务清单)
|
||||
|
||||
### 需求基准
|
||||
- 管理端界面与接口以 `开发文档/1、需求/以界面定需求.md` 第三节为准。
|
||||
- 允许路径:`/api/admin/*`、`/api/db/*`、`/api/orders` 等;禁止调用 `/api/miniprogram/*`。
|
||||
|
||||
### 主要功能域(稳定版基线)
|
||||
- **登录与鉴权**:JWT Bearer;鉴权失败应清 token 并回登录(避免假登录态)。
|
||||
- **数据概览**:用户/订单/收入、趋势与最近列表。
|
||||
- **内容管理**:章节树/内容编辑/API 文档入口(以稳定版为主,迁移新版时不覆盖核心逻辑)。
|
||||
- **用户管理**:列表/搜索/详情;VIP 设置(到期必填);用户余额与行为轨迹。
|
||||
- **订单管理**:支付方式(微信/余额/代付);筛选/退款;用户/推荐人信息展示。
|
||||
- **提现管理**:审核/打款/状态流转;测试接口在 release 环境不可用。
|
||||
- **系统设置**:免费章节、推广设置、站点与 OSS 配置(如有 region 等字段需与后端契约一致)。
|
||||
|
||||
### 新版差异(迁移待办口径)
|
||||
- 迁移与否以 `开发文档/迁移完成度与待办清单.md` 为准:优先补齐“运行时配置/审核模式/auditMode”相关的界面隐藏与配置读取一致性。
|
||||
|
||||
### 分享链路验收提醒(管理端侧)
|
||||
- 虽然 singlePage 属于小程序端场景,但管理端涉及“配置/开关/文案”时,需要给小程序提供可配置的引导文案或开关(若业务要求),否则前端只能硬编码。
|
||||
|
||||
## 超级个体开通后自动创建@人(链接人与事)
|
||||
|
||||
### 管理端影响面
|
||||
- 自动创建的 Person 记录会出现在「链接人与事」列表中;如后端新增 `persons.user_id`,管理端可选增加一列“绑定用户/来源”,便于运营排查与避免重名困扰。
|
||||
- mention 展示依赖 TipTap 的 `data-label`:只要后端/数据层保证 `data-label=昵称`,管理端预览与编辑侧显示即可稳定。
|
||||
|
||||
34
.cursor/agent/软件测试/evolution/2026-03-18.md
Normal file
34
.cursor/agent/软件测试/evolution/2026-03-18.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# 软件测试 经验记录 - 2026-03-18
|
||||
|
||||
## 文档归档后测试口径统一(按“界面→接口→规则”验收)
|
||||
|
||||
### 验收基准(先看文档再测)
|
||||
- 《以界面定需求》:`开发文档/1、需求/以界面定需求.md`
|
||||
- 《需求清单》:`开发文档/1、需求/需求汇总.md`
|
||||
- 《变更与决议》:`开发文档/10、项目管理/运营与变更.md`
|
||||
- 《测试流程》:`scripts/test/功能测试流程.md`
|
||||
- 《里程碑推进》:`开发文档/10、项目管理/项目落地推进表.md`
|
||||
|
||||
### 分享场景新增必测点(高优先级)
|
||||
- **好友分享**:进入页面能力完整,支付/登录/领取均可用。
|
||||
- **朋友圈分享(singlePage)**:
|
||||
- 进入后页面能力可能不完整
|
||||
- 关键按钮点击应 **不执行强动作**(支付/自动领取/自动登录等)
|
||||
- 必须出现明确引导:点击底部 **「前往小程序」** 进入完整版
|
||||
|
||||
### 回归覆盖建议(与分享强相关的链路)
|
||||
- 代付分享:发起人支付→分享→好友打开→自动/手动领取→解锁正文→重复进入幂等
|
||||
- 支付与回调:微信/余额两条路,状态一致,失败/取消提示一致
|
||||
- 路径隔离:小程序只调 `/api/miniprogram/*`;管理端不调 miniprogram
|
||||
|
||||
## 新增用例:超级个体开通后自动创建@人与支付前资料引导
|
||||
|
||||
### 资料引导拦截
|
||||
- 默认资料(昵称/头像)→ 点击支付超级个体 → 必须跳转 `avatar-nickname` 引导页并阻断支付
|
||||
- 引导页保存成功 → 返回原流程继续支付(不丢失上下文)
|
||||
- 已完善资料 → 不拦截支付
|
||||
|
||||
### Person 自动创建(幂等)
|
||||
- 支付成功回调重复触发/用户重复进入成功页 → Person 记录不应重复创建
|
||||
- 昵称变更后同步策略回归(若实现“跟随昵称”)
|
||||
|
||||
130
.cursor/meeting/2026-03-18_超级个体开通后自动创建@人与资料引导.md
Normal file
130
.cursor/meeting/2026-03-18_超级个体开通后自动创建@人与资料引导.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# 会议纪要 - 2026-03-18 | 超级个体开通后自动创建@人与资料引导
|
||||
|
||||
> 本文件由**助理橙子**在会议结束后自动生成。
|
||||
|
||||
---
|
||||
|
||||
## 基本信息
|
||||
|
||||
- **时间**:2026-03-18 (记录时间以当天为准)
|
||||
- **议题**:
|
||||
- 小程序用户开通「超级个体」后,管理端「链接人与事」需自动创建一个与用户昵称一致的 `@人`
|
||||
- 小程序新增校验:用户昵称/头像为默认值时,跳转到仅修改头像+昵称的引导页;且在支付超级个体之前也必须先完成此检查
|
||||
- **触发方式**:开个会
|
||||
- **参与角色**:产品经理、后端开发、管理端开发工程师、小程序开发工程师、测试人员
|
||||
|
||||
---
|
||||
|
||||
## 各角色发言
|
||||
|
||||
### 【产品经理】
|
||||
|
||||
- **目标**:让超级个体开通后立刻具备「可被内容 @」的入口,形成“内容→人→转化”的闭环;同时在付费前强制用户完善头像昵称,避免默认资料影响信任与转化。
|
||||
- **业务规则**:
|
||||
- 自动创建的 `@人` 展示名 **必须与当前用户昵称一致**(后续昵称变更需要同步策略)。
|
||||
- 资料引导页只包含头像+昵称两项,用户完成后回到原流程继续支付/使用。
|
||||
- **验收**:
|
||||
- 开通成功后,管理端「链接人与事」列表出现该用户昵称对应的记录;且可在内容编辑中被搜索/插入为 mention。
|
||||
- 支付超级个体前若昵称/头像为默认值,必须跳转引导页并阻断支付。
|
||||
|
||||
### 【后端开发】
|
||||
|
||||
- **建议实现点**:在“超级个体开通成功”的后端闭环(支付回调/开通接口最终落库点)触发一次“确保 Person 存在”的逻辑。
|
||||
- **数据与幂等**:
|
||||
- 仅用 `name=nickname` 作为唯一键会遇到重名与改名问题;建议在 `persons` 增加 `user_id`(或等价字段)作为绑定关系,做到幂等与可追溯。
|
||||
- 若暂不加字段,也至少保证:同名已存在则复用,不重复创建。
|
||||
- **接口契约方向**:
|
||||
- 小程序资料默认判定若后端统一输出更稳:建议在用户资料接口/登录态接口返回 `profileNeedComplete` / `isDefaultAvatar` / `isDefaultNickname`,小程序只负责跳转与阻断支付。
|
||||
|
||||
### 【管理端开发工程师】
|
||||
|
||||
- 「链接人与事」现有列表与 Person CRUD 已具备承载自动创建记录的能力;若增加 `user_id` 字段,可在列表中加一列“来源/绑定用户”便于运营排查。
|
||||
- 内容编辑插入 mention 已依赖 `data-label` 显示名规则:只要后端/存储保证 `data-label=昵称`,即可正确展示。
|
||||
|
||||
### 【小程序开发工程师】
|
||||
|
||||
- 工程上已有独立页 `pages/avatar-nickname/avatar-nickname`(仅头像+昵称),可复用。
|
||||
- 需要补两处拦截:
|
||||
- **支付前**:在“去支付/确认支付”按钮入口做一次默认资料校验,未通过则跳转引导页并 return。
|
||||
- **开通后**:在超级个体权益页/开通成功回调后再做一次校验,若仍为默认则跳引导页(兼容用户开通后才意识到资料不完善)。
|
||||
- 默认判定若只靠前端字符串规则易漂移,建议后端提供明确布尔值;前端可做兜底规则(空昵称、昵称以“微信用户”开头、头像为空/命中默认头像域名等)。
|
||||
|
||||
### 【测试人员】
|
||||
|
||||
- 新增用例需覆盖:
|
||||
- 资料默认 → 触发引导页 → 保存成功 → 回到支付流程继续完成支付
|
||||
- 资料已完善 → 不拦截支付
|
||||
- 开通成功后自动创建 Person:重复支付回调/重复点击导致的幂等(不应创建多条)
|
||||
- 昵称变更后:自动创建记录是否更新展示名(按决议验收)
|
||||
|
||||
---
|
||||
|
||||
## 讨论过程
|
||||
|
||||
- 讨论了 Person 的现有语义(用于文章 mention 与 CKB 线索承接),并确认“超级个体开通后自动创建 @人”应落在同一条 Person 体系内,避免另起一套表导致前后端两套 mention 逻辑分裂。
|
||||
- 对“唯一键”的讨论:仅用昵称无法保证幂等与长期一致性,因此倾向新增 `persons.user_id` 绑定用户;并在昵称变化时做同步更新策略。
|
||||
- 对“默认资料判定”的讨论:前端硬编码规则不稳,后端输出明确 flags 最稳;前端保留兜底。
|
||||
|
||||
---
|
||||
|
||||
## 会议决议
|
||||
|
||||
1. **自动创建 @人(Person)触发点**:在“超级个体开通成功”的后端落库闭环触发 `ensurePersonForUser(userId)`;幂等保证不重复创建。
|
||||
2. **Person 与用户绑定**:优先方案为 `persons` 增加 `user_id` 字段(并加唯一约束/索引),以 `user_id` 为幂等键;`name` 作为展示名,与用户昵称保持同步策略。
|
||||
3. **小程序资料引导**:复用 `pages/avatar-nickname`,在“支付前入口”与“开通后进入权益页/成功回调”两处增加默认资料校验与跳转。
|
||||
4. **默认资料判定口径**:后端优先提供明确 flags(如 `profileNeedComplete` / `isDefaultNickname` / `isDefaultAvatar`),小程序仅消费;前端可保留兜底规则。
|
||||
|
||||
---
|
||||
|
||||
## 待办事项
|
||||
|
||||
| 责任角色 | 任务 | 优先级 | 截止建议 |
|
||||
|---------|------|--------|---------|
|
||||
| 后端开发 | 在超级个体开通成功链路增加 `ensurePersonForUser`;为 `persons` 增加 `user_id` 并做幂等;必要时补“昵称变更同步” | 高 | 2026-03-20 |
|
||||
| 管理端开发工程师 | 链接人与事列表可选增加 `userId/来源` 展示与筛选;确认插入 mention 时 `data-label` 始终为昵称 | 中 | 2026-03-21 |
|
||||
| 小程序开发工程师 | 支付前与开通后两处增加资料默认校验;不通过则跳转 `avatar-nickname` 引导页并阻断后续动作 | 高 | 2026-03-20 |
|
||||
| 产品经理 | 明确“昵称变更后的同步规则/是否允许重名冲突显示”;补充验收标准文案 | 中 | 2026-03-19 |
|
||||
| 测试人员 | 补充用例:资料引导阻断支付 + 幂等创建 Person + 昵称变更同步回归 | 中 | 联调前 |
|
||||
|
||||
---
|
||||
|
||||
## 问题与作答区
|
||||
|
||||
| # | 问题 | 责任角色 | 作答 |
|
||||
|---|------|---------|------|
|
||||
| 1 | `persons.user_id` 是否需要唯一索引?重名用户在列表展示如何区分? | 后端/产品 | (待补充) |
|
||||
| 2 | 昵称变更是否必须同步更新 Person.name?若同步,是否保留历史别名? | 产品/后端 | (待补充) |
|
||||
| 3 | 默认头像/昵称判定的最终口径(后端 flags vs 前端兜底规则)以哪一套为准? | 后端/小程序 | (待补充) |
|
||||
|
||||
---
|
||||
|
||||
## 各角色经验与业务理解更新
|
||||
|
||||
### 产品经理
|
||||
|
||||
- 超级个体开通后要立刻具备“可被内容 @”入口;支付前资料完善是转化关键拦截点(头像/昵称)。
|
||||
|
||||
### 后端开发
|
||||
|
||||
- 自动创建 Person 必须做幂等,建议以 `user_id` 绑定,避免仅靠昵称造成重名/改名混乱;默认资料判定尽量由后端输出 flags,前端只消费。
|
||||
|
||||
### 管理端开发工程师
|
||||
|
||||
- Person 体系可承载自动创建记录;mention 显示依赖 `data-label`,需确保展示名与数据一致。
|
||||
|
||||
### 小程序开发工程师
|
||||
|
||||
- 复用 `avatar-nickname` 引导页,在支付前/开通后两处做资料校验与跳转;后端 flags 优先,前端规则兜底。
|
||||
|
||||
### 测试人员
|
||||
|
||||
- 新增强约束:资料未完善必须阻断支付;自动创建 Person 要验幂等与昵称变更同步。
|
||||
|
||||
### 团队共享
|
||||
|
||||
- “可被 @ 的人”统一走 Person 体系;幂等键优先绑定业务主键(userId),展示名同步为昵称;默认资料判定由后端输出布尔 flags 更稳定。
|
||||
|
||||
---
|
||||
|
||||
*会议纪要由助理橙子生成 | 各角色经验已同步至 `agent/{角色}/evolution/2026-03-18.md`*
|
||||
|
||||
@@ -79,3 +79,4 @@ YYYY-MM-DD_会议主题.md
|
||||
| 2026-03-17 | 稳定版源码质量优化方案讨论与开发安排 | 产品、后端、管理端、小程序、测试 | [2026-03-17_稳定版源码质量优化方案讨论与开发安排.md](2026-03-17_稳定版源码质量优化方案讨论与开发安排.md) |
|
||||
| 2026-03-17 | 会议收尾:源码优化完成与测试流程定稿 | 产品、后端、管理端、小程序、测试、助理橙子 | [2026-03-17_会议收尾-源码优化完成与测试流程定稿.md](2026-03-17_会议收尾-源码优化完成与测试流程定稿.md) |
|
||||
| 2026-03-17 | 性能优化与 Redis 缓存方案落地 | 后端、管理端、小程序、测试、助理橙子 | [2026-03-17_性能优化与Redis缓存方案落地.md](2026-03-17_性能优化与Redis缓存方案落地.md) |
|
||||
| 2026-03-18 | 超级个体开通后自动创建@人与资料引导 | 产品、后端、管理端、小程序、测试 | [2026-03-18_超级个体开通后自动创建@人与资料引导.md](2026-03-18_超级个体开通后自动创建@人与资料引导.md) |
|
||||
|
||||
@@ -13,8 +13,8 @@ const DEFAULT_WITHDRAW_TMPL_ID = 'u3MbZGPRkrZIk-I7QdpwzFxnO_CeQPaCWF2FkiIablE'
|
||||
App({
|
||||
globalData: {
|
||||
// API 基础地址:开发时修改下面一行切换环境
|
||||
baseUrl: "https://soulapi.quwanzhi.com",
|
||||
// baseUrl: 'http://localhost:8080', // 开发
|
||||
// baseUrl: "https://soulapi.quwanzhi.com",
|
||||
baseUrl: 'http://localhost:8080', // 开发
|
||||
// baseUrl: 'https://souldev.quwanzhi.com', // 测试
|
||||
// 小程序配置 - 真实AppID
|
||||
appId: DEFAULT_APP_ID,
|
||||
|
||||
@@ -124,6 +124,10 @@
|
||||
<image class="btn-icon" src="/assets/icons/share.svg" mode="aspectFit"/>
|
||||
<text>发送给好友</text>
|
||||
</button>
|
||||
<!-- 已退款:不可再分享/领取 -->
|
||||
<view wx:elif="{{detail.status === 'refunded' || detail.action === 'refunded'}}" class="footer-btn footer-btn-disabled">
|
||||
<text>已退款</text>
|
||||
</view>
|
||||
<!-- 好友 action=redeem:领取并阅读 -->
|
||||
<button wx:elif="{{!isInitiator && detail.action === 'redeem'}}" class="footer-btn redeem-btn" bindtap="doRedeem" disabled="{{redeeming}}">
|
||||
<image class="btn-icon" src="/assets/icons/arrow-right.svg" mode="aspectFit"/>
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<view class="card-row card-meta">
|
||||
<text class="quantity" wx:if="{{item.quantity > 1}}">{{item.quantity}}份</text>
|
||||
<text class="redeemed" wx:if="{{item.status === 'paid'}}">已领 {{item.redeemedCount || 0}}/{{item.quantity || 1}}</text>
|
||||
<text class="status {{item.status}}">{{item.status === 'pending' || item.status === 'pending_pay' ? '待支付' : item.status === 'paid' ? '已支付' : item.status === 'cancelled' ? '已取消' : '已过期'}}</text>
|
||||
<text class="status {{item.status}}">{{item.status === 'pending' || item.status === 'pending_pay' ? '待支付' : item.status === 'paid' ? '已支付' : item.status === 'refunded' ? '已退款' : item.status === 'cancelled' ? '已取消' : '已过期'}}</text>
|
||||
<view class="actions" wx:if="{{item.status === 'pending' || item.status === 'pending_pay'}}">
|
||||
<text class="action-text cancel" catchtap="cancelRequest" data-sn="{{item.requestSn}}">取消</text>
|
||||
</view>
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
<text>去阅读</text>
|
||||
</view>
|
||||
<view class="btn-link" bindtap="goToDetail">
|
||||
<text>{{detail.status === 'paid' ? '去分享' : detail.status === 'pending_pay' ? '去支付' : '查看详情'}}</text>
|
||||
<text>{{detail.status === 'paid' ? '去分享' : detail.status === 'pending_pay' ? '去支付' : detail.status === 'refunded' ? '已退款' : '查看详情'}}</text>
|
||||
</view>
|
||||
</view>
|
||||
</section>
|
||||
|
||||
@@ -35,10 +35,6 @@
|
||||
<view class="profile-name-row">
|
||||
<text class="user-name" bindtap="editNickname">{{userInfo.nickname || '点击设置昵称'}}</text>
|
||||
<view class="profile-name-actions">
|
||||
<view class="profile-edit-btn" bindtap="goToProfileShow">
|
||||
<image class="profile-edit-icon" src="/assets/icons/edit-gray.svg" mode="aspectFit"/>
|
||||
<text class="profile-edit-text">编辑</text>
|
||||
</view>
|
||||
<view class="become-member-btn {{isVip ? 'become-member-vip' : ''}}" wx:if="{{!auditMode}}" bindtap="goToVip">{{isVip ? '会员中心' : '成为会员'}}</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -67,7 +67,12 @@ Page({
|
||||
// 弹窗
|
||||
showShareModal: false,
|
||||
showGiftModal: false,
|
||||
giftQuantity: 1,
|
||||
giftQuantity: 6,
|
||||
giftUnitPrice: 0,
|
||||
giftTotalPrice: '0.00',
|
||||
giftPaying: false,
|
||||
giftPaid: false,
|
||||
giftRequestSn: '',
|
||||
showLoginModal: false,
|
||||
agreeProtocol: false,
|
||||
showPosterModal: false,
|
||||
@@ -82,6 +87,9 @@ Page({
|
||||
|
||||
// 审核模式:隐藏购买按钮
|
||||
auditMode: false,
|
||||
|
||||
// 好友从代付分享进入:待自动领取的 requestSn
|
||||
pendingGiftRequestSn: '',
|
||||
},
|
||||
|
||||
onShow() {
|
||||
@@ -111,13 +119,11 @@ Page({
|
||||
// 支持 scene(扫码)、mid、id、ref、gift(代付)
|
||||
const sceneStr = (options && options.scene) || ''
|
||||
const parsed = parseScene(sceneStr)
|
||||
const ref = options.ref || parsed.ref
|
||||
const isGift = options.gift === '1' || options.gift === 'true'
|
||||
// 代付统一到代付页:gift=1&ref=requestSn 时直接跳转,禁止在阅读页代付
|
||||
if (isGift && ref) {
|
||||
wx.redirectTo({ url: `/pages/gift-pay/detail?requestSn=${encodeURIComponent(ref)}` })
|
||||
return
|
||||
}
|
||||
// 代付:分享链路使用 requestSn(优先 options.requestSn;兼容旧链路 gift=1&ref=requestSn)
|
||||
const giftRequestSn = (options.requestSn || (isGift ? (options.ref || parsed.ref) : '') || '').trim()
|
||||
// 推荐码:仅在非代付链路使用 ref
|
||||
const ref = (!isGift ? (options.ref || parsed.ref) : '') || ''
|
||||
const mid = options.mid ? parseInt(options.mid, 10) : (parsed.mid || app.globalData.initialSectionMid || 0)
|
||||
let id = options.id || parsed.id || app.globalData.initialSectionId
|
||||
if (app.globalData.initialSectionMid) delete app.globalData.initialSectionMid
|
||||
@@ -149,7 +155,8 @@ Page({
|
||||
sectionId: id,
|
||||
sectionMid: mid || null,
|
||||
loading: true,
|
||||
accessState: 'unknown'
|
||||
accessState: 'unknown',
|
||||
pendingGiftRequestSn: giftRequestSn || ''
|
||||
})
|
||||
|
||||
if (ref) {
|
||||
@@ -167,8 +174,8 @@ Page({
|
||||
|
||||
// 统一:先拉章节数据,用 isFree/price===0 判断免费
|
||||
const chapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true })
|
||||
const accessState = await accessManager.determineAccessState(id, chapterRes)
|
||||
const canAccess = accessManager.canAccessFullContent(accessState)
|
||||
let accessState = await accessManager.determineAccessState(id, chapterRes)
|
||||
let canAccess = accessManager.canAccessFullContent(accessState)
|
||||
|
||||
this.setData({
|
||||
accessState,
|
||||
@@ -179,6 +186,23 @@ Page({
|
||||
|
||||
// 加载内容(复用已拉取的章节数据,避免二次请求)
|
||||
await this.loadContent(id, accessState, chapterRes)
|
||||
|
||||
// 代付自动领取:好友打开阅读页时自动领取并解锁
|
||||
if (this.data.pendingGiftRequestSn) {
|
||||
const redeemed = await this._tryAutoRedeemGift(this.data.pendingGiftRequestSn)
|
||||
if (redeemed) {
|
||||
// 领取成功后刷新章节与权限(保守:重新拉章节数据 + 重新判断权限)
|
||||
await accessManager.refreshUserPurchaseStatus()
|
||||
const freshChapterRes = await app.request({ url: this._getChapterUrl({ id, mid }), silent: true })
|
||||
accessState = await accessManager.determineAccessState(id, freshChapterRes)
|
||||
canAccess = accessManager.canAccessFullContent(accessState)
|
||||
this.setData({ accessState, canAccess, showPaywall: !canAccess, pendingGiftRequestSn: '' })
|
||||
if (canAccess) {
|
||||
await this.loadContent(id, accessState, freshChapterRes)
|
||||
readingTracker.init(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 【标准流程】4. 如果有权限,初始化阅读追踪
|
||||
if (canAccess) {
|
||||
@@ -196,6 +220,58 @@ Page({
|
||||
this.setData({ loading: false })
|
||||
}
|
||||
},
|
||||
|
||||
_getGiftUnitPrice() {
|
||||
const p = this.data.section?.price
|
||||
const cfg = this.data.sectionPrice
|
||||
const v = (p != null && p !== '') ? Number(p) : Number(cfg || 0)
|
||||
return isNaN(v) ? 0 : v
|
||||
},
|
||||
|
||||
_updateGiftTotalPrice() {
|
||||
const unit = this.data.giftUnitPrice || this._getGiftUnitPrice()
|
||||
const q = parseInt(this.data.giftQuantity, 10) || 0
|
||||
const total = unit * q
|
||||
this.setData({
|
||||
giftUnitPrice: unit,
|
||||
giftTotalPrice: (isNaN(total) ? 0 : total).toFixed(2)
|
||||
})
|
||||
},
|
||||
|
||||
async _tryAutoRedeemGift(requestSn) {
|
||||
// 单页模式(朋友圈)不做自动领取,避免隐式登录/支付能力限制
|
||||
try {
|
||||
const sys = wx.getSystemInfoSync()
|
||||
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
|
||||
if (isSinglePage) return false
|
||||
} catch (e) {}
|
||||
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) {
|
||||
// 记住 requestSn,登录后自动领取
|
||||
this.setData({ pendingGiftRequestSn: requestSn })
|
||||
wx.showToast({ title: '登录后将自动领取并解锁', icon: 'none', duration: 2500 })
|
||||
this.showLoginModal()
|
||||
return false
|
||||
}
|
||||
try {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/gift-pay/redeem',
|
||||
method: 'POST',
|
||||
data: { requestSn, userId }
|
||||
})
|
||||
if (res && res.success) return true
|
||||
// 已领取/已无名额等都视为无需再重试
|
||||
if (res && (res.error || res.message)) {
|
||||
wx.showToast({ title: res.error || res.message || '领取失败', icon: 'none' })
|
||||
}
|
||||
this.setData({ pendingGiftRequestSn: '' })
|
||||
return false
|
||||
} catch (e) {
|
||||
console.warn('[Read][Gift] 自动领取失败:', e)
|
||||
return false
|
||||
}
|
||||
},
|
||||
|
||||
// 从后端加载免费章节配置
|
||||
onPageScroll(e) {
|
||||
@@ -652,7 +728,123 @@ Page({
|
||||
wx.showToast({ title: '章节信息异常', icon: 'none' })
|
||||
return
|
||||
}
|
||||
wx.navigateTo({ url: `/pages/gift-pay/detail?sectionId=${encodeURIComponent(sectionId)}` })
|
||||
this.setData({
|
||||
showGiftModal: true,
|
||||
giftPaid: false,
|
||||
giftRequestSn: '',
|
||||
giftPaying: false,
|
||||
giftQuantity: 6
|
||||
})
|
||||
this._updateGiftTotalPrice()
|
||||
},
|
||||
|
||||
closeGiftModal() {
|
||||
this.setData({ showGiftModal: false })
|
||||
},
|
||||
|
||||
selectGiftQuantity(e) {
|
||||
const q = parseInt(e.currentTarget.dataset.q, 10)
|
||||
if (!q || q < 1) return
|
||||
this.setData({ giftQuantity: q })
|
||||
this._updateGiftTotalPrice()
|
||||
},
|
||||
|
||||
async confirmGiftPay() {
|
||||
if (this.data.giftPaying) return
|
||||
// 朋友圈单页模式禁止支付
|
||||
try {
|
||||
const sys = wx.getSystemInfoSync()
|
||||
const isSinglePage = (sys && sys.mode === 'singlePage') || app.globalData.isSinglePageMode
|
||||
if (isSinglePage) {
|
||||
wx.showModal({
|
||||
title: '朋友圈单页',
|
||||
content: '当前为朋友圈单页,无法发起代付支付。请点击底部「前往小程序」进入完整版后再操作。',
|
||||
showCancel: false
|
||||
})
|
||||
return
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
const userId = app.globalData.userInfo?.id
|
||||
if (!userId) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const sectionId = this.data.sectionId
|
||||
const quantity = parseInt(this.data.giftQuantity, 10)
|
||||
if (!sectionId || !quantity) {
|
||||
wx.showToast({ title: '参数异常', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
let openId = app.globalData.openId || wx.getStorageSync('openId')
|
||||
if (!openId) {
|
||||
wx.showLoading({ title: '获取支付凭证...', mask: true })
|
||||
openId = await app.getOpenId()
|
||||
wx.hideLoading()
|
||||
}
|
||||
if (!openId) {
|
||||
wx.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({ giftPaying: true })
|
||||
wx.showLoading({ title: '创建订单中...', mask: true })
|
||||
try {
|
||||
// 1) 创建代付请求
|
||||
const createRes = await app.request({
|
||||
url: '/api/miniprogram/gift-pay/create',
|
||||
method: 'POST',
|
||||
data: { userId, productType: 'section', productId: sectionId, quantity }
|
||||
})
|
||||
if (!createRes?.success || !createRes.requestSn) {
|
||||
throw new Error(createRes?.error || '创建失败')
|
||||
}
|
||||
const requestSn = createRes.requestSn
|
||||
|
||||
// 2) 发起人支付(微信支付)
|
||||
const payRes = await app.request({
|
||||
url: '/api/miniprogram/gift-pay/initiator-pay',
|
||||
method: 'POST',
|
||||
data: { requestSn, openId, userId }
|
||||
})
|
||||
wx.hideLoading()
|
||||
if (!payRes || !payRes.success || !payRes.data?.payParams) {
|
||||
throw new Error(payRes?.error || '创建订单失败')
|
||||
}
|
||||
const payParams = payRes.data.payParams
|
||||
const orderSn = payRes.data.orderSn
|
||||
await new Promise((resolve, reject) => {
|
||||
wx.requestPayment({
|
||||
timeStamp: payParams.timeStamp,
|
||||
nonceStr: payParams.nonceStr,
|
||||
package: payParams.package,
|
||||
signType: payParams.signType || 'RSA',
|
||||
paySign: payParams.paySign,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
|
||||
// 3) 主动同步(与其他支付流程一致)
|
||||
if (orderSn) {
|
||||
try {
|
||||
await app.request(`/api/miniprogram/pay?orderSn=${encodeURIComponent(orderSn)}`, { silent: true })
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
wx.showToast({ title: '支付成功', icon: 'success' })
|
||||
this.setData({ giftPaid: true, giftRequestSn: requestSn, giftPaying: false })
|
||||
} catch (e) {
|
||||
wx.hideLoading()
|
||||
const msg = e?.message || e?.error || e?.errMsg || '支付失败'
|
||||
if (e?.errMsg && String(e.errMsg).includes('cancel')) {
|
||||
wx.showToast({ title: '已取消支付', icon: 'none' })
|
||||
} else {
|
||||
wx.showToast({ title: msg, icon: 'none', duration: 2500 })
|
||||
}
|
||||
this.setData({ giftPaying: false })
|
||||
}
|
||||
},
|
||||
|
||||
// 复制链接
|
||||
@@ -691,11 +883,22 @@ Page({
|
||||
},
|
||||
|
||||
// 分享到微信 - 自动带分享人ID
|
||||
onShareAppMessage() {
|
||||
onShareAppMessage(e) {
|
||||
trackClick('read', 'btn_click', '分享_' + this.data.sectionId)
|
||||
const { section, sectionId, sectionMid } = this.data
|
||||
const ref = app.getMyReferralCode()
|
||||
const q = sectionMid ? `mid=${sectionMid}` : `id=${sectionId}`
|
||||
// 代付分享按钮(支付后):好友打开阅读页自动领取解锁
|
||||
const isGiftShare = e?.from === 'button' && e?.target?.dataset?.gift === '1'
|
||||
const requestSn = (e?.target?.dataset?.requestSn || '').trim()
|
||||
if (isGiftShare && requestSn) {
|
||||
let path = `/pages/read/read?${q}&gift=1&requestSn=${encodeURIComponent(requestSn)}`
|
||||
if (ref) path += `&ref=${encodeURIComponent(ref)}`
|
||||
const t = section?.title || 'Soul创业派对'
|
||||
const title = `我已为你买单:${t.length > 18 ? t.slice(0, 18) + '...' : t}`
|
||||
return { title, path }
|
||||
}
|
||||
|
||||
const path = ref ? `/pages/read/read?${q}&ref=${ref}` : `/pages/read/read?${q}`
|
||||
const title = section?.title
|
||||
? `📚 ${section.title.length > 20 ? section.title.slice(0, 20) + '...' : section.title}`
|
||||
@@ -840,6 +1043,25 @@ Page({
|
||||
wx.showLoading({ title: '更新状态中...', mask: true })
|
||||
|
||||
try {
|
||||
// 0. 若有代付待领取,先领取再刷新购买状态
|
||||
if (this.data.pendingGiftRequestSn) {
|
||||
try {
|
||||
const userId = app.globalData.userInfo?.id
|
||||
const requestSn = this.data.pendingGiftRequestSn
|
||||
if (userId && requestSn) {
|
||||
const res = await app.request({
|
||||
url: '/api/miniprogram/gift-pay/redeem',
|
||||
method: 'POST',
|
||||
data: { requestSn, userId }
|
||||
})
|
||||
if (res && res.success) {
|
||||
this.setData({ pendingGiftRequestSn: '' })
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[Read][Gift] 登录后自动领取失败:', e)
|
||||
}
|
||||
}
|
||||
// 1. 刷新用户购买状态(从 orders 表拉取最新)
|
||||
await accessManager.refreshUserPurchaseStatus()
|
||||
|
||||
|
||||
@@ -288,6 +288,51 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 代付分享弹窗:阅读页内发起代付并支付 -->
|
||||
<view class="modal-overlay modal-overlay-center" wx:if="{{showGiftModal}}" bindtap="closeGiftModal">
|
||||
<view class="modal-content modal-content-center gift-modal-v2" catchtap="stopPropagation">
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{giftPaid ? '代付已完成' : '生成代付链接'}}</text>
|
||||
<view class="modal-close" bindtap="closeGiftModal"><icon name="x" size="36" color="#8e8e93"></icon></view>
|
||||
</view>
|
||||
|
||||
<view class="gift-article-card">
|
||||
<text class="gift-article-title">{{section.title || '代付商品'}}</text>
|
||||
<text class="gift-article-desc" wx:if="{{section.desc}}">{{section.desc}}</text>
|
||||
</view>
|
||||
|
||||
<view wx:if="{{!giftPaid}}">
|
||||
<text class="gift-label">选择代付名额数</text>
|
||||
<view class="gift-spots-grid">
|
||||
<view class="gift-spot-btn {{giftQuantity===6?'gift-spot-active':''}}" bindtap="selectGiftQuantity" data-q="6">6</view>
|
||||
<view class="gift-spot-btn {{giftQuantity===30?'gift-spot-active':''}}" bindtap="selectGiftQuantity" data-q="30">30</view>
|
||||
<view class="gift-spot-btn {{giftQuantity===100?'gift-spot-active':''}}" bindtap="selectGiftQuantity" data-q="100">100</view>
|
||||
<view class="gift-spot-btn {{giftQuantity===1000?'gift-spot-active':''}}" bindtap="selectGiftQuantity" data-q="1000">1000</view>
|
||||
</view>
|
||||
|
||||
<view class="gift-price-box">
|
||||
<text class="gift-price-label">待支付总价格</text>
|
||||
<view class="gift-price-row">
|
||||
<text class="gift-price-formula">¥{{giftUnitPrice}} × {{giftQuantity}} =</text>
|
||||
<text class="gift-price-total">¥{{giftTotalPrice}}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button class="gift-pay-btn" bindtap="confirmGiftPay" disabled="{{giftPaying}}">
|
||||
{{giftPaying ? '支付中...' : '确认并支付'}}
|
||||
</button>
|
||||
<view class="gift-cancel-text" bindtap="closeGiftModal">取消</view>
|
||||
</view>
|
||||
|
||||
<view wx:else class="gift-paid-wrap">
|
||||
<text class="gift-paid-tip">支付成功,点击下方按钮直接分享给好友。好友打开阅读页将自动领取并解锁。</text>
|
||||
<button class="gift-share-btn" open-type="share" data-gift="1" data-request-sn="{{giftRequestSn}}">
|
||||
发送给好友
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 登录弹窗 - 须勾选同意协议,《用户协议》《隐私政策》可点击查看 -->
|
||||
<view class="modal-overlay" wx:if="{{showLoginModal}}" bindtap="closeLoginModal">
|
||||
<view class="modal-content login-modal" catchtap="stopPropagation">
|
||||
|
||||
@@ -600,6 +600,10 @@
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-overlay-center {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100%;
|
||||
max-width: 750rpx;
|
||||
@@ -610,11 +614,25 @@
|
||||
animation: slideUp 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-content-center {
|
||||
width: 640rpx;
|
||||
max-width: calc(100vw - 80rpx);
|
||||
border-radius: 32rpx;
|
||||
padding: 40rpx;
|
||||
padding-bottom: 40rpx;
|
||||
animation: popIn 0.18s ease-out;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(100%); }
|
||||
to { transform: translateY(0); }
|
||||
}
|
||||
|
||||
@keyframes popIn {
|
||||
from { transform: scale(0.94); opacity: 0.6; }
|
||||
to { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -706,6 +724,117 @@
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 阅读页内代付弹窗(v2:档位按钮 + 价格计算 + 支付后分享) */
|
||||
.gift-modal-v2 {
|
||||
padding: 40rpx;
|
||||
}
|
||||
.gift-article-card {
|
||||
padding: 28rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 24rpx;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
.gift-article-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
display: block;
|
||||
line-height: 1.3;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.gift-article-desc {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
display: block;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.gift-spots-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 18rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
.gift-spot-btn {
|
||||
text-align: center;
|
||||
padding: 22rpx 0;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
font-weight: 600;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
.gift-spot-active {
|
||||
border-color: rgba(0, 206, 209, 0.9);
|
||||
color: #00CED1;
|
||||
background: rgba(0, 206, 209, 0.12);
|
||||
}
|
||||
.gift-price-box {
|
||||
padding: 26rpx;
|
||||
border-radius: 24rpx;
|
||||
background: rgba(0, 0, 0, 0.25);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.08);
|
||||
margin-bottom: 28rpx;
|
||||
text-align: center;
|
||||
}
|
||||
.gift-price-label {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
display: block;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
.gift-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 14rpx;
|
||||
}
|
||||
.gift-price-formula {
|
||||
font-size: 24rpx;
|
||||
color: rgba(0, 206, 209, 0.8);
|
||||
}
|
||||
.gift-price-total {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
color: #00CED1;
|
||||
}
|
||||
.gift-pay-btn {
|
||||
width: 100%;
|
||||
border-radius: 999rpx;
|
||||
background: #00CED1;
|
||||
color: #000;
|
||||
font-weight: 800;
|
||||
font-size: 32rpx;
|
||||
padding: 26rpx 0;
|
||||
}
|
||||
.gift-cancel-text {
|
||||
text-align: center;
|
||||
margin-top: 18rpx;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
.gift-paid-wrap {
|
||||
padding-top: 8rpx;
|
||||
}
|
||||
.gift-paid-tip {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
.gift-share-btn {
|
||||
width: 100%;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(0, 206, 209, 0.12);
|
||||
border: 1rpx solid rgba(0, 206, 209, 0.45);
|
||||
color: #00CED1;
|
||||
font-weight: 800;
|
||||
font-size: 32rpx;
|
||||
padding: 26rpx 0;
|
||||
}
|
||||
|
||||
.share-modal-desc {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
@@ -85,6 +85,20 @@ Page({
|
||||
return
|
||||
}
|
||||
}
|
||||
// 支付前:若头像/昵称仍为默认值,引导先完善(仅头像+昵称)
|
||||
if (this._shouldGuideAvatarNickname()) {
|
||||
wx.showModal({
|
||||
title: '完善资料',
|
||||
content: '开通超级个体前,请先设置头像和昵称,让他人更好地认识你',
|
||||
confirmText: '去完善',
|
||||
cancelText: '稍后',
|
||||
success: (res) => {
|
||||
if (res.confirm) wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
|
||||
}
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
this.setData({ purchasing: true })
|
||||
const amount = this.data.price
|
||||
try {
|
||||
@@ -158,12 +172,26 @@ Page({
|
||||
if (typeof p.initUserStatus === 'function') p.initUserStatus()
|
||||
else if (typeof p.updateUserStatus === 'function') p.updateUserStatus()
|
||||
})
|
||||
|
||||
// 开通成功后兜底:仍为默认头像/昵称则引导完善
|
||||
if (this._shouldGuideAvatarNickname()) {
|
||||
wx.navigateTo({ url: '/pages/avatar-nickname/avatar-nickname' })
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[VIP] 支付后同步失败:', e)
|
||||
}
|
||||
wx.hideLoading()
|
||||
},
|
||||
|
||||
_shouldGuideAvatarNickname() {
|
||||
const user = app.globalData.userInfo || {}
|
||||
const avatar = (user.avatar || user.avatarUrl || '').trim()
|
||||
const nickname = (user.nickname || user.nickName || '').trim()
|
||||
// 与 ruleEngine.checkRule_FillAvatar 保持同口径(允许前端兜底)
|
||||
if (avatar && !avatar.includes('default') && nickname && nickname !== '微信用户' && !nickname.startsWith('微信用户')) return false
|
||||
return true
|
||||
},
|
||||
|
||||
goBack() { getApp().goBackOrToHome() },
|
||||
|
||||
onShareAppMessage() {
|
||||
|
||||
238
open-api-sign.md
238
open-api-sign.md
@@ -1,238 +0,0 @@
|
||||
# 存客宝开放 API — 鉴权规范(V1)
|
||||
|
||||
> 适用接口:`/v1/open/*`
|
||||
|
||||
---
|
||||
|
||||
## 〇、存客宝 API 区分说明
|
||||
|
||||
存客宝存在**两套 API**,互不通用:
|
||||
|
||||
| 类型 | 路径 | 鉴权方式 | 使用场景 |
|
||||
|------|------|----------|----------|
|
||||
| **内部 API** | `/v1/api/scenarios` | apiKey + timestamp + sign(query 传参),**无需 JWT** | 小程序首页「链接卡若」、文章 @ 人物留资等 |
|
||||
| **开放 API** | `/v1/open/*` | 先 `POST /v1/open/auth/token` 获取 JWT,再 `Authorization: Bearer <token>` | 本文档规范,需签名与加密 |
|
||||
|
||||
当前 Soul 项目「链接卡若」使用的是**内部 API**,与本文档的开放 API 不同。
|
||||
|
||||
---
|
||||
|
||||
## 一、整体流程
|
||||
|
||||
```
|
||||
第一步 POST /v1/open/auth/token
|
||||
携带:apiKey + account + timestamp + sign
|
||||
服务端验签后返回 JWT Token(有效期 2 小时)
|
||||
│
|
||||
▼
|
||||
第二步 POST /v1/open/scenarios (或其他业务接口)
|
||||
Header: Authorization: Bearer <token>
|
||||
无需再传 apiKey / sign,与存客宝内部接口完全兼容
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、API Key 说明
|
||||
|
||||
| 属性 | 描述 |
|
||||
|--------------|----------------------------------------------------------------|
|
||||
| **颁发对象** | 每个存客宝账号(`ck_users`)一把专属 API Key |
|
||||
| **格式** | `5 组 × 5 位`,大小写字母 + 数字,组间以 `-` 分隔 |
|
||||
| **示例** | `aB3k9-Z8c1Q-0f4Xk-M9n2P-1A2b3` |
|
||||
| **获取方式** | 门店端 → 用户中心 → 对外接口 → 查看/生成 API Key |
|
||||
| **有效期** | 永久有效,可随时点击"重新生成"覆盖旧 Key(旧 Key 立即失效) |
|
||||
|
||||
---
|
||||
|
||||
## 三、第一步:获取 JWT Token
|
||||
|
||||
### 接口
|
||||
|
||||
```
|
||||
POST /v1/open/auth/token
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
### 请求参数
|
||||
|
||||
| 字段 | 类型 | 必填 | 说明 |
|
||||
|-------------|--------|------|---------------------------------------------------|
|
||||
| `apiKey` | string | 是 | 账号专属 API Key |
|
||||
| `account` | string | 是 | 登录账号(`ck_users.account`) |
|
||||
| `timestamp` | int | 是 | 当前秒级 Unix 时间戳(与服务器时差不超过 5 分钟)|
|
||||
| `sign` | string | 是 | 签名值,生成方式见下方 |
|
||||
|
||||
### 签名算法
|
||||
|
||||
只有三个固定字段参与签名,业务参数不参与:
|
||||
|
||||
```
|
||||
stringToSign = account值 + timestamp值 ← 字段名 ASCII 升序,直接拼接值
|
||||
firstMd5 = MD5(stringToSign)
|
||||
sign = MD5(firstMd5 + apiKey)
|
||||
```
|
||||
|
||||
> `account` < `timestamp`(ASCII 升序),所以拼接顺序固定为:`account值 + timestamp值`
|
||||
|
||||
### 成功响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "success",
|
||||
"data": {
|
||||
"token": "eyJ...",
|
||||
"expires_in": 7200
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 常见错误
|
||||
|
||||
| code | message | 原因 |
|
||||
|------|-----------------|----------------------------------------|
|
||||
| 400 | apiKey不能为空 | 未传 `apiKey` |
|
||||
| 400 | account不能为空 | 未传 `account` |
|
||||
| 400 | sign不能为空 | 未传 `sign` |
|
||||
| 400 | timestamp不能为空 | 未传 `timestamp` |
|
||||
| 400 | 请求已过期 | `timestamp` 与服务器时差超 5 分钟 |
|
||||
| 401 | 无效的apiKey | Key 不存在、账号不匹配或账号已禁用 |
|
||||
| 401 | 签名验证失败 | account / timestamp / apiKey 值有误 |
|
||||
|
||||
---
|
||||
|
||||
## 四、第二步:调用业务接口
|
||||
|
||||
拿到 Token 后,所有 `/v1/open/*` 接口在 HTTP Header 中携带:
|
||||
|
||||
```
|
||||
Authorization: Bearer <token>
|
||||
```
|
||||
|
||||
不再需要 `apiKey`、`sign`、`timestamp` 参数。
|
||||
|
||||
Token 过期(2 小时)后重新请求 `/v1/open/auth/token` 换新 Token。
|
||||
|
||||
---
|
||||
|
||||
## 五、示例代码
|
||||
|
||||
### PHP
|
||||
|
||||
```php
|
||||
$apiKey = 'aB3k9-Z8c1Q-0f4Xk-M9n2P-1A2b3';
|
||||
$account = 'user001';
|
||||
$timestamp = (string)time();
|
||||
|
||||
// 生成签名
|
||||
$stringToSign = $account . $timestamp; // account < timestamp(ASCII)
|
||||
$firstMd5 = md5($stringToSign);
|
||||
$sign = md5($firstMd5 . $apiKey);
|
||||
|
||||
// 获取 Token
|
||||
$response = file_get_contents('https://ckbapi.quwanzhi.com/v1/open/auth/token', false,
|
||||
stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => 'Content-Type: application/json',
|
||||
'content' => json_encode(compact('apiKey', 'account', 'timestamp', 'sign')),
|
||||
]])
|
||||
);
|
||||
$token = json_decode($response, true)['data']['token'];
|
||||
|
||||
// 调用业务接口
|
||||
$response2 = file_get_contents('https://ckbapi.quwanzhi.com/v1/open/scenarios', false,
|
||||
stream_context_create(['http' => [
|
||||
'method' => 'POST',
|
||||
'header' => "Authorization: Bearer {$token}\r\nContent-Type: application/json",
|
||||
'content' => json_encode([
|
||||
'planId' => 42,
|
||||
'phone' => '13800000000',
|
||||
'name' => '张三',
|
||||
'source' => '百度推广',
|
||||
]),
|
||||
]])
|
||||
);
|
||||
```
|
||||
|
||||
### JavaScript / Node.js
|
||||
|
||||
```javascript
|
||||
const crypto = require('crypto');
|
||||
|
||||
const apiKey = 'aB3k9-Z8c1Q-0f4Xk-M9n2P-1A2b3';
|
||||
const account = 'user001';
|
||||
const timestamp = String(Math.floor(Date.now() / 1000));
|
||||
|
||||
// 签名
|
||||
const firstMd5 = crypto.createHash('md5').update(account + timestamp, 'utf8').digest('hex');
|
||||
const sign = crypto.createHash('md5').update(firstMd5 + apiKey, 'utf8').digest('hex');
|
||||
|
||||
// 获取 Token
|
||||
const res = await fetch('https://ckbapi.quwanzhi.com/v1/open/auth/token', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ apiKey, account, timestamp, sign }),
|
||||
});
|
||||
const { token } = (await res.json()).data;
|
||||
|
||||
// 调用业务接口
|
||||
const res2 = await fetch('https://ckbapi.quwanzhi.com/v1/open/scenarios', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
|
||||
body: JSON.stringify({ planId: 42, phone: '13800000000', name: '张三' }),
|
||||
});
|
||||
```
|
||||
|
||||
### Python
|
||||
|
||||
```python
|
||||
import hashlib, time, requests
|
||||
|
||||
api_key = 'aB3k9-Z8c1Q-0f4Xk-M9n2P-1A2b3'
|
||||
account = 'user001'
|
||||
timestamp = str(int(time.time()))
|
||||
|
||||
# 签名
|
||||
first_md5 = hashlib.md5((account + timestamp).encode()).hexdigest()
|
||||
sign = hashlib.md5((first_md5 + api_key).encode()).hexdigest()
|
||||
|
||||
# 获取 Token
|
||||
r = requests.post('https://ckbapi.quwanzhi.com/v1/open/auth/token',
|
||||
json={'apiKey': api_key, 'account': account,
|
||||
'timestamp': timestamp, 'sign': sign})
|
||||
token = r.json()['data']['token']
|
||||
|
||||
# 调用业务接口
|
||||
r2 = requests.post('https://ckbapi.quwanzhi.com/v1/open/scenarios',
|
||||
headers={'Authorization': f'Bearer {token}'},
|
||||
json={'planId': 42, 'phone': '13800000000', 'name': '张三'})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、签名自测用例
|
||||
|
||||
| 字段 | 值 |
|
||||
|-----------|------------------------------------|
|
||||
| apiKey | `TestKey-12345-ABCDE-67890-xYzWv` |
|
||||
| account | `user001` |
|
||||
| timestamp | `1710000000` |
|
||||
|
||||
计算过程:
|
||||
|
||||
```
|
||||
stringToSign = "user001" + "1710000000" = "user0011710000000"
|
||||
firstMd5 = MD5("user0011710000000")
|
||||
sign = MD5(firstMd5 + "TestKey-12345-ABCDE-67890-xYzWv")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、项目配置(当前密钥)
|
||||
|
||||
> ⚠️ **安全提醒**:本文件含真实密钥,请勿推送到公共仓库。建议将 `open-api-sign.md` 加入 `.gitignore`,或改用环境变量 `CKB_API_KEY` 存储。
|
||||
|
||||
| 字段 | 值 |
|
||||
|--------|--------------------------------------|
|
||||
| apiKey | `mI9Ol-NO6cS-ho3Py-7Pj22-WyK3A` |
|
||||
| account| 需与存客宝账号(`ck_users.account`)对应 |
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
4
soul-admin/dist/index.html
vendored
4
soul-admin/dist/index.html
vendored
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>管理后台 - Soul创业派对</title>
|
||||
<script type="module" crossorigin src="/assets/index-DyqIjjBz.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-o3d5k2lQ.css">
|
||||
<script type="module" crossorigin src="/assets/index-jQTzpVXX.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-BgHLp2oy.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -1285,6 +1285,7 @@ export function DistributionPage() {
|
||||
<option value="pending">待支付(旧)</option>
|
||||
<option value="pending_pay">待发起人支付</option>
|
||||
<option value="paid">已支付</option>
|
||||
<option value="refunded">已退款</option>
|
||||
<option value="cancelled">已取消</option>
|
||||
<option value="expired">已过期</option>
|
||||
</select>
|
||||
@@ -1331,10 +1332,20 @@ export function DistributionPage() {
|
||||
? 'bg-green-500/20 text-green-400 border-0'
|
||||
: r.status === 'pending' || r.status === 'pending_pay'
|
||||
? 'bg-amber-500/20 text-amber-400 border-0'
|
||||
: r.status === 'refunded'
|
||||
? 'bg-red-500/20 text-red-400 border-0'
|
||||
: 'bg-gray-500/20 text-gray-400 border-0'
|
||||
}
|
||||
>
|
||||
{r.status === 'paid' ? '已支付' : r.status === 'pending' || r.status === 'pending_pay' ? '待支付' : r.status === 'cancelled' ? '已取消' : '已过期'}
|
||||
{r.status === 'paid'
|
||||
? '已支付'
|
||||
: r.status === 'pending' || r.status === 'pending_pay'
|
||||
? '待支付'
|
||||
: r.status === 'refunded'
|
||||
? '已退款'
|
||||
: r.status === 'cancelled'
|
||||
? '已取消'
|
||||
: '已过期'}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="p-4 text-gray-400 text-sm">
|
||||
|
||||
@@ -44,7 +44,7 @@ func ensurePersonByName(db *gorm.DB, name string) (token string, err error) {
|
||||
if db.Where("name = ?", name).First(&p).Error == nil {
|
||||
return p.Token, nil
|
||||
}
|
||||
created, err := createPersonMinimal(db, clean)
|
||||
created, err := createPersonMinimal(db, clean, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
@@ -577,7 +577,7 @@ func previewContent(content string, percent int) string {
|
||||
limit = total
|
||||
}
|
||||
runes := []rune(content)
|
||||
return string(runes[:limit]) + "\n\n……(购买后阅读完整内容)"
|
||||
return string(runes[:limit]) + "\n\n……"
|
||||
}
|
||||
|
||||
// findChapterAndRespond 按条件查章节并返回统一格式
|
||||
|
||||
@@ -72,7 +72,7 @@ func ckbOpenGetToken() (string, error) {
|
||||
if msg == "" {
|
||||
msg = "存客宝鉴权失败"
|
||||
}
|
||||
return "", fmt.Errorf(msg)
|
||||
return "", fmt.Errorf("%s", msg)
|
||||
}
|
||||
return authResult.Data.Token, nil
|
||||
}
|
||||
@@ -114,7 +114,7 @@ func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int
|
||||
if result.Message == "" {
|
||||
result.Message = "创建计划失败"
|
||||
}
|
||||
return 0, nil, ckbResponse, fmt.Errorf(result.Message)
|
||||
return 0, nil, ckbResponse, fmt.Errorf("%s", result.Message)
|
||||
}
|
||||
// 原始 data 转为 map 供响应展示
|
||||
createData = make(map[string]interface{})
|
||||
@@ -127,6 +127,49 @@ func ckbOpenCreatePlan(token string, payload map[string]interface{}) (planID int
|
||||
return 0, createData, ckbResponse, fmt.Errorf("创建计划返回结果中缺少 planId")
|
||||
}
|
||||
|
||||
// ckbOpenUpdatePlan 调用 PUT /v1/plan/update 更新获客计划(用于停用/启用)
|
||||
// payload 至少包含 planId
|
||||
func ckbOpenUpdatePlan(token string, payload map[string]interface{}) (ckbResponse map[string]interface{}, err error) {
|
||||
raw, _ := json.Marshal(payload)
|
||||
req, err := http.NewRequest(http.MethodPut, ckbOpenBaseURL+"/v1/plan/update", bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("构造更新计划请求失败: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("请求存客宝更新计划失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
var result struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
_ = json.Unmarshal(b, &result)
|
||||
ckbResponse = map[string]interface{}{
|
||||
"code": result.Code,
|
||||
"message": result.Message,
|
||||
"data": nil,
|
||||
}
|
||||
if len(result.Data) > 0 {
|
||||
var dataObj interface{}
|
||||
_ = json.Unmarshal(result.Data, &dataObj)
|
||||
ckbResponse["data"] = dataObj
|
||||
}
|
||||
if result.Code != 200 {
|
||||
msg := result.Message
|
||||
if msg == "" {
|
||||
msg = "更新计划失败"
|
||||
}
|
||||
return ckbResponse, fmt.Errorf("%s", msg)
|
||||
}
|
||||
return ckbResponse, nil
|
||||
}
|
||||
|
||||
// parseApiKeyFromCreateData 从 create 返回的 data 中解析 apiKey(若存客宝直接返回则复用,避免二次请求)
|
||||
func parseApiKeyFromCreateData(data map[string]interface{}) string {
|
||||
for _, key := range []string{"apiKey", "api_key"} {
|
||||
@@ -200,7 +243,7 @@ func ckbOpenGetPlanDetail(token string, planID int64) (string, error) {
|
||||
if result.Message == "" {
|
||||
result.Message = "获取计划详情失败"
|
||||
}
|
||||
return "", fmt.Errorf(result.Message)
|
||||
return "", fmt.Errorf("%s", result.Message)
|
||||
}
|
||||
if result.Data.APIKey == "" {
|
||||
return "", fmt.Errorf("计划详情中缺少 apiKey")
|
||||
@@ -341,7 +384,7 @@ func ckbOpenDeletePlan(token string, planID int64) error {
|
||||
if result.Message == "" {
|
||||
result.Message = "删除计划失败"
|
||||
}
|
||||
return fmt.Errorf(result.Message)
|
||||
return fmt.Errorf("%s", result.Message)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -203,3 +203,86 @@ func CronSyncOrders(c *gin.Context) {
|
||||
func CronUnbindExpired(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": true})
|
||||
}
|
||||
|
||||
// RunSyncVipCkbPlans 扫描已到期 VIP 用户,自动停用其绑定 Person 的存客宝计划
|
||||
// - 最佳努力:停用失败只记日志,不中断整体任务
|
||||
// - 幂等:重复执行不会产生额外副作用(计划已停用则仍然 update)
|
||||
func RunSyncVipCkbPlans(ctx context.Context, limit int) (scanned, disabled int, err error) {
|
||||
if limit < 1 {
|
||||
limit = 200
|
||||
}
|
||||
if limit > 2000 {
|
||||
limit = 2000
|
||||
}
|
||||
db := database.DB()
|
||||
|
||||
// 只处理“有过期日且已过期,并且绑定了 Person(user_id) 且有 planId”的用户
|
||||
// 说明:persons.user_id 为新增字段;历史未绑定的不在本任务处理范围内
|
||||
type row struct {
|
||||
UserID string `gorm:"column:user_id"`
|
||||
PlanID int64 `gorm:"column:ckb_plan_id"`
|
||||
Nickname string `gorm:"column:nickname"`
|
||||
}
|
||||
rows := make([]row, 0)
|
||||
q := `
|
||||
SELECT u.id as user_id, p.ckb_plan_id, COALESCE(u.nickname,'') as nickname
|
||||
FROM users u
|
||||
INNER JOIN persons p ON p.user_id = u.id
|
||||
WHERE u.is_vip = 1
|
||||
AND u.vip_expire_date IS NOT NULL
|
||||
AND u.vip_expire_date <= NOW()
|
||||
AND p.ckb_plan_id > 0
|
||||
ORDER BY u.vip_expire_date ASC
|
||||
LIMIT ?`
|
||||
if err := db.Raw(q, limit).Scan(&rows).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
scanned = len(rows)
|
||||
if scanned == 0 {
|
||||
return scanned, 0, nil
|
||||
}
|
||||
|
||||
openToken, tokErr := ckbOpenGetToken()
|
||||
if tokErr != nil {
|
||||
// 没 token 直接失败,让 cron 重试(避免把用户标记成非 VIP 但计划未停用)
|
||||
return scanned, 0, tokErr
|
||||
}
|
||||
|
||||
for _, r := range rows {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return scanned, disabled, ctx.Err()
|
||||
default:
|
||||
}
|
||||
if r.PlanID <= 0 || r.UserID == "" {
|
||||
continue
|
||||
}
|
||||
if err := setCkbPlanEnabled(openToken, r.PlanID, false); err != nil {
|
||||
syncOrdersLogf("停用存客宝计划失败: userId=%s, planId=%d, nickname=%s, err=%v", r.UserID, r.PlanID, r.Nickname, err)
|
||||
continue
|
||||
}
|
||||
disabled++
|
||||
syncOrdersLogf("已停用存客宝计划: userId=%s, planId=%d, nickname=%s", r.UserID, r.PlanID, r.Nickname)
|
||||
|
||||
// 兜底清理脏标记:到期用户将 is_vip 置为 0(vip_expire_date 保留)
|
||||
_ = db.Model(&model.User{}).Where("id = ?", r.UserID).Update("is_vip", false).Error
|
||||
}
|
||||
return scanned, disabled, nil
|
||||
}
|
||||
|
||||
// CronSyncVipCkbPlans GET/POST /api/cron/sync-vip-ckb-plans
|
||||
// ?limit=200 每次最多处理 N 个到期用户
|
||||
func CronSyncVipCkbPlans(c *gin.Context) {
|
||||
limit := 200
|
||||
if s := strings.TrimSpace(c.Query("limit")); s != "" {
|
||||
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 2000 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
scanned, disabled, err := RunSyncVipCkbPlans(c.Request.Context(), limit)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": err.Error(), "scanned": scanned, "disabled": disabled})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "scanned": scanned, "disabled": disabled, "limit": limit})
|
||||
}
|
||||
|
||||
@@ -226,7 +226,8 @@ func DBPersonSave(c *gin.Context) {
|
||||
}
|
||||
|
||||
// createPersonMinimal 仅按 name 创建 Person(含存客宝计划),供 autolink 复用
|
||||
func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
|
||||
// userID 可为空;用于“绑定用户 → 幂等创建”的场景
|
||||
func createPersonMinimal(db *gorm.DB, name string, userID string) (*model.Person, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return nil, fmt.Errorf("name 必填")
|
||||
@@ -271,6 +272,7 @@ func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
|
||||
}
|
||||
}
|
||||
newPerson := model.Person{
|
||||
UserID: strPtrIfNotEmpty(userID),
|
||||
PersonID: personID,
|
||||
Token: tok,
|
||||
Name: name,
|
||||
@@ -287,6 +289,103 @@ func createPersonMinimal(db *gorm.DB, name string) (*model.Person, error) {
|
||||
return &newPerson, nil
|
||||
}
|
||||
|
||||
func strPtrIfNotEmpty(s string) *string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
// setCkbPlanEnabled 将存客宝计划置为启用/停用(最佳努力)
|
||||
func setCkbPlanEnabled(openToken string, planID int64, enabled bool) error {
|
||||
if planID <= 0 {
|
||||
return fmt.Errorf("planID 无效")
|
||||
}
|
||||
status := 0
|
||||
if enabled {
|
||||
status = 1
|
||||
}
|
||||
payload := map[string]interface{}{
|
||||
"planId": planID,
|
||||
"status": status,
|
||||
"enabled": enabled,
|
||||
"scenario": 9, // 兜底:部分接口可能要求带 scenario,与 create 保持一致
|
||||
}
|
||||
_, err := ckbOpenUpdatePlan(openToken, payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// ensurePersonForUser 确保用户对应的 Person 存在(用于超级个体开通成功后的自动创建)
|
||||
// 幂等规则:
|
||||
// 1) 优先按 persons.user_id 查;存在则必要时同步 name=nickname
|
||||
// 2) 若无 user_id 记录,则按 name=nickname 兜底复用;若复用成功且 user_id 为空则补绑
|
||||
// 3) 都不存在则创建(含 CKB 计划)
|
||||
//
|
||||
// 该逻辑为“最佳努力”,调用方不应因失败而阻断支付/权益激活。
|
||||
func ensurePersonForUser(db *gorm.DB, userID string) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return fmt.Errorf("userID 不能为空")
|
||||
}
|
||||
var user model.User
|
||||
if err := db.Select("id", "nickname").Where("id = ?", userID).First(&user).Error; err != nil {
|
||||
return fmt.Errorf("用户不存在: %w", err)
|
||||
}
|
||||
nickname := ""
|
||||
if user.Nickname != nil {
|
||||
nickname = strings.TrimSpace(*user.Nickname)
|
||||
}
|
||||
if nickname == "" {
|
||||
return fmt.Errorf("用户昵称为空,跳过创建 Person")
|
||||
}
|
||||
if !isValidNameOrLabel(nickname) {
|
||||
return fmt.Errorf("用户昵称不符合 Person.name 规则,跳过创建 Person")
|
||||
}
|
||||
|
||||
// 获取 CKB open token(仅在需要启用计划时使用;失败不阻断)
|
||||
openToken, _ := ckbOpenGetToken()
|
||||
|
||||
// 1) 按 user_id 查
|
||||
var p model.Person
|
||||
if err := db.Where("user_id = ?", userID).First(&p).Error; err == nil {
|
||||
// 同步展示名(跟随昵称)
|
||||
if strings.TrimSpace(p.Name) != nickname {
|
||||
db.Model(&p).Updates(map[string]interface{}{"name": nickname, "updated_at": time.Now()})
|
||||
}
|
||||
// 续费/恢复:若已有计划则尝试重新启用(最佳努力)
|
||||
if openToken != "" && p.CkbPlanID > 0 {
|
||||
_ = setCkbPlanEnabled(openToken, p.CkbPlanID, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 2) 按 name 兜底复用
|
||||
var byName model.Person
|
||||
if err := db.Where("name = ?", nickname).First(&byName).Error; err == nil {
|
||||
// 若未绑定 user_id,补绑;并确保 name 为昵称
|
||||
updates := map[string]interface{}{}
|
||||
if byName.UserID == nil || strings.TrimSpace(*byName.UserID) == "" {
|
||||
updates["user_id"] = userID
|
||||
}
|
||||
if strings.TrimSpace(byName.Name) != nickname {
|
||||
updates["name"] = nickname
|
||||
}
|
||||
if len(updates) > 0 {
|
||||
updates["updated_at"] = time.Now()
|
||||
db.Model(&byName).Updates(updates)
|
||||
}
|
||||
if openToken != "" && byName.CkbPlanID > 0 {
|
||||
_ = setCkbPlanEnabled(openToken, byName.CkbPlanID, true)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// 3) 创建
|
||||
_, err := createPersonMinimal(db, nickname, userID)
|
||||
return err
|
||||
}
|
||||
|
||||
func genPersonToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
|
||||
@@ -360,7 +360,7 @@ func GiftPayDetail(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if gpr.Status != "pending" && gpr.Status != "pending_pay" && gpr.Status != "paid" {
|
||||
if gpr.Status != "pending" && gpr.Status != "pending_pay" && gpr.Status != "paid" && gpr.Status != "refunded" {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "该代付已处理"})
|
||||
return
|
||||
}
|
||||
@@ -431,6 +431,8 @@ func GiftPayDetail(c *gin.Context) {
|
||||
action = "pay"
|
||||
} else if gpr.Status == "paid" {
|
||||
action = "share"
|
||||
} else if gpr.Status == "refunded" {
|
||||
action = "refunded"
|
||||
} else if gpr.Status == "pending" {
|
||||
action = "share" // 旧版:待好友付
|
||||
}
|
||||
@@ -449,6 +451,8 @@ func GiftPayDetail(c *gin.Context) {
|
||||
} else {
|
||||
action = "redeem"
|
||||
}
|
||||
} else if gpr.Status == "refunded" {
|
||||
action = "refunded"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -675,6 +675,10 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
}
|
||||
expireDate := activateVIP(db, beneficiaryUserID, 365, vipActivatedAt)
|
||||
fmt.Printf("[VIP] 设置方式=支付设置, userId=%s, orderSn=%s, 过期日=%s, activatedAt=%s\n", beneficiaryUserID, orderSn, expireDate.Format("2006-01-02"), vipActivatedAt.Format("2006-01-02 15:04:05"))
|
||||
// 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力)
|
||||
if err := ensurePersonForUser(db, beneficiaryUserID); err != nil {
|
||||
fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, orderSn=%s, err=%v\n", beneficiaryUserID, orderSn, err)
|
||||
}
|
||||
} else if attach.ProductType == "match" {
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", beneficiaryUserID, orderSn)
|
||||
} else if attach.ProductType == "balance_recharge" {
|
||||
@@ -1096,6 +1100,10 @@ func activateOrderBenefits(db *gorm.DB, order *model.Order, payTime time.Time) {
|
||||
db.Model(&model.User{}).Where("id = ?", userID).Update("has_full_book", true)
|
||||
case "vip":
|
||||
activateVIP(db, userID, 365, payTime)
|
||||
// 超级个体/会员开通后:确保链接人与事存在同名 @人(最佳努力,不阻断权益)
|
||||
if err := ensurePersonForUser(db, userID); err != nil {
|
||||
fmt.Printf("[VIP] ensurePersonForUser 失败: userId=%s, err=%v\n", userID, err)
|
||||
}
|
||||
case "balance_recharge":
|
||||
ConfirmBalanceRechargeByOrder(db, order)
|
||||
}
|
||||
|
||||
@@ -274,5 +274,12 @@ func AdminOrderRefund(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"success": false, "error": "退款成功但更新订单状态失败: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
// 代付批量订单退款:同步更新 gift_pay_requests 状态,避免小程序仍可分享/领取
|
||||
if order.GiftPayRequestID != nil && *order.GiftPayRequestID != "" {
|
||||
_ = db.Model(&model.GiftPayRequest{}).
|
||||
Where("id = ?", *order.GiftPayRequestID).
|
||||
Updates(map[string]interface{}{"status": "refunded"}).Error
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"success": true, "message": "退款成功"})
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ package model
|
||||
import "time"
|
||||
|
||||
// GiftPayRequest 代付请求表(改造后:发起人创建并支付,好友领取)
|
||||
// status: pending_pay(待发起人支付)| paid(已支付待领取)| cancelled | expired
|
||||
// status: pending_pay(待发起人支付)| paid(已支付待领取)| refunded(已退款,不可再分享/领取)| cancelled | expired
|
||||
type GiftPayRequest struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:50" json:"id"`
|
||||
RequestSN string `gorm:"column:request_sn;uniqueIndex;size:32" json:"requestSn"`
|
||||
@@ -12,7 +12,7 @@ type GiftPayRequest struct {
|
||||
ProductID string `gorm:"column:product_id;size:50" json:"productId"`
|
||||
Amount float64 `gorm:"column:amount;type:decimal(10,2)" json:"amount"`
|
||||
Description string `gorm:"column:description;size:200" json:"description"`
|
||||
Status string `gorm:"column:status;size:20;index" json:"status"` // pending_pay / paid / cancelled / expired
|
||||
Status string `gorm:"column:status;size:20;index" json:"status"` // pending_pay / paid / refunded / cancelled / expired
|
||||
Quantity int `gorm:"column:quantity;default:1" json:"quantity"`
|
||||
RedeemedCount int `gorm:"column:redeemed_count;default:0" json:"redeemedCount"`
|
||||
PayerUserID *string `gorm:"column:payer_user_id;size:50" json:"payerUserId,omitempty"`
|
||||
|
||||
@@ -8,6 +8,10 @@ import "time"
|
||||
type Person struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
|
||||
// 绑定用户(用于“超级个体开通后自动创建@人”等幂等场景)
|
||||
// 允许为空:历史数据/手工创建的 Person 不一定绑定 user
|
||||
UserID *string `gorm:"column:user_id;size:50;uniqueIndex" json:"userId"`
|
||||
|
||||
PersonID string `gorm:"column:person_id;size:50;uniqueIndex" json:"personId"`
|
||||
Token string `gorm:"column:token;size:36;uniqueIndex" json:"token"` // 32 位唯一 token,文章/小程序传此值
|
||||
Name string `gorm:"column:name;size:100" json:"name"`
|
||||
|
||||
@@ -147,6 +147,8 @@ func Setup(cfg *config.Config) *gin.Engine {
|
||||
{
|
||||
cron.GET("/sync-orders", handler.CronSyncOrders)
|
||||
cron.POST("/sync-orders", handler.CronSyncOrders)
|
||||
cron.GET("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
|
||||
cron.POST("/sync-vip-ckb-plans", handler.CronSyncVipCkbPlans)
|
||||
cron.GET("/unbind-expired", handler.CronUnbindExpired)
|
||||
cron.POST("/unbind-expired", handler.CronUnbindExpired)
|
||||
}
|
||||
|
||||
10
soul-api/scripts/add-persons-user-id.sql
Normal file
10
soul-api/scripts/add-persons-user-id.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
-- persons 表新增 user_id:用于“超级个体开通后自动创建@人”等幂等绑定
|
||||
-- 说明:
|
||||
-- - 允许为空(历史数据/手工创建不绑定 user)
|
||||
-- - 允许多条 NULL(MySQL UNIQUE 对 NULL 不冲突)
|
||||
-- - 绑定后建议一人仅一条 Person(满足“昵称同名@人”需求)
|
||||
|
||||
ALTER TABLE persons
|
||||
ADD COLUMN user_id VARCHAR(50) DEFAULT NULL COMMENT '绑定用户ID(幂等创建@人)',
|
||||
ADD UNIQUE KEY uk_persons_user_id (user_id);
|
||||
|
||||
2380
soul_miniprogram.sql
2380
soul_miniprogram.sql
File diff suppressed because one or more lines are too long
@@ -1,75 +0,0 @@
|
||||
# 需求:文章编辑时 @某人 / #标签 自动创建并绑定
|
||||
|
||||
> 加个需求:用户添加/编辑文章时,@某人 若不存在则自动新增到「链接人与事」并同步存客宝;#某个标签 若不存在则自动新增到「链接标签」并默认配置。
|
||||
|
||||
---
|
||||
|
||||
## 一、需求描述
|
||||
|
||||
| 场景 | 当前行为 | 期望行为 |
|
||||
|------|----------|----------|
|
||||
| 编辑文章输入 `@卡若` | 若 persons 中无「卡若」→ 保持纯文本,不解析 | 自动创建 Person「卡若」+ 同步存客宝计划 → 转为可点击的 mention |
|
||||
| 编辑文章输入 `#创业资源` | 若 linkTags 中无「创业资源」→ 保持纯文本 | 自动创建 LinkTag「创业资源」→ 转为可点击的 linkTag |
|
||||
| 已存在 | 正常绑定 | 不变 |
|
||||
|
||||
---
|
||||
|
||||
## 二、三端分析
|
||||
|
||||
| 端 | 分析 | 任务 |
|
||||
|----|------|------|
|
||||
| **小程序** | 无变更,content 解析逻辑不变 | 无 |
|
||||
| **管理端** | 保存前需「确保存在」:对 content 中不存在的 @name 调用创建 Person,对 #label 调用创建 LinkTag,再执行 autoLinkContent | 1. 提取 content 中所有 @xxx、#xxx<br>2. 对不存在的调用后端「确保存在」接口<br>3. 刷新 persons/linkTags 后 autoLinkContent |
|
||||
| **后端** | 需提供「按名称确保 Person 存在」「按 label 确保 LinkTag 存在」能力 | 1. 新增或扩展接口支持简化创建<br>2. Person:无 deviceGroups 时用默认配置创建存客宝计划<br>3. LinkTag:默认 type=url、url 空 |
|
||||
|
||||
---
|
||||
|
||||
## 三、接口契约
|
||||
|
||||
### 3.1 确保 Person 存在
|
||||
|
||||
**方案**:扩展 `POST /api/db/persons`,支持仅传 `name` 的简化创建;或新增 `POST /api/db/persons/ensure`。
|
||||
|
||||
| 请求 | 说明 |
|
||||
|------|------|
|
||||
| `{ "name": "卡若" }` | 按 name 查找,存在则返回;不存在则创建(personId=name_slug、无 deviceGroups、调存客宝用默认配置) |
|
||||
|
||||
**默认配置**:greeting/tips 空,addInterval=1,startTime=09:00,endTime=18:00,deviceGroups 空(存客宝允许无设备时创建?需确认)
|
||||
|
||||
### 3.2 确保 LinkTag 存在
|
||||
|
||||
**方案**:扩展 `POST /api/db/link-tags`,支持仅传 `label`,tagId 自动生成(label 或 slug(label))。
|
||||
|
||||
| 请求 | 说明 |
|
||||
|------|------|
|
||||
| `{ "label": "创业资源" }` 或 `{ "tagId": "创业资源", "label": "创业资源", "type": "url", "url": "" }` | 按 label 查找,存在则返回;不存在则创建,默认 type=url、url 空 |
|
||||
|
||||
---
|
||||
|
||||
## 四、任务指派
|
||||
|
||||
| 序号 | 角色 | 任务 |
|
||||
|------|------|------|
|
||||
| 1 | 后端 | 扩展 DBPersonSave:支持仅 name,按 name 查 persons 表(若 persons 有 name 列唯一则用;否则需确认按 name 查逻辑);不存在时用默认配置创建 + 调存客宝(deviceGroups 空时是否允许需验证) |
|
||||
| 2 | 后端 | 扩展 DBLinkTagSave 或新增 ensure:支持仅 label,按 label 查;不存在时创建,tagId=label,type=url,url="" |
|
||||
| 3 | 管理端 | handleSaveSection/handleCreateSection 前:extractMentionsAndTags(content) → 对每个 @name 若 persons 无则 POST persons ensure;对每个 #label 若 linkTags 无则 POST link-tags ensure → loadPersons/loadLinkTags → autoLinkContent |
|
||||
|
||||
---
|
||||
|
||||
## 五、验收标准
|
||||
|
||||
- [ ] 编辑文章输入 `@新人物`(链接人与事中无)→ 保存 → 链接人与事列表出现「新人物」,存客宝有对应计划
|
||||
- [ ] 编辑文章输入 `#新标签`(链接标签中无)→ 保存 → 链接标签列表出现「新标签」
|
||||
- [ ] 小程序阅读页点击 @新人物、#新标签 可正常跳转/加好友
|
||||
- [ ] 已存在的 @某人、#标签 行为不变
|
||||
|
||||
---
|
||||
|
||||
## 六、实施记录
|
||||
|
||||
- 2026-03-16:后端扩展 DBPersonSave(按 name 查找)、DBLinkTagSave(按 label 查找,tagId 可缺省);管理端 ensureMentionsAndTags + handleSaveSection/handleCreateSection 集成
|
||||
|
||||
## 七、风险与待确认
|
||||
|
||||
- ~~存客宝创建计划时 deviceGroups 为空是否允许?~~ 已确认:deviceGroups 必填;未传时默认选择名为 soul 的设备(ckbOpenGetDefaultDeviceID)
|
||||
- Person 按 name 查找:取第一个匹配;若有多人同名,会复用第一个
|
||||
143
代付页面/stitch_soul/_1/code.html
Normal file
143
代付页面/stitch_soul/_1/code.html
Normal file
@@ -0,0 +1,143 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>代付订单详情 - 我发起的</title>
|
||||
<!-- Tailwind CSS v3 CDN with plugins -->
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<style data-purpose="custom-colors">
|
||||
/* Custom theme colors based on the design tokens */
|
||||
:root {
|
||||
--brand-mint: #4adeca;
|
||||
--bg-dark: #000000;
|
||||
--card-bg: #1c1c1e;
|
||||
--text-muted: #8e8e93;
|
||||
}
|
||||
</style>
|
||||
<style data-purpose="layout-styles">
|
||||
body {
|
||||
background-color: var(--bg-dark);
|
||||
color: white;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
.mint-glow {
|
||||
box-shadow: 0 0 20px rgba(74, 222, 202, 0.3);
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="flex flex-col min-h-screen">
|
||||
<!-- BEGIN: Header -->
|
||||
<header class="sticky top-0 z-10 bg-black/80 backdrop-blur-md px-4 py-4 flex items-center">
|
||||
<button class="text-white mr-4" data-purpose="back-button">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-lg font-medium">代付订单详情</h1>
|
||||
</header>
|
||||
<!-- END: Header -->
|
||||
<main class="flex-grow px-5 pb-32">
|
||||
<!-- BEGIN: Article Card -->
|
||||
<section class="mt-4 mb-8" data-purpose="article-preview">
|
||||
<div class="bg-[#1c1c1e] rounded-2xl p-5 border border-white/5">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div class="flex-1 pr-4">
|
||||
<span class="inline-block bg-[#1a2d2a] text-[#4adeca] text-[10px] font-bold px-2 py-0.5 rounded border border-[#4adeca]/30 mb-2">FREE GIFT</span>
|
||||
<h2 class="text-xl font-bold leading-tight mb-2">3000万流水如何跑出来(退税模式解析)</h2>
|
||||
<p class="text-sm text-[#8e8e93] line-clamp-2">深入剖析跨境电商退税合规路径,解析三千万级流水规模下的财务闭环与架构...</p>
|
||||
</div>
|
||||
<div class="w-16 h-16 bg-[#2c2c2e] rounded-xl flex items-center justify-center shrink-0 border border-white/10">
|
||||
<svg class="h-8 w-8 text-[#4adeca]" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 6.253v13m0-13C10.832 5.477 9.246 5 7.5 5S4.168 5.477 3 6.253v13C4.168 18.477 5.754 18 7.5 18s3.332.477 4.5 1.253m0-13C13.168 5.477 14.754 5 16.5 5c1.747 0 3.332.477 4.5 1.253v13C19.832 18.477 18.247 18 16.5 18c-1.746 0-3.332.477-4.5 1.253" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div class="border-t border-white/5 pt-4 flex justify-between items-center">
|
||||
<span class="text-[#4adeca] text-sm italic">“我已为你买单,点击免费阅读”</span>
|
||||
<svg class="h-4 w-4 text-[#8e8e93]" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 5l7 7-7 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END: Article Card -->
|
||||
<!-- BEGIN: Claim Progress -->
|
||||
<section class="mb-10" data-purpose="progress-section">
|
||||
<div class="flex justify-between items-end mb-3">
|
||||
<h3 class="text-sm font-medium text-white">领取进度</h3>
|
||||
<span class="text-xs text-[#8e8e93]">已领取 <span class="text-[#4adeca] font-bold">3</span> / 5</span>
|
||||
</div>
|
||||
<!-- Progress Bar -->
|
||||
<div class="w-full bg-[#2c2c2e] h-2 rounded-full overflow-hidden">
|
||||
<div class="bg-[#4adeca] h-full rounded-full" style="width: 60%"></div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END: Claim Progress -->
|
||||
<!-- BEGIN: Friends Claim List -->
|
||||
<section data-purpose="friends-list">
|
||||
<h3 class="text-sm font-medium text-[#8e8e93] mb-4">领取详情</h3>
|
||||
<div class="space-y-6">
|
||||
<!-- Friend Item 1 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<img alt="User Avatar" class="w-10 h-10 rounded-full bg-gray-700 mr-3" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCy88duzYMwiZg941lgxnxeIp3W-VjiK6NfDZh45dViX3osk1Y953WQuUaBGy5kG2gKYs02dgrWgdFRm-cmHlw4USfI-zQZ4UfOmPoPYqV39tfr8JxKl818FwpRaCEPCh3E9CUX-Dd9AX4h77UzK-u_R8ov42zz1Q1KN2TSU7QtPm9W3zbG6N6gwrXY_91-xbea1vzHS4SYXZ53r3qYvkzhGVu5D3HcxgAJO-uYuDy2VZ8B2ryahrRt12nK50br5i4w-KG74A_ivis"/>
|
||||
<div>
|
||||
<p class="text-[15px] font-medium text-white">王小明</p>
|
||||
<p class="text-xs text-[#8e8e93]">通过分享链接领取</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-[#8e8e93]">10-24 14:20</span>
|
||||
</div>
|
||||
<!-- Friend Item 2 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<img alt="User Avatar" class="w-10 h-10 rounded-full bg-gray-700 mr-3" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBQDll7zv_l-i7hevQbdFDD3URTtlQB788GW_mpBoB2TRPEI0lqZ3l713ZCGC87YKuwf-nIp0z-VnD-rYV4b5gkmECUK92mLf0nBfPuQqHI-Fo-1uVCnExztDlE30XxRe6lIWYpL9UsdSaF6iBVDIdUyzHTZungvsM-QPj-C2S4IrwecJTumScH5yChMRjPfqbdmqyF2CTeOhCiYh7af63QFS5WdrOJ6ZWZTF6suUxrqs4Pr-086FTDnSLnyBi-r-AdiUK3DgNt8sQ"/>
|
||||
<div>
|
||||
<p class="text-[15px] font-medium text-white">Li Hua</p>
|
||||
<p class="text-xs text-[#8e8e93]">通过微信群领取</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-[#8e8e93]">10-24 15:05</span>
|
||||
</div>
|
||||
<!-- Friend Item 3 -->
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center">
|
||||
<img alt="User Avatar" class="w-10 h-10 rounded-full bg-gray-700 mr-3" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCZSKjVj0NEcMcndyXVBy9-pBnv9Ml5nD3XK22C61MBFv7Gsv24560Zh4Xw3iWD4IxpXnRwNbNQOCqRpR8-sbZeVQ0uYQcT_bRxhFdZVB2LVZDTJTvSGKTIeZ3plotcOS4SiCX4m3qIMhGzKEr3htlfDTv8NyaKexdbHWd4X4Kx-U2qoZ9m3mfGRwNk29T5xTyBZstubZIvG213v4hJbJoM5ymHxtlv-9K42tYhMQFC_CpG2stMespb9MyiKg0haq95EVVBDp3quT4"/>
|
||||
<div>
|
||||
<p class="text-[15px] font-medium text-white">张大伟</p>
|
||||
<p class="text-xs text-[#8e8e93]">通过分享链接领取</p>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-xs text-[#8e8e93]">10-24 16:45</span>
|
||||
</div>
|
||||
<!-- Waiting Slot 4 -->
|
||||
<div class="flex items-center justify-between opacity-50">
|
||||
<div class="flex items-center">
|
||||
<div class="w-10 h-10 rounded-full bg-[#2c2c2e] border-2 border-dashed border-[#444] flex items-center justify-center mr-3">
|
||||
<span class="text-[#444] text-xs">?</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[15px] font-medium text-white/60">待领取</p>
|
||||
<p class="text-xs text-[#8e8e93]">剩余 2 个名额</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<!-- END: Friends Claim List -->
|
||||
</main>
|
||||
<!-- BEGIN: Bottom Action -->
|
||||
<footer class="fixed bottom-0 left-0 right-0 p-6 bg-gradient-to-t from-black via-black/90 to-transparent">
|
||||
<button class="w-full bg-[#4adeca] text-black font-bold py-4 rounded-full flex items-center justify-center gap-2 mint-glow active:scale-[0.98] transition-transform">
|
||||
<svg class="h-5 w-5" fill="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81 1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9c-1.66 0-3 1.34-3 3s1.34 3 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.16c-.05.21-.08.43-.08.65 0 1.61 1.31 2.92 2.92 2.92 1.61 0 2.92-1.31 2.92-2.92s-1.31-2.92-2.92-2.92z"></path>
|
||||
</svg>
|
||||
再次分享给好友
|
||||
</button>
|
||||
</footer>
|
||||
<!-- END: Bottom Action -->
|
||||
</body></html>
|
||||
BIN
代付页面/stitch_soul/_1/screen.png
Normal file
BIN
代付页面/stitch_soul/_1/screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 186 KiB |
133
代付页面/stitch_soul/_2/code.html
Normal file
133
代付页面/stitch_soul/_2/code.html
Normal file
@@ -0,0 +1,133 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>生成代付链接 - 支付确认</title>
|
||||
<!-- Tailwind CSS v3 CDN -->
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
'mint-green': '#40E0D0', // Primary Mint Green from Screen 116/142
|
||||
'dark-gray': '#1C1C1E', // Dark card background
|
||||
'inner-card': '#242426', // Slightly lighter card for nested content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style data-purpose="custom-styling">
|
||||
/* Custom glassmorphism-like shadow and transitions */
|
||||
.modal-shadow {
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.btn-glow:active {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.9;
|
||||
}
|
||||
.input-field:focus {
|
||||
border-color: #40E0D0 !important;
|
||||
ring-color: #40E0D0 !important;
|
||||
}
|
||||
/* Hide number input spinners */
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-black flex items-center justify-center min-h-screen font-sans antialiased text-white">
|
||||
<!-- BEGIN: ModalOverlay -->
|
||||
<div class="fixed inset-0 bg-black/80 flex items-center justify-center p-6 z-50" data-purpose="payment-proxy-modal">
|
||||
<!-- BEGIN: ModalContent -->
|
||||
<div class="bg-dark-gray w-full max-w-sm rounded-[32px] p-6 relative modal-shadow border border-white/5">
|
||||
<!-- Close Button -->
|
||||
<button aria-label="Close" class="absolute right-6 top-6 text-gray-500 hover:text-white transition-colors">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 18L18 6M6 6l12 12" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Header Title -->
|
||||
<div class="mt-4 mb-8 text-center">
|
||||
<h2 class="text-xl font-bold tracking-tight">生成代付链接</h2>
|
||||
</div>
|
||||
<!-- BEGIN: ArticlePreview -->
|
||||
<div class="bg-inner-card rounded-2xl p-5 mb-6 border border-white/5" data-purpose="article-info">
|
||||
<h3 class="text-lg font-bold leading-tight mb-3">3000万流水如何跑出来(退税模式解析)</h3>
|
||||
<p class="text-gray-400 text-sm leading-relaxed line-clamp-2">
|
||||
深入剖析跨境电商退税合规路径,解析三千万级流水规模下的财务闭环与架构...
|
||||
</p>
|
||||
</div>
|
||||
<!-- END: ArticlePreview -->
|
||||
<!-- BEGIN: InputSection -->
|
||||
<div class="space-y-4 mb-8" data-purpose="payment-form">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-400 mb-4 ml-1">选择代付名额数</label>
|
||||
<div class="grid grid-cols-4 gap-3" id="spots-selection">
|
||||
<button class="spot-btn border border-white/10 bg-inner-card rounded-xl py-3 text-white hover:border-mint-green/50 transition-all font-medium" data-value="6">6</button>
|
||||
<button class="spot-btn border border-white/10 bg-inner-card rounded-xl py-3 text-white hover:border-mint-green/50 transition-all font-medium" data-value="30">30</button>
|
||||
<button class="spot-btn border border-white/10 bg-inner-card rounded-xl py-3 text-white hover:border-mint-green/50 transition-all font-medium" data-value="100">100</button>
|
||||
<button class="spot-btn border border-white/10 bg-inner-card rounded-xl py-3 text-white hover:border-mint-green/50 transition-all font-medium" data-value="1000">1000</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- BEGIN: PriceDisplay -->
|
||||
<div class="flex flex-col items-center justify-center py-4" data-purpose="price-calculation">
|
||||
<span class="text-xs text-gray-500 mb-1">待支付总价格</span>
|
||||
<div class="flex items-baseline gap-2">
|
||||
<span class="text-mint-green text-sm">¥99.00 × <span id="display-spots">1</span> =</span>
|
||||
<span class="text-mint-green text-3xl font-bold" id="total-price">¥99.00</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- END: PriceDisplay -->
|
||||
</div>
|
||||
<!-- END: InputSection -->
|
||||
<!-- BEGIN: ActionButtons -->
|
||||
<div class="flex flex-col gap-4">
|
||||
<button class="w-full bg-mint-green text-black font-bold py-4 rounded-full text-lg shadow-[0_0_20px_rgba(64,224,208,0.3)] btn-glow transition-all" id="confirm-btn">
|
||||
确认并支付
|
||||
</button>
|
||||
<button class="w-full py-2 text-gray-500 text-sm font-medium hover:text-gray-300 transition-colors">
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
<!-- END: ActionButtons -->
|
||||
</div>
|
||||
<!-- END: ModalContent -->
|
||||
</div>
|
||||
<!-- END: ModalOverlay -->
|
||||
<!-- BEGIN: LogicScript -->
|
||||
<script data-purpose="simple-price-calculator">
|
||||
const buttons = document.querySelectorAll('.spot-btn');
|
||||
const displaySpots = document.getElementById('display-spots');
|
||||
const totalPrice = document.getElementById('total-price');
|
||||
const UNIT_PRICE = 99.00;
|
||||
|
||||
buttons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
// Reset all buttons
|
||||
buttons.forEach(b => {
|
||||
b.classList.remove('border-mint-green', 'text-mint-green', 'bg-mint-green/10');
|
||||
b.classList.add('border-white/10', 'text-white', 'bg-inner-card');
|
||||
});
|
||||
|
||||
// Highlight selected button
|
||||
btn.classList.add('border-mint-green', 'text-mint-green', 'bg-mint-green/10');
|
||||
btn.classList.remove('border-white/10', 'text-white', 'bg-inner-card');
|
||||
|
||||
// Update price
|
||||
const val = parseInt(btn.getAttribute('data-value'));
|
||||
displaySpots.textContent = val;
|
||||
const total = (val * UNIT_PRICE).toFixed(2);
|
||||
totalPrice.textContent = `¥${total}`;
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize with first button
|
||||
buttons[0].click();
|
||||
</script>
|
||||
<!-- END: LogicScript -->
|
||||
</body></html>
|
||||
BIN
代付页面/stitch_soul/_2/screen.png
Normal file
BIN
代付页面/stitch_soul/_2/screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 148 KiB |
181
代付页面/stitch_soul/_3/code.html
Normal file
181
代付页面/stitch_soul/_3/code.html
Normal file
@@ -0,0 +1,181 @@
|
||||
<!DOCTYPE html>
|
||||
|
||||
<html lang="zh-CN"><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1.0" name="viewport"/>
|
||||
<title>订单列表 - 代付共享</title>
|
||||
<!-- Tailwind CSS v3 with plugins -->
|
||||
<script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
brand: {
|
||||
DEFAULT: '#4ade80',
|
||||
dark: '#1f2937',
|
||||
surface: '#121212',
|
||||
card: '#1e1e1e',
|
||||
},
|
||||
mint: '#44d7b6',
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style data-purpose="custom-styling">
|
||||
body {
|
||||
background-color: #000000;
|
||||
color: #ffffff;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.tab-active {
|
||||
color: #44d7b6;
|
||||
border-bottom: 2px solid #44d7b6;
|
||||
}
|
||||
/* Smooth transition for tab switching */
|
||||
.tab-content {
|
||||
transition: opacity 0.2s ease-in-out;
|
||||
}
|
||||
.hidden-tab {
|
||||
display: none;
|
||||
}
|
||||
/* Subtle glow for brand elements */
|
||||
.brand-glow {
|
||||
box-shadow: 0 0 15px rgba(68, 215, 182, 0.3);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body class="min-h-screen font-sans">
|
||||
<!-- BEGIN: MainHeader -->
|
||||
<header class="sticky top-0 z-50 bg-black/80 backdrop-blur-md border-b border-white/10">
|
||||
<div class="px-4 py-4 flex items-center justify-between">
|
||||
<button class="p-1">
|
||||
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M15 19l-7-7 7-7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<h1 class="text-lg font-bold">订单列表</h1>
|
||||
<div class="w-8"></div> <!-- Spacer for centering -->
|
||||
</div>
|
||||
</header>
|
||||
<!-- END: MainHeader -->
|
||||
<main class="p-4 space-y-4">
|
||||
<!-- BEGIN: I Initiated List -->
|
||||
<section class="tab-content space-y-3" id="list-initiated">
|
||||
<!-- Order Card 1 -->
|
||||
<article class="bg-brand-card rounded-2xl p-5 border border-white/5" data-purpose="order-card">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h2 class="text-base font-bold flex-1 pr-4 leading-relaxed">3000万流水如何跑出来(退税模式解析)</h2>
|
||||
<span class="text-mint font-bold text-lg shrink-0">¥99.00</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4 border-t border-white/5">
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-400">
|
||||
<span>总共 5 份</span>
|
||||
<span class="text-gray-600">·</span>
|
||||
<span class="text-mint">还剩 2 份</span>
|
||||
</div>
|
||||
<button class="px-5 py-2 bg-mint/10 text-mint text-xs font-bold rounded-full border border-mint/20 hover:bg-mint/20 transition-colors">
|
||||
再次分享
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<!-- Order Card 2 -->
|
||||
<article class="bg-brand-card rounded-2xl p-5 border border-white/5" data-purpose="order-card">
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<h2 class="text-base font-bold flex-1 pr-4 leading-relaxed">跨境电商财务合规与架构设计实战手册</h2>
|
||||
<span class="text-mint font-bold text-lg shrink-0">¥128.00</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between pt-4 border-t border-white/5">
|
||||
<div class="flex items-center space-x-2 text-sm text-gray-400">
|
||||
<span>总共 3 份</span>
|
||||
<span class="text-gray-600">·</span>
|
||||
<span>已领完</span>
|
||||
</div>
|
||||
<button class="px-5 py-2 bg-white/5 text-gray-400 text-xs font-bold rounded-full border border-white/5 hover:bg-white/10 transition-colors">
|
||||
查看详情
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<!-- END: I Initiated List -->
|
||||
<!-- BEGIN: I Paid For List (Hidden by default, keeping for structure if needed later) -->
|
||||
<section class="tab-content space-y-4 hidden-tab" id="list-paid">
|
||||
<!-- Paid Card 1 -->
|
||||
<article class="bg-brand-card rounded-2xl p-4 border border-white/5" data-purpose="order-card">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<h3 class="text-base font-bold flex-1 pr-4 leading-tight">2024全球供应链趋势分析报告</h3>
|
||||
<span class="text-mint font-bold text-lg">¥59.00</span>
|
||||
</div>
|
||||
<!-- Friend Info Section -->
|
||||
<div class="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="relative">
|
||||
<img alt="Avatar" class="w-10 h-10 rounded-full border-2 border-mint/50 p-0.5" src="https://lh3.googleusercontent.com/aida-public/AB6AXuBh6DTueRcPNv93o66NBMhoFPhc6CMe14WYnm2-IWYG3DWUHVV4-LxnEojzT-XSjMVYGPASbct9a5KEPXJSc-fM9UG_bS6p2xJ2mS_cvffCFi3P0V7ZmFEosMgT3R6utOJvOUrCKbjScpAiRQaU8m_Jw_XdlRG-xYqNojme_v0yBhSYIo_sioCEeYgP72dL8f6reDQ4wA68WmCQPNlanpBeTdvGdSVQgEHvBbqCvInlGuCazj79aCQUdRMUNqth5O1sODz7uIbnDxk"/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">为好友代付</p>
|
||||
<p class="text-sm font-medium">张小龙</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-mint text-xs flex items-center">
|
||||
查看文章
|
||||
<svg class="h-3 w-3 ml-1" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 5l7 7-7 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
<!-- Paid Card 2 -->
|
||||
<article class="bg-brand-card rounded-2xl p-4 border border-white/5" data-purpose="order-card">
|
||||
<div class="flex justify-between items-start mb-3">
|
||||
<h3 class="text-base font-bold flex-1 pr-4 leading-tight">高保真原型设计进阶课</h3>
|
||||
<span class="text-mint font-bold text-lg">¥199.00</span>
|
||||
</div>
|
||||
<div class="mt-4 pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3">
|
||||
<div class="relative">
|
||||
<img alt="Avatar" class="w-10 h-10 rounded-full border-2 border-mint/50 p-0.5" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDSj1xdhZYfbS-_FRPiwvYqvStJ5tQhU6pIBEyGAKx88n2MWrX6UjfzIFK84BTL6UGOxq-FRcjn41fhLXD1DquNdRif97qp_iCAtCI8n0WZ1k1kYojPjUBe-Axhv6eRldKpOnUx7rIQxNLWW148gYboyOk4QO3C2duT_QhulEihi00n08hTYj-pPgOkj63v78IlNYe-906lkOcBNuk_mmTFLG9VAFaXQuhn8yq8MWFi_UpYKIpNAVV02DOIZWYy5_z7wHhbajXmWX8"/>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-xs text-gray-500">为好友代付</p>
|
||||
<p class="text-sm font-medium">李建国</p>
|
||||
</div>
|
||||
</div>
|
||||
<button class="text-mint text-xs flex items-center">
|
||||
查看文章
|
||||
<svg class="h-3 w-3 ml-1" fill="none" stroke="currentColor" viewbox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9 5l7 7-7 7" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
<!-- END: I Paid For List -->
|
||||
</main>
|
||||
<!-- BEGIN: Bottom Interaction -->
|
||||
<div class="fixed bottom-8 left-0 right-0 px-6 flex justify-center pointer-events-none">
|
||||
<button class="pointer-events-auto flex items-center justify-center bg-mint text-black font-bold py-4 px-10 rounded-full w-full max-w-sm brand-glow transition-transform active:scale-95">
|
||||
<svg class="h-5 w-5 mr-2" fill="currentColor" viewbox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||
<path clip-rule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" fill-rule="evenodd"></path>
|
||||
</svg>
|
||||
发起新的代付
|
||||
</button>
|
||||
</div>
|
||||
<!-- END: Bottom Interaction -->
|
||||
<script data-purpose="tab-switcher">
|
||||
function switchTab(type) {
|
||||
const initiatedList = document.getElementById('list-initiated');
|
||||
const paidList = document.getElementById('list-paid');
|
||||
|
||||
// Since tabs are removed, this functionality is effectively disabled but kept for structure
|
||||
if (type === 'initiated') {
|
||||
initiatedList.classList.remove('hidden-tab');
|
||||
paidList.classList.add('hidden-tab');
|
||||
} else {
|
||||
initiatedList.classList.add('hidden-tab');
|
||||
paidList.classList.remove('hidden-tab');
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body></html>
|
||||
BIN
代付页面/stitch_soul/_3/screen.png
Normal file
BIN
代付页面/stitch_soul/_3/screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 112 KiB |
BIN
代付页面/stitch_soul/image.png/screen.png
Normal file
BIN
代付页面/stitch_soul/image.png/screen.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 197 KiB |
@@ -469,6 +469,26 @@ Mycontent-temp/miniprogram 为样式预览分支,miniprogram 为线上主线
|
||||
|
||||
---
|
||||
|
||||
# 第二十部分:代付流程优化与退款禁用(2026-03-18)
|
||||
|
||||
## 代付流程(阅读页内发起)
|
||||
|
||||
- **发起代付**:阅读页内使用**居中弹窗**选择名额数 → 微信支付 → 支付成功后直接分享给好友。
|
||||
- **好友领取**:好友点击分享进入阅读页时,自动调用领取接口并解锁文章(非 singlePage 场景)。
|
||||
- **朋友圈 singlePage 兼容**:若为朋友圈单页模式(能力不完整),禁止执行支付/自动领取等强动作,提示并引导用户点击底部「前往小程序」进入完整版后再操作。
|
||||
|
||||
## 退款后的禁用规则(关键修复)
|
||||
|
||||
- 管理端对“代付批量订单”退款成功后,必须同步将对应 `gift_pay_requests.status` 标记为 `refunded`。
|
||||
- 小程序「我发起的代付」与代付详情页在 `refunded` 状态下:展示“已退款”,不可再分享/领取。
|
||||
- 管理端推广中心“代付请求”Tab:支持筛选与展示 `refunded`。
|
||||
|
||||
## 相关文档
|
||||
|
||||
- `开发文档/代付-需求分析与待完善.md`(状态机补充:pending_pay/paid/refunded/cancelled/expired)
|
||||
|
||||
---
|
||||
|
||||
# 第十九部分:代付功能完善(2026-03-17 橙子整理)
|
||||
|
||||
## 变更摘要
|
||||
|
||||
57
开发文档/10、项目管理/项目落地推进表.md
Normal file
57
开发文档/10、项目管理/项目落地推进表.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 项目落地推进表(里程碑)
|
||||
|
||||
> 作为《需求汇总》《以界面定需求》《运营与变更》的“执行层”补充:记录**当前阶段目标**、**里程碑**、**阻塞项**与**下一步**。
|
||||
> 更新日期:2026-03-18
|
||||
|
||||
---
|
||||
|
||||
## 一、当前阶段(概览)
|
||||
|
||||
| 目标 | 说明 | 负责人 | 状态 |
|
||||
|------|------|--------|------|
|
||||
| 稳定版三端功能闭环 | 小程序/管理端/后端按《以界面定需求》对齐,核心链路可用 | 全员 | 已完成(持续优化) |
|
||||
| 新版差异收敛 | new-soul 作为参考,按“稳定版为准”选择性迁移 | 产品/开发 | 进行中 |
|
||||
| 性能与稳定性 | Redis 缓存、OSS、/health、测试流程定稿 | 后端/测试 | 已完成(回归中) |
|
||||
|
||||
---
|
||||
|
||||
## 二、里程碑(按主题)
|
||||
|
||||
### 2.1 内容阅读与付费(阅读/购买/权益)
|
||||
|
||||
| 里程碑 | 验收口径 | 负责人 | 状态 | 备注 |
|
||||
|-------|----------|--------|------|------|
|
||||
| 阅读页权限状态机 | 未授权只返回预览;授权显示全文;VIP 全章免费 | 后端/小程序 | 已完成 | 见《运营与变更》阅读规则澄清 |
|
||||
| 支付链路(微信/余额) | 下单→支付→回调→解锁/分润正确 | 后端/小程序/测试 | 已完成 | 见 scripts/test 流程用例 |
|
||||
|
||||
### 2.2 代付分享(发起人支付,好友领取)
|
||||
|
||||
| 里程碑 | 验收口径 | 负责人 | 状态 | 备注 |
|
||||
|-------|----------|--------|------|------|
|
||||
| 代付闭环 | 发起人支付后分享;好友打开领取并解锁;领取明细可查 | 小程序/后端/测试 | 已完成 | 相关文档:代付-业务线框图/简化流程设计/需求分析 |
|
||||
| 朋友圈 singlePage 兼容 | 单页模式不做强动作(支付/自动领取),引导“前往小程序” | 小程序/测试 | 已完成 | 团队经验库 2026-03-18 |
|
||||
|
||||
### 2.3 推广分销与提现
|
||||
|
||||
| 里程碑 | 验收口径 | 负责人 | 状态 | 备注 |
|
||||
|-------|----------|--------|------|------|
|
||||
| 分润规则可配置 | 会员订单 20%/10%,内容订单 90%,配置可控 | 后端/管理端 | 已完成 | 推广设置页 |
|
||||
| 提现闭环 | 小程序申请→管理端审核/打款→回写状态→订阅消息 | 后端/管理端/小程序 | 已完成 | 见部署文档与订阅消息 |
|
||||
|
||||
---
|
||||
|
||||
## 三、风险与阻塞(持续维护)
|
||||
|
||||
| 项 | 风险/阻塞 | 影响 | 建议处理 |
|
||||
|----|-----------|------|----------|
|
||||
| 朋友圈 singlePage | 分享进入能力不完整,支付/登录/领取易失败 | 转化、投诉 | 统一单页模式判断与引导(已沉淀) |
|
||||
| 新版运行时配置差异 | runtime bootstrap / mpConfig 扩展未完全迁移 | 部署与环境切换 | 按《迁移完成度与待办清单》P0/P1 分批补齐 |
|
||||
|
||||
---
|
||||
|
||||
## 四、下一步(建议顺序)
|
||||
|
||||
1. **补齐新版运行时配置(P0/P1)**:runtime config、loadMpConfig 扩展、auditMode 审核隐藏逻辑(以《迁移完成度与待办清单》为准)
|
||||
2. **文档持续归档**:新增/变更功能后同步更新《以界面定需求》《需求汇总》《运营与变更》
|
||||
3. **按测试流程回归**:每次大变更按 `scripts/test/功能测试流程.md` 输出报告并归档
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
|----------|----------|----------|
|
||||
| **pages/index/index** | 首页:超级个体/VIP 成员、精选推荐、目录入口、最新章节、用户资料弹窗 | `GET /api/miniprogram/vip/members`、`GET /api/miniprogram/users`、`GET /api/miniprogram/book/recommended`、`GET /api/miniprogram/book/all-chapters`、`GET /api/miniprogram/book/latest-chapters`、`GET /api/miniprogram/user/profile` |
|
||||
| **pages/chapters/chapters** | 目录:章节列表、免费/付费/VIP 权限、热门与最新 | `GET /api/miniprogram/book/all-chapters`、`GET /api/miniprogram/config` |
|
||||
| **pages/read/read** | 阅读:章节内容、购买状态、支付下单、阅读进度、@提及 | `GET /api/miniprogram/book/chapter`、`GET /api/miniprogram/user/purchase-status`、`POST /api/miniprogram/pay`、`POST /api/miniprogram/user/reading-progress`、`GET /api/miniprogram/config` |
|
||||
| **pages/read/read** | 阅读:章节内容、购买状态、支付下单、阅读进度、@提及、代付分享(发起→支付→分享;好友自动领取解锁) | `GET /api/miniprogram/book/chapter`、`GET /api/miniprogram/user/purchase-status`、`POST /api/miniprogram/pay`、`POST /api/miniprogram/user/reading-progress`、`GET /api/miniprogram/config`、`POST /api/miniprogram/gift-pay/create`、`POST /api/miniprogram/gift-pay/initiator-pay`、`POST /api/miniprogram/gift-pay/redeem`、`GET /api/miniprogram/gift-pay/detail` |
|
||||
| **pages/match/match** | 找伙伴:匹配类型、留资/匹配提交、资源对接 | `GET /api/miniprogram/config`(match_config)、`POST /api/miniprogram/match/*` 等 |
|
||||
| **pages/my/my** | 我的:配置、阅读统计、待确认提现、收益、VIP 状态、提现入口 | `GET /api/miniprogram/config`、`GET /api/miniprogram/user/dashboard-stats`、`GET /api/miniprogram/withdraw/pending-confirm`、`GET /api/miniprogram/earnings`、`GET /api/miniprogram/vip/status`、`POST /api/miniprogram/user/update`、`POST /api/miniprogram/withdraw` |
|
||||
| **pages/referral/referral** | 推广中心:推广数据、海报与小程序码、复制文案、申请提现 | `GET /api/miniprogram/referral/data`、`POST /api/miniprogram/qrcode`、`POST /api/miniprogram/withdraw` |
|
||||
|
||||
@@ -55,4 +55,6 @@ IP 设定、风格、输出规范(见原卡若角色设定)。
|
||||
| 2026-03-16 | 存客宝创建计划参数:planType=1、sceneId=9、status=1 | 已完成 | db_person.go |
|
||||
| 2026-03-16 | @mention 存储格式:span 必须含 data-label,否则 TipTap 显示 token | 已完成 | ParseAutoLinkContent、autolink.go |
|
||||
| 2026-03-17 | 代付统一到代付页:gift=1&ref 打开 read 页时 redirectTo 代付页,禁止在阅读页代付;代付页显示部分文章信息 | 已完成 | read.js onLoad;代付页已有 description |
|
||||
| 2026-03-18 | 代付流程调整:阅读页内居中弹窗选择名额→支付成功直接分享;好友打开阅读页自动领取并解锁(含朋友圈 singlePage 降级引导) | 已完成 | read 页代付弹窗+自动领取;singlePage 引导规则入库 |
|
||||
| 2026-03-18 | 代付退款后禁用:订单退款后代付请求标记 refunded,小程序/管理端均展示“已退款”,不可再分享/领取 | 已完成 | 后端退款同步 gift_pay_requests.status=refunded |
|
||||
| - | 链接人与事所有同步需求汇总 | - | 链接人与事-所有同步需求.md |
|
||||
|
||||
@@ -68,6 +68,7 @@ read 页「代付分享」
|
||||
| 发起人视角 | 隐藏发起人信息、安全徽章、右侧箭头 |
|
||||
| 创建态提示 | 创建后无法退款 |
|
||||
| 份数校验 | 前端支付时校验正整数;后端校验 ≥1 |
|
||||
| 退款后禁用 | 订单退款后,同步将 gift_pay_requests 标记为 refunded,小程序端不可再分享/领取,展示「已退款」 | ✅ 已实现(2026-03-18) |
|
||||
|
||||
---
|
||||
|
||||
@@ -76,3 +77,15 @@ read 页「代付分享」
|
||||
| 项 | 说明 |
|
||||
|------|------|
|
||||
| ref 解析 | app 对分享 path 中 ref 的解析,与 bindReferralCode 规则是否一致 |
|
||||
|
||||
---
|
||||
|
||||
## 五、状态机补充(用于验收)
|
||||
|
||||
| gift_pay_requests.status | 含义 | 发起人可操作 | 好友可操作 |
|
||||
|---|---|---|---|
|
||||
| pending_pay | 待发起人支付 | 支付 / 取消 | 等待 |
|
||||
| paid | 已支付待领取 | 分享 | 领取/已领取 |
|
||||
| refunded | 已退款 | 不可分享(仅展示已退款) | 不可领取 |
|
||||
| cancelled | 已取消 | 不可操作 | 不可操作 |
|
||||
| expired | 已过期 | 不可操作 | 不可操作 |
|
||||
|
||||
Reference in New Issue
Block a user