邀请关系
This commit is contained in:
parent
e0db8751f3
commit
994a7ba0df
613
.claude/plan/channel-user-bindding-doc.md
Normal file
613
.claude/plan/channel-user-bindding-doc.md
Normal file
@ -0,0 +1,613 @@
|
||||
# 渠道管理与用户注册绑定调用链文档
|
||||
|
||||
## 概述
|
||||
|
||||
本文档描述 Bindbox Game 项目中渠道管理模块与用户注册绑定的调用链关系。
|
||||
|
||||
**渠道绑定的三种方式:**
|
||||
1. **用户登录时绑定** - 微信/抖音登录时传入 `channel_code`
|
||||
2. **定时任务自动绑定** - 直播间奖品发放时,根据活动关联渠道自动绑定主播邀请人
|
||||
3. **抖音登录绑定** - 抖音小程序登录时传入 `channel_code`
|
||||
|
||||
---
|
||||
|
||||
## 一、数据模型
|
||||
|
||||
### 1.1 渠道表 (channels)
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | 主键ID |
|
||||
| name | string | 渠道名称 |
|
||||
| code | string | 渠道唯一标识(用于登录时绑定) |
|
||||
| type | string | 渠道类型 |
|
||||
| remarks | string | 备注 |
|
||||
| created_at | time | 创建时间 |
|
||||
| updated_at | time | 更新时间 |
|
||||
| deleted_at | time | 删除时间(软删) |
|
||||
|
||||
**文件位置**: `internal/repository/mysql/model/channels.gen.go:16-25`
|
||||
|
||||
### 1.2 用户表 (users) - 渠道相关字段
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | int64 | 主键ID |
|
||||
| channel_id | int64 | 渠道ID(关联 channels.id) |
|
||||
| invite_code | string | 用户唯一邀请码 |
|
||||
| inviter_id | int64 | 邀请人用户ID |
|
||||
| openid | string | 微信openid |
|
||||
| unionid | string | 微信unionid |
|
||||
|
||||
**文件位置**: `internal/repository/mysql/model/users.gen.go:16-33`
|
||||
|
||||
---
|
||||
|
||||
## 二、调用链架构图
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 前端/客户端 │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ API 路由层 │
|
||||
│ internal/router/router.go │
|
||||
│ │
|
||||
│ 渠道管理路由: │
|
||||
│ - POST /api/admin/channels → CreateChannel() │
|
||||
│ - PUT /api/admin/channels/:id → ModifyChannel() │
|
||||
│ - DELETE /api/admin/channels/:id → DeleteChannel() │
|
||||
│ - GET /api/admin/channels → ListChannels() │
|
||||
│ - GET /api/admin/channels/:id/stats → ChannelStats() │
|
||||
│ │
|
||||
│ 用户登录路由: │
|
||||
│ - POST /api/app/users/weixin/login → WeixinLogin() │
|
||||
│ (携带 channel_code 参数) │
|
||||
│ - POST /api/app/users/douyin/login → DouyinLogin() │
|
||||
│ (携带 channel_code 参数) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
┌─────────────────────┼─────────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────┐ ┌───────────────────┐ ┌───────────────────────┐
|
||||
│ 渠道管理 API 层 │ │ 用户登录 API 层 │ │ 定时任务调度器 │
|
||||
│ internal/api/admin/ │ │ internal/api/user/│ │ internal/service/ │
|
||||
│ channels.go │ │ login_app.go │ │ douyin/scheduler.go │
|
||||
│ │ │ login_douyin_app │ │ │
|
||||
│ - CreateChannel() │ │ - WeixinLogin() │ │ - GrantLivestreamPrizes()
|
||||
│ - ModifyChannel() │ │ - DouyinLogin() │ │ - bindAnchorInviter │
|
||||
│ - DeleteChannel() │ │ 接收channel_code│ │ IfNeeded() │
|
||||
│ - ListChannels() │ │ │ │ │
|
||||
│ - ChannelStats() │ │ │ │ 每5分钟自动执行 │
|
||||
└─────────────────────────┘ └───────────────────┘ └───────────────────────┘
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────────────┐ ┌───────────────────┐ ┌───────────────────────┐
|
||||
│ 渠道 Service 层 │ │ 用户 Service 层 │ │ 用户 Service 层 │
|
||||
│ internal/service/ │ │ internal/service/ │ │ internal/service/user │
|
||||
│ channel/channel.go │ │ user/ │ │ │
|
||||
│ │ │ login_weixin.go │ │ - BindInviter() │
|
||||
│ - Create() │ │ login_douyin.go │ │ (定时任务调用) │
|
||||
│ - Modify() │ │ │ │ │
|
||||
│ - Delete() │ │ - LoginWeixin() │ │ │
|
||||
│ - List() │ │ - LoginDouyin() │ │ │
|
||||
│ - GetStats() │ │ 查渠道并绑定用户 │ │ │
|
||||
│ - GetByID() │ │ │ │ │
|
||||
└─────────────────────────┘ └───────────────────┘ └───────────────────────┘
|
||||
│ │ │
|
||||
└─────────────────────┼─────────────────────┘
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ 数据访问层 (DAO) │
|
||||
│ internal/repository/mysql/dao/ │
|
||||
│ │
|
||||
│ - channels.gen.go 渠道表操作 │
|
||||
│ - users.gen.go 用户表操作 │
|
||||
│ - user_invites.gen.go 邀请关系表操作 │
|
||||
│ - livestream_activities.gen.go 直播间活动表(含渠道字段) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────────────────────────────────────────────┐
|
||||
│ MySQL 数据库 │
|
||||
│ - channels 表 │
|
||||
│ - users 表 (channel_id 字段) │
|
||||
│ - user_invites 表 │
|
||||
│ - livestream_activities 表 (channel_id, channel_code 字段) │
|
||||
└─────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、详细调用链
|
||||
|
||||
### 3.1 渠道管理(管理端)
|
||||
|
||||
#### 创建渠道
|
||||
|
||||
```
|
||||
HTTP POST /api/admin/channels
|
||||
│
|
||||
├── 参数: { name, code, type, remarks }
|
||||
│
|
||||
▼
|
||||
internal/api/admin/channels.go:26 CreateChannel()
|
||||
│
|
||||
├── 验证参数
|
||||
│
|
||||
▼
|
||||
internal/service/channel/channel.go:79 Create()
|
||||
│
|
||||
├── 创建 Channels 模型
|
||||
│
|
||||
▼
|
||||
dao.Channels.Create(m)
|
||||
│
|
||||
▼
|
||||
MySQL INSERT INTO channels
|
||||
```
|
||||
|
||||
#### 查询渠道列表(含用户数统计)
|
||||
|
||||
```
|
||||
HTTP GET /api/admin/channels
|
||||
│
|
||||
▼
|
||||
internal/api/admin/channels.go:135 ListChannels()
|
||||
│
|
||||
▼
|
||||
internal/service/channel/channel.go:111 List()
|
||||
│
|
||||
├── 1. 查询渠道列表
|
||||
│ SELECT * FROM channels WHERE name LIKE ? ORDER BY id DESC
|
||||
│
|
||||
├── 2. 统计每个渠道的用户数
|
||||
│ SELECT channel_id, count(*) as count
|
||||
│ FROM users
|
||||
│ WHERE channel_id IN (?)
|
||||
│ GROUP BY channel_id
|
||||
│
|
||||
▼
|
||||
返回渠道列表(含 user_count 字段)
|
||||
```
|
||||
|
||||
#### 渠道数据分析
|
||||
|
||||
```
|
||||
HTTP GET /api/admin/channels/:channel_id/stats
|
||||
│
|
||||
▼
|
||||
internal/api/admin/channels.go:53 ChannelStats()
|
||||
│
|
||||
▼
|
||||
internal/service/channel/channel.go:169 GetStats()
|
||||
│
|
||||
├── 1. 统计渠道用户总数
|
||||
│ SELECT count(*) FROM users WHERE channel_id = ?
|
||||
│
|
||||
├── 2. 统计渠道订单数和GMV
|
||||
│ SELECT count(*) as count, sum(actual_amount) as gmv
|
||||
│ FROM orders o
|
||||
│ JOIN users u ON u.id = o.user_id
|
||||
│ WHERE u.channel_id = ? AND o.status = 2
|
||||
│
|
||||
├── 3. 月度用户增长统计
|
||||
│ SELECT DATE_FORMAT(created_at, '%Y-%m') as date, count(*) as count
|
||||
│ FROM users
|
||||
│ WHERE channel_id = ? AND created_at >= ?
|
||||
│ GROUP BY date
|
||||
│
|
||||
├── 4. 月度订单统计
|
||||
│ SELECT DATE_FORMAT(created_at, '%Y-%m') as date, count(*), sum(actual_amount)
|
||||
│ FROM orders o
|
||||
│ JOIN users u ON u.id = o.user_id
|
||||
│ WHERE u.channel_id = ? AND o.status = 2 AND o.created_at >= ?
|
||||
│ GROUP BY date
|
||||
│
|
||||
▼
|
||||
返回渠道统计数据
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.2 用户注册绑定渠道
|
||||
|
||||
#### 微信登录(绑定渠道)
|
||||
|
||||
```
|
||||
HTTP POST /api/app/users/weixin/login
|
||||
│
|
||||
├── 参数: { code, invite_code, douyin_id, channel_code }
|
||||
│
|
||||
▼
|
||||
internal/api/user/login_app.go:47 WeixinLogin()
|
||||
│
|
||||
├── 1. 微信 code2session 获取 openid/unionid
|
||||
│
|
||||
▼
|
||||
internal/service/user/login_weixin.go:42 LoginWeixin()
|
||||
│
|
||||
├── 2. 查询渠道(如果传入 channel_code)
|
||||
│ ch, _ := s.readDB.Channels.Where(Channels.Code.Eq(in.ChannelCode)).First()
|
||||
│ channelID = ch.ID
|
||||
│ 【文件位置: login_weixin.go:86-92】
|
||||
│
|
||||
├── 3. 查找或创建用户
|
||||
│ ├── 查找: WHERE openid = ? OR unionid = ?
|
||||
│ │
|
||||
│ └── 创建新用户:
|
||||
│ u = &model.Users{
|
||||
│ Nickname: nickname,
|
||||
│ Openid: in.OpenID,
|
||||
│ ChannelID: channelID, // 绑定渠道
|
||||
│ ...
|
||||
│ }
|
||||
│ 【文件位置: login_weixin.go:113-124】
|
||||
│
|
||||
├── 4. 更新已有用户(如果传入 channel_code)
|
||||
│ if channelID > 0 {
|
||||
│ UPDATE users SET channel_id = ? WHERE id = ?
|
||||
│ }
|
||||
│ 【文件位置: login_weixin.go:141-143】
|
||||
│
|
||||
├── 5. 处理邀请关系(如果传入 invite_code 且是新用户)
|
||||
│ 【详见 3.3 节】
|
||||
│
|
||||
▼
|
||||
返回用户信息和 Token
|
||||
```
|
||||
|
||||
**关键代码片段**(`login_weixin.go:86-92`):
|
||||
|
||||
```go
|
||||
// 查找渠道ID
|
||||
var channelID int64
|
||||
if in.ChannelCode != "" {
|
||||
ch, _ := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.Code.Eq(in.ChannelCode)).First()
|
||||
if ch != nil {
|
||||
channelID = ch.ID
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.3 抖音登录(绑定渠道)
|
||||
|
||||
```
|
||||
HTTP POST /api/app/users/douyin/login
|
||||
│
|
||||
├── 参数: { code, anonymous_code, invite_code, channel_code }
|
||||
│
|
||||
▼
|
||||
internal/api/user/login_douyin_app.go:44 DouyinLogin()
|
||||
│
|
||||
├── 参数校验
|
||||
│
|
||||
▼
|
||||
internal/service/user/login_douyin.go:39 LoginDouyin()
|
||||
│
|
||||
├── 1. 抖音 code2session 获取 openid
|
||||
│
|
||||
├── 2. 查询渠道(如果传入 channel_code)
|
||||
│ ch, _ := s.readDB.Channels.Where(Channels.Code.Eq(in.ChannelCode)).First()
|
||||
│ channelID = ch.ID
|
||||
│ 【文件位置: login_douyin.go:91-97】
|
||||
│
|
||||
├── 3. 查找或创建用户
|
||||
│ ├── 查找: WHERE douyin_id = ? OR unionid = ?
|
||||
│ │
|
||||
│ └── 创建新用户:
|
||||
│ u = &model.Users{
|
||||
│ Nickname: nickname,
|
||||
│ DouyinID: openID,
|
||||
│ ChannelID: channelID, // 绑定渠道
|
||||
│ ...
|
||||
│ }
|
||||
│ 【文件位置: login_douyin.go:119-127】
|
||||
│
|
||||
├── 4. 更新已有用户(如果传入 channel_code 且未绑定)
|
||||
│ if channelID > 0 && u.ChannelID == 0 {
|
||||
│ UPDATE users SET channel_id = ?
|
||||
│ }
|
||||
│ 【文件位置: login_douyin.go:143-144】
|
||||
│
|
||||
└── 5. 处理邀请关系(如果传入 invite_code 且是新用户)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.4 邀请关系绑定
|
||||
|
||||
用户绑定邀请人有两种方式:
|
||||
|
||||
#### 方式一:登录时自动绑定(推荐)
|
||||
|
||||
```
|
||||
用户登录时传入 invite_code 参数
|
||||
│
|
||||
▼
|
||||
LoginWeixin() / LoginDouyin() 内部处理
|
||||
│
|
||||
├── 检查是否新用户
|
||||
│
|
||||
├── 查找邀请人(通过 invite_code)
|
||||
│
|
||||
├── 创建 user_invites 记录
|
||||
│
|
||||
├── 更新 users.inviter_id
|
||||
│
|
||||
▼
|
||||
触发任务中心奖励: task.OnInviteSuccess()
|
||||
```
|
||||
|
||||
#### 方式二:用户主动绑定
|
||||
|
||||
```
|
||||
HTTP POST /api/app/users/inviter/bind
|
||||
│
|
||||
├── 参数: { invite_code }
|
||||
│
|
||||
▼
|
||||
internal/api/user/bind_inviter_app.go:34 BindInviter()
|
||||
│
|
||||
▼
|
||||
internal/service/user/bind_inviter.go:33 BindInviter()
|
||||
│
|
||||
├── 1. 加锁获取当前用户
|
||||
│
|
||||
├── 2. 检查是否已绑定(inviter_id != 0 则拒绝)
|
||||
│
|
||||
├── 3. 查找邀请人
|
||||
│
|
||||
├── 4. 创建 user_invites 记录
|
||||
│
|
||||
├── 5. 更新 users.inviter_id
|
||||
│
|
||||
▼
|
||||
触发任务中心奖励: task.OnInviteSuccess()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3.5 定时任务自动绑定渠道(直播间奖品发放)
|
||||
|
||||
**重要场景:直播间用户通过渠道绑定主播邀请人**
|
||||
|
||||
```
|
||||
定时任务 (每5分钟)
|
||||
│
|
||||
▼
|
||||
internal/service/douyin/scheduler.go:24 StartDouyinOrderSync()
|
||||
│
|
||||
├── ticker5min.C 触发
|
||||
│
|
||||
▼
|
||||
internal/service/douyin/scheduler.go:155 GrantLivestreamPrizes()
|
||||
│
|
||||
├── 1. 查找未发放的直播抽奖记录
|
||||
│ SELECT * FROM livestream_draw_logs WHERE is_granted = 0
|
||||
│
|
||||
├── 2. 解析活动关联的渠道/主播邀请码
|
||||
│ resolveActivityAnchorCodes()
|
||||
│ 【文件位置: scheduler.go:418-489】
|
||||
│
|
||||
│ ├── 查询直播间活动的渠道信息
|
||||
│ │ SELECT id, channel_id, channel_code
|
||||
│ │ FROM livestream_activities
|
||||
│ │ WHERE id IN (?)
|
||||
│ │ 【文件位置: scheduler.go:451-458】
|
||||
│ │
|
||||
│ └── 补充缺失的渠道 code
|
||||
│ fetchChannelCodes()
|
||||
│ SELECT id, code FROM channels WHERE id IN (?)
|
||||
│ 【文件位置: scheduler.go:491-513】
|
||||
│
|
||||
├── 3. 自动绑定主播邀请人(如果用户未绑定)
|
||||
│ bindAnchorInviterIfNeeded(ctx, userID, anchorCode)
|
||||
│ 【文件位置: scheduler.go:515-546】
|
||||
│
|
||||
│ ├── 查询用户是否已有邀请人
|
||||
│ │ SELECT inviter_id FROM users WHERE id = ?
|
||||
│ │
|
||||
│ └── 如果 inviter_id == 0,调用绑定服务
|
||||
│ s.userSvc.BindInviter(ctx, userID, BindInviterInput{InviteCode: anchorCode})
|
||||
│ 【文件位置: scheduler.go:534】
|
||||
│
|
||||
└── 4. 发放奖品并更新状态
|
||||
```
|
||||
|
||||
**关键代码:自动绑定主播邀请人**
|
||||
|
||||
```go
|
||||
// scheduler.go:515-546
|
||||
func (s *service) bindAnchorInviterIfNeeded(ctx context.Context, userID int64, anchorCode string) {
|
||||
// 1. 检查用户是否已有邀请人
|
||||
userRecord, err := s.readDB.Users.WithContext(ctx).
|
||||
Select(s.readDB.Users.InviterID).
|
||||
Where(s.readDB.Users.ID.Eq(userID)).
|
||||
First()
|
||||
if userRecord.InviterID != 0 {
|
||||
return // 已绑定,跳过
|
||||
}
|
||||
|
||||
// 2. 自动绑定主播邀请人
|
||||
s.userSvc.BindInviter(ctx, userID, user.BindInviterInput{InviteCode: anchorCode})
|
||||
}
|
||||
```
|
||||
|
||||
**数据流:**
|
||||
|
||||
```
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ 直播间活动配置 │
|
||||
│ livestream_activities │
|
||||
│ ├── channel_id (关联渠道ID) │
|
||||
│ └── channel_code (主播邀请码) │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ 直播抽奖记录 │
|
||||
│ livestream_draw_logs │
|
||||
│ ├── activity_id (关联活动) │
|
||||
│ ├── local_user_id (本地用户ID) │
|
||||
│ └── is_granted (发放状态) │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ 定时任务处理 │
|
||||
│ GrantLivestreamPrizes() │
|
||||
│ │
|
||||
│ 1. 查 activity → 获取 channel_code │
|
||||
│ 2. 查 channels → 补充缺失的 code │
|
||||
│ 3. 查 users.inviter_id → 检查是否已绑定 │
|
||||
│ 4. 未绑定 → 调用 BindInviter() 绑定主播 │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────────────────────────────────────────────────────┐
|
||||
│ 用户邀请关系 │
|
||||
│ users.inviter_id = 主播用户ID │
|
||||
│ user_invites 表新增记录 │
|
||||
└───────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、核心文件索引
|
||||
|
||||
| 文件路径 | 说明 | 关键函数 |
|
||||
|----------|------|----------|
|
||||
| `internal/api/admin/channels.go` | 渠道管理 API | CreateChannel, ListChannels, ChannelStats |
|
||||
| `internal/service/channel/channel.go` | 渠道业务逻辑 | Create, List, GetStats |
|
||||
| `internal/api/user/login_app.go` | 用户登录 API (微信) | WeixinLogin |
|
||||
| `internal/service/user/login_weixin.go` | 微信登录逻辑 | LoginWeixin(渠道绑定核心) |
|
||||
| `internal/api/user/login_douyin_app.go` | 用户登录 API (抖音) | DouyinLogin |
|
||||
| `internal/service/user/login_douyin.go` | 抖音登录逻辑 | LoginDouyin(渠道绑定核心) |
|
||||
| `internal/api/user/bind_inviter_app.go` | 绑定邀请人 API | BindInviter |
|
||||
| `internal/service/user/bind_inviter.go` | 绑定邀请人逻辑 | BindInviter |
|
||||
| **`internal/service/douyin/scheduler.go`** | **抖音定时任务** | **GrantLivestreamPrizes, bindAnchorInviterIfNeeded** |
|
||||
| `internal/repository/mysql/model/channels.gen.go` | 渠道模型 | Channels struct |
|
||||
| `internal/repository/mysql/model/users.gen.go` | 用户模型 | Users struct(含 channel_id) |
|
||||
| `internal/repository/mysql/model/user_invites.gen.go` | 邀请关系模型 | UserInvites struct |
|
||||
| `internal/repository/mysql/model/livestream_activities.gen.go` | 直播间活动模型 | LivestreamActivities(含 channel_id, channel_code) |
|
||||
| `internal/router/router.go` | 路由配置 | 渠道路由: 215-219 行 |
|
||||
|
||||
---
|
||||
|
||||
## 五、数据流图
|
||||
|
||||
### 5.1 用户注册绑定渠道流程
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────────┐ ┌────────────────┐
|
||||
│ 前端 │────▶│ 微信登录 API │────▶│ 用户 Service │
|
||||
│ │ │ │ │ │
|
||||
│ channel_ │ │ code │ │ 1. code2session│
|
||||
│ code │ │ invite_code │ │ 2. 查渠道 │
|
||||
└──────────┘ └──────────────┘ │ 3. 创建/更新用户│
|
||||
│ 4. 绑定邀请人 │
|
||||
└───────┬────────┘
|
||||
│
|
||||
┌─────────────────────────┼─────────────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
|
||||
│ channels 表 │ │ users 表 │ │user_invites表│
|
||||
│ │ │ │ │ │
|
||||
│ code → ID │ │ channel_id │ │ inviter_id │
|
||||
│ │ │ inviter_id │ │ invitee_id │
|
||||
└──────────────┘ └──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
### 5.2 渠道统计查询流程
|
||||
|
||||
```
|
||||
┌──────────┐ ┌──────────────┐ ┌────────────────┐
|
||||
│ 管理后台 │────▶│ 渠道统计 API │────▶│ 渠道 Service │
|
||||
│ │ │ │ │ │
|
||||
│ 选择渠道 │ │ channel_id │ │ 1. 统计用户数 │
|
||||
│ 时间范围 │ │ days │ │ 2. 统计订单数 │
|
||||
└──────────┘ │ start_date │ │ 3. 统计GMV │
|
||||
│ end_date │ │ 4. 月度趋势 │
|
||||
└──────────────┘ └───────┬────────┘
|
||||
│
|
||||
┌─────────────────────────┴────────────────┐
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────┐ ┌──────────────┐
|
||||
│ users 表 │ │ orders 表 │
|
||||
│ │ │ │
|
||||
│ WHERE │ │ JOIN users │
|
||||
│ channel_id=? │ │ WHERE │
|
||||
│ │ │ channel_id=? │
|
||||
└──────────────┘ └──────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、业务规则
|
||||
|
||||
### 6.1 渠道绑定规则
|
||||
|
||||
| 场景 | 触发条件 | 说明 |
|
||||
|------|----------|------|
|
||||
| 微信登录绑定 | 传入 `channel_code` | 查询 channels 表,绑定 channel_id 到用户 |
|
||||
| 抖音登录绑定 | 传入 `channel_code` | 查询 channels 表,仅当用户未绑定时才更新 |
|
||||
| 定时任务绑定 | 直播间活动配置了 `channel_code` | 自动绑定主播邀请人(邀请关系,非渠道) |
|
||||
|
||||
### 6.2 渠道与邀请人的区别
|
||||
|
||||
| 概念 | 字段 | 说明 |
|
||||
|------|------|------|
|
||||
| **渠道 (Channel)** | `users.channel_id` | 用户来源渠道,用于统计分析 |
|
||||
| **邀请人 (Inviter)** | `users.inviter_id` | 邀请该用户注册的人,用于奖励计算 |
|
||||
|
||||
**定时任务场景:**
|
||||
- 直播间活动的 `channel_code` 用作**主播邀请码**
|
||||
- 定时任务调用 `BindInviter()` 绑定的是**邀请关系**,而非渠道
|
||||
- 主播邀请码 = 某个用户的 `invite_code`(通常是主播账号)
|
||||
|
||||
### 6.3 邀请绑定规则
|
||||
|
||||
1. **仅限一次**: 用户绑定邀请人后不可更改
|
||||
2. **不能自邀**: 用户不能邀请自己
|
||||
3. **奖励触发**: 绑定成功后触发任务中心奖励逻辑
|
||||
4. **定时任务补绑**: 直播间用户未绑定邀请人时,自动绑定主播
|
||||
|
||||
### 6.4 权限控制
|
||||
|
||||
渠道管理接口需要以下权限:
|
||||
|
||||
| 操作 | 权限标识 |
|
||||
|------|----------|
|
||||
| 创建渠道 | `channel:create` |
|
||||
| 修改渠道 | `channel:modify` |
|
||||
| 删除渠道 | `channel:delete` |
|
||||
| 查看渠道 | `channel:view` |
|
||||
|
||||
---
|
||||
|
||||
## 七、相关迁移文件
|
||||
|
||||
| 文件 | 说明 |
|
||||
|------|------|
|
||||
| `migrations/20260223_add_channel_fields_to_livestream_activities.sql` | 直播间活动添加渠道字段 |
|
||||
|
||||
---
|
||||
|
||||
## 八、注意事项
|
||||
|
||||
1. **渠道 Code 唯一性**: 渠道的 `code` 字段必须唯一,用于用户登录时匹配
|
||||
2. **统计性能**: 渠道统计涉及多表 JOIN,大数据量时需注意性能优化
|
||||
3. **事务处理**: 用户创建和渠道绑定在同一事务中,保证数据一致性
|
||||
4. **软删除**: 渠道删除为软删除,不影响已绑定用户
|
||||
|
||||
---
|
||||
|
||||
*文档生成时间: 2026-02-27*
|
||||
*项目: bindbox_game*
|
||||
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/cache
|
||||
126
.serena/project.yml
Normal file
126
.serena/project.yml
Normal file
@ -0,0 +1,126 @@
|
||||
# the name by which the project can be referenced within Serena
|
||||
project_name: "bindbox_game"
|
||||
|
||||
|
||||
# list of languages for which language servers are started; choose from:
|
||||
# al bash clojure cpp csharp
|
||||
# csharp_omnisharp dart elixir elm erlang
|
||||
# fortran fsharp go groovy haskell
|
||||
# java julia kotlin lua markdown
|
||||
# matlab nix pascal perl php
|
||||
# php_phpactor powershell python python_jedi r
|
||||
# rego ruby ruby_solargraph rust scala
|
||||
# swift terraform toml typescript typescript_vts
|
||||
# vue yaml zig
|
||||
# (This list may be outdated. For the current list, see values of Language enum here:
|
||||
# https://github.com/oraios/serena/blob/main/src/solidlsp/ls_config.py
|
||||
# For some languages, there are alternative language servers, e.g. csharp_omnisharp, ruby_solargraph.)
|
||||
# Note:
|
||||
# - For C, use cpp
|
||||
# - For JavaScript, use typescript
|
||||
# - For Free Pascal/Lazarus, use pascal
|
||||
# Special requirements:
|
||||
# Some languages require additional setup/installations.
|
||||
# See here for details: https://oraios.github.io/serena/01-about/020_programming-languages.html#language-servers
|
||||
# When using multiple languages, the first language server that supports a given file will be used for that file.
|
||||
# The first language is the default language and the respective language server will be used as a fallback.
|
||||
# Note that when using the JetBrains backend, language servers are not used and this list is correspondingly ignored.
|
||||
languages:
|
||||
- go
|
||||
|
||||
# the encoding used by text files in the project
|
||||
# For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings
|
||||
encoding: "utf-8"
|
||||
|
||||
# The language backend to use for this project.
|
||||
# If not set, the global setting from serena_config.yml is used.
|
||||
# Valid values: LSP, JetBrains
|
||||
# Note: the backend is fixed at startup. If a project with a different backend
|
||||
# is activated post-init, an error will be returned.
|
||||
language_backend:
|
||||
|
||||
# whether to use project's .gitignore files to ignore files
|
||||
ignore_all_files_in_gitignore: true
|
||||
|
||||
# list of additional paths to ignore in this project.
|
||||
# Same syntax as gitignore, so you can use * and **.
|
||||
# Note: global ignored_paths from serena_config.yml are also applied additively.
|
||||
ignored_paths: []
|
||||
|
||||
# whether the project is in read-only mode
|
||||
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||
# Added on 2025-04-18
|
||||
read_only: false
|
||||
|
||||
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||
# Below is the complete list of tools for convenience.
|
||||
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||
# execute `uv run scripts/print_tool_overview.py`.
|
||||
#
|
||||
# * `activate_project`: Activates a project by name.
|
||||
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||
# * `delete_lines`: Deletes a range of lines within a file.
|
||||
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||
# * `execute_shell_command`: Executes a shell command.
|
||||
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||
# Should only be used in settings where the system prompt cannot be set,
|
||||
# e.g. in clients you have no control over, like Claude Desktop.
|
||||
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||
# * `read_file`: Reads a file within the project directory.
|
||||
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||
# * `remove_project`: Removes a project from the Serena configuration.
|
||||
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||
# * `switch_modes`: Activates modes by providing a list of their names
|
||||
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||
excluded_tools: []
|
||||
|
||||
# list of tools to include that would otherwise be disabled (particularly optional tools that are disabled by default)
|
||||
included_optional_tools: []
|
||||
|
||||
# fixed set of tools to use as the base tool set (if non-empty), replacing Serena's default set of tools.
|
||||
# This cannot be combined with non-empty excluded_tools or included_optional_tools.
|
||||
fixed_tools: []
|
||||
|
||||
# list of mode names to that are always to be included in the set of active modes
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the base_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this setting overrides the global configuration.
|
||||
# Set this to [] to disable base modes for this project.
|
||||
# Set this to a list of mode names to always include the respective modes for this project.
|
||||
base_modes:
|
||||
|
||||
# list of mode names that are to be activated by default.
|
||||
# The full set of modes to be activated is base_modes + default_modes.
|
||||
# If the setting is undefined, the default_modes from the global configuration (serena_config.yml) apply.
|
||||
# Otherwise, this overrides the setting from the global configuration (serena_config.yml).
|
||||
# This setting can, in turn, be overridden by CLI parameters (--mode).
|
||||
default_modes:
|
||||
|
||||
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||
# (contrary to the memories, which are loaded on demand).
|
||||
initial_prompt: ""
|
||||
|
||||
# time budget (seconds) per tool call for the retrieval of additional symbol information
|
||||
# such as docstrings or parameter information.
|
||||
# This overrides the corresponding setting in the global configuration; see the documentation there.
|
||||
# If null or missing, use the setting from the global configuration.
|
||||
symbol_info_budget:
|
||||
@ -1,164 +0,0 @@
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"mini-chat/internal/code"
|
||||
"mini-chat/internal/pkg/core"
|
||||
"mini-chat/internal/pkg/miniprogram"
|
||||
"mini-chat/internal/pkg/validation"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type templateRequest struct {
|
||||
AppID string `json:"app_id" binding:"required"` // 微信小程序 AppID
|
||||
}
|
||||
|
||||
type templateResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
AppID string `json:"app_id"` // 小程序 AppID
|
||||
TemplateID string `json:"template_id"` // 模板 ID
|
||||
}
|
||||
|
||||
// GetTemplate 获取微信小程序模板ID
|
||||
// @Summary 获取微信小程序模板ID
|
||||
// @Description 根据 AppID 获取微信小程序的模板ID
|
||||
// @Tags 微信
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body templateRequest true "请求参数"
|
||||
// @Success 200 {object} templateResponse
|
||||
// @Failure 400 {object} code.Failure
|
||||
// @Failure 404 {object} code.Failure
|
||||
// @Failure 500 {object} code.Failure
|
||||
// @Router /api/wechat/template [post]
|
||||
func (h *handler) GetTemplate() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
req := new(templateRequest)
|
||||
res := new(templateResponse)
|
||||
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(
|
||||
http.StatusBadRequest,
|
||||
code.ParamBindError,
|
||||
validation.Error(err),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 根据 AppID 查询小程序信息
|
||||
miniProgram, err := h.readDB.MiniProgram.WithContext(ctx.RequestContext()).
|
||||
Where(h.readDB.MiniProgram.AppID.Eq(req.AppID)).
|
||||
First()
|
||||
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
ctx.AbortWithError(core.Error(
|
||||
http.StatusNotFound,
|
||||
code.ServerError,
|
||||
fmt.Sprintf("未找到 AppID 为 %s 的小程序", req.AppID),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
h.logger.Error(fmt.Sprintf("查询小程序信息失败: %s", err.Error()))
|
||||
ctx.AbortWithError(core.Error(
|
||||
http.StatusInternalServerError,
|
||||
code.ServerError,
|
||||
"查询小程序信息失败",
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查模板ID是否存在
|
||||
if miniProgram.TemplateID == "" {
|
||||
ctx.AbortWithError(core.Error(
|
||||
http.StatusNotFound,
|
||||
code.ServerError,
|
||||
"该小程序未配置模板ID",
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
res.Success = true
|
||||
res.Message = "获取模板ID成功"
|
||||
res.AppID = miniProgram.AppID
|
||||
res.TemplateID = miniProgram.TemplateID
|
||||
|
||||
ctx.Payload(res)
|
||||
}
|
||||
}
|
||||
|
||||
type sendSubscribeMessageRequest struct {
|
||||
AppID string `json:"app_id" binding:"required"` // 微信小程序 AppID
|
||||
TemplateID string `json:"template_id" binding:"required"` // 模板 ID
|
||||
AppSecret string `json:"app_secret" binding:"required"` // 小程序 AppSecret
|
||||
Touser string `json:"touser" binding:"required"` // 接收者(用户)的 openid
|
||||
}
|
||||
|
||||
type sendSubscribeMessageResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// SendSubscribeMessage 发送订阅消息
|
||||
// @Summary 发送订阅消息
|
||||
// @Description 根据模板ID发送订阅消息
|
||||
// @Tags 微信
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body sendSubscribeMessageRequest true "请求参数"
|
||||
// @Success 200 {object} sendSubscribeMessageResponse
|
||||
// @Failure 400 {object} code.Failure
|
||||
// @Failure 404 {object} code.Failure
|
||||
// @Failure 500 {object} code.Failure
|
||||
// @Router /api/wechat/subscribe [post]
|
||||
func (h *handler) SendSubscribeMessage() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
req := new(sendSubscribeMessageRequest)
|
||||
res := new(sendSubscribeMessageResponse)
|
||||
|
||||
// 参数绑定和验证
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(
|
||||
http.StatusBadRequest,
|
||||
code.ParamBindError,
|
||||
validation.Error(err),
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
// 发送模版消息
|
||||
accessToken, err := h.servicesMiniProgram.GetAccessToken(req.AppID, req.AppSecret, ctx)
|
||||
if err != nil {
|
||||
h.logger.Error(fmt.Sprintf("获取access_token失败: %s", err.Error()))
|
||||
} else {
|
||||
sendSubscribeMessageRequest := new(miniprogram.SendSubscribeMessageRequest)
|
||||
sendSubscribeMessageRequest.Touser = req.Touser
|
||||
sendSubscribeMessageRequest.TemplateID = req.TemplateID
|
||||
sendSubscribeMessageRequest.Page = "pages/index/detail?url=1"
|
||||
sendSubscribeMessageRequest.MiniprogramState = "formal" // 需要改成正式版 目前是体验版 跳转小程序类型:developer 为开发版;trial为体验版;formal 为正式版;默认为正式版
|
||||
sendSubscribeMessageRequest.Lang = "zh_CN"
|
||||
sendSubscribeMessageRequest.Data.Thing1.Value = "留言提醒"
|
||||
sendSubscribeMessageRequest.Data.Time2.Value = time.Now().Format("2006-01-02 15:04:05")
|
||||
sendSubscribeMessageRequest.Data.Thing3.Value = "您有一条新的消息..."
|
||||
|
||||
sendSubscribeMessageResponse := new(miniprogram.SendSubscribeMessageResponse)
|
||||
err = miniprogram.SendSubscribeMessage(accessToken, sendSubscribeMessageRequest, sendSubscribeMessageResponse)
|
||||
if err != nil {
|
||||
res.Success = false
|
||||
res.Message = "发送订阅消息失败" + err.Error()
|
||||
h.logger.Error(fmt.Sprintf("发送模版消息失败: %s", err.Error()))
|
||||
} else {
|
||||
res.Success = true
|
||||
res.Message = "订阅消息发送成功"
|
||||
}
|
||||
}
|
||||
|
||||
ctx.Payload(res)
|
||||
}
|
||||
}
|
||||
@ -4,12 +4,13 @@ import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
gormmysql "gorm.io/driver/mysql"
|
||||
)
|
||||
|
||||
func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
|
||||
@ -17,7 +18,7 @@ func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
|
||||
if err != nil {
|
||||
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
|
||||
}
|
||||
gormDB, err := gorm.Open(mysql.New(mysql.Config{
|
||||
gormDB, err := gorm.Open(gormmysql.New(gormmysql.Config{
|
||||
Conn: db,
|
||||
SkipInitializeWithVersion: true,
|
||||
}), &gorm.Config{})
|
||||
@ -29,9 +30,12 @@ func setupMockDB(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
|
||||
|
||||
func newTestService(db *gorm.DB) *service {
|
||||
q := dao.Use(db)
|
||||
// Create a SQLite repo for testing (satisfies mysql.Repo interface)
|
||||
repo, _ := mysql.NewSQLiteRepoForTest()
|
||||
return &service{
|
||||
readDB: q,
|
||||
writeDB: q,
|
||||
repo: repo,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,6 +6,7 @@ import (
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"image/png"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/pkg/douyin"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
@ -87,11 +88,17 @@ func (s *service) LoginDouyin(ctx context.Context, in LoginDouyinInput) (*LoginD
|
||||
}
|
||||
}
|
||||
|
||||
// 查找渠道ID
|
||||
// 查找渠道ID(在事务内使用 tx 查询,避免主从延迟问题)
|
||||
var channelID int64
|
||||
if in.ChannelCode != "" {
|
||||
ch, _ := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.Code.Eq(in.ChannelCode)).First()
|
||||
if ch != nil {
|
||||
ch, err := tx.Channels.WithContext(ctx).Where(tx.Channels.Code.Eq(in.ChannelCode)).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.logger.Warn("渠道不存在或已删除", zap.String("channel_code", in.ChannelCode))
|
||||
} else {
|
||||
s.logger.Error("查询渠道失败", zap.String("channel_code", in.ChannelCode), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
channelID = ch.ID
|
||||
}
|
||||
}
|
||||
@ -154,33 +161,36 @@ func (s *service) LoginDouyin(ctx context.Context, in LoginDouyinInput) (*LoginD
|
||||
// 5. 处理邀请逻辑
|
||||
// 只有在真正创建新用户记录时才发放邀请奖励,防止多账号切换重复刷奖励
|
||||
if in.InviteCode != "" && isNewUser {
|
||||
// 查询邀请人
|
||||
var inviter model.Users
|
||||
// First() 返回 (result, error)
|
||||
inviterResult, err := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First()
|
||||
if err == nil && inviterResult != nil && inviterResult.ID != u.ID {
|
||||
inviter = *inviterResult
|
||||
// 创建邀请记录
|
||||
invite := &model.UserInvites{
|
||||
InviteeID: u.ID, // UserID -> InviteeID
|
||||
InviterID: inviter.ID,
|
||||
InviteCode: in.InviteCode,
|
||||
// Status: 1, // Removed
|
||||
}
|
||||
if err := tx.UserInvites.WithContext(ctx).Create(invite); err != nil {
|
||||
return err
|
||||
}
|
||||
// 检查是否已存在邀请记录(防止重复)
|
||||
existed, _ := tx.UserInvites.WithContext(ctx).Where(tx.UserInvites.InviteeID.Eq(u.ID)).First()
|
||||
if existed == nil {
|
||||
inviter, _ := tx.Users.WithContext(ctx).Where(tx.Users.InviteCode.Eq(in.InviteCode)).First()
|
||||
if inviter != nil && inviter.ID != u.ID {
|
||||
now := time.Now()
|
||||
invite := &model.UserInvites{
|
||||
InviterID: inviter.ID,
|
||||
InviteeID: u.ID,
|
||||
InviteCode: in.InviteCode,
|
||||
RewardPoints: 0,
|
||||
RewardedAt: &now,
|
||||
IsEffective: true,
|
||||
}
|
||||
if err := tx.UserInvites.WithContext(ctx).Create(invite); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 更新被邀请人的邀请人ID
|
||||
// UpdateColumn for single column update
|
||||
if _, err := tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).
|
||||
UpdateColumn(tx.Users.InviterID, inviter.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 更新被邀请人的邀请人ID(检查是否已有邀请人)
|
||||
if u.InviterID == 0 {
|
||||
if _, err := tx.Users.WithContext(ctx).Where(tx.Users.ID.Eq(u.ID)).
|
||||
UpdateColumn(tx.Users.InviterID, inviter.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// 返回邀请人ID,以便外层触发任务中心逻辑
|
||||
inviterID = inviter.ID
|
||||
s.logger.Info("抖音登录邀请关系建立成功", zap.Int64("user_id", u.ID), zap.Int64("inviter_id", inviter.ID))
|
||||
// 返回邀请人ID,以便外层触发任务中心逻辑
|
||||
inviterID = inviter.ID
|
||||
s.logger.Info("抖音登录邀请关系建立成功", zap.Int64("user_id", u.ID), zap.Int64("inviter_id", inviter.ID))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -82,11 +82,17 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*LoginW
|
||||
}
|
||||
}
|
||||
|
||||
// 查找渠道ID
|
||||
// 查找渠道ID(在事务内使用 tx 查询,避免主从延迟问题)
|
||||
var channelID int64
|
||||
if in.ChannelCode != "" {
|
||||
ch, _ := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.Code.Eq(in.ChannelCode)).First()
|
||||
if ch != nil {
|
||||
ch, err := tx.Channels.WithContext(ctx).Where(tx.Channels.Code.Eq(in.ChannelCode)).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
s.logger.Warn("渠道不存在或已删除", zap.String("channel_code", in.ChannelCode))
|
||||
} else {
|
||||
s.logger.Error("查询渠道失败", zap.String("channel_code", in.ChannelCode), zap.Error(err))
|
||||
}
|
||||
} else {
|
||||
channelID = ch.ID
|
||||
}
|
||||
}
|
||||
@ -138,7 +144,8 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*LoginW
|
||||
set["openid"] = in.OpenID
|
||||
u.Openid = in.OpenID
|
||||
}
|
||||
if channelID > 0 {
|
||||
// 只有未绑定渠道的用户才更新渠道(避免覆盖已有渠道)
|
||||
if channelID > 0 && u.ChannelID == 0 {
|
||||
set["channel_id"] = channelID
|
||||
}
|
||||
if len(set) > 0 {
|
||||
@ -162,6 +169,7 @@ func (s *service) LoginWeixin(ctx context.Context, in LoginWeixinInput) (*LoginW
|
||||
InviteCode: in.InviteCode,
|
||||
RewardPoints: 0,
|
||||
RewardedAt: &now,
|
||||
IsEffective: true,
|
||||
}
|
||||
if err := tx.UserInvites.WithContext(ctx).Create(inv); err != nil {
|
||||
return err
|
||||
|
||||
@ -3,13 +3,65 @@ package user
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestRequestShippings_DedupAndSkip(t *testing.T) {
|
||||
s := New(nil, nil)
|
||||
// s.readDB/s.writeDB are nil in this placeholder; this test is a placeholder to ensure compilation.
|
||||
_, _, _, _, _, err := s.RequestShippings(context.Background(), 1, []int64{0, 1, 1}, nil)
|
||||
func setupMockDBForShipping(t *testing.T) (*gorm.DB, sqlmock.Sqlmock) {
|
||||
db, mock, err := sqlmock.New()
|
||||
if err != nil {
|
||||
// no real DB; just ensure function can be called
|
||||
t.Fatalf("an error '%s' was not expected when opening a stub database connection", err)
|
||||
}
|
||||
gormDB, err := gorm.Open(mysql.New(mysql.Config{
|
||||
Conn: db,
|
||||
SkipInitializeWithVersion: true,
|
||||
}), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("an error '%s' was not expected when opening gorm database", err)
|
||||
}
|
||||
return gormDB, mock
|
||||
}
|
||||
|
||||
func TestRequestShippings_EmptyInventoryIDs(t *testing.T) {
|
||||
db, _ := setupMockDBForShipping(t)
|
||||
svc := newTestService(db)
|
||||
|
||||
// Empty inventory IDs should return failed with "invalid_params"
|
||||
_, _, _, _, failed, err := svc.RequestShippings(context.Background(), 1, []int64{}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, failed, 1)
|
||||
assert.Equal(t, "invalid_params", failed[0].Reason)
|
||||
}
|
||||
|
||||
func TestRequestShippings_AllZeroInventoryIDs(t *testing.T) {
|
||||
db, _ := setupMockDBForShipping(t)
|
||||
svc := newTestService(db)
|
||||
|
||||
// All zero or negative IDs should be filtered, resulting in empty uniq list
|
||||
_, _, _, _, failed, err := svc.RequestShippings(context.Background(), 1, []int64{0, -1, 0}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, failed, 1)
|
||||
assert.Equal(t, "invalid_params", failed[0].Reason)
|
||||
}
|
||||
|
||||
func TestRequestShippings_NoDefaultAddress(t *testing.T) {
|
||||
db, mock := setupMockDBForShipping(t)
|
||||
svc := newTestService(db)
|
||||
|
||||
// Mock default address query - return no rows
|
||||
mock.ExpectQuery("SELECT .* FROM `user_addresses`").
|
||||
WillReturnRows(sqlmock.NewRows(nil))
|
||||
|
||||
// Mock all addresses query - return empty
|
||||
mock.ExpectQuery("SELECT .* FROM `user_addresses`").
|
||||
WillReturnRows(sqlmock.NewRows(nil))
|
||||
|
||||
// With valid IDs but no address, should return no_default_address error
|
||||
_, _, _, _, failed, err := svc.RequestShippings(context.Background(), 1, []int64{1, 2}, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, failed, 1)
|
||||
assert.Equal(t, "no_default_address", failed[0].Reason)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user