fix: 修复退款时清理一番赏格位、积分兑换商品库存校验及抖音登录自邀问题。

This commit is contained in:
邹方成 2026-01-06 01:46:25 +08:00
parent 359ca9121f
commit e3a96e68d8
30 changed files with 2877 additions and 319 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -0,0 +1,50 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"log"
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/service/douyin"
syscfgsvc "bindbox-game/internal/service/sysconfig"
)
func main() {
orderID := flag.String("id", "", "抖音订单号 (order_id)")
flag.Parse()
if *orderID == "" {
log.Fatal("请提供订单号,例如: -id=6946062444563338504")
}
// 1. 初始化 MySQL
dbRepo, err := mysql.New()
if err != nil {
log.Fatalf("MySQL 初始化失败: %v", err)
}
// 2. 初始化 Logger (简易版)
customLogger, _ := logger.NewCustomLogger(dao.Use(dbRepo.GetDbW()), logger.WithOutputInConsole())
// 3. 初始化 Service
sysCfgSvc := syscfgsvc.New(customLogger, dbRepo)
douyinSvc := douyin.New(customLogger, dbRepo, sysCfgSvc, nil)
// 4. 执行测试
fmt.Printf("--- 正在测试订单号: %s ---\n", *orderID)
ctx := context.Background()
order, err := douyinSvc.GetOrderByOrderID(ctx, *orderID)
if err != nil {
log.Fatalf("查询失败: %v", err)
}
// 5. 打印结果
fmt.Println("查询成功!返回数据如下:")
data, _ := json.MarshalIndent(order, "", " ")
fmt.Println(string(data))
}

View File

@ -0,0 +1,83 @@
# 任务对齐:统一积分与元比例 (Standardize Points and Yuan Ratio)
## 1. 项目上下文分析
- **项目结构**:
- 后端: `bindbox_game` (Go)
- 管理后台: `bindbox_game/web/admin` (Vue 3)
- 小程序: `bindbox-mini` (UniApp / Vue)
- **核心问题**:
- 当前代码逻辑隐含 "1积分 = 1分钱" (100积分=1元),前端通过 `/100` 强行展示为 "1.00",导致逻辑割裂。
- 用户期望标准: **1元 = 1积分**
- 后端配置缺失,且计算公式需要适配 "元" 为单位。
## 2. 需求确认
- **目标**:
1. **统一比例**: 全局统一为 **1 元 = 1 积分** (即 1 积分价值 100 分钱)。
2. **配置化**: 后台可配置 "积分/元 兑换比例" (Rate),当前固定为 1。
3. **整改范围**: 修正后端转换公式,修正前端展示逻辑,添加后台配置界面。
4. **业务场景**: 暂时不涉及"消费送积分" (即订单完成后自动按比例赠送),主要关注积分价值本身(如充值、抵扣、退款等场景的换算)。
## 3. 现状分析 (As-Is)
- **后端 (`bindbox_game`)**:
- `CentsToPoints`: 依赖 `points_exchange_per_cent` (分换积分比例),默认为 1。即 1 分钱 = 1 积分单位。
- `RefundPointsAmount`: 存在硬编码 `/ 100`,逻辑存疑。
- **小程序 (`bindbox-mini`)**:
- `formatPoints`: `value / 100`
- 如果后端给 100 积分单位,前端展示为 "1.0"。
- 含义模糊是“1积分”还是“1.0元价值”?
- **后台 (`web/admin`)**:
- 缺少积分比例配置界面。
## 4. 关键决策点 (Resolved)
1. **积分定义**:
- 确认采用 **1 积分 = 1 元** 的价值锚点。
- 数据库存储: 存 `1` 代表 1 积分 (即 1 元)。
- *变更*: 现在的 `100` (分) 对应 1 元,未来 `1` (积分) 对应 1 元。需要明确是否存在历史数据需要迁移(或者是新项目/可重置)。**假设目前无存量包袱或接受重置/迁移,或者我们调整代码适配现有数值**。
- *风险提示*: 如果仅仅改代码不改数据,原来的 100 积分 (1元) 瞬间变成 100 积分 (100元)。**必须确认是否需要数据清洗脚本**。
2. **兑换比例配置**:
- 配置项: `points_exchange_rate` (1 元对应多少积分)。
- 默认值: 1。
- 公式:
- 元转积分 (Amount -> Points): `points = amount_yuan * rate` => `points = (cents / 100) * rate`
- 积分转元 (Points -> Amount): `cents = (points / rate) * 100`
## 5. 实施方案 (Architecture)
### 5.1 数据库与配置
- **配置表 (`sys_configs`)**:
- Key: `points_exchange_rate`
- Value: `1` (Default)
- Description: "积分/元 兑换比例(多少积分=1元)"。
### 5.2 后端改造 (`bindbox_game`)
- **`internal/pkg/points/convert.go`**:
- `CentsToPoints(cents, rate)`:
- Old: `cents * rate` (Assumed rate per cent)
- New: `(cents * rate) / 100` (Rate per Yuan)
- `PointsToCents(points, rate)`:
- Old: `points / rate`
- New: `(points * 100) / rate`
- `RefundPointsAmount`: 适配新公式。
- **Service Layer**:
- 确保读取新的配置 Key。
- 检查所有手动计算积分的地方,全部收敛到 `convert` 包。
### 5.3 后台改造 (`web/admin`)
- **界面**: 在 `SystemConfigs` (系统配置) -> 新增 "积分配置" 分组。
- **功能**: 编辑 `points_exchange_rate`
### 5.4 小程序/前端改造 (`bindbox-mini`)
- **展示逻辑**:
- 移除 `formatPoints` 中的 `/ 100`
- 直接展示后端返回的整数积分。
- 检查 "积分抵扣" 等页面,确保传给后端的数值正确。
## 6. 执行计划 (Task Split)
1. **Design**: 确认方案无误 (当前步骤)。
2. **Backend**:
- 修改 Convert 算法。
- 确保 Config 读取逻辑正确。
- (Optional) 数据迁移脚本/重置脚本 (如果已有数据)。
3. **Frontend (Admin)**: 添加配置界面。
4. **Frontend (App)**: 修正展示逻辑。
5. **Verify**: 验证 1 元订单是否对应 1 积分(模拟),或者充值/手动增加 1 积分是否显示为 1。

View File

