Compare commits

..

No commits in common. "main" and "zuncle" have entirely different histories.
main ... zuncle

68 changed files with 889 additions and 7451 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -1,404 +0,0 @@
# 📋 实施计划:扫雷排行榜管理后台 + 去除免费模式
## 背景理解
用户说明:
1. **没有免费模式**`minesweeper_free` 这个 game_type 已废弃,前后端都要移除
2. **排行榜需要在管理后台展示**:当前排行榜只有 App 端接口,管理后台缺少排行榜 Tab
3. **"积分"含义模糊**:排行榜里的 `total_rank_points` 字段是"游戏对战分"不是平台积分points页面上要加说明
---
## 任务类型
- [x] 全栈(后端 + 前端并行)
---
## 技术方案
### 后端
`internal/api/game/handler.go` 新增一个 Admin 专用排行榜接口:
- `GET /api/admin/games/leaderboard` — 管理后台查排行榜(分页、支持搜索用户昵称)
- `GET /api/admin/games/records` — 管理后台查每局对战记录(分页、支持按用户/时间筛选)
两个接口都走读库,无需鉴权以外的特殊处理。
### 前端
`web/admin/src/views/operations/minesweeper/index.vue` 新增两个 Tab
- **排行榜 Tab**:表格展示所有玩家的排行数据,含"对战分"说明
- **对战记录 Tab**:按局查每场游戏的明细
同时去掉前端中所有 `minesweeper_free` 的相关逻辑和 `game_type` 切换选项。
---
## 实施步骤
### Step 1 — 后端:新增 Admin 排行榜接口
**文件**: `internal/api/game/handler.go`(在现有 Admin API 区域末尾追加)
```go
// GetAdminLeaderboard Admin查询扫雷排行榜
// @Router /api/admin/games/leaderboard [get]
func (h *handler) GetAdminLeaderboard() core.HandlerFunc {
return func(ctx core.Context) {
var req struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Nickname string `form:"nickname"` // 可选:按昵称模糊搜索
}
_ = ctx.ShouldBindQuery(&req)
if req.Page <= 0 { req.Page = 1 }
if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 20 }
offset := (req.Page - 1) * req.PageSize
type row struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
TotalRankPoints int64 `json:"total_rank_points"`
MatchesPlayed int `json:"matches_played"`
Wins int `json:"wins"`
Losses int `json:"losses"`
WinRate float64 `json:"win_rate"`
BestScore int `json:"best_score"`
AvgScore float64 `json:"avg_score"`
}
query := h.db.GetDbR().Table("minesweeper_leaderboard l").
Select("l.user_id, COALESCE(u.nick_name,'') AS nickname, COALESCE(u.avatar_url,'') AS avatar, l.total_rank_points, l.matches_played, l.wins, l.losses, CAST(l.win_rate AS DECIMAL(7,4)) AS win_rate, l.best_score, CAST(l.avg_score AS DECIMAL(12,2)) AS avg_score").
Joins("LEFT JOIN users u ON u.id = l.user_id").
Where("l.game_type = ?", "minesweeper")
if req.Nickname != "" {
query = query.Where("u.nick_name LIKE ?", "%"+req.Nickname+"%")
}
var total int64
query.Count(&total)
var rows []row
query.Order("l.total_rank_points DESC, l.wins DESC, l.best_score DESC").
Limit(req.PageSize).Offset(offset).Scan(&rows)
// 补名次
list := make([]map[string]any, 0, len(rows))
for i, r := range rows {
list = append(list, map[string]any{
"rank": offset + i + 1,
"user_id": r.UserID,
"nickname": r.Nickname,
"avatar": r.Avatar,
"total_rank_points": r.TotalRankPoints,
"matches_played": r.MatchesPlayed,
"wins": r.Wins,
"losses": r.Losses,
"win_rate": r.WinRate,
"best_score": r.BestScore,
"avg_score": r.AvgScore,
})
}
ctx.Payload(map[string]any{
"total": total,
"page": req.Page,
"page_size": req.PageSize,
"list": list,
})
}
}
// GetAdminGameRecords Admin查询扫雷对战记录
// @Router /api/admin/games/records [get]
func (h *handler) GetAdminGameRecords() core.HandlerFunc {
return func(ctx core.Context) {
var req struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
UserID int64 `form:"user_id"`
MatchID string `form:"match_id"`
}
_ = ctx.ShouldBindQuery(&req)
if req.Page <= 0 { req.Page = 1 }
if req.PageSize <= 0 || req.PageSize > 100 { req.PageSize = 20 }
offset := (req.Page - 1) * req.PageSize
type row struct {
ID int64 `json:"id"`
MatchID string `json:"match_id"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
IsWinner bool `json:"is_winner"`
RankPosition int `json:"rank_position"`
TotalPlayers int `json:"total_players"`
Score int `json:"score"`
DamageDealt int `json:"damage_dealt"`
ChestsCollected int `json:"chests_collected"`
RankPoints int `json:"rank_points"`
SettledAt string `json:"settled_at"`
}
query := h.db.GetDbR().Table("minesweeper_game_records r").
Select("r.id, r.match_id, r.user_id, COALESCE(u.nick_name,'') AS nickname, r.is_winner, r.rank_position, r.total_players, r.score, r.damage_dealt, r.chests_collected, r.rank_points, r.settled_at").
Joins("LEFT JOIN users u ON u.id = r.user_id").
Where("r.game_type = ?", "minesweeper")
if req.UserID > 0 {
query = query.Where("r.user_id = ?", req.UserID)
}
if req.MatchID != "" {
query = query.Where("r.match_id = ?", req.MatchID)
}
var total int64
query.Count(&total)
var rows []row
query.Order("r.settled_at DESC").Limit(req.PageSize).Offset(offset).Scan(&rows)
ctx.Payload(map[string]any{
"total": total,
"page": req.Page,
"page_size": req.PageSize,
"list": rows,
})
}
}
```
### Step 2 — 后端:注册新路由
**文件**: `internal/router/router.go`
在 admin 认证路由区域找到 game 相关路由,追加:
```go
adminAuthApiRouter.GET("/games/leaderboard", gameHandler.GetAdminLeaderboard())
adminAuthApiRouter.GET("/games/records", gameHandler.GetAdminGameRecords())
```
### Step 3 — 后端SettleGame 去掉免费模式分支
**文件**: `internal/api/game/handler.go`
`isFreeMode` 判断相关逻辑仍可保留(对 `minesweeper` 类型无影响),但移除文档/注释中所有 `minesweeper_free` 提及。
实际上后端逻辑本身没问题,如果 Nakama 不再发送 `minesweeper_free` 类型就不会触发,无需修改业务逻辑。
### Step 4 — 前端:在 index.vue 新增"排行榜"Tab
**文件**: `web/admin/src/views/operations/minesweeper/index.vue`
#### 4.1 在 `<el-tabs>` 中新增两个 Tab pane追加在"配置预览"之前)
```html
<!-- 5. 排行榜 -->
<el-tab-pane label="排行榜" name="leaderboard">
<div class="tab-content">
<el-alert
title="对战分说明:对战分是游戏内部的排名积分,与平台积分(商城积分/兑换积分)无关。赢得对局可获得更多对战分,用于在此排行榜中排名。"
type="info"
:closable="false"
class="mb-4"
show-icon
/>
<div class="flex gap-2 mb-4">
<el-input
v-model="lbSearch"
placeholder="搜索玩家昵称"
clearable
style="width: 240px"
@change="fetchLeaderboard"
/>
<el-button @click="fetchLeaderboard">刷新</el-button>
</div>
<el-table :data="lbList" border stripe v-loading="lbLoading">
<el-table-column label="排名" width="70" align="center">
<template #default="scope">
<span :class="scope.row.rank <= 3 ? 'font-bold text-yellow-600' : ''">
{{ scope.row.rank }}
</span>
</template>
</el-table-column>
<el-table-column label="玩家" min-width="140">
<template #default="scope">
<div class="flex items-center gap-2">
<el-avatar :src="scope.row.avatar" :size="28" v-if="scope.row.avatar" />
<span>{{ scope.row.nickname || scope.row.user_id }}</span>
</div>
</template>
</el-table-column>
<el-table-column label="对战分" prop="total_rank_points" width="100" align="right" sortable />
<el-table-column label="场次" prop="matches_played" width="80" align="center" />
<el-table-column label="胜场" prop="wins" width="70" align="center" />
<el-table-column label="胜率" width="80" align="center">
<template #default="scope">{{ (scope.row.win_rate * 100).toFixed(1) }}%</template>
</el-table-column>
<el-table-column label="最高分" prop="best_score" width="90" align="right" />
<el-table-column label="平均分" prop="avg_score" width="90" align="right">
<template #default="scope">{{ Number(scope.row.avg_score).toFixed(1) }}</template>
</el-table-column>
</el-table>
<div class="flex justify-end mt-3">
<el-pagination
v-model:current-page="lbPage"
v-model:page-size="lbPageSize"
:total="lbTotal"
layout="total, prev, pager, next"
@current-change="fetchLeaderboard"
/>
</div>
</div>
</el-tab-pane>
<!-- 6. 对战记录 -->
<el-tab-pane label="对战记录" name="records">
<div class="tab-content">
<div class="flex gap-2 mb-4">
<el-input
v-model="recUserID"
placeholder="按用户ID筛选"
clearable
style="width: 180px"
@change="fetchRecords"
/>
<el-input
v-model="recMatchID"
placeholder="按局ID筛选"
clearable
style="width: 260px"
@change="fetchRecords"
/>
<el-button @click="fetchRecords">刷新</el-button>
</div>
<el-table :data="recList" border stripe v-loading="recLoading" size="small">
<el-table-column label="局ID" prop="match_id" min-width="200" show-overflow-tooltip />
<el-table-column label="玩家" min-width="120">
<template #default="scope">{{ scope.row.nickname || scope.row.user_id }}</template>
</el-table-column>
<el-table-column label="结果" width="70" align="center">
<template #default="scope">
<el-tag :type="scope.row.is_winner ? 'success' : 'info'" size="small">
{{ scope.row.is_winner ? '胜' : '败' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="名次" prop="rank_position" width="65" align="center" />
<el-table-column label="总人数" prop="total_players" width="75" align="center" />
<el-table-column label="得分" prop="score" width="80" align="right" />
<el-table-column label="对战分" prop="rank_points" width="85" align="right" />
<el-table-column label="结算时间" prop="settled_at" width="160" />
</el-table>
<div class="flex justify-end mt-3">
<el-pagination
v-model:current-page="recPage"
v-model:page-size="recPageSize"
:total="recTotal"
layout="total, prev, pager, next"
@current-change="fetchRecords"
/>
</div>
</div>
</el-tab-pane>
```
#### 4.2 在 `<script setup>` 中追加响应式数据和 API 函数
```typescript
import request from '@/utils/http'
// 排行榜
const lbSearch = ref('')
const lbLoading = ref(false)
const lbList = ref<any[]>([])
const lbTotal = ref(0)
const lbPage = ref(1)
const lbPageSize = ref(20)
const fetchLeaderboard = async () => {
lbLoading.value = true
try {
const res = await request.get('/admin/games/leaderboard', {
params: { page: lbPage.value, page_size: lbPageSize.value, nickname: lbSearch.value }
})
lbList.value = res.list || []
lbTotal.value = res.total || 0
} finally {
lbLoading.value = false
}
}
// 对战记录
const recUserID = ref('')
const recMatchID = ref('')
const recLoading = ref(false)
const recList = ref<any[]>([])
const recTotal = ref(0)
const recPage = ref(1)
const recPageSize = ref(20)
const fetchRecords = async () => {
recLoading.value = true
try {
const res = await request.get('/admin/games/records', {
params: {
page: recPage.value,
page_size: recPageSize.value,
user_id: recUserID.value || undefined,
match_id: recMatchID.value || undefined,
}
})
recList.value = res.list || []
recTotal.value = res.total || 0
} finally {
recLoading.value = false
}
}
```
#### 4.3 在 `onMounted` 中追加调用
```typescript
onMounted(() => {
loadConfig()
fetchLeaderboard()
fetchRecords()
})
```
#### 4.4 监听 Tab 切换(可选优化)
在 Tab 切换到对应 Tab 时按需加载,避免首次全量请求:
```typescript
watch(activeTab, (val) => {
if (val === 'leaderboard') fetchLeaderboard()
if (val === 'records') fetchRecords()
})
```
---
## 关键文件汇总
| 文件 | 操作 | 说明 |
|------|------|------|
| `internal/api/game/handler.go` | 修改 | 新增 `GetAdminLeaderboard()``GetAdminGameRecords()` 两个函数 |
| `internal/router/router.go` | 修改 | 注册两条新路由 |
| `web/admin/src/views/operations/minesweeper/index.vue` | 修改 | 新增排行榜和对战记录两个 Tab + script 逻辑 |
---
## 注意事项
- "积分"含义:页面上 `total_rank_points` 显示为"对战分",并加 Alert 说明避免与平台积分points/兑换币)混淆
- 不需要新建文件,全部在现有文件中追加
- 后端无需修改任何免费模式的业务逻辑;前端 Tab 中不再展示 game_type 切换选项即可
- `activeTab` 初始值改为 `'board'`(已经是),排行榜 Tab 不作为默认 Tab
- 分页默认每页 20 条
---
## SESSION_ID
- CODEX_SESSION: N/A本次直接由 Claude 规划,无外部模型调用)
- GEMINI_SESSION: N/A

Binary file not shown.

View File

@ -1,145 +0,0 @@
// abogus_poc 验证 goja 嵌入 a_bogus 算法能否绕过抖店风控
//
// 流程:
// 1. 构造 抖店 searchlist 的 URL params不含 a_bogus
// 2. 用 goja 跑 a_bogus.js 算签名
// 3. 把 a_bogus 拼上去发请求,看是否返回 st=0 的真实订单数据
//
// 用法:
//
// go run cmd/abogus_poc/main.go -buyer 47074703875 -cookie '<完整 cookie>'
package main
import (
"flag"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"bindbox-game/internal/service/douyin/abogus"
)
const (
defaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
defaultReferer = "https://fxg.jinritemai.com/ffa/morder/order/list"
)
func main() {
buyer := flag.String("buyer", "", "抖音 Buyer ID必填")
cookie := flag.String("cookie", "", "抖店登录 cookie 字符串,必填")
pageSize := flag.Int("page-size", 10, "pageSize")
flag.Parse()
if *buyer == "" || *cookie == "" {
fmt.Println("用法: -buyer <id> -cookie <cookie>")
os.Exit(1)
}
gen, err := abogus.NewGenerator()
if err != nil {
exit("初始化 a_bogus 生成器失败: %v", err)
}
csrf := parseCookie(*cookie, "csrf_session_id")
msToken := parseCookie(*cookie, "msToken")
if csrf == "" {
fmt.Println("[WARN] cookie 中找不到 csrf_session_id__token 用兜底值")
}
if msToken == "" {
msToken = "qo0QYnkK7z_SrM7MPt2AA5xdWwKSGInO7AEeALRJ_BshJqip3nSLTnGa-gFL-aSNP6m1qNnf71-kf6hUf8xbwwLhbsaa_q3BamgxUXPxm4oXIyWPwBOXeXldqOkRV3naDtcad6PJb7rbxhbOaESKQ1YHY1y__z9Wt8GduCOxF-3ks9xHqstnKccV"
fmt.Println("[WARN] cookie 中找不到 msToken使用历史值很可能已过期建议从浏览器刷新一份")
}
verifyFp := parseCookie(*cookie, "s_v_web_id")
if verifyFp == "" {
verifyFp = "verify_mmwdotm1_QYpHiLoc_99vO_49un_9xFU_0ZKfqsmF8gzh"
}
params := url.Values{}
params.Set("page", "0")
params.Set("pageSize", fmt.Sprintf("%d", *pageSize))
params.Set("compact_time[select]", "create_time_start,create_time_end")
params.Set("buyer", *buyer)
params.Set("order_by", "create_time")
params.Set("order", "desc")
params.Set("tab", "all")
params.Set("appid", "1")
if csrf != "" {
params.Set("__token", csrf)
}
params.Set("_bid", "ffa_order")
params.Set("aid", "4272")
params.Set("verifyFp", verifyFp)
params.Set("fp", verifyFp)
params.Set("msToken", msToken)
queryStr := params.Encode()
aBogus, err := gen.Sign(queryStr, defaultUA)
if err != nil {
exit("计算 a_bogus 失败: %v", err)
}
fmt.Printf("生成 a_bogus = %s (长度 %d)\n", aBogus, len(aBogus))
finalURL := "https://fxg.jinritemai.com/api/order/searchlist?" + queryStr + "&a_bogus=" + url.QueryEscape(aBogus)
body, status, err := doGet(finalURL, *cookie)
if err != nil {
exit("请求失败: %v", err)
}
fmt.Printf("HTTP %d\n响应前 1500 字节:\n%s\n", status, truncate(body, 1500))
}
func parseCookie(cookie, key string) string {
for _, part := range strings.Split(cookie, ";") {
kv := strings.TrimSpace(part)
if strings.HasPrefix(kv, key+"=") {
return strings.TrimPrefix(kv, key+"=")
}
}
return ""
}
func doGet(u, cookie string) (string, int, error) {
req, err := http.NewRequest("GET", u, nil)
if err != nil {
return "", 0, err
}
req.Header.Set("User-Agent", defaultUA)
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Cookie", cookie)
req.Header.Set("Referer", defaultReferer)
req.Header.Set("priority", "u=1, i")
req.Header.Set("sec-ch-ua", `"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"macOS"`)
req.Header.Set("sec-fetch-dest", "empty")
req.Header.Set("sec-fetch-mode", "cors")
req.Header.Set("sec-fetch-site", "same-origin")
req.Close = true
cli := &http.Client{Timeout: 60 * time.Second}
resp, err := cli.Do(req)
if err != nil {
return "", 0, err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return string(b), resp.StatusCode, err
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func exit(format string, a ...any) {
fmt.Fprintf(os.Stderr, format+"\n", a...)
os.Exit(1)
}

View File

@ -2,12 +2,9 @@ package main
import (
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"os"
"time"
@ -22,7 +19,6 @@ import (
// staticSyscfg implements sysconfig.Service with fixed cookie
type staticSyscfg struct {
cookie string
proxy string
}
func (s *staticSyscfg) GetByKey(ctx context.Context, key string) (*model.SystemConfigs, error) {
@ -34,11 +30,6 @@ func (s *staticSyscfg) GetByKey(ctx context.Context, key string) (*model.SystemC
return &model.SystemConfigs{ConfigKey: key, ConfigValue: s.cookie}, nil
case douyin.ConfigKeyDouyinInterval:
return &model.SystemConfigs{ConfigKey: key, ConfigValue: "5"}, nil
case douyin.ConfigKeyDouyinProxy:
if s.proxy == "" {
return nil, errors.New("douyin proxy 未设置")
}
return &model.SystemConfigs{ConfigKey: key, ConfigValue: s.proxy}, nil
default:
return nil, errors.New("暂不支持的配置 key: " + key)
}
@ -61,23 +52,19 @@ func main() {
minutes := flag.Int("minutes", 10, "同步最近多少分钟的订单")
useProxy := flag.Bool("proxy", false, "是否使用服务内置代理")
printLimit := flag.Int("print", 10, "同步后打印多少条订单 (0 表示不打印)")
mode := flag.String("mode", "sync-all", "同步模式: sync-all(默认增量)/fetch(按绑定用户)/user(指定抖音 buyer)")
mode := flag.String("mode", "sync-all", "同步模式: sync-all(默认增量)/fetch(按绑定用户)")
grantMinesweeper := flag.Bool("grant-minesweeper", false, "同步后执行 GrantMinesweeperQualifications")
fetchOnlyUnmatched := flag.Bool("fetch-only-unmatched", true, "按用户同步时是否仅同步未匹配订单的用户")
fetchMaxUsers := flag.Int("fetch-max-users", 200, "按用户同步时最多处理的用户数量 (50-1000)")
fetchBatchSize := flag.Int("fetch-batch-size", 20, "按用户同步时的单批次用户数量 (5-50)")
fetchConcurrency := flag.Int("fetch-concurrency", 5, "按用户同步时的并发抓取数 (<=批次大小)")
fetchDelay := flag.Int("fetch-delay-ms", 200, "批次之间的停顿时间 (毫秒)")
buyer := flag.String("buyer", "", "指定抖音 ID (douyin_user_id / Buyer ID),仅同步此用户。等价于 -mode user")
proxyURL := flag.String("proxy-url", "", "覆盖代理地址 (例: http://user:pass@host:port)")
replayURL := flag.String("replay-url", "", "[救急] 把浏览器抓到的完整 searchlist URL 贴进来,绕过风控直接同步")
replayCookie := flag.String("replay-cookie", "", "[救急] 与 -replay-url 配套的 cookie 串")
flag.Parse()
env.Active() // 初始化 env flag依赖已有的全局 -env/ACTIVE_ENV 配置)
configs.Init()
cookie := "passport_csrf_token=133a0751277aa016a5851e4cfc27c30c; passport_csrf_token_default=133a0751277aa016a5851e4cfc27c30c; s_v_web_id=verify_mmwdotm1_QYpHiLoc_99vO_49un_9xFU_0ZKfqsmF8gzh; is_staff_user=false; ttwid=1%7Caa-Nm2neyE97yjVd8lXbX7cMYg2IRxLWDrrcDT-XwQI%7C1778257690%7C8366dc43f20a0c89c5c9b77aaa7a5f554d1e2eed250b654e65ae9828aba5b8be; odin_tt=c4ebef3065193a34066032a3d3e72ecd8584a523548d361b090a303d3af9d410dde8802c1319457684ebca43f05632bf3a190329be76dc16918a45fc0453e4d0; uid_tt=8d9cfddb6881e9a2e5f2985ba1509aef; sid_tt=f5f318456b8263a30e1d4a6a5926ef41; sessionid=f5f318456b8263a30e1d4a6a5926ef41; sessionid_ss=f5f318456b8263a30e1d4a6a5926ef41; PHPSESSID=cf2e0d098639e2c3eed2d3d9eb1e0293; csrf_session_id=436ad6d84082eaf485438e339abd88df; ecom_gray_shop_id=156231010; sid_guard=f5f318456b8263a30e1d4a6a5926ef41%7C1778257842%7C5184000%7CTue%2C+07-Jul-2026+16%3A30%3A42+GMT"
cookie := "is_staff_user=false; SHOP_ID=156231010; PIGEON_CID=4339134776748827; bd_ticket_guard_web_domain=3; passport_mfa_token=CjcMUe8O6Zz52W9O1T3zlEkIxpWSHBCB4dHw9XBdiDU%2BIPU1pzwEXLpVjGth2W2nXGHC8OM6ffSmGkoKPAAAAAAAAAAAAABQK6uUDAbmPNiLgEkCaMWLdiWMpTEiK%2Fm1NGLpqOUmR4vBZtoNbJWrAhzjfim%2BBtfMlxCj6IsOGPax0WwgAiIBA8pTDDU%3D; bd_ticket_guard_server_data=eyJ0aWNrZXQiOiJoYXNoLk1SWGtrczRwYTZpWG91ODhuZENOT05idm9iSjI2SHlXOXRYN2JKNTdZMWM9IiwidHNfc2lnbiI6InRzLjIuMDg1MDhmMjljNWI2MjkzMjQ4ZTAwNGY0YjdiNjMwODI4ODk1YjFkZWQ1ZTRlYmFiZTc3NmYzZTUxYWJjZjZhNGM0ZmJlODdkMjMxOWNmMDUzMTg2MjRjZWRhMTQ5MTFjYTQwNmRlZGJlYmVkZGIyZTMwZmNlOGQ0ZmEwMjU3NWQiLCJjbGllbnRfY2VydCI6InB1Yi5CTHVTREdkVFRHWUdNMVY3ZDZKS2M4V2FwWGJ1K3JVYmVqRThONTZoeTI4SUJXdmVxZjBLMS9GczE0dWx5RTVRd2d4cjdnaDd6SXdMZjlsWDkwOFZQQWs9IiwibG9nX2lkIjoiMjAyNjAzMTExODE4NDBGQUVGNkZGMDBCMkUwQTJEQTU2QSIsImNyZWF0ZV90aW1lIjoxNzczMjI0MzIwfQ%3D%3D; passport_csrf_token=8a80d263a6af8795adf8692ddf2b0bd7; passport_csrf_token_default=8a80d263a6af8795adf8692ddf2b0bd7; s_v_web_id=verify_mmuhek92_2WWTTE1q_Nt89_4Uwc_An7s_aO5e3MjRRUH2; _tea_utm_cache_2631=undefined; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1772107597,1772794481,1773223394,1773858658; ttwid=1%7CNnXcElGkMBE8UTpDOFYR5OfCUYkFjQaLyn1EagPBZgM%7C1773858585%7C74563a93a61ed33b1e9bd4697c260eb21177e46e87f72fddd86075bb903fa984; odin_tt=23564caa6c90cf80bbf73fe1d2a40f56b6c64bfaef87d2208db39c9d147b12ac7a28b422abddac143dee7a3ea2ee0fbd848d17f0ddcbe96db5dba6eca0e79fc2; passport_auth_status=28ac3ac0246ed02dd0776a5f51e7a3f1%2C581a8676e64d918c69ee3930f4dacf8b; passport_auth_status_ss=28ac3ac0246ed02dd0776a5f51e7a3f1%2C581a8676e64d918c69ee3930f4dacf8b; uid_tt=4086ea16cf4b601d9d9657f42419b53c; uid_tt_ss=4086ea16cf4b601d9d9657f42419b53c; sid_tt=6047a612c0e067f6c142d13bd87a9acc; sessionid=6047a612c0e067f6c142d13bd87a9acc; sessionid_ss=6047a612c0e067f6c142d13bd87a9acc; PHPSESSID=692181d913993eab8bc8bac1f6e26b1c; PHPSESSID_SS=692181d913993eab8bc8bac1f6e26b1c; ucas_c0=CkEKBTEuMC4wELaIh5S537vdaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cf3uvNBkifkqjQBlC_vL6Ekt3t1GdYbhIUnz26j2aLNwC7K9D-UoY94GOoC_4; ucas_c0_ss=CkEKBTEuMC4wELaIh5S537vdaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cf3uvNBkifkqjQBlC_vL6Ekt3t1GdYbhIUnz26j2aLNwC7K9D-UoY94GOoC_4; zsgw_business_data=%7B%22uuid%22%3A%226756720f-c380-4bda-ab81-3dd27ca08a2d%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.fxg.jinritemai.com%22%7D; sid_guard=6047a612c0e067f6c142d13bd87a9acc%7C1773858597%7C5184000%7CSun%2C+17-May-2026+18%3A29%3A57+GMT; session_tlb_tag=sttt%7C10%7CYEemEsDgZ_bBQtE72HqazP________-jmJZ0rdd_Chl68Ti2aPyUgCao_SCeTs9hmqZrN0gLLeU%3D; sid_ucp_v1=1.0.0-KDE5ZGI2OTFkMTFkOGNlZGM5MDk0M2I4NTc5MzRjNzllMmM5MjBjMTUKGQib1oDYuM3aBxCl3uvNBhiwISAMOAZA9AcaAmxmIiA2MDQ3YTYxMmMwZTA2N2Y2YzE0MmQxM2JkODdhOWFjYw; ssid_ucp_v1=1.0.0-KDE5ZGI2OTFkMTFkOGNlZGM5MDk0M2I4NTc5MzRjNzllMmM5MjBjMTUKGQib1oDYuM3aBxCl3uvNBhiwISAMOAZA9AcaAmxmIiA2MDQ3YTYxMmMwZTA2N2Y2YzE0MmQxM2JkODdhOWFjYw; COMPASS_LUOPAN_DT=session_7618659644277604634; BUYIN_SASID=SID2_7618660342810313012; gfkadpd=4272,23756; csrf_session_id=e94f18f5f8c89da31caf1805f7fc4ac7; ecom_gray_shop_id=156231010"
if cookie == "" {
fmt.Println("请通过环境变量 DOUYIN_COOKIE 提供抖店 Cookie")
os.Exit(1)
@ -95,63 +82,12 @@ func main() {
defer repo.DbRClose()
defer repo.DbWClose()
if *proxyURL != "" {
fmt.Printf("使用代理: %s\n", *proxyURL)
}
svc := douyin.New(log, repo, &staticSyscfg{cookie: cookie, proxy: *proxyURL}, nil, nil, nil)
svc := douyin.New(log, repo, &staticSyscfg{cookie: cookie}, nil, nil, nil)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
defer cancel()
effectiveMode := *mode
if *replayURL != "" {
effectiveMode = "replay"
} else if *buyer != "" {
effectiveMode = "user"
}
switch effectiveMode {
case "replay":
if *replayURL == "" || *replayCookie == "" {
fmt.Println("replay 模式需要同时提供 -replay-url 和 -replay-cookie")
os.Exit(1)
}
if *buyer == "" {
fmt.Println("replay 模式需要 -buyer 用于关联本地用户 ID")
os.Exit(1)
}
var u model.Users
if err := repo.GetDbR().Where("douyin_user_id = ?", *buyer).First(&u).Error; err != nil {
fmt.Printf("未找到绑定该抖音 ID 的用户 (douyin_user_id=%s): %v\n", *buyer, err)
os.Exit(1)
}
fmt.Printf("Replaylocal_user_id=%d, nickname=%s, douyin_user_id=%s\n", u.ID, u.Nickname, u.DouyinUserID)
newOrders, matched, total, err := replayFetchAndSync(ctx, svc, *replayURL, *replayCookie, u.ID)
if err != nil {
fmt.Printf("replay 失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("完成:抓取 %d新订单 %d匹配 %d。\n", total, newOrders, matched)
case "user":
if *buyer == "" {
fmt.Println("使用 -mode user 时必须通过 -buyer 指定抖音 ID")
os.Exit(1)
}
var u model.Users
if err := repo.GetDbR().Where("douyin_user_id = ?", *buyer).First(&u).Error; err != nil {
fmt.Printf("未找到绑定该抖音 ID 的用户 (douyin_user_id=%s): %v\n", *buyer, err)
os.Exit(1)
}
fmt.Printf("开始 SyncUserOrderslocal_user_id=%d, nickname=%s, douyin_user_id=%s\n",
u.ID, u.Nickname, u.DouyinUserID)
result, err := svc.SyncUserOrders(ctx, u.ID)
if err != nil {
fmt.Printf("SyncUserOrders 失败: %v\n", err)
os.Exit(1)
}
fmt.Printf("完成:抓取 %d新订单 %d匹配 %d用时 %.2fs。\nDebug: %s\n",
result.TotalFetched, result.NewOrders, result.MatchedUsers,
float64(result.ElapsedMS)/1000.0, result.DebugInfo)
switch *mode {
case "fetch":
fmt.Println("开始 FetchAndSyncOrders按绑定用户同步...")
result, err := svc.FetchAndSyncOrders(ctx, &douyin.FetchOptions{
@ -203,68 +139,3 @@ func main() {
}
// go run cmd/douyin_sync_debug/main.go -env dev -mode fetch -fetch-only-unmatched=false -fetch-max-users=200 -fetch-batch-size=1 -fetch-concurrency=1 -fetch-delay-ms=0
// replayFetchAndSync 把浏览器抓到的完整 URL+cookie 直接打过去,绕开风控,然后把订单写入 DB
func replayFetchAndSync(ctx context.Context, svc douyin.Service, fullURL, cookie string, localUserID int64) (newOrders, matched, total int, err error) {
req, err := http.NewRequestWithContext(ctx, "GET", fullURL, nil)
if err != nil {
return 0, 0, 0, fmt.Errorf("构造请求失败: %w", err)
}
req.Header.Set("Cookie", cookie)
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Referer", "https://fxg.jinritemai.com/ffa/morder/order/list")
req.Header.Set("priority", "u=1, i")
req.Header.Set("sec-ch-ua", `"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"macOS"`)
req.Header.Set("sec-fetch-dest", "empty")
req.Header.Set("sec-fetch-mode", "cors")
req.Header.Set("sec-fetch-site", "same-origin")
req.Close = true
client := &http.Client{Timeout: 60 * time.Second}
resp, err := client.Do(req)
if err != nil {
return 0, 0, 0, fmt.Errorf("请求失败: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return 0, 0, 0, fmt.Errorf("读取响应失败: %w", err)
}
var wrap struct {
St int `json:"st"`
Code int `json:"code"`
Msg string `json:"msg"`
Data []douyin.DouyinOrderItem `json:"data"`
}
if err := json.Unmarshal(body, &wrap); err != nil {
fmt.Printf("响应原文(前2000字节): %s\n", string(body[:min2k(len(body))]))
return 0, 0, 0, fmt.Errorf("解析响应失败: %w", err)
}
if wrap.St != 0 && wrap.Code != 0 {
return 0, 0, 0, fmt.Errorf("API 错误: %s (st=%d code=%d) body=%s", wrap.Msg, wrap.St, wrap.Code, string(body[:min2k(len(body))]))
}
total = len(wrap.Data)
for i := range wrap.Data {
isNew, isMatched := svc.SyncOrder(ctx, &wrap.Data[i], localUserID, "")
if isNew {
newOrders++
}
if isMatched {
matched++
}
}
return newOrders, matched, total, nil
}
func min2k(n int) int {
if n > 2000 {
return 2000
}
return n
}

View File

@ -1,284 +0,0 @@
package main
import (
"bufio"
"context"
"flag"
"fmt"
"os"
"strconv"
"strings"
"bindbox-game/configs"
"bindbox-game/internal/pkg/env"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
usersvc "bindbox-game/internal/service/user"
welfaresvc "bindbox-game/internal/service/welfare_activity"
"github.com/eiannone/keyboard"
)
func main() {
operator := flag.String("operator", "cli", "操作人标识")
flag.Parse()
env.Active()
configs.Init()
logg, err := logger.NewCustomLogger(logger.WithOutputInConsole(), logger.WithDebugLevel())
if err != nil {
fmt.Printf("[ERR] 初始化日志失败: %v\n", err)
os.Exit(1)
}
repo, err := mysql.New()
if err != nil {
fmt.Printf("[ERR] 初始化 MySQL 失败: %v\n", err)
os.Exit(1)
}
defer repo.DbRClose()
defer repo.DbWClose()
userSvc := usersvc.New(logg, repo)
welfareSvc := welfaresvc.New(logg, repo)
ctx := context.Background()
reader := bufio.NewReader(os.Stdin)
fmt.Printf("[WARN] 当前环境: %s\n", env.Active().Value())
fmt.Println("[WARN] 该工具会写入真实已支付测试订单,仅用于 dev/fat/uat")
userID, err := promptInt64(reader, "请输入用户ID")
if err != nil {
fmt.Printf("[ERR] 读取用户ID失败: %v\n", err)
os.Exit(1)
}
user, err := userSvc.GetProfile(ctx, userID)
if err != nil || user == nil {
fmt.Printf("[ERR] 用户不存在: %v\n", err)
os.Exit(1)
}
fmt.Println("[INFO] 用户信息")
fmt.Printf("- 用户名: %s\n", fallback(user.Nickname, "-"))
fmt.Printf("- 用户ID: %d\n", user.ID)
fmt.Printf("- 手机号: %s\n", fallback(user.Mobile, "-"))
packages, err := loadActivePackages(ctx, repo)
if err != nil {
fmt.Printf("[ERR] 加载活动次卡套餐失败: %v\n", err)
os.Exit(1)
}
if len(packages) == 0 {
fmt.Println("[WARN] 当前没有可用的活动次卡套餐")
return
}
selectedPackage, err := selectPackage(packages)
if err != nil {
fmt.Printf("[ERR] 选择套餐失败: %v\n", err)
os.Exit(1)
}
count, err := promptInt32(reader, "请输入购买次数")
if err != nil {
fmt.Printf("[ERR] 读取购买次数失败: %v\n", err)
os.Exit(1)
}
purchase, err := userSvc.CreatePaidGamePassOrderForTest(ctx, usersvc.CreatePaidGamePassOrderForTestInput{
UserID: user.ID,
PackageID: selectedPackage.ID,
Count: count,
Operator: *operator,
})
if err != nil {
fmt.Printf("[ERR] 模拟消费失败: %v\n", err)
os.Exit(1)
}
fmt.Println("[OK] 模拟消费成功")
fmt.Printf("- 订单号: %s\n", purchase.OrderNo)
fmt.Printf("- 套餐: %s\n", purchase.PackageName)
fmt.Printf("- 次数: %d\n", purchase.Count)
fmt.Printf("- 支付金额: %s\n", formatAmount(purchase.TotalAmount))
fmt.Printf("- 支付时间: %s\n", purchase.PaidAt.Format("2006-01-02 15:04:05"))
activities, err := welfareSvc.ListJoinableActivitiesForUser(ctx, user.ID)
if err != nil {
fmt.Printf("[ERR] 加载福利活动失败: %v\n", err)
os.Exit(1)
}
if len(activities) == 0 {
fmt.Println("[WARN] 当前没有可选的进行中福利活<E588A9><E6B4BB>")
return
}
selected, err := selectActivity(activities)
if err != nil {
fmt.Printf("[ERR] 选择活动失败: %v\n", err)
os.Exit(1)
}
fmt.Println("[INFO] 已选择福利活动")
fmt.Printf("- 活动ID: %d\n", selected.ActivityID)
fmt.Printf("- 活动名称: %s\n", selected.Title)
fmt.Printf("- 当前消费/门槛: %s/%s\n", formatAmount(selected.CurrentPaid), formatAmount(selected.ThresholdAmount))
if err := welfareSvc.Join(ctx, selected.ActivityID, user.ID); err != nil {
fmt.Printf("[ERR] 参与失败: %v\n", err)
os.Exit(1)
}
fmt.Println("[OK] 参与成功")
fmt.Printf("- 用户ID: %d\n", user.ID)
fmt.Printf("- 活动ID: %d\n", selected.ActivityID)
fmt.Printf("- 开奖时间: %s\n", selected.DrawTime.Format("2006-01-02 15:04:05"))
fmt.Println("[INFO] 后续请等待系统自动开奖,再登录验证中奖、订单、背包和中奖名单")
}
func promptInt64(reader *bufio.Reader, label string) (int64, error) {
fmt.Printf("%s: ", label)
text, err := reader.ReadString('\n')
if err != nil {
return 0, err
}
return strconv.ParseInt(strings.TrimSpace(text), 10, 64)
}
func promptInt32(reader *bufio.Reader, label string) (int32, error) {
value, err := promptInt64(reader, label)
if err != nil {
return 0, err
}
return int32(value), nil
}
func loadActivePackages(ctx context.Context, repo mysql.Repo) ([]*model.GamePassPackages, error) {
var packages []*model.GamePassPackages
err := repo.GetDbR().WithContext(ctx).
Where("status = 1 AND deleted_at IS NULL").
Order("sort_order DESC, id ASC").
Find(&packages).Error
return packages, err
}
func selectPackage(items []*model.GamePassPackages) (*model.GamePassPackages, error) {
if err := keyboard.Open(); err != nil {
return nil, err
}
defer keyboard.Close()
index := 0
for {
fmt.Println()
fmt.Println("请选择活动次卡套餐(↑↓ 选择Enter 确认Esc 退出)")
for i, item := range items {
prefix := " "
if i == index {
prefix = "> "
}
activityScope := "全局"
if item.ActivityID > 0 {
activityScope = fmt.Sprintf("活动ID:%d", item.ActivityID)
}
fmt.Printf("%s%s | 套餐ID:%d | %s | %d次 | %s\n", prefix, item.Name, item.ID, activityScope, item.PassCount, formatAmount(item.Price))
}
char, key, err := keyboard.GetKey()
if err != nil {
return nil, err
}
switch key {
case keyboard.KeyArrowUp:
if index > 0 {
index--
}
case keyboard.KeyArrowDown:
if index < len(items)-1 {
index++
}
case keyboard.KeyEnter:
return items[index], nil
case keyboard.KeyEsc, keyboard.KeyCtrlC:
return nil, fmt.Errorf("已取消选择")
default:
if char == 0 {
continue
}
}
}
}
func selectActivity(items []welfaresvc.JoinableActivityItem) (*welfaresvc.JoinableActivityItem, error) {
if err := keyboard.Open(); err != nil {
return nil, err
}
defer keyboard.Close()
index := 0
for {
fmt.Println()
fmt.Println("请选择福利活动(↑↓ 选择Enter 确认Esc 退出)")
for i, item := range items {
prefix := " "
if i == index {
prefix = "> "
}
status := "未达标"
if item.Joined {
status = "已参与"
} else if item.CanJoin {
status = "可参与"
}
fmt.Printf("%s%s %s %s/%s %s\n", prefix, item.Title, typeLabel(item.Type), formatAmount(item.CurrentPaid), formatAmount(item.ThresholdAmount), status)
}
char, key, err := keyboard.GetKey()
if err != nil {
return nil, err
}
switch key {
case keyboard.KeyArrowUp:
if index > 0 {
index--
}
case keyboard.KeyArrowDown:
if index < len(items)-1 {
index++
}
case keyboard.KeyEnter:
return &items[index], nil
case keyboard.KeyEsc, keyboard.KeyCtrlC:
return nil, fmt.Errorf("已取消选择")
default:
if char == 0 {
continue
}
}
}
}
func formatAmount(cents int64) string {
return fmt.Sprintf("%.2f", float64(cents)/100)
}
func typeLabel(t string) string {
switch t {
case welfaresvc.TypeWeekly:
return "每周福利"
case welfaresvc.TypeMonthly:
return "每月福利"
default:
return "每日福利"
}
}
func fallback(v, d string) string {
if strings.TrimSpace(v) == "" {
return d
}
return v
}

View File

@ -3,13 +3,13 @@ local = 'zh-cn'
[mysql.read]
addr = '150.158.78.154:3306'
name = 'bindbox_game'
name = 'dev_game'
pass = 'bindbox2025kdy'
user = 'root'
[mysql.write]
addr = '150.158.78.154:3306'
name = 'bindbox_game'
name = 'dev_game'
pass = 'bindbox2025kdy'
user = 'root'

View File

@ -1,280 +0,0 @@
# 用户排查报告 — 大熊 (user_id: 9522)
> 排查日期2026-04-11
> 数据库dev_game @ 150.158.78.154:3306
---
## 一、用户基本信息
| 字段 | 值 |
|------|-----|
| user_id | 9522 |
| 昵称 | 大熊 |
| 注册时间 | 2026-03-28 14:47:37 |
| 当前积分余额 | **0** |
| 当前持有库存 | 碎片PP夹 ×1 (价值 ¥6.01) |
---
## 二、今日操作时间线
### 2.1 抖音直播间下单 (15:18 ~ 15:41)
共 6 笔抖音订单,全部状态=3(已完成)。
| # | 时间 | 抖音订单号 | 数量 | 实付 | 奖励次数(游戏通行证) |
|---|------|-----------|------|------|---------------------|
| 1 | 15:18 | 6925520384119242107 | 30 | ¥597.00 | 30 |
| 2 | 15:22 | 6925518086728154491 | 30 | ¥596.00 | 30 |
| 3 | 15:26 | 6925518071101619579 | 10 | ¥199.00 | 10 |
| 4 | 15:29 | 6925518076052536699 | 5 | ¥99.50 | 5 |
| 5 | 15:40 | 6925522513679908219 | 10 | ¥99.00 | 10 |
| 6 | 15:41 | 6925516058552663419 | 10 | ¥99.00 | 10 |
**小计85 份商品,付款 ¥1,689.50,获得 95 次游戏通行证**
> 说明douyin_reward_logs 今日无单独奖励发放记录,奖励以 game pass 形式直接挂在 douyin_orders.reward_granted 字段。
---
### 2.2 小程序抽奖 — 微信支付 (16:10 ~ 16:16)
共 6 笔订单4 笔已支付2 笔取消),使用了 3 张优惠券(各抵扣 ¥10
| # | 时间 | 订单ID | 活动 | 期号 | 抽数 | 总价 | 优惠券 | 实付 | 状态 | 中奖结果 |
|---|------|--------|------|------|------|------|--------|------|------|---------|
| 1 | 16:10 | 42182 | 104-无限激战2元干 | 114 | 10 | ¥20 | ¥10 (券1932) | **¥10** | 已支付 | 全中 level5 → 碎片手帕纸 ×10 |
| 2 | 16:11 | 42183 | 118-最悲情主角机 | 129 | 10 | ¥30 | ¥10 (券1903) | **¥20** | 已支付 | 全中 level4 → 碎片PP夹 ×10 |
| 3 | 16:11 | 42184 | 118-最悲情主角机 | 129 | 10 | ¥30 | ¥10 (券1902) | **¥20** | 已支付 | 全中 level4 → 碎片PP夹 ×10 |
| 4 | 16:15 | 42194 | 118-最悲情主角机 | 129 | 10 | ¥30 | — | — | **已取消** | — |
| 5 | 16:15 | 42195 | 118-最悲情主角机 | 129 | 10 | ¥30 | — | **¥30** | 已支付 | 全中 level4 → 碎片PP夹 ×10 |
| 6 | 16:16 | 42196 | 118-最悲情主角机 | 129 | 10 | ¥30 | — | **¥30** | 已支付 | 8×level4 + **1×level1(大奖)** + 1×level4 |
**大奖命中MG MSN-00100 黄金百式2.0 高达Z (价值 ¥390)**
**小计4 笔成功,实付 ¥110用掉 3 张优惠券(共 ¥30)**
---
### 2.3 购买游戏通行证包 (16:36 ~ 16:40)
| # | 时间 | 订单ID | 包名 | 次数 | 金额 |
|---|------|--------|------|------|------|
| 1 | 16:36 | 42234 | 2元就是干(pkg 20) | 30 | ¥60 |
| 2 | 16:36 | 42237 | 2元就是干(pkg 20) | 50 | ¥100 |
| 3 | 16:37 | 42241 | 最悲情主角机(pkg 24) | 50 | ¥150 |
| 4 | 16:38 | 42248 | 最悲情主角机(pkg 24) | 100 | ¥300 |
| 5 | 16:40 | 42263 | 最悲情主角机(pkg 24) | 100 | ¥300 |
**小计330 次通行证,付款 ¥910**
---
### 2.4 使用游戏通行证抽奖 (16:35 ~ 16:41)
共 37 笔订单全部使用游戏通行证支付actual_amount = 0
| 活动 | 期号 | 订单数 | 总抽次 | 中奖情况 |
|------|------|--------|--------|---------|
| 104-无限激战2元干 | 114 | 15 | 125 | 全中 level5 → 碎片手帕纸 ×125 |
| 118-最悲情主角机 | 129 | 22 | 200 | 全中 level4 → 碎片PP夹 ×200 |
**小计325 次抽奖,实付 ¥0**
<details>
<summary>展开全部 37 笔通行证抽奖订单</summary>
**活动 104 — 无限激战 (issue 114, level5):**
| 订单ID | 时间 | 抽数 | 通行证来源 |
|--------|------|------|-----------|
| 42223 | 16:35:12 | 10 | 微信支付 |
| 42224 | 16:35:27 | 10 | 微信支付 |
| 42225 | 16:35:41 | 10 | 微信支付 |
| 42235 | 16:36:19 | 10 | gp 1316 |
| 42236 | 16:36:22 | 5 | gp 1316 |
| 42238 | 16:36:51 | 10 | gp 1316 |
| 42239 | 16:36:53 | 10 | gp 1316+1317 |
| 42240 | 16:36:55 | 5 | gp 1317 |
| 42259 | 16:39:46 | 10 | gp 1317 |
| 42260 | 16:39:47 | 10 | gp 1317 |
| 42261 | 16:39:48 | 10 | gp 1317 |
| 42262 | 16:39:48 | 10 | gp 1317 |
> 注42223-42225 虽然 source_type=4但 actual_amount>0属于直接微信支付的抽奖订单。
**活动 118 — 最悲情主角机 (issue 129, level4):**
| 订单ID | 时间 | 抽数 | 通行证来源 |
|--------|------|------|-----------|
| 42242 | 16:37:28 | 10 | gp 1318 |
| 42243 | 16:37:31 | 5 | gp 1318 |
| 42244 | 16:37:33 | 10 | gp 1318 |
| 42245 | 16:37:35 | 10 | gp 1318 |
| 42246 | 16:37:37 | 10 | gp 1318 |
| 42247 | 16:37:40 | 5 | gp 1318 |
| 42249 | 16:39:08 | 10 | gp 1319 |
| 42250 | 16:39:09 | 10 | gp 1319 |
| 42251 | 16:39:10 | 10 | gp 1319 |
| 42252 | 16:39:11 | 10 | gp 1319 |
| 42253 | 16:39:12 | 10 | gp 1319 |
| 42254 | 16:39:13 | 10 | gp 1319 |
| 42255 | 16:39:13 | 10 | gp 1319 |
| 42256 | 16:39:14 | 10 | gp 1319 |
| 42257 | 16:39:14 | 10 | gp 1319 |
| 42258 | 16:39:15 | 10 | gp 1319 |
| 42272 | 16:40:53 | 5 | gp 1320 |
| 42273 | 16:40:56 | 10 | gp 1320 |
| 42274 | 16:40:57 | 10 | gp 1320 |
| 42275 | 16:40:58 | 10 | gp 1320 |
| 42276 | 16:40:58 | 10 | gp 1320 |
| 42277 | 16:40:59 | 10 | gp 1320 |
| 42278 | 16:41:00 | 10 | gp 1320 |
| 42279 | 16:41:02 | 5 | gp 1320 |
| 42280 | 16:41:22 | 10 | gp 1320 |
| 42281 | 16:41:26 | 10 | gp 1320 |
| 42282 | 16:41:32 | 5 | gp 1320 |
| 42283 | 16:41:33 | 5 | gp 1320 |
</details>
---
### 2.5 碎片合成 (16:38 ~ 16:49)
共 156 次合成操作。
| 配方ID | 规则 | 合成次数 | 消耗碎片数 | 产出数 |
|--------|------|---------|-----------|--------|
| 1 | 10 碎片 → 1 合成品 | 11 | 110 | 11 |
| 5 | 2 碎片 → 1 合成品 | 145 | 290 | 145 |
| **合计** | | **156** | **400** | **156** |
---
### 2.6 库存兑换积分 — batch_redeem (16:38 ~ 16:58)
将抽奖/合成获得的库存物品批量转化为积分。
| # | 时间 | 批次 | 物品数 | 获得积分 |
|---|------|------|--------|---------|
| 1 | 16:38:36 | batch:16 | 16 件 | +45,900 |
| 2 | 16:42:54 | batch:13 | 13 件 | +5,100 |
| 3 | 16:44:01 | batch:28 | 28 件 | +8,400 |
| 4 | 16:45:32 | batch:49 | 49 件 | +14,700 |
| 5 | 16:49:44 | batch:51 | 51 件 | +15,300 |
| 6 | 16:58:10 | batch:1 | 1 件 | +65,000 |
| 7 | 16:58:32 | batch:1 | 1 件 | +95,000 |
**积分赚取合计:+249,400 分**
---
### 2.7 积分兑换商品 — redeem_product (16:57 ~ 16:59)
| # | 时间 | 商品 | 商品ID | 消耗积分 | 商品价值 | 兑换订单号 |
|---|------|------|--------|---------|---------|-----------|
| 1 | 16:57:53 | 蜗之壳手办 | 647 | -65,000 | ¥650 | RG20260411165753882669 |
| 2 | 16:58:13 | Zippo打火机高达 | 456 | -95,000 | ¥950 | RG20260411165813844939 |
| 3 | 16:58:35 | 白雪公主积木 | 291 | -117,500 | ¥1,175 | RG20260411165835556950 |
| 4 | 16:59:01 | 高达徽章 | 566 | -500 | ¥5 | RG20260411165901650409 |
**积分消耗合计:-278,000 分**
---
### 2.8 优惠券使用
| 优惠券ID | 券ID | 使用时间 | 抵扣 | 关联订单 |
|---------|------|---------|------|---------|
| 1932 | 18 | 16:10:50 | ¥10 | 42182 |
| 1903 | 18 | 16:11:12 | ¥10 | 42183 |
| 1902 | 18 | 16:11:25 | ¥10 | 42184 |
**优惠券已全部用完,当前无可用优惠券。**
---
## 三、今日库存变动汇总
| 商品 | 商品ID | 数量 | 单价(分) | 总价值 | 状态 | 说明 |
|------|--------|------|---------|--------|------|------|
| 碎片手帕纸1小包 | 584 | 120 | 201 | ¥241.20 | status=2(已消耗) | 合成时消耗 |
| 碎片PP夹1个随机发 | 588 | 288 | 601 | ¥1,730.88 | status=2(已消耗) | 合成时消耗 |
| 碎片PP夹1个随机发 | 588 | **1** | 601 | ¥6.01 | **status=1(持有)** | 剩余未合成 |
| 木质拼装蝴蝶刀 | 297 | 12 | 600 | ¥72.00 | status=3(已兑换) | 合成产出 → 兑积分 |
| 冰箱贴 | 324 | 144 | 300 | ¥432.00 | status=3(已兑换) | 合成产出 → 兑积分 |
| Zippo打火机高达 | 456 | 1 | 95,000 | ¥950.00 | status=3(已兑换) | 积分兑换获得 |
| MG黄金百式2.0 | 547 | 1 | 39,000 | ¥390.00 | status=3(已兑换) | 抽奖 level1 大奖 |
| 蜗之壳手办 | 647 | 1 | 65,000 | ¥650.00 | status=3(已兑换) | 积分兑换获得 |
---
## 四、积分流水汇总
| 方向 | 金额 | 来源 |
|------|------|------|
| 今日赚取 | +249,400 分 | 库存批量兑换积分 (7 笔 batch_redeem) |
| 今日消耗 | -278,000 分 | 商品兑换 (4 笔 redeem_product) |
| **今日净变化** | **-28,600 分** | 消耗了历史余额 28,600 分 |
| **当前余额** | **0 分** | |
---
## 五、资金总览
### 用户消费
| 渠道 | 金额 | 说明 |
|------|------|------|
| 抖音直播间 | ¥1,689.50 | 6 笔抖音订单85 份商品 |
| 小程序微信支付(抽奖) | ¥110.00 | 4 笔成功订单 |
| 小程序微信支付(通行证包) | ¥910.00 | 5 笔通行证购买 |
| 优惠券抵扣 | ¥30.00 | 3 张优惠券 |
| **用户总支出** | **¥2,739.50** | |
### 用户获得的实物商品
| 商品 | 价值 | 获取方式 |
|------|------|---------|
| MG黄金百式2.0 高达Z | ¥390 | 抽奖 level1 大奖直接获得 |
| 蜗之壳手办 | ¥650 | 积分兑换 |
| Zippo打火机高达 | ¥950 | 积分兑换 |
| 白雪公主积木 | ¥1,175 | 积分兑换 |
| 高达徽章 | ¥5 | 积分兑换 |
| **实物商品价值合计** | **¥3,170** | |
### 盈亏对比
| 项目 | 金额 |
|------|------|
| 用户总支出 | ¥2,739.50 |
| 用户获得实物价值 | ¥3,170.00 |
| 用户额外消耗历史积分 | 28,600 分 |
| **平台视角盈亏** | **-¥430.50 + 历史积分价值** |
---
## 六、今日未发生的操作
以下维度已查询,今日均**无记录**
- 直播间抽奖 (livestream_draw_logs) — 无
- 抖音奖励发放 (douyin_reward_logs) — 无
- 游戏票变动 (game_ticket_logs) — 无
- 任务中心事件 (task_center_event_logs) — 无
- 发货记录 (shipping_records) — 无
- 退款记录 (lottery_refund_logs) — 无
---
## 七、待确认排查点
1. **积分缺口 28,600 分**:用户今日积分消耗比赚取多 28,600 分,说明使用了历史余额。如果用户质疑积分不对,需对比历史 ledger 总收支是否与当前余额 0 一致。
2. **抖音 reward_granted 95 次 vs 通行证使用**:抖音发放 95 次通行证,用户另外购买了 330 次¥910总计可用 425 次。实际抽奖消耗 325 次(通行证抽奖)+ 微信直付抽奖不消耗通行证。需确认通行证余量是否与账户记录匹配。
3. **合成产出追踪**156 次合成消耗了 400 个碎片,产出 156 个合成品。产出的 156 个合成品被批量兑换为积分(对应 batch_redeem 的 157 件 = 156 合成品 + 1 个 MG黄金百式
4. **level1 大奖确认**:订单 42196 的第 8 抽(draw_index=8) 命中 level1对应 MG黄金百式2.0 (product_id=547, ¥390)。可在 user_inventory 中确认 id=83933。

Binary file not shown.

5
go.mod
View File

@ -14,7 +14,6 @@ require (
github.com/bwmarrin/snowflake v0.3.0
github.com/bytedance/sonic v1.13.2
github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203
github.com/fatih/color v1.14.1
github.com/gin-contrib/pprof v1.4.0
github.com/gin-gonic/gin v1.9.1
@ -76,8 +75,6 @@ require (
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/dlclark/regexp2 v1.11.4 // indirect
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
@ -87,11 +84,9 @@ require (
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect

10
go.sum
View File

@ -145,12 +145,6 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/dlclark/regexp2 v1.11.4 h1:rPYF9/LECdNymJufQKmri9gV604RvvABwgOA8un7yAo=
github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c h1:OcLmPfx1T1RmZVHHFwWMPaZDdRf0DBMZOFMVWJa7Pdk=
github.com/dop251/goja v0.0.0-20260311135729-065cd970411c/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg=
github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
@ -206,8 +200,6 @@ github.com/go-playground/validator/v10 v10.15.0 h1:nDU5XeOKtB3GEa+uB7GNYwhVKsgjA
github.com/go-playground/validator/v10 v10.15.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-resty/resty/v2 v2.10.0 h1:Qla4W/+TMmv0fOeeRqzEpXPLfTUnR5HZ1+lGs+CkiCo=
github.com/go-resty/resty/v2 v2.10.0/go.mod h1:iiP/OpA0CkcL3IGt1O0+/SIItFUbkkyw5BGXiVdTu+A=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyLFfuisuzeidWPMPWmECqU=
github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg=
github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
@ -281,8 +273,6 @@ github.com/google/pprof v0.0.0-20200708004538-1a94d8640e99/go.mod h1:ZgVRPoUq/hf
github.com/google/pprof v0.0.0-20201023163331-3e6fc7fc9c4c/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904 h1:4/hN5RUoecvl+RmJRE2YxKWtnnQls6rQjjW5oV7qg2U=
github.com/google/pprof v0.0.0-20230207041349-798e818bf904/go.mod h1:uglQLonpP8qtYCYyzA+8c/9qtqgA3qsXGYqCPKARAFg=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=

View File

@ -5,11 +5,9 @@ import (
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
activitysvc "bindbox-game/internal/service/activity"
prizegrantsvc "bindbox-game/internal/service/prize_grant_activity"
tasksvc "bindbox-game/internal/service/task_center"
titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user"
welfaresvc "bindbox-game/internal/service/welfare_activity"
"github.com/redis/go-redis/v9"
)
@ -25,8 +23,6 @@ type handler struct {
task tasksvc.Service
redis *redis.Client
activityOrder activitysvc.ActivityOrderService // 活动订单服务
welfare welfaresvc.Service
prizeGrant prizegrantsvc.Service
}
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client, task tasksvc.Service) *handler {
@ -42,7 +38,5 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client, task task
task: task,
redis: rdb,
activityOrder: activitysvc.NewActivityOrderService(logger, db),
welfare: welfaresvc.New(logger, db),
prizeGrant: prizegrantsvc.New(logger, db),
}
}

View File

@ -2,6 +2,8 @@ package app
import (
"context"
"crypto/rand"
"encoding/binary"
"encoding/hex"
"encoding/json"
"fmt"
@ -370,6 +372,7 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
return
}
// 1. Concurrency Lock: Prevent multiple check requests for the same game
lockKey := fmt.Sprintf("lock:matching_game:check:%s", req.GameID)
locked, err := h.redis.SetNX(ctx.RequestContext(), lockKey, "1", 10*time.Second).Result()
if err != nil {
@ -393,6 +396,9 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
return
}
// 校验:不能超过理论最大对数
// 【关键校验】检查订单是否已支付
// 对对碰游戏必须先支付才能结算和发奖
order, err := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First()
if err != nil || order == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170003, "订单不存在"))
@ -406,6 +412,7 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
return
}
// 检查活动状态
activity, err := h.activity.GetActivity(ctx.RequestContext(), game.ActivityID)
if err != nil || activity == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "活动不存在"))
@ -416,6 +423,7 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
return
}
// 【核心安全校验】使用服务端模拟计算实际对数,不信任客户端提交的值
serverSimulatedPairs := game.SimulateMaxPairs()
h.logger.Debug("对对碰Check: 服务端模拟验证",
zap.Int64("client_pairs", req.TotalPairs),
@ -424,7 +432,11 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
zap.String("position", game.Position),
zap.String("game_id", req.GameID))
// 使用服务端模拟的对数,而非客户端提交的值
// 这样即使客户端伪造数据也无法作弊
actualPairs := serverSimulatedPairs
// 如果客户端提交的值与服务端模拟不一致,记录警告日志(可能是作弊尝试)
if req.TotalPairs != serverSimulatedPairs {
h.logger.Warn("对对碰Check: 客户端提交数值与服务端模拟不一致",
zap.Int64("client_pairs", req.TotalPairs),
@ -432,35 +444,220 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
zap.String("game_id", req.GameID))
}
game.TotalPairs = actualPairs
game.TotalPairs = actualPairs // 使用服务端验证后的值
var rewardInfo *MatchingRewardInfo
// 【幂等性检查】在发奖前检查该订单是否已经获得过奖励
existingLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID),
h.readDB.ActivityDrawLogs.IsWinner.Eq(1),
).First()
if existingLog != nil {
h.logger.Warn("对对碰Check: 订单已获得过奖励,拒绝重复发放",
zap.Int64("order_id", game.OrderID),
zap.Int64("existing_log_id", existingLog.ID))
// 返回已有的奖励信息而不是重复发放
if existingLog.RewardID > 0 {
rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityRewardSettings.ID.Eq(existingLog.RewardID)).First()
if rw != nil {
prodName := ""
prodImage := ""
if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(rw.ProductID)).First(); p != nil {
prodName = p.Name
prodImage = getFirstImage(p.ImagesJSON)
}
rewardInfo = &MatchingRewardInfo{
RewardID: rw.ID,
Name: prodName,
ProductName: prodName,
ProductImage: prodImage,
Level: rw.Level,
}
}
}
ctx.Payload(&matchingGameCheckResponse{
GameID: req.GameID,
TotalPairs: req.TotalPairs,
Finished: true,
Reward: rewardInfo,
})
return
}
// 1. Fetch Rewards
rewards, err := h.activity.ListIssueRewards(ctx.RequestContext(), game.IssueID)
if err == nil && len(rewards) > 0 {
// 2. Filter & Sort
var candidate *model.ActivityRewardSettings
for _, r := range rewards {
if r.Quantity <= 0 {
continue
}
// 精确匹配:服务端验证的对子数 == 奖品设置的对子数
if actualPairs == r.MinScore {
candidate = r
break
break // 找到精确匹配,直接使用
}
}
if candidate != nil {
plan, err := h.settleMatchingReward(ctx.RequestContext(), game, order, candidate, false)
if err != nil {
// 3. Prepare Grant Params
// Fetch real product name for remark
productName := ""
if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil {
productName = p.Name
}
finalReward := candidate
finalQuantity := 1
finalRemark := fmt.Sprintf("%s %s", order.OrderNo, productName)
var cardToVoid int64 = 0
// 4. Apply Item Card Effects (Determine final reward and quantity)
icID := parseItemCardIDFromRemark(order.Remark)
h.logger.Debug("CheckMatchingGame: 道具卡检查",
zap.String("order_no", order.OrderNo),
zap.String("remark", order.Remark),
zap.Int64("icID", icID))
if icID > 0 {
uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.UserItemCards.ID.Eq(icID),
h.readDB.UserItemCards.UserID.Eq(game.UserID),
).First()
if uic == nil {
h.logger.Warn("CheckMatchingGame: 用户道具卡未找到", zap.Int64("icID", icID), zap.Int64("user_id", game.UserID))
} else if uic.Status != 1 {
h.logger.Warn("CheckMatchingGame: 用户道具卡状态无效", zap.Int32("status", uic.Status))
} else { // Status == 1
ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.SystemItemCards.ID.Eq(uic.CardID),
h.readDB.SystemItemCards.Status.Eq(1),
).First()
now := time.Now()
if ic != nil && !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) {
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID)
h.logger.Debug("道具卡-CheckMatchingGame: 范围检查",
zap.Int32("scope_type", ic.ScopeType),
zap.Int64("activity_id", game.ActivityID),
zap.Int64("issue_id", game.IssueID),
zap.Bool("is_ok", scopeOK))
if scopeOK {
// Fix: Don't set cardToVoid immediately. Only set it if an effect is actually applied.
// Double reward
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
cardToVoid = icID // Mark for consumption
h.logger.Info("道具卡-CheckMatchingGame: 应用双倍奖励", zap.Int32("multiplier", ic.RewardMultiplierX1000))
finalQuantity = 2
finalRemark += "(倍数)"
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// Probability boost
cardToVoid = icID // Mark for consumption (even if RNG fails, the card is "used")
h.logger.Debug("道具卡-CheckMatchingGame: 应用概率提升", zap.Int32("boost_rate", ic.BoostRateX1000))
allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID),
).Find()
var better *model.ActivityRewardSettings
for _, r := range allRewards {
if r.MinScore > candidate.MinScore && r.Quantity > 0 {
if better == nil || r.MinScore < better.MinScore {
better = r
}
}
}
if better != nil {
// Use crypto/rand for secure random
randBytes := make([]byte, 4)
rand.Read(randBytes)
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
h.logger.Debug("道具卡-CheckMatchingGame: 概率检定",
zap.Int32("rand", randVal),
zap.Int32("threshold", ic.BoostRateX1000))
if randVal < ic.BoostRateX1000 {
// 获取升级后的商品名称
betterProdName := ""
if better.ProductID > 0 {
if bp, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(better.ProductID)).First(); bp != nil {
betterProdName = bp.Name
}
}
h.logger.Info("道具卡-CheckMatchingGame: 概率提升成功",
zap.Int64("new_reward_id", better.ID),
zap.String("product_name", betterProdName))
finalReward = better
finalRemark = betterProdName + "(升级)"
} else {
h.logger.Debug("道具卡-CheckMatchingGame: 概率提升失败")
}
} else {
h.logger.Debug("道具卡-CheckMatchingGame: 未找到更好的奖品可升级", zap.Int64("current_score", candidate.MinScore))
}
} else {
// Effect not recognized or params too low
h.logger.Warn("道具卡-CheckMatchingGame: 效果类型未知或参数无效,不消耗卡片",
zap.Int32("effect_type", ic.EffectType),
zap.Int32("multiplier", ic.RewardMultiplierX1000))
}
} else {
h.logger.Debug("道具卡-CheckMatchingGame: 范围校验失败")
}
} else {
h.logger.Debug("道具卡-CheckMatchingGame: 时间或系统卡状态无效",
zap.Bool("has_ic", ic != nil),
zap.Time("start", uic.ValidStart),
zap.Time("end", uic.ValidEnd),
zap.Time("now", now))
}
}
}
// 5. Grant Reward
if err := h.grantRewardHelper(ctx.RequestContext(), game.UserID, game.OrderID, finalReward, finalQuantity, finalRemark); err != nil {
h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err))
} else if plan != nil {
} else {
prodImage := ""
if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(plan.reward.ProductID)).First(); p != nil {
if p, _ := h.readDB.Products.WithContext(ctx.RequestContext()).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil {
productName = p.Name
prodImage = getFirstImage(p.ImagesJSON)
}
rewardInfo = &MatchingRewardInfo{
RewardID: plan.reward.ID,
Name: plan.rewardName,
ProductName: plan.productName,
RewardID: finalReward.ID,
Name: productName,
ProductName: productName,
ProductImage: prodImage,
Level: plan.reward.Level,
Level: finalReward.Level,
}
// 6. Void Item Card (if used)
if cardToVoid > 0 {
h.logger.Info("道具卡-CheckMatchingGame: 核销道具卡", zap.Int64("uic_id", cardToVoid))
now := time.Now()
// Get DrawLog ID for the order
drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID),
).First()
var drawLogID int64
if drawLog != nil {
drawLogID = drawLog.ID
}
_, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.writeDB.UserItemCards.ID.Eq(cardToVoid),
h.writeDB.UserItemCards.UserID.Eq(game.UserID),
h.writeDB.UserItemCards.Status.Eq(1),
).Updates(map[string]any{
h.writeDB.UserItemCards.Status.ColumnName().String(): 2,
h.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): drawLogID,
h.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): game.ActivityID,
h.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): game.IssueID,
h.writeDB.UserItemCards.UsedAt.ColumnName().String(): now,
})
}
}
}
@ -473,6 +670,8 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
Reward: rewardInfo,
}
// 7. Virtual Shipping (Async)
// Upload shipping info to WeChat (similar to Ichiban Kuji) so user can see "Shipped" status and reward info.
rewardName := "无奖励"
if rewardInfo != nil {
rewardName = rewardInfo.Name
@ -480,12 +679,14 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
go func(orderID int64, orderNo string, userID int64, rName string) {
bgCtx := context.Background()
// 1. Get Payment Transaction
tx, _ := h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
if tx == nil || tx.TransactionID == "" {
h.logger.Warn("CheckMatchingGame: No payment transaction found for shipping", zap.String("order_no", orderNo))
return
}
// 2. Get User OpenID (Prioritize PayerOpenid from transaction)
payerOpenid := tx.PayerOpenid
if payerOpenid == "" {
u, _ := h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First()
@ -494,11 +695,13 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
}
}
// 3. Construct Item Desc
itemsDesc := fmt.Sprintf("对对碰 %s 赏品: %s", orderNo, rName)
if len(itemsDesc) > 120 {
itemsDesc = itemsDesc[:120]
}
// 4. Upload
c := configs.Get()
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil {
h.logger.Error("CheckMatchingGame: Failed to upload virtual shipping", zap.Error(err))
@ -507,7 +710,9 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
}
}(game.OrderID, order.OrderNo, game.UserID, rewardName)
// 结算完成,清理会话 (Delete from Redis)
_ = h.redis.Del(ctx.RequestContext(), activitysvc.MatchingGameKeyPrefix+req.GameID)
ctx.Payload(rsp)
}
}

View File

@ -2,8 +2,6 @@ package app
import (
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"strings"
"sync"
@ -18,36 +16,6 @@ import (
"go.uber.org/zap"
)
func matchingRewardQuantity(reward *model.ActivityRewardSettings, isDoubled bool) int {
if reward == nil {
return 0
}
quantity := int(reward.DropQuantity)
if quantity < 1 {
quantity = 1
}
if isDoubled {
quantity *= 2
}
return quantity
}
func matchingRewardDisplayName(name string, isDoubled bool) string {
if !isDoubled {
return name
}
return name + "(翻倍)"
}
type matchingSettlementPlan struct {
reward *model.ActivityRewardSettings
quantity int
productName string
rewardName string
cardToVoid int64
drawLogID int64
}
// grantRewardHelper 发放奖励辅助函数
func (h *handler) grantRewardHelper(ctx context.Context, userID, orderID int64, r *model.ActivityRewardSettings, quantity int, remark string) error {
// 1. Grant to Order (Delegating stock check to user service)
@ -83,162 +51,6 @@ func (h *handler) grantRewardHelper(ctx context.Context, userID, orderID int64,
return err
}
func (h *handler) buildMatchingSettlementPlan(ctx context.Context, game *activitysvc.MatchingGame, order *model.Orders, candidate *model.ActivityRewardSettings) *matchingSettlementPlan {
if candidate == nil {
return nil
}
productName := ""
if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil {
productName = p.Name
}
plan := &matchingSettlementPlan{
reward: candidate,
quantity: matchingRewardQuantity(candidate, false),
productName: productName,
rewardName: matchingRewardDisplayName(productName, false),
}
icID := parseItemCardIDFromRemark(order.Remark)
h.logger.Debug("MatchingSettlement: 道具卡检查",
zap.String("order_no", order.OrderNo),
zap.String("remark", order.Remark),
zap.Int64("icID", icID))
if icID <= 0 {
return plan
}
uic, _ := h.readDB.UserItemCards.WithContext(ctx).Where(
h.readDB.UserItemCards.ID.Eq(icID),
h.readDB.UserItemCards.UserID.Eq(game.UserID),
).First()
if uic == nil || uic.Status != 1 {
return plan
}
ic, _ := h.readDB.SystemItemCards.WithContext(ctx).Where(
h.readDB.SystemItemCards.ID.Eq(uic.CardID),
h.readDB.SystemItemCards.Status.Eq(1),
).First()
now := time.Now()
if ic == nil || uic.ValidStart.After(now) || uic.ValidEnd.Before(now) {
return plan
}
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID)
if !scopeOK {
return plan
}
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
plan.cardToVoid = icID
plan.quantity = matchingRewardQuantity(plan.reward, true)
plan.rewardName = matchingRewardDisplayName(plan.productName, true)
return plan
}
if ic.EffectType != 2 || ic.BoostRateX1000 <= 0 {
return plan
}
plan.cardToVoid = icID
allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx).Where(
h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID),
).Find()
var better *model.ActivityRewardSettings
for _, r := range allRewards {
if r.MinScore > candidate.MinScore && r.Quantity > 0 {
if better == nil || r.MinScore < better.MinScore {
better = r
}
}
}
if better == nil {
return plan
}
randBytes := make([]byte, 4)
rand.Read(randBytes)
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
if randVal >= ic.BoostRateX1000 {
return plan
}
betterProdName := ""
if better.ProductID > 0 {
if bp, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(better.ProductID)).First(); bp != nil {
betterProdName = bp.Name
}
}
plan.reward = better
plan.productName = betterProdName
plan.rewardName = matchingRewardDisplayName(betterProdName, false) + "(升级)"
plan.quantity = matchingRewardQuantity(better, false)
return plan
}
func (h *handler) settleMatchingReward(ctx context.Context, game *activitysvc.MatchingGame, order *model.Orders, candidate *model.ActivityRewardSettings, auto bool) (*matchingSettlementPlan, error) {
plan := h.buildMatchingSettlementPlan(ctx, game, order, candidate)
if plan == nil || plan.reward == nil || plan.quantity <= 0 {
return nil, nil
}
drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx).Where(
h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID),
).First()
if drawLog != nil {
plan.drawLogID = drawLog.ID
}
var existingCount int64
query := h.readDB.UserInventory.WithContext(ctx).Where(
h.readDB.UserInventory.UserID.Eq(game.UserID),
h.readDB.UserInventory.OrderID.Eq(game.OrderID),
h.readDB.UserInventory.RewardID.Eq(plan.reward.ID),
)
if auto {
query = query.Where(h.readDB.UserInventory.Remark.Like(order.OrderNo + "%"))
}
existingCount, _ = query.Count()
missingCount := int64(plan.quantity) - existingCount
if missingCount < 0 {
missingCount = 0
}
if missingCount > 0 {
if err := h.grantRewardHelper(ctx, game.UserID, game.OrderID, plan.reward, int(missingCount), fmt.Sprintf("%s %s", order.OrderNo, plan.rewardName)); err != nil {
return nil, err
}
} else if plan.drawLogID > 0 {
_, _ = h.writeDB.ActivityDrawLogs.WithContext(ctx).Where(
h.writeDB.ActivityDrawLogs.ID.Eq(plan.drawLogID),
).Updates(&model.ActivityDrawLogs{
IsWinner: 1,
RewardID: plan.reward.ID,
Level: plan.reward.Level,
})
}
if plan.cardToVoid > 0 && plan.drawLogID > 0 {
now := time.Now()
_, _ = h.writeDB.UserItemCards.WithContext(ctx).Where(
h.writeDB.UserItemCards.ID.Eq(plan.cardToVoid),
h.writeDB.UserItemCards.UserID.Eq(game.UserID),
h.writeDB.UserItemCards.Status.Eq(1),
).Updates(map[string]any{
h.writeDB.UserItemCards.Status.ColumnName().String(): 2,
h.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): plan.drawLogID,
h.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): game.ActivityID,
h.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): game.IssueID,
h.writeDB.UserItemCards.UsedAt.ColumnName().String(): now,
})
}
return plan, nil
}
var matchingCleanupOnce sync.Once
func (h *handler) startMatchingGameCleanup() {
@ -428,28 +240,33 @@ func (h *handler) doAutoCheck(ctx context.Context, gameID string, game *activity
}
if candidate != nil {
plan, err := h.settleMatchingReward(ctx, game, order, candidate, true)
if err != nil {
productName := ""
if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil {
productName = p.Name
}
finalRemark := fmt.Sprintf("%s %s (自动开奖)", order.OrderNo, productName)
if err := h.grantRewardHelper(ctx, game.UserID, game.OrderID, candidate, 1, finalRemark); err != nil {
h.logger.Error("对对碰自动开奖: 发放奖励失败", zap.Int64("order_id", game.OrderID), zap.Error(err))
} else if plan != nil {
} else {
prodImage := ""
if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(plan.reward.ProductID)).First(); p != nil {
if p, _ := h.readDB.Products.WithContext(ctx).Where(h.readDB.Products.ID.Eq(candidate.ProductID)).First(); p != nil {
prodImage = getFirstImage(p.ImagesJSON)
}
rewardInfo = &MatchingRewardInfo{
RewardID: plan.reward.ID,
Name: plan.rewardName,
ProductName: plan.productName,
RewardID: candidate.ID,
Name: productName,
ProductName: productName,
ProductImage: prodImage,
Level: plan.reward.Level,
Level: candidate.Level,
}
h.logger.Info("对对碰自动开奖: 奖励发放成功",
zap.Int64("order_id", game.OrderID),
zap.String("product_name", plan.productName),
zap.Int32("level", plan.reward.Level),
zap.Int("quantity", plan.quantity))
zap.String("product_name", productName),
zap.Int32("level", candidate.Level))
}
}
}

View File

@ -1,38 +0,0 @@
package app
import (
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
)
func (h *handler) GetPendingPrizeGrantActivity() core.HandlerFunc {
return func(ctx core.Context) {
userID := int64(ctx.SessionUserInfo().Id)
res, err := h.prizeGrant.GetPendingActivity(ctx.RequestContext(), userID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) ClaimPrizeGrantActivity() core.HandlerFunc {
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || activityID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
userID := int64(ctx.SessionUserInfo().Id)
res, err := h.prizeGrant.ClaimActivity(ctx.RequestContext(), activityID, userID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(res)
}
}

View File

@ -1,119 +0,0 @@
package app
import (
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
welfaresvc "bindbox-game/internal/service/welfare_activity"
)
type listWelfareActivitiesRequest struct {
Type string `form:"type"`
Status string `form:"status"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listWelfareParticipantsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listWelfareWinnersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
func (h *handler) ListWelfareActivities() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listWelfareActivitiesRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
status := req.Status
if status == "" {
status = welfaresvc.StatusActive
}
res, err := h.welfare.ListActivities(ctx.RequestContext(), welfaresvc.ListActivitiesRequest{Type: req.Type, Status: status, Page: req.Page, PageSize: req.PageSize})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) GetWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
userID := int64(ctx.SessionUserInfo().Id)
res, err := h.welfare.GetActivity(ctx.RequestContext(), id, userID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) JoinWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
info := ctx.SessionUserInfo()
userID := int64(info.Id)
if userID <= 0 {
ctx.AbortWithError(core.Error(http.StatusUnauthorized, code.AuthorizationError, "请先登录"))
return
}
if err := h.welfare.Join(ctx.RequestContext(), id, userID); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(map[string]string{"message": "参与成功"})
}
}
func (h *handler) ListWelfareParticipants() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
req := new(listWelfareParticipantsRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.welfare.ListParticipants(ctx.RequestContext(), id, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) ListWelfareWinners() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
req := new(listWelfareWinnersRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.welfare.ListWinners(ctx.RequestContext(), id, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}

View File

@ -13,13 +13,11 @@ import (
gamesvc "bindbox-game/internal/service/game"
livestreamsvc "bindbox-game/internal/service/livestream"
productsvc "bindbox-game/internal/service/product"
prizegrantsvc "bindbox-game/internal/service/prize_grant_activity"
snapshotsvc "bindbox-game/internal/service/snapshot"
synthesissvc "bindbox-game/internal/service/synthesis"
syscfgsvc "bindbox-game/internal/service/sysconfig"
titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user"
welfaresvc "bindbox-game/internal/service/welfare_activity"
"github.com/redis/go-redis/v9"
)
@ -43,8 +41,6 @@ type handler struct {
livestream livestreamsvc.Service
synthesis synthesissvc.Service
financeSvc financesvc.Service // P&L service (read-only)
welfare welfaresvc.Service
prizeGrant prizegrantsvc.Service
}
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler {
@ -73,7 +69,5 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
livestream: livestreamsvc.New(logger, db, ticketSvc),
synthesis: synthesissvc.New(db),
financeSvc: financesvc.New(logger, db),
welfare: welfaresvc.New(logger, db),
prizeGrant: prizegrantsvc.New(logger, db),
}
}

View File

@ -736,10 +736,6 @@ func parseRange(rangeType, startS, endS string) (time.Time, time.Time) {
e := now
s := e.Add(-30 * 24 * time.Hour)
return s, e
case "all":
e := now
s := time.Date(2000, 1, 1, 0, 0, 0, 0, now.Location())
return s, e
case "custom":
if startS != "" && endS != "" {
if st, err := time.Parse("2006-01-02", startS); err == nil {
@ -1858,80 +1854,61 @@ type productPerformanceItem struct {
func (h *handler) OperationsProductPerformance() core.HandlerFunc {
return func(ctx core.Context) {
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), ctx.Request().URL.Query().Get("start"), ctx.Request().URL.Query().Get("end"))
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
type performanceRow struct {
ActivityID int64 `gorm:"column:activity_id"`
SalesCount int64 `gorm:"column:sales_count"`
PaymentCount int64 `gorm:"column:payment_count"`
GamePassCount int64 `gorm:"column:game_pass_count"`
TotalCost int64 `gorm:"column:total_cost"`
PriceDraw int64 `gorm:"column:price_draw"`
RevenueCents int64 `gorm:"column:revenue_cents"`
ContributionBase int64 `gorm:"column:contribution_base"`
// 按活动聚合抽奖数据
type drawRow struct {
ActivityID int64 `gorm:"column:activity_id"`
Count int64 `gorm:"column:count"`
TotalCost int64 `gorm:"column:total_cost"`
}
var rows []performanceRow
var rows []drawRow
if err := db.Table(model.TableNameActivityDrawLogs).
Select(`
activity_issues.activity_id,
SUM(CASE WHEN orders.status = 2 AND (
orders.source_type = 4
OR orders.order_no LIKE 'GP%'
OR (orders.actual_amount = 0 AND COALESCE(orders.remark, '') LIKE '%use_game_pass%')
) THEN 1 ELSE 0 END) as game_pass_count,
SUM(CASE WHEN orders.status = 2 AND NOT (
orders.source_type = 4
OR orders.order_no LIKE 'GP%'
OR (orders.actual_amount = 0 AND COALESCE(orders.remark, '') LIKE '%use_game_pass%')
) THEN 1 ELSE 0 END) as payment_count,
SUM(CASE WHEN orders.status = 2 THEN 1 ELSE 0 END) as sales_count,
COALESCE(MAX(activities.price_draw), 0) as price_draw,
SUM(CASE WHEN orders.status = 2 THEN COALESCE(activities.price_draw, 0) ELSE 0 END) as revenue_cents,
SUM(CASE WHEN orders.status = 2 THEN COALESCE(activities.price_draw, 0) ELSE 0 END) as contribution_base,
CAST(SUM(CASE WHEN orders.status = 2 THEN COALESCE(products.cost_price, 0) * (
COALESCE(NULLIF(activity_reward_settings.drop_quantity, 0), 1) +
CASE WHEN user_item_cards.used_draw_log_id = activity_draw_logs.id AND system_item_cards.effect_type = 1 AND system_item_cards.reward_multiplier_x1000 >= 2000 THEN 1 ELSE 0 END
) ELSE 0 END) AS SIGNED) as total_cost
`).
// 统计抽奖日志,按活动分组,并计算奖品成本
if err := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_id").
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Where("orders.status = ?", 2).
Where("COALESCE(orders.paid_at, orders.created_at) >= ?", s).
Where("COALESCE(orders.paid_at, orders.created_at) <= ?", e).
Where("activity_draw_logs.created_at >= ?", s).
Where("activity_draw_logs.created_at <= ?", e).
Select(
"activity_issues.activity_id",
"COUNT(activity_draw_logs.id) as count",
"CAST(SUM(IF(activity_draw_logs.is_winner = 1, COALESCE(activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000, 0)) AS SIGNED) as total_cost",
).
Group("activity_issues.activity_id").
Order("sales_count DESC").
Order("count DESC").
Limit(10).
Scan(&rows).Error; err != nil {
h.logger.Error(fmt.Sprintf("OperationsProductPerformance stats error: %v", err))
h.logger.Error(fmt.Sprintf("OperationsProductPerformance draw cost stats error: %v", err))
}
// 获取活动详情(名称和单价)
activityIDs := make([]int64, len(rows))
for i, r := range rows {
activityIDs[i] = r.ActivityID
}
type actInfo struct {
Name string
Name string
PriceDraw int64
}
actMap := make(map[int64]actInfo)
if len(activityIDs) > 0 {
acts, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.Activities.ID.In(activityIDs...)).Find()
for _, a := range acts {
actMap[a.ID] = actInfo{Name: a.Name}
actMap[a.ID] = actInfo{Name: a.Name, PriceDraw: a.PriceDraw}
}
}
var totalRevenueCents int64
// 计算总数用于贡献率
var totalCount int64
for _, r := range rows {
totalRevenueCents += r.ContributionBase
totalCount += r.Count
}
out := make([]productPerformanceItem, len(rows))
@ -1939,29 +1916,30 @@ func (h *handler) OperationsProductPerformance() core.HandlerFunc {
info := actMap[r.ActivityID]
var contribution float64
if totalRevenueCents > 0 {
contribution = float64(r.ContributionBase) / float64(totalRevenueCents) * 100
if totalCount > 0 {
contribution = float64(r.Count) / float64(totalCount) * 100
}
// 周转率简化计算
days := e.Sub(s).Hours() / 24
if days < 1 {
days = 1
}
turnover := float64(r.SalesCount) / days * 7
turnover := float64(r.Count) / days * 7
profitCents := r.RevenueCents - r.TotalCost
out[i] = productPerformanceItem{
ID: r.ActivityID,
SeriesName: info.Name,
SalesCount: r.SalesCount,
Amount: r.RevenueCents / 100,
Profit: profitCents / 100,
SalesCount: r.Count,
Amount: (r.Count * info.PriceDraw) / 100, // 转换为元
Profit: (r.Count*info.PriceDraw - r.TotalCost) / 100,
ProfitRate: 0,
ContributionRate: float64(int(contribution*10)) / 10.0,
InventoryTurnover: float64(int(turnover*10)) / 10.0,
}
if r.RevenueCents > 0 {
pr := float64(profitCents) / float64(r.RevenueCents) * 100
if r.Count > 0 && info.PriceDraw > 0 {
revenue := r.Count * info.PriceDraw
pr := float64(revenue-r.TotalCost) / float64(revenue) * 100
out[i].ProfitRate = float64(int(pr*10)) / 10.0
}
}

View File

@ -1,233 +0,0 @@
package admin
import (
"net/http"
"strconv"
"strings"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
prizegrantsvc "bindbox-game/internal/service/prize_grant_activity"
)
type savePrizeGrantActivityRequest struct {
Reason string `json:"reason" binding:"required"`
Status string `json:"status"`
Rewards []prizegrantsvc.RewardInput `json:"rewards" binding:"required"`
}
type listPrizeGrantActivitiesRequest struct {
Reason string `form:"reason"`
Status string `form:"status"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listPrizeGrantRecordsRequest struct {
Status string `form:"status"`
Keyword string `form:"keyword"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type markPrizeGrantUsersRequest struct {
UserIDs []int64 `json:"user_ids" binding:"required"`
}
func (h *handler) CreatePrizeGrantActivity() core.HandlerFunc {
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作"))
return
}
req := new(savePrizeGrantActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
item, err := h.prizeGrant.CreateActivity(ctx.RequestContext(), prizegrantsvc.SaveActivityRequest{Reason: req.Reason, Status: req.Status, Rewards: req.Rewards})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateActivityError, err.Error()))
return
}
ctx.Payload(item)
}
}
func (h *handler) UpdatePrizeGrantActivity() core.HandlerFunc {
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作"))
return
}
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
req := new(savePrizeGrantActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if err := h.prizeGrant.UpdateActivity(ctx.RequestContext(), id, prizegrantsvc.SaveActivityRequest{Reason: req.Reason, Status: req.Status, Rewards: req.Rewards}); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
func (h *handler) DeletePrizeGrantActivity() core.HandlerFunc {
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作"))
return
}
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
if err := h.prizeGrant.DeleteActivity(ctx.RequestContext(), id); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.DeleteActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "删除成功"})
}
}
func (h *handler) ListPrizeGrantActivities() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listPrizeGrantActivitiesRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.prizeGrant.ListActivities(ctx.RequestContext(), prizegrantsvc.ListActivitiesRequest{Reason: req.Reason, Status: req.Status, Page: req.Page, PageSize: req.PageSize})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) GetPrizeGrantCostSummary() core.HandlerFunc {
return func(ctx core.Context) {
res, err := h.prizeGrant.GetCostSummary(ctx.RequestContext())
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) GetPrizeGrantActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
res, err := h.prizeGrant.GetActivity(ctx.RequestContext(), id)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) ListPrizeGrantUserRecords() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
req := new(listPrizeGrantRecordsRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.prizeGrant.ListUserRecords(ctx.RequestContext(), id, strings.TrimSpace(req.Status), strings.TrimSpace(req.Keyword), req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) MarkPrizeGrantUsersProcessed() core.HandlerFunc {
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作"))
return
}
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
req := new(markPrizeGrantUsersRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
adminID := int64(ctx.SessionUserInfo().Id)
if err := h.prizeGrant.MarkUsersProcessed(ctx.RequestContext(), id, adminID, req.UserIDs); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
func (h *handler) MarkAllPrizeGrantUsersProcessed() core.HandlerFunc {
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作"))
return
}
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
adminID := int64(ctx.SessionUserInfo().Id)
count, err := h.prizeGrant.MarkAllUsersProcessed(ctx.RequestContext(), id, adminID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(map[string]any{"message": "操作成功", "count": count})
}
}
func (h *handler) DeletePrizeGrantUserRecord() core.HandlerFunc {
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusForbidden, code.AuthorizationError, "无权限操作"))
return
}
activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || activityID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
recordID, err := strconv.ParseInt(ctx.Param("record_id"), 10, 64)
if err != nil || recordID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "记录ID无效"))
return
}
if err := h.prizeGrant.DeleteUserRecord(ctx.RequestContext(), activityID, recordID); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.DeleteActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "删除成功"})
}
}

View File

@ -1,311 +0,0 @@
package admin
import (
"net/http"
"strconv"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
welfaresvc "bindbox-game/internal/service/welfare_activity"
)
type saveWelfareActivityRequest struct {
Title string `json:"title" binding:"required"`
Type string `json:"type" binding:"required"`
ThresholdAmount int64 `json:"threshold_amount"`
StartTime string `json:"start_time" binding:"required"`
EndTime string `json:"end_time" binding:"required"`
DrawTime string `json:"draw_time" binding:"required"`
Status string `json:"status"`
Description string `json:"description"`
CoverImage string `json:"cover_image"`
Prizes []welfaresvc.PrizeInput `json:"prizes"`
}
func (h *handler) CreateWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
req := new(saveWelfareActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
input, err := req.toInput()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
item, err := h.welfare.CreateActivity(ctx.RequestContext(), input)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateActivityError, err.Error()))
return
}
ctx.Payload(map[string]interface{}{"id": item.ID, "message": "操作成功"})
}
}
func (h *handler) UpdateWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
req := new(saveWelfareActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
input, err := req.toInput()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
if err := h.welfare.UpdateActivity(ctx.RequestContext(), id, input); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
type listAdminWelfareActivitiesRequest struct {
Title string `form:"title"`
Type string `form:"type"`
Status string `form:"status"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type listAdminWelfareWinnersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type welfareCostSummaryRequest struct {
StartTime string `form:"start_time"`
EndTime string `form:"end_time"`
}
func (h *handler) ListWelfareActivities() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listAdminWelfareActivitiesRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.welfare.ListActivities(ctx.RequestContext(), welfaresvc.ListActivitiesRequest{Type: req.Type, Status: req.Status, Title: req.Title, Page: req.Page, PageSize: req.PageSize})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) GetWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
res, err := h.welfare.GetActivityAdmin(ctx.RequestContext(), id)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.GetActivityError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) DeleteWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
if err := h.welfare.DeleteActivity(ctx.RequestContext(), id); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.DeleteActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
type copyWelfareActivityRequest struct {
Title string `json:"title"`
Type string `json:"type" binding:"required"`
ThresholdAmount int64 `json:"threshold_amount"`
StartTime string `json:"start_time" binding:"required"`
EndTime string `json:"end_time" binding:"required"`
DrawTime string `json:"draw_time" binding:"required"`
Status string `json:"status"`
}
func (h *handler) CopyWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "活动ID无效"))
return
}
req := new(copyWelfareActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
start, err := parseRequiredTime(req.StartTime)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
end, err := parseRequiredTime(req.EndTime)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
draw, err := parseRequiredTime(req.DrawTime)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
newID, err := h.welfare.CopyActivity(ctx.RequestContext(), id, welfaresvc.SaveActivityRequest{
Title: req.Title,
Type: req.Type,
ThresholdAmount: req.ThresholdAmount,
StartTime: start,
EndTime: end,
DrawTime: draw,
Status: req.Status,
})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateActivityError, err.Error()))
return
}
ctx.Payload(map[string]interface{}{"new_activity_id": newID, "status": "success"})
}
}
func (h *handler) ListWelfareParticipants() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
res, err := h.welfare.ListParticipants(ctx.RequestContext(), id, 1, 100)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) ListWelfareWinners() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
req := new(listAdminWelfareWinnersRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
res, err := h.welfare.ListWinners(ctx.RequestContext(), id, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) DrawWelfareActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err := h.welfare.Draw(ctx.RequestContext(), id); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ModifyActivityError, err.Error()))
return
}
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
}
}
func (h *handler) GetWelfareCost() core.HandlerFunc {
return func(ctx core.Context) {
id, _ := strconv.ParseInt(ctx.Param("id"), 10, 64)
res, err := h.welfare.GetCost(ctx.RequestContext(), id)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (h *handler) GetWelfareCostSummary() core.HandlerFunc {
return func(ctx core.Context) {
req := new(welfareCostSummaryRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
}
start, _ := parseOptionalTime(req.StartTime)
end, _ := parseOptionalTime(req.EndTime)
res, err := h.welfare.GetCostSummary(ctx.RequestContext(), start, end)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListActivitiesError, err.Error()))
return
}
ctx.Payload(res)
}
}
func (r *saveWelfareActivityRequest) toInput() (welfaresvc.SaveActivityRequest, error) {
start, err := parseRequiredTime(r.StartTime)
if err != nil {
return welfaresvc.SaveActivityRequest{}, err
}
end, err := parseRequiredTime(r.EndTime)
if err != nil {
return welfaresvc.SaveActivityRequest{}, err
}
if sameDay(start, end) && end.Hour() == 0 && end.Minute() == 0 && end.Second() == 0 {
end = time.Date(end.Year(), end.Month(), end.Day(), 23, 59, 59, 0, end.Location())
}
draw, err := parseRequiredTime(r.DrawTime)
if err != nil {
return welfaresvc.SaveActivityRequest{}, err
}
prizes := make([]welfaresvc.PrizeInput, 0, len(r.Prizes))
for _, prize := range r.Prizes {
if prize.RewardType == "" && prize.ProductID > 0 {
prize.RewardType = welfaresvc.RewardTypeProduct
prize.RewardRefID = prize.ProductID
}
prizes = append(prizes, prize)
}
return welfaresvc.SaveActivityRequest{Title: r.Title, Type: r.Type, ThresholdAmount: r.ThresholdAmount, StartTime: start, EndTime: end, DrawTime: draw, Status: r.Status, Description: r.Description, CoverImage: r.CoverImage, Prizes: prizes}, nil
}
func parseRequiredTime(v string) (time.Time, error) {
if t, err := time.Parse(time.RFC3339, v); err == nil {
return t, nil
}
return time.ParseInLocation("2006-01-02 15:04:05", v, time.Local)
}
func parseOptionalTime(v string) (*time.Time, error) {
if v == "" {
return nil, nil
}
t, err := parseRequiredTime(v)
if err != nil {
return nil, err
}
return &t, nil
}
func sameDay(a, b time.Time) bool {
y1, m1, d1 := a.Date()
y2, m2, d2 := b.Date()
return y1 == y2 && m1 == m2 && d1 == d2
}

View File

@ -9,13 +9,10 @@ import (
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/service/game"
usersvc "bindbox-game/internal/service/user"
"context"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
@ -351,29 +348,13 @@ func (h *handler) VerifyTicket() core.HandlerFunc {
}
}
type settlePlayerRecord struct {
UserID int64 `json:"user_id"`
Ticket string `json:"ticket"`
Win bool `json:"win"`
Rank int `json:"rank"`
Score int `json:"score"`
DamageDealt int `json:"damage_dealt"`
DamageTaken int `json:"damage_taken"`
Kills int `json:"kills"`
ChestsCollected int `json:"chests_collected"`
RoundsSurvived int `json:"rounds_survived"`
}
type settleRequest struct {
MatchID string `json:"match_id"`
GameType string `json:"game_type"`
TotalRounds int `json:"total_rounds"`
Players []settlePlayerRecord `json:"players"`
// 兼容旧版单人结算字段
UserID string `json:"user_id"`
Ticket string `json:"ticket"`
Win bool `json:"win"`
Score int `json:"score"`
UserID string `json:"user_id"`
Ticket string `json:"ticket"`
MatchID string `json:"match_id"`
Win bool `json:"win"`
Score int `json:"score"`
GameType string `json:"game_type"` // 游戏类型,如 "minesweeper" 或 "minesweeper_free"
}
type settleResponse struct {
@ -381,22 +362,7 @@ type settleResponse struct {
Reward string `json:"reward,omitempty"`
}
func calcRankPoints(rank int) int64 {
switch rank {
case 1:
return 1000
case 2:
return -900
case 3:
return -1100
case 4:
return -1300
default:
return 0
}
}
// SettleGame Internal游戏结算批量全员
// SettleGame Internal游戏结算
// @Summary 游戏结算
// @Tags Internal.游戏
// @Param RequestBody body settleRequest true "请求参数"
@ -410,197 +376,103 @@ func (h *handler) SettleGame() core.HandlerFunc {
return
}
// 兼容旧版单人结算Nakama 未升级时的过渡)
if len(req.Players) == 0 && req.UserID != "" {
uid, _ := strconv.ParseInt(req.UserID, 10, 64)
if uid > 0 {
rank := 2
if req.Win {
rank = 1
}
req.Players = []settlePlayerRecord{{
UserID: uid,
Ticket: req.Ticket,
Win: req.Win,
Rank: rank,
Score: req.Score,
}}
if req.GameType == "" {
req.GameType = "minesweeper"
}
}
}
// 直接从请求参数判断是否为免费模式
isFreeMode := req.GameType == "minesweeper_free"
if len(req.Players) == 0 {
ctx.Payload(&settleResponse{Success: true})
// 拦截免费场结算(免费模式不发放任何奖励)
if isFreeMode {
h.logger.Info("Free mode game settled without rewards",
zap.String("user_id", req.UserID),
zap.String("match_id", req.MatchID),
zap.Bool("win", req.Win))
ctx.Payload(&settleResponse{Success: true, Reward: "体验模式无奖励"})
return
}
// 幂等检查match_id 已结算则直接返回
if req.MatchID != "" {
var count int64
h.db.GetDbR().Table("minesweeper_game_records").Where("match_id = ?", req.MatchID).Count(&count)
if count > 0 {
h.logger.Info("Game already settled, skip", zap.String("match_id", req.MatchID))
ctx.Payload(&settleResponse{Success: true})
return
}
}
// 验证 ticket可选用于防止重复结算
if req.Ticket != "" {
storedValue, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result()
if err != nil {
h.logger.Warn("Ticket validation failed (not found)", zap.String("ticket", req.Ticket))
} else {
// Parse "userID:gameType"
parts := strings.Split(storedValue, ":")
storedUserID := parts[0]
isFreeMode := req.GameType == "minesweeper_free"
now := time.Now()
// 读取奖励配置(付费场用)
var msConfig struct {
WinnerRewardPoints int64 `json:"winner_reward_points"`
WinnerRewardProductID int64 `json:"winner_reward_product_id"`
ParticipationRewardPoints int64 `json:"participation_reward_points"`
}
if !isFreeMode {
conf, _ := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).
Where(h.readDB.SystemConfigs.ConfigKey.Eq("game_minesweeper_config")).First()
if conf != nil {
json.Unmarshal([]byte(conf.ConfigValue), &msConfig)
}
}
for _, p := range req.Players {
// 兜底:旧客户端漏传 Rank 时按 Win 推断
rank := p.Rank
if rank == 0 {
rank = 2
if p.Win {
rank = 1
}
}
p.Rank = rank
p.Win = p.Rank == 1
rankPoints := calcRankPoints(p.Rank)
rawJSON, _ := json.Marshal(p)
// 写入游戏记录(忽略 duplicate key 错误)
h.db.GetDbW().Exec(`
INSERT IGNORE INTO minesweeper_game_records
(match_id, user_id, game_type, ticket, is_winner, rank_position, total_players,
total_rounds, rounds_survived, score, damage_dealt, damage_taken, kills,
chests_collected, rank_points, raw_summary, settled_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
req.MatchID, p.UserID, req.GameType, p.Ticket,
p.Win, p.Rank, len(req.Players),
req.TotalRounds, p.RoundsSurvived, p.Score,
p.DamageDealt, p.DamageTaken, p.Kills,
p.ChestsCollected, rankPoints, string(rawJSON), now, now,
)
// UPSERT 聚合榜
wins, losses := 0, 1
if p.Win {
wins, losses = 1, 0
}
h.db.GetDbW().Exec(`
INSERT INTO minesweeper_leaderboard
(user_id, game_type, matches_played, wins, losses, win_rate,
total_score, best_score, avg_score,
total_damage_dealt, total_damage_taken, avg_damage_dealt,
total_chests_collected, total_rounds_survived,
total_rank_points, last_match_id, last_settled_at, created_at, updated_at)
VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
matches_played = matches_played + 1,
wins = wins + VALUES(wins),
losses = losses + VALUES(losses),
win_rate = ROUND((wins + VALUES(wins)) / (matches_played + 1), 4),
total_score = total_score + VALUES(total_score),
best_score = GREATEST(best_score, VALUES(best_score)),
avg_score = ROUND((total_score + VALUES(total_score)) / (matches_played + 1), 2),
total_damage_dealt = total_damage_dealt + VALUES(total_damage_dealt),
total_damage_taken = total_damage_taken + VALUES(total_damage_taken),
avg_damage_dealt = ROUND((total_damage_dealt + VALUES(total_damage_dealt)) / (matches_played + 1), 2),
total_chests_collected = total_chests_collected + VALUES(total_chests_collected),
total_rounds_survived = total_rounds_survived + VALUES(total_rounds_survived),
total_rank_points = total_rank_points + VALUES(total_rank_points),
last_match_id = VALUES(last_match_id),
last_settled_at = VALUES(last_settled_at),
updated_at = VALUES(updated_at)`,
p.UserID, req.GameType, wins, losses, float64(wins),
p.Score, p.Score, float64(p.Score),
p.DamageDealt, p.DamageTaken, float64(p.DamageDealt),
p.ChestsCollected, p.RoundsSurvived,
rankPoints, req.MatchID, now, now, now,
)
// 清除 Redis ticket
if p.Ticket != "" {
h.redis.Del(ctx.RequestContext(), "game:token:ticket:"+p.Ticket)
}
// 付费场发奖励(仅赢家)
if !isFreeMode && p.Win && p.UserID > 0 {
if msConfig.WinnerRewardProductID > 0 {
h.userSvc.GrantReward(ctx.RequestContext(), p.UserID, usersvc.GrantRewardRequest{
ProductID: msConfig.WinnerRewardProductID,
Quantity: 1,
Remark: "扫雷游戏奖励",
})
if storedUserID != req.UserID {
h.logger.Warn("Ticket validation failed (user mismatch)",
zap.String("ticket", req.Ticket), zap.String("user_id", req.UserID), zap.String("stored", storedUserID))
} else {
pts := msConfig.WinnerRewardPoints
if pts == 0 {
pts = 100
}
h.userSvc.AddPointsWithAction(ctx.RequestContext(), p.UserID, pts, "game_reward", "扫雷游戏奖励", "minesweeper_settle", nil, nil)
// 删除 ticket 防止重复使用
h.redis.Del(ctx.RequestContext(), "game:token:ticket:"+req.Ticket)
}
}
}
// 异步刷新排行榜缓存
go h.refreshLeaderboardCache(req.GameType)
// 注意即使ticket验证失败作为internal API我们仍然信任游戏服务器传来的UserID
ctx.Payload(&settleResponse{Success: true})
// 奖品发放逻辑
var rewardMsg string
var msConfig struct {
WinnerRewardPoints int64 `json:"winner_reward_points"`
ParticipationRewardPoints int64 `json:"participation_reward_points"`
WinnerRewardProductID int64 `json:"winner_reward_product_id"`
ParticipationRewardProductID int64 `json:"participation_reward_product_id"`
}
// 1. 读取配置
configKey := "game_minesweeper_config"
conf, err := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq(configKey)).First()
if err == nil && conf != nil {
json.Unmarshal([]byte(conf.ConfigValue), &msConfig)
}
uid, _ := strconv.ParseInt(req.UserID, 10, 64)
// 2. 确定奖励内容
var targetProductID int64
var targetPoints int64
if req.Win {
targetProductID = msConfig.WinnerRewardProductID
targetPoints = msConfig.WinnerRewardPoints
if targetPoints == 0 && targetProductID == 0 {
targetPoints = 100 // 兜底
}
} else {
targetProductID = msConfig.ParticipationRewardProductID
targetPoints = msConfig.ParticipationRewardPoints
if targetPoints == 0 && targetProductID == 0 {
targetPoints = 10 // 兜底
}
}
// 3. 发放奖励(仅付费模式,免费模式已在前面拦截)
if targetProductID > 0 {
res, err := h.userSvc.GrantReward(ctx.RequestContext(), uid, usersvc.GrantRewardRequest{
ProductID: targetProductID,
Quantity: 1,
Remark: "扫雷游戏奖励",
})
if err != nil || !res.Success {
h.logger.Error("Failed to grant game product reward", zap.Error(err), zap.String("msg", res.Message))
rewardMsg = "奖励发放失败"
} else {
rewardMsg = "获得奖品"
}
} else if targetPoints > 0 {
err := h.userSvc.AddPointsWithAction(ctx.RequestContext(), uid, targetPoints, "game_reward", "扫雷游戏奖励", "minesweeper_settle", nil, nil)
if err != nil {
h.logger.Error("Failed to grant game points", zap.Error(err))
}
rewardMsg = strconv.FormatInt(targetPoints, 10) + "积分"
}
ctx.Payload(&settleResponse{Success: true, Reward: rewardMsg})
}
}
func (h *handler) refreshLeaderboardCache(gameType string) {
type lbRow struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
TotalRankPoints int64 `json:"total_rank_points"`
MatchesPlayed int `json:"matches_played"`
Wins int `json:"wins"`
Losses int `json:"losses"`
WinRate float64 `json:"win_rate"`
BestScore int `json:"best_score"`
AvgScore float64 `json:"avg_score"`
AvgDamageDealt float64 `json:"avg_damage_dealt"`
TotalChests int64 `json:"total_chests_collected"`
}
var rows []lbRow
h.db.GetDbR().Raw(`
SELECT l.user_id,
COALESCE(u.nick_name, '') AS nickname,
COALESCE(u.avatar_url, '') AS avatar,
l.total_rank_points, l.matches_played, l.wins, l.losses,
CAST(l.win_rate AS DECIMAL(7,4)) AS win_rate,
l.best_score,
CAST(l.avg_score AS DECIMAL(12,2)) AS avg_score,
CAST(l.avg_damage_dealt AS DECIMAL(12,2)) AS avg_damage_dealt,
l.total_chests_collected
FROM minesweeper_leaderboard l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.game_type = ?
ORDER BY l.total_rank_points DESC, l.wins DESC, l.best_score DESC, l.user_id ASC
LIMIT 100`, gameType).Scan(&rows)
if len(rows) == 0 {
return
}
data, _ := json.Marshal(rows)
cacheKey := fmt.Sprintf("ms:lb:v1:%s:top100", gameType)
h.redis.Set(context.Background(), cacheKey, string(data), 5*time.Minute)
}
type consumeTicketRequest struct {
UserID string `json:"user_id"`
GameCode string `json:"game_code"`
@ -684,299 +556,6 @@ func (h *handler) GetMinesweeperConfig() core.HandlerFunc {
}
}
// GetLeaderboard App端排行榜查询
// @Summary 扫雷排行榜
// @Tags APP端.游戏
// @Param game_type query string false "游戏类型 minesweeper|minesweeper_free"
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Success 200 {object} map[string]any
// @Router /api/app/games/leaderboard [get]
func (h *handler) GetLeaderboard() core.HandlerFunc {
return func(ctx core.Context) {
var req struct {
GameType string `form:"game_type"`
Page int `form:"page"`
PageSize int `form:"page_size"`
}
_ = ctx.ShouldBindQuery(&req)
if req.GameType == "" {
req.GameType = "minesweeper"
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 || req.PageSize > 100 {
req.PageSize = 20
}
si := ctx.SessionUserInfo()
myUserID := int64(si.Id)
gameType := req.GameType
page := req.Page
pageSize := req.PageSize
// 直接查 MySQL实时数据不走缓存
type lbRow struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
TotalRankPoints int64 `json:"total_rank_points"`
MatchesPlayed int `json:"matches_played"`
Wins int `json:"wins"`
Losses int `json:"losses"`
WinRate float64 `json:"win_rate"`
BestScore int `json:"best_score"`
AvgScore float64 `json:"avg_score"`
}
var rows []lbRow
var total int64
offset := (page - 1) * pageSize
h.db.GetDbR().Table("minesweeper_leaderboard").Where("game_type = ?", gameType).Count(&total)
h.db.GetDbR().Raw(`
SELECT l.user_id,
COALESCE(u.nick_name, '') AS nickname,
COALESCE(u.avatar_url, '') AS avatar,
l.total_rank_points, l.matches_played, l.wins, l.losses,
CAST(l.win_rate AS DECIMAL(7,4)) AS win_rate,
l.best_score,
CAST(l.avg_score AS DECIMAL(12,2)) AS avg_score
FROM minesweeper_leaderboard l
LEFT JOIN users u ON u.id = l.user_id
WHERE l.game_type = ?
ORDER BY l.total_rank_points DESC, l.wins DESC, l.best_score DESC, l.user_id ASC
LIMIT ? OFFSET ?`, gameType, pageSize, offset).Scan(&rows)
// 补名次
list := make([]map[string]any, 0, len(rows))
for i, r := range rows {
m := map[string]any{
"rank": offset + i + 1,
"user_id": r.UserID,
"nickname": r.Nickname,
"avatar": r.Avatar,
"total_rank_points": r.TotalRankPoints,
"matches_played": r.MatchesPlayed,
"wins": r.Wins,
"losses": r.Losses,
"win_rate": r.WinRate,
"best_score": r.BestScore,
"avg_score": r.AvgScore,
}
list = append(list, m)
}
ctx.Payload(map[string]any{
"game_type": gameType,
"page": page,
"page_size": pageSize,
"total": total,
"list": list,
"me": h.queryMyRank(ctx.RequestContext(), myUserID, gameType),
})
}
}
// GetLeaderboardInternal 内部排行榜查询(供 Nakama RPC 代理)
func (h *handler) GetLeaderboardInternal() core.HandlerFunc {
return h.GetLeaderboard()
}
func (h *handler) queryMyRank(ctx context.Context, userID int64, gameType string) map[string]any {
if userID <= 0 {
return nil
}
var count int64
h.db.GetDbR().Table("minesweeper_leaderboard").
Where("user_id = ? AND game_type = ?", userID, gameType).
Count(&count)
if count == 0 {
return nil
}
var myPoints int64
h.db.GetDbR().Table("minesweeper_leaderboard").
Select("total_rank_points").
Where("user_id = ? AND game_type = ?", userID, gameType).
Scan(&myPoints)
var myRank int64
h.db.GetDbR().Table("minesweeper_leaderboard").
Where("game_type = ? AND total_rank_points > ?", gameType, myPoints).
Count(&myRank)
var row struct {
Wins int `json:"wins"`
MatchesPlayed int `json:"matches_played"`
WinRate float64 `json:"win_rate"`
BestScore int `json:"best_score"`
}
h.db.GetDbR().Table("minesweeper_leaderboard").
Select("wins, matches_played, CAST(win_rate AS DECIMAL(7,4)) as win_rate, best_score").
Where("user_id = ? AND game_type = ?", userID, gameType).
Scan(&row)
return map[string]any{
"rank": myRank + 1,
"user_id": userID,
"total_rank_points": myPoints,
"wins": row.Wins,
"matches_played": row.MatchesPlayed,
"win_rate": row.WinRate,
"best_score": row.BestScore,
}
}
// ========== Admin API: 排行榜 & 对战记录 ==========
// GetAdminLeaderboard Admin查询扫雷排行榜
// @Summary 查询扫雷排行榜
// @Tags 管理端.游戏
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param nickname query string false "按玩家昵称模糊搜索"
// @Success 200 {object} map[string]any
// @Router /api/admin/games/leaderboard [get]
func (h *handler) GetAdminLeaderboard() core.HandlerFunc {
return func(ctx core.Context) {
var req struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Nickname string `form:"nickname"`
}
_ = ctx.ShouldBindQuery(&req)
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 || req.PageSize > 100 {
req.PageSize = 20
}
offset := (req.Page - 1) * req.PageSize
type lbRow struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
TotalRankPoints int64 `json:"total_rank_points"`
MatchesPlayed int `json:"matches_played"`
Wins int `json:"wins"`
Losses int `json:"losses"`
WinRate float64 `json:"win_rate"`
BestScore int `json:"best_score"`
AvgScore float64 `json:"avg_score"`
}
query := h.db.GetDbR().Table("minesweeper_leaderboard l").
Select("l.user_id, COALESCE(u.nick_name,'') AS nickname, COALESCE(u.avatar_url,'') AS avatar, l.total_rank_points, l.matches_played, l.wins, l.losses, CAST(l.win_rate AS DECIMAL(7,4)) AS win_rate, l.best_score, CAST(l.avg_score AS DECIMAL(12,2)) AS avg_score").
Joins("LEFT JOIN users u ON u.id = l.user_id").
Where("l.game_type = ?", "minesweeper")
if req.Nickname != "" {
query = query.Where("u.nick_name LIKE ?", "%"+req.Nickname+"%")
}
var total int64
query.Count(&total)
var rows []lbRow
query.Order("l.total_rank_points DESC, l.wins DESC, l.best_score DESC").
Limit(req.PageSize).Offset(offset).Scan(&rows)
list := make([]map[string]any, 0, len(rows))
for i, r := range rows {
list = append(list, map[string]any{
"rank": offset + i + 1,
"user_id": r.UserID,
"nickname": r.Nickname,
"avatar": r.Avatar,
"total_rank_points": r.TotalRankPoints,
"matches_played": r.MatchesPlayed,
"wins": r.Wins,
"losses": r.Losses,
"win_rate": r.WinRate,
"best_score": r.BestScore,
"avg_score": r.AvgScore,
})
}
ctx.Payload(map[string]any{
"total": total,
"page": req.Page,
"page_size": req.PageSize,
"list": list,
})
}
}
// GetAdminGameRecords Admin查询扫雷对战记录
// @Summary 查询扫雷对战记录
// @Tags 管理端.游戏
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param user_id query int false "按用户ID筛选"
// @Param match_id query string false "按局ID筛选"
// @Success 200 {object} map[string]any
// @Router /api/admin/games/records [get]
func (h *handler) GetAdminGameRecords() core.HandlerFunc {
return func(ctx core.Context) {
var req struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
UserID int64 `form:"user_id"`
MatchID string `form:"match_id"`
}
_ = ctx.ShouldBindQuery(&req)
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 || req.PageSize > 100 {
req.PageSize = 20
}
offset := (req.Page - 1) * req.PageSize
type recRow struct {
ID int64 `json:"id"`
MatchID string `json:"match_id"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
IsWinner bool `json:"is_winner"`
RankPosition int `json:"rank_position"`
TotalPlayers int `json:"total_players"`
Score int `json:"score"`
DamageDealt int `json:"damage_dealt"`
ChestsCollected int `json:"chests_collected"`
RankPoints int `json:"rank_points"`
SettledAt string `json:"settled_at"`
}
query := h.db.GetDbR().Table("minesweeper_game_records r").
Select("r.id, r.match_id, r.user_id, COALESCE(u.nick_name,'') AS nickname, r.is_winner, r.rank_position, r.total_players, r.score, r.damage_dealt, r.chests_collected, r.rank_points, r.settled_at").
Joins("LEFT JOIN users u ON u.id = r.user_id").
Where("r.game_type = ?", "minesweeper")
if req.UserID > 0 {
query = query.Where("r.user_id = ?", req.UserID)
}
if req.MatchID != "" {
query = query.Where("r.match_id = ?", req.MatchID)
}
var total int64
query.Count(&total)
var rows []recRow
query.Order("r.settled_at DESC").Limit(req.PageSize).Offset(offset).Scan(&rows)
ctx.Payload(map[string]any{
"total": total,
"page": req.Page,
"page_size": req.PageSize,
"list": rows,
})
}
}
// ========== Helpers ==========
func generateTicketToken(userID int64) string {

View File

@ -17,193 +17,211 @@ import (
"github.com/stretchr/testify/assert"
)
// ---- 与 handler.go 保持同步的本地类型 ----
type settlePlayerRecord struct {
UserID int64 `json:"user_id"`
Ticket string `json:"ticket"`
Win bool `json:"win"`
Rank int `json:"rank"`
Score int `json:"score"`
DamageDealt int `json:"damage_dealt"`
DamageTaken int `json:"damage_taken"`
Kills int `json:"kills"`
ChestsCollected int `json:"chests_collected"`
RoundsSurvived int `json:"rounds_survived"`
}
// settleRequest 结算请求结构体(与 handler.go 保持一致)
type settleRequest struct {
MatchID string `json:"match_id"`
GameType string `json:"game_type"`
TotalRounds int `json:"total_rounds"`
Players []settlePlayerRecord `json:"players"`
// 兼容旧版单人字段
UserID string `json:"user_id"`
Ticket string `json:"ticket"`
Win bool `json:"win"`
Score int `json:"score"`
UserID string `json:"user_id"`
Ticket string `json:"ticket"`
MatchID string `json:"match_id"`
Win bool `json:"win"`
Score int `json:"score"`
GameType string `json:"game_type"`
}
// settleResponse 结算响应结构体
type settleResponse struct {
Success bool `json:"success"`
Reward string `json:"reward,omitempty"`
}
// ---- calcRankPoints 本地副本(保持与 handler.go 一致) ----
func calcRankPoints(rank int) int64 {
switch rank {
case 1:
return 1000
case 2:
return -900
case 3:
return -1100
case 4:
return -1300
default:
return 0
}
}
// ---- 名次积分单元测试 ----
func TestCalcRankPoints(t *testing.T) {
tests := []struct {
name string
rank int
expected int64
}{
{
name: "第1名固定加分",
rank: 1,
expected: 1000,
},
{
name: "第2名固定扣分",
rank: 2,
expected: -900,
},
{
name: "第3名固定扣分",
rank: 3,
expected: -1100,
},
{
name: "第4名固定扣分",
rank: 4,
expected: -1300,
},
{
name: "未知名次兜底为0",
rank: 0,
expected: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := calcRankPoints(tt.rank)
assert.Equal(t, tt.expected, got)
})
}
}
// ---- 批量结算:免费模式检测 ----
// TestSettleGame_FreeModeDetection 测试免费模式判断逻辑
// 这是核心测试:验证免费模式通过 game_type 参数判断,而不是依赖 Redis
func TestSettleGame_FreeModeDetection(t *testing.T) {
tests := []struct {
name string
gameType string
expectFree bool
name string
gameType string
ticketInRedis bool // 是否在 Redis 中存储 ticket
expectedReward string // 预期的奖励消息
shouldBlock bool // 是否应该被拦截(免费模式)
}{
{"免费模式", "minesweeper_free", true},
{"付费模式", "minesweeper", false},
{"空game_type不算免费", "", false},
{
name: "免费模式_有ticket_应拦截",
gameType: "minesweeper_free",
ticketInRedis: true,
expectedReward: "体验模式无奖励",
shouldBlock: true,
},
{
name: "免费模式_无ticket_应拦截",
gameType: "minesweeper_free",
ticketInRedis: false,
expectedReward: "体验模式无奖励",
shouldBlock: true,
},
{
name: "付费模式_有ticket_应发奖",
gameType: "minesweeper",
ticketInRedis: true,
expectedReward: "", // 付费模式会发放积分奖励
shouldBlock: false,
},
{
name: "付费模式_无ticket_应发奖",
gameType: "minesweeper",
ticketInRedis: false,
expectedReward: "", // 付费模式会发放积分奖励
shouldBlock: false,
},
{
name: "空game_type_应发奖",
gameType: "",
ticketInRedis: false,
expectedReward: "", // 空类型不是免费模式
shouldBlock: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isFree := tt.gameType == "minesweeper_free"
assert.Equal(t, tt.expectFree, isFree)
// 模拟判断逻辑
isFreeMode := tt.gameType == "minesweeper_free"
if tt.shouldBlock {
assert.True(t, isFreeMode, "免费模式应该被正确识别")
} else {
assert.False(t, isFreeMode, "非免费模式不应该被拦截")
}
})
}
}
// ---- 幂等match_id 重复检测 ----
func TestSettleGame_Idempotency(t *testing.T) {
// 模拟:同一 match_id 第二次进来时应跳过
recorded := map[string]bool{}
settle := func(matchID string) bool {
if recorded[matchID] {
return false // 幂等跳过
}
recorded[matchID] = true
return true
}
assert.True(t, settle("match-001"), "第一次应成功")
assert.False(t, settle("match-001"), "第二次应被跳过")
assert.True(t, settle("match-002"), "不同 match_id 应成功")
}
// ---- 批量结算:兼容旧版单人字段 ----
func TestSettleGame_BackwardCompatibility(t *testing.T) {
// 旧版请求(无 players 字段)
old := settleRequest{
UserID: "12345",
Ticket: "GT001",
MatchID: "match-old",
Win: true,
Score: 50,
GameType: "minesweeper",
}
// 兼容逻辑players 为空时,从旧版字段构建
if len(old.Players) == 0 && old.UserID != "" {
old.Players = []settlePlayerRecord{{
UserID: 12345,
Ticket: old.Ticket,
Win: old.Win,
Score: old.Score,
}}
}
assert.Len(t, old.Players, 1)
assert.Equal(t, int64(12345), old.Players[0].UserID)
assert.True(t, old.Players[0].Win)
}
// ---- Redis ticket 清理验证 ----
func TestSettleGame_TicketCleanup(t *testing.T) {
// TestSettleGame_FreeModeWithRedis 测试 Redis ticket 不影响免费模式判断
func TestSettleGame_FreeModeWithRedis(t *testing.T) {
// 1. 启动 miniredis
mr, err := miniredis.Run()
assert.NoError(t, err)
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
rdb := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})
ctx := context.Background()
userID := "12345"
ticket := "GT123456789"
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
rdb.Set(ctx, ticketKey, "12345:minesweeper", 30*time.Minute)
// 确认 ticket 存在
val, err := rdb.Get(ctx, ticketKey).Result()
assert.NoError(t, err)
assert.Contains(t, val, "12345")
// 场景1: Redis 中有 ticket但 game_type 是免费模式
t.Run("Redis有ticket但game_type是免费模式", func(t *testing.T) {
// 存储 ticket 到 Redis
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
rdb.Set(ctx, ticketKey, fmt.Sprintf("%s:minesweeper_free", userID), 30*time.Minute)
// 结算后清除 ticket
rdb.Del(ctx, ticketKey)
req := settleRequest{
UserID: userID,
Ticket: ticket,
MatchID: "match-001",
Win: true,
Score: 100,
GameType: "minesweeper_free",
}
_, err = rdb.Get(ctx, ticketKey).Result()
assert.Error(t, err, "ticket 应已被清除")
// 直接从 req.GameType 判断
isFreeMode := req.GameType == "minesweeper_free"
assert.True(t, isFreeMode, "应该识别为免费模式")
// 清理
rdb.Del(ctx, ticketKey)
})
// 场景2: Redis 中没有 ticket已被删除但 game_type 是免费模式
t.Run("Redis无ticket但game_type是免费模式", func(t *testing.T) {
req := settleRequest{
UserID: userID,
Ticket: ticket,
MatchID: "match-002",
Win: true,
Score: 100,
GameType: "minesweeper_free",
}
// 确认 Redis 中没有 ticket
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
_, err := rdb.Get(ctx, ticketKey).Result()
assert.Error(t, err, "ticket 应该不存在")
// 直接从 req.GameType 判断(修复后的逻辑)
isFreeMode := req.GameType == "minesweeper_free"
assert.True(t, isFreeMode, "即使 Redis 中没有 ticket也应该识别为免费模式")
})
// 场景3: Redis 中有 ticket 且是免费模式,但 game_type 参数为空(防止绕过)
t.Run("Redis标记免费但game_type参数为空", func(t *testing.T) {
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
rdb.Set(ctx, ticketKey, fmt.Sprintf("%s:minesweeper_free", userID), 30*time.Minute)
req := settleRequest{
UserID: userID,
Ticket: ticket,
MatchID: "match-003",
Win: true,
Score: 100,
GameType: "", // 恶意留空
}
// 使用修复后的逻辑:以请求参数为准
isFreeMode := req.GameType == "minesweeper_free"
assert.False(t, isFreeMode, "game_type 为空时不应识别为免费模式")
// 注意:这里是一个潜在的安全风险,需要确保游戏服务器正确传递 game_type
// 建议:可以增加双重校验,从 Redis 读取作为备份
rdb.Del(ctx, ticketKey)
})
}
// ---- HTTP 集成测试(模拟简化版 settle handler ----
// TestSettleGame_OldBugScenario 重现并验证旧 bug 已被修复
func TestSettleGame_OldBugScenario(t *testing.T) {
// 模拟旧代码的问题场景
t.Run("旧bug重现_ticket被删除后误判为付费模式", func(t *testing.T) {
mr, _ := miniredis.Run()
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
ctx := context.Background()
userID := "12345"
ticket := "GT123456789"
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
// 模拟场景:
// 1. 用户进入免费游戏ticket 存入 Redis
rdb.Set(ctx, ticketKey, fmt.Sprintf("%s:minesweeper_free", userID), 30*time.Minute)
// 2. 匹配成功后ticket 被删除
rdb.Del(ctx, ticketKey)
// 3. 游戏结算时尝试读取 ticket
_, err := rdb.Get(ctx, ticketKey).Result()
assert.Error(t, err, "ticket 应该已被删除")
// --- 旧代码逻辑(有 bug---
oldIsFreeMode := false
if err == nil {
// 只有在 Redis 中找到 ticket 时才能判断
// 这里 err != nil所以 isFreeMode 保持 false
}
assert.False(t, oldIsFreeMode, "旧代码ticket 被删除后无法判断免费模式")
// --- 新代码逻辑(已修复)---
req := settleRequest{
UserID: userID,
Ticket: ticket,
GameType: "minesweeper_free", // 直接从请求参数获取
}
newIsFreeMode := req.GameType == "minesweeper_free"
assert.True(t, newIsFreeMode, "新代码:直接从 game_type 判断,不受 Redis 影响")
})
}
// TestSettleGame_Integration 集成测试(模拟完整的 HTTP 请求)
func TestSettleGame_Integration(t *testing.T) {
gin.SetMode(gin.TestMode)
@ -214,59 +232,29 @@ func TestSettleGame_Integration(t *testing.T) {
checkResponse func(t *testing.T, body []byte)
}{
{
name: "免费模式_批量结算_直接成功",
name: "免费模式结算_应返回体验模式无奖励",
request: settleRequest{
MatchID: "match-free-001",
GameType: "minesweeper_free",
TotalRounds: 10,
Players: []settlePlayerRecord{
{UserID: 10001, Win: true, Score: 30, ChestsCollected: 2},
{UserID: 10002, Win: false, Score: 15},
},
UserID: "12345",
Ticket: "GT123456789",
MatchID: "match-001",
Win: true,
Score: 100,
GameType: "minesweeper_free",
},
expectedStatus: http.StatusOK,
checkResponse: func(t *testing.T, body []byte) {
var resp settleResponse
_ = json.Unmarshal(body, &resp)
assert.True(t, resp.Success)
},
},
{
name: "付费模式_批量结算_成功",
request: settleRequest{
MatchID: "match-paid-001",
GameType: "minesweeper",
TotalRounds: 15,
Players: []settlePlayerRecord{
{UserID: 10001, Win: true, Score: 50, DamageDealt: 8, ChestsCollected: 3},
{UserID: 10002, Win: false, Score: 20, DamageTaken: 6},
},
},
expectedStatus: http.StatusOK,
checkResponse: func(t *testing.T, body []byte) {
var resp settleResponse
_ = json.Unmarshal(body, &resp)
assert.True(t, resp.Success)
},
},
{
name: "空players且无旧版字段_直接返回成功",
request: settleRequest{
MatchID: "match-empty-001",
GameType: "minesweeper",
Players: []settlePlayerRecord{},
},
expectedStatus: http.StatusOK,
checkResponse: func(t *testing.T, body []byte) {
var resp settleResponse
_ = json.Unmarshal(body, &resp)
err := json.Unmarshal(body, &resp)
assert.NoError(t, err)
assert.True(t, resp.Success)
assert.Equal(t, "体验模式无奖励", resp.Reward)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 创建模拟的 handler简化版仅测试免费模式判断逻辑
router := gin.New()
router.POST("/internal/game/settle", func(c *gin.Context) {
var req settleRequest
@ -275,32 +263,28 @@ func TestSettleGame_Integration(t *testing.T) {
return
}
// 兼容旧版
if len(req.Players) == 0 && req.UserID != "" {
req.Players = []settlePlayerRecord{{UserID: 12345, Win: req.Win, Rank: 1, Score: req.Score}}
}
if len(req.Players) == 0 {
c.JSON(http.StatusOK, settleResponse{Success: true})
// 核心逻辑:直接从请求参数判断
isFreeMode := req.GameType == "minesweeper_free"
if isFreeMode {
c.JSON(http.StatusOK, settleResponse{
Success: true,
Reward: "体验模式无奖励",
})
return
}
// 计算积分(验证公式被调用)
// 计算积分(验证名次映射)
expectedRankPoints := map[int]int64{1: 1000, 2: -900, 3: -1100, 4: -1300}
for _, p := range req.Players {
pts := calcRankPoints(p.Rank)
if expected, ok := expectedRankPoints[p.Rank]; ok {
assert.Equal(t, expected, pts)
}
}
c.JSON(http.StatusOK, settleResponse{Success: true})
// 付费模式发奖逻辑(简化)
c.JSON(http.StatusOK, settleResponse{
Success: true,
Reward: "100积分",
})
})
// 发送请求
body, _ := json.Marshal(tt.request)
req, _ := http.NewRequest("POST", "/internal/game/settle", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@ -313,34 +297,35 @@ func TestSettleGame_Integration(t *testing.T) {
}
}
// ---- 旧版单人 Bug 场景:现在通过 players 字段兼容 ----
// BenchmarkFreeModeCheck 性能测试:对比新旧实现
func BenchmarkFreeModeCheck(b *testing.B) {
// 旧实现:需要查询 Redis
b.Run("旧实现_Redis查询", func(b *testing.B) {
mr, _ := miniredis.Run()
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
ctx := context.Background()
func TestSettleGame_OldBugScenario(t *testing.T) {
t.Run("旧版单人结算字段兼容", func(t *testing.T) {
req := settleRequest{
UserID: "12345",
Ticket: "GT123",
GameType: "minesweeper_free",
Win: true,
Score: 100,
ticket := "GT123456789"
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
rdb.Set(ctx, ticketKey, "12345:minesweeper_free", 30*time.Minute)
b.ResetTimer()
for i := 0; i < b.N; i++ {
val, err := rdb.Get(ctx, ticketKey).Result()
if err == nil {
_ = val == "12345:minesweeper_free"
}
}
})
// 新版兼容逻辑
isFree := req.GameType == "minesweeper_free"
assert.True(t, isFree, "免费模式通过 game_type 判断,不依赖 Redis")
// 新实现:直接比较字符串
b.Run("新实现_字符串比较", func(b *testing.B) {
gameType := "minesweeper_free"
// players 为空时从旧版字段补全
if len(req.Players) == 0 && req.UserID != "" {
req.Players = []settlePlayerRecord{{UserID: 12345, Win: req.Win, Score: req.Score}}
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = gameType == "minesweeper_free"
}
assert.Len(t, req.Players, 1)
})
}
// ---- 性能基准:积分计算 ----
func BenchmarkCalcRankPoints(b *testing.B) {
for i := 0; i < b.N; i++ {
calcRankPoints(1)
}
}

View File

@ -33,32 +33,7 @@ func Test_ListUserCouponUsage_App(t *testing.T) {
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`CREATE TABLE user_coupons (
id BIGINT PRIMARY KEY,
user_id BIGINT NOT NULL,
coupon_id BIGINT NOT NULL,
balance_amount BIGINT NOT NULL,
valid_start DATETIME,
valid_end DATETIME,
status INTEGER NOT NULL
)`).Error; err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`CREATE TABLE system_coupons (
id BIGINT PRIMARY KEY,
name TEXT NOT NULL,
discount_value BIGINT NOT NULL,
deleted_at TEXT
)`).Error; err != nil {
t.Fatal(err)
}
// seed rows
if err := repo.GetDbW().Exec(`INSERT INTO system_coupons (id, name, discount_value, deleted_at) VALUES (100, '测试优惠券', 1000, NULL)`).Error; err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`INSERT INTO user_coupons (id, user_id, coupon_id, balance_amount, valid_start, valid_end, status) VALUES (10, 1, 100, 700, '2025-01-01 00:00:00', '2025-12-31 23:59:59', 1)`).Error; err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`INSERT INTO user_coupon_ledger (user_id,user_coupon_id,change_amount,balance_after,order_id,action,created_at) VALUES (1,10,-100,900,0,'apply','2025-01-01 10:00:00')`).Error; err != nil {
t.Fatal(err)
}
@ -66,7 +41,7 @@ func Test_ListUserCouponUsage_App(t *testing.T) {
t.Fatal(err)
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
lg, err := logger.NewCustomLogger(nil, logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
@ -80,7 +55,7 @@ func Test_ListUserCouponUsage_App(t *testing.T) {
return proposal.SessionUserInfo{Id: 1}, nil
}
app := mux.Group("/api/app", core.WrapAuthHandler(dummyAuth))
app.GET("/users/:user_id/coupons/:user_coupon_id/usage", New(lg, repo, nil).ListUserCouponUsage())
app.GET("/users/:user_id/coupons/:user_coupon_id/usage", New(lg, repo).ListUserCouponUsage())
rr := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/app/users/1/coupons/10/usage?page=1&page_size=20", bytes.NewBufferString(""))

View File

@ -46,13 +46,8 @@ func (h *handler) RequestShippingBatch() core.HandlerFunc {
}
userID := int64(ctx.SessionUserInfo().Id)
needFee, reason, err := h.user.CheckShippingFeeRequirement(ctx.RequestContext(), userID, req.InventoryIDs)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150004, err.Error()))
return
}
if needFee {
// 运费校验:不满 5 件须已支付运费订单
if len(req.InventoryIDs) < shippingFeeThreshold {
paid, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).
Where(
h.readDB.Orders.UserID.Eq(userID),
@ -60,13 +55,7 @@ func (h *handler) RequestShippingBatch() core.HandlerFunc {
h.readDB.Orders.Status.Eq(2),
).Count()
if paid == 0 {
msg := "需先支付运费"
if reason == shippingFeeReasonContainsNonFreeShipping {
msg = "所选商品包含不包邮商品,需先支付运费"
} else if reason == shippingFeeReasonBelowThreshold {
msg = "不满5件需先支付运费"
}
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150003, msg))
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150003, "不满5件需先支付运费"))
return
}
}

View File

@ -13,11 +13,9 @@ import (
)
const (
shippingFeeThreshold = 5
shippingFeeCents = 1000 // 运费金额10 元
shippingFeeSourceType = int32(5) // orders.source_type: 5 = 运费订单
shippingFeeReasonBelowThreshold = "below_threshold"
shippingFeeReasonContainsNonFreeShipping = "contains_non_free_shipping_item"
shippingFeeThreshold = 5 // 低于此件数收运费
shippingFeeCents = 1000 // 运费金额10 元
shippingFeeSourceType = int32(5) // orders.source_type: 5 = 运费订单
)
type shippingFeePreorderRequest struct {
@ -28,40 +26,9 @@ type shippingFeePreorderResponse struct {
OrderNo string `json:"order_no"`
}
type shippingFeeCheckResponse struct {
NeedFee bool `json:"need_fee"`
Reason string `json:"reason,omitempty"`
FeeCents int64 `json:"fee_cents"`
}
func (h *handler) ShippingFeeCheck() core.HandlerFunc {
return func(ctx core.Context) {
req := new(shippingFeePreorderRequest)
rsp := &shippingFeeCheckResponse{FeeCents: shippingFeeCents}
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.InventoryIDs) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "inventory_ids 不能为空"))
return
}
userID := int64(ctx.SessionUserInfo().Id)
needFee, reason, err := h.user.CheckShippingFeeRequirement(ctx.RequestContext(), userID, req.InventoryIDs)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, err.Error()))
return
}
rsp.NeedFee = needFee
rsp.Reason = reason
ctx.Payload(rsp)
}
}
// ShippingFeePreorder 创建运费订单
// @Summary 创建运费订单
// @Description 选中商品命中运费规则时,创建 10 元运费订单并返回 order_no前端再调用 /pay/wechat/jsapi/preorder 发起支付;无需运费时不应调用
// @Description 选中件数不满 5 件时,创建 10 元运费订单并返回 order_no前端再调用 /pay/wechat/jsapi/preorder 发起支付;满 5 件包邮无需调用
// @Tags APP端.用户
// @Accept json
// @Produce json
@ -83,17 +50,12 @@ func (h *handler) ShippingFeePreorder() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "inventory_ids 不能为空"))
return
}
if len(req.InventoryIDs) >= shippingFeeThreshold {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150001, fmt.Sprintf("件数满 %d 件,无需支付运费", shippingFeeThreshold)))
return
}
userID := int64(ctx.SessionUserInfo().Id)
needFee, _, err := h.user.CheckShippingFeeRequirement(ctx.RequestContext(), userID, req.InventoryIDs)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, err.Error()))
return
}
if !needFee {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150001, fmt.Sprintf("件数满 %d 件且均非不包邮分类商品,无需支付运费", shippingFeeThreshold)))
return
}
remarkBytes, _ := json.Marshal(req.InventoryIDs)

View File

@ -1,66 +0,0 @@
package app
import (
"context"
"net/http"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
)
type syncMyDouyinOrdersResponse struct {
Message string `json:"message"`
DouyinUserID string `json:"douyin_user_id"`
TotalFetched int `json:"total_fetched"`
NewOrders int `json:"new_orders"`
MatchedUsers int `json:"matched_users"`
ElapsedMS int64 `json:"elapsed_ms"`
}
// SyncMyDouyinOrders 同步当前登录用户的抖音订单
// @Summary 同步我的抖音订单
// @Description 从当前登录用户绑定的 douyin_user_id 定向拉取抖音订单并同步到本地
// @Tags APP端.用户
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Success 200 {object} syncMyDouyinOrdersResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/users/douyin/orders/sync [post]
func (h *handler) SyncMyDouyinOrders() core.HandlerFunc {
return func(ctx core.Context) {
currentUserID := int64(ctx.SessionUserInfo().Id)
if currentUserID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效用户信息"))
return
}
currentUser, err := h.readDB.Users.WithContext(ctx.RequestContext()).
Where(h.readDB.Users.ID.Eq(currentUserID)).
First()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return
}
bgCtx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
result, err := h.douyin.SyncUserOrders(bgCtx, currentUserID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return
}
ctx.Payload(syncMyDouyinOrdersResponse{
Message: "抖音订单同步成功",
DouyinUserID: currentUser.DouyinUserID,
TotalFetched: result.TotalFetched,
NewOrders: result.NewOrders,
MatchedUsers: result.MatchedUsers,
ElapsedMS: result.ElapsedMS,
})
}
}

View File

@ -1,191 +0,0 @@
package app
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"time"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/proposal"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
douyinsvc "bindbox-game/internal/service/douyin"
)
type fakeDouyinService struct {
syncUserOrdersFn func(ctx context.Context, localUserID int64) (*douyinsvc.SyncResult, error)
}
func (f *fakeDouyinService) FetchAndSyncOrders(ctx context.Context, opts *douyinsvc.FetchOptions) (*douyinsvc.SyncResult, error) {
return nil, nil
}
func (f *fakeDouyinService) SyncUserOrders(ctx context.Context, localUserID int64) (*douyinsvc.SyncResult, error) {
if f.syncUserOrdersFn == nil {
return nil, nil
}
return f.syncUserOrdersFn(ctx, localUserID)
}
func (f *fakeDouyinService) SyncAllOrders(ctx context.Context, duration time.Duration, useProxy bool) (*douyinsvc.SyncResult, error) {
return nil, nil
}
func (f *fakeDouyinService) ListOrders(ctx context.Context, page, pageSize int, filter *douyinsvc.ListOrdersFilter) ([]*model.DouyinOrders, int64, error) {
return nil, 0, nil
}
func (f *fakeDouyinService) GetConfig(ctx context.Context) (*douyinsvc.DouyinConfig, error) {
return nil, nil
}
func (f *fakeDouyinService) SaveConfig(ctx context.Context, cookie, proxy string, intervalMinutes int) error {
return nil
}
func (f *fakeDouyinService) SyncOrder(ctx context.Context, item *douyinsvc.DouyinOrderItem, suggestUserID int64, productID string) (bool, bool) {
return false, false
}
func (f *fakeDouyinService) GrantMinesweeperQualifications(ctx context.Context) error {
return nil
}
func (f *fakeDouyinService) GrantLivestreamPrizes(ctx context.Context) error {
return nil
}
func (f *fakeDouyinService) SyncRefundStatus(ctx context.Context) error {
return nil
}
func (f *fakeDouyinService) GrantOrderReward(ctx context.Context, shopOrderID string) (*douyinsvc.GrantOrderRewardResult, error) {
return nil, nil
}
func TestSyncMyDouyinOrders_AppSuccess(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`CREATE TABLE users (
id INTEGER PRIMARY KEY,
nickname TEXT,
douyin_user_id TEXT,
deleted_at DATETIME
)`).Error; err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`INSERT INTO users (id, nickname, douyin_user_id) VALUES (1, 'tester', 'dy_user_001')`).Error; err != nil {
t.Fatal(err)
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
mux, err := core.New(lg)
if err != nil {
t.Fatal(err)
}
h := New(lg, repo, nil)
h.douyin = &fakeDouyinService{
syncUserOrdersFn: func(ctx context.Context, localUserID int64) (*douyinsvc.SyncResult, error) {
if localUserID != 1 {
t.Fatalf("unexpected localUserID: %d", localUserID)
}
return &douyinsvc.SyncResult{
TotalFetched: 4,
NewOrders: 2,
MatchedUsers: 3,
ElapsedMS: 123,
}, nil
},
}
dummyAuth := func(ctx core.Context) (proposal.SessionUserInfo, core.BusinessError) {
return proposal.SessionUserInfo{Id: 1}, nil
}
app := mux.Group("/api/app", core.WrapAuthHandler(dummyAuth))
app.POST("/users/douyin/orders/sync", h.SyncMyDouyinOrders())
rr := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/app/users/douyin/orders/sync", bytes.NewBufferString(""))
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("code=%d body=%s", rr.Code, rr.Body.String())
}
var rsp syncMyDouyinOrdersResponse
if err := json.Unmarshal(rr.Body.Bytes(), &rsp); err != nil {
t.Fatal(err)
}
if rsp.DouyinUserID != "dy_user_001" {
t.Fatalf("unexpected douyin_user_id: %s", rsp.DouyinUserID)
}
if rsp.TotalFetched != 4 || rsp.NewOrders != 2 || rsp.MatchedUsers != 3 || rsp.ElapsedMS != 123 {
t.Fatalf("unexpected response: %+v", rsp)
}
}
func TestSyncMyDouyinOrders_AppError(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`CREATE TABLE users (
id INTEGER PRIMARY KEY,
nickname TEXT,
douyin_user_id TEXT,
deleted_at DATETIME
)`).Error; err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`INSERT INTO users (id, nickname, douyin_user_id) VALUES (1, 'tester', '')`).Error; err != nil {
t.Fatal(err)
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
mux, err := core.New(lg)
if err != nil {
t.Fatal(err)
}
h := New(lg, repo, nil)
h.douyin = &fakeDouyinService{
syncUserOrdersFn: func(ctx context.Context, localUserID int64) (*douyinsvc.SyncResult, error) {
return nil, errors.New("当前用户未绑定抖音号")
},
}
dummyAuth := func(ctx core.Context) (proposal.SessionUserInfo, core.BusinessError) {
return proposal.SessionUserInfo{Id: 1}, nil
}
app := mux.Group("/api/app", core.WrapAuthHandler(dummyAuth))
app.POST("/users/douyin/orders/sync", h.SyncMyDouyinOrders())
rr := httptest.NewRecorder()
req, _ := http.NewRequest("POST", "/api/app/users/douyin/orders/sync", bytes.NewBufferString(""))
mux.ServeHTTP(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("code=%d body=%s", rr.Code, rr.Body.String())
}
if !bytes.Contains(rr.Body.Bytes(), []byte("当前用户未绑定抖音号")) {
t.Fatalf("unexpected body=%s", rr.Body.String())
}
}

View File

@ -45,23 +45,6 @@ func (h *handler) DoSynthesis() core.HandlerFunc {
}
}
func (h *handler) DoBatchSynthesis() core.HandlerFunc {
return func(ctx core.Context) {
req := new(synthesizeRequest)
if err := ctx.ShouldBindJSON(req); err != nil || req.RecipeID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid recipe_id"))
return
}
userID := int64(ctx.SessionUserInfo().Id)
result, err := h.synthesis.BatchSynthesize(ctx.RequestContext(), userID, req.RecipeID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return
}
ctx.Payload(result)
}
}
func (h *handler) ListSynthesisLogsForUser() core.HandlerFunc {
return func(ctx core.Context) {
userID := int64(ctx.SessionUserInfo().Id)

View File

@ -4,12 +4,12 @@ import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"bindbox-game/internal/pkg/httpclient"
pkgutils "bindbox-game/internal/pkg/utils"
"bindbox-game/internal/pkg/wechat"
"go.uber.org/zap"
)
@ -46,6 +46,44 @@ type LotteryResultNotificationResponse struct {
Errmsg string `json:"errmsg"`
}
// AccessTokenResponse access_token 响应
type AccessTokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
}
// getAccessToken 获取微信 access_token
func getAccessToken(ctx context.Context, appID, appSecret string) (string, error) {
url := "https://api.weixin.qq.com/cgi-bin/token"
client := httpclient.GetHttpClient()
resp, err := client.R().
SetQueryParams(map[string]string{
"grant_type": "client_credential",
"appid": appID,
"secret": appSecret,
}).
Get(url)
if err != nil {
return "", fmt.Errorf("获取access_token失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
return "", fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
return "", fmt.Errorf("解析access_token响应失败: %v", err)
}
if tokenResp.ErrCode != 0 {
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
if tokenResp.AccessToken == "" {
return "", fmt.Errorf("获取到的access_token为空")
}
return tokenResp.AccessToken, nil
}
// SendLotteryResultNotification 发送开奖结果订阅消息
// ctx: context
// cfg: 微信通知配置
@ -64,11 +102,8 @@ func SendLotteryResultNotification(ctx context.Context, cfg *WechatNotifyConfig,
return nil
}
// 获取 access_token。必须复用统一缓存避免高峰期每条订阅通知都请求微信 token 触发 45009 限流。
accessToken, err := wechat.GetAccessTokenWithContext(ctx, &wechat.WechatConfig{
AppID: cfg.AppID,
AppSecret: cfg.AppSecret,
})
// 获取 access_token
accessToken, err := getAccessToken(ctx, cfg.AppID, cfg.AppSecret)
if err != nil {
zap.L().Error("[开奖通知] 获取access_token失败", zap.Error(err), zap.String("openid", openid))
return err

View File

@ -46,7 +46,7 @@ func Code2Session(ctx context.Context, config *WechatConfig, code string) (*Code
return nil, err
}
if r.ErrCode != 0 {
return nil, fmt.Errorf("%s", r.ErrMsg)
return nil, fmt.Errorf(r.ErrMsg)
}
if r.OpenID == "" || r.SessionKey == "" {
return nil, fmt.Errorf("响应缺少必要字段")

View File

@ -1,50 +1,50 @@
package wechat
import (
"encoding/json"
"fmt"
"net/http"
"encoding/json"
"fmt"
"net/http"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/httpclient"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/httpclient"
)
type PhoneNumberResponse struct {
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
PhoneInfo struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
} `json:"phone_info"`
ErrCode int `json:"errcode,omitempty"`
ErrMsg string `json:"errmsg,omitempty"`
PhoneInfo struct {
PhoneNumber string `json:"phoneNumber"`
PurePhoneNumber string `json:"purePhoneNumber"`
CountryCode string `json:"countryCode"`
} `json:"phone_info"`
}
// GetPhoneNumber 使用微信开放接口换取用户手机号
// DOC: https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/user-info/phone-number/getPhoneNumber.html
func GetPhoneNumber(ctx core.Context, accessToken, code string) (*PhoneNumberResponse, error) {
if accessToken == "" || code == "" {
return nil, fmt.Errorf("参数缺失")
}
client := httpclient.GetHttpClientWithContext(ctx.RequestContext())
resp, err := client.R().
SetQueryParam("access_token", accessToken).
SetBody(map[string]string{"code": code}).
Post("https://api.weixin.qq.com/wxa/business/getuserphonenumber")
if err != nil {
return nil, err
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("HTTP错误: %d", resp.StatusCode())
}
var r PhoneNumberResponse
if err := json.Unmarshal(resp.Body(), &r); err != nil {
return nil, err
}
if r.ErrCode != 0 {
return nil, fmt.Errorf("%s", r.ErrMsg)
}
if r.PhoneInfo.PurePhoneNumber == "" && r.PhoneInfo.PhoneNumber == "" {
return nil, fmt.Errorf("未获取到手机号")
}
return &r, nil
}
if accessToken == "" || code == "" {
return nil, fmt.Errorf("参数缺失")
}
client := httpclient.GetHttpClientWithContext(ctx.RequestContext())
resp, err := client.R().
SetQueryParam("access_token", accessToken).
SetBody(map[string]string{"code": code}).
Post("https://api.weixin.qq.com/wxa/business/getuserphonenumber")
if err != nil {
return nil, err
}
if resp.StatusCode() != http.StatusOK {
return nil, fmt.Errorf("HTTP错误: %d", resp.StatusCode())
}
var r PhoneNumberResponse
if err := json.Unmarshal(resp.Body(), &r); err != nil {
return nil, err
}
if r.ErrCode != 0 {
return nil, fmt.Errorf(r.ErrMsg)
}
if r.PhoneInfo.PurePhoneNumber == "" && r.PhoneInfo.PhoneNumber == "" {
return nil, fmt.Errorf("未获取到手机号")
}
return &r, nil
}

View File

@ -6,7 +6,6 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
@ -40,19 +39,6 @@ type TokenCache struct {
// 全局 token 缓存
var globalTokenCache = &TokenCache{}
type accessTokenFailure struct {
Err error
RetryAt time.Time
RecordedAt time.Time
}
type accessTokenFailureCache struct {
mutex sync.Mutex
failures map[string]accessTokenFailure
}
var globalAccessTokenFailureCache = &accessTokenFailureCache{failures: make(map[string]accessTokenFailure)}
// WechatConfig 微信配置
type WechatConfig struct {
AppID string
@ -124,10 +110,6 @@ func GetAccessToken(ctx core.Context, config *WechatConfig) (string, error) {
return globalTokenCache.Token, nil
}
if err := accessTokenBackoffError(config.AppID); err != nil {
return "", err
}
// 3. 调用微信 API 获取新 token (使用 stable_token 接口)
url := "https://api.weixin.qq.com/cgi-bin/stable_token"
requestBody := map[string]any{
@ -142,41 +124,30 @@ func GetAccessToken(ctx core.Context, config *WechatConfig) (string, error) {
SetBody(requestBody).
Post(url)
if err != nil {
wrapped := fmt.Errorf("获取stable_access_token失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
return "", fmt.Errorf("获取stable_access_token失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
err := fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
rememberAccessTokenFailure(config.AppID, err)
return "", err
return "", fmt.Errorf("HTTP请求失败状态码: %d", resp.StatusCode())
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
wrapped := fmt.Errorf("解析access_token响应失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
return "", fmt.Errorf("解析access_token响应失败: %v", err)
}
if tokenResp.ErrCode != 0 {
err := fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
rememberAccessTokenFailure(config.AppID, err)
return "", err
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
if tokenResp.AccessToken == "" {
err := fmt.Errorf("获取到的access_token为空")
rememberAccessTokenFailure(config.AppID, err)
return "", err
return "", fmt.Errorf("获取到的access_token为空")
}
// 4. 更新缓存提前5分钟过期以留出刷新余地
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn-300) * time.Second)
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = expiresAt
clearAccessTokenFailure(config.AppID)
return tokenResp.AccessToken, nil
}
@ -201,10 +172,6 @@ func GetAccessTokenWithContext(ctx context.Context, config *WechatConfig) (strin
return token, nil
}
if err := accessTokenBackoffError(config.AppID); err != nil {
return "", err
}
// 2. Redis 中没有,使用分布式锁获取新 token
lockKey := fmt.Sprintf("lock:wechat:access_token:%s", config.AppID)
locked, err := acquireDistributedLock(ctx, lockKey, 10*time.Second)
@ -238,30 +205,20 @@ func GetAccessTokenWithContext(ctx context.Context, config *WechatConfig) (strin
SetBody(requestBody).
Post(url)
if err != nil {
wrapped := fmt.Errorf("获取stable_access_token失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
return "", fmt.Errorf("获取stable_access_token失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
err := fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
rememberAccessTokenFailure(config.AppID, err)
return "", err
return "", fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
wrapped := fmt.Errorf("解析access_token响应失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
return "", fmt.Errorf("解析access_token响应失败: %v", err)
}
if tokenResp.ErrCode != 0 {
err := fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
rememberAccessTokenFailure(config.AppID, err)
return "", err
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
if tokenResp.AccessToken == "" {
err := fmt.Errorf("获取到的access_token为空")
rememberAccessTokenFailure(config.AppID, err)
return "", err
return "", fmt.Errorf("获取到的access_token为空")
}
// 5. 存储到 Redis (提前5分钟过期以留出刷新余地)
@ -276,7 +233,6 @@ func GetAccessTokenWithContext(ctx context.Context, config *WechatConfig) (strin
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
globalTokenCache.mutex.Unlock()
clearAccessTokenFailure(config.AppID)
return tokenResp.AccessToken, nil
}
@ -437,53 +393,6 @@ func releaseDistributedLock(ctx context.Context, lockKey string) {
_ = client.Del(ctx, lockKey).Err()
}
func accessTokenBackoffError(appID string) error {
globalAccessTokenFailureCache.mutex.Lock()
defer globalAccessTokenFailureCache.mutex.Unlock()
failure, ok := globalAccessTokenFailureCache.failures[appID]
if !ok {
return nil
}
if time.Now().After(failure.RetryAt) {
delete(globalAccessTokenFailureCache.failures, appID)
return nil
}
return fmt.Errorf("access_token 获取暂时退避,%s 后重试: %w", time.Until(failure.RetryAt).Round(time.Second), failure.Err)
}
func rememberAccessTokenFailure(appID string, err error) {
if err == nil {
return
}
delay := 30 * time.Second
if isAccessTokenQuotaError(err) {
delay = 10 * time.Minute
}
globalAccessTokenFailureCache.mutex.Lock()
defer globalAccessTokenFailureCache.mutex.Unlock()
globalAccessTokenFailureCache.failures[appID] = accessTokenFailure{
Err: err,
RetryAt: time.Now().Add(delay),
RecordedAt: time.Now(),
}
}
func clearAccessTokenFailure(appID string) {
globalAccessTokenFailureCache.mutex.Lock()
defer globalAccessTokenFailureCache.mutex.Unlock()
delete(globalAccessTokenFailureCache.failures, appID)
}
func isAccessTokenQuotaError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "45009") || strings.Contains(msg, "reach max api daily quota limit")
}
// getTokenFromMemoryOrAPI 降级方案:从内存缓存获取或调用API
func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string, error) {
// 1. 先检查内存缓存
@ -504,10 +413,6 @@ func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string,
return globalTokenCache.Token, nil
}
if err := accessTokenBackoffError(config.AppID); err != nil {
return "", err
}
// 3. 调用微信 API (使用 stable_token 接口)
url := "https://api.weixin.qq.com/cgi-bin/stable_token"
requestBody := map[string]any{
@ -522,30 +427,20 @@ func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string,
SetBody(requestBody).
Post(url)
if err != nil {
wrapped := fmt.Errorf("获取stable_access_token失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
return "", fmt.Errorf("获取stable_access_token失败: %v", err)
}
if resp.StatusCode() != http.StatusOK {
err := fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
rememberAccessTokenFailure(config.AppID, err)
return "", err
return "", fmt.Errorf("HTTP请求失败,状态码: %d", resp.StatusCode())
}
var tokenResp AccessTokenResponse
if err := json.Unmarshal(resp.Body(), &tokenResp); err != nil {
wrapped := fmt.Errorf("解析access_token响应失败: %v", err)
rememberAccessTokenFailure(config.AppID, wrapped)
return "", wrapped
return "", fmt.Errorf("解析access_token响应失败: %v", err)
}
if tokenResp.ErrCode != 0 {
err := fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
rememberAccessTokenFailure(config.AppID, err)
return "", err
return "", fmt.Errorf("获取access_token失败: errcode=%d, errmsg=%s", tokenResp.ErrCode, tokenResp.ErrMsg)
}
if tokenResp.AccessToken == "" {
err := fmt.Errorf("获取到的access_token为空")
rememberAccessTokenFailure(config.AppID, err)
return "", err
return "", fmt.Errorf("获取到的access_token为空")
}
// 4. 更新内存缓存
@ -555,7 +450,6 @@ func getTokenFromMemoryOrAPI(ctx context.Context, config *WechatConfig) (string,
}
globalTokenCache.Token = tokenResp.AccessToken
globalTokenCache.ExpiresAt = time.Now().Add(time.Duration(expiresIn) * time.Second)
clearAccessTokenFailure(config.AppID)
return tokenResp.AccessToken, nil
}

View File

@ -1,28 +0,0 @@
package wechat
import (
"fmt"
"strings"
"testing"
)
func TestAccessTokenBackoffForQuotaError(t *testing.T) {
appID := "test-quota-app"
clearAccessTokenFailure(appID)
t.Cleanup(func() { clearAccessTokenFailure(appID) })
rememberAccessTokenFailure(appID, fmt.Errorf("获取access_token失败: errcode=45009, errmsg=reach max api daily quota limit"))
err := accessTokenBackoffError(appID)
if err == nil {
t.Fatalf("expected backoff error")
}
if !strings.Contains(err.Error(), "45009") {
t.Fatalf("expected original quota error to be preserved, got %v", err)
}
clearAccessTokenFailure(appID)
if err := accessTokenBackoffError(appID); err != nil {
t.Fatalf("expected no backoff after clear, got %v", err)
}
}

View File

@ -109,7 +109,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
internalRouter.POST("/game/settle", gameHandler.SettleGame())
internalRouter.POST("/game/consume-ticket", gameHandler.ConsumeTicket())
internalRouter.GET("/game/minesweeper/config", gameHandler.GetMinesweeperConfig())
internalRouter.GET("/game/leaderboard", gameHandler.GetLeaderboardInternal())
}
// 管理端非认证接口路由组
@ -172,17 +171,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.GET("/dashboard/order_trend", adminHandler.DashboardOrderTrend())
adminAuthApiRouter.GET("/dashboard/activity_stats", adminHandler.DashboardActivityStats())
adminAuthApiRouter.GET("/dashboard/item_card_sales", adminHandler.DashboardItemCardSales())
adminAuthApiRouter.GET("/welfare-activities", intc.RequireAdminAction("activity:view"), adminHandler.ListWelfareActivities())
adminAuthApiRouter.POST("/welfare-activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateWelfareActivity())
adminAuthApiRouter.GET("/welfare-activities/cost-summary", intc.RequireAdminAction("activity:view"), adminHandler.GetWelfareCostSummary())
adminAuthApiRouter.GET("/welfare-activities/:id", intc.RequireAdminAction("activity:view"), adminHandler.GetWelfareActivity())
adminAuthApiRouter.PUT("/welfare-activities/:id", intc.RequireAdminAction("activity:modify"), adminHandler.UpdateWelfareActivity())
adminAuthApiRouter.DELETE("/welfare-activities/:id", intc.RequireAdminAction("activity:delete"), adminHandler.DeleteWelfareActivity())
adminAuthApiRouter.POST("/welfare-activities/:id/copy", intc.RequireAdminAction("activity:create"), adminHandler.CopyWelfareActivity())
adminAuthApiRouter.GET("/welfare-activities/:id/participants", intc.RequireAdminAction("activity:view"), adminHandler.ListWelfareParticipants())
adminAuthApiRouter.GET("/welfare-activities/:id/winners", intc.RequireAdminAction("activity:view"), adminHandler.ListWelfareWinners())
adminAuthApiRouter.POST("/welfare-activities/:id/draw", intc.RequireAdminAction("activity:modify"), adminHandler.DrawWelfareActivity())
adminAuthApiRouter.GET("/welfare-activities/:id/cost", intc.RequireAdminAction("activity:view"), adminHandler.GetWelfareCost())
adminAuthApiRouter.POST("/activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateActivity())
adminAuthApiRouter.GET("/activities", intc.RequireAdminAction("activity:view"), adminHandler.ListActivities())
adminAuthApiRouter.PUT("/activities/:activity_id", intc.RequireAdminAction("activity:modify"), adminHandler.ModifyActivity())
@ -276,19 +264,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.PUT("/system/configs/:id", adminHandler.ModifySystemConfig())
adminAuthApiRouter.DELETE("/system/configs/:id", adminHandler.DeleteSystemConfig())
// 奖品发放活动
adminAuthApiRouter.POST("/prize-grant-activities", adminHandler.CreatePrizeGrantActivity())
adminAuthApiRouter.GET("/prize-grant-activities", adminHandler.ListPrizeGrantActivities())
adminAuthApiRouter.GET("/prize-grant-activities/cost-summary", adminHandler.GetPrizeGrantCostSummary())
adminAuthApiRouter.GET("/prize-grant-activities/:id", adminHandler.GetPrizeGrantActivity())
adminAuthApiRouter.PUT("/prize-grant-activities/:id", adminHandler.UpdatePrizeGrantActivity())
adminAuthApiRouter.DELETE("/prize-grant-activities/:id", adminHandler.DeletePrizeGrantActivity())
adminAuthApiRouter.GET("/prize-grant-activities/:id/user-records", adminHandler.ListPrizeGrantUserRecords())
adminAuthApiRouter.DELETE("/prize-grant-activities/:id/user-records/:record_id", adminHandler.DeletePrizeGrantUserRecord())
adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-processed", adminHandler.MarkPrizeGrantUsersProcessed())
adminAuthApiRouter.POST("/prize-grant-activities/:id/mark-all-processed", adminHandler.MarkAllPrizeGrantUsersProcessed())
// 用户管理
// 用户管理
adminAuthApiRouter.GET("/users", intc.RequireAdminAction("user:view"), adminHandler.ListAppUsers())
adminAuthApiRouter.GET("/users/optimized", intc.RequireAdminAction("user:view"), adminHandler.ListAppUsersOptimized()) // 优化版本性能提升83%
adminAuthApiRouter.GET("/users/:user_id/invites", intc.RequireAdminAction("user:view"), adminHandler.ListUserInvites())
@ -377,10 +353,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.GET("/users/:user_id/game_tickets", gameHandler.ListUserTickets())
adminAuthApiRouter.POST("/users/:user_id/game_tickets", gameHandler.GrantUserTicket())
// 扫雷排行榜 & 对战记录
adminAuthApiRouter.GET("/games/leaderboard", gameHandler.GetAdminLeaderboard())
adminAuthApiRouter.GET("/games/records", gameHandler.GetAdminGameRecords())
// 发货统计
adminAuthApiRouter.GET("/ops_shipping_stats", intc.RequireAdminAction("ops:shipping:view"), adminHandler.ListShippingStats())
adminAuthApiRouter.GET("/ops_shipping_stats/:id", intc.RequireAdminAction("ops:shipping:view"), adminHandler.GetShippingStat())
@ -467,10 +439,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
appPublicApiRouter.GET("/activities/:activity_id/issues/:issue_id/rewards", activityHandler.ListIssueRewards())
appPublicApiRouter.GET("/activities/:activity_id/issues/:issue_id/draw_logs", activityHandler.ListDrawLogs())
appPublicApiRouter.GET("/activities/:activity_id/issues/:issue_id/draw_logs_grouped", activityHandler.ListDrawLogsByLevel())
appPublicApiRouter.GET("/welfare-activities", activityHandler.ListWelfareActivities())
appPublicApiRouter.GET("/welfare-activities/:id", activityHandler.GetWelfareActivity())
appPublicApiRouter.GET("/welfare-activities/:id/participants", activityHandler.ListWelfareParticipants())
appPublicApiRouter.GET("/welfare-activities/:id/winners", activityHandler.ListWelfareWinners())
// APP 端轮播图
appPublicApiRouter.GET("/banners", appapi.NewBanner(logger, db).ListBannersForApp())
@ -525,7 +493,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
appAuthApiRouter.POST("/users/:user_id/phone/bind", userHandler.BindPhone())
appAuthApiRouter.POST("/users/:user_id/douyin/phone/bind", userHandler.DouyinBindPhone())
appAuthApiRouter.POST("/users/douyin/bind", userHandler.BindDouyinOrder())
appAuthApiRouter.POST("/users/douyin/orders/sync", userHandler.SyncMyDouyinOrders())
appAuthApiRouter.GET("/users/:user_id/invites", userHandler.ListUserInvites())
appAuthApiRouter.POST("/users/inviter/bind", userHandler.BindInviter())
appAuthApiRouter.GET("/users/:user_id/inventory", userHandler.ListUserInventory())
@ -538,13 +505,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
appAuthApiRouter.PUT("/users/:user_id/addresses/:address_id", userHandler.UpdateUserAddress())
appAuthApiRouter.DELETE("/users/:user_id/addresses/:address_id", userHandler.DeleteUserAddress())
appAuthApiRouter.GET("/welfare-activities/:id/my", activityHandler.GetWelfareActivity())
appAuthApiRouter.POST("/welfare-activities/:id/join", activityHandler.JoinWelfareActivity())
appAuthApiRouter.GET("/prize-grant-activities/pending", activityHandler.GetPendingPrizeGrantActivity())
appAuthApiRouter.POST("/prize-grant-activities/:id/claim", activityHandler.ClaimPrizeGrantActivity())
// 任务中心 APP 端
// 任务中心 APP 端
appAuthApiRouter.GET("/task-center/tasks", taskCenterHandler.ListTasksForApp())
appAuthApiRouter.GET("/task-center/tasks/:id/progress/:user_id", taskCenterHandler.GetTaskProgressForApp())
appAuthApiRouter.POST("/task-center/tasks/:id/claim/:user_id", taskCenterHandler.ClaimTaskTierForApp())
@ -567,7 +528,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
// 扫雷游戏
lotteryGroup.POST("/games/enter", gameHandler.EnterGame())
lotteryGroup.GET("/games/leaderboard", gameHandler.GetLeaderboard())
// 积分兑换操作也应该检查黑名单
lotteryGroup.POST("/users/:user_id/points/redeem-coupon", userHandler.RedeemPointsToCoupon())
@ -575,7 +535,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
lotteryGroup.POST("/users/:user_id/points/redeem-item-card", userHandler.RedeemPointsToItemCard())
// 资产操作(发货/回收)
lotteryGroup.POST("/users/:user_id/inventory/shipping-fee/check", userHandler.ShippingFeeCheck())
lotteryGroup.POST("/users/:user_id/inventory/shipping-fee/preorder", userHandler.ShippingFeePreorder())
lotteryGroup.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch())
lotteryGroup.POST("/users/:user_id/inventory/cancel-shipping", userHandler.CancelShipping())
@ -585,7 +544,6 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
// 碎片合成
appAuthApiRouter.GET("/users/:user_id/synthesis/recipes", userHandler.ListSynthesisRecipesForUser())
appAuthApiRouter.POST("/users/:user_id/synthesis/do", userHandler.DoSynthesis())
appAuthApiRouter.POST("/users/:user_id/synthesis/do-batch", userHandler.DoBatchSynthesis())
appAuthApiRouter.GET("/users/:user_id/synthesis/logs", userHandler.ListSynthesisLogsForUser())
// 对对碰其他接口不需要严查黑名单或者已在preorder查过

View File

@ -71,43 +71,15 @@ func (s *service) CopyActivity(ctx context.Context, activityID int64) (int64, er
}
for _, r := range rewards {
priceSnapshot := r.PriceSnapshotCents
costSnapshot := r.CostSnapshotCents
snapshotAt := r.PriceSnapshotAt
if r.ProductID > 0 && (priceSnapshot == 0 || costSnapshot == 0) {
product, err := s.readDB.Products.WithContext(ctx).Where(s.readDB.Products.ID.Eq(r.ProductID)).First()
if err != nil {
return err
}
if priceSnapshot == 0 {
priceSnapshot = product.Price
}
if costSnapshot == 0 {
costSnapshot = product.CostPrice
}
}
if snapshotAt.IsZero() {
snapshotAt = time.Now()
}
dropQuantity := r.DropQuantity
if dropQuantity < 1 {
dropQuantity = 1
}
nr := &model.ActivityRewardSettings{
IssueID: idMap[r.IssueID],
ProductID: r.ProductID,
PriceSnapshotCents: priceSnapshot,
PriceSnapshotAt: snapshotAt,
Weight: r.Weight,
Quantity: r.Quantity,
OriginalQty: r.OriginalQty,
Level: r.Level,
Sort: r.Sort,
IsBoss: r.IsBoss,
MinScore: r.MinScore,
DropQuantity: dropQuantity,
CostSnapshotCents: costSnapshot,
IssueID: idMap[r.IssueID],
ProductID: r.ProductID,
Weight: r.Weight,
Quantity: r.Quantity,
OriginalQty: r.OriginalQty,
Level: r.Level,
Sort: r.Sort,
IsBoss: r.IsBoss,
}
if err := tx.ActivityRewardSettings.WithContext(ctx).Create(nr); err != nil {
return err

View File

@ -18,8 +18,6 @@ import (
"go.uber.org/zap"
)
const virtualShippingRetryLockTTL = 5 * time.Minute
// ProcessOrderLottery 处理订单开奖(统原子化高性能幂等逻辑)
func (s *service) ProcessOrderLottery(ctx context.Context, orderID int64) error {
s.logger.Debug("开始原子化处理订单开奖", zap.Int64("order_id", orderID))
@ -258,17 +256,8 @@ func (s *service) ProcessOrderLottery(ctx context.Context, orderID int64) error
// TriggerVirtualShipping 触发虚拟发货同步到微信
func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, orderNo string, userID int64, aid int64, actName string, playType string) {
if !s.acquireVirtualShippingRetrySlot(ctx, orderID, orderNo) {
return
}
drawLogs, err := s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
if err != nil {
s.logger.Error("[虚拟发货] 查询开奖记录失败", zap.Int64("order_id", orderID), zap.String("order_no", orderNo), zap.Error(err))
return
}
drawLogs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).Where(s.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find()
if len(drawLogs) == 0 {
s.logger.Warn("[虚拟发货] 跳过: 未找到开奖记录", zap.Int64("order_id", orderID), zap.String("order_no", orderNo))
return
}
// 批量获取 reward 信息
@ -311,9 +300,8 @@ func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, ord
}
itemsDesc := actName + " " + orderNo + " 盲盒赏品: " + strings.Join(rewardNames, ", ")
itemsDesc = utf8SafeTruncate(itemsDesc, 110) // 微信限制 128 字节,我们保守一点截断到 110
tx, err := s.readDB.PaymentTransactions.WithContext(ctx).Where(s.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
tx, _ := s.readDB.PaymentTransactions.WithContext(ctx).Where(s.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First()
if tx == nil || tx.TransactionID == "" {
s.logger.Warn("[虚拟发货] 跳过: 未找到支付交易", zap.Int64("order_id", orderID), zap.String("order_no", orderNo), zap.Error(err))
return
}
// 优先使用支付时的 openid (避免多小程序/多渠道导致的 openid 不一致)
@ -324,9 +312,6 @@ func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, ord
payerOpenid = u.Openid
}
}
if payerOpenid == "" {
s.logger.Warn("[虚拟发货] 支付 openid 为空,继续尝试发货", zap.Int64("order_id", orderID), zap.String("order_no", orderNo), zap.String("transaction_id", tx.TransactionID))
}
var cfg *wechat.WechatConfig
if dc := sysconfig.GetDynamicConfig(); dc != nil {
wc := dc.GetWechat(ctx)
@ -335,32 +320,10 @@ func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, ord
c := configs.Get()
cfg = &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}
}
var errUpload error
retryDelays := []time.Duration{0, 5 * time.Second, 30 * time.Second}
for attempt, delay := range retryDelays {
if delay > 0 {
time.Sleep(delay)
}
errUpload = wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc)
if errUpload == nil || isVirtualShippingAlreadyDone(errUpload) || !isRetriableVirtualShippingError(errUpload) {
break
}
if isWechatAccessTokenQuotaError(errUpload) {
break
}
if attempt == len(retryDelays)-1 {
break
}
s.logger.Warn("[虚拟发货] 上传失败,将重试",
zap.Int64("order_id", orderID),
zap.String("order_no", orderNo),
zap.Int("attempt", attempt+1),
zap.Duration("next_delay", retryDelays[attempt+1]),
zap.Error(errUpload))
}
errUpload := wechat.UploadVirtualShippingForBackground(ctx, cfg, tx.TransactionID, orderNo, payerOpenid, itemsDesc)
// 如果发货成功,或者微信提示已经发过货了(10060003),则标记本地订单为已履约
if errUpload == nil || isVirtualShippingAlreadyDone(errUpload) {
if errUpload == nil || strings.Contains(errUpload.Error(), "10060003") {
_, _ = s.writeDB.Orders.WithContext(ctx).Where(s.readDB.Orders.ID.Eq(orderID)).Update(s.readDB.Orders.IsConsumed, 1)
if errUpload != nil {
s.logger.Info("[虚拟发货] 微信反馈已处理,更新本地标记", zap.String("order_no", orderNo))
@ -391,63 +354,6 @@ func (s *service) TriggerVirtualShipping(ctx context.Context, orderID int64, ord
}
}
func (s *service) acquireVirtualShippingRetrySlot(ctx context.Context, orderID int64, orderNo string) bool {
if s.redis == nil {
return true
}
lockKey := fmt.Sprintf("lock:virtual_shipping:order:%d", orderID)
locked, err := s.redis.SetNX(ctx, lockKey, "1", virtualShippingRetryLockTTL).Result()
if err != nil {
s.logger.Warn("[虚拟发货] 防抖锁异常,继续尝试", zap.Int64("order_id", orderID), zap.String("order_no", orderNo), zap.Error(err))
return true
}
if !locked {
s.logger.Debug("[虚拟发货] 跳过: 近期已尝试", zap.Int64("order_id", orderID), zap.String("order_no", orderNo))
return false
}
return true
}
func isVirtualShippingAlreadyDone(err error) bool {
return err != nil && strings.Contains(err.Error(), "10060003")
}
func isWechatAccessTokenQuotaError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "45009") || strings.Contains(msg, "reach max api daily quota limit")
}
func isRetriableVirtualShippingError(err error) bool {
if err == nil {
return false
}
if isWechatAccessTokenQuotaError(err) {
return false
}
msg := strings.ToLower(err.Error())
retriableMarkers := []string{
"10060001",
"支付单不存在",
"timeout",
"deadline exceeded",
"connection reset",
"connection refused",
"eof",
"temporary",
"获取stable_access_token失败",
"http请求失败",
}
for _, marker := range retriableMarkers {
if strings.Contains(msg, strings.ToLower(marker)) {
return true
}
}
return false
}
func (s *service) applyItemCardEffect(ctx context.Context, icID int64, aid int64, iss int64, log *model.ActivityDrawLogs) {
uic, _ := s.readDB.UserItemCards.WithContext(ctx).Where(s.readDB.UserItemCards.ID.Eq(icID), s.readDB.UserItemCards.UserID.Eq(log.UserID), s.readDB.UserItemCards.Status.Eq(1)).First()
if uic == nil {

View File

@ -3,7 +3,6 @@ package activity
import (
"context"
"testing"
"time"
"bindbox-game/internal/repository/mysql/dao"
@ -13,7 +12,7 @@ import (
func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite failed: %v", err)
}
@ -21,7 +20,6 @@ func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB)
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
price INTEGER NOT NULL,
cost_price INTEGER NOT NULL DEFAULT 0,
stock INTEGER NOT NULL,
images_json TEXT,
updated_at DATETIME,
@ -29,54 +27,6 @@ func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB)
);`).Error; err != nil {
t.Fatalf("create products failed: %v", err)
}
if err := db.Exec(`CREATE TABLE activities (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
name TEXT,
banner TEXT,
activity_category_id INTEGER,
status INTEGER,
price_draw INTEGER,
is_boss INTEGER,
allow_item_cards BOOLEAN,
allow_coupons BOOLEAN,
end_time DATETIME,
start_time DATETIME,
scheduled_time DATETIME,
last_settled_at DATETIME,
draw_mode TEXT,
play_type TEXT,
min_participants INTEGER,
interval_minutes INTEGER,
refund_coupon_id INTEGER,
deleted_at DATETIME,
image TEXT,
commitment_algo TEXT,
commitment_seed_master TEXT,
commitment_seed_hash TEXT,
commitment_state_version INTEGER,
commitment_items_root TEXT,
gameplay_intro TEXT,
daily_seed TEXT,
daily_seed_date TEXT,
last_daily_seed TEXT,
last_daily_seed_date TEXT
);`).Error; err != nil {
t.Fatalf("create activities failed: %v", err)
}
if err := db.Exec(`CREATE TABLE activity_issues (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
updated_at DATETIME,
activity_id INTEGER NOT NULL,
issue_number TEXT,
status INTEGER,
sort INTEGER,
deleted_at DATETIME
);`).Error; err != nil {
t.Fatalf("create activity_issues failed: %v", err)
}
if err := db.Exec(`CREATE TABLE activity_reward_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME,
@ -92,15 +42,13 @@ func newRewardSnapshotTestService(t *testing.T) (*service, *dao.Query, *gorm.DB)
sort INTEGER,
is_boss INTEGER,
min_score INTEGER NOT NULL DEFAULT 0,
drop_quantity INTEGER NOT NULL DEFAULT 1,
cost_snapshot_cents INTEGER NOT NULL DEFAULT 0,
deleted_at DATETIME
);`).Error; err != nil {
t.Fatalf("create activity_reward_settings failed: %v", err)
}
q := dao.Use(db)
svc := &service{readDB: q, writeDB: q, repo: nil}
svc := &service{readDB: q, writeDB: q}
return svc, q, db
}
@ -108,7 +56,7 @@ func TestCreateIssueRewards_SnapshotFromProductPrice(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
if err := db.Exec("INSERT INTO products (id, name, price, cost_price, stock, images_json) VALUES (101, 'A', 1000, 500, 10, '[]')").Error; err != nil {
if err := db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error; err != nil {
t.Fatalf("insert product failed: %v", err)
}
@ -135,21 +83,15 @@ func TestCreateIssueRewards_SnapshotFromProductPrice(t *testing.T) {
if row.PriceSnapshotCents != 1000 {
t.Fatalf("expected snapshot=1000, got=%d", row.PriceSnapshotCents)
}
if row.CostSnapshotCents != 500 {
t.Fatalf("expected cost snapshot=500, got=%d", row.CostSnapshotCents)
}
if row.PriceSnapshotAt.IsZero() {
t.Fatalf("expected price snapshot time to be set")
}
}
func TestModifyIssueReward_ProductChanged_RecomputeSnapshot(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
_ = db.Exec("INSERT INTO products (id, name, price, cost_price, stock, images_json) VALUES (101, 'A', 1000, 500, 10, '[]')").Error
_ = db.Exec("INSERT INTO products (id, name, price, cost_price, stock, images_json) VALUES (102, 'B', 2300, 1300, 10, '[]')").Error
_ = db.Exec("INSERT INTO activity_reward_settings (id, issue_id, product_id, price_snapshot_cents, cost_snapshot_cents, price_snapshot_at, weight, quantity, original_qty, level, sort, is_boss, min_score, drop_quantity) VALUES (1, 9, 101, 1000, 500, CURRENT_TIMESTAMP, 1, 1, 1, 1, 1, 0, 0, 1)").Error
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (101, 'A', 1000, 10, '[]')").Error
_ = db.Exec("INSERT INTO products (id, name, price, stock, images_json) VALUES (102, 'B', 2300, 10, '[]')").Error
_ = db.Exec("INSERT INTO activity_reward_settings (id, issue_id, product_id, price_snapshot_cents, weight, quantity, original_qty, level, sort, is_boss, min_score) VALUES (1, 9, 101, 1000, 1, 1, 1, 1, 1, 0, 0)").Error
newProductID := int64(102)
if err := svc.ModifyIssueReward(ctx, 1, ModifyRewardInput{ProductID: &newProductID}); err != nil {
@ -166,92 +108,4 @@ func TestModifyIssueReward_ProductChanged_RecomputeSnapshot(t *testing.T) {
if row.PriceSnapshotCents != 2300 {
t.Fatalf("expected snapshot=2300, got=%d", row.PriceSnapshotCents)
}
if row.CostSnapshotCents != 1300 {
t.Fatalf("expected cost snapshot=1300, got=%d", row.CostSnapshotCents)
}
}
func TestCopyActivity_CopiesRewardSnapshotsAndMissingFields(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
snapshotAt := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
if err := db.Exec("INSERT INTO activities (id, name, banner, activity_category_id, status, price_draw, is_boss, end_time) VALUES (118, '源活动', 'banner', 3, 1, 199, 0, CURRENT_TIMESTAMP)").Error; err != nil {
t.Fatalf("insert activity failed: %v", err)
}
if err := db.Exec("INSERT INTO activity_issues (id, activity_id, issue_number, status, sort) VALUES (201, 118, '001', 1, 9)").Error; err != nil {
t.Fatalf("insert issue failed: %v", err)
}
if err := db.Exec("INSERT INTO activity_reward_settings (issue_id, product_id, price_snapshot_cents, cost_snapshot_cents, price_snapshot_at, weight, quantity, original_qty, level, sort, is_boss, min_score, drop_quantity) VALUES (201, 101, 1800, 900, ?, 5, 10, 10, 1, 7, 0, 88, 3)", snapshotAt).Error; err != nil {
t.Fatalf("insert reward failed: %v", err)
}
newActivityID, err := svc.CopyActivity(ctx, 118)
if err != nil {
t.Fatalf("CopyActivity failed: %v", err)
}
if newActivityID == 118 || newActivityID == 0 {
t.Fatalf("expected new activity id, got=%d", newActivityID)
}
issues, err := q.ActivityIssues.WithContext(ctx).Where(q.ActivityIssues.ActivityID.Eq(newActivityID)).Find()
if err != nil || len(issues) != 1 {
t.Fatalf("query new issues failed: %v len=%d", err, len(issues))
}
reward, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.IssueID.Eq(issues[0].ID)).First()
if err != nil {
t.Fatalf("query copied reward failed: %v", err)
}
if reward.PriceSnapshotCents != 1800 || reward.CostSnapshotCents != 900 {
t.Fatalf("unexpected copied snapshots: price=%d cost=%d", reward.PriceSnapshotCents, reward.CostSnapshotCents)
}
if !reward.PriceSnapshotAt.Equal(snapshotAt) {
t.Fatalf("expected copied snapshot time %v, got %v", snapshotAt, reward.PriceSnapshotAt)
}
if reward.MinScore != 88 || reward.DropQuantity != 3 {
t.Fatalf("expected min_score/drop_quantity copied, got min_score=%d drop_quantity=%d", reward.MinScore, reward.DropQuantity)
}
}
func TestCopyActivity_FillsMissingSnapshotData(t *testing.T) {
svc, q, db := newRewardSnapshotTestService(t)
ctx := context.Background()
if err := db.Exec("INSERT INTO products (id, name, price, cost_price, stock, images_json) VALUES (101, 'A', 2600, 1700, 10, '[]')").Error; err != nil {
t.Fatalf("insert product failed: %v", err)
}
if err := db.Exec("INSERT INTO activities (id, name, banner, activity_category_id, status, price_draw, is_boss, end_time) VALUES (118, '源活动', 'banner', 3, 1, 199, 0, CURRENT_TIMESTAMP)").Error; err != nil {
t.Fatalf("insert activity failed: %v", err)
}
if err := db.Exec("INSERT INTO activity_issues (id, activity_id, issue_number, status, sort) VALUES (201, 118, '001', 1, 9)").Error; err != nil {
t.Fatalf("insert issue failed: %v", err)
}
if err := db.Exec("INSERT INTO activity_reward_settings (issue_id, product_id, price_snapshot_cents, cost_snapshot_cents, weight, quantity, original_qty, level, sort, is_boss, min_score, drop_quantity) VALUES (201, 101, 0, 0, 5, 10, 10, 1, 7, 0, 0, 0)").Error; err != nil {
t.Fatalf("insert legacy reward failed: %v", err)
}
newActivityID, err := svc.CopyActivity(ctx, 118)
if err != nil {
t.Fatalf("CopyActivity failed: %v", err)
}
issues, err := q.ActivityIssues.WithContext(ctx).Where(q.ActivityIssues.ActivityID.Eq(newActivityID)).Find()
if err != nil || len(issues) != 1 {
t.Fatalf("query new issues failed: %v len=%d", err, len(issues))
}
reward, err := q.ActivityRewardSettings.WithContext(ctx).Where(q.ActivityRewardSettings.IssueID.Eq(issues[0].ID)).First()
if err != nil {
t.Fatalf("query copied reward failed: %v", err)
}
if reward.PriceSnapshotCents != 2600 || reward.CostSnapshotCents != 1700 {
t.Fatalf("expected fallback snapshots from product, got price=%d cost=%d", reward.PriceSnapshotCents, reward.CostSnapshotCents)
}
if reward.PriceSnapshotAt.IsZero() {
t.Fatalf("expected snapshot time to be backfilled")
}
if reward.DropQuantity != 1 {
t.Fatalf("expected default drop quantity 1, got %d", reward.DropQuantity)
}
}

View File

@ -38,7 +38,6 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo, rdb *redis
go func() {
t := time.NewTicker(30 * time.Second)
defer t.Stop()
lastVirtualShippingRetry := time.Time{}
for range t.C {
ctx := context.Background()
now := time.Now()
@ -299,47 +298,10 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo, rdb *redis
}
}
}
if lastVirtualShippingRetry.IsZero() || now.Sub(lastVirtualShippingRetry) >= 5*time.Minute {
retryPendingVirtualShipping(ctx, l, repo, activitySvc, now)
lastVirtualShippingRetry = now
}
}
}()
}
func retryPendingVirtualShipping(ctx context.Context, l logger.CustomLogger, repo mysql.Repo, activitySvc Service, now time.Time) {
var rows []struct {
ID int64
}
err := repo.GetDbR().Raw(`
SELECT DISTINCT o.id
FROM orders o
INNER JOIN activity_draw_logs dl ON dl.order_id = o.id
WHERE o.status = 2
AND o.source_type = 2
AND o.is_consumed = 0
AND o.created_at < ?
AND o.created_at > ?
ORDER BY o.id ASC
LIMIT 50
`, now.Add(-5*time.Minute), now.Add(-72*time.Hour)).Scan(&rows).Error
if err != nil {
l.Error("[虚拟发货补偿] 查询失败", zap.Error(err))
return
}
if len(rows) == 0 {
return
}
l.Info("[虚拟发货补偿] 发现已开奖未发货订单", zap.Int("count", len(rows)))
for _, row := range rows {
if err := activitySvc.ProcessOrderLottery(ctx, row.ID); err != nil {
l.Error("[虚拟发货补偿] ProcessOrderLottery 失败", zap.Int64("order_id", row.ID), zap.Error(err))
}
}
}
// checkAndResetIchibanSlots 检查并重置所有售罄且已完成的一番赏期号
func checkAndResetIchibanSlots(ctx context.Context, l logger.CustomLogger, repo mysql.Repo, r *dao.Query) {
// 查找所有一番赏活动下的活跃期号

View File

@ -1,430 +0,0 @@
// All the content in this article is only for learning and communication use, not for any other purpose, strictly prohibited for commercial use and illegal use, otherwise all the consequences are irrelevant to the author!
function rc4_encrypt(plaintext, key) {
var s = [];
for (var i = 0; i < 256; i++) {
s[i] = i;
}
var j = 0;
for (var i = 0; i < 256; i++) {
j = (j + s[i] + key.charCodeAt(i % key.length)) % 256;
var temp = s[i];
s[i] = s[j];
s[j] = temp;
}
var i = 0;
var j = 0;
var cipher = [];
for (var k = 0; k < plaintext.length; k++) {
i = (i + 1) % 256;
j = (j + s[i]) % 256;
var temp = s[i];
s[i] = s[j];
s[j] = temp;
var t = (s[i] + s[j]) % 256;
cipher.push(String.fromCharCode(s[t] ^ plaintext.charCodeAt(k)));
}
return cipher.join('');
}
function le(e, r) {
return (e << (r %= 32) | e >>> 32 - r) >>> 0
}
function de(e) {
return 0 <= e && e < 16 ? 2043430169 : 16 <= e && e < 64 ? 2055708042 : void console['error']("invalid j for constant Tj")
}
function pe(e, r, t, n) {
return 0 <= e && e < 16 ? (r ^ t ^ n) >>> 0 : 16 <= e && e < 64 ? (r & t | r & n | t & n) >>> 0 : (console['error']('invalid j for bool function FF'),
0)
}
function he(e, r, t, n) {
return 0 <= e && e < 16 ? (r ^ t ^ n) >>> 0 : 16 <= e && e < 64 ? (r & t | ~r & n) >>> 0 : (console['error']('invalid j for bool function GG'),
0)
}
function reset() {
this.reg[0] = 1937774191,
this.reg[1] = 1226093241,
this.reg[2] = 388252375,
this.reg[3] = 3666478592,
this.reg[4] = 2842636476,
this.reg[5] = 372324522,
this.reg[6] = 3817729613,
this.reg[7] = 2969243214,
this["chunk"] = [],
this["size"] = 0
}
function write(e) {
var a = "string" == typeof e ? function (e) {
n = encodeURIComponent(e)['replace'](/%([0-9A-F]{2})/g, (function (e, r) {
return String['fromCharCode']("0x" + r)
}
))
, a = new Array(n['length']);
return Array['prototype']['forEach']['call'](n, (function (e, r) {
a[r] = e.charCodeAt(0)
}
)),
a
}(e) : e;
this.size += a.length;
var f = 64 - this['chunk']['length'];
if (a['length'] < f)
this['chunk'] = this['chunk'].concat(a);
else
for (this['chunk'] = this['chunk'].concat(a.slice(0, f)); this['chunk'].length >= 64;)
this['_compress'](this['chunk']),
f < a['length'] ? this['chunk'] = a['slice'](f, Math['min'](f + 64, a['length'])) : this['chunk'] = [],
f += 64
}
function sum(e, t) {
e && (this['reset'](),
this['write'](e)),
this['_fill']();
for (var f = 0; f < this.chunk['length']; f += 64)
this._compress(this['chunk']['slice'](f, f + 64));
var i = null;
if (t == 'hex') {
i = "";
for (f = 0; f < 8; f++)
i += se(this['reg'][f]['toString'](16), 8, "0")
} else
for (i = new Array(32),
f = 0; f < 8; f++) {
var c = this.reg[f];
i[4 * f + 3] = (255 & c) >>> 0,
c >>>= 8,
i[4 * f + 2] = (255 & c) >>> 0,
c >>>= 8,
i[4 * f + 1] = (255 & c) >>> 0,
c >>>= 8,
i[4 * f] = (255 & c) >>> 0
}
return this['reset'](),
i
}
function _compress(t) {
if (t < 64)
console.error("compress error: not enough data");
else {
for (var f = function (e) {
for (var r = new Array(132), t = 0; t < 16; t++)
r[t] = e[4 * t] << 24,
r[t] |= e[4 * t + 1] << 16,
r[t] |= e[4 * t + 2] << 8,
r[t] |= e[4 * t + 3],
r[t] >>>= 0;
for (var n = 16; n < 68; n++) {
var a = r[n - 16] ^ r[n - 9] ^ le(r[n - 3], 15);
a = a ^ le(a, 15) ^ le(a, 23),
r[n] = (a ^ le(r[n - 13], 7) ^ r[n - 6]) >>> 0
}
for (n = 0; n < 64; n++)
r[n + 68] = (r[n] ^ r[n + 4]) >>> 0;
return r
}(t), i = this['reg'].slice(0), c = 0; c < 64; c++) {
var o = le(i[0], 12) + i[4] + le(de(c), c)
, s = ((o = le(o = (4294967295 & o) >>> 0, 7)) ^ le(i[0], 12)) >>> 0
, u = pe(c, i[0], i[1], i[2]);
u = (4294967295 & (u = u + i[3] + s + f[c + 68])) >>> 0;
var b = he(c, i[4], i[5], i[6]);
b = (4294967295 & (b = b + i[7] + o + f[c])) >>> 0,
i[3] = i[2],
i[2] = le(i[1], 9),
i[1] = i[0],
i[0] = u,
i[7] = i[6],
i[6] = le(i[5], 19),
i[5] = i[4],
i[4] = (b ^ le(b, 9) ^ le(b, 17)) >>> 0
}
for (var l = 0; l < 8; l++)
this['reg'][l] = (this['reg'][l] ^ i[l]) >>> 0
}
}
function _fill() {
var a = 8 * this['size']
, f = this['chunk']['push'](128) % 64;
for (64 - f < 8 && (f -= 64); f < 56; f++)
this.chunk['push'](0);
for (var i = 0; i < 4; i++) {
var c = Math['floor'](a / 4294967296);
this['chunk'].push(c >>> 8 * (3 - i) & 255)
}
for (i = 0; i < 4; i++)
this['chunk']['push'](a >>> 8 * (3 - i) & 255)
}
function SM3() {
this.reg = [];
this.chunk = [];
this.size = 0;
this.reset()
}
SM3.prototype.reset = reset;
SM3.prototype.write = write;
SM3.prototype.sum = sum;
SM3.prototype._compress = _compress;
SM3.prototype._fill = _fill;
function result_encrypt(long_str, num = null) {
let s_obj = {
"s0": "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/=",
"s1": "Dkdpgh4ZKsQB80/Mfvw36XI1R25+WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
"s2": "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=",
"s3": "ckdp1h4ZKsUB80/Mfvw36XIgR25+WQAlEi7NLboqYTOPuzmFjJnryx9HVGDaStCe",
"s4": "Dkdpgh2ZmsQB80/MfvV36XI1R45-WUAlEixNLwoqYTOPuzKFjJnry79HbGcaStCe"
}
let constant = {
"0": 16515072,
"1": 258048,
"2": 4032,
"str": s_obj[num],
}
let result = "";
let lound = 0;
let long_int = get_long_int(lound, long_str);
for (let i = 0; i < long_str.length / 3 * 4; i++) {
if (Math.floor(i / 4) !== lound) {
lound += 1;
long_int = get_long_int(lound, long_str);
}
let key = i % 4;
switch (key) {
case 0:
temp_int = (long_int & constant["0"]) >> 18;
result += constant["str"].charAt(temp_int);
break;
case 1:
temp_int = (long_int & constant["1"]) >> 12;
result += constant["str"].charAt(temp_int);
break;
case 2:
temp_int = (long_int & constant["2"]) >> 6;
result += constant["str"].charAt(temp_int);
break;
case 3:
temp_int = long_int & 63;
result += constant["str"].charAt(temp_int);
break;
default:
break;
}
}
return result;
}
function get_long_int(round, long_str) {
round = round * 3;
return (long_str.charCodeAt(round) << 16) | (long_str.charCodeAt(round + 1) << 8) | (long_str.charCodeAt(round + 2));
}
function gener_random(random, option) {
return [
(random & 255 & 170) | option[0] & 85, // 163
(random & 255 & 85) | option[0] & 170, //87
(random >> 8 & 255 & 170) | option[1] & 85, //37
(random >> 8 & 255 & 85) | option[1] & 170, //41
]
}
//////////////////////////////////////////////
function generate_rc4_bb_str(url_search_params, user_agent, window_env_str, suffix = "cus", Arguments = [0, 1, 14]) {
let sm3 = new SM3()
let start_time = Date.now()
/**
* 进行3次加密处理
* 1: url_search_params两次sm3之的结果
* 2: 对后缀两次sm3之的结果
* 3: 对ua处理之后的结果
*/
// url_search_params两次sm3之的结果
let url_search_params_list = sm3.sum(sm3.sum(url_search_params + suffix))
// 对后缀两次sm3之的结果
let cus = sm3.sum(sm3.sum(suffix))
// 对ua处理之后的结果
let ua = sm3.sum(result_encrypt(rc4_encrypt(user_agent, String.fromCharCode.apply(null, [0.00390625, 1, 14])), "s3"))
//
let end_time = Date.now()
// b
let b = {
8: 3, // 固定
10: end_time, //3次加密结束时间
15: {
"aid": 6383,
"pageId": 6241,
"boe": false,
"ddrt": 7,
"paths": {
"include": [
{},
{},
{},
{},
{},
{},
{}
],
"exclude": []
},
"track": {
"mode": 0,
"delay": 300,
"paths": []
},
"dump": true,
"rpU": ""
},
16: start_time, //3次加密开始时间
18: 44, //固定
19: [1, 0, 1, 5],
}
//3次加密开始时间
b[20] = (b[16] >> 24) & 255
b[21] = (b[16] >> 16) & 255
b[22] = (b[16] >> 8) & 255
b[23] = b[16] & 255
b[24] = (b[16] / 256 / 256 / 256 / 256) >> 0
b[25] = (b[16] / 256 / 256 / 256 / 256 / 256) >> 0
// 参数Arguments [0, 1, 14, ...]
// let Arguments = [0, 1, 14]
b[26] = (Arguments[0] >> 24) & 255
b[27] = (Arguments[0] >> 16) & 255
b[28] = (Arguments[0] >> 8) & 255
b[29] = Arguments[0] & 255
b[30] = (Arguments[1] / 256) & 255
b[31] = (Arguments[1] % 256) & 255
b[32] = (Arguments[1] >> 24) & 255
b[33] = (Arguments[1] >> 16) & 255
b[34] = (Arguments[2] >> 24) & 255
b[35] = (Arguments[2] >> 16) & 255
b[36] = (Arguments[2] >> 8) & 255
b[37] = Arguments[2] & 255
// (url_search_params + "cus") 两次sm3之的结果
/**let url_search_params_list = [
91, 186, 35, 86, 143, 253, 6, 76,
34, 21, 167, 148, 7, 42, 192, 219,
188, 20, 182, 85, 213, 74, 213, 147,
37, 155, 93, 139, 85, 118, 228, 213
]*/
b[38] = url_search_params_list[21]
b[39] = url_search_params_list[22]
// ("cus") 对后缀两次sm3之的结果
/**
* let cus = [
136, 101, 114, 147, 58, 77, 207, 201,
215, 162, 154, 93, 248, 13, 142, 160,
105, 73, 215, 241, 83, 58, 51, 43,
255, 38, 168, 141, 216, 194, 35, 236
]*/
b[40] = cus[21]
b[41] = cus[22]
// 对ua处理之后的结果
/**
* let ua = [
129, 190, 70, 186, 86, 196, 199, 53,
99, 38, 29, 209, 243, 17, 157, 69,
147, 104, 53, 23, 114, 126, 66, 228,
135, 30, 168, 185, 109, 156, 251, 88
]*/
b[42] = ua[23]
b[43] = ua[24]
//3次加密结束时间
b[44] = (b[10] >> 24) & 255
b[45] = (b[10] >> 16) & 255
b[46] = (b[10] >> 8) & 255
b[47] = b[10] & 255
b[48] = b[8]
b[49] = (b[10] / 256 / 256 / 256 / 256) >> 0
b[50] = (b[10] / 256 / 256 / 256 / 256 / 256) >> 0
// object配置项
b[51] = b[15]['pageId']
b[52] = (b[15]['pageId'] >> 24) & 255
b[53] = (b[15]['pageId'] >> 16) & 255
b[54] = (b[15]['pageId'] >> 8) & 255
b[55] = b[15]['pageId'] & 255
b[56] = b[15]['aid']
b[57] = b[15]['aid'] & 255
b[58] = (b[15]['aid'] >> 8) & 255
b[59] = (b[15]['aid'] >> 16) & 255
b[60] = (b[15]['aid'] >> 24) & 255
// 中间进行了环境检测
// 代码索引: 2496 索引值: 17 索引64关键条件
// '1536|747|1536|834|0|30|0|0|1536|834|1536|864|1525|747|24|24|Win32'.charCodeAt()得到65位数组
/**
* let window_env_list = [49, 53, 51, 54, 124, 55, 52, 55, 124, 49, 53, 51, 54, 124, 56, 51, 52, 124, 48, 124, 51,
* 48, 124, 48, 124, 48, 124, 49, 53, 51, 54, 124, 56, 51, 52, 124, 49, 53, 51, 54, 124, 56,
* 54, 52, 124, 49, 53, 50, 53, 124, 55, 52, 55, 124, 50, 52, 124, 50, 52, 124, 87, 105, 110,
* 51, 50]
*/
let window_env_list = [];
for (let index = 0; index < window_env_str.length; index++) {
window_env_list.push(window_env_str.charCodeAt(index))
}
b[64] = window_env_list.length
b[65] = b[64] & 255
b[66] = (b[64] >> 8) & 255
b[69] = [].length
b[70] = b[69] & 255
b[71] = (b[69] >> 8) & 255
b[72] = b[18] ^ b[20] ^ b[26] ^ b[30] ^ b[38] ^ b[40] ^ b[42] ^ b[21] ^ b[27] ^ b[31] ^ b[35] ^ b[39] ^ b[41] ^ b[43] ^ b[22] ^
b[28] ^ b[32] ^ b[36] ^ b[23] ^ b[29] ^ b[33] ^ b[37] ^ b[44] ^ b[45] ^ b[46] ^ b[47] ^ b[48] ^ b[49] ^ b[50] ^ b[24] ^
b[25] ^ b[52] ^ b[53] ^ b[54] ^ b[55] ^ b[57] ^ b[58] ^ b[59] ^ b[60] ^ b[65] ^ b[66] ^ b[70] ^ b[71]
let bb = [
b[18], b[20], b[52], b[26], b[30], b[34], b[58], b[38], b[40], b[53], b[42], b[21], b[27], b[54], b[55], b[31],
b[35], b[57], b[39], b[41], b[43], b[22], b[28], b[32], b[60], b[36], b[23], b[29], b[33], b[37], b[44], b[45],
b[59], b[46], b[47], b[48], b[49], b[50], b[24], b[25], b[65], b[66], b[70], b[71]
]
bb = bb.concat(window_env_list).concat(b[72])
return rc4_encrypt(String.fromCharCode.apply(null, bb), String.fromCharCode.apply(null, [121]));
}
function generate_random_str() {
let random_str_list = []
random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [3, 45]))
random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [1, 0]))
random_str_list = random_str_list.concat(gener_random(Math.random() * 10000, [1, 5]))
return String.fromCharCode.apply(null, random_str_list)
}
function generate_a_bogus(url_search_params, user_agent) {
/**
* url_search_params"device_platform=webapp&aid=6383&channel=channel_pc_web&update_version_code=170400&pc_client_type=1&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1536&screen_height=864&browser_language=zh-CN&browser_platform=Win32&browser_name=Chrome&browser_version=123.0.0.0&browser_online=true&engine_name=Blink&engine_version=123.0.0.0&os_name=Windows&os_version=10&cpu_core_num=16&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50&webid=7362810250930783783&msToken=VkDUvz1y24CppXSl80iFPr6ez-3FiizcwD7fI1OqBt6IICq9RWG7nCvxKb8IVi55mFd-wnqoNkXGnxHrikQb4PuKob5Q-YhDp5Um215JzlBszkUyiEvR"
* user_agent"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
*/
let result_str = generate_random_str() + generate_rc4_bb_str(
url_search_params,
user_agent,
"1536|747|1536|834|0|30|0|0|1536|834|1536|864|1525|747|24|24|Win32"
);
return result_encrypt(result_str, "s4") + "=";
}
//测试调用
// console.log(generate_a_bogus(
// "device_platform=webapp&aid=6383&channel=channel_pc_web&update_version_code=170400&pc_client_type=1&version_code=170400&version_name=17.4.0&cookie_enabled=true&screen_width=1536&screen_height=864&browser_language=zh-CN&browser_platform=Win32&browser_name=Chrome&browser_version=123.0.0.0&browser_online=true&engine_name=Blink&engine_version=123.0.0.0&os_name=Windows&os_version=10&cpu_core_num=16&device_memory=8&platform=PC&downlink=10&effective_type=4g&round_trip_time=50&webid=7362810250930783783&msToken=VkDUvz1y24CppXSl80iFPr6ez-3FiizcwD7fI1OqBt6IICq9RWG7nCvxKb8IVi55mFd-wnqoNkXGnxHrikQb4PuKob5Q-YhDp5Um215JzlBszkUyiEvR",
// "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
// ));

View File

@ -1,71 +0,0 @@
// Package abogus computes the a_bogus signature required by 抖店/抖音 risk-controlled endpoints.
//
// Implementation: embeds a JavaScript file with the a_bogus algorithm and runs it via goja
// (pure-Go JS engine, no CGO). The JS itself does not depend on any browser globals — it
// takes the URL query string and User-Agent as inputs and returns the signature string.
package abogus
import (
_ "embed"
"fmt"
"sync"
"github.com/dop251/goja"
)
//go:embed a_bogus.js
var aBogusJS string
// Generator is reusable. NewGenerator compiles the JS once; Sign() is goroutine-safe.
type Generator struct {
program *goja.Program
mu sync.Mutex
pool []*goja.Runtime
}
// NewGenerator compiles the embedded a_bogus.js and returns a ready-to-use generator.
func NewGenerator() (*Generator, error) {
// strict=false: a_bogus.js 原文有 `n = ...`未声明等浏览器宽松模式特性goja 严格模式会拒绝
prog, err := goja.Compile("a_bogus.js", aBogusJS, false)
if err != nil {
return nil, fmt.Errorf("compile a_bogus.js: %w", err)
}
return &Generator{program: prog}, nil
}
// Sign computes the a_bogus signature for a given URL query string and User-Agent.
// urlSearchParams is the raw query string WITHOUT a_bogus itself (everything else included).
func (g *Generator) Sign(urlSearchParams, userAgent string) (string, error) {
rt := g.acquire()
defer g.release(rt)
if _, err := rt.RunProgram(g.program); err != nil {
return "", fmt.Errorf("run a_bogus.js: %w", err)
}
fn, ok := goja.AssertFunction(rt.Get("generate_a_bogus"))
if !ok {
return "", fmt.Errorf("generate_a_bogus is not a function")
}
v, err := fn(goja.Undefined(), rt.ToValue(urlSearchParams), rt.ToValue(userAgent))
if err != nil {
return "", fmt.Errorf("call generate_a_bogus: %w", err)
}
return v.String(), nil
}
func (g *Generator) acquire() *goja.Runtime {
g.mu.Lock()
defer g.mu.Unlock()
if n := len(g.pool); n > 0 {
rt := g.pool[n-1]
g.pool = g.pool[:n-1]
return rt
}
return goja.New()
}
func (g *Generator) release(rt *goja.Runtime) {
g.mu.Lock()
defer g.mu.Unlock()
g.pool = append(g.pool, rt)
}

View File

@ -20,7 +20,6 @@ import (
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/douyin/abogus"
"bindbox-game/internal/service/game"
"bindbox-game/internal/service/sysconfig"
"bindbox-game/internal/service/user"
@ -40,8 +39,6 @@ const (
type Service interface {
// FetchAndSyncOrders 从抖店 API 获取订单并同步到本地 (按绑定用户同步)
FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*SyncResult, error)
// SyncUserOrders 按本地用户定向同步其绑定的抖音订单
SyncUserOrders(ctx context.Context, localUserID int64) (*SyncResult, error)
// SyncAllOrders 批量同步所有订单变更 (基于更新时间,不分状态)
// useProxy: 是否使用代理服务器访问抖音API
SyncAllOrders(ctx context.Context, duration time.Duration, useProxy bool) (*SyncResult, error)
@ -209,8 +206,6 @@ type service struct {
sfGroup singleflight.Group
lastSyncTime time.Time
syncLock sync.Mutex
aBogus *abogus.Generator
}
func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticketSvc game.TicketService, userSvc user.Service, titleSvc TitleAssigner) Service {
@ -219,11 +214,6 @@ func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticke
if titleSvc != nil {
dispatcher = NewRewardDispatcher(ticketSvc, userSvc, titleSvc)
}
gen, err := abogus.NewGenerator()
if err != nil {
// 编译失败时打日志并继续fetchDouyinOrdersByBuyer 会自行降级到不带 a_bogus 的请求
l.Warn("[抖店同步] a_bogus 生成器初始化失败,被风控的用户将无法同步", zap.Error(err))
}
return &service{
logger: l,
repo: repo,
@ -233,7 +223,6 @@ func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticke
ticketSvc: ticketSvc,
userSvc: userSvc,
rewardDispatcher: dispatcher,
aBogus: gen,
}
}
@ -372,20 +361,52 @@ func (s *service) FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*
var mu sync.Mutex
syncUser := func(u model.Users) {
fetched, newOrders, matchedOrders, err := s.syncOrdersForBoundUser(ctx, cfg, u)
select {
case <-ctx.Done():
return
default:
}
s.logger.Info("[抖店同步] 开始同步用户订单",
zap.Int64("user_id", u.ID),
zap.String("nickname", u.Nickname),
zap.String("douyin_user_id", u.DouyinUserID))
orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID, cfg.Proxy)
if err != nil {
s.logger.Warn("[抖店同步] 抓取用户订单失败",
zap.String("douyin_user_id", u.DouyinUserID),
zap.Error(err))
mu.Lock()
result.SkippedUsers++
mu.Unlock()
return
}
perUserNew := 0
perUserMatched := 0
for _, order := range orders {
isNew, matched := s.SyncOrder(ctx, &order, u.ID, "")
if isNew {
perUserNew++
}
if matched {
perUserMatched++
}
}
mu.Lock()
result.ProcessedUsers++
result.TotalFetched += fetched
result.NewOrders += newOrders
result.MatchedUsers += matchedOrders
result.TotalFetched += len(orders)
result.NewOrders += perUserNew
result.MatchedUsers += perUserMatched
mu.Unlock()
s.logger.Info("[抖店同步] 用户订单同步完成",
zap.Int64("user_id", u.ID),
zap.Int("fetched", len(orders)),
zap.Int("new_orders", perUserNew),
zap.Int("matched_orders", perUserMatched))
}
for start := 0; start < len(users); start += options.BatchSize {
@ -456,151 +477,15 @@ func (s *service) FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*
return result, nil
}
// SyncUserOrders 按本地用户定向同步其绑定的抖音订单
func (s *service) SyncUserOrders(ctx context.Context, localUserID int64) (*SyncResult, error) {
if localUserID <= 0 {
return nil, fmt.Errorf("无效用户ID")
}
var boundUser model.Users
if err := s.repo.GetDbR().WithContext(ctx).
Model(&model.Users{}).
Where("id = ?", localUserID).
First(&boundUser).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("用户不存在")
}
return nil, fmt.Errorf("获取用户信息失败: %w", err)
}
if strings.TrimSpace(boundUser.DouyinUserID) == "" {
return nil, fmt.Errorf("当前用户未绑定抖音号")
}
cfg, err := s.GetConfig(ctx)
if err != nil {
return nil, fmt.Errorf("获取配置失败: %w", err)
}
if cfg.Cookie == "" {
return nil, fmt.Errorf("抖店 Cookie 未配置")
}
sharedKey := fmt.Sprintf("SyncUserOrders:%d", localUserID)
value, err, _ := s.sfGroup.Do(sharedKey, func() (interface{}, error) {
result := &SyncResult{TotalUsers: 1}
startAt := time.Now()
s.logger.Info("[抖店同步] 用户手动触发同步开始",
zap.Int64("user_id", boundUser.ID),
zap.String("nickname", boundUser.Nickname),
zap.String("douyin_user_id", boundUser.DouyinUserID))
fetched, newOrders, matchedOrders, syncErr := s.syncOrdersForBoundUser(ctx, cfg, boundUser)
if syncErr != nil {
return nil, syncErr
}
result.ProcessedUsers = 1
result.TotalFetched = fetched
result.NewOrders = newOrders
result.MatchedUsers = matchedOrders
result.ElapsedMS = time.Since(startAt).Milliseconds()
result.DebugInfo = fmt.Sprintf(
"用户定向同步完成: user_id=%d, 抓取 %d, 新订单 %d, 匹配 %d, 耗时 %.2fs",
boundUser.ID, fetched, newOrders, matchedOrders, float64(result.ElapsedMS)/1000.0,
)
s.logger.Info("[抖店同步] 用户手动触发同步完成",
zap.Int64("user_id", boundUser.ID),
zap.Int("fetched", fetched),
zap.Int("new_orders", newOrders),
zap.Int("matched_orders", matchedOrders),
zap.Int64("elapsed_ms", result.ElapsedMS))
return result, nil
})
if err != nil {
return nil, err
}
result, ok := value.(*SyncResult)
if !ok {
return nil, fmt.Errorf("同步结果类型错误: %T", value)
}
return result, nil
}
func (s *service) syncOrdersForBoundUser(ctx context.Context, cfg *DouyinConfig, boundUser model.Users) (int, int, int, error) {
select {
case <-ctx.Done():
return 0, 0, 0, ctx.Err()
default:
}
s.logger.Info("[抖店同步] 开始同步用户订单",
zap.Int64("user_id", boundUser.ID),
zap.String("nickname", boundUser.Nickname),
zap.String("douyin_user_id", boundUser.DouyinUserID))
orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, boundUser.DouyinUserID, cfg.Proxy)
if err != nil {
s.logger.Warn("[抖店同步] 抓取用户订单失败",
zap.Int64("user_id", boundUser.ID),
zap.String("douyin_user_id", boundUser.DouyinUserID),
zap.Error(err))
return 0, 0, 0, err
}
newOrders := 0
matchedOrders := 0
for _, order := range orders {
isNew, matched := s.SyncOrder(ctx, &order, boundUser.ID, "")
if isNew {
newOrders++
}
if matched {
matchedOrders++
}
}
s.logger.Info("[抖店同步] 用户订单同步完成",
zap.Int64("user_id", boundUser.ID),
zap.Int("fetched", len(orders)),
zap.Int("new_orders", newOrders),
zap.Int("matched_orders", matchedOrders))
return len(orders), newOrders, matchedOrders, nil
}
// removed SyncShopOrders
// 抖店 API 响应结构
// 注意code 在不同风控分支会返回 string 或 int故用 json.RawMessage 兼容
type douyinOrderResponse struct {
Errno int `json:"errno"`
Code json.RawMessage `json:"code"`
St int `json:"st"` // 抖店实际返回的是 st 而非 code
Msg string `json:"msg"`
Data any `json:"data"` // data 可能是订单数组,也可能是验证对象
}
// codeString 把可能是 int 或 string 的 code 字段统一转成字符串
func (r *douyinOrderResponse) codeString() string {
if len(r.Code) == 0 {
return ""
}
s := string(r.Code)
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
return s[1 : len(s)-1]
}
return s
}
// codeIsZero 判断 code 是否表示成功(数值 0 或字符串 "0"
func (r *douyinOrderResponse) codeIsZero() bool {
c := r.codeString()
return c == "" || c == "0"
Errno int `json:"errno"`
Code int `json:"code"`
St int `json:"st"` // 抖店实际返回的是 st 而非 code
Msg string `json:"msg"`
Data any `json:"data"` // data 可能是订单数组,也可能是验证对象
}
// 抖店验证响应结构 (当检测到自动化请求时返回)
@ -636,34 +521,11 @@ type SkuOrderItem struct {
SkuID string `json:"sku_id"`
}
// parseCookieValue 从 cookie 字符串中提取指定 key 对应的值
func parseCookieValue(cookie, key string) string {
for _, part := range strings.Split(cookie, ";") {
kv := strings.TrimSpace(part)
if strings.HasPrefix(kv, key+"=") {
return strings.TrimPrefix(kv, key+"=")
}
}
return ""
}
// 抖店 searchlist 接口被风控时会返回这套伪装响应,识别它就走 a_bogus 重试
const douyinAntiBotST = 602502051
// uaForSign 是计算 a_bogus 时用的 UA必须和实际请求保持一致
const uaForSign = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36"
// fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单
// 流程:
// 1. 拼基础参数(含 __token / msToken / verifyFp / fp全部按浏览器结构对齐
// 2. 用 goja 跑 a_bogus.js 算签名并附加
// 3. 命中风控时退化为不带 a_bogus 的简化请求兜底重试一次
// fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单 (保持向后兼容)
func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string, proxy string) ([]DouyinOrderItem, error) {
params := url.Values{}
params.Set("page", "0")
// 与浏览器抓包保持一致pageSize=100 在历史观测中容易触发风控
params.Set("pageSize", "10")
params.Set("compact_time[select]", "create_time_start,create_time_end")
params.Set("pageSize", "100")
params.Set("buyer", buyer)
params.Set("order_by", "create_time")
params.Set("order", "desc")
@ -671,55 +533,15 @@ func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string, proxy st
params.Set("appid", "1")
params.Set("_bid", "ffa_order")
params.Set("aid", "4272")
params.Set("__token", "55397afced1b2e260b939336045e29cd")
if token := parseCookieValue(cookie, "csrf_session_id"); token != "" {
params.Set("__token", token)
} else {
s.logger.Warn("[抖店API] cookie 中未解析到 csrf_session_id使用兜底 __token请尽快刷新 Cookie")
params.Set("__token", "55397afced1b2e260b939336045e29cd")
}
if v := parseCookieValue(cookie, "s_v_web_id"); v != "" {
params.Set("verifyFp", v)
params.Set("fp", v)
} else {
// s_v_web_id 是设备指纹cookie 缺时给个常见占位值,确保 a_bogus 算的 url 与发送的 url 一致
fp := "verify_mmwdotm1_QYpHiLoc_99vO_49un_9xFU_0ZKfqsmF8gzh"
params.Set("verifyFp", fp)
params.Set("fp", fp)
}
if v := parseCookieValue(cookie, "msToken"); v != "" {
params.Set("msToken", v)
} else {
// msToken 抖店通常会下发到 cookie缺就给个旧值主要是为了保证 a_bogus 计算时 url 与请求一致
params.Set("msToken", "qo0QYnkK7z_SrM7MPt2AA5xdWwKSGInO7AEeALRJ_BshJqip3nSLTnGa-gFL-aSNP6m1qNnf71-kf6hUf8xbwwLhbsaa_q3BamgxUXPxm4oXIyWPwBOXeXldqOkRV3naDtcad6PJb7rbxhbOaESKQ1YHY1y__z9Wt8GduCOxF-3ks9xHqstnKccV")
}
// 用 goja 计算 a_bogus失败不阻塞会走兜底
// 关键a_bogus 必须以「同样字节序的 query 字符串」尾接到 URL不能放回 params 再 Encode
// Encode 会把 a_bogus 排到字母序中间,请求 URL 与签名时算的字符串就对不上了)
extra := ""
if s.aBogus != nil {
if sig, err := s.aBogus.Sign(params.Encode(), uaForSign); err == nil {
extra = "a_bogus=" + url.QueryEscape(sig)
} else {
s.logger.Warn("[抖店API] a_bogus 签名失败", zap.String("buyer", buyer), zap.Error(err))
}
}
return s.fetchDouyinOrders(cookie, params, proxy, extra)
return s.fetchDouyinOrders(cookie, params, proxy)
}
// fetchDouyinOrders 通用的抖店订单抓取方法
// extraQuery 会在 params.Encode() 后用 "&" 拼接到末尾(用于 a_bogus 这类不能参与字母序排序的签名参数)
func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr string, extraQuery ...string) ([]DouyinOrderItem, error) {
func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr string) ([]DouyinOrderItem, error) {
baseUrl := "https://fxg.jinritemai.com/api/order/searchlist"
fullUrl := baseUrl + "?" + params.Encode()
for _, e := range extraQuery {
if e != "" {
fullUrl += "&" + e
}
}
// 配置代理服务器巨量代理IP (可选)
var proxyURL *url.URL
@ -740,12 +562,11 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr
}
// 设置请求头(模拟真实浏览器)
// UA 必须与 uaForSign 完全一致a_bogus 签名时把 UA 算进去了UA 错位会被风控秒拒
req.Header.Set("User-Agent", uaForSign)
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/145.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9")
req.Header.Set("Referer", "https://fxg.jinritemai.com/ffa/morder/order/list")
req.Header.Set("sec-ch-ua", `"Google Chrome";v="147", "Not.A/Brand";v="8", "Chromium";v="147"`)
req.Header.Set("sec-ch-ua", `"Not:A-Brand";v="99", "Google Chrome";v="145", "Chromium";v="145"`)
req.Header.Set("sec-ch-ua-mobile", "?0")
req.Header.Set("sec-ch-ua-platform", `"macOS"`)
req.Header.Set("sec-fetch-dest", "empty")
@ -822,28 +643,13 @@ func (s *service) fetchDouyinOrders(cookie string, params url.Values, proxyAddr
}
}
buyer := params.Get("buyer")
codeStr := respData.codeString()
if len(orders) == 0 {
// 空响应是定位「拉不到」的关键日志:打全 body 便于判断是风控、空数据还是过期 token
s.logger.Warn("[抖店API] 接口返回空订单",
zap.String("buyer", buyer),
zap.Int("st", respData.St),
zap.String("code", codeStr),
zap.Int("errno", respData.Errno),
zap.String("msg", respData.Msg),
zap.String("body", string(body[:min(len(body), 2000)])))
} else {
s.logger.Info("[抖店API] 接口返回订单",
zap.String("buyer", buyer),
zap.Int("count", len(orders)),
zap.Int("st", respData.St),
zap.String("code", codeStr),
zap.String("body_head", string(body[:min(len(body), 500)])))
// 临时调试日志:打印第一笔订单的金额字段
if len(orders) > 0 {
fmt.Printf("[DEBUG] 抖店订单 0 金额测试: RawBody(500)=%s\n", string(body[:min(len(body), 500)]))
}
if respData.St != 0 && !respData.codeIsZero() {
return nil, fmt.Errorf("API 返回错误: %s (ST:%d CODE:%s)", respData.Msg, respData.St, codeStr)
if respData.St != 0 && respData.Code != 0 {
return nil, fmt.Errorf("API 返回错误: %s (ST:%d CODE:%d)", respData.Msg, respData.St, respData.Code)
}
return orders, nil

View File

@ -1,43 +0,0 @@
package douyin
import (
"context"
"strings"
"testing"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
)
func TestSyncUserOrders_RejectsUserWithoutBinding(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`CREATE TABLE users (
id INTEGER PRIMARY KEY,
nickname TEXT,
douyin_user_id TEXT,
deleted_at DATETIME
)`).Error; err != nil {
t.Fatal(err)
}
if err := repo.GetDbW().Exec(`INSERT INTO users (id, nickname, douyin_user_id) VALUES (1, 'tester', '')`).Error; err != nil {
t.Fatal(err)
}
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
svc := New(lg, repo, nil, nil, nil, nil).(*service)
_, err = svc.SyncUserOrders(context.Background(), 1)
if err == nil {
t.Fatal("expected error for user without douyin binding")
}
if !strings.Contains(err.Error(), "未绑定抖音号") {
t.Fatalf("unexpected error: %v", err)
}
}

View File

@ -105,7 +105,7 @@ func (s *gameTokenService) GenerateToken(ctx context.Context, userID int64, user
// ValidateToken validates a game token and returns the claims
func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string) (*GameTokenClaims, error) {
// 1. Parse and validate JWT (game_token format)
// 1. Parse and validate JWT
token, err := jwt.ParseWithClaims(tokenString, &GameTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
@ -114,9 +114,8 @@ func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string
})
if err != nil {
// Fallback: try parsing as business login JWT (for browser testing)
s.logger.Info("Game token validation failed, trying business token fallback", zap.Error(err))
return s.tryBusinessTokenFallback(tokenString)
s.logger.Warn("Token JWT validation failed", zap.Error(err))
return nil, fmt.Errorf("invalid token: %w", err)
}
claims, ok := token.Claims.(*GameTokenClaims)
@ -154,49 +153,6 @@ func (s *gameTokenService) ValidateToken(ctx context.Context, tokenString string
return claims, nil
}
// businessLoginClaims mirrors the business login JWT structure (proposal.SessionUserInfo)
type businessLoginClaims struct {
Id int32 `json:"id"`
UserName string `json:"username"`
NickName string `json:"nickname"`
IsSuper int32 `json:"is_super"`
Platform string `json:"platform"`
jwt.RegisteredClaims
}
// tryBusinessTokenFallback attempts to parse a business login JWT and convert it to GameTokenClaims.
// This allows browser testing with the user's login token instead of a game_token.
func (s *gameTokenService) tryBusinessTokenFallback(tokenString string) (*GameTokenClaims, error) {
patientSecret := configs.Get().JWT.PatientSecret
token, err := jwt.ParseWithClaims(tokenString, &businessLoginClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(patientSecret), nil
})
if err != nil {
return nil, fmt.Errorf("invalid token: %w", err)
}
bClaims, ok := token.Claims.(*businessLoginClaims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid business token claims")
}
s.logger.Info("Business token fallback succeeded",
zap.Int32("user_id", bClaims.Id),
zap.String("username", bClaims.NickName),
zap.String("platform", bClaims.Platform))
return &GameTokenClaims{
UserID: int64(bClaims.Id),
Username: bClaims.NickName,
GameType: "minesweeper",
Ticket: fmt.Sprintf("BIZ%d%d", bClaims.Id, time.Now().UnixNano()),
RegisteredClaims: bClaims.RegisteredClaims,
}, nil
}
// InvalidateTicket marks a ticket as used
func (s *gameTokenService) InvalidateTicket(ctx context.Context, ticket string) error {
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)

View File

@ -1,547 +0,0 @@
package prize_grant_activity
import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"context"
"encoding/json"
"errors"
"fmt"
"math/rand"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service interface {
CreateActivity(ctx context.Context, req SaveActivityRequest) (*Activity, error)
UpdateActivity(ctx context.Context, id int64, req SaveActivityRequest) error
DeleteActivity(ctx context.Context, id int64) error
GetActivity(ctx context.Context, id int64) (*ActivityDetail, error)
ListActivities(ctx context.Context, req ListActivitiesRequest) (*ListActivitiesResponse, error)
GetPendingActivity(ctx context.Context, userID int64) (*PendingActivityResponse, error)
ClaimActivity(ctx context.Context, activityID int64, userID int64) (*ClaimResponse, error)
MarkUsersProcessed(ctx context.Context, activityID int64, adminID int64, userIDs []int64) error
MarkAllUsersProcessed(ctx context.Context, activityID int64, adminID int64) (int64, error)
DeleteUserRecord(ctx context.Context, activityID int64, recordID int64) error
GetCostSummary(ctx context.Context) (*CostSummary, error)
ListUserRecords(ctx context.Context, activityID int64, status string, keyword string, page int, pageSize int) (map[string]any, error)
}
type service struct {
logger logger.CustomLogger
repo mysql.Repo
writeDB *dao.Query
readDB *dao.Query
}
func New(log logger.CustomLogger, repo mysql.Repo) Service {
return &service{
logger: log,
repo: repo,
writeDB: dao.Use(repo.GetDbW()),
readDB: dao.Use(repo.GetDbR()),
}
}
func (s *service) CreateActivity(ctx context.Context, req SaveActivityRequest) (*Activity, error) {
if err := validateSaveRequest(req); err != nil {
return nil, err
}
item := &Activity{Reason: strings.TrimSpace(req.Reason), Status: normalizeStatus(req.Status)}
err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(item).Error; err != nil {
return err
}
return s.replaceRewards(ctx, tx, item.ID, req.Rewards)
})
return item, err
}
func (s *service) UpdateActivity(ctx context.Context, id int64, req SaveActivityRequest) error {
if id <= 0 {
return errors.New("活动ID无效")
}
if err := validateSaveRequest(req); err != nil {
return err
}
return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&Activity{}).Where("id = ?", id).Updates(map[string]any{
"reason": strings.TrimSpace(req.Reason),
"status": normalizeStatus(req.Status),
}).Error; err != nil {
return err
}
return s.replaceRewards(ctx, tx, id, req.Rewards)
})
}
func (s *service) DeleteActivity(ctx context.Context, id int64) error {
if id <= 0 {
return errors.New("活动ID无效")
}
return s.repo.GetDbW().WithContext(ctx).Where("id = ?", id).Delete(&Activity{}).Error
}
func (s *service) ListActivities(ctx context.Context, req ListActivitiesRequest) (*ListActivitiesResponse, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
db := s.repo.GetDbR().WithContext(ctx).Model(&Activity{})
if strings.TrimSpace(req.Reason) != "" {
db = db.Where("reason LIKE ?", "%"+strings.TrimSpace(req.Reason)+"%")
}
if strings.TrimSpace(req.Status) != "" {
db = db.Where("status = ?", strings.TrimSpace(req.Status))
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var rows []Activity
if err := db.Order("id DESC").Offset((req.Page-1)*req.PageSize).Limit(req.PageSize).Find(&rows).Error; err != nil {
return nil, err
}
list := make([]ActivityListItem, 0, len(rows))
for _, row := range rows {
item := ActivityListItem{Activity: row}
s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_rewards").Where("activity_id = ?", row.ID).Count(&item.RewardCount)
s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", row.ID, RecordStatusClaimed).Count(&item.ClaimedCount)
s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", row.ID, RecordStatusProcessed).Count(&item.ProcessedCount)
cost, err := s.calculateClaimedCost(ctx, row.ID)
if err != nil {
return nil, err
}
item.CostCents = cost
list = append(list, item)
}
return &ListActivitiesResponse{Page: req.Page, PageSize: req.PageSize, Total: total, List: list}, nil
}
func (s *service) GetActivity(ctx context.Context, id int64) (*ActivityDetail, error) {
var item Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", id).First(&item).Error; err != nil {
return nil, err
}
rewards, err := s.loadRewardViews(ctx, id)
if err != nil {
return nil, err
}
detail := &ActivityDetail{Activity: item, Rewards: rewards}
s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", id, RecordStatusClaimed).Count(&detail.ClaimedCount)
s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", id, RecordStatusProcessed).Count(&detail.ProcessedCount)
var totalUsers int64
s.repo.GetDbR().WithContext(ctx).Model(&model.Users{}).Count(&totalUsers)
detail.PendingCount = totalUsers - detail.ClaimedCount - detail.ProcessedCount
if detail.PendingCount < 0 {
detail.PendingCount = 0
}
cost, err := s.calculateClaimedCost(ctx, id)
if err != nil {
return nil, err
}
detail.CostCents = cost
return detail, nil
}
func (s *service) GetPendingActivity(ctx context.Context, userID int64) (*PendingActivityResponse, error) {
if userID <= 0 {
return &PendingActivityResponse{HasPending: false}, nil
}
var item Activity
if err := s.repo.GetDbR().WithContext(ctx).
Where("status = ?", "active").
Where("NOT EXISTS (SELECT 1 FROM prize_grant_activity_user_records r WHERE r.activity_id = prize_grant_activities.id AND r.user_id = ?)", userID).
Order("id DESC").
First(&item).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return &PendingActivityResponse{HasPending: false}, nil
}
return nil, err
}
rewards, err := s.loadRewardViews(ctx, item.ID)
if err != nil {
return nil, err
}
return &PendingActivityResponse{HasPending: true, Activity: &PendingActivity{ID: item.ID, Reason: item.Reason, Rewards: rewards}}, nil
}
func (s *service) ClaimActivity(ctx context.Context, activityID int64, userID int64) (*ClaimResponse, error) {
if activityID <= 0 || userID <= 0 {
return nil, errors.New("参数无效")
}
var recordID int64
err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var item Activity
if err := tx.WithContext(ctx).Where("id = ? AND status = ?", activityID, "active").First(&item).Error; err != nil {
return err
}
var existing UserRecord
err := tx.WithContext(ctx).Where("activity_id = ? AND user_id = ?", activityID, userID).First(&existing).Error
if err == nil {
return errors.New("该活动已处理")
}
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var rewards []Reward
if err := tx.WithContext(ctx).Where("activity_id = ?", activityID).Order("sort ASC, id ASC").Find(&rewards).Error; err != nil {
return err
}
if len(rewards) == 0 {
return errors.New("活动未配置奖品")
}
for _, reward := range rewards {
for i := int32(0); i < reward.QuantityPerClaim; i++ {
if err := s.grantReward(ctx, tx, activityID, userID, reward); err != nil {
return err
}
}
}
now := time.Now()
record := &UserRecord{ActivityID: activityID, UserID: userID, Status: RecordStatusClaimed, ClaimedAt: &now, OperatorAdminID: 0}
if err := tx.WithContext(ctx).Create(record).Error; err != nil {
return err
}
recordID = record.ID
return nil
})
if err != nil {
return nil, err
}
return &ClaimResponse{ActivityID: activityID, RecordID: recordID, Status: RecordStatusClaimed}, nil
}
func (s *service) MarkUsersProcessed(ctx context.Context, activityID int64, adminID int64, userIDs []int64) error {
if activityID <= 0 {
return errors.New("活动ID无效")
}
if len(userIDs) == 0 {
return errors.New("用户不能为空")
}
now := time.Now()
return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
for _, userID := range userIDs {
if userID <= 0 {
continue
}
record := &UserRecord{ActivityID: activityID, UserID: userID, Status: RecordStatusProcessed, ProcessedAt: &now, OperatorAdminID: adminID}
if err := tx.WithContext(ctx).Clauses(clauseOnConflictUpdateProcessed()).Create(record).Error; err != nil {
return err
}
}
return nil
})
}
func (s *service) MarkAllUsersProcessed(ctx context.Context, activityID int64, adminID int64) (int64, error) {
var users []model.Users
if err := s.repo.GetDbR().WithContext(ctx).Find(&users).Error; err != nil {
return 0, err
}
ids := make([]int64, 0, len(users))
for _, user := range users {
ids = append(ids, user.ID)
}
if err := s.MarkUsersProcessed(ctx, activityID, adminID, ids); err != nil {
return 0, err
}
return int64(len(ids)), nil
}
func (s *service) DeleteUserRecord(ctx context.Context, activityID int64, recordID int64) error {
if activityID <= 0 || recordID <= 0 {
return errors.New("参数无效")
}
return s.repo.GetDbW().WithContext(ctx).Where("id = ? AND activity_id = ?", recordID, activityID).Delete(&UserRecord{}).Error
}
func (s *service) GetCostSummary(ctx context.Context) (*CostSummary, error) {
var activities []Activity
if err := s.repo.GetDbR().WithContext(ctx).Find(&activities).Error; err != nil {
return nil, err
}
var total int64
for _, activity := range activities {
cost, err := s.calculateClaimedCost(ctx, activity.ID)
if err != nil {
return nil, err
}
total += cost
}
var count int64
if err := s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("status = ?", RecordStatusClaimed).Count(&count).Error; err != nil {
return nil, err
}
return &CostSummary{CostCents: total, Count: count}, nil
}
func (s *service) ListUserRecords(ctx context.Context, activityID int64, status string, keyword string, page int, pageSize int) (map[string]any, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
db := s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records r").
Select("r.id, r.activity_id, r.user_id, r.status, r.claimed_at, r.processed_at, r.operator_admin_id, r.created_at, r.updated_at, u.nickname, u.mobile").
Joins("LEFT JOIN users u ON u.id = r.user_id").
Where("r.activity_id = ?", activityID)
if strings.TrimSpace(status) != "" {
db = db.Where("r.status = ?", strings.TrimSpace(status))
}
if strings.TrimSpace(keyword) != "" {
kw := "%" + strings.TrimSpace(keyword) + "%"
db = db.Where("CAST(r.user_id AS CHAR) LIKE ? OR u.nickname LIKE ? OR u.mobile LIKE ?", kw, kw, kw)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var rows []map[string]any
if err := db.Order("r.id DESC").Offset((page-1)*pageSize).Limit(pageSize).Scan(&rows).Error; err != nil {
return nil, err
}
return map[string]any{"page": page, "page_size": pageSize, "total": total, "list": rows}, nil
}
func validateSaveRequest(req SaveActivityRequest) error {
if strings.TrimSpace(req.Reason) == "" {
return errors.New("发奖原因不能为空")
}
if len(req.Rewards) == 0 {
return errors.New("至少配置一个奖品")
}
for _, reward := range req.Rewards {
if reward.RewardRefID <= 0 {
return errors.New("奖品资源ID无效")
}
if reward.QuantityPerClaim <= 0 {
return errors.New("领取数量必须大于0")
}
if reward.RewardType != RewardTypeProduct && reward.RewardType != RewardTypeCoupon && reward.RewardType != RewardTypeItemCard {
return fmt.Errorf("不支持的奖品类型: %s", reward.RewardType)
}
}
return nil
}
func normalizeStatus(status string) string {
if strings.TrimSpace(status) == "active" {
return "active"
}
return "inactive"
}
func (s *service) replaceRewards(ctx context.Context, tx *gorm.DB, activityID int64, inputs []RewardInput) error {
if err := tx.WithContext(ctx).Where("activity_id = ?", activityID).Delete(&Reward{}).Error; err != nil {
return err
}
items := make([]Reward, 0, len(inputs))
for idx, input := range inputs {
sort := input.Sort
if sort == 0 {
sort = int32(idx + 1)
}
items = append(items, Reward{ActivityID: activityID, RewardType: input.RewardType, RewardRefID: input.RewardRefID, QuantityPerClaim: input.QuantityPerClaim, Sort: sort})
}
return tx.WithContext(ctx).Create(&items).Error
}
func (s *service) loadRewardViews(ctx context.Context, activityID int64) ([]RewardView, error) {
var rewards []Reward
if err := s.repo.GetDbR().WithContext(ctx).Where("activity_id = ?", activityID).Order("sort ASC, id ASC").Find(&rewards).Error; err != nil {
return nil, err
}
views := make([]RewardView, 0, len(rewards))
for _, reward := range rewards {
view := RewardView{RewardType: reward.RewardType, RewardRefID: reward.RewardRefID, Quantity: reward.QuantityPerClaim}
switch reward.RewardType {
case RewardTypeProduct:
var product model.Products
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&product).Error; err == nil {
view.Name = product.Name
view.ValueCents = product.Price
images := parseProductImages(product.ImagesJSON)
if len(images) > 0 {
view.Image = images[0]
}
}
case RewardTypeCoupon:
var coupon model.SystemCoupons
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&coupon).Error; err == nil {
view.Name = coupon.Name
view.ValueCents = coupon.DiscountValue
}
case RewardTypeItemCard:
var card model.SystemItemCards
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&card).Error; err == nil {
view.Name = card.Name
view.ValueCents = card.Price
}
}
views = append(views, view)
}
return views, nil
}
func (s *service) calculateClaimedCost(ctx context.Context, activityID int64) (int64, error) {
var rewards []Reward
if err := s.repo.GetDbR().WithContext(ctx).Where("activity_id = ?", activityID).Find(&rewards).Error; err != nil {
return 0, err
}
var claimedCount int64
if err := s.repo.GetDbR().WithContext(ctx).Table("prize_grant_activity_user_records").Where("activity_id = ? AND status = ?", activityID, RecordStatusClaimed).Count(&claimedCount).Error; err != nil {
return 0, err
}
var costPerClaim int64
for _, reward := range rewards {
switch reward.RewardType {
case RewardTypeProduct:
var product model.Products
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&product).Error; err == nil {
unit := product.CostPrice
if unit <= 0 {
unit = product.Price
}
costPerClaim += unit * int64(reward.QuantityPerClaim)
}
case RewardTypeCoupon:
var coupon model.SystemCoupons
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&coupon).Error; err == nil {
costPerClaim += coupon.DiscountValue * int64(reward.QuantityPerClaim)
}
case RewardTypeItemCard:
var card model.SystemItemCards
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&card).Error; err == nil {
costPerClaim += card.Price * int64(reward.QuantityPerClaim)
}
}
}
return costPerClaim * claimedCount, nil
}
func (s *service) grantReward(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, reward Reward) error {
switch reward.RewardType {
case RewardTypeCoupon:
var tpl model.SystemCoupons
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", reward.RewardRefID).First(&tpl).Error; err != nil {
return err
}
item := &model.UserCoupons{UserID: userID, CouponID: tpl.ID, Status: 1}
if !tpl.ValidStart.IsZero() {
item.ValidStart = tpl.ValidStart
} else {
item.ValidStart = time.Now()
}
if !tpl.ValidEnd.IsZero() {
item.ValidEnd = tpl.ValidEnd
}
do := tx.WithContext(ctx).Omit("used_at", "used_order_id")
if tpl.ValidEnd.IsZero() {
do = do.Omit("valid_end")
}
if err := do.Create(item).Error; err != nil {
return err
}
balance := int64(0)
if tpl.DiscountType == 1 && tpl.DiscountValue > 0 {
balance = tpl.DiscountValue
}
return tx.WithContext(ctx).Model(&model.UserCoupons{}).Where("id = ?", item.ID).Update("balance_amount", balance).Error
case RewardTypeItemCard:
var card model.SystemItemCards
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", reward.RewardRefID).First(&card).Error; err != nil {
return err
}
now := time.Now()
item := &model.UserItemCards{UserID: userID, CardID: card.ID, Status: 1, Remark: "奖品发放活动领取"}
if !card.ValidStart.IsZero() {
item.ValidStart = card.ValidStart
} else {
item.ValidStart = now
}
if !card.ValidEnd.IsZero() {
item.ValidEnd = card.ValidEnd
}
do := tx.WithContext(ctx).Omit("used_at", "used_draw_log_id", "used_activity_id", "used_issue_id")
if card.ValidEnd.IsZero() {
do = do.Omit("valid_end")
}
return do.Create(item).Error
default:
var product model.Products
if err := tx.WithContext(ctx).Where("id = ?", reward.RewardRefID).First(&product).Error; err != nil {
return err
}
if product.Stock <= 0 {
return fmt.Errorf("商品库存不足: %s", product.Name)
}
result := tx.WithContext(ctx).Model(&model.Products{}).Where("id = ? AND stock > 0", product.ID).Update("stock", gorm.Expr("stock - 1"))
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return fmt.Errorf("商品库存不足: %s", product.Name)
}
now := time.Now()
minValidTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
order := &model.Orders{OrderNo: fmt.Sprintf("PG%d%d%04d", activityID, now.Unix(), rand.Intn(10000)), UserID: userID, SourceType: 6, Status: 2, PaidAt: now, CancelledAt: minValidTime, Remark: "奖品发放活动领取", CreatedAt: now, UpdatedAt: now}
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
return err
}
orderItem := &model.OrderItems{OrderID: order.ID, ProductID: product.ID, Title: product.Name, Quantity: 1, ProductImages: product.ImagesJSON, Status: 1}
if err := tx.WithContext(ctx).Create(orderItem).Error; err != nil {
return err
}
value := product.CostPrice
if value <= 0 {
value = product.Price
}
inventory := &model.UserInventory{UserID: userID, ProductID: product.ID, ValueCents: value, ValueSource: 2, ValueSnapshotAt: now, OrderID: order.ID, ActivityID: activityID, Status: 1, Remark: "奖品发放活动领取"}
return tx.WithContext(ctx).Create(inventory).Error
}
}
func clauseOnConflictUpdateProcessed() clause.OnConflict {
return clause.OnConflict{
Columns: []clause.Column{{Name: "activity_id"}, {Name: "user_id"}},
DoUpdates: clause.Assignments(map[string]any{
"status": RecordStatusProcessed,
"processed_at": gorm.Expr("VALUES(processed_at)"),
"operator_admin_id": gorm.Expr("VALUES(operator_admin_id)"),
"updated_at": gorm.Expr("CURRENT_TIMESTAMP(3)"),
}),
}
}
func parseProductImages(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
var arr []string
if err := json.Unmarshal([]byte(raw), &arr); err == nil {
result := make([]string, 0, len(arr))
for _, item := range arr {
item = strings.TrimSpace(item)
if item != "" {
result = append(result, item)
}
}
return result
}
return nil
}

View File

@ -1,124 +0,0 @@
package prize_grant_activity
import "time"
const (
RewardTypeProduct = "product"
RewardTypeItemCard = "item_card"
RewardTypeCoupon = "coupon"
RecordStatusClaimed = "claimed"
RecordStatusProcessed = "processed"
)
type Activity struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Reason string `gorm:"column:reason" json:"reason"`
Status string `gorm:"column:status" json:"status"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (*Activity) TableName() string { return "prize_grant_activities" }
type Reward struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
ActivityID int64 `gorm:"column:activity_id" json:"activity_id"`
RewardType string `gorm:"column:reward_type" json:"reward_type"`
RewardRefID int64 `gorm:"column:reward_ref_id" json:"reward_ref_id"`
QuantityPerClaim int32 `gorm:"column:quantity_per_claim" json:"quantity_per_claim"`
Sort int32 `gorm:"column:sort" json:"sort"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (*Reward) TableName() string { return "prize_grant_activity_rewards" }
type UserRecord struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
ActivityID int64 `gorm:"column:activity_id" json:"activity_id"`
UserID int64 `gorm:"column:user_id" json:"user_id"`
Status string `gorm:"column:status" json:"status"`
ClaimedAt *time.Time `gorm:"column:claimed_at" json:"claimed_at,omitempty"`
ProcessedAt *time.Time `gorm:"column:processed_at" json:"processed_at,omitempty"`
OperatorAdminID int64 `gorm:"column:operator_admin_id" json:"operator_admin_id"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
}
func (*UserRecord) TableName() string { return "prize_grant_activity_user_records" }
type SaveActivityRequest struct {
Reason string `json:"reason"`
Status string `json:"status"`
Rewards []RewardInput `json:"rewards"`
}
type RewardInput struct {
RewardType string `json:"reward_type"`
RewardRefID int64 `json:"reward_ref_id"`
QuantityPerClaim int32 `json:"quantity_per_claim"`
Sort int32 `json:"sort"`
}
type ListActivitiesRequest struct {
Reason string
Status string
Page int
PageSize int
}
type ActivityListItem struct {
Activity
RewardCount int64 `json:"reward_count"`
ClaimedCount int64 `json:"claimed_count"`
ProcessedCount int64 `json:"processed_count"`
CostCents int64 `json:"cost_cents"`
}
type CostSummary struct {
CostCents int64 `json:"cost_cents"`
Count int64 `json:"count"`
}
type ListActivitiesResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []ActivityListItem `json:"list"`
}
type RewardView struct {
RewardType string `json:"reward_type"`
RewardRefID int64 `json:"reward_ref_id"`
Name string `json:"name"`
Image string `json:"image"`
ValueCents int64 `json:"value_cents"`
Quantity int32 `json:"quantity"`
}
type ActivityDetail struct {
Activity
Rewards []RewardView `json:"rewards"`
ClaimedCount int64 `json:"claimed_count"`
ProcessedCount int64 `json:"processed_count"`
PendingCount int64 `json:"pending_count"`
CostCents int64 `json:"cost_cents"`
}
type PendingActivityResponse struct {
HasPending bool `json:"has_pending"`
Activity *PendingActivity `json:"activity,omitempty"`
}
type PendingActivity struct {
ID int64 `json:"id"`
Reason string `json:"reason"`
Rewards []RewardView `json:"rewards"`
}
type ClaimResponse struct {
ActivityID int64 `json:"activity_id"`
RecordID int64 `json:"record_id"`
Status string `json:"status"`
}

View File

@ -10,7 +10,6 @@ import (
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service interface {
@ -21,7 +20,6 @@ type Service interface {
DeleteRecipe(ctx context.Context, id int64) error
GetAvailableRecipesForUser(ctx context.Context, userID int64) ([]*UserRecipeView, error)
Synthesize(ctx context.Context, userID int64, recipeID int64) (*model.UserInventory, error)
BatchSynthesize(ctx context.Context, userID int64, recipeID int64) (*BatchSynthesizeResult, error)
ListLogs(ctx context.Context, page, size int, userID *int64) (list []*SynthesisLogView, total int64, err error)
}
@ -53,22 +51,12 @@ type UserMaterialView struct {
}
type UserRecipeView struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
TargetProduct *model.Products `json:"target_product"`
CanSynthesize bool `json:"can_synthesize"`
MaxSynthesizeCount int64 `json:"max_synthesize_count"`
Materials []UserMaterialView `json:"materials"`
}
type BatchSynthesizeResult struct {
RecipeID int64 `json:"recipe_id"`
TargetProductID int64 `json:"target_product_id"`
TargetProductName string `json:"target_product_name"`
SynthesizedCount int64 `json:"synthesized_count"`
ProducedInventoryIDs []int64 `json:"produced_inventory_ids"`
ConsumedInventoryCount int `json:"consumed_inventory_count"`
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
TargetProduct *model.Products `json:"target_product"`
CanSynthesize bool `json:"can_synthesize"`
Materials []UserMaterialView `json:"materials"`
}
type SynthesisLogView struct {
@ -253,11 +241,10 @@ func (s *service) GetAvailableRecipesForUser(ctx context.Context, userID int64)
Name: r.Name,
Description: r.Description,
TargetProduct: &targetProduct,
CanSynthesize: true,
Materials: make([]UserMaterialView, 0, len(materials)),
}
maxSynthesizeCount := int64(0)
initialized := false
for _, m := range materials {
var p model.Products
db.WithContext(ctx).Where("id = ?", m.FragmentProductID).First(&p)
@ -267,15 +254,9 @@ func (s *service) GetAvailableRecipesForUser(ctx context.Context, userID int64)
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
Count(&ownedCount)
currentCount := int64(0)
if m.RequiredCount > 0 {
currentCount = ownedCount / int64(m.RequiredCount)
if ownedCount < int64(m.RequiredCount) {
view.CanSynthesize = false
}
if !initialized || currentCount < maxSynthesizeCount {
maxSynthesizeCount = currentCount
initialized = true
}
image := ""
if p.ImagesJSON != "" {
var imgs []string
@ -291,34 +272,12 @@ func (s *service) GetAvailableRecipesForUser(ctx context.Context, userID int64)
OwnedCount: ownedCount,
})
}
view.MaxSynthesizeCount = maxSynthesizeCount
view.CanSynthesize = maxSynthesizeCount > 0
result = append(result, view)
}
return result, nil
}
func (s *service) Synthesize(ctx context.Context, userID int64, recipeID int64) (*model.UserInventory, error) {
result, err := s.batchSynthesize(ctx, userID, recipeID, 1)
if err != nil {
return nil, err
}
if len(result.ProducedInventoryIDs) == 0 {
return nil, fmt.Errorf("synthesis_failed")
}
var newInv model.UserInventory
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ?", result.ProducedInventoryIDs[0]).First(&newInv).Error; err != nil {
return nil, err
}
return &newInv, nil
}
func (s *service) BatchSynthesize(ctx context.Context, userID int64, recipeID int64) (*BatchSynthesizeResult, error) {
return s.batchSynthesize(ctx, userID, recipeID, 0)
}
func (s *service) batchSynthesize(ctx context.Context, userID int64, recipeID int64, limitTimes int64) (*BatchSynthesizeResult, error) {
db := s.repo.GetDbR()
var recipe model.FragmentSynthesisRecipes
@ -343,42 +302,16 @@ func (s *service) batchSynthesize(ctx context.Context, userID int64, recipeID in
InventoryIDs []int64
}
toConsume := make([]materialConsume, 0, len(materials))
maxTimes := int64(0)
initialized := false
for _, m := range materials {
var ownedCount int64
db.WithContext(ctx).Model(&model.UserInventory{}).
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
Count(&ownedCount)
currentTimes := int64(0)
if m.RequiredCount > 0 {
currentTimes = ownedCount / int64(m.RequiredCount)
}
if !initialized || currentTimes < maxTimes {
maxTimes = currentTimes
initialized = true
}
}
if limitTimes > 0 && maxTimes > limitTimes {
maxTimes = limitTimes
}
if maxTimes <= 0 {
return nil, fmt.Errorf("insufficient_fragments")
}
for _, m := range materials {
requiredTotal := int(m.RequiredCount) * int(maxTimes)
var invList []*model.UserInventory
db.WithContext(ctx).
Where("user_id = ? AND product_id = ? AND status = 1", userID, m.FragmentProductID).
Order("id ASC").
Limit(requiredTotal).
Limit(int(m.RequiredCount)).
Find(&invList)
if len(invList) < requiredTotal {
if int32(len(invList)) < m.RequiredCount {
return nil, fmt.Errorf("insufficient_fragments")
}
ids := make([]int64, len(invList))
@ -392,84 +325,52 @@ func (s *service) batchSynthesize(ctx context.Context, userID int64, recipeID in
})
}
result := &BatchSynthesizeResult{
RecipeID: recipeID,
TargetProductID: recipe.TargetProductID,
TargetProductName: targetProduct.Name,
SynthesizedCount: maxTimes,
}
var newInv model.UserInventory
wdb := s.repo.GetDbW()
err := wdb.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
consumedByRound := make([][]int64, int(maxTimes))
allConsumedCount := 0
allConsumedIDs := make([]int64, 0)
for _, mc := range toConsume {
var locked []model.UserInventory
query := tx.WithContext(ctx).Where("id IN ? AND user_id = ? AND status = 1", mc.InventoryIDs, userID)
if tx.Dialector.Name() != "sqlite" {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
if err := query.Find(&locked).Error; err != nil {
if err := tx.Raw("SELECT * FROM user_inventory WHERE id IN ? AND user_id = ? AND status = 1 FOR UPDATE", mc.InventoryIDs, userID).Scan(&locked).Error; err != nil {
return err
}
if len(locked) < len(mc.InventoryIDs) {
if int32(len(locked)) < mc.Required {
return fmt.Errorf("insufficient_fragments")
}
updates := map[string]interface{}{
"status": 2,
"updated_at": time.Now(),
}
if tx.Dialector.Name() == "sqlite" {
updates["remark"] = gorm.Expr("COALESCE(remark, '') || ?", fmt.Sprintf("|synthesis_consumed:recipe_%d", recipeID))
} else {
updates["remark"] = gorm.Expr("CONCAT(IFNULL(remark,''), ?)", fmt.Sprintf("|synthesis_consumed:recipe_%d", recipeID))
}
if err := tx.Model(&model.UserInventory{}).
Where("id IN ? AND user_id = ? AND status = 1", mc.InventoryIDs, userID).
Updates(updates).Error; err != nil {
if err := tx.Exec(
"UPDATE user_inventory SET status = 2, updated_at = NOW(3), remark = CONCAT(IFNULL(remark,''), '|synthesis_consumed:recipe_', ?) WHERE id IN ? AND user_id = ? AND status = 1",
recipeID, mc.InventoryIDs, userID,
).Error; err != nil {
return err
}
allConsumedCount += len(mc.InventoryIDs)
for round := int64(0); round < maxTimes; round++ {
start := int(round) * int(mc.Required)
end := start + int(mc.Required)
consumedByRound[round] = append(consumedByRound[round], mc.InventoryIDs[start:end]...)
}
allConsumedIDs = append(allConsumedIDs, mc.InventoryIDs...)
}
result.ConsumedInventoryCount = allConsumedCount
result.ProducedInventoryIDs = make([]int64, 0, int(maxTimes))
for round := int64(0); round < maxTimes; round++ {
newInv := model.UserInventory{
UserID: userID,
ProductID: recipe.TargetProductID,
ValueCents: targetProduct.Price,
Status: 1,
Remark: fmt.Sprintf("batch_synthesis_produced:recipe_%d:round_%d", recipeID, round+1),
}
if err := tx.Omit("ValueSnapshotAt", "ShippingNo").Create(&newInv).Error; err != nil {
return err
}
result.ProducedInventoryIDs = append(result.ProducedInventoryIDs, newInv.ID)
consumedJSON, _ := json.Marshal(consumedByRound[round])
log := &model.FragmentSynthesisLogs{
UserID: userID,
RecipeID: recipeID,
ConsumedInventoryIDs: string(consumedJSON),
ProducedInventoryID: newInv.ID,
}
if err := tx.Create(log).Error; err != nil {
return err
}
newInv = model.UserInventory{
UserID: userID,
ProductID: recipe.TargetProductID,
ValueCents: targetProduct.Price,
Status: 1,
Remark: fmt.Sprintf("synthesis_produced:recipe_%d", recipeID),
}
return nil
if err := tx.Omit("ValueSnapshotAt", "ShippingNo").Create(&newInv).Error; err != nil {
return err
}
consumedJSON, _ := json.Marshal(allConsumedIDs)
log := &model.FragmentSynthesisLogs{
UserID: userID,
RecipeID: recipeID,
ConsumedInventoryIDs: string(consumedJSON),
ProducedInventoryID: newInv.ID,
}
return tx.Create(log).Error
})
if err != nil {
return nil, err
}
return result, nil
return &newInv, nil
}
func (s *service) ListLogs(ctx context.Context, page, size int, userID *int64) ([]*SynthesisLogView, int64, error) {

View File

@ -5,7 +5,6 @@ import (
"testing"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
@ -19,69 +18,24 @@ func newSynthesisServiceForTest(t *testing.T) *service {
t.Fatalf("open sqlite failed: %v", err)
}
statements := []string{
`CREATE TABLE product_categories (
if err := db.Exec(`
CREATE TABLE product_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
is_fragment INTEGER NOT NULL DEFAULT 0,
deleted_at DATETIME NULL
);`,
`CREATE TABLE products (
);
`).Error; err != nil {
t.Fatalf("create product_categories failed: %v", err)
}
if err := db.Exec(`
CREATE TABLE products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER NOT NULL DEFAULT 0,
name TEXT NOT NULL DEFAULT '',
price INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
images_json TEXT NOT NULL DEFAULT '',
deleted_at DATETIME NULL
);`,
`CREATE TABLE fragment_synthesis_recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
target_product_id INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
);`,
`CREATE TABLE fragment_synthesis_recipe_materials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
recipe_id INTEGER NOT NULL DEFAULT 0,
fragment_product_id INTEGER NOT NULL DEFAULT 0,
required_count INTEGER NOT NULL DEFAULT 0,
created_at DATETIME NULL,
updated_at DATETIME NULL,
deleted_at DATETIME NULL
);`,
`CREATE TABLE fragment_synthesis_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NULL,
user_id INTEGER NOT NULL DEFAULT 0,
recipe_id INTEGER NOT NULL DEFAULT 0,
consumed_inventory_ids TEXT NOT NULL DEFAULT '',
produced_inventory_id INTEGER NOT NULL DEFAULT 0
);`,
`CREATE TABLE user_inventory (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_at DATETIME NULL,
updated_at DATETIME NULL,
user_id INTEGER NOT NULL DEFAULT 0,
product_id INTEGER NOT NULL DEFAULT 0,
value_cents INTEGER NOT NULL DEFAULT 0,
value_source INTEGER NOT NULL DEFAULT 0,
value_snapshot_at DATETIME NULL,
order_id INTEGER NOT NULL DEFAULT 0,
activity_id INTEGER NOT NULL DEFAULT 0,
reward_id INTEGER NOT NULL DEFAULT 0,
status INTEGER NOT NULL DEFAULT 1,
shipping_no TEXT NOT NULL DEFAULT '',
remark TEXT NOT NULL DEFAULT ''
);`,
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("exec schema failed: %v", err)
}
);
`).Error; err != nil {
t.Fatalf("create products failed: %v", err)
}
return New(mysql.NewTestRepo(db)).(*service)
@ -139,111 +93,3 @@ func TestValidateRecipeProducts_ValidCombination(t *testing.T) {
}
}
func TestBatchSynthesizeProducesAllPossibleItems(t *testing.T) {
svc := newSynthesisServiceForTest(t)
ctx := context.Background()
seedBatchSynthesisFixture(t, svc)
result, err := svc.BatchSynthesize(ctx, 1001, 1)
if err != nil {
t.Fatalf("batch synthesize failed: %v", err)
}
if result.SynthesizedCount != 3 {
t.Fatalf("expected 3 syntheses, got %d", result.SynthesizedCount)
}
if len(result.ProducedInventoryIDs) != 3 {
t.Fatalf("expected 3 produced ids, got %d", len(result.ProducedInventoryIDs))
}
if result.ConsumedInventoryCount != 9 {
t.Fatalf("expected 9 consumed inventory items, got %d", result.ConsumedInventoryCount)
}
assertInventoryStatusCount(t, svc, 1001, 11, 2, 6)
assertInventoryStatusCount(t, svc, 1001, 12, 2, 3)
assertInventoryStatusCount(t, svc, 1001, 10, 1, 3)
assertSynthesisLogCount(t, svc, 1001, 1, 3)
}
func TestBatchSynthesizeUsesShortestMaterial(t *testing.T) {
svc := newSynthesisServiceForTest(t)
ctx := context.Background()
seedBatchSynthesisFixture(t, svc)
if err := svc.repo.GetDbW().Exec("INSERT INTO user_inventory(user_id, product_id, value_cents, status, remark) VALUES (1001, 12, 0, 1, 'extra_fragment')").Error; err != nil {
t.Fatalf("seed extra fragment failed: %v", err)
}
result, err := svc.BatchSynthesize(ctx, 1001, 1)
if err != nil {
t.Fatalf("batch synthesize failed: %v", err)
}
if result.SynthesizedCount != 3 {
t.Fatalf("expected shortest material to cap at 3, got %d", result.SynthesizedCount)
}
assertInventoryStatusCount(t, svc, 1001, 12, 1, 1)
}
func TestBatchSynthesizeFailsWhenInsufficient(t *testing.T) {
svc := newSynthesisServiceForTest(t)
ctx := context.Background()
seedBatchSynthesisFixture(t, svc)
if err := svc.repo.GetDbW().Exec("DELETE FROM user_inventory WHERE user_id = ? AND product_id = ?", 1001, 12).Error; err != nil {
t.Fatalf("clear fragments failed: %v", err)
}
_, err := svc.BatchSynthesize(ctx, 1001, 1)
if err == nil || err.Error() != "insufficient_fragments" {
t.Fatalf("expected insufficient_fragments, got %v", err)
}
}
func seedBatchSynthesisFixture(t *testing.T, svc *service) {
t.Helper()
db := svc.repo.GetDbW()
statements := []string{
"INSERT INTO product_categories(id, is_fragment) VALUES (1, 1), (2, 0)",
"INSERT INTO products(id, category_id, name, price, status) VALUES (10, 2, '目标商品', 1999, 1), (11, 1, '碎片A', 0, 1), (12, 1, '碎片B', 0, 1)",
"INSERT INTO fragment_synthesis_recipes(id, name, description, target_product_id, status) VALUES (1, '配方1', '测试配方', 10, 1)",
"INSERT INTO fragment_synthesis_recipe_materials(id, recipe_id, fragment_product_id, required_count) VALUES (1, 1, 11, 2), (2, 1, 12, 1)",
}
for _, stmt := range statements {
if err := db.Exec(stmt).Error; err != nil {
t.Fatalf("seed fixture failed: %v", err)
}
}
for i := 0; i < 6; i++ {
if err := db.Create(&model.UserInventory{UserID: 1001, ProductID: 11, ValueCents: 0, Status: 1, Remark: "fragment_a"}).Error; err != nil {
t.Fatalf("seed fragment a failed: %v", err)
}
}
for i := 0; i < 3; i++ {
if err := db.Create(&model.UserInventory{UserID: 1001, ProductID: 12, ValueCents: 0, Status: 1, Remark: "fragment_b"}).Error; err != nil {
t.Fatalf("seed fragment b failed: %v", err)
}
}
}
func assertInventoryStatusCount(t *testing.T, svc *service, userID, productID int64, status int32, want int64) {
t.Helper()
var count int64
if err := svc.repo.GetDbR().Model(&model.UserInventory{}).Where("user_id = ? AND product_id = ? AND status = ?", userID, productID, status).Count(&count).Error; err != nil {
t.Fatalf("count inventory failed: %v", err)
}
if count != want {
t.Fatalf("expected %d inventory rows for product %d status %d, got %d", want, productID, status, count)
}
}
func assertSynthesisLogCount(t *testing.T, svc *service, userID, recipeID int64, want int64) {
t.Helper()
var count int64
if err := svc.repo.GetDbR().Model(&model.FragmentSynthesisLogs{}).Where("user_id = ? AND recipe_id = ?", userID, recipeID).Count(&count).Error; err != nil {
t.Fatalf("count logs failed: %v", err)
}
if count != want {
t.Fatalf("expected %d synthesis logs, got %d", want, count)
}
}

View File

@ -23,7 +23,6 @@ func TestInviteLogicSymmetry(t *testing.T) {
status INTEGER NOT NULL DEFAULT 1,
source_type INTEGER NOT NULL DEFAULT 0,
total_amount INTEGER NOT NULL DEFAULT 0,
actual_amount INTEGER NOT NULL DEFAULT 0,
remark TEXT,
deleted_at DATETIME
);`)
@ -50,7 +49,7 @@ func TestInviteLogicSymmetry(t *testing.T) {
// 只有 101 在活动 77 中下过单并开奖
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (1, 77)")
db.Exec("INSERT INTO activities (id, price_draw) VALUES (77, 100)")
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, actual_amount, source_type) VALUES (10, 101, 2, 100, 100, 0)")
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type) VALUES (10, 101, 2, 100, 0)")
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (10, 1)")
// === 场景 1全局任务 (ActivityID = 0) ===

View File

@ -186,11 +186,11 @@ type TaskRewardItem struct {
}
type orderMetricRow struct {
OrderID int64
ActivityID int64
DrawCount int64
TicketPrice int64
ActualAmount int64
OrderID int64
ActivityID int64
DrawCount int64
TicketPrice int64
TotalAmount int64
}
var allowedWindows = map[string]struct{}{
@ -226,37 +226,23 @@ func tierFingerprint(metric string, threshold int64, activityID int64, window st
return fmt.Sprintf("%s-%d-%d-%s", metric, threshold, activityID, window)
}
func paidTimeExpr() string {
return "COALESCE(NULLIF(orders.paid_at, '1970-01-01 00:00:00'), orders.created_at)"
}
func (s *service) fetchOrderMetricRows(ctx context.Context, userID int64, activityIDs []int64, start, end *time.Time) ([]orderMetricRow, error) {
query := s.repo.GetDbR().WithContext(ctx).Table(model.TableNameOrders).
Select("orders.id AS order_id, 0 AS activity_id, 0 AS draw_count, 0 AS ticket_price, orders.actual_amount").
Where("orders.user_id = ? AND orders.status = 2", userID)
if start != nil {
query = query.Where(paidTimeExpr()+" >= ?", *start)
}
if end != nil {
query = query.Where(paidTimeExpr()+" <= ?", *end)
}
Select("orders.id AS order_id, activity_issues.activity_id AS activity_id, COUNT(activity_draw_logs.id) AS draw_count, COALESCE(activities.price_draw, 0) AS ticket_price, orders.total_amount").
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id").
Where("orders.user_id = ? AND orders.status = 2 AND orders.source_type != 1", userID).
Group("orders.id, activity_issues.activity_id, activities.price_draw, orders.total_amount")
if len(activityIDs) > 0 {
query = s.repo.GetDbR().WithContext(ctx).Table(model.TableNameOrders).
Select("orders.id AS order_id, activity_issues.activity_id AS activity_id, COUNT(activity_draw_logs.id) AS draw_count, COALESCE(activities.price_draw, 0) AS ticket_price, orders.actual_amount").
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id").
Where("orders.user_id = ? AND orders.status = 2 AND orders.source_type != 1", userID).
Where("activity_issues.activity_id IN ?", activityIDs).
Group("orders.id, activity_issues.activity_id, activities.price_draw, orders.actual_amount")
if start != nil {
query = query.Where(paidTimeExpr()+" >= ?", *start)
}
if end != nil {
query = query.Where(paidTimeExpr()+" <= ?", *end)
}
query = query.Where("activity_issues.activity_id IN ?", activityIDs)
}
if start != nil {
query = query.Where("orders.created_at >= ?", *start)
}
if end != nil {
query = query.Where("orders.created_at <= ?", *end)
}
var rows []orderMetricRow
@ -267,7 +253,18 @@ func (s *service) fetchOrderMetricRows(ctx context.Context, userID int64, activi
}
func (s *service) calculateEffectiveAmount(row orderMetricRow) int64 {
return row.ActualAmount
if row.TicketPrice > 0 && row.DrawCount > 0 {
return row.TicketPrice * row.DrawCount
}
if row.TotalAmount > 0 {
if s.logger != nil && row.TicketPrice == 0 {
s.logger.Warn("task center: missing ticket price snapshot, fallback to order amount",
zap.Int64("order_id", row.OrderID),
zap.Int64("activity_id", row.ActivityID))
}
return row.TotalAmount
}
return 0
}
func (s *service) aggregateOrderMetrics(rows []orderMetricRow, perActivity bool) (count int64, amount int64) {

View File

@ -25,7 +25,6 @@ func ensureExtraTablesForServiceTest(t *testing.T, db *gorm.DB) {
total_amount INTEGER NOT NULL DEFAULT 0,
actual_amount INTEGER NOT NULL DEFAULT 0,
remark TEXT,
paid_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
@ -131,17 +130,17 @@ func TestGetUserProgress_TimeWindow_Integration(t *testing.T) {
// 插入三笔订单与邀请,处于不同时间段
o1Time := now.Format(time.DateTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (101, ?, 2, 0, 100, 100, ?)", userID, o1Time)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (101, ?, 2, 0, 100, ?)", userID, o1Time)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (101, 1)")
db.Exec("INSERT INTO user_invites (inviter_id, invitee_id, created_at) VALUES (?, 901, ?)", userID, o1Time)
o2Time := now.AddDate(0, -2, 0).Format(time.DateTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (102, ?, 2, 0, 100, 100, ?)", userID, o2Time)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (102, ?, 2, 0, 100, ?)", userID, o2Time)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (102, 1)")
db.Exec("INSERT INTO user_invites (inviter_id, invitee_id, created_at) VALUES (?, 902, ?)", userID, o2Time)
o3Time := now.AddDate(-1, 0, 0).Format(time.DateTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (103, ?, 2, 0, 100, 100, ?)", userID, o3Time)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (103, ?, 2, 0, 100, ?)", userID, o3Time)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (103, 1)")
db.Exec("INSERT INTO user_invites (inviter_id, invitee_id, created_at) VALUES (?, 903, ?)", userID, o3Time)
@ -261,7 +260,7 @@ func TestUpsertTaskRewards_AllowsMultipleRewardsSameType(t *testing.T) {
}
}
func TestGetUserProgress_UsesActualAmount(t *testing.T) {
func TestGetUserProgress_UsesEffectiveAmount(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatalf("创建 repo 失败: %v", err)
@ -314,13 +313,13 @@ func TestGetUserProgress_UsesActualAmount(t *testing.T) {
now := time.Now()
inside := now.Format(time.DateTime)
// 次卡订单:actual_amount=0按纯实付口径不计入消费金额
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (401, ?, 2, 0, 0, 0, ?)", userID, inside)
// 次卡订单:total_amount=0但 price_draw>0, draw_count=2
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (401, ?, 2, 0, 0, ?)", userID, inside)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (401, 301)")
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (401, 301)")
// 现金订单:按 actual_amount 统计
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (402, ?, 2, 0, 1500, 1500, ?)", userID, inside)
// 现金订单:price_draw=0需回退 total_amount
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (402, ?, 2, 0, 1500, ?)", userID, inside)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (402, 302)")
progress, err := svc.GetUserProgress(context.Background(), userID, task.ID)
@ -328,8 +327,8 @@ func TestGetUserProgress_UsesActualAmount(t *testing.T) {
t.Fatalf("获取进度失败: %v", err)
}
if progress.OrderAmount != 1500 {
t.Fatalf("订单金额统计错误,期望 1500 实际 %d", progress.OrderAmount)
if progress.OrderAmount != 3500 {
t.Fatalf("订单金额统计错误,期望 3500 实际 %d", progress.OrderAmount)
}
if progress.OrderCount != 2 {
t.Fatalf("订单数量统计错误,期望 2 实际 %d", progress.OrderCount)
@ -338,82 +337,14 @@ func TestGetUserProgress_UsesActualAmount(t *testing.T) {
if !ok {
t.Fatalf("未找到档位进度")
}
if tierProgress.OrderAmount != 0 {
t.Fatalf("档位金额错误,期望 0 实际 %d", tierProgress.OrderAmount)
if tierProgress.OrderAmount != 2000 {
t.Fatalf("档位金额错误,期望 2000 实际 %d", tierProgress.OrderAmount)
}
if tierProgress.OrderCount != 1 {
t.Fatalf("档位订单数错误,期望 1 实际 %d", tierProgress.OrderCount)
}
}
func TestOrderAmount_GlobalTaskUsesAllPaidOrders(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
t.Fatalf("创建 repo 失败: %v", err)
}
db := repo.GetDbW()
initTestTables(t, db)
ensureExtraTablesForServiceTest(t, db)
svc := New(nil, repo, nil, nil, nil)
now := time.Now()
taskStart := now.Add(-24 * time.Hour)
taskEnd := now.Add(24 * time.Hour)
task := &tcmodel.Task{
Name: "全局消费任务",
Status: 1,
Visibility: 1,
StartTime: &taskStart,
EndTime: &taskEnd,
}
if err := db.Create(task).Error; err != nil {
t.Fatalf("创建任务失败: %v", err)
}
tier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: MetricOrderAmount,
Operator: OperatorGTE,
Threshold: 1000,
Window: WindowLifetime,
ActivityID: 0,
}
if err := db.Create(tier).Error; err != nil {
t.Fatalf("创建档位失败: %v", err)
}
userID := int64(8888)
inside := now.Format(time.DateTime)
// 普通实付订单:以前不会进 activity_draw_logs 链路,现在全局任务应计入
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (801, ?, 2, 4, 4550, 4550, ?)", userID, inside)
// 抽奖订单:也应计入
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (802, ?, 2, 2, 2000, 2000, ?)", userID, inside)
db.Exec("INSERT INTO activities (id, price_draw) VALUES (901, 500)")
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (902, 901)")
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (802, 902)")
// 零实付订单金额应为0但订单数仍按现有逻辑计入已支付订单
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (803, ?, 2, 4, 1300, 0, ?)", userID, inside)
progress, err := svc.GetUserProgress(context.Background(), userID, task.ID)
if err != nil {
t.Fatalf("获取进度失败: %v", err)
}
if progress.OrderAmount != 6550 {
t.Fatalf("全局任务金额统计错误,期望 6550 实际 %d", progress.OrderAmount)
}
if progress.OrderCount != 3 {
t.Fatalf("全局任务订单数错误,期望 3 实际 %d", progress.OrderCount)
}
tierProgress := progress.TierProgressMap[tier.ID]
if tierProgress.OrderAmount != 6550 {
t.Fatalf("全局任务档位金额错误,期望 6550 实际 %d", tierProgress.OrderAmount)
}
}
func TestTimeWindow_ActivityPeriod(t *testing.T) {
repo, err := mysql.NewSQLiteRepoForTest()
if err != nil {
@ -457,10 +388,10 @@ func TestTimeWindow_ActivityPeriod(t *testing.T) {
inside := start.Add(24 * time.Hour).Format(time.DateTime)
outside := end.Add(24 * time.Hour).Format(time.DateTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (701, ?, 2, 0, 0, 0, ?)", userID, inside)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (701, ?, 2, 0, 0, ?)", userID, inside)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (701, 601)")
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (702, ?, 2, 0, 0, 0, ?)", userID, outside)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (702, ?, 2, 0, 0, ?)", userID, outside)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (702, 601)")
progress, err := svc.GetUserProgress(context.Background(), userID, task.ID)
@ -638,14 +569,14 @@ func TestLifetimeWindow_RespectsTaskStartTime(t *testing.T) {
// 插入历史订单(任务开始之前)
historicalOrder := taskStart.Add(-10 * 24 * time.Hour).Format(time.DateTime)
for i := int64(101); i <= 105; i++ {
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (?, ?, 2, 0, 100, 100, ?)", i, userID, historicalOrder)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (?, ?, 2, 0, 100, ?)", i, userID, historicalOrder)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (?, 1)", i)
}
// 插入新订单(任务开始之后)
recentOrder := now.Add(-1 * 24 * time.Hour).Format(time.DateTime)
for i := int64(201); i <= 202; i++ {
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (?, ?, 2, 0, 100, 100, ?)", i, userID, recentOrder)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (?, ?, 2, 0, 100, ?)", i, userID, recentOrder)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (?, 1)", i)
}
@ -719,12 +650,12 @@ func TestEmptyWindow_RespectsTaskStartTime(t *testing.T) {
// 历史订单(任务开始前)
oldTime := taskStart.Add(-24 * time.Hour).Format(time.DateTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (301, ?, 2, 0, 100, 100, ?)", userID, oldTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (301, ?, 2, 0, 100, ?)", userID, oldTime)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (301, 1)")
// 新订单(任务开始后)
newTime := now.Add(-1 * time.Hour).Format(time.DateTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, actual_amount, created_at) VALUES (302, ?, 2, 0, 100, 100, ?)", userID, newTime)
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (302, ?, 2, 0, 100, ?)", userID, newTime)
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (302, 1)")
progress, err := svc.GetUserProgress(context.Background(), userID, task.ID)

View File

@ -378,12 +378,12 @@ func TestOrderCountMetric(t *testing.T) {
t.Logf("订单数量指标测试通过: 当前订单数=%d", p.OrderCount)
}
// TestOrderAmountMetric 测试实付金额指标
// TestOrderAmountMetric 测试消费金额指标
func TestOrderAmountMetric(t *testing.T) {
db := CreateTestDB(t)
combo := TaskCombination{
Name: "实付金额测试任务",
Name: "消费金额测试任务",
Metric: MetricOrderAmount,
Operator: OperatorGTE,
Threshold: 10000, // 100元
@ -411,10 +411,10 @@ func TestOrderAmountMetric(t *testing.T) {
var p tcmodel.UserTaskProgress
db.Where("user_id = ? AND task_id = ?", userID, taskID).First(&p)
if p.OrderAmount < 10000 {
t.Error("实付金额未达到阈值")
t.Error("消费金额未达到阈值")
}
t.Logf("实付金额指标测试通过: 当前实付=%d分", p.OrderAmount)
t.Logf("消费金额指标测试通过: 当前消费=%d分", p.OrderAmount)
}
// TestInviteCountMetric 测试邀请人数指标
@ -682,7 +682,6 @@ func TestGetUserProgress_ActivityFilter_Integration(t *testing.T) {
total_amount INTEGER NOT NULL DEFAULT 0,
actual_amount INTEGER NOT NULL DEFAULT 0,
remark TEXT,
paid_at DATETIME,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted_at DATETIME
@ -748,25 +747,25 @@ func TestGetUserProgress_ActivityFilter_Integration(t *testing.T) {
db.Exec("INSERT INTO activity_issues (id, activity_id) VALUES (20, 200)")
// 订单 A: 匹配活动 100
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, actual_amount, source_type, remark) VALUES (1, ?, 2, 100, 100, 0, ?)", userID, "activity:100|count:1")
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (1, ?, 2, 100, 0, ?)", userID, "activity:100|count:1")
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (1, 10)")
// 订单 B: 匹配活动 200 (不应被统计,因为任务关联的是 100)
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, actual_amount, source_type, remark) VALUES (2, ?, 2, 200, 200, 0, ?)", userID, "activity:200|count:1")
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (2, ?, 2, 200, 0, ?)", userID, "activity:200|count:1")
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (2, 20)")
// 订单 C: 普通订单 (不应被统计,因为没有关联活动 100)
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, actual_amount, source_type, remark) VALUES (3, ?, 2, 300, 300, 0, ?)", userID, "normal_order")
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (3, ?, 2, 300, 0, ?)", userID, "normal_order")
// 订单 D: 匹配活动 100 但未支付 (不应被统计)
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, actual_amount, source_type, remark) VALUES (4, ?, 1, 100, 100, 0, ?)", userID, "activity:100|count:1")
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (4, ?, 1, 100, 0, ?)", userID, "activity:100|count:1")
// 3. 插入邀请记录
db.Exec("INSERT INTO user_invites (inviter_id, invitee_id) VALUES (?, 1001)", userID)
db.Exec("INSERT INTO user_invites (inviter_id, invitee_id) VALUES (?, 1002)", userID)
// 4. 让其中一个被邀请人(1001)在活动 100 中产生有效订单(使其成为"有效邀请”)
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, actual_amount, source_type, remark) VALUES (10, 1001, 2, 50, 50, 0, ?)", "activity:100|count:1")
db.Exec("INSERT INTO orders (id, user_id, status, total_amount, source_type, remark) VALUES (10, 1001, 2, 50, 0, ?)", "activity:100|count:1")
db.Exec("INSERT INTO activity_draw_logs (order_id, issue_id) VALUES (10, 10)")
// 5. 调用 GetUserProgress

View File

@ -15,17 +15,6 @@ import (
"gorm.io/gorm"
)
const (
shippingFeeThreshold = 5
shippingFeeReasonBelowThreshold = "below_threshold"
shippingFeeReasonContainsNonFreeShipping = "contains_non_free_shipping_item"
)
var nonFreeShippingCategoryIDs = map[int64]struct{}{
14: {},
15: {},
}
type shareClaims struct {
OwnerUserID int64 `json:"owner_user_id"`
InventoryID int64 `json:"inventory_id"`
@ -330,63 +319,6 @@ func generateBatchNo(userID int64) string {
return fmt.Sprintf("B%d%d", userID, time.Now().UnixNano()/1000000)
}
func (s *service) CheckShippingFeeRequirement(ctx context.Context, userID int64, inventoryIDs []int64) (bool, string, error) {
uniqMap := make(map[int64]struct{}, len(inventoryIDs))
uniq := make([]int64, 0, len(inventoryIDs))
for _, id := range inventoryIDs {
if id <= 0 {
continue
}
if _, ok := uniqMap[id]; ok {
continue
}
uniqMap[id] = struct{}{}
uniq = append(uniq, id)
}
if len(uniq) == 0 {
return false, "", fmt.Errorf("invalid inventory_ids")
}
invList, err := s.readDB.UserInventory.WithContext(ctx).
Where(s.readDB.UserInventory.ID.In(uniq...)).
Find()
if err != nil {
return false, "", err
}
productIDSet := make(map[int64]struct{}, len(invList))
productIDs := make([]int64, 0, len(invList))
for _, inv := range invList {
if inv == nil || inv.UserID != userID || inv.ProductID <= 0 {
continue
}
if _, ok := productIDSet[inv.ProductID]; ok {
continue
}
productIDSet[inv.ProductID] = struct{}{}
productIDs = append(productIDs, inv.ProductID)
}
if len(productIDs) > 0 {
products, err := s.readDB.Products.WithContext(ctx).
Where(s.readDB.Products.ID.In(productIDs...)).
Find()
if err != nil {
return false, "", err
}
for _, product := range products {
if _, ok := nonFreeShippingCategoryIDs[product.CategoryID]; ok {
return true, shippingFeeReasonContainsNonFreeShipping, nil
}
}
}
if len(uniq) < shippingFeeThreshold {
return true, shippingFeeReasonBelowThreshold, nil
}
return false, "", nil
}
func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (addrID int64, batchNo string, success []int64, skipped []struct {
ID int64
Reason string

View File

@ -1,108 +0,0 @@
package user
import (
"context"
"errors"
"fmt"
"strings"
"time"
"bindbox-game/internal/pkg/env"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
)
type CreatePaidGamePassOrderForTestInput struct {
UserID int64
PackageID int64
Count int32
Operator string
}
type CreatePaidGamePassOrderForTestOutput struct {
OrderNo string
PackageName string
PackageID int64
Count int32
TotalAmount int64
PaidAt time.Time
UserID int64
ActivityID int64
PassesGranted int32
}
func (s *service) CreatePaidGamePassOrderForTest(ctx context.Context, input CreatePaidGamePassOrderForTestInput) (*CreatePaidGamePassOrderForTestOutput, error) {
if env.Active().IsPro() {
return nil, errors.New("正式环境禁止执行福利测试购买工具")
}
if input.UserID <= 0 {
return nil, errors.New("用户ID无效")
}
if input.PackageID <= 0 {
return nil, errors.New("套餐ID无效")
}
if input.Count <= 0 {
return nil, errors.New("购买次数必须大于0")
}
if input.Count > 999 {
return nil, errors.New("购买次数过大")
}
pkg, err := s.readDB.GamePassPackages.WithContext(ctx).
Where(s.readDB.GamePassPackages.ID.Eq(input.PackageID)).
Where(s.readDB.GamePassPackages.Status.Eq(1)).
First()
if err != nil || pkg == nil {
return nil, errors.New("套餐不存在或已下架")
}
totalPrice := pkg.Price * int64(input.Count)
now := time.Now()
orderNo := fmt.Sprintf("GPTEST%s%04d", now.Format("20060102150405"), now.UnixNano()%10000)
operator := strings.TrimSpace(input.Operator)
if operator == "" {
operator = "cli"
}
remark := fmt.Sprintf("game_pass_package:%s|pkg_id:%d|count:%d|test:welfare_cmd|operator:%s", pkg.Name, pkg.ID, input.Count, operator)
minValidTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
err = s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
order := &model.Orders{
UserID: input.UserID,
OrderNo: orderNo,
SourceType: 4,
TotalAmount: totalPrice,
DiscountAmount: 0,
PointsAmount: 0,
ActualAmount: totalPrice,
Status: 2,
PaidAt: now,
CancelledAt: minValidTime,
IsConsumed: 0,
Remark: remark,
ExtOrderID: "",
CreatedAt: now,
UpdatedAt: now,
}
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
return err
}
return s.GrantGamePass(ctx, input.UserID, pkg.ID, input.Count, orderNo)
})
if err != nil {
return nil, err
}
return &CreatePaidGamePassOrderForTestOutput{
OrderNo: orderNo,
PackageName: pkg.Name,
PackageID: pkg.ID,
Count: input.Count,
TotalAmount: totalPrice,
PaidAt: now,
UserID: input.UserID,
ActivityID: pkg.ActivityID,
PassesGranted: pkg.PassCount * input.Count,
}, nil
}

View File

@ -29,6 +29,7 @@ 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)
@ -39,6 +40,7 @@ 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)
@ -49,88 +51,17 @@ func TestRequestShippings_NoDefaultAddress(t *testing.T) {
db, mock := setupMockDBForShipping(t)
svc := newTestService(db)
mock.ExpectQuery("SELECT .* FROM `user_addresses`").
WillReturnRows(sqlmock.NewRows(nil))
// 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)
}
func TestCheckShippingFeeRequirement_BelowThreshold(t *testing.T) {
db, mock := setupMockDBForShipping(t)
svc := newTestService(db)
mock.ExpectQuery("SELECT .* FROM `user_inventory`").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "product_id"}).
AddRow(1, 99, 101).
AddRow(2, 99, 102).
AddRow(3, 99, 103).
AddRow(4, 99, 104))
mock.ExpectQuery("SELECT .* FROM `products`").
WillReturnRows(sqlmock.NewRows([]string{"id", "category_id"}).
AddRow(101, 1).
AddRow(102, 2).
AddRow(103, 3).
AddRow(104, 4))
needFee, reason, err := svc.CheckShippingFeeRequirement(context.Background(), 99, []int64{1, 2, 3, 4})
assert.NoError(t, err)
assert.True(t, needFee)
assert.Equal(t, shippingFeeReasonBelowThreshold, reason)
}
func TestCheckShippingFeeRequirement_FreeWhenThresholdReached(t *testing.T) {
db, mock := setupMockDBForShipping(t)
svc := newTestService(db)
mock.ExpectQuery("SELECT .* FROM `user_inventory`").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "product_id"}).
AddRow(1, 99, 101).
AddRow(2, 99, 102).
AddRow(3, 99, 103).
AddRow(4, 99, 104).
AddRow(5, 99, 105))
mock.ExpectQuery("SELECT .* FROM `products`").
WillReturnRows(sqlmock.NewRows([]string{"id", "category_id"}).
AddRow(101, 1).
AddRow(102, 2).
AddRow(103, 3).
AddRow(104, 4).
AddRow(105, 5))
needFee, reason, err := svc.CheckShippingFeeRequirement(context.Background(), 99, []int64{1, 2, 3, 4, 5})
assert.NoError(t, err)
assert.False(t, needFee)
assert.Equal(t, "", reason)
}
func TestCheckShippingFeeRequirement_NonFreeCategoryOverridesThreshold(t *testing.T) {
db, mock := setupMockDBForShipping(t)
svc := newTestService(db)
mock.ExpectQuery("SELECT .* FROM `user_inventory`").
WillReturnRows(sqlmock.NewRows([]string{"id", "user_id", "product_id"}).
AddRow(1, 99, 101).
AddRow(2, 99, 102).
AddRow(3, 99, 103).
AddRow(4, 99, 104).
AddRow(5, 99, 105).
AddRow(6, 99, 106))
mock.ExpectQuery("SELECT .* FROM `products`").
WillReturnRows(sqlmock.NewRows([]string{"id", "category_id"}).
AddRow(101, 1).
AddRow(102, 2).
AddRow(103, 3).
AddRow(104, 4).
AddRow(105, 14).
AddRow(106, 5))
needFee, reason, err := svc.CheckShippingFeeRequirement(context.Background(), 99, []int64{1, 2, 3, 4, 5, 6})
assert.NoError(t, err)
assert.True(t, needFee)
assert.Equal(t, shippingFeeReasonContainsNonFreeShipping, reason)
}

View File

@ -57,7 +57,6 @@ type Service interface {
SubmitAddressShare(ctx context.Context, shareToken string, name string, mobile string, province string, city string, district string, address string, submittedByUserID *int64, submittedIP *string) (int64, error)
RequestShipping(ctx context.Context, userID int64, inventoryID int64) (int64, error)
CancelShipping(ctx context.Context, userID int64, inventoryID int64, batchNo string) (int64, error)
CheckShippingFeeRequirement(ctx context.Context, userID int64, inventoryIDs []int64) (needFee bool, reason string, err error)
RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (addrID int64, batchNo string, success []int64, skipped []struct {
ID int64
Reason string
@ -81,7 +80,6 @@ type Service interface {
SendSmsCode(ctx context.Context, mobile string) error
LoginByCode(ctx context.Context, in SmsLoginInput) (*SmsLoginOutput, error)
GrantGamePass(ctx context.Context, userID int64, packageID int64, count int32, orderNo string) error
CreatePaidGamePassOrderForTest(ctx context.Context, input CreatePaidGamePassOrderForTestInput) (*CreatePaidGamePassOrderForTestOutput, error)
// 邀请人绑定
BindInviter(ctx context.Context, userID int64, in BindInviterInput) (*BindInviterOutput, error)
// 管理端强制绑定/修改/解绑邀请人

View File

@ -1,363 +0,0 @@
package welfare_activity
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm"
)
func (s *service) CreateActivity(ctx context.Context, req SaveActivityRequest) (*Activity, error) {
if err := validateActivity(req); err != nil {
return nil, err
}
item := &Activity{
Title: normalizeActivityTitle(req.Title),
Type: req.Type,
ThresholdAmount: req.ThresholdAmount,
StartTime: req.StartTime,
EndTime: req.EndTime,
DrawTime: req.DrawTime,
Status: normalizeStatus(req.Status),
Description: req.Description,
CoverImage: req.CoverImage,
}
err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(item).Error; err != nil {
return err
}
return s.replacePrizes(ctx, tx, item.ID, req.Prizes)
})
return item, err
}
func (s *service) UpdateActivity(ctx context.Context, id int64, req SaveActivityRequest) error {
if id <= 0 {
return errors.New("活动ID无效")
}
if err := validateActivity(req); err != nil {
return err
}
var existing Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&existing).Error; err != nil {
return err
}
return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
status := req.Status
if strings.TrimSpace(status) == "" {
status = existing.Status
}
updates := map[string]interface{}{
"title": normalizeActivityTitle(req.Title),
"type": req.Type,
"threshold_amount": req.ThresholdAmount,
"start_time": req.StartTime,
"end_time": req.EndTime,
"draw_time": req.DrawTime,
"status": normalizeStatus(status),
"description": req.Description,
"cover_image": req.CoverImage,
}
var current Activity
if err := tx.WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&current).Error; err != nil {
return err
}
if current.Status == StatusFinished {
return errors.New("已结束活动不可编辑")
}
if err := tx.Model(&Activity{}).Where("id = ? AND deleted_at IS NULL", id).Updates(updates).Error; err != nil {
return err
}
return s.replacePrizes(ctx, tx, id, req.Prizes)
})
}
func (s *service) CopyActivity(ctx context.Context, id int64, req SaveActivityRequest) (int64, error) {
var src Activity
db := s.repo.GetDbR().WithContext(ctx)
if err := db.Where("id = ? AND deleted_at IS NULL", id).First(&src).Error; err != nil {
return 0, err
}
var prizes []Prize
if err := db.Where("activity_id = ?", id).Order("sort ASC, id ASC").Find(&prizes).Error; err != nil {
return 0, err
}
copyReq := SaveActivityRequest{
Title: req.Title,
Type: req.Type,
ThresholdAmount: req.ThresholdAmount,
StartTime: req.StartTime,
EndTime: req.EndTime,
DrawTime: req.DrawTime,
Status: normalizeStatus(req.Status),
CoverImage: src.CoverImage,
}
for _, p := range prizes {
copyReq.Prizes = append(copyReq.Prizes, PrizeInput{
RewardType: p.RewardType,
RewardRefID: p.RewardRefID,
Quantity: p.Quantity,
Sort: p.Sort,
})
}
item, err := s.CreateActivity(ctx, copyReq)
if err != nil {
return 0, err
}
return item.ID, nil
}
func (s *service) ListActivities(ctx context.Context, req ListActivitiesRequest) (*ListActivitiesResponse, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
now := time.Now()
db := s.repo.GetDbR().WithContext(ctx).Model(&Activity{}).Where("deleted_at IS NULL")
if req.Type != "" {
db = db.Where("type = ?", req.Type)
}
if req.Status != "" {
db = db.Where("status = ?", req.Status)
if req.Status == StatusActive {
db = db.Where("start_time <= ? AND end_time >= ? AND draw_time >= ?", now, now, now)
}
}
if strings.TrimSpace(req.Title) != "" {
db = db.Where("title LIKE ?", "%"+strings.TrimSpace(req.Title)+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var rows []Activity
if err := db.Order("id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&rows).Error; err != nil {
return nil, err
}
list := make([]ActivityListItem, 0, len(rows))
for _, row := range rows {
item := ActivityListItem{Activity: row}
s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_participants").Where("activity_id = ?", row.ID).Count(&item.ParticipantCount)
s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners").Where("activity_id = ?", row.ID).Count(&item.WinnerCount)
s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners").Select("COALESCE(SUM(cost_cents),0)").Where("activity_id = ?", row.ID).Scan(&item.CostCents)
list = append(list, item)
}
return &ListActivitiesResponse{Page: req.Page, PageSize: req.PageSize, Total: total, List: list}, nil
}
func (s *service) GetActivity(ctx context.Context, id int64, userID int64) (*ActivityDetail, error) {
var item Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&item).Error; err != nil {
return nil, err
}
if userID <= 0 && item.Status == StatusActive && time.Now().Before(item.StartTime) {
return nil, errors.New("活动未开始")
}
return s.buildActivityDetail(ctx, item, userID)
}
func (s *service) GetActivityAdmin(ctx context.Context, id int64) (*ActivityDetail, error) {
var item Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", id).First(&item).Error; err != nil {
return nil, err
}
return s.buildActivityDetail(ctx, item, 0)
}
func (s *service) buildActivityDetail(ctx context.Context, item Activity, userID int64) (*ActivityDetail, error) {
detail := &ActivityDetail{Activity: item}
s.repo.GetDbR().WithContext(ctx).Where("activity_id = ?", item.ID).Order("sort ASC, id ASC").Find(&detail.Prizes)
s.fillPrizeMeta(ctx, detail.Prizes)
participants, _ := s.ListParticipants(ctx, item.ID, 1, 20)
if participants != nil {
detail.ParticipantCount = participants.Total
detail.Participants = participants.List
}
winners, _ := s.ListWinners(ctx, item.ID, 1, 20)
if winners != nil {
detail.Winners = winners.List
}
if userID > 0 {
start, end, period := periodRange(item.Type, time.Now())
detail.CurrentPaid, _ = s.sumPaidAmount(ctx, userID, start, end)
detail.CanJoin = isJoinWindowOpen(item, time.Now()) && detail.CurrentPaid >= item.ThresholdAmount
var count int64
s.repo.GetDbR().WithContext(ctx).Model(&Participant{}).Where("activity_id = ? AND user_id = ? AND period_key = ?", item.ID, userID, period).Count(&count)
detail.Joined = count > 0
}
return detail, nil
}
func (s *service) DeleteActivity(ctx context.Context, id int64) error {
if id <= 0 {
return errors.New("活动ID无效")
}
now := time.Now()
return s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).Where("id = ? AND deleted_at IS NULL", id).Update("deleted_at", now).Error
}
func (s *service) SavePrizes(ctx context.Context, activityID int64, prizes []PrizeInput) error {
return s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.replacePrizes(ctx, tx, activityID, prizes)
})
}
func (s *service) replacePrizes(ctx context.Context, tx *gorm.DB, activityID int64, inputs []PrizeInput) error {
if err := tx.WithContext(ctx).Where("activity_id = ?", activityID).Delete(&Prize{}).Error; err != nil {
return err
}
for _, input := range inputs {
prizeInput, err := normalizePrizeInput(input)
if err != nil {
return err
}
if prizeInput.Quantity <= 0 {
return errors.New("奖品数量不能为空")
}
prize, err := s.buildPrizeSnapshot(ctx, tx, activityID, prizeInput)
if err != nil {
return err
}
if err := tx.WithContext(ctx).Create(prize).Error; err != nil {
return err
}
}
return nil
}
func validateActivity(req SaveActivityRequest) error {
if strings.TrimSpace(req.Title) == "" {
return errors.New("活动标题不能为空")
}
if req.Type != TypeDaily && req.Type != TypeWeekly && req.Type != TypeMonthly {
return errors.New("活动类型无效")
}
if req.ThresholdAmount < 0 {
return errors.New("参与门槛不能小于0")
}
if req.StartTime.IsZero() || req.EndTime.IsZero() || req.DrawTime.IsZero() {
return errors.New("活动时间和开奖时间不能为空")
}
if !req.EndTime.After(req.StartTime) {
return errors.New("结束时间必须晚于开始时间")
}
if req.DrawTime.Before(req.StartTime) {
return errors.New("开奖时间不能早于开始时间")
}
return nil
}
func normalizeStatus(status string) string {
if status == "" {
return StatusActive
}
if status == StatusFinished {
return StatusFinished
}
return StatusActive
}
func firstProductImage(imagesJSON string) string {
imagesJSON = strings.TrimSpace(imagesJSON)
if imagesJSON == "" {
return ""
}
var images []string
if err := json.Unmarshal([]byte(imagesJSON), &images); err == nil && len(images) > 0 {
return strings.TrimSpace(images[0])
}
return ""
}
func normalizeActivityTitle(title string) string {
title = strings.TrimSpace(title)
if title == "" {
return time.Now().Format("2006-01-02")
}
return title
}
func normalizePrizeInput(input PrizeInput) (PrizeInput, error) {
if input.RewardType == "" && input.ProductID > 0 {
input.RewardType = RewardTypeProduct
input.RewardRefID = input.ProductID
}
if input.RewardType == "" || input.RewardRefID <= 0 {
return PrizeInput{}, errors.New("奖品类型和资源不能为空")
}
switch input.RewardType {
case RewardTypeProduct, RewardTypeItemCard, RewardTypeCoupon:
return input, nil
default:
return PrizeInput{}, errors.New("奖品类型无效")
}
}
func (s *service) buildPrizeSnapshot(ctx context.Context, tx *gorm.DB, activityID int64, input PrizeInput) (*Prize, error) {
prize := &Prize{
ActivityID: activityID,
RewardType: input.RewardType,
RewardRefID: input.RewardRefID,
Quantity: input.Quantity,
RemainingQuantity: input.Quantity,
Sort: input.Sort,
}
switch input.RewardType {
case RewardTypeProduct:
var product model.Products
if err := tx.WithContext(ctx).Where("id = ?", input.RewardRefID).First(&product).Error; err != nil {
return nil, fmt.Errorf("商品不存在: %d", input.RewardRefID)
}
prize.RewardNameSnapshot = product.Name
prize.RewardImageSnapshot = firstProductImage(product.ImagesJSON)
prize.RewardValueSnapshotCents = product.Price
prize.CostSnapshotCents = product.CostPrice
if prize.CostSnapshotCents <= 0 {
prize.CostSnapshotCents = product.Price
}
case RewardTypeItemCard:
var card model.SystemItemCards
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", input.RewardRefID).First(&card).Error; err != nil {
return nil, fmt.Errorf("道具卡不存在或未启用: %d", input.RewardRefID)
}
prize.RewardNameSnapshot = card.Name
prize.RewardImageSnapshot = ""
prize.RewardValueSnapshotCents = card.Price
prize.CostSnapshotCents = card.Price
case RewardTypeCoupon:
var coupon model.SystemCoupons
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", input.RewardRefID).First(&coupon).Error; err != nil {
return nil, fmt.Errorf("优惠券不存在或未启用: %d", input.RewardRefID)
}
prize.RewardNameSnapshot = coupon.Name
prize.RewardImageSnapshot = ""
prize.RewardValueSnapshotCents = coupon.DiscountValue
prize.CostSnapshotCents = coupon.DiscountValue
}
return prize, nil
}
func (s *service) fillPrizeMeta(ctx context.Context, prizes []Prize) {
for i := range prizes {
if prizes[i].RewardNameSnapshot != "" {
prizes[i].Name = prizes[i].RewardNameSnapshot
}
if prizes[i].RewardImageSnapshot != "" {
prizes[i].Image = prizes[i].RewardImageSnapshot
}
if prizes[i].RewardValueSnapshotCents > 0 {
prizes[i].PriceCents = prizes[i].RewardValueSnapshotCents
}
}
}

View File

@ -1,329 +0,0 @@
package welfare_activity
import (
"context"
crand "crypto/rand"
"encoding/binary"
"errors"
"fmt"
"math/rand"
"time"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
"go.uber.org/zap"
"gorm.io/gorm"
)
type prizeGrantResult struct {
RewardType string
RewardRefID int64
PrizeNameSnapshot string
PrizeImageSnapshot string
PrizeValueSnapshotCents int64
GrantRecordType string
GrantRecordID int64
CostCents int64
}
func (s *service) DrawDueActivities(ctx context.Context) error {
var ids []int64
if err := s.repo.GetDbR().WithContext(ctx).Model(&Activity{}).
Where("deleted_at IS NULL AND status = ? AND draw_time <= ?", StatusActive, time.Now()).
Pluck("id", &ids).Error; err != nil {
return err
}
for _, id := range ids {
if err := s.Draw(ctx, id); err != nil && s.logger != nil {
s.logger.Warn("welfare activity draw failed", zap.Int64("activity_id", id), zap.Error(err))
}
}
return nil
}
func (s *service) Draw(ctx context.Context, activityID int64) error {
if activityID <= 0 {
return errors.New("活动ID无效")
}
batch := fmt.Sprintf("WA%d-%d", activityID, time.Now().UnixNano())
updated := s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).
Where("id = ? AND deleted_at IS NULL AND status = ?", activityID, StatusActive).
Update("draw_batch", batch).RowsAffected
if updated == 0 {
return errors.New("活动不允许开奖或已开奖")
}
err := s.repo.GetDbW().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var participants []Participant
if err := tx.Where("activity_id = ?", activityID).Find(&participants).Error; err != nil {
return err
}
if len(participants) == 0 {
return nil
}
var prizes []Prize
if err := tx.Where("activity_id = ? AND remaining_quantity > 0", activityID).Order("sort ASC, id ASC").Find(&prizes).Error; err != nil {
return err
}
if len(prizes) == 0 {
return errors.New("未配置可发放奖品")
}
shuffleParticipants(participants)
prizePool := buildPrizePool(prizes)
shufflePrizes(prizePool)
used := map[int64]bool{}
idx := 0
for _, prize := range prizePool {
for idx < len(participants) && used[participants[idx].UserID] {
idx++
}
if idx >= len(participants) {
break
}
userID := participants[idx].UserID
idx++
used[userID] = true
grantResult, err := s.grantPrizeInTx(ctx, tx, activityID, userID, prize)
if err != nil {
return err
}
winner := &Winner{
ActivityID: activityID,
PrizeID: prize.ID,
RewardType: grantResult.RewardType,
RewardRefID: grantResult.RewardRefID,
PrizeNameSnapshot: grantResult.PrizeNameSnapshot,
PrizeImageSnapshot: grantResult.PrizeImageSnapshot,
PrizeValueSnapshotCents: grantResult.PrizeValueSnapshotCents,
UserID: userID,
GrantRecordType: grantResult.GrantRecordType,
GrantRecordID: grantResult.GrantRecordID,
CostCents: grantResult.CostCents,
DrawBatch: batch,
}
if err := tx.Create(winner).Error; err != nil {
return err
}
if err := tx.Model(&Prize{}).Where("id = ? AND remaining_quantity > 0", prize.ID).Update("remaining_quantity", gorm.Expr("remaining_quantity - 1")).Error; err != nil {
return err
}
}
return nil
})
if err != nil {
return err
}
if err := s.repo.GetDbW().WithContext(ctx).Model(&Activity{}).Where("id = ?", activityID).Updates(map[string]interface{}{"status": StatusFinished, "draw_batch": batch}).Error; err != nil {
return err
}
return nil
}
func (s *service) grantPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) {
switch prize.RewardType {
case RewardTypeItemCard:
return s.grantItemCardPrizeInTx(ctx, tx, activityID, userID, prize)
case RewardTypeCoupon:
return s.grantCouponPrizeInTx(ctx, tx, activityID, userID, prize)
default:
return s.grantProductPrizeInTx(ctx, tx, activityID, userID, prize)
}
}
func (s *service) grantProductPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) {
var product model.Products
if err := tx.WithContext(ctx).Where("id = ?", prize.RewardRefID).First(&product).Error; err != nil {
return nil, err
}
if product.Stock <= 0 {
return nil, fmt.Errorf("商品库存不足: %s", product.Name)
}
result := tx.WithContext(ctx).Model(&model.Products{}).Where("id = ? AND stock > 0", product.ID).Update("stock", gorm.Expr("stock - 1"))
if result.Error != nil {
return nil, result.Error
}
if result.RowsAffected == 0 {
return nil, fmt.Errorf("商品库存不足: %s", product.Name)
}
now := time.Now()
minValidTime := time.Date(1970, 1, 1, 0, 0, 0, 0, time.UTC)
order := &model.Orders{OrderNo: fmt.Sprintf("WA%d%d", activityID, now.UnixNano()), UserID: userID, SourceType: 6, Status: 2, PaidAt: now, CancelledAt: minValidTime, Remark: "福利活动中奖发放", CreatedAt: now, UpdatedAt: now}
if err := tx.WithContext(ctx).Create(order).Error; err != nil {
return nil, err
}
orderItem := &model.OrderItems{OrderID: order.ID, ProductID: product.ID, Title: product.Name, Quantity: 1, ProductImages: product.ImagesJSON, Status: 1}
if err := tx.WithContext(ctx).Create(orderItem).Error; err != nil {
return nil, err
}
value := prize.CostSnapshotCents
if value <= 0 {
value = product.CostPrice
}
if value <= 0 {
value = product.Price
}
inventory := &model.UserInventory{UserID: userID, ProductID: product.ID, ValueCents: value, ValueSource: 1, ValueSnapshotAt: now, OrderID: order.ID, ActivityID: activityID, RewardID: prize.ID, Status: 1, Remark: "福利活动中奖发放"}
if err := tx.WithContext(ctx).Create(inventory).Error; err != nil {
return nil, err
}
return &prizeGrantResult{
RewardType: RewardTypeProduct,
RewardRefID: prize.RewardRefID,
PrizeNameSnapshot: fallbackRewardName(prize.RewardNameSnapshot, product.Name),
PrizeImageSnapshot: fallbackRewardImage(prize.RewardImageSnapshot, firstProductImage(product.ImagesJSON)),
PrizeValueSnapshotCents: fallbackRewardValue(prize.RewardValueSnapshotCents, product.Price),
GrantRecordType: GrantRecordTypeInventory,
GrantRecordID: inventory.ID,
CostCents: prize.CostSnapshotCents,
}, nil
}
func (s *service) grantItemCardPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) {
var card model.SystemItemCards
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", prize.RewardRefID).First(&card).Error; err != nil {
return nil, err
}
now := time.Now()
item := &model.UserItemCards{UserID: userID, CardID: card.ID, Status: 1, Remark: "福利活动中奖发放"}
if !card.ValidStart.IsZero() {
item.ValidStart = card.ValidStart
} else {
item.ValidStart = now
}
if !card.ValidEnd.IsZero() {
item.ValidEnd = card.ValidEnd
}
do := tx.WithContext(ctx).Omit("used_at", "used_draw_log_id", "used_activity_id", "used_issue_id")
if card.ValidEnd.IsZero() {
do = do.Omit("valid_end")
}
if err := do.Create(item).Error; err != nil {
return nil, err
}
return &prizeGrantResult{
RewardType: RewardTypeItemCard,
RewardRefID: prize.RewardRefID,
PrizeNameSnapshot: fallbackRewardName(prize.RewardNameSnapshot, card.Name),
PrizeImageSnapshot: fallbackRewardImage(prize.RewardImageSnapshot, ""),
PrizeValueSnapshotCents: fallbackRewardValue(prize.RewardValueSnapshotCents, card.Price),
GrantRecordType: GrantRecordTypeItemCard,
GrantRecordID: item.ID,
CostCents: prize.CostSnapshotCents,
}, nil
}
func (s *service) grantCouponPrizeInTx(ctx context.Context, tx *gorm.DB, activityID int64, userID int64, prize Prize) (*prizeGrantResult, error) {
var tpl model.SystemCoupons
if err := tx.WithContext(ctx).Where("id = ? AND status = 1", prize.RewardRefID).First(&tpl).Error; err != nil {
return nil, err
}
if !tpl.ValidEnd.IsZero() && tpl.ValidEnd.Before(time.Now()) {
return nil, errors.New("coupon template expired")
}
if tpl.TotalQuantity > 0 {
var issued int64
if err := tx.WithContext(ctx).Model(&model.UserCoupons{}).Where("coupon_id = ?", tpl.ID).Count(&issued).Error; err != nil {
return nil, err
}
if issued >= tpl.TotalQuantity {
return nil, gorm.ErrInvalidData
}
}
item := &model.UserCoupons{UserID: userID, CouponID: tpl.ID, Status: 1}
if !tpl.ValidStart.IsZero() {
item.ValidStart = tpl.ValidStart
} else {
item.ValidStart = time.Now()
}
if !tpl.ValidEnd.IsZero() {
item.ValidEnd = tpl.ValidEnd
}
do := tx.WithContext(ctx).Omit("used_at", "used_order_id")
if tpl.ValidEnd.IsZero() {
do = do.Omit("valid_end")
}
if err := do.Create(item).Error; err != nil {
return nil, err
}
balance := int64(0)
if tpl.DiscountType == 1 && tpl.DiscountValue > 0 {
balance = tpl.DiscountValue
}
if err := tx.WithContext(ctx).Model(&model.UserCoupons{}).Where("id = ?", item.ID).Update("balance_amount", balance).Error; err != nil {
return nil, err
}
return &prizeGrantResult{
RewardType: RewardTypeCoupon,
RewardRefID: prize.RewardRefID,
PrizeNameSnapshot: fallbackRewardName(prize.RewardNameSnapshot, tpl.Name),
PrizeImageSnapshot: fallbackRewardImage(prize.RewardImageSnapshot, ""),
PrizeValueSnapshotCents: fallbackRewardValue(prize.RewardValueSnapshotCents, tpl.DiscountValue),
GrantRecordType: GrantRecordTypeCoupon,
GrantRecordID: item.ID,
CostCents: prize.CostSnapshotCents,
}, nil
}
func fallbackRewardName(snapshot string, fallback string) string {
if snapshot != "" {
return snapshot
}
return fallback
}
func fallbackRewardImage(snapshot string, fallback string) string {
if snapshot != "" {
return snapshot
}
return fallback
}
func fallbackRewardValue(snapshot int64, fallback int64) int64 {
if snapshot > 0 {
return snapshot
}
return fallback
}
func shuffleParticipants(list []Participant) {
seed := time.Now().UnixNano()
var b [8]byte
if _, err := crand.Read(b[:]); err == nil {
seed = int64(binary.LittleEndian.Uint64(b[:]))
}
r := rand.New(rand.NewSource(seed))
r.Shuffle(len(list), func(i, j int) { list[i], list[j] = list[j], list[i] })
}
func buildPrizePool(prizes []Prize) []Prize {
pool := make([]Prize, 0)
for _, prize := range prizes {
for i := 0; i < prize.RemainingQuantity; i++ {
pool = append(pool, prize)
}
}
return pool
}
func shufflePrizes(list []Prize) {
seed := time.Now().UnixNano()
var b [8]byte
if _, err := crand.Read(b[:]); err == nil {
seed = int64(binary.LittleEndian.Uint64(b[:]))
}
r := rand.New(rand.NewSource(seed))
r.Shuffle(len(list), func(i, j int) { list[i], list[j] = list[j], list[i] })
}
func StartScheduledDraw(log logger.CustomLogger, repo mysql.Repo) {
svc := New(log, repo)
go func() {
ticker := time.NewTicker(time.Minute)
defer ticker.Stop()
for range ticker.C {
_ = svc.DrawDueActivities(context.Background())
}
}()
}

View File

@ -1,93 +0,0 @@
package welfare_activity
import (
"context"
"errors"
"fmt"
"time"
)
func (s *service) Join(ctx context.Context, activityID int64, userID int64) error {
if activityID <= 0 || userID <= 0 {
return errors.New("活动或用户无效")
}
var activity Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("id = ? AND deleted_at IS NULL", activityID).First(&activity).Error; err != nil {
return err
}
now := time.Now()
if !isJoinWindowOpen(activity, now) {
if now.Before(activity.StartTime) {
return errors.New("活动未开始")
}
return errors.New("当前不在活动参与时间内")
}
start, end, period := periodRange(activity.Type, now)
paid, err := s.sumPaidAmount(ctx, userID, start, end)
if err != nil {
return err
}
if paid < activity.ThresholdAmount {
return fmt.Errorf("未达到参与门槛,还差%d分", activity.ThresholdAmount-paid)
}
participant := &Participant{ActivityID: activityID, UserID: userID, PeriodKey: period, PaidAmountSnapshot: paid}
return s.repo.GetDbW().WithContext(ctx).Create(participant).Error
}
func (s *service) ListParticipants(ctx context.Context, activityID int64, page int, pageSize int) (*ParticipantResponse, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
var total int64
db := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_participants").Where("activity_id = ?", activityID)
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var list []ParticipantAvatar
err := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_participants p").
Select("p.user_id, COALESCE(u.nickname, '') AS nickname, COALESCE(u.avatar, '') AS avatar").
Joins("LEFT JOIN users u ON u.id = p.user_id").
Where("p.activity_id = ?", activityID).
Order("p.id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&list).Error
return &ParticipantResponse{Page: page, PageSize: pageSize, Total: total, List: list}, err
}
func (s *service) sumPaidAmount(ctx context.Context, userID int64, start time.Time, end time.Time) (int64, error) {
var total int64
err := s.repo.GetDbR().WithContext(ctx).Table("orders").
Select("COALESCE(SUM(actual_amount), 0)").
Where("user_id = ? AND status = 2 AND actual_amount > 0", userID).
Where("COALESCE(NULLIF(paid_at, '1970-01-01 00:00:00'), created_at) >= ?", start).
Where("COALESCE(NULLIF(paid_at, '1970-01-01 00:00:00'), created_at) < ?", end).
Scan(&total).Error
return total, err
}
func periodRange(activityType string, now time.Time) (time.Time, time.Time, string) {
loc := now.Location()
switch activityType {
case TypeWeekly:
dayOffset := (int(now.Weekday()) + 6) % 7
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc).AddDate(0, 0, -dayOffset)
end := start.AddDate(0, 0, 7)
y, w := start.ISOWeek()
return start, end, fmt.Sprintf("%04d-W%02d", y, w)
case TypeMonthly:
start := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, loc)
end := start.AddDate(0, 1, 0)
return start, end, start.Format("2006-01")
default:
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, loc)
end := start.AddDate(0, 0, 1)
return start, end, start.Format("2006-01-02")
}
}

View File

@ -1,69 +0,0 @@
package welfare_activity
import (
"context"
"time"
)
func (s *service) ListJoinableActivitiesForUser(ctx context.Context, userID int64) ([]JoinableActivityItem, error) {
now := time.Now()
var activities []Activity
if err := s.repo.GetDbR().WithContext(ctx).Where("deleted_at IS NULL AND status = ? AND start_time <= ? AND end_time > ? AND draw_time > ?", StatusActive, now, now, now).Order("draw_time ASC, id ASC").Find(&activities).Error; err != nil {
return nil, err
}
if len(activities) == 0 {
return []JoinableActivityItem{}, nil
}
paidByType := map[string]int64{}
periodByType := map[string]string{}
for _, activity := range activities {
if _, ok := paidByType[activity.Type]; ok {
continue
}
start, end, period := periodRange(activity.Type, now)
paid, err := s.sumPaidAmount(ctx, userID, start, end)
if err != nil {
return nil, err
}
paidByType[activity.Type] = paid
periodByType[activity.Type] = period
}
activityIDs := make([]int64, 0, len(activities))
for _, activity := range activities {
activityIDs = append(activityIDs, activity.ID)
}
var participants []Participant
if err := s.repo.GetDbR().WithContext(ctx).Where("user_id = ? AND activity_id IN ?", userID, activityIDs).Find(&participants).Error; err != nil {
return nil, err
}
joinedMap := map[int64]map[string]bool{}
for _, participant := range participants {
if _, ok := joinedMap[participant.ActivityID]; !ok {
joinedMap[participant.ActivityID] = map[string]bool{}
}
joinedMap[participant.ActivityID][participant.PeriodKey] = true
}
items := make([]JoinableActivityItem, 0, len(activities))
for _, activity := range activities {
currentPaid := paidByType[activity.Type]
periodKey := periodByType[activity.Type]
joined := joinedMap[activity.ID][periodKey]
canJoin := !joined && currentPaid >= activity.ThresholdAmount
items = append(items, JoinableActivityItem{
ActivityID: activity.ID,
Title: activity.Title,
Type: activity.Type,
ThresholdAmount: activity.ThresholdAmount,
CurrentPaid: currentPaid,
CanJoin: canJoin,
Joined: joined,
StartTime: activity.StartTime,
EndTime: activity.EndTime,
DrawTime: activity.DrawTime,
})
}
return items, nil
}

View File

@ -1,193 +0,0 @@
package welfare_activity
import "time"
const (
RewardTypeProduct = "product"
RewardTypeItemCard = "item_card"
RewardTypeCoupon = "coupon"
GrantRecordTypeInventory = "inventory"
GrantRecordTypeItemCard = "user_item_card"
GrantRecordTypeCoupon = "user_coupon"
)
type Activity struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
Title string `gorm:"column:title" json:"title"`
Type string `gorm:"column:type" json:"type"`
ThresholdAmount int64 `gorm:"column:threshold_amount" json:"threshold_amount"`
StartTime time.Time `gorm:"column:start_time" json:"start_time"`
EndTime time.Time `gorm:"column:end_time" json:"end_time"`
DrawTime time.Time `gorm:"column:draw_time" json:"draw_time"`
Status string `gorm:"column:status" json:"status"`
Description string `gorm:"column:description" json:"description"`
CoverImage string `gorm:"column:cover_image" json:"cover_image"`
DrawBatch string `gorm:"column:draw_batch" json:"draw_batch"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
DeletedAt *time.Time `gorm:"column:deleted_at" json:"deleted_at,omitempty"`
}
func (*Activity) TableName() string { return "welfare_activities" }
type Prize struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
ActivityID int64 `gorm:"column:activity_id" json:"activity_id"`
RewardType string `gorm:"column:reward_type" json:"reward_type"`
RewardRefID int64 `gorm:"column:reward_ref_id" json:"reward_ref_id"`
RewardNameSnapshot string `gorm:"column:reward_name_snapshot" json:"reward_name_snapshot"`
RewardImageSnapshot string `gorm:"column:reward_image_snapshot" json:"reward_image_snapshot"`
RewardValueSnapshotCents int64 `gorm:"column:reward_value_snapshot_cents" json:"reward_value_snapshot_cents"`
CostSnapshotCents int64 `gorm:"column:cost_snapshot_cents" json:"cost_snapshot_cents"`
Quantity int `gorm:"column:quantity" json:"quantity"`
RemainingQuantity int `gorm:"column:remaining_quantity" json:"remaining_quantity"`
Sort int `gorm:"column:sort" json:"sort"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
Name string `gorm:"-" json:"name,omitempty"`
Image string `gorm:"-" json:"image,omitempty"`
PriceCents int64 `gorm:"-" json:"price_cents,omitempty"`
}
func (*Prize) TableName() string { return "welfare_activity_prizes" }
type Participant struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
ActivityID int64 `gorm:"column:activity_id" json:"activity_id"`
UserID int64 `gorm:"column:user_id" json:"user_id"`
PeriodKey string `gorm:"column:period_key" json:"period_key"`
PaidAmountSnapshot int64 `gorm:"column:paid_amount_snapshot" json:"paid_amount_snapshot"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (*Participant) TableName() string { return "welfare_activity_participants" }
type Winner struct {
ID int64 `gorm:"column:id;primaryKey" json:"id"`
ActivityID int64 `gorm:"column:activity_id" json:"activity_id"`
PrizeID int64 `gorm:"column:prize_id" json:"prize_id"`
RewardType string `gorm:"column:reward_type" json:"reward_type"`
RewardRefID int64 `gorm:"column:reward_ref_id" json:"reward_ref_id"`
PrizeNameSnapshot string `gorm:"column:prize_name_snapshot" json:"prize_name_snapshot"`
PrizeImageSnapshot string `gorm:"column:prize_image_snapshot" json:"prize_image_snapshot"`
PrizeValueSnapshotCents int64 `gorm:"column:prize_value_snapshot_cents" json:"prize_value_snapshot_cents"`
UserID int64 `gorm:"column:user_id" json:"user_id"`
GrantRecordType string `gorm:"column:grant_record_type" json:"grant_record_type"`
GrantRecordID int64 `gorm:"column:grant_record_id" json:"grant_record_id"`
CostCents int64 `gorm:"column:cost_cents" json:"cost_cents"`
DrawBatch string `gorm:"column:draw_batch" json:"draw_batch"`
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
}
func (*Winner) TableName() string { return "welfare_activity_winners" }
type SaveActivityRequest struct {
Title string `json:"title"`
Type string `json:"type"`
ThresholdAmount int64 `json:"threshold_amount"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DrawTime time.Time `json:"draw_time"`
Status string `json:"status"`
Description string `json:"description"`
CoverImage string `json:"cover_image"`
Prizes []PrizeInput `json:"prizes"`
}
type PrizeInput struct {
RewardType string `json:"reward_type"`
RewardRefID int64 `json:"reward_ref_id"`
ProductID int64 `json:"product_id,omitempty"`
Quantity int `json:"quantity"`
Sort int `json:"sort"`
}
type ListActivitiesRequest struct {
Type string
Status string
Title string
Page int
PageSize int
}
type ActivityListItem struct {
Activity
ParticipantCount int64 `json:"participant_count"`
WinnerCount int64 `json:"winner_count"`
CostCents int64 `json:"cost_cents"`
}
type ListActivitiesResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []ActivityListItem `json:"list"`
}
type ActivityDetail struct {
Activity
Prizes []Prize `json:"prizes"`
CurrentPaid int64 `json:"current_paid"`
CanJoin bool `json:"can_join"`
Joined bool `json:"joined"`
ParticipantCount int64 `json:"participant_count"`
Participants []ParticipantAvatar `json:"participants"`
Winners []WinnerItem `json:"winners"`
}
type ParticipantAvatar struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
type ParticipantResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []ParticipantAvatar `json:"list"`
}
type WinnerItem struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
PrizeID int64 `json:"prize_id"`
RewardType string `json:"reward_type"`
RewardRefID int64 `json:"reward_ref_id"`
PrizeName string `json:"prize_name"`
PrizeImage string `json:"prize_image"`
PriceCents int64 `json:"price_cents"`
CostCents int64 `json:"cost_cents"`
GrantRecordType string `json:"grant_record_type"`
GrantRecordID int64 `json:"grant_record_id"`
CreatedAt time.Time `json:"created_at"`
}
type WinnerListResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []WinnerItem `json:"list"`
}
type JoinableActivityItem struct {
ActivityID int64 `json:"activity_id"`
Title string `json:"title"`
Type string `json:"type"`
ThresholdAmount int64 `json:"threshold_amount"`
CurrentPaid int64 `json:"current_paid"`
CanJoin bool `json:"can_join"`
Joined bool `json:"joined"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DrawTime time.Time `json:"draw_time"`
}
type CostSummary struct {
CostCents int64 `json:"cost_cents"`
Count int64 `json:"count"`
}

View File

@ -1,61 +0,0 @@
package welfare_activity
import (
"context"
"time"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
usersvc "bindbox-game/internal/service/user"
)
const (
TypeDaily = "daily"
TypeWeekly = "weekly"
TypeMonthly = "monthly"
StatusActive = "active"
StatusFinished = "finished"
)
type Service interface {
CreateActivity(ctx context.Context, req SaveActivityRequest) (*Activity, error)
UpdateActivity(ctx context.Context, id int64, req SaveActivityRequest) error
CopyActivity(ctx context.Context, id int64, req SaveActivityRequest) (int64, error)
ListActivities(ctx context.Context, req ListActivitiesRequest) (*ListActivitiesResponse, error)
GetActivity(ctx context.Context, id int64, userID int64) (*ActivityDetail, error)
GetActivityAdmin(ctx context.Context, id int64) (*ActivityDetail, error)
DeleteActivity(ctx context.Context, id int64) error
SavePrizes(ctx context.Context, activityID int64, prizes []PrizeInput) error
ListParticipants(ctx context.Context, activityID int64, page int, pageSize int) (*ParticipantResponse, error)
Join(ctx context.Context, activityID int64, userID int64) error
ListWinners(ctx context.Context, activityID int64, page int, pageSize int) (*WinnerListResponse, error)
ListJoinableActivitiesForUser(ctx context.Context, userID int64) ([]JoinableActivityItem, error)
Draw(ctx context.Context, activityID int64) error
DrawDueActivities(ctx context.Context) error
GetCost(ctx context.Context, activityID int64) (*CostSummary, error)
GetCostSummary(ctx context.Context, startTime *time.Time, endTime *time.Time) (*CostSummary, error)
}
type service struct {
logger logger.CustomLogger
repo mysql.Repo
userSvc usersvc.Service
}
func New(log logger.CustomLogger, repo mysql.Repo) Service {
return &service{logger: log, repo: repo, userSvc: usersvc.New(log, repo)}
}
func isJoinWindowOpen(activity Activity, now time.Time) bool {
if activity.Status != StatusActive {
return false
}
if now.Before(activity.StartTime) {
return false
}
if now.After(activity.EndTime) || now.After(activity.DrawTime) {
return false
}
return true
}

View File

@ -1,57 +0,0 @@
package welfare_activity
import (
"context"
"time"
)
func (s *service) ListWinners(ctx context.Context, activityID int64, page int, pageSize int) (*WinnerListResponse, error) {
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
db := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners w").Where("w.activity_id = ?", activityID)
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, err
}
var list []WinnerItem
err := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners w").
Select("w.id, w.user_id, COALESCE(u.nickname, '') AS nickname, COALESCE(u.avatar, '') AS avatar, w.prize_id, w.reward_type, w.reward_ref_id, w.prize_name_snapshot AS prize_name, w.prize_image_snapshot AS prize_image, w.prize_value_snapshot_cents AS price_cents, w.cost_cents, w.grant_record_type, w.grant_record_id, w.created_at").
Joins("LEFT JOIN users u ON u.id = w.user_id").
Where("w.activity_id = ?", activityID).
Order("w.id DESC").
Offset((page - 1) * pageSize).
Limit(pageSize).
Scan(&list).Error
return &WinnerListResponse{Page: page, PageSize: pageSize, Total: total, List: list}, err
}
func (s *service) GetCost(ctx context.Context, activityID int64) (*CostSummary, error) {
var out CostSummary
err := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners").
Select("COALESCE(SUM(cost_cents), 0) AS cost_cents, COUNT(*) AS count").
Where("activity_id = ?", activityID).
Scan(&out).Error
return &out, err
}
func (s *service) GetCostSummary(ctx context.Context, startTime *time.Time, endTime *time.Time) (*CostSummary, error) {
q := s.repo.GetDbR().WithContext(ctx).Table("welfare_activity_winners").Select("COALESCE(SUM(cost_cents), 0) AS cost_cents, COUNT(*) AS count")
if startTime != nil {
q = q.Where("created_at >= ?", *startTime)
}
if endTime != nil {
q = q.Where("created_at < ?", *endTime)
}
var out CostSummary
if err := q.Scan(&out).Error; err != nil {
return nil, err
}
return &out, nil
}

View File

@ -20,7 +20,6 @@ import (
syscfgsvc "bindbox-game/internal/service/sysconfig"
titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user"
welfaresvc "bindbox-game/internal/service/welfare_activity"
"flag"
@ -100,7 +99,6 @@ func main() {
}()
activitysvc.StartScheduledSettlement(customLogger, dbRepo, redis.GetClient())
welfaresvc.StartScheduledDraw(customLogger, dbRepo)
usersvc.StartExpirationCheck(customLogger, dbRepo)
usersvc.StartAutoCancelWorker(customLogger, dbRepo)

View File

@ -1,53 +0,0 @@
-- 扫雷游戏:每局每人的对战记录
CREATE TABLE IF NOT EXISTS `minesweeper_game_records` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`match_id` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'Nakama match ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`game_type` VARCHAR(32) NOT NULL DEFAULT 'minesweeper' COMMENT 'minesweeper / minesweeper_free',
`ticket` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '入场券',
`is_winner` TINYINT(1) NOT NULL DEFAULT 0,
`rank_position` TINYINT NOT NULL DEFAULT 0 COMMENT '本局名次',
`total_players` TINYINT NOT NULL DEFAULT 0,
`total_rounds` INT NOT NULL DEFAULT 0 COMMENT '游戏总轮次',
`rounds_survived` INT NOT NULL DEFAULT 0 COMMENT '存活轮次',
`score` INT NOT NULL DEFAULT 0,
`damage_dealt` INT NOT NULL DEFAULT 0,
`damage_taken` INT NOT NULL DEFAULT 0,
`kills` INT NOT NULL DEFAULT 0,
`chests_collected` INT NOT NULL DEFAULT 0,
`rank_points` INT NOT NULL DEFAULT 0 COMMENT '积分变动',
`raw_summary` JSON COMMENT '完整结算数据快照',
`settled_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_match_user` (`match_id`, `user_id`),
KEY `idx_user_game` (`user_id`, `game_type`),
KEY `idx_settled_at` (`settled_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='扫雷对战记录';
-- 扫雷游戏:排行榜(每人每游戏类型一行,累计聚合)
CREATE TABLE IF NOT EXISTS `minesweeper_leaderboard` (
`id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` BIGINT NOT NULL,
`game_type` VARCHAR(32) NOT NULL DEFAULT 'minesweeper',
`matches_played` INT NOT NULL DEFAULT 0,
`wins` INT NOT NULL DEFAULT 0,
`losses` INT NOT NULL DEFAULT 0,
`win_rate` DECIMAL(6,4) NOT NULL DEFAULT 0.0000,
`total_score` INT NOT NULL DEFAULT 0,
`best_score` INT NOT NULL DEFAULT 0,
`avg_score` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`total_damage_dealt` INT NOT NULL DEFAULT 0,
`total_damage_taken` INT NOT NULL DEFAULT 0,
`avg_damage_dealt` DECIMAL(10,2) NOT NULL DEFAULT 0.00,
`total_chests_collected` INT NOT NULL DEFAULT 0,
`total_rounds_survived` INT NOT NULL DEFAULT 0,
`total_rank_points` INT NOT NULL DEFAULT 0,
`last_match_id` VARCHAR(128) NOT NULL DEFAULT '',
`last_settled_at` DATETIME NOT NULL,
`created_at` DATETIME NOT NULL,
`updated_at` DATETIME NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_user_game` (`user_id`, `game_type`),
KEY `idx_rank_points` (`game_type`, `total_rank_points` DESC)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='扫雷排行榜聚合';

View File

@ -1,75 +0,0 @@
CREATE TABLE IF NOT EXISTS `welfare_activities` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`title` VARCHAR(128) NOT NULL COMMENT '活动标题',
`type` VARCHAR(16) NOT NULL COMMENT '活动类型: daily/weekly/monthly',
`threshold_amount` BIGINT NOT NULL DEFAULT 0 COMMENT '参与门槛金额(分)',
`start_time` DATETIME(3) NOT NULL COMMENT '开始时间',
`end_time` DATETIME(3) NOT NULL COMMENT '结束时间',
`draw_time` DATETIME(3) NOT NULL COMMENT '开奖时间',
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/finished',
`description` TEXT NULL COMMENT '活动说明',
`cover_image` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '封面图',
`draw_batch` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '开奖批次',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
`deleted_at` DATETIME(3) NULL COMMENT '删除时间',
PRIMARY KEY (`id`),
KEY `idx_welfare_activities_type_status` (`type`, `status`),
KEY `idx_welfare_activities_draw_time` (`draw_time`),
KEY `idx_welfare_activities_deleted_at` (`deleted_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='福利活动';
CREATE TABLE IF NOT EXISTS `welfare_activity_prizes` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '福利活动ID',
`reward_type` VARCHAR(32) NOT NULL COMMENT '奖品类型: product/item_card/coupon',
`reward_ref_id` BIGINT NOT NULL COMMENT '奖品资源ID',
`reward_name_snapshot` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '奖品名称快照',
`reward_image_snapshot` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '奖品图片快照',
`reward_value_snapshot_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '奖品展示价值快照(分)',
`cost_snapshot_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '奖品成本快照(分)',
`quantity` INT NOT NULL DEFAULT 0 COMMENT '初始奖品数量',
`remaining_quantity` INT NOT NULL DEFAULT 0 COMMENT '剩余数量',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_welfare_prizes_activity` (`activity_id`),
KEY `idx_welfare_prizes_reward` (`reward_type`, `reward_ref_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='福利活动奖品配置';
CREATE TABLE IF NOT EXISTS `welfare_activity_participants` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '福利活动ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`period_key` VARCHAR(16) NOT NULL COMMENT '参与周期标识',
`paid_amount_snapshot` BIGINT NOT NULL DEFAULT 0 COMMENT '参与时周期消费快照(分)',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_welfare_participant` (`activity_id`, `user_id`, `period_key`),
KEY `idx_welfare_participants_activity` (`activity_id`, `created_at`),
KEY `idx_welfare_participants_user` (`user_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='福利活动参与记录';
CREATE TABLE IF NOT EXISTS `welfare_activity_winners` (
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键ID',
`activity_id` BIGINT NOT NULL COMMENT '福利活动ID',
`prize_id` BIGINT NOT NULL COMMENT '奖品配置ID',
`reward_type` VARCHAR(32) NOT NULL COMMENT '奖品类型: product/item_card/coupon',
`reward_ref_id` BIGINT NOT NULL COMMENT '奖品资源ID',
`prize_name_snapshot` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '中奖奖品名称快照',
`prize_image_snapshot` VARCHAR(512) NOT NULL DEFAULT '' COMMENT '中奖奖品图片快照',
`prize_value_snapshot_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '中奖奖品展示价值快照(分)',
`user_id` BIGINT NOT NULL COMMENT '中奖用户ID',
`grant_record_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '发放记录类型: inventory/user_item_card/user_coupon',
`grant_record_id` BIGINT NOT NULL DEFAULT 0 COMMENT '发放记录ID',
`cost_cents` BIGINT NOT NULL DEFAULT 0 COMMENT '成本(分)',
`draw_batch` VARCHAR(64) NOT NULL DEFAULT '' COMMENT '开奖批次',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_welfare_winner_user` (`activity_id`, `user_id`),
KEY `idx_welfare_winners_activity` (`activity_id`, `created_at`),
KEY `idx_welfare_winners_user` (`user_id`),
KEY `idx_welfare_winners_reward` (`reward_type`, `reward_ref_id`),
KEY `idx_welfare_winners_grant` (`grant_record_type`, `grant_record_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='福利活动中奖记录';

View File

@ -1,39 +0,0 @@
CREATE TABLE IF NOT EXISTS `prize_grant_activities` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`reason` VARCHAR(255) NOT NULL COMMENT '发奖原因',
`status` VARCHAR(16) NOT NULL DEFAULT 'inactive' COMMENT '状态:active/inactive',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_pga_status_id` (`status`, `id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='奖品发放活动';
CREATE TABLE IF NOT EXISTS `prize_grant_activity_rewards` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`reward_type` VARCHAR(32) NOT NULL COMMENT '奖品类型:product/item_card/coupon',
`reward_ref_id` BIGINT NOT NULL COMMENT '奖品资源ID',
`quantity_per_claim` INT NOT NULL DEFAULT 1 COMMENT '每次领取数量',
`sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
KEY `idx_pgar_activity` (`activity_id`, `sort`, `id`),
KEY `idx_pgar_reward` (`reward_type`, `reward_ref_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='奖品发放活动奖品';
CREATE TABLE IF NOT EXISTS `prize_grant_activity_user_records` (
`id` BIGINT NOT NULL AUTO_INCREMENT,
`activity_id` BIGINT NOT NULL COMMENT '活动ID',
`user_id` BIGINT NOT NULL COMMENT '用户ID',
`status` VARCHAR(16) NOT NULL COMMENT '状态:claimed/processed',
`claimed_at` DATETIME(3) NULL DEFAULT NULL COMMENT '领取时间',
`processed_at` DATETIME(3) NULL DEFAULT NULL COMMENT '处理时间',
`operator_admin_id` BIGINT NOT NULL DEFAULT 0 COMMENT '操作管理员ID',
`created_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
`updated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (`id`),
UNIQUE KEY `uk_pgaur_activity_user` (`activity_id`, `user_id`),
KEY `idx_pgaur_activity_status` (`activity_id`, `status`, `updated_at`),
KEY `idx_pgaur_user_status` (`user_id`, `status`, `updated_at`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='奖品发放活动用户记录';

BIN
web/.DS_Store vendored

Binary file not shown.

@ -1 +1 @@
Subproject commit f865b7eef716e8b270458a82fb6ab9141bd2ef40
Subproject commit 6878f71e9d4c6161b5b0249dc23c31399824e911