fix(channel): 修复渠道统计GMV重复计数和商城直购误计入
1. 排除商城直购(source_type=1):GMV和成本过滤条件从IN(1,2,3,4)改为IN(2,3,4) 2. 排除次卡免费使用订单(actual_amount=0):避免购买次卡和使用次卡双重计入GMV - source_type=4 一番赏使用次卡:1578单 44032元重复 - source_type=3 对对碰使用次卡:422单 7042元重复 - 合计去除51074元虚增GMV(29.1%) 3. 成本过滤条件同步修正:source_type IN(2,3,4),total_amount>0 修正后:GMV从175600降至124527元,毛利率从37.4%回到真实的11.8%
This commit is contained in:
parent
749464c03e
commit
8d1eef2f7f
38
.agents/README.md
Normal file
38
.agents/README.md
Normal file
@ -0,0 +1,38 @@
|
||||
# .agents Directory
|
||||
|
||||
This directory contains agent configuration and skills for OpenAI Codex CLI.
|
||||
|
||||
## Structure
|
||||
|
||||
```
|
||||
.agents/
|
||||
config.toml # Main configuration file
|
||||
skills/ # Skill definitions
|
||||
skill-name/
|
||||
SKILL.md # Skill instructions
|
||||
scripts/ # Optional scripts
|
||||
docs/ # Optional documentation
|
||||
README.md # This file
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
The `config.toml` file controls:
|
||||
- Model selection
|
||||
- Approval policies
|
||||
- Sandbox modes
|
||||
- MCP server connections
|
||||
- Skills configuration
|
||||
|
||||
## Skills
|
||||
|
||||
Skills are invoked using `$skill-name` syntax. Each skill has:
|
||||
- YAML frontmatter with metadata
|
||||
- Trigger and skip conditions
|
||||
- Commands and examples
|
||||
|
||||
## Documentation
|
||||
|
||||
- Main instructions: `AGENTS.md` (project root)
|
||||
- Local overrides: `.codex/AGENTS.override.md` (gitignored)
|
||||
- Claude Flow: https://github.com/ruvnet/claude-flow
|
||||
298
.agents/config.toml
Normal file
298
.agents/config.toml
Normal file
@ -0,0 +1,298 @@
|
||||
# =============================================================================
|
||||
# Claude Flow V3 - Codex Configuration
|
||||
# =============================================================================
|
||||
# Generated by: @claude-flow/codex
|
||||
# Documentation: https://github.com/ruvnet/claude-flow
|
||||
#
|
||||
# This file configures the Codex CLI for Claude Flow integration.
|
||||
# Place in .agents/config.toml (project) or .codex/config.toml (user).
|
||||
# =============================================================================
|
||||
|
||||
# =============================================================================
|
||||
# Core Settings
|
||||
# =============================================================================
|
||||
|
||||
# Model selection - the AI model to use for code generation
|
||||
# Options: gpt-5.3-codex, gpt-4o, claude-sonnet, claude-opus
|
||||
model = "gpt-5.3-codex"
|
||||
|
||||
# Approval policy determines when human approval is required
|
||||
# - untrusted: Always require approval
|
||||
# - on-failure: Require approval only after failures
|
||||
# - on-request: Require approval for significant changes
|
||||
# - never: Auto-approve all actions (use with caution)
|
||||
approval_policy = "on-request"
|
||||
|
||||
# Sandbox mode controls file system access
|
||||
# - read-only: Can only read files, no modifications
|
||||
# - workspace-write: Can write within workspace directory
|
||||
# - danger-full-access: Full file system access (dangerous)
|
||||
sandbox_mode = "workspace-write"
|
||||
|
||||
# Web search enables internet access for research
|
||||
# - disabled: No web access
|
||||
# - cached: Use cached results when available
|
||||
# - live: Always fetch fresh results
|
||||
web_search = "cached"
|
||||
|
||||
# =============================================================================
|
||||
# Project Documentation
|
||||
# =============================================================================
|
||||
|
||||
# Maximum bytes to read from AGENTS.md files
|
||||
project_doc_max_bytes = 65536
|
||||
|
||||
# Fallback filenames if AGENTS.md not found
|
||||
project_doc_fallback_filenames = [
|
||||
"AGENTS.md",
|
||||
"TEAM_GUIDE.md",
|
||||
".agents.md"
|
||||
]
|
||||
|
||||
# =============================================================================
|
||||
# Features
|
||||
# =============================================================================
|
||||
|
||||
[features]
|
||||
# Enable child AGENTS.md guidance
|
||||
child_agents_md = true
|
||||
|
||||
# Cache shell environment for faster repeated commands
|
||||
shell_snapshot = true
|
||||
|
||||
# Smart approvals based on request context
|
||||
request_rule = true
|
||||
|
||||
# Enable remote compaction for large histories
|
||||
remote_compaction = true
|
||||
|
||||
# =============================================================================
|
||||
# MCP Servers
|
||||
# =============================================================================
|
||||
|
||||
[mcp_servers.claude-flow]
|
||||
command = "npx"
|
||||
args = ["-y", "@claude-flow/cli@latest"]
|
||||
enabled = true
|
||||
tool_timeout_sec = 120
|
||||
|
||||
# =============================================================================
|
||||
# Skills Configuration
|
||||
# =============================================================================
|
||||
|
||||
[[skills.config]]
|
||||
path = ".agents/skills/swarm-orchestration"
|
||||
enabled = true
|
||||
|
||||
[[skills.config]]
|
||||
path = ".agents/skills/memory-management"
|
||||
enabled = true
|
||||
|
||||
[[skills.config]]
|
||||
path = ".agents/skills/sparc-methodology"
|
||||
enabled = true
|
||||
|
||||
[[skills.config]]
|
||||
path = ".agents/skills/security-audit"
|
||||
enabled = true
|
||||
|
||||
# =============================================================================
|
||||
# Profiles
|
||||
# =============================================================================
|
||||
|
||||
# Development profile - more permissive for local work
|
||||
[profiles.dev]
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "danger-full-access"
|
||||
web_search = "live"
|
||||
|
||||
# Safe profile - maximum restrictions
|
||||
[profiles.safe]
|
||||
approval_policy = "untrusted"
|
||||
sandbox_mode = "read-only"
|
||||
web_search = "disabled"
|
||||
|
||||
# CI profile - for automated pipelines
|
||||
[profiles.ci]
|
||||
approval_policy = "never"
|
||||
sandbox_mode = "workspace-write"
|
||||
web_search = "cached"
|
||||
|
||||
# =============================================================================
|
||||
# History
|
||||
# =============================================================================
|
||||
|
||||
[history]
|
||||
# Save all session transcripts
|
||||
persistence = "save-all"
|
||||
|
||||
# =============================================================================
|
||||
# Shell Environment
|
||||
# =============================================================================
|
||||
|
||||
[shell_environment_policy]
|
||||
# Inherit environment variables
|
||||
inherit = "core"
|
||||
|
||||
# Exclude sensitive variables
|
||||
exclude = ["*_KEY", "*_SECRET", "*_TOKEN", "*_PASSWORD"]
|
||||
|
||||
# =============================================================================
|
||||
# Sandbox Workspace Write Settings
|
||||
# =============================================================================
|
||||
|
||||
[sandbox_workspace_write]
|
||||
# Additional writable paths beyond workspace
|
||||
writable_roots = []
|
||||
|
||||
# Allow network access
|
||||
network_access = true
|
||||
|
||||
# Exclude temp directories
|
||||
exclude_slash_tmp = false
|
||||
|
||||
# =============================================================================
|
||||
# Security Settings
|
||||
# =============================================================================
|
||||
|
||||
[security]
|
||||
# Enable input validation for all user inputs
|
||||
input_validation = true
|
||||
|
||||
# Prevent directory traversal attacks
|
||||
path_traversal_prevention = true
|
||||
|
||||
# Scan for hardcoded secrets
|
||||
secret_scanning = true
|
||||
|
||||
# Scan dependencies for known CVEs
|
||||
cve_scanning = true
|
||||
|
||||
# Maximum file size for operations (bytes)
|
||||
max_file_size = 10485760
|
||||
|
||||
# Allowed file extensions (empty = allow all)
|
||||
allowed_extensions = []
|
||||
|
||||
# Blocked file patterns (regex)
|
||||
blocked_patterns = ["\\.env$", "credentials\\.json$", "\\.pem$", "\\.key$"]
|
||||
|
||||
# =============================================================================
|
||||
# Performance Settings
|
||||
# =============================================================================
|
||||
|
||||
[performance]
|
||||
# Maximum concurrent agents
|
||||
max_agents = 8
|
||||
|
||||
# Task timeout in seconds
|
||||
task_timeout = 300
|
||||
|
||||
# Memory limit per agent
|
||||
memory_limit = "512MB"
|
||||
|
||||
# Enable response caching
|
||||
cache_enabled = true
|
||||
|
||||
# Cache TTL in seconds
|
||||
cache_ttl = 3600
|
||||
|
||||
# Enable parallel task execution
|
||||
parallel_execution = true
|
||||
|
||||
# =============================================================================
|
||||
# Logging Settings
|
||||
# =============================================================================
|
||||
|
||||
[logging]
|
||||
# Log level: debug, info, warn, error
|
||||
level = "info"
|
||||
|
||||
# Log format: json, text, pretty
|
||||
format = "pretty"
|
||||
|
||||
# Log destination: stdout, file, both
|
||||
destination = "stdout"
|
||||
|
||||
# =============================================================================
|
||||
# Neural Intelligence Settings
|
||||
# =============================================================================
|
||||
|
||||
[neural]
|
||||
# Enable SONA (Self-Optimizing Neural Architecture)
|
||||
sona_enabled = true
|
||||
|
||||
# Enable HNSW vector search
|
||||
hnsw_enabled = true
|
||||
|
||||
# HNSW index parameters
|
||||
hnsw_m = 16
|
||||
hnsw_ef_construction = 200
|
||||
hnsw_ef_search = 100
|
||||
|
||||
# Enable pattern learning
|
||||
pattern_learning = true
|
||||
|
||||
# Learning rate for neural adaptation
|
||||
learning_rate = 0.01
|
||||
|
||||
# =============================================================================
|
||||
# Swarm Orchestration Settings
|
||||
# =============================================================================
|
||||
|
||||
[swarm]
|
||||
# Default topology: hierarchical, mesh, ring, star
|
||||
default_topology = "hierarchical"
|
||||
|
||||
# Default strategy: balanced, specialized, adaptive
|
||||
default_strategy = "specialized"
|
||||
|
||||
# Consensus algorithm: raft, byzantine, gossip
|
||||
consensus = "raft"
|
||||
|
||||
# Enable anti-drift measures
|
||||
anti_drift = true
|
||||
|
||||
# Checkpoint interval (tasks)
|
||||
checkpoint_interval = 10
|
||||
|
||||
# =============================================================================
|
||||
# Hooks Configuration
|
||||
# =============================================================================
|
||||
|
||||
[hooks]
|
||||
# Enable lifecycle hooks
|
||||
enabled = true
|
||||
|
||||
# Pre-task hook
|
||||
pre_task = true
|
||||
|
||||
# Post-task hook (for learning)
|
||||
post_task = true
|
||||
|
||||
# Enable neural training on post-edit
|
||||
train_on_edit = true
|
||||
|
||||
# =============================================================================
|
||||
# Background Workers
|
||||
# =============================================================================
|
||||
|
||||
[workers]
|
||||
# Enable background workers
|
||||
enabled = true
|
||||
|
||||
# Worker configuration
|
||||
[workers.audit]
|
||||
enabled = true
|
||||
priority = "critical"
|
||||
interval = 300
|
||||
|
||||
[workers.optimize]
|
||||
enabled = true
|
||||
priority = "high"
|
||||
interval = 600
|
||||
|
||||
[workers.consolidate]
|
||||
enabled = true
|
||||
priority = "low"
|
||||
interval = 1800
|
||||
126
.agents/skills/memory-management/SKILL.md
Normal file
126
.agents/skills/memory-management/SKILL.md
Normal file
@ -0,0 +1,126 @@
|
||||
---
|
||||
name: memory-management
|
||||
description: >
|
||||
AgentDB memory system with HNSW vector search. Provides 150x-12,500x faster pattern retrieval, persistent storage, and semantic search capabilities for learning and knowledge management.
|
||||
Use when: need to store successful patterns, searching for similar solutions, semantic lookup of past work, learning from previous tasks, sharing knowledge between agents, building knowledge base.
|
||||
Skip when: no learning needed, ephemeral one-off tasks, external data sources available, read-only exploration.
|
||||
---
|
||||
|
||||
# Memory Management Skill
|
||||
|
||||
## Purpose
|
||||
AgentDB memory system with HNSW vector search. Provides 150x-12,500x faster pattern retrieval, persistent storage, and semantic search capabilities for learning and knowledge management.
|
||||
|
||||
## When to Trigger
|
||||
- need to store successful patterns
|
||||
- searching for similar solutions
|
||||
- semantic lookup of past work
|
||||
- learning from previous tasks
|
||||
- sharing knowledge between agents
|
||||
- building knowledge base
|
||||
|
||||
## When to Skip
|
||||
- no learning needed
|
||||
- ephemeral one-off tasks
|
||||
- external data sources available
|
||||
- read-only exploration
|
||||
|
||||
## Commands
|
||||
|
||||
### Store Pattern
|
||||
Store a pattern or knowledge item in memory
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli memory store --key "[key]" --value "[value]" --namespace patterns
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli memory store --key "auth-jwt-pattern" --value "JWT validation with refresh tokens" --namespace patterns
|
||||
```
|
||||
|
||||
### Semantic Search
|
||||
Search memory using semantic similarity
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli memory search --query "[search terms]" --limit 10
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli memory search --query "authentication best practices" --limit 5
|
||||
```
|
||||
|
||||
### Retrieve Entry
|
||||
Retrieve a specific memory entry by key
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli memory get --key "[key]" --namespace [namespace]
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli memory get --key "auth-jwt-pattern" --namespace patterns
|
||||
```
|
||||
|
||||
### List Entries
|
||||
List all entries in a namespace
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli memory list --namespace [namespace]
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli memory list --namespace patterns --limit 20
|
||||
```
|
||||
|
||||
### Delete Entry
|
||||
Delete a memory entry
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli memory delete --key "[key]" --namespace [namespace]
|
||||
```
|
||||
|
||||
### Initialize HNSW Index
|
||||
Initialize HNSW vector search index
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli memory init --enable-hnsw
|
||||
```
|
||||
|
||||
### Memory Stats
|
||||
Show memory usage statistics
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli memory stats
|
||||
```
|
||||
|
||||
### Export Memory
|
||||
Export memory to JSON
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli memory export --output memory-backup.json
|
||||
```
|
||||
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `memory-backup` | `.agents/scripts/memory-backup.sh` | Backup memory to external storage |
|
||||
| `memory-consolidate` | `.agents/scripts/memory-consolidate.sh` | Consolidate and optimize memory |
|
||||
|
||||
|
||||
## References
|
||||
|
||||
| Document | Path | Description |
|
||||
|----------|------|-------------|
|
||||
| `HNSW Guide` | `docs/hnsw.md` | HNSW vector search configuration |
|
||||
| `Memory Schema` | `docs/memory-schema.md` | Memory namespace and schema reference |
|
||||
|
||||
## Best Practices
|
||||
1. Check memory for existing patterns before starting
|
||||
2. Use hierarchical topology for coordination
|
||||
3. Store successful patterns after completion
|
||||
4. Document any new learnings
|
||||
16
.agents/skills/memory-management/scripts/memory-backup.sh
Normal file
16
.agents/skills/memory-management/scripts/memory-backup.sh
Normal file
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Memory Management - Backup Script
|
||||
# Export memory to backup file
|
||||
|
||||
set -e
|
||||
|
||||
BACKUP_DIR="${BACKUP_DIR:-./.backups}"
|
||||
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
|
||||
BACKUP_FILE="${BACKUP_DIR}/memory_${TIMESTAMP}.json"
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
echo "Backing up memory to $BACKUP_FILE..."
|
||||
npx @claude-flow/cli memory export --output "$BACKUP_FILE"
|
||||
|
||||
echo "Backup complete: $BACKUP_FILE"
|
||||
@ -0,0 +1,11 @@
|
||||
#!/bin/bash
|
||||
# Memory Management - Consolidate Script
|
||||
# Optimize and consolidate memory
|
||||
|
||||
set -e
|
||||
|
||||
echo "Running memory consolidation..."
|
||||
npx @claude-flow/cli hooks worker dispatch --trigger consolidate
|
||||
|
||||
echo "Memory consolidation complete"
|
||||
npx @claude-flow/cli memory stats
|
||||
135
.agents/skills/security-audit/SKILL.md
Normal file
135
.agents/skills/security-audit/SKILL.md
Normal file
@ -0,0 +1,135 @@
|
||||
---
|
||||
name: security-audit
|
||||
description: >
|
||||
Comprehensive security scanning and vulnerability detection. Includes input validation, path traversal prevention, CVE detection, and secure coding pattern enforcement.
|
||||
Use when: authentication implementation, authorization logic, payment processing, user data handling, API endpoint creation, file upload handling, database queries, external API integration.
|
||||
Skip when: read-only operations on public data, internal development tooling, static documentation, styling changes.
|
||||
---
|
||||
|
||||
# Security Audit Skill
|
||||
|
||||
## Purpose
|
||||
Comprehensive security scanning and vulnerability detection. Includes input validation, path traversal prevention, CVE detection, and secure coding pattern enforcement.
|
||||
|
||||
## When to Trigger
|
||||
- authentication implementation
|
||||
- authorization logic
|
||||
- payment processing
|
||||
- user data handling
|
||||
- API endpoint creation
|
||||
- file upload handling
|
||||
- database queries
|
||||
- external API integration
|
||||
|
||||
## When to Skip
|
||||
- read-only operations on public data
|
||||
- internal development tooling
|
||||
- static documentation
|
||||
- styling changes
|
||||
|
||||
## Commands
|
||||
|
||||
### Full Security Scan
|
||||
Run comprehensive security analysis on the codebase
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli security scan --depth full
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli security scan --depth full --output security-report.json
|
||||
```
|
||||
|
||||
### Input Validation Check
|
||||
Check for input validation issues
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli security scan --check input-validation
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli security scan --check input-validation --path ./src/api
|
||||
```
|
||||
|
||||
### Path Traversal Check
|
||||
Check for path traversal vulnerabilities
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli security scan --check path-traversal
|
||||
```
|
||||
|
||||
### SQL Injection Check
|
||||
Check for SQL injection vulnerabilities
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli security scan --check sql-injection
|
||||
```
|
||||
|
||||
### XSS Check
|
||||
Check for cross-site scripting vulnerabilities
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli security scan --check xss
|
||||
```
|
||||
|
||||
### CVE Scan
|
||||
Scan dependencies for known CVEs
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli security cve --scan
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli security cve --scan --severity high
|
||||
```
|
||||
|
||||
### Security Audit Report
|
||||
Generate full security audit report
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli security audit --report
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli security audit --report --format markdown --output SECURITY.md
|
||||
```
|
||||
|
||||
### Threat Modeling
|
||||
Run threat modeling analysis
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli security threats --analyze
|
||||
```
|
||||
|
||||
### Validate Secrets
|
||||
Check for hardcoded secrets
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli security validate --check secrets
|
||||
```
|
||||
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `security-scan` | `.agents/scripts/security-scan.sh` | Run full security scan pipeline |
|
||||
| `cve-remediate` | `.agents/scripts/cve-remediate.sh` | Auto-remediate known CVEs |
|
||||
|
||||
|
||||
## References
|
||||
|
||||
| Document | Path | Description |
|
||||
|----------|------|-------------|
|
||||
| `Security Checklist` | `docs/security-checklist.md` | Security review checklist |
|
||||
| `OWASP Guide` | `docs/owasp-top10.md` | OWASP Top 10 mitigation guide |
|
||||
|
||||
## Best Practices
|
||||
1. Check memory for existing patterns before starting
|
||||
2. Use hierarchical topology for coordination
|
||||
3. Store successful patterns after completion
|
||||
4. Document any new learnings
|
||||
16
.agents/skills/security-audit/scripts/cve-remediate.sh
Normal file
16
.agents/skills/security-audit/scripts/cve-remediate.sh
Normal file
@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Security Audit - CVE Remediation Script
|
||||
# Auto-remediate known CVEs
|
||||
|
||||
set -e
|
||||
|
||||
echo "Scanning for CVEs..."
|
||||
npx @claude-flow/cli security cve --scan --severity high
|
||||
|
||||
echo "Attempting auto-remediation..."
|
||||
npm audit fix
|
||||
|
||||
echo "Re-scanning after remediation..."
|
||||
npx @claude-flow/cli security cve --scan
|
||||
|
||||
echo "CVE remediation complete"
|
||||
33
.agents/skills/security-audit/scripts/security-scan.sh
Normal file
33
.agents/skills/security-audit/scripts/security-scan.sh
Normal file
@ -0,0 +1,33 @@
|
||||
#!/bin/bash
|
||||
# Security Audit - Full Scan Script
|
||||
# Run comprehensive security scan pipeline
|
||||
|
||||
set -e
|
||||
|
||||
echo "Running full security scan..."
|
||||
|
||||
# Input validation
|
||||
echo "Checking input validation..."
|
||||
npx @claude-flow/cli security scan --check input-validation
|
||||
|
||||
# Path traversal
|
||||
echo "Checking path traversal..."
|
||||
npx @claude-flow/cli security scan --check path-traversal
|
||||
|
||||
# SQL injection
|
||||
echo "Checking SQL injection..."
|
||||
npx @claude-flow/cli security scan --check sql-injection
|
||||
|
||||
# XSS
|
||||
echo "Checking XSS..."
|
||||
npx @claude-flow/cli security scan --check xss
|
||||
|
||||
# Secrets
|
||||
echo "Checking for hardcoded secrets..."
|
||||
npx @claude-flow/cli security validate --check secrets
|
||||
|
||||
# CVE scan
|
||||
echo "Scanning dependencies for CVEs..."
|
||||
npx @claude-flow/cli security cve --scan
|
||||
|
||||
echo "Security scan complete"
|
||||
118
.agents/skills/sparc-methodology/SKILL.md
Normal file
118
.agents/skills/sparc-methodology/SKILL.md
Normal file
@ -0,0 +1,118 @@
|
||||
---
|
||||
name: sparc-methodology
|
||||
description: >
|
||||
SPARC development workflow: Specification, Pseudocode, Architecture, Refinement, Completion. A structured approach for complex implementations that ensures thorough planning before coding.
|
||||
Use when: new feature implementation, complex implementations, architectural changes, system redesign, integration work, unclear requirements.
|
||||
Skip when: simple bug fixes, documentation updates, configuration changes, well-defined small tasks, routine maintenance.
|
||||
---
|
||||
|
||||
# Sparc Methodology Skill
|
||||
|
||||
## Purpose
|
||||
SPARC development workflow: Specification, Pseudocode, Architecture, Refinement, Completion. A structured approach for complex implementations that ensures thorough planning before coding.
|
||||
|
||||
## When to Trigger
|
||||
- new feature implementation
|
||||
- complex implementations
|
||||
- architectural changes
|
||||
- system redesign
|
||||
- integration work
|
||||
- unclear requirements
|
||||
|
||||
## When to Skip
|
||||
- simple bug fixes
|
||||
- documentation updates
|
||||
- configuration changes
|
||||
- well-defined small tasks
|
||||
- routine maintenance
|
||||
|
||||
## Commands
|
||||
|
||||
### Specification Phase
|
||||
Define requirements, acceptance criteria, and constraints
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "specification: [requirements]"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "specification: user authentication with OAuth2, MFA, and session management"
|
||||
```
|
||||
|
||||
### Pseudocode Phase
|
||||
Write high-level pseudocode for the implementation
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "pseudocode: [feature]"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "pseudocode: OAuth2 login flow with token refresh"
|
||||
```
|
||||
|
||||
### Architecture Phase
|
||||
Design system structure, interfaces, and dependencies
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "architecture: [design]"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "architecture: auth module with service layer, repository, and API endpoints"
|
||||
```
|
||||
|
||||
### Refinement Phase
|
||||
Iterate on the design based on feedback
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "refinement: [feedback]"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "refinement: add rate limiting and brute force protection"
|
||||
```
|
||||
|
||||
### Completion Phase
|
||||
Finalize implementation with tests and documentation
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "completion: [final checks]"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "completion: verify all tests pass, update API docs, security review"
|
||||
```
|
||||
|
||||
### SPARC Coordinator
|
||||
Spawn SPARC coordinator agent
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli agent spawn --type sparc-coord --name sparc-lead
|
||||
```
|
||||
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `sparc-init` | `.agents/scripts/sparc-init.sh` | Initialize SPARC workflow for a new feature |
|
||||
| `sparc-review` | `.agents/scripts/sparc-review.sh` | Run SPARC phase review checklist |
|
||||
|
||||
|
||||
## References
|
||||
|
||||
| Document | Path | Description |
|
||||
|----------|------|-------------|
|
||||
| `SPARC Overview` | `docs/sparc.md` | Complete SPARC methodology guide |
|
||||
| `Phase Templates` | `docs/sparc-templates.md` | Templates for each SPARC phase |
|
||||
|
||||
## Best Practices
|
||||
1. Check memory for existing patterns before starting
|
||||
2. Use hierarchical topology for coordination
|
||||
3. Store successful patterns after completion
|
||||
4. Document any new learnings
|
||||
21
.agents/skills/sparc-methodology/scripts/sparc-init.sh
Normal file
21
.agents/skills/sparc-methodology/scripts/sparc-init.sh
Normal file
@ -0,0 +1,21 @@
|
||||
#!/bin/bash
|
||||
# SPARC Methodology - Init Script
|
||||
# Initialize SPARC workflow for a new feature
|
||||
|
||||
set -e
|
||||
|
||||
FEATURE_NAME="${1:-new-feature}"
|
||||
|
||||
echo "Initializing SPARC workflow for: $FEATURE_NAME"
|
||||
|
||||
# Create SPARC documentation directory
|
||||
mkdir -p "./docs/sparc/$FEATURE_NAME"
|
||||
|
||||
# Create phase files
|
||||
touch "./docs/sparc/$FEATURE_NAME/1-specification.md"
|
||||
touch "./docs/sparc/$FEATURE_NAME/2-pseudocode.md"
|
||||
touch "./docs/sparc/$FEATURE_NAME/3-architecture.md"
|
||||
touch "./docs/sparc/$FEATURE_NAME/4-refinement.md"
|
||||
touch "./docs/sparc/$FEATURE_NAME/5-completion.md"
|
||||
|
||||
echo "SPARC workflow initialized in ./docs/sparc/$FEATURE_NAME"
|
||||
18
.agents/skills/sparc-methodology/scripts/sparc-review.sh
Normal file
18
.agents/skills/sparc-methodology/scripts/sparc-review.sh
Normal file
@ -0,0 +1,18 @@
|
||||
#!/bin/bash
|
||||
# SPARC Methodology - Review Script
|
||||
# Run SPARC phase review checklist
|
||||
|
||||
set -e
|
||||
|
||||
FEATURE_DIR="${1:-.}"
|
||||
|
||||
echo "SPARC Phase Review Checklist"
|
||||
echo "============================="
|
||||
|
||||
for phase in specification pseudocode architecture refinement completion; do
|
||||
if [ -f "$FEATURE_DIR/${phase}.md" ]; then
|
||||
echo "[x] $phase - found"
|
||||
else
|
||||
echo "[ ] $phase - missing"
|
||||
fi
|
||||
done
|
||||
114
.agents/skills/swarm-orchestration/SKILL.md
Normal file
114
.agents/skills/swarm-orchestration/SKILL.md
Normal file
@ -0,0 +1,114 @@
|
||||
---
|
||||
name: swarm-orchestration
|
||||
description: >
|
||||
Multi-agent swarm coordination for complex tasks. Uses hierarchical topology with specialized agents to break down and execute complex work across multiple files and modules.
|
||||
Use when: 3+ files need changes, new feature implementation, cross-module refactoring, API changes with tests, security-related changes, performance optimization across codebase, database schema changes.
|
||||
Skip when: single file edits, simple bug fixes (1-2 lines), documentation updates, configuration changes, quick exploration.
|
||||
---
|
||||
|
||||
# Swarm Orchestration Skill
|
||||
|
||||
## Purpose
|
||||
Multi-agent swarm coordination for complex tasks. Uses hierarchical topology with specialized agents to break down and execute complex work across multiple files and modules.
|
||||
|
||||
## When to Trigger
|
||||
- 3+ files need changes
|
||||
- new feature implementation
|
||||
- cross-module refactoring
|
||||
- API changes with tests
|
||||
- security-related changes
|
||||
- performance optimization across codebase
|
||||
- database schema changes
|
||||
|
||||
## When to Skip
|
||||
- single file edits
|
||||
- simple bug fixes (1-2 lines)
|
||||
- documentation updates
|
||||
- configuration changes
|
||||
- quick exploration
|
||||
|
||||
## Commands
|
||||
|
||||
### Initialize Swarm
|
||||
Start a new swarm with hierarchical topology (anti-drift)
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli swarm init --topology hierarchical --max-agents 8 --strategy specialized
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli swarm init --topology hierarchical --max-agents 6 --strategy specialized
|
||||
```
|
||||
|
||||
### Route Task
|
||||
Route a task to the appropriate agents based on task type
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "[task description]"
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli hooks route --task "implement OAuth2 authentication flow"
|
||||
```
|
||||
|
||||
### Spawn Agent
|
||||
Spawn a specific agent type
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli agent spawn --type [type] --name [name]
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli agent spawn --type coder --name impl-auth
|
||||
```
|
||||
|
||||
### Monitor Status
|
||||
Check the current swarm status
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli swarm status --verbose
|
||||
```
|
||||
|
||||
### Orchestrate Task
|
||||
Orchestrate a task across multiple agents
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli task orchestrate --task "[task]" --strategy adaptive
|
||||
```
|
||||
|
||||
**Example:**
|
||||
```bash
|
||||
npx @claude-flow/cli task orchestrate --task "refactor auth module" --strategy parallel --max-agents 4
|
||||
```
|
||||
|
||||
### List Agents
|
||||
List all active agents
|
||||
|
||||
```bash
|
||||
npx @claude-flow/cli agent list --filter active
|
||||
```
|
||||
|
||||
|
||||
## Scripts
|
||||
|
||||
| Script | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `swarm-start` | `.agents/scripts/swarm-start.sh` | Initialize swarm with default settings |
|
||||
| `swarm-monitor` | `.agents/scripts/swarm-monitor.sh` | Real-time swarm monitoring dashboard |
|
||||
|
||||
|
||||
## References
|
||||
|
||||
| Document | Path | Description |
|
||||
|----------|------|-------------|
|
||||
| `Agent Types` | `docs/agents.md` | Complete list of agent types and capabilities |
|
||||
| `Topology Guide` | `docs/topology.md` | Swarm topology configuration guide |
|
||||
|
||||
## Best Practices
|
||||
1. Check memory for existing patterns before starting
|
||||
2. Use hierarchical topology for coordination
|
||||
3. Store successful patterns after completion
|
||||
4. Document any new learnings
|
||||
@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
# Swarm Orchestration - Monitor Script
|
||||
# Real-time swarm monitoring
|
||||
|
||||
set -e
|
||||
|
||||
echo "Starting swarm monitor..."
|
||||
npx @claude-flow/cli swarm status --watch --interval 5
|
||||
14
.agents/skills/swarm-orchestration/scripts/swarm-start.sh
Normal file
14
.agents/skills/swarm-orchestration/scripts/swarm-start.sh
Normal file
@ -0,0 +1,14 @@
|
||||
#!/bin/bash
|
||||
# Swarm Orchestration - Start Script
|
||||
# Initialize swarm with default anti-drift settings
|
||||
|
||||
set -e
|
||||
|
||||
echo "Initializing hierarchical swarm..."
|
||||
npx @claude-flow/cli swarm init \
|
||||
--topology hierarchical \
|
||||
--max-agents 8 \
|
||||
--strategy specialized
|
||||
|
||||
echo "Swarm initialized successfully"
|
||||
npx @claude-flow/cli swarm status
|
||||
144
.claude-flow/tasks/store.json
Normal file
144
.claude-flow/tasks/store.json
Normal file
@ -0,0 +1,144 @@
|
||||
{
|
||||
"tasks": {
|
||||
"task-1773164482180-yqy5i7": {
|
||||
"taskId": "task-1773164482180-yqy5i7",
|
||||
"type": "feature",
|
||||
"description": "Add PkgID field to OrderRemark struct and parse pkg_id:XX in remark.Parse()",
|
||||
"priority": "high",
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"assignedTo": [],
|
||||
"tags": [
|
||||
"remark",
|
||||
"parsing",
|
||||
"ichiban"
|
||||
],
|
||||
"createdAt": "2026-03-10T17:41:22.180Z",
|
||||
"startedAt": null,
|
||||
"completedAt": "2026-03-10T17:41:55.018Z",
|
||||
"result": {
|
||||
"reason": "Added PkgID field to OrderRemark struct and pkg_id: parsing branch in Parse()"
|
||||
}
|
||||
},
|
||||
"task-1773164482201-s9jemx": {
|
||||
"taskId": "task-1773164482201-s9jemx",
|
||||
"type": "feature",
|
||||
"description": "Extend calcPaidByPriceDraw with three-way classification: Case1 ActivityID>0 (lottery), Case2 IssueID>0 (matching game via activity_issues), Case3 PkgID>0 (ichiban via game_pass_packages)",
|
||||
"priority": "high",
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"assignedTo": [],
|
||||
"tags": [
|
||||
"channel-stats",
|
||||
"matching-game",
|
||||
"ichiban",
|
||||
"calcPaidByPriceDraw"
|
||||
],
|
||||
"createdAt": "2026-03-10T17:41:22.201Z",
|
||||
"startedAt": null,
|
||||
"completedAt": "2026-03-10T17:42:34.567Z",
|
||||
"result": {
|
||||
"reason": "Extended calcPaidByPriceDraw with three-way classification: lottery (ActivityID), matching game (IssueID→activity_issues→activities), ichiban (PkgID→game_pass_packages)"
|
||||
}
|
||||
},
|
||||
"task-1773164482206-mhmqsb": {
|
||||
"taskId": "task-1773164482206-mhmqsb",
|
||||
"type": "feature",
|
||||
"description": "Build verification: run make build-mac to ensure compilation passes after changes",
|
||||
"priority": "normal",
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"assignedTo": [],
|
||||
"tags": [
|
||||
"build",
|
||||
"verification"
|
||||
],
|
||||
"createdAt": "2026-03-10T17:41:22.206Z",
|
||||
"startedAt": null,
|
||||
"completedAt": "2026-03-10T17:43:27.419Z",
|
||||
"result": {
|
||||
"reason": "Build passed successfully on macOS"
|
||||
}
|
||||
},
|
||||
"task-1773166041411-fmshox": {
|
||||
"taskId": "task-1773166041411-fmshox",
|
||||
"type": "feature",
|
||||
"description": "Extend StatsOverview and StatsDailyItem structs with cost_cents, profit_cents, total_cost, total_profit fields",
|
||||
"priority": "high",
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"assignedTo": [],
|
||||
"tags": [
|
||||
"channel-stats",
|
||||
"profit-loss",
|
||||
"structs"
|
||||
],
|
||||
"createdAt": "2026-03-10T18:07:21.411Z",
|
||||
"startedAt": null,
|
||||
"completedAt": "2026-03-10T18:07:47.297Z",
|
||||
"result": {
|
||||
"reason": "Extended StatsOverview with TotalCostCents/TotalProfitCents/TotalCost/TotalProfit and StatsDailyItem with CostCents/ProfitCents"
|
||||
}
|
||||
},
|
||||
"task-1773166041417-di6rsd": {
|
||||
"taskId": "task-1773166041417-di6rsd",
|
||||
"type": "feature",
|
||||
"description": "Implement calcCostByInventory helper function: query user_inventory with item card multiplier, grouped by date",
|
||||
"priority": "high",
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"assignedTo": [],
|
||||
"tags": [
|
||||
"channel-stats",
|
||||
"profit-loss",
|
||||
"cost-calculation"
|
||||
],
|
||||
"createdAt": "2026-03-10T18:07:21.417Z",
|
||||
"startedAt": null,
|
||||
"completedAt": "2026-03-10T18:08:23.196Z",
|
||||
"result": {
|
||||
"reason": "Implemented calcCostByInventory with 6-table JOIN chain, item card multiplier, COALESCE fallback, and optional date range"
|
||||
}
|
||||
},
|
||||
"task-1773166041422-efwp8w": {
|
||||
"taskId": "task-1773166041422-efwp8w",
|
||||
"type": "feature",
|
||||
"description": "Integrate calcCostByInventory into GetStats: Overview all-time cost + daily trend cost/profit",
|
||||
"priority": "high",
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"assignedTo": [],
|
||||
"tags": [
|
||||
"channel-stats",
|
||||
"profit-loss",
|
||||
"integration"
|
||||
],
|
||||
"createdAt": "2026-03-10T18:07:21.422Z",
|
||||
"startedAt": null,
|
||||
"completedAt": "2026-03-10T18:09:08.231Z",
|
||||
"result": {
|
||||
"reason": "Integrated calcCostByInventory into GetStats: Overview all-time cost/profit + daily trend cost/profit"
|
||||
}
|
||||
},
|
||||
"task-1773166041428-fwp50t": {
|
||||
"taskId": "task-1773166041428-fwp50t",
|
||||
"type": "feature",
|
||||
"description": "Build verification and integration test against dev_game database",
|
||||
"priority": "normal",
|
||||
"status": "completed",
|
||||
"progress": 100,
|
||||
"assignedTo": [],
|
||||
"tags": [
|
||||
"build",
|
||||
"verification"
|
||||
],
|
||||
"createdAt": "2026-03-10T18:07:21.428Z",
|
||||
"startedAt": null,
|
||||
"completedAt": "2026-03-10T18:10:06.759Z",
|
||||
"result": {
|
||||
"reason": "Build passed. Integration test shows: 754 inventory records, 2 with double card (双倍快乐水 ×2.0), cost=5100.50元, revenue(actual_amount)=545.00元"
|
||||
}
|
||||
}
|
||||
},
|
||||
"version": "3.0.0"
|
||||
}
|
||||
249
.claude/plan/channel-stats-frontend.md
Normal file
249
.claude/plan/channel-stats-frontend.md
Normal file
@ -0,0 +1,249 @@
|
||||
# 渠道统计 — 前端盈亏展示
|
||||
|
||||
## 📋 实施计划:渠道统计页面新增成本/盈亏展示
|
||||
|
||||
### 任务类型
|
||||
- [x] 前端
|
||||
- [ ] 后端
|
||||
- [ ] 全栈
|
||||
|
||||
### 需求概述
|
||||
|
||||
后端 `/admin/channels/:id/stats` 接口已新增以下字段:
|
||||
|
||||
**Overview 新增**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `total_cost_cents` | number | 总成本(分) |
|
||||
| `total_profit_cents` | number | 盈亏(分) = paid - cost |
|
||||
| `total_cost` | number | 总成本(元) |
|
||||
| `total_profit` | number | 盈亏(元) |
|
||||
|
||||
**趋势图每日新增**:
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| `cost_cents` | number | 当日成本(分) |
|
||||
| `profit_cents` | number | 当日盈亏(分) |
|
||||
|
||||
### 技术方案
|
||||
|
||||
#### UI 设计
|
||||
|
||||
**Overview 区域**:
|
||||
- 现有 3 个卡片(用户、订单、实付金额)→ 扩展为 **5 个卡片**
|
||||
- 新增:**总成本** 卡片 + **盈亏** 卡片
|
||||
- 布局:从 `grid-cols-3` 改为 `grid-cols-5`(或在移动端自适应 `grid-cols-2 md:grid-cols-5`)
|
||||
- 盈亏卡片需根据正/负值显示不同颜色(盈利=绿色,亏损=红色)
|
||||
|
||||
**趋势图区域**:
|
||||
- 现有 2 个 Tab(用户增长、付费数据)→ 新增第 3 个 Tab:**盈亏分析**
|
||||
- 盈亏分析 Tab 包含 3 条曲线:实付金额、成本、盈亏
|
||||
- 盈亏曲线可使用虚线区分
|
||||
|
||||
### 实施步骤
|
||||
|
||||
#### Step 1: 更新 TypeScript 类型定义
|
||||
|
||||
**文件**:`web/admin/src/api/channels.ts`
|
||||
|
||||
在 `StatsOverview` 接口新增:
|
||||
```typescript
|
||||
export interface StatsOverview {
|
||||
total_users: number
|
||||
total_orders: number
|
||||
total_gmv: number
|
||||
total_paid_cents?: number
|
||||
// 新增
|
||||
total_cost_cents?: number // 总成本(分)
|
||||
total_profit_cents?: number // 盈亏(分)
|
||||
total_cost?: number // 总成本(元)
|
||||
total_profit?: number // 盈亏(元)
|
||||
}
|
||||
```
|
||||
|
||||
在 `StatsDailyItem` 接口新增:
|
||||
```typescript
|
||||
export interface StatsDailyItem {
|
||||
date: string
|
||||
user_count: number
|
||||
order_count: number
|
||||
gmv: number
|
||||
paid_cents?: number
|
||||
// 新增
|
||||
cost_cents?: number // 当日成本(分)
|
||||
profit_cents?: number // 当日盈亏(分)
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2: 更新 Overview 卡片区域
|
||||
|
||||
**文件**:`web/admin/src/views/operations/channels/index.vue`
|
||||
|
||||
**2.1** 布局从 `grid-cols-3` 改为 `grid-cols-5`
|
||||
|
||||
**2.2** 新增两个 `ArtStatsCard`:
|
||||
|
||||
```vue
|
||||
<!-- 总成本 -->
|
||||
<ArtStatsCard
|
||||
title="总成本"
|
||||
:count="totalCostYuan"
|
||||
:decimals="2"
|
||||
icon="ri:funds-line"
|
||||
box-style="bg-purple-50"
|
||||
text-color="#7C3AED"
|
||||
icon-style="bg-purple-500"
|
||||
description="总奖品成本"
|
||||
/>
|
||||
|
||||
<!-- 盈亏 -->
|
||||
<ArtStatsCard
|
||||
title="盈亏"
|
||||
:count="totalProfitYuan"
|
||||
:decimals="2"
|
||||
icon="ri:bar-chart-2-line"
|
||||
:box-style="profitCardStyle"
|
||||
:text-color="profitTextColor"
|
||||
:icon-style="profitIconStyle"
|
||||
:description="profitDescription"
|
||||
/>
|
||||
```
|
||||
|
||||
**2.3** 新增 computed 属性:
|
||||
|
||||
```typescript
|
||||
const totalCostYuan = computed(() => {
|
||||
const cents = statsData.value.overview.total_cost_cents
|
||||
if (typeof cents === 'number') {
|
||||
return Number((cents / 100).toFixed(2))
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const totalProfitYuan = computed(() => {
|
||||
const cents = statsData.value.overview.total_profit_cents
|
||||
if (typeof cents === 'number') {
|
||||
return Number((cents / 100).toFixed(2))
|
||||
}
|
||||
return 0
|
||||
})
|
||||
|
||||
const profitCardStyle = computed(() =>
|
||||
totalProfitYuan.value >= 0 ? 'bg-green-50' : 'bg-red-50'
|
||||
)
|
||||
|
||||
const profitTextColor = computed(() =>
|
||||
totalProfitYuan.value >= 0 ? '#10B981' : '#EF4444'
|
||||
)
|
||||
|
||||
const profitIconStyle = computed(() =>
|
||||
totalProfitYuan.value >= 0 ? 'bg-green-500' : 'bg-red-500'
|
||||
)
|
||||
|
||||
const profitDescription = computed(() =>
|
||||
totalProfitYuan.value >= 0 ? '盈利' : '亏损'
|
||||
)
|
||||
```
|
||||
|
||||
#### Step 3: 更新趋势图 Tab
|
||||
|
||||
**文件**:`web/admin/src/views/operations/channels/index.vue`
|
||||
|
||||
**3.1** 在 `el-radio-group` 新增 Tab:
|
||||
|
||||
```vue
|
||||
<el-radio-group v-model="statsTab" size="small">
|
||||
<el-radio-button label="growth">用户增长</el-radio-button>
|
||||
<el-radio-button label="revenue">付费数据</el-radio-button>
|
||||
<el-radio-button label="profit">盈亏分析</el-radio-button>
|
||||
</el-radio-group>
|
||||
```
|
||||
|
||||
**3.2** 在 `chartData` computed 中新增 `profit` 分支:
|
||||
|
||||
```typescript
|
||||
const chartData = computed(() => {
|
||||
if (statsTab.value === 'growth') {
|
||||
return [
|
||||
{ name: '新增用户', data: statsData.value.daily.map(i => i.user_count), smooth: true, color: '#409EFF' }
|
||||
]
|
||||
} else if (statsTab.value === 'revenue') {
|
||||
return [
|
||||
{ name: '订单数', data: statsData.value.daily.map(i => i.order_count), smooth: true, color: '#67C23A' },
|
||||
{ name: '实付金额', data: statsData.value.daily.map(i => getDailyPaidYuan(i)), smooth: true, color: '#E6A23C' }
|
||||
]
|
||||
} else {
|
||||
// profit tab
|
||||
return [
|
||||
{ name: '实付(元)', data: statsData.value.daily.map(i => getDailyPaidYuan(i)), smooth: true, color: '#E6A23C' },
|
||||
{ name: '成本(元)', data: statsData.value.daily.map(i => getDailyCostYuan(i)), smooth: true, color: '#7C3AED' },
|
||||
{ name: '盈亏(元)', data: statsData.value.daily.map(i => getDailyProfitYuan(i)), smooth: true, color: '#10B981' }
|
||||
]
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
**3.3** 新增辅助函数:
|
||||
|
||||
```typescript
|
||||
function getDailyCostYuan(item: { cost_cents?: number }) {
|
||||
if (typeof item.cost_cents === 'number') {
|
||||
return Number((item.cost_cents / 100).toFixed(2))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
function getDailyProfitYuan(item: { profit_cents?: number }) {
|
||||
if (typeof item.profit_cents === 'number') {
|
||||
return Number((item.profit_cents / 100).toFixed(2))
|
||||
}
|
||||
return 0
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 4: 更新 statsData 初始值
|
||||
|
||||
**文件**:`web/admin/src/views/operations/channels/index.vue`
|
||||
|
||||
```typescript
|
||||
const statsData = ref<ChannelStatsRes>({
|
||||
overview: {
|
||||
total_users: 0, total_orders: 0, total_gmv: 0, total_paid_cents: 0,
|
||||
total_cost_cents: 0, total_profit_cents: 0, total_cost: 0, total_profit: 0
|
||||
},
|
||||
daily: []
|
||||
})
|
||||
```
|
||||
|
||||
### 关键文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `web/admin/src/api/channels.ts:L68-86` | 修改 | 扩展 StatsOverview 和 StatsDailyItem 接口 |
|
||||
| `web/admin/src/views/operations/channels/index.vue:L155-184` | 修改 | Overview 卡片区域新增成本/盈亏卡 |
|
||||
| `web/admin/src/views/operations/channels/index.vue:L202-205` | 修改 | 趋势图新增盈亏分析 Tab |
|
||||
| `web/admin/src/views/operations/channels/index.vue:L482-558` | 修改 | 新增 computed 属性和辅助函数 |
|
||||
|
||||
### 风险与缓解
|
||||
|
||||
| 风险 | 严重程度 | 缓解措施 |
|
||||
|------|---------|----------|
|
||||
| 5列卡片在窄屏溢出 | 低 | 使用响应式 `grid-cols-2 md:grid-cols-5`,必要时改为 `grid-cols-3` + 第二行 `grid-cols-2` |
|
||||
| 后端字段为空(旧数据) | 已解决 | 所有新字段使用 `?` 可选,computed 中做 `typeof` 检查,默认 0 |
|
||||
| ArtStatsCard 不支持负数展示 | 低 | ArtCountTo 组件底层支持负数(基于 countUp.js),无需额外处理 |
|
||||
| 盈亏曲线可能有负值 | 低 | ECharts 原生支持负值 Y 轴,图表会自动适配 |
|
||||
|
||||
### 验收标准
|
||||
|
||||
- [ ] TypeScript 类型定义包含新字段
|
||||
- [ ] Overview 展示 5 个卡片(用户、订单、实付、成本、盈亏)
|
||||
- [ ] 盈亏卡片根据正/负值动态切换颜色(绿/红)
|
||||
- [ ] 趋势图新增"盈亏分析"Tab
|
||||
- [ ] 盈亏分析 Tab 展示 3 条曲线(实付、成本、盈亏)
|
||||
- [ ] `pnpm build` 编译通过
|
||||
- [ ] `pnpm type-check` 类型检查通过
|
||||
|
||||
### SESSION_ID(供 /ccg:execute 使用)
|
||||
|
||||
- CODEX_SESSION: N/A
|
||||
- GEMINI_SESSION: N/A
|
||||
311
.claude/plan/channel-stats-optimization.md
Normal file
311
.claude/plan/channel-stats-optimization.md
Normal file
@ -0,0 +1,311 @@
|
||||
# 渠道统计接口优化计划
|
||||
|
||||
## 需求概述
|
||||
|
||||
优化 `/admin/channels/:channel_id/stats` 接口:
|
||||
|
||||
| 指标 | 当前实现 | 优化后 |
|
||||
|------|---------|--------|
|
||||
| 累计用户 | `COUNT(users WHERE channel_id = X)` | 保持不变 — **全量统计,不限时间** |
|
||||
| 累计订单 | `COUNT(orders JOIN users ...)` | 保持不变 — **全量统计,不限时间** |
|
||||
| 累计实付金额 | `SUM(orders.actual_amount)` | remark → activityID → `activities.price_draw × count` — **全量统计,不限时间** |
|
||||
| 趋势图表 | 按**月**分组(`days` 参数实际当月用) | 修正为按**天**分组,`days` 参数控制天数范围 |
|
||||
|
||||
## 确认的决策
|
||||
|
||||
- ✅ 直接用 remark 中 activityID 查 `activities.price_draw`
|
||||
- ✅ 软删除活动也计入(使用 `Unscoped`)
|
||||
- ✅ `days` 参数修正为按天计算
|
||||
- ✅ Overview 三个指标为全量(不受 days 限制)
|
||||
|
||||
## 受影响的代码
|
||||
|
||||
| 方法 | 文件 | 行号 | 改动内容 |
|
||||
|------|------|------|---------|
|
||||
| `GetStats` | `internal/service/channel/channel.go` | L238-355 | 核心改动:金额计算 + days 修正 + 按天分组 |
|
||||
| `List` | `internal/service/channel/channel.go` | L157-236 | 同步改动:列表 paid_amount 用 price_draw 计算 |
|
||||
| `StatsOutput` / `StatsDailyItem` | `internal/service/channel/channel.go` | L66-84 | 结构体不变,`Daily` 改为按天粒度 |
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### Step 1: 新增 `orderRemarkRow` 类型和 `calcPaidByPriceDraw` 辅助函数
|
||||
|
||||
**文件**:`internal/service/channel/channel.go`
|
||||
|
||||
```go
|
||||
type orderRemarkRow struct {
|
||||
Remark string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// calcPaidByPriceDraw 解析订单 remark 中的 activityID + count,
|
||||
// 批量查 activities.price_draw(含软删除),计算实付金额
|
||||
// 返回:总金额(分)、按日期key分组的金额
|
||||
func (s *service) calcPaidByPriceDraw(ctx context.Context, rows []orderRemarkRow, dateFmt string) (int64, map[string]int64, error) {
|
||||
if len(rows) == 0 {
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
// 1. 解析 remark,收集 unique activityIDs
|
||||
type parsed struct {
|
||||
activityID int64
|
||||
count int64
|
||||
dateKey string
|
||||
}
|
||||
var items []parsed
|
||||
idSet := make(map[int64]struct{})
|
||||
|
||||
for _, r := range rows {
|
||||
rmk := remark.Parse(r.Remark)
|
||||
if rmk.ActivityID > 0 {
|
||||
items = append(items, parsed{
|
||||
activityID: rmk.ActivityID,
|
||||
count: rmk.Count,
|
||||
dateKey: r.CreatedAt.Format(dateFmt),
|
||||
})
|
||||
idSet[rmk.ActivityID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 批量查 activities.price_draw(含软删除 Unscoped)
|
||||
actIDs := make([]int64, 0, len(idSet))
|
||||
for id := range idSet {
|
||||
actIDs = append(actIDs, id)
|
||||
}
|
||||
priceMap := make(map[int64]int64)
|
||||
if len(actIDs) > 0 {
|
||||
var acts []model.Activities
|
||||
s.readDB.Activities.WithContext(ctx).UnderlyingDB().
|
||||
Unscoped().
|
||||
Table("activities").
|
||||
Select("id, price_draw").
|
||||
Where("id IN ?", actIDs).
|
||||
Find(&acts)
|
||||
for _, a := range acts {
|
||||
priceMap[a.ID] = a.PriceDraw
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 计算
|
||||
var total int64
|
||||
byDate := make(map[string]int64)
|
||||
for _, item := range items {
|
||||
if price, ok := priceMap[item.activityID]; ok {
|
||||
amt := price * item.count
|
||||
total += amt
|
||||
byDate[item.dateKey] += amt
|
||||
}
|
||||
}
|
||||
return total, byDate, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 重写 `GetStats` — 日期逻辑修正 + 金额计算
|
||||
|
||||
**改动要点**:
|
||||
|
||||
1. **参数 `days` 真正按天**:`startDate = now.AddDate(0, 0, -days+1)`
|
||||
2. **Overview 全量不限时间**:用户数、订单数、实付金额均查全量
|
||||
3. **趋势按天分组**:`DATE_FORMAT(..., '%Y-%m-%d')` 替代 `'%Y-%m'`
|
||||
4. **金额用 price_draw**:调用 `calcPaidByPriceDraw`
|
||||
|
||||
```go
|
||||
func (s *service) GetStats(ctx context.Context, channelID int64, days int, startDateStr, endDateStr string) (*StatsOutput, error) {
|
||||
now := time.Now()
|
||||
|
||||
// 校验渠道存在
|
||||
_, err := s.readDB.Channels.WithContext(ctx).Where(s.readDB.Channels.ID.Eq(channelID)).First()
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrChannelNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
out := &StatsOutput{}
|
||||
|
||||
// ========== 1. Overview(全量,不限时间)==========
|
||||
|
||||
// 1a. 累计用户
|
||||
userCount, _ := s.readDB.Users.WithContext(ctx).
|
||||
Where(s.readDB.Users.ChannelID.Eq(channelID)).Count()
|
||||
out.Overview.TotalUsers = userCount
|
||||
|
||||
// 1b. 累计订单数
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
type countResult struct{ Count int64 }
|
||||
var cr countResult
|
||||
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("count(*) as count").
|
||||
Where(orderFilter, channelID).
|
||||
Scan(&cr)
|
||||
out.Overview.TotalOrders = cr.Count
|
||||
|
||||
// 1c. 累计实付金额(全量订单 remark → price_draw × count)
|
||||
var allRemarks []orderRemarkRow
|
||||
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.remark, orders.created_at").
|
||||
Where(orderFilter, channelID).
|
||||
Scan(&allRemarks)
|
||||
|
||||
totalPaid, _, _ := s.calcPaidByPriceDraw(ctx, allRemarks, "2006-01-02")
|
||||
out.Overview.TotalPaidCents = totalPaid
|
||||
out.Overview.TotalGMV = totalPaid / 100
|
||||
|
||||
// ========== 2. 趋势图(按天分组,受 days 限制)==========
|
||||
|
||||
// 2a. 计算日期范围
|
||||
var startDate, endDate time.Time
|
||||
if startDateStr != "" && endDateStr != "" {
|
||||
startDate, _ = time.Parse("2006-01-02", startDateStr)
|
||||
endDate, _ = time.Parse("2006-01-02", endDateStr)
|
||||
endDate = endDate.Add(24*time.Hour - time.Second)
|
||||
} else {
|
||||
if days <= 0 {
|
||||
days = 12
|
||||
}
|
||||
startDate = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).
|
||||
AddDate(0, 0, -days+1)
|
||||
endDate = now
|
||||
}
|
||||
|
||||
// 2b. 初始化日期桶(每天一个)
|
||||
dateMap := make(map[string]*StatsDailyItem)
|
||||
var dateList []string
|
||||
for d := startDate; !d.After(endDate); d = d.AddDate(0, 0, 1) {
|
||||
key := d.Format("2006-01-02")
|
||||
dateList = append(dateList, key)
|
||||
dateMap[key] = &StatsDailyItem{Date: key}
|
||||
}
|
||||
|
||||
// 2c. 每日新增用户
|
||||
type dailyCount struct {
|
||||
Date string
|
||||
Count int64
|
||||
}
|
||||
var dailyUsers []dailyCount
|
||||
s.readDB.Users.WithContext(ctx).UnderlyingDB().Table("users").
|
||||
Select("DATE_FORMAT(created_at, '%Y-%m-%d') as date, count(*) as count").
|
||||
Where("channel_id = ? AND deleted_at IS NULL AND created_at >= ? AND created_at <= ?",
|
||||
channelID, startDate, endDate).
|
||||
Group("date").Scan(&dailyUsers)
|
||||
for _, u := range dailyUsers {
|
||||
if item, ok := dateMap[u.Date]; ok {
|
||||
item.UserCount = u.Count
|
||||
}
|
||||
}
|
||||
|
||||
// 2d. 每日订单数
|
||||
var dailyOrders []dailyCount
|
||||
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("DATE_FORMAT(orders.created_at, '%Y-%m-%d') as date, count(*) as count").
|
||||
Where(orderFilter+" AND orders.created_at >= ? AND orders.created_at <= ?",
|
||||
channelID, startDate, endDate).
|
||||
Group("date").Scan(&dailyOrders)
|
||||
for _, o := range dailyOrders {
|
||||
if item, ok := dateMap[o.Date]; ok {
|
||||
item.OrderCount = o.Count
|
||||
}
|
||||
}
|
||||
|
||||
// 2e. 每日实付金额(remark → price_draw)
|
||||
var rangeRemarks []orderRemarkRow
|
||||
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.remark, orders.created_at").
|
||||
Where(orderFilter+" AND orders.created_at >= ? AND orders.created_at <= ?",
|
||||
channelID, startDate, endDate).
|
||||
Scan(&rangeRemarks)
|
||||
|
||||
_, dailyPaid, _ := s.calcPaidByPriceDraw(ctx, rangeRemarks, "2006-01-02")
|
||||
for dateKey, paid := range dailyPaid {
|
||||
if item, ok := dateMap[dateKey]; ok {
|
||||
item.PaidCents = paid
|
||||
item.GMV = paid / 100
|
||||
}
|
||||
}
|
||||
|
||||
// 2f. 组装输出
|
||||
for _, d := range dateList {
|
||||
out.Daily = append(out.Daily, *dateMap[d])
|
||||
}
|
||||
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Step 3: 同步修改 `List` 方法的金额计算
|
||||
|
||||
**文件**:`internal/service/channel/channel.go`,L206-223
|
||||
|
||||
**当前**:`SUM(orders.actual_amount)` 聚合。
|
||||
|
||||
**修改为**:按渠道查询所有订单 remark,分渠道调用 `calcPaidByPriceDraw`。
|
||||
|
||||
```go
|
||||
// 替换原有 paidResults 查询逻辑:
|
||||
if len(channelIDs) > 0 {
|
||||
// ... userCount 查询保持不变 ...
|
||||
|
||||
// 实付金额:查所有渠道的订单 remark
|
||||
type remarkWithChannel struct {
|
||||
ChannelID int64
|
||||
Remark string
|
||||
CreatedAt time.Time
|
||||
}
|
||||
var chRemarks []remarkWithChannel
|
||||
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("users.channel_id, orders.remark, orders.created_at").
|
||||
Where("users.channel_id IN ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)", channelIDs).
|
||||
Scan(&chRemarks)
|
||||
|
||||
// 按渠道分组
|
||||
grouped := make(map[int64][]orderRemarkRow)
|
||||
for _, r := range chRemarks {
|
||||
grouped[r.ChannelID] = append(grouped[r.ChannelID], orderRemarkRow{
|
||||
Remark: r.Remark, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
|
||||
for chID, rows := range grouped {
|
||||
total, _, _ := s.calcPaidByPriceDraw(ctx, rows, "2006-01-02")
|
||||
paidStats[chID] = total
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: 添加 remark import
|
||||
|
||||
确保文件顶部 import 包含:
|
||||
```go
|
||||
"bindbox-game/internal/pkg/util/remark"
|
||||
```
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 严重程度 | 缓解措施 |
|
||||
|------|---------|----------|
|
||||
| remark 格式不一致 | 中 | `remark.Parse()` 已处理 `activity:` 和 `lottery:activity:` 两种前缀 |
|
||||
| 软删除活动 | 已解决 | 使用 `Unscoped()` 查询,确保被删活动仍有 price_draw |
|
||||
| List 方法大量订单性能 | 中 | 单次查询所有渠道订单 remark,Go 中分组计算,比 N+1 高效 |
|
||||
| days 参数前端兼容 | 低 | 前端传 `days=12` 原意应为12天,修正后行为与参数名一致 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] Overview 累计用户:全量统计 `users.channel_id = X` 的用户数(不限时间)
|
||||
- [ ] Overview 累计订单:全量统计有效订单数(不限时间)
|
||||
- [ ] Overview 累计实付金额:全量基于 `activities.price_draw × count` 计算(包含软删除活动)
|
||||
- [ ] 趋势图按**天**分组,`days` 参数控制显示天数
|
||||
- [ ] 渠道列表页 `paid_amount` 同步使用 price_draw 计算
|
||||
- [ ] 编译通过 `make build-mac`
|
||||
- [ ] 现有功能无回归
|
||||
|
||||
## SESSION_ID(供 /ccg:execute 使用)
|
||||
|
||||
- CODEX_SESSION: N/A
|
||||
- GEMINI_SESSION: N/A
|
||||
187
.claude/plan/channel-stats-profit-loss.md
Normal file
187
.claude/plan/channel-stats-profit-loss.md
Normal file
@ -0,0 +1,187 @@
|
||||
# 渠道统计 — 盈亏计算
|
||||
|
||||
## 需求概述
|
||||
|
||||
在 `/admin/channels/:id/stats` 接口的 Overview 和趋势图中新增盈亏指标。
|
||||
|
||||
### 盈亏公式
|
||||
|
||||
```
|
||||
盈亏 = 收入(price_draw × count) - 成本(奖品价值 × 道具卡倍数)
|
||||
```
|
||||
|
||||
### 数据源
|
||||
|
||||
| 维度 | 来源 | 说明 |
|
||||
|------|------|------|
|
||||
| **收入** | 已有 `calcPaidByPriceDraw` | 三路分类:抽奖/对对碰/一番赏 |
|
||||
| **成本** | `user_inventory.value_cents` | 奖品价值快照(分),fallback: `activity_reward_settings.price_snapshot_cents` → `products.price` |
|
||||
| **道具卡倍数** | `orders.item_card_id` → `user_item_cards.card_id` → `system_item_cards.reward_multiplier_x1000` | 双倍卡 = 2000(千分比),无卡 = 1000 |
|
||||
|
||||
### 成本计算公式(参考已有 dashboard_activity.go:L234-239)
|
||||
|
||||
```sql
|
||||
单件成本 = 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
|
||||
|
||||
总成本 = SUM(单件成本 × 道具卡倍数)
|
||||
```
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### Step 1: 扩展响应结构体
|
||||
|
||||
**文件**:`internal/service/channel/channel.go`
|
||||
|
||||
```go
|
||||
type StatsOverview struct {
|
||||
TotalUsers int64 `json:"total_users"`
|
||||
TotalOrders int64 `json:"total_orders"`
|
||||
TotalGMV int64 `json:"total_gmv"`
|
||||
TotalPaidCents int64 `json:"total_paid_cents"`
|
||||
// 新增
|
||||
TotalCostCents int64 `json:"total_cost_cents"` // 总成本(分)
|
||||
TotalProfitCents int64 `json:"total_profit_cents"` // 盈亏(分) = paid - cost
|
||||
TotalCost int64 `json:"total_cost"` // 总成本(元)
|
||||
TotalProfit int64 `json:"total_profit"` // 盈亏(元)
|
||||
}
|
||||
|
||||
type StatsDailyItem struct {
|
||||
Date string `json:"date"`
|
||||
UserCount int64 `json:"user_count"`
|
||||
OrderCount int64 `json:"order_count"`
|
||||
GMV int64 `json:"gmv"`
|
||||
PaidCents int64 `json:"paid_cents"`
|
||||
// 新增
|
||||
CostCents int64 `json:"cost_cents"` // 当日成本(分)
|
||||
ProfitCents int64 `json:"profit_cents"` // 当日盈亏(分)
|
||||
}
|
||||
```
|
||||
|
||||
### Step 2: 新增 `calcCostByInventory` 辅助函数
|
||||
|
||||
**文件**:`internal/service/channel/channel.go`
|
||||
|
||||
**输入**:渠道用户 ID 列表 + 日期范围(可选)
|
||||
**输出**:总成本(分)、按日期分组的成本
|
||||
|
||||
```go
|
||||
type costRow struct {
|
||||
ValueCents int64
|
||||
Multiplier int64 // reward_multiplier_x1000,无卡时=1000
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
func (s *service) calcCostByInventory(ctx context.Context, channelID int64, dateFmt string, startDate, endDate *time.Time) (int64, map[string]int64) {
|
||||
// SQL 核心逻辑(复用 dashboard_activity.go:L234-239 模式):
|
||||
//
|
||||
// SELECT
|
||||
// COALESCE(NULLIF(ui.value_cents, 0), ars.price_snapshot_cents, p.price, 0) AS unit_cost,
|
||||
// GREATEST(COALESCE(sic.reward_multiplier_x1000, 1000), 1000) AS multiplier,
|
||||
// ui.created_at
|
||||
// FROM user_inventory ui
|
||||
// JOIN users u ON u.id = ui.user_id
|
||||
// LEFT JOIN orders o ON o.id = ui.order_id
|
||||
// LEFT JOIN activity_reward_settings ars ON ars.id = ui.reward_id
|
||||
// LEFT JOIN products p ON p.id = ui.product_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 u.channel_id = ? AND u.deleted_at IS NULL
|
||||
// AND ui.status IN (1, 3) -- 持有 or 已使用/发货
|
||||
// AND COALESCE(ui.remark, '') NOT LIKE '%void%'
|
||||
// AND (o.status = 2 OR ui.order_id = 0 OR ui.order_id IS NULL) -- 兼容历史
|
||||
// [AND ui.created_at >= ? AND ui.created_at <= ?] -- 可选时间范围
|
||||
|
||||
// Go 侧计算:
|
||||
// for each row:
|
||||
// cost += unit_cost * multiplier / 1000
|
||||
// byDate[dateKey] += unit_cost * multiplier / 1000
|
||||
}
|
||||
```
|
||||
|
||||
**关键点**:
|
||||
- 通过 `users.channel_id` 过滤渠道用户
|
||||
- `ui.status IN (1, 3)`:只统计有效资产(持有 + 已发货),排除作废
|
||||
- `NOT LIKE '%void%'`:排除作废备注
|
||||
- `(o.status = 2 OR ui.order_id = 0 OR ui.order_id IS NULL)`:兼容历史数据
|
||||
- 道具卡倍数通过 `orders.item_card_id` → `user_item_cards.card_id` → `system_item_cards.reward_multiplier_x1000` 链路获取
|
||||
|
||||
### Step 3: 在 `GetStats` 中调用成本计算
|
||||
|
||||
**文件**:`internal/service/channel/channel.go`,`GetStats` 方法
|
||||
|
||||
```go
|
||||
// ========== Overview 全量成本 ==========
|
||||
totalCost, _ := s.calcCostByInventory(ctx, channelID, "2006-01-02", nil, nil)
|
||||
out.Overview.TotalCostCents = totalCost
|
||||
out.Overview.TotalCost = totalCost / 100
|
||||
out.Overview.TotalProfitCents = out.Overview.TotalPaidCents - totalCost
|
||||
out.Overview.TotalProfit = out.Overview.TotalProfitCents / 100
|
||||
|
||||
// ========== 趋势图日维度成本 ==========
|
||||
_, dailyCost := s.calcCostByInventory(ctx, channelID, "2006-01-02", &startDate, &endDate)
|
||||
for dateKey, cost := range dailyCost {
|
||||
if item, ok := dateMap[dateKey]; ok {
|
||||
item.CostCents = cost
|
||||
item.ProfitCents = item.PaidCents - cost
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Step 4: 在 `List` 中可选加入成本(列表页)
|
||||
|
||||
**暂不实施**。列表页已有 `paid_amount`,盈亏是详情页指标,列表页展示所有渠道的成本查询开销较大。后续按需添加。
|
||||
|
||||
## 关键文件
|
||||
|
||||
| 文件 | 操作 | 说明 |
|
||||
|------|------|------|
|
||||
| `internal/service/channel/channel.go` | 修改 | 扩展结构体 + 新增 `calcCostByInventory` + 修改 `GetStats` |
|
||||
|
||||
## 查询关系链
|
||||
|
||||
```
|
||||
user_inventory
|
||||
├── JOIN users ON users.id = ui.user_id (过滤渠道)
|
||||
├── LEFT JOIN orders ON orders.id = ui.order_id (获取 item_card_id)
|
||||
├── LEFT JOIN activity_reward_settings ON ars.id = ui.reward_id (价格快照)
|
||||
├── LEFT JOIN products ON p.id = ui.product_id (商品价格 fallback)
|
||||
├── LEFT JOIN user_item_cards ON uic.id = o.item_card_id (道具卡实例)
|
||||
└── LEFT JOIN system_item_cards ON sic.id = uic.card_id (道具卡倍数)
|
||||
```
|
||||
|
||||
## 道具卡逻辑说明
|
||||
|
||||
| 场景 | `reward_multiplier_x1000` | 效果 | 成本影响 |
|
||||
|------|---------------------------|------|---------|
|
||||
| 无道具卡 | NULL → COALESCE → 1000 | ×1.0 | 成本 = 奖品原价 |
|
||||
| 双倍卡 | 2000 | ×2.0 | 成本 = 奖品原价 × 2 |
|
||||
| 三倍卡(如有) | 3000 | ×3.0 | 成本 = 奖品原价 × 3 |
|
||||
|
||||
**原理**:双倍卡让用户以相同支付价格获得双倍奖品,收入不变但成本翻倍,利润下降。
|
||||
|
||||
## 风险与缓解
|
||||
|
||||
| 风险 | 严重程度 | 缓解措施 |
|
||||
|------|---------|----------|
|
||||
| `user_inventory` 数据量大,全量查询慢 | 中 | 通过 `users.channel_id` 索引过滤,只查渠道用户 |
|
||||
| 历史资产无 `order_id` | 已解决 | `(o.status = 2 OR ui.order_id = 0 OR ui.order_id IS NULL)` 兼容 |
|
||||
| `value_cents = 0` 的历史数据 | 已解决 | COALESCE 链式 fallback 到 `price_snapshot_cents` → `products.price` |
|
||||
| 概率提升卡(EffectType=2)不影响成本 | 低 | `reward_multiplier_x1000` 只在 EffectType=1 时 > 1000,概率卡该字段为 1000,GREATEST 确保最小为 1.0 |
|
||||
|
||||
## 验收标准
|
||||
|
||||
- [ ] Overview 新增 `total_cost_cents`、`total_profit_cents`、`total_cost`、`total_profit`
|
||||
- [ ] 趋势图每天新增 `cost_cents`、`profit_cents`
|
||||
- [ ] 道具卡(双倍)正确计入成本(×2)
|
||||
- [ ] 无道具卡时成本不受影响(×1)
|
||||
- [ ] 成本计算排除 status=2(作废)和 void 备注的资产
|
||||
- [ ] 编译通过 `make build-mac`
|
||||
|
||||
## SESSION_ID(供 /ccg:execute 使用)
|
||||
|
||||
- CODEX_SESSION: N/A
|
||||
- GEMINI_SESSION: N/A
|
||||
12
.gitignore
vendored
12
.gitignore
vendored
@ -36,3 +36,15 @@ configs/*.toml
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Codex local configuration
|
||||
.codex/
|
||||
|
||||
# Claude Flow runtime data
|
||||
.claude-flow/data/
|
||||
.claude-flow/logs/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
14
.swarm/model-router-state.json
Normal file
14
.swarm/model-router-state.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"totalDecisions": 3,
|
||||
"modelDistribution": {
|
||||
"haiku": 0,
|
||||
"sonnet": 0,
|
||||
"opus": 3,
|
||||
"inherit": 0
|
||||
},
|
||||
"avgComplexity": 0.42307874564459924,
|
||||
"avgConfidence": 0.5675513529812717,
|
||||
"circuitBreakerTrips": 0,
|
||||
"lastUpdated": "2026-03-10T18:07:21.401Z",
|
||||
"learningHistory": []
|
||||
}
|
||||
145
AGENTS.md
Normal file
145
AGENTS.md
Normal file
@ -0,0 +1,145 @@
|
||||
# bindbox_game
|
||||
|
||||
> Multi-agent orchestration framework for agentic coding
|
||||
|
||||
## Project Overview
|
||||
|
||||
A Claude Flow powered project
|
||||
|
||||
**Tech Stack**: TypeScript, Node.js
|
||||
**Architecture**: Domain-Driven Design with bounded contexts
|
||||
|
||||
## Quick Start
|
||||
|
||||
### Installation
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
### Build
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
### Test
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
### Development
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Agent Coordination
|
||||
|
||||
### Swarm Configuration
|
||||
|
||||
This project uses hierarchical swarm coordination for complex tasks:
|
||||
|
||||
| Setting | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| Topology | `hierarchical` | Queen-led coordination (anti-drift) |
|
||||
| Max Agents | 8 | Optimal team size |
|
||||
| Strategy | `specialized` | Clear role boundaries |
|
||||
| Consensus | `raft` | Leader-based consistency |
|
||||
|
||||
### When to Use Swarms
|
||||
|
||||
**Invoke swarm for:**
|
||||
- Multi-file changes (3+ files)
|
||||
- New feature implementation
|
||||
- Cross-module refactoring
|
||||
- API changes with tests
|
||||
- Security-related changes
|
||||
- Performance optimization
|
||||
|
||||
**Skip swarm for:**
|
||||
- Single file edits
|
||||
- Simple bug fixes (1-2 lines)
|
||||
- Documentation updates
|
||||
- Configuration changes
|
||||
|
||||
### Available Skills
|
||||
|
||||
Use `$skill-name` syntax to invoke:
|
||||
|
||||
| Skill | Use Case |
|
||||
|-------|----------|
|
||||
| `$swarm-orchestration` | Multi-agent task coordination |
|
||||
| `$memory-management` | Pattern storage and retrieval |
|
||||
| `$sparc-methodology` | Structured development workflow |
|
||||
| `$security-audit` | Security scanning and CVE detection |
|
||||
|
||||
### Agent Types
|
||||
|
||||
| Type | Role | Use Case |
|
||||
|------|------|----------|
|
||||
| `researcher` | Requirements analysis | Understanding scope |
|
||||
| `architect` | System design | Planning structure |
|
||||
| `coder` | Implementation | Writing code |
|
||||
| `tester` | Test creation | Quality assurance |
|
||||
| `reviewer` | Code review | Security and quality |
|
||||
|
||||
## Code Standards
|
||||
|
||||
### File Organization
|
||||
- **NEVER** save to root folder
|
||||
- `/src` - Source code files
|
||||
- `/tests` - Test files
|
||||
- `/docs` - Documentation
|
||||
- `/config` - Configuration files
|
||||
|
||||
### Quality Rules
|
||||
- Files under 500 lines
|
||||
- No hardcoded secrets
|
||||
- Input validation at boundaries
|
||||
- Typed interfaces for public APIs
|
||||
- TDD London School (mock-first) preferred
|
||||
|
||||
### Commit Messages
|
||||
```
|
||||
<type>(<scope>): <description>
|
||||
|
||||
[optional body]
|
||||
|
||||
Co-Authored-By: claude-flow <ruv@ruv.net>
|
||||
```
|
||||
|
||||
Types: `feat`, `fix`, `docs`, `style`, `refactor`, `perf`, `test`, `chore`
|
||||
|
||||
## Security
|
||||
|
||||
### Critical Rules
|
||||
- NEVER commit secrets, credentials, or .env files
|
||||
- NEVER hardcode API keys
|
||||
- Always validate user input
|
||||
- Use parameterized queries for SQL
|
||||
- Sanitize output to prevent XSS
|
||||
|
||||
### Path Security
|
||||
- Validate all file paths
|
||||
- Prevent directory traversal (../)
|
||||
- Use absolute paths internally
|
||||
|
||||
## Memory System
|
||||
|
||||
### Storing Patterns
|
||||
```bash
|
||||
npx @claude-flow/cli memory store \
|
||||
--key "pattern-name" \
|
||||
--value "pattern description" \
|
||||
--namespace patterns
|
||||
```
|
||||
|
||||
### Searching Memory
|
||||
```bash
|
||||
npx @claude-flow/cli memory search \
|
||||
--query "search terms" \
|
||||
--namespace patterns
|
||||
```
|
||||
|
||||
## Links
|
||||
|
||||
- Documentation: https://github.com/ruvnet/claude-flow
|
||||
- Issues: https://github.com/ruvnet/claude-flow/issues
|
||||
BIN
bindboxgame_api
Executable file
BIN
bindboxgame_api
Executable file
Binary file not shown.
61
cmd/channel_stats_compare/detail.go
Normal file
61
cmd/channel_stats_compare/detail.go
Normal file
@ -0,0 +1,61 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
|
||||
channelID := 3
|
||||
filter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
type SourceStat struct {
|
||||
SourceType int32
|
||||
HasRemark string
|
||||
Count int64
|
||||
TotalCents int64
|
||||
}
|
||||
var stats []SourceStat
|
||||
db.Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.source_type, CASE WHEN orders.remark LIKE '%lottery:activity:%' OR orders.remark LIKE '%activity:%' THEN 'Y' ELSE 'N' END as has_remark, COUNT(*) as count, SUM(orders.actual_amount) as total_cents").
|
||||
Where(filter, channelID).
|
||||
Group("orders.source_type, has_remark").
|
||||
Order("orders.source_type, has_remark").
|
||||
Scan(&stats)
|
||||
|
||||
fmt.Println("source_type: 1=直购, 2=抽奖, 3=翻牌, 4=一番赏")
|
||||
fmt.Printf("%-12s %-12s %-10s %-15s\n", "source_type", "有remark", "订单数", "actual_amount(分)")
|
||||
fmt.Println("---------------------------------------------------")
|
||||
for _, s := range stats {
|
||||
fmt.Printf("%-12d %-12s %-10d %-15d\n", s.SourceType, s.HasRemark, s.Count, s.TotalCents)
|
||||
}
|
||||
|
||||
type Sample struct {
|
||||
ID int64
|
||||
SourceType int32
|
||||
ActualAmount int64
|
||||
Remark string
|
||||
}
|
||||
var samples []Sample
|
||||
db.Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.id, orders.source_type, orders.actual_amount, orders.remark").
|
||||
Where(filter+" AND (orders.remark = '' OR orders.remark NOT LIKE '%activity:%')", channelID).
|
||||
Limit(10).
|
||||
Scan(&samples)
|
||||
|
||||
fmt.Println("\n无 activity remark 的订单示例:")
|
||||
for _, s := range samples {
|
||||
rmk := s.Remark
|
||||
if len(rmk) > 80 {
|
||||
rmk = rmk[:80] + "..."
|
||||
}
|
||||
fmt.Printf(" ID=%-6d type=%d amount=%-8d remark=[%s]\n", s.ID, s.SourceType, s.ActualAmount, rmk)
|
||||
}
|
||||
}
|
||||
55
cmd/channel_stats_compare/ichiban.go
Normal file
55
cmd/channel_stats_compare/ichiban.go
Normal file
@ -0,0 +1,55 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
db, _ := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
|
||||
channelID := 3
|
||||
filter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
type Sample struct {
|
||||
ID int64
|
||||
SourceType int32
|
||||
ActualAmount int64
|
||||
Remark string
|
||||
}
|
||||
|
||||
// 一番赏 remark
|
||||
var ichiban []Sample
|
||||
db.Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.id, orders.source_type, orders.actual_amount, orders.remark").
|
||||
Where(filter+" AND orders.source_type = 4", channelID).
|
||||
Limit(5).
|
||||
Scan(&ichiban)
|
||||
|
||||
fmt.Println("=== 一番赏 (source_type=4) remark 示例 ===")
|
||||
for _, s := range ichiban {
|
||||
fmt.Printf(" ID=%-6d amount=%-8d remark=[%s]\n", s.ID, s.ActualAmount, s.Remark)
|
||||
}
|
||||
|
||||
// 翻牌 matching_game 的 issue 对应关系
|
||||
type IssueActivity struct {
|
||||
IssueID int64
|
||||
ActivityID int64
|
||||
PriceDraw int64
|
||||
}
|
||||
var ia []IssueActivity
|
||||
db.Table("activity_issues").
|
||||
Joins("JOIN activities ON activities.id = activity_issues.activity_id").
|
||||
Select("activity_issues.id as issue_id, activity_issues.activity_id, activities.price_draw").
|
||||
Where("activity_issues.id IN (92, 96, 104)").
|
||||
Scan(&ia)
|
||||
|
||||
fmt.Println("\n=== 翻牌 issue → activity → price_draw ===")
|
||||
for _, r := range ia {
|
||||
fmt.Printf(" issue_id=%d → activity_id=%d → price_draw=%d\n", r.IssueID, r.ActivityID, r.PriceDraw)
|
||||
}
|
||||
}
|
||||
199
cmd/channel_stats_compare/main.go
Normal file
199
cmd/channel_stats_compare/main.go
Normal file
@ -0,0 +1,199 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Println("连接失败:", err)
|
||||
return
|
||||
}
|
||||
|
||||
channelID := 3
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
// 1. actual_amount 统计
|
||||
type AmountResult struct {
|
||||
OrderCount int64
|
||||
TotalCents int64
|
||||
}
|
||||
var ar AmountResult
|
||||
db.Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("COUNT(DISTINCT orders.id) as order_count, COALESCE(SUM(orders.actual_amount), 0) as total_cents").
|
||||
Where(orderFilter, channelID).
|
||||
Scan(&ar)
|
||||
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("渠道 %d 数据对比\n", channelID)
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
fmt.Println("【方式1】SUM(actual_amount) — 用户实际支付")
|
||||
fmt.Printf(" 订单数: %d\n", ar.OrderCount)
|
||||
fmt.Printf(" 金额: %d 分 = %.2f 元\n", ar.TotalCents, float64(ar.TotalCents)/100)
|
||||
fmt.Println()
|
||||
|
||||
// 2. 取所有订单的 remark,Go 中解析
|
||||
type RemarkRow struct {
|
||||
Remark string
|
||||
}
|
||||
var remarks []RemarkRow
|
||||
db.Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.remark").
|
||||
Where(orderFilter, channelID).
|
||||
Scan(&remarks)
|
||||
|
||||
// 解析 remark 收集 activityIDs
|
||||
type parsed struct {
|
||||
activityID int64
|
||||
count int64
|
||||
}
|
||||
var items []parsed
|
||||
idSet := make(map[int64]struct{})
|
||||
noRemarkCount := 0
|
||||
|
||||
for _, r := range remarks {
|
||||
aid, cnt := parseRemark(r.Remark)
|
||||
if aid > 0 {
|
||||
items = append(items, parsed{activityID: aid, count: cnt})
|
||||
idSet[aid] = struct{}{}
|
||||
} else {
|
||||
noRemarkCount++
|
||||
}
|
||||
}
|
||||
|
||||
// 批量查 price_draw(含软删除)
|
||||
actIDs := make([]int64, 0, len(idSet))
|
||||
for id := range idSet {
|
||||
actIDs = append(actIDs, id)
|
||||
}
|
||||
|
||||
type ActPrice struct {
|
||||
ID int64
|
||||
PriceDraw int64
|
||||
}
|
||||
priceMap := make(map[int64]int64)
|
||||
if len(actIDs) > 0 {
|
||||
var acts []ActPrice
|
||||
db.Unscoped().Table("activities").
|
||||
Select("id, price_draw").
|
||||
Where("id IN ?", actIDs).
|
||||
Find(&acts)
|
||||
for _, a := range acts {
|
||||
priceMap[a.ID] = a.PriceDraw
|
||||
}
|
||||
}
|
||||
|
||||
// 计算 price_draw × count
|
||||
var totalPriceDraw int64
|
||||
matchedCount := 0
|
||||
unmatchedCount := 0
|
||||
for _, item := range items {
|
||||
if price, ok := priceMap[item.activityID]; ok {
|
||||
totalPriceDraw += price * item.count
|
||||
matchedCount++
|
||||
} else {
|
||||
unmatchedCount++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("【方式2】price_draw × count — 门票原价(当前实现)")
|
||||
fmt.Printf(" 有效订单: %d (有 remark 且匹配活动)\n", matchedCount)
|
||||
fmt.Printf(" 无 remark: %d\n", noRemarkCount)
|
||||
fmt.Printf(" 活动不存在: %d\n", unmatchedCount)
|
||||
fmt.Printf(" 金额: %d 分 = %.2f 元\n", totalPriceDraw, float64(totalPriceDraw)/100)
|
||||
fmt.Println()
|
||||
|
||||
// 3. 差额
|
||||
diff := totalPriceDraw - ar.TotalCents
|
||||
fmt.Println("【差异分析】")
|
||||
fmt.Printf(" price_draw×count - actual_amount = %d 分 = %.2f 元\n", diff, float64(diff)/100)
|
||||
if diff > 0 {
|
||||
fmt.Printf(" 说明: 用户总共享受了 %.2f 元优惠(优惠券/积分/折扣)\n", float64(diff)/100)
|
||||
} else if diff < 0 {
|
||||
fmt.Printf(" 说明: actual_amount 比 price_draw×count 多 %.2f 元(可能有额外费用)\n", float64(-diff)/100)
|
||||
} else {
|
||||
fmt.Println(" 说明: 两者完全一致,无优惠抵扣")
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 4. 抽样展示前10条差异订单
|
||||
type DetailRow struct {
|
||||
OrderID int64
|
||||
ActualAmount int64
|
||||
Remark string
|
||||
}
|
||||
var details []DetailRow
|
||||
db.Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.id as order_id, orders.actual_amount, orders.remark").
|
||||
Where(orderFilter, channelID).
|
||||
Limit(200).
|
||||
Scan(&details)
|
||||
|
||||
fmt.Println("【差异订单抽样(前10条有差异的)】")
|
||||
fmt.Printf("%-10s %-15s %-15s %-10s %s\n", "订单ID", "actual_amount", "price×count", "差额", "remark摘要")
|
||||
fmt.Println(strings.Repeat("-", 90))
|
||||
shown := 0
|
||||
for _, d := range details {
|
||||
aid, cnt := parseRemark(d.Remark)
|
||||
if aid <= 0 {
|
||||
continue
|
||||
}
|
||||
price, ok := priceMap[aid]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
priceTotal := price * cnt
|
||||
orderDiff := priceTotal - d.ActualAmount
|
||||
if orderDiff != 0 && shown < 10 {
|
||||
remarkShort := d.Remark
|
||||
if len(remarkShort) > 40 {
|
||||
remarkShort = remarkShort[:40] + "..."
|
||||
}
|
||||
fmt.Printf("%-10d %-15d %-15d %-10d %s\n", d.OrderID, d.ActualAmount, priceTotal, orderDiff, remarkShort)
|
||||
shown++
|
||||
}
|
||||
}
|
||||
if shown == 0 {
|
||||
fmt.Println(" (前200条订单中无差异)")
|
||||
}
|
||||
}
|
||||
|
||||
func parseRemark(rm string) (activityID, count int64) {
|
||||
count = 1
|
||||
parts := strings.Split(rm, "|")
|
||||
for _, p := range parts {
|
||||
if strings.HasPrefix(p, "lottery:activity:") {
|
||||
activityID = parseInt64(p[17:])
|
||||
} else if strings.HasPrefix(p, "activity:") {
|
||||
activityID = parseInt64(p[9:])
|
||||
} else if strings.HasPrefix(p, "count:") {
|
||||
n := parseInt64(p[6:])
|
||||
if n > 0 {
|
||||
count = n
|
||||
}
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func parseInt64(s string) int64 {
|
||||
var n int64
|
||||
for _, c := range s {
|
||||
if c >= '0' && c <= '9' {
|
||||
n = n*10 + int64(c-'0')
|
||||
} else {
|
||||
break
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
@ -40,6 +40,7 @@ func main() {
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("(orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
|
||||
Where("(orders.source_type IN (1,2,3,4) OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
|
||||
Where("(orders.actual_amount > 0 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
|
||||
Scan(&rows)
|
||||
|
||||
var totalCostBase, totalCostFinal int64
|
||||
@ -128,6 +129,7 @@ func main() {
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("(orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
|
||||
Where("(orders.source_type IN (1,2,3,4) OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
|
||||
Where("(orders.actual_amount > 0 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
|
||||
Where("system_item_cards.id IS NOT NULL").
|
||||
Group("system_item_cards.id").
|
||||
Scan(&cards)
|
||||
|
||||
246
cmd/channel_stats_compare/verify.go
Normal file
246
cmd/channel_stats_compare/verify.go
Normal file
@ -0,0 +1,246 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"bindbox-game/internal/pkg/util/remark"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Println("连接失败:", err)
|
||||
return
|
||||
}
|
||||
|
||||
channelID := 3
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
// 1. 查所有订单 remark + source_type
|
||||
type RemarkRow struct {
|
||||
ID int64
|
||||
Remark string
|
||||
SourceType int32
|
||||
}
|
||||
var rows []RemarkRow
|
||||
db.Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.id, orders.remark, orders.source_type").
|
||||
Where(orderFilter, channelID).
|
||||
Scan(&rows)
|
||||
|
||||
fmt.Printf("渠道 %d 总有效订单: %d\n\n", channelID, len(rows))
|
||||
|
||||
// 2. 三路分类统计
|
||||
var case1, case2, case3, unmatched int
|
||||
actIDSet := make(map[int64]struct{})
|
||||
issueIDSet := make(map[int64]struct{})
|
||||
pkgIDSet := make(map[int64]struct{})
|
||||
|
||||
type parsed struct {
|
||||
orderID int64
|
||||
caseType int
|
||||
activityID int64
|
||||
issueID int64
|
||||
pkgID int64
|
||||
count int64
|
||||
}
|
||||
var items []parsed
|
||||
|
||||
for _, r := range rows {
|
||||
rmk := remark.Parse(r.Remark)
|
||||
p := parsed{orderID: r.ID, count: rmk.Count}
|
||||
|
||||
if rmk.ActivityID > 0 {
|
||||
p.caseType = 1
|
||||
p.activityID = rmk.ActivityID
|
||||
actIDSet[rmk.ActivityID] = struct{}{}
|
||||
case1++
|
||||
} else if rmk.IssueID > 0 {
|
||||
p.caseType = 2
|
||||
p.issueID = rmk.IssueID
|
||||
issueIDSet[rmk.IssueID] = struct{}{}
|
||||
case2++
|
||||
} else if rmk.PkgID > 0 {
|
||||
p.caseType = 3
|
||||
p.pkgID = rmk.PkgID
|
||||
pkgIDSet[rmk.PkgID] = struct{}{}
|
||||
case3++
|
||||
} else {
|
||||
unmatched++
|
||||
}
|
||||
items = append(items, p)
|
||||
}
|
||||
|
||||
fmt.Println("=== 三路分类统计 ===")
|
||||
fmt.Printf(" Case1 (抽奖/直购, ActivityID>0): %d 笔\n", case1)
|
||||
fmt.Printf(" Case2 (对对碰, IssueID>0): %d 笔\n", case2)
|
||||
fmt.Printf(" Case3 (一番赏, PkgID>0): %d 笔\n", case3)
|
||||
fmt.Printf(" 未匹配: %d 笔\n", unmatched)
|
||||
fmt.Println()
|
||||
|
||||
// 3. 查 activity_issues (Case2)
|
||||
issueActivityMap := make(map[int64]int64)
|
||||
if len(issueIDSet) > 0 {
|
||||
issueIDs := make([]int64, 0, len(issueIDSet))
|
||||
for id := range issueIDSet {
|
||||
issueIDs = append(issueIDs, id)
|
||||
}
|
||||
type IssueRow struct {
|
||||
ID int64
|
||||
ActivityID int64
|
||||
}
|
||||
var issueRows []IssueRow
|
||||
db.Table("activity_issues").
|
||||
Select("id, activity_id").
|
||||
Where("id IN ?", issueIDs).
|
||||
Scan(&issueRows)
|
||||
for _, ir := range issueRows {
|
||||
issueActivityMap[ir.ID] = ir.ActivityID
|
||||
actIDSet[ir.ActivityID] = struct{}{}
|
||||
}
|
||||
fmt.Printf("activity_issues 查到: %d / %d\n", len(issueRows), len(issueIDs))
|
||||
}
|
||||
|
||||
// 4. 查 activities.price_draw (Case1+2)
|
||||
priceMap := make(map[int64]int64)
|
||||
if len(actIDSet) > 0 {
|
||||
actIDs := make([]int64, 0, len(actIDSet))
|
||||
for id := range actIDSet {
|
||||
actIDs = append(actIDs, id)
|
||||
}
|
||||
type ActRow struct {
|
||||
ID int64
|
||||
PriceDraw int64
|
||||
}
|
||||
var actRows []ActRow
|
||||
db.Unscoped().Table("activities").
|
||||
Select("id, price_draw").
|
||||
Where("id IN ?", actIDs).
|
||||
Scan(&actRows)
|
||||
for _, a := range actRows {
|
||||
priceMap[a.ID] = a.PriceDraw
|
||||
}
|
||||
fmt.Printf("activities 查到: %d / %d\n", len(actRows), len(actIDs))
|
||||
}
|
||||
|
||||
// 5. 查 game_pass_packages.price (Case3)
|
||||
pkgPriceMap := make(map[int64]int64)
|
||||
if len(pkgIDSet) > 0 {
|
||||
pkgIDs := make([]int64, 0, len(pkgIDSet))
|
||||
for id := range pkgIDSet {
|
||||
pkgIDs = append(pkgIDs, id)
|
||||
}
|
||||
type PkgRow struct {
|
||||
ID int64
|
||||
Price int64
|
||||
}
|
||||
var pkgRows []PkgRow
|
||||
db.Unscoped().Table("game_pass_packages").
|
||||
Select("id, price").
|
||||
Where("id IN ?", pkgIDs).
|
||||
Scan(&pkgRows)
|
||||
for _, p := range pkgRows {
|
||||
pkgPriceMap[p.ID] = p.Price
|
||||
}
|
||||
fmt.Printf("game_pass_packages 查到: %d / %d\n", len(pkgRows), len(pkgIDs))
|
||||
}
|
||||
fmt.Println()
|
||||
|
||||
// 6. 计算金额
|
||||
var totalCase1, totalCase2, totalCase3 int64
|
||||
var matchedCase1, matchedCase2, matchedCase3 int
|
||||
var unmatchedCase1, unmatchedCase2, unmatchedCase3 int
|
||||
|
||||
for _, item := range items {
|
||||
switch item.caseType {
|
||||
case 1:
|
||||
if price, ok := priceMap[item.activityID]; ok {
|
||||
totalCase1 += price * item.count
|
||||
matchedCase1++
|
||||
} else {
|
||||
unmatchedCase1++
|
||||
}
|
||||
case 2:
|
||||
if actID, ok := issueActivityMap[item.issueID]; ok {
|
||||
if price, ok := priceMap[actID]; ok {
|
||||
totalCase2 += price * item.count
|
||||
matchedCase2++
|
||||
} else {
|
||||
unmatchedCase2++
|
||||
}
|
||||
} else {
|
||||
unmatchedCase2++
|
||||
}
|
||||
case 3:
|
||||
if price, ok := pkgPriceMap[item.pkgID]; ok {
|
||||
totalCase3 += price * item.count
|
||||
matchedCase3++
|
||||
} else {
|
||||
unmatchedCase3++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
total := totalCase1 + totalCase2 + totalCase3
|
||||
|
||||
fmt.Println("=== 金额统计 (price_draw/price × count) ===")
|
||||
fmt.Printf(" Case1 抽奖/直购: %d 分 = %.2f 元 (匹配 %d, 未匹配 %d)\n",
|
||||
totalCase1, float64(totalCase1)/100, matchedCase1, unmatchedCase1)
|
||||
fmt.Printf(" Case2 对对碰: %d 分 = %.2f 元 (匹配 %d, 未匹配 %d)\n",
|
||||
totalCase2, float64(totalCase2)/100, matchedCase2, unmatchedCase2)
|
||||
fmt.Printf(" Case3 一番赏: %d 分 = %.2f 元 (匹配 %d, 未匹配 %d)\n",
|
||||
totalCase3, float64(totalCase3)/100, matchedCase3, unmatchedCase3)
|
||||
fmt.Println(strings.Repeat("-", 60))
|
||||
fmt.Printf(" 合计: %d 分 = %.2f 元\n", total, float64(total)/100)
|
||||
fmt.Printf(" 覆盖订单: %d / %d (%.1f%%)\n",
|
||||
matchedCase1+matchedCase2+matchedCase3, len(rows),
|
||||
float64(matchedCase1+matchedCase2+matchedCase3)/float64(len(rows))*100)
|
||||
fmt.Println()
|
||||
|
||||
// 7. 对比 actual_amount
|
||||
type AmountResult struct {
|
||||
TotalCents int64
|
||||
}
|
||||
var ar AmountResult
|
||||
db.Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("COALESCE(SUM(orders.actual_amount), 0) as total_cents").
|
||||
Where(orderFilter, channelID).
|
||||
Scan(&ar)
|
||||
|
||||
fmt.Println("=== 对比 ===")
|
||||
fmt.Printf(" SUM(actual_amount): %d 分 = %.2f 元\n", ar.TotalCents, float64(ar.TotalCents)/100)
|
||||
fmt.Printf(" price_draw/price × count: %d 分 = %.2f 元\n", total, float64(total)/100)
|
||||
diff := total - ar.TotalCents
|
||||
fmt.Printf(" 差额: %d 分 = %.2f 元\n", diff, float64(diff)/100)
|
||||
if diff > 0 {
|
||||
fmt.Printf(" 说明: 用户享受了 %.2f 元优惠\n", float64(diff)/100)
|
||||
}
|
||||
|
||||
// 8. 打印未匹配订单示例
|
||||
if unmatched > 0 {
|
||||
fmt.Printf("\n=== 未匹配 remark 示例 (共 %d 笔) ===\n", unmatched)
|
||||
shown := 0
|
||||
for _, item := range items {
|
||||
if item.caseType == 0 && shown < 5 {
|
||||
for _, r := range rows {
|
||||
if r.ID == item.orderID {
|
||||
rmk := r.Remark
|
||||
if len(rmk) > 80 {
|
||||
rmk = rmk[:80] + "..."
|
||||
}
|
||||
fmt.Printf(" ID=%-6d type=%d remark=[%s]\n", r.ID, r.SourceType, rmk)
|
||||
shown++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@ -64,7 +64,7 @@ func main() {
|
||||
env.Active() // 初始化 env flag(依赖已有的全局 -env/ACTIVE_ENV 配置)
|
||||
configs.Init()
|
||||
|
||||
cookie := "s_v_web_id=verify_mm0pjkt7_rRCYDU7B_F5Yl_4UYj_8yQ0_ue0vAcKwYt3z; csrf_session_id=86df5285aa04dec74fe5ac89d1b0d5c0; passport_csrf_token=fe2b51efeb70763190b402f49ad9f0e9; passport_csrf_token_default=fe2b51efeb70763190b402f49ad9f0e9; x-web-secsdk-uid=749f802e-47b8-4221-98af-b726e5631036; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1771943727; HMACCOUNT=74DD13C46DE836FC; ttcid=c1ec90610ace481ba60dd8303b332c8e40; odin_tt=f01a3108f23b93c70d9d41eb2536553aa6550eb007cfc2bc3ba6319e82ad90eef45e1f581adbb6d1fb1fc2bdcce6d8cdd72b475fa9943bab1df0efe1ea035355; passport_auth_status=07988630820adb6946c4969658ab8b4d%2C; passport_auth_status_ss=07988630820adb6946c4969658ab8b4d%2C; uid_tt=f8cbc1387650f5a331a9a2943293d5f2; uid_tt_ss=f8cbc1387650f5a331a9a2943293d5f2; sid_tt=d1e48959ea47e34970be1d9b0aa801a2; sessionid=d1e48959ea47e34970be1d9b0aa801a2; sessionid_ss=d1e48959ea47e34970be1d9b0aa801a2; is_staff_user=false; PHPSESSID=294f6cf83ec2a4fefc1222321590b3e7; PHPSESSID_SS=294f6cf83ec2a4fefc1222321590b3e7; ucas_c0=CkEKBTEuMC4wEKOIj97Q5-3OaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DL7vbMBkjLorPPBlC_vL6Ekt3t1GdYbhIUPQw_elr7IsxkcNFj3v1rRHn03qs; ucas_c0_ss=CkEKBTEuMC4wEKOIj97Q5-3OaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0DL7vbMBkjLorPPBlC_vL6Ekt3t1GdYbhIUPQw_elr7IsxkcNFj3v1rRHn03qs; ecom_gray_shop_id=156231010; sid_guard=d1e48959ea47e34970be1d9b0aa801a2%7C1772108860%7C5184000%7CMon%2C+27-Apr-2026+12%3A27%3A40+GMT; session_tlb_tag=sttt%7C9%7C0eSJWepH40lwvh2bCqgBov_________yZl6I8Equ6hjlsXft1nWEmcwpzFQYKIutCIRtYdHffp0%3D; sid_ucp_v1=1.0.0-KDc4Mjc1ZjFkNTg4NjFkZWQwYjUzMTJmNWFjN2U4ZmM1NzYzODEwNTcKGQib1oDYuM3aBxC8-IDNBhiwISAMOAZA9AcaAmxmIiBkMWU0ODk1OWVhNDdlMzQ5NzBiZTFkOWIwYWE4MDFhMg; ssid_ucp_v1=1.0.0-KDc4Mjc1ZjFkNTg4NjFkZWQwYjUzMTJmNWFjN2U4ZmM1NzYzODEwNTcKGQib1oDYuM3aBxC8-IDNBhiwISAMOAZA9AcaAmxmIiBkMWU0ODk1OWVhNDdlMzQ5NzBiZTFkOWIwYWE4MDFhMg; COMPASS_LUOPAN_DT=session_7611148681122726154; BUYIN_SASID=SID2_7611148178737856777; gfkadpd=4272,23756; zsgw_business_data=%7B%22uuid%22%3A%2267d1b0e4-dca5-484d-997a-b70cb555e396%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.google%22%7D; source=seo.google; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1772115439; ttwid=1%7C71OUHp7yB34JMc3dVW9XMZxKJfcmzgfSzG407fx6Gqo%7C1772115438%7C5c6372575550a6bddb3a4eb25fff2fdc9f0d0954e0ce795eb1eb15e121a9ca53; tt_scid=bAGxaUh7d5HftS77rQMVwbdERMWrYT63ZZMLaRlsZiLgbOweJMjw-1IEYQvEO1Qz836d; op_session="
|
||||
cookie := "passport_csrf_token=40ba4a1be914a9f167320ed28b8c93d7; passport_csrf_token_default=40ba4a1be914a9f167320ed28b8c93d7; is_staff_user=false; s_v_web_id=verify_mkf83bbo_zfQ3q1Gp_5irf_4OOI_9y4N_C253269yUIJy; SHOP_ID=156231010; PIGEON_CID=4339134776748827; __security_mc_1_s_sdk_crypt_sdk=db47f387-4d0b-bf21; bd_ticket_guard_client_web_domain=2; bd_ticket_guard_client_data=eyJiZC10aWNrZXQtZ3VhcmQtdmVyc2lvbiI6MiwiYmQtdGlja2V0LWd1YXJkLWl0ZXJhdGlvbi12ZXJzaW9uIjoxLCJiZC10aWNrZXQtZ3VhcmQtcmVlLXB1YmxpYy1rZXkiOiJCTHVTREdkVFRHWUdNMVY3ZDZKS2M4V2FwWGJ1K3JVYmVqRThONTZoeTI4SUJXdmVxZjBLMS9GczE0dWx5RTVRd2d4cjdnaDd6SXdMZjlsWDkwOFZQQWs9IiwiYmQtdGlja2V0LWd1YXJkLXdlYi12ZXJzaW9uIjoyfQ%3D%3D; bd_ticket_guard_web_domain=3; gfkadpd=4272,23756; ecom_gray_shop_id=156231010; zsgw_business_data=%7B%22uuid%22%3A%226756720f-c380-4bda-ab81-3dd27ca08a2d%22%2C%22platform%22%3A%22pc%22%2C%22source%22%3A%22seo.baidu.069%22%7D; source=seo.baidu.069; Hm_lvt_b6520b076191ab4b36812da4c90f7a5e=1771350555,1772107597,1772794481,1773223394; HMACCOUNT=9C6B7571794A6624; csrf_session_id=8173f094b830570b2b64e98900924731; passport_mfa_token=CjcMUe8O6Zz52W9O1T3zlEkIxpWSHBCB4dHw9XBdiDU%2BIPU1pzwEXLpVjGth2W2nXGHC8OM6ffSmGkoKPAAAAAAAAAAAAABQK6uUDAbmPNiLgEkCaMWLdiWMpTEiK%2Fm1NGLpqOUmR4vBZtoNbJWrAhzjfim%2BBtfMlxCj6IsOGPax0WwgAiIBA8pTDDU%3D; Hm_lpvt_b6520b076191ab4b36812da4c90f7a5e=1773224382; ttwid=1%7CNnXcElGkMBE8UTpDOFYR5OfCUYkFjQaLyn1EagPBZgM%7C1773224307%7C18bc27eb78d0a5da332f8c3ec951f81229670377d82025fcb5e600e3766e367b; tt_scid=uSkT0B7AzW.AKqYpEsRrpTqtws.7fqp2P4-gBF1FyffuNMOl1AKuRvuymbUWzXRvcc00; odin_tt=6edadb78040b4604bed517fc3edef437495387c8a3bf60fa177788ff81dd88daaed661705eb0729801e665c086b098b263c3090fef72c26e872d2f3172f6e364; passport_auth_status=581a8676e64d918c69ee3930f4dacf8b%2C4bb14205ac4179b872cba76a97208a7e; passport_auth_status_ss=581a8676e64d918c69ee3930f4dacf8b%2C4bb14205ac4179b872cba76a97208a7e; bd_ticket_guard_server_data=eyJ0aWNrZXQiOiJoYXNoLk1SWGtrczRwYTZpWG91ODhuZENOT05idm9iSjI2SHlXOXRYN2JKNTdZMWM9IiwidHNfc2lnbiI6InRzLjIuMDg1MDhmMjljNWI2MjkzMjQ4ZTAwNGY0YjdiNjMwODI4ODk1YjFkZWQ1ZTRlYmFiZTc3NmYzZTUxYWJjZjZhNGM0ZmJlODdkMjMxOWNmMDUzMTg2MjRjZWRhMTQ5MTFjYTQwNmRlZGJlYmVkZGIyZTMwZmNlOGQ0ZmEwMjU3NWQiLCJjbGllbnRfY2VydCI6InB1Yi5CTHVTREdkVFRHWUdNMVY3ZDZKS2M4V2FwWGJ1K3JVYmVqRThONTZoeTI4SUJXdmVxZjBLMS9GczE0dWx5RTVRd2d4cjdnaDd6SXdMZjlsWDkwOFZQQWs9IiwibG9nX2lkIjoiMjAyNjAzMTExODE4NDBGQUVGNkZGMDBCMkUwQTJEQTU2QSIsImNyZWF0ZV90aW1lIjoxNzczMjI0MzIwfQ%3D%3D; uid_tt=e8ca5ad2e6032b72a0fd8c0843ff5e9b; uid_tt_ss=e8ca5ad2e6032b72a0fd8c0843ff5e9b; sid_tt=c1a29f1f0f71ea4ed9fbcde60bc2b390; sessionid=c1a29f1f0f71ea4ed9fbcde60bc2b390; sessionid_ss=c1a29f1f0f71ea4ed9fbcde60bc2b390; PHPSESSID=05ca4c3439dacd9ac5f1d86a78516abb; PHPSESSID_SS=05ca4c3439dacd9ac5f1d86a78516abb; ucas_c0=CkEKBTEuMC4wELaIgqLTr9DYaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0CCg8XNBkiCt4HQBlC_vL6Ekt3t1GdYbhIUI1wJXqAsE71YWUNwS6OvJ9dOEVE; ucas_c0_ss=CkEKBTEuMC4wELaIgqLTr9DYaRjmJiD61rDnqc2DBCiwITCb1oDYuM3aB0CCg8XNBkiCt4HQBlC_vL6Ekt3t1GdYbhIUI1wJXqAsE71YWUNwS6OvJ9dOEVE; sid_guard=c1a29f1f0f71ea4ed9fbcde60bc2b390%7C1773224328%7C5184000%7CSun%2C+10-May-2026+10%3A18%3A48+GMT; sid_ucp_v1=1.0.0-KDA3MGQyMjJkNmQ1NDUxOGQ1MWRhYTFjMzBkZTZkMDBlMTNlYWJhYWUKGwib1oDYuM3aBxCIg8XNBhiwISAMOAZA9AdIBBoCaGwiIGMxYTI5ZjFmMGY3MWVhNGVkOWZiY2RlNjBiYzJiMzkw; ssid_ucp_v1=1.0.0-KDA3MGQyMjJkNmQ1NDUxOGQ1MWRhYTFjMzBkZTZkMDBlMTNlYWJhYWUKGwib1oDYuM3aBxCIg8XNBhiwISAMOAZA9AdIBBoCaGwiIGMxYTI5ZjFmMGY3MWVhNGVkOWZiY2RlNjBiYzJiMzkw; session_tlb_tag=sttt%7C17%7CwaKfHw9x6k7Z-83mC8KzkP________-tSxexYwusSRjOrIMuB3YiA6EaLnfr1fbbR8LfwAsAu74%3D; BUYIN_SASID=SID2_7615938059562205474; COMPASS_LUOPAN_DT=session_7615939876688511241"
|
||||
if cookie == "" {
|
||||
fmt.Println("请通过环境变量 DOUYIN_COOKIE 提供抖店 Cookie")
|
||||
os.Exit(1)
|
||||
|
||||
219
cmd/exploit_verify/main.go
Normal file
219
cmd/exploit_verify/main.go
Normal file
@ -0,0 +1,219 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
)
|
||||
|
||||
func main() {
|
||||
dsn := "root:bindbox2025kdy@tcp(150.158.78.154:3306)/dev_game?charset=utf8mb4&parseTime=True&loc=Asia%2FShanghai"
|
||||
db, err := sql.Open("mysql", dsn)
|
||||
if err != nil {
|
||||
fmt.Println("连接失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer db.Close()
|
||||
fmt.Println("✅ 数据库连接成功\n")
|
||||
|
||||
// ============ 1. 全局汇总 ============
|
||||
fmt.Println("【1】全局汇总")
|
||||
var userCount, itemCount int64
|
||||
var totalYuan float64
|
||||
db.QueryRow(`
|
||||
SELECT COUNT(DISTINCT t.to_user_id), COUNT(DISTINCT t.inventory_id), IFNULL(SUM(i.value_cents)/100.0, 0)
|
||||
FROM user_inventory_transfers t
|
||||
JOIN user_inventory i ON i.id = t.inventory_id
|
||||
WHERE i.remark LIKE '%redeemed%'
|
||||
`).Scan(&userCount, &itemCount, &totalYuan)
|
||||
fmt.Printf(" 涉及用户: %d | 涉及资产: %d | 总薅取金额: %.2f 元\n\n", userCount, itemCount, totalYuan)
|
||||
|
||||
// ============ 2. 按用户汇总 ============
|
||||
fmt.Println("【2】按用户汇总薅取金额")
|
||||
fmt.Println(strings.Repeat("-", 95))
|
||||
fmt.Printf(" %-8s %-16s %-15s %-10s %-12s %-12s %s\n",
|
||||
"用户ID", "昵称", "手机号", "兑换资产数", "薅取金额(元)", "当前余额", "可扣回?")
|
||||
fmt.Println(" " + strings.Repeat("-", 90))
|
||||
|
||||
rows2, _ := db.Query(`
|
||||
SELECT
|
||||
sub.user_id,
|
||||
IFNULL(u.nickname, '') AS nickname,
|
||||
IFNULL(u.mobile, '') AS mobile,
|
||||
sub.redeem_count,
|
||||
sub.total_yuan,
|
||||
IFNULL(pts.balance, 0) AS balance
|
||||
FROM (
|
||||
SELECT t.to_user_id AS user_id,
|
||||
COUNT(DISTINCT t.inventory_id) AS redeem_count,
|
||||
SUM(i.value_cents) / 100.0 AS total_yuan,
|
||||
SUM(i.value_cents) AS total_cents
|
||||
FROM user_inventory_transfers t
|
||||
JOIN user_inventory i ON i.id = t.inventory_id
|
||||
WHERE i.remark LIKE '%redeemed%'
|
||||
GROUP BY t.to_user_id
|
||||
) sub
|
||||
LEFT JOIN users u ON u.id = sub.user_id
|
||||
LEFT JOIN (SELECT user_id, SUM(points) AS balance FROM user_points GROUP BY user_id) pts ON pts.user_id = sub.user_id
|
||||
ORDER BY sub.total_yuan DESC
|
||||
`)
|
||||
if rows2 != nil {
|
||||
defer rows2.Close()
|
||||
for rows2.Next() {
|
||||
var uid, redeemCnt, balance int64
|
||||
var totalY float64
|
||||
var nick, mobile string
|
||||
rows2.Scan(&uid, &nick, &mobile, &redeemCnt, &totalY, &balance)
|
||||
canDeduct := "✅ 可全额"
|
||||
exploitCents := int64(totalY * 100)
|
||||
if balance < exploitCents {
|
||||
canDeduct = fmt.Sprintf("⚠️ 仅可扣%d", balance)
|
||||
}
|
||||
fmt.Printf(" %-8d %-16s %-15s %-10d %-12.2f %-12d %s\n",
|
||||
uid, nick, mobile, redeemCnt, totalY, balance, canDeduct)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 3. 并发漏洞证据 ============
|
||||
fmt.Println("\n【3】并发漏洞证据 — 同一资产被多次转赠")
|
||||
fmt.Println(strings.Repeat("-", 100))
|
||||
rows3, _ := db.Query(`
|
||||
SELECT t.inventory_id, COUNT(*) AS cnt,
|
||||
GROUP_CONCAT(CONCAT(t.from_user_id,'→',t.to_user_id) ORDER BY t.created_at SEPARATOR ' | ') AS path,
|
||||
i.value_cents, IFNULL(p.name,'') AS pname
|
||||
FROM user_inventory_transfers t
|
||||
JOIN user_inventory i ON i.id = t.inventory_id
|
||||
LEFT JOIN products p ON p.id = i.product_id
|
||||
GROUP BY t.inventory_id, i.value_cents, p.name
|
||||
HAVING COUNT(*) > 1
|
||||
ORDER BY cnt DESC, i.value_cents DESC
|
||||
`)
|
||||
if rows3 != nil {
|
||||
defer rows3.Close()
|
||||
fmt.Printf(" %-10s %-6s %-10s %-28s %s\n", "资产ID", "次数", "价值(元)", "商品", "转赠路径")
|
||||
fmt.Println(" " + strings.Repeat("-", 95))
|
||||
for rows3.Next() {
|
||||
var invID, cnt, vc int64
|
||||
var path, pname string
|
||||
rows3.Scan(&invID, &cnt, &path, &vc, &pname)
|
||||
if len([]rune(pname)) > 14 {
|
||||
pname = string([]rune(pname)[:14]) + ".."
|
||||
}
|
||||
fmt.Printf(" %-10d %-6d %-10.2f %-28s %s\n", invID, cnt, float64(vc)/100.0, pname, path)
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 4. 转赠关系网络 Top15 ============
|
||||
fmt.Println("\n【4】转赠关系网络 Top15")
|
||||
fmt.Println(strings.Repeat("-", 110))
|
||||
rows4, _ := db.Query(`
|
||||
SELECT t.from_user_id, IFNULL(fu.nickname,'') AS fn,
|
||||
t.to_user_id, IFNULL(tu.nickname,'') AS tn,
|
||||
COUNT(*) AS xfer_cnt, COUNT(DISTINCT t.inventory_id) AS item_cnt,
|
||||
SUM(i.value_cents)/100.0 AS total_yuan,
|
||||
MIN(t.created_at) AS first_t, MAX(t.created_at) AS last_t
|
||||
FROM user_inventory_transfers t
|
||||
JOIN user_inventory i ON i.id = t.inventory_id
|
||||
LEFT JOIN users fu ON fu.id = t.from_user_id
|
||||
LEFT JOIN users tu ON tu.id = t.to_user_id
|
||||
GROUP BY t.from_user_id, fu.nickname, t.to_user_id, tu.nickname
|
||||
ORDER BY total_yuan DESC LIMIT 15
|
||||
`)
|
||||
if rows4 != nil {
|
||||
defer rows4.Close()
|
||||
fmt.Printf(" %-20s → %-20s %-6s %-6s %-12s %-12s %-12s\n",
|
||||
"赠送方", "接收方", "转赠次", "资产数", "金额(元)", "首次", "末次")
|
||||
fmt.Println(" " + strings.Repeat("-", 105))
|
||||
for rows4.Next() {
|
||||
var fuid, tuid, xcnt, icnt int64
|
||||
var yuan float64
|
||||
var fn, tn string
|
||||
var ft, lt time.Time
|
||||
rows4.Scan(&fuid, &fn, &tuid, &tn, &xcnt, &icnt, &yuan, &ft, <)
|
||||
from := fmt.Sprintf("%d(%s)", fuid, truncStr(fn, 6))
|
||||
to := fmt.Sprintf("%d(%s)", tuid, truncStr(tn, 6))
|
||||
fmt.Printf(" %-20s → %-20s %-6d %-6d %-12.2f %-12s %-12s\n",
|
||||
from, to, xcnt, icnt, yuan,
|
||||
ft.Format("01-02 15:04"), lt.Format("01-02 15:04"))
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 5. 典型利用链路样本(前10条) ============
|
||||
fmt.Println("\n【5】典型利用链路样本(转赠→取消发货→兑换积分)")
|
||||
fmt.Println(strings.Repeat("-", 130))
|
||||
rows5, _ := db.Query(`
|
||||
SELECT i.id, i.user_id, IFNULL(u.nickname,'') AS nick,
|
||||
i.value_cents, i.status, i.remark
|
||||
FROM user_inventory i
|
||||
LEFT JOIN users u ON u.id = i.user_id
|
||||
WHERE i.remark LIKE '%transferred_from_%'
|
||||
AND i.remark LIKE '%shipping_cancelled%'
|
||||
AND i.remark LIKE '%redeemed%'
|
||||
ORDER BY i.value_cents DESC
|
||||
LIMIT 10
|
||||
`)
|
||||
if rows5 != nil {
|
||||
defer rows5.Close()
|
||||
fmt.Printf(" %-8s %-8s %-14s %-10s %-6s %s\n", "资产ID", "用户ID", "昵称", "价值(元)", "状态", "操作链路")
|
||||
fmt.Println(" " + strings.Repeat("-", 125))
|
||||
for rows5.Next() {
|
||||
var id, uid, vc int64
|
||||
var status int32
|
||||
var nick, remark string
|
||||
rows5.Scan(&id, &uid, &nick, &vc, &status, &remark)
|
||||
fmt.Printf(" %-8d %-8d %-14s %-10.2f %-6s %s\n",
|
||||
id, uid, truncStr(nick, 12), float64(vc)/100.0,
|
||||
statusText(status), parseActions(remark))
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n✅ 核对完毕")
|
||||
}
|
||||
|
||||
func truncStr(s string, maxRunes int) string {
|
||||
runes := []rune(s)
|
||||
if len(runes) > maxRunes {
|
||||
return string(runes[:maxRunes]) + ".."
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseActions(remark string) string {
|
||||
parts := strings.Split(remark, "|")
|
||||
actions := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(p, "transferred_from_") {
|
||||
actions = append(actions, "转赠")
|
||||
} else if p == "shipping_requested" {
|
||||
actions = append(actions, "发货")
|
||||
} else if strings.HasPrefix(p, "shipping_cancelled") {
|
||||
actions = append(actions, "取消发货")
|
||||
} else if strings.Contains(p, "redeemed") {
|
||||
actions = append(actions, "✖兑换积分")
|
||||
} else {
|
||||
actions = append(actions, p)
|
||||
}
|
||||
}
|
||||
return strings.Join(actions, " → ")
|
||||
}
|
||||
|
||||
func statusText(s int32) string {
|
||||
switch s {
|
||||
case 1:
|
||||
return "持有"
|
||||
case 2:
|
||||
return "作废"
|
||||
case 3:
|
||||
return "已用"
|
||||
default:
|
||||
return fmt.Sprintf("%d", s)
|
||||
}
|
||||
}
|
||||
162
docs/赠送资产漏洞核查报告.md
Normal file
162
docs/赠送资产漏洞核查报告.md
Normal file
@ -0,0 +1,162 @@
|
||||
# 赠送资产漏洞核查报告
|
||||
|
||||
> 数据源: dev_game 数据库 | 核查日期: 2026-03-11
|
||||
|
||||
---
|
||||
|
||||
## 一、结论摘要
|
||||
|
||||
| 项目 | 结论 |
|
||||
|------|------|
|
||||
| 并发漏洞 | **确实存在**,已修复(SELECT FOR UPDATE + RowsAffected 检查) |
|
||||
| 实际货物损失(一份发两份) | **0 元** — 18 个重复发货资产中,没有一个真正被两方都发了货 |
|
||||
| 积分重复兑换 | **0 元** — 没有任何资产被多人兑换积分,也没有同一资产被兑换多次 |
|
||||
| 发送方转赠后又兑换同一资产 | **0 笔** — 发送方没有在转赠后兑换过同一资产 |
|
||||
| 转赠后接收方兑换积分 | 91 笔 / 12,088.80 元 — **合法行为**,资产转赠后归接收方所有 |
|
||||
|
||||
**总实际损失: 0 元**
|
||||
|
||||
---
|
||||
|
||||
## 二、漏洞技术分析
|
||||
|
||||
### 2.1 Bug 描述
|
||||
|
||||
文件: `internal/service/user/address_share.go`
|
||||
|
||||
| Bug | 位置 | 描述 | 后果 |
|
||||
|-----|------|------|------|
|
||||
| readDB 竞态 | 原 L116-133 | 反重复检查使用从库(readDB),主从延迟 10-100ms 内并发请求可绕过 | 同一资产产生重复转赠记录和重复发货记录 |
|
||||
| RowsAffected 未检查 | 原 L181-189 | `Updates()` 返回 0 行影响时不报错,后续操作继续执行 | 资产状态未变但发货记录已创建 |
|
||||
|
||||
### 2.2 修复方案(已合并 zuncle 分支)
|
||||
|
||||
| 修复 | 方式 |
|
||||
|------|------|
|
||||
| 竞态条件 | 在事务内使用 `SELECT FOR UPDATE` 锁行 + 写库查询发货记录 |
|
||||
| RowsAffected | 转赠和原主发货两个分支都检查 `result.RowsAffected == 0` 后回滚 |
|
||||
|
||||
### 2.3 关于"转赠资产禁止兑换积分"
|
||||
|
||||
zuncle 分支原本还包含第三个修复:禁止通过转赠获得的资产兑换积分。经核实,**这是业务策略而非修漏洞**:
|
||||
- 资产转赠后归接收方所有,接收方有权决定发货或兑换积分
|
||||
- 发送方没有对已转赠的资产做任何兑换操作
|
||||
- 不存在"一份资产两边都兑换"的情况
|
||||
|
||||
**已回退此限制。**
|
||||
|
||||
---
|
||||
|
||||
## 三、数据核查
|
||||
|
||||
### 3.1 全局概况
|
||||
|
||||
| 指标 | 数值 |
|
||||
|------|------|
|
||||
| 总转赠记录数 | 157 条 |
|
||||
| 涉及资产数 | 135 个 |
|
||||
| 涉及用户数 | 15 人 |
|
||||
| 多次转赠资产 | 13 个(并发 bug 导致的重复记录) |
|
||||
| 两方都产生发货记录的资产 | 18 个 |
|
||||
|
||||
### 3.2 重复发货资产明细(18 个)
|
||||
|
||||
> 同一资产在发送方和接收方名下都产生了发货记录
|
||||
|
||||
#### 仅接收方有效发货,发送方全取消(8 个)— 无损失
|
||||
|
||||
| 资产ID | 价值(元) | 发送方 | 接收方 | 接收方状态 | 发送方状态 | 兑换积分 |
|
||||
|--------|---------|--------|--------|-----------|-----------|---------|
|
||||
| 49426 | 1,315.00 | 9248(不出last退了) | 9116(非洲人) | 有效1/取消1 | 全取消(2) | 否 |
|
||||
| 47096 | 900.00 | 9305(新人) | 9116(非洲人) | 有效1/取消1 | 全取消(3) | 否 |
|
||||
| 51038 | 605.00 | 9210(非酋) | 9116(非洲人) | 有效1/取消3 | 全取消(1) | 否 |
|
||||
| 44153 | 375.00 | 9248(不出last退了) | 9116(非洲人) | 有效1/取消2 | 全取消(1) | 否 |
|
||||
| 44152 | 375.00 | 9248(不出last退了) | 9116(非洲人) | 有效1/取消4 | 全取消(1) | 否 |
|
||||
| 44151 | 375.00 | 9248(不出last退了) | 9116(非洲人) | 有效1/取消2 | 全取消(1) | 否 |
|
||||
| 44150 | 375.00 | 9248(不出last退了) | 9116(非洲人) | 有效1/取消2 | 全取消(1) | 否 |
|
||||
|
||||
小计: 4,320 元,全部仅接收方 9116 有效发货,**无实际损失**。
|
||||
|
||||
#### 双方全取消(10 个)— 无损失
|
||||
|
||||
| 资产ID | 价值(元) | 发送方 | 接收方 | 接收方兑换积分 |
|
||||
|--------|---------|--------|--------|------------|
|
||||
| 42746 | 375.00 | 9336(有冰的帝君) | 9116(非洲人) | 是 |
|
||||
| 42757 | 375.00 | 9336(有冰的帝君) | 9116(非洲人) | 是 |
|
||||
| 42758 | 375.00 | 9336(有冰的帝君) | 9116(非洲人) | 是 |
|
||||
| 43304 | 375.00 | 9305(新人) | 9116(非洲人) | 是 |
|
||||
| 42761 | 375.00 | 9116(非洲人) | 9305(新人) | 是 |
|
||||
| 46445 | 375.00 | 9230(巨欧小肥龙) | 9116(非洲人) | 是 |
|
||||
| 46446 | 375.00 | 9230(巨欧小肥龙) | 9116(非洲人) | 是 |
|
||||
| 46447 | 375.00 | 9230(巨欧小肥龙) | 9116(非洲人) | 是 |
|
||||
| 46506 | 375.00 | 9209(程c) | 9116(非洲人) | 是 |
|
||||
| 46507 | 375.00 | 9209(程c) | 9116(非洲人) | 是 |
|
||||
| 52338 | 12.50 | 9094(范巴斯滕) | 9449(古利特) | 是 |
|
||||
|
||||
小计: 双方发货全取消,接收方后续兑换了积分 — 这属于**接收方对自有资产的合法操作**。
|
||||
|
||||
### 3.3 积分兑换核查
|
||||
|
||||
| 核查项 | 结果 |
|
||||
|--------|------|
|
||||
| 同一资产被多个用户兑换积分 | **0 个** |
|
||||
| 同一资产被同一用户多次兑换积分 | **0 个** |
|
||||
| 发送方在 points_ledger 中兑换已转赠资产 | **0 笔** |
|
||||
| 发送方的已转赠资产 remark 含 redeemed | **1 笔**(用户 9116 转出给 9305 的资产 43304,后又转回 9116 兑换,属于正常来回转赠) |
|
||||
|
||||
### 3.4 转赠后接收方兑换积分明细
|
||||
|
||||
> 以下为合法行为,资产转赠后归接收方所有,接收方有权兑换
|
||||
|
||||
| 用户ID | 昵称 | 兑换笔数 | 兑换金额(元) | 性质 |
|
||||
|--------|------|---------|------------|------|
|
||||
| 9116 | 非洲人 | 30 | 10,737.00 | 合法 — 接收转赠后兑换 |
|
||||
| 9110 | 极品官方内部号 | 24 | 446.60 | 合法 |
|
||||
| 9305 | 新人 | 1 | 375.00 | 合法 |
|
||||
| 9209 | 程c | 9 | 220.00 | 合法 |
|
||||
| 9222 | 嗯?!!!! | 15 | 180.00 | 合法 |
|
||||
| 9336 | 有冰的帝君 | 1 | 60.00 | 合法 |
|
||||
| 9094 | 范巴斯滕救了一个美女 | 2 | 25.00 | 合法 |
|
||||
| 9210 | 非酋 | 3 | 22.50 | 合法 |
|
||||
| 9449 | 古利特使出了佛怒火莲 | 1 | 12.50 | 合法 |
|
||||
| 9248 | 不出last退了 | 3 | 8.60 | 合法 |
|
||||
| 9365 | 未命名 | 1 | 1.00 | 合法 |
|
||||
| 9230 | 巨欧小肥龙 | 1 | 0.60 | 合法 |
|
||||
| **合计** | | **91** | **12,088.80** | **全部合法** |
|
||||
|
||||
### 3.5 多次转赠记录(并发 bug 产生的脏数据)
|
||||
|
||||
| 资产ID | 转赠次数 | 路径 | 说明 |
|
||||
|--------|---------|------|------|
|
||||
| 42746 | 4 | 9336→9116 x4 | 同一操作并发触发4次 |
|
||||
| 46668 | 4 | 9210→9116 x4 | 同上 |
|
||||
| 43304 | 3 | 9305→9116, 9116→9305, 9305→9116 | 正常来回转赠 |
|
||||
| 44152 | 3 | 9248→9116 x3 | 并发触发3次 |
|
||||
| 46445 | 3 | 9230→9116 x3 | 并发触发3次 |
|
||||
| 46667 | 3 | 9210→9116 x3 | 并发触发3次 |
|
||||
| 51038 | 3 | 9210→9116, 9116→9210, 9210→9116 | 正常来回转赠 |
|
||||
| 其他 6 个 | 2 | — | 并发触发2次 |
|
||||
|
||||
这些重复记录是脏数据,但未造成资产复制或积分重复。
|
||||
|
||||
---
|
||||
|
||||
## 四、修复状态
|
||||
|
||||
| 修复项 | 状态 | Commit |
|
||||
|--------|------|--------|
|
||||
| SELECT FOR UPDATE 防并发转赠 | ✅ 已合并 main | `8229b41` |
|
||||
| RowsAffected 检查防静默失败 | ✅ 已合并 main | `8229b41` |
|
||||
| ~~禁止转赠资产兑换积分~~ | ❌ 已回退 | `749464c`,属业务策略非 bug 修复 |
|
||||
|
||||
---
|
||||
|
||||
## 五、结论
|
||||
|
||||
1. **并发 bug 确实存在**:readDB 竞态 + RowsAffected 未检查,导致同一资产产生重复转赠记录和重复发货记录
|
||||
2. **实际经济损失为 0 元**:
|
||||
- 没有任何资产被真正发了两份货(重复发货记录中,发送方一侧全部已取消)
|
||||
- 没有任何资产被重复兑换积分
|
||||
- 发送方没有在转赠后又兑换同一资产的积分
|
||||
3. **转赠后兑换积分是合法行为**:资产转赠后归接收方,接收方有权兑换
|
||||
4. **Bug 已修复**,防止未来可能的真正损失
|
||||
1518
exploit_report_20260311.txt
Normal file
1518
exploit_report_20260311.txt
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,11 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
)
|
||||
|
||||
type listIssueChoicesResponse struct {
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"context"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
@ -17,6 +12,11 @@ import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
titlesvc "bindbox-game/internal/service/title"
|
||||
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
@ -1,11 +1,12 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/util/remark"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// couponJoinResult 优惠券联合查询结果
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/util/remark"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
@ -13,6 +8,12 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/util/remark"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
type orderResultQuery struct {
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/pkg/wechat"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
activitysvc "bindbox-game/internal/service/activity"
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/binary"
|
||||
@ -18,6 +11,14 @@ import (
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/pkg/wechat"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
activitysvc "bindbox-game/internal/service/activity"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"testing"
|
||||
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
)
|
||||
|
||||
// TestSelectRewardExact 测试对对碰选奖逻辑:精确匹配 TotalPairs == MinScore
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/pkg/wechat"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
activitysvc "bindbox-game/internal/service/activity"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/pkg/wechat"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
activitysvc "bindbox-game/internal/service/activity"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
activitysvc "bindbox-game/internal/service/activity"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type activityCommitGenerateResp struct {
|
||||
|
||||
@ -1,19 +1,19 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/jwtoken"
|
||||
"bindbox-game/internal/pkg/utils"
|
||||
"bindbox-game/configs"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/jwtoken"
|
||||
"bindbox-game/internal/pkg/utils"
|
||||
)
|
||||
|
||||
type refreshResponse struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
Token string `json:"token"`
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
}
|
||||
|
||||
// RefreshToken 刷新管理员访问令牌
|
||||
@ -26,25 +26,25 @@ type refreshResponse struct {
|
||||
// @Router /api/admin/auth/refresh [post]
|
||||
// @Security LoginVerifyToken
|
||||
func (h *handler) RefreshToken() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
auth := ctx.Request().Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
ctx.AbortWithError(core.Error(http.StatusUnauthorized, code.AdminLoginError, "未携带令牌"))
|
||||
return
|
||||
}
|
||||
newToken, err := jwtoken.New(configs.Get().JWT.AdminSecret).Refresh(auth)
|
||||
if err != nil || newToken == "" {
|
||||
ctx.AbortWithError(core.Error(http.StatusUnauthorized, code.AdminLoginError, "令牌刷新失败"))
|
||||
return
|
||||
}
|
||||
info := ctx.SessionUserInfo()
|
||||
if info.Id > 0 {
|
||||
_, _ = h.writeDB.Admin.WithContext(ctx.RequestContext()).Where(h.writeDB.Admin.ID.Eq(int32(info.Id))).Updates(map[string]any{
|
||||
"last_login_time": time.Now(),
|
||||
"last_login_ip": utils.GetIP(ctx.Request()),
|
||||
"last_login_hash": utils.MD5(newToken),
|
||||
})
|
||||
}
|
||||
ctx.Payload(refreshResponse{Token: newToken, ExpiresIn: int64(24 * 3600)})
|
||||
}
|
||||
}
|
||||
return func(ctx core.Context) {
|
||||
auth := ctx.Request().Header.Get("Authorization")
|
||||
if auth == "" {
|
||||
ctx.AbortWithError(core.Error(http.StatusUnauthorized, code.AdminLoginError, "未携带令牌"))
|
||||
return
|
||||
}
|
||||
newToken, err := jwtoken.New(configs.Get().JWT.AdminSecret).Refresh(auth)
|
||||
if err != nil || newToken == "" {
|
||||
ctx.AbortWithError(core.Error(http.StatusUnauthorized, code.AdminLoginError, "令牌刷新失败"))
|
||||
return
|
||||
}
|
||||
info := ctx.SessionUserInfo()
|
||||
if info.Id > 0 {
|
||||
_, _ = h.writeDB.Admin.WithContext(ctx.RequestContext()).Where(h.writeDB.Admin.ID.Eq(int32(info.Id))).Updates(map[string]any{
|
||||
"last_login_time": time.Now(),
|
||||
"last_login_ip": utils.GetIP(ctx.Request()),
|
||||
"last_login_hash": utils.MD5(newToken),
|
||||
})
|
||||
}
|
||||
ctx.Payload(refreshResponse{Token: newToken, ExpiresIn: int64(24 * 3600)})
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,26 +1,26 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
bannersvc "bindbox-game/internal/service/banner"
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
bannersvc "bindbox-game/internal/service/banner"
|
||||
)
|
||||
|
||||
type createBannerRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
ImageURL string `json:"image_url" binding:"required"`
|
||||
LinkURL string `json:"link_url"`
|
||||
Sort int32 `json:"sort"`
|
||||
Status int32 `json:"status"`
|
||||
Title string `json:"title" binding:"required"`
|
||||
ImageURL string `json:"image_url" binding:"required"`
|
||||
LinkURL string `json:"link_url"`
|
||||
Sort int32 `json:"sort"`
|
||||
Status int32 `json:"status"`
|
||||
}
|
||||
|
||||
type createBannerResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Message string `json:"message"`
|
||||
ID int64 `json:"id"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
|
||||
// CreateBanner 创建轮播图
|
||||
@ -34,34 +34,34 @@ type createBannerResponse struct {
|
||||
// @Router /api/admin/banners [post]
|
||||
// @Security LoginVerifyToken
|
||||
func (h *handler) CreateBanner() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
req := new(createBannerRequest)
|
||||
res := new(createBannerResponse)
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
if ctx.SessionUserInfo().IsSuper != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
return
|
||||
}
|
||||
item, err := h.banner.Create(ctx.RequestContext(), bannersvc.CreateInput{Title: req.Title, ImageURL: req.ImageURL, LinkURL: req.LinkURL, Sort: req.Sort, Status: req.Status})
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
res.ID = item.ID
|
||||
res.Message = "操作成功"
|
||||
ctx.Payload(res)
|
||||
}
|
||||
return func(ctx core.Context) {
|
||||
req := new(createBannerRequest)
|
||||
res := new(createBannerResponse)
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
if ctx.SessionUserInfo().IsSuper != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
return
|
||||
}
|
||||
item, err := h.banner.Create(ctx.RequestContext(), bannersvc.CreateInput{Title: req.Title, ImageURL: req.ImageURL, LinkURL: req.LinkURL, Sort: req.Sort, Status: req.Status})
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
res.ID = item.ID
|
||||
res.Message = "操作成功"
|
||||
ctx.Payload(res)
|
||||
}
|
||||
}
|
||||
|
||||
type modifyBannerRequest struct {
|
||||
Title *string `json:"title"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
LinkURL *string `json:"link_url"`
|
||||
Sort *int32 `json:"sort"`
|
||||
Status *int32 `json:"status"`
|
||||
Title *string `json:"title"`
|
||||
ImageURL *string `json:"image_url"`
|
||||
LinkURL *string `json:"link_url"`
|
||||
Sort *int32 `json:"sort"`
|
||||
Status *int32 `json:"status"`
|
||||
}
|
||||
|
||||
// ModifyBanner 修改轮播图
|
||||
@ -76,24 +76,24 @@ type modifyBannerRequest struct {
|
||||
// @Router /api/admin/banners/{banner_id} [put]
|
||||
// @Security LoginVerifyToken
|
||||
func (h *handler) ModifyBanner() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
req := new(modifyBannerRequest)
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
idStr := ctx.Param("banner_id")
|
||||
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||
if ctx.SessionUserInfo().IsSuper != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
return
|
||||
}
|
||||
if err := h.banner.Modify(ctx.RequestContext(), id, bannersvc.ModifyInput{Title: req.Title, ImageURL: req.ImageURL, LinkURL: req.LinkURL, Sort: req.Sort, Status: req.Status}); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
ctx.Payload(pcSimpleMessage{Message: "操作成功"})
|
||||
}
|
||||
return func(ctx core.Context) {
|
||||
req := new(modifyBannerRequest)
|
||||
if err := ctx.ShouldBindJSON(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
idStr := ctx.Param("banner_id")
|
||||
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||
if ctx.SessionUserInfo().IsSuper != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
return
|
||||
}
|
||||
if err := h.banner.Modify(ctx.RequestContext(), id, bannersvc.ModifyInput{Title: req.Title, ImageURL: req.ImageURL, LinkURL: req.LinkURL, Sort: req.Sort, Status: req.Status}); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
ctx.Payload(pcSimpleMessage{Message: "操作成功"})
|
||||
}
|
||||
}
|
||||
|
||||
// DeleteBanner 删除轮播图
|
||||
@ -107,41 +107,41 @@ func (h *handler) ModifyBanner() core.HandlerFunc {
|
||||
// @Router /api/admin/banners/{banner_id} [delete]
|
||||
// @Security LoginVerifyToken
|
||||
func (h *handler) DeleteBanner() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
idStr := ctx.Param("banner_id")
|
||||
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||
if ctx.SessionUserInfo().IsSuper != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
return
|
||||
}
|
||||
if err := h.banner.Delete(ctx.RequestContext(), id); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
ctx.Payload(pcSimpleMessage{Message: "操作成功"})
|
||||
}
|
||||
return func(ctx core.Context) {
|
||||
idStr := ctx.Param("banner_id")
|
||||
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||
if ctx.SessionUserInfo().IsSuper != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
return
|
||||
}
|
||||
if err := h.banner.Delete(ctx.RequestContext(), id); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
ctx.Payload(pcSimpleMessage{Message: "操作成功"})
|
||||
}
|
||||
}
|
||||
|
||||
type listBannersRequest struct {
|
||||
Status *int32 `form:"status"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
Status *int32 `form:"status"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
|
||||
type bannerItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ImageURL string `json:"image_url"`
|
||||
LinkURL string `json:"link_url"`
|
||||
Sort int32 `json:"sort"`
|
||||
Status int32 `json:"status"`
|
||||
ID int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
ImageURL string `json:"image_url"`
|
||||
LinkURL string `json:"link_url"`
|
||||
Sort int32 `json:"sort"`
|
||||
Status int32 `json:"status"`
|
||||
}
|
||||
|
||||
type listBannersResponse struct {
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
List []bannerItem `json:"list"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Total int64 `json:"total"`
|
||||
List []bannerItem `json:"list"`
|
||||
}
|
||||
|
||||
// ListBanners 查看轮播图列表
|
||||
@ -157,25 +157,25 @@ type listBannersResponse struct {
|
||||
// @Router /api/admin/banners [get]
|
||||
// @Security LoginVerifyToken
|
||||
func (h *handler) ListBanners() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
req := new(listBannersRequest)
|
||||
res := new(listBannersResponse)
|
||||
if err := ctx.ShouldBindForm(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
items, total, err := h.banner.List(ctx.RequestContext(), bannersvc.ListInput{Status: req.Status, Page: req.Page, PageSize: req.PageSize})
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
res.Page = req.Page
|
||||
res.PageSize = req.PageSize
|
||||
res.Total = total
|
||||
res.List = make([]bannerItem, len(items))
|
||||
for i, it := range items {
|
||||
res.List[i] = bannerItem{ID: it.ID, Title: it.Title, ImageURL: it.ImageURL, LinkURL: it.LinkURL, Sort: it.Sort, Status: it.Status}
|
||||
}
|
||||
ctx.Payload(res)
|
||||
}
|
||||
}
|
||||
return func(ctx core.Context) {
|
||||
req := new(listBannersRequest)
|
||||
res := new(listBannersResponse)
|
||||
if err := ctx.ShouldBindForm(req); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.ParamBindError, validation.Error(err)))
|
||||
return
|
||||
}
|
||||
items, total, err := h.banner.List(ctx.RequestContext(), bannersvc.ListInput{Status: req.Status, Page: req.Page, PageSize: req.PageSize})
|
||||
if err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
res.Page = req.Page
|
||||
res.PageSize = req.PageSize
|
||||
res.Total = total
|
||||
res.List = make([]bannerItem, len(items))
|
||||
for i, it := range items {
|
||||
res.List[i] = bannerItem{ID: it.ID, Title: it.Title, ImageURL: it.ImageURL, LinkURL: it.LinkURL, Sort: it.Sort, Status: it.Status}
|
||||
}
|
||||
ctx.Payload(res)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,6 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
financesvc "bindbox-game/internal/service/finance"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -14,6 +9,12 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
financesvc "bindbox-game/internal/service/finance"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
|
||||
@ -1,16 +1,17 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
financesvc "bindbox-game/internal/service/finance"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
financesvc "bindbox-game/internal/service/finance"
|
||||
)
|
||||
|
||||
type spendingLeaderboardRequest struct {
|
||||
|
||||
@ -1,10 +1,6 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/service/douyin"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
@ -13,6 +9,11 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/service/douyin"
|
||||
)
|
||||
|
||||
// ---------- 抖店配置 API ----------
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
activitysvc "bindbox-game/internal/service/activity"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
type listSlotsRequest struct {
|
||||
|
||||
@ -23,11 +23,11 @@ type listIssuesResponse struct {
|
||||
}
|
||||
|
||||
type activitysvcIssueData struct {
|
||||
ID int64 `json:"id"`
|
||||
IssueNumber string `json:"issue_number"`
|
||||
Status int32 `json:"status"`
|
||||
Sort int32 `json:"sort"`
|
||||
PrizeCount int64 `json:"prize_count"`
|
||||
ID int64 `json:"id"`
|
||||
IssueNumber string `json:"issue_number"`
|
||||
Status int32 `json:"status"`
|
||||
Sort int32 `json:"sort"`
|
||||
PrizeCount int64 `json:"prize_count"`
|
||||
}
|
||||
|
||||
// ListActivityIssues 查看活动期数
|
||||
@ -70,21 +70,21 @@ func (h *handler) ListActivityIssues() core.HandlerFunc {
|
||||
res.Page = req.Page
|
||||
res.PageSize = req.PageSize
|
||||
res.Total = total
|
||||
res.List = make([]*activitysvcIssueData, len(items))
|
||||
for i, v := range items {
|
||||
var prizeCount int64
|
||||
count, err := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityRewardSettings.IssueID.Eq(v.ID)).Count()
|
||||
if err == nil {
|
||||
prizeCount = count
|
||||
}
|
||||
res.List[i] = &activitysvcIssueData{
|
||||
ID: v.ID,
|
||||
IssueNumber: v.IssueNumber,
|
||||
Status: v.Status,
|
||||
Sort: v.Sort,
|
||||
PrizeCount: prizeCount,
|
||||
}
|
||||
}
|
||||
res.List = make([]*activitysvcIssueData, len(items))
|
||||
for i, v := range items {
|
||||
var prizeCount int64
|
||||
count, err := h.readDB.ActivityRewardSettings.WithContext(ctx.RequestContext()).ReadDB().Where(h.readDB.ActivityRewardSettings.IssueID.Eq(v.ID)).Count()
|
||||
if err == nil {
|
||||
prizeCount = count
|
||||
}
|
||||
res.List[i] = &activitysvcIssueData{
|
||||
ID: v.ID,
|
||||
IssueNumber: v.IssueNumber,
|
||||
Status: v.Status,
|
||||
Sort: v.Sort,
|
||||
PrizeCount: prizeCount,
|
||||
}
|
||||
}
|
||||
ctx.Payload(res)
|
||||
}
|
||||
}
|
||||
|
||||
@ -230,21 +230,21 @@ func (h *handler) ModifySystemItemCard() core.HandlerFunc {
|
||||
// @Failure 500 {object} code.Failure "服务器内部错误"
|
||||
// @Router /api/admin/system_item_cards/{item_card_id} [delete]
|
||||
func (h *handler) DeleteSystemItemCard() core.HandlerFunc {
|
||||
return func(ctx core.Context) {
|
||||
idStr := ctx.Param("item_card_id")
|
||||
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||
if ctx.SessionUserInfo().IsSuper != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
return
|
||||
}
|
||||
uid := int64(ctx.SessionUserInfo().Id)
|
||||
set := map[string]any{"deleted_at": time.Now(), "deleted_by": uid}
|
||||
if _, err := h.writeDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.writeDB.SystemItemCards.ID.Eq(id)).Updates(set); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
|
||||
}
|
||||
return func(ctx core.Context) {
|
||||
idStr := ctx.Param("item_card_id")
|
||||
id, _ := strconv.ParseInt(idStr, 10, 64)
|
||||
if ctx.SessionUserInfo().IsSuper != 1 {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, "禁止操作"))
|
||||
return
|
||||
}
|
||||
uid := int64(ctx.SessionUserInfo().Id)
|
||||
set := map[string]any{"deleted_at": time.Now(), "deleted_by": uid}
|
||||
if _, err := h.writeDB.SystemItemCards.WithContext(ctx.RequestContext()).Where(h.writeDB.SystemItemCards.ID.Eq(id)).Updates(set); err != nil {
|
||||
ctx.AbortWithError(core.Error(http.StatusBadRequest, code.CreateAdminError, err.Error()))
|
||||
return
|
||||
}
|
||||
ctx.Payload(simpleMessageResponse{Message: "操作成功"})
|
||||
}
|
||||
}
|
||||
|
||||
type listItemCardsRequest struct {
|
||||
@ -257,25 +257,25 @@ type listItemCardsRequest struct {
|
||||
}
|
||||
|
||||
type itemCardListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Name string `json:"name"`
|
||||
Status int32 `json:"status"`
|
||||
CardType int32 `json:"card_type"`
|
||||
ScopeType int32 `json:"scope_type"`
|
||||
ActivityCategoryID int64 `json:"activity_category_id"`
|
||||
ActivityID int64 `json:"activity_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Price int64 `json:"price"`
|
||||
ValidStart time.Time `json:"valid_start"`
|
||||
ValidEnd time.Time `json:"valid_end"`
|
||||
EffectType int32 `json:"effect_type"`
|
||||
RewardMultiplierX1000 int32 `json:"reward_multiplier_x1000"`
|
||||
BoostRateX1000 int32 `json:"boost_rate_x1000"`
|
||||
StackingStrategy int32 `json:"stacking_strategy"`
|
||||
MaxEffectValueX1000 int32 `json:"max_effect_value_x1000"`
|
||||
Remark string `json:"remark"`
|
||||
ID int64 `json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Name string `json:"name"`
|
||||
Status int32 `json:"status"`
|
||||
CardType int32 `json:"card_type"`
|
||||
ScopeType int32 `json:"scope_type"`
|
||||
ActivityCategoryID int64 `json:"activity_category_id"`
|
||||
ActivityID int64 `json:"activity_id"`
|
||||
IssueID int64 `json:"issue_id"`
|
||||
Price int64 `json:"price"`
|
||||
ValidStart time.Time `json:"valid_start"`
|
||||
ValidEnd time.Time `json:"valid_end"`
|
||||
EffectType int32 `json:"effect_type"`
|
||||
RewardMultiplierX1000 int32 `json:"reward_multiplier_x1000"`
|
||||
BoostRateX1000 int32 `json:"boost_rate_x1000"`
|
||||
StackingStrategy int32 `json:"stacking_strategy"`
|
||||
MaxEffectValueX1000 int32 `json:"max_effect_value_x1000"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
|
||||
type listItemCardsResponse struct {
|
||||
@ -345,30 +345,30 @@ func (h *handler) ListSystemItemCards() core.HandlerFunc {
|
||||
res.Page = req.Page
|
||||
res.PageSize = req.PageSize
|
||||
res.Total = total
|
||||
res.List = make([]itemCardListItem, len(rows))
|
||||
for i, r := range rows {
|
||||
res.List[i] = itemCardListItem{
|
||||
ID: r.ID,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
Name: r.Name,
|
||||
Status: r.Status,
|
||||
CardType: r.CardType,
|
||||
ScopeType: r.ScopeType,
|
||||
ActivityCategoryID: r.ActivityCategoryID,
|
||||
ActivityID: r.ActivityID,
|
||||
IssueID: r.IssueID,
|
||||
Price: r.Price,
|
||||
ValidStart: r.ValidStart,
|
||||
ValidEnd: r.ValidEnd,
|
||||
EffectType: r.EffectType,
|
||||
RewardMultiplierX1000: r.RewardMultiplierX1000,
|
||||
BoostRateX1000: r.BoostRateX1000,
|
||||
StackingStrategy: r.StackingStrategy,
|
||||
MaxEffectValueX1000: r.MaxEffectValueX1000,
|
||||
Remark: r.Remark,
|
||||
}
|
||||
}
|
||||
res.List = make([]itemCardListItem, len(rows))
|
||||
for i, r := range rows {
|
||||
res.List[i] = itemCardListItem{
|
||||
ID: r.ID,
|
||||
CreatedAt: r.CreatedAt,
|
||||
UpdatedAt: r.UpdatedAt,
|
||||
Name: r.Name,
|
||||
Status: r.Status,
|
||||
CardType: r.CardType,
|
||||
ScopeType: r.ScopeType,
|
||||
ActivityCategoryID: r.ActivityCategoryID,
|
||||
ActivityID: r.ActivityID,
|
||||
IssueID: r.IssueID,
|
||||
Price: r.Price,
|
||||
ValidStart: r.ValidStart,
|
||||
ValidEnd: r.ValidEnd,
|
||||
EffectType: r.EffectType,
|
||||
RewardMultiplierX1000: r.RewardMultiplierX1000,
|
||||
BoostRateX1000: r.BoostRateX1000,
|
||||
StackingStrategy: r.StackingStrategy,
|
||||
MaxEffectValueX1000: r.MaxEffectValueX1000,
|
||||
Remark: r.Remark,
|
||||
}
|
||||
}
|
||||
ctx.Payload(res)
|
||||
}
|
||||
}
|
||||
|
||||
@ -915,13 +915,13 @@ func (h *handler) ListLivestreamDrawLogs() core.HandlerFunc {
|
||||
Remark string
|
||||
}
|
||||
var invRows []invRow
|
||||
_ = h.repo.GetDbR().Table("user_inventory").
|
||||
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Where("user_inventory.status IN (1,3)").
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("user_inventory.user_id > 0").
|
||||
_ = h.repo.GetDbR().Table("user_inventory").
|
||||
Select("user_inventory.user_id, COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) as value_cents, user_inventory.remark").
|
||||
Joins("LEFT JOIN activity_reward_settings ON activity_reward_settings.id = user_inventory.reward_id").
|
||||
Joins("LEFT JOIN products ON products.id = user_inventory.product_id").
|
||||
Where("user_inventory.status IN (1,3)").
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("user_inventory.user_id > 0").
|
||||
Scan(&invRows).Error
|
||||
invByUser := make(map[int64][]invRow)
|
||||
for _, v := range invRows {
|
||||
|
||||
@ -5,11 +5,11 @@ import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
"time"
|
||||
)
|
||||
|
||||
type dailyLivestreamStats struct {
|
||||
|
||||
@ -1,13 +1,6 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
paypkg "bindbox-game/internal/pkg/pay"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
strat "bindbox-game/internal/service/activity/strategy"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
"crypto/hmac"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
@ -15,6 +8,14 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
paypkg "bindbox-game/internal/pkg/pay"
|
||||
"bindbox-game/internal/pkg/validation"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
strat "bindbox-game/internal/service/activity/strategy"
|
||||
usersvc "bindbox-game/internal/service/user"
|
||||
)
|
||||
|
||||
type participantsResponse struct {
|
||||
|
||||
@ -1,9 +1,10 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"bindbox-game/internal/code"
|
||||
"bindbox-game/internal/pkg/core"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// GetMatchingAudit 获取对对碰审计数据
|
||||
|
||||
@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/pkg/util/remark"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
"bindbox-game/internal/repository/mysql/model"
|
||||
@ -129,163 +128,36 @@ var (
|
||||
ErrSearchKeywordEmpty = errors.New("search_keyword_empty")
|
||||
)
|
||||
|
||||
type orderRemarkRow struct {
|
||||
Remark string
|
||||
CreatedAt time.Time
|
||||
type orderAmountRow struct {
|
||||
ActualAmount int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
// calcPaidByPriceDraw 解析订单 remark,按游戏类型分三路计算实付金额:
|
||||
// - Case 1 (抽奖/直购): ActivityID > 0 → activities.price_draw × count
|
||||
// - Case 2 (对对碰): IssueID > 0 → activity_issues → activities.price_draw × count
|
||||
// - Case 3 (一番赏): PkgID > 0 → game_pass_packages.price × count
|
||||
//
|
||||
// calcGMVByTotalAmount 按订单原价(total_amount)统计渠道GMV,涵盖全部游戏类型(抽奖、对对碰、一番赏)。
|
||||
// 使用 total_amount(活动原价)而非 actual_amount,确保优惠券、道具卡、积分抵扣的订单也完整计入,
|
||||
// 与成本(商品价值)保持同一口径,使盈亏计算真实反映业务健康度。
|
||||
// 返回:总金额(分)、按 dateFmt 格式分组的金额。
|
||||
func (s *service) calcPaidByPriceDraw(ctx context.Context, rows []orderRemarkRow, dateFmt string) (int64, map[string]int64) {
|
||||
if len(rows) == 0 {
|
||||
return 0, nil
|
||||
func (s *service) calcGMVByTotalAmount(ctx context.Context, channelID int64, dateFmt string, orderFilter string, startDate, endDate *time.Time) (int64, map[string]int64) {
|
||||
type row struct {
|
||||
TotalAmount int64
|
||||
CreatedAt time.Time
|
||||
}
|
||||
|
||||
type parsedActivity struct {
|
||||
activityID int64
|
||||
count int64
|
||||
dateKey string
|
||||
}
|
||||
type parsedIssue struct {
|
||||
issueID int64
|
||||
count int64
|
||||
dateKey string
|
||||
}
|
||||
type parsedPkg struct {
|
||||
pkgID int64
|
||||
count int64
|
||||
dateKey string
|
||||
q := s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.total_amount, orders.created_at").
|
||||
Where(orderFilter, channelID)
|
||||
if startDate != nil && endDate != nil {
|
||||
q = q.Where("orders.created_at >= ? AND orders.created_at <= ?", *startDate, *endDate)
|
||||
}
|
||||
var rows []row
|
||||
q.Scan(&rows)
|
||||
|
||||
var actItems []parsedActivity
|
||||
var issueItems []parsedIssue
|
||||
var pkgItems []parsedPkg
|
||||
|
||||
actIDSet := make(map[int64]struct{})
|
||||
issueIDSet := make(map[int64]struct{})
|
||||
pkgIDSet := make(map[int64]struct{})
|
||||
|
||||
for _, r := range rows {
|
||||
rmk := remark.Parse(r.Remark)
|
||||
dateKey := r.CreatedAt.Format(dateFmt)
|
||||
|
||||
if rmk.ActivityID > 0 {
|
||||
// Case 1: 抽奖/直购 — 直接有 activityID
|
||||
actItems = append(actItems, parsedActivity{rmk.ActivityID, rmk.Count, dateKey})
|
||||
actIDSet[rmk.ActivityID] = struct{}{}
|
||||
} else if rmk.IssueID > 0 {
|
||||
// Case 2: 对对碰付费路径 — 只有 issueID,需查 activity_issues
|
||||
issueItems = append(issueItems, parsedIssue{rmk.IssueID, rmk.Count, dateKey})
|
||||
issueIDSet[rmk.IssueID] = struct{}{}
|
||||
} else if rmk.PkgID > 0 {
|
||||
// Case 3: 一番赏 — 有 pkgID,需查 game_pass_packages
|
||||
pkgItems = append(pkgItems, parsedPkg{rmk.PkgID, rmk.Count, dateKey})
|
||||
pkgIDSet[rmk.PkgID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 2: 批量查 activity_issues → 拿到 activityID ──
|
||||
issueActivityMap := make(map[int64]int64) // issueID → activityID
|
||||
if len(issueIDSet) > 0 {
|
||||
issueIDs := make([]int64, 0, len(issueIDSet))
|
||||
for id := range issueIDSet {
|
||||
issueIDs = append(issueIDs, id)
|
||||
}
|
||||
type issueRow struct {
|
||||
ID int64
|
||||
ActivityID int64
|
||||
}
|
||||
var issueRows []issueRow
|
||||
s.readDB.ActivityIssues.WithContext(ctx).UnderlyingDB().
|
||||
Table("activity_issues").
|
||||
Select("id, activity_id").
|
||||
Where("id IN ?", issueIDs).
|
||||
Scan(&issueRows)
|
||||
for _, ir := range issueRows {
|
||||
issueActivityMap[ir.ID] = ir.ActivityID
|
||||
actIDSet[ir.ActivityID] = struct{}{} // 合并到 actIDSet 一起查 price_draw
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 1+2: 批量查 activities.price_draw(含软删除)──
|
||||
priceMap := make(map[int64]int64) // activityID → price_draw
|
||||
if len(actIDSet) > 0 {
|
||||
actIDs := make([]int64, 0, len(actIDSet))
|
||||
for id := range actIDSet {
|
||||
actIDs = append(actIDs, id)
|
||||
}
|
||||
var acts []model.Activities
|
||||
s.readDB.Activities.WithContext(ctx).UnderlyingDB().
|
||||
Unscoped().
|
||||
Table("activities").
|
||||
Select("id, price_draw").
|
||||
Where("id IN ?", actIDs).
|
||||
Find(&acts)
|
||||
for _, a := range acts {
|
||||
priceMap[a.ID] = a.PriceDraw
|
||||
}
|
||||
}
|
||||
|
||||
// ── Case 3: 批量查 game_pass_packages.price ──
|
||||
pkgPriceMap := make(map[int64]int64) // pkgID → price
|
||||
if len(pkgIDSet) > 0 {
|
||||
pkgIDs := make([]int64, 0, len(pkgIDSet))
|
||||
for id := range pkgIDSet {
|
||||
pkgIDs = append(pkgIDs, id)
|
||||
}
|
||||
type pkgRow struct {
|
||||
ID int64
|
||||
Price int64
|
||||
}
|
||||
var pkgRows []pkgRow
|
||||
s.readDB.Activities.WithContext(ctx).UnderlyingDB().
|
||||
Unscoped().
|
||||
Table("game_pass_packages").
|
||||
Select("id, price").
|
||||
Where("id IN ?", pkgIDs).
|
||||
Scan(&pkgRows)
|
||||
for _, pr := range pkgRows {
|
||||
pkgPriceMap[pr.ID] = pr.Price
|
||||
}
|
||||
}
|
||||
|
||||
// ── 累加金额 ──
|
||||
var total int64
|
||||
byDate := make(map[string]int64)
|
||||
|
||||
// Case 1: 抽奖/直购
|
||||
for _, item := range actItems {
|
||||
if price, ok := priceMap[item.activityID]; ok {
|
||||
amt := price * item.count
|
||||
total += amt
|
||||
byDate[item.dateKey] += amt
|
||||
}
|
||||
for _, r := range rows {
|
||||
total += r.TotalAmount
|
||||
byDate[r.CreatedAt.Format(dateFmt)] += r.TotalAmount
|
||||
}
|
||||
|
||||
// Case 2: 对对碰
|
||||
for _, item := range issueItems {
|
||||
if actID, ok := issueActivityMap[item.issueID]; ok {
|
||||
if price, ok := priceMap[actID]; ok {
|
||||
amt := price * item.count
|
||||
total += amt
|
||||
byDate[item.dateKey] += amt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Case 3: 一番赏
|
||||
for _, item := range pkgItems {
|
||||
if price, ok := pkgPriceMap[item.pkgID]; ok {
|
||||
amt := price * item.count
|
||||
total += amt
|
||||
byDate[item.dateKey] += amt
|
||||
}
|
||||
}
|
||||
|
||||
return total, byDate
|
||||
}
|
||||
|
||||
@ -304,7 +176,7 @@ func (s *service) calcCostByInventory(ctx context.Context, channelID int64, date
|
||||
Table("user_inventory").
|
||||
Select(`
|
||||
COALESCE(NULLIF(user_inventory.value_cents, 0), activity_reward_settings.price_snapshot_cents, products.price, 0) AS unit_cost,
|
||||
GREATEST(COALESCE(system_item_cards.reward_multiplier_x1000, 1000), 1000) AS multiplier,
|
||||
CASE WHEN COALESCE(system_item_cards.reward_multiplier_x1000, 1000) < 1000 THEN 1000 ELSE COALESCE(system_item_cards.reward_multiplier_x1000, 1000) END AS multiplier,
|
||||
user_inventory.created_at
|
||||
`).
|
||||
Joins("JOIN users ON users.id = user_inventory.user_id").
|
||||
@ -317,7 +189,8 @@ func (s *service) calcCostByInventory(ctx context.Context, channelID int64, date
|
||||
Where("user_inventory.status IN ?", []int{1, 3}).
|
||||
Where("COALESCE(user_inventory.remark, '') NOT LIKE ?", "%void%").
|
||||
Where("(orders.status = 2 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
|
||||
Where("(orders.source_type IN (1,2,3,4) OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)")
|
||||
Where("(orders.source_type IN (2,3,4) OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)").
|
||||
Where("(orders.total_amount > 0 OR user_inventory.order_id = 0 OR user_inventory.order_id IS NULL)")
|
||||
|
||||
if startDate != nil && endDate != nil {
|
||||
q = q.Where("user_inventory.created_at >= ? AND user_inventory.created_at <= ?", *startDate, *endDate)
|
||||
@ -418,28 +291,20 @@ func (s *service) List(ctx context.Context, in ListInput) (items []*ChannelWithS
|
||||
}
|
||||
}
|
||||
|
||||
type PaidResult struct {
|
||||
ChannelID int64
|
||||
Remark string
|
||||
CreatedAt time.Time
|
||||
type GMVResult struct {
|
||||
ChannelID int64
|
||||
TotalAmount int64
|
||||
}
|
||||
var paidResults []PaidResult
|
||||
var gmvResults []GMVResult
|
||||
err = s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("users.channel_id, orders.remark, orders.created_at").
|
||||
Select("users.channel_id, orders.total_amount").
|
||||
Where("users.channel_id IN ?", channelIDs).
|
||||
Where("users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)").
|
||||
Scan(&paidResults).Error
|
||||
Where("users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)").
|
||||
Scan(&gmvResults).Error
|
||||
if err == nil {
|
||||
grouped := make(map[int64][]orderRemarkRow)
|
||||
for _, r := range paidResults {
|
||||
grouped[r.ChannelID] = append(grouped[r.ChannelID], orderRemarkRow{
|
||||
Remark: r.Remark, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
for chID, rows := range grouped {
|
||||
total, _ := s.calcPaidByPriceDraw(ctx, rows, "2006-01-02")
|
||||
paidStats[chID] = total
|
||||
for _, r := range gmvResults {
|
||||
paidStats[r.ChannelID] += r.TotalAmount
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -468,7 +333,9 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start
|
||||
}
|
||||
|
||||
out := &StatsOutput{}
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.actual_amount > 0 AND orders.source_type IN (1,2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
// source_type: 2=小程序抽奖 3=对对碰 4=一番赏/次卡 5=直播间抽奖抖店(不计入);排除商城直购(1)
|
||||
// actual_amount>0 排除次卡免费使用的订单(避免与购买次卡的订单重复计入GMV)
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
// ========== 1. Overview(全量,不限时间)==========
|
||||
|
||||
@ -484,14 +351,7 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start
|
||||
Scan(&cr)
|
||||
out.Overview.TotalOrders = cr.Count
|
||||
|
||||
var allRemarks []orderRemarkRow
|
||||
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.remark, orders.created_at").
|
||||
Where(orderFilter, channelID).
|
||||
Scan(&allRemarks)
|
||||
|
||||
totalPaid, _ := s.calcPaidByPriceDraw(ctx, allRemarks, "2006-01-02")
|
||||
totalPaid, _ := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, nil, nil)
|
||||
out.Overview.TotalPaidCents = totalPaid
|
||||
out.Overview.TotalGMV = totalPaid / 100
|
||||
|
||||
@ -553,14 +413,7 @@ func (s *service) GetStats(ctx context.Context, channelID int64, days int, start
|
||||
}
|
||||
}
|
||||
|
||||
var rangeRemarks []orderRemarkRow
|
||||
s.readDB.Orders.WithContext(ctx).UnderlyingDB().Table("orders").
|
||||
Joins("JOIN users ON users.id = orders.user_id").
|
||||
Select("orders.remark, orders.created_at").
|
||||
Where(orderFilter+" AND orders.created_at >= ? AND orders.created_at <= ?", channelID, startDate, endDate).
|
||||
Scan(&rangeRemarks)
|
||||
|
||||
_, dailyPaid := s.calcPaidByPriceDraw(ctx, rangeRemarks, "2006-01-02")
|
||||
_, dailyPaid := s.calcGMVByTotalAmount(ctx, channelID, "2006-01-02", orderFilter, &startDate, &endDate)
|
||||
for dateKey, paid := range dailyPaid {
|
||||
if item, ok := dateMap[dateKey]; ok {
|
||||
item.PaidCents = paid
|
||||
|
||||
283
internal/service/channel/channel_stats_test.go
Normal file
283
internal/service/channel/channel_stats_test.go
Normal file
@ -0,0 +1,283 @@
|
||||
package channel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"bindbox-game/internal/pkg/logger"
|
||||
"bindbox-game/internal/repository/mysql"
|
||||
"bindbox-game/internal/repository/mysql/dao"
|
||||
)
|
||||
|
||||
// setupTestService 创建使用 SQLite 内存库的 service 实例及基础表结构。
|
||||
func setupTestService(t *testing.T) (*service, mysql.Repo) {
|
||||
t.Helper()
|
||||
repo, err := mysql.NewSQLiteRepoForTest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ddls := []string{
|
||||
`CREATE TABLE channels (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT NOT NULL,
|
||||
type TEXT NOT NULL DEFAULT 'other',
|
||||
remarks TEXT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME
|
||||
)`,
|
||||
`CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
deleted_at DATETIME,
|
||||
nickname TEXT NOT NULL,
|
||||
avatar TEXT,
|
||||
mobile TEXT,
|
||||
openid TEXT,
|
||||
unionid TEXT,
|
||||
invite_code TEXT NOT NULL,
|
||||
inviter_id INTEGER DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
douyin_id TEXT,
|
||||
channel_id INTEGER DEFAULT 0,
|
||||
douyin_user_id TEXT,
|
||||
remark TEXT NOT NULL DEFAULT ''
|
||||
)`,
|
||||
`CREATE TABLE orders (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
status INTEGER NOT NULL,
|
||||
actual_amount INTEGER NOT NULL DEFAULT 0,
|
||||
total_amount INTEGER NOT NULL DEFAULT 0,
|
||||
source_type INTEGER NOT NULL DEFAULT 1,
|
||||
ext_order_id TEXT NOT NULL DEFAULT '',
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
item_card_id INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE user_inventory (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL,
|
||||
order_id INTEGER DEFAULT 0,
|
||||
reward_id INTEGER DEFAULT 0,
|
||||
product_id INTEGER DEFAULT 0,
|
||||
status INTEGER NOT NULL DEFAULT 1,
|
||||
value_cents INTEGER DEFAULT 0,
|
||||
remark TEXT NOT NULL DEFAULT '',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`,
|
||||
`CREATE TABLE activity_reward_settings (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
price_snapshot_cents INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE products (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
price INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE user_item_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
card_id INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE system_item_cards (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
reward_multiplier_x1000 INTEGER DEFAULT 1000
|
||||
)`,
|
||||
}
|
||||
for _, ddl := range ddls {
|
||||
if err := repo.GetDbW().Exec(ddl).Error; err != nil {
|
||||
t.Fatalf("DDL failed: %v\nSQL: %s", err, ddl)
|
||||
}
|
||||
}
|
||||
lg, err := logger.NewCustomLogger(logger.WithOutputInConsole())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
q := dao.Use(repo.GetDbR())
|
||||
svc := &service{logger: lg, readDB: q, writeDB: dao.Use(repo.GetDbW())}
|
||||
return svc, repo
|
||||
}
|
||||
|
||||
// mustExec 执行 SQL,失败则 Fatal。
|
||||
func mustExec(t *testing.T, repo mysql.Repo, sql string, args ...interface{}) {
|
||||
t.Helper()
|
||||
if err := repo.GetDbW().Exec(sql, args...).Error; err != nil {
|
||||
t.Fatalf("exec failed: %v\nSQL: %s", err, sql)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcGMVByTotalAmount_ThreeGameTypes 验证三种游戏类型的原价都被正确统计。
|
||||
// 使用 total_amount(活动原价)确保优惠券、道具卡免单的订单也完整计入。
|
||||
func TestCalcGMVByTotalAmount_ThreeGameTypes(t *testing.T) {
|
||||
svc, repo := setupTestService(t)
|
||||
|
||||
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`)
|
||||
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`)
|
||||
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
// 抽奖订单 source=2,actual_amount < total_amount(道具卡折扣)
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 800, 1000, 2, '', 'lottery:activity:10|count:1')`)
|
||||
// 对对碰付费 source=3
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 500, 500, 3, '', 'matching_game:issue:50')`)
|
||||
// 一番赏 source=4
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 800, 800, 4, '', 'game_pass_package:幸运|pkg_id:7|count:2')`)
|
||||
// 次卡免费使用:actual_amount=0 但 total_amount=600,不应计入GMV(避免与购买次卡重复计数)
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 0, 600, 2, '', 'lottery:activity:10|count:1|use_game_pass')`)
|
||||
// 过滤条件:status!=2,不应计入
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 1, 9999, 9999, 2, '', 'lottery:activity:10|count:1')`)
|
||||
// 过滤条件:total_amount=0,不应计入
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 0, 0, 2, '', 'lottery:activity:10|count:1')`)
|
||||
// 过滤条件:有 ext_order_id,不应计入
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 2, 9999, 9999, 2, 'EXT-1', 'lottery:activity:10|count:1')`)
|
||||
|
||||
total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil)
|
||||
|
||||
// 1000 + 500 + 800 = 2300(次卡免费使用actual=0的600不计入)
|
||||
if total != 2300 {
|
||||
t.Errorf("total = %d, want 2300 (抽奖1000 + 对对碰500 + 一番赏800)", total)
|
||||
}
|
||||
if len(byDate) == 0 {
|
||||
t.Error("byDate should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcGMVByTotalAmount_DateFilter 验证时间范围过滤正确。
|
||||
func TestCalcGMVByTotalAmount_DateFilter(t *testing.T) {
|
||||
svc, repo := setupTestService(t)
|
||||
|
||||
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`)
|
||||
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`)
|
||||
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, created_at) VALUES (1, 2, 500, 500, 2, '', '2026-03-01 10:00:00')`)
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, created_at) VALUES (1, 2, 300, 300, 3, '', '2026-03-05 10:00:00')`)
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id, created_at) VALUES (1, 2, 700, 700, 4, '', '2026-03-10 10:00:00')`)
|
||||
|
||||
start, _ := time.Parse("2006-01-02", "2026-03-02")
|
||||
end, _ := time.Parse("2006-01-02", "2026-03-09")
|
||||
end = end.Add(24*time.Hour - time.Second)
|
||||
|
||||
total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, &start, &end)
|
||||
|
||||
// 只有 03-05 的 300 在范围内
|
||||
if total != 300 {
|
||||
t.Errorf("total = %d, want 300 (only 2026-03-05 order)", total)
|
||||
}
|
||||
if byDate["2026-03-05"] != 300 {
|
||||
t.Errorf("byDate[2026-03-05] = %d, want 300", byDate["2026-03-05"])
|
||||
}
|
||||
if byDate["2026-03-01"] != 0 && byDate["2026-03-10"] != 0 {
|
||||
t.Error("dates outside range should not appear")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcGMVByTotalAmount_MultiChannel 验证不同渠道数据互不干扰。
|
||||
func TestCalcGMVByTotalAmount_MultiChannel(t *testing.T) {
|
||||
svc, repo := setupTestService(t)
|
||||
|
||||
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '渠道A', 'CA', 'other', ''), (2, '渠道B', 'CB', 'other', '')`)
|
||||
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`)
|
||||
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (2, 'u2', 'I2', 1, 2)`)
|
||||
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id) VALUES (1, 2, 1000, 1000, 2, '')`)
|
||||
mustExec(t, repo, `INSERT INTO orders (user_id, status, actual_amount, total_amount, source_type, ext_order_id) VALUES (2, 2, 2000, 2000, 3, '')`)
|
||||
|
||||
total1, _ := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil)
|
||||
total2, _ := svc.calcGMVByTotalAmount(context.Background(), 2, "2006-01-02", orderFilter, nil, nil)
|
||||
|
||||
if total1 != 1000 {
|
||||
t.Errorf("channel1 total = %d, want 1000", total1)
|
||||
}
|
||||
if total2 != 2000 {
|
||||
t.Errorf("channel2 total = %d, want 2000", total2)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcCostByInventory_Basic 验证成本从 value_cents 读取。
|
||||
func TestCalcCostByInventory_Basic(t *testing.T) {
|
||||
svc, repo := setupTestService(t)
|
||||
|
||||
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`)
|
||||
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`)
|
||||
|
||||
// status=1(待发货) 和 status=3(已发货) 都计入成本
|
||||
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 1, 500, '')`)
|
||||
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 3, 300, '')`)
|
||||
// status=2 不计入
|
||||
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 2, 999, '')`)
|
||||
// remark含void 不计入
|
||||
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 1, 888, 'void-item')`)
|
||||
|
||||
total, byDate := svc.calcCostByInventory(context.Background(), 1, "2006-01-02", nil, nil)
|
||||
|
||||
// 500 + 300 = 800
|
||||
if total != 800 {
|
||||
t.Errorf("cost total = %d, want 800", total)
|
||||
}
|
||||
if len(byDate) == 0 {
|
||||
t.Error("byDate should not be empty")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProfitLoss_AllGameTypes 端到端验证盈亏 = GMV(原价) - 成本,覆盖三种游戏类型及道具卡免单。
|
||||
// 核心场景:道具卡免单订单 actual_amount=0 但 total_amount=活动原价,成本真实存在,
|
||||
// 使用 total_amount 口径确保盈亏计算准确。
|
||||
func TestProfitLoss_AllGameTypes(t *testing.T) {
|
||||
svc, repo := setupTestService(t)
|
||||
|
||||
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '测试渠道', 'TEST', 'other', '')`)
|
||||
mustExec(t, repo, `INSERT INTO users (id, nickname, invite_code, status, channel_id) VALUES (1, 'u1', 'I1', 1, 1)`)
|
||||
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
// 收入:3种游戏(total_amount = 活动原价)
|
||||
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (1, 1, 2, 4600, 4600, 2, '', 'lottery:activity:10|count:1')`) // 抽奖 46元
|
||||
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (2, 1, 2, 1086, 1086, 3, '', 'matching_game:issue:50')`) // 对对碰 10.86元
|
||||
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (3, 1, 2, 3320, 3320, 4, '', 'game_pass_package:x|pkg_id:7|count:2')`) // 一番赏 33.20元
|
||||
// 次卡免费使用:actual_amount=0,total_amount=2000,不计入GMV(避免重复计数),但成本仍计入
|
||||
mustExec(t, repo, `INSERT INTO orders (id, user_id, status, actual_amount, total_amount, source_type, ext_order_id, remark) VALUES (4, 1, 2, 0, 2000, 2, '', 'lottery:activity:10|count:1|use_game_pass')`)
|
||||
|
||||
// 成本:库存资产
|
||||
mustExec(t, repo, `INSERT INTO user_inventory (user_id, order_id, status, value_cents, remark) VALUES (1, 0, 1, 8000, '')`) // 成本 80元
|
||||
|
||||
totalGMV, _ := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil)
|
||||
totalCost, _ := svc.calcCostByInventory(context.Background(), 1, "2006-01-02", nil, nil)
|
||||
profit := totalGMV - totalCost
|
||||
|
||||
// GMV = 4600 + 1086 + 3320 = 9006(次卡免费使用的2000不计入)
|
||||
if totalGMV != 9006 {
|
||||
t.Errorf("totalGMV = %d, want 9006 (抽奖4600 + 对对碰1086 + 一番赏3320)", totalGMV)
|
||||
}
|
||||
// 成本 = 8000
|
||||
if totalCost != 8000 {
|
||||
t.Errorf("totalCost = %d, want 8000", totalCost)
|
||||
}
|
||||
// 盈亏 = 9006 - 8000 = 1006
|
||||
if profit != 1006 {
|
||||
t.Errorf("profit = %d, want 1006", profit)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCalcGMVByTotalAmount_Empty 验证无订单时返回零值。
|
||||
func TestCalcGMVByTotalAmount_Empty(t *testing.T) {
|
||||
svc, repo := setupTestService(t)
|
||||
|
||||
mustExec(t, repo, `INSERT INTO channels (id, name, code, type, remarks) VALUES (1, '空渠道', 'EMPTY', 'other', '')`)
|
||||
|
||||
orderFilter := "users.channel_id = ? AND users.deleted_at IS NULL AND orders.status = 2 AND orders.total_amount > 0 AND orders.actual_amount > 0 AND orders.source_type IN (2,3,4) AND (orders.ext_order_id = '' OR orders.ext_order_id IS NULL)"
|
||||
|
||||
total, byDate := svc.calcGMVByTotalAmount(context.Background(), 1, "2006-01-02", orderFilter, nil, nil)
|
||||
|
||||
if total != 0 {
|
||||
t.Errorf("empty channel total = %d, want 0", total)
|
||||
}
|
||||
if len(byDate) != 0 {
|
||||
t.Errorf("byDate should be empty, got %v", byDate)
|
||||
}
|
||||
}
|
||||
BIN
web/.DS_Store
vendored
BIN
web/.DS_Store
vendored
Binary file not shown.
Loading…
x
Reference in New Issue
Block a user