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

195 lines
14 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.

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