无订阅 支付后出现两个订阅
Some checks failed
Build docker and publish / build (20.15.1) (push) Failing after 7m37s

This commit is contained in:
shanshanzhong 2026-03-05 21:53:36 -08:00
parent a1ab0fefa4
commit 7308aa9191
51 changed files with 2041 additions and 556 deletions

7
.claude-flow/.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
# Claude Flow runtime files
data/
logs/
sessions/
neural/
*.log
*.tmp

View File

@ -0,0 +1,403 @@
# RuFlo V3 - Complete Capabilities Reference
> Generated: 2026-03-06T01:38:48.235Z
> Full documentation: https://github.com/ruvnet/claude-flow
## 📋 Table of Contents
1. [Overview](#overview)
2. [Swarm Orchestration](#swarm-orchestration)
3. [Available Agents (60+)](#available-agents)
4. [CLI Commands (26 Commands, 140+ Subcommands)](#cli-commands)
5. [Hooks System (27 Hooks + 12 Workers)](#hooks-system)
6. [Memory & Intelligence (RuVector)](#memory--intelligence)
7. [Hive-Mind Consensus](#hive-mind-consensus)
8. [Performance Targets](#performance-targets)
9. [Integration Ecosystem](#integration-ecosystem)
---
## Overview
RuFlo V3 is a domain-driven design architecture for multi-agent AI coordination with:
- **15-Agent Swarm Coordination** with hierarchical and mesh topologies
- **HNSW Vector Search** - 150x-12,500x faster pattern retrieval
- **SONA Neural Learning** - Self-optimizing with <0.05ms adaptation
- **Byzantine Fault Tolerance** - Queen-led consensus mechanisms
- **MCP Server Integration** - Model Context Protocol support
### Current Configuration
| Setting | Value |
|---------|-------|
| Topology | hierarchical-mesh |
| Max Agents | 15 |
| Memory Backend | hybrid |
| HNSW Indexing | Enabled |
| Neural Learning | Enabled |
| LearningBridge | Enabled (SONA + ReasoningBank) |
| Knowledge Graph | Enabled (PageRank + Communities) |
| Agent Scopes | Enabled (project/local/user) |
---
## Swarm Orchestration
### Topologies
| Topology | Description | Best For |
|----------|-------------|----------|
| `hierarchical` | Queen controls workers directly | Anti-drift, tight control |
| `mesh` | Fully connected peer network | Distributed tasks |
| `hierarchical-mesh` | V3 hybrid (recommended) | 10+ agents |
| `ring` | Circular communication | Sequential workflows |
| `star` | Central coordinator | Simple coordination |
| `adaptive` | Dynamic based on load | Variable workloads |
### Strategies
- `balanced` - Even distribution across agents
- `specialized` - Clear roles, no overlap (anti-drift)
- `adaptive` - Dynamic task routing
### Quick Commands
```bash
# Initialize swarm
npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized
# Check status
npx @claude-flow/cli@latest swarm status
# Monitor activity
npx @claude-flow/cli@latest swarm monitor
```
---
## Available Agents
### Core Development (5)
`coder`, `reviewer`, `tester`, `planner`, `researcher`
### V3 Specialized (4)
`security-architect`, `security-auditor`, `memory-specialist`, `performance-engineer`
### Swarm Coordination (5)
`hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator`, `collective-intelligence-coordinator`, `swarm-memory-manager`
### Consensus & Distributed (7)
`byzantine-coordinator`, `raft-manager`, `gossip-coordinator`, `consensus-builder`, `crdt-synchronizer`, `quorum-manager`, `security-manager`
### Performance & Optimization (5)
`perf-analyzer`, `performance-benchmarker`, `task-orchestrator`, `memory-coordinator`, `smart-agent`
### GitHub & Repository (9)
`github-modes`, `pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager`, `workflow-automation`, `project-board-sync`, `repo-architect`, `multi-repo-swarm`
### SPARC Methodology (6)
`sparc-coord`, `sparc-coder`, `specification`, `pseudocode`, `architecture`, `refinement`
### Specialized Development (8)
`backend-dev`, `mobile-dev`, `ml-developer`, `cicd-engineer`, `api-docs`, `system-architect`, `code-analyzer`, `base-template-generator`
### Testing & Validation (2)
`tdd-london-swarm`, `production-validator`
### Agent Routing by Task
| Task Type | Recommended Agents | Topology |
|-----------|-------------------|----------|
| Bug Fix | researcher, coder, tester | mesh |
| New Feature | coordinator, architect, coder, tester, reviewer | hierarchical |
| Refactoring | architect, coder, reviewer | mesh |
| Performance | researcher, perf-engineer, coder | hierarchical |
| Security | security-architect, auditor, reviewer | hierarchical |
| Docs | researcher, api-docs | mesh |
---
## CLI Commands
### Core Commands (12)
| Command | Subcommands | Description |
|---------|-------------|-------------|
| `init` | 4 | Project initialization |
| `agent` | 8 | Agent lifecycle management |
| `swarm` | 6 | Multi-agent coordination |
| `memory` | 11 | AgentDB with HNSW search |
| `mcp` | 9 | MCP server management |
| `task` | 6 | Task assignment |
| `session` | 7 | Session persistence |
| `config` | 7 | Configuration |
| `status` | 3 | System monitoring |
| `workflow` | 6 | Workflow templates |
| `hooks` | 17 | Self-learning hooks |
| `hive-mind` | 6 | Consensus coordination |
### Advanced Commands (14)
| Command | Subcommands | Description |
|---------|-------------|-------------|
| `daemon` | 5 | Background workers |
| `neural` | 5 | Pattern training |
| `security` | 6 | Security scanning |
| `performance` | 5 | Profiling & benchmarks |
| `providers` | 5 | AI provider config |
| `plugins` | 5 | Plugin management |
| `deployment` | 5 | Deploy management |
| `embeddings` | 4 | Vector embeddings |
| `claims` | 4 | Authorization |
| `migrate` | 5 | V2→V3 migration |
| `process` | 4 | Process management |
| `doctor` | 1 | Health diagnostics |
| `completions` | 4 | Shell completions |
### Example Commands
```bash
# Initialize
npx @claude-flow/cli@latest init --wizard
# Spawn agent
npx @claude-flow/cli@latest agent spawn -t coder --name my-coder
# Memory operations
npx @claude-flow/cli@latest memory store --key "pattern" --value "data" --namespace patterns
npx @claude-flow/cli@latest memory search --query "authentication"
# Diagnostics
npx @claude-flow/cli@latest doctor --fix
```
---
## Hooks System
### 27 Available Hooks
#### Core Hooks (6)
| Hook | Description |
|------|-------------|
| `pre-edit` | Context before file edits |
| `post-edit` | Record edit outcomes |
| `pre-command` | Risk assessment |
| `post-command` | Command metrics |
| `pre-task` | Task start + agent suggestions |
| `post-task` | Task completion learning |
#### Session Hooks (4)
| Hook | Description |
|------|-------------|
| `session-start` | Start/restore session |
| `session-end` | Persist state |
| `session-restore` | Restore previous |
| `notify` | Cross-agent notifications |
#### Intelligence Hooks (5)
| Hook | Description |
|------|-------------|
| `route` | Optimal agent routing |
| `explain` | Routing decisions |
| `pretrain` | Bootstrap intelligence |
| `build-agents` | Generate configs |
| `transfer` | Pattern transfer |
#### Coverage Hooks (3)
| Hook | Description |
|------|-------------|
| `coverage-route` | Coverage-based routing |
| `coverage-suggest` | Improvement suggestions |
| `coverage-gaps` | Gap analysis |
### 12 Background Workers
| Worker | Priority | Purpose |
|--------|----------|---------|
| `ultralearn` | normal | Deep knowledge |
| `optimize` | high | Performance |
| `consolidate` | low | Memory consolidation |
| `predict` | normal | Predictive preload |
| `audit` | critical | Security |
| `map` | normal | Codebase mapping |
| `preload` | low | Resource preload |
| `deepdive` | normal | Deep analysis |
| `document` | normal | Auto-docs |
| `refactor` | normal | Suggestions |
| `benchmark` | normal | Benchmarking |
| `testgaps` | normal | Coverage gaps |
---
## Memory & Intelligence
### RuVector Intelligence System
- **SONA**: Self-Optimizing Neural Architecture (<0.05ms)
- **MoE**: Mixture of Experts routing
- **HNSW**: 150x-12,500x faster search
- **EWC++**: Prevents catastrophic forgetting
- **Flash Attention**: 2.49x-7.47x speedup
- **Int8 Quantization**: 3.92x memory reduction
### 4-Step Intelligence Pipeline
1. **RETRIEVE** - HNSW pattern search
2. **JUDGE** - Success/failure verdicts
3. **DISTILL** - LoRA learning extraction
4. **CONSOLIDATE** - EWC++ preservation
### Self-Learning Memory (ADR-049)
| Component | Status | Description |
|-----------|--------|-------------|
| **LearningBridge** | ✅ Enabled | Connects insights to SONA/ReasoningBank neural pipeline |
| **MemoryGraph** | ✅ Enabled | PageRank knowledge graph + community detection |
| **AgentMemoryScope** | ✅ Enabled | 3-scope agent memory (project/local/user) |
**LearningBridge** - Insights trigger learning trajectories. Confidence evolves: +0.03 on access, -0.005/hour decay. Consolidation runs the JUDGE/DISTILL/CONSOLIDATE pipeline.
**MemoryGraph** - Builds a knowledge graph from entry references. PageRank identifies influential insights. Communities group related knowledge. Graph-aware ranking blends vector + structural scores.
**AgentMemoryScope** - Maps Claude Code 3-scope directories:
- `project`: `<gitRoot>/.claude/agent-memory/<agent>/`
- `local`: `<gitRoot>/.claude/agent-memory-local/<agent>/`
- `user`: `~/.claude/agent-memory/<agent>/`
High-confidence insights (>0.8) can transfer between agents.
### Memory Commands
```bash
# Store pattern
npx @claude-flow/cli@latest memory store --key "name" --value "data" --namespace patterns
# Semantic search
npx @claude-flow/cli@latest memory search --query "authentication"
# List entries
npx @claude-flow/cli@latest memory list --namespace patterns
# Initialize database
npx @claude-flow/cli@latest memory init --force
```
---
## Hive-Mind Consensus
### Queen Types
| Type | Role |
|------|------|
| Strategic Queen | Long-term planning |
| Tactical Queen | Execution coordination |
| Adaptive Queen | Dynamic optimization |
### Worker Types (8)
`researcher`, `coder`, `analyst`, `tester`, `architect`, `reviewer`, `optimizer`, `documenter`
### Consensus Mechanisms
| Mechanism | Fault Tolerance | Use Case |
|-----------|-----------------|----------|
| `byzantine` | f < n/3 faulty | Adversarial |
| `raft` | f < n/2 failed | Leader-based |
| `gossip` | Eventually consistent | Large scale |
| `crdt` | Conflict-free | Distributed |
| `quorum` | Configurable | Flexible |
### Hive-Mind Commands
```bash
# Initialize
npx @claude-flow/cli@latest hive-mind init --queen-type strategic
# Status
npx @claude-flow/cli@latest hive-mind status
# Spawn workers
npx @claude-flow/cli@latest hive-mind spawn --count 5 --type worker
# Consensus
npx @claude-flow/cli@latest hive-mind consensus --propose "task"
```
---
## Performance Targets
| Metric | Target | Status |
|--------|--------|--------|
| HNSW Search | 150x-12,500x faster | ✅ Implemented |
| Memory Reduction | 50-75% | ✅ Implemented (3.92x) |
| SONA Integration | Pattern learning | ✅ Implemented |
| Flash Attention | 2.49x-7.47x | 🔄 In Progress |
| MCP Response | <100ms | Achieved |
| CLI Startup | <500ms | Achieved |
| SONA Adaptation | <0.05ms | 🔄 In Progress |
| Graph Build (1k) | <200ms | 2.78ms (71.9x headroom) |
| PageRank (1k) | <100ms | 12.21ms (8.2x headroom) |
| Insight Recording | <5ms/each | 0.12ms (41x headroom) |
| Consolidation | <500ms | 0.26ms (1,955x headroom) |
| Knowledge Transfer | <100ms | 1.25ms (80x headroom) |
---
## Integration Ecosystem
### Integrated Packages
| Package | Version | Purpose |
|---------|---------|---------|
| agentic-flow | 3.0.0-alpha.1 | Core coordination + ReasoningBank + Router |
| agentdb | 3.0.0-alpha.10 | Vector database + 8 controllers |
| @ruvector/attention | 0.1.3 | Flash attention |
| @ruvector/sona | 0.1.5 | Neural learning |
### Optional Integrations
| Package | Command |
|---------|---------|
| ruv-swarm | `npx ruv-swarm mcp start` |
| flow-nexus | `npx flow-nexus@latest mcp start` |
| agentic-jujutsu | `npx agentic-jujutsu@latest` |
### MCP Server Setup
```bash
# Add Claude Flow MCP
claude mcp add claude-flow -- npx -y @claude-flow/cli@latest
# Optional servers
claude mcp add ruv-swarm -- npx -y ruv-swarm mcp start
claude mcp add flow-nexus -- npx -y flow-nexus@latest mcp start
```
---
## Quick Reference
### Essential Commands
```bash
# Setup
npx @claude-flow/cli@latest init --wizard
npx @claude-flow/cli@latest daemon start
npx @claude-flow/cli@latest doctor --fix
# Swarm
npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8
npx @claude-flow/cli@latest swarm status
# Agents
npx @claude-flow/cli@latest agent spawn -t coder
npx @claude-flow/cli@latest agent list
# Memory
npx @claude-flow/cli@latest memory search --query "patterns"
# Hooks
npx @claude-flow/cli@latest hooks pre-task --description "task"
npx @claude-flow/cli@latest hooks worker dispatch --trigger optimize
```
### File Structure
```
.claude-flow/
├── config.yaml # Runtime configuration
├── CAPABILITIES.md # This file
├── data/ # Memory storage
├── logs/ # Operation logs
├── sessions/ # Session state
├── hooks/ # Custom hooks
├── agents/ # Agent configs
└── workflows/ # Workflow templates
```
---
**Full Documentation**: https://github.com/ruvnet/claude-flow
**Issues**: https://github.com/ruvnet/claude-flow/issues

43
.claude-flow/config.yaml Normal file
View File

@ -0,0 +1,43 @@
# RuFlo V3 Runtime Configuration
# Generated: 2026-03-06T01:38:48.235Z
version: "3.0.0"
swarm:
topology: hierarchical-mesh
maxAgents: 15
autoScale: true
coordinationStrategy: consensus
memory:
backend: hybrid
enableHNSW: true
persistPath: .claude-flow/data
cacheSize: 100
# ADR-049: Self-Learning Memory
learningBridge:
enabled: true
sonaMode: balanced
confidenceDecayRate: 0.005
accessBoostAmount: 0.03
consolidationThreshold: 10
memoryGraph:
enabled: true
pageRankDamping: 0.85
maxNodes: 5000
similarityThreshold: 0.8
agentScopes:
enabled: true
defaultScope: project
neural:
enabled: true
modelPath: .claude-flow/neural
hooks:
enabled: true
autoExecute: true
mcp:
autoStart: false
port: 3000

View File

@ -0,0 +1,17 @@
{
"initialized": "2026-03-06T01:38:48.235Z",
"routing": {
"accuracy": 0,
"decisions": 0
},
"patterns": {
"shortTerm": 0,
"longTerm": 0,
"quality": 0
},
"sessions": {
"total": 0,
"current": null
},
"_note": "Intelligence grows as you use Claude Flow"
}

View File

@ -0,0 +1,18 @@
{
"timestamp": "2026-03-06T01:38:48.235Z",
"processes": {
"agentic_flow": 0,
"mcp_server": 0,
"estimated_agents": 0
},
"swarm": {
"active": false,
"agent_count": 0,
"coordination_active": false
},
"integration": {
"agentic_flow_active": false,
"mcp_active": false
},
"_initialized": true
}

View File

@ -0,0 +1,26 @@
{
"version": "3.0.0",
"initialized": "2026-03-06T01:38:48.235Z",
"domains": {
"completed": 0,
"total": 5,
"status": "INITIALIZING"
},
"ddd": {
"progress": 0,
"modules": 0,
"totalFiles": 0,
"totalLines": 0
},
"swarm": {
"activeAgents": 0,
"maxAgents": 15,
"topology": "hierarchical-mesh"
},
"learning": {
"status": "READY",
"patternsLearned": 0,
"sessionsCompleted": 0
},
"_note": "Metrics will update as you use Claude Flow. Run: npx @claude-flow/cli@latest daemon start"
}

View File

@ -0,0 +1,8 @@
{
"initialized": "2026-03-06T01:38:48.236Z",
"status": "PENDING",
"cvesFixed": 0,
"totalCves": 3,
"lastScan": null,
"_note": "Run: npx @claude-flow/cli@latest security scan"
}

22
.mcp.json Normal file
View File

@ -0,0 +1,22 @@
{
"mcpServers": {
"claude-flow": {
"command": "npx",
"args": [
"-y",
"@claude-flow/cli@latest",
"mcp",
"start"
],
"env": {
"npm_config_update_notifier": "false",
"CLAUDE_FLOW_MODE": "v3",
"CLAUDE_FLOW_HOOKS_ENABLED": "true",
"CLAUDE_FLOW_TOPOLOGY": "hierarchical-mesh",
"CLAUDE_FLOW_MAX_AGENTS": "15",
"CLAUDE_FLOW_MEMORY_BACKEND": "hybrid"
},
"autoStart": false
}
}
}

188
CLAUDE.md Normal file
View File

@ -0,0 +1,188 @@
# Claude Code Configuration - RuFlo V3
## Behavioral Rules (Always Enforced)
- Do what has been asked; nothing more, nothing less
- NEVER create files unless they're absolutely necessary for achieving your goal
- ALWAYS prefer editing an existing file to creating a new one
- NEVER proactively create documentation files (*.md) or README files unless explicitly requested
- NEVER save working files, text/mds, or tests to the root folder
- Never continuously check status after spawning a swarm — wait for results
- ALWAYS read a file before editing it
- NEVER commit secrets, credentials, or .env files
## File Organization
- NEVER save to root folder — use the directories below
- Use `/src` for source code files
- Use `/tests` for test files
- Use `/docs` for documentation and markdown files
- Use `/config` for configuration files
- Use `/scripts` for utility scripts
- Use `/examples` for example code
## Project Architecture
- Follow Domain-Driven Design with bounded contexts
- Keep files under 500 lines
- Use typed interfaces for all public APIs
- Prefer TDD London School (mock-first) for new code
- Use event sourcing for state changes
- Ensure input validation at system boundaries
### Project Config
- **Topology**: hierarchical-mesh
- **Max Agents**: 15
- **Memory**: hybrid
- **HNSW**: Enabled
- **Neural**: Enabled
## Build & Test
```bash
# Build
npm run build
# Test
npm test
# Lint
npm run lint
```
- ALWAYS run tests after making code changes
- ALWAYS verify build succeeds before committing
## Security Rules
- NEVER hardcode API keys, secrets, or credentials in source files
- NEVER commit .env files or any file containing secrets
- Always validate user input at system boundaries
- Always sanitize file paths to prevent directory traversal
- Run `npx @claude-flow/cli@latest security scan` after security-related changes
## Concurrency: 1 MESSAGE = ALL RELATED OPERATIONS
- All operations MUST be concurrent/parallel in a single message
- Use Claude Code's Task tool for spawning agents, not just MCP
- ALWAYS batch ALL todos in ONE TodoWrite call (5-10+ minimum)
- ALWAYS spawn ALL agents in ONE message with full instructions via Task tool
- ALWAYS batch ALL file reads/writes/edits in ONE message
- ALWAYS batch ALL Bash commands in ONE message
## Swarm Orchestration
- MUST initialize the swarm using CLI tools when starting complex tasks
- MUST spawn concurrent agents using Claude Code's Task tool
- Never use CLI tools alone for execution — Task tool agents do the actual work
- MUST call CLI tools AND Task tool in ONE message for complex work
### 3-Tier Model Routing (ADR-026)
| Tier | Handler | Latency | Cost | Use Cases |
|------|---------|---------|------|-----------|
| **1** | Agent Booster (WASM) | <1ms | $0 | Simple transforms (varconst, add types) Skip LLM |
| **2** | Haiku | ~500ms | $0.0002 | Simple tasks, low complexity (<30%) |
| **3** | Sonnet/Opus | 2-5s | $0.003-0.015 | Complex reasoning, architecture, security (>30%) |
- Always check for `[AGENT_BOOSTER_AVAILABLE]` or `[TASK_MODEL_RECOMMENDATION]` before spawning agents
- Use Edit tool directly when `[AGENT_BOOSTER_AVAILABLE]`
## Swarm Configuration & Anti-Drift
- ALWAYS use hierarchical topology for coding swarms
- Keep maxAgents at 6-8 for tight coordination
- Use specialized strategy for clear role boundaries
- Use `raft` consensus for hive-mind (leader maintains authoritative state)
- Run frequent checkpoints via `post-task` hooks
- Keep shared memory namespace for all agents
```bash
npx @claude-flow/cli@latest swarm init --topology hierarchical --max-agents 8 --strategy specialized
```
## Swarm Execution Rules
- ALWAYS use `run_in_background: true` for all agent Task calls
- ALWAYS put ALL agent Task calls in ONE message for parallel execution
- After spawning, STOP — do NOT add more tool calls or check status
- Never poll TaskOutput or check swarm status — trust agents to return
- When agent results arrive, review ALL results before proceeding
## V3 CLI Commands
### Core Commands
| Command | Subcommands | Description |
|---------|-------------|-------------|
| `init` | 4 | Project initialization |
| `agent` | 8 | Agent lifecycle management |
| `swarm` | 6 | Multi-agent swarm coordination |
| `memory` | 11 | AgentDB memory with HNSW search |
| `task` | 6 | Task creation and lifecycle |
| `session` | 7 | Session state management |
| `hooks` | 17 | Self-learning hooks + 12 workers |
| `hive-mind` | 6 | Byzantine fault-tolerant consensus |
### Quick CLI Examples
```bash
npx @claude-flow/cli@latest init --wizard
npx @claude-flow/cli@latest agent spawn -t coder --name my-coder
npx @claude-flow/cli@latest swarm init --v3-mode
npx @claude-flow/cli@latest memory search --query "authentication patterns"
npx @claude-flow/cli@latest doctor --fix
```
## Available Agents (60+ Types)
### Core Development
`coder`, `reviewer`, `tester`, `planner`, `researcher`
### Specialized
`security-architect`, `security-auditor`, `memory-specialist`, `performance-engineer`
### Swarm Coordination
`hierarchical-coordinator`, `mesh-coordinator`, `adaptive-coordinator`
### GitHub & Repository
`pr-manager`, `code-review-swarm`, `issue-tracker`, `release-manager`
### SPARC Methodology
`sparc-coord`, `sparc-coder`, `specification`, `pseudocode`, `architecture`
## Memory Commands Reference
```bash
# Store (REQUIRED: --key, --value; OPTIONAL: --namespace, --ttl, --tags)
npx @claude-flow/cli@latest memory store --key "pattern-auth" --value "JWT with refresh" --namespace patterns
# Search (REQUIRED: --query; OPTIONAL: --namespace, --limit, --threshold)
npx @claude-flow/cli@latest memory search --query "authentication patterns"
# List (OPTIONAL: --namespace, --limit)
npx @claude-flow/cli@latest memory list --namespace patterns --limit 10
# Retrieve (REQUIRED: --key; OPTIONAL: --namespace)
npx @claude-flow/cli@latest memory retrieve --key "pattern-auth" --namespace patterns
```
## Quick Setup
```bash
claude mcp add claude-flow -- npx -y @claude-flow/cli@latest
npx @claude-flow/cli@latest daemon start
npx @claude-flow/cli@latest doctor --fix
```
## Claude Code vs CLI Tools
- Claude Code's Task tool handles ALL execution: agents, file ops, code generation, git
- CLI tools handle coordination via Bash: swarm init, memory, hooks, routing
- NEVER use CLI tools as a substitute for Task tool agents
## Support
- Documentation: https://github.com/ruvnet/claude-flow
- Issues: https://github.com/ruvnet/claude-flow/issues

View File

@ -93,3 +93,4 @@ service ppanel {
@handler GetRedemptionRecordList
get /record/list (GetRedemptionRecordListRequest) returns (GetRedemptionRecordListResponse)
}

View File

@ -127,6 +127,14 @@ service ppanel {
@handler UpdateVerifyCodeConfig
put /verify_code_config (VerifyCodeConfig)
@doc "Get Signature Config"
@handler GetSignatureConfig
get /signature_config returns (SignatureConfig)
@doc "Update Signature Config"
@handler UpdateSignatureConfig
put /signature_config (SignatureConfig)
@doc "PreView Node Multiplier"
@handler PreViewNodeMultiplier
get /node_multiplier/preview returns (PreViewNodeMultiplierResponse)

View File

@ -366,3 +366,4 @@ service ppanel {
@handler DissolveFamily
put /family/dissolve (DissolveFamilyRequest)
}

View File

@ -24,6 +24,7 @@ type (
Invite InviteConfig `json:"invite"`
Currency Currency `json:"currency"`
Subscribe SubscribeConfig `json:"subscribe"`
Signature SignatureConfig `json:"signature"`
VerifyCode PubilcVerifyCodeConfig `json:"verify_code"`
OAuthMethods []string `json:"oauth_methods"`
WebAd bool `json:"web_ad"`

View File

@ -30,3 +30,4 @@ service ppanel {
@handler RedeemCode
post / (RedeemCodeRequest) returns (RedeemCodeResponse)
}

View File

@ -14,11 +14,9 @@ type (
QuerySubscribeListRequest {
Language string `form:"language"`
}
QueryUserSubscribeNodeListResponse {
List []UserSubscribeInfo `json:"list"`
}
UserSubscribeInfo {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
@ -41,7 +39,6 @@ type (
IsTryOut bool `json:"is_try_out"`
Nodes []*UserSubscribeNodeInfo `json:"nodes"`
}
UserSubscribeNodeInfo {
Id int64 `json:"id"`
Name string `json:"name"`
@ -75,3 +72,4 @@ service ppanel {
@handler QueryUserSubscribeNodeList
get /node/list returns (QueryUserSubscribeNodeListResponse)
}

View File

@ -66,7 +66,6 @@ type (
UnbindOAuthRequest {
Method string `json:"method"`
}
GetLoginLogRequest {
Page int `form:"page"`
Size int `form:"size"`
@ -95,21 +94,17 @@ type (
Email string `json:"email" validate:"required"`
Code string `json:"code" validate:"required"`
}
GetDeviceListResponse {
List []UserDevice `json:"list"`
Total int64 `json:"total"`
}
UnbindDeviceRequest {
Id int64 `json:"id" validate:"required"`
}
UpdateUserSubscribeNoteRequest {
UserSubscribeId int64 `json:"user_subscribe_id" validate:"required"`
Note string `json:"note" validate:"max=500"`
}
UpdateUserRulesRequest {
Rules []string `json:"rules" validate:"required"`
}
@ -135,13 +130,10 @@ type (
List []WithdrawalLog `json:"list"`
Total int64 `json:"total"`
}
GetDeviceOnlineStatsResponse {
WeeklyStats []WeeklyStat `json:"weekly_stats"`
ConnectionRecords ConnectionRecords `json:"connection_records"`
}
WeeklyStat {
Day int `json:"day"`
DayName string `json:"day_name"`
@ -279,16 +271,16 @@ service ppanel {
@doc "Delete Current User Account"
@handler DeleteCurrentUserAccount
delete /current_user_account
}
@server (
prefix: v1/public/user
group: public/user/ws
middleware: AuthMiddleware
)
service ppanel {
@doc "Webosocket Device Connect"
@handler DeviceWsConnect
get /device_ws_connect
}

View File

@ -84,6 +84,9 @@ type (
VerifyCodeLimit int64 `json:"verify_code_limit"`
VerifyCodeInterval int64 `json:"verify_code_interval"`
}
SignatureConfig {
EnableSignature bool `json:"enable_signature"`
}
PubilcVerifyCodeConfig {
VerifyCodeInterval int64 `json:"verify_code_interval"`
}
@ -737,7 +740,6 @@ type (
List []SubscribeGroup `json:"list"`
Total int64 `json:"total"`
}
GetUserSubscribeTrafficLogsRequest {
Page int `form:"page"`
Size int `form:"size"`
@ -915,3 +917,4 @@ type (
UserSubscribeId int64 `json:"user_subscribe_id"`
}
)

View File

@ -40,6 +40,21 @@ Redis:
ReadTimeout: 3 # 读操作超时时间建议2-3秒
WriteTimeout: 3 # 写操作超时时间建议2-3秒
AppSignature:
AppSecrets:
android-client: uB4G,XxL2{7b # Android 客户端签名密钥
ios-client: uB4G,XxL2{7b # iOS 客户端签名密钥
web-client: uB4G,XxL2{7b # Web 客户端签名密钥
ValidWindowSeconds: 300 # 签名时间窗口(秒)
SkipPrefixes:
- /v1/notify/ # 支付回调不验签
- /v1/iap/notifications # Apple IAP 回调不验签
- /v1/telegram/webhook # Telegram 回调不验签
- /v1/subscribe/config # 订阅导出不验签
Signature:
EnableSignature: false # 系统签名开关(实际运行会以数据库 system.signature.EnableSignature 为准)
Administrator:
Email: admin@ppanel.dev # 后台登录邮箱,请修改
Password: CHANGE_ME_TO_STRONG_PASSWORD # 后台登录密码,请修改为强密码

View File

@ -13,6 +13,7 @@ func StartInitSystemConfig(svc *svc.ServiceContext) {
Invite(svc)
Verify(svc)
Subscribe(svc)
Signature(svc)
Register(svc)
Mobile(svc)
Currency(svc)

View File

@ -0,0 +1,4 @@
DELETE
FROM `system`
WHERE `category` = 'signature'
AND `key` = 'EnableSignature';

View File

@ -0,0 +1,14 @@
INSERT INTO `system` (`category`, `key`, `value`, `type`, `desc`, `created_at`, `updated_at`)
SELECT 'signature',
'EnableSignature',
'false',
'bool',
'Enable signature verification for public APIs',
NOW(3),
NOW(3)
WHERE NOT EXISTS (
SELECT 1
FROM `system`
WHERE `category` = 'signature'
AND `key` = 'EnableSignature'
);

22
initialize/signature.go Normal file
View File

@ -0,0 +1,22 @@
package initialize
import (
"context"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
)
func Signature(svc *svc.ServiceContext) {
logger.Debug("Signature config initialization")
configs, err := svc.SystemModel.GetSignatureConfig(context.Background())
if err != nil {
logger.Error("[Init Signature Config] Get Signature Config Error: ", logger.Field("error", err.Error()))
return
}
var signatureConfig config.Signature
tool.SystemConfigSliceReflectToStruct(configs, &signatureConfig)
svc.Config.Signature = signatureConfig
}

View File

@ -36,6 +36,9 @@ const TosConfigKey = "system:tos_config"
// VerifyCodeConfigKey Verify Code Config Key
const VerifyCodeConfigKey = "system:verify_code_config"
// SignatureConfigKey Signature Config Key
const SignatureConfigKey = "system:signature_config"
// SessionIdKey cache session key
const SessionIdKey = "auth:session_id"

View File

@ -5,6 +5,7 @@ import (
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/orm"
"github.com/perfect-panel/server/pkg/signature"
"github.com/perfect-panel/server/pkg/trace"
)
@ -23,6 +24,8 @@ type Config struct {
Mobile MobileConfig `yaml:"Mobile"`
Email EmailConfig `yaml:"Email"`
Device DeviceConfig `yaml:"device"`
AppSignature signature.SignatureConf `yaml:"AppSignature"`
Signature Signature `yaml:"Signature"`
Verify Verify `yaml:"Verify"`
VerifyCode VerifyCode `yaml:"VerifyCode"`
Register RegisterConfig `yaml:"Register"`
@ -122,6 +125,10 @@ type DeviceConfig struct {
SecuritySecret string `yaml:"security_secret"`
}
type Signature struct {
EnableSignature bool `yaml:"EnableSignature" default:"false"`
}
type SiteConfig struct {
Host string `yaml:"Host" default:""`
SiteName string `yaml:"SiteName" default:""`

View File

@ -0,0 +1,18 @@
package system
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/system"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/result"
)
// Get Signature Config
func GetSignatureConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
l := system.NewGetSignatureConfigLogic(c.Request.Context(), svcCtx)
resp, err := l.GetSignatureConfig()
result.HttpResult(c, resp, err)
}
}

View File

@ -0,0 +1,26 @@
package system
import (
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/logic/admin/system"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/result"
)
// Update Signature Config
func UpdateSignatureConfigHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
return func(c *gin.Context) {
var req types.SignatureConfig
_ = c.ShouldBind(&req)
validateErr := svcCtx.Validate(&req)
if validateErr != nil {
result.ParamErrorResult(c, validateErr)
return
}
l := system.NewUpdateSignatureConfigLogic(c.Request.Context(), svcCtx)
err := l.UpdateSignatureConfig(&req)
result.HttpResult(c, nil, err)
}
}

View File

@ -28,7 +28,6 @@ import (
common "github.com/perfect-panel/server/internal/handler/common"
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
publicDocument "github.com/perfect-panel/server/internal/handler/public/document"
publicIapApple "github.com/perfect-panel/server/internal/handler/public/iap/apple"
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
@ -237,12 +236,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Filter traffic log details
adminLogGroupRouter.GET("/traffic/details", adminLog.FilterTrafficLogDetailsHandler(serverCtx))
// Error message log list
adminLogGroupRouter.GET("/message/error/list", adminLog.GetErrorLogMessageListHandler(serverCtx))
// Error message log detail
adminLogGroupRouter.GET("/message/error/detail", adminLog.GetErrorLogMessageDetailHandler(serverCtx))
}
adminMarketingGroupRouter := router.Group("/v1/admin/marketing")
@ -471,6 +464,12 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// setting telegram bot
adminSystemGroupRouter.POST("/setting_telegram_bot", adminSystem.SettingTelegramBotHandler(serverCtx))
// Get Signature Config
adminSystemGroupRouter.GET("/signature_config", adminSystem.GetSignatureConfigHandler(serverCtx))
// Update Signature Config
adminSystemGroupRouter.PUT("/signature_config", adminSystem.UpdateSignatureConfigHandler(serverCtx))
// Get site config
adminSystemGroupRouter.GET("/site_config", adminSystem.GetSiteConfigHandler(serverCtx))
@ -579,6 +578,21 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// kick offline user device
adminUserGroupRouter.PUT("/device/kick_offline", adminUser.KickOfflineByUserDeviceHandler(serverCtx))
// Get family detail
adminUserGroupRouter.GET("/family/detail", adminUser.GetFamilyDetailHandler(serverCtx))
// Dissolve family
adminUserGroupRouter.PUT("/family/dissolve", adminUser.DissolveFamilyHandler(serverCtx))
// Get family list
adminUserGroupRouter.GET("/family/list", adminUser.GetFamilyListHandler(serverCtx))
// Update family max members
adminUserGroupRouter.PUT("/family/max_members", adminUser.UpdateFamilyMaxMembersHandler(serverCtx))
// Remove family member
adminUserGroupRouter.PUT("/family/member/remove", adminUser.RemoveFamilyMemberHandler(serverCtx))
// Get user list
adminUserGroupRouter.GET("/list", adminUser.GetUserListHandler(serverCtx))
@ -623,21 +637,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get user subcribe traffic logs
adminUserGroupRouter.GET("/subscribe/traffic_logs", adminUser.GetUserSubscribeTrafficLogsHandler(serverCtx))
// Get family list
adminUserGroupRouter.GET("/family/list", adminUser.GetFamilyListHandler(serverCtx))
// Get family detail
adminUserGroupRouter.GET("/family/detail", adminUser.GetFamilyDetailHandler(serverCtx))
// Update family max members
adminUserGroupRouter.PUT("/family/max_members", adminUser.UpdateFamilyMaxMembersHandler(serverCtx))
// Remove family member
adminUserGroupRouter.PUT("/family/member/remove", adminUser.RemoveFamilyMemberHandler(serverCtx))
// Dissolve family
adminUserGroupRouter.PUT("/family/dissolve", adminUser.DissolveFamilyHandler(serverCtx))
}
authGroupRouter := router.Group("/v1/auth")
@ -650,18 +649,9 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Check user telephone is exist
authGroupRouter.GET("/check/telephone", auth.CheckUserTelephoneHandler(serverCtx))
// Check legacy verification code
authGroupRouter.POST("/check-code", middleware.ApiVersionSwitchHandler(
auth.CheckCodeLegacyV1Handler(serverCtx),
auth.CheckCodeLegacyV2Handler(serverCtx),
))
// User login
authGroupRouter.POST("/login", auth.UserLoginHandler(serverCtx))
// Email login
authGroupRouter.POST("/login/email", auth.EmailLoginHandler(serverCtx))
// Device Login
authGroupRouter.POST("/login/device", auth.DeviceLoginHandler(serverCtx))
@ -702,17 +692,11 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
commonGroupRouter.GET("/ads", common.GetAdsHandler(serverCtx))
// Check verification code
commonGroupRouter.POST("/check_verification_code", middleware.ApiVersionSwitchHandler(
common.CheckVerificationCodeV1Handler(serverCtx),
common.CheckVerificationCodeV2Handler(serverCtx),
))
commonGroupRouter.POST("/check_verification_code", common.CheckVerificationCodeHandler(serverCtx))
// Get Client
commonGroupRouter.GET("/client", common.GetClientHandler(serverCtx))
// Get Download Link
commonGroupRouter.GET("/client/download", common.GetDownloadLinkHandler(serverCtx))
// Heartbeat
commonGroupRouter.GET("/heartbeat", common.HeartbeatHandler(serverCtx))
@ -733,12 +717,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Get Tos Content
commonGroupRouter.GET("/site/tos", common.GetTosHandler(serverCtx))
// Submit contact info
commonGroupRouter.POST("/contact", common.SubmitContactHandler(serverCtx))
// Report client error log
commonGroupRouter.POST("/log/message/report", common.ReportLogMessageHandler(serverCtx))
}
publicAnnouncementGroupRouter := router.Group("/v1/public/announcement")
@ -828,15 +806,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
publicRedemptionGroupRouter.POST("/", publicRedemption.RedeemCodeHandler(serverCtx))
}
iapAppleGroupRouter := router.Group("/v1/public/iap/apple")
iapAppleGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
{
iapAppleGroupRouter.GET("/status", publicIapApple.GetAppleStatusHandler(serverCtx))
iapAppleGroupRouter.POST("/transactions/attach", publicIapApple.AttachAppleTransactionHandler(serverCtx))
iapAppleGroupRouter.POST("/transactions/attach_by_id", publicIapApple.AttachAppleTransactionByIdHandler(serverCtx))
iapAppleGroupRouter.POST("/restore", publicIapApple.RestoreAppleTransactionsHandler(serverCtx))
}
publicSubscribeGroupRouter := router.Group("/v1/public/subscribe")
publicSubscribeGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
@ -961,30 +930,6 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
// Query Withdrawal Log
publicUserGroupRouter.GET("/withdrawal_log", publicUser.QueryWithdrawalLogHandler(serverCtx))
// Bind Email With Verification
publicUserGroupRouter.POST("/bind_email_with_verification", publicUser.BindEmailWithVerificationHandler(serverCtx))
// Bind Invite Code
publicUserGroupRouter.POST("/bind_invite_code", publicUser.BindInviteCodeHandler(serverCtx))
// Get Subscribe Status
publicUserGroupRouter.POST("/subscribe_status", publicUser.GetSubscribeStatusHandler(serverCtx))
// Delete Account
publicUserGroupRouter.POST("/delete_account", publicUser.DeleteAccountHandler(serverCtx))
// Get agent realtime data
publicUserGroupRouter.GET("/agent/realtime", publicUser.GetAgentRealtimeHandler(serverCtx))
// Get agent downloads data
publicUserGroupRouter.GET("/agent/downloads", publicUser.GetAgentDownloadsHandler(serverCtx))
// Get user invite statistics
publicUserGroupRouter.GET("/invite/stats", publicUser.GetUserInviteStatsHandler(serverCtx))
// Get invite sales data
publicUserGroupRouter.GET("/invite/sales", publicUser.GetInviteSalesHandler(serverCtx))
}
publicUserWsGroupRouter := router.Group("/v1/public/user")
@ -1015,10 +960,10 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
serverGroupRouter.GET("/user", server.GetServerUserListHandler(serverCtx))
}
serverV2GroupRouter := router.Group("/v2/server")
serverGroupRouter := router.Group("/v2/server")
{
// Get Server Protocol Config
serverV2GroupRouter.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
serverGroupRouter.GET("/:server_id", server.QueryServerProtocolConfigHandler(serverCtx))
}
}

View File

@ -0,0 +1,38 @@
package system
import (
"context"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
)
type GetSignatureConfigLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Get Signature Config
func NewGetSignatureConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *GetSignatureConfigLogic {
return &GetSignatureConfigLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *GetSignatureConfigLogic) GetSignatureConfig() (resp *types.SignatureConfig, err error) {
resp = &types.SignatureConfig{}
configs, err := l.svcCtx.SystemModel.GetSignatureConfig(l.ctx)
if err != nil {
l.Errorw("[GetSignatureConfig] Database query error", logger.Field("error", err.Error()))
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "get signature config error: %v", err.Error())
}
tool.SystemConfigSliceReflectToStruct(configs, resp)
return resp, nil
}

View File

@ -0,0 +1,53 @@
package system
import (
"context"
"reflect"
"github.com/perfect-panel/server/initialize"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/model/system"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/internal/types"
"github.com/perfect-panel/server/pkg/logger"
"github.com/perfect-panel/server/pkg/tool"
"github.com/perfect-panel/server/pkg/xerr"
"github.com/pkg/errors"
"gorm.io/gorm"
)
type UpdateSignatureConfigLogic struct {
logger.Logger
ctx context.Context
svcCtx *svc.ServiceContext
}
// Update Signature Config
func NewUpdateSignatureConfigLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateSignatureConfigLogic {
return &UpdateSignatureConfigLogic{
Logger: logger.WithContext(ctx),
ctx: ctx,
svcCtx: svcCtx,
}
}
func (l *UpdateSignatureConfigLogic) UpdateSignatureConfig(req *types.SignatureConfig) error {
v := reflect.ValueOf(*req)
t := v.Type()
err := l.svcCtx.SystemModel.Transaction(l.ctx, func(db *gorm.DB) error {
for i := 0; i < v.NumField(); i++ {
fieldName := t.Field(i).Name
fieldValue := tool.ConvertValueToString(v.Field(i))
if err := db.Model(&system.System{}).Where("`category` = 'signature' and `key` = ?", fieldName).Update("value", fieldValue).Error; err != nil {
return err
}
}
return l.svcCtx.Redis.Del(l.ctx, config.SignatureConfigKey, config.GlobalConfigKey).Err()
})
if err != nil {
l.Errorw("[UpdateSignatureConfig] update signature config error", logger.Field("error", err.Error()))
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update signature config error: %v", err.Error())
}
initialize.Signature(l.svcCtx)
return nil
}

View File

@ -47,6 +47,7 @@ func (l *GetGlobalConfigLogic) GetGlobalConfig() (resp *types.GetGlobalConfigRes
tool.DeepCopy(&resp.Auth.Email, l.svcCtx.Config.Email)
tool.DeepCopy(&resp.Auth.Mobile, l.svcCtx.Config.Mobile)
tool.DeepCopy(&resp.Auth.Register, l.svcCtx.Config.Register)
tool.DeepCopy(&resp.Signature, l.svcCtx.Config.Signature)
tool.DeepCopy(&resp.Verify, l.svcCtx.Config.Verify)
tool.DeepCopy(&resp.Invite, l.svcCtx.Config.Invite)
tool.SystemConfigSliceReflectToStruct(currencyCfg, &resp.Currency)

View File

@ -113,16 +113,17 @@ func (l *QueryUserSubscribeNodeListLogic) getServers(userSub *user.Subscribe) (u
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe details error: %v", err.Error())
}
nodeIds := tool.StringToInt64Slice(subDetails.Nodes)
tags := strings.Split(subDetails.NodeTags, ",")
tags := normalizeSubscribeNodeTags(subDetails.NodeTags)
l.Debugf("[Generate Subscribe]nodes: %v, NodeTags: %v", nodeIds, tags)
enable := true
_, nodes, err := l.svcCtx.NodeModel.FilterNodeList(l.ctx, &node.FilterNodeParams{
Page: 0,
Page: 1,
Size: 1000,
NodeId: nodeIds,
Tag: tags,
Enabled: &enable, // Only get enabled nodes
})
@ -213,3 +214,21 @@ func (l *QueryUserSubscribeNodeListLogic) getUserSubscribe(token string) (*user.
return userSub, nil
}
func normalizeSubscribeNodeTags(raw string) []string {
if raw == "" {
return nil
}
parts := strings.Split(raw, ",")
cleaned := make([]string, 0, len(parts))
for _, tag := range parts {
trimmed := strings.TrimSpace(tag)
if trimmed == "" {
continue
}
cleaned = append(cleaned, trimmed)
}
return tool.RemoveDuplicateElements(cleaned...)
}

View File

@ -23,3 +23,11 @@ func TestFillUserSubscribeInfoEntitlementFields(t *testing.T) {
require.Equal(t, int64(3001), sub.EntitlementOwnerUserId)
require.True(t, sub.ReadOnly)
}
func TestNormalizeSubscribeNodeTags(t *testing.T) {
tags := normalizeSubscribeNodeTags("美国, 日本, , 美国, ,日本")
require.Equal(t, []string{"美国", "日本"}, tags)
empty := normalizeSubscribeNodeTags("")
require.Nil(t, empty)
}

View File

@ -15,7 +15,10 @@ func CorsMiddleware(c *gin.Context) {
}
// c.Writer.Header().Set("Access-Control-Allow-Origin", c.Request.Host)
c.Writer.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS, PUT, DELETE, UPDATE")
c.Writer.Header().Set("Access-Control-Allow-Headers", "Content-Type, Origin, X-CSRF-Token, Authorization, AccessToken, Token, Range, api-header")
c.Writer.Header().Set(
"Access-Control-Allow-Headers",
"Content-Type, Origin, X-CSRF-Token, Authorization, AccessToken, Token, Range, api-header, X-Signature-Enabled, X-App-Id, X-Timestamp, X-Nonce, X-Signature",
)
c.Writer.Header().Set("Access-Control-Expose-Headers", "Content-Length, Access-Control-Allow-Origin, Access-Control-Allow-Headers")
c.Writer.Header().Set("Access-Control-Allow-Credentials", "true")
c.Writer.Header().Set("Access-Control-Max-Age", "172800")

View File

@ -0,0 +1,137 @@
package middleware
import (
"bytes"
"io"
"strings"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/result"
"github.com/perfect-panel/server/pkg/signature"
"github.com/perfect-panel/server/pkg/xerr"
)
var (
publicSignaturePrefixes = []string{
"/v1/auth",
"/v1/auth/oauth",
"/v1/common",
"/v1/public",
}
defaultSignatureSkipPrefixes = []string{
"/v1/notify/",
"/v1/iap/notifications",
"/v1/telegram/webhook",
"/v1/subscribe/config",
}
)
func SignatureMiddleware(srvCtx *svc.ServiceContext) func(c *gin.Context) {
skipPrefixes := collectSignatureSkipPrefixes(srvCtx)
return func(c *gin.Context) {
path := c.Request.URL.Path
if !isPublicSignaturePath(path) {
c.Next()
return
}
for _, prefix := range skipPrefixes {
if strings.HasPrefix(path, prefix) {
c.Next()
return
}
}
if !srvCtx.Config.Signature.EnableSignature {
c.Next()
return
}
if c.GetHeader("X-Signature-Enabled") != "1" {
c.Next()
return
}
appId := c.GetHeader("X-App-Id")
if appId == "" {
result.HttpResult(c, nil, xerr.NewErrCode(xerr.InvalidAccess))
c.Abort()
return
}
timestamp := c.GetHeader("X-Timestamp")
nonce := c.GetHeader("X-Nonce")
sig := c.GetHeader("X-Signature")
if timestamp == "" || nonce == "" || sig == "" {
result.HttpResult(c, nil, xerr.NewErrCode(xerr.SignatureMissing))
c.Abort()
return
}
var bodyBytes []byte
if c.Request.Body != nil {
bodyBytes, _ = io.ReadAll(c.Request.Body)
c.Request.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
}
sts := signature.BuildStringToSign(
c.Request.Method,
path,
c.Request.URL.RawQuery,
bodyBytes,
appId,
timestamp,
nonce,
)
if err := srvCtx.SignatureValidator.Validate(c.Request.Context(), appId, timestamp, nonce, sig, sts); err != nil {
result.HttpResult(c, nil, xerr.NewErrCode(mapSignatureErr(err)))
c.Abort()
return
}
c.Next()
}
}
func isPublicSignaturePath(path string) bool {
for _, prefix := range publicSignaturePrefixes {
if strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func collectSignatureSkipPrefixes(srvCtx *svc.ServiceContext) []string {
prefixSet := make(map[string]struct{}, len(defaultSignatureSkipPrefixes)+len(srvCtx.Config.AppSignature.SkipPrefixes)+1)
for _, prefix := range defaultSignatureSkipPrefixes {
prefixSet[prefix] = struct{}{}
}
for _, prefix := range srvCtx.Config.AppSignature.SkipPrefixes {
if strings.TrimSpace(prefix) == "" {
continue
}
prefixSet[prefix] = struct{}{}
}
if path := strings.TrimSpace(srvCtx.Config.Subscribe.SubscribePath); path != "" {
prefixSet[path] = struct{}{}
}
prefixes := make([]string, 0, len(prefixSet))
for prefix := range prefixSet {
prefixes = append(prefixes, prefix)
}
return prefixes
}
func mapSignatureErr(err error) uint32 {
switch err {
case signature.ErrSignatureMissing:
return xerr.SignatureMissing
case signature.ErrSignatureExpired:
return xerr.SignatureExpired
case signature.ErrSignatureReplay:
return xerr.SignatureReplay
default:
return xerr.SignatureInvalid
}
}

View File

@ -0,0 +1,239 @@
package middleware
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/perfect-panel/server/internal/config"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/signature"
"github.com/perfect-panel/server/pkg/xerr"
)
type testNonceStore struct {
seen map[string]bool
}
func newTestNonceStore() *testNonceStore {
return &testNonceStore{seen: map[string]bool{}}
}
func (s *testNonceStore) SetIfNotExists(_ context.Context, appId, nonce string, _ int64) (bool, error) {
key := appId + ":" + nonce
if s.seen[key] {
return true, nil
}
s.seen[key] = true
return false, nil
}
func makeTestSignature(secret, sts string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(sts))
return hex.EncodeToString(mac.Sum(nil))
}
func newTestServiceContext() *svc.ServiceContext {
conf := config.Config{}
conf.Signature.EnableSignature = true
conf.AppSignature = signature.SignatureConf{
AppSecrets: map[string]string{
"web-client": "test-secret",
},
ValidWindowSeconds: 300,
SkipPrefixes: []string{
"/v1/public/health",
},
}
return &svc.ServiceContext{
Config: conf,
SignatureValidator: signature.NewValidator(conf.AppSignature, newTestNonceStore()),
}
}
func newTestServiceContextWithSwitch(enabled bool) *svc.ServiceContext {
svcCtx := newTestServiceContext()
svcCtx.Config.Signature.EnableSignature = enabled
return svcCtx
}
func decodeCode(t *testing.T, body []byte) uint32 {
t.Helper()
var resp struct {
Code uint32 `json:"code"`
}
if err := json.Unmarshal(body, &resp); err != nil {
t.Fatalf("unmarshal response failed: %v", err)
}
return resp.Code
}
func TestSignatureMiddlewareMissingAppID(t *testing.T) {
gin.SetMode(gin.TestMode)
svcCtx := newTestServiceContext()
r := gin.New()
r.Use(SignatureMiddleware(svcCtx))
r.GET("/v1/public/ping", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
req.Header.Set("X-Signature-Enabled", "1")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
if code := decodeCode(t, resp.Body.Bytes()); code != xerr.InvalidAccess {
t.Fatalf("expected InvalidAccess(%d), got %d", xerr.InvalidAccess, code)
}
}
func TestSignatureMiddlewareMissingSignatureHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
svcCtx := newTestServiceContext()
r := gin.New()
r.Use(SignatureMiddleware(svcCtx))
r.GET("/v1/public/ping", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
req.Header.Set("X-Signature-Enabled", "1")
req.Header.Set("X-App-Id", "web-client")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
if code := decodeCode(t, resp.Body.Bytes()); code != xerr.SignatureMissing {
t.Fatalf("expected SignatureMissing(%d), got %d", xerr.SignatureMissing, code)
}
}
func TestSignatureMiddlewarePassesWhenSignatureHeaderMissing(t *testing.T) {
gin.SetMode(gin.TestMode)
svcCtx := newTestServiceContext()
r := gin.New()
r.Use(SignatureMiddleware(svcCtx))
r.GET("/v1/public/ping", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
t.Fatalf("expected pass-through without X-Signature-Enabled, got code=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestSignatureMiddlewarePassesWhenSignatureHeaderIsZero(t *testing.T) {
gin.SetMode(gin.TestMode)
svcCtx := newTestServiceContext()
r := gin.New()
r.Use(SignatureMiddleware(svcCtx))
r.GET("/v1/public/ping", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
req.Header.Set("X-Signature-Enabled", "0")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
t.Fatalf("expected pass-through when X-Signature-Enabled=0, got code=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestSignatureMiddlewarePassesWhenSystemSwitchDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
svcCtx := newTestServiceContextWithSwitch(false)
r := gin.New()
r.Use(SignatureMiddleware(svcCtx))
r.GET("/v1/public/ping", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/v1/public/ping", nil)
req.Header.Set("X-Signature-Enabled", "1")
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
t.Fatalf("expected pass-through when system switch is disabled, got code=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestSignatureMiddlewareSkipsNonPublicPath(t *testing.T) {
gin.SetMode(gin.TestMode)
svcCtx := newTestServiceContext()
r := gin.New()
r.Use(SignatureMiddleware(svcCtx))
r.GET("/v1/admin/ping", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/v1/admin/ping", nil)
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
t.Fatalf("expected pass-through for non-public path, got code=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestSignatureMiddlewareHonorsSkipPrefix(t *testing.T) {
gin.SetMode(gin.TestMode)
svcCtx := newTestServiceContext()
r := gin.New()
r.Use(SignatureMiddleware(svcCtx))
r.GET("/v1/public/healthz", func(c *gin.Context) {
c.String(http.StatusOK, "ok")
})
req := httptest.NewRequest(http.MethodGet, "/v1/public/healthz", nil)
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK || resp.Body.String() != "ok" {
t.Fatalf("expected skip-prefix pass-through, got code=%d body=%s", resp.Code, resp.Body.String())
}
}
func TestSignatureMiddlewareRestoresBodyAfterVerify(t *testing.T) {
gin.SetMode(gin.TestMode)
svcCtx := newTestServiceContext()
r := gin.New()
r.Use(SignatureMiddleware(svcCtx))
r.POST("/v1/public/body", func(c *gin.Context) {
body, _ := io.ReadAll(c.Request.Body)
c.String(http.StatusOK, string(body))
})
body := `{"hello":"world"}`
req := httptest.NewRequest(http.MethodPost, "/v1/public/body?a=1&b=2", strings.NewReader(body))
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := "nonce-body-1"
sts := signature.BuildStringToSign(http.MethodPost, "/v1/public/body", "a=1&b=2", []byte(body), "web-client", ts, nonce)
req.Header.Set("X-Signature-Enabled", "1")
req.Header.Set("X-App-Id", "web-client")
req.Header.Set("X-Timestamp", ts)
req.Header.Set("X-Nonce", nonce)
req.Header.Set("X-Signature", makeTestSignature("test-secret", sts))
resp := httptest.NewRecorder()
r.ServeHTTP(resp, req)
if resp.Code != http.StatusOK || resp.Body.String() != body {
t.Fatalf("expected restored body, got code=%d body=%s", resp.Code, resp.Body.String())
}
}

View File

@ -202,27 +202,36 @@ func (m *customServerModel) ClearServerAllCache(ctx context.Context) error {
// CountNodesByIdsAndTags 根据节点ID和标签计算启用的节点数量
func (m *customServerModel) CountNodesByIdsAndTags(ctx context.Context, nodeIds []int64, tags []string) (int64, error) {
var count int64
query := m.WithContext(ctx).Model(&Node{}).Where("enabled = ?", true)
if len(nodeIds) > 0 || len(tags) > 0 {
subQuery := m.WithContext(ctx).Model(&Node{}).Where("enabled = ?", true)
if len(nodeIds) > 0 && len(tags) > 0 {
subQuery = subQuery.Where("id IN ? OR ?", nodeIds, InSet("tags", tags))
} else if len(nodeIds) > 0 {
subQuery = subQuery.Where("id IN ?", nodeIds)
} else {
subQuery = subQuery.Scopes(InSet("tags", tags))
tags = normalizeNodeTags(tags)
if len(nodeIds) == 0 && len(tags) == 0 {
return 0, nil
}
query = subQuery
var count int64
query := m.WithContext(ctx).Model(&Node{}).Where("enabled = ?", true)
if len(nodeIds) > 0 {
query = query.Where("id IN ?", nodeIds)
}
if len(tags) > 0 {
query = query.Scopes(InSet("tags", tags))
}
err := query.Count(&count).Error
return count, err
}
func normalizeNodeTags(tags []string) []string {
cleaned := make([]string, 0, len(tags))
for _, tag := range tags {
trimmed := strings.TrimSpace(tag)
if trimmed == "" {
continue
}
cleaned = append(cleaned, trimmed)
}
return tool.RemoveDuplicateElements(cleaned...)
}
// InSet 支持多值 OR 查询
func InSet(field string, values []string) func(db *gorm.DB) *gorm.DB {
return func(db *gorm.DB) *gorm.DB {

View File

@ -0,0 +1,12 @@
package node
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeNodeTags(t *testing.T) {
tags := normalizeNodeTags([]string{"美国", " 日本 ", "", "美国", " ", "日本"})
require.Equal(t, []string{"美国", "日本"}, tags)
}

View File

@ -16,6 +16,7 @@ import (
var _ Model = (*customSubscribeModel)(nil)
var (
cacheSubscribeIdPrefix = "cache:subscribe:id:"
cacheUserSubscribeUserPrefix = "cache:user:subscribe:user:"
)
type (
@ -119,13 +120,31 @@ func (m *defaultSubscribeModel) Update(ctx context.Context, data *Subscribe, tx
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var userIds []int64
err = m.QueryNoCacheCtx(ctx, &userIds, func(conn *gorm.DB, v interface{}) error {
return conn.Table("user_subscribe").
Where("subscribe_id = ? AND status IN (0, 1)", data.Id).
Distinct("user_id").
Pluck("user_id", &userIds).Error
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
userSubscribeCacheKeys := make([]string, 0, len(userIds))
for _, userId := range userIds {
userSubscribeCacheKeys = append(userSubscribeCacheKeys, fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, userId))
}
allCacheKeys := append(m.getCacheKeys(old), userSubscribeCacheKeys...)
err = m.ExecCtx(ctx, func(conn *gorm.DB) error {
db := conn
if len(tx) > 0 {
db = tx[0]
}
return db.Save(data).Error
}, m.getCacheKeys(old)...)
}, allCacheKeys...)
return err
}
@ -137,13 +156,31 @@ func (m *defaultSubscribeModel) Delete(ctx context.Context, id int64, tx ...*gor
}
return err
}
var userIds []int64
err = m.QueryNoCacheCtx(ctx, &userIds, func(conn *gorm.DB, v interface{}) error {
return conn.Table("user_subscribe").
Where("subscribe_id = ? AND status IN (0, 1)", id).
Distinct("user_id").
Pluck("user_id", &userIds).Error
})
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
userSubscribeCacheKeys := make([]string, 0, len(userIds))
for _, userId := range userIds {
userSubscribeCacheKeys = append(userSubscribeCacheKeys, fmt.Sprintf("%s%d", cacheUserSubscribeUserPrefix, userId))
}
allCacheKeys := append(m.getCacheKeys(data), userSubscribeCacheKeys...)
err = m.ExecCtx(ctx, func(conn *gorm.DB) error {
db := conn
if len(tx) > 0 {
db = tx[0]
}
return db.Delete(&Subscribe{}, id).Error
}, m.getCacheKeys(data)...)
}, allCacheKeys...)
return err
}

View File

@ -19,6 +19,7 @@ type customSystemLogicModel interface {
GetTosConfig(ctx context.Context) ([]*System, error)
GetCurrencyConfig(ctx context.Context) ([]*System, error)
GetVerifyCodeConfig(ctx context.Context) ([]*System, error)
GetSignatureConfig(ctx context.Context) ([]*System, error)
GetLogConfig(ctx context.Context) ([]*System, error)
UpdateNodeMultiplierConfig(ctx context.Context, config string) error
FindNodeMultiplierConfig(ctx context.Context) (*System, error)
@ -154,6 +155,15 @@ func (m *customSystemModel) GetVerifyCodeConfig(ctx context.Context) ([]*System,
return configs, err
}
// GetSignatureConfig returns the signature config.
func (m *customSystemModel) GetSignatureConfig(ctx context.Context) ([]*System, error) {
var configs []*System
err := m.QueryCtx(ctx, &configs, config.SignatureConfigKey, func(conn *gorm.DB, v interface{}) error {
return conn.Where("`category` = ?", "signature").Find(v).Error
})
return configs, err
}
// GetLogConfig returns the log config.
func (m *customSystemModel) GetLogConfig(ctx context.Context) ([]*System, error) {
var configs []*System

View File

@ -50,7 +50,14 @@ func initServer(svc *svc.ServiceContext) *gin.Engine {
}
r.Use(sessions.Sessions("ppanel", sessionStore))
// use cors middleware
r.Use(middleware.TraceMiddleware(svc), middleware.ApiVersionMiddleware(svc), middleware.LoggerMiddleware(svc), middleware.CorsMiddleware, gin.Recovery())
r.Use(
middleware.TraceMiddleware(svc),
middleware.ApiVersionMiddleware(svc),
middleware.LoggerMiddleware(svc),
middleware.SignatureMiddleware(svc),
middleware.CorsMiddleware,
gin.Recovery(),
)
// register handlers
handler.RegisterHandlers(r, svc)

View File

@ -28,6 +28,7 @@ import (
"github.com/perfect-panel/server/pkg/limit"
"github.com/perfect-panel/server/pkg/nodeMultiplier"
"github.com/perfect-panel/server/pkg/orm"
"github.com/perfect-panel/server/pkg/signature"
tgbotapi "github.com/go-telegram-bot-api/telegram-bot-api/v5"
"github.com/hibiken/asynq"
@ -42,6 +43,7 @@ type ServiceContext struct {
Queue *asynq.Client
ExchangeRate float64
GeoIP *IPLocation
SignatureValidator *signature.Validator
//NodeCache *cache.NodeCacheClient
AuthModel auth.Model
@ -109,6 +111,8 @@ func NewServiceContext(c config.Config) *ServiceContext {
_ = rds.FlushAll(context.Background()).Err()
}
authLimiter := limit.NewPeriodLimit(86400, 15, rds, config.SendCountLimitKeyPrefix, limit.Align())
nonceStore := signature.NewRedisNonceStore(rds)
srv := &ServiceContext{
DB: db,
Redis: rds,
@ -116,6 +120,7 @@ func NewServiceContext(c config.Config) *ServiceContext {
Queue: NewAsynqClient(c),
ExchangeRate: 0,
GeoIP: geoIP,
SignatureValidator: signature.NewValidator(c.AppSignature, nonceStore),
//NodeCache: cache.NewNodeCacheClient(rds),
AuthLimiter: authLimiter,
AdsModel: ads.NewModel(db, rds),

View File

@ -0,0 +1,61 @@
package types
type ContactRequest struct {
Name string `json:"name" validate:"required,max=100"`
Email string `json:"email" validate:"required,email"`
OtherContact string `json:"other_contact" validate:"max=200"`
Notes string `json:"notes" validate:"max=2000"`
}
type GetDownloadLinkRequest struct {
InviteCode string `form:"invite_code,optional"`
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
}
type GetDownloadLinkResponse struct {
Url string `json:"url"`
}
type ReportLogMessageRequest struct {
Platform string `json:"platform" validate:"required,max=32"`
AppVersion string `json:"app_version" validate:"required,max=64"`
OsName string `json:"os_name" validate:"max=64"`
OsVersion string `json:"os_version" validate:"max=64"`
DeviceId string `json:"device_id" validate:"required,max=255"`
UserId int64 `json:"user_id"`
SessionId string `json:"session_id" validate:"max=255"`
Level uint8 `json:"level"`
ErrorCode string `json:"error_code" validate:"max=128"`
Message string `json:"message" validate:"required,max=65535"`
Stack string `json:"stack" validate:"max=1048576"`
Context interface{} `json:"context"`
OccurredAt int64 `json:"occurred_at"`
}
type ReportLogMessageResponse struct {
Id int64 `json:"id"`
}
type LegacyCheckVerificationCodeRequest struct {
Method string `json:"method" form:"method" validate:"omitempty,oneof=email mobile"`
Account string `json:"account" form:"account"`
Email string `json:"email" form:"email"`
Code string `json:"code" form:"code" validate:"required"`
Type uint8 `json:"type" form:"type" validate:"required"`
}
type LegacyCheckVerificationCodeResponse struct {
Status bool `json:"status"`
Exist bool `json:"exist"`
}
type EmailLoginRequest struct {
Identifier string `json:"identifier" form:"identifier"`
Email string `json:"email" form:"email" validate:"required,email"`
Code string `json:"code" form:"code" validate:"required"`
Invite string `json:"invite" form:"invite"`
IP string `header:"X-Original-Forwarded-For"`
UserAgent string `header:"User-Agent"`
LoginType string `header:"Login-Type"`
CfToken string `json:"cf_token,optional" form:"cf_token"`
}

View File

@ -221,19 +221,6 @@ type CheckVerificationCodeRespone struct {
Exist bool `json:"exist,omitempty"`
}
type LegacyCheckVerificationCodeRequest struct {
Method string `json:"method" form:"method"`
Account string `json:"account" form:"account"`
Email string `json:"email" form:"email"`
Code string `json:"code" form:"code" validate:"required"`
Type uint8 `json:"type" form:"type" validate:"required"`
}
type LegacyCheckVerificationCodeResponse struct {
Status bool `json:"status"`
Exist bool `json:"exist"`
}
type CheckoutOrderRequest struct {
OrderNo string `json:"orderNo"`
ReturnUrl string `json:"returnUrl,omitempty"`
@ -576,6 +563,11 @@ type DeviceLoginRequest struct {
ShortCode string `json:"short_code,optional"`
}
type DissolveFamilyRequest struct {
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
Reason string `json:"reason,omitempty"`
}
type Document struct {
Id int64 `json:"id"`
Title string `json:"title"`
@ -615,6 +607,34 @@ type EmailAuthticateConfig struct {
DomainSuffixList string `json:"domain_suffix_list"`
}
type FamilyDetail struct {
Summary FamilySummary `json:"summary"`
Members []FamilyMemberItem `json:"members"`
}
type FamilyMemberItem struct {
UserId int64 `json:"user_id"`
Identifier string `json:"identifier"`
Role uint8 `json:"role"`
RoleName string `json:"role_name"`
Status uint8 `json:"status"`
StatusName string `json:"status_name"`
JoinSource string `json:"join_source"`
JoinedAt int64 `json:"joined_at"`
LeftAt int64 `json:"left_at,omitempty"`
}
type FamilySummary struct {
FamilyId int64 `json:"family_id"`
OwnerUserId int64 `json:"owner_user_id"`
OwnerIdentifier string `json:"owner_identifier"`
Status string `json:"status"`
ActiveMemberCount int64 `json:"active_member_count"`
MaxMembers int64 `json:"max_members"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type FilterBalanceLogRequest struct {
FilterLogParams
UserId int64 `form:"user_id,optional"`
@ -887,6 +907,25 @@ type GetDocumentListResponse struct {
List []Document `json:"list"`
}
type GetFamilyDetailRequest struct {
Id int64 `form:"id" validate:"required"`
}
type GetFamilyListRequest struct {
Page int `form:"page"`
Size int `form:"size"`
Keyword string `form:"keyword,omitempty"`
Status string `form:"status,omitempty"`
OwnerUserId *int64 `form:"owner_user_id,omitempty"`
FamilyId *int64 `form:"family_id,omitempty"`
UserId *int64 `form:"user_id,omitempty"`
}
type GetFamilyListResponse struct {
List []FamilySummary `json:"list"`
Total int64 `json:"total"`
}
type GetGlobalConfigResponse struct {
Site SiteConfig `json:"site"`
Verify VeifyConfig `json:"verify"`
@ -894,6 +933,7 @@ type GetGlobalConfigResponse struct {
Invite InviteConfig `json:"invite"`
Currency Currency `json:"currency"`
Subscribe SubscribeConfig `json:"subscribe"`
Signature SignatureConfig `json:"signature"`
VerifyCode PubilcVerifyCodeConfig `json:"verify_code"`
OAuthMethods []string `json:"oauth_methods"`
WebAd bool `json:"web_ad"`
@ -1188,41 +1228,6 @@ type GetUserSubscribeResetTrafficLogsResponse struct {
Total int64 `json:"total"`
}
type GetFamilyListRequest struct {
Page int `form:"page"`
Size int `form:"size"`
Keyword string `form:"keyword,omitempty"`
Status string `form:"status,omitempty"`
OwnerUserId *int64 `form:"owner_user_id,omitempty"`
FamilyId *int64 `form:"family_id,omitempty"`
UserId *int64 `form:"user_id,omitempty"`
}
type GetFamilyListResponse struct {
List []FamilySummary `json:"list"`
Total int64 `json:"total"`
}
type GetFamilyDetailRequest struct {
Id int64 `form:"id" validate:"required"`
}
type UpdateFamilyMaxMembersRequest struct {
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
MaxMembers int64 `json:"max_members" validate:"required,gt=0"`
}
type RemoveFamilyMemberRequest struct {
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
UserId int64 `json:"user_id" validate:"required,gt=0"`
Reason string `json:"reason,omitempty"`
}
type DissolveFamilyRequest struct {
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
Reason string `json:"reason,omitempty"`
}
type GetUserSubscribeTrafficLogsRequest struct {
Page int `form:"page"`
Size int `form:"size"`
@ -1708,8 +1713,6 @@ type QueryOrderDetailRequest struct {
type QueryOrderListRequest struct {
Page int `form:"page" validate:"required"`
Size int `form:"size" validate:"required"`
Status int `form:"status"`
Search string `form:"search"`
}
type QueryOrderListResponse struct {
@ -1932,6 +1935,12 @@ type RegisterLog struct {
Timestamp int64 `json:"timestamp"`
}
type RemoveFamilyMemberRequest struct {
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
UserId int64 `json:"user_id" validate:"required,gt=0"`
Reason string `json:"reason,omitempty"`
}
type RenewalOrderRequest struct {
UserSubscribeID int64 `json:"user_subscribe_id"`
Quantity int64 `json:"quantity" validate:"lte=1000"`
@ -2163,6 +2172,10 @@ type ShadowsocksProtocol struct {
Method string `json:"method"`
}
type SignatureConfig struct {
EnableSignature bool `json:"enable_signature"`
}
type SiteConfig struct {
Host string `json:"host"`
SiteName string `json:"site_name"`
@ -2523,6 +2536,11 @@ type UpdateDocumentRequest struct {
Show *bool `json:"show"`
}
type UpdateFamilyMaxMembersRequest struct {
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
MaxMembers int64 `json:"max_members" validate:"required,gt=0"`
}
type UpdateNodeRequest struct {
Id int64 `json:"id"`
Name string `json:"name"`
@ -2699,7 +2717,6 @@ type User struct {
GiftAmount int64 `json:"gift_amount"`
Telegram int64 `json:"telegram"`
ReferCode string `json:"refer_code"`
ShareLink string `json:"share_link,omitempty"`
RefererId int64 `json:"referer_id"`
Enable bool `json:"enable"`
IsAdmin bool `json:"is_admin,omitempty"`
@ -2716,8 +2733,6 @@ type User struct {
IsDel bool `json:"is_del,omitempty"`
Remark string `json:"remark,omitempty"`
PurchasedPackage string `json:"purchased_package,omitempty"`
LastLoginTime int64 `json:"last_login_time"`
MemberStatus string `json:"member_status"`
FamilyJoined bool `json:"family_joined,omitempty"`
FamilyId int64 `json:"family_id,omitempty"`
FamilyRole uint8 `json:"family_role,omitempty"`
@ -2761,34 +2776,6 @@ type UserLoginLog struct {
Timestamp int64 `json:"timestamp"`
}
type FamilySummary struct {
FamilyId int64 `json:"family_id"`
OwnerUserId int64 `json:"owner_user_id"`
OwnerIdentifier string `json:"owner_identifier"`
Status string `json:"status"`
ActiveMemberCount int64 `json:"active_member_count"`
MaxMembers int64 `json:"max_members"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type FamilyMemberItem struct {
UserId int64 `json:"user_id"`
Identifier string `json:"identifier"`
Role uint8 `json:"role"`
RoleName string `json:"role_name"`
Status uint8 `json:"status"`
StatusName string `json:"status_name"`
JoinSource string `json:"join_source"`
JoinedAt int64 `json:"joined_at"`
LeftAt int64 `json:"left_at,omitempty"`
}
type FamilyDetail struct {
Summary FamilySummary `json:"summary"`
Members []FamilyMemberItem `json:"members"`
}
type UserLoginRequest struct {
Identifier string `json:"identifier"`
Email string `json:"email" validate:"required"`
@ -2844,7 +2831,6 @@ type UserSubscribe struct {
EntitlementOwnerUserId int64 `json:"entitlement_owner_user_id"`
ReadOnly bool `json:"read_only"`
Short string `json:"short"`
IsGift bool `json:"is_gift"`
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
@ -3023,221 +3009,3 @@ type WithdrawalLog struct {
CreatedAt int64 `json:"created_at"`
UpdatedAt int64 `json:"updated_at"`
}
type EmailLoginRequest struct {
Identifier string `json:"identifier"`
Email string `json:"email" validate:"required"`
Code string `json:"code" validate:"required"`
Invite string `json:"invite,optional"`
IP string `header:"X-Original-Forwarded-For"`
UserAgent string `header:"User-Agent"`
LoginType string `header:"Login-Type"`
CfToken string `json:"cf_token,optional"`
}
type BindEmailWithVerificationRequest struct {
Email string `json:"email" validate:"required"`
Code string `json:"code" validate:"required"`
}
type BindEmailWithVerificationResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
Token string `json:"token,omitempty"`
UserId int64 `json:"user_id,omitempty"`
FamilyJoined bool `json:"family_joined,omitempty"`
FamilyId int64 `json:"family_id,omitempty"`
OwnerUserId int64 `json:"owner_user_id,omitempty"`
}
type BindInviteCodeRequest struct {
InviteCode string `json:"invite_code" validate:"required"`
}
type GetSubscribeStatusResponse struct {
DeviceStatus bool `json:"device_status"`
EmailStatus bool `json:"email_status"`
}
type GetSubscribeStatusRequest struct {
Email string `json:"email" validate:"required,email"`
}
type DeleteAccountResponse struct {
Success bool `json:"success"`
Message string `json:"message,omitempty"`
UserId int64 `json:"user_id"`
Code int `json:"code"`
}
type GetDownloadLinkRequest struct {
InviteCode string `form:"invite_code,optional"`
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
}
type GetDownloadLinkResponse struct {
Url string `json:"url"`
}
type ContactRequest struct {
Name string `json:"name" validate:"required"`
Email string `json:"email" validate:"required,email"`
OtherContact string `json:"other_contact,omitempty"`
Notes string `json:"notes,omitempty"`
}
type ReportLogMessageRequest struct {
Platform string `json:"platform" validate:"required"`
AppVersion string `json:"appVersion"`
OsName string `json:"osName"`
OsVersion string `json:"osVersion"`
DeviceId string `json:"deviceId"`
UserId int64 `json:"userId"`
SessionId string `json:"sessionId"`
Level uint8 `json:"level"`
ErrorCode string `json:"errorCode"`
Message string `json:"message" validate:"required"`
Stack string `json:"stack"`
Context map[string]interface{} `json:"context"`
OccurredAt int64 `json:"occurredAt"`
}
type ReportLogMessageResponse struct {
Id int64 `json:"id"`
}
type GetErrorLogMessageListRequest struct {
Page int `form:"page"`
Size int `form:"size"`
Platform string `form:"platform"`
Level uint8 `form:"level"`
UserId int64 `form:"user_id"`
DeviceId string `form:"device_id"`
ErrorCode string `form:"error_code"`
Keyword string `form:"keyword"`
Start int64 `form:"start"`
End int64 `form:"end"`
}
type ErrorLogMessage struct {
Id int64 `json:"id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
OsName string `json:"os_name"`
OsVersion string `json:"os_version"`
DeviceId string `json:"device_id"`
UserId int64 `json:"user_id"`
SessionId string `json:"session_id"`
Level uint8 `json:"level"`
ErrorCode string `json:"error_code"`
Message string `json:"message"`
CreatedAt int64 `json:"created_at"`
}
type GetErrorLogMessageListResponse struct {
Total int64 `json:"total"`
List []ErrorLogMessage `json:"list"`
}
type GetErrorLogMessageDetailResponse struct {
Id int64 `json:"id"`
Platform string `json:"platform"`
AppVersion string `json:"app_version"`
OsName string `json:"os_name"`
OsVersion string `json:"os_version"`
DeviceId string `json:"device_id"`
UserId int64 `json:"user_id"`
SessionId string `json:"session_id"`
Level uint8 `json:"level"`
ErrorCode string `json:"error_code"`
Message string `json:"message"`
Stack string `json:"stack"`
Context map[string]interface{} `json:"context"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
Locale string `json:"locale"`
OccurredAt int64 `json:"occurred_at"`
CreatedAt int64 `json:"created_at"`
}
type AttachAppleTransactionRequest struct {
SignedTransactionJWS string `json:"signed_transaction_jws" validate:"required"`
DurationDays int64 `json:"duration_days,omitempty"`
Tier string `json:"tier,omitempty"`
SubscribeId int64 `json:"subscribe_id,omitempty"`
OrderNo string `json:"order_no" validate:"required"`
}
type AttachAppleTransactionResponse struct {
ExpiresAt int64 `json:"expires_at"`
Tier string `json:"tier"`
}
type AttachAppleTransactionByIdRequest struct {
TransactionId string `json:"transaction_id" validate:"required"`
OrderNo string `json:"order_no" validate:"required"`
Sandbox *bool `json:"sandbox,omitempty"`
}
type RestoreAppleTransactionsRequest struct {
Transactions []string `json:"transactions" validate:"required"`
}
type GetAppleStatusResponse struct {
Active bool `json:"active"`
ExpiresAt int64 `json:"expires_at"`
Tier string `json:"tier"`
}
type GetAgentRealtimeRequest struct{}
type GetAgentRealtimeResponse struct {
Total int64 `json:"total"`
Clicks int64 `json:"clicks"`
Views int64 `json:"views"`
Installs int64 `json:"installs"`
PaidCount int64 `json:"paid_count"`
GrowthRate string `json:"growth_rate"`
PaidGrowthRate string `json:"paid_growth_rate"`
}
type GetAgentDownloadsRequest struct{}
type GetAgentDownloadsResponse struct {
Total int64 `json:"total"`
Platforms *PlatformDownloads `json:"platforms"`
ComparisonRate *string `json:"comparison_rate,omitempty"`
}
type PlatformDownloads struct {
IOS int64 `json:"ios"`
Android int64 `json:"android"`
Windows int64 `json:"windows"`
Mac int64 `json:"mac"`
}
type GetUserInviteStatsRequest struct{}
type GetUserInviteStatsResponse struct {
FriendlyCount int64 `json:"friendly_count"`
HistoryCount int64 `json:"history_count"`
}
type GetInviteSalesRequest struct {
Page int `form:"page" validate:"required"`
Size int `form:"size" validate:"required"`
StartTime int64 `form:"start_time,optional"`
EndTime int64 `form:"end_time,optional"`
}
type GetInviteSalesResponse struct {
Total int64 `json:"total"`
List []InvitedUserSale `json:"list"`
}
type InvitedUserSale struct {
Amount float64 `json:"amount"`
UpdatedAt int64 `json:"update_at"`
UserHash string `json:"user_hash"`
ProductName string `json:"product_name"`
}

View File

@ -0,0 +1,50 @@
package signature
import (
"crypto/sha256"
"fmt"
"net/url"
"sort"
"strings"
)
// BuildStringToSign StringToSign = METHOD\nPATH\nCANONICAL_QUERY\nBODY_SHA256\nX-App-Id\nX-Timestamp\nX-Nonce
func BuildStringToSign(method, path, rawQuery string, body []byte, appId, timestamp, nonce string) string {
canonical := canonicalQuery(rawQuery)
bodyHash := sha256Hex(body)
parts := []string{
strings.ToUpper(method),
path,
canonical,
bodyHash,
appId,
timestamp,
nonce,
}
return strings.Join(parts, "\n")
}
func canonicalQuery(rawQuery string) string {
if rawQuery == "" {
return ""
}
values, err := url.ParseQuery(rawQuery)
if err != nil {
return rawQuery
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%s", key, values.Get(key)))
}
return strings.Join(parts, "&")
}
func sha256Hex(data []byte) string {
h := sha256.Sum256(data)
return fmt.Sprintf("%x", h)
}

7
pkg/signature/config.go Normal file
View File

@ -0,0 +1,7 @@
package signature
type SignatureConf struct {
AppSecrets map[string]string `yaml:"AppSecrets"`
ValidWindowSeconds int64 `yaml:"ValidWindowSeconds"`
SkipPrefixes []string `yaml:"SkipPrefixes"`
}

10
pkg/signature/errors.go Normal file
View File

@ -0,0 +1,10 @@
package signature
import "errors"
var (
ErrSignatureMissing = errors.New("signature missing")
ErrSignatureExpired = errors.New("signature expired")
ErrSignatureInvalid = errors.New("signature invalid")
ErrSignatureReplay = errors.New("signature replay")
)

View File

@ -0,0 +1,30 @@
package signature
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type NonceStore interface {
SetIfNotExists(ctx context.Context, appId, nonce string, ttlSeconds int64) (bool, error)
}
type RedisNonceStore struct {
client *redis.Client
}
func NewRedisNonceStore(client *redis.Client) *RedisNonceStore {
return &RedisNonceStore{client: client}
}
func (s *RedisNonceStore) SetIfNotExists(ctx context.Context, appId, nonce string, ttlSeconds int64) (bool, error) {
key := fmt.Sprintf("sig:nonce:%s:%s", appId, nonce)
ok, err := s.client.SetNX(ctx, key, "1", time.Duration(ttlSeconds)*time.Second).Result()
if err != nil {
return false, err
}
return !ok, nil
}

View File

@ -0,0 +1,120 @@
package signature
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"strconv"
"testing"
"time"
)
type mockNonceStore struct {
seen map[string]bool
}
func newMockNonceStore() *mockNonceStore {
return &mockNonceStore{seen: map[string]bool{}}
}
func (m *mockNonceStore) SetIfNotExists(_ context.Context, appId, nonce string, _ int64) (bool, error) {
key := appId + ":" + nonce
if m.seen[key] {
return true, nil
}
m.seen[key] = true
return false, nil
}
func makeSignature(secret, stringToSign string) string {
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
return hex.EncodeToString(mac.Sum(nil))
}
func TestValidateSuccess(t *testing.T) {
conf := SignatureConf{
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
ValidWindowSeconds: 300,
}
v := NewValidator(conf, newMockNonceStore())
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := fmt.Sprintf("%x", time.Now().UnixNano())
sts := BuildStringToSign("POST", "/v1/public/order/create", "", []byte(`{"plan_id":1}`), "web-client", ts, nonce)
sig := makeSignature("uB4G,XxL2{7b", sts)
if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != nil {
t.Fatalf("expected success, got %v", err)
}
}
func TestValidateExpired(t *testing.T) {
conf := SignatureConf{
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
ValidWindowSeconds: 300,
}
v := NewValidator(conf, newMockNonceStore())
ts := strconv.FormatInt(time.Now().Unix()-400, 10)
nonce := "abc"
sts := BuildStringToSign("GET", "/v1/public/user/info", "", nil, "web-client", ts, nonce)
sig := makeSignature("uB4G,XxL2{7b", sts)
if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != ErrSignatureExpired {
t.Fatalf("expected ErrSignatureExpired, got %v", err)
}
}
func TestValidateReplay(t *testing.T) {
conf := SignatureConf{
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
ValidWindowSeconds: 300,
}
v := NewValidator(conf, newMockNonceStore())
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := "same-nonce-replay"
sts := BuildStringToSign("GET", "/v1/public/user/info", "", nil, "web-client", ts, nonce)
sig := makeSignature("uB4G,XxL2{7b", sts)
_ = v.Validate(context.Background(), "web-client", ts, nonce, sig, sts)
if err := v.Validate(context.Background(), "web-client", ts, nonce, sig, sts); err != ErrSignatureReplay {
t.Fatalf("expected ErrSignatureReplay, got %v", err)
}
}
func TestValidateInvalidSignature(t *testing.T) {
conf := SignatureConf{
AppSecrets: map[string]string{"web-client": "uB4G,XxL2{7b"},
ValidWindowSeconds: 300,
}
v := NewValidator(conf, newMockNonceStore())
ts := strconv.FormatInt(time.Now().Unix(), 10)
nonce := "nonce-invalid-sig"
sts := BuildStringToSign("POST", "/v1/public/order/create", "", []byte(`{"plan_id":1}`), "web-client", ts, nonce)
if err := v.Validate(context.Background(), "web-client", ts, nonce, "badsignature", sts); err != ErrSignatureInvalid {
t.Fatalf("expected ErrSignatureInvalid, got %v", err)
}
}
func TestBuildStringToSignCanonicalQuery(t *testing.T) {
got := BuildStringToSign(
"get",
"/v1/public/order/list",
"b=2&a=1&a=3&c=",
nil,
"web-client",
"1700000000",
"nonce-1",
)
want := "GET\n/v1/public/order/list\na=1&b=2&c=\ne3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\nweb-client\n1700000000\nnonce-1"
if got != want {
t.Fatalf("unexpected stringToSign\nwant: %s\ngot: %s", want, got)
}
}

View File

@ -0,0 +1,61 @@
package signature
import (
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
)
type Validator struct {
conf SignatureConf
nonceStore NonceStore
}
func NewValidator(conf SignatureConf, store NonceStore) *Validator {
return &Validator{
conf: conf,
nonceStore: store,
}
}
func (v *Validator) windowSeconds() int64 {
if v.conf.ValidWindowSeconds <= 0 {
return 300
}
return v.conf.ValidWindowSeconds
}
func (v *Validator) Validate(ctx context.Context, appId, timestamp, nonce, signature, stringToSign string) error {
secret, ok := v.conf.AppSecrets[appId]
if !ok {
return ErrSignatureMissing
}
ts, err := strconv.ParseInt(timestamp, 10, 64)
if err != nil {
return ErrSignatureExpired
}
diff := time.Now().Unix() - ts
if diff < 0 {
diff = -diff
}
if diff > v.windowSeconds() {
return ErrSignatureExpired
}
replayed, err := v.nonceStore.SetIfNotExists(ctx, appId, nonce, v.windowSeconds()+60)
if err == nil && replayed {
return ErrSignatureReplay
}
mac := hmac.New(sha256.New, []byte(secret))
mac.Write([]byte(stringToSign))
expected := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(expected), []byte(signature)) {
return ErrSignatureInvalid
}
return nil
}

View File

@ -58,6 +58,10 @@ const (
InvalidAccess uint32 = 40005
InvalidCiphertext uint32 = 40006
SecretIsEmpty uint32 = 40007
SignatureMissing uint32 = 40008
SignatureExpired uint32 = 40009
SignatureInvalid uint32 = 40010
SignatureReplay uint32 = 40011
)
//coupon error

View File

@ -17,6 +17,10 @@ func init() {
SecretIsEmpty: "Secret is empty",
InvalidAccess: "Invalid access",
InvalidCiphertext: "Invalid ciphertext",
SignatureMissing: "Signature headers are missing",
SignatureExpired: "Signature is expired",
SignatureInvalid: "Signature is invalid",
SignatureReplay: "Signature nonce replay detected",
// Database error
DatabaseQueryError: "Database query error",
DatabaseUpdateError: "Database update error",