"""Configuration: severity/reward ladder, reply templates, FY budget, reply signature. Everything here is operator-editable from the console's 配置 tab. Two invariants the callers rely on: * **A tier key never disappears.** Builtin tiers cannot be deleted and custom ones only when no report references them, so ``severity`` on a historical report always resolves to a label. * **Budget resolution never yields zero by accident.** A fiscal year with no explicit entry inherits ``default_usd``; only an explicit ``by_fy`` entry of 0 means "no budget this year". """ from __future__ import annotations import uuid from datetime import UTC, datetime from decimal import Decimal, DecimalException from typing import Any from sqlalchemy import func, select from sqlalchemy.dialects.mysql import insert as mysql_insert from sqlalchemy.ext.asyncio import AsyncSession from app.core import fiscal_year as fy from app.core.exceptions import AppException, NotFoundError, ValidationError from app.domains.bounty import constants as const from app.domains.bounty.catalog import ( DEFAULT_SIGNATURE, REPLY_TEMPLATES, SEVERITY_TIERS, ) from app.domains.bounty.models import ( BountyReplyTemplate, BountyReport, BountySeverityTier, ) from app.domains.bounty.rendering import declared_tokens from app.integrations.models import PlatformSetting def _now() -> datetime: return datetime.now(UTC) # ════════════════════════════════════════════════════════════════════ # Platform settings # ════════════════════════════════════════════════════════════════════ async def _get_setting(db: AsyncSession, key: str) -> dict[str, Any]: row = await db.get(PlatformSetting, key) value = row.value if row else None return value if isinstance(value, dict) else {} async def _put_setting( db: AsyncSession, key: str, value: dict[str, Any], *, actor_id: uuid.UUID | None, description: str | None = None, ) -> None: row = await db.get(PlatformSetting, key) if row is None: row = PlatformSetting(key=key, value=value, description=description) db.add(row) else: row.value = value if description and not row.description: row.description = description row.updated_by = actor_id await db.flush() async def get_signature(db: AsyncSession) -> dict[str, Any]: """Signature/entity block merged over the shipped defaults, so a partially-filled setting can never blank out the company name.""" return {**DEFAULT_SIGNATURE, **await _get_setting(db, const.SETTING_SIGNATURE)} async def update_signature( db: AsyncSession, payload: dict[str, Any], *, actor_id: uuid.UUID | None, ) -> dict[str, Any]: allowed = set(DEFAULT_SIGNATURE.keys()) unknown = sorted(set(payload) - allowed) if unknown: raise ValidationError( f"unknown signature field(s): {', '.join(unknown)}", ) current = await _get_setting(db, const.SETTING_SIGNATURE) merged = {**current} for key, value in payload.items(): merged[key] = "" if value is None else str(value).strip() await _put_setting( db, const.SETTING_SIGNATURE, merged, actor_id=actor_id, description="白帽子回信的签名与法人主体信息,渲染邮件模板时自动带入。", ) return {**DEFAULT_SIGNATURE, **merged} async def get_alert_recipient_ids(db: AsyncSession) -> list[uuid.UUID]: """Explicitly configured alert recipients, or ``[]`` for "use the default". ``[]`` deliberately means "the role default" rather than "nobody": the desk has to be reachable on a fresh install, and a config surface whose empty state silences the channel is how a queue goes unwatched. Muting is done by disabling the poll job, which is visible on the jobs page. """ raw = (await _get_setting(db, const.SETTING_ALERT_RECIPIENTS)).get( "person_ids", ) if not isinstance(raw, list): return [] out: list[uuid.UUID] = [] for item in raw: try: parsed = uuid.UUID(str(item)) except (ValueError, AttributeError, TypeError): # A malformed id must not take the whole recipient list down — # the rest of the desk still needs the alert. continue if parsed not in out: out.append(parsed) return out async def update_alert_recipients( db: AsyncSession, person_ids: list[Any], *, actor_id: uuid.UUID | None, ) -> list[uuid.UUID]: """Replace the recipient list. An empty list restores the role default.""" if len(person_ids) > 50: raise ValidationError( "告警收件人最多 50 人 —— 再多说明应该用角色而不是点名。", ) cleaned: list[uuid.UUID] = [] for item in person_ids: try: parsed = uuid.UUID(str(item)) except (ValueError, AttributeError, TypeError): raise ValidationError(f"不是合法的人员 id: {item!r}") from None if parsed not in cleaned: cleaned.append(parsed) await _put_setting( db, const.SETTING_ALERT_RECIPIENTS, {"person_ids": [str(x) for x in cleaned]}, actor_id=actor_id, description=( "漏洞赏金台的告警收件人(新来信 / 白帽子催办 / 业务负责人回执)。" "留空 = 全部在职安全员。" ), ) return cleaned def _coerce_track(raw: Any) -> dict[str, Any]: """One track's ``{default_usd, by_fy}``, junk-tolerant. A hand-edited setting with a bad year or amount must not take the whole budget widget down — the bad entry is skipped, the rest stands. """ raw = raw if isinstance(raw, dict) else {} try: default_usd = Decimal(str(raw.get("default_usd", const.DEFAULT_BUDGET_USD))) except (DecimalException, TypeError, ValueError): default_usd = Decimal(const.DEFAULT_BUDGET_USD) by_fy: dict[int, Decimal] = {} source = raw.get("by_fy") if isinstance(raw.get("by_fy"), dict) else {} for key, value in (source or {}).items(): try: by_fy[int(key)] = Decimal(str(value)) except (DecimalException, TypeError, ValueError): continue return {"default_usd": default_usd, "by_fy": by_fy} async def get_budget_config(db: AsyncSession) -> dict[str, Any]: """Per-track budget config. Reads the pre-2026-08-24 flat shape (``default_usd`` / ``by_fy`` at the top level) as the ``product`` track, so an environment whose migration has not run yet keeps its configured ceiling instead of silently reverting to the shipped default. """ raw = await _get_setting(db, const.SETTING_BUDGET) tracks_raw = raw.get("tracks") if isinstance(raw.get("tracks"), dict) else None tracks: dict[str, dict[str, Any]] = {} for track in const.BUDGET_TRACKS: if tracks_raw is not None: tracks[track] = _coerce_track(tracks_raw.get(track)) elif track == const.BUDGET_TRACK_PRODUCT: tracks[track] = _coerce_track(raw) else: tracks[track] = _coerce_track(None) return { "tracks": tracks, "currency": str(raw.get("currency") or const.DEFAULT_CURRENCY), } async def update_budget_config( db: AsyncSession, *, tracks: dict[str, dict[str, Any]], actor_id: uuid.UUID | None, ) -> dict[str, Any]: """Patch one or both tracks. An absent track keeps what it had — editing the IT ceiling must not blank the product one.""" current = await get_budget_config(db) merged: dict[str, dict[str, Any]] = {} for track in const.BUDGET_TRACKS: existing = current["tracks"][track] patch = tracks.get(track) or {} default_usd = patch.get("default_usd", existing["default_usd"]) by_fy = patch.get("by_fy", existing["by_fy"]) if Decimal(str(default_usd)) < 0: raise ValidationError( f"budget for track '{track}' must not be negative", ) for year, amount in (by_fy or {}).items(): if Decimal(str(amount)) < 0: raise ValidationError( f"budget for track '{track}' FY{year} must not be negative", ) merged[track] = { "default_usd": Decimal(str(default_usd)), "by_fy": {int(y): Decimal(str(a)) for y, a in (by_fy or {}).items()}, } unknown = sorted(set(tracks) - set(const.BUDGET_TRACKS)) if unknown: raise ValidationError(f"unknown budget track(s): {', '.join(unknown)}") await _put_setting( db, const.SETTING_BUDGET, { "tracks": { track: { "default_usd": float(values["default_usd"]), "by_fy": { str(y): float(a) for y, a in sorted(values["by_fy"].items()) }, } for track, values in merged.items() }, "currency": const.DEFAULT_CURRENCY, }, actor_id=actor_id, description=( "漏洞赏金财年预算(USD),按预算块分开:product=产研,it=IT。" "每块可按财年覆盖 default_usd。" ), ) return await get_budget_config(db) async def budget_for_fy( db: AsyncSession, fiscal_year: int, *, track: str | None = None, ) -> Decimal: """One track's ceiling for a fiscal year, or the sum of both when no track is named.""" cfg = await get_budget_config(db) wanted = const.BUDGET_TRACKS if track is None else (track,) total = Decimal("0") for name in wanted: values = cfg["tracks"].get(name) if values is None: continue by_fy: dict[int, Decimal] = values["by_fy"] total += ( by_fy[fiscal_year] if fiscal_year in by_fy else Decimal(str(values["default_usd"])) ) return total async def current_fy(db: AsyncSession, *, now: datetime | None = None) -> int: """The fiscal year a reward decided *now* is charged to.""" start_month = await fy.get_fiscal_year_start_month(db) return fy.current_fiscal_year(start_month, now) async def fy_label(db: AsyncSession, fiscal_year: int) -> str: start_month = await fy.get_fiscal_year_start_month(db) return fy.fy_label(fiscal_year, start_month) # ════════════════════════════════════════════════════════════════════ # Self-healing seed # ════════════════════════════════════════════════════════════════════ # # Migration 533 seeds the ladder and the template library, so a # migrated database always has them. These guards cover the states a # migration cannot: a schema created straight from the ORM metadata # (tests, a scratch environment), and an operator who deleted every # custom tier/template and left the table empty. An empty ladder means # the console cannot triage anything at all, so "heal it" beats "show an # empty dropdown". # # They fire ONLY on a completely empty table — never to re-add a row an # operator deliberately deleted while others remain — and use # ``ON DUPLICATE KEY UPDATE`` on the natural key so two concurrent # requests racing the same empty table cannot raise a 1062 (which would # poison the request's transaction). async def _seed_tiers_if_empty(db: AsyncSession) -> bool: exists = ( await db.execute(select(BountySeverityTier.id).limit(1)) ).scalar_one_or_none() if exists is not None: return False rows = [ { "id": uuid.uuid4(), "key": seed["key"], "label_zh": seed["label_zh"], "label_en": seed["label_en"], "label_ja": seed["label_ja"], "default_reward_usd": Decimal(str(seed["default_reward_usd"])), "max_reward_usd": ( None if seed["max_reward_usd"] is None else Decimal(str(seed["max_reward_usd"])) ), "fix_sla_days": seed["fix_sla_days"], "color": seed["color"], "sort_order": seed["sort_order"], "enabled": True, "is_builtin": True, } for seed in SEVERITY_TIERS ] stmt = mysql_insert(BountySeverityTier).values(rows) await db.execute( stmt.on_duplicate_key_update(key=stmt.inserted.key), ) await db.flush() return True async def _seed_templates_if_empty(db: AsyncSession) -> bool: exists = ( await db.execute(select(BountyReplyTemplate.id).limit(1)) ).scalar_one_or_none() if exists is not None: return False rows = [ { "id": uuid.uuid4(), "key": seed["key"], "name_zh": seed["name_zh"], "name_en": seed["name_en"], "name_ja": seed["name_ja"], "category": seed["category"], "subject": seed["subject"], "body": seed["body"], "variables": list(seed["variables"]), "sort_order": seed["sort_order"], "enabled": True, "is_builtin": True, "use_count": 0, } for seed in REPLY_TEMPLATES ] stmt = mysql_insert(BountyReplyTemplate).values(rows) await db.execute( stmt.on_duplicate_key_update(key=stmt.inserted.key), ) await db.flush() return True # ════════════════════════════════════════════════════════════════════ # Severity tiers # ════════════════════════════════════════════════════════════════════ async def list_tiers( db: AsyncSession, *, include_disabled: bool = True, ) -> list[BountySeverityTier]: await _seed_tiers_if_empty(db) stmt = select(BountySeverityTier).order_by( BountySeverityTier.sort_order, BountySeverityTier.key, ) if not include_disabled: stmt = stmt.where(BountySeverityTier.enabled.is_(True)) return list((await db.execute(stmt)).scalars().all()) async def tier_map(db: AsyncSession) -> dict[str, BountySeverityTier]: return {t.key: t for t in await list_tiers(db)} async def get_tier(db: AsyncSession, key: str) -> BountySeverityTier: await _seed_tiers_if_empty(db) row = ( await db.execute( select(BountySeverityTier).where(BountySeverityTier.key == key), ) ).scalar_one_or_none() if row is None: raise NotFoundError(f"severity tier '{key}' not found") return row async def create_tier( db: AsyncSession, *, key: str, label_zh: str, label_en: str, label_ja: str, default_reward_usd: Decimal, max_reward_usd: Decimal | None, fix_sla_days: int | None, color: str | None, sort_order: int, actor_id: uuid.UUID | None, ) -> BountySeverityTier: key = (key or "").strip().lower() if not key or not key.replace("_", "").isalnum(): raise ValidationError( "tier key must be a non-empty alphanumeric/underscore slug", ) existing = ( await db.execute( select(BountySeverityTier.id).where(BountySeverityTier.key == key), ) ).scalar_one_or_none() if existing is not None: raise AppException( code="BOUNTY_TIER_EXISTS", message=f"severity tier '{key}' already exists", status_code=409, ) _validate_tier_amounts(default_reward_usd, max_reward_usd) row = BountySeverityTier( key=key, label_zh=label_zh.strip(), label_en=label_en.strip(), label_ja=label_ja.strip(), default_reward_usd=default_reward_usd, max_reward_usd=max_reward_usd, fix_sla_days=fix_sla_days, color=(color or None), sort_order=sort_order, enabled=True, is_builtin=False, updated_by=actor_id, ) db.add(row) await db.flush() return row async def update_tier( db: AsyncSession, key: str, *, patch: dict[str, Any], actor_id: uuid.UUID | None, ) -> BountySeverityTier: row = await get_tier(db, key) for field_name in ( "label_zh", "label_en", "label_ja", "color", "sort_order", "fix_sla_days", "enabled", ): if field_name in patch: setattr(row, field_name, patch[field_name]) if "default_reward_usd" in patch or "max_reward_usd" in patch: default_reward = patch.get("default_reward_usd", row.default_reward_usd) max_reward = patch.get("max_reward_usd", row.max_reward_usd) _validate_tier_amounts(Decimal(str(default_reward)), None if max_reward is None else Decimal(str(max_reward))) row.default_reward_usd = default_reward row.max_reward_usd = max_reward row.updated_by = actor_id await db.flush() return row def _validate_tier_amounts( default_reward: Decimal, max_reward: Decimal | None, ) -> None: if default_reward < 0: raise ValidationError("default reward must not be negative") if default_reward > const.MAX_REWARD_USD: raise ValidationError( f"default reward exceeds the {const.MAX_REWARD_USD} USD sanity cap", ) if max_reward is not None: if max_reward < 0: raise ValidationError("max reward must not be negative") if max_reward < default_reward: raise ValidationError( "max reward must be greater than or equal to the default reward", ) if max_reward > const.MAX_REWARD_USD: raise ValidationError( f"max reward exceeds the {const.MAX_REWARD_USD} USD sanity cap", ) async def delete_tier(db: AsyncSession, key: str) -> None: """Delete a custom, unreferenced tier. Builtins are never deletable (disable them instead) and a tier in use would orphan the ``severity`` value on those reports.""" row = await get_tier(db, key) if row.is_builtin: raise AppException( code="BOUNTY_TIER_BUILTIN", message=( "内置级别不可删除,可改金额或停用(停用后不再出现在定级选项中," "历史报告仍能正确显示)。" ), status_code=409, ) in_use = ( await db.execute( select(func.count()) .select_from(BountyReport) .where(BountyReport.severity == key), ) ).scalar_one() if in_use: raise AppException( code="BOUNTY_TIER_IN_USE", message=( f"该级别已被 {in_use} 条报告使用,不能删除;请改为停用。" ), status_code=409, ) await db.delete(row) await db.flush() async def restore_builtin_tiers( db: AsyncSession, *, actor_id: uuid.UUID | None, ) -> int: """Reset builtin tiers to the shipped catalogue values. Custom tiers are untouched. Returns the number of rows restored.""" by_key = {t.key: t for t in await list_tiers(db)} restored = 0 for seed in SEVERITY_TIERS: row = by_key.get(seed["key"]) if row is None: row = BountySeverityTier(key=seed["key"], is_builtin=True) db.add(row) row.label_zh = seed["label_zh"] row.label_en = seed["label_en"] row.label_ja = seed["label_ja"] row.default_reward_usd = Decimal(str(seed["default_reward_usd"])) row.max_reward_usd = ( None if seed["max_reward_usd"] is None else Decimal(str(seed["max_reward_usd"])) ) row.fix_sla_days = seed["fix_sla_days"] row.color = seed["color"] row.sort_order = seed["sort_order"] row.enabled = True row.is_builtin = True row.updated_by = actor_id restored += 1 await db.flush() return restored # ════════════════════════════════════════════════════════════════════ # Reply templates # ════════════════════════════════════════════════════════════════════ async def list_templates( db: AsyncSession, *, include_disabled: bool = True, ) -> list[BountyReplyTemplate]: await _seed_templates_if_empty(db) stmt = select(BountyReplyTemplate).order_by( BountyReplyTemplate.sort_order, BountyReplyTemplate.key, ) if not include_disabled: stmt = stmt.where(BountyReplyTemplate.enabled.is_(True)) return list((await db.execute(stmt)).scalars().all()) async def get_template(db: AsyncSession, key: str) -> BountyReplyTemplate: await _seed_templates_if_empty(db) row = ( await db.execute( select(BountyReplyTemplate).where(BountyReplyTemplate.key == key), ) ).scalar_one_or_none() if row is None: raise NotFoundError(f"reply template '{key}' not found") return row async def create_template( db: AsyncSession, *, key: str, name_zh: str, name_en: str, name_ja: str, category: str, subject: str, body: str, sort_order: int, actor_id: uuid.UUID | None, ) -> BountyReplyTemplate: key = (key or "").strip().lower() if not key or not key.replace("_", "").isalnum(): raise ValidationError( "template key must be a non-empty alphanumeric/underscore slug", ) if category not in const.REPLY_CATEGORIES: raise ValidationError(f"unknown template category '{category}'") if not (subject or "").strip() or not (body or "").strip(): raise ValidationError("template subject and body are required") existing = ( await db.execute( select(BountyReplyTemplate.id).where(BountyReplyTemplate.key == key), ) ).scalar_one_or_none() if existing is not None: raise AppException( code="BOUNTY_TEMPLATE_EXISTS", message=f"reply template '{key}' already exists", status_code=409, ) row = BountyReplyTemplate( key=key, name_zh=name_zh.strip(), name_en=name_en.strip(), name_ja=name_ja.strip(), category=category, subject=subject.strip(), body=body.strip(), variables=declared_tokens(subject, body), sort_order=sort_order, enabled=True, is_builtin=False, updated_by=actor_id, ) db.add(row) await db.flush() return row async def update_template( db: AsyncSession, key: str, *, patch: dict[str, Any], actor_id: uuid.UUID | None, ) -> BountyReplyTemplate: row = await get_template(db, key) if "category" in patch and patch["category"] not in const.REPLY_CATEGORIES: raise ValidationError(f"unknown template category '{patch['category']}'") for field_name in ( "name_zh", "name_en", "name_ja", "category", "sort_order", "enabled", ): if field_name in patch: setattr(row, field_name, patch[field_name]) if "subject" in patch: if not (patch["subject"] or "").strip(): raise ValidationError("template subject is required") row.subject = patch["subject"].strip() if "body" in patch: if not (patch["body"] or "").strip(): raise ValidationError("template body is required") row.body = patch["body"].strip() # Keep the declared-variables hint in sync with the text that is # actually stored, so the editor's "variables used" chip cannot lie. row.variables = declared_tokens(row.subject, row.body) row.updated_by = actor_id await db.flush() return row async def delete_template(db: AsyncSession, key: str) -> None: row = await get_template(db, key) if row.is_builtin: raise AppException( code="BOUNTY_TEMPLATE_BUILTIN", message="内置模板不可删除,可编辑内容或停用。", status_code=409, ) await db.delete(row) await db.flush() async def restore_builtin_template( db: AsyncSession, key: str, *, actor_id: uuid.UUID | None, ) -> BountyReplyTemplate: """Restore one builtin template's subject/body/name to the shipped catalogue text, discarding operator edits.""" seed = next((t for t in REPLY_TEMPLATES if t["key"] == key), None) if seed is None: raise NotFoundError(f"'{key}' is not a builtin reply template") try: row = await get_template(db, key) except NotFoundError: row = BountyReplyTemplate(key=key, is_builtin=True) db.add(row) row.name_zh = seed["name_zh"] row.name_en = seed["name_en"] row.name_ja = seed["name_ja"] row.category = seed["category"] row.subject = seed["subject"] row.body = seed["body"] row.variables = list(seed["variables"]) row.sort_order = seed["sort_order"] row.enabled = True row.is_builtin = True row.updated_by = actor_id await db.flush() return row async def lowest_enabled_tier(db: AsyncSession) -> BountySeverityTier | None: """The tier a report arrives at when nobody has graded it. Lowest by ``sort_order``, which is the ladder's own ordering (critical=10 … informational=50), not by reward — an operator who re-prices the ladder must not accidentally change which tier is the safe default. Disabled tiers are skipped: a tier somebody switched off is not something to assign automatically. Returns None when the ladder is empty or entirely disabled, and the caller leaves the report ungraded — which is the honest state. """ rows = await list_tiers(db) enabled = [t for t in rows if t.enabled] if not enabled: return None return max(enabled, key=lambda t: t.sort_order)