2026-03-21 16:28:48 +08:00

355 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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*