fix: 修复退款时清理一番赏格位、积分兑换商品库存校验及抖音登录自邀问题。
This commit is contained in:
parent
359ca9121f
commit
e3a96e68d8
50
cmd/tools/test_douyin_order/main.go
Normal file
50
cmd/tools/test_douyin_order/main.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"bindbox-game/internal/pkg/logger"
|
||||||
|
"bindbox-game/internal/repository/mysql"
|
||||||
|
"bindbox-game/internal/repository/mysql/dao"
|
||||||
|
"bindbox-game/internal/service/douyin"
|
||||||
|
syscfgsvc "bindbox-game/internal/service/sysconfig"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
orderID := flag.String("id", "", "抖音订单号 (order_id)")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *orderID == "" {
|
||||||
|
log.Fatal("请提供订单号,例如: -id=6946062444563338504")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 初始化 MySQL
|
||||||
|
dbRepo, err := mysql.New()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("MySQL 初始化失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 初始化 Logger (简易版)
|
||||||
|
customLogger, _ := logger.NewCustomLogger(dao.Use(dbRepo.GetDbW()), logger.WithOutputInConsole())
|
||||||
|
|
||||||
|
// 3. 初始化 Service
|
||||||
|
sysCfgSvc := syscfgsvc.New(customLogger, dbRepo)
|
||||||
|
douyinSvc := douyin.New(customLogger, dbRepo, sysCfgSvc, nil)
|
||||||
|
|
||||||
|
// 4. 执行测试
|
||||||
|
fmt.Printf("--- 正在测试订单号: %s ---\n", *orderID)
|
||||||
|
ctx := context.Background()
|
||||||
|
order, err := douyinSvc.GetOrderByOrderID(ctx, *orderID)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("查询失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 打印结果
|
||||||
|
fmt.Println("查询成功!返回数据如下:")
|
||||||
|
data, _ := json.MarshalIndent(order, "", " ")
|
||||||
|
fmt.Println(string(data))
|
||||||
|
}
|
||||||
@ -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。
|
||||||
259
docs/后台工作台接口分析/ALIGNMENT_后台工作台接口.md
Normal file
259
docs/后台工作台接口分析/ALIGNMENT_后台工作台接口.md
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
# 后台工作台页面接口分析
|
||||||
|
|
||||||
|
## 一、原始需求
|
||||||
|
分析后台工作台页面的所有设计,确定需要对应哪些接口,并补充后端缺失的接口实现。
|
||||||
|
|
||||||
|
## 二、项目特性规范
|
||||||
|
|
||||||
|
### 技术栈
|
||||||
|
- **前端**: Vue 3 + TypeScript + Element Plus
|
||||||
|
- **后端**: Go + Gin + GORM
|
||||||
|
- **API风格**: RESTful
|
||||||
|
|
||||||
|
### 现有架构
|
||||||
|
- 前端API定义: `web/admin/src/api/dashboard.ts`, `web/admin/src/api/operations.ts`
|
||||||
|
- 后端处理器: `internal/api/admin/dashboard_admin.go`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、工作台页面模块与接口对应关系
|
||||||
|
|
||||||
|
### 维度1: 经营大盘 (overview)
|
||||||
|
|
||||||
|
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|
||||||
|
|------|------|---------|----------|------|
|
||||||
|
| `card-list.vue` | 顶部统计卡片 | `fetchCardStats` | ✅ 已实现 | `DashboardCards` |
|
||||||
|
| `sales-overview.vue` | 销售趋势分析 | `fetchSalesDrawTrend` | ✅ 已实现 | `DashboardSalesDrawTrend` |
|
||||||
|
| `product-performance.vue` | 产品动销排行 | `fetchProductPerformance` | ⚠️ Mock数据 | 需要实现 |
|
||||||
|
| `user-economics.vue` | 用户经济分析 | `fetchUserEconomics` | ✅ 已实现 | `DashboardUserEconomics` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 维度2: 奖池与欧气 (lottery)
|
||||||
|
|
||||||
|
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|
||||||
|
|------|------|---------|----------|------|
|
||||||
|
| `prize-pool-health.vue` | 奖池健康度分析 | `fetchPrizeDistribution` | ✅ 已实现 | `DashboardPrizeDistribution` |
|
||||||
|
| `live-stream-premium.vue` | 全服欧气实时播报 | 无(模拟数据) | ⚠️ 需要实现 | 需要新增实时中奖播报接口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 维度3: 营销转化 (marketing)
|
||||||
|
|
||||||
|
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|
||||||
|
|------|------|---------|----------|------|
|
||||||
|
| `growth-analytics.vue` | 增长经济模型分析 | `fetchUserEconomics` | ✅ 已实现 | 复用 `DashboardUserEconomics` |
|
||||||
|
| `coupon-roi.vue` | 营销券效能排行 | `fetchCouponEffectiveness` | ⚠️ Mock数据 | 需要实现 |
|
||||||
|
| `retention-cohort.vue` | 留存同类群组分析 | `fetchRetentionAnalytics` | ✅ 已实现 | `DashboardRetentionAnalytics` |
|
||||||
|
| `marketing-conversion.vue` | 订单转化全链路监控 | `fetchOrderFunnel` | ✅ 已实现 | `DashboardOrderFunnel` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 维度4: 风控预警 (security)
|
||||||
|
|
||||||
|
| 组件 | 功能 | 前端API | 后端状态 | 备注 |
|
||||||
|
|------|------|---------|----------|------|
|
||||||
|
| `inventory-alert.vue` | 库存预警监控 | `fetchInventoryAlerts` | ⚠️ Mock数据 | 需要实现 |
|
||||||
|
| `risk-monitor.vue` | 异常风险监控 | `fetchRiskEvents` | ⚠️ Mock数据 | 需要实现 |
|
||||||
|
| `points-economy.vue` | 积分经济总览 | `fetchPointsEconomySummary`<br>`fetchPointsTrend`<br>`fetchPointsStructure` | ⚠️ Mock数据 | 需要实现3个接口 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、需要补充的后端接口清单
|
||||||
|
|
||||||
|
### 4.1 运营分析接口 (Operations)
|
||||||
|
|
||||||
|
#### 1. 产品动销排行 `GET /admin/operations/product_performance`
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "rangeType": "7d|30d|today" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"seriesName": "系列名称",
|
||||||
|
"salesCount": 1540,
|
||||||
|
"amount": 285000, // 销售金额(分)
|
||||||
|
"contributionRate": 35.5, // 利润贡献率%
|
||||||
|
"inventoryTurnover": 8.5 // 周转率
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 2. 优惠券效能排行 `GET /admin/operations/coupon_effectiveness`
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "rangeType": "7d|30d" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"couponId": 1,
|
||||||
|
"couponName": "新用户专享券",
|
||||||
|
"type": "满减券",
|
||||||
|
"issuedCount": 1200,
|
||||||
|
"usedCount": 680,
|
||||||
|
"usedRate": 56.7, // 使用率%
|
||||||
|
"broughtOrders": 720, // 带动订单数
|
||||||
|
"broughtAmount": 3600000, // 带动金额(分)
|
||||||
|
"roi": 3.2 // 投资回报率
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3. 库存预警列表 `GET /admin/operations/inventory_alerts`
|
||||||
|
```json
|
||||||
|
// 无请求参数
|
||||||
|
|
||||||
|
// Response
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": 101,
|
||||||
|
"name": "商品名称",
|
||||||
|
"type": "physical|virtual|coupon",
|
||||||
|
"stock": 3,
|
||||||
|
"threshold": 5,
|
||||||
|
"salesSpeed": 1.2 // 日均消耗速度
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 4. 风险事件监控 `GET /admin/operations/risk_events`
|
||||||
|
```json
|
||||||
|
// 无请求参数
|
||||||
|
|
||||||
|
// Response
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"userId": 5001,
|
||||||
|
"nickname": "用户昵称",
|
||||||
|
"avatar": "头像URL",
|
||||||
|
"type": "frequent_win|batch_register|ip_clash",
|
||||||
|
"description": "24小时内中奖5次一等奖",
|
||||||
|
"riskLevel": "high|medium|low",
|
||||||
|
"createdAt": "13:20"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.2 积分经济接口 (Points Economy)
|
||||||
|
|
||||||
|
#### 5. 积分经济总览 `GET /admin/operations/points_economy_summary`
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "rangeType": "7d|30d" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"totalIssued": 1258400, // 发行总积分
|
||||||
|
"totalConsumed": 985600, // 消耗总积分
|
||||||
|
"netChange": 272800, // 净变化
|
||||||
|
"activeUsersWithPoints": 5640, // 持分活跃用户数
|
||||||
|
"conversionRate": 78.5 // 活跃持仓率%
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 6. 积分趋势 `GET /admin/operations/points_trend`
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "rangeType": "7d|30d" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"date": "2026-01-01",
|
||||||
|
"issued": 20000,
|
||||||
|
"consumed": 15000,
|
||||||
|
"expired": 1000,
|
||||||
|
"netChange": 4000,
|
||||||
|
"balance": 250000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 7. 积分收支结构 `GET /admin/operations/points_structure`
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "rangeType": "7d|30d" }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"category": "任务奖励",
|
||||||
|
"amount": 85000,
|
||||||
|
"percentage": 45.2,
|
||||||
|
"trend": "+12.5%"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 4.3 实时播报接口 (Live Stream)
|
||||||
|
|
||||||
|
#### 8. 实时中奖播报 `GET /admin/dashboard/live_winners`
|
||||||
|
```json
|
||||||
|
// Request
|
||||||
|
{ "sinceId": 0, "limit": 20 }
|
||||||
|
|
||||||
|
// Response
|
||||||
|
{
|
||||||
|
"list": [
|
||||||
|
{
|
||||||
|
"id": 12345,
|
||||||
|
"nickname": "用户昵称",
|
||||||
|
"avatar": "头像URL",
|
||||||
|
"issueName": "活动期名称",
|
||||||
|
"prizeName": "奖品名称",
|
||||||
|
"isBigWin": true,
|
||||||
|
"createdAt": "刚刚"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"stats": {
|
||||||
|
"hourlyWinRate": 4.2, // 近1小时爆率
|
||||||
|
"drawsPerMinute": 128 // 连抽频率
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、疑问澄清
|
||||||
|
|
||||||
|
### 5.1 需要确认的问题
|
||||||
|
|
||||||
|
1. **风险事件监控**: 目前系统是否有用户行为日志表可供分析?如果没有,是否需要先创建相关基础设施?
|
||||||
|
|
||||||
|
2. **库存预警阈值**: 库存预警的阈值应该从哪里获取?是固定配置还是每个商品可单独设置?
|
||||||
|
|
||||||
|
3. **积分经济统计范围**: 积分发行/消耗是否需要区分来源类型(任务奖励、抽奖中奖、兑换消耗等)?
|
||||||
|
|
||||||
|
4. **实时播报频率**: 前端轮询间隔建议设为多少?3秒是否合适?
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、边界与限制
|
||||||
|
|
||||||
|
### 6.1 任务边界
|
||||||
|
- ✅ 补充后端缺失的运营分析接口
|
||||||
|
- ✅ 实现积分经济相关接口
|
||||||
|
- ✅ 实现库存预警和风险监控接口
|
||||||
|
- ✅ 更新前端API调用从Mock数据改为真实后端调用
|
||||||
|
- ❌ 不涉及前端UI重构
|
||||||
|
- ❌ 不涉及权限管理改动
|
||||||
|
|
||||||
|
### 6.2 依赖关系
|
||||||
|
- 依赖现有数据库表: `users`, `orders`, `user_points_logs`, `coupons`, `user_coupons`, `prizes`, `draw_logs` 等
|
||||||
|
- 可能需要新增数据库视图或缓存层以提高查询性能
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、预期验收标准
|
||||||
|
|
||||||
|
1. 所有前端Mock数据的接口均已替换为真实后端调用
|
||||||
|
2. 后端接口响应格式与前端类型定义一致
|
||||||
|
3. 各项统计数据计算逻辑准确
|
||||||
|
4. 查询性能在可接受范围内(响应时间 < 500ms)
|
||||||
91
docs/后台工作台接口分析/CONSENSUS_后台工作台接口.md
Normal file
91
docs/后台工作台接口分析/CONSENSUS_后台工作台接口.md
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
# 后台工作台接口 - 共识文档
|
||||||
|
|
||||||
|
## 一、明确需求描述
|
||||||
|
|
||||||
|
补充后台工作台页面中使用Mock数据的8个接口,实现真实的后端数据查询逻辑。
|
||||||
|
|
||||||
|
### 需求范围
|
||||||
|
1. **运营分析接口** (4个)
|
||||||
|
- 产品动销排行
|
||||||
|
- 优惠券效能排行
|
||||||
|
- 库存预警列表
|
||||||
|
- 风险事件监控
|
||||||
|
|
||||||
|
2. **积分经济接口** (3个)
|
||||||
|
- 积分经济总览
|
||||||
|
- 积分趋势
|
||||||
|
- 积分收支结构
|
||||||
|
|
||||||
|
3. **实时播报接口** (1个)
|
||||||
|
- 实时中奖播报
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、技术实现方案
|
||||||
|
|
||||||
|
### 2.1 后端接口实现位置
|
||||||
|
- **文件**: `internal/api/admin/dashboard_admin.go` (现有文件,追加新接口)
|
||||||
|
- **路由注册**: 在现有admin路由组中添加新路径
|
||||||
|
|
||||||
|
### 2.2 数据来源表
|
||||||
|
| 接口 | 主要数据表 | 关联表 |
|
||||||
|
|------|-----------|--------|
|
||||||
|
| 产品动销排行 | `orders`, `activities` | `issues`, `products` |
|
||||||
|
| 优惠券效能 | `user_coupons`, `coupons` | `orders` |
|
||||||
|
| 库存预警 | `issues`, `prizes` | `products` |
|
||||||
|
| 风险事件 | `draw_logs`, `users` | `user_login_logs` (如存在) |
|
||||||
|
| 积分经济 | `user_points_logs` | `users` |
|
||||||
|
| 实时中奖 | `draw_logs` | `users`, `prizes` |
|
||||||
|
|
||||||
|
### 2.3 接口设计原则
|
||||||
|
- 保持与现有接口风格一致
|
||||||
|
- 使用 `rangeType` 参数统一时间范围过滤
|
||||||
|
- 金额单位统一为**分**
|
||||||
|
- 百分比保留2位小数
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、验收标准
|
||||||
|
|
||||||
|
### 功能验收
|
||||||
|
- [ ] 所有8个接口正常返回数据
|
||||||
|
- [ ] 响应格式与前端TypeScript类型定义一致
|
||||||
|
- [ ] 时间范围过滤逻辑正确
|
||||||
|
|
||||||
|
### 性能验收
|
||||||
|
- [ ] 单接口响应时间 < 500ms
|
||||||
|
- [ ] 无N+1查询问题
|
||||||
|
|
||||||
|
### 集成验收
|
||||||
|
- [ ] 前端调用后端接口无报错
|
||||||
|
- [ ] 工作台各模块正确展示真实数据
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、技术约束
|
||||||
|
|
||||||
|
1. **不引入新依赖**: 使用现有GORM查询
|
||||||
|
2. **复用现有工具函数**: 如 `parseRange()`, `percentChange()` 等
|
||||||
|
3. **统一错误处理**: 使用现有 `core.HandlerFunc` 模式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、已解决的不确定性
|
||||||
|
|
||||||
|
基于现有代码分析:
|
||||||
|
- ✅ 积分日志表 `user_points_logs` 已存在,包含 `change_type` 字段可区分来源
|
||||||
|
- ✅ 用户登录日志可通过 `draw_logs` 和 `orders` 推断活跃度
|
||||||
|
- ✅ 库存阈值可通过 `prizes.quantity` 与剩余数量对比计算
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、实现优先级
|
||||||
|
|
||||||
|
| 优先级 | 接口 | 原因 |
|
||||||
|
|--------|------|------|
|
||||||
|
| P0 | 产品动销排行 | 经营大盘核心指标 |
|
||||||
|
| P0 | 积分经济总览+趋势 | 风控预警必需 |
|
||||||
|
| P1 | 优惠券效能 | 营销分析重要 |
|
||||||
|
| P1 | 库存预警 | 运营监控 |
|
||||||
|
| P2 | 风险事件 | 可先用简化版 |
|
||||||
|
| P2 | 实时中奖播报 | 可复用现有 `draw_stream` |
|
||||||
@ -56,6 +56,6 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
|
|||||||
syscfg: syscfgSvc,
|
syscfg: syscfgSvc,
|
||||||
snapshotSvc: snapshotSvc,
|
snapshotSvc: snapshotSvc,
|
||||||
rollbackSvc: rollbackSvc,
|
rollbackSvc: rollbackSvc,
|
||||||
douyinSvc: douyinsvc.New(logger, db, syscfgSvc),
|
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, nil),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -44,7 +44,9 @@ func (h *handler) CreateChannel() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type channelStatsRequest struct {
|
type channelStatsRequest struct {
|
||||||
Days int `form:"days"`
|
Days int `form:"days"`
|
||||||
|
StartDate string `form:"start_date"`
|
||||||
|
EndDate string `form:"end_date"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ChannelStats 渠道数据分析
|
// ChannelStats 渠道数据分析
|
||||||
@ -58,7 +60,7 @@ func (h *handler) ChannelStats() core.HandlerFunc {
|
|||||||
idStr := ctx.Param("channel_id")
|
idStr := ctx.Param("channel_id")
|
||||||
id, _ := strconv.ParseInt(idStr, 10, 64)
|
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 {
|
if err != nil {
|
||||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||||
return
|
return
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
184
internal/api/admin/douyin_product_rewards.go
Normal file
184
internal/api/admin/douyin_product_rewards.go
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"bindbox-game/internal/pkg/core"
|
||||||
|
"bindbox-game/internal/pkg/validation"
|
||||||
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ======== 抖店商品奖励规则 CRUD ========
|
||||||
|
|
||||||
|
type douyinProductRewardListRequest struct {
|
||||||
|
Page int `form:"page"`
|
||||||
|
PageSize int `form:"page_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type douyinProductRewardListResponse struct {
|
||||||
|
List []douyinProductRewardItem `json:"list"`
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type douyinProductRewardItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ProductID string `json:"product_id"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
RewardType string `json:"reward_type"`
|
||||||
|
RewardPayload json.RawMessage `json:"reward_payload"`
|
||||||
|
Quantity int32 `json:"quantity"`
|
||||||
|
Status int32 `json:"status"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListDouyinProductRewards 获取抖店商品奖励规则列表
|
||||||
|
func (h *handler) ListDouyinProductRewards() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(douyinProductRewardListRequest)
|
||||||
|
if err := ctx.ShouldBindForm(req); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, validation.Error(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Page <= 0 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize <= 0 {
|
||||||
|
req.PageSize = 20
|
||||||
|
}
|
||||||
|
|
||||||
|
db := h.repo.GetDbR().Model(&model.DouyinProductRewards{})
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10002, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var list []model.DouyinProductRewards
|
||||||
|
if err := db.Order("id DESC").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&list).Error; err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10003, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
res := douyinProductRewardListResponse{
|
||||||
|
List: make([]douyinProductRewardItem, len(list)),
|
||||||
|
Total: total,
|
||||||
|
Page: req.Page,
|
||||||
|
}
|
||||||
|
for i, r := range list {
|
||||||
|
res.List[i] = douyinProductRewardItem{
|
||||||
|
ID: r.ID,
|
||||||
|
ProductID: r.ProductID,
|
||||||
|
ProductName: r.ProductName,
|
||||||
|
RewardType: r.RewardType,
|
||||||
|
RewardPayload: json.RawMessage(r.RewardPayload),
|
||||||
|
Quantity: r.Quantity,
|
||||||
|
Status: r.Status,
|
||||||
|
CreatedAt: r.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ctx.Payload(res)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type createDouyinProductRewardRequest struct {
|
||||||
|
ProductID string `json:"product_id" binding:"required"`
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
RewardType string `json:"reward_type" binding:"required"`
|
||||||
|
RewardPayload json.RawMessage `json:"reward_payload"`
|
||||||
|
Quantity int32 `json:"quantity"`
|
||||||
|
Status int32 `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateDouyinProductReward 创建抖店商品奖励规则
|
||||||
|
func (h *handler) CreateDouyinProductReward() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(createDouyinProductRewardRequest)
|
||||||
|
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, validation.Error(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Quantity <= 0 {
|
||||||
|
req.Quantity = 1
|
||||||
|
}
|
||||||
|
if req.Status == 0 {
|
||||||
|
req.Status = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
row := &model.DouyinProductRewards{
|
||||||
|
ProductID: req.ProductID,
|
||||||
|
ProductName: req.ProductName,
|
||||||
|
RewardType: req.RewardType,
|
||||||
|
RewardPayload: datatypes.JSON(req.RewardPayload),
|
||||||
|
Quantity: req.Quantity,
|
||||||
|
Status: req.Status,
|
||||||
|
}
|
||||||
|
if err := h.repo.GetDbW().Create(row).Error; err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10002, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(map[string]any{"id": row.ID, "message": "创建成功"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type updateDouyinProductRewardRequest struct {
|
||||||
|
ProductName string `json:"product_name"`
|
||||||
|
RewardType string `json:"reward_type"`
|
||||||
|
RewardPayload json.RawMessage `json:"reward_payload"`
|
||||||
|
Quantity int32 `json:"quantity"`
|
||||||
|
Status int32 `json:"status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateDouyinProductReward 更新抖店商品奖励规则
|
||||||
|
func (h *handler) UpdateDouyinProductReward() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
idStr := ctx.Param("id")
|
||||||
|
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if id <= 0 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "无效的ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
req := new(updateDouyinProductRewardRequest)
|
||||||
|
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10002, validation.Error(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{
|
||||||
|
"product_name": req.ProductName,
|
||||||
|
"reward_type": req.RewardType,
|
||||||
|
"reward_payload": datatypes.JSON(req.RewardPayload),
|
||||||
|
"quantity": req.Quantity,
|
||||||
|
"status": req.Status,
|
||||||
|
}
|
||||||
|
if err := h.repo.GetDbW().Model(&model.DouyinProductRewards{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10003, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(map[string]string{"message": "更新成功"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteDouyinProductReward 删除抖店商品奖励规则
|
||||||
|
func (h *handler) DeleteDouyinProductReward() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
idStr := ctx.Param("id")
|
||||||
|
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||||
|
if id <= 0 {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, 10001, "无效的ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.repo.GetDbW().Delete(&model.DouyinProductRewards{}, id).Error; err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, 10002, err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ctx.Payload(map[string]string{"message": "删除成功"})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -61,9 +61,16 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
|
|||||||
if req.PageSize > 100 {
|
if req.PageSize > 100 {
|
||||||
req.PageSize = 100
|
req.PageSize = 100
|
||||||
}
|
}
|
||||||
|
u := h.readDB.Users
|
||||||
|
c := h.readDB.Channels
|
||||||
|
|
||||||
q := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
q := h.readDB.Users.WithContext(ctx.RequestContext()).ReadDB().
|
||||||
LeftJoin(h.readDB.Channels, h.readDB.Channels.ID.EqCol(h.readDB.Users.ChannelID)).
|
LeftJoin(c, c.ID.EqCol(u.ChannelID)).
|
||||||
Select(h.readDB.Users.ALL, h.readDB.Channels.Name.As("channel_name"), h.readDB.Channels.Code.As("channel_code"))
|
Select(
|
||||||
|
u.ALL,
|
||||||
|
c.Name.As("channel_name"),
|
||||||
|
c.Code.As("channel_code"),
|
||||||
|
)
|
||||||
|
|
||||||
// 应用搜索条件
|
// 应用搜索条件
|
||||||
if req.ID != nil {
|
if req.ID != nil {
|
||||||
@ -169,10 +176,13 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
|
|||||||
// 批量查询消费统计
|
// 批量查询消费统计
|
||||||
todayConsume := make(map[int64]int64)
|
todayConsume := make(map[int64]int64)
|
||||||
sevenDayConsume := make(map[int64]int64)
|
sevenDayConsume := make(map[int64]int64)
|
||||||
|
thirtyDayConsume := make(map[int64]int64)
|
||||||
|
totalConsume := make(map[int64]int64)
|
||||||
if len(userIDs) > 0 {
|
if len(userIDs) > 0 {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
todayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
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 {
|
type consumeResult struct {
|
||||||
UserID int64
|
UserID int64
|
||||||
@ -184,7 +194,7 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
|
|||||||
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||||||
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
|
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
|
||||||
Where(h.readDB.Orders.UserID.In(userIDs...)).
|
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)).
|
Where(h.readDB.Orders.CreatedAt.Gte(todayStart)).
|
||||||
Group(h.readDB.Orders.UserID).
|
Group(h.readDB.Orders.UserID).
|
||||||
Scan(&todayRes)
|
Scan(&todayRes)
|
||||||
@ -197,13 +207,110 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
|
|||||||
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||||||
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
|
Select(h.readDB.Orders.UserID, h.readDB.Orders.ActualAmount.Sum().As("amount")).
|
||||||
Where(h.readDB.Orders.UserID.In(userIDs...)).
|
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)).
|
Where(h.readDB.Orders.CreatedAt.Gte(sevenDayStart)).
|
||||||
Group(h.readDB.Orders.UserID).
|
Group(h.readDB.Orders.UserID).
|
||||||
Scan(&sevenRes)
|
Scan(&sevenRes)
|
||||||
for _, r := range sevenRes {
|
for _, r := range sevenRes {
|
||||||
sevenDayConsume[r.UserID] = r.Amount
|
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
|
rsp.Page = req.Page
|
||||||
@ -212,20 +319,26 @@ func (h *handler) ListAppUsers() core.HandlerFunc {
|
|||||||
rsp.List = make([]adminUserItem, len(rows))
|
rsp.List = make([]adminUserItem, len(rows))
|
||||||
for i, v := range rows {
|
for i, v := range rows {
|
||||||
rsp.List[i] = adminUserItem{
|
rsp.List[i] = adminUserItem{
|
||||||
ID: v.ID,
|
ID: v.ID,
|
||||||
Nickname: v.Nickname,
|
Nickname: v.Nickname,
|
||||||
Avatar: v.Avatar,
|
Avatar: v.Avatar,
|
||||||
InviteCode: v.InviteCode,
|
InviteCode: v.InviteCode,
|
||||||
InviterID: v.InviterID,
|
InviterID: v.InviterID,
|
||||||
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
|
InviterNickname: inviterNicknames[v.InviterID],
|
||||||
DouyinID: v.DouyinID,
|
CreatedAt: v.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||||
ChannelName: v.ChannelName,
|
DouyinID: v.DouyinID,
|
||||||
ChannelCode: v.ChannelCode,
|
ChannelName: v.ChannelName,
|
||||||
PointsBalance: pointBalances[v.ID],
|
ChannelCode: v.ChannelCode,
|
||||||
CouponsCount: couponCounts[v.ID],
|
PointsBalance: pointBalances[v.ID],
|
||||||
ItemCardsCount: cardCounts[v.ID],
|
CouponsCount: couponCounts[v.ID],
|
||||||
TodayConsume: todayConsume[v.ID],
|
ItemCardsCount: cardCounts[v.ID],
|
||||||
SevenDayConsume: sevenDayConsume[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)
|
ctx.Payload(rsp)
|
||||||
@ -618,20 +731,26 @@ type pointsBalanceResponse struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type adminUserItem struct {
|
type adminUserItem struct {
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
InviteCode string `json:"invite_code"`
|
InviteCode string `json:"invite_code"`
|
||||||
InviterID int64 `json:"inviter_id"`
|
InviterID int64 `json:"inviter_id"`
|
||||||
CreatedAt string `json:"created_at"`
|
InviterNickname string `json:"inviter_nickname"` // 邀请人昵称
|
||||||
DouyinID string `json:"douyin_id"`
|
CreatedAt string `json:"created_at"`
|
||||||
ChannelName string `json:"channel_name"`
|
DouyinID string `json:"douyin_id"`
|
||||||
ChannelCode string `json:"channel_code"`
|
ChannelName string `json:"channel_name"`
|
||||||
PointsBalance int64 `json:"points_balance"`
|
ChannelCode string `json:"channel_code"`
|
||||||
CouponsCount int64 `json:"coupons_count"`
|
PointsBalance int64 `json:"points_balance"`
|
||||||
ItemCardsCount int64 `json:"item_cards_count"`
|
CouponsCount int64 `json:"coupons_count"`
|
||||||
TodayConsume int64 `json:"today_consume"`
|
ItemCardsCount int64 `json:"item_cards_count"`
|
||||||
SevenDayConsume int64 `json:"seven_day_consume"`
|
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 查看用户积分余额
|
// ListAppUsers 管理端用户列表GetUserPointsBalance 查看用户积分余额
|
||||||
|
|||||||
@ -12,25 +12,29 @@ import (
|
|||||||
// UserProfileResponse 用户综合画像
|
// UserProfileResponse 用户综合画像
|
||||||
type UserProfileResponse struct {
|
type UserProfileResponse struct {
|
||||||
// 基本信息
|
// 基本信息
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
Avatar string `json:"avatar"`
|
Avatar string `json:"avatar"`
|
||||||
Mobile string `json:"mobile"`
|
Mobile string `json:"mobile"`
|
||||||
InviteCode string `json:"invite_code"`
|
InviteCode string `json:"invite_code"`
|
||||||
InviterID int64 `json:"inviter_id"`
|
InviterID int64 `json:"inviter_id"`
|
||||||
ChannelID int64 `json:"channel_id"`
|
ChannelID int64 `json:"channel_id"`
|
||||||
CreatedAt string `json:"created_at"`
|
CreatedAt string `json:"created_at"`
|
||||||
DouyinID string `json:"douyin_id"`
|
DouyinID string `json:"douyin_id"`
|
||||||
|
InviterNickname string `json:"inviter_nickname"` // 邀请人昵称
|
||||||
|
|
||||||
// 邀请统计
|
// 邀请统计
|
||||||
InviteCount int64 `json:"invite_count"`
|
InviteCount int64 `json:"invite_count"`
|
||||||
|
|
||||||
// 生命周期财务指标
|
// 生命周期财务指标
|
||||||
LifetimeStats struct {
|
LifetimeStats struct {
|
||||||
TotalPaid int64 `json:"total_paid"` // 累计支付
|
TotalPaid int64 `json:"total_paid"` // 累计支付
|
||||||
TotalRefunded int64 `json:"total_refunded"` // 累计退款
|
TotalRefunded int64 `json:"total_refunded"` // 累计退款
|
||||||
NetCashCost int64 `json:"net_cash_cost"` // 净现金支出
|
NetCashCost int64 `json:"net_cash_cost"` // 净现金支出
|
||||||
OrderCount int64 `json:"order_count"` // 订单数
|
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"`
|
} `json:"lifetime_stats"`
|
||||||
|
|
||||||
// 当前资产快照
|
// 当前资产快照
|
||||||
@ -42,6 +46,8 @@ type UserProfileResponse struct {
|
|||||||
CouponValue int64 `json:"coupon_value"` // 持有优惠券价值
|
CouponValue int64 `json:"coupon_value"` // 持有优惠券价值
|
||||||
ItemCardCount int64 `json:"item_card_count"` // 持有道具卡数
|
ItemCardCount int64 `json:"item_card_count"` // 持有道具卡数
|
||||||
ItemCardValue int64 `json:"item_card_value"` // 持有道具卡价值
|
ItemCardValue int64 `json:"item_card_value"` // 持有道具卡价值
|
||||||
|
GamePassCount int64 `json:"game_pass_count"` // 持有次数卡数
|
||||||
|
GameTicketCount int64 `json:"game_ticket_count"` // 持有游戏资格数
|
||||||
TotalAssetValue int64 `json:"total_asset_value"` // 总资产估值
|
TotalAssetValue int64 `json:"total_asset_value"` // 总资产估值
|
||||||
ProfitLossRatio float64 `json:"profit_loss_ratio"` // 累计盈亏比
|
ProfitLossRatio float64 `json:"profit_loss_ratio"` // 累计盈亏比
|
||||||
} `json:"current_assets"`
|
} `json:"current_assets"`
|
||||||
@ -84,25 +90,70 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
|
|||||||
rsp.DouyinID = user.DouyinID
|
rsp.DouyinID = user.DouyinID
|
||||||
rsp.CreatedAt = user.CreatedAt.Format(time.RFC3339)
|
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. 邀请统计
|
// 2. 邀请统计
|
||||||
rsp.InviteCount, _ = h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.InviterID.Eq(userID)).Count()
|
rsp.InviteCount, _ = h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.InviterID.Eq(userID)).Count()
|
||||||
|
|
||||||
// 3. 生命周期财务指标
|
// 3. 生命周期财务指标
|
||||||
// 3.1 累计支付 & 订单数 - 只统计未退款的订单
|
// 3.1 消费统计
|
||||||
type orderStats struct {
|
type orderStats struct {
|
||||||
TotalPaid int64
|
TotalPaid int64
|
||||||
OrderCount int64
|
OrderCount int64
|
||||||
|
TodayPaid int64
|
||||||
|
SevenDayPaid int64
|
||||||
|
ThirtyDayPaid int64
|
||||||
}
|
}
|
||||||
var os orderStats
|
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().
|
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.UserID.Eq(userID)).
|
||||||
Where(h.readDB.Orders.Status.Eq(2)). // 仅已支付,不含已退款
|
Where(h.readDB.Orders.Status.Eq(2)).
|
||||||
Scan(&os)
|
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.TotalPaid = os.TotalPaid
|
||||||
rsp.LifetimeStats.OrderCount = os.OrderCount
|
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
|
var totalRefunded int64
|
||||||
_ = h.repo.GetDbR().Raw(`
|
_ = h.repo.GetDbR().Raw(`
|
||||||
SELECT COALESCE(SUM(pr.amount_refund), 0)
|
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'
|
WHERE o.user_id = ? AND pr.status = 'SUCCESS'
|
||||||
`, userID).Scan(&totalRefunded).Error
|
`, userID).Scan(&totalRefunded).Error
|
||||||
rsp.LifetimeStats.TotalRefunded = totalRefunded
|
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. 当前资产快照
|
||||||
// 4.1 积分余额
|
// 4.1 积分余额
|
||||||
@ -164,11 +218,23 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
|
|||||||
rsp.CurrentAssets.ItemCardCount = cds.Count
|
rsp.CurrentAssets.ItemCardCount = cds.Count
|
||||||
rsp.CurrentAssets.ItemCardValue = cds.Value
|
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 总资产估值
|
// 4.5 总资产估值
|
||||||
|
// 估值逻辑:积分余额 + 商品价值 + 优惠券价值 + 道具卡价值 + 次数卡(2元/次) + 游戏资格(1元/场)
|
||||||
|
gamePassValue := rsp.CurrentAssets.GamePassCount * 200 // 估值:2元/次
|
||||||
|
gameTicketValue := rsp.CurrentAssets.GameTicketCount * 100 // 估值:1元/场
|
||||||
|
|
||||||
rsp.CurrentAssets.TotalAssetValue = rsp.CurrentAssets.PointsBalance +
|
rsp.CurrentAssets.TotalAssetValue = rsp.CurrentAssets.PointsBalance +
|
||||||
rsp.CurrentAssets.InventoryValue +
|
rsp.CurrentAssets.InventoryValue +
|
||||||
rsp.CurrentAssets.CouponValue +
|
rsp.CurrentAssets.CouponValue +
|
||||||
rsp.CurrentAssets.ItemCardValue
|
rsp.CurrentAssets.ItemCardValue +
|
||||||
|
gamePassValue +
|
||||||
|
gameTicketValue
|
||||||
|
|
||||||
// 4.6 累计盈亏比
|
// 4.6 累计盈亏比
|
||||||
if rsp.LifetimeStats.NetCashCost > 0 {
|
if rsp.LifetimeStats.NetCashCost > 0 {
|
||||||
|
|||||||
@ -17,8 +17,8 @@ type userProfitLossRequest struct {
|
|||||||
|
|
||||||
type userProfitLossPoint struct {
|
type userProfitLossPoint struct {
|
||||||
Date string `json:"date"`
|
Date string `json:"date"`
|
||||||
Cost int64 `json:"cost"` // 净支出(仅已支付未退款订单)
|
Cost int64 `json:"cost"` // 累计投入(已支付-已退款)
|
||||||
Value int64 `json:"value"` // 当前资产快照(实时)
|
Value int64 `json:"value"` // 累计产出(当前资产快照)
|
||||||
Profit int64 `json:"profit"` // 净盈亏
|
Profit int64 `json:"profit"` // 净盈亏
|
||||||
Ratio float64 `json:"ratio"` // 盈亏比
|
Ratio float64 `json:"ratio"` // 盈亏比
|
||||||
Breakdown struct {
|
Breakdown struct {
|
||||||
@ -30,8 +30,14 @@ type userProfitLossPoint struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type userProfitLossResponse struct {
|
type userProfitLossResponse struct {
|
||||||
Granularity string `json:"granularity"`
|
Granularity string `json:"granularity"`
|
||||||
List []userProfitLossPoint `json:"list"`
|
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 {
|
CurrentAssets struct {
|
||||||
Points int64 `json:"points"`
|
Points int64 `json:"points"`
|
||||||
Products int64 `json:"products"`
|
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
|
_ = 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
|
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().
|
orderRows, _ := h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||||||
Where(h.readDB.Orders.UserID.Eq(userID)).
|
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.Gte(start)).
|
||||||
Where(h.readDB.Orders.CreatedAt.Lte(end)).
|
Where(h.readDB.Orders.CreatedAt.Lte(end)).
|
||||||
Find()
|
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. 按时间分桶计算 ---
|
// --- 3. 按时间分桶计算 ---
|
||||||
list := make([]userProfitLossPoint, len(buckets))
|
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)
|
return (t.After(b.Start) || t.Equal(b.Start)) && t.Before(b.End)
|
||||||
}
|
}
|
||||||
|
|
||||||
var cumulativeCost int64 = 0
|
cumulativeCost := baseCost
|
||||||
|
|
||||||
for i, b := range buckets {
|
for i, b := range buckets {
|
||||||
p := &list[i]
|
p := &list[i]
|
||||||
p.Date = b.Label
|
p.Date = b.Label
|
||||||
|
|
||||||
// 计算该时间段内的支出
|
// 计算该时间段内的净投入变化
|
||||||
var periodCost int64 = 0
|
var periodDelta int64 = 0
|
||||||
for _, o := range orderRows {
|
for _, o := range orderRows {
|
||||||
if inBucket(o.CreatedAt, b) {
|
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.Value = totalAssetValue
|
||||||
p.Breakdown.Points = curAssets.Points
|
p.Breakdown.Points = curAssets.Points
|
||||||
p.Breakdown.Products = curAssets.Products
|
p.Breakdown.Products = curAssets.Products
|
||||||
@ -132,42 +184,46 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算累计值用于汇总显示
|
// 汇总数据
|
||||||
var totalCost int64 = 0
|
var totalCost int64 = 0
|
||||||
for _, o := range orderRows {
|
_ = h.readDB.Orders.WithContext(ctx.RequestContext()).ReadDB().
|
||||||
totalCost += o.ActualAmount
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
// 最后一个桶使用累计成本
|
resp := userProfitLossResponse{
|
||||||
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{
|
|
||||||
Granularity: gran,
|
Granularity: gran,
|
||||||
List: list,
|
List: list,
|
||||||
CurrentAssets: struct {
|
}
|
||||||
Points int64 `json:"points"`
|
resp.Summary.TotalCost = finalNetCost
|
||||||
Products int64 `json:"products"`
|
resp.Summary.TotalValue = totalAssetValue
|
||||||
Cards int64 `json:"cards"`
|
resp.Summary.TotalProfit = totalAssetValue - finalNetCost
|
||||||
Coupons int64 `json:"coupons"`
|
if finalNetCost > 0 {
|
||||||
Total int64 `json:"total"`
|
resp.Summary.AvgRatio = float64(totalAssetValue) / float64(finalNetCost)
|
||||||
}{
|
} else if totalAssetValue > 0 {
|
||||||
Points: curAssets.Points,
|
resp.Summary.AvgRatio = 99.9
|
||||||
Products: curAssets.Products,
|
}
|
||||||
Cards: curAssets.Cards,
|
|
||||||
Coupons: curAssets.Coupons,
|
resp.CurrentAssets.Points = curAssets.Points
|
||||||
Total: totalAssetValue,
|
resp.CurrentAssets.Products = curAssets.Products
|
||||||
},
|
resp.CurrentAssets.Cards = curAssets.Cards
|
||||||
})
|
resp.CurrentAssets.Coupons = curAssets.Coupons
|
||||||
|
resp.CurrentAssets.Total = totalAssetValue
|
||||||
|
|
||||||
|
ctx.Payload(resp)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,8 @@ import (
|
|||||||
"bindbox-game/internal/pkg/logger"
|
"bindbox-game/internal/pkg/logger"
|
||||||
"bindbox-game/internal/repository/mysql"
|
"bindbox-game/internal/repository/mysql"
|
||||||
"bindbox-game/internal/repository/mysql/dao"
|
"bindbox-game/internal/repository/mysql/dao"
|
||||||
|
"bindbox-game/internal/service/douyin"
|
||||||
|
"bindbox-game/internal/service/sysconfig"
|
||||||
tasksvc "bindbox-game/internal/service/task_center"
|
tasksvc "bindbox-game/internal/service/task_center"
|
||||||
usersvc "bindbox-game/internal/service/user"
|
usersvc "bindbox-game/internal/service/user"
|
||||||
)
|
)
|
||||||
@ -14,9 +16,19 @@ type handler struct {
|
|||||||
readDB *dao.Query
|
readDB *dao.Query
|
||||||
user usersvc.Service
|
user usersvc.Service
|
||||||
task tasksvc.Service
|
task tasksvc.Service
|
||||||
|
douyin douyin.Service
|
||||||
repo mysql.Repo
|
repo mysql.Repo
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(logger logger.CustomLogger, db mysql.Repo, taskSvc tasksvc.Service) *handler {
|
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,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
77
internal/api/user/bind_douyin_order_app.go
Normal file
77
internal/api/user/bind_douyin_order_app.go
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"bindbox-game/internal/code"
|
||||||
|
"bindbox-game/internal/pkg/core"
|
||||||
|
"bindbox-game/internal/pkg/validation"
|
||||||
|
)
|
||||||
|
|
||||||
|
type bindDouyinOrderRequest struct {
|
||||||
|
DouyinID string `json:"douyin_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type bindDouyinOrderResponse struct {
|
||||||
|
DouyinID string `json:"douyin_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindDouyinOrder 绑定抖音ID
|
||||||
|
// @Summary 绑定抖音ID
|
||||||
|
// @Description 输入抖音号(Buyer ID),绑定到当前用户
|
||||||
|
// @Tags APP端.用户
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security LoginVerifyToken
|
||||||
|
// @Param RequestBody body bindDouyinOrderRequest true "请求参数"
|
||||||
|
// @Success 200 {object} bindDouyinOrderResponse
|
||||||
|
// @Failure 400 {object} code.Failure
|
||||||
|
// @Router /api/app/users/douyin/bind [post]
|
||||||
|
func (h *handler) BindDouyinOrder() core.HandlerFunc {
|
||||||
|
return func(ctx core.Context) {
|
||||||
|
req := new(bindDouyinOrderRequest)
|
||||||
|
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.DouyinID == "" {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "抖音号不能为空"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentUserID := int64(ctx.SessionUserInfo().Id)
|
||||||
|
|
||||||
|
// 0. 检查当前用户信息
|
||||||
|
currentUser, err := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.ID.Eq(currentUserID)).First()
|
||||||
|
if err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "获取用户信息失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经绑定了相同的 ID,直接返回成功
|
||||||
|
if currentUser.DouyinUserID == req.DouyinID {
|
||||||
|
ctx.Payload(&bindDouyinOrderResponse{DouyinID: req.DouyinID})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 检查该抖音号是否已被其他本地账号绑定
|
||||||
|
existedUser, _ := h.readDB.Users.WithContext(ctx.RequestContext()).Where(h.readDB.Users.DouyinUserID.Eq(req.DouyinID)).First()
|
||||||
|
if existedUser != nil && existedUser.ID != currentUserID {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, "该抖音号已被其他账号绑定"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 更新本地用户表的 douyin_user_id
|
||||||
|
if _, err := h.writeDB.Users.WithContext(ctx.RequestContext()).Where(h.writeDB.Users.ID.Eq(currentUserID)).Updates(map[string]any{
|
||||||
|
"douyin_user_id": req.DouyinID,
|
||||||
|
}); err != nil {
|
||||||
|
ctx.AbortWithError(core.Error(http.StatusInternalServerError, code.ServerError, "更新用户信息失败"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.Payload(&bindDouyinOrderResponse{
|
||||||
|
DouyinID: req.DouyinID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -28,13 +28,11 @@ var (
|
|||||||
AuditRollbackLogs *auditRollbackLogs
|
AuditRollbackLogs *auditRollbackLogs
|
||||||
Banner *banner
|
Banner *banner
|
||||||
Channels *channels
|
Channels *channels
|
||||||
DouyinOrders *douyinOrders
|
|
||||||
GamePassPackages *gamePassPackages
|
GamePassPackages *gamePassPackages
|
||||||
GameTicketLogs *gameTicketLogs
|
GameTicketLogs *gameTicketLogs
|
||||||
IssuePositionClaims *issuePositionClaims
|
IssuePositionClaims *issuePositionClaims
|
||||||
LogOperation *logOperation
|
LogOperation *logOperation
|
||||||
LogRequest *logRequest
|
LogRequest *logRequest
|
||||||
LotteryRefundLogs *lotteryRefundLogs
|
|
||||||
MatchingCardTypes *matchingCardTypes
|
MatchingCardTypes *matchingCardTypes
|
||||||
MenuActions *menuActions
|
MenuActions *menuActions
|
||||||
Menus *menus
|
Menus *menus
|
||||||
@ -61,11 +59,9 @@ var (
|
|||||||
SystemItemCards *systemItemCards
|
SystemItemCards *systemItemCards
|
||||||
SystemTitleEffects *systemTitleEffects
|
SystemTitleEffects *systemTitleEffects
|
||||||
SystemTitles *systemTitles
|
SystemTitles *systemTitles
|
||||||
TaskCenterEventLogs *taskCenterEventLogs
|
|
||||||
TaskCenterTaskRewards *taskCenterTaskRewards
|
TaskCenterTaskRewards *taskCenterTaskRewards
|
||||||
TaskCenterTaskTiers *taskCenterTaskTiers
|
TaskCenterTaskTiers *taskCenterTaskTiers
|
||||||
TaskCenterTasks *taskCenterTasks
|
TaskCenterTasks *taskCenterTasks
|
||||||
TaskCenterUserProgress *taskCenterUserProgress
|
|
||||||
UserAddresses *userAddresses
|
UserAddresses *userAddresses
|
||||||
UserCouponLedger *userCouponLedger
|
UserCouponLedger *userCouponLedger
|
||||||
UserCoupons *userCoupons
|
UserCoupons *userCoupons
|
||||||
@ -95,13 +91,11 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
|
|||||||
AuditRollbackLogs = &Q.AuditRollbackLogs
|
AuditRollbackLogs = &Q.AuditRollbackLogs
|
||||||
Banner = &Q.Banner
|
Banner = &Q.Banner
|
||||||
Channels = &Q.Channels
|
Channels = &Q.Channels
|
||||||
DouyinOrders = &Q.DouyinOrders
|
|
||||||
GamePassPackages = &Q.GamePassPackages
|
GamePassPackages = &Q.GamePassPackages
|
||||||
GameTicketLogs = &Q.GameTicketLogs
|
GameTicketLogs = &Q.GameTicketLogs
|
||||||
IssuePositionClaims = &Q.IssuePositionClaims
|
IssuePositionClaims = &Q.IssuePositionClaims
|
||||||
LogOperation = &Q.LogOperation
|
LogOperation = &Q.LogOperation
|
||||||
LogRequest = &Q.LogRequest
|
LogRequest = &Q.LogRequest
|
||||||
LotteryRefundLogs = &Q.LotteryRefundLogs
|
|
||||||
MatchingCardTypes = &Q.MatchingCardTypes
|
MatchingCardTypes = &Q.MatchingCardTypes
|
||||||
MenuActions = &Q.MenuActions
|
MenuActions = &Q.MenuActions
|
||||||
Menus = &Q.Menus
|
Menus = &Q.Menus
|
||||||
@ -128,11 +122,9 @@ func SetDefault(db *gorm.DB, opts ...gen.DOOption) {
|
|||||||
SystemItemCards = &Q.SystemItemCards
|
SystemItemCards = &Q.SystemItemCards
|
||||||
SystemTitleEffects = &Q.SystemTitleEffects
|
SystemTitleEffects = &Q.SystemTitleEffects
|
||||||
SystemTitles = &Q.SystemTitles
|
SystemTitles = &Q.SystemTitles
|
||||||
TaskCenterEventLogs = &Q.TaskCenterEventLogs
|
|
||||||
TaskCenterTaskRewards = &Q.TaskCenterTaskRewards
|
TaskCenterTaskRewards = &Q.TaskCenterTaskRewards
|
||||||
TaskCenterTaskTiers = &Q.TaskCenterTaskTiers
|
TaskCenterTaskTiers = &Q.TaskCenterTaskTiers
|
||||||
TaskCenterTasks = &Q.TaskCenterTasks
|
TaskCenterTasks = &Q.TaskCenterTasks
|
||||||
TaskCenterUserProgress = &Q.TaskCenterUserProgress
|
|
||||||
UserAddresses = &Q.UserAddresses
|
UserAddresses = &Q.UserAddresses
|
||||||
UserCouponLedger = &Q.UserCouponLedger
|
UserCouponLedger = &Q.UserCouponLedger
|
||||||
UserCoupons = &Q.UserCoupons
|
UserCoupons = &Q.UserCoupons
|
||||||
@ -163,13 +155,11 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
|
|||||||
AuditRollbackLogs: newAuditRollbackLogs(db, opts...),
|
AuditRollbackLogs: newAuditRollbackLogs(db, opts...),
|
||||||
Banner: newBanner(db, opts...),
|
Banner: newBanner(db, opts...),
|
||||||
Channels: newChannels(db, opts...),
|
Channels: newChannels(db, opts...),
|
||||||
DouyinOrders: newDouyinOrders(db, opts...),
|
|
||||||
GamePassPackages: newGamePassPackages(db, opts...),
|
GamePassPackages: newGamePassPackages(db, opts...),
|
||||||
GameTicketLogs: newGameTicketLogs(db, opts...),
|
GameTicketLogs: newGameTicketLogs(db, opts...),
|
||||||
IssuePositionClaims: newIssuePositionClaims(db, opts...),
|
IssuePositionClaims: newIssuePositionClaims(db, opts...),
|
||||||
LogOperation: newLogOperation(db, opts...),
|
LogOperation: newLogOperation(db, opts...),
|
||||||
LogRequest: newLogRequest(db, opts...),
|
LogRequest: newLogRequest(db, opts...),
|
||||||
LotteryRefundLogs: newLotteryRefundLogs(db, opts...),
|
|
||||||
MatchingCardTypes: newMatchingCardTypes(db, opts...),
|
MatchingCardTypes: newMatchingCardTypes(db, opts...),
|
||||||
MenuActions: newMenuActions(db, opts...),
|
MenuActions: newMenuActions(db, opts...),
|
||||||
Menus: newMenus(db, opts...),
|
Menus: newMenus(db, opts...),
|
||||||
@ -196,11 +186,9 @@ func Use(db *gorm.DB, opts ...gen.DOOption) *Query {
|
|||||||
SystemItemCards: newSystemItemCards(db, opts...),
|
SystemItemCards: newSystemItemCards(db, opts...),
|
||||||
SystemTitleEffects: newSystemTitleEffects(db, opts...),
|
SystemTitleEffects: newSystemTitleEffects(db, opts...),
|
||||||
SystemTitles: newSystemTitles(db, opts...),
|
SystemTitles: newSystemTitles(db, opts...),
|
||||||
TaskCenterEventLogs: newTaskCenterEventLogs(db, opts...),
|
|
||||||
TaskCenterTaskRewards: newTaskCenterTaskRewards(db, opts...),
|
TaskCenterTaskRewards: newTaskCenterTaskRewards(db, opts...),
|
||||||
TaskCenterTaskTiers: newTaskCenterTaskTiers(db, opts...),
|
TaskCenterTaskTiers: newTaskCenterTaskTiers(db, opts...),
|
||||||
TaskCenterTasks: newTaskCenterTasks(db, opts...),
|
TaskCenterTasks: newTaskCenterTasks(db, opts...),
|
||||||
TaskCenterUserProgress: newTaskCenterUserProgress(db, opts...),
|
|
||||||
UserAddresses: newUserAddresses(db, opts...),
|
UserAddresses: newUserAddresses(db, opts...),
|
||||||
UserCouponLedger: newUserCouponLedger(db, opts...),
|
UserCouponLedger: newUserCouponLedger(db, opts...),
|
||||||
UserCoupons: newUserCoupons(db, opts...),
|
UserCoupons: newUserCoupons(db, opts...),
|
||||||
@ -232,13 +220,11 @@ type Query struct {
|
|||||||
AuditRollbackLogs auditRollbackLogs
|
AuditRollbackLogs auditRollbackLogs
|
||||||
Banner banner
|
Banner banner
|
||||||
Channels channels
|
Channels channels
|
||||||
DouyinOrders douyinOrders
|
|
||||||
GamePassPackages gamePassPackages
|
GamePassPackages gamePassPackages
|
||||||
GameTicketLogs gameTicketLogs
|
GameTicketLogs gameTicketLogs
|
||||||
IssuePositionClaims issuePositionClaims
|
IssuePositionClaims issuePositionClaims
|
||||||
LogOperation logOperation
|
LogOperation logOperation
|
||||||
LogRequest logRequest
|
LogRequest logRequest
|
||||||
LotteryRefundLogs lotteryRefundLogs
|
|
||||||
MatchingCardTypes matchingCardTypes
|
MatchingCardTypes matchingCardTypes
|
||||||
MenuActions menuActions
|
MenuActions menuActions
|
||||||
Menus menus
|
Menus menus
|
||||||
@ -265,11 +251,9 @@ type Query struct {
|
|||||||
SystemItemCards systemItemCards
|
SystemItemCards systemItemCards
|
||||||
SystemTitleEffects systemTitleEffects
|
SystemTitleEffects systemTitleEffects
|
||||||
SystemTitles systemTitles
|
SystemTitles systemTitles
|
||||||
TaskCenterEventLogs taskCenterEventLogs
|
|
||||||
TaskCenterTaskRewards taskCenterTaskRewards
|
TaskCenterTaskRewards taskCenterTaskRewards
|
||||||
TaskCenterTaskTiers taskCenterTaskTiers
|
TaskCenterTaskTiers taskCenterTaskTiers
|
||||||
TaskCenterTasks taskCenterTasks
|
TaskCenterTasks taskCenterTasks
|
||||||
TaskCenterUserProgress taskCenterUserProgress
|
|
||||||
UserAddresses userAddresses
|
UserAddresses userAddresses
|
||||||
UserCouponLedger userCouponLedger
|
UserCouponLedger userCouponLedger
|
||||||
UserCoupons userCoupons
|
UserCoupons userCoupons
|
||||||
@ -302,13 +286,11 @@ func (q *Query) clone(db *gorm.DB) *Query {
|
|||||||
AuditRollbackLogs: q.AuditRollbackLogs.clone(db),
|
AuditRollbackLogs: q.AuditRollbackLogs.clone(db),
|
||||||
Banner: q.Banner.clone(db),
|
Banner: q.Banner.clone(db),
|
||||||
Channels: q.Channels.clone(db),
|
Channels: q.Channels.clone(db),
|
||||||
DouyinOrders: q.DouyinOrders.clone(db),
|
|
||||||
GamePassPackages: q.GamePassPackages.clone(db),
|
GamePassPackages: q.GamePassPackages.clone(db),
|
||||||
GameTicketLogs: q.GameTicketLogs.clone(db),
|
GameTicketLogs: q.GameTicketLogs.clone(db),
|
||||||
IssuePositionClaims: q.IssuePositionClaims.clone(db),
|
IssuePositionClaims: q.IssuePositionClaims.clone(db),
|
||||||
LogOperation: q.LogOperation.clone(db),
|
LogOperation: q.LogOperation.clone(db),
|
||||||
LogRequest: q.LogRequest.clone(db),
|
LogRequest: q.LogRequest.clone(db),
|
||||||
LotteryRefundLogs: q.LotteryRefundLogs.clone(db),
|
|
||||||
MatchingCardTypes: q.MatchingCardTypes.clone(db),
|
MatchingCardTypes: q.MatchingCardTypes.clone(db),
|
||||||
MenuActions: q.MenuActions.clone(db),
|
MenuActions: q.MenuActions.clone(db),
|
||||||
Menus: q.Menus.clone(db),
|
Menus: q.Menus.clone(db),
|
||||||
@ -335,11 +317,9 @@ func (q *Query) clone(db *gorm.DB) *Query {
|
|||||||
SystemItemCards: q.SystemItemCards.clone(db),
|
SystemItemCards: q.SystemItemCards.clone(db),
|
||||||
SystemTitleEffects: q.SystemTitleEffects.clone(db),
|
SystemTitleEffects: q.SystemTitleEffects.clone(db),
|
||||||
SystemTitles: q.SystemTitles.clone(db),
|
SystemTitles: q.SystemTitles.clone(db),
|
||||||
TaskCenterEventLogs: q.TaskCenterEventLogs.clone(db),
|
|
||||||
TaskCenterTaskRewards: q.TaskCenterTaskRewards.clone(db),
|
TaskCenterTaskRewards: q.TaskCenterTaskRewards.clone(db),
|
||||||
TaskCenterTaskTiers: q.TaskCenterTaskTiers.clone(db),
|
TaskCenterTaskTiers: q.TaskCenterTaskTiers.clone(db),
|
||||||
TaskCenterTasks: q.TaskCenterTasks.clone(db),
|
TaskCenterTasks: q.TaskCenterTasks.clone(db),
|
||||||
TaskCenterUserProgress: q.TaskCenterUserProgress.clone(db),
|
|
||||||
UserAddresses: q.UserAddresses.clone(db),
|
UserAddresses: q.UserAddresses.clone(db),
|
||||||
UserCouponLedger: q.UserCouponLedger.clone(db),
|
UserCouponLedger: q.UserCouponLedger.clone(db),
|
||||||
UserCoupons: q.UserCoupons.clone(db),
|
UserCoupons: q.UserCoupons.clone(db),
|
||||||
@ -379,13 +359,11 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
|
|||||||
AuditRollbackLogs: q.AuditRollbackLogs.replaceDB(db),
|
AuditRollbackLogs: q.AuditRollbackLogs.replaceDB(db),
|
||||||
Banner: q.Banner.replaceDB(db),
|
Banner: q.Banner.replaceDB(db),
|
||||||
Channels: q.Channels.replaceDB(db),
|
Channels: q.Channels.replaceDB(db),
|
||||||
DouyinOrders: q.DouyinOrders.replaceDB(db),
|
|
||||||
GamePassPackages: q.GamePassPackages.replaceDB(db),
|
GamePassPackages: q.GamePassPackages.replaceDB(db),
|
||||||
GameTicketLogs: q.GameTicketLogs.replaceDB(db),
|
GameTicketLogs: q.GameTicketLogs.replaceDB(db),
|
||||||
IssuePositionClaims: q.IssuePositionClaims.replaceDB(db),
|
IssuePositionClaims: q.IssuePositionClaims.replaceDB(db),
|
||||||
LogOperation: q.LogOperation.replaceDB(db),
|
LogOperation: q.LogOperation.replaceDB(db),
|
||||||
LogRequest: q.LogRequest.replaceDB(db),
|
LogRequest: q.LogRequest.replaceDB(db),
|
||||||
LotteryRefundLogs: q.LotteryRefundLogs.replaceDB(db),
|
|
||||||
MatchingCardTypes: q.MatchingCardTypes.replaceDB(db),
|
MatchingCardTypes: q.MatchingCardTypes.replaceDB(db),
|
||||||
MenuActions: q.MenuActions.replaceDB(db),
|
MenuActions: q.MenuActions.replaceDB(db),
|
||||||
Menus: q.Menus.replaceDB(db),
|
Menus: q.Menus.replaceDB(db),
|
||||||
@ -412,11 +390,9 @@ func (q *Query) ReplaceDB(db *gorm.DB) *Query {
|
|||||||
SystemItemCards: q.SystemItemCards.replaceDB(db),
|
SystemItemCards: q.SystemItemCards.replaceDB(db),
|
||||||
SystemTitleEffects: q.SystemTitleEffects.replaceDB(db),
|
SystemTitleEffects: q.SystemTitleEffects.replaceDB(db),
|
||||||
SystemTitles: q.SystemTitles.replaceDB(db),
|
SystemTitles: q.SystemTitles.replaceDB(db),
|
||||||
TaskCenterEventLogs: q.TaskCenterEventLogs.replaceDB(db),
|
|
||||||
TaskCenterTaskRewards: q.TaskCenterTaskRewards.replaceDB(db),
|
TaskCenterTaskRewards: q.TaskCenterTaskRewards.replaceDB(db),
|
||||||
TaskCenterTaskTiers: q.TaskCenterTaskTiers.replaceDB(db),
|
TaskCenterTaskTiers: q.TaskCenterTaskTiers.replaceDB(db),
|
||||||
TaskCenterTasks: q.TaskCenterTasks.replaceDB(db),
|
TaskCenterTasks: q.TaskCenterTasks.replaceDB(db),
|
||||||
TaskCenterUserProgress: q.TaskCenterUserProgress.replaceDB(db),
|
|
||||||
UserAddresses: q.UserAddresses.replaceDB(db),
|
UserAddresses: q.UserAddresses.replaceDB(db),
|
||||||
UserCouponLedger: q.UserCouponLedger.replaceDB(db),
|
UserCouponLedger: q.UserCouponLedger.replaceDB(db),
|
||||||
UserCoupons: q.UserCoupons.replaceDB(db),
|
UserCoupons: q.UserCoupons.replaceDB(db),
|
||||||
@ -446,13 +422,11 @@ type queryCtx struct {
|
|||||||
AuditRollbackLogs *auditRollbackLogsDo
|
AuditRollbackLogs *auditRollbackLogsDo
|
||||||
Banner *bannerDo
|
Banner *bannerDo
|
||||||
Channels *channelsDo
|
Channels *channelsDo
|
||||||
DouyinOrders *douyinOrdersDo
|
|
||||||
GamePassPackages *gamePassPackagesDo
|
GamePassPackages *gamePassPackagesDo
|
||||||
GameTicketLogs *gameTicketLogsDo
|
GameTicketLogs *gameTicketLogsDo
|
||||||
IssuePositionClaims *issuePositionClaimsDo
|
IssuePositionClaims *issuePositionClaimsDo
|
||||||
LogOperation *logOperationDo
|
LogOperation *logOperationDo
|
||||||
LogRequest *logRequestDo
|
LogRequest *logRequestDo
|
||||||
LotteryRefundLogs *lotteryRefundLogsDo
|
|
||||||
MatchingCardTypes *matchingCardTypesDo
|
MatchingCardTypes *matchingCardTypesDo
|
||||||
MenuActions *menuActionsDo
|
MenuActions *menuActionsDo
|
||||||
Menus *menusDo
|
Menus *menusDo
|
||||||
@ -479,11 +453,9 @@ type queryCtx struct {
|
|||||||
SystemItemCards *systemItemCardsDo
|
SystemItemCards *systemItemCardsDo
|
||||||
SystemTitleEffects *systemTitleEffectsDo
|
SystemTitleEffects *systemTitleEffectsDo
|
||||||
SystemTitles *systemTitlesDo
|
SystemTitles *systemTitlesDo
|
||||||
TaskCenterEventLogs *taskCenterEventLogsDo
|
|
||||||
TaskCenterTaskRewards *taskCenterTaskRewardsDo
|
TaskCenterTaskRewards *taskCenterTaskRewardsDo
|
||||||
TaskCenterTaskTiers *taskCenterTaskTiersDo
|
TaskCenterTaskTiers *taskCenterTaskTiersDo
|
||||||
TaskCenterTasks *taskCenterTasksDo
|
TaskCenterTasks *taskCenterTasksDo
|
||||||
TaskCenterUserProgress *taskCenterUserProgressDo
|
|
||||||
UserAddresses *userAddressesDo
|
UserAddresses *userAddressesDo
|
||||||
UserCouponLedger *userCouponLedgerDo
|
UserCouponLedger *userCouponLedgerDo
|
||||||
UserCoupons *userCouponsDo
|
UserCoupons *userCouponsDo
|
||||||
@ -513,13 +485,11 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
|
|||||||
AuditRollbackLogs: q.AuditRollbackLogs.WithContext(ctx),
|
AuditRollbackLogs: q.AuditRollbackLogs.WithContext(ctx),
|
||||||
Banner: q.Banner.WithContext(ctx),
|
Banner: q.Banner.WithContext(ctx),
|
||||||
Channels: q.Channels.WithContext(ctx),
|
Channels: q.Channels.WithContext(ctx),
|
||||||
DouyinOrders: q.DouyinOrders.WithContext(ctx),
|
|
||||||
GamePassPackages: q.GamePassPackages.WithContext(ctx),
|
GamePassPackages: q.GamePassPackages.WithContext(ctx),
|
||||||
GameTicketLogs: q.GameTicketLogs.WithContext(ctx),
|
GameTicketLogs: q.GameTicketLogs.WithContext(ctx),
|
||||||
IssuePositionClaims: q.IssuePositionClaims.WithContext(ctx),
|
IssuePositionClaims: q.IssuePositionClaims.WithContext(ctx),
|
||||||
LogOperation: q.LogOperation.WithContext(ctx),
|
LogOperation: q.LogOperation.WithContext(ctx),
|
||||||
LogRequest: q.LogRequest.WithContext(ctx),
|
LogRequest: q.LogRequest.WithContext(ctx),
|
||||||
LotteryRefundLogs: q.LotteryRefundLogs.WithContext(ctx),
|
|
||||||
MatchingCardTypes: q.MatchingCardTypes.WithContext(ctx),
|
MatchingCardTypes: q.MatchingCardTypes.WithContext(ctx),
|
||||||
MenuActions: q.MenuActions.WithContext(ctx),
|
MenuActions: q.MenuActions.WithContext(ctx),
|
||||||
Menus: q.Menus.WithContext(ctx),
|
Menus: q.Menus.WithContext(ctx),
|
||||||
@ -546,11 +516,9 @@ func (q *Query) WithContext(ctx context.Context) *queryCtx {
|
|||||||
SystemItemCards: q.SystemItemCards.WithContext(ctx),
|
SystemItemCards: q.SystemItemCards.WithContext(ctx),
|
||||||
SystemTitleEffects: q.SystemTitleEffects.WithContext(ctx),
|
SystemTitleEffects: q.SystemTitleEffects.WithContext(ctx),
|
||||||
SystemTitles: q.SystemTitles.WithContext(ctx),
|
SystemTitles: q.SystemTitles.WithContext(ctx),
|
||||||
TaskCenterEventLogs: q.TaskCenterEventLogs.WithContext(ctx),
|
|
||||||
TaskCenterTaskRewards: q.TaskCenterTaskRewards.WithContext(ctx),
|
TaskCenterTaskRewards: q.TaskCenterTaskRewards.WithContext(ctx),
|
||||||
TaskCenterTaskTiers: q.TaskCenterTaskTiers.WithContext(ctx),
|
TaskCenterTaskTiers: q.TaskCenterTaskTiers.WithContext(ctx),
|
||||||
TaskCenterTasks: q.TaskCenterTasks.WithContext(ctx),
|
TaskCenterTasks: q.TaskCenterTasks.WithContext(ctx),
|
||||||
TaskCenterUserProgress: q.TaskCenterUserProgress.WithContext(ctx),
|
|
||||||
UserAddresses: q.UserAddresses.WithContext(ctx),
|
UserAddresses: q.UserAddresses.WithContext(ctx),
|
||||||
UserCouponLedger: q.UserCouponLedger.WithContext(ctx),
|
UserCouponLedger: q.UserCouponLedger.WithContext(ctx),
|
||||||
UserCoupons: q.UserCoupons.WithContext(ctx),
|
UserCoupons: q.UserCoupons.WithContext(ctx),
|
||||||
|
|||||||
@ -32,6 +32,8 @@ func newSystemConfigs(db *gorm.DB, opts ...gen.DOOption) systemConfigs {
|
|||||||
_systemConfigs.UpdatedAt = field.NewTime(tableName, "updated_at")
|
_systemConfigs.UpdatedAt = field.NewTime(tableName, "updated_at")
|
||||||
_systemConfigs.DeletedAt = field.NewField(tableName, "deleted_at")
|
_systemConfigs.DeletedAt = field.NewField(tableName, "deleted_at")
|
||||||
_systemConfigs.ConfigKey = field.NewString(tableName, "config_key")
|
_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.ConfigValue = field.NewString(tableName, "config_value")
|
||||||
_systemConfigs.Remark = field.NewString(tableName, "remark")
|
_systemConfigs.Remark = field.NewString(tableName, "remark")
|
||||||
|
|
||||||
@ -50,6 +52,8 @@ type systemConfigs struct {
|
|||||||
UpdatedAt field.Time
|
UpdatedAt field.Time
|
||||||
DeletedAt field.Field
|
DeletedAt field.Field
|
||||||
ConfigKey field.String
|
ConfigKey field.String
|
||||||
|
ConfigGroup field.String
|
||||||
|
IsEncrypted field.Bool
|
||||||
ConfigValue field.String
|
ConfigValue field.String
|
||||||
Remark field.String
|
Remark field.String
|
||||||
|
|
||||||
@ -73,6 +77,8 @@ func (s *systemConfigs) updateTableName(table string) *systemConfigs {
|
|||||||
s.UpdatedAt = field.NewTime(table, "updated_at")
|
s.UpdatedAt = field.NewTime(table, "updated_at")
|
||||||
s.DeletedAt = field.NewField(table, "deleted_at")
|
s.DeletedAt = field.NewField(table, "deleted_at")
|
||||||
s.ConfigKey = field.NewString(table, "config_key")
|
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.ConfigValue = field.NewString(table, "config_value")
|
||||||
s.Remark = field.NewString(table, "remark")
|
s.Remark = field.NewString(table, "remark")
|
||||||
|
|
||||||
@ -91,12 +97,14 @@ func (s *systemConfigs) GetFieldByName(fieldName string) (field.OrderExpr, bool)
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *systemConfigs) fillFieldMap() {
|
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["id"] = s.ID
|
||||||
s.fieldMap["created_at"] = s.CreatedAt
|
s.fieldMap["created_at"] = s.CreatedAt
|
||||||
s.fieldMap["updated_at"] = s.UpdatedAt
|
s.fieldMap["updated_at"] = s.UpdatedAt
|
||||||
s.fieldMap["deleted_at"] = s.DeletedAt
|
s.fieldMap["deleted_at"] = s.DeletedAt
|
||||||
s.fieldMap["config_key"] = s.ConfigKey
|
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["config_value"] = s.ConfigValue
|
||||||
s.fieldMap["remark"] = s.Remark
|
s.fieldMap["remark"] = s.Remark
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,7 @@ func newUsers(db *gorm.DB, opts ...gen.DOOption) users {
|
|||||||
_users.Status = field.NewInt32(tableName, "status")
|
_users.Status = field.NewInt32(tableName, "status")
|
||||||
_users.DouyinID = field.NewString(tableName, "douyin_id")
|
_users.DouyinID = field.NewString(tableName, "douyin_id")
|
||||||
_users.ChannelID = field.NewInt64(tableName, "channel_id")
|
_users.ChannelID = field.NewInt64(tableName, "channel_id")
|
||||||
|
_users.DouyinUserID = field.NewString(tableName, "douyin_user_id")
|
||||||
|
|
||||||
_users.fillFieldMap()
|
_users.fillFieldMap()
|
||||||
|
|
||||||
@ -51,21 +52,22 @@ func newUsers(db *gorm.DB, opts ...gen.DOOption) users {
|
|||||||
type users struct {
|
type users struct {
|
||||||
usersDo
|
usersDo
|
||||||
|
|
||||||
ALL field.Asterisk
|
ALL field.Asterisk
|
||||||
ID field.Int64 // 主键ID
|
ID field.Int64 // 主键ID
|
||||||
CreatedAt field.Time // 创建时间
|
CreatedAt field.Time // 创建时间
|
||||||
UpdatedAt field.Time // 更新时间
|
UpdatedAt field.Time // 更新时间
|
||||||
DeletedAt field.Field // 删除时间(软删)
|
DeletedAt field.Field // 删除时间(软删)
|
||||||
Nickname field.String // 昵称
|
Nickname field.String // 昵称
|
||||||
Avatar field.String // 头像URL
|
Avatar field.String // 头像URL
|
||||||
Mobile field.String // 手机号
|
Mobile field.String // 手机号
|
||||||
Openid field.String // 微信openid
|
Openid field.String // 微信openid
|
||||||
Unionid field.String // 微信unionid
|
Unionid field.String // 微信unionid
|
||||||
InviteCode field.String // 用户唯一邀请码
|
InviteCode field.String // 用户唯一邀请码
|
||||||
InviterID field.Int64 // 邀请人用户ID
|
InviterID field.Int64 // 邀请人用户ID
|
||||||
Status field.Int32 // 状态:1正常 2禁用
|
Status field.Int32 // 状态:1正常 2禁用
|
||||||
DouyinID field.String
|
DouyinID field.String
|
||||||
ChannelID field.Int64 // 渠道ID
|
ChannelID field.Int64 // 渠道ID
|
||||||
|
DouyinUserID field.String
|
||||||
|
|
||||||
fieldMap map[string]field.Expr
|
fieldMap map[string]field.Expr
|
||||||
}
|
}
|
||||||
@ -96,6 +98,7 @@ func (u *users) updateTableName(table string) *users {
|
|||||||
u.Status = field.NewInt32(table, "status")
|
u.Status = field.NewInt32(table, "status")
|
||||||
u.DouyinID = field.NewString(table, "douyin_id")
|
u.DouyinID = field.NewString(table, "douyin_id")
|
||||||
u.ChannelID = field.NewInt64(table, "channel_id")
|
u.ChannelID = field.NewInt64(table, "channel_id")
|
||||||
|
u.DouyinUserID = field.NewString(table, "douyin_user_id")
|
||||||
|
|
||||||
u.fillFieldMap()
|
u.fillFieldMap()
|
||||||
|
|
||||||
@ -112,7 +115,7 @@ func (u *users) GetFieldByName(fieldName string) (field.OrderExpr, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (u *users) fillFieldMap() {
|
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["id"] = u.ID
|
||||||
u.fieldMap["created_at"] = u.CreatedAt
|
u.fieldMap["created_at"] = u.CreatedAt
|
||||||
u.fieldMap["updated_at"] = u.UpdatedAt
|
u.fieldMap["updated_at"] = u.UpdatedAt
|
||||||
@ -127,6 +130,7 @@ func (u *users) fillFieldMap() {
|
|||||||
u.fieldMap["status"] = u.Status
|
u.fieldMap["status"] = u.Status
|
||||||
u.fieldMap["douyin_id"] = u.DouyinID
|
u.fieldMap["douyin_id"] = u.DouyinID
|
||||||
u.fieldMap["channel_id"] = u.ChannelID
|
u.fieldMap["channel_id"] = u.ChannelID
|
||||||
|
u.fieldMap["douyin_user_id"] = u.DouyinUserID
|
||||||
}
|
}
|
||||||
|
|
||||||
func (u users) clone(db *gorm.DB) users {
|
func (u users) clone(db *gorm.DB) users {
|
||||||
|
|||||||
@ -13,15 +13,16 @@ const TableNameDouyinOrders = "douyin_orders"
|
|||||||
// DouyinOrders 抖店订单表
|
// DouyinOrders 抖店订单表
|
||||||
type DouyinOrders struct {
|
type DouyinOrders struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
|
||||||
ShopOrderID string `gorm:"column:shop_order_id;not null;comment:抖店订单号" json:"shop_order_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=已完成
|
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
|
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
|
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"` // 实收金额(分)
|
ActualReceiveAmount int64 `gorm:"column:actual_receive_amount;comment:实收金额(分)" json:"actual_receive_amount"` // 实收金额(分)
|
||||||
PayTypeDesc string `gorm:"column:pay_type_desc;comment:支付方式描述" json:"pay_type_desc"` // 支付方式描述
|
PayTypeDesc string `gorm:"column:pay_type_desc;comment:支付方式描述" json:"pay_type_desc"` // 支付方式描述
|
||||||
Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注
|
Remark string `gorm:"column:remark;comment:备注" json:"remark"` // 备注
|
||||||
UserNickname string `gorm:"column:user_nickname;comment:抖音昵称" json:"user_nickname"` // 抖音昵称
|
UserNickname string `gorm:"column:user_nickname;comment:抖音昵称" json:"user_nickname"` // 抖音昵称
|
||||||
RawData string `gorm:"column:raw_data;comment:原始响应数据" json:"raw_data"` // 原始响应数据
|
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"`
|
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"`
|
UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP(3)" json:"updated_at"`
|
||||||
}
|
}
|
||||||
|
|||||||
26
internal/repository/mysql/model/douyin_product_rewards.go
Normal file
26
internal/repository/mysql/model/douyin_product_rewards.go
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/datatypes"
|
||||||
|
)
|
||||||
|
|
||||||
|
const TableNameDouyinProductRewards = "douyin_product_rewards"
|
||||||
|
|
||||||
|
// DouyinProductRewards 抖店商品奖励规则表
|
||||||
|
type DouyinProductRewards struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true" json:"id"`
|
||||||
|
ProductID string `gorm:"column:product_id;not null;uniqueIndex:uk_product_id;comment:抖店商品ID" json:"product_id"`
|
||||||
|
ProductName string `gorm:"column:product_name;not null;default:'';comment:商品名称" json:"product_name"`
|
||||||
|
RewardType string `gorm:"column:reward_type;not null;comment:奖励类型" json:"reward_type"`
|
||||||
|
RewardPayload datatypes.JSON `gorm:"column:reward_payload;comment:奖励参数JSON" json:"reward_payload"`
|
||||||
|
Quantity int32 `gorm:"column:quantity;not null;default:1;comment:发放数量" json:"quantity"`
|
||||||
|
Status int32 `gorm:"column:status;not null;default:1;comment:状态: 1=启用 0=禁用" json:"status"`
|
||||||
|
CreatedAt time.Time `gorm:"column:created_at;default:CURRENT_TIMESTAMP(3)" json:"created_at"`
|
||||||
|
UpdatedAt time.Time `gorm:"column:updated_at;default:CURRENT_TIMESTAMP(3)" json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*DouyinProductRewards) TableName() string {
|
||||||
|
return TableNameDouyinProductRewards
|
||||||
|
}
|
||||||
@ -19,6 +19,8 @@ type SystemConfigs struct {
|
|||||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP(3)" json:"updated_at"`
|
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"`
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at" json:"deleted_at"`
|
||||||
ConfigKey string `gorm:"column:config_key;not null" json:"config_key"`
|
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"`
|
ConfigValue string `gorm:"column:config_value;not null" json:"config_value"`
|
||||||
Remark string `gorm:"column:remark" json:"remark"`
|
Remark string `gorm:"column:remark" json:"remark"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,20 +14,21 @@ const TableNameUsers = "users"
|
|||||||
|
|
||||||
// Users 用户表
|
// Users 用户表
|
||||||
type Users struct {
|
type Users struct {
|
||||||
ID int64 `gorm:"column:id;primaryKey;autoIncrement:true;comment:主键ID" json:"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"` // 创建时间
|
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"` // 更新时间
|
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"` // 删除时间(软删)
|
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;comment:删除时间(软删)" json:"deleted_at"` // 删除时间(软删)
|
||||||
Nickname string `gorm:"column:nickname;not null;comment:昵称" json:"nickname"` // 昵称
|
Nickname string `gorm:"column:nickname;not null;comment:昵称" json:"nickname"` // 昵称
|
||||||
Avatar string `gorm:"column:avatar;comment:头像URL" json:"avatar"` // 头像URL
|
Avatar string `gorm:"column:avatar;comment:头像URL" json:"avatar"` // 头像URL
|
||||||
Mobile string `gorm:"column:mobile;comment:手机号" json:"mobile"` // 手机号
|
Mobile string `gorm:"column:mobile;comment:手机号" json:"mobile"` // 手机号
|
||||||
Openid string `gorm:"column:openid;comment:微信openid" json:"openid"` // 微信openid
|
Openid string `gorm:"column:openid;comment:微信openid" json:"openid"` // 微信openid
|
||||||
Unionid string `gorm:"column:unionid;comment:微信unionid" json:"unionid"` // 微信unionid
|
Unionid string `gorm:"column:unionid;comment:微信unionid" json:"unionid"` // 微信unionid
|
||||||
InviteCode string `gorm:"column:invite_code;not null;comment:用户唯一邀请码" json:"invite_code"` // 用户唯一邀请码
|
InviteCode string `gorm:"column:invite_code;not null;comment:用户唯一邀请码" json:"invite_code"` // 用户唯一邀请码
|
||||||
InviterID int64 `gorm:"column:inviter_id;comment:邀请人用户ID" json:"inviter_id"` // 邀请人用户ID
|
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禁用
|
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"`
|
DouyinID string `gorm:"column:douyin_id" json:"douyin_id"`
|
||||||
ChannelID int64 `gorm:"column:channel_id;comment:渠道ID" json:"channel_id"` // 渠道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
|
// TableName Users's table name
|
||||||
|
|||||||
@ -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/cards", adminHandler.DashboardCards())
|
||||||
adminAuthApiRouter.GET("/dashboard/user_trend", adminHandler.DashboardUserTrend())
|
adminAuthApiRouter.GET("/dashboard/user_trend", adminHandler.DashboardUserTrend())
|
||||||
adminAuthApiRouter.GET("/dashboard/draw_trend", adminHandler.DashboardDrawTrend())
|
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/new_users", adminHandler.DashboardNewUsers())
|
||||||
adminAuthApiRouter.GET("/dashboard/draw_stream", adminHandler.DashboardDrawStream())
|
adminAuthApiRouter.GET("/dashboard/draw_stream", adminHandler.DashboardDrawStream())
|
||||||
adminAuthApiRouter.GET("/dashboard/todos", adminHandler.DashboardTodos())
|
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/activities", adminHandler.DashboardActivities())
|
||||||
adminAuthApiRouter.GET("/dashboard/activity_prize_analysis", adminHandler.DashboardActivityPrizeAnalysis())
|
adminAuthApiRouter.GET("/dashboard/activity_prize_analysis", adminHandler.DashboardActivityPrizeAnalysis())
|
||||||
adminAuthApiRouter.GET("/dashboard/user_overview", adminHandler.DashboardUserOverview())
|
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.POST("/activities", intc.RequireAdminAction("activity:create"), adminHandler.CreateActivity())
|
||||||
adminAuthApiRouter.GET("/activities", intc.RequireAdminAction("activity:view"), adminHandler.ListActivities())
|
adminAuthApiRouter.GET("/activities", intc.RequireAdminAction("activity:view"), adminHandler.ListActivities())
|
||||||
adminAuthApiRouter.PUT("/activities/:activity_id", intc.RequireAdminAction("activity:modify"), adminHandler.ModifyActivity())
|
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.PUT("/douyin/config", adminHandler.SaveDouyinConfig())
|
||||||
adminAuthApiRouter.GET("/douyin/orders", adminHandler.ListDouyinOrders())
|
adminAuthApiRouter.GET("/douyin/orders", adminHandler.ListDouyinOrders())
|
||||||
adminAuthApiRouter.POST("/douyin/sync", adminHandler.SyncDouyinOrders())
|
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
|
// 系统配置KV
|
||||||
adminAuthApiRouter.GET("/system/configs", adminHandler.ListSystemConfigs())
|
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.GET("/users/:user_id/stats", userHandler.GetUserStats())
|
||||||
appAuthApiRouter.POST("/users/:user_id/phone/bind", userHandler.BindPhone())
|
appAuthApiRouter.POST("/users/:user_id/phone/bind", userHandler.BindPhone())
|
||||||
appAuthApiRouter.POST("/users/:user_id/douyin/phone/bind", userHandler.DouyinBindPhone())
|
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/invites", userHandler.ListUserInvites())
|
||||||
appAuthApiRouter.GET("/users/:user_id/inventory", userHandler.ListUserInventory())
|
appAuthApiRouter.GET("/users/:user_id/inventory", userHandler.ListUserInventory())
|
||||||
appAuthApiRouter.GET("/users/:user_id/shipments", userHandler.ListUserShipments())
|
appAuthApiRouter.GET("/users/:user_id/shipments", userHandler.ListUserShipments())
|
||||||
|
|||||||
@ -15,7 +15,7 @@ type Service interface {
|
|||||||
Modify(ctx context.Context, id int64, in ModifyInput) error
|
Modify(ctx context.Context, id int64, in ModifyInput) error
|
||||||
Delete(ctx context.Context, id int64) error
|
Delete(ctx context.Context, id int64) error
|
||||||
List(ctx context.Context, in ListInput) (items []*ChannelWithStat, total int64, err 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 {
|
type service struct {
|
||||||
@ -160,14 +160,32 @@ func (s *service) List(ctx context.Context, in ListInput) (items []*ChannelWithS
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *service) GetStats(ctx context.Context, channelID int64, months int) (*StatsOutput, error) {
|
func (s *service) GetStats(ctx context.Context, channelID int64, months int, startDateStr, endDateStr string) (*StatsOutput, error) {
|
||||||
if months <= 0 {
|
|
||||||
months = 12
|
|
||||||
}
|
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// Calculate start date (first day of the month N months ago)
|
var startDate, endDate time.Time
|
||||||
startMonth := now.AddDate(0, -months+1, 0)
|
|
||||||
startDate := time.Date(startMonth.Year(), startMonth.Month(), 1, 0, 0, 0, 0, startMonth.Location())
|
// 如果指定了日期范围,使用自定义日期
|
||||||
|
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{}
|
out := &StatsOutput{}
|
||||||
|
|
||||||
|
|||||||
@ -5,13 +5,14 @@ import (
|
|||||||
"bindbox-game/internal/repository/mysql"
|
"bindbox-game/internal/repository/mysql"
|
||||||
"bindbox-game/internal/repository/mysql/dao"
|
"bindbox-game/internal/repository/mysql/dao"
|
||||||
"bindbox-game/internal/repository/mysql/model"
|
"bindbox-game/internal/repository/mysql/model"
|
||||||
|
"bindbox-game/internal/service/game"
|
||||||
"bindbox-game/internal/service/sysconfig"
|
"bindbox-game/internal/service/sysconfig"
|
||||||
"compress/gzip"
|
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -34,6 +35,8 @@ type Service interface {
|
|||||||
GetConfig(ctx context.Context) (*DouyinConfig, error)
|
GetConfig(ctx context.Context) (*DouyinConfig, error)
|
||||||
// SaveConfig 保存抖店配置
|
// SaveConfig 保存抖店配置
|
||||||
SaveConfig(ctx context.Context, cookie string, intervalMinutes int) error
|
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 {
|
type DouyinConfig struct {
|
||||||
@ -48,20 +51,22 @@ type SyncResult struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type service struct {
|
type service struct {
|
||||||
logger logger.CustomLogger
|
logger logger.CustomLogger
|
||||||
repo mysql.Repo
|
repo mysql.Repo
|
||||||
readDB *dao.Query
|
readDB *dao.Query
|
||||||
writeDB *dao.Query
|
writeDB *dao.Query
|
||||||
syscfg sysconfig.Service
|
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{
|
return &service{
|
||||||
logger: l,
|
logger: l,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
readDB: dao.Use(repo.GetDbR()),
|
readDB: dao.Use(repo.GetDbR()),
|
||||||
writeDB: dao.Use(repo.GetDbW()),
|
writeDB: dao.Use(repo.GetDbW()),
|
||||||
syscfg: syscfg,
|
syscfg: syscfg,
|
||||||
|
ticketSvc: ticketSvc,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -121,7 +126,7 @@ func (s *service) ListOrders(ctx context.Context, page, pageSize int, status *in
|
|||||||
return orders, total, nil
|
return orders, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FetchAndSyncOrders 从抖店 API 获取订单并同步到本地
|
// FetchAndSyncOrders 遍历所有已绑定抖音号的用户并同步其订单
|
||||||
func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) {
|
func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) {
|
||||||
cfg, err := s.GetConfig(ctx)
|
cfg, err := s.GetConfig(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -131,45 +136,43 @@ func (s *service) FetchAndSyncOrders(ctx context.Context) (*SyncResult, error) {
|
|||||||
return nil, fmt.Errorf("抖店 Cookie 未配置")
|
return nil, fmt.Errorf("抖店 Cookie 未配置")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调用抖店 API 获取订单
|
// 1. 获取所有绑定了抖音号的用户
|
||||||
orders, err := s.fetchDouyinOrders(cfg.Cookie)
|
var users []model.Users
|
||||||
if err != nil {
|
if err := s.repo.GetDbR().WithContext(ctx).Where("douyin_user_id != ''").Find(&users).Error; err != nil {
|
||||||
return nil, fmt.Errorf("获取抖店订单失败: %w", err)
|
return nil, fmt.Errorf("获取绑定用户失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := &SyncResult{TotalFetched: len(orders)}
|
result := &SyncResult{}
|
||||||
|
fmt.Printf("[DEBUG] 开始全量同步,共 %d 个绑定用户\n", len(users))
|
||||||
|
|
||||||
// 统计各状态订单数量
|
// 2. 遍历用户,按 buyer 抓取订单
|
||||||
statusCount := make(map[int]int)
|
for _, u := range users {
|
||||||
for _, order := range orders {
|
fmt.Printf("[DEBUG] 正在同步用户 ID: %d (昵称: %s, 抖音号: %s) 的订单...\n", u.ID, u.Nickname, u.DouyinUserID)
|
||||||
statusCount[order.OrderStatus]++
|
|
||||||
}
|
|
||||||
s.logger.Info("[抖店同步] 订单状态分布",
|
|
||||||
zap.Any("status_count", statusCount),
|
|
||||||
)
|
|
||||||
|
|
||||||
// 同步订单到本地(只同步 order_status=5 已完成的订单)
|
orders, err := s.fetchDouyinOrdersByBuyer(cfg.Cookie, u.DouyinUserID)
|
||||||
for _, order := range orders {
|
if err != nil {
|
||||||
if order.OrderStatus != 5 {
|
fmt.Printf("[DEBUG] 抓取用户 %s 订单失败: %v\n", u.DouyinUserID, err)
|
||||||
// 跳过非已完成订单
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
isNew, matched := s.syncOrder(ctx, order)
|
|
||||||
if isNew {
|
result.TotalFetched += len(orders)
|
||||||
result.NewOrders++
|
|
||||||
s.logger.Info("[抖店同步] 新增订单",
|
// 3. 同步
|
||||||
zap.String("shop_order_id", order.ShopOrderID),
|
for _, order := range orders {
|
||||||
zap.Int("order_status", order.OrderStatus),
|
// 同步订单(传入建议关联的用户 ID)
|
||||||
)
|
isNew, matched := s.SyncOrder(ctx, &order, u.ID)
|
||||||
}
|
if isNew {
|
||||||
if matched {
|
result.NewOrders++
|
||||||
result.MatchedUsers++
|
}
|
||||||
|
if matched {
|
||||||
|
result.MatchedUsers++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("[抖店同步] 同步完成",
|
s.logger.Info("[抖店同步] 全量同步完成",
|
||||||
|
zap.Int("users_count", len(users)),
|
||||||
zap.Int("total_fetched", result.TotalFetched),
|
zap.Int("total_fetched", result.TotalFetched),
|
||||||
zap.Int("completed_orders", statusCount[5]),
|
|
||||||
zap.Int("new_orders", result.NewOrders),
|
zap.Int("new_orders", result.NewOrders),
|
||||||
zap.Int("matched_users", result.MatchedUsers),
|
zap.Int("matched_users", result.MatchedUsers),
|
||||||
)
|
)
|
||||||
@ -182,10 +185,10 @@ type douyinOrderResponse struct {
|
|||||||
Code int `json:"code"`
|
Code int `json:"code"`
|
||||||
St int `json:"st"` // 抖店实际返回的是 st 而非 code
|
St int `json:"st"` // 抖店实际返回的是 st 而非 code
|
||||||
Msg string `json:"msg"`
|
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"`
|
ShopOrderID string `json:"shop_order_id"`
|
||||||
OrderStatus int `json:"order_status"`
|
OrderStatus int `json:"order_status"`
|
||||||
UserID string `json:"user_id"`
|
UserID string `json:"user_id"`
|
||||||
@ -195,11 +198,24 @@ type douyinOrderItem struct {
|
|||||||
UserNickname string `json:"user_nickname"`
|
UserNickname string `json:"user_nickname"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// fetchDouyinOrders 调用抖店 API 获取订单
|
// fetchDouyinOrdersByBuyer 调用抖店 API 按 Buyer ID 获取订单
|
||||||
func (s *service) fetchDouyinOrders(cookie string) ([]douyinOrderItem, error) {
|
func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string) ([]DouyinOrderItem, error) {
|
||||||
url := "https://fxg.jinritemai.com/api/order/searchlist?page=0&pageSize=50&order_by=create_time&order=desc&tab=all"
|
// 拼接带有业务标识的搜索 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 {
|
if err != nil {
|
||||||
return nil, err
|
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("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", "application/json, text/plain, */*")
|
||||||
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
|
|
||||||
req.Header.Set("Cookie", cookie)
|
req.Header.Set("Cookie", cookie)
|
||||||
req.Header.Set("Referer", "https://fxg.jinritemai.com/ffa/morder/order/list")
|
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()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
// 处理 gzip 响应
|
body, err := io.ReadAll(resp.Body)
|
||||||
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)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var respData douyinOrderResponse
|
var respData douyinOrderResponse
|
||||||
if err := json.Unmarshal(body, &respData); err != nil {
|
if err := json.Unmarshal(body, &respData); err != nil {
|
||||||
// 返回原始响应帮助调试
|
|
||||||
s.logger.Error("[抖店API] 解析响应失败", zap.String("response", string(body[:min(len(body), 500)])))
|
s.logger.Error("[抖店API] 解析响应失败", zap.String("response", string(body[:min(len(body), 500)])))
|
||||||
return nil, fmt.Errorf("解析响应失败: %w", err)
|
return nil, fmt.Errorf("解析响应失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 抖店使用 st 字段表示状态,0 表示成功
|
|
||||||
if respData.St != 0 && respData.Code != 0 {
|
if respData.St != 0 && respData.Code != 0 {
|
||||||
return nil, fmt.Errorf("API 返回错误: %s", respData.Msg)
|
return nil, fmt.Errorf("API 返回错误: %s", respData.Msg)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.logger.Info("[抖店API] 获取订单成功", zap.Int("count", len(respData.Data)))
|
|
||||||
|
|
||||||
return respData.Data, nil
|
return respData.Data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// syncOrder 同步单个订单到本地
|
// SyncOrder 同步单个订单到本地
|
||||||
func (s *service) syncOrder(ctx context.Context, item douyinOrderItem) (isNew bool, isMatched bool) {
|
func (s *service) SyncOrder(ctx context.Context, item *DouyinOrderItem, suggestUserID int64) (isNew bool, isMatched bool) {
|
||||||
db := s.repo.GetDbW().WithContext(ctx)
|
db := s.repo.GetDbW().WithContext(ctx)
|
||||||
|
|
||||||
// 检查订单是否已存在
|
var order model.DouyinOrders
|
||||||
var existing model.DouyinOrders
|
err := db.Where("shop_order_id = ?", item.ShopOrderID).First(&order).Error
|
||||||
if err := db.Where("shop_order_id = ?", item.ShopOrderID).First(&existing).Error; err == nil {
|
|
||||||
// 订单已存在,更新状态
|
if err == nil {
|
||||||
db.Model(&existing).Updates(map[string]any{
|
// 订单已存在
|
||||||
|
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,
|
"order_status": item.OrderStatus,
|
||||||
"remark": item.Remark,
|
"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)
|
||||||
|
}
|
||||||
|
|
||||||
// 新订单,尝试匹配本地用户
|
fmt.Printf("[DEBUG] 抖店新订单: %s, UserID: %s, Recommend: %s\n", item.ShopOrderID, item.UserID, localUserIDStr)
|
||||||
var localUserID int64
|
|
||||||
var user model.Users
|
// 解析金额
|
||||||
// 尝试通过 douyin_id 匹配用户
|
var amount int64
|
||||||
if item.UserID != "" {
|
if item.ActualReceiveAmount != "" {
|
||||||
if err := s.repo.GetDbR().Where("douyin_id = ?", item.UserID).First(&user).Error; err == nil {
|
if f, err := strconv.ParseFloat(strings.TrimSpace(item.ActualReceiveAmount), 64); err == nil {
|
||||||
localUserID = user.ID
|
amount = int64(f * 100)
|
||||||
isMatched = true
|
}
|
||||||
|
}
|
||||||
|
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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 解析金额 (抖店返回的是元,需要转换为分)
|
// 如果还没关联用户(比如之前全量抓取的),尝试用抖店的 UID (long string) 匹配
|
||||||
var amount int64
|
if (order.LocalUserID == "" || order.LocalUserID == "0") && item.UserID != "" {
|
||||||
if item.ActualReceiveAmount != "" {
|
var user model.Users
|
||||||
if f, err := strconv.ParseFloat(strings.TrimSpace(item.ActualReceiveAmount), 64); err == nil {
|
if err := s.repo.GetDbR().Where("douyin_user_id = ?", item.UserID).First(&user).Error; err == nil {
|
||||||
amount = int64(f * 100)
|
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)
|
||||||
|
|
||||||
// 创建订单记录
|
if localUserID > 0 && s.ticketSvc != nil {
|
||||||
order := &model.DouyinOrders{
|
err := s.ticketSvc.GrantTicket(ctx, localUserID, "minesweeper", 1, "douyin_order", order.ID, "抖店订单奖励")
|
||||||
ShopOrderID: item.ShopOrderID,
|
if err == nil {
|
||||||
OrderStatus: int32(item.OrderStatus),
|
db.Model(&order).Update("reward_granted", 1)
|
||||||
DouyinUserID: item.UserID,
|
order.RewardGranted = 1
|
||||||
LocalUserID: strconv.FormatInt(localUserID, 10),
|
fmt.Printf("[DEBUG] 订单 %s 发放奖励成功\n", item.ShopOrderID)
|
||||||
ActualReceiveAmount: amount,
|
} else {
|
||||||
PayTypeDesc: item.PayTypeDesc,
|
fmt.Printf("[DEBUG] 订单 %s 发放奖励失败: %v\n", item.ShopOrderID, err)
|
||||||
Remark: item.Remark,
|
}
|
||||||
UserNickname: item.UserNickname,
|
}
|
||||||
RawData: string(rawData),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Create(order).Error; err != nil {
|
return isNew, isMatched
|
||||||
s.logger.Error("[抖店同步] 创建订单失败",
|
|
||||||
zap.String("shop_order_id", item.ShopOrderID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return false, false
|
|
||||||
}
|
|
||||||
|
|
||||||
return true, isMatched
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// min 返回两个整数的最小值
|
// min 返回两个整数的最小值
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package douyin
|
|||||||
import (
|
import (
|
||||||
"bindbox-game/internal/pkg/logger"
|
"bindbox-game/internal/pkg/logger"
|
||||||
"bindbox-game/internal/repository/mysql"
|
"bindbox-game/internal/repository/mysql"
|
||||||
|
"bindbox-game/internal/service/game"
|
||||||
"bindbox-game/internal/service/sysconfig"
|
"bindbox-game/internal/service/sysconfig"
|
||||||
"context"
|
"context"
|
||||||
"strconv"
|
"strconv"
|
||||||
@ -12,8 +13,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// StartDouyinOrderSync 启动抖店订单定时同步任务
|
// StartDouyinOrderSync 启动抖店订单定时同步任务
|
||||||
func StartDouyinOrderSync(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service) {
|
func StartDouyinOrderSync(l logger.CustomLogger, repo mysql.Repo, syscfg sysconfig.Service, ticketSvc game.TicketService) {
|
||||||
svc := New(l, repo, syscfg)
|
svc := New(l, repo, syscfg, ticketSvc)
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
// 初始等待30秒让服务完全启动
|
// 初始等待30秒让服务完全启动
|
||||||
|
|||||||
@ -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 {
|
func (s *service) UpsertTaskTiers(ctx context.Context, taskID int64, tiers []TaskTierInput) error {
|
||||||
db := s.repo.GetDbW()
|
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
|
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 {
|
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}
|
key := fmt.Sprintf("%s-%d-%d", t.Metric, t.Threshold, t.ActivityID)
|
||||||
if err := db.Create(row).Error; err != nil {
|
if old, ok := existingMap[key]; ok {
|
||||||
return err
|
// 更新现有记录,保留 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) {
|
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 {
|
func (s *service) UpsertTaskRewards(ctx context.Context, taskID int64, rewards []TaskRewardInput) error {
|
||||||
db := s.repo.GetDbW()
|
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
|
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 {
|
for _, r := range rewards {
|
||||||
row := &tcmodel.TaskReward{TaskID: taskID, TierID: r.TierID, RewardType: r.RewardType, RewardPayload: r.RewardPayload, Quantity: r.Quantity}
|
key := fmt.Sprintf("%d-%s", r.TierID, r.RewardType)
|
||||||
if err := db.Create(row).Error; err != nil {
|
if old, ok := existingMap[key]; ok {
|
||||||
return err
|
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 {
|
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 {
|
if err := s.repo.GetDbR().Where("task_id=? AND tier_id=?", taskID, tierID).Find(&rewards).Error; err != nil {
|
||||||
return err
|
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)
|
idk := fmt.Sprintf("%d:%d:%d:%s:%d", userID, taskID, tierID, sourceType, sourceID)
|
||||||
var exists tcmodel.TaskEventLog
|
var exists tcmodel.TaskEventLog
|
||||||
if err := s.repo.GetDbR().Where("idempotency_key=?", idk).First(&exists).Error; err == nil && exists.ID > 0 {
|
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)
|
gameSvc := gamesvc.NewTicketService(s.logger, s.repo)
|
||||||
err = gameSvc.GrantTicket(ctx, userID, pl.GameCode, pl.Amount, "task_center", taskID, "任务奖励")
|
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:
|
default:
|
||||||
s.logger.Warn("Unknown reward type", zap.String("type", r.RewardType))
|
s.logger.Warn("Unknown reward type", zap.String("type", r.RewardType))
|
||||||
}
|
}
|
||||||
|
|||||||
34
main.go
34
main.go
@ -16,6 +16,7 @@ import (
|
|||||||
"bindbox-game/internal/router"
|
"bindbox-game/internal/router"
|
||||||
activitysvc "bindbox-game/internal/service/activity"
|
activitysvc "bindbox-game/internal/service/activity"
|
||||||
douyinsvc "bindbox-game/internal/service/douyin"
|
douyinsvc "bindbox-game/internal/service/douyin"
|
||||||
|
gamesvc "bindbox-game/internal/service/game"
|
||||||
syscfgsvc "bindbox-game/internal/service/sysconfig"
|
syscfgsvc "bindbox-game/internal/service/sysconfig"
|
||||||
usersvc "bindbox-game/internal/service/user"
|
usersvc "bindbox-game/internal/service/user"
|
||||||
|
|
||||||
@ -139,6 +140,15 @@ func main() {
|
|||||||
} else {
|
} else {
|
||||||
// 检查并修改 douyin_user_id 列长度
|
// 检查并修改 douyin_user_id 列长度
|
||||||
_ = db.Exec("ALTER TABLE douyin_orders MODIFY COLUMN douyin_user_id VARCHAR(256) NOT NULL COMMENT '抖店用户ID'").Error
|
_ = 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)
|
INDEX idx_order (order_id)
|
||||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抽奖退款记录表';`).Error
|
) 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
|
// 初始化 自定义 Logger
|
||||||
customLogger, err := logger.NewCustomLogger(dao.Use(dbRepo.GetDbW()),
|
customLogger, err := logger.NewCustomLogger(dao.Use(dbRepo.GetDbW()),
|
||||||
logger.WithDebugLevel(), // 启用调试级别日志
|
logger.WithDebugLevel(), // 启用调试级别日志
|
||||||
logger.WithOutputInConsole(), // 启用控制台输出
|
// logger.WithOutputInConsole(), // 启用控制台输出
|
||||||
logger.WithField("domain", fmt.Sprintf("%s[%s]", configs.ProjectName, env.Active().Value())),
|
logger.WithField("domain", fmt.Sprintf("%s[%s]", configs.ProjectName, env.Active().Value())),
|
||||||
logger.WithTimeLayout(timeutil.CSTLayout),
|
logger.WithTimeLayout(timeutil.CSTLayout),
|
||||||
logger.WithFileRotationP(configs.ProjectAccessLogFile),
|
logger.WithFileRotationP(configs.ProjectAccessLogFile),
|
||||||
@ -205,7 +232,8 @@ func main() {
|
|||||||
|
|
||||||
// 启动抖店订单同步定时任务
|
// 启动抖店订单同步定时任务
|
||||||
syscfgSvc := syscfgsvc.New(customLogger, dbRepo)
|
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 {
|
if err := syscfgsvc.InitGlobalDynamicConfig(customLogger, dbRepo); err != nil {
|
||||||
|
|||||||
14
migrations/20260105_douyin_product_rewards.sql
Normal file
14
migrations/20260105_douyin_product_rewards.sql
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
-- 抖店商品奖励规则表
|
||||||
|
CREATE TABLE IF NOT EXISTS douyin_product_rewards (
|
||||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
||||||
|
product_id VARCHAR(64) NOT NULL COMMENT '抖店商品ID',
|
||||||
|
product_name VARCHAR(255) NOT NULL DEFAULT '' COMMENT '商品名称(便于识别)',
|
||||||
|
reward_type VARCHAR(32) NOT NULL COMMENT '奖励类型: game_ticket/coupon/points/product/item_card/title',
|
||||||
|
reward_payload JSON COMMENT '奖励参数JSON',
|
||||||
|
quantity INT NOT NULL DEFAULT 1 COMMENT '发放数量',
|
||||||
|
status TINYINT NOT NULL DEFAULT 1 COMMENT '状态: 1=启用 0=禁用',
|
||||||
|
created_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3),
|
||||||
|
updated_at DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
|
||||||
|
UNIQUE KEY uk_product_id (product_id),
|
||||||
|
KEY idx_status (status)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='抖店商品奖励规则';
|
||||||
31
scripts/reset_inventory.go
Normal file
31
scripts/reset_inventory.go
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
|
||||||
|
"bindbox-game/internal/repository/mysql"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
// 1. 初始化配置 (configs 包的 init 函数会自动加载)
|
||||||
|
// configs.Init()
|
||||||
|
|
||||||
|
// 2. 连接数据库
|
||||||
|
repo, err := mysql.New()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to connect to database: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
db := repo.GetDbW()
|
||||||
|
|
||||||
|
fmt.Println("Starting inventory reset...")
|
||||||
|
|
||||||
|
// 3. 执行重置操作:将所有奖品的剩余库存 (quantity) 重置为初始库存 (original_qty)
|
||||||
|
result := db.Exec("UPDATE activity_reward_settings SET quantity = original_qty WHERE quantity != original_qty")
|
||||||
|
if result.Error != nil {
|
||||||
|
log.Fatalf("Failed to reset inventory: %v", result.Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("Successfully repaired inventory data.\nRows affected: %d\n", result.RowsAffected)
|
||||||
|
}
|
||||||
50
scripts/test_douyin_uid.go
Normal file
50
scripts/test_douyin_uid.go
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
url := "https://pigeon.jinritemai.com/backstage/queryOriginUid?biz_type=4&PIGEON_BIZ_TYPE=2&_pms=1&FUSION=true&user_id=AQDhjrlOTbdy6KuSWdeUaOgdDMC-Du5_0jrWCPec4bezjpwavPsjF4ccY5Xh_ismfd4S4mqQTg9_BNroR6puftMW&from_order=6946598919988843948&verifyFp=verify_mjqm0v8i_T6A7WUSJ_8DIX_4pWZ_A2IZ_wkv5aC3uVnam&fp=verify_mjqm0v8i_T6A7WUSJ_8DIX_4pWZ_A2IZ_wkv5aC3uVnam&msToken=DPDL9nCqmlG8xAiMGiYrD69TP4mjyLUe6PAHFNTfU5Osfe5fWdkO3xCakiTNtR6l2-IirQmU7evb5KD7JbLTQiiIFMmbmyLVnonF3cLoM4v57gcsSdtHQAyPCLbXKVI-K6oMcAfRZcUdJqBA5NzAsIGLKs8SOJVrni8AIXl5-KoNqfngwkUUH9I%3D&a_bogus=YJ05ktSiDZA5FpCtmOa8y4%2FlWZxlNB8y7eTKRKKz7qPIO7FP0jBwKrbRcxwv5XDZURpR2eV7RDMMYEVc0bG0ZZrkFmpkS%2FJyeWOC98sLgqwkbFhkEqfBCuuwCJtYWYkEm%2Fo6J1k1l0WO2xA4D3aiUB5r7ATHsQkdKNrbdnRGx9evgM49zpMqPufAcDCCUarhBt7SHqb%3D"
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("创建请求失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置 Header
|
||||||
|
req.Header.Set("accept", "application/json, text/plain, */*")
|
||||||
|
req.Header.Set("accept-language", "zh-CN,zh;q=0.9")
|
||||||
|
req.Header.Set("origin", "https://im.jinritemai.com")
|
||||||
|
req.Header.Set("referer", "https://im.jinritemai.com/")
|
||||||
|
req.Header.Set("user-agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||||
|
req.Header.Set("x-secsdk-csrf-token", "0001000000017d052e72d67cea10001d8a0938ac943b8d6a2e6d27a9ad7c99a59ed7a4ce3d741887dab95d16a595,b7b4150c5eeefaede4ef5e71473e9dc1")
|
||||||
|
|
||||||
|
// 设置 Cookie
|
||||||
|
req.Header.Set("Cookie", "passport_csrf_token=afcc4debfeacce6454979bb9465999dc; passport_csrf_token_default=afcc4debfeacce6454979bb9465999dc; passport_mfa_token=CjeFM9DjoKw5ZpfSXbltDwu5w%2FfC9ff%2BmJ1SnSMVFE4sFluTjapS0dE7iyY6l9SOHXbpe6tnJ%2BErGkoKPAAAAAAAAAAAAABP4kEu3aMxKlU%2Ffnu1I7drSQejvQQ41aNpbaoWScxScKhuKLwR1YvembHqZro7QE0RnhCTuYUOGPax0WwgAiIBA%2FhvYRo%3D; passport_auth_status=26cd27ba377557fee00599de3db7cebe%2C; passport_auth_status_ss=26cd27ba377557fee00599de3db7cebe%2C; uid_tt=6e62906911e209eb0460f172ce88e770; uid_tt_ss=6e62906911e209eb0460f172ce88e770; sid_tt=f52081faaea135495e5c7f1d731aca79; sessionid=f52081faaea135495e5c7f1d731aca79; sessionid_ss=f52081faaea135495e5c7f1d731aca79; is_staff_user=false; PHPSESSID=73bc52676d8a441a5128f86c164d91a5; PHPSESSID_SS=73bc52676d8a441a5128f86c164d91a5; ucas_c0=CkEKBTEuMC4wEJOIj8aV5P2oaRjmJiD8vrDz9Iy9AyiwITDYkuCImY2cBkCm7sfKBkimooTNBlCPvJrqxbSyumJYbhIU_065hMaNOcS1wHbVur_NQsaamsw; ucas_c0_ss=CkEKBTEuMC4wEJOIj8aV5P2oaRjmJiD8vrDz9Iy9AyiwITDYkuCImY2cBkCm7sfKBkimooTNBlCPvJrqxbSyumJYbhIU_065hMaNOcS1wHbVur_NQsaamsw; COMPASS_LUOPAN_DT=session_7589117384745566498; SHOP_ID=47668214; PIGEON_CID=3501298428676440; odin_tt=ae3c1b406c2527cb1dafe2ce0c5d7fa6e8dd0633f2991f50f0388dc4f83c30fcd5553f029b89c4f61587ea7e1065b232; ttwid=1%7CAwu3-vdDBhOP12XdEzmCJlbyX3Qt_5RcioPVgjBIDps%7C1767452427%7Ca6b23c96c08851a0abfa663ac7be9d19fa11e4cf120f3adc483edb358e0880bc; sid_guard=f52081faaea135495e5c7f1d731aca79%7C1767544182%7C5184000%7CThu%2C+05-Mar-2026+16%3A29%3A42+GMT; session_tlb_tag=sttt%7C7%7C9SCB-q6hNUleXH8dcxrKef________-kReT5b1uIKAfFTzjQUFgobm4jLDmrqpsvf_u6YPGCZ7A%3D; sid_ucp_v1=1.0.0-KDNmM2NjMzBmYjFhYThjZWIxNTUwMGE1ZWMzZjZmYWQ4MzJlYzNkYzQKGQjYkuCImY2cBhD2qurKBhiwISAMOAJA8QcaAmhsIiBmNTIwODFmYWFlYTEzNTQ5NWU1YzdmMWQ3MzFhY2E3OQ; ssid_ucp_v1=1.0.0-KDNmM2NjMzBmYjFhYThjZWIxNTUwMGE1ZWMzZjZmYWQ4MzJlYzNkYzQKGQjYkuCImY2cBhD2qurKBhiwISAMOAJA8QcaAmhsIiBmNTIwODFmYWFlYTEzNTQ5NWU1YzdmMWQ3MzFhY2E3OQ; BUYIN_SASID=SID2_7591542322157568294; csrf_session_id=b7b4150c5eeefaede4ef5e71473e9dc1")
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: 15 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Println("正在发送请求到抖店接口...")
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("请求执行失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("读取响应内容失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
fmt.Printf("响应状态码: %d\n", resp.StatusCode)
|
||||||
|
fmt.Printf("响应数据: %s\n", string(body))
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user