跳转至

私服监控模块 · Private Servers Module

Status: shipped (Steps 1–10) Frontend route: /private-servers Backend 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_admin sees only its portal_publications; super_admin sees 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

{ "state": "confirmed|suspected|false_positive|unknown",
  "reason": "可选自由文本" }

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}.

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/.

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_idactor.id AND actor.metadata.linked_private_server_idsps.id (jsonb array dedup-on-add).

Scan task queue

POST /api/v1/private-servers/scan (super_admin only).

{ "game": "MIR4",
  "keywords": ["传奇4 私服","mir4 sf"],
  "notes": "demo 前预热" }

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]):

score >= 70  → confirmed
score 40-69  → suspected
score 20-39  → unknown
score <  20  → false_positive

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_at events where source_type=private_server (i.e. "what showed up for me this week").
  • super_admin: counts synced_at on 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 = replace ClassifyPrivateServer body 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 queued until 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. The LinkedEntities "证据链" 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), replace BuildPrivateServerAISummary'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.