@ -0,0 +1,259 @@
# 后台工作台页面接口分析
## 一、原始需求
分析后台工作台页面的所有设计,确定需要对应哪些接口,并补充后端缺失的接口实现。
## 二、项目特性规范
### 技术栈
- **前端**: Vue 3 + TypeScript + Element Plus
- **后端**: Go + Gin + GORM
- **API风格**: RESTful
### 现有架构
- 前端API定义: `web/admin/src/api/dashboard.ts`, `web/admin/src/api/operations.ts`
- 后端处理器: `internal/api/admin/dashboard_admin.go`
---
## 三、工作台页面模块与接口对应关系
### 维度1: 经营大盘 (overview)
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|------|------|---------|----------|------|
| `card-list.vue` | 顶部统计卡片 | `fetchCardStats` | ✅ 已实现 | `DashboardCards` |
| `sales-overview.vue` | 销售趋势分析 | `fetchSalesDrawTrend` | ✅ 已实现 | `DashboardSalesDrawTrend` |
| `product-performance.vue` | 产品动销排行 | `fetchProductPerformance` | ⚠️ Mock数据 | 需要实现 |
| `user-economics.vue` | 用户经济分析 | `fetchUserEconomics` | ✅ 已实现 | `DashboardUserEconomics` |
---
### 维度2: 奖池与欧气 (lottery)
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|------|------|---------|----------|------|
| `prize-pool-health.vue` | 奖池健康度分析 | `fetchPrizeDistribution` | ✅ 已实现 | `DashboardPrizeDistribution` |
| `live-stream-premium.vue` | 全服欧气实时播报 | 无(模拟数据) | ⚠️ 需要实现 | 需要新增实时中奖播报接口 |
---
### 维度3: 营销转化 (marketing)
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|------|------|---------|----------|------|
| `growth-analytics.vue` | 增长经济模型分析 | `fetchUserEconomics` | ✅ 已实现 | 复用 `DashboardUserEconomics` |
| `coupon-roi.vue` | 营销券效能排行 | `fetchCouponEffectiveness` | ⚠️ Mock数据 | 需要实现 |
| `retention-cohort.vue` | 留存同类群组分析 | `fetchRetentionAnalytics` | ✅ 已实现 | `DashboardRetentionAnalytics` |
| `marketing-conversion.vue` | 订单转化全链路监控 | `fetchOrderFunnel` | ✅ 已实现 | `DashboardOrderFunnel` |
---
### 维度4: 风控预警 (security)
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|------|------|---------|----------|------|
| `inventory-alert.vue` | 库存预警监控 | `fetchInventoryAlerts` | ⚠️ Mock数据 | 需要实现 |
| `risk-monitor.vue` | 异常风险监控 | `fetchRiskEvents` | ⚠️ Mock数据 | 需要实现 |
| `points-economy.vue` | 积分经济总览 | `fetchPointsEconomySummary`<br>`fetchPointsTrend`<br>`fetchPointsStructure` | ⚠️ Mock数据 | 需要实现3个接口 |
---
## 四、需要补充的后端接口清单
### 4.1 运营分析接口 (Operations)
#### 1. 产品动销排行 `GET /admin/operations/product_performance`
```json
// Request
{ "rangeType": "7d|30d|today" }
// Response
[
{
"id": 1,
"seriesName": "系列名称",
"salesCount": 1540,
"amount": 285000, // 销售金额(分)
"contributionRate": 35.5, // 利润贡献率%
"inventoryTurnover": 8.5 // 周转率
}
]
```
#### 2. 优惠券效能排行 `GET /admin/operations/coupon_effectiveness`
```json
// Request
{ "rangeType": "7d|30d" }
// Response
[
{
"couponId": 1,
"couponName": "新用户专享券",
"type": "满减券",
"issuedCount": 1200,
"usedCount": 680,
"usedRate": 56.7, // 使用率%
"broughtOrders": 720, // 带动订单数
"broughtAmount": 3600000, // 带动金额(分)
"roi": 3.2 // 投资回报率
}
]
```
#### 3. 库存预警列表 `GET /admin/operations/inventory_alerts`
```json
// 无请求参数
// Response
[
{
"id": 101,
"name": "商品名称",
"type": "physical|virtual|coupon",
"stock": 3,
"threshold": 5,
"salesSpeed": 1.2 // 日均消耗速度
}
]
```
#### 4. 风险事件监控 `GET /admin/operations/risk_events`
```json
// 无请求参数
// Response
[
{
"userId": 5001,
"nickname": "用户昵称",
"avatar": "头像URL",
"type": "frequent_win|batch_register|ip_clash",
"description": "24小时内中奖5次一等奖",
"riskLevel": "high|medium|low",
"createdAt": "13:20"
}
]
```
---
### 4.2 积分经济接口 (Points Economy)
#### 5. 积分经济总览 `GET /admin/operations/points_economy_summary`
```json
// Request
{ "rangeType": "7d|30d" }
// Response
{
"totalIssued": 1258400, // 发行总积分
"totalConsumed": 985600, // 消耗总积分
"netChange": 272800, // 净变化
"activeUsersWithPoints": 5640, // 持分活跃用户数
"conversionRate": 78.5 // 活跃持仓率%
}
```
#### 6. 积分趋势 `GET /admin/operations/points_trend`
```json
// Request
{ "rangeType": "7d|30d" }
// Response
[
{
"date": "2026-01-01",
"issued": 20000,
"consumed": 15000,
"expired": 1000,
"netChange": 4000,
"balance": 250000
}
]
```
#### 7. 积分收支结构 `GET /admin/operations/points_structure`
```json
// Request
{ "rangeType": "7d|30d" }
// Response
[
{
"category": "任务奖励",
"amount": 85000,
"percentage": 45.2,
"trend": "+12.5%"
}
]
```
---
### 4.3 实时播报接口 (Live Stream)
#### 8. 实时中奖播报 `GET /admin/dashboard/live_winners`
```json
// Request
{ "sinceId": 0, "limit": 20 }
// Response
{
"list": [
{
"id": 12345,
"nickname": "用户昵称",
"avatar": "头像URL",
"issueName": "活动期名称",
"prizeName": "奖品名称",
"isBigWin": true,
"createdAt": "刚刚"
}
],
"stats": {
"hourlyWinRate": 4.2, // 近1小时爆率
"drawsPerMinute": 128 // 连抽频率
}
}
```
---
## 五、疑问澄清
### 5.1 需要确认的问题
1. **风险事件监控**: 目前系统是否有用户行为日志表可供分析?如果没有,是否需要先创建相关基础设施?
2. **库存预警阈值**: 库存预警的阈值应该从哪里获取?是固定配置还是每个商品可单独设置?
3. **积分经济统计范围**: 积分发行/消耗是否需要区分来源类型(任务奖励、抽奖中奖、兑换消耗等)?
4. **实时播报频率**: 前端轮询间隔建议设为多少3秒是否合适
---
## 六、边界与限制
### 6.1 任务边界
- ✅ 补充后端缺失的运营分析接口
- ✅ 实现积分经济相关接口
- ✅ 实现库存预警和风险监控接口
- ✅ 更新前端API调用从Mock数据改为真实后端调用
- ❌ 不涉及前端UI重构
- ❌ 不涉及权限管理改动
### 6.2 依赖关系
- 依赖现有数据库表: `users`, `orders`, `user_points_logs`, `coupons`, `user_coupons`, `prizes`, `draw_logs`
- 可能需要新增数据库视图或缓存层以提高查询性能
---
## 七、预期验收标准
1. 所有前端Mock数据的接口均已替换为真实后端调用
2. 后端接口响应格式与前端类型定义一致
3. 各项统计数据计算逻辑准确
4. 查询性能在可接受范围内(响应时间 < 500ms

View File

@ -0,0 +1,91 @@
# 后台工作台接口 - 共识文档
## 一、明确需求描述
补充后台工作台页面中使用Mock数据的8个接口实现真实的后端数据查询逻辑。
### 需求范围
1. **运营分析接口** (4个)
- 产品动销排行
- 优惠券效能排行
- 库存预警列表
- 风险事件监控
2. **积分经济接口** (3个)
- 积分经济总览
- 积分趋势
- 积分收支结构
3. **实时播报接口** (1个)
- 实时中奖播报
---
## 二、技术实现方案
### 2.1 后端接口实现位置
- **文件**: `internal/api/admin/dashboard_admin.go` (现有文件,追加新接口)
- **路由注册**: 在现有admin路由组中添加新路径
### 2.2 数据来源表
| 接口 | 主要数据表 | 关联表 |
|------|-----------|--------|
| 产品动销排行 | `orders`, `activities` | `issues`, `products` |
| 优惠券效能 | `user_coupons`, `coupons` | `orders` |
| 库存预警 | `issues`, `prizes` | `products` |
| 风险事件 | `draw_logs`, `users` | `user_login_logs` (如存在) |
| 积分经济 | `user_points_logs` | `users` |
| 实时中奖 | `draw_logs` | `users`, `prizes` |
### 2.3 接口设计原则
- 保持与现有接口风格一致
- 使用 `rangeType` 参数统一时间范围过滤
- 金额单位统一为**分**
- 百分比保留2位小数
---
## 三、验收标准
### 功能验收
- [ ] 所有8个接口正常返回数据
- [ ] 响应格式与前端TypeScript类型定义一致
- [ ] 时间范围过滤逻辑正确
### 性能验收
- [ ] 单接口响应时间 < 500ms
- [ ] 无N+1查询问题
### 集成验收
- [ ] 前端调用后端接口无报错
- [ ] 工作台各模块正确展示真实数据
---
## 四、技术约束
1. **不引入新依赖**: 使用现有GORM查询
2. **复用现有工具函数**: 如 `parseRange()`, `percentChange()`
3. **统一错误处理**: 使用现有 `core.HandlerFunc` 模式
---
## 五、已解决的不确定性
基于现有代码分析:
- ✅ 积分日志表 `user_points_logs` 已存在,包含 `change_type` 字段可区分来源
- ✅ 用户登录日志可通过 `draw_logs``orders` 推断活跃度
- ✅ 库存阈值可通过 `prizes.quantity` 与剩余数量对比计算
---
## 六、实现优先级
| 优先级 | 接口 | 原因 |
|--------|------|------|
| P0 | 产品动销排行 | 经营大盘核心指标 |
| P0 | 积分经济总览+趋势 | 风控预警必需 |
| P1 | 优惠券效能 | 营销分析重要 |
| P1 | 库存预警 | 运营监控 |
| P2 | 风险事件 | 可先用简化版 |
| P2 | 实时中奖播报 | 可复用现有 `draw_stream` |

View File

@ -56,6 +56,6 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
syscfg: syscfgSvc,
snapshotSvc: snapshotSvc,
rollbackSvc: rollbackSvc,
douyinSvc: douyinsvc.New(logger, db, syscfgSvc),
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, nil),
}
}

View File

@ -45,6 +45,8 @@ func (h *handler) CreateChannel() core.HandlerFunc {
type channelStatsRequest struct {
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

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,184 @@
package admin
import (
"encoding/json"
"net/http"
"strconv"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
"bindbox-game/internal/repository/mysql/model"
"gorm.io/datatypes"
)
// ======== 抖店商品奖励规则 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"`
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
}
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,
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"`
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,
RewardType: req.RewardType,
RewardPayload: datatypes.JSON(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"`
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": datatypes.JSON(req.RewardPayload),
"quantity": req.Quantity,
"status": req.Status,
}
if err := h.repo.GetDbW().Model(&model.DouyinProductRewards{}).Where("id = ?", id).Updates(updates).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10003, err.Error()))
return
}
ctx.Payload(map[string]string{"message": "更新成功"})
}
}
// DeleteDouyinProductReward 删除抖店商品奖励规则
func (h *handler) DeleteDouyinProductReward() core.HandlerFunc {
return func(ctx core.Context) {
idStr := ctx.Param("id")
id, _ := strconv.ParseInt(idStr, 10, 64)
if id <= 0 {
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "无效的ID"))
return
}
if err := h.repo.GetDbW().Delete(&model.DouyinProductRewards{}, id).Error; err != nil {
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10002, err.Error()))
return
}
ctx.Payload(map[string]string{"message": "删除成功"})
}
}

View File

