feat: 新增订单列表筛选条件与活动信息展示

refactor(orders): 重构订单列表查询逻辑,支持按消耗状态筛选
feat(orders): 订单列表返回新增活动分类与玩法类型信息
fix(orders): 修复订单支付时间空指针问题
docs(swagger): 更新订单相关接口文档
test(matching): 添加对对碰奖励匹配测试用例
chore: 清理无用脚本文件
This commit is contained in:
邹方成 2025-12-22 15:15:18 +08:00
parent c8b04e2bc6
commit 16e2ede037
23 changed files with 2485 additions and 1146 deletions

BIN
build.zip

Binary file not shown.

View File

@ -1,31 +1,50 @@
[mysql]
[language]
local = 'zh-cn'
[mysql.read]
addr = "127.0.0.1:3306"
user = "root"
pass = "123456"
name = "bindbox_game"
[mysql.write]
addr = "127.0.0.1:3306"
user = "root"
pass = "123456"
name = "bindbox_game"
addr = 'sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555'
name = 'bindbox_game'
pass = 'api2api..'
user = 'root'
[redis]
addr = "118.25.13.43:8379"
pass = "xbm#2023by1024"
db = 5
[random]
commit_master_key = "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
[mysql.write]
addr = 'sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555'
name = 'bindbox_game'
pass = 'api2api..'
user = 'root'
[jwt]
admin_secret = "m9ycX9RTPyuYTWw9FrCc"
[wechat]
app_id = ""
app_secret = ""
lottery_result_template_id = ""
app_id = "wx26ad074017e1e63f"
app_secret = "026c19ce4f3bb090c56573024c59a8be"
lottery_result_template_id = "O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI"
[cos]
bucket = "keaiya-1259195914"
region = "ap-shanghai"
secret_id = "AKIDtjPtAFPNDuR1UnxvoUCoRAnJgw164Zv6"
secret_key = "B0vvjMoMsKcipnJlLnFyWt6A2JRSJ0Wr"
# 可选:如有 CDN/自定义域名则填写,否则留空
base_url = ""
[random]
commit_master_key = "4d7a3b8f9c2e1a5d6b4f8c0e3a7d2b1c6f9e4a5d8c1b3f7a2e5d6c4b8f0e3a7d2b1c"
[wechatpay]
mchid = ""
serial_no = ""
private_key_path = ""
api_v3_key = ""
notify_url = "http://localhost:9991/api/pay/wechat/notify"
mchid = "1610439635"
serial_no = "3AFD505D597831F8E931EBFFEEB5976B81F66F03"
private_key_path = "./configs/cert/apiclient_key.pem"
api_v3_key = "3tbwEFZV3fZtOslpUJC7Sacb8qjzhm05"
notify_url = "https://mini-chat.1024tool.vip/api/pay/wechat/notify"
public_key_id = "PUB_KEY_ID_0116104396352025041000211519001600"
public_key_path = "./configs/cert/pub_key.pem"

View File

