diff --git a/README.md b/README.md
index 66fc8e2..ac5b6e0 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,7 @@ CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -ldflags "-s -w" -tags timetzda
export DOCKER_DEFAULT_PLATFORM=linux/amd64
-docker build -t zfc931912343/bindbox-game:v1.9 .
-docker push zfc931912343/bindbox-game:v1.9
+docker build -t zfc931912343/bindbox-game:v1.10 .
+docker push zfc931912343/bindbox-game:v1.10
-docker pull zfc931912343/bindbox-game:v1.9 &&docker rm -f bindbox-game && docker run -d --name bindbox-game -p 9991:9991 zfc931912343/bindbox-game:v1.9
+docker pull zfc931912343/bindbox-game:v1.10 &&docker rm -f bindbox-game && docker run -d --name bindbox-game -p 9991:9991 zfc931912343/bindbox-game:v1.10
diff --git a/build.zip b/build.zip
index 9a8b4f4..32f6cd4 100644
Binary files a/build.zip and b/build.zip differ
diff --git a/build/resources/admin/index.html b/build/resources/admin/index.html
index 6cc47bf..b1f1ac1 100644
--- a/build/resources/admin/index.html
+++ b/build/resources/admin/index.html
@@ -38,7 +38,7 @@
}
})()
-
+
diff --git a/cmd/gormgen/README.md b/cmd/gormgen/README.md
index ba71f20..9c7a2e0 100644
--- a/cmd/gormgen/README.md
+++ b/cmd/gormgen/README.md
@@ -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"
+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"
```
diff --git a/internal/api/game/handler.go b/internal/api/game/handler.go
new file mode 100644
index 0000000..4b8916c
--- /dev/null
+++ b/internal/api/game/handler.go
@@ -0,0 +1,380 @@
+package game
+
+import (
+ "bindbox-game/internal/code"
+ "bindbox-game/internal/pkg/core"
+ "bindbox-game/internal/pkg/logger"
+ "bindbox-game/internal/pkg/validation"
+ "bindbox-game/internal/repository/mysql"
+ "bindbox-game/internal/repository/mysql/dao"
+ "bindbox-game/internal/service/game"
+ usersvc "bindbox-game/internal/service/user"
+ "encoding/json"
+ "net/http"
+ "strconv"
+
+ "github.com/redis/go-redis/v9"
+ "go.uber.org/zap"
+)
+
+type handler struct {
+ logger logger.CustomLogger
+ db mysql.Repo
+ redis *redis.Client
+ ticketSvc game.TicketService
+ userSvc usersvc.Service
+ readDB *dao.Query
+}
+
+func New(l logger.CustomLogger, db mysql.Repo, rdb *redis.Client, userSvc usersvc.Service) *handler {
+ return &handler{
+ logger: l,
+ db: db,
+ redis: rdb,
+ ticketSvc: game.NewTicketService(l, db),
+ userSvc: userSvc,
+ readDB: dao.Use(db.GetDbR()),
+ }
+}
+
+// ========== Admin API ==========
+
+type grantTicketRequest struct {
+ GameCode string `json:"game_code" binding:"required"`
+ Amount int `json:"amount" binding:"required,min=1"`
+ Remark string `json:"remark"`
+}
+
+// GrantUserTicket Admin为用户发放游戏资格
+// @Summary 发放游戏资格
+// @Tags 管理端.游戏
+// @Param user_id path int true "用户ID"
+// @Param RequestBody body grantTicketRequest true "请求参数"
+// @Success 200 {object} map[string]any
+// @Router /api/admin/users/{user_id}/game_tickets [post]
+func (h *handler) GrantUserTicket() core.HandlerFunc {
+ return func(ctx core.Context) {
+ userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
+ if err != nil || userID <= 0 {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid user_id"))
+ return
+ }
+
+ req := new(grantTicketRequest)
+ if err := ctx.ShouldBindJSON(req); err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
+ return
+ }
+
+ err = h.ticketSvc.GrantTicket(ctx.RequestContext(), userID, req.GameCode, req.Amount, "admin", 0, req.Remark)
+ if err != nil {
+ ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
+ return
+ }
+
+ ctx.Payload(map[string]any{"success": true})
+ }
+}
+
+// ListUserTickets Admin查询用户游戏资格日志
+// @Summary 查询用户游戏资格日志
+// @Tags 管理端.游戏
+// @Param user_id path int true "用户ID"
+// @Param page query int false "页码"
+// @Param page_size query int false "每页数量"
+// @Success 200 {object} map[string]any
+// @Router /api/admin/users/{user_id}/game_tickets [get]
+func (h *handler) ListUserTickets() core.HandlerFunc {
+ return func(ctx core.Context) {
+ userID, err := strconv.ParseInt(ctx.Param("user_id"), 10, 64)
+ if err != nil || userID <= 0 {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid user_id"))
+ return
+ }
+
+ var req struct {
+ Page int `form:"page"`
+ PageSize int `form:"page_size"`
+ }
+ _ = ctx.ShouldBindQuery(&req)
+
+ if req.Page <= 0 {
+ req.Page = 1
+ }
+ if req.PageSize <= 0 {
+ req.PageSize = 20
+ }
+
+ logs, total, err := h.ticketSvc.GetTicketLogs(ctx.RequestContext(), userID, req.Page, req.PageSize)
+ if err != nil {
+ ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
+ return
+ }
+
+ ctx.Payload(map[string]any{
+ "list": logs,
+ "total": total,
+ "page": req.Page,
+ "page_size": req.PageSize,
+ })
+ }
+}
+
+// ========== App API ==========
+
+// GetMyTickets App获取我的游戏资格
+// @Summary 获取我的游戏资格
+// @Tags APP端.游戏
+// @Param user_id path int true "用户ID"
+// @Success 200 {object} map[string]int
+// @Router /api/app/users/{user_id}/game_tickets [get]
+func (h *handler) GetMyTickets() core.HandlerFunc {
+ return func(ctx core.Context) {
+ userID := int64(ctx.SessionUserInfo().Id)
+
+ tickets, err := h.ticketSvc.GetUserTickets(ctx.RequestContext(), userID)
+ if err != nil {
+ ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, err.Error()))
+ return
+ }
+
+ ctx.Payload(tickets)
+ }
+}
+
+type enterGameRequest struct {
+ GameCode string `json:"game_code" binding:"required"`
+}
+
+type enterGameResponse struct {
+ TicketToken string `json:"ticket_token"`
+ NakamaServer string `json:"nakama_server"`
+ NakamaKey string `json:"nakama_key"`
+ RemainingTimes int `json:"remaining_times"`
+}
+
+// EnterGame App进入游戏(消耗资格)
+// @Summary 进入游戏
+// @Tags APP端.游戏
+// @Param RequestBody body enterGameRequest true "请求参数"
+// @Success 200 {object} enterGameResponse
+// @Router /api/app/games/enter [post]
+func (h *handler) EnterGame() core.HandlerFunc {
+ return func(ctx core.Context) {
+ userID := int64(ctx.SessionUserInfo().Id)
+
+ req := new(enterGameRequest)
+ if err := ctx.ShouldBindJSON(req); err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
+ return
+ }
+
+ // 扣减资格
+ if err := h.ticketSvc.UseTicket(ctx.RequestContext(), userID, req.GameCode); err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, 180001, "游戏次数不足"))
+ return
+ }
+
+ // 生成临时token并存入Redis
+ ticketToken := generateTicketToken(userID)
+ h.redis.Set(ctx.RequestContext(), "game:ticket:"+ticketToken, userID, 30*60*1000000000) // 30分钟
+
+ // 查询剩余次数
+ ticket, _ := h.ticketSvc.GetUserTicketByGame(ctx.RequestContext(), userID, req.GameCode)
+ remaining := 0
+ if ticket != nil {
+ remaining = int(ticket.Available)
+ }
+
+ // TODO: 从配置读取Nakama服务器信息
+ ctx.Payload(&enterGameResponse{
+ TicketToken: ticketToken,
+ NakamaServer: "ws://localhost:7350",
+ NakamaKey: "defaultkey",
+ RemainingTimes: remaining,
+ })
+ }
+}
+
+// ========== Internal API (Nakama调用) ==========
+
+type verifyRequest struct {
+ UserID string `json:"user_id"`
+ Ticket string `json:"ticket"`
+}
+
+type verifyResponse struct {
+ Valid bool `json:"valid"`
+ UserID string `json:"user_id"`
+ GameConfig map[string]any `json:"game_config,omitempty"`
+}
+
+// VerifyTicket Internal验证游戏票据
+// @Summary 验证票据
+// @Tags Internal.游戏
+// @Param RequestBody body verifyRequest true "请求参数"
+// @Success 200 {object} verifyResponse
+// @Router /internal/game/verify [post]
+func (h *handler) VerifyTicket() core.HandlerFunc {
+ return func(ctx core.Context) {
+ req := new(verifyRequest)
+ if err := ctx.ShouldBindJSON(req); err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
+ return
+ }
+
+ // 从Redis验证token
+ storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:ticket:"+req.Ticket).Result()
+ if err != nil || storedUserID != req.UserID {
+ ctx.Payload(&verifyResponse{Valid: false})
+ return
+ }
+
+ // 获取游戏配置
+ gameConfig := make(map[string]any)
+ configKey := "game_minesweeper_config"
+ conf, err := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq(configKey)).First()
+ if err == nil && conf != nil {
+ json.Unmarshal([]byte(conf.ConfigValue), &gameConfig)
+ }
+
+ ctx.Payload(&verifyResponse{Valid: true, UserID: req.UserID, GameConfig: gameConfig})
+ }
+}
+
+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"`
+}
+
+type settleResponse struct {
+ Success bool `json:"success"`
+ Reward string `json:"reward,omitempty"`
+}
+
+// SettleGame Internal游戏结算
+// @Summary 游戏结算
+// @Tags Internal.游戏
+// @Param RequestBody body settleRequest true "请求参数"
+// @Success 200 {object} settleResponse
+// @Router /internal/game/settle [post]
+func (h *handler) SettleGame() core.HandlerFunc {
+ return func(ctx core.Context) {
+ req := new(settleRequest)
+ if err := ctx.ShouldBindJSON(req); err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
+ return
+ }
+
+ // 验证token
+ storedUserID, err := h.redis.Get(ctx.RequestContext(), "game:ticket:"+req.Ticket).Result()
+ if err != nil || storedUserID != req.UserID {
+ ctx.Payload(&settleResponse{Success: false})
+ return
+ }
+
+ // 删除token防止重复使用
+ h.redis.Del(ctx.RequestContext(), "game:ticket:"+req.Ticket)
+
+ // 奖品发放逻辑
+ var rewardMsg string
+ var msConfig struct {
+ WinnerRewardPoints int64 `json:"winner_reward_points"`
+ ParticipationRewardPoints int64 `json:"participation_reward_points"`
+ WinnerRewardProductID int64 `json:"winner_reward_product_id"`
+ ParticipationRewardProductID int64 `json:"participation_reward_product_id"`
+ }
+
+ // 1. 读取配置
+ configKey := "game_minesweeper_config"
+ conf, err := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq(configKey)).First()
+ if err == nil && conf != nil {
+ json.Unmarshal([]byte(conf.ConfigValue), &msConfig)
+ }
+
+ uid, _ := strconv.ParseInt(req.UserID, 10, 64)
+
+ // 2. 确定奖励内容
+ var targetProductID int64
+ var targetPoints int64
+
+ if req.Win {
+ targetProductID = msConfig.WinnerRewardProductID
+ targetPoints = msConfig.WinnerRewardPoints
+ if targetPoints == 0 && targetProductID == 0 {
+ targetPoints = 100 // 兜底
+ }
+ } else {
+ targetProductID = msConfig.ParticipationRewardProductID
+ targetPoints = msConfig.ParticipationRewardPoints
+ if targetPoints == 0 && targetProductID == 0 {
+ targetPoints = 10 // 兜底
+ }
+ }
+
+ // 3. 发放奖励
+ if targetProductID > 0 {
+ res, err := h.userSvc.GrantReward(ctx.RequestContext(), uid, usersvc.GrantRewardRequest{
+ ProductID: targetProductID,
+ Quantity: 1,
+ Remark: "扫雷游戏奖励",
+ })
+ if err != nil || !res.Success {
+ h.logger.Error("Failed to grant game product reward", zap.Error(err), zap.String("msg", res.Message))
+ rewardMsg = "奖励发放失败"
+ } else {
+ rewardMsg = "获得奖品"
+ }
+ } else if targetPoints > 0 {
+ err := h.userSvc.AddPointsWithAction(ctx.RequestContext(), uid, targetPoints, "game_reward", "扫雷游戏奖励", "minesweeper_settle", nil, nil)
+ if err != nil {
+ h.logger.Error("Failed to grant game points", zap.Error(err))
+ }
+ rewardMsg = strconv.FormatInt(targetPoints, 10) + "积分"
+ }
+
+ ctx.Payload(&settleResponse{Success: true, Reward: rewardMsg})
+ }
+}
+
+// GetMinesweeperConfig Internal获取扫雷配置
+// @Summary 获取扫雷配置
+// @Tags Internal.游戏
+// @Success 200 {object} map[string]interface{}
+// @Router /internal/game/minesweeper/config [get]
+func (h *handler) GetMinesweeperConfig() core.HandlerFunc {
+ return func(ctx core.Context) {
+ configKey := "game_minesweeper_config"
+ conf, err := h.readDB.SystemConfigs.WithContext(ctx.RequestContext()).Where(h.readDB.SystemConfigs.ConfigKey.Eq(configKey)).First()
+ if err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, "获取配置失败"))
+ return
+ }
+
+ var gameConfig map[string]interface{}
+ if err := json.Unmarshal([]byte(conf.ConfigValue), &gameConfig); err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, "解析配置失败"))
+ return
+ }
+
+ ctx.Payload(gameConfig)
+ }
+}
+
+// ========== Helpers ==========
+
+func generateTicketToken(userID int64) string {
+ return "GT" + randomString(16)
+}
+
+func randomString(n int) string {
+ const letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
+ b := make([]byte, n)
+ for i := range b {
+ b[i] = letters[i%len(letters)]
+ }
+ return string(b)
+}
diff --git a/internal/api/user/cancel_shipping_app.go b/internal/api/user/cancel_shipping_app.go
index f925699..6552b71 100644
--- a/internal/api/user/cancel_shipping_app.go
+++ b/internal/api/user/cancel_shipping_app.go
@@ -8,36 +8,44 @@ import (
)
type cancelShippingRequest struct {
- InventoryID int64 `json:"inventory_id"`
+ InventoryID int64 `json:"inventory_id"` // 单个资产ID(与batch_no二选一)
+ BatchNo string `json:"batch_no"` // 批次号(与inventory_id二选一,取消整批)
}
-type cancelShippingResponse struct{}
+type cancelShippingResponse struct {
+ CancelledCount int64 `json:"cancelled_count"` // 成功取消的数量
+}
// CancelShipping 取消发货申请
// @Summary 取消发货申请
-// @Description 取消已提交但未发货的申请;恢复库存状态
+// @Description 取消已提交但未发货的申请;恢复库存状态。支持按单个资产ID取消或按批次号批量取消
// @Tags APP端.用户
// @Accept json
// @Produce json
// @Security LoginVerifyToken
// @Param user_id path integer true "用户ID"
-// @Param RequestBody body cancelShippingRequest true "请求参数:资产ID"
+// @Param RequestBody body cancelShippingRequest true "请求参数:资产ID或批次号(二选一)"
// @Success 200 {object} cancelShippingResponse "成功"
// @Failure 400 {object} code.Failure "参数错误/记录不存在/已处理"
// @Router /api/app/users/{user_id}/inventory/cancel-shipping [post]
func (h *handler) CancelShipping() core.HandlerFunc {
return func(ctx core.Context) {
req := new(cancelShippingRequest)
- if err := ctx.ShouldBindJSON(req); err != nil || req.InventoryID == 0 {
+ if err := ctx.ShouldBindJSON(req); err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "参数错误"))
return
}
+ // 必须提供 inventory_id 或 batch_no 其中之一
+ if req.InventoryID == 0 && req.BatchNo == "" {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "请提供inventory_id或batch_no"))
+ return
+ }
userID := int64(ctx.SessionUserInfo().Id)
- err := h.user.CancelShipping(ctx.RequestContext(), userID, req.InventoryID)
+ count, err := h.user.CancelShipping(ctx.RequestContext(), userID, req.InventoryID, req.BatchNo)
if err != nil {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10024, err.Error()))
return
}
- ctx.Payload(nil)
+ ctx.Payload(&cancelShippingResponse{CancelledCount: count})
}
}
diff --git a/internal/api/user/request_shipping_batch_app.go b/internal/api/user/request_shipping_batch_app.go
index 0f065b6..9267d12 100644
--- a/internal/api/user/request_shipping_batch_app.go
+++ b/internal/api/user/request_shipping_batch_app.go
@@ -1,22 +1,23 @@
package app
import (
- "bindbox-game/internal/code"
- "bindbox-game/internal/pkg/core"
- "bindbox-game/internal/pkg/validation"
- "net/http"
+ "bindbox-game/internal/code"
+ "bindbox-game/internal/pkg/core"
+ "bindbox-game/internal/pkg/validation"
+ "net/http"
)
type requestShippingBatchRequest struct {
- InventoryIDs []int64 `json:"inventory_ids"`
- AddressID *int64 `json:"address_id"`
+ InventoryIDs []int64 `json:"inventory_ids"`
+ AddressID *int64 `json:"address_id"`
}
type requestShippingBatchResponse struct {
- AddressID int64 `json:"address_id"`
- SuccessIDs []int64 `json:"success_ids"`
- Skipped []map[string]any `json:"skipped"`
- Failed []map[string]any `json:"failed"`
+ AddressID int64 `json:"address_id"`
+ BatchNo string `json:"batch_no"`
+ SuccessIDs []int64 `json:"success_ids"`
+ Skipped []map[string]any `json:"skipped"`
+ Failed []map[string]any `json:"failed"`
}
// RequestShippingBatch 批量申请发货(使用默认地址或指定地址)
@@ -32,32 +33,32 @@ type requestShippingBatchResponse struct {
// @Failure 400 {object} code.Failure
// @Router /api/app/users/{user_id}/inventory/request-shipping-batch [post]
func (h *handler) RequestShippingBatch() core.HandlerFunc {
- return func(ctx core.Context) {
- req := new(requestShippingBatchRequest)
- rsp := new(requestShippingBatchResponse)
- if err := ctx.ShouldBindJSON(req); err != nil {
- ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
- return
- }
- if len(req.InventoryIDs) == 0 || len(req.InventoryIDs) > 100 {
- ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid inventory_ids"))
- return
- }
- userID := int64(ctx.SessionUserInfo().Id)
- addrID, success, skipped, failed, err := h.user.RequestShippings(ctx.RequestContext(), userID, req.InventoryIDs, req.AddressID)
- if err != nil {
- ctx.AbortWithError(core.Error(http.StatusBadRequest, 10023, err.Error()))
- return
- }
- rsp.AddressID = addrID
- rsp.SuccessIDs = success
- for _, s := range skipped {
- rsp.Skipped = append(rsp.Skipped, map[string]any{"id": s.ID, "reason": s.Reason})
- }
- for _, f := range failed {
- rsp.Failed = append(rsp.Failed, map[string]any{"id": f.ID, "reason": f.Reason})
- }
- ctx.Payload(rsp)
- }
+ return func(ctx core.Context) {
+ req := new(requestShippingBatchRequest)
+ rsp := new(requestShippingBatchResponse)
+ if err := ctx.ShouldBindJSON(req); err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
+ return
+ }
+ if len(req.InventoryIDs) == 0 || len(req.InventoryIDs) > 100 {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "invalid inventory_ids"))
+ return
+ }
+ userID := int64(ctx.SessionUserInfo().Id)
+ addrID, batchNo, success, skipped, failed, err := h.user.RequestShippings(ctx.RequestContext(), userID, req.InventoryIDs, req.AddressID)
+ if err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, 10023, err.Error()))
+ return
+ }
+ rsp.AddressID = addrID
+ rsp.BatchNo = batchNo
+ rsp.SuccessIDs = success
+ for _, s := range skipped {
+ rsp.Skipped = append(rsp.Skipped, map[string]any{"id": s.ID, "reason": s.Reason})
+ }
+ for _, f := range failed {
+ rsp.Failed = append(rsp.Failed, map[string]any{"id": f.ID, "reason": f.Reason})
+ }
+ ctx.Payload(rsp)
+ }
}
-
diff --git a/internal/repository/mysql/dao/game_ticket_logs.gen.go b/internal/repository/mysql/dao/game_ticket_logs.gen.go
new file mode 100644
index 0000000..d889d68
--- /dev/null
+++ b/internal/repository/mysql/dao/game_ticket_logs.gen.go
@@ -0,0 +1,356 @@
+// 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 newGameTicketLogs(db *gorm.DB, opts ...gen.DOOption) gameTicketLogs {
+ _gameTicketLogs := gameTicketLogs{}
+
+ _gameTicketLogs.gameTicketLogsDo.UseDB(db, opts...)
+ _gameTicketLogs.gameTicketLogsDo.UseModel(&model.GameTicketLogs{})
+
+ tableName := _gameTicketLogs.gameTicketLogsDo.TableName()
+ _gameTicketLogs.ALL = field.NewAsterisk(tableName)
+ _gameTicketLogs.ID = field.NewInt64(tableName, "id")
+ _gameTicketLogs.CreatedAt = field.NewTime(tableName, "created_at")
+ _gameTicketLogs.UserID = field.NewInt64(tableName, "user_id")
+ _gameTicketLogs.GameCode = field.NewString(tableName, "game_code")
+ _gameTicketLogs.ChangeType = field.NewInt32(tableName, "change_type")
+ _gameTicketLogs.Amount = field.NewInt32(tableName, "amount")
+ _gameTicketLogs.Balance = field.NewInt32(tableName, "balance")
+ _gameTicketLogs.Source = field.NewString(tableName, "source")
+ _gameTicketLogs.SourceID = field.NewInt64(tableName, "source_id")
+ _gameTicketLogs.Remark = field.NewString(tableName, "remark")
+
+ _gameTicketLogs.fillFieldMap()
+
+ return _gameTicketLogs
+}
+
+// gameTicketLogs 游戏资格变动日志
+type gameTicketLogs struct {
+ gameTicketLogsDo
+
+ ALL field.Asterisk
+ ID field.Int64
+ CreatedAt field.Time
+ UserID field.Int64 // 用户ID
+ GameCode field.String // 游戏代码
+ ChangeType field.Int32 // 1=获得 2=使用
+ Amount field.Int32 // 变动数量
+ Balance field.Int32 // 变动后余额
+ Source field.String // 来源: order/task/admin
+ SourceID field.Int64 // 来源ID
+ Remark field.String // 备注
+
+ fieldMap map[string]field.Expr
+}
+
+func (g gameTicketLogs) Table(newTableName string) *gameTicketLogs {
+ g.gameTicketLogsDo.UseTable(newTableName)
+ return g.updateTableName(newTableName)
+}
+
+func (g gameTicketLogs) As(alias string) *gameTicketLogs {
+ g.gameTicketLogsDo.DO = *(g.gameTicketLogsDo.As(alias).(*gen.DO))
+ return g.updateTableName(alias)
+}
+
+func (g *gameTicketLogs) updateTableName(table string) *gameTicketLogs {
+ g.ALL = field.NewAsterisk(table)
+ g.ID = field.NewInt64(table, "id")
+ g.CreatedAt = field.NewTime(table, "created_at")
+ g.UserID = field.NewInt64(table, "user_id")
+ g.GameCode = field.NewString(table, "game_code")
+ g.ChangeType = field.NewInt32(table, "change_type")
+ g.Amount = field.NewInt32(table, "amount")
+ g.Balance = field.NewInt32(table, "balance")
+ g.Source = field.NewString(table, "source")
+ g.SourceID = field.NewInt64(table, "source_id")
+ g.Remark = field.NewString(table, "remark")
+
+ g.fillFieldMap()
+
+ return g
+}
+
+func (g *gameTicketLogs) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
+ _f, ok := g.fieldMap[fieldName]
+ if !ok || _f == nil {
+ return nil, false
+ }
+ _oe, ok := _f.(field.OrderExpr)
+ return _oe, ok
+}
+
+func (g *gameTicketLogs) fillFieldMap() {
+ g.fieldMap = make(map[string]field.Expr, 10)
+ g.fieldMap["id"] = g.ID
+ g.fieldMap["created_at"] = g.CreatedAt
+ g.fieldMap["user_id"] = g.UserID
+ g.fieldMap["game_code"] = g.GameCode
+ g.fieldMap["change_type"] = g.ChangeType
+ g.fieldMap["amount"] = g.Amount
+ g.fieldMap["balance"] = g.Balance
+ g.fieldMap["source"] = g.Source
+ g.fieldMap["source_id"] = g.SourceID
+ g.fieldMap["remark"] = g.Remark
+}
+
+func (g gameTicketLogs) clone(db *gorm.DB) gameTicketLogs {
+ g.gameTicketLogsDo.ReplaceConnPool(db.Statement.ConnPool)
+ return g
+}
+
+func (g gameTicketLogs) replaceDB(db *gorm.DB) gameTicketLogs {
+ g.gameTicketLogsDo.ReplaceDB(db)
+ return g
+}
+
+type gameTicketLogsDo struct{ gen.DO }
+
+func (g gameTicketLogsDo) Debug() *gameTicketLogsDo {
+ return g.withDO(g.DO.Debug())
+}
+
+func (g gameTicketLogsDo) WithContext(ctx context.Context) *gameTicketLogsDo {
+ return g.withDO(g.DO.WithContext(ctx))
+}
+
+func (g gameTicketLogsDo) ReadDB() *gameTicketLogsDo {
+ return g.Clauses(dbresolver.Read)
+}
+
+func (g gameTicketLogsDo) WriteDB() *gameTicketLogsDo {
+ return g.Clauses(dbresolver.Write)
+}
+
+func (g gameTicketLogsDo) Session(config *gorm.Session) *gameTicketLogsDo {
+ return g.withDO(g.DO.Session(config))
+}
+
+func (g gameTicketLogsDo) Clauses(conds ...clause.Expression) *gameTicketLogsDo {
+ return g.withDO(g.DO.Clauses(conds...))
+}
+
+func (g gameTicketLogsDo) Returning(value interface{}, columns ...string) *gameTicketLogsDo {
+ return g.withDO(g.DO.Returning(value, columns...))
+}
+
+func (g gameTicketLogsDo) Not(conds ...gen.Condition) *gameTicketLogsDo {
+ return g.withDO(g.DO.Not(conds...))
+}
+
+func (g gameTicketLogsDo) Or(conds ...gen.Condition) *gameTicketLogsDo {
+ return g.withDO(g.DO.Or(conds...))
+}
+
+func (g gameTicketLogsDo) Select(conds ...field.Expr) *gameTicketLogsDo {
+ return g.withDO(g.DO.Select(conds...))
+}
+
+func (g gameTicketLogsDo) Where(conds ...gen.Condition) *gameTicketLogsDo {
+ return g.withDO(g.DO.Where(conds...))
+}
+
+func (g gameTicketLogsDo) Order(conds ...field.Expr) *gameTicketLogsDo {
+ return g.withDO(g.DO.Order(conds...))
+}
+
+func (g gameTicketLogsDo) Distinct(cols ...field.Expr) *gameTicketLogsDo {
+ return g.withDO(g.DO.Distinct(cols...))
+}
+
+func (g gameTicketLogsDo) Omit(cols ...field.Expr) *gameTicketLogsDo {
+ return g.withDO(g.DO.Omit(cols...))
+}
+
+func (g gameTicketLogsDo) Join(table schema.Tabler, on ...field.Expr) *gameTicketLogsDo {
+ return g.withDO(g.DO.Join(table, on...))
+}
+
+func (g gameTicketLogsDo) LeftJoin(table schema.Tabler, on ...field.Expr) *gameTicketLogsDo {
+ return g.withDO(g.DO.LeftJoin(table, on...))
+}
+
+func (g gameTicketLogsDo) RightJoin(table schema.Tabler, on ...field.Expr) *gameTicketLogsDo {
+ return g.withDO(g.DO.RightJoin(table, on...))
+}
+
+func (g gameTicketLogsDo) Group(cols ...field.Expr) *gameTicketLogsDo {
+ return g.withDO(g.DO.Group(cols...))
+}
+
+func (g gameTicketLogsDo) Having(conds ...gen.Condition) *gameTicketLogsDo {
+ return g.withDO(g.DO.Having(conds...))
+}
+
+func (g gameTicketLogsDo) Limit(limit int) *gameTicketLogsDo {
+ return g.withDO(g.DO.Limit(limit))
+}
+
+func (g gameTicketLogsDo) Offset(offset int) *gameTicketLogsDo {
+ return g.withDO(g.DO.Offset(offset))
+}
+
+func (g gameTicketLogsDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *gameTicketLogsDo {
+ return g.withDO(g.DO.Scopes(funcs...))
+}
+
+func (g gameTicketLogsDo) Unscoped() *gameTicketLogsDo {
+ return g.withDO(g.DO.Unscoped())
+}
+
+func (g gameTicketLogsDo) Create(values ...*model.GameTicketLogs) error {
+ if len(values) == 0 {
+ return nil
+ }
+ return g.DO.Create(values)
+}
+
+func (g gameTicketLogsDo) CreateInBatches(values []*model.GameTicketLogs, batchSize int) error {
+ return g.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 (g gameTicketLogsDo) Save(values ...*model.GameTicketLogs) error {
+ if len(values) == 0 {
+ return nil
+ }
+ return g.DO.Save(values)
+}
+
+func (g gameTicketLogsDo) First() (*model.GameTicketLogs, error) {
+ if result, err := g.DO.First(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.GameTicketLogs), nil
+ }
+}
+
+func (g gameTicketLogsDo) Take() (*model.GameTicketLogs, error) {
+ if result, err := g.DO.Take(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.GameTicketLogs), nil
+ }
+}
+
+func (g gameTicketLogsDo) Last() (*model.GameTicketLogs, error) {
+ if result, err := g.DO.Last(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.GameTicketLogs), nil
+ }
+}
+
+func (g gameTicketLogsDo) Find() ([]*model.GameTicketLogs, error) {
+ result, err := g.DO.Find()
+ return result.([]*model.GameTicketLogs), err
+}
+
+func (g gameTicketLogsDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.GameTicketLogs, err error) {
+ buf := make([]*model.GameTicketLogs, 0, batchSize)
+ err = g.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 (g gameTicketLogsDo) FindInBatches(result *[]*model.GameTicketLogs, batchSize int, fc func(tx gen.Dao, batch int) error) error {
+ return g.DO.FindInBatches(result, batchSize, fc)
+}
+
+func (g gameTicketLogsDo) Attrs(attrs ...field.AssignExpr) *gameTicketLogsDo {
+ return g.withDO(g.DO.Attrs(attrs...))
+}
+
+func (g gameTicketLogsDo) Assign(attrs ...field.AssignExpr) *gameTicketLogsDo {
+ return g.withDO(g.DO.Assign(attrs...))
+}
+
+func (g gameTicketLogsDo) Joins(fields ...field.RelationField) *gameTicketLogsDo {
+ for _, _f := range fields {
+ g = *g.withDO(g.DO.Joins(_f))
+ }
+ return &g
+}
+
+func (g gameTicketLogsDo) Preload(fields ...field.RelationField) *gameTicketLogsDo {
+ for _, _f := range fields {
+ g = *g.withDO(g.DO.Preload(_f))
+ }
+ return &g
+}
+
+func (g gameTicketLogsDo) FirstOrInit() (*model.GameTicketLogs, error) {
+ if result, err := g.DO.FirstOrInit(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.GameTicketLogs), nil
+ }
+}
+
+func (g gameTicketLogsDo) FirstOrCreate() (*model.GameTicketLogs, error) {
+ if result, err := g.DO.FirstOrCreate(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.GameTicketLogs), nil
+ }
+}
+
+func (g gameTicketLogsDo) FindByPage(offset int, limit int) (result []*model.GameTicketLogs, count int64, err error) {
+ result, err = g.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 = g.Offset(-1).Limit(-1).Count()
+ return
+}
+
+func (g gameTicketLogsDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
+ count, err = g.Count()
+ if err != nil {
+ return
+ }
+
+ err = g.Offset(offset).Limit(limit).Scan(result)
+ return
+}
+
+func (g gameTicketLogsDo) Scan(result interface{}) (err error) {
+ return g.DO.Scan(result)
+}
+
+func (g gameTicketLogsDo) Delete(models ...*model.GameTicketLogs) (result gen.ResultInfo, err error) {
+ return g.DO.Delete(models)
+}
+
+func (g *gameTicketLogsDo) withDO(do gen.Dao) *gameTicketLogsDo {
+ g.DO = *do.(*gen.DO)
+ return g
+}
diff --git a/internal/repository/mysql/dao/gen.go b/internal/repository/mysql/dao/gen.go
index 574d7ec..7a6c2ed 100644
--- a/internal/repository/mysql/dao/gen.go
+++ b/internal/repository/mysql/dao/gen.go
@@ -27,6 +27,7 @@ var (
Admin *admin
Banner *banner
Channels *channels
+ GameTicketLogs *gameTicketLogs
IssuePositionClaims *issuePositionClaims
LogOperation *logOperation
LogRequest *logRequest
@@ -60,6 +61,7 @@ var (
TaskCenterTasks *taskCenterTasks
UserAddresses *userAddresses
UserCoupons *userCoupons
+ UserGameTickets *userGameTickets
UserInventory *userInventory
UserInventoryTransfers *userInventoryTransfers
UserInvites *userInvites
@@ -83,6 +85,7 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
Admin = &Q.Admin
Banner = &Q.Banner
Channels = &Q.Channels
+ GameTicketLogs = &Q.GameTicketLogs
IssuePositionClaims = &Q.IssuePositionClaims
LogOperation = &Q.LogOperation
LogRequest = &Q.LogRequest
@@ -116,6 +119,7 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
TaskCenterTasks = &Q.TaskCenterTasks
UserAddresses = &Q.UserAddresses
UserCoupons = &Q.UserCoupons
+ UserGameTickets = &Q.UserGameTickets
UserInventory = &Q.UserInventory
UserInventoryTransfers = &Q.UserInventoryTransfers
UserInvites = &Q.UserInvites
@@ -140,6 +144,7 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
Admin: newAdmin(db, opts...),
Banner: newBanner(db, opts...),
Channels: newChannels(db, opts...),
+ GameTicketLogs: newGameTicketLogs(db, opts...),
IssuePositionClaims: newIssuePositionClaims(db, opts...),
LogOperation: newLogOperation(db, opts...),
LogRequest: newLogRequest(db, opts...),
@@ -173,6 +178,7 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
TaskCenterTasks: newTaskCenterTasks(db, opts...),
UserAddresses: newUserAddresses(db, opts...),
UserCoupons: newUserCoupons(db, opts...),
+ UserGameTickets: newUserGameTickets(db, opts...),
UserInventory: newUserInventory(db, opts...),
UserInventoryTransfers: newUserInventoryTransfers(db, opts...),
UserInvites: newUserInvites(db, opts...),
@@ -198,6 +204,7 @@ type Query struct {
Admin admin
Banner banner
Channels channels
+ GameTicketLogs gameTicketLogs
IssuePositionClaims issuePositionClaims
LogOperation logOperation
LogRequest logRequest
@@ -231,6 +238,7 @@ type Query struct {
TaskCenterTasks taskCenterTasks
UserAddresses userAddresses
UserCoupons userCoupons
+ UserGameTickets userGameTickets
UserInventory userInventory
UserInventoryTransfers userInventoryTransfers
UserInvites userInvites
@@ -257,6 +265,7 @@ func (q *Query) clone(db *gorm.DB) *Query {
Admin: q.Admin.clone(db),
Banner: q.Banner.clone(db),
Channels: q.Channels.clone(db),
+ GameTicketLogs: q.GameTicketLogs.clone(db),
IssuePositionClaims: q.IssuePositionClaims.clone(db),
LogOperation: q.LogOperation.clone(db),
LogRequest: q.LogRequest.clone(db),
@@ -290,6 +299,7 @@ func (q *Query) clone(db *gorm.DB) *Query {
TaskCenterTasks: q.TaskCenterTasks.clone(db),
UserAddresses: q.UserAddresses.clone(db),
UserCoupons: q.UserCoupons.clone(db),
+ UserGameTickets: q.UserGameTickets.clone(db),
UserInventory: q.UserInventory.clone(db),
UserInventoryTransfers: q.UserInventoryTransfers.clone(db),
UserInvites: q.UserInvites.clone(db),
@@ -323,6 +333,7 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
Admin: q.Admin.replaceDB(db),
Banner: q.Banner.replaceDB(db),
Channels: q.Channels.replaceDB(db),
+ GameTicketLogs: q.GameTicketLogs.replaceDB(db),
IssuePositionClaims: q.IssuePositionClaims.replaceDB(db),
LogOperation: q.LogOperation.replaceDB(db),
LogRequest: q.LogRequest.replaceDB(db),
@@ -356,6 +367,7 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
TaskCenterTasks: q.TaskCenterTasks.replaceDB(db),
UserAddresses: q.UserAddresses.replaceDB(db),
UserCoupons: q.UserCoupons.replaceDB(db),
+ UserGameTickets: q.UserGameTickets.replaceDB(db),
UserInventory: q.UserInventory.replaceDB(db),
UserInventoryTransfers: q.UserInventoryTransfers.replaceDB(db),
UserInvites: q.UserInvites.replaceDB(db),
@@ -379,6 +391,7 @@ type queryCtx struct {
Admin *adminDo
Banner *bannerDo
Channels *channelsDo
+ GameTicketLogs *gameTicketLogsDo
IssuePositionClaims *issuePositionClaimsDo
LogOperation *logOperationDo
LogRequest *logRequestDo
@@ -412,6 +425,7 @@ type queryCtx struct {
TaskCenterTasks *taskCenterTasksDo
UserAddresses *userAddressesDo
UserCoupons *userCouponsDo
+ UserGameTickets *userGameTicketsDo
UserInventory *userInventoryDo
UserInventoryTransfers *userInventoryTransfersDo
UserInvites *userInvitesDo
@@ -435,6 +449,7 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
Admin: q.Admin.WithContext(ctx),
Banner: q.Banner.WithContext(ctx),
Channels: q.Channels.WithContext(ctx),
+ GameTicketLogs: q.GameTicketLogs.WithContext(ctx),
IssuePositionClaims: q.IssuePositionClaims.WithContext(ctx),
LogOperation: q.LogOperation.WithContext(ctx),
LogRequest: q.LogRequest.WithContext(ctx),
@@ -468,6 +483,7 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
TaskCenterTasks: q.TaskCenterTasks.WithContext(ctx),
UserAddresses: q.UserAddresses.WithContext(ctx),
UserCoupons: q.UserCoupons.WithContext(ctx),
+ UserGameTickets: q.UserGameTickets.WithContext(ctx),
UserInventory: q.UserInventory.WithContext(ctx),
UserInventoryTransfers: q.UserInventoryTransfers.WithContext(ctx),
UserInvites: q.UserInvites.WithContext(ctx),
diff --git a/internal/repository/mysql/dao/user_game_tickets.gen.go b/internal/repository/mysql/dao/user_game_tickets.gen.go
new file mode 100644
index 0000000..95fc909
--- /dev/null
+++ b/internal/repository/mysql/dao/user_game_tickets.gen.go
@@ -0,0 +1,348 @@
+// 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 newUserGameTickets(db *gorm.DB, opts ...gen.DOOption) userGameTickets {
+ _userGameTickets := userGameTickets{}
+
+ _userGameTickets.userGameTicketsDo.UseDB(db, opts...)
+ _userGameTickets.userGameTicketsDo.UseModel(&model.UserGameTickets{})
+
+ tableName := _userGameTickets.userGameTicketsDo.TableName()
+ _userGameTickets.ALL = field.NewAsterisk(tableName)
+ _userGameTickets.ID = field.NewInt64(tableName, "id")
+ _userGameTickets.CreatedAt = field.NewTime(tableName, "created_at")
+ _userGameTickets.UpdatedAt = field.NewTime(tableName, "updated_at")
+ _userGameTickets.UserID = field.NewInt64(tableName, "user_id")
+ _userGameTickets.GameCode = field.NewString(tableName, "game_code")
+ _userGameTickets.Available = field.NewInt32(tableName, "available")
+ _userGameTickets.TotalEarned = field.NewInt32(tableName, "total_earned")
+ _userGameTickets.TotalUsed = field.NewInt32(tableName, "total_used")
+
+ _userGameTickets.fillFieldMap()
+
+ return _userGameTickets
+}
+
+// userGameTickets 用户游戏资格
+type userGameTickets struct {
+ userGameTicketsDo
+
+ ALL field.Asterisk
+ ID field.Int64
+ CreatedAt field.Time
+ UpdatedAt field.Time
+ UserID field.Int64 // 用户ID
+ GameCode field.String // 游戏代码
+ Available field.Int32 // 可用次数
+ TotalEarned field.Int32 // 累计获得
+ TotalUsed field.Int32 // 累计使用
+
+ fieldMap map[string]field.Expr
+}
+
+func (u userGameTickets) Table(newTableName string) *userGameTickets {
+ u.userGameTicketsDo.UseTable(newTableName)
+ return u.updateTableName(newTableName)
+}
+
+func (u userGameTickets) As(alias string) *userGameTickets {
+ u.userGameTicketsDo.DO = *(u.userGameTicketsDo.As(alias).(*gen.DO))
+ return u.updateTableName(alias)
+}
+
+func (u *userGameTickets) updateTableName(table string) *userGameTickets {
+ u.ALL = field.NewAsterisk(table)
+ u.ID = field.NewInt64(table, "id")
+ u.CreatedAt = field.NewTime(table, "created_at")
+ u.UpdatedAt = field.NewTime(table, "updated_at")
+ u.UserID = field.NewInt64(table, "user_id")
+ u.GameCode = field.NewString(table, "game_code")
+ u.Available = field.NewInt32(table, "available")
+ u.TotalEarned = field.NewInt32(table, "total_earned")
+ u.TotalUsed = field.NewInt32(table, "total_used")
+
+ u.fillFieldMap()
+
+ return u
+}
+
+func (u *userGameTickets) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
+ _f, ok := u.fieldMap[fieldName]
+ if !ok || _f == nil {
+ return nil, false
+ }
+ _oe, ok := _f.(field.OrderExpr)
+ return _oe, ok
+}
+
+func (u *userGameTickets) fillFieldMap() {
+ u.fieldMap = make(map[string]field.Expr, 8)
+ u.fieldMap["id"] = u.ID
+ u.fieldMap["created_at"] = u.CreatedAt
+ u.fieldMap["updated_at"] = u.UpdatedAt
+ u.fieldMap["user_id"] = u.UserID
+ u.fieldMap["game_code"] = u.GameCode
+ u.fieldMap["available"] = u.Available
+ u.fieldMap["total_earned"] = u.TotalEarned
+ u.fieldMap["total_used"] = u.TotalUsed
+}
+
+func (u userGameTickets) clone(db *gorm.DB) userGameTickets {
+ u.userGameTicketsDo.ReplaceConnPool(db.Statement.ConnPool)
+ return u
+}
+
+func (u userGameTickets) replaceDB(db *gorm.DB) userGameTickets {
+ u.userGameTicketsDo.ReplaceDB(db)
+ return u
+}
+
+type userGameTicketsDo struct{ gen.DO }
+
+func (u userGameTicketsDo) Debug() *userGameTicketsDo {
+ return u.withDO(u.DO.Debug())
+}
+
+func (u userGameTicketsDo) WithContext(ctx context.Context) *userGameTicketsDo {
+ return u.withDO(u.DO.WithContext(ctx))
+}
+
+func (u userGameTicketsDo) ReadDB() *userGameTicketsDo {
+ return u.Clauses(dbresolver.Read)
+}
+
+func (u userGameTicketsDo) WriteDB() *userGameTicketsDo {
+ return u.Clauses(dbresolver.Write)
+}
+
+func (u userGameTicketsDo) Session(config *gorm.Session) *userGameTicketsDo {
+ return u.withDO(u.DO.Session(config))
+}
+
+func (u userGameTicketsDo) Clauses(conds ...clause.Expression) *userGameTicketsDo {
+ return u.withDO(u.DO.Clauses(conds...))
+}
+
+func (u userGameTicketsDo) Returning(value interface{}, columns ...string) *userGameTicketsDo {
+ return u.withDO(u.DO.Returning(value, columns...))
+}
+
+func (u userGameTicketsDo) Not(conds ...gen.Condition) *userGameTicketsDo {
+ return u.withDO(u.DO.Not(conds...))
+}
+
+func (u userGameTicketsDo) Or(conds ...gen.Condition) *userGameTicketsDo {
+ return u.withDO(u.DO.Or(conds...))
+}
+
+func (u userGameTicketsDo) Select(conds ...field.Expr) *userGameTicketsDo {
+ return u.withDO(u.DO.Select(conds...))
+}
+
+func (u userGameTicketsDo) Where(conds ...gen.Condition) *userGameTicketsDo {
+ return u.withDO(u.DO.Where(conds...))
+}
+
+func (u userGameTicketsDo) Order(conds ...field.Expr) *userGameTicketsDo {
+ return u.withDO(u.DO.Order(conds...))
+}
+
+func (u userGameTicketsDo) Distinct(cols ...field.Expr) *userGameTicketsDo {
+ return u.withDO(u.DO.Distinct(cols...))
+}
+
+func (u userGameTicketsDo) Omit(cols ...field.Expr) *userGameTicketsDo {
+ return u.withDO(u.DO.Omit(cols...))
+}
+
+func (u userGameTicketsDo) Join(table schema.Tabler, on ...field.Expr) *userGameTicketsDo {
+ return u.withDO(u.DO.Join(table, on...))
+}
+
+func (u userGameTicketsDo) LeftJoin(table schema.Tabler, on ...field.Expr) *userGameTicketsDo {
+ return u.withDO(u.DO.LeftJoin(table, on...))
+}
+
+func (u userGameTicketsDo) RightJoin(table schema.Tabler, on ...field.Expr) *userGameTicketsDo {
+ return u.withDO(u.DO.RightJoin(table, on...))
+}
+
+func (u userGameTicketsDo) Group(cols ...field.Expr) *userGameTicketsDo {
+ return u.withDO(u.DO.Group(cols...))
+}
+
+func (u userGameTicketsDo) Having(conds ...gen.Condition) *userGameTicketsDo {
+ return u.withDO(u.DO.Having(conds...))
+}
+
+func (u userGameTicketsDo) Limit(limit int) *userGameTicketsDo {
+ return u.withDO(u.DO.Limit(limit))
+}
+
+func (u userGameTicketsDo) Offset(offset int) *userGameTicketsDo {
+ return u.withDO(u.DO.Offset(offset))
+}
+
+func (u userGameTicketsDo) Scopes(funcs ...func(gen.Dao) gen.Dao) *userGameTicketsDo {
+ return u.withDO(u.DO.Scopes(funcs...))
+}
+
+func (u userGameTicketsDo) Unscoped() *userGameTicketsDo {
+ return u.withDO(u.DO.Unscoped())
+}
+
+func (u userGameTicketsDo) Create(values ...*model.UserGameTickets) error {
+ if len(values) == 0 {
+ return nil
+ }
+ return u.DO.Create(values)
+}
+
+func (u userGameTicketsDo) CreateInBatches(values []*model.UserGameTickets, batchSize int) error {
+ return u.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 (u userGameTicketsDo) Save(values ...*model.UserGameTickets) error {
+ if len(values) == 0 {
+ return nil
+ }
+ return u.DO.Save(values)
+}
+
+func (u userGameTicketsDo) First() (*model.UserGameTickets, error) {
+ if result, err := u.DO.First(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.UserGameTickets), nil
+ }
+}
+
+func (u userGameTicketsDo) Take() (*model.UserGameTickets, error) {
+ if result, err := u.DO.Take(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.UserGameTickets), nil
+ }
+}
+
+func (u userGameTicketsDo) Last() (*model.UserGameTickets, error) {
+ if result, err := u.DO.Last(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.UserGameTickets), nil
+ }
+}
+
+func (u userGameTicketsDo) Find() ([]*model.UserGameTickets, error) {
+ result, err := u.DO.Find()
+ return result.([]*model.UserGameTickets), err
+}
+
+func (u userGameTicketsDo) FindInBatch(batchSize int, fc func(tx gen.Dao, batch int) error) (results []*model.UserGameTickets, err error) {
+ buf := make([]*model.UserGameTickets, 0, batchSize)
+ err = u.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 (u userGameTicketsDo) FindInBatches(result *[]*model.UserGameTickets, batchSize int, fc func(tx gen.Dao, batch int) error) error {
+ return u.DO.FindInBatches(result, batchSize, fc)
+}
+
+func (u userGameTicketsDo) Attrs(attrs ...field.AssignExpr) *userGameTicketsDo {
+ return u.withDO(u.DO.Attrs(attrs...))
+}
+
+func (u userGameTicketsDo) Assign(attrs ...field.AssignExpr) *userGameTicketsDo {
+ return u.withDO(u.DO.Assign(attrs...))
+}
+
+func (u userGameTicketsDo) Joins(fields ...field.RelationField) *userGameTicketsDo {
+ for _, _f := range fields {
+ u = *u.withDO(u.DO.Joins(_f))
+ }
+ return &u
+}
+
+func (u userGameTicketsDo) Preload(fields ...field.RelationField) *userGameTicketsDo {
+ for _, _f := range fields {
+ u = *u.withDO(u.DO.Preload(_f))
+ }
+ return &u
+}
+
+func (u userGameTicketsDo) FirstOrInit() (*model.UserGameTickets, error) {
+ if result, err := u.DO.FirstOrInit(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.UserGameTickets), nil
+ }
+}
+
+func (u userGameTicketsDo) FirstOrCreate() (*model.UserGameTickets, error) {
+ if result, err := u.DO.FirstOrCreate(); err != nil {
+ return nil, err
+ } else {
+ return result.(*model.UserGameTickets), nil
+ }
+}
+
+func (u userGameTicketsDo) FindByPage(offset int, limit int) (result []*model.UserGameTickets, count int64, err error) {
+ result, err = u.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 = u.Offset(-1).Limit(-1).Count()
+ return
+}
+
+func (u userGameTicketsDo) ScanByPage(result interface{}, offset int, limit int) (count int64, err error) {
+ count, err = u.Count()
+ if err != nil {
+ return
+ }
+
+ err = u.Offset(offset).Limit(limit).Scan(result)
+ return
+}
+
+func (u userGameTicketsDo) Scan(result interface{}) (err error) {
+ return u.DO.Scan(result)
+}
+
+func (u userGameTicketsDo) Delete(models ...*model.UserGameTickets) (result gen.ResultInfo, err error) {
+ return u.DO.Delete(models)
+}
+
+func (u *userGameTicketsDo) withDO(do gen.Dao) *userGameTicketsDo {
+ u.DO = *do.(*gen.DO)
+ return u
+}
diff --git a/internal/repository/mysql/model/game_ticket_logs.gen.go b/internal/repository/mysql/model/game_ticket_logs.gen.go
new file mode 100644
index 0000000..11f8cea
--- /dev/null
+++ b/internal/repository/mysql/model/game_ticket_logs.gen.go
@@ -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 TableNameGameTicketLogs = "game_ticket_logs"
+
+// GameTicketLogs 游戏资格变动日志
+type GameTicketLogs struct {
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
+ CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP(3)" json:"created_at"`
+ UserID int64 `gorm:"column:user_id;not null;comment:用户ID" json:"user_id"` // 用户ID
+ GameCode string `gorm:"column:game_code;not null;comment:游戏代码" json:"game_code"` // 游戏代码
+ ChangeType int32 `gorm:"column:change_type;not null;comment:1=获得 2=使用" json:"change_type"` // 1=获得 2=使用
+ Amount int32 `gorm:"column:amount;not null;comment:变动数量" json:"amount"` // 变动数量
+ Balance int32 `gorm:"column:balance;not null;comment:变动后余额" json:"balance"` // 变动后余额
+ Source string `gorm:"column:source;comment:来源: order/task/admin" json:"source"` // 来源: order/task/admin
+ SourceID int64 `gorm:"column:source_id;comment:来源ID" json:"source_id"` // 来源ID
+ Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注
+}
+
+// TableName GameTicketLogs's table name
+func (*GameTicketLogs) TableName() string {
+ return TableNameGameTicketLogs
+}
diff --git a/internal/repository/mysql/model/user_game_tickets.gen.go b/internal/repository/mysql/model/user_game_tickets.gen.go
new file mode 100644
index 0000000..dd98f33
--- /dev/null
+++ b/internal/repository/mysql/model/user_game_tickets.gen.go
@@ -0,0 +1,28 @@
+// 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 TableNameUserGameTickets = "user_game_tickets"
+
+// UserGameTickets 用户游戏资格
+type UserGameTickets struct {
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
+ 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"`
+ UserID int64 `gorm:"column:user_id;not null;comment:用户ID" json:"user_id"` // 用户ID
+ GameCode string `gorm:"column:game_code;not null;default:minesweeper;comment:游戏代码" json:"game_code"` // 游戏代码
+ Available int32 `gorm:"column:available;not null;comment:可用次数" json:"available"` // 可用次数
+ TotalEarned int32 `gorm:"column:total_earned;not null;comment:累计获得" json:"total_earned"` // 累计获得
+ TotalUsed int32 `gorm:"column:total_used;not null;comment:累计使用" json:"total_used"` // 累计使用
+}
+
+// TableName UserGameTickets's table name
+func (*UserGameTickets) TableName() string {
+ return TableNameUserGameTickets
+}
diff --git a/internal/router/router.go b/internal/router/router.go
index e71967b..ed9a087 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -6,6 +6,7 @@ import (
"bindbox-game/internal/api/admin"
appapi "bindbox-game/internal/api/app"
commonapi "bindbox-game/internal/api/common"
+ gameapi "bindbox-game/internal/api/game"
payapi "bindbox-game/internal/api/pay"
taskcenterapi "bindbox-game/internal/api/task_center"
userapi "bindbox-game/internal/api/user"
@@ -65,16 +66,17 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
userHandler := userapi.New(logger, db)
commonHandler := commonapi.New(logger, db)
payHandler := payapi.New(logger, db, taskSvc)
- // minesweeperHandler := minesweeperapi.New(logger, db)
+ gameHandler := gameapi.New(logger, db, rdb, userSvc)
intc := interceptor.New(logger, db)
// 内部服务接口路由组 (供 Nakama 调用)
- // internalRouter := mux.Group("/internal")
- // {
- // TODO: 添加IP白名单或Internal-Key验证中间件
- // internalRouter.POST("/game/verify", minesweeperHandler.VerifyTicket())
- // internalRouter.POST("/game/settle", minesweeperHandler.SettleGame())
- // }
+ internalRouter := mux.Group("/internal")
+ {
+ // TODO: 添加IP白名单或Internal-Key验证中间件
+ internalRouter.POST("/game/verify", gameHandler.VerifyTicket())
+ internalRouter.POST("/game/settle", gameHandler.SettleGame())
+ internalRouter.GET("/game/minesweeper/config", gameHandler.GetMinesweeperConfig())
+ }
// 管理端非认证接口路由组
adminNonAuthApiRouter := mux.Group("/api/admin")
@@ -224,6 +226,10 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.PUT("/matching_card_types/:id", adminHandler.ModifyMatchingCardType())
adminAuthApiRouter.DELETE("/matching_card_types/:id", adminHandler.DeleteMatchingCardType())
+ // 游戏资格管理
+ adminAuthApiRouter.GET("/users/:user_id/game_tickets", gameHandler.ListUserTickets())
+ adminAuthApiRouter.POST("/users/:user_id/game_tickets", gameHandler.GrantUserTicket())
+
// 发货统计
adminAuthApiRouter.GET("/ops_shipping_stats", intc.RequireAdminAction("ops:shipping:view"), adminHandler.ListShippingStats())
adminAuthApiRouter.GET("/ops_shipping_stats/:id", intc.RequireAdminAction("ops:shipping:view"), adminHandler.GetShippingStat())
@@ -351,6 +357,10 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
appAuthApiRouter.POST("/matching/check", activityHandler.CheckMatchingGame())
appAuthApiRouter.GET("/matching/state", activityHandler.GetMatchingGameState())
+ // 扫雷游戏
+ appAuthApiRouter.GET("/users/:user_id/game_tickets", gameHandler.GetMyTickets())
+ appAuthApiRouter.POST("/games/enter", gameHandler.EnterGame())
+
appAuthApiRouter.POST("/users/:user_id/inventory/address-share/create", userHandler.CreateAddressShare())
appAuthApiRouter.POST("/users/:user_id/inventory/address-share/revoke", userHandler.RevokeAddressShare())
appAuthApiRouter.POST("/users/:user_id/inventory/request-shipping", userHandler.RequestShippingBatch())
diff --git a/internal/service/game/ticket_service.go b/internal/service/game/ticket_service.go
new file mode 100644
index 0000000..bf03398
--- /dev/null
+++ b/internal/service/game/ticket_service.go
@@ -0,0 +1,190 @@
+package game
+
+import (
+ "bindbox-game/internal/pkg/logger"
+ "bindbox-game/internal/repository/mysql"
+ "bindbox-game/internal/repository/mysql/dao"
+ "bindbox-game/internal/repository/mysql/model"
+ "context"
+ "fmt"
+ "time"
+
+ "go.uber.org/zap"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+)
+
+// TicketService 游戏资格服务
+type TicketService interface {
+ // GrantTicket 发放游戏资格
+ GrantTicket(ctx context.Context, userID int64, gameCode string, amount int, source string, sourceID int64, remark string) error
+ // UseTicket 使用游戏资格
+ UseTicket(ctx context.Context, userID int64, gameCode string) error
+ // GetUserTickets 获取用户资格
+ GetUserTickets(ctx context.Context, userID int64) (map[string]int, error)
+ // GetUserTicketByGame 获取用户指定游戏资格
+ GetUserTicketByGame(ctx context.Context, userID int64, gameCode string) (*model.UserGameTickets, error)
+ // GetTicketLogs 获取用户资格变动日志
+ GetTicketLogs(ctx context.Context, userID int64, page, pageSize int) ([]*model.GameTicketLogs, int64, error)
+}
+
+type ticketService struct {
+ logger logger.CustomLogger
+ readDB *dao.Query
+ writeDB *dao.Query
+ repo mysql.Repo
+}
+
+// NewTicketService 创建资格服务
+func NewTicketService(l logger.CustomLogger, db mysql.Repo) TicketService {
+ return &ticketService{
+ logger: l,
+ readDB: dao.Use(db.GetDbR()),
+ writeDB: dao.Use(db.GetDbW()),
+ repo: db,
+ }
+}
+
+// GrantTicket 发放游戏资格
+func (s *ticketService) GrantTicket(ctx context.Context, userID int64, gameCode string, amount int, source string, sourceID int64, remark string) error {
+ if amount <= 0 {
+ return fmt.Errorf("amount must be positive")
+ }
+
+ return s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
+ // Upsert user_game_tickets
+ ticket := &model.UserGameTickets{
+ UserID: userID,
+ GameCode: gameCode,
+ Available: int32(amount),
+ TotalEarned: int32(amount),
+ TotalUsed: 0,
+ }
+
+ err := tx.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "user_id"}, {Name: "game_code"}},
+ DoUpdates: clause.Assignments(map[string]interface{}{
+ "available": gorm.Expr("available + ?", amount),
+ "total_earned": gorm.Expr("total_earned + ?", amount),
+ "updated_at": time.Now(),
+ }),
+ }).Create(ticket).Error
+ if err != nil {
+ return err
+ }
+
+ // 查询变动后余额
+ var balance int32
+ tx.Model(&model.UserGameTickets{}).
+ Where("user_id = ? AND game_code = ?", userID, gameCode).
+ Pluck("available", &balance)
+
+ // 记录日志
+ log := &model.GameTicketLogs{
+ UserID: userID,
+ GameCode: gameCode,
+ ChangeType: 1, // 获得
+ Amount: int32(amount),
+ Balance: balance,
+ Source: source,
+ SourceID: sourceID,
+ Remark: remark,
+ }
+ return tx.Create(log).Error
+ })
+}
+
+// UseTicket 使用游戏资格
+func (s *ticketService) UseTicket(ctx context.Context, userID int64, gameCode string) error {
+ return s.repo.GetDbW().Transaction(func(tx *gorm.DB) error {
+ // 检查并扣减
+ result := tx.Model(&model.UserGameTickets{}).
+ Where("user_id = ? AND game_code = ? AND available > 0", userID, gameCode).
+ Updates(map[string]interface{}{
+ "available": gorm.Expr("available - 1"),
+ "total_used": gorm.Expr("total_used + 1"),
+ "updated_at": time.Now(),
+ })
+
+ if result.Error != nil {
+ return result.Error
+ }
+ if result.RowsAffected == 0 {
+ return fmt.Errorf("insufficient game tickets")
+ }
+
+ // 查询变动后余额
+ var balance int32
+ tx.Model(&model.UserGameTickets{}).
+ Where("user_id = ? AND game_code = ?", userID, gameCode).
+ Pluck("available", &balance)
+
+ // 记录日志
+ log := &model.GameTicketLogs{
+ UserID: userID,
+ GameCode: gameCode,
+ ChangeType: 2, // 使用
+ Amount: 1,
+ Balance: balance,
+ Source: "game_enter",
+ Remark: "进入游戏",
+ }
+ return tx.Create(log).Error
+ })
+}
+
+// GetUserTickets 获取用户所有游戏资格
+func (s *ticketService) GetUserTickets(ctx context.Context, userID int64) (map[string]int, error) {
+ var tickets []*model.UserGameTickets
+ err := s.repo.GetDbR().WithContext(ctx).
+ Where("user_id = ?", userID).
+ Find(&tickets).Error
+ if err != nil {
+ s.logger.Error("GetUserTickets failed", zap.Int64("user_id", userID), zap.Error(err))
+ return nil, err
+ }
+
+ result := make(map[string]int)
+ for _, t := range tickets {
+ result[t.GameCode] = int(t.Available)
+ }
+ return result, nil
+}
+
+// GetUserTicketByGame 获取用户指定游戏资格
+func (s *ticketService) GetUserTicketByGame(ctx context.Context, userID int64, gameCode string) (*model.UserGameTickets, error) {
+ var ticket model.UserGameTickets
+ err := s.repo.GetDbR().WithContext(ctx).
+ Where("user_id = ? AND game_code = ?", userID, gameCode).
+ First(&ticket).Error
+ if err != nil {
+ return nil, err
+ }
+ return &ticket, nil
+}
+
+// GetTicketLogs 获取用户游戏资格变动日志
+func (s *ticketService) GetTicketLogs(ctx context.Context, userID int64, page, pageSize int) ([]*model.GameTicketLogs, int64, error) {
+ var logs []*model.GameTicketLogs
+ var total int64
+
+ db := s.repo.GetDbR().WithContext(ctx).Model(&model.GameTicketLogs{}).Where("user_id = ?", userID)
+
+ if err := db.Count(&total).Error; err != nil {
+ return nil, 0, err
+ }
+
+ if page <= 0 {
+ page = 1
+ }
+ if pageSize <= 0 {
+ pageSize = 20
+ }
+
+ offset := (page - 1) * pageSize
+ if err := db.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&logs).Error; err != nil {
+ return nil, 0, err
+ }
+
+ return logs, total, nil
+}
diff --git a/internal/service/task_center/service.go b/internal/service/task_center/service.go
index 19c30f2..cdacd7c 100644
--- a/internal/service/task_center/service.go
+++ b/internal/service/task_center/service.go
@@ -12,6 +12,7 @@ import (
"fmt"
"time"
+ gamesvc "bindbox-game/internal/service/game"
titlesvc "bindbox-game/internal/service/title"
usersvc "bindbox-game/internal/service/user"
@@ -665,6 +666,16 @@ func (s *service) grantTierRewards(ctx context.Context, taskID int64, tierID int
if pl.TitleID > 0 {
err = s.titleSvc.AssignUserTitle(ctx, userID, pl.TitleID, nil, "task_center")
}
+ case "game_ticket":
+ var pl struct {
+ GameCode string `json:"game_code"`
+ Amount int `json:"amount"`
+ }
+ _ = json.Unmarshal([]byte(r.RewardPayload), &pl)
+ if pl.GameCode != "" && pl.Amount > 0 {
+ gameSvc := gamesvc.NewTicketService(s.logger, s.repo)
+ err = gameSvc.GrantTicket(ctx, userID, pl.GameCode, pl.Amount, "task_center", taskID, "任务奖励")
+ }
}
if err != nil {
tx.Rollback()
diff --git a/internal/service/user/address_share.go b/internal/service/user/address_share.go
index 667e838..3309a57 100644
--- a/internal/service/user/address_share.go
+++ b/internal/service/user/address_share.go
@@ -142,15 +142,15 @@ func generateBatchNo(userID int64) string {
return fmt.Sprintf("B%d%d", userID, time.Now().UnixNano()/1000000)
}
-func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (int64, []int64, []struct {
+func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (addrID int64, batchNo string, success []int64, skipped []struct {
ID int64
Reason string
-}, []struct {
+}, failed []struct {
ID int64
Reason string
-}, error) {
+}, err error) {
if len(inventoryIDs) == 0 {
- return 0, nil, nil, []struct {
+ return 0, "", nil, nil, []struct {
ID int64
Reason string
}{{ID: 0, Reason: "invalid_params"}}, nil
@@ -166,25 +166,24 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
}
}
if len(uniq) == 0 {
- return 0, nil, nil, []struct {
+ return 0, "", nil, nil, []struct {
ID int64
Reason string
}{{ID: 0, Reason: "invalid_params"}}, nil
}
- var addrID int64
if addressID != nil && *addressID > 0 {
ua, _ := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.ID.Eq(*addressID), s.readDB.UserAddresses.UserID.Eq(userID)).First()
if ua == nil {
- return 0, nil, nil, []struct {
+ return 0, "", nil, nil, []struct {
ID int64
Reason string
}{{ID: 0, Reason: "address_not_found"}}, nil
}
addrID = ua.ID
} else {
- da, err := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.UserID.Eq(userID), s.readDB.UserAddresses.IsDefault.Eq(1)).First()
- if err != nil || da == nil {
- return 0, nil, nil, []struct {
+ da, e := s.readDB.UserAddresses.WithContext(ctx).Where(s.readDB.UserAddresses.UserID.Eq(userID), s.readDB.UserAddresses.IsDefault.Eq(1)).First()
+ if e != nil || da == nil {
+ return 0, "", nil, nil, []struct {
ID int64
Reason string
}{{ID: 0, Reason: "no_default_address"}}, nil
@@ -192,18 +191,15 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
addrID = da.ID
}
- // 生成批次号(只有多个有效项时才生成)
- batchNo := ""
- if len(uniq) > 1 {
- batchNo = generateBatchNo(userID)
- }
+ // 始终生成批次号,方便用户查询和管理
+ batchNo = generateBatchNo(userID)
- success := make([]int64, 0, len(uniq))
- skipped := make([]struct {
+ success = make([]int64, 0, len(uniq))
+ skipped = make([]struct {
ID int64
Reason string
}, 0)
- failed := make([]struct {
+ failed = make([]struct {
ID int64
Reason string
}, 0)
@@ -246,7 +242,7 @@ func (s *service) RequestShippings(ctx context.Context, userID int64, inventoryI
}
success = append(success, id)
}
- return addrID, success, skipped, failed, nil
+ return addrID, batchNo, success, skipped, failed, nil
}
func (s *service) RedeemInventoryToPoints(ctx context.Context, userID int64, inventoryID int64) (int64, error) {
diff --git a/internal/service/user/cancel_shipping.go b/internal/service/user/cancel_shipping.go
index d54c693..3389b29 100644
--- a/internal/service/user/cancel_shipping.go
+++ b/internal/service/user/cancel_shipping.go
@@ -7,40 +7,84 @@ import (
)
// CancelShipping 取消发货申请
-func (s *service) CancelShipping(ctx context.Context, userID int64, inventoryID int64) error {
- // 1. 开启事务
- return s.writeDB.Transaction(func(tx *dao.Query) error {
- // 2. 查询发货记录(必须是待发货状态 status=1)
- sr, err := tx.ShippingRecords.WithContext(ctx).
- Where(tx.ShippingRecords.InventoryID.Eq(inventoryID)).
- Where(tx.ShippingRecords.UserID.Eq(userID)).
- Where(tx.ShippingRecords.Status.Eq(1)).
- First()
+// 支持按单个资产ID取消,或按批次号批量取消
+// 返回成功取消的记录数
+func (s *service) CancelShipping(ctx context.Context, userID int64, inventoryID int64, batchNo string) (int64, error) {
+ var cancelledCount int64
- if err != nil {
- return fmt.Errorf("shipping record not found or already processed")
+ err := s.writeDB.Transaction(func(tx *dao.Query) error {
+ var records []*struct {
+ ID int64
+ InventoryID int64
}
- // 3. 更新发货记录状态为已取消 (status=5)
- if _, err := tx.ShippingRecords.WithContext(ctx).
- Where(tx.ShippingRecords.ID.Eq(sr.ID)).
- Update(tx.ShippingRecords.Status, 5); err != nil {
- return err
+ // 根据参数查询待取消的发货记录
+ if batchNo != "" {
+ // 按批次号查询
+ rows, err := tx.ShippingRecords.WithContext(ctx).
+ Select(tx.ShippingRecords.ID, tx.ShippingRecords.InventoryID).
+ Where(tx.ShippingRecords.BatchNo.Eq(batchNo)).
+ Where(tx.ShippingRecords.UserID.Eq(userID)).
+ Where(tx.ShippingRecords.Status.Eq(1)). // 待发货状态
+ Find()
+ if err != nil {
+ return fmt.Errorf("query shipping records failed: %w", err)
+ }
+ for _, r := range rows {
+ records = append(records, &struct {
+ ID int64
+ InventoryID int64
+ }{ID: r.ID, InventoryID: r.InventoryID})
+ }
+ } else if inventoryID > 0 {
+ // 按单个资产ID查询
+ sr, err := tx.ShippingRecords.WithContext(ctx).
+ Where(tx.ShippingRecords.InventoryID.Eq(inventoryID)).
+ Where(tx.ShippingRecords.UserID.Eq(userID)).
+ Where(tx.ShippingRecords.Status.Eq(1)).
+ First()
+ if err != nil {
+ return fmt.Errorf("shipping record not found or already processed")
+ }
+ records = append(records, &struct {
+ ID int64
+ InventoryID int64
+ }{ID: sr.ID, InventoryID: sr.InventoryID})
}
- // 4. 恢复库存状态为可用 (status=1)
- // 并追加备注
- // 使用原生SQL以确保CONCAT行为一致
- remark := fmt.Sprintf("|shipping_cancelled_by_user:%d", userID)
- if err := tx.UserInventory.WithContext(ctx).UnderlyingDB().Exec(
- "UPDATE user_inventory SET status=1, remark=CONCAT(IFNULL(remark,''), ?) WHERE id=? AND user_id=?",
- remark,
- inventoryID,
- userID,
- ).Error; err != nil {
- return err
+ if len(records) == 0 {
+ return fmt.Errorf("no pending shipping records found")
+ }
+
+ // 批量处理每条记录
+ for _, rec := range records {
+ // 更新发货记录状态为已取消 (status=5)
+ if _, err := tx.ShippingRecords.WithContext(ctx).
+ Where(tx.ShippingRecords.ID.Eq(rec.ID)).
+ Update(tx.ShippingRecords.Status, 5); err != nil {
+ return err
+ }
+
+ // 恢复库存状态为可用 (status=1)
+ remark := fmt.Sprintf("|shipping_cancelled_by_user:%d", userID)
+ if err := tx.UserInventory.WithContext(ctx).UnderlyingDB().Exec(
+ "UPDATE user_inventory SET status=1, remark=CONCAT(IFNULL(remark,''), ?) WHERE id=? AND user_id=?",
+ remark,
+ rec.InventoryID,
+ userID,
+ ).Error; err != nil {
+ return err
+ }
+
+ cancelledCount++
}
return nil
})
+
+ if err != nil {
+ return 0, err
+ }
+
+ return cancelledCount, nil
}
diff --git a/internal/service/user/request_shipping_batch_test.go b/internal/service/user/request_shipping_batch_test.go
index 18bd27a..9985bb7 100644
--- a/internal/service/user/request_shipping_batch_test.go
+++ b/internal/service/user/request_shipping_batch_test.go
@@ -1,16 +1,15 @@
package user
import (
- "context"
- "testing"
+ "context"
+ "testing"
)
func TestRequestShippings_DedupAndSkip(t *testing.T) {
- s := New(nil, nil)
- // s.readDB/s.writeDB are nil in this placeholder; this test is a placeholder to ensure compilation.
- _, _, _, _, err := s.RequestShippings(context.Background(), 1, []int64{0, 1, 1}, nil)
- if err != nil {
- // no real DB; just ensure function can be called
- }
+ s := New(nil, nil)
+ // s.readDB/s.writeDB are nil in this placeholder; this test is a placeholder to ensure compilation.
+ _, _, _, _, _, err := s.RequestShippings(context.Background(), 1, []int64{0, 1, 1}, nil)
+ if err != nil {
+ // no real DB; just ensure function can be called
+ }
}
-
diff --git a/internal/service/user/user.go b/internal/service/user/user.go
index 19b6355..ca6cad8 100644
--- a/internal/service/user/user.go
+++ b/internal/service/user/user.go
@@ -50,14 +50,14 @@ type Service interface {
RevokeAddressShare(ctx context.Context, userID int64, inventoryID int64) error
SubmitAddressShare(ctx context.Context, shareToken string, name string, mobile string, province string, city string, district string, address string, submittedByUserID *int64, submittedIP *string) (int64, error)
RequestShipping(ctx context.Context, userID int64, inventoryID int64) (int64, error)
- CancelShipping(ctx context.Context, userID int64, inventoryID int64) error
- RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (int64, []int64, []struct {
+ CancelShipping(ctx context.Context, userID int64, inventoryID int64, batchNo string) (int64, error)
+ RequestShippings(ctx context.Context, userID int64, inventoryIDs []int64, addressID *int64) (addrID int64, batchNo string, success []int64, skipped []struct {
ID int64
Reason string
- }, []struct {
+ }, failed []struct {
ID int64
Reason string
- }, error)
+ }, err error)
VoidUserInventory(ctx context.Context, adminID int64, userID int64, inventoryID int64) error
RedeemInventoryToPoints(ctx context.Context, userID int64, inventoryID int64) (int64, error)
RedeemInventoriesToPoints(ctx context.Context, userID int64, inventoryIDs []int64) (int64, error)
diff --git a/logs/mini-chat-access.log b/logs/mini-chat-access.log
index 748bdf8..b532934 100644
--- a/logs/mini-chat-access.log
+++ b/logs/mini-chat-access.log
@@ -331,3 +331,39 @@
{"level":"info","time":"2025-12-23 23:32:14","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":2}
{"level":"info","time":"2025-12-23 23:32:14","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":3}
{"level":"info","time":"2025-12-23 23:32:14","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":0}
+{"level":"info","time":"2025-12-23 23:38:04","caller":"logger/logger.go:309","msg":"Connected to Redis","domain":"mini-chat[fat]","addr":"118.25.13.43:8379"}
+{"level":"info","time":"2025-12-23 23:38:04","caller":"logger/logger.go:309","msg":"Task center worker started","domain":"mini-chat[fat]"}
+{"level":"info","time":"2025-12-23 23:38:04","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":1}
+{"level":"info","time":"2025-12-23 23:38:04","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":2}
+{"level":"info","time":"2025-12-23 23:38:04","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":0}
+{"level":"info","time":"2025-12-23 23:38:04","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":3}
+{"level":"info","time":"2025-12-23 23:38:04","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":4}
+{"level":"info","time":"2025-12-24 13:04:26","caller":"logger/logger.go:309","msg":"Connected to Redis","domain":"mini-chat[fat]","addr":"118.25.13.43:8379"}
+{"level":"info","time":"2025-12-24 13:04:26","caller":"logger/logger.go:309","msg":"Task center worker started","domain":"mini-chat[fat]"}
+{"level":"info","time":"2025-12-24 13:04:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":4}
+{"level":"info","time":"2025-12-24 13:04:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":0}
+{"level":"info","time":"2025-12-24 13:04:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":3}
+{"level":"info","time":"2025-12-24 13:04:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":1}
+{"level":"info","time":"2025-12-24 13:04:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":2}
+{"level":"info","time":"2025-12-24 13:26:35","caller":"logger/logger.go:309","msg":"Connected to Redis","domain":"mini-chat[fat]","addr":"118.25.13.43:8379"}
+{"level":"info","time":"2025-12-24 13:26:35","caller":"logger/logger.go:309","msg":"Task center worker started","domain":"mini-chat[fat]"}
+{"level":"info","time":"2025-12-24 13:26:35","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":2}
+{"level":"info","time":"2025-12-24 13:26:35","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":0}
+{"level":"info","time":"2025-12-24 13:26:35","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":3}
+{"level":"info","time":"2025-12-24 13:26:35","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":1}
+{"level":"info","time":"2025-12-24 13:26:35","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":4}
+{"level":"info","time":"2025-12-24 13:29:35","caller":"logger/logger.go:309","msg":"Processing event","domain":"mini-chat[fat]","type":"order_paid","worker_id":1}
+{"level":"info","time":"2025-12-24 14:48:36","caller":"logger/logger.go:309","msg":"Connected to Redis","domain":"mini-chat[fat]","addr":"118.25.13.43:8379"}
+{"level":"info","time":"2025-12-24 14:48:36","caller":"logger/logger.go:309","msg":"Task center worker started","domain":"mini-chat[fat]"}
+{"level":"info","time":"2025-12-24 14:48:36","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":4}
+{"level":"info","time":"2025-12-24 14:48:36","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":1}
+{"level":"info","time":"2025-12-24 14:48:36","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":2}
+{"level":"info","time":"2025-12-24 14:48:36","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":0}
+{"level":"info","time":"2025-12-24 14:48:36","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":3}
+{"level":"info","time":"2025-12-24 17:05:25","caller":"logger/logger.go:309","msg":"Connected to Redis","domain":"mini-chat[fat]","addr":"118.25.13.43:8379"}
+{"level":"info","time":"2025-12-24 17:05:26","caller":"logger/logger.go:309","msg":"Task center worker started","domain":"mini-chat[fat]"}
+{"level":"info","time":"2025-12-24 17:05:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":2}
+{"level":"info","time":"2025-12-24 17:05:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":0}
+{"level":"info","time":"2025-12-24 17:05:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":4}
+{"level":"info","time":"2025-12-24 17:05:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":1}
+{"level":"info","time":"2025-12-24 17:05:26","caller":"logger/logger.go:309","msg":"Worker routine started","domain":"mini-chat[fat]","worker_id":3}
diff --git a/migrations/006_game_tickets.sql b/migrations/006_game_tickets.sql
new file mode 100644
index 0000000..17f2c88
--- /dev/null
+++ b/migrations/006_game_tickets.sql
@@ -0,0 +1,32 @@
+-- 游戏资格系统表
+-- Migration: 006_game_tickets.sql
+
+-- 用户游戏资格
+CREATE TABLE IF NOT EXISTS user_game_tickets (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
+ updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
+ user_id BIGINT NOT NULL COMMENT '用户ID',
+ game_code VARCHAR(32) NOT NULL DEFAULT 'minesweeper' COMMENT '游戏代码',
+ available INT NOT NULL DEFAULT 0 COMMENT '可用次数',
+ total_earned INT NOT NULL DEFAULT 0 COMMENT '累计获得',
+ total_used INT NOT NULL DEFAULT 0 COMMENT '累计使用',
+ UNIQUE KEY uk_user_game (user_id, game_code),
+ INDEX idx_user_id (user_id)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户游戏资格';
+
+-- 资格变动日志
+CREATE TABLE IF NOT EXISTS game_ticket_logs (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
+ user_id BIGINT NOT NULL COMMENT '用户ID',
+ game_code VARCHAR(32) NOT NULL COMMENT '游戏代码',
+ change_type TINYINT NOT NULL COMMENT '1=获得 2=使用',
+ amount INT NOT NULL COMMENT '变动数量',
+ balance INT NOT NULL COMMENT '变动后余额',
+ source VARCHAR(32) COMMENT '来源: order/task/admin',
+ source_id BIGINT COMMENT '来源ID',
+ remark VARCHAR(255) COMMENT '备注',
+ INDEX idx_user_game (user_id, game_code),
+ INDEX idx_created (created_at)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='游戏资格变动日志';