Compare commits
15 Commits
9cb4aaa511
...
1f8f3f7fad
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1f8f3f7fad | ||
|
|
2a7b731484 | ||
|
|
b99bcbd06f | ||
|
|
e78bbaaf76 | ||
|
|
e0097f50c8 | ||
|
|
e3b0ab7cca | ||
|
|
4f3b9b8fa7 | ||
|
|
9e3d893938 | ||
|
|
fd2c9e242e | ||
|
|
485798d551 | ||
|
|
5ede909be4 | ||
| 5b34972ee3 | |||
| fe3141e2b5 | |||
| ddd66bf4e9 | |||
| 535106f158 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -43,6 +43,8 @@ configs/*.toml
|
||||
# Claude Flow runtime data
|
||||
.claude-flow/data/
|
||||
.claude-flow/logs/
|
||||
.planning
|
||||
.gocache
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
|
||||
78
.planning/PROJECT.md
Normal file
78
.planning/PROJECT.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Bindbox Game 盈亏统计函数
|
||||
|
||||
## What This Is
|
||||
|
||||
为 Bindbox Game 平台新增两个 Service 层通用盈亏统计函数,支持按用户维度和活动维度查询平台盈亏情况。函数接收资产类型、维度 ID、时间范围等参数,返回汇总数据和按资产类型拆分的明细。
|
||||
|
||||
## Core Value
|
||||
|
||||
提供可复用的盈亏统计方法,使平台运营能从用户和活动两个维度快速了解各类资产的收支状况。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
<!-- 已有能力(从现有代码推断) -->
|
||||
|
||||
- ✓ 活动盈亏分析 Dashboard 接口 — existing (`DashboardActivityProfitLoss`)
|
||||
- ✓ 用户消费看板接口 — existing (`GetUserSpendingDashboard`)
|
||||
- ✓ 支付订单查询 — existing (pay orders API)
|
||||
- ✓ 用户积分、优惠券、道具卡、库存数据模型 — existing (GORM models)
|
||||
|
||||
### Active
|
||||
|
||||
<!-- 当前范围 -->
|
||||
|
||||
- [ ] 用户维度盈亏统计函数:输入资产类型+用户ID(支持多个)+时间范围,返回汇总+分类拆分
|
||||
- [ ] 活动维度盈亏统计函数:输入资产类型+活动ID+时间范围,返回汇总+分类拆分
|
||||
- [ ] 参数全部可选:不传资产类型则统计全部类型,不传ID则统计全量
|
||||
- [ ] 支持5种资产类型:积分、优惠券、道具卡、实物商品、碎片
|
||||
- [ ] 平台视角计算口径:收入=用户实际支付(金额+优惠券+次卡) - 成本=用户获取的资产奖品
|
||||
- [ ] 支持时间范围筛选
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- 前端 UI / Admin API endpoint — 本次只做 Service 层方法
|
||||
- 复用现有 Dashboard 盈亏逻辑 — 全新实现
|
||||
- 实时计算 / 缓存 — 首版直接查询数据库
|
||||
|
||||
## Context
|
||||
|
||||
- 现有代码中已有 `DashboardActivityProfitLoss` 等接口做活动级别盈亏,但计算口径和复用性不满足需求
|
||||
- 项目使用 Go 1.24 + Gin + GORM,分层架构 (Handler → Service → Repository)
|
||||
- 数据库为 MySQL 读写分离,统计查询走从库
|
||||
- 资产相关数据分布在多张表:支付订单、积分流水、优惠券记录、库存记录、道具卡记录、碎片记录等
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Tech Stack**: Go, GORM, MySQL — 遵循现有项目架构
|
||||
- **Performance**: 统计查询走从库 (DbR),避免影响写库性能
|
||||
- **Compatibility**: 新函数放在 `internal/service/finance/` 下,不修改现有接口
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
|----------|-----------|---------|
|
||||
| 全新实现而非复用 Dashboard 逻辑 | 现有逻辑耦合度高,计算口径不一致 | — Pending |
|
||||
| Service 层方法 | 通用性优先,后续可被多处调用 | — Pending |
|
||||
| 平台视角 (收入-成本) | 运营核心关注平台盈亏而非用户盈亏 | — Pending |
|
||||
|
||||
## Evolution
|
||||
|
||||
This document evolves at phase transitions and milestone boundaries.
|
||||
|
||||
**After each phase transition** (via `/gsd:transition`):
|
||||
1. Requirements invalidated? → Move to Out of Scope with reason
|
||||
2. Requirements validated? → Move to Validated with phase reference
|
||||
3. New requirements emerged? → Add to Active
|
||||
4. Decisions to log? → Add to Key Decisions
|
||||
5. "What This Is" still accurate? → Update if drifted
|
||||
|
||||
**After each milestone** (via `/gsd:complete-milestone`):
|
||||
1. Full review of all sections
|
||||
2. Core Value check — still the right priority?
|
||||
3. Audit Out of Scope — reasons still valid?
|
||||
4. Update Context with current state
|
||||
|
||||
---
|
||||
*Last updated: 2026-03-21 after initialization*
|
||||
108
.planning/REQUIREMENTS.md
Normal file
108
.planning/REQUIREMENTS.md
Normal file
@ -0,0 +1,108 @@
|
||||
# Requirements: Bindbox Game 盈亏统计函数
|
||||
|
||||
**Defined:** 2026-03-21
|
||||
**Core Value:** 提供可复用的盈亏统计方法,使平台运营能从用户和活动两个维度快速了解各类资产的收支状况
|
||||
|
||||
## v1 Requirements
|
||||
|
||||
Requirements for initial release. Each maps to roadmap phases.
|
||||
|
||||
### Core P&L Functions
|
||||
|
||||
- [ ] **PNL-01**: 函数接收 ProfitLossParams 参数结构体,所有字段可选(资产类型、维度ID、时间范围)
|
||||
- [ ] **PNL-02**: Revenue 计算口径为 actual_amount + discount_amount,排除已退款/取消订单(status=3,4)
|
||||
- [ ] **PNL-03**: Game-pass 订单通过 finance.IsGamePassOrder 三条件检测(source_type=4、order_no LIKE 'GP%'、remark含use_game_pass),与现金收入严格互斥
|
||||
- [ ] **PNL-04**: Game-pass 订单收入通过 finance.ComputeGamePassValue 计算(draw_count × activity_price)
|
||||
- [ ] **PNL-05**: Prize cost 通过 finance.ComputePrizeCostWithMultiplier 计算,包含道具卡倍率
|
||||
- [ ] **PNL-06**: Profit 通过 finance.ComputeProfit 计算,返回 int64 分 + float64 利润率
|
||||
- [ ] **PNL-07**: 排除已作废库存(remark LIKE '%void%' 或 status=2)不计入成本
|
||||
- [ ] **PNL-08**: 兼容 order_id=0 或 NULL 的历史数据(不受订单状态过滤影响)
|
||||
|
||||
### Query Dimensions
|
||||
|
||||
- [ ] **DIM-01**: QueryUserProfitLoss 接收 []int64 用户ID,空切片=统计全部用户
|
||||
- [ ] **DIM-02**: QueryActivityProfitLoss 接收 []int64 活动ID,空切片=统计全部活动
|
||||
- [ ] **DIM-03**: 时间范围过滤使用 *time.Time(nil=不限),不使用零值作哨兵
|
||||
- [ ] **DIM-04**: AssetType 可选过滤,nil/All(0)=统计全部资产类型
|
||||
|
||||
### Return Structure
|
||||
|
||||
- [ ] **RET-01**: ProfitLossResult 包含汇总数据:总收入、总成本、净盈亏、利润率
|
||||
- [ ] **RET-02**: ProfitLossResult 包含 []ProfitLossBreakdown 按资产类型拆分明细
|
||||
- [ ] **RET-03**: 所有金额以 int64 分为单位,不使用 float64 存储金额
|
||||
|
||||
### Asset Types
|
||||
|
||||
- [ ] **AST-01**: 定义 AssetType 枚举:Points(1)、Coupon(2)、ItemCard(3)、Product(4)、Fragment(5)、All(0)
|
||||
- [ ] **AST-02**: 每种资产类型对应不同的数据源表和 JOIN 路径
|
||||
- [ ] **AST-03**: Fragment 成本从 fragment_synthesis_logs 表获取
|
||||
|
||||
### Code Quality
|
||||
|
||||
- [ ] **QUA-01**: 新函数放在 internal/service/finance/ 包下
|
||||
- [ ] **QUA-02**: Service 构造器仅注入 DbR(读库),包内不出现 GetDbW() 调用
|
||||
- [ ] **QUA-03**: 每个 Scan() 调用必须检查 .Error 并返回错误,不静默吞掉
|
||||
- [ ] **QUA-04**: 复用现有 finance.* 工具函数(ClassifyOrderSpending、IsGamePassOrder 等),不重复实现
|
||||
- [ ] **QUA-05**: 使用 fan-out + in-memory merge 查询模式(多次独立 Scan,Go 层合并)
|
||||
|
||||
## v2 Requirements
|
||||
|
||||
Deferred to future release.
|
||||
|
||||
### Performance & Caching
|
||||
|
||||
- **PERF-01**: Redis TTL 缓存包装(查询延迟超过 2s 时启用)
|
||||
- **PERF-02**: 增量/时间桶聚合 + 物化统计表
|
||||
|
||||
### Integration
|
||||
|
||||
- **INT-01**: 抖音/直播间订单纳入用户维度盈亏统计
|
||||
- **INT-02**: 新增对应 Admin API endpoint 供前端调用
|
||||
|
||||
## Out of Scope
|
||||
|
||||
| Feature | Reason |
|
||||
|---------|--------|
|
||||
| 复用现有 Dashboard 盈亏逻辑 | 现有逻辑耦合 HTTP 上下文,计算口径不一致 |
|
||||
| Service 层内置缓存 | 缓存属于调用层责任,不应在 Service 函数内实现 |
|
||||
| 分页 | 聚合函数返回完整结果集,分页由 API 层处理 |
|
||||
| 返回格式化金额字符串 | 格式化属于展示层,Service 返回 int64 分 |
|
||||
| 物化表预计算 | 需要 schema 变更和写权限,v1 直接查询 |
|
||||
| 实时推送盈亏变化 | 需要事件基础设施,超出 v1 范围 |
|
||||
|
||||
## Traceability
|
||||
|
||||
| Requirement | Phase | Status |
|
||||
|-------------|-------|--------|
|
||||
| PNL-01 | Phase 1 | Pending |
|
||||
| PNL-02 | Phase 1 | Pending |
|
||||
| PNL-03 | Phase 1 | Pending |
|
||||
| PNL-04 | Phase 1 | Pending |
|
||||
| PNL-05 | Phase 1 | Pending |
|
||||
| PNL-06 | Phase 1 | Pending |
|
||||
| PNL-07 | Phase 1 | Pending |
|
||||
| PNL-08 | Phase 1 | Pending |
|
||||
| DIM-01 | Phase 1 | Pending |
|
||||
| DIM-02 | Phase 1 | Pending |
|
||||
| DIM-03 | Phase 1 | Pending |
|
||||
| DIM-04 | Phase 1 | Pending |
|
||||
| RET-01 | Phase 1 | Pending |
|
||||
| RET-02 | Phase 2 | Pending |
|
||||
| RET-03 | Phase 1 | Pending |
|
||||
| AST-01 | Phase 1 | Pending |
|
||||
| AST-02 | Phase 2 | Pending |
|
||||
| AST-03 | Phase 2 | Pending |
|
||||
| QUA-01 | Phase 1 | Pending |
|
||||
| QUA-02 | Phase 1 | Pending |
|
||||
| QUA-03 | Phase 1 | Pending |
|
||||
| QUA-04 | Phase 1 | Pending |
|
||||
| QUA-05 | Phase 1 | Pending |
|
||||
|
||||
**Coverage:**
|
||||
- v1 requirements: 23 total
|
||||
- Mapped to phases: 23
|
||||
- Unmapped: 0 ✓
|
||||
|
||||
---
|
||||
*Requirements defined: 2026-03-21*
|
||||
*Last updated: 2026-03-21 after roadmap creation*
|
||||
56
.planning/ROADMAP.md
Normal file
56
.planning/ROADMAP.md
Normal file
@ -0,0 +1,56 @@
|
||||
# Roadmap: Bindbox Game 盈亏统计函数
|
||||
|
||||
## Overview
|
||||
|
||||
Two reusable service-layer functions — `QueryUserProfitLoss` and `QueryActivityProfitLoss` — are built in a new `internal/service/finance/` package. Phase 1 delivers correct, working functions covering the full P&L computation path. Phase 2 populates the per-asset-type breakdown slice, including Fragment synthesis cost. The result is a clean, testable service that callers can invoke without touching HTTP handler logic.
|
||||
|
||||
## Phases
|
||||
|
||||
**Phase Numbering:**
|
||||
- Integer phases (1, 2, 3): Planned milestone work
|
||||
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
||||
|
||||
Decimal phases appear between their surrounding integers in numeric order.
|
||||
|
||||
- [x] **Phase 1: Core P&L Functions** - Scaffold the finance package and deliver working QueryUserProfitLoss / QueryActivityProfitLoss with correct revenue, cost, and profit (Completed: 2026-03-21)
|
||||
- [ ] **Phase 2: Per-Asset-Type Breakdown** - Populate the ProfitLossBreakdown slice for all 5 asset types, including Fragment synthesis cost from its own table
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 1: Core P&L Functions
|
||||
**Goal**: Callers can invoke QueryUserProfitLoss and QueryActivityProfitLoss and receive a correct ProfitLossResult with total revenue, cost, profit, and profit rate — with all edge cases handled
|
||||
**Depends on**: Nothing (first phase)
|
||||
**Requirements**: PNL-01, PNL-02, PNL-03, PNL-04, PNL-05, PNL-06, PNL-07, PNL-08, DIM-01, DIM-02, DIM-03, DIM-04, RET-01, RET-03, AST-01, QUA-01, QUA-02, QUA-03, QUA-04, QUA-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. Calling QueryUserProfitLoss with a list of user IDs returns a ProfitLossResult where Revenue equals actual_amount + discount_amount for non-refunded orders only, and game-pass orders contribute draw_count × activity_price instead of cash revenue
|
||||
2. Calling QueryActivityProfitLoss with an activity ID returns a ProfitLossResult where Revenue is attributed directly to the order's activity (1:1 per D-01 — no proration subquery)
|
||||
3. Both functions return an error (not silent zero) when any db.Scan() call fails
|
||||
4. Passing nil for StartTime/EndTime applies no time filter; passing nil/0 for AssetType returns aggregated totals across all asset types
|
||||
5. The package contains no call to GetDbW() — all queries route through the injected DbR handle; voided inventory (remark LIKE '%void%' or status=2) and refunded orders (status=3,4) are excluded from cost and revenue respectively
|
||||
**Plans**: 4 plans
|
||||
|
||||
Plans:
|
||||
- [ ] 01-01-PLAN.md — Package scaffold: types.go (AssetType enum + param/result structs) + service.go (interface + read-only constructor) + service_test.go (SQLite test infrastructure)
|
||||
- [ ] 01-02-PLAN.md — QueryUserProfitLoss: query_user.go with 4 fan-out scans (revenue, inventory cost, points cost, coupon cost) + integration tests
|
||||
- [ ] 01-03-PLAN.md — QueryActivityProfitLoss: query_activity.go with 4 fan-out scans attributed to activity dimension + integration tests
|
||||
- [ ] 01-04-PLAN.md — Phase 1 verification: full test suite + static checks (no GetDbW, fan-out count, finance functions reused, int64 monetary types)
|
||||
|
||||
### Phase 2: Per-Asset-Type Breakdown
|
||||
**Goal**: The ProfitLossBreakdown slice in every ProfitLossResult contains one entry per relevant asset type (Points, Coupon, ItemCard, Product, Fragment), with correct per-type cost including Fragment synthesis cost sourced from fragment_synthesis_logs
|
||||
**Depends on**: Phase 1
|
||||
**Requirements**: AST-02, AST-03, RET-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. A ProfitLossResult for an activity that awarded Points, Coupons, and Items contains exactly one ProfitLossBreakdown entry per asset type that had non-zero cost, with the correct cost value for each
|
||||
2. Fragment cost in the breakdown is sourced from fragment_synthesis_logs using the verified join path (not from user_inventory), and matches manual spot-checks against the database
|
||||
3. Summing all breakdown entries' Cost fields equals the total Cost field in the parent ProfitLossResult (no gaps or double-counting between breakdown and totals)
|
||||
**Plans**: TBD
|
||||
|
||||
## Progress
|
||||
|
||||
**Execution Order:**
|
||||
Phases execute in numeric order: 1 → 2
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. Core P&L Functions | 4/4 | Complete | 2026-03-21 |
|
||||
| 2. Per-Asset-Type Breakdown | 0/TBD | Not started | - |
|
||||
63
.planning/STATE.md
Normal file
63
.planning/STATE.md
Normal file
@ -0,0 +1,63 @@
|
||||
# Project State
|
||||
|
||||
## Project Reference
|
||||
|
||||
See: .planning/PROJECT.md (updated 2026-03-21)
|
||||
|
||||
**Core value:** 提供可复用的盈亏统计方法,使平台运营能从用户和活动两个维度快速了解各类资产的收支状况
|
||||
**Current focus:** Phase 2 — Per-Asset-Type Breakdown
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 1 of 2 (COMPLETE) → Phase 2 next
|
||||
Plan: 4/4 in Phase 1 complete
|
||||
Status: Phase 1 complete — ready to plan Phase 2
|
||||
Last activity: 2026-03-21 — Phase 1 executed: QueryUserProfitLoss + QueryActivityProfitLoss implemented, 22 tests passing
|
||||
|
||||
Progress: [█████░░░░░] 50%
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
**Velocity:**
|
||||
- Total plans completed: 0
|
||||
- Average duration: — min
|
||||
- Total execution time: 0 hours
|
||||
|
||||
**By Phase:**
|
||||
|
||||
| Phase | Plans | Total | Avg/Plan |
|
||||
|-------|-------|-------|----------|
|
||||
| - | - | - | - |
|
||||
|
||||
**Recent Trend:**
|
||||
- Last 5 plans: —
|
||||
- Trend: —
|
||||
|
||||
*Updated after each plan completion*
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Decisions
|
||||
|
||||
Decisions are logged in PROJECT.md Key Decisions table.
|
||||
Recent decisions affecting current work:
|
||||
|
||||
- Init: Fan-out + in-memory merge query pattern chosen (avoids Cartesian product JOINs)
|
||||
- Init: Read-only DB routing enforced — constructor injects DbR only, no GetDbW() in package
|
||||
- Init: All existing finance.* utilities (IsGamePassOrder, ComputeProfit, etc.) must be reused, not re-derived
|
||||
- Init: Phase 2 (Fragment breakdown) requires schema verification of fragment_synthesis_logs join path before implementation
|
||||
|
||||
### Pending Todos
|
||||
|
||||
None yet.
|
||||
|
||||
### Blockers/Concerns
|
||||
|
||||
- Phase 2: fragment_synthesis_logs join path and cost formula not yet verified — requires schema review during Phase 2 planning
|
||||
- Phase 1: SQLite test compatibility for CAST(AS SIGNED), GREATEST(), LIKE 'GP%' — must use Go-layer helpers or conditional SQL paths in integration tests
|
||||
|
||||
## Session Continuity
|
||||
|
||||
Last session: 2026-03-21
|
||||
Stopped at: Roadmap created — ROADMAP.md and STATE.md written, REQUIREMENTS.md traceability updated
|
||||
Resume file: None
|
||||
148
.planning/codebase/ARCHITECTURE.md
Normal file
148
.planning/codebase/ARCHITECTURE.md
Normal file
@ -0,0 +1,148 @@
|
||||
# Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
Bindbox Game follows a **layered monolith** architecture pattern built with Go and the Gin HTTP framework. The application serves as a backend for a blind box / lottery game platform with both a WeChat Mini Program client and a Vue 3 admin panel.
|
||||
|
||||
## Architectural Pattern
|
||||
|
||||
**Layered Architecture (Handler → Service → Repository)**
|
||||
|
||||
```
|
||||
HTTP Request
|
||||
↓
|
||||
[Router] → route matching + middleware (auth, RBAC, blacklist)
|
||||
↓
|
||||
[API Handler] → request parsing, validation, response formatting
|
||||
↓
|
||||
[Service Layer] → business logic, orchestration
|
||||
↓
|
||||
[Repository Layer] → GORM-based data access (MySQL read/write split)
|
||||
↓
|
||||
MySQL (Master/Slave)
|
||||
```
|
||||
|
||||
## Key Layers
|
||||
|
||||
### 1. Router Layer (`internal/router/`)
|
||||
|
||||
- `router.go` — Single file defining all routes via `NewHTTPMux()`
|
||||
- Routes organized into groups:
|
||||
- `/api/internal` — Internal service calls (X-Internal-Key auth)
|
||||
- `/api/admin` — Admin panel (JWT + RBAC)
|
||||
- `/api/app` — Mini Program public endpoints (no auth)
|
||||
- `/api/app` (auth group) — Mini Program authenticated endpoints
|
||||
- `/api/public` — Public livestream endpoints (access code auth)
|
||||
- `/api/pay` — WeChat Pay callbacks (no auth)
|
||||
|
||||
### 2. Interceptor / Middleware Layer (`internal/router/interceptor/`)
|
||||
|
||||
- `admin_auth.go` — JWT token verification for admin users
|
||||
- `admin_rbac.go` — Role-based access control with action-level permissions
|
||||
- `app_auth.go` — App user token verification
|
||||
- `blacklist.go` — Douyin user blacklist checking
|
||||
- `interceptor.go` — Base interceptor struct with shared dependencies
|
||||
|
||||
### 3. API Handler Layer (`internal/api/`)
|
||||
|
||||
Organized by domain:
|
||||
- `admin/` — Admin panel handlers (largest, ~30+ files)
|
||||
- `activity/` — Lottery/game activity handlers
|
||||
- `app/` — Store, product, banner, category handlers
|
||||
- `game/` — Game ticket and minesweeper handlers
|
||||
- `pay/` — Payment handlers
|
||||
- `user/` — User management, orders, addresses
|
||||
- `task_center/` — Task center handlers
|
||||
- `common/` — Shared utilities (upload, openid)
|
||||
- `public/` — Public livestream handlers
|
||||
- `internal/` — Internal API handlers (Nakama integration)
|
||||
|
||||
### 4. Service Layer (`internal/service/`)
|
||||
|
||||
Business logic organized by domain:
|
||||
- `activity/` — Activity CRUD, lottery processing, matching game, settlements, strategy pattern for draw types
|
||||
- `admin/` — Admin user management, login
|
||||
- `user/` — User management, orders, points, coupons, inventory, shipping, synthesis
|
||||
- `order/` — Order processing
|
||||
- `game/` — Game ticket management, minesweeper
|
||||
- `douyin/` — Douyin order sync, reward dispatching
|
||||
- `task_center/` — Task definitions, progress tracking, worker
|
||||
- `product/` — Product management
|
||||
- `finance/` — Financial operations, ledger
|
||||
- `channel/` — Marketing channel management
|
||||
- `title/` — User title/badge system
|
||||
- `banner/`, `sysconfig/`, `common/`, `snapshot/`, `recycle/`, `synthesis/`, `livestream/`
|
||||
|
||||
### 5. Repository Layer (`internal/repository/mysql/`)
|
||||
|
||||
- `mysql.go` — Database connection management (read/write split via `Repo` interface)
|
||||
- `plugin.go` — GORM plugins
|
||||
- `model/*.gen.go` — Generated GORM models (do not edit)
|
||||
- `dao/*.gen.go` — Generated GORM DAOs (do not edit)
|
||||
- `task_center/models.go` — Task center specific models
|
||||
- `test_helper.go`, `testrepo_sqlite.go` — Test infrastructure
|
||||
|
||||
## Entry Point
|
||||
|
||||
`main.go` initializes all infrastructure in order:
|
||||
1. Config (Viper/TOML)
|
||||
2. OpenTelemetry
|
||||
3. MySQL (master + slave)
|
||||
4. Logger (Zap-based with rotation)
|
||||
5. Redis
|
||||
6. HTTP server (Gin)
|
||||
7. Background workers (settlement, expiration, order sync, dynamic config)
|
||||
8. Graceful shutdown handler
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Typical API Request Flow
|
||||
```
|
||||
Client → Gin Router → Middleware Chain → Handler → Service → Repository → MySQL
|
||||
↓
|
||||
Redis (cache, locks)
|
||||
```
|
||||
|
||||
### Background Task Flow
|
||||
```
|
||||
Scheduler (cron) → Service Method → Repository → MySQL
|
||||
↓
|
||||
External API (WeChat, Douyin)
|
||||
```
|
||||
|
||||
### Payment Flow
|
||||
```
|
||||
Client → Preorder API → WeChat Pay API → Client pays → WeChat Callback → Notify Handler → Order Service
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Read/write DB split | Performance: heavy reads go to slave, writes to master |
|
||||
| GORM code generation | Consistency: models and DAOs auto-generated from schema |
|
||||
| Custom `core.Context` wrapper | Standardized error handling, tracing, session management across all handlers |
|
||||
| Strategy pattern for lottery | Different draw types (standard, ichiban) share interface but have different logic |
|
||||
| Background workers in main process | Simplicity: no separate worker binary, uses goroutines |
|
||||
| JWT with hash verification | Security: stored token hash prevents concurrent sessions |
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
- **Logging**: Zap-based with file rotation (`internal/pkg/logger/`)
|
||||
- **Tracing**: OpenTelemetry integration (`internal/pkg/otel/`)
|
||||
- **Error Codes**: 5-digit system in `internal/code/` (service level + module + specific)
|
||||
- **Alerts**: Alert notification system (`internal/alert/`)
|
||||
- **Metrics**: Prometheus metrics (`internal/metrics/`)
|
||||
|
||||
## External Service Boundaries
|
||||
|
||||
The application integrates with multiple external services through dedicated packages in `internal/pkg/`:
|
||||
- WeChat Mini Program API (`wechat/`, `miniprogram/`)
|
||||
- WeChat Pay v3 API (`pay/`)
|
||||
- Douyin/TikTok API (`douyin/`)
|
||||
- Aliyun SMS (`sms/`)
|
||||
- Tencent COS (object storage)
|
||||
- OpenTelemetry collector
|
||||
|
||||
---
|
||||
*Generated: 2026-03-21*
|
||||
213
.planning/codebase/CONCERNS.md
Normal file
213
.planning/codebase/CONCERNS.md
Normal file
@ -0,0 +1,213 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-03-21
|
||||
|
||||
---
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**WeChat AppSecret logged in plaintext:**
|
||||
- Risk: WeChat AppSecret (OAuth credentials) are written to application logs at Info/Error level, exposing secrets in log files and any log aggregation systems.
|
||||
- Files:
|
||||
- `internal/service/user/login_weixin.go:52` — `s.logger.Info("DEBUG: LoginWeixin Config", zap.String("AppSecret", wcfg.AppSecret))`
|
||||
- `internal/api/user/phone_bind.go:59` — `h.logger.Error("...", zap.String("app_secret", wxCfg.AppSecret))`
|
||||
- `internal/api/common/openid_app.go:44` — `h.logger.Info("GetOpenID Config", zap.String("AppSecret", wxcfg.AppSecret))`
|
||||
- Current mitigation: None. These are active, non-conditional log calls.
|
||||
- Recommendations: Remove all AppSecret fields from log calls immediately. If debugging is needed, log only AppID (never AppSecret).
|
||||
|
||||
**Hardcoded internal API key fallback:**
|
||||
- Risk: If `Internal.ApiKey` config is missing or empty, the system falls back to the hardcoded literal `"bindbox-internal-secret-2024"`. Any attacker with knowledge of this default can call all internal game settlement endpoints.
|
||||
- Files: `internal/router/router.go:99`
|
||||
- Current mitigation: Config key overrides the default when set.
|
||||
- Recommendations: Remove the hardcoded default. Fail-closed: if `expectedKey == ""`, reject all requests with 503 rather than falling back to a known string.
|
||||
|
||||
**Hardcoded Nakama server key:**
|
||||
- Risk: `internal/api/game/handler.go:207` contains `nakamaKey := "defaultkey"` which is the well-known Nakama default key, used when config is absent.
|
||||
- Files: `internal/api/game/handler.go:207`
|
||||
- Current mitigation: Config value overrides when present.
|
||||
- Recommendations: Panic at startup if Nakama key is not configured in production mode.
|
||||
|
||||
**CORS allows all origins with credentials:**
|
||||
- Risk: `AllowedOrigins: []string{"*"}` combined with `AllowCredentials: true` is an invalid CORS configuration per spec (browsers block it) and signals the intent to allow arbitrary origins was not fully thought through.
|
||||
- Files: `internal/pkg/cors/cors.go:14,34`
|
||||
- Current mitigation: Browsers enforce the spec restriction, partially preventing exploitation.
|
||||
- Recommendations: Replace `"*"` with an explicit allowlist of trusted origins.
|
||||
|
||||
**pprof profiling endpoint exposed in production:**
|
||||
- Risk: `core.WithEnablePProf()` is unconditionally passed in `internal/router/router.go:45`. The Go pprof endpoints at `/debug/pprof/*` expose heap dumps, goroutine stacks, CPU profiles, and memory layout — high-value information for attackers.
|
||||
- Files: `internal/router/router.go:45`, `internal/pkg/core/core.go:252-258`
|
||||
- Current mitigation: None — pprof is always enabled.
|
||||
- Recommendations: Gate `WithEnablePProf()` behind an environment check (`ENV != "pro"`).
|
||||
|
||||
---
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**Skipped Redis ticket validation in game token service:**
|
||||
- Issue: `internal/service/game/token.go:127-136` has a commented-out `return` statement under a `// TODO: 临时跳过 Redis 验证`. When the Redis key is not found, validation is bypassed and the game token is accepted regardless. This permanently disables single-use ticket enforcement.
|
||||
- Files: `internal/service/game/token.go:127-136`
|
||||
- Impact: A valid JWT can be replayed indefinitely once issued; single-use semantics are broken.
|
||||
- Fix approach: Restore the `return nil, fmt.Errorf("ticket not found or expired")` line. Investigate why tickets expire from Redis before use (likely TTL too short or Redis key prefix mismatch).
|
||||
|
||||
**Stub minesweeper game handlers:**
|
||||
- Issue: `internal/api/internal/minesweeper/handler.go` contains two handlers (`VerifyTicket`, `SettleGame`) that are entirely mock implementations. Both contain `// TODO: 实际验证逻辑` and return hardcoded responses. The settle handler always returns `success: true` and a mock reward string.
|
||||
- Files: `internal/api/internal/minesweeper/handler.go:46-78`
|
||||
- Impact: Minesweeper game settlement and ticket verification are non-functional. Any caller receives a fake success response regardless of actual state.
|
||||
- Fix approach: Implement proper Redis ticket lookup (matching the game token service), deduct tickets, and grant actual rewards on win.
|
||||
|
||||
**TODO counts in douyin order sync:**
|
||||
- Issue: `internal/api/admin/douyin_orders_admin.go:302,337` have `GrantedCount: 0` and `RefundedCount: 0` with TODO comments noting these should return actual counts from the sync/grant functions. Admin UI shows incorrect stats (always 0) for these operations.
|
||||
- Files: `internal/api/admin/douyin_orders_admin.go:302,337`
|
||||
- Impact: Douyin sync reports are inaccurate. Operators cannot confirm how many prizes were actually granted or refunded per sync run.
|
||||
- Fix approach: Update `SyncRefundStatus` and `GrantLivestreamPrizes` to return counts, propagate to response.
|
||||
|
||||
**Duplicate user handler registration with undocumented intent:**
|
||||
- Issue: `internal/router/router.go:83` has an explicit `// TODO: Check if userHandler and userAppHandler are redundant or distinct.` comment. Two user handler instances exist with unclear separation of responsibility.
|
||||
- Files: `internal/router/router.go:82-86`
|
||||
- Impact: Risk of inconsistent behavior — changes to one handler may be expected to apply to both but don't.
|
||||
- Fix approach: Audit both handler paths; consolidate or document the distinction.
|
||||
|
||||
---
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**Debug fmt.Printf statements left in production code:**
|
||||
- Symptoms: Over 35 `fmt.Printf("[DEBUG]...")` calls across production code path are unconditionally executed in all environments, writing to stdout rather than the structured Zap logger. This pollutes logs, degrades performance, and leaks business data.
|
||||
- Files (representative):
|
||||
- `internal/service/douyin/order_sync.go:648,710,731,800,812,824,829,840,846,850,855,859,872`
|
||||
- `internal/service/sysconfig/dynamic_config.go:198`
|
||||
- `internal/service/activity/activity_order_service.go:150,158,163,207,235,265`
|
||||
- `internal/service/user/order_timeout.go:51,60,150`
|
||||
- `internal/service/user/coupon_transfer.go:97`
|
||||
- `internal/api/user/login_app.go:59`
|
||||
- `internal/api/admin/lottery_admin.go:535`
|
||||
- `internal/api/activity/lottery_app.go:67`
|
||||
- `internal/api/activity/issue_choices_app.go:76,79,82`
|
||||
- `internal/service/game/token.go:78,131,141,147,152,159`
|
||||
- `internal/pkg/wechat/code2session.go:22`
|
||||
- Trigger: Always — these are unconditional print calls in hot paths.
|
||||
- Fix: Remove all `fmt.Printf` calls; replace necessary observability with `s.logger.Debug(...)` calls gated by log level.
|
||||
|
||||
**Silently discarded errors in critical paths:**
|
||||
- Symptoms: Multiple writes/inserts ignore errors via `_ = h.repo.GetDbW().Exec(...)` and `_ = h.repo.GetDbR().Raw(...).Scan(...)`. Failed inventory updates, ledger entries, and coupon operations produce no error response.
|
||||
- Files (representative):
|
||||
- `internal/api/admin/pay_refund_admin.go:155,174,195,198,224,227,235,239,262`
|
||||
- `internal/api/activity/lottery_app.go:429,639,667`
|
||||
- `internal/api/activity/issues_app.go:80`
|
||||
- `internal/api/admin/users_profile.go:172,188,197,222,246,249`
|
||||
- `internal/api/admin/activity_commitment_admin.go:62,63,64,66,96`
|
||||
- Trigger: On database errors in refund, inventory, and query flows.
|
||||
- Fix: Wrap these in proper error handling; at minimum log errors; for writes in financial flows, propagate errors to callers.
|
||||
|
||||
---
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**Dashboard admin handler is a 2,666-line monolith:**
|
||||
- Problem: `internal/api/admin/dashboard_admin.go` contains the entire dashboard implementation in a single file. Many dashboard queries perform multiple full-table scans on orders, inventory, and draw_logs tables without pagination constraints.
|
||||
- Files: `internal/api/admin/dashboard_admin.go`
|
||||
- Cause: Dashboard endpoints aggregate across all-time data. Complex multi-table JOINs (up to 7 tables in `dashboard_spending.go`) run inline per request.
|
||||
- Improvement path: Introduce materialized summaries or scheduled background computation for dashboard aggregates. Split the file into per-widget files (max 400 lines each).
|
||||
|
||||
**Unguarded `Find()` calls without LIMIT in handler layer:**
|
||||
- Problem: Several handlers call `.Find()` on potentially unbounded result sets.
|
||||
- Files:
|
||||
- `internal/api/app/categories.go:44` — all active categories loaded at once
|
||||
- `internal/api/activity/issue_choices_app.go:50` — all reward settings for an issue
|
||||
- `internal/api/activity/draw_logs_app.go:133,146,164` — users, rewards, products hydration in loops
|
||||
- `internal/api/app/product_category.go:47`
|
||||
- Cause: Missing `Limit()` calls; no pagination on supporting queries.
|
||||
- Improvement path: Add `.Limit(500)` guards and paginate public-facing endpoints. For in-memory hydration loops, batch-query by IN clause (already done in some places) but add bounds.
|
||||
|
||||
**time.Sleep in hot production paths:**
|
||||
- Problem: `internal/pkg/wechat/shipping.go` uses `time.Sleep(time.Second)` and `time.Sleep(2 * time.Second)` between WeChat API calls, blocking goroutines for up to 4 seconds per operation. Under load, this exhausts the goroutine pool.
|
||||
- Files: `internal/pkg/wechat/shipping.go:138,165,215,246`
|
||||
- Cause: Naive retry/rate-limit handling.
|
||||
- Improvement path: Replace with exponential backoff using `time.After` or `context`-aware wait, and move to a worker pool pattern.
|
||||
|
||||
---
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**Handler layer bypasses service layer for DB writes:**
|
||||
- Files: `internal/api/admin/pay_refund_admin.go`, `internal/api/activity/lottery_app.go`, `internal/api/activity/issue_choices_app.go`
|
||||
- Why fragile: 113 direct `GetDbW()` calls exist in the API handler layer. Business logic (inventory updates, ledger entries, item card resets) is scattered across handlers and services, making transactional consistency hard to enforce and audit.
|
||||
- Safe modification: Any change to refund or inventory logic must trace all three locations (handler, service, scheduler). Do not add new business writes in handlers.
|
||||
- Test coverage: No dedicated tests for `pay_refund_admin.go` refund flows.
|
||||
|
||||
**Douyin order sync with goroutine fan-out and mutex:**
|
||||
- Files: `internal/service/douyin/order_sync.go`
|
||||
- Why fragile: The sync loop at line 1056 spawns a goroutine per order item with a shared `sync.Mutex` for counter updates. Errors from individual goroutines are collected in a slice protected by mutex, but goroutine lifecycle is managed only through a `sync.WaitGroup`. A single panicking goroutine will be caught by the task center worker recover, but may leave the mutex in an inconsistent state.
|
||||
- Safe modification: Add goroutine-level panic recovery inside the fan-out goroutine (line 1056). Do not increase fan-out concurrency without adding semaphore limiting.
|
||||
- Test coverage: 1 test file for a 1,094-line service.
|
||||
|
||||
**Task center service is a 1,665-line file with embedded BUG FIX comments:**
|
||||
- Files: `internal/service/task_center/service.go`
|
||||
- Why fragile: The file contains multiple `// BUG FIX:` comments at lines 715, 809, 1558, 1581, 1609, 1629 indicating patch-on-patch fixes. The quantity resolution logic for reward payloads has been fixed three times (lines 1558, 1581, 1609, 1629) with slight variations — these must be kept consistent.
|
||||
- Safe modification: When modifying reward quantity parsing, update all four sites. Do not add a fifth variant.
|
||||
- Test coverage: 4 test files cover primarily list filtering and invite logic, not the reward claim path.
|
||||
|
||||
**Dynamic config service with global singleton and panic:**
|
||||
- Files: `internal/service/sysconfig/global.go:34-37`, `internal/service/sysconfig/dynamic_config.go`
|
||||
- Why fragile: `GetGlobalDynamicConfig()` panics if called before `InitGlobalDynamicConfig()`. Any package that calls this at init time or before `main.go` initialization order completes will crash the process.
|
||||
- Safe modification: Always call `InitGlobalDynamicConfig()` as the first step after DB initialization in `main.go`. Do not call `GetGlobalDynamicConfig()` at package `init()` scope.
|
||||
- Test coverage: No tests in `internal/service/sysconfig/`.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**User service layer (37 source files, 2 test files):**
|
||||
- What's not tested: Login flow, coupon add/transfer, order timeout, address share, expiration task, WeChat integration wrappers.
|
||||
- Files: `internal/service/user/` (35 untested files including `login_weixin.go`, `coupon_add.go`, `order_timeout.go`, `address_share.go`)
|
||||
- Risk: Regressions in payment-adjacent and user lifecycle code go undetected.
|
||||
- Priority: High
|
||||
|
||||
**Activity service (24 source files, 3 test files):**
|
||||
- What's not tested: `lottery_process.go`, `activity_order_service.go`, `scheduler.go`, `matching_game.go`, most of the 24 files.
|
||||
- Files: `internal/service/activity/`
|
||||
- Risk: Core lottery and order creation flows have no automated test coverage. Any refactor risks silent breakage.
|
||||
- Priority: High
|
||||
|
||||
**Admin service (6 source files, 0 test files):**
|
||||
- What's not tested: All of `internal/service/admin/`.
|
||||
- Files: `internal/service/admin/`
|
||||
- Risk: Admin-level business logic untested.
|
||||
- Priority: Medium
|
||||
|
||||
**Snapshot and recycle services:**
|
||||
- What's not tested: `internal/service/snapshot/` (2 files, 0 tests), `internal/service/recycle/` (1 file, 0 tests).
|
||||
- Risk: Snapshot replay and recycle operations silently broken.
|
||||
- Priority: Medium
|
||||
|
||||
**Sysconfig service:**
|
||||
- What's not tested: `internal/service/sysconfig/` (3 files, 0 tests) including the global singleton and dynamic config loader.
|
||||
- Risk: Config loading failures or key formatting bugs go undetected.
|
||||
- Priority: Medium
|
||||
|
||||
---
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**Matching game in-memory state:**
|
||||
- Current capacity: Game state for the matching card game (`MatchingGameState`) is stored in `internal/service/activity/matching_game.go` using an in-memory `sync.Mutex`-protected struct. This is per-process state.
|
||||
- Limit: Cannot scale horizontally — a second server instance has no visibility into game state of the first.
|
||||
- Scaling path: Migrate game state to Redis using atomic operations or use a dedicated game state store (e.g., Nakama, which is already referenced in the codebase).
|
||||
|
||||
**Scheduler goroutines without stop signal:**
|
||||
- Current capacity: `internal/service/activity/scheduler.go:38` and `internal/service/douyin/scheduler.go:29` spawn bare goroutines that loop forever on `time.Sleep(30 * time.Second)`.
|
||||
- Limit: Cannot be stopped gracefully on shutdown — goroutines outlive the shutdown context.
|
||||
- Scaling path: Thread a `context.Context` into the scheduler loop and break on `ctx.Done()`.
|
||||
|
||||
---
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**Proliferation of debug command tools in `cmd/`:**
|
||||
- Risk: `cmd/` contains 9+ one-off debug/diagnostic tools (`debug_task_270`, `debug_check_coupon_22`, `fix_openid`, `exploit_verify`, `check_order`, etc.) that have hardcoded database credentials or connection strings for one-time use. These tools may be committed with active credentials and are not maintained.
|
||||
- Impact: Security exposure if credentials are embedded; build confusion if these break the CI pipeline.
|
||||
- Migration plan: Move all one-off tools to a `tools/` directory with a clear no-deploy policy, or delete after use.
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-03-21*
|
||||
173
.planning/codebase/CONVENTIONS.md
Normal file
173
.planning/codebase/CONVENTIONS.md
Normal file
@ -0,0 +1,173 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-03-21
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files (Go backend):**
|
||||
- snake_case for all Go source files: `activity_order_service.go`, `draw_config_save.go`
|
||||
- Test files co-located with source: `reward_snapshot_test.go` next to `rewards_create.go`
|
||||
- Generated files suffixed with `.gen.go`: never edited manually
|
||||
- Package names match directory name: `package activity` in `internal/service/activity/`
|
||||
|
||||
**Files (Vue frontend):**
|
||||
- kebab-case for TypeScript API files: `pay-orders.ts`, `order-snapshots.ts`
|
||||
- kebab-case for view directories: `player-manage/`, `shipping-orders/`
|
||||
- PascalCase for Vue component filenames where applicable
|
||||
|
||||
**Functions (Go):**
|
||||
- PascalCase for exported: `NewActivityOrderService`, `CreateActivityOrder`, `ListProductsForApp`
|
||||
- camelCase for unexported: `newRewardSnapshotTestService`, `shouldTriggerInstantDraw`, `assertAttribution`
|
||||
- Constructor functions named `New<Type>` for service constructors: `NewProduct(...)`, `NewStore(...)`
|
||||
- Handler methods return `core.HandlerFunc` (closure pattern): `func (h *productHandler) ListProductsForApp() core.HandlerFunc`
|
||||
|
||||
**Functions (TypeScript frontend):**
|
||||
- `fetch` prefix for API functions: `fetchGetActivities`, `fetchGetActivityDetail`
|
||||
- camelCase for all functions
|
||||
|
||||
**Variables:**
|
||||
- camelCase in Go: `userID`, `activityID`, `testLogger`
|
||||
- Named ID variables use int64 type consistently: `userID int64`, `activityID int64`
|
||||
|
||||
**Types/Structs (Go):**
|
||||
- PascalCase for exported: `CreateActivityOrderRequest`, `ActivityOrderService`
|
||||
- Unexported structs for implementation: `activityOrderService`, `productHandler`, `context`
|
||||
- Request structs named `<verb><Domain>Request`: `listAppProductsRequest`, `CreateActivityOrderRequest`
|
||||
- Response structs named `<verb><Domain>Response`: `listAppProductsResponse`, `getAppProductDetailResponse`
|
||||
- Interface types use verb-noun: `ActivityOrderService`, `Service`, `Repo`
|
||||
|
||||
**Constants (Go error codes):**
|
||||
- 5-digit pattern: service level (1) + module level (2) + specific error (2)
|
||||
- All-caps with CamelCase words: `ServerError = 10101`, `ParamBindError = 10102`
|
||||
- Grouped by domain in `internal/code/code.go`
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting (Go):**
|
||||
- `gofmt -s` via `make fmt` (uses standard gofmt)
|
||||
- Import grouping via `go run cmd/mfmt/main.go`: stdlib → local module (`bindbox-game/...`) → third-party
|
||||
- Line length not strictly enforced but long lines occur in handler code
|
||||
|
||||
**Linting (Go):**
|
||||
- `golangci-lint run -D staticcheck` via `make lint`
|
||||
- staticcheck disabled; other default golangci-lint checks active
|
||||
|
||||
**Formatting (Frontend):**
|
||||
- Prettier for all file types, configured via lint-staged hooks
|
||||
- ESLint with `eslint-plugin-prettier/recommended`
|
||||
- Single quotes enforced: `quotes: ['error', 'single']`
|
||||
- No semicolons: `semi: ['error', 'never']`
|
||||
- No `var`: `'no-var': 'error'` — use `let` or `const`
|
||||
- `@typescript-eslint/no-explicit-any` disabled (any is allowed)
|
||||
- Vue multi-word component name rule disabled
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Go — Three groups (enforced by `cmd/mfmt/main.go`):**
|
||||
1. Standard library: `"context"`, `"net/http"`, `"testing"`
|
||||
2. Local module: `"bindbox-game/internal/pkg/core"`, `"bindbox-game/internal/repository/mysql"`
|
||||
3. Third-party: `"gorm.io/gorm"`, `"github.com/gin-gonic/gin"`, `"go.uber.org/zap"`
|
||||
|
||||
**TypeScript — Relative imports with `@/` alias:**
|
||||
- `import request from '@/utils/http'`
|
||||
- `import { getActivityDetail } from './adminActivities'` (relative for same-level)
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Handler layer pattern:**
|
||||
```go
|
||||
if err := ctx.ShouldBindForm(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Service-to-handler errors:**
|
||||
```go
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ServerError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Special string-based sentinel errors (avoid when possible, currently used in product handler):**
|
||||
```go
|
||||
if err.Error() == "PRODUCT_OFFSHELF" {
|
||||
ctx.AbortWithError(core.Error(http.StatusOK, 20001, "商品已下架"))
|
||||
return
|
||||
}
|
||||
```
|
||||
|
||||
**Business error construction:** Always use `core.Error(httpCode, businessCode, message)`. Optionally chain `.WithError(err)` to attach stack trace or `.WithAlert()` for alerting.
|
||||
|
||||
**Test error handling:** Use `t.Fatal(err)` for setup failures, `t.Fatalf(...)` with format strings for assertion failures, `t.Skipf(...)` when preconditions fail (e.g., no live DB).
|
||||
|
||||
## Logging
|
||||
|
||||
**Framework:** Zap-based custom logger via `internal/pkg/logger` (`logger.CustomLogger` interface)
|
||||
|
||||
**Patterns:**
|
||||
- Logger injected into handlers and services via constructor
|
||||
- Handler structs hold `logger logger.CustomLogger` field
|
||||
- Service structs hold `logger logger.CustomLogger` field
|
||||
- Use structured fields: `zap.Field` variadic args
|
||||
- Exported methods: `Info`, `Error`, `Warn`, `Debug`
|
||||
- In tests, use `logger.NewCustomLogger(nil, logger.WithOutputInConsole())`
|
||||
|
||||
## Comments
|
||||
|
||||
**Swagger annotations on every exported handler:**
|
||||
```go
|
||||
// ListProductsForApp 商品列表
|
||||
// @Summary 商品列表
|
||||
// @Description ...
|
||||
// @Tags APP端.商品
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security LoginVerifyToken
|
||||
// @Param ...
|
||||
// @Success 200 {object} listAppProductsResponse
|
||||
// @Failure 400 {object} code.Failure
|
||||
// @Router /api/app/products [get]
|
||||
```
|
||||
|
||||
**Chinese comments common** for domain logic inline comments, struct field descriptions, and test assertions — bilingual codebase.
|
||||
|
||||
**Interface private guard pattern:**
|
||||
```go
|
||||
// i 为了避免被其他包实现
|
||||
i()
|
||||
```
|
||||
|
||||
## Function Design
|
||||
|
||||
**Handler functions:** Return `core.HandlerFunc` (closure); keep handler thin — delegate to service layer.
|
||||
|
||||
**Service constructors:** Always return interface, not concrete struct:
|
||||
```go
|
||||
func NewActivityOrderService(l logger.CustomLogger, db mysql.Repo) ActivityOrderService {
|
||||
return &activityOrderService{...}
|
||||
}
|
||||
```
|
||||
|
||||
**Service structs hold:** `logger`, `readDB *dao.Query`, `writeDB *dao.Query`, `repo mysql.Repo`, plus nested service interfaces for cross-domain calls.
|
||||
|
||||
**Context propagation:** Use `core.Context` in handlers (not `gin.Context`); extract `context.Context` via `ctx.RequestContext()` for service calls.
|
||||
|
||||
**Pagination defaults:** Always default `Page = 1`, `PageSize = 20` when not provided.
|
||||
|
||||
## Module Design
|
||||
|
||||
**Layer boundaries:**
|
||||
- `internal/api/` → handlers only, thin, call services
|
||||
- `internal/service/` → business logic, call DAOs and other services
|
||||
- `internal/repository/mysql/` → data access via GORM DAOs (generated)
|
||||
- `internal/pkg/` → shared utilities, no business logic
|
||||
|
||||
**Barrel files:** Not used in Go. Each file exports its own types.
|
||||
|
||||
**Repo interface:** All database access goes through `mysql.Repo` interface (`GetDbR()`, `GetDbW()`). Always use `dao.Use(db.GetDbR())` for read queries and `dao.Use(db.GetDbW())` for writes.
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-03-21*
|
||||
186
.planning/codebase/INTEGRATIONS.md
Normal file
186
.planning/codebase/INTEGRATIONS.md
Normal file
@ -0,0 +1,186 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-03-21
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**WeChat Mini Program:**
|
||||
- Service: WeChat Mini Program (微信小程序)
|
||||
- Purpose: User authentication, phone number retrieval, QR code generation, subscribe messages, short links, URL schemes, shipping queries
|
||||
- SDK/Client: Custom HTTP client in `internal/pkg/wechat/` and `internal/pkg/miniprogram/`
|
||||
- Key files: `internal/pkg/wechat/code2session.go`, `internal/pkg/wechat/phone_number.go`, `internal/pkg/wechat/decrypt.go`, `internal/pkg/wechat/qrcode.go`, `internal/pkg/miniprogram/access_token.go`, `internal/pkg/miniprogram/subscribe.go`
|
||||
- Auth: `configs.Wechat.AppID` / `configs.Wechat.AppSecret` (config keys: `wechat.app_id`, `wechat.app_secret`)
|
||||
- Template: `configs.Wechat.LotteryResultTemplateID` for subscribe messages
|
||||
|
||||
**WeChat Pay:**
|
||||
- Service: WeChat Pay API v3 (微信支付)
|
||||
- Purpose: Payment processing for game activities
|
||||
- SDK/Client: `github.com/wechatpay-apiv3/wechatpay-go v0.2.21`
|
||||
- Key files: `internal/pkg/pay/wechat.go`, `internal/pkg/pay/client.go`
|
||||
- Auth: Merchant ID (`WECHAT_MCHID`), API v3 key (`WECHAT_API_V3_KEY`), serial number (`WECHAT_SERIAL_NO`), RSA private key (`WECHAT_PRIVATE_KEY_PATH`)
|
||||
- Supports dynamic config override from `sysconfig` service (Base64 private key stored in DB)
|
||||
- Notify URL: `WECHAT_NOTIFY_URL` (callback for payment results)
|
||||
|
||||
**Douyin (TikTok) / 抖店:**
|
||||
- Service: Douyin Mini Program + 抖店 (TikTok Shop) API
|
||||
- Purpose: User auth, order synchronization, product rewards, Douyin access token
|
||||
- SDK/Client: Custom HTTP client in `internal/pkg/douyin/`
|
||||
- Key files: `internal/pkg/douyin/access_token.go`, `internal/pkg/douyin/code2session.go`, `internal/pkg/douyin/phonenumber.go`
|
||||
- External endpoint: `https://developer.toutiao.com/api/apps/v2/token`
|
||||
- Auth: `configs.Douyin.AppID` / `configs.Douyin.AppSecret` (read from dynamic sysconfig at runtime)
|
||||
- Background task: `douyinsvc.StartDouyinOrderSync()` runs scheduled order sync
|
||||
|
||||
**Aliyun SMS (阿里云短信):**
|
||||
- Service: Alibaba Cloud Dysms (短信服务)
|
||||
- Purpose: SMS verification code delivery
|
||||
- SDK/Client: `github.com/alibabacloud-go/dysmsapi-20170525/v4 v4.1.3` + `github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13`
|
||||
- Key files: `internal/pkg/sms/aliyun.go`
|
||||
- External endpoint: `dysmsapi.aliyuncs.com`
|
||||
- Auth: `ALIYUN_SMS_ACCESS_KEY_ID` / `ALIYUN_SMS_ACCESS_KEY_SECRET`
|
||||
- Config: `ALIYUN_SMS_SIGN_NAME`, `ALIYUN_SMS_TEMPLATE_CODE`
|
||||
|
||||
**Tencent COS (腾讯云对象存储):**
|
||||
- Service: Tencent Cloud Object Storage
|
||||
- Purpose: File uploads (images, game assets, user avatars)
|
||||
- SDK/Client: `github.com/tencentyun/cos-go-sdk-v5 v0.7.37`
|
||||
- Auth: `configs.COS.SecretID` / `configs.COS.SecretKey`
|
||||
- Config: `configs.COS.Bucket` (e.g., `keaiya-1259195914`), `configs.COS.Region` (e.g., `ap-shanghai`), `configs.COS.BaseURL` (optional CDN URL)
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- MySQL (primary)
|
||||
- Connection: Read replica via `MYSQL_READ_ADDR` / `MYSQL_ADDR`; Write master via `MYSQL_WRITE_ADDR` / `MYSQL_ADDR`
|
||||
- User: `MYSQL_USER`, Password: `MYSQL_PASS`, DB name: `MYSQL_NAME`
|
||||
- Client: GORM v1.25.9 (`gorm.io/gorm`) with `gorm.io/driver/mysql v1.5.2`
|
||||
- Pool: max 100 open connections, 5 idle, 2 min lifetime
|
||||
- Read/write split: manual two-connection pattern (`GetDbR()` / `GetDbW()`) in `internal/repository/mysql/mysql.go`
|
||||
- Generated DAOs: `internal/repository/mysql/dao/*.gen.go`
|
||||
- Generated models: `internal/repository/mysql/model/*.gen.go`
|
||||
- Do NOT edit `.gen.go` files directly
|
||||
|
||||
- SQLite (test only)
|
||||
- Used in test helpers (`internal/repository/mysql/testrepo_sqlite.go`) for in-memory unit tests
|
||||
- Driver: `gorm.io/driver/sqlite v1.4.3`
|
||||
|
||||
**File Storage:**
|
||||
- Tencent COS - all uploaded files (see COS section above)
|
||||
- Local filesystem for logs (`./logs/mini-chat-access.log`) with rotation via lumberjack
|
||||
|
||||
**Caching:**
|
||||
- Redis (single-node)
|
||||
- Connection: `REDIS_ADDR` (default in dev: `127.0.0.1:6379`), `REDIS_PASS`, DB index from `configs.Redis.DB`
|
||||
- Client: `github.com/redis/go-redis/v9 v9.17.2`
|
||||
- Singleton initialized in `internal/pkg/redis/redis.go` via `redis.Init()`
|
||||
- Pool: 20 connections, dial timeout 5s, read/write timeout 3s
|
||||
- Used for: activity settlement, task center worker, session management
|
||||
- Test: `github.com/alicebob/miniredis/v2 v2.36.1` for in-memory Redis in tests
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Admin JWT:**
|
||||
- Provider: Custom JWT (HS256)
|
||||
- Implementation: `internal/pkg/jwtoken/jwtoken.go`
|
||||
- Middleware: `internal/router/interceptor/admin_auth.go`
|
||||
- Secret: `ADMIN_JWT_SECRET` env var (falls back to `configs.JWT.AdminSecret`)
|
||||
- Token payload: `proposal.SessionUserInfo` (user ID, role, session info)
|
||||
- Token verification: signature + user active + token hash match (prevents concurrent sessions)
|
||||
|
||||
**App User JWT (WeChat/Douyin users):**
|
||||
- Provider: Custom JWT (HS256)
|
||||
- Middleware: `internal/router/interceptor/app_auth.go`
|
||||
- Secret: `configs.JWT.PatientSecret` (config key: `jwt.patient_secret`)
|
||||
- Separate secret from admin tokens
|
||||
|
||||
**RBAC (Admin):**
|
||||
- Implementation: `internal/router/interceptor/admin_rbac.go`
|
||||
- Pattern: Role-based — `RequireAdminRole()` checks any role assigned; `RequireAdminAction(mark)` checks specific action permission
|
||||
|
||||
**Internal Service Auth:**
|
||||
- Pattern: `X-Internal-Key` header check for internal API endpoints (`/api/internal/*`)
|
||||
- Secret: `configs.Internal.ApiKey` (env: hardcoded fallback `bindbox-internal-secret-2024`)
|
||||
- Used for Nakama game server communication
|
||||
|
||||
**Blacklist:**
|
||||
- Implementation: `internal/router/interceptor/blacklist.go`
|
||||
- Token blacklisting support (likely Redis-backed)
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Distributed Tracing:**
|
||||
- Service: OpenTelemetry (OTLP HTTP) — compatible with Grafana Tempo
|
||||
- SDK: `go.opentelemetry.io/otel v1.39.0` + `otlptracehttp` exporter
|
||||
- Implementation: `internal/pkg/otel/otel.go`, `internal/pkg/otel/middleware.go`
|
||||
- Config: `configs.Otel.Enabled` (bool), `configs.Otel.Endpoint` (e.g., `tempo:4318`)
|
||||
- Middleware applied in `internal/router/router.go` when enabled
|
||||
- Gin middleware traces all HTTP requests
|
||||
|
||||
**Metrics:**
|
||||
- Service: Prometheus
|
||||
- SDK: `github.com/prometheus/client_golang v1.17.0`
|
||||
- Implementation: `internal/metrics/` package (referenced in proposal)
|
||||
|
||||
**Logging:**
|
||||
- Framework: Uber Zap `go.uber.org/zap v1.26.0`
|
||||
- Custom wrapper: `internal/pkg/logger/logger.go`
|
||||
- File rotation: `gopkg.in/natefinch/lumberjack.v2 v2.2.1`
|
||||
- Log file: `./logs/mini-chat-access.log`
|
||||
- Log levels: debug, info, warn, error, fatal
|
||||
|
||||
**Profiling:**
|
||||
- pprof endpoint enabled in dev via `github.com/gin-contrib/pprof v1.4.0`
|
||||
- Enabled in router: `core.WithEnablePProf()`
|
||||
|
||||
**Error Tracking:**
|
||||
- Custom alert handler: `internal/alert/` package
|
||||
- Registered via `core.WithAlertNotify(alert.NotifyHandler())` in router
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Containerization:**
|
||||
- Docker multi-stage build: `Dockerfile`
|
||||
- Build image: `golang:1.24-alpine`
|
||||
- Runtime image: `alpine:latest`
|
||||
- Port: `9991`
|
||||
- Health check: `GET http://localhost:9991/system/health`
|
||||
- Example image: `zfc931912343/bindbox-game:v1.10`
|
||||
|
||||
**Build Targets:**
|
||||
- Linux (amd64): `make build-linux` → binary `bindboxgame_api`
|
||||
- macOS: `make build-mac`
|
||||
- Windows: `make build-win` → `bindboxgame_api.exe`
|
||||
|
||||
**CI Pipeline:**
|
||||
- Not detected in codebase (no GitHub Actions / CI config files found)
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars (production):**
|
||||
- `ENV` — Environment selector (`dev`/`fat`/`uat`/`pro`)
|
||||
- `MYSQL_ADDR` or `MYSQL_READ_ADDR` + `MYSQL_WRITE_ADDR`
|
||||
- `MYSQL_USER`, `MYSQL_PASS`, `MYSQL_NAME`
|
||||
- `REDIS_ADDR`, `REDIS_PASS`
|
||||
- `WECHAT_MCHID`, `WECHAT_SERIAL_NO`, `WECHAT_API_V3_KEY`, `WECHAT_PRIVATE_KEY_PATH`, `WECHAT_NOTIFY_URL`
|
||||
- `ALIYUN_SMS_ACCESS_KEY_ID`, `ALIYUN_SMS_ACCESS_KEY_SECRET`, `ALIYUN_SMS_SIGN_NAME`, `ALIYUN_SMS_TEMPLATE_CODE`
|
||||
- `ADMIN_JWT_SECRET`
|
||||
|
||||
**Secrets location:**
|
||||
- Primary: TOML config files embedded in binary (`configs/*.toml`) — note dev TOML contains real credentials (security concern)
|
||||
- Override: Environment variables at runtime (preferred for production)
|
||||
- WeChat Pay private key: file path or Base64 in `sysconfig` DB table (dynamic config)
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- WeChat Pay payment notification: `configs.WechatPay.NotifyURL` (`WECHAT_NOTIFY_URL`) — called by WeChat servers to deliver payment results; handled in `internal/api/pay/` package
|
||||
- Douyin order notification: `configs.Douyin.NotifyURL` — callback for Douyin order events
|
||||
|
||||
**Outgoing:**
|
||||
- Douyin access token refresh: `POST https://developer.toutiao.com/api/apps/v2/token`
|
||||
- Aliyun SMS send: `POST https://dysmsapi.aliyuncs.com`
|
||||
- WeChat API calls: Various WeChat Mini Program endpoints for auth, phone, subscribe messages
|
||||
- Tencent COS: Object upload/download operations
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-03-21*
|
||||
144
.planning/codebase/STACK.md
Normal file
144
.planning/codebase/STACK.md
Normal file
@ -0,0 +1,144 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-03-21
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- Go 1.24.0 - Backend server, all business logic, API handlers
|
||||
- TypeScript ~5.6.3 - Frontend admin panel (`web/admin/src/`)
|
||||
|
||||
**Secondary:**
|
||||
- SQL - Database migrations (`migrations/` directory)
|
||||
- TOML - Configuration files (`configs/*.toml`)
|
||||
- SCSS - Frontend styles (`web/admin/src/assets/styles/`)
|
||||
|
||||
## Runtime
|
||||
|
||||
**Backend:**
|
||||
- Go runtime 1.24.0 (toolchain go1.24.2)
|
||||
- Docker: `golang:1.24-alpine` build stage, `alpine:latest` final stage
|
||||
|
||||
**Frontend:**
|
||||
- Node.js >= 18.0.0
|
||||
|
||||
**Package Manager:**
|
||||
- Go modules (`go.mod` / `go.sum`) - lockfile present
|
||||
- pnpm >= 8.8.0 - frontend (`web/admin/pnpm-lock.yaml`) - lockfile present
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Backend Core:**
|
||||
- `github.com/gin-gonic/gin v1.9.1` - HTTP web framework
|
||||
- `gorm.io/gorm v1.25.9` - ORM for MySQL
|
||||
- `gorm.io/gen v0.3.26` - GORM code generation from schema
|
||||
- `gorm.io/plugin/dbresolver v1.5.0` - Read/write split support
|
||||
|
||||
**Frontend Core:**
|
||||
- Vue 3 `^3.5.21` - UI framework (`web/admin/src/`)
|
||||
- Vite `^5.4.10` - Build tool and dev server
|
||||
- Element Plus `^2.11.2` - UI component library
|
||||
- Pinia `^3.0.3` - State management
|
||||
- Vue Router `^4.5.1` - Client-side routing
|
||||
- Tailwind CSS `^4.1.14` - Utility-first CSS
|
||||
|
||||
**Testing (Backend):**
|
||||
- `github.com/stretchr/testify v1.11.1` - Assertions
|
||||
- `github.com/DATA-DOG/go-sqlmock v1.5.2` - MySQL mock
|
||||
- `github.com/alicebob/miniredis/v2 v2.36.1` - In-memory Redis for tests
|
||||
- `gorm.io/driver/sqlite v1.4.3` - SQLite for in-memory test DB (`internal/repository/mysql/testrepo_sqlite.go`)
|
||||
|
||||
**Testing (Frontend):**
|
||||
- Vitest `^1.0.0` - Unit test runner
|
||||
- `@vue/test-utils ^2.4.0` - Vue component testing
|
||||
|
||||
**Build/Dev (Backend):**
|
||||
- Makefile - Task runner (`Makefile`)
|
||||
- `golangci-lint` - Linter (install via `make tools`)
|
||||
- `go-swagger` - Swagger generation (install via `make tools`)
|
||||
- `cmd/mfmt/main.go` - Custom import formatter (groups: stdlib, local, third-party)
|
||||
- `cmd/gormgen/main.go` - GORM model/DAO code generator
|
||||
|
||||
**Build/Dev (Frontend):**
|
||||
- ESLint `^9.9.1` + TypeScript ESLint `^8.3.0` - Linting
|
||||
- Prettier `^3.5.3` - Code formatting
|
||||
- Stylelint `^16.20.0` - CSS/SCSS linting
|
||||
- Husky `^9.1.5` + lint-staged - Pre-commit hooks
|
||||
- Terser `^5.36.0` - Minification
|
||||
- `vite-plugin-compression ^0.5.1` - Gzip compression for production
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical (Backend):**
|
||||
- `github.com/spf13/viper v1.17.0` - Configuration management (TOML, env var overrides)
|
||||
- `go.uber.org/zap v1.26.0` - Structured logging
|
||||
- `gopkg.in/natefinch/lumberjack.v2 v2.2.1` - Log file rotation
|
||||
- `github.com/golang-jwt/jwt/v5 v5.2.0` - JWT auth tokens
|
||||
- `github.com/redis/go-redis/v9 v9.17.2` - Redis client (singleton)
|
||||
- `github.com/go-sql-driver/mysql v1.7.1` - MySQL driver
|
||||
- `github.com/bytedance/sonic v1.13.2` - High-performance JSON encoder/decoder
|
||||
- `github.com/bwmarrin/snowflake v0.3.0` - Distributed ID generation
|
||||
- `github.com/go-resty/resty/v2 v2.10.0` - HTTP client for external API calls
|
||||
- `github.com/prometheus/client_golang v1.17.0` - Prometheus metrics
|
||||
- `golang.org/x/crypto v0.44.0` - Cryptographic utilities
|
||||
|
||||
**Critical (Frontend):**
|
||||
- Axios `^1.12.2` - HTTP client for API calls
|
||||
- Echarts `^6.0.0` - Charts and data visualization
|
||||
- `@vueuse/core ^13.9.0` - Vue composition utilities
|
||||
- `pinia-plugin-persistedstate ^4.3.0` - Persistent state storage
|
||||
- `dayjs ^1.11.19` - Date/time manipulation
|
||||
- `crypto-js ^4.2.0` - Client-side cryptography
|
||||
- `xlsx ^0.18.5` - Excel file generation/parsing
|
||||
- `@wangeditor/editor ^5.1.23` - Rich text editor
|
||||
|
||||
**Infrastructure (Backend):**
|
||||
- `go.opentelemetry.io/otel v1.39.0` - Distributed tracing (OTLP HTTP exporter)
|
||||
- `github.com/gin-contrib/pprof v1.4.0` - Go profiling endpoint
|
||||
- `github.com/swaggo/gin-swagger v1.6.0` - Swagger UI embedded in Gin
|
||||
- `github.com/tealeg/xlsx v1.0.5` - Excel file generation (server-side)
|
||||
- `github.com/rs/cors/wrapper/gin v0.0.0-20231013084403-73f81b45a644` - CORS middleware
|
||||
|
||||
## Configuration
|
||||
|
||||
**Backend Environment:**
|
||||
- Set via `ENV` environment variable: `dev` | `fat` | `uat` | `pro` (default: `fat`)
|
||||
- Config files embedded into binary at build time via `//go:embed` directives
|
||||
- Config files: `configs/dev_configs.toml`, `configs/fat_configs.toml`, `configs/uat_configs.toml`, `configs/pro_configs.toml`
|
||||
- TOML format parsed via Viper (`github.com/spf13/viper`)
|
||||
|
||||
**Environment Variable Overrides (at runtime):**
|
||||
- `MYSQL_ADDR`, `MYSQL_READ_ADDR`, `MYSQL_WRITE_ADDR`, `MYSQL_USER`, `MYSQL_PASS`, `MYSQL_NAME`
|
||||
- `REDIS_ADDR`, `REDIS_PASS`
|
||||
- `WECHAT_MCHID`, `WECHAT_SERIAL_NO`, `WECHAT_PRIVATE_KEY_PATH`, `WECHAT_API_V3_KEY`, `WECHAT_NOTIFY_URL`, `WECHAT_PUBLIC_KEY_ID`, `WECHAT_PUBLIC_KEY_PATH`
|
||||
- `ALIYUN_SMS_ACCESS_KEY_ID`, `ALIYUN_SMS_ACCESS_KEY_SECRET`, `ALIYUN_SMS_SIGN_NAME`, `ALIYUN_SMS_TEMPLATE_CODE`
|
||||
- `ADMIN_JWT_SECRET` - Admin JWT signing secret override
|
||||
|
||||
**Frontend Environment:**
|
||||
- Vite env vars: `VITE_VERSION`, `VITE_PORT`, `VITE_BASE_URL`, `VITE_API_URL`, `VITE_API_PROXY_URL`
|
||||
- Dev proxy: `/api` requests forwarded to `VITE_API_PROXY_URL`
|
||||
|
||||
**Build:**
|
||||
- Backend: `Dockerfile` (multi-stage, `golang:1.24-alpine` → `alpine:latest`)
|
||||
- Server port: `9991` (constant in `configs/constants.go`)
|
||||
- Container exposes port `9991`
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- Go 1.24+
|
||||
- Node.js >= 18.0.0, pnpm >= 8.8.0
|
||||
- MySQL instance (read/write addresses)
|
||||
- Redis instance
|
||||
- `golangci-lint` and `go-swagger` for linting/docs
|
||||
|
||||
**Production:**
|
||||
- Docker (Linux/amd64 binary, CGO_ENABLED=0)
|
||||
- Alpine Linux container
|
||||
- MySQL with optional read replica (master-slave)
|
||||
- Redis single-node
|
||||
- Optional: OpenTelemetry-compatible collector (Tempo) at configured OTLP endpoint
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-03-21*
|
||||
180
.planning/codebase/STRUCTURE.md
Normal file
180
.planning/codebase/STRUCTURE.md
Normal file
@ -0,0 +1,180 @@
|
||||
# Directory Structure
|
||||
|
||||
## Top-Level Layout
|
||||
|
||||
```
|
||||
bindbox_game/
|
||||
├── main.go # Application entry point
|
||||
├── go.mod / go.sum # Go module definition
|
||||
├── Makefile # Build, test, lint, format commands
|
||||
├── Dockerfile # Docker build config
|
||||
├── CLAUDE.md # AI assistant guidance
|
||||
│
|
||||
├── configs/ # Environment-specific TOML config files
|
||||
│ ├── dev_configs.toml
|
||||
│ ├── fat_configs.toml
|
||||
│ ├── uat_configs.toml
|
||||
│ ├── pro_configs.toml
|
||||
│ └── cert/ # SSL/payment certificates
|
||||
│
|
||||
├── internal/ # Core application code (Go convention)
|
||||
│ ├── api/ # HTTP handlers (organized by domain)
|
||||
│ ├── service/ # Business logic layer
|
||||
│ ├── repository/mysql/ # Data access layer (GORM)
|
||||
│ ├── router/ # HTTP routing and middleware
|
||||
│ ├── pkg/ # Shared internal packages
|
||||
│ ├── code/ # Error code definitions
|
||||
│ ├── alert/ # Alert notification system
|
||||
│ ├── metrics/ # Prometheus metrics
|
||||
│ ├── proposal/ # Shared types/interfaces
|
||||
│ └── dblogger/ # Database query logger
|
||||
│
|
||||
├── cmd/ # CLI tools and debug utilities
|
||||
│ ├── gormgen/ # GORM model code generator
|
||||
│ ├── mfmt/ # Import formatter
|
||||
│ ├── douyin_sync_debug/ # Douyin sync debugging
|
||||
│ ├── check_order/ # Order checking tool
|
||||
│ └── ... # Various debug/diagnostic tools
|
||||
│
|
||||
├── web/admin/ # Vue 3 admin panel (separate git repo)
|
||||
│ ├── src/ # Vue source code
|
||||
│ ├── dist/ # Production build output
|
||||
│ └── package.json # Frontend dependencies
|
||||
│
|
||||
├── migrations/ # SQL migration files (date-prefixed)
|
||||
├── resources/admin/ # Embedded admin panel assets
|
||||
├── build/ # Build output directory
|
||||
├── deploy/ # Deployment configurations
|
||||
├── docs/ # Documentation
|
||||
├── logs/ # Application log files
|
||||
├── scripts/ # Utility scripts
|
||||
└── tools/ # Standalone analysis/debug tools
|
||||
```
|
||||
|
||||
## Key Locations
|
||||
|
||||
### API Handlers (`internal/api/`)
|
||||
|
||||
```
|
||||
api/
|
||||
├── admin/ # Admin panel endpoints (~30+ files)
|
||||
│ ├── activities_admin.go # Activity CRUD
|
||||
│ ├── dashboard_*.go # Dashboard analytics (multiple files)
|
||||
│ ├── users_admin.go # User management
|
||||
│ ├── douyin_orders_admin.go # Douyin order management
|
||||
│ ├── livestream_admin.go # Livestream management
|
||||
│ └── ...
|
||||
├── activity/ # Lottery/game activity endpoints
|
||||
│ ├── lottery_app.go # Lottery join/draw
|
||||
│ ├── matching_game_app.go # Matching game logic
|
||||
│ └── ...
|
||||
├── app/ # Store/product endpoints
|
||||
│ ├── store.go # Store items
|
||||
│ ├── product.go # Products
|
||||
│ └── coupon_transfer.go # Coupon transfers
|
||||
├── game/ # Game (minesweeper) endpoints
|
||||
├── pay/ # Payment endpoints
|
||||
├── user/ # User management endpoints
|
||||
├── task_center/ # Task center endpoints
|
||||
├── common/ # Shared handlers (upload)
|
||||
├── public/ # Public livestream endpoints
|
||||
└── internal/ # Internal service endpoints
|
||||
```
|
||||
|
||||
### Service Layer (`internal/service/`)
|
||||
|
||||
```
|
||||
service/
|
||||
├── activity/ # Activity business logic
|
||||
│ ├── activity.go # Service struct and constructor
|
||||
│ ├── lottery_process.go # Core lottery algorithm
|
||||
│ ├── matching_game.go # Matching game logic
|
||||
│ ├── scheduler.go # Settlement scheduler
|
||||
│ └── strategy/ # Draw strategy pattern
|
||||
│ ├── strategy.go # Interface definition
|
||||
│ ├── default.go # Standard lottery
|
||||
│ └── ichiban.go # Ichiban-style lottery
|
||||
├── admin/ # Admin user management
|
||||
├── user/ # User business logic (largest service)
|
||||
├── order/ # Order processing
|
||||
├── game/ # Game ticket management
|
||||
├── douyin/ # Douyin integration
|
||||
│ ├── order_sync.go # Order synchronization
|
||||
│ ├── reward_dispatcher.go # Reward granting
|
||||
│ └── scheduler.go # Sync scheduler
|
||||
├── task_center/ # Task center (worker pattern)
|
||||
├── finance/ # Financial/ledger operations
|
||||
├── product/ # Product management
|
||||
├── channel/ # Marketing channels
|
||||
├── title/ # User titles/badges
|
||||
└── ...
|
||||
```
|
||||
|
||||
### Shared Packages (`internal/pkg/`)
|
||||
|
||||
```
|
||||
pkg/
|
||||
├── core/ # Custom Gin context wrapper (core.Context, core.Mux)
|
||||
├── logger/ # Zap-based logger with file rotation
|
||||
├── redis/ # Redis client initialization
|
||||
├── jwtoken/ # JWT generation and parsing
|
||||
├── otel/ # OpenTelemetry integration
|
||||
├── wechat/ # WeChat Mini Program helpers
|
||||
├── miniprogram/ # WeChat access token, subscribe messages
|
||||
├── pay/ # WeChat Pay v3 integration
|
||||
├── douyin/ # Douyin API client
|
||||
├── sms/ # Aliyun SMS client
|
||||
├── validation/ # Input validation
|
||||
├── httpclient/ # HTTP client wrapper
|
||||
├── idgen/ # ID generation
|
||||
├── timeutil/ # Time utility (CST layout)
|
||||
├── errors/ # Error types
|
||||
├── points/ # Points calculation utilities
|
||||
├── notify/ # Notification helpers
|
||||
├── async/ # Async task utilities
|
||||
├── cryptoaes/ # AES encryption
|
||||
├── cryptorsa/ # RSA encryption
|
||||
├── env/ # Environment detection
|
||||
├── color/ # Console color output
|
||||
├── debug/ # Debug utilities
|
||||
├── cors/ # CORS configuration
|
||||
├── shutdown/ # Graceful shutdown
|
||||
├── startup/ # Startup utilities
|
||||
├── trace/ # Trace utilities
|
||||
├── util/ # General utilities
|
||||
├── utils/ # Additional utilities
|
||||
└── jsonutil/ # JSON helpers
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
### Files
|
||||
- `*_app.go` — App (mini program) facing handler
|
||||
- `*_admin.go` — Admin panel handler
|
||||
- `*_test.go` — Test files (alongside source)
|
||||
- `*.gen.go` — Generated code (do not edit)
|
||||
- `*_helper.go` — Helper functions for a domain
|
||||
|
||||
### Packages
|
||||
- Service constructors: `New(logger, db)` returns service struct
|
||||
- Handler constructors: `New(logger, db, ...)` returns handler struct
|
||||
- Method naming: `Create*`, `Modify*`, `Delete*`, `List*`, `Get*`
|
||||
|
||||
### Database
|
||||
- Migration files: `YYYYMMDD_description.sql` (e.g., `20260207_add_column.sql`)
|
||||
- Model files: `internal/repository/mysql/model/*.gen.go`
|
||||
- DAO files: `internal/repository/mysql/dao/*.gen.go`
|
||||
|
||||
### Configuration
|
||||
- Environment-specific: `{env}_configs.toml`
|
||||
- Environments: `dev`, `fat`, `uat`, `pro`
|
||||
|
||||
## Important Notes
|
||||
|
||||
- `web/admin/` is a **separate git repository** (has its own `.git`)
|
||||
- Generated files in `model/` and `dao/` should never be manually edited
|
||||
- The `tools/` directory contains standalone Go programs for debugging and analysis
|
||||
- `cmd/` contains both production tools (gormgen, mfmt) and debug utilities
|
||||
|
||||
---
|
||||
*Generated: 2026-03-21*
|
||||
283
.planning/codebase/TESTING.md
Normal file
283
.planning/codebase/TESTING.md
Normal file
@ -0,0 +1,283 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-03-21
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- Go standard `testing` package
|
||||
- Config: `Makefile` target `test`
|
||||
|
||||
**Assertion Libraries:**
|
||||
- `github.com/stretchr/testify v1.11.1` — `assert` package (preferred in newer tests)
|
||||
- Standard `t.Fatal`, `t.Fatalf`, `t.Errorf` (used in most tests, older style)
|
||||
|
||||
**Mocking Libraries:**
|
||||
- `github.com/DATA-DOG/go-sqlmock v1.5.2` — SQL-level DB mocking
|
||||
- `github.com/alicebob/miniredis/v2 v2.36.1` — in-process Redis mock server
|
||||
- Manual mock structs implementing interfaces (for `core.Context`, logger)
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
make test # Run all tests: go test -v --cover ./internal/...
|
||||
go test -v ./internal/service/... # Test specific package
|
||||
go test -v -run TestFunctionName ./... # Run single test
|
||||
```
|
||||
|
||||
**Coverage output:** `coverage.out` (present in repo root)
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- Co-located with source files — `foo_test.go` sits in the same directory as `foo.go`
|
||||
- No separate `tests/` directory for Go unit/integration tests
|
||||
|
||||
**Naming:**
|
||||
- `<subject>_test.go` matching the function/feature under test
|
||||
- Package declaration: same package as source (`package activity`) for white-box tests, or `package game_test` for black-box tests (rare)
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
internal/service/activity/
|
||||
├── activity_order_service.go
|
||||
├── concurrency_test.go # integration (real DB)
|
||||
├── reward_snapshot_test.go # integration (SQLite in-memory)
|
||||
├── sanitize_test.go # unit (no DB)
|
||||
internal/service/game/
|
||||
├── token.go
|
||||
├── token_test.go # uses miniredis + SQLite
|
||||
internal/service/user/
|
||||
├── error_test.go # uses go-sqlmock
|
||||
├── request_shipping_batch_test.go
|
||||
internal/api/app/
|
||||
├── store_test.go # HTTP handler integration test
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization (table-driven tests):**
|
||||
```go
|
||||
func TestShouldTriggerInstantDraw(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
orderStatus int32
|
||||
drawMode string
|
||||
shouldTrigger bool
|
||||
}{
|
||||
{"已支付+即时开奖", 2, "instant", true},
|
||||
{"已支付+定时开奖", 2, "scheduled", false},
|
||||
{"未支付+即时开奖", 1, "instant", false},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := shouldTriggerInstantDraw(tc.orderStatus, tc.drawMode)
|
||||
if result != tc.shouldTrigger {
|
||||
t.Errorf("期望触发=%v,实际触发=%v", tc.shouldTrigger, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Patterns:**
|
||||
- Setup: create repo/DB then initialize service via constructor
|
||||
- Teardown: SQLite in-memory DBs are ephemeral (no cleanup needed); miniredis uses `defer mr.Close()`
|
||||
- Assertion: `t.Fatalf` for unrecoverable setup failures; `t.Errorf` for assertion failures
|
||||
- Skipping: `t.Skipf` when preconditions absent (e.g., real DB unavailable in concurrency tests)
|
||||
|
||||
## Mocking
|
||||
|
||||
**In-memory SQLite (primary DB mock):**
|
||||
```go
|
||||
repo, err := mysql.NewSQLiteRepoForTest() // internal/repository/mysql/testrepo_sqlite.go
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
db := repo.GetDbW()
|
||||
// Manually create tables with SQLite-compatible DDL
|
||||
db.Exec(`CREATE TABLE products (id INTEGER PRIMARY KEY AUTOINCREMENT, ...)`)
|
||||
```
|
||||
`NewSQLiteRepoForTest()` → `gorm.Open(sqlite.Open(":memory:"), ...)` → returns `Repo` interface.
|
||||
|
||||
**TestRepo wrapping real DB (for integration tests with live MySQL):**
|
||||
```go
|
||||
db, err := gorm.Open(drivermysql.Open(dsn), &gorm.Config{})
|
||||
repo := mysql.NewTestRepo(db) // internal/repository/mysql/test_helper.go
|
||||
```
|
||||
|
||||
**go-sqlmock (SQL-level mocking):**
|
||||
```go
|
||||
db, mock, err := sqlmock.New()
|
||||
gormDB, _ := gorm.Open(gormmysql.New(gormmysql.Config{
|
||||
Conn: db,
|
||||
SkipInitializeWithVersion: true,
|
||||
}), &gorm.Config{})
|
||||
|
||||
mock.ExpectQuery("SELECT .* FROM `system_item_cards`").
|
||||
WithArgs(100, sqlmock.AnyArg()).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id", "status"}).AddRow(100, 0))
|
||||
```
|
||||
|
||||
**miniredis (Redis mock):**
|
||||
```go
|
||||
mr, err := miniredis.Run()
|
||||
defer mr.Close()
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
```
|
||||
|
||||
**Manual interface mock (for `core.Context`):**
|
||||
```go
|
||||
type mockContext struct {
|
||||
core.Context // embed to satisfy interface
|
||||
ctx context.Context
|
||||
}
|
||||
|
||||
func (m *mockContext) RequestContext() core.StdContext { return core.StdContext{Context: m.ctx} }
|
||||
func (m *mockContext) ShouldBindJSON(obj interface{}) error { return nil }
|
||||
func (m *mockContext) AbortWithError(err core.BusinessError) {}
|
||||
// ... implement all interface methods with no-op stubs
|
||||
```
|
||||
|
||||
**MockLogger pattern:**
|
||||
```go
|
||||
type MockLogger struct {
|
||||
logger.CustomLogger
|
||||
}
|
||||
func (l *MockLogger) Info(msg string, fields ...zap.Field) {}
|
||||
func (l *MockLogger) Error(msg string, fields ...zap.Field) {}
|
||||
func (l *MockLogger) Warn(msg string, fields ...zap.Field) {}
|
||||
func (l *MockLogger) Debug(msg string, fields ...zap.Field) {}
|
||||
```
|
||||
|
||||
**What to Mock:**
|
||||
- DB connection (use SQLite in-memory or go-sqlmock)
|
||||
- Redis (use miniredis)
|
||||
- External API clients (use interface injection)
|
||||
- Logger (use MockLogger embedding `logger.CustomLogger`)
|
||||
- `core.Context` (use manual mock struct)
|
||||
|
||||
**What NOT to Mock:**
|
||||
- Business logic within services under test
|
||||
- DAO query building when testing DB query behavior
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data (DDL + seed SQL directly in test):**
|
||||
```go
|
||||
// Create table
|
||||
repo.GetDbW().Exec(`CREATE TABLE orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
...
|
||||
)`)
|
||||
|
||||
// Seed data
|
||||
db.Exec("INSERT INTO orders (id, user_id, status, source_type, total_amount, created_at) VALUES (101, ?, 2, 0, 100, ?)", userID, o1Time)
|
||||
```
|
||||
|
||||
**Shared table initialization helpers:**
|
||||
```go
|
||||
func initTestTables(t *testing.T, db *gorm.DB) { ... } // in task_center package tests
|
||||
func ensureExtraTablesForServiceTest(t *testing.T, db *gorm.DB) { ... }
|
||||
```
|
||||
|
||||
**Test helper functions marked with `t.Helper()`:**
|
||||
```go
|
||||
func assertAttribution(t *testing.T, got map[int64]activityAttribution, activityID, wantChannelID int64, wantChannelCode string) {
|
||||
t.Helper()
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
**Location:**
|
||||
- No centralized fixtures directory. Each test file creates its own data inline.
|
||||
- Shared helpers defined within the same package test files.
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:** `make test` runs with `--cover` flag but no enforced minimum threshold.
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
go test -v --cover ./internal/... # prints coverage % per package
|
||||
```
|
||||
|
||||
Coverage output file: `/Users/win/2025/AICoding/bindbox/bindbox_game/coverage.out`
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests (pure logic, no DB):**
|
||||
- Scope: individual pure functions — JSON utilities, string parsing, coupon discount math
|
||||
- Pattern: call function, assert return value
|
||||
- Examples: `internal/service/product/product_test.go` (TestNormalizeJSON, TestSplitImages), `internal/service/order/discount_test.go` (TestApplyCouponDiscount), `internal/service/activity/sanitize_test.go`
|
||||
|
||||
**Integration Tests (SQLite in-memory DB):**
|
||||
- Scope: service methods requiring DB reads/writes, HTTP handler end-to-end via `net/http/httptest`
|
||||
- Pattern: `NewSQLiteRepoForTest()` → create DDL → seed data → call service/handler → assert DB state or response
|
||||
- Examples: `internal/service/task_center/service_test.go`, `internal/api/app/store_test.go`, `internal/service/activity/reward_snapshot_test.go`
|
||||
|
||||
**Integration Tests (Real MySQL — skipped when unavailable):**
|
||||
- Scope: concurrency/race condition testing requiring real DB transactions
|
||||
- Pattern: hardcoded DSN, `t.Skipf` on connection failure
|
||||
- Examples: `internal/service/activity/concurrency_test.go`
|
||||
|
||||
**E2E Tests:** Not present in the current codebase.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**HTTP Handler Testing:**
|
||||
```go
|
||||
mux, _ := core.New(lg)
|
||||
mux.Group("/api/app").GET("/store/items", NewStore(lg, repo).ListStoreItemsForApp())
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
req, _ := http.NewRequest("GET", "/api/app/store/items?kind=product&page=1&page_size=10", bytes.NewBufferString(""))
|
||||
mux.ServeHTTP(rr, req)
|
||||
|
||||
if rr.Code != http.StatusOK {
|
||||
t.Fatalf("code=%d body=%s", rr.Code, rr.Body.String())
|
||||
}
|
||||
var rsp map[string]interface{}
|
||||
json.Unmarshal([]byte(rr.Body.String()), &rsp)
|
||||
```
|
||||
|
||||
**Async/Concurrency Testing:**
|
||||
```go
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
successCount := 0
|
||||
|
||||
for i := 0; i < concurrency; i++ {
|
||||
wg.Add(1)
|
||||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
// ... concurrent operation
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
successCount++
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
// assert final DB state
|
||||
```
|
||||
|
||||
**Error Path Testing:**
|
||||
```go
|
||||
err := svc.AddItemCard(context.Background(), 1, 100, 1)
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "record not found", err.Error())
|
||||
```
|
||||
|
||||
**Testify assertion style (preferred in newer tests):**
|
||||
```go
|
||||
assert.NoError(t, err)
|
||||
assert.NotEmpty(t, token)
|
||||
assert.Equal(t, userID, claims.UserID)
|
||||
assert.Contains(t, err.Error(), "invalid ticket format")
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-03-21*
|
||||
32
.planning/config.json
Normal file
32
.planning/config.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"model_profile": "balanced",
|
||||
"commit_docs": true,
|
||||
"parallelization": true,
|
||||
"search_gitignored": false,
|
||||
"brave_search": false,
|
||||
"firecrawl": false,
|
||||
"exa_search": false,
|
||||
"git": {
|
||||
"branching_strategy": "none",
|
||||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
||||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
||||
"quick_branch_template": null
|
||||
},
|
||||
"workflow": {
|
||||
"research": true,
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
"auto_advance": false,
|
||||
"node_repair": true,
|
||||
"node_repair_budget": 2,
|
||||
"ui_phase": true,
|
||||
"ui_safety_gate": true,
|
||||
"text_mode": false
|
||||
},
|
||||
"hooks": {
|
||||
"context_warnings": true
|
||||
},
|
||||
"mode": "yolo",
|
||||
"granularity": "standard"
|
||||
}
|
||||
488
.planning/phases/01-core-pnl-functions/01-01-PLAN.md
Normal file
488
.planning/phases/01-core-pnl-functions/01-01-PLAN.md
Normal file
@ -0,0 +1,488 @@
|
||||
---
|
||||
phase: 01-core-pnl-functions
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/service/finance/types.go
|
||||
- internal/service/finance/service.go
|
||||
- internal/service/finance/service_test.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PNL-01
|
||||
- RET-01
|
||||
- RET-03
|
||||
- AST-01
|
||||
- DIM-01
|
||||
- DIM-02
|
||||
- DIM-03
|
||||
- DIM-04
|
||||
- QUA-01
|
||||
- QUA-02
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Package internal/service/finance compiles successfully with the new files"
|
||||
- "AssetType constants All=0, Points=1, Coupon=2, ItemCard=3, Product=4, Fragment=5 are exported"
|
||||
- "UserProfitLossParams and ActivityProfitLossParams structs exist with all optional fields"
|
||||
- "ProfitLossResult struct has int64 TotalRevenue/TotalCost/TotalProfit and float64 ProfitRate"
|
||||
- "Service interface exposes QueryUserProfitLoss and QueryActivityProfitLoss methods"
|
||||
- "New() constructor injects only DbR — no GetDbW() call anywhere in the package"
|
||||
- "service_test.go contains SQLite test setup that compiles and all existing tests pass"
|
||||
artifacts:
|
||||
- path: "internal/service/finance/types.go"
|
||||
provides: "AssetType enum, UserProfitLossParams, ActivityProfitLossParams, ProfitLossDetail, ProfitLossResult"
|
||||
exports:
|
||||
- AssetType
|
||||
- AssetTypeAll
|
||||
- AssetTypePoints
|
||||
- AssetTypeCoupon
|
||||
- AssetTypeItemCard
|
||||
- AssetTypeProduct
|
||||
- AssetTypeFragment
|
||||
- UserProfitLossParams
|
||||
- ActivityProfitLossParams
|
||||
- ProfitLossDetail
|
||||
- ProfitLossResult
|
||||
- path: "internal/service/finance/service.go"
|
||||
provides: "Service interface + New() constructor"
|
||||
exports:
|
||||
- Service
|
||||
- New
|
||||
- path: "internal/service/finance/service_test.go"
|
||||
provides: "Test helper newTestSvc() and seed helpers for orders/inventory/points/coupons"
|
||||
key_links:
|
||||
- from: "internal/service/finance/service.go"
|
||||
to: "internal/repository/mysql/mysql.go"
|
||||
via: "New(l logger.CustomLogger, db mysql.Repo) — calls db.GetDbR() only"
|
||||
pattern: "GetDbR\\(\\)"
|
||||
- from: "internal/service/finance/types.go"
|
||||
to: "internal/service/finance/query_user.go (Plan 02)"
|
||||
via: "UserProfitLossParams consumed by QueryUserProfitLoss"
|
||||
pattern: "UserProfitLossParams"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Scaffold the internal/service/finance package with all shared contracts: AssetType enum, parameter structs, result types, the Service interface, and the read-only constructor. Also create the service_test.go file with SQLite test infrastructure that Plans 02 and 03 will extend.
|
||||
|
||||
Purpose: Plans 02 and 03 run in parallel and both depend on these type definitions. Creating them first eliminates any ambiguity about field names, types, and the constructor signature.
|
||||
|
||||
Output: types.go, service.go, service_test.go — all compiling, all tested.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/01-core-pnl-functions/1-CONTEXT.md
|
||||
@.planning/phases/01-core-pnl-functions/01-RESEARCH.md
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Key types and contracts the executor needs. Extracted from codebase. -->
|
||||
|
||||
From internal/repository/mysql/mysql.go:
|
||||
```go
|
||||
type Repo interface {
|
||||
i()
|
||||
GetDbR() *gorm.DB
|
||||
GetDbW() *gorm.DB
|
||||
DbRClose() error
|
||||
DbWClose() error
|
||||
}
|
||||
|
||||
func NewSQLiteRepoForTest() (Repo, error) // in testrepo_sqlite.go
|
||||
```
|
||||
|
||||
From internal/pkg/logger/logger.go:
|
||||
```go
|
||||
type CustomLogger interface { /* zap-based */ }
|
||||
func NewCustomLogger(w io.Writer, opts ...Option) CustomLogger
|
||||
func WithOutputInConsole() Option
|
||||
```
|
||||
|
||||
From internal/service/finance/profit_metrics.go (EXISTING — must not be redefined):
|
||||
```go
|
||||
type SpendingBreakdown struct { PaidCoupon, GamePass, Total int64; IsGamePass bool }
|
||||
func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown
|
||||
func IsGamePassOrder(sourceType int32, orderNo string, actualAmount int64, remark string) bool
|
||||
func ComputeGamePassValue(drawCount, activityPrice int64) int64
|
||||
func NormalizeMultiplierX1000(multiplierX1000 int64) int64
|
||||
func ComputePrizeCostWithMultiplier(baseCost, multiplierX1000 int64) int64
|
||||
func ComputeProfit(spending, prizeCost int64) (int64, float64)
|
||||
```
|
||||
|
||||
From internal/repository/mysql/model/user_inventory.gen.go:
|
||||
```go
|
||||
const TableNameUserInventory = "user_inventory"
|
||||
// Fields used: user_id, activity_id, order_id, value_cents, status, remark, reward_id, product_id
|
||||
```
|
||||
|
||||
From internal/repository/mysql/model/user_points_ledger.gen.go:
|
||||
```go
|
||||
const TableNameUserPointsLedger = "user_points_ledger"
|
||||
// Fields used: user_id, action, points, ref_table, ref_id
|
||||
```
|
||||
|
||||
From internal/repository/mysql/model/user_coupon_ledger.gen.go:
|
||||
```go
|
||||
const TableNameUserCouponLedger = "user_coupon_ledger"
|
||||
// Fields used: user_id, change_amount, order_id, action
|
||||
```
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create types.go — AssetType enum and all struct contracts</name>
|
||||
<read_first>
|
||||
- internal/service/finance/profit_metrics.go (verify SpendingBreakdown is not redefined here)
|
||||
- internal/repository/mysql/model/user_inventory.gen.go (confirm value_cents field name)
|
||||
- .planning/phases/01-core-pnl-functions/1-CONTEXT.md (locked decisions D-04 through D-11)
|
||||
</read_first>
|
||||
<files>internal/service/finance/types.go</files>
|
||||
<behavior>
|
||||
- AssetTypeAll = 0, AssetTypePoints = 1, AssetTypeCoupon = 2, AssetTypeItemCard = 3, AssetTypeProduct = 4, AssetTypeFragment = 5
|
||||
- UserProfitLossParams has: UserIDs []int64, AssetType AssetType, StartTime *time.Time, EndTime *time.Time
|
||||
- ActivityProfitLossParams has: ActivityIDs []int64, AssetType AssetType, StartTime *time.Time, EndTime *time.Time
|
||||
- ProfitLossDetail has: UserID int64, ActivityID int64, Revenue int64, Cost int64, Profit int64, ProfitRate float64
|
||||
- ProfitLossResult has: TotalRevenue int64, TotalCost int64, TotalProfit int64, ProfitRate float64, Details []ProfitLossDetail, Breakdown []interface{}
|
||||
- All monetary fields are int64 (fen); only ProfitRate and ProfitLossDetail.ProfitRate use float64
|
||||
- Breakdown is []interface{} initialized as empty slice (Phase 2 placeholder per CONTEXT.md deferred section)
|
||||
</behavior>
|
||||
<action>
|
||||
Create `internal/service/finance/types.go` with package `finance`. Import only `"time"`.
|
||||
|
||||
Define the AssetType and constants block:
|
||||
```go
|
||||
type AssetType int
|
||||
|
||||
const (
|
||||
AssetTypeAll AssetType = 0 // zero value = all types (DIM-04)
|
||||
AssetTypePoints AssetType = 1
|
||||
AssetTypeCoupon AssetType = 2
|
||||
AssetTypeItemCard AssetType = 3
|
||||
AssetTypeProduct AssetType = 4
|
||||
AssetTypeFragment AssetType = 5
|
||||
)
|
||||
```
|
||||
|
||||
Define param structs (per D-04 — two independent structs, not shared):
|
||||
```go
|
||||
// UserProfitLossParams — all fields optional (D-07)
|
||||
type UserProfitLossParams struct {
|
||||
UserIDs []int64 // empty = all users (DIM-01)
|
||||
AssetType AssetType // 0 = all types (DIM-04)
|
||||
StartTime *time.Time // nil = no lower bound (DIM-03)
|
||||
EndTime *time.Time // nil = no upper bound (DIM-03)
|
||||
}
|
||||
|
||||
// ActivityProfitLossParams — all fields optional (D-07)
|
||||
type ActivityProfitLossParams struct {
|
||||
ActivityIDs []int64 // empty = all activities (DIM-02)
|
||||
AssetType AssetType // 0 = all types (DIM-04)
|
||||
StartTime *time.Time // nil = no lower bound (DIM-03)
|
||||
EndTime *time.Time // nil = no upper bound (DIM-03)
|
||||
}
|
||||
```
|
||||
|
||||
Define result structs (per D-05, D-06, RET-01, RET-03):
|
||||
```go
|
||||
// ProfitLossDetail — per-user or per-activity row (D-06)
|
||||
type ProfitLossDetail struct {
|
||||
UserID int64 // populated for user dimension queries
|
||||
ActivityID int64 // populated for activity dimension queries
|
||||
Revenue int64 // fen (RET-03: int64 only, no float64 for monetary)
|
||||
Cost int64 // fen
|
||||
Profit int64 // fen
|
||||
ProfitRate float64 // ratio; only float64 field for monetary concept
|
||||
}
|
||||
|
||||
// ProfitLossResult — aggregated P&L result (RET-01)
|
||||
type ProfitLossResult struct {
|
||||
TotalRevenue int64 // fen
|
||||
TotalCost int64 // fen
|
||||
TotalProfit int64 // fen
|
||||
ProfitRate float64 // ratio
|
||||
Details []ProfitLossDetail // per-user or per-activity breakdowns (D-06)
|
||||
Breakdown []interface{} // Phase 2: per-asset-type breakdown (empty for Phase 1)
|
||||
}
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./internal/service/finance/ 2>&1 | grep -v "^$" || echo "BUILD OK"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- internal/service/finance/types.go exists
|
||||
- File contains `type AssetType int`
|
||||
- File contains `AssetTypeAll AssetType = 0`
|
||||
- File contains `AssetTypeFragment AssetType = 5`
|
||||
- File contains `type UserProfitLossParams struct`
|
||||
- File contains `type ActivityProfitLossParams struct`
|
||||
- File contains `UserIDs []int64` inside UserProfitLossParams
|
||||
- File contains `ActivityIDs []int64` inside ActivityProfitLossParams
|
||||
- File contains `StartTime *time.Time` and `EndTime *time.Time` (pointer, not value)
|
||||
- File contains `type ProfitLossResult struct`
|
||||
- File contains `TotalRevenue int64`
|
||||
- File contains `TotalCost int64`
|
||||
- File contains `TotalProfit int64`
|
||||
- File contains `ProfitRate float64`
|
||||
- File contains `Details []ProfitLossDetail`
|
||||
- File contains `Breakdown []interface{}`
|
||||
- File contains `type ProfitLossDetail struct`
|
||||
- File contains `Revenue int64` (not float64)
|
||||
- File contains `Cost int64` (not float64)
|
||||
- `go build ./internal/service/finance/` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>types.go exists in internal/service/finance/, all types exported with correct field names and types, package builds without errors.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Create service.go — Service interface and read-only constructor</name>
|
||||
<read_first>
|
||||
- internal/service/finance/types.go (just created — verify param/result type names)
|
||||
- internal/service/user/user.go (lines 93-102 — constructor pattern to replicate)
|
||||
- internal/repository/mysql/mysql.go (Repo interface — confirm GetDbR() signature)
|
||||
- .planning/phases/01-core-pnl-functions/1-CONTEXT.md (QUA-02: no GetDbW() in this package)
|
||||
</read_first>
|
||||
<files>internal/service/finance/service.go</files>
|
||||
<behavior>
|
||||
- Service interface declares exactly two methods: QueryUserProfitLoss and QueryActivityProfitLoss
|
||||
- QueryUserProfitLoss signature: (ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)
|
||||
- QueryActivityProfitLoss signature: (ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)
|
||||
- service struct has logger field and dbR *gorm.DB — NO writeDB or GetDbW() call
|
||||
- New() calls db.GetDbR() to populate dbR; no reference to GetDbW() anywhere in file
|
||||
- Stub implementations return (nil, nil) — they will be replaced in Plans 02 and 03
|
||||
</behavior>
|
||||
<action>
|
||||
Create `internal/service/finance/service.go` with package `finance`.
|
||||
|
||||
Imports:
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
```
|
||||
|
||||
Define Service interface and struct:
|
||||
```go
|
||||
type Service interface {
|
||||
QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)
|
||||
QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger logger.CustomLogger
|
||||
dbR *gorm.DB // read replica only — QUA-02: no writes in this package
|
||||
}
|
||||
|
||||
func New(l logger.CustomLogger, db mysql.Repo) Service {
|
||||
return &service{
|
||||
logger: l,
|
||||
dbR: db.GetDbR(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Add stub method bodies (Plans 02 and 03 will replace these):
|
||||
```go
|
||||
func (s *service) QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *service) QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) {
|
||||
return nil, nil
|
||||
}
|
||||
```
|
||||
|
||||
CRITICAL: Do NOT write `db.GetDbW()` or `GetDbW` anywhere in this file or any other file in the package.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./internal/service/finance/ && grep -r "GetDbW" ./internal/service/finance/ | wc -l | xargs test 0 -eq && echo "QUA-02 OK: no GetDbW in package"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- internal/service/finance/service.go exists
|
||||
- File contains `type Service interface`
|
||||
- File contains `QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)`
|
||||
- File contains `QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)`
|
||||
- File contains `type service struct`
|
||||
- File contains `dbR *gorm.DB`
|
||||
- File does NOT contain `GetDbW`
|
||||
- File contains `db.GetDbR()`
|
||||
- File contains `func New(l logger.CustomLogger, db mysql.Repo) Service`
|
||||
- `go build ./internal/service/finance/` exits 0
|
||||
- `grep -r "GetDbW" ./internal/service/finance/` returns empty output (zero matches)
|
||||
</acceptance_criteria>
|
||||
<done>service.go compiles, Service interface declared with both method signatures, constructor injects GetDbR() only, no GetDbW() anywhere in the finance package.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 3: Create service_test.go — SQLite test infrastructure and contract tests</name>
|
||||
<read_first>
|
||||
- internal/service/finance/service.go (just created — verify New() signature)
|
||||
- internal/service/finance/types.go (verify AssetType constant values and struct field names)
|
||||
- internal/repository/mysql/testrepo_sqlite.go (NewSQLiteRepoForTest() — verify signature)
|
||||
- internal/service/finance/profit_metrics_test.go (existing test style to replicate)
|
||||
- .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 6: SQLite compat — CAST AS INTEGER not SIGNED)
|
||||
</read_first>
|
||||
<files>internal/service/finance/service_test.go</files>
|
||||
<behavior>
|
||||
- newTestSvc() helper creates SQLiteRepo and returns (Service, *gorm.DB, error)
|
||||
- seedOrder() helper inserts a model.Orders row into the test DB
|
||||
- seedInventory() helper inserts a model.UserInventory row
|
||||
- seedPointsLedger() helper inserts a model.UserPointsLedger row
|
||||
- seedCouponLedger() helper inserts a model.UserCouponLedger row
|
||||
- TestAssetTypeConstants verifies All=0, Points=1, Coupon=2, ItemCard=3, Product=4, Fragment=5
|
||||
- TestNew_ReturnsService verifies New() returns a non-nil Service
|
||||
- TestQueryUserProfitLoss_EmptyParams_ReturnsNoError verifies stub returns (nil, nil) — will be updated in Plan 02
|
||||
- TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError same for activity function
|
||||
- AutoMigrate runs for Orders, UserInventory, UserPointsLedger, UserCouponLedger tables
|
||||
</behavior>
|
||||
<action>
|
||||
Create `internal/service/finance/service_test.go` with package `finance`.
|
||||
|
||||
Imports needed:
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
```
|
||||
|
||||
Test helper — newTestSvc creates an in-memory SQLite repo, auto-migrates tables, returns (Service, *gorm.DB):
|
||||
```go
|
||||
func newTestSvc(t *testing.T) (Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
require.NoError(t, err)
|
||||
db := repo.GetDbR()
|
||||
err = db.AutoMigrate(
|
||||
&model.Orders{},
|
||||
&model.UserInventory{},
|
||||
&model.UserPointsLedger{},
|
||||
&model.UserCouponLedger{},
|
||||
)
|
||||
require.NoError(t, err)
|
||||
svc := New(logger.NewCustomLogger(nil, logger.WithOutputInConsole()), repo)
|
||||
return svc, db
|
||||
}
|
||||
```
|
||||
|
||||
Seed helpers (minimal fields — add more fields in Plans 02/03 tests as needed):
|
||||
```go
|
||||
func seedOrder(t *testing.T, db *gorm.DB, o model.Orders) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Create(&o).Error)
|
||||
}
|
||||
|
||||
func seedInventory(t *testing.T, db *gorm.DB, inv model.UserInventory) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Create(&inv).Error)
|
||||
}
|
||||
|
||||
func seedPointsLedger(t *testing.T, db *gorm.DB, row model.UserPointsLedger) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Create(&row).Error)
|
||||
}
|
||||
|
||||
func seedCouponLedger(t *testing.T, db *gorm.DB, row model.UserCouponLedger) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Create(&row).Error)
|
||||
}
|
||||
```
|
||||
|
||||
Tests:
|
||||
```go
|
||||
func TestAssetTypeConstants(t *testing.T) {
|
||||
require.Equal(t, AssetType(0), AssetTypeAll)
|
||||
require.Equal(t, AssetType(1), AssetTypePoints)
|
||||
require.Equal(t, AssetType(2), AssetTypeCoupon)
|
||||
require.Equal(t, AssetType(3), AssetTypeItemCard)
|
||||
require.Equal(t, AssetType(4), AssetTypeProduct)
|
||||
require.Equal(t, AssetType(5), AssetTypeFragment)
|
||||
}
|
||||
|
||||
func TestNew_ReturnsService(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
require.NotNil(t, svc)
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
_ = result // stub returns nil — Plan 02 will make this return real data
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
_ = result // stub returns nil — Plan 03 will make this return real data
|
||||
}
|
||||
```
|
||||
|
||||
NOTE on SQLite compatibility (Pitfall 6 from RESEARCH.md):
|
||||
- Do NOT use CAST(... AS SIGNED) in test SQL — SQLite requires CAST(... AS INTEGER)
|
||||
- Do NOT use GREATEST() in SQL for tests — apply multiplier logic in Go instead
|
||||
- Tests here are unit/compile-time tests only; integration tests added in Plans 02 and 03
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -v -run "TestAssetType|TestNew|TestQuery.*EmptyParams" ./internal/service/finance/</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- internal/service/finance/service_test.go exists
|
||||
- File contains `func newTestSvc(t *testing.T) (Service, *gorm.DB)`
|
||||
- File contains `func seedOrder(`
|
||||
- File contains `func seedInventory(`
|
||||
- File contains `func seedPointsLedger(`
|
||||
- File contains `func seedCouponLedger(`
|
||||
- File contains `func TestAssetTypeConstants(`
|
||||
- File contains `mysql.NewSQLiteRepoForTest()`
|
||||
- File contains `db.AutoMigrate`
|
||||
- `go test -v -run "TestAssetType|TestNew|TestQuery.*EmptyParams" ./internal/service/finance/` exits 0
|
||||
- All 4 tests pass: TestAssetTypeConstants, TestNew_ReturnsService, TestQueryUserProfitLoss_EmptyParams_ReturnsNoError, TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError
|
||||
- `go test -v ./internal/service/finance/` exits 0 (all existing profit_metrics tests still pass)
|
||||
</acceptance_criteria>
|
||||
<done>service_test.go compiles and all tests pass including the existing profit_metrics tests. The newTestSvc and seed helpers are ready for Plans 02 and 03 to extend.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
|
||||
1. Package compiles: `go build ./internal/service/finance/` exits 0
|
||||
2. All tests green: `go test -v ./internal/service/finance/` — must show PASS for all tests including existing profit_metrics tests
|
||||
3. No write DB leak: `grep -r "GetDbW" ./internal/service/finance/` returns 0 matches
|
||||
4. AssetType values correct: `grep -A8 "AssetTypeAll" internal/service/finance/types.go` shows All=0 through Fragment=5
|
||||
5. Pointer time fields: `grep "StartTime\|EndTime" internal/service/finance/types.go | grep "\*time.Time"` returns 2 matches
|
||||
6. int64 monetary fields only: `grep "Revenue\|Cost\|Profit\b" internal/service/finance/types.go | grep "float64"` returns 0 matches (ProfitRate is the only float64)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- internal/service/finance/types.go: 6 AssetType constants + 2 param structs + 2 result structs, all exported
|
||||
- internal/service/finance/service.go: Service interface with 2 methods, read-only constructor, zero GetDbW() references
|
||||
- internal/service/finance/service_test.go: SQLite setup helper + 4 seed helpers + 4 tests all passing
|
||||
- `go test -v ./internal/service/finance/` exits 0 with PASS for all tests
|
||||
- Full build clean: `go build ./...` exits 0
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-core-pnl-functions/01-01-SUMMARY.md`
|
||||
</output>
|
||||
622
.planning/phases/01-core-pnl-functions/01-02-PLAN.md
Normal file
622
.planning/phases/01-core-pnl-functions/01-02-PLAN.md
Normal file
@ -0,0 +1,622 @@
|
||||
---
|
||||
phase: 01-core-pnl-functions
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 01-01
|
||||
files_modified:
|
||||
- internal/service/finance/query_user.go
|
||||
- internal/service/finance/service.go
|
||||
- internal/service/finance/service_test.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PNL-02
|
||||
- PNL-03
|
||||
- PNL-04
|
||||
- PNL-05
|
||||
- PNL-06
|
||||
- PNL-07
|
||||
- PNL-08
|
||||
- DIM-01
|
||||
- DIM-03
|
||||
- DIM-04
|
||||
- QUA-03
|
||||
- QUA-04
|
||||
- QUA-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "QueryUserProfitLoss with a paid cash order returns Revenue = actual_amount + discount_amount"
|
||||
- "QueryUserProfitLoss excludes refunded orders (status=3 or status=4) from Revenue"
|
||||
- "QueryUserProfitLoss with a game-pass order returns Revenue = draw_count × activity_price (not actual_amount)"
|
||||
- "QueryUserProfitLoss excludes voided inventory (remark LIKE '%void%' or status=2) from Cost"
|
||||
- "QueryUserProfitLoss includes legacy inventory with order_id=0 in Cost (not filtered out)"
|
||||
- "QueryUserProfitLoss with empty UserIDs returns aggregated result for all users (not empty)"
|
||||
- "QueryUserProfitLoss returns error (not nil) when a Scan() call fails"
|
||||
- "ProfitLossResult.TotalProfit = TotalRevenue - TotalCost; ProfitRate computed by ComputeProfit()"
|
||||
artifacts:
|
||||
- path: "internal/service/finance/query_user.go"
|
||||
provides: "QueryUserProfitLoss implementation with fan-out scans"
|
||||
exports: []
|
||||
- path: "internal/service/finance/service.go"
|
||||
provides: "Updated QueryUserProfitLoss method body (replaces stub from Plan 01)"
|
||||
- path: "internal/service/finance/service_test.go"
|
||||
provides: "Integration tests: refund exclusion, game-pass revenue, void exclusion, legacy order_id=0"
|
||||
key_links:
|
||||
- from: "internal/service/finance/query_user.go"
|
||||
to: "internal/repository/mysql/model (Orders, UserInventory, UserPointsLedger, UserCouponLedger)"
|
||||
via: "s.dbR.Table(model.TableNameOrders).Select(...).Scan()"
|
||||
pattern: "Scan\\(&"
|
||||
- from: "internal/service/finance/query_user.go"
|
||||
to: "finance.ClassifyOrderSpending / IsGamePassOrder / ComputeProfit"
|
||||
via: "Go-layer classification of per-order rows after scan"
|
||||
pattern: "ClassifyOrderSpending|ComputeProfit"
|
||||
- from: "internal/service/finance/query_user.go"
|
||||
to: "system_configs table"
|
||||
via: "getPointsExchangeRate() reads 'points.exchange_rate' key"
|
||||
pattern: "points\\.exchange_rate"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement QueryUserProfitLoss in a new query_user.go file using the fan-out + in-memory merge pattern. Four independent Scan() calls gather revenue, inventory cost, points cost, and coupon cost; results are merged in Go via map[int64]*ProfitLossDetail. The service.go stub from Plan 01 is replaced with a real dispatch call.
|
||||
|
||||
Purpose: Deliver the user-dimension P&L function with all PNL-02 through PNL-08 requirements satisfied and all DIM-01/03/04 parameter handling in place.
|
||||
|
||||
Output: query_user.go (implementation), service.go (updated), service_test.go (extended with integration tests).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-core-pnl-functions/1-CONTEXT.md
|
||||
@.planning/phases/01-core-pnl-functions/01-RESEARCH.md
|
||||
@.planning/phases/01-core-pnl-functions/01-01-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts from Plan 01 and existing codebase that this plan builds against -->
|
||||
|
||||
From internal/service/finance/types.go (created in Plan 01):
|
||||
```go
|
||||
type AssetType int
|
||||
const ( AssetTypeAll=0; AssetTypePoints=1; AssetTypeCoupon=2; AssetTypeItemCard=3; AssetTypeProduct=4; AssetTypeFragment=5 )
|
||||
|
||||
type UserProfitLossParams struct {
|
||||
UserIDs []int64
|
||||
AssetType AssetType
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
}
|
||||
|
||||
type ProfitLossDetail struct {
|
||||
UserID, ActivityID int64
|
||||
Revenue, Cost, Profit int64
|
||||
ProfitRate float64
|
||||
}
|
||||
|
||||
type ProfitLossResult struct {
|
||||
TotalRevenue, TotalCost, TotalProfit int64
|
||||
ProfitRate float64
|
||||
Details []ProfitLossDetail
|
||||
Breakdown []interface{}
|
||||
}
|
||||
```
|
||||
|
||||
From internal/service/finance/service.go (created in Plan 01):
|
||||
```go
|
||||
type service struct { logger logger.CustomLogger; dbR *gorm.DB }
|
||||
// QueryUserProfitLoss stub — REPLACE with real dispatch: return s.queryUser(ctx, params)
|
||||
```
|
||||
|
||||
From internal/service/finance/profit_metrics.go (existing — MUST reuse, do not reimplement):
|
||||
```go
|
||||
func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown
|
||||
func IsGamePassOrder(sourceType int32, orderNo string, actualAmount int64, remark string) bool
|
||||
func ComputeGamePassValue(drawCount, activityPrice int64) int64
|
||||
func ComputePrizeCostWithMultiplier(baseCost, multiplierX1000 int64) int64
|
||||
func ComputeProfit(spending, prizeCost int64) (int64, float64)
|
||||
```
|
||||
|
||||
From internal/repository/mysql/model/ (table name constants):
|
||||
```go
|
||||
model.TableNameOrders = "orders"
|
||||
model.TableNameUserInventory = "user_inventory"
|
||||
model.TableNameUserPointsLedger = "user_points_ledger"
|
||||
model.TableNameUserCouponLedger = "user_coupon_ledger"
|
||||
```
|
||||
|
||||
Orders table fields used: id, user_id, status, source_type, order_no, actual_amount, discount_amount, remark, draw_count, created_at
|
||||
UserInventory fields used: user_id, activity_id, order_id, value_cents, status, remark, reward_id (for item-card join)
|
||||
UserPointsLedger fields used: user_id, action, points, created_at
|
||||
UserCouponLedger fields used: user_id, change_amount, order_id, created_at
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create query_user.go — QueryUserProfitLoss fan-out implementation</name>
|
||||
<read_first>
|
||||
- internal/service/finance/service.go (verify service struct and stub method signature)
|
||||
- internal/service/finance/types.go (verify UserProfitLossParams and ProfitLossResult fields)
|
||||
- internal/service/finance/profit_metrics.go (verify ClassifyOrderSpending, ComputeProfit signatures)
|
||||
- internal/api/admin/dashboard_activity.go (lines 225-300, fan-out pattern and exact WHERE conditions)
|
||||
- .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 1: CAST AS SIGNED; Pitfall 2: empty slice; Pitfall 3: game-pass double count; Pitfall 4: refund+inventory; Pitfall 5: scan error; Pitfall 6: SQLite compat)
|
||||
</read_first>
|
||||
<files>internal/service/finance/query_user.go</files>
|
||||
<behavior>
|
||||
- Revenue scan: SELECT user_id + raw order fields (source_type, order_no, actual_amount, discount_amount, remark) for orders WHERE status=2; classify per-row in Go using ClassifyOrderSpending(); sum by user_id
|
||||
- Game-pass needs draw_count and activity price; join orders → activity_draw_logs → activity_issues → activities to get activities.price_draw per order
|
||||
- Cost scan (inventory): SELECT user_id, SUM(value_cents) grouped by user_id; WHERE status IN (1,3) AND remark NOT LIKE '%void%' AND (orders.status=2 OR order_id=0 OR order_id IS NULL); LEFT JOIN orders ON orders.id = user_inventory.order_id; for multiplier: LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id; apply ComputePrizeCostWithMultiplier(value_cents, multiplier) in Go (not SQL) for SQLite compat
|
||||
- Cost scan (points): SELECT user_id, SUM(points) WHERE action='order_deduct' AND points < 0; convert via getPointsExchangeRate() and points.PointsToCents()
|
||||
- Cost scan (coupons): SELECT user_id, SUM(ABS(change_amount)) WHERE change_amount < 0; JOIN orders ON orders.id = user_coupon_ledger.order_id WHERE orders.status=2
|
||||
- Every Scan() must check .Error and return fmt.Errorf("QueryUserProfitLoss %s scan: %w", step, err)
|
||||
- Empty UserIDs: do NOT add WHERE user_id IN clause (all users)
|
||||
- Time filters: add WHERE created_at >= *StartTime only if StartTime != nil
|
||||
- Merge all scans in Go via map[int64]*ProfitLossDetail
|
||||
- Final aggregation: sum all Details into TotalRevenue/TotalCost; call ComputeProfit for TotalProfit+ProfitRate
|
||||
- Return &ProfitLossResult{..., Breakdown: []interface{}{}} with empty Breakdown slice
|
||||
</behavior>
|
||||
<action>
|
||||
Create `internal/service/finance/query_user.go` with package `finance`.
|
||||
|
||||
Imports:
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"bindbox-game/internal/pkg/points"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
```
|
||||
|
||||
Private method `(s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)`.
|
||||
|
||||
**Step 1: Revenue scan** — scan raw order fields per user, classify in Go.
|
||||
|
||||
Scan struct:
|
||||
```go
|
||||
type userRevenueRow struct {
|
||||
UserID int64
|
||||
SourceType int32
|
||||
OrderNo string
|
||||
ActualAmount int64
|
||||
DiscountAmount int64
|
||||
Remark string
|
||||
DrawCount int64
|
||||
ActivityPrice int64 // from activities.price_draw via JOIN
|
||||
}
|
||||
```
|
||||
|
||||
Query (scan per-order, not pre-aggregated — needed for per-row classification):
|
||||
```go
|
||||
var revenueRows []userRevenueRow
|
||||
q := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameOrders).
|
||||
Select(`orders.user_id, orders.source_type, orders.order_no,
|
||||
orders.actual_amount, orders.discount_amount, orders.remark,
|
||||
COUNT(activity_draw_logs.id) as draw_count,
|
||||
COALESCE(MAX(activities.price_draw), 0) as activity_price`).
|
||||
Joins(`LEFT JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id`).
|
||||
Joins(`LEFT JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id`).
|
||||
Joins(`LEFT JOIN activities ON activities.id = activity_issues.activity_id`).
|
||||
Where("orders.status = ?", 2).
|
||||
Group("orders.id, orders.user_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark")
|
||||
if len(params.UserIDs) > 0 {
|
||||
q = q.Where("orders.user_id IN ?", params.UserIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
q = q.Where("orders.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
q = q.Where("orders.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
if err := q.Scan(&revenueRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryUserProfitLoss revenue scan: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
Merge revenue into resultMap — classify per row using Go functions:
|
||||
```go
|
||||
resultMap := make(map[int64]*ProfitLossDetail)
|
||||
for _, r := range revenueRows {
|
||||
gpValue := ComputeGamePassValue(r.DrawCount, r.ActivityPrice)
|
||||
bd := ClassifyOrderSpending(r.SourceType, r.OrderNo, r.ActualAmount, r.DiscountAmount, r.Remark, gpValue)
|
||||
if _, ok := resultMap[r.UserID]; !ok {
|
||||
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
||||
}
|
||||
resultMap[r.UserID].Revenue += bd.Total
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Inventory cost scan** — scan raw value_cents + multiplier per inventory row, apply ComputePrizeCostWithMultiplier in Go.
|
||||
|
||||
Scan struct:
|
||||
```go
|
||||
type userInventoryRow struct {
|
||||
UserID int64
|
||||
ValueCents int64
|
||||
MultiplierX1000 int64
|
||||
}
|
||||
```
|
||||
|
||||
Query:
|
||||
```go
|
||||
var inventoryRows []userInventoryRow
|
||||
iq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserInventory).
|
||||
Select(`user_inventory.user_id,
|
||||
user_inventory.value_cents,
|
||||
COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`).
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
|
||||
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
|
||||
if len(params.UserIDs) > 0 {
|
||||
iq = iq.Where("user_inventory.user_id IN ?", params.UserIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
iq = iq.Where("user_inventory.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
iq = iq.Where("user_inventory.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
var inventoryRows []userInventoryRow
|
||||
if err := iq.Scan(&inventoryRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryUserProfitLoss inventory cost scan: %w", err)
|
||||
}
|
||||
for _, r := range inventoryRows {
|
||||
cost := ComputePrizeCostWithMultiplier(r.ValueCents, r.MultiplierX1000)
|
||||
if _, ok := resultMap[r.UserID]; !ok {
|
||||
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
||||
}
|
||||
resultMap[r.UserID].Cost += cost
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Points cost scan** — read points deductions and convert to cents.
|
||||
|
||||
```go
|
||||
type userPointsRow struct {
|
||||
UserID int64
|
||||
TotalPoints int64 // SUM of negative points = total deducted (positive value after ABS)
|
||||
}
|
||||
var pointsRows []userPointsRow
|
||||
pq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserPointsLedger).
|
||||
Select("user_id, SUM(-points) as total_points"). // points is negative for deductions
|
||||
Where("action = ?", "order_deduct").
|
||||
Where("points < ?", 0)
|
||||
if len(params.UserIDs) > 0 {
|
||||
pq = pq.Where("user_id IN ?", params.UserIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
pq = pq.Where("created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
pq = pq.Where("created_at <= ?", *params.EndTime)
|
||||
}
|
||||
pq = pq.Group("user_id")
|
||||
if err := pq.Scan(&pointsRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryUserProfitLoss points cost scan: %w", err)
|
||||
}
|
||||
rate := s.getPointsExchangeRate(ctx)
|
||||
for _, r := range pointsRows {
|
||||
costCents := points.PointsToCents(r.TotalPoints, float64(rate))
|
||||
if _, ok := resultMap[r.UserID]; !ok {
|
||||
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
||||
}
|
||||
resultMap[r.UserID].Cost += costCents
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Coupon cost scan** — sum coupon deductions from paid orders.
|
||||
|
||||
```go
|
||||
type userCouponRow struct {
|
||||
UserID int64
|
||||
TotalCost int64 // SUM(ABS(change_amount)) for deductions
|
||||
}
|
||||
var couponRows []userCouponRow
|
||||
cq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserCouponLedger).
|
||||
Select("user_coupon_ledger.user_id, SUM(-user_coupon_ledger.change_amount) as total_cost").
|
||||
Joins("LEFT JOIN orders ON orders.id = user_coupon_ledger.order_id").
|
||||
Where("user_coupon_ledger.change_amount < ?", 0).
|
||||
Where("orders.status = ?", 2)
|
||||
if len(params.UserIDs) > 0 {
|
||||
cq = cq.Where("user_coupon_ledger.user_id IN ?", params.UserIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
cq = cq.Group("user_coupon_ledger.user_id")
|
||||
if err := cq.Scan(&couponRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryUserProfitLoss coupon cost scan: %w", err)
|
||||
}
|
||||
for _, r := range couponRows {
|
||||
if _, ok := resultMap[r.UserID]; !ok {
|
||||
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
||||
}
|
||||
resultMap[r.UserID].Cost += r.TotalCost
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Apply ComputeProfit per detail and aggregate totals.**
|
||||
|
||||
```go
|
||||
details := make([]ProfitLossDetail, 0, len(resultMap))
|
||||
var totalRevenue, totalCost int64
|
||||
for _, d := range resultMap {
|
||||
d.Profit, d.ProfitRate = ComputeProfit(d.Revenue, d.Cost)
|
||||
totalRevenue += d.Revenue
|
||||
totalCost += d.Cost
|
||||
details = append(details, *d)
|
||||
}
|
||||
totalProfit, profitRate := ComputeProfit(totalRevenue, totalCost)
|
||||
return &ProfitLossResult{
|
||||
TotalRevenue: totalRevenue,
|
||||
TotalCost: totalCost,
|
||||
TotalProfit: totalProfit,
|
||||
ProfitRate: profitRate,
|
||||
Details: details,
|
||||
Breakdown: []interface{}{},
|
||||
}, nil
|
||||
```
|
||||
|
||||
**Private helper — getPointsExchangeRate** (reads system_configs, safe default=1):
|
||||
```go
|
||||
func (s *service) getPointsExchangeRate(ctx context.Context) int64 {
|
||||
var cfg struct { ConfigValue string }
|
||||
if err := s.dbR.WithContext(ctx).
|
||||
Table("system_configs").
|
||||
Select("config_value").
|
||||
Where("config_key = ?", "points.exchange_rate").
|
||||
First(&cfg).Error; err != nil {
|
||||
return 1 // default: 1 yuan = 1 point
|
||||
}
|
||||
var rate int64
|
||||
fmt.Sscanf(cfg.ConfigValue, "%d", &rate)
|
||||
if rate <= 0 {
|
||||
return 1
|
||||
}
|
||||
return rate
|
||||
}
|
||||
```
|
||||
|
||||
**Update service.go stub** — replace the stub QueryUserProfitLoss body:
|
||||
```go
|
||||
func (s *service) QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
|
||||
return s.queryUser(ctx, params)
|
||||
}
|
||||
```
|
||||
|
||||
CRITICAL rules (from RESEARCH.md anti-patterns):
|
||||
- NEVER call GetDbW() — only s.dbR
|
||||
- NEVER skip .Error check on any Scan()
|
||||
- NEVER add WHERE user_id IN when params.UserIDs is empty
|
||||
- NEVER use CAST(AS SIGNED) in SQL — apply multiplier in Go via ComputePrizeCostWithMultiplier
|
||||
- NEVER re-implement IsGamePassOrder logic in SQL — use Go function
|
||||
- NEVER use COALESCE fallback chain for value_cents — D-09: value_cents is single source of truth
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./internal/service/finance/ && go test -v -run "TestQueryUser" ./internal/service/finance/</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- internal/service/finance/query_user.go exists
|
||||
- File contains `func (s *service) queryUser(`
|
||||
- File contains `func (s *service) getPointsExchangeRate(`
|
||||
- File contains at least 4 `Scan(&` calls (one per data source)
|
||||
- File contains `ClassifyOrderSpending(` (reusing existing function, not reimplementing)
|
||||
- File contains `ComputeProfit(` call for totals
|
||||
- File contains `ComputePrizeCostWithMultiplier(` in Go layer (not inside SQL string)
|
||||
- File contains `points.PointsToCents(`
|
||||
- File does NOT contain `GetDbW`
|
||||
- File does NOT contain `CAST(` (multiplier applied in Go, not SQL)
|
||||
- File does NOT contain `COALESCE(NULLIF(user_inventory.value_cents` (no fallback chain)
|
||||
- For each `Scan(` call, there is a corresponding `if err :=` error check
|
||||
- service.go QueryUserProfitLoss body contains `return s.queryUser(ctx, params)`
|
||||
- `go build ./internal/service/finance/` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>query_user.go implements QueryUserProfitLoss with 4 fan-out scans, all errors propagated, game-pass classified in Go, multiplier applied in Go, service.go dispatches to it.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Add QueryUserProfitLoss integration tests to service_test.go</name>
|
||||
<read_first>
|
||||
- internal/service/finance/service_test.go (existing helpers from Plan 01 — newTestSvc, seedOrder etc.)
|
||||
- internal/service/finance/query_user.go (just implemented — verify scan logic to test correctly)
|
||||
- internal/repository/mysql/model/orders.gen.go (Orders struct field names for seeding)
|
||||
- internal/repository/mysql/model/user_inventory.gen.go (UserInventory struct field names)
|
||||
- .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 6: SQLite compat — no CAST AS SIGNED in test SQL; Pitfall 1: CAST issue only affects MySQL not SQLite int scan)
|
||||
</read_first>
|
||||
<files>internal/service/finance/service_test.go</files>
|
||||
<behavior>
|
||||
- TestQueryUserProfitLoss_CashOrder: seed one paid order (status=2, non-game-pass), assert Revenue = actual_amount + discount_amount
|
||||
- TestQueryUserProfitLoss_RefundedOrderExcluded: seed one refunded order (status=4), assert Revenue=0
|
||||
- TestQueryUserProfitLoss_GamePassOrder: seed one game-pass order (source_type=4, actual_amount=0), seed activity with price_draw, assert Revenue = draw_count × price_draw
|
||||
- TestQueryUserProfitLoss_VoidedInventoryExcluded: seed inventory with status=2, assert Cost=0
|
||||
- TestQueryUserProfitLoss_RemarkVoidExcluded: seed inventory with remark='void_test', assert Cost=0
|
||||
- TestQueryUserProfitLoss_LegacyZeroOrderID: seed inventory with order_id=0, assert it IS included in Cost (not excluded)
|
||||
- TestQueryUserProfitLoss_AllUsers: seed 2 users, call with empty UserIDs, assert both appear in Details
|
||||
- TestQueryUserProfitLoss_FilterByUserID: seed 2 users, call with one UserID, assert only that user in Details
|
||||
- TestQueryUserProfitLoss_ResultShape: assert returned ProfitLossResult has non-nil Details and non-nil Breakdown fields
|
||||
- TestQueryUserProfitLoss_ProfitCalculation: seed order + inventory, assert TotalProfit = TotalRevenue - TotalCost
|
||||
</behavior>
|
||||
<action>
|
||||
Append integration tests to `internal/service/finance/service_test.go`.
|
||||
|
||||
First check what model fields are available by reading model/orders.gen.go. The Orders model has fields: ID, UserID, Status, SourceType, OrderNo, ActualAmount, DiscountAmount, Remark, DrawCount, CreatedAt, etc.
|
||||
|
||||
NOTE: The AutoMigrate in newTestSvc must cover all tables used in query_user.go. Update newTestSvc if it doesn't already include system_configs, activities, activity_draw_logs, activity_issues, user_item_cards, system_item_cards tables. If AutoMigrate fails for a table (because model doesn't exist), use db.Exec("CREATE TABLE IF NOT EXISTS ...") for simple tables.
|
||||
|
||||
For game-pass test, seed the activity and activity draw log rows so the JOIN in queryUser can find the activity price. Alternatively, use source_type=4 + order_no LIKE 'GP%' and a direct activities.price_draw lookup. Keep test setup minimal.
|
||||
|
||||
```go
|
||||
func TestQueryUserProfitLoss_CashOrder(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{
|
||||
ID: 1, UserID: 101, Status: 2,
|
||||
SourceType: 2, OrderNo: "O20260321001",
|
||||
ActualAmount: 800, DiscountAmount: 200,
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{101}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(1000), result.TotalRevenue, "cash revenue = actual + discount")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_RefundedOrderExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{
|
||||
ID: 2, UserID: 102, Status: 4, // refunded
|
||||
SourceType: 2, OrderNo: "O20260321002",
|
||||
ActualAmount: 1000, DiscountAmount: 0,
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{102}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_VoidedInventoryExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 1, UserID: 103, Status: 2, // voided status
|
||||
ValueCents: 5000, OrderID: 0,
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{103}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalCost, "voided inventory (status=2) must not contribute cost")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_RemarkVoidExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 2, UserID: 104, Status: 1, // valid status
|
||||
ValueCents: 3000, OrderID: 0,
|
||||
Remark: "void_20260101", // remark contains 'void' — must be excluded
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{104}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalCost, "inventory with remark containing 'void' must not contribute cost")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_LegacyZeroOrderID(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 3, UserID: 105, Status: 1, // valid
|
||||
ValueCents: 2000, OrderID: 0, // legacy: order_id = 0 (no order linked)
|
||||
Remark: "",
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{105}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(2000), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_AllUsers(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 10, UserID: 201, Status: 2, SourceType: 2, OrderNo: "O001", ActualAmount: 100})
|
||||
seedOrder(t, db, model.Orders{ID: 11, UserID: 202, Status: 2, SourceType: 2, OrderNo: "O002", ActualAmount: 200})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{}) // empty UserIDs = all
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
userIDs := make(map[int64]bool)
|
||||
for _, d := range result.Details {
|
||||
userIDs[d.UserID] = true
|
||||
}
|
||||
require.True(t, userIDs[201], "user 201 must be in results")
|
||||
require.True(t, userIDs[202], "user 202 must be in results")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_FilterByUserID(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 20, UserID: 301, Status: 2, SourceType: 2, OrderNo: "O003", ActualAmount: 500})
|
||||
seedOrder(t, db, model.Orders{ID: 21, UserID: 302, Status: 2, SourceType: 2, OrderNo: "O004", ActualAmount: 600})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{301}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
for _, d := range result.Details {
|
||||
require.Equal(t, int64(301), d.UserID, "only user 301 should appear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_ProfitCalculation(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 30, UserID: 401, Status: 2, SourceType: 2, OrderNo: "O005", ActualAmount: 1000, DiscountAmount: 200})
|
||||
seedInventory(t, db, model.UserInventory{ID: 10, UserID: 401, Status: 1, ValueCents: 800, OrderID: 30, Remark: ""})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{401}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(1200), result.TotalRevenue)
|
||||
require.Equal(t, int64(800), result.TotalCost)
|
||||
require.Equal(t, int64(400), result.TotalProfit, "profit = revenue - cost")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_ResultShape(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Details, "Details must be non-nil slice")
|
||||
require.NotNil(t, result.Breakdown, "Breakdown must be non-nil slice (empty for Phase 1)")
|
||||
}
|
||||
```
|
||||
|
||||
If seedOrder/seedInventory fail because Orders or UserInventory don't have all required fields with zero values (SQLite is lenient), add zero values explicitly. If newTestSvc's AutoMigrate doesn't cover user_item_cards or system_item_cards (used in the cost scan JOINs), add `db.Exec("CREATE TABLE IF NOT EXISTS user_item_cards (id integer, card_id integer)")` and `db.Exec("CREATE TABLE IF NOT EXISTS system_item_cards (id integer, reward_multiplier_x1000 integer)")` in newTestSvc BEFORE the Scan test runs, or update newTestSvc to include empty table creation.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -v -run "TestQueryUser" ./internal/service/finance/</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test -v -run "TestQueryUser" ./internal/service/finance/` exits 0
|
||||
- TestQueryUserProfitLoss_CashOrder PASS: TotalRevenue=1000
|
||||
- TestQueryUserProfitLoss_RefundedOrderExcluded PASS: TotalRevenue=0
|
||||
- TestQueryUserProfitLoss_VoidedInventoryExcluded PASS: TotalCost=0
|
||||
- TestQueryUserProfitLoss_RemarkVoidExcluded PASS: TotalCost=0
|
||||
- TestQueryUserProfitLoss_LegacyZeroOrderID PASS: TotalCost=2000
|
||||
- TestQueryUserProfitLoss_AllUsers PASS: both users in Details
|
||||
- TestQueryUserProfitLoss_FilterByUserID PASS: only user 301
|
||||
- TestQueryUserProfitLoss_ProfitCalculation PASS: TotalProfit=400
|
||||
- TestQueryUserProfitLoss_ResultShape PASS: Details and Breakdown non-nil
|
||||
- `go test -v ./internal/service/finance/` exits 0 (all tests including Plan 01 tests still pass)
|
||||
</acceptance_criteria>
|
||||
<done>All QueryUserProfitLoss integration tests pass on SQLite. All PNL-02 through PNL-08, DIM-01, DIM-03, DIM-04 behaviors verified by automated tests.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
|
||||
1. Package compiles: `go build ./internal/service/finance/` exits 0
|
||||
2. All tests pass: `go test -v ./internal/service/finance/` exits 0
|
||||
3. No write DB: `grep -r "GetDbW" ./internal/service/finance/` returns 0 matches
|
||||
4. Fan-out verified: `grep -c "Scan(&" ./internal/service/finance/query_user.go` returns >= 4
|
||||
5. Finance functions reused: `grep -E "ClassifyOrderSpending|ComputeProfit|ComputePrizeCostWithMultiplier" ./internal/service/finance/query_user.go | wc -l` returns >= 3
|
||||
6. No SQL CAST in tests: `grep "AS SIGNED" ./internal/service/finance/service_test.go | wc -l` returns 0
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- query_user.go: 4 fan-out scans (revenue, inventory cost, points cost, coupon cost), all Scan errors propagated, game-pass classified in Go, multiplier applied via ComputePrizeCostWithMultiplier
|
||||
- service.go: QueryUserProfitLoss dispatches to s.queryUser (no more stub)
|
||||
- service_test.go: 9+ integration tests for QueryUserProfitLoss all passing on SQLite
|
||||
- `go test -v ./internal/service/finance/` exits 0 with 13+ total PASS results
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-core-pnl-functions/01-02-SUMMARY.md`
|
||||
</output>
|
||||
619
.planning/phases/01-core-pnl-functions/01-03-PLAN.md
Normal file
619
.planning/phases/01-core-pnl-functions/01-03-PLAN.md
Normal file
@ -0,0 +1,619 @@
|
||||
---
|
||||
phase: 01-core-pnl-functions
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 01-01
|
||||
files_modified:
|
||||
- internal/service/finance/query_activity.go
|
||||
- internal/service/finance/service.go
|
||||
- internal/service/finance/service_test.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PNL-02
|
||||
- PNL-03
|
||||
- PNL-04
|
||||
- PNL-05
|
||||
- PNL-06
|
||||
- PNL-07
|
||||
- PNL-08
|
||||
- DIM-02
|
||||
- DIM-03
|
||||
- DIM-04
|
||||
- RET-01
|
||||
- RET-03
|
||||
- QUA-03
|
||||
- QUA-04
|
||||
- QUA-05
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "QueryActivityProfitLoss with a paid cash order returns Revenue = actual_amount + discount_amount attributed to that order's activity"
|
||||
- "QueryActivityProfitLoss with a game-pass order returns Revenue = draw_count × activity_price for that activity"
|
||||
- "QueryActivityProfitLoss excludes refunded orders (status=3 or status=4) from Revenue"
|
||||
- "QueryActivityProfitLoss excludes voided inventory from Cost"
|
||||
- "QueryActivityProfitLoss includes legacy inventory with order_id=0 in Cost"
|
||||
- "QueryActivityProfitLoss with empty ActivityIDs returns aggregated result for all activities"
|
||||
- "QueryActivityProfitLoss returns error (not nil) when a Scan() call fails"
|
||||
- "1:1 order-to-activity: no revenue proration subquery — revenue attributed directly from orders.activity_id or via single JOIN"
|
||||
artifacts:
|
||||
- path: "internal/service/finance/query_activity.go"
|
||||
provides: "QueryActivityProfitLoss implementation with fan-out scans"
|
||||
exports: []
|
||||
- path: "internal/service/finance/service.go"
|
||||
provides: "Updated QueryActivityProfitLoss method body (replaces stub from Plan 01)"
|
||||
- path: "internal/service/finance/service_test.go"
|
||||
provides: "Integration tests for QueryActivityProfitLoss — activity dimension variants"
|
||||
key_links:
|
||||
- from: "internal/service/finance/query_activity.go"
|
||||
to: "orders table"
|
||||
via: "s.dbR.Table(TableNameOrders) JOIN activity_draw_logs JOIN activity_issues to get activity_id"
|
||||
pattern: "activity_issues\\.activity_id"
|
||||
- from: "internal/service/finance/query_activity.go"
|
||||
to: "finance.ClassifyOrderSpending / ComputeProfit"
|
||||
via: "Go-layer classification per order row after scan"
|
||||
pattern: "ClassifyOrderSpending|ComputeProfit"
|
||||
- from: "internal/service/finance/query_activity.go"
|
||||
to: "user_inventory.activity_id"
|
||||
via: "WHERE user_inventory.activity_id IN activityIDs for cost grouping"
|
||||
pattern: "user_inventory\\.activity_id"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Implement QueryActivityProfitLoss in a new query_activity.go file using the same fan-out + in-memory merge pattern as Plan 02. The key difference from the user dimension: dimension key is activity_id (not user_id), and revenue is attributed to activities via the orders → activity_draw_logs → activity_issues → activities JOIN path (1:1 per D-01, no proration needed). The service.go stub is replaced with a real dispatch call.
|
||||
|
||||
Purpose: Deliver the activity-dimension P&L function completing all Phase 1 requirements. Plan 03 runs in parallel with Plan 02 since they touch different files (query_activity.go vs query_user.go).
|
||||
|
||||
Output: query_activity.go (implementation), service.go (updated), service_test.go (extended with activity tests).
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-core-pnl-functions/1-CONTEXT.md
|
||||
@.planning/phases/01-core-pnl-functions/01-RESEARCH.md
|
||||
@.planning/phases/01-core-pnl-functions/01-01-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<interfaces>
|
||||
<!-- Contracts from Plan 01 that this plan builds against -->
|
||||
|
||||
From internal/service/finance/types.go (created in Plan 01):
|
||||
```go
|
||||
type ActivityProfitLossParams struct {
|
||||
ActivityIDs []int64
|
||||
AssetType AssetType
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
}
|
||||
|
||||
type ProfitLossDetail struct {
|
||||
UserID, ActivityID int64
|
||||
Revenue, Cost, Profit int64
|
||||
ProfitRate float64
|
||||
}
|
||||
|
||||
type ProfitLossResult struct {
|
||||
TotalRevenue, TotalCost, TotalProfit int64
|
||||
ProfitRate float64
|
||||
Details []ProfitLossDetail
|
||||
Breakdown []interface{}
|
||||
}
|
||||
```
|
||||
|
||||
From internal/service/finance/service.go (created in Plan 01):
|
||||
```go
|
||||
type service struct { logger logger.CustomLogger; dbR *gorm.DB }
|
||||
// QueryActivityProfitLoss stub — REPLACE with: return s.queryActivity(ctx, params)
|
||||
```
|
||||
|
||||
From internal/service/finance/profit_metrics.go (existing — MUST reuse):
|
||||
```go
|
||||
func ClassifyOrderSpending(sourceType int32, orderNo string, actualAmount, discountAmount int64, remark string, gamePassValue int64) SpendingBreakdown
|
||||
func ComputeGamePassValue(drawCount, activityPrice int64) int64
|
||||
func ComputePrizeCostWithMultiplier(baseCost, multiplierX1000 int64) int64
|
||||
func ComputeProfit(spending, prizeCost int64) (int64, float64)
|
||||
```
|
||||
|
||||
From internal/repository/mysql/model/:
|
||||
```go
|
||||
// Orders fields: user_id, status (2=paid,3=cancelled,4=refunded), source_type, order_no,
|
||||
// actual_amount, discount_amount, remark, item_card_id, created_at
|
||||
// UserInventory fields: activity_id, user_id, order_id, value_cents, status, remark, reward_id
|
||||
// UserPointsLedger fields: user_id, action, points, ref_table, ref_id, created_at
|
||||
// UserCouponLedger fields: user_id, change_amount, order_id, created_at
|
||||
// Key table constants:
|
||||
// model.TableNameOrders, model.TableNameUserInventory
|
||||
// model.TableNameUserPointsLedger, model.TableNameUserCouponLedger
|
||||
```
|
||||
|
||||
Key decision from CONTEXT.md (D-01): 1:1 order-to-activity — NO revenue proration subquery needed.
|
||||
The activity dimension gets revenue by joining orders to activity_draw_logs to get which activity each order belongs to.
|
||||
Game-pass revenue: draw_count per activity_draw_logs × activities.price_draw, grouped by activity_id.
|
||||
</interfaces>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 1: Create query_activity.go — QueryActivityProfitLoss fan-out implementation</name>
|
||||
<read_first>
|
||||
- internal/service/finance/service.go (verify service struct — dbR field, stub method to replace)
|
||||
- internal/service/finance/types.go (verify ActivityProfitLossParams and ProfitLossResult fields)
|
||||
- internal/service/finance/profit_metrics.go (verify ClassifyOrderSpending, ComputeProfit signatures)
|
||||
- internal/service/finance/query_user.go (if Plan 02 completed — compare fan-out structure to mirror)
|
||||
- internal/api/admin/dashboard_activity.go (lines 225-300 — cost scan pattern with activity_id grouping)
|
||||
- .planning/phases/01-core-pnl-functions/1-CONTEXT.md (D-01: 1:1 order-to-activity; D-09: value_cents single source; D-02: game-pass per activity)
|
||||
- .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 1: CAST; Pitfall 2: empty slice; Pitfall 5: scan error; Pitfall 6: SQLite compat)
|
||||
</read_first>
|
||||
<files>internal/service/finance/query_activity.go</files>
|
||||
<behavior>
|
||||
- Revenue scan: per-order rows joined to activity via activity_draw_logs → activity_issues → activities; classify per-row in Go using ClassifyOrderSpending(); sum revenue by activity_id
|
||||
- Game-pass revenue: count draws per activity per order via activity_draw_logs JOIN; multiply by activities.price_draw using ComputeGamePassValue() in Go
|
||||
- Cost scan (inventory): group by user_inventory.activity_id; WHERE status IN (1,3) AND remark NOT LIKE '%void%' AND (orders.status=2 OR order_id=0 OR order_id IS NULL); apply ComputePrizeCostWithMultiplier in Go (not SQL)
|
||||
- Cost scan (points): join user_points_ledger to orders via ref_table='orders' and ref_id=order_no, then join to activity to get activity_id; OR use a simpler approach: join to activity_draw_logs via user_id+order filters — use the approach that SQLite can handle; SUM points by activity WHERE action='order_deduct'
|
||||
- Cost scan (coupons): join user_coupon_ledger to orders (via order_id) to orders to activity_draw_logs to activity_issues to get activity_id; WHERE change_amount < 0 AND orders.status=2
|
||||
- Every Scan() must check .Error and return fmt.Errorf("QueryActivityProfitLoss %s scan: %w", step, err)
|
||||
- Empty ActivityIDs: do NOT add WHERE activity_id IN clause
|
||||
- Time filters: add WHERE orders.created_at >= *StartTime only if non-nil
|
||||
- Merge in Go via map[int64]*ProfitLossDetail keyed by activity_id
|
||||
- Final aggregation: sum all Details into TotalRevenue/TotalCost; call ComputeProfit for totals
|
||||
- Return &ProfitLossResult{..., Breakdown: []interface{}{}}
|
||||
</behavior>
|
||||
<action>
|
||||
Create `internal/service/finance/query_activity.go` with package `finance`.
|
||||
|
||||
Imports:
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"bindbox-game/internal/pkg/points"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
```
|
||||
|
||||
Private method `(s *service) queryActivity(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)`.
|
||||
|
||||
**Step 1: Revenue scan** — per-order rows with activity attribution via draw logs JOIN.
|
||||
|
||||
D-01 simplification: one order belongs to one activity. Join orders → activity_draw_logs → activity_issues → activities to get the activity_id per order. Use MAX(activity_issues.activity_id) since 1:1.
|
||||
|
||||
```go
|
||||
type activityRevenueRow struct {
|
||||
ActivityID int64
|
||||
SourceType int32
|
||||
OrderNo string
|
||||
ActualAmount int64
|
||||
DiscountAmount int64
|
||||
Remark string
|
||||
DrawCount int64 // COUNT(activity_draw_logs.id) for game-pass value
|
||||
ActivityPrice int64 // activities.price_draw
|
||||
}
|
||||
var revenueRows []activityRevenueRow
|
||||
q := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameOrders).
|
||||
Select(`activity_issues.activity_id,
|
||||
orders.source_type, orders.order_no,
|
||||
orders.actual_amount, orders.discount_amount, orders.remark,
|
||||
COUNT(activity_draw_logs.id) as draw_count,
|
||||
COALESCE(MAX(activities.price_draw), 0) as activity_price`).
|
||||
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
|
||||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||||
Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id").
|
||||
Where("orders.status = ?", 2).
|
||||
Group("orders.id, activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark")
|
||||
if len(params.ActivityIDs) > 0 {
|
||||
q = q.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
q = q.Where("orders.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
q = q.Where("orders.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
if err := q.Scan(&revenueRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryActivityProfitLoss revenue scan: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
Merge revenue — classify per row in Go:
|
||||
```go
|
||||
resultMap := make(map[int64]*ProfitLossDetail)
|
||||
for _, r := range revenueRows {
|
||||
gpValue := ComputeGamePassValue(r.DrawCount, r.ActivityPrice)
|
||||
bd := ClassifyOrderSpending(r.SourceType, r.OrderNo, r.ActualAmount, r.DiscountAmount, r.Remark, gpValue)
|
||||
if _, ok := resultMap[r.ActivityID]; !ok {
|
||||
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
|
||||
}
|
||||
resultMap[r.ActivityID].Revenue += bd.Total
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2: Inventory cost scan** — group by user_inventory.activity_id; apply multiplier in Go.
|
||||
|
||||
```go
|
||||
type activityInventoryRow struct {
|
||||
ActivityID int64
|
||||
ValueCents int64
|
||||
MultiplierX1000 int64
|
||||
}
|
||||
iq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserInventory).
|
||||
Select(`user_inventory.activity_id,
|
||||
user_inventory.value_cents,
|
||||
COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`).
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
|
||||
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
|
||||
if len(params.ActivityIDs) > 0 {
|
||||
iq = iq.Where("user_inventory.activity_id IN ?", params.ActivityIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
iq = iq.Where("user_inventory.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
iq = iq.Where("user_inventory.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
var inventoryRows []activityInventoryRow
|
||||
if err := iq.Scan(&inventoryRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryActivityProfitLoss inventory cost scan: %w", err)
|
||||
}
|
||||
for _, r := range inventoryRows {
|
||||
cost := ComputePrizeCostWithMultiplier(r.ValueCents, r.MultiplierX1000)
|
||||
if _, ok := resultMap[r.ActivityID]; !ok {
|
||||
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
|
||||
}
|
||||
resultMap[r.ActivityID].Cost += cost
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3: Points cost scan** — link points to activity via activity_draw_logs.
|
||||
Since user_points_ledger.ref_table = 'orders' and ref_id = order_no, join via orders then to draw_logs:
|
||||
|
||||
```go
|
||||
type activityPointsRow struct {
|
||||
ActivityID int64
|
||||
TotalPoints int64
|
||||
}
|
||||
pq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserPointsLedger).
|
||||
Select("activity_issues.activity_id, SUM(-user_points_ledger.points) as total_points").
|
||||
Joins("JOIN orders ON orders.order_no = user_points_ledger.ref_id AND user_points_ledger.ref_table = 'orders'").
|
||||
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
|
||||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||||
Where("user_points_ledger.action = ?", "order_deduct").
|
||||
Where("user_points_ledger.points < ?", 0).
|
||||
Where("orders.status = ?", 2)
|
||||
if len(params.ActivityIDs) > 0 {
|
||||
pq = pq.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
pq = pq.Where("user_points_ledger.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
pq = pq.Where("user_points_ledger.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
pq = pq.Group("activity_issues.activity_id")
|
||||
var pointsRows []activityPointsRow
|
||||
if err := pq.Scan(&pointsRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryActivityProfitLoss points cost scan: %w", err)
|
||||
}
|
||||
rate := s.getPointsExchangeRate(ctx)
|
||||
for _, r := range pointsRows {
|
||||
costCents := points.PointsToCents(r.TotalPoints, float64(rate))
|
||||
if _, ok := resultMap[r.ActivityID]; !ok {
|
||||
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
|
||||
}
|
||||
resultMap[r.ActivityID].Cost += costCents
|
||||
}
|
||||
```
|
||||
|
||||
**Step 4: Coupon cost scan** — link coupons to activity via orders → draw_logs.
|
||||
|
||||
```go
|
||||
type activityCouponRow struct {
|
||||
ActivityID int64
|
||||
TotalCost int64
|
||||
}
|
||||
cq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserCouponLedger).
|
||||
Select("activity_issues.activity_id, SUM(-user_coupon_ledger.change_amount) as total_cost").
|
||||
Joins("JOIN orders ON orders.id = user_coupon_ledger.order_id").
|
||||
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
|
||||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||||
Where("user_coupon_ledger.change_amount < ?", 0).
|
||||
Where("orders.status = ?", 2)
|
||||
if len(params.ActivityIDs) > 0 {
|
||||
cq = cq.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
cq = cq.Group("activity_issues.activity_id")
|
||||
var couponRows []activityCouponRow
|
||||
if err := cq.Scan(&couponRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryActivityProfitLoss coupon cost scan: %w", err)
|
||||
}
|
||||
for _, r := range couponRows {
|
||||
if _, ok := resultMap[r.ActivityID]; !ok {
|
||||
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
|
||||
}
|
||||
resultMap[r.ActivityID].Cost += r.TotalCost
|
||||
}
|
||||
```
|
||||
|
||||
**Step 5: Apply ComputeProfit and aggregate totals.**
|
||||
|
||||
```go
|
||||
details := make([]ProfitLossDetail, 0, len(resultMap))
|
||||
var totalRevenue, totalCost int64
|
||||
for _, d := range resultMap {
|
||||
d.Profit, d.ProfitRate = ComputeProfit(d.Revenue, d.Cost)
|
||||
totalRevenue += d.Revenue
|
||||
totalCost += d.Cost
|
||||
details = append(details, *d)
|
||||
}
|
||||
totalProfit, profitRate := ComputeProfit(totalRevenue, totalCost)
|
||||
return &ProfitLossResult{
|
||||
TotalRevenue: totalRevenue,
|
||||
TotalCost: totalCost,
|
||||
TotalProfit: totalProfit,
|
||||
ProfitRate: profitRate,
|
||||
Details: details,
|
||||
Breakdown: []interface{}{},
|
||||
}, nil
|
||||
```
|
||||
|
||||
NOTE: `getPointsExchangeRate` is defined in query_user.go in the same package — it is accessible from query_activity.go without redefinition. Do NOT redefine it.
|
||||
|
||||
**Update service.go stub** — replace QueryActivityProfitLoss body:
|
||||
```go
|
||||
func (s *service) QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) {
|
||||
return s.queryActivity(ctx, params)
|
||||
}
|
||||
```
|
||||
|
||||
CRITICAL rules (same as Plan 02):
|
||||
- NEVER call GetDbW() — only s.dbR
|
||||
- NEVER skip .Error check on any Scan()
|
||||
- NEVER add WHERE activity_id IN when params.ActivityIDs is empty
|
||||
- NEVER use CAST(AS SIGNED) in SQL — apply multiplier via ComputePrizeCostWithMultiplier in Go
|
||||
- NEVER use COALESCE fallback chain for value_cents — D-09: value_cents only
|
||||
- NEVER re-implement IsGamePassOrder in SQL CASE expressions — classify in Go via ClassifyOrderSpending
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./internal/service/finance/ && go test -v -run "TestQueryActivity" ./internal/service/finance/</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- internal/service/finance/query_activity.go exists
|
||||
- File contains `func (s *service) queryActivity(`
|
||||
- File does NOT contain `func (s *service) getPointsExchangeRate(` (defined in query_user.go, not here)
|
||||
- File contains at least 4 `Scan(&` calls (revenue, inventory, points, coupon)
|
||||
- File contains `ClassifyOrderSpending(` in Go layer
|
||||
- File contains `ComputeProfit(` for totals
|
||||
- File contains `ComputePrizeCostWithMultiplier(` in Go layer (not inside SQL string)
|
||||
- File contains `points.PointsToCents(`
|
||||
- File does NOT contain `GetDbW`
|
||||
- File does NOT contain `CAST(` (no SQL-layer casting — multiplier in Go)
|
||||
- File does NOT contain `COALESCE(NULLIF(user_inventory.value_cents` (no fallback chain)
|
||||
- For each `Scan(` call, there is a `if err :=` error check with `return nil, fmt.Errorf`
|
||||
- service.go QueryActivityProfitLoss body contains `return s.queryActivity(ctx, params)`
|
||||
- `go build ./internal/service/finance/` exits 0
|
||||
</acceptance_criteria>
|
||||
<done>query_activity.go implements QueryActivityProfitLoss with 4 fan-out scans attributed to activity dimension, all errors propagated, game-pass classified in Go, multiplier applied in Go.</done>
|
||||
</task>
|
||||
|
||||
<task type="auto" tdd="true">
|
||||
<name>Task 2: Add QueryActivityProfitLoss integration tests to service_test.go</name>
|
||||
<read_first>
|
||||
- internal/service/finance/service_test.go (existing helpers and tests from Plans 01+02)
|
||||
- internal/service/finance/query_activity.go (just implemented — verify scan logic to test correctly)
|
||||
- internal/repository/mysql/model/orders.gen.go (Orders struct fields for seeding)
|
||||
- internal/repository/mysql/model/user_inventory.gen.go (UserInventory fields)
|
||||
- .planning/phases/01-core-pnl-functions/01-RESEARCH.md (Pitfall 6: SQLite compat — avoid CAST AS SIGNED; activity JOIN tables must exist for SQLite AutoMigrate or CREATE TABLE)
|
||||
</read_first>
|
||||
<files>internal/service/finance/service_test.go</files>
|
||||
<behavior>
|
||||
- TestQueryActivityProfitLoss_EmptyParams_ReturnsResult: call with empty params, assert no error, result not nil
|
||||
- TestQueryActivityProfitLoss_CashOrderRevenue: seed order + draw_log + activity, assert Revenue = actual + discount
|
||||
- TestQueryActivityProfitLoss_RefundedOrderExcluded: seed refunded order (status=4), assert Revenue=0
|
||||
- TestQueryActivityProfitLoss_VoidedInventoryExcluded: seed inventory with status=2 for activity, assert Cost=0
|
||||
- TestQueryActivityProfitLoss_LegacyZeroOrderID: seed inventory with order_id=0 and activity_id set, assert included in Cost
|
||||
- TestQueryActivityProfitLoss_AllActivities: seed 2 activities with orders, call with empty ActivityIDs, assert both in Details
|
||||
- TestQueryActivityProfitLoss_FilterByActivityID: seed 2 activities, call with one ActivityID, assert only that activity in Details
|
||||
- TestQueryActivityProfitLoss_ProfitCalculation: revenue - cost = profit
|
||||
- TestQueryActivityProfitLoss_ResultShape: Details and Breakdown non-nil
|
||||
- For SQLite compat: seed draw log tables with db.Exec CREATE TABLE IF NOT EXISTS or via AutoMigrate using struct (if model exists); the query JOINs activity_draw_logs, activity_issues, activities — these tables must exist in test DB
|
||||
</behavior>
|
||||
<action>
|
||||
Append activity integration tests to `internal/service/finance/service_test.go`.
|
||||
|
||||
The activity-dimension tests require activity_draw_logs, activity_issues, and activities tables to exist in the SQLite test DB. Update `newTestSvc` if needed to create these tables (use db.Exec for tables without model structs, or check if model structs exist for AutoMigrate).
|
||||
|
||||
First check if model.ActivityDrawLogs, model.ActivityIssues, model.Activities exist in the model package — if they do, add them to AutoMigrate; if not, create minimal SQLite tables via db.Exec.
|
||||
|
||||
For the tests, create a helper `seedActivityWithDrawLog` that seeds an activity, an activity_issue, and an activity_draw_log linked to a given order:
|
||||
|
||||
```go
|
||||
// seedActivity creates minimal activity + issue + draw_log for JOIN tests
|
||||
// activityID: the activity.id, orderID: the linked order.id, userID: the draw user
|
||||
func seedActivitySetup(t *testing.T, db *gorm.DB, activityID, issueID, orderID, userID int64, priceDraw int64) {
|
||||
t.Helper()
|
||||
// Create tables if not covered by AutoMigrate
|
||||
db.Exec("CREATE TABLE IF NOT EXISTS activities (id integer primary key, price_draw integer not null default 0)")
|
||||
db.Exec("CREATE TABLE IF NOT EXISTS activity_issues (id integer primary key, activity_id integer not null)")
|
||||
db.Exec("CREATE TABLE IF NOT EXISTS activity_draw_logs (id integer primary key, order_id integer, issue_id integer, user_id integer)")
|
||||
// Seed rows
|
||||
require.NoError(t, db.Exec("INSERT OR IGNORE INTO activities (id, price_draw) VALUES (?, ?)", activityID, priceDraw).Error)
|
||||
require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_issues (id, activity_id) VALUES (?, ?)", issueID, activityID).Error)
|
||||
require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_draw_logs (id, order_id, issue_id, user_id) VALUES (?, ?, ?, ?)", orderID*100+issueID, orderID, issueID, userID).Error)
|
||||
}
|
||||
```
|
||||
|
||||
Tests:
|
||||
```go
|
||||
func TestQueryActivityProfitLoss_CashOrderRevenue(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{
|
||||
ID: 50, UserID: 501, Status: 2,
|
||||
SourceType: 2, OrderNo: "A001",
|
||||
ActualAmount: 600, DiscountAmount: 150,
|
||||
})
|
||||
seedActivitySetup(t, db, 1001, 2001, 50, 501, 100)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1001}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(750), result.TotalRevenue, "cash revenue = actual(600) + discount(150)")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_RefundedOrderExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{
|
||||
ID: 51, UserID: 502, Status: 4, // refunded
|
||||
SourceType: 2, OrderNo: "A002",
|
||||
ActualAmount: 800, DiscountAmount: 0,
|
||||
})
|
||||
seedActivitySetup(t, db, 1002, 2002, 51, 502, 100)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1002}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_VoidedInventoryExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 20, UserID: 503, ActivityID: 1003,
|
||||
Status: 2, // voided status
|
||||
ValueCents: 4000, OrderID: 0,
|
||||
})
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1003}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalCost, "voided inventory must not contribute cost")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_LegacyZeroOrderID(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 21, UserID: 504, ActivityID: 1004,
|
||||
Status: 1, ValueCents: 3500, OrderID: 0,
|
||||
Remark: "",
|
||||
})
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1004}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(3500), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_AllActivities(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 60, UserID: 601, Status: 2, SourceType: 2, OrderNo: "A010", ActualAmount: 100})
|
||||
seedOrder(t, db, model.Orders{ID: 61, UserID: 602, Status: 2, SourceType: 2, OrderNo: "A011", ActualAmount: 200})
|
||||
seedActivitySetup(t, db, 2001, 3001, 60, 601, 50)
|
||||
seedActivitySetup(t, db, 2002, 3002, 61, 602, 50)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{}) // empty = all
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
actIDs := make(map[int64]bool)
|
||||
for _, d := range result.Details {
|
||||
actIDs[d.ActivityID] = true
|
||||
}
|
||||
require.True(t, actIDs[2001], "activity 2001 must be in results")
|
||||
require.True(t, actIDs[2002], "activity 2002 must be in results")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_FilterByActivityID(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 70, UserID: 701, Status: 2, SourceType: 2, OrderNo: "A020", ActualAmount: 300})
|
||||
seedOrder(t, db, model.Orders{ID: 71, UserID: 702, Status: 2, SourceType: 2, OrderNo: "A021", ActualAmount: 400})
|
||||
seedActivitySetup(t, db, 3001, 4001, 70, 701, 50)
|
||||
seedActivitySetup(t, db, 3002, 4002, 71, 702, 50)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{3001}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
for _, d := range result.Details {
|
||||
require.Equal(t, int64(3001), d.ActivityID, "only activity 3001 should appear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_ProfitCalculation(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 80, UserID: 801, Status: 2, SourceType: 2, OrderNo: "A030", ActualAmount: 2000, DiscountAmount: 500})
|
||||
seedActivitySetup(t, db, 4001, 5001, 80, 801, 100)
|
||||
seedInventory(t, db, model.UserInventory{ID: 30, UserID: 801, ActivityID: 4001, Status: 1, ValueCents: 1200, OrderID: 80})
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{4001}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(2500), result.TotalRevenue, "revenue = actual(2000) + discount(500)")
|
||||
require.Equal(t, int64(1200), result.TotalCost)
|
||||
require.Equal(t, int64(1300), result.TotalProfit, "profit = 2500 - 1200")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_ResultShape(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Details, "Details must be non-nil slice")
|
||||
require.NotNil(t, result.Breakdown, "Breakdown must be non-nil empty slice")
|
||||
}
|
||||
```
|
||||
|
||||
NOTE on SQLite compat: The revenue scan JOINs activity_draw_logs, activity_issues, activities. For tests without these rows seeded, the INNER JOIN will return 0 rows (not error) — so TestQueryActivityProfitLoss_VoidedInventoryExcluded and TestQueryActivityProfitLoss_LegacyZeroOrderID only test the cost scan (inventory), which uses user_inventory.activity_id directly, not the draw_log JOIN. This is correct — cost and revenue are independent fan-out scans.
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -v -run "TestQueryActivity" ./internal/service/finance/</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test -v -run "TestQueryActivity" ./internal/service/finance/` exits 0
|
||||
- TestQueryActivityProfitLoss_CashOrderRevenue PASS: TotalRevenue=750
|
||||
- TestQueryActivityProfitLoss_RefundedOrderExcluded PASS: TotalRevenue=0
|
||||
- TestQueryActivityProfitLoss_VoidedInventoryExcluded PASS: TotalCost=0
|
||||
- TestQueryActivityProfitLoss_LegacyZeroOrderID PASS: TotalCost=3500
|
||||
- TestQueryActivityProfitLoss_AllActivities PASS: both activities in Details
|
||||
- TestQueryActivityProfitLoss_FilterByActivityID PASS: only activity 3001 in Details
|
||||
- TestQueryActivityProfitLoss_ProfitCalculation PASS: TotalProfit=1300
|
||||
- TestQueryActivityProfitLoss_ResultShape PASS: Details and Breakdown non-nil
|
||||
- `go test -v ./internal/service/finance/` exits 0 (ALL tests pass including Plan 01 and 02 tests)
|
||||
</acceptance_criteria>
|
||||
<done>All QueryActivityProfitLoss integration tests pass on SQLite. All DIM-02, DIM-03, DIM-04, PNL-02 through PNL-08, RET-01, RET-03 behaviors verified for the activity dimension.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
After all tasks complete:
|
||||
|
||||
1. Package compiles: `go build ./internal/service/finance/` exits 0
|
||||
2. All tests pass: `go test -v ./internal/service/finance/` exits 0
|
||||
3. No write DB: `grep -r "GetDbW" ./internal/service/finance/` returns 0 matches
|
||||
4. Fan-out verified: `grep -c "Scan(&" ./internal/service/finance/query_activity.go` returns >= 4
|
||||
5. Finance functions reused: `grep -E "ClassifyOrderSpending|ComputeProfit|ComputePrizeCostWithMultiplier" ./internal/service/finance/query_activity.go | wc -l` returns >= 3
|
||||
6. No duplicate helper: `grep -c "getPointsExchangeRate" ./internal/service/finance/query_activity.go` returns 1 (call) not 2 (definition would mean duplicate)
|
||||
7. Full build: `go build ./...` exits 0
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- query_activity.go: 4 fan-out scans attributed to activity dimension, all errors propagated, game-pass classified in Go, multiplier applied in Go, no GetDbW()
|
||||
- service.go: QueryActivityProfitLoss dispatches to s.queryActivity
|
||||
- service_test.go: 8+ TestQueryActivity* tests all PASS on SQLite
|
||||
- `go test -v ./internal/service/finance/` exits 0 with 20+ total PASS results
|
||||
- `go build ./...` exits 0
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion, create `.planning/phases/01-core-pnl-functions/01-03-SUMMARY.md`
|
||||
</output>
|
||||
194
.planning/phases/01-core-pnl-functions/01-04-PLAN.md
Normal file
194
.planning/phases/01-core-pnl-functions/01-04-PLAN.md
Normal file
@ -0,0 +1,194 @@
|
||||
---
|
||||
phase: 01-core-pnl-functions
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 3
|
||||
depends_on:
|
||||
- 01-02
|
||||
- 01-03
|
||||
files_modified: []
|
||||
autonomous: true
|
||||
requirements:
|
||||
- QUA-01
|
||||
- QUA-02
|
||||
- QUA-03
|
||||
- QUA-04
|
||||
- QUA-05
|
||||
- PNL-06
|
||||
- RET-01
|
||||
- RET-03
|
||||
- AST-01
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "Full test suite passes: go test -v ./internal/service/finance/... exits 0"
|
||||
- "Full build passes: go build ./... exits 0"
|
||||
- "No GetDbW() call exists anywhere in the finance package"
|
||||
- "All Scan() calls in query_user.go and query_activity.go have corresponding error checks"
|
||||
- "finance.* utility functions (ClassifyOrderSpending, ComputeProfit, etc.) are called from query_*.go, not reimplemented"
|
||||
- "Fan-out pattern: query_user.go has >= 4 Scan calls; query_activity.go has >= 4 Scan calls"
|
||||
- "All monetary struct fields in types.go are int64 (no float64 for monetary values except ProfitRate)"
|
||||
artifacts:
|
||||
- path: "internal/service/finance/types.go"
|
||||
provides: "Verified: all monetary fields int64, ProfitRate float64 only"
|
||||
- path: "internal/service/finance/query_user.go"
|
||||
provides: "Verified: >=4 Scan calls, all error-checked, finance functions called"
|
||||
- path: "internal/service/finance/query_activity.go"
|
||||
provides: "Verified: >=4 Scan calls, all error-checked, finance functions called"
|
||||
key_links:
|
||||
- from: "internal/service/finance package"
|
||||
to: "existing profit_metrics_test.go"
|
||||
via: "go test -v ./internal/service/finance/... — all tests including profit_metrics tests must pass"
|
||||
pattern: "PASS"
|
||||
---
|
||||
|
||||
<objective>
|
||||
Run all verification checks for Phase 1. No new files are created — this plan executes a series of automated checks to confirm that all 20 requirements (PNL-01 through QUA-05) are satisfied before declaring Phase 1 complete.
|
||||
|
||||
Purpose: Gate phase completion. Catches any drift between plans that ran in parallel (Plans 02 and 03), verifies static code properties that tests alone cannot guarantee, and confirms the full build is green.
|
||||
|
||||
Output: Evidence that all Phase 1 requirements are met. Creates no new files.
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@~/.claude/get-shit-done/workflows/execute-plan.md
|
||||
@~/.claude/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-core-pnl-functions/1-CONTEXT.md
|
||||
@.planning/phases/01-core-pnl-functions/01-02-SUMMARY.md
|
||||
@.planning/phases/01-core-pnl-functions/01-03-SUMMARY.md
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: Run full test suite and static code checks</name>
|
||||
<read_first>
|
||||
- internal/service/finance/query_user.go (verify 4+ Scan calls and finance function calls before running checks)
|
||||
- internal/service/finance/query_activity.go (verify 4+ Scan calls and finance function calls)
|
||||
- internal/service/finance/types.go (verify int64 monetary fields)
|
||||
- internal/service/finance/service.go (verify no GetDbW — both stubs should dispatch to queryUser/queryActivity)
|
||||
</read_first>
|
||||
<files></files>
|
||||
<action>
|
||||
Run the following verification sequence in order. For each check, output PASS or FAIL with evidence.
|
||||
|
||||
**Check 1: Full test suite (QUA-03, QUA-04, QUA-05)**
|
||||
```bash
|
||||
go test -v --cover ./internal/service/finance/...
|
||||
```
|
||||
Expected: All tests PASS, 0 failures.
|
||||
|
||||
**Check 2: Full project build (QUA-01)**
|
||||
```bash
|
||||
go build ./...
|
||||
```
|
||||
Expected: exits 0, no errors.
|
||||
|
||||
**Check 3: No GetDbW() in finance package (QUA-02)**
|
||||
```bash
|
||||
grep -r "GetDbW" ./internal/service/finance/
|
||||
```
|
||||
Expected: empty output (zero matches). If any match found, FAIL — locate and remove the call.
|
||||
|
||||
**Check 4: Fan-out scan count — user (QUA-05)**
|
||||
```bash
|
||||
grep -c "Scan(&" ./internal/service/finance/query_user.go
|
||||
```
|
||||
Expected: >= 4.
|
||||
|
||||
**Check 5: Fan-out scan count — activity (QUA-05)**
|
||||
```bash
|
||||
grep -c "Scan(&" ./internal/service/finance/query_activity.go
|
||||
```
|
||||
Expected: >= 4.
|
||||
|
||||
**Check 6: Scan errors all checked (QUA-03)**
|
||||
```bash
|
||||
grep -B1 "Scan(&" ./internal/service/finance/query_user.go | grep -c "if err :="
|
||||
grep -B1 "Scan(&" ./internal/service/finance/query_activity.go | grep -c "if err :="
|
||||
```
|
||||
Expected: count matches the number of Scan calls in each file. If any Scan is not wrapped in `if err :=`, locate and fix.
|
||||
|
||||
**Check 7: Finance utility functions reused (QUA-04)**
|
||||
```bash
|
||||
grep -E "ClassifyOrderSpending|ComputeGamePassValue|ComputePrizeCostWithMultiplier|ComputeProfit" ./internal/service/finance/query_user.go | wc -l
|
||||
grep -E "ClassifyOrderSpending|ComputeGamePassValue|ComputePrizeCostWithMultiplier|ComputeProfit" ./internal/service/finance/query_activity.go | wc -l
|
||||
```
|
||||
Expected: >= 3 matches in each file.
|
||||
|
||||
**Check 8: No SQL-embedded CAST AS SIGNED (SQLite test compat)**
|
||||
```bash
|
||||
grep "AS SIGNED" ./internal/service/finance/query_user.go ./internal/service/finance/query_activity.go | wc -l
|
||||
```
|
||||
Expected: 0. Multipliers must be applied in Go via ComputePrizeCostWithMultiplier.
|
||||
|
||||
**Check 9: int64 monetary fields (RET-03)**
|
||||
```bash
|
||||
grep -E "Revenue|Cost|Profit\b" ./internal/service/finance/types.go | grep "float64"
|
||||
```
|
||||
Expected: 0 matches. Only `ProfitRate float64` is allowed; all Revenue/Cost/Profit fields must be int64.
|
||||
|
||||
**Check 10: AssetType constants (AST-01)**
|
||||
```bash
|
||||
grep -A7 "AssetTypeAll" ./internal/service/finance/types.go
|
||||
```
|
||||
Expected: shows All=0, Points=1, Coupon=2, ItemCard=3, Product=4, Fragment=5.
|
||||
|
||||
If any check fails:
|
||||
1. Identify which file has the issue from the check output
|
||||
2. Read the file
|
||||
3. Make the targeted fix (do not rewrite the whole file)
|
||||
4. Re-run the failing check to confirm it now passes
|
||||
5. Re-run `go test -v ./internal/service/finance/...` to confirm tests still pass
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go test -v --cover ./internal/service/finance/... && go build ./... && echo "ALL PHASE 1 CHECKS PASSED"</automated>
|
||||
</verify>
|
||||
<acceptance_criteria>
|
||||
- `go test -v --cover ./internal/service/finance/...` exits 0 with PASS for all tests
|
||||
- `go build ./...` exits 0
|
||||
- `grep -r "GetDbW" ./internal/service/finance/` returns empty (0 matches)
|
||||
- `grep -c "Scan(&" ./internal/service/finance/query_user.go` returns >= 4
|
||||
- `grep -c "Scan(&" ./internal/service/finance/query_activity.go` returns >= 4
|
||||
- `grep -E "ClassifyOrderSpending|ComputeProfit" ./internal/service/finance/query_user.go | wc -l` returns >= 2
|
||||
- `grep -E "ClassifyOrderSpending|ComputeProfit" ./internal/service/finance/query_activity.go | wc -l` returns >= 2
|
||||
- `grep "AS SIGNED" ./internal/service/finance/query_*.go | wc -l` returns 0
|
||||
- `grep -E "Revenue|Cost|Profit\b" ./internal/service/finance/types.go | grep "float64" | wc -l` returns 0
|
||||
- All 10 verification checks above show PASS or expected result
|
||||
</acceptance_criteria>
|
||||
<done>All 10 static and automated checks pass. Phase 1 is complete — QueryUserProfitLoss and QueryActivityProfitLoss are implemented, tested, and meet all 20 Phase 1 requirements.</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
Phase 1 is complete when ALL of the following are simultaneously true:
|
||||
|
||||
1. `go test -v --cover ./internal/service/finance/...` — all PASS, coverage reported
|
||||
2. `go build ./...` — exits 0
|
||||
3. `grep -r "GetDbW" ./internal/service/finance/` — 0 matches
|
||||
4. `grep -c "Scan(&" query_user.go` — >= 4
|
||||
5. `grep -c "Scan(&" query_activity.go` — >= 4
|
||||
6. All finance.* utility functions called (not reimplemented) in query_*.go
|
||||
7. All monetary fields in types.go are int64 (ProfitRate is the only float64)
|
||||
8. VALIDATION.md nyquist_compliant updated to true
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- All 20 Phase 1 requirements (PNL-01 through QUA-05) verified as complete
|
||||
- Full test suite green including existing profit_metrics tests
|
||||
- Full project build clean
|
||||
- Finance package has zero write-DB references
|
||||
- Phase 1 ready for hand-off to Phase 2
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
After completion:
|
||||
1. Create `.planning/phases/01-core-pnl-functions/01-04-SUMMARY.md`
|
||||
2. Update `.planning/phases/01-core-pnl-functions/01-VALIDATION.md`: set `nyquist_compliant: true` and `wave_0_complete: true`
|
||||
3. Update `.planning/ROADMAP.md`: mark Phase 1 plans list with actual plan files
|
||||
4. Update `.planning/STATE.md`: set current plan to 4/4 complete, progress ~50%
|
||||
</output>
|
||||
675
.planning/phases/01-core-pnl-functions/01-RESEARCH.md
Normal file
675
.planning/phases/01-core-pnl-functions/01-RESEARCH.md
Normal file
@ -0,0 +1,675 @@
|
||||
# Phase 1: Core P&L Functions - Research
|
||||
|
||||
**Researched:** 2026-03-21
|
||||
**Domain:** Go service layer financial aggregation — multi-dimensional P&L query functions in an existing GORM/MySQL codebase
|
||||
**Confidence:** HIGH
|
||||
|
||||
<user_constraints>
|
||||
## User Constraints (from CONTEXT.md)
|
||||
|
||||
### Locked Decisions
|
||||
|
||||
**Revenue Attribution Rules**
|
||||
- D-01: 一个订单只对应一个活动(1:1 关系),不需要比例分摊逻辑(跳过 dashboard 中的 two-level subquery 方案)
|
||||
- D-02: Game-pass 收入按 draw_count × activity_unit_price 计算,每个活动独立计算
|
||||
- D-03: 用户维度直接汇总用户所有订单,不做跨活动分摊
|
||||
|
||||
**Function Signature Design**
|
||||
- D-04: 两个独立的参数结构体:`UserProfitLossParams` 和 `ActivityProfitLossParams`(不共享)
|
||||
- D-05: 返回 `(*ProfitLossResult, error)` — Go 标准模式,error 时 result 为 nil
|
||||
- D-06: ProfitLossResult 包含汇总(TotalResult)+ 明细切片(`[]ProfitLossDetail`,每个元素含 UserID/ActivityID 字段)
|
||||
- D-07: 参数全部可选:空 []int64 = 统计全部,nil time = 不限时间,AssetType=0 = 全部类型
|
||||
|
||||
**Cost Source Mapping**
|
||||
- D-08: 成本数据分布在多张表:user_inventory(实物/道具卡)、user_points_ledger(积分)、user_coupon_ledger(优惠券)、fragment_synthesis_logs(碎片,Phase 2)
|
||||
- D-09: 实物商品/道具卡成本以 `user_inventory.value_cents` 为准(单一真相源),不需要 fallback chain
|
||||
- D-10: 积分通过 system_configs 表中的固定汇率换算为金额(如 100积分 = 1元)
|
||||
- D-11: 优惠券成本 = 优惠券面值(discount_amount)
|
||||
|
||||
### Claude's Discretion
|
||||
- 具体 SQL 查询结构和 GORM 调用方式
|
||||
- ProfitLossDetail 内部字段的精确命名
|
||||
- fan-out 查询的拆分粒度和合并策略
|
||||
- 单元测试的具体用例选择
|
||||
|
||||
### Deferred Ideas (OUT OF SCOPE)
|
||||
- Per-asset-type breakdown (Phase 2) — struct field defined here as empty slice, populated in Phase 2
|
||||
- Fragment synthesis cost integration (Phase 2) — AST-03
|
||||
- Redis caching wrapper (v2)
|
||||
- Admin API endpoints for frontend (v2)
|
||||
</user_constraints>
|
||||
|
||||
<phase_requirements>
|
||||
## Phase Requirements
|
||||
|
||||
| ID | Description | Research Support |
|
||||
|----|-------------|------------------|
|
||||
| PNL-01 | 函数接收 ProfitLossParams 参数结构体,所有字段可选(资产类型、维度ID、时间范围) | D-04, D-07; param struct pattern confirmed from user.go constructor pattern |
|
||||
| PNL-02 | Revenue = actual_amount + discount_amount,排除已退款/取消订单(status=3,4) | Confirmed from dashboard_activity.go:209; status=2 filter is the correct paid-only gate |
|
||||
| PNL-03 | Game-pass 订单通过 finance.IsGamePassOrder 三条件检测,与现金收入严格互斥 | finance.IsGamePassOrder verified in profit_metrics.go:43-51; 3 conditions documented |
|
||||
| PNL-04 | Game-pass 订单收入通过 finance.ComputeGamePassValue 计算(draw_count × activity_price) | finance.ComputeGamePassValue verified in profit_metrics.go:53-58 |
|
||||
| PNL-05 | Prize cost 通过 finance.ComputePrizeCostWithMultiplier 计算,包含道具卡倍率 | finance.ComputePrizeCostWithMultiplier verified in profit_metrics.go:67-73 |
|
||||
| PNL-06 | Profit 通过 finance.ComputeProfit 计算,返回 int64 分 + float64 利润率 | finance.ComputeProfit verified in profit_metrics.go:75-81 |
|
||||
| PNL-07 | 排除已作废库存(remark LIKE '%void%' 或 status=2)不计入成本 | Pattern in dashboard_activity.go:248-249; status IN (1,3) + remark NOT LIKE '%void%' |
|
||||
| PNL-08 | 兼容 order_id=0 或 NULL 的历史数据(不受订单状态过滤影响) | Pattern in dashboard_activity.go:251; `OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL` |
|
||||
| DIM-01 | QueryUserProfitLoss 接收 []int64 用户ID,空切片=统计全部用户 | GORM WHERE IN with empty slice guard; empty = omit WHERE clause |
|
||||
| DIM-02 | QueryActivityProfitLoss 接收 []int64 活动ID,空切片=统计全部活动 | Same pattern as DIM-01 |
|
||||
| DIM-03 | 时间范围过滤使用 *time.Time(nil=不限),不使用零值作哨兵 | Confirmed; *time.Time pointer pattern is idiomatic in this codebase |
|
||||
| DIM-04 | AssetType 可选过滤,nil/All(0)=统计全部资产类型 | AssetTypeAll=0 as zero value is natural Go default |
|
||||
| RET-01 | ProfitLossResult 包含汇总数据:总收入、总成本、净盈亏、利润率 | Struct fields verified against finance.ComputeProfit return values |
|
||||
| RET-03 | 所有金额以 int64 分为单位,不使用 float64 存储金额 | Codebase-wide convention confirmed; only profit_rate is float64 |
|
||||
| AST-01 | 定义 AssetType 枚举:Points(1)、Coupon(2)、ItemCard(3)、Product(4)、Fragment(5)、All(0) | Clean iota-style const block; All=0 as zero value |
|
||||
| QUA-01 | 新函数放在 internal/service/finance/ 包下 | Package already exists with profit_metrics.go |
|
||||
| QUA-02 | Service 构造器仅注入 DbR(读库),包内不出现 GetDbW() 调用 | Pattern from user.go New() func; finance service omits writeDB field entirely |
|
||||
| QUA-03 | 每个 Scan() 调用必须检查 .Error 并返回错误,不静默吞掉 | Critical pattern; dashboard had this bug; new code must not repeat it |
|
||||
| QUA-04 | 复用现有 finance.* 工具函数,不重复实现 | All 6 functions verified in profit_metrics.go; reuse confirmed |
|
||||
| QUA-05 | 使用 fan-out + in-memory merge 查询模式 | Pattern from dashboard_activity.go; multiple Scan() calls merged via map[int64]*item |
|
||||
</phase_requirements>
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
This phase adds two new service-layer functions — `QueryUserProfitLoss` and `QueryActivityProfitLoss` — to the existing `internal/service/finance/` package. The codebase already has all the building blocks: six tested pure functions (`profit_metrics.go`), established fan-out query patterns (`dashboard_activity.go`), a SQLite test infrastructure, and clear conventions for constructor injection and error handling.
|
||||
|
||||
The key insight from reviewing the existing dashboard code is that the new service functions are *simpler* than the dashboard handlers because D-01 locks the 1:1 order-to-activity rule, eliminating the two-level subquery revenue proration that the dashboard requires. Revenue for the user dimension is a direct `SUM(actual_amount + discount_amount)` on the user's orders; for the activity dimension it is a direct `SUM` filtered to that activity's orders.
|
||||
|
||||
Cost still requires the same multi-join pattern as the dashboard: `user_inventory` joined to `orders` (for refund exclusion), `activity_reward_settings` and `products` (for price fallback chain per D-09 — **note: D-09 says value_cents is the single source of truth**, so the fallback chain simplifies to just `user_inventory.value_cents`), and `system_item_cards` (for multiplier). Points cost requires a separate scan on `user_points_ledger` converted via the `points.exchange_rate` system config. Coupon cost requires a scan on `user_coupon_ledger` for deduction amounts.
|
||||
|
||||
**Primary recommendation:** Implement as five new files in `internal/service/finance/`: `service.go` (interface + constructor), `types.go` (params, result structs, AssetType enum), `query_user.go`, `query_activity.go`, and `service_test.go`. Each query file executes 3-4 fan-out scans and merges in Go using the established map pattern.
|
||||
|
||||
---
|
||||
|
||||
## Standard Stack
|
||||
|
||||
### Core
|
||||
| Library | Version | Purpose | Why Standard |
|
||||
|---------|---------|---------|--------------|
|
||||
| gorm.io/gorm | 1.25.9 | ORM query execution | Already in project; `.Table().Select().Scan()` pattern used throughout |
|
||||
| go.uber.org/zap (via logger.CustomLogger) | 1.26.0 | Structured error logging | Project-standard logger interface; injected via constructor |
|
||||
| gorm.io/driver/sqlite | 1.4.3 | In-memory test DB | `NewSQLiteRepoForTest()` already exists in testrepo_sqlite.go |
|
||||
| github.com/stretchr/testify | 1.11.1 | Test assertions | Project-standard test library |
|
||||
|
||||
### Supporting
|
||||
| Library | Version | Purpose | When to Use |
|
||||
|---------|---------|---------|-------------|
|
||||
| bindbox-game/internal/pkg/points | local | Points ↔ cents conversion | When computing points cost in cents; `PointsToCents(pts, rate)` |
|
||||
| bindbox-game/internal/service/finance | local (same pkg) | Reusable finance primitives | All 6 functions from profit_metrics.go |
|
||||
|
||||
**No new dependencies required.** All libraries are in `go.mod`.
|
||||
|
||||
---
|
||||
|
||||
## Architecture Patterns
|
||||
|
||||
### Recommended Project Structure
|
||||
|
||||
```
|
||||
internal/service/finance/
|
||||
├── profit_metrics.go (EXISTING — pure business logic, no DB)
|
||||
├── profit_metrics_test.go (EXISTING — pure unit tests)
|
||||
├── service.go (NEW — Service interface + New() constructor)
|
||||
├── types.go (NEW — AssetType enum, param structs, result types)
|
||||
├── query_user.go (NEW — QueryUserProfitLoss scan logic, 3-4 Scan calls)
|
||||
├── query_activity.go (NEW — QueryActivityProfitLoss scan logic, 3-4 Scan calls)
|
||||
└── service_test.go (NEW — integration tests using NewSQLiteRepoForTest())
|
||||
```
|
||||
|
||||
### Pattern 1: Service Constructor (Read-Only DB Injection)
|
||||
|
||||
**What:** Constructor injects only the read replica `*gorm.DB`; the finance service struct has no `writeDB` field.
|
||||
|
||||
**When to use:** Always — QUA-02 mandates no `GetDbW()` in this package.
|
||||
|
||||
```go
|
||||
// Source: internal/service/user/user.go (constructor pattern reference)
|
||||
package finance
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)
|
||||
QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger logger.CustomLogger
|
||||
dbR *gorm.DB // read replica only — never use for writes
|
||||
}
|
||||
|
||||
func New(l logger.CustomLogger, db mysql.Repo) Service {
|
||||
return &service{
|
||||
logger: l,
|
||||
dbR: db.GetDbR(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: Fan-Out + In-Memory Merge
|
||||
|
||||
**What:** Execute N independent `Scan()` calls (one per data source), then merge results in Go using `map[int64]*ProfitLossDetail`.
|
||||
|
||||
**When to use:** Any query requiring data from multiple tables that have 1-to-many relationships (avoids Cartesian product JOINs).
|
||||
|
||||
```go
|
||||
// Source: internal/api/admin/dashboard_activity.go (fan-out pattern reference)
|
||||
|
||||
// Step 1: revenue scan
|
||||
type revenueRow struct {
|
||||
DimensionID int64
|
||||
TotalRevenue int64
|
||||
TotalGamePassValue int64
|
||||
}
|
||||
var revenueRows []revenueRow
|
||||
if err := db.Table(model.TableNameOrders).
|
||||
Select(`
|
||||
orders.user_id as dimension_id,
|
||||
SUM(CASE WHEN source_type = 4 OR order_no LIKE 'GP%' OR (actual_amount = 0 AND remark LIKE '%use_game_pass%')
|
||||
THEN 0
|
||||
ELSE actual_amount + discount_amount
|
||||
END) as total_revenue
|
||||
`).
|
||||
Where("orders.status = ?", 2).
|
||||
Group("orders.user_id").
|
||||
Scan(&revenueRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("revenue scan failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 2: cost scan (separate query)
|
||||
type costRow struct {
|
||||
DimensionID int64
|
||||
TotalCost int64
|
||||
}
|
||||
var costRows []costRow
|
||||
if err := db.Table(model.TableNameUserInventory).
|
||||
Select(`
|
||||
user_inventory.user_id as dimension_id,
|
||||
SUM(user_inventory.value_cents) as total_cost
|
||||
`).
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2).
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Group("user_inventory.user_id").
|
||||
Scan(&costRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("cost scan failed: %w", err)
|
||||
}
|
||||
|
||||
// Step 3: merge in Go
|
||||
resultMap := make(map[int64]*ProfitLossDetail)
|
||||
for _, r := range revenueRows {
|
||||
resultMap[r.DimensionID] = &ProfitLossDetail{
|
||||
UserID: r.DimensionID,
|
||||
Revenue: r.TotalRevenue,
|
||||
}
|
||||
}
|
||||
for _, c := range costRows {
|
||||
if item, ok := resultMap[c.DimensionID]; ok {
|
||||
item.Cost = c.TotalCost
|
||||
}
|
||||
}
|
||||
|
||||
// Step 4: apply finance functions
|
||||
for _, item := range resultMap {
|
||||
item.Profit, item.ProfitRate = ComputeProfit(item.Revenue, item.Cost)
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 3: Optional Parameter Filtering
|
||||
|
||||
**What:** Build up the GORM query conditionally; only add WHERE clauses when params are non-nil/non-empty.
|
||||
|
||||
**When to use:** All query functions in this service — DIM-01 through DIM-04.
|
||||
|
||||
```go
|
||||
// Source: established codebase pattern
|
||||
func (s *service) buildBaseQuery(ctx context.Context, params UserProfitLossParams) *gorm.DB {
|
||||
db := s.dbR.WithContext(ctx).Table(model.TableNameOrders)
|
||||
|
||||
// Empty slice = no filter (all records)
|
||||
if len(params.UserIDs) > 0 {
|
||||
db = db.Where("orders.user_id IN ?", params.UserIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
db = db.Where("orders.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
db = db.Where("orders.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
return db
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 4: Points Cost Resolution
|
||||
|
||||
**What:** Read `points.exchange_rate` from `system_configs`, then convert points deductions from `user_points_ledger` to cents.
|
||||
|
||||
**When to use:** When computing points cost contribution (cost data from `user_points_ledger`).
|
||||
|
||||
```go
|
||||
// Source: internal/service/user/points_convert.go (getExchangeRate pattern)
|
||||
func (s *service) getPointsExchangeRate(ctx context.Context) int64 {
|
||||
var cfg model.SystemConfigs
|
||||
if err := s.dbR.WithContext(ctx).
|
||||
Where("config_key = ?", "points.exchange_rate").
|
||||
First(&cfg).Error; err != nil {
|
||||
return 1 // default: 1 yuan = 1 point
|
||||
}
|
||||
var rate int64
|
||||
_, _ = fmt.Sscanf(cfg.ConfigValue, "%d", &rate)
|
||||
if rate <= 0 {
|
||||
return 1
|
||||
}
|
||||
return rate
|
||||
}
|
||||
|
||||
// Convert points to cents: cents = points * 100 / rate
|
||||
// Source: internal/pkg/points/convert.go PointsToCents()
|
||||
pointsCostCents := points.PointsToCents(totalPointsDeducted, float64(exchangeRate))
|
||||
```
|
||||
|
||||
### Pattern 5: CAST(AS SIGNED) for Division SUM
|
||||
|
||||
**What:** Wrap any SUM expression containing division with `CAST(... AS SIGNED)` to prevent MySQL returning Decimal type.
|
||||
|
||||
**When to use:** Any SQL aggregation involving division in SUM.
|
||||
|
||||
**Note:** D-09 locks `user_inventory.value_cents` as the single source of truth for inventory cost, so the fallback COALESCE chain from the dashboard is NOT used. The cost formula simplifies to `SUM(user_inventory.value_cents * multiplier / 1000)` which still requires CAST.
|
||||
|
||||
```go
|
||||
// Source: internal/api/admin/dashboard_activity.go:237 (CAST pattern)
|
||||
// MySQL returns DECIMAL for SUM(x * y / z) — must cast to SIGNED for int64 scan
|
||||
Select(`
|
||||
CAST(SUM(
|
||||
user_inventory.value_cents
|
||||
* GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000)
|
||||
/ 1000
|
||||
) AS SIGNED) as total_cost
|
||||
`)
|
||||
```
|
||||
|
||||
### Pattern 6: Game-Pass Revenue Calculation
|
||||
|
||||
**What:** Use `IsGamePassOrder()` to classify orders. Game-pass revenue = `draw_count × activity_price`. Cash revenue = `actual_amount + discount_amount`. These are mutually exclusive (D-03).
|
||||
|
||||
**When to use:** Every revenue scan in both query functions.
|
||||
|
||||
**Key simplification vs. dashboard:** Since D-01 establishes 1:1 order-to-activity, there is NO need for the draw-count proration subquery that the dashboard uses. Revenue is directly attributable to the order's user/activity.
|
||||
|
||||
```go
|
||||
// For user dimension: classify per-order in Go after scanning raw fields
|
||||
// Scan raw fields: source_type, order_no, actual_amount, discount_amount, remark, draw_count, activity_price
|
||||
// Then call: ClassifyOrderSpending(sourceType, orderNo, actualAmount, discountAmount, remark, gpValue)
|
||||
// Sum breakdown.Total as dimension revenue
|
||||
```
|
||||
|
||||
### Anti-Patterns to Avoid
|
||||
|
||||
- **Mega-JOIN across all tables:** Produces Cartesian products. Use fan-out separate Scan calls instead.
|
||||
- **Scanning division-SUM into int64 without CAST:** Returns zero silently (MySQL Decimal → int64 mismatch).
|
||||
- **Skipping `.Error` check on Scan():** Silent wrong data. Every Scan must check error.
|
||||
- **Using GetDbW() in this package:** Violates QUA-02 and adds load to write master.
|
||||
- **Using `time.Time{}` zero value as "no filter" sentinel:** Use `*time.Time`; nil = no filter.
|
||||
- **Passing empty slice to `WHERE IN (?)`:** GORM generates invalid SQL. Guard with `if len(ids) > 0` before adding the WHERE clause.
|
||||
- **Re-implementing game-pass classification logic in SQL CASE expressions:** Duplicates `IsGamePassOrder()` and diverges from the canonical rule. Scan raw fields, classify in Go.
|
||||
- **COALESCE fallback chain for value_cents:** D-09 locks `user_inventory.value_cents` as single truth source — do NOT use the dashboard's `COALESCE(NULLIF(value_cents,0), price_snapshot_cents, products.price, 0)`.
|
||||
|
||||
---
|
||||
|
||||
## Don't Hand-Roll
|
||||
|
||||
| Problem | Don't Build | Use Instead | Why |
|
||||
|---------|-------------|-------------|-----|
|
||||
| Game-pass order classification | Custom CASE expression in SQL or new Go function | `finance.IsGamePassOrder()` | Three conditions; already tested; must stay in sync across codebase |
|
||||
| Game-pass value calculation | `drawCount * price` inline everywhere | `finance.ComputeGamePassValue()` | Guards against zero/negative inputs |
|
||||
| Prize cost with multiplier | Custom multiplication in query | `finance.ComputePrizeCostWithMultiplier()` | Handles multiplier normalization (GREATEST/default 1000) |
|
||||
| Profit + profit rate calculation | `revenue - cost` inline | `finance.ComputeProfit()` | Handles zero-revenue edge case (avoids division by zero) |
|
||||
| Points-to-cents conversion | Custom formula | `points.PointsToCents(pts, rate)` | Handles rounding via `math.Round`; tested |
|
||||
| Exchange rate lookup | Hardcode or re-implement | Pattern from `user.getExchangeRate()` | Reads from `system_configs` table with safe default |
|
||||
| Test database | Real MySQL connection | `mysql.NewSQLiteRepoForTest()` | In-memory, zero-config, existing infrastructure |
|
||||
|
||||
**Key insight:** The `internal/service/finance/` package already contains the entire mathematical foundation. This phase is a database query layer on top, not a business logic reimplementation.
|
||||
|
||||
---
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### Pitfall 1: MySQL SUM with Division Returns Decimal (Silent Zero)
|
||||
|
||||
**What goes wrong:** `SUM(value_cents * multiplier / 1000)` returns Decimal type in MySQL. GORM scan into `int64` silently produces 0. Cost appears as 0 even with data.
|
||||
|
||||
**Why it happens:** MySQL promotes arithmetic involving division to Decimal to preserve fractional precision. GORM does not coerce types.
|
||||
|
||||
**How to avoid:** Wrap the entire SUM expression with `CAST(... AS SIGNED)`. Applies specifically to the multiplier cost calculation.
|
||||
|
||||
**Warning signs:** Cost fields are uniformly 0 across all activities/users despite inventory data existing.
|
||||
|
||||
### Pitfall 2: Empty Slice in WHERE IN Produces Invalid SQL
|
||||
|
||||
**What goes wrong:** `db.Where("user_id IN ?", []int64{})` generates `WHERE user_id IN ()` — invalid SQL that returns error or empty result instead of all records.
|
||||
|
||||
**Why it happens:** GORM does not guard against empty slice inputs.
|
||||
|
||||
**How to avoid:** Always check `len(ids) > 0` before adding the WHERE clause. Empty slice means "all records" per D-07 — do not add the filter at all.
|
||||
|
||||
**Warning signs:** Function returns empty result or SQL error when called with no IDs.
|
||||
|
||||
### Pitfall 3: Game-Pass and Cash Revenue Double-Counted
|
||||
|
||||
**What goes wrong:** Including both `actual_amount + discount_amount` AND game-pass value for the same order. Game-pass orders have `actual_amount = 0`, so their coupon-based revenue is 0, but their game-pass value is not — adding both produces correct total by accident, but the classification is wrong and subtotals diverge from dashboard.
|
||||
|
||||
**Why it happens:** Treating all orders uniformly in a single SUM.
|
||||
|
||||
**How to avoid:** Scan both raw order fields AND activity price/draw_count. Classify per-order in Go with `ClassifyOrderSpending()`. The mutual exclusion is enforced by the function.
|
||||
|
||||
**Warning signs:** `SpendingPaidCoupon` and `SpendingGamePass` are both non-zero for the same order-level scan.
|
||||
|
||||
### Pitfall 4: Refunded Order Inventory Counted as Cost
|
||||
|
||||
**What goes wrong:** `user_inventory` rows exist for prizes awarded from subsequently-refunded orders. Counting them inflates cost while excluding their revenue.
|
||||
|
||||
**Why it happens:** Inventory is created on award (before refund window). Refunds update `orders.status` to 4, not delete inventory.
|
||||
|
||||
**How to avoid:** Always join `orders` and filter `(orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)`. The legacy escape hatch (`order_id = 0 OR NULL`) is mandatory for old data compatibility (PNL-08).
|
||||
|
||||
**Warning signs:** Platform appears to have given away prizes for free; test with a refunded order shows non-zero cost.
|
||||
|
||||
### Pitfall 5: Silently Ignored Scan Errors
|
||||
|
||||
**What goes wrong:** If a Scan fails (schema mismatch, DB failover), the result struct stays at zero values. No error is returned. The P&L appears correct (all zeros) rather than failing.
|
||||
|
||||
**Why it happens:** GORM method chaining makes it easy to omit `.Error` check.
|
||||
|
||||
**How to avoid:** Every Scan must be: `if err := db...Scan(&result).Error; err != nil { return nil, fmt.Errorf("...: %w", err) }`. This is QUA-03.
|
||||
|
||||
**Warning signs:** Function returns zero P&L with nil error even when data exists; test with a deliberately broken query.
|
||||
|
||||
### Pitfall 6: SQLite Test Incompatibilities
|
||||
|
||||
**What goes wrong:** Tests using `NewSQLiteRepoForTest()` fail because SQLite does not support:
|
||||
- `CAST(... AS SIGNED)` — use `CAST(... AS INTEGER)` in test SQL or compute in Go
|
||||
- `GREATEST()` MySQL function — not available in SQLite
|
||||
- `LIKE 'GP%'` may behave differently in edge cases
|
||||
|
||||
**Why it happens:** Integration tests use SQLite for speed/simplicity but production uses MySQL.
|
||||
|
||||
**How to avoid:** Keep game-pass classification in Go (scan raw fields, call `IsGamePassOrder()` in Go). Keep multiplier application in Go (scan raw `value_cents` and `multiplier_x1000`, call `ComputePrizeCostWithMultiplier()` in Go). Only perform non-division aggregations in SQL for tests.
|
||||
|
||||
**Warning signs:** Tests pass locally with Go-layer classification but fail when logic is moved into SQL CASE expressions.
|
||||
|
||||
---
|
||||
|
||||
## Code Examples
|
||||
|
||||
Verified patterns from codebase analysis:
|
||||
|
||||
### Service Constructor
|
||||
|
||||
```go
|
||||
// Source: internal/service/user/user.go:100-102 (reference pattern)
|
||||
// Finance service omits writeDB entirely (QUA-02)
|
||||
func New(l logger.CustomLogger, db mysql.Repo) Service {
|
||||
return &service{
|
||||
logger: l,
|
||||
dbR: db.GetDbR(),
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Param Structs and Types
|
||||
|
||||
```go
|
||||
// Source: CONTEXT.md D-04, D-07; STACK.md pattern
|
||||
type AssetType int
|
||||
|
||||
const (
|
||||
AssetTypeAll AssetType = 0 // zero value = all types
|
||||
AssetTypePoints AssetType = 1
|
||||
AssetTypeCoupon AssetType = 2
|
||||
AssetTypeItemCard AssetType = 3
|
||||
AssetTypeProduct AssetType = 4
|
||||
AssetTypeFragment AssetType = 5
|
||||
)
|
||||
|
||||
type UserProfitLossParams struct {
|
||||
UserIDs []int64 // empty = all users (DIM-01)
|
||||
AssetType AssetType // 0 = all types (DIM-04)
|
||||
StartTime *time.Time // nil = no lower bound (DIM-03)
|
||||
EndTime *time.Time // nil = no upper bound (DIM-03)
|
||||
}
|
||||
|
||||
type ActivityProfitLossParams struct {
|
||||
ActivityIDs []int64 // empty = all activities (DIM-02)
|
||||
AssetType AssetType // 0 = all types (DIM-04)
|
||||
StartTime *time.Time // nil = no lower bound (DIM-03)
|
||||
EndTime *time.Time // nil = no upper bound (DIM-03)
|
||||
}
|
||||
```
|
||||
|
||||
### Result Structs
|
||||
|
||||
```go
|
||||
// Source: CONTEXT.md D-05, D-06; REQUIREMENTS.md RET-01, RET-03
|
||||
type ProfitLossDetail struct {
|
||||
UserID int64 // populated for user dimension
|
||||
ActivityID int64 // populated for activity dimension
|
||||
Revenue int64 // fen (RET-03: int64 only)
|
||||
Cost int64 // fen
|
||||
Profit int64 // fen
|
||||
ProfitRate float64 // ratio (only field that uses float64)
|
||||
}
|
||||
|
||||
type ProfitLossResult struct {
|
||||
TotalRevenue int64 // fen (RET-01)
|
||||
TotalCost int64 // fen
|
||||
TotalProfit int64 // fen
|
||||
ProfitRate float64 // ratio
|
||||
Details []ProfitLossDetail // per-user or per-activity (D-06)
|
||||
Breakdown []interface{} // Phase 2: empty slice placeholder (deferred)
|
||||
}
|
||||
```
|
||||
|
||||
### Revenue Query (User Dimension, No Proration)
|
||||
|
||||
```go
|
||||
// Source: CONTEXT.md D-01, D-03; simplification vs dashboard_activity.go
|
||||
// No two-level subquery needed — 1:1 order-to-activity (D-01)
|
||||
type userRevenueRow struct {
|
||||
UserID int64
|
||||
CashRevenue int64 // actual_amount + discount_amount for non-game-pass orders
|
||||
GamePassDraws int64 // draw count for game-pass orders
|
||||
ActivityPriceDraw int64 // unit price of the activity (for game-pass value calc)
|
||||
}
|
||||
// NOTE: Game-pass value = GamePassDraws × ActivityPriceDraw, computed in Go
|
||||
// using ComputeGamePassValue() — not computed in SQL to maintain SQLite test compat
|
||||
```
|
||||
|
||||
### Cost Query (Inventory, User Dimension)
|
||||
|
||||
```go
|
||||
// Source: dashboard_activity.go:234-263 (adapted per D-09: value_cents only, no fallback chain)
|
||||
type userCostRow struct {
|
||||
UserID int64
|
||||
TotalCostCents int64 // CAST(SUM(value_cents * multiplier / 1000) AS SIGNED)
|
||||
}
|
||||
// Note: CAST required for division-containing SUM (Pitfall 1)
|
||||
// Note: status IN (1,3) + remark NOT LIKE '%void%' + legacy order_id=0 guard (PNL-07, PNL-08)
|
||||
```
|
||||
|
||||
### Error Handling Pattern
|
||||
|
||||
```go
|
||||
// Source: PITFALLS.md Pitfall 4; QUA-03
|
||||
var rows []revenueRow
|
||||
if err := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameOrders).
|
||||
Select("...").
|
||||
Where("orders.status = ?", 2).
|
||||
Group("orders.user_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryUserProfitLoss revenue scan: %w", err)
|
||||
}
|
||||
```
|
||||
|
||||
### Points Cost Resolution
|
||||
|
||||
```go
|
||||
// Source: internal/service/user/points_convert.go:13-25
|
||||
// Read exchange rate from system_configs, convert points ledger deductions to cents
|
||||
var pointRows []struct {
|
||||
UserID int64
|
||||
TotalPoints int64 // SUM of negative point changes = cost
|
||||
}
|
||||
// After scan:
|
||||
rate := s.getPointsExchangeRate(ctx) // reads "points.exchange_rate" key
|
||||
for _, r := range pointRows {
|
||||
costCents := points.PointsToCents(r.TotalPoints, float64(rate))
|
||||
resultMap[r.UserID].Cost += costCents
|
||||
}
|
||||
```
|
||||
|
||||
### Test Setup
|
||||
|
||||
```go
|
||||
// Source: internal/repository/mysql/testrepo_sqlite.go
|
||||
func TestQueryUserProfitLoss(t *testing.T) {
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create tables with AutoMigrate
|
||||
db := repo.GetDbR()
|
||||
require.NoError(t, db.AutoMigrate(&model.Orders{}, &model.UserInventory{}, ...))
|
||||
|
||||
// Seed test data
|
||||
// ...
|
||||
|
||||
svc := New(logger.NewCustomLogger(nil, logger.WithOutputInConsole()), repo)
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
// assert...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## State of the Art
|
||||
|
||||
| Old Approach | Current Approach | When Changed | Impact |
|
||||
|--------------|------------------|--------------|--------|
|
||||
| Single mega-JOIN across orders + inventory + draw_logs | Fan-out separate Scan calls merged in Go | Dashboard v2 (already in codebase) | Eliminates Cartesian product; individual queries are independently testable |
|
||||
| Revenue attributed by scanning orders directly in handler | Service-layer function with injected DB and typed params | This phase | Callers don't need to write SQL; consistent calculation across all endpoints |
|
||||
| Dashboard handlers as source of truth for P&L numbers | `finance.*` utility functions + new service layer | This phase | Decoupled from HTTP context; reusable from any caller |
|
||||
| float64 scan for SUM-with-division | CAST(... AS SIGNED) + int64 scan | Dashboard bugfix (already in code) | Eliminates floating-point monetary rounding |
|
||||
|
||||
**Deprecated/outdated:**
|
||||
- Fallback COALESCE chain for `value_cents` — D-09 deprecates this in the new service (dashboard still uses it for backward compat; new service uses `value_cents` directly)
|
||||
- Inline game-pass CASE expressions in SQL — deprecated in favor of Go-layer classification via `IsGamePassOrder()`
|
||||
|
||||
---
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. **Points ledger: which `action` values represent cost deductions?**
|
||||
- What we know: `user_points_ledger.action` includes `order_deduct`, `refund_restore`, `signin`, `manual`
|
||||
- What's unclear: Should only `order_deduct` actions count as cost? Or all negative-delta entries?
|
||||
- Recommendation: Filter on `action = 'order_deduct'` AND `points < 0` for cost. Refund restores (`refund_restore`) should cancel the cost — verify by checking if the net sum correctly cancels on refund.
|
||||
|
||||
2. **Coupon cost: which `user_coupon_ledger.action` values represent platform cost?**
|
||||
- What we know: `user_coupon_ledger` has `change_amount` (negative = deduction), `order_id`, `action`
|
||||
- What's unclear: Is `SUM(ABS(change_amount)) WHERE change_amount < 0` the correct cost formula, or should we filter by action?
|
||||
- Recommendation: Sum all deductions (`change_amount < 0`) for orders with status=2 (paid). Join to orders table to filter refunded orders.
|
||||
|
||||
3. **Activity price for game-pass value: which field is authoritative?**
|
||||
- What we know: D-02 says `draw_count × activity_unit_price`. `activities.price_draw` is the per-draw price used in the dashboard.
|
||||
- What's unclear: For the user dimension query, orders may span multiple activities. Does each order carry the activity's price at order time, or must we join to `activities`?
|
||||
- Recommendation: Join `orders` → `activity_draw_logs` → `activity_issues` → `activities` to get `activities.price_draw`. This is the same join the dashboard uses for game-pass value (dashboard_activity.go:280-296).
|
||||
|
||||
---
|
||||
|
||||
## Validation Architecture
|
||||
|
||||
### Test Framework
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Framework | Go testing + testify v1.11.1 |
|
||||
| Config file | none — standard `go test` |
|
||||
| Quick run command | `go test -v ./internal/service/finance/...` |
|
||||
| Full suite command | `make test` (runs `go test -v --cover ./internal/...`) |
|
||||
|
||||
### Phase Requirements → Test Map
|
||||
|
||||
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||
|--------|----------|-----------|-------------------|-------------|
|
||||
| PNL-01 | Params struct accepted with all nil/empty fields | unit | `go test -run TestQueryUserProfitLoss_EmptyParams ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| PNL-02 | Refunded orders excluded from revenue | integration | `go test -run TestQueryUserProfitLoss_RefundedOrderExcluded ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| PNL-03 | Game-pass orders classified correctly, mutually exclusive with cash | unit | `go test -run TestClassifyOrderSpending ./internal/service/finance/` | ✅ profit_metrics_test.go |
|
||||
| PNL-04 | Game-pass value = draw_count × activity_price | unit | `go test -run TestComputeGamePassValue ./internal/service/finance/` | ✅ profit_metrics_test.go |
|
||||
| PNL-05 | Prize cost includes item-card multiplier | unit | `go test -run TestComputePrizeCostWithMultiplier ./internal/service/finance/` | ✅ profit_metrics_test.go |
|
||||
| PNL-06 | Profit and profit_rate computed correctly | unit | `go test -run TestProfit ./internal/service/finance/` | ✅ profit_metrics_test.go |
|
||||
| PNL-07 | Voided inventory excluded from cost | integration | `go test -run TestQueryUserProfitLoss_VoidedInventoryExcluded ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| PNL-08 | Legacy order_id=0 inventory included in cost | integration | `go test -run TestQueryUserProfitLoss_LegacyZeroOrderID ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| DIM-01 | Empty UserIDs returns all users | integration | `go test -run TestQueryUserProfitLoss_AllUsers ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| DIM-02 | Empty ActivityIDs returns all activities | integration | `go test -run TestQueryActivityProfitLoss_AllActivities ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| DIM-03 | *time.Time nil = no time filter | unit | `go test -run TestBuildBaseQuery_NilTime ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| DIM-04 | AssetType=0 returns all types | unit | `go test -run TestQueryUserProfitLoss_AllAssetTypes ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| RET-01 | Result includes TotalRevenue, TotalCost, TotalProfit, ProfitRate | integration | `go test -run TestQueryUserProfitLoss_ResultShape ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| RET-03 | All monetary fields are int64 | compile-time | `go build ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| AST-01 | AssetType constants defined with correct values | unit | `go test -run TestAssetTypeConstants ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| QUA-01 | New files in correct package | compile-time | `go build ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| QUA-02 | No GetDbW() in finance package | static | `grep -r "GetDbW" ./internal/service/finance/` must return empty | ❌ Wave 0 |
|
||||
| QUA-03 | All Scan() errors checked | code review + test | `go test -run TestQueryUserProfitLoss_ScanError ./internal/service/finance/` | ❌ Wave 0 |
|
||||
| QUA-04 | finance.* utilities called, not reimplemented | code review | `grep -r "IsGamePassOrder\|ComputeProfit" ./internal/service/finance/query_*.go` | ❌ Wave 0 |
|
||||
| QUA-05 | Fan-out pattern: multiple Scan calls, merge in Go | code review | `grep -c "Scan" ./internal/service/finance/query_user.go` should be >= 3 | ❌ Wave 0 |
|
||||
|
||||
### Sampling Rate
|
||||
- **Per task commit:** `go test -v ./internal/service/finance/...`
|
||||
- **Per wave merge:** `make test`
|
||||
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||
|
||||
### Wave 0 Gaps
|
||||
- [ ] `internal/service/finance/service_test.go` — all integration tests using SQLiteRepoForTest
|
||||
- [ ] `internal/service/finance/service.go` — Service interface + New() constructor
|
||||
- [ ] `internal/service/finance/types.go` — AssetType enum, param structs, result types
|
||||
|
||||
*(Existing `profit_metrics_test.go` covers PNL-03, PNL-04, PNL-05, PNL-06 — no gaps for those)*
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence)
|
||||
- `internal/service/finance/profit_metrics.go` — All 6 reusable finance functions verified in source
|
||||
- `internal/service/finance/profit_metrics_test.go` — Existing test patterns confirmed
|
||||
- `internal/api/admin/dashboard_activity.go` — Fan-out query pattern, CAST(AS SIGNED), refund exclusion, game-pass stats, void exclusion confirmed at lines 146-309
|
||||
- `internal/repository/mysql/mysql.go` — Repo interface, GetDbR()/GetDbW() confirmed
|
||||
- `internal/repository/mysql/testrepo_sqlite.go` — NewSQLiteRepoForTest() confirmed
|
||||
- `internal/service/user/user.go` — Service interface + constructor pattern confirmed at lines 93-102
|
||||
- `internal/service/user/points_convert.go` — getExchangeRate pattern confirmed
|
||||
- `internal/pkg/points/convert.go` — PointsToCents/CentsToPoints confirmed
|
||||
- `internal/repository/mysql/model/user_inventory.gen.go` — UserInventory schema: value_cents, status, remark, order_id, activity_id fields
|
||||
- `internal/repository/mysql/model/user_points_ledger.gen.go` — Points ledger schema confirmed
|
||||
- `internal/repository/mysql/model/user_coupon_ledger.gen.go` — Coupon ledger schema: change_amount, order_id, action fields
|
||||
- `.planning/research/PITFALLS.md` — 6 pitfalls with codebase evidence
|
||||
- `.planning/research/STACK.md` — Query patterns and SQLite compat notes
|
||||
- `.planning/research/FEATURES.md` — Feature prioritization matrix
|
||||
|
||||
### Secondary (MEDIUM confidence)
|
||||
- `.planning/phases/01-core-pnl-functions/1-CONTEXT.md` — All locked decisions (D-01 through D-11)
|
||||
- `.planning/REQUIREMENTS.md` — Requirement definitions
|
||||
|
||||
---
|
||||
|
||||
## Metadata
|
||||
|
||||
**Confidence breakdown:**
|
||||
- Standard stack: HIGH — entire stack is existing, verified from source files
|
||||
- Architecture patterns: HIGH — all patterns lifted directly from existing codebase implementations
|
||||
- Pitfalls: HIGH — derived from existing bug-fix comments in dashboard code plus Go/MySQL behavior
|
||||
- Open questions: MEDIUM — points/coupon cost query specifics require validation against schema and business intent during implementation
|
||||
|
||||
**Research date:** 2026-03-21
|
||||
**Valid until:** 2026-06-21 (stable Go/GORM stack; schema changes would invalidate)
|
||||
77
.planning/phases/01-core-pnl-functions/01-VALIDATION.md
Normal file
77
.planning/phases/01-core-pnl-functions/01-VALIDATION.md
Normal file
@ -0,0 +1,77 @@
|
||||
---
|
||||
phase: 1
|
||||
slug: core-pnl-functions
|
||||
status: draft
|
||||
nyquist_compliant: false
|
||||
wave_0_complete: false
|
||||
created: 2026-03-21
|
||||
---
|
||||
|
||||
# Phase 1 — Validation Strategy
|
||||
|
||||
> Per-phase validation contract for feedback sampling during execution.
|
||||
|
||||
---
|
||||
|
||||
## Test Infrastructure
|
||||
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| **Framework** | go test (testify v1.11.1) |
|
||||
| **Config file** | none — existing test infrastructure via `testrepo_sqlite.go` |
|
||||
| **Quick run command** | `go test -v ./internal/service/finance/...` |
|
||||
| **Full suite command** | `go test -v --cover ./internal/service/finance/...` |
|
||||
| **Estimated runtime** | ~5 seconds |
|
||||
|
||||
---
|
||||
|
||||
## Sampling Rate
|
||||
|
||||
- **After every task commit:** Run `go test -v ./internal/service/finance/...`
|
||||
- **After every plan wave:** Run `go test -v --cover ./internal/service/finance/...`
|
||||
- **Before `/gsd:verify-work`:** Full suite must be green
|
||||
- **Max feedback latency:** 10 seconds
|
||||
|
||||
---
|
||||
|
||||
## Per-Task Verification Map
|
||||
|
||||
| Task ID | Plan | Wave | Requirement | Test Type | Automated Command | File Exists | Status |
|
||||
|---------|------|------|-------------|-----------|-------------------|-------------|--------|
|
||||
| 01-01-01 | 01 | 1 | AST-01 | unit | `go test -run TestAssetType ./internal/service/finance/...` | ❌ W0 | ⬜ pending |
|
||||
| 01-01-02 | 01 | 1 | PNL-01 | unit | `go test -run TestParams ./internal/service/finance/...` | ❌ W0 | ⬜ pending |
|
||||
| 01-01-03 | 01 | 1 | QUA-01 | unit | `go test -run TestNew ./internal/service/finance/...` | ❌ W0 | ⬜ pending |
|
||||
| 01-02-01 | 02 | 2 | DIM-01,PNL-02..08 | integration | `go test -run TestQueryUser ./internal/service/finance/...` | ❌ W0 | ⬜ pending |
|
||||
| 01-03-01 | 03 | 2 | DIM-02,PNL-02..08 | integration | `go test -run TestQueryActivity ./internal/service/finance/...` | ❌ W0 | ⬜ pending |
|
||||
|
||||
*Status: ⬜ pending · ✅ green · ❌ red · ⚠️ flaky*
|
||||
|
||||
---
|
||||
|
||||
## Wave 0 Requirements
|
||||
|
||||
- [ ] `internal/service/finance/service_test.go` — test file with SQLite setup via `NewSQLiteRepoForTest()`
|
||||
- [ ] Test helper functions for seeding orders, inventory, points, coupons test data
|
||||
|
||||
*Existing infrastructure: `testrepo_sqlite.go` provides `NewSQLiteRepoForTest()` — no framework install needed.*
|
||||
|
||||
---
|
||||
|
||||
## Manual-Only Verifications
|
||||
|
||||
| Behavior | Requirement | Why Manual | Test Instructions |
|
||||
|----------|-------------|------------|-------------------|
|
||||
| GetDbW() absence | QUA-02 | Static check | `grep -r 'GetDbW' internal/service/finance/ \| wc -l` should be 0 |
|
||||
|
||||
---
|
||||
|
||||
## Validation Sign-Off
|
||||
|
||||
- [ ] All tasks have `<automated>` verify or Wave 0 dependencies
|
||||
- [ ] Sampling continuity: no 3 consecutive tasks without automated verify
|
||||
- [ ] Wave 0 covers all MISSING references
|
||||
- [ ] No watch-mode flags
|
||||
- [ ] Feedback latency < 10s
|
||||
- [ ] `nyquist_compliant: true` set in frontmatter
|
||||
|
||||
**Approval:** pending
|
||||
113
.planning/phases/01-core-pnl-functions/1-CONTEXT.md
Normal file
113
.planning/phases/01-core-pnl-functions/1-CONTEXT.md
Normal file
@ -0,0 +1,113 @@
|
||||
# Phase 1: Core P&L Functions - Context
|
||||
|
||||
**Gathered:** 2026-03-21
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
在 `internal/service/finance/` 包下新增两个函数 `QueryUserProfitLoss` 和 `QueryActivityProfitLoss`,接收可选参数(资产类型、维度 ID、时间范围),返回含汇总 + 明细的 ProfitLossResult。正确处理 game-pass 互斥、退款排除、已作废库存排除、历史数据兼容等边界情况。
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Revenue Attribution Rules
|
||||
- **D-01:** 一个订单只对应一个活动(1:1 关系),不需要比例分摊逻辑(跳过 dashboard 中的 two-level subquery 方案)
|
||||
- **D-02:** Game-pass 收入按 draw_count × activity_unit_price 计算,每个活动独立计算
|
||||
- **D-03:** 用户维度直接汇总用户所有订单,不做跨活动分摊
|
||||
|
||||
### Function Signature Design
|
||||
- **D-04:** 两个独立的参数结构体:`UserProfitLossParams` 和 `ActivityProfitLossParams`(不共享)
|
||||
- **D-05:** 返回 `(*ProfitLossResult, error)` — Go 标准模式,error 时 result 为 nil
|
||||
- **D-06:** ProfitLossResult 包含汇总(TotalResult)+ 明细切片(`[]ProfitLossDetail`,每个元素含 UserID/ActivityID 字段)
|
||||
- **D-07:** 参数全部可选:空 []int64 = 统计全部,nil time = 不限时间,AssetType=0 = 全部类型
|
||||
|
||||
### Cost Source Mapping
|
||||
- **D-08:** 成本数据分布在多张表:user_inventory(实物/道具卡)、user_points_ledger(积分)、user_coupon_ledger(优惠券)、fragment_synthesis_logs(碎片,Phase 2)
|
||||
- **D-09:** 实物商品/道具卡成本以 `user_inventory.value_cents` 为准(单一真相源),不需要 fallback chain
|
||||
- **D-10:** 积分通过 system_configs 表中的固定汇率换算为金额(如 100积分 = 1元)
|
||||
- **D-11:** 优惠券成本 = 优惠券面值(discount_amount)
|
||||
|
||||
### Claude's Discretion
|
||||
- 具体 SQL 查询结构和 GORM 调用方式
|
||||
- ProfitLossDetail 内部字段的精确命名
|
||||
- fan-out 查询的拆分粒度和合并策略
|
||||
- 单元测试的具体用例选择
|
||||
|
||||
</decisions>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- 订单与活动是 1:1 关系,简化了活动维度的收入归属查询(不需要 dashboard 中复杂的双层子查询)
|
||||
- 复用 `finance.ClassifyOrderSpending` 做 game-pass / 现金收入分类,保持与 dashboard 计算一致
|
||||
- 积分汇率存在 system_configs KV 表中,需要在查询时动态读取
|
||||
|
||||
</specifics>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
### Finance primitives (MUST reuse)
|
||||
- `internal/service/finance/profit_metrics.go` — ClassifyOrderSpending, IsGamePassOrder, ComputeGamePassValue, ComputePrizeCostWithMultiplier, ComputeProfit
|
||||
- `internal/service/finance/profit_metrics_test.go` — Existing test patterns for finance functions
|
||||
|
||||
### Existing dashboard implementations (reference patterns, don't copy)
|
||||
- `internal/api/admin/dashboard_activity.go` — Activity-level P&L query pattern, cost query with item-card multiplier, game-pass value calculation
|
||||
- `internal/api/admin/dashboard_user_spending.go` — User-dimension spending aggregation pattern
|
||||
- `internal/api/admin/users_profit_loss.go` — User P&L trend, per-user details
|
||||
|
||||
### Data models
|
||||
- `internal/repository/mysql/model/*.gen.go` — GORM models for orders, user_inventory, user_points_ledger, user_coupon_ledger, system_configs, activities
|
||||
- `internal/repository/mysql/mysql.go` — Repo interface, GetDbR()/GetDbW() split
|
||||
|
||||
### Research findings
|
||||
- `.planning/research/PITFALLS.md` — 6 critical pitfalls with prevention strategies
|
||||
- `.planning/research/STACK.md` — Query patterns and SQLite test compatibility notes
|
||||
- `.planning/research/FEATURES.md` — Feature prioritization and dependency map
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `finance.ClassifyOrderSpending()` — Unified spending classification (game-pass vs cash)
|
||||
- `finance.IsGamePassOrder()` — Three-condition game-pass detection
|
||||
- `finance.ComputeGamePassValue()` — draw_count × activity_price
|
||||
- `finance.ComputePrizeCostWithMultiplier()` — Base cost × item-card multiplier
|
||||
- `finance.ComputeProfit()` — profit + profit_rate calculation
|
||||
- `finance.NormalizeMultiplierX1000()` — Multiplier normalization
|
||||
|
||||
### Established Patterns
|
||||
- **Service constructor:** `New(logger, db) → Service` with injected logger and repo
|
||||
- **Fan-out queries:** Multiple independent `db.Table().Scan()` calls, merge in Go via `map[int64]*Result`
|
||||
- **Read-only routing:** `repo.GetDbR()` for all read operations
|
||||
- **CAST(AS SIGNED):** Required for any SUM containing division (MySQL returns Decimal)
|
||||
- **Void exclusion:** `user_inventory.status IN (1, 3)` AND `remark NOT LIKE '%void%'`
|
||||
- **Refund exclusion:** `orders.status = 2` with legacy escape: `OR order_id = 0 OR order_id IS NULL`
|
||||
|
||||
### Integration Points
|
||||
- Constructor receives `mysql.Repo` — uses `GetDbR()` only
|
||||
- Constructor receives `logger.CustomLogger` — project-standard logger
|
||||
- Result types defined in same package — callers import `finance.ProfitLossResult`
|
||||
- system_configs table for points exchange rate lookup
|
||||
|
||||
</code_context>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- Per-asset-type breakdown (Phase 2) — struct field defined here as empty slice, populated in Phase 2
|
||||
- Fragment synthesis cost integration (Phase 2) — AST-03
|
||||
- Redis caching wrapper (v2)
|
||||
- Admin API endpoints for frontend (v2)
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 01-core-pnl-functions*
|
||||
*Context gathered: 2026-03-21*
|
||||
194
.planning/research/FEATURES.md
Normal file
194
.planning/research/FEATURES.md
Normal file
@ -0,0 +1,194 @@
|
||||
# Feature Research
|
||||
|
||||
**Domain:** Profit/Loss analytics functions — platform-perspective P&L aggregation for a game/e-commerce platform
|
||||
**Researched:** 2026-03-21
|
||||
**Confidence:** HIGH (based on direct codebase analysis of existing analytics + domain patterns)
|
||||
|
||||
---
|
||||
|
||||
## Feature Landscape
|
||||
|
||||
### Table Stakes (Users Expect These)
|
||||
|
||||
These are the non-negotiable capabilities that any reusable P&L service function must have.
|
||||
Operators calling these functions expect all of the following to "just work."
|
||||
|
||||
| Feature | Why Expected | Complexity | Notes |
|
||||
|---------|--------------|------------|-------|
|
||||
| Revenue calculation (actual_amount + discount_amount) | Core platform-perspective income; existing Dashboard already does this | LOW | Coupon discount must be added back: it's real value received |
|
||||
| Game-pass order classification | Orders with source_type=4, order_no LIKE 'GP%', or remark containing 'use_game_pass' need separate treatment | LOW | Logic already exists in `finance.IsGamePassOrder` — must be reused, not reimplemented |
|
||||
| Game-pass value derivation (draw_count × activity_price) | Zero-cash orders have economic value; existing logic computes it correctly | LOW | `finance.ComputeGamePassValue` exists; new functions must call it |
|
||||
| Prize cost calculation with item-card multiplier | Item cards double/triple prize value output; omitting multiplier understates cost | MEDIUM | `finance.ComputePrizeCostWithMultiplier` exists; value comes from `system_item_cards.reward_multiplier_x1000` |
|
||||
| Profit = spending - prize_cost | Core formula; operators see profit and profit_rate | LOW | `finance.ComputeProfit` exists and returns (int64, float64) |
|
||||
| Time-range filter (optional) | All existing dashboard analytics support time scoping | LOW | Must accept `*time.Time` for start/end; nil = all-time |
|
||||
| User-dimension aggregation (one or many user IDs) | Operators look up whale users; existing `GetUserSpendingDashboard` does single-user only | MEDIUM | New function must accept `[]int64`; empty = all users |
|
||||
| Activity-dimension aggregation (one activity ID) | Per-activity P&L is the primary ops view; `DashboardActivityProfitLoss` does this at handler level | MEDIUM | New function wraps the same logic as a reusable service method |
|
||||
| "All asset types" as default (nil asset type = all) | PROJECT.md requires all params optional | LOW | Asset-type filter is additive; absence means no filter |
|
||||
| Summary + per-asset-type breakdown in return value | Operators need total AND split by asset class | MEDIUM | Return struct must carry both `Summary` and `[]AssetBreakdown` |
|
||||
| Refund/cancelled order exclusion | Orders in status 3 (cancelled) or 4 (refunded) must NOT count as revenue | LOW | Already enforced in existing Dashboard SQL; must be replicated |
|
||||
| Voided inventory exclusion | Inventory with remark LIKE '%void%' or status=2 represents decomposed assets; must be excluded from prize cost | LOW | Pattern already established in existing queries |
|
||||
|
||||
### Differentiators (Competitive Advantage)
|
||||
|
||||
Features that go beyond what the existing dashboard provides, making the new service layer genuinely more reusable.
|
||||
|
||||
| Feature | Value Proposition | Complexity | Notes |
|
||||
|---------|-------------------|------------|-------|
|
||||
| Multi-user batch support (`[]int64` user IDs) | Existing dashboard only handles single user at a time; batch enables cross-user analytics (e.g., cohort P&L) | MEDIUM | Accept empty slice as "all users"; pass through as SQL IN clause |
|
||||
| Composable filter struct (asset type, dimension ID, time range all optional) | Callers can mix and match filters without writing bespoke queries | MEDIUM | Use a `ProfitLossFilter` options struct with pointer fields for optionality |
|
||||
| Canonical `AssetType` enum covering all 5 types | Points, coupon, item-card, physical-good, fragment — each type maps to different source tables | MEDIUM | Defining the enum properly prevents future callers guessing string/int values |
|
||||
| Per-asset-type cost tracking (not just total) | Operators want to see "how much did item-card prizes cost vs physical goods" — the Dashboard conflates them | HIGH | Requires separate GROUP BY legs or CASE-based aggregation per type |
|
||||
| Canonical spending classification reuse | New functions must call `finance.ClassifyOrderSpending` — not re-derive the rule — so calculation stays consistent everywhere | LOW | This is a correctness feature; prevents drift from the Dashboard numbers |
|
||||
| Read-only DB enforcement (`DbR`) | Statistics queries must route to the read replica; new functions must accept a `*gorm.DB` injected from the caller (already DbR-aware) | LOW | Function signature should accept `db *gorm.DB` so callers can pass `h.repo.GetDbR()` |
|
||||
|
||||
### Anti-Features (Commonly Requested, Often Problematic)
|
||||
|
||||
| Feature | Why Requested | Why Problematic | Alternative |
|
||||
|---------|---------------|-----------------|-------------|
|
||||
| Caching / memoization inside the service function | "Stats queries are slow" | The service layer is not the right place for caching; it would break test isolation and caller control over staleness | Let the HTTP handler or a future cache layer wrap the call; the function stays pure |
|
||||
| Real-time streaming / push notifications for P&L changes | "Alert me when profit drops" | Out of scope for v1 per PROJECT.md; adds event infrastructure complexity | Defer to a future monitoring milestone |
|
||||
| Automatic pagination inside the aggregate function | "Return page X of users by profit" | Pagination belongs at the API layer; the service function returning a flat result set is more composable | Callers receive the full aggregated slice and paginate themselves |
|
||||
| Reusing `DashboardActivityProfitLoss` handler logic directly | "Don't duplicate code" | The handler is tightly coupled to HTTP context, request parsing, and response formatting; pulling it into service layer would invert the dependency | New functions in `internal/service/finance/` are fresh implementations using shared `finance.*` primitives |
|
||||
| Storing computed P&L in a materialized table | "Pre-compute for speed" | Requires write access and schema migration; risks stale data bugs | Query on demand from DbR; optimize with indexes if needed later |
|
||||
| Returning string-formatted amounts (e.g. "¥12.50") | "UI-ready output" | Formatting belongs in the presentation layer; service functions should return raw int64 cents | Callers convert cents to display strings |
|
||||
|
||||
---
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
[Time-range filter]
|
||||
└──requires──> [Optional *time.Time parameters]
|
||||
|
||||
[Multi-user aggregation]
|
||||
└──requires──> [Revenue calculation]
|
||||
└──requires──> [Game-pass classification]
|
||||
└──requires──> [Prize cost with multiplier]
|
||||
└──requires──> [Refund/void exclusion]
|
||||
|
||||
[Activity-dimension aggregation]
|
||||
└──requires──> [Revenue calculation]
|
||||
└──requires──> [Game-pass classification]
|
||||
└──requires──> [Prize cost with multiplier]
|
||||
└──requires──> [Refund/void exclusion]
|
||||
|
||||
[Per-asset-type breakdown]
|
||||
└──requires──> [Canonical AssetType enum]
|
||||
└──enhances──> [User-dimension aggregation]
|
||||
└──enhances──> [Activity-dimension aggregation]
|
||||
|
||||
[Composable filter struct]
|
||||
└──enhances──> [User-dimension aggregation]
|
||||
└──enhances──> [Activity-dimension aggregation]
|
||||
|
||||
[Canonical spending classification reuse]
|
||||
└──requires──> [finance.ClassifyOrderSpending (existing)]
|
||||
└──prevents-conflict──> [Game-pass classification (must not re-derive)]
|
||||
|
||||
[Read-only DB enforcement]
|
||||
└──requires──> [Caller passes *gorm.DB from DbR]
|
||||
```
|
||||
|
||||
### Dependency Notes
|
||||
|
||||
- **Per-asset-type breakdown requires AssetType enum:** Without a canonical type definition, callers and implementations will use ad-hoc int/string values that drift.
|
||||
- **Multi-user aggregation requires all revenue/cost sub-features:** The aggregation is just a GROUP BY wrapper around the same revenue and cost logic.
|
||||
- **Canonical spending classification must reuse existing `finance.*` functions:** The existing Dashboard and the new service functions must produce identical numbers for the same data. Any divergence in classification logic breaks operator trust in the analytics.
|
||||
- **Composable filter struct enhances both dimension functions:** A `ProfitLossFilter` struct with optional fields (asset types, IDs, time range) is shared between the user-dimension and activity-dimension functions — same struct, different dimension-ID field used.
|
||||
|
||||
---
|
||||
|
||||
## MVP Definition
|
||||
|
||||
### Launch With (v1)
|
||||
|
||||
The minimum that makes both service functions useful and correct.
|
||||
|
||||
- [x] `ProfitLossFilter` struct — optional asset types, optional user/activity IDs, optional time range
|
||||
- [x] `QueryUserProfitLoss(db, filter) (ProfitLossResult, error)` — aggregates across specified user IDs
|
||||
- [x] `QueryActivityProfitLoss(db, filter) (ProfitLossResult, error)` — aggregates for a single activity ID
|
||||
- [x] `ProfitLossResult` struct — total revenue, total cost, profit, profit_rate, plus `[]AssetBreakdown`
|
||||
- [x] Canonical `AssetType` constants: Points, Coupon, ItemCard, PhysicalGood, Fragment
|
||||
- [x] Revenue calculation reusing `finance.ClassifyOrderSpending` (existing)
|
||||
- [x] Prize cost calculation reusing `finance.ComputePrizeCostWithMultiplier` (existing)
|
||||
- [x] Refund (status 3/4) and voided inventory exclusion
|
||||
- [x] Time-range filter applied consistently to both orders and inventory tables
|
||||
- [x] Unit tests covering: normal order, game-pass order, mixed, empty result, nil filter
|
||||
|
||||
### Add After Validation (v1.x)
|
||||
|
||||
- [ ] Per-asset-type breakdown populated (requires extending SQL GROUP BY or running separate legs per type)
|
||||
- Trigger: ops team requests drill-down beyond total numbers
|
||||
- [ ] Fragment asset type cost integration via `fragment_synthesis_logs`
|
||||
- Trigger: fragment economy becomes significant in platform revenue reports
|
||||
- [ ] Batch activity IDs support (`[]int64` activity IDs, not just one)
|
||||
- Trigger: ops needs cross-activity comparison in a single call
|
||||
|
||||
### Future Consideration (v2+)
|
||||
|
||||
- [ ] Caching wrapper (Redis TTL-based) around the query functions
|
||||
- Defer: not needed until query latency becomes user-visible (>2s)
|
||||
- [ ] Incremental / time-bucketed aggregation (daily snapshots stored in a stats table)
|
||||
- Defer: requires schema additions and migration planning
|
||||
- [ ] Douyin (livestream) order integration into the user-dimension function
|
||||
- Defer: currently only in the HTTP-layer spending leaderboard; integrating it requires joining `douyin_orders` which adds complexity and is outside the 5 declared asset types
|
||||
|
||||
---
|
||||
|
||||
## Feature Prioritization Matrix
|
||||
|
||||
| Feature | Operator Value | Implementation Cost | Priority |
|
||||
|---------|---------------|---------------------|----------|
|
||||
| Revenue calculation (reuse existing `finance.*`) | HIGH | LOW | P1 |
|
||||
| Game-pass classification (reuse existing) | HIGH | LOW | P1 |
|
||||
| Prize cost with multiplier (reuse existing) | HIGH | LOW | P1 |
|
||||
| Refund/void exclusion | HIGH | LOW | P1 |
|
||||
| Time-range filter | HIGH | LOW | P1 |
|
||||
| User-dimension aggregation | HIGH | MEDIUM | P1 |
|
||||
| Activity-dimension aggregation | HIGH | MEDIUM | P1 |
|
||||
| `ProfitLossFilter` composable struct | HIGH | LOW | P1 |
|
||||
| `ProfitLossResult` with Summary + Breakdown | HIGH | LOW | P1 |
|
||||
| Canonical `AssetType` enum | MEDIUM | LOW | P1 |
|
||||
| Multi-user batch ([]int64) | MEDIUM | LOW | P1 |
|
||||
| Per-asset-type breakdown (5 types) | MEDIUM | HIGH | P2 |
|
||||
| Fragment synthesis cost integration | LOW | MEDIUM | P2 |
|
||||
| Batch activity IDs support | LOW | LOW | P2 |
|
||||
| Read-only DB routing enforcement | HIGH | LOW | P1 (design constraint, not optional) |
|
||||
|
||||
**Priority key:**
|
||||
- P1: Must have for launch — without these the functions are not useful or correct
|
||||
- P2: Should have — adds analytical depth, add when P1 is proven
|
||||
- P3: Nice to have — future milestone
|
||||
|
||||
---
|
||||
|
||||
## Competitor Feature Analysis
|
||||
|
||||
This is an internal platform analytics function, not a user-facing product.
|
||||
The relevant "competition" is the existing Dashboard code that this service layer must be consistent with and eventually replace as the canonical source of truth.
|
||||
|
||||
| Feature | Existing Dashboard (DashboardActivityProfitLoss) | Existing Dashboard (GetUserSpendingDashboard) | New Service Functions |
|
||||
|---------|-------------------------------------------------|----------------------------------------------|----------------------|
|
||||
| Reusability | None — HTTP handler only | None — HTTP handler only | Core goal: callable from anywhere |
|
||||
| Multi-user support | No — activity-scoped | No — single user ID only | Yes — []int64 user IDs |
|
||||
| Asset-type breakdown | Implicit (physical goods via inventory) | Implicit | Explicit enum + breakdown slice |
|
||||
| Time-range | Not supported | Supported | Supported (optional) |
|
||||
| Spending classification | Inline SQL CASE | Inline SQL CASE | Calls `finance.ClassifyOrderSpending` |
|
||||
| Douyin/livestream | Not included | Included (separate leg) | Out of scope for v1 |
|
||||
| Calculation consistency | Source of truth today | Source of truth today | Must match exactly |
|
||||
| Fragment asset type | Not supported | Not supported | Enum defined; cost TBD in v1.x |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Direct analysis of `/internal/service/finance/profit_metrics.go` — existing shared primitives
|
||||
- Direct analysis of `/internal/api/admin/dashboard_activity.go` — activity P&L implementation
|
||||
- Direct analysis of `/internal/api/admin/dashboard_spending.go` — user spending leaderboard
|
||||
- Direct analysis of `/internal/api/admin/dashboard_user_spending.go` — per-user spending drill-down
|
||||
- Direct analysis of GORM models: `orders`, `user_inventory`, `user_points_ledger`, `user_coupon_ledger`, `fragment_synthesis_logs`
|
||||
- PROJECT.md requirements (validated requirements section)
|
||||
|
||||
---
|
||||
*Feature research for: Bindbox Game profit/loss analytics service layer*
|
||||
*Researched: 2026-03-21*
|
||||
287
.planning/research/PITFALLS.md
Normal file
287
.planning/research/PITFALLS.md
Normal file
@ -0,0 +1,287 @@
|
||||
# Pitfalls Research
|
||||
|
||||
**Domain:** Go/GORM/MySQL financial analytics — profit/loss aggregation functions
|
||||
**Researched:** 2026-03-21
|
||||
**Confidence:** HIGH (derived directly from existing codebase evidence + confirmed Go/MySQL behavior)
|
||||
|
||||
---
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### Pitfall 1: MySQL SUM with Division Returns Decimal, Not SIGNED Integer
|
||||
|
||||
**What goes wrong:**
|
||||
When a `SUM()` expression includes any division operation (e.g., `SUM(amount * draw_count / total_count)`), MySQL returns the result as a `Decimal` type, not `BIGINT`. Scanning a Decimal into a Go `int64` field silently returns `0`. The dashboard code already hit this and left a comment documenting it.
|
||||
|
||||
Evidence from `dashboard_activity.go:174`:
|
||||
```
|
||||
// 注意: MySQL SUM()运算涉及除法时会返回Decimal类型,需要Scan到float64
|
||||
```
|
||||
The fix used there: scan revenue stats into `float64`, then cast to `int64` in Go.
|
||||
|
||||
**Why it happens:**
|
||||
MySQL promotes arithmetic involving division to Decimal to preserve fractional precision. GORM's `Scan()` does not coerce types — it matches Go field types exactly, and `int64` ≠ Decimal causes a silent zero.
|
||||
|
||||
**How to avoid:**
|
||||
Wrap any `SUM` that contains division with `CAST(... AS SIGNED)` in the SQL itself. This forces integer rounding at the database layer and lets you scan directly into `int64`. The existing cost query in `dashboard_activity.go:237` already uses this pattern:
|
||||
```sql
|
||||
CAST(SUM(...) AS SIGNED) as total_cost
|
||||
```
|
||||
Use `CAST(... AS SIGNED)` on every aggregated column that involves division. Never scan division-containing SUM results directly into `int64` without the cast.
|
||||
|
||||
**Warning signs:**
|
||||
- Aggregated monetary fields come back as `0` even when data exists
|
||||
- Revenue stats are non-zero but cost stats are zero (or vice versa)
|
||||
- Struct fields stay at their zero values after `Scan()`
|
||||
|
||||
**Phase to address:** Implementation phase — apply during every query that uses proportional allocation (e.g., distributing an order's revenue across multiple activities via `draw_count / total_count`).
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 2: Double-Counting Revenue When One Order Spans Multiple Activities
|
||||
|
||||
**What goes wrong:**
|
||||
A single order can result in draw logs across multiple activities (e.g., a user plays activity A and activity B in one checkout). If you `SUM(orders.actual_amount)` grouped by activity without proportional allocation, the full order amount is counted in every activity it touches. The existing dashboard already experienced this and added two-level subquery attribution.
|
||||
|
||||
Evidence from `dashboard_activity.go:197-212`: the fix was to compute `draw_count per (order, activity)` and `total_count per order` in two separate subqueries, then scale the order amount by the ratio `draw_count / total_count`.
|
||||
|
||||
**Why it happens:**
|
||||
Aggregation joins `orders` to `activity_draw_logs` which is a one-to-many relationship. Without explicit proration, the order amount fans out to every matching activity row.
|
||||
|
||||
**How to avoid:**
|
||||
Always attribute revenue using the subquery pattern:
|
||||
```sql
|
||||
JOIN (
|
||||
SELECT order_id, activity_id, COUNT(*) as draw_count
|
||||
FROM activity_draw_logs JOIN activity_issues ON ...
|
||||
GROUP BY order_id, activity_id
|
||||
) as order_activity_draws ON order_activity_draws.order_id = orders.id
|
||||
JOIN (
|
||||
SELECT order_id, COUNT(*) as total_count
|
||||
FROM activity_draw_logs GROUP BY order_id
|
||||
) as order_total_draws ON order_total_draws.order_id = orders.id
|
||||
```
|
||||
Then multiply: `orders.actual_amount * order_activity_draws.draw_count / order_total_draws.total_count`. For the user-dimension function, this pattern still applies if a user's order touches multiple issues.
|
||||
|
||||
**Warning signs:**
|
||||
- Total revenue across all activities exceeds the sum of all actual order payments
|
||||
- A user's computed spending is greater than what WeChat Pay received
|
||||
- Profit rates are implausibly negative across many activities
|
||||
|
||||
**Phase to address:** Implementation phase — design the user-dimension and activity-dimension query structure before writing SQL.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 3: Mixing Game-Pass Orders into Cash Revenue (Calculation Mouth-Discrepancy)
|
||||
|
||||
**What goes wrong:**
|
||||
Game-pass orders (次卡) have `actual_amount = 0` and `source_type = 4` (or `order_no LIKE 'GP%'` or remark containing `use_game_pass`). Including them in `SUM(actual_amount + discount_amount)` makes their "revenue" appear as zero, understating total income. Including them in cost without crediting their imputed value makes every game-pass activity show a loss.
|
||||
|
||||
The codebase defines three detection conditions in `internal/service/finance/profit_metrics.go:IsGamePassOrder`. These must all be checked — any single condition is insufficient because historical data uses different conventions.
|
||||
|
||||
**Why it happens:**
|
||||
Game-pass orders are structurally identical to regular orders but have zero monetary value. Treating all orders uniformly by summing `actual_amount` misses the imputed value of the subscription the user already paid.
|
||||
|
||||
**How to avoid:**
|
||||
Use strict mutual exclusion in SQL:
|
||||
- If game-pass order: revenue = `draw_count * activity.price_draw`, discount = 0, cash = 0
|
||||
- If cash/coupon order: revenue = `actual_amount + discount_amount`, game-pass value = 0
|
||||
- Use `CASE WHEN (source_type=4 OR order_no LIKE 'GP%' OR (actual_amount=0 AND remark LIKE '%use_game_pass%')) THEN ... ELSE ...` in every SUM
|
||||
|
||||
Never add `actual_amount + discount_amount + game_pass_value` as if they are additive columns of the same thing. They are alternative values for the same economic event.
|
||||
|
||||
**Warning signs:**
|
||||
- Activities with many game-pass players show profit rates near -100%
|
||||
- Total platform revenue is suspiciously lower than WeChat Pay reports
|
||||
- `SpendingPaidCoupon` and `SpendingGamePass` are both non-zero for the same order
|
||||
|
||||
**Phase to address:** Implementation phase — encode the mutual-exclusion rule in query construction helpers before writing any aggregate SQL.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 4: Silently Ignoring Scan Errors on Aggregation Queries
|
||||
|
||||
**What goes wrong:**
|
||||
Several existing dashboard queries call `db.Table(...).Select(...).Scan(&stats)` without checking the returned error. If the query fails (schema mismatch, column rename, database failover), `stats` remains an empty slice, downstream computations produce zero results, and no error is returned to the caller. The data looks correct (all zeros) rather than erroring.
|
||||
|
||||
Evidence from `dashboard_activity.go:146-158` — `drawStats` scan has no `.Error` check. The pattern appears in multiple places throughout the dashboard handlers.
|
||||
|
||||
**Why it happens:**
|
||||
GORM's method chaining makes it easy to forget error handling. The pattern `db.Table(...).Scan(&x)` is syntactically identical whether you check `.Error` or not. In exploratory handler code that was never tested, errors were skipped for brevity.
|
||||
|
||||
**How to avoid:**
|
||||
The new `internal/service/finance/` package must check every query error:
|
||||
```go
|
||||
if err := db.Table(...).Scan(&result).Error; err != nil {
|
||||
return nil, fmt.Errorf("profit_loss query failed: %w", err)
|
||||
}
|
||||
```
|
||||
Service functions should return `error` as second return value — not swallow errors internally. The existing `profit_metrics.go` pure functions have no DB access and are fine; the DB-querying functions must propagate errors.
|
||||
|
||||
**Warning signs:**
|
||||
- Function returns zero values with no error in tests against an empty SQLite db
|
||||
- Aggregation results are uniformly zero across all parameters
|
||||
- Schema changes (column renames, table renames) cause silent failures
|
||||
|
||||
**Phase to address:** Implementation phase — establish error-check convention in the first function written; testing phase — assert non-nil error on deliberately broken queries.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 5: Omitting Refunded Orders from Cost Calculation
|
||||
|
||||
**What goes wrong:**
|
||||
Inventory items (`user_inventory`) awarded from a subsequently refunded order should be excluded from cost. If you compute cost by summing `user_inventory.value_cents` grouped by `activity_id` without filtering on `orders.status`, you count the cost of prizes from refunded orders but don't count their revenue — making the platform appear to have given away prizes for free.
|
||||
|
||||
The existing code in `dashboard_activity.go:250-251` already had to special-case this:
|
||||
```go
|
||||
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
|
||||
```
|
||||
Note the legacy data escape hatch: some old inventory rows have `order_id = 0` or NULL and cannot be filtered by order status. This must be preserved.
|
||||
|
||||
**Why it happens:**
|
||||
`user_inventory` records are created when prizes are awarded, which happens before the refund window closes. Refunds do not delete inventory rows — they update `orders.status` to 4. Naive aggregation on `user_inventory` ignores order status entirely.
|
||||
|
||||
**How to avoid:**
|
||||
Always join `orders` to `user_inventory` via `order_id` and include the legacy escape hatch:
|
||||
```sql
|
||||
LEFT JOIN orders ON orders.id = user_inventory.order_id
|
||||
WHERE (orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)
|
||||
AND COALESCE(user_inventory.remark, '') NOT LIKE '%void%'
|
||||
```
|
||||
The `void` remark filter is also required — manually voided inventory entries should never count as platform cost.
|
||||
|
||||
**Warning signs:**
|
||||
- Platform cost is higher than expected for activities with known refund activity
|
||||
- Cost-side totals don't reconcile with accounting system data
|
||||
- Test cases with a refunded order still show non-zero cost
|
||||
|
||||
**Phase to address:** Implementation phase — add a test case with a refunded order and verify cost = 0 for that order's prizes.
|
||||
|
||||
---
|
||||
|
||||
### Pitfall 6: Using Write DB (DbW) for Analytics Queries
|
||||
|
||||
**What goes wrong:**
|
||||
The project has master-slave read-write splitting. Analytics queries that run on `GetDbW()` (master) instead of `GetDbR()` (replica) add latency to the write path, can block replication, and in the worst case cause master overload under concurrent analytics requests.
|
||||
|
||||
The CONCERNS.md already flags 113 direct `GetDbW()` calls in the handler layer. The pattern of bypassing the correct DB connection is established in the codebase and can propagate to new code.
|
||||
|
||||
**Why it happens:**
|
||||
`GetDbR()` and `GetDbW()` look identical in usage. Developers copying from handler code that was written for writes will use `GetDbW()` by accident. The finance service package does not yet have established conventions.
|
||||
|
||||
**How to avoid:**
|
||||
The new `internal/service/finance/` service must accept a `*gorm.DB` read-only handle at construction time (inject `repo.GetDbR()`), not a full repository. Document in the function signatures or struct fields that only the read replica is used:
|
||||
```go
|
||||
type ProfitLossService struct {
|
||||
dbR *gorm.DB // read replica only — never use for writes
|
||||
logger *zap.Logger
|
||||
}
|
||||
```
|
||||
Never call `repo.GetDbW()` inside finance analytics functions.
|
||||
|
||||
**Warning signs:**
|
||||
- MySQL master replication lag increases when analytics endpoint is called
|
||||
- Write latency spikes during dashboard loads
|
||||
- `GetDbW()` appears in `internal/service/finance/` source files
|
||||
|
||||
**Phase to address:** Implementation phase — inject read-only DB handle in constructor; testing phase — verify with a mock that only the read DB is called.
|
||||
|
||||
---
|
||||
|
||||
## Technical Debt Patterns
|
||||
|
||||
| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|
||||
|----------|-------------------|----------------|-----------------|
|
||||
| Scan revenue into `float64` instead of fixing SQL with `CAST(AS SIGNED)` | Avoids SQL rewrite | Floating-point rounding on monetary values (e.g., 0.1 + 0.2 ≠ 0.3 in IEEE 754) | Never for monetary fields — always use `CAST(AS SIGNED)` |
|
||||
| In-memory sort + full table fetch for custom sort order | Simpler than `ORDER BY` with computed columns | Loads unbounded rows into Go heap when activity count grows | Only acceptable if total row count is bounded by pagination elsewhere |
|
||||
| Hardcoding game-pass detection conditions in each query | Avoids abstraction overhead | Three different detection conditions must stay in sync across multiple queries | Never — centralize detection in `IsGamePassOrder()` already defined in `finance` package |
|
||||
| Skip error check on `Scan()` | Fewer lines of code | Silent wrong data; impossible to distinguish "query returned zero rows" from "query failed" | Never for financial data |
|
||||
| Use `AVG(multiplier)` across draws as the cost multiplier | One query instead of per-row | Hides per-order multiplier variance; a 2x card on one draw inflates cost for all draws in the group | Acceptable for summary statistics; not for per-order breakdowns |
|
||||
|
||||
---
|
||||
|
||||
## Integration Gotchas
|
||||
|
||||
| Integration | Common Mistake | Correct Approach |
|
||||
|-------------|----------------|-----------------|
|
||||
| GORM `Scan` into anonymous struct | Forgetting to qualify column names in SELECT causes ambiguous column error when multiple tables have `id`, `created_at`, etc. | Always alias computed columns explicitly: `SELECT orders.user_id as user_id`, not `SELECT user_id` |
|
||||
| GORM raw SQL with `Raw()` + `Scan()` | Parameterized values passed in wrong order cause SQL to silently use zero values | Verify query with `db.Statement.SQL.String()` during development; test with non-trivial input values |
|
||||
| MySQL `COALESCE` with nullable int columns | `COALESCE(NULL, 0)` works but `COALESCE(column, 0)` on a non-nullable column with value `0` returns `0` — `NULLIF` needed to distinguish "not set" from "explicitly zero" | Use `COALESCE(NULLIF(value_cents, 0), fallback_1, fallback_2, 0)` pattern already established in existing cost queries |
|
||||
| Multiple ID lists in `WHERE IN (?)` with GORM | Passing an empty slice `[]int64{}` produces invalid SQL `WHERE id IN ()` in some GORM versions | Guard with `if len(ids) == 0 { return emptyResult, nil }` before building the query |
|
||||
| Read replica lag | Querying replica immediately after a write (e.g., after seeding test data) can return stale results | In tests, use write DB handle or wait for sync; in production, this is acceptable for analytics |
|
||||
|
||||
---
|
||||
|
||||
## Performance Traps
|
||||
|
||||
| Trap | Symptoms | Prevention | When It Breaks |
|
||||
|------|----------|------------|----------------|
|
||||
| Fetching all activities before computing profit/loss (no predicate pushdown) | 100% CPU on `Find(&activities)`, slow response time | Apply all filters (status, name, date range) in the initial `query` before scanning, then pass `activityIDs` to subsequent queries | When activity count exceeds ~1,000 |
|
||||
| Correlated subquery inside SUM for every row | Query time grows O(n²) with draw log volume | Pre-aggregate into a derived table subquery joined once, not per-row | When draw_logs table exceeds ~500K rows |
|
||||
| No index on `activity_draw_logs.order_id` or `user_inventory.activity_id` | Sequential scan on every analytics query | Verify indexes exist with `SHOW INDEX FROM activity_draw_logs`; add composite index `(issue_id, order_id)` if missing | From day one on tables with writes |
|
||||
| Loading all activities into memory for in-application sort | Memory spike on large result sets; no benefit if caller only wants top-10 | Accept this tradeoff only when total activities < 500; add a hard cap with an error if exceeded | When activity count exceeds ~500 |
|
||||
| Querying `user_inventory` without `status IN (1, 3)` filter | Voided/cancelled inventory items inflate cost | Always filter: `WHERE user_inventory.status IN (1, 3)` | Immediately — even small void counts distort cost |
|
||||
|
||||
---
|
||||
|
||||
## Security Mistakes
|
||||
|
||||
| Mistake | Risk | Prevention |
|
||||
|---------|------|------------|
|
||||
| Interpolating user-supplied `user_id` or `activity_id` into raw SQL string instead of parameterized query | SQL injection — attacker can exfiltrate all financial data | Always use parameterized queries: `.Where("user_id IN ?", ids)` not `fmt.Sprintf("user_id IN (%s)", idsStr)` |
|
||||
| Exposing raw profit/loss data without admin role check | Non-admin users can read platform margin data | The new service functions are Service layer — callers (API handlers) must apply `RequireAdminRole()` middleware; document this requirement in the function's GoDoc |
|
||||
| Logging query parameters that contain user IDs | User ID lists in error logs can be correlated with financial data | Log query failure with a count, not the full ID list: `"profit_loss query failed for %d users: %v"` |
|
||||
|
||||
---
|
||||
|
||||
## "Looks Done But Isn't" Checklist
|
||||
|
||||
- [ ] **Game-pass mutual exclusion:** Verify that `SpendingPaidCoupon` and `SpendingGamePass` are never both non-zero for the same order. Write a test case with a mixed-type order set.
|
||||
- [ ] **Refunded order exclusion:** Add a test case where an order is refunded (status=4) and verify it contributes zero to both revenue and cost.
|
||||
- [ ] **Legacy zero order_id:** Confirm inventory rows with `order_id = 0` are included in cost (not excluded by the orders JOIN). Add a test row with `order_id = 0` and verify it appears in cost.
|
||||
- [ ] **Empty parameter handling:** Call both functions with nil/empty `userIDs` and nil/empty `activityID` — verify they return all-data aggregation, not empty results or SQL errors.
|
||||
- [ ] **All five asset types covered:** Points, coupons, item cards, physical products, fragments. Verify all five appear in the breakdown output. Missing one silently understates cost.
|
||||
- [ ] **CAST on division SUM:** Open every query with a `/` operator in a SUM and confirm `CAST(... AS SIGNED)` wraps the entire expression.
|
||||
- [ ] **Read-only DB used:** Grep for `GetDbW` inside `internal/service/finance/` — result must be empty.
|
||||
- [ ] **Error propagation:** Every `Scan()` call inside finance functions must have its `.Error` checked and returned to the caller.
|
||||
|
||||
---
|
||||
|
||||
## Recovery Strategies
|
||||
|
||||
| Pitfall | Recovery Cost | Recovery Steps |
|
||||
|---------|---------------|----------------|
|
||||
| Decimal-to-int64 silent zero | LOW | Add `CAST(AS SIGNED)` to affected SQL; rerun query — no data migration needed |
|
||||
| Revenue double-counting discovered post-launch | MEDIUM | Backfill correct totals by recomputing with fixed query over historical data; notify operators of corrected figures |
|
||||
| Wrong DB handle (write instead of read) | LOW | Change constructor injection; no data impact |
|
||||
| Missing refund exclusion | MEDIUM | Recompute affected period's profit/loss with corrected query; mark old reports as superseded |
|
||||
| Silently swallowed errors causing wrong zeros | LOW-MEDIUM | Add error checks; add alerting on zero-result aggregations where data is expected; audit logs for the affected period |
|
||||
|
||||
---
|
||||
|
||||
## Pitfall-to-Phase Mapping
|
||||
|
||||
| Pitfall | Prevention Phase | Verification |
|
||||
|---------|-----------------|--------------|
|
||||
| Decimal/int64 scan mismatch | Implementation — SQL design | Integration test: query with division-containing SUM, assert non-zero int64 result |
|
||||
| Revenue double-counting | Implementation — query structure design | Test: one order across two activities; assert sum of per-activity revenue equals order total |
|
||||
| Game-pass mutual exclusion | Implementation — use `IsGamePassOrder()` helper | Unit test: game-pass order contributes to `SpendingGamePass` only, not `SpendingPaidCoupon` |
|
||||
| Ignored Scan errors | Implementation — code review gate | Test: deliberately broken query (wrong table name); assert returned error is non-nil |
|
||||
| Refunded order in cost | Implementation — WHERE clause | Test: refunded order inventory; assert cost contribution is zero |
|
||||
| Write DB used | Implementation — constructor injection | Grep check in CI: `GetDbW` must not appear in `internal/service/finance/` |
|
||||
| Missing LIMIT on supporting queries | Implementation — query design | Load test with 1000 activities; verify response time stays under 2s |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- `internal/api/admin/dashboard_activity.go` — direct evidence of BUG FIX comments for Decimal/int64, double-counting, game-pass misclassification (lines 173-175, 274-275, 544-545)
|
||||
- `internal/api/admin/dashboard_spending.go` — evidence of multi-join aggregation patterns and game-pass CASE expressions
|
||||
- `internal/service/finance/profit_metrics.go` — `IsGamePassOrder()` three-condition detection; `ComputeProfit()` integer arithmetic; established pattern for cost multiplier
|
||||
- `internal/service/finance/profit_metrics_test.go` — existing test coverage confirming pure-function behavior
|
||||
- `.planning/codebase/CONCERNS.md` — flagged 113 `GetDbW()` calls in handler layer, silently swallowed errors in financial paths, missing error checks in `pay_refund_admin.go`
|
||||
- Go `database/sql` specification — `Scan()` does not coerce types; MySQL 8.x documentation — SUM with division promotes to Decimal
|
||||
|
||||
---
|
||||
*Pitfalls research for: Go/GORM/MySQL profit/loss analytics (Bindbox Game)*
|
||||
*Researched: 2026-03-21*
|
||||
354
.planning/research/STACK.md
Normal file
354
.planning/research/STACK.md
Normal file
@ -0,0 +1,354 @@
|
||||
# Technology Stack
|
||||
|
||||
**Project:** Bindbox Game — Profit/Loss Analytics Functions
|
||||
**Researched:** 2026-03-21
|
||||
**Scope:** Service-layer multi-dimensional financial aggregation in an existing Go 1.24 / GORM 1.25 / MySQL project
|
||||
|
||||
---
|
||||
|
||||
## Existing Stack (Confirmed from Codebase)
|
||||
|
||||
The following are already in use and must not be replaced or duplicated.
|
||||
|
||||
| Layer | Technology | Version | Notes |
|
||||
|-------|-----------|---------|-------|
|
||||
| Language | Go | 1.24.0 | toolchain go1.24.2 |
|
||||
| ORM | gorm.io/gorm | 1.25.9 | with `gorm.io/gen v0.3.26` |
|
||||
| Database | MySQL | 8.x (inferred) | read/write split via `gorm.io/plugin/dbresolver` |
|
||||
| DB driver | github.com/go-sql-driver/mysql | 1.7.1 | |
|
||||
| Logger | go.uber.org/zap (wrapped) | 1.26.0 | project custom `logger.CustomLogger` interface |
|
||||
| Test DB | gorm.io/driver/sqlite | 1.4.3 | in-memory SQLite via `NewSQLiteRepoForTest()` |
|
||||
| Test assertions | github.com/stretchr/testify | 1.11.1 | |
|
||||
| SQL mock | github.com/DATA-DOG/go-sqlmock | 1.5.2 | |
|
||||
|
||||
No new runtime dependencies are required for this milestone.
|
||||
|
||||
---
|
||||
|
||||
## Recommended Patterns for Analytics Functions
|
||||
|
||||
### 1. Query Execution: `db.Raw()` + Named Scan Struct for Complex Aggregations
|
||||
|
||||
**Confidence: HIGH** (verified from existing codebase usage in `dashboard_activity.go`, `dashboard_spending.go`)
|
||||
|
||||
The project already uses two GORM query styles:
|
||||
|
||||
**Style A — GORM builder with `.Select()` + `.Scan()`** (for joins + GROUP BY with multiple aggregated columns):
|
||||
|
||||
```go
|
||||
type revenueRow struct {
|
||||
DimensionID int64
|
||||
TotalRevenue float64
|
||||
TotalCost int64
|
||||
}
|
||||
var rows []revenueRow
|
||||
|
||||
db.Table(model.TableNameOrders).
|
||||
Select(`
|
||||
orders.user_id as dimension_id,
|
||||
SUM(...) as total_revenue,
|
||||
SUM(...) as total_cost
|
||||
`).
|
||||
Joins("LEFT JOIN ...").
|
||||
Where("orders.status = ?", 2).
|
||||
Group("orders.user_id").
|
||||
Scan(&rows)
|
||||
```
|
||||
|
||||
Use this style when:
|
||||
- Grouping by a single dimension (user_id, activity_id)
|
||||
- The aggregation fits in one SQL pass
|
||||
- The query does not require correlated subqueries that GORM cannot model
|
||||
|
||||
**Style B — `db.Raw()` + `.Scan()`** (for queries with inline derived tables / CTEs):
|
||||
|
||||
```go
|
||||
db.Raw(`
|
||||
SELECT user_id, SUM(revenue) as total_revenue
|
||||
FROM (
|
||||
SELECT user_id, actual_amount + discount_amount as revenue
|
||||
FROM orders WHERE status = 2
|
||||
) t
|
||||
WHERE user_id IN (?)
|
||||
GROUP BY user_id
|
||||
`, userIDs).Scan(&rows)
|
||||
```
|
||||
|
||||
Use this style when:
|
||||
- The query has two or more levels of subqueries
|
||||
- GORM's builder would produce ambiguous `deleted_at` injection (known GORM pitfall, already documented in `dashboard_activity.go` comments)
|
||||
- Conditional aggregation across multiple joins is complex enough to be unmaintainable in builder form
|
||||
|
||||
**Recommendation:** Use Style A (builder) as the default. Drop to Style B only when builder clarity degrades — which happens when the query has more than 2 subquery levels.
|
||||
|
||||
---
|
||||
|
||||
### 2. Service Constructor Pattern
|
||||
|
||||
**Confidence: HIGH** (established project pattern)
|
||||
|
||||
All services follow this exact signature:
|
||||
|
||||
```go
|
||||
package finance
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
)
|
||||
|
||||
type Service interface {
|
||||
QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)
|
||||
QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger logger.CustomLogger
|
||||
readDB *dao.Query // analytics always use read replica
|
||||
repo mysql.Repo // for direct *gorm.DB access when needed
|
||||
}
|
||||
|
||||
func New(l logger.CustomLogger, db mysql.Repo) Service {
|
||||
return &service{
|
||||
logger: l,
|
||||
readDB: dao.Use(db.GetDbR()),
|
||||
repo: db,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Always use `db.GetDbR()` for analytics (read replica). Never use `GetDbW()` in the new `finance` service. The `writeDB` field should not exist in this service.
|
||||
|
||||
---
|
||||
|
||||
### 3. Multi-Dimensional Aggregation: Fan-Out + In-Memory Merge
|
||||
|
||||
**Confidence: HIGH** (established by `DashboardPlayerSpendingLeaderboard` and `DashboardActivityProfitLoss`)
|
||||
|
||||
The codebase consistently uses this pattern for analytics requiring data from multiple tables:
|
||||
|
||||
1. Fetch the primary dimension IDs in one query (user IDs or activity IDs)
|
||||
2. Execute N parallel scan queries — one per data source (orders, inventory, draw_logs, etc.)
|
||||
3. Accumulate results into a `map[int64]*ResultItem`
|
||||
4. Apply in-memory business logic (e.g. `ComputeProfit`, `ClassifyOrderSpending`)
|
||||
5. Return the merged result
|
||||
|
||||
```go
|
||||
// Step 1: collect IDs
|
||||
dimensionIDs := []int64{...}
|
||||
|
||||
// Step 2: fan out — each scan targets one logical data source
|
||||
var revRows []revRow
|
||||
db.Table(...).Select(...).Where("user_id IN ?", dimensionIDs).Group("user_id").Scan(&revRows)
|
||||
|
||||
var costRows []costRow
|
||||
db.Table(...).Select(...).Where("user_id IN ?", dimensionIDs).Group("user_id").Scan(&costRows)
|
||||
|
||||
// Step 3: merge into map
|
||||
resultMap := make(map[int64]*ProfitLossResult)
|
||||
for _, r := range revRows {
|
||||
resultMap[r.UserID].Revenue = r.Total
|
||||
}
|
||||
for _, c := range costRows {
|
||||
resultMap[c.UserID].Cost = c.Total
|
||||
}
|
||||
|
||||
// Step 4: apply finance functions
|
||||
for _, item := range resultMap {
|
||||
item.Profit, item.ProfitRate = financesvc.ComputeProfit(item.Revenue, item.Cost)
|
||||
}
|
||||
```
|
||||
|
||||
**Why this approach over a single mega-JOIN:**
|
||||
- Avoids Cartesian products when joining tables with 1-to-many relationships (draw_logs × inventory × orders)
|
||||
- Individual queries are independently cacheable in future
|
||||
- Easier to test each data segment in isolation
|
||||
- Avoids MySQL's `GROUP BY` optimizer struggling with multi-table fan-out
|
||||
|
||||
---
|
||||
|
||||
### 4. Optional Parameter Pattern with Struct
|
||||
|
||||
**Confidence: HIGH** (aligns with project idiom and Go best practices for analytics functions)
|
||||
|
||||
The new functions must accept all-optional parameters (no asset type = all types, no IDs = all records, no time range = all time). Use a plain struct — not variadic options or functional options — consistent with how the project already expresses request inputs:
|
||||
|
||||
```go
|
||||
// AssetType constants — defined in finance package
|
||||
type AssetType int
|
||||
|
||||
const (
|
||||
AssetTypeAll AssetType = 0 // zero value = "all types"
|
||||
AssetTypePoints AssetType = 1
|
||||
AssetTypeCoupon AssetType = 2
|
||||
AssetTypeItemCard AssetType = 3
|
||||
AssetTypeProduct AssetType = 4
|
||||
AssetTypeFragment AssetType = 5
|
||||
)
|
||||
|
||||
type UserProfitLossParams struct {
|
||||
AssetTypes []AssetType // empty = all types
|
||||
UserIDs []int64 // empty = all users
|
||||
StartTime *time.Time // nil = no lower bound
|
||||
EndTime *time.Time // nil = no upper bound
|
||||
}
|
||||
|
||||
type ActivityProfitLossParams struct {
|
||||
AssetTypes []AssetType // empty = all types
|
||||
ActivityIDs []int64 // empty = all activities
|
||||
StartTime *time.Time
|
||||
EndTime *time.Time
|
||||
}
|
||||
```
|
||||
|
||||
Do NOT use `time.Time` zero values as sentinels — pointer semantics make optionality explicit and avoid the zero-time edge case in GORM queries.
|
||||
|
||||
---
|
||||
|
||||
### 5. Result Type Design
|
||||
|
||||
**Confidence: HIGH** (matches the finance domain model already in place)
|
||||
|
||||
```go
|
||||
// ProfitLossBreakdown is one asset-type slice within the result.
|
||||
type ProfitLossBreakdown struct {
|
||||
AssetType AssetType `json:"asset_type"`
|
||||
Revenue int64 `json:"revenue"` // platform income (fen)
|
||||
Cost int64 `json:"cost"` // prize cost (fen)
|
||||
Profit int64 `json:"profit"` // revenue - cost (fen)
|
||||
}
|
||||
|
||||
// ProfitLossResult is returned by both dimension functions.
|
||||
type ProfitLossResult struct {
|
||||
TotalRevenue int64 `json:"total_revenue"`
|
||||
TotalCost int64 `json:"total_cost"`
|
||||
TotalProfit int64 `json:"total_profit"`
|
||||
ProfitRate float64 `json:"profit_rate"`
|
||||
Breakdown []ProfitLossBreakdown `json:"breakdown"`
|
||||
}
|
||||
```
|
||||
|
||||
Keep all monetary values as `int64` fen (1/100 RMB), consistent with the entire codebase. Never use `float64` for monetary storage — only for profit rate display.
|
||||
|
||||
---
|
||||
|
||||
### 6. Existing Finance Utilities (Reuse, Do Not Reimplement)
|
||||
|
||||
**Confidence: HIGH** (verified in `internal/service/finance/profit_metrics.go`)
|
||||
|
||||
These functions are already tested and must be reused in the new service:
|
||||
|
||||
| Function | Purpose |
|
||||
|----------|---------|
|
||||
| `ClassifyOrderSpending(sourceType, orderNo, actualAmount, discountAmount, remark, gamePassValue)` | Classifies order as game-pass or paid-coupon and returns `SpendingBreakdown` |
|
||||
| `IsGamePassOrder(sourceType, orderNo, actualAmount, remark)` | Boolean test for game pass order |
|
||||
| `ComputeGamePassValue(drawCount, activityPrice)` | Calculates game pass monetary value |
|
||||
| `ComputePrizeCostWithMultiplier(baseCost, multiplierX1000)` | Applies item card multiplier to base cost |
|
||||
| `ComputeProfit(spending, prizeCost)` | Returns `(profit int64, profitRate float64)` |
|
||||
| `NormalizeMultiplierX1000(multiplierX1000)` | Clamps multiplier to minimum 1000 |
|
||||
|
||||
The new aggregation functions will call these at the per-row level when processing scan results in Go, not inside SQL expressions where possible.
|
||||
|
||||
---
|
||||
|
||||
### 7. SQL Aggregation Best Practices for This Codebase
|
||||
|
||||
**Confidence: HIGH** (derived from existing queries and MySQL behavior)
|
||||
|
||||
**Use `CAST(... AS SIGNED)` for SUM over expressions involving division:**
|
||||
MySQL returns `DECIMAL` for `SUM(x / y)` even when inputs are `BIGINT`. This causes GORM scan failures into `int64`. The existing code already uses `CAST(SUM(...) AS SIGNED)`.
|
||||
|
||||
**Use `COALESCE(NULLIF(col, 0), fallback1, fallback2, 0)` for value resolution:**
|
||||
The price priority chain for inventory items is established in the codebase:
|
||||
```sql
|
||||
COALESCE(NULLIF(user_inventory.value_cents, 0),
|
||||
activity_reward_settings.price_snapshot_cents,
|
||||
products.price,
|
||||
0)
|
||||
```
|
||||
Always use this chain when resolving item cost — do not use `products.price` alone as it may be stale.
|
||||
|
||||
**Use `GREATEST(COALESCE(multiplier, 1000), 1000)` for multiplier safety:**
|
||||
Prevents zero or negative multipliers from producing incorrect cost calculations.
|
||||
|
||||
**Avoid GORM auto-injecting `deleted_at` in subqueries:**
|
||||
When writing raw subqueries inside `.Joins()`, explicitly add `deleted_at IS NULL` conditions. GORM does NOT auto-inject soft-delete conditions inside string literals passed to `.Joins()`. This is a known bug documented in the existing code comments.
|
||||
|
||||
**Time range filtering — use explicit column prefix:**
|
||||
```go
|
||||
if params.StartTime != nil {
|
||||
db = db.Where("orders.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
db = db.Where("orders.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
```
|
||||
Always prefix column names with table names in multi-join queries to prevent `ambiguous column` errors.
|
||||
|
||||
---
|
||||
|
||||
### 8. Testing Pattern
|
||||
|
||||
**Confidence: HIGH** (established pattern in `profit_metrics_test.go` and `testrepo_sqlite.go`)
|
||||
|
||||
**Unit tests for pure finance logic** (no DB): test all functions in `profit_metrics.go` and the new calculation logic directly. These should cover boundary cases (zero revenue, zero cost, all-optional params, single asset type).
|
||||
|
||||
**Integration tests for scan functions**: use `NewSQLiteRepoForTest()` to create an in-memory SQLite DB. Note the limitations:
|
||||
|
||||
- SQLite does not support `CAST(... AS SIGNED)` — use `CAST(... AS INTEGER)` in test-only helper SQL, or restructure the scan to accept `float64` and convert in Go
|
||||
- SQLite does not support `LIKE 'GP%'` the same way in some edge cases — keep game-pass detection in Go-layer logic where possible, not in SQL CASE expressions during testing
|
||||
- The `GREATEST()` MySQL function is not available in SQLite — abstract multiplier logic into Go helpers
|
||||
|
||||
Recommended test structure for the new service:
|
||||
|
||||
```
|
||||
internal/service/finance/
|
||||
├── profit_metrics.go (existing — pure business logic, no DB)
|
||||
├── profit_metrics_test.go (existing — pure unit tests)
|
||||
├── service.go (NEW — Service interface + constructor)
|
||||
├── params.go (NEW — param structs, AssetType constants, result types)
|
||||
├── query_user.go (NEW — UserProfitLoss scan logic)
|
||||
├── query_activity.go (NEW — ActivityProfitLoss scan logic)
|
||||
└── service_test.go (NEW — integration tests using SQLiteRepoForTest)
|
||||
```
|
||||
|
||||
Keep each query file under 300 lines. If `query_user.go` grows beyond that, split by data source (e.g. `query_user_revenue.go`, `query_user_cost.go`).
|
||||
|
||||
---
|
||||
|
||||
## What NOT to Do
|
||||
|
||||
| Anti-Pattern | Why | What to Do Instead |
|
||||
|-------------|-----|-------------------|
|
||||
| Single mega-JOIN across orders + inventory + draw_logs + products | Produces Cartesian products; MySQL optimizer struggles; query becomes unmaintainable | Fan-out into separate `.Scan()` calls per data source, merge in Go |
|
||||
| `float64` for monetary storage in result structs | Precision loss at large values; inconsistent with codebase | Use `int64` (fen); only use `float64` for display-only fields like `profit_rate` |
|
||||
| Using GORM GEN query builder for complex aggregations | GEN is designed for CRUD; `.Select()` + `.Group()` via GEN is awkward for multi-table GROUP BY with conditional SUM | Use `db.GetDbR().Table(...).Select(raw).Joins(...).Scan()` directly |
|
||||
| Returning raw `*gorm.DB` from the service layer | Leaks ORM dependency upward; breaks testability | Return typed result structs |
|
||||
| Putting business logic (e.g. game-pass classification) inside SQL CASE expressions | Hard to test; differs between MySQL and SQLite; duplicates logic from `finance` package | Compute classification in Go after scanning raw amounts |
|
||||
| Accepting `time.Time{}` zero value to mean "no filter" | Zero time is a valid timestamp; causes subtle bugs | Use `*time.Time`; nil means "no filter" |
|
||||
| Writing analytics queries to the write DB | Unnecessary load on master; read replica exists exactly for this purpose | Always use `repo.GetDbR()` in analytics service |
|
||||
| Reusing the existing dashboard handler logic directly | Dashboard logic is tightly coupled to HTTP handler, specific response shape, and pagination | Implement fresh service-layer functions with clean params/result types |
|
||||
|
||||
---
|
||||
|
||||
## Dependency Additions
|
||||
|
||||
None required. All necessary libraries are already in `go.mod`.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- Codebase analysis: `internal/service/finance/profit_metrics.go` (existing finance utilities)
|
||||
- Codebase analysis: `internal/api/admin/dashboard_activity.go` (activity-dimension aggregation pattern)
|
||||
- Codebase analysis: `internal/api/admin/dashboard_spending.go` (user-dimension aggregation pattern)
|
||||
- Codebase analysis: `internal/repository/mysql/mysql.go` (Repo interface, DbR/DbW split)
|
||||
- Codebase analysis: `internal/repository/mysql/testrepo_sqlite.go` (test DB pattern)
|
||||
- Codebase analysis: `internal/service/user/user.go` (Service interface + constructor pattern)
|
||||
- GORM v1.25 docs: soft-delete not injected into raw JOIN strings — HIGH confidence (matches existing code comments)
|
||||
- MySQL docs: `SUM()` returns DECIMAL when expression involves division — HIGH confidence (matches `CAST(... AS SIGNED)` usage in codebase)
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-03-21*
|
||||
191
.planning/research/SUMMARY.md
Normal file
191
.planning/research/SUMMARY.md
Normal file
@ -0,0 +1,191 @@
|
||||
# Project Research Summary
|
||||
|
||||
**Project:** Bindbox Game — Profit/Loss Analytics Service Layer
|
||||
**Domain:** Go/GORM/MySQL financial aggregation functions for a game/e-commerce platform
|
||||
**Researched:** 2026-03-21
|
||||
**Confidence:** HIGH
|
||||
|
||||
## Executive Summary
|
||||
|
||||
This milestone implements two reusable service-layer functions — `QueryUserProfitLoss` and `QueryActivityProfitLoss` — that aggregate platform-perspective profit and loss data across user and activity dimensions. The domain is internal financial analytics on an existing Go 1.24 / GORM 1.25 / MySQL 8.x stack. No new runtime dependencies are required. All necessary shared logic (game-pass classification, prize cost with multiplier, profit computation) already exists in `internal/service/finance/profit_metrics.go` and must be reused without reimplementation. The key architectural decision is a dedicated `internal/service/finance/` package with a clean `Service` interface, constructor accepting only `DbR` (read replica), and typed input/output structs — not an extension of existing HTTP handler logic.
|
||||
|
||||
The recommended query approach is the fan-out + in-memory merge pattern already established in the codebase: issue separate, independently scoped `db.Table(...).Scan()` calls per data source (orders, inventory, draw logs, ledger), then merge results in Go using a `map[int64]*ProfitLossResult`. This avoids Cartesian products from multi-table JOINs, keeps individual queries testable in isolation, and remains compatible with the SQLite test harness. Raw SQL (`db.Raw()`) should be used only when the query requires more than two levels of subqueries, consistent with the existing codebase convention.
|
||||
|
||||
The highest-severity risks are revenue double-counting (when one order spans multiple activities), silent scan errors (GORM's `Scan()` does not surface type-mismatch failures), and misclassifying game-pass orders as zero-revenue orders. All three have prior-art evidence in the existing dashboard code, with fix patterns already established. The new service layer must enforce: `CAST(... AS SIGNED)` on any `SUM` containing division, strict mutual exclusion between game-pass and cash revenue paths, refunded-order exclusion from both revenue and cost, and error propagation from every `Scan()` call.
|
||||
|
||||
---
|
||||
|
||||
## Key Findings
|
||||
|
||||
### Recommended Stack
|
||||
|
||||
The existing stack is fully sufficient. Go 1.24, GORM 1.25 with `gorm.io/gen v0.3.26`, MySQL 8.x with read/write split via `gorm.io/plugin/dbresolver`, `go.uber.org/zap` (wrapped as `logger.CustomLogger`), `testify` for assertions, and in-memory SQLite via `NewSQLiteRepoForTest()` for integration tests. Adding no new dependencies reduces risk and keeps the codebase coherent.
|
||||
|
||||
**Core technologies:**
|
||||
- **Go 1.24**: Primary language — existing toolchain, no change
|
||||
- **GORM 1.25**: ORM — use `db.Table().Select().Scan()` (Style A) for single-dimension GROUP BY; `db.Raw().Scan()` (Style B) for multi-level subqueries
|
||||
- **MySQL 8.x (DbR)**: Read replica — all analytics queries must route here via `repo.GetDbR()`
|
||||
- **`logger.CustomLogger`**: Project-standard logger — inject at constructor, not package-level
|
||||
- **SQLite (test only)**: In-memory test DB — `NewSQLiteRepoForTest()`; note SQLite does not support `CAST(AS SIGNED)` or `GREATEST()` — abstract these into Go helpers
|
||||
- **Existing `finance.*` utilities**: `ClassifyOrderSpending`, `IsGamePassOrder`, `ComputeGamePassValue`, `ComputePrizeCostWithMultiplier`, `ComputeProfit`, `NormalizeMultiplierX1000` — all must be called, never re-derived
|
||||
|
||||
### Expected Features
|
||||
|
||||
**Must have (table stakes — P1):**
|
||||
- Revenue calculation: `actual_amount + discount_amount` (coupon discount adds back real value) with strict refund/void exclusion
|
||||
- Game-pass order classification via `finance.IsGamePassOrder` — three-condition detection, mutual exclusion from cash revenue
|
||||
- Game-pass value derivation: `draw_count × activity_price` via `finance.ComputeGamePassValue`
|
||||
- Prize cost with item-card multiplier via `finance.ComputePrizeCostWithMultiplier`
|
||||
- Profit calculation via `finance.ComputeProfit` returning `(int64, float64)`
|
||||
- Time-range filter: `*time.Time` start/end, nil means no bound — never use zero-value sentinel
|
||||
- User-dimension aggregation: `QueryUserProfitLoss(ctx, ProfitLossParams)` accepting `[]int64` user IDs (empty = all users)
|
||||
- Activity-dimension aggregation: `QueryActivityProfitLoss(ctx, ProfitLossParams)` accepting one activity ID
|
||||
- `ProfitLossResult` struct: total revenue, cost, profit, profit_rate, plus `[]ProfitLossBreakdown`
|
||||
- Canonical `AssetType` enum: Points (1), Coupon (2), ItemCard (3), Product (4), Fragment (5), All (0)
|
||||
- All monetary values stored as `int64` fen — never `float64` for storage
|
||||
- Read-only DB routing: constructor injects `repo.GetDbR()` only — `GetDbW()` must not appear anywhere in this package
|
||||
- Error propagation: every `Scan()` error checked and returned, never swallowed
|
||||
|
||||
**Should have (differentiators — P2):**
|
||||
- Per-asset-type cost breakdown (5 types as separate breakdown slice entries)
|
||||
- Fragment asset type cost integration via `fragment_synthesis_logs`
|
||||
- Batch activity IDs support (`[]int64` activity IDs, not just one)
|
||||
|
||||
**Defer (v2+):**
|
||||
- Redis TTL caching wrapper around the query functions (defer until query latency exceeds 2s)
|
||||
- Incremental / time-bucketed aggregation with materialized stats tables (requires schema additions)
|
||||
- Douyin (livestream) order integration into user-dimension function
|
||||
|
||||
### Architecture Approach
|
||||
|
||||
Note: A separate ARCHITECTURE.md was not produced; architecture findings are synthesized from STACK.md and codebase analysis embedded in all three research files.
|
||||
|
||||
The architecture follows the established layered pattern: new package `internal/service/finance/` with a `Service` interface and constructor accepting `logger.CustomLogger` and `mysql.Repo`. Business logic lives exclusively in service functions; HTTP handlers call service functions and handle pagination, auth, and response formatting. The fan-out query pattern (multiple targeted `Scan()` calls merged in Go) replaces any attempt at a single mega-JOIN. Pure finance computation functions (no DB access) remain in `profit_metrics.go`; new DB-querying logic lives in separate, focused files.
|
||||
|
||||
**Major components:**
|
||||
1. **`service.go`** — `Service` interface definition + `New(logger, repo)` constructor; stores `dbR *gorm.DB` (read-only handle) and `logger`
|
||||
2. **`params.go`** — `AssetType` constants, `UserProfitLossParams`, `ActivityProfitLossParams`, `ProfitLossResult`, `ProfitLossBreakdown` types; shared between both query files
|
||||
3. **`query_user.go`** — `QueryUserProfitLoss` implementation: ID collection, fan-out scans, in-memory merge calling existing `finance.*` utilities
|
||||
4. **`query_activity.go`** — `QueryActivityProfitLoss` implementation: same pattern, activity-dimension scoping with proportional revenue attribution
|
||||
5. **`profit_metrics.go`** (existing) — pure business logic functions; no modification needed
|
||||
6. **`service_test.go`** — integration tests using `NewSQLiteRepoForTest()`; covers boundary cases (zero revenue, refunded orders, game-pass, legacy `order_id=0` inventory)
|
||||
|
||||
### Critical Pitfalls
|
||||
|
||||
1. **MySQL `SUM` with division returns Decimal, not SIGNED integer** — Wrap every `SUM(... / ...)` expression with `CAST(... AS SIGNED)` in SQL. Scanning Decimal into `int64` silently returns 0. Already hit in `dashboard_activity.go:174`; the fix is established — apply it consistently.
|
||||
|
||||
2. **Revenue double-counting when one order spans multiple activities** — Use the two-level subquery attribution pattern from `dashboard_activity.go:197-212`: compute `draw_count per (order, activity)` and `total_count per order` in separate derived tables, then prorate: `actual_amount * draw_count / total_count`. Naive `SUM(actual_amount)` grouped by activity fans out the full order to every matching activity.
|
||||
|
||||
3. **Game-pass orders misclassified as zero-revenue orders** — Use strict mutual exclusion: if `IsGamePassOrder()` returns true, revenue = `draw_count × activity_price`; otherwise revenue = `actual_amount + discount_amount`. Never sum both paths together. Three detection conditions must all be checked — not just `source_type=4`.
|
||||
|
||||
4. **Silently ignored `Scan()` errors causing all-zero results** — Every `Scan()` call must check `.Error` and return the error to the caller. "All zeros" is indistinguishable from a failed query without this check. This pattern is missing from existing dashboard code and must be corrected in the new package.
|
||||
|
||||
5. **Refunded order inventory counted as prize cost** — Always join `user_inventory` to `orders` on `order_id` and filter `orders.status = 2`, with the legacy escape hatch `OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL`. Add `NOT LIKE '%void%'` on `user_inventory.remark`. Refunds update `orders.status` but do not delete inventory rows.
|
||||
|
||||
6. **Writing analytics queries to the write DB (DbW)** — The constructor must accept and store only `repo.GetDbR()`. No call to `GetDbW()` should exist in `internal/service/finance/`. Enforce via grep in CI.
|
||||
|
||||
---
|
||||
|
||||
## Implications for Roadmap
|
||||
|
||||
Based on combined research, a 3-phase structure is recommended. All P1 features are tightly interdependent (revenue depends on game-pass classification, cost depends on multiplier logic, profit depends on both) so they belong in one implementation phase. Per-asset-type breakdown is isolated enough to be a second phase. Testing and hardening constitute a third phase.
|
||||
|
||||
### Phase 1: Foundation and Core P&L Functions
|
||||
|
||||
**Rationale:** All P1 features share the same data sources and query infrastructure. Building them together ensures the fan-out pattern, error handling convention, and read-replica routing are established consistently from the start. Deferring any P1 feature creates inconsistency in the result struct and breaks the `ProfitLossResult` contract for callers.
|
||||
|
||||
**Delivers:** Working `QueryUserProfitLoss` and `QueryActivityProfitLoss` with correct revenue (cash + game-pass), correct cost (with multiplier), correct profit, time-range filter, and refund/void exclusion. New package skeleton: `service.go`, `params.go`, `query_user.go`, `query_activity.go`.
|
||||
|
||||
**Addresses (from FEATURES.md):** All P1 features — revenue calculation, game-pass classification/derivation, prize cost with multiplier, profit formula, time-range filter, user-dimension aggregation, activity-dimension aggregation, composable filter struct, result type, AssetType enum, multi-user batch, read-DB enforcement.
|
||||
|
||||
**Avoids (from PITFALLS.md):** Decimal/int64 scan mismatch (CAST), revenue double-counting (subquery attribution), game-pass mutual exclusion, write-DB usage, silently swallowed scan errors.
|
||||
|
||||
**Research flag:** Standard patterns — established codebase conventions are documented; no additional research phase needed.
|
||||
|
||||
### Phase 2: Per-Asset-Type Breakdown
|
||||
|
||||
**Rationale:** The breakdown slice in `ProfitLossResult` can be populated as a separate set of GROUP BY legs per asset type once the core aggregation is proven correct. This requires extending the SQL or adding additional scan passes — but must not alter the top-level totals, making it safe to do independently.
|
||||
|
||||
**Delivers:** Populated `[]ProfitLossBreakdown` in the result, with one entry per `AssetType` (Points, Coupon, ItemCard, Product, Fragment). Fragment cost integration from `fragment_synthesis_logs`.
|
||||
|
||||
**Addresses (from FEATURES.md):** Per-asset-type breakdown (P2), Fragment synthesis cost (P2).
|
||||
|
||||
**Avoids (from PITFALLS.md):** Missing asset type silently understating cost ("Looks Done But Isn't" checklist item).
|
||||
|
||||
**Research flag:** Needs shallow research — Fragment synthesis log schema and join path require verification against the current DB model before implementation.
|
||||
|
||||
### Phase 3: Batch Activity IDs and Hardening
|
||||
|
||||
**Rationale:** Batch activity ID support (`[]int64`) is a low-complexity extension once the single-activity path is correct. Hardening (additional test cases, CI grep gate, load test) consolidates correctness guarantees.
|
||||
|
||||
**Delivers:** `QueryActivityProfitLoss` accepting `[]int64` activity IDs. CI enforcement of `GetDbW` absence. Integration tests covering all "Looks Done But Isn't" checklist items. Load test verification with 1,000 activities.
|
||||
|
||||
**Addresses (from FEATURES.md):** Batch activity IDs support (P2).
|
||||
|
||||
**Avoids (from PITFALLS.md):** Empty `[]int64{}` producing invalid SQL `WHERE IN ()`; performance trap of unbounded activity fetch; missing LIMIT guard.
|
||||
|
||||
**Research flag:** Standard patterns — no research phase needed.
|
||||
|
||||
### Phase Ordering Rationale
|
||||
|
||||
- Phase 1 must precede Phase 2 because the `ProfitLossResult` struct and fan-out pattern must be stable before extending it with per-type breakdown legs.
|
||||
- Phase 2 must precede Phase 3 because batch activity ID support needs the full result struct (including breakdown) to be defined first.
|
||||
- The proportional revenue attribution pattern (subquery join) is the most complex SQL in Phase 1 and must be designed before any other query is written — it anchors the activity-dimension function's correctness.
|
||||
- SQLite test compatibility limits matter for Phase 1: `CAST(AS SIGNED)` and `GREATEST()` must be abstracted into Go helpers or test-specific SQL variants before the integration test suite is written.
|
||||
|
||||
### Research Flags
|
||||
|
||||
Phases needing deeper research during planning:
|
||||
- **Phase 2:** Fragment synthesis log schema — verify `fragment_synthesis_logs` table columns, join path to `user_inventory` or `activity_id`, and whether the cost model for fragments differs from physical goods.
|
||||
|
||||
Phases with standard patterns (skip research-phase):
|
||||
- **Phase 1:** All patterns are documented in the existing codebase with explicit prior-art examples.
|
||||
- **Phase 3:** Batch ID extension and CI hardening are mechanical changes on established patterns.
|
||||
|
||||
---
|
||||
|
||||
## Confidence Assessment
|
||||
|
||||
| Area | Confidence | Notes |
|
||||
|------|------------|-------|
|
||||
| Stack | HIGH | All findings verified directly from `go.mod`, existing source files, and inline code comments — no speculation |
|
||||
| Features | HIGH | Derived from direct codebase analysis of existing dashboard implementations and `profit_metrics.go`; requirements validated against PROJECT.md |
|
||||
| Architecture | HIGH | Architecture inferred from STACK.md (no separate ARCHITECTURE.md produced); all patterns confirmed from multiple existing service examples in codebase |
|
||||
| Pitfalls | HIGH | Every pitfall has direct evidence — code comments, inline bug fixes, or `.planning/codebase/CONCERNS.md` entries in the existing codebase |
|
||||
|
||||
**Overall confidence:** HIGH
|
||||
|
||||
### Gaps to Address
|
||||
|
||||
- **ARCHITECTURE.md was not produced** by the parallel research phase. Architecture guidance was successfully recovered from STACK.md (which contained the service constructor pattern, file structure, and query patterns) and from codebase analysis referenced in FEATURES.md and PITFALLS.md. No meaningful gap results — all architectural decisions are documented in this summary.
|
||||
|
||||
- **Fragment asset type cost model** is undefined for v1. The `fragment_synthesis_logs` table exists and the `AssetType` enum entry is defined, but the exact cost calculation formula and join path are not yet verified. Address in Phase 2 planning with a focused schema review.
|
||||
|
||||
- **SQLite test compatibility**: `CAST(AS SIGNED)`, `GREATEST()`, and `LIKE 'GP%'` are MySQL-specific. Integration tests on SQLite will require either Go-layer abstraction of these expressions or conditional SQL paths. This is a known constraint; address during Phase 1 test writing, not a blocker.
|
||||
|
||||
- **Batch activity IDs** deferred to Phase 3. The current `QueryActivityProfitLoss` design assumes one activity ID. The parameter struct should use `[]int64` from the start (even if Phase 1 only enforces `len(activityIDs) == 1`) to avoid a breaking interface change in Phase 3.
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
### Primary (HIGH confidence — direct codebase analysis)
|
||||
|
||||
- `internal/service/finance/profit_metrics.go` — existing shared finance primitives; `IsGamePassOrder`, `ComputeProfit`, `ComputePrizeCostWithMultiplier`, `ClassifyOrderSpending`
|
||||
- `internal/api/admin/dashboard_activity.go` — activity-dimension aggregation, prior-art bug fixes for Decimal/int64, double-counting, game-pass classification (lines 146-274)
|
||||
- `internal/api/admin/dashboard_spending.go` — user-dimension aggregation, multi-join fan-out pattern
|
||||
- `internal/api/admin/dashboard_user_spending.go` — per-user spending drill-down
|
||||
- `internal/repository/mysql/mysql.go` — `Repo` interface, `GetDbR()` / `GetDbW()` split
|
||||
- `internal/repository/mysql/testrepo_sqlite.go` — `NewSQLiteRepoForTest()` pattern
|
||||
- `internal/service/user/user.go` — canonical `Service` interface + constructor pattern
|
||||
- `.planning/codebase/CONCERNS.md` — flagged 113 `GetDbW()` calls in handler layer; silently swallowed errors in financial paths
|
||||
- `go.mod` — confirmed dependency versions (Go 1.24.0, GORM 1.25.9, testify 1.11.1)
|
||||
|
||||
### Secondary (HIGH confidence — official documentation cross-referenced with codebase evidence)
|
||||
|
||||
- GORM v1.25 docs: soft-delete not auto-injected into raw JOIN strings — confirmed by existing code comments
|
||||
- MySQL 8.x docs: `SUM()` with division promotes to Decimal — confirmed by `dashboard_activity.go:174` comment and `CAST(AS SIGNED)` fix pattern
|
||||
|
||||
---
|
||||
*Research completed: 2026-03-21*
|
||||
*Ready for roadmap: yes*
|
||||
275
CLAUDE.md
275
CLAUDE.md
@ -289,3 +289,278 @@ Services should contain business logic and call DAOs for data access. Keep handl
|
||||
- **GORM generation fails**: Check database connectivity and ensure `cmd/gormgen/main.go` has correct DB credentials
|
||||
- **Frontend build errors**: Clear node_modules and reinstall: `cd web/admin && rm -rf node_modules && pnpm install`
|
||||
- **JWT token issues**: If admin tokens are invalid, check `ADMIN_JWT_SECRET` environment variable matches config
|
||||
|
||||
<!-- GSD:project-start source:PROJECT.md -->
|
||||
## Project
|
||||
|
||||
**Bindbox Game 盈亏统计函数**
|
||||
|
||||
为 Bindbox Game 平台新增两个 Service 层通用盈亏统计函数,支持按用户维度和活动维度查询平台盈亏情况。函数接收资产类型、维度 ID、时间范围等参数,返回汇总数据和按资产类型拆分的明细。
|
||||
|
||||
**Core Value:** 提供可复用的盈亏统计方法,使平台运营能从用户和活动两个维度快速了解各类资产的收支状况。
|
||||
|
||||
### Constraints
|
||||
|
||||
- **Tech Stack**: Go, GORM, MySQL — 遵循现有项目架构
|
||||
- **Performance**: 统计查询走从库 (DbR),避免影响写库性能
|
||||
- **Compatibility**: 新函数放在 `internal/service/finance/` 下,不修改现有接口
|
||||
<!-- GSD:project-end -->
|
||||
|
||||
<!-- GSD:stack-start source:codebase/STACK.md -->
|
||||
## Technology Stack
|
||||
|
||||
## Languages
|
||||
- Go 1.24.0 - Backend server, all business logic, API handlers
|
||||
- TypeScript ~5.6.3 - Frontend admin panel (`web/admin/src/`)
|
||||
- SQL - Database migrations (`migrations/` directory)
|
||||
- TOML - Configuration files (`configs/*.toml`)
|
||||
- SCSS - Frontend styles (`web/admin/src/assets/styles/`)
|
||||
## Runtime
|
||||
- Go runtime 1.24.0 (toolchain go1.24.2)
|
||||
- Docker: `golang:1.24-alpine` build stage, `alpine:latest` final stage
|
||||
- Node.js >= 18.0.0
|
||||
- Go modules (`go.mod` / `go.sum`) - lockfile present
|
||||
- pnpm >= 8.8.0 - frontend (`web/admin/pnpm-lock.yaml`) - lockfile present
|
||||
## Frameworks
|
||||
- `github.com/gin-gonic/gin v1.9.1` - HTTP web framework
|
||||
- `gorm.io/gorm v1.25.9` - ORM for MySQL
|
||||
- `gorm.io/gen v0.3.26` - GORM code generation from schema
|
||||
- `gorm.io/plugin/dbresolver v1.5.0` - Read/write split support
|
||||
- Vue 3 `^3.5.21` - UI framework (`web/admin/src/`)
|
||||
- Vite `^5.4.10` - Build tool and dev server
|
||||
- Element Plus `^2.11.2` - UI component library
|
||||
- Pinia `^3.0.3` - State management
|
||||
- Vue Router `^4.5.1` - Client-side routing
|
||||
- Tailwind CSS `^4.1.14` - Utility-first CSS
|
||||
- `github.com/stretchr/testify v1.11.1` - Assertions
|
||||
- `github.com/DATA-DOG/go-sqlmock v1.5.2` - MySQL mock
|
||||
- `github.com/alicebob/miniredis/v2 v2.36.1` - In-memory Redis for tests
|
||||
- `gorm.io/driver/sqlite v1.4.3` - SQLite for in-memory test DB (`internal/repository/mysql/testrepo_sqlite.go`)
|
||||
- Vitest `^1.0.0` - Unit test runner
|
||||
- `@vue/test-utils ^2.4.0` - Vue component testing
|
||||
- Makefile - Task runner (`Makefile`)
|
||||
- `golangci-lint` - Linter (install via `make tools`)
|
||||
- `go-swagger` - Swagger generation (install via `make tools`)
|
||||
- `cmd/mfmt/main.go` - Custom import formatter (groups: stdlib, local, third-party)
|
||||
- `cmd/gormgen/main.go` - GORM model/DAO code generator
|
||||
- ESLint `^9.9.1` + TypeScript ESLint `^8.3.0` - Linting
|
||||
- Prettier `^3.5.3` - Code formatting
|
||||
- Stylelint `^16.20.0` - CSS/SCSS linting
|
||||
- Husky `^9.1.5` + lint-staged - Pre-commit hooks
|
||||
- Terser `^5.36.0` - Minification
|
||||
- `vite-plugin-compression ^0.5.1` - Gzip compression for production
|
||||
## Key Dependencies
|
||||
- `github.com/spf13/viper v1.17.0` - Configuration management (TOML, env var overrides)
|
||||
- `go.uber.org/zap v1.26.0` - Structured logging
|
||||
- `gopkg.in/natefinch/lumberjack.v2 v2.2.1` - Log file rotation
|
||||
- `github.com/golang-jwt/jwt/v5 v5.2.0` - JWT auth tokens
|
||||
- `github.com/redis/go-redis/v9 v9.17.2` - Redis client (singleton)
|
||||
- `github.com/go-sql-driver/mysql v1.7.1` - MySQL driver
|
||||
- `github.com/bytedance/sonic v1.13.2` - High-performance JSON encoder/decoder
|
||||
- `github.com/bwmarrin/snowflake v0.3.0` - Distributed ID generation
|
||||
- `github.com/go-resty/resty/v2 v2.10.0` - HTTP client for external API calls
|
||||
- `github.com/prometheus/client_golang v1.17.0` - Prometheus metrics
|
||||
- `golang.org/x/crypto v0.44.0` - Cryptographic utilities
|
||||
- Axios `^1.12.2` - HTTP client for API calls
|
||||
- Echarts `^6.0.0` - Charts and data visualization
|
||||
- `@vueuse/core ^13.9.0` - Vue composition utilities
|
||||
- `pinia-plugin-persistedstate ^4.3.0` - Persistent state storage
|
||||
- `dayjs ^1.11.19` - Date/time manipulation
|
||||
- `crypto-js ^4.2.0` - Client-side cryptography
|
||||
- `xlsx ^0.18.5` - Excel file generation/parsing
|
||||
- `@wangeditor/editor ^5.1.23` - Rich text editor
|
||||
- `go.opentelemetry.io/otel v1.39.0` - Distributed tracing (OTLP HTTP exporter)
|
||||
- `github.com/gin-contrib/pprof v1.4.0` - Go profiling endpoint
|
||||
- `github.com/swaggo/gin-swagger v1.6.0` - Swagger UI embedded in Gin
|
||||
- `github.com/tealeg/xlsx v1.0.5` - Excel file generation (server-side)
|
||||
- `github.com/rs/cors/wrapper/gin v0.0.0-20231013084403-73f81b45a644` - CORS middleware
|
||||
## Configuration
|
||||
- Set via `ENV` environment variable: `dev` | `fat` | `uat` | `pro` (default: `fat`)
|
||||
- Config files embedded into binary at build time via `//go:embed` directives
|
||||
- Config files: `configs/dev_configs.toml`, `configs/fat_configs.toml`, `configs/uat_configs.toml`, `configs/pro_configs.toml`
|
||||
- TOML format parsed via Viper (`github.com/spf13/viper`)
|
||||
- `MYSQL_ADDR`, `MYSQL_READ_ADDR`, `MYSQL_WRITE_ADDR`, `MYSQL_USER`, `MYSQL_PASS`, `MYSQL_NAME`
|
||||
- `REDIS_ADDR`, `REDIS_PASS`
|
||||
- `WECHAT_MCHID`, `WECHAT_SERIAL_NO`, `WECHAT_PRIVATE_KEY_PATH`, `WECHAT_API_V3_KEY`, `WECHAT_NOTIFY_URL`, `WECHAT_PUBLIC_KEY_ID`, `WECHAT_PUBLIC_KEY_PATH`
|
||||
- `ALIYUN_SMS_ACCESS_KEY_ID`, `ALIYUN_SMS_ACCESS_KEY_SECRET`, `ALIYUN_SMS_SIGN_NAME`, `ALIYUN_SMS_TEMPLATE_CODE`
|
||||
- `ADMIN_JWT_SECRET` - Admin JWT signing secret override
|
||||
- Vite env vars: `VITE_VERSION`, `VITE_PORT`, `VITE_BASE_URL`, `VITE_API_URL`, `VITE_API_PROXY_URL`
|
||||
- Dev proxy: `/api` requests forwarded to `VITE_API_PROXY_URL`
|
||||
- Backend: `Dockerfile` (multi-stage, `golang:1.24-alpine` → `alpine:latest`)
|
||||
- Server port: `9991` (constant in `configs/constants.go`)
|
||||
- Container exposes port `9991`
|
||||
## Platform Requirements
|
||||
- Go 1.24+
|
||||
- Node.js >= 18.0.0, pnpm >= 8.8.0
|
||||
- MySQL instance (read/write addresses)
|
||||
- Redis instance
|
||||
- `golangci-lint` and `go-swagger` for linting/docs
|
||||
- Docker (Linux/amd64 binary, CGO_ENABLED=0)
|
||||
- Alpine Linux container
|
||||
- MySQL with optional read replica (master-slave)
|
||||
- Redis single-node
|
||||
- Optional: OpenTelemetry-compatible collector (Tempo) at configured OTLP endpoint
|
||||
<!-- GSD:stack-end -->
|
||||
|
||||
<!-- GSD:conventions-start source:CONVENTIONS.md -->
|
||||
## Conventions
|
||||
|
||||
## Naming Patterns
|
||||
- snake_case for all Go source files: `activity_order_service.go`, `draw_config_save.go`
|
||||
- Test files co-located with source: `reward_snapshot_test.go` next to `rewards_create.go`
|
||||
- Generated files suffixed with `.gen.go`: never edited manually
|
||||
- Package names match directory name: `package activity` in `internal/service/activity/`
|
||||
- kebab-case for TypeScript API files: `pay-orders.ts`, `order-snapshots.ts`
|
||||
- kebab-case for view directories: `player-manage/`, `shipping-orders/`
|
||||
- PascalCase for Vue component filenames where applicable
|
||||
- PascalCase for exported: `NewActivityOrderService`, `CreateActivityOrder`, `ListProductsForApp`
|
||||
- camelCase for unexported: `newRewardSnapshotTestService`, `shouldTriggerInstantDraw`, `assertAttribution`
|
||||
- Constructor functions named `New<Type>` for service constructors: `NewProduct(...)`, `NewStore(...)`
|
||||
- Handler methods return `core.HandlerFunc` (closure pattern): `func (h *productHandler) ListProductsForApp() core.HandlerFunc`
|
||||
- `fetch` prefix for API functions: `fetchGetActivities`, `fetchGetActivityDetail`
|
||||
- camelCase for all functions
|
||||
- camelCase in Go: `userID`, `activityID`, `testLogger`
|
||||
- Named ID variables use int64 type consistently: `userID int64`, `activityID int64`
|
||||
- PascalCase for exported: `CreateActivityOrderRequest`, `ActivityOrderService`
|
||||
- Unexported structs for implementation: `activityOrderService`, `productHandler`, `context`
|
||||
- Request structs named `<verb><Domain>Request`: `listAppProductsRequest`, `CreateActivityOrderRequest`
|
||||
- Response structs named `<verb><Domain>Response`: `listAppProductsResponse`, `getAppProductDetailResponse`
|
||||
- Interface types use verb-noun: `ActivityOrderService`, `Service`, `Repo`
|
||||
- 5-digit pattern: service level (1) + module level (2) + specific error (2)
|
||||
- All-caps with CamelCase words: `ServerError = 10101`, `ParamBindError = 10102`
|
||||
- Grouped by domain in `internal/code/code.go`
|
||||
## Code Style
|
||||
- `gofmt -s` via `make fmt` (uses standard gofmt)
|
||||
- Import grouping via `go run cmd/mfmt/main.go`: stdlib → local module (`bindbox-game/...`) → third-party
|
||||
- Line length not strictly enforced but long lines occur in handler code
|
||||
- `golangci-lint run -D staticcheck` via `make lint`
|
||||
- staticcheck disabled; other default golangci-lint checks active
|
||||
- Prettier for all file types, configured via lint-staged hooks
|
||||
- ESLint with `eslint-plugin-prettier/recommended`
|
||||
- Single quotes enforced: `quotes: ['error', 'single']`
|
||||
- No semicolons: `semi: ['error', 'never']`
|
||||
- No `var`: `'no-var': 'error'` — use `let` or `const`
|
||||
- `@typescript-eslint/no-explicit-any` disabled (any is allowed)
|
||||
- Vue multi-word component name rule disabled
|
||||
## Import Organization
|
||||
- `import request from '@/utils/http'`
|
||||
- `import { getActivityDetail } from './adminActivities'` (relative for same-level)
|
||||
## Error Handling
|
||||
## Logging
|
||||
- Logger injected into handlers and services via constructor
|
||||
- Handler structs hold `logger logger.CustomLogger` field
|
||||
- Service structs hold `logger logger.CustomLogger` field
|
||||
- Use structured fields: `zap.Field` variadic args
|
||||
- Exported methods: `Info`, `Error`, `Warn`, `Debug`
|
||||
- In tests, use `logger.NewCustomLogger(nil, logger.WithOutputInConsole())`
|
||||
## Comments
|
||||
## Function Design
|
||||
## Module Design
|
||||
- `internal/api/` → handlers only, thin, call services
|
||||
- `internal/service/` → business logic, call DAOs and other services
|
||||
- `internal/repository/mysql/` → data access via GORM DAOs (generated)
|
||||
- `internal/pkg/` → shared utilities, no business logic
|
||||
<!-- GSD:conventions-end -->
|
||||
|
||||
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
||||
## Architecture
|
||||
|
||||
## Overview
|
||||
## Architectural Pattern
|
||||
```
|
||||
```
|
||||
## Key Layers
|
||||
### 1. Router Layer (`internal/router/`)
|
||||
- `router.go` — Single file defining all routes via `NewHTTPMux()`
|
||||
- Routes organized into groups:
|
||||
### 2. Interceptor / Middleware Layer (`internal/router/interceptor/`)
|
||||
- `admin_auth.go` — JWT token verification for admin users
|
||||
- `admin_rbac.go` — Role-based access control with action-level permissions
|
||||
- `app_auth.go` — App user token verification
|
||||
- `blacklist.go` — Douyin user blacklist checking
|
||||
- `interceptor.go` — Base interceptor struct with shared dependencies
|
||||
### 3. API Handler Layer (`internal/api/`)
|
||||
- `admin/` — Admin panel handlers (largest, ~30+ files)
|
||||
- `activity/` — Lottery/game activity handlers
|
||||
- `app/` — Store, product, banner, category handlers
|
||||
- `game/` — Game ticket and minesweeper handlers
|
||||
- `pay/` — Payment handlers
|
||||
- `user/` — User management, orders, addresses
|
||||
- `task_center/` — Task center handlers
|
||||
- `common/` — Shared utilities (upload, openid)
|
||||
- `public/` — Public livestream handlers
|
||||
- `internal/` — Internal API handlers (Nakama integration)
|
||||
### 4. Service Layer (`internal/service/`)
|
||||
- `activity/` — Activity CRUD, lottery processing, matching game, settlements, strategy pattern for draw types
|
||||
- `admin/` — Admin user management, login
|
||||
- `user/` — User management, orders, points, coupons, inventory, shipping, synthesis
|
||||
- `order/` — Order processing
|
||||
- `game/` — Game ticket management, minesweeper
|
||||
- `douyin/` — Douyin order sync, reward dispatching
|
||||
- `task_center/` — Task definitions, progress tracking, worker
|
||||
- `product/` — Product management
|
||||
- `finance/` — Financial operations, ledger
|
||||
- `channel/` — Marketing channel management
|
||||
- `title/` — User title/badge system
|
||||
- `banner/`, `sysconfig/`, `common/`, `snapshot/`, `recycle/`, `synthesis/`, `livestream/`
|
||||
### 5. Repository Layer (`internal/repository/mysql/`)
|
||||
- `mysql.go` — Database connection management (read/write split via `Repo` interface)
|
||||
- `plugin.go` — GORM plugins
|
||||
- `model/*.gen.go` — Generated GORM models (do not edit)
|
||||
- `dao/*.gen.go` — Generated GORM DAOs (do not edit)
|
||||
- `task_center/models.go` — Task center specific models
|
||||
- `test_helper.go`, `testrepo_sqlite.go` — Test infrastructure
|
||||
## Entry Point
|
||||
## Data Flow
|
||||
### Typical API Request Flow
|
||||
```
|
||||
```
|
||||
### Background Task Flow
|
||||
```
|
||||
```
|
||||
### Payment Flow
|
||||
```
|
||||
```
|
||||
## Key Design Decisions
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Read/write DB split | Performance: heavy reads go to slave, writes to master |
|
||||
| GORM code generation | Consistency: models and DAOs auto-generated from schema |
|
||||
| Custom `core.Context` wrapper | Standardized error handling, tracing, session management across all handlers |
|
||||
| Strategy pattern for lottery | Different draw types (standard, ichiban) share interface but have different logic |
|
||||
| Background workers in main process | Simplicity: no separate worker binary, uses goroutines |
|
||||
| JWT with hash verification | Security: stored token hash prevents concurrent sessions |
|
||||
## Cross-Cutting Concerns
|
||||
- **Logging**: Zap-based with file rotation (`internal/pkg/logger/`)
|
||||
- **Tracing**: OpenTelemetry integration (`internal/pkg/otel/`)
|
||||
- **Error Codes**: 5-digit system in `internal/code/` (service level + module + specific)
|
||||
- **Alerts**: Alert notification system (`internal/alert/`)
|
||||
- **Metrics**: Prometheus metrics (`internal/metrics/`)
|
||||
## External Service Boundaries
|
||||
- WeChat Mini Program API (`wechat/`, `miniprogram/`)
|
||||
- WeChat Pay v3 API (`pay/`)
|
||||
- Douyin/TikTok API (`douyin/`)
|
||||
- Aliyun SMS (`sms/`)
|
||||
- Tencent COS (object storage)
|
||||
- OpenTelemetry collector
|
||||
<!-- GSD:architecture-end -->
|
||||
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
## GSD Workflow Enforcement
|
||||
|
||||
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
|
||||
|
||||
Use these entry points:
|
||||
- `/gsd:quick` for small fixes, doc updates, and ad-hoc tasks
|
||||
- `/gsd:debug` for investigation and bug fixing
|
||||
- `/gsd:execute-phase` for planned phase work
|
||||
|
||||
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
|
||||
<!-- GSD:workflow-end -->
|
||||
|
||||
<!-- GSD:profile-start -->
|
||||
## Developer Profile
|
||||
|
||||
> Profile not yet configured. Run `/gsd:profile-user` to generate your developer profile.
|
||||
> This section is managed by `generate-claude-profile` -- do not edit manually.
|
||||
<!-- GSD:profile-end -->
|
||||
|
||||
@ -64,7 +64,7 @@ func main() {
|
||||
env.Active() // 初始化 env flag(依赖已有的全局 -env/ACTIVE_ENV 配置)
|
||||
configs.Init()
|
||||
|
||||
cookie := "Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1773139856,1773407744,1773419459,1773858730; HMACCOUNT=F3B5BBA45AAD7006; passport_csrf_token=133a0751277aa016a5851e4cfc27c30c; passport_csrf_token_default=133a0751277aa016a5851e4cfc27c30c; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1773858732; s_v_web_id=verify_mmwdotm1_QYpHiLoc_99vO_49un_9xFU_0ZKfqsmF8gzh; ttwid=1%7Caa-Nm2neyE97yjVd8lXbX7cMYg2IRxLWDrrcDT-XwQI%7C1773858743%7C5ed45fcc397866e9ff1018ed6645a9d60db4ca1eb3b79c0d89935b9a3f2b4d1d; odin_tt=7df0869854126bc16f7be2322dc76227670fb02f8eae669788a93a9284185a793246e9d0e0efaac74aad9f64f1b3c014d060fd70f43361f11db669b394e6a2d5; passport_auth_status=414956d87dcb35d7ee019a02e518c832%2C; passport_auth_status_ss=414956d87dcb35d7ee019a02e518c832%2C; uid_tt=834c56a1b55881ba0850017833f57d5c; uid_tt_ss=834c56a1b55881ba0850017833f57d5c; sid_tt=190fa90c5c7cfcfe3eab172abe7f618d; sessionid=190fa90c5c7cfcfe3eab172abe7f618d; sessionid_ss=190fa90c5c7cfcfe3eab172abe7f618d; is_staff_user=false; PHPSESSID=4e8994de1e2d204fd3cc252063ea5cf8; PHPSESSID_SS=4e8994de1e2d204fd3cc252063ea5cf8; ucas_c0=CkEKBTEuMC4wEKaIh8qL9bvdaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DH3-vNBkjHk6jQBlC_vL6Ekt3t1GdYbhIUp6EWta511kbQ8CmoiX_vryfRgD0; ucas_c0_ss=CkEKBTEuMC4wEKaIh8qL9bvdaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DH3-vNBkjHk6jQBlC_vL6Ekt3t1GdYbhIUp6EWta511kbQ8CmoiX_vryfRgD0; zsgw_business_data=%7B%22uuid%22%3A%22bc287121-46d2-4609-a684-b9b59ecd9f97%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.fxg.jinritemai.com%22%7D; source=seo.fxg.jinritemai.com; gfkadpd=4272,23756; csrf_session_id=1579f92b6914e7cbfbc81471e18918fc; ecom_gray_shop_id=156231010; COMPASS_LUOPAN_DT=session_7618656142230683913; sid_guard=190fa90c5c7cfcfe3eab172abe7f618d%7C1773858765%7C5183999%7CSun%2C+17-May-2026+18%3A32%3A44+GMT; session_tlb_tag=sttt%7C15%7CGQ-pDFx8_P4-qxcqvn9hjf_________dAEcZdrUq51A-FUS1s-6d6GSdYV7SE5IXVs9qSbNY5N0%3D; sid_ucp_v1=1.0.0-KGI4NTg4ZDQ0ZTRiM2JhYWU2NDZlNDc5YmUyMmI1YzM3OWY1OTQzOTcKGQib1oDYuM3aBxDN3-vNBhiwISAMOAZA9AcaAmhsIiAxOTBmYTkwYzVjN2NmY2ZlM2VhYjE3MmFiZTdmNjE4ZA; ssid_ucp_v1=1.0.0-KGI4NTg4ZDQ0ZTRiM2JhYWU2NDZlNDc5YmUyMmI1YzM3OWY1OTQzOTcKGQib1oDYuM3aBxDN3-vNBhiwISAMOAZA9AcaAmhsIiAxOTBmYTkwYzVjN2NmY2ZlM2VhYjE3MmFiZTdmNjE4ZA; BUYIN_SASID=SID2_7618660089398984998"
|
||||
cookie := "is_staff_user=false; SHOP_ID=156231010; PIGEON_CID=4339134776748827; bd_ticket_guard_web_domain=3; passport_mfa_token=CjcMUe8O6Zz52W9O1T3zlEkIxpWSHBCB4dHw9XBdiDU%2BIPU1pzwEXLpVjGth2W2nXGHC8OM6ffSmGkoKPAAAAAAAAAAAAABQK6uUDAbmPNiLgEkCaMWLdiWMpTEiK%2Fm1NGLpqOUmR4vBZtoNbJWrAhzjfim%2BBtfMlxCj6IsOGPax0WwgAiIBA8pTDDU%3D; bd_ticket_guard_server_data=eyJ0aWNrZXQiOiJoYXNoLk1SWGtrczRwYTZpWG91ODhuZENOT05idm9iSjI2SHlXOXRYN2JKNTdZMWM9IiwidHNfc2lnbiI6InRzLjIuMDg1MDhmMjljNWI2MjkzMjQ4ZTAwNGY0YjdiNjMwODI4ODk1YjFkZWQ1ZTRlYmFiZTc3NmYzZTUxYWJjZjZhNGM0ZmJlODdkMjMxOWNmMDUzMTg2MjRjZWRhMTQ5MTFjYTQwNmRlZGJlYmVkZGIyZTMwZmNlOGQ0ZmEwMjU3NWQiLCJjbGllbnRfY2VydCI6InB1Yi5CTHVTREdkVFRHWUdNMVY3ZDZKS2M4V2FwWGJ1K3JVYmVqRThONTZoeTI4SUJXdmVxZjBLMS9GczE0dWx5RTVRd2d4cjdnaDd6SXdMZjlsWDkwOFZQQWs9IiwibG9nX2lkIjoiMjAyNjAzMTExODE4NDBGQUVGNkZGMDBCMkUwQTJEQTU2QSIsImNyZWF0ZV90aW1lIjoxNzczMjI0MzIwfQ%3D%3D; passport_csrf_token=8a80d263a6af8795adf8692ddf2b0bd7; passport_csrf_token_default=8a80d263a6af8795adf8692ddf2b0bd7; s_v_web_id=verify_mmuhek92_2WWTTE1q_Nt89_4Uwc_An7s_aO5e3MjRRUH2; _tea_utm_cache_2631=undefined; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1772107597,1772794481,1773223394,1773858658; ttwid=1%7CNnXcElGkMBE8UTpDOFYR5OfCUYkFjQaLyn1EagPBZgM%7C1773858585%7C74563a93a61ed33b1e9bd4697c260eb21177e46e87f72fddd86075bb903fa984; odin_tt=23564caa6c90cf80bbf73fe1d2a40f56b6c64bfaef87d2208db39c9d147b12ac7a28b422abddac143dee7a3ea2ee0fbd848d17f0ddcbe96db5dba6eca0e79fc2; passport_auth_status=28ac3ac0246ed02dd0776a5f51e7a3f1%2C581a8676e64d918c69ee3930f4dacf8b; passport_auth_status_ss=28ac3ac0246ed02dd0776a5f51e7a3f1%2C581a8676e64d918c69ee3930f4dacf8b; uid_tt=4086ea16cf4b601d9d9657f42419b53c; uid_tt_ss=4086ea16cf4b601d9d9657f42419b53c; sid_tt=6047a612c0e067f6c142d13bd87a9acc; sessionid=6047a612c0e067f6c142d13bd87a9acc; sessionid_ss=6047a612c0e067f6c142d13bd87a9acc; PHPSESSID=692181d913993eab8bc8bac1f6e26b1c; PHPSESSID_SS=692181d913993eab8bc8bac1f6e26b1c; ucas_c0=CkEKBTEuMC4wELaIh5S537vdaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cf3uvNBkifkqjQBlC_vL6Ekt3t1GdYbhIUnz26j2aLNwC7K9D-UoY94GOoC_4; ucas_c0_ss=CkEKBTEuMC4wELaIh5S537vdaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0Cf3uvNBkifkqjQBlC_vL6Ekt3t1GdYbhIUnz26j2aLNwC7K9D-UoY94GOoC_4; zsgw_business_data=%7B%22uuid%22%3A%226756720f-c380-4bda-ab81-3dd27ca08a2d%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.fxg.jinritemai.com%22%7D; sid_guard=6047a612c0e067f6c142d13bd87a9acc%7C1773858597%7C5184000%7CSun%2C+17-May-2026+18%3A29%3A57+GMT; session_tlb_tag=sttt%7C10%7CYEemEsDgZ_bBQtE72HqazP________-jmJZ0rdd_Chl68Ti2aPyUgCao_SCeTs9hmqZrN0gLLeU%3D; sid_ucp_v1=1.0.0-KDE5ZGI2OTFkMTFkOGNlZGM5MDk0M2I4NTc5MzRjNzllMmM5MjBjMTUKGQib1oDYuM3aBxCl3uvNBhiwISAMOAZA9AcaAmxmIiA2MDQ3YTYxMmMwZTA2N2Y2YzE0MmQxM2JkODdhOWFjYw; ssid_ucp_v1=1.0.0-KDE5ZGI2OTFkMTFkOGNlZGM5MDk0M2I4NTc5MzRjNzllMmM5MjBjMTUKGQib1oDYuM3aBxCl3uvNBhiwISAMOAZA9AcaAmxmIiA2MDQ3YTYxMmMwZTA2N2Y2YzE0MmQxM2JkODdhOWFjYw; COMPASS_LUOPAN_DT=session_7618659644277604634; BUYIN_SASID=SID2_7618660342810313012; gfkadpd=4272,23756; csrf_session_id=e94f18f5f8c89da31caf1805f7fc4ac7; ecom_gray_shop_id=156231010"
|
||||
if cookie == "" {
|
||||
fmt.Println("请通过环境变量 DOUYIN_COOKIE 提供抖店 Cookie")
|
||||
os.Exit(1)
|
||||
|
||||
@ -9,6 +9,7 @@ import (
|
||||
bannersvc "bindbox-game/internal/service/banner"
|
||||
channelsvc "bindbox-game/internal/service/channel"
|
||||
douyinsvc "bindbox-game/internal/service/douyin"
|
||||
financesvc "bindbox-game/internal/service/finance"
|
||||
gamesvc "bindbox-game/internal/service/game"
|
||||
livestreamsvc "bindbox-game/internal/service/livestream"
|
||||
productsvc "bindbox-game/internal/service/product"
|
||||
@ -39,6 +40,7 @@ type handler struct {
|
||||
douyinSvc douyinsvc.Service
|
||||
livestream livestreamsvc.Service
|
||||
synthesis synthesissvc.Service
|
||||
financeSvc financesvc.Service // P&L service (read-only)
|
||||
}
|
||||
|
||||
func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler {
|
||||
@ -64,7 +66,8 @@ func New(logger logger.CustomLogger, db mysql.Repo, rdb *redis.Client) *handler
|
||||
snapshotSvc: snapshotSvc,
|
||||
rollbackSvc: rollbackSvc,
|
||||
douyinSvc: douyinsvc.New(logger, db, syscfgSvc, ticketSvc, userSvc, titleSvc),
|
||||
livestream: livestreamsvc.New(logger, db, ticketSvc), // 传入ticketSvc
|
||||
livestream: livestreamsvc.New(logger, db, ticketSvc),
|
||||
synthesis: synthesissvc.New(db),
|
||||
financeSvc: financesvc.New(logger, db),
|
||||
}
|
||||
}
|
||||
|
||||
@ -222,44 +222,26 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 统计成本 (通过 user_inventory 关联 products 和 orders)
|
||||
// 修正:增加关联 orders 表,过滤掉已退款/取消的订单 (status!=2)
|
||||
type costStat struct {
|
||||
ActivityID int64
|
||||
TotalCost int64
|
||||
TotalCostBase int64
|
||||
AvgMultiplierX10 int64
|
||||
// 4. 从 finance.Service 获取成本(替换原有直接 SQL 成本查询)
|
||||
// finance.Service 用 value_cents 作为单一真相源(D-09),无需 COALESCE fallback chain
|
||||
financeParams := financesvc.ActivityProfitLossParams{
|
||||
ActivityIDs: activityIDs,
|
||||
}
|
||||
var costStats []costStat
|
||||
if err := db.Table(model.TableNameUserInventory).
|
||||
Select(`
|
||||
COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) as activity_id,
|
||||
CAST(SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) * GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 1000) AS SIGNED) as total_cost,
|
||||
SUM(COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0)) as total_cost_base,
|
||||
CAST(COALESCE(AVG(GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) / 100), 10) AS SIGNED) as avg_multiplier_x10
|
||||
`).
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN activity_issues AS cost_issues ON cost_issues.id = activity_reward_settings.issue_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
|
||||
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
|
||||
Where("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id) IN ?", activityIDs).
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
// 兼容历史数据:部分老资产可能未写入 order_id,避免被 JOIN 条件整批过滤为0
|
||||
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2).
|
||||
Group("COALESCE(NULLIF(user_inventory.activity_id, 0), cost_issues.activity_id)").
|
||||
Scan(&costStats).Error; err != nil {
|
||||
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss cost stats error: %v", err))
|
||||
} else {
|
||||
for _, s := range costStats {
|
||||
if item, ok := activityMap[s.ActivityID]; ok {
|
||||
item.TotalCost = s.TotalCost
|
||||
item.PrizeCostBase = s.TotalCostBase
|
||||
item.PrizeCostFinal = s.TotalCost
|
||||
item.PrizeCostMultiplier = s.AvgMultiplierX10
|
||||
}
|
||||
financeResult, financeErr := h.financeSvc.QueryActivityProfitLoss(ctx.RequestContext(), financeParams)
|
||||
if financeErr != nil {
|
||||
h.logger.Error(fmt.Sprintf("GetActivityProfitLoss finance cost error: %v", financeErr))
|
||||
}
|
||||
// 按 activity_id 建立 cost 索引
|
||||
financeCostMap := make(map[int64]int64)
|
||||
if financeResult != nil {
|
||||
for _, d := range financeResult.Details {
|
||||
financeCostMap[d.ActivityID] = d.Cost
|
||||
}
|
||||
}
|
||||
for actID, item := range activityMap {
|
||||
if cost, ok := financeCostMap[actID]; ok {
|
||||
item.TotalCost = cost
|
||||
item.PrizeCostFinal = cost
|
||||
}
|
||||
}
|
||||
|
||||
@ -297,7 +279,8 @@ func (h *handler) DashboardActivityProfitLoss() core.HandlerFunc {
|
||||
}
|
||||
|
||||
// 6. 计算盈亏和比率
|
||||
// 公式: 盈亏 = 用户支出(普通单支付+优惠券 或 次卡价值) - 奖品成本(含道具卡倍率)
|
||||
// 成本来自 finance.Service(value_cents 单一真相源 + 道具卡倍率)
|
||||
// 收入来自原有 scan(保留 total_discount / total_game_pass_value 拆分字段)
|
||||
finalList := make([]activityProfitLossItem, 0, len(activities))
|
||||
for _, a := range activities {
|
||||
item := activityMap[a.ID]
|
||||
|
||||
@ -195,12 +195,20 @@ func (h *handler) GetUserProfile() core.HandlerFunc {
|
||||
}
|
||||
var is invStats
|
||||
_ = h.repo.GetDbR().Raw(`
|
||||
SELECT
|
||||
SELECT
|
||||
COUNT(ui.id) as count,
|
||||
COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0) as value
|
||||
CAST(COALESCE(SUM(
|
||||
COALESCE(NULLIF(ui.value_cents, 0), ars.price_snapshot_cents, p.price, 0)
|
||||
* GREATEST(COALESCE(sic.reward_multiplier_x1000, 1000), 1000) / 1000
|
||||
), 0) AS SIGNED) as value
|
||||
FROM user_inventory ui
|
||||
LEFT JOIN products p ON p.id = ui.product_id
|
||||
WHERE ui.user_id = ? AND ui.status = 1
|
||||
LEFT JOIN activity_reward_settings ars ON ars.id = ui.reward_id
|
||||
LEFT JOIN orders o ON o.id = ui.order_id
|
||||
LEFT JOIN user_item_cards uic ON uic.id = o.item_card_id
|
||||
LEFT JOIN system_item_cards sic ON sic.id = uic.card_id
|
||||
WHERE ui.user_id = ? AND ui.status IN (1, 3)
|
||||
AND COALESCE(ui.remark, '') NOT LIKE '%%void%%'
|
||||
`, userID).Scan(&is).Error
|
||||
rsp.CurrentAssets.InventoryCount = is.Count
|
||||
rsp.CurrentAssets.InventoryValue = is.Value
|
||||
|
||||
@ -88,10 +88,18 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
||||
}
|
||||
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(points), 0) FROM user_points WHERE user_id = ? AND (valid_end IS NULL OR valid_end > NOW())", userID).Scan(&curAssets.Points).Error
|
||||
_ = h.repo.GetDbR().Raw(`
|
||||
SELECT COALESCE(SUM(COALESCE(NULLIF(ui.value_cents, 0), p.price, 0)), 0)
|
||||
SELECT CAST(COALESCE(SUM(
|
||||
COALESCE(NULLIF(ui.value_cents, 0), ars.price_snapshot_cents, p.price, 0)
|
||||
* GREATEST(COALESCE(sic.reward_multiplier_x1000, 1000), 1000) / 1000
|
||||
), 0) AS SIGNED)
|
||||
FROM user_inventory ui
|
||||
LEFT JOIN products p ON p.id = ui.product_id
|
||||
WHERE ui.user_id = ? AND ui.status = 1
|
||||
LEFT JOIN activity_reward_settings ars ON ars.id = ui.reward_id
|
||||
LEFT JOIN orders o ON o.id = ui.order_id
|
||||
LEFT JOIN user_item_cards uic ON uic.id = o.item_card_id
|
||||
LEFT JOIN system_item_cards sic ON sic.id = uic.card_id
|
||||
WHERE ui.user_id = ? AND ui.status IN (1, 3)
|
||||
AND COALESCE(ui.remark, '') NOT LIKE '%%void%%'
|
||||
`, userID).Scan(&curAssets.Products).Error
|
||||
_ = h.repo.GetDbR().Raw("SELECT COALESCE(SUM(sc.price), 0) FROM user_item_cards uic LEFT JOIN system_item_cards sc ON sc.id = uic.card_id WHERE uic.user_id = ? AND uic.status = 1", userID).Scan(&curAssets.Cards).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
|
||||
@ -208,10 +216,10 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
||||
p.Breakdown.Cards = curAssets.Cards
|
||||
p.Breakdown.Coupons = curAssets.Coupons
|
||||
|
||||
p.Profit = p.Value - p.Cost
|
||||
if p.Cost > 0 {
|
||||
p.Ratio = float64(p.Value) / float64(p.Cost)
|
||||
} else if p.Value > 0 {
|
||||
p.Profit = p.Cost - p.Value
|
||||
if p.Value > 0 {
|
||||
p.Ratio = float64(p.Cost) / float64(p.Value)
|
||||
} else if p.Cost > 0 {
|
||||
p.Ratio = 99.9
|
||||
}
|
||||
}
|
||||
@ -254,10 +262,10 @@ func (h *handler) GetUserProfitLossTrend() core.HandlerFunc {
|
||||
}
|
||||
resp.Summary.TotalCost = finalNetCost
|
||||
resp.Summary.TotalValue = totalAssetValue
|
||||
resp.Summary.TotalProfit = totalAssetValue - finalNetCost
|
||||
if finalNetCost > 0 {
|
||||
resp.Summary.AvgRatio = float64(totalAssetValue) / float64(finalNetCost)
|
||||
} else if totalAssetValue > 0 {
|
||||
resp.Summary.TotalProfit = finalNetCost - totalAssetValue
|
||||
if totalAssetValue > 0 {
|
||||
resp.Summary.AvgRatio = float64(finalNetCost) / float64(totalAssetValue)
|
||||
} else if finalNetCost > 0 {
|
||||
resp.Summary.AvgRatio = 99.9
|
||||
}
|
||||
|
||||
|
||||
@ -533,7 +533,7 @@ func (s *service) fetchDouyinOrdersByBuyer(cookie string, buyer string, proxy st
|
||||
params.Set("appid", "1")
|
||||
params.Set("_bid", "ffa_order")
|
||||
params.Set("aid", "4272")
|
||||
params.Set("__token", "0b67ab6212a41bd1903f03d4f9a887f9")
|
||||
params.Set("__token", "55397afced1b2e260b939336045e29cd")
|
||||
|
||||
return s.fetchDouyinOrders(cookie, params, proxy)
|
||||
}
|
||||
|
||||
189
internal/service/finance/query_activity.go
Normal file
189
internal/service/finance/query_activity.go
Normal file
@ -0,0 +1,189 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"bindbox-game/internal/pkg/points"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
// queryActivity implements QueryActivityProfitLoss using fan-out + in-memory merge.
|
||||
// Four independent Scan() calls gather revenue, inventory cost, points cost,
|
||||
// and coupon cost attributed to activity dimension; results merged in Go.
|
||||
func (s *service) queryActivity(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) {
|
||||
// Step 1: Revenue scan — per-order rows attributed to activity via draw logs
|
||||
type activityRevenueRow struct {
|
||||
ActivityID int64
|
||||
SourceType int32
|
||||
OrderNo string
|
||||
ActualAmount int64
|
||||
DiscountAmount int64
|
||||
Remark string
|
||||
DrawCount int64
|
||||
ActivityPrice int64
|
||||
}
|
||||
var revenueRows []activityRevenueRow
|
||||
q := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameOrders).
|
||||
Select(`activity_issues.activity_id,
|
||||
orders.source_type, orders.order_no,
|
||||
orders.actual_amount, orders.discount_amount, orders.remark,
|
||||
COUNT(activity_draw_logs.id) as draw_count,
|
||||
COALESCE(MAX(activities.price_draw), 0) as activity_price`).
|
||||
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
|
||||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||||
Joins("LEFT JOIN activities ON activities.id = activity_issues.activity_id").
|
||||
Where("orders.status = ?", 2).
|
||||
Group("orders.id, activity_issues.activity_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark")
|
||||
if len(params.ActivityIDs) > 0 {
|
||||
q = q.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
q = q.Where("orders.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
q = q.Where("orders.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
if err := q.Scan(&revenueRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryActivityProfitLoss revenue scan: %w", err)
|
||||
}
|
||||
|
||||
resultMap := make(map[int64]*ProfitLossDetail)
|
||||
for _, r := range revenueRows {
|
||||
gpValue := ComputeGamePassValue(r.DrawCount, r.ActivityPrice)
|
||||
bd := ClassifyOrderSpending(r.SourceType, r.OrderNo, r.ActualAmount, r.DiscountAmount, r.Remark, gpValue)
|
||||
if _, ok := resultMap[r.ActivityID]; !ok {
|
||||
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
|
||||
}
|
||||
resultMap[r.ActivityID].Revenue += bd.Total
|
||||
}
|
||||
|
||||
// Step 2: Inventory cost scan — grouped by activity_id, multiplier applied in Go
|
||||
type activityInventoryRow struct {
|
||||
ActivityID int64
|
||||
ValueCents int64
|
||||
MultiplierX1000 int64
|
||||
}
|
||||
iq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserInventory).
|
||||
Select(`user_inventory.activity_id,
|
||||
user_inventory.value_cents,
|
||||
COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`).
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
|
||||
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
|
||||
if len(params.ActivityIDs) > 0 {
|
||||
iq = iq.Where("user_inventory.activity_id IN ?", params.ActivityIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
iq = iq.Where("user_inventory.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
iq = iq.Where("user_inventory.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
var inventoryRows []activityInventoryRow
|
||||
if err := iq.Scan(&inventoryRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryActivityProfitLoss inventory cost scan: %w", err)
|
||||
}
|
||||
for _, r := range inventoryRows {
|
||||
cost := ComputePrizeCostWithMultiplier(r.ValueCents, r.MultiplierX1000)
|
||||
if _, ok := resultMap[r.ActivityID]; !ok {
|
||||
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
|
||||
}
|
||||
resultMap[r.ActivityID].Cost += cost
|
||||
}
|
||||
|
||||
// Step 3: Points cost scan — link via orders → draw_logs → activity
|
||||
type activityPointsRow struct {
|
||||
ActivityID int64
|
||||
TotalPoints int64
|
||||
}
|
||||
pq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserPointsLedger).
|
||||
Select("activity_issues.activity_id, SUM(-user_points_ledger.points) as total_points").
|
||||
Joins("JOIN orders ON orders.order_no = user_points_ledger.ref_id AND user_points_ledger.ref_table = 'orders'").
|
||||
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
|
||||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||||
Where("user_points_ledger.action = ?", "order_deduct").
|
||||
Where("user_points_ledger.points < ?", 0).
|
||||
Where("orders.status = ?", 2)
|
||||
if len(params.ActivityIDs) > 0 {
|
||||
pq = pq.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
pq = pq.Where("user_points_ledger.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
pq = pq.Where("user_points_ledger.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
pq = pq.Group("activity_issues.activity_id")
|
||||
var pointsRows []activityPointsRow
|
||||
if err := pq.Scan(&pointsRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryActivityProfitLoss points cost scan: %w", err)
|
||||
}
|
||||
rate := s.getPointsExchangeRate(ctx)
|
||||
for _, r := range pointsRows {
|
||||
costCents := points.PointsToCents(r.TotalPoints, float64(rate))
|
||||
if _, ok := resultMap[r.ActivityID]; !ok {
|
||||
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
|
||||
}
|
||||
resultMap[r.ActivityID].Cost += costCents
|
||||
}
|
||||
|
||||
// Step 4: Coupon cost scan — link via orders → draw_logs → activity
|
||||
type activityCouponRow struct {
|
||||
ActivityID int64
|
||||
TotalCost int64
|
||||
}
|
||||
cq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserCouponLedger).
|
||||
Select("activity_issues.activity_id, SUM(-user_coupon_ledger.change_amount) as total_cost").
|
||||
Joins("JOIN orders ON orders.id = user_coupon_ledger.order_id").
|
||||
Joins("JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id").
|
||||
Joins("JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id").
|
||||
Where("user_coupon_ledger.change_amount < ?", 0).
|
||||
Where("orders.status = ?", 2)
|
||||
if len(params.ActivityIDs) > 0 {
|
||||
cq = cq.Where("activity_issues.activity_id IN ?", params.ActivityIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
cq = cq.Group("activity_issues.activity_id")
|
||||
var couponRows []activityCouponRow
|
||||
if err := cq.Scan(&couponRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryActivityProfitLoss coupon cost scan: %w", err)
|
||||
}
|
||||
for _, r := range couponRows {
|
||||
if _, ok := resultMap[r.ActivityID]; !ok {
|
||||
resultMap[r.ActivityID] = &ProfitLossDetail{ActivityID: r.ActivityID}
|
||||
}
|
||||
resultMap[r.ActivityID].Cost += r.TotalCost
|
||||
}
|
||||
|
||||
// Step 5: Apply ComputeProfit per detail and aggregate totals
|
||||
details := make([]ProfitLossDetail, 0, len(resultMap))
|
||||
var totalRevenue, totalCost int64
|
||||
for _, d := range resultMap {
|
||||
d.Profit, d.ProfitRate = ComputeProfit(d.Revenue, d.Cost)
|
||||
totalRevenue += d.Revenue
|
||||
totalCost += d.Cost
|
||||
details = append(details, *d)
|
||||
}
|
||||
totalProfit, profitRate := ComputeProfit(totalRevenue, totalCost)
|
||||
return &ProfitLossResult{
|
||||
TotalRevenue: totalRevenue,
|
||||
TotalCost: totalCost,
|
||||
TotalProfit: totalProfit,
|
||||
ProfitRate: profitRate,
|
||||
Details: details,
|
||||
Breakdown: []interface{}{},
|
||||
}, nil
|
||||
}
|
||||
201
internal/service/finance/query_user.go
Normal file
201
internal/service/finance/query_user.go
Normal file
@ -0,0 +1,201 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"bindbox-game/internal/pkg/points"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
// queryUser implements QueryUserProfitLoss using fan-out + in-memory merge pattern.
|
||||
// Four independent Scan() calls gather revenue, inventory cost, points cost,
|
||||
// and coupon cost; results are merged in Go via map[int64]*ProfitLossDetail.
|
||||
func (s *service) queryUser(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
|
||||
// Step 1: Revenue scan — per-order rows classified in Go
|
||||
type userRevenueRow struct {
|
||||
UserID int64
|
||||
SourceType int32
|
||||
OrderNo string
|
||||
ActualAmount int64
|
||||
DiscountAmount int64
|
||||
Remark string
|
||||
DrawCount int64
|
||||
ActivityPrice int64
|
||||
}
|
||||
var revenueRows []userRevenueRow
|
||||
q := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameOrders).
|
||||
Select(`orders.user_id, orders.source_type, orders.order_no,
|
||||
orders.actual_amount, orders.discount_amount, orders.remark,
|
||||
COUNT(activity_draw_logs.id) as draw_count,
|
||||
COALESCE(MAX(activities.price_draw), 0) as activity_price`).
|
||||
Joins(`LEFT JOIN activity_draw_logs ON activity_draw_logs.order_id = orders.id`).
|
||||
Joins(`LEFT JOIN activity_issues ON activity_issues.id = activity_draw_logs.issue_id`).
|
||||
Joins(`LEFT JOIN activities ON activities.id = activity_issues.activity_id`).
|
||||
Where("orders.status = ?", 2).
|
||||
Group("orders.id, orders.user_id, orders.source_type, orders.order_no, orders.actual_amount, orders.discount_amount, orders.remark")
|
||||
if len(params.UserIDs) > 0 {
|
||||
q = q.Where("orders.user_id IN ?", params.UserIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
q = q.Where("orders.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
q = q.Where("orders.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
if err := q.Scan(&revenueRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryUserProfitLoss revenue scan: %w", err)
|
||||
}
|
||||
|
||||
resultMap := make(map[int64]*ProfitLossDetail)
|
||||
for _, r := range revenueRows {
|
||||
gpValue := ComputeGamePassValue(r.DrawCount, r.ActivityPrice)
|
||||
bd := ClassifyOrderSpending(r.SourceType, r.OrderNo, r.ActualAmount, r.DiscountAmount, r.Remark, gpValue)
|
||||
if _, ok := resultMap[r.UserID]; !ok {
|
||||
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
||||
}
|
||||
resultMap[r.UserID].Revenue += bd.Total
|
||||
}
|
||||
|
||||
// Step 2: Inventory cost scan — apply multiplier in Go (not SQL, for SQLite compat)
|
||||
type userInventoryRow struct {
|
||||
UserID int64
|
||||
ValueCents int64
|
||||
MultiplierX1000 int64
|
||||
}
|
||||
iq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserInventory).
|
||||
Select(`user_inventory.user_id,
|
||||
user_inventory.value_cents,
|
||||
COALESCE(system_item_cards.reward_multiplier_x1000, 1000) as multiplier_x1000`).
|
||||
Joins("LEFT JOIN orders ON orders.id = user_inventory.order_id").
|
||||
Joins("LEFT JOIN user_item_cards ON user_item_cards.id = orders.item_card_id").
|
||||
Joins("LEFT JOIN system_item_cards ON system_item_cards.id = user_item_cards.card_id").
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("(orders.status = ? OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)", 2)
|
||||
if len(params.UserIDs) > 0 {
|
||||
iq = iq.Where("user_inventory.user_id IN ?", params.UserIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
iq = iq.Where("user_inventory.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
iq = iq.Where("user_inventory.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
var inventoryRows []userInventoryRow
|
||||
if err := iq.Scan(&inventoryRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryUserProfitLoss inventory cost scan: %w", err)
|
||||
}
|
||||
for _, r := range inventoryRows {
|
||||
cost := ComputePrizeCostWithMultiplier(r.ValueCents, r.MultiplierX1000)
|
||||
if _, ok := resultMap[r.UserID]; !ok {
|
||||
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
||||
}
|
||||
resultMap[r.UserID].Cost += cost
|
||||
}
|
||||
|
||||
// Step 3: Points cost scan
|
||||
type userPointsRow struct {
|
||||
UserID int64
|
||||
TotalPoints int64
|
||||
}
|
||||
pq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserPointsLedger).
|
||||
Select("user_id, SUM(-points) as total_points").
|
||||
Where("action = ?", "order_deduct").
|
||||
Where("points < ?", 0)
|
||||
if len(params.UserIDs) > 0 {
|
||||
pq = pq.Where("user_id IN ?", params.UserIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
pq = pq.Where("created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
pq = pq.Where("created_at <= ?", *params.EndTime)
|
||||
}
|
||||
pq = pq.Group("user_id")
|
||||
var pointsRows []userPointsRow
|
||||
if err := pq.Scan(&pointsRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryUserProfitLoss points cost scan: %w", err)
|
||||
}
|
||||
rate := s.getPointsExchangeRate(ctx)
|
||||
for _, r := range pointsRows {
|
||||
costCents := points.PointsToCents(r.TotalPoints, float64(rate))
|
||||
if _, ok := resultMap[r.UserID]; !ok {
|
||||
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
||||
}
|
||||
resultMap[r.UserID].Cost += costCents
|
||||
}
|
||||
|
||||
// Step 4: Coupon cost scan — join to paid orders
|
||||
type userCouponRow struct {
|
||||
UserID int64
|
||||
TotalCost int64
|
||||
}
|
||||
cq := s.dbR.WithContext(ctx).
|
||||
Table(model.TableNameUserCouponLedger).
|
||||
Select("user_coupon_ledger.user_id, SUM(-user_coupon_ledger.change_amount) as total_cost").
|
||||
Joins("LEFT JOIN orders ON orders.id = user_coupon_ledger.order_id").
|
||||
Where("user_coupon_ledger.change_amount < ?", 0).
|
||||
Where("orders.status = ?", 2)
|
||||
if len(params.UserIDs) > 0 {
|
||||
cq = cq.Where("user_coupon_ledger.user_id IN ?", params.UserIDs)
|
||||
}
|
||||
if params.StartTime != nil {
|
||||
cq = cq.Where("user_coupon_ledger.created_at >= ?", *params.StartTime)
|
||||
}
|
||||
if params.EndTime != nil {
|
||||
cq = cq.Where("user_coupon_ledger.created_at <= ?", *params.EndTime)
|
||||
}
|
||||
cq = cq.Group("user_coupon_ledger.user_id")
|
||||
var couponRows []userCouponRow
|
||||
if err := cq.Scan(&couponRows).Error; err != nil {
|
||||
return nil, fmt.Errorf("QueryUserProfitLoss coupon cost scan: %w", err)
|
||||
}
|
||||
for _, r := range couponRows {
|
||||
if _, ok := resultMap[r.UserID]; !ok {
|
||||
resultMap[r.UserID] = &ProfitLossDetail{UserID: r.UserID}
|
||||
}
|
||||
resultMap[r.UserID].Cost += r.TotalCost
|
||||
}
|
||||
|
||||
// Step 5: Apply ComputeProfit per detail and aggregate totals
|
||||
details := make([]ProfitLossDetail, 0, len(resultMap))
|
||||
var totalRevenue, totalCost int64
|
||||
for _, d := range resultMap {
|
||||
d.Profit, d.ProfitRate = ComputeProfit(d.Revenue, d.Cost)
|
||||
totalRevenue += d.Revenue
|
||||
totalCost += d.Cost
|
||||
details = append(details, *d)
|
||||
}
|
||||
totalProfit, profitRate := ComputeProfit(totalRevenue, totalCost)
|
||||
return &ProfitLossResult{
|
||||
TotalRevenue: totalRevenue,
|
||||
TotalCost: totalCost,
|
||||
TotalProfit: totalProfit,
|
||||
ProfitRate: profitRate,
|
||||
Details: details,
|
||||
Breakdown: []interface{}{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getPointsExchangeRate reads system_configs for the points exchange rate.
|
||||
// Falls back to 1 (1 yuan = 1 point) on any error.
|
||||
func (s *service) getPointsExchangeRate(ctx context.Context) int64 {
|
||||
var cfg struct{ ConfigValue string }
|
||||
if err := s.dbR.WithContext(ctx).
|
||||
Table("system_configs").
|
||||
Select("config_value").
|
||||
Where("config_key = ?", "points.exchange_rate").
|
||||
First(&cfg).Error; err != nil {
|
||||
return 1
|
||||
}
|
||||
var rate int64
|
||||
fmt.Sscanf(cfg.ConfigValue, "%d", &rate)
|
||||
if rate <= 0 {
|
||||
return 1
|
||||
}
|
||||
return rate
|
||||
}
|
||||
39
internal/service/finance/service.go
Normal file
39
internal/service/finance/service.go
Normal file
@ -0,0 +1,39 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// Service defines the finance P&L query interface.
|
||||
type Service interface {
|
||||
QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error)
|
||||
QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error)
|
||||
}
|
||||
|
||||
type service struct {
|
||||
logger logger.CustomLogger
|
||||
dbR *gorm.DB // read replica only — QUA-02: no writes in this package
|
||||
}
|
||||
|
||||
// New creates a new finance Service backed by the read-only DB replica.
|
||||
// CRITICAL: only db.GetDbR() is called — never GetDbW() (QUA-02).
|
||||
func New(l logger.CustomLogger, db mysql.Repo) Service {
|
||||
return &service{
|
||||
logger: l,
|
||||
dbR: db.GetDbR(),
|
||||
}
|
||||
}
|
||||
|
||||
// QueryUserProfitLoss dispatches to the user-dimension implementation (Plan 02).
|
||||
func (s *service) QueryUserProfitLoss(ctx context.Context, params UserProfitLossParams) (*ProfitLossResult, error) {
|
||||
return s.queryUser(ctx, params)
|
||||
}
|
||||
|
||||
// QueryActivityProfitLoss dispatches to the activity-dimension implementation (Plan 03).
|
||||
func (s *service) QueryActivityProfitLoss(ctx context.Context, params ActivityProfitLossParams) (*ProfitLossResult, error) {
|
||||
return s.queryActivity(ctx, params)
|
||||
}
|
||||
385
internal/service/finance/service_test.go
Normal file
385
internal/service/finance/service_test.go
Normal file
@ -0,0 +1,385 @@
|
||||
package finance
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// newTestSvc creates an in-memory SQLite repo, creates all required tables,
|
||||
// and returns (Service, *gorm.DB) for test use.
|
||||
// NOTE: Uses manual CREATE TABLE instead of AutoMigrate to avoid CURRENT_TIMESTAMP(3)
|
||||
// SQLite incompatibility present in the GORM model tags.
|
||||
func newTestSvc(t *testing.T) (Service, *gorm.DB) {
|
||||
t.Helper()
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
require.NoError(t, err)
|
||||
db := repo.GetDbR()
|
||||
|
||||
// Create tables manually — SQLite does not support CURRENT_TIMESTAMP(3)
|
||||
// which is present in the GORM model default tags.
|
||||
stmts := []string{
|
||||
`CREATE TABLE IF NOT EXISTS orders (
|
||||
id integer primary key,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
user_id integer not null default 0,
|
||||
order_no text not null default '',
|
||||
source_type integer not null default 1,
|
||||
total_amount integer not null default 0,
|
||||
discount_amount integer not null default 0,
|
||||
points_amount integer not null default 0,
|
||||
actual_amount integer not null default 0,
|
||||
status integer not null default 1,
|
||||
pay_preorder_id integer,
|
||||
paid_at datetime,
|
||||
cancelled_at datetime,
|
||||
user_address_id integer,
|
||||
is_consumed integer not null default 0,
|
||||
points_ledger_id integer,
|
||||
coupon_id integer,
|
||||
item_card_id integer,
|
||||
remark text,
|
||||
ext_order_id text not null default ''
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_inventory (
|
||||
id integer primary key,
|
||||
created_at datetime,
|
||||
updated_at datetime,
|
||||
user_id integer not null default 0,
|
||||
product_id integer,
|
||||
value_cents integer not null default 0,
|
||||
value_source integer not null default 0,
|
||||
value_snapshot_at datetime,
|
||||
order_id integer,
|
||||
activity_id integer,
|
||||
reward_id integer,
|
||||
status integer not null default 1,
|
||||
shipping_no text not null default '',
|
||||
remark text
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_points_ledger (
|
||||
id integer primary key,
|
||||
created_at datetime,
|
||||
user_id integer not null default 0,
|
||||
action text not null default '',
|
||||
points integer not null default 0,
|
||||
ref_table text,
|
||||
ref_id text,
|
||||
remark text
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_coupon_ledger (
|
||||
id integer primary key,
|
||||
user_id integer not null default 0,
|
||||
user_coupon_id integer not null default 0,
|
||||
change_amount integer not null default 0,
|
||||
balance_after integer not null default 0,
|
||||
order_id integer,
|
||||
action text not null default '',
|
||||
created_at datetime
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS activity_draw_logs (id integer primary key, order_id integer, issue_id integer, user_id integer)`,
|
||||
`CREATE TABLE IF NOT EXISTS activity_issues (id integer primary key, activity_id integer not null)`,
|
||||
`CREATE TABLE IF NOT EXISTS activities (id integer primary key, price_draw integer not null default 0)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_item_cards (id integer primary key, card_id integer)`,
|
||||
`CREATE TABLE IF NOT EXISTS system_item_cards (id integer primary key, reward_multiplier_x1000 integer)`,
|
||||
`CREATE TABLE IF NOT EXISTS system_configs (id integer primary key, config_key text, config_value text)`,
|
||||
}
|
||||
for _, stmt := range stmts {
|
||||
require.NoError(t, db.Exec(stmt).Error)
|
||||
}
|
||||
|
||||
l, err := logger.NewCustomLogger(logger.WithOutputInConsole())
|
||||
require.NoError(t, err)
|
||||
svc := New(l, repo)
|
||||
return svc, db
|
||||
}
|
||||
|
||||
// --- Seed helpers ---
|
||||
|
||||
func seedOrder(t *testing.T, db *gorm.DB, o model.Orders) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Create(&o).Error)
|
||||
}
|
||||
|
||||
func seedInventory(t *testing.T, db *gorm.DB, inv model.UserInventory) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Create(&inv).Error)
|
||||
}
|
||||
|
||||
func seedPointsLedger(t *testing.T, db *gorm.DB, row model.UserPointsLedger) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Create(&row).Error)
|
||||
}
|
||||
|
||||
func seedCouponLedger(t *testing.T, db *gorm.DB, row model.UserCouponLedger) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Create(&row).Error)
|
||||
}
|
||||
|
||||
// seedActivitySetup creates minimal activity + issue + draw_log for JOIN tests.
|
||||
func seedActivitySetup(t *testing.T, db *gorm.DB, activityID, issueID, orderID, userID int64, priceDraw int64) {
|
||||
t.Helper()
|
||||
require.NoError(t, db.Exec("INSERT OR IGNORE INTO activities (id, price_draw) VALUES (?, ?)", activityID, priceDraw).Error)
|
||||
require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_issues (id, activity_id) VALUES (?, ?)", issueID, activityID).Error)
|
||||
require.NoError(t, db.Exec("INSERT OR IGNORE INTO activity_draw_logs (id, order_id, issue_id, user_id) VALUES (?, ?, ?, ?)", orderID*100+issueID, orderID, issueID, userID).Error)
|
||||
}
|
||||
|
||||
// --- Plan 01 contract tests ---
|
||||
|
||||
func TestAssetTypeConstants(t *testing.T) {
|
||||
require.Equal(t, AssetType(0), AssetTypeAll)
|
||||
require.Equal(t, AssetType(1), AssetTypePoints)
|
||||
require.Equal(t, AssetType(2), AssetTypeCoupon)
|
||||
require.Equal(t, AssetType(3), AssetTypeItemCard)
|
||||
require.Equal(t, AssetType(4), AssetTypeProduct)
|
||||
require.Equal(t, AssetType(5), AssetTypeFragment)
|
||||
}
|
||||
|
||||
func TestNew_ReturnsService(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
require.NotNil(t, svc)
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
_ = result
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_EmptyParams_ReturnsNoError(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
_ = result
|
||||
}
|
||||
|
||||
// --- Plan 02 QueryUserProfitLoss integration tests ---
|
||||
|
||||
func TestQueryUserProfitLoss_CashOrder(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{
|
||||
ID: 1, UserID: 101, Status: 2,
|
||||
SourceType: 2, OrderNo: "O20260321001",
|
||||
ActualAmount: 800, DiscountAmount: 200,
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{101}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(1000), result.TotalRevenue, "cash revenue = actual + discount")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_RefundedOrderExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{
|
||||
ID: 2, UserID: 102, Status: 4, // refunded
|
||||
SourceType: 2, OrderNo: "O20260321002",
|
||||
ActualAmount: 1000, DiscountAmount: 0,
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{102}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_VoidedInventoryExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 1, UserID: 103, Status: 2, // voided status
|
||||
ValueCents: 5000, OrderID: 0,
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{103}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalCost, "voided inventory (status=2) must not contribute cost")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_RemarkVoidExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 2, UserID: 104, Status: 1, // valid status
|
||||
ValueCents: 3000, OrderID: 0,
|
||||
Remark: "void_20260101",
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{104}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalCost, "inventory with remark containing 'void' must not contribute cost")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_LegacyZeroOrderID(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 3, UserID: 105, Status: 1,
|
||||
ValueCents: 2000, OrderID: 0,
|
||||
Remark: "",
|
||||
})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{105}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(2000), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_AllUsers(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 10, UserID: 201, Status: 2, SourceType: 2, OrderNo: "O001", ActualAmount: 100})
|
||||
seedOrder(t, db, model.Orders{ID: 11, UserID: 202, Status: 2, SourceType: 2, OrderNo: "O002", ActualAmount: 200})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
userIDs := make(map[int64]bool)
|
||||
for _, d := range result.Details {
|
||||
userIDs[d.UserID] = true
|
||||
}
|
||||
require.True(t, userIDs[201], "user 201 must be in results")
|
||||
require.True(t, userIDs[202], "user 202 must be in results")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_FilterByUserID(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 20, UserID: 301, Status: 2, SourceType: 2, OrderNo: "O003", ActualAmount: 500})
|
||||
seedOrder(t, db, model.Orders{ID: 21, UserID: 302, Status: 2, SourceType: 2, OrderNo: "O004", ActualAmount: 600})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{301}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
for _, d := range result.Details {
|
||||
require.Equal(t, int64(301), d.UserID, "only user 301 should appear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_ProfitCalculation(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 30, UserID: 401, Status: 2, SourceType: 2, OrderNo: "O005", ActualAmount: 1000, DiscountAmount: 200})
|
||||
seedInventory(t, db, model.UserInventory{ID: 10, UserID: 401, Status: 1, ValueCents: 800, OrderID: 30, Remark: ""})
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{UserIDs: []int64{401}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(1200), result.TotalRevenue)
|
||||
require.Equal(t, int64(800), result.TotalCost)
|
||||
require.Equal(t, int64(400), result.TotalProfit, "profit = revenue - cost")
|
||||
}
|
||||
|
||||
func TestQueryUserProfitLoss_ResultShape(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
result, err := svc.QueryUserProfitLoss(context.Background(), UserProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Details, "Details must be non-nil slice")
|
||||
require.NotNil(t, result.Breakdown, "Breakdown must be non-nil slice (empty for Phase 1)")
|
||||
}
|
||||
|
||||
// --- Plan 03 QueryActivityProfitLoss integration tests ---
|
||||
|
||||
func TestQueryActivityProfitLoss_CashOrderRevenue(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{
|
||||
ID: 50, UserID: 501, Status: 2,
|
||||
SourceType: 2, OrderNo: "A001",
|
||||
ActualAmount: 600, DiscountAmount: 150,
|
||||
})
|
||||
seedActivitySetup(t, db, 1001, 2001, 50, 501, 100)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1001}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(750), result.TotalRevenue, "cash revenue = actual(600) + discount(150)")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_RefundedOrderExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{
|
||||
ID: 51, UserID: 502, Status: 4, // refunded
|
||||
SourceType: 2, OrderNo: "A002",
|
||||
ActualAmount: 800, DiscountAmount: 0,
|
||||
})
|
||||
seedActivitySetup(t, db, 1002, 2002, 51, 502, 100)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1002}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalRevenue, "refunded order must not contribute revenue")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_VoidedInventoryExcluded(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 20, UserID: 503, ActivityID: 1003,
|
||||
Status: 2, // voided
|
||||
ValueCents: 4000, OrderID: 0,
|
||||
})
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1003}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(0), result.TotalCost, "voided inventory must not contribute cost")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_LegacyZeroOrderID(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedInventory(t, db, model.UserInventory{
|
||||
ID: 21, UserID: 504, ActivityID: 1004,
|
||||
Status: 1, ValueCents: 3500, OrderID: 0,
|
||||
Remark: "",
|
||||
})
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{1004}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(3500), result.TotalCost, "legacy inventory with order_id=0 MUST be included in cost (PNL-08)")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_AllActivities(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 60, UserID: 601, Status: 2, SourceType: 2, OrderNo: "A010", ActualAmount: 100})
|
||||
seedOrder(t, db, model.Orders{ID: 61, UserID: 602, Status: 2, SourceType: 2, OrderNo: "A011", ActualAmount: 200})
|
||||
seedActivitySetup(t, db, 2001, 3001, 60, 601, 50)
|
||||
seedActivitySetup(t, db, 2002, 3002, 61, 602, 50)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
actIDs := make(map[int64]bool)
|
||||
for _, d := range result.Details {
|
||||
actIDs[d.ActivityID] = true
|
||||
}
|
||||
require.True(t, actIDs[2001], "activity 2001 must be in results")
|
||||
require.True(t, actIDs[2002], "activity 2002 must be in results")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_FilterByActivityID(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 70, UserID: 701, Status: 2, SourceType: 2, OrderNo: "A020", ActualAmount: 300})
|
||||
seedOrder(t, db, model.Orders{ID: 71, UserID: 702, Status: 2, SourceType: 2, OrderNo: "A021", ActualAmount: 400})
|
||||
seedActivitySetup(t, db, 3001, 4001, 70, 701, 50)
|
||||
seedActivitySetup(t, db, 3002, 4002, 71, 702, 50)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{3001}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
for _, d := range result.Details {
|
||||
require.Equal(t, int64(3001), d.ActivityID, "only activity 3001 should appear")
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_ProfitCalculation(t *testing.T) {
|
||||
svc, db := newTestSvc(t)
|
||||
seedOrder(t, db, model.Orders{ID: 80, UserID: 801, Status: 2, SourceType: 2, OrderNo: "A030", ActualAmount: 2000, DiscountAmount: 500})
|
||||
seedActivitySetup(t, db, 4001, 5001, 80, 801, 100)
|
||||
seedInventory(t, db, model.UserInventory{ID: 30, UserID: 801, ActivityID: 4001, Status: 1, ValueCents: 1200, OrderID: 80})
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{ActivityIDs: []int64{4001}})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, int64(2500), result.TotalRevenue, "revenue = actual(2000) + discount(500)")
|
||||
require.Equal(t, int64(1200), result.TotalCost)
|
||||
require.Equal(t, int64(1300), result.TotalProfit, "profit = 2500 - 1200")
|
||||
}
|
||||
|
||||
func TestQueryActivityProfitLoss_ResultShape(t *testing.T) {
|
||||
svc, _ := newTestSvc(t)
|
||||
result, err := svc.QueryActivityProfitLoss(context.Background(), ActivityProfitLossParams{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, result.Details, "Details must be non-nil slice")
|
||||
require.NotNil(t, result.Breakdown, "Breakdown must be non-nil empty slice")
|
||||
}
|
||||
51
internal/service/finance/types.go
Normal file
51
internal/service/finance/types.go
Normal file
@ -0,0 +1,51 @@
|
||||
package finance
|
||||
|
||||
import "time"
|
||||
|
||||
// AssetType represents the type of asset in P&L calculations.
|
||||
type AssetType int
|
||||
|
||||
const (
|
||||
AssetTypeAll AssetType = 0 // zero value = all types (DIM-04)
|
||||
AssetTypePoints AssetType = 1
|
||||
AssetTypeCoupon AssetType = 2
|
||||
AssetTypeItemCard AssetType = 3
|
||||
AssetTypeProduct AssetType = 4
|
||||
AssetTypeFragment AssetType = 5
|
||||
)
|
||||
|
||||
// UserProfitLossParams — all fields optional (D-07)
|
||||
type UserProfitLossParams struct {
|
||||
UserIDs []int64 // empty = all users (DIM-01)
|
||||
AssetType AssetType // 0 = all types (DIM-04)
|
||||
StartTime *time.Time // nil = no lower bound (DIM-03)
|
||||
EndTime *time.Time // nil = no upper bound (DIM-03)
|
||||
}
|
||||
|
||||
// ActivityProfitLossParams — all fields optional (D-07)
|
||||
type ActivityProfitLossParams struct {
|
||||
ActivityIDs []int64 // empty = all activities (DIM-02)
|
||||
AssetType AssetType // 0 = all types (DIM-04)
|
||||
StartTime *time.Time // nil = no lower bound (DIM-03)
|
||||
EndTime *time.Time // nil = no upper bound (DIM-03)
|
||||
}
|
||||
|
||||
// ProfitLossDetail — per-user or per-activity row (D-06)
|
||||
type ProfitLossDetail struct {
|
||||
UserID int64 // populated for user dimension queries
|
||||
ActivityID int64 // populated for activity dimension queries
|
||||
Revenue int64 // fen (RET-03: int64 only, no float64 for monetary)
|
||||
Cost int64 // fen
|
||||
Profit int64 // fen
|
||||
ProfitRate float64 // ratio; only float64 field for monetary concept
|
||||
}
|
||||
|
||||
// ProfitLossResult — aggregated P&L result (RET-01)
|
||||
type ProfitLossResult struct {
|
||||
TotalRevenue int64 // fen
|
||||
TotalCost int64 // fen
|
||||
TotalProfit int64 // fen
|
||||
ProfitRate float64 // ratio
|
||||
Details []ProfitLossDetail // per-user or per-activity breakdowns (D-06)
|
||||
Breakdown []interface{} // Phase 2: per-asset-type breakdown (empty for Phase 1)
|
||||
}
|
||||
@ -65,41 +65,52 @@ func (s *service) cleanupExpiredOrders() {
|
||||
func (s *service) cancelExpiredOrder(ctx context.Context, orderID int64, userID int64, couponID int64, pointsAmount int64) {
|
||||
// 1. 恢复优惠券
|
||||
if couponID > 0 {
|
||||
type couponRow struct {
|
||||
AppliedAmount int64
|
||||
DiscountType int32
|
||||
}
|
||||
var cr couponRow
|
||||
s.readDB.OrderCoupons.WithContext(ctx).UnderlyingDB().Raw(`
|
||||
SELECT oc.applied_amount, sc.discount_type
|
||||
FROM order_coupons oc
|
||||
JOIN user_coupons uc ON uc.id = oc.user_coupon_id
|
||||
JOIN system_coupons sc ON sc.id = uc.coupon_id
|
||||
WHERE oc.order_id = ? AND oc.user_coupon_id = ?
|
||||
`, orderID, couponID).Scan(&cr)
|
||||
// 幂等校验:若已记录过 timeout_refund 流水则跳过
|
||||
var refundCount int64
|
||||
s.readDB.UserCouponLedger.WithContext(ctx).UnderlyingDB().Raw(`
|
||||
SELECT COUNT(*) FROM user_coupon_ledger
|
||||
WHERE user_coupon_id = ? AND order_id = ? AND action = 'timeout_refund'
|
||||
`, couponID, orderID).Scan(&refundCount)
|
||||
|
||||
if cr.AppliedAmount > 0 {
|
||||
// 统一回退逻辑:无论券种,统统将预扣金额加回余额,并重置状态为 1 (未使用/有余额)
|
||||
res := s.writeDB.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(`
|
||||
UPDATE user_coupons
|
||||
SET balance_amount = balance_amount + ?,
|
||||
status = 1,
|
||||
used_order_id = NULL,
|
||||
used_at = NULL
|
||||
WHERE id = ? AND status = 4
|
||||
`, cr.AppliedAmount, couponID)
|
||||
if refundCount == 0 {
|
||||
// 优先从 order_coupons 获取实际抵扣金额
|
||||
var appliedAmount int64
|
||||
s.readDB.OrderCoupons.WithContext(ctx).UnderlyingDB().Raw(`
|
||||
SELECT applied_amount FROM order_coupons
|
||||
WHERE order_id = ? AND user_coupon_id = ?
|
||||
`, orderID, couponID).Scan(&appliedAmount)
|
||||
|
||||
if res.RowsAffected > 0 {
|
||||
// 记录流水
|
||||
s.writeDB.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{
|
||||
UserID: userID,
|
||||
UserCouponID: couponID,
|
||||
ChangeAmount: cr.AppliedAmount,
|
||||
BalanceAfter: 0, // 异步流水无法实时算最新,标记 0 或查询后填入,这里暂保持 Action
|
||||
OrderID: orderID,
|
||||
Action: "timeout_refund",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
// 兜底:order_coupons 无记录时,从流水中回推预扣金额
|
||||
if appliedAmount <= 0 {
|
||||
s.readDB.UserCouponLedger.WithContext(ctx).UnderlyingDB().Raw(`
|
||||
SELECT COALESCE(SUM(CASE WHEN change_amount < 0 THEN -change_amount ELSE 0 END), 0)
|
||||
FROM user_coupon_ledger
|
||||
WHERE user_id = ? AND user_coupon_id = ? AND order_id = ? AND action IN ('reserve', 'usage')
|
||||
`, userID, couponID, orderID).Scan(&appliedAmount)
|
||||
}
|
||||
|
||||
if appliedAmount > 0 {
|
||||
// 恢复余额 + 重置状态(不依赖 status 条件,兼容金额券 status=1/2 和冻结券 status=4)
|
||||
res := s.writeDB.UserCoupons.WithContext(ctx).UnderlyingDB().Exec(`
|
||||
UPDATE user_coupons
|
||||
SET balance_amount = balance_amount + ?,
|
||||
status = 1,
|
||||
used_order_id = NULL,
|
||||
used_at = NULL
|
||||
WHERE id = ?
|
||||
`, appliedAmount, couponID)
|
||||
|
||||
if res.RowsAffected > 0 {
|
||||
// 记录流水
|
||||
s.writeDB.UserCouponLedger.WithContext(ctx).Create(&model.UserCouponLedger{
|
||||
UserID: userID,
|
||||
UserCouponID: couponID,
|
||||
ChangeAmount: appliedAmount,
|
||||
OrderID: orderID,
|
||||
Action: "timeout_refund",
|
||||
CreatedAt: time.Now(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
Subproject commit 10c445d1ed670c74e9be1de25dad22c89a66c29b
|
||||
Subproject commit 6878f71e9d4c6161b5b0249dc23c31399824e911
|
||||
Loading…
x
Reference in New Issue
Block a user