From 203cdd9330c06c0d0d5d01df1baf221be0988514 Mon Sep 17 00:00:00 2001 From: ZL-Q Date: Wed, 29 Apr 2026 21:28:21 +0800 Subject: [PATCH 1/4] fix(deploy): reduce backend worker footprint --- backend/scripts/trigger_feedback_report.py | 6 +- .../src/core/agentscope/events/__init__.py | 39 ++++++++++-- .../agentscope/runtime/json_react_agent.py | 2 +- .../core/agentscope/runtime/orchestrator.py | 13 ++-- backend/src/core/agentscope/runtime/runner.py | 2 +- .../core/agentscope/runtime/stage_emitter.py | 2 +- .../core/agentscope/runtime/task_handles.py | 15 +++++ backend/src/core/agentscope/runtime/tasks.py | 59 +++++++++++-------- backend/src/core/taskiq/app.py | 2 +- backend/src/v1/agent/asr.py | 6 +- backend/src/v1/agent/dependencies.py | 2 +- backend/src/v1/feedback/tasks.py | 22 ++++--- deploy/README.md | 19 +++++- deploy/docker-compose.prod.yml | 16 +---- infra/scripts/app.sh | 5 +- 15 files changed, 136 insertions(+), 74 deletions(-) create mode 100644 backend/src/core/agentscope/runtime/task_handles.py diff --git a/backend/scripts/trigger_feedback_report.py b/backend/scripts/trigger_feedback_report.py index 355f068..8c2b276 100644 --- a/backend/scripts/trigger_feedback_report.py +++ b/backend/scripts/trigger_feedback_report.py @@ -1,4 +1,4 @@ -"""手动触发 worker-general 定时任务:生成反馈报告 +"""手动触发 worker-agent 定时任务:生成反馈报告 用法: cd /home/qzl/Code/eryao/.worktrees/feat-user-feedback @@ -12,13 +12,13 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) -from core.taskiq.app import worker_general_broker +from core.taskiq.app import worker_agent_broker from v1.feedback.tasks import generate_daily_feedback_report def main(): task = generate_daily_feedback_report.kiq() - result = worker_general_broker.wait_result(task, timeout=120) + result = worker_agent_broker.wait_result(task, timeout=120) print(f"Task result: {result.return_value}") diff --git a/backend/src/core/agentscope/events/__init__.py b/backend/src/core/agentscope/events/__init__.py index 5c52772..b69ad2e 100644 --- a/backend/src/core/agentscope/events/__init__.py +++ b/backend/src/core/agentscope/events/__init__.py @@ -1,9 +1,3 @@ -from core.agentscope.events.agui_codec import AgentScopeAgUiCodec, to_agui_wire_event -from core.agentscope.events.pipeline import AgentScopeEventPipeline -from core.agentscope.events.redis_bus import RedisStreamBus -from core.agentscope.events.sse import to_sse_event -from core.agentscope.events.store import NullEventStore, SqlAlchemyEventStore - __all__ = [ "AgentScopeAgUiCodec", "AgentScopeEventPipeline", @@ -13,3 +7,36 @@ __all__ = [ "to_agui_wire_event", "to_sse_event", ] + + +def __getattr__(name: str): + if name in {"AgentScopeAgUiCodec", "to_agui_wire_event"}: + from core.agentscope.events.agui_codec import ( + AgentScopeAgUiCodec, + to_agui_wire_event, + ) + + return { + "AgentScopeAgUiCodec": AgentScopeAgUiCodec, + "to_agui_wire_event": to_agui_wire_event, + }[name] + if name == "AgentScopeEventPipeline": + from core.agentscope.events.pipeline import AgentScopeEventPipeline + + return AgentScopeEventPipeline + if name == "RedisStreamBus": + from core.agentscope.events.redis_bus import RedisStreamBus + + return RedisStreamBus + if name == "to_sse_event": + from core.agentscope.events.sse import to_sse_event + + return to_sse_event + if name in {"NullEventStore", "SqlAlchemyEventStore"}: + from core.agentscope.events.store import NullEventStore, SqlAlchemyEventStore + + return { + "NullEventStore": NullEventStore, + "SqlAlchemyEventStore": SqlAlchemyEventStore, + }[name] + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") diff --git a/backend/src/core/agentscope/runtime/json_react_agent.py b/backend/src/core/agentscope/runtime/json_react_agent.py index 45f4d6a..b54ece2 100644 --- a/backend/src/core/agentscope/runtime/json_react_agent.py +++ b/backend/src/core/agentscope/runtime/json_react_agent.py @@ -6,7 +6,7 @@ from agentscope.agent import ReActAgent from agentscope.message import Msg from pydantic import BaseModel -from core.agentscope.utils import finalize_json_response +from core.agentscope.utils.json_finalize import finalize_json_response class JsonReActAgent(ReActAgent): diff --git a/backend/src/core/agentscope/runtime/orchestrator.py b/backend/src/core/agentscope/runtime/orchestrator.py index 2e429db..c674b0c 100644 --- a/backend/src/core/agentscope/runtime/orchestrator.py +++ b/backend/src/core/agentscope/runtime/orchestrator.py @@ -1,17 +1,18 @@ from __future__ import annotations import asyncio -from typing import Any, Awaitable, Callable, Protocol +from typing import TYPE_CHECKING, Any, Awaitable, Callable, Protocol from ag_ui.core.types import RunAgentInput -from agentscope.message import Msg from openai import APIConnectionError -from core.agentscope.runtime.runner import AgentScopeRunner from core.agentscope.runtime.protocols import PipelineLike from core.logging import get_logger from schemas.agent.runtime_config import RuntimeConfig from schemas.shared.user import UserContext +if TYPE_CHECKING: + from agentscope.message import Msg + logger = get_logger("core.agentscope.runtime.orchestrator") @@ -38,8 +39,12 @@ class AgentScopeRuntimeOrchestrator: pipeline: PipelineLike, runner: RunnerLike | None = None, ) -> None: + if runner is None: + from core.agentscope.runtime.runner import AgentScopeRunner + + runner = AgentScopeRunner() self._pipeline = pipeline - self._runner = runner or AgentScopeRunner() + self._runner = runner async def run( self, diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index 23dd5d5..edf007c 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -22,7 +22,7 @@ from core.divination import derive_divination from core.agentscope.runtime.json_react_agent import JsonReActAgent from core.agentscope.runtime.model_tracking import TrackingChatModel from core.agentscope.runtime.stage_emitter import PipelineStageEmitter -from core.agentscope.utils import patch_agentscope_json_repair_compat +from core.agentscope.utils.compat import patch_agentscope_json_repair_compat from core.agentscope.utils.json_finalize import finalize_json_response from core.config.settings import config from core.db.session import AsyncSessionLocal diff --git a/backend/src/core/agentscope/runtime/stage_emitter.py b/backend/src/core/agentscope/runtime/stage_emitter.py index 6231c78..fb6b57c 100644 --- a/backend/src/core/agentscope/runtime/stage_emitter.py +++ b/backend/src/core/agentscope/runtime/stage_emitter.py @@ -5,7 +5,7 @@ from uuid import uuid4 from agentscope.message import Msg -from core.agentscope.utils import parse_tool_agent_output +from core.agentscope.utils.parsing import parse_tool_agent_output class PipelineLike(Protocol): diff --git a/backend/src/core/agentscope/runtime/task_handles.py b/backend/src/core/agentscope/runtime/task_handles.py new file mode 100644 index 0000000..5500c55 --- /dev/null +++ b/backend/src/core/agentscope/runtime/task_handles.py @@ -0,0 +1,15 @@ +from __future__ import annotations + +from core.taskiq.app import worker_agent_broker + + +@worker_agent_broker.task(task_name="tasks.agentscope.run_command.agent") +async def run_command_task_agent(command: dict[str, object]) -> dict[str, object]: + del command + raise RuntimeError("task handle is only for enqueueing") + + +@worker_agent_broker.task(task_name="tasks.agentscope.run_command.general") +async def run_command_task_general(command: dict[str, object]) -> dict[str, object]: + del command + raise RuntimeError("task handle is only for enqueueing") diff --git a/backend/src/core/agentscope/runtime/tasks.py b/backend/src/core/agentscope/runtime/tasks.py index f2caecf..e398b61 100644 --- a/backend/src/core/agentscope/runtime/tasks.py +++ b/backend/src/core/agentscope/runtime/tasks.py @@ -3,31 +3,13 @@ from __future__ import annotations import asyncio import base64 import json -from typing import Any, cast +from typing import TYPE_CHECKING, Any, cast from uuid import UUID -from agentscope.message import Msg from pydantic import TypeAdapter -from core.agentscope.caches import create_user_context_cache -from core.agentscope.caches.attachment_content_cache import ( - create_attachment_content_cache, -) -from core.agentscope.caches.context_messages_cache import ( - create_context_messages_cache, -) -from core.agentscope.events import ( - AgentScopeAgUiCodec, - AgentScopeEventPipeline, - RedisStreamBus, - SqlAlchemyEventStore, -) -from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator from core.agentscope.schemas.agui_input import parse_run_input -from core.agentscope.services.context_service import AgentContextService -from core.config.settings import config -from core.db.session import AsyncSessionLocal from core.logging import get_logger -from core.taskiq.app import worker_agent_broker, worker_general_broker +from core.taskiq.app import worker_agent_broker from schemas.agent.forwarded_props import ( RuntimeMode, parse_forwarded_props_runtime_mode, @@ -40,12 +22,10 @@ from schemas.domain.chat_message import ( ) from schemas.shared.user import UserContext from schemas.shared.user import parse_profile_settings -from services.base.redis import get_or_init_redis_client -from services.base.supabase import supabase_service -from v1.agent.repository import AgentRepository -from v1.points.repository import PointsRepository from v1.users.repository import SQLAlchemyUserRepository -from v1.points.service import PointsService + +if TYPE_CHECKING: + from agentscope.message import Msg logger = get_logger("core.agentscope.runtime.tasks") _MAX_CONTEXT_ATTACHMENTS = 3 @@ -176,6 +156,8 @@ def _serialize_assistant_context_from_metadata( def _load_runtime() -> type[Any]: + from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator + return AgentScopeRuntimeOrchestrator @@ -186,6 +168,8 @@ async def _build_user_context( session: Any, session_id: str, ) -> UserContext: + from core.agentscope.caches import create_user_context_cache + cache = create_user_context_cache() cached = await cache.get(session_id=UUID(session_id)) if cached: @@ -218,6 +202,17 @@ async def _build_recent_context_messages( runtime_mode: RuntimeMode = RuntimeMode.CHAT, context_config: "MessageContextConfig", ) -> list[Msg]: + from agentscope.message import Msg + from core.agentscope.caches.attachment_content_cache import ( + create_attachment_content_cache, + ) + from core.agentscope.caches.context_messages_cache import ( + create_context_messages_cache, + ) + from core.agentscope.services.context_service import AgentContextService + from services.base.supabase import supabase_service + from v1.agent.repository import AgentRepository + context_cache = create_context_messages_cache() attachment_cache = create_attachment_content_cache() raw_messages = await context_cache.get( @@ -353,6 +348,18 @@ async def _build_recent_context_messages( async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]: + from core.agentscope.events import ( + AgentScopeAgUiCodec, + AgentScopeEventPipeline, + RedisStreamBus, + SqlAlchemyEventStore, + ) + from core.config.settings import config + from core.db.session import AsyncSessionLocal + from services.base.redis import get_or_init_redis_client + from v1.points.repository import PointsRepository + from v1.points.service import PointsService + command_type = str(command.get("command", "run")).strip().lower() raw_owner_id = command.get("owner_id") raw_owner_email = command.get("owner_email") @@ -485,6 +492,6 @@ async def run_command_task_agent(command: dict[str, object]) -> dict[str, object return await run_agentscope_task(command) -@worker_general_broker.task(task_name="tasks.agentscope.run_command.general") +@worker_agent_broker.task(task_name="tasks.agentscope.run_command.general") async def run_command_task_general(command: dict[str, object]) -> dict[str, object]: return await run_agentscope_task(command) diff --git a/backend/src/core/taskiq/app.py b/backend/src/core/taskiq/app.py index 22ab470..3d6138f 100644 --- a/backend/src/core/taskiq/app.py +++ b/backend/src/core/taskiq/app.py @@ -23,7 +23,7 @@ def _build_broker(queue_name: str) -> ListQueueBroker: worker_agent_broker = _build_broker("agent") -worker_general_broker = _build_broker("general") +worker_general_broker = worker_agent_broker broker = worker_agent_broker diff --git a/backend/src/v1/agent/asr.py b/backend/src/v1/agent/asr.py index 36ee940..75d0c60 100644 --- a/backend/src/v1/agent/asr.py +++ b/backend/src/v1/agent/asr.py @@ -3,9 +3,6 @@ from __future__ import annotations import asyncio from typing import Any -import dashscope -from dashscope.audio.asr import Recognition, RecognitionCallback - from core.config.settings import config from core.logging import get_logger @@ -28,6 +25,9 @@ class AsrService: async def transcribe_file(self, file_path: str, filename: str) -> str: try: + import dashscope + from dashscope.audio.asr import Recognition, RecognitionCallback + dashscope.api_key = self._get_api_key() loop = asyncio.get_event_loop() diff --git a/backend/src/v1/agent/dependencies.py b/backend/src/v1/agent/dependencies.py index 1750607..693074c 100644 --- a/backend/src/v1/agent/dependencies.py +++ b/backend/src/v1/agent/dependencies.py @@ -48,7 +48,7 @@ class TaskiqQueueClient: @staticmethod def _select_queue_task(command: dict[str, object]) -> Any: - from core.agentscope.runtime.tasks import ( + from core.agentscope.runtime.task_handles import ( run_command_task_agent, run_command_task_general, ) diff --git a/backend/src/v1/feedback/tasks.py b/backend/src/v1/feedback/tasks.py index fe3db1e..a623adf 100644 --- a/backend/src/v1/feedback/tasks.py +++ b/backend/src/v1/feedback/tasks.py @@ -5,15 +5,11 @@ from pathlib import Path from sqlalchemy import select from structlog import get_logger -from taskiq_redis import RedisScheduleSource from core.config.settings import config from core.db.session import AsyncSessionLocal -from core.email.sender import EmailAttachment, EmailMessage, EmailSender -from core.email.template_loader import load_template -from core.taskiq.app import worker_general_broker +from core.taskiq.app import worker_agent_broker from models.user_feedback import UserFeedback -from v1.feedback.report import generate_feedback_report logger = get_logger("v1.feedback.tasks") @@ -52,6 +48,8 @@ def _build_report_email_html( end_time: datetime, push_hour: int, ) -> str: + from core.email.template_loader import load_template + template = load_template("feedback", "daily_report.html") return template.substitute( start_date=start_time.strftime("%Y-%m-%d"), @@ -70,6 +68,8 @@ def _build_no_feedback_email_html( end_time: datetime, push_hour: int, ) -> str: + from core.email.template_loader import load_template + template = load_template("feedback", "no_feedback.html") return template.substitute( start_date=start_time.strftime("%Y-%m-%d"), @@ -87,6 +87,8 @@ async def _send_feedback_email( push_hour: int, report_path: Path | None = None, ) -> bool: + from core.email.sender import EmailAttachment, EmailMessage, EmailSender + sender = EmailSender() if feedbacks: @@ -123,12 +125,14 @@ async def _send_feedback_email( # type: ignore reportArgumentType for taskiq decorator -@worker_general_broker.on_event("startup") # pyright: ignore[reportArgumentType] +@worker_agent_broker.on_event("startup") # pyright: ignore[reportArgumentType] async def _register_feedback_report_schedule() -> None: if not config.feedback_report.enabled: logger.info("Feedback report scheduling disabled") return + from taskiq_redis import RedisScheduleSource + schedule_source = RedisScheduleSource( url=config.taskiq_broker_url, prefix="schedule:feedback", @@ -146,12 +150,14 @@ async def _register_feedback_report_schedule() -> None: ) -@worker_general_broker.task(task_name="tasks.feedback.generate_daily_report") +@worker_agent_broker.task(task_name="tasks.feedback.generate_daily_report") async def generate_daily_feedback_report() -> str | None: if not config.feedback_report.enabled: logger.info("Feedback report is disabled, skipping") return None + from v1.feedback.report import generate_feedback_report + now = datetime.now(timezone.utc) push_hour = 10 end_time = now.replace(hour=push_hour, minute=0, second=0, microsecond=0) @@ -192,6 +198,8 @@ async def generate_daily_feedback_report() -> str | None: async def generate_all_feedback_report() -> Path: + from v1.feedback.report import generate_feedback_report + async with AsyncSessionLocal() as session: stmt = select(UserFeedback).order_by(UserFeedback.created_at.desc()) result = await session.execute(stmt) diff --git a/deploy/README.md b/deploy/README.md index 1116d8f..5811027 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -71,6 +71,24 @@ ERYAO_DEPLOY_BIND_HOST=127.0.0.1 ERYAO_DEPLOY_BIND_HOST=0.0.0.0 ``` +### 进程配置建议 + +生产 Compose 只启动一个 `worker-agent` 容器。Agent 任务、低频通用任务和反馈日报任务共用 `agent` 队列,不再单独常驻 `worker-general` 进程。 + +2 核 2G 机器建议使用: + +```text +ERYAO_WEB__WORKERS=1 +ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY=2 +``` + +4G 以上机器可按流量提高 Web 或 Agent worker 数量: + +```text +ERYAO_WEB__WORKERS=2 +ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY=2 +``` + ## 登录 ECR 进入部署目录,并把 `.env` 加载到当前 shell: @@ -128,7 +146,6 @@ cd deploy docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers ps docker logs -f eryao-prod-backend docker logs -f eryao-prod-worker-agent -docker logs -f eryao-prod-worker-general docker logs -f eryao-prod-redis ``` diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 1284570..e87d152 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -34,21 +34,7 @@ services: command: - sh - -c - - exec taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2} - - worker-general: - <<: *backend-common - container_name: eryao-prod-worker-general - profiles: ["workers"] - environment: - ERYAO_RUNTIME__ENVIRONMENT: prod - ERYAO_RUNTIME__SERVICE_NAME: worker-general - ERYAO_REDIS__HOST: redis - ERYAO_REDIS__PORT: 6379 - command: - - sh - - -c - - exec taskiq worker core.taskiq.app:worker_general_broker core.agentscope.runtime.tasks v1.feedback.tasks --workers ${ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY:-1} + - exec taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks v1.feedback.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2} redis: image: redis:7.4.2-alpine diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index 6233b95..a41ef98 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -153,14 +153,12 @@ start() { WEB_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=web uv run uvicorn app:app --host ${ERYAO_WEB__HOST:-0.0.0.0} --port ${WEB_PORT} --workers ${ERYAO_WEB__WORKERS:-2} --log-level ${UVICORN_LOG_LEVEL}" - WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2}" - WORKER_GENERAL_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-general uv run taskiq worker core.taskiq.app:worker_general_broker core.agentscope.runtime.tasks v1.feedback.tasks --workers ${ERYAO_WORKER__GROUPS__GENERAL__CONCURRENCY:-1}" + WORKER_AGENT_CMD="cd '$ROOT_DIR' && PYTHONPATH=backend/src ERYAO_RUNTIME__SERVICE_NAME=worker-agent uv run taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks v1.feedback.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2}" echo "Starting tmux web process in session '$SESSION_NAME'..." tmux new-session -d -s "$SESSION_NAME" -n web "bash -lc \"$WEB_CMD; echo '[web] exited'; exec bash\"" tmux new-window -t "$SESSION_NAME" -n worker-agent "bash -lc \"$WORKER_AGENT_CMD; echo '[worker-agent] exited'; exec bash\"" - tmux new-window -t "$SESSION_NAME" -n worker-general "bash -lc \"$WORKER_GENERAL_CMD; echo '[worker-general] exited'; exec bash\"" echo "" echo "=== App Started ===" @@ -170,7 +168,6 @@ start() { echo "Log files will be created in logs/ directory:" echo " - web.log, web.error.log" echo " - worker-agent.log, worker-agent.error.log" - echo " - worker-general.log, worker-general.error.log" echo "" echo "tmux attach -t $SESSION_NAME" echo "tmux list-windows -t $SESSION_NAME" From 688c0917709152036a437e5fca226e9d82f12073 Mon Sep 17 00:00:00 2001 From: ZL-Q Date: Wed, 29 Apr 2026 21:31:27 +0800 Subject: [PATCH 2/4] =?UTF-8?q?docs:=20=E7=BB=9F=E4=B8=80=E5=BA=94?= =?UTF-8?q?=E7=94=A8=E5=90=8D=E7=A7=B0=E4=B8=BA=E8=A7=85=E7=88=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/assets/legal/zh/privacy_policy.md | 2 +- apps/assets/legal/zh/terms_of_service.md | 2 +- apps/assets/legal/zh_Hant/about_us.md | 4 ++-- apps/assets/legal/zh_Hant/privacy_policy.md | 2 +- apps/assets/legal/zh_Hant/terms_of_service.md | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/assets/legal/zh/privacy_policy.md b/apps/assets/legal/zh/privacy_policy.md index 7632d21..500a059 100644 --- a/apps/assets/legal/zh/privacy_policy.md +++ b/apps/assets/legal/zh/privacy_policy.md @@ -8,7 +8,7 @@ ## 引言 -尊敬的用户,欢迎使用 米爻 MeeYao(以下简称"本应用"),本应用由**个人开发者**("我")独立开发和运营。我致力于保护您的个人隐私,并遵守适用的美国联邦和州隐私法律,包括《加州消费者隐私法》(CCPA/CPRA)、《儿童在线隐私保护法》(COPPA)、CalOPPA 以及其他美国州隐私法规。 +尊敬的用户,欢迎使用 觅爻 MeeYao(以下简称"本应用"),本应用由**个人开发者**("我")独立开发和运营。我致力于保护您的个人隐私,并遵守适用的美国联邦和州隐私法律,包括《加州消费者隐私法》(CCPA/CPRA)、《儿童在线隐私保护法》(COPPA)、CalOPPA 以及其他美国州隐私法规。 本隐私政策清晰说明: diff --git a/apps/assets/legal/zh/terms_of_service.md b/apps/assets/legal/zh/terms_of_service.md index b3504b0..d503c04 100644 --- a/apps/assets/legal/zh/terms_of_service.md +++ b/apps/assets/legal/zh/terms_of_service.md @@ -6,7 +6,7 @@ ## 1. 条款接受 -米爻 MeeYao(以下简称"本应用")由**个人开发者**("我")独立开发、拥有和运营。 +觅爻 MeeYao(以下简称"本应用")由**个人开发者**("我")独立开发、拥有和运营。 下载、安装、注册、访问或使用本应用,即表示您("您"或"用户")确认已阅读、理解并无条件同意受本服务条款("条款")及我的隐私政策约束。如果您不同意本条款,请勿使用本应用。 diff --git a/apps/assets/legal/zh_Hant/about_us.md b/apps/assets/legal/zh_Hant/about_us.md index 009e578..15e8453 100644 --- a/apps/assets/legal/zh_Hant/about_us.md +++ b/apps/assets/legal/zh_Hant/about_us.md @@ -1,10 +1,10 @@ # 關於我們 -歡迎使用 觅爻 MeeYao,一款依託 AI 技術、以傳統六爻文化與易經智慧為核心的傳統文化參考工具。 +歡迎使用 覓爻 MeeYao,一款依託 AI 技術、以傳統六爻文化與易經智慧為核心的傳統文化參考工具。 六爻文化源自博大精深的易經哲學體系,承載著古人對於心念、時序與天地變化相生相融的傳統認知。結合卦象文化、五行理論及干支傳統人文理念,幫助用戶探索東方傳統文化內涵,獲得多元的生活參考視角。 -觅爻 MeeYao 根植於東方傳統文脈,核心初衷是幫助用戶跳出固有思維局限,以更開闊的視角看待日常抉擇與生活狀態,保持理性平和的心態。我們希望借助現代 AI 技術,讓大眾更輕鬆地了解、感受與體驗中華傳統經典文化。 +覓爻 MeeYao 根植於東方傳統文脈,核心初衷是幫助用戶跳出固有思維局限,以更開闊的視角看待日常抉擇與生活狀態,保持理性平和的心態。我們希望借助現代 AI 技術,讓大眾更輕鬆地了解、感受與體驗中華傳統經典文化。 --- diff --git a/apps/assets/legal/zh_Hant/privacy_policy.md b/apps/assets/legal/zh_Hant/privacy_policy.md index ca577b3..33ca2d0 100644 --- a/apps/assets/legal/zh_Hant/privacy_policy.md +++ b/apps/assets/legal/zh_Hant/privacy_policy.md @@ -8,7 +8,7 @@ ## 引言 -尊敬的用戶,歡迎使用 米爻 MeeYao(以下簡稱「本應用」),本應用由**個人開發者**(「我」)獨立開發和運營。我致力於保護您的個人隱私,並遵守適用的美國聯邦和州隱私法律,包括《加州消費者隱私法》(CCPA/CPRA)、《兒童在線隱私保護法》(COPPA)、CalOPPA 以及其他美國州隱私法規。 +尊敬的用戶,歡迎使用 覓爻 MeeYao(以下簡稱「本應用」),本應用由**個人開發者**(「我」)獨立開發和運營。我致力於保護您的個人隱私,並遵守適用的美國聯邦和州隱私法律,包括《加州消費者隱私法》(CCPA/CPRA)、《兒童在線隱私保護法》(COPPA)、CalOPPA 以及其他美國州隱私法規。 本隱私政策清晰說明: diff --git a/apps/assets/legal/zh_Hant/terms_of_service.md b/apps/assets/legal/zh_Hant/terms_of_service.md index 08af393..4b3a20d 100644 --- a/apps/assets/legal/zh_Hant/terms_of_service.md +++ b/apps/assets/legal/zh_Hant/terms_of_service.md @@ -6,7 +6,7 @@ ## 1. 條款接受 -米爻 MeeYao(以下簡稱「本應用」)由**個人開發者**(「我」)獨立開發、擁有和運營。 +覓爻 MeeYao(以下簡稱「本應用」)由**個人開發者**(「我」)獨立開發、擁有和運營。 下載、安裝、註冊、訪問或使用本應用,即表示您(「您」或「用戶」)確認已閱讀、理解並無條件同意受本服務條款(「條款」)及我的隱私政策約束。如果您不同意本條款,請勿使用本應用。 From 79d5d0638a88034d71c98d50e05d85363e502c20 Mon Sep 17 00:00:00 2001 From: qzl Date: Thu, 30 Apr 2026 11:02:37 +0800 Subject: [PATCH 3/4] chore(deploy): automate production rollout --- .gitea/workflows/build-production-docker.yml | 80 +++++++++++++++++++- .gitignore | 5 ++ deploy/README.md | 25 +++++- 3 files changed, 106 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/build-production-docker.yml b/.gitea/workflows/build-production-docker.yml index 264bbbb..05ca271 100644 --- a/.gitea/workflows/build-production-docker.yml +++ b/.gitea/workflows/build-production-docker.yml @@ -24,6 +24,7 @@ jobs: test -n "${{ secrets.AWS_REGION }}" test -n "${{ secrets.AWS_ACCOUNT_ID }}" test -n "${{ secrets.ECR_REPOSITORY }}" + test -n "${{ secrets.DEPLOY_SSH_KEY }}" - name: Build backend production image run: | @@ -33,7 +34,6 @@ jobs: --load \ --file backend/Dockerfile \ --tag ${IMAGE_NAME}:prod-${GITHUB_SHA} \ - --tag ${IMAGE_NAME}:prod-latest \ . - name: Check image size budget @@ -88,7 +88,81 @@ jobs: aws ecr get-login-password --region "${AWS_REGION}" \ | docker login --username AWS --password-stdin "${ecr_registry}" - docker tag "${IMAGE_NAME}:prod-${GITHUB_SHA}" "${ecr_image}:${GITHUB_SHA}" docker tag "${IMAGE_NAME}:prod-${GITHUB_SHA}" "${ecr_image}:latest" - docker push "${ecr_image}:${GITHUB_SHA}" + + image_ids="$(aws ecr list-images \ + --region "${AWS_REGION}" \ + --repository-name "${ECR_REPOSITORY}" \ + --query 'imageIds[*]' \ + --output json)" + if [ "${image_ids}" != "[]" ]; then + aws ecr batch-delete-image \ + --region "${AWS_REGION}" \ + --repository-name "${ECR_REPOSITORY}" \ + --image-ids "${image_ids}" >/dev/null + fi + docker push "${ecr_image}:latest" + + deploy-production: + needs: build-backend-image + runs-on: wsl2-docker-host + steps: + - name: Validate deploy configuration + run: | + set -euo pipefail + test -n "${{ secrets.DEPLOY_SSH_KEY }}" + test -n "${{ secrets.DEPLOY_HOST }}" + test -n "${{ secrets.DEPLOY_USER }}" + test -n "${{ secrets.AWS_ACCESS_KEY_ID }}" + test -n "${{ secrets.AWS_SECRET_ACCESS_KEY }}" + test -n "${{ secrets.AWS_REGION }}" + + - name: Deploy production server + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + AWS_DEFAULT_REGION: ${{ secrets.AWS_REGION }} + AWS_REGION: ${{ secrets.AWS_REGION }} + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + run: | + set -euo pipefail + + install -m 700 -d ~/.ssh + printf '%s\n' '${{ secrets.DEPLOY_SSH_KEY }}' > ~/.ssh/eryao_deploy_key + chmod 600 ~/.ssh/eryao_deploy_key + ssh-keyscan -H "${DEPLOY_HOST}" >> ~/.ssh/known_hosts + + ssh -i ~/.ssh/eryao_deploy_key \ + -o IdentitiesOnly=yes \ + "${DEPLOY_USER}@${DEPLOY_HOST}" \ + "AWS_ACCESS_KEY_ID='${AWS_ACCESS_KEY_ID}' AWS_SECRET_ACCESS_KEY='${AWS_SECRET_ACCESS_KEY}' AWS_DEFAULT_REGION='${AWS_REGION}' AWS_REGION='${AWS_REGION}' bash -se" <<'REMOTE' + set -euo pipefail + + cd ~/deploy + set -a + . ./.env + set +a + + ecr_registry="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + aws ecr get-login-password --region "${AWS_REGION}" \ + | sudo docker login --username AWS --password-stdin "${ecr_registry}" + + sudo docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers pull + sudo docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers up -d --remove-orphans + + for attempt in $(seq 1 12); do + if curl -fsS "http://127.0.0.1:${ERYAO_WEB__PORT:-5775}/health"; then + break + fi + if [ "${attempt}" -eq 12 ]; then + sudo docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers ps + sudo docker logs --tail 200 eryao-prod-backend || true + exit 1 + fi + sleep 5 + done + + sudo docker image prune -af --filter "until=168h" + REMOTE diff --git a/.gitignore b/.gitignore index e1fd418..b6898b1 100644 --- a/.gitignore +++ b/.gitignore @@ -137,6 +137,11 @@ ENV/ env.bak/ venv.bak/ +# Local deployment secrets +*.pem +deploy_eryao_ci +deploy_eryao_ci.pub + # Spyder project settings .spyderproject .spyproject diff --git a/deploy/README.md b/deploy/README.md index 5811027..a637e7a 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -151,7 +151,30 @@ docker logs -f eryao-prod-redis ## 更新版本 -CI 推送新镜像到 ECR 后,在生产机器执行: +当前 CI/CD 会在 `main` 分支构建后自动部署到生产机器: + +- 推送前清空 ECR 仓库旧镜像,只保留新推送的 `latest`。 +- 通过 `DEPLOY_SSH_KEY` 登录生产机器。 +- 在生产机器执行 ECR 登录、`docker compose pull`、`docker compose up -d`。 +- 健康检查通过后清理 7 天前未使用的本地 Docker 镜像。 + +Gitea Secrets 需要包含: + +```text +AWS_ACCESS_KEY_ID +AWS_SECRET_ACCESS_KEY +AWS_REGION +AWS_ACCOUNT_ID +ECR_REPOSITORY +DEPLOY_SSH_KEY +DEPLOY_HOST +DEPLOY_USER +``` + +`DEPLOY_SSH_KEY` 是已加入生产机器 `ubuntu` 用户 `~/.ssh/authorized_keys` 的部署专用私钥。 +当前生产机器对应:`DEPLOY_HOST=18.218.38.213`,`DEPLOY_USER=ubuntu`。 + +如需手动更新,在生产机器执行: ```bash cd deploy From 98f4a8d07aff9f13785b51635d7d16ede0ecccc0 Mon Sep 17 00:00:00 2001 From: qzl Date: Thu, 30 Apr 2026 11:07:57 +0800 Subject: [PATCH 4/4] fix: update production app configuration --- .env.example | 14 ++------ apps/README.md | 15 +++++---- apps/assets/legal/en/about_us.md | 2 +- apps/assets/legal/en/privacy_policy.md | 4 +-- apps/assets/legal/en/terms_of_service.md | 2 +- apps/assets/legal/zh/about_us.md | 2 +- apps/assets/legal/zh/privacy_policy.md | 4 +-- apps/assets/legal/zh/terms_of_service.md | 2 +- apps/assets/legal/zh_Hant/about_us.md | 2 +- apps/assets/legal/zh_Hant/privacy_policy.md | 4 +-- apps/assets/legal/zh_Hant/terms_of_service.md | 2 +- apps/lib/core/config/env.dart | 12 ++----- backend/src/core/agentscope/runtime/tasks.py | 32 ++++++++++++------- backend/src/core/config/settings.py | 10 +----- backend/src/v1/agent/dependencies.py | 2 +- backend/src/v1/agent/router.py | 2 +- backend/src/v1/payments/apple_verifier.py | 12 ------- backend/src/v1/payments/service.py | 2 -- .../unit/payments/test_payment_service.py | 28 ++++++++++++++-- docs/protocols/common/http-error-codes.md | 1 - docs/protocols/payments/apple-iap-protocol.md | 10 +++++- 21 files changed, 84 insertions(+), 80 deletions(-) diff --git a/.env.example b/.env.example index 9cdf0f7..24bf2de 100644 --- a/.env.example +++ b/.env.example @@ -91,12 +91,6 @@ ERYAO_LLM__PROVIDER_KEYS__DEEPSEEK= ERYAO_POINTS_POLICY__REGISTER_BONUS_POINTS=60 ERYAO_POINTS_POLICY__REGISTER_BONUS_HMAC_KEY=replace-with-strong-random-key -############ -# 敏感词配置 -############ -ERYAO_SENSITIVE_WORD__USE_ALIYUN=true -ERYAO_SENSITIVE_WORD__FALLBACK_TO_LOCAL=true - ############ # CORS 配置 ############ @@ -112,13 +106,9 @@ ERYAO_TEST__CODE=123456 # Apple IAP 配置 ############ ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen +# Apple IAP 环境识别。auto 表示以后端验签后的 Apple transaction environment 为准。 +ERYAO_APPLE_IAP__ENVIRONMENT=auto # Server API 密钥(可选,用于主动查询交易状态) ERYAO_APPLE_IAP__SERVER_API_KEY_ID= ERYAO_APPLE_IAP__SERVER_API_PRIVATE_KEY= ERYAO_APPLE_IAP__SERVER_API_ISSUER_ID= -# 沙盒测试账号(仅用于手动测试,不用于后端验证) -ERYAO_APPLE_IAP__SANDBOX_TESTER_EMAIL= -ERYAO_APPLE_IAP__SANDBOX_TESTER_PASSWORD= -# Server Notifications V2 URL(在 App Store Connect 中配置) -# 格式: https:///api/v1/payments/apple/notifications -ERYAO_APPLE_IAP__SERVER_NOTIFICATIONS_URL= diff --git a/apps/README.md b/apps/README.md index a032a7f..fbbd671 100644 --- a/apps/README.md +++ b/apps/README.md @@ -2,9 +2,15 @@ Flutter client for `觅爻签问`. -## Debug startup with backend injection +## Backend URL -This app supports injecting backend URL at startup (same pattern as social-app): +Default backend URL: + +```text +https://api.meeyao.com +``` + +This app also supports injecting backend URL at startup (same pattern as social-app): - Dart read path: `lib/core/config/env.dart` - Injection key: `BACKEND_URL` @@ -21,7 +27,4 @@ flutter run --dart-define=BACKEND_URL=http://192.168.1.100:5775 ./tool/run-dev.sh --backend-url http://192.168.1.100:5775 ``` -If `BACKEND_URL` is not provided, fallback is: - -- Android emulator: `http://10.0.2.2:5775` -- Others: `http://localhost:5775` +If `BACKEND_URL` is not provided, the app uses the production backend URL above. diff --git a/apps/assets/legal/en/about_us.md b/apps/assets/legal/en/about_us.md index da7bf64..af65574 100644 --- a/apps/assets/legal/en/about_us.md +++ b/apps/assets/legal/en/about_us.md @@ -12,7 +12,7 @@ MeeYao Divination is designed based on traditional oriental culture. Our core go **Developer:** Ann Lee -**Contact Email:** ann@xumee.com +**Contact Email:** ann@xunmee.com --- diff --git a/apps/assets/legal/en/privacy_policy.md b/apps/assets/legal/en/privacy_policy.md index bf93fb2..7d0a817 100644 --- a/apps/assets/legal/en/privacy_policy.md +++ b/apps/assets/legal/en/privacy_policy.md @@ -116,7 +116,7 @@ In accordance with CCPA/CPRA and U.S. local privacy laws, you enjoy the followin You can submit data requests through the only dedicated contact method: -- **Contact Email**: ann@xumee.com +- **Contact Email**: ann@xunmee.com I will respond to your legitimate request within 45 days, and properly verify your identity to ensure data security before processing. @@ -152,7 +152,7 @@ This Privacy Policy may be updated irregularly to adapt to platform rules and le If you have any questions, suggestions or privacy-related complaints about this Privacy Policy, please contact me: -**Developer Email**: ann@xumee.com +**Developer Email**: ann@xunmee.com If you are a California resident and dissatisfied with the processing result, you can consult the local privacy regulatory authority. diff --git a/apps/assets/legal/en/terms_of_service.md b/apps/assets/legal/en/terms_of_service.md index e18bf46..634c97b 100644 --- a/apps/assets/legal/en/terms_of_service.md +++ b/apps/assets/legal/en/terms_of_service.md @@ -118,4 +118,4 @@ I reserve the right to revise and update these Terms of Service at any time. Mat If you have questions, feedback or legal inquiries about these Terms, please contact: - **Developer**: Individual Independent Developer -- **Contact Email**: ann@xumee.com +- **Contact Email**: ann@xunmee.com diff --git a/apps/assets/legal/zh/about_us.md b/apps/assets/legal/zh/about_us.md index f8c1d46..01b3acf 100644 --- a/apps/assets/legal/zh/about_us.md +++ b/apps/assets/legal/zh/about_us.md @@ -12,7 +12,7 @@ **开发者**:Ann Lee -**联系邮箱**:ann@xumee.com +**联系邮箱**:ann@xunmee.com --- diff --git a/apps/assets/legal/zh/privacy_policy.md b/apps/assets/legal/zh/privacy_policy.md index 500a059..9094c48 100644 --- a/apps/assets/legal/zh/privacy_policy.md +++ b/apps/assets/legal/zh/privacy_policy.md @@ -116,7 +116,7 @@ 您可以通过唯一指定联系方式提交数据请求: -- **联系邮箱**:ann@xumee.com +- **联系邮箱**:ann@xunmee.com 我将在 45 天内回复您的合法请求,并在处理前妥善验证您的身份以确保数据安全。 @@ -152,7 +152,7 @@ 如果您对本隐私政策有任何疑问、建议或隐私相关投诉,请联系我: -**开发者邮箱**:ann@xumee.com +**开发者邮箱**:ann@xunmee.com 如果您是加州居民且对处理结果不满意,可咨询当地隐私监管机构。 diff --git a/apps/assets/legal/zh/terms_of_service.md b/apps/assets/legal/zh/terms_of_service.md index d503c04..696bd11 100644 --- a/apps/assets/legal/zh/terms_of_service.md +++ b/apps/assets/legal/zh/terms_of_service.md @@ -118,4 +118,4 @@ 如果您对本条款有疑问、反馈或法律咨询,请联系: - **开发者**:独立个人开发者 -- **联系邮箱**:ann@xumee.com +- **联系邮箱**:ann@xunmee.com diff --git a/apps/assets/legal/zh_Hant/about_us.md b/apps/assets/legal/zh_Hant/about_us.md index 15e8453..5b36c43 100644 --- a/apps/assets/legal/zh_Hant/about_us.md +++ b/apps/assets/legal/zh_Hant/about_us.md @@ -12,7 +12,7 @@ **開發者**:Ann Lee -**聯繫郵箱**:ann@xumee.com +**聯繫郵箱**:ann@xunmee.com --- diff --git a/apps/assets/legal/zh_Hant/privacy_policy.md b/apps/assets/legal/zh_Hant/privacy_policy.md index 33ca2d0..92e15fa 100644 --- a/apps/assets/legal/zh_Hant/privacy_policy.md +++ b/apps/assets/legal/zh_Hant/privacy_policy.md @@ -116,7 +116,7 @@ 您可以通過唯一指定聯繫方式提交數據請求: -- **聯繫郵箱**:ann@xumee.com +- **聯繫郵箱**:ann@xunmee.com 我將在 45 天內回覆您的合法請求,並在處理前妥善驗證您的身份以確保數據安全。 @@ -152,7 +152,7 @@ 如果您對本隱私政策有任何疑問、建議或隱私相關投訴,請聯繫我: -**開發者郵箱**:ann@xumee.com +**開發者郵箱**:ann@xunmee.com 如果您是加州居民且對處理結果不滿意,可諮詢當地隱私監管機構。 diff --git a/apps/assets/legal/zh_Hant/terms_of_service.md b/apps/assets/legal/zh_Hant/terms_of_service.md index 4b3a20d..3cd3393 100644 --- a/apps/assets/legal/zh_Hant/terms_of_service.md +++ b/apps/assets/legal/zh_Hant/terms_of_service.md @@ -118,4 +118,4 @@ 如果您對本條款有疑問、反饋或法律諮詢,請聯繫: - **開發者**:獨立個人開發者 -- **聯繫郵箱**:ann@xumee.com +- **聯繫郵箱**:ann@xunmee.com diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index 27c0d9c..c9a7b8d 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -1,19 +1,13 @@ -import 'dart:io'; - class Env { + static const _productionBackendUrl = 'https://api.meeyao.com'; + static String get backendUrl { final injected = const String.fromEnvironment('BACKEND_URL'); if (injected.isNotEmpty && injected != 'false') { return injected; } - if (Platform.isAndroid) { - return 'http://10.0.2.2:5775'; - } - if (Platform.isIOS) { - return 'http://192.168.1.63:5775'; - } - return 'http://localhost:5775'; + return _productionBackendUrl; } static Future init() async {} diff --git a/backend/src/core/agentscope/runtime/tasks.py b/backend/src/core/agentscope/runtime/tasks.py index e398b61..d47051b 100644 --- a/backend/src/core/agentscope/runtime/tasks.py +++ b/backend/src/core/agentscope/runtime/tasks.py @@ -32,6 +32,22 @@ _MAX_CONTEXT_ATTACHMENTS = 3 _RUNTIME_AGENT_OUTPUT_ADAPTER = TypeAdapter(RuntimeAgentOutput) +def create_context_messages_cache() -> Any: + from core.agentscope.caches.context_messages_cache import ( + create_context_messages_cache as factory, + ) + + return factory() + + +def create_attachment_content_cache() -> Any: + from core.agentscope.caches.attachment_content_cache import ( + create_attachment_content_cache as factory, + ) + + return factory() + + def _serialize_tool_agent_output( *, metadata: AgentChatMessageMetadata | None, @@ -203,12 +219,6 @@ async def _build_recent_context_messages( context_config: "MessageContextConfig", ) -> list[Msg]: from agentscope.message import Msg - from core.agentscope.caches.attachment_content_cache import ( - create_attachment_content_cache, - ) - from core.agentscope.caches.context_messages_cache import ( - create_context_messages_cache, - ) from core.agentscope.services.context_service import AgentContextService from services.base.supabase import supabase_service from v1.agent.repository import AgentRepository @@ -348,12 +358,10 @@ async def _build_recent_context_messages( async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]: - from core.agentscope.events import ( - AgentScopeAgUiCodec, - AgentScopeEventPipeline, - RedisStreamBus, - SqlAlchemyEventStore, - ) + from core.agentscope.events.agui_codec import AgentScopeAgUiCodec + from core.agentscope.events.pipeline import AgentScopeEventPipeline + from core.agentscope.events.redis_bus import RedisStreamBus + from core.agentscope.events.store import SqlAlchemyEventStore from core.config.settings import config from core.db.session import AsyncSessionLocal from services.base.redis import get_or_init_redis_client diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 7c945c5..ed237fb 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -183,11 +183,6 @@ class DatabaseSettings(BaseModel): ) -class SensitiveWordSettings(BaseModel): - use_aliyun: bool = True - fallback_to_local: bool = True - - class TestSettings(BaseModel): email: str = "" code: str = "" @@ -232,12 +227,10 @@ class AppleIapSettings(BaseModel): bundle_id: str = Field(default="com.meeyao.qianwen", min_length=1) root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer" jws_x5c_cert_url: str = "https://api.storekit.itunes.apple.com/v1/verificationKeys" + environment: Literal["auto", "sandbox", "production"] = "auto" server_api_issuer_id: str | None = None server_api_key_id: str | None = None server_api_private_key: SecretStr | None = None - sandbox_tester_email: str | None = None - sandbox_tester_password: SecretStr | None = None - server_notifications_url: str | None = None def _resolve_env_files() -> list[str]: @@ -283,7 +276,6 @@ class Settings(BaseSettings): storage: StorageSettings = StorageSettings() llm: LlmSettings = LlmSettings() database: DatabaseSettings = DatabaseSettings() - sensitive_word: SensitiveWordSettings = Field(default_factory=SensitiveWordSettings) test: TestSettings = Field(default_factory=TestSettings) taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings) agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings) diff --git a/backend/src/v1/agent/dependencies.py b/backend/src/v1/agent/dependencies.py index 693074c..7fd516f 100644 --- a/backend/src/v1/agent/dependencies.py +++ b/backend/src/v1/agent/dependencies.py @@ -9,7 +9,7 @@ from fastapi import Depends from redis.asyncio import Redis from sqlalchemy.ext.asyncio import AsyncSession -from core.agentscope.events import RedisStreamBus +from core.agentscope.events.redis_bus import RedisStreamBus from core.agentscope.tools.tool_result_storage import ( create_tool_result_storage, ) diff --git a/backend/src/v1/agent/router.py b/backend/src/v1/agent/router.py index f5a4b2a..e6cd7aa 100644 --- a/backend/src/v1/agent/router.py +++ b/backend/src/v1/agent/router.py @@ -9,7 +9,7 @@ from typing import Annotated from ag_ui.core import RunAgentInput from core.http.errors import ApiProblemError, problem_payload -from core.agentscope.events import to_sse_event +from core.agentscope.events.sse import to_sse_event from core.agentscope.schemas.agui_input import ( parse_run_input, validate_run_request_messages_contract, diff --git a/backend/src/v1/payments/apple_verifier.py b/backend/src/v1/payments/apple_verifier.py index b7ffca0..1858705 100644 --- a/backend/src/v1/payments/apple_verifier.py +++ b/backend/src/v1/payments/apple_verifier.py @@ -47,7 +47,6 @@ class AppleJwsVerifier: *, expected_bundle_id: str, expected_product_id: str, - expected_environment: str, ) -> VerifiedTransaction | VerificationError: try: unverified_header = jwt.get_unverified_header(signed_transaction_info) @@ -148,17 +147,6 @@ class AppleJwsVerifier: detail=f"Invalid environment: {environment}", ) - if environment != expected_environment: - logger.error( - "Environment mismatch: expected=%s got=%s", - expected_environment, - environment, - ) - return VerificationError( - code="PAYMENT_ENVIRONMENT_MISMATCH", - detail=f"Environment mismatch: expected={expected_environment} got={environment}", - ) - revocation_date_raw = payload.get("revocationDate") revocation_date: int | None = ( int(revocation_date_raw) if revocation_date_raw is not None else None diff --git a/backend/src/v1/payments/service.py b/backend/src/v1/payments/service.py index 2a48ff4..ec8a99e 100644 --- a/backend/src/v1/payments/service.py +++ b/backend/src/v1/payments/service.py @@ -114,12 +114,10 @@ class PaymentService: ) expected_bundle_id = config.apple_iap.bundle_id - expected_environment = "Sandbox" if config.runtime.environment != "prod" else "Production" result = self._verifier.verify_signed_transaction( request.signed_transaction_info, expected_bundle_id=expected_bundle_id, expected_product_id=product_mapping.app_store_product_id, - expected_environment=expected_environment, ) if isinstance(result, VerificationError): diff --git a/backend/tests/unit/payments/test_payment_service.py b/backend/tests/unit/payments/test_payment_service.py index a25c8fa..d2488e4 100644 --- a/backend/tests/unit/payments/test_payment_service.py +++ b/backend/tests/unit/payments/test_payment_service.py @@ -82,10 +82,8 @@ class _FakeVerifier: *, expected_bundle_id: str, expected_product_id: str, - expected_environment: str, ) -> VerifiedTransaction | VerificationError: del signed_transaction_info, expected_bundle_id, expected_product_id - del expected_environment return self._result @@ -290,6 +288,32 @@ class TestPaymentServiceSuccessfulGrant: assert len(points_repo.appended_ledger) == 1 assert len(payment_repo.inserted_transactions) == 1 + @pytest.mark.asyncio + async def test_grants_production_transaction_from_verified_payload(self) -> None: + payment_repo = _FakePaymentRepository() + service = PaymentService( + payment_repo=payment_repo, + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier( + result=_make_verified_transaction(environment="Production") + ), + ) + request = VerifyTransactionRequest( + productCode="starter_pack", + appStoreProductId="com.meeyao.qianwen.starter_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + + result = await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + + assert result.status == "granted" + assert payment_repo.inserted_transactions[0].environment == "Production" + class TestPaymentServiceStarterPackIneligible: @pytest.mark.asyncio diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index 1a8bae7..a276165 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -108,7 +108,6 @@ This document is the source of truth for backend RFC7807 `code` values consumed |---|---:|---|---| | `PAYMENT_PRODUCT_NOT_FOUND` | 404 | `productCode` does not exist or is not enabled | Refresh packages and show product-unavailable message | | `PAYMENT_PRODUCT_MISMATCH` | 422 | Client product ID does not match backend/Apple verification result | Block grant and prompt retry | -| `PAYMENT_ENVIRONMENT_MISMATCH` | 422 | Transaction environment (Sandbox/Production) does not match server environment | Show purchase-verification-failed message | | `PAYMENT_TRANSACTION_INVALID` | 422 | Apple signed transaction invalid, signature verification failed, or payload malformed | Show purchase-verification-failed message | | `PAYMENT_TRANSACTION_REVOKED` | 409 | Transaction has been revoked or refunded, grant not allowed | Show purchase-unavailable message | | `PAYMENT_TRANSACTION_CONFLICT` | 409 | Transaction already processed by another user or in conflicting state | Prompt to contact support or refresh balance | diff --git a/docs/protocols/payments/apple-iap-protocol.md b/docs/protocols/payments/apple-iap-protocol.md index ee488d2..b847326 100644 --- a/docs/protocols/payments/apple-iap-protocol.md +++ b/docs/protocols/payments/apple-iap-protocol.md @@ -14,6 +14,7 @@ Protocol verification status: - Current strategy: additive evolution (`backward-compatible`). - Breaking change requires explicit migration + rollback notes (`requires-migration`). +- Apple IAP environment is auto-detected from verified Apple transaction payloads; the verify API is not split by Sandbox/Production. ## Route overview @@ -70,7 +71,6 @@ Verify and grant credits for an Apple IAP transaction. |---|---:|---| | `PAYMENT_PRODUCT_NOT_FOUND` | 404 | `productCode` does not exist or is not enabled | | `PAYMENT_PRODUCT_MISMATCH` | 422 | Client product ID does not match backend/Apple verification result | -| `PAYMENT_ENVIRONMENT_MISMATCH` | 422 | Transaction environment (Sandbox/Production) does not match server environment | | `PAYMENT_TRANSACTION_INVALID` | 422 | Apple signed transaction invalid, signature verification failed, or payload malformed | | `PAYMENT_TRANSACTION_REVOKED` | 409 | Transaction has been revoked or refunded, grant not allowed | | `PAYMENT_TRANSACTION_CONFLICT` | 409 | Transaction already processed by another user or in conflicting state | @@ -119,6 +119,14 @@ product_mappings: - Backend tracks purchase via `register_bonus_claims.has_purchased_starter_pack` - If already purchased, returns `PAYMENT_STARTER_PACK_INELIGIBLE` (409) +## Environment handling + +- `signedTransactionInfo` is verified server-side and its Apple-provided `environment` field is the source of truth. +- Valid environments are `Sandbox` and `Production`. +- TestFlight and App Review Sandbox transactions are accepted when Apple signs them as `Sandbox`. +- App Store Production transactions are accepted when Apple signs them as `Production`. +- Backend configuration defaults to `apple_iap.environment=auto`; it must not force all verifications into one Apple environment. + ## Ledger integration - Successful purchases create a ledger entry with: