私服监控模块 · Private Servers Module¶
Status: shipped (Steps 1–10) Frontend route:
/private-serversBackend prefix:/api/v1/private-servers
10-step upgrade modelled on the threats module. Each step ships independently end-to-end (backend + UI) and is independently verifiable.
1. Module purpose¶
A single workspace for finding the real private servers out of the noisy crawl pool flowing into the portal:
- Discover (queued scans) → Triage (true/疑/假) → Evidence → Action (下架函 / 关联运营者) — and prove ROI to Wemade.
- AI 初评(rule engine + trust_score)+ 人工最终判定 — manual decisions always win.
- Tabbed workspace: "工作集 / 假阳性 / 全部" so analysts spend zero time looking at obvious noise.
- AI summary + live activity feed for situational awareness, same primitives as the threats page.
- Multi-tenant:
tenant_adminsees only itsportal_publications;super_adminsees the global pool.
2. Step ledger (what shipped, in order)¶
| Step | Scope | Deliverable |
|---|---|---|
| 1 | KPI 重做 + Hero | 5-tile KpiTile strip: 真私服 / 疑似 / 待审 / 假阳性 / 本周新增 — bins always sum to total |
| 2 | 详情 Drawer | PrivateServerDetailDrawer.tsx — big screenshot + WHOIS/DNS fold + discovery context + 3 footer actions |
| 3 | 手动 Classification | 4-button toolbar in drawer (真 / 疑似 / 假 / 待审) + 卡片 angle chip; PATCH /:id/classification jsonb merge |
| 4 | 自动规则引擎 + trust_score | ps_classifier.go rule engine (whitelist + 关键词 + TLD + domain pattern + screenshot + CN) + POST /classify-auto |
| 5 | 关联展示 (actor/evidence/legal) | GET /:id/links (3 bridges: linked_actor_id / actor reverse / threats promoted) + 3-tab in drawer |
| 6 | 一键动作 | PrivateServerLegalModal + PrivateServerLinkActorModal + POST /legal-docs/draft from_private_server_id + POST /:id/link-actor |
| 7 | 扫描触发 | portal_scan_tasks table + POST /scan + GET /scans + worker sync endpoints (/sync/private-server-scans/...) + PrivateServerScanModal |
| 8 | 活动流 + AI 摘要 | PrivateServerActivityFeed.tsx + PrivateServerAISummary.tsx + GET /activity (5 sources unioned) + GET /ai-summary (stats-derived/v1) |
| 9 | 列表层 + 工作集 tab | ?classification=csv + ?trust_min=N + trust sort options; 3 workspace tabs ("工作集 / 假阳性 / 全部") |
| 10 | 验证 + 文档 + 部署 | Routes test + this reference doc + ai-astraweb deploy |
3. API reference¶
List & filter¶
GET /api/v1/private-servers
| Query | Type | Notes |
|---|---|---|
game |
string | exact match against game_name |
status |
string | active | taken_down | inactive |
search |
string | ILIKE across domain / server_name / ip_address / game_name |
sort |
enum | last_seen_desc (default) | last_seen_asc | first_seen_desc | risk_desc | risk_asc | trust_desc | trust_asc |
classification |
csv | confirmed,suspected,false_positive,unknown — matches COALESCE(classification, classification_hint, 'unknown') |
trust_min |
int | filter rows with trust_score >= N; rows without a score treated as 0 |
page / page_size |
int | default 1 / 24 |
Stats¶
GET /api/v1/private-servers/stats
Returns:
{
"total": 154,
"active": 154,
"new_this_week": 22,
"by_game": [{"game_name":"MIR4","total":52,"active":50}, ...],
"by_country": [{"geo_country":"CN","count":124}, ...],
"by_classification": [
{"classification":"confirmed","count":12},
{"classification":"suspected","count":18},
{"classification":"unknown","count":83},
{"classification":"false_positive","count":41}
]
}
by_classification uses COALESCE(classification, classification_hint, 'unknown') — manual classification (Step 3) wins over auto hint (Step 4); both sum to total.
Detail¶
GET /api/v1/private-servers/:id — full row including metadata blob.
GET /api/v1/private-servers/:id/links — {actors, evidence, legal_docs, threats} resolved via the 3 bridges (see §5).
Classification¶
PATCH /api/v1/private-servers/:id/classification
Atomically merges classification / classification_reason / classified_at / classified_by into metadata. tenant_admin gated by publication visibility; super_admin can hit anything.
POST /api/v1/private-servers/classify-auto?game=<optional>&only_unclassified=true|false (super_admin only). Runs the Step 4 rule engine across the visible set; writes trust_score + classification_hint + classification_hint_reason + classified_at_auto into each row's metadata. Never overwrites manual classification. Returns {processed, by_hint}.
Legal draft¶
POST /api/v1/legal-docs/draft
Two forms — exactly one of the following:
{ "from_threat_id": "<uuid>", ... } // existing threat path
{ "from_private_server_id": "<uuid>", ... } // NEW (Step 6)
For the ps path, derived defaults: document_type=cease_desist, recipient = <domain> · 域名注册商 (<registrar>) / 托管 IDC 滥用处理部, target_url = http://<domain>. Writes legal_draft_id + legal_drafted_at back into ps.metadata so Step 5 can hyperlink, and (for tenant_admin) inserts a publication row so the new draft is immediately visible in /legal/.
Actor link¶
POST /api/v1/private-servers/:id/link-actor
{ "actor_id": "<uuid>" } // path A — link to existing actor
{ "new_actor_name": "<string>" } // path B — create + link
Single transaction:
- Path A verifies the actor is in this tenant's scope.
- Path B mints a new actor (risk=medium, status=active) and (for tenant_admin) publishes it.
- Both paths bidirectionally stamp:
ps.metadata.linked_actor_id←actor.idANDactor.metadata.linked_private_server_ids←ps.id(jsonb array dedup-on-add).
Scan task queue¶
POST /api/v1/private-servers/scan (super_admin only).
Returns the queued PortalScanTask. Lifecycle: queued → in_progress → done | failed | canceled.
GET /api/v1/private-servers/scans?limit=30&status=&game= — status board.
Internal worker (sync-key path):
GET /api/v1/sync/private-server-scans/queued?limit=5— atomically claim queued rows (SKIP LOCKED).POST /api/v1/sync/private-server-scans/:id/complete—{ status, summary, error }.
Activity + AI summary¶
GET /api/v1/private-servers/activity?limit=30 — 5 sources merge-sorted by timestamp DESC:
| Kind | Source |
|---|---|
ps_new |
publication.published_at (tenant) or synced_at (super_admin) |
classified |
metadata.classified_at (manual) |
legal_draft |
metadata.legal_drafted_at + legal_draft_id |
actor_link |
metadata.linked_actor_at + linked_actor_id |
scan_done |
portal_scan_tasks.completed_at + status |
GET /api/v1/private-servers/ai-summary — templated derivation from PrivateServerStats. Returns {generated_at, model:"stats-derived/v1", headline, paragraphs[], insights[], suggestions[]}. Forward-compatible shape — swap in an LLM by replacing the function body only.
4. Frontend component tree¶
frontend/src/app/(portal)/private-servers/page.tsx
├── PillSelector × 2 (game + status filters)
├── KpiTile × 5 (Hero classification strip)
├── PrivateServerAISummary.tsx (Step 8 left card)
├── PrivateServerActivityFeed.tsx (Step 8 right card)
├── WORKSPACES tabs (Step 9 — 工作集 / 假阳性 / 全部)
└── ServerCard × N
├── ClassChip (corner) (Step 3)
└── MiniTrust (1px bar) (Step 4)
PrivateServerDetailDrawer.tsx (Step 2 — opened on card click)
├── 大截图
├── WHOIS / DNS fold-out
├── AI 初评 panel (Step 4 trust + hint vs current)
├── LinkedEntities tabs (Step 5 — actors / evidence / legal)
└── Footer
├── Classification toolbar (4 buttons — Step 3)
└── 3 action buttons → onAction("legal" | "actor" | "evidence")
PrivateServerLegalModal.tsx (Step 6)
PrivateServerLinkActorModal.tsx (Step 6)
PrivateServerScanModal.tsx (Step 7)
UI primitives reused across all of this (live in components/ui/):
ModalShell · KpiTile · DetailRow · FormGroup · TimelineRow · MiniSparkline · PillSelector · Eyebrow · Panel · Badge · GameTag · StatusBadge.
5. Cross-entity bridges¶
Private servers don't have FK columns to actors / evidence / legal_docs. All
linkage rides on jsonb metadata, written by the action handlers and read by
the GET /:id/links query:
| From → To | Direction | Key | Written by |
|---|---|---|---|
| ps → actor | forward (singular) | ps.metadata.linked_actor_id |
Step 6 link-actor |
| actor → ps | reverse (set) | actor.metadata.linked_private_server_ids (jsonb array) |
Step 6 link-actor |
| ps → legal_doc | forward | ps.metadata.legal_draft_id + legal_drafted_at |
Step 6 legal draft |
| threat → ps | forward | threat.metadata.promoted_to_private_server_id |
Threats Step 8 promote |
| actor → evidence/legal | forward | evidence.actor_id / legal_doc.actor_id (existing schema columns) |
Sync |
Effect:
- After Step 6 link-actor runs, the linked actor + all evidence/legal under that actor surface in the drawer's "运营者 / 证据链 / 法律文书" tabs.
- After Step 6 legal-draft runs, the doc appears in the "法律文书" tab.
- Threats promoted-to-this-ps (Threats Step 8) appear in the "来源威胁" sub-section regardless of Step 6 state.
6. Auto-classifier (Step 4)¶
internal/repository/ps_classifier.go. Pure function ClassifyPrivateServer(*PortalPrivateServer) → {trust_score, classification_hint, reason}.
| Signal | Weight |
|---|---|
| Hostname matches a curated whitelist (35 entries: 4399, taptap, 18183, gamersky, bilibili, wikipedia, wemade.com, playmir4.com, …) | hard short-circuit → score 5, hint false_positive |
Title / first_query contains a smoking-gun keyword (私服 / sf / 新开 / 注册送 / GM后台 / 打金服 / 无限元宝 / 复古服 / …) |
+30 |
Hostname stem matches letters+digits[+letters] (e.g. zhaotj / mir4cool / yb235) |
+15 |
Low-cost TLD (.top / .xyz / .vip / .club / .online / .site / .cc) |
+10 |
| Landing-page screenshot was captured | +10 |
WHOIS country = CN |
+5 |
Buckets (after cap to [0,100]):
Verify rule changes with go test ./internal/repository/ -run TestClassifyPrivateServer (8 cases pinned to canonical Phase 3 examples).
7. Time-bucket policy (mirrors threats)¶
For the Hero KPI strip's new_this_week:
- tenant_admin: counts
portal_publications.published_atevents where source_type=private_server(i.e. "what showed up for me this week"). - super_admin: counts
synced_aton the global pool (i.e. "what was discovered this week").
Same split is applied in PrivateServerActivityFeed's ps_new source. This is the same fix used in Threats Step 1 — keeps a fresh wemade tenant from seeing "本周新增 0" when admin has actually published a batch on Monday.
8. Verification¶
Local:
# Compile + vet + tests
go build ./...
go vet ./...
go test ./internal/handler/ -run TestPortalRoutesRegister # all 17 routes registered
go test ./internal/repository/ -run TestClassifyPrivateServer # rule-engine bands
# Frontend
cd frontend
npx tsc --noEmit
npx eslint src/app/\(portal\)/private-servers/page.tsx src/components/viz/PrivateServer*.tsx
npm run build # 28 static pages, /private-servers included
End-to-end smoke against the live deployment (ai-astraweb · portal.astraeye.ai):
TOKEN=... # super_admin or wemade tenant_admin JWT
BASE=https://portal.astraeye.ai/api/v1
# Hero KPIs
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/private-servers/stats" \
| jq '{total, new_this_week, by_classification}'
# Workspace tabs
curl -s -H "Authorization: Bearer $TOKEN" \
"$BASE/private-servers?classification=confirmed,suspected,unknown&page_size=1" \
| jq '.total'
# AI 初评 batch (super_admin)
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
"$BASE/private-servers/classify-auto?game=MIR4&only_unclassified=true" \
| jq '.'
# Activity + AI summary
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/private-servers/activity?limit=10" \
| jq '.items | length'
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/private-servers/ai-summary" \
| jq '.headline'
# Queue a scan
curl -s -X POST -H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"game":"MIR4","keywords":["传奇4 私服"],"notes":"smoke"}' \
"$BASE/private-servers/scan" | jq '{id, status}'
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/private-servers/scans?limit=5" \
| jq '.count'
9. Known limits / next steps¶
- Auto-classifier is rule-based, not learned. Easy to extend the whitelist + keyword list in
ps_classifier.go; the test pins the buckets so you'll notice if weights drift. Swapping in an LLM = replaceClassifyPrivateServerbody only; the JSONB write path doesn't move. - Scan worker not yet implemented on ai-astrahost. Portal-side API is complete; expected worker behaviour documented in §3 "Scan task queue". Tasks sit in
queueduntil a worker comes online. - Evidence creation from the drawer is still a no-op (
onAction("evidence")). Step 6 was scoped to legal + actor link; building evidence chains from a ps row is a follow-up. TheLinkedEntities"证据链" tab already pulls evidence for any linked actors. - AI summary is templated.
model: "stats-derived/v1"is the marker. To upgrade to a real LLM (DGX vLLM Gemma-4 or dashscope), replaceBuildPrivateServerAISummary's body — the payload shape and frontend stay. - Multi-tenant classification is currently global (latest writer wins). If Wemade wants its own classification overlay separate from a future second tenant, add
metadata.classifications[tenant_id]map.