feat: 添加对对碰游戏功能与Redis支持

refactor: 重构抽奖逻辑以支持可验证凭据
feat(redis): 集成Redis客户端并添加配置支持
fix: 修复订单取消时的优惠券和库存处理逻辑
docs: 添加对对碰游戏前端对接指南和示例JSON
test: 添加对对碰游戏模拟测试和验证逻辑
This commit is contained in:
邹方成 2025-12-21 17:31:32 +08:00
parent 45815bfb7d
commit e2782a69d3
42 changed files with 3581 additions and 911 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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..."
}

View File

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

View File

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

View File

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

View File

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

4
go.mod
View File

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

10
go.sum
View File

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

View File

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

View File

@ -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"`
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
package app

View File

@ -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()
...
*/
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 取消订单

View File

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

View File

@ -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"`
}

View File

@ -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:下单用户IDuser_members.id" json:"user_id"` // 下单用户IDuser_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:关联预支付单IDpayment_preorder.id" json:"pay_preorder_id"` // 关联预支付单IDpayment_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:收货地址IDuser_addresses.id" json:"user_address_id"` // 收货地址IDuser_addresses.id
IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产)
PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水IDuser_points_ledger.id" json:"points_ledger_id"` // 积分扣减流水IDuser_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:下单用户IDuser_members.id" json:"user_id"` // 下单用户IDuser_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:关联预支付单IDpayment_preorder.id" json:"pay_preorder_id"` // 关联预支付单IDpayment_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:收货地址IDuser_addresses.id" json:"user_address_id"` // 收货地址IDuser_addresses.id
IsConsumed int32 `gorm:"column:is_consumed;not null;comment:是否已履约/消耗(对虚拟资产)" json:"is_consumed"` // 是否已履约/消耗(对虚拟资产)
PointsLedgerID int64 `gorm:"column:points_ledger_id;comment:积分扣减流水IDuser_points_ledger.id" json:"points_ledger_id"` // 积分扣减流水IDuser_points_ledger.id
Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注
}
// TableName Orders's table name

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.