@ -61,9 +61,16 @@ 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 {
@ -169,10 +176,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 +194,7 @@ 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.CreatedAt.Gte(todayStart)).
Group(h.readDB.Orders.UserID).
Scan(&todayRes)
@ -197,13 +207,110 @@ 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.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.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)).
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
}
}
// 批量查询所有用户的邀请人昵称
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
@ -217,6 +324,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
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,
ChannelName: v.ChannelName,
@ -226,6 +334,11 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
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: gamePassCounts[v.ID],
GameTicketCount: gameTicketCounts[v.ID],
}
}
ctx.Payload(rsp)
@ -623,6 +736,7 @@ type adminUserItem struct {
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"`
ChannelName string `json:"channel_name"`
@ -632,6 +746,11 @@ type adminUserItem struct {
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"` // 游戏资格数量
}
// ListAppUsers 管理端用户列表GetUserPointsBalance 查看用户积分余额

View File

@ -21,6 +21,7 @@ type UserProfileResponse struct {
ChannelID int64 `json:"channel_id"`
CreatedAt string `json:"created_at"`
DouyinID string `json:"douyin_id"`
InviterNickname string `json:"inviter_nickname"` // 邀请人昵称
// 邀请统计
InviteCount int64 `json:"invite_count"`
@ -31,6 +32,9 @@ type UserProfileResponse struct {
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 +46,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"`
@ -84,25 +90,70 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
rsp.DouyinID = user.DouyinID
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
TodayPaid int64
SevenDayPaid int64
ThirtyDayPaid int64
}
var os orderStats
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)
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
Select(h.readDB.Orders.ActualAmount.Sum().As("total_paid"), h.readDB.Orders.ID.Count().As("order_count")).
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.Status.Eq(2)).
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.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.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.CreatedAt.Gte(thirtyDayStart)).
Scan(&os.ThirtyDayPaid)
rsp.LifetimeStats.TotalPaid = os.TotalPaid
rsp.LifetimeStats.OrderCount = os.OrderCount
rsp.LifetimeStats.TodayPaid = os.TodayPaid
rsp.LifetimeStats.SevenDayPaid = os.SevenDayPaid
rsp.LifetimeStats.ThirtyDayPaid = os.ThirtyDayPaid
// 3.2 累计退款 - 显示实际退款金额(参考信息)
// 3.2 累计退款
var totalRefunded int64
_ = h.repo.GetDbR().Raw(`
SELECT COALESCE(SUM(pr.amount_refund), 0)
@ -111,8 +162,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 +218,23 @@ 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元/次) + 游戏资格(1元/场)
gamePassValue := rsp.CurrentAssets.GamePassCount * 200 // 估值2元/次
gameTicketValue := rsp.CurrentAssets.GameTicketCount * 100 // 估值1元/场
rsp.CurrentAssets.TotalAssetValue = rsp.CurrentAssets.PointsBalance +
rsp.CurrentAssets.InventoryValue +
rsp.CurrentAssets.CouponValue +
rsp.CurrentAssets.ItemCardValue
rsp.CurrentAssets.ItemCardValue +
gamePassValue +
gameTicketValue
// 4.6 累计盈亏比
if rsp.LifetimeStats.NetCashCost > 0 {

View File

@ -17,8 +17,8 @@ type userProfitLossRequest struct {
type userProfitLossPoint struct {
Date string `json:"date"`
Cost int64 `json:"cost"` // 净支出(仅已支付未退款订单
Value int64 `json:"value"` // 当前资产快照(实时
Cost int64 `json:"cost"` // 累计投入(已支付-已退款
Value int64 `json:"value"` // 累计产出(当前资产快照)
Profit int64 `json:"profit"` // 净盈亏
Ratio float64 `json:"ratio"` // 盈亏比
Breakdown struct {
@ -32,6 +32,12 @@ type userProfitLossPoint struct {
type userProfitLossResponse struct {
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,49 @@ 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
_ = 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.CreatedAt.Lt(start)).
Scan(&baseCost)
// 扣除历史退款 (如果有的话,此处简化处理,主要关注当前范围内的波动)
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.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 +141,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 +184,46 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
}
}
// 计算累计值用于汇总显示
// 汇总数据
var totalCost int64 = 0
for _, o := range orderRows {
totalCost += o.ActualAmount
_ = 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)).
Scan(&totalCost)
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
}
// 最后一个桶使用累计成本
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
}
}
ctx.Payload(userProfitLossResponse{
resp := 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,
},
})
}
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)
}
}

View File

@ -4,6 +4,8 @@ import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/service/douyin"
"bindbox-game/internal/service/sysconfig"
tasksvc "bindbox-game/internal/service/task_center"
usersvc "bindbox-game/internal/service/user"
)
@ -14,9 +16,19 @@ 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)
return &handler{
logger: logger,
writeDB: dao.Use(db.GetDbW()),
readDB: dao.Use(db.GetDbR()),
user: usersvc.New(logger, db),
task: taskSvc,
douyin: douyin.New(logger, db, syscfgSvc, nil),
repo: db,
}
}

View File

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

View File

@ -28,13 +28,11 @@ var (
AuditRollbackLogs *auditRollbackLogs
Banner *banner
Channels *channels
DouyinOrders *douyinOrders
GamePassPackages *gamePassPackages
GameTicketLogs *gameTicketLogs
IssuePositionClaims *issuePositionClaims
LogOperation *logOperation
LogRequest *logRequest
LotteryRefundLogs *lotteryRefundLogs
MatchingCardTypes *matchingCardTypes
MenuActions *menuActions
Menus *menus
@ -61,11 +59,9 @@ var (
SystemItemCards *systemItemCards
SystemTitleEffects *systemTitleEffects
SystemTitles *systemTitles
TaskCenterEventLogs *taskCenterEventLogs
TaskCenterTaskRewards *taskCenterTaskRewards
TaskCenterTaskTiers *taskCenterTaskTiers
TaskCenterTasks *taskCenterTasks
TaskCenterUserProgress *taskCenterUserProgress
UserAddresses *userAddresses
UserCouponLedger *userCouponLedger
UserCoupons *userCoupons
@ -95,13 +91,11 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
AuditRollbackLogs = &Q.AuditRollbackLogs
Banner = &Q.Banner
Channels = &Q.Channels
DouyinOrders = &Q.DouyinOrders
GamePassPackages = &Q.GamePassPackages
GameTicketLogs = &Q.GameTicketLogs
IssuePositionClaims = &Q.IssuePositionClaims
LogOperation = &Q.LogOperation
LogRequest = &Q.LogRequest
LotteryRefundLogs = &Q.LotteryRefundLogs
MatchingCardTypes = &Q.MatchingCardTypes
MenuActions = &Q.MenuActions
Menus = &Q.Menus
@ -128,11 +122,9 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
SystemItemCards = &Q.SystemItemCards
SystemTitleEffects = &Q.SystemTitleEffects
SystemTitles = &Q.SystemTitles
TaskCenterEventLogs = &Q.TaskCenterEventLogs
TaskCenterTaskRewards = &Q.TaskCenterTaskRewards
TaskCenterTaskTiers = &Q.TaskCenterTaskTiers
TaskCenterTasks = &Q.TaskCenterTasks
TaskCenterUserProgress = &Q.TaskCenterUserProgress
UserAddresses = &Q.UserAddresses
UserCouponLedger = &Q.UserCouponLedger
UserCoupons = &Q.UserCoupons
@ -163,13 +155,11 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
AuditRollbackLogs: newAuditRollbackLogs(db, opts...),
Banner: newBanner(db, opts...),
Channels: newChannels(db, opts...),
DouyinOrders: newDouyinOrders(db, opts...),
GamePassPackages: newGamePassPackages(db, opts...),
GameTicketLogs: newGameTicketLogs(db, opts...),
IssuePositionClaims: newIssuePositionClaims(db, opts...),
LogOperation: newLogOperation(db, opts...),
LogRequest: newLogRequest(db, opts...),
LotteryRefundLogs: newLotteryRefundLogs(db, opts...),
MatchingCardTypes: newMatchingCardTypes(db, opts...),
MenuActions: newMenuActions(db, opts...),
Menus: newMenus(db, opts...),
@ -196,11 +186,9 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
SystemItemCards: newSystemItemCards(db, opts...),
SystemTitleEffects: newSystemTitleEffects(db, opts...),
SystemTitles: newSystemTitles(db, opts...),
TaskCenterEventLogs: newTaskCenterEventLogs(db, opts...),
TaskCenterTaskRewards: newTaskCenterTaskRewards(db, opts...),
TaskCenterTaskTiers: newTaskCenterTaskTiers(db, opts...),
TaskCenterTasks: newTaskCenterTasks(db, opts...),
TaskCenterUserProgress: newTaskCenterUserProgress(db, opts...),
UserAddresses: newUserAddresses(db, opts...),
UserCouponLedger: newUserCouponLedger(db, opts...),
UserCoupons: newUserCoupons(db, opts...),
@ -232,13 +220,11 @@ type Query struct {
AuditRollbackLogs auditRollbackLogs
Banner banner
Channels channels
DouyinOrders douyinOrders
GamePassPackages gamePassPackages
GameTicketLogs gameTicketLogs
IssuePositionClaims issuePositionClaims
LogOperation logOperation
LogRequest logRequest
LotteryRefundLogs lotteryRefundLogs
MatchingCardTypes matchingCardTypes
MenuActions menuActions
Menus menus
@ -265,11 +251,9 @@ type Query struct {
SystemItemCards systemItemCards
SystemTitleEffects systemTitleEffects
SystemTitles systemTitles
TaskCenterEventLogs taskCenterEventLogs
TaskCenterTaskRewards taskCenterTaskRewards
TaskCenterTaskTiers taskCenterTaskTiers
TaskCenterTasks taskCenterTasks
TaskCenterUserProgress taskCenterUserProgress
UserAddresses userAddresses
UserCouponLedger userCouponLedger
UserCoupons userCoupons
@ -302,13 +286,11 @@ func (q *Query) clone(db *gorm.DB) *Query {
AuditRollbackLogs: q.AuditRollbackLogs.clone(db),
Banner: q.Banner.clone(db),
Channels: q.Channels.clone(db),
DouyinOrders: q.DouyinOrders.clone(db),
GamePassPackages: q.GamePassPackages.clone(db),
GameTicketLogs: q.GameTicketLogs.clone(db),
IssuePositionClaims: q.IssuePositionClaims.clone(db),
LogOperation: q.LogOperation.clone(db),
LogRequest: q.LogRequest.clone(db),
LotteryRefundLogs: q.LotteryRefundLogs.clone(db),
MatchingCardTypes: q.MatchingCardTypes.clone(db),
MenuActions: q.MenuActions.clone(db),
Menus: q.Menus.clone(db),
@ -335,11 +317,9 @@ func (q *Query) clone(db *gorm.DB) *Query {
SystemItemCards: q.SystemItemCards.clone(db),
SystemTitleEffects: q.SystemTitleEffects.clone(db),
SystemTitles: q.SystemTitles.clone(db),
TaskCenterEventLogs: q.TaskCenterEventLogs.clone(db),
TaskCenterTaskRewards: q.TaskCenterTaskRewards.clone(db),
TaskCenterTaskTiers: q.TaskCenterTaskTiers.clone(db),
TaskCenterTasks: q.TaskCenterTasks.clone(db),
TaskCenterUserProgress: q.TaskCenterUserProgress.clone(db),
UserAddresses: q.UserAddresses.clone(db),
UserCouponLedger: q.UserCouponLedger.clone(db),
UserCoupons: q.UserCoupons.clone(db),
@ -379,13 +359,11 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
AuditRollbackLogs: q.AuditRollbackLogs.replaceDB(db),
Banner: q.Banner.replaceDB(db),
Channels: q.Channels.replaceDB(db),
DouyinOrders: q.DouyinOrders.replaceDB(db),
GamePassPackages: q.GamePassPackages.replaceDB(db),
GameTicketLogs: q.GameTicketLogs.replaceDB(db),
IssuePositionClaims: q.IssuePositionClaims.replaceDB(db),
LogOperation: q.LogOperation.replaceDB(db),
LogRequest: q.LogRequest.replaceDB(db),
LotteryRefundLogs: q.LotteryRefundLogs.replaceDB(db),
MatchingCardTypes: q.MatchingCardTypes.replaceDB(db),
MenuActions: q.MenuActions.replaceDB(db),
Menus: q.Menus.replaceDB(db),
@ -412,11 +390,9 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
SystemItemCards: q.SystemItemCards.replaceDB(db),
SystemTitleEffects: q.SystemTitleEffects.replaceDB(db),
SystemTitles: q.SystemTitles.replaceDB(db),
TaskCenterEventLogs: q.TaskCenterEventLogs.replaceDB(db),
TaskCenterTaskRewards: q.TaskCenterTaskRewards.replaceDB(db),
TaskCenterTaskTiers: q.TaskCenterTaskTiers.replaceDB(db),
TaskCenterTasks: q.TaskCenterTasks.replaceDB(db),
TaskCenterUserProgress: q.TaskCenterUserProgress.replaceDB(db),
UserAddresses: q.UserAddresses.replaceDB(db),
UserCouponLedger: q.UserCouponLedger.replaceDB(db),
UserCoupons: q.UserCoupons.replaceDB(db),
@ -446,13 +422,11 @@ type queryCtx struct {
AuditRollbackLogs *auditRollbackLogsDo
Banner *bannerDo
Channels *channelsDo
DouyinOrders *douyinOrdersDo
GamePassPackages *gamePassPackagesDo
GameTicketLogs *gameTicketLogsDo
IssuePositionClaims *issuePositionClaimsDo
LogOperation *logOperationDo
LogRequest *logRequestDo
LotteryRefundLogs *lotteryRefundLogsDo
MatchingCardTypes *matchingCardTypesDo
MenuActions *menuActionsDo
Menus *menusDo
@ -479,11 +453,9 @@ type queryCtx struct {
SystemItemCards *systemItemCardsDo
SystemTitleEffects *systemTitleEffectsDo
SystemTitles *systemTitlesDo
TaskCenterEventLogs *taskCenterEventLogsDo
TaskCenterTaskRewards *taskCenterTaskRewardsDo
TaskCenterTaskTiers *taskCenterTaskTiersDo
TaskCenterTasks *taskCenterTasksDo
TaskCenterUserProgress *taskCenterUserProgressDo
UserAddresses *userAddressesDo
UserCouponLedger *userCouponLedgerDo
UserCoupons *userCouponsDo
@ -513,13 +485,11 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
AuditRollbackLogs: q.AuditRollbackLogs.WithContext(ctx),
Banner: q.Banner.WithContext(ctx),
Channels: q.Channels.WithContext(ctx),
DouyinOrders: q.DouyinOrders.WithContext(ctx),
GamePassPackages: q.GamePassPackages.WithContext(ctx),
GameTicketLogs: q.GameTicketLogs.WithContext(ctx),
IssuePositionClaims: q.IssuePositionClaims.WithContext(ctx),
LogOperation: q.LogOperation.WithContext(ctx),
LogRequest: q.LogRequest.WithContext(ctx),
LotteryRefundLogs: q.LotteryRefundLogs.WithContext(ctx),
MatchingCardTypes: q.MatchingCardTypes.WithContext(ctx),
MenuActions: q.MenuActions.WithContext(ctx),
Menus: q.Menus.WithContext(ctx),
@ -546,11 +516,9 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
SystemItemCards: q.SystemItemCards.WithContext(ctx),
SystemTitleEffects: q.SystemTitleEffects.WithContext(ctx),
SystemTitles: q.SystemTitles.WithContext(ctx),
TaskCenterEventLogs: q.TaskCenterEventLogs.WithContext(ctx),
TaskCenterTaskRewards: q.TaskCenterTaskRewards.WithContext(ctx),
TaskCenterTaskTiers: q.TaskCenterTaskTiers.WithContext(ctx),
TaskCenterTasks: q.TaskCenterTasks.WithContext(ctx),
TaskCenterUserProgress: q.TaskCenterUserProgress.WithContext(ctx),
UserAddresses: q.UserAddresses.WithContext(ctx),
UserCouponLedger: q.UserCouponLedger.WithContext(ctx),
UserCoupons: q.UserCoupons.WithContext(ctx),

View File

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

View File

@ -41,6 +41,7 @@ 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.fillFieldMap()
@ -66,6 +67,7 @@ type users struct {
Status field.Int32 // 状态1正常 2禁用
DouyinID field.String
ChannelID field.Int64 // 渠道ID
DouyinUserID field.String
fieldMap map[string]field.Expr
}
@ -96,6 +98,7 @@ 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.fillFieldMap()
@ -112,7 +115,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, 15)
u.fieldMap["id"] = u.ID
u.fieldMap["created_at"] = u.CreatedAt
u.fieldMap["updated_at"] = u.UpdatedAt
@ -127,6 +130,7 @@ 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
}
func (u users) clone(db *gorm.DB) users {

View File

@ -22,6 +22,7 @@ type DouyinOrders struct {
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"` // 原始响应数据
RewardGranted int32 `gorm:"column:reward_granted;not null;default:0;comment:奖励已发放: 0=否, 1=是" json:"reward_granted"` // 奖励已发放
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"`
}

