diff --git a/README.md b/README.md index 7f471e9..bf7f7df 100644 --- a/README.md +++ b/README.md @@ -18,5 +18,7 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -tags timetzda export DOCKER_DEFAULT_PLATFORM=linux/amd64 -docker build -t zfc931912343/bindbox-game:v1.6 . -docker push zfc931912343/bindbox-game:v1.6 +docker build -t zfc931912343/bindbox-game:v1.8 . +docker push zfc931912343/bindbox-game:v1.8 + +docker pull zfc931912343/bindbox-game:v1.8 &&docker rm -f bindbox-game && docker run -d --name bindbox-game -p 9991:9991 zfc931912343/bindbox-game:v1.8 \ No newline at end of file diff --git a/configs/configs.go b/configs/configs.go index 4b451df..53fb6c3 100644 --- a/configs/configs.go +++ b/configs/configs.go @@ -29,6 +29,12 @@ type Config struct { } `mapstructure:"write" toml:"write"` } `mapstructure:"mysql" toml:"mysql"` + Redis struct { + Addr string `mapstructure:"addr" toml:"addr"` + Pass string `mapstructure:"pass" toml:"pass"` + DB int `mapstructure:"db" toml:"db"` + } `mapstructure:"redis" toml:"redis"` + JWT struct { AdminSecret string `mapstructure:"admin_secret" toml:"admin_secret"` PatientSecret string `mapstructure:"patient_secret" toml:"patient_secret"` diff --git a/configs/dev_configs.toml b/configs/dev_configs.toml index b2c8111..5abf92a 100644 --- a/configs/dev_configs.toml +++ b/configs/dev_configs.toml @@ -11,9 +11,9 @@ name = "bindbox_game" [redis] - addr = "127.0.0.1:6379" - pass = "" - db = 0 + addr = "118.25.13.43:8379" + pass = "xbm#2023by1024" + db = 5 [random] commit_master_key = "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456" diff --git a/configs/fat_configs.toml b/configs/fat_configs.toml index dd5f7f1..0ab1acd 100644 --- a/configs/fat_configs.toml +++ b/configs/fat_configs.toml @@ -7,6 +7,12 @@ name = 'bindbox_game' pass = 'api2api..' user = 'root' +[redis] + addr = "118.25.13.43:8379" + pass = "xbm#2023by1024" + db = 5 + + [mysql.write] addr = 'sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555' name = 'bindbox_game' diff --git a/docs/docs.go b/docs/docs.go index af628b7..26a7133 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -3510,6 +3510,51 @@ const docTemplate = `{ } } }, + "/api/app/activities/{activity_id}/issues/{issue_id}/draw_logs_grouped": { + "get": { + "description": "查看指定活动期数的抽奖记录,按奖品等级分组返回", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.活动" + ], + "summary": "按奖品等级分类的抽奖记录", + "parameters": [ + { + "type": "integer", + "description": "活动ID", + "name": "activity_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "期ID", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.listDrawLogsByLevelResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/activities/{activity_id}/issues/{issue_id}/rewards": { "get": { "description": "获取指定期的奖励配置列表", @@ -3711,14 +3756,9 @@ const docTemplate = `{ } } }, - "/api/app/matching/play": { - "post": { - "security": [ - { - "LoginVerifyToken": [] - } - ], - "description": "执行一轮配对,返回配对结果和新抽取的牌", + "/api/app/matching/card_types": { + "get": { + "description": "获取所有启用的卡牌类型配置,用于App端预览或动画展示", "consumes": [ "application/json" ], @@ -3728,23 +3768,15 @@ const docTemplate = `{ "tags": [ "APP端.活动" ], - "summary": "执行一轮对对碰游戏", - "parameters": [ - { - "description": "请求参数", - "name": "RequestBody", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/app.matchingGamePlayRequest" - } - } - ], + "summary": "列出对对碰卡牌类型", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/app.matchingGamePlayResponse" + "type": "array", + "items": { + "$ref": "#/definitions/app.CardTypeConfig" + } } }, "400": { @@ -3756,14 +3788,14 @@ const docTemplate = `{ } } }, - "/api/app/matching/start": { + "/api/app/matching/check": { "post": { "security": [ { "LoginVerifyToken": [] } ], - "description": "创建新的对对碰游戏会话,返回初始手牌和第一轮结果", + "description": "前端游戏结束后上报结果,服务器发放奖励", "consumes": [ "application/json" ], @@ -3773,7 +3805,7 @@ const docTemplate = `{ "tags": [ "APP端.活动" ], - "summary": "开始对对碰游戏", + "summary": "游戏结束结算校验", "parameters": [ { "description": "请求参数", @@ -3781,7 +3813,7 @@ const docTemplate = `{ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/app.matchingGameStartRequest" + "$ref": "#/definitions/app.matchingGameCheckRequest" } } ], @@ -3789,7 +3821,52 @@ const docTemplate = `{ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/app.matchingGameStartResponse" + "$ref": "#/definitions/app.matchingGameCheckResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/matching/preorder": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "用户下单,服务器扣费并返回全量99张乱序卡牌,前端自行负责游戏流程", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.活动" + ], + "summary": "下单并获取对对碰全量数据", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.matchingGamePreOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.matchingGamePreOrderResponse" } }, "400": { @@ -3845,6 +3922,100 @@ const docTemplate = `{ } } }, + "/api/app/orders/{order_id}": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "获取指定订单的详细信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "获取订单详情", + "parameters": [ + { + "type": "integer", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/user.OrderWithItems" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/orders/{order_id}/cancel": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "取消指定订单(仅限待付款状态)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "取消订单", + "parameters": [ + { + "type": "integer", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + }, + { + "description": "取消原因", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/app.cancelOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.cancelOrderResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/pay/wechat/jsapi/preorder": { "post": { "security": [ @@ -7254,6 +7425,9 @@ const docTemplate = `{ "level": { "type": "integer" }, + "min_score": { + "type": "integer" + }, "name": { "type": "string" }, @@ -7366,6 +7540,9 @@ const docTemplate = `{ "level": { "type": "integer" }, + "min_score": { + "type": "integer" + }, "name": { "type": "string" }, @@ -7547,11 +7724,11 @@ const docTemplate = `{ } } }, - "app.MatchingCard": { + "app.CardTypeConfig": { "type": "object", "properties": { - "id": { - "type": "integer" + "code": { + "type": "string" }, "image_url": { "type": "string" @@ -7559,56 +7736,32 @@ const docTemplate = `{ "name": { "type": "string" }, + "quantity": { + "type": "integer" + } + } + }, + "app.MatchingCard": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, "type": { "type": "string" } } }, - "app.MatchingPair": { + "app.MatchingRewardInfo": { "type": "object", "properties": { - "card_type": { + "level": { + "type": "integer" + }, + "name": { "type": "string" }, - "count": { - "type": "integer" - } - } - }, - "app.MatchingRoundResult": { - "type": "object", - "properties": { - "can_continue": { - "type": "boolean" - }, - "drawn_cards": { - "type": "array", - "items": { - "type": "string" - } - }, - "hand_after": { - "type": "array", - "items": { - "type": "string" - } - }, - "hand_before": { - "type": "array", - "items": { - "type": "string" - } - }, - "pairs": { - "type": "array", - "items": { - "$ref": "#/definitions/app.MatchingPair" - } - }, - "pairs_count": { - "type": "integer" - }, - "round": { + "reward_id": { "type": "integer" } } @@ -7859,6 +8012,31 @@ const docTemplate = `{ } } }, + "app.cancelOrderRequest": { + "type": "object", + "properties": { + "reason": { + "type": "string" + } + } + }, + "app.cancelOrderResponse": { + "type": "object", + "properties": { + "cancelled_at": { + "type": "string" + }, + "order_id": { + "type": "integer" + }, + "order_no": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, "app.couponDetail": { "type": "object", "properties": { @@ -7976,6 +8154,23 @@ const docTemplate = `{ } } }, + "app.drawLogGroup": { + "type": "object", + "properties": { + "level": { + "type": "integer" + }, + "level_name": { + "type": "string" + }, + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/app.drawLogItem" + } + } + } + }, "app.drawLogItem": { "type": "object", "properties": { @@ -8309,6 +8504,17 @@ const docTemplate = `{ } } }, + "app.listDrawLogsByLevelResponse": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/app.drawLogGroup" + } + } + } + }, "app.listDrawLogsResponse": { "type": "object", "properties": { @@ -8520,58 +8726,75 @@ const docTemplate = `{ } } }, - "app.matchingGamePlayRequest": { + "app.matchingGameCheckRequest": { "type": "object", + "required": [ + "game_id" + ], "properties": { "game_id": { "type": "string" + }, + "total_pairs": { + "description": "客户端上报的消除总对数", + "type": "integer" } } }, - "app.matchingGamePlayResponse": { + "app.matchingGameCheckResponse": { "type": "object", "properties": { - "final_state": { - "type": "object", - "additionalProperties": {} - }, - "game_over": { + "finished": { "type": "boolean" }, - "round": { - "$ref": "#/definitions/app.MatchingRoundResult" + "game_id": { + "type": "string" + }, + "reward": { + "$ref": "#/definitions/app.MatchingRewardInfo" }, "total_pairs": { "type": "integer" } } }, - "app.matchingGameStartRequest": { + "app.matchingGamePreOrderRequest": { "type": "object", "properties": { + "coupon_id": { + "type": "integer" + }, "issue_id": { "type": "integer" + }, + "item_card_id": { + "type": "integer" + }, + "position": { + "type": "string" } } }, - "app.matchingGameStartResponse": { + "app.matchingGamePreOrderResponse": { "type": "object", "properties": { - "deck_count": { - "type": "integer" - }, - "first_round": { - "$ref": "#/definitions/app.MatchingRoundResult" - }, - "game_id": { - "type": "string" - }, - "hand": { + "all_cards": { + "description": "全量99张卡牌(乱序)", "type": "array", "items": { "$ref": "#/definitions/app.MatchingCard" } }, + "game_id": { + "type": "string" + }, + "order_no": { + "type": "string" + }, + "pay_status": { + "description": "1=Pending, 2=Paid", + "type": "integer" + }, "server_seed_hash": { "type": "string" } @@ -9481,6 +9704,65 @@ const docTemplate = `{ } } }, + "user.DrawReceiptInfo": { + "type": "object", + "properties": { + "algo_version": { + "type": "string" + }, + "client_id": { + "type": "integer" + }, + "client_seed": { + "type": "string" + }, + "draw_id": { + "type": "integer" + }, + "draw_index": { + "type": "integer" + }, + "draw_log_id": { + "type": "integer" + }, + "items_root": { + "type": "string" + }, + "items_snapshot": { + "type": "string" + }, + "nonce": { + "type": "integer" + }, + "rand_proof": { + "type": "string" + }, + "reward_id": { + "type": "integer" + }, + "round_id": { + "type": "integer" + }, + "selected_index": { + "type": "integer" + }, + "server_seed_hash": { + "type": "string" + }, + "server_sub_seed": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "weights_total": { + "type": "integer" + } + } + }, "user.InventoryWithProduct": { "type": "object", "properties": { @@ -9631,6 +9913,12 @@ const docTemplate = `{ "description": "优惠券抵扣金额(分)", "type": "integer" }, + "draw_receipts": { + "type": "array", + "items": { + "$ref": "#/definitions/user.DrawReceiptInfo" + } + }, "id": { "description": "主键ID", "type": "integer" diff --git a/docs/matching_game_api_example.jsonc b/docs/matching_game_api_example.jsonc new file mode 100644 index 0000000..19e6a4a --- /dev/null +++ b/docs/matching_game_api_example.jsonc @@ -0,0 +1,105 @@ +{ + // 游戏唯一ID,用于后续校验和记录查询 + "game_id": "MG10011639882000", + + // 初始棋盘状态(9个格子) + // null 表示该格子为空(理论上初始时不应有空,除非卡牌不够) + "initial_board": [ + { + "id": "c1", // 卡牌唯一实例ID + "type": "A", // 卡牌类型代码(对应配置中的 Code) + "name": "苹果", // 卡牌名称 + "image_url": "apple.png" // 卡牌图片地址 + }, + { "id": "c2", "type": "A", "name": "苹果", "image_url": "apple.png" }, + { "id": "c3", "type": "B", "name": "香蕉", "image_url": "banana.png" }, + { "id": "c4", "type": "B", "name": "香蕉", "image_url": "banana.png" }, + { "id": "c5", "type": "C", "name": "樱桃", "image_url": "cherry.png" }, + { "id": "c6", "type": "C", "name": "樱桃", "image_url": "cherry.png" }, + { "id": "c7", "type": "A", "name": "苹果", "image_url": "apple.png" }, + { "id": "c8", "type": "A", "name": "苹果", "image_url": "apple.png" }, + { "id": "c9", "type": "B", "name": "香蕉", "image_url": "banana.png" } + ], + + // 游戏完整时间轴,前端按顺序播放数组中的每一个 Round + "timeline": [ + // --- 第 1 回合 --- + { + "round": 1, // 回合数 + + // 当前回合开始时的棋盘状态(主要用于校准,前端动画可基于此状态开始) + "board": [ ... ], + + // 本回合消除的配对信息 + "pairs": [ + { + "card_type": "A", // 消除的卡牌类型 + "count": 2, // 消除数量 + "card_ids": ["c1", "c2"], // 被消除的卡牌ID + "slot_indices": [0, 1] // 被消除的棋盘格子索引 (0-8) -> 前端需播放这些位置的消除动画 + } + ], + "pairs_count": 1, // 本回合总消除对数 + + // 消除后,从牌堆抽卡填补空位的信息 + "drawn_cards": [ + { + "slot_index": 0, // 填补到哪个格子 (0-8) + "card": { // 填补的具体卡牌信息 + "id": "c10", + "type": "B", + "name": "香蕉", + "image_url": "banana.png" + } + }, + { + "slot_index": 1, + "card": { + "id": "c11", + "type": "C", + "name": "樱桃", + "image_url": "cherry.png" + } + } + ], + + "reshuffled": false, // 本回合是否触发了“死局重洗”。如果为 true,前端需播放棋盘重洗动画(所有卡牌重新排列) + "can_continue": true // 游戏是否继续。如果为 false,表示游戏结束 + }, + + // --- 第 2 回合 --- + { + "round": 2, + "pairs": [ + { + "card_type": "B", + "count": 4, // 一次消除了4张!(2对) + "card_ids": ["c3", "c4", "c9", "c10"], + "slot_indices": [2, 3, 8, 0] // 这些位置的卡牌一起消失 + } + ], + "pairs_count": 2, + "drawn_cards": [ ... ], // 可能会填补4张新卡 + "reshuffled": false, + "can_continue": true + }, + + // ... 中间省略若干回合 ... + + // --- 最后回合 --- + { + "round": 5, + "pairs": [], // 没有可消除的了 + "pairs_count": 0, + "drawn_cards": [], + "reshuffled": false, + "can_continue": false // 游戏结束 + } + ], + + // 游戏总消除对数(最终得分) + "total_pairs": 8, + + // 服务器种子哈希(用于公平性验证,可忽略) + "server_seed_hash": "a1b2c3d4..." +} diff --git a/docs/matching_game_full_example.json b/docs/matching_game_full_example.json new file mode 100644 index 0000000..d90f0d0 --- /dev/null +++ b/docs/matching_game_full_example.json @@ -0,0 +1,127 @@ +{ + "game_id": "MG1001_FULL_DEMO_2025", + "initial_board": [ + { "id": "c1", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" }, + { "id": "c2", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" }, + { "id": "c3", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + { "id": "c4", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + { "id": "c5", "type": "C", "name": "樱桃", "image_url": "https://example.com/cherry.png" }, + { "id": "c6", "type": "C", "name": "樱桃", "image_url": "https://example.com/cherry.png" }, + { "id": "c7", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" }, + { "id": "c8", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + { "id": "c9", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" } + ], + "timeline": [ + { + "round": 1, + "board": [ + { "id": "c1", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" }, + { "id": "c2", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" }, + { "id": "c3", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + { "id": "c4", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + { "id": "c5", "type": "C", "name": "樱桃", "image_url": "https://example.com/cherry.png" }, + { "id": "c6", "type": "C", "name": "樱桃", "image_url": "https://example.com/cherry.png" }, + { "id": "c7", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" }, + { "id": "c8", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + { "id": "c9", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" } + ], + "pairs": [ + { + "card_type": "A", + "count": 2, + "card_ids": ["c1", "c2"], + "slot_indices": [0, 1] + } + ], + "pairs_count": 1, + "drawn_cards": [ + { + "slot_index": 0, + "card": { "id": "c10", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" } + } + ], + "reshuffled": false, + "can_continue": true + }, + { + "round": 2, + "board": [ + { "id": "c10", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + null, + { "id": "c3", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + { "id": "c4", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + { "id": "c5", "type": "C", "name": "樱桃", "image_url": "https://example.com/cherry.png" }, + { "id": "c6", "type": "C", "name": "樱桃", "image_url": "https://example.com/cherry.png" }, + { "id": "c7", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" }, + { "id": "c8", "type": "B", "name": "香蕉", "image_url": "https://example.com/banana.png" }, + { "id": "c9", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" } + ], + "pairs": [ + { + "card_type": "B", + "count": 4, + "card_ids": ["c10", "c3", "c4", "c8"], + "slot_indices": [0, 2, 3, 7] + } + ], + "pairs_count": 2, + "drawn_cards": [], + "reshuffled": false, + "can_continue": true + }, + { + "round": 3, + "board": [ + null, + null, + null, + null, + { "id": "c5", "type": "C", "name": "樱桃", "image_url": "https://example.com/cherry.png" }, + { "id": "c6", "type": "C", "name": "樱桃", "image_url": "https://example.com/cherry.png" }, + { "id": "c7", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" }, + null, + { "id": "c9", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" } + ], + "pairs": [ + { + "card_type": "C", + "count": 2, + "card_ids": ["c5", "c6"], + "slot_indices": [4, 5] + } + ], + "pairs_count": 1, + "drawn_cards": [], + "reshuffled": false, + "can_continue": true + }, + { + "round": 4, + "board": [ + null, + null, + null, + null, + null, + null, + { "id": "c7", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" }, + null, + { "id": "c9", "type": "A", "name": "苹果", "image_url": "https://example.com/apple.png" } + ], + "pairs": [ + { + "card_type": "A", + "count": 2, + "card_ids": ["c7", "c9"], + "slot_indices": [6, 8] + } + ], + "pairs_count": 1, + "drawn_cards": [], + "reshuffled": false, + "can_continue": false + } + ], + "total_pairs": 5, + "server_seed_hash": "a1b2c3d4e5f6g7h8i9j0" +} diff --git a/docs/swagger.json b/docs/swagger.json index 40c4312..888b115 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -3502,6 +3502,51 @@ } } }, + "/api/app/activities/{activity_id}/issues/{issue_id}/draw_logs_grouped": { + "get": { + "description": "查看指定活动期数的抽奖记录,按奖品等级分组返回", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.活动" + ], + "summary": "按奖品等级分类的抽奖记录", + "parameters": [ + { + "type": "integer", + "description": "活动ID", + "name": "activity_id", + "in": "path", + "required": true + }, + { + "type": "integer", + "description": "期ID", + "name": "issue_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.listDrawLogsByLevelResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/activities/{activity_id}/issues/{issue_id}/rewards": { "get": { "description": "获取指定期的奖励配置列表", @@ -3703,14 +3748,9 @@ } } }, - "/api/app/matching/play": { - "post": { - "security": [ - { - "LoginVerifyToken": [] - } - ], - "description": "执行一轮配对,返回配对结果和新抽取的牌", + "/api/app/matching/card_types": { + "get": { + "description": "获取所有启用的卡牌类型配置,用于App端预览或动画展示", "consumes": [ "application/json" ], @@ -3720,23 +3760,15 @@ "tags": [ "APP端.活动" ], - "summary": "执行一轮对对碰游戏", - "parameters": [ - { - "description": "请求参数", - "name": "RequestBody", - "in": "body", - "required": true, - "schema": { - "$ref": "#/definitions/app.matchingGamePlayRequest" - } - } - ], + "summary": "列出对对碰卡牌类型", "responses": { "200": { "description": "OK", "schema": { - "$ref": "#/definitions/app.matchingGamePlayResponse" + "type": "array", + "items": { + "$ref": "#/definitions/app.CardTypeConfig" + } } }, "400": { @@ -3748,14 +3780,14 @@ } } }, - "/api/app/matching/start": { + "/api/app/matching/check": { "post": { "security": [ { "LoginVerifyToken": [] } ], - "description": "创建新的对对碰游戏会话,返回初始手牌和第一轮结果", + "description": "前端游戏结束后上报结果,服务器发放奖励", "consumes": [ "application/json" ], @@ -3765,7 +3797,7 @@ "tags": [ "APP端.活动" ], - "summary": "开始对对碰游戏", + "summary": "游戏结束结算校验", "parameters": [ { "description": "请求参数", @@ -3773,7 +3805,7 @@ "in": "body", "required": true, "schema": { - "$ref": "#/definitions/app.matchingGameStartRequest" + "$ref": "#/definitions/app.matchingGameCheckRequest" } } ], @@ -3781,7 +3813,52 @@ "200": { "description": "OK", "schema": { - "$ref": "#/definitions/app.matchingGameStartResponse" + "$ref": "#/definitions/app.matchingGameCheckResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/matching/preorder": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "用户下单,服务器扣费并返回全量99张乱序卡牌,前端自行负责游戏流程", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.活动" + ], + "summary": "下单并获取对对碰全量数据", + "parameters": [ + { + "description": "请求参数", + "name": "RequestBody", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/app.matchingGamePreOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.matchingGamePreOrderResponse" } }, "400": { @@ -3837,6 +3914,100 @@ } } }, + "/api/app/orders/{order_id}": { + "get": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "获取指定订单的详细信息", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "获取订单详情", + "parameters": [ + { + "type": "integer", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/user.OrderWithItems" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, + "/api/app/orders/{order_id}/cancel": { + "post": { + "security": [ + { + "LoginVerifyToken": [] + } + ], + "description": "取消指定订单(仅限待付款状态)", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "APP端.用户" + ], + "summary": "取消订单", + "parameters": [ + { + "type": "integer", + "description": "订单ID", + "name": "order_id", + "in": "path", + "required": true + }, + { + "description": "取消原因", + "name": "body", + "in": "body", + "schema": { + "$ref": "#/definitions/app.cancelOrderRequest" + } + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/app.cancelOrderResponse" + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/code.Failure" + } + } + } + } + }, "/api/app/pay/wechat/jsapi/preorder": { "post": { "security": [ @@ -7246,6 +7417,9 @@ "level": { "type": "integer" }, + "min_score": { + "type": "integer" + }, "name": { "type": "string" }, @@ -7358,6 +7532,9 @@ "level": { "type": "integer" }, + "min_score": { + "type": "integer" + }, "name": { "type": "string" }, @@ -7539,11 +7716,11 @@ } } }, - "app.MatchingCard": { + "app.CardTypeConfig": { "type": "object", "properties": { - "id": { - "type": "integer" + "code": { + "type": "string" }, "image_url": { "type": "string" @@ -7551,56 +7728,32 @@ "name": { "type": "string" }, + "quantity": { + "type": "integer" + } + } + }, + "app.MatchingCard": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, "type": { "type": "string" } } }, - "app.MatchingPair": { + "app.MatchingRewardInfo": { "type": "object", "properties": { - "card_type": { + "level": { + "type": "integer" + }, + "name": { "type": "string" }, - "count": { - "type": "integer" - } - } - }, - "app.MatchingRoundResult": { - "type": "object", - "properties": { - "can_continue": { - "type": "boolean" - }, - "drawn_cards": { - "type": "array", - "items": { - "type": "string" - } - }, - "hand_after": { - "type": "array", - "items": { - "type": "string" - } - }, - "hand_before": { - "type": "array", - "items": { - "type": "string" - } - }, - "pairs": { - "type": "array", - "items": { - "$ref": "#/definitions/app.MatchingPair" - } - }, - "pairs_count": { - "type": "integer" - }, - "round": { + "reward_id": { "type": "integer" } } @@ -7851,6 +8004,31 @@ } } }, + "app.cancelOrderRequest": { + "type": "object", + "properties": { + "reason": { + "type": "string" + } + } + }, + "app.cancelOrderResponse": { + "type": "object", + "properties": { + "cancelled_at": { + "type": "string" + }, + "order_id": { + "type": "integer" + }, + "order_no": { + "type": "string" + }, + "status": { + "type": "integer" + } + } + }, "app.couponDetail": { "type": "object", "properties": { @@ -7968,6 +8146,23 @@ } } }, + "app.drawLogGroup": { + "type": "object", + "properties": { + "level": { + "type": "integer" + }, + "level_name": { + "type": "string" + }, + "list": { + "type": "array", + "items": { + "$ref": "#/definitions/app.drawLogItem" + } + } + } + }, "app.drawLogItem": { "type": "object", "properties": { @@ -8301,6 +8496,17 @@ } } }, + "app.listDrawLogsByLevelResponse": { + "type": "object", + "properties": { + "groups": { + "type": "array", + "items": { + "$ref": "#/definitions/app.drawLogGroup" + } + } + } + }, "app.listDrawLogsResponse": { "type": "object", "properties": { @@ -8512,58 +8718,75 @@ } } }, - "app.matchingGamePlayRequest": { + "app.matchingGameCheckRequest": { "type": "object", + "required": [ + "game_id" + ], "properties": { "game_id": { "type": "string" + }, + "total_pairs": { + "description": "客户端上报的消除总对数", + "type": "integer" } } }, - "app.matchingGamePlayResponse": { + "app.matchingGameCheckResponse": { "type": "object", "properties": { - "final_state": { - "type": "object", - "additionalProperties": {} - }, - "game_over": { + "finished": { "type": "boolean" }, - "round": { - "$ref": "#/definitions/app.MatchingRoundResult" + "game_id": { + "type": "string" + }, + "reward": { + "$ref": "#/definitions/app.MatchingRewardInfo" }, "total_pairs": { "type": "integer" } } }, - "app.matchingGameStartRequest": { + "app.matchingGamePreOrderRequest": { "type": "object", "properties": { + "coupon_id": { + "type": "integer" + }, "issue_id": { "type": "integer" + }, + "item_card_id": { + "type": "integer" + }, + "position": { + "type": "string" } } }, - "app.matchingGameStartResponse": { + "app.matchingGamePreOrderResponse": { "type": "object", "properties": { - "deck_count": { - "type": "integer" - }, - "first_round": { - "$ref": "#/definitions/app.MatchingRoundResult" - }, - "game_id": { - "type": "string" - }, - "hand": { + "all_cards": { + "description": "全量99张卡牌(乱序)", "type": "array", "items": { "$ref": "#/definitions/app.MatchingCard" } }, + "game_id": { + "type": "string" + }, + "order_no": { + "type": "string" + }, + "pay_status": { + "description": "1=Pending, 2=Paid", + "type": "integer" + }, "server_seed_hash": { "type": "string" } @@ -9473,6 +9696,65 @@ } } }, + "user.DrawReceiptInfo": { + "type": "object", + "properties": { + "algo_version": { + "type": "string" + }, + "client_id": { + "type": "integer" + }, + "client_seed": { + "type": "string" + }, + "draw_id": { + "type": "integer" + }, + "draw_index": { + "type": "integer" + }, + "draw_log_id": { + "type": "integer" + }, + "items_root": { + "type": "string" + }, + "items_snapshot": { + "type": "string" + }, + "nonce": { + "type": "integer" + }, + "rand_proof": { + "type": "string" + }, + "reward_id": { + "type": "integer" + }, + "round_id": { + "type": "integer" + }, + "selected_index": { + "type": "integer" + }, + "server_seed_hash": { + "type": "string" + }, + "server_sub_seed": { + "type": "string" + }, + "signature": { + "type": "string" + }, + "timestamp": { + "type": "integer" + }, + "weights_total": { + "type": "integer" + } + } + }, "user.InventoryWithProduct": { "type": "object", "properties": { @@ -9623,6 +9905,12 @@ "description": "优惠券抵扣金额(分)", "type": "integer" }, + "draw_receipts": { + "type": "array", + "items": { + "$ref": "#/definitions/user.DrawReceiptInfo" + } + }, "id": { "description": "主键ID", "type": "integer" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 21d2239..8201170 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -981,6 +981,8 @@ definitions: type: integer level: type: integer + min_score: + type: integer name: type: string original_qty: @@ -1049,6 +1051,8 @@ definitions: type: integer level: type: integer + min_score: + type: integer name: type: string original_qty: @@ -1174,47 +1178,31 @@ definitions: total: type: integer type: object - app.MatchingCard: + app.CardTypeConfig: properties: - id: - type: integer + code: + type: string image_url: type: string name: type: string + quantity: + type: integer + type: object + app.MatchingCard: + properties: + id: + type: string type: type: string type: object - app.MatchingPair: + app.MatchingRewardInfo: properties: - card_type: + level: + type: integer + name: type: string - count: - type: integer - type: object - app.MatchingRoundResult: - properties: - can_continue: - type: boolean - drawn_cards: - items: - type: string - type: array - hand_after: - items: - type: string - type: array - hand_before: - items: - type: string - type: array - pairs: - items: - $ref: '#/definitions/app.MatchingPair' - type: array - pairs_count: - type: integer - round: + reward_id: type: integer type: object app.activityDetailResponse: @@ -1377,6 +1365,22 @@ definitions: success: type: boolean type: object + app.cancelOrderRequest: + properties: + reason: + type: string + type: object + app.cancelOrderResponse: + properties: + cancelled_at: + type: string + order_id: + type: integer + order_no: + type: string + status: + type: integer + type: object app.couponDetail: properties: amount: @@ -1458,6 +1462,17 @@ definitions: description: 订单号 type: string type: object + app.drawLogGroup: + properties: + level: + type: integer + level_name: + type: string + list: + items: + $ref: '#/definitions/app.drawLogItem' + type: array + type: object app.drawLogItem: properties: current_level: @@ -1675,6 +1690,13 @@ definitions: total: type: integer type: object + app.listDrawLogsByLevelResponse: + properties: + groups: + items: + $ref: '#/definitions/app.drawLogGroup' + type: array + type: object app.listDrawLogsResponse: properties: list: @@ -1812,40 +1834,52 @@ definitions: total: type: integer type: object - app.matchingGamePlayRequest: + app.matchingGameCheckRequest: properties: game_id: type: string + total_pairs: + description: 客户端上报的消除总对数 + type: integer + required: + - game_id type: object - app.matchingGamePlayResponse: + app.matchingGameCheckResponse: properties: - final_state: - additionalProperties: {} - type: object - game_over: + finished: type: boolean - round: - $ref: '#/definitions/app.MatchingRoundResult' + game_id: + type: string + reward: + $ref: '#/definitions/app.MatchingRewardInfo' total_pairs: type: integer type: object - app.matchingGameStartRequest: + app.matchingGamePreOrderRequest: properties: + coupon_id: + type: integer issue_id: type: integer - type: object - app.matchingGameStartResponse: - properties: - deck_count: + item_card_id: type: integer - first_round: - $ref: '#/definitions/app.MatchingRoundResult' - game_id: + position: type: string - hand: + type: object + app.matchingGamePreOrderResponse: + properties: + all_cards: + description: 全量99张卡牌(乱序) items: $ref: '#/definitions/app.MatchingCard' type: array + game_id: + type: string + order_no: + type: string + pay_status: + description: 1=Pending, 2=Paid + type: integer server_seed_hash: type: string type: object @@ -2461,6 +2495,45 @@ definitions: type: object type: array type: object + user.DrawReceiptInfo: + properties: + algo_version: + type: string + client_id: + type: integer + client_seed: + type: string + draw_id: + type: integer + draw_index: + type: integer + draw_log_id: + type: integer + items_root: + type: string + items_snapshot: + type: string + nonce: + type: integer + rand_proof: + type: string + reward_id: + type: integer + round_id: + type: integer + selected_index: + type: integer + server_seed_hash: + type: string + server_sub_seed: + type: string + signature: + type: string + timestamp: + type: integer + weights_total: + type: integer + type: object user.InventoryWithProduct: properties: activity_id: @@ -2569,6 +2642,10 @@ definitions: discount_amount: description: 优惠券抵扣金额(分) type: integer + draw_receipts: + items: + $ref: '#/definitions/user.DrawReceiptInfo' + type: array id: description: 主键ID type: integer @@ -4934,6 +5011,36 @@ paths: summary: 抽奖记录列表 tags: - APP端.活动 + /api/app/activities/{activity_id}/issues/{issue_id}/draw_logs_grouped: + get: + consumes: + - application/json + description: 查看指定活动期数的抽奖记录,按奖品等级分组返回 + parameters: + - description: 活动ID + in: path + name: activity_id + required: true + type: integer + - description: 期ID + in: path + name: issue_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.listDrawLogsByLevelResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + summary: 按奖品等级分类的抽奖记录 + tags: + - APP端.活动 /api/app/activities/{activity_id}/issues/{issue_id}/rewards: get: consumes: @@ -5063,60 +5170,81 @@ paths: summary: 抽奖订单结果查询 tags: - APP端.抽奖 - /api/app/matching/play: - post: + /api/app/matching/card_types: + get: consumes: - application/json - description: 执行一轮配对,返回配对结果和新抽取的牌 - parameters: - - description: 请求参数 - in: body - name: RequestBody - required: true - schema: - $ref: '#/definitions/app.matchingGamePlayRequest' + description: 获取所有启用的卡牌类型配置,用于App端预览或动画展示 produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/app.matchingGamePlayResponse' + items: + $ref: '#/definitions/app.CardTypeConfig' + type: array "400": description: Bad Request schema: $ref: '#/definitions/code.Failure' - security: - - LoginVerifyToken: [] - summary: 执行一轮对对碰游戏 + summary: 列出对对碰卡牌类型 tags: - APP端.活动 - /api/app/matching/start: + /api/app/matching/check: post: consumes: - application/json - description: 创建新的对对碰游戏会话,返回初始手牌和第一轮结果 + description: 前端游戏结束后上报结果,服务器发放奖励 parameters: - description: 请求参数 in: body name: RequestBody required: true schema: - $ref: '#/definitions/app.matchingGameStartRequest' + $ref: '#/definitions/app.matchingGameCheckRequest' produces: - application/json responses: "200": description: OK schema: - $ref: '#/definitions/app.matchingGameStartResponse' + $ref: '#/definitions/app.matchingGameCheckResponse' "400": description: Bad Request schema: $ref: '#/definitions/code.Failure' security: - LoginVerifyToken: [] - summary: 开始对对碰游戏 + summary: 游戏结束结算校验 + tags: + - APP端.活动 + /api/app/matching/preorder: + post: + consumes: + - application/json + description: 用户下单,服务器扣费并返回全量99张乱序卡牌,前端自行负责游戏流程 + parameters: + - description: 请求参数 + in: body + name: RequestBody + required: true + schema: + $ref: '#/definitions/app.matchingGamePreOrderRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.matchingGamePreOrderResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 下单并获取对对碰全量数据 tags: - APP端.活动 /api/app/matching/state: @@ -5147,6 +5275,65 @@ paths: summary: 获取对对碰游戏状态 tags: - APP端.活动 + /api/app/orders/{order_id}: + get: + consumes: + - application/json + description: 获取指定订单的详细信息 + parameters: + - description: 订单ID + in: path + name: order_id + required: true + type: integer + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/user.OrderWithItems' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 获取订单详情 + tags: + - APP端.用户 + /api/app/orders/{order_id}/cancel: + post: + consumes: + - application/json + description: 取消指定订单(仅限待付款状态) + parameters: + - description: 订单ID + in: path + name: order_id + required: true + type: integer + - description: 取消原因 + in: body + name: body + schema: + $ref: '#/definitions/app.cancelOrderRequest' + produces: + - application/json + responses: + "200": + description: OK + schema: + $ref: '#/definitions/app.cancelOrderResponse' + "400": + description: Bad Request + schema: + $ref: '#/definitions/code.Failure' + security: + - LoginVerifyToken: [] + summary: 取消订单 + tags: + - APP端.用户 /api/app/pay/wechat/jsapi/preorder: post: consumes: diff --git a/docs/前端对接指南_对对碰.md b/docs/前端对接指南_对对碰.md new file mode 100644 index 0000000..218f8e2 --- /dev/null +++ b/docs/前端对接指南_对对碰.md @@ -0,0 +1,122 @@ +# 对对碰(Matching Game)前端对接指南 + +## 1. 核心流程概述 + +本版本采用 **"预计算 + 客户端渲染"** 模式,以确保流畅体验并防止网络延迟影响游戏节奏。 + +### 交互时序 +1. **开始游戏 (API)**: 用户点击开始 -> 调用 `PreOrder` 接口 -> 服务器扣费并返回**完整游戏剧本**。 +2. **动画播放 (Frontend)**: 前端根据返回的 `timeline` 数据,按顺序播放每一轮的 消除、填补、重洗 动画。 +3. **游戏结算 (API)**: 动画播放完毕(或用户点击跳过) -> 调用 `Check` 接口 -> 服务器确认结束并发放奖励。 + +--- + +## 2. 接口调用详解 + +### 2.1 第一步:下单并获取数据 (Start) + +* **接口**: `POST /api/app/matching/preorder` +* **参数**: `{ "issue_id": 123 }` +* **核心响应**: + ```json + { + "game_id": "MG_123456", // 保存此ID用于结算 + "initial_board": [ ... ], // 初始9宫格数据 (用于渲染第一帧) + "timeline": [ ... ], // 关键!动画剧本数组 + "total_pairs": 5 // 预计总消除对数 (可用于显示"目标") + } + ``` + +### 2.2 第二步:前端渲染循环 (Render) + +前端拿到 `timeline` 后,不需要再请求服务器,直接在本地执行以下逻辑: + +```javascript +// 伪代码示例 +async function playFullGame(data) { + // 1. 渲染初始棋盘 + renderBoard(data.initial_board); + + // 2. 遍历时间轴,逐回合播放 + for (const round of data.timeline) { + + // --- 阶段 A: 消除动画 --- + if (round.pairs.length > 0) { + // 高亮要消除的卡牌 + highlightCards(round.pairs); + await wait(500); // 停顿 + + // 播放消除特效(根据 slot_indices 找到对应格子) + for (const pair of round.pairs) { + // pair.slot_indices 数组包含了所有要消除的格子下标 (0-8) + playEliminateEffect(pair.slot_indices); + } + await wait(500); // 等待特效结束 + + // 从界面移除卡牌 DOM + removeCardsFromBoard(round.pairs); + } + + // --- 阶段 B: 填补动画 --- + if (round.drawn_cards.length > 0) { + // 播放发牌/飞入动画 + for (const draw of round.drawn_cards) { + // draw.slot_index 是目标格子 + // draw.card 是新卡牌数据 + flyCardToSlot(draw.card, draw.slot_index); + } + await wait(500); + } + + // --- 阶段 C: 死局重洗 (如果有) --- + if (round.reshuffled) { + showToast("死局重洗中..."); + playShuffleAnimation(); // 播放所有卡牌重新排列的动画 + // 动画结束后,根据 round.board 更新整个棋盘状态,确保位置正确 + updateFullBoard(round.board); + await wait(1000); + } + + // 每一轮结束后稍作停顿 + await wait(300); + } + + // 3. 全部播放完毕,调用结算 + doCheck(data.game_id); +} +``` + +### 2.3 第三步:结算与领奖 (Check) + +* **接口**: `POST /api/app/matching/check` +* **参数**: `{ "game_id": "MG_123456" }` +* **作用**: 告知服务器动画已播完,触发最终奖励发放(虽然服务器在PreOrder时已经计算好了,但这个调用作为完整的业务闭环)。 + +--- + +## 3. 数据结构字典 + +### `MatchingRoundResult` (时间轴中的每一项) + +| 字段 | 类型 | 说明 | 前端处理建议 | +| :--- | :--- | :--- | :--- | +| `round` | int | 回合数 | 用于UI显示"第几轮" | +| `pairs` | array | 消除列表 | **关键**:遍历此数组,读取 `slot_indices` 来决定哪些格子要播消除动画 | +| `drawn_cards` | array | 填补列表 | **关键**:遍历此数组,读取 `slot_index` 来决定新卡牌飞入哪个格子 | +| `reshuffled` | bool | 是否重洗 | 若为 true,必须播放全屏洗牌特效,并强制刷新棋盘数据 | +| `can_continue` | bool | 是否继续 | 若为 false,播放完本轮后显示"游戏结束"弹窗 | + +### `MatchingPair` (消除详情) + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| `card_type` | string | 消除的卡牌类型 | +| `count` | int | 消除数量 (通常是2,连消可能是4,6) | +| `slot_indices` | int[] | **核心**:被消除的格子下标列表,如 `[0, 1]` | + +### `DrawnCardInfo` (填补详情) + +| 字段 | 类型 | 说明 | +| :--- | :--- | :--- | +| `slot_index` | int | **核心**:填入的目标格子下标 (0-8) | +| `card` | object | 新卡牌的完整数据 (id, image_url, name) | diff --git a/go.mod b/go.mod index 1ed15ae..60c501c 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/issue9/identicon/v2 v2.1.2 github.com/pkg/errors v0.9.1 github.com/prometheus/client_golang v1.17.0 + github.com/redis/go-redis/v9 v9.17.2 github.com/rs/cors/wrapper/gin v0.0.0-20231013084403-73f81b45a644 github.com/spf13/cast v1.5.1 github.com/spf13/viper v1.17.0 @@ -48,10 +49,11 @@ require ( github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bytedance/sonic/loader v0.2.4 // indirect - github.com/cespare/xxhash/v2 v2.2.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/clbanning/mxj v1.8.4 // indirect github.com/cloudwego/base64x v0.1.5 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/gabriel-vasile/mimetype v1.4.2 // indirect github.com/gin-contrib/sse v0.1.0 // indirect diff --git a/go.sum b/go.sum index d33e3bf..c709a0a 100644 --- a/go.sum +++ b/go.sum @@ -53,6 +53,8 @@ github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3 github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ= @@ -61,8 +63,8 @@ github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4 github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY= github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= -github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= -github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= @@ -82,6 +84,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= @@ -298,6 +302,8 @@ github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdO github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI= +github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= github.com/rogpeppe/go-internal v1.8.0/go.mod h1:WmiCO8CzOY8rg0OYDC4/i/2WRWAB6poM+XZ2dLUbcbE= diff --git a/internal/api/activity/app.go b/internal/api/activity/app.go index f137565..f09b88c 100644 --- a/internal/api/activity/app.go +++ b/internal/api/activity/app.go @@ -1,32 +1,39 @@ package app import ( - "bindbox-game/internal/pkg/logger" - "bindbox-game/internal/repository/mysql" - "bindbox-game/internal/repository/mysql/dao" - activitysvc "bindbox-game/internal/service/activity" - syscfgsvc "bindbox-game/internal/service/sysconfig" - usersvc "bindbox-game/internal/service/user" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/repository/mysql" + "bindbox-game/internal/repository/mysql/dao" + activitysvc "bindbox-game/internal/service/activity" + syscfgsvc "bindbox-game/internal/service/sysconfig" + titlesvc "bindbox-game/internal/service/title" + usersvc "bindbox-game/internal/service/user" + + "github.com/redis/go-redis/v9" ) type handler struct { - logger logger.CustomLogger - writeDB *dao.Query - readDB *dao.Query - activity activitysvc.Service - syscfg syscfgsvc.Service - repo mysql.Repo - user usersvc.Service + logger logger.CustomLogger + writeDB *dao.Query + readDB *dao.Query + activity activitysvc.Service + syscfg syscfgsvc.Service + title titlesvc.Service + repo mysql.Repo + user usersvc.Service + redis *redis.Client } -func New(logger logger.CustomLogger, db mysql.Repo) *handler { - return &handler{ - logger: logger, - writeDB: dao.Use(db.GetDbW()), - readDB: dao.Use(db.GetDbR()), - activity: activitysvc.New(logger, db), - syscfg: syscfgsvc.New(logger, db), - repo: db, - user: usersvc.New(logger, db), - } +func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler { + return &handler{ + logger: logger, + writeDB: dao.Use(db.GetDbW()), + readDB: dao.Use(db.GetDbR()), + activity: activitysvc.New(logger, db), + syscfg: syscfgsvc.New(logger, db), + title: titlesvc.New(logger, db), + repo: db, + user: usersvc.New(logger, db), + redis: rdb, + } } diff --git a/internal/api/activity/draw_logs_app.go b/internal/api/activity/draw_logs_app.go index eb584cb..99395fb 100644 --- a/internal/api/activity/draw_logs_app.go +++ b/internal/api/activity/draw_logs_app.go @@ -3,6 +3,7 @@ package app import ( "net/http" "strconv" + "time" "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" @@ -82,3 +83,115 @@ func (h *handler) ListDrawLogs() core.HandlerFunc { ctx.Payload(res) } } + +// ListDrawLogsByLevel 按奖品等级分类的抽奖记录 +// @Summary 按奖品等级分类的抽奖记录 +// @Description 查看指定活动期数的抽奖记录,按奖品等级分组返回 +// @Tags APP端.活动 +// @Accept json +// @Produce json +// @Param activity_id path integer true "活动ID" +// @Param issue_id path integer true "期ID" +// @Success 200 {object} listDrawLogsByLevelResponse +// @Failure 400 {object} code.Failure +// @Router /api/app/activities/{activity_id}/issues/{issue_id}/draw_logs_grouped [get] +func (h *handler) ListDrawLogsByLevel() core.HandlerFunc { + return func(ctx core.Context) { + issueID, err := strconv.ParseInt(ctx.Param("issue_id"), 10, 64) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递期ID")) + return + } + + // 1. 获取所有中奖记录 + // 我们假设这里不需要分页,或者分页逻辑比较复杂(每个等级分页?)。 + // 根据需求描述“按奖品等级进行归类”,通常 implied 展示所有或者前N个。 + // 这里暂且获取所有(或者一个较大的限制),然后在内存中分组。 + // 如果数据量巨大,需要由 Service 层提供 Group By 查询。 + // 考虑到单期中奖人数通常有限(除非是大规模活动),先尝试获取列表后分组。 + logs, _, err := h.activity.ListDrawLogs(ctx.RequestContext(), issueID, 1, 1000) // 假设最多显示1000条中奖记录 + if err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListDrawLogsError, err.Error())) + return + } + + // 2. 获取奖品配置以获取等级名称 + rewards, err := h.activity.ListIssueRewards(ctx.RequestContext(), issueID) + if err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.GetActivityError, err.Error())) + return + } + + levelNameMap := make(map[int32]string) + for _, r := range rewards { + // 通常 Level 1 是大奖,名字如 "一等奖" + // 也有可能 ActivityRewardSettings 里没有直接存 "一等奖" 这种字样,而是 Name="iPhone 15"。 + // 如果需要 "一等奖" 这种分类名,可能需要额外配置或从 Name 推断。 + // 此处暂且使用 Reward 的 Name 作为 fallback,或者如果有 LevelName 字段最好。 + // 查看 model 定义,ActivityRewardSettings 只有 Name。 + // 假如用户希望看到的是 "一等奖", "二等奖",我们需要确立 Level 到 显示名的映射。 + // 现阶段简单起见,我们将同一 Level 的奖品视为一组。 + // 组名可以使用该 Level 下任意一个奖品的 Name,或者如果不一致,则可能需要前端映射。 + // 为了通用性,我们返回 level 值,并尝试找到一个代表性的 Name。 + if _, ok := levelNameMap[r.Level]; !ok { + levelNameMap[r.Level] = r.Name // 简单取第一个遇到的名字 + } + } + + // 3. 分组 (只显示5分钟前的记录) + groupsMap := make(map[int32][]drawLogItem) + fiveMinutesAgo := time.Now().Add(-5 * time.Minute) + for _, v := range logs { + // 过滤掉5分钟内的记录 + if v.CreatedAt.After(fiveMinutesAgo) { + continue + } + if v.IsWinner == 1 { + item := drawLogItem{ + ID: v.ID, + UserID: v.UserID, + IssueID: v.IssueID, + OrderID: v.OrderID, + RewardID: v.RewardID, + IsWinner: v.IsWinner, + Level: v.Level, + CurrentLevel: v.CurrentLevel, + } + groupsMap[v.Level] = append(groupsMap[v.Level], item) + } + } + + // 4. 构造响应 + var resp listDrawLogsByLevelResponse + for level, items := range groupsMap { + group := drawLogGroup{ + Level: level, + LevelName: levelNameMap[level], + List: items, + } + resp.Groups = append(resp.Groups, group) + } + + // 排序 Groups (Level 升序? 也就是 1等奖在前) + // 简单的冒泡排序或 slice sort + for i := 0; i < len(resp.Groups)-1; i++ { + for j := 0; j < len(resp.Groups)-1-i; j++ { + if resp.Groups[j].Level > resp.Groups[j+1].Level { + resp.Groups[j], resp.Groups[j+1] = resp.Groups[j+1], resp.Groups[j] + } + } + } + + ctx.Payload(resp) + } +} + +type drawLogGroup struct { + Level int32 `json:"level"` + LevelName string `json:"level_name"` + List []drawLogItem `json:"list"` +} + +type listDrawLogsByLevelResponse struct { + Groups []drawLogGroup `json:"groups"` +} diff --git a/internal/api/activity/lottery_app.go b/internal/api/activity/lottery_app.go index a39baa0..80cdda5 100644 --- a/internal/api/activity/lottery_app.go +++ b/internal/api/activity/lottery_app.go @@ -10,10 +10,13 @@ import ( "crypto/hmac" "crypto/sha256" "encoding/base64" + "encoding/json" "fmt" "math/rand" "net/http" "time" + + titlesvc "bindbox-game/internal/service/title" ) type joinLotteryRequest struct { @@ -69,6 +72,11 @@ func (h *handler) JoinLottery() core.HandlerFunc { ctx.AbortWithError(core.Error(http.StatusBadRequest, 170010, "本活动不支持道具卡")) return } + // Ichiban Restriction: No Item Cards allowed (even if Activity logic might technically allow it, enforce strict rule here) + if activity.PlayType == "ichiban" && req.ItemCardID != nil && *req.ItemCardID > 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 170010, "一番赏活动不支持道具卡")) + return + } cfgMode := "scheduled" if activity.DrawMode != "" { cfgMode = activity.DrawMode @@ -117,6 +125,61 @@ func (h *handler) JoinLottery() core.HandlerFunc { applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *req.CouponID) fmt.Printf("[抽奖下单] 优惠后 实付(分)=%d 累计优惠(分)=%d 备注=%s\n", order.ActualAmount, order.DiscountAmount, order.Remark) } + // Title Discount Logic + // 1. Fetch active effects for this user, scoped to this activity/issue/category + // Note: Category ID is not readily available on Activity struct in this scope easily without join, skipping detailed scope for now or fetch if needed. + // Assuming scope by ActivityID and IssueID is enough. + titleEffects, _ := h.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{ + ActivityID: &req.ActivityID, + IssueID: &req.IssueID, + }) + + // 2. Apply Type=2 (Discount) effects + for _, ef := range titleEffects { + if ef.EffectType == 2 { + // Parse ParamsJSON: {"discount_type":"percentage","value_x1000":...,"max_discount_x1000":...} + // Simple parsing here or helper + var p struct { + DiscountType string `json:"discount_type"` + ValueX1000 int64 `json:"value_x1000"` + MaxDiscountX1000 int64 `json:"max_discount_x1000"` + } + if jsonErr := json.Unmarshal([]byte(ef.ParamsJSON), &p); jsonErr == nil { + var discount int64 + if p.DiscountType == "percentage" { + // e.g. 900 = 90% (10% off), or value_x1000 is the discount rate? + // Usually "value" is what you pay? Title "8折" -> value=800? + // Let's assume value_x1000 is the DISCOUNT amount (e.g. 200 = 20% off). + // Wait, standard is usually "multiplier". Title "Discount" usually means "Cut". + // Let's look at `ValidateEffectParams`: "percentage" or "fixed". + // Assume ValueX1000 is discount ratio. 200 = 20% off. + discount = order.ActualAmount * p.ValueX1000 / 1000 + } else if p.DiscountType == "fixed" { + discount = p.ValueX1000 // In cents + } + + if p.MaxDiscountX1000 > 0 && discount > p.MaxDiscountX1000 { + discount = p.MaxDiscountX1000 + } + + if discount > order.ActualAmount { + discount = order.ActualAmount + } + + if discount > 0 { + order.ActualAmount -= discount + fmt.Printf("[抽奖下单] Title Discount Applied: -%d (EffectID: %d)\n", discount, ef.ID) + // Append to remark or separate logging? + if order.Remark == "" { + order.Remark = fmt.Sprintf("title_discount:%d:%d", ef.ID, discount) + } else { + order.Remark += fmt.Sprintf("|title_discount:%d:%d", ef.ID, discount) + } + } + } + } + } + if req.UsePoints != nil && *req.UsePoints > 0 { bal, _ := h.user.GetPointsBalance(ctx.RequestContext(), userID) usePts := *req.UsePoints @@ -300,28 +363,44 @@ func (h *handler) JoinLottery() core.HandlerFunc { if e != nil { break } - rid, _, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), req.ActivityID, req.IssueID, slot) - } else { - rid, _, e2 = sel.SelectItem(ctx.RequestContext(), req.ActivityID, req.IssueID, userID) + } + } + var proof map[string]any + var rw *model.ActivityRewardSettings + var log *model.ActivityDrawLogs + if activity.PlayType == "ichiban" { + slot := parseSlotFromRemark(ord.Remark) + rid, proof, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), req.ActivityID, req.IssueID, slot) + if e2 == nil && rid > 0 { + rw, _ = h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() + if rw != nil { + _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid) + // 创建抽奖日志 + log = &model.ActivityDrawLogs{UserID: userID, IssueID: req.IssueID, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} + _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log) + // 保存凭证 + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, req.IssueID, userID, proof) + // ... 道具卡逻辑 + } } } else { - rid, _, e2 = sel.SelectItem(ctx.RequestContext(), req.ActivityID, req.IssueID, userID) + rid, proof, e2 = sel.SelectItem(ctx.RequestContext(), req.ActivityID, req.IssueID, userID) + if e2 == nil && rid > 0 { + rw, _ = h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() + if rw != nil { + _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &req.ActivityID, RewardID: &rid, Remark: rw.Name}) + // 创建抽奖日志 + log = &model.ActivityDrawLogs{UserID: userID, IssueID: req.IssueID, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} + _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log) + // 保存凭证 + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, req.IssueID, userID, proof) + // ... 道具卡逻辑 + } + } } - if e2 != nil || rid <= 0 { + if log == nil { break } - rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() - if rw == nil { - break - } - if activity.PlayType == "ichiban" { - _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid) - } else { - _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &req.ActivityID, RewardID: &rid, Remark: rw.Name}) - } - // 创建抽奖日志(获取ID用于道具卡核销) - log := &model.ActivityDrawLogs{UserID: userID, IssueID: req.IssueID, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log) // 道具卡效果(奖励倍数/概率提升的简单实现:奖励倍数=额外发同奖品;概率提升=尝试升级到更高等级) fmt.Printf("[道具卡-JoinLottery] 开始检查 活动允许道具卡=%t 请求道具卡ID=%v\n", activity.AllowItemCards, req.ItemCardID) if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 { @@ -367,7 +446,17 @@ func (h *handler) JoinLottery() core.HandlerFunc { } else { _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: better.ProductID, Quantity: 1, ActivityID: &req.ActivityID, RewardID: &rid2, Remark: better.Name + "(升级)"}) } - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: userID, IssueID: req.IssueID, OrderID: ord.ID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1}) + // 创建抽奖日志并保存凭据 + drawLog := &model.ActivityDrawLogs{UserID: userID, IssueID: req.IssueID, OrderID: ord.ID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1} + if err := h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog); err != nil { + fmt.Printf("[道具卡-JoinLottery] ❌ 创建抽奖日志失败 err=%v\n", err) + } else { + if err := strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog.ID, req.IssueID, userID, proof); err != nil { + fmt.Printf("[道具卡-JoinLottery] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v\n", drawLog.ID, req.IssueID, userID, err) + } else { + fmt.Printf("[道具卡-JoinLottery] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, req.IssueID) + } + } } else { fmt.Printf("[道具卡-JoinLottery] 概率提升未触发\n") } @@ -515,12 +604,15 @@ func (h *handler) GetLotteryResult() core.HandlerFunc { slot := parseSlotFromRemark(ord.Remark) if slot >= 0 { if err := h.repo.GetDbW().Exec("INSERT INTO issue_position_claims (issue_id, slot_index, user_id, order_id, created_at) VALUES (?,?,?,?,NOW(3))", issueID, slot, userID, orderID).Error; err == nil { - rid, _, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), activityID, issueID, slot) + var proof map[string]any + rid, proof, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), activityID, issueID, slot) if e2 == nil && rid > 0 { rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() if rw != nil { _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid) - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: orderID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1}) + log := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: orderID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} + _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log) + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, issueID, userID, proof) rsp.Result = map[string]any{"reward_id": rid, "reward_name": rw.Name} } } @@ -528,7 +620,8 @@ func (h *handler) GetLotteryResult() core.HandlerFunc { } } else { sel := strat.NewDefault(h.readDB, h.writeDB) - rid, _, e2 := sel.SelectItem(ctx.RequestContext(), activityID, issueID, userID) + var proof map[string]any + rid, proof, e2 := sel.SelectItem(ctx.RequestContext(), activityID, issueID, userID) if e2 == nil && rid > 0 { rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() _ = sel.GrantReward(ctx.RequestContext(), userID, rid) @@ -539,6 +632,7 @@ func (h *handler) GetLotteryResult() core.HandlerFunc { return 1 }(), CurrentLevel: 1} _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log) + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, issueID, userID, proof) icID := parseItemCardIDFromRemark(ord.Remark) fmt.Printf("[道具卡-GetLotteryResult] 从订单备注解析道具卡ID icID=%d 订单备注=%s\n", icID, ord.Remark) if icID > 0 { @@ -591,7 +685,17 @@ func (h *handler) GetLotteryResult() core.HandlerFunc { rid2 := better.ID fmt.Printf("[道具卡-GetLotteryResult] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", rid2, better.Name) _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: orderID, ProductID: better.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid2, Remark: better.Name + "(升级)"}) - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: orderID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1}) + // 创建升级后的抽奖日志并保存凭据 + drawLog2 := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: orderID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1} + if err := h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog2); err != nil { + fmt.Printf("[道具卡-GetLotteryResult] ❌ 创建升级抽奖日志失败 err=%v\n", err) + } else { + if err := strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog2.ID, issueID, userID, proof); err != nil { + fmt.Printf("[道具卡-GetLotteryResult] ⚠️ 保存升级凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v\n", drawLog2.ID, issueID, userID, err) + } else { + fmt.Printf("[道具卡-GetLotteryResult] ✅ 保存升级凭据成功 DrawLogID=%d IssueID=%d\n", drawLog2.ID, issueID) + } + } } else { fmt.Printf("[道具卡-GetLotteryResult] 概率提升未触发\n") } @@ -1065,115 +1169,132 @@ func (h *handler) processInstantDraw(ctx core.Context, userID int64, activity *m rid := int64(0) var e2 error if activity.PlayType == "ichiban" { - slot := func() int64 { - if len(slotsIdx) > 0 && len(slotsIdx) == len(rem) { - for cur < len(rem) && rem[cur] == 0 { - cur++ + // ... (inside loop) + var proof map[string]any + if activity.PlayType == "ichiban" { + slot := func() int64 { + // ... (existing slot parsing logic) + if len(slotsIdx) > 0 && len(slotsIdx) == len(rem) { + for cur < len(rem) && rem[cur] == 0 { + cur++ + } + if cur >= len(rem) { + return -1 + } + rem[cur]-- + return slotsIdx[cur] - 1 } - if cur >= len(rem) { - return -1 + return parseSlotFromRemark(ord.Remark) + }() + if slot >= 0 { + var cnt int64 + _ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id=? AND slot_index=?", issueID, slot).Scan(&cnt).Error + if cnt > 0 { + break } - rem[cur]-- - return slotsIdx[cur] - 1 + e := h.repo.GetDbW().Exec("INSERT INTO issue_position_claims (issue_id, slot_index, user_id, order_id, created_at) VALUES (?,?,?,?,NOW(3))", issueID, slot, userID, ord.ID).Error + if e != nil { + break + } + rid, proof, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), activityID, issueID, slot) + } else { + rid, proof, e2 = sel.SelectItem(ctx.RequestContext(), activityID, issueID, userID) } - return parseSlotFromRemark(ord.Remark) - }() - if slot >= 0 { - var cnt int64 - _ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id=? AND slot_index=?", issueID, slot).Scan(&cnt).Error - if cnt > 0 { - break - } - e := h.repo.GetDbW().Exec("INSERT INTO issue_position_claims (issue_id, slot_index, user_id, order_id, created_at) VALUES (?,?,?,?,NOW(3))", issueID, slot, userID, ord.ID).Error - if e != nil { - break - } - rid, _, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), activityID, issueID, slot) } else { - rid, _, e2 = sel.SelectItem(ctx.RequestContext(), activityID, issueID, userID) + rid, proof, e2 = sel.SelectItem(ctx.RequestContext(), activityID, issueID, userID) } - } else { - rid, _, e2 = sel.SelectItem(ctx.RequestContext(), activityID, issueID, userID) - } - if e2 != nil || rid <= 0 { - break - } - rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() - if rw == nil { - break - } - if activity.PlayType == "ichiban" { - _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid) - } else { - _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid, Remark: rw.Name}) - } - log := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log) - fmt.Printf("[道具卡-processInstantDraw] 开始检查 活动允许道具卡=%t itemCardID=%v\n", activity.AllowItemCards, itemCardID) - if activity.AllowItemCards && itemCardID != nil && *itemCardID > 0 { - fmt.Printf("[道具卡-processInstantDraw] 查询用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, *itemCardID) - uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(*itemCardID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).First() - if uic != nil { - fmt.Printf("[道具卡-processInstantDraw] 找到用户道具卡 ID=%d CardID=%d Status=%d ValidStart=%s ValidEnd=%s\n", uic.ID, uic.CardID, uic.Status, uic.ValidStart.Format(time.RFC3339), uic.ValidEnd.Format(time.RFC3339)) - ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.SystemItemCards.ID.Eq(uic.CardID), h.readDB.SystemItemCards.Status.Eq(1)).First() - now := time.Now() - if ic != nil { - fmt.Printf("[道具卡-processInstantDraw] 找到系统道具卡 ID=%d Name=%s EffectType=%d RewardMultiplierX1000=%d ScopeType=%d ActivityID=%d IssueID=%d Status=%d\n", ic.ID, ic.Name, ic.EffectType, ic.RewardMultiplierX1000, ic.ScopeType, ic.ActivityID, ic.IssueID, ic.Status) - fmt.Printf("[道具卡-processInstantDraw] 时间检查 当前时间=%s ValidStart.After(now)=%t ValidEnd.Before(now)=%t\n", now.Format(time.RFC3339), uic.ValidStart.After(now), uic.ValidEnd.Before(now)) - if !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) { - scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == activityID) || (ic.ScopeType == 4 && ic.IssueID == issueID) - fmt.Printf("[道具卡-processInstantDraw] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, activityID, issueID, scopeOK) - if scopeOK { - if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { - fmt.Printf("[道具卡-processInstantDraw] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s PlayType=%s\n", ic.RewardMultiplierX1000, rid, rw.Name, activity.PlayType) - if activity.PlayType == "ichiban" { - _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid) - } else { - _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid, Remark: rw.Name + "(倍数)"}) - } - fmt.Printf("[道具卡-processInstantDraw] ✅ 双倍奖励发放完成\n") - } else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 { - fmt.Printf("[道具卡-processInstantDraw] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000) - uprw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.IssueID.Eq(issueID)).Find() - var better *model.ActivityRewardSettings - for _, r := range uprw { - if r.Level < rw.Level && r.Quantity != 0 { - if better == nil || r.Level < better.Level { - better = r + if e2 != nil || rid <= 0 { + break + } + rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() + if rw == nil { + break + } + if activity.PlayType == "ichiban" { + _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid) + } else { + _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid, Remark: rw.Name}) + } + log := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} + _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(log) + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, issueID, userID, proof) + fmt.Printf("[道具卡-processInstantDraw] 开始检查 活动允许道具卡=%t itemCardID=%v\n", activity.AllowItemCards, itemCardID) + if activity.AllowItemCards && itemCardID != nil && *itemCardID > 0 { + fmt.Printf("[道具卡-processInstantDraw] 查询用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, *itemCardID) + uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(*itemCardID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).First() + if uic != nil { + fmt.Printf("[道具卡-processInstantDraw] 找到用户道具卡 ID=%d CardID=%d Status=%d ValidStart=%s ValidEnd=%s\n", uic.ID, uic.CardID, uic.Status, uic.ValidStart.Format(time.RFC3339), uic.ValidEnd.Format(time.RFC3339)) + ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.SystemItemCards.ID.Eq(uic.CardID), h.readDB.SystemItemCards.Status.Eq(1)).First() + now := time.Now() + if ic != nil { + fmt.Printf("[道具卡-processInstantDraw] 找到系统道具卡 ID=%d Name=%s EffectType=%d RewardMultiplierX1000=%d ScopeType=%d ActivityID=%d IssueID=%d Status=%d\n", ic.ID, ic.Name, ic.EffectType, ic.RewardMultiplierX1000, ic.ScopeType, ic.ActivityID, ic.IssueID, ic.Status) + fmt.Printf("[道具卡-processInstantDraw] 时间检查 当前时间=%s ValidStart.After(now)=%t ValidEnd.Before(now)=%t\n", now.Format(time.RFC3339), uic.ValidStart.After(now), uic.ValidEnd.Before(now)) + if !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) { + scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == activityID) || (ic.ScopeType == 4 && ic.IssueID == issueID) + fmt.Printf("[道具卡-processInstantDraw] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, activityID, issueID, scopeOK) + if scopeOK { + if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 { + fmt.Printf("[道具卡-processInstantDraw] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s PlayType=%s\n", ic.RewardMultiplierX1000, rid, rw.Name, activity.PlayType) + if activity.PlayType == "ichiban" { + _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid) + } else { + _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid, Remark: rw.Name + "(倍数)"}) + } + fmt.Printf("[道具卡-processInstantDraw] ✅ 双倍奖励发放完成\n") + } else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 { + fmt.Printf("[道具卡-processInstantDraw] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000) + uprw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.IssueID.Eq(issueID)).Find() + var better *model.ActivityRewardSettings + for _, r := range uprw { + if r.Level < rw.Level && r.Quantity != 0 { + if better == nil || r.Level < better.Level { + better = r + } } } - } - if better != nil && rand.Int31n(1000) < ic.BoostRateX1000 { - rid2 := better.ID - fmt.Printf("[道具卡-processInstantDraw] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", rid2, better.Name) - if activity.PlayType == "ichiban" { - _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid2) + if better != nil && rand.Int31n(1000) < ic.BoostRateX1000 { + rid2 := better.ID + fmt.Printf("[道具卡-processInstantDraw] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", rid2, better.Name) + if activity.PlayType == "ichiban" { + _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid2) + } else { + _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: better.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid2, Remark: better.Name + "(升级)"}) + } + // 创建升级后的抽奖日志并保存凭据 + drawLog2 := &model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: ord.ID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1} + if err := h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog2); err != nil { + fmt.Printf("[道具卡-processInstantDraw] ❌ 创建升级抽奖日志失败 err=%v\n", err) + } else { + if err := strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog2.ID, issueID, userID, proof); err != nil { + fmt.Printf("[道具卡-processInstantDraw] ⚠️ 保存升级凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v\n", drawLog2.ID, issueID, userID, err) + } else { + fmt.Printf("[道具卡-processInstantDraw] ✅ 保存升级凭据成功 DrawLogID=%d IssueID=%d\n", drawLog2.ID, issueID) + } + } } else { - _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: better.ProductID, Quantity: 1, ActivityID: &activityID, RewardID: &rid2, Remark: better.Name + "(升级)"}) + fmt.Printf("[道具卡-processInstantDraw] 概率提升未触发\n") } - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: userID, IssueID: issueID, OrderID: ord.ID, RewardID: rid2, IsWinner: 1, Level: better.Level, CurrentLevel: 1}) } else { - fmt.Printf("[道具卡-processInstantDraw] 概率提升未触发\n") + fmt.Printf("[道具卡-processInstantDraw] ⚠️ 道具卡效果类型不匹配或倍数不足 EffectType=%d RewardMultiplierX1000=%d\n", ic.EffectType, ic.RewardMultiplierX1000) } + fmt.Printf("[道具卡-processInstantDraw] 核销道具卡 用户道具卡ID=%d\n", *itemCardID) + _, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(*itemCardID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).Updates(map[string]any{h.readDB.UserItemCards.Status.ColumnName().String(): 2, h.readDB.UserItemCards.UsedDrawLogID.ColumnName().String(): log.ID, h.readDB.UserItemCards.UsedActivityID.ColumnName().String(): activityID, h.readDB.UserItemCards.UsedIssueID.ColumnName().String(): issueID, h.readDB.UserItemCards.UsedAt.ColumnName().String(): time.Now()}) } else { - fmt.Printf("[道具卡-processInstantDraw] ⚠️ 道具卡效果类型不匹配或倍数不足 EffectType=%d RewardMultiplierX1000=%d\n", ic.EffectType, ic.RewardMultiplierX1000) + fmt.Printf("[道具卡-processInstantDraw] ❌ 范围检查失败\n") } - fmt.Printf("[道具卡-processInstantDraw] 核销道具卡 用户道具卡ID=%d\n", *itemCardID) - _, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(h.readDB.UserItemCards.ID.Eq(*itemCardID), h.readDB.UserItemCards.UserID.Eq(userID), h.readDB.UserItemCards.Status.Eq(1)).Updates(map[string]any{h.readDB.UserItemCards.Status.ColumnName().String(): 2, h.readDB.UserItemCards.UsedDrawLogID.ColumnName().String(): log.ID, h.readDB.UserItemCards.UsedActivityID.ColumnName().String(): activityID, h.readDB.UserItemCards.UsedIssueID.ColumnName().String(): issueID, h.readDB.UserItemCards.UsedAt.ColumnName().String(): time.Now()}) } else { - fmt.Printf("[道具卡-processInstantDraw] ❌ 范围检查失败\n") + fmt.Printf("[道具卡-processInstantDraw] ❌ 时间检查失败\n") } } else { - fmt.Printf("[道具卡-processInstantDraw] ❌ 时间检查失败\n") + fmt.Printf("[道具卡-processInstantDraw] ❌ 未找到系统道具卡 CardID=%d\n", uic.CardID) } } else { - fmt.Printf("[道具卡-processInstantDraw] ❌ 未找到系统道具卡 CardID=%d\n", uic.CardID) + fmt.Printf("[道具卡-processInstantDraw] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, *itemCardID) } } else { - fmt.Printf("[道具卡-processInstantDraw] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", userID, *itemCardID) + fmt.Printf("[道具卡-processInstantDraw] 跳过道具卡检查\n") } - } else { - fmt.Printf("[道具卡-processInstantDraw] 跳过道具卡检查\n") } } + } diff --git a/internal/api/activity/lottery_result_order_app.go b/internal/api/activity/lottery_result_order_app.go index 7bcc784..43c17f0 100644 --- a/internal/api/activity/lottery_result_order_app.go +++ b/internal/api/activity/lottery_result_order_app.go @@ -129,12 +129,15 @@ func (h *handler) LotteryResultByOrder() core.HandlerFunc { if cnt > 0 { st = "slot_unavailable" } else if err := h.repo.GetDbW().Exec("INSERT INTO issue_position_claims (issue_id, slot_index, user_id, order_id, created_at) VALUES (?,?,?,?,NOW(3))", iss, slot, userID, ord.ID).Error; err == nil { - rid, _, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), act.ID, iss, slot) + var proof map[string]any + rid, proof, e2 = strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), act.ID, iss, slot) if e2 == nil && rid > 0 { rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() if rw != nil { _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), userID, rid) - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: userID, IssueID: iss, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1}) + drawLog := &model.ActivityDrawLogs{UserID: userID, IssueID: iss, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} + _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog) + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog.ID, iss, userID, proof) completed++ items = append(items, orderResultItem{RewardID: rid, RewardName: rw.Name, Level: rw.Level, DrawIndex: int32(completed)}) } @@ -143,12 +146,15 @@ func (h *handler) LotteryResultByOrder() core.HandlerFunc { } } else { sel := strat.NewDefault(h.readDB, h.writeDB) - rid, _, e2 = sel.SelectItem(ctx.RequestContext(), act.ID, iss, userID) + var proof map[string]any + rid, proof, e2 = sel.SelectItem(ctx.RequestContext(), act.ID, iss, userID) if e2 == nil && rid > 0 { rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() if rw != nil { _, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), userID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &act.ID, RewardID: &rid, Remark: rw.Name}) - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: userID, IssueID: iss, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1}) + drawLog := &model.ActivityDrawLogs{UserID: userID, IssueID: iss, OrderID: ord.ID, RewardID: rid, IsWinner: 1, Level: rw.Level, CurrentLevel: 1} + _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog) + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog.ID, iss, userID, proof) completed++ items = append(items, orderResultItem{RewardID: rid, RewardName: rw.Name, Level: rw.Level, DrawIndex: int32(completed)}) } @@ -256,22 +262,44 @@ func parseIssueIDFromRemark(remark string) int64 { if remark == "" { return 0 } - p := 0 + // Try "issue:" or "matching_game:issue:" + // Split by | + segs := make([]string, 0) + last := 0 for i := 0; i < len(remark); i++ { if remark[i] == '|' { - seg := remark[p:i] - if len(seg) > 6 && seg[:6] == "issue:" { - var n int64 - for j := 6; j < len(seg); j++ { - c := seg[j] - if c < '0' || c > '9' { - break - } - n = n*10 + int64(c-'0') + segs = append(segs, remark[last:i]) + last = i + 1 + } + } + if last < len(remark) { + segs = append(segs, remark[last:]) + } + + for _, seg := range segs { + // handle 'issue:123' + if len(seg) > 6 && seg[:6] == "issue:" { + var n int64 + for j := 6; j < len(seg); j++ { + c := seg[j] + if c < '0' || c > '9' { + break } - return n + n = n*10 + int64(c-'0') } - p = i + 1 + return n + } + // handle 'matching_game:issue:123' + if len(seg) > 20 && seg[:20] == "matching_game:issue:" { + var n int64 + for j := 20; j < len(seg); j++ { + c := seg[j] + if c < '0' || c > '9' { + break + } + n = n*10 + int64(c-'0') + } + return n } } return 0 diff --git a/internal/api/activity/matching_game_app.go b/internal/api/activity/matching_game_app.go index 3ae25aa..6a3f6d3 100644 --- a/internal/api/activity/matching_game_app.go +++ b/internal/api/activity/matching_game_app.go @@ -3,15 +3,27 @@ package app import ( "bindbox-game/internal/code" "bindbox-game/internal/pkg/core" + "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/pkg/validation" "bindbox-game/internal/repository/mysql/dao" + "bindbox-game/internal/repository/mysql/model" "context" "crypto/hmac" "crypto/rand" "crypto/sha256" "encoding/binary" + "encoding/hex" + "encoding/json" "fmt" "net/http" + "sync" "time" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + + titlesvc "bindbox-game/internal/service/title" + usersvc "bindbox-game/internal/service/user" ) // CardType 卡牌类型 @@ -19,46 +31,61 @@ type CardType string // CardTypeConfig 卡牌类型配置(从数据库加载) type CardTypeConfig struct { - Code CardType - Name string - ImageURL string - Quantity int32 + Code CardType `json:"code"` + Name string `json:"name"` + ImageURL string `json:"image_url"` + Quantity int32 `json:"quantity"` +} + +// MatchingCard 游戏中的卡牌实例 +type MatchingCard struct { + ID string `json:"id"` + Type CardType `json:"type"` } // MatchingGame 对对碰游戏结构 type MatchingGame struct { - serverSeed []byte - serverSeedHash string - nonce int64 - cardConfigs []CardTypeConfig - cards []CardType - hand []CardType - deck []CardType - totalPairs int64 - round int64 - roundHistory []MatchingRoundResult -} + Mu sync.Mutex `json:"-"` // 互斥锁保护并发访问 + ServerSeed []byte `json:"server_seed"` + ServerSeedHash string `json:"server_seed_hash"` + Nonce int64 `json:"nonce"` + CardConfigs []CardTypeConfig `json:"card_configs"` + Deck []*MatchingCard `json:"deck"` // 牌堆 (预生成的卡牌对象) + Board [9]*MatchingCard `json:"board"` // 固定9格棋盘 + CardIDCounter int64 `json:"card_id_counter"` // 用于生成唯一ID + TotalPairs int64 `json:"total_pairs"` + MaxPossiblePairs int64 `json:"max_possible_pairs"` // 最大可能消除对数 (安全校验) + Round int64 `json:"round"` + RoundHistory []MatchingRoundResult `json:"round_history"` + LastActivity time.Time `json:"last_activity"` -type MatchingCard struct { - ID int `json:"id"` - Type CardType `json:"type"` - Name string `json:"name,omitempty"` - ImageURL string `json:"image_url,omitempty"` + // Context info for reward granting + ActivityID int64 `json:"activity_id"` + IssueID int64 `json:"issue_id"` + OrderID int64 `json:"order_id"` + UserID int64 `json:"user_id"` } type MatchingRoundResult struct { - Round int64 `json:"round"` - HandBefore []CardType `json:"hand_before"` - Pairs []MatchingPair `json:"pairs"` - PairsCount int64 `json:"pairs_count"` - DrawnCards []CardType `json:"drawn_cards"` - HandAfter []CardType `json:"hand_after"` - CanContinue bool `json:"can_continue"` + Round int64 `json:"round"` + Board [9]*MatchingCard `json:"board"` + Pairs []MatchingPair `json:"pairs"` + PairsCount int64 `json:"pairs_count"` + DrawnCards []DrawnCardInfo `json:"drawn_cards"` // 优化:包含位置信息 + Reshuffled bool `json:"reshuffled"` + CanContinue bool `json:"can_continue"` +} + +type DrawnCardInfo struct { + SlotIndex int `json:"slot_index"` + Card MatchingCard `json:"card"` } type MatchingPair struct { - CardType CardType `json:"card_type"` - Count int64 `json:"count"` + CardType CardType `json:"card_type"` + Count int64 `json:"count"` + CardIDs []string `json:"card_ids"` + SlotIndices []int `json:"slot_indices"` // 新增:消除的格子索引 } // loadCardTypesFromDB 从数据库加载启用的卡牌类型配置 @@ -80,226 +107,568 @@ func loadCardTypesFromDB(ctx context.Context, readDB *dao.Query) ([]CardTypeConf } // NewMatchingGameWithConfig 使用数据库配置创建游戏 -func NewMatchingGameWithConfig(configs []CardTypeConfig) *MatchingGame { +// NewMatchingGameWithConfig 使用数据库配置创建游戏 +// position: 用户选择的位置(可选),用于增加随机熵值 +func NewMatchingGameWithConfig(configs []CardTypeConfig, position string) *MatchingGame { g := &MatchingGame{ - cardConfigs: configs, - roundHistory: []MatchingRoundResult{}, + CardConfigs: configs, + RoundHistory: []MatchingRoundResult{}, + Board: [9]*MatchingCard{}, + LastActivity: time.Now(), } // 生成服务器种子 - g.serverSeed = make([]byte, 32) - rand.Read(g.serverSeed) - hash := sha256.Sum256(g.serverSeed) - g.serverSeedHash = fmt.Sprintf("%x", hash) + g.ServerSeed = make([]byte, 32) + rand.Read(g.ServerSeed) - // 根据配置生成卡牌 + // 如果有 position 参数,将其混入种子逻辑 + if position != "" { + // 使用 SHA256 (seed + position + timestamp) 生成新的混合种子 + h := sha256.New() + h.Write(g.ServerSeed) + h.Write([]byte(position)) + // 还可以加个时间戳确保不仅仅依赖 position + h.Write([]byte(fmt.Sprintf("%d", time.Now().UnixNano()))) + g.ServerSeed = h.Sum(nil) + } + + hash := sha256.Sum256(g.ServerSeed) + g.ServerSeedHash = fmt.Sprintf("%x", hash) + + // 根据配置生成所有卡牌 (99张) totalCards := 0 for _, cfg := range configs { totalCards += int(cfg.Quantity) } - g.cards = make([]CardType, 0, totalCards) + // 创建所有卡牌对象 + g.CardIDCounter = 0 + allCards := make([]*MatchingCard, 0, totalCards) for _, cfg := range configs { for i := int32(0); i < cfg.Quantity; i++ { - g.cards = append(g.cards, cfg.Code) + // 创建卡牌对象 + g.CardIDCounter++ + id := fmt.Sprintf("c%d", g.CardIDCounter) + mc := &MatchingCard{ + ID: id, + Type: cfg.Code, + } + allCards = append(allCards, mc) } } + g.Deck = allCards // 安全洗牌 g.secureShuffle() - // 分配手牌和牌堆(手牌9张) - handSize := 9 - if len(g.cards) < handSize { - handSize = len(g.cards) + // 初始填充棋盘 + for i := 0; i < 9; i++ { + if len(g.Deck) > 0 { + // 从牌堆顶取一张 + card := g.Deck[0] + g.Deck = g.Deck[1:] + g.Board[i] = card + } else { + g.Board[i] = nil + } } - g.hand = g.cards[:handSize] - g.deck = g.cards[handSize:] + + // 计算理论最大对数 (Sanity Check) + // 遍历所有生成的卡牌配置 + var theoreticalMax int64 + for _, cfg := range configs { + theoreticalMax += int64(cfg.Quantity / 2) // 向下取整,每2张算1对 + } + g.MaxPossiblePairs = theoreticalMax return g } +// createMatchingCard (已废弃,改为预生成) - 但为了兼容 PlayRound 里可能的动态生成(如有),保留作为 helper? +// 不,PlayRound 现在应该直接从 deck 取对象。 +// 只需要保留 getCardConfig 即可。 + // getCardConfig 获取指定卡牌类型的配置 func (g *MatchingGame) getCardConfig(cardType CardType) *CardTypeConfig { - for i := range g.cardConfigs { - if g.cardConfigs[i].Code == cardType { - return &g.cardConfigs[i] + for i := range g.CardConfigs { + if g.CardConfigs[i].Code == cardType { + return &g.CardConfigs[i] } } return nil } -// 保留原有函数用于向后兼容(使用默认配置) -func NewMatchingGame() *MatchingGame { - defaultConfigs := []CardTypeConfig{ - {Code: "A", Name: "类型A", Quantity: 9}, - {Code: "B", Name: "类型B", Quantity: 9}, - {Code: "C", Name: "类型C", Quantity: 9}, - {Code: "D", Name: "类型D", Quantity: 9}, - {Code: "E", Name: "类型E", Quantity: 9}, - {Code: "F", Name: "类型F", Quantity: 9}, - {Code: "G", Name: "类型G", Quantity: 9}, - {Code: "H", Name: "类型H", Quantity: 9}, - {Code: "I", Name: "类型I", Quantity: 9}, - {Code: "J", Name: "类型J", Quantity: 9}, - {Code: "K", Name: "类型K", Quantity: 9}, - } - return NewMatchingGameWithConfig(defaultConfigs) -} - // secureShuffle 使用 HMAC-SHA256 的 Fisher-Yates 洗牌 func (g *MatchingGame) secureShuffle() { - n := len(g.cards) + n := len(g.Deck) for i := n - 1; i > 0; i-- { j := g.secureRandInt(i+1, fmt.Sprintf("shuffle:%d", i)) - g.cards[i], g.cards[j] = g.cards[j], g.cards[i] + g.Deck[i], g.Deck[j] = g.Deck[j], g.Deck[i] } } // secureRandInt 使用 HMAC-SHA256 生成安全随机数 func (g *MatchingGame) secureRandInt(max int, context string) int { - g.nonce++ - message := fmt.Sprintf("%s|nonce:%d", context, g.nonce) - mac := hmac.New(sha256.New, g.serverSeed) + g.Nonce++ + message := fmt.Sprintf("%s|nonce:%d", context, g.Nonce) + mac := hmac.New(sha256.New, g.ServerSeed) mac.Write([]byte(message)) sum := mac.Sum(nil) val := binary.BigEndian.Uint64(sum[:8]) return int(val % uint64(max)) } -// PlayRound 执行一轮游戏 -func (g *MatchingGame) PlayRound(isFirst bool) MatchingRoundResult { - g.round++ - handBefore := make([]CardType, len(g.hand)) - copy(handBefore, g.hand) - result := MatchingRoundResult{ - Round: g.round, - HandBefore: handBefore, +// reshuffleBoard 重洗棋盘和牌堆 +func (g *MatchingGame) reshuffleBoard() { + // 1. 回收所有卡牌(板上 + 牌堆) + tempDeck := make([]*MatchingCard, 0, len(g.Deck)+9) + tempDeck = append(tempDeck, g.Deck...) + for i := 0; i < 9; i++ { + if g.Board[i] != nil { + tempDeck = append(tempDeck, g.Board[i]) + g.Board[i] = nil + } } - // 第一轮:如果有类型A的卡牌,额外抽牌 - if isFirst { - appleCount := 0 - for _, c := range g.hand { - if c == "A" { - appleCount++ + // 2. 循环尝试洗牌,直到开局有解(或者尝试一定次数) + // 尝试最多 10 次,寻找一个起手就有解的局面 + bestDeck := make([]*MatchingCard, len(tempDeck)) + copy(bestDeck, tempDeck) + + for retry := 0; retry < 10; retry++ { + // 复制一份进行尝试 + currentDeck := make([]*MatchingCard, len(tempDeck)) + copy(currentDeck, tempDeck) + g.Deck = currentDeck + g.secureShuffle() + + // 检查前9张(或更少)是否有对子 + checkCount := 9 + if len(g.Deck) < 9 { + checkCount = len(g.Deck) + } + + counts := make(map[CardType]int) + hasPair := false + for k := 0; k < checkCount; k++ { + t := g.Deck[k].Type + counts[t]++ + if counts[t] >= 2 { + hasPair = true + break } } - if appleCount > 0 && len(g.deck) >= appleCount { - extra := g.deck[:appleCount] - g.hand = append(g.hand, extra...) - g.deck = g.deck[appleCount:] - result.DrawnCards = append(result.DrawnCards, extra...) + + if hasPair { + // 找到有解的洗牌结果,采用之 + // g.deck 已经是洗好的状态 + break } } - // 统计每种牌的数量 - counter := make(map[CardType]int) - for _, c := range g.hand { - counter[c]++ - } - - // 找出配对 - pairsCount := int64(0) - remaining := []CardType{} - for cardType, count := range counter { - pairs := count / 2 - if pairs > 0 { - pairsCount += int64(pairs) - result.Pairs = append(result.Pairs, MatchingPair{CardType: cardType, Count: int64(pairs * 2)}) - } - // 剩余单张 - if count%2 == 1 { - remaining = append(remaining, cardType) + // 3. 重新填满棋盘 + for i := 0; i < 9; i++ { + if len(g.Deck) > 0 { + card := g.Deck[0] + g.Deck = g.Deck[1:] + g.Board[i] = card } } - - result.PairsCount = pairsCount - g.totalPairs += pairsCount - - // 抽取新牌 - if pairsCount > 0 { - drawCount := int(pairsCount) - if drawCount > len(g.deck) { - drawCount = len(g.deck) - } - if drawCount > 0 { - newCards := g.deck[:drawCount] - g.deck = g.deck[drawCount:] - remaining = append(remaining, newCards...) - result.DrawnCards = append(result.DrawnCards, newCards...) - } - } - - g.hand = remaining - handAfter := make([]CardType, len(g.hand)) - copy(handAfter, g.hand) - result.HandAfter = handAfter - result.CanContinue = pairsCount > 0 - - g.roundHistory = append(g.roundHistory, result) - - return result } // GetGameState 获取游戏状态 func (g *MatchingGame) GetGameState() map[string]any { return map[string]any{ - "hand": g.hand, - "hand_count": len(g.hand), - "deck_count": len(g.deck), - "total_pairs": g.totalPairs, - "round": g.round, - "server_seed_hash": g.serverSeedHash, + "board": g.Board, + "deck_count": len(g.Deck), + "total_pairs": g.TotalPairs, + "round": g.Round, + "server_seed_hash": g.ServerSeedHash, } } // ========== API Handlers ========== -type matchingGameStartRequest struct { - IssueID int64 `json:"issue_id"` +type matchingGamePreOrderRequest struct { + IssueID int64 `json:"issue_id"` + Position string `json:"position"` + CouponID *int64 `json:"coupon_id"` + ItemCardID *int64 `json:"item_card_id"` } -type matchingGameStartResponse struct { - GameID string `json:"game_id"` - Hand []MatchingCard `json:"hand"` - DeckCount int `json:"deck_count"` - ServerSeedHash string `json:"server_seed_hash"` - FirstRound MatchingRoundResult `json:"first_round"` +type matchingGamePreOrderResponse struct { + GameID string `json:"game_id"` + OrderNo string `json:"order_no"` + PayStatus int32 `json:"pay_status"` // 1=Pending, 2=Paid + AllCards []MatchingCard `json:"all_cards"` // 全量99张卡牌(乱序) + ServerSeedHash string `json:"server_seed_hash"` } -type matchingGamePlayRequest struct { - GameID string `json:"game_id"` +type matchingGameCheckRequest struct { + GameID string `json:"game_id" binding:"required"` + TotalPairs int64 `json:"total_pairs"` // 客户端上报的消除总对数 } -type matchingGamePlayResponse struct { - Round MatchingRoundResult `json:"round"` +type MatchingRewardInfo struct { + RewardID int64 `json:"reward_id"` + Name string `json:"name"` + Level int32 `json:"level"` +} + +type matchingGameCheckResponse struct { + GameID string `json:"game_id"` TotalPairs int64 `json:"total_pairs"` - GameOver bool `json:"game_over"` - FinalState map[string]any `json:"final_state,omitempty"` + Finished bool `json:"finished"` + Reward *MatchingRewardInfo `json:"reward,omitempty"` } -// 游戏会话存储(生产环境应使用 Redis) -var gameSessionsV2 = make(map[string]*MatchingGame) +// Redis Key Prefix +const matchingGameKeyPrefix = "bindbox:matching_game:" -// StartMatchingGame 开始对对碰游戏 -// @Summary 开始对对碰游戏 -// @Description 创建新的对对碰游戏会话,返回初始手牌和第一轮结果 +// saveGameToRedis 保存游戏状态到 Redis +func (h *handler) saveGameToRedis(ctx context.Context, gameID string, game *MatchingGame) error { + data, err := json.Marshal(game) + if err != nil { + return err + } + // TTL: 30 minutes + return h.redis.Set(ctx, matchingGameKeyPrefix+gameID, data, 30*time.Minute).Err() +} + +// loadGameFromRedis 从 Redis 加载游戏状态 +// 如果 Redis 中没有找到,则尝试从数据库恢复 +func (h *handler) loadGameFromRedis(ctx context.Context, gameID string) (*MatchingGame, error) { + data, err := h.redis.Get(ctx, matchingGameKeyPrefix+gameID).Bytes() + if err == nil { + var game MatchingGame + if err := json.Unmarshal(data, &game); err != nil { + return nil, err + } + return &game, nil + } + // Redis miss - try to recover from DB + if err == redis.Nil { + game, recoverErr := h.recoverGameFromDB(ctx, gameID) + if recoverErr != nil { + return nil, redis.Nil // Return original error to indicate session not found + } + // Cache the recovered game back to Redis + _ = h.saveGameToRedis(ctx, gameID, game) + return game, nil + } + return nil, err +} + +// recoverGameFromDB 从数据库恢复游戏状态 +// 通过 game_id 解析 user_id,然后查找对应的 activity_draw_receipts 记录 +// 使用 ServerSubSeed 重建游戏状态 +func (h *handler) recoverGameFromDB(ctx context.Context, gameID string) (*MatchingGame, error) { + // Parse user_id from game_id (format: MG{userID}{timestamp}) + // Example: MG121766299471192637903 + if len(gameID) < 3 || gameID[:2] != "MG" { + return nil, fmt.Errorf("invalid game_id format") + } + + // Extract user_id: find the first digit sequence after "MG" + // The user_id is typically short (1-5 digits), timestamp is long (19 digits) + numPart := gameID[2:] + var userID int64 + if len(numPart) > 19 { + // User ID is everything before the last 19 chars (nanosecond timestamp) + userIDStr := numPart[:len(numPart)-19] + userID = parseInt64(userIDStr) + } else { + return nil, fmt.Errorf("cannot parse user_id from game_id") + } + + if userID <= 0 { + return nil, fmt.Errorf("invalid user_id in game_id") + } + + // Find the most recent matching game receipt for this user + receipt, err := h.readDB.ActivityDrawReceipts.WithContext(ctx). + Where(h.readDB.ActivityDrawReceipts.ClientID.Eq(userID)). + Where(h.readDB.ActivityDrawReceipts.AlgoVersion.Eq("HMAC-SHA256-v1")). + Order(h.readDB.ActivityDrawReceipts.ID.Desc()). + First() + if err != nil || receipt == nil { + return nil, fmt.Errorf("no matching game receipt found for user %d", userID) + } + + // Decode ServerSubSeed (hex -> bytes) + serverSeed, err := hex.DecodeString(receipt.ServerSubSeed) + if err != nil || len(serverSeed) == 0 { + return nil, fmt.Errorf("invalid server seed in receipt") + } + + // Get DrawLog to find IssueID and OrderID + drawLog, err := h.readDB.ActivityDrawLogs.WithContext(ctx). + Where(h.readDB.ActivityDrawLogs.ID.Eq(receipt.DrawLogID)). + First() + if err != nil || drawLog == nil { + return nil, fmt.Errorf("draw log not found") + } + + // Load card configs + configs, err := loadCardTypesFromDB(ctx, h.readDB) + if err != nil || len(configs) == 0 { + // Fallback to default configs + configs = []CardTypeConfig{ + {Code: "A", Name: "类型A", Quantity: 9}, + {Code: "B", Name: "类型B", Quantity: 9}, + {Code: "C", Name: "类型C", Quantity: 9}, + {Code: "D", Name: "类型D", Quantity: 9}, + {Code: "E", Name: "类型E", Quantity: 9}, + {Code: "F", Name: "类型F", Quantity: 9}, + {Code: "G", Name: "类型G", Quantity: 9}, + {Code: "H", Name: "类型H", Quantity: 9}, + {Code: "I", Name: "类型I", Quantity: 9}, + {Code: "J", Name: "类型J", Quantity: 9}, + {Code: "K", Name: "类型K", Quantity: 9}, + } + } + + // Reconstruct game with the same seed + game := &MatchingGame{ + CardConfigs: configs, + RoundHistory: []MatchingRoundResult{}, + Board: [9]*MatchingCard{}, + LastActivity: time.Now(), + ServerSeed: serverSeed, + ServerSeedHash: receipt.ServerSeedHash, + Nonce: 0, // Reset nonce for reconstruction + ActivityID: drawLog.IssueID, // Note: IssueID is stored in DrawLog + IssueID: drawLog.IssueID, + OrderID: drawLog.OrderID, + UserID: userID, + } + + // Get ActivityID from Issue + if issue, _ := h.readDB.ActivityIssues.WithContext(ctx).Where(h.readDB.ActivityIssues.ID.Eq(drawLog.IssueID)).First(); issue != nil { + game.ActivityID = issue.ActivityID + } + + // Generate all cards + totalCards := 0 + for _, cfg := range configs { + totalCards += int(cfg.Quantity) + } + game.CardIDCounter = 0 + allCards := make([]*MatchingCard, 0, totalCards) + for _, cfg := range configs { + for i := int32(0); i < cfg.Quantity; i++ { + game.CardIDCounter++ + id := fmt.Sprintf("c%d", game.CardIDCounter) + mc := &MatchingCard{ + ID: id, + Type: cfg.Code, + } + allCards = append(allCards, mc) + } + } + game.Deck = allCards + + // Shuffle with the same seed (deterministic) + game.secureShuffle() + + // Fill board + for i := 0; i < 9; i++ { + if len(game.Deck) > 0 { + card := game.Deck[0] + game.Deck = game.Deck[1:] + game.Board[i] = card + } else { + game.Board[i] = nil + } + } + + // Calculate max possible pairs + var theoreticalMax int64 + for _, cfg := range configs { + theoreticalMax += int64(cfg.Quantity / 2) + } + game.MaxPossiblePairs = theoreticalMax + + fmt.Printf("[会话恢复] 成功从数据库恢复游戏 game_id=%s user_id=%d issue_id=%d\n", gameID, userID, drawLog.IssueID) + + return game, nil +} + +// PreOrderMatchingGame 下单并预生成对对碰游戏数据 +// @Summary 下单并获取对对碰全量数据 +// @Description 用户下单,服务器扣费并返回全量99张乱序卡牌,前端自行负责游戏流程 // @Tags APP端.活动 // @Accept json // @Produce json // @Security LoginVerifyToken -// @Param RequestBody body matchingGameStartRequest true "请求参数" -// @Success 200 {object} matchingGameStartResponse +// @Param RequestBody body matchingGamePreOrderRequest true "请求参数" +// @Success 200 {object} matchingGamePreOrderResponse // @Failure 400 {object} code.Failure -// @Router /api/app/matching/start [post] -func (h *handler) StartMatchingGame() core.HandlerFunc { +// @Router /api/app/matching/preorder [post] +func (h *handler) PreOrderMatchingGame() core.HandlerFunc { + // 启动清理协程(Lazy Init) + h.startMatchingGameCleanup() return func(ctx core.Context) { userID := int64(ctx.SessionUserInfo().Id) + req := new(matchingGamePreOrderRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) + return + } - // 从数据库加载卡牌类型配置 + // 1. Get Activity/Issue Info (Mocking price for now or fetching if available) + // Assuming price is fixed or fetched. Let's fetch basic activity info if possible, or assume config. + // Since Request has IssueID, let's fetch Issue to get ActivityID and Price. + // Note: The current handler doesn't have easy access to Issue struct helper without exporting or duplicating. + // We will assume `req.IssueID` is valid and fetch price via `h.activity.GetActivity` if we had ActivityID. + // But req only has IssueID. Let's look up Issue first. + issue, err := h.writeDB.ActivityIssues.WithContext(ctx.RequestContext()).Where(h.writeDB.ActivityIssues.ID.Eq(req.IssueID)).First() + if err != nil || issue == nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "issue not found")) + return + } + activity, err := h.activity.GetActivity(ctx.RequestContext(), issue.ActivityID) + if err != nil || activity == nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 170001, "activity not found")) + return + } + + // Validation + if !activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 170009, "本活动不支持优惠券")) + return + } + if !activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 { + ctx.AbortWithError(core.Error(http.StatusBadRequest, 170010, "本活动不支持道具卡")) + return + } + + // 2. Prepare Order + orderNo := h.randomID("O") // Reuse helper from lottery_app.go? No, handler is same struct but method not exported? + // randomID is private in handler. We need to duplicate or make it public. + // Let's implement a local randomID or use UUID. + // h.randomID is `func (h *handler) randomID(prefix string) string`, it IS available to `h`. + + total := activity.PriceDraw // Matching game ensures 1 draw basically? Or is it a session price? + // "PreOrder" usually implies 1 game session. + + // Create basic order model + order := &model.Orders{ + UserID: userID, + OrderNo: orderNo, + TotalAmount: total, + ActualAmount: total, + Status: 1, // Pending + SourceType: 3, // Other/Game + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + } + // Minimal Remark + order.Remark = fmt.Sprintf("matching_game:issue:%d", req.IssueID) + if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 { + order.Remark += fmt.Sprintf("|itemcard:%d", *req.ItemCardID) + } + + // 3. Apply Title Discount + titleEffects, _ := h.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{ + ActivityID: &issue.ActivityID, + IssueID: &req.IssueID, + }) + for _, ef := range titleEffects { + if ef.EffectType == 2 { + var p struct { + DiscountType string `json:"discount_type"` + ValueX1000 int64 `json:"value_x1000"` + MaxDiscountX1000 int64 `json:"max_discount_x1000"` + } + if jsonErr := json.Unmarshal([]byte(ef.ParamsJSON), &p); jsonErr == nil { + var discount int64 + if p.DiscountType == "percentage" { + discount = order.ActualAmount * p.ValueX1000 / 1000 + } else if p.DiscountType == "fixed" { + discount = p.ValueX1000 + } + if p.MaxDiscountX1000 > 0 && discount > p.MaxDiscountX1000 { + discount = p.MaxDiscountX1000 + } + if discount > order.ActualAmount { + discount = order.ActualAmount + } + if discount > 0 { + order.ActualAmount -= discount + order.Remark += fmt.Sprintf("|title_discount:%d:%d", ef.ID, discount) + } + } + } + } + + // 4. Apply Coupon + var appliedCouponVal int64 + if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 { + // Reuse h.applyCouponWithCap? It is private. + // We need to either export it or duplicate logic. + // For safety and speed, let's duplicate the core check logic or use `usersvc` directly if possible. + // `h.applyCouponWithCap` logic: fetch coupon, check status, check expiry, check min_spend, calc discount. + // Let's implement simplified version here. + uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(*req.CouponID), h.readDB.UserCoupons.UserID.Eq(userID)).First() + if uc != nil && uc.Status == 1 && (uc.ValidEnd.IsZero() || uc.ValidEnd.After(time.Now())) { + sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID)).First() + if sc != nil && order.ActualAmount >= sc.MinSpend { + var discount int64 + if sc.DiscountType == 3 { // Rate (Discount) + // DiscountValue is rate x 1000 (e.g. 800 = 80%) + // Discount Amount = Total * (1 - Rate/1000) ? Or simply applied rate? + // Usually "DiscountValue" for Type 3 means "The Ratio you PAY" or "The Ratio OFF"? + // Standard "zhekou": 8 zhe = pay 80%. Discount = 20%. + // Let's assume DiscountValue=800 means 80% pay. + discount = order.ActualAmount * (1000 - sc.DiscountValue) / 1000 + } else { // Fixed (Type 1 or 2) + discount = sc.DiscountValue + } + + if discount > order.ActualAmount { + discount = order.ActualAmount + } + if discount > 0 { + order.ActualAmount -= discount + order.DiscountAmount = discount + appliedCouponVal = discount + // Record usage later + } + } + } + } + + // Create Order + if err := h.writeDB.Orders.WithContext(ctx.RequestContext()).Create(order); err != nil { + ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error())) + return + } + + // Record Coupon Usage + if appliedCouponVal > 0 && req.CouponID != nil { + _ = h.user.RecordOrderCouponUsage(ctx.RequestContext(), order.ID, *req.CouponID, appliedCouponVal) + } + + // Handle 0-amount auto-pay + if order.ActualAmount == 0 { + now := time.Now() + _, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{ + h.writeDB.Orders.Status.ColumnName().String(): 2, + h.writeDB.Orders.PaidAt.ColumnName().String(): now, + }) + // Consume Coupon + if req.CouponID != nil { + _, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(*req.CouponID)).Updates(map[string]any{ + h.readDB.UserCoupons.Status.ColumnName().String(): 2, + h.readDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID, + h.readDB.UserCoupons.UsedAt.ColumnName().String(): now, + }) + } + } + + // 2. 加载配置 configs, err := loadCardTypesFromDB(ctx.RequestContext(), h.readDB) if err != nil || len(configs) == 0 { - // 如果数据库没有配置,使用默认配置 configs = []CardTypeConfig{ {Code: "A", Name: "类型A", Quantity: 9}, {Code: "B", Name: "类型B", Quantity: 9}, @@ -315,83 +684,180 @@ func (h *handler) StartMatchingGame() core.HandlerFunc { } } - // 创建新游戏 - game := NewMatchingGameWithConfig(configs) + // 3. 创建游戏并洗牌 + game := NewMatchingGameWithConfig(configs, req.Position) + game.ActivityID = issue.ActivityID + game.IssueID = req.IssueID + game.OrderID = order.ID + game.UserID = userID - // 生成游戏ID - gameID := fmt.Sprintf("MG%d%d", userID, time.Now().UnixNano()) - - // 存储游戏会话 - gameSessionsV2[gameID] = game - - // 执行第一轮 - firstRound := game.PlayRound(true) - - // 构建手牌展示(包含卡牌名称和图片) - cards := make([]MatchingCard, len(game.hand)) - for i, c := range game.hand { - mc := MatchingCard{ID: i, Type: c} - if cfg := game.getCardConfig(c); cfg != nil { - mc.Name = cfg.Name - mc.ImageURL = cfg.ImageURL + // 4. 构造 AllCards (仅需返回 Flat List) + // game.deck 包含了所有的牌(已洗好,且包含了 board[0..8] 因为 NewMatchingGameWithConfig 中我们是从 deck 发到 board 的) + // 但 NewMatchingGameWithConfig 目前的逻辑是:生成 -> 洗牌 -> 发前9张到 board -> deck只剩剩下的。 + // 所以我们需要把 board 和 deck 拼起来。 + allCards := make([]MatchingCard, 0, 99) + for _, c := range game.Board { + if c != nil { + allCards = append(allCards, *c) } - cards[i] = mc + } + for _, c := range game.Deck { + allCards = append(allCards, *c) } - rsp := &matchingGameStartResponse{ + // 5. 生成GameID并存储 (主要用于 Check 时校验存在性,或者验签) + gameID := fmt.Sprintf("MG%d%d", userID, time.Now().UnixNano()) + + // Save to Redis + if err := h.saveGameToRedis(ctx.RequestContext(), gameID, game); err != nil { + h.logger.Error("Failed to save matching game session", zap.Error(err)) + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "failed to create game session")) + return + } + + // 6. Save Verification Data (ActivityDrawLogs + ActivityDrawReceipts) + // This is required for the "Verification" feature in App/Admin to work. + // A "Matching Game" session is treated as one "Draw". + + // 6.1 Create DrawLog + drawLog := &model.ActivityDrawLogs{ + UserID: userID, + IssueID: req.IssueID, + OrderID: order.ID, + CreatedAt: time.Now(), + IsWinner: 0, // Will be updated if they win prizes at `Check`? Or just 0 for participation. + Level: 0, + } + _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog) + + // 6.2 Create DrawReceipt + if drawLog.ID > 0 { + receipt := &model.ActivityDrawReceipts{ + CreatedAt: time.Now(), + DrawLogID: drawLog.ID, + AlgoVersion: "HMAC-SHA256-v1", + RoundID: req.IssueID, + DrawID: time.Now().UnixNano(), // Use timestamp to ensure uniqueness as we don't have real DrawID + ClientID: userID, + Timestamp: time.Now().UnixMilli(), + ServerSeedHash: game.ServerSeedHash, + ServerSubSeed: "", // Matching game generic seed + ClientSeed: req.Position, // Use Position as ClientSeed + Nonce: 0, + ItemsRoot: "", // Could enable if we hashed the deck + WeightsTotal: 0, + SelectedIndex: 0, + RandProof: "", + Signature: "", + } + // Hex encode server seed + receipt.ServerSubSeed = hex.EncodeToString(game.ServerSeed) + + _ = h.writeDB.ActivityDrawReceipts.WithContext(ctx.RequestContext()).Create(receipt) + } + + // 7. 返回数据 + rsp := &matchingGamePreOrderResponse{ GameID: gameID, - Hand: cards, - DeckCount: len(game.deck), - ServerSeedHash: game.serverSeedHash, - FirstRound: firstRound, + OrderNo: order.OrderNo, + PayStatus: order.Status, + AllCards: allCards, + ServerSeedHash: game.ServerSeedHash, } ctx.Payload(rsp) } } -// PlayMatchingGame 执行一轮对对碰游戏 -// @Summary 执行一轮对对碰游戏 -// @Description 执行一轮配对,返回配对结果和新抽取的牌 +// CheckMatchingGame 游戏结束结算校验 +// @Summary 游戏结束结算校验 +// @Description 前端游戏结束后上报结果,服务器发放奖励 // @Tags APP端.活动 // @Accept json // @Produce json // @Security LoginVerifyToken -// @Param RequestBody body matchingGamePlayRequest true "请求参数" -// @Success 200 {object} matchingGamePlayResponse +// @Param RequestBody body matchingGameCheckRequest true "请求参数" +// @Success 200 {object} matchingGameCheckResponse // @Failure 400 {object} code.Failure -// @Router /api/app/matching/play [post] -func (h *handler) PlayMatchingGame() core.HandlerFunc { +// @Router /api/app/matching/check [post] +func (h *handler) CheckMatchingGame() core.HandlerFunc { return func(ctx core.Context) { - req := new(matchingGamePlayRequest) - if err := ctx.ShouldBindJSON(req); err != nil || req.GameID == "" { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game_id required")) + req := new(matchingGameCheckRequest) + if err := ctx.ShouldBindJSON(req); err != nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err))) return } - // 获取游戏会话 - game, ok := gameSessionsV2[req.GameID] - if !ok { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found")) + game, err := h.loadGameFromRedis(ctx.RequestContext(), req.GameID) + if err != nil { + if err == redis.Nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found or expired")) + } else { + h.logger.Error("Failed to load matching game session", zap.Error(err)) + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "internal server error")) + } return } - // 执行一轮 - round := game.PlayRound(false) - - rsp := &matchingGamePlayResponse{ - Round: round, - TotalPairs: game.totalPairs, - GameOver: !round.CanContinue, + // 校验:不能超过理论最大对数 + if req.TotalPairs > game.MaxPossiblePairs { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, fmt.Sprintf("invalid total pairs: %d > %d", req.TotalPairs, game.MaxPossiblePairs))) + return } - // 如果游戏结束,返回最终状态 - if !round.CanContinue { - rsp.FinalState = game.GetGameState() - // 清理会话 - delete(gameSessionsV2, req.GameID) + game.TotalPairs = req.TotalPairs // 记录一下 + var rewardInfo *MatchingRewardInfo + + // 1. Fetch Rewards + rewards, err := h.activity.ListIssueRewards(ctx.RequestContext(), game.IssueID) + + if err == nil && len(rewards) > 0 { + // 2. Filter & Sort + var candidate *model.ActivityRewardSettings + for _, r := range rewards { + if r.Quantity <= 0 { + continue + } + if int64(req.TotalPairs) >= r.MinScore { + if candidate == nil { + candidate = r + } else { + // Prioritize Higher MinScore + if r.MinScore > candidate.MinScore { + candidate = r + } else if r.MinScore == candidate.MinScore { + if r.Sort < candidate.Sort { + candidate = r + } + } + } + } + } + + // 3. Grant Reward if found + if candidate != nil { + if err := h.grantRewardHelper(ctx.RequestContext(), game.UserID, game.OrderID, candidate); err != nil { + h.logger.Error("Failed to grant matching reward", zap.Int64("order_id", game.OrderID), zap.Error(err)) + } else { + rewardInfo = &MatchingRewardInfo{ + RewardID: candidate.ID, + Name: candidate.Name, + Level: candidate.Level, + } + } + } } + rsp := &matchingGameCheckResponse{ + GameID: req.GameID, + TotalPairs: req.TotalPairs, + Finished: true, + Reward: rewardInfo, + } + + // 结算完成,清理会话 (Delete from Redis) + _ = h.redis.Del(ctx.RequestContext(), matchingGameKeyPrefix+req.GameID) + ctx.Payload(rsp) } } @@ -415,12 +881,119 @@ func (h *handler) GetMatchingGameState() core.HandlerFunc { return } - game, ok := gameSessionsV2[gameID] - if !ok { - ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found")) + game, err := h.loadGameFromRedis(ctx.RequestContext(), gameID) + if err != nil { + if err == redis.Nil { + ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "game session not found")) + } else { + h.logger.Error("Failed to load matching game", zap.Error(err)) + } return } + // Keep-Alive: Refresh Redis TTL + h.redis.Expire(ctx.RequestContext(), matchingGameKeyPrefix+gameID, 30*time.Minute) + ctx.Payload(game.GetGameState()) } } + +// ListMatchingCardTypes 列出对对碰卡牌类型(App端枚举) +// @Summary 列出对对碰卡牌类型 +// @Description 获取所有启用的卡牌类型配置,用于App端预览或动画展示 +// @Tags APP端.活动 +// @Accept json +// @Produce json +// @Success 200 {array} CardTypeConfig +// @Failure 400 {object} code.Failure +// @Router /api/app/matching/card_types [get] +func (h *handler) ListMatchingCardTypes() core.HandlerFunc { + return func(ctx core.Context) { + configs, err := loadCardTypesFromDB(ctx.RequestContext(), h.readDB) + if err != nil { + // Try to serve default configs if DB fails? Or just error safely. + // Let's rely on DB being available. + ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ParamBindError, err.Error())) + return + } + ctx.Payload(configs) + } +} + +// startMatchingGameCleanup ... (Deprecated since we use Redis TTL) +func (h *handler) startMatchingGameCleanup() { + // No-op +} + +// cleanupExpiredMatchingGames ... (Deprecated) +func cleanupExpiredMatchingGames(logger logger.CustomLogger) { + // No-op +} + +// grantRewardHelper 发放奖励辅助函数 +func (h *handler) grantRewardHelper(ctx context.Context, userID, orderID int64, r *model.ActivityRewardSettings) error { + // 1. 扣减库存 + res, err := h.writeDB.ActivityRewardSettings.WithContext(ctx).Where( + h.writeDB.ActivityRewardSettings.ID.Eq(r.ID), + h.writeDB.ActivityRewardSettings.Quantity.Gt(0), + ).UpdateSimple(h.writeDB.ActivityRewardSettings.Quantity.Add(-1)) + if err != nil { + return err + } + if res.RowsAffected == 0 { + return fmt.Errorf("reward out of stock") + } + + // 2. Grant to Order + issue, _ := h.readDB.ActivityIssues.WithContext(ctx).Where(h.readDB.ActivityIssues.ID.Eq(r.IssueID)).First() + var actID int64 + if issue != nil { + actID = issue.ActivityID + } + + rid := r.ID + _, err = h.user.GrantRewardToOrder(ctx, userID, usersvc.GrantRewardToOrderRequest{ + OrderID: orderID, + ProductID: r.ProductID, + Quantity: 1, // 1 prize + ActivityID: &actID, + RewardID: &rid, + Remark: "Matching Game Reward", + }) + if err != nil { + // Use h.logger.Error if available, else fmt.Printf or zap.L().Error + // h.logger is likely type definition interface. + // Let's use generic logger if h.logger doesn't support structured. + // But usually it does. + // h.logger.Error(msg, fields...) + h.logger.Error("Failed to grant reward to order", zap.Int64("order_id", orderID), zap.Error(err)) + return err + } + + // 3. Update Draw Log (IsWinner = 1) + _, err = h.writeDB.ActivityDrawLogs.WithContext(ctx).Where( + h.writeDB.ActivityDrawLogs.OrderID.Eq(orderID), + ).Updates(&model.ActivityDrawLogs{ + IsWinner: 1, + RewardID: r.ID, + Level: r.Level, + // RewardName: r.Name, // Removed + // ProductPrice: 0, // Removed + // UpdatedAt: time.Now(), // Removed + }) + + return err +} + +// parseInt64 将字符串转换为int64 +func parseInt64(s string) int64 { + var n int64 + for i := 0; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + n = n*10 + int64(c-'0') + } + return n +} diff --git a/internal/api/activity/matching_game_app_test.go b/internal/api/activity/matching_game_app_test.go new file mode 100644 index 0000000..4879f7a --- /dev/null +++ b/internal/api/activity/matching_game_app_test.go @@ -0,0 +1 @@ +package app diff --git a/internal/api/activity/matching_game_cleanup_test.go b/internal/api/activity/matching_game_cleanup_test.go new file mode 100644 index 0000000..3e0f1c8 --- /dev/null +++ b/internal/api/activity/matching_game_cleanup_test.go @@ -0,0 +1,21 @@ +package app + +import ( + "bindbox-game/internal/pkg/logger" + "testing" +) + +// MockLogger for testing +type MockLogger struct { + logger.CustomLogger +} + +func TestCleanupExpiredMatchingGames(t *testing.T) { + // Deprecated: Redis handles expiry now. + t.Skip("Deprecated: Redis handles expiry now") + /* + // 1. Setup + gameSessionsMutex.Lock() + ... + */ +} diff --git a/internal/api/activity/matching_game_verify_test.go b/internal/api/activity/matching_game_verify_test.go new file mode 100644 index 0000000..cbac98d --- /dev/null +++ b/internal/api/activity/matching_game_verify_test.go @@ -0,0 +1,137 @@ +package app + +import ( + "fmt" + "testing" +) + +// SimulateClientPlay mimics the frontend logic: Match -> Eliminate -> Fill -> Reshuffle +func SimulateClientPlay(initialCards []*MatchingCard) int { + // Deep copy to avoid modifying original test data + deck := make([]*MatchingCard, len(initialCards)) + copy(deck, initialCards) + + board := make([]*MatchingCard, 9) + // Initial fill + for i := 0; i < 9; i++ { + board[i] = deck[0] + deck = deck[1:] + } + + pairsFound := 0 + + for { + // 1. Check for matches on board + counts := make(map[string][]int) // type -> list of indices + for i, c := range board { + if c != nil { + // Cast CardType to string for map key + counts[string(c.Type)] = append(counts[string(c.Type)], i) + } + } + + matchedType := "" + for t, indices := range counts { + if len(indices) >= 2 { + matchedType = t + break + } + } + + if matchedType != "" { + // Elimination + pairsFound++ + indices := counts[matchedType] + // Remove first 2 + idx1, idx2 := indices[0], indices[1] + board[idx1] = nil + board[idx2] = nil + + // Filling: Fill empty slots from deck + for i := 0; i < 9; i++ { + if board[i] == nil && len(deck) > 0 { + board[i] = deck[0] + deck = deck[1:] + } + } + } else { + // Deadlock (No matches on board) + // User requirement: "Stop when no pairs can be generated" (i.e., No Reshuffle) + // If we are stuck, we stop. + break + } + } + return pairsFound +} + +// TestVerification_DataIntegrity simulates the PreOrder logic 10000 times +func TestVerification_DataIntegrity(t *testing.T) { + fmt.Println("=== Starting Full Game Simulation (10000 Runs) ===") + // Using 10k runs to keep test time reasonable + + configs := []CardTypeConfig{ + {Code: "A", Name: "TypeA", Quantity: 9}, + {Code: "B", Name: "TypeB", Quantity: 9}, + {Code: "C", Name: "TypeC", Quantity: 9}, + {Code: "D", Name: "TypeD", Quantity: 9}, + {Code: "E", Name: "TypeE", Quantity: 9}, + {Code: "F", Name: "TypeF", Quantity: 9}, + {Code: "G", Name: "TypeG", Quantity: 9}, + {Code: "H", Name: "TypeH", Quantity: 9}, + {Code: "I", Name: "TypeI", Quantity: 9}, + {Code: "J", Name: "TypeJ", Quantity: 9}, + {Code: "K", Name: "TypeK", Quantity: 9}, + } + + scoreDist := make(map[int]int) + + for i := 0; i < 10000; i++ { + // 1. Simulate PreOrder generation + game := NewMatchingGameWithConfig(configs, fmt.Sprintf("pos_%d", i)) + + // 2. Reconstruct "all_cards" + allCards := make([]*MatchingCard, 0, 99) + for _, c := range game.Board { + if c != nil { + allCards = append(allCards, c) + } + } + allCards = append(allCards, game.Deck...) + + // 3. Play the game! + score := SimulateClientPlay(allCards) + scoreDist[score]++ + // Note: Without reshuffle, score < 44 is expected. + } + + // Calculate Stats + totalScore := 0 + var allScores []int + for s := 0; s <= 44; s++ { + count := scoreDist[s] + for c := 0; c < count; c++ { + allScores = append(allScores, s) + totalScore += s + } + } + + mean := float64(totalScore) / float64(len(allScores)) + median := allScores[len(allScores)/2] + + fmt.Println("\n=== No-Reshuffle Statistical Analysis (10000 Runs) ===") + fmt.Printf("Mean Score: %.2f / 44\n", mean) + fmt.Printf("Median Score: %d / 44\n", median) + fmt.Printf("Pass Rate: %.2f%%\n", float64(scoreDist[44])/100.0) + fmt.Println("------------------------------------------------") + + // Output Distribution Segments + fmt.Println("Detailed Distribution:") + cumulative := 0 + for s := 0; s <= 44; s++ { + count := scoreDist[s] + if count > 0 { + cumulative += count + fmt.Printf("Score %d: %d times (%.2f%%) [Cum: %.2f%%]\n", s, count, float64(count)/100.0, float64(cumulative)/100.0) + } + } +} diff --git a/internal/api/admin/lottery_admin.go b/internal/api/admin/lottery_admin.go index 2ca9fb5..8390e3a 100644 --- a/internal/api/admin/lottery_admin.go +++ b/internal/api/admin/lottery_admin.go @@ -186,18 +186,21 @@ func (h *handler) SettleIssue() core.HandlerFunc { // 人数达标统一开奖 s := strat.NewDefault(h.readDB, h.writeDB) for _, o := range orders { - rid, _, e2 := s.SelectItem(ctx.RequestContext(), iss.ActivityID, iss.ID, o.UserID) + rid, proof, e2 := s.SelectItem(ctx.RequestContext(), iss.ActivityID, iss.ID, o.UserID) if e2 != nil || rid <= 0 { continue } rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() _ = s.GrantReward(ctx.RequestContext(), o.UserID, rid) - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: o.UserID, IssueID: iss.ID, OrderID: o.ID, RewardID: rid, IsWinner: 1, Level: func() int32 { + drawLog := &model.ActivityDrawLogs{UserID: o.UserID, IssueID: iss.ID, OrderID: o.ID, RewardID: rid, IsWinner: 1, Level: func() int32 { if rw != nil { return rw.Level } return 1 - }(), CurrentLevel: 1}) + }(), CurrentLevel: 1} + _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog) + // 保存可验证凭据 + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog.ID, iss.ID, o.UserID, proof) granted++ } } @@ -205,18 +208,21 @@ func (h *handler) SettleIssue() core.HandlerFunc { // 即时或强制:统一开奖 s := strat.NewDefault(h.readDB, h.writeDB) for _, o := range orders { - rid, _, e2 := s.SelectItem(ctx.RequestContext(), iss.ActivityID, iss.ID, o.UserID) + rid, proof, e2 := s.SelectItem(ctx.RequestContext(), iss.ActivityID, iss.ID, o.UserID) if e2 != nil || rid <= 0 { continue } rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(rid)).First() _ = s.GrantReward(ctx.RequestContext(), o.UserID, rid) - _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(&model.ActivityDrawLogs{UserID: o.UserID, IssueID: iss.ID, OrderID: o.ID, RewardID: rid, IsWinner: 1, Level: func() int32 { + drawLog := &model.ActivityDrawLogs{UserID: o.UserID, IssueID: iss.ID, OrderID: o.ID, RewardID: rid, IsWinner: 1, Level: func() int32 { if rw != nil { return rw.Level } return 1 - }(), CurrentLevel: 1}) + }(), CurrentLevel: 1} + _ = h.writeDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Create(drawLog) + // 保存可验证凭据 + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, drawLog.ID, iss.ID, o.UserID, proof) granted++ } } diff --git a/internal/api/admin/pay_orders_admin.go b/internal/api/admin/pay_orders_admin.go index 08e7102..8f628d2 100644 --- a/internal/api/admin/pay_orders_admin.go +++ b/internal/api/admin/pay_orders_admin.go @@ -11,6 +11,7 @@ import ( "bindbox-game/internal/pkg/validation" "bindbox-game/internal/pkg/wechat" "bindbox-game/internal/repository/mysql/model" + usersvc "bindbox-game/internal/service/user" ) type listPayOrdersRequest struct { @@ -224,7 +225,8 @@ type getPayOrderResponse struct { UnitPrice int64 `json:"unit_price"` Amount int64 `json:"amount"` } `json:"reward_items"` - RewardShipments []*model.ShippingRecords `json:"reward_shipments"` + RewardShipments []*model.ShippingRecords `json:"reward_shipments"` + DrawReceipts []*usersvc.DrawReceiptInfo `json:"draw_receipts"` } func (h *handler) GetPayOrderDetail() core.HandlerFunc { @@ -595,6 +597,51 @@ func (h *handler) GetPayOrderDetail() core.HandlerFunc { rsp.RewardShipments = shipsAll } } + + // 填充抽奖凭据 Verify Seed Data + if order.SourceType == 2 { // Buffer/Lottery orders + drawLogs, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawLogs.OrderID.Eq(order.ID)).Find() + var logIDs []int64 + for _, l := range drawLogs { + logIDs = append(logIDs, l.ID) + } + if len(logIDs) > 0 { + receipts, _ := h.readDB.ActivityDrawReceipts.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityDrawReceipts.DrawLogID.In(logIDs...)).Find() + var drList []*usersvc.DrawReceiptInfo + for i, r := range receipts { + // Find rewardID from logs if possible, though receipt has DrawLogID + var rid int64 + for _, l := range drawLogs { + if l.ID == r.DrawLogID { + rid = l.RewardID + break + } + } + drList = append(drList, &usersvc.DrawReceiptInfo{ + DrawLogID: r.DrawLogID, + RewardID: rid, + DrawIndex: i + 1, // approximate + AlgoVersion: r.AlgoVersion, + RoundID: r.RoundID, + DrawID: r.DrawID, + ClientID: r.ClientID, + Timestamp: r.Timestamp, + ServerSeedHash: r.ServerSeedHash, + ServerSubSeed: r.ServerSubSeed, + ClientSeed: r.ClientSeed, + Nonce: r.Nonce, + ItemsRoot: r.ItemsRoot, + WeightsTotal: r.WeightsTotal, + SelectedIndex: r.SelectedIndex, + RandProof: r.RandProof, + Signature: r.Signature, + ItemsSnapshot: r.ItemsSnapshot, + }) + } + rsp.DrawReceipts = drList + } + } + ctx.Payload(rsp) } } diff --git a/internal/api/admin/pay_refund_admin.go b/internal/api/admin/pay_refund_admin.go index ed08ae3..6463062 100644 --- a/internal/api/admin/pay_refund_admin.go +++ b/internal/api/admin/pay_refund_admin.go @@ -98,20 +98,70 @@ func (h *handler) CreateRefund() core.HandlerFunc { ctx.AbortWithError(core.Error(http.StatusInternalServerError, 160002, err.Error())) return } - // 全额退款:恢复订单中使用的优惠券(优先使用结构化明细表) - var cnt int64 - _ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM order_coupons WHERE order_id=?", order.ID).Scan(&cnt).Error - if cnt > 0 { - type ocRow struct { - UserCouponID int64 - AppliedAmount int64 - } - var rows []ocRow - _ = h.repo.GetDbR().Raw("SELECT user_coupon_id, applied_amount FROM order_coupons WHERE order_id=?", order.ID).Scan(&rows).Error - for _, r := range rows { - if r.UserCouponID > 0 && r.AppliedAmount > 0 { - _ = h.repo.GetDbW().Exec("UPDATE user_coupons SET balance_amount=COALESCE(balance_amount,0)+?, status=1, used_order_id=0, used_at=NULL WHERE id=? AND user_id=?", r.AppliedAmount, r.UserCouponID, order.UserID).Error + // 全额退款:恢复订单中使用的优惠券(细化余额与状态逻辑) + type ocRow struct { + UserCouponID int64 + AppliedAmount int64 + DiscountType int32 + DiscountValue int64 + BalanceAmount int64 + } + var rows []ocRow + _ = h.repo.GetDbR().Raw("SELECT oc.user_coupon_id, oc.applied_amount, sc.discount_type, sc.discount_value, uc.balance_amount FROM order_coupons oc JOIN user_coupons uc ON uc.id=oc.user_coupon_id JOIN system_coupons sc ON sc.id=uc.coupon_id WHERE oc.order_id=?", order.ID).Scan(&rows).Error + for _, r := range rows { + if r.UserCouponID > 0 && r.AppliedAmount > 0 { + newBal := r.BalanceAmount + r.AppliedAmount + if r.DiscountType == 1 { // 直金额券:判断回退后是否全满 + if newBal >= r.DiscountValue { + _ = h.repo.GetDbW().Exec("UPDATE user_coupons SET balance_amount=?, status=1, used_order_id=0, used_at=NULL WHERE id=?", r.DiscountValue, r.UserCouponID).Error + newBal = r.DiscountValue + } else { + // 若金额未满,维持 status=2 (已使用/使用中) + _ = h.repo.GetDbW().Exec("UPDATE user_coupons SET balance_amount=?, status=2 WHERE id=?", newBal, r.UserCouponID).Error + } + } else { // 满减/折扣券:一律恢复为未使用 + _ = h.repo.GetDbW().Exec("UPDATE user_coupons SET status=1, used_order_id=0, used_at=NULL WHERE id=?", r.UserCouponID).Error + newBal = 0 } + // 记录流水 + ledger := &model.UserCouponLedger{ + UserID: order.UserID, + UserCouponID: r.UserCouponID, + ChangeAmount: r.AppliedAmount, + BalanceAfter: newBal, + OrderID: order.ID, + Action: "refund_restore", + CreatedAt: time.Now(), + } + _ = h.repo.GetDbW().Create(ledger).Error + } + } + + // 全额退款:回收中奖资产与奖品库存 + type invRow struct { + ID int64 + RewardID int64 + } + var invs []invRow + _ = h.repo.GetDbR().Raw("SELECT id, reward_id FROM user_inventory WHERE order_id=? AND status=1", order.ID).Scan(&invs).Error + for _, inv := range invs { + // 更新状态为2 (作废) + _ = h.repo.GetDbW().Exec("UPDATE user_inventory SET status=2, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_void') WHERE id=?", inv.ID).Error + // 恢复奖品库存 (ActivityRewardSettings) + if inv.RewardID > 0 { + _ = h.repo.GetDbW().Exec("UPDATE activity_reward_settings SET quantity = quantity + 1 WHERE id=?", inv.RewardID).Error + } + } + + // 全额退款:取消待发货记录 + _ = h.repo.GetDbW().Exec("UPDATE shipping_records SET status=4, updated_at=NOW(3), remark=CONCAT(IFNULL(remark,''),'|refund_cancel') WHERE order_id=? AND status=1", order.ID).Error + + // 全额退款:回退道具卡 + var itemCardIDs []int64 + _ = h.repo.GetDbR().Raw("SELECT user_item_card_id FROM activity_draw_effects WHERE draw_log_id IN (SELECT id FROM activity_draw_logs WHERE order_id=?)", order.ID).Scan(&itemCardIDs).Error + for _, icID := range itemCardIDs { + if icID > 0 { + _ = h.repo.GetDbW().Exec("UPDATE user_item_cards SET status=1, used_at=NULL, used_draw_log_id=0, used_activity_id=0, used_issue_id=0, updated_at=NOW(3) WHERE id=?", icID).Error } } } diff --git a/internal/api/admin/rewards_admin.go b/internal/api/admin/rewards_admin.go index 54bcc6c..918dada 100644 --- a/internal/api/admin/rewards_admin.go +++ b/internal/api/admin/rewards_admin.go @@ -20,6 +20,7 @@ type rewardItem struct { Level int32 `json:"level" binding:"required"` Sort int32 `json:"sort"` IsBoss int32 `json:"is_boss"` + MinScore int64 `json:"min_score"` } type createRewardsRequest struct { @@ -75,6 +76,7 @@ func (h *handler) CreateIssueRewards() core.HandlerFunc { Level: r.Level, Sort: r.Sort, IsBoss: r.IsBoss, + MinScore: r.MinScore, }) } @@ -124,6 +126,7 @@ func (h *handler) ListIssueRewards() core.HandlerFunc { Level: v.Level, Sort: v.Sort, IsBoss: v.IsBoss, + MinScore: v.MinScore, } } ctx.Payload(res) @@ -139,6 +142,7 @@ type modifyRewardRequest struct { Level *int32 `json:"level"` Sort *int32 `json:"sort"` IsBoss *int32 `json:"is_boss"` + MinScore *int64 `json:"min_score"` } // ModifyIssueReward 更新期数奖励 @@ -180,6 +184,7 @@ func (h *handler) ModifyIssueReward() core.HandlerFunc { Level: req.Level, Sort: req.Sort, IsBoss: req.IsBoss, + MinScore: req.MinScore, } if err := h.activity.ModifyIssueReward(ctx.RequestContext(), rewardID, in); err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateIssueRewardsError, err.Error())) diff --git a/internal/api/pay/wechat_notify.go b/internal/api/pay/wechat_notify.go index 9cbbc65..a96f60e 100644 --- a/internal/api/pay/wechat_notify.go +++ b/internal/api/pay/wechat_notify.go @@ -1,6 +1,7 @@ package pay import ( + "context" "encoding/json" "fmt" "net/http" @@ -344,7 +345,7 @@ func (h *handler) WechatNotify() core.HandlerFunc { break } // 位置已在上面占用,这里直接选择奖品 - rid, _, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), aid, iss, slot) + rid, proof, e2 := strat.NewIchiban(h.readDB, h.writeDB).SelectItemBySlot(ctx.RequestContext(), aid, iss, slot) fmt.Printf("[支付回调-抽奖] SelectItemBySlot 结果 rid=%d err=%v\n", rid, e2) if e2 != nil || rid <= 0 { fmt.Printf("[支付回调-抽奖] ❌ SelectItemBySlot 失败,跳出循环\n") @@ -362,6 +363,8 @@ func (h *handler) WechatNotify() core.HandlerFunc { fmt.Printf("[支付回调-抽奖] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", errLog) continue } + // 保存抽奖凭据(种子数据)供用户验证 + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, iss, ord.UserID, proof) _ = strat.NewIchiban(h.readDB, h.writeDB).GrantReward(ctx.RequestContext(), ord.UserID, rid) // 道具卡效果处理 fmt.Printf("[支付回调-道具卡] 开始检查 活动允许道具卡=%t 道具卡ID=%d\n", act.AllowItemCards, icID) @@ -398,7 +401,7 @@ func (h *handler) WechatNotify() core.HandlerFunc { } else { sel := strat.NewDefault(h.readDB, h.writeDB) for i := done; i < dc; i++ { - rid, _, e2 := sel.SelectItem(ctx.RequestContext(), aid, iss, ord.UserID) + rid, proof, e2 := sel.SelectItem(ctx.RequestContext(), aid, iss, ord.UserID) if e2 != nil || rid <= 0 { break } @@ -412,6 +415,8 @@ func (h *handler) WechatNotify() core.HandlerFunc { fmt.Printf("[支付回调-默认玩法] ❌ 创建开奖日志失败 err=%v,可能已存在,跳过\n", errLog) break } + // 保存抽奖凭据(种子数据)供用户验证 + _ = strat.SaveDrawReceipt(ctx.RequestContext(), h.writeDB, log.ID, iss, ord.UserID, proof) _, errGrant := h.user.GrantRewardToOrder(ctx.RequestContext(), ord.UserID, usersvc.GrantRewardToOrderRequest{OrderID: ord.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &rid, Remark: rw.Name}) if errGrant != nil { fmt.Printf("[支付回调-默认玩法] ❌ 发奖失败 err=%v,执行退款\n", errGrant) @@ -467,7 +472,8 @@ func (h *handler) WechatNotify() core.HandlerFunc { } // 【开奖后虚拟发货】即时开奖完成后上传虚拟发货 go func(orderID int64, orderNo string, userID int64, actName string) { - drawLogs, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find() + bgCtx := context.Background() + drawLogs, _ := h.readDB.ActivityDrawLogs.WithContext(bgCtx).Where(h.readDB.ActivityDrawLogs.OrderID.Eq(orderID)).Find() if len(drawLogs) == 0 { fmt.Printf("[即时开奖-虚拟发货] 没有开奖记录,跳过 order_id=%d\n", orderID) return @@ -475,7 +481,7 @@ func (h *handler) WechatNotify() core.HandlerFunc { // 收集赏品名称 var rewardNames []string for _, lg := range drawLogs { - if rw, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(h.readDB.ActivityRewardSettings.ID.Eq(lg.RewardID)).First(); rw != nil { + if rw, _ := h.readDB.ActivityRewardSettings.WithContext(bgCtx).Where(h.readDB.ActivityRewardSettings.ID.Eq(lg.RewardID)).First(); rw != nil { rewardNames = append(rewardNames, rw.Name) } } @@ -485,20 +491,20 @@ func (h *handler) WechatNotify() core.HandlerFunc { } // 获取支付交易信息 var tx *model.PaymentTransactions - tx, _ = h.readDB.PaymentTransactions.WithContext(ctx.RequestContext()).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First() + tx, _ = h.readDB.PaymentTransactions.WithContext(bgCtx).Where(h.readDB.PaymentTransactions.OrderNo.Eq(orderNo)).First() if tx == nil || tx.TransactionID == "" { fmt.Printf("[即时开奖-虚拟发货] 没有支付交易记录,跳过 order_no=%s\n", orderNo) return } // 获取用户openid var u *model.Users - u, _ = h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(userID)).First() + u, _ = h.readDB.Users.WithContext(bgCtx).Where(h.readDB.Users.ID.Eq(userID)).First() payerOpenid := "" if u != nil { payerOpenid = u.Openid } fmt.Printf("[即时开奖-虚拟发货] 上传 order_no=%s transaction_id=%s items_desc=%s\n", orderNo, tx.TransactionID, itemsDesc) - if err := wechat.UploadVirtualShippingWithFallback(ctx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc, time.Now()); err != nil { + if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: c.Wechat.AppID, AppSecret: c.Wechat.AppSecret}, tx.TransactionID, orderNo, payerOpenid, itemsDesc); err != nil { fmt.Printf("[即时开奖-虚拟发货] 上传失败: %v\n", err) } // 【开奖后推送通知】 @@ -507,7 +513,7 @@ func (h *handler) WechatNotify() core.HandlerFunc { AppSecret: c.Wechat.AppSecret, LotteryResultTemplateID: c.Wechat.LotteryResultTemplateID, } - _ = lotterynotify.SendLotteryResultNotification(ctx.RequestContext(), notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now()) + _ = lotterynotify.SendLotteryResultNotification(bgCtx, notifyCfg, payerOpenid, actName, rewardNames, orderNo, time.Now()) }(ord.ID, ord.OrderNo, ord.UserID, act.Name) } } diff --git a/internal/api/user/orders_app.go b/internal/api/user/orders_app.go index 46de6e9..f722950 100644 --- a/internal/api/user/orders_app.go +++ b/internal/api/user/orders_app.go @@ -96,10 +96,10 @@ type cancelOrderRequest struct { } type cancelOrderResponse struct { - OrderID int64 `json:"order_id"` - OrderNo string `json:"order_no"` - Status int32 `json:"status"` - CancelledAt time.Time `json:"cancelled_at"` + OrderID int64 `json:"order_id"` + OrderNo string `json:"order_no"` + Status int32 `json:"status"` + CancelledAt *time.Time `json:"cancelled_at"` } // CancelOrder 取消订单 diff --git a/internal/pkg/redis/redis.go b/internal/pkg/redis/redis.go new file mode 100644 index 0000000..fb2e739 --- /dev/null +++ b/internal/pkg/redis/redis.go @@ -0,0 +1,56 @@ +package redis + +import ( + "context" + "sync" + "time" + + "bindbox-game/configs" + "bindbox-game/internal/pkg/logger" + + "github.com/redis/go-redis/v9" + "go.uber.org/zap" +) + +var ( + once sync.Once + client *redis.Client +) + +// Init Initialize Redis client +func Init(ctx context.Context, logger logger.CustomLogger) error { + var err error + once.Do(func() { + cfg := configs.Get().Redis + client = redis.NewClient(&redis.Options{ + Addr: cfg.Addr, + Password: cfg.Pass, + DB: cfg.DB, + DialTimeout: 5 * time.Second, + ReadTimeout: 3 * time.Second, + WriteTimeout: 3 * time.Second, + PoolSize: 20, + }) + + // Ping to verify connection + pingCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + if err = client.Ping(pingCtx).Err(); err != nil { + logger.Error("Failed to connect to Redis", zap.String("addr", cfg.Addr), zap.Error(err)) + return // err set above + } + logger.Info("Connected to Redis", zap.String("addr", cfg.Addr)) + }) + return err +} + +// GetClient Returns the singleton Redis client +func GetClient() *redis.Client { + if client == nil { + // Should have been initialized. If not, panic or try init? + // For safety in this strict environment, let's assume Init was called in main. + // Panic might be safer to detect misuse early. + panic("Redis client not initialized") + } + return client +} diff --git a/internal/repository/mysql/model/activity_reward_settings.gen.go b/internal/repository/mysql/model/activity_reward_settings.gen.go index f4a70e7..a8559bc 100644 --- a/internal/repository/mysql/model/activity_reward_settings.gen.go +++ b/internal/repository/mysql/model/activity_reward_settings.gen.go @@ -26,6 +26,7 @@ type ActivityRewardSettings struct { Level int32 `gorm:"column:level;not null;comment:奖级(如1=S 2=A 3=B)" json:"level"` // 奖级(如1=S 2=A 3=B) Sort int32 `gorm:"column:sort;comment:排序" json:"sort"` // 排序 IsBoss int32 `gorm:"column:is_boss;comment:Boss 1 是 0 不是" json:"is_boss"` // Boss 1 是 0 不是 + MinScore int64 `gorm:"column:min_score;not null;default:0;comment:最低得分/碰数要求" json:"min_score"` // 最低得分/碰数要求 DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"` } diff --git a/internal/repository/mysql/model/orders.gen.go b/internal/repository/mysql/model/orders.gen.go index 80b4453..e731afe 100644 --- a/internal/repository/mysql/model/orders.gen.go +++ b/internal/repository/mysql/model/orders.gen.go @@ -12,24 +12,24 @@ const TableNameOrders = "orders" // Orders 订单 type Orders struct { - ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID - CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间 - UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间 - UserID int64 `gorm:"column:user_id;not null;comment:下单用户ID(user_members.id)" json:"user_id"` // 下单用户ID(user_members.id) - OrderNo string `gorm:"column:order_no;not null;comment:业务订单号(唯一)" json:"order_no"` // 业务订单号(唯一) - SourceType int32 `gorm:"column:source_type;not null;default:1;comment:来源:1商城直购 2抽奖票据 3其他" json:"source_type"` // 来源:1商城直购 2抽奖票据 3其他 - TotalAmount int64 `gorm:"column:total_amount;not null;comment:订单总金额(分)" json:"total_amount"` // 订单总金额(分) - DiscountAmount int64 `gorm:"column:discount_amount;not null;comment:优惠券抵扣金额(分)" json:"discount_amount"` // 优惠券抵扣金额(分) - PointsAmount int64 `gorm:"column:points_amount;not null;comment:积分抵扣金额(分)" json:"points_amount"` // 积分抵扣金额(分) - ActualAmount int64 `gorm:"column:actual_amount;not null;comment:实际支付金额(分)" json:"actual_amount"` // 实际支付金额(分) - Status int32 `gorm:"column:status;not null;default:1;comment:订单状态:1待支付 2已支付 3已取消 4已退款" json:"status"` // 订单状态:1待支付 2已支付 3已取消 4已退款 - PayPreorderID int64 `gorm:"column:pay_preorder_id;comment:关联预支付单ID(payment_preorder.id)" json:"pay_preorder_id"` // 关联预支付单ID(payment_preorder.id) - PaidAt time.Time `gorm:"column:paid_at;comment:支付完成时间" json:"paid_at"` // 支付完成时间 - CancelledAt time.Time `gorm:"column:cancelled_at;comment:取消时间" json:"cancelled_at"` // 取消时间 - UserAddressID int64 `gorm:"column:user_address_id;comment:收货地址ID(user_addresses.id)" json:"user_address_id"` // 收货地址ID(user_addresses.id) - IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产) - PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水ID(user_points_ledger.id)" json:"points_ledger_id"` // 积分扣减流水ID(user_points_ledger.id) - Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注 + ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID + CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:创建时间" json:"created_at"` // 创建时间 + UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3);comment:更新时间" json:"updated_at"` // 更新时间 + UserID int64 `gorm:"column:user_id;not null;comment:下单用户ID(user_members.id)" json:"user_id"` // 下单用户ID(user_members.id) + OrderNo string `gorm:"column:order_no;not null;comment:业务订单号(唯一)" json:"order_no"` // 业务订单号(唯一) + SourceType int32 `gorm:"column:source_type;not null;default:1;comment:来源:1商城直购 2抽奖票据 3其他" json:"source_type"` // 来源:1商城直购 2抽奖票据 3其他 + TotalAmount int64 `gorm:"column:total_amount;not null;comment:订单总金额(分)" json:"total_amount"` // 订单总金额(分) + DiscountAmount int64 `gorm:"column:discount_amount;not null;comment:优惠券抵扣金额(分)" json:"discount_amount"` // 优惠券抵扣金额(分) + PointsAmount int64 `gorm:"column:points_amount;not null;comment:积分抵扣金额(分)" json:"points_amount"` // 积分抵扣金额(分) + ActualAmount int64 `gorm:"column:actual_amount;not null;comment:实际支付金额(分)" json:"actual_amount"` // 实际支付金额(分) + Status int32 `gorm:"column:status;not null;default:1;comment:订单状态:1待支付 2已支付 3已取消 4已退款" json:"status"` // 订单状态:1待支付 2已支付 3已取消 4已退款 + PayPreorderID int64 `gorm:"column:pay_preorder_id;comment:关联预支付单ID(payment_preorder.id)" json:"pay_preorder_id"` // 关联预支付单ID(payment_preorder.id) + PaidAt *time.Time `gorm:"column:paid_at;comment:支付完成时间" json:"paid_at"` // 支付完成时间 + CancelledAt *time.Time `gorm:"column:cancelled_at;comment:取消时间" json:"cancelled_at"` // 取消时间 + UserAddressID int64 `gorm:"column:user_address_id;comment:收货地址ID(user_addresses.id)" json:"user_address_id"` // 收货地址ID(user_addresses.id) + IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产) + PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水ID(user_points_ledger.id)" json:"points_ledger_id"` // 积分扣减流水ID(user_points_ledger.id) + Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注 } // TableName Orders's table name diff --git a/internal/router/router.go b/internal/router/router.go index 66d7bc5..4ac2adc 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -12,8 +12,10 @@ import ( "bindbox-game/internal/dblogger" "bindbox-game/internal/pkg/core" "bindbox-game/internal/pkg/logger" + "bindbox-game/internal/pkg/redis" "bindbox-game/internal/repository/mysql" "bindbox-game/internal/router/interceptor" + "context" "github.com/pkg/errors" ) @@ -39,9 +41,15 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { panic(err) } + // Init Redis + if err := redis.Init(context.Background(), logger); err != nil { + panic(err) + } + rdb := redis.GetClient() + // 实例化拦截器 adminHandler := admin.New(logger, db) - activityHandler := activityapi.New(logger, db) + activityHandler := activityapi.New(logger, db, rdb) taskCenterHandler := taskcenterapi.New(logger, db) userHandler := userapi.New(logger, db) commonHandler := commonapi.New(logger, db) @@ -266,6 +274,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { appPublicApiRouter.GET("/activities/:activity_id/issues", activityHandler.ListActivityIssues()) appPublicApiRouter.GET("/activities/:activity_id/issues/:issue_id/rewards", activityHandler.ListIssueRewards()) appPublicApiRouter.GET("/activities/:activity_id/issues/:issue_id/draw_logs", activityHandler.ListDrawLogs()) + appPublicApiRouter.GET("/activities/:activity_id/issues/:issue_id/draw_logs_grouped", activityHandler.ListDrawLogsByLevel()) // APP 端轮播图 appPublicApiRouter.GET("/banners", appapi.NewBanner(logger, db).ListBannersForApp()) @@ -275,6 +284,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { // 登录保持公开 appPublicApiRouter.POST("/users/weixin/login", userHandler.WeixinLogin()) appPublicApiRouter.POST("/address-share/submit", userHandler.SubmitAddressShare()) + appPublicApiRouter.GET("/matching/card_types", activityHandler.ListMatchingCardTypes()) } // APP 端认证接口路由组 @@ -317,8 +327,8 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, error) { appAuthApiRouter.GET("/lottery/result", activityHandler.LotteryResultByOrder()) // 对对碰游戏 - appAuthApiRouter.POST("/matching/start", activityHandler.StartMatchingGame()) - appAuthApiRouter.POST("/matching/play", activityHandler.PlayMatchingGame()) + appAuthApiRouter.POST("/matching/preorder", activityHandler.PreOrderMatchingGame()) + appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame()) appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState()) appAuthApiRouter.POST("/users/:user_id/inventory/address-share/create", userHandler.CreateAddressShare()) diff --git a/internal/service/activity/activity.go b/internal/service/activity/activity.go index 1d73b1a..4da7c29 100644 --- a/internal/service/activity/activity.go +++ b/internal/service/activity/activity.go @@ -11,219 +11,221 @@ import ( ) type Service interface { - // CreateActivity 创建活动 - // 参数: in 活动创建输入 - // 返回: 活动记录与错误 - CreateActivity(ctx context.Context, in CreateActivityInput) (*model.Activities, error) - // ModifyActivity 修改活动 - // 参数: id 活动ID, in 修改输入 - // 返回: 错误信息 - ModifyActivity(ctx context.Context, id int64, in ModifyActivityInput) error - // DeleteActivity 删除活动 - // 参数: id 活动ID - // 返回: 错误信息 - DeleteActivity(ctx context.Context, id int64) error - // GetActivity 获取活动详情 - // 参数: id 活动ID - // 返回: 活动记录与错误 - GetActivity(ctx context.Context, id int64) (*model.Activities, error) - // ListActivities 活动列表 - // 参数: in 列表查询输入 - // 返回: 活动集合、总数与错误 - ListActivities(ctx context.Context, in ListActivitiesInput) (items []*model.Activities, total int64, err error) + // CreateActivity 创建活动 + // 参数: in 活动创建输入 + // 返回: 活动记录与错误 + CreateActivity(ctx context.Context, in CreateActivityInput) (*model.Activities, error) + // ModifyActivity 修改活动 + // 参数: id 活动ID, in 修改输入 + // 返回: 错误信息 + ModifyActivity(ctx context.Context, id int64, in ModifyActivityInput) error + // DeleteActivity 删除活动 + // 参数: id 活动ID + // 返回: 错误信息 + DeleteActivity(ctx context.Context, id int64) error + // GetActivity 获取活动详情 + // 参数: id 活动ID + // 返回: 活动记录与错误 + GetActivity(ctx context.Context, id int64) (*model.Activities, error) + // ListActivities 活动列表 + // 参数: in 列表查询输入 + // 返回: 活动集合、总数与错误 + ListActivities(ctx context.Context, in ListActivitiesInput) (items []*model.Activities, total int64, err error) - // ListIssues 活动期列表 - // 参数: activityID 活动ID, page 页码, pageSize 每页数量 - // 返回: 期列表、总数与错误 - ListIssues(ctx context.Context, activityID int64, page, pageSize int) (items []*model.ActivityIssues, total int64, err error) - // CreateIssue 创建期 - // 参数: activityID 活动ID, in 创建输入 - // 返回: 期记录与错误 - CreateIssue(ctx context.Context, activityID int64, in CreateIssueInput) (*model.ActivityIssues, error) - // ModifyIssue 修改期 - // 参数: issueID 期ID, in 修改输入 - // 返回: 错误信息 - ModifyIssue(ctx context.Context, issueID int64, in ModifyIssueInput) error - // DeleteIssue 删除期 - // 参数: issueID 期ID - // 返回: 错误信息 - DeleteIssue(ctx context.Context, issueID int64) error + // ListIssues 活动期列表 + // 参数: activityID 活动ID, page 页码, pageSize 每页数量 + // 返回: 期列表、总数与错误 + ListIssues(ctx context.Context, activityID int64, page, pageSize int) (items []*model.ActivityIssues, total int64, err error) + // CreateIssue 创建期 + // 参数: activityID 活动ID, in 创建输入 + // 返回: 期记录与错误 + CreateIssue(ctx context.Context, activityID int64, in CreateIssueInput) (*model.ActivityIssues, error) + // ModifyIssue 修改期 + // 参数: issueID 期ID, in 修改输入 + // 返回: 错误信息 + ModifyIssue(ctx context.Context, issueID int64, in ModifyIssueInput) error + // DeleteIssue 删除期 + // 参数: issueID 期ID + // 返回: 错误信息 + DeleteIssue(ctx context.Context, issueID int64) error - // CreateIssueRewards 批量创建期奖励 - // 参数: issueID 期ID, rewards 奖励创建输入数组 - // 返回: 错误信息 - CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error - // ListIssueRewards 查询期奖励列表 - // 参数: issueID 期ID - // 返回: 奖励集合与错误 - ListIssueRewards(ctx context.Context, issueID int64) (items []*model.ActivityRewardSettings, err error) - // ModifyIssueReward 修改单个奖励 - // 参数: rewardID 奖励ID, in 修改输入 - // 返回: 错误信息 - ModifyIssueReward(ctx context.Context, rewardID int64, in ModifyRewardInput) error - // DeleteIssueReward 删除单个奖励 - // 参数: rewardID 奖励ID - // 返回: 错误信息 - DeleteIssueReward(ctx context.Context, rewardID int64) error + // CreateIssueRewards 批量创建期奖励 + // 参数: issueID 期ID, rewards 奖励创建输入数组 + // 返回: 错误信息 + CreateIssueRewards(ctx context.Context, issueID int64, rewards []CreateRewardInput) error + // ListIssueRewards 查询期奖励列表 + // 参数: issueID 期ID + // 返回: 奖励集合与错误 + ListIssueRewards(ctx context.Context, issueID int64) (items []*model.ActivityRewardSettings, err error) + // ModifyIssueReward 修改单个奖励 + // 参数: rewardID 奖励ID, in 修改输入 + // 返回: 错误信息 + ModifyIssueReward(ctx context.Context, rewardID int64, in ModifyRewardInput) error + // DeleteIssueReward 删除单个奖励 + // 参数: rewardID 奖励ID + // 返回: 错误信息 + DeleteIssueReward(ctx context.Context, rewardID int64) error - // ListDrawLogs 抽奖记录列表 - // 参数: issueID 期ID, page/pageSize 分页 - // 返回: 抽奖记录集合、总数与错误 - ListDrawLogs(ctx context.Context, issueID int64, page, pageSize int) (items []*model.ActivityDrawLogs, total int64, err error) + // ListDrawLogs 抽奖记录列表 + // 参数: issueID 期ID, page/pageSize 分页 + // 返回: 抽奖记录集合、总数与错误 + ListDrawLogs(ctx context.Context, issueID int64, page, pageSize int) (items []*model.ActivityDrawLogs, total int64, err error) - // GetCategoryNames 批量查询分类名称 - // 参数: ids 分类ID数组 - // 返回: id->名称映射与错误 - GetCategoryNames(ctx context.Context, ids []int64) (map[int64]string, error) + // GetCategoryNames 批量查询分类名称 + // 参数: ids 分类ID数组 + // 返回: id->名称映射与错误 + GetCategoryNames(ctx context.Context, ids []int64) (map[int64]string, error) - // CopyActivity 复制活动及其期次与奖励 - // 参数: activityID 源活动ID - // 返回: 新活动ID与错误 - CopyActivity(ctx context.Context, activityID int64) (int64, error) + // CopyActivity 复制活动及其期次与奖励 + // 参数: activityID 源活动ID + // 返回: 新活动ID与错误 + CopyActivity(ctx context.Context, activityID int64) (int64, error) - // SaveActivityDrawConfig 保存活动开奖配置 - // 参数: activityID 活动ID, cfg 配置 - // 返回: 错误信息 - SaveActivityDrawConfig(ctx context.Context, activityID int64, cfg DrawConfig) error - // GetActivityDrawConfig 读取活动开奖配置 - // 参数: activityID 活动ID - // 返回: 配置与错误 - GetActivityDrawConfig(ctx context.Context, activityID int64) (*DrawConfig, error) + // SaveActivityDrawConfig 保存活动开奖配置 + // 参数: activityID 活动ID, cfg 配置 + // 返回: 错误信息 + SaveActivityDrawConfig(ctx context.Context, activityID int64, cfg DrawConfig) error + // GetActivityDrawConfig 读取活动开奖配置 + // 参数: activityID 活动ID + // 返回: 配置与错误 + GetActivityDrawConfig(ctx context.Context, activityID int64) (*DrawConfig, error) } type service struct { - logger logger.CustomLogger - readDB *dao.Query - writeDB *dao.Query - repo mysql.Repo + logger logger.CustomLogger + readDB *dao.Query + writeDB *dao.Query + repo mysql.Repo } func New(l logger.CustomLogger, db mysql.Repo) Service { - return &service{ - logger: l, - readDB: dao.Use(db.GetDbR()), - writeDB: dao.Use(db.GetDbW()), - repo: db, - } + return &service{ + logger: l, + readDB: dao.Use(db.GetDbR()), + writeDB: dao.Use(db.GetDbW()), + repo: db, + } } type CreateActivityInput struct { - // Name 活动名称 - Name string - // Banner 活动头图 - Banner string - // Image 活动主图 - Image string - // GameplayIntro 玩法介绍 - GameplayIntro string - // ActivityCategoryID 活动分类ID - ActivityCategoryID int64 - // Status 活动状态 - Status int32 - // PriceDraw 单次抽奖价格(分) - PriceDraw int64 - // IsBoss 是否Boss活动 - IsBoss int32 - // StartTime 活动开始时间(可选) - StartTime *time.Time - // EndTime 活动结束时间(可选) - EndTime *time.Time - AllowItemCards int32 - AllowCoupons int32 + // Name 活动名称 + Name string + // Banner 活动头图 + Banner string + // Image 活动主图 + Image string + // GameplayIntro 玩法介绍 + GameplayIntro string + // ActivityCategoryID 活动分类ID + ActivityCategoryID int64 + // Status 活动状态 + Status int32 + // PriceDraw 单次抽奖价格(分) + PriceDraw int64 + // IsBoss 是否Boss活动 + IsBoss int32 + // StartTime 活动开始时间(可选) + StartTime *time.Time + // EndTime 活动结束时间(可选) + EndTime *time.Time + AllowItemCards int32 + AllowCoupons int32 } type ModifyActivityInput struct { - // Name 活动名称 - Name string - // Banner 活动头图 - Banner string - // Image 活动主图 - Image string - // GameplayIntro 玩法介绍 - GameplayIntro string - // ActivityCategoryID 活动分类ID - ActivityCategoryID int64 - // Status 活动状态 - Status int32 - // PriceDraw 单次抽奖价格(分) - PriceDraw int64 - // IsBoss 是否Boss活动 - IsBoss int32 - // StartTime 活动开始时间(可选) - StartTime *time.Time - // EndTime 活动结束时间(可选) - EndTime *time.Time - AllowItemCards *int32 - AllowCoupons *int32 + // Name 活动名称 + Name string + // Banner 活动头图 + Banner string + // Image 活动主图 + Image string + // GameplayIntro 玩法介绍 + GameplayIntro string + // ActivityCategoryID 活动分类ID + ActivityCategoryID int64 + // Status 活动状态 + Status int32 + // PriceDraw 单次抽奖价格(分) + PriceDraw int64 + // IsBoss 是否Boss活动 + IsBoss int32 + // StartTime 活动开始时间(可选) + StartTime *time.Time + // EndTime 活动结束时间(可选) + EndTime *time.Time + AllowItemCards *int32 + AllowCoupons *int32 } type ListActivitiesInput struct { - // Name 名称过滤 - Name string - // CategoryID 分类过滤 - CategoryID int64 - // IsBoss Boss过滤 - IsBoss *int32 - // Status 状态过滤 - Status *int32 - // Page 页码 - Page int - // PageSize 每页数量 - PageSize int + // Name 名称过滤 + Name string + // CategoryID 分类过滤 + CategoryID int64 + // IsBoss Boss过滤 + IsBoss *int32 + // Status 状态过滤 + Status *int32 + // Page 页码 + Page int + // PageSize 每页数量 + PageSize int } type CreateIssueInput struct { - // IssueNumber 期号 - IssueNumber string - // Status 状态 - Status int32 - // Sort 排序 - Sort int32 + // IssueNumber 期号 + IssueNumber string + // Status 状态 + Status int32 + // Sort 排序 + Sort int32 } type ModifyIssueInput struct { - // IssueNumber 期号 - IssueNumber string - // Status 状态 - Status int32 - // Sort 排序 - Sort int32 + // IssueNumber 期号 + IssueNumber string + // Status 状态 + Status int32 + // Sort 排序 + Sort int32 } type CreateRewardInput struct { - // ProductID 商品ID - ProductID int64 - // Name 奖励名称 - Name string - // Weight 权重 - Weight int32 - // Quantity 数量(-1 表示不限) - Quantity int64 - // OriginalQty 初始数量 - OriginalQty int64 - // Level 奖励等级 - Level int32 - // Sort 排序 - Sort int32 - // IsBoss 是否Boss奖励 - IsBoss int32 + // ProductID 商品ID + ProductID int64 + // Name 奖励名称 + Name string + // Weight 权重 + Weight int32 + // Quantity 数量(-1 表示不限) + Quantity int64 + // OriginalQty 初始数量 + OriginalQty int64 + // Level 奖励等级 + Level int32 + // Sort 排序 + Sort int32 + // IsBoss 是否Boss奖励 + IsBoss int32 + MinScore int64 } type ModifyRewardInput struct { - // ProductID 商品ID - ProductID *int64 - // Name 奖励名称 - Name string - // Weight 权重 - Weight *int32 - // Quantity 数量(-1 表示不限) - Quantity *int64 - // OriginalQty 初始数量 - OriginalQty *int64 - // Level 奖励等级 - Level *int32 - // Sort 排序 - Sort *int32 - // IsBoss 是否Boss奖励 - IsBoss *int32 + // ProductID 商品ID + ProductID *int64 + // Name 奖励名称 + Name string + // Weight 权重 + Weight *int32 + // Quantity 数量(-1 表示不限) + Quantity *int64 + // OriginalQty 初始数量 + OriginalQty *int64 + // Level 奖励等级 + Level *int32 + // Sort 排序 + Sort *int32 + // IsBoss 是否Boss奖励 + IsBoss *int32 + MinScore *int64 } diff --git a/internal/service/activity/rewards_create.go b/internal/service/activity/rewards_create.go index 0a77f76..8b5b784 100644 --- a/internal/service/activity/rewards_create.go +++ b/internal/service/activity/rewards_create.go @@ -23,6 +23,7 @@ func (s *service) CreateIssueRewards(ctx context.Context, issueID int64, rewards Level: r.Level, Sort: r.Sort, IsBoss: r.IsBoss, + MinScore: r.MinScore, } if err := tx.ActivityRewardSettings.WithContext(ctx).Create(item); err != nil { return err diff --git a/internal/service/activity/rewards_modify.go b/internal/service/activity/rewards_modify.go index 056e89c..0bc0f03 100644 --- a/internal/service/activity/rewards_modify.go +++ b/internal/service/activity/rewards_modify.go @@ -39,6 +39,9 @@ func (s *service) ModifyIssueReward(ctx context.Context, rewardID int64, in Modi if in.IsBoss != nil { item.IsBoss = *in.IsBoss } + if in.MinScore != nil { + item.MinScore = *in.MinScore + } item.UpdatedAt = time.Now() return s.writeDB.ActivityRewardSettings.WithContext(ctx).Save(item) } diff --git a/internal/service/activity/scheduler.go b/internal/service/activity/scheduler.go index 6134bd1..e6932c7 100644 --- a/internal/service/activity/scheduler.go +++ b/internal/service/activity/scheduler.go @@ -177,7 +177,7 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) { } // 使用 claim 中的 slot_index 直接获取奖品 - rid, _, err := ichibanSel.SelectItemBySlot(ctx, aid, iss, claim.SlotIndex) + rid, proof, err := ichibanSel.SelectItemBySlot(ctx, aid, iss, claim.SlotIndex) if err != nil || rid <= 0 { fmt.Printf("[定时开奖-一番赏] ❌ SelectItemBySlot失败 err=%v rid=%d\n", err, rid) continue @@ -219,6 +219,13 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) { } fmt.Printf("[定时开奖-一番赏] ✅ 开奖成功 UserID=%d OrderID=%d RewardID=%d\n", uid, o.ID, rid) + + // 保存可验证凭据 + if err := strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof); err != nil { + fmt.Printf("[定时开奖-一番赏] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v proof=%+v\n", drawLog.ID, iss, uid, err, proof) + } else { + fmt.Printf("[定时开奖-一番赏] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss) + } } // 【开奖后虚拟发货】定时一番赏开奖后上传虚拟发货 uploadVirtualShippingForScheduledDraw(ctx, r, o.ID, o.OrderNo, uid, func() string { @@ -259,7 +266,11 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) { // 发放奖励(在原订单上添加中奖商品,不创建新订单) _, _ = us.GrantRewardToOrder(ctx, uid, usersvc.GrantRewardToOrderRequest{OrderID: o.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &aid, RewardID: &rid, Remark: rw.Name}) // 保存可验证凭据 - _ = strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof) + if err := strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof); err != nil { + fmt.Printf("[定时开奖-默认] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v proof=%+v\n", drawLog.ID, iss, uid, err, proof) + } else { + fmt.Printf("[定时开奖-默认] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss) + } } // 【开奖后虚拟发货】定时开奖后上传虚拟发货 uploadVirtualShippingForScheduledDraw(ctx, r, o.ID, o.OrderNo, uid, func() string { @@ -324,7 +335,11 @@ func StartScheduledSettlement(l logger.CustomLogger, repo mysql.Repo) { } _, _ = us.GrantRewardToOrder(ctx, uid, usersvc.GrantRewardToOrderRequest{OrderID: o2.ID, ProductID: rw.ProductID, Quantity: 1, ActivityID: &ia.ID, RewardID: &rid, Remark: rw.Name}) // 保存可验证凭据 - _ = strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof) + if err := strat.SaveDrawReceipt(ctx, w, drawLog.ID, iss, uid, proof); err != nil { + fmt.Printf("[即时开奖补偿] ⚠️ 保存凭据失败 DrawLogID=%d IssueID=%d UserID=%d err=%v proof=%+v\n", drawLog.ID, iss, uid, err, proof) + } else { + fmt.Printf("[即时开奖补偿] ✅ 保存凭据成功 DrawLogID=%d IssueID=%d\n", drawLog.ID, iss) + } } } } diff --git a/internal/service/activity/strategy/ichiban.go b/internal/service/activity/strategy/ichiban.go index 9553999..47dc590 100644 --- a/internal/service/activity/strategy/ichiban.go +++ b/internal/service/activity/strategy/ichiban.go @@ -1,56 +1,77 @@ package strategy import ( - "crypto/hmac" - "crypto/sha256" - "encoding/binary" - "errors" - "fmt" - "context" - "bindbox-game/internal/repository/mysql/dao" + "bindbox-game/internal/repository/mysql/dao" + "context" + "crypto/hmac" + "crypto/sha256" + "encoding/binary" + "errors" + "fmt" ) type ichibanStrategy struct { - read *dao.Query - write *dao.Query + read *dao.Query + write *dao.Query } func NewIchiban(read *dao.Query, write *dao.Query) *ichibanStrategy { - return &ichibanStrategy{read: read, write: write} + return &ichibanStrategy{read: read, write: write} } func (s *ichibanStrategy) SelectItemBySlot(ctx context.Context, activityID int64, issueID int64, slotIndex int64) (int64, map[string]any, error) { - act, err := s.read.Activities.WithContext(ctx).Where(s.read.Activities.ID.Eq(activityID)).First() - if err != nil || act == nil || len(act.CommitmentSeedMaster) == 0 { return 0, nil, errors.New("commitment not found") } - rewards, err := s.read.ActivityRewardSettings.WithContext(ctx).ReadDB().Where(s.read.ActivityRewardSettings.IssueID.Eq(issueID)).Order( - s.read.ActivityRewardSettings.Level.Desc(), - s.read.ActivityRewardSettings.Sort.Asc(), - s.read.ActivityRewardSettings.ID.Asc(), - ).Find() - if err != nil || len(rewards) == 0 { return 0, nil, errors.New("no rewards") } - var totalSlots int64 - for _, r := range rewards { - if r.OriginalQty > 0 { totalSlots += r.OriginalQty } - } - if totalSlots <= 0 { return 0, nil, errors.New("no slots") } - if slotIndex < 0 || slotIndex >= totalSlots { return 0, nil, errors.New("slot out of range") } - // build list - slots := make([]int64, 0, totalSlots) - for _, r := range rewards { - for i := int64(0); i < r.OriginalQty; i++ { slots = append(slots, r.ID) } - } - // deterministic shuffle by server seed - mac := hmac.New(sha256.New, act.CommitmentSeedMaster) - for i := int(totalSlots-1); i > 0; i-- { - mac.Reset() - mac.Write([]byte(fmt.Sprintf("shuffle:%d|issue:%d", i, issueID))) - sum := mac.Sum(nil) - rnd := int(binary.BigEndian.Uint64(sum[:8]) % uint64(i+1)) - slots[i], slots[rnd] = slots[rnd], slots[i] - } - picked := slots[slotIndex] - proof := map[string]any{"total_slots": totalSlots, "slot_index": slotIndex} - return picked, proof, nil + act, err := s.read.Activities.WithContext(ctx).Where(s.read.Activities.ID.Eq(activityID)).First() + if err != nil || act == nil || len(act.CommitmentSeedMaster) == 0 { + return 0, nil, errors.New("commitment not found") + } + rewards, err := s.read.ActivityRewardSettings.WithContext(ctx).ReadDB().Where(s.read.ActivityRewardSettings.IssueID.Eq(issueID)).Order( + s.read.ActivityRewardSettings.Level.Desc(), + s.read.ActivityRewardSettings.Sort.Asc(), + s.read.ActivityRewardSettings.ID.Asc(), + ).Find() + if err != nil || len(rewards) == 0 { + return 0, nil, errors.New("no rewards") + } + var totalSlots int64 + for _, r := range rewards { + if r.OriginalQty > 0 { + totalSlots += r.OriginalQty + } + } + if totalSlots <= 0 { + return 0, nil, errors.New("no slots") + } + if slotIndex < 0 || slotIndex >= totalSlots { + return 0, nil, errors.New("slot out of range") + } + // build list + slots := make([]int64, 0, totalSlots) + for _, r := range rewards { + for i := int64(0); i < r.OriginalQty; i++ { + slots = append(slots, r.ID) + } + } + // deterministic shuffle by server seed + mac := hmac.New(sha256.New, act.CommitmentSeedMaster) + for i := int(totalSlots - 1); i > 0; i-- { + mac.Reset() + mac.Write([]byte(fmt.Sprintf("shuffle:%d|issue:%d", i, issueID))) + sum := mac.Sum(nil) + rnd := int(binary.BigEndian.Uint64(sum[:8]) % uint64(i+1)) + slots[i], slots[rnd] = slots[rnd], slots[i] + } + picked := slots[slotIndex] + + // Calculate seed hash for proof + sha := sha256.Sum256(act.CommitmentSeedMaster) + seedHash := fmt.Sprintf("%x", sha) + + proof := map[string]any{ + "total_slots": totalSlots, + "slot_index": slotIndex, + "seed_hash": seedHash, + } + return picked, proof, nil } func (s *ichibanStrategy) GrantReward(ctx context.Context, userID int64, rewardID int64) error { diff --git a/internal/service/activity/strategy/ichiban_verify_placeholder.go b/internal/service/activity/strategy/ichiban_verify_placeholder.go new file mode 100644 index 0000000..2eb0e6b --- /dev/null +++ b/internal/service/activity/strategy/ichiban_verify_placeholder.go @@ -0,0 +1,9 @@ +package strategy + +// Mock objects for testing (simplified) +// Since we can't easily mock the full DAO chain without a comprehensive mock library or interface, +// we will rely on checking the logic I just added: manual inspection of the code change was logically sound. +// However, to be thorough, I'll write a test that mocks the DB interactions if possible, or use the existing test file `ichiban_test.go` as a base. + +// Let's assume `ichiban_test.go` exists (I saw it in the file list earlier). +// I will read it first to see if I can add a case there. diff --git a/internal/service/activity/strategy/ichiban_verify_test.go b/internal/service/activity/strategy/ichiban_verify_test.go new file mode 100644 index 0000000..a35631b --- /dev/null +++ b/internal/service/activity/strategy/ichiban_verify_test.go @@ -0,0 +1,66 @@ +package strategy + +import ( + "bindbox-game/internal/repository/mysql/dao" + "bindbox-game/internal/repository/mysql/model" + "context" + "crypto/sha256" + "fmt" + "testing" + + "gorm.io/driver/sqlite" + "gorm.io/gorm" +) + +func TestIchibanProofHasSeedHash(t *testing.T) { + // 1. Setup In-Memory DB + db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{}) + if err != nil { + t.Fatalf("db open err: %v", err) + } + + // 2. Migrate Tables + db.Exec(`CREATE TABLE activities (id INTEGER PRIMARY KEY AUTOINCREMENT, commitment_seed_master BLOB, commitment_state_version INTEGER, deleted_at DATETIME);`) + db.Exec(`CREATE TABLE activity_reward_settings (id INTEGER PRIMARY KEY AUTOINCREMENT, created_at DATETIME, updated_at DATETIME, issue_id INTEGER NOT NULL, product_id INTEGER, name TEXT NOT NULL, weight INTEGER NOT NULL, quantity INTEGER NOT NULL, original_qty INTEGER NOT NULL, level INTEGER NOT NULL, sort INTEGER NOT NULL, is_boss INTEGER NOT NULL, deleted_at DATETIME);`) + + q := dao.Use(db) + + // 3. Insert Test Data + seedBytes := []byte("testseedvalue") + expectedHash := fmt.Sprintf("%x", sha256.Sum256(seedBytes)) + + db.Exec("INSERT INTO activities (id, commitment_seed_master, commitment_state_version) VALUES (?, ?, ?)", 100, seedBytes, 1) + + r1 := &model.ActivityRewardSettings{IssueID: 10, Name: "A", Weight: 10, Quantity: 10, OriginalQty: 5, Level: 1, Sort: 1} + q.ActivityRewardSettings.Create(r1) + + // 4. Test SelectItemBySlot + s := NewIchiban(q, q) + ctx := context.Background() + + _, proof, err := s.SelectItemBySlot(ctx, 100, 10, 0) + if err != nil { + t.Fatalf("SelectItemBySlot failed: %v", err) + } + + // 5. Verify seed_hash is in proof + if proof == nil { + t.Fatal("proof is nil") + } + + val, ok := proof["seed_hash"] + if !ok { + t.Fatal("seed_hash missing from proof") + } + + seedHash, ok := val.(string) + if !ok { + t.Fatalf("seed_hash is not a string, got %T", val) + } + + if seedHash != expectedHash { + t.Fatalf("seed_hash mismatch. got %s, want %s", seedHash, expectedHash) + } + + t.Logf("Success: seed_hash found in proof: %s", seedHash) +} diff --git a/internal/service/user/orders_list.go b/internal/service/user/orders_list.go index 09cd37e..e768e7b 100644 --- a/internal/service/user/orders_list.go +++ b/internal/service/user/orders_list.go @@ -7,6 +7,28 @@ import ( "bindbox-game/internal/repository/mysql/model" ) +// DrawReceiptInfo 抽奖验证凭据信息 +type DrawReceiptInfo struct { + DrawLogID int64 `json:"draw_log_id"` + RewardID int64 `json:"reward_id,omitempty"` + DrawIndex int `json:"draw_index"` + AlgoVersion string `json:"algo_version"` + RoundID int64 `json:"round_id"` + DrawID int64 `json:"draw_id"` + ClientID int64 `json:"client_id"` + Timestamp int64 `json:"timestamp"` + ServerSeedHash string `json:"server_seed_hash"` + ServerSubSeed string `json:"server_sub_seed"` + ClientSeed string `json:"client_seed"` + Nonce int64 `json:"nonce"` + ItemsRoot string `json:"items_root"` + WeightsTotal int64 `json:"weights_total"` + SelectedIndex int32 `json:"selected_index"` + RandProof string `json:"rand_proof"` + Signature string `json:"signature,omitempty"` + ItemsSnapshot string `json:"items_snapshot,omitempty"` +} + // OrderWithItems 包含订单项的订单信息 type OrderWithItems struct { *model.Orders @@ -16,6 +38,7 @@ type OrderWithItems struct { IsDraw bool `json:"is_draw"` IsWinner bool `json:"is_winner"` RewardLevel int32 `json:"reward_level"` + DrawReceipts []*DrawReceiptInfo `json:"draw_receipts"` } func (s *service) ListOrders(ctx context.Context, userID int64, page, pageSize int) (items []*model.Orders, total int64, err error) { @@ -97,9 +120,11 @@ func (s *service) GetOrderWithItems(ctx context.Context, userID int64, orderID i } // 补充开奖信息 - log, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).ReadDB().Where(s.readDB.ActivityDrawLogs.OrderID.Eq(order.ID)).First() - if log != nil { + logs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).ReadDB().Where(s.readDB.ActivityDrawLogs.OrderID.Eq(order.ID)).Find() + if len(logs) > 0 { res.IsDraw = true + // 取第一条记录的信息 + log := logs[0] res.IsWinner = log.IsWinner == 1 res.RewardLevel = log.Level issue, _ := s.readDB.ActivityIssues.WithContext(ctx).ReadDB().Where(s.readDB.ActivityIssues.ID.Eq(log.IssueID)).First() @@ -110,6 +135,49 @@ func (s *service) GetOrderWithItems(ctx context.Context, userID int64, orderID i res.ActivityName = act.Name } } + + // 查询抽奖凭据 + drawLogIDs := make([]int64, len(logs)) + drawLogIDToIndex := make(map[int64]int) + for i, lg := range logs { + drawLogIDs[i] = lg.ID + drawLogIDToIndex[lg.ID] = i + 1 + } + receipts, _ := s.readDB.ActivityDrawReceipts.WithContext(ctx).ReadDB().Where(s.readDB.ActivityDrawReceipts.DrawLogID.In(drawLogIDs...)).Find() + if len(receipts) > 0 { + res.DrawReceipts = make([]*DrawReceiptInfo, 0, len(receipts)) + for _, r := range receipts { + drawIndex := drawLogIDToIndex[r.DrawLogID] + // 找到对应的 reward_id + var rewardID int64 + for _, lg := range logs { + if lg.ID == r.DrawLogID { + rewardID = lg.RewardID + break + } + } + res.DrawReceipts = append(res.DrawReceipts, &DrawReceiptInfo{ + DrawLogID: r.DrawLogID, + RewardID: rewardID, + DrawIndex: drawIndex, + AlgoVersion: r.AlgoVersion, + RoundID: r.RoundID, + DrawID: r.DrawID, + ClientID: r.ClientID, + Timestamp: r.Timestamp, + ServerSeedHash: r.ServerSeedHash, + ServerSubSeed: r.ServerSubSeed, + ClientSeed: r.ClientSeed, + Nonce: r.Nonce, + ItemsRoot: r.ItemsRoot, + WeightsTotal: r.WeightsTotal, + SelectedIndex: r.SelectedIndex, + RandProof: r.RandProof, + Signature: r.Signature, + ItemsSnapshot: r.ItemsSnapshot, + }) + } + } } return res, nil @@ -196,16 +264,18 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status } // 批量查询活动开奖信息 - drawLogsMap := make(map[int64]*model.ActivityDrawLogs) + drawLogsListMap := make(map[int64][]*model.ActivityDrawLogs) // orderID -> logs activityMap := make(map[int64]*model.Activities) issueMap := make(map[int64]*model.ActivityIssues) + var allDrawLogIDs []int64 if len(orderIDs) > 0 { logs, _ := s.readDB.ActivityDrawLogs.WithContext(ctx).ReadDB().Where(s.readDB.ActivityDrawLogs.OrderID.In(orderIDs...)).Find() var issueIDs []int64 for _, log := range logs { - drawLogsMap[log.OrderID] = log + drawLogsListMap[log.OrderID] = append(drawLogsListMap[log.OrderID], log) issueIDs = append(issueIDs, log.IssueID) + allDrawLogIDs = append(allDrawLogIDs, log.ID) } if len(issueIDs) > 0 { @@ -225,6 +295,15 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status } } + // 批量查询抽奖凭据 + receiptsMap := make(map[int64]*model.ActivityDrawReceipts) // drawLogID -> receipt + if len(allDrawLogIDs) > 0 { + receipts, _ := s.readDB.ActivityDrawReceipts.WithContext(ctx).ReadDB().Where(s.readDB.ActivityDrawReceipts.DrawLogID.In(allDrawLogIDs...)).Find() + for _, r := range receipts { + receiptsMap[r.DrawLogID] = r + } + } + // 构建返回结果 items = make([]*OrderWithItems, len(orders)) for i, order := range orders { @@ -233,8 +312,10 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status Items: itemsMap[order.ID], } - if log, ok := drawLogsMap[order.ID]; ok { + if logs, ok := drawLogsListMap[order.ID]; ok && len(logs) > 0 { items[i].IsDraw = true + // 取第一条记录的基本信息 + log := logs[0] items[i].IsWinner = log.IsWinner == 1 items[i].RewardLevel = log.Level if issue, ok := issueMap[log.IssueID]; ok { @@ -243,6 +324,32 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status items[i].ActivityName = act.Name } } + + // 填充抽奖凭据 + for idx, lg := range logs { + if r, ok := receiptsMap[lg.ID]; ok { + items[i].DrawReceipts = append(items[i].DrawReceipts, &DrawReceiptInfo{ + DrawLogID: r.DrawLogID, + RewardID: lg.RewardID, + DrawIndex: idx + 1, + AlgoVersion: r.AlgoVersion, + RoundID: r.RoundID, + DrawID: r.DrawID, + ClientID: r.ClientID, + Timestamp: r.Timestamp, + ServerSeedHash: r.ServerSeedHash, + ServerSubSeed: r.ServerSubSeed, + ClientSeed: r.ClientSeed, + Nonce: r.Nonce, + ItemsRoot: r.ItemsRoot, + WeightsTotal: r.WeightsTotal, + SelectedIndex: r.SelectedIndex, + RandProof: r.RandProof, + Signature: r.Signature, + ItemsSnapshot: r.ItemsSnapshot, + }) + } + } } } diff --git a/internal/service/user/reward_grant.go b/internal/service/user/reward_grant.go index 4c7952c..f7fa429 100644 --- a/internal/service/user/reward_grant.go +++ b/internal/service/user/reward_grant.go @@ -91,8 +91,8 @@ func (s *service) GrantReward(ctx context.Context, userID int64, req GrantReward PointsAmount: 0, ActualAmount: 0, IsConsumed: 0, - PaidAt: now, // 设置支付时间为当前时间 - CancelledAt: minValidTime, // 设置取消时间为最小有效时间,避免MySQL错误 + PaidAt: &now, // 设置支付时间为当前时间 + CancelledAt: &minValidTime, // 设置取消时间为最小有效时间,避免MySQL错误 Remark: req.Remark, CreatedAt: now, UpdatedAt: now, diff --git a/logs/mini-chat-access.log b/logs/mini-chat-access.log index 38ff361..37fb948 100644 --- a/logs/mini-chat-access.log +++ b/logs/mini-chat-access.log @@ -18,3 +18,4 @@ {"level":"fatal","time":"2025-12-08 21:00:04","caller":"logger/logger.go:333","msg":"http server startup err","domain":"mini-chat[fat]","error":"listen tcp :9991: bind: address already in use"} {"level":"fatal","time":"2025-12-11 00:56:11","caller":"logger/logger.go:333","msg":"http server startup err","domain":"mini-chat[fat]","error":"listen tcp :9991: bind: address already in use"} {"level":"fatal","time":"2025-12-11 15:05:14","caller":"logger/logger.go:333","msg":"http server startup err","domain":"mini-chat[fat]","error":"listen tcp :9991: bind: address already in use"} +{"level":"info","time":"2025-12-21 14:34:33","caller":"logger/logger.go:309","msg":"Connected to Redis","domain":"mini-chat[fat]","addr":"118.25.13.43:8379"} diff --git a/scripts/matching_simulation.py b/scripts/matching_simulation.py new file mode 100644 index 0000000..612a156 --- /dev/null +++ b/scripts/matching_simulation.py @@ -0,0 +1,124 @@ +import random +import collections + +# Configuration: 11 types, 9 cards each. Total 99 cards. +CARD_TYPES = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K'] +CARDS_PER_TYPE = 9 +BOARD_SIZE = 9 + +def create_deck(): + """Create a full deck of 99 cards.""" + deck = [] + for t in CARD_TYPES: + for _ in range(CARDS_PER_TYPE): + deck.append(t) + random.shuffle(deck) + return deck + +def play_one_game(): + """Simulate one full game session.""" + # 1. Initialize + full_deck = create_deck() + board = full_deck[:BOARD_SIZE] + deck = full_deck[BOARD_SIZE:] + + pairs_found = 0 + + # Loop until game over + while True: + # 2. Check for matches on board + # Strategy: Always eliminate the first pair found. + # (In real game, user choice might matter, but for max pairs, + # elimination order rarely changes the total count if we have infinite reshuffles) + + counts = collections.Counter(board) + match_type = None + for t, count in counts.items(): + if count >= 2: + match_type = t + break + + if match_type: + # ELIMINATE + pairs_found += 1 + + # Remove 2 instances of match_type + removed_count = 0 + new_board = [] + for card in board: + if card == match_type and removed_count < 2: + removed_count += 1 + continue # Skip (remove) + new_board.append(card) + board = new_board + + # Fill from deck + while len(board) < BOARD_SIZE and len(deck) > 0: + board.append(deck.pop(0)) + + else: + # DEADLOCK (No matches on board) + if len(deck) == 0: + # Game Over + break + else: + # RESHUFFLE + # Collect all remaining cards (board + deck) + remaining = board + deck + random.shuffle(remaining) + + # Refill board and deck + board = remaining[:BOARD_SIZE] + deck = remaining[BOARD_SIZE:] + + # Check if new board has matches. + # Theoretically possibility of repeated deadlocks, but loop continues. + # To prevent infinite loop in case of bad logic (e.g. 1 card left), check solvability. + # But here we have types. If we have pairs left in TOTAL, reshuffle will eventually bring them to board. + + # Optimization: If total remaining of every type is < 2, we can never match again. + total_counts = collections.Counter(remaining) + can_match = False + for t, c in total_counts.items(): + if c >= 2: + can_match = True + break + + if not can_match: + # Impossible to match anything more + break + + return pairs_found + +def run_simulation(times=10000): + print(f"Simulating {times} games...") + print(f"Config: {len(CARD_TYPES)} types x {CARDS_PER_TYPE} cards = {len(CARD_TYPES)*CARDS_PER_TYPE} total.") + + results = [] + for i in range(times): + score = play_one_game() + results.append(score) + if (i+1) % 1000 == 0: + print(f"Progress: {i+1}/{times}") + + max_score = max(results) + min_score = min(results) + avg_score = sum(results) / len(results) + + print("\n=== Results ===") + print(f"Max Pairs: {max_score}") + print(f"Min Pairs: {min_score}") + print(f"Avg Pairs: {avg_score:.2f}") + + # Theoretical Max + # floor(9/2) * 11 = 4 * 11 = 44 + print(f"Theoretical Max: {int(CARDS_PER_TYPE/2) * len(CARD_TYPES)}") + + # Distribution + dist = collections.Counter(results) + print("\nDistribution:") + for score in sorted(dist.keys()): + print(f"{score} pairs: {dist[score]} times") + +if __name__ == "__main__": + run_simulation() diff --git a/bin/server b/tmp_build similarity index 80% rename from bin/server rename to tmp_build index fd6872c..f111c97 100755 Binary files a/bin/server and b/tmp_build differ