"""钓鱼样本库 + 找茬场景库 service — overview, admin list/status, AI gen. Same shape as the trivia service: a curated seed ships in the migration; admins list / archive / AI-generate to grow the banks. AI generation reuses the org LLM connector and is a graceful no-op without a key. """ from __future__ import annotations import logging import re import uuid from typing import Any from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.core.exceptions import AppException, NotFoundError from app.domains.arcade.content_models import ( SOURCE_AI, STATUS_ACTIVE, STATUS_ARCHIVED, ArcadePhishingSample, ArcadeSpotScene, ) from app.identity.person_models import IdentityPerson logger = logging.getLogger(__name__) # Only these statuses are recognised by the game (build picks STATUS_ACTIVE) # and the list filters. Whitelist admin mutations so a typo can't silently # hide a sample/scene under an unqueryable status. _VALID_STATUSES = frozenset({STATUS_ACTIVE, STATUS_ARCHIVED}) def _require_valid_status(status: str) -> str: status = (status or "").strip() if status not in _VALID_STATUSES: raise AppException( code="ARCADE_CONTENT_BAD", message="status must be one of: active, archived.", status_code=422, ) return status async def bank_overview(db: AsyncSession) -> dict[str, Any]: phish = int((await db.scalar( select(func.count()).select_from(ArcadePhishingSample) .where(ArcadePhishingSample.status == STATUS_ACTIVE) )) or 0) phish_true = int((await db.scalar( select(func.count()).select_from(ArcadePhishingSample) .where(ArcadePhishingSample.status == STATUS_ACTIVE, ArcadePhishingSample.is_phishing.is_(True)) )) or 0) scenes = int((await db.scalar( select(func.count()).select_from(ArcadeSpotScene) .where(ArcadeSpotScene.status == STATUS_ACTIVE) )) or 0) return { "phishing_active": phish, "phishing_true_count": phish_true, "phishing_legit_count": phish - phish_true, "scenes_active": scenes, } # ── phishing ───────────────────────────────────────────────────────── # Any http(s) URL host in an AI-generated phishing body is rewritten to an # RFC-2606 reserved, non-resolvable placeholder before storing — so even if # the model hallucinates a real domain, the "suspicious link" shown in the # game can never point at a live host. Sender addresses are left intact (the # domain IS the teaching tell). The body renders as inert text (no # linkification) either way; this is defense in depth for content-safety. _URL_RE = re.compile(r"(https?://)([^\s/]+)", re.IGNORECASE) _SAFE_PHISH_HOST = "secure-login.example" def _neutralize_body_urls(body: str) -> str: return _URL_RE.sub(lambda m: f"{m.group(1)}{_SAFE_PHISH_HOST}", body or "") def serialize_phishing(s: ArcadePhishingSample) -> dict[str, Any]: return { "id": str(s.id), "sender_name": s.sender_name, "sender_email": s.sender_email, "subject": s.subject, "body": s.body, "is_phishing": s.is_phishing, "flags": s.flags or [], "explanation": s.explanation, "status": s.status, "source": s.source, "created_at": s.created_at.isoformat() if s.created_at else None, } async def list_phishing(db: AsyncSession, *, status: str | None = None) -> list[dict[str, Any]]: stmt = select(ArcadePhishingSample).order_by(ArcadePhishingSample.created_at.desc()) if status: stmt = stmt.where(ArcadePhishingSample.status == status) rows = (await db.execute(stmt.limit(500))).scalars().all() return [serialize_phishing(s) for s in rows] async def set_phishing_status(db: AsyncSession, sample_id: uuid.UUID, status: str) -> dict[str, Any]: status = _require_valid_status(status) s = await db.get(ArcadePhishingSample, sample_id) if s is None: raise NotFoundError("arcade_phishing_sample", str(sample_id)) s.status = status await db.flush() await db.commit() await db.refresh(s) return serialize_phishing(s) async def generate_ai_phishing( db: AsyncSession, *, actor: IdentityPerson, count: int = 6, ) -> dict[str, Any]: count = max(1, min(count, 12)) from app.domains.support.copilot import _resolve_llm cfg = await _resolve_llm(db) if cfg is None: return {"generated": 0, "reason": "llm_not_configured"} if cfg.provider != "bailian": return {"generated": 0, "reason": "llm_not_bailian"} system = ( "你是企业安全意识培训的钓鱼邮件出题专家。请生成简体中文的邮件样本," "用于「钓鱼邮件鉴别」小游戏。一半是钓鱼邮件、一半是正常邮件。钓鱼邮件" "要体现真实的钓鱼特征(仿冒域名、紧迫话术、诱导点链接/运行附件、索要凭据" "或礼品卡等),但**务必使用明显虚构的域名和链接**(如 example-verify.com)," "绝不使用任何真实可访问的恶意链接。正常邮件应贴近日常办公。" ) user = ( f"请生成 {count} 封邮件。严格输出 JSON:" '{"emails":[{"sender_name":"...","sender_email":"...","subject":"...",' '"body":"...","is_phishing":true,"flags":["...",".."],"explanation":"..."}]}' ) try: from app.domains.bailian.llm_client import BailianLLMClient client = BailianLLMClient(api_key=cfg.key, model=cfg.model, region=cfg.region, timeout=30.0, purpose="arcade.content") payload = await client.chat_json(system=system, user=user, max_tokens=4000, temperature=0.8) except Exception as exc: # noqa: BLE001 logger.warning("arcade phishing AI generation failed: %s", exc) return {"generated": 0, "reason": "llm_error"} items = payload.get("emails") if isinstance(payload, dict) else None if not isinstance(items, list): return {"generated": 0, "reason": "bad_response"} generated = 0 for it in items: if not isinstance(it, dict): continue subject = (it.get("subject") or "").strip() body = (it.get("body") or "").strip() sender_email = (it.get("sender_email") or "").strip() if not subject or not body or not sender_email: continue flags = it.get("flags") db.add(ArcadePhishingSample( id=uuid.uuid4(), sender_name=(it.get("sender_name") or "").strip()[:128] or "—", sender_email=sender_email[:256], subject=subject[:256], body=_neutralize_body_urls(body), is_phishing=bool(it.get("is_phishing")), flags=flags if isinstance(flags, list) else [], explanation=(it.get("explanation") or None), status=STATUS_ACTIVE, source=SOURCE_AI, created_by=actor.id, )) generated += 1 if generated: await db.commit() return {"generated": generated, "reason": "ok" if generated else "no_valid_items"} # ── spot-the-risk scenes ───────────────────────────────────────────── def serialize_scene(s: ArcadeSpotScene) -> dict[str, Any]: return { "id": str(s.id), "title": s.title, "elements": s.elements, "status": s.status, "source": s.source, "created_at": s.created_at.isoformat() if s.created_at else None, } async def list_scenes(db: AsyncSession, *, status: str | None = None) -> list[dict[str, Any]]: stmt = select(ArcadeSpotScene).order_by(ArcadeSpotScene.created_at.desc()) if status: stmt = stmt.where(ArcadeSpotScene.status == status) rows = (await db.execute(stmt.limit(200))).scalars().all() return [serialize_scene(s) for s in rows] async def set_scene_status(db: AsyncSession, scene_id: uuid.UUID, status: str) -> dict[str, Any]: status = _require_valid_status(status) s = await db.get(ArcadeSpotScene, scene_id) if s is None: raise NotFoundError("arcade_spot_scene", str(scene_id)) s.status = status await db.flush() await db.commit() await db.refresh(s) return serialize_scene(s) def _validate_scene_elements(elements: Any) -> list[dict]: if not isinstance(elements, list) or len(elements) < 3: raise AppException(code="ARCADE_SCENE_BAD", message="At least 3 elements required.", status_code=422) out = [] keys = set() hazards = 0 for e in elements: if not isinstance(e, dict): continue key = str(e.get("key") or "").strip() label = str(e.get("label") or "").strip() if not key or not label or key in keys: continue keys.add(key) hazard = bool(e.get("hazard")) hazards += 1 if hazard else 0 out.append({"key": key, "label": label, "hazard": hazard, "reason": (e.get("reason") or None)}) if len(out) < 3 or hazards == 0: raise AppException( code="ARCADE_SCENE_BAD", message="Scene needs >=3 valid elements and at least one hazard.", status_code=422, ) return out async def create_scene( db: AsyncSession, *, actor: IdentityPerson, title: str, elements: Any, ) -> dict[str, Any]: if not title or not title.strip(): raise AppException(code="ARCADE_SCENE_BAD", message="Title required.", status_code=422) validated = _validate_scene_elements(elements) s = ArcadeSpotScene( id=uuid.uuid4(), title=title.strip()[:128], elements=validated, status=STATUS_ACTIVE, source="manual", created_by=actor.id, ) db.add(s) await db.flush() await db.commit() await db.refresh(s) return serialize_scene(s) __all__ = [ "bank_overview", "list_phishing", "set_phishing_status", "generate_ai_phishing", "serialize_phishing", "list_scenes", "set_scene_status", "create_scene", "serialize_scene", ]