@ -3658,6 +3658,35 @@ const docTemplate = `{
}
}
},
"/api/app/categories": {
"get": {
"description": "获取APP端商品分类列表",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"APP端.基础"
],
"summary": "获取分类列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/app.listAppCategoriesResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/code.Failure"
}
}
}
}
},
"/api/app/lottery/join": {
"post": {
"security": [
@ -3912,6 +3941,29 @@ const docTemplate = `{
}
}
},
"/api/app/notices": {
"get": {
"description": "获取APP首页滚动公告",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"APP端.基础"
],
"summary": "获取公告列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/app.listAppNoticesResponse"
}
}
}
}
},
"/api/app/orders/{order_id}": {
"get": {
"security": [
@ -4148,6 +4200,189 @@ const docTemplate = `{
}
}
},
"/api/app/store/items": {
"get": {
"security": [
{
"LoginVerifyToken": []
}
],
"description": "分页获取积分商城商品列表,支持按类型筛选(product/item_card/coupon)",
"consumes": [
"application/x-www-form-urlencoded"
],
"produces": [
"application/json"
],
"tags": [
"APP端.积分商城"
],
"summary": "获取积分商城商品列表",
"parameters": [
{
"type": "string",
"description": "商品类型: product(默认), item_card, coupon",
"name": "kind",
"in": "query"
},
{
"type": "integer",
"default": 1,
"description": "页码",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "每页数量",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/app.listStoreItemsResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/code.Failure"
}
}
}
}
},
"/api/app/task-center/tasks": {
"get": {
"description": "获取当前可用的任务列表,支持分页",
"consumes": [
"application/x-www-form-urlencoded"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "获取任务列表(App)",
"parameters": [
{
"type": "integer",
"default": 1,
"description": "页码",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "每页数量",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "任务列表",
"schema": {
"$ref": "#/definitions/taskcenter.listTasksResponse"
}
}
}
}
},
"/api/app/task-center/tasks/{id}/claim/{user_id}": {
"post": {
"description": "用户领取指定任务层级的奖励",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "领取任务奖励(App)",
"parameters": [
{
"type": "integer",
"description": "任务ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"description": "领取请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/taskcenter.claimTaskRequest"
}
}
],
"responses": {
"200": {
"description": "领取成功",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/app/task-center/tasks/{id}/progress/{user_id}": {
"get": {
"description": "获取指定用户在特定任务上的进度详情",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "获取用户任务进度(App)",
"parameters": [
{
"type": "integer",
"description": "任务ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "任务进度详情",
"schema": {
"$ref": "#/definitions/taskcenter.taskProgressResponse"
}
}
}
}
},
"/api/app/users/weixin/login": {
"post": {
"description": "微信静默登录(需传递 code可选 invite_code",
@ -5218,6 +5453,18 @@ const docTemplate = `{
"name": "page_size",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "订单状态1待支付 2已支付 3已取消 4已退款",
"name": "status",
"in": "query"
},
{
"type": "integer",
"description": "是否已消耗/履约0否 1是",
"name": "is_consumed",
"in": "query"
}
],
"responses": {
@ -5435,6 +5682,58 @@ const docTemplate = `{
}
}
},
"/api/app/users/{user_id}/points/redeem-item-card": {
"post": {
"security": [
{
"LoginVerifyToken": []
}
],
"description": "使用积分兑换指定数量的道具卡",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"APP端.用户"
],
"summary": "积分兑换道具卡",
"parameters": [
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"description": "兑换请求参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/app.redeemItemCardRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/app.redeemItemCardResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/code.Failure"
}
}
}
}
},
"/api/app/users/{user_id}/points/redeem-product": {
"post": {
"security": [
@ -5728,133 +6027,6 @@ const docTemplate = `{
"responses": {}
}
},
"/app/task_center/tasks": {
"get": {
"description": "获取当前可用的任务列表,支持分页",
"consumes": [
"application/x-www-form-urlencoded"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "获取任务列表(App)",
"parameters": [
{
"type": "integer",
"default": 1,
"description": "页码",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "每页数量",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "任务列表",
"schema": {
"$ref": "#/definitions/taskcenter.listTasksResponse"
}
}
}
}
},
"/app/task_center/tasks/{id}/progress/{user_id}": {
"get": {
"description": "获取指定用户在特定任务上的进度详情",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "获取用户任务进度(App)",
"parameters": [
{
"type": "integer",
"description": "任务ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "任务进度详情",
"schema": {
"$ref": "#/definitions/taskcenter.taskProgressResponse"
}
}
}
}
},
"/app/task_center/tasks/{id}/users/{user_id}/claim": {
"post": {
"description": "用户领取指定任务层级的奖励",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "领取任务奖励(App)",
"parameters": [
{
"type": "integer",
"description": "任务ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"description": "领取请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/taskcenter.claimTaskRequest"
}
}
],
"responses": {
"200": {
"description": "领取成功",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/common/upload/wangeditor": {
"post": {
"description": "适配 WangEditor 的图片上传接口",
@ -8051,6 +8223,25 @@ const docTemplate = `{
}
}
},
"app.appCategoryItem": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
},
"app.appNoticeItem": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
},
"app.bindPhoneRequest": {
"type": "object",
"properties": {
@ -8488,6 +8679,28 @@ const docTemplate = `{
}
}
},
"app.listAppCategoriesResponse": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/app.appCategoryItem"
}
}
}
},
"app.listAppNoticesResponse": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/app.appNoticeItem"
}
}
}
},
"app.listAppProductsItem": {
"type": "object",
"properties": {
@ -8756,6 +8969,64 @@ const docTemplate = `{
}
}
},
"app.listStoreItem": {
"type": "object",
"properties": {
"discount_type": {
"type": "integer"
},
"discount_value": {
"type": "integer"
},
"id": {
"type": "integer"
},
"in_stock": {
"type": "boolean"
},
"kind": {
"type": "string"
},
"main_image": {
"type": "string"
},
"min_spend": {
"type": "integer"
},
"name": {
"type": "string"
},
"price": {
"type": "integer"
},
"status": {
"type": "integer"
},
"supported": {
"type": "boolean"
}
}
},
"app.listStoreItemsResponse": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/app.listStoreItem"
}
},
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"app.listUserItemCardUsesResponse": {
"type": "object",
"properties": {
@ -9010,6 +9281,34 @@ const docTemplate = `{
}
}
},
"app.redeemItemCardRequest": {
"type": "object",
"properties": {
"card_id": {
"type": "integer"
},
"quantity": {
"type": "integer"
}
}
},
"app.redeemItemCardResponse": {
"type": "object",
"properties": {
"card_id": {
"type": "integer"
},
"ledger_id": {
"type": "integer"
},
"message": {
"type": "string"
},
"success": {
"type": "boolean"
}
}
},
"app.redeemProductRequest": {
"type": "object",
"properties": {
@ -10023,6 +10322,9 @@ const docTemplate = `{
"user.OrderWithItems": {
"type": "object",
"properties": {
"activity_id": {
"type": "integer"
},
"activity_name": {
"type": "string"
},
@ -10034,6 +10336,12 @@ const docTemplate = `{
"description": "取消时间",
"type": "string"
},
"category_id": {
"type": "integer"
},
"category_name": {
"type": "string"
},
"created_at": {
"description": "创建时间",
"type": "string"
@ -10083,6 +10391,9 @@ const docTemplate = `{
"description": "关联预支付单IDpayment_preorder.id",
"type": "integer"
},
"play_type": {
"type": "string"
},
"points_amount": {
"description": "积分抵扣金额(分)",
"type": "integer"

View File

@ -3650,6 +3650,35 @@
}
}
},
"/api/app/categories": {
"get": {
"description": "获取APP端商品分类列表",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"APP端.基础"
],
"summary": "获取分类列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/app.listAppCategoriesResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/code.Failure"
}
}
}
}
},
"/api/app/lottery/join": {
"post": {
"security": [
@ -3904,6 +3933,29 @@
}
}
},
"/api/app/notices": {
"get": {
"description": "获取APP首页滚动公告",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"APP端.基础"
],
"summary": "获取公告列表",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/app.listAppNoticesResponse"
}
}
}
}
},
"/api/app/orders/{order_id}": {
"get": {
"security": [
@ -4140,6 +4192,189 @@
}
}
},
"/api/app/store/items": {
"get": {
"security": [
{
"LoginVerifyToken": []
}
],
"description": "分页获取积分商城商品列表,支持按类型筛选(product/item_card/coupon)",
"consumes": [
"application/x-www-form-urlencoded"
],
"produces": [
"application/json"
],
"tags": [
"APP端.积分商城"
],
"summary": "获取积分商城商品列表",
"parameters": [
{
"type": "string",
"description": "商品类型: product(默认), item_card, coupon",
"name": "kind",
"in": "query"
},
{
"type": "integer",
"default": 1,
"description": "页码",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "每页数量",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/app.listStoreItemsResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/code.Failure"
}
}
}
}
},
"/api/app/task-center/tasks": {
"get": {
"description": "获取当前可用的任务列表,支持分页",
"consumes": [
"application/x-www-form-urlencoded"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "获取任务列表(App)",
"parameters": [
{
"type": "integer",
"default": 1,
"description": "页码",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "每页数量",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "任务列表",
"schema": {
"$ref": "#/definitions/taskcenter.listTasksResponse"
}
}
}
}
},
"/api/app/task-center/tasks/{id}/claim/{user_id}": {
"post": {
"description": "用户领取指定任务层级的奖励",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "领取任务奖励(App)",
"parameters": [
{
"type": "integer",
"description": "任务ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"description": "领取请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/taskcenter.claimTaskRequest"
}
}
],
"responses": {
"200": {
"description": "领取成功",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/api/app/task-center/tasks/{id}/progress/{user_id}": {
"get": {
"description": "获取指定用户在特定任务上的进度详情",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "获取用户任务进度(App)",
"parameters": [
{
"type": "integer",
"description": "任务ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "任务进度详情",
"schema": {
"$ref": "#/definitions/taskcenter.taskProgressResponse"
}
}
}
}
},
"/api/app/users/weixin/login": {
"post": {
"description": "微信静默登录(需传递 code可选 invite_code",
@ -5210,6 +5445,18 @@
"name": "page_size",
"in": "query",
"required": true
},
{
"type": "integer",
"description": "订单状态1待支付 2已支付 3已取消 4已退款",
"name": "status",
"in": "query"
},
{
"type": "integer",
"description": "是否已消耗/履约0否 1是",
"name": "is_consumed",
"in": "query"
}
],
"responses": {
@ -5427,6 +5674,58 @@
}
}
},
"/api/app/users/{user_id}/points/redeem-item-card": {
"post": {
"security": [
{
"LoginVerifyToken": []
}
],
"description": "使用积分兑换指定数量的道具卡",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"APP端.用户"
],
"summary": "积分兑换道具卡",
"parameters": [
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"description": "兑换请求参数",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/app.redeemItemCardRequest"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/app.redeemItemCardResponse"
}
},
"400": {
"description": "Bad Request",
"schema": {
"$ref": "#/definitions/code.Failure"
}
}
}
}
},
"/api/app/users/{user_id}/points/redeem-product": {
"post": {
"security": [
@ -5720,133 +6019,6 @@
"responses": {}
}
},
"/app/task_center/tasks": {
"get": {
"description": "获取当前可用的任务列表,支持分页",
"consumes": [
"application/x-www-form-urlencoded"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "获取任务列表(App)",
"parameters": [
{
"type": "integer",
"default": 1,
"description": "页码",
"name": "page",
"in": "query"
},
{
"type": "integer",
"default": 20,
"description": "每页数量",
"name": "page_size",
"in": "query"
}
],
"responses": {
"200": {
"description": "任务列表",
"schema": {
"$ref": "#/definitions/taskcenter.listTasksResponse"
}
}
}
}
},
"/app/task_center/tasks/{id}/progress/{user_id}": {
"get": {
"description": "获取指定用户在特定任务上的进度详情",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "获取用户任务进度(App)",
"parameters": [
{
"type": "integer",
"description": "任务ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "任务进度详情",
"schema": {
"$ref": "#/definitions/taskcenter.taskProgressResponse"
}
}
}
}
},
"/app/task_center/tasks/{id}/users/{user_id}/claim": {
"post": {
"description": "用户领取指定任务层级的奖励",
"consumes": [
"application/json"
],
"produces": [
"application/json"
],
"tags": [
"TaskCenter(App)"
],
"summary": "领取任务奖励(App)",
"parameters": [
{
"type": "integer",
"description": "任务ID",
"name": "id",
"in": "path",
"required": true
},
{
"type": "integer",
"description": "用户ID",
"name": "user_id",
"in": "path",
"required": true
},
{
"description": "领取请求",
"name": "request",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/taskcenter.claimTaskRequest"
}
}
],
"responses": {
"200": {
"description": "领取成功",
"schema": {
"type": "object",
"additionalProperties": true
}
}
}
}
},
"/common/upload/wangeditor": {
"post": {
"description": "适配 WangEditor 的图片上传接口",
@ -8043,6 +8215,25 @@
}
}
},
"app.appCategoryItem": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
}
}
},
"app.appNoticeItem": {
"type": "object",
"properties": {
"content": {
"type": "string"
}
}
},
"app.bindPhoneRequest": {
"type": "object",
"properties": {
@ -8480,6 +8671,28 @@
}
}
},
"app.listAppCategoriesResponse": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/app.appCategoryItem"
}
}
}
},
"app.listAppNoticesResponse": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/app.appNoticeItem"
}
}
}
},
"app.listAppProductsItem": {
"type": "object",
"properties": {
@ -8748,6 +8961,64 @@
}
}
},
"app.listStoreItem": {
"type": "object",
"properties": {
"discount_type": {
"type": "integer"
},
"discount_value": {
"type": "integer"
},
"id": {
"type": "integer"
},
"in_stock": {
"type": "boolean"
},
"kind": {
"type": "string"
},
"main_image": {
"type": "string"
},
"min_spend": {
"type": "integer"
},
"name": {
"type": "string"
},
"price": {
"type": "integer"
},
"status": {
"type": "integer"
},
"supported": {
"type": "boolean"
}
}
},
"app.listStoreItemsResponse": {
"type": "object",
"properties": {
"list": {
"type": "array",
"items": {
"$ref": "#/definitions/app.listStoreItem"
}
},
"page": {
"type": "integer"
},
"page_size": {
"type": "integer"
},
"total": {
"type": "integer"
}
}
},
"app.listUserItemCardUsesResponse": {
"type": "object",
"properties": {
@ -9002,6 +9273,34 @@
}
}
},
"app.redeemItemCardRequest": {
"type": "object",
"properties": {
"card_id": {
"type": "integer"
},
"quantity": {
"type": "integer"
}
}
},
"app.redeemItemCardResponse": {
"type": "object",
"properties": {
"card_id": {
"type": "integer"
},
"ledger_id": {
"type": "integer"
},
"message": {
"type": "string"
},
"success": {
"type": "boolean"
}
}
},
"app.redeemProductRequest": {
"type": "object",
"properties": {
@ -10015,6 +10314,9 @@
"user.OrderWithItems": {
"type": "object",
"properties": {
"activity_id": {
"type": "integer"
},
"activity_name": {
"type": "string"
},
@ -10026,6 +10328,12 @@
"description": "取消时间",
"type": "string"
},
"category_id": {
"type": "integer"
},
"category_name": {
"type": "string"
},
"created_at": {
"description": "创建时间",
"type": "string"
@ -10075,6 +10383,9 @@
"description": "关联预支付单IDpayment_preorder.id",
"type": "integer"
},
"play_type": {
"type": "string"
},
"points_amount": {
"description": "积分抵扣金额(分)",
"type": "integer"

View File

@ -1353,6 +1353,18 @@ definitions:
title:
type: string
type: object
app.appCategoryItem:
properties:
id:
type: integer
name:
type: string
type: object
app.appNoticeItem:
properties:
content:
type: string
type: object
app.bindPhoneRequest:
properties:
code:
@ -1642,6 +1654,20 @@ definitions:
$ref: '#/definitions/app.appBannerItem'
type: array
type: object
app.listAppCategoriesResponse:
properties:
list:
items:
$ref: '#/definitions/app.appCategoryItem'
type: array
type: object
app.listAppNoticesResponse:
properties:
list:
items:
$ref: '#/definitions/app.appNoticeItem'
type: array
type: object
app.listAppProductsItem:
properties:
id:
@ -1816,6 +1842,44 @@ definitions:
total:
type: integer
type: object
app.listStoreItem:
properties:
discount_type:
type: integer
discount_value:
type: integer
id:
type: integer
in_stock:
type: boolean
kind:
type: string
main_image:
type: string
min_spend:
type: integer
name:
type: string
price:
type: integer
status:
type: integer
supported:
type: boolean
type: object
app.listStoreItemsResponse:
properties:
list:
items:
$ref: '#/definitions/app.listStoreItem'
type: array
page:
type: integer
page_size:
type: integer
total:
type: integer
type: object
app.listUserItemCardUsesResponse:
properties:
list:
@ -1981,6 +2045,24 @@ definitions:
points:
type: integer
type: object
app.redeemItemCardRequest:
properties:
card_id:
type: integer
quantity:
type: integer
type: object
app.redeemItemCardResponse:
properties:
card_id:
type: integer
ledger_id:
type: integer
message:
type: string
success:
type: boolean
type: object
app.redeemProductRequest:
properties:
product_id:
@ -2674,6 +2756,8 @@ definitions:
type: object
user.OrderWithItems:
properties:
activity_id:
type: integer
activity_name:
type: string
actual_amount:
@ -2682,6 +2766,10 @@ definitions:
cancelled_at:
description: 取消时间
type: string
category_id:
type: integer
category_name:
type: string
created_at:
description: 创建时间
type: string
@ -2717,6 +2805,8 @@ definitions:
pay_preorder_id:
description: 关联预支付单IDpayment_preorder.id
type: integer
play_type:
type: string
points_amount:
description: 积分抵扣金额(分)
type: integer
@ -5153,6 +5243,25 @@ paths:
summary: APP端轮播图列表
tags:
- APP端.运营
/api/app/categories:
get:
consumes:
- application/json
description: 获取APP端商品分类列表
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/app.listAppCategoriesResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/code.Failure'
summary: 获取分类列表
tags:
- APP端.基础
/api/app/lottery/join:
post:
consumes:
@ -5313,6 +5422,21 @@ paths:
summary: 获取对对碰游戏状态
tags:
- APP端.活动
/api/app/notices:
get:
consumes:
- application/json
description: 获取APP首页滚动公告
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/app.listAppNoticesResponse'
summary: 获取公告列表
tags:
- APP端.基础
/api/app/orders/{order_id}:
get:
consumes:
@ -5461,6 +5585,127 @@ paths:
summary: 商品详情
tags:
- APP端.商品
/api/app/store/items:
get:
consumes:
- application/x-www-form-urlencoded
description: 分页获取积分商城商品列表,支持按类型筛选(product/item_card/coupon)
parameters:
- description: '商品类型: product(默认), item_card, coupon'
in: query
name: kind
type: string
- default: 1
description: 页码
in: query
name: page
type: integer
- default: 20
description: 每页数量
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/app.listStoreItemsResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/code.Failure'
security:
- LoginVerifyToken: []
summary: 获取积分商城商品列表
tags:
- APP端.积分商城
/api/app/task-center/tasks:
get:
consumes:
- application/x-www-form-urlencoded
description: 获取当前可用的任务列表,支持分页
parameters:
- default: 1
description: 页码
in: query
name: page
type: integer
- default: 20
description: 每页数量
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: 任务列表
schema:
$ref: '#/definitions/taskcenter.listTasksResponse'
summary: 获取任务列表(App)
tags:
- TaskCenter(App)
/api/app/task-center/tasks/{id}/claim/{user_id}:
post:
consumes:
- application/json
description: 用户领取指定任务层级的奖励
parameters:
- description: 任务ID
in: path
name: id
required: true
type: integer
- description: 用户ID
in: path
name: user_id
required: true
type: integer
- description: 领取请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/taskcenter.claimTaskRequest'
produces:
- application/json
responses:
"200":
description: 领取成功
schema:
additionalProperties: true
type: object
summary: 领取任务奖励(App)
tags:
- TaskCenter(App)
/api/app/task-center/tasks/{id}/progress/{user_id}:
get:
consumes:
- application/json
description: 获取指定用户在特定任务上的进度详情
parameters:
- description: 任务ID
in: path
name: id
required: true
type: integer
- description: 用户ID
in: path
name: user_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 任务进度详情
schema:
$ref: '#/definitions/taskcenter.taskProgressResponse'
summary: 获取用户任务进度(App)
tags:
- TaskCenter(App)
/api/app/users/{user_id}:
put:
consumes:
@ -6119,6 +6364,14 @@ paths:
name: page_size
required: true
type: integer
- description: 订单状态1待支付 2已支付 3已取消 4已退款
in: query
name: status
type: integer
- description: 是否已消耗/履约0否 1是
in: query
name: is_consumed
type: integer
produces:
- application/json
responses:
@ -6262,6 +6515,39 @@ paths:
summary: 积分兑换优惠券
tags:
- APP端.积分
/api/app/users/{user_id}/points/redeem-item-card:
post:
consumes:
- application/json
description: 使用积分兑换指定数量的道具卡
parameters:
- description: 用户ID
in: path
name: user_id
required: true
type: integer
- description: 兑换请求参数
in: body
name: request
required: true
schema:
$ref: '#/definitions/app.redeemItemCardRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/app.redeemItemCardResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/code.Failure'
security:
- LoginVerifyToken: []
summary: 积分兑换道具卡
tags:
- APP端.用户
/api/app/users/{user_id}/points/redeem-product:
post:
consumes:
@ -6476,91 +6762,6 @@ paths:
/api/v3/system/menus/simple:
get:
responses: {}
/app/task_center/tasks:
get:
consumes:
- application/x-www-form-urlencoded
description: 获取当前可用的任务列表,支持分页
parameters:
- default: 1
description: 页码
in: query
name: page
type: integer
- default: 20
description: 每页数量
in: query
name: page_size
type: integer
produces:
- application/json
responses:
"200":
description: 任务列表
schema:
$ref: '#/definitions/taskcenter.listTasksResponse'
summary: 获取任务列表(App)
tags:
- TaskCenter(App)
/app/task_center/tasks/{id}/progress/{user_id}:
get:
consumes:
- application/json
description: 获取指定用户在特定任务上的进度详情
parameters:
- description: 任务ID
in: path
name: id
required: true
type: integer
- description: 用户ID
in: path
name: user_id
required: true
type: integer
produces:
- application/json
responses:
"200":
description: 任务进度详情
schema:
$ref: '#/definitions/taskcenter.taskProgressResponse'
summary: 获取用户任务进度(App)
tags:
- TaskCenter(App)
/app/task_center/tasks/{id}/users/{user_id}/claim:
post:
consumes:
- application/json
description: 用户领取指定任务层级的奖励
parameters:
- description: 任务ID
in: path
name: id
required: true
type: integer
- description: 用户ID
in: path
name: user_id
required: true
type: integer
- description: 领取请求
in: body
name: request
required: true
schema:
$ref: '#/definitions/taskcenter.claimTaskRequest'
produces:
- application/json
responses:
"200":
description: 领取成功
schema:
additionalProperties: true
type: object
summary: 领取任务奖励(App)
tags:
- TaskCenter(App)
/common/upload/wangeditor:
post:
consumes:

View File

@ -22,6 +22,8 @@ type handler struct {
repo mysql.Repo
user usersvc.Service
redis *redis.Client
activityOrder activitysvc.ActivityOrderService // 活动订单服务
rewardEffects activitysvc.RewardEffectsService // 奖励效果服务
}
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler {
@ -35,5 +37,7 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
repo: db,
user: usersvc.New(logger, db),
redis: rdb,
activityOrder: activitysvc.NewActivityOrderService(logger, db),
rewardEffects: activitysvc.NewRewardEffectsService(logger, db),
}
}

View File

@ -22,7 +22,7 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
titlesvc "bindbox-game/internal/service/title"
activitysvc "bindbox-game/internal/service/activity"
usersvc "bindbox-game/internal/service/user"
)
@ -542,129 +542,32 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
return
}
// 2. Prepare Order
orderNo := h.randomID("O") // Reuse helper from lottery_app.go? No, handler is same struct but method not exported?
// randomID is private in handler. We need to duplicate or make it public.
// Let's implement a local randomID or use UUID.
// h.randomID is `func (h *handler) randomID(prefix string) string`, it IS available to `h`.
total := activity.PriceDraw // Matching game ensures 1 draw basically? Or is it a session price?
// "PreOrder" usually implies 1 game session.
// Create basic order model
order := &model.Orders{
UserID: userID,
OrderNo: orderNo,
TotalAmount: total,
ActualAmount: total,
Status: 1, // Pending
SourceType: 3, // Other/Game
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Minimal Remark
order.Remark = fmt.Sprintf("matching_game:issue:%d", req.IssueID)
if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
order.Remark += fmt.Sprintf("|itemcard:%d", *req.ItemCardID)
}
// 3. Apply Title Discount
titleEffects, _ := h.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{
ActivityID: &issue.ActivityID,
IssueID: &req.IssueID,
})
for _, ef := range titleEffects {
if ef.EffectType == 2 {
var p struct {
DiscountType string `json:"discount_type"`
ValueX1000 int64 `json:"value_x1000"`
MaxDiscountX1000 int64 `json:"max_discount_x1000"`
}
if jsonErr := json.Unmarshal([]byte(ef.ParamsJSON), &p); jsonErr == nil {
var discount int64
if p.DiscountType == "percentage" {
discount = order.ActualAmount * p.ValueX1000 / 1000
} else if p.DiscountType == "fixed" {
discount = p.ValueX1000
}
if p.MaxDiscountX1000 > 0 && discount > p.MaxDiscountX1000 {
discount = p.MaxDiscountX1000
}
if discount > order.ActualAmount {
discount = order.ActualAmount
}
if discount > 0 {
order.ActualAmount -= discount
order.Remark += fmt.Sprintf("|title_discount:%d:%d", ef.ID, discount)
}
}
}
}
// 4. Apply Coupon
var appliedCouponVal int64
// 2. Create Order using ActivityOrderService
var couponID *int64
if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
// Reuse h.applyCouponWithCap? It is private.
// We need to either export it or duplicate logic.
// For safety and speed, let's duplicate the core check logic or use `usersvc` directly if possible.
// `h.applyCouponWithCap` logic: fetch coupon, check status, check expiry, check min_spend, calc discount.
// Let's implement simplified version here.
uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(*req.CouponID), h.readDB.UserCoupons.UserID.Eq(userID)).First()
if uc != nil && uc.Status == 1 && (uc.ValidEnd.IsZero() || uc.ValidEnd.After(time.Now())) {
sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID)).First()
if sc != nil && order.ActualAmount >= sc.MinSpend {
var discount int64
if sc.DiscountType == 3 { // Rate (Discount)
// DiscountValue is rate x 1000 (e.g. 800 = 80%)
// Discount Amount = Total * (1 - Rate/1000) ? Or simply applied rate?
// Usually "DiscountValue" for Type 3 means "The Ratio you PAY" or "The Ratio OFF"?
// Standard "zhekou": 8 zhe = pay 80%. Discount = 20%.
// Let's assume DiscountValue=800 means 80% pay.
discount = order.ActualAmount * (1000 - sc.DiscountValue) / 1000
} else { // Fixed (Type 1 or 2)
discount = sc.DiscountValue
couponID = req.CouponID
}
var itemCardID *int64
if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
itemCardID = req.ItemCardID
}
if discount > order.ActualAmount {
discount = order.ActualAmount
}
if discount > 0 {
order.ActualAmount -= discount
order.DiscountAmount = discount
appliedCouponVal = discount
// Record usage later
}
}
}
}
// Create Order
if err := h.writeDB.Orders.WithContext(ctx.RequestContext()).Create(order); err != nil {
orderResult, err := h.activityOrder.CreateActivityOrder(ctx, activitysvc.CreateActivityOrderRequest{
UserID: userID,
ActivityID: issue.ActivityID,
IssueID: req.IssueID,
Count: 1,
UnitPrice: activity.PriceDraw,
SourceType: 3, // 对对碰
CouponID: couponID,
ItemCardID: itemCardID,
ExtraRemark: fmt.Sprintf("matching_game:issue:%d", req.IssueID),
})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 170002, err.Error()))
return
}
// Record Coupon Usage
if appliedCouponVal > 0 && req.CouponID != nil {
_ = h.user.RecordOrderCouponUsage(ctx.RequestContext(), order.ID, *req.CouponID, appliedCouponVal)
}
// Handle 0-amount auto-pay
if order.ActualAmount == 0 {
now := time.Now()
_, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{
h.writeDB.Orders.Status.ColumnName().String(): 2,
h.writeDB.Orders.PaidAt.ColumnName().String(): now,
})
// Consume Coupon
if req.CouponID != nil {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).Where(h.readDB.UserCoupons.ID.Eq(*req.CouponID)).Updates(map[string]any{
h.readDB.UserCoupons.Status.ColumnName().String(): 2,
h.readDB.UserCoupons.UsedOrderID.ColumnName().String(): order.ID,
h.readDB.UserCoupons.UsedAt.ColumnName().String(): now,
})
}
}
order := orderResult.Order
// 2. 加载配置
configs, err := loadCardTypesFromDB(ctx.RequestContext(), h.readDB)
@ -799,6 +702,20 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
return
}
// 校验:不能超过理论最大对数
// 【关键校验】检查订单是否已支付
// 对对碰游戏必须先支付才能结算和发奖
order, err := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First()
if err != nil || order == nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170003, "订单不存在"))
return
}
if order.Status != 2 {
fmt.Printf("[对对碰Check] ⏳ 订单支付确认中 order_id=%d status=%d等待回调完成\n", order.ID, order.Status)
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170004, "支付确认中,请稍后重试"))
return
}
// 校验:不能超过理论最大对数
if req.TotalPairs > game.MaxPossiblePairs {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, fmt.Sprintf("invalid total pairs: %d > %d", req.TotalPairs, game.MaxPossiblePairs)))
@ -818,19 +735,10 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
if r.Quantity <= 0 {
continue
}
if int64(req.TotalPairs) >= r.MinScore {
if candidate == nil {
// 精确匹配:用户消除的对子数 == 奖品设置的 MinScore
if int64(req.TotalPairs) == r.MinScore {
candidate = r
} else {
// Prioritize Higher MinScore
if r.MinScore > candidate.MinScore {
candidate = r
} else if r.MinScore == candidate.MinScore {
if r.Sort < candidate.Sort {
candidate = r
}
}
}
break // 精确匹配,直接使用
}
}
@ -844,6 +752,101 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
Name: candidate.Name,
Level: candidate.Level,
}
// 4. Apply Item Card Effects (if any)
ord, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(game.OrderID)).First()
if ord != nil {
icID := parseItemCardIDFromRemark(ord.Remark)
fmt.Printf("[道具卡-CheckMatchingGame] 从订单备注解析道具卡ID icID=%d 订单备注=%s\n", icID, ord.Remark)
if icID > 0 {
uic, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.UserItemCards.ID.Eq(icID),
h.readDB.UserItemCards.UserID.Eq(game.UserID),
h.readDB.UserItemCards.Status.Eq(1),
).First()
if uic != nil {
ic, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(
h.readDB.SystemItemCards.ID.Eq(uic.CardID),
h.readDB.SystemItemCards.Status.Eq(1),
).First()
now := time.Now()
if ic != nil && !uic.ValidStart.After(now) && !uic.ValidEnd.Before(now) {
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == game.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == game.IssueID)
fmt.Printf("[道具卡-CheckMatchingGame] 范围检查 ScopeType=%d ActivityID=%d IssueID=%d scopeOK=%t\n", ic.ScopeType, game.ActivityID, game.IssueID, scopeOK)
if scopeOK {
// Apply effect based on type
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
// Double reward
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s\n", ic.RewardMultiplierX1000, candidate.ID, candidate.Name)
rid := candidate.ID
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), game.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: game.OrderID,
ProductID: candidate.ProductID,
Quantity: 1,
ActivityID: &game.ActivityID,
RewardID: &rid,
Remark: candidate.Name + "(倍数)",
})
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// Probability boost - try to upgrade to better reward
fmt.Printf("[道具卡-CheckMatchingGame] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID),
).Find()
var better *model.ActivityRewardSettings
for _, r := range allRewards {
if r.MinScore > candidate.MinScore && r.Quantity > 0 {
if better == nil || r.MinScore < better.MinScore {
better = r
}
}
}
if better != nil {
// Use crypto/rand for secure random
randBytes := make([]byte, 4)
rand.Read(randBytes)
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
if randVal < ic.BoostRateX1000 {
fmt.Printf("[道具卡-CheckMatchingGame] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name)
rid := better.ID
_, _ = h.user.GrantRewardToOrder(ctx.RequestContext(), game.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: game.OrderID,
ProductID: better.ProductID,
Quantity: 1,
ActivityID: &game.ActivityID,
RewardID: &rid,
Remark: better.Name + "(升级)",
})
}
}
}
// Void the item card
fmt.Printf("[道具卡-CheckMatchingGame] 核销道具卡 用户道具卡ID=%d\n", icID)
// Get DrawLog ID for the order
drawLog, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityDrawLogs.OrderID.Eq(game.OrderID),
).First()
var drawLogID int64
if drawLog != nil {
drawLogID = drawLog.ID
}
_, _ = h.writeDB.UserItemCards.WithContext(ctx.RequestContext()).Where(
h.writeDB.UserItemCards.ID.Eq(icID),
h.writeDB.UserItemCards.UserID.Eq(game.UserID),
h.writeDB.UserItemCards.Status.Eq(1),
).Updates(map[string]any{
h.writeDB.UserItemCards.Status.ColumnName().String(): 2,
h.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): drawLogID,
h.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): game.ActivityID,
h.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): game.IssueID,
h.writeDB.UserItemCards.UsedAt.ColumnName().String(): now,
})
}
}
}
}
}
}
}
}

View File

@ -1 +1,98 @@
package app
import (
"bindbox-game/internal/repository/mysql/model"
"testing"
)
// TestSelectRewardByExactMatch 测试对对碰选奖逻辑:精确匹配 TotalPairs == MinScore
func TestSelectRewardByExactMatch(t *testing.T) {
// 模拟奖品设置
rewards := []*model.ActivityRewardSettings{
{ID: 1, Name: "奖品A-10对", MinScore: 10, Quantity: 5},
{ID: 2, Name: "奖品B-20对", MinScore: 20, Quantity: 5},
{ID: 3, Name: "奖品C-30对", MinScore: 30, Quantity: 5},
{ID: 4, Name: "奖品D-40对", MinScore: 40, Quantity: 5},
{ID: 5, Name: "奖品E-45对", MinScore: 45, Quantity: 5},
}
testCases := []struct {
name string
totalPairs int64
expectReward *int64 // nil = 无匹配
expectName string
}{
{"精确匹配10对", 10, ptr(int64(1)), "奖品A-10对"},
{"精确匹配20对", 20, ptr(int64(2)), "奖品B-20对"},
{"精确匹配30对", 30, ptr(int64(3)), "奖品C-30对"},
{"精确匹配40对", 40, ptr(int64(4)), "奖品D-40对"},
{"精确匹配45对", 45, ptr(int64(5)), "奖品E-45对"},
{"15对-无匹配", 15, nil, ""},
{"25对-无匹配", 25, nil, ""},
{"35对-无匹配", 35, nil, ""},
{"50对-无匹配", 50, nil, ""},
{"0对-无匹配", 0, nil, ""},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
candidate := selectRewardExact(rewards, tc.totalPairs)
if tc.expectReward == nil {
if candidate != nil {
t.Errorf("期望无匹配,但得到奖品: %s (ID=%d)", candidate.Name, candidate.ID)
}
} else {
if candidate == nil {
t.Errorf("期望匹配奖品ID=%d但无匹配", *tc.expectReward)
} else if candidate.ID != *tc.expectReward {
t.Errorf("期望奖品ID=%d实际=%d", *tc.expectReward, candidate.ID)
} else if candidate.Name != tc.expectName {
t.Errorf("期望奖品名=%s实际=%s", tc.expectName, candidate.Name)
}
}
})
}
}
// TestSelectRewardWithZeroQuantity 测试库存为0时不匹配
func TestSelectRewardWithZeroQuantity(t *testing.T) {
rewards := []*model.ActivityRewardSettings{
{ID: 1, Name: "奖品A-10对", MinScore: 10, Quantity: 0}, // 库存为0
{ID: 2, Name: "奖品B-20对", MinScore: 20, Quantity: 5},
}
// 即使精确匹配库存为0也不应匹配
candidate := selectRewardExact(rewards, 10)
if candidate != nil {
t.Errorf("库存为0时不应匹配但得到: %s", candidate.Name)
}
// 库存>0应正常匹配
candidate = selectRewardExact(rewards, 20)
if candidate == nil {
t.Error("库存>0时应匹配但无匹配")
} else if candidate.ID != 2 {
t.Errorf("期望ID=2实际=%d", candidate.ID)
}
}
// selectRewardExact 精确匹配选奖逻辑从matching_game_app.go提取
// 这是 CheckMatchingGame 中实际使用的逻辑
func selectRewardExact(rewards []*model.ActivityRewardSettings, totalPairs int64) *model.ActivityRewardSettings {
for _, r := range rewards {
if r.Quantity <= 0 {
continue
}
// 精确匹配:用户消除的对子数 == 奖品设置的 MinScore
if totalPairs == r.MinScore {
return r
}
}
return nil
}
// ptr 辅助函数创建int64指针
func ptr(v int64) *int64 {
return &v
}

View File

@ -5,6 +5,8 @@ import (
"net/http"
"time"
"go.uber.org/zap"
"bindbox-game/configs"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
@ -16,7 +18,9 @@ import (
type listPayOrdersRequest struct {
Page int `form:"page"`
Current int `form:"current"`
PageSize int `form:"page_size"`
Size int `form:"size"`
Status *int32 `form:"status"`
SourceType *int32 `form:"source_type"`
ExcludeSourceType *int32 `form:"exclude_source_type"`
@ -44,6 +48,12 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 && req.Current > 0 {
req.Page = req.Current
}
if req.PageSize <= 0 && req.Size > 0 {
req.PageSize = req.Size
}
if req.Page <= 0 {
req.Page = 1
}
@ -63,9 +73,6 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
if req.ExcludeSourceType != nil {
q = q.Not(h.readDB.Orders.SourceType.Eq(*req.ExcludeSourceType))
}
if req.SourceType == nil && req.ExcludeSourceType == nil {
q = q.Not(h.readDB.Orders.SourceType.Eq(3))
}
if req.UserID != nil {
q = q.Where(h.readDB.Orders.UserID.Eq(*req.UserID))
}
@ -114,9 +121,13 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
pointsRate = r
}
}
out := make([]map[string]any, 0, len(rows))
for _, o := range rows {
ledgers, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(o.OrderNo)).Find()
ledgers, err := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(o.OrderNo)).Find()
if err != nil {
h.logger.Error("ListPayOrders fetch ledgers error", zap.Error(err), zap.String("order_no", o.OrderNo))
}
var consumePointsSum int64
for _, lg := range ledgers {
if lg.Points < 0 {
@ -141,7 +152,7 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
"actual_amount": o.ActualAmount,
"status": o.Status,
"paid_at": func() string {
if !o.PaidAt.IsZero() {
if o.PaidAt != nil && !o.PaidAt.IsZero() {
return o.PaidAt.Format("2006-01-02 15:04:05")
}
return ""
@ -468,7 +479,12 @@ func (h *handler) GetPayOrderDetail() core.HandlerFunc {
}{
Status: order.Status,
ActualAmount: order.ActualAmount,
PaidAt: order.PaidAt.Format("2006-01-02 15:04:05"),
PaidAt: func() string {
if order.PaidAt != nil && !order.PaidAt.IsZero() {
return order.PaidAt.Format("2006-01-02 15:04:05")
}
return ""
}(),
PayPreorderID: order.PayPreorderID,
TransactionID: "",
PointsAmount: pointsAmountCents,

View File

@ -9,6 +9,7 @@ import (
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"github.com/tealeg/xlsx"
)
@ -53,7 +54,9 @@ func (h *handler) ExportPayOrders() core.HandlerFunc {
if cfgRate, _ := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq("points_exchange_per_cent")).First(); cfgRate != nil {
var r int64
_, _ = fmt.Sscanf(cfgRate.ConfigValue, "%d", &r)
if r > 0 { pointsRate = r }
if r > 0 {
pointsRate = r
}
}
file := xlsx.NewFile()
sheet, _ := file.AddSheet("orders")
@ -66,14 +69,26 @@ func (h *handler) ExportPayOrders() core.HandlerFunc {
for _, o := range rows {
leds, _ := h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.UserPointsLedger.RefTable.Eq("orders"), h.readDB.UserPointsLedger.RefID.Eq(o.OrderNo)).Find()
var consumePointsSum int64
for _, lg := range leds { if lg.Points < 0 { consumePointsSum += -lg.Points } }
for _, lg := range leds {
if lg.Points < 0 {
consumePointsSum += -lg.Points
}
}
pa := o.PointsAmount
if pa == 0 && consumePointsSum > 0 { pa = consumePointsSum / pointsRate }
if pa == 0 && consumePointsSum > 0 {
pa = consumePointsSum / pointsRate
}
pu := int64(0)
if consumePointsSum > 0 { pu = consumePointsSum } else if pa > 0 { pu = pa * pointsRate }
if consumePointsSum > 0 {
pu = consumePointsSum
} else if pa > 0 {
pu = pa * pointsRate
}
ocs, _ := h.readDB.OrderCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.OrderCoupons.OrderID.Eq(o.ID)).Find()
var couponApplied int64
for _, oc := range ocs { couponApplied += oc.AppliedAmount }
for _, oc := range ocs {
couponApplied += oc.AppliedAmount
}
r := sheet.AddRow()
r.AddCell().Value = o.OrderNo
r.AddCell().SetInt64(o.UserID)
@ -85,7 +100,11 @@ func (h *handler) ExportPayOrders() core.HandlerFunc {
r.AddCell().SetInt64(pu)
r.AddCell().SetInt64(couponApplied)
r.AddCell().SetInt64(o.ActualAmount)
if o.PaidAt != nil {
r.AddCell().Value = o.PaidAt.Format("2006-01-02 15:04:05")
} else {
r.AddCell().Value = ""
}
r.AddCell().Value = o.CreatedAt.Format("2006-01-02 15:04:05")
}
var buf bytes.Buffer

View File

@ -314,7 +314,7 @@ func (h *handler) ListUserOrders() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
items, total, err := h.user.ListOrdersWithItems(ctx.RequestContext(), userID, 0, req.Page, req.PageSize)
items, total, err := h.user.ListOrdersWithItems(ctx.RequestContext(), userID, 0, nil, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20104, err.Error()))
return

View File

@ -15,6 +15,7 @@ type listOrdersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Status int32 `form:"status"`
IsConsumed *int32 `form:"is_consumed"`
}
type listOrdersResponse struct {
Page int `json:"page"`
@ -33,6 +34,8 @@ type listOrdersResponse struct {
// @Security LoginVerifyToken
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Param status query int false "订单状态1待支付 2已支付 3已取消 4已退款"
// @Param is_consumed query int false "是否已消耗/履约0否 1是"
// @Success 200 {object} listOrdersResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/users/{user_id}/orders [get]
@ -45,7 +48,8 @@ func (h *handler) ListUserOrders() core.HandlerFunc {
return
}
userID := int64(ctx.SessionUserInfo().Id)
items, total, err := h.user.ListOrdersWithItems(ctx.RequestContext(), userID, req.Status, req.Page, req.PageSize)
items, total, err := h.user.ListOrdersWithItems(ctx.RequestContext(), userID, req.Status, req.IsConsumed, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10002, err.Error()))
return

View File

@ -343,7 +343,7 @@ func New(logger logger.CustomLogger, options ...Option) (Mux, error) {
if err := recover(); err != nil {
panicStackInfo = string(debug.Stack())
panicError = fmt.Sprintf("%+v", err)
// logger.Error("got panic", zap.String("panic", fmt.Sprintf("%+v", err)), zap.String("stack", stackInfo))
logger.Error("got panic", zap.String("panic", fmt.Sprintf("%+v", err)), zap.String("stack", panicStackInfo))
context.AbortWithError(Error(
http.StatusInternalServerError,
code.ServerError,
@ -490,17 +490,17 @@ func New(logger logger.CustomLogger, options ...Option) (Mux, error) {
t.Success = !ctx.IsAborted() && (ctx.Writer.Status() == http.StatusOK)
t.CostSeconds = time.Since(ts).Seconds()
//logger.Info("trace-log",
// zap.Any("method", ctx.Request.Method),
// zap.Any("path", decodedURL),
// zap.Any("http_code", ctx.Writer.Status()),
// zap.Any("business_code", businessCode),
// zap.Any("success", t.Success),
// zap.Any("cost_seconds", t.CostSeconds),
// zap.Any("trace_id", t.Identifier),
// zap.Any("trace_info", t),
// zap.Error(abortErr),
//)
logger.Info("trace-log",
zap.Any("method", ctx.Request.Method),
zap.Any("path", decodedURL),
zap.Any("http_code", ctx.Writer.Status()),
zap.Any("business_code", businessCode),
zap.Any("success", t.Success),
zap.Any("cost_seconds", t.CostSeconds),
zap.Any("trace_id", t.Identifier),
zap.Any("trace_info", t),
zap.Error(abortErr),
)
traceInfo := ""
if traceJsonData, err := json.Marshal(t); err == nil {

View File

@ -0,0 +1,309 @@
package activity
import (
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user"
"context"
"encoding/json"
"fmt"
"time"
)
// ActivityOrderService 活动订单创建服务
// 统一处理一番赏和对对碰的订单创建逻辑
type ActivityOrderService interface {
// CreateActivityOrder 创建活动订单
// 统一处理优惠券、称号折扣、道具卡记录等
CreateActivityOrder(ctx core.Context, req CreateActivityOrderRequest) (*CreateActivityOrderResult, error)
}
// CreateActivityOrderRequest 订单创建请求
type CreateActivityOrderRequest struct {
UserID int64 // 用户ID
ActivityID int64 // 活动ID
IssueID int64 // 期ID
Count int64 // 数量
UnitPrice int64 // 单价(分)
SourceType int32 // 订单来源类型: 2=抽奖, 3=对对碰
CouponID *int64 // 优惠券ID可选
ItemCardID *int64 // 道具卡ID可选
ExtraRemark string // 额外备注信息
}
// CreateActivityOrderResult 订单创建结果
type CreateActivityOrderResult struct {
Order *model.Orders // 创建的订单
AppliedCouponVal int64 // 应用的优惠券抵扣金额
}
type activityOrderService struct {
logger logger.CustomLogger
readDB *dao.Query
writeDB *dao.Query
repo mysql.Repo
title titlesvc.Service
user usersvc.Service
}
// NewActivityOrderService 创建活动订单服务
func NewActivityOrderService(l logger.CustomLogger, db mysql.Repo) ActivityOrderService {
return &activityOrderService{
logger: l,
readDB: dao.Use(db.GetDbR()),
writeDB: dao.Use(db.GetDbW()),
repo: db,
title: titlesvc.New(l, db),
user: usersvc.New(l, db),
}
}
// CreateActivityOrder 创建活动订单
func (s *activityOrderService) CreateActivityOrder(ctx core.Context, req CreateActivityOrderRequest) (*CreateActivityOrderResult, error) {
userID := req.UserID
count := req.Count
if count <= 0 {
count = 1
}
total := req.UnitPrice * count
// 1. 创建订单基础信息
orderNo := fmt.Sprintf("O%s", time.Now().Format("20060102150405"))
order := &model.Orders{
UserID: userID,
OrderNo: orderNo,
SourceType: req.SourceType,
TotalAmount: total,
ActualAmount: total,
Status: 1, // Pending
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// 设置备注
if req.ExtraRemark != "" {
order.Remark = req.ExtraRemark
} else {
order.Remark = fmt.Sprintf("activity:%d|issue:%d|count:%d", req.ActivityID, req.IssueID, count)
}
// 2. 应用称号折扣 (Title Discount)
titleEffects, _ := s.title.ResolveActiveEffects(ctx.RequestContext(), userID, titlesvc.EffectScope{
ActivityID: &req.ActivityID,
IssueID: &req.IssueID,
})
for _, ef := range titleEffects {
if ef.EffectType == 2 { // Discount effect
var p struct {
DiscountType string `json:"discount_type"`
ValueX1000 int64 `json:"value_x1000"`
MaxDiscountX1000 int64 `json:"max_discount_x1000"`
}
if jsonErr := json.Unmarshal([]byte(ef.ParamsJSON), &p); jsonErr == nil {
var discount int64
if p.DiscountType == "percentage" {
discount = order.ActualAmount * p.ValueX1000 / 1000
} else if p.DiscountType == "fixed" {
discount = p.ValueX1000
}
if p.MaxDiscountX1000 > 0 && discount > p.MaxDiscountX1000 {
discount = p.MaxDiscountX1000
}
if discount > order.ActualAmount {
discount = order.ActualAmount
}
if discount > 0 {
order.ActualAmount -= discount
order.Remark += fmt.Sprintf("|title_discount:%d:%d", ef.ID, discount)
}
}
}
}
// 3. 应用优惠券 (using applyCouponWithCap logic)
var appliedCouponVal int64
if req.CouponID != nil && *req.CouponID > 0 {
fmt.Printf("[订单服务] 尝试优惠券 用户券ID=%d 应用前实付(分)=%d\n", *req.CouponID, order.ActualAmount)
appliedCouponVal = s.applyCouponWithCap(ctx.RequestContext(), userID, order, req.ActivityID, *req.CouponID)
fmt.Printf("[订单服务] 优惠后 实付(分)=%d 累计优惠(分)=%d\n", order.ActualAmount, order.DiscountAmount)
}
// 4. 记录道具卡到备注
if req.ItemCardID != nil && *req.ItemCardID > 0 {
order.Remark += fmt.Sprintf("|itemcard:%d", *req.ItemCardID)
}
// 5. 保存订单
if err := s.writeDB.Orders.WithContext(ctx.RequestContext()).Omit(s.writeDB.Orders.PaidAt, s.writeDB.Orders.CancelledAt).Create(order); err != nil {
return nil, err
}
// 6. 记录优惠券使用明细
if appliedCouponVal > 0 && req.CouponID != nil && *req.CouponID > 0 {
_ = s.user.RecordOrderCouponUsage(ctx.RequestContext(), order.ID, *req.CouponID, appliedCouponVal)
}
// 7. 处理0元订单自动支付
if order.ActualAmount == 0 {
now := time.Now()
_, _ = s.writeDB.Orders.WithContext(ctx.RequestContext()).Where(s.writeDB.Orders.OrderNo.Eq(orderNo)).Updates(map[string]any{
s.writeDB.Orders.Status.ColumnName().String(): 2,
s.writeDB.Orders.PaidAt.ColumnName().String(): now,
})
order.Status = 2
// 核销优惠券
if req.CouponID != nil && *req.CouponID > 0 {
s.consumeCouponOnZeroPay(ctx.RequestContext(), userID, order.ID, *req.CouponID, appliedCouponVal, now)
}
}
fmt.Printf("[订单服务] 订单创建完成 订单号=%s 总额(分)=%d 优惠(分)=%d 实付(分)=%d 状态=%d\n",
order.OrderNo, order.TotalAmount, order.DiscountAmount, order.ActualAmount, order.Status)
return &CreateActivityOrderResult{
Order: order,
AppliedCouponVal: appliedCouponVal,
}, nil
}
// applyCouponWithCap 优惠券抵扣含50%封顶与金额券部分使用)
func (s *activityOrderService) applyCouponWithCap(ctx context.Context, userID int64, order *model.Orders, activityID int64, userCouponID int64) int64 {
uc, _ := s.readDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID), s.readDB.UserCoupons.UserID.Eq(userID), s.readDB.UserCoupons.Status.Eq(1)).First()
if uc == nil {
return 0
}
fmt.Printf("[优惠券] 用户券ID=%d 开始=%s 结束=%s\n", userCouponID, uc.ValidStart.Format(time.RFC3339), func() string {
if uc.ValidEnd.IsZero() {
return "无截止"
}
return uc.ValidEnd.Format(time.RFC3339)
}())
sc, _ := s.readDB.SystemCoupons.WithContext(ctx).Where(s.readDB.SystemCoupons.ID.Eq(uc.CouponID), s.readDB.SystemCoupons.Status.Eq(1)).First()
now := time.Now()
if sc == nil {
fmt.Printf("[优惠券] 模板不存在 用户券ID=%d\n", userCouponID)
return 0
}
if uc.ValidStart.After(now) {
fmt.Printf("[优惠券] 未到开始时间 用户券ID=%d\n", userCouponID)
return 0
}
if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) {
fmt.Printf("[优惠券] 已过期 用户券ID=%d\n", userCouponID)
return 0
}
scopeOK := (sc.ScopeType == 1) || (sc.ScopeType == 2 && sc.ActivityID == activityID)
if !scopeOK {
fmt.Printf("[优惠券] 范围不匹配 用户券ID=%d scope_type=%d\n", userCouponID, sc.ScopeType)
return 0
}
if order.TotalAmount < sc.MinSpend {
fmt.Printf("[优惠券] 未达使用门槛 用户券ID=%d min_spend(分)=%d 订单总额(分)=%d\n", userCouponID, sc.MinSpend, order.TotalAmount)
return 0
}
// 50% 封顶
cap := order.TotalAmount / 2
remainingCap := cap - order.DiscountAmount
if remainingCap <= 0 {
fmt.Printf("[优惠券] 已达封顶\n")
return 0
}
applied := int64(0)
switch sc.DiscountType {
case 1: // 金额券
var bal int64
_ = s.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", userCouponID).Scan(&bal).Error
if bal <= 0 {
bal = sc.DiscountValue
}
if bal > 0 {
if bal > remainingCap {
applied = remainingCap
} else {
applied = bal
}
}
case 2: // 满减券
applied = sc.DiscountValue
if applied > remainingCap {
applied = remainingCap
}
case 3: // 折扣券
rate := sc.DiscountValue
if rate < 0 {
rate = 0
}
if rate > 1000 {
rate = 1000
}
newAmt := order.ActualAmount * rate / 1000
d := order.ActualAmount - newAmt
if d > remainingCap {
applied = remainingCap
} else {
applied = d
}
}
if applied > order.ActualAmount {
applied = order.ActualAmount
}
if applied <= 0 {
return 0
}
order.ActualAmount -= applied
order.DiscountAmount += applied
order.Remark += fmt.Sprintf("|c:%d:%d", userCouponID, applied)
fmt.Printf("[优惠券] 本次抵扣(分)=%d\n", applied)
return applied
}
// consumeCouponOnZeroPay 0元支付时核销优惠券
func (s *activityOrderService) consumeCouponOnZeroPay(ctx context.Context, userID int64, orderID int64, userCouponID int64, applied int64, now time.Time) {
uc, _ := s.readDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID), s.readDB.UserCoupons.UserID.Eq(userID)).First()
if uc == nil {
return
}
sc, _ := s.readDB.SystemCoupons.WithContext(ctx).Where(s.readDB.SystemCoupons.ID.Eq(uc.CouponID)).First()
if sc == nil {
return
}
if sc.DiscountType == 1 { // 金额券 - 部分扣减
var bal int64
_ = s.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", userCouponID).Scan(&bal).Error
nb := bal - applied
if nb < 0 {
nb = 0
}
if nb == 0 {
_, _ = s.writeDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{
"balance_amount": nb,
s.readDB.UserCoupons.Status.ColumnName().String(): 2,
s.readDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID,
s.readDB.UserCoupons.UsedAt.ColumnName().String(): now,
})
} else {
_, _ = s.writeDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{
"balance_amount": nb,
s.readDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID,
s.readDB.UserCoupons.UsedAt.ColumnName().String(): now,
})
}
} else { // 满减/折扣券 - 直接核销
_, _ = s.writeDB.UserCoupons.WithContext(ctx).Where(s.readDB.UserCoupons.ID.Eq(userCouponID)).Updates(map[string]any{
s.readDB.UserCoupons.Status.ColumnName().String(): 2,
s.readDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID,
s.readDB.UserCoupons.UsedAt.ColumnName().String(): now,
})
}
}

View File

@ -0,0 +1,265 @@
package activity
import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
usersvc "bindbox-game/internal/service/user"
"context"
"crypto/rand"
"encoding/binary"
"fmt"
"time"
)
// RewardEffectsService 奖励效果服务
// 统一处理奖励发放和道具卡效果应用
type RewardEffectsService interface {
// GrantRewardWithEffects 发放奖励并应用道具卡效果
GrantRewardWithEffects(ctx context.Context, req GrantRewardRequest) (*GrantRewardResult, error)
}
// GrantRewardRequest 奖励发放请求
type GrantRewardRequest struct {
UserID int64 // 用户ID
OrderID int64 // 订单ID
ActivityID int64 // 活动ID
IssueID int64 // 期ID
Reward *model.ActivityRewardSettings // 要发放的奖励
AllRewards []*model.ActivityRewardSettings // 所有可用奖励(用于概率提升升级)
}
// GrantRewardResult 奖励发放结果
type GrantRewardResult struct {
RewardID int64 // 发放的奖励ID
RewardName string // 奖励名称
ItemCardApplied bool // 是否应用了道具卡效果
UpgradedReward *model.ActivityRewardSettings // 如果概率提升成功,升级后的奖励
DrawLogID int64 // 创建的抽奖日志ID
}
type rewardEffectsService struct {
logger logger.CustomLogger
readDB *dao.Query
writeDB *dao.Query
repo mysql.Repo
user usersvc.Service
}
// NewRewardEffectsService 创建奖励效果服务
func NewRewardEffectsService(l logger.CustomLogger, db mysql.Repo) RewardEffectsService {
return &rewardEffectsService{
logger: l,
readDB: dao.Use(db.GetDbR()),
writeDB: dao.Use(db.GetDbW()),
repo: db,
user: usersvc.New(l, db),
}
}
// GrantRewardWithEffects 发放奖励并应用道具卡效果
func (s *rewardEffectsService) GrantRewardWithEffects(ctx context.Context, req GrantRewardRequest) (*GrantRewardResult, error) {
if req.Reward == nil {
return nil, fmt.Errorf("reward is nil")
}
result := &GrantRewardResult{
RewardID: req.Reward.ID,
RewardName: req.Reward.Name,
}
// 1. 扣减库存
res, err := s.writeDB.ActivityRewardSettings.WithContext(ctx).Where(
s.writeDB.ActivityRewardSettings.ID.Eq(req.Reward.ID),
s.writeDB.ActivityRewardSettings.Quantity.Gt(0),
).UpdateSimple(s.writeDB.ActivityRewardSettings.Quantity.Add(-1))
if err != nil {
return nil, err
}
if res.RowsAffected == 0 {
return nil, fmt.Errorf("reward out of stock")
}
// 2. 发放奖励到订单
rid := req.Reward.ID
_, err = s.user.GrantRewardToOrder(ctx, req.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: req.OrderID,
ProductID: req.Reward.ProductID,
Quantity: 1,
ActivityID: &req.ActivityID,
RewardID: &rid,
Remark: req.Reward.Name,
})
if err != nil {
return nil, err
}
// 3. 创建抽奖日志
drawLog := &model.ActivityDrawLogs{
UserID: req.UserID,
IssueID: req.IssueID,
OrderID: req.OrderID,
RewardID: req.Reward.ID,
IsWinner: 1,
Level: req.Reward.Level,
CurrentLevel: 1,
CreatedAt: time.Now(),
}
if err := s.writeDB.ActivityDrawLogs.WithContext(ctx).Create(drawLog); err != nil {
return nil, err
}
result.DrawLogID = drawLog.ID
// 4. 从订单备注解析道具卡ID并应用效果
ord, _ := s.readDB.Orders.WithContext(ctx).Where(s.readDB.Orders.ID.Eq(req.OrderID)).First()
if ord != nil {
icID := parseItemCardIDFromRemark(ord.Remark)
if icID > 0 {
applied, upgradedReward := s.applyItemCardEffects(ctx, req, icID, drawLog.ID)
result.ItemCardApplied = applied
result.UpgradedReward = upgradedReward
}
}
return result, nil
}
// applyItemCardEffects 应用道具卡效果
func (s *rewardEffectsService) applyItemCardEffects(ctx context.Context, req GrantRewardRequest, icID int64, drawLogID int64) (bool, *model.ActivityRewardSettings) {
fmt.Printf("[道具卡-RewardEffects] 从订单备注解析道具卡ID icID=%d\n", icID)
uic, _ := s.readDB.UserItemCards.WithContext(ctx).Where(
s.readDB.UserItemCards.ID.Eq(icID),
s.readDB.UserItemCards.UserID.Eq(req.UserID),
s.readDB.UserItemCards.Status.Eq(1),
).First()
if uic == nil {
fmt.Printf("[道具卡-RewardEffects] ❌ 未找到用户道具卡 用户ID=%d 道具卡ID=%d\n", req.UserID, icID)
return false, nil
}
ic, _ := s.readDB.SystemItemCards.WithContext(ctx).Where(
s.readDB.SystemItemCards.ID.Eq(uic.CardID),
s.readDB.SystemItemCards.Status.Eq(1),
).First()
if ic == nil {
fmt.Printf("[道具卡-RewardEffects] ❌ 未找到系统道具卡 CardID=%d\n", uic.CardID)
return false, nil
}
now := time.Now()
if uic.ValidStart.After(now) || uic.ValidEnd.Before(now) {
fmt.Printf("[道具卡-RewardEffects] ❌ 道具卡不在有效期\n")
return false, nil
}
// 范围检查
scopeOK := (ic.ScopeType == 1) || (ic.ScopeType == 3 && ic.ActivityID == req.ActivityID) || (ic.ScopeType == 4 && ic.IssueID == req.IssueID)
if !scopeOK {
fmt.Printf("[道具卡-RewardEffects] ❌ 范围检查失败 ScopeType=%d\n", ic.ScopeType)
return false, nil
}
var upgradedReward *model.ActivityRewardSettings
// 应用效果
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
// 双倍奖励
fmt.Printf("[道具卡-RewardEffects] ✅ 应用双倍奖励 倍数=%d 奖品ID=%d 奖品名=%s\n", ic.RewardMultiplierX1000, req.Reward.ID, req.Reward.Name)
rid := req.Reward.ID
_, _ = s.user.GrantRewardToOrder(ctx, req.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: req.OrderID,
ProductID: req.Reward.ProductID,
Quantity: 1,
ActivityID: &req.ActivityID,
RewardID: &rid,
Remark: req.Reward.Name + "(倍数)",
})
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// 概率提升 - 尝试升级到更好的奖励
fmt.Printf("[道具卡-RewardEffects] 应用概率提升 BoostRateX1000=%d\n", ic.BoostRateX1000)
var better *model.ActivityRewardSettings
for _, r := range req.AllRewards {
if r.MinScore > req.Reward.MinScore && r.Quantity > 0 {
if better == nil || r.MinScore < better.MinScore {
better = r
}
}
}
if better != nil {
randBytes := make([]byte, 4)
rand.Read(randBytes)
randVal := int32(binary.BigEndian.Uint32(randBytes) % 1000)
if randVal < ic.BoostRateX1000 {
fmt.Printf("[道具卡-RewardEffects] ✅ 概率提升成功 升级到奖品ID=%d 奖品名=%s\n", better.ID, better.Name)
rid := better.ID
_, _ = s.user.GrantRewardToOrder(ctx, req.UserID, usersvc.GrantRewardToOrderRequest{
OrderID: req.OrderID,
ProductID: better.ProductID,
Quantity: 1,
ActivityID: &req.ActivityID,
RewardID: &rid,
Remark: better.Name + "(升级)",
})
upgradedReward = better
}
}
}
// 核销道具卡
fmt.Printf("[道具卡-RewardEffects] 核销道具卡 用户道具卡ID=%d\n", icID)
_, _ = s.writeDB.UserItemCards.WithContext(ctx).Where(
s.writeDB.UserItemCards.ID.Eq(icID),
s.writeDB.UserItemCards.UserID.Eq(req.UserID),
s.writeDB.UserItemCards.Status.Eq(1),
).Updates(map[string]any{
s.writeDB.UserItemCards.Status.ColumnName().String(): 2,
s.writeDB.UserItemCards.UsedDrawLogID.ColumnName().String(): drawLogID,
s.writeDB.UserItemCards.UsedActivityID.ColumnName().String(): req.ActivityID,
s.writeDB.UserItemCards.UsedIssueID.ColumnName().String(): req.IssueID,
s.writeDB.UserItemCards.UsedAt.ColumnName().String(): now,
})
return true, upgradedReward
}
// parseItemCardIDFromRemark 从订单备注解析道具卡ID
func parseItemCardIDFromRemark(remark string) int64 {
if remark == "" {
return 0
}
// 查找 |itemcard:xxx 模式
prefix := "|itemcard:"
idx := -1
for i := 0; i <= len(remark)-len(prefix); i++ {
if remark[i:i+len(prefix)] == prefix {
idx = i + len(prefix)
break
}
}
if idx < 0 {
// 也检查开头没有 | 的情况
prefix = "itemcard:"
for i := 0; i <= len(remark)-len(prefix); i++ {
if remark[i:i+len(prefix)] == prefix {
idx = i + len(prefix)
break
}
}
}
if idx < 0 {
return 0
}
var n int64
for idx < len(remark) {
c := remark[idx]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
idx++
}
return n
}

View File

@ -34,6 +34,10 @@ type OrderWithItems struct {
*model.Orders
Items []*model.OrderItems `json:"items"`
ActivityName string `json:"activity_name"`
ActivityID int64 `json:"activity_id,omitempty"`
PlayType string `json:"play_type,omitempty"`
CategoryID int64 `json:"category_id,omitempty"`
CategoryName string `json:"category_name,omitempty"`
IssueNumber string `json:"issue_number"`
IsDraw bool `json:"is_draw"`
IsWinner bool `json:"is_winner"`
@ -133,6 +137,15 @@ func (s *service) GetOrderWithItems(ctx context.Context, userID int64, orderID i
act, _ := s.readDB.Activities.WithContext(ctx).ReadDB().Where(s.readDB.Activities.ID.Eq(issue.ActivityID)).First()
if act != nil {
res.ActivityName = act.Name
res.ActivityID = act.ID
res.PlayType = act.PlayType
res.CategoryID = act.ActivityCategoryID
if act.ActivityCategoryID > 0 {
cat, _ := s.readDB.ActivityCategories.WithContext(ctx).ReadDB().Where(s.readDB.ActivityCategories.ID.Eq(act.ActivityCategoryID)).First()
if cat != nil {
res.CategoryName = cat.Name
}
}
}
}
@ -184,12 +197,15 @@ func (s *service) GetOrderWithItems(ctx context.Context, userID int64, orderID i
}
// ListOrdersWithItems 查询用户的订单列表,包含订单项详情
func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status int32, page, pageSize int) (items []*OrderWithItems, total int64, err error) {
func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status int32, isConsumed *int32, page, pageSize int) (items []*OrderWithItems, total int64, err error) {
// 查询用户的所有订单,包括商城直购(1)、抽奖票据(2)和系统发放(3)
q := s.readDB.Orders.WithContext(ctx).ReadDB().Where(s.readDB.Orders.UserID.Eq(userID))
if status > 0 {
q = q.Where(s.readDB.Orders.Status.Eq(status))
}
if isConsumed != nil {
q = q.Where(s.readDB.Orders.IsConsumed.Eq(*isConsumed))
}
total, err = q.Count()
if err != nil {
return nil, 0, err
@ -293,6 +309,42 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status
}
}
}
// 为没有开奖记录的抽奖订单补充查询活动信息
var extraActIDs []int64
for _, order := range orders {
if order.SourceType == 2 {
if _, hasLogs := drawLogsListMap[order.ID]; !hasLogs {
actID := parseActivityIDFromRemark(order.Remark)
if actID > 0 {
if _, exists := activityMap[actID]; !exists {
extraActIDs = append(extraActIDs, actID)
}
}
}
}
}
if len(extraActIDs) > 0 {
extraActs, _ := s.readDB.Activities.WithContext(ctx).ReadDB().Where(s.readDB.Activities.ID.In(extraActIDs...)).Find()
for _, act := range extraActs {
activityMap[act.ID] = act
}
}
}
// 批量查询活动分类信息
categoryMap := make(map[int64]*model.ActivityCategories)
var categoryIDs []int64
for _, act := range activityMap {
if act.ActivityCategoryID > 0 {
categoryIDs = append(categoryIDs, act.ActivityCategoryID)
}
}
if len(categoryIDs) > 0 {
categories, _ := s.readDB.ActivityCategories.WithContext(ctx).ReadDB().Where(s.readDB.ActivityCategories.ID.In(categoryIDs...)).Find()
for _, cat := range categories {
categoryMap[cat.ID] = cat
}
}
// 批量查询抽奖凭据
@ -322,6 +374,12 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status
items[i].IssueNumber = issue.IssueNumber
if act, ok := activityMap[issue.ActivityID]; ok {
items[i].ActivityName = act.Name
items[i].ActivityID = act.ID
items[i].PlayType = act.PlayType
items[i].CategoryID = act.ActivityCategoryID
if cat, ok := categoryMap[act.ActivityCategoryID]; ok {
items[i].CategoryName = cat.Name
}
}
}
@ -350,8 +408,45 @@ func (s *service) ListOrdersWithItems(ctx context.Context, userID int64, status
})
}
}
} else if order.SourceType == 2 {
// 抽奖订单但没有开奖记录(如对对碰支付后还没玩),从备注解析活动信息
actID := parseActivityIDFromRemark(order.Remark)
if actID > 0 {
if act, ok := activityMap[actID]; ok {
items[i].ActivityName = act.Name
items[i].ActivityID = act.ID
items[i].PlayType = act.PlayType
items[i].CategoryID = act.ActivityCategoryID
if cat, ok := categoryMap[act.ActivityCategoryID]; ok {
items[i].CategoryName = cat.Name
}
}
}
}
}
return items, total, nil
}
// parseActivityIDFromRemark 从订单备注解析活动ID
func parseActivityIDFromRemark(remark string) int64 {
if remark == "" {
return 0
}
parts := strings.Split(remark, "|")
for _, p := range parts {
if strings.HasPrefix(p, "lottery:activity:") {
idStr := p[len("lottery:activity:"):]
var n int64
for i := 0; i < len(idStr); i++ {
c := idStr[i]
if c < '0' || c > '9' {
break
}
n = n*10 + int64(c-'0')
}
return n
}
}
return 0
}

View File

@ -12,7 +12,7 @@ import (
type Service interface {
UpdateProfile(ctx context.Context, userID int64, nickname *string, avatar *string) (*model.Users, error)
ListOrders(ctx context.Context, userID int64, page, pageSize int) (items []*model.Orders, total int64, err error)
ListOrdersWithItems(ctx context.Context, userID int64, status int32, page, pageSize int) (items []*OrderWithItems, total int64, err error)
ListOrdersWithItems(ctx context.Context, userID int64, status int32, isConsumed *int32, page, pageSize int) (items []*OrderWithItems, total int64, err error)
ListInventoryWithProduct(ctx context.Context, userID int64, page, pageSize int) (items []*InventoryWithProduct, total int64, err error)
ListInventoryWithProductActive(ctx context.Context, userID int64, page, pageSize int) (items []*InventoryWithProduct, total int64, err error)
ListCoupons(ctx context.Context, userID int64, page, pageSize int) (items []*model.UserCoupons, total int64, err error)

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,61 @@
-- 对对碰奖励测试数据
-- 前提:需要先有一个活动和期次
-- 假设期次ID为 ? (请根据实际情况替换)
-- 查看现有期次
-- SELECT id, activity_id, issue_number FROM activity_issues ORDER BY id DESC LIMIT 10;
-- 设置期次ID (请替换为实际的期次ID)
SET @issue_id = 1;
-- 清理该期次下的旧奖励数据(谨慎使用)
-- DELETE FROM activity_reward_settings WHERE issue_id = @issue_id;
-- 插入对对碰奖励测试数据
-- 每个 min_score 对应一个奖品,精确匹配
INSERT INTO activity_reward_settings
(issue_id, product_id, name, weight, quantity, original_qty, level, sort, is_boss, min_score, created_at, updated_at)
VALUES
-- 0-10对参与奖
(@issue_id, 0, '参与奖-5对', 0, 100, 100, 10, 1, 0, 5, NOW(), NOW()),
(@issue_id, 0, '参与奖-10对', 0, 100, 100, 9, 2, 0, 10, NOW(), NOW()),
-- 10-20对铜奖
(@issue_id, 0, '铜奖-15对', 0, 50, 50, 8, 3, 0, 15, NOW(), NOW()),
(@issue_id, 0, '铜奖-20对', 0, 50, 50, 7, 4, 0, 20, NOW(), NOW()),
-- 20-30对银奖
(@issue_id, 0, '银奖-25对', 0, 30, 30, 6, 5, 0, 25, NOW(), NOW()),
(@issue_id, 0, '银奖-30对', 0, 30, 30, 5, 6, 0, 30, NOW(), NOW()),
-- 30-40对金奖
(@issue_id, 0, '金奖-35对', 0, 20, 20, 4, 7, 0, 35, NOW(), NOW()),
(@issue_id, 0, '金奖-40对', 0, 20, 20, 3, 8, 0, 40, NOW(), NOW()),
-- 40-45对特等奖
(@issue_id, 0, '特等奖-42对', 0, 10, 10, 2, 9, 0, 42, NOW(), NOW()),
(@issue_id, 0, '特等奖-44对', 0, 5, 5, 1, 10, 0, 44, NOW(), NOW()),
-- 45对以上大奖理论最大45-49对
(@issue_id, 0, '终极大奖-45对', 0, 3, 3, 0, 11, 1, 45, NOW(), NOW());
-- 验证插入的数据
SELECT id, issue_id, name, min_score, quantity, level, sort
FROM activity_reward_settings
WHERE issue_id = @issue_id
ORDER BY min_score ASC;
-- ================================================================
-- 使用说明:
-- 1. 将 @issue_id 替换为实际用于对对碰测试的期次ID
-- 2. 执行此脚本插入测试数据
-- 3. 通过APP或接口测试对对碰游戏
-- 4. 验证不同对子数是否匹配到正确的奖品
--
-- 测试用例:
-- - 消除5对 → 获得 "参与奖-5对"
-- - 消除10对 → 获得 "参与奖-10对"
-- - 消除15对 → 获得 "铜奖-15对"
-- - 消除12对 → 无匹配(因为是精确匹配)
-- - 消除45对 → 获得 "终极大奖-45对"
-- ================================================================

View File

@ -1,27 +0,0 @@
package main
import "fmt"
func CheckAPIJSONTags() {
tasks := doRequest("GET", "/api/admin/task-center/tasks", nil)
list, ok := tasks["list"].([]interface{})
if !ok || len(list) == 0 {
fmt.Println("No tasks found")
return
}
firstTask, ok := list[0].(map[string]any)
if !ok {
fmt.Println("Invalid task list format")
return
}
taskID := int64(firstTask["id"].(float64))
fmt.Printf("Checking Task ID: %d\n", taskID)
fmt.Println("\n--- Checking Tiers API ---")
tiersRaw := doRequestRaw("GET", fmt.Sprintf("/api/admin/task-center/tasks/%d/tiers", taskID), nil)
fmt.Println(string(tiersRaw))
fmt.Println("\n--- Checking Rewards API ---")
rewardsRaw := doRequestRaw("GET", fmt.Sprintf("/api/admin/task-center/tasks/%d/rewards", taskID), nil)
fmt.Println(string(rewardsRaw))
}

View File

@ -1,169 +0,0 @@
import os
import hmac
import hashlib
import time
from typing import List, Dict, Any, Tuple
from collections import Counter
class SecureBlindBoxRNG:
"""使用 HMAC-SHA256 的加密安全随机数生成器"""
def __init__(self):
self.server_seed = os.urandom(32)
self.server_seed_hash = hashlib.sha256(self.server_seed).hexdigest()
self.nonce = 0
def _generate_random_bytes(self, context: str) -> bytes:
self.nonce += 1
message = f"{context}|nonce:{self.nonce}".encode()
return hmac.new(self.server_seed, message, hashlib.sha256).digest()
def secure_randint(self, max_value: int, context: str = "rand") -> int:
if max_value <= 0:
return 0
rand_bytes = self._generate_random_bytes(context)
return int.from_bytes(rand_bytes[:8], 'big') % max_value
def secure_shuffle(self, items: List) -> List:
items = items[:]
n = len(items)
for i in range(n - 1, 0, -1):
j = self.secure_randint(i + 1, f"shuffle:{i}")
items[i], items[j] = items[j], items[i]
return items
class MatchingGame:
"""对对碰游戏 - 可视化版本"""
def __init__(self):
self.rng = SecureBlindBoxRNG()
# 11种卡牌每种9张
self.card_types = ['🍎', '🍊', '🍋', '🍇', '🍓', '🍒', '🥝', '🍑', '🍌', '🍉', '🫐']
self.card_list = [card for card in self.card_types for _ in range(9)]
# 安全洗牌
self.card_list = self.rng.secure_shuffle(self.card_list)
self.hand = self.card_list[:9]
self.deck = self.card_list[9:]
self.total_pairs = 0
self.round = 0
def display_hand(self):
"""显示当前手牌"""
print(f"\n{'='*50}")
print(f"{self.round} 轮 | 手牌: {len(self.hand)}张 | 牌堆: {len(self.deck)}")
print(f"{'='*50}")
print(f" 手牌: {' '.join(self.hand)}")
# 统计每种牌的数量
counter = Counter(self.hand)
pairs_info = []
for card, count in counter.items():
if count >= 2:
pairs_info.append(f"{card}×{count}")
if pairs_info:
print(f" 可配对: {', '.join(pairs_info)}")
else:
print(f" 可配对: 无")
def find_and_remove_pairs(self) -> Tuple[int, List[str]]:
"""找出并移除配对的牌"""
counter = Counter(self.hand)
pairs_found = 0
pairs_detail = []
remaining = []
for card, count in counter.items():
pairs = count // 2
if pairs > 0:
pairs_found += pairs
pairs_detail.append(f"{card}×{pairs*2}")
# 剩余的单张
if count % 2 == 1:
remaining.append(card)
return pairs_found, remaining, pairs_detail
def play_round(self, is_first: bool = False):
"""执行一轮游戏"""
self.round += 1
# 第一轮:如果有🍎,额外抽牌
if is_first:
apple_count = self.hand.count('🍎')
if apple_count > 0:
print(f"\n 🎁 发现 {apple_count} 张🍎,额外抽取 {apple_count} 张牌!")
extra = self.deck[:apple_count]
self.hand.extend(extra)
self.deck = self.deck[apple_count:]
print(f" 抽到: {' '.join(extra)}")
self.display_hand()
# 配对
pairs, remaining, pairs_detail = self.find_and_remove_pairs()
if pairs > 0:
print(f"\n ✨ 配对成功: {', '.join(pairs_detail)}")
print(f" 本轮配对: {pairs}")
self.total_pairs += pairs
# 抽取新牌
draw_count = min(pairs, len(self.deck))
if draw_count > 0:
new_cards = self.deck[:draw_count]
self.deck = self.deck[draw_count:]
remaining.extend(new_cards)
print(f" 从牌堆抽取: {' '.join(new_cards)}")
self.hand = remaining
return True
else:
print(f"\n ❌ 无法配对,游戏结束")
self.hand = remaining
return False
def play(self):
"""完整游戏流程"""
print("\n" + "🎮" * 20)
print(" 欢迎来到 对对碰 游戏!")
print("🎮" * 20)
print(f"\n游戏规则:")
print(f" 1. 初始手牌 9 张")
print(f" 2. 相同的牌可以配对消除")
print(f" 3. 每配对一次,从牌堆抽取相应数量的新牌")
print(f" 4. 直到无法配对为止")
print(f"\n种子哈希: {self.rng.server_seed_hash[:16]}...")
# 第一轮
can_continue = self.play_round(is_first=True)
# 后续轮次
while can_continue:
input("\n 按回车继续...")
can_continue = self.play_round(is_first=False)
# 游戏结束
print(f"\n{'='*50}")
print(f" 🏆 游戏结束!")
print(f" 总配对数: {self.total_pairs}")
print(f" 剩余手牌: {len(self.hand)}{' '.join(self.hand)}")
print(f" 剩余牌堆: {len(self.deck)}")
print(f"{'='*50}")
return {
"total_pairs": self.total_pairs,
"remaining_hand": self.hand,
"remaining_deck_count": len(self.deck),
"seed_hash": self.rng.server_seed_hash
}
if __name__ == "__main__":
game = MatchingGame()
result = game.play()

View File

@ -1,124 +0,0 @@
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()

View File

@ -1,203 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
BaseURL = "http://localhost:9991"
Token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6MiwidXNlcm5hbWUiOiJTdXBlciIsIm5pY2tuYW1lIjoiU3VwZXIiLCJpc19zdXBlciI6MSwicGxhdGZvcm0iOiLnrqHnkIbnq68iLCJleHAiOjE3NjU2NDIwMDYsIm5iZiI6MTc2NTM4MjgwNiwiaWF0IjoxNzY1MzgyODA2fQ.1pYRmSmFd-b4PudiYhGXgCv-LBr49_MMfNdyuc_7LXA"
)
func main() {
if len(os.Args) > 1 && os.Args[1] == "check-json" {
CheckAPIJSONTags()
return
}
fmt.Println("Starting to setup complete task center...")
// 1. 清理现有任务
tasks := doRequest("GET", "/api/admin/task-center/tasks", nil)
list := tasks["list"].([]interface{})
for _, t := range list {
task := t.(map[string]any)
id := int64(task["id"].(float64))
fmt.Printf("Deleting existing task: %d - %s\n", id, task["name"])
doRequest("DELETE", fmt.Sprintf("/api/admin/task-center/tasks/%d", id), nil)
}
// ---------------------------------------------------------
// 任务 1: 每日订单挑战 (Daily Order Challenge)
// ---------------------------------------------------------
t1ID := createTask("每日订单挑战", "每日完成订单挑战,赢取积分与道具", 1, 1)
upsertTiers(t1ID, []map[string]any{
{"metric": "order_count", "operator": ">=", "threshold": 1, "window": "daily", "repeatable": 1, "priority": 1},
{"metric": "order_count", "operator": ">=", "threshold": 10, "window": "daily", "repeatable": 1, "priority": 2},
{"metric": "order_count", "operator": ">=", "threshold": 100, "window": "daily", "repeatable": 1, "priority": 3},
})
// 获取 Tier IDs 并配置奖励
t1Tiers := listTiers(t1ID)
var r1 []map[string]any
for _, t := range t1Tiers {
p := int(t["Priority"].(float64))
tid := int64(t["ID"].(float64))
if p == 1 { // 1单 -> 1元(100积分)
r1 = append(r1, map[string]any{"tier_id": tid, "reward_type": "points", "reward_payload": map[string]int{"points": 100}, "quantity": 1})
} else if p == 2 { // 10单 -> 优惠券ID 1
r1 = append(r1, map[string]any{"tier_id": tid, "reward_type": "coupon", "reward_payload": map[string]int{"coupon_id": 1}, "quantity": 1})
} else if p == 3 { // 100单 -> 道具卡ID 1
r1 = append(r1, map[string]any{"tier_id": tid, "reward_type": "item_card", "reward_payload": map[string]int{"card_id": 1, "quantity": 1}, "quantity": 1})
}
}
upsertRewards(t1ID, r1)
fmt.Printf("Created Task [Daily Order]: %d\n", t1ID)
// ---------------------------------------------------------
// 任务 2: 每日邀请挑战 (Daily Invite Challenge)
// ---------------------------------------------------------
t2ID := createTask("每日邀请挑战", "每日邀请好友,奖励领不停", 1, 1)
upsertTiers(t2ID, []map[string]any{
{"metric": "invite_count", "operator": ">=", "threshold": 1, "window": "daily", "repeatable": 1, "priority": 1},
{"metric": "invite_count", "operator": ">=", "threshold": 10, "window": "daily", "repeatable": 1, "priority": 2},
{"metric": "invite_count", "operator": ">=", "threshold": 100, "window": "daily", "repeatable": 1, "priority": 3},
})
t2Tiers := listTiers(t2ID)
var r2 []map[string]any
for _, t := range t2Tiers {
p := int(t["Priority"].(float64))
tid := int64(t["ID"].(float64))
if p == 1 { // 1人 -> 1元(100积分)
r2 = append(r2, map[string]any{"tier_id": tid, "reward_type": "points", "reward_payload": map[string]int{"points": 100}, "quantity": 1})
} else if p == 2 { // 10人 -> 优惠券ID 1
r2 = append(r2, map[string]any{"tier_id": tid, "reward_type": "coupon", "reward_payload": map[string]int{"coupon_id": 1}, "quantity": 1})
} else if p == 3 { // 100人 -> 道具卡ID 1
r2 = append(r2, map[string]any{"tier_id": tid, "reward_type": "item_card", "reward_payload": map[string]int{"card_id": 1, "quantity": 1}, "quantity": 1})
}
}
upsertRewards(t2ID, r2)
fmt.Printf("Created Task [Daily Invite]: %d\n", t2ID)
// ---------------------------------------------------------
// 任务 3: 新人首单福利 (Newbie First Order) - Lifetime
// ---------------------------------------------------------
t3ID := createTask("新人首单福利", "完成首次订单,获得新人专属奖励", 1, 1)
upsertTiers(t3ID, []map[string]any{
{"metric": "first_order", "operator": "==", "threshold": 1, "window": "lifetime", "repeatable": 0, "priority": 1},
})
t3Tiers := listTiers(t3ID)
var r3 []map[string]any
for _, t := range t3Tiers {
// 首单奖励 500 积分
r3 = append(r3, map[string]any{"tier_id": int64(t["ID"].(float64)), "reward_type": "points", "reward_payload": map[string]int{"points": 500}, "quantity": 1})
}
upsertRewards(t3ID, r3)
fmt.Printf("Created Task [Newbie First Order]: %d\n", t3ID)
// ---------------------------------------------------------
// 任务 4: 邀请达人 (Master Inviter) - Lifetime Cumulative
// ---------------------------------------------------------
t4ID := createTask("邀请达人挑战", "累计邀请好友,解锁达人成就", 1, 1)
upsertTiers(t4ID, []map[string]any{
{"metric": "invite_count", "operator": ">=", "threshold": 50, "window": "lifetime", "repeatable": 0, "priority": 1},
{"metric": "invite_count", "operator": ">=", "threshold": 200, "window": "lifetime", "repeatable": 0, "priority": 2},
{"metric": "invite_count", "operator": ">=", "threshold": 500, "window": "lifetime", "repeatable": 0, "priority": 3},
})
t4Tiers := listTiers(t4ID)
var r4 []map[string]any
for _, t := range t4Tiers {
p := int(t["Priority"].(float64))
tid := int64(t["ID"].(float64))
if p == 1 { // 50人 -> 道具卡 ID 1 x2
r4 = append(r4, map[string]any{"tier_id": tid, "reward_type": "item_card", "reward_payload": map[string]int{"card_id": 1, "quantity": 2}, "quantity": 2})
} else if p == 2 { // 200人 -> 优惠券 ID 1 x5
r4 = append(r4, map[string]any{"tier_id": tid, "reward_type": "coupon", "reward_payload": map[string]int{"coupon_id": 1}, "quantity": 5})
} else if p == 3 { // 500人 -> 10000 积分
r4 = append(r4, map[string]any{"tier_id": tid, "reward_type": "points", "reward_payload": map[string]int{"points": 10000}, "quantity": 1})
}
}
upsertRewards(t4ID, r4)
fmt.Printf("Created Task [Master Inviter]: %d\n", t4ID)
fmt.Println("All tasks setup completed!")
}
func doRequest(method, path string, body any) map[string]any {
var bodyReader io.Reader
if body != nil {
b, _ := json.Marshal(body)
bodyReader = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, BaseURL+path, bodyReader)
req.Header.Set("Authorization", Token)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
panic(fmt.Sprintf("Status: %d, Body: %s", resp.StatusCode, string(b)))
}
var res map[string]any
json.Unmarshal(b, &res)
return res
}
func doRequestRaw(method, path string, body any) []byte {
var bodyReader io.Reader
if body != nil {
b, _ := json.Marshal(body)
bodyReader = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, BaseURL+path, bodyReader)
req.Header.Set("Authorization", Token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
b, _ := io.ReadAll(resp.Body)
return b
}
func createTask(name, desc string, status, visibility int) int64 {
res := doRequest("POST", "/api/admin/task-center/tasks", map[string]any{
"name": name, "description": desc, "status": status, "visibility": visibility,
})
return int64(res["id"].(float64))
}
func upsertTiers(taskID int64, tiers []map[string]any) {
doRequest("POST", fmt.Sprintf("/api/admin/task-center/tasks/%d/tiers", taskID), map[string]any{
"tiers": tiers,
})
}
func listTiers(taskID int64) []map[string]any {
res := doRequest("GET", fmt.Sprintf("/api/admin/task-center/tasks/%d/tiers", taskID), nil)
list := res["list"].([]interface{})
var ret []map[string]any
for _, v := range list {
ret = append(ret, v.(map[string]any))
}
return ret
}
func upsertRewards(taskID int64, rewards []map[string]any) {
doRequest("POST", fmt.Sprintf("/api/admin/task-center/tasks/%d/rewards", taskID), map[string]any{
"rewards": rewards,
})
}