From ca0ffb8dbe62591eec6fd40b0eab42ab935117b6 Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Mon, 6 Jul 2026 14:57:25 +0800 Subject: [PATCH] feat: implement t-604 keyword prompt moderation --- .env.example | 7 ++ apps/api/generation.py | 10 ++ apps/api/tests.py | 51 +++++++++ apps/moderation/__init__.py | 0 apps/moderation/admin.py | 20 ++++ apps/moderation/apps.py | 10 ++ apps/moderation/migrations/0001_initial.py | 46 ++++++++ apps/moderation/migrations/__init__.py | 1 + apps/moderation/models.py | 51 +++++++++ apps/moderation/normalization.py | 54 ++++++++++ apps/moderation/policy.py | 59 +++++++++++ apps/moderation/providers/__init__.py | 17 +++ apps/moderation/providers/base.py | 49 +++++++++ apps/moderation/providers/keyword.py | 117 +++++++++++++++++++++ apps/moderation/services.py | 94 +++++++++++++++++ apps/moderation/signals.py | 18 ++++ apps/moderation/tests.py | 106 +++++++++++++++++++ apps/moderation/versioning.py | 27 +++++ config/settings.py | 9 ++ docs/06-tasks.md | 2 +- docs/current-state.md | 15 +-- docs/moderation.md | 6 +- progress.md | 27 +++++ requirements.txt | 1 + 24 files changed, 787 insertions(+), 10 deletions(-) create mode 100644 apps/moderation/__init__.py create mode 100644 apps/moderation/admin.py create mode 100644 apps/moderation/apps.py create mode 100644 apps/moderation/migrations/0001_initial.py create mode 100644 apps/moderation/migrations/__init__.py create mode 100644 apps/moderation/models.py create mode 100644 apps/moderation/normalization.py create mode 100644 apps/moderation/policy.py create mode 100644 apps/moderation/providers/__init__.py create mode 100644 apps/moderation/providers/base.py create mode 100644 apps/moderation/providers/keyword.py create mode 100644 apps/moderation/services.py create mode 100644 apps/moderation/signals.py create mode 100644 apps/moderation/tests.py create mode 100644 apps/moderation/versioning.py diff --git a/.env.example b/.env.example index 66f6d9b..0509cfb 100644 --- a/.env.example +++ b/.env.example @@ -43,6 +43,13 @@ IMAGE_URL_CONNECT_TIMEOUT_SECONDS=10 IMAGE_URL_READ_TIMEOUT_SECONDS=60 RECHARGE_MAX_AMOUNT_CNY=100000.00 +# Content moderation +MODERATION_ENABLED=false +MODERATION_PROVIDER=keyword +MODERATION_FAIL_CLOSED=true +MODERATION_BLOCK_ON_REVIEW=false +MODERATION_CACHE_VERSION_KEY=moderation:sensitive_words:version + # Payment callback verification PAYMENT_CALLBACK_MODE=mock PAYMENT_MOCK_CALLBACK_SECRET=change-me-mock-callback-secret diff --git a/apps/api/generation.py b/apps/api/generation.py index 419a79d..bdf3f51 100644 --- a/apps/api/generation.py +++ b/apps/api/generation.py @@ -29,6 +29,8 @@ from apps.billing.services import ( mark_call_success, ) +from apps.moderation.services import moderate_prompt + from .errors import api_error from .storage import save_generated_image @@ -60,6 +62,7 @@ def generate_title_response(*, user, api_key, request_data: Mapping[str, Any]) - alias = request_data.get("model") or None resolution = normalize_resolution(request_data.get("resolution") or "1K") or "1K" parameters = dict(request_data.get("parameters") or {}) + moderate_prompt_or_raise(user=user, api_key=api_key, prompt=prompt) image_input = load_image_input(request_data) model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.TITLE, alias) @@ -134,6 +137,7 @@ def generate_image_response(*, user, api_key, request, request_data: Mapping[str resolution = normalize_resolution(request_data.get("resolution") or "1K") or "1K" aspect_ratio = request_data.get("aspect_ratio") or "1:1" parameters = dict(request_data.get("parameters") or {}) + moderate_prompt_or_raise(user=user, api_key=api_key, prompt=prompt) image_input = load_image_input(request_data) model_alias = resolve_model_alias_or_raise(CallRecord.OperationType.IMAGE, alias) @@ -202,6 +206,12 @@ def generate_image_response(*, user, api_key, request, request_data: Mapping[str } +def moderate_prompt_or_raise(*, user, api_key, prompt: str) -> None: + outcome = moderate_prompt(user=user, api_key=api_key, prompt=prompt) + if outcome.blocked: + raise ApiRequestError("content_blocked", "输入内容未通过安全审核", status.HTTP_400_BAD_REQUEST) + + def resolve_model_alias_or_raise(operation_type: str, alias: str | None): try: return resolve_model_alias(operation_type, alias) diff --git a/apps/api/tests.py b/apps/api/tests.py index 9895e93..405c8e3 100644 --- a/apps/api/tests.py +++ b/apps/api/tests.py @@ -39,6 +39,8 @@ from apps.billing.payment_gateways import ( build_mock_body_signature, ) from apps.billing.services import RechargePayment +from apps.moderation.models import SensitiveWord +from apps.moderation.providers.keyword import reset_keyword_matcher_cache from apps.users.models import ApiKey from apps.users.models import UserWallet @@ -824,9 +826,11 @@ def dns_result(address: str): class GenerateApiTests(TestCase): def setUp(self): cache.clear() + reset_keyword_matcher_cache() suffix = uuid.uuid4().hex[:8] self.media_dir = tempfile.TemporaryDirectory() self.addCleanup(self.media_dir.cleanup) + self.addCleanup(reset_keyword_matcher_cache) media_override = override_settings( MEDIA_ROOT=self.media_dir.name, MEDIA_URL="/media/", @@ -905,6 +909,53 @@ class GenerateApiTests(TestCase): self.assertFalse(CallRecord.objects.filter(user=self.user).exists()) self.assertFalse(PointsLedger.objects.filter(user=self.user).exists()) + @override_settings( + MODERATION_ENABLED=True, + MODERATION_PROVIDER="keyword", + MODERATION_CACHE_VERSION_KEY="test:api:moderation:sensitive_words:version", + ) + def test_blocked_prompt_returns_content_blocked_before_image_download_or_charge(self): + SensitiveWord.objects.create(word="敏感词", category="policy") + + with ( + patch("apps.api.generation.socket.getaddrinfo") as dns_lookup, + patch("apps.api.generation.requests.Session.get") as image_get, + ): + response = self.post_with_provider( + "/api/v1/generate/image", + { + "prompt": "请生成敏-感\u200b 词图片", + "model": self.image_alias, + "image_url": "https://safe.example.com/input.jpg", + "resolution": "1K", + "aspect_ratio": "1:1", + }, + ) + + self.assertEqual(response.status_code, 400) + self.assertEqual(response.data["error"]["code"], "content_blocked") + dns_lookup.assert_not_called() + image_get.assert_not_called() + self.assertEqual(self.provider.image_calls, []) + self.assert_generation_not_charged() + + @override_settings( + MODERATION_ENABLED=False, + MODERATION_PROVIDER="keyword", + MODERATION_CACHE_VERSION_KEY="test:api:moderation:sensitive_words:version", + ) + def test_disabled_moderation_does_not_block_matching_prompt(self): + SensitiveWord.objects.create(word="敏感词") + + response = self.post_with_provider( + "/api/v1/generate/title", + {"prompt": "敏感词", "model": self.title_alias}, + ) + + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data["points_cost"], 2) + self.assertEqual(len(self.provider.text_calls), 1) + def test_generate_title_uses_default_alias_charges_points_and_writes_call_record(self): response = self.post_with_provider( "/api/v1/generate/title", diff --git a/apps/moderation/__init__.py b/apps/moderation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/moderation/admin.py b/apps/moderation/admin.py new file mode 100644 index 0000000..3a2a7e9 --- /dev/null +++ b/apps/moderation/admin.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +from django.contrib import admin + +from .models import SensitiveWord + + +@admin.register(SensitiveWord) +class SensitiveWordAdmin(admin.ModelAdmin): + list_display = ( + "word", + "normalized_word", + "category", + "action", + "is_active", + "updated_at", + ) + list_filter = ("category", "action", "is_active") + search_fields = ("word", "normalized_word", "category") + readonly_fields = ("normalized_word", "created_at", "updated_at") diff --git a/apps/moderation/apps.py b/apps/moderation/apps.py new file mode 100644 index 0000000..5b303d7 --- /dev/null +++ b/apps/moderation/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class ModerationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.moderation" + verbose_name = "内容安全" + + def ready(self) -> None: + from . import signals # noqa: F401 diff --git a/apps/moderation/migrations/0001_initial.py b/apps/moderation/migrations/0001_initial.py new file mode 100644 index 0000000..85327bf --- /dev/null +++ b/apps/moderation/migrations/0001_initial.py @@ -0,0 +1,46 @@ +# Generated by Codex for T-604. + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="SensitiveWord", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("word", models.CharField(max_length=255, verbose_name="敏感词")), + ("normalized_word", models.CharField(editable=False, max_length=255, verbose_name="归一化敏感词")), + ("category", models.CharField(blank=True, default="custom", max_length=64, verbose_name="分类")), + ("action", models.CharField(choices=[("block", "拦截")], default="block", max_length=16, verbose_name="动作")), + ("is_active", models.BooleanField(default=True, verbose_name="启用")), + ("created_at", models.DateTimeField(auto_now_add=True, verbose_name="创建时间")), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")), + ], + options={ + "verbose_name": "敏感词", + "verbose_name_plural": "敏感词", + "db_table": "sensitive_word", + "ordering": ("category", "word"), + }, + ), + migrations.AddIndex( + model_name="sensitiveword", + index=models.Index(fields=["is_active", "category"], name="sw_active_category_idx"), + ), + migrations.AddIndex( + model_name="sensitiveword", + index=models.Index(fields=["normalized_word"], name="sw_normalized_word_idx"), + ), + migrations.AddConstraint( + model_name="sensitiveword", + constraint=models.UniqueConstraint( + fields=("category", "normalized_word"), + name="unique_sensitive_word_per_category", + ), + ), + ] diff --git a/apps/moderation/migrations/__init__.py b/apps/moderation/migrations/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/moderation/migrations/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/moderation/models.py b/apps/moderation/models.py new file mode 100644 index 0000000..b9709a4 --- /dev/null +++ b/apps/moderation/models.py @@ -0,0 +1,51 @@ +from __future__ import annotations + +from django.core.exceptions import ValidationError +from django.db import models + +from .normalization import normalize_text + + +class SensitiveWord(models.Model): + class Action(models.TextChoices): + BLOCK = "block", "拦截" + + word = models.CharField("敏感词", max_length=255) + normalized_word = models.CharField("归一化敏感词", max_length=255, editable=False) + category = models.CharField("分类", max_length=64, default="custom", blank=True) + action = models.CharField("动作", max_length=16, choices=Action.choices, default=Action.BLOCK) + is_active = models.BooleanField("启用", default=True) + created_at = models.DateTimeField("创建时间", auto_now_add=True) + updated_at = models.DateTimeField("更新时间", auto_now=True) + + class Meta: + db_table = "sensitive_word" + verbose_name = "敏感词" + verbose_name_plural = "敏感词" + ordering = ("category", "word") + constraints = [ + models.UniqueConstraint( + fields=("category", "normalized_word"), + name="unique_sensitive_word_per_category", + ), + ] + indexes = [ + models.Index(fields=("is_active", "category"), name="sw_active_category_idx"), + models.Index(fields=("normalized_word",), name="sw_normalized_word_idx"), + ] + + def __str__(self) -> str: + return self.word + + def clean(self) -> None: + self.word = (self.word or "").strip() + self.category = (self.category or "custom").strip() or "custom" + self.normalized_word = normalize_text(self.word) + if not self.normalized_word: + raise ValidationError({"word": "敏感词归一化后不能为空"}) + if self.action != self.Action.BLOCK: + raise ValidationError({"action": "MVP 只支持 block 动作"}) + + def save(self, *args, **kwargs) -> None: + self.full_clean() + super().save(*args, **kwargs) diff --git a/apps/moderation/normalization.py b/apps/moderation/normalization.py new file mode 100644 index 0000000..bf34586 --- /dev/null +++ b/apps/moderation/normalization.py @@ -0,0 +1,54 @@ +from __future__ import annotations + +import unicodedata +from functools import lru_cache + +_ZERO_WIDTH_CHARS = { + "\u200b", + "\u200c", + "\u200d", + "\ufeff", + "\u2060", +} + + +@lru_cache(maxsize=1) +def _opencc_converter(): + try: + from opencc import OpenCC + except Exception: + return None + try: + return OpenCC("t2s") + except Exception: + return None + + +def _to_simplified(value: str) -> str: + converter = _opencc_converter() + if converter is None: + return value + return converter.convert(value) + + +def normalize_text(value: str | None) -> str: + """Normalize text before keyword matching. + + The local keyword MVP removes obvious bypass characters only; it does not + attempt semantic rewriting or fuzzy matching. + """ + if not value: + return "" + + normalized = unicodedata.normalize("NFKC", value).lower() + normalized = _to_simplified(normalized) + + chars: list[str] = [] + for char in normalized: + if char in _ZERO_WIDTH_CHARS: + continue + category = unicodedata.category(char) + if category[0] in {"P", "Z"}: + continue + chars.append(char) + return "".join(chars) diff --git a/apps/moderation/policy.py b/apps/moderation/policy.py new file mode 100644 index 0000000..f79a804 --- /dev/null +++ b/apps/moderation/policy.py @@ -0,0 +1,59 @@ +from __future__ import annotations + +from django.conf import settings + +from .providers import ModerationConfigError, ModerationProvider, Verdict + + +def moderation_enabled() -> bool: + return bool(getattr(settings, "MODERATION_ENABLED", False)) + + +def fail_closed() -> bool: + return bool(getattr(settings, "MODERATION_FAIL_CLOSED", True)) + + +def block_on_review() -> bool: + return bool(getattr(settings, "MODERATION_BLOCK_ON_REVIEW", False)) + + +def verdict_is_blocking(verdict: Verdict) -> bool: + if verdict == Verdict.BLOCK: + return True + if verdict == Verdict.REVIEW: + return block_on_review() + return False + + +def selected_provider_name() -> str: + return str(getattr(settings, "MODERATION_PROVIDER", "") or "").strip().lower() + + +def _build_keyword() -> ModerationProvider: + from .providers.keyword import KeywordModerationProvider + + return KeywordModerationProvider() + + +_PROVIDER_BUILDERS = { + "keyword": _build_keyword, +} + + +def biz_type(stage: str) -> str: + return stage + + +def _build_provider() -> ModerationProvider: + """按 MODERATION_PROVIDER 选具体厂商。未选 / 未知 → ModerationConfigError(上层按 fail-closed 处理)。""" + name = selected_provider_name() + if not name: + raise ModerationConfigError("MODERATION_PROVIDER 未设置(内容安全厂商未选定)") + builder = _PROVIDER_BUILDERS.get(name) + if builder is None: + raise ModerationConfigError(f"未知内容安全厂商: {name}") + return builder() + + +def get_text_provider() -> ModerationProvider: + return _build_provider() diff --git a/apps/moderation/providers/__init__.py b/apps/moderation/providers/__init__.py new file mode 100644 index 0000000..8dc34ee --- /dev/null +++ b/apps/moderation/providers/__init__.py @@ -0,0 +1,17 @@ +from .base import ( + ModerationConfigError, + ModerationError, + ModerationProvider, + ModerationResult, + ModerationUnavailableError, + Verdict, +) + +__all__ = [ + "ModerationConfigError", + "ModerationError", + "ModerationProvider", + "ModerationResult", + "ModerationUnavailableError", + "Verdict", +] diff --git a/apps/moderation/providers/base.py b/apps/moderation/providers/base.py new file mode 100644 index 0000000..7a00828 --- /dev/null +++ b/apps/moderation/providers/base.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from enum import Enum +from typing import Any, Protocol + + +class ModerationError(RuntimeError): + """Base error for content-moderation failures.""" + + +class ModerationUnavailableError(ModerationError): + """Provider could not return a verdict (down / timeout / not implemented). + + The orchestration layer converts this into a fail-closed BLOCK or a + fail-open PASS depending on ``MODERATION_FAIL_CLOSED`` policy. + """ + + +class ModerationConfigError(ModerationError): + """Provider is misconfigured (missing credentials / biz type / endpoint).""" + + +class Verdict(str, Enum): + PASS = "pass" # 放行 + REVIEW = "review" # 建议人工复审(是否等同拦截由 policy 决定) + BLOCK = "block" # 拦截 + + +@dataclass(frozen=True) +class ModerationResult: + """Normalized moderation verdict, decoupled from any vendor's response shape. + + NOTE: ``raw`` is only for debugging in-process; never persist it verbatim + (may contain the moderated content / vendor internals). Compliance logging + stores a summary (verdict + labels + request_id), not ``raw``. + """ + + verdict: Verdict + labels: tuple[str, ...] = () + score: int | None = None + keywords: tuple[str, ...] = () + request_id: str = "" + raw: Any = field(default=None, repr=False, compare=False) + + +class ModerationProvider(Protocol): + def moderate_text(self, text: str, *, biz_type: str = "") -> ModerationResult: + ... diff --git a/apps/moderation/providers/keyword.py b/apps/moderation/providers/keyword.py new file mode 100644 index 0000000..589d12a --- /dev/null +++ b/apps/moderation/providers/keyword.py @@ -0,0 +1,117 @@ +from __future__ import annotations + +import threading +from dataclasses import dataclass + +try: + from ahocorapy.keywordtree import KeywordTree +except ImportError: # pragma: no cover - exercised only when dependency is missing. + KeywordTree = None + +from apps.moderation.models import SensitiveWord +from apps.moderation.normalization import normalize_text +from apps.moderation.versioning import get_sensitive_words_version + +from .base import ModerationResult, ModerationUnavailableError, Verdict + + +@dataclass(frozen=True) +class SensitiveWordMatch: + id: int + word: str + normalized_word: str + category: str + + +class KeywordMatcher: + def __init__(self, records: list[SensitiveWordMatch]) -> None: + self._records_by_keyword: dict[str, list[SensitiveWordMatch]] = {} + if KeywordTree is None: + raise ModerationUnavailableError("ahocorapy is not installed") + self._tree = KeywordTree(case_insensitive=False) + for record in records: + self._records_by_keyword.setdefault(record.normalized_word, []).append(record) + for keyword in self._records_by_keyword: + self._tree.add(keyword) + self._tree.finalize() + + def search(self, text: str) -> list[SensitiveWordMatch]: + normalized = normalize_text(text) + if not normalized: + return [] + + matches: list[SensitiveWordMatch] = [] + seen: set[int] = set() + for keyword, _index in self._tree.search_all(normalized): + for record in self._records_by_keyword.get(keyword, ()): + if record.id in seen: + continue + seen.add(record.id) + matches.append(record) + return matches + + +@dataclass(frozen=True) +class _MatcherState: + version: str + matcher: KeywordMatcher + + +_matcher_lock = threading.Lock() +_matcher_state: _MatcherState | None = None + + +def reset_keyword_matcher_cache() -> None: + global _matcher_state + with _matcher_lock: + _matcher_state = None + + +def _load_active_records() -> list[SensitiveWordMatch]: + rows = SensitiveWord.objects.filter( + is_active=True, + action=SensitiveWord.Action.BLOCK, + ).only("id", "word", "normalized_word", "category") + return [ + SensitiveWordMatch( + id=row.id, + word=row.word, + normalized_word=row.normalized_word, + category=row.category, + ) + for row in rows.order_by("id") + if row.normalized_word + ] + + +def get_keyword_matcher() -> KeywordMatcher: + global _matcher_state + version = get_sensitive_words_version() + state = _matcher_state + if state is not None and state.version == version: + return state.matcher + + with _matcher_lock: + state = _matcher_state + if state is not None and state.version == version: + return state.matcher + matcher = KeywordMatcher(_load_active_records()) + _matcher_state = _MatcherState(version=version, matcher=matcher) + return matcher + + +class KeywordModerationProvider: + def moderate_text(self, text: str, *, biz_type: str = "") -> ModerationResult: + matches = get_keyword_matcher().search(text) + if not matches: + return ModerationResult(verdict=Verdict.PASS) + + labels = tuple(dict.fromkeys(match.category for match in matches)) + keywords = tuple(dict.fromkeys(match.word for match in matches)) + word_ids = tuple(match.id for match in matches) + return ModerationResult( + verdict=Verdict.BLOCK, + labels=labels, + keywords=keywords, + raw={"word_ids": word_ids}, + ) diff --git a/apps/moderation/services.py b/apps/moderation/services.py new file mode 100644 index 0000000..273dc91 --- /dev/null +++ b/apps/moderation/services.py @@ -0,0 +1,94 @@ +"""Prompt moderation orchestration. + +T-604 只做本地关键词 prompt 审核:在图片下载、定价、预扣点和上游调用之前执行。 +MVP 不做输出审核、不做图片审核,也不保存用户 prompt 原文。 +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field + +from . import policy +from .providers import ModerationError, ModerationResult, Verdict + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ModerationOutcome: + stage: str + verdict: Verdict + blocked: bool + labels: tuple[str, ...] = () + keywords: tuple[str, ...] = () + matched_word_ids: tuple[int, ...] = () + request_id: str = "" + reason: str = "" + result: ModerationResult | None = field(default=None, repr=False) + + +def _passed(stage: str) -> ModerationOutcome: + return ModerationOutcome(stage=stage, verdict=Verdict.PASS, blocked=False) + + +def _on_provider_failure(stage: str, exc: Exception) -> ModerationOutcome: + """provider 不可用时的兜底:fail-closed → BLOCK,fail-open → PASS。""" + if policy.fail_closed(): + logger.warning("moderation fail-closed at %s: %s", stage, exc.__class__.__name__) + return ModerationOutcome( + stage=stage, + verdict=Verdict.BLOCK, + blocked=True, + reason=f"fail-closed: {exc.__class__.__name__}", + ) + logger.warning("moderation fail-open at %s: %s", stage, exc.__class__.__name__) + return ModerationOutcome(stage=stage, verdict=Verdict.PASS, blocked=False, reason="fail-open") + + +def _evaluate(stage: str, call) -> ModerationOutcome: + if not policy.moderation_enabled(): + return _passed(stage) + try: + result = call() + except ModerationError as exc: + outcome = _on_provider_failure(stage, exc) + _record(outcome) + return outcome + + outcome = ModerationOutcome( + stage=stage, + verdict=result.verdict, + blocked=policy.verdict_is_blocking(result.verdict), + labels=result.labels, + keywords=result.keywords, + matched_word_ids=tuple(result.raw.get("word_ids", ())) if isinstance(result.raw, dict) else (), + request_id=result.request_id, + result=result, + ) + _record(outcome) + return outcome + + +def _record(outcome: ModerationOutcome) -> None: + logger.info( + "moderation stage=%s verdict=%s blocked=%s labels=%s word_ids=%s request_id=%s", + outcome.stage, + outcome.verdict.value, + outcome.blocked, + ",".join(outcome.labels), + ",".join(str(word_id) for word_id in outcome.matched_word_ids), + outcome.request_id, + ) + + +def moderate_prompt(*, user=None, api_key=None, prompt: str) -> ModerationOutcome: + return _evaluate( + "prompt", + lambda: policy.get_text_provider().moderate_text( + prompt, biz_type=policy.biz_type("input") + ), + ) + +def moderate_input(*, user=None, prompt: str, **kwargs) -> ModerationOutcome: + return moderate_prompt(user=user, prompt=prompt) diff --git a/apps/moderation/signals.py b/apps/moderation/signals.py new file mode 100644 index 0000000..75378ea --- /dev/null +++ b/apps/moderation/signals.py @@ -0,0 +1,18 @@ +from __future__ import annotations + +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from .models import SensitiveWord +from .versioning import bump_sensitive_words_version + + +@receiver(post_save, sender=SensitiveWord) +@receiver(post_delete, sender=SensitiveWord) +def invalidate_sensitive_words_cache(**kwargs) -> None: + bump_sensitive_words_version() + try: + from .providers.keyword import reset_keyword_matcher_cache + except Exception: + return + reset_keyword_matcher_cache() diff --git a/apps/moderation/tests.py b/apps/moderation/tests.py new file mode 100644 index 0000000..62ad4c8 --- /dev/null +++ b/apps/moderation/tests.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +from django.core.cache import cache +from django.core.exceptions import ValidationError +from django.test import TestCase, override_settings + +from .models import SensitiveWord +from .normalization import normalize_text +from .providers.keyword import reset_keyword_matcher_cache +from .services import moderate_prompt +from .versioning import get_sensitive_words_version + + +@override_settings(MODERATION_CACHE_VERSION_KEY="test:moderation:sensitive_words:version") +class SensitiveWordNormalizationTests(TestCase): + def setUp(self): + cache.clear() + reset_keyword_matcher_cache() + + def tearDown(self): + reset_keyword_matcher_cache() + cache.clear() + + def test_normalize_text_removes_separators_zero_width_and_case(self): + self.assertEqual(normalize_text("Bad-\u200b Word"), "badword") + self.assertEqual(normalize_text("敏 感-词"), normalize_text("敏感词")) + + def test_sensitive_word_saves_normalized_word_and_rejects_empty_normalized_word(self): + word = SensitiveWord.objects.create(word=" 敏 感-词 ", category="") + + self.assertEqual(word.category, "custom") + self.assertEqual(word.normalized_word, "敏感词") + + with self.assertRaises(ValidationError): + SensitiveWord.objects.create(word=" - \u200b ") + + def test_word_save_bumps_shared_cache_version(self): + before = get_sensitive_words_version() + + SensitiveWord.objects.create(word="敏感词") + + self.assertNotEqual(get_sensitive_words_version(), before) + + +@override_settings( + MODERATION_ENABLED=True, + MODERATION_PROVIDER="keyword", + MODERATION_CACHE_VERSION_KEY="test:moderation:sensitive_words:version", +) +class KeywordModerationTests(TestCase): + def setUp(self): + cache.clear() + reset_keyword_matcher_cache() + + def tearDown(self): + reset_keyword_matcher_cache() + cache.clear() + + def test_prompt_blocks_normalized_keyword_without_storing_prompt(self): + word = SensitiveWord.objects.create(word="敏感词", category="policy") + prompt = "请处理敏-感\u200b 词内容" + + with self.assertLogs("apps.moderation.services", level="INFO") as logs: + outcome = moderate_prompt(prompt=prompt) + + self.assertTrue(outcome.blocked) + self.assertEqual(outcome.labels, ("policy",)) + self.assertEqual(outcome.keywords, ("敏感词",)) + self.assertEqual(outcome.matched_word_ids, (word.id,)) + self.assertNotIn(prompt, "\n".join(logs.output)) + + def test_non_matching_prompt_passes(self): + SensitiveWord.objects.create(word="敏感词") + + outcome = moderate_prompt(prompt="正常业务标题") + + self.assertFalse(outcome.blocked) + + def test_matcher_rebuilds_after_shared_version_changes(self): + self.assertFalse(moderate_prompt(prompt="新增词").blocked) + + SensitiveWord.objects.create(word="新增词") + + self.assertTrue(moderate_prompt(prompt="这个提示包含新增词").blocked) + + +@override_settings( + MODERATION_ENABLED=False, + MODERATION_PROVIDER="keyword", + MODERATION_CACHE_VERSION_KEY="test:moderation:sensitive_words:version", +) +class DisabledModerationTests(TestCase): + def setUp(self): + cache.clear() + reset_keyword_matcher_cache() + + def tearDown(self): + reset_keyword_matcher_cache() + cache.clear() + + def test_disabled_moderation_is_noop(self): + SensitiveWord.objects.create(word="敏感词") + + outcome = moderate_prompt(prompt="敏感词") + + self.assertFalse(outcome.blocked) diff --git a/apps/moderation/versioning.py b/apps/moderation/versioning.py new file mode 100644 index 0000000..3ac47f9 --- /dev/null +++ b/apps/moderation/versioning.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from uuid import uuid4 + +from django.conf import settings +from django.core.cache import cache + + +def sensitive_words_version_key() -> str: + return str( + getattr( + settings, + "MODERATION_CACHE_VERSION_KEY", + "moderation:sensitive_words:version", + ) + or "moderation:sensitive_words:version" + ) + + +def get_sensitive_words_version() -> str: + return str(cache.get(sensitive_words_version_key()) or "0") + + +def bump_sensitive_words_version() -> str: + version = uuid4().hex + cache.set(sensitive_words_version_key(), version, timeout=None) + return version diff --git a/config/settings.py b/config/settings.py index 5262c0e..6a7d028 100644 --- a/config/settings.py +++ b/config/settings.py @@ -117,6 +117,14 @@ IMAGE_URL_MAX_REDIRECTS = env_int("IMAGE_URL_MAX_REDIRECTS", 3) IMAGE_URL_CONNECT_TIMEOUT_SECONDS = env_int("IMAGE_URL_CONNECT_TIMEOUT_SECONDS", 10) IMAGE_URL_READ_TIMEOUT_SECONDS = env_int("IMAGE_URL_READ_TIMEOUT_SECONDS", 60) RECHARGE_MAX_AMOUNT_CNY = env_decimal("RECHARGE_MAX_AMOUNT_CNY", "100000.00") +MODERATION_ENABLED = env_bool("MODERATION_ENABLED", False) +MODERATION_PROVIDER = os.environ.get("MODERATION_PROVIDER", "keyword").strip().lower() +MODERATION_FAIL_CLOSED = env_bool("MODERATION_FAIL_CLOSED", True) +MODERATION_BLOCK_ON_REVIEW = env_bool("MODERATION_BLOCK_ON_REVIEW", False) +MODERATION_CACHE_VERSION_KEY = os.environ.get( + "MODERATION_CACHE_VERSION_KEY", + "moderation:sensitive_words:version", +) # Application definition @@ -135,6 +143,7 @@ INSTALLED_APPS = [ 'apps.portal', 'apps.billing', 'apps.ai', + 'apps.moderation', 'apps.api', ] diff --git a/docs/06-tasks.md b/docs/06-tasks.md index dd87855..631d81b 100644 --- a/docs/06-tasks.md +++ b/docs/06-tasks.md @@ -83,7 +83,7 @@ | T-602 | django-admin 中文化(第 1-3 层) | - | 让运营后台表名/分组/框架文字显示中文,**仅显示层、不改业务逻辑与 DB 结构**。**第 1 层**:`config/settings.py` 设 `LANGUAGE_CODE='zh-hans'` + `USE_I18N=True`,使 Django 自带 admin 界面与内置 `auth`(用户/组/权限)中文化(无迁移)。**第 2 层**:各 app `AppConfig.verbose_name` 设中文分组名(`users`/`billing`/`ai`/`api`/`portal`),无迁移。**第 3 层**:各模型 `Meta.verbose_name`/`verbose_name_plural` 设中文表名(`User`/`UserWallet`/`ApiKey`/`PointsLedger`/`CallRecord`/`PricingRule`/`ExchangeRate`/`RechargeOrder`/`AiModel`/`ModelAlias`/`AiConfigAuditLog`)。**不含第 4 层字段级 `verbose_name`(字段列名保持英文,见 T-603)**。第 3 层会产出 `AlterModelOptions` 等**无 DB 变更**迁移;验收:`makemigrations`(仅 options 变更)→ `migrate` → `check` 0 issues → `test` 全绿 → `/admin/` 目视中文化,并在 `../progress.md` 留证据 | DONE | | T-603 | django-admin 中文化(第 4 层·字段级) | T-602 | 给各模型**字段**加中文 `verbose_name`,使 admin 列表/编辑页的字段标签显示中文,在 T-602(1-3 层)之上做。范围覆盖 `users`/`billing`/`ai`(及 portal 相关)模型的业务字段,例如 `points_balance`→点数余额、`key_prefix`→Key 前缀、`points_delta`→点数变动、`balance_after`→变动后余额、`amount_money`→金额、`exchange_rate`→汇率、`points_granted`→到账点数、`api_type`/`capabilities`/`is_active`/`created_at` 等;`admin.py` 里自定义显示方法(`@admin.display(description=...)`)同步中文。**仅显示层**:`verbose_name` 只改展示,**不改字段名、不改代码引用、英文字段名保持不变**;产出的是 `AlterField`(metadata-only,**无 DB schema 变更**)迁移。验收:`makemigrations`(仅 AlterField,无 schema 变更)→ `migrate` → `check` 0 issues → `test` 全绿 → `/admin/` 字段标签目视中文,并在 `../progress.md` 留证据。T-605 已解除邮箱验证口径阻塞;当前待在稳定 MySQL 测试库上补跑完整验证后再标 `DONE` | TODO | | T-605 | 落实「免邮箱验证」策略 | T-501 | 把 `ACCOUNT_EMAIL_VERIFICATION="none"`(注册即可用、不发验证邮件、邮箱仍必填且唯一)作为**既定策略**清理落地:① `config/settings.py` 把 `ACCOUNT_EMAIL_VERIFICATION = "none"#"mandatory"` 改为干净的 `"none"`(去行内注释),并把免验证下无实际意义的 `ACCOUNT_LOGIN_ON_EMAIL_CONFIRMATION` 设为 `False`;② 更新 `apps/portal/tests.py` 里假设 mandatory 的 2 条测试(signup 不再依赖验证邮件、未验证也可直接登录),改为断言「注册后可直接登录」;③ 同步全项目文档口径(`02-requirements`/`05-coding-rules`/`api`/`04-architecture`/`03-tech-stack`/`routes`/`env`/`deployment`/`00-ai-start-here` 中「邮箱验证」→「免邮箱验证,邮箱仍唯一」)。验收:`check` 通过,`apps.portal` 26 tests OK;全量 `manage.py test` 已尝试,跑到 95/131 后因远程 MySQL 连接超时失败(WinError 10051/10060),非本任务断言失败,详见 `../progress.md` | DONE | -| T-604 | 中文敏感词本地过滤(本地 keyword provider) | T-302, T-401 | 按 [`moderation.md`](moderation.md) 实施。**范围收紧**:T-604 只做输入 prompt 的本地敏感词快筛,不做云内容安全、不做输出审核、不做图片审核。**关键时序**:serializer 后先审 prompt,命中即 `400 content_blocked`;不得先下载 `image_url`,不得预扣点,不写 `CallRecord` / `PointsLedger`,不调上游。**核心实现**:新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线、Aho-Corasick matcher;`ahocorapy` 作为候选依赖,编码前必须验证 PyPI 可用性和 API 形状。**缓存**:matcher 进程内缓存,词库变更用共享 cache 版本号失效,不能只靠 `post_save` signal;生产依赖共享 cache。**配置**:`MODERATION_ENABLED=false` 默认 no-op;启用时 `MODERATION_PROVIDER=keyword`;MVP `SensitiveWord.action` 只支持 `block`。**验收**:`check`/`test` 全绿,覆盖 no-op、命中拦截且不扣点/不建记录/不调上游/不下载图片、归一化防绕过、词库变更后 matcher 重建;真实词库数据不进仓库,`__pycache__` 不进 Git | TODO | +| T-604 | 中文敏感词本地过滤(本地 keyword provider) | T-302, T-401 | 按 [`moderation.md`](moderation.md) 实施。**范围收紧**:T-604 只做输入 prompt 的本地敏感词快筛,不做云内容安全、不做输出审核、不做图片审核。**关键时序**:serializer 后先审 prompt,命中即 `400 content_blocked`;不得先下载 `image_url`,不得预扣点,不写 `CallRecord` / `PointsLedger`,不调上游。**核心实现**:新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线、Aho-Corasick matcher;`ahocorapy` 作为候选依赖,编码前必须验证 PyPI 可用性和 API 形状。**缓存**:matcher 进程内缓存,词库变更用共享 cache 版本号失效,不能只靠 `post_save` signal;生产依赖共享 cache。**配置**:`MODERATION_ENABLED=false` 默认 no-op;启用时 `MODERATION_PROVIDER=keyword`;MVP `SensitiveWord.action` 只支持 `block`。**验收**:`check`/`test` 全绿,覆盖 no-op、命中拦截且不扣点/不建记录/不调上游/不下载图片、归一化防绕过、词库变更后 matcher 重建;真实词库数据不进仓库,`__pycache__` 不进 Git | DONE | ## 里程碑 diff --git a/docs/current-state.md b/docs/current-state.md index f71f408..74c18e2 100644 --- a/docs/current-state.md +++ b/docs/current-state.md @@ -12,12 +12,13 @@ ## 当前快照 - 日期:2026-07-06 -- 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层、T-605 免邮箱验证策略落地已完成;T-603 django-admin 字段级中文化代码与迁移已完成,待稳定 MySQL 测试库补跑完整验证后收尾 -- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 49.0.0 + requests 2.34.2 + wechatpayv3 2.0.2 + python-alipay-sdk 3.4.0 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md` -- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 消费记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL 与 HSTS 环境变量支持;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body` +- 阶段:Phase 6 增强(MVP 后);Phase 3 对外 API 与充值已完成到 T-306,Phase 4 用户端 T-501 注册 / 登录(allauth)、T-502 API Key 自助管理页、T-503 个人中心 / 记录页、T-504 充值页与 T-505 用户端审核优化已完成,Phase 5 T-401 运营后台完善、T-402 MVP 完整验收与 T-403 部署 / 运行文档已完成,Phase 6 T-601 可用别名发现、T-602 django-admin 中文化第 1-3 层、T-604 中文敏感词本地过滤、T-605 免邮箱验证策略落地已完成;T-603 django-admin 字段级中文化代码与迁移已完成,待稳定 MySQL 测试库补跑完整验证后收尾 +- 技术栈:系统 Python 3.12.3 + Django 5.2.15 + DRF 3.16.1 + django-allauth 65.18.0 + PyMySQL 1.1.3 + cryptography 49.0.0 + requests 2.34.2 + ahocorapy 1.6.2 + wechatpayv3 2.0.2 + python-alipay-sdk 3.4.0 + django-admin;MySQL 8.4 已接入 settings,并支持 `MYSQL_CONNECT_TIMEOUT` / `MYSQL_READ_TIMEOUT` / `MYSQL_WRITE_TIMEOUT`;用户端已用 Django 模板 SSR + Bootstrap + allauth 落地注册登录;生产部署口径为 VPS / 宝塔 + Nginx + Gunicorn(gthread) + systemd;详见 `03-tech-stack.md` 与 `deployment.md` +- 生产代码:已有最小 Django 工程骨架:`manage.py`、`config/`;T-002 已创建 `apps/users|portal|billing|ai|api`;T-003 已把自定义 `User` 注册进 django-admin;T-004 已完成 email 唯一性、init 版本断言、app 顺序、`.env.example` 与 `pyproject.toml`;T-101 已新增 `apps/ai/providers/`(Provider 接口、注册表、chat/gemini/images/images_edits 适配器);T-102 已新增 `AiModel` / `ModelAlias`、Fernet 加密密钥存储、别名解析、admin 配置页、`import_ai_models` 导入命令;T-103 已新增 `AiConfigAuditLog` 审计表、admin 只读页面和后台保存/删除审计 hook;T-104/T-105 已完成录制 title/image smoke 与审核修补;T-201 已新增 `UserWallet` / `ApiKey`、`PointsLedger` / `CallRecord`、对应 admin 与迁移;T-202 已新增 `PricingRule` / `ExchangeRate`、`apps.billing.pricing` 计费计算函数、admin 配置页与迁移;T-203 已新增 `apps.billing.services`,实现并发安全预扣、成功确认与幂等失败退点;T-204 已新增 `billing.0003_pointsledger_unique_ledger_change_type_per_call`,用 MySQL 可落地的 `ref_call + change_type` 复合唯一约束兜底防重复 refund;T-301 已新增 `apps.api.authentication.ApiKeyAuthentication` 与 `ExternalApiView`;T-302 已新增生成接口编排、序列化器、图片本地存储和 `/api/v1/generate/title|image` 路由;T-303 已新增 `apps.billing.services.get_balance_snapshot()` 与 `/api/v1/balance` 余额查询接口;T-304 已新增 `RechargeOrder`、充值回调验签适配器、幂等入账服务、微信/支付宝回调路由与迁移 `billing.0004_rechargeorder_and_more`;T-305 已新增 `create_recharge_order()`、微信/支付宝扫码下单 mock/SDK 入口、`/api/v1/recharge/create` 与 `/api/v1/recharge/status`;T-306 已新增 `apps.api.throttles`、`apps.api.exceptions`、`REST_FRAMEWORK` 安全默认认证、生成/认证失败限流、`image_url` SSRF 防护与响应大小上限、充值单笔金额上限;T-501 已接入 allauth,新增 portal 路由、注册适配器、登录/注册/登出模板和最小 dashboard,注册成功创建 0 点钱包且不写赠点流水;T-502 已新增 `/apikeys`、API Key 创建表单、列表页和删除(吊销)动作,生成后明文只显示一次,列表只显示 prefix;T-503 已扩展 `/dashboard` 为个人中心汇总,并新增 `/records/recharge` 充值记录与 `/records/usage` 消费记录,只读展示当前用户数据;T-504 已新增 `/recharge` 页面、`RechargeCreateForm`、充值导航入口和轮询脚本,页面创建 pending 订单、展示二维码票据、轮询 `/api/v1/recharge/status`,订单 paid 后刷新余额;T-505 已把 Bootstrap 5 CSS 与 qrcode.js vendoring 到 `apps/portal/static/portal/vendor/`,页面不再依赖 jsdelivr,并把充值记录 / 消费记录改为 Django `Paginator` 分页;T-401 已新增 `adjust_wallet_points()` 手工调点服务、钱包 admin 专用调点表单与模板,后台可管理/检索用户、钱包、API Key(脱敏)、计费规则、汇率、充值订单、点数流水和调用记录,流水/订单/调用记录保持只读;T-402 已新增 `docs/mvp-acceptance.md`,按 P0 验收矩阵记录 MVP 完整验收结论、测试证据和已知限制;T-403 已新增 `docs/deployment.md` 与 `requirements-production.txt`,并在 settings 中补齐 `STATIC_ROOT`、`CSRF_TRUSTED_ORIGINS`、共享 `CACHES`、HTTPS cookie、proxy SSL 与 HSTS 环境变量支持;T-601 已新增 `apps.ai.catalog.get_public_model_catalog()`、`GET /api/v1/models` 与 portal `/models` 只读页面,只展示 active 可调用别名、能力、是否需要原图和点数单价,不解密 provider key,不暴露底层 SKU / URL / key / `extra_body`;T-604 已新增 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、keyword provider、归一化管线和共享 cache 版本失效,生成接口已改为 prompt 先审再读取图片/计费/扣点/调上游 - 用户端导航:顶部导航 active 状态已修复,`portal/base.html` 基于 `request.resolver_match.url_name` 高亮当前页面入口,并用 `aria-current="page"` 标记;「充值」不再在非充值页固定深色高亮。 - 测试:T-601 已验证:`.\init.ps1` 通过(Python 3.12.3,依赖已满足,`manage.py check` 0 issues,打印启动命令);`py -3.12 -m py_compile apps\ai\catalog.py apps\api\views.py apps\api\urls.py apps\api\tests.py apps\portal\views.py apps\portal\urls.py apps\portal\tests.py` 通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`git diff --check` 通过;`py -3.12 manage.py test apps.api.tests.ModelsCatalogApiTests apps.portal.tests.PortalAccountFlowTests.test_models_page_requires_session_login apps.portal.tests.PortalAccountFlowTests.test_models_page_lists_public_aliases_prices_and_unpriced_state --keepdb --noinput --verbosity 2` 首次运行时 3 条 API 测试已通过,portal `setUpClass` 阶段因远程 MySQL 连接 43.128.3.240 超时中断;随后 `Test-NetConnection 43.128.3.240 -Port 3306` 显示 `TcpTestSucceeded=True`,单独重跑两条 portal 测试通过,2 tests OK。T-403 已验证:`./init.ps1` 开工前通过;`py -3.12 manage.py check` 通过;`py -3.12 manage.py makemigrations --check --dry-run` 无变化;`py -3.12 manage.py findstatic portal/vendor/bootstrap/bootstrap.min.css portal/vendor/qrcode/qrcode.js --verbosity 1` 找到两个本地 static 文件;`py -3.12 manage.py check --deploy` 在当前开发 `.env` 下仅报预期安全配置警告,临时注入生产型安全环境变量(含 HSTS includeSubDomains/preload)后通过,0 issues;`py -3.12 manage.py collectstatic --dry-run --noinput` 通过,预期收集 169 个 static 文件;`py -3.12 manage.py createcachetable --dry-run cmhub_cache` 通过,输出 MySQL cache 表 DDL;`py -3.12 -m compileall config` 通过;尝试 `py -3.12 manage.py test apps.api --noinput --keepdb --verbosity 2` 时 25 条用例已通过,`GenerateApiTests` 14 条因远程 MySQL 连接重置/事务中断被记 ERROR,随后 `Test-NetConnection 43.128.3.240 -Port 3306` 端口可达,单独重跑 `py -3.12 manage.py test apps.api.tests.GenerateApiTests --noinput --keepdb --verbosity 2` 通过,14 tests OK。测试/迁移阶段仍有 allauth `account.EmailAddress` 条件唯一约束在 MySQL 上不可创建的 `models.W036` 警告;本项目用户账本邮箱唯一性由 `user.email` 唯一约束承担。 - 最新验证:T-605 免邮箱验证策略已验证 `py -3.12 -m py_compile config\settings.py apps\portal\tests.py` 通过;`py -3.12 manage.py check` 通过,0 issues;`py -3.12 manage.py makemigrations --check --dry-run` 通过,No changes detected;更新后的注册 / 登录目标测试 3 tests OK;`py -3.12 manage.py test apps.portal --keepdb --noinput --verbosity 2` 通过,26 tests OK;`git diff --check` 通过,仅 Windows CRLF 提示。全量 `py -3.12 manage.py test --keepdb --noinput --verbosity 2` 已尝试,发现 131 tests,跑到 95 tests 后远程 MySQL `43.128.3.240` 出现连接超时 / WinError 10051,最终 22 errors;随后 `Test-NetConnection 43.128.3.240 -Port 3306` 显示 `TcpTestSucceeded=True`,单独复跑失败起点的 billing 分组时 17 条业务用例 OK,并发扣点用例本体打印 `ok` 后在测试库 flush 阶段再次因 MySQL 连接超时 / WinError 10060 记 ERROR。结论:T-605 相关测试已通过,全量单次绿受远程 MySQL 稳定性阻塞。 +- 最新验证:T-604 中文敏感词本地过滤已验证 `py -3.12 -m py_compile config\settings.py apps\api\generation.py apps\api\tests.py apps\moderation\apps.py apps\moderation\models.py apps\moderation\normalization.py apps\moderation\policy.py apps\moderation\services.py apps\moderation\signals.py apps\moderation\versioning.py apps\moderation\providers\base.py apps\moderation\providers\keyword.py apps\moderation\tests.py` 通过;`py -3.12 manage.py check` 通过,0 issues;`py -3.12 manage.py makemigrations --check --dry-run` 通过,No changes detected;`py -3.12 manage.py test apps.moderation --keepdb --noinput --verbosity 2` 通过,7 tests OK;新增生成接口目标测试 2 tests OK;`py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 2` 通过,16 tests OK。测试期仅保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告。 - 线上热修验证:`/admin/billing/exchangerate/` 500 根因是 `ExchangeRateAdmin.date_hierarchy` 触发 MySQL `CONVERT_TZ`,而生产 MySQL 未加载时区表,Django 抛 `ValueError: Database returned an invalid datetime value`;已移除 `apps.billing.admin` 中 4 个 DateTime `date_hierarchy`,保留 `list_filter` 日期筛选。验证 `py -3.12 -m py_compile apps\billing\admin.py apps\billing\tests.py`、`py -3.12 manage.py check`、`py -3.12 manage.py test apps.billing.tests.BillingAdminTests.test_exchange_rate_changelist_renders_when_rates_exist --keepdb --noinput --verbosity 2` 均通过。 - Mock 支付提示:`PAYMENT_CALLBACK_MODE=mock` 时,用户端 `/recharge` 的待支付订单区域会显示醒目测试模式提示,说明二维码只用于联调订单创建、页面渲染和状态轮询,不能使用微信或支付宝真实付款;`sdk` 模式不显示该提示。验证 `py -3.12 -m py_compile apps\portal\views.py apps\portal\tests.py`、`py -3.12 manage.py check`、`py -3.12 manage.py test apps.portal.tests.PortalAccountFlowTests.test_recharge_page_post_creates_pending_order_without_crediting_wallet_or_ledger apps.portal.tests.PortalAccountFlowTests.test_recharge_page_does_not_show_mock_notice_in_sdk_mode --keepdb --noinput --verbosity 2` 均通过。 - 微信正式下单热修:线上微信 Native 下单失败根因是 `wechatpayv3` SDK 需要显式 `pay_type=WeChatPayType.NATIVE`,且成功响应为 `(status_code, json_string)` tuple 而非 dict;已兼容 tuple/json 响应并覆盖下单与主动查单。线上用修复后的 `_create_wechat_payment_order_with_sdk()` 创建 1 分诊断预支付请求,确认返回 `weixin://wxpay/bizpayurl` 票据。另:pending 订单主动查单异常不再让 `/api/v1/recharge/status` 返回 500,而是保持当前订单状态返回 200。 @@ -26,7 +27,7 @@ - 标准启动路径:Windows 用 `./init.ps1`;Unix/WSL 用 `./init.sh` - 标准验证路径:Windows 用 `py -3.12 manage.py check` / `py -3.12 manage.py test` - 设计基线:**自助用户端 + 对外 API + 运营后台**三合一单体;用户模型 `User`(auth)/`UserWallet`(点数,锁 wallet 扣点)/`ApiKey`(1:N,哈希存储);对外两接口 + **能力别名 + Provider 适配器**(可插拔供应商);自助扫码充值;注册不送点数。详见 `04-architecture.md` 与 2026-06-29 / 2026-07-01 的 `progress.md` 决策 -- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。用户端注册策略固定为 `ACCOUNT_EMAIL_VERIFICATION="none"`:免邮箱验证、注册即可用、邮箱仍必填且唯一;`DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 仅用于后续密码找回、通知或恢复邮箱验证等邮件能力,不作为当前注册登录前置条件。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。T-403 起生产静态、共享 cache 与 HTTPS 安全配置包含 `STATIC_URL`、`STATIC_ROOT`、`DJANGO_CACHE_BACKEND`、`DJANGO_CACHE_LOCATION`、`DJANGO_CSRF_TRUSTED_ORIGINS`、`DJANGO_SESSION_COOKIE_SECURE`、`DJANGO_CSRF_COOKIE_SECURE`、`DJANGO_SECURE_SSL_REDIRECT`、`DJANGO_SECURE_PROXY_SSL_HEADER`、`DJANGO_SECURE_HSTS_SECONDS`。 +- 配置基线:运行环境变量集中见 `docs/env.md`;真实密钥/支付凭证不得写入代码或文档样例。用户端注册策略固定为 `ACCOUNT_EMAIL_VERIFICATION="none"`:免邮箱验证、注册即可用、邮箱仍必填且唯一;`DJANGO_EMAIL_BACKEND` / `DJANGO_DEFAULT_FROM_EMAIL` 仅用于后续密码找回、通知或恢复邮箱验证等邮件能力,不作为当前注册登录前置条件。充值订单在创建时锁定汇率与预计点数,回调入账使用订单值,不按新汇率重算。支付回调与下单由 `PAYMENT_CALLBACK_MODE` 控制:本地/测试可用 HMAC `mock`,生产应为 `sdk`;二维码本地有效期提示由 `PAYMENT_QR_EXPIRES_MINUTES` 控制。T-306 起对外 API 安全配置包含 `API_GENERATE_THROTTLE_RATE`、`API_AUTH_FAILURE_THROTTLE_RATE`、`IMAGE_URL_MAX_BYTES`、`IMAGE_URL_MAX_REDIRECTS`、`IMAGE_URL_CONNECT_TIMEOUT_SECONDS`、`IMAGE_URL_READ_TIMEOUT_SECONDS`、`RECHARGE_MAX_AMOUNT_CNY`。T-403 起生产静态、共享 cache 与 HTTPS 安全配置包含 `STATIC_URL`、`STATIC_ROOT`、`DJANGO_CACHE_BACKEND`、`DJANGO_CACHE_LOCATION`、`DJANGO_CSRF_TRUSTED_ORIGINS`、`DJANGO_SESSION_COOKIE_SECURE`、`DJANGO_CSRF_COOKIE_SECURE`、`DJANGO_SECURE_SSL_REDIRECT`、`DJANGO_SECURE_PROXY_SSL_HEADER`、`DJANGO_SECURE_HSTS_SECONDS`。T-604 起内容安全配置包含 `MODERATION_ENABLED`、`MODERATION_PROVIDER=keyword`、`MODERATION_FAIL_CLOSED`、`MODERATION_BLOCK_ON_REVIEW`、`MODERATION_CACHE_VERSION_KEY`;生产多 worker 下必须使用共享 cache 承载敏感词版本号。 - 当前 blocker:T-603 不再受邮箱验证口径阻塞;剩余阻塞是远程 MySQL 测试库长跑稳定性,完整测试需预留较长时间并优先用 `--keepdb` 串行跑,必要时改用更稳定的测试库。微信正式下单已能返回二维码,但线上微信回调曾出现 `PaymentVerificationError`,仍需单独修复并完成“付款后自动入账”闭环验收;支付宝恢复依赖开放平台把 `43.128.3.240` 加入可信 IP。真实 AI 上游 smoke 需要先配置 `AI_KEY_ENCRYPTION_KEY` 并导入 AiModel/ModelAlias。图片同步真实耗时风险仍未退,T-403 已在 `deployment.md` 明确上线前必须记录真实图片 smoke 耗时;未跑通前只能按保守超时内测,不能声称已验证。 ## 当前目录要点 @@ -39,7 +40,7 @@ | `init.sh` / `init.ps1` | 已有 | 启动验证入口,已固定系统 Python 3.12 命令,并校验解释器版本 `>=3.12,<3.14` | | `requirements.txt` / `requirements-production.txt` / `pyproject.toml` | 已有 | `requirements.txt` 管本地/基础运行依赖;`requirements-production.txt` 追加 Linux 生产 Gunicorn;`pyproject.toml` 落地 `requires-python`;T-101 新增 `requests`;T-102 使用既有 `cryptography` 做 Fernet 加密;T-501 新增 `django-allauth` | | `config/`(Django 工程) | 已有 | T-001 创建,含 settings / urls / wsgi / asgi | -| `apps/`(users/portal/billing/ai/api) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-401 已在 `apps/billing/services.py` 落 `adjust_wallet_points()`,在 `apps/users/admin.py` 与 `apps/users/templates/admin/users/userwallet/adjust_points.html` 落钱包手工调点入口;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301~T-306 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询、`image_url` SSRF 防护、生成/认证限流与统一 429 错误响应;T-501 已在 portal 落 allauth 注册/登录/登出路由、模板、adapter 与 dashboard;T-502 已在 portal 落 `/apikeys`、API Key 创建表单、列表模板与删除(吊销)动作;T-503 已在 portal 落个人中心汇总、充值记录和消费记录页;T-504 已在 portal 落 `/recharge` 充值页、充值表单、二维码票据展示和状态轮询;T-505 已在 portal 落本地 vendor static 与记录分页;T-601 已在 `apps/ai/catalog.py` 落公开模型目录,在 api 落 `/api/v1/models`,在 portal 落 `/models` 页面和导航入口 | +| `apps/`(users/portal/billing/ai/api/moderation) | 已有 | T-002 创建;`apps/users` 已定义自定义 `User`;T-003 已注册 admin 与 admin smoke test;T-004 已给 `User.email` 加唯一约束;T-101 已新增 `apps/ai/providers`;T-102 已新增 `apps/ai/security.py`、`aliases.py`、`importers.py`、management command 与 `ai.0001_initial` 迁移;T-103 已新增 `apps/ai/audit.py` 与 `ai.0002_aiconfigauditlog` 迁移;T-104/T-105 已新增 `smoke_ai_generation` 录制 title/image smoke 命令;T-201 已在 users 落 `UserWallet` / `ApiKey`,在 billing 落 `PointsLedger` / `CallRecord`;T-202 已在 billing 落 `PricingRule` / `ExchangeRate` 与 `pricing.py`;T-203/T-303/T-304/T-305 已在 `apps/billing/services.py` 落扣点/退点、余额快照、充值入账与充值下单;T-401 已在 `apps/billing/services.py` 落 `adjust_wallet_points()`,在 `apps/users/admin.py` 与 `apps/users/templates/admin/users/userwallet/adjust_points.html` 落钱包手工调点入口;T-304/T-305 已在 `apps/billing/payment_gateways.py` 落回调验签、mock 下单与 SDK 入口;T-301~T-306 已在 api 落鉴权、生成接口编排、序列化器、图片存储、余额查询、充值回调、充值下单/状态查询、`image_url` SSRF 防护、生成/认证限流与统一 429 错误响应;T-501 已在 portal 落 allauth 注册/登录/登出路由、模板、adapter 与 dashboard;T-502 已在 portal 落 `/apikeys`、API Key 创建表单、列表模板与删除(吊销)动作;T-503 已在 portal 落个人中心汇总、充值记录和消费记录页;T-504 已在 portal 落 `/recharge` 充值页、充值表单、二维码票据展示和状态轮询;T-505 已在 portal 落本地 vendor static 与记录分页;T-601 已在 `apps/ai/catalog.py` 落公开模型目录,在 api 落 `/api/v1/models`,在 portal 落 `/models` 页面和导航入口;T-604 已在 moderation 落 `SensitiveWord`、keyword matcher、归一化、共享 cache 版本失效与 admin | | `manage.py` | 已有 | T-001 创建 | | `tests/` | 待建 | 随各任务补充 | @@ -47,7 +48,7 @@ 任务状态以 [`06-tasks.md`](06-tasks.md) 为准,历史执行记录见 [`../progress.md`](../progress.md)。 -- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-605 免邮箱验证策略落地。 +- 已完成:T-001 初始化 Django + DRF 项目骨架;T-002 建立 apps 目录、自定义 User 与配置;T-003 接通 django-admin 与最小测试;T-004 Phase 0 骨架审核修补;T-101 Provider 适配器层 + 移植 cmbot 调用;T-102 AiModel + ModelAlias 模型 + 别名解析;T-103 配置变更审计;T-104 跑通一次录制标题生成;T-105 Phase 1 AI 层审核修补;T-201 User / UserWallet / ApiKey / PointsLedger / CallRecord 模型;T-202 PricingRule / ExchangeRate 模型 + 计费计算;T-203 并发安全扣点 / 退点;T-204 Phase 2 计费核心审核加固;T-301 API Key 鉴权;T-302 生成标题 / 图片接口;T-303 余额查询接口;T-304 充值回调;T-305 扫码充值下单 + 轮询;T-306 Phase 3 对外 API 安全加固;T-501 注册 / 登录(allauth);T-502 API Key 自助管理页;T-503 个人中心 / 记录页;T-504 充值页(扫码 + 轮询到账);T-505 Phase 4 用户端审核优化;T-401 运营后台完善;T-402 完整验收 MVP;T-403 部署 / 运行文档;T-601 可用别名发现;T-602 django-admin 中文化(第 1-3 层);T-604 中文敏感词本地过滤;T-605 免邮箱验证策略落地。 - 正在进行:无。 - 待开始:T-603 待在稳定 MySQL 测试库上补跑完整验证;之后可继续处理真实支付、AI 模型导入与真实图片耗时验证,或从 Backlog 拆新任务。 - 当前 blocker:T-603 的字段级中文化代码/迁移已完成,邮箱验证口径已由 T-605 解封;当前只剩远程 MySQL 长跑稳定性阻塞完整测试单次全绿。支付商户真实密钥/证书与生产 SDK 依赖仍待提供;真实 AI 上游 smoke 仍需配置密钥和模型数据后执行。 @@ -107,7 +108,7 @@ gunicorn config.wsgi:application --bind 127.0.0.1:8002 --workers 1 --worker-clas - `GET /records/recharge` - `GET /records/usage` -当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501/T-605 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="none"`,免邮箱验证、注册即可用,邮箱仍必填且唯一;注册成功创建 0 点钱包、不写点数流水。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。T-401 已落地运营后台完善:用户列表显示钱包余额,钱包余额只读且通过专用表单手工调点,调点必须填原因、非 0、不得扣成负数,并经 `adjust_wallet_points()` 锁钱包写 `PointsLedger(adjust)`;API Key admin 只展示 prefix 和 hash 摘要,不回显明文或完整 hash;订单、流水、调用记录继续只读并增强检索。T-402 已完成 MVP P0 验收并新增 `docs/mvp-acceptance.md`。T-403 已完成部署 / 运行文档,生产按 `deployment.md` 执行,并已补 settings 对生产静态目录、共享 cache、CSRF trusted origins、HTTPS cookie/proxy/HSTS 的环境变量支持。T-601 已落地可用别名发现:`GET /api/v1/models` 用 API Key 鉴权返回公开别名目录,`/models` 用 session 展示只读「可用模型」页;两者均不解密 provider key,不输出底层 SKU、URL、key 或 `extra_body`。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片并记录图片耗时。 +当前骨架可运行。T-002 已在首次迁移前创建自定义 User,并按 `env.md` 接入 MySQL 8.4 / utf8mb4;远程 MySQL 已完成 Django 初始迁移。T-003 已接通 django-admin,测试可创建/销毁 `test_cmhub` 测试库;当前远程 MySQL 对频繁建库/销库仍可能间歇超时,必要时用 `--keepdb` 且串行跑测试。T-004 已应用 `users.0002_alter_user_email`,`user.email` 已有唯一索引。T-101 的 AI provider 层只做 HTTP 调用与响应解析;T-102 已把 provider 运行配置接到数据库 `AiModel` / `ModelAlias`,`resolve_alias()` 每次查当前 active 配置并按 `text` / `image` 能力校验。T-103 已补 `AiConfigAuditLog`,admin 保存/删除 `AiModel` / `ModelAlias` 时记录 actor、action、target、changed_fields、changes、created_at,密钥只记录 empty/set 状态。T-104/T-105 已用临时回滚配置跑通录制标题和录制图片生成。T-201 已落地钱包、API Key、点数流水和调用记录:API Key 明文只在创建 helper 返回,库内只存 hash/prefix;CallRecord 只存 `result_ref`/`result_summary`,没有 provider raw 字段。T-202 已落地 `PricingRule` / `ExchangeRate`:计费按 `operation_type + alias + resolution` 查 active 规则,优先精确分辨率,再回退默认价;缺规则抛 `NoPricingRuleError(code="no_pricing_rule")`;金额换点数按当前 active 汇率向下取整。T-203 已落地 `precharge_call()` / `mark_call_success()` / `refund_call_points()`:预扣锁钱包行,余额不足不写调用/流水;失败退点锁调用记录并幂等写 refund 流水。T-204 已完成复合唯一约束加固,并取得一次完整 `manage.py test` 单次全绿。T-301 已落地 `Authorization: Bearer ` 鉴权:成功后 `request.user` 为所属用户、`request.auth` 为 `ApiKey`,缺失/无效 Key 返回 401,用户或 Key 禁用返回 403,外部 API 不接受 Web session。T-302 已落地生成接口:请求别名解析后按规则计费,预扣成功才调用 Provider,成功确认调用记录,`AiProviderError` / `AiCapabilityError` 等失败路径会退点;图片结果保存到本地 media 并返回 URL。T-303 已落地余额查询接口:`GET /api/v1/balance` 继承外部 API Key 鉴权,读取 billing 余额快照并返回 `user` 与 `points_balance`,测试覆盖余额与流水累加一致。T-304 已落地充值回调:`RechargeOrder` 保存下单锁定的金额/汇率/点数,微信/支付宝回调先验签再按订单幂等入账,重复回调不重复加点,金额不一致不入账;主动查单兜底可调用 `query_and_apply_recharge_payment(order_no, query_func)` 复用同一入账路径。T-305 已落地扫码下单与轮询:用户端 session 登录后可 `POST /api/v1/recharge/create` 创建 pending 订单并拿到 mock/SDK 二维码票据,`GET /api/v1/recharge/status` 只返回本人订单并在 pending 时尝试主动查单补入账;API Key 不能调用这两个用户端接口。T-306 已落地对外 API 安全加固:`image_url` 下载在扣点前做协议白名单、公网地址校验、重定向逐跳校验和响应大小上限;DRF 全局默认不再隐式启用 Session/Basic;生成接口按 Key 限流,认证失败按 IP 限流;充值下单有单笔金额上限。T-501/T-605 已落地 allauth 注册 / 登录:`ACCOUNT_EMAIL_VERIFICATION="none"`,免邮箱验证、注册即可用,邮箱仍必填且唯一;注册成功创建 0 点钱包、不写点数流水。T-502 已落地 API Key 自助管理:`/apikeys` 登录访问,生成后完整明文只显示一次,列表只显示 prefix,不显示 hash 或历史明文;删除为吊销 `revoked`,吊销后外部 API 返回 403。T-503 已落地个人中心与记录页:`/dashboard` 展示剩余点数、充值总额、入账点数、净消耗点数和最近记录;`/records/recharge` 展示当前用户充值订单;`/records/usage` 展示当前用户 consume/refund 点数流水并关联调用信息;所有页面均只读且只查本人。T-504 已落地充值页:`/recharge` GET 展示余额、充值表单、当前订单和最近充值,POST 创建 pending 订单并展示二维码票据,浏览器轮询 `/api/v1/recharge/status`,paid 后刷新页面重新读取余额;页面不直接写钱包或流水。T-401 已落地运营后台完善:用户列表显示钱包余额,钱包余额只读且通过专用表单手工调点,调点必须填原因、非 0、不得扣成负数,并经 `adjust_wallet_points()` 锁钱包写 `PointsLedger(adjust)`;API Key admin 只展示 prefix 和 hash 摘要,不回显明文或完整 hash;订单、流水、调用记录继续只读并增强检索。T-402 已完成 MVP P0 验收并新增 `docs/mvp-acceptance.md`。T-403 已完成部署 / 运行文档,生产按 `deployment.md` 执行,并已补 settings 对生产静态目录、共享 cache、CSRF trusted origins、HTTPS cookie/proxy/HSTS 的环境变量支持。T-601 已落地可用别名发现:`GET /api/v1/models` 用 API Key 鉴权返回公开别名目录,`/models` 用 session 展示只读「可用模型」页;两者均不解密 provider key,不输出底层 SKU、URL、key 或 `extra_body`。T-604 已落地本地 prompt 敏感词过滤:`MODERATION_ENABLED=false` 默认 no-op,启用 `keyword` 后命中返回 `content_blocked`,并在下载 `image_url`、解析别名、计费、预扣点和上游调用前拦截。真实上游生成未执行,原因是当前环境未配置 `AI_KEY_ENCRYPTION_KEY` 且数据库没有 AiModel/ModelAlias;后续配置后可用 `import_ai_models` 导入,再通过接口跑真实标题/图片并记录图片耗时。 ## 开始编码前检查 diff --git a/docs/moderation.md b/docs/moderation.md index 0b4e33f..321bc3b 100644 --- a/docs/moderation.md +++ b/docs/moderation.md @@ -1,6 +1,6 @@ # 内容安全与本地敏感词过滤 -> T-604 的实施口径。目标是先做「中文 prompt 本地敏感词快筛」,命中在扣点和调上游之前拦截;云内容安全与输出审核保留接口,不在本任务做实。 +> T-604 的实施口径与实现记录。目标是先做「中文 prompt 本地敏感词快筛」,命中在扣点和调上游之前拦截;云内容安全与输出审核不在本任务做实。 ## 定位 @@ -21,7 +21,7 @@ 6. 预扣点、写 pending 调用和 consume 流水。 7. 调上游,成功后写成功记录;失败走既有退点路径。 -说明:当前 `apps/moderation` 骨架里有 `moderate_output_*` 钩子,但 T-604 不启用输出审核。输出审核若后续落地,必须重新定义「命中是否退点」的产品策略,并让 `MODERATION_REFUND_ON_OUTPUT_BLOCK` 真的生效;在 T-604 中不要暴露一个未实现的配置。 +说明:T-604 已清理早期骨架里的 `moderate_output_*` 钩子和云厂商 provider stub,只保留 `moderate_prompt()` 本地关键词入口。输出审核若后续落地,必须重新定义「命中是否退点」的产品策略,并让对应配置真的生效;在 T-604 中不要暴露一个未实现的配置。 ## 数据模型 @@ -83,6 +83,8 @@ admin 要求: ## 验收 +当前实现状态:DONE。已落地 `apps.moderation`、`SensitiveWord` 模型/admin/迁移、`ahocorapy` keyword provider、归一化管线和共享 cache 版本号失效;生成接口已改为 serializer 后先审 prompt,命中时不会下载 `image_url`、不会扣点、不会写调用/流水、不会调上游。 + T-604 完成前至少覆盖: - `MODERATION_ENABLED=false` 时生成接口 no-op,既有用例不变。 diff --git a/progress.md b/progress.md index 8dc51ff..2f2e32d 100644 --- a/progress.md +++ b/progress.md @@ -1241,3 +1241,30 @@ - 单独复跑失败起点 `apps.billing.tests.BillingServiceTests apps.billing.tests.BillingAdminTests apps.billing.tests.ConcurrentDebitTests`:17 条业务用例 OK;并发扣点用例本体打印 `ok` 后在测试库 flush 阶段再次因 MySQL 连接超时 / WinError 10060 记 ERROR。 - 结论:T-605 相关测试已通过,全量单次绿受远程 MySQL 长跑稳定性阻塞,不是本任务断言失败。 - 下一步:回到 T-603,在更稳定的 MySQL 测试库或网络条件下补跑完整验证,再决定是否标 `DONE`。 + +## 2026-07-06 T-604:中文敏感词本地过滤 + +- 状态:DONE。 +- 目标:按 `docs/moderation.md` 落地本地 keyword provider,只审核输入 prompt;命中必须在下载 `image_url`、解析别名、计费、预扣点和上游调用前返回 `400 content_blocked`。 +- 依赖确认:编码前已验证 `ahocorapy==1.6.2` 可安装,API 使用 `KeywordTree.add()` / `finalize()` / `search_all()`,`search_all()` 返回 `(matched_keyword, index)`。 +- 代码变更: + - `config/settings.py` / `.env.example` / `requirements.txt`:接入 `apps.moderation`、`MODERATION_*` 配置和 `ahocorapy` 依赖。 + - `apps/moderation`:新增 `SensitiveWord` 模型/admin/迁移、归一化管线、共享 cache 版本号、signal 失效、keyword provider 与 prompt 审核 service;清理早期 Tencent stub 和输出审核骨架。 + - `apps/api/generation.py`:生成 title/image 的入口改为 serializer 后先 `moderate_prompt()`;命中直接 `content_blocked`,未命中才读取图片并继续别名、计费、扣点、上游调用。 + - `apps/api/tests.py` / `apps/moderation/tests.py`:覆盖 no-op、归一化、防绕过、词库版本重建、命中不下载图片/不扣点/不建记录/不调上游。 +- 文档变更: + - `docs/moderation.md`:从设计口径更新为实现态,明确 T-604 已清理输出审核和云厂商 stub。 + - `docs/06-tasks.md`:T-604 标为 DONE。 + - `docs/current-state.md`:同步依赖、当前状态、配置基线、验证证据和任务看板。 +- 验证: + - `py -3.12 -m py_compile config\settings.py apps\api\generation.py apps\api\tests.py apps\moderation\apps.py apps\moderation\models.py apps\moderation\normalization.py apps\moderation\policy.py apps\moderation\services.py apps\moderation\signals.py apps\moderation\versioning.py apps\moderation\providers\base.py apps\moderation\providers\keyword.py apps\moderation\tests.py`:通过。 + - `py -3.12 manage.py check`:通过,0 issues。 + - `py -3.12 manage.py makemigrations --check --dry-run`:通过,No changes detected。 + - `py -3.12 manage.py test apps.moderation --keepdb --noinput --verbosity 2`:通过,7 tests OK。 + - `py -3.12 manage.py test apps.api.tests.GenerateApiTests.test_blocked_prompt_returns_content_blocked_before_image_download_or_charge apps.api.tests.GenerateApiTests.test_disabled_moderation_does_not_block_matching_prompt --keepdb --noinput --verbosity 2`:通过,2 tests OK。 + - `py -3.12 manage.py test apps.api.tests.GenerateApiTests --keepdb --noinput --verbosity 2`:通过,16 tests OK。 + - 测试期仅保留 allauth 在 MySQL 条件唯一约束上的既有 `models.W036` 警告,本项目邮箱唯一性由 `user.email` 承担。 +- 注意: + - 真实敏感词库数据不入仓库,需要运营在 admin 中维护或后续另做导入命令。 + - 生产多 worker 必须使用共享 cache(DatabaseCache/Redis/Memcached)承载 `MODERATION_CACHE_VERSION_KEY`,不能依赖 `LocMemCache` 做跨进程刷新。 + - 下一个看板任务仍是 T-603:在稳定 MySQL 测试库上补跑完整验证并收尾。 diff --git a/requirements.txt b/requirements.txt index d8d23ed..bb8e1f4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,5 +4,6 @@ django-allauth>=65.18,<66 PyMySQL>=1.1,<1.2 cryptography>=42,<50 requests>=2.32,<3 +ahocorapy>=1.6,<1.7 wechatpayv3>=2.0,<3 python-alipay-sdk>=3.4,<4