侵权威胁模块 · Threats Module¶
Status: shipped (Phase 4, Steps 1–10) Frontend route:
/threatsBackend prefix:/api/v1/threats
10-step upgrade modelled on the legal-document module. Each step ships independently, end-to-end (backend + UI), and is independently verifiable.
1. Module purpose¶
A single workspace for game-IP owners to triage, evidence, and act on infringement signals flowing into the portal:
- Detect → Triage → Evidence → Action → Escalate → Close (6-stage pipeline)
- One-click pivots: 调取证据链 / 转法务起草 / 升级为私服 · 关联运营者
- AI summary + live activity feed for situational awareness
- Bulk close / restore / CSV export
- Multi-tenant:
tenant_adminsees only itsportal_publications;super_adminsees the global pool.
2. Step ledger (what shipped, in order)¶
| Step | Scope | Deliverable |
|---|---|---|
| 0 | Shared primitives | lib/tone.ts + lib/format.ts + 5 components/ui building blocks (ModalShell, KpiTile, …) |
| 1 | Hero rewrite | 6 KpiTile cards + 24h live banner + 7-day MiniSparkline (uses prev_7d for delta colour) |
| 2 | Pipeline | ThreatPipeline.tsx — 6 stage cards with flow connectors; clicking a stage filters the list |
| 3 | Filter bar | MultiSelectPopover + DateRangePopover + SavedQueriesDropdown (localStorage); URL sync |
| 4 | Bulk operations | Sticky toolbar (selected count, 批量关闭, 批量恢复, 导出 CSV); role-based routing via POST /threats/bulk |
| 5 | Detail drawer | ThreatDetailDrawer.tsx — KPIs + metadata + 3 action buttons; replaces old preview modal |
| 6 | Evidence sub-modal | ThreatEvidenceModal.tsx + /threats/:id/evidence (+ ZIP bundle with manifest.json + SHA-256) |
| 7 | Legal handoff sub-modal | ThreatToLegalModal.tsx + POST /legal-docs/draft (transactional: doc + publication) |
| 8 | Promote sub-modal | ThreatPromoteModal.tsx + POST /threats/:id/promote (private_server OR actor; jsonb stamping) |
| 9 | AI summary + activity feed | ThreatAISummary.tsx + ThreatActivityFeed.tsx + GET /threats/ai-summary + /threats/activity |
| 10 | Verification + docs | Route-registration test + this reference doc |
3. API reference¶
List & filter¶
GET /api/v1/threats
| Query | Type | Notes |
|---|---|---|
search |
string | ILIKE across title / content_preview / author |
platform |
string | exact match |
stage |
enum | detected | triaged | evidenced | actioned | escalated | closed |
risk_level |
csv | critical,high,medium,low |
threat_type |
csv | |
platforms |
csv | |
games |
csv | |
date_from / date_to |
RFC3339 | |
page / page_size |
int | default 1 / 20 |
Response: { items: PortalThreat[], total, page, page_size }
Stats¶
GET /api/v1/threats/stats — powers Hero, Pipeline, Filter facets
{
"total": 22918,
"by_type": [{"threat_type": "private_server", "count": 8112}, ...],
"by_risk": [{"risk_level": "high", "count": 4520}, ...],
"by_stage": {"detected": 12, "triaged": 4, ...}, // always all 6 keys
"by_platform": [{"value": "telegram", "count": 5403}, ...],
"by_game": [{"value": "MIR4", "count": 4112}, ...],
"new_24h": 137, "new_7d": 812, "prev_7d": 691,
"sparkline_7d": [82, 90, 110, 132, 95, 121, 137] // oldest → newest
}
Time bucketing: tenant scope → portal_publications.published_at ·
super_admin scope → portal_threats.synced_at.
Bulk¶
POST /api/v1/threats/bulk — role-aware
// body
{
"ids": ["uuid", "..."],
"action": "close" | "restore" | "set_status",
"status": "closed_resolved" // only with set_status
}
// response
{ "rows_affected": 3 }
tenant_adminclose/restore → mutatesportal_publications.revoked_atsuper_adminclose/restore → mutatesportal_threats.status
Evidence¶
| Method | Path | Returns |
|---|---|---|
| GET | /threats/:id/evidence |
{ items: ThreatEvidenceItem[], total, anchored, tsa } |
| GET | /threats/:id/evidence-bundle |
ZIP — threat.json + evidence-items.json + README.md + manifest.json (SHA-256 chain) |
Author-bridge: items merge a synthetic "self" snapshot of the threat with every portal_evidence_chain whose actor.name matches threat.author (case-insensitive).
Legal handoff (Step 7)¶
POST /api/v1/legal-docs/draft — transactional: portal_legal_docs row + portal_publications row
// body
{
"from_threat_id": "uuid",
"document_type": "dmca_takedown" | "cease_desist" | "preservation_letter" | "trademark_notice" | "infringement_notice",
"target_url": "https://...",
"recipient": "Telegram Trust & Safety <[email protected]>",
"language": "zh" | "en",
"evidence_ids": ["..."], // informational, embedded in body
"instructions": "free-form additional context"
}
// response
{ "doc": PortalLegalDoc, "publication_id": "uuid"|null }
Smart defaults if a field is empty:
document_type←templateForThreatType(threat.threat_type)recipient←platformRecipient(threat.platform)(10+ platforms mapped)title←[{game}] {label} · {threat.title}content← templated body (中/英文 stub with【AI 起草占位】line)
Promote (Step 8)¶
POST /api/v1/threats/:id/promote
// body — mode determines which fields are used
{
"mode": "private_server" | "actor",
// private_server fields
"server_name": "...",
"domain": "...", // default: extracted from threat.source_url
"ip_address": "...",
"geo_country": "CN",
"risk_level": "high",
"notes": "...",
// actor fields
"actor_id": "uuid", // when set: link to existing
"new_actor_name": "...", // when actor_id is empty: create
"actor_aliases": ["..."],
"profile_summary": "..."
}
// response
{
"mode": "private_server",
"private_server": PortalPrivateServer | null,
"actor": PortalActor | null,
"publication_id": "uuid" | null
}
Side effects:
- New
portal_private_serversrow OR new/linkedportal_actorsrow portal_publicationsrow (skipped for super_admin to allow intel-dispatch routing)portal_threats.metadata←jsonb_setwithpromoted_to_*_id,promoted_at,promoted_mode
GET /api/v1/threats/:id/promote-candidates — drives "link existing operator" autocomplete; returns up to 10 actors whose name / aliases match threat.author (ILIKE).
Activity feed & AI summary (Step 9)¶
GET /api/v1/threats/activity?limit=30
{ "items": [{
"id": "threat:uuid" | "legal:uuid" | "promote:uuid",
"kind": "threat_new" | "legal_draft" | "promotion" | "actor_link",
"title": "...",
"detail": "...",
"tone": "info" | "warning" | "review" | "ok" | "danger",
"timestamp": "RFC3339",
"source_id": "...",
"source_type":"threat" | "legal_doc"
}] }
3 SQL queries unioned + merged in Go: threats (recent), legal drafts (recent), threats with metadata ? 'promoted_at'.
GET /api/v1/threats/ai-summary
{
"generated_at": "RFC3339",
"model": "stats-derived/v1",
"headline": "本期共监测到 22918 条侵权威胁,过去 24h 新增 137 条,整体 7 日趋势上行。",
"paragraphs": ["重点风险面:telegram 平台...", "对比上一个 7 天周期..."],
"insights": ["Top 2 平台:telegram (5403) / youtube (3120)", ...],
"suggestions": ["批量勾选高风险威胁 → 批量「转法务起草」", ...]
}
Currently model: stats-derived/v1 — pure templating over ThreatStats. Hot-swap to DGX vLLM or dashscope by replacing the body of BuildThreatAISummary; payload shape is forward-compatible.
4. Frontend component tree¶
src/app/(portal)/threats/page.tsx ← entrypoint
├── Hero (6 KpiTile, 24h live banner, MiniSparkline)
├── AI summary + Activity feed ← Step 9
│ ├── components/viz/ThreatAISummary.tsx
│ └── components/viz/ThreatActivityFeed.tsx
├── ThreatPipeline ← Step 2
│ └── components/viz/ThreatPipeline.tsx
├── FilterBar ← Step 3
│ ├── components/ui/MultiSelectPopover.tsx
│ ├── components/ui/DateRangePopover.tsx
│ └── components/ui/SavedQueriesDropdown.tsx
├── BulkToolbar (sticky) ← Step 4
└── ThreatList → row click → ThreatDetailDrawer ← Step 5
└── 3 action buttons:
├── 调取证据链 → ThreatEvidenceModal ← Step 6
├── 转法务起草 → ThreatToLegalModal ← Step 7
└── 升级 / 关联 → ThreatPromoteModal ← Step 8
Shared building blocks (Step 0): ModalShell, KpiTile, DetailRow,
FormGroup, TimelineRow, MiniSparkline. All consume the 7-tone
palette in lib/tone.ts (info / warning / review / accent /
ok / complete / danger).
5. Verification¶
Static (CI-ready):
go build ./... # backend
go vet ./... # static analysis
go test ./internal/handler/ -run TestPortalRoutesRegister # route panic check
(cd frontend && npx tsc --noEmit)
(cd frontend && npx eslint 'src/**/*.{ts,tsx}')
(cd frontend && npm run build) # 28 static pages
End-to-end smoke against the live deployment (ai-astraweb · portal.astraeye.ai):
TOKEN=...
BASE=https://portal.astraeye.ai/api/v1
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/threats/stats" | jq .
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/threats/activity?limit=10" | jq '.items | length'
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/threats/ai-summary" | jq '.headline'
THREAT=$(curl -s -H "Authorization: Bearer $TOKEN" "$BASE/threats?page_size=1" | jq -r '.items[0].id')
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/threats/$THREAT/evidence" | jq '.total'
curl -s -H "Authorization: Bearer $TOKEN" "$BASE/threats/$THREAT/promote-candidates" | jq '.items | length'
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d "{\"from_threat_id\":\"$THREAT\",\"document_type\":\"dmca_takedown\",\"language\":\"zh\"}" \
"$BASE/legal-docs/draft" | jq '.doc.id'
6. Known limits & next iterations¶
- AI summary is templated — real LLM swap is a one-function rewrite
(
BuildThreatAISummaryininternal/repository/portal_repo.go). - Evidence picker in legal modal records selected IDs but doesn't yet splice individual evidence references into the letter body.
- Promote → actor mode marks the threat's metadata but doesn't backfill
portal_evidence_chains.actor_id— author-bridge is still the pre-existing soft link. - No retry / undo on bulk close beyond the existing restore button.