From a96b1543f0fdadb82db3aa18d2af75c02b237685 Mon Sep 17 00:00:00 2001 From: win Date: Mon, 20 Apr 2026 15:53:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=92=E8=A1=8C=E6=A6=9C=20=E6=89=AB?= =?UTF-8?q?=E9=9B=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .DS_Store | Bin 10244 -> 10244 bytes cmd/douyin_sync_debug/main.go | 2 +- docs/user_9522_debug_20260411.md | 280 +++++++++++ internal/api/game/handler.go | 453 ++++++++++++++---- internal/api/game/handler_test.go | 448 ++++++++--------- internal/api/user/coupons_usage_app_test.go | 29 +- internal/api/user/sync_douyin_orders_app.go | 66 +++ .../api/user/sync_douyin_orders_app_test.go | 191 ++++++++ internal/router/router.go | 3 + internal/service/douyin/order_sync.go | 159 ++++-- internal/service/douyin/order_sync_test.go | 43 ++ migrations/20260420_minesweeper_tables.sql | 53 ++ web/.DS_Store | Bin 6148 -> 6148 bytes web/admin | 2 +- 14 files changed, 1387 insertions(+), 342 deletions(-) create mode 100644 docs/user_9522_debug_20260411.md create mode 100644 internal/api/user/sync_douyin_orders_app.go create mode 100644 internal/api/user/sync_douyin_orders_app_test.go create mode 100644 internal/service/douyin/order_sync_test.go create mode 100644 migrations/20260420_minesweeper_tables.sql diff --git a/.DS_Store b/.DS_Store index 257f8cbea49d50e709f3243a2871ef6f787b2e50..aea7d4e632baa0210a9c8da0a4ad85ff5403173d 100755 GIT binary patch delta 59 zcmV-B0L1@)^I delta 45 zcmZn(XbG6$&nUk!U^hRb{AM137o3x;#1ba63wceRC&j+GL#Cd2W5Z*{&Fl()*#Tz9 B59$B_ diff --git a/cmd/douyin_sync_debug/main.go b/cmd/douyin_sync_debug/main.go index 638ba50..0ecbbfa 100644 --- a/cmd/douyin_sync_debug/main.go +++ b/cmd/douyin_sync_debug/main.go @@ -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) diff --git a/docs/user_9522_debug_20260411.md b/docs/user_9522_debug_20260411.md new file mode 100644 index 0000000..f737199 --- /dev/null +++ b/docs/user_9522_debug_20260411.md @@ -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** + +
+展开全部 37 笔通行证抽奖订单 + +**活动 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 | + +
+ +--- + +### 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。 diff --git a/internal/api/game/handler.go b/internal/api/game/handler.go index af95f62..3ecf820 100755 --- a/internal/api/game/handler.go +++ b/internal/api/game/handler.go @@ -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 { diff --git a/internal/api/game/handler_test.go b/internal/api/game/handler_test.go index 757b7a2..60f3073 100755 --- a/internal/api/game/handler_test.go +++ b/internal/api/game/handler_test.go @@ -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) + } +} diff --git a/internal/api/user/coupons_usage_app_test.go b/internal/api/user/coupons_usage_app_test.go index f884008..f2f0cc0 100755 --- a/internal/api/user/coupons_usage_app_test.go +++ b/internal/api/user/coupons_usage_app_test.go @@ -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("")) diff --git a/internal/api/user/sync_douyin_orders_app.go b/internal/api/user/sync_douyin_orders_app.go new file mode 100644 index 0000000..2da2d9a --- /dev/null +++ b/internal/api/user/sync_douyin_orders_app.go @@ -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, + }) + } +} diff --git a/internal/api/user/sync_douyin_orders_app_test.go b/internal/api/user/sync_douyin_orders_app_test.go new file mode 100644 index 0000000..a43e709 --- /dev/null +++ b/internal/api/user/sync_douyin_orders_app_test.go @@ -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()) + } +} diff --git a/internal/router/router.go b/internal/router/router.go index eaf8c41..da82666 100755 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -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()) diff --git a/internal/service/douyin/order_sync.go b/internal/service/douyin/order_sync.go index 8188408..d6a4424 100755 --- a/internal/service/douyin/order_sync.go +++ b/internal/service/douyin/order_sync.go @@ -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 响应结构 diff --git a/internal/service/douyin/order_sync_test.go b/internal/service/douyin/order_sync_test.go new file mode 100644 index 0000000..368f525 --- /dev/null +++ b/internal/service/douyin/order_sync_test.go @@ -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) + } +} diff --git a/migrations/20260420_minesweeper_tables.sql b/migrations/20260420_minesweeper_tables.sql new file mode 100644 index 0000000..5021e40 --- /dev/null +++ b/migrations/20260420_minesweeper_tables.sql @@ -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='扫雷排行榜聚合'; diff --git a/web/.DS_Store b/web/.DS_Store index cc0c9cda435451469be7abfa0526e4ef4c4cc472..035096273afed87cf64b3eeb158883acc026bb52 100755 GIT binary patch delta 68 zcmZoMXffE}&%(H4asW%Ax@t^fc4 delta 72 zcmZoMXffE}&%(HSasW%AmUMNsu92~&k&c3qxnZr2LbaihshN&~sf9^xEhmSlvc7dt ce0EN5UViW77cBCOeVaL0|1fQ4=lIJH0Pg=4VgLXD diff --git a/web/admin b/web/admin index 6878f71..0677ac7 160000 --- a/web/admin +++ b/web/admin @@ -1 +1 @@ -Subproject commit 6878f71e9d4c6161b5b0249dc23c31399824e911 +Subproject commit 0677ac73c5b7576b96f5ba403da3219609f3f527