355 lines
15 KiB
Markdown
355 lines
15 KiB
Markdown
# 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*
|