邀请关系

This commit is contained in:
win 2026-02-27 17:51:38 +08:00
parent e0db8751f3
commit 994a7ba0df
8 changed files with 853 additions and 203 deletions

View 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
View File

@ -0,0 +1 @@
/cache

126
.serena/project.yml Normal file
View 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:

View File

@ -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)
}
}

View File

@ -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,
}
}

View File

@ -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))
}
}
}

View File

@ -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

View File

@ -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)
}