Compare commits

..

81 Commits

Author SHA1 Message Date
9214501756 x 2026-02-02 23:56:35 +08:00
25c44c2064 x 2026-02-02 23:56:01 +08:00
55e22086e8 201 2026-02-01 00:27:38 +08:00
1a8f94d7b8 fix 2026-01-29 19:25:16 +08:00
f8624cca49 delete 2026-01-28 21:41:47 +08:00
ff404e21f0 x 2026-01-28 21:38:44 +08:00
021ab34c75 add:opencode 2026-01-28 21:36:36 +08:00
6d33cc7fd0 fix:盈亏计算 2026-01-27 01:33:32 +08:00
5ad2f4ace3 feat: 保存当前开发进度 - 直播抽奖验证功能 2026-01-18 01:55:54 +08:00
b21e2db8ef feat: 添加抖音商品奖励功能,并增强后台用户列表统计数据、邀请人数及道具数量展示。 2026-01-08 10:13:29 +08:00
e3a96e68d8 fix: 修复退款时清理一番赏格位、积分兑换商品库存校验及抖音登录自邀问题。 2026-01-06 01:46:25 +08:00
359ca9121f chore: 添加定时开奖和抖店同步的调试与信息日志 2026-01-04 22:58:38 +08:00
fb6dc1e434 feat: 新增抖音登录功能、管理端次数卡及套餐管理接口,并引入配置迁移工具。 2026-01-04 01:40:11 +08:00
e8bfff8261 feat: 新增抖音订单、游戏通行证、快照回滚、短信登录及管理后台功能,并优化支付、活动与用户服务模块,同时清理旧文档 2026-01-02 12:38:03 +08:00
4a582997d1 feat: 引入活动抽奖策略槽位选择功能,新增用户库存发货单号字段,并优化支付与活动服务集成。 2025-12-26 18:15:15 +08:00
8eb28465a2 feat: 新增用户地址更新功能并优化抽奖结果展示,支持显示订单所有抽奖记录 2025-12-26 12:44:29 +08:00
2838ccb4c7 feat: 商店商品展示新增所需积分,抽奖策略强制使用活动承诺种子,并新增用户过期任务和游戏令牌服务 2025-12-26 12:22:32 +08:00
5710b977e0 feat: minesweeper dynamic config and granular rewards 2025-12-24 17:33:13 +08:00
6435226f6d refactor: 优化订单时间字段处理及数据库模型结构调整
- 将订单的PaidAt和CancelledAt从指针类型改为值类型
- 统一时间字段的判空逻辑,使用IsZero()替代nil检查
- 调整多个数据库模型结构,添加新字段并优化字段顺序
- 为活动奖励设置、用户邀请等表添加新字段
- 更新对应的DAO层代码以匹配模型变更
2025-12-23 23:37:59 +08:00
9aeca5344f feat: 新增取消发货功能并优化任务中心
fix: 修复微信通知字段截断导致的编码错误
feat: 添加有效邀请相关字段和任务中心常量
refactor: 重构一番赏奖品格位逻辑
perf: 优化道具卡列表聚合显示
docs: 更新项目说明文档和API文档
test: 添加字符串截断工具测试
2025-12-23 22:26:07 +08:00
4aad2ad07c feat: 新增订单列表筛选条件与活动信息展示
refactor(orders): 重构订单列表查询逻辑,支持按消耗状态筛选
feat(orders): 订单列表返回新增活动分类与玩法类型信息
fix(orders): 修复订单支付时间空指针问题
docs(swagger): 更新订单相关接口文档
test(matching): 添加对对碰奖励匹配测试用例
chore: 清理无用脚本文件
2025-12-22 15:15:18 +08:00
bf11f32edf feat(活动): 新增抽奖记录等级筛选功能并优化展示信息
refactor(抽奖记录): 重构抽奖记录列表接口,支持按等级筛选
新增用户昵称、头像及奖品名称、图片等展示字段
优化分页逻辑,默认返回最新100条记录

feat(游戏): 添加扫雷游戏验证和结算接口
新增游戏票据验证和结算相关接口定义及Swagger文档

docs(API): 更新Swagger文档
更新抽奖记录和游戏相关接口的文档描述

style(路由): 添加游戏路由注释
添加扫雷游戏接口路由的占位注释
2025-12-21 23:45:01 +08:00
9dbd37e07f feat: 添加对对碰游戏功能与Redis支持
refactor: 重构抽奖逻辑以支持可验证凭据
feat(redis): 集成Redis客户端并添加配置支持
fix: 修复订单取消时的优惠券和库存处理逻辑
docs: 添加对对碰游戏前端对接指南和示例JSON
test: 添加对对碰游戏模拟测试和验证逻辑
2025-12-21 17:31:32 +08:00
d055f81b90 chore: 清理无用文件与优化代码结构
refactor(utils): 修复密码哈希比较逻辑错误
feat(user): 新增按状态筛选优惠券接口
docs: 添加虚拟发货与任务中心相关文档
fix(wechat): 修正Code2Session上下文传递问题
test: 补充订单折扣与积分转换测试用例
build: 更新配置文件与构建脚本
style: 清理多余的空行与注释
2025-12-18 17:35:55 +08:00
a29c8ead15 build: 更新前端构建产物和资源文件
更新了前端构建产物包括JavaScript、CSS和HTML文件,主要涉及以下变更:

1. 新增了多个组件和工具函数,包括异常页面组件、iframe组件等
2. 更新了活动管理、产品管理、优惠券管理等业务模块
3. 优化了构建配置和依赖管理
4. 修复了一些样式和功能问题
5. 更新了测试相关文件

同时更新了部分后端服务接口和测试用例。这些变更主要是为了支持新功能和改进现有功能的用户体验。
2025-11-21 01:24:13 +08:00
be91dbbfa0 feat(admin): 更新前端资源文件及修复相关功能
refactor(service): 修改banner和guild删除逻辑为软删除
fix(service): 修复删除操作使用软删除而非物理删除

build: 添加SQLite测试仓库实现
docs: 新增奖励管理字段拆分和批量抽奖UI改造文档

ci: 更新CI忽略文件
style: 清理无用资源文件
2025-11-19 01:35:55 +08:00
46977aef2a feat: 新增支付测试小程序与微信支付集成
feat(pay): 添加支付API基础结构
feat(miniapp): 创建支付测试小程序页面与配置
feat(wechatpay): 配置微信支付参数与证书
fix(guild): 修复成员列表查询条件
docs: 更新代码规范文档与需求文档
style: 统一前后端枚举显示与注释格式
refactor(admin): 重构用户奖励发放接口参数处理
test(title): 添加称号效果参数验证测试
2025-11-17 00:42:08 +08:00
208b7cde8a feat(工作台): 实现管理端工作台接口并优化数据展示
feat(抽奖动态): 修复抽奖动态未渲染问题并优化文案展示
fix(用户概览): 修复用户概览无数据显示问题
feat(新用户列表): 在新用户列表显示称号明细
refactor(待办事项): 移除代办模块并全宽展示实时动态
feat(批量操作): 限制为单用户操作并在批量时提醒
fix(称号分配): 防重复分配称号的改造计划
perf(接口性能): 优化新用户和抽奖动态接口性能
feat(订单漏斗): 优化订单转化漏斗指标计算
docs(测试计划): 完善盲盒运营API核查与闭环测试计划
2025-11-16 14:00:29 +08:00
0ae202166a feat: 添加环境变量支持并增强系统标题效果验证
feat(security): 支持通过环境变量配置主密钥和JWT密钥
refactor(router): 移除开发便捷路由接口
feat(admin): 添加超级管理员权限检查
feat(titles): 增加系统标题效果参数验证逻辑
2025-11-16 11:51:47 +08:00
0b15f075b6 feat(称号系统): 新增称号管理功能与抽奖效果集成
- 新增系统称号模板与效果配置表及相关CRUD接口
- 实现用户称号分配与抽奖效果应用逻辑
- 优化抽奖接口支持用户ID参数以应用称号效果
- 新增称号管理前端页面与分配功能
- 修复Windows时区错误与JSON字段初始化问题
- 移除无用管理接口代码并更新文档说明
2025-11-16 11:37:40 +08:00
83d5faf46b feat(activity): 实现抽奖随机承诺与验证功能
新增随机种子生成与验证逻辑,包括:
1. 添加随机承诺生成接口
2. 实现抽奖执行与验证流程
3. 新增批量用户创建与删除功能
4. 添加抽奖收据记录表
5. 完善配置管理与错误码

新增测试用例验证随机算法正确性
2025-11-15 20:39:13 +08:00
21340a48c6 feat: 添加用户统计功能及相关API接口
feat(admin): 新增管理后台前端资源文件

feat(api): 实现获取用户统计数据的API接口
- 添加获取用户道具卡数量、优惠券数量和积分余额的接口
- 实现设置默认地址和删除地址的接口

feat(service): 新增用户统计服务方法
- 实现GetUserStats方法查询用户统计数据
- 添加地址管理相关服务方法

fix(core): 修复静态资源路由问题
- 调整静态资源路由配置
- 优化404路由处理逻辑

chore: 更新前端构建配置
- 添加Windows平台构建命令
- 更新README构建说明
2025-11-15 03:08:53 +08:00
9106663083 feat(interceptor): 添加APP端token验证接口并实现用户私有数据鉴权
refactor(api/user): 重构用户相关接口使用token验证替代user_id路径参数

docs: 更新API文档规范,明确私有接口需携带token及返回字段要求

fix(service/user): 避免写入未使用字段的零值导致MySQL校验错误

style: 统一格式化部分代码缩进和导入顺序

chore: 更新DS_Store等IDE配置文件
2025-11-15 00:49:53 +08:00
5088eec733 refactor: 重构项目结构并重命名模块
feat(admin): 新增工会管理功能
feat(activity): 添加活动管理相关服务
feat(user): 实现用户道具卡和积分管理
feat(guild): 新增工会成员管理功能

fix: 修复数据库连接配置
fix: 修正jwtoken导入路径
fix: 解决端口冲突问题

style: 统一代码格式和注释风格
style: 更新项目常量命名

docs: 添加项目框架和开发规范文档
docs: 更新接口文档注释

chore: 移除无用代码和文件
chore: 更新Makefile和配置文件
chore: 清理日志文件

test: 添加道具卡测试脚本
2025-11-14 21:10:00 +08:00
67ce1dcda4 feat: 添加测试链工具并修复小程序状态监控错误
添加测试链工具用于验证活动和工会功能,修复小程序状态监控中的错误处理逻辑
2025-11-13 14:26:54 +08:00
summer
f60eaddae6 feat(1.0): 新增脚本定时更新小程序状态 2025-11-06 20:41:46 +08:00
summer
5885463bb0 feat(1.0): 新增脚本定时更新小程序状态 2025-11-06 20:41:08 +08:00
ca4f6e9119 Merge branch 'main' of https://git.1024tool.vip/xl/mini-chat 2025-11-06 20:37:02 +08:00
0fb58a4555 feat(小程序): 添加检查小程序状态接口
refactor(测试): 移除测试中的硬编码凭证
fix(模板消息): 将小程序状态改为正式版
docs(swagger): 更新API文档并移除密码必填限制
2025-11-06 20:37:01 +08:00
summer
2487062b46 feat(1.0): 新增脚本定时更新小程序状态 2025-11-06 20:25:47 +08:00
summer
64a5154a53 feat(1.0): 调整统计数字逻辑 2025-11-06 15:53:13 +08:00
summer
7b8bab65f4 feat(1.0): 调整统计数字逻辑 2025-11-06 13:09:24 +08:00
summer
df01bfc96d feat(1.0): 调整关联小程序 2025-11-06 11:33:02 +08:00
summer
a68a90c3e5 feat(1.0): 调整密码编辑 2025-11-06 11:26:16 +08:00
summer
b665e37f1e feat(1.0): 调整模版变量 2025-11-05 16:43:44 +08:00
summer
f029ffa38b feat(1.0): 调整模版变量 2025-11-05 14:52:28 +08:00
summer
0a94ac88da feat(1.0):调整为实时获取 access_token 2025-11-05 14:48:39 +08:00
summer
0767f3a8af feat(1.0):新增消息总数字段 2025-11-05 14:12:07 +08:00
summer
f56da9ee9b feat(1.0):优化代码 2025-11-05 11:33:28 +08:00
5e1a6f925f admin-desc 2025-10-30 23:58:37 +08:00
fe07eed82c fix(wechat): 修正订阅消息页面跳转链接中的参数分隔符
将订阅消息页面跳转链接中的参数分隔符从"&"改为"?",以符合URL规范
2025-10-30 23:44:29 +08:00
d2b96107c5 feat(消息列表): 添加消息ID字段并实现分页查询
在消息列表数据结构中添加ID字段,用于前端展示
实现分页查询功能,通过offset和limit参数控制返回结果
2025-10-30 23:43:09 +08:00
4e11abb342 fix(wechat): 修改订阅消息跳转页面链接参数 2025-10-30 23:32:06 +08:00
3acf6d7644 fix(消息列表): 移除分页参数限制为固定100条
修复消息列表查询中分页参数的问题,改为固定返回100条记录以提升性能
2025-10-30 23:30:46 +08:00
eca842af84 desc 2025-10-30 23:24:51 +08:00
summer
f86afcf9c1 feat(1.0):调整已读的节点 2025-10-30 17:06:49 +08:00
summer
27e1b44161 feat(1.0):调试模版消息 2025-10-30 15:25:43 +08:00
00e6248f71 feat(wechat): 添加发送订阅消息接口并移除登录中的模板消息逻辑
将发送模板消息的逻辑从登录接口中移除,并新增独立的订阅消息发送接口
2025-10-29 23:08:16 +08:00
09d18605c4 refactor(wechat): 移除小程序模板获取的权限检查
权限检查已在中间件统一处理,此处重复检查已无必要
2025-10-29 22:28:25 +08:00
d085b8d035 feat(wechat): 添加获取微信小程序模板消息接口 2025-10-29 22:26:25 +08:00
e22a5f078c feat(微信): 添加获取微信小程序模板ID接口
新增/api/wechat/template接口,用于根据AppID获取微信小程序的模板ID
添加相关请求和响应数据结构定义
实现模板ID查询逻辑,包括权限验证和错误处理
更新swagger文档
2025-10-29 22:23:35 +08:00
summer
b1aeafcdb3 feat(1.0):调试模版消息 2025-10-29 17:32:22 +08:00
summer
b9d32893fa feat(1.0):调试模版消息 2025-10-29 17:20:59 +08:00
summer
466b16a274 feat(1.0):调试模版消息 2025-10-27 11:05:03 +08:00
d4d2355e36 chore: 更新数据库连接配置和jwt密钥
将本地开发数据库配置更改为腾讯云数据库
更新jwt admin_secret以提高安全性
2025-10-22 02:10:23 +08:00
4ec9dc4492 Merge branch 'main' of https://git.1024tool.vip/xl/mini-chat 2025-10-22 02:07:18 +08:00
6aab4a2cf4 refactor: 更新构建输出名称和数据库配置
将构建输出文件名称从miniChat改为MINI
更新数据库配置为本地开发环境,包括地址、用户名和密码
在消息列表响应中添加消息ID字段并调整时间格式
2025-10-22 02:07:16 +08:00
summer
799e7705a7 feat(1.0):调整已读的节点 2025-10-21 18:41:09 +08:00
summer
bc2fa2fd64 feat(1.0):调整已读的节点 2025-10-21 18:30:28 +08:00
summer
727b942c5d feat(1.0):路径追加 /api 2025-10-21 10:30:53 +08:00
summer
d86b883413 feat(1.0):调整未读列表 2025-10-20 17:06:18 +08:00
summer
3c6ae48196 feat(1.0):调整未读列表 2025-10-20 15:55:25 +08:00
summer
147af6fdfb feat(1.0):调整未读列表 2025-10-20 15:48:43 +08:00
summer
a798a8d10e feat(1.0):调整未读列表 2025-10-20 15:20:39 +08:00
summer
4c192426df feat(1.0):调整未读列表 2025-10-20 14:21:42 +08:00
summer
abccbe3a54 feat(1.0):调整未读列表 2025-10-20 14:11:27 +08:00
998e6cdd57 refactor(wechat): 替换随机用户名和头像生成方式
使用randomname库生成随机用户名
使用dicebear API生成头像URL
移除不再需要的依赖项
2025-10-19 01:05:33 +08:00
3fd89b0462 feat(wechat): 重构小程序登录接口,实现自动生成用户信息和头像
- 移除微信用户信息解密相关代码,改为系统自动生成用户名和头像
- 添加用户信息存储功能,使用openID作为用户ID
- 集成govatar和namegenerator库生成用户头像和随机用户名
- 添加token生成功能,返回给客户端用于后续认证
- 更新swagger文档,反映接口变更
2025-10-19 00:34:02 +08:00
9e821723f8 refactor(wechat): 重构微信二维码生成接口上下文处理
移除全局token缓存逻辑,统一使用core.Context接口
修改GenerateQRCode方法签名,增加上下文参数
更新相关调用链以适配新的上下文处理方式
2025-10-19 00:23:44 +08:00
0d10872e2e feat(wechat): 增加微信小程序用户数据解密功能
添加对微信小程序加密用户数据的解密支持,包括签名验证和解密用户信息
更新swagger文档以反映新的API字段和数据结构
2025-10-18 23:08:55 +08:00
7bdf81782b refactor(消息): 重构消息已读状态处理
将消息已读状态从单独的表迁移到消息表,简化架构
移除标记消息已读的独立接口,改为直接更新消息表
更新相关模型、路由和文档以反映架构变更
2025-10-18 19:41:08 +08:00
172 changed files with 16253 additions and 216645 deletions

BIN
.DS_Store vendored

Binary file not shown.

10
.gitignore vendored
View File

@ -27,3 +27,13 @@ go.work.sum
resources/*
build/resources/admin/
logs/
web/*
# 敏感配置文件
configs/*.toml
!configs/*.example.toml
# 环境变量
.env
.env.*
!.env.example

View File

@ -1,5 +1,5 @@
# Build stage
FROM golang:1.24.5-alpine AS builder
FROM golang:1.24-alpine AS builder
# Set working directory
WORKDIR /app
@ -12,20 +12,20 @@ COPY go.mod go.sum ./
# Set Go environment variables and proxy
ENV GO111MODULE=on \
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
GOPROXY=https://goproxy.cn,https://goproxy.io,direct \
GOSUMDB=sum.golang.google.cn
CGO_ENABLED=0 \
GOOS=linux \
GOARCH=amd64 \
GOPROXY=https://goproxy.cn,https://goproxy.io,direct \
GOSUMDB=sum.golang.google.cn
# Download dependencies with retry mechanism
RUN go mod download || \
(echo "Retrying with different proxy..." && \
go env -w GOPROXY=https://goproxy.io,https://mirrors.aliyun.com/goproxy/,direct && \
go mod download) || \
(echo "Final retry with direct mode..." && \
go env -w GOPROXY=direct && \
go mod download)
(echo "Retrying with different proxy..." && \
go env -w GOPROXY=https://goproxy.io,https://mirrors.aliyun.com/goproxy/,direct && \
go mod download) || \
(echo "Final retry with direct mode..." && \
go env -w GOPROXY=direct && \
go mod download)
# Copy source code
COPY . .
@ -62,4 +62,4 @@ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:9991/system/health || exit 1
# Run the application
CMD ["./miniChat"]
CMD ["sh", "-c", "./miniChat -env=${ACTIVE_ENV}"]

View File

@ -1,20 +0,0 @@
package main
import (
"bindbox-game/internal/pkg/utils"
"fmt"
)
func main() {
password := "123456"
hash, err := utils.GenerateAdminHashedPassword(password)
if err != nil {
fmt.Println("Error:", err)
return
}
fmt.Println("Password hash for '123456':")
fmt.Println(hash)
fmt.Println()
fmt.Println("SQL to insert admin:")
fmt.Printf("INSERT INTO admin (username, nickname, password, login_status, is_super, created_user, created_at) VALUES ('CC', 'CC', '%s', 1, 0, 'system', NOW());\n", hash)
}

View File

@ -34,6 +34,6 @@ eg :
```shell
# 根目录下执行
go run cmd/gormgen/main.go -dsn "root:api2api..@tcp(sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local" -tables "admin,log_operation,log_request,activities,activity_categories,activity_draw_logs,activity_issues,activity_reward_settings,system_coupons,user_coupons,user_inventory,user_inventory_transfers,user_points,user_points_ledger,users,user_addresses,menu_actions,menus,role_actions,role_menus,role_users,roles,order_items,orders,products,shipping_records,product_categories,user_invites,system_item_cards,user_item_cards,activity_draw_effects,banner,activity_draw_receipts,system_titles,system_title_effects,user_titles,user_title_effect_claims,payment_preorders,payment_transactions,payment_refunds,payment_notify_events,payment_bills,payment_bill_diff,ops_shipping_stats,system_configs,issue_position_claims,task_center_tasks,task_center_task_tiers,task_center_task_rewards,order_coupons,matching_card_types,channels,user_game_tickets,game_ticket_logs,order_snapshots,audit_rollback_logs,user_coupon_ledger,user_game_passes,game_pass_packages"
go run cmd/gormgen/main.go -dsn "root:api2api..@tcp(sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555)/bindbox_game?charset=utf8mb4&parseTime=True&loc=Local" -tables "admin,log_operation,log_request,activities,activity_categories,activity_draw_logs,activity_issues,activity_reward_settings,system_coupons,user_coupons,user_inventory,user_inventory_transfers,user_points,user_points_ledger,users,user_addresses,menu_actions,menus,role_actions,role_menus,role_users,roles,order_items,orders,products,shipping_records,product_categories,user_invites,system_item_cards,user_item_cards,activity_draw_effects,banner,activity_draw_receipts,system_titles,system_title_effects,user_titles,user_title_effect_claims,payment_preorders,payment_transactions,payment_refunds,payment_notify_events,payment_bills,payment_bill_diff,ops_shipping_stats,system_configs,issue_position_claims,task_center_tasks,task_center_task_tiers,task_center_task_rewards,order_coupons,matching_card_types,channels,user_game_tickets,game_ticket_logs,order_snapshots,audit_rollback_logs,user_coupon_ledger,user_game_passes,game_pass_packages,livestream_activities,livestream_prizes,livestream_draw_logs"
```

View File

@ -1,28 +0,0 @@
## 自动生成数据库模型和常见的 CRUD 操作
### Usage
```shell
go run cmd/handlergen/main.go -h
Usage of ./cmd/handlergen/main.go:
-table string
enter the required data table
```
#### -table
指定要生成的表名称。
eg :
```shell
--tables="admin" # generate from `admin`
```
## 示例
```shell
# 根目录下执行
go run cmd/handlergen/main.go -table "customer"
```

View File

@ -1,265 +0,0 @@
package {{.PackageName}}
import (
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"WeChatService/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"go.uber.org/zap"
"gorm.io/gorm"
)
type handler struct {
logger logger.CustomLoggerLogger
writeDB *dao.Query
readDB *dao.Query
}
type genResultInfo struct {
RowsAffected int64 `json:"rows_affected"`
Error error `json:"error"`
}
func New(logger logger.CustomLogger, db mysql.Repo) *handler {
return &handler{
logger: logger,
writeDB: dao.Use(db.GetDbW()),
readDB: dao.Use(db.GetDbR()),
}
}
// Create 新增数据
// @Summary 新增数据
// @Description 新增数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Param RequestBody body model.{{.StructName}} true "请求参数"
// @Success 200 {object} model.{{.StructName}}
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}} [post]
func (h *handler) Create() core.HandlerFunc {
return func(ctx core.Context) {
var createData model.{{.StructName}}
if err := ctx.ShouldBindJSON(&createData); err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
err.Error()),
)
return
}
if err := h.writeDB.{{.StructName}}.WithContext(ctx.RequestContext()).Create(&createData); err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
return
}
ctx.Payload(createData)
}
}
// List 获取列表数据
// @Summary 获取列表数据
// @Description 获取列表数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Success 200 {object} []model.{{.StructName}}
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}}s [get]
func (h *handler) List() core.HandlerFunc {
return func(ctx core.Context) {
list, err := h.readDB.{{.StructName}}.WithContext(ctx.RequestContext()).Find()
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
return
}
ctx.Payload(list)
}
}
// GetByID 根据 ID 获取数据
// @Summary 根据 ID 获取数据
// @Description 根据 ID 获取数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Param id path string true "ID"
// @Success 200 {object} model.{{.StructName}}
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}}/{id} [get]
func (h *handler) GetByID() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
err.Error()),
)
return
}
info, err := h.readDB.{{.StructName}}.WithContext(ctx.RequestContext()).Where(h.readDB.{{.StructName}}.ID.Eq(int32(id))).First()
if err != nil {
if err == gorm.ErrRecordNotFound {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
"record not found"),
)
} else {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
}
return
}
ctx.Payload(info)
}
}
// DeleteByID 根据 ID 删除数据
// @Summary 根据 ID 删除数据
// @Description 根据 ID 删除数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Param id path string true "ID"
// @Success 200 {object} genResultInfo
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}}/{id} [delete]
func (h *handler) DeleteByID() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
err.Error()),
)
return
}
info, err := h.readDB.{{.StructName}}.WithContext(ctx.RequestContext()).Where(h.readDB.{{.StructName}}.ID.Eq(int32(id))).First()
if err != nil {
if err == gorm.ErrRecordNotFound {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
"record not found"),
)
} else {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
}
return
}
result, err := h.writeDB.{{.StructName}}.Delete(info)
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
}
resultInfo := new(genResultInfo)
resultInfo.RowsAffected = result.RowsAffected
resultInfo.Error = result.Error
ctx.Payload(resultInfo)
}
}
// UpdateByID 根据 ID 更新数据
// @Summary 根据 ID 更新数据
// @Description 根据 ID 更新数据
// @Tags API.{{.VariableName}}
// @Accept json
// @Produce json
// @Param id path string true "ID"
// @Param RequestBody body model.{{.StructName}} true "请求参数"
// @Success 200 {object} genResultInfo
// @Failure 400 {object} code.Failure
// @Router /api/{{.VariableName}}/{id} [put]
func (h *handler) UpdateByID() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.Atoi(ctx.Param("id"))
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
err.Error()),
)
return
}
var updateData map[string]interface{}
if err := ctx.ShouldBindJSON(&updateData); err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
return
}
info, err := h.readDB.{{.StructName}}.WithContext(ctx.RequestContext()).Where(h.readDB.{{.StructName}}.ID.Eq(int32(id))).First()
if err != nil {
if err == gorm.ErrRecordNotFound {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
"record not found"),
)
} else {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
}
return
}
result, err := h.writeDB.{{.StructName}}.WithContext(ctx.RequestContext()).Where(h.writeDB.{{.StructName}}.ID.Eq(info.ID)).Updates(updateData)
if err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ServerError,
err.Error()),
)
return
}
resultInfo := new(genResultInfo)
resultInfo.RowsAffected = result.RowsAffected
resultInfo.Error = result.Error
ctx.Payload(resultInfo)
}
}

View File

@ -1,90 +0,0 @@
package main
import (
"flag"
"fmt"
"log"
"os"
"path/filepath"
"strings"
"text/template"
)
type TemplateData struct {
PackageName string
VariableName string
StructName string
}
func main() {
table := flag.String("table", "", "enter the required data table")
flag.Parse()
tableName := *table
if tableName == "" {
log.Fatal("table cannot be empty, please provide a valid table name.")
}
// 获取当前工作目录
wd, err := os.Getwd()
if err != nil {
log.Fatalf("Error getting working directory:%s", err.Error())
}
// 模板文件路径
tmplPath := fmt.Sprintf("%s/cmd/handlergen/handler_template.go.tpl", wd)
tmpl, err := template.ParseFiles(tmplPath)
if err != nil {
log.Fatal(err)
}
log.Printf("Template file parsed: %s", tmplPath)
// 替换的变量
data := TemplateData{
PackageName: tableName,
VariableName: tableName,
StructName: toCamelCase(tableName),
}
// 生成文件的目录和文件名
outputDir := fmt.Sprintf("%s/internal/api/%s", wd, tableName)
outputFile := filepath.Join(outputDir, fmt.Sprintf("%s.gen.go", tableName))
// 创建目录
err = os.MkdirAll(outputDir, os.ModePerm)
if err != nil {
log.Fatal(err)
}
// 创建文件
file, err := os.Create(outputFile)
if err != nil {
log.Fatal(err)
}
defer file.Close()
log.Printf("File created: %s", outputFile)
// 执行模板并生成文件
err = tmpl.Execute(file, data)
if err != nil {
log.Fatal(err)
}
log.Println("Template execution completed successfully.")
}
// 将字符串转为驼峰式命名
func toCamelCase(s string) string {
// 用下划线分割字符串
parts := strings.Split(s, "_")
// 对每个部分首字母大写
for i := 0; i < len(parts); i++ {
parts[i] = strings.Title(parts[i])
}
// 拼接所有部分
return strings.Join(parts, "")
}

View File

@ -1,93 +0,0 @@
package main
import (
"context"
"flag"
"fmt"
"sort"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/pkg/redis"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
activitysvc "bindbox-game/internal/service/activity"
)
// usage: go run cmd/matching_sim/main.go -env dev -runs 10000
func main() {
runs := flag.Int("runs", 10000, "运行模拟的次数")
flag.Parse()
// 1. 初始化数据库
dbRepo, err := mysql.New()
if err != nil {
panic(fmt.Sprintf("数据库连接失败: %v", err))
}
// 2. 初始化日志 (模拟 Service 需要)
l, err := logger.NewCustomLogger(dao.Use(dbRepo.GetDbW()))
if err != nil {
panic(err)
}
// 3. 初始化 Service (完全模拟真实注入)
// 注意:这里不需要真实的 user service传入 nil 即可
svc := activitysvc.New(l, dbRepo, nil, redis.GetClient())
ctx := context.Background()
// 4. 从真实数据库加载卡牌配置
fmt.Println(">>> 正在从数据库加载真实卡牌配置...")
configs, err := svc.ListMatchingCardTypes(ctx)
if err != nil {
panic(fmt.Sprintf("读取卡牌配置失败: %v", err))
}
if len(configs) == 0 {
fmt.Println("警告: 数据库中没有启用的卡牌配置,将使用默认配置。")
configs = []activitysvc.CardTypeConfig{
{Code: "A", Quantity: 9}, {Code: "B", Quantity: 9}, {Code: "C", Quantity: 9},
{Code: "D", Quantity: 9}, {Code: "E", Quantity: 9}, {Code: "F", Quantity: 9},
{Code: "G", Quantity: 9}, {Code: "H", Quantity: 9}, {Code: "I", Quantity: 9},
{Code: "J", Quantity: 9}, {Code: "K", Quantity: 9},
}
}
fmt.Println("当前生效配置:")
for _, c := range configs {
fmt.Printf(" - [%s]: %d张\n", c.Code, c.Quantity)
}
// 5. 开始执行模拟
fmt.Printf("\n>>> 正在执行 %d 次大规模真实模拟...\n", *runs)
results := make(map[int64]int)
mseed := []byte("production_simulation_seed")
position := "B" // 默认模拟选中 B 类型
for i := 0; i < *runs; i++ {
// 调用真实业务函数创建游戏 (固定数量逻辑)
game := activitysvc.NewMatchingGameWithConfig(configs, position, mseed)
// 调用真实业务模拟函数
pairs := game.SimulateMaxPairs()
results[pairs]++
}
// 6. 统计并输出
fmt.Println("\n对数分布统计 (100% 模拟真实生产路径):")
var pairsList []int64
for k := range results {
pairsList = append(pairsList, k)
}
sort.Slice(pairsList, func(i, j int) bool {
return pairsList[i] < pairsList[j]
})
sumPairs := int64(0)
for _, p := range pairsList {
count := results[p]
sumPairs += p * int64(count)
fmt.Printf(" %2d 对: %5d 次 (%5.2f%%)\n", p, count, float64(count)/float64(*runs)*100)
}
fmt.Printf("\n平均对数: %.4f\n\n", float64(sumPairs)/float64(*runs))
}

View File

@ -1,155 +0,0 @@
package main
import (
"context"
"encoding/base64"
"flag"
"fmt"
"os"
"bindbox-game/configs"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/service/sysconfig"
)
var (
dryRun = flag.Bool("dry-run", false, "仅打印将要写入的配置,不实际写入数据库")
force = flag.Bool("force", false, "强制覆盖已存在的配置")
)
func main() {
flag.Parse()
// 初始化数据库
dbRepo, err := mysql.New()
if err != nil {
fmt.Printf("数据库连接失败: %v\n", err)
os.Exit(1)
}
// 初始化 logger (简化版)
customLogger, err := logger.NewCustomLogger(dao.Use(dbRepo.GetDbW()),
logger.WithDebugLevel(),
logger.WithOutputInConsole(),
)
if err != nil {
fmt.Printf("Logger 初始化失败: %v\n", err)
os.Exit(1)
}
ctx := context.Background()
// 创建动态配置服务
dynamicCfg := sysconfig.NewDynamicConfig(customLogger, dbRepo)
staticCfg := configs.Get()
// 定义要迁移的配置项
type configItem struct {
Key string
Value string
Remark string
}
// 读取证书文件内容并 Base64 编码
readAndEncode := func(path string) string {
if path == "" {
return ""
}
data, err := os.ReadFile(path)
if err != nil {
fmt.Printf("警告: 读取文件 %s 失败: %v\n", path, err)
return ""
}
return base64.StdEncoding.EncodeToString(data)
}
items := []configItem{
// COS 配置
{sysconfig.KeyCOSBucket, staticCfg.COS.Bucket, "COS Bucket名称"},
{sysconfig.KeyCOSRegion, staticCfg.COS.Region, "COS 地域"},
{sysconfig.KeyCOSSecretID, staticCfg.COS.SecretID, "COS SecretID (加密存储)"},
{sysconfig.KeyCOSSecretKey, staticCfg.COS.SecretKey, "COS SecretKey (加密存储)"},
{sysconfig.KeyCOSBaseURL, staticCfg.COS.BaseURL, "COS 自定义域名"},
// 微信小程序配置
{sysconfig.KeyWechatAppID, staticCfg.Wechat.AppID, "微信小程序 AppID"},
{sysconfig.KeyWechatAppSecret, staticCfg.Wechat.AppSecret, "微信小程序 AppSecret (加密存储)"},
{sysconfig.KeyWechatLotteryResultTemplateID, staticCfg.Wechat.LotteryResultTemplateID, "中奖结果订阅消息模板ID"},
// 微信支付配置
{sysconfig.KeyWechatPayMchID, staticCfg.WechatPay.MchID, "微信支付商户号"},
{sysconfig.KeyWechatPaySerialNo, staticCfg.WechatPay.SerialNo, "微信支付证书序列号"},
{sysconfig.KeyWechatPayPrivateKey, readAndEncode(staticCfg.WechatPay.PrivateKeyPath), "微信支付私钥 (Base64编码, 加密存储)"},
{sysconfig.KeyWechatPayApiV3Key, staticCfg.WechatPay.ApiV3Key, "微信支付 API v3 密钥 (加密存储)"},
{sysconfig.KeyWechatPayNotifyURL, staticCfg.WechatPay.NotifyURL, "微信支付回调地址"},
{sysconfig.KeyWechatPayPublicKeyID, staticCfg.WechatPay.PublicKeyID, "微信支付公钥ID"},
{sysconfig.KeyWechatPayPublicKey, readAndEncode(staticCfg.WechatPay.PublicKeyPath), "微信支付公钥 (Base64编码, 加密存储)"},
// 阿里云短信配置
{sysconfig.KeyAliyunSMSAccessKeyID, staticCfg.AliyunSMS.AccessKeyID, "阿里云短信 AccessKeyID"},
{sysconfig.KeyAliyunSMSAccessKeySecret, staticCfg.AliyunSMS.AccessKeySecret, "阿里云短信 AccessKeySecret (加密存储)"},
{sysconfig.KeyAliyunSMSSignName, staticCfg.AliyunSMS.SignName, "短信签名"},
{sysconfig.KeyAliyunSMSTemplateCode, staticCfg.AliyunSMS.TemplateCode, "短信模板Code"},
}
fmt.Println("========== 配置迁移工具 ==========")
fmt.Printf("环境: %s\n", configs.ProjectName)
fmt.Printf("Dry Run: %v\n", *dryRun)
fmt.Printf("Force: %v\n", *force)
fmt.Println()
successCount := 0
skipCount := 0
failCount := 0
for _, item := range items {
if item.Value == "" {
fmt.Printf("[跳过] %s: 值为空\n", item.Key)
skipCount++
continue
}
// 检查是否已存在
existing := dynamicCfg.Get(ctx, item.Key)
if existing != "" && !*force {
fmt.Printf("[跳过] %s: 已存在 (使用 -force 覆盖)\n", item.Key)
skipCount++
continue
}
// 脱敏显示
displayValue := item.Value
if sysconfig.IsSensitiveKey(item.Key) {
if len(displayValue) > 8 {
displayValue = displayValue[:4] + "****" + displayValue[len(displayValue)-4:]
} else {
displayValue = "****"
}
} else if len(displayValue) > 50 {
displayValue = displayValue[:50] + "..."
}
if *dryRun {
fmt.Printf("[预览] %s = %s\n", item.Key, displayValue)
successCount++
} else {
if err := dynamicCfg.Set(ctx, item.Key, item.Value, item.Remark); err != nil {
fmt.Printf("[失败] %s: %v\n", item.Key, err)
failCount++
} else {
fmt.Printf("[成功] %s = %s\n", item.Key, displayValue)
successCount++
}
}
}
fmt.Println()
fmt.Printf("========== 迁移结果 ==========\n")
fmt.Printf("成功: %d, 跳过: %d, 失败: %d\n", successCount, skipCount, failCount)
if *dryRun {
fmt.Println("\n这只是预览使用不带 -dry-run 参数执行实际迁移")
}
}

View File

@ -1,263 +0,0 @@
package main
import (
"context"
"fmt"
"time"
"bindbox-game/configs"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
tcmodel "bindbox-game/internal/repository/mysql/task_center"
tasksvc "bindbox-game/internal/service/task_center"
"bindbox-game/internal/service/title"
"bindbox-game/internal/service/user"
"github.com/redis/go-redis/v9"
)
// IntegrationTest 运行集成测试流
func IntegrationTest(repo mysql.Repo) error {
ctx := context.Background()
cfg := configs.Get()
// 1. 初始化日志(自定义)
l, err := logger.NewCustomLogger(dao.Use(repo.GetDbW()))
if err != nil {
return fmt.Errorf("初始化日志失败: %v", err)
}
// 2. 初始化 Redis
rdb := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Addr,
Password: cfg.Redis.Pass,
DB: cfg.Redis.DB,
})
if err := rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("连接 Redis 失败: %v", err)
}
// 3. 初始化依赖服务
userSvc := user.New(l, repo)
titleSvc := title.New(l, repo)
taskSvc := tasksvc.New(l, repo, rdb, userSvc, titleSvc)
// 3.5 清理缓存以确保能加载最新配置
if err := rdb.Del(ctx, "task_center:active_tasks").Err(); err != nil {
fmt.Printf("⚠️ 清理缓存失败: %v\n", err)
}
// 4. 选择一个测试用户和任务
// ... (代码逻辑不变)
userID := int64(8888)
// 搜索一个首单任务(满足 lifetime 窗口,奖励为点数)
var task tcmodel.Task
db := repo.GetDbW()
if err := db.Joins("JOIN task_center_task_tiers ON task_center_task_tiers.task_id = task_center_tasks.id").
Joins("JOIN task_center_task_rewards ON task_center_task_rewards.task_id = task_center_tasks.id").
Where("task_center_task_tiers.metric = ? AND task_center_task_tiers.window = ? AND task_center_task_rewards.reward_type = ?", "first_order", "lifetime", "points").
First(&task).Error; err != nil {
return fmt.Errorf("未找到符合条件的集成测试任务: %v", err)
}
fmt.Printf("--- 开始集成测试 ---\n")
fmt.Printf("用户ID: %d, 任务ID: %d (%s)\n", userID, task.ID, task.Name)
// 5. 创建一个模拟订单
orderNo := fmt.Sprintf("TEST_ORDER_%d", time.Now().Unix())
order := &model.Orders{
UserID: userID,
OrderNo: orderNo,
TotalAmount: 100,
ActualAmount: 100,
Status: 2, // 已支付
PaidAt: time.Now(),
}
if err := db.Omit("cancelled_at").Create(order).Error; err != nil {
return fmt.Errorf("创建测试订单失败: %v", err)
}
fmt.Printf("创建测试订单: %s (ID: %d)\n", orderNo, order.ID)
// 6. 触发 OnOrderPaid
fmt.Println("触发 OnOrderPaid 事件...")
if err := taskSvc.OnOrderPaid(ctx, userID, order.ID); err != nil {
return fmt.Errorf("OnOrderPaid 失败: %v", err)
}
// 7. 验证结果
// A. 检查进度是否更新
var progress tcmodel.UserTaskProgress
if err := db.Where("user_id = ? AND task_id = ?", userID, task.ID).First(&progress).Error; err != nil {
fmt.Printf("❌ 进度记录未找到: %v\n", err)
} else {
fmt.Printf("✅ 进度记录已更新: first_order=%d\n", progress.FirstOrder)
}
// B. 检查奖励日志
time.Sleep(1 * time.Second)
var eventLog tcmodel.TaskEventLog
if err := db.Where("user_id = ? AND task_id = ?", userID, task.ID).Order("id desc").First(&eventLog).Error; err != nil {
fmt.Printf("❌ 奖励日志未找到: %v\n", err)
} else {
fmt.Printf("✅ 奖励日志已找到: Status=%s, Result=%s\n", eventLog.Status, eventLog.Result)
if eventLog.Status == "granted" {
fmt.Printf("🎉 集成测试通过!奖励已成功发放。\n")
} else {
fmt.Printf("⚠️ 奖励发放状态异常: %s\n", eventLog.Status)
}
}
return nil
}
// InviteAndTaskIntegrationTest 运行邀请与任务全链路集成测试
func InviteAndTaskIntegrationTest(repo mysql.Repo) error {
ctx := context.Background()
cfg := configs.Get()
db := repo.GetDbW()
// 1. 初始化
l, _ := logger.NewCustomLogger(dao.Use(db))
rdb := redis.NewClient(&redis.Options{Addr: cfg.Redis.Addr, Password: cfg.Redis.Pass, DB: cfg.Redis.DB})
userSvc := user.New(l, repo)
titleSvc := title.New(l, repo)
taskSvc := tasksvc.New(l, repo, rdb, userSvc, titleSvc)
// 2. 准备角色
inviterID := int64(9001)
inviteeID := int64(9002)
_ = ensureUserExists(repo, inviterID, "老司机(邀请者)")
_ = ensureUserExists(repo, inviteeID, "萌新(被邀请者)")
// 3. 建立邀请关系
if err := ensureInviteRelationship(repo, inviterID, inviteeID); err != nil {
return fmt.Errorf("建立邀请关系失败: %v", err)
}
// 4. 清理 Redis 缓存
_ = rdb.Del(ctx, "task_center:active_tasks").Err()
// 5. 查找测试任务
var inviteTask tcmodel.Task
if err := db.Joins("JOIN task_center_task_tiers ON task_center_task_tiers.task_id = task_center_tasks.id").
Where("task_center_task_tiers.metric = ? AND task_center_task_tiers.window = ?", "invite_count", "lifetime").
First(&inviteTask).Error; err != nil {
return fmt.Errorf("未找到邀请任务: %v", err)
}
var firstOrderTask tcmodel.Task
if err := db.Joins("JOIN task_center_task_tiers ON task_center_task_tiers.task_id = task_center_tasks.id").
Where("task_center_task_tiers.metric = ? AND task_center_task_tiers.window = ?", "first_order", "lifetime").
First(&firstOrderTask).Error; err != nil {
return fmt.Errorf("未找到首单任务: %v", err)
}
fmt.Printf("--- 开始邀请全链路测试 ---\n")
fmt.Printf("邀请人: %d, 被邀请人: %d\n", inviterID, inviteeID)
// 6. 模拟邀请成功事件 (触发两次以确保达到默认阈值 2)
fmt.Println("触发 OnInviteSuccess 事件 (第1次)...")
if err := taskSvc.OnInviteSuccess(ctx, inviterID, inviteeID); err != nil {
return fmt.Errorf("OnInviteSuccess 失败: %v", err)
}
fmt.Println("触发 OnInviteSuccess 事件 (第2次, 换个用户ID)...")
if err := taskSvc.OnInviteSuccess(ctx, inviterID, 9999); err != nil {
return fmt.Errorf("OnInviteSuccess 失败: %v", err)
}
// 7. 模拟被邀请者下单
orderNo := fmt.Sprintf("INVITE_ORDER_%d", time.Now().Unix())
order := &model.Orders{
UserID: inviteeID,
OrderNo: orderNo,
TotalAmount: 100,
ActualAmount: 100,
Status: 2, // 已支付
PaidAt: time.Now(),
}
if err := db.Omit("cancelled_at").Create(order).Error; err != nil {
return fmt.Errorf("创建被邀请者订单失败: %v", err)
}
fmt.Printf("被邀请者下单成功: %s (ID: %d)\n", orderNo, order.ID)
fmt.Println("触发 OnOrderPaid 事件 (被邀请者)...")
if err := taskSvc.OnOrderPaid(ctx, inviteeID, order.ID); err != nil {
return fmt.Errorf("OnOrderPaid 失败: %v", err)
}
// 8. 验证
time.Sleep(1 * time.Second)
fmt.Println("\n--- 数据库进度核查 ---")
var allProgress []tcmodel.UserTaskProgress
db.Where("user_id IN (?)", []int64{inviterID, inviteeID}).Find(&allProgress)
if len(allProgress) == 0 {
fmt.Println("⚠️ 数据库中未找到任何进度记录!")
}
for _, p := range allProgress {
userLabel := "邀请人"
if p.UserID == inviteeID {
userLabel = "被邀请人"
}
fmt.Printf("[%s] 用户:%d 任务:%d | Invite=%d, OrderCount=%d, FirstOrder=%d\n",
userLabel, p.UserID, p.TaskID, p.InviteCount, p.OrderCount, p.FirstOrder)
}
fmt.Println("\n--- 奖励发放核查 ---")
var logs []tcmodel.TaskEventLog
db.Where("user_id IN (?) AND status = ?", []int64{inviterID, inviteeID}, "granted").Find(&logs)
fmt.Printf("✅ 累计发放奖励次数: %d\n", len(logs))
for _, l := range logs {
fmt.Printf(" - 用户 %d 触发任务 %d 奖励 | Source:%s\n", l.UserID, l.TaskID, l.SourceType)
}
if len(logs) >= 2 {
fmt.Println("\n🎉 邀请全链路集成测试通过!邀请人和被邀请人都获得了奖励。")
} else {
fmt.Printf("\n⚠ 测试部分完成,奖励次数(%d)少于预期(2)\n", len(logs))
}
return nil
}
// 模拟创建用户的方法(如果不存在)
func ensureUserExists(repo mysql.Repo, userID int64, nickname string) error {
db := repo.GetDbW()
var user model.Users
if err := db.Where("id = ?", userID).First(&user).Error; err != nil {
user = model.Users{
ID: userID,
Nickname: nickname,
Avatar: "http://example.com/a.png",
Status: 1,
InviteCode: fmt.Sprintf("CODE%d", userID),
}
if err := db.Create(&user).Error; err != nil {
return err
}
fmt.Printf("已确保测试用户存在: %d (%s)\n", userID, nickname)
}
return nil
}
// 建立邀请关系
func ensureInviteRelationship(repo mysql.Repo, inviterID, inviteeID int64) error {
db := repo.GetDbW()
var rel model.UserInvites
if err := db.Where("invitee_id = ?", inviteeID).First(&rel).Error; err != nil {
rel = model.UserInvites{
InviterID: inviterID,
InviteeID: inviteeID,
InviteCode: fmt.Sprintf("CODE%d", inviterID),
}
return db.Omit("rewarded_at").Create(&rel).Error
}
// 如果已存在但邀请人不对,修正它
if rel.InviterID != inviterID {
return db.Model(&rel).Update("inviter_id", inviterID).Error
}
return nil
}

View File

@ -1,477 +0,0 @@
// 任务中心配置组合测试工具
// 功能:
// 1. 生成所有有效的任务配置组合到 MySQL 数据库
// 2. 模拟用户任务进度
// 3. 验证任务功能是否正常
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"time"
"bindbox-game/configs"
"bindbox-game/internal/repository/mysql"
tcmodel "bindbox-game/internal/repository/mysql/task_center"
"gorm.io/datatypes"
)
// ================================
// 常量定义
// ================================
const (
// 任务指标
MetricFirstOrder = "first_order"
MetricOrderCount = "order_count"
MetricOrderAmount = "order_amount"
MetricInviteCount = "invite_count"
// 操作符
OperatorGTE = ">="
OperatorEQ = "="
// 时间窗口
WindowDaily = "daily"
WindowWeekly = "weekly"
WindowMonthly = "monthly"
WindowLifetime = "lifetime"
// 奖励类型
RewardTypePoints = "points"
RewardTypeCoupon = "coupon"
RewardTypeItemCard = "item_card"
RewardTypeTitle = "title"
RewardTypeGameTicket = "game_ticket"
)
// TaskCombination 表示一种任务配置组合
type TaskCombination struct {
Name string
Metric string
Operator string
Threshold int64
Window string
RewardType string
}
// TestResult 测试结果
type TestResult struct {
Name string
Passed bool
Message string
}
// ================================
// 配置组合生成器
// ================================
// GenerateAllCombinations 生成所有有效的任务配置组合
func GenerateAllCombinations() []TaskCombination {
metrics := []struct {
name string
operators []string
threshold int64
}{
{MetricFirstOrder, []string{OperatorEQ}, 1},
{MetricOrderCount, []string{OperatorGTE, OperatorEQ}, 3},
{MetricOrderAmount, []string{OperatorGTE, OperatorEQ}, 10000},
{MetricInviteCount, []string{OperatorGTE, OperatorEQ}, 2},
}
windows := []string{WindowDaily, WindowWeekly, WindowMonthly, WindowLifetime}
rewards := []string{RewardTypePoints, RewardTypeCoupon, RewardTypeItemCard, RewardTypeTitle, RewardTypeGameTicket}
var combinations []TaskCombination
idx := 0
for _, m := range metrics {
for _, op := range m.operators {
for _, w := range windows {
for _, r := range rewards {
idx++
combinations = append(combinations, TaskCombination{
Name: fmt.Sprintf("测试任务%03d_%s_%s_%s", idx, m.name, w, r),
Metric: m.name,
Operator: op,
Threshold: m.threshold,
Window: w,
RewardType: r,
})
}
}
}
}
return combinations
}
// generateRewardPayload 根据奖励类型生成对应的 JSON payload
func generateRewardPayload(rewardType string) string {
switch rewardType {
case RewardTypePoints:
return `{"points": 100}`
case RewardTypeCoupon:
return `{"coupon_id": 1, "quantity": 1}`
case RewardTypeItemCard:
return `{"card_id": 1, "quantity": 1}`
case RewardTypeTitle:
return `{"title_id": 1}`
case RewardTypeGameTicket:
return `{"game_code": "minesweeper", "amount": 5}`
default:
return `{}`
}
}
// ================================
// 数据库操作
// ================================
// SeedAllCombinations 将所有配置组合写入数据库
func SeedAllCombinations(repo mysql.Repo, dryRun bool) error {
db := repo.GetDbW()
combos := GenerateAllCombinations()
fmt.Printf("准备生成 %d 个任务配置组合\n", len(combos))
if dryRun {
fmt.Println("【试运行模式】不会实际写入数据库")
for i, c := range combos {
fmt.Printf(" %3d. %s (指标=%s, 操作符=%s, 窗口=%s, 奖励=%s)\n",
i+1, c.Name, c.Metric, c.Operator, c.Window, c.RewardType)
}
return nil
}
// 开始事务
tx := db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
}
}()
// 清理旧的测试数据
if err := tx.Where("name LIKE ?", "测试任务%").Delete(&tcmodel.Task{}).Error; err != nil {
tx.Rollback()
return fmt.Errorf("清理旧任务失败: %v", err)
}
fmt.Println("已清理旧的测试任务数据")
created := 0
for _, combo := range combos {
// 检查是否已存在
var exists tcmodel.Task
if err := tx.Where("name = ?", combo.Name).First(&exists).Error; err == nil {
fmt.Printf(" 跳过: %s (已存在)\n", combo.Name)
continue
}
// 插入任务
task := &tcmodel.Task{
Name: combo.Name,
Description: fmt.Sprintf("测试 %s + %s + %s + %s", combo.Metric, combo.Operator, combo.Window, combo.RewardType),
Status: 1,
Visibility: 1,
}
if err := tx.Create(task).Error; err != nil {
tx.Rollback()
return fmt.Errorf("插入任务失败: %v", err)
}
// 插入档位
tier := &tcmodel.TaskTier{
TaskID: task.ID,
Metric: combo.Metric,
Operator: combo.Operator,
Threshold: combo.Threshold,
Window: combo.Window,
Priority: 0,
}
if err := tx.Create(tier).Error; err != nil {
tx.Rollback()
return fmt.Errorf("插入档位失败: %v", err)
}
// 插入奖励
payload := generateRewardPayload(combo.RewardType)
reward := &tcmodel.TaskReward{
TaskID: task.ID,
TierID: tier.ID,
RewardType: combo.RewardType,
RewardPayload: datatypes.JSON(payload),
Quantity: 10,
}
if err := tx.Create(reward).Error; err != nil {
tx.Rollback()
return fmt.Errorf("插入奖励失败: %v", err)
}
created++
if created%10 == 0 {
fmt.Printf(" 已创建 %d 个任务...\n", created)
}
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("提交事务失败: %v", err)
}
fmt.Printf("✅ 成功创建 %d 个任务配置组合\n", created)
return nil
}
// ================================
// 模拟用户任务
// ================================
// SimulateUserTask 模拟用户完成任务
func SimulateUserTask(repo mysql.Repo, userID int64, taskID int64) error {
db := repo.GetDbW()
// 查询任务和档位
var task tcmodel.Task
if err := db.Where("id = ?", taskID).First(&task).Error; err != nil {
return fmt.Errorf("任务不存在: %v", err)
}
var tier tcmodel.TaskTier
if err := db.Where("task_id = ?", taskID).First(&tier).Error; err != nil {
return fmt.Errorf("档位不存在: %v", err)
}
fmt.Printf("模拟任务: %s (指标=%s, 阈值=%d)\n", task.Name, tier.Metric, tier.Threshold)
// 创建或更新用户进度
progress := &tcmodel.UserTaskProgress{
UserID: userID,
TaskID: taskID,
ClaimedTiers: datatypes.JSON("[]"),
}
// 根据指标类型设置进度
switch tier.Metric {
case MetricFirstOrder:
progress.FirstOrder = 1
progress.OrderCount = 1
progress.OrderAmount = 10000
case MetricOrderCount:
progress.OrderCount = tier.Threshold
case MetricOrderAmount:
progress.OrderAmount = tier.Threshold
progress.OrderCount = 1
case MetricInviteCount:
progress.InviteCount = tier.Threshold
}
// Upsert
if err := db.Where("user_id = ? AND task_id = ?", userID, taskID).
Assign(progress).
FirstOrCreate(progress).Error; err != nil {
return fmt.Errorf("创建进度失败: %v", err)
}
fmt.Printf("✅ 用户 %d 的任务进度已更新: order_count=%d, order_amount=%d, invite_count=%d, first_order=%d\n",
userID, progress.OrderCount, progress.OrderAmount, progress.InviteCount, progress.FirstOrder)
return nil
}
// ================================
// 验证功能
// ================================
// VerifyAllConfigs 验证所有配置是否正确
func VerifyAllConfigs(repo mysql.Repo) []TestResult {
db := repo.GetDbR()
var results []TestResult
// 1. 检查任务数量
var taskCount int64
var sampleTasks []tcmodel.Task
db.Model(&tcmodel.Task{}).Where("name LIKE ?", "测试任务%").Count(&taskCount)
db.Model(&tcmodel.Task{}).Where("name LIKE ?", "测试任务%").Limit(5).Find(&sampleTasks)
var sampleMsg string
for _, t := range sampleTasks {
sampleMsg += fmt.Sprintf("[%d:%s] ", t.ID, t.Name)
}
results = append(results, TestResult{
Name: "任务数量检查",
Passed: taskCount > 0,
Message: fmt.Sprintf("找到 %d 个测试任务. 样本: %s", taskCount, sampleMsg),
})
// 2. 检查每种指标的覆盖
metrics := []string{MetricFirstOrder, MetricOrderCount, MetricOrderAmount, MetricInviteCount}
for _, m := range metrics {
var count int64
db.Model(&tcmodel.TaskTier{}).Where("metric = ?", m).Count(&count)
results = append(results, TestResult{
Name: fmt.Sprintf("指标覆盖: %s", m),
Passed: count > 0,
Message: fmt.Sprintf("找到 %d 个档位使用此指标", count),
})
}
// 3. 检查每种时间窗口的覆盖
windows := []string{WindowDaily, WindowWeekly, WindowMonthly, WindowLifetime}
for _, w := range windows {
var count int64
db.Model(&tcmodel.TaskTier{}).Where("window = ?", w).Count(&count)
results = append(results, TestResult{
Name: fmt.Sprintf("时间窗口覆盖: %s", w),
Passed: count > 0,
Message: fmt.Sprintf("找到 %d 个档位使用此时间窗口", count),
})
}
// 4. 检查每种奖励类型的覆盖
rewards := []string{RewardTypePoints, RewardTypeCoupon, RewardTypeItemCard, RewardTypeTitle, RewardTypeGameTicket}
for _, r := range rewards {
var count int64
db.Model(&tcmodel.TaskReward{}).Where("reward_type = ?", r).Count(&count)
results = append(results, TestResult{
Name: fmt.Sprintf("奖励类型覆盖: %s", r),
Passed: count > 0,
Message: fmt.Sprintf("找到 %d 个奖励使用此类型", count),
})
}
// 5. 检查奖励 payload 格式
var rewardList []tcmodel.TaskReward
db.Limit(20).Find(&rewardList)
for _, r := range rewardList {
var data map[string]interface{}
err := json.Unmarshal([]byte(r.RewardPayload), &data)
passed := err == nil
msg := "JSON 格式正确"
if err != nil {
msg = fmt.Sprintf("JSON 解析失败: %v", err)
}
results = append(results, TestResult{
Name: fmt.Sprintf("奖励Payload格式: ID=%d, Type=%s", r.ID, r.RewardType),
Passed: passed,
Message: msg,
})
}
return results
}
// PrintResults 打印测试结果
func PrintResults(results []TestResult) {
passed := 0
failed := 0
fmt.Println("\n========== 测试结果 ==========")
for _, r := range results {
status := "✅ PASS"
if !r.Passed {
status = "❌ FAIL"
failed++
} else {
passed++
}
fmt.Printf("%s | %s | %s\n", status, r.Name, r.Message)
}
fmt.Println("==============================")
fmt.Printf("总计: %d 通过, %d 失败\n", passed, failed)
}
// ================================
// 主程序
// ================================
func main() {
// 命令行参数
action := flag.String("action", "help", "操作类型: seed/simulate/verify/integration/invite-test/help")
dryRun := flag.Bool("dry-run", false, "试运行模式,不实际写入数据库")
userID := flag.Int64("user", 8888, "用户ID (用于 simulate 或 integration)")
taskID := flag.Int64("task", 0, "任务ID")
flag.Parse()
// 显示帮助
if *action == "help" {
fmt.Println(`
任务中心配置组合测试工具
用法:
go run main.go -action=<操作>
操作类型:
seed - 生成所有配置组合到数据库
simulate - 简单模拟用户进度 (仅修改进度表)
integration - 真实集成测试 (触发 OnOrderPaid, 验证全流程)
invite-test - 邀请全链路测试 (模拟邀请下单双端奖励发放)
verify - 验证配置是否正确
参数:
-dry-run - 试运行模式不实际写入数据库
-user - 用户ID (默认: 8888)
-task - 任务ID
示例:
# 邀请全链路测试
go run main.go -action=invite-test
`)
return
}
// 初始化数据库连接
repo, err := mysql.New()
if err != nil {
log.Fatalf("连接数据库失败: %v", err)
}
cfg := configs.Get()
fmt.Printf("已连接到数据库: %s\n", cfg.MySQL.Write.Name)
fmt.Printf("时间: %s\n", time.Now().Format("2006-01-02 15:04:05"))
// 执行操作
switch *action {
case "seed":
if err := SeedAllCombinations(repo, *dryRun); err != nil {
log.Printf("生成配置失败: %v", err)
os.Exit(1)
}
case "simulate":
if *taskID == 0 {
fmt.Println("请指定任务ID: -task=<ID>")
os.Exit(1)
}
if err := SimulateUserTask(repo, *userID, *taskID); err != nil {
log.Printf("模拟失败: %v", err)
os.Exit(1)
}
case "integration":
// 确保用户存在
if err := ensureUserExists(repo, *userID, "测试用户"); err != nil {
log.Printf("预检用户失败: %v", err)
os.Exit(1)
}
if err := IntegrationTest(repo); err != nil {
log.Printf("集成测试失败: %v", err)
os.Exit(1)
}
case "invite-test":
if err := InviteAndTaskIntegrationTest(repo); err != nil {
log.Printf("邀请测试失败: %v", err)
os.Exit(1)
}
case "verify":
results := VerifyAllConfigs(repo)
PrintResults(results)
default:
fmt.Printf("未知操作: %s\n", *action)
os.Exit(1)
}
}

View File

@ -89,6 +89,11 @@ type Config struct {
AppSecret string `mapstructure:"app_secret" toml:"app_secret"`
NotifyURL string `mapstructure:"notify_url" toml:"notify_url"`
} `mapstructure:"douyin" toml:"douyin"`
Otel struct {
Enabled bool `mapstructure:"enabled" toml:"enabled"`
Endpoint string `mapstructure:"endpoint" toml:"endpoint"`
} `mapstructure:"otel" toml:"otel"`
}
var (
@ -105,7 +110,7 @@ var (
proConfigs []byte
)
func init() {
func Init() {
var r io.Reader
switch env.Active().Value() {
@ -166,6 +171,38 @@ func init() {
if v := os.Getenv("ALIYUN_SMS_TEMPLATE_CODE"); v != "" {
config.AliyunSMS.TemplateCode = v
}
// MySQL 配置环境变量覆盖
if v := os.Getenv("MYSQL_ADDR"); v != "" {
config.MySQL.Read.Addr = v
config.MySQL.Write.Addr = v
}
if v := os.Getenv("MYSQL_READ_ADDR"); v != "" {
config.MySQL.Read.Addr = v
}
if v := os.Getenv("MYSQL_WRITE_ADDR"); v != "" {
config.MySQL.Write.Addr = v
}
if v := os.Getenv("MYSQL_USER"); v != "" {
config.MySQL.Read.User = v
config.MySQL.Write.User = v
}
if v := os.Getenv("MYSQL_PASS"); v != "" {
config.MySQL.Read.Pass = v
config.MySQL.Write.Pass = v
}
if v := os.Getenv("MYSQL_NAME"); v != "" {
config.MySQL.Read.Name = v
config.MySQL.Write.Name = v
}
// Redis 配置环境变量覆盖
if v := os.Getenv("REDIS_ADDR"); v != "" {
config.Redis.Addr = v
}
if v := os.Getenv("REDIS_PASS"); v != "" {
config.Redis.Pass = v
}
}
func Get() Config {

View File

@ -2,31 +2,28 @@
local = 'zh-cn'
[mysql.read]
addr = 'sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555'
name = 'bindbox_game'
pass = 'api2api..'
addr = '150.158.78.154:3306'
name = 'dev_game'
pass = 'bindbox2025kdy'
user = 'root'
[mysql.write]
addr = '150.158.78.154:3306'
name = 'dev_game'
pass = 'bindbox2025kdy'
user = 'root'
[redis]
addr = "118.25.13.43:8379"
pass = "xbm#2023by1024"
addr = "127.0.0.1:6379"
pass = ""
db = 5
[mysql.write]
addr = 'sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555'
name = 'bindbox_game'
pass = 'api2api..'
user = 'root'
[jwt]
admin_secret = "m9ycX9RTPyuYTWw9FrCc"
patient_secret = "AppUserJwtSecret2025"
[wechat]
app_id = "wx26ad074017e1e63f"
app_secret = "026c19ce4f3bb090c56573024c59a8be"
lottery_result_template_id = "O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI"
[cos]
bucket = "keaiya-1259195914"
@ -40,13 +37,13 @@ base_url = ""
commit_master_key = "4d7a3b8f9c2e1a5d6b4f8c0e3a7d2b1c6f9e4a5d8c1b3f7a2e5d6c4b8f0e3a7d2b1c"
[wechatpay]
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"
mchid = ""
serial_no = ""
private_key_path = ""
api_v3_key = ""
notify_url = ""
public_key_id = ""
public_key_path = ""
[aliyun_sms]
access_key_id = ""
@ -54,5 +51,8 @@ access_key_secret = ""
sign_name = ""
template_code = ""
[internal]
api_key = "bindbox-internal-secret-2024"

View File

@ -2,54 +2,75 @@
local = 'zh-cn'
[mysql.read]
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
<<<<<<< Updated upstream
addr = "mysql:3306"
user = "root"
pass = "bindbox2025kdy"
name = "bindbox_game"
[mysql.write]
addr = 'sh-cynosdbmysql-grp-88th45wy.sql.tencentcdb.com:28555'
name = 'bindbox_game'
pass = 'api2api..'
user = 'root'
addr = "mysql:3306"
user = "root"
pass = "bindbox2025kdy"
name = "bindbox_game"
[redis]
addr = "redis:6379"
pass = ""
db = 0
[jwt]
admin_secret = "m9ycX9RTPyuYTWw9FrCc"
patient_secret = "AppUserJwtSecret2025"
[wechat]
app_id = "wx26ad074017e1e63f"
app_secret = "026c19ce4f3bb090c56573024c59a8be"
lottery_result_template_id = "O2eqJQD3pn-vQ6g2z9DWzINVwOmPoz8yW-172J_YcpI"
app_id = ""
app_secret = ""
lottery_result_template_id = ""
[cos]
bucket = "keaiya-1259195914"
region = "ap-shanghai"
secret_id = "AKIDtjPtAFPNDuR1UnxvoUCoRAnJgw164Zv6"
secret_key = "B0vvjMoMsKcipnJlLnFyWt6A2JRSJ0Wr"
# 可选:如有 CDN/自定义域名则填写,否则留空
base_url = ""
[random]
commit_master_key = "4d7a3b8f9c2e1a5d6b4f8c0e3a7d2b1c6f9e4a5d8c1b3f7a2e5d6c4b8f0e3a7d2b1c"
[wechatpay]
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"
mchid = ""
serial_no = ""
private_key_path = ""
api_v3_key = ""
notify_url = ""
public_key_id = ""
public_key_path = ""
[aliyun_sms]
access_key_id = "LTAI5tJ55hp81F5HDa2oSYb3"
access_key_secret = "cUd3Ym73i7OKsDDBJre5IAkpwwTiLs"
sign_name = "沙琪玛上海信息技术"
template_code = "SMS_499200896"
access_key_id = ""
access_key_secret = ""
sign_name = ""
template_code = ""
[internal]
api_key = "bindbox-internal-secret-2024"
[otel]
enabled = true
endpoint = "tempo:4318"
=======
addr = '127.0.0.1:3306'
name = 'mini_chat'
pass = 'zRXiHTHzRHTdShFZ'
user = 'mini_chat'
[mysql.write]
addr = '127.0.0.1:3306'
name = 'mini_chat'
pass = 'zRXiHTHzRHTdShFZ'
user = 'mini_chat'
[jwt]
admin_secret = "X9Rm9ycFr66w9syuYTWCc"
>>>>>>> Stashed changes

View File

@ -1,42 +1,60 @@
[mysql]
[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"
[language]
local = 'zh-cn'
[mysql.read]
addr = "mysql:3306"
user = "root"
pass = "bindbox2025kdy"
name = "bindbox_game"
[mysql.write]
addr = "mysql:3306"
user = "root"
pass = "bindbox2025kdy"
name = "bindbox_game"
[redis]
addr = "127.0.0.1:6379"
pass = ""
db = 0
addr = "redis:6379"
pass = ""
db = 0
[jwt]
admin_secret = "m9ycX9RTPyuYTWw9FrCc"
patient_secret = "AppUserJwtSecret2025"
[random]
commit_master_key = "a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef123456"
[wechat]
app_id = ""
app_secret = ""
lottery_result_template_id = ""
app_id = ""
app_secret = ""
lottery_result_template_id = ""
[cos]
bucket = "keaiya-1259195914"
region = "ap-shanghai"
secret_id = "AKIDtjPtAFPNDuR1UnxvoUCoRAnJgw164Zv6"
secret_key = "B0vvjMoMsKcipnJlLnFyWt6A2JRSJ0Wr"
base_url = ""
[random]
commit_master_key = "4d7a3b8f9c2e1a5d6b4f8c0e3a7d2b1c6f9e4a5d8c1b3f7a2e5d6c4b8f0e3a7d2b1c"
[wechatpay]
mchid = ""
serial_no = ""
private_key_path = ""
api_v3_key = ""
notify_url = "https://example.com/api/pay/wechat/notify"
mchid = ""
serial_no = ""
private_key_path = ""
api_v3_key = ""
notify_url = ""
public_key_id = ""
public_key_path = ""
[aliyun_sms]
access_key_id = ""
access_key_secret = ""
sign_name = ""
template_code = ""
template_code = ""
[internal]
api_key = "bindbox-internal-secret-2024"
[otel]
enabled = true
endpoint = "tempo:4318"

45
go.mod
View File

@ -1,6 +1,8 @@
module bindbox-game
go 1.19
go 1.24.0
toolchain go1.24.2
require (
github.com/DATA-DOG/go-sqlmock v1.5.2
@ -18,6 +20,7 @@ require (
github.com/go-playground/universal-translator v0.18.1
github.com/go-playground/validator/v10 v10.15.0
github.com/go-resty/resty/v2 v2.10.0
github.com/go-sql-driver/mysql v1.7.0
github.com/golang-jwt/jwt/v5 v5.2.0
github.com/issue9/identicon/v2 v2.1.2
github.com/pkg/errors v0.9.1
@ -26,17 +29,22 @@ require (
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
github.com/stretchr/testify v1.8.4
github.com/stretchr/testify v1.11.1
github.com/swaggo/files v1.0.1
github.com/swaggo/gin-swagger v1.6.0
github.com/swaggo/swag v1.16.2
github.com/tealeg/xlsx v1.0.5
github.com/tencentyun/cos-go-sdk-v5 v0.7.37
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
go.opentelemetry.io/otel v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0
go.opentelemetry.io/otel/sdk v1.39.0
go.opentelemetry.io/otel/trace v1.39.0
go.uber.org/multierr v1.10.0
go.uber.org/zap v1.26.0
golang.org/x/crypto v0.27.0
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d
golang.org/x/crypto v0.44.0
golang.org/x/tools v0.38.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gorm.io/datatypes v1.1.1-0.20230130040222-c43177d3cf8c
gorm.io/driver/mysql v1.5.2
@ -55,9 +63,11 @@ require (
github.com/alibabacloud-go/endpoint-util v1.1.0 // indirect
github.com/alibabacloud-go/openapi-util v0.1.1 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/alicebob/miniredis/v2 v2.36.1 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/beorn7/perks v1.0.1 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
@ -67,14 +77,18 @@ require (
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
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-openapi/jsonpointer v0.19.5 // indirect
github.com/go-openapi/jsonreference v0.19.6 // indirect
github.com/go-openapi/spec v0.20.4 // indirect
github.com/go-openapi/swag v0.19.15 // indirect
github.com/go-sql-driver/mysql v1.7.0 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.3 // indirect
github.com/golang-jwt/jwt/v4 v4.5.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-querystring v1.0.0 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
@ -107,14 +121,21 @@ require (
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
golang.org/x/arch v0.4.0 // indirect
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/sync v0.8.0 // indirect
golang.org/x/sys v0.25.0 // indirect
golang.org/x/text v0.18.0 // indirect
google.golang.org/protobuf v1.31.0 // indirect
golang.org/x/mod v0.29.0 // indirect
golang.org/x/net v0.47.0 // indirect
golang.org/x/sync v0.18.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.31.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 // indirect
google.golang.org/grpc v1.77.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect

111
go.sum
View File

@ -96,6 +96,8 @@ github.com/alibabacloud-go/tea-utils/v2 v2.0.6/go.mod h1:qxn986l+q33J5VkialKMqT/
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8=
github.com/alicebob/miniredis/v2 v2.36.1 h1:Dvc5oAnNOr7BIfPn7tF269U8DvRW1dBG2D5n0WrfYMI=
github.com/alicebob/miniredis/v2 v2.36.1/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
@ -104,7 +106,9 @@ github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQ
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/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
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=
@ -112,6 +116,8 @@ github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
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/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
@ -148,11 +154,13 @@ github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7
github.com/fatih/color v1.14.1 h1:qfhVLaG5s+nCROl1zJsZRxFeYrHLqWroPOQ8BWiNb4w=
github.com/fatih/color v1.14.1/go.mod h1:2oHN61fhTpgcxD3TSWCgKDiH1+x4OiDVVGH8WlgGZGg=
github.com/frankban/quicktest v1.14.4 h1:g2rn0vABPOOXmZUj+vbmUp0lPoXEMuhTpIluN0XL9UY=
github.com/frankban/quicktest v1.14.4/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/gzip v0.0.6 h1:NjcunTcGAj5CO1gn4N8jHOSIeRFHIbn51z6K+xaN4d4=
github.com/gin-contrib/gzip v0.0.6/go.mod h1:QOJlmV2xmayAjkNS2Y8NQsMneuRShOU/kjovCXNuzzk=
github.com/gin-contrib/pprof v1.4.0 h1:XxiBSf5jWZ5i16lNOPbMTVdgHBdhfGRD5PZ1LWazzvg=
github.com/gin-contrib/pprof v1.4.0/go.mod h1:RrehPJasUVBPK6yTUwOl8/NP6i0vbUgmxtis+Z5KE90=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@ -163,6 +171,11 @@ github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SU
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY=
github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg=
@ -175,6 +188,7 @@ github.com/go-openapi/swag v0.19.15 h1:D2NRCBzS9/pEY3gP9Nl8aDqGUcPFrwG2p+CNFrLyr
github.com/go-openapi/swag v0.19.15/go.mod h1:QYRuS/SOXUCsnplDa677K7+DxSOj6IPNl/eQntq43wQ=
github.com/go-playground/assert/v2 v2.0.1/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.0/go.mod h1:sawfccIbzZTqEDETgFXqTho0QybSa7l++s0DH+LDiLs=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
@ -192,10 +206,14 @@ github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9
github.com/goccy/go-json v0.9.7/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang-jwt/jwt/v5 v5.2.0 h1:d/ix8ftRUorsN+5eMIlF4T6J8CAt9rch3My2winC1Jw=
github.com/golang-jwt/jwt/v5 v5.2.0/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9 h1:au07oEsX2xN0ktxqI+Sida1w446QrXBRJ0nee3SNZlA=
github.com/golang-sql/civil v0.0.0-20220223132316-b832511892a9/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0=
github.com/golang-sql/sqlexp v0.1.0 h1:ZCD6MBpcuOVfGVqsEmY5/4FtYiKz6tSyUv9LPEDei6A=
github.com/golang-sql/sqlexp v0.1.0/go.mod h1:J4ad9Vo8ZCWQ2GMrC4UCQy1JpCbwU9m3EOqtpKwwwHI=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
@ -222,8 +240,8 @@ github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QD
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@ -236,8 +254,9 @@ github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASuANWTrk=
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
@ -256,13 +275,16 @@ github.com/google/pprof v0.0.0-20201203190320-1bf35d6f28c2/go.mod h1:kpwsk12EmLe
github.com/google/pprof v0.0.0-20201218002935-b9804c9f04c2/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/googleapis/google-cloud-go-testing v0.0.0-20200911160855-bcd43fbb19e8/go.mod h1:dvDLG8qkwmyD9a/MJJN3XJcT3xFxOKAvTZGvuZmac9g=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
@ -270,16 +292,25 @@ github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/issue9/assert/v4 v4.1.1 h1:OhPE8SB8n/qZCNGLQa+6MQtr/B3oON0JAVj68k8jJlc=
github.com/issue9/assert/v4 v4.1.1/go.mod h1:v7qDRXi7AsaZZNh8eAK2rkLJg5/clztqQGA1DRv9Lv4=
github.com/issue9/identicon/v2 v2.1.2 h1:tu+4vveoiJNXfmWYvl1pDcZSAHCG37+lsoEc2UfCzkI=
github.com/issue9/identicon/v2 v2.1.2/go.mod h1:h5JXMtcgkqxltElhpF7PPicNyvFDWzi8VCSHdNjG7KY=
github.com/jackc/chunkreader/v2 v2.0.1 h1:i+RDz65UE+mmpjTfyz0MoVTnzeYxroil2G82ki7MGG8=
github.com/jackc/chunkreader/v2 v2.0.1/go.mod h1:odVSm741yZoC3dpHEUXIqA9tQRhFrgOHwnPIn9lDKlk=
github.com/jackc/pgconn v1.13.0 h1:3L1XMNV2Zvca/8BYhzcRFS70Lr0WlDg16Di6SFGAbys=
github.com/jackc/pgconn v1.13.0/go.mod h1:AnowpAqO4CMIIJNZl2VJp+KrkAZciAkhEl0W0JIobpI=
github.com/jackc/pgio v1.0.0 h1:g12B9UwVnzGhueNavwioyEEpAmqMe1E/BN9ES+8ovkE=
github.com/jackc/pgio v1.0.0/go.mod h1:oP+2QK2wFfUWgr+gxjoBH9KGBb31Eio69xUb0w5bYf8=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgproto3/v2 v2.3.1 h1:nwj7qwf0S+Q7ISFfBndqeLwSwxs+4DPsbRFjECT1Y4Y=
github.com/jackc/pgproto3/v2 v2.3.1/go.mod h1:WfJCnwN3HIg9Ish/j3sgWXnAfK8A9Y0bwXYU5xKaEdA=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b h1:C8S2+VttkHFdOOCXJe+YGfa4vHYwlt4Zx+IVXQ97jYg=
github.com/jackc/pgservicefile v0.0.0-20200714003250-2b9c44734f2b/go.mod h1:vsD4gTJCa9TptPL8sPkXrLZ+hDuNrZCnj29CQpr4X1E=
github.com/jackc/pgtype v1.12.0 h1:Dlq8Qvcch7kiehm8wPGIW0W3KsCCHJnRacKW0UM8n5w=
github.com/jackc/pgtype v1.12.0/go.mod h1:LUMuVrfsFfdKGLw+AFFVv6KtHOFMwRgDDzBt76IqCA4=
github.com/jackc/pgx/v4 v4.17.2 h1:0Ut0rpeKwvIVbMQ1KbMBU4h6wxehBI535LK6Flheh8E=
github.com/jackc/pgx/v4 v4.17.2/go.mod h1:lcxIZN44yMIrWI78a5CpucdD14hX0SBDbNRvjDBItsw=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.2/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
@ -305,6 +336,7 @@ github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORN
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
@ -330,6 +362,7 @@ github.com/mattn/go-sqlite3 v1.14.15/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S
github.com/matttproud/golang_protobuf_extensions v1.0.4 h1:mmDVorXM7PCGKw94cs5zkfA9PSy5pEvNWRP0ET0TIVo=
github.com/matttproud/golang_protobuf_extensions v1.0.4/go.mod h1:BSXmuO+STAnVfrANrmjBb36TMTDstsz7MSK+HVaYKv4=
github.com/microsoft/go-mssqldb v0.17.0 h1:Fto83dMZPnYv1Zwx5vHHxpNraeEaUlQ/hhHLgZiaenE=
github.com/microsoft/go-mssqldb v0.17.0/go.mod h1:OkoNGhGEs8EZqchVTtochlXruEhEOaO4S0d2sB5aeGQ=
github.com/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
@ -367,7 +400,8 @@ github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0
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=
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/rs/cors v1.8.1 h1:OrP+y5H+5Md29ACTA9imbALaKHwOSUZkcizaG0LT5ow=
github.com/rs/cors v1.8.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU=
github.com/rs/cors/wrapper/gin v0.0.0-20231013084403-73f81b45a644 h1:BBwREPixt0iE77C9z7DOenoeh5OGFrzyL1cWOp5oQTs=
@ -402,8 +436,9 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE=
@ -435,13 +470,34 @@ github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9de
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.4/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.5/go.mod h1:5pWMHQbX5EPX2/62yrJeAkowc+lfs/XD7Uxpq3pI6kk=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0 h1:f0cb2XPmrqn4XMy9PNliTgRKJgS5WcL/u0/WRYGz4t0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.39.0/go.mod h1:vnakAaFckOMiMtOIhFI2MNH4FYrZzXCYxmb1LlhoGz8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0 h1:Ckwye2FpXkYgiHX7fyVrN1uA/UYd9ounqqTuSNAv0k4=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.39.0/go.mod h1:teIFJh5pW2y+AN7riv6IBPX2DuesS3HgP39mwOspKwU=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
@ -467,8 +523,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/crypto v0.44.0 h1:A97SsFvM3AIwEEmTBiaxPPTYpDC47w720rdiiUvgoAU=
golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@ -508,8 +564,9 @@ golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA=
golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@ -555,8 +612,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -581,8 +638,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ=
golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -635,8 +692,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -663,8 +720,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -722,12 +779,15 @@ golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ=
golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
@ -790,6 +850,10 @@ google.golang.org/genproto v0.0.0-20201210142538-e3217bee35cc/go.mod h1:FWY/as6D
google.golang.org/genproto v0.0.0-20201214200347-8c77b98c765d/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210108203827-ffc7fda8c3d7/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20210226172003-ab064af71705/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217 h1:gRkg/vSppuSQoDjxyiGfN4Upv/h/DQmIR10ZU8dh4Ww=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
@ -806,6 +870,8 @@ google.golang.org/grpc v1.31.1/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
google.golang.org/grpc v1.34.0/go.mod h1:WotjhfgOW/POjDeRt8vscBtXq+2VjORFy659qA51WJ8=
google.golang.org/grpc v1.35.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
google.golang.org/grpc v1.77.0 h1:wVVY6/8cGA6vvffn+wWK5ToddbgdU3d8MNENr4evgXM=
google.golang.org/grpc v1.77.0/go.mod h1:z0BY1iVj0q8E1uSQCjL9cppRj+gnZjzDnzV0dHhrNig=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -817,10 +883,9 @@ google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpAD
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@ -848,10 +913,12 @@ gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10
gorm.io/driver/mysql v1.5.2 h1:QC2HRskSE75wBuOxe0+iCkyJZ+RqpudsQtqkp+IMuXs=
gorm.io/driver/mysql v1.5.2/go.mod h1:pQLhh1Ut/WUAySdTHwBpBv6+JKcj+ua4ZFx1QQTBzb8=
gorm.io/driver/postgres v1.4.5 h1:mTeXTTtHAgnS9PgmhN2YeUbazYpLhUI1doLnw42XUZc=
gorm.io/driver/postgres v1.4.5/go.mod h1:GKNQYSJ14qvWkvPwXljMGehpKrhlDNsqYRr5HnYGncg=
gorm.io/driver/sqlite v1.1.6/go.mod h1:W8LmC/6UvVbHKah0+QOC7Ja66EaZXHwUTjgXY8YNWX8=
gorm.io/driver/sqlite v1.4.3 h1:HBBcZSDnWi5BW3B3rwvVTc510KGkBkexlOg0QrmLUuU=
gorm.io/driver/sqlite v1.4.3/go.mod h1:0Aq3iPO+v9ZKbcdiz8gLWRw5VOPcBOPUQJFLq5e2ecI=
gorm.io/driver/sqlserver v1.4.1 h1:t4r4r6Jam5E6ejqP7N82qAJIJAht27EGT41HyPfXRw0=
gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHDnJLig=
gorm.io/gen v0.3.26 h1:sFf1j7vNStimPRRAtH4zz5NiHM+1dr6eA9aaRdplyhY=
gorm.io/gen v0.3.26/go.mod h1:a5lq5y3w4g5LMxBcw0wnO6tYUCdNutWODq5LrIt75LE=
gorm.io/gorm v1.21.15/go.mod h1:F+OptMscr0P2F2qU97WT1WimdH9GaQPoDW7AYd5i2Y0=

View File

@ -39,28 +39,28 @@ type listActivitiesResponse struct {
}
type activityDetailResponse struct {
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `json:"name"`
Banner string `json:"banner"`
ActivityCategoryID int64 `json:"activity_category_id"`
Status int32 `json:"status"`
PriceDraw int64 `json:"price_draw"`
IsBoss int32 `json:"is_boss"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DrawMode string `json:"draw_mode"`
PlayType string `json:"play_type"`
MinParticipants int64 `json:"min_participants"`
IntervalMinutes int64 `json:"interval_minutes"`
ScheduledTime time.Time `json:"scheduled_time"`
LastSettledAt time.Time `json:"last_settled_at"`
RefundCouponID int64 `json:"refund_coupon_id"`
Image string `json:"image"`
GameplayIntro string `json:"gameplay_intro"`
AllowItemCards bool `json:"allow_item_cards"`
AllowCoupons bool `json:"allow_coupons"`
ID int64 `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Name string `json:"name"`
Banner string `json:"banner"`
ActivityCategoryID int64 `json:"activity_category_id"`
Status int32 `json:"status"`
PriceDraw int64 `json:"price_draw"`
IsBoss int32 `json:"is_boss"`
StartTime time.Time `json:"start_time"`
EndTime time.Time `json:"end_time"`
DrawMode string `json:"draw_mode"`
PlayType string `json:"play_type"`
MinParticipants int64 `json:"min_participants"`
IntervalMinutes int64 `json:"interval_minutes"`
ScheduledTime *time.Time `json:"scheduled_time"`
LastSettledAt time.Time `json:"last_settled_at"`
RefundCouponID int64 `json:"refund_coupon_id"`
Image string `json:"image"`
GameplayIntro string `json:"gameplay_intro"`
AllowItemCards bool `json:"allow_item_cards"`
AllowCoupons bool `json:"allow_coupons"`
}
// ListActivities 活动列表
@ -86,6 +86,16 @@ func (h *handler) ListActivities() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
var isBossPtr *int32
if req.IsBoss == 0 || req.IsBoss == 1 {
isBossPtr = &req.IsBoss
@ -180,7 +190,7 @@ func (h *handler) GetActivityDetail() core.HandlerFunc {
PlayType: item.PlayType,
MinParticipants: item.MinParticipants,
IntervalMinutes: item.IntervalMinutes,
ScheduledTime: item.ScheduledTime,
ScheduledTime: &item.ScheduledTime,
LastSettledAt: item.LastSettledAt,
RefundCouponID: item.RefundCouponID,
Image: item.Image,
@ -188,6 +198,13 @@ func (h *handler) GetActivityDetail() core.HandlerFunc {
AllowItemCards: item.AllowItemCards,
AllowCoupons: item.AllowCoupons,
}
// 修复一番赏:即时模式下,清空 ScheduledTime (设置为 nil) 以绕过前端下单拦截
// 如果返回零值时间,前端会解析为很早的时间从而判定已结束,必须明确返回 nil
if rsp.PlayType == "ichiban" && rsp.DrawMode == "instant" {
rsp.ScheduledTime = nil
}
ctx.Payload(rsp)
}
}

View File

@ -77,16 +77,18 @@ func (h *handler) ListDrawLogs() core.HandlerFunc {
pageSize = 100
}
// 计算5分钟前的时间点
fiveMinutesAgo := time.Now().Add(-5 * time.Minute)
now := time.Now()
// 计算5分钟前的时间点 (用于延迟显示)
fiveMinutesAgo := now.Add(-5 * time.Minute)
// 计算当天零点 (用于仅显示当天数据)
startOfToday := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
// 为了保证过滤后依然有足够数据,我们多取一些
fetchPageSize := pageSize
if pageSize < 100 {
fetchPageSize = 100 // 至少取100条来过滤
}
// [修改] 强制获取当天最新的 100 条数据 (Service 层限制最大 100)
// 忽略前端传入的 Page/PageSize总是获取第一页的 100 条
fetchPageSize := 100
fetchPage := 1
items, total, err := h.activity.ListDrawLogs(ctx.RequestContext(), issueID, page, fetchPageSize, req.Level)
items, total, err := h.activity.ListDrawLogs(ctx.RequestContext(), issueID, fetchPage, fetchPageSize, req.Level)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ListDrawLogsError, err.Error()))
return
@ -100,10 +102,21 @@ func (h *handler) ListDrawLogs() core.HandlerFunc {
var filteredItems []*model.ActivityDrawLogs
for _, v := range items {
// 恢复 5 分钟过滤逻辑
// 1. 过滤掉太新的数据 (5分钟延迟)
if v.CreatedAt.After(fiveMinutesAgo) {
continue
}
// 2. 过滤掉非当天的数据 (当天零点之前)
if v.CreatedAt.Before(startOfToday) {
// 因为是按时间倒序返回的,一旦遇到早于今天的,后续的更早,直接结束
break
}
// 3. 数量限制 (虽然 Service 取了 100这里再保个底或者遵循前端 pageSize?
// 需求是 "获取当天的 最新100 个数据",这里我们以 filteredItems 为准,
// 如果前端 pageSize 传了比如 20是否应该只给 20
// 按照通常逻辑,列表接口应遵循 pageSize。但在这种定制逻辑下用户似乎想要的是“当天数据的视图”。
// 保持原逻辑:遵循 pageSize 限制输出数量,但我们上面强行取了 100 作为源数据。
// 如果用户原本想看 100 条,前端传 100 即可。
if len(filteredItems) >= pageSize {
break
}

View File

@ -64,6 +64,10 @@ func (h *handler) JoinLottery() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// DEBUG LOG: Print request params to diagnose frontend issue
reqBytes, _ := json.Marshal(req)
h.logger.Info(fmt.Sprintf("JoinLottery Request Params: UserID=%d Payload=%s", ctx.SessionUserInfo().Id, string(reqBytes)))
userID := int64(ctx.SessionUserInfo().Id)
h.logger.Info(fmt.Sprintf("JoinLottery Start: UserID=%d ActivityID=%d IssueID=%d", userID, req.ActivityID, req.IssueID))
activity, err := h.activity.GetActivity(ctx.RequestContext(), req.ActivityID)
@ -132,8 +136,10 @@ func (h *handler) JoinLottery() core.HandlerFunc {
}
if activity.AllowCoupons && req.CouponID != nil && *req.CouponID > 0 {
order.CouponID = *req.CouponID
applied = h.applyCouponWithCap(ctx, userID, order, req.ActivityID, *req.CouponID)
if applied > 0 {
order.CouponID = *req.CouponID
}
}
// Title Discount Logic
// 1. Fetch active effects for this user, scoped to this activity/issue/category
@ -263,6 +269,13 @@ func (h *handler) JoinLottery() core.HandlerFunc {
return err
}
deducted += canDeduct
// Record usage for remark (Format: gp_use:ID:Count)
if order.Remark == "" {
order.Remark = fmt.Sprintf("gp_use:%d:%d", p.ID, canDeduct)
} else {
order.Remark += fmt.Sprintf("|gp_use:%d:%d", p.ID, canDeduct)
}
}
if deducted < count {
@ -273,15 +286,8 @@ func (h *handler) JoinLottery() core.HandlerFunc {
order.ActualAmount = 0
order.SourceType = 4 // Cleanly mark as Game Pass source
// existing lottery logic sets SourceType based on "h.orderModel" which defaults to something?
// h.orderModel(..., c) implementation needs to be checked or inferred.
// Assuming orderModel sets SourceType based on activity or defaults.
// Let's explicitly mark it or rely on Remark.
if order.Remark == "" {
order.Remark = "use_game_pass"
} else {
order.Remark += "|use_game_pass"
}
// Legacy marker for backward compatibility or simple check
order.Remark += "|use_game_pass"
// Note: If we change SourceType to 4, ProcessOrderLottery might skip it if checks SourceType.
// Lottery app usually expects SourceType=2 or similar.
// Let's KEEP SourceType as is (likely 2 for ichiban), but Amount=0 ensures it's treated as Paid.
@ -289,24 +295,21 @@ func (h *handler) JoinLottery() core.HandlerFunc {
if !useGamePass && req.UsePoints != nil && *req.UsePoints > 0 {
bal, _ := h.user.GetPointsBalance(ctx.RequestContext(), userID)
usePts := *req.UsePoints
if bal > 0 && usePts > bal {
usePts = bal
// req.UsePoints 是前端传入的积分数,需要转换为分
usePtsCents, _ := h.user.PointsToCents(ctx.RequestContext(), *req.UsePoints)
// bal 已经是分单位
if bal > 0 && usePtsCents > bal {
usePtsCents = bal
}
ratePtsPerCent, _ := h.user.CentsToPoints(ctx.RequestContext(), 1)
if ratePtsPerCent <= 0 {
ratePtsPerCent = 1
}
deductCents := usePts / ratePtsPerCent
// deductCents 是要从订单金额中抵扣的分数
deductCents := usePtsCents
if deductCents > order.ActualAmount {
deductCents = order.ActualAmount
}
if deductCents > 0 {
needPts := deductCents * ratePtsPerCent
if needPts > usePts {
needPts = usePts
}
// needPts 是实际需要扣除的分数
needPts := deductCents
// Inline ConsumePointsFor logic using tx
// Lock rows
rows, errFind := tx.UserPoints.WithContext(ctx.RequestContext()).Clauses(clause.Locking{Strength: "UPDATE"}).Where(tx.UserPoints.UserID.Eq(userID)).Order(tx.UserPoints.ValidEnd.Asc()).Find()
@ -394,9 +397,30 @@ func (h *handler) JoinLottery() core.HandlerFunc {
}
}
}
// Inline RecordOrderCouponUsage (no logging)
if applied > 0 && req.CouponID != nil && *req.CouponID > 0 {
_ = tx.Orders.UnderlyingDB().Exec("INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", order.ID, *req.CouponID, applied).Error
// 优惠券预扣:在事务中原子性扣减余额
// 如果余额不足(被其他并发订单消耗),事务回滚
if applied > 0 && order.CouponID > 0 {
// 原子更新优惠券余额和状态
now := time.Now()
res := tx.Orders.UnderlyingDB().Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount - ?,
status = CASE WHEN balance_amount - ? <= 0 THEN 4 ELSE 4 END,
used_order_id = ?,
used_at = ?
WHERE id = ? AND user_id = ? AND balance_amount >= ? AND status IN (1, 4)
`, applied, applied, order.ID, now, order.CouponID, userID, applied)
if res.Error != nil {
return fmt.Errorf("优惠券预扣失败: %w", res.Error)
}
if res.RowsAffected == 0 {
// 余额不足或状态不对,事务回滚
return errors.New("优惠券余额不足或已被使用")
}
// 记录使用关系
_ = tx.Orders.UnderlyingDB().Exec("INSERT INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))", order.ID, order.CouponID, applied).Error
}
return nil
})
@ -412,16 +436,8 @@ func (h *handler) JoinLottery() core.HandlerFunc {
rsp.ActualAmount = order.ActualAmount
rsp.Status = order.Status
// Immediate Draw Trigger if Paid (e.g. Game Pass or Free)
if order.Status == 2 && activity.DrawMode == "instant" {
go func() {
_ = h.activity.ProcessOrderLottery(context.Background(), order.ID)
}()
}
// Immediate Draw Trigger if Paid (e.g. Game Pass or Free)
if order.Status == 2 && activity.DrawMode == "instant" {
// Trigger process asynchronously or synchronously?
// Usually WechatNotify triggers it. Since we bypass WechatNotify, we must trigger it.
// 即时开奖触发(已支付 + 即时开奖模式)
if shouldTriggerInstantDraw(order.Status, activity.DrawMode) {
go func() {
_ = h.activity.ProcessOrderLottery(context.Background(), order.ID)
}()
@ -618,32 +634,34 @@ func (h *handler) validateIchibanSlots(ctx core.Context, req *joinLotteryRequest
if totalSlots <= 0 {
return core.Error(http.StatusBadRequest, 170008, "no slots")
}
if len(req.SlotIndex) > 0 {
if req.Count <= 0 || req.Count != int64(len(req.SlotIndex)) {
return core.Error(http.StatusBadRequest, code.ParamBindError, "参数错误")
}
// 1. 强制校验:必须选择位置
if len(req.SlotIndex) == 0 {
return core.Error(http.StatusBadRequest, code.ParamBindError, "一番赏必须选择位置")
}
if req.Count <= 0 || req.Count != int64(len(req.SlotIndex)) {
return core.Error(http.StatusBadRequest, code.ParamBindError, "参数错误:数量与位置不匹配")
}
// 1. 内存中去重和范围检查
selectedSlots := make([]int64, 0, len(req.SlotIndex))
seen := make(map[int64]struct{}, len(req.SlotIndex))
for _, si := range req.SlotIndex {
if _, ok := seen[si]; ok {
return core.Error(http.StatusBadRequest, 170011, "duplicate slots not allowed")
}
seen[si] = struct{}{}
if si < 1 || si > totalSlots {
return core.Error(http.StatusBadRequest, 170008, "slot out of range")
}
selectedSlots = append(selectedSlots, si-1)
// 2. 内存中去重和范围检查
selectedSlots := make([]int64, 0, len(req.SlotIndex))
seen := make(map[int64]struct{}, len(req.SlotIndex))
for _, si := range req.SlotIndex {
if _, ok := seen[si]; ok {
return core.Error(http.StatusBadRequest, 170011, "duplicate slots not allowed")
}
seen[si] = struct{}{}
if si < 1 || si > totalSlots {
return core.Error(http.StatusBadRequest, 170008, "slot out of range")
}
selectedSlots = append(selectedSlots, si-1)
}
// 2. 批量查询数据库检查格位是否已被占用
var occupiedCount int64
_ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id=? AND slot_index IN ?", req.IssueID, selectedSlots).Scan(&occupiedCount).Error
if occupiedCount > 0 {
// 如果有占用,为了告知具体是哪个位置,可以打个 log 或者简单的直接返回错误
return core.Error(http.StatusBadRequest, 170007, "部分位置已被占用,请刷新重试")
}
// 3. 批量查询数据库检查格位是否已被占用
var occupiedCount int64
_ = h.repo.GetDbR().Raw("SELECT COUNT(*) FROM issue_position_claims WHERE issue_id=? AND slot_index IN ?", req.IssueID, selectedSlots).Scan(&occupiedCount).Error
if occupiedCount > 0 {
// 即使是并发场景,这里做一个 Pre-check 也能拦截大部分冲突
return core.Error(http.StatusBadRequest, 170007, "部分位置已被占用,请刷新重试")
}
return nil
}

View File

@ -1,11 +1,71 @@
package app
import "testing"
import (
"sync/atomic"
"testing"
"time"
)
func TestParseSlotFromRemark(t *testing.T) {
r := parseSlotFromRemark("lottery:activity:1|issue:2|count:1|slot:42")
if r != 42 { t.Fatalf("slot parse failed: %d", r) }
r2 := parseSlotFromRemark("lottery:activity:1|issue:2|count:1")
if r2 != -1 { t.Fatalf("expected -1, got %d", r2) }
r := parseSlotFromRemark("lottery:activity:1|issue:2|count:1|slot:42")
if r != 42 {
t.Fatalf("slot parse failed: %d", r)
}
r2 := parseSlotFromRemark("lottery:activity:1|issue:2|count:1")
if r2 != -1 {
t.Fatalf("expected -1, got %d", r2)
}
}
// TestShouldTriggerInstantDraw 验证即时开奖触发条件
func TestShouldTriggerInstantDraw(t *testing.T) {
testCases := []struct {
name string
orderStatus int32
drawMode string
shouldTrigger bool
}{
{"已支付+即时开奖", 2, "instant", true},
{"已支付+定时开奖", 2, "scheduled", false},
{"未支付+即时开奖", 1, "instant", false},
{"未支付+定时开奖", 1, "scheduled", false},
{"已取消+即时开奖", 3, "instant", false},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
result := shouldTriggerInstantDraw(tc.orderStatus, tc.drawMode)
if result != tc.shouldTrigger {
t.Errorf("期望触发=%v实际触发=%v", tc.shouldTrigger, result)
}
})
}
}
// TestInstantDrawTriggerOnce 验证即时开奖只触发一次
// 这个测试模拟 JoinLottery 中的触发逻辑,确保不会重复触发
func TestInstantDrawTriggerOnce(t *testing.T) {
var callCount int32 = 0
// 模拟 ProcessOrderLottery 的调用
processOrderLottery := func() {
atomic.AddInt32(&callCount, 1)
}
// 模拟订单状态
orderStatus := int32(2)
drawMode := "instant"
// 执行触发逻辑(使用辅助函数,避免重复代码)
if shouldTriggerInstantDraw(orderStatus, drawMode) {
go processOrderLottery()
}
// 等待 goroutine 完成
time.Sleep(100 * time.Millisecond)
// 验证只调用一次
if callCount != 1 {
t.Errorf("ProcessOrderLottery 应该只被调用 1 次,实际调用了 %d 次", callCount)
}
}

View File

@ -51,7 +51,7 @@ func (h *handler) applyCouponWithCap(ctx core.Context, userID int64, order *mode
sc.discount_value
FROM user_coupons uc
INNER JOIN system_coupons sc ON uc.coupon_id = sc.id AND sc.status = 1
WHERE uc.id = ? AND uc.user_id = ? AND uc.status = 1
WHERE uc.id = ? AND uc.user_id = ? AND uc.status IN (1, 4)
LIMIT 1
`, userCouponID, userID).Scan(&result).Error
@ -82,9 +82,6 @@ func (h *handler) applyCouponWithCap(ctx core.Context, userID int64, order *mode
switch result.DiscountType {
case 1: // 金额券
bal := result.BalanceAmount
if bal <= 0 {
bal = result.DiscountValue
}
if bal > 0 {
if bal > remainingCap {
applied = remainingCap
@ -125,6 +122,46 @@ func (h *handler) applyCouponWithCap(ctx core.Context, userID int64, order *mode
return applied
}
// preDeductCouponInTx 在事务中预扣优惠券余额
// 功能:原子性地扣减余额并设置 status=4预扣中防止并发超额使用
// 参数:
// - ctx请求上下文
// - tx数据库事务必须在事务中调用
// - userID用户ID
// - userCouponID用户持券ID
// - appliedAmount要预扣的金额
// - orderID关联的订单ID
//
// 返回:是否成功预扣
func (h *handler) preDeductCouponInTx(ctx core.Context, txDB interface {
Exec(sql string, values ...interface{}) interface {
RowsAffected() int64
Error() error
}
}, userID int64, userCouponID int64, appliedAmount int64, orderID int64) bool {
if appliedAmount <= 0 || userCouponID <= 0 {
return false
}
now := time.Now()
// 原子更新:扣减余额 + 设置状态为预扣中(4) + 关联订单
// 条件:余额足够 且 状态为未使用(1)或使用中(4支持同一券多订单分批扣减场景但需余额足够)
result := txDB.Exec(`
UPDATE user_coupons
SET balance_amount = balance_amount - ?,
status = CASE WHEN balance_amount - ? <= 0 THEN 4 ELSE 4 END,
used_order_id = ?,
used_at = ?
WHERE id = ? AND user_id = ? AND balance_amount >= ? AND status IN (1, 4)
`, appliedAmount, appliedAmount, orderID, now, userCouponID, userID, appliedAmount)
if result.Error() != nil {
return false
}
return result.RowsAffected() > 0
}
// updateUserCouponAfterApply 应用后更新用户券(扣减余额或核销)
// 功能:根据订单 remark 中记录的 applied_amount
//
@ -154,7 +191,7 @@ func (h *handler) updateUserCouponAfterApply(ctx core.Context, userID int64, ord
sc.discount_value
FROM user_coupons uc
INNER JOIN system_coupons sc ON uc.coupon_id = sc.id AND sc.status = 1
WHERE uc.id = ? AND uc.user_id = ? AND uc.status = 1
WHERE uc.id = ? AND uc.user_id = ? AND uc.status IN (1, 4)
LIMIT 1
`, userCouponID, userID).Scan(&result).Error
@ -274,3 +311,14 @@ func parseIssueIDFromRemark(remarkStr string) int64 {
func parseCountFromRemark(remarkStr string) int64 {
return remark.Parse(remarkStr).Count
}
// shouldTriggerInstantDraw 判断是否应该触发即时开奖
// 功能:封装即时开奖触发条件判断,避免条件重复
// 参数:
// - orderStatus订单状态2=已支付)
// - drawMode开奖模式"instant"=即时开奖)
//
// 返回:是否应该触发即时开奖
func shouldTriggerInstantDraw(orderStatus int32, drawMode string) bool {
return orderStatus == 2 && drawMode == "instant"
}

View File

@ -30,6 +30,7 @@ type matchingGamePreOrderRequest struct {
CouponID *int64 `json:"coupon_id"`
ItemCardID *int64 `json:"item_card_id"`
UseGamePass bool `json:"use_game_pass"` // 新增:是否使用次数卡
Count int64 `json:"count"` // 新增:购买数量
}
type matchingGamePreOrderResponse struct {
@ -82,6 +83,12 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
return
}
// 校验 Count对对碰只能单次购买
if req.Count > 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170013, "对对碰游戏暂不支持批量购买,请单次支付"))
return
}
// 1. Get Activity/Issue Info (Mocking price for now or fetching if available)
// Assuming price is fixed or fetched. Let's fetch basic activity info if possible, or assume config.
// Since Request has IssueID, let's fetch Issue to get ActivityID and Price.
@ -166,10 +173,16 @@ func (h *handler) PreOrderMatchingGame() core.HandlerFunc {
ActualAmount: 0, // 次数卡抵扣实付0元
DiscountAmount: activity.PriceDraw,
Status: 2, // 已支付
Remark: fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID),
CreatedAt: now,
UpdatedAt: now,
PaidAt: now,
Remark: func() string {
r := fmt.Sprintf("activity:%d|game_pass:%d|matching_game:issue:%d", activity.ID, validPass.ID, req.IssueID)
if activity.AllowItemCards && req.ItemCardID != nil && *req.ItemCardID > 0 {
r += fmt.Sprintf("|itemcard:%d", *req.ItemCardID)
}
return r
}(),
CreatedAt: now,
UpdatedAt: now,
PaidAt: now,
}
if err := h.writeDB.Orders.WithContext(ctx.RequestContext()).
@ -532,14 +545,18 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
zap.Bool("is_ok", scopeOK))
if scopeOK {
cardToVoid = icID
// Fix: Don't set cardToVoid immediately. Only set it if an effect is actually applied.
// Double reward
if ic.EffectType == 1 && ic.RewardMultiplierX1000 >= 2000 {
// Double reward
cardToVoid = icID // Mark for consumption
h.logger.Info("道具卡-CheckMatchingGame: 应用双倍奖励", zap.Int32("multiplier", ic.RewardMultiplierX1000))
finalQuantity = 2
finalRemark += "(倍数)"
} else if ic.EffectType == 2 && ic.BoostRateX1000 > 0 {
// Probability boost - try to upgrade to better reward
// Probability boost
cardToVoid = icID // Mark for consumption (even if RNG fails, the card is "used")
h.logger.Debug("道具卡-CheckMatchingGame: 应用概率提升", zap.Int32("boost_rate", ic.BoostRateX1000))
allRewards, _ := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).Where(
h.readDB.ActivityRewardSettings.IssueID.Eq(game.IssueID),
@ -580,6 +597,11 @@ func (h *handler) CheckMatchingGame() core.HandlerFunc {
} else {
h.logger.Debug("道具卡-CheckMatchingGame: 未找到更好的奖品可升级", zap.Int64("current_score", candidate.MinScore))
}
} else {
// Effect not recognized or params too low
h.logger.Warn("道具卡-CheckMatchingGame: 效果类型未知或参数无效,不消耗卡片",
zap.Int32("effect_type", ic.EffectType),
zap.Int32("multiplier", ic.RewardMultiplierX1000))
}
} else {
h.logger.Debug("道具卡-CheckMatchingGame: 范围校验失败")

View File

@ -7,31 +7,31 @@ import (
// TestSelectRewardExact 测试对对碰选奖逻辑:精确匹配 TotalPairs == MinScore
func TestSelectRewardExact(t *testing.T) {
// 模拟奖品设置
// 模拟奖品设置 (使用 Level 作为标识,因为 ActivityRewardSettings 没有 Name 字段)
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},
{ID: 1, Level: 1, MinScore: 10, Quantity: 5},
{ID: 2, Level: 2, MinScore: 20, Quantity: 5},
{ID: 3, Level: 3, MinScore: 30, Quantity: 5},
{ID: 4, Level: 4, MinScore: 40, Quantity: 5},
{ID: 5, Level: 5, MinScore: 45, Quantity: 5},
}
testCases := []struct {
name string
totalPairs int64
expectReward *int64 // nil = 无匹配
expectName string
expectLevel int32
}{
{"精确匹配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, ""},
{"精确匹配10对", 10, ptr(int64(1)), 1},
{"精确匹配20对", 20, ptr(int64(2)), 2},
{"精确匹配30对", 30, ptr(int64(3)), 3},
{"精确匹配40对", 40, ptr(int64(4)), 4},
{"精确匹配45对", 45, ptr(int64(5)), 5},
{"15对-无匹配", 15, nil, 0},
{"25对-无匹配", 25, nil, 0},
{"35对-无匹配", 35, nil, 0},
{"50对-无匹配", 50, nil, 0},
{"0对-无匹配", 0, nil, 0},
}
for _, tc := range testCases {
@ -40,15 +40,15 @@ func TestSelectRewardExact(t *testing.T) {
if tc.expectReward == nil {
if candidate != nil {
t.Errorf("期望无匹配,但得到奖品: %s (ID=%d)", candidate.Name, candidate.ID)
t.Errorf("期望无匹配,但得到奖品: Level=%d (ID=%d)", candidate.Level, 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)
} else if candidate.Level != tc.expectLevel {
t.Errorf("期望奖品Level=%d实际=%d", tc.expectLevel, candidate.Level)
}
}
})
@ -58,14 +58,14 @@ func TestSelectRewardExact(t *testing.T) {
// 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},
{ID: 1, Level: 1, MinScore: 10, Quantity: 0}, // 库存为0
{ID: 2, Level: 2, MinScore: 20, Quantity: 5},
}
// 即使精确匹配库存为0也不应匹配
candidate := selectRewardExact(rewards, 10)
if candidate != nil {
t.Errorf("库存为0时不应匹配但得到: %s", candidate.Name)
t.Errorf("库存为0时不应匹配但得到: Level=%d", candidate.Level)
}
// 库存>0应正常匹配

View File

@ -65,10 +65,73 @@ func (h *handler) startMatchingGameCleanup() {
})
}
// autoCheckDatabaseFallback 数据库扫描兜底防止Redis缓存过期导致漏单
func (h *handler) autoCheckDatabaseFallback() {
ctx := context.Background()
// 1. 查询 30分钟前~24小时内 已支付 但 未开奖 的对对碰订单 (SourceType=3)
// 这个时间窗口是为了避开正常游戏中的订单 (Redis TTL 30m)
startTime := time.Now().Add(-24 * time.Hour)
endTime := time.Now().Add(-30 * time.Minute)
// 使用 left join 排除已有日志的订单
var orderNos []string
err := h.readDB.Orders.WithContext(ctx).UnderlyingDB().Raw(`
SELECT o.order_no
FROM orders o
LEFT JOIN activity_draw_logs l ON o.id = l.order_id
WHERE o.source_type = 3
AND o.status = 2
AND o.created_at BETWEEN ? AND ?
AND l.id IS NULL
`, startTime, endTime).Scan(&orderNos).Error
if err != nil {
h.logger.Error("对对碰兜底扫描: 查询失败", zap.Error(err))
return
}
if len(orderNos) == 0 {
return
}
h.logger.Info("对对碰兜底扫描: 发现异常订单", zap.Int("count", len(orderNos)))
for _, orderNo := range orderNos {
// 2. 加载订单详情
order, err := h.readDB.Orders.WithContext(ctx).Where(h.readDB.Orders.OrderNo.Eq(orderNo)).First()
if err != nil || order == nil {
continue
}
// 3. 重构游戏状态
// 我们需要从 Seed, Position 等信息重构 Memory Graph
game, err := h.activity.ReconstructMatchingGame(ctx, orderNo)
if err != nil {
h.logger.Error("对对碰兜底扫描: 游戏状态重构失败", zap.String("order_no", orderNo), zap.Error(err))
continue
}
// 4. 重构 GameID (模拟)
// 注意:原始 GameID 可能丢失,这里我们并不真的需要精确的 Request GameID
// 因为 doAutoCheck 主要依赖 game 对象和 OrderID。
// 但为了锁的唯一性,我们使用 MG_FALLBACK_{OrderID}
fakeGameID := fmt.Sprintf("FALLBACK_%d", order.ID)
h.logger.Info("对对碰兜底扫描: 触发补单", zap.String("order_no", orderNo))
h.doAutoCheck(ctx, fakeGameID, game, order)
}
}
// autoCheckExpiredGames 扫描超时未结算的对对碰游戏并自动开奖
func (h *handler) autoCheckExpiredGames() {
ctx := context.Background()
// 0. 执行数据库兜底扫描 (低频执行,例如每次 autoCheck 都跑,或者加计数器)
// 由于 autoCheckHelper 是每3分钟跑一次这里直接调用损耗可控
// 且查询走了索引 (created_at)
h.autoCheckDatabaseFallback()
// 1. 扫描 Redis 中所有 matching_game key
keys, err := h.redis.Keys(ctx, activitysvc.MatchingGameKeyPrefix+"*").Result()
if err != nil {

View File

@ -464,6 +464,7 @@ type activityItem struct {
Status int32 `json:"status"`
PriceDraw int64 `json:"price_draw"`
IsBoss int32 `json:"is_boss"`
PlayType string `json:"play_type"`
}
type listActivitiesResponse struct {
@ -544,6 +545,7 @@ func (h *handler) ListActivities() core.HandlerFunc {
Status: v.Status,
PriceDraw: v.PriceDraw,
IsBoss: v.IsBoss,
PlayType: v.PlayType,
}
}
ctx.Payload(res)

View File

@ -1,47 +1,112 @@
package admin
import (
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/repository/mysql/dao"
activitysvc "bindbox-game/internal/service/activity"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/repository/mysql/dao"
activitysvc "bindbox-game/internal/service/activity"
"net/http"
"strconv"
)
type activityCommitGenerateResp struct{ SeedVersion int32 `json:"seed_version"` }
type activityCommitSummaryResp struct{ SeedVersion int32 `json:"seed_version"`; Algo string `json:"algo"`; HasSeed bool `json:"has_seed"`; LenSeedMaster int `json:"len_seed_master"`; LenSeedHash int `json:"len_seed_hash"`; LenItemsRoot int `json:"len_items_root"`; ItemsRootHex string `json:"items_root_hex"` }
type activityCommitGenerateResp struct {
SeedVersion int32 `json:"seed_version"`
}
type activityCommitSummaryResp struct {
SeedVersion int32 `json:"seed_version"`
Algo string `json:"algo"`
HasSeed bool `json:"has_seed"`
LenSeedMaster int `json:"len_seed_master"`
LenSeedHash int `json:"len_seed_hash"`
LenItemsRoot int `json:"len_items_root"`
ItemsRootHex string `json:"items_root_hex"`
}
type activityCredentialResp struct {
SeedMasterHex string `json:"seed_master_hex"`
SeedHashHex string `json:"seed_hash_hex"`
ItemsRootHex string `json:"items_root_hex"`
}
func (h *handler) GenerateActivityCommitmentGeneral() core.HandlerFunc {
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("activity_id"), 10, 64); if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递活动ID")); return }
svc := activitysvc.NewActivityCommitmentService(dao.Use(h.repo.GetDbR()), dao.Use(h.repo.GetDbW()), h.repo)
ver, e := svc.Generate(ctx.RequestContext(), activityID)
if e != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 170301, e.Error())); return }
ctx.Payload(&activityCommitGenerateResp{SeedVersion: ver})
}
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("activity_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递活动ID"))
return
}
svc := activitysvc.NewActivityCommitmentService(dao.Use(h.repo.GetDbR()), dao.Use(h.repo.GetDbW()), h.repo)
ver, e := svc.Generate(ctx.RequestContext(), activityID)
if e != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170301, e.Error()))
return
}
ctx.Payload(&activityCommitGenerateResp{SeedVersion: ver})
}
}
func (h *handler) GetActivityCommitmentSummaryGeneral() core.HandlerFunc {
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("activity_id"), 10, 64); if err != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递活动ID")); return }
svc := activitysvc.NewActivityCommitmentService(dao.Use(h.repo.GetDbR()), dao.Use(h.repo.GetDbW()), h.repo)
sum, e := svc.Summary(ctx.RequestContext(), activityID)
if e != nil { ctx.AbortWithError(core.Error(http.StatusBadRequest, 170302, e.Error())); return }
var lenMaster, lenHash, lenRoot *int
_ = h.repo.GetDbR().Raw("SELECT LENGTH(commitment_seed_master) FROM activities WHERE id=?", activityID).Scan(&lenMaster)
_ = h.repo.GetDbR().Raw("SELECT LENGTH(commitment_seed_hash) FROM activities WHERE id=?", activityID).Scan(&lenHash)
_ = h.repo.GetDbR().Raw("SELECT LENGTH(commitment_items_root) FROM activities WHERE id=?", activityID).Scan(&lenRoot)
var itemsHex *string
_ = h.repo.GetDbR().Raw("SELECT HEX(commitment_items_root) FROM activities WHERE id=?", activityID).Scan(&itemsHex)
lm, lh, lr := 0, 0, 0
if lenMaster != nil { lm = *lenMaster }
if lenHash != nil { lh = *lenHash }
if lenRoot != nil { lr = *lenRoot }
ih := ""
if itemsHex != nil { ih = *itemsHex }
ctx.Payload(&activityCommitSummaryResp{SeedVersion: sum.SeedVersion, Algo: sum.Algo, HasSeed: sum.HasSeed, LenSeedMaster: lm, LenSeedHash: lh, LenItemsRoot: lr, ItemsRootHex: ih})
}
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("activity_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递活动ID"))
return
}
svc := activitysvc.NewActivityCommitmentService(dao.Use(h.repo.GetDbR()), dao.Use(h.repo.GetDbW()), h.repo)
sum, e := svc.Summary(ctx.RequestContext(), activityID)
if e != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 170302, e.Error()))
return
}
var lenMaster, lenHash, lenRoot *int
_ = h.repo.GetDbR().Raw("SELECT LENGTH(commitment_seed_master) FROM activities WHERE id=?", activityID).Scan(&lenMaster)
_ = h.repo.GetDbR().Raw("SELECT LENGTH(commitment_seed_hash) FROM activities WHERE id=?", activityID).Scan(&lenHash)
_ = h.repo.GetDbR().Raw("SELECT LENGTH(commitment_items_root) FROM activities WHERE id=?", activityID).Scan(&lenRoot)
var itemsHex *string
_ = h.repo.GetDbR().Raw("SELECT HEX(commitment_items_root) FROM activities WHERE id=?", activityID).Scan(&itemsHex)
lm, lh, lr := 0, 0, 0
if lenMaster != nil {
lm = *lenMaster
}
if lenHash != nil {
lh = *lenHash
}
if lenRoot != nil {
lr = *lenRoot
}
ih := ""
if itemsHex != nil {
ih = *itemsHex
}
ctx.Payload(&activityCommitSummaryResp{SeedVersion: sum.SeedVersion, Algo: sum.Algo, HasSeed: sum.HasSeed, LenSeedMaster: lm, LenSeedHash: lh, LenItemsRoot: lr, ItemsRootHex: ih})
}
}
func (h *handler) GetActivityCredential() core.HandlerFunc {
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("activity_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递活动ID"))
return
}
var seedMasterHex, seedHashHex, itemsRootHex *string
_ = h.repo.GetDbR().Raw("SELECT HEX(commitment_seed_master) FROM activities WHERE id=?", activityID).Scan(&seedMasterHex)
_ = h.repo.GetDbR().Raw("SELECT HEX(commitment_seed_hash) FROM activities WHERE id=?", activityID).Scan(&seedHashHex)
_ = h.repo.GetDbR().Raw("SELECT HEX(commitment_items_root) FROM activities WHERE id=?", activityID).Scan(&itemsRootHex)
resp := &activityCredentialResp{}
if seedMasterHex != nil {
resp.SeedMasterHex = *seedMasterHex
}
if seedHashHex != nil {
resp.SeedHashHex = *seedHashHex
}
if itemsRootHex != nil {
resp.ItemsRootHex = *itemsRootHex
}
ctx.Payload(resp)
}
}

View File

@ -9,6 +9,8 @@ import (
bannersvc "bindbox-game/internal/service/banner"
channelsvc "bindbox-game/internal/service/channel"
douyinsvc "bindbox-game/internal/service/douyin"
gamesvc "bindbox-game/internal/service/game"
livestreamsvc "bindbox-game/internal/service/livestream"
productsvc "bindbox-game/internal/service/product"
snapshotsvc "bindbox-game/internal/service/snapshot"
syscfgsvc "bindbox-game/internal/service/sysconfig"
@ -34,6 +36,7 @@ type handler struct {
snapshotSvc snapshotsvc.Service
rollbackSvc snapshotsvc.RollbackService
douyinSvc douyinsvc.Service
livestream livestreamsvc.Service
}
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler {
@ -41,6 +44,8 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
snapshotSvc := snapshotsvc.NewService(db)
rollbackSvc := snapshotsvc.NewRollbackService(db, snapshotSvc)
syscfgSvc := syscfgsvc.New(logger, db)
ticketSvc := gamesvc.NewTicketService(logger, db) // 游戏资格服务
titleSvc := titlesvc.New(logger, db) // 称号服务
return &handler{
logger: logger,
writeDB: dao.Use(db.GetDbW()),
@ -52,10 +57,11 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
userSvc: userSvc,
banner: bannersvc.New(logger, db),
channel: channelsvc.New(logger, db),
title: titlesvc.New(logger, db),
title: titleSvc,
syscfg: syscfgSvc,
snapshotSvc: snapshotSvc,
rollbackSvc: rollbackSvc,
douyinSvc: douyinsvc.New(logger, db, syscfgSvc),
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc),
livestream: livestreamsvc.New(logger, db, ticketSvc), // 传入ticketSvc
}
}

View File

@ -0,0 +1,319 @@
package admin
import (
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
)
// ========== 黑名单管理 ==========
type addBlacklistRequest struct {
DouyinUserID string `json:"douyin_user_id" binding:"required"`
Reason string `json:"reason"`
}
type blacklistResponse struct {
ID int64 `json:"id"`
DouyinUserID string `json:"douyin_user_id"`
Reason string `json:"reason"`
OperatorID int64 `json:"operator_id"`
Status int32 `json:"status"`
CreatedAt string `json:"created_at"`
}
type listBlacklistRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Keyword string `form:"keyword"`
}
type listBlacklistResponse struct {
List []blacklistResponse `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// ListBlacklist 获取黑名单列表
// @Summary 获取黑名单列表
// @Description 获取抖音用户黑名单列表,支持分页和关键词搜索
// @Tags 管理端.黑名单
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Param keyword query string false "搜索关键词(抖音ID)"
// @Success 200 {object} listBlacklistResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/blacklist [get]
// @Security LoginVerifyToken
func (h *handler) ListBlacklist() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listBlacklistRequest)
if err := ctx.ShouldBindQuery(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
db := h.repo.GetDbR().WithContext(ctx.RequestContext()).
Table("douyin_blacklist").
Where("status = 1")
if req.Keyword != "" {
db = db.Where("douyin_user_id LIKE ?", "%"+req.Keyword+"%")
}
var total int64
if err := db.Count(&total).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
var list []model.DouyinBlacklist
if err := db.Order("id DESC").
Offset((req.Page - 1) * req.PageSize).
Limit(req.PageSize).
Find(&list).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
rsp := &listBlacklistResponse{
List: make([]blacklistResponse, len(list)),
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}
for i, item := range list {
rsp.List[i] = blacklistResponse{
ID: item.ID,
DouyinUserID: item.DouyinUserID,
Reason: item.Reason,
OperatorID: item.OperatorID,
Status: item.Status,
CreatedAt: item.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
ctx.Payload(rsp)
}
}
// AddBlacklist 添加黑名单
// @Summary 添加黑名单
// @Description 将抖音用户添加到黑名单
// @Tags 管理端.黑名单
// @Accept json
// @Produce json
// @Param body body addBlacklistRequest true "请求参数"
// @Success 200 {object} blacklistResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/blacklist [post]
// @Security LoginVerifyToken
func (h *handler) AddBlacklist() core.HandlerFunc {
return func(ctx core.Context) {
req := new(addBlacklistRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// 检查是否已在黑名单
var existCount int64
h.repo.GetDbR().WithContext(ctx.RequestContext()).
Table("douyin_blacklist").
Where("douyin_user_id = ? AND status = 1", req.DouyinUserID).
Count(&existCount)
if existCount > 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "该用户已在黑名单中"))
return
}
operatorID := int64(0)
if ctx.SessionUserInfo().Id > 0 {
operatorID = int64(ctx.SessionUserInfo().Id)
}
blacklist := &model.DouyinBlacklist{
DouyinUserID: req.DouyinUserID,
Reason: req.Reason,
OperatorID: operatorID,
Status: 1,
}
if err := h.repo.GetDbW().WithContext(ctx.RequestContext()).Create(blacklist).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(&blacklistResponse{
ID: blacklist.ID,
DouyinUserID: blacklist.DouyinUserID,
Reason: blacklist.Reason,
OperatorID: blacklist.OperatorID,
Status: blacklist.Status,
CreatedAt: blacklist.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
}
// RemoveBlacklist 移除黑名单
// @Summary 移除黑名单
// @Description 将用户从黑名单中移除软删除status设为0
// @Tags 管理端.黑名单
// @Accept json
// @Produce json
// @Param id path integer true "黑名单ID"
// @Success 200 {object} simpleMessageResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/blacklist/{id} [delete]
// @Security LoginVerifyToken
func (h *handler) RemoveBlacklist() core.HandlerFunc {
return func(ctx core.Context) {
idStr := ctx.Param("id")
id, err := strconv.ParseInt(idStr, 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的ID"))
return
}
result := h.repo.GetDbW().WithContext(ctx.RequestContext()).
Table("douyin_blacklist").
Where("id = ?", id).
Update("status", 0)
if result.Error != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, result.Error.Error()))
return
}
if result.RowsAffected == 0 {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ParamBindError, "黑名单记录不存在"))
return
}
ctx.Payload(&simpleMessageResponse{Message: "移除成功"})
}
}
// CheckBlacklist 检查用户是否在黑名单
// @Summary 检查黑名单状态
// @Description 检查指定抖音用户是否在黑名单中
// @Tags 管理端.黑名单
// @Accept json
// @Produce json
// @Param douyin_user_id query string true "抖音用户ID"
// @Success 200 {object} map[string]bool
// @Failure 400 {object} code.Failure
// @Router /api/admin/blacklist/check [get]
// @Security LoginVerifyToken
func (h *handler) CheckBlacklist() core.HandlerFunc {
return func(ctx core.Context) {
douyinUserID := ctx.RequestInputParams().Get("douyin_user_id")
if douyinUserID == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "抖音用户ID不能为空"))
return
}
var count int64
h.repo.GetDbR().WithContext(ctx.RequestContext()).
Table("douyin_blacklist").
Where("douyin_user_id = ? AND status = 1", douyinUserID).
Count(&count)
ctx.Payload(map[string]any{
"douyin_user_id": douyinUserID,
"is_blacklisted": count > 0,
})
}
}
// BatchAddBlacklist 批量添加黑名单
// @Summary 批量添加黑名单
// @Description 批量将抖音用户添加到黑名单
// @Tags 管理端.黑名单
// @Accept json
// @Produce json
// @Param body body batchAddBlacklistRequest true "请求参数"
// @Success 200 {object} batchAddBlacklistResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/blacklist/batch [post]
// @Security LoginVerifyToken
func (h *handler) BatchAddBlacklist() core.HandlerFunc {
return func(ctx core.Context) {
var req struct {
DouyinUserIDs []string `json:"douyin_user_ids" binding:"required"`
Reason string `json:"reason"`
}
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.DouyinUserIDs) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "抖音用户ID列表不能为空"))
return
}
// 获取操作人ID
operatorID := int64(0)
if ctx.SessionUserInfo().Id > 0 {
operatorID = int64(ctx.SessionUserInfo().Id)
}
// 查询已存在的黑名单
var existingIDs []string
h.repo.GetDbR().WithContext(ctx.RequestContext()).
Table("douyin_blacklist").
Where("douyin_user_id IN ? AND status = 1", req.DouyinUserIDs).
Pluck("douyin_user_id", &existingIDs)
existMap := make(map[string]bool)
for _, id := range existingIDs {
existMap[id] = true
}
// 过滤出需要新增的
var toAdd []model.DouyinBlacklist
for _, uid := range req.DouyinUserIDs {
if !existMap[uid] {
toAdd = append(toAdd, model.DouyinBlacklist{
DouyinUserID: uid,
Reason: req.Reason,
OperatorID: operatorID,
Status: 1,
})
}
}
addedCount := 0
if len(toAdd) > 0 {
if err := h.repo.GetDbW().WithContext(ctx.RequestContext()).Create(&toAdd).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
addedCount = len(toAdd)
}
ctx.Payload(map[string]any{
"total_requested": len(req.DouyinUserIDs),
"added": addedCount,
"skipped": len(req.DouyinUserIDs) - addedCount,
})
}
}

View File

@ -44,7 +44,9 @@ func (h *handler) CreateChannel() core.HandlerFunc {
}
type channelStatsRequest struct {
Days int `form:"days"`
Days int `form:"days"`
StartDate string `form:"start_date"`
EndDate string `form:"end_date"`
}
// ChannelStats 渠道数据分析
@ -58,7 +60,7 @@ func (h *handler) ChannelStats() core.HandlerFunc {
idStr := ctx.Param("channel_id")
id, _ := strconv.ParseInt(idStr, 10, 64)
stats, err := h.channel.GetStats(ctx.RequestContext(), id, req.Days)
stats, err := h.channel.GetStats(ctx.RequestContext(), id, req.Days, req.StartDate, req.EndDate)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
return

View File

@ -0,0 +1,681 @@
package admin
import (
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
)
type activityProfitLossRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Name string `form:"name"`
Status int32 `form:"status"` // 1进行中 2下线
SortBy string `form:"sort_by"` // profit, profit_asc, profit_rate, draw_count
}
type activityProfitLossItem struct {
ActivityID int64 `json:"activity_id"`
ActivityName string `json:"activity_name"`
Status int32 `json:"status"`
DrawCount int64 `json:"draw_count"`
GamePassCount int64 `json:"game_pass_count"` // 次卡抽奖次数
PaymentCount int64 `json:"payment_count"` // 现金/优惠券抽奖次数
RefundCount int64 `json:"refund_count"` // 退款/取消抽奖次数
PlayerCount int64 `json:"player_count"`
TotalRevenue int64 `json:"total_revenue"` // 实际支付金额 (分)
TotalDiscount int64 `json:"total_discount"` // 优惠券抵扣金额 (分)
TotalGamePassValue int64 `json:"total_game_pass_value"` // 次卡价值 (分)
TotalCost int64 `json:"total_cost"` // 奖品标价总和 (分)
Profit int64 `json:"profit"` // (Revenue + Discount + GamePassValue) - Cost
ProfitRate float64 `json:"profit_rate"` // Profit / (Revenue + Discount + GamePassValue)
}
type activityProfitLossResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []activityProfitLossItem `json:"list"`
}
func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
return func(ctx core.Context) {
req := new(activityProfitLossRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
// 1. 获取活动列表基础信息
// 1. 获取活动列表基础信息
var activities []model.Activities
// 仅查询有完整配置(Issue->RewardSettings)且未删除的活动
// 使用 Raw SQL 避免 GORM 自动注入 ambiguous 的 deleted_at
rawSubQuery := fmt.Sprintf(`
SELECT activity_issues.activity_id
FROM %s AS activity_issues
JOIN %s AS activity_reward_settings ON activity_reward_settings.issue_id = activity_issues.id
WHERE activity_issues.deleted_at IS NULL
AND activity_reward_settings.deleted_at IS NULL
`, model.TableNameActivityIssues, model.TableNameActivityRewardSettings)
query := db.Table(model.TableNameActivities).
Where("activities.deleted_at IS NULL").
Where(fmt.Sprintf("activities.id IN (%s)", rawSubQuery))
if req.Name != "" {
query = query.Where("activities.name LIKE ?", "%"+req.Name+"%")
}
if req.Status > 0 {
query = query.Where("activities.status = ?", req.Status)
}
var total int64
query.Count(&total)
// 如果有排序需求,先获取所有活动计算盈亏后排序,再分页
// 如果没有排序需求,直接数据库分页
needCustomSort := req.SortBy != ""
var limitQuery = query
if !needCustomSort {
limitQuery = query.Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize)
}
if err := limitQuery.Order("id DESC").Find(&activities).Error; err != nil {
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss activities error: %v", err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 21021, err.Error()))
return
}
if len(activities) == 0 {
ctx.Payload(&activityProfitLossResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: []activityProfitLossItem{},
})
return
}
activityIDs := make([]int64, len(activities))
activityMap := make(map[int64]*activityProfitLossItem)
for i, a := range activities {
activityIDs[i] = a.ID
activityMap[a.ID] = &activityProfitLossItem{
ActivityID: a.ID,
ActivityName: a.Name,
Status: a.Status,
}
}
// 2. 统计抽奖次数和人数 (通过 activity_draw_logs 关联 activity_issues 和 orders)
type drawStat struct {
ActivityID int64
TotalCount int64
GamePassCount int64
PaymentCount int64
RefundCount int64
PlayerCount int64
}
var drawStats []drawStat
db.Table(model.TableNameActivityDrawLogs).
Select(`
activity_issues.activity_id,
COUNT(activity_draw_logs.id) as total_count,
SUM(CASE WHEN orders.status = 2 AND (orders.source_type = 4 OR orders.order_no LIKE 'GP%') THEN 1 ELSE 0 END) as game_pass_count,
SUM(CASE WHEN orders.status = 2 AND NOT (orders.source_type = 4 OR orders.order_no LIKE 'GP%') THEN 1 ELSE 0 END) as payment_count,
SUM(CASE WHEN orders.status IN (3, 4) THEN 1 ELSE 0 END) as refund_count,
COUNT(DISTINCT CASE WHEN orders.status = 2 THEN activity_draw_logs.user_id END) as player_count
`).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_id").
Where("activity_issues.activity_id IN ?", activityIDs).
Group("activity_issues.activity_id").
Scan(&drawStats)
for _, s := range drawStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.DrawCount = s.GamePassCount + s.PaymentCount // 仅统计有效抽奖(次卡+支付)
item.GamePassCount = s.GamePassCount
item.PaymentCount = s.PaymentCount
item.RefundCount = s.RefundCount
item.PlayerCount = s.PlayerCount
}
}
// 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs)
// 3. 统计营收和优惠券抵扣 (通过 orders 关联 activity_draw_logs)
// BUG修复排除已退款订单(status=4)。
// 注意: MySQL SUM()运算涉及除法时会返回Decimal类型需要Scan到float64
type revenueStat struct {
ActivityID int64
TotalRevenue float64
TotalDiscount float64
}
var revenueStats []revenueStat
// 修正: 按抽奖次数比例分摊订单金额 (解决多活动订单归因问题)
// 逻辑: 活动分摊收入 = 订单实际金额 * (该活动在该订单中的抽奖次数 / 该订单总抽奖次数)
var err error
err = db.Table(model.TableNameOrders).
Select(`
order_activity_draws.activity_id,
SUM(1.0 * orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count) as total_revenue,
SUM(CASE WHEN orders.source_type = 4 OR orders.order_no LIKE 'GP%' THEN 0 ELSE 1.0 * orders.discount_amount * order_activity_draws.draw_count / order_total_draws.total_count END) as total_discount
`).
// Subquery 1: Calculate draw counts per order per activity (and link to issue->activity)
Joins(`JOIN (
SELECT activity_draw_logs.order_id, activity_issues.activity_id, COUNT(*) as draw_count
FROM activity_draw_logs
JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id
GROUP BY activity_draw_logs.order_id, activity_issues.activity_id
) as order_activity_draws ON order_activity_draws.order_id = orders.id`).
// Subquery 2: Calculate total draw counts per order
Joins(`JOIN (
SELECT order_id, COUNT(*) as total_count
FROM activity_draw_logs
GROUP BY order_id
) as order_total_draws ON order_total_draws.order_id = orders.id`).
Where("orders.status = ?", 2). // 已支付(排除待支付、取消、退款状态)
Where("order_activity_draws.activity_id IN ?", activityIDs).
Group("order_activity_draws.activity_id").
Scan(&revenueStats).Error
if err != nil {
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss revenue stats error: %v", err))
}
for _, s := range revenueStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.TotalRevenue = int64(s.TotalRevenue)
item.TotalDiscount = int64(s.TotalDiscount)
}
}
// 4. 统计成本 (通过 user_inventory 关联 products 和 orders)
// 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2)
type costStat struct {
ActivityID int64
TotalCost int64
}
var costStats []costStat
db.Table(model.TableNameUserInventory).
Select("user_inventory.activity_id, SUM(products.price) as total_cost").
Joins("JOIN products ON products.id = user_inventory.product_id").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Where("user_inventory.activity_id IN ?", activityIDs).
Where("orders.status = ?", 2). // 仅统计已支付订单产生的成本
Group("user_inventory.activity_id").
Scan(&costStats)
for _, s := range costStats {
if item, ok := activityMap[s.ActivityID]; ok {
item.TotalCost = s.TotalCost
}
}
// 5. 统计次卡价值 (0元订单按活动单价计算)
// 先获取各活动的单价
activityPriceMap := make(map[int64]int64)
for _, a := range activities {
activityPriceMap[a.ID] = a.PriceDraw
}
// 统计每个活动的0元订单对应的抽奖次数 (次卡支付)
// BUG修复之前统计的是订单数量但一个订单可能包含多次抽奖
// 正确做法是统计抽奖次数,再乘以活动单价
type gamePassStat struct {
ActivityID int64
GamePassDraws int64 // 抽奖次数,非订单数
}
var gamePassStats []gamePassStat
db.Table(model.TableNameActivityDrawLogs).
Select("activity_issues.activity_id, COUNT(activity_draw_logs.id) as game_pass_draws").
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("JOIN orders ON orders.id = activity_draw_logs.order_id").
Where("orders.status = ? AND orders.status != ?", 2, 4). // 已支付且未退款
Where("orders.actual_amount = 0"). // 0元订单
Where("orders.source_type = 4 OR orders.order_no LIKE 'GP%'"). // 次数卡 (Lottery SourceType=4 OR Matching Game GP prefix)
Where("activity_issues.activity_id IN ?", activityIDs).
Group("activity_issues.activity_id").
Scan(&gamePassStats)
for _, s := range gamePassStats {
if item, ok := activityMap[s.ActivityID]; ok {
// 次卡价值 = 次卡抽奖次数 * 活动单价
item.TotalGamePassValue = s.GamePassDraws * activityPriceMap[s.ActivityID]
}
}
// 6. 计算盈亏和比率
// 公式: 盈亏 = (支付金额 + 优惠券抵扣 + 次卡价值) - 产品成本
finalList := make([]activityProfitLossItem, 0, len(activities))
for _, a := range activities {
item := activityMap[a.ID]
totalIncome := item.TotalRevenue + item.TotalDiscount + item.TotalGamePassValue
item.Profit = totalIncome - item.TotalCost
if totalIncome > 0 {
item.ProfitRate = float64(item.Profit) / float64(totalIncome)
}
finalList = append(finalList, *item)
}
// 按请求的字段排序
if needCustomSort {
sort.Slice(finalList, func(i, j int) bool {
switch req.SortBy {
case "profit":
return finalList[i].Profit > finalList[j].Profit
case "profit_asc":
return finalList[i].Profit < finalList[j].Profit
case "profit_rate":
return finalList[i].ProfitRate > finalList[j].ProfitRate
case "draw_count":
return finalList[i].DrawCount > finalList[j].DrawCount
default:
return false // 保持原有顺序 (id DESC)
}
})
// 排序后再分页
start := (req.Page - 1) * req.PageSize
end := start + req.PageSize
if start > len(finalList) {
start = len(finalList)
}
if end > len(finalList) {
end = len(finalList)
}
finalList = finalList[start:end]
}
ctx.Payload(&activityProfitLossResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: finalList,
})
}
}
type activityLogsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type activityLogItem struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
ProductID int64 `json:"product_id"`
ProductName string `json:"product_name"`
ProductImage string `json:"product_image"`
ProductPrice int64 `json:"product_price"`
ProductQuantity int64 `json:"product_quantity"` // 奖品数量
OrderAmount int64 `json:"order_amount"`
OrderNo string `json:"order_no"` // 订单号
DiscountAmount int64 `json:"discount_amount"` // 优惠金额(分)
PayType string `json:"pay_type"` // 支付方式/类型 (现金/道具卡/次数卡)
UsedCard string `json:"used_card"` // 使用的卡券名称(兼容旧字段)
OrderStatus int32 `json:"order_status"` // 订单状态: 1待支付 2已支付 3已取消 4已退款
Profit int64 `json:"profit"`
CreatedAt time.Time `json:"created_at"`
// 新增:详细支付信息
PaymentDetails PaymentDetails `json:"payment_details"`
}
// PaymentDetails 支付详细信息
type PaymentDetails struct {
CouponUsed bool `json:"coupon_used"` // 是否使用优惠券
CouponName string `json:"coupon_name"` // 优惠券名称
CouponDiscount int64 `json:"coupon_discount"` // 优惠券抵扣金额(分)
ItemCardUsed bool `json:"item_card_used"` // 是否使用道具卡
ItemCardName string `json:"item_card_name"` // 道具卡名称
GamePassUsed bool `json:"game_pass_used"` // 是否使用次数卡
GamePassInfo string `json:"game_pass_info"` // 次数卡使用信息
PointsUsed bool `json:"points_used"` // 是否使用积分
PointsDiscount int64 `json:"points_discount"` // 积分抵扣金额(分)
}
type activityLogsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []activityLogItem `json:"list"`
}
func (h *handler) DashboardActivityLogs() core.HandlerFunc {
return func(ctx core.Context) {
activityID, _ := strconv.ParseInt(ctx.Param("activity_id"), 10, 64)
if activityID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "Invalid activity ID"))
return
}
req := new(activityLogsRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
var total int64
db.Table(model.TableNameActivityDrawLogs).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Where("activity_issues.activity_id = ?", activityID).
Count(&total)
var logs []struct {
ID int64
UserID int64
Nickname string
Avatar string
ProductID int64
ProductName string
ImagesJSON string
ProductPrice int64
OrderAmount int64
DiscountAmount int64
PointsAmount int64 // 积分抵扣金额
OrderStatus int32 // 订单状态
SourceType int32
CouponID int64
CouponName string
ItemCardID int64
ItemCardName string
EffectType int32
Multiplier int32
OrderRemark string // BUG修复增加remark字段用于解析次数卡使用信息
OrderNo string // 订单号
DrawCount int64 // 该订单的总抽奖次数(用于金额分摊)
UsedDrawLogID int64 // 道具卡实际使用的日志ID
CreatedAt time.Time
}
err := db.Table(model.TableNameActivityDrawLogs).
Select(`
activity_draw_logs.id,
activity_draw_logs.user_id,
COALESCE(users.nickname, '') as nickname,
COALESCE(users.avatar, '') as avatar,
activity_reward_settings.product_id,
COALESCE(products.name, '') as product_name,
COALESCE(products.images_json, '[]') as images_json,
COALESCE(products.price, 0) as product_price,
COALESCE(orders.actual_amount, 0) as order_amount,
COALESCE(orders.discount_amount, 0) as discount_amount,
COALESCE(orders.points_amount, 0) as points_amount,
COALESCE(orders.status, 0) as order_status,
orders.source_type,
COALESCE(orders.coupon_id, 0) as coupon_id,
COALESCE(system_coupons.name, '') as coupon_name,
COALESCE(orders.item_card_id, 0) as item_card_id,
COALESCE(system_item_cards.name, '') as item_card_name,
COALESCE(system_item_cards.effect_type, 0) as effect_type,
COALESCE(system_item_cards.reward_multiplier_x1000, 0) as multiplier,
COALESCE(orders.remark, '') as order_remark,
COALESCE(orders.order_no, '') as order_no,
COALESCE(order_draw_counts.draw_count, 1) as draw_count,
COALESCE(user_item_cards.used_draw_log_id, 0) as used_draw_log_id,
activity_draw_logs.created_at
`).
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
Joins("LEFT JOIN users ON users.id = activity_draw_logs.user_id").
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = activity_draw_logs.reward_id").
Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
Joins("LEFT JOIN orders ON orders.id = activity_draw_logs.order_id").
Joins("LEFT JOIN user_coupons ON user_coupons.id = orders.coupon_id").
Joins("LEFT JOIN system_coupons ON system_coupons.id = user_coupons.coupon_id").
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
Joins("LEFT JOIN (SELECT order_id, COUNT(*) as draw_count FROM activity_draw_logs GROUP BY order_id) as order_draw_counts ON order_draw_counts.order_id = activity_draw_logs.order_id").
Where("activity_issues.activity_id = ?", activityID).
Order("activity_draw_logs.id DESC").
Offset((req.Page - 1) * req.PageSize).
Limit(req.PageSize).
Scan(&logs).Error
if err != nil {
h.logger.Error(fmt.Sprintf("GetActivityLogs error: %v", err))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 21022, err.Error()))
return
}
list := make([]activityLogItem, len(logs))
for i, l := range logs {
var images []string
_ = json.Unmarshal([]byte(l.ImagesJSON), &images)
productImage := ""
if len(images) > 0 {
productImage = images[0]
}
// Default quantity is 1
quantity := int64(1)
// Determine PayType and UsedCard + PaymentDetails
payType := "现金支付"
usedCard := ""
paymentDetails := PaymentDetails{} // 金额将在 drawCount 计算后设置
// 检查是否使用了优惠券
if l.CouponID > 0 || l.CouponName != "" {
paymentDetails.CouponUsed = true
paymentDetails.CouponName = l.CouponName
if paymentDetails.CouponName == "" {
paymentDetails.CouponName = "优惠券"
}
usedCard = paymentDetails.CouponName
payType = "优惠券"
}
// 检查是否使用了道具卡
// BUG FIX: 仅当该条日志的 ID 等于 item_card 记录的 used_draw_log_id 时,才显示道具卡信息
// 防止一个订单下的所有抽奖记录都显示 "双倍快乐水"
isCardValidForThisLog := (l.UsedDrawLogID == 0) || (l.UsedDrawLogID == l.ID)
if (l.ItemCardID > 0 || l.ItemCardName != "") && isCardValidForThisLog {
paymentDetails.ItemCardUsed = true
paymentDetails.ItemCardName = l.ItemCardName
if paymentDetails.ItemCardName == "" {
paymentDetails.ItemCardName = "道具卡"
}
if usedCard != "" {
usedCard = usedCard + " + " + paymentDetails.ItemCardName
} else {
usedCard = paymentDetails.ItemCardName
}
payType = "道具卡"
// 计算双倍/多倍卡数量
if l.EffectType == 1 && l.Multiplier > 1000 {
quantity = quantity * int64(l.Multiplier) / 1000
}
}
// 检查是否使用了次数卡 (source_type=4 或 remark包含use_game_pass)
if l.SourceType == 4 || strings.Contains(l.OrderRemark, "use_game_pass") {
paymentDetails.GamePassUsed = true
// 解析 gp_use:ID:Count 格式获取次数卡使用信息
gamePassInfo := "次数卡"
if strings.Contains(l.OrderRemark, "gp_use:") {
// 从remark中提取次数卡信息格式: use_game_pass;gp_use:ID:Count;gp_use:ID:Count
parts := strings.Split(l.OrderRemark, ";")
var gpParts []string
for _, p := range parts {
if strings.HasPrefix(p, "gp_use:") {
gpParts = append(gpParts, p)
}
}
if len(gpParts) > 0 {
gamePassInfo = fmt.Sprintf("使用%d种次数卡", len(gpParts))
}
}
paymentDetails.GamePassInfo = gamePassInfo
if usedCard != "" {
usedCard = usedCard + " + " + gamePassInfo
} else {
usedCard = gamePassInfo
}
payType = "次数卡"
}
// 检查是否使用了积分
if l.PointsAmount > 0 {
paymentDetails.PointsUsed = true
}
// 如果同时使用了多种方式,标记为组合支付
usedCount := 0
if paymentDetails.CouponUsed {
usedCount++
}
if paymentDetails.ItemCardUsed {
usedCount++
}
if paymentDetails.GamePassUsed {
usedCount++
}
if usedCount > 1 {
payType = "组合支付"
} else if usedCount == 0 && l.OrderAmount > 0 {
payType = "现金支付"
} else if usedCount == 0 && l.OrderAmount == 0 {
// 0元支付默认视为次数卡使用实际业务中几乎不存在真正免费的情况
payType = "次数卡"
paymentDetails.GamePassUsed = true
if paymentDetails.GamePassInfo == "" {
paymentDetails.GamePassInfo = "次数卡"
}
}
// 计算单次抽奖的分摊金额(一个订单可能包含多次抽奖)
drawCount := l.DrawCount
if drawCount <= 0 {
drawCount = 1
}
perDrawOrderAmount := l.OrderAmount / drawCount
perDrawDiscountAmount := l.DiscountAmount / drawCount
perDrawPointsAmount := l.PointsAmount / drawCount
// 设置支付详情中的分摊金额
paymentDetails.CouponDiscount = perDrawDiscountAmount
paymentDetails.PointsDiscount = perDrawPointsAmount
list[i] = activityLogItem{
ID: l.ID,
UserID: l.UserID,
Nickname: l.Nickname,
Avatar: l.Avatar,
ProductID: l.ProductID,
ProductName: l.ProductName,
ProductImage: productImage,
ProductPrice: l.ProductPrice,
ProductQuantity: quantity,
OrderAmount: perDrawOrderAmount, // 单次抽奖分摊的支付金额
OrderNo: l.OrderNo, // 订单号
DiscountAmount: perDrawDiscountAmount, // 单次抽奖分摊的优惠金额
PayType: payType,
UsedCard: usedCard,
OrderStatus: l.OrderStatus,
Profit: perDrawOrderAmount + perDrawDiscountAmount - l.ProductPrice*quantity, // 单次盈亏 = 分摊收入 - 成本*数量
CreatedAt: l.CreatedAt,
PaymentDetails: paymentDetails,
}
}
ctx.Payload(&activityLogsResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: list,
})
}
}
type ensureActivityProfitLossMenuResponse struct {
Ensured bool `json:"ensured"`
Parent int64 `json:"parent_id"`
MenuID int64 `json:"menu_id"`
}
// EnsureActivityProfitLossMenu 确保运营分析下存在“活动盈亏”菜单
func (h *handler) EnsureActivityProfitLossMenu() core.HandlerFunc {
return func(ctx core.Context) {
// 1. 查找是否存在“控制台”或者“运营中心”类的父菜单
// 很多系统会将概览放在 Dashboard 下。根据 titles_seed.go运营是 Operations。
parent, _ := h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Name.Eq("Operations")).First()
var parentID int64
if parent == nil {
// 如果没有 Operations尝试查找 Dashboard
parent, _ = h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Name.Eq("Dashboard")).First()
}
if parent != nil {
parentID = parent.ID
}
// 2. 查找活动盈亏菜单
// 路径指向控制台并带上查参数
menuPath := "/dashboard/console?tab=activity-profit"
exists, _ := h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Path.Eq(menuPath)).First()
if exists != nil {
ctx.Payload(&ensureActivityProfitLossMenuResponse{Ensured: true, Parent: parentID, MenuID: exists.ID})
return
}
// 3. 创建菜单
newMenu := &model.Menus{
ParentID: parentID,
Path: menuPath,
Name: "活动盈亏",
Component: "/dashboard/console/index",
Icon: "ri:pie-chart-2-fill",
Sort: 60, // 排序在称号之后
Status: true,
KeepAlive: true,
IsHide: false,
IsHideTab: false,
CreatedUser: "system",
UpdatedUser: "system",
}
if err := h.writeDB.Menus.WithContext(ctx.RequestContext()).Create(newMenu); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 21023, "创建菜单失败: "+err.Error()))
return
}
// 读取新创建的 ID
created, _ := h.readDB.Menus.WithContext(ctx.RequestContext()).Where(h.readDB.Menus.Path.Eq(menuPath)).First()
menuID := int64(0)
if created != nil {
menuID = created.ID
}
ctx.Payload(&ensureActivityProfitLossMenuResponse{Ensured: true, Parent: parentID, MenuID: menuID})
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,344 @@
package admin
import (
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"fmt"
"net/http"
"sort"
"time"
)
type spendingLeaderboardRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
RangeType string `form:"rangeType"` // today, 7d, 30d, custom
StartDate string `form:"start"`
EndDate string `form:"end"`
SortBy string `form:"sort_by"` // spending, profit
}
type spendingLeaderboardItem struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
OrderCount int64 `json:"-"` // Hidden
TotalSpending int64 `json:"-"` // Hidden
TotalPrizeValue int64 `json:"-"` // Hidden
TotalDiscount int64 `json:"total_discount"` // Total Coupon Discount (Fen)
TotalPoints int64 `json:"total_points"` // Total Points Discount (Fen)
GamePassCount int64 `json:"game_pass_count"` // Count of SourceType=4
ItemCardCount int64 `json:"item_card_count"` // Count where ItemCardID > 0
// Breakdown by game type
IchibanSpending int64 `json:"ichiban_spending"`
IchibanPrize int64 `json:"ichiban_prize"`
IchibanProfit int64 `json:"ichiban_profit"`
IchibanCount int64 `json:"ichiban_count"`
InfiniteSpending int64 `json:"infinite_spending"`
InfinitePrize int64 `json:"infinite_prize"`
InfiniteProfit int64 `json:"infinite_profit"`
InfiniteCount int64 `json:"infinite_count"`
MatchingSpending int64 `json:"matching_spending"`
MatchingPrize int64 `json:"matching_prize"`
MatchingProfit int64 `json:"matching_profit"`
MatchingCount int64 `json:"matching_count"`
// 直播间统计 (source_type=5)
LivestreamSpending int64 `json:"livestream_spending"`
LivestreamPrize int64 `json:"livestream_prize"`
LivestreamProfit int64 `json:"livestream_profit"`
LivestreamCount int64 `json:"livestream_count"`
Profit int64 `json:"profit"` // Spending - PrizeValue
ProfitRate float64 `json:"profit_rate"` // Profit / Spending
}
type spendingLeaderboardResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []spendingLeaderboardItem `json:"list"`
}
func (h *handler) DashboardPlayerSpendingLeaderboard() core.HandlerFunc {
return func(ctx core.Context) {
req := new(spendingLeaderboardRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
var start, end time.Time
if req.RangeType != "all" {
start, end = parseRange(req.RangeType, req.StartDate, req.EndDate)
h.logger.Info(fmt.Sprintf("SpendingLeaderboard range: start=%v, end=%v, type=%s", start, end, req.RangeType))
} else {
h.logger.Info("SpendingLeaderboard range: ALL TIME")
}
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
// 1. Get Top Spenders from Orders
type orderStat struct {
UserID int64
TotalAmount int64 // ActualAmount
OrderCount int64
TotalDiscount int64
TotalPoints int64
GamePassCount int64
ItemCardCount int64
IchibanSpending int64
IchibanCount int64
InfiniteSpending int64
InfiniteCount int64
MatchingSpending int64
MatchingCount int64
LivestreamSpending int64
LivestreamCount int64
}
var stats []orderStat
query := db.Table(model.TableNameOrders).
Joins("LEFT JOIN (SELECT l.order_id, MAX(a.activity_category_id) as category_id FROM activity_draw_logs l JOIN activity_issues i ON i.id = l.issue_id JOIN activities a ON a.id = i.activity_id GROUP BY l.order_id) oa ON oa.order_id = orders.id").
Where("orders.status = ?", 2)
if req.RangeType != "all" {
query = query.Where("orders.created_at >= ?", start).Where("orders.created_at <= ?", end)
}
if err := query.Select(`
orders.user_id,
SUM(orders.total_amount) as total_amount,
COUNT(orders.id) as order_count,
SUM(orders.discount_amount) as total_discount,
SUM(orders.points_amount) as total_points,
SUM(CASE WHEN orders.source_type = 4 THEN 1 ELSE 0 END) as game_pass_count,
SUM(CASE WHEN orders.item_card_id > 0 THEN 1 ELSE 0 END) as item_card_count,
SUM(CASE WHEN oa.category_id = 1 THEN orders.total_amount ELSE 0 END) as ichiban_spending,
SUM(CASE WHEN oa.category_id = 1 THEN 1 ELSE 0 END) as ichiban_count,
SUM(CASE WHEN oa.category_id = 2 THEN orders.total_amount ELSE 0 END) as infinite_spending,
SUM(CASE WHEN oa.category_id = 2 THEN 1 ELSE 0 END) as infinite_count,
SUM(CASE WHEN oa.category_id = 3 THEN orders.total_amount ELSE 0 END) as matching_spending,
SUM(CASE WHEN oa.category_id = 3 THEN 1 ELSE 0 END) as matching_count,
SUM(CASE WHEN orders.source_type = 5 THEN orders.total_amount ELSE 0 END) as livestream_spending,
SUM(CASE WHEN orders.source_type = 5 THEN 1 ELSE 0 END) as livestream_count
`).
Group("orders.user_id").
Order("total_amount DESC").
Limit(100).
Scan(&stats).Error; err != nil {
h.logger.Error(fmt.Sprintf("SpendingLeaderboard SQL error: %v", err))
ctx.AbortWithError(core.Error(http.StatusBadRequest, 21020, err.Error()))
return
}
h.logger.Info(fmt.Sprintf("SpendingLeaderboard SQL done: count=%d", len(stats)))
// 2. Collect User IDs
userIDs := make([]int64, 0, len(stats))
statMap := make(map[int64]*spendingLeaderboardItem)
for _, s := range stats {
userIDs = append(userIDs, s.UserID)
statMap[s.UserID] = &spendingLeaderboardItem{
UserID: s.UserID,
TotalSpending: s.TotalAmount,
OrderCount: s.OrderCount,
TotalDiscount: s.TotalDiscount,
TotalPoints: s.TotalPoints,
GamePassCount: s.GamePassCount,
ItemCardCount: s.ItemCardCount,
IchibanSpending: s.IchibanSpending,
IchibanCount: s.IchibanCount,
InfiniteSpending: s.InfiniteSpending,
InfiniteCount: s.InfiniteCount,
MatchingSpending: s.MatchingSpending,
MatchingCount: s.MatchingCount,
LivestreamSpending: 0, // Will be updated from douyin_orders
LivestreamCount: s.LivestreamCount,
}
}
// 2.1 Fetch Real Douyin Spending
if len(userIDs) > 0 {
type dyStat struct {
UserID int64
Amount int64
Count int64
}
var dyStats []dyStat
dyQuery := h.repo.GetDbR().Table("douyin_orders").
Select("CAST(local_user_id AS SIGNED) as user_id, SUM(actual_pay_amount) as amount, COUNT(*) as count").
Where("local_user_id IN ?", userIDs).
Where("local_user_id != '' AND local_user_id != '0'")
if req.RangeType != "all" {
dyQuery = dyQuery.Where("created_at >= ?", start).Where("created_at <= ?", end)
}
if err := dyQuery.Group("local_user_id").Scan(&dyStats).Error; err == nil {
for _, ds := range dyStats {
if item, ok := statMap[ds.UserID]; ok {
item.LivestreamSpending = ds.Amount
item.LivestreamCount = ds.Count // Use real paid order count
item.TotalSpending += ds.Amount // Add to total since orders.total_amount was 0 for these
}
}
}
}
if len(userIDs) > 0 {
// 3. Get User Info
// Use h.readDB.Users (GEN) as it's simple
users, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Users.ID.In(userIDs...)).Find()
for _, u := range users {
if item, ok := statMap[u.ID]; ok {
item.Nickname = u.Nickname
item.Avatar = u.Avatar
}
}
// 4. Calculate Prize Value (Inventory)
type invStat struct {
UserID int64
TotalValue int64
IchibanPrize int64
InfinitePrize int64
MatchingPrize int64
LivestreamPrize int64
}
var invStats []invStat
// Join with Products, Activities, and Orders (for livestream detection)
query := db.Table(model.TableNameUserInventory).
Joins("JOIN products ON products.id = user_inventory.product_id").
Joins("LEFT JOIN activities ON activities.id = user_inventory.activity_id").
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
Where("user_inventory.user_id IN ?", userIDs)
if req.RangeType != "all" {
query = query.Where("user_inventory.created_at >= ?", start).
Where("user_inventory.created_at <= ?", end)
}
// Only include Holding (1) and Shipped/Used (3) items. Exclude Void/Decomposed (2).
query = query.Where("user_inventory.status IN ?", []int{1, 3}).
Where("user_inventory.remark NOT LIKE ?", "%void%")
err := query.Select(`
user_inventory.user_id,
SUM(products.price) as total_value,
SUM(CASE WHEN activities.activity_category_id = 1 THEN products.price ELSE 0 END) as ichiban_prize,
SUM(CASE WHEN activities.activity_category_id = 2 THEN products.price ELSE 0 END) as infinite_prize,
SUM(CASE WHEN activities.activity_category_id = 3 THEN products.price ELSE 0 END) as matching_prize
`).
Group("user_inventory.user_id").
Scan(&invStats).Error
if err == nil {
for _, is := range invStats {
if item, ok := statMap[is.UserID]; ok {
item.TotalPrizeValue = is.TotalValue
item.IchibanPrize = is.IchibanPrize
item.InfinitePrize = is.InfinitePrize
item.MatchingPrize = is.MatchingPrize
}
}
}
// 4.1 Calculate Livestream Prize Value (From Draw Logs)
type lsStat struct {
UserID int64
Amount int64
}
var lsStats []lsStat
lsQuery := db.Table(model.TableNameLivestreamDrawLogs).
Joins("JOIN livestream_prizes ON livestream_prizes.id = livestream_draw_logs.prize_id").
Joins("JOIN products ON products.id = livestream_prizes.product_id").
Select("livestream_draw_logs.local_user_id as user_id, SUM(products.price) as amount").
Where("livestream_draw_logs.local_user_id IN ?", userIDs).
Where("livestream_draw_logs.is_refunded = 0")
if req.RangeType != "all" {
lsQuery = lsQuery.Where("livestream_draw_logs.created_at >= ?", start).
Where("livestream_draw_logs.created_at <= ?", end)
}
if err := lsQuery.Group("livestream_draw_logs.local_user_id").Scan(&lsStats).Error; err == nil {
for _, ls := range lsStats {
if item, ok := statMap[ls.UserID]; ok {
item.LivestreamPrize = ls.Amount
// item.TotalPrizeValue += ls.Amount // Already included in user_inventory
}
}
}
// 4.2 Calculate Profit for each category
for _, item := range statMap {
item.IchibanProfit = item.IchibanSpending - item.IchibanPrize
item.InfiniteProfit = item.InfiniteSpending - item.InfinitePrize
item.MatchingProfit = item.MatchingSpending - item.MatchingPrize
item.LivestreamProfit = item.LivestreamSpending - item.LivestreamPrize
}
}
// 5. Calculate Profit and Final List
list := make([]spendingLeaderboardItem, 0, len(statMap))
for _, item := range statMap {
// Calculate totals based on the 4 displayed categories to ensure UI consistency
calculatedSpending := item.IchibanSpending + item.InfiniteSpending + item.MatchingSpending + item.LivestreamSpending
calculatedProfit := item.IchibanProfit + item.InfiniteProfit + item.MatchingProfit + item.LivestreamProfit
item.Profit = calculatedProfit
if calculatedSpending > 0 {
item.ProfitRate = float64(item.Profit) / float64(calculatedSpending)
} else {
item.ProfitRate = 0
}
list = append(list, *item)
}
// 6. Sort (in memory since we only have top N spenders)
sortBy := req.SortBy
if sortBy == "" {
sortBy = "spending"
}
sort.Slice(list, func(i, j int) bool {
switch sortBy {
case "profit":
return list[i].Profit > list[j].Profit // Higher profit first
case "profit_asc":
return list[i].Profit < list[j].Profit // Lower profit (loss) first
default:
return list[i].TotalSpending > list[j].TotalSpending
}
})
// Pagination on the result list
startIdx := (req.Page - 1) * req.PageSize
if startIdx >= len(list) {
startIdx = len(list)
}
endIdx := startIdx + req.PageSize
if endIdx > len(list) {
endIdx = len(list)
}
finalList := list[startIdx:endIdx]
if finalList == nil {
finalList = []spendingLeaderboardItem{}
}
ctx.Payload(&spendingLeaderboardResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: int64(len(list)), // Total of the fetched top batch
List: finalList,
})
}
}

View File

@ -70,9 +70,11 @@ type douyinOrderItem struct {
LocalUserID int64 `json:"local_user_id"`
LocalUserNickname string `json:"local_user_nickname"`
ActualReceiveAmount string `json:"actual_receive_amount"`
ActualPayAmount string `json:"actual_pay_amount"`
PayTypeDesc string `json:"pay_type_desc"`
Remark string `json:"remark"`
UserNickname string `json:"user_nickname"`
ProductCount int64 `json:"product_count"`
CreatedAt string `json:"created_at"`
}
@ -129,9 +131,11 @@ func (h *handler) ListDouyinOrders() core.HandlerFunc {
LocalUserID: uid,
LocalUserNickname: userNicknameMap[uid],
ActualReceiveAmount: formatAmount(o.ActualReceiveAmount),
ActualPayAmount: formatAmount(o.ActualPayAmount),
PayTypeDesc: o.PayTypeDesc,
Remark: o.Remark,
UserNickname: o.UserNickname,
ProductCount: int64(o.ProductCount),
CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
@ -182,7 +186,7 @@ func getOrderStatusText(status int32) string {
case 3:
return "已发货"
case 4:
return "已取消"
return "已退款/已取消"
case 5:
return "已完成"
default:

View File

@ -0,0 +1,214 @@
package admin
import (
"encoding/json"
"net/http"
"strconv"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
)
// ======== 抖店商品奖励规则 CRUD ========
type douyinProductRewardListRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type douyinProductRewardListResponse struct {
List []douyinProductRewardItem `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
}
type douyinProductRewardItem struct {
ID int64 `json:"id"`
ProductID string `json:"product_id"`
ProductName string `json:"product_name"`
ActivityID int64 `json:"activity_id"`
ActivityName string `json:"activity_name"`
RewardType string `json:"reward_type"`
RewardPayload json.RawMessage `json:"reward_payload"`
Quantity int32 `json:"quantity"`
Status int32 `json:"status"`
CreatedAt string `json:"created_at"`
}
// ListDouyinProductRewards 获取抖店商品奖励规则列表
func (h *handler) ListDouyinProductRewards() core.HandlerFunc {
return func(ctx core.Context) {
req := new(douyinProductRewardListRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
db := h.repo.GetDbR().Model(&model.DouyinProductRewards{})
var total int64
if err := db.Count(&total).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10002, err.Error()))
return
}
var list []model.DouyinProductRewards
if err := db.Order("id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&list).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10003, err.Error()))
return
}
// 收集所有需要查询的 activity_id
activityIDs := make([]int64, 0)
for _, r := range list {
if r.ActivityID > 0 {
activityIDs = append(activityIDs, r.ActivityID)
}
}
// 批量查询活动名称
activityNameMap := make(map[int64]string)
if len(activityIDs) > 0 {
var activities []model.LivestreamActivities
if err := h.repo.GetDbR().Model(&model.LivestreamActivities{}).
Select("id, name").
Where("id IN ?", activityIDs).
Find(&activities).Error; err == nil {
for _, a := range activities {
activityNameMap[a.ID] = a.Name
}
}
}
res := douyinProductRewardListResponse{
List: make([]douyinProductRewardItem, len(list)),
Total: total,
Page: req.Page,
}
for i, r := range list {
res.List[i] = douyinProductRewardItem{
ID: r.ID,
ProductID: r.ProductID,
ProductName: r.ProductName,
ActivityID: r.ActivityID,
ActivityName: activityNameMap[r.ActivityID],
RewardType: r.RewardType,
RewardPayload: json.RawMessage(r.RewardPayload),
Quantity: r.Quantity,
Status: r.Status,
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
ctx.Payload(res)
}
}
type createDouyinProductRewardRequest struct {
ProductID string `json:"product_id" binding:"required"`
ProductName string `json:"product_name"`
ActivityID int64 `json:"activity_id"`
RewardType string `json:"reward_type" binding:"required"`
RewardPayload json.RawMessage `json:"reward_payload"`
Quantity int32 `json:"quantity"`
Status int32 `json:"status"`
}
// CreateDouyinProductReward 创建抖店商品奖励规则
func (h *handler) CreateDouyinProductReward() core.HandlerFunc {
return func(ctx core.Context) {
req := new(createDouyinProductRewardRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, validation.Error(err)))
return
}
if req.Quantity <= 0 {
req.Quantity = 1
}
if req.Status == 0 {
req.Status = 1
}
row := &model.DouyinProductRewards{
ProductID: req.ProductID,
ProductName: req.ProductName,
ActivityID: req.ActivityID,
RewardType: req.RewardType,
RewardPayload: string(req.RewardPayload),
Quantity: req.Quantity,
Status: req.Status,
}
if err := h.repo.GetDbW().Create(row).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10002, err.Error()))
return
}
ctx.Payload(map[string]any{"id": row.ID, "message": "创建成功"})
}
}
type updateDouyinProductRewardRequest struct {
ProductName string `json:"product_name"`
ActivityID *int64 `json:"activity_id"`
RewardType string `json:"reward_type"`
RewardPayload json.RawMessage `json:"reward_payload"`
Quantity int32 `json:"quantity"`
Status int32 `json:"status"`
}
// UpdateDouyinProductReward 更新抖店商品奖励规则
func (h *handler) UpdateDouyinProductReward() core.HandlerFunc {
return func(ctx core.Context) {
idStr := ctx.Param("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
if id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "无效的ID"))
return
}
req := new(updateDouyinProductRewardRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10002, validation.Error(err)))
return
}
updates := map[string]any{
"product_name": req.ProductName,
"reward_type": req.RewardType,
"reward_payload": string(req.RewardPayload),
"quantity": req.Quantity,
"status": req.Status,
}
if req.ActivityID != nil {
updates["activity_id"] = *req.ActivityID
}
if err := h.repo.GetDbW().Model(&model.DouyinProductRewards{}).Where("id = ?", id).Updates(updates).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10003, err.Error()))
return
}
ctx.Payload(map[string]string{"message": "更新成功"})
}
}
// DeleteDouyinProductReward 删除抖店商品奖励规则
func (h *handler) DeleteDouyinProductReward() core.HandlerFunc {
return func(ctx core.Context) {
idStr := ctx.Param("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
if id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "无效的ID"))
return
}
if err := h.repo.GetDbW().Delete(&model.DouyinProductRewards{}, id).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10002, err.Error()))
return
}
ctx.Payload(map[string]string{"message": "删除成功"})
}
}

View File

@ -0,0 +1,825 @@
package admin
import (
"net/http"
"strconv"
"strings"
"time"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/livestream"
"gorm.io/gorm"
)
// ========== 直播间活动管理 ==========
type createLivestreamActivityRequest struct {
Name string `json:"name" binding:"required"`
StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"`
DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型: flip_card/minesweeper
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量: 1-100
TicketPrice int64 `json:"ticket_price"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
}
type livestreamActivityResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"`
AccessCode string `json:"access_code"`
DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
OrderRewardQuantity int32 `json:"order_reward_quantity"` // 下单奖励数量
TicketPrice int64 `json:"ticket_price"`
Status int32 `json:"status"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
CreatedAt string `json:"created_at"`
}
// CreateLivestreamActivity 创建直播间活动
// @Summary 创建直播间活动
// @Description 创建新的直播间活动,自动生成唯一访问码
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param RequestBody body createLivestreamActivityRequest true "请求参数"
// @Success 200 {object} livestreamActivityResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities [post]
// @Security LoginVerifyToken
func (h *handler) CreateLivestreamActivity() core.HandlerFunc {
return func(ctx core.Context) {
req := new(createLivestreamActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
input := livestream.CreateActivityInput{
Name: req.Name,
StreamerName: req.StreamerName,
StreamerContact: req.StreamerContact,
DouyinProductID: req.DouyinProductID,
OrderRewardType: req.OrderRewardType,
OrderRewardQuantity: req.OrderRewardQuantity,
TicketPrice: req.TicketPrice,
}
if req.StartTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.StartTime, time.Local); err == nil {
input.StartTime = &t
}
}
if req.EndTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local); err == nil {
input.EndTime = &t
}
}
activity, err := h.livestream.CreateActivity(ctx.RequestContext(), input)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(&livestreamActivityResponse{
ID: activity.ID,
Name: activity.Name,
StreamerName: activity.StreamerName,
StreamerContact: activity.StreamerContact,
AccessCode: activity.AccessCode,
DouyinProductID: activity.DouyinProductID,
OrderRewardType: activity.OrderRewardType,
OrderRewardQuantity: activity.OrderRewardQuantity,
TicketPrice: int64(activity.TicketPrice),
Status: activity.Status,
CreatedAt: activity.CreatedAt.Format("2006-01-02 15:04:05"),
})
}
}
type updateLivestreamActivityRequest struct {
Name string `json:"name"`
StreamerName string `json:"streamer_name"`
StreamerContact string `json:"streamer_contact"`
DouyinProductID string `json:"douyin_product_id"`
OrderRewardType string `json:"order_reward_type"` // 下单奖励类型
OrderRewardQuantity *int32 `json:"order_reward_quantity"` // 下单奖励数量
TicketPrice *int64 `json:"ticket_price"`
Status *int32 `json:"status"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
}
// UpdateLivestreamActivity 更新直播间活动
// @Summary 更新直播间活动
// @Description 更新直播间活动信息
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param id path integer true "活动ID"
// @Param RequestBody body updateLivestreamActivityRequest true "请求参数"
// @Success 200 {object} simpleMessageResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{id} [put]
// @Security LoginVerifyToken
func (h *handler) UpdateLivestreamActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
req := new(updateLivestreamActivityRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
input := livestream.UpdateActivityInput{
Name: req.Name,
StreamerName: req.StreamerName,
StreamerContact: req.StreamerContact,
DouyinProductID: req.DouyinProductID,
OrderRewardType: req.OrderRewardType,
OrderRewardQuantity: req.OrderRewardQuantity,
TicketPrice: req.TicketPrice,
Status: req.Status,
}
if req.StartTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.StartTime, time.Local); err == nil {
input.StartTime = &t
}
}
if req.EndTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local); err == nil {
input.EndTime = &t
}
}
if err := h.livestream.UpdateActivity(ctx.RequestContext(), id, input); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(&simpleMessageResponse{Message: "操作成功"})
}
}
type listLivestreamActivitiesRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Status *int32 `form:"status"`
}
type listLivestreamActivitiesResponse struct {
List []livestreamActivityResponse `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// ListLivestreamActivities 直播间活动列表
// @Summary 直播间活动列表
// @Description 获取直播间活动列表
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Param status query int false "状态过滤"
// @Success 200 {object} listLivestreamActivitiesResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities [get]
// @Security LoginVerifyToken
func (h *handler) ListLivestreamActivities() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listLivestreamActivitiesRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
list, total, err := h.livestream.ListActivities(ctx.RequestContext(), req.Page, req.PageSize, req.Status)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
res := &listLivestreamActivitiesResponse{
List: make([]livestreamActivityResponse, len(list)),
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}
for i, a := range list {
item := livestreamActivityResponse{
ID: a.ID,
Name: a.Name,
StreamerName: a.StreamerName,
StreamerContact: a.StreamerContact,
AccessCode: a.AccessCode,
DouyinProductID: a.DouyinProductID,
OrderRewardType: a.OrderRewardType,
OrderRewardQuantity: a.OrderRewardQuantity,
TicketPrice: int64(a.TicketPrice),
Status: a.Status,
CreatedAt: a.CreatedAt.Format("2006-01-02 15:04:05"),
}
if !a.StartTime.IsZero() {
item.StartTime = a.StartTime.Format("2006-01-02 15:04:05")
}
if !a.EndTime.IsZero() {
item.EndTime = a.EndTime.Format("2006-01-02 15:04:05")
}
res.List[i] = item
}
ctx.Payload(res)
}
}
// GetLivestreamActivity 获取直播间活动详情
// @Summary 获取直播间活动详情
// @Description 根据ID获取直播间活动详情
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param id path integer true "活动ID"
// @Success 200 {object} livestreamActivityResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{id} [get]
// @Security LoginVerifyToken
func (h *handler) GetLivestreamActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
activity, err := h.livestream.GetActivity(ctx.RequestContext(), id)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
return
}
res := &livestreamActivityResponse{
ID: activity.ID,
Name: activity.Name,
StreamerName: activity.StreamerName,
StreamerContact: activity.StreamerContact,
AccessCode: activity.AccessCode,
DouyinProductID: activity.DouyinProductID,
OrderRewardType: activity.OrderRewardType,
OrderRewardQuantity: activity.OrderRewardQuantity,
TicketPrice: int64(activity.TicketPrice),
Status: activity.Status,
CreatedAt: activity.CreatedAt.Format("2006-01-02 15:04:05"),
}
if !activity.StartTime.IsZero() {
res.StartTime = activity.StartTime.Format("2006-01-02 15:04:05")
}
if !activity.EndTime.IsZero() {
res.EndTime = activity.EndTime.Format("2006-01-02 15:04:05")
}
ctx.Payload(res)
}
}
// DeleteLivestreamActivity 删除直播间活动
// @Summary 删除直播间活动
// @Description 删除指定直播间活动
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param id path integer true "活动ID"
// @Success 200 {object} simpleMessageResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{id} [delete]
// @Security LoginVerifyToken
func (h *handler) DeleteLivestreamActivity() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
if err := h.livestream.DeleteActivity(ctx.RequestContext(), id); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(&simpleMessageResponse{Message: "删除成功"})
}
}
// ========== 直播间奖品管理 ==========
type createLivestreamPrizeRequest struct {
Name string `json:"name"`
Image string `json:"image"`
Level int32 `json:"level"`
Weight int32 `json:"weight"`
Quantity int32 `json:"quantity"`
ProductID int64 `json:"product_id"`
CostPrice int64 `json:"cost_price"`
}
type livestreamPrizeResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Level int32 `json:"level"`
Weight int32 `json:"weight"`
Quantity int32 `json:"quantity"`
Remaining int64 `json:"remaining"`
ProductID int64 `json:"product_id"`
CostPrice int64 `json:"cost_price"`
Sort int32 `json:"sort"`
}
// CreateLivestreamPrizes 批量创建奖品
// @Summary 批量创建直播间奖品
// @Description 为指定活动批量创建奖品
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param activity_id path integer true "活动ID"
// @Param RequestBody body []createLivestreamPrizeRequest true "奖品列表"
// @Success 200 {object} simpleMessageResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{activity_id}/prizes [post]
// @Security LoginVerifyToken
func (h *handler) CreateLivestreamPrizes() core.HandlerFunc {
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || activityID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
var req []createLivestreamPrizeRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
var inputs []livestream.CreatePrizeInput
for _, p := range req {
inputs = append(inputs, livestream.CreatePrizeInput{
Name: p.Name,
Image: p.Image,
Weight: p.Weight,
Quantity: p.Quantity,
Level: p.Level,
ProductID: p.ProductID,
CostPrice: p.CostPrice,
})
}
if err := h.livestream.CreatePrizes(ctx.RequestContext(), activityID, inputs); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(&simpleMessageResponse{Message: "创建成功"})
}
}
// ListLivestreamPrizes 获取活动奖品列表
// @Summary 获取直播间活动奖品列表
// @Description 获取指定活动的所有奖品
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param activity_id path integer true "活动ID"
// @Success 200 {object} []livestreamPrizeResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{activity_id}/prizes [get]
// @Security LoginVerifyToken
func (h *handler) ListLivestreamPrizes() core.HandlerFunc {
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || activityID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
prizes, err := h.livestream.ListPrizes(ctx.RequestContext(), activityID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
res := make([]livestreamPrizeResponse, len(prizes))
for i, p := range prizes {
res[i] = livestreamPrizeResponse{
ID: p.ID,
Name: p.Name,
Image: p.Image,
Weight: p.Weight,
Quantity: p.Quantity,
Remaining: int64(p.Remaining),
Level: p.Level,
ProductID: p.ProductID,
CostPrice: p.CostPrice,
Sort: p.Sort,
}
}
ctx.Payload(res)
}
}
// DeleteLivestreamPrize 删除奖品
// @Summary 删除直播间奖品
// @Description 删除指定奖品
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param prize_id path integer true "奖品ID"
// @Success 200 {object} simpleMessageResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/prizes/{prize_id} [delete]
// @Security LoginVerifyToken
func (h *handler) DeleteLivestreamPrize() core.HandlerFunc {
return func(ctx core.Context) {
prizeID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || prizeID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的奖品ID"))
return
}
if err := h.livestream.DeletePrize(ctx.RequestContext(), prizeID); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(&simpleMessageResponse{Message: "删除成功"})
}
}
// ========== 直播间中奖记录 ==========
type livestreamDrawLogResponse struct {
ID int64 `json:"id"`
ActivityID int64 `json:"activity_id"`
PrizeID int64 `json:"prize_id"`
PrizeName string `json:"prize_name"`
Level int32 `json:"level"`
DouyinOrderID int64 `json:"douyin_order_id"` // 关联ID
ShopOrderID string `json:"shop_order_id"` // 店铺订单号
LocalUserID int64 `json:"local_user_id"`
DouyinUserID string `json:"douyin_user_id"`
UserNickname string `json:"user_nickname"` // 用户昵称
SeedHash string `json:"seed_hash"`
CreatedAt string `json:"created_at"`
}
type listLivestreamDrawLogsResponse struct {
List []livestreamDrawLogResponse `json:"list"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Stats *livestreamDrawLogsStats `json:"stats,omitempty"`
}
type livestreamDrawLogsStats struct {
UserCount int64 `json:"user_count"`
OrderCount int64 `json:"order_count"`
TotalRev int64 `json:"total_revenue"` // 总流水
TotalRefund int64 `json:"total_refund"`
TotalCost int64 `json:"total_cost"`
NetProfit int64 `json:"net_profit"`
}
type listLivestreamDrawLogsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
StartTime string `form:"start_time"`
EndTime string `form:"end_time"`
Keyword string `form:"keyword"`
ExcludeUserIDs string `form:"exclude_user_ids"` // 逗号分隔的 UserIDs
}
// ListLivestreamDrawLogs 获取中奖记录
// @Summary 获取直播间中奖记录
// @Description 获取指定活动的中奖记录,支持时间范围和关键词筛选
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param activity_id path integer true "活动ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Param start_time query string false "开始时间 (YYYY-MM-DD)"
// @Param end_time query string false "结束时间 (YYYY-MM-DD)"
// @Param keyword query string false "搜索关键词 (昵称/订单号/奖品名称)"
// @Success 200 {object} listLivestreamDrawLogsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{activity_id}/draw_logs [get]
// @Security LoginVerifyToken
func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
return func(ctx core.Context) {
activityID, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || activityID <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
req := new(listLivestreamDrawLogsRequest)
_ = ctx.ShouldBindForm(req)
page := req.Page
pageSize := req.PageSize
if page <= 0 {
page = 1
}
if pageSize <= 0 {
pageSize = 20
}
// 解析时间范围 (支持 YYYY-MM-DD HH:mm:ss 和 YYYY-MM-DD)
var startTime, endTime *time.Time
if req.StartTime != "" {
// 尝试解析完整时间
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.StartTime, time.Local); err == nil {
startTime = &t
} else if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil {
// 只有日期,默认 00:00:00
startTime = &t
}
}
if req.EndTime != "" {
if t, err := time.ParseInLocation("2006-01-02 15:04:05", req.EndTime, time.Local); err == nil {
endTime = &t
} else if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil {
// 只有日期,设为当天结束 23:59:59.999
end := t.Add(24*time.Hour - time.Nanosecond)
endTime = &end
}
}
// 解析排除用户ID
var excludeUIDs []int64
if req.ExcludeUserIDs != "" {
parts := strings.Split(req.ExcludeUserIDs, ",")
for _, p := range parts {
p = strings.TrimSpace(p)
if val, err := strconv.ParseInt(p, 10, 64); err == nil && val > 0 {
excludeUIDs = append(excludeUIDs, val)
}
}
}
// 使用底层 GORM 直接查询以支持 keyword
db := h.repo.GetDbR().Model(&model.LivestreamDrawLogs{}).Where("activity_id = ?", activityID)
if startTime != nil {
db = db.Where("created_at >= ?", startTime)
}
if endTime != nil {
db = db.Where("created_at <= ?", endTime)
}
if req.Keyword != "" {
keyword := "%" + req.Keyword + "%"
db = db.Where("(user_nickname LIKE ? OR shop_order_id LIKE ? OR prize_name LIKE ?)", keyword, keyword, keyword)
}
if len(excludeUIDs) > 0 {
db = db.Where("local_user_id NOT IN ?", excludeUIDs)
}
var total int64
db.Count(&total)
// 计算统计数据 (仅当有数据时)
var stats *livestreamDrawLogsStats
if total > 0 {
stats = &livestreamDrawLogsStats{}
// 1. 统计用户数
// 使用 Session() 避免污染主 db 对象
db.Session(&gorm.Session{}).Select("COUNT(DISTINCT douyin_user_id)").Scan(&stats.UserCount)
// 2. 获取所有相关的 douyin_order_id 和 prize_id用于在内存中聚合金额和成本
// 注意:如果数据量极大,这里可能有性能隐患。但考虑到这是后台查询且通常带有筛选,暂且全量拉取 ID。
// 优化:只查需要的字段
type logMeta struct {
DouyinOrderID int64
PrizeID int64
ShopOrderID string // 用于关联退款状态查 douyin_orders
}
var metas []logMeta
// 使用不带分页的 db 克隆
if err := db.Session(&gorm.Session{}).Select("douyin_order_id, prize_id, shop_order_id").Scan(&metas).Error; err == nil {
orderIDs := make([]int64, 0, len(metas))
distinctOrderIDs := make(map[int64]bool)
prizeIDCount := make(map[int64]int64)
for _, m := range metas {
if !distinctOrderIDs[m.DouyinOrderID] {
distinctOrderIDs[m.DouyinOrderID] = true
orderIDs = append(orderIDs, m.DouyinOrderID)
}
}
stats.OrderCount = int64(len(orderIDs))
// 3. 查询订单金额和退款状态
if len(orderIDs) > 0 {
var orders []model.DouyinOrders
// 分批查询防止 IN 子句过长? 暂时假设量级可控
h.repo.GetDbR().Select("id, actual_pay_amount, order_status").
Where("id IN ?", orderIDs).Find(&orders)
orderRefundMap := make(map[int64]bool)
for _, o := range orders {
// 统计营收 (总流水)
stats.TotalRev += int64(o.ActualPayAmount)
if o.OrderStatus == 4 { // 已退款
stats.TotalRefund += int64(o.ActualPayAmount)
orderRefundMap[o.ID] = true
}
}
// 4. 统计成本 (剔除退款订单)
for _, m := range metas {
if !orderRefundMap[m.DouyinOrderID] {
prizeIDCount[m.PrizeID]++
}
}
// 计算奖品成本 (逻辑参考 GetLivestreamStats简化版)
if len(prizeIDCount) > 0 {
prizeIDs := make([]int64, 0, len(prizeIDCount))
for pid := range prizeIDCount {
prizeIDs = append(prizeIDs, pid)
}
var prizes []model.LivestreamPrizes
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes)
// 批量获取关联商品
productIDs := make([]int64, 0)
for _, p := range prizes {
if p.CostPrice == 0 && p.ProductID > 0 {
productIDs = append(productIDs, p.ProductID)
}
}
productPriceMap := make(map[int64]int64)
if len(productIDs) > 0 {
var products []model.Products
h.repo.GetDbR().Select("id, price").Where("id IN ?", productIDs).Find(&products)
for _, prod := range products {
productPriceMap[prod.ID] = prod.Price
}
}
for _, p := range prizes {
cost := p.CostPrice
if cost == 0 && p.ProductID > 0 {
cost = productPriceMap[p.ProductID]
}
count := prizeIDCount[p.ID]
stats.TotalCost += cost * count
}
}
}
}
stats.NetProfit = (stats.TotalRev - stats.TotalRefund) - stats.TotalCost
}
var logs []model.LivestreamDrawLogs
// 重置 Select确保查询 logs 时获取所有字段 (或者指定 default fields)
// db 对象如果被污染,这里需要显式清除 Select。使用 Session 应该能避免。
// 安全起见,这里也可以用 db.Session(&gorm.Session{})
if err := db.Session(&gorm.Session{}).Order("id DESC").Offset((page - 1) * pageSize).Limit(pageSize).Find(&logs).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
res := &listLivestreamDrawLogsResponse{
List: make([]livestreamDrawLogResponse, len(logs)),
Total: total,
Page: page,
PageSize: pageSize,
Stats: stats,
}
for i, log := range logs {
res.List[i] = livestreamDrawLogResponse{
ID: log.ID,
ActivityID: log.ActivityID,
PrizeID: log.PrizeID,
PrizeName: log.PrizeName,
Level: log.Level,
DouyinOrderID: log.DouyinOrderID,
ShopOrderID: log.ShopOrderID,
LocalUserID: log.LocalUserID,
DouyinUserID: log.DouyinUserID,
UserNickname: log.UserNickname,
SeedHash: log.SeedHash,
CreatedAt: log.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
ctx.Payload(res)
}
}
// ========== 直播间承诺管理 ==========
type livestreamCommitmentSummaryResponse struct {
SeedVersion int32 `json:"seed_version"`
Algo string `json:"algo"`
HasSeed bool `json:"has_seed"`
LenSeed int `json:"len_seed_master"`
LenHash int `json:"len_seed_hash"`
SeedHashHex string `json:"seed_hash_hex"` // 种子哈希的十六进制表示(可公开复制)
}
// GenerateLivestreamCommitment 生成直播间活动承诺
// @Summary 生成直播间活动承诺
// @Description 为直播间活动生成可验证的承诺种子
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param id path integer true "活动ID"
// @Success 200 {object} map[string]int32
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{id}/commitment/generate [post]
// @Security LoginVerifyToken
func (h *handler) GenerateLivestreamCommitment() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
version, err := h.livestream.GenerateCommitment(ctx.RequestContext(), id)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(map[string]int32{"seed_version": version})
}
}
// GetLivestreamCommitmentSummary 获取直播间活动承诺摘要
// @Summary 获取直播间活动承诺摘要
// @Description 获取直播间活动的承诺状态信息
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param id path integer true "活动ID"
// @Success 200 {object} livestreamCommitmentSummaryResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{id}/commitment/summary [get]
// @Security LoginVerifyToken
func (h *handler) GetLivestreamCommitmentSummary() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
summary, err := h.livestream.GetCommitmentSummary(ctx.RequestContext(), id)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(&livestreamCommitmentSummaryResponse{
SeedVersion: summary.SeedVersion,
Algo: summary.Algo,
HasSeed: summary.HasSeed,
LenSeed: summary.LenSeed,
LenHash: summary.LenHash,
SeedHashHex: summary.SeedHashHex,
})
}
}

View File

@ -0,0 +1,333 @@
package admin
import (
"math"
"net/http"
"strconv"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/repository/mysql/model"
"time"
)
type dailyLivestreamStats struct {
Date string `json:"date"` // 日期
TotalRevenue int64 `json:"total_revenue"` // 营收
TotalRefund int64 `json:"total_refund"` // 退款
TotalCost int64 `json:"total_cost"` // 成本
NetProfit int64 `json:"net_profit"` // 净利润
ProfitMargin float64 `json:"profit_margin"` // 利润率
OrderCount int64 `json:"order_count"` // 订单数
RefundCount int64 `json:"refund_count"` // 退款单数
}
type livestreamStatsResponse struct {
TotalRevenue int64 `json:"total_revenue"` // 总营收(分)
TotalRefund int64 `json:"total_refund"` // 总退款(分)
TotalCost int64 `json:"total_cost"` // 总成本(分)
NetProfit int64 `json:"net_profit"` // 净利润(分)
OrderCount int64 `json:"order_count"` // 订单数
RefundCount int64 `json:"refund_count"` // 退款数
ProfitMargin float64 `json:"profit_margin"` // 利润率 %
Daily []dailyLivestreamStats `json:"daily"` // 每日明细
}
// GetLivestreamStats 获取直播间盈亏统计
// @Summary 获取直播间盈亏统计
// @Description 计算逻辑:净利润 = (营收 - 退款) - 奖品成本。营收 = 抽奖次数 * 门票价格。成本 = 中奖奖品成本总和。
// @Tags 管理端.直播间
// @Accept json
// @Produce json
// @Param id path integer true "活动ID"
// @Success 200 {object} livestreamStatsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/livestream/activities/{id}/stats [get]
// @Security LoginVerifyToken
func (h *handler) GetLivestreamStats() core.HandlerFunc {
return func(ctx core.Context) {
id, err := strconv.ParseInt(ctx.Param("id"), 10, 64)
if err != nil || id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的活动ID"))
return
}
req := new(struct {
StartTime string `form:"start_time"`
EndTime string `form:"end_time"`
})
_ = ctx.ShouldBindQuery(req)
var startTime, endTime *time.Time
if req.StartTime != "" {
if t, err := time.ParseInLocation("2006-01-02", req.StartTime, time.Local); err == nil {
startTime = &t
}
}
if req.EndTime != "" {
if t, err := time.ParseInLocation("2006-01-02", req.EndTime, time.Local); err == nil {
end := t.Add(24*time.Hour - time.Nanosecond)
endTime = &end
}
}
// 1. 获取活动信息(门票价格)
var activity model.LivestreamActivities
if err := h.repo.GetDbR().Where("id = ?", id).First(&activity).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, code.ServerError, "活动不存在"))
return
}
// ticketPrice 暂未使用,但在统计中可能作为参考,这里移除未使用的报错
// 2. 核心统计逻辑重构:从关联的订单表获取真实金额
// 使用子查询或 Join 来获取不重复的订单金额,确保即便一次订单对应多次抽奖,金额也不重计
var totalRevenue, orderCount int64
// 统计营收:来自已参与过抽奖(产生过日志)且未退款的订单 (order_status != 4)
// 使用 actual_pay_amount (实付金额)
queryRevenue := `
SELECT
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as rev,
COUNT(*) as cnt
FROM (
SELECT DISTINCT o.id, o.actual_pay_amount
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ?
`
if startTime != nil {
queryRevenue += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
}
if endTime != nil {
queryRevenue += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
queryRevenue += ") as distinct_orders"
_ = h.repo.GetDbR().Raw(queryRevenue, id).Row().Scan(&totalRevenue, &orderCount)
// 统计退款:来自已参与过抽奖且标记为退款的订单 (order_status = 4)
var totalRefund, refundCount int64
queryRefund := `
SELECT
CAST(IFNULL(SUM(distinct_orders.actual_pay_amount), 0) AS SIGNED) as ref_amt,
COUNT(*) as ref_cnt
FROM (
SELECT DISTINCT o.id, o.actual_pay_amount
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ? AND o.order_status = 4
`
if startTime != nil {
queryRefund += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
}
if endTime != nil {
queryRefund += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
queryRefund += ") as distinct_orders"
_ = h.repo.GetDbR().Raw(queryRefund, id).Row().Scan(&totalRefund, &refundCount)
// 3. 获取所有抽奖记录用于成本计算
var drawLogs []model.LivestreamDrawLogs
db := h.repo.GetDbR().Where("activity_id = ?", id)
if startTime != nil {
db = db.Where("created_at >= ?", startTime)
}
if endTime != nil {
db = db.Where("created_at <= ?", endTime)
}
db.Find(&drawLogs)
// 3.1 获取该时间段内所有退款的 shop_order_id 集合,用于过滤成本
refundedShopOrderIDs := make(map[string]bool)
var refundedOrders []string
qRefundIDs := `
SELECT DISTINCT o.shop_order_id
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ? AND o.order_status = 4
`
if startTime != nil {
qRefundIDs += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
}
if endTime != nil {
qRefundIDs += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
h.repo.GetDbR().Raw(qRefundIDs, id).Scan(&refundedOrders)
for _, oid := range refundedOrders {
refundedShopOrderIDs[oid] = true
}
// 4. 计算成本(只统计未退款订单的奖品成本)
prizeIDCountMap := make(map[int64]int64)
for _, log := range drawLogs {
// 排除已退款的订单 (检查 douyin_orders 状态)
if refundedShopOrderIDs[log.ShopOrderID] {
continue
}
prizeIDCountMap[log.PrizeID]++
}
prizeIDs := make([]int64, 0, len(prizeIDCountMap))
for pid := range prizeIDCountMap {
prizeIDs = append(prizeIDs, pid)
}
var totalCost int64
prizeCostMap := make(map[int64]int64)
if len(prizeIDs) > 0 {
var prizes []model.LivestreamPrizes
h.repo.GetDbR().Where("id IN ?", prizeIDs).Find(&prizes)
productIDsNeedingFallback := make([]int64, 0)
prizeProductMap := make(map[int64]int64)
for _, p := range prizes {
if p.CostPrice > 0 {
prizeCostMap[p.ID] = p.CostPrice
} else if p.ProductID > 0 {
productIDsNeedingFallback = append(productIDsNeedingFallback, p.ProductID)
prizeProductMap[p.ID] = p.ProductID
}
}
if len(productIDsNeedingFallback) > 0 {
var products []model.Products
h.repo.GetDbR().Where("id IN ?", productIDsNeedingFallback).Find(&products)
productPriceMap := make(map[int64]int64)
for _, prod := range products {
productPriceMap[prod.ID] = prod.Price
}
for prizeID, productID := range prizeProductMap {
if _, ok := prizeCostMap[prizeID]; !ok {
if price, found := productPriceMap[productID]; found {
prizeCostMap[prizeID] = price
}
}
}
}
for prizeID, count := range prizeIDCountMap {
if cost, ok := prizeCostMap[prizeID]; ok {
totalCost += cost * count
}
}
}
// 5. 按天分组统计
dailyMap := make(map[string]*dailyLivestreamStats)
// 5.1 统计每日营收和退款(直接累加订单实付金额)
type DailyAmount struct {
DateKey string
Amount int64
Count int64
IsRefunded int32
}
var dailyAmounts []DailyAmount
queryDailyCorrect := `
SELECT
date_key,
CAST(SUM(actual_pay_amount) AS SIGNED) as amount,
COUNT(id) as cnt,
refund_flag as is_refunded
FROM (
SELECT
o.id,
DATE_FORMAT(MIN(l.created_at), '%Y-%m-%d') as date_key,
o.actual_pay_amount,
IF(o.order_status = 4, 1, 0) as refund_flag
FROM douyin_orders o
JOIN livestream_draw_logs l ON o.id = l.douyin_order_id
WHERE l.activity_id = ?
`
if startTime != nil {
queryDailyCorrect += " AND l.created_at >= '" + startTime.Format("2006-01-02 15:04:05") + "'"
}
if endTime != nil {
queryDailyCorrect += " AND l.created_at <= '" + endTime.Format("2006-01-02 15:04:05") + "'"
}
queryDailyCorrect += `
GROUP BY o.id
) as t
GROUP BY date_key, is_refunded
`
rows, _ := h.repo.GetDbR().Raw(queryDailyCorrect, id).Rows()
defer rows.Close()
for rows.Next() {
var da DailyAmount
_ = rows.Scan(&da.DateKey, &da.Amount, &da.Count, &da.IsRefunded)
dailyAmounts = append(dailyAmounts, da)
}
for _, da := range dailyAmounts {
if _, ok := dailyMap[da.DateKey]; !ok {
dailyMap[da.DateKey] = &dailyLivestreamStats{Date: da.DateKey}
}
// 修正口径:营收(Revenue) = 总流水 (含退款与未退款)
// 这样下面的 NetProfit = TotalRevenue - TotalRefund - TotalCost 才不会双重扣除
dailyMap[da.DateKey].TotalRevenue += da.Amount
dailyMap[da.DateKey].OrderCount += da.Count
if da.IsRefunded == 1 {
dailyMap[da.DateKey].TotalRefund += da.Amount
dailyMap[da.DateKey].RefundCount += da.Count
}
}
// 5.2 统计每日成本(基于 Logs
for _, log := range drawLogs {
// 排除退款订单
if refundedShopOrderIDs[log.ShopOrderID] {
continue
}
dateKey := log.CreatedAt.Format("2006-01-02")
ds := dailyMap[dateKey]
if ds != nil {
if cost, ok := prizeCostMap[log.PrizeID]; ok {
ds.TotalCost += cost
}
}
}
// 6. 汇总每日数据并计算总体指标
var calcTotalRevenue, calcTotalRefund, calcTotalCost int64
dailyList := make([]dailyLivestreamStats, 0, len(dailyMap))
for _, ds := range dailyMap {
ds.NetProfit = (ds.TotalRevenue - ds.TotalRefund) - ds.TotalCost
netRev := ds.TotalRevenue - ds.TotalRefund
if netRev > 0 {
ds.ProfitMargin = math.Trunc(float64(ds.NetProfit)/float64(netRev)*10000) / 100
} else if netRev == 0 && ds.TotalCost > 0 {
ds.ProfitMargin = -100
}
dailyList = append(dailyList, *ds)
calcTotalRevenue += ds.TotalRevenue
calcTotalRefund += ds.TotalRefund
calcTotalCost += ds.TotalCost
}
netProfit := (totalRevenue - totalRefund) - totalCost
var margin float64
netRevenue := totalRevenue - totalRefund
if netRevenue > 0 {
margin = float64(netProfit) / float64(netRevenue) * 100
} else if netRevenue == 0 && totalCost > 0 {
margin = -100
} else {
margin = 0
}
ctx.Payload(&livestreamStatsResponse{
TotalRevenue: totalRevenue,
TotalRefund: totalRefund,
TotalCost: totalCost,
NetProfit: netProfit,
OrderCount: orderCount,
RefundCount: refundCount,
ProfitMargin: math.Trunc(margin*100) / 100,
Daily: dailyList,
})
}
}

View File

@ -181,6 +181,8 @@ func (h *handler) SettleIssue() core.HandlerFunc {
if refundCouponID > 0 {
_ = usersvc.New(h.logger, h.repo).AddCoupon(ctx.RequestContext(), o.UserID, refundCouponID)
}
// 增加一番赏位置恢复
_ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), o.ID)
refunded++
}
}

View File

@ -1,15 +1,15 @@
package admin
import (
"encoding/base64"
"net/http"
"net/url"
"encoding/base64"
"net/http"
"net/url"
"bindbox-game/configs"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/service/sysconfig"
)
type miniappQRCodeRequest struct {
@ -30,7 +30,7 @@ func (h *handler) GenerateMiniAppQRCode() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
q := url.Values{}
if req.InviteCode != "" {
q.Set("invite_code", req.InviteCode)
@ -41,15 +41,19 @@ func (h *handler) GenerateMiniAppQRCode() core.HandlerFunc {
if req.ChannelCode != "" {
q.Set("channel_code", req.ChannelCode)
}
if len(q) == 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "参数不能为空"))
return
}
path := "/pages/login/index?" + q.Encode()
cfg := configs.Get()
wxcfg := &wechat.WechatConfig{AppID: cfg.Wechat.AppID, AppSecret: cfg.Wechat.AppSecret}
// 使用动态配置
dc := sysconfig.GetDynamicConfig()
wxConfig := dc.GetWechat(ctx.RequestContext())
wxcfg := &wechat.WechatConfig{AppID: wxConfig.AppID, AppSecret: wxConfig.AppSecret}
qReq := &wechat.QRCodeRequest{Path: path}
if req.Width != nil {
qReq.Width = *req.Width
@ -62,4 +66,4 @@ func (h *handler) GenerateMiniAppQRCode() core.HandlerFunc {
out := &miniappQRCodeResponse{ImageBase64: base64.StdEncoding.EncodeToString(rsp.Buffer)}
ctx.Payload(out)
}
}
}

View File

@ -277,12 +277,23 @@ func (h *handler) ListPayOrders() core.HandlerFunc {
}
}
type adminOrderPointsLedgerItem struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Action string `json:"action"`
Points int64 `json:"points"`
RefTable string `json:"ref_table"`
RefID string `json:"ref_id"`
Remark string `json:"remark"`
CreatedAt string `json:"created_at"`
}
type getPayOrderResponse struct {
Order *model.Orders `json:"order"`
Items []*model.OrderItems `json:"items"`
Shipments []*model.ShippingRecords `json:"shipments"`
Ledgers []*model.UserPointsLedger `json:"ledgers"`
User *model.Users `json:"user"`
Order *model.Orders `json:"order"`
Items []*model.OrderItems `json:"items"`
Shipments []*model.ShippingRecords `json:"shipments"`
Ledgers []adminOrderPointsLedgerItem `json:"ledgers"`
User *model.Users `json:"user"`
Coupons []*struct {
UserCouponID int64 `json:"user_coupon_id"`
AppliedAmount int64 `json:"applied_amount"`
@ -644,7 +655,19 @@ func (h *handler) GetPayOrderDetail() core.HandlerFunc {
rsp.Items = items
rsp.Coupons = couponList
rsp.Shipments = shipments
rsp.Ledgers = ledgers
rsp.Ledgers = make([]adminOrderPointsLedgerItem, len(ledgers))
for i, l := range ledgers {
rsp.Ledgers[i] = adminOrderPointsLedgerItem{
ID: l.ID,
UserID: l.UserID,
Action: l.Action,
Points: int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), l.Points)),
RefTable: l.RefTable,
RefID: l.RefID,
Remark: l.Remark,
CreatedAt: l.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
if order.SourceType == 2 {
unit := int64(0)
if count > 0 {

View File

@ -2,7 +2,6 @@ package admin
import (
"bytes"
"fmt"
"net/http"
"time"
@ -50,14 +49,7 @@ func (h *handler) ExportPayOrders() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 23001, err.Error()))
return
}
var pointsRate int64 = 1
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
}
}
file := xlsx.NewFile()
sheet, _ := file.AddSheet("orders")
header := []string{"订单号", "用户ID", "来源", "状态", "总金额", "折扣", "积分抵扣(分)", "积分抵扣(积分)", "优惠券抵扣(分)", "实付", "支付时间", "创建时间"}
@ -76,13 +68,17 @@ func (h *handler) ExportPayOrders() core.HandlerFunc {
}
pa := o.PointsAmount
if pa == 0 && consumePointsSum > 0 {
pa = consumePointsSum / pointsRate
{
// Backwards compatibility if o.PointsAmount is missing
// If consumePointsSum is Cents, pa is Cents.
pa = consumePointsSum
}
}
pu := int64(0)
if consumePointsSum > 0 {
pu = consumePointsSum
} else if pa > 0 {
pu = pa * pointsRate
// pu is Points Unit
pu := int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), consumePointsSum))
if pu == 0 && pa > 0 {
// If no ledger, try converting from Cents
pu = int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), pa))
}
ocs, _ := h.readDB.OrderCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.OrderCoupons.OrderID.Eq(o.ID)).Find()
var couponApplied int64

View File

@ -168,6 +168,9 @@ func (h *handler) CreateRefund() core.HandlerFunc {
// 全额退款:回收中奖资产与奖品库存(包含已兑换积分的资产)
svc := usersvc.New(h.logger, h.repo)
// 直接使用已初始化的 activity service 清理格位
_ = h.activity.ClearIchibanPositionsByOrderID(ctx.RequestContext(), order.ID)
var pointsShortage bool
for _, inv := range allInvs {
if inv.Status == 1 {
@ -236,17 +239,33 @@ func (h *handler) CreateRefund() core.HandlerFunc {
}
// 全额退款回退次数卡user_game_passes
// 解析订单 remark 中的 game_pass:xxx ID
reGamePass := regexp.MustCompile(`game_pass:(\d+)`)
gamePassMatches := reGamePass.FindStringSubmatch(order.Remark)
if len(gamePassMatches) > 1 {
gamePassID, _ := strconv.ParseInt(gamePassMatches[1], 10, 64)
if gamePassID > 0 {
// 恢复次数卡remaining +1, total_used -1
if err := h.repo.GetDbW().Exec("UPDATE user_game_passes SET remaining = remaining + 1, total_used = GREATEST(total_used - 1, 0), updated_at = NOW(3) WHERE id = ?", gamePassID).Error; err != nil {
h.logger.Error(fmt.Sprintf("refund restore game_pass failed: order=%s game_pass_id=%d err=%v", order.OrderNo, gamePassID, err))
} else {
h.logger.Info(fmt.Sprintf("refund restore game_pass success: order=%s game_pass_id=%d", order.OrderNo, gamePassID))
// 优先解析新格式: gp_use:ID:Count (支持多张卡、多数量)
reGpNew := regexp.MustCompile(`gp_use:(\d+):(\d+)`)
matchesNew := reGpNew.FindAllStringSubmatch(order.Remark, -1)
if len(matchesNew) > 0 {
for _, m := range matchesNew {
gpID, _ := strconv.ParseInt(m[1], 10, 64)
gpCount, _ := strconv.ParseInt(m[2], 10, 64)
if gpID > 0 && gpCount > 0 {
if err := h.repo.GetDbW().Exec("UPDATE user_game_passes SET remaining = remaining + ?, total_used = GREATEST(total_used - ?, 0), updated_at = NOW(3) WHERE id = ?", gpCount, gpCount, gpID).Error; err != nil {
h.logger.Error(fmt.Sprintf("refund restore game_pass failed: order=%s gp_id=%d count=%d err=%v", order.OrderNo, gpID, gpCount, err))
} else {
h.logger.Info(fmt.Sprintf("refund restore game_pass success: order=%s gp_id=%d count=%d", order.OrderNo, gpID, gpCount))
}
}
}
} else {
// 兼容旧格式: game_pass:ID (仅恢复 1 次)
reGamePass := regexp.MustCompile(`game_pass:(\d+)`)
gamePassMatches := reGamePass.FindStringSubmatch(order.Remark)
if len(gamePassMatches) > 1 {
gamePassID, _ := strconv.ParseInt(gamePassMatches[1], 10, 64)
if gamePassID > 0 {
if err := h.repo.GetDbW().Exec("UPDATE user_game_passes SET remaining = remaining + 1, total_used = GREATEST(total_used - 1, 0), updated_at = NOW(3) WHERE id = ?", gamePassID).Error; err != nil {
h.logger.Error(fmt.Sprintf("refund restore game_pass failed: order=%s game_pass_id=%d err=%v", order.OrderNo, gamePassID, err))
} else {
h.logger.Info(fmt.Sprintf("refund restore game_pass success: order=%s game_pass_id=%d", order.OrderNo, gamePassID))
}
}
}
}

View File

@ -45,6 +45,7 @@ type ShippingOrderGroup struct {
Name string `json:"name"`
Image string `json:"image"`
Price int64 `json:"price"`
Count int64 `json:"count"` // 增加数量字段
} `json:"products"` // 商品详情列表
}
@ -215,29 +216,32 @@ func (h *handler) ListShippingOrders() core.HandlerFunc {
}
}
// 获取商品信息(去重
pidSet := make(map[int64]struct{})
// 获取商品信息(去重并计数
pidCounts := make(map[int64]int64)
for _, pid := range a.pid {
pidSet[pid] = struct{}{}
pidCounts[pid]++
}
var products []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Price int64 `json:"price"`
Count int64 `json:"count"`
}
for pid := range pidSet {
for pid, count := range pidCounts {
if prod, _ := h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Products.ID.Eq(pid)).First(); prod != nil {
products = append(products, struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Price int64 `json:"price"`
Count int64 `json:"count"`
}{
ID: prod.ID,
Name: prod.Name,
Image: prod.ImagesJSON, // 商品图片JSON
Price: prod.Price,
Count: count,
})
}
}

View File

@ -132,21 +132,20 @@ func (h *handler) ModifySystemCoupon() core.HandlerFunc {
}
func (h *handler) DeleteSystemCoupon() core.HandlerFunc {
return func(ctx core.Context) {
idStr := ctx.Param("coupon_id")
id, _ := strconv.ParseInt(idStr, 10, 64)
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
uid := int64(ctx.SessionUserInfo().Id)
set := map[string]any{"deleted_at": time.Now(), "deleted_by": uid}
if _, err := h.writeDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.SystemCoupons.ID.Eq(id)).Updates(set); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
return
}
ctx.Payload(pcSimpleMessage{Message: "操作成功"})
}
return func(ctx core.Context) {
idStr := ctx.Param("coupon_id")
id, _ := strconv.ParseInt(idStr, 10, 64)
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
set := map[string]any{"deleted_at": time.Now()}
if _, err := h.writeDB.SystemCoupons.WithContext(ctx.RequestContext()).Where(h.writeDB.SystemCoupons.ID.Eq(id)).Updates(set); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
return
}
ctx.Payload(pcSimpleMessage{Message: "操作成功"})
}
}
type listSystemCouponsRequest struct {

View File

@ -1,6 +1,7 @@
package admin
import (
"math"
"net/http"
"strconv"
"time"
@ -13,13 +14,15 @@ import (
)
type listUsersRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Nickname string `form:"nickname"`
InviteCode string `form:"inviteCode"`
StartDate string `form:"startDate"`
EndDate string `form:"endDate"`
ID *int64 `form:"id"`
Page int `form:"page"`
PageSize int `form:"page_size"`
Nickname string `form:"nickname"`
InviteCode string `form:"inviteCode"`
StartDate string `form:"startDate"`
EndDate string `form:"endDate"`
ID string `form:"id"`
DouyinID string `form:"douyin_id"`
DouyinUserID string `form:"douyin_user_id"`
}
type listUsersResponse struct {
Page int `json:"page"`
@ -61,13 +64,22 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
if req.PageSize > 100 {
req.PageSize = 100
}
u := h.readDB.Users
c := h.readDB.Channels
q := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.Channels, h.readDB.Channels.ID.EqCol(h.readDB.Users.ChannelID)).
Select(h.readDB.Users.ALL, h.readDB.Channels.Name.As("channel_name"), h.readDB.Channels.Code.As("channel_code"))
LeftJoin(c, c.ID.EqCol(u.ChannelID)).
Select(
u.ALL,
c.Name.As("channel_name"),
c.Code.As("channel_code"),
)
// 应用搜索条件
if req.ID != nil {
q = q.Where(h.readDB.Users.ID.Eq(*req.ID))
if req.ID != "" {
if id, err := strconv.ParseInt(req.ID, 10, 64); err == nil {
q = q.Where(h.readDB.Users.ID.Eq(id))
}
}
if req.Nickname != "" {
q = q.Where(h.readDB.Users.Nickname.Like("%" + req.Nickname + "%"))
@ -87,6 +99,12 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
q = q.Where(h.readDB.Users.CreatedAt.Lte(endTime))
}
}
if req.DouyinID != "" {
q = q.Where(h.readDB.Users.DouyinID.Like("%" + req.DouyinID + "%"))
}
if req.DouyinUserID != "" {
q = q.Where(h.readDB.Users.DouyinUserID.Like("%" + req.DouyinUserID + "%"))
}
total, err := q.Count()
if err != nil {
@ -143,7 +161,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
Group(h.readDB.UserPoints.UserID).
Scan(&bRes)
for _, b := range bRes {
pointBalances[b.UserID] = b.Points
pointBalances[b.UserID] = int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), b.Points))
}
}
@ -169,10 +187,13 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
// 批量查询消费统计
todayConsume := make(map[int64]int64)
sevenDayConsume := make(map[int64]int64)
thirtyDayConsume := make(map[int64]int64)
totalConsume := make(map[int64]int64)
if len(userIDs) > 0 {
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
sevenDayStart := todayStart.AddDate(0, 0, -6) // 包括今天共7天
sevenDayStart := todayStart.AddDate(0, 0, -6)
thirtyDayStart := todayStart.AddDate(0, 0, -29)
type consumeResult struct {
UserID int64
@ -184,7 +205,8 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
Where(h.readDB.Orders.UserID.In(userIDs...)).
Where(h.readDB.Orders.Status.Eq(2)). // 2=已支付
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Gte(todayStart)).
Group(h.readDB.Orders.UserID).
Scan(&todayRes)
@ -197,13 +219,172 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
Where(h.readDB.Orders.UserID.In(userIDs...)).
Where(h.readDB.Orders.Status.Eq(2)). // 2=已支付
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Gte(sevenDayStart)).
Group(h.readDB.Orders.UserID).
Scan(&sevenRes)
for _, r := range sevenRes {
sevenDayConsume[r.UserID] = r.Amount
}
// 近30天消费
var thirtyRes []consumeResult
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
Where(h.readDB.Orders.UserID.In(userIDs...)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Gte(thirtyDayStart)).
Group(h.readDB.Orders.UserID).
Scan(&thirtyRes)
for _, r := range thirtyRes {
thirtyDayConsume[r.UserID] = r.Amount
}
// 累计消费
var totalRes []consumeResult
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
Where(h.readDB.Orders.UserID.In(userIDs...)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Group(h.readDB.Orders.UserID).
Scan(&totalRes)
for _, r := range totalRes {
totalConsume[r.UserID] = r.Amount
}
}
// 批量查询邀请人数
inviteCounts := make(map[int64]int64)
if len(userIDs) > 0 {
type countResult struct {
InviterID int64
Count int64
}
var counts []countResult
h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Users.InviterID, h.readDB.Users.ID.Count().As("count")).
Where(h.readDB.Users.InviterID.In(userIDs...)).
Group(h.readDB.Users.InviterID).
Scan(&counts)
for _, c := range counts {
inviteCounts[c.InviterID] = c.Count
}
}
// 批量查询次数卡数量
gamePassCounts := make(map[int64]int64)
if len(userIDs) > 0 {
type countResult struct {
UserID int64
Count int64
}
var counts []countResult
now := time.Now()
h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserGamePasses.UserID, h.readDB.UserGamePasses.Remaining.Sum().As("count")).
Where(h.readDB.UserGamePasses.UserID.In(userIDs...)).
Where(h.readDB.UserGamePasses.Remaining.Gt(0)).
Where(h.readDB.UserGamePasses.Where(h.readDB.UserGamePasses.ExpiredAt.Gt(now)).Or(h.readDB.UserGamePasses.ExpiredAt.IsNull())).
Group(h.readDB.UserGamePasses.UserID).
Scan(&counts)
for _, c := range counts {
gamePassCounts[c.UserID] = c.Count
}
}
// 批量查询游戏资格数量
gameTicketCounts := make(map[int64]int64)
if len(userIDs) > 0 {
type countResult struct {
UserID int64
Count int64
}
var counts []countResult
h.readDB.UserGameTickets.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserGameTickets.UserID, h.readDB.UserGameTickets.Available.Sum().As("count")).
Where(h.readDB.UserGameTickets.UserID.In(userIDs...)).
Group(h.readDB.UserGameTickets.UserID).
Scan(&counts)
for _, c := range counts {
gameTicketCounts[c.UserID] = c.Count
}
}
// 批量查询持有商品价值
inventoryValues := make(map[int64]int64)
if len(userIDs) > 0 {
type invResult struct {
UserID int64
Value int64
}
var invRes []invResult
h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.UserInventory.ProductID)).
Select(h.readDB.UserInventory.UserID, h.readDB.Products.Price.Sum().As("value")).
Where(h.readDB.UserInventory.UserID.In(userIDs...)).
Where(h.readDB.UserInventory.Status.Eq(1)). // 1=持有
Group(h.readDB.UserInventory.UserID).
Scan(&invRes)
for _, r := range invRes {
inventoryValues[r.UserID] = r.Value
}
}
// 批量查询优惠券价值(余额之和)
couponValues := make(map[int64]int64)
if len(userIDs) > 0 {
type valResult struct {
UserID int64
Value int64
}
var vRes []valResult
h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.UserCoupons.UserID, h.readDB.UserCoupons.BalanceAmount.Sum().As("value")).
Where(h.readDB.UserCoupons.UserID.In(userIDs...)).
Where(h.readDB.UserCoupons.Status.Eq(1)).
Group(h.readDB.UserCoupons.UserID).
Scan(&vRes)
for _, v := range vRes {
couponValues[v.UserID] = v.Value
}
}
// 批量查询道具卡价值
itemCardValues := make(map[int64]int64)
if len(userIDs) > 0 {
type valResult struct {
UserID int64
Value int64
}
var vRes []valResult
h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.SystemItemCards, h.readDB.SystemItemCards.ID.EqCol(h.readDB.UserItemCards.CardID)).
Select(h.readDB.UserItemCards.UserID, h.readDB.SystemItemCards.Price.Sum().As("value")).
Where(h.readDB.UserItemCards.UserID.In(userIDs...)).
Where(h.readDB.UserItemCards.Status.Eq(1)).
Group(h.readDB.UserItemCards.UserID).
Scan(&vRes)
for _, v := range vRes {
itemCardValues[v.UserID] = v.Value
}
}
// 批量查询所有用户的邀请人昵称
inviterNicknames := make(map[int64]string)
inviterIDs := make([]int64, 0)
for _, v := range rows {
if v.InviterID > 0 {
inviterIDs = append(inviterIDs, v.InviterID)
}
}
if len(inviterIDs) > 0 {
inviters, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.In(inviterIDs...)).Find()
for _, inv := range inviters {
inviterNicknames[inv.ID] = inv.Nickname
}
}
rsp.Page = req.Page
@ -211,21 +392,44 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
rsp.Total = total
rsp.List = make([]adminUserItem, len(rows))
for i, v := range rows {
pointsBal := pointBalances[v.ID]
invVal := inventoryValues[v.ID]
cpVal := couponValues[v.ID]
icVal := itemCardValues[v.ID]
gpCount := gamePassCounts[v.ID]
gtCount := gameTicketCounts[v.ID]
// 总资产估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次)
// 游戏资格不计入估值(购买其他商品赠送,无实际价值)
assetVal := pointsBal*100 + invVal + cpVal + icVal + gpCount*200
rsp.List[i] = adminUserItem{
ID: v.ID,
Nickname: v.Nickname,
Avatar: v.Avatar,
InviteCode: v.InviteCode,
InviterID: v.InviterID,
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
DouyinID: v.DouyinID,
ChannelName: v.ChannelName,
ChannelCode: v.ChannelCode,
PointsBalance: pointBalances[v.ID],
CouponsCount: couponCounts[v.ID],
ItemCardsCount: cardCounts[v.ID],
TodayConsume: todayConsume[v.ID],
SevenDayConsume: sevenDayConsume[v.ID],
ID: v.ID,
Nickname: v.Nickname,
Avatar: v.Avatar,
InviteCode: v.InviteCode,
InviterID: v.InviterID,
InviterNickname: inviterNicknames[v.InviterID],
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
DouyinID: v.DouyinID,
DouyinUserID: v.DouyinUserID,
Mobile: v.Mobile,
Remark: v.Remark,
ChannelName: v.ChannelName,
ChannelCode: v.ChannelCode,
PointsBalance: pointsBal,
CouponsCount: couponCounts[v.ID],
ItemCardsCount: cardCounts[v.ID],
TodayConsume: todayConsume[v.ID],
SevenDayConsume: sevenDayConsume[v.ID],
ThirtyDayConsume: thirtyDayConsume[v.ID],
TotalConsume: totalConsume[v.ID],
InviteCount: inviteCounts[v.ID],
GamePassCount: gpCount,
GameTicketCount: gtCount,
InventoryValue: invVal,
TotalAssetValue: assetVal,
Status: v.Status,
}
}
ctx.Payload(rsp)
@ -297,8 +501,10 @@ type listOrdersResponse struct {
}
type listInventoryRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
Page int `form:"page"`
PageSize int `form:"page_size"`
Keyword string `form:"keyword"` // 搜索关键词(商品名称)
Status int32 `form:"status"` // 状态筛选0=全部, 1=持有, 2=作废, 3=已使用
}
type listInventoryResponse struct {
Page int `json:"page"`
@ -355,6 +561,8 @@ func (h *handler) ListUserOrders() core.HandlerFunc {
// @Param user_id path integer true "用户ID"
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Param keyword query string false "搜索关键词"
// @Param status query int false "状态筛选: 0=全部, 1=持有, 2=作废, 3=已使用"
// @Success 200 {object} listInventoryResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/inventory [get]
@ -372,7 +580,139 @@ func (h *handler) ListUserInventory() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
rows, total, err := h.userSvc.ListInventoryWithProduct(ctx.RequestContext(), userID, req.Page, req.PageSize)
// 处理分页参数
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
// 如果有搜索关键词,使用带搜索的查询
if req.Keyword != "" {
// 联表查询以支持按商品名称搜索
ui := h.readDB.UserInventory
p := h.readDB.Products
// Check if keyword is numeric
numKeyword, errNum := strconv.ParseInt(req.Keyword, 10, 64)
// Count query logic
countQ := h.readDB.UserInventory.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(p, p.ID.EqCol(ui.ProductID)).
Where(ui.UserID.Eq(userID))
// 应用状态筛选
if req.Status > 0 {
countQ = countQ.Where(ui.Status.Eq(req.Status))
} else {
// 默认只过滤掉已软删除的记录如果有的话status=2是作废通常后台要能看到作废的所以这里如果不传status默认查所有非删除的
// 既然是管理端如果不传status应该显示所有状态的记录
}
if errNum == nil {
// Keyword is numeric, search by name OR ID OR OrderID
countQ = countQ.Where(
ui.Where(p.Name.Like("%" + req.Keyword + "%")).
Or(ui.ID.Eq(numKeyword)).
Or(ui.OrderID.Eq(numKeyword)),
)
} else {
// Keyword is not numeric, search by name only
countQ = countQ.Where(p.Name.Like("%" + req.Keyword + "%"))
}
total, err := countQ.Count()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error()))
return
}
// 查询资产数据
type inventoryRow struct {
ID int64
UserID int64
ProductID int64
OrderID int64
ActivityID int64
RewardID int64
Status int32
Remark string
CreatedAt string
UpdatedAt string
ProductName string
ProductImages string
ProductPrice int64
}
var rows []inventoryRow
sql := `
SELECT ui.id, ui.user_id, ui.product_id, ui.order_id, ui.activity_id, ui.reward_id,
ui.status, ui.remark, ui.created_at, ui.updated_at,
p.name as product_name, p.images_json as product_images, p.price as product_price
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.user_id = ?
`
var args []interface{}
args = append(args, userID)
if req.Status > 0 {
sql += " AND ui.status = ?"
args = append(args, req.Status)
}
if errNum == nil {
sql += " AND (p.name LIKE ? OR ui.id = ? OR ui.order_id = ?)"
args = append(args, "%"+req.Keyword+"%", numKeyword, numKeyword)
} else {
sql += " AND p.name LIKE ?"
args = append(args, "%"+req.Keyword+"%")
}
sql += " ORDER BY ui.id DESC LIMIT ? OFFSET ?"
args = append(args, req.PageSize, (req.Page-1)*req.PageSize)
err = h.repo.GetDbR().Raw(sql, args...).Scan(&rows).Error
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error()))
return
}
// 转换结果
items := make([]*user.InventoryWithProduct, len(rows))
for i, r := range rows {
items[i] = &user.InventoryWithProduct{
UserInventory: &model.UserInventory{
ID: r.ID,
UserID: r.UserID,
ProductID: r.ProductID,
OrderID: r.OrderID,
ActivityID: r.ActivityID,
RewardID: r.RewardID,
Status: r.Status,
Remark: r.Remark,
},
ProductName: r.ProductName,
ProductImages: r.ProductImages,
ProductPrice: r.ProductPrice,
}
}
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = items
ctx.Payload(rsp)
return
}
// 无搜索关键词时使用原有逻辑
rows, total, err := h.userSvc.ListInventoryWithProduct(ctx.RequestContext(), userID, req.Page, req.PageSize, req.Status)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20105, err.Error()))
return
@ -512,16 +852,17 @@ func (h *handler) ListUserCoupons() core.HandlerFunc {
MinSpend int64
BalanceAmount int64
}
q := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
LeftJoin(h.readDB.SystemCoupons, h.readDB.SystemCoupons.ID.EqCol(h.readDB.UserCoupons.CouponID)).
q := base.
Select(
h.readDB.UserCoupons.ID, h.readDB.UserCoupons.CouponID, h.readDB.UserCoupons.Status,
h.readDB.UserCoupons.UsedOrderID, h.readDB.UserCoupons.UsedAt, h.readDB.UserCoupons.ValidStart, h.readDB.UserCoupons.ValidEnd,
h.readDB.SystemCoupons.Name, h.readDB.SystemCoupons.ScopeType, h.readDB.SystemCoupons.DiscountType,
h.readDB.SystemCoupons.DiscountValue, h.readDB.SystemCoupons.MinSpend,
h.readDB.UserCoupons.BalanceAmount,
h.readDB.UserCoupons.UsedOrderID, h.readDB.UserCoupons.UsedAt,
h.readDB.UserCoupons.ValidStart, h.readDB.UserCoupons.ValidEnd,
h.readDB.SystemCoupons.Name, h.readDB.SystemCoupons.ScopeType,
h.readDB.SystemCoupons.DiscountType, h.readDB.SystemCoupons.DiscountValue,
h.readDB.SystemCoupons.MinSpend,
).
Where(h.readDB.UserCoupons.UserID.Eq(userID)).
LeftJoin(h.readDB.SystemCoupons, h.readDB.SystemCoupons.ID.EqCol(h.readDB.UserCoupons.CouponID)).
Order(h.readDB.UserCoupons.ID.Desc()).
Limit(req.PageSize).Offset((req.Page - 1) * req.PageSize)
@ -556,6 +897,164 @@ func (h *handler) ListUserCoupons() core.HandlerFunc {
}
}
type AuditLogItem struct {
CreatedAt string `json:"created_at"` // 时间
Category string `json:"category"` // 大类: points/order/shipping/draw
SubType string `json:"sub_type"` // 子类: action/status
AmountStr string `json:"amount_str"` // 金额/数值变化 (带符号字符串)
RefInfo string `json:"ref_info"` // 关联信息 (RefID/OrderNo/ExpressNo)
DetailInfo string `json:"detail_info"` // 详细描述 (Remark/PrizeName)
}
type listAuditLogsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"` // 由于UNION ALL分页较难精确Count Total这里可能返回估算值或分步Count为简化MVP先只做翻页不用Total或者Total设为0
List []AuditLogItem `json:"list"`
}
// ListUserAuditLogs 查看用户行为审计日志
// @Summary 查看用户行为审计日志
// @Description 聚合查看用户的积分、订单、发货、抽奖等行为记录
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量" default(20)
// @Success 200 {object} listAuditLogsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/audit [get]
// @Security LoginVerifyToken
func (h *handler) ListUserAuditLogs() core.HandlerFunc {
return func(ctx core.Context) {
req := new(listInvitesRequest) // 复用分页参数结构
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
offset := (req.Page - 1) * req.PageSize
limit := req.PageSize
var logs []AuditLogItem
// 构建 UNION ALL 查询
// 1. 积分流水
// 2. 订单记录
// 3. 发货记录
// 4. 抽奖记录 (只看中奖的? 或者全部? 这里先只看中奖 IsWinner=1 避免数据量太大)
sql := `
SELECT * FROM (
-- 1. Points Ledger
SELECT
created_at,
'points' as category,
CONVERT(action USING utf8mb4) as sub_type,
CAST(points AS CHAR) as amount_str,
CONCAT(CONVERT(ref_table USING utf8mb4), ':', CONVERT(ref_id USING utf8mb4)) as ref_info,
CONVERT(remark USING utf8mb4) as detail_info
FROM user_points_ledger
WHERE user_id = ?
UNION ALL
-- 2. Orders
SELECT
created_at,
'order' as category,
'paid' as sub_type,
CAST(actual_amount AS CHAR) as amount_str,
CONVERT(order_no USING utf8mb4) as ref_info,
CONVERT(remark USING utf8mb4) as detail_info
FROM orders
WHERE user_id = ? AND status = 2
UNION ALL
-- 3. Shipping Records
SELECT
created_at,
'shipping' as category,
CAST(status AS CHAR) as sub_type,
CAST(quantity AS CHAR) as amount_str,
CONCAT(IFNULL(CONVERT(express_code USING utf8mb4),''), ':', IFNULL(CONVERT(express_no USING utf8mb4),'')) as ref_info,
CONVERT(remark USING utf8mb4) as detail_info
FROM shipping_records
WHERE user_id = ?
UNION ALL
-- 4. Draw Logs (Winners)
SELECT
l.created_at,
'draw' as category,
IF(l.is_winner=1, 'win', 'lose') as sub_type,
CAST(1 AS CHAR) as amount_str,
CAST(l.order_id AS CHAR) as ref_info,
CONCAT(
'游戏: ', IFNULL(CONVERT(act.name USING utf8mb4), '未知'),
' | 奖品: ', IFNULL(CONVERT(prod.name USING utf8mb4), '未知'),
' | 级别: ', CASE l.level WHEN 1 THEN 'S' WHEN 2 THEN 'A' WHEN 3 THEN 'B' WHEN 4 THEN 'C' ELSE CAST(l.level AS CHAR) END
) as detail_info
FROM activity_draw_logs l
LEFT JOIN activity_issues issue ON l.issue_id = issue.id
LEFT JOIN activities act ON issue.activity_id = act.id
LEFT JOIN activity_reward_settings reward ON l.reward_id = reward.id
LEFT JOIN products prod ON reward.product_id = prod.id
WHERE l.user_id = ? AND l.is_winner = 1
) as combined_logs
ORDER BY created_at DESC
LIMIT ? OFFSET ?
`
if err := h.repo.GetDbR().Raw(sql, userID, userID, userID, userID, limit, offset).Scan(&logs).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20107, err.Error()))
return
}
// 格式化处理 (Optional)
for i := range logs {
// 将时间标准化
if t, err := time.Parse(time.RFC3339, logs[i].CreatedAt); err == nil {
logs[i].CreatedAt = t.Format("2006-01-02 15:04:05")
}
// 翻译 Shipping Status 等 (可选项,也可以前端做)
if logs[i].Category == "shipping" {
switch logs[i].SubType {
case "1":
logs[i].SubType = "待发货"
case "2":
logs[i].SubType = "已发货"
case "3":
logs[i].SubType = "已签收"
case "4":
logs[i].SubType = "异常"
}
}
}
ctx.Payload(&listAuditLogsResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: 0, // 为了性能暂时忽略
List: logs,
})
}
}
func nullableToString(s *string) string {
if s == nil {
return ""
@ -567,11 +1066,22 @@ type listPointsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
}
type adminUserPointsLedgerItem struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
Action string `json:"action"`
Points float64 `json:"points"` // 改为 float64 支持小数积分
RefTable string `json:"ref_table"`
RefID string `json:"ref_id"`
Remark string `json:"remark"`
CreatedAt string `json:"created_at"`
}
type listPointsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []*model.UserPointsLedger `json:"list"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []adminUserPointsLedgerItem `json:"list"`
}
// ListUserPoints 查看用户积分记录
@ -608,7 +1118,20 @@ func (h *handler) ListUserPoints() core.HandlerFunc {
rsp.Page = req.Page
rsp.PageSize = req.PageSize
rsp.Total = total
rsp.List = items
// Convert ledger items
rsp.List = make([]adminUserPointsLedgerItem, len(items))
for i, v := range items {
rsp.List[i] = adminUserPointsLedgerItem{
ID: v.ID,
UserID: v.UserID,
Action: v.Action,
Points: h.userSvc.CentsToPointsFloat(ctx.RequestContext(), v.Points),
RefTable: v.RefTable,
RefID: v.RefID,
Remark: v.Remark,
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
ctx.Payload(rsp)
}
}
@ -618,20 +1141,32 @@ type pointsBalanceResponse struct {
}
type adminUserItem struct {
ID int64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
InviteCode string `json:"invite_code"`
InviterID int64 `json:"inviter_id"`
CreatedAt string `json:"created_at"`
DouyinID string `json:"douyin_id"`
ChannelName string `json:"channel_name"`
ChannelCode string `json:"channel_code"`
PointsBalance int64 `json:"points_balance"`
CouponsCount int64 `json:"coupons_count"`
ItemCardsCount int64 `json:"item_cards_count"`
TodayConsume int64 `json:"today_consume"`
SevenDayConsume int64 `json:"seven_day_consume"`
ID int64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
InviteCode string `json:"invite_code"`
InviterID int64 `json:"inviter_id"`
InviterNickname string `json:"inviter_nickname"` // 邀请人昵称
CreatedAt string `json:"created_at"`
DouyinID string `json:"douyin_id"`
DouyinUserID string `json:"douyin_user_id"` // 用户的抖音账号ID
Mobile string `json:"mobile"` // 手机号
Remark string `json:"remark"` // 备注
ChannelName string `json:"channel_name"`
ChannelCode string `json:"channel_code"`
PointsBalance int64 `json:"points_balance"`
CouponsCount int64 `json:"coupons_count"`
ItemCardsCount int64 `json:"item_cards_count"`
TodayConsume int64 `json:"today_consume"`
SevenDayConsume int64 `json:"seven_day_consume"`
ThirtyDayConsume int64 `json:"thirty_day_consume"` // 近30天消费
TotalConsume int64 `json:"total_consume"` // 累计消费
InviteCount int64 `json:"invite_count"` // 邀请人数
GamePassCount int64 `json:"game_pass_count"` // 次数卡数量
GameTicketCount int64 `json:"game_ticket_count"` // 游戏资格数量
InventoryValue int64 `json:"inventory_value"` // 持有商品总价值
TotalAssetValue int64 `json:"total_asset_value"` // 总资产估值
Status int32 `json:"status"` // 用户状态1正常 2禁用 3黑名单
}
// ListAppUsers 管理端用户列表GetUserPointsBalance 查看用户积分余额
@ -658,16 +1193,16 @@ func (h *handler) GetUserPointsBalance() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20107, err.Error()))
return
}
rsp.Balance = total
rsp.Balance = int64(h.userSvc.CentsToPointsFloat(ctx.RequestContext(), total))
ctx.Payload(rsp)
}
}
type addPointsRequest struct {
Points int64 `json:"points"` // 正数=增加,负数=扣减
Kind string `json:"kind"`
Remark string `json:"remark"`
ValidDays *int `json:"valid_days"`
Points float64 `json:"points"` // 正数=增加,负数=扣减
Kind string `json:"kind"`
Remark string `json:"remark"`
ValidDays *int `json:"valid_days"`
}
type addPointsResponse struct {
Success bool `json:"success"`
@ -703,10 +1238,16 @@ func (h *handler) AddUserPoints() core.HandlerFunc {
return
}
// 将浮点数积分转换为分Cents
// 1 积分 = 1 元 = 100 分
// 使用 math.Round 避免精度问题
pointsCents := int64(math.Round(req.Points * 100))
// 如果是扣减积分,先检查余额
if req.Points < 0 {
if pointsCents < 0 {
balance, _ := h.userSvc.GetPointsBalance(ctx.RequestContext(), userID)
if balance+req.Points < 0 {
deductCents := -pointsCents
if balance < deductCents {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, "积分余额不足,无法扣减"))
return
}
@ -716,14 +1257,15 @@ func (h *handler) AddUserPoints() core.HandlerFunc {
var validEnd *time.Time
now := time.Now()
// 只有增加积分时才设置有效期
if req.Points > 0 {
if pointsCents > 0 {
validStart = &now
if req.ValidDays != nil && *req.ValidDays > 0 {
ve := now.Add(time.Duration(*req.ValidDays) * 24 * time.Hour)
validEnd = &ve
}
}
if err := h.userSvc.AddPoints(ctx.RequestContext(), userID, req.Points, req.Kind, req.Remark, validStart, validEnd); err != nil {
if err := h.userSvc.AddPoints(ctx.RequestContext(), userID, pointsCents, req.Kind, req.Remark, validStart, validEnd); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, err.Error()))
return
}
@ -1022,3 +1564,145 @@ func (h *handler) ListUserCouponUsage() core.HandlerFunc {
ctx.Payload(rsp)
}
}
// LinkUserDouyinRequest 关联用户抖音账号请求
type LinkUserDouyinRequest struct {
DouyinUserID string `json:"douyin_user_id" binding:"required"`
}
// UpdateUserDouyinID 更新用户的抖音账号ID
// @Summary 更新用户抖音ID
// @Description 管理员绑定或修改用户的抖音账号ID
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param body body LinkUserDouyinRequest true "抖音用户ID"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/douyin_user_id [put]
// @Security LoginVerifyToken
func (h *handler) UpdateUserDouyinID() core.HandlerFunc {
return func(ctx core.Context) {
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效"))
return
}
req := new(LinkUserDouyinRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// 更新用户抖音ID
_, err = h.writeDB.Users.WithContext(ctx.RequestContext()).
Where(h.writeDB.Users.ID.Eq(userID)).
Update(h.writeDB.Users.DouyinUserID, req.DouyinUserID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20301, "更新失败: "+err.Error()))
return
}
ctx.Payload(map[string]any{
"success": true,
"message": "抖音ID更新成功",
})
}
}
// updateUserRemarkRequest 更新用户备注请求
type updateUserRemarkRequest struct {
Remark string `json:"remark"`
}
// UpdateUserRemark 更新用户备注
// @Summary 更新用户备注
// @Description 管理员修改用户备注
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param body body updateUserRemarkRequest true "备注信息"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/remark [put]
// @Security LoginVerifyToken
func (h *handler) UpdateUserRemark() core.HandlerFunc {
return func(ctx core.Context) {
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "用户ID无效"))
return
}
req := new(updateUserRemarkRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
// 更新用户备注
_, err = h.writeDB.Users.WithContext(ctx.RequestContext()).
Where(h.writeDB.Users.ID.Eq(userID)).
Update(h.writeDB.Users.Remark, req.Remark)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20302, "更新失败: "+err.Error()))
return
}
ctx.Payload(map[string]any{
"success": true,
"message": "备注更新成功",
})
}
}
type updateUserStatusRequest struct {
Status int32 `json:"status" form:"status"` // 1=正常 2=禁用 3=黑名单
}
// UpdateUserStatus 修改用户状态
// @Summary 修改用户状态
// @Description 管理员修改用户状态1正常 2禁用 3黑名单
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param body body updateUserStatusRequest true "状态信息"
// @Success 200 {object} map[string]any
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/status [put]
// @Security LoginVerifyToken
func (h *handler) UpdateUserStatus() core.HandlerFunc {
return func(ctx core.Context) {
req := new(updateUserStatusRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
if req.Status != 1 && req.Status != 2 && req.Status != 3 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "无效的状态值"))
return
}
// 使用 Updates 以支持更新为 0 (虽然这里status不为0) 但 gorm Update 单列更安全
_, err = h.writeDB.Users.WithContext(ctx.RequestContext()).
Where(h.writeDB.Users.ID.Eq(userID)).
Update(h.writeDB.Users.Status, req.Status)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
return
}
ctx.Payload(map[string]any{"success": true})
}
}

View File

@ -1,144 +1,156 @@
package admin
import (
"net/http"
"net/http"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
usersvc "bindbox-game/internal/service/user"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
usersvc "bindbox-game/internal/service/user"
)
type batchPointsRequest struct {
Users []int64 `json:"users" binding:"required"`
Amount int64 `json:"amount" binding:"min=1"`
Reason string `json:"reason"`
IdempotencyKey string `json:"idempotency_key"`
Users []int64 `json:"users" binding:"required"`
Amount int64 `json:"amount" binding:"min=1"`
Reason string `json:"reason"`
IdempotencyKey string `json:"idempotency_key"`
}
type batchCouponsRequest struct {
Users []int64 `json:"users" binding:"required"`
CouponID int64 `json:"coupon_id" binding:"required"`
QuantityPerUser int `json:"quantity_per_user"`
IdempotencyKey string `json:"idempotency_key"`
Users []int64 `json:"users" binding:"required"`
CouponID int64 `json:"coupon_id" binding:"required"`
QuantityPerUser int `json:"quantity_per_user"`
IdempotencyKey string `json:"idempotency_key"`
}
type batchRewardsRequest struct {
Users []int64 `json:"users" binding:"required"`
ProductID int64 `json:"product_id" binding:"required"`
Quantity int `json:"quantity" binding:"min=1"`
ActivityID *int64 `json:"activity_id"`
RewardID *int64 `json:"reward_id"`
AddressID *int64 `json:"address_id"`
Remark string `json:"remark"`
IdempotencyKey string `json:"idempotency_key"`
Users []int64 `json:"users" binding:"required"`
ProductID int64 `json:"product_id" binding:"required"`
Quantity int `json:"quantity" binding:"min=1"`
ActivityID *int64 `json:"activity_id"`
RewardID *int64 `json:"reward_id"`
AddressID *int64 `json:"address_id"`
Remark string `json:"remark"`
IdempotencyKey string `json:"idempotency_key"`
}
type batchItemResult struct {
UserID int64 `json:"user_id"`
Status string `json:"status"`
Message string `json:"message"`
UserID int64 `json:"user_id"`
Status string `json:"status"`
Message string `json:"message"`
}
type batchResponse struct {
Success int `json:"success"`
Failed int `json:"failed"`
Details []batchItemResult `json:"details"`
Success int `json:"success"`
Failed int `json:"failed"`
Details []batchItemResult `json:"details"`
}
func (h *handler) BatchAddUserPoints() core.HandlerFunc {
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
req := new(batchPointsRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.Users) != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "当前仅支持单用户操作请仅选择1位用户"))
return
}
res := &batchResponse{Details: make([]batchItemResult, 0, len(req.Users))}
for _, uid := range req.Users {
if err := h.userSvc.AddPoints(ctx.RequestContext(), uid, req.Amount, "manual", req.Reason, nil, nil); err != nil {
res.Failed++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "failed", Message: err.Error()})
} else {
res.Success++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "success"})
}
}
ctx.Payload(res)
}
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
req := new(batchPointsRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.Users) != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "当前仅支持单用户操作请仅选择1位用户"))
return
}
res := &batchResponse{Details: make([]batchItemResult, 0, len(req.Users))}
// 将管理员输入的积分转换为分
amountCents, _ := h.userSvc.PointsToCents(ctx.RequestContext(), req.Amount)
for _, uid := range req.Users {
if err := h.userSvc.AddPoints(ctx.RequestContext(), uid, amountCents, "manual", req.Reason, nil, nil); err != nil {
res.Failed++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "failed", Message: err.Error()})
} else {
res.Success++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "success"})
}
}
ctx.Payload(res)
}
}
func (h *handler) BatchAddUserCoupons() core.HandlerFunc {
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
req := new(batchCouponsRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.Users) != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "当前仅支持单用户操作请仅选择1位用户"))
return
}
if req.QuantityPerUser <= 0 { req.QuantityPerUser = 1 }
if req.QuantityPerUser > 5 { req.QuantityPerUser = 5 }
res := &batchResponse{Details: make([]batchItemResult, 0, len(req.Users))}
for _, uid := range req.Users {
ok := true
for i := 0; i < req.QuantityPerUser; i++ {
if err := h.userSvc.AddCoupon(ctx.RequestContext(), uid, req.CouponID); err != nil { ok = false }
}
if ok {
res.Success++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "success"})
} else {
res.Failed++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "failed"})
}
}
ctx.Payload(res)
}
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
req := new(batchCouponsRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.Users) != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "当前仅支持单用户操作请仅选择1位用户"))
return
}
if req.QuantityPerUser <= 0 {
req.QuantityPerUser = 1
}
if req.QuantityPerUser > 5 {
req.QuantityPerUser = 5
}
res := &batchResponse{Details: make([]batchItemResult, 0, len(req.Users))}
for _, uid := range req.Users {
ok := true
for i := 0; i < req.QuantityPerUser; i++ {
if err := h.userSvc.AddCoupon(ctx.RequestContext(), uid, req.CouponID); err != nil {
ok = false
}
}
if ok {
res.Success++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "success"})
} else {
res.Failed++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "failed"})
}
}
ctx.Payload(res)
}
}
func (h *handler) BatchGrantUserRewards() core.HandlerFunc {
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
req := new(batchRewardsRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.Users) != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "当前仅支持单用户操作请仅选择1位用户"))
return
}
if req.Quantity <= 0 { req.Quantity = 1 }
if req.Quantity > 10 { req.Quantity = 10 }
res := &batchResponse{Details: make([]batchItemResult, 0, len(req.Users))}
r := usersvc.GrantRewardRequest{ProductID: req.ProductID, Quantity: req.Quantity, ActivityID: req.ActivityID, RewardID: req.RewardID, AddressID: req.AddressID, Remark: req.Remark}
for _, uid := range req.Users {
_, err := h.userSvc.GrantReward(ctx.RequestContext(), uid, r)
if err != nil {
res.Failed++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "failed", Message: err.Error()})
} else {
res.Success++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "success"})
}
}
ctx.Payload(res)
}
}
return func(ctx core.Context) {
if ctx.SessionUserInfo().IsSuper != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
return
}
req := new(batchRewardsRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if len(req.Users) != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "当前仅支持单用户操作请仅选择1位用户"))
return
}
if req.Quantity <= 0 {
req.Quantity = 1
}
if req.Quantity > 10 {
req.Quantity = 10
}
res := &batchResponse{Details: make([]batchItemResult, 0, len(req.Users))}
r := usersvc.GrantRewardRequest{ProductID: req.ProductID, Quantity: req.Quantity, ActivityID: req.ActivityID, RewardID: req.RewardID, AddressID: req.AddressID, Remark: req.Remark}
for _, uid := range req.Users {
_, err := h.userSvc.GrantReward(ctx.RequestContext(), uid, r)
if err != nil {
res.Failed++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "failed", Message: err.Error()})
} else {
res.Success++
res.Details = append(res.Details, batchItemResult{UserID: uid, Status: "success"})
}
}
ctx.Payload(res)
}
}

View File

@ -12,25 +12,30 @@ import (
// UserProfileResponse 用户综合画像
type UserProfileResponse struct {
// 基本信息
ID int64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Mobile string `json:"mobile"`
InviteCode string `json:"invite_code"`
InviterID int64 `json:"inviter_id"`
ChannelID int64 `json:"channel_id"`
CreatedAt string `json:"created_at"`
DouyinID string `json:"douyin_id"`
ID int64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Mobile string `json:"mobile"`
InviteCode string `json:"invite_code"`
InviterID int64 `json:"inviter_id"`
ChannelID int64 `json:"channel_id"`
CreatedAt string `json:"created_at"`
DouyinID string `json:"douyin_id"`
DouyinUserID string `json:"douyin_user_id"` // 用户的抖音账号ID
InviterNickname string `json:"inviter_nickname"` // 邀请人昵称
// 邀请统计
InviteCount int64 `json:"invite_count"`
// 生命周期财务指标
LifetimeStats struct {
TotalPaid int64 `json:"total_paid"` // 累计支付
TotalRefunded int64 `json:"total_refunded"` // 累计退款
NetCashCost int64 `json:"net_cash_cost"` // 净现金支出
OrderCount int64 `json:"order_count"` // 订单数
TotalPaid int64 `json:"total_paid"` // 累计支付
TotalRefunded int64 `json:"total_refunded"` // 累计退款
NetCashCost int64 `json:"net_cash_cost"` // 净现金支出
OrderCount int64 `json:"order_count"` // 订单数
TodayPaid int64 `json:"today_paid"` // 当日支付
SevenDayPaid int64 `json:"seven_day_paid"` // 近7天支付
ThirtyDayPaid int64 `json:"thirty_day_paid"` // 近30天支付
} `json:"lifetime_stats"`
// 当前资产快照
@ -42,6 +47,8 @@ type UserProfileResponse struct {
CouponValue int64 `json:"coupon_value"` // 持有优惠券价值
ItemCardCount int64 `json:"item_card_count"` // 持有道具卡数
ItemCardValue int64 `json:"item_card_value"` // 持有道具卡价值
GamePassCount int64 `json:"game_pass_count"` // 持有次数卡数
GameTicketCount int64 `json:"game_ticket_count"` // 持有游戏资格数
TotalAssetValue int64 `json:"total_asset_value"` // 总资产估值
ProfitLossRatio float64 `json:"profit_loss_ratio"` // 累计盈亏比
} `json:"current_assets"`
@ -82,27 +89,85 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
rsp.InviterID = user.InviterID
rsp.ChannelID = user.ChannelID
rsp.DouyinID = user.DouyinID
rsp.DouyinUserID = user.DouyinUserID
rsp.CreatedAt = user.CreatedAt.Format(time.RFC3339)
// 1.1 查询邀请人昵称
if user.InviterID > 0 {
inviter, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(user.InviterID)).First()
if inviter != nil {
rsp.InviterNickname = inviter.Nickname
}
}
// 2. 邀请统计
rsp.InviteCount, _ = h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.InviterID.Eq(userID)).Count()
// 3. 生命周期财务指标
// 3.1 累计支付 & 订单数 - 只统计未退款的订单
// 3.1 消费统计
type orderStats struct {
TotalPaid int64
OrderCount int64
TotalPaid *int64
OrderCount int64
TodayPaid *int64
SevenDayPaid *int64
ThirtyDayPaid *int64
}
var os orderStats
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum().As("total_paid"), h.readDB.Orders.ID.Count().As("order_count")).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)). // 仅已支付,不含已退款
Scan(&os)
rsp.LifetimeStats.TotalPaid = os.TotalPaid
rsp.LifetimeStats.OrderCount = os.OrderCount
now := time.Now()
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
sevenDayStart := todayStart.AddDate(0, 0, -6)
thirtyDayStart := todayStart.AddDate(0, 0, -29)
// 3.2 累计退款 - 显示实际退款金额(参考信息)
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(
h.readDB.Orders.ActualAmount.Sum().As("total_paid"),
h.readDB.Orders.ID.Count().As("order_count"),
).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换)
Scan(&os)
// 分阶段统计
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum().As("today_paid")).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换)
Where(h.readDB.Orders.CreatedAt.Gte(todayStart)).
Scan(&os.TodayPaid)
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum().As("seven_day_paid")).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换)
Where(h.readDB.Orders.CreatedAt.Gte(sevenDayStart)).
Scan(&os.SevenDayPaid)
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum().As("thirty_day_paid")).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 统计抽奖、对对碰、次卡购买(排除积分兑换)
Where(h.readDB.Orders.CreatedAt.Gte(thirtyDayStart)).
Scan(&os.ThirtyDayPaid)
if os.TotalPaid != nil {
rsp.LifetimeStats.TotalPaid = *os.TotalPaid
}
rsp.LifetimeStats.OrderCount = os.OrderCount
if os.TodayPaid != nil {
rsp.LifetimeStats.TodayPaid = *os.TodayPaid
}
if os.SevenDayPaid != nil {
rsp.LifetimeStats.SevenDayPaid = *os.SevenDayPaid
}
if os.ThirtyDayPaid != nil {
rsp.LifetimeStats.ThirtyDayPaid = *os.ThirtyDayPaid
}
// 3.2 累计退款
var totalRefunded int64
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(pr.amount_refund), 0)
@ -111,8 +176,11 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
WHERE o.user_id = ? AND pr.status = 'SUCCESS'
`, userID).Scan(&totalRefunded).Error
rsp.LifetimeStats.TotalRefunded = totalRefunded
// 净投入 = 累计支付(因为已排除退款订单,所以不减退款)
rsp.LifetimeStats.NetCashCost = rsp.LifetimeStats.TotalPaid
// 净现金投入 = 累计实付 - 累计退款
rsp.LifetimeStats.NetCashCost = rsp.LifetimeStats.TotalPaid - totalRefunded
if rsp.LifetimeStats.NetCashCost < 0 {
rsp.LifetimeStats.NetCashCost = 0
}
// 4. 当前资产快照
// 4.1 积分余额
@ -164,11 +232,24 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
rsp.CurrentAssets.ItemCardCount = cds.Count
rsp.CurrentAssets.ItemCardValue = cds.Value
// 4.5 持有次数卡
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(remaining), 0) FROM user_game_passes WHERE user_id = ? AND remaining > 0 AND (expired_at IS NULL OR expired_at > NOW())", userID).Scan(&rsp.CurrentAssets.GamePassCount).Error
// 4.6 持有游戏资格
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(available), 0) FROM user_game_tickets WHERE user_id = ?", userID).Scan(&rsp.CurrentAssets.GameTicketCount).Error
// 4.5 总资产估值
// 估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次)
// 游戏资格不计入估值(购买其他商品赠送,无实际价值)
gamePassValue := rsp.CurrentAssets.GamePassCount * 200 // 估值2元/次
gameTicketValue := int64(0) // 游戏资格不计入估值
rsp.CurrentAssets.TotalAssetValue = rsp.CurrentAssets.PointsBalance +
rsp.CurrentAssets.InventoryValue +
rsp.CurrentAssets.CouponValue +
rsp.CurrentAssets.ItemCardValue
rsp.CurrentAssets.ItemCardValue +
gamePassValue +
gameTicketValue
// 4.6 累计盈亏比
if rsp.LifetimeStats.NetCashCost > 0 {

View File

@ -17,8 +17,8 @@ type userProfitLossRequest struct {
type userProfitLossPoint struct {
Date string `json:"date"`
Cost int64 `json:"cost"` // 净支出(仅已支付未退款订单
Value int64 `json:"value"` // 当前资产快照(实时
Cost int64 `json:"cost"` // 累计投入(已支付-已退款
Value int64 `json:"value"` // 累计产出(当前资产快照)
Profit int64 `json:"profit"` // 净盈亏
Ratio float64 `json:"ratio"` // 盈亏比
Breakdown struct {
@ -30,8 +30,14 @@ type userProfitLossPoint struct {
}
type userProfitLossResponse struct {
Granularity string `json:"granularity"`
List []userProfitLossPoint `json:"list"`
Granularity string `json:"granularity"`
List []userProfitLossPoint `json:"list"`
Summary struct {
TotalCost int64 `json:"total_cost"`
TotalValue int64 `json:"total_value"`
TotalProfit int64 `json:"total_profit"`
AvgRatio float64 `json:"avg_ratio"`
} `json:"summary"`
CurrentAssets struct {
Points int64 `json:"points"`
Products int64 `json:"products"`
@ -85,14 +91,55 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(balance_amount), 0) FROM user_coupons WHERE user_id = ? AND status = 1", userID).Scan(&curAssets.Coupons).Error
totalAssetValue := curAssets.Points + curAssets.Products + curAssets.Cards + curAssets.Coupons
// --- 2. 获取订单数据(仅 status=2 已支付未退款)---
// --- 2. 获取订单数据(仅 status=2 已支付) ---
// 注意:为了计算累计趋势,我们需要获取 start 之前的所有已支付订单总额作为基数
var baseCost int64 = 0
var baseCostPtr *int64
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum()).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Lt(start)).
Scan(&baseCostPtr)
if baseCostPtr != nil {
baseCost = *baseCostPtr
}
// 扣除历史退款 (如果有的话,此处简化处理,主要关注当前范围内的波动)
var baseRefund int64 = 0
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(pr.amount_refund), 0)
FROM payment_refunds pr
JOIN orders o ON o.order_no = pr.order_no COLLATE utf8mb4_unicode_ci
WHERE o.user_id = ? AND pr.status = 'SUCCESS' AND pr.created_at < ?
`, userID, start).Scan(&baseRefund).Error
baseCost -= baseRefund
if baseCost < 0 {
baseCost = 0
}
orderRows, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)). // 仅已支付,不含已退款
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Where(h.readDB.Orders.CreatedAt.Gte(start)).
Where(h.readDB.Orders.CreatedAt.Lte(end)).
Find()
// 获取当前范围内的退款
type refundInfo struct {
Amount int64
CreatedAt time.Time
}
var refunds []refundInfo
_ = h.repo.GetDbR().Raw(`
SELECT pr.amount_refund as amount, pr.created_at
FROM payment_refunds pr
JOIN orders o ON o.order_no = pr.order_no COLLATE utf8mb4_unicode_ci
WHERE o.user_id = ? AND pr.status = 'SUCCESS' AND pr.created_at BETWEEN ? AND ?
`, userID, start, end).Scan(&refunds).Error
// --- 3. 按时间分桶计算 ---
list := make([]userProfitLossPoint, len(buckets))
@ -100,24 +147,35 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
return (t.After(b.Start) || t.Equal(b.Start)) && t.Before(b.End)
}
var cumulativeCost int64 = 0
cumulativeCost := baseCost
for i, b := range buckets {
p := &list[i]
p.Date = b.Label
// 计算该时间段内的支出
var periodCost int64 = 0
// 计算该时间段内的净投入变化
var periodDelta int64 = 0
for _, o := range orderRows {
if inBucket(o.CreatedAt, b) {
periodCost += o.ActualAmount
periodDelta += o.ActualAmount
}
}
for _, r := range refunds {
if inBucket(r.CreatedAt, b) {
periodDelta -= r.Amount
}
}
cumulativeCost += periodCost
p.Cost = periodCost
// 使用当前资产快照作为产出值最后一个桶显示完整值其他桶按比例或显示0
// 简化:所有桶都显示当前快照值,让用户一眼看到当前状态
cumulativeCost += periodDelta
if cumulativeCost < 0 {
cumulativeCost = 0
}
p.Cost = cumulativeCost
// 产出值:当前资产是一个存量值。
// 理想逻辑是回溯各时间点的余额,简化逻辑下:
// 如果该点还没有在该范围内发生过任何投入且没有基数则显示0否则显示当前快照值。
// 这里我们统一显示当前快照,但在前端图表上它会是一条水平线或阶梯线。
p.Value = totalAssetValue
p.Breakdown.Points = curAssets.Points
p.Breakdown.Products = curAssets.Products
@ -132,42 +190,342 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
}
}
// 计算累计值用于汇总显示
// 汇总数据
var totalCost int64 = 0
for _, o := range orderRows {
totalCost += o.ActualAmount
var totalCostPtr *int64
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum()).
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.SourceType.In(2, 3, 4)). // 排除积分兑换(1)和奖品兑换(5)
Scan(&totalCostPtr)
if totalCostPtr != nil {
totalCost = *totalCostPtr
}
// 最后一个桶使用累计成本
if len(list) > 0 {
lastIdx := len(list) - 1
// 汇总数据:使用累计成本和当前资产值
list[lastIdx].Cost = totalCost
list[lastIdx].Value = totalAssetValue
list[lastIdx].Profit = totalAssetValue - totalCost
if totalCost > 0 {
list[lastIdx].Ratio = float64(totalAssetValue) / float64(totalCost)
} else if totalAssetValue > 0 {
list[lastIdx].Ratio = 99.9
var totalRefund int64 = 0
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(pr.amount_refund), 0)
FROM payment_refunds pr
JOIN orders o ON o.order_no = pr.order_no COLLATE utf8mb4_unicode_ci
WHERE o.user_id = ? AND pr.status = 'SUCCESS'
`, userID).Scan(&totalRefund).Error
finalNetCost := totalCost - totalRefund
if finalNetCost < 0 {
finalNetCost = 0
}
resp := userProfitLossResponse{
Granularity: gran,
List: list,
}
resp.Summary.TotalCost = finalNetCost
resp.Summary.TotalValue = totalAssetValue
resp.Summary.TotalProfit = totalAssetValue - finalNetCost
if finalNetCost > 0 {
resp.Summary.AvgRatio = float64(totalAssetValue) / float64(finalNetCost)
} else if totalAssetValue > 0 {
resp.Summary.AvgRatio = 99.9
}
resp.CurrentAssets.Points = curAssets.Points
resp.CurrentAssets.Products = curAssets.Products
resp.CurrentAssets.Cards = curAssets.Cards
resp.CurrentAssets.Coupons = curAssets.Coupons
resp.CurrentAssets.Total = totalAssetValue
ctx.Payload(resp)
}
}
// 盈亏明细请求
type profitLossDetailsRequest struct {
Page int `form:"page"`
PageSize int `form:"page_size"`
RangeType string `form:"rangeType"`
}
// 盈亏明细项
type profitLossDetailItem struct {
OrderID int64 `json:"order_id"`
OrderNo string `json:"order_no"`
CreatedAt string `json:"created_at"`
SourceType int32 `json:"source_type"` // 来源类型 1商城 2抽奖 3系统
ActivityName string `json:"activity_name"` // 活动名称
ActualAmount int64 `json:"actual_amount"` // 实际支付金额(分)
RefundAmount int64 `json:"refund_amount"` // 退款金额(分)
NetCost int64 `json:"net_cost"` // 净投入(分)
PrizeValue int64 `json:"prize_value"` // 获得奖品价值(分)
PrizeName string `json:"prize_name"` // 奖品名称
PointsEarned int64 `json:"points_earned"` // 获得积分
PointsValue int64 `json:"points_value"` // 积分价值(分)
CouponUsedValue int64 `json:"coupon_used_value"` // 使用优惠券价值(分)
CouponUsedName string `json:"coupon_used_name"` // 使用的优惠券名称
ItemCardUsed string `json:"item_card_used"` // 使用的道具卡名称
ItemCardValue int64 `json:"item_card_value"` // 道具卡价值(分)
NetProfit int64 `json:"net_profit"` // 净盈亏
}
// 盈亏明细响应
type profitLossDetailsResponse struct {
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
List []profitLossDetailItem `json:"list"`
Summary struct {
TotalCost int64 `json:"total_cost"`
TotalValue int64 `json:"total_value"`
TotalProfit int64 `json:"total_profit"`
} `json:"summary"`
}
// GetUserProfitLossDetails 获取用户盈亏明细
// @Summary 获取用户盈亏明细
// @Description 获取用户每笔订单的详细盈亏信息
// @Tags 管理端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Param rangeType query string false "时间范围" default("all")
// @Success 200 {object} profitLossDetailsResponse
// @Failure 400 {object} code.Failure
// @Router /api/admin/users/{user_id}/stats/profit_loss_details [get]
// @Security LoginVerifyToken
func (h *handler) GetUserProfitLossDetails() core.HandlerFunc {
return func(ctx core.Context) {
userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "未传递用户ID"))
return
}
req := new(profitLossDetailsRequest)
if err := ctx.ShouldBindForm(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = 20
}
if req.PageSize > 100 {
req.PageSize = 100
}
// 解析时间范围
start, end := parseRange(req.RangeType, "", "")
if req.RangeType == "all" {
u, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(userID)).First()
if u != nil {
start = u.CreatedAt
} else {
start = time.Date(2025, 1, 1, 0, 0, 0, 0, time.Local)
}
}
ctx.Payload(userProfitLossResponse{
Granularity: gran,
List: list,
CurrentAssets: struct {
Points int64 `json:"points"`
Products int64 `json:"products"`
Cards int64 `json:"cards"`
Coupons int64 `json:"coupons"`
Total int64 `json:"total"`
}{
Points: curAssets.Points,
Products: curAssets.Products,
Cards: curAssets.Cards,
Coupons: curAssets.Coupons,
Total: totalAssetValue,
},
})
// 查询订单总数
orderQ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Where(h.readDB.Orders.UserID.Eq(userID)).
Where(h.readDB.Orders.Status.Eq(2)).
Where(h.readDB.Orders.CreatedAt.Gte(start)).
Where(h.readDB.Orders.CreatedAt.Lte(end))
total, err := orderQ.Count()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, err.Error()))
return
}
// 分页查询订单
orders, err := orderQ.Order(h.readDB.Orders.CreatedAt.Desc()).
Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 20108, err.Error()))
return
}
// 收集订单ID
orderIDs := make([]int64, len(orders))
orderNos := make([]string, len(orders))
for i, o := range orders {
orderIDs[i] = o.ID
orderNos[i] = o.OrderNo
}
// 批量查询退款信息
refundMap := make(map[string]int64)
if len(orderNos) > 0 {
type refundRow struct {
OrderNo string
Amount int64
}
var refunds []refundRow
_ = h.repo.GetDbR().Raw(`
SELECT order_no, COALESCE(SUM(amount_refund), 0) as amount
FROM payment_refunds
WHERE order_no IN ? AND status = 'SUCCESS'
GROUP BY order_no
`, orderNos).Scan(&refunds).Error
for _, r := range refunds {
refundMap[r.OrderNo] = r.Amount
}
}
// 批量查询库存价值(获得的奖品)
prizeValueMap := make(map[int64]int64)
prizeNameMap := make(map[int64]string)
if len(orderIDs) > 0 {
type prizeRow struct {
OrderID int64
Value int64
Name string
}
var prizes []prizeRow
_ = h.repo.GetDbR().Raw(`
SELECT ui.order_id, COALESCE(SUM(p.price), 0) as value,
GROUP_CONCAT(p.name SEPARATOR ', ') as name
FROM user_inventory ui
LEFT JOIN products p ON p.id = ui.product_id
WHERE ui.order_id IN ?
GROUP BY ui.order_id
`, orderIDs).Scan(&prizes).Error
for _, p := range prizes {
prizeValueMap[p.OrderID] = p.Value
prizeNameMap[p.OrderID] = p.Name
}
}
// 批量查询使用的优惠券
couponValueMap := make(map[int64]int64)
couponNameMap := make(map[int64]string)
if len(orderIDs) > 0 {
type couponRow struct {
OrderID int64
Value int64
Name string
}
var coupons []couponRow
_ = h.repo.GetDbR().Raw(`
SELECT ucu.order_id, COALESCE(SUM(ABS(ucu.change_amount)), 0) as value,
GROUP_CONCAT(DISTINCT sc.name SEPARATOR ', ') as name
FROM user_coupon_usage ucu
LEFT JOIN user_coupons uc ON uc.id = ucu.user_coupon_id
LEFT JOIN system_coupons sc ON sc.id = uc.coupon_id
WHERE ucu.order_id IN ?
GROUP BY ucu.order_id
`, orderIDs).Scan(&coupons).Error
for _, c := range coupons {
couponValueMap[c.OrderID] = c.Value
couponNameMap[c.OrderID] = c.Name
}
}
// 批量查询活动信息
activityNameMap := make(map[int64]string)
if len(orderIDs) > 0 {
type actRow struct {
OrderID int64
ActivityName string
}
var acts []actRow
_ = h.repo.GetDbR().Raw(`
SELECT o.id as order_id, a.name as activity_name
FROM orders o
LEFT JOIN activities a ON a.id = o.activity_id
WHERE o.id IN ? AND o.activity_id > 0
`, orderIDs).Scan(&acts).Error
for _, a := range acts {
activityNameMap[a.OrderID] = a.ActivityName
}
}
// 组装明细数据
list := make([]profitLossDetailItem, len(orders))
var totalCost, totalValue int64
for i, o := range orders {
refund := refundMap[o.OrderNo]
prizeValue := prizeValueMap[o.ID]
couponValue := couponValueMap[o.ID]
netCost := o.ActualAmount - refund
netProfit := prizeValue - netCost
list[i] = profitLossDetailItem{
OrderID: o.ID,
OrderNo: o.OrderNo,
CreatedAt: o.CreatedAt.Format("2006-01-02 15:04:05"),
SourceType: o.SourceType,
ActivityName: activityNameMap[o.ID],
ActualAmount: o.ActualAmount,
RefundAmount: refund,
NetCost: netCost,
PrizeValue: prizeValue,
PrizeName: prizeNameMap[o.ID],
PointsEarned: 0, // 简化处理
PointsValue: 0,
CouponUsedValue: couponValue,
CouponUsedName: couponNameMap[o.ID],
ItemCardUsed: "", // 从订单备注中解析
ItemCardValue: 0,
NetProfit: netProfit,
}
// 解析道具卡信息(从订单备注)
if o.Remark != "" {
list[i].ItemCardUsed = parseItemCardFromRemark(o.Remark)
}
totalCost += netCost
totalValue += prizeValue
}
resp := profitLossDetailsResponse{
Page: req.Page,
PageSize: req.PageSize,
Total: total,
List: list,
}
resp.Summary.TotalCost = totalCost
resp.Summary.TotalValue = totalValue
resp.Summary.TotalProfit = totalValue - totalCost
ctx.Payload(resp)
}
}
// 从订单备注中解析道具卡信息
func parseItemCardFromRemark(remark string) string {
// 格式: itemcard:xxx|...
if len(remark) == 0 {
return ""
}
idx := 0
for i := 0; i < len(remark); i++ {
if remark[i:] == "itemcard:" || (i+9 <= len(remark) && remark[i:i+9] == "itemcard:") {
idx = i
break
}
}
if idx == 0 && len(remark) < 9 {
return ""
}
if idx+9 >= len(remark) {
return ""
}
seg := remark[idx+9:]
// 找到 | 分隔符
end := len(seg)
for i := 0; i < len(seg); i++ {
if seg[i] == '|' {
end = i
break
}
}
return seg[:end]
}

View File

@ -38,13 +38,13 @@ type listAppProductsRequest struct {
}
type listAppProductsItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
MainImage string `json:"main_image"`
Price int64 `json:"price"`
PointsRequired int64 `json:"points_required"`
Sales int64 `json:"sales"`
InStock bool `json:"in_stock"`
ID int64 `json:"id"`
Name string `json:"name"`
MainImage string `json:"main_image"`
Price int64 `json:"price"`
PointsRequired float64 `json:"points_required"` // 积分(分/rate`
Sales int64 `json:"sales"`
InStock bool `json:"in_stock"`
}
type listAppProductsResponse struct {
@ -87,7 +87,7 @@ func (h *productHandler) ListProductsForApp() core.HandlerFunc {
}
rsp := &listAppProductsResponse{Total: total, CurrentPage: req.Page, PageSize: req.PageSize, List: make([]listAppProductsItem, len(items))}
for i, it := range items {
pts, _ := h.user.CentsToPoints(ctx.RequestContext(), it.Price)
pts := h.user.CentsToPointsFloat(ctx.RequestContext(), it.Price)
rsp.List[i] = listAppProductsItem{ID: it.ID, Name: it.Name, MainImage: it.MainImage, Price: it.Price, PointsRequired: pts, Sales: it.Sales, InStock: it.InStock}
}
ctx.Payload(rsp)
@ -99,7 +99,7 @@ type getAppProductDetailResponse struct {
Name string `json:"name"`
Album []string `json:"album"`
Price int64 `json:"price"`
PointsRequired int64 `json:"points_required"`
PointsRequired float64 `json:"points_required"` // 积分(分/rate`
Sales int64 `json:"sales"`
Stock int64 `json:"stock"`
Description string `json:"description"`
@ -135,10 +135,10 @@ func (h *productHandler) GetProductDetailForApp() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
return
}
ptsDetail, _ := h.user.CentsToPoints(ctx.RequestContext(), d.Price)
ptsDetail := h.user.CentsToPointsFloat(ctx.RequestContext(), d.Price)
rsp := &getAppProductDetailResponse{ID: d.ID, Name: d.Name, Album: d.Album, Price: d.Price, PointsRequired: ptsDetail, Sales: d.Sales, Stock: d.Stock, Description: d.Description, Service: d.Service, Recommendations: make([]listAppProductsItem, len(d.Recommendations))}
for i, it := range d.Recommendations {
ptsRec, _ := h.user.CentsToPoints(ctx.RequestContext(), it.Price)
ptsRec := h.user.CentsToPointsFloat(ctx.RequestContext(), it.Price)
rsp.Recommendations[i] = listAppProductsItem{ID: it.ID, Name: it.Name, MainImage: it.MainImage, Price: it.Price, PointsRequired: ptsRec, Sales: it.Sales, InStock: it.InStock}
}
ctx.Payload(rsp)

View File

@ -25,21 +25,24 @@ type listStoreItemsRequest struct {
Kind string `form:"kind"`
Page int `form:"page"`
PageSize int `form:"page_size"`
Keyword string `form:"keyword"` // 关键词搜索
PriceMin *int64 `form:"price_min"` // 最低积分价格(积分单位)
PriceMax *int64 `form:"price_max"` // 最高积分价格(积分单位)
}
type listStoreItem struct {
ID int64 `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
MainImage string `json:"main_image"`
Price int64 `json:"price"`
PointsRequired int64 `json:"points_required"`
InStock bool `json:"in_stock"`
Status int32 `json:"status"`
DiscountType int32 `json:"discount_type"`
DiscountValue int64 `json:"discount_value"`
MinSpend int64 `json:"min_spend"`
Supported bool `json:"supported"`
ID int64 `json:"id"`
Kind string `json:"kind"`
Name string `json:"name"`
MainImage string `json:"main_image"`
Price int64 `json:"price"`
PointsRequired float64 `json:"points_required"` // 积分(分/rate`
InStock bool `json:"in_stock"`
Status int32 `json:"status"`
DiscountType int32 `json:"discount_type"`
DiscountValue int64 `json:"discount_value"`
MinSpend int64 `json:"min_spend"`
Supported bool `json:"supported"`
}
type listStoreItemsResponse struct {
@ -83,32 +86,76 @@ func (h *storeHandler) ListStoreItemsForApp() core.HandlerFunc {
offset := (req.Page - 1) * req.PageSize
limit := req.PageSize
// 将积分价格转换为分进行查询
var priceMinCents, priceMaxCents int64
if req.PriceMin != nil && *req.PriceMin > 0 {
centsVal, _ := h.user.PointsToCents(ctx.RequestContext(), *req.PriceMin)
priceMinCents = centsVal
}
if req.PriceMax != nil && *req.PriceMax > 0 {
centsVal, _ := h.user.PointsToCents(ctx.RequestContext(), *req.PriceMax)
priceMaxCents = centsVal
}
switch req.Kind {
case "item_card":
q := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemItemCards.Status.Eq(1))
// 关键词筛选
if req.Keyword != "" {
q = q.Where(h.readDB.SystemItemCards.Name.Like("%" + req.Keyword + "%"))
}
// 价格区间筛选
if priceMinCents > 0 {
q = q.Where(h.readDB.SystemItemCards.Price.Gte(priceMinCents))
}
if priceMaxCents > 0 {
q = q.Where(h.readDB.SystemItemCards.Price.Lte(priceMaxCents))
}
total, _ = q.Count()
rows, _ := q.Order(h.readDB.SystemItemCards.ID.Desc()).Offset(offset).Limit(limit).Find()
list = make([]listStoreItem, len(rows))
for i, it := range rows {
pts, _ := h.user.CentsToPoints(ctx.RequestContext(), it.Price)
pts := h.user.CentsToPointsFloat(ctx.RequestContext(), it.Price)
list[i] = listStoreItem{ID: it.ID, Kind: "item_card", Name: it.Name, Price: it.Price, PointsRequired: pts, Status: it.Status, Supported: true}
}
case "coupon":
q := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.SystemCoupons.Status.Eq(1))
// 关键词筛选
if req.Keyword != "" {
q = q.Where(h.readDB.SystemCoupons.Name.Like("%" + req.Keyword + "%"))
}
// 价格区间筛选 (优惠券用 DiscountValue)
if priceMinCents > 0 {
q = q.Where(h.readDB.SystemCoupons.DiscountValue.Gte(priceMinCents))
}
if priceMaxCents > 0 {
q = q.Where(h.readDB.SystemCoupons.DiscountValue.Lte(priceMaxCents))
}
total, _ = q.Count()
rows, _ := q.Order(h.readDB.SystemCoupons.ID.Desc()).Offset(offset).Limit(limit).Find()
list = make([]listStoreItem, len(rows))
for i, it := range rows {
pts, _ := h.user.CentsToPoints(ctx.RequestContext(), it.DiscountValue)
pts := h.user.CentsToPointsFloat(ctx.RequestContext(), it.DiscountValue)
list[i] = listStoreItem{ID: it.ID, Kind: "coupon", Name: it.Name, DiscountType: it.DiscountType, DiscountValue: it.DiscountValue, PointsRequired: pts, MinSpend: it.MinSpend, Status: it.Status, Supported: it.DiscountType == 1}
}
default: // product
q := h.readDB.Products.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.Products.Status.Eq(1))
// 关键词筛选
if req.Keyword != "" {
q = q.Where(h.readDB.Products.Name.Like("%" + req.Keyword + "%"))
}
// 价格区间筛选
if priceMinCents > 0 {
q = q.Where(h.readDB.Products.Price.Gte(priceMinCents))
}
if priceMaxCents > 0 {
q = q.Where(h.readDB.Products.Price.Lte(priceMaxCents))
}
total, _ = q.Count()
rows, _ := q.Order(h.readDB.Products.ID.Desc()).Offset(offset).Limit(limit).Find()
list = make([]listStoreItem, len(rows))
for i, it := range rows {
pts, _ := h.user.CentsToPoints(ctx.RequestContext(), it.Price)
pts := h.user.CentsToPointsFloat(ctx.RequestContext(), it.Price)
list[i] = listStoreItem{ID: it.ID, Kind: "product", Name: it.Name, MainImage: parseFirstImage(it.ImagesJSON), Price: it.Price, PointsRequired: pts, InStock: it.Stock > 0 && it.Status == 1, Status: it.Status, Supported: true}
}
}

View File

@ -0,0 +1,50 @@
package common
import (
"bindbox-game/internal/pkg/core"
)
type ConfigResponse struct {
SubscribeTemplates map[string]string `json:"subscribe_templates"`
ContactServiceQRCode string `json:"contact_service_qrcode"` // 客服二维码
}
// GetPublicConfig 获取公开配置(包含订阅模板ID)
// @Summary 获取公开配置
// @Description 获取小程序前端需要用到的公开配置如订阅消息模板ID
// @Tags 公共
// @Accept json
// @Produce json
// @Success 200 {object} ConfigResponse
// @Router /api/app/config/public [get]
func (h *handler) GetPublicConfig() core.HandlerFunc {
return func(ctx core.Context) {
// 查询配置
var subscribeTemplateID string
var serviceQRCode string
configs, err := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).
Where(h.readDB.SystemConfigs.ConfigKey.In("wechat.lottery_result_template_id", "contact.service_qrcode")).
Find()
if err == nil {
for _, cfg := range configs {
switch cfg.ConfigKey {
case "wechat.lottery_result_template_id":
subscribeTemplateID = cfg.ConfigValue
case "contact.service_qrcode":
serviceQRCode = cfg.ConfigValue
}
}
}
rsp := ConfigResponse{
SubscribeTemplates: map[string]string{
"lottery_result": subscribeTemplateID,
},
ContactServiceQRCode: serviceQRCode,
}
ctx.Payload(rsp)
}
}

View File

@ -6,6 +6,9 @@ import (
"bindbox-game/configs"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/service/sysconfig"
"go.uber.org/zap"
)
type openidRequest struct {
@ -26,8 +29,19 @@ func (h *handler) GetOpenID() core.HandlerFunc {
return
}
cfg := configs.Get()
wxcfg := &wechat.WechatConfig{AppID: cfg.Wechat.AppID, AppSecret: cfg.Wechat.AppSecret}
// 使用动态配置
wxcfg := &wechat.WechatConfig{}
if dc := sysconfig.GetDynamicConfig(); dc != nil {
c := dc.GetWechat(ctx.RequestContext().Context)
wxcfg.AppID = c.AppID
wxcfg.AppSecret = c.AppSecret
} else {
cfg := configs.Get()
wxcfg.AppID = cfg.Wechat.AppID
wxcfg.AppSecret = cfg.Wechat.AppSecret
}
h.logger.Info("GetOpenID Config", zap.String("AppID", wxcfg.AppID), zap.String("AppSecret", wxcfg.AppSecret))
c2s, err := wechat.Code2Session(ctx.RequestContext().Context, wxcfg, req.Code)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error()))

View File

@ -12,6 +12,7 @@ import (
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
@ -191,16 +192,20 @@ func (h *handler) EnterGame() core.HandlerFunc {
}
// 查询剩余次数
ticket, _ := h.ticketSvc.GetUserTicketByGame(ctx.RequestContext(), userID, req.GameCode)
remaining := 0
if ticket != nil {
remaining = int(ticket.Available)
if req.GameCode == "minesweeper_free" {
remaining = 999999 // Represent infinite for free mode
} else {
ticket, _ := h.ticketSvc.GetUserTicketByGame(ctx.RequestContext(), userID, req.GameCode)
if ticket != nil {
remaining = int(ticket.Available)
}
}
// 从系统配置读取Nakama服务器信息
nakamaServer := "wss://nakama.yourdomain.com"
nakamaServer := "ws://127.0.0.1:7350"
nakamaKey := "defaultkey"
clientUrl := "https://game.1024tool.vip"
clientUrl := "http://127.0.0.1:9991" // 指向当前后端地址作为默认
configKey := "game_" + req.GameCode + "_config"
// map generic game code to specific config key if needed, or just use convention
if req.GameCode == "minesweeper" {
@ -312,8 +317,21 @@ func (h *handler) VerifyTicket() core.HandlerFunc {
}
// 从Redis验证token
storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result()
if err != nil || storedUserID != req.UserID {
storedValue, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result()
if err != nil {
ctx.Payload(&verifyResponse{Valid: false})
return
}
// Parse "userID:gameType"
parts := strings.Split(storedValue, ":")
if len(parts) < 2 {
ctx.Payload(&verifyResponse{Valid: false})
return
}
storedUserID := parts[0]
if storedUserID != req.UserID {
ctx.Payload(&verifyResponse{Valid: false})
return
}
@ -331,11 +349,12 @@ func (h *handler) VerifyTicket() core.HandlerFunc {
}
type settleRequest struct {
UserID string `json:"user_id"`
Ticket string `json:"ticket"`
MatchID string `json:"match_id"`
Win bool `json:"win"`
Score int `json:"score"`
UserID string `json:"user_id"`
Ticket string `json:"ticket"`
MatchID string `json:"match_id"`
Win bool `json:"win"`
Score int `json:"score"`
GameType string `json:"game_type"` // 游戏类型,如 "minesweeper" 或 "minesweeper_free"
}
type settleResponse struct {
@ -357,17 +376,39 @@ func (h *handler) SettleGame() core.HandlerFunc {
return
}
// 验证token可选如果游戏服务器传了ticket则验证否则信任internal调用
// 直接从请求参数判断是否为免费模式
isFreeMode := req.GameType == "minesweeper_free"
// 拦截免费场结算(免费模式不发放任何奖励)
if isFreeMode {
h.logger.Info("Free mode game settled without rewards",
zap.String("user_id", req.UserID),
zap.String("match_id", req.MatchID),
zap.Bool("win", req.Win))
ctx.Payload(&settleResponse{Success: true, Reward: "体验模式无奖励"})
return
}
// 验证 ticket可选用于防止重复结算
if req.Ticket != "" {
storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result()
if err != nil || storedUserID != req.UserID {
h.logger.Warn("Ticket validation failed, but proceeding with internal trust",
zap.String("ticket", req.Ticket), zap.String("user_id", req.UserID))
storedValue, err := h.redis.Get(ctx.RequestContext(), "game:token:ticket:"+req.Ticket).Result()
if err != nil {
h.logger.Warn("Ticket validation failed (not found)", zap.String("ticket", req.Ticket))
} else {
// 删除token防止重复使用
h.redis.Del(ctx.RequestContext(), "game:token:ticket:"+req.Ticket)
// Parse "userID:gameType"
parts := strings.Split(storedValue, ":")
storedUserID := parts[0]
if storedUserID != req.UserID {
h.logger.Warn("Ticket validation failed (user mismatch)",
zap.String("ticket", req.Ticket), zap.String("user_id", req.UserID), zap.String("stored", storedUserID))
} else {
// 删除 ticket 防止重复使用
h.redis.Del(ctx.RequestContext(), "game:token:ticket:"+req.Ticket)
}
}
}
// 注意即使ticket验证失败作为internal API我们仍然信任游戏服务器传来的UserID
// 奖品发放逻辑
@ -406,7 +447,8 @@ func (h *handler) SettleGame() core.HandlerFunc {
}
}
// 3. 发放奖励
// 3. 发放奖励(仅付费模式,免费模式已在前面拦截)
if targetProductID > 0 {
res, err := h.userSvc.GrantReward(ctx.RequestContext(), uid, usersvc.GrantRewardRequest{
ProductID: targetProductID,
@ -468,11 +510,16 @@ func (h *handler) ConsumeTicket() core.HandlerFunc {
}
// 扣减游戏次数
err := h.ticketSvc.UseTicket(ctx.RequestContext(), uid, gameCode)
if err != nil {
h.logger.Error("Failed to consume ticket", zap.Int64("user_id", uid), zap.String("game_code", gameCode), zap.Error(err))
ctx.Payload(&consumeTicketResponse{Success: false, Error: err.Error()})
return
if gameCode == "minesweeper_free" {
// 免费场场不扣减次数,直接通过
h.logger.Info("Free mode consume ticket skipped deduction", zap.Int64("user_id", uid))
} else {
err := h.ticketSvc.UseTicket(ctx.RequestContext(), uid, gameCode)
if err != nil {
h.logger.Error("Failed to consume ticket", zap.Int64("user_id", uid), zap.String("game_code", gameCode), zap.Error(err))
ctx.Payload(&consumeTicketResponse{Success: false, Error: err.Error()})
return
}
}
// 使 ticket 失效(防止重复扣减)

View File

@ -0,0 +1,331 @@
package game_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
)
// settleRequest 结算请求结构体(与 handler.go 保持一致)
type settleRequest struct {
UserID string `json:"user_id"`
Ticket string `json:"ticket"`
MatchID string `json:"match_id"`
Win bool `json:"win"`
Score int `json:"score"`
GameType string `json:"game_type"`
}
// settleResponse 结算响应结构体
type settleResponse struct {
Success bool `json:"success"`
Reward string `json:"reward,omitempty"`
}
// TestSettleGame_FreeModeDetection 测试免费模式判断逻辑
// 这是核心测试:验证免费模式通过 game_type 参数判断,而不是依赖 Redis
func TestSettleGame_FreeModeDetection(t *testing.T) {
tests := []struct {
name string
gameType string
ticketInRedis bool // 是否在 Redis 中存储 ticket
expectedReward string // 预期的奖励消息
shouldBlock bool // 是否应该被拦截(免费模式)
}{
{
name: "免费模式_有ticket_应拦截",
gameType: "minesweeper_free",
ticketInRedis: true,
expectedReward: "体验模式无奖励",
shouldBlock: true,
},
{
name: "免费模式_无ticket_应拦截",
gameType: "minesweeper_free",
ticketInRedis: false,
expectedReward: "体验模式无奖励",
shouldBlock: true,
},
{
name: "付费模式_有ticket_应发奖",
gameType: "minesweeper",
ticketInRedis: true,
expectedReward: "", // 付费模式会发放积分奖励
shouldBlock: false,
},
{
name: "付费模式_无ticket_应发奖",
gameType: "minesweeper",
ticketInRedis: false,
expectedReward: "", // 付费模式会发放积分奖励
shouldBlock: false,
},
{
name: "空game_type_应发奖",
gameType: "",
ticketInRedis: false,
expectedReward: "", // 空类型不是免费模式
shouldBlock: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 模拟判断逻辑
isFreeMode := tt.gameType == "minesweeper_free"
if tt.shouldBlock {
assert.True(t, isFreeMode, "免费模式应该被正确识别")
} else {
assert.False(t, isFreeMode, "非免费模式不应该被拦截")
}
})
}
}
// TestSettleGame_FreeModeWithRedis 测试 Redis ticket 不影响免费模式判断
func TestSettleGame_FreeModeWithRedis(t *testing.T) {
// 1. 启动 miniredis
mr, err := miniredis.Run()
assert.NoError(t, err)
defer mr.Close()
rdb := redis.NewClient(&redis.Options{
Addr: mr.Addr(),
})
ctx := context.Background()
userID := "12345"
ticket := "GT123456789"
// 场景1: Redis 中有 ticket但 game_type 是免费模式
t.Run("Redis有ticket但game_type是免费模式", func(t *testing.T) {
// 存储 ticket 到 Redis
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
rdb.Set(ctx, ticketKey, fmt.Sprintf("%s:minesweeper_free", userID), 30*time.Minute)
req := settleRequest{
UserID: userID,
Ticket: ticket,
MatchID: "match-001",
Win: true,
Score: 100,
GameType: "minesweeper_free",
}
// 直接从 req.GameType 判断
isFreeMode := req.GameType == "minesweeper_free"
assert.True(t, isFreeMode, "应该识别为免费模式")
// 清理
rdb.Del(ctx, ticketKey)
})
// 场景2: Redis 中没有 ticket已被删除但 game_type 是免费模式
t.Run("Redis无ticket但game_type是免费模式", func(t *testing.T) {
req := settleRequest{
UserID: userID,
Ticket: ticket,
MatchID: "match-002",
Win: true,
Score: 100,
GameType: "minesweeper_free",
}
// 确认 Redis 中没有 ticket
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
_, err := rdb.Get(ctx, ticketKey).Result()
assert.Error(t, err, "ticket 应该不存在")
// 直接从 req.GameType 判断(修复后的逻辑)
isFreeMode := req.GameType == "minesweeper_free"
assert.True(t, isFreeMode, "即使 Redis 中没有 ticket也应该识别为免费模式")
})
// 场景3: Redis 中有 ticket 且是免费模式,但 game_type 参数为空(防止绕过)
t.Run("Redis标记免费但game_type参数为空", func(t *testing.T) {
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
rdb.Set(ctx, ticketKey, fmt.Sprintf("%s:minesweeper_free", userID), 30*time.Minute)
req := settleRequest{
UserID: userID,
Ticket: ticket,
MatchID: "match-003",
Win: true,
Score: 100,
GameType: "", // 恶意留空
}
// 使用修复后的逻辑:以请求参数为准
isFreeMode := req.GameType == "minesweeper_free"
assert.False(t, isFreeMode, "game_type 为空时不应识别为免费模式")
// 注意:这里是一个潜在的安全风险,需要确保游戏服务器正确传递 game_type
// 建议:可以增加双重校验,从 Redis 读取作为备份
rdb.Del(ctx, ticketKey)
})
}
// TestSettleGame_OldBugScenario 重现并验证旧 bug 已被修复
func TestSettleGame_OldBugScenario(t *testing.T) {
// 模拟旧代码的问题场景
t.Run("旧bug重现_ticket被删除后误判为付费模式", func(t *testing.T) {
mr, _ := miniredis.Run()
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
ctx := context.Background()
userID := "12345"
ticket := "GT123456789"
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
// 模拟场景:
// 1. 用户进入免费游戏ticket 存入 Redis
rdb.Set(ctx, ticketKey, fmt.Sprintf("%s:minesweeper_free", userID), 30*time.Minute)
// 2. 匹配成功后ticket 被删除
rdb.Del(ctx, ticketKey)
// 3. 游戏结算时尝试读取 ticket
_, err := rdb.Get(ctx, ticketKey).Result()
assert.Error(t, err, "ticket 应该已被删除")
// --- 旧代码逻辑(有 bug---
oldIsFreeMode := false
if err == nil {
// 只有在 Redis 中找到 ticket 时才能判断
// 这里 err != nil所以 isFreeMode 保持 false
}
assert.False(t, oldIsFreeMode, "旧代码ticket 被删除后无法判断免费模式")
// --- 新代码逻辑(已修复)---
req := settleRequest{
UserID: userID,
Ticket: ticket,
GameType: "minesweeper_free", // 直接从请求参数获取
}
newIsFreeMode := req.GameType == "minesweeper_free"
assert.True(t, newIsFreeMode, "新代码:直接从 game_type 判断,不受 Redis 影响")
})
}
// TestSettleGame_Integration 集成测试(模拟完整的 HTTP 请求)
func TestSettleGame_Integration(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
request settleRequest
expectedStatus int
checkResponse func(t *testing.T, body []byte)
}{
{
name: "免费模式结算_应返回体验模式无奖励",
request: settleRequest{
UserID: "12345",
Ticket: "GT123456789",
MatchID: "match-001",
Win: true,
Score: 100,
GameType: "minesweeper_free",
},
expectedStatus: http.StatusOK,
checkResponse: func(t *testing.T, body []byte) {
var resp settleResponse
err := json.Unmarshal(body, &resp)
assert.NoError(t, err)
assert.True(t, resp.Success)
assert.Equal(t, "体验模式无奖励", resp.Reward)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// 创建模拟的 handler简化版仅测试免费模式判断逻辑
router := gin.New()
router.POST("/internal/game/settle", func(c *gin.Context) {
var req settleRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// 核心逻辑:直接从请求参数判断
isFreeMode := req.GameType == "minesweeper_free"
if isFreeMode {
c.JSON(http.StatusOK, settleResponse{
Success: true,
Reward: "体验模式无奖励",
})
return
}
// 付费模式发奖逻辑(简化)
c.JSON(http.StatusOK, settleResponse{
Success: true,
Reward: "100积分",
})
})
// 发送请求
body, _ := json.Marshal(tt.request)
req, _ := http.NewRequest("POST", "/internal/game/settle", bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
assert.Equal(t, tt.expectedStatus, w.Code)
if tt.checkResponse != nil {
respBody, _ := io.ReadAll(w.Body)
tt.checkResponse(t, respBody)
}
})
}
}
// BenchmarkFreeModeCheck 性能测试:对比新旧实现
func BenchmarkFreeModeCheck(b *testing.B) {
// 旧实现:需要查询 Redis
b.Run("旧实现_Redis查询", func(b *testing.B) {
mr, _ := miniredis.Run()
defer mr.Close()
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
ctx := context.Background()
ticket := "GT123456789"
ticketKey := fmt.Sprintf("game:token:ticket:%s", ticket)
rdb.Set(ctx, ticketKey, "12345:minesweeper_free", 30*time.Minute)
b.ResetTimer()
for i := 0; i < b.N; i++ {
val, err := rdb.Get(ctx, ticketKey).Result()
if err == nil {
_ = val == "12345:minesweeper_free"
}
}
})
// 新实现:直接比较字符串
b.Run("新实现_字符串比较", func(b *testing.B) {
gameType := "minesweeper_free"
b.ResetTimer()
for i := 0; i < b.N; i++ {
_ = gameType == "minesweeper_free"
}
})
}

View File

@ -8,7 +8,6 @@ import (
"strings"
"time"
"bindbox-game/configs"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/pay"
@ -16,6 +15,7 @@ import (
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/sysconfig"
"go.uber.org/zap"
@ -23,7 +23,6 @@ import (
"github.com/wechatpay-apiv3/wechatpay-go/core/downloader"
"github.com/wechatpay-apiv3/wechatpay-go/core/notify"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
type notifyAck struct {
@ -45,39 +44,61 @@ type notifyAck struct {
// @Router /pay/wechat/notify [post]
func (h *handler) WechatNotify() core.HandlerFunc {
return func(ctx core.Context) {
c := configs.Get()
if c.WechatPay.ApiV3Key == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay config incomplete"))
// Use dynamic configurations exclusively
dc := sysconfig.GetDynamicConfig()
cfg := dc.GetWechatPay(ctx.RequestContext().Context)
if cfg.ApiV3Key == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay config (ApiV3Key) missing"))
return
}
var handler *notify.Handler
if c.WechatPay.PublicKeyID != "" && c.WechatPay.PublicKeyPath != "" {
pubKey, err := utils.LoadPublicKeyWithPath(c.WechatPay.PublicKeyPath)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150001, err.Error()))
mchID := cfg.MchID
serialNo := cfg.SerialNo
apiV3Key := cfg.ApiV3Key
publicKeyID := cfg.PublicKeyID
var notifyHandler *notify.Handler
if publicKeyID != "" {
// 使用公钥验签模式
if cfg.PublicKey == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay public key content missing"))
return
}
handler = notify.NewNotifyHandler(c.WechatPay.ApiV3Key, verifiers.NewSHA256WithRSAPubkeyVerifier(c.WechatPay.PublicKeyID, *pubKey))
pubKey, err := pay.LoadPublicKeyFromBase64(cfg.PublicKey)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150001, "load public key err: "+err.Error()))
return
}
notifyHandler = notify.NewNotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAPubkeyVerifier(publicKeyID, *pubKey))
} else {
if c.WechatPay.MchID == "" || c.WechatPay.SerialNo == "" || c.WechatPay.PrivateKeyPath == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay config incomplete"))
// 使用证书自动下载模式
if mchID == "" || serialNo == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay mchid/serial_no missing for cert mode"))
return
}
mchPrivateKey, err := utils.LoadPrivateKeyWithPath(c.WechatPay.PrivateKeyPath)
if cfg.PrivateKey == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150000, "wechat pay private key missing"))
return
}
mchPrivateKey, err := pay.LoadPrivateKeyFromBase64(cfg.PrivateKey)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150002, err.Error()))
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150002, "load private key err: "+err.Error()))
return
}
if err := downloader.MgrInstance().RegisterDownloaderWithPrivateKey(ctx.RequestContext(), mchPrivateKey, c.WechatPay.SerialNo, c.WechatPay.MchID, c.WechatPay.ApiV3Key); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150003, err.Error()))
if err := downloader.MgrInstance().RegisterDownloaderWithPrivateKey(ctx.RequestContext().Context, mchPrivateKey, serialNo, mchID, apiV3Key); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 150003, "register downloader err: "+err.Error()))
return
}
certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(c.WechatPay.MchID)
handler = notify.NewNotifyHandler(c.WechatPay.ApiV3Key, verifiers.NewSHA256WithRSAVerifier(certificateVisitor))
certificateVisitor := downloader.MgrInstance().GetCertificateVisitor(mchID)
notifyHandler = notify.NewNotifyHandler(apiV3Key, verifiers.NewSHA256WithRSAVerifier(certificateVisitor))
}
var transaction payments.Transaction
notification, err := handler.ParseNotifyRequest(ctx.RequestContext(), ctx.Request(), &transaction)
notification, err := notifyHandler.ParseNotifyRequest(ctx.RequestContext().Context, ctx.Request(), &transaction)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, err.Error()))
return
@ -278,6 +299,18 @@ func (h *handler) WechatNotify() core.HandlerFunc {
rmk := remark.Parse(ord.Remark)
act, _ := h.readDB.Activities.WithContext(bgCtx).Where(h.readDB.Activities.ID.Eq(rmk.ActivityID)).First()
// 获取微信配置 (动态)
var wxConfig *wechat.WechatConfig
if dc := sysconfig.GetDynamicConfig(); dc != nil {
cfg := dc.GetWechat(bgCtx)
wxConfig = &wechat.WechatConfig{AppID: cfg.AppID, AppSecret: cfg.AppSecret}
}
if wxConfig == nil || wxConfig.AppID == "" {
h.logger.Error("微信配置缺失(AppID为空),跳过虚拟发货/抽奖", zap.String("order_no", order.OrderNo))
return
}
if ord.SourceType == 2 && act != nil && act.DrawMode == "instant" {
_ = h.activity.ProcessOrderLottery(bgCtx, ord.ID)
} else if ord.SourceType == 4 {
@ -311,7 +344,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
}
return ""
}(); txID != "" {
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil {
if err := wechat.UploadVirtualShippingForBackground(bgCtx, wxConfig, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil {
h.logger.Error("次数卡虚拟发货失败", zap.Error(err), zap.String("order_no", ord.OrderNo))
} else {
h.logger.Info("次数卡虚拟发货成功", zap.String("order_no", ord.OrderNo))
@ -330,7 +363,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
}
return ""
}(); txID != "" {
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil {
if err := wechat.UploadVirtualShippingForBackground(bgCtx, wxConfig, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil {
h.logger.Error("对对碰虚拟发货失败", zap.Error(err), zap.String("order_no", ord.OrderNo))
} else {
h.logger.Info("对对碰虚拟发货成功", zap.String("order_no", ord.OrderNo))
@ -349,7 +382,7 @@ func (h *handler) WechatNotify() core.HandlerFunc {
}
return ""
}(); txID != "" {
if err := wechat.UploadVirtualShippingForBackground(bgCtx, &wechat.WechatConfig{AppID: configs.Get().Wechat.AppID, AppSecret: configs.Get().Wechat.AppSecret}, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil {
if err := wechat.UploadVirtualShippingForBackground(bgCtx, wxConfig, txID, ord.OrderNo, payerOpenid, itemsDesc); err != nil {
h.logger.Error("商户订单虚拟发货失败", zap.Error(err), zap.String("order_no", ord.OrderNo))
} else {
h.logger.Info("商户订单虚拟发货成功", zap.String("order_no", ord.OrderNo))

View File

@ -0,0 +1,454 @@
package public
import (
"fmt"
"net/http"
"time"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/model"
douyinsvc "bindbox-game/internal/service/douyin"
gamesvc "bindbox-game/internal/service/game"
livestreamsvc "bindbox-game/internal/service/livestream"
"go.uber.org/zap"
"gorm.io/gorm"
)
type handler struct {
logger logger.CustomLogger
repo mysql.Repo
livestream livestreamsvc.Service
douyin douyinsvc.Service
}
// New 创建公开接口处理器
func New(l logger.CustomLogger, repo mysql.Repo, douyin douyinsvc.Service) *handler {
ticketSvc := gamesvc.NewTicketService(l, repo)
return &handler{
logger: l,
repo: repo,
livestream: livestreamsvc.New(l, repo, ticketSvc),
douyin: douyin,
}
}
// ========== 直播间公开接口 ==========
type publicActivityResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
StreamerName string `json:"streamer_name"`
Status int32 `json:"status"`
StartTime string `json:"start_time,omitempty"`
EndTime string `json:"end_time,omitempty"`
Prizes []publicPrizeResponse `json:"prizes"`
}
type publicPrizeResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Image string `json:"image"`
Level int32 `json:"level"`
Remaining int32 `json:"remaining"`
Probability string `json:"probability"`
Weight int32 `json:"weight"`
}
// GetLivestreamByAccessCode 根据访问码获取直播间活动详情
// @Summary 获取直播间活动详情(公开)
// @Description 根据访问码获取直播间活动和奖品信息,无需登录
// @Tags 公开接口.直播间
// @Accept json
// @Produce json
// @Param access_code path string true "访问码"
// @Success 200 {object} publicActivityResponse
// @Failure 404 {object} code.Failure
// @Router /api/public/livestream/{access_code} [get]
func (h *handler) GetLivestreamByAccessCode() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10002, "活动不存在或已结束"))
return
}
prizes, _ := h.livestream.ListPrizes(ctx.RequestContext(), activity.ID)
res := &publicActivityResponse{
ID: activity.ID,
Name: activity.Name,
StreamerName: activity.StreamerName,
Status: activity.Status,
Prizes: make([]publicPrizeResponse, len(prizes)),
}
if !activity.StartTime.IsZero() {
res.StartTime = activity.StartTime.Format("2006-01-02 15:04:05")
}
if !activity.EndTime.IsZero() {
res.EndTime = activity.EndTime.Format("2006-01-02 15:04:05")
}
// 计算总权重 (仅统计有库存的)
var totalWeight int64
for _, p := range prizes {
if p.Remaining != 0 {
totalWeight += int64(p.Weight)
}
}
for i, p := range prizes {
probStr := "0%"
if p.Remaining != 0 && totalWeight > 0 {
prob := (float64(p.Weight) / float64(totalWeight)) * 100
probStr = fmt.Sprintf("%.2f%%", prob)
}
res.Prizes[i] = publicPrizeResponse{
ID: p.ID,
Name: p.Name,
Image: p.Image,
Level: p.Level,
Remaining: p.Remaining,
Probability: probStr,
Weight: p.Weight,
}
}
ctx.Payload(res)
}
}
type publicDrawLogResponse struct {
PrizeName string `json:"prize_name"`
Level int32 `json:"level"`
DouyinUserID string `json:"douyin_user_id"`
CreatedAt string `json:"created_at"`
}
type listPublicDrawLogsResponse struct {
List []publicDrawLogResponse `json:"list"`
Total int64 `json:"total"`
}
// GetLivestreamWinners 获取中奖记录(公开)
// @Summary 获取直播间中奖记录(公开)
// @Description 根据访问码获取直播间中奖历史,无需登录
// @Tags 公开接口.直播间
// @Accept json
// @Produce json
// @Param access_code path string true "访问码"
// @Param page query int false "页码" default(1)
// @Param page_size query int false "每页数量" default(20)
// @Success 200 {object} listPublicDrawLogsResponse
// @Failure 404 {object} code.Failure
// @Router /api/public/livestream/{access_code}/winners [get]
func (h *handler) GetLivestreamWinners() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10002, "活动不存在"))
return
}
page := 1 // Default page 1
pageSize := 20 // Default pageSize 20
var startTime, endTime *time.Time
params := ctx.RequestInputParams()
if stStr := params.Get("start_time"); stStr != "" {
if t, err := time.Parse(time.RFC3339, stStr); err == nil {
startTime = &t
}
}
if etStr := params.Get("end_time"); etStr != "" {
if t, err := time.Parse(time.RFC3339, etStr); err == nil {
endTime = &t
}
}
logs, total, err := h.livestream.ListDrawLogs(ctx.RequestContext(), activity.ID, page, pageSize, startTime, endTime)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10003, err.Error()))
return
}
res := &listPublicDrawLogsResponse{
List: make([]publicDrawLogResponse, len(logs)),
Total: total,
}
for i, log := range logs {
// 隐藏部分抖音ID
maskedID := log.DouyinUserID
if len(maskedID) > 4 {
maskedID = maskedID[:2] + "****" + maskedID[len(maskedID)-2:]
}
res.List[i] = publicDrawLogResponse{
PrizeName: log.PrizeName,
Level: log.Level,
DouyinUserID: maskedID,
CreatedAt: log.CreatedAt.Format("2006-01-02 15:04:05"),
}
}
ctx.Payload(res)
}
}
// ========== 直播间抽奖接口 ==========
type drawRequest struct {
DouyinOrderID string `json:"shop_order_id" binding:"required"` // 店铺订单号
DouyinUserID string `json:"douyin_user_id"` // 可选,兼容旧逻辑
}
type drawReceipt struct {
SeedVersion int32 `json:"seed_version"`
Timestamp int64 `json:"timestamp"`
Nonce int64 `json:"nonce"`
Signature string `json:"signature"`
Algorithm string `json:"algorithm"`
}
type drawResponse struct {
PrizeID int64 `json:"prize_id"`
PrizeName string `json:"prize_name"`
PrizeImage string `json:"prize_image"`
Level int32 `json:"level"`
SeedHash string `json:"seed_hash"`
UserNickname string `json:"user_nickname"`
Receipt *drawReceipt `json:"receipt,omitempty"`
}
// DrawLivestream 执行直播间抽奖
// @Summary 执行直播间抽奖(公开)
// @Description 根据访问码执行抽奖需提供抖音用户ID
// @Tags 公开接口.直播间
// @Accept json
// @Produce json
// @Param access_code path string true "访问码"
// @Param body body drawRequest true "抽奖参数"
// @Success 200 {object} drawResponse
// @Failure 400 {object} code.Failure
// @Failure 404 {object} code.Failure
// @Router /api/public/livestream/{access_code}/draw [post]
func (h *handler) DrawLivestream() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
var req drawRequest
if err := ctx.ShouldBindJSON(&req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10002, "参数格式错误"))
return
}
activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10003, "活动不存在或已结束"))
return
}
if activity.Status != 1 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10004, "活动未开始"))
return
}
// 1. [核心重构] 根据店铺订单号查出本地记录并核销
var order model.DouyinOrders
db := h.repo.GetDbW().WithContext(ctx.RequestContext())
err = db.Where("shop_order_id = ?", req.DouyinOrderID).First(&order).Error
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10005, "订单不存在"))
return
}
if order.RewardGranted >= int32(order.ProductCount) {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, "该订单已完成抽奖,请勿重复操作"))
return
}
// 执行抽奖
result, err := h.livestream.Draw(ctx.RequestContext(), livestreamsvc.DrawInput{
ActivityID: activity.ID,
DouyinOrderID: order.ID,
ShopOrderID: order.ShopOrderID,
DouyinUserID: order.DouyinUserID,
UserNickname: order.UserNickname,
})
if err != nil {
// 检查是否为黑名单错误
if err.Error() == "该用户已被列入黑名单,无法开奖" {
ctx.AbortWithError(core.Error(http.StatusForbidden, 10008, err.Error()))
return
}
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10007, err.Error()))
return
}
// 标记订单已核销 (增加已发放计数)
// 使用 GORM 表达式更新,确保并发安全
// update douyin_orders set reward_granted = reward_granted + 1, updated_at = now() where id = ?
if err := db.Model(&order).Update("reward_granted", gorm.Expr("reward_granted + 1")).Error; err != nil {
h.logger.Error("[Draw] 更新订单发放状态失败", zap.String("order_id", order.ShopOrderID), zap.Error(err))
// 注意:这里虽然更新失败,但已执行抽奖,可能会导致用户少一次抽奖机会(计数没加),但为了防止超发,宁可少发。
// 理想情况是放在事务中,但 livestream.Draw 内部可能有独立事务。
}
res := &drawResponse{
PrizeID: result.Prize.ID,
PrizeName: result.Prize.Name,
PrizeImage: result.Prize.Image,
Level: result.Prize.Level,
SeedHash: result.SeedHash,
UserNickname: order.UserNickname,
}
// 填充凭证信息
if result.Receipt != nil {
res.Receipt = &drawReceipt{
SeedVersion: result.Receipt.SeedVersion,
Timestamp: result.Receipt.Timestamp,
Nonce: result.Receipt.Nonce,
Signature: result.Receipt.Signature,
Algorithm: result.Receipt.Algorithm,
}
}
ctx.Payload(res)
}
}
// SyncLivestreamOrders 触发全店订单同步并尝试发奖
func (h *handler) SyncLivestreamOrders() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
// 调用服务执行全量扫描 (基于时间更新覆盖最近1小时变化)
result, err := h.douyin.SyncAllOrders(ctx.RequestContext(), 1*time.Hour)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10004, err.Error()))
return
}
ctx.Payload(map[string]any{
"message": "同步完成",
"total_fetched": result.TotalFetched,
"new_orders": result.NewOrders,
"matched_users": result.MatchedUsers,
})
}
}
// GetLivestreamPendingOrders 获取当前活动的待抽奖订单(严格模式:防止窜台)
func (h *handler) GetLivestreamPendingOrders() core.HandlerFunc {
return func(ctx core.Context) {
accessCode := ctx.Param("access_code")
if accessCode == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "访问码不能为空"))
return
}
// ✅ 新增获取活动信息获取绑定的产品ID
activity, err := h.livestream.GetActivityByAccessCode(ctx.RequestContext(), accessCode)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusNotFound, 10002, "活动不存在或已结束"))
return
}
// ✅ 严格模式如果活动未绑定产品ID返回空列表防止"窜台"
if activity.DouyinProductID == "" {
h.logger.Warn("[GetPendingOrders] 活动未绑定产品ID返回空列表防止窜台",
zap.String("access_code", accessCode),
zap.Int64("activity_id", activity.ID))
// 返回空列表
type OrderWithBlacklist struct {
model.DouyinOrders
IsBlacklisted bool `json:"is_blacklisted"`
}
ctx.Payload([]OrderWithBlacklist{})
return
}
// [核心优化] 自动同步:每次拉取待抽奖列表前,静默执行一次快速全局扫描 (最近 10 分钟)
_, _ = h.douyin.SyncAllOrders(ctx.RequestContext(), 10*time.Minute)
// ✅ 修改添加产品ID过滤条件核心修复防止不同活动订单窜台
var pendingOrders []model.DouyinOrders
db := h.repo.GetDbR().WithContext(ctx.RequestContext())
err = db.Where("order_status = 2 AND reward_granted < product_count AND douyin_product_id = ?",
activity.DouyinProductID).
Find(&pendingOrders).Error
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10003, err.Error()))
return
}
// 查询黑名单用户
blacklistMap := make(map[string]bool)
if len(pendingOrders) > 0 {
var douyinUserIDs []string
for _, order := range pendingOrders {
if order.DouyinUserID != "" {
douyinUserIDs = append(douyinUserIDs, order.DouyinUserID)
}
}
if len(douyinUserIDs) > 0 {
var blacklistUsers []model.DouyinBlacklist
db.Table("douyin_blacklist").
Where("douyin_user_id IN ? AND status = 1", douyinUserIDs).
Find(&blacklistUsers)
for _, bl := range blacklistUsers {
blacklistMap[bl.DouyinUserID] = true
}
}
}
// 构造响应,包含黑名单状态
type OrderWithBlacklist struct {
model.DouyinOrders
IsBlacklisted bool `json:"is_blacklisted"`
}
result := make([]OrderWithBlacklist, len(pendingOrders))
for i, order := range pendingOrders {
result[i] = OrderWithBlacklist{
DouyinOrders: order,
IsBlacklisted: blacklistMap[order.DouyinUserID],
}
}
ctx.Payload(result)
}
}

View File

@ -8,6 +8,8 @@ import (
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/jwtoken"
"go.uber.org/zap"
)
type addressShareSubmitRequest struct {
@ -58,6 +60,9 @@ func (h *handler) SubmitAddressShare() core.HandlerFunc {
// 统一使用 ctx.RequestContext() 包含 context 内容
addrID, err := h.user.SubmitAddressShare(ctx.RequestContext(), req.ShareToken, req.Name, req.Mobile, req.Province, req.City, req.District, req.Address, submitUserID, &ip)
if err != nil {
// Log the error for debugging
h.logger.Error("SubmitAddressShare API Error", zap.Error(err), zap.String("token_masked", req.ShareToken[:10]+"..."))
// 处理业务错误,映射到具体代码
msg := err.Error()
errorCode := 10024

View File

@ -4,7 +4,11 @@ import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/service/douyin"
gamesvc "bindbox-game/internal/service/game"
"bindbox-game/internal/service/sysconfig"
tasksvc "bindbox-game/internal/service/task_center"
titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user"
)
@ -14,9 +18,21 @@ type handler struct {
readDB *dao.Query
user usersvc.Service
task tasksvc.Service
douyin douyin.Service
repo mysql.Repo
}
func New(logger logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
return &handler{logger: logger, writeDB: dao.Use(db.GetDbW()), readDB: dao.Use(db.GetDbR()), user: usersvc.New(logger, db), task: taskSvc, repo: db}
syscfgSvc := sysconfig.New(logger, db)
userSvc := usersvc.New(logger, db)
titleSvc := titlesvc.New(logger, db)
return &handler{
logger: logger,
writeDB: dao.Use(db.GetDbW()),
readDB: dao.Use(db.GetDbR()),
user: userSvc,
task: taskSvc,
douyin: douyin.New(logger, db, syscfgSvc, gamesvc.NewTicketService(logger, db), userSvc, titleSvc),
repo: db,
}
}

View File

@ -0,0 +1,77 @@
package app
import (
"net/http"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
)
type bindDouyinOrderRequest struct {
DouyinID string `json:"douyin_id"`
}
type bindDouyinOrderResponse struct {
DouyinID string `json:"douyin_id"`
}
// BindDouyinOrder 绑定抖音ID
// @Summary 绑定抖音ID
// @Description 输入抖音号Buyer ID绑定到当前用户
// @Tags APP端.用户
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param RequestBody body bindDouyinOrderRequest true "请求参数"
// @Success 200 {object} bindDouyinOrderResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/users/douyin/bind [post]
func (h *handler) BindDouyinOrder() core.HandlerFunc {
return func(ctx core.Context) {
req := new(bindDouyinOrderRequest)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if req.DouyinID == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "抖音号不能为空"))
return
}
currentUserID := int64(ctx.SessionUserInfo().Id)
// 0. 检查当前用户信息
currentUser, err := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(currentUserID)).First()
if err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "获取用户信息失败"))
return
}
// 如果已经绑定了相同的 ID直接返回成功
if currentUser.DouyinUserID == req.DouyinID {
ctx.Payload(&bindDouyinOrderResponse{DouyinID: req.DouyinID})
return
}
// 1. 检查该抖音号是否已被其他本地账号绑定
existedUser, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.DouyinUserID.Eq(req.DouyinID)).First()
if existedUser != nil && existedUser.ID != currentUserID {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "该抖音号已被其他账号绑定"))
return
}
// 2. 更新本地用户表的 douyin_user_id
if _, err := h.writeDB.Users.WithContext(ctx.RequestContext()).Where(h.writeDB.Users.ID.Eq(currentUserID)).Updates(map[string]any{
"douyin_user_id": req.DouyinID,
}); err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "更新用户信息失败"))
return
}
ctx.Payload(&bindDouyinOrderResponse{
DouyinID: req.DouyinID,
})
}
}

View File

@ -30,8 +30,10 @@ type couponItem struct {
ValidStart string `json:"valid_start"`
ValidEnd string `json:"valid_end"`
Status int32 `json:"status"`
StatusDesc string `json:"status_desc"` // 状态描述:未使用、已用完、已过期
Rules string `json:"rules"`
UsedAt string `json:"used_at,omitempty"` // 使用时间(已使用时返回)
UsedAmount int64 `json:"used_amount"` // 已使用金额
}
// ListUserCoupons 查看用户优惠券
@ -58,13 +60,13 @@ func (h *handler) ListUserCoupons() core.HandlerFunc {
}
userID := int64(ctx.SessionUserInfo().Id)
// 默认查询未使用的优惠券
status := int32(1)
if req.Status != nil && *req.Status > 0 {
// 状态0未使用 1已使用 2已过期 (直接对接前端标准)
status := int32(0)
if req.Status != nil {
status = *req.Status
}
items, total, err := h.user.ListCouponsByStatus(ctx.RequestContext(), userID, status, req.Page, req.PageSize)
items, total, err := h.user.ListAppCoupons(ctx.RequestContext(), userID, status, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10003, err.Error()))
return
@ -100,14 +102,8 @@ func (h *handler) ListUserCoupons() core.HandlerFunc {
rules := ""
if sc != nil {
name = sc.Name
// 金额券amount 显示模板面值remaining 显示当前余额
if sc.DiscountType == 1 {
amount = sc.DiscountValue
_ = h.repo.GetDbR().Raw("SELECT COALESCE(balance_amount,0) FROM user_coupons WHERE id=?", it.ID).Scan(&remaining).Error
} else {
amount = sc.DiscountValue
remaining = sc.DiscountValue
}
amount = sc.DiscountValue
remaining = it.BalanceAmount
rules = buildCouponRules(sc)
}
vs := it.ValidStart.Format("2006-01-02 15:04:05")
@ -119,7 +115,24 @@ func (h *handler) ListUserCoupons() core.HandlerFunc {
if !it.UsedAt.IsZero() {
usedAt = it.UsedAt.Format("2006-01-02 15:04:05")
}
vi := couponItem{ID: it.ID, Name: name, Amount: amount, Remaining: remaining, ValidStart: vs, ValidEnd: ve, Status: it.Status, Rules: rules, UsedAt: usedAt}
statusDesc := "未使用"
if it.Status == 2 {
if it.BalanceAmount == 0 {
statusDesc = "已使用"
} else {
statusDesc = "使用中"
}
} else if it.Status == 3 {
// 若面值等于余额,说明完全没用过,否则为“已到期”
sc, ok := mp[it.CouponID]
if ok && it.BalanceAmount < sc.DiscountValue {
statusDesc = "已到期"
} else {
statusDesc = "已过期"
}
}
usedAmount := amount - remaining
vi := couponItem{ID: it.ID, Name: name, Amount: amount, Remaining: remaining, UsedAmount: usedAmount, ValidStart: vs, ValidEnd: ve, Status: it.Status, StatusDesc: statusDesc, Rules: rules, UsedAt: usedAt}
rsp.List = append(rsp.List, vi)
}
ctx.Payload(rsp)

View File

@ -9,6 +9,7 @@ import (
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/gorm/clause"
)
// ==================== 用户次数卡 API ====================
@ -197,8 +198,9 @@ func (h *handler) GetGamePassPackages() core.HandlerFunc {
// ==================== 购买套餐 API ====================
type purchasePackageRequest struct {
PackageID int64 `json:"package_id" binding:"required"`
Count int32 `json:"count"` // 购买数量
PackageID int64 `json:"package_id" binding:"required"`
Count int32 `json:"count"` // 购买数量
CouponIDs []int64 `json:"coupon_ids"` // 优惠券ID列表
}
type purchasePackageResponse struct {
@ -208,7 +210,7 @@ type purchasePackageResponse struct {
// PurchaseGamePassPackage 购买次数卡套餐(创建订单)
// @Summary 购买次数卡套餐
// @Description 购买次数卡套餐,创建订单等待支付
// @Description 购买次数卡套餐,创建订单等待支付,支持使用优惠券
// @Tags APP端.用户
// @Accept json
// @Produce json
@ -245,7 +247,7 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc {
// Calculate total price
totalPrice := pkg.Price * int64(req.Count)
// 创建订单
// 创建订单 (支持优惠券)
now := time.Now()
orderNo := now.Format("20060102150405") + fmt.Sprintf("%04d", now.UnixNano()%10000)
order := &model.Orders{
@ -255,11 +257,33 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc {
TotalAmount: totalPrice,
ActualAmount: totalPrice,
Status: 1, // 待支付
Remark: fmt.Sprintf("game_pass_package:%s|count:%d", pkg.Name, req.Count),
Remark: fmt.Sprintf("game_pass_package:%s|pkg_id:%d|count:%d", pkg.Name, pkg.ID, req.Count),
CreatedAt: now,
UpdatedAt: now,
}
// 应用优惠券 (如果有)
var appliedCouponVal int64
var couponID int64
if len(req.CouponIDs) > 0 {
couponID = req.CouponIDs[0]
// 调用优惠券应用函数
applied, err := h.applyCouponToGamePassOrder(ctx, order, userID, couponID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, err.Error()))
return
}
appliedCouponVal = applied
// 记录优惠券到订单
if appliedCouponVal > 0 {
order.CouponID = couponID
order.Remark += fmt.Sprintf("|coupon:%d", couponID)
}
}
// 保存订单
if err := h.writeDB.Orders.WithContext(ctx.RequestContext()).
Omit(h.writeDB.Orders.PaidAt, h.writeDB.Orders.CancelledAt).
Create(order); err != nil {
@ -267,14 +291,222 @@ func (h *handler) PurchaseGamePassPackage() core.HandlerFunc {
return
}
// 在备注中记录套餐ID和数量
remark := fmt.Sprintf("%s|pkg_id:%d|count:%d", order.Remark, pkg.ID, req.Count)
h.writeDB.Orders.WithContext(ctx.RequestContext()).
Where(h.writeDB.Orders.ID.Eq(order.ID)).
Updates(map[string]any{"remark": remark})
// 如果使用了优惠券,记录到order_coupons表
if appliedCouponVal > 0 {
_ = h.writeDB.OrderCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Exec(
"INSERT IGNORE INTO order_coupons (order_id, user_coupon_id, applied_amount, created_at) VALUES (?,?,?,NOW(3))",
order.ID, couponID, appliedCouponVal)
}
// 处理0元订单
if order.ActualAmount == 0 {
order.Status = 2
order.PaidAt = 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,
})
// 0元订单确认优惠券扣减
if appliedCouponVal > 0 {
h.confirmCouponUsage(ctx, couponID, order.ID, now)
}
}
res.OrderNo = order.OrderNo
res.Message = "订单创建成功,请完成支付"
ctx.Payload(res)
}
}
// ==================== 优惠券辅助函数 ====================
// applyCouponToGamePassOrder 应用优惠券到次卡购买订单
// 返回应用的优惠金额 (分)
func (h *handler) applyCouponToGamePassOrder(ctx core.Context, order *model.Orders, userID int64, userCouponID int64) (int64, error) {
// 使用 SELECT ... FOR UPDATE 锁定行
uc, _ := h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).
Clauses(clause.Locking{Strength: "UPDATE"}).
Where(
h.writeDB.UserCoupons.ID.Eq(userCouponID),
h.writeDB.UserCoupons.UserID.Eq(userID),
).First()
if uc == nil {
return 0, nil
}
// 检查状态 (必须是可用状态)
if uc.Status != 1 {
return 0, fmt.Errorf("优惠券不可用")
}
// 获取优惠券模板
sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).
Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID), h.readDB.SystemCoupons.Status.Eq(1)).
First()
now := time.Now()
if sc == nil {
return 0, fmt.Errorf("优惠券模板不存在")
}
// 验证有效期
if uc.ValidStart.After(now) {
return 0, fmt.Errorf("优惠券未到开始时间")
}
if !uc.ValidEnd.IsZero() && uc.ValidEnd.Before(now) {
return 0, fmt.Errorf("优惠券已过期")
}
// 验证使用范围 (次卡购买只支持全场券 scope_type=1)
if sc.ScopeType != 1 {
return 0, fmt.Errorf("次卡购买仅支持全场通用优惠券")
}
// 验证门槛
if order.TotalAmount < sc.MinSpend {
return 0, fmt.Errorf("未达优惠券使用门槛")
}
// 50% 封顶
cap := order.TotalAmount / 2
remainingCap := cap - order.DiscountAmount
if remainingCap <= 0 {
return 0, fmt.Errorf("已达优惠封顶限制")
}
// 计算优惠金额
applied := int64(0)
switch sc.DiscountType {
case 1: // 金额券
bal := uc.BalanceAmount
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, nil
}
// 更新订单金额
order.ActualAmount -= applied
order.DiscountAmount += applied
// 扣减优惠券余额或标记为冻结
if sc.DiscountType == 1 {
// 金额券:直接扣余额
newBal := uc.BalanceAmount - applied
newStatus := int32(1)
if newBal <= 0 {
newBal = 0
newStatus = 2 // 已使用
}
res := h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Exec(
"UPDATE user_coupons SET balance_amount = ?, status = ? WHERE id = ?",
newBal, newStatus, userCouponID)
if res.Error != nil {
return 0, res.Error
}
// 记录流水
_ = h.writeDB.UserCouponLedger.WithContext(ctx.RequestContext()).Create(&model.UserCouponLedger{
UserID: userID,
UserCouponID: userCouponID,
ChangeAmount: -applied,
BalanceAfter: newBal,
OrderID: order.ID,
Action: "reserve", // 预扣
CreatedAt: time.Now(),
})
} else {
// 满减/折扣券:标记为冻结 (状态4)
res := h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().Exec(
"UPDATE user_coupons SET status = 4 WHERE id = ? AND status = 1", userCouponID)
if res.Error != nil {
return 0, res.Error
}
if res.RowsAffected == 0 {
return 0, fmt.Errorf("优惠券已被使用")
}
// 记录流水
_ = h.writeDB.UserCouponLedger.WithContext(ctx.RequestContext()).Create(&model.UserCouponLedger{
UserID: userID,
UserCouponID: userCouponID,
ChangeAmount: 0,
BalanceAfter: 0,
OrderID: order.ID,
Action: "reserve",
CreatedAt: time.Now(),
})
}
return applied, nil
}
// confirmCouponUsage 确认优惠券使用 (支付成功后调用)
func (h *handler) confirmCouponUsage(ctx core.Context, userCouponID int64, orderID int64, paidAt time.Time) {
uc, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).
Where(h.readDB.UserCoupons.ID.Eq(userCouponID)).
First()
if uc == nil {
return
}
sc, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).
Where(h.readDB.SystemCoupons.ID.Eq(uc.CouponID)).
First()
if sc == nil {
return
}
// 金额券:余额已经扣减,只需记录used_order_id
if sc.DiscountType == 1 {
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).
Where(h.writeDB.UserCoupons.ID.Eq(userCouponID)).
Updates(map[string]any{
h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID,
h.writeDB.UserCoupons.UsedAt.ColumnName().String(): paidAt,
})
} else {
// 满减/折扣券:状态从冻结(4)改为已使用(2)
_, _ = h.writeDB.UserCoupons.WithContext(ctx.RequestContext()).
Where(h.writeDB.UserCoupons.ID.Eq(userCouponID)).
Updates(map[string]any{
h.writeDB.UserCoupons.Status.ColumnName().String(): 2,
h.writeDB.UserCoupons.UsedOrderID.ColumnName().String(): orderID,
h.writeDB.UserCoupons.UsedAt.ColumnName().String(): paidAt,
})
}
}

View File

@ -1,6 +1,7 @@
package app
import (
"fmt"
"net/http"
"time"
@ -11,6 +12,7 @@ import (
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/proposal"
"bindbox-game/internal/service/sysconfig"
usersvc "bindbox-game/internal/service/user"
"go.uber.org/zap"
@ -25,6 +27,7 @@ type weixinLoginResponse struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Mobile string `json:"mobile"` // 新增手机号字段
InviteCode string `json:"invite_code"`
OpenID string `json:"openid"`
Token string `json:"token"`
@ -48,8 +51,12 @@ func (h *handler) WeixinLogin() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
cfg := configs.Get()
wxcfg := &wechat.WechatConfig{AppID: cfg.Wechat.AppID, AppSecret: cfg.Wechat.AppSecret}
// Use dynamic config
wxCfgVal := sysconfig.GetDynamicConfig().GetWechat(ctx.RequestContext().Context)
wxcfg := &wechat.WechatConfig{AppID: wxCfgVal.AppID, AppSecret: wxCfgVal.AppSecret}
fmt.Printf("DEBUG WeixinLogin: Using Config AppID=%s\n", wxcfg.AppID)
c2s, err := wechat.Code2Session(ctx.RequestContext().Context, wxcfg, req.Code)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10006, err.Error()))
@ -74,6 +81,7 @@ func (h *handler) WeixinLogin() core.HandlerFunc {
}
}
rsp.Avatar = u.Avatar
rsp.Mobile = u.Mobile // 返回手机号
rsp.InviteCode = u.InviteCode
rsp.OpenID = c2s.OpenID
sessionUserInfo := proposal.SessionUserInfo{Id: int32(u.ID), UserName: u.Nickname, NickName: u.Nickname, IsSuper: 0, Platform: "APP"}

View File

@ -26,6 +26,7 @@ type douyinLoginResponse struct {
UserID int64 `json:"user_id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Mobile string `json:"mobile"` // 新增手机号字段
InviteCode string `json:"invite_code"`
Token string `json:"token"`
}
@ -70,6 +71,7 @@ func (h *handler) DouyinLogin() core.HandlerFunc {
rsp.UserID = u.ID
rsp.Nickname = u.Nickname
rsp.Avatar = u.Avatar
rsp.Mobile = u.Mobile // 返回手机号
rsp.InviteCode = u.InviteCode
// 触发邀请奖励逻辑

View File

@ -3,12 +3,12 @@ package app
import (
"net/http"
"bindbox-game/configs"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/pay"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/sysconfig"
)
type jsapiPreorderRequest struct {
@ -45,11 +45,15 @@ func (h *handler) WechatJSAPIPreorder() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
if ok, err := pay.ValidateConfig(); !ok {
if ok, err := pay.ValidateConfig(ctx.RequestContext()); !ok {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 140001, err.Error()))
return
}
c := configs.Get()
// Use dynamic configurations
dynamicDC := sysconfig.GetDynamicConfig()
wxCfg := dynamicDC.GetWechat(ctx.RequestContext().Context)
wxPayCfg := dynamicDC.GetWechatPay(ctx.RequestContext().Context)
if req.OrderNo == "" || req.OpenID == "" {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 140002, "order_no/openid required"))
return
@ -76,18 +80,18 @@ func (h *handler) WechatJSAPIPreorder() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 140004, err.Error()))
return
}
pid, err := wc.JSAPIPrepay(ctx.RequestContext(), c.Wechat.AppID, c.WechatPay.MchID, "订单"+req.OrderNo, req.OrderNo, order.ActualAmount, req.OpenID, c.WechatPay.NotifyURL)
pid, err := wc.JSAPIPrepay(ctx.RequestContext(), wxCfg.AppID, wxPayCfg.MchID, "订单"+req.OrderNo, req.OrderNo, order.ActualAmount, req.OpenID, wxPayCfg.NotifyURL)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 140005, err.Error()))
return
}
prepayID = pid
pre := &model.PaymentPreorders{OrderID: order.ID, OrderNo: order.OrderNo, OutTradeNo: order.OrderNo, PrepayID: prepayID, AmountTotal: order.ActualAmount, PayerOpenid: req.OpenID, NotifyURL: c.WechatPay.NotifyURL, Status: "created"}
pre := &model.PaymentPreorders{OrderID: order.ID, OrderNo: order.OrderNo, OutTradeNo: order.OrderNo, PrepayID: prepayID, AmountTotal: order.ActualAmount, PayerOpenid: req.OpenID, NotifyURL: wxPayCfg.NotifyURL, Status: "created"}
if err := h.writeDB.PaymentPreorders.WithContext(ctx.RequestContext()).Omit(h.writeDB.PaymentPreorders.ExpiredAt).Create(pre); err == nil {
_, _ = h.writeDB.Orders.WithContext(ctx.RequestContext()).Where(h.readDB.Orders.ID.Eq(order.ID)).Updates(map[string]any{h.readDB.Orders.PayPreorderID.ColumnName().String(): pre.ID})
}
}
ts, nonce, pkg, signType, paySign, err := pay.BuildJSAPIParams(c.Wechat.AppID, prepayID)
ts, nonce, pkg, signType, paySign, err := pay.BuildJSAPIParams(ctx.RequestContext(), wxCfg.AppID, prepayID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 140003, err.Error()))
return

View File

@ -3,12 +3,12 @@ package app
import (
"net/http"
"bindbox-game/configs"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/miniprogram"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/pkg/wechat"
"bindbox-game/internal/service/sysconfig"
"go.uber.org/zap"
)
@ -48,11 +48,15 @@ func (h *handler) BindPhone() core.HandlerFunc {
return
}
cfg := configs.Get()
// cfg := configs.Get()
// Use dynamic config
wxCfg := sysconfig.GetDynamicConfig().GetWechat(ctx.RequestContext().Context)
var tokenRes struct {
AccessToken string `json:"access_token"`
}
if err := miniprogram.GetAccessToken(cfg.Wechat.AppID, cfg.Wechat.AppSecret, &tokenRes); err != nil || tokenRes.AccessToken == "" {
if err := miniprogram.GetAccessToken(wxCfg.AppID, wxCfg.AppSecret, &tokenRes); err != nil || tokenRes.AccessToken == "" {
h.logger.Error("获取微信access_token失败", zap.Error(err), zap.String("app_id", wxCfg.AppID), zap.String("app_secret", wxCfg.AppSecret))
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "获取微信access_token失败"))
return
}

View File

@ -1,12 +1,12 @@
package app
import (
"net/http"
"net/http"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
)
type listPointsRequest struct {
@ -20,7 +20,7 @@ type listPointsResponse struct {
List []*model.UserPointsLedger `json:"list"`
}
type pointsBalanceResponse struct {
Balance int64 `json:"balance"`
Balance float64 `json:"balance"` // 积分(分/rate
}
// ListUserPoints 查看用户积分记录
@ -29,8 +29,8 @@ type pointsBalanceResponse struct {
// @Tags APP端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Security LoginVerifyToken
// @Param user_id path integer true "用户ID"
// @Security LoginVerifyToken
// @Param page query int true "页码" default(1)
// @Param page_size query int true "每页数量最多100" default(20)
// @Success 200 {object} listPointsResponse
@ -44,8 +44,8 @@ func (h *handler) ListUserPoints() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
return
}
userID := int64(ctx.SessionUserInfo().Id)
items, total, err := h.user.ListPointsLedger(ctx.RequestContext(), userID, req.Page, req.PageSize)
userID := int64(ctx.SessionUserInfo().Id)
items, total, err := h.user.ListPointsLedger(ctx.RequestContext(), userID, req.Page, req.PageSize)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10004, err.Error()))
return
@ -64,21 +64,21 @@ func (h *handler) ListUserPoints() core.HandlerFunc {
// @Tags APP端.用户
// @Accept json
// @Produce json
// @Param user_id path integer true "用户ID"
// @Security LoginVerifyToken
// @Param user_id path integer true "用户ID"
// @Security LoginVerifyToken
// @Success 200 {object} pointsBalanceResponse
// @Failure 400 {object} code.Failure
// @Router /api/app/users/{user_id}/points/balance [get]
func (h *handler) GetUserPointsBalance() core.HandlerFunc {
return func(ctx core.Context) {
rsp := new(pointsBalanceResponse)
userID := int64(ctx.SessionUserInfo().Id)
total, err := h.user.GetPointsBalance(ctx.RequestContext(), userID)
userID := int64(ctx.SessionUserInfo().Id)
total, err := h.user.GetPointsBalance(ctx.RequestContext(), userID)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10005, err.Error()))
return
}
rsp.Balance = total
rsp.Balance = h.user.CentsToPointsFloat(ctx.RequestContext(), total)
ctx.Payload(rsp)
}
}

View File

@ -52,13 +52,19 @@ func (h *handler) RedeemPointsToCoupon() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150002, "only amount coupons supported"))
return
}
needPoints, _ := h.user.CentsToPoints(ctx.RequestContext(), sc.DiscountValue)
if needPoints <= 0 {
needPoints = 1
// sc.DiscountValue 是优惠券面值(分),直接用于扣除
// 例如30 元优惠券 = 3000 分
needCents := sc.DiscountValue
if needCents <= 0 {
needCents = 1
}
ledgerID, err := h.user.ConsumePointsFor(ctx.RequestContext(), userID, needPoints, "system_coupons", strconv.FormatInt(req.CouponID, 10), "redeem coupon", "redeem_coupon")
ledgerID, err := h.user.ConsumePointsFor(ctx.RequestContext(), userID, needCents, "system_coupons", strconv.FormatInt(req.CouponID, 10), "redeem coupon", "redeem_coupon")
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150003, err.Error()))
errMsg := err.Error()
if errMsg == "insufficient_points" {
errMsg = "积分不足,无法兑换"
}
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150003, errMsg))
return
}
if err := h.user.AddCoupon(ctx.RequestContext(), userID, req.CouponID); err != nil {

View File

@ -54,17 +54,36 @@ func (h *handler) RedeemPointsToProduct() core.HandlerFunc {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150101, "product not found"))
return
}
ptsPerUnit, _ := h.user.CentsToPoints(ctx.RequestContext(), prod.Price)
needPoints := ptsPerUnit * int64(req.Quantity)
if needPoints <= 0 {
needPoints = 1
}
ledgerID, err := h.user.ConsumePointsFor(ctx.RequestContext(), userID, needPoints, "products", strconv.FormatInt(req.ProductID, 10), "redeem product", "redeem_product")
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150102, err.Error()))
// 检查商品库存
if prod.Stock <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150105, "商品库存不足,请联系客服处理"))
return
}
resp, err := h.user.GrantReward(ctx.RequestContext(), userID, usersvc.GrantRewardRequest{ProductID: req.ProductID, Quantity: req.Quantity, Remark: prod.Name, PointsAmount: needPoints})
// prod.Price 是商品价格(分),直接用于扣除
// 例如30 元商品 = 3000 分
needCents := prod.Price * int64(req.Quantity)
if needCents <= 0 {
needCents = 1
}
ledgerID, err := h.user.ConsumePointsFor(ctx.RequestContext(), userID, needCents, "products", strconv.FormatInt(req.ProductID, 10), "redeem product", "redeem_product")
if err != nil {
errMsg := err.Error()
if errMsg == "insufficient_points" {
errMsg = "积分不足,无法完成兑换"
}
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150102, errMsg))
return
}
// Mall Direct Purchase (SourceType=1)
sourceType := int32(1)
resp, err := h.user.GrantReward(ctx.RequestContext(), userID, usersvc.GrantRewardRequest{
ProductID: req.ProductID,
Quantity: req.Quantity,
Remark: prod.Name,
PointsAmount: needCents,
SourceType: &sourceType,
})
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 150103, err.Error()))
return

View File

@ -35,15 +35,18 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
}
balance, _ := h.user.GetPointsBalance(ctx.RequestContext(), userID)
// 转换为积分(浮点)用于显示
balancePoints := h.user.CentsToPointsFloat(ctx.RequestContext(), balance)
res := userItem{
ID: user.ID,
Nickname: user.Nickname,
Avatar: user.Avatar,
InviteCode: user.InviteCode,
InviterID: user.InviterID,
Mobile: phone,
Balance: balance,
ID: user.ID,
Nickname: user.Nickname,
Avatar: user.Avatar,
InviteCode: user.InviteCode,
InviterID: user.InviterID,
Mobile: phone,
DouyinUserID: user.DouyinUserID,
Balance: balancePoints,
}
ctx.Payload(res)
}
@ -54,13 +57,14 @@ type modifyUserRequest struct {
Avatar *string `json:"avatar"`
}
type userItem struct {
ID int64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
InviteCode string `json:"invite_code"`
InviterID int64 `json:"inviter_id"`
Mobile string `json:"mobile"`
Balance int64 `json:"balance"` // Points
ID int64 `json:"id"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
InviteCode string `json:"invite_code"`
InviterID int64 `json:"inviter_id"`
Mobile string `json:"mobile"`
DouyinUserID string `json:"douyin_user_id"`
Balance float64 `json:"balance"` // 积分(分/rate
}
type modifyUserResponse struct {
User userItem `json:"user"`
@ -101,15 +105,17 @@ func (h *handler) ModifyUser() core.HandlerFunc {
}
balance, _ := h.user.GetPointsBalance(ctx.RequestContext(), userID)
balancePoints := h.user.CentsToPointsFloat(ctx.RequestContext(), balance)
rsp.User = userItem{
ID: item.ID,
Nickname: item.Nickname,
Avatar: item.Avatar,
InviteCode: item.InviteCode,
InviterID: item.InviterID,
Mobile: maskedPhone,
Balance: balance,
ID: item.ID,
Nickname: item.Nickname,
Avatar: item.Avatar,
InviteCode: item.InviteCode,
InviterID: item.InviterID,
Mobile: maskedPhone,
DouyinUserID: item.DouyinUserID,
Balance: balancePoints,
}
ctx.Payload(rsp)
}

View File

@ -0,0 +1,164 @@
package wechat
import (
"fmt"
"net/http"
"time"
"mini-chat/internal/code"
"mini-chat/internal/pkg/core"
"mini-chat/internal/pkg/miniprogram"
"mini-chat/internal/pkg/validation"
"gorm.io/gorm"
)
type templateRequest struct {
AppID string `json:"app_id" binding:"required"` // 微信小程序 AppID
}
type templateResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
AppID string `json:"app_id"` // 小程序 AppID
TemplateID string `json:"template_id"` // 模板 ID
}
// GetTemplate 获取微信小程序模板ID
// @Summary 获取微信小程序模板ID
// @Description 根据 AppID 获取微信小程序的模板ID
// @Tags 微信
// @Accept json
// @Produce json
// @Param request body templateRequest true "请求参数"
// @Success 200 {object} templateResponse
// @Failure 400 {object} code.Failure
// @Failure 404 {object} code.Failure
// @Failure 500 {object} code.Failure
// @Router /api/wechat/template [post]
func (h *handler) GetTemplate() core.HandlerFunc {
return func(ctx core.Context) {
req := new(templateRequest)
res := new(templateResponse)
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
validation.Error(err),
))
return
}
// 根据 AppID 查询小程序信息
miniProgram, err := h.readDB.MiniProgram.WithContext(ctx.RequestContext()).
Where(h.readDB.MiniProgram.AppID.Eq(req.AppID)).
First()
if err != nil {
if err == gorm.ErrRecordNotFound {
ctx.AbortWithError(core.Error(
http.StatusNotFound,
code.ServerError,
fmt.Sprintf("未找到 AppID 为 %s 的小程序", req.AppID),
))
return
}
h.logger.Error(fmt.Sprintf("查询小程序信息失败: %s", err.Error()))
ctx.AbortWithError(core.Error(
http.StatusInternalServerError,
code.ServerError,
"查询小程序信息失败",
))
return
}
// 检查模板ID是否存在
if miniProgram.TemplateID == "" {
ctx.AbortWithError(core.Error(
http.StatusNotFound,
code.ServerError,
"该小程序未配置模板ID",
))
return
}
res.Success = true
res.Message = "获取模板ID成功"
res.AppID = miniProgram.AppID
res.TemplateID = miniProgram.TemplateID
ctx.Payload(res)
}
}
type sendSubscribeMessageRequest struct {
AppID string `json:"app_id" binding:"required"` // 微信小程序 AppID
TemplateID string `json:"template_id" binding:"required"` // 模板 ID
AppSecret string `json:"app_secret" binding:"required"` // 小程序 AppSecret
Touser string `json:"touser" binding:"required"` // 接收者(用户)的 openid
}
type sendSubscribeMessageResponse struct {
Success bool `json:"success"`
Message string `json:"message"`
}
// SendSubscribeMessage 发送订阅消息
// @Summary 发送订阅消息
// @Description 根据模板ID发送订阅消息
// @Tags 微信
// @Accept json
// @Produce json
// @Param request body sendSubscribeMessageRequest true "请求参数"
// @Success 200 {object} sendSubscribeMessageResponse
// @Failure 400 {object} code.Failure
// @Failure 404 {object} code.Failure
// @Failure 500 {object} code.Failure
// @Router /api/wechat/subscribe [post]
func (h *handler) SendSubscribeMessage() core.HandlerFunc {
return func(ctx core.Context) {
req := new(sendSubscribeMessageRequest)
res := new(sendSubscribeMessageResponse)
// 参数绑定和验证
if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(
http.StatusBadRequest,
code.ParamBindError,
validation.Error(err),
))
return
}
// 发送模版消息
accessToken, err := h.servicesMiniProgram.GetAccessToken(req.AppID, req.AppSecret, ctx)
if err != nil {
h.logger.Error(fmt.Sprintf("获取access_token失败: %s", err.Error()))
} else {
sendSubscribeMessageRequest := new(miniprogram.SendSubscribeMessageRequest)
sendSubscribeMessageRequest.Touser = req.Touser
sendSubscribeMessageRequest.TemplateID = req.TemplateID
sendSubscribeMessageRequest.Page = "pages/index/detail?url=1"
sendSubscribeMessageRequest.MiniprogramState = "formal" // 需要改成正式版 目前是体验版 跳转小程序类型developer 为开发版trial为体验版formal 为正式版;默认为正式版
sendSubscribeMessageRequest.Lang = "zh_CN"
sendSubscribeMessageRequest.Data.Thing1.Value = "留言提醒"
sendSubscribeMessageRequest.Data.Time2.Value = time.Now().Format("2006-01-02 15:04:05")
sendSubscribeMessageRequest.Data.Thing3.Value = "您有一条新的消息..."
sendSubscribeMessageResponse := new(miniprogram.SendSubscribeMessageResponse)
err = miniprogram.SendSubscribeMessage(accessToken, sendSubscribeMessageRequest, sendSubscribeMessageResponse)
if err != nil {
res.Success = false
res.Message = "发送订阅消息失败" + err.Error()
h.logger.Error(fmt.Sprintf("发送模版消息失败: %s", err.Error()))
} else {
res.Success = true
res.Message = "订阅消息发送成功"
}
}
ctx.Payload(res)
}
}

View File

@ -15,17 +15,19 @@ type Failure struct {
Message string `json:"message"` // 描述信息
}
const (
ServerError = 10101
ParamBindError = 10102
JWTAuthVerifyError = 10103
UploadError = 10104
const (
ServerError = 10101
ParamBindError = 10102
JWTAuthVerifyError = 10103
UploadError = 10104
ForbiddenError = 10105
AuthorizationError = 10106
AdminLoginError = 20101
CreateAdminError = 20207
ListAdminError = 20208
ModifyAdminError = 20209
DeleteAdminError = 20210
AdminLoginError = 20101
CreateAdminError = 20207
ListAdminError = 20208
ModifyAdminError = 20209
DeleteAdminError = 20210
)
func Text(code int) string {

View File

@ -200,6 +200,7 @@ type Mux interface {
ServeHTTP(w http.ResponseWriter, req *http.Request)
Group(relativePath string, handlers ...HandlerFunc) RouterGroup
Routes() gin.RoutesInfo
Engine() *gin.Engine
}
type mux struct {
@ -220,6 +221,10 @@ func (m *mux) Routes() gin.RoutesInfo {
return m.engine.Routes()
}
func (m *mux) Engine() *gin.Engine {
return m.engine
}
func New(logger logger.CustomLogger, options ...Option) (Mux, error) {
if logger == nil {
return nil, errors.New("logger required")

View File

@ -3,6 +3,7 @@ package env
import (
"flag"
"fmt"
"os"
"strings"
"sync"
)
@ -65,6 +66,10 @@ func setup() {
val = *envFlag
}
if val == "" {
val = os.Getenv("ACTIVE_ENV")
}
switch strings.ToLower(strings.TrimSpace(val)) {
case "dev":
active = dev

View File

@ -10,6 +10,8 @@ import (
"bindbox-game/internal/pkg/httpclient"
pkgutils "bindbox-game/internal/pkg/utils"
"go.uber.org/zap"
)
// WechatNotifyConfig 微信通知配置
@ -30,13 +32,8 @@ type LotteryResultNotificationRequest struct {
}
// LotteryResultNotificationData 开奖结果通知数据字段
// 根据微信订阅消息模板字段定义
// thing1: 活动名称, phrase3: 中奖结果, thing4: 温馨提示
type LotteryResultNotificationData struct {
Thing1 DataValue `json:"thing1"` // 活动名称
Phrase3 DataValue `json:"phrase3"` // 中奖结果
Thing4 DataValue `json:"thing4"` // 温馨提示
}
// 使用 map 支持动态字段类型,根据模板灵活配置
type LotteryResultNotificationData map[string]DataValue
// DataValue 数据值包装
type DataValue struct {
@ -108,20 +105,24 @@ func SendLotteryResultNotification(ctx context.Context, cfg *WechatNotifyConfig,
// 获取 access_token
accessToken, err := getAccessToken(ctx, cfg.AppID, cfg.AppSecret)
if err != nil {
fmt.Printf("[开奖通知] 获取access_token失败: %v\n", err)
zap.L().Error("[开奖通知] 获取access_token失败", zap.Error(err), zap.String("openid", openid))
return err
}
// 活动名称限制长度thing类型不超过20个字符
activityName = pkgutils.TruncateRunes(activityName, 20)
// 构建中奖结果描述phrase类型限制5个汉字以内
// 由于奖品名称通常较长phrase3 放不下,改为固定文案 "恭喜中奖"
// 将奖品名称放入 Thing4 (温馨提示),限制 20 字符
resultPhrase := "恭喜中奖"
// 活动结果:展示奖品列表
rewardsStr := strings.Join(rewardNames, ",")
warmTips := pkgutils.TruncateRunes(rewardsStr, 20)
if rewardsStr == "" {
rewardsStr = "无奖励"
}
// thing类型限制20字符
resultVal := pkgutils.TruncateRunes(rewardsStr, 20)
// 当前进度:固定为"已发货"
progress := "已发货"
// 使用模板字段thing6=活动名称, thing8=当前进度, thing9=活动结果
req := &LotteryResultNotificationRequest{
Touser: openid,
TemplateID: cfg.LotteryResultTemplateID,
@ -129,13 +130,13 @@ func SendLotteryResultNotification(ctx context.Context, cfg *WechatNotifyConfig,
MiniprogramState: "formal", // 正式版
Lang: "zh_CN",
Data: LotteryResultNotificationData{
Thing1: DataValue{Value: activityName}, // 活动名称
Phrase3: DataValue{Value: resultPhrase}, // 中奖结果
Thing4: DataValue{Value: warmTips}, // 温馨提示(中奖奖品)
"thing6": {Value: activityName}, // 活动名称
"thing8": {Value: progress}, // 当前进度
"thing9": {Value: resultVal}, // 活动结果
},
}
fmt.Printf("[开奖通知] 尝试发送 openid=%s activity=%s rewards=%v\n", openid, activityName, rewardNames)
zap.L().Info("[开奖通知] 尝试发送", zap.String("openid", openid), zap.String("activity", activityName), zap.Strings("rewards", rewardNames))
// 发送请求
url := fmt.Sprintf("https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=%s", accessToken)
@ -145,13 +146,13 @@ func SendLotteryResultNotification(ctx context.Context, cfg *WechatNotifyConfig,
SetBody(req).
Post(url)
if err != nil {
fmt.Printf("[开奖通知] 发送失败: %v\n", err)
zap.L().Error("[开奖通知] 发送失败", zap.Error(err), zap.String("openid", openid))
return err
}
var result LotteryResultNotificationResponse
if err := json.Unmarshal(resp.Body(), &result); err != nil {
fmt.Printf("[开奖通知] 解析响应失败: %v\n", err)
zap.L().Error("[开奖通知] 解析响应失败", zap.Error(err), zap.String("body", string(resp.Body())))
return err
}
@ -159,10 +160,10 @@ func SendLotteryResultNotification(ctx context.Context, cfg *WechatNotifyConfig,
// 常见错误码:
// 43101: 用户拒绝接受消息
// 47003: 模板参数不准确
fmt.Printf("[开奖通知] 发送失败 errcode=%d errmsg=%s\n", result.Errcode, result.Errmsg)
zap.L().Warn("[开奖通知] 发送失败", zap.Int("errcode", result.Errcode), zap.String("errmsg", result.Errmsg), zap.String("openid", openid))
return fmt.Errorf("发送订阅消息失败: errcode=%d, errmsg=%s", result.Errcode, result.Errmsg)
}
fmt.Printf("[开奖通知] ✅ 发送成功 openid=%s\n", openid)
zap.L().Info("[开奖通知] ✅ 发送成功", zap.String("openid", openid))
return nil
}

View File

@ -0,0 +1,66 @@
package otel
import (
"time"
"github.com/gin-gonic/gin"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
"go.opentelemetry.io/otel/trace"
)
// Middleware 返回 Gin 中间件,用于自动创建 span
func Middleware(serviceName string) gin.HandlerFunc {
return func(c *gin.Context) {
// 从请求头提取 trace context
ctx := c.Request.Context()
propagator := propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
)
ctx = propagator.Extract(ctx, propagation.HeaderCarrier(c.Request.Header))
// 创建 span
spanName := c.Request.Method + " " + c.FullPath()
if c.FullPath() == "" {
spanName = c.Request.Method + " " + c.Request.URL.Path
}
ctx, span := Tracer().Start(ctx, spanName,
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(
semconv.HTTPRequestMethodKey.String(c.Request.Method),
semconv.URLFull(c.Request.URL.String()),
semconv.HTTPRoute(c.FullPath()),
semconv.ServerAddress(c.Request.Host),
attribute.String("http.client_ip", c.ClientIP()),
),
)
defer span.End()
// 将新的 context 放入请求
c.Request = c.Request.WithContext(ctx)
// 记录开始时间
start := time.Now()
// 执行后续处理
c.Next()
// 记录响应信息
duration := time.Since(start)
statusCode := c.Writer.Status()
span.SetAttributes(
semconv.HTTPResponseStatusCode(statusCode),
attribute.Int64("http.response_size", int64(c.Writer.Size())),
attribute.Float64("http.duration_ms", float64(duration.Milliseconds())),
)
// 如果有错误,记录错误信息
if len(c.Errors) > 0 {
span.SetAttributes(attribute.String("error.message", c.Errors.String()))
}
}
}

114
internal/pkg/otel/otel.go Normal file
View File

@ -0,0 +1,114 @@
// Package otel 提供 OpenTelemetry 链路追踪功能
package otel
import (
"context"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.37.0"
"go.opentelemetry.io/otel/trace"
)
var tracer trace.Tracer
// Config OpenTelemetry 配置
type Config struct {
ServiceName string
ServiceVersion string
Environment string
Endpoint string // Tempo OTLP HTTP endpoint, e.g., "tempo:4318"
Enabled bool
}
// Init 初始化 OpenTelemetry
// 返回 shutdown 函数,在程序退出时调用
func Init(cfg Config) (func(context.Context) error, error) {
if !cfg.Enabled {
// 如果未启用,返回空的 shutdown 函数
tracer = otel.Tracer(cfg.ServiceName)
return func(ctx context.Context) error { return nil }, nil
}
ctx := context.Background()
// 创建 OTLP HTTP exporter
client := otlptracehttp.NewClient(
otlptracehttp.WithEndpoint(cfg.Endpoint),
otlptracehttp.WithInsecure(), // 内网使用,不需要 TLS
)
exporter, err := otlptrace.New(ctx, client)
if err != nil {
return nil, err
}
// 创建 resource
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(cfg.ServiceName),
semconv.ServiceVersion(cfg.ServiceVersion),
attribute.String("environment", cfg.Environment),
),
)
if err != nil {
return nil, err
}
// 创建 TracerProvider
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter,
sdktrace.WithBatchTimeout(5*time.Second),
),
sdktrace.WithResource(res),
sdktrace.WithSampler(sdktrace.AlwaysSample()), // 生产环境可改为采样
)
// 设置全局 TracerProvider 和 Propagator
otel.SetTracerProvider(tp)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
tracer = tp.Tracer(cfg.ServiceName)
return tp.Shutdown, nil
}
// Tracer 获取全局 Tracer
func Tracer() trace.Tracer {
if tracer == nil {
tracer = otel.Tracer("bindbox-game")
}
return tracer
}
// StartSpan 开始一个新的 span
func StartSpan(ctx context.Context, name string, opts ...trace.SpanStartOption) (context.Context, trace.Span) {
return Tracer().Start(ctx, name, opts...)
}
// SpanFromContext 从 context 获取当前 span
func SpanFromContext(ctx context.Context) trace.Span {
return trace.SpanFromContext(ctx)
}
// AddEvent 为当前 span 添加事件
func AddEvent(ctx context.Context, name string, attrs ...attribute.KeyValue) {
span := trace.SpanFromContext(ctx)
span.AddEvent(name, trace.WithAttributes(attrs...))
}
// SetError 设置 span 错误状态
func SetError(ctx context.Context, err error) {
span := trace.SpanFromContext(ctx)
span.RecordError(err)
}

View File

@ -1,17 +1,19 @@
package pay
import (
"bindbox-game/internal/service/sysconfig"
"context"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/pem"
"errors"
"sync"
"bindbox-game/configs"
"github.com/wechatpay-apiv3/wechatpay-go/core"
"github.com/wechatpay-apiv3/wechatpay-go/core/option"
"github.com/wechatpay-apiv3/wechatpay-go/services/payments/jsapi"
refundsvc "github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic"
"github.com/wechatpay-apiv3/wechatpay-go/utils"
)
type WechatPayClient struct {
@ -25,6 +27,62 @@ var (
clientErr error
)
// LoadPrivateKeyFromBase64 从 Base64 编码的私钥内容创建 RSA 私钥
func LoadPrivateKeyFromBase64(base64Key string) (*rsa.PrivateKey, error) {
// 解码 Base64
keyBytes, err := base64.StdEncoding.DecodeString(base64Key)
if err != nil {
return nil, errors.New("failed to decode base64 private key: " + err.Error())
}
// 解析 PEM
block, _ := pem.Decode(keyBytes)
if block == nil {
return nil, errors.New("invalid private key PEM format")
}
// 尝试 PKCS8 格式
key, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
// 尝试 PKCS1 格式
rsaKey, err2 := x509.ParsePKCS1PrivateKey(block.Bytes)
if err2 != nil {
return nil, errors.New("failed to parse private key: " + err.Error())
}
return rsaKey, nil
}
rsaKey, ok := key.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("private key is not RSA type")
}
return rsaKey, nil
}
// LoadPublicKeyFromBase64 从 Base64 编码的公钥内容创建 RSA 公钥
func LoadPublicKeyFromBase64(base64Key string) (*rsa.PublicKey, error) {
keyBytes, err := base64.StdEncoding.DecodeString(base64Key)
if err != nil {
return nil, errors.New("failed to decode base64 public key: " + err.Error())
}
block, _ := pem.Decode(keyBytes)
if block == nil {
return nil, errors.New("invalid public key PEM format")
}
pub, err := x509.ParsePKIXPublicKey(block.Bytes)
if err != nil {
return nil, errors.New("failed to parse public key: " + err.Error())
}
rsaPub, ok := pub.(*rsa.PublicKey)
if !ok {
return nil, errors.New("public key is not RSA type")
}
return rsaPub, nil
}
// NewWechatPayClient 获取微信支付客户端(单例模式)
// 首次调用会初始化客户端,后续调用直接返回缓存的实例
func NewWechatPayClient(ctx context.Context) (*WechatPayClient, error) {
@ -38,35 +96,66 @@ func NewWechatPayClient(ctx context.Context) (*WechatPayClient, error) {
}
// initWechatPayClient 初始化微信支付客户端(内部实现)
// 优先使用动态配置中的 Base64 私钥内容fallback 到静态配置的文件路径
func initWechatPayClient(ctx context.Context) (*WechatPayClient, error) {
cfg := configs.Get()
if cfg.WechatPay.ApiV3Key == "" {
return nil, errors.New("wechat pay config incomplete")
// 必须从动态配置获取
var dynamicCfg *sysconfig.WechatPayConfig
if dc := sysconfig.GetDynamicConfig(); dc != nil {
cfg := dc.GetWechatPay(ctx)
dynamicCfg = &cfg
}
var opts []core.ClientOption
if cfg.WechatPay.PublicKeyID != "" && cfg.WechatPay.PublicKeyPath != "" {
if cfg.WechatPay.MchID == "" || cfg.WechatPay.SerialNo == "" || cfg.WechatPay.PrivateKeyPath == "" {
return nil, errors.New("wechat pay config incomplete")
}
mchPrivateKey, err := utils.LoadPrivateKeyWithPath(cfg.WechatPay.PrivateKeyPath)
if dynamicCfg == nil {
return nil, errors.New("wechat pay dynamic config missing")
}
mchID := dynamicCfg.MchID
serialNo := dynamicCfg.SerialNo
apiV3Key := dynamicCfg.ApiV3Key
if apiV3Key == "" {
return nil, errors.New("wechat pay config incomplete: api_v3_key missing")
}
if mchID == "" || serialNo == "" {
return nil, errors.New("wechat pay config incomplete: mchid or serial_no missing")
}
// 加载私钥:动态配置 Base64 内容
var mchPrivateKey *rsa.PrivateKey
var err error
if dynamicCfg.PrivateKey != "" {
mchPrivateKey, err = LoadPrivateKeyFromBase64(dynamicCfg.PrivateKey)
if err != nil {
return nil, err
return nil, errors.New("read private key from dynamic config err:" + err.Error())
}
pubKey, err := utils.LoadPublicKeyWithPath(cfg.WechatPay.PublicKeyPath)
if err != nil {
return nil, err
}
opts = []core.ClientOption{option.WithWechatPayPublicKeyAuthCipher(cfg.WechatPay.MchID, cfg.WechatPay.SerialNo, mchPrivateKey, cfg.WechatPay.PublicKeyID, pubKey)}
} else {
if cfg.WechatPay.MchID == "" || cfg.WechatPay.SerialNo == "" || cfg.WechatPay.PrivateKeyPath == "" {
return nil, errors.New("wechat pay config incomplete")
}
mchPrivateKey, err := utils.LoadPrivateKeyWithPath(cfg.WechatPay.PrivateKeyPath)
if err != nil {
return nil, err
}
opts = []core.ClientOption{option.WithWechatPayAutoAuthCipher(cfg.WechatPay.MchID, cfg.WechatPay.SerialNo, mchPrivateKey, cfg.WechatPay.ApiV3Key)}
return nil, errors.New("wechat pay private key not configured")
}
// 构建客户端选项
var opts []core.ClientOption
// 检查是否有公钥配置(新版验签方式)
publicKeyID := dynamicCfg.PublicKeyID
if publicKeyID != "" {
// 使用公钥验签模式
var pubKey *rsa.PublicKey
if dynamicCfg.PublicKey != "" {
pubKey, err = LoadPublicKeyFromBase64(dynamicCfg.PublicKey)
if err != nil {
return nil, errors.New("read public key from dynamic config err:" + err.Error())
}
} else {
return nil, errors.New("wechat pay public key not configured")
}
opts = []core.ClientOption{option.WithWechatPayPublicKeyAuthCipher(mchID, serialNo, mchPrivateKey, publicKeyID, pubKey)}
} else {
// 使用自动证书模式
opts = []core.ClientOption{option.WithWechatPayAutoAuthCipher(mchID, serialNo, mchPrivateKey, apiV3Key)}
}
client, err := core.NewClient(ctx, opts...)
if err != nil {
return nil, err

View File

@ -1,6 +1,7 @@
package pay
import (
"context"
"crypto"
crand "crypto/rand"
"crypto/rsa"
@ -16,18 +17,54 @@ import (
"time"
"bindbox-game/configs"
"bindbox-game/internal/service/sysconfig"
)
// 私钥缓存 - 避免每次请求都从磁盘读取
// 私钥缓存 - 避免每次请求都重新加载
var (
cachedRSAKey *rsa.PrivateKey
rsaKeyOnce sync.Once
rsaKeyLoadErr error
rsaKeyConfigPath string // 记录加载时的路径,用于检测配置变更
cachedRSAKey *rsa.PrivateKey
rsaKeyOnce sync.Once
rsaKeyLoadErr error
rsaKeyLoadFrom string // "dynamic" 或 "file"
)
// loadRSAPrivateKey 从磁盘加载私钥(内部函数,仅在首次调用时执行)
func loadRSAPrivateKey(keyPath string) (*rsa.PrivateKey, error) {
// getCachedRSAKeyForSign 获取缓存的RSA私钥用于签名
// 优先使用动态配置中的 Base64 私钥内容fallback 到静态文件路径
func getCachedRSAKeyForSign(ctx context.Context) (*rsa.PrivateKey, error) {
rsaKeyOnce.Do(func() {
staticCfg := configs.Get()
// 尝试从动态配置获取
var dynamicCfg *sysconfig.WechatPayConfig
if dc := sysconfig.GetDynamicConfig(); dc != nil {
cfg := dc.GetWechatPay(ctx)
dynamicCfg = &cfg
}
// 优先动态配置的 Base64 内容
if dynamicCfg != nil && dynamicCfg.PrivateKey != "" {
cachedRSAKey, rsaKeyLoadErr = LoadPrivateKeyFromBase64(dynamicCfg.PrivateKey)
if rsaKeyLoadErr == nil {
rsaKeyLoadFrom = "dynamic"
return
}
}
// fallback 到静态文件路径
if staticCfg.WechatPay.PrivateKeyPath != "" {
cachedRSAKey, rsaKeyLoadErr = loadRSAPrivateKeyFromFile(staticCfg.WechatPay.PrivateKeyPath)
if rsaKeyLoadErr == nil {
rsaKeyLoadFrom = "file"
}
} else if rsaKeyLoadErr == nil {
rsaKeyLoadErr = errors.New("wechat pay private key not configured")
}
})
return cachedRSAKey, rsaKeyLoadErr
}
// loadRSAPrivateKeyFromFile 从磁盘加载私钥(内部函数)
func loadRSAPrivateKeyFromFile(keyPath string) (*rsa.PrivateKey, error) {
b, err := os.ReadFile(keyPath)
if err != nil {
return nil, err
@ -52,31 +89,13 @@ func loadRSAPrivateKey(keyPath string) (*rsa.PrivateKey, error) {
return rsaKey, nil
}
// getCachedRSAKey 获取缓存的RSA私钥
func getCachedRSAKey(keyPath string) (*rsa.PrivateKey, error) {
rsaKeyOnce.Do(func() {
rsaKeyConfigPath = keyPath
cachedRSAKey, rsaKeyLoadErr = loadRSAPrivateKey(keyPath)
})
// 如果配置路径变更(理论上不应该发生),返回错误以提示重启
if rsaKeyConfigPath != keyPath {
return nil, errors.New("private key path changed, please restart the server")
}
return cachedRSAKey, rsaKeyLoadErr
}
// BuildJSAPIParams 为小程序支付构造客户端参数
// 入参:appid(微信小程序AppID)、prepayID(统一下单返回的prepay_id)
// 返回timeStamp、nonceStr、package(格式为"prepay_id=***" )、signType(固定"RSA")、paySign(RSA-SHA256签名)
// 入参ctx(上下文)、appid(微信小程序AppID)、prepayID(统一下单返回的prepay_id)
// 返回timeStamp、nonceStr、package(格式为"prepay_id=***")、signType(固定"RSA")、paySign(RSA-SHA256签名)
// 错误:当私钥读取或签名失败时返回错误
func BuildJSAPIParams(appid string, prepayID string) (timeStamp string, nonceStr string, pkg string, signType string, paySign string, err error) {
cfg := configs.Get()
if cfg.WechatPay.PrivateKeyPath == "" {
return "", "", "", "", "", errors.New("wechat pay private key path not configured")
}
// 使用缓存的私钥,避免每次都从磁盘读取
rsaKey, err := getCachedRSAKey(cfg.WechatPay.PrivateKeyPath)
func BuildJSAPIParams(ctx context.Context, appid string, prepayID string) (timeStamp string, nonceStr string, pkg string, signType string, paySign string, err error) {
// 使用缓存的私钥,优先动态配置
rsaKey, err := getCachedRSAKeyForSign(ctx)
if err != nil {
return "", "", "", "", "", err
}
@ -103,14 +122,34 @@ func BuildJSAPIParams(appid string, prepayID string) (timeStamp string, nonceStr
}
// ValidateConfig 校验微信支付必要配置
// 入参:
// 入参:ctx(上下文)
// 返回true表示配置齐全false表示缺失并附带错误信息
func ValidateConfig() (bool, error) {
c := configs.Get()
if c.Wechat.AppID == "" {
func ValidateConfig(ctx context.Context) (bool, error) {
// 检查动态配置
var dynamicCfg *sysconfig.WechatPayConfig
var wxCfg *sysconfig.WechatConfig
if dc := sysconfig.GetDynamicConfig(); dc != nil {
pCfg := dc.GetWechatPay(ctx)
dynamicCfg = &pCfg
wCfg := dc.GetWechat(ctx)
wxCfg = &wCfg
}
if wxCfg == nil || wxCfg.AppID == "" {
return false, errors.New("wechat app_id missing")
}
if c.WechatPay.MchID == "" || c.WechatPay.SerialNo == "" || c.WechatPay.PrivateKeyPath == "" || c.WechatPay.ApiV3Key == "" {
if dynamicCfg == nil {
return false, errors.New("wechat pay config incomplete")
}
mchID := dynamicCfg.MchID
serialNo := dynamicCfg.SerialNo
apiV3Key := dynamicCfg.ApiV3Key
hasPrivateKey := dynamicCfg.PrivateKey != ""
if mchID == "" || serialNo == "" || !hasPrivateKey || apiV3Key == "" {
return false, errors.New("wechat pay config incomplete")
}
return true, nil

View File

@ -1,19 +1,25 @@
package points
func CentsToPoints(cents int64, rate int64) int64 {
if cents <= 0 || rate <= 0 { return 0 }
return cents * rate
import "math"
// CentsToPoints converts monetary value (in cents) to points based on the exchange rate (X Points per 1 Yuan).
// Now: 1 Yuan = 100 Cents.
// If Rate = 1 (1 Point = 1 Yuan), then 100 Cents = 1 Point.
// Formula: points = (cents * rate) / 100
func CentsToPoints(cents int64, rate float64) int64 {
if rate <= 0 {
rate = 1
}
// Use rounding to avoid precision loss on division
return int64(math.Round((float64(cents) * rate) / 100.0))
}
func PointsToCents(points int64, rate int64) int64 {
if points <= 0 || rate <= 0 { return 0 }
return points / rate
// PointsToCents converts points to monetary value (in cents).
// If Rate = 1 (1 Point = 1 Yuan), then 1 Point = 100 Cents.
// Formula: cents = (points * 100) / rate
func PointsToCents(points int64, rate float64) int64 {
if rate <= 0 {
rate = 1
}
return int64(math.Round((float64(points) * 100.0) / rate))
}
func RefundPointsAmount(pointsAmountCents int64, refundedCents int64, totalPaidCents int64, rate int64) int64 {
if pointsAmountCents <= 0 || refundedCents <= 0 || totalPaidCents <= 0 || rate <= 0 { return 0 }
if refundedCents > totalPaidCents { refundedCents = totalPaidCents }
targetCents := (pointsAmountCents * refundedCents) / totalPaidCents
return targetCents / 100
}

View File

@ -3,20 +3,22 @@ package points
import "testing"
func TestCentsToPoints_DefaultRate(t *testing.T) {
if got := CentsToPoints(12345, 1); got != 12345 {
t.Fatalf("expected 12345, got %d", got)
}
if got := CentsToPoints(100, 1); got != 1 {
t.Fatalf("expected 1, got %d", got)
}
}
func TestPointsToCents_DefaultRate(t *testing.T) {
if got := PointsToCents(100, 1); got != 100 {
t.Fatalf("expected 100, got %d", got)
}
if got := PointsToCents(1, 1); got != 100 {
t.Fatalf("expected 100, got %d", got)
}
}
func TestRefundPointsAmount(t *testing.T) {
pts := RefundPointsAmount(5000, 2500, 10000, 1)
if pts != 12 {
t.Fatalf("expected 12, got %d", pts)
}
// 100 Points used. Refund 25 Yuan out of 100 Yuan paid.
// Expect 25 Points back.
pts := RefundPointsAmount(100, 2500, 10000, 1)
if pts != 25 {
t.Fatalf("expected 25, got %d", pts)
}
}

View File

@ -19,6 +19,7 @@ type Code2SessionResponse struct {
func Code2Session(ctx context.Context, config *WechatConfig, code string) (*Code2SessionResponse, error) {
if config == nil || config.AppID == "" || config.AppSecret == "" {
fmt.Printf("DEBUG: Code2Session Config Missing: %+v\n", config)
return nil, fmt.Errorf("微信配置缺失")
}
if code == "" {

View File

@ -65,6 +65,18 @@ func uploadVirtualShippingInternal(ctx core.Context, accessToken string, key ord
if itemDesc == "" {
return fmt.Errorf("参数缺失")
}
// Step 1: Check if already shipped to avoid invalid request
state, err := GetOrderShippingStatus(context.Background(), accessToken, key)
if err == nil {
if state >= 2 && state <= 4 {
fmt.Printf("[虚拟发货] 订单已发货/完成(state=%d),跳过上传 order_key=%+v\n", state, key)
return nil
}
} else {
fmt.Printf("[虚拟发货] 查询订单状态失败: %v, 继续尝试发货\n", err)
}
reqBody := &uploadShippingInfoRequest{
OrderKey: key,
LogisticsType: 3,
@ -241,6 +253,56 @@ func UploadVirtualShippingForBackground(ctx context.Context, config *WechatConfi
return uploadVirtualShippingInternalBackground(ctx, accessToken, orderKey{OrderNumberType: 1, MchID: mchID, OutTradeNo: outTradeNo}, payerOpenid, itemDesc, time.Now())
}
// GetOrderShippingStatusResponse 查询订单发货状态响应
type GetOrderShippingStatusResponse struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
Order struct {
OrderState int `json:"order_state"` // 1: 待发货, 2: 已发货, 3: 确认收货, 4: 交易完成, 5: 已退款
} `json:"order"`
}
// GetOrderShippingStatus 查询订单发货状态
// 返回: orderState (1: 待发货, 2: 已发货, 3: 确认收货, 4: 交易完成, 5: 已退款), error
func GetOrderShippingStatus(ctx context.Context, accessToken string, key orderKey) (int, error) {
if accessToken == "" {
return 0, fmt.Errorf("access_token 不能为空")
}
// 文档: https://developers.weixin.qq.com/miniprogram/dev/platform-capabilities/business-capabilities/order-shipping/order-shipping.html#三、查询订单发货状态
// get_order 接口参数是扁平的,不使用 order_key 结构
reqBody := map[string]any{}
if key.TransactionID != "" {
reqBody["transaction_id"] = key.TransactionID
} else {
reqBody["merchant_id"] = key.MchID
reqBody["merchant_trade_no"] = key.OutTradeNo
}
b, _ := json.Marshal(reqBody)
// fmt.Printf("[虚拟发货-查询] 请求 get_order order_key=%+v\n", key) // Debug log
client := httpclient.GetHttpClient()
resp, err := client.R().
SetQueryParam("access_token", accessToken).
SetHeader("Content-Type", "application/json").
SetBody(b).
Post("https://api.weixin.qq.com/wxa/sec/order/get_order")
if err != nil {
return 0, err
}
var r GetOrderShippingStatusResponse
if err := json.Unmarshal(resp.Body(), &r); err != nil {
return 0, fmt.Errorf("解析响应失败: %v", err)
}
if r.ErrCode != 0 {
// 10060001 = 支付单不存在,视为待发货(或未知的)
if r.ErrCode == 10060001 {
return 0, nil // Not found
}
return 0, fmt.Errorf("微信返回错误: errcode=%d, errmsg=%s", r.ErrCode, r.ErrMsg)
}
return r.Order.OrderState, nil
}
// uploadVirtualShippingInternalBackground 后台虚拟发货内部实现(无 core.Context
func uploadVirtualShippingInternalBackground(ctx context.Context, accessToken string, key orderKey, payerOpenid string, itemDesc string, uploadTime time.Time) error {
if accessToken == "" {
@ -249,6 +311,22 @@ func uploadVirtualShippingInternalBackground(ctx context.Context, accessToken st
if itemDesc == "" {
return fmt.Errorf("参数缺失")
}
// Step 1: Check if already shipped to avoid invalid request
state, err := GetOrderShippingStatus(ctx, accessToken, key)
if err == nil {
if state >= 2 && state <= 4 {
fmt.Printf("[虚拟发货-后台] 订单已发货/完成(state=%d),跳过上传 order_key=%+v\n", state, key)
return nil
}
} else {
// Log error but continue to try upload? Or just return error?
// If query fails, maybe we should try upload anyway or just log warning.
// Let's log warning and continue.
fmt.Printf("[虚拟发货-后台] 查询订单状态失败: %v, 继续尝试发货\n", err)
}
// Step 2: Upload shipping info
reqBody := &uploadShippingInfoRequest{
OrderKey: key,
LogisticsType: 3,
@ -275,6 +353,11 @@ func uploadVirtualShippingInternalBackground(ctx context.Context, accessToken st
return fmt.Errorf("解析响应失败: %v", err)
}
if cr.ErrCode != 0 {
// 10060003 = 订单已发货 (Redundant check if status check above passed but state changed or query returned 0)
if cr.ErrCode == 10060003 {
fmt.Printf("[虚拟发货-后台] 微信返回已发货(10060003),视为成功\n")
return nil
}
return fmt.Errorf("微信返回错误: errcode=%d, errmsg=%s", cr.ErrCode, cr.ErrMsg)
}
return nil

View File

View File

@ -0,0 +1,344 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package dao
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/plugin/dbresolver"
"bindbox-game/internal/repository/mysql/model"
)
func newDouyinBlacklist(db *gorm.DB, opts ...gen.DOOption) douyinBlacklist {
_douyinBlacklist := douyinBlacklist{}
_douyinBlacklist.douyinBlacklistDo.UseDB(db, opts...)
_douyinBlacklist.douyinBlacklistDo.UseModel(&model.DouyinBlacklist{})
tableName := _douyinBlacklist.douyinBlacklistDo.TableName()
_douyinBlacklist.ALL = field.NewAsterisk(tableName)
_douyinBlacklist.ID = field.NewInt64(tableName, "id")
_douyinBlacklist.DouyinUserID = field.NewString(tableName, "douyin_user_id")
_douyinBlacklist.Reason = field.NewString(tableName, "reason")
_douyinBlacklist.OperatorID = field.NewInt64(tableName, "operator_id")
_douyinBlacklist.Status = field.NewInt32(tableName, "status")
_douyinBlacklist.CreatedAt = field.NewTime(tableName, "created_at")
_douyinBlacklist.UpdatedAt = field.NewTime(tableName, "updated_at")
_douyinBlacklist.fillFieldMap()
return _douyinBlacklist
}
// douyinBlacklist 抖音用户黑名单表
type douyinBlacklist struct {
douyinBlacklistDo
ALL field.Asterisk
ID field.Int64 // 主键ID
DouyinUserID field.String // 抖音用户ID
Reason field.String // 拉黑原因
OperatorID field.Int64 // 操作人ID
Status field.Int32 // 状态: 1=生效, 0=已解除
CreatedAt field.Time // 创建时间
UpdatedAt field.Time // 更新时间
fieldMap map[string]field.Expr
}
func (d douyinBlacklist) Table(newTableName string) *douyinBlacklist {
d.douyinBlacklistDo.UseTable(newTableName)
return d.updateTableName(newTableName)
}
func (d douyinBlacklist) As(alias string) *douyinBlacklist {
d.douyinBlacklistDo.DO = *(d.douyinBlacklistDo.As(alias).(*gen.DO))
return d.updateTableName(alias)
}
func (d *douyinBlacklist) updateTableName(table string) *douyinBlacklist {
d.ALL = field.NewAsterisk(table)
d.ID = field.NewInt64(table, "id")
d.DouyinUserID = field.NewString(table, "douyin_user_id")
d.Reason = field.NewString(table, "reason")
d.OperatorID = field.NewInt64(table, "operator_id")
d.Status = field.NewInt32(table, "status")
d.CreatedAt = field.NewTime(table, "created_at")
d.UpdatedAt = field.NewTime(table, "updated_at")
d.fillFieldMap()
return d
}
func (d *douyinBlacklist) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
_f, ok := d.fieldMap[fieldName]
if !ok || _f == nil {
return nil, false
}
_oe, ok := _f.(field.OrderExpr)
return _oe, ok
}
func (d *douyinBlacklist) fillFieldMap() {
d.fieldMap = make(map[string]field.Expr, 7)
d.fieldMap["id"] = d.ID
d.fieldMap["douyin_user_id"] = d.DouyinUserID
d.fieldMap["reason"] = d.Reason
d.fieldMap["operator_id"] = d.OperatorID
d.fieldMap["status"] = d.Status
d.fieldMap["created_at"] = d.CreatedAt
d.fieldMap["updated_at"] = d.UpdatedAt
}
func (d douyinBlacklist) clone(db *gorm.DB) douyinBlacklist {
d.douyinBlacklistDo.ReplaceConnPool(db.Statement.ConnPool)
return d
}
func (d douyinBlacklist) replaceDB(db *gorm.DB) douyinBlacklist {
d.douyinBlacklistDo.ReplaceDB(db)
return d
}
type douyinBlacklistDo struct{ gen.DO }
func (d douyinBlacklistDo) Debug() *douyinBlacklistDo {
return d.withDO(d.DO.Debug())
}
func (d douyinBlacklistDo) WithContext(ctx context.Context) *douyinBlacklistDo {
return d.withDO(d.DO.WithContext(ctx))
}
func (d douyinBlacklistDo) ReadDB() *douyinBlacklistDo {
return d.Clauses(dbresolver.Read)
}
func (d douyinBlacklistDo) WriteDB() *douyinBlacklistDo {
return d.Clauses(dbresolver.Write)
}
func (d douyinBlacklistDo) Session(config *gorm.Session) *douyinBlacklistDo {
return d.withDO(d.DO.Session(config))
}
func (d douyinBlacklistDo) Clauses(conds ...clause.Expression) *douyinBlacklistDo {
return d.withDO(d.DO.Clauses(conds...))
}
func (d douyinBlacklistDo) Returning(value interface{}, columns ...string) *douyinBlacklistDo {
return d.withDO(d.DO.Returning(value, columns...))
}
func (d douyinBlacklistDo) Not(conds ...gen.Condition) *douyinBlacklistDo {
return d.withDO(d.DO.Not(conds...))
}
func (d douyinBlacklistDo) Or(conds ...gen.Condition) *douyinBlacklistDo {
return d.withDO(d.DO.Or(conds...))
}
func (d douyinBlacklistDo) Select(conds ...field.Expr) *douyinBlacklistDo {
return d.withDO(d.DO.Select(conds...))
}
func (d douyinBlacklistDo) Where(conds ...gen.Condition) *douyinBlacklistDo {
return d.withDO(d.DO.Where(conds...))
}
func (d douyinBlacklistDo) Order(conds ...field.Expr) *douyinBlacklistDo {
return d.withDO(d.DO.Order(conds...))
}
func (d douyinBlacklistDo) Distinct(cols ...field.Expr) *douyinBlacklistDo {
return d.withDO(d.DO.Distinct(cols...))
}
func (d douyinBlacklistDo) Omit(cols ...field.Expr) *douyinBlacklistDo {
return d.withDO(d.DO.Omit(cols...))
}
func (d douyinBlacklistDo) Join(table schema.Tabler, on ...field.Expr) *douyinBlacklistDo {
return d.withDO(d.DO.Join(table, on...))
}
func (d douyinBlacklistDo) LeftJoin(table schema.Tabler, on ...field.Expr) *douyinBlacklistDo {
return d.withDO(d.DO.LeftJoin(table, on...))
}
func (d douyinBlacklistDo) RightJoin(table schema.Tabler, on ...field.Expr) *douyinBlacklistDo {
return d.withDO(d.DO.RightJoin(table, on...))
}
func (d douyinBlacklistDo) Group(cols ...field.Expr) *douyinBlacklistDo {
return d.withDO(d.DO.Group(cols...))
}
func (d douyinBlacklistDo) Having(conds ...gen.Condition) *douyinBlacklistDo {
return d.withDO(d.DO.Having(conds...))
}
func (d douyinBlacklistDo) Limit(limit int) *douyinBlacklistDo {
return d.withDO(d.DO.Limit(limit))
}
func (d douyinBlacklistDo) Offset(offset int) *douyinBlacklistDo {
return d.withDO(d.DO.Offset(offset))
}
func (d douyinBlacklistDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *douyinBlacklistDo {
return d.withDO(d.DO.Scopes(funcs...))
}
func (d douyinBlacklistDo) Unscoped() *douyinBlacklistDo {
return d.withDO(d.DO.Unscoped())
}
func (d douyinBlacklistDo) Create(values ...*model.DouyinBlacklist) error {
if len(values) == 0 {
return nil
}
return d.DO.Create(values)
}
func (d douyinBlacklistDo) CreateInBatches(values []*model.DouyinBlacklist, batchSize int) error {
return d.DO.CreateInBatches(values, batchSize)
}
// Save : !!! underlying implementation is different with GORM
// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
func (d douyinBlacklistDo) Save(values ...*model.DouyinBlacklist) error {
if len(values) == 0 {
return nil
}
return d.DO.Save(values)
}
func (d douyinBlacklistDo) First() (*model.DouyinBlacklist, error) {
if result, err := d.DO.First(); err != nil {
return nil, err
} else {
return result.(*model.DouyinBlacklist), nil
}
}
func (d douyinBlacklistDo) Take() (*model.DouyinBlacklist, error) {
if result, err := d.DO.Take(); err != nil {
return nil, err
} else {
return result.(*model.DouyinBlacklist), nil
}
}
func (d douyinBlacklistDo) Last() (*model.DouyinBlacklist, error) {
if result, err := d.DO.Last(); err != nil {
return nil, err
} else {
return result.(*model.DouyinBlacklist), nil
}
}
func (d douyinBlacklistDo) Find() ([]*model.DouyinBlacklist, error) {
result, err := d.DO.Find()
return result.([]*model.DouyinBlacklist), err
}
func (d douyinBlacklistDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.DouyinBlacklist, err error) {
buf := make([]*model.DouyinBlacklist, 0, batchSize)
err = d.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
defer func() { results = append(results, buf...) }()
return fc(tx, batch)
})
return results, err
}
func (d douyinBlacklistDo) FindInBatches(result *[]*model.DouyinBlacklist, batchSize int, fc func(tx gen.Dao, batch int) error) error {
return d.DO.FindInBatches(result, batchSize, fc)
}
func (d douyinBlacklistDo) Attrs(attrs ...field.AssignExpr) *douyinBlacklistDo {
return d.withDO(d.DO.Attrs(attrs...))
}
func (d douyinBlacklistDo) Assign(attrs ...field.AssignExpr) *douyinBlacklistDo {
return d.withDO(d.DO.Assign(attrs...))
}
func (d douyinBlacklistDo) Joins(fields ...field.RelationField) *douyinBlacklistDo {
for _, _f := range fields {
d = *d.withDO(d.DO.Joins(_f))
}
return &d
}
func (d douyinBlacklistDo) Preload(fields ...field.RelationField) *douyinBlacklistDo {
for _, _f := range fields {
d = *d.withDO(d.DO.Preload(_f))
}
return &d
}
func (d douyinBlacklistDo) FirstOrInit() (*model.DouyinBlacklist, error) {
if result, err := d.DO.FirstOrInit(); err != nil {
return nil, err
} else {
return result.(*model.DouyinBlacklist), nil
}
}
func (d douyinBlacklistDo) FirstOrCreate() (*model.DouyinBlacklist, error) {
if result, err := d.DO.FirstOrCreate(); err != nil {
return nil, err
} else {
return result.(*model.DouyinBlacklist), nil
}
}
func (d douyinBlacklistDo) FindByPage(offset int, limit int) (result []*model.DouyinBlacklist, count int64, err error) {
result, err = d.Offset(offset).Limit(limit).Find()
if err != nil {
return
}
if size := len(result); 0 < limit && 0 < size && size < limit {
count = int64(size + offset)
return
}
count, err = d.Offset(-1).Limit(-1).Count()
return
}
func (d douyinBlacklistDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
count, err = d.Count()
if err != nil {
return
}
err = d.Offset(offset).Limit(limit).Scan(result)
return
}
func (d douyinBlacklistDo) Scan(result interface{}) (err error) {
return d.DO.Scan(result)
}
func (d douyinBlacklistDo) Delete(models ...*model.DouyinBlacklist) (result gen.ResultInfo, err error) {
return d.DO.Delete(models)
}
func (d *douyinBlacklistDo) withDO(do gen.Dao) *douyinBlacklistDo {
d.DO = *do.(*gen.DO)
return d
}

View File

@ -29,16 +29,20 @@ func newDouyinOrders(db *gorm.DB, opts ...gen.DOOption) douyinOrders {
_douyinOrders.ALL = field.NewAsterisk(tableName)
_douyinOrders.ID = field.NewInt64(tableName, "id")
_douyinOrders.ShopOrderID = field.NewString(tableName, "shop_order_id")
_douyinOrders.DouyinProductID = field.NewString(tableName, "douyin_product_id")
_douyinOrders.OrderStatus = field.NewInt32(tableName, "order_status")
_douyinOrders.DouyinUserID = field.NewString(tableName, "douyin_user_id")
_douyinOrders.LocalUserID = field.NewString(tableName, "local_user_id")
_douyinOrders.ActualReceiveAmount = field.NewInt64(tableName, "actual_receive_amount")
_douyinOrders.ActualPayAmount = field.NewInt64(tableName, "actual_pay_amount")
_douyinOrders.PayTypeDesc = field.NewString(tableName, "pay_type_desc")
_douyinOrders.Remark = field.NewString(tableName, "remark")
_douyinOrders.UserNickname = field.NewString(tableName, "user_nickname")
_douyinOrders.RawData = field.NewString(tableName, "raw_data")
_douyinOrders.CreatedAt = field.NewTime(tableName, "created_at")
_douyinOrders.UpdatedAt = field.NewTime(tableName, "updated_at")
_douyinOrders.RewardGranted = field.NewBool(tableName, "reward_granted")
_douyinOrders.ProductCount = field.NewInt32(tableName, "product_count")
_douyinOrders.fillFieldMap()
@ -52,16 +56,20 @@ type douyinOrders struct {
ALL field.Asterisk
ID field.Int64
ShopOrderID field.String // 抖店订单号
DouyinProductID field.String // 关联商品ID
OrderStatus field.Int32 // 订单状态: 5=已完成
DouyinUserID field.String // 抖店用户ID
LocalUserID field.String // 匹配到的本地用户ID
ActualReceiveAmount field.Int64 // 实收金额(分)
ActualPayAmount field.Int64 // 实付金额(分)
PayTypeDesc field.String // 支付方式描述
Remark field.String // 备注
UserNickname field.String // 抖音昵称
RawData field.String // 原始响应数据
CreatedAt field.Time
UpdatedAt field.Time
RewardGranted field.Bool // 奖励已发放: 0=否, 1=是
ProductCount field.Int32 // 商品数量
fieldMap map[string]field.Expr
}
@ -80,16 +88,20 @@ func (d *douyinOrders) updateTableName(table string) *douyinOrders {
d.ALL = field.NewAsterisk(table)
d.ID = field.NewInt64(table, "id")
d.ShopOrderID = field.NewString(table, "shop_order_id")
d.DouyinProductID = field.NewString(table, "douyin_product_id")
d.OrderStatus = field.NewInt32(table, "order_status")
d.DouyinUserID = field.NewString(table, "douyin_user_id")
d.LocalUserID = field.NewString(table, "local_user_id")
d.ActualReceiveAmount = field.NewInt64(table, "actual_receive_amount")
d.ActualPayAmount = field.NewInt64(table, "actual_pay_amount")
d.PayTypeDesc = field.NewString(table, "pay_type_desc")
d.Remark = field.NewString(table, "remark")
d.UserNickname = field.NewString(table, "user_nickname")
d.RawData = field.NewString(table, "raw_data")
d.CreatedAt = field.NewTime(table, "created_at")
d.UpdatedAt = field.NewTime(table, "updated_at")
d.RewardGranted = field.NewBool(table, "reward_granted")
d.ProductCount = field.NewInt32(table, "product_count")
d.fillFieldMap()
@ -106,19 +118,23 @@ func (d *douyinOrders) GetFieldByName(fieldName string) (field.OrderExpr, bool)
}
func (d *douyinOrders) fillFieldMap() {
d.fieldMap = make(map[string]field.Expr, 12)
d.fieldMap = make(map[string]field.Expr, 16)
d.fieldMap["id"] = d.ID
d.fieldMap["shop_order_id"] = d.ShopOrderID
d.fieldMap["douyin_product_id"] = d.DouyinProductID
d.fieldMap["order_status"] = d.OrderStatus
d.fieldMap["douyin_user_id"] = d.DouyinUserID
d.fieldMap["local_user_id"] = d.LocalUserID
d.fieldMap["actual_receive_amount"] = d.ActualReceiveAmount
d.fieldMap["actual_pay_amount"] = d.ActualPayAmount
d.fieldMap["pay_type_desc"] = d.PayTypeDesc
d.fieldMap["remark"] = d.Remark
d.fieldMap["user_nickname"] = d.UserNickname
d.fieldMap["raw_data"] = d.RawData
d.fieldMap["created_at"] = d.CreatedAt
d.fieldMap["updated_at"] = d.UpdatedAt
d.fieldMap["reward_granted"] = d.RewardGranted
d.fieldMap["product_count"] = d.ProductCount
}
func (d douyinOrders) clone(db *gorm.DB) douyinOrders {

View File

@ -0,0 +1,352 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package dao
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/plugin/dbresolver"
"bindbox-game/internal/repository/mysql/model"
)
func newDouyinProductRewards(db *gorm.DB, opts ...gen.DOOption) douyinProductRewards {
_douyinProductRewards := douyinProductRewards{}
_douyinProductRewards.douyinProductRewardsDo.UseDB(db, opts...)
_douyinProductRewards.douyinProductRewardsDo.UseModel(&model.DouyinProductRewards{})
tableName := _douyinProductRewards.douyinProductRewardsDo.TableName()
_douyinProductRewards.ALL = field.NewAsterisk(tableName)
_douyinProductRewards.ID = field.NewInt64(tableName, "id")
_douyinProductRewards.ProductID = field.NewString(tableName, "product_id")
_douyinProductRewards.ProductName = field.NewString(tableName, "product_name")
_douyinProductRewards.RewardType = field.NewString(tableName, "reward_type")
_douyinProductRewards.RewardPayload = field.NewString(tableName, "reward_payload")
_douyinProductRewards.Quantity = field.NewInt32(tableName, "quantity")
_douyinProductRewards.Status = field.NewInt32(tableName, "status")
_douyinProductRewards.CreatedAt = field.NewTime(tableName, "created_at")
_douyinProductRewards.UpdatedAt = field.NewTime(tableName, "updated_at")
_douyinProductRewards.fillFieldMap()
return _douyinProductRewards
}
// douyinProductRewards 抖店商品奖励规则
type douyinProductRewards struct {
douyinProductRewardsDo
ALL field.Asterisk
ID field.Int64
ProductID field.String // 抖店商品ID
ProductName field.String // 商品名称
RewardType field.String // 奖励类型
RewardPayload field.String // 奖励参数JSON
Quantity field.Int32 // 发放数量
Status field.Int32 // 状态: 1=启用 0=禁用
CreatedAt field.Time
UpdatedAt field.Time
fieldMap map[string]field.Expr
}
func (d douyinProductRewards) Table(newTableName string) *douyinProductRewards {
d.douyinProductRewardsDo.UseTable(newTableName)
return d.updateTableName(newTableName)
}
func (d douyinProductRewards) As(alias string) *douyinProductRewards {
d.douyinProductRewardsDo.DO = *(d.douyinProductRewardsDo.As(alias).(*gen.DO))
return d.updateTableName(alias)
}
func (d *douyinProductRewards) updateTableName(table string) *douyinProductRewards {
d.ALL = field.NewAsterisk(table)
d.ID = field.NewInt64(table, "id")
d.ProductID = field.NewString(table, "product_id")
d.ProductName = field.NewString(table, "product_name")
d.RewardType = field.NewString(table, "reward_type")
d.RewardPayload = field.NewString(table, "reward_payload")
d.Quantity = field.NewInt32(table, "quantity")
d.Status = field.NewInt32(table, "status")
d.CreatedAt = field.NewTime(table, "created_at")
d.UpdatedAt = field.NewTime(table, "updated_at")
d.fillFieldMap()
return d
}
func (d *douyinProductRewards) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
_f, ok := d.fieldMap[fieldName]
if !ok || _f == nil {
return nil, false
}
_oe, ok := _f.(field.OrderExpr)
return _oe, ok
}
func (d *douyinProductRewards) fillFieldMap() {
d.fieldMap = make(map[string]field.Expr, 9)
d.fieldMap["id"] = d.ID
d.fieldMap["product_id"] = d.ProductID
d.fieldMap["product_name"] = d.ProductName
d.fieldMap["reward_type"] = d.RewardType
d.fieldMap["reward_payload"] = d.RewardPayload
d.fieldMap["quantity"] = d.Quantity
d.fieldMap["status"] = d.Status
d.fieldMap["created_at"] = d.CreatedAt
d.fieldMap["updated_at"] = d.UpdatedAt
}
func (d douyinProductRewards) clone(db *gorm.DB) douyinProductRewards {
d.douyinProductRewardsDo.ReplaceConnPool(db.Statement.ConnPool)
return d
}
func (d douyinProductRewards) replaceDB(db *gorm.DB) douyinProductRewards {
d.douyinProductRewardsDo.ReplaceDB(db)
return d
}
type douyinProductRewardsDo struct{ gen.DO }
func (d douyinProductRewardsDo) Debug() *douyinProductRewardsDo {
return d.withDO(d.DO.Debug())
}
func (d douyinProductRewardsDo) WithContext(ctx context.Context) *douyinProductRewardsDo {
return d.withDO(d.DO.WithContext(ctx))
}
func (d douyinProductRewardsDo) ReadDB() *douyinProductRewardsDo {
return d.Clauses(dbresolver.Read)
}
func (d douyinProductRewardsDo) WriteDB() *douyinProductRewardsDo {
return d.Clauses(dbresolver.Write)
}
func (d douyinProductRewardsDo) Session(config *gorm.Session) *douyinProductRewardsDo {
return d.withDO(d.DO.Session(config))
}
func (d douyinProductRewardsDo) Clauses(conds ...clause.Expression) *douyinProductRewardsDo {
return d.withDO(d.DO.Clauses(conds...))
}
func (d douyinProductRewardsDo) Returning(value interface{}, columns ...string) *douyinProductRewardsDo {
return d.withDO(d.DO.Returning(value, columns...))
}
func (d douyinProductRewardsDo) Not(conds ...gen.Condition) *douyinProductRewardsDo {
return d.withDO(d.DO.Not(conds...))
}
func (d douyinProductRewardsDo) Or(conds ...gen.Condition) *douyinProductRewardsDo {
return d.withDO(d.DO.Or(conds...))
}
func (d douyinProductRewardsDo) Select(conds ...field.Expr) *douyinProductRewardsDo {
return d.withDO(d.DO.Select(conds...))
}
func (d douyinProductRewardsDo) Where(conds ...gen.Condition) *douyinProductRewardsDo {
return d.withDO(d.DO.Where(conds...))
}
func (d douyinProductRewardsDo) Order(conds ...field.Expr) *douyinProductRewardsDo {
return d.withDO(d.DO.Order(conds...))
}
func (d douyinProductRewardsDo) Distinct(cols ...field.Expr) *douyinProductRewardsDo {
return d.withDO(d.DO.Distinct(cols...))
}
func (d douyinProductRewardsDo) Omit(cols ...field.Expr) *douyinProductRewardsDo {
return d.withDO(d.DO.Omit(cols...))
}
func (d douyinProductRewardsDo) Join(table schema.Tabler, on ...field.Expr) *douyinProductRewardsDo {
return d.withDO(d.DO.Join(table, on...))
}
func (d douyinProductRewardsDo) LeftJoin(table schema.Tabler, on ...field.Expr) *douyinProductRewardsDo {
return d.withDO(d.DO.LeftJoin(table, on...))
}
func (d douyinProductRewardsDo) RightJoin(table schema.Tabler, on ...field.Expr) *douyinProductRewardsDo {
return d.withDO(d.DO.RightJoin(table, on...))
}
func (d douyinProductRewardsDo) Group(cols ...field.Expr) *douyinProductRewardsDo {
return d.withDO(d.DO.Group(cols...))
}
func (d douyinProductRewardsDo) Having(conds ...gen.Condition) *douyinProductRewardsDo {
return d.withDO(d.DO.Having(conds...))
}
func (d douyinProductRewardsDo) Limit(limit int) *douyinProductRewardsDo {
return d.withDO(d.DO.Limit(limit))
}
func (d douyinProductRewardsDo) Offset(offset int) *douyinProductRewardsDo {
return d.withDO(d.DO.Offset(offset))
}
func (d douyinProductRewardsDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *douyinProductRewardsDo {
return d.withDO(d.DO.Scopes(funcs...))
}
func (d douyinProductRewardsDo) Unscoped() *douyinProductRewardsDo {
return d.withDO(d.DO.Unscoped())
}
func (d douyinProductRewardsDo) Create(values ...*model.DouyinProductRewards) error {
if len(values) == 0 {
return nil
}
return d.DO.Create(values)
}
func (d douyinProductRewardsDo) CreateInBatches(values []*model.DouyinProductRewards, batchSize int) error {
return d.DO.CreateInBatches(values, batchSize)
}
// Save : !!! underlying implementation is different with GORM
// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
func (d douyinProductRewardsDo) Save(values ...*model.DouyinProductRewards) error {
if len(values) == 0 {
return nil
}
return d.DO.Save(values)
}
func (d douyinProductRewardsDo) First() (*model.DouyinProductRewards, error) {
if result, err := d.DO.First(); err != nil {
return nil, err
} else {
return result.(*model.DouyinProductRewards), nil
}
}
func (d douyinProductRewardsDo) Take() (*model.DouyinProductRewards, error) {
if result, err := d.DO.Take(); err != nil {
return nil, err
} else {
return result.(*model.DouyinProductRewards), nil
}
}
func (d douyinProductRewardsDo) Last() (*model.DouyinProductRewards, error) {
if result, err := d.DO.Last(); err != nil {
return nil, err
} else {
return result.(*model.DouyinProductRewards), nil
}
}
func (d douyinProductRewardsDo) Find() ([]*model.DouyinProductRewards, error) {
result, err := d.DO.Find()
return result.([]*model.DouyinProductRewards), err
}
func (d douyinProductRewardsDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.DouyinProductRewards, err error) {
buf := make([]*model.DouyinProductRewards, 0, batchSize)
err = d.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
defer func() { results = append(results, buf...) }()
return fc(tx, batch)
})
return results, err
}
func (d douyinProductRewardsDo) FindInBatches(result *[]*model.DouyinProductRewards, batchSize int, fc func(tx gen.Dao, batch int) error) error {
return d.DO.FindInBatches(result, batchSize, fc)
}
func (d douyinProductRewardsDo) Attrs(attrs ...field.AssignExpr) *douyinProductRewardsDo {
return d.withDO(d.DO.Attrs(attrs...))
}
func (d douyinProductRewardsDo) Assign(attrs ...field.AssignExpr) *douyinProductRewardsDo {
return d.withDO(d.DO.Assign(attrs...))
}
func (d douyinProductRewardsDo) Joins(fields ...field.RelationField) *douyinProductRewardsDo {
for _, _f := range fields {
d = *d.withDO(d.DO.Joins(_f))
}
return &d
}
func (d douyinProductRewardsDo) Preload(fields ...field.RelationField) *douyinProductRewardsDo {
for _, _f := range fields {
d = *d.withDO(d.DO.Preload(_f))
}
return &d
}
func (d douyinProductRewardsDo) FirstOrInit() (*model.DouyinProductRewards, error) {
if result, err := d.DO.FirstOrInit(); err != nil {
return nil, err
} else {
return result.(*model.DouyinProductRewards), nil
}
}
func (d douyinProductRewardsDo) FirstOrCreate() (*model.DouyinProductRewards, error) {
if result, err := d.DO.FirstOrCreate(); err != nil {
return nil, err
} else {
return result.(*model.DouyinProductRewards), nil
}
}
func (d douyinProductRewardsDo) FindByPage(offset int, limit int) (result []*model.DouyinProductRewards, count int64, err error) {
result, err = d.Offset(offset).Limit(limit).Find()
if err != nil {
return
}
if size := len(result); 0 < limit && 0 < size && size < limit {
count = int64(size + offset)
return
}
count, err = d.Offset(-1).Limit(-1).Count()
return
}
func (d douyinProductRewardsDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
count, err = d.Count()
if err != nil {
return
}
err = d.Offset(offset).Limit(limit).Scan(result)
return
}
func (d douyinProductRewardsDo) Scan(result interface{}) (err error) {
return d.DO.Scan(result)
}
func (d douyinProductRewardsDo) Delete(models ...*model.DouyinProductRewards) (result gen.ResultInfo, err error) {
return d.DO.Delete(models)
}
func (d *douyinProductRewardsDo) withDO(do gen.Dao) *douyinProductRewardsDo {
d.DO = *do.(*gen.DO)
return d
}

View File

@ -28,10 +28,15 @@ var (
AuditRollbackLogs *auditRollbackLogs
Banner *banner
Channels *channels
DouyinBlacklist *douyinBlacklist
DouyinOrders *douyinOrders
DouyinProductRewards *douyinProductRewards
GamePassPackages *gamePassPackages
GameTicketLogs *gameTicketLogs
IssuePositionClaims *issuePositionClaims
LivestreamActivities *livestreamActivities
LivestreamDrawLogs *livestreamDrawLogs
LivestreamPrizes *livestreamPrizes
LogOperation *logOperation
LogRequest *logRequest
LotteryRefundLogs *lotteryRefundLogs
@ -95,10 +100,15 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
AuditRollbackLogs = &Q.AuditRollbackLogs
Banner = &Q.Banner
Channels = &Q.Channels
DouyinBlacklist = &Q.DouyinBlacklist
DouyinOrders = &Q.DouyinOrders
DouyinProductRewards = &Q.DouyinProductRewards
GamePassPackages = &Q.GamePassPackages
GameTicketLogs = &Q.GameTicketLogs
IssuePositionClaims = &Q.IssuePositionClaims
LivestreamActivities = &Q.LivestreamActivities
LivestreamDrawLogs = &Q.LivestreamDrawLogs
LivestreamPrizes = &Q.LivestreamPrizes
LogOperation = &Q.LogOperation
LogRequest = &Q.LogRequest
LotteryRefundLogs = &Q.LotteryRefundLogs
@ -163,10 +173,15 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
AuditRollbackLogs: newAuditRollbackLogs(db, opts...),
Banner: newBanner(db, opts...),
Channels: newChannels(db, opts...),
DouyinBlacklist: newDouyinBlacklist(db, opts...),
DouyinOrders: newDouyinOrders(db, opts...),
DouyinProductRewards: newDouyinProductRewards(db, opts...),
GamePassPackages: newGamePassPackages(db, opts...),
GameTicketLogs: newGameTicketLogs(db, opts...),
IssuePositionClaims: newIssuePositionClaims(db, opts...),
LivestreamActivities: newLivestreamActivities(db, opts...),
LivestreamDrawLogs: newLivestreamDrawLogs(db, opts...),
LivestreamPrizes: newLivestreamPrizes(db, opts...),
LogOperation: newLogOperation(db, opts...),
LogRequest: newLogRequest(db, opts...),
LotteryRefundLogs: newLotteryRefundLogs(db, opts...),
@ -232,10 +247,15 @@ type Query struct {
AuditRollbackLogs auditRollbackLogs
Banner banner
Channels channels
DouyinBlacklist douyinBlacklist
DouyinOrders douyinOrders
DouyinProductRewards douyinProductRewards
GamePassPackages gamePassPackages
GameTicketLogs gameTicketLogs
IssuePositionClaims issuePositionClaims
LivestreamActivities livestreamActivities
LivestreamDrawLogs livestreamDrawLogs
LivestreamPrizes livestreamPrizes
LogOperation logOperation
LogRequest logRequest
LotteryRefundLogs lotteryRefundLogs
@ -302,10 +322,15 @@ func (q *Query) clone(db *gorm.DB) *Query {
AuditRollbackLogs: q.AuditRollbackLogs.clone(db),
Banner: q.Banner.clone(db),
Channels: q.Channels.clone(db),
DouyinBlacklist: q.DouyinBlacklist.clone(db),
DouyinOrders: q.DouyinOrders.clone(db),
DouyinProductRewards: q.DouyinProductRewards.clone(db),
GamePassPackages: q.GamePassPackages.clone(db),
GameTicketLogs: q.GameTicketLogs.clone(db),
IssuePositionClaims: q.IssuePositionClaims.clone(db),
LivestreamActivities: q.LivestreamActivities.clone(db),
LivestreamDrawLogs: q.LivestreamDrawLogs.clone(db),
LivestreamPrizes: q.LivestreamPrizes.clone(db),
LogOperation: q.LogOperation.clone(db),
LogRequest: q.LogRequest.clone(db),
LotteryRefundLogs: q.LotteryRefundLogs.clone(db),
@ -379,10 +404,15 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
AuditRollbackLogs: q.AuditRollbackLogs.replaceDB(db),
Banner: q.Banner.replaceDB(db),
Channels: q.Channels.replaceDB(db),
DouyinBlacklist: q.DouyinBlacklist.replaceDB(db),
DouyinOrders: q.DouyinOrders.replaceDB(db),
DouyinProductRewards: q.DouyinProductRewards.replaceDB(db),
GamePassPackages: q.GamePassPackages.replaceDB(db),
GameTicketLogs: q.GameTicketLogs.replaceDB(db),
IssuePositionClaims: q.IssuePositionClaims.replaceDB(db),
LivestreamActivities: q.LivestreamActivities.replaceDB(db),
LivestreamDrawLogs: q.LivestreamDrawLogs.replaceDB(db),
LivestreamPrizes: q.LivestreamPrizes.replaceDB(db),
LogOperation: q.LogOperation.replaceDB(db),
LogRequest: q.LogRequest.replaceDB(db),
LotteryRefundLogs: q.LotteryRefundLogs.replaceDB(db),
@ -446,10 +476,15 @@ type queryCtx struct {
AuditRollbackLogs *auditRollbackLogsDo
Banner *bannerDo
Channels *channelsDo
DouyinBlacklist *douyinBlacklistDo
DouyinOrders *douyinOrdersDo
DouyinProductRewards *douyinProductRewardsDo
GamePassPackages *gamePassPackagesDo
GameTicketLogs *gameTicketLogsDo
IssuePositionClaims *issuePositionClaimsDo
LivestreamActivities *livestreamActivitiesDo
LivestreamDrawLogs *livestreamDrawLogsDo
LivestreamPrizes *livestreamPrizesDo
LogOperation *logOperationDo
LogRequest *logRequestDo
LotteryRefundLogs *lotteryRefundLogsDo
@ -513,10 +548,15 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
AuditRollbackLogs: q.AuditRollbackLogs.WithContext(ctx),
Banner: q.Banner.WithContext(ctx),
Channels: q.Channels.WithContext(ctx),
DouyinBlacklist: q.DouyinBlacklist.WithContext(ctx),
DouyinOrders: q.DouyinOrders.WithContext(ctx),
DouyinProductRewards: q.DouyinProductRewards.WithContext(ctx),
GamePassPackages: q.GamePassPackages.WithContext(ctx),
GameTicketLogs: q.GameTicketLogs.WithContext(ctx),
IssuePositionClaims: q.IssuePositionClaims.WithContext(ctx),
LivestreamActivities: q.LivestreamActivities.WithContext(ctx),
LivestreamDrawLogs: q.LivestreamDrawLogs.WithContext(ctx),
LivestreamPrizes: q.LivestreamPrizes.WithContext(ctx),
LogOperation: q.LogOperation.WithContext(ctx),
LogRequest: q.LogRequest.WithContext(ctx),
LotteryRefundLogs: q.LotteryRefundLogs.WithContext(ctx),

View File

@ -0,0 +1,384 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package dao
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/plugin/dbresolver"
"bindbox-game/internal/repository/mysql/model"
)
func newLivestreamActivities(db *gorm.DB, opts ...gen.DOOption) livestreamActivities {
_livestreamActivities := livestreamActivities{}
_livestreamActivities.livestreamActivitiesDo.UseDB(db, opts...)
_livestreamActivities.livestreamActivitiesDo.UseModel(&model.LivestreamActivities{})
tableName := _livestreamActivities.livestreamActivitiesDo.TableName()
_livestreamActivities.ALL = field.NewAsterisk(tableName)
_livestreamActivities.ID = field.NewInt64(tableName, "id")
_livestreamActivities.Name = field.NewString(tableName, "name")
_livestreamActivities.StreamerName = field.NewString(tableName, "streamer_name")
_livestreamActivities.StreamerContact = field.NewString(tableName, "streamer_contact")
_livestreamActivities.AccessCode = field.NewString(tableName, "access_code")
_livestreamActivities.DouyinProductID = field.NewString(tableName, "douyin_product_id")
_livestreamActivities.Status = field.NewInt32(tableName, "status")
_livestreamActivities.CommitmentAlgo = field.NewString(tableName, "commitment_algo")
_livestreamActivities.CommitmentSeedMaster = field.NewBytes(tableName, "commitment_seed_master")
_livestreamActivities.CommitmentSeedHash = field.NewBytes(tableName, "commitment_seed_hash")
_livestreamActivities.CommitmentStateVersion = field.NewInt32(tableName, "commitment_state_version")
_livestreamActivities.StartTime = field.NewTime(tableName, "start_time")
_livestreamActivities.EndTime = field.NewTime(tableName, "end_time")
_livestreamActivities.CreatedAt = field.NewTime(tableName, "created_at")
_livestreamActivities.UpdatedAt = field.NewTime(tableName, "updated_at")
_livestreamActivities.DeletedAt = field.NewField(tableName, "deleted_at")
_livestreamActivities.TicketPrice = field.NewInt32(tableName, "ticket_price")
_livestreamActivities.fillFieldMap()
return _livestreamActivities
}
// livestreamActivities 直播间活动表
type livestreamActivities struct {
livestreamActivitiesDo
ALL field.Asterisk
ID field.Int64 // 主键ID
Name field.String // 活动名称
StreamerName field.String // 主播名称
StreamerContact field.String // 主播联系方式
AccessCode field.String // 唯一访问码
DouyinProductID field.String // 关联抖店商品ID
Status field.Int32 // 状态:1进行中 2已结束
CommitmentAlgo field.String // 承诺算法版本
CommitmentSeedMaster field.Bytes // 主种子(32字节)
CommitmentSeedHash field.Bytes // 种子SHA256哈希
CommitmentStateVersion field.Int32 // 状态版本
StartTime field.Time // 开始时间
EndTime field.Time // 结束时间
CreatedAt field.Time // 创建时间
UpdatedAt field.Time // 更新时间
DeletedAt field.Field // 删除时间
TicketPrice field.Int32
fieldMap map[string]field.Expr
}
func (l livestreamActivities) Table(newTableName string) *livestreamActivities {
l.livestreamActivitiesDo.UseTable(newTableName)
return l.updateTableName(newTableName)
}
func (l livestreamActivities) As(alias string) *livestreamActivities {
l.livestreamActivitiesDo.DO = *(l.livestreamActivitiesDo.As(alias).(*gen.DO))
return l.updateTableName(alias)
}
func (l *livestreamActivities) updateTableName(table string) *livestreamActivities {
l.ALL = field.NewAsterisk(table)
l.ID = field.NewInt64(table, "id")
l.Name = field.NewString(table, "name")
l.StreamerName = field.NewString(table, "streamer_name")
l.StreamerContact = field.NewString(table, "streamer_contact")
l.AccessCode = field.NewString(table, "access_code")
l.DouyinProductID = field.NewString(table, "douyin_product_id")
l.Status = field.NewInt32(table, "status")
l.CommitmentAlgo = field.NewString(table, "commitment_algo")
l.CommitmentSeedMaster = field.NewBytes(table, "commitment_seed_master")
l.CommitmentSeedHash = field.NewBytes(table, "commitment_seed_hash")
l.CommitmentStateVersion = field.NewInt32(table, "commitment_state_version")
l.StartTime = field.NewTime(table, "start_time")
l.EndTime = field.NewTime(table, "end_time")
l.CreatedAt = field.NewTime(table, "created_at")
l.UpdatedAt = field.NewTime(table, "updated_at")
l.DeletedAt = field.NewField(table, "deleted_at")
l.TicketPrice = field.NewInt32(table, "ticket_price")
l.fillFieldMap()
return l
}
func (l *livestreamActivities) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
_f, ok := l.fieldMap[fieldName]
if !ok || _f == nil {
return nil, false
}
_oe, ok := _f.(field.OrderExpr)
return _oe, ok
}
func (l *livestreamActivities) fillFieldMap() {
l.fieldMap = make(map[string]field.Expr, 17)
l.fieldMap["id"] = l.ID
l.fieldMap["name"] = l.Name
l.fieldMap["streamer_name"] = l.StreamerName
l.fieldMap["streamer_contact"] = l.StreamerContact
l.fieldMap["access_code"] = l.AccessCode
l.fieldMap["douyin_product_id"] = l.DouyinProductID
l.fieldMap["status"] = l.Status
l.fieldMap["commitment_algo"] = l.CommitmentAlgo
l.fieldMap["commitment_seed_master"] = l.CommitmentSeedMaster
l.fieldMap["commitment_seed_hash"] = l.CommitmentSeedHash
l.fieldMap["commitment_state_version"] = l.CommitmentStateVersion
l.fieldMap["start_time"] = l.StartTime
l.fieldMap["end_time"] = l.EndTime
l.fieldMap["created_at"] = l.CreatedAt
l.fieldMap["updated_at"] = l.UpdatedAt
l.fieldMap["deleted_at"] = l.DeletedAt
l.fieldMap["ticket_price"] = l.TicketPrice
}
func (l livestreamActivities) clone(db *gorm.DB) livestreamActivities {
l.livestreamActivitiesDo.ReplaceConnPool(db.Statement.ConnPool)
return l
}
func (l livestreamActivities) replaceDB(db *gorm.DB) livestreamActivities {
l.livestreamActivitiesDo.ReplaceDB(db)
return l
}
type livestreamActivitiesDo struct{ gen.DO }
func (l livestreamActivitiesDo) Debug() *livestreamActivitiesDo {
return l.withDO(l.DO.Debug())
}
func (l livestreamActivitiesDo) WithContext(ctx context.Context) *livestreamActivitiesDo {
return l.withDO(l.DO.WithContext(ctx))
}
func (l livestreamActivitiesDo) ReadDB() *livestreamActivitiesDo {
return l.Clauses(dbresolver.Read)
}
func (l livestreamActivitiesDo) WriteDB() *livestreamActivitiesDo {
return l.Clauses(dbresolver.Write)
}
func (l livestreamActivitiesDo) Session(config *gorm.Session) *livestreamActivitiesDo {
return l.withDO(l.DO.Session(config))
}
func (l livestreamActivitiesDo) Clauses(conds ...clause.Expression) *livestreamActivitiesDo {
return l.withDO(l.DO.Clauses(conds...))
}
func (l livestreamActivitiesDo) Returning(value interface{}, columns ...string) *livestreamActivitiesDo {
return l.withDO(l.DO.Returning(value, columns...))
}
func (l livestreamActivitiesDo) Not(conds ...gen.Condition) *livestreamActivitiesDo {
return l.withDO(l.DO.Not(conds...))
}
func (l livestreamActivitiesDo) Or(conds ...gen.Condition) *livestreamActivitiesDo {
return l.withDO(l.DO.Or(conds...))
}
func (l livestreamActivitiesDo) Select(conds ...field.Expr) *livestreamActivitiesDo {
return l.withDO(l.DO.Select(conds...))
}
func (l livestreamActivitiesDo) Where(conds ...gen.Condition) *livestreamActivitiesDo {
return l.withDO(l.DO.Where(conds...))
}
func (l livestreamActivitiesDo) Order(conds ...field.Expr) *livestreamActivitiesDo {
return l.withDO(l.DO.Order(conds...))
}
func (l livestreamActivitiesDo) Distinct(cols ...field.Expr) *livestreamActivitiesDo {
return l.withDO(l.DO.Distinct(cols...))
}
func (l livestreamActivitiesDo) Omit(cols ...field.Expr) *livestreamActivitiesDo {
return l.withDO(l.DO.Omit(cols...))
}
func (l livestreamActivitiesDo) Join(table schema.Tabler, on ...field.Expr) *livestreamActivitiesDo {
return l.withDO(l.DO.Join(table, on...))
}
func (l livestreamActivitiesDo) LeftJoin(table schema.Tabler, on ...field.Expr) *livestreamActivitiesDo {
return l.withDO(l.DO.LeftJoin(table, on...))
}
func (l livestreamActivitiesDo) RightJoin(table schema.Tabler, on ...field.Expr) *livestreamActivitiesDo {
return l.withDO(l.DO.RightJoin(table, on...))
}
func (l livestreamActivitiesDo) Group(cols ...field.Expr) *livestreamActivitiesDo {
return l.withDO(l.DO.Group(cols...))
}
func (l livestreamActivitiesDo) Having(conds ...gen.Condition) *livestreamActivitiesDo {
return l.withDO(l.DO.Having(conds...))
}
func (l livestreamActivitiesDo) Limit(limit int) *livestreamActivitiesDo {
return l.withDO(l.DO.Limit(limit))
}
func (l livestreamActivitiesDo) Offset(offset int) *livestreamActivitiesDo {
return l.withDO(l.DO.Offset(offset))
}
func (l livestreamActivitiesDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *livestreamActivitiesDo {
return l.withDO(l.DO.Scopes(funcs...))
}
func (l livestreamActivitiesDo) Unscoped() *livestreamActivitiesDo {
return l.withDO(l.DO.Unscoped())
}
func (l livestreamActivitiesDo) Create(values ...*model.LivestreamActivities) error {
if len(values) == 0 {
return nil
}
return l.DO.Create(values)
}
func (l livestreamActivitiesDo) CreateInBatches(values []*model.LivestreamActivities, batchSize int) error {
return l.DO.CreateInBatches(values, batchSize)
}
// Save : !!! underlying implementation is different with GORM
// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
func (l livestreamActivitiesDo) Save(values ...*model.LivestreamActivities) error {
if len(values) == 0 {
return nil
}
return l.DO.Save(values)
}
func (l livestreamActivitiesDo) First() (*model.LivestreamActivities, error) {
if result, err := l.DO.First(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamActivities), nil
}
}
func (l livestreamActivitiesDo) Take() (*model.LivestreamActivities, error) {
if result, err := l.DO.Take(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamActivities), nil
}
}
func (l livestreamActivitiesDo) Last() (*model.LivestreamActivities, error) {
if result, err := l.DO.Last(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamActivities), nil
}
}
func (l livestreamActivitiesDo) Find() ([]*model.LivestreamActivities, error) {
result, err := l.DO.Find()
return result.([]*model.LivestreamActivities), err
}
func (l livestreamActivitiesDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.LivestreamActivities, err error) {
buf := make([]*model.LivestreamActivities, 0, batchSize)
err = l.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
defer func() { results = append(results, buf...) }()
return fc(tx, batch)
})
return results, err
}
func (l livestreamActivitiesDo) FindInBatches(result *[]*model.LivestreamActivities, batchSize int, fc func(tx gen.Dao, batch int) error) error {
return l.DO.FindInBatches(result, batchSize, fc)
}
func (l livestreamActivitiesDo) Attrs(attrs ...field.AssignExpr) *livestreamActivitiesDo {
return l.withDO(l.DO.Attrs(attrs...))
}
func (l livestreamActivitiesDo) Assign(attrs ...field.AssignExpr) *livestreamActivitiesDo {
return l.withDO(l.DO.Assign(attrs...))
}
func (l livestreamActivitiesDo) Joins(fields ...field.RelationField) *livestreamActivitiesDo {
for _, _f := range fields {
l = *l.withDO(l.DO.Joins(_f))
}
return &l
}
func (l livestreamActivitiesDo) Preload(fields ...field.RelationField) *livestreamActivitiesDo {
for _, _f := range fields {
l = *l.withDO(l.DO.Preload(_f))
}
return &l
}
func (l livestreamActivitiesDo) FirstOrInit() (*model.LivestreamActivities, error) {
if result, err := l.DO.FirstOrInit(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamActivities), nil
}
}
func (l livestreamActivitiesDo) FirstOrCreate() (*model.LivestreamActivities, error) {
if result, err := l.DO.FirstOrCreate(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamActivities), nil
}
}
func (l livestreamActivitiesDo) FindByPage(offset int, limit int) (result []*model.LivestreamActivities, count int64, err error) {
result, err = l.Offset(offset).Limit(limit).Find()
if err != nil {
return
}
if size := len(result); 0 < limit && 0 < size && size < limit {
count = int64(size + offset)
return
}
count, err = l.Offset(-1).Limit(-1).Count()
return
}
func (l livestreamActivitiesDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
count, err = l.Count()
if err != nil {
return
}
err = l.Offset(offset).Limit(limit).Scan(result)
return
}
func (l livestreamActivitiesDo) Scan(result interface{}) (err error) {
return l.DO.Scan(result)
}
func (l livestreamActivitiesDo) Delete(models ...*model.LivestreamActivities) (result gen.ResultInfo, err error) {
return l.DO.Delete(models)
}
func (l *livestreamActivitiesDo) withDO(do gen.Dao) *livestreamActivitiesDo {
l.DO = *do.(*gen.DO)
return l
}

View File

@ -0,0 +1,380 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package dao
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/plugin/dbresolver"
"bindbox-game/internal/repository/mysql/model"
)
func newLivestreamDrawLogs(db *gorm.DB, opts ...gen.DOOption) livestreamDrawLogs {
_livestreamDrawLogs := livestreamDrawLogs{}
_livestreamDrawLogs.livestreamDrawLogsDo.UseDB(db, opts...)
_livestreamDrawLogs.livestreamDrawLogsDo.UseModel(&model.LivestreamDrawLogs{})
tableName := _livestreamDrawLogs.livestreamDrawLogsDo.TableName()
_livestreamDrawLogs.ALL = field.NewAsterisk(tableName)
_livestreamDrawLogs.ID = field.NewInt64(tableName, "id")
_livestreamDrawLogs.ActivityID = field.NewInt64(tableName, "activity_id")
_livestreamDrawLogs.PrizeID = field.NewInt64(tableName, "prize_id")
_livestreamDrawLogs.DouyinOrderID = field.NewInt64(tableName, "douyin_order_id")
_livestreamDrawLogs.ShopOrderID = field.NewString(tableName, "shop_order_id")
_livestreamDrawLogs.LocalUserID = field.NewInt64(tableName, "local_user_id")
_livestreamDrawLogs.DouyinUserID = field.NewString(tableName, "douyin_user_id")
_livestreamDrawLogs.UserNickname = field.NewString(tableName, "user_nickname")
_livestreamDrawLogs.PrizeName = field.NewString(tableName, "prize_name")
_livestreamDrawLogs.Level = field.NewInt32(tableName, "level")
_livestreamDrawLogs.SeedHash = field.NewString(tableName, "seed_hash")
_livestreamDrawLogs.RandValue = field.NewInt64(tableName, "rand_value")
_livestreamDrawLogs.WeightsTotal = field.NewInt64(tableName, "weights_total")
_livestreamDrawLogs.CreatedAt = field.NewTime(tableName, "created_at")
_livestreamDrawLogs.IsGranted = field.NewBool(tableName, "is_granted")
_livestreamDrawLogs.IsRefunded = field.NewInt32(tableName, "is_refunded")
_livestreamDrawLogs.fillFieldMap()
return _livestreamDrawLogs
}
// livestreamDrawLogs 直播间中奖记录表
type livestreamDrawLogs struct {
livestreamDrawLogsDo
ALL field.Asterisk
ID field.Int64 // 主键ID
ActivityID field.Int64 // 关联livestream_activities.id
PrizeID field.Int64 // 关联livestream_prizes.id
DouyinOrderID field.Int64 // 关联douyin_orders.id
ShopOrderID field.String // 抖店订单号
LocalUserID field.Int64 // 本地用户ID
DouyinUserID field.String // 抖音用户ID
UserNickname field.String // 用户昵称
PrizeName field.String // 中奖奖品名称快照
Level field.Int32 // 奖品等级
SeedHash field.String // 哈希种子
RandValue field.Int64 // 随机值
WeightsTotal field.Int64 // 权重总和
CreatedAt field.Time // 中奖时间
IsGranted field.Bool // 是否已发放奖品
IsRefunded field.Int32 // 订单是否已退款
fieldMap map[string]field.Expr
}
func (l livestreamDrawLogs) Table(newTableName string) *livestreamDrawLogs {
l.livestreamDrawLogsDo.UseTable(newTableName)
return l.updateTableName(newTableName)
}
func (l livestreamDrawLogs) As(alias string) *livestreamDrawLogs {
l.livestreamDrawLogsDo.DO = *(l.livestreamDrawLogsDo.As(alias).(*gen.DO))
return l.updateTableName(alias)
}
func (l *livestreamDrawLogs) updateTableName(table string) *livestreamDrawLogs {
l.ALL = field.NewAsterisk(table)
l.ID = field.NewInt64(table, "id")
l.ActivityID = field.NewInt64(table, "activity_id")
l.PrizeID = field.NewInt64(table, "prize_id")
l.DouyinOrderID = field.NewInt64(table, "douyin_order_id")
l.ShopOrderID = field.NewString(table, "shop_order_id")
l.LocalUserID = field.NewInt64(table, "local_user_id")
l.DouyinUserID = field.NewString(table, "douyin_user_id")
l.UserNickname = field.NewString(table, "user_nickname")
l.PrizeName = field.NewString(table, "prize_name")
l.Level = field.NewInt32(table, "level")
l.SeedHash = field.NewString(table, "seed_hash")
l.RandValue = field.NewInt64(table, "rand_value")
l.WeightsTotal = field.NewInt64(table, "weights_total")
l.CreatedAt = field.NewTime(table, "created_at")
l.IsGranted = field.NewBool(table, "is_granted")
l.IsRefunded = field.NewInt32(table, "is_refunded")
l.fillFieldMap()
return l
}
func (l *livestreamDrawLogs) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
_f, ok := l.fieldMap[fieldName]
if !ok || _f == nil {
return nil, false
}
_oe, ok := _f.(field.OrderExpr)
return _oe, ok
}
func (l *livestreamDrawLogs) fillFieldMap() {
l.fieldMap = make(map[string]field.Expr, 16)
l.fieldMap["id"] = l.ID
l.fieldMap["activity_id"] = l.ActivityID
l.fieldMap["prize_id"] = l.PrizeID
l.fieldMap["douyin_order_id"] = l.DouyinOrderID
l.fieldMap["shop_order_id"] = l.ShopOrderID
l.fieldMap["local_user_id"] = l.LocalUserID
l.fieldMap["douyin_user_id"] = l.DouyinUserID
l.fieldMap["user_nickname"] = l.UserNickname
l.fieldMap["prize_name"] = l.PrizeName
l.fieldMap["level"] = l.Level
l.fieldMap["seed_hash"] = l.SeedHash
l.fieldMap["rand_value"] = l.RandValue
l.fieldMap["weights_total"] = l.WeightsTotal
l.fieldMap["created_at"] = l.CreatedAt
l.fieldMap["is_granted"] = l.IsGranted
l.fieldMap["is_refunded"] = l.IsRefunded
}
func (l livestreamDrawLogs) clone(db *gorm.DB) livestreamDrawLogs {
l.livestreamDrawLogsDo.ReplaceConnPool(db.Statement.ConnPool)
return l
}
func (l livestreamDrawLogs) replaceDB(db *gorm.DB) livestreamDrawLogs {
l.livestreamDrawLogsDo.ReplaceDB(db)
return l
}
type livestreamDrawLogsDo struct{ gen.DO }
func (l livestreamDrawLogsDo) Debug() *livestreamDrawLogsDo {
return l.withDO(l.DO.Debug())
}
func (l livestreamDrawLogsDo) WithContext(ctx context.Context) *livestreamDrawLogsDo {
return l.withDO(l.DO.WithContext(ctx))
}
func (l livestreamDrawLogsDo) ReadDB() *livestreamDrawLogsDo {
return l.Clauses(dbresolver.Read)
}
func (l livestreamDrawLogsDo) WriteDB() *livestreamDrawLogsDo {
return l.Clauses(dbresolver.Write)
}
func (l livestreamDrawLogsDo) Session(config *gorm.Session) *livestreamDrawLogsDo {
return l.withDO(l.DO.Session(config))
}
func (l livestreamDrawLogsDo) Clauses(conds ...clause.Expression) *livestreamDrawLogsDo {
return l.withDO(l.DO.Clauses(conds...))
}
func (l livestreamDrawLogsDo) Returning(value interface{}, columns ...string) *livestreamDrawLogsDo {
return l.withDO(l.DO.Returning(value, columns...))
}
func (l livestreamDrawLogsDo) Not(conds ...gen.Condition) *livestreamDrawLogsDo {
return l.withDO(l.DO.Not(conds...))
}
func (l livestreamDrawLogsDo) Or(conds ...gen.Condition) *livestreamDrawLogsDo {
return l.withDO(l.DO.Or(conds...))
}
func (l livestreamDrawLogsDo) Select(conds ...field.Expr) *livestreamDrawLogsDo {
return l.withDO(l.DO.Select(conds...))
}
func (l livestreamDrawLogsDo) Where(conds ...gen.Condition) *livestreamDrawLogsDo {
return l.withDO(l.DO.Where(conds...))
}
func (l livestreamDrawLogsDo) Order(conds ...field.Expr) *livestreamDrawLogsDo {
return l.withDO(l.DO.Order(conds...))
}
func (l livestreamDrawLogsDo) Distinct(cols ...field.Expr) *livestreamDrawLogsDo {
return l.withDO(l.DO.Distinct(cols...))
}
func (l livestreamDrawLogsDo) Omit(cols ...field.Expr) *livestreamDrawLogsDo {
return l.withDO(l.DO.Omit(cols...))
}
func (l livestreamDrawLogsDo) Join(table schema.Tabler, on ...field.Expr) *livestreamDrawLogsDo {
return l.withDO(l.DO.Join(table, on...))
}
func (l livestreamDrawLogsDo) LeftJoin(table schema.Tabler, on ...field.Expr) *livestreamDrawLogsDo {
return l.withDO(l.DO.LeftJoin(table, on...))
}
func (l livestreamDrawLogsDo) RightJoin(table schema.Tabler, on ...field.Expr) *livestreamDrawLogsDo {
return l.withDO(l.DO.RightJoin(table, on...))
}
func (l livestreamDrawLogsDo) Group(cols ...field.Expr) *livestreamDrawLogsDo {
return l.withDO(l.DO.Group(cols...))
}
func (l livestreamDrawLogsDo) Having(conds ...gen.Condition) *livestreamDrawLogsDo {
return l.withDO(l.DO.Having(conds...))
}
func (l livestreamDrawLogsDo) Limit(limit int) *livestreamDrawLogsDo {
return l.withDO(l.DO.Limit(limit))
}
func (l livestreamDrawLogsDo) Offset(offset int) *livestreamDrawLogsDo {
return l.withDO(l.DO.Offset(offset))
}
func (l livestreamDrawLogsDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *livestreamDrawLogsDo {
return l.withDO(l.DO.Scopes(funcs...))
}
func (l livestreamDrawLogsDo) Unscoped() *livestreamDrawLogsDo {
return l.withDO(l.DO.Unscoped())
}
func (l livestreamDrawLogsDo) Create(values ...*model.LivestreamDrawLogs) error {
if len(values) == 0 {
return nil
}
return l.DO.Create(values)
}
func (l livestreamDrawLogsDo) CreateInBatches(values []*model.LivestreamDrawLogs, batchSize int) error {
return l.DO.CreateInBatches(values, batchSize)
}
// Save : !!! underlying implementation is different with GORM
// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
func (l livestreamDrawLogsDo) Save(values ...*model.LivestreamDrawLogs) error {
if len(values) == 0 {
return nil
}
return l.DO.Save(values)
}
func (l livestreamDrawLogsDo) First() (*model.LivestreamDrawLogs, error) {
if result, err := l.DO.First(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamDrawLogs), nil
}
}
func (l livestreamDrawLogsDo) Take() (*model.LivestreamDrawLogs, error) {
if result, err := l.DO.Take(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamDrawLogs), nil
}
}
func (l livestreamDrawLogsDo) Last() (*model.LivestreamDrawLogs, error) {
if result, err := l.DO.Last(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamDrawLogs), nil
}
}
func (l livestreamDrawLogsDo) Find() ([]*model.LivestreamDrawLogs, error) {
result, err := l.DO.Find()
return result.([]*model.LivestreamDrawLogs), err
}
func (l livestreamDrawLogsDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.LivestreamDrawLogs, err error) {
buf := make([]*model.LivestreamDrawLogs, 0, batchSize)
err = l.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
defer func() { results = append(results, buf...) }()
return fc(tx, batch)
})
return results, err
}
func (l livestreamDrawLogsDo) FindInBatches(result *[]*model.LivestreamDrawLogs, batchSize int, fc func(tx gen.Dao, batch int) error) error {
return l.DO.FindInBatches(result, batchSize, fc)
}
func (l livestreamDrawLogsDo) Attrs(attrs ...field.AssignExpr) *livestreamDrawLogsDo {
return l.withDO(l.DO.Attrs(attrs...))
}
func (l livestreamDrawLogsDo) Assign(attrs ...field.AssignExpr) *livestreamDrawLogsDo {
return l.withDO(l.DO.Assign(attrs...))
}
func (l livestreamDrawLogsDo) Joins(fields ...field.RelationField) *livestreamDrawLogsDo {
for _, _f := range fields {
l = *l.withDO(l.DO.Joins(_f))
}
return &l
}
func (l livestreamDrawLogsDo) Preload(fields ...field.RelationField) *livestreamDrawLogsDo {
for _, _f := range fields {
l = *l.withDO(l.DO.Preload(_f))
}
return &l
}
func (l livestreamDrawLogsDo) FirstOrInit() (*model.LivestreamDrawLogs, error) {
if result, err := l.DO.FirstOrInit(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamDrawLogs), nil
}
}
func (l livestreamDrawLogsDo) FirstOrCreate() (*model.LivestreamDrawLogs, error) {
if result, err := l.DO.FirstOrCreate(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamDrawLogs), nil
}
}
func (l livestreamDrawLogsDo) FindByPage(offset int, limit int) (result []*model.LivestreamDrawLogs, count int64, err error) {
result, err = l.Offset(offset).Limit(limit).Find()
if err != nil {
return
}
if size := len(result); 0 < limit && 0 < size && size < limit {
count = int64(size + offset)
return
}
count, err = l.Offset(-1).Limit(-1).Count()
return
}
func (l livestreamDrawLogsDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
count, err = l.Count()
if err != nil {
return
}
err = l.Offset(offset).Limit(limit).Scan(result)
return
}
func (l livestreamDrawLogsDo) Scan(result interface{}) (err error) {
return l.DO.Scan(result)
}
func (l livestreamDrawLogsDo) Delete(models ...*model.LivestreamDrawLogs) (result gen.ResultInfo, err error) {
return l.DO.Delete(models)
}
func (l *livestreamDrawLogsDo) withDO(do gen.Dao) *livestreamDrawLogsDo {
l.DO = *do.(*gen.DO)
return l
}

View File

@ -0,0 +1,368 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package dao
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"gorm.io/gorm/schema"
"gorm.io/gen"
"gorm.io/gen/field"
"gorm.io/plugin/dbresolver"
"bindbox-game/internal/repository/mysql/model"
)
func newLivestreamPrizes(db *gorm.DB, opts ...gen.DOOption) livestreamPrizes {
_livestreamPrizes := livestreamPrizes{}
_livestreamPrizes.livestreamPrizesDo.UseDB(db, opts...)
_livestreamPrizes.livestreamPrizesDo.UseModel(&model.LivestreamPrizes{})
tableName := _livestreamPrizes.livestreamPrizesDo.TableName()
_livestreamPrizes.ALL = field.NewAsterisk(tableName)
_livestreamPrizes.ID = field.NewInt64(tableName, "id")
_livestreamPrizes.ActivityID = field.NewInt64(tableName, "activity_id")
_livestreamPrizes.Name = field.NewString(tableName, "name")
_livestreamPrizes.Image = field.NewString(tableName, "image")
_livestreamPrizes.Weight = field.NewInt32(tableName, "weight")
_livestreamPrizes.Quantity = field.NewInt32(tableName, "quantity")
_livestreamPrizes.Remaining = field.NewInt32(tableName, "remaining")
_livestreamPrizes.Level = field.NewInt32(tableName, "level")
_livestreamPrizes.ProductID = field.NewInt64(tableName, "product_id")
_livestreamPrizes.Sort = field.NewInt32(tableName, "sort")
_livestreamPrizes.CreatedAt = field.NewTime(tableName, "created_at")
_livestreamPrizes.UpdatedAt = field.NewTime(tableName, "updated_at")
_livestreamPrizes.CostPrice = field.NewInt64(tableName, "cost_price")
_livestreamPrizes.fillFieldMap()
return _livestreamPrizes
}
// livestreamPrizes 直播间奖品表
type livestreamPrizes struct {
livestreamPrizesDo
ALL field.Asterisk
ID field.Int64 // 主键ID
ActivityID field.Int64 // 关联livestream_activities.id
Name field.String // 奖品名称
Image field.String // 奖品图片
Weight field.Int32 // 抽奖权重
Quantity field.Int32 // 库存数量(-1=无限)
Remaining field.Int32 // 剩余数量
Level field.Int32 // 奖品等级
ProductID field.Int64 // 关联系统商品ID
Sort field.Int32 // 排序
CreatedAt field.Time // 创建时间
UpdatedAt field.Time // 更新时间
CostPrice field.Int64 // 成本价(分)
fieldMap map[string]field.Expr
}
func (l livestreamPrizes) Table(newTableName string) *livestreamPrizes {
l.livestreamPrizesDo.UseTable(newTableName)
return l.updateTableName(newTableName)
}
func (l livestreamPrizes) As(alias string) *livestreamPrizes {
l.livestreamPrizesDo.DO = *(l.livestreamPrizesDo.As(alias).(*gen.DO))
return l.updateTableName(alias)
}
func (l *livestreamPrizes) updateTableName(table string) *livestreamPrizes {
l.ALL = field.NewAsterisk(table)
l.ID = field.NewInt64(table, "id")
l.ActivityID = field.NewInt64(table, "activity_id")
l.Name = field.NewString(table, "name")
l.Image = field.NewString(table, "image")
l.Weight = field.NewInt32(table, "weight")
l.Quantity = field.NewInt32(table, "quantity")
l.Remaining = field.NewInt32(table, "remaining")
l.Level = field.NewInt32(table, "level")
l.ProductID = field.NewInt64(table, "product_id")
l.Sort = field.NewInt32(table, "sort")
l.CreatedAt = field.NewTime(table, "created_at")
l.UpdatedAt = field.NewTime(table, "updated_at")
l.CostPrice = field.NewInt64(table, "cost_price")
l.fillFieldMap()
return l
}
func (l *livestreamPrizes) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
_f, ok := l.fieldMap[fieldName]
if !ok || _f == nil {
return nil, false
}
_oe, ok := _f.(field.OrderExpr)
return _oe, ok
}
func (l *livestreamPrizes) fillFieldMap() {
l.fieldMap = make(map[string]field.Expr, 13)
l.fieldMap["id"] = l.ID
l.fieldMap["activity_id"] = l.ActivityID
l.fieldMap["name"] = l.Name
l.fieldMap["image"] = l.Image
l.fieldMap["weight"] = l.Weight
l.fieldMap["quantity"] = l.Quantity
l.fieldMap["remaining"] = l.Remaining
l.fieldMap["level"] = l.Level
l.fieldMap["product_id"] = l.ProductID
l.fieldMap["sort"] = l.Sort
l.fieldMap["created_at"] = l.CreatedAt
l.fieldMap["updated_at"] = l.UpdatedAt
l.fieldMap["cost_price"] = l.CostPrice
}
func (l livestreamPrizes) clone(db *gorm.DB) livestreamPrizes {
l.livestreamPrizesDo.ReplaceConnPool(db.Statement.ConnPool)
return l
}
func (l livestreamPrizes) replaceDB(db *gorm.DB) livestreamPrizes {
l.livestreamPrizesDo.ReplaceDB(db)
return l
}
type livestreamPrizesDo struct{ gen.DO }
func (l livestreamPrizesDo) Debug() *livestreamPrizesDo {
return l.withDO(l.DO.Debug())
}
func (l livestreamPrizesDo) WithContext(ctx context.Context) *livestreamPrizesDo {
return l.withDO(l.DO.WithContext(ctx))
}
func (l livestreamPrizesDo) ReadDB() *livestreamPrizesDo {
return l.Clauses(dbresolver.Read)
}
func (l livestreamPrizesDo) WriteDB() *livestreamPrizesDo {
return l.Clauses(dbresolver.Write)
}
func (l livestreamPrizesDo) Session(config *gorm.Session) *livestreamPrizesDo {
return l.withDO(l.DO.Session(config))
}
func (l livestreamPrizesDo) Clauses(conds ...clause.Expression) *livestreamPrizesDo {
return l.withDO(l.DO.Clauses(conds...))
}
func (l livestreamPrizesDo) Returning(value interface{}, columns ...string) *livestreamPrizesDo {
return l.withDO(l.DO.Returning(value, columns...))
}
func (l livestreamPrizesDo) Not(conds ...gen.Condition) *livestreamPrizesDo {
return l.withDO(l.DO.Not(conds...))
}
func (l livestreamPrizesDo) Or(conds ...gen.Condition) *livestreamPrizesDo {
return l.withDO(l.DO.Or(conds...))
}
func (l livestreamPrizesDo) Select(conds ...field.Expr) *livestreamPrizesDo {
return l.withDO(l.DO.Select(conds...))
}
func (l livestreamPrizesDo) Where(conds ...gen.Condition) *livestreamPrizesDo {
return l.withDO(l.DO.Where(conds...))
}
func (l livestreamPrizesDo) Order(conds ...field.Expr) *livestreamPrizesDo {
return l.withDO(l.DO.Order(conds...))
}
func (l livestreamPrizesDo) Distinct(cols ...field.Expr) *livestreamPrizesDo {
return l.withDO(l.DO.Distinct(cols...))
}
func (l livestreamPrizesDo) Omit(cols ...field.Expr) *livestreamPrizesDo {
return l.withDO(l.DO.Omit(cols...))
}
func (l livestreamPrizesDo) Join(table schema.Tabler, on ...field.Expr) *livestreamPrizesDo {
return l.withDO(l.DO.Join(table, on...))
}
func (l livestreamPrizesDo) LeftJoin(table schema.Tabler, on ...field.Expr) *livestreamPrizesDo {
return l.withDO(l.DO.LeftJoin(table, on...))
}
func (l livestreamPrizesDo) RightJoin(table schema.Tabler, on ...field.Expr) *livestreamPrizesDo {
return l.withDO(l.DO.RightJoin(table, on...))
}
func (l livestreamPrizesDo) Group(cols ...field.Expr) *livestreamPrizesDo {
return l.withDO(l.DO.Group(cols...))
}
func (l livestreamPrizesDo) Having(conds ...gen.Condition) *livestreamPrizesDo {
return l.withDO(l.DO.Having(conds...))
}
func (l livestreamPrizesDo) Limit(limit int) *livestreamPrizesDo {
return l.withDO(l.DO.Limit(limit))
}
func (l livestreamPrizesDo) Offset(offset int) *livestreamPrizesDo {
return l.withDO(l.DO.Offset(offset))
}
func (l livestreamPrizesDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *livestreamPrizesDo {
return l.withDO(l.DO.Scopes(funcs...))
}
func (l livestreamPrizesDo) Unscoped() *livestreamPrizesDo {
return l.withDO(l.DO.Unscoped())
}
func (l livestreamPrizesDo) Create(values ...*model.LivestreamPrizes) error {
if len(values) == 0 {
return nil
}
return l.DO.Create(values)
}
func (l livestreamPrizesDo) CreateInBatches(values []*model.LivestreamPrizes, batchSize int) error {
return l.DO.CreateInBatches(values, batchSize)
}
// Save : !!! underlying implementation is different with GORM
// The method is equivalent to executing the statement: db.Clauses(clause.OnConflict{UpdateAll: true}).Create(values)
func (l livestreamPrizesDo) Save(values ...*model.LivestreamPrizes) error {
if len(values) == 0 {
return nil
}
return l.DO.Save(values)
}
func (l livestreamPrizesDo) First() (*model.LivestreamPrizes, error) {
if result, err := l.DO.First(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamPrizes), nil
}
}
func (l livestreamPrizesDo) Take() (*model.LivestreamPrizes, error) {
if result, err := l.DO.Take(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamPrizes), nil
}
}
func (l livestreamPrizesDo) Last() (*model.LivestreamPrizes, error) {
if result, err := l.DO.Last(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamPrizes), nil
}
}
func (l livestreamPrizesDo) Find() ([]*model.LivestreamPrizes, error) {
result, err := l.DO.Find()
return result.([]*model.LivestreamPrizes), err
}
func (l livestreamPrizesDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.LivestreamPrizes, err error) {
buf := make([]*model.LivestreamPrizes, 0, batchSize)
err = l.DO.FindInBatches(&buf, batchSize, func(tx gen.Dao, batch int) error {
defer func() { results = append(results, buf...) }()
return fc(tx, batch)
})
return results, err
}
func (l livestreamPrizesDo) FindInBatches(result *[]*model.LivestreamPrizes, batchSize int, fc func(tx gen.Dao, batch int) error) error {
return l.DO.FindInBatches(result, batchSize, fc)
}
func (l livestreamPrizesDo) Attrs(attrs ...field.AssignExpr) *livestreamPrizesDo {
return l.withDO(l.DO.Attrs(attrs...))
}
func (l livestreamPrizesDo) Assign(attrs ...field.AssignExpr) *livestreamPrizesDo {
return l.withDO(l.DO.Assign(attrs...))
}
func (l livestreamPrizesDo) Joins(fields ...field.RelationField) *livestreamPrizesDo {
for _, _f := range fields {
l = *l.withDO(l.DO.Joins(_f))
}
return &l
}
func (l livestreamPrizesDo) Preload(fields ...field.RelationField) *livestreamPrizesDo {
for _, _f := range fields {
l = *l.withDO(l.DO.Preload(_f))
}
return &l
}
func (l livestreamPrizesDo) FirstOrInit() (*model.LivestreamPrizes, error) {
if result, err := l.DO.FirstOrInit(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamPrizes), nil
}
}
func (l livestreamPrizesDo) FirstOrCreate() (*model.LivestreamPrizes, error) {
if result, err := l.DO.FirstOrCreate(); err != nil {
return nil, err
} else {
return result.(*model.LivestreamPrizes), nil
}
}
func (l livestreamPrizesDo) FindByPage(offset int, limit int) (result []*model.LivestreamPrizes, count int64, err error) {
result, err = l.Offset(offset).Limit(limit).Find()
if err != nil {
return
}
if size := len(result); 0 < limit && 0 < size && size < limit {
count = int64(size + offset)
return
}
count, err = l.Offset(-1).Limit(-1).Count()
return
}
func (l livestreamPrizesDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
count, err = l.Count()
if err != nil {
return
}
err = l.Offset(offset).Limit(limit).Scan(result)
return
}
func (l livestreamPrizesDo) Scan(result interface{}) (err error) {
return l.DO.Scan(result)
}
func (l livestreamPrizesDo) Delete(models ...*model.LivestreamPrizes) (result gen.ResultInfo, err error) {
return l.DO.Delete(models)
}
func (l *livestreamPrizesDo) withDO(do gen.Dao) *livestreamPrizesDo {
l.DO = *do.(*gen.DO)
return l
}

View File

@ -51,7 +51,7 @@ func newShippingRecords(db *gorm.DB, opts ...gen.DOOption) shippingRecords {
return _shippingRecords
}
// shippingRecords 发货记录(合并:单
// shippingRecords 发货记录
type shippingRecords struct {
shippingRecordsDo

View File

@ -32,6 +32,8 @@ func newSystemConfigs(db *gorm.DB, opts ...gen.DOOption) systemConfigs {
_systemConfigs.UpdatedAt = field.NewTime(tableName, "updated_at")
_systemConfigs.DeletedAt = field.NewField(tableName, "deleted_at")
_systemConfigs.ConfigKey = field.NewString(tableName, "config_key")
_systemConfigs.ConfigGroup = field.NewString(tableName, "config_group")
_systemConfigs.IsEncrypted = field.NewBool(tableName, "is_encrypted")
_systemConfigs.ConfigValue = field.NewString(tableName, "config_value")
_systemConfigs.Remark = field.NewString(tableName, "remark")
@ -50,6 +52,8 @@ type systemConfigs struct {
UpdatedAt field.Time
DeletedAt field.Field
ConfigKey field.String
ConfigGroup field.String
IsEncrypted field.Bool
ConfigValue field.String
Remark field.String
@ -73,6 +77,8 @@ func (s *systemConfigs) updateTableName(table string) *systemConfigs {
s.UpdatedAt = field.NewTime(table, "updated_at")
s.DeletedAt = field.NewField(table, "deleted_at")
s.ConfigKey = field.NewString(table, "config_key")
s.ConfigGroup = field.NewString(table, "config_group")
s.IsEncrypted = field.NewBool(table, "is_encrypted")
s.ConfigValue = field.NewString(table, "config_value")
s.Remark = field.NewString(table, "remark")
@ -91,12 +97,14 @@ func (s *systemConfigs) GetFieldByName(fieldName string) (field.OrderExpr, bool)
}
func (s *systemConfigs) fillFieldMap() {
s.fieldMap = make(map[string]field.Expr, 7)
s.fieldMap = make(map[string]field.Expr, 9)
s.fieldMap["id"] = s.ID
s.fieldMap["created_at"] = s.CreatedAt
s.fieldMap["updated_at"] = s.UpdatedAt
s.fieldMap["deleted_at"] = s.DeletedAt
s.fieldMap["config_key"] = s.ConfigKey
s.fieldMap["config_group"] = s.ConfigGroup
s.fieldMap["is_encrypted"] = s.IsEncrypted
s.fieldMap["config_value"] = s.ConfigValue
s.fieldMap["remark"] = s.Remark
}

View File

@ -41,6 +41,8 @@ func newUsers(db *gorm.DB, opts ...gen.DOOption) users {
_users.Status = field.NewInt32(tableName, "status")
_users.DouyinID = field.NewString(tableName, "douyin_id")
_users.ChannelID = field.NewInt64(tableName, "channel_id")
_users.DouyinUserID = field.NewString(tableName, "douyin_user_id")
_users.Remark = field.NewString(tableName, "remark")
_users.fillFieldMap()
@ -51,21 +53,23 @@ func newUsers(db *gorm.DB, opts ...gen.DOOption) users {
type users struct {
usersDo
ALL field.Asterisk
ID field.Int64 // 主键ID
CreatedAt field.Time // 创建时间
UpdatedAt field.Time // 更新时间
DeletedAt field.Field // 删除时间(软删)
Nickname field.String // 昵称
Avatar field.String // 头像URL
Mobile field.String // 手机号
Openid field.String // 微信openid
Unionid field.String // 微信unionid
InviteCode field.String // 用户唯一邀请码
InviterID field.Int64 // 邀请人用户ID
Status field.Int32 // 状态1正常 2禁用
DouyinID field.String
ChannelID field.Int64 // 渠道ID
ALL field.Asterisk
ID field.Int64 // 主键ID
CreatedAt field.Time // 创建时间
UpdatedAt field.Time // 更新时间
DeletedAt field.Field // 删除时间(软删)
Nickname field.String // 昵称
Avatar field.String // 头像URL
Mobile field.String // 手机号
Openid field.String // 微信openid
Unionid field.String // 微信unionid
InviteCode field.String // 用户唯一邀请码
InviterID field.Int64 // 邀请人用户ID
Status field.Int32 // 状态1正常 2禁用
DouyinID field.String
ChannelID field.Int64 // 渠道ID
DouyinUserID field.String
Remark field.String // 管理员备注
fieldMap map[string]field.Expr
}
@ -96,6 +100,8 @@ func (u *users) updateTableName(table string) *users {
u.Status = field.NewInt32(table, "status")
u.DouyinID = field.NewString(table, "douyin_id")
u.ChannelID = field.NewInt64(table, "channel_id")
u.DouyinUserID = field.NewString(table, "douyin_user_id")
u.Remark = field.NewString(table, "remark")
u.fillFieldMap()
@ -112,7 +118,7 @@ func (u *users) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
}
func (u *users) fillFieldMap() {
u.fieldMap = make(map[string]field.Expr, 14)
u.fieldMap = make(map[string]field.Expr, 16)
u.fieldMap["id"] = u.ID
u.fieldMap["created_at"] = u.CreatedAt
u.fieldMap["updated_at"] = u.UpdatedAt
@ -127,6 +133,8 @@ func (u *users) fillFieldMap() {
u.fieldMap["status"] = u.Status
u.fieldMap["douyin_id"] = u.DouyinID
u.fieldMap["channel_id"] = u.ChannelID
u.fieldMap["douyin_user_id"] = u.DouyinUserID
u.fieldMap["remark"] = u.Remark
}
func (u users) clone(db *gorm.DB) users {

View File

@ -0,0 +1,27 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package model
import (
"time"
)
const TableNameDouyinBlacklist = "douyin_blacklist"
// DouyinBlacklist 抖音用户黑名单表
type DouyinBlacklist struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
DouyinUserID string `gorm:"column:douyin_user_id;not null;comment:抖音用户ID" json:"douyin_user_id"` // 抖音用户ID
Reason string `gorm:"column:reason;comment:拉黑原因" json:"reason"` // 拉黑原因
OperatorID int64 `gorm:"column:operator_id;comment:操作人ID" json:"operator_id"` // 操作人ID
Status int32 `gorm:"column:status;not null;default:1;comment:状态: 1=生效, 0=已解除" json:"status"` // 状态: 1=生效, 0=已解除
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"` // 更新时间
}
// TableName DouyinBlacklist's table name
func (*DouyinBlacklist) TableName() string {
return TableNameDouyinBlacklist
}

View File

@ -14,16 +14,20 @@ const TableNameDouyinOrders = "douyin_orders"
type DouyinOrders struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
ShopOrderID string `gorm:"column:shop_order_id;not null;comment:抖店订单号" json:"shop_order_id"` // 抖店订单号
DouyinProductID string `gorm:"column:douyin_product_id;comment:关联商品ID" json:"douyin_product_id"` // 关联商品ID
OrderStatus int32 `gorm:"column:order_status;not null;comment:订单状态: 5=已完成" json:"order_status"` // 订单状态: 5=已完成
DouyinUserID string `gorm:"column:douyin_user_id;not null;comment:抖店用户ID" json:"douyin_user_id"` // 抖店用户ID
LocalUserID string `gorm:"column:local_user_id;default:0;comment:匹配到的本地用户ID" json:"local_user_id"` // 匹配到的本地用户ID
ActualReceiveAmount int64 `gorm:"column:actual_receive_amount;comment:实收金额(分)" json:"actual_receive_amount"` // 实收金额(分)
ActualPayAmount int64 `gorm:"column:actual_pay_amount;comment:实付金额(分)" json:"actual_pay_amount"` // 实付金额(分)
PayTypeDesc string `gorm:"column:pay_type_desc;comment:支付方式描述" json:"pay_type_desc"` // 支付方式描述
Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注
UserNickname string `gorm:"column:user_nickname;comment:抖音昵称" json:"user_nickname"` // 抖音昵称
RawData string `gorm:"column:raw_data;comment:原始响应数据" json:"raw_data"` // 原始响应数据
CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP(3)" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP(3)" json:"updated_at"`
RewardGranted int32 `gorm:"column:reward_granted;not null;comment:奖励已发放: 0=否, 1=是" json:"reward_granted"` // 奖励已发放: 0=否, 1=是
ProductCount int32 `gorm:"column:product_count;not null;default:1;comment:商品数量" json:"product_count"` // 商品数量
}
// TableName DouyinOrders's table name

View File

@ -0,0 +1,30 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package model
import (
"time"
)
const TableNameDouyinProductRewards = "douyin_product_rewards"
// DouyinProductRewards 抖店商品奖励规则
type DouyinProductRewards struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
ProductID string `gorm:"column:product_id;not null;comment:抖店商品ID" json:"product_id"` // 抖店商品ID
ProductName string `gorm:"column:product_name;not null;comment:商品名称" json:"product_name"` // 商品名称
ActivityID int64 `gorm:"column:activity_id;comment:关联直播活动ID" json:"activity_id"` // 关联直播活动ID (可选)
RewardType string `gorm:"column:reward_type;not null;comment:奖励类型" json:"reward_type"` // 奖励类型
RewardPayload string `gorm:"column:reward_payload;comment:奖励参数JSON" json:"reward_payload"` // 奖励参数JSON
Quantity int32 `gorm:"column:quantity;not null;default:1;comment:发放数量" json:"quantity"` // 发放数量
Status int32 `gorm:"column:status;not null;default:1;comment:状态: 1=启用 0=禁用" json:"status"` // 状态: 1=启用 0=禁用
CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP(3)" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP(3)" json:"updated_at"`
}
// TableName DouyinProductRewards's table name
func (*DouyinProductRewards) TableName() string {
return TableNameDouyinProductRewards
}

View File

@ -0,0 +1,41 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package model
import (
"time"
"gorm.io/gorm"
)
const TableNameLivestreamActivities = "livestream_activities"
// LivestreamActivities 直播间活动表
type LivestreamActivities struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
Name string `gorm:"column:name;not null;comment:活动名称" json:"name"` // 活动名称
StreamerName string `gorm:"column:streamer_name;comment:主播名称" json:"streamer_name"` // 主播名称
StreamerContact string `gorm:"column:streamer_contact;comment:主播联系方式" json:"streamer_contact"` // 主播联系方式
AccessCode string `gorm:"column:access_code;not null;comment:唯一访问码" json:"access_code"` // 唯一访问码
DouyinProductID string `gorm:"column:douyin_product_id;comment:关联抖店商品ID" json:"douyin_product_id"` // 关联抖店商品ID
OrderRewardType string `gorm:"column:order_reward_type;default:'';comment:下单奖励类型: flip_card/minesweeper" json:"order_reward_type"` // 下单奖励类型
OrderRewardQuantity int32 `gorm:"column:order_reward_quantity;default:1;comment:下单奖励数量: 1-100" json:"order_reward_quantity"` // 下单奖励数量
Status int32 `gorm:"column:status;not null;default:1;comment:状态:1进行中 2已结束" json:"status"` // 状态:1进行中 2已结束
CommitmentAlgo string `gorm:"column:commitment_algo;default:commit-v1;comment:承诺算法版本" json:"commitment_algo"` // 承诺算法版本
CommitmentSeedMaster []byte `gorm:"column:commitment_seed_master;comment:主种子(32字节)" json:"commitment_seed_master"` // 主种子(32字节)
CommitmentSeedHash []byte `gorm:"column:commitment_seed_hash;comment:种子SHA256哈希" json:"commitment_seed_hash"` // 种子SHA256哈希
CommitmentStateVersion int32 `gorm:"column:commitment_state_version;comment:状态版本" json:"commitment_state_version"` // 状态版本
StartTime time.Time `gorm:"column:start_time;comment:开始时间" json:"start_time"` // 开始时间
EndTime time.Time `gorm:"column:end_time;comment:结束时间" json:"end_time"` // 结束时间
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"` // 更新时间
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;comment:删除时间" json:"deleted_at"` // 删除时间
TicketPrice int32 `gorm:"column:ticket_price" json:"ticket_price"`
}
// TableName LivestreamActivities's table name
func (*LivestreamActivities) TableName() string {
return TableNameLivestreamActivities
}

View File

@ -0,0 +1,36 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package model
import (
"time"
)
const TableNameLivestreamDrawLogs = "livestream_draw_logs"
// LivestreamDrawLogs 直播间中奖记录表
type LivestreamDrawLogs struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
ActivityID int64 `gorm:"column:activity_id;not null;comment:关联livestream_activities.id" json:"activity_id"` // 关联livestream_activities.id
PrizeID int64 `gorm:"column:prize_id;not null;comment:关联livestream_prizes.id" json:"prize_id"` // 关联livestream_prizes.id
DouyinOrderID int64 `gorm:"column:douyin_order_id;comment:关联douyin_orders.id" json:"douyin_order_id"` // 关联douyin_orders.id
ShopOrderID string `gorm:"column:shop_order_id;comment:抖店订单号" json:"shop_order_id"` // 抖店订单号
LocalUserID int64 `gorm:"column:local_user_id;comment:本地用户ID" json:"local_user_id"` // 本地用户ID
DouyinUserID string `gorm:"column:douyin_user_id;comment:抖音用户ID" json:"douyin_user_id"` // 抖音用户ID
UserNickname string `gorm:"column:user_nickname;comment:用户昵称" json:"user_nickname"` // 用户昵称
PrizeName string `gorm:"column:prize_name;comment:中奖奖品名称快照" json:"prize_name"` // 中奖奖品名称快照
Level int32 `gorm:"column:level;default:1;comment:奖品等级" json:"level"` // 奖品等级
SeedHash string `gorm:"column:seed_hash;comment:哈希种子" json:"seed_hash"` // 哈希种子
RandValue int64 `gorm:"column:rand_value;comment:随机值" json:"rand_value"` // 随机值
WeightsTotal int64 `gorm:"column:weights_total;comment:权重总和" json:"weights_total"` // 权重总和
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP(3);comment:中奖时间" json:"created_at"` // 中奖时间
IsGranted bool `gorm:"column:is_granted;comment:是否已发放奖品" json:"is_granted"` // 是否已发放奖品
IsRefunded int32 `gorm:"column:is_refunded;comment:订单是否已退款" json:"is_refunded"` // 订单是否已退款
}
// TableName LivestreamDrawLogs's table name
func (*LivestreamDrawLogs) TableName() string {
return TableNameLivestreamDrawLogs
}

View File

@ -0,0 +1,33 @@
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
// Code generated by gorm.io/gen. DO NOT EDIT.
package model
import (
"time"
)
const TableNameLivestreamPrizes = "livestream_prizes"
// LivestreamPrizes 直播间奖品表
type LivestreamPrizes struct {
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
ActivityID int64 `gorm:"column:activity_id;not null;comment:关联livestream_activities.id" json:"activity_id"` // 关联livestream_activities.id
Name string `gorm:"column:name;not null;comment:奖品名称" json:"name"` // 奖品名称
Image string `gorm:"column:image;comment:奖品图片" json:"image"` // 奖品图片
Weight int32 `gorm:"column:weight;not null;default:1;comment:抽奖权重" json:"weight"` // 抽奖权重
Quantity int32 `gorm:"column:quantity;not null;default:-1;comment:库存数量(-1=无限)" json:"quantity"` // 库存数量(-1=无限)
Remaining int32 `gorm:"column:remaining;not null;default:-1;comment:剩余数量" json:"remaining"` // 剩余数量
Level int32 `gorm:"column:level;not null;default:1;comment:奖品等级" json:"level"` // 奖品等级
ProductID int64 `gorm:"column:product_id;comment:关联系统商品ID" json:"product_id"` // 关联系统商品ID
Sort int32 `gorm:"column:sort;not null;comment:排序" json:"sort"` // 排序
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"` // 更新时间
CostPrice int64 `gorm:"column:cost_price;comment:成本价(分)" json:"cost_price"` // 成本价(分)
}
// TableName LivestreamPrizes's table name
func (*LivestreamPrizes) TableName() string {
return TableNameLivestreamPrizes
}

Some files were not shown because too many files have changed in this diff Show More