排行榜 扫雷

This commit is contained in:
win 2026-04-20 15:53:31 +08:00
parent 0e202fabd8
commit a96b1543f0
14 changed files with 1387 additions and 342 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -64,7 +64,7 @@ func main() {
env.Active() // 初始化 env flag依赖已有的全局 -env/ACTIVE_ENV 配置)
configs.Init()
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"
cookie := "passport_csrf_token=59cdf9f8b9154bb170fbe3718b5c2c41; passport_csrf_token_default=59cdf9f8b9154bb170fbe3718b5c2c41; s_v_web_id=verify_mnkmeu91_r7NhDaDR_4MVT_4Icm_85n7_EDp2hQZTZj6o; is_staff_user=false; has_biz_token=false; zsgw_business_data=%7B%22uuid%22%3A%2295540517-0144-4b48-8d52-a060aa220f27%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.fxg.jinritemai.com%22%7D; gfkadpd=4272,23756; ecom_gray_shop_id=156231010; SHOP_ID=156231010; PIGEON_CID=4339134776748827; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1774525909,1774632716,1774968034,1775994293; HMACCOUNT=0D91B8CECCE6C828; csrf_session_id=513aabd94aa6a91c47c47dd861880f60; channel_account_verify_jwt=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjdXN0b21fY2xhaW0iOiI0MzM5MTM0Nzc2NzQ4ODI3Iiwic3ViIjoiY2hhbm5lbF9hY2NvdW50X3ZlcmlmeSIsImV4cCI6MTc3NjAzNzUzMCwibmJmIjoxNzc1OTk0MzMwLCJpYXQiOjE3NzU5OTQzMzB9.RSCDRSD2d2kEqoREXzVpDG3EYyLAXIFEFyD2fIgy2h4; channel_account_verify=cfcd208495d565ef66e7dff9f98764da; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1776010821; ttwid=1%7Cs-eQn8Q_A0kZTCaP0uZ6tFZ-5nSc-YV48RZrmP6MSxo%7C1776010857%7C5714d0cbd808c2cf2089b67d6539964c9109031c29f23749b1bb8d708dfd7e66; odin_tt=88b295e3e44318f7efde24295646e0724be377f012f90be7a12b1fa4e530c3f5cfc06d9af74411d3b15f348c5f3900e0b52fbdb9089c745fbd6f3921a3aa3783; passport_auth_status=dfeee43cd8f8414913f2a1194f2ed9fc%2C; passport_auth_status_ss=dfeee43cd8f8414913f2a1194f2ed9fc%2C; uid_tt=447779f3f27396b07599eb6fd21aaf34; uid_tt_ss=447779f3f27396b07599eb6fd21aaf34; sid_tt=997579cda00e9f4fee35eadbbb7c7ba8; sessionid=997579cda00e9f4fee35eadbbb7c7ba8; sessionid_ss=997579cda00e9f4fee35eadbbb7c7ba8; ucas_c0=CkEKBTEuMC4wEIaIh9jXy_HtaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DujO_OBkjuwKvRBlC_vL6Ekt3t1GdYbhIUEG_zKIPeZdy8IvzBnEeUQZh2Jmk; ucas_c0_ss=CkEKBTEuMC4wEIaIh9jXy_HtaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DujO_OBkjuwKvRBlC_vL6Ekt3t1GdYbhIUEG_zKIPeZdy8IvzBnEeUQZh2Jmk; PHPSESSID=d1a5b3819b815de4643be5556e0eb8d4; PHPSESSID_SS=d1a5b3819b815de4643be5556e0eb8d4; ecom_us_lt=6ee137f29c931bcba00435bff17681cea86fbbab104f1d7a32df87c406c1b2cd; ecom_us_lt_ss=6ee137f29c931bcba00435bff17681cea86fbbab104f1d7a32df87c406c1b2cd; source=seo.fxg.jinritemai.com; sid_guard=997579cda00e9f4fee35eadbbb7c7ba8%7C1776010868%7C5184000%7CThu%2C+11-Jun-2026+16%3A21%3A08+GMT; session_tlb_tag=sttt%7C7%7CmXV5zaAOn0_uNerbu3x7qP_________DvWrNB4ZNs5w88zu8OQBZtctF5iZKpB38WdY8WkW1gV8%3D; sid_ucp_v1=1.0.0-KGFlMWE0OWU3MWFhZjY2YjJmZmNiYWExZjg2ZjA1NjZiNDNiNmJlNmMKGQib1oDYuM3aBxD0jO_OBhiwISAMOAZA9AcaAmxmIiA5OTc1NzljZGEwMGU5ZjRmZWUzNWVhZGJiYjdjN2JhOA; ssid_ucp_v1=1.0.0-KGFlMWE0OWU3MWFhZjY2YjJmZmNiYWExZjg2ZjA1NjZiNDNiNmJlNmMKGQib1oDYuM3aBxD0jO_OBhiwISAMOAZA9AcaAmxmIiA5OTc1NzljZGEwMGU5ZjRmZWUzNWVhZGJiYjdjN2JhOA; BUYIN_SASID=SID2_7627905386221912374"
if cookie == "" {
fmt.Println("请通过环境变量 DOUYIN_COOKIE 提供抖店 Cookie")
os.Exit(1)

View File

@ -0,0 +1,280 @@
# 用户排查报告 — 大熊 (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。

View File

@ -9,10 +9,13 @@ 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"
@ -348,13 +351,29 @@ 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 {
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"
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"`
}
type settleResponse struct {
@ -362,7 +381,19 @@ type settleResponse struct {
Reward string `json:"reward,omitempty"`
}
// SettleGame Internal游戏结算
func calcRankPoints(win bool, score, damageDealt, damageTaken, chests, totalRounds int) int64 {
base := int64(100)
if win {
base = 1000
}
pts := base + int64(score)*10 + int64(damageDealt)*3 + int64(chests)*50 - int64(damageTaken)*2 - int64(totalRounds)
if pts < 0 {
pts = 0
}
return pts
}
// SettleGame Internal游戏结算批量全员
// @Summary 游戏结算
// @Tags Internal.游戏
// @Param RequestBody body settleRequest true "请求参数"
@ -376,103 +407,179 @@ func (h *handler) SettleGame() core.HandlerFunc {
return
}
// 直接从请求参数判断是否为免费模式
isFreeMode := req.GameType == "minesweeper_free"
// 拦截免费场结算(免费模式不发放任何奖励)
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
}
// 验证 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]
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 {
// 删除 ticket 防止重复使用
h.redis.Del(ctx.RequestContext(), "game:token:ticket:"+req.Ticket)
// 兼容旧版单人结算Nakama 未升级时的过渡)
if len(req.Players) == 0 && req.UserID != "" {
uid, _ := strconv.ParseInt(req.UserID, 10, 64)
if uid > 0 {
req.Players = []settlePlayerRecord{{
UserID: uid,
Ticket: req.Ticket,
Win: req.Win,
Score: req.Score,
}}
if req.GameType == "" {
req.GameType = "minesweeper"
}
}
}
// 注意即使ticket验证失败作为internal API我们仍然信任游戏服务器传来的UserID
if len(req.Players) == 0 {
ctx.Payload(&settleResponse{Success: true})
return
}
// 奖品发放逻辑
var rewardMsg string
// 幂等检查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
}
}
isFreeMode := req.GameType == "minesweeper_free"
now := time.Now()
// 读取奖励配置(付费场用)
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"`
WinnerRewardPoints int64 `json:"winner_reward_points"`
WinnerRewardProductID int64 `json:"winner_reward_product_id"`
ParticipationRewardPoints int64 `json:"participation_reward_points"`
}
// 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 // 兜底
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)
}
}
// 3. 发放奖励(仅付费模式,免费模式已在前面拦截)
for _, p := range req.Players {
rankPoints := calcRankPoints(p.Win, p.Score, p.DamageDealt, p.DamageTaken, p.ChestsCollected, req.TotalRounds)
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 = "获得奖品"
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
}
} 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))
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: "扫雷游戏奖励",
})
} else {
pts := msConfig.WinnerRewardPoints
if pts == 0 {
pts = 100
}
h.userSvc.AddPointsWithAction(ctx.RequestContext(), p.UserID, pts, "game_reward", "扫雷游戏奖励", "minesweeper_settle", nil, nil)
}
}
rewardMsg = strconv.FormatInt(targetPoints, 10) + "积分"
}
ctx.Payload(&settleResponse{Success: true, Reward: rewardMsg})
// 异步刷新排行榜缓存
go h.refreshLeaderboardCache(req.GameType)
ctx.Payload(&settleResponse{Success: true})
}
}
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)
data, _ := json.Marshal(rows)
cacheKey := fmt.Sprintf("ms:lb:v1:%s:top100", gameType)
h.redis.Set(context.Background(), cacheKey, string(data), 24*time.Hour)
}
type consumeTicketRequest struct {
UserID string `json:"user_id"`
GameCode string `json:"game_code"`
@ -556,6 +663,180 @@ 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
// page=1 优先读 Redis 缓存
if page == 1 && pageSize <= 100 {
cacheKey := fmt.Sprintf("ms:lb:v1:%s:top100", gameType)
cached, err := h.redis.Get(ctx.RequestContext(), cacheKey).Result()
if err == nil && cached != "" {
var list []map[string]any
if json.Unmarshal([]byte(cached), &list) == nil {
// 加上名次
for i := range list {
list[i]["rank"] = i + 1
}
// 截取分页
start := (page - 1) * pageSize
end := start + pageSize
if start >= len(list) {
start = len(list)
}
if end > len(list) {
end = len(list)
}
pageList := list[start:end]
ctx.Payload(map[string]any{
"game_type": gameType,
"page": page,
"page_size": pageSize,
"total": len(list),
"list": pageList,
"me": h.queryMyRank(ctx.RequestContext(), myUserID, gameType),
})
return
}
}
}
// 直接查 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 myPoints int64
h.db.GetDbR().Table("minesweeper_leaderboard").
Select("total_rank_points").
Where("user_id = ? AND game_type = ?", userID, gameType).
Scan(&myPoints)
if myPoints == 0 {
return nil
}
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,
}
}
// ========== Helpers ==========
func generateTicketToken(userID int64) string {

View File

@ -17,211 +17,198 @@ import (
"github.com/stretchr/testify/assert"
)
// settleRequest 结算请求结构体(与 handler.go 保持一致)
type settleRequest struct {
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"`
// ---- 与 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"`
}
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"`
}
// settleResponse 结算响应结构体
type settleResponse struct {
Success bool `json:"success"`
Reward string `json:"reward,omitempty"`
}
// TestSettleGame_FreeModeDetection 测试免费模式判断逻辑
// 这是核心测试:验证免费模式通过 game_type 参数判断,而不是依赖 Redis
func TestSettleGame_FreeModeDetection(t *testing.T) {
// ---- calcRankPoints 本地副本(保持与 handler.go 一致) ----
func calcRankPoints(win bool, score, damageDealt, damageTaken, chests, totalRounds int) int64 {
base := int64(100)
if win {
base = 1000
}
pts := base + int64(score)*10 + int64(damageDealt)*3 + int64(chests)*50 - int64(damageTaken)*2 - int64(totalRounds)
if pts < 0 {
pts = 0
}
return pts
}
// ---- 积分公式单元测试 ----
func TestCalcRankPoints(t *testing.T) {
tests := []struct {
name string
gameType string
ticketInRedis bool // 是否在 Redis 中存储 ticket
expectedReward string // 预期的奖励消息
shouldBlock bool // 是否应该被拦截(免费模式)
name string
win bool
score int
damageDealt int
damageTaken int
chests int
rounds int
expected int64
}{
{
name: "免费模式_有ticket_应拦截",
gameType: "minesweeper_free",
ticketInRedis: true,
expectedReward: "体验模式无奖励",
shouldBlock: true,
name: "赢家基础积分",
win: true, score: 0, damageDealt: 0, damageTaken: 0, chests: 0, rounds: 0,
expected: 1000,
},
{
name: "免费模式_无ticket_应拦截",
gameType: "minesweeper_free",
ticketInRedis: false,
expectedReward: "体验模式无奖励",
shouldBlock: true,
name: "输家基础积分",
win: false, score: 0, damageDealt: 0, damageTaken: 0, chests: 0, rounds: 0,
expected: 100,
},
{
name: "付费模式_有ticket_应发奖",
gameType: "minesweeper",
ticketInRedis: true,
expectedReward: "", // 付费模式会发放积分奖励
shouldBlock: false,
name: "赢家满加成",
win: true, score: 50, damageDealt: 10, damageTaken: 5, chests: 3, rounds: 12,
// 1000 + 50*10 + 10*3 + 3*50 - 5*2 - 12 = 1000+500+30+150-10-12 = 1658
expected: 1658,
},
{
name: "付费模式_无ticket_应发奖",
gameType: "minesweeper",
ticketInRedis: false,
expectedReward: "", // 付费模式会发放积分奖励
shouldBlock: false,
name: "不能为负数",
win: false, score: 0, damageDealt: 0, damageTaken: 100, chests: 0, rounds: 1000,
// 100 - 200 - 1000 < 0 → 0
expected: 0,
},
{
name: "空game_type_应发奖",
gameType: "",
ticketInRedis: false,
expectedReward: "", // 空类型不是免费模式
shouldBlock: false,
name: "宝箱加成显著",
win: true, score: 0, damageDealt: 0, damageTaken: 0, chests: 5, rounds: 0,
// 1000 + 5*50 = 1250
expected: 1250,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 模拟判断逻辑
isFreeMode := tt.gameType == "minesweeper_free"
if tt.shouldBlock {
assert.True(t, isFreeMode, "免费模式应该被正确识别")
} else {
assert.False(t, isFreeMode, "非免费模式不应该被拦截")
}
got := calcRankPoints(tt.win, tt.score, tt.damageDealt, tt.damageTaken, tt.chests, tt.rounds)
assert.Equal(t, tt.expected, got)
})
}
}
// TestSettleGame_FreeModeWithRedis 测试 Redis ticket 不影响免费模式判断
func TestSettleGame_FreeModeWithRedis(t *testing.T) {
// 1. 启动 miniredis
// ---- 批量结算:免费模式检测 ----
func TestSettleGame_FreeModeDetection(t *testing.T) {
tests := []struct {
name string
gameType string
expectFree bool
}{
{"免费模式", "minesweeper_free", true},
{"付费模式", "minesweeper", false},
{"空game_type不算免费", "", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
isFree := tt.gameType == "minesweeper_free"
assert.Equal(t, tt.expectFree, isFree)
})
}
}
// ---- 幂等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) {
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)
// 场景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 存在
val, err := rdb.Get(ctx, ticketKey).Result()
assert.NoError(t, err)
assert.Contains(t, val, "12345")
req := settleRequest{
UserID: userID,
Ticket: ticket,
MatchID: "match-001",
Win: true,
Score: 100,
GameType: "minesweeper_free",
}
// 结算后清除 ticket
rdb.Del(ctx, ticketKey)
// 直接从 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)
})
_, err = rdb.Get(ctx, ticketKey).Result()
assert.Error(t, err, "ticket 应已被清除")
}
// 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()
// ---- HTTP 集成测试(模拟简化版 settle handler ----
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)
@ -232,29 +219,59 @@ func TestSettleGame_Integration(t *testing.T) {
checkResponse func(t *testing.T, body []byte)
}{
{
name: "免费模式结算_应返回体验模式无奖励",
name: "免费模式_批量结算_直接成功",
request: settleRequest{
UserID: "12345",
Ticket: "GT123456789",
MatchID: "match-001",
Win: true,
Score: 100,
GameType: "minesweeper_free",
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},
},
},
expectedStatus: http.StatusOK,
checkResponse: func(t *testing.T, body []byte) {
var resp settleResponse
err := json.Unmarshal(body, &resp)
assert.NoError(t, err)
_ = 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)
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
@ -263,28 +280,28 @@ func TestSettleGame_Integration(t *testing.T) {
return
}
// 核心逻辑:直接从请求参数判断
isFreeMode := req.GameType == "minesweeper_free"
if isFreeMode {
c.JSON(http.StatusOK, settleResponse{
Success: true,
Reward: "体验模式无奖励",
})
// 兼容旧版
if len(req.Players) == 0 && req.UserID != "" {
req.Players = []settlePlayerRecord{{UserID: 12345, Win: req.Win, Score: req.Score}}
}
if len(req.Players) == 0 {
c.JSON(http.StatusOK, settleResponse{Success: true})
return
}
// 付费模式发奖逻辑(简化)
c.JSON(http.StatusOK, settleResponse{
Success: true,
Reward: "100积分",
})
// 计算积分(验证公式被调用)
for _, p := range req.Players {
pts := calcRankPoints(p.Win, p.Score, p.DamageDealt, p.DamageTaken, p.ChestsCollected, req.TotalRounds)
assert.GreaterOrEqual(t, pts, int64(0))
}
c.JSON(http.StatusOK, settleResponse{Success: true})
})
// 发送请求
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)
@ -297,35 +314,34 @@ func TestSettleGame_Integration(t *testing.T) {
}
}
// 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()
// ---- 旧版单人 Bug 场景:现在通过 players 字段兼容 ----
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"
}
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,
}
})
// 新实现:直接比较字符串
b.Run("新实现_字符串比较", func(b *testing.B) {
gameType := "minesweeper_free"
// 新版兼容逻辑
isFree := req.GameType == "minesweeper_free"
assert.True(t, isFree, "免费模式通过 game_type 判断,不依赖 Redis")
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = gameType == "minesweeper_free"
// players 为空时从旧版字段补全
if len(req.Players) == 0 && req.UserID != "" {
req.Players = []settlePlayerRecord{{UserID: 12345, Win: req.Win, Score: req.Score}}
}
assert.Len(t, req.Players, 1)
})
}
// ---- 性能基准:积分计算 ----
func BenchmarkCalcRankPoints(b *testing.B) {
for i := 0; i < b.N; i++ {
calcRankPoints(true, 50, 10, 5, 3, 12)
}
}

