更新项目文档,添加会话启动自检说明,强化 GORM 使用规范,确保数据操作遵循事务管理和预加载原则。修正多个处理函数以使用链式查询和数据验证,提升代码一致性和可维护性。
This commit is contained in:
@@ -2,6 +2,8 @@
|
||||
|
||||
本目录的 rules 与 skills 均为**当前项目(Soul 创业派对)**服务,用于约束开发、防止互窜、减少漏改。
|
||||
|
||||
**会话启动自检**:新 Cursor 打开本项目时,应优先执行 soul-project-boundary 中的「会话启动自检」,仅沿用本项目的 rules、skills、开发风格与配置参数,排除无关的全局或其它项目规则。
|
||||
|
||||
---
|
||||
|
||||
## 一、Rules 执行顺序与生效范围
|
||||
@@ -29,6 +31,8 @@
|
||||
| soul-api/ | SKILL-API开发.md | soul-api-coding.mdc(编码细节)、SKILL-变更关联检查.md |
|
||||
| next-project/ | SKILL-next-project仅预览.md | api-reliability.mdc(若改 Next API) |
|
||||
|
||||
**拆解项目时**:有类似 next-project 的 Next.js 全栈项目需拆为前后端分离 + 小程序时,使用 **SKILL-Next全栈拆解为前后端分离与小程序.md**。
|
||||
|
||||
**变更时**:无论改哪端,改完都需过 **soul-change-checklist.mdc**,并参考 **SKILL-变更关联检查.md**。
|
||||
|
||||
---
|
||||
|
||||
@@ -11,9 +11,13 @@ alwaysApply: false
|
||||
- **一律使用 GORM 进行数据读写**,通过 `database.DB()` 获取 `*gorm.DB`,操作集中在 `internal/model` 中定义的模型上。
|
||||
- **禁止**在 handler 中手写 `db.Exec("INSERT ...")`、`db.Raw("SELECT ...")` 等裸 SQL,除非满足下方「例外」。
|
||||
- 常规 CRUD 必须用 GORM 链式 API:
|
||||
- 查询:`db.Where(...).First/Find/Count`、`db.Preload`、`db.Select`、`db.Order`
|
||||
- 查询:`db.Where(...).First/Find/Count`、`db.Preload`、`db.Select`、`db.Order`、`db.Pluck`(单列)、`db.Joins`(关联)
|
||||
- 写入:`db.Create`、`db.Save`、`db.Model(...).Updates(map/struct)`、`db.Where(...).Delete`
|
||||
- 原子更新:用 `gorm.Expr`,例如 `Update("pending_earnings", gorm.Expr("pending_earnings + ?", delta))`,而不是多行 Raw/Exec。
|
||||
- **事务(必用)**:涉及多表写入(如支付回调、分佣、订单+用户同时更新)必须用 `db.Transaction(func(tx *gorm.DB) error { ... })` 包裹,避免部分成功导致数据不一致。
|
||||
- **Preload**:列表返回关联数据(如 orders 带 user)时,优先用 `db.Preload("User").Find(&orders)` 或 `db.Joins` 减少 N+1;无 GORM 关联定义时可用 `Joins` 或 `Where("id IN ?", ids).Find(&users)` 批量查。
|
||||
- **Pluck**:仅需单列时用 `db.Model(&Order{}).Where(...).Pluck("product_id", &ids)`,不写 Raw。
|
||||
- **Scopes**:复杂或重复的查询条件可抽成 `func(db *gorm.DB) *gorm.DB` 的 Scopes,便于复用。
|
||||
- **例外**(允许少量 Raw/Exec):
|
||||
- 单条复杂统计 SQL 且用 GORM 表达冗长时,可用 `db.Raw(...).Scan(&struct)`,并加简短注释说明原因。
|
||||
- 必须用原生 SQL 的原子多列更新(如多字段 `SET a=a+?, b=b+?`)可保留 `db.Exec`,其余尽量改为 `Model().Update(Expr(...))`。
|
||||
@@ -29,11 +33,15 @@ alwaysApply: false
|
||||
|
||||
## 3. 依赖物尽其用
|
||||
|
||||
- **Gin**:入参用 `c.ShouldBindJSON(&req)` + `binding:"required"` 等做校验;统一用 `c.JSON(status, gin.H{...})` 或结构体返回;路由按功能挂在 `router.Setup` 的对应 Group(如 `/admin` 用 `middleware.AdminAuth()`)。
|
||||
- **GORM**:能用链式条件、Scopes、预加载完成的,不写 Raw;事务用 `db.Transaction(func(tx *gorm.DB) error { ... })`。
|
||||
- **Gin**:入参用 `c.ShouldBindJSON(&req)` + `binding` 标签做校验;统一用 `c.JSON(status, gin.H{...})` 或结构体返回;路由按功能挂在 `router.Setup` 的对应 Group(如 `/admin` 用 `middleware.AdminAuth()`)。
|
||||
- **binding 标签**:`required`、`min=N`、`max=N`、`len=N`、`email`、`gte`、`lte` 等(go-playground/validator);金额可用 `binding:"required,gte=0"`,手机号可用 `binding:"required,len=11"`。
|
||||
- **GORM**:能用链式条件、Scopes、Preload、Pluck、Joins 完成的,不写 Raw;多表写入必须用 `db.Transaction`。
|
||||
- **配置**:仅通过 `internal/config` 的 `config.Load()` 读环境变量;业务代码不直接 `os.Getenv`;新配置项加到 `Config` 结构体并在 `Load()` 中解析。
|
||||
- **中间件**:安全头用 `middleware.Secure()`,跨域用 `cors`,限流用 `middleware.NewRateLimiter(...).Middleware()`;新路由按需挂到已有或新 Group,避免重复造轮子。
|
||||
- **微信/支付**:小程序、支付、转账相关统一走 `internal/wechat` 封装,handler 只做参数与结果转换。
|
||||
- **微信/支付**:小程序、支付、转账相关统一走 `internal/wechat` 封装(PowerWeChat),handler 只做参数与结果转换。
|
||||
- **JWT**:管理端鉴权用 `internal/auth` 的 `IssueAdminJWT`、`ParseAdminJWT`、`GetAdminJWTFromRequest`,不手写 token 解析。
|
||||
- **godotenv**:配置加载在 `config.Load()` 内完成,业务代码不直接调。
|
||||
- **Redis**:当前为间接依赖(PowerWeChat 引入),未直接使用;若需分布式缓存或限流,可显式引入并统一封装,避免各处散落。
|
||||
|
||||
## 4. 接口按使用方归类(小程序 vs 管理端)
|
||||
|
||||
|
||||
@@ -1,11 +1,24 @@
|
||||
---
|
||||
description: Soul 创业派对项目整体边界与 Skill 索引,防止子项目互窜
|
||||
description: Soul 创业派对项目整体边界与 Skill 索引,防止子项目互窜;含会话启动自检
|
||||
globs: ["**"]
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Soul 创业派对 - 项目边界与开发约束
|
||||
|
||||
## 会话启动自检(新 Cursor 打开本项目时优先执行)
|
||||
|
||||
当新的 Cursor 会话打开本项目时,**先进行自检**,确保仅沿用本项目的开发风格与配置:
|
||||
|
||||
1. **Rules 与 Skills 范围**:仅使用本项目 `.cursor/rules/` 与 `.cursor/skills/` 下的规则与技能;不套用与本项目无关的全局或其它项目的 rules/skills(如存客宝AI、React 转 Vue、Next 全栈拆分等与本项目无关的能力)。
|
||||
2. **开发风格**:按当前编辑目录遵守对应 boundary 与 Skill(miniprogram → 小程序规范;soul-admin → 管理端规范;soul-api → API 规范);API 路径、路由分组、变更检查清单等均以本规则与 `.cursor/README.md` 为准。
|
||||
3. **配置参数**:baseUrl、鉴权方式、路由前缀(`/api/miniprogram/*`、`/api/admin/*`、`/api/db/*`)等以项目内实际配置为准,不引入外部项目的默认值或约定。
|
||||
4. **清理无关项**:若发现会话上下文中存在与本项目无关的 rules 或 skills 引用,应忽略或排除,仅以本项目 `.cursor` 为准。
|
||||
|
||||
自检通过后,再按「项目组成」「防互窜原则」「开发时」执行后续开发。
|
||||
|
||||
---
|
||||
|
||||
## 项目组成
|
||||
|
||||
| 子项目 | 目录 | 用途 | 后端对接 |
|
||||
|
||||
@@ -33,9 +33,22 @@
|
||||
## 3. 数据访问与 Model
|
||||
|
||||
- **一律使用 GORM**,通过 `database.DB()` 获取 `*gorm.DB`;禁止在 handler 中手写裸 SQL(除文档允许的少数统计等例外)。
|
||||
- **事务**:多表写入(支付回调、分佣、订单+用户)必须用 `db.Transaction` 包裹。
|
||||
- **Preload / Joins / Pluck**:列表带关联用 Preload 或 Joins 减少 N+1;单列用 Pluck 替代 Raw;复杂条件抽 Scopes。
|
||||
- **Model**:所有表对应结构体在 `internal/model`,带 `gorm` 与 `json` 标签;不对外暴露字段用 `json:"-"`。
|
||||
- **配置**:仅通过 `internal/config` 的 `Load()` 读环境变量;业务代码不直接 `os.Getenv`。
|
||||
|
||||
## 3.1 依赖物尽其用(补充)
|
||||
|
||||
| 依赖 | 用途 | 约定 |
|
||||
|------|------|------|
|
||||
| **Gin** | 路由、绑定、响应 | `ShouldBindJSON` + `binding:"required"` 等;金额 `gte=0`、手机号 `len=11`、`email` 等 |
|
||||
| **GORM** | 数据访问 | Transaction、Preload、Pluck、Scopes、Joins;原子更新用 `gorm.Expr` |
|
||||
| **PowerWeChat** | 微信/支付/转账 | 统一走 `internal/wechat`,handler 只做参数转换 |
|
||||
| **JWT** | 管理端鉴权 | 用 `internal/auth`,不手写解析 |
|
||||
| **中间件** | 安全、跨域、限流 | Secure、cors、RateLimiter;新路由挂到已有 Group |
|
||||
| **Redis** | 缓存(可选) | 当前未用;若引入需统一封装 |
|
||||
|
||||
---
|
||||
|
||||
## 4. 响应与错误
|
||||
|
||||
187
.cursor/skills/SKILL-Next全栈拆解为前后端分离与小程序.md
Normal file
187
.cursor/skills/SKILL-Next全栈拆解为前后端分离与小程序.md
Normal file
@@ -0,0 +1,187 @@
|
||||
# Next.js 全栈项目拆解为前后端分离 + 小程序
|
||||
|
||||
当你有类似 **next-project** 的 Next.js 全栈项目(app/api + app/view + app/admin),需要拆解为**前后端分离 + 小程序**架构时,使用本 Skill。本 Skill 基于 next-project 实际代码结构归纳,目标架构参考本仓库的 soul-api、soul-admin、miniprogram。
|
||||
|
||||
---
|
||||
|
||||
## 1. 何时使用本 Skill
|
||||
|
||||
- 有 Next.js 全栈项目(API 路由 + 用户端页面 + 管理端页面)
|
||||
- 需要拆分为:**独立后端 API** + **管理端前端** + **微信小程序**(可选保留 Web 用户端)
|
||||
- 后续会有类似项目需要按同一模式拆解
|
||||
|
||||
---
|
||||
|
||||
## 2. 原项目结构识别(以 next-project 为例)
|
||||
|
||||
```
|
||||
next-project/
|
||||
├── app/
|
||||
│ ├── api/ # API 路由(后端逻辑)
|
||||
│ │ ├── admin/ # 管理端接口
|
||||
│ │ ├── miniprogram/ # 小程序专属接口(登录、支付、二维码等)
|
||||
│ │ ├── user/ # 用户相关(profile、addresses、purchase-status)
|
||||
│ │ ├── payment/ # 支付、回调
|
||||
│ │ ├── referral/ # 推荐、绑定
|
||||
│ │ ├── db/ # 数据/配置(管理端)
|
||||
│ │ ├── book/ # 书籍、章节
|
||||
│ │ ├── ckb/ # 找伙伴等业务
|
||||
│ │ └── ...
|
||||
│ ├── view/ # 用户端 Web 页面(C 端)
|
||||
│ │ ├── my/ # 个人中心、推荐、地址、购买记录
|
||||
│ │ ├── match/ # 找伙伴
|
||||
│ │ ├── login/
|
||||
│ │ └── ...
|
||||
│ └── admin/ # 管理端页面
|
||||
│ ├── layout.tsx
|
||||
│ ├── users/
|
||||
│ ├── withdrawals/
|
||||
│ └── ...
|
||||
├── lib/
|
||||
│ ├── db.ts # 数据库(mysql2)
|
||||
│ ├── payment/ # 支付逻辑
|
||||
│ ├── wechat-transfer.ts # 微信转账
|
||||
│ ├── store.ts # Zustand 状态
|
||||
│ └── ...
|
||||
└── components/
|
||||
```
|
||||
|
||||
**关键点**:API 与页面同域,view/admin 通过 `fetch('/api/...')` 调用;小程序需单独对接,路径需与小程序约定一致。
|
||||
|
||||
---
|
||||
|
||||
## 3. 目标架构(前后端分离 + 小程序)
|
||||
|
||||
| 原 next-project 部分 | 拆分后 | 技术栈 |
|
||||
|---------------------|--------|--------|
|
||||
| **app/api/** 全部逻辑 | **soul-api**(独立后端) | Go + Gin + GORM |
|
||||
| **app/admin/** 页面 | **soul-admin**(管理端前端) | React + Vite + Tailwind |
|
||||
| **用户端** | **miniprogram**(微信小程序) | 微信原生 WXML/JS |
|
||||
| **app/view/**(可选) | 可废弃或改为独立 SPA | 若保留:React/Vue + Vite |
|
||||
|
||||
**接口约定**:
|
||||
- 管理端:`/api/admin/*`、`/api/db/*`、`/api/orders` 等
|
||||
- 小程序:`/api/miniprogram/*`(登录、支付、书籍、用户、提现等)
|
||||
- 路径与 next-project 的 `app/api` 对应,仅 baseUrl 改为 soul-api 地址
|
||||
|
||||
---
|
||||
|
||||
## 4. 拆解流程 Checklist
|
||||
|
||||
```
|
||||
阶段1: 分析
|
||||
- [ ] 列出 app/api 下所有 route.ts,按使用方分类(admin / miniprogram / 共用)
|
||||
- [ ] 识别 lib/ 中数据库、支付、微信等依赖
|
||||
- [ ] 记录 API 路径与请求/响应格式(便于迁移时保持一致)
|
||||
|
||||
阶段2: 创建后端(soul-api)
|
||||
- [ ] 初始化 Go 项目,GORM + Gin
|
||||
- [ ] 按模块迁移 handler(auth、user、payment、referral、admin、miniprogram)
|
||||
- [ ] 路由按使用方挂到 admin / db / miniprogram 组
|
||||
- [ ] 数据库用 GORM model,JSON 用 camelCase
|
||||
|
||||
阶段3: 创建管理端(soul-admin)
|
||||
- [ ] React + Vite,API 客户端封装(get/post/put/del)
|
||||
- [ ] 迁移 app/admin 页面,fetch 改为调用 soul-api
|
||||
- [ ] 鉴权用 JWT(Authorization: Bearer)
|
||||
|
||||
阶段4: 小程序对接
|
||||
- [ ] 小程序 baseUrl 指向 soul-api
|
||||
- [ ] 所有请求走 /api/miniprogram/*(与 soul-api 的 miniprogram 组一致)
|
||||
- [ ] 登录、支付、书籍、用户、提现等接口路径与 next-project 的 app/api/miniprogram 对应
|
||||
|
||||
阶段5: 验证与收尾
|
||||
- [ ] 管理端、小程序分别联调 soul-api
|
||||
- [ ] 废弃或保留 app/view(若保留需改为调用 soul-api)
|
||||
- [ ] 更新文档与 Rules/Skills
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API 迁移对照(next-project → soul-api)
|
||||
|
||||
| next-project 路径 | soul-api 路径 | 使用方 |
|
||||
|------------------|---------------|--------|
|
||||
| /api/admin, /api/admin/logout | /api/admin, /api/admin/logout | 管理端 |
|
||||
| /api/admin/withdrawals, users, ... | /api/admin/* | 管理端 |
|
||||
| /api/db/users, config, chapters | /api/db/* | 管理端 |
|
||||
| /api/miniprogram/login | /api/miniprogram/login | 小程序 |
|
||||
| /api/miniprogram/pay, pay/notify | /api/miniprogram/pay, pay/notify | 小程序 |
|
||||
| /api/miniprogram/phone, qrcode | /api/miniprogram/phone, qrcode | 小程序 |
|
||||
| /api/user/profile, addresses | /api/miniprogram/user/* | 小程序 |
|
||||
| /api/book/all-chapters, chapter/[id] | /api/miniprogram/book/* | 小程序 |
|
||||
| /api/referral/bind, visit, data | /api/miniprogram/referral/* | 小程序 |
|
||||
| /api/ckb/join, match | /api/miniprogram/ckb/* | 小程序 |
|
||||
| /api/payment/* (回调) | /api/payment/* 或 /api/miniprogram/pay/notify | 后端回调 |
|
||||
|
||||
**原则**:小程序统一走 `/api/miniprogram/*`,管理端走 `/api/admin/*`、`/api/db/*`,避免混用。
|
||||
|
||||
---
|
||||
|
||||
## 6. 数据与依赖迁移要点
|
||||
|
||||
### 6.1 数据库
|
||||
|
||||
- **next-project**:`lib/db.ts` + mysql2,`query(sql, params)` 裸 SQL
|
||||
- **soul-api**:GORM,`database.DB()`,model 在 `internal/model`
|
||||
- **迁移**:将 `query` 调用改为 GORM 链式 API;复杂统计可保留 `db.Raw().Scan()`
|
||||
|
||||
### 6.2 支付与微信
|
||||
|
||||
- **next-project**:`lib/payment/`、`lib/wechat-transfer.ts`
|
||||
- **soul-api**:`internal/wechat/` 封装,handler 只做参数转换
|
||||
- **迁移**:微信登录、支付、转账逻辑迁入 `internal/wechat`,PowerWeChat 或官方 API
|
||||
|
||||
### 6.3 配置
|
||||
|
||||
- **next-project**:`process.env.*`、`lib/db` 内配置
|
||||
- **soul-api**:`internal/config` 的 `Load()`,`.env` 读取
|
||||
|
||||
---
|
||||
|
||||
## 7. 前端迁移要点
|
||||
|
||||
### 7.1 管理端(app/admin → soul-admin)
|
||||
|
||||
- **原**:`fetch('/api/admin', { credentials: 'include' })`,同域
|
||||
- **现**:`get('/api/admin')`,baseUrl 指向 soul-api,header 带 `Authorization: Bearer <token>`
|
||||
- **鉴权**:登录后存 token,401 跳转登录页
|
||||
|
||||
### 7.2 小程序(新建或已有 miniprogram)
|
||||
|
||||
- **原**:若 next-project 曾作小程序后端,则小程序调 next-project 的 `/api/miniprogram/*`
|
||||
- **现**:baseUrl 改为 soul-api,路径保持 `/api/miniprogram/*`
|
||||
- **请求**:统一 `getApp().request(url, options)`,自动带 token
|
||||
|
||||
### 7.3 用户端 Web(app/view,可选)
|
||||
|
||||
- 若**废弃**:用户仅通过小程序使用
|
||||
- 若**保留**:改为独立 SPA,fetch 指向 soul-api,路径需与 soul-api 提供的接口一致(可能需在 soul-api 增加非 miniprogram 的公开接口)
|
||||
|
||||
---
|
||||
|
||||
## 8. 与现有项目 Rules/Skills 的配合
|
||||
|
||||
拆解完成后,开发约束以本仓库为准:
|
||||
|
||||
- **soul-api**:遵守 soul-api-coding.mdc、SKILL-API开发.md
|
||||
- **soul-admin**:遵守 soul-admin-boundary、SKILL-管理端开发.md
|
||||
- **miniprogram**:遵守 soul-miniprogram-boundary、SKILL-小程序开发.md
|
||||
- **next-project**:仅作参考,见 SKILL-next-project仅预览.md
|
||||
|
||||
---
|
||||
|
||||
## 9. 后续类似项目
|
||||
|
||||
遇到新的 Next.js 全栈项目需拆解时:
|
||||
|
||||
1. **先分析**:按「2. 原项目结构识别」梳理 api、view、admin、lib
|
||||
2. **再规划**:按「3. 目标架构」确定后端、管理端、小程序、用户端 Web 的归属
|
||||
3. **按 Checklist**:执行「4. 拆解流程」
|
||||
4. **按对照表**:迁移 API 时参考「5. API 迁移对照」
|
||||
5. **按要点**:数据、支付、前端迁移参考「6」「7」
|
||||
|
||||
---
|
||||
|
||||
**创建时间**:2026-02
|
||||
**参考项目**:next-project、soul-api、soul-admin、miniprogram
|
||||
@@ -78,29 +78,18 @@ func MiniprogramLogin(c *gin.Context) {
|
||||
db.Model(&user).Update("session_key", sessionKey)
|
||||
}
|
||||
|
||||
// 从 orders 表查询真实购买记录
|
||||
// 从 orders 表查询真实购买记录(Pluck 替代 Raw)
|
||||
var purchasedSections []string
|
||||
var orderRows []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
}
|
||||
|
||||
db.Raw(`
|
||||
SELECT DISTINCT product_id
|
||||
FROM orders
|
||||
WHERE user_id = ?
|
||||
AND status = 'paid'
|
||||
AND product_type = 'section'
|
||||
`, user.ID).Scan(&orderRows)
|
||||
|
||||
for _, row := range orderRows {
|
||||
if row.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, row.ProductID)
|
||||
db.Model(&model.Order{}).Where("user_id = ? AND status = ? AND product_type = ?", user.ID, "paid", "section").
|
||||
Distinct("product_id").Pluck("product_id", &purchasedSections)
|
||||
// 过滤空字符串
|
||||
filtered := make([]string, 0, len(purchasedSections))
|
||||
for _, s := range purchasedSections {
|
||||
if s != "" {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
|
||||
if purchasedSections == nil {
|
||||
purchasedSections = []string{}
|
||||
}
|
||||
purchasedSections = filtered
|
||||
|
||||
// 构建返回的用户对象
|
||||
responseUser := map[string]interface{}{
|
||||
@@ -177,7 +166,7 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
OpenID string `json:"openId" binding:"required"`
|
||||
ProductType string `json:"productType" binding:"required"`
|
||||
ProductID string `json:"productId"`
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required,gte=0"`
|
||||
Description string `json:"description"`
|
||||
UserID string `json:"userId"`
|
||||
ReferralCode string `json:"referralCode"`
|
||||
@@ -198,18 +187,11 @@ func miniprogramPayPost(c *gin.Context) {
|
||||
// 查询用户的有效推荐人(先查 binding,再查 referralCode)
|
||||
var referrerID *string
|
||||
if req.UserID != "" {
|
||||
var binding struct {
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
}
|
||||
err := db.Raw(`
|
||||
SELECT referrer_id
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active' AND expiry_date > NOW()
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, req.UserID).Scan(&binding).Error
|
||||
if err == nil && binding.ReferrerID != "" {
|
||||
referrerID = &binding.ReferrerID
|
||||
var refID string
|
||||
err := db.Model(&model.ReferralBinding{}).Where("referee_id = ? AND status = ? AND expiry_date > ?", req.UserID, "active", time.Now()).
|
||||
Order("binding_date DESC").Limit(1).Pluck("referrer_id", &refID).Error
|
||||
if err == nil && refID != "" {
|
||||
referrerID = &refID
|
||||
}
|
||||
}
|
||||
if referrerID == nil && req.ReferralCode != "" {
|
||||
@@ -381,97 +363,99 @@ func MiniprogramPayNotify(c *gin.Context) {
|
||||
}
|
||||
|
||||
db := database.DB()
|
||||
buyerUserID := attach.UserID
|
||||
if openID != "" {
|
||||
var user model.User
|
||||
if err := db.Where("open_id = ?", openID).First(&user).Error; err == nil {
|
||||
if attach.UserID != "" && user.ID != attach.UserID {
|
||||
fmt.Printf("[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准\n")
|
||||
}
|
||||
buyerUserID = user.ID
|
||||
}
|
||||
}
|
||||
if buyerUserID == "" && attach.UserID != "" {
|
||||
buyerUserID = attach.UserID
|
||||
}
|
||||
|
||||
var order model.Order
|
||||
result := db.Where("order_sn = ?", orderSn).First(&order)
|
||||
if result.Error != nil {
|
||||
fmt.Printf("[PayNotify] 订单不存在,补记订单: %s\n", orderSn)
|
||||
productID := attach.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
productType := attach.ProductType
|
||||
if productType == "" {
|
||||
productType = "unknown"
|
||||
}
|
||||
desc := "支付回调补记订单"
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
order = model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: buyerUserID,
|
||||
OpenID: openID,
|
||||
ProductType: productType,
|
||||
ProductID: &productID,
|
||||
Amount: totalAmount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
TransactionID: &transactionID,
|
||||
PayTime: &now,
|
||||
}
|
||||
if err := db.Create(&order).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 补记订单失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("create order: %w", err)
|
||||
}
|
||||
} else if *order.Status != "paid" {
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
if err := db.Model(&order).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
}).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("update order: %w", err)
|
||||
}
|
||||
fmt.Printf("[PayNotify] 订单状态已更新为已支付: %s\n", orderSn)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
|
||||
}
|
||||
|
||||
if buyerUserID != "" && attach.ProductType != "" {
|
||||
if attach.ProductType == "fullbook" {
|
||||
db.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
|
||||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
|
||||
} else if attach.ProductType == "match" {
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", buyerUserID, orderSn)
|
||||
} else if attach.ProductType == "section" && attach.ProductID != "" {
|
||||
var count int64
|
||||
db.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductID, orderSn,
|
||||
).Count(&count)
|
||||
if count == 0 {
|
||||
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
buyerUserID := attach.UserID
|
||||
if openID != "" {
|
||||
var user model.User
|
||||
if err := tx.Where("open_id = ?", openID).First(&user).Error; err == nil {
|
||||
if attach.UserID != "" && user.ID != attach.UserID {
|
||||
fmt.Printf("[PayNotify] 买家身份校验: attach.userId 与 openId 解析不一致,以 openId 为准\n")
|
||||
}
|
||||
buyerUserID = user.ID
|
||||
}
|
||||
}
|
||||
productID := attach.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
if buyerUserID == "" && attach.UserID != "" {
|
||||
buyerUserID = attach.UserID
|
||||
}
|
||||
db.Where(
|
||||
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductType, productID, orderSn,
|
||||
).Delete(&model.Order{})
|
||||
processReferralCommission(db, buyerUserID, totalAmount, orderSn, &order)
|
||||
}
|
||||
return nil
|
||||
|
||||
var order model.Order
|
||||
result := tx.Where("order_sn = ?", orderSn).First(&order)
|
||||
if result.Error != nil {
|
||||
fmt.Printf("[PayNotify] 订单不存在,补记订单: %s\n", orderSn)
|
||||
productID := attach.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
productType := attach.ProductType
|
||||
if productType == "" {
|
||||
productType = "unknown"
|
||||
}
|
||||
desc := "支付回调补记订单"
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
order = model.Order{
|
||||
ID: orderSn,
|
||||
OrderSN: orderSn,
|
||||
UserID: buyerUserID,
|
||||
OpenID: openID,
|
||||
ProductType: productType,
|
||||
ProductID: &productID,
|
||||
Amount: totalAmount,
|
||||
Description: &desc,
|
||||
Status: &status,
|
||||
TransactionID: &transactionID,
|
||||
PayTime: &now,
|
||||
}
|
||||
if err := tx.Create(&order).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 补记订单失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("create order: %w", err)
|
||||
}
|
||||
} else if *order.Status != "paid" {
|
||||
status := "paid"
|
||||
now := time.Now()
|
||||
if err := tx.Model(&order).Updates(map[string]interface{}{
|
||||
"status": status,
|
||||
"transaction_id": transactionID,
|
||||
"pay_time": now,
|
||||
}).Error; err != nil {
|
||||
fmt.Printf("[PayNotify] 更新订单状态失败: %s, err=%v\n", orderSn, err)
|
||||
return fmt.Errorf("update order: %w", err)
|
||||
}
|
||||
fmt.Printf("[PayNotify] 订单状态已更新为已支付: %s\n", orderSn)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 订单已支付,跳过更新: %s\n", orderSn)
|
||||
}
|
||||
|
||||
if buyerUserID != "" && attach.ProductType != "" {
|
||||
if attach.ProductType == "fullbook" {
|
||||
tx.Model(&model.User{}).Where("id = ?", buyerUserID).Update("has_full_book", true)
|
||||
fmt.Printf("[PayNotify] 用户已购全书: %s\n", buyerUserID)
|
||||
} else if attach.ProductType == "match" {
|
||||
fmt.Printf("[PayNotify] 用户购买匹配次数: %s,订单 %s\n", buyerUserID, orderSn)
|
||||
} else if attach.ProductType == "section" && attach.ProductID != "" {
|
||||
var count int64
|
||||
tx.Model(&model.Order{}).Where(
|
||||
"user_id = ? AND product_type = 'section' AND product_id = ? AND status = 'paid' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductID, orderSn,
|
||||
).Count(&count)
|
||||
if count == 0 {
|
||||
fmt.Printf("[PayNotify] 用户首次购买章节: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
} else {
|
||||
fmt.Printf("[PayNotify] 用户已有该章节的其他已支付订单: %s - %s\n", buyerUserID, attach.ProductID)
|
||||
}
|
||||
}
|
||||
productID := attach.ProductID
|
||||
if productID == "" {
|
||||
productID = "fullbook"
|
||||
}
|
||||
tx.Where(
|
||||
"user_id = ? AND product_type = ? AND product_id = ? AND status = 'created' AND order_sn != ?",
|
||||
buyerUserID, attach.ProductType, productID, orderSn,
|
||||
).Delete(&model.Order{})
|
||||
processReferralCommission(tx, buyerUserID, totalAmount, orderSn, &order)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Printf("[PayNotify] 处理回调失败: %v\n", err)
|
||||
@@ -514,24 +498,9 @@ func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64,
|
||||
}
|
||||
}
|
||||
|
||||
// 查找有效推广绑定
|
||||
type Binding struct {
|
||||
ID int `gorm:"column:id"`
|
||||
ReferrerID string `gorm:"column:referrer_id"`
|
||||
ExpiryDate time.Time `gorm:"column:expiry_date"`
|
||||
PurchaseCount int `gorm:"column:purchase_count"`
|
||||
TotalCommission float64 `gorm:"column:total_commission"`
|
||||
}
|
||||
|
||||
var binding Binding
|
||||
err := db.Raw(`
|
||||
SELECT id, referrer_id, expiry_date, purchase_count, total_commission
|
||||
FROM referral_bindings
|
||||
WHERE referee_id = ? AND status = 'active'
|
||||
ORDER BY binding_date DESC
|
||||
LIMIT 1
|
||||
`, buyerUserID).Scan(&binding).Error
|
||||
|
||||
// 查找有效推广绑定(GORM 替代 Raw)
|
||||
var binding model.ReferralBinding
|
||||
err := db.Where("referee_id = ? AND status = ?", buyerUserID, "active").Order("binding_date DESC").First(&binding).Error
|
||||
if err != nil {
|
||||
fmt.Printf("[PayNotify] 用户无有效推广绑定,跳过分佣: %s\n", buyerUserID)
|
||||
return
|
||||
@@ -545,24 +514,30 @@ func processReferralCommission(db *gorm.DB, buyerUserID string, amount float64,
|
||||
|
||||
// 计算佣金(按原价)
|
||||
commission := commissionBase * distributorShare
|
||||
newPurchaseCount := binding.PurchaseCount + 1
|
||||
newTotalCommission := binding.TotalCommission + commission
|
||||
purchaseCount := 0
|
||||
if binding.PurchaseCount != nil {
|
||||
purchaseCount = *binding.PurchaseCount
|
||||
}
|
||||
totalCommission := 0.0
|
||||
if binding.TotalCommission != nil {
|
||||
totalCommission = *binding.TotalCommission
|
||||
}
|
||||
newPurchaseCount := purchaseCount + 1
|
||||
newTotalCommission := totalCommission + commission
|
||||
|
||||
fmt.Printf("[PayNotify] 处理分佣: referrerId=%s, amount=%.2f, commission=%.2f, shareRate=%.0f%%\n",
|
||||
binding.ReferrerID, amount, commission, distributorShare*100)
|
||||
|
||||
// 更新推广者的待结算收益
|
||||
db.Model(&model.User{}).Where("id = ?", binding.ReferrerID).
|
||||
Update("pending_earnings", db.Raw("pending_earnings + ?", commission))
|
||||
Update("pending_earnings", gorm.Expr("COALESCE(pending_earnings, 0) + ?", commission))
|
||||
|
||||
// 更新绑定记录(COALESCE 避免 total_commission 为 NULL 时 NULL+?=NULL)
|
||||
db.Exec(`
|
||||
UPDATE referral_bindings
|
||||
SET last_purchase_date = NOW(),
|
||||
purchase_count = COALESCE(purchase_count, 0) + 1,
|
||||
total_commission = COALESCE(total_commission, 0) + ?
|
||||
WHERE id = ?
|
||||
`, commission, binding.ID)
|
||||
db.Model(&model.ReferralBinding{}).Where("id = ?", binding.ID).Updates(map[string]interface{}{
|
||||
"last_purchase_date": time.Now(),
|
||||
"purchase_count": gorm.Expr("COALESCE(purchase_count, 0) + 1"),
|
||||
"total_commission": gorm.Expr("COALESCE(total_commission, 0) + ?", commission),
|
||||
})
|
||||
|
||||
fmt.Printf("[PayNotify] 分佣完成: 推广者 %s 获得 %.2f 元(第 %d 次购买,累计 %.2f 元)\n",
|
||||
binding.ReferrerID, commission, newPurchaseCount, newTotalCommission)
|
||||
|
||||
@@ -93,18 +93,16 @@ func WechatPhoneLogin(c *gin.Context) {
|
||||
user.Phone = &phone
|
||||
}
|
||||
|
||||
var orderRows []struct {
|
||||
ProductID string `gorm:"column:product_id"`
|
||||
}
|
||||
db.Raw(`
|
||||
SELECT DISTINCT product_id FROM orders WHERE user_id = ? AND status = 'paid' AND product_type = 'section'
|
||||
`, user.ID).Scan(&orderRows)
|
||||
purchasedSections := []string{}
|
||||
for _, row := range orderRows {
|
||||
if row.ProductID != "" {
|
||||
purchasedSections = append(purchasedSections, row.ProductID)
|
||||
var purchasedSections []string
|
||||
database.DB().Model(&model.Order{}).Where("user_id = ? AND status = ? AND product_type = ?", user.ID, "paid", "section").
|
||||
Distinct("product_id").Pluck("product_id", &purchasedSections)
|
||||
filtered := make([]string, 0, len(purchasedSections))
|
||||
for _, s := range purchasedSections {
|
||||
if s != "" {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
purchasedSections = filtered
|
||||
|
||||
responseUser := map[string]interface{}{
|
||||
"id": user.ID,
|
||||
|
||||
@@ -57,7 +57,7 @@ func generateWithdrawID() string {
|
||||
func WithdrawPost(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID string `json:"userId" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required"`
|
||||
Amount float64 `json:"amount" binding:"required,gte=0"`
|
||||
UserName string `json:"userName"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user