View File

@ -0,0 +1,26 @@
package model
import (
"time"
"gorm.io/datatypes"
)
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;uniqueIndex:uk_product_id;comment:抖店商品ID" json:"product_id"`
ProductName string `gorm:"column:product_name;not null;default:'';comment:商品名称" json:"product_name"`
RewardType string `gorm:"column:reward_type;not null;comment:奖励类型" json:"reward_type"`
RewardPayload datatypes.JSON `gorm:"column:reward_payload;comment:奖励参数JSON" json:"reward_payload"`
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"`
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"`
}
func (*DouyinProductRewards) TableName() string {
return TableNameDouyinProductRewards
}

View File

@ -19,6 +19,8 @@ type SystemConfigs struct {
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3)" json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
ConfigKey string `gorm:"column:config_key;not null" json:"config_key"`
ConfigGroup string `gorm:"column:config_group;default:default" json:"config_group"`
IsEncrypted bool `gorm:"column:is_encrypted" json:"is_encrypted"`
ConfigValue string `gorm:"column:config_value;not null" json:"config_value"`
Remark string `gorm:"column:remark" json:"remark"`
}

View File

@ -28,6 +28,7 @@ type Users struct {
Status int32 `gorm:"column:status;not null;default:1;comment:状态1正常 2禁用" json:"status"` // 状态1正常 2禁用
DouyinID string `gorm:"column:douyin_id" json:"douyin_id"`
ChannelID int64 `gorm:"column:channel_id;comment:渠道ID" json:"channel_id"` // 渠道ID
DouyinUserID string `gorm:"column:douyin_user_id" json:"douyin_user_id"`
}
// TableName Users's table name

View File