View File

@ -33,7 +33,32 @@ 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)
}
@ -41,7 +66,7 @@ func Test_ListUserCouponUsage_App(t *testing.T) {
t.Fatal(err)
}
lg, err := logger.NewCustomLogger(nil, logger.WithOutputInConsole())
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
if err != nil {
t.Fatal(err)
}
@ -55,7 +80,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).ListUserCouponUsage())
app.GET("/users/:user_id/coupons/:user_coupon_id/usage", New(lg, repo, nil).ListUserCouponUsage())
rr := httptest.NewRecorder()
req, _ := http.NewRequest("GET", "/api/app/users/1/coupons/10/usage?page=1&page_size=20", bytes.NewBufferString(""))

View File

@ -0,0 +1,66 @@
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

@ -0,0 +1,191 @@
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

@ -109,6 +109,7 @@ 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())
}
// 管理端非认证接口路由组
@ -493,6 +494,7 @@ 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())
@ -528,6 +530,7 @@ 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())

View File

@ -39,6 +39,8 @@ 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)
@ -361,52 +363,20 @@ func (s *service) FetchAndSyncOrders(ctx context.Context, opts *FetchOptions) (*
var mu sync.Mutex
syncUser := func(u model.Users) {
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)
fetched, newOrders, matchedOrders, err := s.syncOrdersForBoundUser(ctx, cfg, u)
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 += len(orders)
result.NewOrders += perUserNew
result.MatchedUsers += perUserMatched
result.TotalFetched += fetched
result.NewOrders += newOrders
result.MatchedUsers += matchedOrders
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 {
@ -477,6 +447,123 @@ 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 响应结构

View File

@ -0,0 +1,43 @@
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

@ -0,0 +1,53 @@
-- 扫雷游戏:每局每人的对战记录
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='扫雷排行榜聚合';

BIN
web/.DS_Store vendored

Binary file not shown.

@ -1 +1 @@
Subproject commit 6878f71e9d4c6161b5b0249dc23c31399824e911
Subproject commit 0677ac73c5b7576b96f5ba403da3219609f3f527