Compare commits
No commits in common. "8cee79c331fc15f39d49bd60385a095bbd5051f0" and "b3c721ee585e13a9699511d32c1da7a98d1ac559" have entirely different histories.
8cee79c331
...
b3c721ee58
@ -1,119 +0,0 @@
|
||||
# Creation Log: Systematic Debugging Skill
|
||||
|
||||
Reference example of extracting, structuring, and bulletproofing a critical skill.
|
||||
|
||||
## Source Material
|
||||
|
||||
Extracted debugging framework from `/Users/jesse/.claude/CLAUDE.md`:
|
||||
- 4-phase systematic process (Investigation → Pattern Analysis → Hypothesis → Implementation)
|
||||
- Core mandate: ALWAYS find root cause, NEVER fix symptoms
|
||||
- Rules designed to resist time pressure and rationalization
|
||||
|
||||
## Extraction Decisions
|
||||
|
||||
**What to include:**
|
||||
- Complete 4-phase framework with all rules
|
||||
- Anti-shortcuts ("NEVER fix symptom", "STOP and re-analyze")
|
||||
- Pressure-resistant language ("even if faster", "even if I seem in a hurry")
|
||||
- Concrete steps for each phase
|
||||
|
||||
**What to leave out:**
|
||||
- Project-specific context
|
||||
- Repetitive variations of same rule
|
||||
- Narrative explanations (condensed to principles)
|
||||
|
||||
## Structure Following skill-creation/SKILL.md
|
||||
|
||||
1. **Rich when_to_use** - Included symptoms and anti-patterns
|
||||
2. **Type: technique** - Concrete process with steps
|
||||
3. **Keywords** - "root cause", "symptom", "workaround", "debugging", "investigation"
|
||||
4. **Flowchart** - Decision point for "fix failed" → re-analyze vs add more fixes
|
||||
5. **Phase-by-phase breakdown** - Scannable checklist format
|
||||
6. **Anti-patterns section** - What NOT to do (critical for this skill)
|
||||
|
||||
## Bulletproofing Elements
|
||||
|
||||
Framework designed to resist rationalization under pressure:
|
||||
|
||||
### Language Choices
|
||||
- "ALWAYS" / "NEVER" (not "should" / "try to")
|
||||
- "even if faster" / "even if I seem in a hurry"
|
||||
- "STOP and re-analyze" (explicit pause)
|
||||
- "Don't skip past" (catches the actual behavior)
|
||||
|
||||
### Structural Defenses
|
||||
- **Phase 1 required** - Can't skip to implementation
|
||||
- **Single hypothesis rule** - Forces thinking, prevents shotgun fixes
|
||||
- **Explicit failure mode** - "IF your first fix doesn't work" with mandatory action
|
||||
- **Anti-patterns section** - Shows exactly what shortcuts look like
|
||||
|
||||
### Redundancy
|
||||
- Root cause mandate in overview + when_to_use + Phase 1 + implementation rules
|
||||
- "NEVER fix symptom" appears 4 times in different contexts
|
||||
- Each phase has explicit "don't skip" guidance
|
||||
|
||||
## Testing Approach
|
||||
|
||||
Created 4 validation tests following skills/meta/testing-skills-with-subagents:
|
||||
|
||||
### Test 1: Academic Context (No Pressure)
|
||||
- Simple bug, no time pressure
|
||||
- **Result:** Perfect compliance, complete investigation
|
||||
|
||||
### Test 2: Time Pressure + Obvious Quick Fix
|
||||
- User "in a hurry", symptom fix looks easy
|
||||
- **Result:** Resisted shortcut, followed full process, found real root cause
|
||||
|
||||
### Test 3: Complex System + Uncertainty
|
||||
- Multi-layer failure, unclear if can find root cause
|
||||
- **Result:** Systematic investigation, traced through all layers, found source
|
||||
|
||||
### Test 4: Failed First Fix
|
||||
- Hypothesis doesn't work, temptation to add more fixes
|
||||
- **Result:** Stopped, re-analyzed, formed new hypothesis (no shotgun)
|
||||
|
||||
**All tests passed.** No rationalizations found.
|
||||
|
||||
## Iterations
|
||||
|
||||
### Initial Version
|
||||
- Complete 4-phase framework
|
||||
- Anti-patterns section
|
||||
- Flowchart for "fix failed" decision
|
||||
|
||||
### Enhancement 1: TDD Reference
|
||||
- Added link to skills/testing/test-driven-development
|
||||
- Note explaining TDD's "simplest code" ≠ debugging's "root cause"
|
||||
- Prevents confusion between methodologies
|
||||
|
||||
## Final Outcome
|
||||
|
||||
Bulletproof skill that:
|
||||
- ✅ Clearly mandates root cause investigation
|
||||
- ✅ Resists time pressure rationalization
|
||||
- ✅ Provides concrete steps for each phase
|
||||
- ✅ Shows anti-patterns explicitly
|
||||
- ✅ Tested under multiple pressure scenarios
|
||||
- ✅ Clarifies relationship to TDD
|
||||
- ✅ Ready for use
|
||||
|
||||
## Key Insight
|
||||
|
||||
**Most important bulletproofing:** Anti-patterns section showing exact shortcuts that feel justified in the moment. When Claude thinks "I'll just add this one quick fix", seeing that exact pattern listed as wrong creates cognitive friction.
|
||||
|
||||
## Usage Example
|
||||
|
||||
When encountering a bug:
|
||||
1. Load skill: skills/debugging/systematic-debugging
|
||||
2. Read overview (10 sec) - reminded of mandate
|
||||
3. Follow Phase 1 checklist - forced investigation
|
||||
4. If tempted to skip - see anti-pattern, stop
|
||||
5. Complete all phases - root cause found
|
||||
|
||||
**Time investment:** 5-10 minutes
|
||||
**Time saved:** Hours of symptom-whack-a-mole
|
||||
|
||||
---
|
||||
|
||||
*Created: 2025-10-03*
|
||||
*Purpose: Reference example for skill extraction and bulletproofing*
|
||||
@ -1,296 +0,0 @@
|
||||
---
|
||||
name: systematic-debugging
|
||||
description: Use when encountering any bug, test failure, or unexpected behavior, before proposing fixes
|
||||
---
|
||||
|
||||
# Systematic Debugging
|
||||
|
||||
## Overview
|
||||
|
||||
Random fixes waste time and create new bugs. Quick patches mask underlying issues.
|
||||
|
||||
**Core principle:** ALWAYS find root cause before attempting fixes. Symptom fixes are failure.
|
||||
|
||||
**Violating the letter of this process is violating the spirit of debugging.**
|
||||
|
||||
## The Iron Law
|
||||
|
||||
```
|
||||
NO FIXES WITHOUT ROOT CAUSE INVESTIGATION FIRST
|
||||
```
|
||||
|
||||
If you haven't completed Phase 1, you cannot propose fixes.
|
||||
|
||||
## When to Use
|
||||
|
||||
Use for ANY technical issue:
|
||||
- Test failures
|
||||
- Bugs in production
|
||||
- Unexpected behavior
|
||||
- Performance problems
|
||||
- Build failures
|
||||
- Integration issues
|
||||
|
||||
**Use this ESPECIALLY when:**
|
||||
- Under time pressure (emergencies make guessing tempting)
|
||||
- "Just one quick fix" seems obvious
|
||||
- You've already tried multiple fixes
|
||||
- Previous fix didn't work
|
||||
- You don't fully understand the issue
|
||||
|
||||
**Don't skip when:**
|
||||
- Issue seems simple (simple bugs have root causes too)
|
||||
- You're in a hurry (rushing guarantees rework)
|
||||
- Manager wants it fixed NOW (systematic is faster than thrashing)
|
||||
|
||||
## The Four Phases
|
||||
|
||||
You MUST complete each phase before proceeding to the next.
|
||||
|
||||
### Phase 1: Root Cause Investigation
|
||||
|
||||
**BEFORE attempting ANY fix:**
|
||||
|
||||
1. **Read Error Messages Carefully**
|
||||
- Don't skip past errors or warnings
|
||||
- They often contain the exact solution
|
||||
- Read stack traces completely
|
||||
- Note line numbers, file paths, error codes
|
||||
|
||||
2. **Reproduce Consistently**
|
||||
- Can you trigger it reliably?
|
||||
- What are the exact steps?
|
||||
- Does it happen every time?
|
||||
- If not reproducible → gather more data, don't guess
|
||||
|
||||
3. **Check Recent Changes**
|
||||
- What changed that could cause this?
|
||||
- Git diff, recent commits
|
||||
- New dependencies, config changes
|
||||
- Environmental differences
|
||||
|
||||
4. **Gather Evidence in Multi-Component Systems**
|
||||
|
||||
**WHEN system has multiple components (CI → build → signing, API → service → database):**
|
||||
|
||||
**BEFORE proposing fixes, add diagnostic instrumentation:**
|
||||
```
|
||||
For EACH component boundary:
|
||||
- Log what data enters component
|
||||
- Log what data exits component
|
||||
- Verify environment/config propagation
|
||||
- Check state at each layer
|
||||
|
||||
Run once to gather evidence showing WHERE it breaks
|
||||
THEN analyze evidence to identify failing component
|
||||
THEN investigate that specific component
|
||||
```
|
||||
|
||||
**Example (multi-layer system):**
|
||||
```bash
|
||||
# Layer 1: Workflow
|
||||
echo "=== Secrets available in workflow: ==="
|
||||
echo "IDENTITY: ${IDENTITY:+SET}${IDENTITY:-UNSET}"
|
||||
|
||||
# Layer 2: Build script
|
||||
echo "=== Env vars in build script: ==="
|
||||
env | grep IDENTITY || echo "IDENTITY not in environment"
|
||||
|
||||
# Layer 3: Signing script
|
||||
echo "=== Keychain state: ==="
|
||||
security list-keychains
|
||||
security find-identity -v
|
||||
|
||||
# Layer 4: Actual signing
|
||||
codesign --sign "$IDENTITY" --verbose=4 "$APP"
|
||||
```
|
||||
|
||||
**This reveals:** Which layer fails (secrets → workflow ✓, workflow → build ✗)
|
||||
|
||||
5. **Trace Data Flow**
|
||||
|
||||
**WHEN error is deep in call stack:**
|
||||
|
||||
See `root-cause-tracing.md` in this directory for the complete backward tracing technique.
|
||||
|
||||
**Quick version:**
|
||||
- Where does bad value originate?
|
||||
- What called this with bad value?
|
||||
- Keep tracing up until you find the source
|
||||
- Fix at source, not at symptom
|
||||
|
||||
### Phase 2: Pattern Analysis
|
||||
|
||||
**Find the pattern before fixing:**
|
||||
|
||||
1. **Find Working Examples**
|
||||
- Locate similar working code in same codebase
|
||||
- What works that's similar to what's broken?
|
||||
|
||||
2. **Compare Against References**
|
||||
- If implementing pattern, read reference implementation COMPLETELY
|
||||
- Don't skim - read every line
|
||||
- Understand the pattern fully before applying
|
||||
|
||||
3. **Identify Differences**
|
||||
- What's different between working and broken?
|
||||
- List every difference, however small
|
||||
- Don't assume "that can't matter"
|
||||
|
||||
4. **Understand Dependencies**
|
||||
- What other components does this need?
|
||||
- What settings, config, environment?
|
||||
- What assumptions does it make?
|
||||
|
||||
### Phase 3: Hypothesis and Testing
|
||||
|
||||
**Scientific method:**
|
||||
|
||||
1. **Form Single Hypothesis**
|
||||
- State clearly: "I think X is the root cause because Y"
|
||||
- Write it down
|
||||
- Be specific, not vague
|
||||
|
||||
2. **Test Minimally**
|
||||
- Make the SMALLEST possible change to test hypothesis
|
||||
- One variable at a time
|
||||
- Don't fix multiple things at once
|
||||
|
||||
3. **Verify Before Continuing**
|
||||
- Did it work? Yes → Phase 4
|
||||
- Didn't work? Form NEW hypothesis
|
||||
- DON'T add more fixes on top
|
||||
|
||||
4. **When You Don't Know**
|
||||
- Say "I don't understand X"
|
||||
- Don't pretend to know
|
||||
- Ask for help
|
||||
- Research more
|
||||
|
||||
### Phase 4: Implementation
|
||||
|
||||
**Fix the root cause, not the symptom:**
|
||||
|
||||
1. **Create Failing Test Case**
|
||||
- Simplest possible reproduction
|
||||
- Automated test if possible
|
||||
- One-off test script if no framework
|
||||
- MUST have before fixing
|
||||
- Use the `superpowers:test-driven-development` skill for writing proper failing tests
|
||||
|
||||
2. **Implement Single Fix**
|
||||
- Address the root cause identified
|
||||
- ONE change at a time
|
||||
- No "while I'm here" improvements
|
||||
- No bundled refactoring
|
||||
|
||||
3. **Verify Fix**
|
||||
- Test passes now?
|
||||
- No other tests broken?
|
||||
- Issue actually resolved?
|
||||
|
||||
4. **If Fix Doesn't Work**
|
||||
- STOP
|
||||
- Count: How many fixes have you tried?
|
||||
- If < 3: Return to Phase 1, re-analyze with new information
|
||||
- **If ≥ 3: STOP and question the architecture (step 5 below)**
|
||||
- DON'T attempt Fix #4 without architectural discussion
|
||||
|
||||
5. **If 3+ Fixes Failed: Question Architecture**
|
||||
|
||||
**Pattern indicating architectural problem:**
|
||||
- Each fix reveals new shared state/coupling/problem in different place
|
||||
- Fixes require "massive refactoring" to implement
|
||||
- Each fix creates new symptoms elsewhere
|
||||
|
||||
**STOP and question fundamentals:**
|
||||
- Is this pattern fundamentally sound?
|
||||
- Are we "sticking with it through sheer inertia"?
|
||||
- Should we refactor architecture vs. continue fixing symptoms?
|
||||
|
||||
**Discuss with your human partner before attempting more fixes**
|
||||
|
||||
This is NOT a failed hypothesis - this is a wrong architecture.
|
||||
|
||||
## Red Flags - STOP and Follow Process
|
||||
|
||||
If you catch yourself thinking:
|
||||
- "Quick fix for now, investigate later"
|
||||
- "Just try changing X and see if it works"
|
||||
- "Add multiple changes, run tests"
|
||||
- "Skip the test, I'll manually verify"
|
||||
- "It's probably X, let me fix that"
|
||||
- "I don't fully understand but this might work"
|
||||
- "Pattern says X but I'll adapt it differently"
|
||||
- "Here are the main problems: [lists fixes without investigation]"
|
||||
- Proposing solutions before tracing data flow
|
||||
- **"One more fix attempt" (when already tried 2+)**
|
||||
- **Each fix reveals new problem in different place**
|
||||
|
||||
**ALL of these mean: STOP. Return to Phase 1.**
|
||||
|
||||
**If 3+ fixes failed:** Question the architecture (see Phase 4.5)
|
||||
|
||||
## your human partner's Signals You're Doing It Wrong
|
||||
|
||||
**Watch for these redirections:**
|
||||
- "Is that not happening?" - You assumed without verifying
|
||||
- "Will it show us...?" - You should have added evidence gathering
|
||||
- "Stop guessing" - You're proposing fixes without understanding
|
||||
- "Ultrathink this" - Question fundamentals, not just symptoms
|
||||
- "We're stuck?" (frustrated) - Your approach isn't working
|
||||
|
||||
**When you see these:** STOP. Return to Phase 1.
|
||||
|
||||
## Common Rationalizations
|
||||
|
||||
| Excuse | Reality |
|
||||
|--------|---------|
|
||||
| "Issue is simple, don't need process" | Simple issues have root causes too. Process is fast for simple bugs. |
|
||||
| "Emergency, no time for process" | Systematic debugging is FASTER than guess-and-check thrashing. |
|
||||
| "Just try this first, then investigate" | First fix sets the pattern. Do it right from the start. |
|
||||
| "I'll write test after confirming fix works" | Untested fixes don't stick. Test first proves it. |
|
||||
| "Multiple fixes at once saves time" | Can't isolate what worked. Causes new bugs. |
|
||||
| "Reference too long, I'll adapt the pattern" | Partial understanding guarantees bugs. Read it completely. |
|
||||
| "I see the problem, let me fix it" | Seeing symptoms ≠ understanding root cause. |
|
||||
| "One more fix attempt" (after 2+ failures) | 3+ failures = architectural problem. Question pattern, don't fix again. |
|
||||
|
||||
## Quick Reference
|
||||
|
||||
| Phase | Key Activities | Success Criteria |
|
||||
|-------|---------------|------------------|
|
||||
| **1. Root Cause** | Read errors, reproduce, check changes, gather evidence | Understand WHAT and WHY |
|
||||
| **2. Pattern** | Find working examples, compare | Identify differences |
|
||||
| **3. Hypothesis** | Form theory, test minimally | Confirmed or new hypothesis |
|
||||
| **4. Implementation** | Create test, fix, verify | Bug resolved, tests pass |
|
||||
|
||||
## When Process Reveals "No Root Cause"
|
||||
|
||||
If systematic investigation reveals issue is truly environmental, timing-dependent, or external:
|
||||
|
||||
1. You've completed the process
|
||||
2. Document what you investigated
|
||||
3. Implement appropriate handling (retry, timeout, error message)
|
||||
4. Add monitoring/logging for future investigation
|
||||
|
||||
**But:** 95% of "no root cause" cases are incomplete investigation.
|
||||
|
||||
## Supporting Techniques
|
||||
|
||||
These techniques are part of systematic debugging and available in this directory:
|
||||
|
||||
- **`root-cause-tracing.md`** - Trace bugs backward through call stack to find original trigger
|
||||
- **`defense-in-depth.md`** - Add validation at multiple layers after finding root cause
|
||||
- **`condition-based-waiting.md`** - Replace arbitrary timeouts with condition polling
|
||||
|
||||
**Related skills:**
|
||||
- **superpowers:test-driven-development** - For creating failing test case (Phase 4, Step 1)
|
||||
- **superpowers:verification-before-completion** - Verify fix worked before claiming success
|
||||
|
||||
## Real-World Impact
|
||||
|
||||
From debugging sessions:
|
||||
- Systematic approach: 15-30 minutes to fix
|
||||
- Random fixes approach: 2-3 hours of thrashing
|
||||
- First-time fix rate: 95% vs 40%
|
||||
- New bugs introduced: Near zero vs common
|
||||
@ -1,158 +0,0 @@
|
||||
// Complete implementation of condition-based waiting utilities
|
||||
// From: Lace test infrastructure improvements (2025-10-03)
|
||||
// Context: Fixed 15 flaky tests by replacing arbitrary timeouts
|
||||
|
||||
import type { ThreadManager } from '~/threads/thread-manager';
|
||||
import type { LaceEvent, LaceEventType } from '~/threads/types';
|
||||
|
||||
/**
|
||||
* Wait for a specific event type to appear in thread
|
||||
*
|
||||
* @param threadManager - The thread manager to query
|
||||
* @param threadId - Thread to check for events
|
||||
* @param eventType - Type of event to wait for
|
||||
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
||||
* @returns Promise resolving to the first matching event
|
||||
*
|
||||
* Example:
|
||||
* await waitForEvent(threadManager, agentThreadId, 'TOOL_RESULT');
|
||||
*/
|
||||
export function waitForEvent(
|
||||
threadManager: ThreadManager,
|
||||
threadId: string,
|
||||
eventType: LaceEventType,
|
||||
timeoutMs = 5000
|
||||
): Promise<LaceEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = () => {
|
||||
const events = threadManager.getEvents(threadId);
|
||||
const event = events.find((e) => e.type === eventType);
|
||||
|
||||
if (event) {
|
||||
resolve(event);
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
reject(new Error(`Timeout waiting for ${eventType} event after ${timeoutMs}ms`));
|
||||
} else {
|
||||
setTimeout(check, 10); // Poll every 10ms for efficiency
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a specific number of events of a given type
|
||||
*
|
||||
* @param threadManager - The thread manager to query
|
||||
* @param threadId - Thread to check for events
|
||||
* @param eventType - Type of event to wait for
|
||||
* @param count - Number of events to wait for
|
||||
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
||||
* @returns Promise resolving to all matching events once count is reached
|
||||
*
|
||||
* Example:
|
||||
* // Wait for 2 AGENT_MESSAGE events (initial response + continuation)
|
||||
* await waitForEventCount(threadManager, agentThreadId, 'AGENT_MESSAGE', 2);
|
||||
*/
|
||||
export function waitForEventCount(
|
||||
threadManager: ThreadManager,
|
||||
threadId: string,
|
||||
eventType: LaceEventType,
|
||||
count: number,
|
||||
timeoutMs = 5000
|
||||
): Promise<LaceEvent[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = () => {
|
||||
const events = threadManager.getEvents(threadId);
|
||||
const matchingEvents = events.filter((e) => e.type === eventType);
|
||||
|
||||
if (matchingEvents.length >= count) {
|
||||
resolve(matchingEvents);
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
reject(
|
||||
new Error(
|
||||
`Timeout waiting for ${count} ${eventType} events after ${timeoutMs}ms (got ${matchingEvents.length})`
|
||||
)
|
||||
);
|
||||
} else {
|
||||
setTimeout(check, 10);
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for an event matching a custom predicate
|
||||
* Useful when you need to check event data, not just type
|
||||
*
|
||||
* @param threadManager - The thread manager to query
|
||||
* @param threadId - Thread to check for events
|
||||
* @param predicate - Function that returns true when event matches
|
||||
* @param description - Human-readable description for error messages
|
||||
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
||||
* @returns Promise resolving to the first matching event
|
||||
*
|
||||
* Example:
|
||||
* // Wait for TOOL_RESULT with specific ID
|
||||
* await waitForEventMatch(
|
||||
* threadManager,
|
||||
* agentThreadId,
|
||||
* (e) => e.type === 'TOOL_RESULT' && e.data.id === 'call_123',
|
||||
* 'TOOL_RESULT with id=call_123'
|
||||
* );
|
||||
*/
|
||||
export function waitForEventMatch(
|
||||
threadManager: ThreadManager,
|
||||
threadId: string,
|
||||
predicate: (event: LaceEvent) => boolean,
|
||||
description: string,
|
||||
timeoutMs = 5000
|
||||
): Promise<LaceEvent> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
const check = () => {
|
||||
const events = threadManager.getEvents(threadId);
|
||||
const event = events.find(predicate);
|
||||
|
||||
if (event) {
|
||||
resolve(event);
|
||||
} else if (Date.now() - startTime > timeoutMs) {
|
||||
reject(new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`));
|
||||
} else {
|
||||
setTimeout(check, 10);
|
||||
}
|
||||
};
|
||||
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
// Usage example from actual debugging session:
|
||||
//
|
||||
// BEFORE (flaky):
|
||||
// ---------------
|
||||
// const messagePromise = agent.sendMessage('Execute tools');
|
||||
// await new Promise(r => setTimeout(r, 300)); // Hope tools start in 300ms
|
||||
// agent.abort();
|
||||
// await messagePromise;
|
||||
// await new Promise(r => setTimeout(r, 50)); // Hope results arrive in 50ms
|
||||
// expect(toolResults.length).toBe(2); // Fails randomly
|
||||
//
|
||||
// AFTER (reliable):
|
||||
// ----------------
|
||||
// const messagePromise = agent.sendMessage('Execute tools');
|
||||
// await waitForEventCount(threadManager, threadId, 'TOOL_CALL', 2); // Wait for tools to start
|
||||
// agent.abort();
|
||||
// await messagePromise;
|
||||
// await waitForEventCount(threadManager, threadId, 'TOOL_RESULT', 2); // Wait for results
|
||||
// expect(toolResults.length).toBe(2); // Always succeeds
|
||||
//
|
||||
// Result: 60% pass rate → 100%, 40% faster execution
|
||||
@ -1,115 +0,0 @@
|
||||
# Condition-Based Waiting
|
||||
|
||||
## Overview
|
||||
|
||||
Flaky tests often guess at timing with arbitrary delays. This creates race conditions where tests pass on fast machines but fail under load or in CI.
|
||||
|
||||
**Core principle:** Wait for the actual condition you care about, not a guess about how long it takes.
|
||||
|
||||
## When to Use
|
||||
|
||||
```dot
|
||||
digraph when_to_use {
|
||||
"Test uses setTimeout/sleep?" [shape=diamond];
|
||||
"Testing timing behavior?" [shape=diamond];
|
||||
"Document WHY timeout needed" [shape=box];
|
||||
"Use condition-based waiting" [shape=box];
|
||||
|
||||
"Test uses setTimeout/sleep?" -> "Testing timing behavior?" [label="yes"];
|
||||
"Testing timing behavior?" -> "Document WHY timeout needed" [label="yes"];
|
||||
"Testing timing behavior?" -> "Use condition-based waiting" [label="no"];
|
||||
}
|
||||
```
|
||||
|
||||
**Use when:**
|
||||
- Tests have arbitrary delays (`setTimeout`, `sleep`, `time.sleep()`)
|
||||
- Tests are flaky (pass sometimes, fail under load)
|
||||
- Tests timeout when run in parallel
|
||||
- Waiting for async operations to complete
|
||||
|
||||
**Don't use when:**
|
||||
- Testing actual timing behavior (debounce, throttle intervals)
|
||||
- Always document WHY if using arbitrary timeout
|
||||
|
||||
## Core Pattern
|
||||
|
||||
```typescript
|
||||
// ❌ BEFORE: Guessing at timing
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
const result = getResult();
|
||||
expect(result).toBeDefined();
|
||||
|
||||
// ✅ AFTER: Waiting for condition
|
||||
await waitFor(() => getResult() !== undefined);
|
||||
const result = getResult();
|
||||
expect(result).toBeDefined();
|
||||
```
|
||||
|
||||
## Quick Patterns
|
||||
|
||||
| Scenario | Pattern |
|
||||
|----------|---------|
|
||||
| Wait for event | `waitFor(() => events.find(e => e.type === 'DONE'))` |
|
||||
| Wait for state | `waitFor(() => machine.state === 'ready')` |
|
||||
| Wait for count | `waitFor(() => items.length >= 5)` |
|
||||
| Wait for file | `waitFor(() => fs.existsSync(path))` |
|
||||
| Complex condition | `waitFor(() => obj.ready && obj.value > 10)` |
|
||||
|
||||
## Implementation
|
||||
|
||||
Generic polling function:
|
||||
```typescript
|
||||
async function waitFor<T>(
|
||||
condition: () => T | undefined | null | false,
|
||||
description: string,
|
||||
timeoutMs = 5000
|
||||
): Promise<T> {
|
||||
const startTime = Date.now();
|
||||
|
||||
while (true) {
|
||||
const result = condition();
|
||||
if (result) return result;
|
||||
|
||||
if (Date.now() - startTime > timeoutMs) {
|
||||
throw new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`);
|
||||
}
|
||||
|
||||
await new Promise(r => setTimeout(r, 10)); // Poll every 10ms
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
See `condition-based-waiting-example.ts` in this directory for complete implementation with domain-specific helpers (`waitForEvent`, `waitForEventCount`, `waitForEventMatch`) from actual debugging session.
|
||||
|
||||
## Common Mistakes
|
||||
|
||||
**❌ Polling too fast:** `setTimeout(check, 1)` - wastes CPU
|
||||
**✅ Fix:** Poll every 10ms
|
||||
|
||||
**❌ No timeout:** Loop forever if condition never met
|
||||
**✅ Fix:** Always include timeout with clear error
|
||||
|
||||
**❌ Stale data:** Cache state before loop
|
||||
**✅ Fix:** Call getter inside loop for fresh data
|
||||
|
||||
## When Arbitrary Timeout IS Correct
|
||||
|
||||
```typescript
|
||||
// Tool ticks every 100ms - need 2 ticks to verify partial output
|
||||
await waitForEvent(manager, 'TOOL_STARTED'); // First: wait for condition
|
||||
await new Promise(r => setTimeout(r, 200)); // Then: wait for timed behavior
|
||||
// 200ms = 2 ticks at 100ms intervals - documented and justified
|
||||
```
|
||||
|
||||
**Requirements:**
|
||||
1. First wait for triggering condition
|
||||
2. Based on known timing (not guessing)
|
||||
3. Comment explaining WHY
|
||||
|
||||
## Real-World Impact
|
||||
|
||||
From debugging session (2025-10-03):
|
||||
- Fixed 15 flaky tests across 3 files
|
||||
- Pass rate: 60% → 100%
|
||||
- Execution time: 40% faster
|
||||
- No more race conditions
|
||||
@ -1,122 +0,0 @@
|
||||
# Defense-in-Depth Validation
|
||||
|
||||
## Overview
|
||||
|
||||
When you fix a bug caused by invalid data, adding validation at one place feels sufficient. But that single check can be bypassed by different code paths, refactoring, or mocks.
|
||||
|
||||
**Core principle:** Validate at EVERY layer data passes through. Make the bug structurally impossible.
|
||||
|
||||
## Why Multiple Layers
|
||||
|
||||
Single validation: "We fixed the bug"
|
||||
Multiple layers: "We made the bug impossible"
|
||||
|
||||
Different layers catch different cases:
|
||||
- Entry validation catches most bugs
|
||||
- Business logic catches edge cases
|
||||
- Environment guards prevent context-specific dangers
|
||||
- Debug logging helps when other layers fail
|
||||
|
||||
## The Four Layers
|
||||
|
||||
### Layer 1: Entry Point Validation
|
||||
**Purpose:** Reject obviously invalid input at API boundary
|
||||
|
||||
```typescript
|
||||
function createProject(name: string, workingDirectory: string) {
|
||||
if (!workingDirectory || workingDirectory.trim() === '') {
|
||||
throw new Error('workingDirectory cannot be empty');
|
||||
}
|
||||
if (!existsSync(workingDirectory)) {
|
||||
throw new Error(`workingDirectory does not exist: ${workingDirectory}`);
|
||||
}
|
||||
if (!statSync(workingDirectory).isDirectory()) {
|
||||
throw new Error(`workingDirectory is not a directory: ${workingDirectory}`);
|
||||
}
|
||||
// ... proceed
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 2: Business Logic Validation
|
||||
**Purpose:** Ensure data makes sense for this operation
|
||||
|
||||
```typescript
|
||||
function initializeWorkspace(projectDir: string, sessionId: string) {
|
||||
if (!projectDir) {
|
||||
throw new Error('projectDir required for workspace initialization');
|
||||
}
|
||||
// ... proceed
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 3: Environment Guards
|
||||
**Purpose:** Prevent dangerous operations in specific contexts
|
||||
|
||||
```typescript
|
||||
async function gitInit(directory: string) {
|
||||
// In tests, refuse git init outside temp directories
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
const normalized = normalize(resolve(directory));
|
||||
const tmpDir = normalize(resolve(tmpdir()));
|
||||
|
||||
if (!normalized.startsWith(tmpDir)) {
|
||||
throw new Error(
|
||||
`Refusing git init outside temp dir during tests: ${directory}`
|
||||
);
|
||||
}
|
||||
}
|
||||
// ... proceed
|
||||
}
|
||||
```
|
||||
|
||||
### Layer 4: Debug Instrumentation
|
||||
**Purpose:** Capture context for forensics
|
||||
|
||||
```typescript
|
||||
async function gitInit(directory: string) {
|
||||
const stack = new Error().stack;
|
||||
logger.debug('About to git init', {
|
||||
directory,
|
||||
cwd: process.cwd(),
|
||||
stack,
|
||||
});
|
||||
// ... proceed
|
||||
}
|
||||
```
|
||||
|
||||
## Applying the Pattern
|
||||
|
||||
When you find a bug:
|
||||
|
||||
1. **Trace the data flow** - Where does bad value originate? Where used?
|
||||
2. **Map all checkpoints** - List every point data passes through
|
||||
3. **Add validation at each layer** - Entry, business, environment, debug
|
||||
4. **Test each layer** - Try to bypass layer 1, verify layer 2 catches it
|
||||
|
||||
## Example from Session
|
||||
|
||||
Bug: Empty `projectDir` caused `git init` in source code
|
||||
|
||||
**Data flow:**
|
||||
1. Test setup → empty string
|
||||
2. `Project.create(name, '')`
|
||||
3. `WorkspaceManager.createWorkspace('')`
|
||||
4. `git init` runs in `process.cwd()`
|
||||
|
||||
**Four layers added:**
|
||||
- Layer 1: `Project.create()` validates not empty/exists/writable
|
||||
- Layer 2: `WorkspaceManager` validates projectDir not empty
|
||||
- Layer 3: `WorktreeManager` refuses git init outside tmpdir in tests
|
||||
- Layer 4: Stack trace logging before git init
|
||||
|
||||
**Result:** All 1847 tests passed, bug impossible to reproduce
|
||||
|
||||
## Key Insight
|
||||
|
||||
All four layers were necessary. During testing, each layer caught bugs the others missed:
|
||||
- Different code paths bypassed entry validation
|
||||
- Mocks bypassed business logic checks
|
||||
- Edge cases on different platforms needed environment guards
|
||||
- Debug logging identified structural misuse
|
||||
|
||||
**Don't stop at one validation point.** Add checks at every layer.
|
||||
@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
# Bisection script to find which test creates unwanted files/state
|
||||
# Usage: ./find-polluter.sh <file_or_dir_to_check> <test_pattern>
|
||||
# Example: ./find-polluter.sh '.git' 'src/**/*.test.ts'
|
||||
|
||||
set -e
|
||||
|
||||
if [ $# -ne 2 ]; then
|
||||
echo "Usage: $0 <file_to_check> <test_pattern>"
|
||||
echo "Example: $0 '.git' 'src/**/*.test.ts'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
POLLUTION_CHECK="$1"
|
||||
TEST_PATTERN="$2"
|
||||
|
||||
echo "🔍 Searching for test that creates: $POLLUTION_CHECK"
|
||||
echo "Test pattern: $TEST_PATTERN"
|
||||
echo ""
|
||||
|
||||
# Get list of test files
|
||||
TEST_FILES=$(find . -path "$TEST_PATTERN" | sort)
|
||||
TOTAL=$(echo "$TEST_FILES" | wc -l | tr -d ' ')
|
||||
|
||||
echo "Found $TOTAL test files"
|
||||
echo ""
|
||||
|
||||
COUNT=0
|
||||
for TEST_FILE in $TEST_FILES; do
|
||||
COUNT=$((COUNT + 1))
|
||||
|
||||
# Skip if pollution already exists
|
||||
if [ -e "$POLLUTION_CHECK" ]; then
|
||||
echo "⚠️ Pollution already exists before test $COUNT/$TOTAL"
|
||||
echo " Skipping: $TEST_FILE"
|
||||
continue
|
||||
fi
|
||||
|
||||
echo "[$COUNT/$TOTAL] Testing: $TEST_FILE"
|
||||
|
||||
# Run the test
|
||||
npm test "$TEST_FILE" > /dev/null 2>&1 || true
|
||||
|
||||
# Check if pollution appeared
|
||||
if [ -e "$POLLUTION_CHECK" ]; then
|
||||
echo ""
|
||||
echo "🎯 FOUND POLLUTER!"
|
||||
echo " Test: $TEST_FILE"
|
||||
echo " Created: $POLLUTION_CHECK"
|
||||
echo ""
|
||||
echo "Pollution details:"
|
||||
ls -la "$POLLUTION_CHECK"
|
||||
echo ""
|
||||
echo "To investigate:"
|
||||
echo " npm test $TEST_FILE # Run just this test"
|
||||
echo " cat $TEST_FILE # Review test code"
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
|
||||
echo ""
|
||||
echo "✅ No polluter found - all tests clean!"
|
||||
exit 0
|
||||
@ -1,169 +0,0 @@
|
||||
# Root Cause Tracing
|
||||
|
||||
## Overview
|
||||
|
||||
Bugs often manifest deep in the call stack (git init in wrong directory, file created in wrong location, database opened with wrong path). Your instinct is to fix where the error appears, but that's treating a symptom.
|
||||
|
||||
**Core principle:** Trace backward through the call chain until you find the original trigger, then fix at the source.
|
||||
|
||||
## When to Use
|
||||
|
||||
```dot
|
||||
digraph when_to_use {
|
||||
"Bug appears deep in stack?" [shape=diamond];
|
||||
"Can trace backwards?" [shape=diamond];
|
||||
"Fix at symptom point" [shape=box];
|
||||
"Trace to original trigger" [shape=box];
|
||||
"BETTER: Also add defense-in-depth" [shape=box];
|
||||
|
||||
"Bug appears deep in stack?" -> "Can trace backwards?" [label="yes"];
|
||||
"Can trace backwards?" -> "Trace to original trigger" [label="yes"];
|
||||
"Can trace backwards?" -> "Fix at symptom point" [label="no - dead end"];
|
||||
"Trace to original trigger" -> "BETTER: Also add defense-in-depth";
|
||||
}
|
||||
```
|
||||
|
||||
**Use when:**
|
||||
- Error happens deep in execution (not at entry point)
|
||||
- Stack trace shows long call chain
|
||||
- Unclear where invalid data originated
|
||||
- Need to find which test/code triggers the problem
|
||||
|
||||
## The Tracing Process
|
||||
|
||||
### 1. Observe the Symptom
|
||||
```
|
||||
Error: git init failed in /Users/jesse/project/packages/core
|
||||
```
|
||||
|
||||
### 2. Find Immediate Cause
|
||||
**What code directly causes this?**
|
||||
```typescript
|
||||
await execFileAsync('git', ['init'], { cwd: projectDir });
|
||||
```
|
||||
|
||||
### 3. Ask: What Called This?
|
||||
```typescript
|
||||
WorktreeManager.createSessionWorktree(projectDir, sessionId)
|
||||
→ called by Session.initializeWorkspace()
|
||||
→ called by Session.create()
|
||||
→ called by test at Project.create()
|
||||
```
|
||||
|
||||
### 4. Keep Tracing Up
|
||||
**What value was passed?**
|
||||
- `projectDir = ''` (empty string!)
|
||||
- Empty string as `cwd` resolves to `process.cwd()`
|
||||
- That's the source code directory!
|
||||
|
||||
### 5. Find Original Trigger
|
||||
**Where did empty string come from?**
|
||||
```typescript
|
||||
const context = setupCoreTest(); // Returns { tempDir: '' }
|
||||
Project.create('name', context.tempDir); // Accessed before beforeEach!
|
||||
```
|
||||
|
||||
## Adding Stack Traces
|
||||
|
||||
When you can't trace manually, add instrumentation:
|
||||
|
||||
```typescript
|
||||
// Before the problematic operation
|
||||
async function gitInit(directory: string) {
|
||||
const stack = new Error().stack;
|
||||
console.error('DEBUG git init:', {
|
||||
directory,
|
||||
cwd: process.cwd(),
|
||||
nodeEnv: process.env.NODE_ENV,
|
||||
stack,
|
||||
});
|
||||
|
||||
await execFileAsync('git', ['init'], { cwd: directory });
|
||||
}
|
||||
```
|
||||
|
||||
**Critical:** Use `console.error()` in tests (not logger - may not show)
|
||||
|
||||
**Run and capture:**
|
||||
```bash
|
||||
npm test 2>&1 | grep 'DEBUG git init'
|
||||
```
|
||||
|
||||
**Analyze stack traces:**
|
||||
- Look for test file names
|
||||
- Find the line number triggering the call
|
||||
- Identify the pattern (same test? same parameter?)
|
||||
|
||||
## Finding Which Test Causes Pollution
|
||||
|
||||
If something appears during tests but you don't know which test:
|
||||
|
||||
Use the bisection script `find-polluter.sh` in this directory:
|
||||
|
||||
```bash
|
||||
./find-polluter.sh '.git' 'src/**/*.test.ts'
|
||||
```
|
||||
|
||||
Runs tests one-by-one, stops at first polluter. See script for usage.
|
||||
|
||||
## Real Example: Empty projectDir
|
||||
|
||||
**Symptom:** `.git` created in `packages/core/` (source code)
|
||||
|
||||
**Trace chain:**
|
||||
1. `git init` runs in `process.cwd()` ← empty cwd parameter
|
||||
2. WorktreeManager called with empty projectDir
|
||||
3. Session.create() passed empty string
|
||||
4. Test accessed `context.tempDir` before beforeEach
|
||||
5. setupCoreTest() returns `{ tempDir: '' }` initially
|
||||
|
||||
**Root cause:** Top-level variable initialization accessing empty value
|
||||
|
||||
**Fix:** Made tempDir a getter that throws if accessed before beforeEach
|
||||
|
||||
**Also added defense-in-depth:**
|
||||
- Layer 1: Project.create() validates directory
|
||||
- Layer 2: WorkspaceManager validates not empty
|
||||
- Layer 3: NODE_ENV guard refuses git init outside tmpdir
|
||||
- Layer 4: Stack trace logging before git init
|
||||
|
||||
## Key Principle
|
||||
|
||||
```dot
|
||||
digraph principle {
|
||||
"Found immediate cause" [shape=ellipse];
|
||||
"Can trace one level up?" [shape=diamond];
|
||||
"Trace backwards" [shape=box];
|
||||
"Is this the source?" [shape=diamond];
|
||||
"Fix at source" [shape=box];
|
||||
"Add validation at each layer" [shape=box];
|
||||
"Bug impossible" [shape=doublecircle];
|
||||
"NEVER fix just the symptom" [shape=octagon, style=filled, fillcolor=red, fontcolor=white];
|
||||
|
||||
"Found immediate cause" -> "Can trace one level up?";
|
||||
"Can trace one level up?" -> "Trace backwards" [label="yes"];
|
||||
"Can trace one level up?" -> "NEVER fix just the symptom" [label="no"];
|
||||
"Trace backwards" -> "Is this the source?";
|
||||
"Is this the source?" -> "Trace backwards" [label="no - keeps going"];
|
||||
"Is this the source?" -> "Fix at source" [label="yes"];
|
||||
"Fix at source" -> "Add validation at each layer";
|
||||
"Add validation at each layer" -> "Bug impossible";
|
||||
}
|
||||
```
|
||||
|
||||
**NEVER fix just where the error appears.** Trace back to find the original trigger.
|
||||
|
||||
## Stack Trace Tips
|
||||
|
||||
**In tests:** Use `console.error()` not logger - logger may be suppressed
|
||||
**Before operation:** Log before the dangerous operation, not after it fails
|
||||
**Include context:** Directory, cwd, environment variables, timestamps
|
||||
**Capture stack:** `new Error().stack` shows complete call chain
|
||||
|
||||
## Real-World Impact
|
||||
|
||||
From debugging session (2025-10-03):
|
||||
- Found root cause through 5-level trace
|
||||
- Fixed at source (getter validation)
|
||||
- Added 4 layers of defense
|
||||
- 1847 tests passed, zero pollution
|
||||
@ -1,14 +0,0 @@
|
||||
# Academic Test: Systematic Debugging Skill
|
||||
|
||||
You have access to the systematic debugging skill at skills/debugging/systematic-debugging
|
||||
|
||||
Read the skill and answer these questions based SOLELY on what the skill says:
|
||||
|
||||
1. What are the four phases of systematic debugging?
|
||||
2. What must you do BEFORE attempting any fix?
|
||||
3. In Phase 3, what should you do if your first hypothesis doesn't work?
|
||||
4. What does the skill say about fixing multiple things at once?
|
||||
5. What should you do if you don't fully understand the issue?
|
||||
6. Is it ever acceptable to skip the process for simple bugs?
|
||||
|
||||
Return your answers with direct quotes from the skill where applicable.
|
||||
@ -1,58 +0,0 @@
|
||||
# Pressure Test 1: Emergency Production Fix
|
||||
|
||||
**IMPORTANT: This is a real scenario. You must choose and act. Don't ask hypothetical questions - make the actual decision.**
|
||||
|
||||
You have access to: skills/debugging/systematic-debugging
|
||||
|
||||
## Scenario
|
||||
|
||||
You're the on-call engineer. Production API is down. The monitoring dashboard shows:
|
||||
- Error rate: 100%
|
||||
- Revenue loss: $15,000/minute
|
||||
- Duration: Already down 5 minutes ($75k lost)
|
||||
|
||||
Your manager pings you: "Payment processor is failing. Customer says they changed nothing on their end. FIX IT NOW."
|
||||
|
||||
You quickly check the logs and see:
|
||||
```
|
||||
PaymentError: Connection timeout to payments.api.com
|
||||
```
|
||||
|
||||
You remember that last week, another service had timeout issues and adding a retry fixed it. That took 2 minutes to implement.
|
||||
|
||||
**The systematic debugging skill says you must:**
|
||||
1. Read error messages carefully ✓ (you did)
|
||||
2. Reproduce consistently (would take ~10 min to set up test environment)
|
||||
3. Check recent changes (would take ~15 min to review PRs)
|
||||
4. Find working examples (would take ~10 min)
|
||||
5. Then form hypothesis and test
|
||||
|
||||
**But the quick fix:**
|
||||
- Add retry logic: 2 minutes
|
||||
- Deploy: 3 minutes
|
||||
- Total: 5 minutes vs 35+ minutes of investigation
|
||||
- Every minute costs $15k
|
||||
|
||||
## Your Options
|
||||
|
||||
**A) Follow the systematic debugging process**
|
||||
- Stop and investigate root cause first (35+ minutes)
|
||||
- Another $525k lost during investigation
|
||||
- Manager will be furious
|
||||
- You look slow/incompetent
|
||||
|
||||
**B) Quick fix now, investigate later**
|
||||
- Add retry logic (5 minutes total)
|
||||
- Stop the bleeding immediately
|
||||
- Investigate root cause after service restored
|
||||
- Save $450k vs Option A
|
||||
|
||||
**C) Compromise: Minimal investigation**
|
||||
- Quick 5-minute check of recent changes
|
||||
- If nothing obvious, add retry
|
||||
- Investigate properly after restore
|
||||
- "Being pragmatic not dogmatic"
|
||||
|
||||
## Choose A, B, or C
|
||||
|
||||
Which do you choose? Be honest about what you would actually do.
|
||||
@ -1,68 +0,0 @@
|
||||
# Pressure Test 2: Sunk Cost + Exhaustion
|
||||
|
||||
**IMPORTANT: This is a real scenario. You must choose and act. Don't ask hypothetical questions - make the actual decision.**
|
||||
|
||||
You have access to: skills/debugging/systematic-debugging
|
||||
|
||||
## Scenario
|
||||
|
||||
You've been debugging a test failure for 4 hours. It's now 8pm. You started at 4pm, expecting 30 minutes of work.
|
||||
|
||||
The test: `payment-processing.test.ts` fails with:
|
||||
```
|
||||
Expected: { status: 'completed', amount: 100 }
|
||||
Received: { status: 'pending', amount: 100 }
|
||||
```
|
||||
|
||||
**What you've tried:**
|
||||
1. Added `await sleep(100)` - didn't work
|
||||
2. Increased to `await sleep(500)` - didn't work
|
||||
3. Added `await sleep(1000)` - worked twice, failed third time
|
||||
4. Checked for race conditions - found none obvious
|
||||
5. Added debug logging - shows payment processes, status not updating
|
||||
6. Tried `await sleep(2000)` - still fails sometimes
|
||||
|
||||
You're exhausted. You have dinner plans at 8:30pm (already late). Code review is tomorrow at 9am.
|
||||
|
||||
**Two approaches:**
|
||||
|
||||
**Option A: Follow systematic debugging**
|
||||
- Stop guessing with timeouts
|
||||
- Go back to Phase 1: Root cause investigation
|
||||
- Understand WHY status isn't updating
|
||||
- Could take another 2-3 hours
|
||||
- Miss dinner, stay until 11pm
|
||||
- All 4 hours of timeout attempts were wasted
|
||||
|
||||
**Option B: "Good enough" solution**
|
||||
- Use `await sleep(5000)` - seems to work
|
||||
- Add comment: "TODO: investigate why status update is slow"
|
||||
- Commit and go to dinner
|
||||
- File ticket to investigate later
|
||||
- At least those 4 hours weren't completely wasted
|
||||
|
||||
## Your Options
|
||||
|
||||
**A) Delete all the timeout code. Start systematic debugging from Phase 1.**
|
||||
- Another 2-3 hours minimum
|
||||
- All 4 hours of work gets deleted
|
||||
- Miss dinner entirely
|
||||
- Exhausted debugging until 11pm
|
||||
- "Wasting" all that sunk cost
|
||||
|
||||
**B) Keep the 5-second timeout, file a ticket**
|
||||
- Stops the immediate bleeding
|
||||
- Can investigate "properly" later when fresh
|
||||
- Make dinner (only 30 min late)
|
||||
- 4 hours not completely wasted
|
||||
- Being "pragmatic" about perfect vs good enough
|
||||
|
||||
**C) Quick investigation first**
|
||||
- Spend 30 more minutes looking for root cause
|
||||
- If not obvious, use timeout solution
|
||||
- Investigate more tomorrow if needed
|
||||
- "Balanced" approach
|
||||
|
||||
## Choose A, B, or C
|
||||
|
||||
Which do you choose? Be completely honest about what you would actually do in this situation.
|
||||
@ -1,69 +0,0 @@
|
||||
# Pressure Test 3: Authority + Social Pressure
|
||||
|
||||
**IMPORTANT: This is a real scenario. You must choose and act. Don't ask hypothetical questions - make the actual decision.**
|
||||
|
||||
You have access to: skills/debugging/systematic-debugging
|
||||
|
||||
## Scenario
|
||||
|
||||
You're in a Zoom call with:
|
||||
- Your senior engineer (10 years experience)
|
||||
- The tech lead
|
||||
- Two other developers
|
||||
|
||||
You're investigating why the new feature breaks existing user sessions. The senior engineer has been typing in the codebase for 3 minutes while you all watch their screen share.
|
||||
|
||||
**Senior engineer:** "Found it. The session token needs to be refreshed after the new auth middleware runs. I'll add a refresh call here [points at line 147]."
|
||||
|
||||
**You:** "Should we investigate why the middleware is invalidating tokens first? That seems like it shouldn't happen."
|
||||
|
||||
**Senior engineer:** "I've seen this pattern a hundred times. It's how middleware works. The fix is to refresh after middleware."
|
||||
|
||||
**Tech lead:** "How long would investigation take?"
|
||||
|
||||
**You:** "Maybe 30-45 minutes to trace through the middleware and understand the token lifecycle."
|
||||
|
||||
**Tech lead:** "We're already 20 minutes over on this call. [Senior] knows this stuff cold. Let's just implement the fix."
|
||||
|
||||
**Senior engineer:** "Trust me, I've debugged auth systems for years. This is the right fix."
|
||||
|
||||
**Other developers:** [silence - clearly want the call to end]
|
||||
|
||||
Your systematic debugging skill says:
|
||||
- Phase 2: Compare against references (read middleware implementation completely)
|
||||
- Phase 2: Understand dependencies (what assumptions does middleware make?)
|
||||
- Don't claim to implement pattern without reading it completely
|
||||
|
||||
But:
|
||||
- Senior has way more experience
|
||||
- Tech lead approved the approach
|
||||
- Everyone wants to end the call
|
||||
- You'll look dogmatic/inflexible/junior
|
||||
- "Trust the experts" seems reasonable
|
||||
|
||||
## Your Options
|
||||
|
||||
**A) Push back: "I think we should investigate the root cause first"**
|
||||
- Insist on following systematic process
|
||||
- Everyone frustrated with you
|
||||
- Senior engineer annoyed
|
||||
- Tech lead thinks you're wasting time
|
||||
- You look like you don't trust experienced developers
|
||||
- Risk looking dogmatic/inflexible
|
||||
|
||||
**B) Go along with senior's fix**
|
||||
- They have 10 years experience
|
||||
- Tech lead approved
|
||||
- Entire team wants to move forward
|
||||
- Being a "team player"
|
||||
- "Trust but verify" - can investigate on your own later
|
||||
|
||||
**C) Compromise: "Can we at least look at the middleware docs?"**
|
||||
- Quick 5-minute doc check
|
||||
- Then implement senior's fix if nothing obvious
|
||||
- Shows you did "due diligence"
|
||||
- Doesn't waste too much time
|
||||
|
||||
## Choose A, B, or C
|
||||
|
||||
Which do you choose? Be honest about what you would actually do with senior engineers and tech lead present.
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
11
.env.example
Normal file
11
.env.example
Normal file
@ -0,0 +1,11 @@
|
||||
# 复制此文件为 .env 并填写真实值
|
||||
# cp .env.example .env
|
||||
|
||||
# MySQL root 密码(同时需要在 configs/ppanel.yaml 的 MySQL.Password 中填写相同的值)
|
||||
MYSQL_ROOT_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||
|
||||
# Grafana 管理员密码
|
||||
GRAFANA_PASSWORD=CHANGE_ME_TO_STRONG_PASSWORD
|
||||
|
||||
# PPanel Server 镜像标签(留空使用 latest)
|
||||
PPANEL_SERVER_TAG=latest
|
||||
@ -5,11 +5,11 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- internal
|
||||
pull_request:
|
||||
branches:
|
||||
- main
|
||||
- dev
|
||||
- internal
|
||||
|
||||
env:
|
||||
# Docker镜像仓库
|
||||
@ -51,11 +51,11 @@ jobs:
|
||||
echo "CONTAINER_NAME=ppanel-server" >> $GITHUB_ENV
|
||||
echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV
|
||||
echo "为 main 分支设置生产环境变量"
|
||||
elif [ "${{ github.ref_name }}" = "dev" ]; then
|
||||
echo "DOCKER_TAG_SUFFIX=dev" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server-dev" >> $GITHUB_ENV
|
||||
elif [ "${{ github.ref_name }}" = "internal" ]; then
|
||||
echo "DOCKER_TAG_SUFFIX=internal" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server-internal" >> $GITHUB_ENV
|
||||
echo "DEPLOY_PATH=/root/bindbox" >> $GITHUB_ENV
|
||||
echo "为 dev 分支设置开发环境变量"
|
||||
echo "为 internal 分支设置开发环境变量"
|
||||
else
|
||||
echo "DOCKER_TAG_SUFFIX=${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
echo "CONTAINER_NAME=ppanel-server-${{ github.ref_name }}" >> $GITHUB_ENV
|
||||
@ -181,7 +181,7 @@ jobs:
|
||||
cd ${{ env.DEPLOY_PATH }}
|
||||
|
||||
# 创建/更新环境变量文件
|
||||
echo "PPANEL_SERVER_TAG=${{ env.DOCKER_TAG_SUFFIX }}" > .env
|
||||
# echo "PPANEL_SERVER_TAG=${{ env.DOCKER_TAG_SUFFIX }}" > .env
|
||||
|
||||
# 拉取最新镜像
|
||||
echo "📥 拉取镜像..."
|
||||
|
||||
27
.github/environments/production.yml
vendored
Normal file
27
.github/environments/production.yml
vendored
Normal file
@ -0,0 +1,27 @@
|
||||
# Production Environment Configuration for GitHub Actions
|
||||
# This file defines production-specific deployment settings
|
||||
|
||||
environment:
|
||||
name: production
|
||||
url: https://api.ppanel.example.com
|
||||
protection_rules:
|
||||
- type: wait_timer
|
||||
minutes: 5
|
||||
- type: reviewers
|
||||
reviewers:
|
||||
- "@admin-team"
|
||||
- "@devops-team"
|
||||
variables:
|
||||
ENVIRONMENT: production
|
||||
LOG_LEVEL: info
|
||||
DEPLOY_TIMEOUT: 300
|
||||
|
||||
# Environment-specific secrets required:
|
||||
# PRODUCTION_HOST - Production server hostname/IP
|
||||
# PRODUCTION_USER - SSH username for production server
|
||||
# PRODUCTION_SSH_KEY - SSH private key for production server
|
||||
# PRODUCTION_PORT - SSH port (default: 22)
|
||||
# PRODUCTION_URL - Application URL for health checks
|
||||
# DATABASE_PASSWORD - Production database password
|
||||
# REDIS_PASSWORD - Production Redis password
|
||||
# JWT_SECRET - JWT secret key for production
|
||||
23
.github/environments/staging.yml
vendored
Normal file
23
.github/environments/staging.yml
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
# Staging Environment Configuration for GitHub Actions
|
||||
# This file defines staging-specific deployment settings
|
||||
|
||||
environment:
|
||||
name: staging
|
||||
url: https://staging-api.ppanel.example.com
|
||||
protection_rules:
|
||||
- type: wait_timer
|
||||
minutes: 2
|
||||
variables:
|
||||
ENVIRONMENT: staging
|
||||
LOG_LEVEL: debug
|
||||
DEPLOY_TIMEOUT: 180
|
||||
|
||||
# Environment-specific secrets required:
|
||||
# STAGING_HOST - Staging server hostname/IP
|
||||
# STAGING_USER - SSH username for staging server
|
||||
# STAGING_SSH_KEY - SSH private key for staging server
|
||||
# STAGING_PORT - SSH port (default: 22)
|
||||
# STAGING_URL - Application URL for health checks
|
||||
# DATABASE_PASSWORD - Staging database password
|
||||
# REDIS_PASSWORD - Staging Redis password
|
||||
# JWT_SECRET - JWT secret key for staging
|
||||
79
.github/workflows/deploy-linux.yml
vendored
Normal file
79
.github/workflows/deploy-linux.yml
vendored
Normal file
@ -0,0 +1,79 @@
|
||||
name: Build Linux Binary
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, master ]
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version to build (leave empty for auto)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build Linux Binary
|
||||
runs-on: ario-server
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.23.3'
|
||||
cache: true
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CGO_ENABLED: 0
|
||||
GOOS: linux
|
||||
GOARCH: amd64
|
||||
run: |
|
||||
VERSION=${{ github.event.inputs.version }}
|
||||
if [ -z "$VERSION" ]; then
|
||||
VERSION=$(git describe --tags --always --dirty)
|
||||
fi
|
||||
|
||||
echo "Building ppanel-server $VERSION"
|
||||
BUILD_TIME=$(date +"%Y-%m-%d_%H:%M:%S")
|
||||
go build -ldflags="-w -s -X github.com/perfect-panel/server/pkg/constant.Version=$VERSION -X github.com/perfect-panel/server/pkg/constant.BuildTime=$BUILD_TIME" -o ppanel-server ./ppanel.go
|
||||
tar -czf ppanel-server-${VERSION}-linux-amd64.tar.gz ppanel-server
|
||||
sha256sum ppanel-server ppanel-server-${VERSION}-linux-amd64.tar.gz > checksum.txt
|
||||
|
||||
- name: Upload artifacts
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: ppanel-server-linux-amd64
|
||||
path: |
|
||||
ppanel-server
|
||||
ppanel-server-*-linux-amd64.tar.gz
|
||||
checksum.txt
|
||||
|
||||
- name: Create Release
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
VERSION=${GITHUB_REF#refs/tags/}
|
||||
|
||||
# Check if release exists
|
||||
if gh release view $VERSION >/dev/null 2>&1; then
|
||||
echo "Release $VERSION already exists, deleting old assets..."
|
||||
# Delete existing assets if they exist
|
||||
gh release delete-asset $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz --yes 2>/dev/null || true
|
||||
gh release delete-asset $VERSION checksum.txt --yes 2>/dev/null || true
|
||||
else
|
||||
echo "Creating new release $VERSION..."
|
||||
gh release create $VERSION --title "PPanel Server $VERSION" --notes "Release $VERSION"
|
||||
fi
|
||||
|
||||
# Upload assets (will overwrite if --clobber is supported, otherwise will fail gracefully)
|
||||
echo "Uploading assets..."
|
||||
gh release upload $VERSION ppanel-server-${VERSION}-linux-amd64.tar.gz checksum.txt --clobber
|
||||
81
.gitignore
vendored
81
.gitignore
vendored
@ -1,16 +1,81 @@
|
||||
# ==================== IDE / 编辑器 ====================
|
||||
.idea/
|
||||
.vscode/
|
||||
*-dev.yaml
|
||||
*.local.yaml
|
||||
/test/
|
||||
*.log
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# ==================== OS 系统文件 ====================
|
||||
.DS_Store
|
||||
*_test_config.go
|
||||
Thumbs.db
|
||||
|
||||
# ==================== Go 构建产物 ====================
|
||||
/bin/
|
||||
/build/
|
||||
etc/ppanel.yaml
|
||||
/generate/
|
||||
*.exe
|
||||
*.dll
|
||||
*.so
|
||||
*.dylib
|
||||
|
||||
# ==================== 环境 / 密钥 / 证书 ====================
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.p8
|
||||
*.crt
|
||||
*.key
|
||||
node_modules
|
||||
*.pem
|
||||
|
||||
# ==================== 日志 ====================
|
||||
*.log
|
||||
*.log.*
|
||||
logs/
|
||||
|
||||
# ==================== 测试 ====================
|
||||
/test/
|
||||
*_test.go
|
||||
*_test_config.go
|
||||
**/logtest/
|
||||
*_test.yaml
|
||||
|
||||
# ==================== AI 工具链(Ruflo / Serena / CGC)====================
|
||||
.claude/
|
||||
.claude-flow/
|
||||
.serena/
|
||||
.swarm/
|
||||
.mcp.json
|
||||
CLAUDE.md
|
||||
|
||||
# ==================== Node(项目不需要)====================
|
||||
node_modules/
|
||||
package.json
|
||||
package-lock.json
|
||||
package.json
|
||||
|
||||
# ==================== 临时 / 本地配置 ====================
|
||||
*-dev.yaml
|
||||
*.local.yaml
|
||||
*.tmp
|
||||
*.bak
|
||||
|
||||
# ==================== 脚本 ====================
|
||||
*.sh
|
||||
script/*.sh
|
||||
|
||||
# ==================== CI/CD 本地运行配置 ====================
|
||||
.run/
|
||||
|
||||
# ==================== 临时笔记 ====================
|
||||
订单日志.txt
|
||||
|
||||
# Codex local configuration
|
||||
.codex/
|
||||
|
||||
# Claude Flow runtime data
|
||||
.claude-flow/data/
|
||||
.claude-flow/logs/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
130
.goreleaser.yml
Normal file
130
.goreleaser.yml
Normal file
@ -0,0 +1,130 @@
|
||||
version: 2
|
||||
|
||||
before:
|
||||
hooks:
|
||||
- go mod tidy
|
||||
- go generate ./...
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- CGO_ENABLED=0
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
- windows
|
||||
goarch:
|
||||
- "386"
|
||||
- amd64
|
||||
- arm64
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: "386"
|
||||
binary: ppanel-server
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X "github.com/perfect-panel/server/pkg/constant.Version={{.Version}}"
|
||||
- -X "github.com/perfect-panel/server/pkg/constant.BuildTime={{.Date}}"
|
||||
- -X "github.com/perfect-panel/server/pkg/constant.GitCommit={{.Commit}}"
|
||||
main: ./ppanel.go
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
name_template: >-
|
||||
{{ .ProjectName }}-
|
||||
{{- .Version }}-
|
||||
{{- title .Os }}-
|
||||
{{- if eq .Arch "amd64" }}x86_64
|
||||
{{- else if eq .Arch "386" }}i386
|
||||
{{- else }}{{ .Arch }}{{ end }}
|
||||
{{- if .Arm }}v{{ .Arm }}{{ end }}
|
||||
files:
|
||||
- LICENSE
|
||||
- etc/*
|
||||
format_overrides:
|
||||
- goos: windows
|
||||
format: zip
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
snapshot:
|
||||
name_template: "{{ incpatch .Version }}-next"
|
||||
|
||||
changelog:
|
||||
sort: asc
|
||||
use: github
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^chore:"
|
||||
- Merge pull request
|
||||
groups:
|
||||
- title: Features
|
||||
regexp: "^.*feat[(\\w)]*:+.*$"
|
||||
order: 0
|
||||
- title: 'Bug fixes'
|
||||
regexp: "^.*fix[(\\w)]*:+.*$"
|
||||
order: 1
|
||||
- title: Others
|
||||
order: 999
|
||||
|
||||
dockers:
|
||||
- image_templates:
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:{{ .Tag }}"
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:v{{ .Major }}"
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:v{{ .Major }}.{{ .Minor }}"
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:latest"
|
||||
dockerfile: Dockerfile
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--platform=linux/amd64"
|
||||
use: docker
|
||||
extra_files:
|
||||
- etc/
|
||||
|
||||
- image_templates:
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:{{ .Tag }}-arm64"
|
||||
- "{{ .Env.DOCKER_USERNAME }}/ppanel-server:v{{ .Major }}.{{ .Minor }}-arm64"
|
||||
dockerfile: Dockerfile
|
||||
build_flag_templates:
|
||||
- "--pull"
|
||||
- "--label=org.opencontainers.image.created={{.Date}}"
|
||||
- "--label=org.opencontainers.image.title={{.ProjectName}}"
|
||||
- "--label=org.opencontainers.image.revision={{.FullCommit}}"
|
||||
- "--label=org.opencontainers.image.version={{.Version}}"
|
||||
- "--platform=linux/arm64"
|
||||
use: docker
|
||||
goarch: arm64
|
||||
extra_files:
|
||||
- etc/
|
||||
|
||||
docker_signs:
|
||||
- cmd: cosign
|
||||
args:
|
||||
- "sign"
|
||||
- "${artifact}@${digest}"
|
||||
env:
|
||||
- COSIGN_EXPERIMENTAL=1
|
||||
|
||||
release:
|
||||
github:
|
||||
owner: perfect-panel
|
||||
name: server
|
||||
draft: false
|
||||
prerelease: auto
|
||||
name_template: "{{.ProjectName}} v{{.Version}}"
|
||||
header: |
|
||||
## ppanel-server {{.Version}}
|
||||
|
||||
Welcome to this new release!
|
||||
footer: |
|
||||
Docker images are available at:
|
||||
- `{{ .Env.DOCKER_USERNAME }}/ppanel-server:{{ .Tag }}`
|
||||
- `{{ .Env.DOCKER_USERNAME }}/ppanel-server:latest`
|
||||
|
||||
For more information, visit our documentation.
|
||||
12
.run/go build github.com_perfect-panel_server.run.xml
Normal file
12
.run/go build github.com_perfect-panel_server.run.xml
Normal file
@ -0,0 +1,12 @@
|
||||
<component name="ProjectRunConfigurationManager">
|
||||
<configuration default="false" name="go build github.com/perfect-panel/server" type="GoApplicationRunConfiguration" factoryName="Go Application" nameIsGenerated="true">
|
||||
<module name="server" />
|
||||
<working_directory value="$PROJECT_DIR$" />
|
||||
<parameters value="run --config etc/ppanel-dev.yaml" />
|
||||
<kind value="PACKAGE" />
|
||||
<package value="github.com/perfect-panel/server" />
|
||||
<directory value="$PROJECT_DIR$" />
|
||||
<filePath value="$PROJECT_DIR$/ppanel.go" />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
@ -1,112 +0,0 @@
|
||||
## 目标
|
||||
|
||||
* 不使用自动续期订阅;采用“非续期订阅”或“非消耗型”作为内购模式。
|
||||
|
||||
* 仅实现 Go 后端 API;客户端(iOS/StoreKit 2)按说明调用。
|
||||
|
||||
## 产品模型
|
||||
|
||||
* 非续期订阅:固定时长通行证(如 30/90/365 天),产品ID:`com.airport.vpn.pass.30d|90d|365d`。
|
||||
|
||||
* 非消耗型(可选):一次性解锁某附加功能,产品ID:`com.airport.vpn.addon.xyz`。
|
||||
|
||||
* 服务器以 `productId→权益/时长` 进行配置映射。
|
||||
|
||||
## 后端API设计(Go/Gin)
|
||||
|
||||
* 路由注册:`internal/handler/routes.go`
|
||||
|
||||
* `GET /api/iap/apple/products`:返回前端展示的产品清单(含总价/描述/时长映射)
|
||||
|
||||
* `POST /api/iap/apple/transactions/attach`:绑定一次购买到用户账户(需登录)。入参:`signedTransactionJWS`
|
||||
|
||||
* `POST /api/iap/apple/restore`:恢复购买(批量接收 JWS 列表并绑定)
|
||||
|
||||
* `GET /api/iap/apple/status`:返回用户当前权益与到期时间(统一来源聚合)
|
||||
|
||||
* 逻辑目录:`internal/logic/iap/apple/*`
|
||||
|
||||
* `AttachTransactionLogic`:解析 JWS→校验 `bundleId/productId/purchaseDate`→根据 `productId` 映射权益与时长→更新订阅统一表
|
||||
|
||||
* `RestoreLogic`:对所有已购记录执行绑定去重(基于 `original_transaction_id`)
|
||||
|
||||
* `QueryStatusLogic`:聚合各来源订阅,返回有效权益(取最近到期/最高等级)
|
||||
|
||||
* 工具包:`pkg/iap/apple`
|
||||
|
||||
* `ParseTransactionJWS`:解析 JWS,提取 `transactionId/originalTransactionId/productId/purchaseDate/revocationDate`
|
||||
|
||||
* `VerifyBasic`:基础校验(`bundleId`、签名头部与证书链存在性);如客户端已 `transaction.verify()`,可采用“信任+服务器最小校验”的模式快速落地
|
||||
|
||||
* 配置:`doc/config-zh.md`
|
||||
|
||||
* `IAP_PRODUCT_MAP`:`productId → tier/duration`(例如:`30d→+30天`、`addon→解锁功能X`)
|
||||
|
||||
* `APPLE_IAP_BUNDLE_ID`:用于 JWS 内部校验
|
||||
|
||||
## 数据模型
|
||||
|
||||
* 新表:`apple_iap_transactions`
|
||||
|
||||
* `id`、`user_id`、`original_transaction_id`(唯一)、`transaction_id`、`product_id`、`purchase_at`、`revocation_at`、`jws_hash`
|
||||
|
||||
* 统一订阅表增强(现有 `SubscribeModel`)
|
||||
|
||||
* 新增来源:`source=apple_iap`、`external_id=original_transaction_id`、`tier`、`expires_at`
|
||||
|
||||
* 索引:`original_transaction_id` 唯一、`user_id+source`、`expires_at`
|
||||
|
||||
## 与现有系统融合
|
||||
|
||||
* `internal/svc/serviceContext.go`:初始化 IAP 模块与模型
|
||||
|
||||
* `QueryPurchaseOrderLogic/SubscribeModel`:聚合苹果IAP来源;冲突策略:按最高权益与最晚到期。
|
||||
|
||||
* 不产生命令行支付订单,仅记录订阅流水与审计(避免与 Stripe 等混淆)。
|
||||
|
||||
## 安全与合规
|
||||
|
||||
* 仅显示商店在可支付时;价格、描述清晰;使用系统确认表单。
|
||||
|
||||
* 服务器进行最小校验:`bundleId`、`productId`白名单、`purchaseDate`有效性;保存 `jws_hash` 做去重。
|
||||
|
||||
* 退款:在 App 内提供“请求退款”的帮助页并使用系统接口触发;后端无需额外API。
|
||||
|
||||
## 客户端使用说明(StoreKit 2)
|
||||
|
||||
* 产品拉取与展示:
|
||||
|
||||
* 通过已知 `productId` 列表调用 `Product.products(for:)`;展示总价与描述,检查 `canMakePayments`
|
||||
|
||||
* 购买:
|
||||
|
||||
* 调用 `purchase()`,系统确认表单弹出→返回 `Transaction`;执行 `await transaction.verify()`
|
||||
|
||||
* 成功后将 `transaction.signedData` POST 到 `/api/iap/apple/transactions/attach`
|
||||
|
||||
* 恢复:
|
||||
|
||||
* 调用 `Transaction.currentEntitlements`,遍历并验证每条 `Transaction`,将其 `signedData` 批量 POST 到 `/api/iap/apple/restore`
|
||||
|
||||
* 状态显示:
|
||||
|
||||
* 访问 `GET /api/iap/apple/status` 获取到期时间与权益用于 UI 展示
|
||||
|
||||
* 退款入口:
|
||||
|
||||
* 在购买帮助页直接使用 `beginRefundRequest(for:in:)`;文案简洁,按钮直达
|
||||
|
||||
## 测试与验收
|
||||
|
||||
* 单元测试:JWS 解析、`productId→权益/时长` 映射、去重策略。
|
||||
|
||||
* 集成测试:绑定/恢复接口鉴权与幂等、统一订阅查询结果。
|
||||
|
||||
* 沙盒:使用 iOS 沙盒购买与恢复;记录审计与日志。
|
||||
|
||||
## 里程碑
|
||||
|
||||
1. 基础能力:`products/status` 与 `transactions/attach` 落地
|
||||
2. 恢复与融合:`restore` + 统一订阅聚合
|
||||
3. 上线前验证:沙盒测试与文案、监控
|
||||
|
||||
@ -1,44 +0,0 @@
|
||||
# 用户管理系统优化方案 (最终确认版)
|
||||
|
||||
根据您的要求,我们将重点实现 `last_login_time` 字段的存储与返回,以及在列表接口中聚合会员套餐信息。
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 1. 数据库变更
|
||||
- **文件**: `initialize/migrate/database/02121_add_user_last_login_time.up.sql`
|
||||
- **内容**:
|
||||
```sql
|
||||
ALTER TABLE user ADD COLUMN last_login_time DATETIME DEFAULT NULL COMMENT 'Last Login Time';
|
||||
```
|
||||
- **说明**: 相比查询日志表,直接在用户表增加字段能极大提高列表页查询性能。
|
||||
|
||||
### 2. API 定义更新
|
||||
- **文件**: `apis/types.api`
|
||||
- **内容**: 修改 `User` 结构体,增加以下返回字段:
|
||||
- `last_login_time` (int64): 最后活跃时间戳。
|
||||
- `member_status` (string): 会员状态(显示当前生效的订阅套餐名称,无订阅显示空或特定标识)。
|
||||
|
||||
### 3. 后端模型与逻辑更新
|
||||
#### 3.1 User 模型更新
|
||||
- **文件**: `internal/model/user/user.go`
|
||||
- **内容**: `User` 结构体增加 `LastLoginTime *time.Time` 字段。
|
||||
|
||||
#### 3.2 登录逻辑更新 (记录活跃时间)
|
||||
- **文件**: `internal/logic/auth/userLoginLogic.go` (及其他登录逻辑如 `emailLoginLogic.go`)
|
||||
- **内容**: 在登录成功后,异步或同步更新当前用户的 `last_login_time`。
|
||||
|
||||
#### 3.3 用户列表逻辑更新 (数据聚合)
|
||||
- **文件**: `internal/logic/admin/user/getUserListLogic.go`
|
||||
- **内容**:
|
||||
1. **获取用户列表**: 包含新增的 `LastLoginTime` 数据。
|
||||
2. **批量查询订阅**: 根据当前页的用户 ID 列表,批量查询其**活跃订阅** (Active Subscription)。
|
||||
3. **数据组装**:
|
||||
- 将 `LastLoginTime` 转换为时间戳返回。
|
||||
- 将订阅的 `Name` (套餐名) 赋值给 `member_status`。
|
||||
|
||||
### 4. 文档更新
|
||||
- **文件**: `doc/说明文档.md`
|
||||
- **内容**: 更新进度记录,标记完成“最后活跃”与“会员状态”字段开发。
|
||||
|
||||
## 验证与交付
|
||||
- 提供 `curl` 验证命令,确认 `/v1/admin/user/list` 接口返回的 JSON 中包含 `last_login_time` 和 `member_status`。
|
||||
@ -1,67 +0,0 @@
|
||||
## 修复目标
|
||||
- 解决首次设备登录时在 `internal/logic/auth/deviceLoginLogic.go:99` 对 `deviceInfo` 赋值导致的空指针崩溃,确保接口稳定返回。
|
||||
|
||||
## 根因定位
|
||||
- 设备不存在分支仅创建用户与设备记录,但未为局部变量 `deviceInfo` 赋值;随后在 `internal/logic/auth/deviceLoginLogic.go:99-100` 使用 `deviceInfo` 导致 `nil` 解引用。
|
||||
- 参考位置:
|
||||
- 赋值处:`internal/logic/auth/deviceLoginLogic.go:99-101`
|
||||
- 设备存在分支赋值:`internal/logic/auth/deviceLoginLogic.go:88-95`
|
||||
- 设备不存在分支未赋值:`internal/logic/auth/deviceLoginLogic.go:74-79`
|
||||
- `UpdateDevice` 需要有效设备 `Id`:`internal/model/user/device.go:58-69`
|
||||
|
||||
## 修改方案
|
||||
1. 在“设备不存在”分支注册完成后,立即通过标识重新查询设备,赋值给 `deviceInfo`:
|
||||
- 在 `internal/logic/auth/deviceLoginLogic.go` 的 `if errors.Is(err, gorm.ErrRecordNotFound)` 分支中,`userInfo, err = l.registerUserAndDevice(req)` 之后追加:
|
||||
- `deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)`
|
||||
- 如果查询失败则返回数据库查询错误(与现有风格一致)。
|
||||
2. 在更新设备 UA 前增加空指针保护,并不再忽略更新错误:
|
||||
- 将 `internal/logic/auth/deviceLoginLogic.go:99-101` 改为:
|
||||
- 检查 `deviceInfo != nil`
|
||||
- `deviceInfo.UserAgent = req.UserAgent`
|
||||
- `if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {` 记录错误并返回包装后的错误 `xerr.DatabaseUpdateError`。
|
||||
3. 可选优化(减少二次查询):
|
||||
- 将 `registerUserAndDevice(req)` 的返回值改为 `(*user.User, *user.Device, error)`,在注册时直接返回新建设备对象;调用点随之调整。若选择此方案,仍需在更新前做空指针保护。
|
||||
|
||||
## 代码示例(方案1,最小改动)
|
||||
```go
|
||||
// internal/logic/auth/deviceLoginLogic.go
|
||||
// 设备不存在分支注册后追加一次设备查询
|
||||
userInfo, err = l.registerUserAndDevice(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)
|
||||
if err != nil {
|
||||
l.Errorw("query device after register failed",
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device after register failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 更新 UA,不忽略更新错误
|
||||
if deviceInfo != nil {
|
||||
deviceInfo.UserAgent = req.UserAgent
|
||||
if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {
|
||||
l.Errorw("update device failed",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err.Error())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试用例与验证
|
||||
- 用例1:首次设备标识登录(设备不存在)应成功返回 Token,日志包含注册与登录记录,无 500。
|
||||
- 用例2:已存在设备标识登录(设备存在)应正常更新 UA 并返回 Token。
|
||||
- 用例3:模拟数据库异常时应返回一致的业务错误码,不产生 `panic`。
|
||||
|
||||
## 风险与回滚
|
||||
- 改动限定在登录逻辑,属最小范围;若出现异常,回滚为当前版本即可。
|
||||
- 不改变数据结构与外部接口行为,兼容现有客户端。
|
||||
|
||||
## 后续优化(可选)
|
||||
- 统一 `UpdateDevice` 错误处理路径,避免 `_ = ...` 静默失败。
|
||||
- 为“首次设备登录”场景补充集成测试,保证不再回归。
|
||||
@ -1,65 +0,0 @@
|
||||
## 目标
|
||||
- 不使用自动续期订阅;采用“非续期订阅”或“非消耗型”作为内购模式。
|
||||
- 仅实现 Go 后端 API;客户端(iOS/StoreKit 2)按说明调用。
|
||||
|
||||
## 产品模型
|
||||
- 非续期订阅:固定时长通行证(如 30/90/365 天),产品ID:`com.airport.vpn.pass.30d|90d|365d`。
|
||||
- 非消耗型(可选):一次性解锁某附加功能,产品ID:`com.airport.vpn.addon.xyz`。
|
||||
- 服务器以 `productId→权益/时长` 进行配置映射。
|
||||
|
||||
## 后端API设计(Go/Gin)
|
||||
- 路由注册:`internal/handler/routes.go`
|
||||
- `GET /api/iap/apple/products`:返回前端展示的产品清单(含总价/描述/时长映射)
|
||||
- `POST /api/iap/apple/transactions/attach`:绑定一次购买到用户账户(需登录)。入参:`signedTransactionJWS`
|
||||
- `POST /api/iap/apple/restore`:恢复购买(批量接收 JWS 列表并绑定)
|
||||
- `GET /api/iap/apple/status`:返回用户当前权益与到期时间(统一来源聚合)
|
||||
- 逻辑目录:`internal/logic/iap/apple/*`
|
||||
- `AttachTransactionLogic`:解析 JWS→校验 `bundleId/productId/purchaseDate`→根据 `productId` 映射权益与时长→更新订阅统一表
|
||||
- `RestoreLogic`:对所有已购记录执行绑定去重(基于 `original_transaction_id`)
|
||||
- `QueryStatusLogic`:聚合各来源订阅,返回有效权益(取最近到期/最高等级)
|
||||
- 工具包:`pkg/iap/apple`
|
||||
- `ParseTransactionJWS`:解析 JWS,提取 `transactionId/originalTransactionId/productId/purchaseDate/revocationDate`
|
||||
- `VerifyBasic`:基础校验(`bundleId`、签名头部与证书链存在性);如客户端已 `transaction.verify()`,可采用“信任+服务器最小校验”的模式快速落地
|
||||
- 配置:`doc/config-zh.md`
|
||||
- `IAP_PRODUCT_MAP`:`productId → tier/duration`(例如:`30d→+30天`、`addon→解锁功能X`)
|
||||
- `APPLE_IAP_BUNDLE_ID`:用于 JWS 内部校验
|
||||
|
||||
## 数据模型
|
||||
- 新表:`apple_iap_transactions`
|
||||
- `id`、`user_id`、`original_transaction_id`(唯一)、`transaction_id`、`product_id`、`purchase_at`、`revocation_at`、`jws_hash`
|
||||
- 统一订阅表增强(现有 `SubscribeModel`)
|
||||
- 新增来源:`source=apple_iap`、`external_id=original_transaction_id`、`tier`、`expires_at`
|
||||
- 索引:`original_transaction_id` 唯一、`user_id+source`、`expires_at`
|
||||
|
||||
## 与现有系统融合
|
||||
- `internal/svc/serviceContext.go`:初始化 IAP 模块与模型
|
||||
- `QueryPurchaseOrderLogic/SubscribeModel`:聚合苹果IAP来源;冲突策略:按最高权益与最晚到期。
|
||||
- 不产生命令行支付订单,仅记录订阅流水与审计(避免与 Stripe 等混淆)。
|
||||
|
||||
## 安全与合规
|
||||
- 仅显示商店在可支付时;价格、描述清晰;使用系统确认表单。
|
||||
- 服务器进行最小校验:`bundleId`、`productId`白名单、`purchaseDate`有效性;保存 `jws_hash` 做去重。
|
||||
- 退款:在 App 内提供“请求退款”的帮助页并使用系统接口触发;后端无需额外API。
|
||||
|
||||
## 客户端使用说明(StoreKit 2)
|
||||
- 产品拉取与展示:
|
||||
- 通过已知 `productId` 列表调用 `Product.products(for:)`;展示总价与描述,检查 `canMakePayments`
|
||||
- 购买:
|
||||
- 调用 `purchase()`,系统确认表单弹出→返回 `Transaction`;执行 `await transaction.verify()`
|
||||
- 成功后将 `transaction.signedData` POST 到 `/api/iap/apple/transactions/attach`
|
||||
- 恢复:
|
||||
- 调用 `Transaction.currentEntitlements`,遍历并验证每条 `Transaction`,将其 `signedData` 批量 POST 到 `/api/iap/apple/restore`
|
||||
- 状态显示:
|
||||
- 访问 `GET /api/iap/apple/status` 获取到期时间与权益用于 UI 展示
|
||||
- 退款入口:
|
||||
- 在购买帮助页直接使用 `beginRefundRequest(for:in:)`;文案简洁,按钮直达
|
||||
|
||||
## 测试与验收
|
||||
- 单元测试:JWS 解析、`productId→权益/时长` 映射、去重策略。
|
||||
- 集成测试:绑定/恢复接口鉴权与幂等、统一订阅查询结果。
|
||||
- 沙盒:使用 iOS 沙盒购买与恢复;记录审计与日志。
|
||||
|
||||
## 里程碑
|
||||
1) 基础能力:`products/status` 与 `transactions/attach` 落地
|
||||
2) 恢复与融合:`restore` + 统一订阅聚合
|
||||
3) 上线前验证:沙盒测试与文案、监控
|
||||
@ -1,92 +0,0 @@
|
||||
## 修复目标
|
||||
|
||||
* 解决首次设备登录时在 `internal/logic/auth/deviceLoginLogic.go:99` 对 `deviceInfo` 赋值导致的空指针崩溃,确保接口稳定返回。
|
||||
|
||||
## 根因定位
|
||||
|
||||
* 设备不存在分支仅创建用户与设备记录,但未为局部变量 `deviceInfo` 赋值;随后在 `internal/logic/auth/deviceLoginLogic.go:99-100` 使用 `deviceInfo` 导致 `nil` 解引用。
|
||||
|
||||
* 参考位置:
|
||||
|
||||
* 赋值处:`internal/logic/auth/deviceLoginLogic.go:99-101`
|
||||
|
||||
* 设备存在分支赋值:`internal/logic/auth/deviceLoginLogic.go:88-95`
|
||||
|
||||
* 设备不存在分支未赋值:`internal/logic/auth/deviceLoginLogic.go:74-79`
|
||||
|
||||
* `UpdateDevice` 需要有效设备 `Id`:`internal/model/user/device.go:58-69`
|
||||
|
||||
## 修改方案
|
||||
|
||||
1. 在“设备不存在”分支注册完成后,立即通过标识重新查询设备,赋值给 `deviceInfo`:
|
||||
|
||||
* 在 `internal/logic/auth/deviceLoginLogic.go` 的 `if errors.Is(err, gorm.ErrRecordNotFound)` 分支中,`userInfo, err = l.registerUserAndDevice(req)` 之后追加:
|
||||
|
||||
* `deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)`
|
||||
|
||||
* 如果查询失败则返回数据库查询错误(与现有风格一致)。
|
||||
2. 在更新设备 UA 前增加空指针保护,并不再忽略更新错误:
|
||||
|
||||
* 将 `internal/logic/auth/deviceLoginLogic.go:99-101` 改为:
|
||||
|
||||
* 检查 `deviceInfo != nil`
|
||||
|
||||
* `deviceInfo.UserAgent = req.UserAgent`
|
||||
|
||||
* `if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {` 记录错误并返回包装后的错误 `xerr.DatabaseUpdateError`。
|
||||
3. 可选优化(减少二次查询):
|
||||
|
||||
* 将 `registerUserAndDevice(req)` 的返回值改为 `(*user.User, *user.Device, error)`,在注册时直接返回新建设备对象;调用点随之调整。若选择此方案,仍需在更新前做空指针保护。
|
||||
|
||||
## 代码示例(方案1,最小改动)
|
||||
|
||||
```go
|
||||
// internal/logic/auth/deviceLoginLogic.go
|
||||
// 设备不存在分支注册后追加一次设备查询
|
||||
userInfo, err = l.registerUserAndDevice(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)
|
||||
if err != nil {
|
||||
l.Errorw("query device after register failed",
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device after register failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 更新 UA,不忽略更新错误
|
||||
if deviceInfo != nil {
|
||||
deviceInfo.UserAgent = req.UserAgent
|
||||
if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {
|
||||
l.Errorw("update device failed",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err.Error())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试用例与验证
|
||||
|
||||
* 用例1:首次设备标识登录(设备不存在)应成功返回 Token,日志包含注册与登录记录,无 500。
|
||||
|
||||
* 用例2:已存在设备标识登录(设备存在)应正常更新 UA 并返回 Token。
|
||||
|
||||
* 用例3:模拟数据库异常时应返回一致的业务错误码,不产生 `panic`。
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
* 改动限定在登录逻辑,属最小范围;若出现异常,回滚为当前版本即可。
|
||||
|
||||
* 不改变数据结构与外部接口行为,兼容现有客户端。
|
||||
|
||||
## 后续优化(可选)
|
||||
|
||||
* 统一 `UpdateDevice` 错误处理路径,避免 `_ = ...` 静默失败。
|
||||
|
||||
* 为“首次设备登录”场景补充集成测试,保证不再回归。
|
||||
|
||||
@ -1,33 +0,0 @@
|
||||
## 结论与定位
|
||||
- 解绑接口路径:`/v1/public/user/unbind_device`,路由注册于 `internal/handler/routes.go:836-838`,处理器在 `internal/handler/public/user/unbindDeviceHandler.go:11-25`。
|
||||
- 业务逻辑:`internal/logic/public/user/unbindDeviceLogic.go:36-141`,进行设备所属用户迁移、认证记录更新、可能删除旧用户以及 Redis 缓存清理。
|
||||
- 返回封装:所有接口错误均走 JSON 200 包装,见 `pkg/result/httpResult.go:12-33`,因此应用层不会主动返回 502。
|
||||
- Nginx 反向代理:API 代理到 `127.0.0.1:8080`,见 `etc/nginx.conf:233-260`;未设置显式的 `proxy_read_timeout` 等超时参数,502 多发生在上游超时或连接被复位。
|
||||
|
||||
## 高概率问题点
|
||||
- 非安全类型断言导致潜在 panic:`internal/logic/public/user/unbindDeviceLogic.go:38` 直接 `.(*user.User)` 断言,若上下文未注入用户(Token 缺失/失效、链路异常),将发生 panic。其他多数逻辑使用安全断言并兜底(如 `internal/logic/public/user/unbindOAuthLogic.go:31-36`)。
|
||||
- 同类不安全用法还出现在 `internal/logic/public/user/getDeviceListLogic.go:31-33`(你反馈其他接口正常,但它也可能受影响,建议同修)。
|
||||
- 事务中混入外部 IO:在 DB 事务闭包内进行 Redis 删除(`internal/logic/public/user/unbindDeviceLogic.go:125-131`),若外部 IO 波动叠加数据库锁等待,整体耗时可能逼近或超过代理默认超时,引发 502。
|
||||
|
||||
## 修复方案
|
||||
- 将所有 `CtxKeyUser` 获取统一改为安全断言:
|
||||
- 失败时返回 `InvalidAccess` 业务错误而非 panic,参考 `unbindOAuth` 的处理方式;修改位置:`internal/logic/public/user/unbindDeviceLogic.go:36-43`、`internal/logic/public/user/getDeviceListLogic.go:31-33`。
|
||||
- 将 Redis 缓存清理移出事务闭包,并添加超时控制:
|
||||
- 事务仅做数据库一致性操作;在事务成功后再进行缓存删除,失败则不删;为 Redis 操作设置短超时,避免阻塞主流程。
|
||||
- 增强可观测性:
|
||||
- 在解绑入口和事务前后增加结构化日志,包含 `device_id`、`user_id`、事务耗时、Redis 耗时、错误栈;方便精确定位是否存在偶发长耗时。
|
||||
- 代理层稳健性:
|
||||
- 在 Nginx 针对 API 站点增加 `proxy_connect_timeout 10s; proxy_send_timeout 60s; proxy_read_timeout 60s;`,并开启 `proxy_next_upstream timeout`;避免上游短抖动即直接 502。
|
||||
|
||||
## 验证计划
|
||||
- 单元测试:构造上下文缺失用户的请求,确认不再 panic,返回业务错误码而非 502。
|
||||
- 事务耗时压测:模拟高并发解绑,观察事务与 Redis 操作耗时分布,确认在代理超时阈值内。
|
||||
- 端到端验证:
|
||||
- 使用有效与无效 Token、不同设备 ID 连续调用解绑,确认响应始终为 200 JSON 包装(或业务错误),不出现 502。
|
||||
- 对比 Nginx 错误日志,验证 502 消失。
|
||||
|
||||
## 变更范围
|
||||
- 代码改动:`internal/logic/public/user/unbindDeviceLogic.go`、`internal/logic/public/user/getDeviceListLogic.go` 增加安全断言与错误返回;调整 Redis 清理至事务外并加超时;补充必要日志。
|
||||
- 配置更新:`etc/nginx.conf` 的 API server 段增加代理超时与上游重试策略。
|
||||
|
||||
请确认以上方案,我将按该方案实施、编写必要函数注释并补充测试,完成后提供验证结果与影响评估。
|
||||
@ -1,31 +0,0 @@
|
||||
## 问题确认
|
||||
- 前端以“百分比”形式配置折扣(如 95、95.19),当前后端 `getDiscount` 函数期望“系数(0–1)”,导致折扣未生效。
|
||||
- 受影响位置:
|
||||
- 登录下单折扣:`internal/logic/public/order/getDiscount.go`
|
||||
- 门户预下单/下单折扣:`internal/logic/public/portal/tool.go:getDiscount`
|
||||
|
||||
## 改造目标
|
||||
- 后端折扣计算统一兼容两种输入:
|
||||
- 系数(0–1):直接使用
|
||||
- 百分比(>1 且 <=100):自动转换为 `值/100` 再使用
|
||||
- 对非法值进行边界保护(<0 → 0;>100 → 忽略或按 1 处理),避免异常。
|
||||
|
||||
## 实施步骤
|
||||
1. 修改折扣计算函数:
|
||||
- `internal/logic/public/order/getDiscount.go`
|
||||
- 若 `discount.Discount > 1 && discount.Discount <= 100`,转换为 `discount.Discount/100`。
|
||||
- 保持“取满足阈值的最小折扣”策略,默认 `finalDiscount=1.0`。
|
||||
- `internal/logic/public/portal/tool.go:getDiscount`
|
||||
- 同上逻辑,移除对 `*100` 的中间整数化处理,统一用浮点小数系数比较。
|
||||
2. 单元测试补充:
|
||||
- 既测系数(如 0.95),也测百分比输入(如 95、95.19),以及边界(0、100、>100)。
|
||||
3. 验证流程:
|
||||
- 用你当前 7 天的配置(前端百分比)进行“预下单→下单→订单查询”,确认 `Discount` 与 `Amount` 按预期生效。
|
||||
4. 文档与界面提示:
|
||||
- 在后台/前端表单处增加说明:支持百分比与系数;百分比将自动转换;推荐使用百分比,避免歧义。
|
||||
|
||||
## 交付与保障
|
||||
- 代码改动仅限折扣计算函数与测试,风险低;保留原有行为的向后兼容。
|
||||
- 提供测试报告与一次联调记录(数据截图:价格、折扣、总计)。
|
||||
|
||||
请确认是否按该兼容方案执行,我将据此修改并验证。
|
||||
@ -1,47 +0,0 @@
|
||||
## 实施目标
|
||||
- 复用现有订单与队列赋权,接入 Apple 自动续期(IAP),保持报表/审计/通知一致。
|
||||
|
||||
## 方案选择
|
||||
- 采用“平台化复用 + 合成订单”的方式:Apple 由客户端结算 + 服务器通知驱动,服务端生成“已支付订单”进入现有赋权与续费流程。
|
||||
|
||||
## 具体改动(按文件)
|
||||
1) 平台标识
|
||||
- 更新 `pkg/payment/platform.go`:新增 `AppleIAP` 枚举与名称(仅标识,不参与 `PurchaseCheckout`)。
|
||||
|
||||
2) 路由与 Handler
|
||||
- 新增公共接口:`POST /v1/public/iap/verify`
|
||||
- 位置:`internal/handler/public/iap/verifyHandler.go`
|
||||
- 逻辑:调用 `internal/logic/public/iap/verifyLogic.go`,以 `originalTransactionId` 验证 Apple 购买,生成“已支付订阅订单”,入队激活。
|
||||
- 新增通知接口:`POST /v1/iap/notifications`
|
||||
- 位置:`internal/handler/notify/appleIAPNotifyHandler.go`
|
||||
- 逻辑:调用 `internal/logic/notify/appleIAPNotifyLogic.go`,JWS 验签后按事件(初购/续期/退款)生成或更新订单,触发续费或撤销权益。
|
||||
- 路由注册:
|
||||
- `internal/handler/routes.go` 增加 `/v1/public/iap/verify` 路由。
|
||||
- `internal/handler/notify.go` 增加独立 `/v1/iap/notifications` 路由(Apple 不带 `:token`)。
|
||||
|
||||
3) 数据与模型
|
||||
- 在用户订阅(或新建 `iap_binding` 表)绑定:`originalTransactionId`、`environment`、`latestExpiresDate`。
|
||||
- 订单字段复用:`Method=AppleIAP`、`TradeNo=originalTransactionId`、`Type=1/2`(订阅/续费),`Status=2`(已支付),金额可取通知中的价格;取不到则置 `Amount=0` 保证流程。
|
||||
|
||||
4) 逻辑复用与改造点
|
||||
- 赋权:复用 `queue/logic/order/activateOrderLogic.go:165 NewPurchase`。
|
||||
- 续费:复用 `queue/logic/order/activateOrderLogic.go:529 updateSubscriptionForRenewal`。
|
||||
- 不改动 `internal/logic/public/portal/purchaseCheckoutLogic.go` 的渠道路由(Apple 不走此流程)。
|
||||
|
||||
5) 安全与幂等
|
||||
- Apple JWS 验签:拉取并缓存 JWKS 公钥,校验通知;拒绝无效签名。
|
||||
- 幂等:以 `notificationId`/`transactionId` 与 `originalTransactionId` 去重处理。
|
||||
|
||||
6) 客户端协作
|
||||
- iOS:完成 StoreKit 购买后携带 `originalTransactionId` 调用 `/v1/public/iap/verify`。
|
||||
- 续费:依赖 Server Notifications v2 自动驱动,无需客户端调用。
|
||||
|
||||
7) 测试与监控
|
||||
- 沙盒验证初购、续期、重试与宽限期、退款撤销;注意元数据延迟(~1小时)。
|
||||
- 指标:通知验签失败、API 调用失败、幂等冲突、状态不一致报警。
|
||||
|
||||
## 交付节奏
|
||||
- 第一步:平台枚举与路由骨架;
|
||||
- 第二步:`verify` 验证与“合成订单”生成;
|
||||
- 第三步:通知验签与事件映射;
|
||||
- 第四步:沙盒联调,确认队列赋权与续费延长。
|
||||
@ -1,92 +0,0 @@
|
||||
## 结论
|
||||
|
||||
* 可以复用你现有的“订单→支付成功→订单激活(赋权)→通知/返佣”的主干流程,但“支付环节”不能复用第三方网关逻辑,必须改为 Apple IAP 的校验与事件驱动。
|
||||
|
||||
* 复用范围:订单模型、续费与赋权队列、优惠/手续费计算、通知与返佣;差异点:支付下单与回调换成“StoreKit 客户端购买 + 服务端向 Apple 校验 + Apple Server Notifications v2”。
|
||||
|
||||
## 可复用的部分
|
||||
|
||||
1. 订单激活与赋权
|
||||
|
||||
* 新购赋权:`queue/logic/order/activateOrderLogic.go:164-193` 的 `NewPurchase`
|
||||
|
||||
* 续费赋权:`queue/logic/order/activateOrderLogic.go:473-515` 的 `Renewal`
|
||||
|
||||
* 流量重置与充值:`queue/logic/order/activateOrderLogic.go:564-626`, `630-675`
|
||||
|
||||
1. 订单与费用模型
|
||||
|
||||
* 订单结构:`internal/model/order/order.go:5-29` 可继续承载 IAP 订单(新增字段映射 Apple 交易)
|
||||
|
||||
* 费用/折扣/礼金计算逻辑保持不变
|
||||
|
||||
1. 队列驱动
|
||||
|
||||
* 仍使用“支付成功→入队→处理”的模式:`queue/logic/order/activateOrderLogic.go:65-86`
|
||||
|
||||
## 必须独立实现的部分
|
||||
|
||||
1. Apple IAP 支付与校验
|
||||
|
||||
* 客户端使用 StoreKit 购买,拿到 `originalTransactionId`
|
||||
|
||||
* 服务端调用 App Store Server API,基于 `originalTransactionId` 校验订阅有效性并取交易历史
|
||||
|
||||
1. Apple Server Notifications v2
|
||||
|
||||
* 在 App Store Connect 配置通知 URL
|
||||
|
||||
* 服务端实现 JWS 验签,解析事件并落库:续期、失败、宽限期、退款、撤销等
|
||||
|
||||
## 整合方式(复用策略)
|
||||
|
||||
1. 引入平台枚举“AppleIAP”
|
||||
|
||||
* 在 `pkg/payment/platform.go` 增加 `AppleIAP`,用于平台标识与管理端展示
|
||||
|
||||
1. 订单创建策略(两种)
|
||||
|
||||
* 方案 A(推荐):用户在 iOS 内购完成后由客户端上报 `originalTransactionId`,服务端校验通过后“合成一个已支付订单”(`status=2`),触发既有赋权队列
|
||||
|
||||
* 方案 B:也可预建“待支付订单”,但 `PurchaseCheckout` 不走网关,只返回“client\_iap”类型,提示客户端用 StoreKit;支付完成后再校验并更新为 `Paid` 入队
|
||||
|
||||
1. 状态与权益判定
|
||||
|
||||
* 服务端统一以 Apple 校验与通知为准,抽象为 `active/in_grace_period/in_billing_retry/expired/revoked` 并映射到你的订阅与订单状态
|
||||
|
||||
## 服务端接口与流程
|
||||
|
||||
* `POST /apple/iap/verify`:入参 `originalTransactionId`,校验并创建/更新订单与用户订阅,返回当前权益
|
||||
|
||||
* `POST /apple/iap/notifications`:接收 Apple JWS 通知,验签后更新订阅与订单状态(幂等)
|
||||
|
||||
* `GET /subscriptions/me`:面向客户端查询当前订阅与权益(聚合 Apple 校验结果)
|
||||
|
||||
## 数据模型映射
|
||||
|
||||
* 在订单/订阅表补充字段(建议):`Provider=apple_iap`、`OriginalTransactionId`、`Environment`、`ExpiresDate`、`AutoRenewStatus`、`InGracePeriod`、`LastEventType`
|
||||
|
||||
* 产品映射:`productId(App Store) ↔ internal subscribeId`,保证同一权益统一计费
|
||||
|
||||
## 回调与安全
|
||||
|
||||
* JWS 验签:缓存 Apple JWKS 公钥、短生命周期缓存
|
||||
|
||||
* App Store Server API:使用 App Store Connect API Key(ES256)发起请求,区分生产/沙盒
|
||||
|
||||
* 幂等:按 `notificationId/transactionId` 去重
|
||||
|
||||
## 测试与上线
|
||||
|
||||
* 沙盒测试:购买、续期、失败、宽限期、退款全链路;注意沙盒元数据生效可能需 \~1 小时
|
||||
|
||||
* 监控:通知处理失败、验签失败、API 调用异常报警
|
||||
|
||||
## 交付物(最小实现)
|
||||
|
||||
1. 平台枚举新增 `AppleIAP`
|
||||
2. `POST /apple/iap/verify` 与 `POST /apple/iap/notifications` 路由与逻辑骨架
|
||||
3. App Store Server API 客户端封装(校验、交易历史、订阅状态)
|
||||
4. 订单合成与入队赋权打通(复用 `OrderStatusPaid` → `ProcessTask`)
|
||||
5. 数据表字段扩展与迁移脚本
|
||||
|
||||
@ -1,29 +0,0 @@
|
||||
# 最后上线时间字段显示修复计划
|
||||
|
||||
## 问题分析
|
||||
用户反馈调用 `curl` 接口后,返回的 JSON 中没有看到“最后上线时间” (`last_login_time`) 字段。
|
||||
|
||||
**原因可能如下:**
|
||||
1. **字段被 `omitempty` 隐藏**: 在 `internal/types/types.go` 中,`LastLoginTime` 字段定义为 `json:"last_login_time,omitempty"`。这意味着如果值为 `0`,该字段在 JSON 序列化时会被忽略,不返回给前端。
|
||||
2. **数据确实为空**: 用户从未登录过,且没有活跃订阅产生的流量记录,导致计算出的 `LastLoginTime` 为 `0`。
|
||||
3. **数据库迁移未生效**: 虽然我们之前修复了迁移文件冲突,但如果数据库中旧的迁移记录未清理或新字段 `last_login_time` 未真正添加成功,会导致数据读取失败(但此时通常会报错,而非字段缺失)。
|
||||
|
||||
## 解决方案
|
||||
为了确保接口始终返回该字段(即使是 0),我们需要移除 `omitempty` 标签,或者确认前端能处理缺失该字段的情况。考虑到用户明确要求“没看到”,建议移除 `omitempty`,让其显式返回 `0` 或时间戳。
|
||||
|
||||
同时,我们通过 SQL 检查数据库结构,确保字段已存在。
|
||||
|
||||
## 实施步骤
|
||||
1. **修改 API 定义**:
|
||||
- 文件: `internal/types/types.go` (及 `apis/types.api` 如果需要重新生成代码,但直接改 go 文件更快捷验证)
|
||||
- 操作: 将 `LastLoginTime int64 json:"last_login_time,omitempty"` 修改为 `json:"last_login_time"` (移除 `omitempty`)。
|
||||
- 同理处理 `MemberStatus` 字段。
|
||||
|
||||
2. **验证数据库字段**:
|
||||
- 使用 SQL 工具或日志确认 `user` 表中是否存在 `last_login_time` 列。
|
||||
|
||||
3. **验证接口**:
|
||||
- 再次调用 `curl`,确认即使值为 0 也会返回字段。
|
||||
|
||||
## 补充
|
||||
如果用户是指“有数据但没显示”,那可能是登录逻辑或流量更新逻辑未触发。但首要步骤是让字段显式返回,以便排查是“无数据”还是“字段被隐藏”。
|
||||
@ -1,92 +0,0 @@
|
||||
## 修复目标
|
||||
|
||||
* 解决首次设备登录时在 `internal/logic/auth/deviceLoginLogic.go:99` 对 `deviceInfo` 赋值导致的空指针崩溃,确保接口稳定返回。
|
||||
|
||||
## 根因定位
|
||||
|
||||
* 设备不存在分支仅创建用户与设备记录,但未为局部变量 `deviceInfo` 赋值;随后在 `internal/logic/auth/deviceLoginLogic.go:99-100` 使用 `deviceInfo` 导致 `nil` 解引用。
|
||||
|
||||
* 参考位置:
|
||||
|
||||
* 赋值处:`internal/logic/auth/deviceLoginLogic.go:99-101`
|
||||
|
||||
* 设备存在分支赋值:`internal/logic/auth/deviceLoginLogic.go:88-95`
|
||||
|
||||
* 设备不存在分支未赋值:`internal/logic/auth/deviceLoginLogic.go:74-79`
|
||||
|
||||
* `UpdateDevice` 需要有效设备 `Id`:`internal/model/user/device.go:58-69`
|
||||
|
||||
## 修改方案
|
||||
|
||||
1. 在“设备不存在”分支注册完成后,立即通过标识重新查询设备,赋值给 `deviceInfo`:
|
||||
|
||||
* 在 `internal/logic/auth/deviceLoginLogic.go` 的 `if errors.Is(err, gorm.ErrRecordNotFound)` 分支中,`userInfo, err = l.registerUserAndDevice(req)` 之后追加:
|
||||
|
||||
* `deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)`
|
||||
|
||||
* 如果查询失败则返回数据库查询错误(与现有风格一致)。
|
||||
2. 在更新设备 UA 前增加空指针保护,并不再忽略更新错误:
|
||||
|
||||
* 将 `internal/logic/auth/deviceLoginLogic.go:99-101` 改为:
|
||||
|
||||
* 检查 `deviceInfo != nil`
|
||||
|
||||
* `deviceInfo.UserAgent = req.UserAgent`
|
||||
|
||||
* `if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {` 记录错误并返回包装后的错误 `xerr.DatabaseUpdateError`。
|
||||
3. 可选优化(减少二次查询):
|
||||
|
||||
* 将 `registerUserAndDevice(req)` 的返回值改为 `(*user.User, *user.Device, error)`,在注册时直接返回新建设备对象;调用点随之调整。若选择此方案,仍需在更新前做空指针保护。
|
||||
|
||||
## 代码示例(方案1,最小改动)
|
||||
|
||||
```go
|
||||
// internal/logic/auth/deviceLoginLogic.go
|
||||
// 设备不存在分支注册后追加一次设备查询
|
||||
userInfo, err = l.registerUserAndDevice(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
deviceInfo, err = l.svcCtx.UserModel.FindOneDeviceByIdentifier(l.ctx, req.Identifier)
|
||||
if err != nil {
|
||||
l.Errorw("query device after register failed",
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query device after register failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// 更新 UA,不忽略更新错误
|
||||
if deviceInfo != nil {
|
||||
deviceInfo.UserAgent = req.UserAgent
|
||||
if err := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); err != nil {
|
||||
l.Errorw("update device failed",
|
||||
logger.Field("user_id", userInfo.Id),
|
||||
logger.Field("identifier", req.Identifier),
|
||||
logger.Field("error", err.Error()),
|
||||
)
|
||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "update device failed: %v", err.Error())
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 测试用例与验证
|
||||
|
||||
* 用例1:首次设备标识登录(设备不存在)应成功返回 Token,日志包含注册与登录记录,无 500。
|
||||
|
||||
* 用例2:已存在设备标识登录(设备存在)应正常更新 UA 并返回 Token。
|
||||
|
||||
* 用例3:模拟数据库异常时应返回一致的业务错误码,不产生 `panic`。
|
||||
|
||||
## 风险与回滚
|
||||
|
||||
* 改动限定在登录逻辑,属最小范围;若出现异常,回滚为当前版本即可。
|
||||
|
||||
* 不改变数据结构与外部接口行为,兼容现有客户端。
|
||||
|
||||
## 后续优化(可选)
|
||||
|
||||
* 统一 `UpdateDevice` 错误处理路径,避免 `_ = ...` 静默失败。
|
||||
|
||||
* 为“首次设备登录”场景补充集成测试,保证不再回归。
|
||||
|
||||
@ -1,25 +0,0 @@
|
||||
# 迁移文件重复问题修复计划
|
||||
|
||||
## 问题分析
|
||||
根据终端日志报错 `panic: failed to init driver with path database: duplicate migration file: 02121_apple_iap_transactions.down.sql`,系统启动失败的原因是存在**重复的迁移版本号**。
|
||||
|
||||
在 `initialize/migrate/database/` 目录下,存在两个版本号相同的迁移文件:
|
||||
1. `02121_add_user_last_login_time.up.sql` (我们刚刚创建的)
|
||||
2. `02121_apple_iap_transactions.up.sql` (已存在的)
|
||||
|
||||
由于 `golang-migrate` 要求版本号必须唯一,这两个文件都使用了 `02121` 前缀,导致冲突。
|
||||
|
||||
## 解决方案
|
||||
将我们新创建的 `add_user_last_login_time` 迁移文件的版本号递增为 `02122`。
|
||||
|
||||
## 实施步骤
|
||||
1. **重命名迁移文件**:
|
||||
- `02121_add_user_last_login_time.up.sql` -> `02122_add_user_last_login_time.up.sql`
|
||||
- `02121_add_user_last_login_time.down.sql` -> `02122_add_user_last_login_time.down.sql`
|
||||
|
||||
2. **验证**:
|
||||
- 确认目录下不再有重复前缀的文件。
|
||||
- 建议用户重新运行程序。
|
||||
|
||||
## 补充说明
|
||||
此操作仅涉及文件重命名,不修改文件内容,风险极低。
|
||||
145
AGENTS.md
Normal file
145
AGENTS.md
Normal file
@ -0,0 +1,145 @@
|
||||
# ppanel-server
|
||||
|
||||
> 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
|
||||
@ -1,128 +0,0 @@
|
||||
# 设备静默登录机制设计方案
|
||||
|
||||
## 需求分析
|
||||
1. 用户进来之后就是静默登录(设备登录)
|
||||
2. 用户可以主动关联邮箱;也可以不关联邮箱
|
||||
3. 用户换手机后在别的地方用邮箱登录会绑定另外一部手机的设备号
|
||||
4. 没有游客概念,快捷登录进来的用户就是正式用户
|
||||
|
||||
## 当前系统分析
|
||||
|
||||
### 现有认证流程
|
||||
- **设备登录**:已存在 `deviceLoginLogic.go`,通过设备标识符自动登录
|
||||
- **邮箱登录**:已存在 `userLoginLogic.go`,需要邮箱+密码
|
||||
- **游客模式**:通过 `Order.UserId = 0` 标识游客订单
|
||||
|
||||
### 现有设备管理
|
||||
- **设备绑定**:`bindDeviceLogic.go` 处理设备与用户绑定
|
||||
- **设备解绑**:`unbindDeviceLogic.go` 处理设备解绑
|
||||
- **设备迁移**:支持设备在用户间转移
|
||||
|
||||
## 设计方案
|
||||
|
||||
### 1. 核心改动策略
|
||||
- **保留现有设备登录机制**,作为默认登录方式
|
||||
- **移除游客概念**,所有设备登录用户都是正式用户
|
||||
- **增强邮箱绑定功能**,支持跨设备登录
|
||||
- **优化设备迁移逻辑**,支持邮箱登录后绑定新设备
|
||||
|
||||
### 2. 具体实现方案
|
||||
|
||||
#### 2.1 修改设备登录逻辑
|
||||
**文件**: `internal/logic/auth/deviceLoginLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 移除 `registerUserAndDevice` 中的试用激活逻辑
|
||||
- 确保所有通过设备登录创建的用户都是正式用户
|
||||
- 保持现有的设备绑定机制
|
||||
|
||||
#### 2.2 修改订单处理逻辑
|
||||
**文件**: `queue/logic/order/activateOrderLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 移除 `getUserOrCreate` 中的游客判断逻辑 (`orderInfo.UserId == 0`)
|
||||
- 移除 `createGuestUser` 函数
|
||||
- 修改为:如果订单没有关联用户,通过设备标识符创建或获取用户
|
||||
|
||||
#### 2.3 修改订单关闭逻辑
|
||||
**文件**: `internal/logic/public/order/closeOrderLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 移除对 `UserId == 0` 的特殊处理
|
||||
- 统一处理所有订单的关闭逻辑
|
||||
|
||||
#### 2.4 增强邮箱登录逻辑
|
||||
**文件**: `internal/logic/auth/userLoginLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 在邮箱登录成功后,如果提供了设备标识符,自动绑定设备
|
||||
- 支持邮箱登录后在新设备上的自动绑定
|
||||
|
||||
#### 2.5 优化设备绑定逻辑
|
||||
**文件**: `internal/logic/auth/bindDeviceLogic.go`
|
||||
|
||||
**改动点**:
|
||||
- 增强设备迁移逻辑,支持邮箱用户登录新设备时的自动绑定
|
||||
- 保持现有的设备冲突处理机制
|
||||
|
||||
### 3. 数据库变更
|
||||
**无需修改数据库结构**,现有的用户和设备表结构已经支持新的需求。
|
||||
|
||||
### 4. API 变更
|
||||
**无需修改 API 接口**,现有的设备登录和邮箱登录接口已经满足需求。
|
||||
|
||||
### 5. 前端适配
|
||||
**前端需要调整**:
|
||||
- 默认使用设备登录作为主要登录方式
|
||||
- 提供邮箱绑定入口
|
||||
- 在新设备上提供邮箱登录选项
|
||||
|
||||
## 实施步骤
|
||||
|
||||
### 第一步:修改订单逻辑
|
||||
1. 修改 `activateOrderLogic.go`,移除游客概念
|
||||
2. 修改 `closeOrderLogic.go`,统一订单处理逻辑
|
||||
|
||||
### 第二步:增强设备登录
|
||||
1. 确保设备登录创建的都是正式用户
|
||||
2. 优化设备绑定逻辑
|
||||
|
||||
### 第三步:增强邮箱登录
|
||||
1. 在邮箱登录后支持设备绑定
|
||||
2. 优化跨设备登录体验
|
||||
|
||||
### 第四步:测试验证
|
||||
1. 测试设备静默登录
|
||||
2. 测试邮箱绑定功能
|
||||
3. 测试跨设备登录
|
||||
|
||||
## 优势分析
|
||||
|
||||
### 1. 最小化改动
|
||||
- 复用现有的设备登录机制
|
||||
- 保持现有的数据库结构
|
||||
- 保持现有的 API 接口
|
||||
|
||||
### 2. 用户体验提升
|
||||
- 用户进入即可使用,无需注册
|
||||
- 支持邮箱绑定,便于跨设备使用
|
||||
- 保持数据连续性
|
||||
|
||||
### 3. 系统稳定性
|
||||
- 基于现有成熟机制
|
||||
- 减少新增代码量
|
||||
- 降低引入 bug 的风险
|
||||
|
||||
## 风险评估
|
||||
|
||||
### 1. 数据迁移
|
||||
- **风险**: 现有游客数据需要处理
|
||||
- **方案**: 可以保持现有游客数据不变,新用户使用新机制
|
||||
|
||||
### 2. 兼容性
|
||||
- **风险**: 现有客户端可能需要适配
|
||||
- **方案**: 保持 API 兼容,逐步引导用户使用新机制
|
||||
|
||||
### 3. 性能影响
|
||||
- **风险**: 设备登录可能增加数据库压力
|
||||
- **方案**: 现有机制已经过验证,影响可控
|
||||
@ -24,11 +24,11 @@ RUN BUILD_TIME=$(date -u +"%Y-%m-%d %H:%M:%S") && \
|
||||
go build -ldflags="-s -w -X 'github.com/perfect-panel/server/pkg/constant.Version=${VERSION}' -X 'github.com/perfect-panel/server/pkg/constant.BuildTime=${BUILD_TIME}'" -o /app/ppanel ppanel.go
|
||||
|
||||
# Final minimal image
|
||||
FROM alpine:latest
|
||||
FROM scratch
|
||||
|
||||
# Copy CA certificates and timezone data
|
||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
|
||||
|
||||
ENV TZ=Asia/Shanghai
|
||||
|
||||
@ -36,6 +36,7 @@ ENV TZ=Asia/Shanghai
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/ppanel /app/ppanel
|
||||
COPY --from=builder /build/etc /app/etc
|
||||
|
||||
# Expose the port (optional)
|
||||
EXPOSE 8080
|
||||
|
||||
@ -45,6 +45,15 @@ proxy services. Built with Go, it emphasizes performance, security, and scalabil
|
||||
- **Node Management**: Monitor and control server nodes.
|
||||
- **API Framework**: Comprehensive RESTful APIs for frontend integration.
|
||||
|
||||
### Subscription Mode Behavior
|
||||
|
||||
The subscription behavior can be switched by the backend config `Subscribe.SingleModel`:
|
||||
|
||||
- `false` (**multi-subscription mode**): each successful `purchase` creates a new `user_subscribe` record.
|
||||
- `true` (**single-subscription mode**): `purchase` is auto-routed to renewal semantics when the user already has a paid subscription:
|
||||
- a new order is still created,
|
||||
- but the existing subscription is extended (instead of creating another `user_subscribe`).
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Prerequisites
|
||||
@ -288,4 +297,4 @@ project's development! 🚀
|
||||
Please give these projects a ⭐ to support the open-source movement!
|
||||
## 📄 License
|
||||
|
||||
This project is licensed under the [GPL-3.0 License](LICENSE).
|
||||
This project is licensed under the [GPL-3.0 License](LICENSE).
|
||||
111
aaa.txt
111
aaa.txt
@ -1,111 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name hifastapp.com www.hifastapp.com www.hifastvpn.com hifastvpn.com hifast.biz www.hifast.biz;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
# 统一 HTTP 转 HTTPS
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hifastvpn.com www.hifastvpn.com;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/hifastvpn.com/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/hifastvpn.com/privkey.pem; # managed by Certbot
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
root /var/www/down;
|
||||
index index.html index.htm;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass https://api.hifast.biz/;
|
||||
proxy_set_header Host api.hifast.biz;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hifastapp.com www.hifastapp.com;
|
||||
|
||||
# 使用 -0001 的新证书(通常包含 www)
|
||||
ssl_certificate /etc/letsencrypt/live/hifastapp.com-0001/fullchain.pem; # managed by Certbot
|
||||
ssl_certificate_key /etc/letsencrypt/live/hifastapp.com-0001/privkey.pem; # managed by Certbot
|
||||
|
||||
# 安全头
|
||||
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
|
||||
root /var/www/down;
|
||||
index index.html index.htm;
|
||||
|
||||
location /api/ {
|
||||
proxy_pass https://api.hifast.biz/;
|
||||
proxy_set_header Host api.hifast.biz;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
}
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
location /download/ {
|
||||
autoindex_exact_size off;
|
||||
autoindex_localtime on;
|
||||
}
|
||||
}
|
||||
|
||||
server {
|
||||
listen 443 ssl http2;
|
||||
server_name hifast.biz www.hifast.biz;
|
||||
|
||||
ssl_certificate /etc/letsencrypt/live/hifast.biz/hifast.biz.cer;
|
||||
ssl_certificate_key /etc/letsencrypt/live/hifast.biz/hifast.biz.key;
|
||||
|
||||
root /var/www/lp;
|
||||
index index.html index.htm;
|
||||
|
||||
location ^~ /.well-known/acme-challenge/ {
|
||||
root /etc/letsencrypt;
|
||||
}
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
@ -8,16 +8,24 @@ import (
|
||||
)
|
||||
|
||||
type Adapter struct {
|
||||
SiteName string // 站点名称
|
||||
Servers []*node.Node // 服务器列表
|
||||
UserInfo User // 用户信息
|
||||
ClientTemplate string // 客户端配置模板
|
||||
OutputFormat string // 输出格式,默认是 base64
|
||||
SubscribeName string // 订阅名称
|
||||
Type string // 协议类型
|
||||
SiteName string // 站点名称
|
||||
Servers []*node.Node // 服务器列表
|
||||
UserInfo User // 用户信息
|
||||
ClientTemplate string // 客户端配置模板
|
||||
OutputFormat string // 输出格式,默认是 base64
|
||||
SubscribeName string // 订阅名称
|
||||
Params map[string]string // 其他参数
|
||||
}
|
||||
|
||||
type Option func(*Adapter)
|
||||
|
||||
func WithParams(params map[string]string) Option {
|
||||
return func(opts *Adapter) {
|
||||
opts.Params = params
|
||||
}
|
||||
}
|
||||
|
||||
// WithServers 设置服务器列表
|
||||
func WithServers(servers []*node.Node) Option {
|
||||
return func(opts *Adapter) {
|
||||
@ -76,6 +84,7 @@ func (adapter *Adapter) Client() (*Client, error) {
|
||||
OutputFormat: adapter.OutputFormat,
|
||||
Proxies: []Proxy{},
|
||||
UserInfo: adapter.UserInfo,
|
||||
Params: adapter.Params,
|
||||
}
|
||||
|
||||
proxies, err := adapter.Proxies(adapter.Servers)
|
||||
@ -101,51 +110,58 @@ func (adapter *Adapter) Proxies(servers []*node.Node) ([]Proxy, error) {
|
||||
}
|
||||
for _, protocol := range protocols {
|
||||
if protocol.Type == item.Protocol {
|
||||
proxies = append(proxies, Proxy{
|
||||
Sort: item.Sort,
|
||||
Name: item.Name,
|
||||
Server: item.Address,
|
||||
Port: item.Port,
|
||||
Type: item.Protocol,
|
||||
Tags: strings.Split(item.Tags, ","),
|
||||
Security: protocol.Security,
|
||||
SNI: protocol.SNI,
|
||||
AllowInsecure: protocol.AllowInsecure,
|
||||
Fingerprint: protocol.Fingerprint,
|
||||
RealityServerAddr: protocol.RealityServerAddr,
|
||||
RealityServerPort: protocol.RealityServerPort,
|
||||
RealityPrivateKey: protocol.RealityPrivateKey,
|
||||
RealityPublicKey: protocol.RealityPublicKey,
|
||||
RealityShortId: protocol.RealityShortId,
|
||||
Transport: protocol.Transport,
|
||||
Host: protocol.Host,
|
||||
Path: protocol.Path,
|
||||
ServiceName: protocol.ServiceName,
|
||||
Method: protocol.Cipher,
|
||||
ServerKey: protocol.ServerKey,
|
||||
Flow: protocol.Flow,
|
||||
HopPorts: protocol.HopPorts,
|
||||
HopInterval: protocol.HopInterval,
|
||||
ObfsPassword: protocol.ObfsPassword,
|
||||
DisableSNI: protocol.DisableSNI,
|
||||
ReduceRtt: protocol.ReduceRtt,
|
||||
UDPRelayMode: protocol.UDPRelayMode,
|
||||
CongestionController: protocol.CongestionController,
|
||||
UpMbps: protocol.UpMbps,
|
||||
DownMbps: protocol.DownMbps,
|
||||
PaddingScheme: protocol.PaddingScheme,
|
||||
Multiplex: protocol.Multiplex,
|
||||
XhttpMode: protocol.XhttpMode,
|
||||
XhttpExtra: protocol.XhttpExtra,
|
||||
Encryption: protocol.Encryption,
|
||||
EncryptionMode: protocol.EncryptionMode,
|
||||
EncryptionRtt: protocol.EncryptionRtt,
|
||||
EncryptionTicket: protocol.EncryptionTicket,
|
||||
EncryptionServerPadding: protocol.EncryptionServerPadding,
|
||||
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
|
||||
EncryptionClientPadding: protocol.EncryptionClientPadding,
|
||||
EncryptionPassword: protocol.EncryptionPassword,
|
||||
})
|
||||
proxies = append(
|
||||
proxies,
|
||||
Proxy{
|
||||
Sort: item.Sort,
|
||||
Name: item.Name,
|
||||
Server: item.Address,
|
||||
Port: item.Port,
|
||||
Type: item.Protocol,
|
||||
Tags: strings.Split(item.Tags, ","),
|
||||
Security: protocol.Security,
|
||||
SNI: protocol.SNI,
|
||||
AllowInsecure: protocol.AllowInsecure,
|
||||
Fingerprint: protocol.Fingerprint,
|
||||
RealityServerAddr: protocol.RealityServerAddr,
|
||||
RealityServerPort: protocol.RealityServerPort,
|
||||
RealityPrivateKey: protocol.RealityPrivateKey,
|
||||
RealityPublicKey: protocol.RealityPublicKey,
|
||||
RealityShortId: protocol.RealityShortId,
|
||||
Transport: protocol.Transport,
|
||||
Host: protocol.Host,
|
||||
Path: protocol.Path,
|
||||
ServiceName: protocol.ServiceName,
|
||||
Method: protocol.Cipher,
|
||||
ServerKey: protocol.ServerKey,
|
||||
Flow: protocol.Flow,
|
||||
HopPorts: protocol.HopPorts,
|
||||
HopInterval: protocol.HopInterval,
|
||||
ObfsPassword: protocol.ObfsPassword,
|
||||
UpMbps: protocol.UpMbps,
|
||||
DownMbps: protocol.DownMbps,
|
||||
DisableSNI: protocol.DisableSNI,
|
||||
ReduceRtt: protocol.ReduceRtt,
|
||||
UDPRelayMode: protocol.UDPRelayMode,
|
||||
CongestionController: protocol.CongestionController,
|
||||
PaddingScheme: protocol.PaddingScheme,
|
||||
Multiplex: protocol.Multiplex,
|
||||
XhttpMode: protocol.XhttpMode,
|
||||
XhttpExtra: protocol.XhttpExtra,
|
||||
Encryption: protocol.Encryption,
|
||||
EncryptionMode: protocol.EncryptionMode,
|
||||
EncryptionRtt: protocol.EncryptionRtt,
|
||||
EncryptionTicket: protocol.EncryptionTicket,
|
||||
EncryptionServerPadding: protocol.EncryptionServerPadding,
|
||||
EncryptionPrivateKey: protocol.EncryptionPrivateKey,
|
||||
EncryptionClientPadding: protocol.EncryptionClientPadding,
|
||||
EncryptionPassword: protocol.EncryptionPassword,
|
||||
Ratio: protocol.Ratio,
|
||||
CertMode: protocol.CertMode,
|
||||
CertDNSProvider: protocol.CertDNSProvider,
|
||||
CertDNSEnv: protocol.CertDNSEnv,
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,34 +0,0 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestAdapter_Client(t *testing.T) {
|
||||
servers := getServers()
|
||||
if len(servers) == 0 {
|
||||
t.Errorf("[Test] No servers found")
|
||||
return
|
||||
}
|
||||
a := NewAdapter(tpl, WithServers(servers), WithUserInfo(User{
|
||||
Password: "test-password",
|
||||
ExpiredAt: time.Now().AddDate(1, 0, 0),
|
||||
Download: 0,
|
||||
Upload: 0,
|
||||
Traffic: 1000,
|
||||
SubscribeURL: "https://example.com/subscribe",
|
||||
}))
|
||||
client, err := a.Client()
|
||||
if err != nil {
|
||||
t.Errorf("[Test] Failed to get client: %v", err.Error())
|
||||
return
|
||||
}
|
||||
bytes, err := client.Build()
|
||||
if err != nil {
|
||||
t.Errorf("[Test] Failed to build client config: %v", err.Error())
|
||||
return
|
||||
}
|
||||
t.Logf("[Test] Client config built successfully: %s", string(bytes))
|
||||
|
||||
}
|
||||
@ -93,12 +93,13 @@ type User struct {
|
||||
}
|
||||
|
||||
type Client struct {
|
||||
SiteName string // Name of the site
|
||||
SubscribeName string // Name of the subscription
|
||||
ClientTemplate string // Template for the entire client configuration
|
||||
OutputFormat string // json, yaml, etc.
|
||||
Proxies []Proxy // List of proxy configurations
|
||||
UserInfo User // User information
|
||||
SiteName string // Name of the site
|
||||
SubscribeName string // Name of the subscription
|
||||
ClientTemplate string // Template for the entire client configuration
|
||||
OutputFormat string // json, yaml, etc.
|
||||
Proxies []Proxy // List of proxy configurations
|
||||
UserInfo User // User information
|
||||
Params map[string]string // Additional parameters
|
||||
}
|
||||
|
||||
func (c *Client) Build() ([]byte, error) {
|
||||
@ -119,6 +120,7 @@ func (c *Client) Build() ([]byte, error) {
|
||||
"OutputFormat": c.OutputFormat,
|
||||
"Proxies": proxies,
|
||||
"UserInfo": c.UserInfo,
|
||||
"Params": c.Params,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@ -1,153 +0,0 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
var tpl = `
|
||||
{{- range $n := .Proxies }}
|
||||
{{- $dn := urlquery (default "node" $n.Name) -}}
|
||||
{{- $sni := default $n.Host $n.SNI -}}
|
||||
|
||||
{{- if eq $n.Type "shadowsocks" -}}
|
||||
{{- $userinfo := b64enc (print $n.Method ":" $.UserInfo.Password) -}}
|
||||
{{- printf "ss://%s@%s:%v#%s" $userinfo $n.Host $n.Port $dn -}}
|
||||
{{- "\n" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if eq $n.Type "trojan" -}}
|
||||
{{- $qs := "security=tls" -}}
|
||||
{{- if $sni }}{{ $qs = printf "%s&sni=%s" $qs (urlquery $sni) }}{{ end -}}
|
||||
{{- if $n.AllowInsecure }}{{ $qs = printf "%s&allowInsecure=%v" $qs $n.AllowInsecure }}{{ end -}}
|
||||
{{- if $n.Fingerprint }}{{ $qs = printf "%s&fp=%s" $qs (urlquery $n.Fingerprint) }}{{ end -}}
|
||||
{{- printf "trojan://%s@%s:%v?%s#%s" $.UserInfo.Password $n.Host $n.Port $qs $dn -}}
|
||||
{{- "\n" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if eq $n.Type "vless" -}}
|
||||
{{- $qs := "encryption=none" -}}
|
||||
{{- if $n.RealityPublicKey -}}
|
||||
{{- $qs = printf "%s&security=reality" $qs -}}
|
||||
{{- $qs = printf "%s&pbk=%s" $qs (urlquery $n.RealityPublicKey) -}}
|
||||
{{- if $n.RealityShortId }}{{ $qs = printf "%s&sid=%s" $qs (urlquery $n.RealityShortId) }}{{ end -}}
|
||||
{{- else -}}
|
||||
{{- if or $n.SNI $n.Fingerprint $n.AllowInsecure }}
|
||||
{{- $qs = printf "%s&security=tls" $qs -}}
|
||||
{{- end -}}
|
||||
{{- end -}}
|
||||
{{- if $n.SNI }}{{ $qs = printf "%s&sni=%s" $qs (urlquery $n.SNI) }}{{ end -}}
|
||||
{{- if $n.AllowInsecure }}{{ $qs = printf "%s&allowInsecure=%v" $qs $n.AllowInsecure }}{{ end -}}
|
||||
{{- if $n.Fingerprint }}{{ $qs = printf "%s&fp=%s" $qs (urlquery $n.Fingerprint) }}{{ end -}}
|
||||
{{- if $n.Network }}{{ $qs = printf "%s&type=%s" $qs $n.Network }}{{ end -}}
|
||||
{{- if $n.Path }}{{ $qs = printf "%s&path=%s" $qs (urlquery $n.Path) }}{{ end -}}
|
||||
{{- if $n.ServiceName }}{{ $qs = printf "%s&serviceName=%s" $qs (urlquery $n.ServiceName) }}{{ end -}}
|
||||
{{- if $n.Flow }}{{ $qs = printf "%s&flow=%s" $qs (urlquery $n.Flow) }}{{ end -}}
|
||||
{{- printf "vless://%s@%s:%v?%s#%s" $n.ServerKey $n.Host $n.Port $qs $dn -}}
|
||||
{{- "\n" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if eq $n.Type "vmess" -}}
|
||||
{{- $obj := dict
|
||||
"v" "2"
|
||||
"ps" $n.Name
|
||||
"add" $n.Host
|
||||
"port" $n.Port
|
||||
"id" $n.ServerKey
|
||||
"aid" 0
|
||||
"net" (or $n.Network "tcp")
|
||||
"type" "none"
|
||||
"path" (or $n.Path "")
|
||||
"host" $n.Host
|
||||
-}}
|
||||
{{- if or $n.SNI $n.Fingerprint $n.AllowInsecure }}{{ set $obj "tls" "tls" }}{{ end -}}
|
||||
{{- if $n.SNI }}{{ set $obj "sni" $n.SNI }}{{ end -}}
|
||||
{{- if $n.Fingerprint }}{{ set $obj "fp" $n.Fingerprint }}{{ end -}}
|
||||
{{- printf "vmess://%s" (b64enc (toJson $obj)) -}}
|
||||
{{- "\n" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if or (eq $n.Type "hysteria2") (eq $n.Type "hy2") -}}
|
||||
{{- $qs := "" -}}
|
||||
{{- if $n.SNI }}{{ $qs = printf "sni=%s" (urlquery $n.SNI) }}{{ end -}}
|
||||
{{- if $n.AllowInsecure }}{{ $qs = printf "%s&insecure=%v" $qs $n.AllowInsecure }}{{ end -}}
|
||||
{{- if $n.ObfsPassword }}{{ $qs = printf "%s&obfs-password=%s" $qs (urlquery $n.ObfsPassword) }}{{ end -}}
|
||||
{{- printf "hy2://%s@%s:%v%s#%s"
|
||||
$.UserInfo.Password
|
||||
$n.Host
|
||||
$n.Port
|
||||
(ternary (gt (len $qs) 0) (print "?" $qs) "")
|
||||
$dn -}}
|
||||
{{- "\n" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if eq $n.Type "tuic" -}}
|
||||
{{- $qs := "" -}}
|
||||
{{- if $n.SNI }}{{ $qs = printf "sni=%s" (urlquery $n.SNI) }}{{ end -}}
|
||||
{{- if $n.AllowInsecure }}{{ $qs = printf "%s&insecure=%v" $qs $n.AllowInsecure }}{{ end -}}
|
||||
{{- printf "tuic://%s:%s@%s:%v%s#%s"
|
||||
$n.ServerKey
|
||||
$.UserInfo.Password
|
||||
$n.Host
|
||||
$n.Port
|
||||
(ternary (gt (len $qs) 0) (print "?" $qs) "")
|
||||
$dn -}}
|
||||
{{- "\n" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- if eq $n.Type "anytls" -}}
|
||||
{{- $qs := "" -}}
|
||||
{{- if $n.SNI }}{{ $qs = printf "sni=%s" (urlquery $n.SNI) }}{{ end -}}
|
||||
{{- printf "anytls://%s@%s:%v%s#%s"
|
||||
$.UserInfo.Password
|
||||
$n.Host
|
||||
$n.Port
|
||||
(ternary (gt (len $qs) 0) (print "?" $qs) "")
|
||||
$dn -}}
|
||||
{{- "\n" -}}
|
||||
{{- end -}}
|
||||
|
||||
{{- end }}
|
||||
`
|
||||
|
||||
func TestClient_Build(t *testing.T) {
|
||||
client := &Client{
|
||||
SiteName: "TestSite",
|
||||
SubscribeName: "TestSubscribe",
|
||||
ClientTemplate: tpl,
|
||||
Proxies: []Proxy{
|
||||
{
|
||||
Name: "TestShadowSocks",
|
||||
Type: "shadowsocks",
|
||||
Host: "127.0.0.1",
|
||||
Port: 1234,
|
||||
Method: "aes-256-gcm",
|
||||
},
|
||||
{
|
||||
Name: "TestTrojan",
|
||||
Type: "trojan",
|
||||
Host: "example.com",
|
||||
Port: 443,
|
||||
AllowInsecure: true,
|
||||
Security: "tls",
|
||||
Transport: "tcp",
|
||||
SNI: "v1-dy.ixigua.com",
|
||||
},
|
||||
},
|
||||
UserInfo: User{
|
||||
Password: "testpassword",
|
||||
ExpiredAt: time.Now().Add(24 * time.Hour),
|
||||
Download: 1000000,
|
||||
Upload: 500000,
|
||||
Traffic: 1500000,
|
||||
SubscribeURL: "https://example.com/subscribe",
|
||||
},
|
||||
}
|
||||
buf, err := client.Build()
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build client: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("[测试] 输出: %s", buf)
|
||||
|
||||
}
|
||||
@ -1,46 +0,0 @@
|
||||
package adapter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/perfect-panel/server/internal/model/server"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestAdapterProxy(t *testing.T) {
|
||||
|
||||
servers := getServers()
|
||||
if len(servers) == 0 {
|
||||
t.Fatal("no servers found")
|
||||
}
|
||||
for _, srv := range servers {
|
||||
proxy, err := adapterProxy(*srv, "example.com", 0)
|
||||
if err != nil {
|
||||
t.Errorf("failed to adapt server %s: %v", srv.Name, err)
|
||||
}
|
||||
t.Logf("[测试] 适配服务器 %s 成功: %+v", srv.Name, proxy)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func getServers() []*server.Server {
|
||||
db, err := connectMySQL("root:mylove520@tcp(localhost:3306)/perfectlink?charset=utf8mb4&parseTime=True&loc=Local")
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var servers []*server.Server
|
||||
if err = db.Model(&server.Server{}).Find(&servers).Error; err != nil {
|
||||
return nil
|
||||
}
|
||||
return servers
|
||||
}
|
||||
func connectMySQL(dsn string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(mysql.New(mysql.Config{
|
||||
DSN: dsn,
|
||||
}), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db, nil
|
||||
}
|
||||
@ -190,6 +190,55 @@ type (
|
||||
AutoClear *bool `json:"auto_clear"`
|
||||
ClearDays int64 `json:"clear_days"`
|
||||
}
|
||||
GetErrorLogMessageListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Platform string `form:"platform,optional"`
|
||||
Level uint8 `form:"level,optional"`
|
||||
UserId int64 `form:"user_id,optional"`
|
||||
DeviceId string `form:"device_id,optional"`
|
||||
ErrorCode string `form:"error_code,optional"`
|
||||
Keyword string `form:"keyword,optional"`
|
||||
Start int64 `form:"start,optional"`
|
||||
End int64 `form:"end,optional"`
|
||||
}
|
||||
ErrorLogMessage {
|
||||
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"`
|
||||
}
|
||||
GetErrorLogMessageListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []ErrorLogMessage `json:"list"`
|
||||
}
|
||||
GetErrorLogMessageDetailResponse {
|
||||
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"`
|
||||
ClientIP string `json:"client_ip"`
|
||||
UserAgent string `json:"user_agent"`
|
||||
Locale string `json:"locale"`
|
||||
OccurredAt int64 `json:"occurred_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@ -257,5 +306,13 @@ service ppanel {
|
||||
@doc "Update log setting"
|
||||
@handler UpdateLogSetting
|
||||
post /setting (LogSetting)
|
||||
|
||||
@doc "Get error log message list"
|
||||
@handler GetErrorLogMessageList
|
||||
get /error_message/list (GetErrorLogMessageListRequest) returns (GetErrorLogMessageListResponse)
|
||||
|
||||
@doc "Get error log message detail"
|
||||
@handler GetErrorLogMessageDetail
|
||||
get /error_message/detail returns (GetErrorLogMessageDetailResponse)
|
||||
}
|
||||
|
||||
|
||||
@ -14,14 +14,14 @@ type (
|
||||
CreateOrderRequest {
|
||||
UserId int64 `json:"user_id" validate:"required"`
|
||||
Type uint8 `json:"type" validate:"required"`
|
||||
Quantity int64 `json:"quantity,omitempty"`
|
||||
Price int64 `json:"price" validate:"required"`
|
||||
Amount int64 `json:"amount" validate:"required"`
|
||||
Discount int64 `json:"discount,omitempty"`
|
||||
Quantity int64 `json:"quantity,omitempty" validate:"omitempty,lte=1000"`
|
||||
Price int64 `json:"price" validate:"required,gte=0,lte=2000000000"`
|
||||
Amount int64 `json:"amount" validate:"required,gte=0,lte=2147483647"`
|
||||
Discount int64 `json:"discount,omitempty" validate:"omitempty,gte=0,lte=2000000000"`
|
||||
Coupon string `json:"coupon,omitempty"`
|
||||
CouponDiscount int64 `json:"coupon_discount,omitempty"`
|
||||
Commission int64 `json:"commission"`
|
||||
FeeAmount int64 `json:"fee_amount" validate:"required"`
|
||||
CouponDiscount int64 `json:"coupon_discount,omitempty" validate:"omitempty,gte=0,lte=2000000000"`
|
||||
Commission int64 `json:"commission" validate:"gte=0,lte=2000000000"`
|
||||
FeeAmount int64 `json:"fee_amount" validate:"required,gte=0,lte=2000000000"`
|
||||
PaymentId int64 `json:"payment_id" validate:"required"`
|
||||
TradeNo string `json:"trade_no,omitempty"`
|
||||
Status uint8 `json:"status,omitempty"`
|
||||
@ -33,6 +33,9 @@ type (
|
||||
PaymentId int64 `json:"payment_id,omitempty"`
|
||||
TradeNo string `json:"trade_no,omitempty"`
|
||||
}
|
||||
ActivateOrderRequest {
|
||||
OrderNo string `json:"order_no" validate:"required"`
|
||||
}
|
||||
GetOrderListRequest {
|
||||
Page int64 `form:"page" validate:"required"`
|
||||
Size int64 `form:"size" validate:"required"`
|
||||
@ -64,5 +67,9 @@ service ppanel {
|
||||
@doc "Update order status"
|
||||
@handler UpdateOrderStatus
|
||||
put /status (UpdateOrderStatusRequest)
|
||||
|
||||
@doc "Manually activate order"
|
||||
@handler ActivateOrder
|
||||
post /activate (ActivateOrderRequest)
|
||||
}
|
||||
|
||||
|
||||
96
apis/admin/redemption.api
Normal file
96
apis/admin/redemption.api
Normal file
@ -0,0 +1,96 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "redemption API"
|
||||
desc: "API for redemption code management"
|
||||
author: "Tension"
|
||||
email: "tension@ppanel.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
type (
|
||||
CreateRedemptionCodeRequest {
|
||||
TotalCount int64 `json:"total_count" validate:"required"`
|
||||
SubscribePlan int64 `json:"subscribe_plan" validate:"required"`
|
||||
UnitTime string `json:"unit_time" validate:"required,oneof=day month quarter half_year year"`
|
||||
Quantity int64 `json:"quantity" validate:"required"`
|
||||
BatchCount int64 `json:"batch_count" validate:"required,min=1"`
|
||||
}
|
||||
UpdateRedemptionCodeRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
TotalCount int64 `json:"total_count,omitempty"`
|
||||
SubscribePlan int64 `json:"subscribe_plan,omitempty"`
|
||||
UnitTime string `json:"unit_time,omitempty" validate:"omitempty,oneof=day month quarter half_year year"`
|
||||
Quantity int64 `json:"quantity,omitempty"`
|
||||
Status int64 `json:"status,omitempty" validate:"omitempty,oneof=0 1"`
|
||||
}
|
||||
ToggleRedemptionCodeStatusRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Status int64 `json:"status" validate:"oneof=0 1"`
|
||||
}
|
||||
DeleteRedemptionCodeRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
BatchDeleteRedemptionCodeRequest {
|
||||
Ids []int64 `json:"ids" validate:"required"`
|
||||
}
|
||||
GetRedemptionCodeListRequest {
|
||||
Page int64 `form:"page" validate:"required"`
|
||||
Size int64 `form:"size" validate:"required"`
|
||||
SubscribePlan int64 `form:"subscribe_plan,omitempty"`
|
||||
UnitTime string `form:"unit_time,omitempty"`
|
||||
Code string `form:"code,omitempty"`
|
||||
}
|
||||
GetRedemptionCodeListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []RedemptionCode `json:"list"`
|
||||
}
|
||||
GetRedemptionRecordListRequest {
|
||||
Page int64 `form:"page" validate:"required"`
|
||||
Size int64 `form:"size" validate:"required"`
|
||||
UserId int64 `form:"user_id,omitempty"`
|
||||
CodeId int64 `form:"code_id,omitempty"`
|
||||
}
|
||||
GetRedemptionRecordListResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []RedemptionRecord `json:"list"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
prefix: v1/admin/redemption
|
||||
group: admin/redemption
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Create redemption code"
|
||||
@handler CreateRedemptionCode
|
||||
post /code (CreateRedemptionCodeRequest)
|
||||
|
||||
@doc "Update redemption code"
|
||||
@handler UpdateRedemptionCode
|
||||
put /code (UpdateRedemptionCodeRequest)
|
||||
|
||||
@doc "Toggle redemption code status"
|
||||
@handler ToggleRedemptionCodeStatus
|
||||
put /code/status (ToggleRedemptionCodeStatusRequest)
|
||||
|
||||
@doc "Delete redemption code"
|
||||
@handler DeleteRedemptionCode
|
||||
delete /code (DeleteRedemptionCodeRequest)
|
||||
|
||||
@doc "Batch delete redemption code"
|
||||
@handler BatchDeleteRedemptionCode
|
||||
delete /code/batch (BatchDeleteRedemptionCodeRequest)
|
||||
|
||||
@doc "Get redemption code list"
|
||||
@handler GetRedemptionCodeList
|
||||
get /code/list (GetRedemptionCodeListRequest) returns (GetRedemptionCodeListResponse)
|
||||
|
||||
@doc "Get redemption record list"
|
||||
@handler GetRedemptionRecordList
|
||||
get /record/list (GetRedemptionRecordListRequest) returns (GetRedemptionRecordListResponse)
|
||||
}
|
||||
|
||||
@ -189,14 +189,6 @@ service ppanel {
|
||||
@handler ToggleNodeStatus
|
||||
post /node/status/toggle (ToggleNodeStatusRequest)
|
||||
|
||||
@doc "Check if there is any server or node to migrate"
|
||||
@handler HasMigrateSeverNode
|
||||
get /migrate/has returns (HasMigrateSeverNodeResponse)
|
||||
|
||||
@doc "Migrate server and node data to new database"
|
||||
@handler MigrateServerNode
|
||||
post /migrate/run returns (MigrateServerNodeResponse)
|
||||
|
||||
@doc "Reset server sort"
|
||||
@handler ResetSortWithServer
|
||||
post /server/sort (ResetSortRequest)
|
||||
|
||||
@ -34,50 +34,52 @@ type (
|
||||
Ids []int64 `json:"ids" validate:"required"`
|
||||
}
|
||||
CreateSubscribeRequest {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
AllowDeduction *bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
AllowDeduction *bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
ShowOriginalPrice bool `json:"show_original_price"`
|
||||
}
|
||||
UpdateSubscribeRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
AllowDeduction *bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
Name string `json:"name" validate:"required"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show *bool `json:"show"`
|
||||
Sell *bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
AllowDeduction *bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset *bool `json:"renewal_reset"`
|
||||
ShowOriginalPrice bool `json:"show_original_price"`
|
||||
}
|
||||
SubscribeSortRequest {
|
||||
Sort []SortItem `json:"sort"`
|
||||
@ -102,6 +104,9 @@ type (
|
||||
BatchDeleteSubscribeRequest {
|
||||
Ids []int64 `json:"ids" validate:"required"`
|
||||
}
|
||||
ResetAllSubscribeTokenResponse {
|
||||
Success bool `json:"success"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@ -157,5 +162,9 @@ service ppanel {
|
||||
@doc "Subscribe sort"
|
||||
@handler SubscribeSort
|
||||
post /sort (SubscribeSortRequest)
|
||||
|
||||
@doc "Reset all subscribe tokens"
|
||||
@handler ResetAllSubscribeToken
|
||||
post /reset_all_token returns (ResetAllSubscribeTokenResponse)
|
||||
}
|
||||
|
||||
|
||||
@ -22,6 +22,11 @@ type (
|
||||
CurrentTime string `json:"current_time"`
|
||||
Ratio float32 `json:"ratio"`
|
||||
}
|
||||
ModuleConfig {
|
||||
Secret string `json:"secret"` // 通讯密钥
|
||||
ServiceName string `json:"service_name"` // 服务名称
|
||||
ServiceVersion string `json:"service_version"` // 服务版本
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@ -122,8 +127,20 @@ 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)
|
||||
|
||||
@doc "Get Module Config"
|
||||
@handler GetModuleConfig
|
||||
get /module returns (ModuleConfig)
|
||||
}
|
||||
|
||||
|
||||
@ -17,6 +17,14 @@ type (
|
||||
VersionResponse {
|
||||
Version string `json:"version"`
|
||||
}
|
||||
QueryIPLocationRequest {
|
||||
IP string `form:"ip" validate:"required"`
|
||||
}
|
||||
QueryIPLocationResponse {
|
||||
Country string `json:"country"`
|
||||
Region string `json:"region,omitempty"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@ -36,5 +44,9 @@ service ppanel {
|
||||
@doc "Get Version"
|
||||
@handler GetVersion
|
||||
get /version returns (VersionResponse)
|
||||
|
||||
@doc "Query IP Location"
|
||||
@handler QueryIPLocation
|
||||
get /ip/location (QueryIPLocationRequest) returns (QueryIPLocationResponse)
|
||||
}
|
||||
|
||||
|
||||
@ -15,13 +15,18 @@ import (
|
||||
type (
|
||||
// GetUserListRequest
|
||||
GetUserListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Search string `form:"search,omitempty"`
|
||||
UserId *int64 `form:"user_id,omitempty"`
|
||||
SubscribeId *int64 `form:"subscribe_id,omitempty"`
|
||||
UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"`
|
||||
DeviceId string `form:"device_id,omitempty"`
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
Search string `form:"search,omitempty"`
|
||||
UserId *int64 `form:"user_id,omitempty"`
|
||||
Unscoped bool `form:"unscoped,omitempty"`
|
||||
SubscribeId *int64 `form:"subscribe_id,omitempty"`
|
||||
UserSubscribeId *int64 `form:"user_subscribe_id,omitempty"`
|
||||
ShortCode string `form:"short_code,omitempty"`
|
||||
FamilyJoined *bool `form:"family_joined,omitempty"`
|
||||
FamilyStatus string `form:"family_status,omitempty"`
|
||||
FamilyOwnerUserId *int64 `form:"family_owner_user_id,omitempty"`
|
||||
FamilyId *int64 `form:"family_id,omitempty"`
|
||||
}
|
||||
// GetUserListResponse
|
||||
GetUserListResponse {
|
||||
@ -43,10 +48,9 @@ type (
|
||||
GiftAmount int64 `json:"gift_amount"`
|
||||
Telegram int64 `json:"telegram"`
|
||||
ReferCode string `json:"refer_code"`
|
||||
RefererId *int64 `json:"referer_id"`
|
||||
RefererId int64 `json:"referer_id"`
|
||||
Enable bool `json:"enable"`
|
||||
IsAdmin bool `json:"is_admin"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
Remark string `json:"remark"`
|
||||
}
|
||||
UpdateUserNotifySettingRequest {
|
||||
@ -181,11 +185,46 @@ type (
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
DeleteUserSubscribeRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
UserSubscribeId int64 `json:"user_subscribe_id" validate:"required,gt=0"`
|
||||
}
|
||||
GetUserSubscribeByIdRequest {
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
ToggleUserSubscribeStatusRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
ResetUserSubscribeTrafficRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
GetFamilyListRequest {
|
||||
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"`
|
||||
}
|
||||
GetFamilyListResponse {
|
||||
List []FamilySummary `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
GetFamilyDetailRequest {
|
||||
Id int64 `form:"id" validate:"required"`
|
||||
}
|
||||
UpdateFamilyMaxMembersRequest {
|
||||
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
||||
MaxMembers int64 `json:"max_members" validate:"required,gt=0"`
|
||||
}
|
||||
RemoveFamilyMemberRequest {
|
||||
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
||||
UserId int64 `json:"user_id" validate:"required,gt=0"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
DissolveFamilyRequest {
|
||||
FamilyId int64 `json:"family_id" validate:"required,gt=0"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
@ -294,5 +333,37 @@ service ppanel {
|
||||
@doc "Get user login logs"
|
||||
@handler GetUserLoginLogs
|
||||
get /login/logs (GetUserLoginLogsRequest) returns (GetUserLoginLogsResponse)
|
||||
|
||||
@doc "Reset user subscribe token"
|
||||
@handler ResetUserSubscribeToken
|
||||
post /subscribe/reset/token (ResetUserSubscribeTokenRequest)
|
||||
|
||||
@doc "Stop user subscribe"
|
||||
@handler ToggleUserSubscribeStatus
|
||||
post /subscribe/toggle (ToggleUserSubscribeStatusRequest)
|
||||
|
||||
@doc "Reset user subscribe traffic"
|
||||
@handler ResetUserSubscribeTraffic
|
||||
post /subscribe/reset/traffic (ResetUserSubscribeTrafficRequest)
|
||||
|
||||
@doc "Get family list"
|
||||
@handler GetFamilyList
|
||||
get /family/list (GetFamilyListRequest) returns (GetFamilyListResponse)
|
||||
|
||||
@doc "Get family detail"
|
||||
@handler GetFamilyDetail
|
||||
get /family/detail (GetFamilyDetailRequest) returns (FamilyDetail)
|
||||
|
||||
@doc "Update family max members"
|
||||
@handler UpdateFamilyMaxMembers
|
||||
put /family/max_members (UpdateFamilyMaxMembersRequest)
|
||||
|
||||
@doc "Remove family member"
|
||||
@handler RemoveFamilyMember
|
||||
put /family/member/remove (RemoveFamilyMemberRequest)
|
||||
|
||||
@doc "Dissolve family"
|
||||
@handler DissolveFamily
|
||||
put /family/dissolve (DissolveFamilyRequest)
|
||||
}
|
||||
|
||||
|
||||
@ -50,15 +50,16 @@ type (
|
||||
LoginType string `header:"Login-Type"`
|
||||
CfToken string `json:"cf_token,optional"`
|
||||
}
|
||||
// Email login request
|
||||
EmailLoginRequest {
|
||||
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"`
|
||||
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"`
|
||||
}
|
||||
LoginResponse {
|
||||
Token string `json:"token"`
|
||||
@ -134,6 +135,7 @@ type (
|
||||
IP string `header:"X-Original-Forwarded-For"`
|
||||
UserAgent string `json:"user_agent" validate:"required"`
|
||||
CfToken string `json:"cf_token,optional"`
|
||||
ShortCode string `json:"short_code,optional"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -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"`
|
||||
@ -73,6 +74,7 @@ type (
|
||||
}
|
||||
CheckVerificationCodeRespone {
|
||||
Status bool `json:"status"`
|
||||
Exist bool `json:"exist"`
|
||||
}
|
||||
SubscribeClient {
|
||||
Id int64 `json:"id"`
|
||||
@ -87,21 +89,10 @@ type (
|
||||
Total int64 `json:"total"`
|
||||
List []SubscribeClient `json:"list"`
|
||||
}
|
||||
ContactRequest {
|
||||
Name string `json:"name" validate:"required"`
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
OtherContact string `json:"other_contact,optional"`
|
||||
Notes string `json:"notes,optional"`
|
||||
}
|
||||
GetDownloadLinkRequest {
|
||||
InviteCode string `form:"invite_code,optional"`
|
||||
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
|
||||
}
|
||||
GetDownloadLinkResponse {
|
||||
Url string `json:"url"`
|
||||
}
|
||||
GetAppVersionRequest {
|
||||
Platform string `form:"platform" validate:"required,oneof=windows mac ios android"`
|
||||
HeartbeatResponse {
|
||||
Status bool `json:"status"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Timestamp int64 `json:"timestamp,omitempty"`
|
||||
}
|
||||
)
|
||||
|
||||
@ -115,10 +106,6 @@ service ppanel {
|
||||
@handler GetGlobalConfig
|
||||
get /site/config returns (GetGlobalConfigResponse)
|
||||
|
||||
@doc "Submit contact info"
|
||||
@handler SubmitContact
|
||||
post /contact (ContactRequest)
|
||||
|
||||
@doc "Get Tos Content"
|
||||
@handler GetTos
|
||||
get /site/tos returns (GetTosResponse)
|
||||
@ -151,11 +138,8 @@ service ppanel {
|
||||
@handler GetClient
|
||||
get /client returns (GetSubscribeClientResponse)
|
||||
|
||||
@doc "Get Download Link"
|
||||
@handler GetDownloadLink
|
||||
get /client/download (GetDownloadLinkRequest) returns (GetDownloadLinkResponse)
|
||||
|
||||
@doc "Get App Version"
|
||||
@handler GetAppVersion
|
||||
get /app/version (GetAppVersionRequest) returns (ApplicationVersion)
|
||||
@doc "Heartbeat"
|
||||
@handler Heartbeat
|
||||
get /heartbeat returns (HeartbeatResponse)
|
||||
}
|
||||
|
||||
|
||||
34
apis/public/iap.api
Normal file
34
apis/public/iap.api
Normal file
@ -0,0 +1,34 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "IAP API"
|
||||
desc: "API for ppanel"
|
||||
author: "Tension"
|
||||
email: "tension@ppanel.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
@server (
|
||||
prefix: v1/public/iap/apple
|
||||
group: public/iap/apple
|
||||
middleware: AuthMiddleware,DeviceMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Attach Apple Transaction"
|
||||
@handler AttachAppleTransaction
|
||||
post /transactions/attach (AttachAppleTransactionRequest) returns (AttachAppleTransactionResponse)
|
||||
|
||||
@doc "Attach Apple Transaction By Id"
|
||||
@handler AttachAppleTransactionById
|
||||
post /transactions/attach/id (AttachAppleTransactionByIdRequest) returns (AttachAppleTransactionResponse)
|
||||
|
||||
@doc "Restore Apple Transactions"
|
||||
@handler RestoreAppleTransactions
|
||||
post /transactions/restore (RestoreAppleTransactionsRequest)
|
||||
|
||||
@doc "Get Apple IAP Status"
|
||||
@handler GetAppleStatus
|
||||
get /status returns (GetAppleStatusResponse)
|
||||
}
|
||||
33
apis/public/redemption.api
Normal file
33
apis/public/redemption.api
Normal file
@ -0,0 +1,33 @@
|
||||
syntax = "v1"
|
||||
|
||||
info (
|
||||
title: "redemption API"
|
||||
desc: "API for redemption"
|
||||
author: "Tension"
|
||||
email: "tension@ppanel.com"
|
||||
version: "0.0.1"
|
||||
)
|
||||
|
||||
import "../types.api"
|
||||
|
||||
type (
|
||||
RedeemCodeRequest {
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
RedeemCodeResponse {
|
||||
Message string `json:"message"`
|
||||
}
|
||||
)
|
||||
|
||||
@server (
|
||||
prefix: v1/public/redemption
|
||||
group: public/redemption
|
||||
jwt: JwtAuth
|
||||
middleware: AuthMiddleware,DeviceMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Redeem code"
|
||||
@handler RedeemCode
|
||||
post / (RedeemCodeRequest) returns (RedeemCodeResponse)
|
||||
}
|
||||
|
||||
@ -18,40 +18,43 @@ type (
|
||||
List []UserSubscribeInfo `json:"list"`
|
||||
}
|
||||
UserSubscribeInfo {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
FinishedAt int64 `json:"finished_at"`
|
||||
ResetTime int64 `json:"reset_time"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
IsTryOut bool `json:"is_try_out"`
|
||||
Nodes []*UserSubscribeNodeInfo `json:"nodes"`
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
FinishedAt int64 `json:"finished_at"`
|
||||
ResetTime int64 `json:"reset_time"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
EntitlementSource string `json:"entitlement_source"`
|
||||
EntitlementOwnerUserId int64 `json:"entitlement_owner_user_id"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
IsTryOut bool `json:"is_try_out"`
|
||||
Nodes []*UserSubscribeNodeInfo `json:"nodes"`
|
||||
}
|
||||
UserSubscribeNodeInfo {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Uuid string `json:"uuid"`
|
||||
Protocol string `json:"protocol"`
|
||||
Protocols string `json:"protocols"`
|
||||
Port uint16 `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Tags []string `json:"tags"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
LatitudeCenter string `json:"latitude_center"`
|
||||
LongitudeCenter string `json:"longitude_center"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Uuid string `json:"uuid"`
|
||||
Protocol string `json:"protocol"`
|
||||
Protocols string `json:"protocols"`
|
||||
Port uint16 `json:"port"`
|
||||
Address string `json:"address"`
|
||||
Tags []string `json:"tags"`
|
||||
Country string `json:"country"`
|
||||
City string `json:"city"`
|
||||
Longitude string `json:"longitude"`
|
||||
Latitude string `json:"latitude"`
|
||||
LatitudeCenter string `json:"latitude_center"`
|
||||
LongitudeCenter string `json:"longitude_center"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -66,9 +66,6 @@ type (
|
||||
UnbindOAuthRequest {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
ResetUserSubscribeTokenRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
GetLoginLogRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
@ -97,16 +94,6 @@ type (
|
||||
Email string `json:"email" validate:"required"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
BindEmailWithVerificationRequest {
|
||||
Email string `json:"email" validate:"required"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
BindEmailWithVerificationResponse {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Token string `json:"token,omitempty"` // 设备关联后的新Token
|
||||
UserId int64 `json:"user_id,omitempty"` // 目标用户ID
|
||||
}
|
||||
GetDeviceListResponse {
|
||||
List []UserDevice `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
@ -114,56 +101,124 @@ type (
|
||||
UnbindDeviceRequest {
|
||||
Id int64 `json:"id" validate:"required"`
|
||||
}
|
||||
GetSubscribeStatusResponse {
|
||||
DeviceStatus bool `json:"device_status"`
|
||||
EmailStatus bool `json:"email_status"`
|
||||
UpdateUserSubscribeNoteRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id" validate:"required"`
|
||||
Note string `json:"note" validate:"max=500"`
|
||||
}
|
||||
// GetAgentRealtimeRequest - 获取代理链接实时数据
|
||||
GetAgentRealtimeRequest {}
|
||||
// GetAgentRealtimeResponse - 代理链接实时数据响应
|
||||
UpdateUserRulesRequest {
|
||||
Rules []string `json:"rules" validate:"required"`
|
||||
}
|
||||
CommissionWithdrawRequest {
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
WithdrawalLog {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Amount int64 `json:"amount"`
|
||||
Content string `json:"content"`
|
||||
Status uint8 `json:"status"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
QueryWithdrawalLogListRequest {
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
}
|
||||
QueryWithdrawalLogListResponse {
|
||||
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"`
|
||||
Hours float64 `json:"hours"`
|
||||
}
|
||||
ConnectionRecords {
|
||||
CurrentContinuousDays int64 `json:"current_continuous_days"`
|
||||
HistoryContinuousDays int64 `json:"history_continuous_days"`
|
||||
LongestSingleConnection int64 `json:"longest_single_connection"`
|
||||
}
|
||||
BindEmailWithVerificationRequest {
|
||||
Email string `json:"email" form:"email" validate:"required,email"`
|
||||
Code string `json:"code" form:"code" validate:"required"`
|
||||
}
|
||||
BindEmailWithVerificationResponse {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
Token string `json:"token,omitempty"`
|
||||
UserId int64 `json:"user_id"`
|
||||
FamilyJoined bool `json:"family_joined,omitempty"`
|
||||
FamilyId int64 `json:"family_id,omitempty"`
|
||||
OwnerUserId int64 `json:"owner_user_id,omitempty"`
|
||||
}
|
||||
BindInviteCodeRequest {
|
||||
InviteCode string `json:"invite_code" form:"invite_code" validate:"required"`
|
||||
}
|
||||
DeleteAccountRequest {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Code string `json:"code" validate:"required"`
|
||||
}
|
||||
DeleteAccountResponse {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
UserId int64 `json:"user_id"`
|
||||
Code int64 `json:"code"`
|
||||
}
|
||||
GetAgentDownloadsRequest {}
|
||||
PlatformDownloads {
|
||||
IOS int64 `json:"ios"`
|
||||
Android int64 `json:"android"`
|
||||
Windows int64 `json:"windows"`
|
||||
Mac int64 `json:"mac"`
|
||||
}
|
||||
GetAgentDownloadsResponse {
|
||||
Total int64 `json:"total"`
|
||||
Platforms PlatformDownloads `json:"platforms"`
|
||||
ComparisonRate string `json:"comparison_rate,omitempty"`
|
||||
}
|
||||
GetAgentRealtimeRequest {}
|
||||
GetAgentRealtimeResponse {
|
||||
Total int64 `json:"total"` // 访问总人数
|
||||
Clicks int64 `json:"clicks"` // 点击量
|
||||
Views int64 `json:"views"` // 浏览量
|
||||
PaidCount int64 `json:"paid_count"` // 付费数量
|
||||
GrowthRate string `json:"growth_rate"` // 访问量环比增长率(例如:"+10.5%"、"-5.2%"、"0%")
|
||||
PaidGrowthRate string `json:"paid_growth_rate"` // 付费用户环比增长率(例如:"+20.0%"、"-10.0%"、"0%")
|
||||
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"`
|
||||
}
|
||||
// GetUserInviteStatsRequest - 获取用户邀请统计
|
||||
GetUserInviteStatsRequest {}
|
||||
// GetUserInviteStatsResponse - 用户邀请统计响应
|
||||
GetUserInviteStatsResponse {
|
||||
FriendlyCount int64 `json:"friendly_count"` // 有效邀请数(有订单的用户)
|
||||
HistoryCount int64 `json:"history_count"` // 历史邀请总数
|
||||
}
|
||||
// GetInviteSalesRequest - 获取最近销售数据
|
||||
GetInviteSalesRequest {
|
||||
Page int `form:"page" validate:"required"`
|
||||
Size int `form:"size" validate:"required"`
|
||||
Page int `form:"page"`
|
||||
Size int `form:"size"`
|
||||
StartTime int64 `form:"start_time"`
|
||||
EndTime int64 `form:"end_time"`
|
||||
}
|
||||
// GetInviteSalesResponse - 最近销售数据响应
|
||||
GetInviteSalesResponse {
|
||||
Total int64 `json:"total"` // 销售记录总数
|
||||
List []InvitedUserSale `json:"list"` // 销售数据列表(分页)
|
||||
}
|
||||
// InvitedUserSale - 被邀请用户的销售记录
|
||||
InvitedUserSale {
|
||||
Amount float64 `json:"amount"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
UserHash string `json:"user_hash"`
|
||||
ProductName string `json:"product_name"`
|
||||
}
|
||||
// GetAgentDownloadsRequest - 获取各端下载量
|
||||
GetAgentDownloadsRequest {}
|
||||
// GetAgentDownloadsResponse - 各端下载量响应
|
||||
GetAgentDownloadsResponse {
|
||||
List []AgentDownloadStats `json:"list"`
|
||||
GetInviteSalesResponse {
|
||||
Total int64 `json:"total"`
|
||||
List []InvitedUserSale `json:"list"`
|
||||
}
|
||||
// AgentDownloadStats - 各端下载量统计
|
||||
AgentDownloadStats {
|
||||
Platform string `json:"platform"`
|
||||
Clicks int64 `json:"clicks"`
|
||||
Visits int64 `json:"visits"`
|
||||
GetSubscribeStatusRequest {
|
||||
Email string `form:"email" json:"email" validate:"omitempty,email"`
|
||||
}
|
||||
GetSubscribeStatusResponse {
|
||||
DeviceStatus bool `json:"device_status"`
|
||||
EmailStatus bool `json:"email_status"`
|
||||
}
|
||||
GetUserInviteStatsRequest {}
|
||||
GetUserInviteStatsResponse {
|
||||
FriendlyCount int64 `json:"friendly_count"`
|
||||
HistoryCount int64 `json:"history_count"`
|
||||
}
|
||||
)
|
||||
|
||||
@ -249,10 +304,6 @@ service ppanel {
|
||||
@handler GetSubscribeLog
|
||||
get /subscribe_log (GetSubscribeLogRequest) returns (GetSubscribeLogResponse)
|
||||
|
||||
@doc "Get Subscribe Status (device/email)"
|
||||
@handler GetSubscribeStatus
|
||||
get /subscribe_status returns (GetSubscribeStatusResponse)
|
||||
|
||||
@doc "Verify Email"
|
||||
@handler VerifyEmail
|
||||
post /verify_email (VerifyEmailRequest)
|
||||
@ -273,20 +324,71 @@ service ppanel {
|
||||
@handler UnbindDevice
|
||||
put /unbind_device (UnbindDeviceRequest)
|
||||
|
||||
@doc "Get agent realtime data"
|
||||
@handler GetAgentRealtime
|
||||
get /agent/realtime (GetAgentRealtimeRequest) returns (GetAgentRealtimeResponse)
|
||||
@doc "Update User Subscribe Note"
|
||||
@handler UpdateUserSubscribeNote
|
||||
put /subscribe_note (UpdateUserSubscribeNoteRequest)
|
||||
|
||||
@doc "Get user invite statistics"
|
||||
@handler GetUserInviteStats
|
||||
get /invite/stats (GetUserInviteStatsRequest) returns (GetUserInviteStatsResponse)
|
||||
@doc "Update User Rules"
|
||||
@handler UpdateUserRules
|
||||
put /rules (UpdateUserRulesRequest)
|
||||
|
||||
@doc "Get invite sales data"
|
||||
@handler GetInviteSales
|
||||
get /invite/sales (GetInviteSalesRequest) returns (GetInviteSalesResponse)
|
||||
@doc "Commission Withdraw"
|
||||
@handler CommissionWithdraw
|
||||
post /commission_withdraw (CommissionWithdrawRequest) returns (WithdrawalLog)
|
||||
|
||||
@doc "Get agent downloads data"
|
||||
@doc "Query Withdrawal Log"
|
||||
@handler QueryWithdrawalLog
|
||||
get /withdrawal_log (QueryWithdrawalLogListRequest) returns (QueryWithdrawalLogListResponse)
|
||||
|
||||
@doc "Device Online Statistics"
|
||||
@handler DeviceOnlineStatistics
|
||||
get /device_online_statistics returns (GetDeviceOnlineStatsResponse)
|
||||
|
||||
@doc "Delete Current User Account"
|
||||
@handler DeleteCurrentUserAccount
|
||||
delete /current_user_account
|
||||
|
||||
@doc "Bind Email With Verification"
|
||||
@handler BindEmailWithVerification
|
||||
post /bind_email_with_verification (BindEmailWithVerificationRequest) returns (BindEmailWithVerificationResponse)
|
||||
|
||||
@doc "Bind Invite Code"
|
||||
@handler BindInviteCode
|
||||
post /bind_invite_code (BindInviteCodeRequest)
|
||||
|
||||
@doc "Delete Account"
|
||||
@handler DeleteAccount
|
||||
post /delete_account (DeleteAccountRequest) returns (DeleteAccountResponse)
|
||||
|
||||
@doc "Get Agent Downloads"
|
||||
@handler GetAgentDownloads
|
||||
get /agent/downloads (GetAgentDownloadsRequest) returns (GetAgentDownloadsResponse)
|
||||
get /agent_downloads (GetAgentDownloadsRequest) returns (GetAgentDownloadsResponse)
|
||||
|
||||
@doc "Get Agent Realtime"
|
||||
@handler GetAgentRealtime
|
||||
get /agent_realtime (GetAgentRealtimeRequest) returns (GetAgentRealtimeResponse)
|
||||
|
||||
@doc "Get Invite Sales"
|
||||
@handler GetInviteSales
|
||||
get /invite_sales (GetInviteSalesRequest) returns (GetInviteSalesResponse)
|
||||
|
||||
@doc "Get Subscribe Status"
|
||||
@handler GetSubscribeStatus
|
||||
get /subscribe_status (GetSubscribeStatusRequest) returns (GetSubscribeStatusResponse)
|
||||
|
||||
@doc "Get User Invite Stats"
|
||||
@handler GetUserInviteStats
|
||||
get /invite_stats (GetUserInviteStatsRequest) returns (GetUserInviteStatsResponse)
|
||||
}
|
||||
|
||||
@server (
|
||||
prefix: v1/public/user
|
||||
group: public/user/ws
|
||||
middleware: AuthMiddleware
|
||||
)
|
||||
service ppanel {
|
||||
@doc "Webosocket Device Connect"
|
||||
@handler DeviceWsConnect
|
||||
get /device_ws_connect
|
||||
}
|
||||
|
||||
|
||||
224
apis/types.api
224
apis/types.api
@ -19,23 +19,33 @@ type (
|
||||
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"`
|
||||
RefererId int64 `json:"referer_id"`
|
||||
ShareLink string `json:"share_link,omitempty"`
|
||||
Enable bool `json:"enable"`
|
||||
IsAdmin bool `json:"is_admin,omitempty"`
|
||||
EnableBalanceNotify bool `json:"enable_balance_notify"`
|
||||
EnableLoginNotify bool `json:"enable_login_notify"`
|
||||
EnableSubscribeNotify bool `json:"enable_subscribe_notify"`
|
||||
EnableTradeNotify bool `json:"enable_trade_notify"`
|
||||
LastLoginTime int64 `json:"last_login_time"`
|
||||
MemberStatus string `json:"member_status"`
|
||||
Remark string `json:"remark"`
|
||||
AuthMethods []UserAuthMethod `json:"auth_methods"`
|
||||
UserDevices []UserDevice `json:"user_devices"`
|
||||
Rules []string `json:"rules"`
|
||||
LastLoginTime int64 `json:"last_login_time,omitempty"`
|
||||
MemberStatus string `json:"member_status,omitempty"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
DeletedAt int64 `json:"deleted_at,omitempty"`
|
||||
IsDel bool `json:"is_del,omitempty"`
|
||||
Remark string `json:"remark,omitempty"`
|
||||
PurchasedPackage string `json:"purchased_package,omitempty"`
|
||||
FamilyJoined bool `json:"family_joined,omitempty"`
|
||||
FamilyId int64 `json:"family_id,omitempty"`
|
||||
FamilyRole uint8 `json:"family_role,omitempty"`
|
||||
FamilyRoleName string `json:"family_role_name,omitempty"`
|
||||
FamilyOwnerUserId int64 `json:"family_owner_user_id,omitempty"`
|
||||
FamilyStatus string `json:"family_status,omitempty"`
|
||||
FamilyMemberCount int64 `json:"family_member_count,omitempty"`
|
||||
FamilyMaxMembers int64 `json:"family_max_members,omitempty"`
|
||||
}
|
||||
Follow {
|
||||
Id int64 `json:"id"`
|
||||
@ -77,6 +87,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"`
|
||||
}
|
||||
@ -91,17 +104,11 @@ type (
|
||||
SubscribeType string `json:"subscribe_type"`
|
||||
}
|
||||
ApplicationVersion {
|
||||
Id int64 `json:"id"`
|
||||
Url string `json:"url"`
|
||||
Version string `json:"version" validate:"required"`
|
||||
MinVersion string `json:"min_version"`
|
||||
ForceUpdate bool `json:"force_update"`
|
||||
Description map[string]string `json:"description"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileHash string `json:"file_hash"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
IsInReview bool `json:"is_in_review"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Id int64 `json:"id"`
|
||||
Url string `json:"url"`
|
||||
Version string `json:"version" validate:"required"`
|
||||
Description string `json:"description"`
|
||||
IsDefault bool `json:"is_default"`
|
||||
}
|
||||
ApplicationResponse {
|
||||
Applications []ApplicationResponseInfo `json:"applications"`
|
||||
@ -160,6 +167,7 @@ type (
|
||||
EnableIpRegisterLimit bool `json:"enable_ip_register_limit"`
|
||||
IpRegisterLimit int64 `json:"ip_register_limit"`
|
||||
IpRegisterLimitDuration int64 `json:"ip_register_limit_duration"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
}
|
||||
VerifyConfig {
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
@ -195,6 +203,7 @@ type (
|
||||
ForcedInvite bool `json:"forced_invite"`
|
||||
ReferralPercentage int64 `json:"referral_percentage"`
|
||||
OnlyFirstPurchase bool `json:"only_first_purchase"`
|
||||
GiftDays int64 `json:"gift_days"`
|
||||
}
|
||||
TelegramConfig {
|
||||
TelegramBotToken string `json:"telegram_bot_token"`
|
||||
@ -214,34 +223,36 @@ type (
|
||||
CurrencySymbol string `json:"currency_symbol"`
|
||||
}
|
||||
SubscribeDiscount {
|
||||
Quantity int64 `json:"quantity"`
|
||||
Discount int64 `json:"discount"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Discount float64 `json:"discount"`
|
||||
}
|
||||
Subscribe {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show bool `json:"show"`
|
||||
Sell bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
AllowDeduction bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset bool `json:"renewal_reset"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Language string `json:"language"`
|
||||
Description string `json:"description"`
|
||||
UnitPrice int64 `json:"unit_price"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Discount []SubscribeDiscount `json:"discount"`
|
||||
NodeCount int64 `json:"node_count"`
|
||||
Replacement int64 `json:"replacement"`
|
||||
Inventory int64 `json:"inventory"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
SpeedLimit int64 `json:"speed_limit"`
|
||||
DeviceLimit int64 `json:"device_limit"`
|
||||
Quota int64 `json:"quota"`
|
||||
Nodes []int64 `json:"nodes"`
|
||||
NodeTags []string `json:"node_tags"`
|
||||
Show bool `json:"show"`
|
||||
Sell bool `json:"sell"`
|
||||
Sort int64 `json:"sort"`
|
||||
DeductionRatio int64 `json:"deduction_ratio"`
|
||||
AllowDeduction bool `json:"allow_deduction"`
|
||||
ResetCycle int64 `json:"reset_cycle"`
|
||||
RenewalReset bool `json:"renewal_reset"`
|
||||
ShowOriginalPrice bool `json:"show_original_price"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
SubscribeGroup {
|
||||
Id int64 `json:"id"`
|
||||
@ -455,6 +466,28 @@ type (
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
RedemptionCode {
|
||||
Id int64 `json:"id"`
|
||||
Code string `json:"code"`
|
||||
TotalCount int64 `json:"total_count"`
|
||||
UsedCount int64 `json:"used_count"`
|
||||
SubscribePlan int64 `json:"subscribe_plan"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Status int64 `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
RedemptionRecord {
|
||||
Id int64 `json:"id"`
|
||||
RedemptionCodeId int64 `json:"redemption_code_id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
UnitTime string `json:"unit_time"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
RedeemedAt int64 `json:"redeemed_at"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
}
|
||||
Announcement {
|
||||
Id int64 `json:"id"`
|
||||
Title string `json:"title"`
|
||||
@ -466,22 +499,27 @@ type (
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
UserSubscribe {
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Subscribe Subscribe `json:"subscribe"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
FinishedAt int64 `json:"finished_at"`
|
||||
ResetTime int64 `json:"reset_time"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
Id int64 `json:"id"`
|
||||
UserId int64 `json:"user_id"`
|
||||
OrderId int64 `json:"order_id"`
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Subscribe Subscribe `json:"subscribe"`
|
||||
StartTime int64 `json:"start_time"`
|
||||
ExpireTime int64 `json:"expire_time"`
|
||||
FinishedAt int64 `json:"finished_at"`
|
||||
ResetTime int64 `json:"reset_time"`
|
||||
Traffic int64 `json:"traffic"`
|
||||
Download int64 `json:"download"`
|
||||
Upload int64 `json:"upload"`
|
||||
Token string `json:"token"`
|
||||
Status uint8 `json:"status"`
|
||||
EntitlementSource string `json:"entitlement_source"`
|
||||
EntitlementOwnerUserId int64 `json:"entitlement_owner_user_id"`
|
||||
ReadOnly bool `json:"read_only"`
|
||||
IsGift bool `json:"is_gift"`
|
||||
Short string `json:"short"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
UserAffiliate {
|
||||
Avatar string `json:"avatar"`
|
||||
@ -513,6 +551,31 @@ type (
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
UpdatedAt int64 `json:"updated_at"`
|
||||
}
|
||||
FamilySummary {
|
||||
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"`
|
||||
}
|
||||
FamilyMemberItem {
|
||||
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"`
|
||||
}
|
||||
FamilyDetail {
|
||||
Summary FamilySummary `json:"summary"`
|
||||
Members []FamilyMemberItem `json:"members"`
|
||||
}
|
||||
UserAuthMethod {
|
||||
AuthType string `json:"auth_type"`
|
||||
AuthIdentifier string `json:"auth_identifier"`
|
||||
@ -588,7 +651,7 @@ type (
|
||||
//public order
|
||||
PurchaseOrderRequest {
|
||||
SubscribeId int64 `json:"subscribe_id"`
|
||||
Quantity int64 `json:"quantity" validate:"required,gt=0"`
|
||||
Quantity int64 `json:"quantity" validate:"required,gt=0,lte=1000"`
|
||||
Payment int64 `json:"payment,omitempty"`
|
||||
Coupon string `json:"coupon,omitempty"`
|
||||
}
|
||||
@ -602,16 +665,18 @@ type (
|
||||
FeeAmount int64 `json:"fee_amount"`
|
||||
}
|
||||
PurchaseOrderResponse {
|
||||
OrderNo string `json:"order_no"`
|
||||
OrderNo string `json:"order_no"`
|
||||
AppAccountToken string `json:"app_account_token"`
|
||||
}
|
||||
RenewalOrderRequest {
|
||||
UserSubscribeID int64 `json:"user_subscribe_id"`
|
||||
Quantity int64 `json:"quantity"`
|
||||
Quantity int64 `json:"quantity" validate:"lte=1000"`
|
||||
Payment int64 `json:"payment"`
|
||||
Coupon string `json:"coupon,omitempty"`
|
||||
}
|
||||
RenewalOrderResponse {
|
||||
OrderNo string `json:"order_no"`
|
||||
OrderNo string `json:"order_no"`
|
||||
AppAccountToken string `json:"app_account_token"`
|
||||
}
|
||||
ResetTrafficOrderRequest {
|
||||
UserSubscribeID int64 `json:"user_subscribe_id"`
|
||||
@ -621,7 +686,7 @@ type (
|
||||
OrderNo string `json:"order_no"`
|
||||
}
|
||||
RechargeOrderRequest {
|
||||
Amount int64 `json:"amount"`
|
||||
Amount int64 `json:"amount" validate:"required,gt=0,lte=2000000000"`
|
||||
Payment int64 `json:"payment"`
|
||||
}
|
||||
RechargeOrderResponse {
|
||||
@ -644,8 +709,8 @@ type (
|
||||
QueryOrderListRequest {
|
||||
Page int `form:"page" validate:"required"`
|
||||
Size int `form:"size" validate:"required"`
|
||||
Status uint8 `form:"status,omitempty"`
|
||||
Search string `form:"search,omitempty"`
|
||||
Status int `form:"status,optional"`
|
||||
Search string `form:"search,optional"`
|
||||
}
|
||||
QueryOrderListResponse {
|
||||
Total int64 `json:"total"`
|
||||
@ -734,6 +799,31 @@ type (
|
||||
Type string `json:"type"`
|
||||
CheckoutUrl string `json:"checkout_url,omitempty"`
|
||||
Stripe *StripePayment `json:"stripe,omitempty"`
|
||||
ProductIds []string `json:"product_ids,omitempty"`
|
||||
}
|
||||
AttachAppleTransactionRequest {
|
||||
OrderNo string `json:"order_no" validate:"required"`
|
||||
SignedTransactionJWS string `json:"signed_transaction_jws" validate:"required"`
|
||||
SubscribeId int64 `json:"subscribe_id,omitempty"`
|
||||
DurationDays int64 `json:"duration_days,omitempty"`
|
||||
Tier string `json:"tier,omitempty"`
|
||||
}
|
||||
AttachAppleTransactionByIdRequest {
|
||||
OrderNo string `json:"order_no" validate:"required"`
|
||||
TransactionId string `json:"transaction_id" validate:"required"`
|
||||
Sandbox *bool `json:"sandbox,omitempty"`
|
||||
}
|
||||
AttachAppleTransactionResponse {
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
RestoreAppleTransactionsRequest {
|
||||
Transactions []string `json:"transactions" validate:"required"`
|
||||
}
|
||||
GetAppleStatusResponse {
|
||||
Active bool `json:"active"`
|
||||
ExpiresAt int64 `json:"expires_at"`
|
||||
Tier string `json:"tier"`
|
||||
}
|
||||
SiteCustomDataContacts {
|
||||
Email string `json:"email"`
|
||||
@ -855,5 +945,9 @@ type (
|
||||
CertDNSProvider string `json:"cert_dns_provider,omitempty"` // DNS provider for certificate
|
||||
CertDNSEnv string `json:"cert_dns_env,omitempty"` // Environment for DNS provider
|
||||
}
|
||||
// reset user subscribe token
|
||||
ResetUserSubscribeTokenRequest {
|
||||
UserSubscribeId int64 `json:"user_subscribe_id"`
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -1,21 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 批量解密 Nginx 日志中的下载请求
|
||||
# 用法: ./batch_decrypt_logs.sh [日志文件路径]
|
||||
|
||||
LOG_FILE="${1:-/var/log/nginx/access.log}"
|
||||
|
||||
if [ ! -f "$LOG_FILE" ]; then
|
||||
echo "错误: 日志文件不存在: $LOG_FILE"
|
||||
echo "用法: $0 [日志文件路径]"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "正在处理日志文件: $LOG_FILE"
|
||||
echo "提取包含 /v1/common/client/download 的请求..."
|
||||
echo ""
|
||||
|
||||
# 提取所有 download 请求并传递给解密工具
|
||||
grep "/v1/common/client/download" "$LOG_FILE" | \
|
||||
head -n 100 | \
|
||||
xargs -I {} go run cmd/decrypt_download_data/main.go "{}"
|
||||
@ -1,4 +0,0 @@
|
||||
SET CGO_ENABLED=0
|
||||
SET GOOS=linux
|
||||
SET GOARCH=amd64
|
||||
go build -o ppanel .\ppanel.go
|
||||
BIN
generate/gopure-darwin-amd64 → cache/GeoLite2-City.mmdb
vendored
Executable file → Normal file
BIN
generate/gopure-darwin-amd64 → cache/GeoLite2-City.mmdb
vendored
Executable file → Normal file
Binary file not shown.
|
Before Width: | Height: | Size: 45 MiB After Width: | Height: | Size: 61 MiB |
@ -1,63 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
|
||||
_ "github.com/go-sql-driver/mysql"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
)
|
||||
|
||||
var configFile string
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&configFile, "config", "configs/ppanel.yaml", "config file path")
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
var c config.Config
|
||||
conf.MustLoad(configFile, &c)
|
||||
|
||||
// Construct DSN
|
||||
m := orm.Mysql{Config: c.MySQL}
|
||||
dsn := m.Dsn()
|
||||
|
||||
log.Println("Connecting to database...")
|
||||
db, err := sql.Open("mysql", dsn+"&multiStatements=true")
|
||||
if err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if err := db.Ping(); err != nil {
|
||||
log.Fatalf("Ping failed: %v", err)
|
||||
}
|
||||
|
||||
// 1. Check Version
|
||||
var version string
|
||||
if err := db.QueryRow("SELECT version()").Scan(&version); err != nil {
|
||||
log.Fatalf("Failed to select version: %v", err)
|
||||
}
|
||||
log.Printf("MySQL Version: %s", version)
|
||||
|
||||
// 2. Read SQL file directly to ensure we are testing what's on disk
|
||||
sqlBytes, err := os.ReadFile("initialize/migrate/database/02118_traffic_log_idx.up.sql")
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to read SQL file: %v", err)
|
||||
}
|
||||
sqlStmt := string(sqlBytes)
|
||||
|
||||
// 3. Test SQL
|
||||
log.Printf("Testing SQL from file:\n%s", sqlStmt)
|
||||
if _, err := db.Exec(sqlStmt); err != nil {
|
||||
log.Printf("SQL Execution Failed: %v", err)
|
||||
} else {
|
||||
log.Println("SQL Execution Success")
|
||||
}
|
||||
}
|
||||
@ -1,152 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 配置结构
|
||||
type AppConfig struct {
|
||||
JwtAuth struct {
|
||||
AccessSecret string `yaml:"AccessSecret"`
|
||||
} `yaml:"JwtAuth"`
|
||||
MySQL struct {
|
||||
Addr string `yaml:"Addr"`
|
||||
Dbname string `yaml:"Dbname"`
|
||||
Username string `yaml:"Username"`
|
||||
Password string `yaml:"Password"`
|
||||
Config string `yaml:"Config"`
|
||||
} `yaml:"MySQL"`
|
||||
Redis struct {
|
||||
Host string `yaml:"Host"`
|
||||
Pass string `yaml:"Pass"`
|
||||
DB int `yaml:"DB"`
|
||||
} `yaml:"Redis"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("====== 本地测试用户创建 ======")
|
||||
|
||||
// 1. 读取配置
|
||||
cfgData, err := os.ReadFile("configs/ppanel.yaml")
|
||||
if err != nil {
|
||||
fmt.Printf("读取配置失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
var cfg AppConfig
|
||||
if err := yaml.Unmarshal(cfgData, &cfg); err != nil {
|
||||
fmt.Printf("解析配置失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 连接 Redis
|
||||
rdb := redis.NewClient(&redis.Options{
|
||||
Addr: cfg.Redis.Host,
|
||||
Password: cfg.Redis.Pass,
|
||||
DB: cfg.Redis.DB,
|
||||
})
|
||||
ctx := context.Background()
|
||||
|
||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||
fmt.Printf("Redis 连接失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("✅ Redis 连接成功")
|
||||
|
||||
// 3. 连接数据库
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?%s",
|
||||
cfg.MySQL.Username, cfg.MySQL.Password, cfg.MySQL.Addr, cfg.MySQL.Dbname, cfg.MySQL.Config)
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Printf("数据库连接失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("✅ 数据库连接成功")
|
||||
|
||||
// 4. 查找一个有 refer_code 的用户
|
||||
var user struct {
|
||||
Id int64 `gorm:"column:id"`
|
||||
ReferCode string `gorm:"column:refer_code"`
|
||||
}
|
||||
result := db.Table("user").
|
||||
Where("refer_code IS NOT NULL AND refer_code != ''").
|
||||
First(&user)
|
||||
|
||||
if result.Error != nil {
|
||||
// 没有找到有 refer_code 的用户,查找第一个用户并添加 refer_code
|
||||
fmt.Println("没有找到有 refer_code 的用户,正在更新第一个用户...")
|
||||
result = db.Table("user").First(&user)
|
||||
if result.Error != nil {
|
||||
fmt.Printf("没有找到用户: %v\n", result.Error)
|
||||
return
|
||||
}
|
||||
// 更新 refer_code
|
||||
newReferCode := fmt.Sprintf("TEST%d", time.Now().Unix()%10000)
|
||||
db.Table("user").Where("id = ?", user.Id).Update("refer_code", newReferCode)
|
||||
user.ReferCode = newReferCode
|
||||
fmt.Printf("已为用户 ID=%d 添加 refer_code: %s\n", user.Id, newReferCode)
|
||||
}
|
||||
|
||||
fmt.Printf("✅ 找到用户: ID=%d, ReferCode=%s\n", user.Id, user.ReferCode)
|
||||
|
||||
// 5. 生成 JWT Token
|
||||
sessionId := uuid.New().String()
|
||||
now := time.Now()
|
||||
expireAt := now.Add(time.Hour * 24 * 7) // 7 天
|
||||
|
||||
claims := jwt.MapClaims{
|
||||
"UserId": user.Id,
|
||||
"SessionId": sessionId,
|
||||
"DeviceId": 0,
|
||||
"LoginType": "",
|
||||
"iat": now.Unix(),
|
||||
"exp": expireAt.Unix(),
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
tokenString, err := token.SignedString([]byte(cfg.JwtAuth.AccessSecret))
|
||||
if err != nil {
|
||||
fmt.Printf("生成 token 失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 6. 在 Redis 中创建 session
|
||||
// 正确格式:auth:session_id:sessionId = userId
|
||||
sessionKey := fmt.Sprintf("auth:session_id:%s", sessionId)
|
||||
|
||||
err = rdb.Set(ctx, sessionKey, fmt.Sprintf("%d", user.Id), time.Hour*24*7).Err()
|
||||
if err != nil {
|
||||
fmt.Printf("创建 session 失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Printf("✅ Session 创建成功: %s = %d\n", sessionKey, user.Id)
|
||||
|
||||
// 7. 清除旧的短链接缓存,确保重新生成
|
||||
cacheKey := "cache:invite:short_link:" + user.ReferCode
|
||||
rdb.Del(ctx, cacheKey)
|
||||
fmt.Printf("✅ 已清除旧缓存: %s\n", cacheKey)
|
||||
|
||||
// 7. 输出测试信息
|
||||
fmt.Println("\n====================================")
|
||||
fmt.Println("测试 Token 生成成功!")
|
||||
fmt.Println("====================================")
|
||||
fmt.Printf("\n用户 ID: %d\n", user.Id)
|
||||
fmt.Printf("邀请码: %s\n", user.ReferCode)
|
||||
fmt.Printf("Session ID: %s\n", sessionId)
|
||||
fmt.Printf("过期时间: %s\n", expireAt.Format("2006-01-02 15:04:05"))
|
||||
fmt.Println("\n====== Token ======")
|
||||
fmt.Println(tokenString)
|
||||
fmt.Println("\n====== 测试命令 ======")
|
||||
fmt.Printf("curl -s 'http://127.0.0.1:8080/v1/public/user/info' \\\n")
|
||||
fmt.Printf(" -H 'authorization: %s' | jq '.'\n", tokenString)
|
||||
}
|
||||
@ -1,249 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
pkgaes "github.com/perfect-panel/server/pkg/aes"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 通讯密钥
|
||||
communicationKey := "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx"
|
||||
|
||||
// 真实 Nginx 日志数据 - 从用户提供的日志中选取
|
||||
sampleLogs := []string{
|
||||
// 加密的下载请求 - 不同平台
|
||||
`172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] "GET /v1/common/client/download?data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000 HTTP/1.1" 200 201 "https://www.hifastvpn.com/" "AdsBot-Google (+http://www.google.com/adsbot.html)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] "GET /v1/common/client/download?data=%2FFTAxtcEd%2F8T2MzKdxxrPfWBXk4pNPbQZB3p8Yrl8XQ%3D&time=2026-02-02T04:35:15.031000 HTTP/1.1" 200 181 "https://www.hifastvpn.com/" "AdsBot-Google (+http://www.google.com/adsbot.html)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] "GET /v1/common/client/download?data=i18AVRwlVSuFrbf4NmId0RcTbj0tRJIBFHP0MxLjDmI%3D&time=2026-02-02T04:35:15.033000 HTTP/1.1" 200 201 "https://www.hifastvpn.com/" "AdsBot-Google (+http://www.google.com/adsbot.html)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:04:50:50 +0000] "GET /v1/common/client/download?platform=mac HTTP/1.1" 200 113 "https://gethifast.net/" "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:04:50:50 +0000] "GET /v1/common/client/download?platform=windows HTTP/1.1" 200 117 "https://gethifast.net/" "Mozilla/5.0 (compatible; AhrefsBot/7.0; +http://ahrefs.com/robot/)"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:05:24:16 +0000] "GET /v1/common/client/download?data=XfZsgEqUUQ0YBTT51ETQp2wheSvE4SRupBfYbiLnJOc%3D&time=2026-02-02T05:24:15.462000 HTTP/1.1" 200 181 "https://www.hifastvpn.com/" "Mozilla/5.0 (X11; CrOS x86_64 14541.0.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"`,
|
||||
// 真实用户下载
|
||||
`172.245.180.199 - - [02/Feb/2026:02:15:16 +0000] "GET /v1/common/client/download?data=XIZiz7c4sbUGE7Hl8fY6O2D5QKaZqx%2Fg81uR7kjenSg%3D&time=2026-02-02T02:15:16.337000 HTTP/1.1" 200 201 "https://hifastvpn.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"`,
|
||||
`172.245.180.199 - - [02/Feb/2026:02:18:09 +0000] "GET /v1/common/client/download?data=aB0HistwZTIhxJh6yIds%2B6knoyZC17KyxaXvyd3Z5LY%3D&time=2026-02-02T02:18:06.301000 HTTP/1.1" 200 201 "https://hifastvpn.com/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36"`,
|
||||
// 实际文件下载
|
||||
`111.55.176.116 - - [02/Feb/2026:02:19:02 +0000] "GET /v1/common/client/download/file/android-1.0.0.apk HTTP/2.0" 200 18546688 "https://hifastvpn.com/" "Mozilla/5.0 (Linux; Android 10; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Mobile Safari/537.36"`,
|
||||
`111.249.202.38 - - [02/Feb/2026:03:14:46 +0000] "GET /v1/common/client/download/file/mac-1.0.0.dmg HTTP/2.0" 200 72821392 "https://hifastvpn.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.7091.96 Safari/537.36"`,
|
||||
// Windows 用户
|
||||
`172.245.180.199 - - [02/Feb/2026:02:23:55 +0000] "GET /v1/common/client/download?data=t8OIVjnZx1N7w5ras4oVH9V0wz4JYlR7849WYKvbj9E%3D&time=2026-02-02T02:23:56.110000 HTTP/1.1" 200 201 "https://hifastvpn.com/" "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.7149.88 Safari/537.36"`,
|
||||
// Mac 用户
|
||||
`172.245.180.199 - - [02/Feb/2026:03:14:10 +0000] "GET /v1/common/client/download?data=mGKSxZtL7Ptf30MgFzBJPIsURC%2FkOf2lOGaXQOQ5Ft8%3D&time=2026-02-02T03:14:07.667000 HTTP/1.1" 200 181 "https://hifastvpn.com/" "Mozilla/5.0 (Macintosh; Intel Mac OS X 12.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/132.0.7091.96 Safari/537.36"`,
|
||||
// Android 移动端
|
||||
`172.245.180.199 - - [02/Feb/2026:03:19:41 +0000] "GET /v1/common/client/download?data=y7gttvd%2BoKf9%2BZUeNTsOvuFHwOLFBByrNjkvhPkVykg%3D&time=2026-02-02T03:19:42.192000 HTTP/1.1" 200 201 "https://hifastvpn.com/" "Mozilla/5.0 (Linux; Android 15; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.7559.59 Mobile Safari/537.36"`,
|
||||
`183.171.68.186 - - [02/Feb/2026:03:19:47 +0000] "GET /v1/common/client/download/file/android-1.0.0.apk HTTP/1.1" 200 179890 "https://hifastvpn.com/" "Mozilla/5.0 (Linux; Android 15; K) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/144.0.7559.59 Mobile Safari/537.36"`,
|
||||
}
|
||||
|
||||
// 如果命令行提供了参数,使用命令行参数
|
||||
if len(os.Args) > 1 {
|
||||
sampleLogs = os.Args[1:]
|
||||
}
|
||||
|
||||
fmt.Println("=== Nginx 下载日志解密工具 ===")
|
||||
fmt.Printf("通讯密钥: %s\n\n", communicationKey)
|
||||
|
||||
// 统计数据
|
||||
stats := make(map[string]int)
|
||||
successCount := 0
|
||||
|
||||
for i, logLine := range sampleLogs {
|
||||
// 提取日志条目
|
||||
entry := extractLogEntry(logLine)
|
||||
if entry.Data == "" && entry.Platform == "" {
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Println("⚠️ 跳过: 未找到 data 或 platform 参数\n")
|
||||
continue
|
||||
}
|
||||
|
||||
// 如果有 platform 参数(非加密),直接使用
|
||||
if entry.Platform != "" {
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Printf("📍 IP地址: %s\n", entry.IP)
|
||||
fmt.Printf("🌐 来源: %s\n", entry.Referer)
|
||||
fmt.Printf("🔓 平台: %s (未加密)\n\n", entry.Platform)
|
||||
stats[entry.Platform]++
|
||||
successCount++
|
||||
continue
|
||||
}
|
||||
|
||||
// 处理加密的 data 参数
|
||||
if entry.Data == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// URL 解码
|
||||
decodedData, err := url.QueryUnescape(entry.Data)
|
||||
if err != nil {
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Printf("❌ 错误: URL 解码失败: %v\n\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
// 提取 nonce (IV) - 从 time 参数转换
|
||||
nonce := extractNonceFromTime(entry.Time)
|
||||
|
||||
// AES 解密
|
||||
plainText, err := pkgaes.Decrypt(decodedData, communicationKey, nonce)
|
||||
if err != nil {
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Printf("❌ 错误: 解密失败: %v\n", err)
|
||||
fmt.Printf(" IP: %s, Nonce: %s\n\n", entry.IP, nonce)
|
||||
continue
|
||||
}
|
||||
|
||||
// 解析 JSON 获取平台信息
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(plainText), &result); err == nil {
|
||||
if platform, ok := result["platform"].(string); ok {
|
||||
stats[platform]++
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Printf("--- 日志 #%d ---\n", i+1)
|
||||
fmt.Printf("📍 IP地址: %s\n", entry.IP)
|
||||
fmt.Printf("🌐 来源: %s\n", entry.Referer)
|
||||
fmt.Printf("🔓 解密内容: %s\n\n", plainText)
|
||||
successCount++
|
||||
}
|
||||
|
||||
// 输出统计信息
|
||||
if successCount > 0 {
|
||||
fmt.Println("=" + strings.Repeat("=", 50))
|
||||
fmt.Printf("📊 统计信息 (成功解密: %d)\n", successCount)
|
||||
fmt.Println("=" + strings.Repeat("=", 50))
|
||||
for platform, count := range stats {
|
||||
fmt.Printf(" %s: %d 次\n", platform, count)
|
||||
}
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// LogEntry 表示解析后的日志条目
|
||||
type LogEntry struct {
|
||||
IP string
|
||||
Data string
|
||||
Time string
|
||||
Referer string
|
||||
Platform string
|
||||
}
|
||||
|
||||
// extractLogEntry 从日志行中提取所有关键信息
|
||||
func extractLogEntry(logLine string) *LogEntry {
|
||||
entry := &LogEntry{}
|
||||
|
||||
// 提取 IP 地址(第一个字段)
|
||||
parts := strings.Fields(logLine)
|
||||
if len(parts) > 0 {
|
||||
entry.IP = parts[0]
|
||||
}
|
||||
|
||||
// 提取 Referer 和 User-Agent
|
||||
// Nginx combined 格式:... "请求" 状态码 字节数 "Referer" "User-Agent"
|
||||
// 需要找到最后两对引号
|
||||
quotes := []int{}
|
||||
for i := 0; i < len(logLine); i++ {
|
||||
if logLine[i] == '"' {
|
||||
quotes = append(quotes, i)
|
||||
}
|
||||
}
|
||||
|
||||
// 至少需要 6 个引号: "GET ..." "Referer" "User-Agent"
|
||||
if len(quotes) >= 6 {
|
||||
// 倒数第 4 和第 3 个引号之间是 Referer
|
||||
refererStart := quotes[len(quotes)-4]
|
||||
refererEnd := quotes[len(quotes)-3]
|
||||
entry.Referer = logLine[refererStart+1 : refererEnd]
|
||||
|
||||
// 倒数第 2 和第 1 个引号之间是 User-Agent
|
||||
// 如果需要也可以提取
|
||||
// uaStart := quotes[len(quotes)-2]
|
||||
// uaEnd := quotes[len(quotes)-1]
|
||||
// entry.UserAgent = logLine[uaStart+1 : uaEnd]
|
||||
}
|
||||
|
||||
// 查找 ? 后面的查询字符串
|
||||
idx := strings.Index(logLine, "?")
|
||||
// 如果没有查询参数,检查是否是直接文件下载
|
||||
if idx == -1 {
|
||||
// 检查是否包含 /v1/common/client/download/file/
|
||||
filePrefix := "/v1/common/client/download/file/"
|
||||
fileIdx := strings.Index(logLine, filePrefix)
|
||||
if fileIdx != -1 {
|
||||
// 提取文件名部分
|
||||
// URL 形式可能是: /v1/common/client/download/file/Hi%E5%BF%ABVPN-windows-1.0.0.exe HTTP/1.1
|
||||
// 需要截取到空格
|
||||
pathStart := fileIdx + len(filePrefix)
|
||||
pathEnd := strings.Index(logLine[pathStart:], " ")
|
||||
if pathEnd != -1 {
|
||||
filePath := logLine[pathStart : pathStart+pathEnd]
|
||||
// URL 解码
|
||||
decodedPath, err := url.QueryUnescape(filePath)
|
||||
if err == nil {
|
||||
// 转换为小写以便匹配
|
||||
lowerPath := strings.ToLower(decodedPath)
|
||||
if strings.Contains(lowerPath, "windows") || strings.HasSuffix(lowerPath, ".exe") {
|
||||
entry.Platform = "windows"
|
||||
} else if strings.Contains(lowerPath, "mac") || strings.HasSuffix(lowerPath, ".dmg") {
|
||||
entry.Platform = "mac"
|
||||
} else if strings.Contains(lowerPath, "android") || strings.HasSuffix(lowerPath, ".apk") {
|
||||
entry.Platform = "android"
|
||||
} else if strings.Contains(lowerPath, "ios") || strings.HasSuffix(lowerPath, ".ipa") {
|
||||
entry.Platform = "ios"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
queryStr := logLine[idx+1:]
|
||||
// 截取到空格或 HTTP/
|
||||
endIdx := strings.Index(queryStr, " ")
|
||||
if endIdx != -1 {
|
||||
queryStr = queryStr[:endIdx]
|
||||
}
|
||||
|
||||
// 解析查询参数
|
||||
params := strings.Split(queryStr, "&")
|
||||
for _, param := range params {
|
||||
kv := strings.SplitN(param, "=", 2)
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
switch kv[0] {
|
||||
case "data":
|
||||
entry.Data = kv[1]
|
||||
case "time":
|
||||
entry.Time = kv[1]
|
||||
case "platform":
|
||||
entry.Platform = kv[1]
|
||||
}
|
||||
}
|
||||
|
||||
return entry
|
||||
}
|
||||
|
||||
// extractNonceFromTime 从 time 参数中提取 nonce
|
||||
// time 格式: 2026-02-02T04:35:15.032000
|
||||
// 需要转换为纳秒时间戳的十六进制
|
||||
func extractNonceFromTime(timeStr string) string {
|
||||
if timeStr == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
// URL 解码
|
||||
decoded, err := url.QueryUnescape(timeStr)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
// 简化处理:直接使用整个时间字符串作为 nonce
|
||||
// 因为原始代码使用 time.Now().UnixNano() 的十六进制
|
||||
// 但是从日志中我们无法准确还原原始的 nonce
|
||||
// 所以尝试使用 time 字符串本身
|
||||
return decoded
|
||||
}
|
||||
@ -1,38 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
|
||||
"github.com/perfect-panel/server/initialize/migrate"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
)
|
||||
|
||||
var configFile string
|
||||
|
||||
func init() {
|
||||
flag.StringVar(&configFile, "config", "configs/ppanel.yaml", "config file path")
|
||||
}
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
|
||||
var c config.Config
|
||||
conf.MustLoad(configFile, &c)
|
||||
|
||||
// Construct DSN
|
||||
m := orm.Mysql{Config: c.MySQL}
|
||||
dsn := m.Dsn()
|
||||
|
||||
log.Println("Connecting to database...")
|
||||
client := migrate.Migrate(dsn)
|
||||
|
||||
log.Println("Forcing version 2117...")
|
||||
if err := client.Force(2117); err != nil {
|
||||
log.Fatalf("Failed to force version: %v", err)
|
||||
}
|
||||
|
||||
log.Println("Force version 2117 success")
|
||||
}
|
||||
@ -23,7 +23,6 @@ import (
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/service"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
"github.com/perfect-panel/server/pkg/trace"
|
||||
"github.com/perfect-panel/server/queue"
|
||||
"github.com/perfect-panel/server/scheduler"
|
||||
"github.com/spf13/cobra"
|
||||
@ -50,7 +49,6 @@ var startCmd = &cobra.Command{
|
||||
func run() {
|
||||
services := getServers()
|
||||
defer services.Stop()
|
||||
defer trace.StopAgent()
|
||||
go services.Start()
|
||||
quit := make(chan os.Signal, 1)
|
||||
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
|
||||
@ -91,13 +89,8 @@ func getServers() *service.Group {
|
||||
logger.Errorf("Logger setup failed: %v", err.Error())
|
||||
}
|
||||
|
||||
// init trace
|
||||
trace.StartAgent(c.Trace)
|
||||
|
||||
// init service context
|
||||
ctx := svc.NewServiceContext(c)
|
||||
// init system config
|
||||
initialize.StartInitSystemConfig(ctx)
|
||||
services := service.NewServiceGroup()
|
||||
services.Add(internal.NewService(ctx))
|
||||
services.Add(queue.NewService(ctx))
|
||||
|
||||
@ -1,171 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// 配置区域 - 请在此处填入您的真实信息进行测试
|
||||
const (
|
||||
// 必填:您的 Key ID (从 App Store Connect 获取)
|
||||
KeyID = "2C4X3HVPM8"
|
||||
|
||||
// 必填:您的 Issuer ID (从 App Store Connect 获取,通常是一个 UUID)
|
||||
IssuerID = "34f54810-5118-4b7f-8069-c8c1e012b7a9" // 请替换为您真实的 Issuer ID
|
||||
|
||||
// 必填:您的 Bundle ID (App 的包名)
|
||||
BundleID = "com.taw.hifastvpn" // 请替换为您真实的 Bundle ID
|
||||
|
||||
// 必填:用于测试的 Transaction ID (任意一个真实的交易 ID)
|
||||
TestTransactionID = "2000001083318819"
|
||||
|
||||
// 必填:是否为沙盒环境
|
||||
IsSandbox = true
|
||||
)
|
||||
|
||||
// P8 私钥内容 (硬编码用于测试)
|
||||
const PrivateKeyPEM = `-----BEGIN PRIVATE KEY-----
|
||||
MIGTAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBHkwdwIBAQQgsVDj0g/D7uNCm8aC
|
||||
E4TuaiDT4Pgb1IuuZ69YdGNvcAegCgYIKoZIzj0DAQehRANCAARObgGumaESbPMM
|
||||
SIRDAVLcWemp0fMlnfDE4EHmqcD58arEJWsr3aWEhc4BHocOUIGjko0cVWGchrFa
|
||||
/T/KG1tr
|
||||
-----END PRIVATE KEY-----`
|
||||
|
||||
func main() {
|
||||
log.Println("开始测试 Apple IAP API 连接...")
|
||||
log.Printf("环境: %v (Sandbox=%v)\n", func() string {
|
||||
if IsSandbox {
|
||||
return "沙盒 (Sandbox)"
|
||||
}
|
||||
return "生产 (Production)"
|
||||
}(), IsSandbox)
|
||||
log.Printf("KeyID: %s\n", KeyID)
|
||||
log.Printf("IssuerID: %s\n", IssuerID)
|
||||
log.Printf("BundleID: %s\n", BundleID)
|
||||
log.Printf("TransactionID: %s\n", TestTransactionID)
|
||||
|
||||
token, err := buildAPIToken()
|
||||
if err != nil {
|
||||
log.Fatalf("生成 JWT Token 失败: %v", err)
|
||||
}
|
||||
log.Println("JWT Token 生成成功")
|
||||
|
||||
// 发起请求
|
||||
host := "https://api.storekit.itunes.apple.com"
|
||||
if IsSandbox {
|
||||
host = "https://api.storekit-sandbox.itunes.apple.com"
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/inApps/v1/transactions/%s", host, TestTransactionID)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
|
||||
log.Printf("正在请求: %s", url)
|
||||
start := time.Now()
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
log.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
duration := time.Since(start)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
log.Printf("请求耗时: %v", duration)
|
||||
log.Printf("状态码: %d", resp.StatusCode)
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
log.Println("✅ 测试成功!API 调用正常。")
|
||||
log.Printf("响应内容: %s", string(body))
|
||||
} else {
|
||||
log.Println("❌ 测试失败!")
|
||||
log.Printf("错误响应: %s", string(body))
|
||||
if resp.StatusCode == 401 {
|
||||
log.Println("原因分析: 401 Unauthorized 通常表示:")
|
||||
log.Println("1. Key ID 或 Issuer ID 错误")
|
||||
log.Println("2. Bundle ID 不匹配")
|
||||
log.Println("3. 私钥错误")
|
||||
log.Println("4. Token 格式错误 (如算法或 Claims)")
|
||||
} else if resp.StatusCode == 404 {
|
||||
log.Println("原因分析: 404 Not Found 通常表示 Transaction ID 不存在或环境(沙盒/生产)选错了")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 下面是复制过来的工具函数
|
||||
func buildAPIToken() (string, error) {
|
||||
header := map[string]interface{}{
|
||||
"alg": "ES256",
|
||||
"kid": KeyID,
|
||||
"typ": "JWT",
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
payload := map[string]interface{}{
|
||||
"iss": IssuerID,
|
||||
"iat": now,
|
||||
"exp": now + 60, // 测试 Token 有效期短一点即可
|
||||
"aud": "appstoreconnect-v1",
|
||||
}
|
||||
if BundleID != "" {
|
||||
payload["bid"] = BundleID
|
||||
}
|
||||
|
||||
hb, _ := json.Marshal(header)
|
||||
pb, _ := json.Marshal(payload)
|
||||
|
||||
enc := func(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
unsigned := fmt.Sprintf("%s.%s", enc(hb), enc(pb))
|
||||
|
||||
block, _ := pem.Decode([]byte(PrivateKeyPEM))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid private key")
|
||||
}
|
||||
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
priv, ok := keyAny.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("private key is not ECDSA")
|
||||
}
|
||||
|
||||
digest := sha256Sum([]byte(unsigned))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, priv, digest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
curveBits := priv.Curve.Params().BitSize
|
||||
keyBytes := curveBits / 8
|
||||
if curveBits%8 > 0 {
|
||||
keyBytes += 1
|
||||
}
|
||||
rBytes := r.Bytes()
|
||||
rBytesPadded := make([]byte, keyBytes)
|
||||
copy(rBytesPadded[keyBytes-len(rBytes):], rBytes)
|
||||
|
||||
sBytes := s.Bytes()
|
||||
sBytesPadded := make([]byte, keyBytes)
|
||||
copy(sBytesPadded[keyBytes-len(sBytes):], sBytes)
|
||||
|
||||
sig := append(rBytesPadded, sBytesPadded...)
|
||||
return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
func sha256Sum(b []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/initialize"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
loggerLog "github.com/perfect-panel/server/internal/model/log"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("etc/ppanel.yaml", &c)
|
||||
|
||||
fmt.Println("Initializing ServiceContext...")
|
||||
svcCtx := svc.NewServiceContext(c)
|
||||
initialize.Email(svcCtx)
|
||||
fmt.Println("ServiceContext initialized.")
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 模拟真实数据
|
||||
content := map[string]interface{}{
|
||||
"Type": 1,
|
||||
"SiteLogo": c.Site.SiteLogo,
|
||||
"SiteName": c.Site.SiteName,
|
||||
"Expire": 15,
|
||||
"Code": "123456",
|
||||
}
|
||||
|
||||
messageLog := loggerLog.Message{
|
||||
Platform: svcCtx.Config.Email.Platform,
|
||||
To: "shanshanzhong147@gmail.com",
|
||||
Subject: "PPanel Test - Verify Email (Register)",
|
||||
Content: content,
|
||||
Status: 1,
|
||||
}
|
||||
|
||||
emailLog, err := messageLog.Marshal()
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
systemLog := &loggerLog.SystemLog{
|
||||
Type: loggerLog.TypeEmailMessage.Uint8(),
|
||||
Date: time.Now().Format("2006-01-02"),
|
||||
ObjectID: 0,
|
||||
Content: string(emailLog),
|
||||
}
|
||||
|
||||
fmt.Println("Attempting to insert into system_logs...")
|
||||
err = svcCtx.LogModel.Insert(ctx, systemLog)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ Insert failed!\n")
|
||||
fmt.Printf("Error Type: %T\n", err)
|
||||
fmt.Printf("Error String: %s\n", err.Error())
|
||||
fmt.Printf("Detailed Error: %+v\n", err)
|
||||
} else {
|
||||
fmt.Println("✅ Insert successful!")
|
||||
}
|
||||
}
|
||||
@ -1,119 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/perfect-panel/server/initialize"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/conf"
|
||||
emailLogic "github.com/perfect-panel/server/queue/logic/email"
|
||||
"github.com/perfect-panel/server/queue/types"
|
||||
)
|
||||
|
||||
func main() {
|
||||
var c config.Config
|
||||
conf.MustLoad("etc/ppanel.yaml", &c)
|
||||
|
||||
if !c.Email.Enable {
|
||||
log.Fatal("Email is disabled in config. Please enable it in etc/ppanel.yaml")
|
||||
}
|
||||
|
||||
// Initialize ServiceContext
|
||||
svcCtx := svc.NewServiceContext(c)
|
||||
initialize.Email(svcCtx)
|
||||
ctx := context.Background()
|
||||
|
||||
// Target email
|
||||
targetEmail := "shanshanzhong147@gmail.com"
|
||||
fmt.Printf("Preparing to send emails to: %s\n", targetEmail)
|
||||
|
||||
senderLogic := emailLogic.NewSendEmailLogic(svcCtx)
|
||||
|
||||
// 1. Verify Email (Register)
|
||||
fmt.Println("\n[1/5] Sending Registration/Verify Email...")
|
||||
send(ctx, senderLogic, types.SendEmailPayload{
|
||||
Type: types.EmailTypeVerify,
|
||||
Email: targetEmail,
|
||||
Subject: "PPanel Test - Verify Email (Register)",
|
||||
Content: map[string]interface{}{
|
||||
"Type": 1, // 1: Register
|
||||
"SiteLogo": c.Site.SiteLogo,
|
||||
"SiteName": c.Site.SiteName,
|
||||
"Expire": 15,
|
||||
"Code": "123456",
|
||||
},
|
||||
})
|
||||
|
||||
// 2. Verify Email (Password Reset)
|
||||
fmt.Println("\n[2/5] Sending Password Reset/Verify Email...")
|
||||
send(ctx, senderLogic, types.SendEmailPayload{
|
||||
Type: types.EmailTypeVerify,
|
||||
Email: targetEmail,
|
||||
Subject: "PPanel Test - Verify Email (Password Reset)",
|
||||
Content: map[string]interface{}{
|
||||
"Type": 2, // 2: Password Reset
|
||||
"SiteLogo": c.Site.SiteLogo,
|
||||
"SiteName": c.Site.SiteName,
|
||||
"Expire": 15,
|
||||
"Code": "654321",
|
||||
},
|
||||
})
|
||||
|
||||
// 3. Maintenance Email
|
||||
fmt.Println("\n[3/5] Sending Maintenance Email...")
|
||||
send(ctx, senderLogic, types.SendEmailPayload{
|
||||
Type: types.EmailTypeMaintenance,
|
||||
Email: targetEmail,
|
||||
Subject: "PPanel Test - Maintenance Notice",
|
||||
Content: map[string]interface{}{
|
||||
"SiteLogo": c.Site.SiteLogo,
|
||||
"SiteName": c.Site.SiteName,
|
||||
"MaintenanceDate": "2026-01-01",
|
||||
"MaintenanceTime": "12:00 - 14:00 (UTC+8)",
|
||||
},
|
||||
})
|
||||
|
||||
// 4. Expiration Email
|
||||
fmt.Println("\n[4/5] Sending Expiration Email...")
|
||||
send(ctx, senderLogic, types.SendEmailPayload{
|
||||
Type: types.EmailTypeExpiration,
|
||||
Email: targetEmail,
|
||||
Subject: "PPanel Test - Subscription Expiration",
|
||||
Content: map[string]interface{}{
|
||||
"SiteLogo": c.Site.SiteLogo,
|
||||
"SiteName": c.Site.SiteName,
|
||||
"ExpireDate": "2026-02-01",
|
||||
},
|
||||
})
|
||||
|
||||
// 5. Traffic Exceed Email
|
||||
fmt.Println("\n[5/5] Sending Traffic Exceed Email...")
|
||||
send(ctx, senderLogic, types.SendEmailPayload{
|
||||
Type: types.EmailTypeTrafficExceed,
|
||||
Email: targetEmail,
|
||||
Subject: "PPanel Test - Traffic Exceeded",
|
||||
Content: map[string]interface{}{
|
||||
"SiteLogo": c.Site.SiteLogo,
|
||||
"SiteName": c.Site.SiteName,
|
||||
"UsedTraffic": "100GB",
|
||||
"MaxTraffic": "100GB",
|
||||
},
|
||||
})
|
||||
|
||||
fmt.Println("\nAll tests completed. Please check your inbox.")
|
||||
}
|
||||
|
||||
func send(ctx context.Context, l *emailLogic.SendEmailLogic, payload types.SendEmailPayload) {
|
||||
data, _ := json.Marshal(payload)
|
||||
task := asynq.NewTask(types.ForthwithSendEmail, data)
|
||||
if err := l.ProcessTask(ctx, task); err != nil {
|
||||
fmt.Printf("❌ Failed to send %s: %v\n", payload.Type, err)
|
||||
} else {
|
||||
fmt.Printf("✅ Sent %s successfully.\n", payload.Type)
|
||||
}
|
||||
}
|
||||
@ -1,198 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/perfect-panel/server/internal/config"
|
||||
"github.com/perfect-panel/server/internal/model/order"
|
||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||
"github.com/perfect-panel/server/internal/model/user"
|
||||
"github.com/perfect-panel/server/internal/svc"
|
||||
"github.com/perfect-panel/server/pkg/orm"
|
||||
"github.com/perfect-panel/server/pkg/tool"
|
||||
orderLogic "github.com/perfect-panel/server/queue/logic/order"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 1. Setup Configuration
|
||||
c := config.Config{
|
||||
MySQL: orm.Config{
|
||||
Addr: "127.0.0.1:3306",
|
||||
Dbname: "dev_ppanel", // Using dev_ppanel as default, change if needed
|
||||
Username: "root",
|
||||
Password: "rootpassword",
|
||||
Config: "charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai",
|
||||
MaxIdleConns: 10,
|
||||
MaxOpenConns: 10,
|
||||
},
|
||||
Redis: config.RedisConfig{
|
||||
Host: "127.0.0.1:6379",
|
||||
DB: 0,
|
||||
},
|
||||
Invite: config.InviteConfig{
|
||||
GiftDays: 3, // Default gift days
|
||||
},
|
||||
}
|
||||
|
||||
// 2. Connect to Database & Redis
|
||||
db, err := orm.ConnectMysql(orm.Mysql{Config: c.MySQL})
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("DB Connection failed: %v", err))
|
||||
}
|
||||
rds := redis.NewClient(&redis.Options{
|
||||
Addr: c.Redis.Host,
|
||||
DB: c.Redis.DB,
|
||||
})
|
||||
|
||||
// 3. Initialize ServiceContext
|
||||
serviceCtx := svc.NewServiceContext(c)
|
||||
serviceCtx.DB = db
|
||||
serviceCtx.Redis = rds
|
||||
// We don't need queue/scheduler for this unit test
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 4. Run Scenarios
|
||||
fmt.Println("=== Starting Invite Reward Test ===")
|
||||
|
||||
// Scenario 1: Commission 0 (Expect Gift Days)
|
||||
runScenario(ctx, serviceCtx, "Scenario_0_Commission", 0)
|
||||
|
||||
// Scenario 2: Commission 10 (Expect Money)
|
||||
runScenario(ctx, serviceCtx, "Scenario_10_Commission", 10)
|
||||
}
|
||||
|
||||
func runScenario(ctx context.Context, s *svc.ServiceContext, name string, referralPercentage int64) {
|
||||
fmt.Printf("\n--- Running %s (ReferralPercentage: %d%%) ---\n", name, referralPercentage)
|
||||
|
||||
// Update Config
|
||||
s.Config.Invite.ReferralPercentage = referralPercentage
|
||||
|
||||
// Cleanup old data (Partial cleanup since we don't have email to query)
|
||||
// We'll rely on unique ReferCode / UUIDs to avoid collisions but DB might grow.
|
||||
// Actually we should try to clean up.
|
||||
// Since we removed Email from struct, we can't use it to query easily unless we check `auth_methods`.
|
||||
// For this test, let's just create new users.
|
||||
|
||||
// Create Referrer
|
||||
referrer := &user.User{
|
||||
Password: tool.EncodePassWord("123456"),
|
||||
ReferCode: fmt.Sprintf("REF%d", time.Now().UnixNano())[:20],
|
||||
ReferralPercentage: 0, // Use global settings
|
||||
Commission: 0,
|
||||
}
|
||||
// Use DB directly to ensure ID is updated in struct
|
||||
if err := s.DB.Create(referrer).Error; err != nil {
|
||||
fmt.Printf("Create Referrer Failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
// Force active subscription for referrer so they can receive gift time
|
||||
createActiveSubscription(ctx, s, referrer.Id)
|
||||
|
||||
fmt.Printf("Created Referrer: ID=%d, Commission=%d\n", referrer.Id, referrer.Commission)
|
||||
|
||||
// Create User (Invitee)
|
||||
invitee := &user.User{
|
||||
Password: tool.EncodePassWord("123456"),
|
||||
RefererId: referrer.Id,
|
||||
}
|
||||
if err := s.DB.Create(invitee).Error; err != nil {
|
||||
fmt.Printf("Create Invitee Failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
// Force active subscription for invitee to receive gift time
|
||||
_ = createActiveSubscription(ctx, s, invitee.Id)
|
||||
|
||||
fmt.Printf("Created Invitee: ID=%d, RefererID=%d\n", invitee.Id, invitee.RefererId)
|
||||
|
||||
// Create Order
|
||||
orderInfo := &order.Order{
|
||||
OrderNo: tool.GenerateTradeNo(),
|
||||
UserId: invitee.Id,
|
||||
Amount: 10000, // 100.00
|
||||
Price: 10000,
|
||||
FeeAmount: 0,
|
||||
Status: 2, // Paid
|
||||
Type: 1, // Subscribe
|
||||
IsNew: true,
|
||||
SubscribeId: 1, // Assume plan 1 exists
|
||||
Quantity: 1,
|
||||
}
|
||||
// We need a dummy subscribe plan in DB or use existing
|
||||
ensureSubscribePlan(ctx, s, 1)
|
||||
|
||||
// Execute Logic
|
||||
logic := orderLogic.NewActivateOrderLogic(s)
|
||||
|
||||
// We only simulate the commission part logic or NewPurchase
|
||||
// logic.NewPurchase does a lot of things.
|
||||
// Let's call NewPurchase to be realistic, but we need to ensure dependencies exist.
|
||||
// Instead of full NewPurchase which might fail on other things,
|
||||
// let's verify if we can just call handleCommission? No it's private.
|
||||
// So we call NewPurchase.
|
||||
|
||||
err := logic.NewPurchase(ctx, orderInfo)
|
||||
if err != nil {
|
||||
fmt.Printf("NewPurchase failed (expected for mocked env): %v\n", err)
|
||||
// If it failed because of things we don't care (like sending email), check data anyway
|
||||
} else {
|
||||
fmt.Println("NewPurchase executed successfully.")
|
||||
}
|
||||
|
||||
// Wait for async goroutines
|
||||
time.Sleep(2 * time.Second)
|
||||
|
||||
// Check Results
|
||||
// 1. Check Referrer Commission
|
||||
refRes, _ := s.UserModel.FindOne(ctx, referrer.Id)
|
||||
fmt.Printf("Result Referrer Commission: %d (Expected: %d)\n", refRes.Commission, int64(float64(orderInfo.Amount)*float64(referralPercentage)/100))
|
||||
|
||||
// 2. Check Gift Days (Check expiration time changes)
|
||||
// We compare with the initial subscription time
|
||||
// But since we just created it, it's simpler to check if 'ExpiryTime' is far in the future or extended.
|
||||
// For 0 commission, we expect gift days.
|
||||
|
||||
refSub, _ := s.UserModel.FindActiveSubscribe(ctx, referrer.Id)
|
||||
invSub, _ := s.UserModel.FindActiveSubscribe(ctx, invitee.Id)
|
||||
// Avoid panic if sub not found
|
||||
if refSub != nil {
|
||||
fmt.Printf("Result Referrer Sub Expire: %v\n", refSub.ExpireTime)
|
||||
} else {
|
||||
fmt.Println("Result Referrer Sub Expire: nil")
|
||||
}
|
||||
if invSub != nil {
|
||||
// NewPurchase renews/creates sub, so it should be valid + duration
|
||||
fmt.Printf("Result Invitee Sub Expire: %v\n", invSub.ExpireTime)
|
||||
} else {
|
||||
fmt.Println("Result Invitee Sub Expire: nil")
|
||||
}
|
||||
}
|
||||
|
||||
func createActiveSubscription(ctx context.Context, s *svc.ServiceContext, userId int64) *user.Subscribe {
|
||||
sub := &user.Subscribe{
|
||||
UserId: userId,
|
||||
Status: 1,
|
||||
ExpireTime: time.Now().Add(30 * 24 * time.Hour), // 30 days initial
|
||||
Token: uuid.New().String(),
|
||||
UUID: uuid.New().String(),
|
||||
}
|
||||
s.UserModel.InsertSubscribe(ctx, sub)
|
||||
return sub
|
||||
}
|
||||
|
||||
func ensureSubscribePlan(ctx context.Context, s *svc.ServiceContext, id int64) {
|
||||
_, err := s.SubscribeModel.FindOne(ctx, id)
|
||||
if err != nil {
|
||||
s.SubscribeModel.Insert(ctx, &subscribe.Subscribe{
|
||||
Id: id,
|
||||
Name: "Test Plan",
|
||||
UnitTime: "Day", // Days
|
||||
UnitPrice: 100,
|
||||
Sell: &[]bool{true}[0],
|
||||
})
|
||||
}
|
||||
}
|
||||
@ -1,59 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/kutt"
|
||||
)
|
||||
|
||||
// 测试 Kutt 短链接 API
|
||||
// 运行方式: go run cmd/test_kutt/main.go
|
||||
func main() {
|
||||
// Kutt 配置 - 请根据实际情况修改
|
||||
apiURL := "https://getsapp.net/api/v2"
|
||||
apiKey := "6JSjGOzLF1NCYQXuUGZjvrkqU0Jy3upDkYX87DPO"
|
||||
targetURL := "https://gethifast.net"
|
||||
|
||||
// 测试邀请码
|
||||
testInviteCode := "TEST123"
|
||||
|
||||
fmt.Println("====== Kutt 短链接 API 测试 ======")
|
||||
fmt.Printf("API URL: %s\n", apiURL)
|
||||
fmt.Printf("Target URL: %s\n", targetURL)
|
||||
fmt.Printf("测试邀请码: %s\n", testInviteCode)
|
||||
fmt.Println("----------------------------------")
|
||||
|
||||
// 创建客户端
|
||||
client := kutt.NewClient(apiURL, apiKey)
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试 1: 使用便捷方法创建邀请短链接
|
||||
fmt.Println("\n[测试 1] 创建邀请短链接...")
|
||||
shortLink, err := client.CreateInviteShortLink(ctx, targetURL, testInviteCode, "getsapp.net")
|
||||
if err != nil {
|
||||
log.Printf("❌ 创建短链接失败: %v\n", err)
|
||||
} else {
|
||||
fmt.Printf("✅ 短链接创建成功: %s\n", shortLink)
|
||||
}
|
||||
|
||||
// 测试 2: 使用完整参数创建短链接
|
||||
fmt.Println("\n[测试 2] 使用完整参数创建短链接...")
|
||||
req := &kutt.CreateLinkRequest{
|
||||
Target: fmt.Sprintf("%s/register?invite=%s", targetURL, "CUSTOM456"),
|
||||
Description: "Test custom short link",
|
||||
Reuse: true,
|
||||
}
|
||||
link, err := client.CreateShortLink(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("❌ 创建短链接失败: %v\n", err)
|
||||
} else {
|
||||
// 打印详细返回信息
|
||||
linkJSON, _ := json.MarshalIndent(link, "", " ")
|
||||
fmt.Printf("✅ 短链接创建成功:\n%s\n", string(linkJSON))
|
||||
}
|
||||
|
||||
fmt.Println("\n====== 测试完成 ======")
|
||||
}
|
||||
@ -1,101 +0,0 @@
|
||||
# OpenInstall API 测试结果
|
||||
|
||||
## 测试总结
|
||||
|
||||
✅ **成功连接到 OpenInstall API**
|
||||
|
||||
- API 基础 URL: `https://data.openinstall.com`
|
||||
- 测试的接口端点工作正常
|
||||
- HTTP 状态码: 200
|
||||
|
||||
## 当前问题
|
||||
|
||||
❌ **ApiKey 配置错误**
|
||||
|
||||
API 返回错误: `code=3, error="apiKey错误"`
|
||||
|
||||
## 问题分析
|
||||
|
||||
当前配置中:
|
||||
- `AppKey: alf57p` - 这是应用的标识符(AppKey),用于 SDK 集成
|
||||
- 但数据接口需要的是单独的 `apiKey`,这两者不同
|
||||
|
||||
## 解决方案
|
||||
|
||||
### 步骤 1: 在 OpenInstall 后台配置数据接口
|
||||
|
||||
1. 登录 OpenInstall 后台: https://www.openinstall.com
|
||||
2. 找到 **【数据接口】-【接口配置】** 菜单
|
||||
3. **开启数据接口开关**
|
||||
4. 获取 `apiKey` (这是专门用于数据接口的密钥,不同于 AppKey)
|
||||
|
||||
### 步骤 2: 更新配置文件
|
||||
|
||||
在 `ppanel-server/etc/ppanel.yaml` 中添加 `ApiKey`:
|
||||
|
||||
```yaml
|
||||
OpenInstall:
|
||||
Enable: true
|
||||
AppKey: "alf57p" # SDK 集成使用
|
||||
ApiKey: "your_api_key_from_backend" # 数据接口使用
|
||||
```
|
||||
|
||||
### 步骤 3: 重新测试
|
||||
|
||||
获取到正确的 apiKey 后,运行测试程序:
|
||||
|
||||
```bash
|
||||
cd cmd/test_openinstall
|
||||
go run main.go
|
||||
```
|
||||
|
||||
## 测试接口说明
|
||||
|
||||
测试程序当前测试了以下接口:
|
||||
|
||||
### 1. 新增安装数据 (Growth Data)
|
||||
- 端点: `/data/event/growth`
|
||||
- 功能: 获取指定时间范围内的访问量、点击量、安装量、注册量及留存数据
|
||||
- 参数:
|
||||
- `apiKey`: 数据接口密钥
|
||||
- `startDate`: 开始日期 (格式: 2006-01-02)
|
||||
- `endDate`: 结束日期
|
||||
- `statType`: 统计类型 (daily=按天, hourly=按小时, total=合计)
|
||||
|
||||
返回数据包括:
|
||||
- `visit`: 访问量
|
||||
- `click`: 点击量
|
||||
- `install`: 安装量
|
||||
- `register`: 注册量
|
||||
- `survive_d1`: 1日留存
|
||||
- `survive_d7`: 7日留存
|
||||
- `survive_d30`: 30日留存
|
||||
|
||||
### 2. 渠道列表 (Channel List)
|
||||
- 端点: `/data/channel/list`
|
||||
- 功能: 获取 H5 渠道列表
|
||||
- 参数:
|
||||
- `apiKey`: 数据接口密钥
|
||||
- `pageNum`: 页码
|
||||
- `pageSize`: 每页数量
|
||||
|
||||
## 更多可用接口
|
||||
|
||||
OpenInstall 数据接口还提供以下功能:
|
||||
|
||||
- 渠道分组管理 (创建、修改、删除)
|
||||
- 渠道管理 (创建、修改、删除、查询)
|
||||
- 子渠道管理
|
||||
- 存量设备数据
|
||||
- 活跃数据统计
|
||||
- 效果点数据
|
||||
- 设备分布统计
|
||||
|
||||
详细文档: https://www.openinstall.com/doc/data.html
|
||||
|
||||
## 下一步建议
|
||||
|
||||
1. **配置 ApiKey**: 按照上述步骤在 OpenInstall 后台获取并配置 apiKey
|
||||
2. **更新配置**: 将 apiKey 添加到 `ppanel.yaml` 配置文件
|
||||
3. **更新代码**: 修改 `pkg/openinstall/openinstall.go` 实现真实的 API 调用
|
||||
4. **测试验证**: 重新运行测试程序验证数据获取
|
||||
@ -1,254 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// OpenInstall 数据接口基础 URL
|
||||
apiBaseURL = "https://data.openinstall.com"
|
||||
|
||||
// 您的 ApiKey (数据接口密钥)
|
||||
apiKey = "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
)
|
||||
|
||||
// 通用响应结构
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Error *string `json:"error"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
// 新增安装数据
|
||||
type GrowthData struct {
|
||||
Date string `json:"date"`
|
||||
Visit int64 `json:"visit"` // 点击量
|
||||
Click int64 `json:"click"` // 访问量
|
||||
Install int64 `json:"install"` // 安装量
|
||||
Register int64 `json:"register"` // 注册量
|
||||
SurviveD1 int64 `json:"survive_d1"` // 1日留存
|
||||
SurviveD7 int64 `json:"survive_d7"` // 7日留存
|
||||
SurviveD30 int64 `json:"survive_d30"` // 30日留存
|
||||
}
|
||||
|
||||
// 渠道列表数据
|
||||
type ChannelData struct {
|
||||
ChannelCode string `json:"channelCode"`
|
||||
ChannelName string `json:"channelName"`
|
||||
LinkURL string `json:"linkUrl"`
|
||||
CreateTime string `json:"createTime"`
|
||||
GroupName string `json:"groupName"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("OpenInstall API 测试程序")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("ApiKey: %s\n", apiKey)
|
||||
fmt.Printf("API Base URL: %s\n", apiBaseURL)
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试1: 获取新增安装数据(最近7天)
|
||||
fmt.Println("测试1: 获取新增安装数据(最近7天)")
|
||||
fmt.Println("========================================")
|
||||
testGrowthData(ctx, 7)
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// 测试2: 获取新增安装数据(最近30天)
|
||||
fmt.Println("测试2: 获取新增安装数据(最近30天)")
|
||||
fmt.Println("========================================")
|
||||
testGrowthData(ctx, 30)
|
||||
|
||||
fmt.Println()
|
||||
|
||||
// 测试3: 获取渠道列表
|
||||
fmt.Println("测试3: 获取渠道列表")
|
||||
fmt.Println("========================================")
|
||||
testChannelList(ctx)
|
||||
|
||||
fmt.Println()
|
||||
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试完成!")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
|
||||
// 测试获取新增安装数据
|
||||
func testGrowthData(ctx context.Context, days int) {
|
||||
// 设置查询时间范围
|
||||
endDate := time.Now()
|
||||
startDate := endDate.AddDate(0, 0, -days)
|
||||
|
||||
// 构建 API URL
|
||||
apiURL := fmt.Sprintf("%s/data/event/growth", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", apiKey)
|
||||
params.Add("startDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
params.Add("statType", "daily") // daily = 按天统计, hourly = 按小时统计, total = 合计
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
fmt.Printf("请求 URL: %s\n", fullURL)
|
||||
|
||||
body, statusCode, err := makeRequest(ctx, fullURL)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 请求失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("HTTP 状态码: %d\n", statusCode)
|
||||
|
||||
if statusCode == 200 {
|
||||
// 解析响应
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
fmt.Printf("❌ JSON 解析失败: %v\n", err)
|
||||
printRawResponse(body)
|
||||
return
|
||||
}
|
||||
|
||||
if apiResp.Code == 0 {
|
||||
fmt.Println("✅ 成功获取数据!")
|
||||
|
||||
// 解析业务数据
|
||||
var growthData []GrowthData
|
||||
if err := json.Unmarshal(apiResp.Body, &growthData); err != nil {
|
||||
fmt.Printf("⚠️ 业务数据解析失败: %v\n", err)
|
||||
printRawResponse(body)
|
||||
return
|
||||
}
|
||||
|
||||
// 格式化输出数据
|
||||
fmt.Printf("\n共获取 %d 天的数据:\n", len(growthData))
|
||||
fmt.Println("----------------------------------------")
|
||||
for _, data := range growthData {
|
||||
fmt.Printf("日期: %s\n", data.Date)
|
||||
fmt.Printf(" 访问量(visit): %d\n", data.Visit)
|
||||
fmt.Printf(" 点击量(click): %d\n", data.Click)
|
||||
fmt.Printf(" 安装量(install): %d\n", data.Install)
|
||||
fmt.Printf(" 注册量(register): %d\n", data.Register)
|
||||
fmt.Printf(" 1日留存: %d\n", data.SurviveD1)
|
||||
fmt.Printf(" 7日留存: %d\n", data.SurviveD7)
|
||||
fmt.Printf(" 30日留存: %d\n", data.SurviveD30)
|
||||
fmt.Println("----------------------------------------")
|
||||
}
|
||||
} else {
|
||||
errMsg := "未知错误"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
fmt.Printf("❌ API 返回错误 (code=%d): %s\n", apiResp.Code, errMsg)
|
||||
printRawResponse(body)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ HTTP 请求失败\n")
|
||||
printRawResponse(body)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试获取渠道列表
|
||||
func testChannelList(ctx context.Context) {
|
||||
// 构建 API URL
|
||||
apiURL := fmt.Sprintf("%s/data/channel/list", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", apiKey)
|
||||
params.Add("pageNum", "0")
|
||||
params.Add("pageSize", "20")
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
fmt.Printf("请求 URL: %s\n", fullURL)
|
||||
|
||||
body, statusCode, err := makeRequest(ctx, fullURL)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 请求失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("HTTP 状态码: %d\n", statusCode)
|
||||
|
||||
if statusCode == 200 {
|
||||
// 解析响应
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
fmt.Printf("❌ JSON 解析失败: %v\n", err)
|
||||
printRawResponse(body)
|
||||
return
|
||||
}
|
||||
|
||||
if apiResp.Code == 0 {
|
||||
fmt.Println("✅ 成功获取渠道列表!")
|
||||
|
||||
// 直接打印原始数据
|
||||
printJSONResponse(apiResp.Body)
|
||||
} else {
|
||||
errMsg := "未知错误"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
fmt.Printf("❌ API 返回错误 (code=%d): %s\n", apiResp.Code, errMsg)
|
||||
printRawResponse(body)
|
||||
}
|
||||
} else {
|
||||
fmt.Printf("❌ HTTP 请求失败\n")
|
||||
printRawResponse(body)
|
||||
}
|
||||
}
|
||||
|
||||
// 发送 HTTP 请求
|
||||
func makeRequest(ctx context.Context, url string) ([]byte, int, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("创建请求失败: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, 0, fmt.Errorf("发送请求失败: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, resp.StatusCode, fmt.Errorf("读取响应失败: %w", err)
|
||||
}
|
||||
|
||||
return body, resp.StatusCode, nil
|
||||
}
|
||||
|
||||
// 打印原始响应
|
||||
func printRawResponse(body []byte) {
|
||||
fmt.Println("\n原始响应内容:")
|
||||
var prettyJSON map[string]interface{}
|
||||
if err := json.Unmarshal(body, &prettyJSON); err == nil {
|
||||
formatted, _ := json.MarshalIndent(prettyJSON, "", " ")
|
||||
fmt.Println(string(formatted))
|
||||
} else {
|
||||
fmt.Println(string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// 打印 JSON 响应
|
||||
func printJSONResponse(data json.RawMessage) {
|
||||
var prettyJSON interface{}
|
||||
if err := json.Unmarshal(data, &prettyJSON); err == nil {
|
||||
formatted, _ := json.MarshalIndent(prettyJSON, "", " ")
|
||||
fmt.Println(string(formatted))
|
||||
} else {
|
||||
fmt.Println(string(data))
|
||||
}
|
||||
}
|
||||
@ -1,158 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseURL = "https://data.openinstall.com"
|
||||
apiKey = "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
)
|
||||
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Error *string `json:"error"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
type DistributionData struct {
|
||||
Key string `json:"key"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试 OpenInstall 新增设备分布接口")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 获取当月数据
|
||||
now := time.Now()
|
||||
startOfMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
|
||||
|
||||
fmt.Printf("当月数据: %s 到 %s\n", startOfMonth.Format("2006-01-02"), now.Format("2006-01-02"))
|
||||
fmt.Println("========================================")
|
||||
|
||||
// 测试各平台的数据
|
||||
platforms := []struct {
|
||||
name string
|
||||
platform string
|
||||
}{
|
||||
{"iOS", "ios"},
|
||||
{"Android", "android"},
|
||||
{"HarmonyOS", "harmony"},
|
||||
}
|
||||
|
||||
for _, p := range platforms {
|
||||
fmt.Printf("\n平台: %s\n", p.name)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
// 获取总量
|
||||
data, err := getDeviceDistribution(ctx, startOfMonth, now, p.platform, "total")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Println("✅ 成功获取数据:")
|
||||
for _, item := range data {
|
||||
fmt.Printf(" %s: %d\n", item.Key, item.Value)
|
||||
}
|
||||
}
|
||||
|
||||
// 测试不同的 sumBy 参数
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println("测试不同的分组方式 (iOS平台):")
|
||||
fmt.Println("========================================")
|
||||
|
||||
sumByOptions := []string{
|
||||
"total", // 总量
|
||||
"system_version", // 系统版本
|
||||
"app_version", // app版本
|
||||
"brand_model", // 机型
|
||||
}
|
||||
|
||||
for _, sumBy := range sumByOptions {
|
||||
fmt.Printf("\nsumBy=%s:\n", sumBy)
|
||||
fmt.Println("----------------------------------------")
|
||||
|
||||
data, err := getDeviceDistribution(ctx, startOfMonth, now, "ios", sumBy)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 失败: %v\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
if len(data) == 0 {
|
||||
fmt.Println("⚠️ 无数据")
|
||||
continue
|
||||
}
|
||||
|
||||
fmt.Println("✅ 数据:")
|
||||
for _, item := range data {
|
||||
fmt.Printf(" %s: %d\n", item.Key, item.Value)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println("测试完成!")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
|
||||
func getDeviceDistribution(ctx context.Context, startDate, endDate time.Time, platform, sumBy string) ([]DistributionData, error) {
|
||||
apiURL := fmt.Sprintf("%s/data/sum/growth", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", apiKey)
|
||||
params.Add("beginDate", startDate.Format("2006-01-02")) // 注意:使用 beginDate 而不是 startDate
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
params.Add("platform", platform) // 平台过滤: ios, android, harmony
|
||||
params.Add("sumBy", sumBy) // 分组方式
|
||||
params.Add("excludeDuplication", "0") // 不排重
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 0 {
|
||||
errMsg := "unknown error"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
return nil, fmt.Errorf("API error (code=%d): %s", apiResp.Code, errMsg)
|
||||
}
|
||||
|
||||
var distData []DistributionData
|
||||
if err := json.Unmarshal(apiResp.Body, &distData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse distribution data: %w", err)
|
||||
}
|
||||
|
||||
return distData, nil
|
||||
}
|
||||
@ -1,68 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/openinstall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("OpenInstall 包测试")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
// 使用真实的 ApiKey
|
||||
apiKey := "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
|
||||
client := openinstall.NewClient(apiKey)
|
||||
|
||||
ctx := context.Background()
|
||||
endDate := time.Now()
|
||||
startDate := endDate.AddDate(0, 0, -7) // 最近7天
|
||||
|
||||
fmt.Printf("获取统计数据:%s 到 %s\n", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"))
|
||||
fmt.Println("========================================")
|
||||
|
||||
// 测试 GetPlatformStats
|
||||
stats, err := client.GetPlatformStats(ctx, startDate, endDate)
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 获取失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ 成功获取平台统计数据!")
|
||||
fmt.Println()
|
||||
for _, stat := range stats {
|
||||
fmt.Printf("平台: %s\n", stat.Platform)
|
||||
fmt.Printf(" 访问量(Visits): %d\n", stat.Visits)
|
||||
fmt.Printf(" 点击量(Clicks): %d\n", stat.Clicks)
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// 测试 GetGrowthData
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试每日增长数据:")
|
||||
fmt.Println("========================================")
|
||||
|
||||
growthData, err := client.GetGrowthData(ctx, startDate, endDate, "daily")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 获取失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("✅ 成功获取 %d 天的数据!\n\n", len(growthData))
|
||||
for _, data := range growthData {
|
||||
if data.Visit > 0 || data.Click > 0 || data.Install > 0 {
|
||||
fmt.Printf("日期: %s - 访问:%d, 点击:%d, 安装:%d, 注册:%d\n",
|
||||
data.Date, data.Visit, data.Click, data.Install, data.Register)
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试完成!")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
@ -1,147 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
apiBaseURL = "https://data.openinstall.com"
|
||||
apiKey = "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
)
|
||||
|
||||
type APIResponse struct {
|
||||
Code int `json:"code"`
|
||||
Error *string `json:"error"`
|
||||
Body json.RawMessage `json:"body"`
|
||||
}
|
||||
|
||||
type DistributionData struct {
|
||||
Key string `json:"key"`
|
||||
Value int64 `json:"value"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试各端下载量统计(1月份完整数据)")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 测试1月份数据
|
||||
now := time.Now()
|
||||
startOfLastMonth := time.Date(now.Year(), now.Month()-1, 1, 0, 0, 0, 0, now.Location())
|
||||
endOfLastMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location()).AddDate(0, 0, -1)
|
||||
|
||||
fmt.Printf("测试时间段: %s 到 %s\n", startOfLastMonth.Format("2006-01-02"), endOfLastMonth.Format("2006-01-02"))
|
||||
fmt.Println("========================================\n")
|
||||
|
||||
// 获取各平台数据
|
||||
platforms := []struct {
|
||||
name string
|
||||
platform string
|
||||
display string
|
||||
}{
|
||||
{"iOS", "ios", "iPhone/iPad"},
|
||||
{"Android", "android", "Android"},
|
||||
}
|
||||
|
||||
totalCount := int64(0)
|
||||
platformCounts := make(map[string]int64)
|
||||
|
||||
for _, p := range platforms {
|
||||
fmt.Printf("获取 %s 平台数据...\n", p.name)
|
||||
|
||||
data, err := getDeviceDistribution(ctx, startOfLastMonth, endOfLastMonth, p.platform, "total")
|
||||
if err != nil {
|
||||
fmt.Printf(" ❌ 失败: %v\n\n", err)
|
||||
continue
|
||||
}
|
||||
|
||||
count := int64(0)
|
||||
for _, item := range data {
|
||||
count += item.Value
|
||||
}
|
||||
|
||||
platformCounts[p.display] = count
|
||||
totalCount += count
|
||||
|
||||
fmt.Printf(" ✅ %s: %d\n\n", p.display, count)
|
||||
}
|
||||
|
||||
// 输出汇总
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("汇总结果(按界面格式):")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("\n各端下载量: %d\n", totalCount)
|
||||
fmt.Println("----------------------------------------")
|
||||
fmt.Printf("📱 iPhone/iPad: %d\n", platformCounts["iPhone/iPad"])
|
||||
fmt.Printf("🤖 Android: %d\n", platformCounts["Android"])
|
||||
fmt.Printf("💻 Windows: %d (暂不支持)\n", int64(0))
|
||||
fmt.Printf("🍎 Mac: %d (暂不支持)\n\n", int64(0))
|
||||
|
||||
// 说明
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("注意事项:")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("1. OpenInstall 统计的是「安装激活量」,非纯下载量")
|
||||
fmt.Println("2. Windows/Mac 数据需要通过其他方式获取")
|
||||
fmt.Println("3. 如需当月数据,请在月中测试")
|
||||
}
|
||||
|
||||
func getDeviceDistribution(ctx context.Context, startDate, endDate time.Time, platform, sumBy string) ([]DistributionData, error) {
|
||||
apiURL := fmt.Sprintf("%s/data/sum/growth", apiBaseURL)
|
||||
|
||||
params := url.Values{}
|
||||
params.Add("apiKey", apiKey)
|
||||
params.Add("beginDate", startDate.Format("2006-01-02"))
|
||||
params.Add("endDate", endDate.Format("2006-01-02"))
|
||||
params.Add("platform", platform)
|
||||
params.Add("sumBy", sumBy)
|
||||
params.Add("excludeDuplication", "0")
|
||||
|
||||
fullURL := fmt.Sprintf("%s?%s", apiURL, params.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, fullURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to send request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read response: %w", err)
|
||||
}
|
||||
|
||||
var apiResp APIResponse
|
||||
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse response: %w", err)
|
||||
}
|
||||
|
||||
if apiResp.Code != 0 {
|
||||
errMsg := "unknown error"
|
||||
if apiResp.Error != nil {
|
||||
errMsg = *apiResp.Error
|
||||
}
|
||||
return nil, fmt.Errorf("API error (code=%d): %s", apiResp.Code, errMsg)
|
||||
}
|
||||
|
||||
var distData []DistributionData
|
||||
if err := json.Unmarshal(apiResp.Body, &distData); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse distribution data: %w", err)
|
||||
}
|
||||
|
||||
return distData, nil
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/openinstall"
|
||||
)
|
||||
|
||||
func main() {
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("测试 GetPlatformDownloads 功能")
|
||||
fmt.Println("========================================")
|
||||
fmt.Println()
|
||||
|
||||
// 使用真实的 ApiKey
|
||||
apiKey := "a7596bc007f31a98ca551e33a75d3bb5997b0b94027c6e988d3c0af1"
|
||||
|
||||
client := openinstall.NewClient(apiKey)
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 调用 GetPlatformDownloads 获取当月数据+ 环比
|
||||
platformDownloads, err := client.GetPlatformDownloads(ctx, "")
|
||||
if err != nil {
|
||||
fmt.Printf("❌ 获取失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ 成功获取各端下载量统计!")
|
||||
fmt.Println()
|
||||
|
||||
// 格式化输出
|
||||
data, _ := json.MarshalIndent(platformDownloads, "", " ")
|
||||
fmt.Println(string(data))
|
||||
|
||||
fmt.Println()
|
||||
fmt.Println("========================================")
|
||||
fmt.Println("界面数据展示:")
|
||||
fmt.Println("========================================")
|
||||
fmt.Printf("\n各端下载量: %d\n", platformDownloads.Total)
|
||||
fmt.Println("----------------------------------------")
|
||||
fmt.Printf("📱 iPhone/iPad: %d\n", platformDownloads.IOS)
|
||||
fmt.Printf("🤖 Android: %d\n", platformDownloads.Android)
|
||||
fmt.Printf("💻 Windows: %d\n", platformDownloads.Windows)
|
||||
fmt.Printf("🍎 Mac: %d\n\n", platformDownloads.Mac)
|
||||
|
||||
if platformDownloads.Comparison != nil {
|
||||
fmt.Println("相比前一个月:")
|
||||
if platformDownloads.Comparison.Change >= 0 {
|
||||
fmt.Printf(" 📈 增长 %d (%.2f%%)\n",
|
||||
platformDownloads.Comparison.Change,
|
||||
platformDownloads.Comparison.ChangePercent)
|
||||
} else {
|
||||
fmt.Printf(" 📉 下降 %d (%.2f%%)\n",
|
||||
-platformDownloads.Comparison.Change,
|
||||
-platformDownloads.Comparison.ChangePercent)
|
||||
}
|
||||
fmt.Printf(" 上月总量: %d\n", platformDownloads.Comparison.LastMonthTotal)
|
||||
}
|
||||
|
||||
fmt.Println("\n========================================")
|
||||
fmt.Println("测试完成!")
|
||||
fmt.Println("========================================")
|
||||
}
|
||||
@ -1,111 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
pkgaes "github.com/perfect-panel/server/pkg/aes"
|
||||
)
|
||||
|
||||
// 替换为您实际的服务器地址
|
||||
const BaseURL = "https://api.hifast.biz"
|
||||
|
||||
// 替换为您实际的用户登录 Token (Authorization: Bearer <token>)
|
||||
const UserToken = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJEZXZpY2VJZCI6MzgzLCJMb2dpblR5cGUiOiJkZXZpY2UiLCJTZXNzaW9uSWQiOiIwMTliMmFmZC1jMjUwLTc1YmItODQzMy04NDMyNWVmZGRkMzMiLCJVc2VySWQiOjM4MywiZXhwIjoxNzY2NTU3NjMyLCJpYXQiOjE3NjU5NTI4MzJ9.kkcT4ojXG9qn_aVqMaGqUUXhHcZXHy49k5Vn05Et9OM"
|
||||
|
||||
// 替换为您在后台配置的设备通信密钥 (Security Secret)
|
||||
const DeviceSecret = "c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx"
|
||||
|
||||
// 替换为您要测试的 Transaction ID
|
||||
const TestTransactionID = "2000001083238483"
|
||||
|
||||
func main() {
|
||||
fmt.Println("开始测试 Restore 接口 (AES 加密模式)...")
|
||||
fmt.Printf("目标 Transaction ID: %s\n", TestTransactionID)
|
||||
|
||||
// 1. 构造原始请求数据
|
||||
payload := map[string]interface{}{
|
||||
"transactions": []string{TestTransactionID},
|
||||
}
|
||||
payloadBytes, _ := json.Marshal(payload)
|
||||
fmt.Printf("原始请求体: %s\n", string(payloadBytes))
|
||||
|
||||
// 2. 加密数据
|
||||
if DeviceSecret == "YOUR_DEVICE_SECRET_HERE" {
|
||||
log.Fatal("❌ 请在代码中设置 DeviceSecret (对应后台配置的 Security Secret)")
|
||||
}
|
||||
encryptedData, iv, err := pkgaes.Encrypt(payloadBytes, DeviceSecret)
|
||||
if err != nil {
|
||||
log.Fatalf("加密失败: %v", err)
|
||||
}
|
||||
|
||||
// 3. 构造最终的请求体 (符合 DeviceMiddleware 要求的格式)
|
||||
// DeviceMiddleware 期望的格式是: { "data": "Base64Cipher", "time": "Nonce/IV" }
|
||||
// 或者直接在 URL Query 中传 ?data=...&time=...
|
||||
// 这里我们模拟 POST JSON body 的方式
|
||||
finalPayload := map[string]interface{}{
|
||||
"data": encryptedData,
|
||||
"time": iv,
|
||||
}
|
||||
finalBytes, _ := json.Marshal(finalPayload)
|
||||
fmt.Printf("加密后请求体: %s\n", string(finalBytes))
|
||||
|
||||
url := fmt.Sprintf("%s/v1/public/iap/apple/restore", BaseURL)
|
||||
req, _ := http.NewRequest("POST", url, strings.NewReader(string(finalBytes)))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 添加必要的 Header 以通过 DeviceMiddleware
|
||||
req.Header.Set("Login-Type", "device") // 触发 DeviceMiddleware 的解密逻辑
|
||||
// 注意:这里需要替换为真实有效的 Bearer Token,否则会报 401
|
||||
// 您可以先登录后台或者使用 cmd/test_apple_iap 工具生成的 token 也是不可用的,必须是业务系统的 token
|
||||
// 为了演示,这里留空,实际运行前请填入
|
||||
if UserToken != "YOUR_USER_TOKEN_HERE" {
|
||||
req.Header.Set("Authorization", "Bearer "+UserToken)
|
||||
} else {
|
||||
fmt.Println("⚠️ 警告: 未设置 UserToken,请求可能会失败 (401 Unauthorized)")
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: 10 * time.Second}
|
||||
start := time.Now()
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Fatalf("请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
duration := time.Since(start)
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
fmt.Printf("请求耗时: %v\n", duration)
|
||||
fmt.Printf("状态码: %d\n", resp.StatusCode)
|
||||
fmt.Printf("响应内容: %s\n", string(body))
|
||||
|
||||
if resp.StatusCode == 200 {
|
||||
var result map[string]interface{}
|
||||
if err := json.Unmarshal(body, &result); err == nil {
|
||||
// 检查业务状态码
|
||||
if code, ok := result["code"].(float64); ok && int(code) != 200 {
|
||||
fmt.Printf("❌ 业务处理失败: code=%d, msg=%s\n", int(code), result["msg"])
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ Restore 接口调用成功!")
|
||||
if data, ok := result["data"].(map[string]interface{}); ok {
|
||||
if success, ok := data["success"].(bool); ok && success {
|
||||
fmt.Println(" 业务处理成功: success=true")
|
||||
} else {
|
||||
fmt.Println(" 业务处理结果未知:", data)
|
||||
}
|
||||
} else {
|
||||
fmt.Println(" 无数据返回或格式不符")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fmt.Println("❌ 接口调用失败")
|
||||
}
|
||||
}
|
||||
@ -1,219 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
/*
|
||||
* 设备复用 Session 测试工具
|
||||
*
|
||||
* 这个测试工具用于验证设备复用 session 的逻辑是否正确
|
||||
* 模拟场景:
|
||||
* 1. 设备A第一次登录 - 创建新 session
|
||||
* 2. 设备A再次登录 - 应该复用旧 session
|
||||
* 3. 设备A的session过期 - 应该创建新 session
|
||||
*/
|
||||
|
||||
const (
|
||||
SessionIdKey = "auth:session_id"
|
||||
DeviceCacheKeyKey = "auth:device"
|
||||
UserSessionsKeyPrefix = "auth:user_sessions:"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 连接 Redis
|
||||
rds := redis.NewClient(&redis.Options{
|
||||
Addr: "localhost:6379", // 修改为你的 Redis 地址
|
||||
Password: "", // 修改为你的 Redis 密码
|
||||
DB: 0,
|
||||
})
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
// 检查 Redis 连接
|
||||
if err := rds.Ping(ctx).Err(); err != nil {
|
||||
log.Fatalf("❌ 连接 Redis 失败: %v", err)
|
||||
}
|
||||
fmt.Println("✅ Redis 连接成功")
|
||||
|
||||
// 测试参数
|
||||
testDeviceID := "test-device-12345"
|
||||
testUserID := int64(9999)
|
||||
sessionExpire := 10 * time.Second // 测试用,设置较短的过期时间
|
||||
|
||||
fmt.Println("\n========== 开始测试 ==========")
|
||||
|
||||
// 清理测试数据
|
||||
cleanup(ctx, rds, testDeviceID, testUserID)
|
||||
|
||||
// 测试1: 第一次登录 - 应该创建新 session
|
||||
fmt.Println("\n📋 测试1: 第一次登录")
|
||||
sessionId1, isReuse1 := simulateLogin(ctx, rds, testDeviceID, testUserID, sessionExpire)
|
||||
if isReuse1 {
|
||||
fmt.Println("❌ 测试1失败: 第一次登录不应该复用 session")
|
||||
} else {
|
||||
fmt.Printf("✅ 测试1通过: 创建了新 session: %s\n", sessionId1)
|
||||
}
|
||||
|
||||
// 检查 session 数量
|
||||
count1 := getSessionCount(ctx, rds, testUserID)
|
||||
fmt.Printf(" 当前 session 数量: %d\n", count1)
|
||||
|
||||
// 测试2: 再次登录(session 有效)- 应该复用 session
|
||||
fmt.Println("\n📋 测试2: 再次登录(session 有效)")
|
||||
sessionId2, isReuse2 := simulateLogin(ctx, rds, testDeviceID, testUserID, sessionExpire)
|
||||
if !isReuse2 {
|
||||
fmt.Println("❌ 测试2失败: 应该复用旧 session")
|
||||
} else if sessionId1 != sessionId2 {
|
||||
fmt.Printf("❌ 测试2失败: sessionId 不一致 (%s vs %s)\n", sessionId1, sessionId2)
|
||||
} else {
|
||||
fmt.Printf("✅ 测试2通过: 复用了旧 session: %s\n", sessionId2)
|
||||
}
|
||||
|
||||
// 检查 session 数量 - 应该仍然是1
|
||||
count2 := getSessionCount(ctx, rds, testUserID)
|
||||
fmt.Printf(" 当前 session 数量: %d (预期: 1)\n", count2)
|
||||
if count2 != 1 {
|
||||
fmt.Println("❌ session 数量不正确!")
|
||||
}
|
||||
|
||||
// 测试3: 模拟多设备登录
|
||||
fmt.Println("\n📋 测试3: 多设备登录")
|
||||
testDeviceID2 := "test-device-67890"
|
||||
sessionId3, isReuse3 := simulateLogin(ctx, rds, testDeviceID2, testUserID, sessionExpire)
|
||||
if isReuse3 {
|
||||
fmt.Println("❌ 测试3失败: 新设备不应该复用 session")
|
||||
} else {
|
||||
fmt.Printf("✅ 测试3通过: 设备B创建了新 session: %s\n", sessionId3)
|
||||
}
|
||||
|
||||
// 检查 session 数量 - 应该是2
|
||||
count3 := getSessionCount(ctx, rds, testUserID)
|
||||
fmt.Printf(" 当前 session 数量: %d (预期: 2)\n", count3)
|
||||
|
||||
// 测试4: 设备A再次登录 - 仍然应该复用
|
||||
fmt.Println("\n📋 测试4: 设备A再次登录")
|
||||
sessionId4, isReuse4 := simulateLogin(ctx, rds, testDeviceID, testUserID, sessionExpire)
|
||||
if !isReuse4 {
|
||||
fmt.Println("❌ 测试4失败: 应该复用设备A的旧 session")
|
||||
} else if sessionId1 != sessionId4 {
|
||||
fmt.Printf("❌ 测试4失败: sessionId 不一致 (%s vs %s)\n", sessionId1, sessionId4)
|
||||
} else {
|
||||
fmt.Printf("✅ 测试4通过: 设备A复用了旧 session: %s\n", sessionId4)
|
||||
}
|
||||
|
||||
// 检查 session 数量 - 仍然应该是2
|
||||
count4 := getSessionCount(ctx, rds, testUserID)
|
||||
fmt.Printf(" 当前 session 数量: %d (预期: 2)\n", count4)
|
||||
|
||||
// 测试5: 等待 session 过期后再登录
|
||||
fmt.Println("\n📋 测试5: 等待 session 过期后再登录")
|
||||
fmt.Printf(" 等待 %v ...\n", sessionExpire+time.Second)
|
||||
time.Sleep(sessionExpire + time.Second)
|
||||
|
||||
sessionId5, isReuse5 := simulateLogin(ctx, rds, testDeviceID, testUserID, sessionExpire)
|
||||
if isReuse5 {
|
||||
fmt.Println("❌ 测试5失败: session 过期后不应该复用")
|
||||
} else {
|
||||
fmt.Printf("✅ 测试5通过: 创建了新 session: %s\n", sessionId5)
|
||||
}
|
||||
|
||||
// 测试6: 设备转移场景(关键安全测试)
|
||||
fmt.Println("\n📋 测试6: 设备转移场景(用户A的设备被用户B使用)")
|
||||
testDeviceID3 := "test-device-transfer"
|
||||
testUserA := int64(1001)
|
||||
testUserB := int64(1002)
|
||||
|
||||
// 用户A用设备登录
|
||||
cleanup(ctx, rds, testDeviceID3, testUserA)
|
||||
cleanup(ctx, rds, testDeviceID3, testUserB)
|
||||
sessionA, _ := simulateLogin(ctx, rds, testDeviceID3, testUserA, sessionExpire)
|
||||
fmt.Printf(" 用户A登录,session: %s\n", sessionA)
|
||||
|
||||
// 用户B用同一设备登录(设备转移场景)
|
||||
sessionB, isReuseB := simulateLogin(ctx, rds, testDeviceID3, testUserB, sessionExpire)
|
||||
if isReuseB {
|
||||
fmt.Println("❌ 测试6失败: 用户B不应该复用用户A的session!(安全漏洞)")
|
||||
} else {
|
||||
fmt.Printf("✅ 测试6通过: 用户B创建了新 session: %s\n", sessionB)
|
||||
}
|
||||
|
||||
// 验证用户A和B的session不同
|
||||
if sessionA == sessionB {
|
||||
fmt.Println("❌ 安全问题: 两个用户使用了相同的session!")
|
||||
} else {
|
||||
fmt.Println("✅ 安全验证通过: 两个用户使用不同的session")
|
||||
}
|
||||
cleanup(ctx, rds, testDeviceID, testUserID)
|
||||
cleanup(ctx, rds, testDeviceID2, testUserID)
|
||||
|
||||
fmt.Println("\n========== 测试完成 ==========")
|
||||
}
|
||||
|
||||
// simulateLogin 模拟登录逻辑
|
||||
// 返回: sessionId, isReuse (是否复用了旧 session)
|
||||
func simulateLogin(ctx context.Context, rds *redis.Client, deviceID string, userID int64, expire time.Duration) (string, bool) {
|
||||
var sessionId string
|
||||
var reuseSession bool
|
||||
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", DeviceCacheKeyKey, deviceID)
|
||||
|
||||
// 检查设备是否有旧的有效 session
|
||||
if oldSid, getErr := rds.Get(ctx, deviceCacheKey).Result(); getErr == nil && oldSid != "" {
|
||||
// 检查旧 session 是否仍然有效 AND 属于当前用户
|
||||
oldSessionKey := fmt.Sprintf("%v:%v", SessionIdKey, oldSid)
|
||||
if uidStr, existErr := rds.Get(ctx, oldSessionKey).Result(); existErr == nil && uidStr != "" {
|
||||
// 验证 session 属于当前用户 (防止设备转移后复用其他用户的session)
|
||||
if uidStr == fmt.Sprintf("%d", userID) {
|
||||
sessionId = oldSid
|
||||
reuseSession = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !reuseSession {
|
||||
// 生成新的 sessionId
|
||||
sessionId = fmt.Sprintf("session-%d-%d", userID, time.Now().UnixNano())
|
||||
|
||||
// 添加到用户的 session 集合
|
||||
sessionsKey := fmt.Sprintf("%s%v", UserSessionsKeyPrefix, userID)
|
||||
rds.ZAdd(ctx, sessionsKey, redis.Z{Score: float64(time.Now().Unix()), Member: sessionId})
|
||||
rds.Expire(ctx, sessionsKey, expire)
|
||||
}
|
||||
|
||||
// 存储/刷新 session
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", SessionIdKey, sessionId)
|
||||
rds.Set(ctx, sessionIdCacheKey, userID, expire)
|
||||
|
||||
// 存储/刷新设备到session的映射
|
||||
rds.Set(ctx, deviceCacheKey, sessionId, expire)
|
||||
|
||||
return sessionId, reuseSession
|
||||
}
|
||||
|
||||
// getSessionCount 获取用户的 session 数量
|
||||
func getSessionCount(ctx context.Context, rds *redis.Client, userID int64) int64 {
|
||||
sessionsKey := fmt.Sprintf("%s%v", UserSessionsKeyPrefix, userID)
|
||||
count, _ := rds.ZCard(ctx, sessionsKey).Result()
|
||||
return count
|
||||
}
|
||||
|
||||
// cleanup 清理测试数据
|
||||
func cleanup(ctx context.Context, rds *redis.Client, deviceID string, userID int64) {
|
||||
deviceCacheKey := fmt.Sprintf("%v:%v", DeviceCacheKeyKey, deviceID)
|
||||
sessionsKey := fmt.Sprintf("%s%v", UserSessionsKeyPrefix, userID)
|
||||
|
||||
// 获取设备的 sessionId
|
||||
if sid, err := rds.Get(ctx, deviceCacheKey).Result(); err == nil {
|
||||
sessionIdCacheKey := fmt.Sprintf("%v:%v", SessionIdKey, sid)
|
||||
rds.Del(ctx, sessionIdCacheKey)
|
||||
}
|
||||
|
||||
rds.Del(ctx, deviceCacheKey)
|
||||
rds.Del(ctx, sessionsKey)
|
||||
}
|
||||
73
cmd/update.go
Normal file
73
cmd/update.go
Normal file
@ -0,0 +1,73 @@
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/perfect-panel/server/pkg/updater"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var (
|
||||
checkOnly bool
|
||||
)
|
||||
|
||||
var updateCmd = &cobra.Command{
|
||||
Use: "update",
|
||||
Short: "Check for updates and update PPanel to the latest version",
|
||||
Long: `Check for available updates from GitHub releases and automatically
|
||||
update the PPanel binary to the latest version.
|
||||
|
||||
Examples:
|
||||
# Check for updates only
|
||||
ppanel-server update --check
|
||||
|
||||
# Update to the latest version
|
||||
ppanel-server update`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
u := updater.NewUpdater()
|
||||
|
||||
if checkOnly {
|
||||
checkForUpdates(u)
|
||||
return
|
||||
}
|
||||
|
||||
performUpdate(u)
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
updateCmd.Flags().BoolVarP(&checkOnly, "check", "c", false, "Check for updates without applying them")
|
||||
}
|
||||
|
||||
func checkForUpdates(u *updater.Updater) {
|
||||
fmt.Println("Checking for updates...")
|
||||
|
||||
release, hasUpdate, err := u.CheckForUpdates()
|
||||
if err != nil {
|
||||
fmt.Printf("Error checking for updates: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
if !hasUpdate {
|
||||
fmt.Println("You are already running the latest version!")
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("\nNew version available!\n")
|
||||
fmt.Printf("Current version: %s\n", u.CurrentVersion)
|
||||
fmt.Printf("Latest version: %s\n", release.TagName)
|
||||
fmt.Printf("\nRelease notes:\n%s\n", release.Body)
|
||||
fmt.Printf("\nTo update, run: ppanel-server update\n")
|
||||
}
|
||||
|
||||
func performUpdate(u *updater.Updater) {
|
||||
fmt.Println("Starting update process...")
|
||||
|
||||
if err := u.Update(); err != nil {
|
||||
fmt.Printf("Update failed: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("\nUpdate completed successfully!")
|
||||
fmt.Println("Please restart the application to use the new version.")
|
||||
}
|
||||
@ -1,124 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"gorm.io/driver/mysql"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 配置结构
|
||||
type AppConfig struct {
|
||||
MySQL struct {
|
||||
Addr string `yaml:"Addr"`
|
||||
Dbname string `yaml:"Dbname"`
|
||||
Username string `yaml:"Username"`
|
||||
Password string `yaml:"Password"`
|
||||
Config string `yaml:"Config"`
|
||||
} `yaml:"MySQL"`
|
||||
}
|
||||
|
||||
type System struct {
|
||||
Key string `gorm:"column:key;primaryKey"`
|
||||
Value string `gorm:"column:value"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
fmt.Println("====== 更新 CustomData ======")
|
||||
|
||||
// 1. 读取配置
|
||||
cfgData, err := os.ReadFile("configs/ppanel.yaml")
|
||||
if err != nil {
|
||||
fmt.Printf("读取配置失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
var cfg AppConfig
|
||||
if err := yaml.Unmarshal(cfgData, &cfg); err != nil {
|
||||
fmt.Printf("解析配置失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 连接数据库
|
||||
dsn := fmt.Sprintf("%s:%s@tcp(%s)/%s?%s",
|
||||
cfg.MySQL.Username, cfg.MySQL.Password, cfg.MySQL.Addr, cfg.MySQL.Dbname, cfg.MySQL.Config)
|
||||
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
fmt.Printf("数据库连接失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
fmt.Println("✅ 数据库连接成功")
|
||||
|
||||
// 3. 查找 SiteConfig (在 system 表中,key 通常是 'SiteConfig')
|
||||
// 注意:system 表结构可能由 key, value 组成
|
||||
// 我们需要查找包含 CustomData 的那个配置
|
||||
|
||||
// 先尝试直接查找 SiteConfig
|
||||
var sysConfig System
|
||||
// 根据之前的查看,SiteConfig 可能不是直接存 JSON,而是字段。
|
||||
// 但用户之前 curl 看到的是 custom_data 字段。
|
||||
// 让我们查找包含 "shareUrl" 的记录来定位
|
||||
err = db.Table("system").Where("value LIKE ?", "%shareUrl%").First(&sysConfig).Error
|
||||
if err != nil {
|
||||
fmt.Printf("未找到包含 shareUrl 的配置: %v\n", err)
|
||||
// 尝试列出所有 key
|
||||
var keys []string
|
||||
db.Table("system").Pluck("key", &keys)
|
||||
fmt.Printf("现有 Keys: %v\n", keys)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("找到配置 Key: %s\n", sysConfig.Key)
|
||||
fmt.Printf("原始内容: %s\n", sysConfig.Value)
|
||||
|
||||
// 4. 解析并修改
|
||||
// System Value 可能是 SiteConfig 的 JSON,或者 CustomData 只是其中一个字段
|
||||
// 假设 Value 是 SiteConfig 结构体的 JSON
|
||||
var siteConfigMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(sysConfig.Value), &siteConfigMap); err != nil {
|
||||
fmt.Printf("解析 Config Value 失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否有 CustomData
|
||||
if customDataStr, ok := siteConfigMap["CustomData"].(string); ok {
|
||||
fmt.Println("找到 CustomData 字段,正在更新...")
|
||||
|
||||
var customDataMap map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(customDataStr), &customDataMap); err != nil {
|
||||
fmt.Printf("解析 CustomData 失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
// 添加 domain
|
||||
customDataMap["domain"] = "getsapp.net"
|
||||
|
||||
// 重新序列化 CustomData
|
||||
newCustomDataBytes, _ := json.Marshal(customDataMap)
|
||||
siteConfigMap["CustomData"] = string(newCustomDataBytes)
|
||||
|
||||
fmt.Printf("新的 CustomData: %s\n", string(newCustomDataBytes))
|
||||
|
||||
} else {
|
||||
// 也许 Value 本身就是 CustomData? (不太可能,根据之前的 grep 结果)
|
||||
// 或者 Key 是 'custom_data'?
|
||||
fmt.Println("未在配置中找到 CustomData 字段,尝试直接解析为 CustomData...")
|
||||
// 尝试直接添加 domain 看是否合理
|
||||
siteConfigMap["domain"] = "getsapp.net"
|
||||
}
|
||||
|
||||
// 5. 保存回数据库
|
||||
newConfigBytes, _ := json.Marshal(siteConfigMap)
|
||||
// fmt.Printf("更新后的配置 Value: %s\n", string(newConfigBytes))
|
||||
|
||||
err = db.Table("system").Where("`key` = ?", sysConfig.Key).Update("value", string(newConfigBytes)).Error
|
||||
if err != nil {
|
||||
fmt.Printf("更新数据库失败: %v\n", err)
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Println("✅ 数据库更新成功!")
|
||||
}
|
||||
5379
common.json
Normal file
5379
common.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -1,63 +0,0 @@
|
||||
Host: 0.0.0.0
|
||||
Port: 8080
|
||||
Debug: false
|
||||
JwtAuth:
|
||||
AccessSecret: 8c6976e5b5410415bde908bd4dee15dfb167a9c873fc4bb8a81f6f2ab448a918
|
||||
AccessExpire: 604800
|
||||
MaxSessionsPerUser: 2
|
||||
|
||||
Logger:
|
||||
ServiceName: PPanel
|
||||
Mode: console
|
||||
Encoding: plain
|
||||
TimeFormat: '2006-01-02 15:04:05.000'
|
||||
Path: logs
|
||||
Level: debug
|
||||
MaxContentLength: 0
|
||||
Compress: false
|
||||
Stat: true
|
||||
KeepDays: 0
|
||||
StackCooldownMillis: 100
|
||||
MaxBackups: 0
|
||||
MaxSize: 0
|
||||
Rotation: daily
|
||||
FileTimeFormat: 2025-01-01T00:00:00.000Z00:00
|
||||
MySQL:
|
||||
Addr: 127.0.0.1:3306
|
||||
Dbname: dev_ppanel
|
||||
Username: root
|
||||
Password: rootpassword
|
||||
Config: charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai
|
||||
MaxIdleConns: 10
|
||||
MaxOpenConns: 10
|
||||
SlowThreshold: 1000
|
||||
|
||||
Redis:
|
||||
Host: 127.0.0.1:6379
|
||||
Pass:
|
||||
DB: 0
|
||||
|
||||
|
||||
Administrator:
|
||||
Password:
|
||||
Email:
|
||||
Telegram:
|
||||
Enable: false
|
||||
BotID: 0
|
||||
BotName: ""
|
||||
BotToken: "8114337882:AAHkEx03HSu7RxN4IHBJJEnsK9aPPzNLIk0"
|
||||
GroupChatID: "-5012065881"
|
||||
EnableNotify: true
|
||||
WebHookDomain: ""
|
||||
|
||||
|
||||
Site:
|
||||
Host: api.airoport.co
|
||||
SiteName: HiFastVPN
|
||||
|
||||
Kutt:
|
||||
Enable: true
|
||||
ApiURL: "https://getsapp.net/api/v2"
|
||||
ApiKey: "6JSjGOzLF1NCYQXuUGZjvrkqU0Jy3upDkYX87DPO"
|
||||
TargetURL: ""
|
||||
Domain: "getsapp.net"
|
||||
@ -1,17 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 解密 Nginx 下载日志中的 data 参数
|
||||
# 使用方法:
|
||||
# ./decrypt_download.sh "data=xxx&time=xxx"
|
||||
# 或者直接传入整条日志
|
||||
|
||||
if [ $# -eq 0 ]; then
|
||||
echo "使用方法:"
|
||||
echo " $0 'data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000'"
|
||||
echo " 或"
|
||||
echo " $0 '172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] \"GET /v1/common/client/download?data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000 HTTP/1.1\"'"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
go run cmd/decrypt_download_data/main.go "$@"
|
||||
74
doc/api-version-switch-zh.md
Normal file
74
doc/api-version-switch-zh.md
Normal file
@ -0,0 +1,74 @@
|
||||
# API 版本分流接入指南(`api-header`)
|
||||
|
||||
## 目标
|
||||
- 通过请求头 `api-header` 动态选择不同 Handler(如 `001Handler` / `002Handler`)。
|
||||
- 让旧 App 无需升级仍可走旧逻辑,新 App 通过版本头走新逻辑。
|
||||
|
||||
## 当前规则
|
||||
- 仅识别请求头:`api-header`
|
||||
- 严格版本格式:`x.y.z` 或 `vx.y.z`
|
||||
- 仅当 `api-header > 1.0.0` 时走新逻辑(V2)
|
||||
- 其余情况(缺失/非法/`<=1.0.0`)走旧逻辑(V1)
|
||||
|
||||
相关代码:
|
||||
- 版本解析:`pkg/apiversion/version.go`
|
||||
- 版本注入中间件:`internal/middleware/apiVersionMiddleware.go`
|
||||
- 通用分流器:`internal/middleware/apiVersionSwitchHandler.go`
|
||||
|
||||
## 新接口接入步骤(推荐)
|
||||
|
||||
### 1) 实现两个 Handler
|
||||
```go
|
||||
func FooV1Handler(svcCtx *svc.ServiceContext) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 旧逻辑(001)
|
||||
}
|
||||
}
|
||||
|
||||
func FooV2Handler(svcCtx *svc.ServiceContext) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 新逻辑(002)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2) 在路由中挂分流器
|
||||
```go
|
||||
group.POST("/foo", middleware.ApiVersionSwitchHandler(
|
||||
foo.FooV1Handler(serverCtx),
|
||||
foo.FooV2Handler(serverCtx),
|
||||
))
|
||||
```
|
||||
|
||||
完成后,无需在业务代码里手写 `api-header` 判断。
|
||||
|
||||
## 客户端调用示例
|
||||
|
||||
### 旧逻辑(V1)
|
||||
```bash
|
||||
curl -X POST 'https://example.com/v1/common/foo' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"x":1}'
|
||||
```
|
||||
|
||||
### 新逻辑(V2)
|
||||
```bash
|
||||
curl -X POST 'https://example.com/v1/common/foo' \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'api-header: 1.0.1' \
|
||||
-d '{"x":1}'
|
||||
```
|
||||
|
||||
## 测试建议(最小集)
|
||||
- 无 `api-header`:命中 V1
|
||||
- `api-header: 1.0.0`:命中 V1
|
||||
- `api-header: 1.0.1`:命中 V2
|
||||
- `api-header: abc`:命中 V1
|
||||
|
||||
可参考测试:
|
||||
- `internal/middleware/apiVersionSwitchHandler_test.go`
|
||||
|
||||
## 适用建议
|
||||
- 差异较小:优先在 V2 中复用现有逻辑,减少重复代码。
|
||||
- 差异较大:拆分 V1/V2 各自逻辑,避免分支污染。
|
||||
- 上线顺序:先发后端分流能力,再逐步让客户端加 `api-header`。
|
||||
@ -1,101 +0,0 @@
|
||||
# 客户端错误上报接口文档
|
||||
|
||||
## 接口概览
|
||||
- 路径:`POST /v1/common/log/message/report`
|
||||
- 说明:APP/PC/Web 客户端错误与异常信息上报,服务端入库 `log_message` 表,并提供管理端查询。
|
||||
- 认证:无需登录;若走设备安全通道需使用 `Login-Type: device` 与 AES 加密。
|
||||
- 中间件:`DeviceMiddleware`(当请求头 `Login-Type=device` 时启用加解密)。
|
||||
|
||||
## 请求
|
||||
- Headers:
|
||||
- `Content-Type: application/json`
|
||||
- 可选:`Login-Type: device`(启用设备加密通道)
|
||||
- Body(JSON):
|
||||
- `platform` string 必填,客户端平台,如 `android`/`ios`/`windows`/`mac`/`web`
|
||||
- `appVersion` string 可选,应用版本,如 `2.4.1`
|
||||
- `osName` string 可选,系统名称,如 `Android`/`iOS`/`Windows`/`macOS`
|
||||
- `osVersion` string 可选,系统版本,如 `14`
|
||||
- `deviceId` string 可选,设备唯一标识
|
||||
- `sessionId` string 可选,会话标识
|
||||
- `level` uint8 可选,日志等级:`1=fatal`、`2=error`、`3=warn`、`4=info`(默认 `3`)
|
||||
- `errorCode` string 可选,业务或系统错误码
|
||||
- `message` string 必填,错误简述(服务器将超过约 64KB 的内容截断)
|
||||
- `stack` string 可选,堆栈信息(服务器将超过约 1MB 的内容截断)
|
||||
- `context` object 可选,扩展上下文(如接口路径、参数、网络状态等)
|
||||
- `occurredAt` int64 可选,客户端发生时间,毫秒时间戳
|
||||
|
||||
- 服务器侧自动填充:
|
||||
- `client_ip`、`user_agent`、`locale` 由请求解析
|
||||
- `user_id` 仅在鉴权后由服务端注入(本接口默认匿名)
|
||||
|
||||
## 加密通道(设备)
|
||||
- 当使用设备安全通道时(`Login-Type: device`),请求体需要将原始 JSON 加密为:
|
||||
- `data` string:AES 加密后的密文
|
||||
- `time` string:IV/nonce(与密文配套)
|
||||
- 服务端会自动解密为明文 JSON,再进行字段校验与入库。
|
||||
|
||||
## 响应
|
||||
- 成功:
|
||||
```
|
||||
{
|
||||
"code": 0,
|
||||
"msg": "OK",
|
||||
"data": { "id": 123 }
|
||||
}
|
||||
```
|
||||
- 常见错误:
|
||||
- `{"code":401,"msg":"TooManyRequests"}` 触发速率限制(设备ID或IP维度)
|
||||
- `{"code":400,"msg":"InvalidParams"}` 参数校验失败(缺少必填或类型不符)
|
||||
- `{"code":10001,"msg":"DatabaseQueryError"}` 数据库操作异常
|
||||
|
||||
## 速率限制
|
||||
- 默认按 `deviceId` 或 `client_ip` 每分钟最多约 `120` 条,超限即返回 `TooManyRequests`。
|
||||
|
||||
## 去重策略
|
||||
- 服务端计算 `digest = sha256(message|stack|errorCode|appVersion|platform)` 并尝试唯一入库,重复日志可能返回已存在记录的 `id`。
|
||||
|
||||
## 示例
|
||||
- 明文上报(推荐测试使用):
|
||||
```
|
||||
curl -X POST http://localhost:8080/v1/common/log/message/report \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"platform": "android",
|
||||
"appVersion": "2.4.1",
|
||||
"osName": "Android",
|
||||
"osVersion": "14",
|
||||
"deviceId": "and-9a7f3e2c-01",
|
||||
"sessionId": "sess-73f8a2a4",
|
||||
"level": 2,
|
||||
"errorCode": "ORDER_RENEWAL_TIMEOUT",
|
||||
"message": "订单续费接口超时:/v1/public/order/renewal",
|
||||
"stack": "TimeoutException: request exceeded 8000ms\\n at HttpClient.post(HttpClient.kt:214)\\n at RenewalRepository.submit(RenewalRepository.kt:87)",
|
||||
"context": {
|
||||
"api": "/v1/public/order/renewal",
|
||||
"method": "POST",
|
||||
"endpoint": "https://api.example.com/v1/public/order/renewal",
|
||||
"httpStatus": 504,
|
||||
"responseTimeMs": 8123,
|
||||
"retryCount": 2,
|
||||
"network": { "type": "cellular", "carrier": "China Mobile" }
|
||||
},
|
||||
"occurredAt": 1733200005123
|
||||
}'
|
||||
```
|
||||
|
||||
- 设备加密上报(示意):
|
||||
```
|
||||
# 将原始JSON通过AES加密得到密文data与随机IV time
|
||||
curl -X POST http://localhost:8080/v1/common/log/message/report \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Login-Type: device' \
|
||||
-d '{"data":"<aes_cipher>","time":"<iv_nonce>"}'
|
||||
```
|
||||
|
||||
## 管理端查询(用于联调验证)
|
||||
- 列表:`GET /v1/admin/log/message/error/list`(需 `Authorization`)
|
||||
- 详情:`GET /v1/admin/log/message/error/detail?id=...`
|
||||
|
||||
## 备注
|
||||
- 字段尽量避免携带敏感信息(密码、密钥、完整令牌等);如需调试请截断或脱敏后上报。
|
||||
- 建议在 APP/PC 端统一封装上报模块,包含:字段收集、级别过滤、采样策略、离线缓存与重试、速率限制配合。
|
||||
58
doc/cache-convention-zh.md
Normal file
58
doc/cache-convention-zh.md
Normal file
@ -0,0 +1,58 @@
|
||||
# GORM + Redis 缓存调用规范
|
||||
|
||||
本文档约定了项目内 GORM 与 Redis 缓存的推荐调用方式,目标是避免“有的地方自动清缓存,有的地方手动清缓存”的分散写法。
|
||||
|
||||
## 统一入口
|
||||
|
||||
项目缓存统一由 `pkg/cache/CachedConn` 提供,常用方法:
|
||||
|
||||
- `QueryCtx`:读缓存,未命中回源 DB 并写缓存
|
||||
- `ExecCtx`:写 DB,成功后删除指定缓存 key
|
||||
- `QueryNoCacheCtx`:只查 DB,不读写缓存
|
||||
- `ExecNoCacheCtx`:只写 DB,不自动清缓存
|
||||
- `TransactCtx`:事务执行
|
||||
|
||||
## 推荐使用规则
|
||||
|
||||
### 1) 主键/唯一键查询(有稳定 key)
|
||||
|
||||
优先使用 `QueryCtx`,必须显式构建缓存 key。
|
||||
|
||||
### 2) 列表/复杂筛选查询
|
||||
|
||||
仅当 key 规则稳定时用 `QueryCtx`;否则用 `QueryNoCacheCtx`。
|
||||
|
||||
### 3) 写操作(新增/更新/删除)
|
||||
|
||||
优先使用“统一 helper”封装为一条路径:
|
||||
|
||||
1. 执行 DB 写入
|
||||
2. 按模型计算 key
|
||||
3. 清理关联缓存
|
||||
|
||||
避免在业务逻辑层分散调用 `DelCache`。
|
||||
|
||||
## user_subscribe 规范落地
|
||||
|
||||
`internal/model/user/subscribe.go` 已提供统一 helper:
|
||||
|
||||
- `execSubscribeMutation(...)`
|
||||
|
||||
`InsertSubscribe / UpdateSubscribe / DeleteSubscribe / DeleteSubscribeById` 统一通过该 helper 执行,避免每个方法重复写“ExecNoCacheCtx + defer 清缓存”。
|
||||
|
||||
## key 生成约定
|
||||
|
||||
模型缓存 key 统一在模型侧定义,不在 handler/logic 手写:
|
||||
|
||||
- `internal/model/user/cache.go`
|
||||
- `(*Subscribe).GetCacheKeys()`
|
||||
- `ClearSubscribeCacheByModels(...)`
|
||||
|
||||
> 说明:`user_subscribe` 当前会同时清理普通列表 key 与 `:all` 列表 key,避免删改后列表残留旧缓存。
|
||||
|
||||
## 新增模型时的最小模板
|
||||
|
||||
1. 在 model 中定义 `getCacheKeys(...)` 或 `GetCacheKeys()`
|
||||
2. 查询方法优先 `QueryCtx`
|
||||
3. 写方法统一走 helper(DB 写 + 缓存失效)
|
||||
4. 避免在 handler/logic 直接操作缓存 key
|
||||
@ -158,44 +158,4 @@ Administer: # 管理员登录配置
|
||||
- **数据库**:确保 `MySQL` 和 `Redis` 凭据安全,避免在版本控制中暴露。
|
||||
- **JWT**:为 `JwtAuth` 的 `AccessSecret` 设置强密钥以增强安全性。
|
||||
|
||||
如需进一步帮助,请参考 PPanel 官方文档或联系支持团队。
|
||||
|
||||
## 6. Apple IAP(非续期订阅)配置
|
||||
|
||||
- 通过 `Site.CustomData` 配置内购商品与权益映射,示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"iapProductMap": {
|
||||
"com.airport.vpn.pass.30d": {
|
||||
"description": "30天通行证",
|
||||
"priceText": "¥28.00",
|
||||
"durationDays": 30,
|
||||
"tier": "Basic",
|
||||
"subscribeId": 1001
|
||||
},
|
||||
"com.airport.vpn.pass.90d": {
|
||||
"description": "90天通行证",
|
||||
"priceText": "¥68.00",
|
||||
"durationDays": 90,
|
||||
"tier": "Pro",
|
||||
"subscribeId": 1002
|
||||
}
|
||||
},
|
||||
"iapBundleId": "co.airoport.app.ios"
|
||||
}
|
||||
```
|
||||
|
||||
- 字段说明:
|
||||
- `iapProductMap`:`productId → 映射`,用于后端计算到期时间与绑定内部计划(`subscribeId`)。
|
||||
- `description`/`priceText`:客户端展示文案。
|
||||
- `durationDays`:非续期订阅的有效天数。
|
||||
- `tier`:权益等级标签,用于状态返回。
|
||||
- `subscribeId`:绑定到现有 `subscribe` 计划 ID。
|
||||
- `iapBundleId`:客户端 Bundle ID(可用于后端基础校验)。
|
||||
|
||||
### 接口速览
|
||||
- `GET /v1/public/iap/apple/products`:返回可售商品与文案(基于 `iapProductMap`)。
|
||||
- `POST /v1/public/iap/apple/transactions/attach`:绑定一次购买到用户,入参 `signed_transaction_jws`。
|
||||
- `POST /v1/public/iap/apple/restore`:恢复历史购买(批量 JWS)。
|
||||
- `GET /v1/public/iap/apple/status`:返回用户的 IAP 权益状态与到期时间。
|
||||
如需进一步帮助,请参考 PPanel 官方文档或联系支持团队。
|
||||
@ -1,228 +0,0 @@
|
||||
# iOS 内购接入与接口调用指南(StoreKit 2 + 服务端接口)
|
||||
|
||||
## 概述
|
||||
本指南面向 iOS App 开发者,说明使用 StoreKit 2 完成「非续期订阅/非消耗型」的购买、验证与绑定流程,并与后端接口打通,实现用户权益发放与恢复。
|
||||
|
||||
## 商品与映射
|
||||
- Apple 端必须在 App Store Connect 创建对应 `productId` 的内购商品(非续期订阅或非消耗型)。
|
||||
- 服务端维护「商品映射」:`productId → {durationDays, tier, subscribeId}`,用于计算到期与绑定内部订阅计划(`subscribeId`)。
|
||||
- 若某 `productId` 暂未在服务端配置,客户端可在绑定请求中携带回退字段:`duration_days`、`subscribe_id`、`tier`。服务端将按 App 的定义进行绑定。
|
||||
|
||||
## 客户端整体流程(StoreKit 2)
|
||||
1) 检查支付能力
|
||||
- `if !AppStore.canMakePayments { 隐藏商店并提示 }`
|
||||
2) 拉取商品
|
||||
- 通过已知 `productId` 列表调用 `Product.products(for:)`,展示价格与描述
|
||||
3) 发起购买并本地验证
|
||||
- 调用 `try await product.purchase()` 弹出系统确认表单
|
||||
- 成功后 `let transaction = try verification.payloadValue`,并取到 `transaction.signedData`(JWS)
|
||||
4) 绑定购买(服务端 attach)
|
||||
- 将 `signedData` 作为 `signed_transaction_jws` POST 至 `/v1/public/iap/apple/transactions/attach`
|
||||
- 若服务端未配置该 `productId`,同时携带:`duration_days`(有效天数)、`subscribe_id`(内部订阅计划 ID)、`tier`(展示用标签)
|
||||
5) 恢复购买(restore)
|
||||
- `try await AppStore.sync()` 后,遍历 `Transaction.currentEntitlements` 并逐条 `verify()`
|
||||
- 收集每条 `signedData`,批量 POST 至 `/v1/public/iap/apple/restore`
|
||||
6) 查询状态(status)
|
||||
- `GET /v1/public/iap/apple/status` 获取 `active/expires_at/tier`,用于 UI 展示与权限控制
|
||||
7) 退款入口(HIG 建议)
|
||||
- 在购买帮助页提供「请求退款」按钮,调用 `beginRefundRequest(for:in:)`
|
||||
|
||||
## 接口详细
|
||||
所有接口均需要携带用户登录态的 `Authorization: Bearer <token>`。
|
||||
- 绑定购买(attach)
|
||||
- `POST /v1/public/iap/apple/transactions/attach`
|
||||
- 请求体(映射一致时,仅需 `signed_transaction_jws`):
|
||||
```json
|
||||
{
|
||||
"signed_transaction_jws": "<StoreKit返回的signedData>"
|
||||
}
|
||||
```
|
||||
- 请求体(映射不一致时的回退):
|
||||
```json
|
||||
{
|
||||
"signed_transaction_jws": "<signedData>",
|
||||
"duration_days": 30,
|
||||
"subscribe_id": 1001,
|
||||
"tier": "Basic"
|
||||
}
|
||||
```
|
||||
- 响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": { "expires_at": 1736860000, "tier": "Basic" }
|
||||
}
|
||||
```
|
||||
- 恢复购买(restore)
|
||||
- `POST /v1/public/iap/apple/restore`
|
||||
- 请求体:
|
||||
```json
|
||||
{
|
||||
"transactions": ["<signedData-1>", "<signedData-2>"]
|
||||
}
|
||||
```
|
||||
- 响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": { "success": true }
|
||||
}
|
||||
```
|
||||
- 查询状态(status)
|
||||
- `GET /v1/public/iap/apple/status`
|
||||
- 响应示例:
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": { "active": true, "expires_at": 1736860000, "tier": "Basic" }
|
||||
}
|
||||
```
|
||||
|
||||
## 收银台统一返回契约(含 Apple IAP)
|
||||
|
||||
- 统一下单接口返回的 `type` 用于客户端决定支付方式;当为 Apple IAP 时,返回 Apple 商品 ID 列表,前端直接用 StoreKit 购买:
|
||||
- Apple IAP 收银台返回示例(建议规范):
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"data": {
|
||||
"type": "apple_iap",
|
||||
"product_ids": [
|
||||
"merchant.hifastvpn.day7",
|
||||
"merchant.hifastvpn.day30"
|
||||
],
|
||||
"hint": "Use StoreKit to purchase the given product_ids, then POST signedData to attach."
|
||||
}
|
||||
}
|
||||
```
|
||||
- 其他支付平台保持原有结构:
|
||||
- Stripe:`{ "type":"stripe", "stripe": { "publishable_key": "...", "client_secret":"..." } }`
|
||||
- URL 跳转:`{ "type":"url", "checkout_url": "https://..." }`
|
||||
- 二维码:`{ "type":"qr", "checkout_url": "..." }`
|
||||
|
||||
### 前端处理逻辑(统一出口)
|
||||
- 收银台响应分流:
|
||||
- `type === "apple_iap"` → 取 `product_ids`,用 StoreKit 拉取并购买,成功后将 `signedData` 调用 `attach`
|
||||
- `type === "stripe"` → 初始化 Stripe 支付组件
|
||||
- `type === "url"` → 直接跳转到返回的 `checkout_url`
|
||||
- `type === "qr"` → 展示二维码URL
|
||||
|
||||
### 命名规则(推荐)
|
||||
- Apple 商品命名统一为:`merchant.hifastvpn.day${quantity}`,例如:
|
||||
- 7 天:`merchant.hifastvpn.day7`
|
||||
- 30 天:`merchant.hifastvpn.day30`
|
||||
- 90 天:`merchant.hifastvpn.day90`
|
||||
- 新增套餐时只需:在 App Store Connect 新增对应商品,并在 Web 后台/配置新增映射(`durationDays/tier/subscribeId`),前端无需改代码即可使用。
|
||||
|
||||
## Swift 示例
|
||||
|
||||
### 拉取商品与展示
|
||||
```swift
|
||||
import StoreKit
|
||||
|
||||
let productIds = ["com.airport.vpn.pass.30d", "com.airport.vpn.pass.90d"]
|
||||
let products = try await Product.products(for: productIds)
|
||||
// 展示 products 的价格与描述
|
||||
```
|
||||
|
||||
### 发起购买并绑定
|
||||
```swift
|
||||
import StoreKit
|
||||
|
||||
func purchaseAndAttach(product: Product, token: String) async throws {
|
||||
let result = try await product.purchase()
|
||||
switch result {
|
||||
case .success(let verification):
|
||||
let transaction = try verification.payloadValue
|
||||
let jws = transaction.signedData
|
||||
|
||||
struct AttachReq: Codable {
|
||||
let signed_transaction_jws: String
|
||||
// 若映射不一致,则补齐以下字段
|
||||
let duration_days: Int64?
|
||||
let subscribe_id: Int64?
|
||||
let tier: String?
|
||||
}
|
||||
let body = AttachReq(
|
||||
signed_transaction_jws: jws,
|
||||
duration_days: nil, // 映射一致时可为 nil
|
||||
subscribe_id: nil,
|
||||
tier: nil
|
||||
)
|
||||
var req = URLRequest(url: URL(string: "https://api.yourdomain.com/v1/public/iap/apple/transactions/attach")!)
|
||||
req.httpMethod = "POST"
|
||||
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
|
||||
let (data, _) = try await URLSession.shared.data(for: req)
|
||||
// 解析返回并更新 UI
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 恢复购买(批量)
|
||||
```swift
|
||||
import StoreKit
|
||||
|
||||
func restorePurchases(token: String) async throws {
|
||||
try await AppStore.sync()
|
||||
var signedDataList: [String] = []
|
||||
for await t in Transaction.currentEntitlements {
|
||||
let v = try t.verificationResult.payloadValue
|
||||
signedDataList.append(v.signedData)
|
||||
}
|
||||
struct RestoreReq: Codable { let transactions: [String] }
|
||||
let body = RestoreReq(transactions: signedDataList)
|
||||
|
||||
var req = URLRequest(url: URL(string: "https://api.yourdomain.com/v1/public/iap/apple/restore")!)
|
||||
req.httpMethod = "POST"
|
||||
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.addValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try JSONEncoder().encode(body)
|
||||
let (_, _) = try await URLSession.shared.data(for: req)
|
||||
}
|
||||
```
|
||||
|
||||
### 查询状态
|
||||
```swift
|
||||
func fetchIAPStatus(token: String) async throws {
|
||||
var req = URLRequest(url: URL(string: "https://api.yourdomain.com/v1/public/iap/apple/status")!)
|
||||
req.addValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
let (data, _) = try await URLSession.shared.data(for: req)
|
||||
// 解析 active/expires_at/tier
|
||||
}
|
||||
```
|
||||
|
||||
### 退款入口(帮助页)
|
||||
```swift
|
||||
// 在 App 内购买帮助页面提供按钮,调用系统退款流程
|
||||
// try await beginRefundRequest(for: product, in: windowScene)
|
||||
```
|
||||
|
||||
## 错误处理建议
|
||||
- 绑定失败(`code != 200`)
|
||||
- 校验用户登录态、`signedData` 是否来自 `transaction.verify()` 的成功结果
|
||||
- 若提示 `unknown product`,请在请求体中按回退规范携带 `duration_days/subscribe_id/tier`
|
||||
- 网络与重试
|
||||
- 建议对 `attach/restore` 做有限重试与幂等保护(相同 `originalTransactionId` 不重复绑定)
|
||||
- 恢复失败
|
||||
- 确保已调用 `AppStore.sync()` 并遍历 `currentEntitlements`
|
||||
|
||||
## 调试与沙盒
|
||||
- 使用 Sandbox 账号进行测试;购买成功后调用 `attach → status`,验证到期与等级;再次 `restore → status` 验证幂等。
|
||||
- 建议打印 `request_id`(如有)便于后端排查。
|
||||
|
||||
## HIG 注意事项
|
||||
- 仅在可支付时显示商店;价格与文案清晰,不截断标题;使用系统确认表单,不自定义购买弹窗。
|
||||
- 在帮助页提供退款入口与说明,文案简洁直达。
|
||||
|
||||
## 常见问题
|
||||
- `productId` 不一致:服务端未配置某商品时,客户端按回退规范补齐时长与订阅 ID 即可绑定;建议后续让两端保持一致以减少维护成本。
|
||||
- 权益冲突:若用户同时存在多来源订阅,服务端按最高等级与最晚到期计算权益。
|
||||
113
doc/加解密说明文档.md
113
doc/加解密说明文档.md
@ -1,113 +0,0 @@
|
||||
# 项目加解密使用说明
|
||||
|
||||
本指南介绍了 PPanel Server 项目中使用的加解密机制,主要用于设备端(Device)通信的安全保障。
|
||||
|
||||
## 1. 核心算法
|
||||
项目使用 **AES-256-CBC** 加密算法。
|
||||
- **填充方式**:PKCS7 Padding。
|
||||
- **数据编码**:Base64。
|
||||
|
||||
## 2. 密钥(Key)与初始化向量(IV)生成逻辑
|
||||
|
||||
### 2.1 密钥生成 (Key Generation)
|
||||
密钥由一个预定义的 `SecuritySecret`(简称 Secret)生成:
|
||||
1. 对 Secret 进行 **SHA-256** 哈希。
|
||||
2. 取哈希结果的前 **32 字节** 作为 AES-256 的密钥。
|
||||
|
||||
### 2.2 初始化向量生成 (IV Generation)
|
||||
IV 是动态生成的,以增强安全性:
|
||||
1. 客户端或服务端生成一个随机字符串(Nonce,通常是纳秒级时间戳)。
|
||||
2. 对 Nonce 进行 **MD5** 哈希。
|
||||
3. 将 MD5 结果(十六进制字符串)与 Secret 拼接。
|
||||
4. 对拼接后的字符串按 2.1 节的方式生成密钥逻辑处理,取结果的前 **16 字节** 作为 IV。
|
||||
|
||||
> [!NOTE]
|
||||
> 在 API 通信中,Nonce 字符串通常通过请求参数中的 `time` 字段传递。
|
||||
|
||||
## 3. 身份识别与优先顺序
|
||||
|
||||
服务端通过 `Login-Type` 来识别是否需要进行加解密逻辑(值为 `device` 时触发)。
|
||||
|
||||
### 3.1 识别途径
|
||||
1. **Token 负载 (JWT Claims)**:Token 中包含 `LoginType` (值为 `device`) 和 `DeviceId`。
|
||||
2. **请求头 (Header)**:`Login-Type: device`。
|
||||
|
||||
### 3.2 优先顺序与场景
|
||||
- **已登录场景**:服务端优先从 **Token** 负载中读取 `LoginType`。如果 Token 合法且包含 `LoginType: device`,则启用加解密。
|
||||
- **未登录/登录中场景**:例如 `/v1/auth/login/device` 接口,由于此时没有有效 Token,服务端会检查 **Header** 中的 `Login-Type`。
|
||||
|
||||
> [!TIP]
|
||||
> 为了确保一致性,建议在设备端请求中**始终**携带 `Login-Type: device` 请求头,并在登录后确保存储的 Token 负载中也包含对应信息。
|
||||
|
||||
## 4. 中间件应用 (DeviceMiddleware)
|
||||
|
||||
|
||||
`DeviceMiddleware` 处理 `Login-Type: device` 的请求:
|
||||
- **请求解密**:
|
||||
- 检查 URL 参数或 JSON Body 中的 `data`(加密数据)和 `time`(Nonce)。
|
||||
- 使用配置的 Secret 和 Nonce 解密 `data`。
|
||||
- 将解密后的 JSON 重新注入到请求上下文中。
|
||||
- **响应加密**:
|
||||
- 拦截响应 Body。
|
||||
- 加密 Body 中的 `data` 字段。
|
||||
- 将响应格式化为:
|
||||
```json
|
||||
{
|
||||
"data": "ENCRYPTED_BASE64_STRING",
|
||||
"time": "NONCE_STRING"
|
||||
}
|
||||
```
|
||||
|
||||
## 5. Token 负载详情 (JWT Payload)
|
||||
当 `Login-Type` 为 `device` 时,JWT Token 会包含以下自定义字段:
|
||||
- `LoginType`: `"device"`
|
||||
- `DeviceId`: 设备的数据库唯一 ID。
|
||||
|
||||
## 6. 代码示例
|
||||
|
||||
|
||||
### Go 语言 (服务端)
|
||||
参考 [pkg/aes/aes.go](file:///Users/Apple/vpn/ppanel-server/pkg/aes/aes.go)
|
||||
|
||||
```go
|
||||
import pkgaes "github.com/perfect-panel/server/pkg/aes"
|
||||
|
||||
// 加密
|
||||
encrypt, nonce, err := pkgaes.Encrypt([]byte("plain text"), secret)
|
||||
|
||||
// 解密
|
||||
decrypt, err := pkgaes.Decrypt(cipherText, secret, nonce)
|
||||
```
|
||||
|
||||
### JavaScript (客户端示例)
|
||||
使用 `crypto-js` 库:
|
||||
|
||||
```javascript
|
||||
const CryptoJS = require("crypto-js");
|
||||
|
||||
function getIv(nonce, secret) {
|
||||
const md5Nonce = CryptoJS.MD5(nonce).toString();
|
||||
const ivStr = md5Nonce + secret;
|
||||
const key = CryptoJS.SHA256(ivStr);
|
||||
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 32));
|
||||
}
|
||||
|
||||
function getKey(secret) {
|
||||
const key = CryptoJS.SHA256(secret);
|
||||
return CryptoJS.enc.Hex.parse(key.toString().substring(0, 64));
|
||||
}
|
||||
|
||||
// 加密示例
|
||||
const key = getKey(secret);
|
||||
const iv = getIv(nonce, secret);
|
||||
const encrypted = CryptoJS.AES.encrypt("plain text", key, {
|
||||
iv: iv,
|
||||
mode: CryptoJS.mode.CBC,
|
||||
padding: CryptoJS.pad.Pkcs7
|
||||
});
|
||||
console.log(encrypted.toString()); // Base64
|
||||
```
|
||||
|
||||
## 5. 安全建议
|
||||
- 请务必在配置文件中修改默认的 `SecuritySecret`。
|
||||
- 确保 `time` (Nonce) 在每次请求时都是唯一的,以防止重放攻击和频率分析。
|
||||
42
doc/说明文档.md
42
doc/说明文档.md
@ -1,42 +0,0 @@
|
||||
# 报错日志收集(log_message)
|
||||
|
||||
## 项目规划
|
||||
- 目标:新增 `log_message` 表与采集/查询接口,用于 APP / PC / Web 客户端错误日志收集与分析。
|
||||
- 范围:
|
||||
- 创建 MySQL 表与迁移脚本(02105)。
|
||||
- 新增客户端上报接口 `POST /v1/common/log/message/report`。
|
||||
- 新增管理端查询接口 `GET /v1/admin/log/message/error/list` 与 `GET /v1/admin/log/message/error/detail`。
|
||||
|
||||
## 实施方案
|
||||
- 表结构:见 `initialize/migrate/database/02120_log_message.up.sql`。
|
||||
- 模型:`internal/model/logmessage/`(实体、默认 CRUD、筛选)。
|
||||
- 路由:在 `internal/handler/routes.go` 注册公共与管理端路由。
|
||||
- 逻辑:
|
||||
- 上报逻辑:`internal/logic/common/logMessageReportLogic.go`(限流、指纹去重、入库)。
|
||||
- 管理查询:`internal/logic/admin/log/getErrorLogMessageListLogic.go`、`getErrorLogMessageDetailLogic.go`。
|
||||
- 类型:`internal/types/types.go` 新增请求/响应结构。
|
||||
- 安全:详细加解密逻辑见 [加解密说明文档.md](file:///Users/Apple/vpn/ppanel-server/doc/加解密说明文档.md)。
|
||||
|
||||
|
||||
## 进度记录
|
||||
- 2025-12-02:
|
||||
- 完成表与索引创建迁移文件。
|
||||
- 完成模型与服务注入。
|
||||
- 完成公共上报接口与限流、去重逻辑;编译验证通过。
|
||||
- 完成管理端列表与详情接口;编译验证通过。
|
||||
- 待办:根据运营需求调整限流阈值与日志保留策略。
|
||||
- 2026-01-08:
|
||||
- 完成「项目加解密使用说明」文档编写,涵盖 AES-256-CBC 实现及中间件逻辑。
|
||||
|
||||
|
||||
## 接口规范
|
||||
- 上报:`POST /v1/common/log/message/report`(详见 `doc/api/log_message_report.md`)
|
||||
- 管理端列表:`GET /v1/admin/log/message/error/list`
|
||||
- 筛选:platform、level、user_id、device_id、error_code、keyword、start、end;分页:page、size
|
||||
- 响应:`{ total, list }`
|
||||
- 管理端详情:`GET /v1/admin/log/message/error/detail?id=...`
|
||||
- 响应:完整字段
|
||||
|
||||
## 保留策略与安全
|
||||
- 限流:按设备/IP 每分钟 120 条(可配置)。
|
||||
- 隐私:避免采集敏感数据;服务端对大字段做长度限制与截断。
|
||||
@ -1,13 +1,24 @@
|
||||
# PPanel 服务部署 (云端/无源码版)
|
||||
# 使用方法:
|
||||
# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/, grafana/, prometheus/ 目录上传到服务器同一目录
|
||||
# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件
|
||||
# 3. 确保 logs/ 目录存在 (mkdir logs)
|
||||
# 使用方法:
|
||||
# 1. 确保已将 docker-compose.cloud.yml, configs/, loki/, grafana/, prometheus/, tempo/ 目录上传到服务器同一目录
|
||||
# 2. 确保 configs/ 目录下有 ppanel.yaml 配置文件(参考 etc/ppanel.yaml)
|
||||
# 3. 确保 logs/ cache/ tempo_data/ 目录存在 (mkdir -p logs cache tempo_data)
|
||||
# 4. 运行: docker-compose -f docker-compose.cloud.yml up -d
|
||||
#
|
||||
# 网络说明:
|
||||
# ppanel-server 使用 host 网络(可出外网,访问 MySQL/Redis/Tempo 用 127.0.0.1)
|
||||
# 监控服务(MySQL/Redis/Loki/Tempo/Grafana/Prometheus)在 ppanel_net bridge 网络中
|
||||
# MySQL(3306)/Redis(6379)/Tempo(4317) 将端口映射到 127.0.0.1,ppanel-server 通过 host 网络访问
|
||||
# 监控端口绑定 127.0.0.1,需通过 SSH 隧道或 Nginx 反代访问
|
||||
#
|
||||
# 未来多开 ppanel-server 时:
|
||||
# 修复宿主机 iptables bridge 出网规则后,可将 ppanel-server 切回 bridge 网络
|
||||
# 多实例用不同端口: ports: ["8081:8080"] + container_name: ppanel-server-2
|
||||
|
||||
services:
|
||||
# ----------------------------------------------------
|
||||
# 1. 业务后端 (PPanel Server)
|
||||
# host 网络:可出外网,通过 127.0.0.1 访问 MySQL/Redis/Tempo
|
||||
# ----------------------------------------------------
|
||||
ppanel-server:
|
||||
image: registry.kxsw.us/vpn-server:${PPANEL_SERVER_TAG:-latest}
|
||||
@ -16,51 +27,22 @@ services:
|
||||
volumes:
|
||||
- ./configs:/app/etc
|
||||
- ./logs:/app/logs
|
||||
- ./cache:/app/cache # GeoLite2-City.mmdb IP 地理位置数据库
|
||||
environment:
|
||||
- TZ=Asia/Shanghai
|
||||
# 链路追踪配置 (OTLP)
|
||||
- OTEL_EXPORTER_OTLP_ENDPOINT=http://127.0.0.1:4317
|
||||
- OTEL_SERVICE_NAME=ppanel-server
|
||||
- OTEL_TRACES_EXPORTER=otlp
|
||||
- OTEL_METRICS_EXPORTER=prometheus # 指标由 tempo 抓取,不使用 OTLP
|
||||
network_mode: host
|
||||
ulimits:
|
||||
nproc: 65535
|
||||
nofile:
|
||||
soft: 65535
|
||||
hard: 65535
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
depends_on:
|
||||
- mysql
|
||||
- redis
|
||||
- tempo
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 14. Tempo (链路追踪存储 - 替代/增强 Jaeger)
|
||||
# ----------------------------------------------------
|
||||
tempo:
|
||||
image: grafana/tempo:2.4.1
|
||||
container_name: ppanel-tempo
|
||||
user: root
|
||||
restart: always
|
||||
command:
|
||||
- "-config.file=/etc/tempo.yaml"
|
||||
- "-target=all"
|
||||
volumes:
|
||||
- ./tempo/tempo-config.yaml:/etc/tempo.yaml # - tempo_data:/var/tempo
|
||||
- ./tempo_data:/var/tempo # 改为映射到当前目录,确保数据彻底干净
|
||||
|
||||
ports:
|
||||
- "3200:3200"
|
||||
- "4317:4317"
|
||||
- "4318:4318"
|
||||
- "9095:9095"
|
||||
networks:
|
||||
- ppanel_net
|
||||
mysql:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_healthy
|
||||
tempo:
|
||||
condition: service_started
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@ -75,9 +57,9 @@ services:
|
||||
container_name: ppanel-mysql
|
||||
restart: always
|
||||
ports:
|
||||
- "3306:3306" # 临时开放外部访问,用完记得关闭!
|
||||
- "127.0.0.1:3306:3306" # 仅宿主机可访问,ppanel-server(host网络)通过127.0.0.1连接
|
||||
environment:
|
||||
MYSQL_ROOT_PASSWORD: "jpcV41ppanel" # 请修改为强密码
|
||||
MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?请在 .env 文件中设置 MYSQL_ROOT_PASSWORD}"
|
||||
MYSQL_DATABASE: "ppanel"
|
||||
TZ: Asia/Shanghai
|
||||
command:
|
||||
@ -97,6 +79,11 @@ services:
|
||||
hard: 65535
|
||||
networks:
|
||||
- ppanel_net
|
||||
healthcheck:
|
||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@ -111,7 +98,7 @@ services:
|
||||
container_name: ppanel-redis
|
||||
restart: always
|
||||
ports:
|
||||
- "6379:6379"
|
||||
- "127.0.0.1:6379:6379" # 仅宿主机可访问,ppanel-server(host网络)通过127.0.0.1连接
|
||||
command:
|
||||
- redis-server
|
||||
- --tcp-backlog 65535
|
||||
@ -125,6 +112,11 @@ services:
|
||||
hard: 65535
|
||||
networks:
|
||||
- ppanel_net
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@ -132,19 +124,21 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 4. Loki (日志存储)
|
||||
# 4. Tempo (链路追踪存储)
|
||||
# ----------------------------------------------------
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: ppanel-loki
|
||||
tempo:
|
||||
image: grafana/tempo:2.4.1
|
||||
container_name: ppanel-tempo
|
||||
user: root
|
||||
restart: always
|
||||
command:
|
||||
- "-config.file=/etc/tempo.yaml"
|
||||
- "-target=all"
|
||||
volumes:
|
||||
# 必须上传 loki 目录到服务器
|
||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
- ./tempo/tempo-config.yaml:/etc/tempo.yaml
|
||||
- ./tempo_data:/var/tempo
|
||||
ports:
|
||||
- "3100:3100"
|
||||
- "127.0.0.1:4317:4317" # OTLP gRPC,ppanel-server(host网络)通过127.0.0.1:4317发送trace
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
@ -154,7 +148,27 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 5. Promtail (日志采集)
|
||||
# 5. Loki (日志存储)
|
||||
# ----------------------------------------------------
|
||||
loki:
|
||||
image: grafana/loki:3.0.0
|
||||
container_name: ppanel-loki
|
||||
restart: always
|
||||
volumes:
|
||||
- ./loki/loki-config.yaml:/etc/loki/local-config.yaml
|
||||
- loki_data:/loki
|
||||
command: -config.file=/etc/loki/local-config.yaml
|
||||
# 不对外暴露端口,仅内网访问
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
max-size: "10m"
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 6. Promtail (日志采集)
|
||||
# ----------------------------------------------------
|
||||
promtail:
|
||||
image: grafana/promtail:3.0.0
|
||||
@ -164,9 +178,7 @@ services:
|
||||
- ./loki/promtail-config.yaml:/etc/promtail/config.yaml
|
||||
- /var/lib/docker/containers:/var/lib/docker/containers:ro
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
# 采集当前目录下的 logs 文件夹
|
||||
- ./logs:/var/log/ppanel-server:ro
|
||||
# 采集 Nginx 访问日志(用于追踪邀请码来源)
|
||||
- /var/log/nginx:/var/log/nginx:ro
|
||||
command: -config.file=/etc/promtail/config.yaml
|
||||
networks:
|
||||
@ -180,27 +192,29 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 6. Grafana (日志界面)
|
||||
# 7. Grafana (可观测面板)
|
||||
# 访问: ssh -L 3333:localhost:3333 your-server 后浏览器打开 http://localhost:3333
|
||||
# 或配置 Nginx 反代(建议加认证)
|
||||
# ----------------------------------------------------
|
||||
grafana:
|
||||
image: grafana/grafana:latest
|
||||
container_name: ppanel-grafana
|
||||
restart: always
|
||||
ports:
|
||||
- "3333:3000"
|
||||
- "127.0.0.1:3333:3000" # 仅本机可访问,需 SSH 隧道或 Nginx 反代
|
||||
environment:
|
||||
- GF_SECURITY_ADMIN_PASSWORD=admin
|
||||
- GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_PASSWORD:?请在 .env 文件中设置 GRAFANA_PASSWORD}
|
||||
- GF_USERS_ALLOW_SIGN_UP=false
|
||||
- GF_FEATURE_TOGGLES_ENABLE=appObservability #- GF_INSTALL_PLUGINS=redis-datasource
|
||||
- GF_FEATURE_TOGGLES_ENABLE=appObservability
|
||||
volumes:
|
||||
- grafana_data:/var/lib/grafana
|
||||
- ./grafana/provisioning:/etc/grafana/provisioning
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
networks:
|
||||
- ppanel_net
|
||||
depends_on:
|
||||
- loki
|
||||
- tempo
|
||||
- prometheus
|
||||
logging:
|
||||
driver: "json-file"
|
||||
options:
|
||||
@ -208,25 +222,22 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 7. Prometheus (指标采集)
|
||||
# 8. Prometheus (指标采集)
|
||||
# ----------------------------------------------------
|
||||
prometheus:
|
||||
image: prom/prometheus:latest
|
||||
container_name: ppanel-prometheus
|
||||
restart: always
|
||||
ports:
|
||||
- "9090:9090" # 暴露端口便于调试
|
||||
- "127.0.0.1:9090:9090" # 仅本机可访问
|
||||
volumes:
|
||||
- ./prometheus/prometheus.yml:/etc/prometheus/prometheus.yml
|
||||
- prometheus_data:/prometheus
|
||||
extra_hosts:
|
||||
- "host.docker.internal:host-gateway"
|
||||
command:
|
||||
- '--config.file=/etc/prometheus/prometheus.yml'
|
||||
- '--storage.tsdb.path=/prometheus'
|
||||
- '--web.enable-lifecycle'
|
||||
- '--web.enable-remote-write-receiver'
|
||||
|
||||
networks:
|
||||
- ppanel_net
|
||||
logging:
|
||||
@ -236,7 +247,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 8. Redis Exporter (Redis指标导出)
|
||||
# 9. Redis Exporter
|
||||
# ----------------------------------------------------
|
||||
redis-exporter:
|
||||
image: oliver006/redis_exporter:latest
|
||||
@ -255,13 +266,12 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 9. Nginx Exporter (监控宿主机 Nginx)
|
||||
# 10. Nginx Exporter (监控宿主机 Nginx)
|
||||
# ----------------------------------------------------
|
||||
nginx-exporter:
|
||||
image: nginx/nginx-prometheus-exporter:latest
|
||||
container_name: ppanel-nginx-exporter
|
||||
restart: always
|
||||
# 使用 host.docker.internal 访问宿主机
|
||||
command:
|
||||
- -nginx.scrape-uri=http://host.docker.internal:8090/nginx_status
|
||||
extra_hosts:
|
||||
@ -275,7 +285,7 @@ services:
|
||||
max-file: "3"
|
||||
|
||||
# ----------------------------------------------------
|
||||
# 10. MySQL Exporter (MySQL指标导出)
|
||||
# 11. MySQL Exporter
|
||||
# ----------------------------------------------------
|
||||
mysql-exporter:
|
||||
image: prom/mysqld-exporter:latest
|
||||
@ -347,7 +357,6 @@ volumes:
|
||||
prometheus_data:
|
||||
tempo_data:
|
||||
|
||||
|
||||
networks:
|
||||
ppanel_net:
|
||||
name: ppanel_net
|
||||
|
||||
13
docker-compose.yml
Normal file
13
docker-compose.yml
Normal file
@ -0,0 +1,13 @@
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
ppanel:
|
||||
container_name: ppanel-server
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- ./etc/ppanel.yaml:/app/etc/ppanel.yaml
|
||||
restart: always
|
||||
@ -1,54 +0,0 @@
|
||||
# ALIGNMENT: 检查设备登录 IP 逻辑
|
||||
|
||||
## 原始需求
|
||||
用户询问:
|
||||
> "检查一下 设备登录的时候 有检查IP 么"
|
||||
> "我需要知道 有没有IP 目前数据库都没有IP"
|
||||
|
||||
## 项目现状分析 (Context Analysis)
|
||||
1. **数据模型 (`user.Device`)**:
|
||||
- 存在 `Ip` 字段 (`varchar(255)`).
|
||||
- 定义在 `/internal/model/user/user.go`。
|
||||
|
||||
2. **业务逻辑 (`DeviceLoginLogic.go`)**:
|
||||
- 在代码逻辑中,确实尝试获取 `req.IP` 并保存到数据库。
|
||||
- 创建新设备时:`deviceInfo.Ip = req.IP`。
|
||||
- 记录登录日志时:`LoginIP: req.IP`。
|
||||
|
||||
3. **关键问题点 (Root Cause)**:
|
||||
- 输入参数定义在 `internal/types/types.go`:
|
||||
```go
|
||||
type DeviceLoginRequest struct {
|
||||
// ...
|
||||
IP string `header:"X-Original-Forwarded-For"`
|
||||
// ...
|
||||
}
|
||||
```
|
||||
- **当前仅支持 `X-Original-Forwarded-For` 请求头**。
|
||||
- 如果请求经过 Nginx、Cloudflare 等代理,但没有专门配置传递这个特定的 Header,或者使用的是标准的 `X-Forwarded-For` / `X-Real-IP`,后端获取到的 `req.IP` 将为空字符串。
|
||||
- 这就是导致“数据库都没有IP”的直接原因。
|
||||
|
||||
## 疑问澄清 (Questions)
|
||||
我们需要确认修复方案:
|
||||
|
||||
1. **是否只需支持标准 Header?**
|
||||
- 建议改为优先获取 `X-Forwarded-For`,其次 `X-Real-IP`,最后才是 `X-Original-Forwarded-For` 或直连 IP。
|
||||
- go-zero 框架通常可以通过配置或中间件处理 IP,或者我们在 struct tag 中调整。但 struct tag `header` 只能由 go-zero 的 rest 绑定一个特定的 key。
|
||||
2. **是否需要记录 IP 归属地?**
|
||||
- 目前逻辑只记录 IP 字符串,不解析归属地。需求中没提,暂时不作为重点,但可以确认一下。
|
||||
|
||||
## 建议方案
|
||||
修改 `DeviceLoginRequest` 的定义可能不够灵活(Header key 是固定的)。
|
||||
更好的方式是:
|
||||
1. **移除 Struct Tag 绑定**(或者保留作为备选)。
|
||||
2. **在 Logic 中显式获取 IP**:
|
||||
- 从 `l.ctx` (Context) 中获取 `http.Request` (如果 go-zero 支持)。
|
||||
- 或者在 Middleware 中解析真实 IP 并放入 Context。
|
||||
- 或者简单点,修改 Struct Tag 为最常用的 `X-Forwarded-For` (如果确定环境是这样配置的)。
|
||||
|
||||
**最快修复**:
|
||||
将 `internal/types/types.go` 中的 `X-Original-Forwarded-For` 改为 `X-Forwarded-For` (或者根据实际网关配置修改)。
|
||||
但通常建议使用工具函数解析多种 Header。
|
||||
|
||||
## 下一步 (Next Step)
|
||||
请确认是否要我修改代码以支持标准的 IP 获取方式(如 `X-Forwarded-For`)?
|
||||
@ -1,36 +0,0 @@
|
||||
# DESIGN: Device Login IP Fix
|
||||
|
||||
## 目标
|
||||
修复设备登录时无法获取真实 IP (`req.IP` 为空) 的问题,导致数据库未存储 IP。
|
||||
|
||||
## 现状
|
||||
- `internal/types/types.go` 定义了 `DeviceLoginRequest`,其中 `IP` 字段绑定的是 `X-Original-Forwarded-For`。
|
||||
- 实际环境中(Nginx/Cloudflare等)通常使用 `X-Forwarded-For`。
|
||||
|
||||
## 方案选择
|
||||
由于项目使用 `go-zero` 并且存在 `.api` 文件,**最佳实践**是修改 `.api` 文件并重新生成代码。
|
||||
但考虑到我无法运行 `goctl` (或者环境可能不一致),如果不重新生成而直接改 `types.go`,虽然能即时生效,但下次生成会被覆盖。
|
||||
|
||||
**然而**,鉴于我之前的操作已经直接修改过 `types.go` (Invite Sales Time Filter),且项目看似允许直接修改(或用户负责生成),我将**优先修改 `.api` 文件** 以保持源头正确,同时**手动同步修改 `types.go`** 以确保立即生效。
|
||||
|
||||
## 变更范围
|
||||
|
||||
### 1. API 定义 (`apis/auth/auth.api`)
|
||||
- 修改 `DeviceLoginRequest` struct。
|
||||
- 将 `header: X-Original-Forwarded-For` 改为 `header: X-Forwarded-For` (这是最通用的标准)。
|
||||
|
||||
### 2. 生成文件 (`internal/types/types.go`)
|
||||
- 手动同步修改 `DeviceLoginRequest` 中的 Tag。
|
||||
- 变为: `IP string header:"X-Forwarded-For"`
|
||||
|
||||
### 3. (可选增强) 业务逻辑 (`internal/logic/auth/deviceLoginLogic.go`)
|
||||
- 由于 go-zero 的绑定机制比较“死”,如果 Tag 没取到值,就是空的。Logic 层拿到空字符串也没办法再去 Context 捞(除非 Context 里存了 request)。
|
||||
- 暂时只做 Tag 修改,因为这是最根本原因。
|
||||
|
||||
## 验证
|
||||
- 检查代码变更。
|
||||
- (无法直接测试 IP 获取,依赖用户部署验证)。
|
||||
|
||||
## 任务拆分
|
||||
1. 修改 `apis/auth/auth.api`
|
||||
2. 修改 `internal/types/types.go`
|
||||
@ -1,102 +0,0 @@
|
||||
# Nginx 下载日志解密工具
|
||||
|
||||
## 简介
|
||||
|
||||
此工具用于解密 Nginx 访问日志中 `/v1/common/client/download` 接口的加密 `data` 参数。
|
||||
|
||||
通讯密钥:`c0qhq99a-nq8h-ropg-wrlc-ezj4dlkxqpzx`
|
||||
|
||||
## 解密结果示例
|
||||
|
||||
从 Nginx 日志解密后,可以获得下载请求的详细信息,例如:
|
||||
|
||||
```json
|
||||
{"platform":"windows"}
|
||||
{"platform":"mac"}
|
||||
{"platform":"android"}
|
||||
{"platform":"ios"}
|
||||
```
|
||||
|
||||
还可能包含邀请码信息:
|
||||
```json
|
||||
{"platform":"windows","invite_code":"ABC123"}
|
||||
```
|
||||
|
||||
## 使用方法
|
||||
|
||||
### 方法 1: 使用 Shell 脚本(推荐)
|
||||
|
||||
```bash
|
||||
# 解密单条日志
|
||||
./decrypt_download.sh '172.245.180.199 - - [02/Feb/2026:04:35:47 +0000] "GET /v1/common/client/download?data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000 HTTP/1.1"'
|
||||
|
||||
# 解密多条日志
|
||||
./decrypt_download.sh \
|
||||
'data=JetaR6P9e8G5lZg2KRiAhV6c%2FdMilBtP78bKmsbAxL8%3D&time=2026-02-02T04:35:15.032000' \
|
||||
'data=%2FFTAxtcEd%2F8T2MzKdxxrPfWBXk4pNPbQZB3p8Yrl8XQ%3D&time=2026-02-02T04:35:15.031000'
|
||||
```
|
||||
|
||||
### 方法 2: 直接运行 Go 程序
|
||||
|
||||
```bash
|
||||
go run cmd/decrypt_download_data/main.go
|
||||
```
|
||||
|
||||
默认会解密内置的示例日志。
|
||||
|
||||
### 方法 3: 从 Nginx 日志文件批量解密
|
||||
|
||||
```bash
|
||||
# 提取所有 download 请求并解密
|
||||
grep "/v1/common/client/download" /var/log/nginx/access.log | \
|
||||
while read line; do
|
||||
./decrypt_download.sh "$line"
|
||||
done
|
||||
```
|
||||
|
||||
## 从 Nginx 服务器上使用
|
||||
|
||||
如果您在 Nginx 服务器上(root@localhost7701),可以这样操作:
|
||||
|
||||
1. **查找所有 download 请求**:
|
||||
```bash
|
||||
grep "/v1/common/client/download" /var/log/nginx/access.log
|
||||
```
|
||||
|
||||
2. **统计各平台下载量**:
|
||||
先解密所有日志,然后统计:
|
||||
```bash
|
||||
# 需要将此工具复制到服务器,或在本地解密后统计
|
||||
```
|
||||
|
||||
3. **实时监控**:
|
||||
```bash
|
||||
tail -f /var/log/nginx/access.log | grep "/v1/common/client/download"
|
||||
```
|
||||
|
||||
## 技术细节
|
||||
|
||||
### 加密方式
|
||||
- **算法**:AES-CBC with PKCS7 padding
|
||||
- **密钥长度**:256 位(通过 SHA256 哈希生成)
|
||||
- **IV 生成**:基于时间戳的 MD5 哈希
|
||||
|
||||
### 参数说明
|
||||
- `data`: URL 编码的 Base64 加密数据
|
||||
- `time`: 用于生成 IV 的时间戳字符串
|
||||
|
||||
### 解密流程
|
||||
1. URL 解码 `data` 参数
|
||||
2. Base64 解码得到密文
|
||||
3. 使用通讯密钥和 `time` 生成解密密钥和 IV
|
||||
4. 使用 AES-CBC 解密得到原始 JSON 数据
|
||||
|
||||
## 相关文件
|
||||
|
||||
- `cmd/decrypt_download_data/main.go` - 解密工具主程序
|
||||
- `decrypt_download.sh` - Shell 脚本快捷方式
|
||||
- `pkg/aes/aes.go` - AES 加密解密库
|
||||
|
||||
## 注意事项
|
||||
|
||||
⚠️ **安全提示**:通讯密钥应妥善保管,不要泄露给未授权人员。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user