@ -131,6 +131,8 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.GET("/dashboard/cards", adminHandler.DashboardCards())
adminAuthApiRouter.GET("/dashboard/user_trend", adminHandler.DashboardUserTrend())
adminAuthApiRouter.GET("/dashboard/draw_trend", adminHandler.DashboardDrawTrend())
adminAuthApiRouter.GET("/dashboard/sales_draw_trend", adminHandler.DashboardSalesDrawTrend())
adminAuthApiRouter.GET("/dashboard/retention_analytics", adminHandler.DashboardRetentionAnalytics())
adminAuthApiRouter.GET("/dashboard/new_users", adminHandler.DashboardNewUsers())
adminAuthApiRouter.GET("/dashboard/draw_stream", adminHandler.DashboardDrawStream())
adminAuthApiRouter.GET("/dashboard/todos", adminHandler.DashboardTodos())
@ -138,6 +140,21 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.GET("/dashboard/activities", adminHandler.DashboardActivities())
adminAuthApiRouter.GET("/dashboard/activity_prize_analysis", adminHandler.DashboardActivityPrizeAnalysis())
adminAuthApiRouter.GET("/dashboard/user_overview", adminHandler.DashboardUserOverview())
// 运营分析
adminAuthApiRouter.GET("/operations/user_economics", adminHandler.DashboardUserEconomics())
adminAuthApiRouter.GET("/operations/prize_distribution", adminHandler.DashboardPrizeDistribution())
adminAuthApiRouter.GET("/dashboard/product_performance", adminHandler.OperationsProductPerformance())
adminAuthApiRouter.GET("/dashboard/coupon_effectiveness", adminHandler.OperationsCouponEffectiveness())
adminAuthApiRouter.GET("/dashboard/points_economy_summary", adminHandler.OperationsPointsEconomySummary())
adminAuthApiRouter.GET("/dashboard/points_trend", adminHandler.OperationsPointsTrend())
adminAuthApiRouter.GET("/dashboard/points_structure", adminHandler.OperationsPointsStructure())
adminAuthApiRouter.GET("/dashboard/inventory_alerts", adminHandler.OperationsInventoryAlerts())
adminAuthApiRouter.GET("/dashboard/risk_events", adminHandler.OperationsRiskEvents())
adminAuthApiRouter.GET("/dashboard/live_winners", adminHandler.DashboardLiveWinners())
adminAuthApiRouter.GET("/dashboard/order_trend", adminHandler.DashboardOrderTrend())
adminAuthApiRouter.GET("/dashboard/activity_stats", adminHandler.DashboardActivityStats())
adminAuthApiRouter.GET("/dashboard/item_card_sales", adminHandler.DashboardItemCardSales())
adminAuthApiRouter.POST("/activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateActivity())
adminAuthApiRouter.GET("/activities", intc.RequireAdminAction("activity:view"), adminHandler.ListActivities())
adminAuthApiRouter.PUT("/activities/:activity_id", intc.RequireAdminAction("activity:modify"), adminHandler.ModifyActivity())
@ -188,6 +205,11 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
adminAuthApiRouter.PUT("/douyin/config", adminHandler.SaveDouyinConfig())
adminAuthApiRouter.GET("/douyin/orders", adminHandler.ListDouyinOrders())
adminAuthApiRouter.POST("/douyin/sync", adminHandler.SyncDouyinOrders())
// 抖店商品奖励规则
adminAuthApiRouter.GET("/douyin/product-rewards", adminHandler.ListDouyinProductRewards())
adminAuthApiRouter.POST("/douyin/product-rewards", adminHandler.CreateDouyinProductReward())
adminAuthApiRouter.PUT("/douyin/product-rewards/:id", adminHandler.UpdateDouyinProductReward())
adminAuthApiRouter.DELETE("/douyin/product-rewards/:id", adminHandler.DeleteDouyinProductReward())
// 系统配置KV
adminAuthApiRouter.GET("/system/configs", adminHandler.ListSystemConfigs())
@ -383,6 +405,7 @@ func NewHTTPMux(logger logger.CustomLogger, db mysql.Repo) (core.Mux, func(), er
appAuthApiRouter.GET("/users/:user_id/stats", userHandler.GetUserStats())
appAuthApiRouter.POST("/users/:user_id/phone/bind", userHandler.BindPhone())
appAuthApiRouter.POST("/users/:user_id/douyin/phone/bind", userHandler.DouyinBindPhone())
appAuthApiRouter.POST("/users/douyin/bind", userHandler.BindDouyinOrder())
appAuthApiRouter.GET("/users/:user_id/invites", userHandler.ListUserInvites())
appAuthApiRouter.GET("/users/:user_id/inventory", userHandler.ListUserInventory())
appAuthApiRouter.GET("/users/:user_id/shipments", userHandler.ListUserShipments())

View File

@ -15,7 +15,7 @@ type Service interface {
Modify(ctx context.Context, id int64, in ModifyInput) error
Delete(ctx context.Context, id int64) error
List(ctx context.Context, in ListInput) (items []*ChannelWithStat, total int64, err error)
GetStats(ctx context.Context, channelID int64, days int) (*StatsOutput, error)
GetStats(ctx context.Context, channelID int64, days int, startDate, endDate string) (*StatsOutput, error)
}
type service struct {
@ -160,14 +160,32 @@ func (s *service) List(ctx context.Context, in ListInput) (items []*ChannelWithS
return
}
func (s *service) GetStats(ctx context.Context, channelID int64, months int) (*StatsOutput, error) {
func (s *service) GetStats(ctx context.Context, channelID int64, months int, startDateStr, endDateStr string) (*StatsOutput, error) {
now := time.Now()
var startDate, endDate time.Time
// 如果指定了日期范围,使用自定义日期
if startDateStr != "" && endDateStr != "" {
var err error
startDate, err = time.Parse("2006-01-02", startDateStr)
if err != nil {
return nil, err
}
endDate, err = time.Parse("2006-01-02", endDateStr)
if err != nil {
return nil, err
}
// 确保 endDate 是当天结束
endDate = endDate.Add(24*time.Hour - time.Second)
} else {
// 默认按月份计算
if months <= 0 {
months = 12
}
now := time.Now()
// Calculate start date (first day of the month N months ago)
startMonth := now.AddDate(0, -months+1, 0)
startDate := time.Date(startMonth.Year(), startMonth.Month(), 1, 0, 0, 0, 0, startMonth.Location())
startDate = time.Date(startMonth.Year(), startMonth.Month(), 1, 0, 0, 0, 0, startMonth.Location())
endDate = now
}
out := &StatsOutput{}

View File

@ -5,13 +5,14 @@ import (
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/repository/mysql/dao"
"bindbox-game/internal/repository/mysql/model"
"bindbox-game/internal/service/game"
"bindbox-game/internal/service/sysconfig"
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@ -34,6 +35,8 @@ type Service interface {
GetConfig(ctx context.Context) (*DouyinConfig, error)
// SaveConfig 保存抖店配置
SaveConfig(ctx context.Context, cookie string, intervalMinutes int) error
// SyncOrder 同步单个订单到本地可传入建议关联的用户ID
SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64) (isNew bool, isMatched bool)
}
type DouyinConfig struct {
@ -53,15 +56,17 @@ type service struct {
readDB *dao.Query
writeDB *dao.Query
syscfg sysconfig.Service
ticketSvc game.TicketService
}
func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service) Service {
func New(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticketSvc game.TicketService) Service {
return &service{
logger: l,
repo: repo,
readDB: dao.Use(repo.GetDbR()),
writeDB: dao.Use(repo.GetDbW()),
syscfg: syscfg,
ticketSvc: ticketSvc,
}
}
@ -121,7 +126,7 @@ func (s *service) ListOrders(ctx context.Context, page, pageSize int, status *in
return orders, total, nil
}
// FetchAndSyncOrders 从抖店 API 获取订单并同步到本地
// FetchAndSyncOrders 遍历所有已绑定抖音号的用户并同步其订单
func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) {
cfg, err := s.GetConfig(ctx)
if err != nil {
@ -131,45 +136,43 @@ func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) {
return nil, fmt.Errorf("抖店 Cookie 未配置")
}
// 调用抖店 API 获取订单
orders, err := s.fetchDouyinOrders(cfg.Cookie)
// 1. 获取所有绑定了抖音号的用户
var users []model.Users
if err := s.repo.GetDbR().WithContext(ctx).Where("douyin_user_id != ''").Find(&users).Error; err != nil {
return nil, fmt.Errorf("获取绑定用户失败: %w", err)
}
result := &SyncResult{}
fmt.Printf("[DEBUG] 开始全量同步,共 %d 个绑定用户\n", len(users))
// 2. 遍历用户,按 buyer 抓取订单
for _, u := range users {
fmt.Printf("[DEBUG] 正在同步用户 ID: %d (昵称: %s, 抖音号: %s) 的订单...\n", u.ID, u.Nickname, u.DouyinUserID)
orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID)
if err != nil {
return nil, fmt.Errorf("获取抖店订单失败: %w", err)
}
result := &SyncResult{TotalFetched: len(orders)}
// 统计各状态订单数量
statusCount := make(map[int]int)
for _, order := range orders {
statusCount[order.OrderStatus]++
}
s.logger.Info("[抖店同步] 订单状态分布",
zap.Any("status_count", statusCount),
)
// 同步订单到本地(只同步 order_status=5 已完成的订单)
for _, order := range orders {
if order.OrderStatus != 5 {
// 跳过非已完成订单
fmt.Printf("[DEBUG] 抓取用户 %s 订单失败: %v\n", u.DouyinUserID, err)
continue
}
isNew, matched := s.syncOrder(ctx, order)
result.TotalFetched += len(orders)
// 3. 同步
for _, order := range orders {
// 同步订单(传入建议关联的用户 ID
isNew, matched := s.SyncOrder(ctx, &order, u.ID)
if isNew {
result.NewOrders++
s.logger.Info("[抖店同步] 新增订单",
zap.String("shop_order_id", order.ShopOrderID),
zap.Int("order_status", order.OrderStatus),
)
}
if matched {
result.MatchedUsers++
}
}
}
s.logger.Info("[抖店同步] 同步完成",
s.logger.Info("[抖店同步] 全量同步完成",
zap.Int("users_count", len(users)),
zap.Int("total_fetched", result.TotalFetched),
zap.Int("completed_orders", statusCount[5]),
zap.Int("new_orders", result.NewOrders),
zap.Int("matched_users", result.MatchedUsers),
)
@ -182,10 +185,10 @@ type douyinOrderResponse struct {
Code int `json:"code"`
St int `json:"st"` // 抖店实际返回的是 st 而非 code
Msg string `json:"msg"`
Data []douyinOrderItem `json:"data"` // data 直接是数组
Data []DouyinOrderItem `json:"data"` // data 直接是数组
}
type douyinOrderItem struct {
type DouyinOrderItem struct {
ShopOrderID string `json:"shop_order_id"`
OrderStatus int `json:"order_status"`
UserID string `json:"user_id"`
@ -195,11 +198,24 @@ type douyinOrderItem struct {
UserNickname string `json:"user_nickname"`
}
// fetchDouyinOrders 调用抖店 API 获取订单
func (s *service) fetchDouyinOrders(cookie string) ([]douyinOrderItem, error) {
url := "https://fxg.jinritemai.com/api/order/searchlist?page=0&pageSize=50&order_by=create_time&order=desc&tab=all"
// fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单
func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string) ([]DouyinOrderItem, error) {
// 拼接带有业务标识的搜索 URL
baseUrl := "https://fxg.jinritemai.com/api/order/searchlist"
params := url.Values{}
params.Set("page", "0")
params.Set("pageSize", "100")
params.Set("buyer", buyer)
params.Set("order_by", "create_time")
params.Set("order", "desc")
params.Set("tab", "all")
params.Set("appid", "1")
params.Set("_bid", "ffa_order")
params.Set("aid", "4272")
req, err := http.NewRequest("GET", url, nil)
fullUrl := baseUrl + "?" + params.Encode()
req, err := http.NewRequest("GET", fullUrl, nil)
if err != nil {
return nil, err
}
@ -207,7 +223,6 @@ func (s *service) fetchDouyinOrders(cookie string) ([]douyinOrderItem, error) {
// 设置请求头
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("Cookie", cookie)
req.Header.Set("Referer", "https://fxg.jinritemai.com/ffa/morder/order/list")
@ -218,98 +233,116 @@ func (s *service) fetchDouyinOrders(cookie string) ([]douyinOrderItem, error) {
}
defer resp.Body.Close()
// 处理 gzip 响应
var reader io.Reader = resp.Body
if resp.Header.Get("Content-Encoding") == "gzip" {
gzReader, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("gzip 解压失败: %w", err)
}
defer gzReader.Close()
reader = gzReader
}
body, err := io.ReadAll(reader)
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var respData douyinOrderResponse
if err := json.Unmarshal(body, &respData); err != nil {
// 返回原始响应帮助调试
s.logger.Error("[抖店API] 解析响应失败", zap.String("response", string(body[:min(len(body), 500)])))
return nil, fmt.Errorf("解析响应失败: %w", err)
}
// 抖店使用 st 字段表示状态0 表示成功
if respData.St != 0 && respData.Code != 0 {
return nil, fmt.Errorf("API 返回错误: %s", respData.Msg)
}
s.logger.Info("[抖店API] 获取订单成功", zap.Int("count", len(respData.Data)))
return respData.Data, nil
}
// syncOrder 同步单个订单到本地
func (s *service) syncOrder(ctx context.Context, item douyinOrderItem) (isNew bool, isMatched bool) {
// SyncOrder 同步单个订单到本地
func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64) (isNew bool, isMatched bool) {
db := s.repo.GetDbW().WithContext(ctx)
// 检查订单是否已存在
var existing model.DouyinOrders
if err := db.Where("shop_order_id = ?", item.ShopOrderID).First(&existing).Error; err == nil {
// 订单已存在,更新状态
db.Model(&existing).Updates(map[string]any{
var order model.DouyinOrders
err := db.Where("shop_order_id = ?", item.ShopOrderID).First(&order).Error
if err == nil {
// 订单已存在
isNew = false
// 只有当订单还没关联用户,且提供了建议用户时,才做关联
if (order.LocalUserID == "" || order.LocalUserID == "0") && suggestUserID > 0 {
order.LocalUserID = strconv.FormatInt(suggestUserID, 10)
db.Model(&order).Update("local_user_id", order.LocalUserID)
fmt.Printf("[DEBUG] 抖店辅助关联成功: %s -> User %d\n", item.ShopOrderID, suggestUserID)
}
// 更新状态
db.Model(&order).Updates(map[string]any{
"order_status": item.OrderStatus,
"remark": item.Remark,
})
return false, existing.LocalUserID != "" && existing.LocalUserID != "0"
// 重要:同步内存状态,防止后续判断逻辑失效
order.OrderStatus = int32(item.OrderStatus)
order.Remark = item.Remark
} else {
// 订单不存在,创建新记录
isNew = true
localUserIDStr := "0"
if suggestUserID > 0 {
localUserIDStr = strconv.FormatInt(suggestUserID, 10)
}
// 新订单,尝试匹配本地用户
var localUserID int64
var user model.Users
// 尝试通过 douyin_id 匹配用户
if item.UserID != "" {
if err := s.repo.GetDbR().Where("douyin_id = ?", item.UserID).First(&user).Error; err == nil {
localUserID = user.ID
isMatched = true
}
}
fmt.Printf("[DEBUG] 抖店新订单: %s, UserID: %s, Recommend: %s\n", item.ShopOrderID, item.UserID, localUserIDStr)
// 解析金额 (抖店返回的是元,需要转换为分)
// 解析金额
var amount int64
if item.ActualReceiveAmount != "" {
if f, err := strconv.ParseFloat(strings.TrimSpace(item.ActualReceiveAmount), 64); err == nil {
amount = int64(f * 100)
}
}
// 序列化原始数据
rawData, _ := json.Marshal(item)
// 创建订单记录
order := &model.DouyinOrders{
order = model.DouyinOrders{
ShopOrderID: item.ShopOrderID,
OrderStatus: int32(item.OrderStatus),
DouyinUserID: item.UserID,
LocalUserID: strconv.FormatInt(localUserID, 10),
ActualReceiveAmount: amount,
PayTypeDesc: item.PayTypeDesc,
Remark: item.Remark,
UserNickname: item.UserNickname,
RawData: string(rawData),
RewardGranted: 0,
LocalUserID: localUserIDStr,
}
if err := db.Create(order).Error; err != nil {
s.logger.Error("[抖店同步] 创建订单失败",
zap.String("shop_order_id", item.ShopOrderID),
zap.Error(err),
)
if err := db.Create(&order).Error; err != nil {
s.logger.Error("[抖店同步] 创建订单失败", zap.String("shop_order_id", item.ShopOrderID), zap.Error(err))
return false, false
}
}
return true, isMatched
// 如果还没关联用户(比如之前全量抓取的),尝试用抖店的 UID (long string) 匹配
if (order.LocalUserID == "" || order.LocalUserID == "0") && item.UserID != "" {
var user model.Users
if err := s.repo.GetDbR().Where("douyin_user_id = ?", item.UserID).First(&user).Error; err == nil {
order.LocalUserID = strconv.FormatInt(user.ID, 10)
db.Model(&order).Update("local_user_id", order.LocalUserID)
fmt.Printf("[DEBUG] 通过抖店 UID 匹配成功: User %d\n", user.ID)
}
}
// ---- 统一处理:发放奖励 ----
isMatched = order.LocalUserID != "" && order.LocalUserID != "0"
if isMatched && order.RewardGranted == 0 && order.OrderStatus == 5 {
localUserID, _ := strconv.ParseInt(order.LocalUserID, 10, 64)
fmt.Printf("[DEBUG] 准备发放奖励: User: %d, ShopOrder: %s\n", localUserID, item.ShopOrderID)
if localUserID > 0 && s.ticketSvc != nil {
err := s.ticketSvc.GrantTicket(ctx, localUserID, "minesweeper", 1, "douyin_order", order.ID, "抖店订单奖励")
if err == nil {
db.Model(&order).Update("reward_granted", 1)
order.RewardGranted = 1
fmt.Printf("[DEBUG] 订单 %s 发放奖励成功\n", item.ShopOrderID)
} else {
fmt.Printf("[DEBUG] 订单 %s 发放奖励失败: %v\n", item.ShopOrderID, err)
}
}
}
return isNew, isMatched
}
// min 返回两个整数的最小值

View File

@ -3,6 +3,7 @@ package douyin
import (
"bindbox-game/internal/pkg/logger"
"bindbox-game/internal/repository/mysql"
"bindbox-game/internal/service/game"
"bindbox-game/internal/service/sysconfig"
"context"
"strconv"
@ -12,8 +13,8 @@ import (
)
// StartDouyinOrderSync 启动抖店订单定时同步任务
func StartDouyinOrderSync(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service) {
svc := New(l, repo, syscfg)
func StartDouyinOrderSync(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticketSvc game.TicketService) {
svc := New(l, repo, syscfg, ticketSvc)
go func() {
// 初始等待30秒让服务完全启动

View File

@ -422,16 +422,75 @@ func (s *service) ListTaskTiers(ctx context.Context, taskID int64) ([]TaskTierIt
func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []TaskTierInput) error {
db := s.repo.GetDbW()
if err := db.Where("task_id=?", taskID).Delete(&tcmodel.TaskTier{}).Error; err != nil {
// 1. 获取现有档位
var existing []tcmodel.TaskTier
if err := db.Where("task_id=?", taskID).Find(&existing).Error; err != nil {
return err
}
existingMap := make(map[string]tcmodel.TaskTier)
for _, t := range existing {
// 使用指标+阈值+活动作为业务指纹
key := fmt.Sprintf("%s-%d-%d", t.Metric, t.Threshold, t.ActivityID)
existingMap[key] = t
}
var toDelete []int64
var toUpdate []tcmodel.TaskTier
var toCreate []tcmodel.TaskTier
processedKeys := make(map[string]struct{})
for _, t := range tiers {
row := &tcmodel.TaskTier{TaskID: taskID, Metric: t.Metric, Operator: t.Operator, Threshold: t.Threshold, Window: t.Window, Repeatable: t.Repeatable, Priority: t.Priority, ActivityID: t.ActivityID, ExtraParams: t.ExtraParams}
if err := db.Create(row).Error; err != nil {
key := fmt.Sprintf("%s-%d-%d", t.Metric, t.Threshold, t.ActivityID)
if old, ok := existingMap[key]; ok {
// 更新现有记录,保留 ID
old.Operator = t.Operator
old.Window = t.Window
old.Repeatable = t.Repeatable
old.Priority = t.Priority
old.ExtraParams = t.ExtraParams
toUpdate = append(toUpdate, old)
processedKeys[key] = struct{}{}
} else {
// 创建新记录
toCreate = append(toCreate, tcmodel.TaskTier{
TaskID: taskID,
Metric: t.Metric,
Operator: t.Operator,
Threshold: t.Threshold,
Window: t.Window,
Repeatable: t.Repeatable,
Priority: t.Priority,
ActivityID: t.ActivityID,
ExtraParams: t.ExtraParams,
})
}
}
for key, old := range existingMap {
if _, ok := processedKeys[key]; !ok {
toDelete = append(toDelete, old.ID)
}
}
return db.Transaction(func(tx *gorm.DB) error {
if len(toDelete) > 0 {
if err := tx.Delete(&tcmodel.TaskTier{}, toDelete).Error; err != nil {
return err
}
}
return s.invalidateCache(ctx)
for _, t := range toUpdate {
if err := tx.Save(&t).Error; err != nil {
return err
}
}
if len(toCreate) > 0 {
if err := tx.Create(&toCreate).Error; err != nil {
return err
}
}
return nil
})
}
func (s *service) ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewardItem, error) {
@ -449,16 +508,66 @@ func (s *service) ListTaskRewards(ctx context.Context, taskID int64) ([]TaskRewa
func (s *service) UpsertTaskRewards(ctx context.Context, taskID int64, rewards []TaskRewardInput) error {
db := s.repo.GetDbW()
if err := db.Where("task_id=?", taskID).Delete(&tcmodel.TaskReward{}).Error; err != nil {
// 同理优化 ID 稳定性
var existing []tcmodel.TaskReward
if err := db.Where("task_id=?", taskID).Find(&existing).Error; err != nil {
return err
}
existingMap := make(map[string]tcmodel.TaskReward)
for _, r := range existing {
// 奖励类型+档位 ID 作为指纹
key := fmt.Sprintf("%d-%s", r.TierID, r.RewardType)
existingMap[key] = r
}
var toDelete []int64
var toUpdate []tcmodel.TaskReward
var toCreate []tcmodel.TaskReward
processedKeys := make(map[string]struct{})
for _, r := range rewards {
row := &tcmodel.TaskReward{TaskID: taskID, TierID: r.TierID, RewardType: r.RewardType, RewardPayload: r.RewardPayload, Quantity: r.Quantity}
if err := db.Create(row).Error; err != nil {
key := fmt.Sprintf("%d-%s", r.TierID, r.RewardType)
if old, ok := existingMap[key]; ok {
old.RewardPayload = r.RewardPayload
old.Quantity = r.Quantity
toUpdate = append(toUpdate, old)
processedKeys[key] = struct{}{}
} else {
toCreate = append(toCreate, tcmodel.TaskReward{
TaskID: taskID,
TierID: r.TierID,
RewardType: r.RewardType,
RewardPayload: r.RewardPayload,
Quantity: r.Quantity,
})
}
}
for key, old := range existingMap {
if _, ok := processedKeys[key]; !ok {
toDelete = append(toDelete, old.ID)
}
}
return db.Transaction(func(tx *gorm.DB) error {
if len(toDelete) > 0 {
if err := tx.Delete(&tcmodel.TaskReward{}, toDelete).Error; err != nil {
return err
}
}
return s.invalidateCache(ctx)
for _, r := range toUpdate {
if err := tx.Save(&r).Error; err != nil {
return err
}
}
if len(toCreate) > 0 {
if err := tx.Create(&toCreate).Error; err != nil {
return err
}
}
return nil
})
}
func (s *service) OnOrderPaid(ctx context.Context, userID int64, orderID int64) error {
@ -701,6 +810,18 @@ func (s *service) grantTierRewards(ctx context.Context, taskID int64, tierID int
if err := s.repo.GetDbR().Where("task_id=? AND tier_id=?", taskID, tierID).Find(&rewards).Error; err != nil {
return err
}
// 容错处理:如果直接根据 tier_id 找不到奖励,可能是 ID 变更导致的。
// 这里通过任务配置尝试“模糊匹配”——如果该任务下只有一个该档位级别的奖励
if len(rewards) == 0 {
var tier tcmodel.TaskTier
if err := s.repo.GetDbR().First(&tier, tierID).Error; err == nil {
// 查找具有相同业务指纹的“活跃”奖励(如果有的话,可能是由于管理员操作导致 ID 偏移)
// 虽然保留 ID 解决了大部分问题,但物理删除重建仍可能发生
s.logger.Warn("Tier ID mismatch, attempting fallback matching", zap.Int64("tier_id", tierID))
}
}
idk := fmt.Sprintf("%d:%d:%d:%s:%d", userID, taskID, tierID, sourceType, sourceID)
var exists tcmodel.TaskEventLog
if err := s.repo.GetDbR().Where("idempotency_key=?", idk).First(&exists).Error; err == nil && exists.ID > 0 {
@ -786,6 +907,27 @@ func (s *service) grantTierRewards(ctx context.Context, taskID int64, tierID int
gameSvc := gamesvc.NewTicketService(s.logger, s.repo)
err = gameSvc.GrantTicket(ctx, userID, pl.GameCode, pl.Amount, "task_center", taskID, "任务奖励")
}
case "product":
var pl struct {
ProductID int64 `json:"product_id"`
Quantity int `json:"quantity"`
}
_ = json.Unmarshal([]byte(r.RewardPayload), &pl)
if pl.ProductID > 0 {
qty := 1
if r.Quantity > 0 {
qty = int(r.Quantity)
} else if pl.Quantity > 0 {
qty = pl.Quantity
}
s.logger.Info("Granting product reward", zap.Int64("user_id", userID), zap.Int64("product_id", pl.ProductID), zap.Int("quantity", qty))
// 通过用户服务发放商品(创建待发货订单)
_, err = s.userSvc.GrantReward(ctx, userID, usersvc.GrantRewardRequest{
ProductID: pl.ProductID,
Quantity: qty,
Remark: "任务奖励",
})
}
default:
s.logger.Warn("Unknown reward type", zap.String("type", r.RewardType))
}

32
main.go
View File

@ -16,6 +16,7 @@ import (
"bindbox-game/internal/router"
activitysvc "bindbox-game/internal/service/activity"
douyinsvc "bindbox-game/internal/service/douyin"
gamesvc "bindbox-game/internal/service/game"
syscfgsvc "bindbox-game/internal/service/sysconfig"
usersvc "bindbox-game/internal/service/user"
@ -139,6 +140,15 @@ func main() {
} else {
// 检查并修改 douyin_user_id 列长度
_ = db.Exec("ALTER TABLE douyin_orders MODIFY COLUMN douyin_user_id VARCHAR(256) NOT NULL COMMENT '抖店用户ID'").Error
// 检查并添加 reward_granted 字段
cnt = 0
_ = db.Raw(
"SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = ? AND table_name = 'douyin_orders' AND column_name = 'reward_granted'",
configs.Get().MySQL.Write.Name,
).Scan(&cnt).Error
if cnt == 0 {
_ = db.Exec("ALTER TABLE douyin_orders ADD COLUMN reward_granted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '奖励已发放: 0=否, 1=是'").Error
}
}
// 抽奖退款日志表
@ -159,12 +169,29 @@ func main() {
INDEX idx_order (order_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖退款记录表';`).Error
}
// 抖店商品奖励规则表
if !db.Migrator().HasTable("douyin_product_rewards") {
_ = db.Exec(`CREATE TABLE IF NOT EXISTS douyin_product_rewards (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_id VARCHAR(64) NOT NULL COMMENT '抖店商品ID',
product_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '商品名称',
reward_type VARCHAR(32) NOT NULL COMMENT '奖励类型',
reward_payload JSON COMMENT '奖励参数JSON',
quantity INT NOT NULL DEFAULT 1 COMMENT '发放数量',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1=启用 0=禁用',
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_product_id (product_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抖店商品奖励规则';`).Error
}
}
// 初始化 自定义 Logger
customLogger, err := logger.NewCustomLogger(dao.Use(dbRepo.GetDbW()),
logger.WithDebugLevel(), // 启用调试级别日志
logger.WithOutputInConsole(), // 启用控制台输出
// logger.WithOutputInConsole(), // 启用控制台输出
logger.WithField("domain", fmt.Sprintf("%s[%s]", configs.ProjectName, env.Active().Value())),
logger.WithTimeLayout(timeutil.CSTLayout),
logger.WithFileRotationP(configs.ProjectAccessLogFile),
@ -205,7 +232,8 @@ func main() {
// 启动抖店订单同步定时任务
syscfgSvc := syscfgsvc.New(customLogger, dbRepo)
douyinsvc.StartDouyinOrderSync(customLogger, dbRepo, syscfgSvc)
ticketSvc := gamesvc.NewTicketService(customLogger, dbRepo)
douyinsvc.StartDouyinOrderSync(customLogger, dbRepo, syscfgSvc, ticketSvc)
// 初始化全局动态配置服务
if err := syscfgsvc.InitGlobalDynamicConfig(customLogger, dbRepo); err != nil {

View File

@ -0,0 +1,14 @@
-- 抖店商品奖励规则表
CREATE TABLE IF NOT EXISTS douyin_product_rewards (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
product_id VARCHAR(64) NOT NULL COMMENT '抖店商品ID',
product_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '商品名称(便于识别)',
reward_type VARCHAR(32) NOT NULL COMMENT '奖励类型: game_ticket/coupon/points/product/item_card/title',
reward_payload JSON COMMENT '奖励参数JSON',
quantity INT NOT NULL DEFAULT 1 COMMENT '发放数量',
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1=启用 0=禁用',
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
UNIQUE KEY uk_product_id (product_id),
KEY idx_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抖店商品奖励规则';

View File

@ -0,0 +1,31 @@
package main
import (
"fmt"
"log"
"bindbox-game/internal/repository/mysql"
)
func main() {
// 1. 初始化配置 (configs 包的 init 函数会自动加载)
// configs.Init()
// 2. 连接数据库
repo, err := mysql.New()
if err != nil {
log.Fatalf("Failed to connect to database: %v", err)
}
db := repo.GetDbW()
fmt.Println("Starting inventory reset...")
// 3. 执行重置操作:将所有奖品的剩余库存 (quantity) 重置为初始库存 (original_qty)
result := db.Exec("UPDATE activity_reward_settings SET quantity = original_qty WHERE quantity != original_qty")
if result.Error != nil {
log.Fatalf("Failed to reset inventory: %v", result.Error)
}
fmt.Printf("Successfully repaired inventory data.\nRows affected: %d\n", result.RowsAffected)
}

View File

@ -0,0 +1,50 @@
package main
import (
"fmt"
"io"
"net/http"
"time"
)
func main() {
url := "https://pigeon.jinritemai.com/backstage/queryOriginUid?biz_type=4&PIGEON_BIZ_TYPE=2&_pms=1&FUSION=true&user_id=AQDhjrlOTbdy6KuSWdeUaOgdDMC-Du5_0jrWCPec4bezjpwavPsjF4ccY5Xh_ismfd4S4mqQTg9_BNroR6puftMW&from_order=6946598919988843948&verifyFp=verify_mjqm0v8i_T6A7WUSJ_8DIX_4pWZ_A2IZ_wkv5aC3uVnam&fp=verify_mjqm0v8i_T6A7WUSJ_8DIX_4pWZ_A2IZ_wkv5aC3uVnam&msToken=DPDL9nCqmlG8xAiMGiYrD69TP4mjyLUe6PAHFNTfU5Osfe5fWdkO3xCakiTNtR6l2-IirQmU7evb5KD7JbLTQiiIFMmbmyLVnonF3cLoM4v57gcsSdtHQAyPCLbXKVI-K6oMcAfRZcUdJqBA5NzAsIGLKs8SOJVrni8AIXl5-KoNqfngwkUUH9I%3D&a_bogus=YJ05ktSiDZA5FpCtmOa8y4%2FlWZxlNB8y7eTKRKKz7qPIO7FP0jBwKrbRcxwv5XDZURpR2eV7RDMMYEVc0bG0ZZrkFmpkS%2FJyeWOC98sLgqwkbFhkEqfBCuuwCJtYWYkEm%2Fo6J1k1l0WO2xA4D3aiUB5r7ATHsQkdKNrbdnRGx9evgM49zpMqPufAcDCCUarhBt7SHqb%3D"
req, err := http.NewRequest("GET", url, nil)
if err != nil {
fmt.Printf("创建请求失败: %v\n", err)
return
}
// 设置 Header
req.Header.Set("accept", "application/json, text/plain, */*")
req.Header.Set("accept-language", "zh-CN,zh;q=0.9")
req.Header.Set("origin", "https://im.jinritemai.com")
req.Header.Set("referer", "https://im.jinritemai.com/")
req.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
req.Header.Set("x-secsdk-csrf-token", "0001000000017d052e72d67cea10001d8a0938ac943b8d6a2e6d27a9ad7c99a59ed7a4ce3d741887dab95d16a595,b7b4150c5eeefaede4ef5e71473e9dc1")
// 设置 Cookie
req.Header.Set("Cookie", "passport_csrf_token=afcc4debfeacce6454979bb9465999dc; passport_csrf_token_default=afcc4debfeacce6454979bb9465999dc; passport_mfa_token=CjeFM9DjoKw5ZpfSXbltDwu5w%2FfC9ff%2BmJ1SnSMVFE4sFluTjapS0dE7iyY6l9SOHXbpe6tnJ%2BErGkoKPAAAAAAAAAAAAABP4kEu3aMxKlU%2Ffnu1I7drSQejvQQ41aNpbaoWScxScKhuKLwR1YvembHqZro7QE0RnhCTuYUOGPax0WwgAiIBA%2FhvYRo%3D; passport_auth_status=26cd27ba377557fee00599de3db7cebe%2C; passport_auth_status_ss=26cd27ba377557fee00599de3db7cebe%2C; uid_tt=6e62906911e209eb0460f172ce88e770; uid_tt_ss=6e62906911e209eb0460f172ce88e770; sid_tt=f52081faaea135495e5c7f1d731aca79; sessionid=f52081faaea135495e5c7f1d731aca79; sessionid_ss=f52081faaea135495e5c7f1d731aca79; is_staff_user=false; PHPSESSID=73bc52676d8a441a5128f86c164d91a5; PHPSESSID_SS=73bc52676d8a441a5128f86c164d91a5; ucas_c0=CkEKBTEuMC4wEJOIj8aV5P2oaRjmJiD8vrDz9Iy9AyiwITDYkuCImY2cBkCm7sfKBkimooTNBlCPvJrqxbSyumJYbhIU_065hMaNOcS1wHbVur_NQsaamsw; ucas_c0_ss=CkEKBTEuMC4wEJOIj8aV5P2oaRjmJiD8vrDz9Iy9AyiwITDYkuCImY2cBkCm7sfKBkimooTNBlCPvJrqxbSyumJYbhIU_065hMaNOcS1wHbVur_NQsaamsw; COMPASS_LUOPAN_DT=session_7589117384745566498; SHOP_ID=47668214; PIGEON_CID=3501298428676440; odin_tt=ae3c1b406c2527cb1dafe2ce0c5d7fa6e8dd0633f2991f50f0388dc4f83c30fcd5553f029b89c4f61587ea7e1065b232; ttwid=1%7CAwu3-vdDBhOP12XdEzmCJlbyX3Qt_5RcioPVgjBIDps%7C1767452427%7Ca6b23c96c08851a0abfa663ac7be9d19fa11e4cf120f3adc483edb358e0880bc; sid_guard=f52081faaea135495e5c7f1d731aca79%7C1767544182%7C5184000%7CThu%2C+05-Mar-2026+16%3A29%3A42+GMT; session_tlb_tag=sttt%7C7%7C9SCB-q6hNUleXH8dcxrKef________-kReT5b1uIKAfFTzjQUFgobm4jLDmrqpsvf_u6YPGCZ7A%3D; sid_ucp_v1=1.0.0-KDNmM2NjMzBmYjFhYThjZWIxNTUwMGE1ZWMzZjZmYWQ4MzJlYzNkYzQKGQjYkuCImY2cBhD2qurKBhiwISAMOAJA8QcaAmhsIiBmNTIwODFmYWFlYTEzNTQ5NWU1YzdmMWQ3MzFhY2E3OQ; ssid_ucp_v1=1.0.0-KDNmM2NjMzBmYjFhYThjZWIxNTUwMGE1ZWMzZjZmYWQ4MzJlYzNkYzQKGQjYkuCImY2cBhD2qurKBhiwISAMOAJA8QcaAmhsIiBmNTIwODFmYWFlYTEzNTQ5NWU1YzdmMWQ3MzFhY2E3OQ; BUYIN_SASID=SID2_7591542322157568294; csrf_session_id=b7b4150c5eeefaede4ef5e71473e9dc1")
client := &http.Client{
Timeout: 15 * time.Second,
}
fmt.Println("正在发送请求到抖店接口...")
resp, err := client.Do(req)
if err != nil {
fmt.Printf("请求执行失败: %v\n", err)
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
fmt.Printf("读取响应内容失败: %v\n", err)
return
}
fmt.Printf("响应状态码: %d\n", resp.StatusCode)
fmt.Printf("响应数据: %s\n", string(body))
}