Compare commits
81 Commits
6c59670c69
...
9214501756
| Author | SHA1 | Date | |
|---|---|---|---|
| 9214501756 | |||
| 25c44c2064 | |||
| 55e22086e8 | |||
| 1a8f94d7b8 | |||
| f8624cca49 | |||
| ff404e21f0 | |||
| 021ab34c75 | |||
| 6d33cc7fd0 | |||
| 5ad2f4ace3 | |||
| b21e2db8ef | |||
| e3a96e68d8 | |||
| 359ca9121f | |||
| fb6dc1e434 | |||
| e8bfff8261 | |||
| 4a582997d1 | |||
| 8eb28465a2 | |||
| 2838ccb4c7 | |||
| 5710b977e0 | |||
| 6435226f6d | |||
| 9aeca5344f | |||
| 4aad2ad07c | |||
| bf11f32edf | |||
| 9dbd37e07f | |||
| d055f81b90 | |||
| a29c8ead15 | |||
| be91dbbfa0 | |||
| 46977aef2a | |||
| 208b7cde8a | |||
| 0ae202166a | |||
| 0b15f075b6 | |||
| 83d5faf46b | |||
| 21340a48c6 | |||
| 9106663083 | |||
| 5088eec733 | |||
| 67ce1dcda4 | |||
|
|
f60eaddae6 | ||
|
|
5885463bb0 | ||
| ca4f6e9119 | |||
| 0fb58a4555 | |||
|
|
2487062b46 | ||
|
|
64a5154a53 | ||
|
|
7b8bab65f4 | ||
|
|
df01bfc96d | ||
|
|
a68a90c3e5 | ||
|
|
b665e37f1e | ||
|
|
f029ffa38b | ||
|
|
0a94ac88da | ||
|
|
0767f3a8af | ||
|
|
f56da9ee9b | ||
| 5e1a6f925f | |||
| fe07eed82c | |||
| d2b96107c5 | |||
| 4e11abb342 | |||
| 3acf6d7644 | |||
| eca842af84 | |||
|
|
f86afcf9c1 | ||
|
|
27e1b44161 | ||
| 00e6248f71 | |||
| 09d18605c4 | |||
| d085b8d035 | |||
| e22a5f078c | |||
|
|
b1aeafcdb3 | ||
|
|
b9d32893fa | ||
|
|
466b16a274 | ||
| d4d2355e36 | |||
| 4ec9dc4492 | |||
| 6aab4a2cf4 | |||
|
|
799e7705a7 | ||
|
|
bc2fa2fd64 | ||
|
|
727b942c5d | ||
|
|
d86b883413 | ||
|
|
3c6ae48196 | ||
|
|
147af6fdfb | ||
|
|
a798a8d10e | ||
|
|
4c192426df | ||
|
|
abccbe3a54 | ||
| 998e6cdd57 | |||
| 3fd89b0462 | |||
| 9e821723f8 | |||
| 0d10872e2e | |||
| 7bdf81782b |
10
.gitignore
vendored
10
.gitignore
vendored
@ -27,3 +27,13 @@ go.work.sum
|
||||
resources/*
|
||||
build/resources/admin/
|
||||
logs/
|
||||
web/*
|
||||
|
||||
# 敏感配置文件
|
||||
configs/*.toml
|
||||
!configs/*.example.toml
|
||||
|
||||
# 环境变量
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
26
Dockerfile
26
Dockerfile
@ -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}"]
|
||||
@ -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)
|
||||
}
|
||||
@ -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"
|
||||
|
||||
```
|
||||
|
||||
@ -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"
|
||||
```
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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, "")
|
||||
}
|
||||
@ -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))
|
||||
}
|
||||
@ -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 参数执行实际迁移")
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
45
go.mod
@ -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
111
go.sum
@ -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=
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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: 范围校验失败")
|
||||
|
||||
@ -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应正常匹配
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
319
internal/api/admin/blacklist_admin.go
Normal file
319
internal/api/admin/blacklist_admin.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
681
internal/api/admin/dashboard_activity.go
Normal file
681
internal/api/admin/dashboard_activity.go
Normal 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
344
internal/api/admin/dashboard_spending.go
Normal file
344
internal/api/admin/dashboard_spending.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
214
internal/api/admin/douyin_product_rewards.go
Normal file
214
internal/api/admin/douyin_product_rewards.go
Normal 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": "删除成功"})
|
||||
}
|
||||
}
|
||||
825
internal/api/admin/livestream_admin.go
Normal file
825
internal/api/admin/livestream_admin.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
333
internal/api/admin/livestream_stats.go
Normal file
333
internal/api/admin/livestream_stats.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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++
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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]
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
50
internal/api/common/config.go
Normal file
50
internal/api/common/config.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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()))
|
||||
|
||||
@ -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 失效(防止重复扣减)
|
||||
|
||||
331
internal/api/game/handler_test.go
Normal file
331
internal/api/game/handler_test.go
Normal 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"
|
||||
}
|
||||
})
|
||||
}
|
||||
@ -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))
|
||||
|
||||
454
internal/api/public/livestream_public.go
Normal file
454
internal/api/public/livestream_public.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
77
internal/api/user/bind_douyin_order_app.go
Normal file
77
internal/api/user/bind_douyin_order_app.go
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"}
|
||||
|
||||
@ -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
|
||||
|
||||
// 触发邀请奖励逻辑
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
164
internal/api/wechat/mini_template.go
Normal file
164
internal/api/wechat/mini_template.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
@ -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")
|
||||
|
||||
5
internal/pkg/env/env.go
vendored
5
internal/pkg/env/env.go
vendored
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
66
internal/pkg/otel/middleware.go
Normal file
66
internal/pkg/otel/middleware.go
Normal 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
114
internal/pkg/otel/otel.go
Normal 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)
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 == "" {
|
||||
|
||||
@ -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
|
||||
|
||||
0
internal/repository/mysql/bindbox.db
Normal file
0
internal/repository/mysql/bindbox.db
Normal file
344
internal/repository/mysql/dao/douyin_blacklist.gen.go
Normal file
344
internal/repository/mysql/dao/douyin_blacklist.gen.go
Normal 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
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
352
internal/repository/mysql/dao/douyin_product_rewards.gen.go
Normal file
352
internal/repository/mysql/dao/douyin_product_rewards.gen.go
Normal 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
|
||||
}
|
||||
@ -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),
|
||||
|
||||
384
internal/repository/mysql/dao/livestream_activities.gen.go
Normal file
384
internal/repository/mysql/dao/livestream_activities.gen.go
Normal 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
|
||||
}
|
||||
380
internal/repository/mysql/dao/livestream_draw_logs.gen.go
Normal file
380
internal/repository/mysql/dao/livestream_draw_logs.gen.go
Normal 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
|
||||
}
|
||||
368
internal/repository/mysql/dao/livestream_prizes.gen.go
Normal file
368
internal/repository/mysql/dao/livestream_prizes.gen.go
Normal 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
|
||||
}
|
||||
@ -51,7 +51,7 @@ func newShippingRecords(db *gorm.DB, opts ...gen.DOOption) shippingRecords {
|
||||
return _shippingRecords
|
||||
}
|
||||
|
||||
// shippingRecords 发货记录(合并:单表)
|
||||
// shippingRecords 发货记录表
|
||||
type shippingRecords struct {
|
||||
shippingRecordsDo
|
||||
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
27
internal/repository/mysql/model/douyin_blacklist.gen.go
Normal file
27
internal/repository/mysql/model/douyin_blacklist.gen.go
Normal 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
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
41
internal/repository/mysql/model/livestream_activities.gen.go
Normal file
41
internal/repository/mysql/model/livestream_activities.gen.go
Normal 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
|
||||
}
|
||||
36
internal/repository/mysql/model/livestream_draw_logs.gen.go
Normal file
36
internal/repository/mysql/model/livestream_draw_logs.gen.go
Normal 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
|
||||
}
|
||||
33
internal/repository/mysql/model/livestream_prizes.gen.go
Normal file
33
internal/repository/mysql/model/livestream_prizes.gen.go
Normal 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
Loading…
x
Reference in New Issue
Block a user