diff --git a/.DS_Store b/.DS_Store
index 8cc0347..5fc34f2 100644
Binary files a/.DS_Store and b/.DS_Store differ
diff --git a/cmd/tools/test_douyin_order/main.go b/cmd/tools/test_douyin_order/main.go
new file mode 100644
index 0000000..194103b
--- /dev/null
+++ b/cmd/tools/test_douyin_order/main.go
@@ -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))
+}
diff --git a/docs/standardize_points_ratio/ALIGNMENT_standardize_points_ratio.md b/docs/standardize_points_ratio/ALIGNMENT_standardize_points_ratio.md
new file mode 100644
index 0000000..bf195d2
--- /dev/null
+++ b/docs/standardize_points_ratio/ALIGNMENT_standardize_points_ratio.md
@@ -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。
diff --git a/docs/后台工作台接口分析/ALIGNMENT_后台工作台接口.md b/docs/后台工作台接口分析/ALIGNMENT_后台工作台接口.md
new file mode 100644
index 0000000..425bc8a
--- /dev/null
+++ b/docs/后台工作台接口分析/ALIGNMENT_后台工作台接口.md
@@ -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`
`fetchPointsTrend`
`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)
diff --git a/docs/后台工作台接口分析/CONSENSUS_后台工作台接口.md b/docs/后台工作台接口分析/CONSENSUS_后台工作台接口.md
new file mode 100644
index 0000000..4a03f4e
--- /dev/null
+++ b/docs/后台工作台接口分析/CONSENSUS_后台工作台接口.md
@@ -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` |
diff --git a/internal/api/admin/admin.go b/internal/api/admin/admin.go
index 1469efd..bede838 100644
--- a/internal/api/admin/admin.go
+++ b/internal/api/admin/admin.go
@@ -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),
}
}
diff --git a/internal/api/admin/channels.go b/internal/api/admin/channels.go
index b33df5b..938d131 100644
--- a/internal/api/admin/channels.go
+++ b/internal/api/admin/channels.go
@@ -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
diff --git a/internal/api/admin/dashboard_admin.go b/internal/api/admin/dashboard_admin.go
index 4e1eab5..2aaab0c 100644
--- a/internal/api/admin/dashboard_admin.go
+++ b/internal/api/admin/dashboard_admin.go
@@ -1,6 +1,7 @@
package admin
import (
+ "database/sql"
"net/http"
"strconv"
"time"
@@ -8,6 +9,8 @@ import (
"bindbox-game/internal/code"
"bindbox-game/internal/pkg/core"
"bindbox-game/internal/pkg/validation"
+
+ "gorm.io/gorm"
)
type cardsRequest struct {
@@ -17,14 +20,17 @@ type cardsRequest struct {
}
type cardStatResponse struct {
- ItemCardSales int64 `json:"itemCardSales"`
- DrawCount int64 `json:"drawCount"`
- NewUsers int64 `json:"newUsers"`
- TotalPoints int64 `json:"totalPoints"`
- ItemCardChange string `json:"itemCardChange"`
- DrawChange string `json:"drawChange"`
- NewUserChange string `json:"newUserChange"`
- PointsChange string `json:"pointsChange"`
+ ItemCardSales int64 `json:"itemCardSales"`
+ DrawCount int64 `json:"drawCount"`
+ NewUsers int64 `json:"newUsers"`
+ TotalPoints int64 `json:"totalPoints"`
+ TotalCoupons int64 `json:"totalCoupons"`
+ TotalItemCards int64 `json:"totalItemCards"`
+ TotalGamePasses int64 `json:"totalGamePasses"`
+ ItemCardChange string `json:"itemCardChange"`
+ DrawChange string `json:"drawChange"`
+ NewUserChange string `json:"newUserChange"`
+ PointsChange string `json:"pointsChange"`
}
func (h *handler) DashboardCards() core.HandlerFunc {
@@ -132,10 +138,35 @@ func (h *handler) DashboardCards() core.HandlerFunc {
prevDelta = prevDeltaRows[0].Sum
}
+ // 批量:存量优惠券 (未使用)
+ tcCur, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserCoupons.Status.Eq(1)).
+ Count()
+
+ // 批量:存量道具卡 (有效)
+ ticCur, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserItemCards.Status.Eq(1)).
+ Count()
+
+ // 批量:存量次卡 (剩余次数)
+ var tgpRows []struct{ Sum int64 }
+ _ = h.readDB.UserGamePasses.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserGamePasses.ExpiredAt.Gt(time.Now())).
+ Or(h.readDB.UserGamePasses.ExpiredAt.Eq(time.Time{})).
+ Select(h.readDB.UserGamePasses.Remaining.Sum().As("sum")).
+ Scan(&tgpRows)
+ var tgpCur int64
+ if len(tgpRows) > 0 {
+ tgpCur = tgpRows[0].Sum
+ }
+
rsp.ItemCardSales = icCur
rsp.DrawCount = dlCur
rsp.NewUsers = nuCur
rsp.TotalPoints = tpCur
+ rsp.TotalCoupons = tcCur
+ rsp.TotalItemCards = ticCur
+ rsp.TotalGamePasses = tgpCur
rsp.ItemCardChange = percentChange(icPrev, icCur)
rsp.DrawChange = percentChange(dlPrev, dlCur)
rsp.NewUserChange = percentChange(nuPrev, nuCur)
@@ -150,8 +181,15 @@ type trendRequest struct {
}
type trendPoint struct {
- Date string `json:"date"`
- Value int64 `json:"value"`
+ Date string `json:"date"`
+ Value int64 `json:"value"`
+ Gmv int64 `json:"gmv"`
+ Orders int64 `json:"orders"`
+}
+
+type salesDrawTrendResponse struct {
+ Granularity string `json:"granularity"`
+ List []trendPoint `json:"list"`
}
type userTrendResponse struct {
@@ -1190,3 +1228,1174 @@ func (h *handler) DashboardUserOverview() core.HandlerFunc {
ctx.Payload(out)
}
}
+
+type retentionCohort struct {
+ Date string `json:"date"`
+ Total int64 `json:"total"`
+ Retention []float64 `json:"retention"`
+}
+
+type retentionAnalyticsResponse struct {
+ List []retentionCohort `json:"list"`
+}
+
+func (h *handler) DashboardRetentionAnalytics() core.HandlerFunc {
+ return func(ctx core.Context) {
+ // 默认取 30 天
+ end := time.Now()
+ start := end.AddDate(0, 0, -30)
+ days := daysBetween(start, end)
+
+ out := make([]retentionCohort, 0, len(days))
+
+ for _, d := range days {
+ ds := time.Date(d.Year(), d.Month(), d.Day(), 0, 0, 0, 0, d.Location())
+ de := ds.AddDate(0, 0, 1).Add(-time.Second)
+
+ // 分群活跃用户:该日期注册的用户
+ var userIDs []int64
+ _ = h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.Users.CreatedAt.Gte(ds)).
+ Where(h.readDB.Users.CreatedAt.Lte(de)).
+ Pluck(h.readDB.Users.ID, &userIDs)
+
+ total := int64(len(userIDs))
+ if total == 0 {
+ continue
+ }
+
+ retention := make([]float64, 8)
+ retention[0] = 100
+
+ for i := 1; i <= 7; i++ {
+ rs := ds.AddDate(0, 0, i)
+ re := rs.AddDate(0, 0, 1).Add(-time.Second)
+
+ // 统计这批用户在 Day i 是否有下单行为作为活跃指标
+ activeCount, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.Orders.UserID.In(userIDs...)).
+ Where(h.readDB.Orders.CreatedAt.Gte(rs)).
+ Where(h.readDB.Orders.CreatedAt.Lte(re)).
+ Distinct(h.readDB.Orders.UserID).
+ Count()
+
+ rate := float64(activeCount) / float64(total) * 100
+ retention[i] = float64(int(rate*10)) / 10.0
+ }
+
+ out = append(out, retentionCohort{
+ Date: ds.Format("2006-01-02"),
+ Total: total,
+ Retention: retention,
+ })
+ }
+
+ ctx.Payload(retentionAnalyticsResponse{List: out})
+ }
+}
+
+type userEconomicsResponse struct {
+ Arpu int64 `json:"arpu"`
+ ArpuTrend float64 `json:"arpuTrend"`
+ Cac int64 `json:"cac"`
+ CacTrend float64 `json:"cacTrend"`
+ Clv int64 `json:"clv"`
+ KFactor float64 `json:"kFactor"`
+}
+
+func (h *handler) DashboardUserEconomics() core.HandlerFunc {
+ return func(ctx core.Context) {
+ now := time.Now()
+ start30 := now.AddDate(0, 0, -30)
+ start60 := now.AddDate(0, 0, -60)
+
+ // 1. ARPU: (总抽奖价值) / 活跃人数
+ type gmvRow struct {
+ TotalVal int64
+ }
+ var curGmv gmvRow
+ h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
+ Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
+ Joins("JOIN activities ON activities.id = activity_issues.activity_id").
+ Where("activity_draw_logs.created_at >= ?", start30).
+ Select("SUM(activities.price_draw) as total_val").
+ Scan(&curGmv)
+
+ var prevGmv gmvRow
+ h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
+ Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
+ Joins("JOIN activities ON activities.id = activity_issues.activity_id").
+ Where("activity_draw_logs.created_at >= ? AND activity_draw_logs.created_at < ?", start60, start30).
+ Select("SUM(activities.price_draw) as total_val").
+ Scan(&prevGmv)
+
+ activeUsers, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(start30)).
+ Distinct(h.readDB.ActivityDrawLogs.UserID).
+ Count()
+ prevUsers, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(start60)).
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Lt(start30)).
+ Distinct(h.readDB.ActivityDrawLogs.UserID).
+ Count()
+
+ var arpu int64
+ var arpuTrend float64
+ if activeUsers > 0 {
+ arpu = curGmv.TotalVal / activeUsers
+ if prevUsers > 0 && prevGmv.TotalVal > 0 {
+ prevArpu := prevGmv.TotalVal / prevUsers
+ if prevArpu > 0 {
+ arpuTrend = float64(arpu-prevArpu) / float64(prevArpu) * 100
+ }
+ }
+ }
+
+ // 2. K-Factor
+ invites, _ := h.readDB.UserInvites.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserInvites.CreatedAt.Gte(start30)).
+ Count()
+
+ var kFactor float64
+ if activeUsers > 0 {
+ kFactor = float64(invites) / float64(activeUsers)
+ }
+
+ // 3. CAC
+ var marketingSpendNull sql.NullInt64
+ _ = h.readDB.UserCoupons.WithContext(ctx.RequestContext()).UnderlyingDB().
+ Joins("JOIN system_coupons ON system_coupons.id = user_coupons.coupon_id").
+ Where("user_coupons.created_at >= ?", start30).
+ Select("SUM(system_coupons.discount_value)").
+ Scan(&marketingSpendNull)
+
+ newUsers, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.Users.CreatedAt.Gte(start30)).
+ Count()
+
+ var cac int64
+ if newUsers > 0 {
+ cac = marketingSpendNull.Int64 / newUsers
+ }
+
+ clv := arpu * 5
+
+ ctx.Payload(userEconomicsResponse{
+ Arpu: arpu / 100,
+ ArpuTrend: float64(int(arpuTrend*10)) / 10.0,
+ Cac: cac / 100,
+ CacTrend: 0.0,
+ Clv: clv / 100,
+ KFactor: float64(int(kFactor*100)) / 100.0,
+ })
+ }
+}
+
+type prizeDistributionItem struct {
+ Level int32 `json:"level"`
+ LevelName string `json:"levelName"`
+ WinnerCount int64 `json:"winnerCount"`
+ PrizeCount int64 `json:"prizeCount"`
+ WinRate float64 `json:"winRate"`
+ Cost int64 `json:"cost"`
+}
+
+func (h *handler) DashboardPrizeDistribution() core.HandlerFunc {
+ return func(ctx core.Context) {
+ // 聚合所有在线期数的奖品级别概率与产出
+ var rows []struct {
+ Level int32
+ LevelTotalQty int64 // 该档位总库存 (SUM original_qty)
+ LevelRemQty int64 // 该档位剩余 (SUM quantity)
+ LevelTotalProb float64 // 该档位总权重 (SUM weight)
+ PrizeCount int64 // 该档位配置项数 (COUNT id)
+ LevelTotalValue int64 // 该档位总货值 (SUM price * original_qty)
+ }
+
+ activityIdStr := ctx.Request().URL.Query().Get("activity_id")
+
+ // 1. 构建基础查询条件(复用)
+ buildBaseDB := func() *gorm.DB {
+ base := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).UnderlyingDB().
+ Joins("JOIN activity_issues ON activity_issues.id = activity_reward_settings.issue_id").
+ Joins("JOIN activities ON activities.id = activity_issues.activity_id")
+
+ if activityIdStr != "" {
+ base = base.Where("activities.id = ?", activityIdStr)
+ } else {
+ base = base.Where("activities.status = ?", 1)
+ }
+ return base
+ }
+
+ // 2. 计算活动总权重 (用于计算真实概率)
+ var totalActivityWeight float64
+ // 使用新的DB Session进行查询,避免GORM Scope污染
+ weightDB := buildBaseDB()
+ var weightResult sql.NullFloat64
+ weightDB.Select("SUM(activity_reward_settings.weight)").Scan(&weightResult)
+ totalActivityWeight = weightResult.Float64
+
+ // 3. 分组统计各项指标
+ // 注意: gorm GEN 模式下的 UnderlyingDB() 返回的是 *gorm.DB,可以链式调用
+ mainDB := buildBaseDB().
+ Joins("LEFT JOIN products ON products.id = activity_reward_settings.product_id").
+ Select(
+ "activity_reward_settings.level",
+ "SUM(activity_reward_settings.original_qty) as level_total_qty",
+ "SUM(activity_reward_settings.quantity) as level_rem_qty",
+ "SUM(activity_reward_settings.weight) as level_total_prob",
+ "COUNT(activity_reward_settings.id) as prize_count",
+ "SUM(products.price * activity_reward_settings.original_qty) as level_total_value",
+ ).
+ Group("activity_reward_settings.level").
+ Order("activity_reward_settings.level").
+ Scan(&rows)
+
+ if mainDB.Error != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, 21030, mainDB.Error.Error()))
+ return
+ }
+
+ out := make([]prizeDistributionItem, len(rows))
+ levelNames := map[int32]string{1: "隐藏款", 2: "A赏", 3: "B赏", 4: "C赏", 5: "D赏", 6: "E赏", 7: "F赏", 8: "Last赏"}
+
+ for i, r := range rows {
+ // 防御性计算:已发出数量 (不能为负)
+ winCount := r.LevelTotalQty - r.LevelRemQty
+ if winCount < 0 {
+ winCount = 0
+ }
+
+ name := levelNames[r.Level]
+ if name == "" {
+ name = strconv.Itoa(int(r.Level)) + "等奖"
+ }
+
+ // 真实概率计算:该档位总权重 / 活动总权重
+ var winRate float64
+ if totalActivityWeight > 0 {
+ winRate = r.LevelTotalProb / totalActivityWeight
+ }
+
+ // 成本计算:(该档位总货值 / 该档位总数量) * 已发出数量
+ // 即:加权平均单价 * 发出数量
+ var cost int64
+ if r.LevelTotalQty > 0 {
+ avgPrice := float64(r.LevelTotalValue) / float64(r.LevelTotalQty)
+ cost = int64(avgPrice * float64(winCount))
+ }
+
+ out[i] = prizeDistributionItem{
+ Level: r.Level,
+ LevelName: name,
+ WinnerCount: winCount,
+ PrizeCount: r.LevelTotalQty, // 前端显示的总库存
+ WinRate: winRate,
+ Cost: cost / 100, // 分转元
+ }
+ }
+
+ ctx.Payload(out)
+ }
+}
+
+func (h *handler) DashboardSalesDrawTrend() core.HandlerFunc {
+ return func(ctx core.Context) {
+ req := new(trendRequest)
+ if err := ctx.ShouldBindForm(req); err != nil {
+ ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
+ return
+ }
+
+ s, e := parseRange(req.RangeType, "", "")
+ gran := normalizeGranularity(req.Granularity)
+ buckets := buildBuckets(s, e, gran)
+
+ list := make([]trendPoint, len(buckets))
+ for i, b := range buckets {
+ // 抽奖数 (Value)
+ draws, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(b.Start)).
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(b.End)).
+ Count()
+
+ // 总业务价值 (GMV) - 基于抽奖单价计算
+ var gmv struct {
+ Total int64
+ }
+ _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).UnderlyingDB().
+ Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
+ Joins("JOIN activities ON activities.id = activity_issues.activity_id").
+ Where("activity_draw_logs.created_at >= ?", b.Start).
+ Where("activity_draw_logs.created_at <= ?", b.End).
+ Select("SUM(activities.price_draw) as total").
+ Scan(&gmv)
+
+ // 订单数 (仅支付成功的订单)
+ orders, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.Orders.Status.Eq(2)).
+ Where(h.readDB.Orders.PaidAt.Gte(b.Start)).
+ Where(h.readDB.Orders.PaidAt.Lte(b.End)).
+ Count()
+
+ list[i] = trendPoint{
+ Date: b.Label,
+ Value: draws,
+ Gmv: gmv.Total / 100, // 转为元
+ Orders: orders,
+ }
+ }
+
+ ctx.Payload(salesDrawTrendResponse{
+ Granularity: gran,
+ List: list,
+ })
+ }
+}
+
+// =====================================================================
+// 运营分析接口 (Operations Analytics)
+// =====================================================================
+
+// 1. 产品动销排行
+type productPerformanceItem struct {
+ ID int64 `json:"id"`
+ SeriesName string `json:"seriesName"`
+ SalesCount int64 `json:"salesCount"`
+ Amount int64 `json:"amount"`
+ ContributionRate float64 `json:"contributionRate"`
+ InventoryTurnover float64 `json:"inventoryTurnover"`
+}
+
+func (h *handler) OperationsProductPerformance() core.HandlerFunc {
+ return func(ctx core.Context) {
+ s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
+
+ // 按活动聚合抽奖数据
+ type drawRow struct {
+ ActivityID int64
+ Count int64
+ Winners int64
+ }
+ var rows []drawRow
+
+ // 统计抽奖日志,按活动分组
+ _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ LeftJoin(h.readDB.ActivityIssues, h.readDB.ActivityIssues.ID.EqCol(h.readDB.ActivityDrawLogs.IssueID)).
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(s)).
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(e)).
+ Select(
+ h.readDB.ActivityIssues.ActivityID,
+ h.readDB.ActivityDrawLogs.ID.Count().As("count"),
+ h.readDB.ActivityDrawLogs.IsWinner.Sum().As("winners"),
+ ).
+ Group(h.readDB.ActivityIssues.ActivityID).
+ Order(h.readDB.ActivityDrawLogs.ID.Count().Desc()).
+ Limit(10).
+ Scan(&rows)
+
+ // 获取活动详情(名称和单价)
+ activityIDs := make([]int64, len(rows))
+ for i, r := range rows {
+ activityIDs[i] = r.ActivityID
+ }
+
+ type actInfo struct {
+ Name string
+ PriceDraw int64
+ }
+ actMap := make(map[int64]actInfo)
+ if len(activityIDs) > 0 {
+ acts, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.Activities.ID.In(activityIDs...)).Find()
+ for _, a := range acts {
+ actMap[a.ID] = actInfo{Name: a.Name, PriceDraw: a.PriceDraw}
+ }
+ }
+
+ // 计算总数用于贡献率
+ var totalCount int64
+ for _, r := range rows {
+ totalCount += r.Count
+ }
+
+ out := make([]productPerformanceItem, len(rows))
+ for i, r := range rows {
+ info := actMap[r.ActivityID]
+
+ var contribution float64
+ if totalCount > 0 {
+ contribution = float64(r.Count) / float64(totalCount) * 100
+ }
+
+ // 周转率简化计算
+ days := e.Sub(s).Hours() / 24
+ if days < 1 {
+ days = 1
+ }
+ turnover := float64(r.Count) / days * 7
+
+ out[i] = productPerformanceItem{
+ ID: r.ActivityID,
+ SeriesName: info.Name,
+ SalesCount: r.Count,
+ Amount: (r.Count * info.PriceDraw) / 100, // 转换为元
+ ContributionRate: float64(int(contribution*10)) / 10.0,
+ InventoryTurnover: float64(int(turnover*10)) / 10.0,
+ }
+ }
+
+ ctx.Payload(out)
+ }
+}
+
+// 2. 积分经济总览
+type pointsEconomySummaryResponse struct {
+ TotalIssued int64 `json:"totalIssued"`
+ TotalConsumed int64 `json:"totalConsumed"`
+ NetChange int64 `json:"netChange"`
+ ActiveUsersWithPoints int64 `json:"activeUsersWithPoints"`
+ ConversionRate float64 `json:"conversionRate"`
+}
+
+func (h *handler) OperationsPointsEconomySummary() core.HandlerFunc {
+ return func(ctx core.Context) {
+ s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
+
+ // 发行总额 (正数积分)
+ var issuedNull sql.NullInt64
+ _ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserPointsLedger.CreatedAt.Gte(s)).
+ Where(h.readDB.UserPointsLedger.CreatedAt.Lte(e)).
+ Where(h.readDB.UserPointsLedger.Points.Gt(0)).
+ Select(h.readDB.UserPointsLedger.Points.Sum()).
+ Scan(&issuedNull)
+ issued := issuedNull.Int64
+
+ // 消耗总额 (负数积分)
+ var consumedNull sql.NullInt64
+ _ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserPointsLedger.CreatedAt.Gte(s)).
+ Where(h.readDB.UserPointsLedger.CreatedAt.Lte(e)).
+ Where(h.readDB.UserPointsLedger.Points.Lt(0)).
+ Select(h.readDB.UserPointsLedger.Points.Sum()).
+ Scan(&consumedNull)
+ consumed := -consumedNull.Int64 // 转为正数
+
+ // 持分活跃用户数
+ activeUsers, _ := h.readDB.UserPoints.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserPoints.Points.Gt(0)).
+ Count()
+
+ // 活跃持仓率
+ totalUsers, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().Count()
+ var conversionRate float64
+ if totalUsers > 0 {
+ conversionRate = float64(activeUsers) / float64(totalUsers) * 100
+ }
+
+ ctx.Payload(pointsEconomySummaryResponse{
+ TotalIssued: issued,
+ TotalConsumed: consumed,
+ NetChange: issued - consumed,
+ ActiveUsersWithPoints: activeUsers,
+ ConversionRate: float64(int(conversionRate*10)) / 10.0,
+ })
+ }
+}
+
+// 3. 积分趋势
+type pointsTrendItem struct {
+ Date string `json:"date"`
+ Issued int64 `json:"issued"`
+ Consumed int64 `json:"consumed"`
+ Expired int64 `json:"expired"`
+ NetChange int64 `json:"netChange"`
+ Balance int64 `json:"balance"`
+}
+
+func (h *handler) OperationsPointsTrend() core.HandlerFunc {
+ return func(ctx core.Context) {
+ s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
+ buckets := buildBuckets(s, e, "day")
+
+ out := make([]pointsTrendItem, len(buckets))
+ var runningBalance int64
+
+ // 获取初始余额
+ var initBalanceNull sql.NullInt64
+ _ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserPointsLedger.CreatedAt.Lt(s)).
+ Select(h.readDB.UserPointsLedger.Points.Sum()).
+ Scan(&initBalanceNull)
+ runningBalance = initBalanceNull.Int64
+
+ for i, b := range buckets {
+ // 当日发行
+ var issuedNull sql.NullInt64
+ _ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserPointsLedger.CreatedAt.Gte(b.Start)).
+ Where(h.readDB.UserPointsLedger.CreatedAt.Lte(b.End)).
+ Where(h.readDB.UserPointsLedger.Points.Gt(0)).
+ Select(h.readDB.UserPointsLedger.Points.Sum()).
+ Scan(&issuedNull)
+ issued := issuedNull.Int64
+
+ // 当日消耗
+ var consumedNull sql.NullInt64
+ _ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserPointsLedger.CreatedAt.Gte(b.Start)).
+ Where(h.readDB.UserPointsLedger.CreatedAt.Lte(b.End)).
+ Where(h.readDB.UserPointsLedger.Points.Lt(0)).
+ Select(h.readDB.UserPointsLedger.Points.Sum()).
+ Scan(&consumedNull)
+ consumed := -consumedNull.Int64
+
+ netChange := issued - consumed
+ runningBalance += netChange
+
+ out[i] = pointsTrendItem{
+ Date: b.Label,
+ Issued: issued,
+ Consumed: consumed,
+ Expired: 0,
+ NetChange: netChange,
+ Balance: runningBalance,
+ }
+ }
+
+ ctx.Payload(out)
+ }
+}
+
+// 4. 积分收支结构
+type pointsStructureItem struct {
+ Category string `json:"category"`
+ Amount int64 `json:"amount"`
+ Percentage float64 `json:"percentage"`
+ Trend string `json:"trend"`
+}
+
+func (h *handler) OperationsPointsStructure() core.HandlerFunc {
+ return func(ctx core.Context) {
+ s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
+
+ // 按Action分组统计
+ type actionRow struct {
+ Action string
+ Total int64
+ }
+ var rows []actionRow
+
+ _ = h.readDB.UserPointsLedger.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserPointsLedger.CreatedAt.Gte(s)).
+ Where(h.readDB.UserPointsLedger.CreatedAt.Lte(e)).
+ Select(
+ h.readDB.UserPointsLedger.Action,
+ h.readDB.UserPointsLedger.Points.Sum().As("total"),
+ ).
+ Group(h.readDB.UserPointsLedger.Action).
+ Scan(&rows)
+
+ // 计算总量
+ var absTotal int64
+ for _, r := range rows {
+ if r.Total > 0 {
+ absTotal += r.Total
+ } else {
+ absTotal += -r.Total
+ }
+ }
+
+ // Action名称映射
+ actionNames := map[string]string{
+ "signin": "签到奖励",
+ "order_deduct": "订单消耗",
+ "refund_restore": "退款返还",
+ "manual": "手动调整",
+ "task_reward": "任务奖励",
+ "draw_win": "抽奖中奖",
+ }
+
+ out := make([]pointsStructureItem, 0, len(rows))
+ for _, r := range rows {
+ name := actionNames[r.Action]
+ if name == "" {
+ name = r.Action
+ }
+ var pct float64
+ if absTotal > 0 {
+ amt := r.Total
+ if amt < 0 {
+ amt = -amt
+ }
+ pct = float64(amt) / float64(absTotal) * 100
+ }
+ out = append(out, pointsStructureItem{
+ Category: name,
+ Amount: r.Total,
+ Percentage: float64(int(pct*10)) / 10.0,
+ Trend: "+0%",
+ })
+ }
+
+ ctx.Payload(out)
+ }
+}
+
+// 5. 优惠券效能排行
+type couponEffectivenessItem struct {
+ CouponID int64 `json:"couponId"`
+ CouponName string `json:"couponName"`
+ Type string `json:"type"`
+ IssuedCount int64 `json:"issuedCount"`
+ UsedCount int64 `json:"usedCount"`
+ UsedRate float64 `json:"usedRate"`
+ BroughtOrders int64 `json:"broughtOrders"`
+ BroughtAmount int64 `json:"broughtAmount"`
+ ROI float64 `json:"roi"`
+}
+
+func (h *handler) OperationsCouponEffectiveness() core.HandlerFunc {
+ return func(ctx core.Context) {
+ s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
+
+ // 获取所有券模板
+ coupons, _ := h.readDB.SystemCoupons.WithContext(ctx.RequestContext()).ReadDB().Find()
+
+ out := make([]couponEffectivenessItem, 0, len(coupons))
+ for _, c := range coupons {
+ // 发放数量
+ issued, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserCoupons.CouponID.Eq(c.ID)).
+ Where(h.readDB.UserCoupons.CreatedAt.Gte(s)).
+ Where(h.readDB.UserCoupons.CreatedAt.Lte(e)).
+ Count()
+
+ // 使用数量
+ used, _ := h.readDB.UserCoupons.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserCoupons.CouponID.Eq(c.ID)).
+ Where(h.readDB.UserCoupons.Status.Eq(2)).
+ Where(h.readDB.UserCoupons.UsedAt.Gte(s)).
+ Where(h.readDB.UserCoupons.UsedAt.Lte(e)).
+ Count()
+
+ // 带动订单和总价值 (实收 + 优惠券面值)
+ var orderStats struct {
+ Orders int64
+ Amount int64
+ }
+ _ = h.readDB.Orders.WithContext(ctx.RequestContext()).UnderlyingDB().
+ Where("coupon_id = ?", c.ID).
+ Where("status = ?", 2).
+ Where("paid_at >= ?", s).
+ Where("paid_at <= ?", e).
+ Select("COUNT(id) as orders, SUM(actual_amount) as amount").
+ Scan(&orderStats)
+
+ discountTotal := c.DiscountValue * used
+ broughtTotalValue := orderStats.Amount + discountTotal // 总业务价值
+
+ var usedRate float64
+ if issued > 0 {
+ usedRate = float64(used) / float64(issued) * 100
+ }
+
+ // ROI计算: 总价值 / 优惠成本
+ var roi float64
+ if discountTotal > 0 {
+ roi = float64(broughtTotalValue) / float64(discountTotal)
+ }
+
+ typeStr := "直减券"
+ switch c.DiscountType {
+ case 2:
+ typeStr = "满减券"
+ case 3:
+ typeStr = "折扣券"
+ }
+
+ out = append(out, couponEffectivenessItem{
+ CouponID: c.ID,
+ CouponName: c.Name,
+ Type: typeStr,
+ IssuedCount: issued,
+ UsedCount: used,
+ UsedRate: float64(int(usedRate*10)) / 10.0,
+ BroughtOrders: orderStats.Orders,
+ BroughtAmount: broughtTotalValue,
+ ROI: float64(int(roi*10)) / 10.0,
+ })
+ }
+
+ ctx.Payload(out)
+ }
+}
+
+// 6. 库存预警列表
+type inventoryAlertItem struct {
+ ID int64 `json:"id"`
+ Name string `json:"name"`
+ Type string `json:"type"`
+ Stock int64 `json:"stock"`
+ Threshold int64 `json:"threshold"`
+ SalesSpeed float64 `json:"salesSpeed"`
+}
+
+func (h *handler) OperationsInventoryAlerts() core.HandlerFunc {
+ return func(ctx core.Context) {
+ threshold := int64(20)
+ out := make([]inventoryAlertItem, 0)
+
+ // 1. 奖品库存预警 (活动奖品设置表)
+ type rewardRow struct {
+ ID int64
+ ProductID int64
+ ProductName string
+ ActivityName string
+ Level int32
+ Quantity int64
+ OriginalQty int64
+ }
+ var rows []rewardRow
+
+ _ = h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().
+ LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.ActivityRewardSettings.ProductID)).
+ LeftJoin(h.readDB.ActivityIssues, h.readDB.ActivityIssues.ID.EqCol(h.readDB.ActivityRewardSettings.IssueID)).
+ LeftJoin(h.readDB.Activities, h.readDB.Activities.ID.EqCol(h.readDB.ActivityIssues.ActivityID)).
+ Where(h.readDB.ActivityRewardSettings.Quantity.Lt(threshold)).
+ Where(h.readDB.ActivityRewardSettings.Quantity.Gt(0)).
+ Select(
+ h.readDB.ActivityRewardSettings.ID,
+ h.readDB.ActivityRewardSettings.ProductID,
+ h.readDB.Products.Name.As("product_name"),
+ h.readDB.Activities.Name.As("activity_name"),
+ h.readDB.ActivityRewardSettings.Level,
+ h.readDB.ActivityRewardSettings.Quantity,
+ h.readDB.ActivityRewardSettings.OriginalQty,
+ ).
+ Order(h.readDB.ActivityRewardSettings.Quantity).
+ Limit(15).
+ Scan(&rows)
+
+ for _, r := range rows {
+ consumed := r.OriginalQty - r.Quantity
+ speed := float64(consumed) / 30.0
+
+ // 构建显示名称
+ displayName := r.ProductName
+ if displayName == "" {
+ displayName = r.ActivityName + " " + strconv.Itoa(int(r.Level)) + "等奖"
+ }
+
+ out = append(out, inventoryAlertItem{
+ ID: r.ID,
+ Name: displayName,
+ Type: "physical",
+ Stock: r.Quantity,
+ Threshold: threshold,
+ SalesSpeed: float64(int(speed*10)) / 10.0,
+ })
+ }
+
+ // 2. 道具卡库存预警 (库存较少的道具卡)
+ cardThreshold := int64(50)
+ type cardRow struct {
+ ID int64
+ CardID int64
+ CardName string
+ Stock int64
+ }
+ var cardRows []cardRow
+
+ _ = h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
+ LeftJoin(h.readDB.SystemItemCards, h.readDB.SystemItemCards.ID.EqCol(h.readDB.UserItemCards.CardID)).
+ Where(h.readDB.UserItemCards.Status.Eq(1)). // 未使用
+ Select(
+ h.readDB.SystemItemCards.ID,
+ h.readDB.UserItemCards.CardID,
+ h.readDB.SystemItemCards.Name.As("card_name"),
+ h.readDB.UserItemCards.ID.Count().As("stock"),
+ ).
+ Group(h.readDB.UserItemCards.CardID).
+ Having(h.readDB.UserItemCards.ID.Count().Lt(int(cardThreshold))).
+ Scan(&cardRows)
+
+ for _, r := range cardRows {
+ out = append(out, inventoryAlertItem{
+ ID: r.CardID,
+ Name: r.CardName,
+ Type: "virtual",
+ Stock: r.Stock,
+ Threshold: cardThreshold,
+ SalesSpeed: 2.0, // 简化
+ })
+ }
+
+ ctx.Payload(out)
+ }
+}
+
+// 7. 风险事件监控
+type riskEventItem struct {
+ UserID int64 `json:"userId"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ Type string `json:"type"`
+ Description string `json:"description"`
+ RiskLevel string `json:"riskLevel"`
+ CreatedAt string `json:"createdAt"`
+}
+
+func (h *handler) OperationsRiskEvents() core.HandlerFunc {
+ return func(ctx core.Context) {
+ now := time.Now()
+ last24h := now.Add(-24 * time.Hour)
+
+ type winnerRow struct {
+ UserID int64
+ WinCount int64
+ }
+ var winners []winnerRow
+
+ _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(last24h)).
+ Select(
+ h.readDB.ActivityDrawLogs.UserID,
+ h.readDB.ActivityDrawLogs.ID.Count().As("win_count"),
+ ).
+ Group(h.readDB.ActivityDrawLogs.UserID).
+ Having(h.readDB.ActivityDrawLogs.ID.Count().Gte(3)).
+ Scan(&winners)
+
+ userIDs := make([]int64, len(winners))
+ for i, w := range winners {
+ userIDs[i] = w.UserID
+ }
+
+ userMap := make(map[int64]struct {
+ Nickname string
+ Avatar string
+ })
+ if len(userIDs) > 0 {
+ users, _ := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.Users.ID.In(userIDs...)).Find()
+ for _, u := range users {
+ userMap[u.ID] = struct {
+ Nickname string
+ Avatar string
+ }{Nickname: u.Nickname, Avatar: u.Avatar}
+ }
+ }
+
+ out := make([]riskEventItem, 0, len(winners))
+ for _, w := range winners {
+ u := userMap[w.UserID]
+ level := "medium"
+ if w.WinCount >= 5 {
+ level = "high"
+ }
+ out = append(out, riskEventItem{
+ UserID: w.UserID,
+ Nickname: u.Nickname,
+ Avatar: u.Avatar,
+ Type: "frequent_win",
+ Description: "24小时内中奖" + strconv.FormatInt(w.WinCount, 10) + "次",
+ RiskLevel: level,
+ CreatedAt: now.Format("15:04"),
+ })
+ }
+
+ ctx.Payload(out)
+ }
+}
+
+// 8. 实时中奖播报
+type liveWinnerItem struct {
+ ID int64 `json:"id"`
+ Nickname string `json:"nickname"`
+ Avatar string `json:"avatar"`
+ IssueName string `json:"issueName"`
+ PrizeName string `json:"prizeName"`
+ IsBigWin bool `json:"isBigWin"`
+ Time string `json:"time"`
+}
+
+type liveWinnersResponse struct {
+ List []liveWinnerItem `json:"list"`
+ Stats struct {
+ HourlyWinRate float64 `json:"hourlyWinRate"`
+ DrawsPerMinute int64 `json:"drawsPerMinute"`
+ } `json:"stats"`
+}
+
+func (h *handler) DashboardLiveWinners() core.HandlerFunc {
+ return func(ctx core.Context) {
+ limitS := ctx.Request().URL.Query().Get("limit")
+ limit := 20
+ if l, err := strconv.Atoi(limitS); err == nil && l > 0 && l <= 50 {
+ limit = l
+ }
+
+ type winRow struct {
+ ID int64
+ UserID int64
+ Nickname string
+ Avatar string
+ IssueName string
+ PrizeName string
+ Level int32
+ CreatedAt time.Time
+ }
+ var rows []winRow
+
+ _ = h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ LeftJoin(h.readDB.Users, h.readDB.Users.ID.EqCol(h.readDB.ActivityDrawLogs.UserID)).
+ LeftJoin(h.readDB.ActivityIssues, h.readDB.ActivityIssues.ID.EqCol(h.readDB.ActivityDrawLogs.IssueID)).
+ LeftJoin(h.readDB.ActivityRewardSettings, h.readDB.ActivityRewardSettings.ID.EqCol(h.readDB.ActivityDrawLogs.RewardID)).
+ LeftJoin(h.readDB.Products, h.readDB.Products.ID.EqCol(h.readDB.ActivityRewardSettings.ProductID)).
+ Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
+ Order(h.readDB.ActivityDrawLogs.ID.Desc()).
+ Limit(limit).
+ Select(
+ h.readDB.ActivityDrawLogs.ID,
+ h.readDB.ActivityDrawLogs.UserID,
+ h.readDB.Users.Nickname,
+ h.readDB.Users.Avatar,
+ h.readDB.ActivityIssues.IssueNumber.As("issue_name"),
+ h.readDB.Products.Name.As("prize_name"),
+ h.readDB.ActivityRewardSettings.Level,
+ h.readDB.ActivityDrawLogs.CreatedAt,
+ ).
+ Scan(&rows)
+
+ now := time.Now()
+ out := make([]liveWinnerItem, len(rows))
+ for i, r := range rows {
+ diff := now.Sub(r.CreatedAt)
+ var timeStr string
+ if diff < time.Minute {
+ timeStr = "刚刚"
+ } else if diff < time.Hour {
+ timeStr = strconv.Itoa(int(diff.Minutes())) + "分钟前"
+ } else {
+ timeStr = strconv.Itoa(int(diff.Hours())) + "小时前"
+ }
+
+ out[i] = liveWinnerItem{
+ ID: r.ID,
+ Nickname: r.Nickname,
+ Avatar: r.Avatar,
+ IssueName: r.IssueName,
+ PrizeName: r.PrizeName,
+ IsBigWin: r.Level <= 2,
+ Time: timeStr,
+ }
+ }
+
+ lastHour := now.Add(-time.Hour)
+ hourDraws, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(lastHour)).
+ Count()
+ hourWins, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(lastHour)).
+ Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
+ Count()
+
+ var winRate float64
+ if hourDraws > 0 {
+ winRate = float64(hourWins) / float64(hourDraws) * 100
+ }
+
+ rsp := liveWinnersResponse{}
+ rsp.List = out
+ rsp.Stats.HourlyWinRate = float64(int(winRate*10)) / 10.0
+ rsp.Stats.DrawsPerMinute = hourDraws / 60
+
+ ctx.Payload(rsp)
+ }
+}
+
+// =====================================================================
+// 补充接口:订单趋势、活动统计、道具卡销售
+// =====================================================================
+
+// 订单转化趋势
+type orderTrendItem struct {
+ Date string `json:"date"`
+ Visitors int64 `json:"visitors"`
+ Orders int64 `json:"orders"`
+ Payments int64 `json:"payments"`
+ Completions int64 `json:"completions"`
+ ConversionRate float64 `json:"conversionRate"`
+}
+
+func (h *handler) DashboardOrderTrend() core.HandlerFunc {
+ return func(ctx core.Context) {
+ s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
+ buckets := buildBuckets(s, e, "day")
+
+ out := make([]orderTrendItem, len(buckets))
+ for i, b := range buckets {
+ // 访问数 (请求日志)
+ visitors, _ := h.readDB.LogRequest.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.LogRequest.CreatedAt.Gte(b.Start)).
+ Where(h.readDB.LogRequest.CreatedAt.Lte(b.End)).
+ Where(h.readDB.LogRequest.Path.Like("/api/app/%")).
+ Count()
+
+ // 下单数
+ orders, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.Orders.CreatedAt.Gte(b.Start)).
+ Where(h.readDB.Orders.CreatedAt.Lte(b.End)).
+ Count()
+
+ // 支付数
+ payments, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.Orders.Status.Eq(2)).
+ Where(h.readDB.Orders.PaidAt.Gte(b.Start)).
+ Where(h.readDB.Orders.PaidAt.Lte(b.End)).
+ Count()
+
+ // 完成数 (已消费 + 已发货)
+ consumed, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.Orders.Status.Eq(2), h.readDB.Orders.IsConsumed.Eq(1)).
+ Where(h.readDB.Orders.UpdatedAt.Gte(b.Start)).
+ Where(h.readDB.Orders.UpdatedAt.Lte(b.End)).
+ Count()
+
+ var rate float64
+ if visitors > 0 {
+ rate = float64(consumed) / float64(visitors) * 100
+ }
+
+ out[i] = orderTrendItem{
+ Date: b.Label,
+ Visitors: visitors,
+ Orders: orders,
+ Payments: payments,
+ Completions: consumed,
+ ConversionRate: float64(int(rate*100)) / 100.0,
+ }
+ }
+
+ ctx.Payload(out)
+ }
+}
+
+// 活动抽奖统计
+type activityStatsResponse struct {
+ TotalActivities int64 `json:"totalActivities"`
+ TotalParticipants int64 `json:"totalParticipants"`
+ TotalDraws int64 `json:"totalDraws"`
+ WinnerCount int64 `json:"winnerCount"`
+ OverallWinRate float64 `json:"overallWinRate"`
+ CostControl float64 `json:"costControl"`
+}
+
+func (h *handler) DashboardActivityStats() core.HandlerFunc {
+ return func(ctx core.Context) {
+ s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
+
+ // 活动总数
+ totalActivities, _ := h.readDB.Activities.WithContext(ctx.RequestContext()).ReadDB().Count()
+
+ // 总抽奖次数
+ totalDraws, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(s)).
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(e)).
+ Count()
+
+ // 中奖数
+ winnerCount, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(s)).
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(e)).
+ Where(h.readDB.ActivityDrawLogs.IsWinner.Eq(1)).
+ Count()
+
+ // 参与人数
+ totalParticipants, _ := h.readDB.ActivityDrawLogs.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Gte(s)).
+ Where(h.readDB.ActivityDrawLogs.CreatedAt.Lte(e)).
+ Distinct(h.readDB.ActivityDrawLogs.UserID).
+ Count()
+
+ var winRate float64
+ if totalDraws > 0 {
+ winRate = float64(winnerCount) / float64(totalDraws) * 100
+ }
+
+ ctx.Payload(activityStatsResponse{
+ TotalActivities: totalActivities,
+ TotalParticipants: totalParticipants,
+ TotalDraws: totalDraws,
+ WinnerCount: winnerCount,
+ OverallWinRate: float64(int(winRate*100)) / 100.0,
+ CostControl: 85.0, // 简化处理
+ })
+ }
+}
+
+// 道具卡销售数据
+type itemCardSalesItem struct {
+ CardID int64 `json:"cardId"`
+ CardName string `json:"cardName"`
+ CardType string `json:"cardType"`
+ SalesCount int64 `json:"salesCount"`
+ SalesAmount int64 `json:"salesAmount"`
+ UsageRate float64 `json:"usageRate"`
+ AvgEffect float64 `json:"avgEffect"`
+}
+
+func (h *handler) DashboardItemCardSales() core.HandlerFunc {
+ return func(ctx core.Context) {
+ s, e := parseRange(ctx.Request().URL.Query().Get("rangeType"), "", "")
+
+ // 获取道具卡列表
+ cards, _ := h.readDB.SystemItemCards.WithContext(ctx.RequestContext()).ReadDB().Find()
+
+ out := make([]itemCardSalesItem, 0, len(cards))
+ for _, c := range cards {
+ // 发放数量
+ issued, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserItemCards.CardID.Eq(c.ID)).
+ Where(h.readDB.UserItemCards.CreatedAt.Gte(s)).
+ Where(h.readDB.UserItemCards.CreatedAt.Lte(e)).
+ Count()
+
+ // 使用数量 (status=2表示已使用)
+ used, _ := h.readDB.UserItemCards.WithContext(ctx.RequestContext()).ReadDB().
+ Where(h.readDB.UserItemCards.CardID.Eq(c.ID)).
+ Where(h.readDB.UserItemCards.Status.Eq(2)).
+ Where(h.readDB.UserItemCards.UsedAt.Gte(s)).
+ Where(h.readDB.UserItemCards.UsedAt.Lte(e)).
+ Count()
+
+ var usageRate float64
+ if issued > 0 {
+ usageRate = float64(used) / float64(issued) * 100
+ }
+
+ // 卡类型映射
+ cardTypeMap := map[int32]string{1: "双倍奖励", 2: "概率提升", 3: "保护卡"}
+ cardTypeName := cardTypeMap[c.CardType]
+ if cardTypeName == "" {
+ cardTypeName = "其他"
+ }
+
+ out = append(out, itemCardSalesItem{
+ CardID: c.ID,
+ CardName: c.Name,
+ CardType: cardTypeName,
+ SalesCount: issued,
+ SalesAmount: issued * c.Price / 100,
+ UsageRate: float64(int(usageRate*10)) / 10.0,
+ AvgEffect: 10.0,
+ })
+ }
+
+ ctx.Payload(out)
+ }
+}
diff --git a/internal/api/admin/douyin_product_rewards.go b/internal/api/admin/douyin_product_rewards.go
new file mode 100644
index 0000000..53e1775
--- /dev/null
+++ b/internal/api/admin/douyin_product_rewards.go
@@ -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": "删除成功"})
+ }
+}
diff --git a/internal/api/admin/users_admin.go b/internal/api/admin/users_admin.go
index d808353..f021ecf 100644
--- a/internal/api/admin/users_admin.go
+++ b/internal/api/admin/users_admin.go
@@ -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
@@ -212,20 +319,26 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
rsp.List = make([]adminUserItem, len(rows))
for i, v := range rows {
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,
+ 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],
+ ThirtyDayConsume: thirtyDayConsume[v.ID],
+ TotalConsume: totalConsume[v.ID],
+ InviteCount: inviteCounts[v.ID],
+ GamePassCount: gamePassCounts[v.ID],
+ GameTicketCount: gameTicketCounts[v.ID],
}
}
ctx.Payload(rsp)
@@ -618,20 +731,26 @@ 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"`
+ 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"` // 游戏资格数量
}
// ListAppUsers 管理端用户列表GetUserPointsBalance 查看用户积分余额
diff --git a/internal/api/admin/users_profile.go b/internal/api/admin/users_profile.go
index 04fe12f..0a5748c 100644
--- a/internal/api/admin/users_profile.go
+++ b/internal/api/admin/users_profile.go
@@ -12,25 +12,29 @@ 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"`
+ 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 +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
+ 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 {
diff --git a/internal/api/admin/users_profit_loss.go b/internal/api/admin/users_profit_loss.go
index c6b96f3..3b04e53 100644
--- a/internal/api/admin/users_profit_loss.go
+++ b/internal/api/admin/users_profit_loss.go
@@ -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,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)
}
}
diff --git a/internal/api/user/app.go b/internal/api/user/app.go
index eebda06..a60f7f1 100644
--- a/internal/api/user/app.go
+++ b/internal/api/user/app.go
@@ -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,
+ }
}
diff --git a/internal/api/user/bind_douyin_order_app.go b/internal/api/user/bind_douyin_order_app.go
new file mode 100644
index 0000000..d742532
--- /dev/null
+++ b/internal/api/user/bind_douyin_order_app.go
@@ -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,
+ })
+ }
+}
diff --git a/internal/repository/mysql/dao/gen.go b/internal/repository/mysql/dao/gen.go
index 8ac057f..5d2761e 100644
--- a/internal/repository/mysql/dao/gen.go
+++ b/internal/repository/mysql/dao/gen.go
@@ -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),
diff --git a/internal/repository/mysql/dao/system_configs.gen.go b/internal/repository/mysql/dao/system_configs.gen.go
index 3844a91..a68c196 100644
--- a/internal/repository/mysql/dao/system_configs.gen.go
+++ b/internal/repository/mysql/dao/system_configs.gen.go
@@ -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
}
diff --git a/internal/repository/mysql/dao/users.gen.go b/internal/repository/mysql/dao/users.gen.go
index 6831688..47b6648 100644
--- a/internal/repository/mysql/dao/users.gen.go
+++ b/internal/repository/mysql/dao/users.gen.go
@@ -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()
@@ -51,21 +52,22 @@ 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
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 {
diff --git a/internal/repository/mysql/model/douyin_orders.gen.go b/internal/repository/mysql/model/douyin_orders.gen.go
index 67d5d67..42c2bb2 100644
--- a/internal/repository/mysql/model/douyin_orders.gen.go
+++ b/internal/repository/mysql/model/douyin_orders.gen.go
@@ -13,15 +13,16 @@ const TableNameDouyinOrders = "douyin_orders"
// DouyinOrders 抖店订单表
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"` // 抖店订单号
- 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"` // 实收金额(分)
- 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"` // 原始响应数据
+ ShopOrderID string `gorm:"column:shop_order_id;not null;comment:抖店订单号" json:"shop_order_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"` // 实收金额(分)
+ 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"` // 原始响应数据
+ 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"`
}
diff --git a/internal/repository/mysql/model/douyin_product_rewards.go b/internal/repository/mysql/model/douyin_product_rewards.go
new file mode 100644
index 0000000..df4b0f0
--- /dev/null
+++ b/internal/repository/mysql/model/douyin_product_rewards.go
@@ -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
+}
diff --git a/internal/repository/mysql/model/system_configs.gen.go b/internal/repository/mysql/model/system_configs.gen.go
index e5aaa05..bda30d7 100644
--- a/internal/repository/mysql/model/system_configs.gen.go
+++ b/internal/repository/mysql/model/system_configs.gen.go
@@ -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"`
}
diff --git a/internal/repository/mysql/model/users.gen.go b/internal/repository/mysql/model/users.gen.go
index 12f65f1..b7aa133 100644
--- a/internal/repository/mysql/model/users.gen.go
+++ b/internal/repository/mysql/model/users.gen.go
@@ -14,20 +14,21 @@ const TableNameUsers = "users"
// Users 用户表
type Users struct {
- ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
- 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"` // 删除时间(软删)
- Nickname string `gorm:"column:nickname;not null;comment:昵称" json:"nickname"` // 昵称
- Avatar string `gorm:"column:avatar;comment:头像URL" json:"avatar"` // 头像URL
- Mobile string `gorm:"column:mobile;comment:手机号" json:"mobile"` // 手机号
- Openid string `gorm:"column:openid;comment:微信openid" json:"openid"` // 微信openid
- Unionid string `gorm:"column:unionid;comment:微信unionid" json:"unionid"` // 微信unionid
- InviteCode string `gorm:"column:invite_code;not null;comment:用户唯一邀请码" json:"invite_code"` // 用户唯一邀请码
- InviterID int64 `gorm:"column:inviter_id;comment:邀请人用户ID" json:"inviter_id"` // 邀请人用户ID
- 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
+ ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"id"` // 主键ID
+ 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"` // 删除时间(软删)
+ Nickname string `gorm:"column:nickname;not null;comment:昵称" json:"nickname"` // 昵称
+ Avatar string `gorm:"column:avatar;comment:头像URL" json:"avatar"` // 头像URL
+ Mobile string `gorm:"column:mobile;comment:手机号" json:"mobile"` // 手机号
+ Openid string `gorm:"column:openid;comment:微信openid" json:"openid"` // 微信openid
+ Unionid string `gorm:"column:unionid;comment:微信unionid" json:"unionid"` // 微信unionid
+ InviteCode string `gorm:"column:invite_code;not null;comment:用户唯一邀请码" json:"invite_code"` // 用户唯一邀请码
+ InviterID int64 `gorm:"column:inviter_id;comment:邀请人用户ID" json:"inviter_id"` // 邀请人用户ID
+ 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
diff --git a/internal/router/router.go b/internal/router/router.go
index ee11cc9..780515d 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -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())
diff --git a/internal/service/channel/channel.go b/internal/service/channel/channel.go
index a9613aa..bc99707 100644
--- a/internal/service/channel/channel.go
+++ b/internal/service/channel/channel.go
@@ -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) {
- if months <= 0 {
- months = 12
- }
+func (s *service) GetStats(ctx context.Context, channelID int64, months int, startDateStr, endDateStr string) (*StatsOutput, error) {
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())
+ 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
+ }
+ startMonth := now.AddDate(0, -months+1, 0)
+ startDate = time.Date(startMonth.Year(), startMonth.Month(), 1, 0, 0, 0, 0, startMonth.Location())
+ endDate = now
+ }
out := &StatsOutput{}
diff --git a/internal/service/douyin/order_sync.go b/internal/service/douyin/order_sync.go
index af77f19..7913e83 100644
--- a/internal/service/douyin/order_sync.go
+++ b/internal/service/douyin/order_sync.go
@@ -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 {
@@ -48,20 +51,22 @@ type SyncResult struct {
}
type service struct {
- logger logger.CustomLogger
- repo mysql.Repo
- readDB *dao.Query
- writeDB *dao.Query
- syscfg sysconfig.Service
+ logger logger.CustomLogger
+ repo mysql.Repo
+ 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,
+ 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)
- if err != nil {
- return nil, fmt.Errorf("获取抖店订单失败: %w", err)
+ // 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{TotalFetched: len(orders)}
+ result := &SyncResult{}
+ fmt.Printf("[DEBUG] 开始全量同步,共 %d 个绑定用户\n", len(users))
- // 统计各状态订单数量
- statusCount := make(map[int]int)
- for _, order := range orders {
- statusCount[order.OrderStatus]++
- }
- s.logger.Info("[抖店同步] 订单状态分布",
- zap.Any("status_count", statusCount),
- )
+ // 2. 遍历用户,按 buyer 抓取订单
+ for _, u := range users {
+ fmt.Printf("[DEBUG] 正在同步用户 ID: %d (昵称: %s, 抖音号: %s) 的订单...\n", u.ID, u.Nickname, u.DouyinUserID)
- // 同步订单到本地(只同步 order_status=5 已完成的订单)
- for _, order := range orders {
- if order.OrderStatus != 5 {
- // 跳过非已完成订单
+ orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID)
+ if err != nil {
+ fmt.Printf("[DEBUG] 抓取用户 %s 订单失败: %v\n", u.DouyinUserID, err)
continue
}
- isNew, matched := s.syncOrder(ctx, order)
- if isNew {
- result.NewOrders++
- s.logger.Info("[抖店同步] 新增订单",
- zap.String("shop_order_id", order.ShopOrderID),
- zap.Int("order_status", order.OrderStatus),
- )
- }
- if matched {
- result.MatchedUsers++
+
+ result.TotalFetched += len(orders)
+
+ // 3. 同步
+ for _, order := range orders {
+ // 同步订单(传入建议关联的用户 ID)
+ isNew, matched := s.SyncOrder(ctx, &order, u.ID)
+ if isNew {
+ result.NewOrders++
+ }
+ 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{
+ ShopOrderID: item.ShopOrderID,
+ OrderStatus: int32(item.OrderStatus),
+ DouyinUserID: item.UserID,
+ 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))
+ return false, false
}
}
- // 解析金额 (抖店返回的是元,需要转换为分)
- var amount int64
- if item.ActualReceiveAmount != "" {
- if f, err := strconv.ParseFloat(strings.TrimSpace(item.ActualReceiveAmount), 64); err == nil {
- amount = int64(f * 100)
+ // 如果还没关联用户(比如之前全量抓取的),尝试用抖店的 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)
}
}
- // 序列化原始数据
- rawData, _ := json.Marshal(item)
+ // ---- 统一处理:发放奖励 ----
+ 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)
- // 创建订单记录
- 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),
+ 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)
+ }
+ }
}
- 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
+ return isNew, isMatched
}
// min 返回两个整数的最小值
diff --git a/internal/service/douyin/scheduler.go b/internal/service/douyin/scheduler.go
index 0cb01bc..28e0876 100644
--- a/internal/service/douyin/scheduler.go
+++ b/internal/service/douyin/scheduler.go
@@ -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秒让服务完全启动
diff --git a/internal/service/task_center/service.go b/internal/service/task_center/service.go
index 1c003d9..d8ed8b5 100644
--- a/internal/service/task_center/service.go
+++ b/internal/service/task_center/service.go
@@ -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 {
- return err
+ 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,
+ })
}
}
- return s.invalidateCache(ctx)
+
+ 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
+ }
+ }
+ 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 {
- return err
+ 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,
+ })
}
}
- return s.invalidateCache(ctx)
+
+ 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
+ }
+ }
+ 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))
}
diff --git a/main.go b/main.go
index 1893900..4d11963 100644
--- a/main.go
+++ b/main.go
@@ -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.WithDebugLevel(), // 启用调试级别日志
+ // 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 {
diff --git a/migrations/20260105_douyin_product_rewards.sql b/migrations/20260105_douyin_product_rewards.sql
new file mode 100644
index 0000000..cb55de2
--- /dev/null
+++ b/migrations/20260105_douyin_product_rewards.sql
@@ -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='抖店商品奖励规则';
diff --git a/scripts/reset_inventory.go b/scripts/reset_inventory.go
new file mode 100644
index 0000000..a2011f6
--- /dev/null
+++ b/scripts/reset_inventory.go
@@ -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)
+}
diff --git a/scripts/test_douyin_uid.go b/scripts/test_douyin_uid.go
new file mode 100644
index 0000000..9285c42
--- /dev/null
+++ b/scripts/test_douyin_uid.go
@@ -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))
+}