Deploy production CI/CD automation #3

Merged
qzl merged 4 commits from dev into main 2026-04-30 11:08:38 +08:00
36 changed files with 320 additions and 152 deletions
+2 -12
View File
@@ -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://<your-domain>/api/v1/payments/apple/notifications
ERYAO_APPLE_IAP__SERVER_NOTIFICATIONS_URL=
+77 -3
View File
@@ -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
+5
View File
@@ -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
+9 -6
View File
@@ -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.
+1 -1
View File
@@ -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
---
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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
+1 -1
View File
@@ -12,7 +12,7 @@
**开发者**Ann Lee
**联系邮箱**ann@xumee.com
**联系邮箱**ann@xunmee.com
---
+3 -3
View File
@@ -8,7 +8,7 @@
## 引言
尊敬的用户,欢迎使用 爻 MeeYao(以下简称"本应用"),本应用由**个人开发者**("我")独立开发和运营。我致力于保护您的个人隐私,并遵守适用的美国联邦和州隐私法律,包括《加州消费者隐私法》(CCPA/CPRA)、《儿童在线隐私保护法》(COPPA)、CalOPPA 以及其他美国州隐私法规。
尊敬的用户,欢迎使用 爻 MeeYao(以下简称"本应用"),本应用由**个人开发者**("我")独立开发和运营。我致力于保护您的个人隐私,并遵守适用的美国联邦和州隐私法律,包括《加州消费者隐私法》(CCPA/CPRA)、《儿童在线隐私保护法》(COPPA)、CalOPPA 以及其他美国州隐私法规。
本隐私政策清晰说明:
@@ -116,7 +116,7 @@
您可以通过唯一指定联系方式提交数据请求:
- **联系邮箱**ann@xumee.com
- **联系邮箱**ann@xunmee.com
我将在 45 天内回复您的合法请求,并在处理前妥善验证您的身份以确保数据安全。
@@ -152,7 +152,7 @@
如果您对本隐私政策有任何疑问、建议或隐私相关投诉,请联系我:
**开发者邮箱**ann@xumee.com
**开发者邮箱**ann@xunmee.com
如果您是加州居民且对处理结果不满意,可咨询当地隐私监管机构。
+2 -2
View File
@@ -6,7 +6,7 @@
## 1. 条款接受
爻 MeeYao(以下简称"本应用")由**个人开发者**("我")独立开发、拥有和运营。
爻 MeeYao(以下简称"本应用")由**个人开发者**("我")独立开发、拥有和运营。
下载、安装、注册、访问或使用本应用,即表示您("您"或"用户")确认已阅读、理解并无条件同意受本服务条款("条款")及我的隐私政策约束。如果您不同意本条款,请勿使用本应用。
@@ -118,4 +118,4 @@
如果您对本条款有疑问、反馈或法律咨询,请联系:
- **开发者**:独立个人开发者
- **联系邮箱**ann@xumee.com
- **联系邮箱**ann@xunmee.com
+3 -3
View File
@@ -1,10 +1,10 @@
# 關於我們
歡迎使用 爻 MeeYao,一款依託 AI 技術、以傳統六爻文化與易經智慧為核心的傳統文化參考工具。
歡迎使用 爻 MeeYao,一款依託 AI 技術、以傳統六爻文化與易經智慧為核心的傳統文化參考工具。
六爻文化源自博大精深的易經哲學體系,承載著古人對於心念、時序與天地變化相生相融的傳統認知。結合卦象文化、五行理論及干支傳統人文理念,幫助用戶探索東方傳統文化內涵,獲得多元的生活參考視角。
爻 MeeYao 根植於東方傳統文脈,核心初衷是幫助用戶跳出固有思維局限,以更開闊的視角看待日常抉擇與生活狀態,保持理性平和的心態。我們希望借助現代 AI 技術,讓大眾更輕鬆地了解、感受與體驗中華傳統經典文化。
爻 MeeYao 根植於東方傳統文脈,核心初衷是幫助用戶跳出固有思維局限,以更開闊的視角看待日常抉擇與生活狀態,保持理性平和的心態。我們希望借助現代 AI 技術,讓大眾更輕鬆地了解、感受與體驗中華傳統經典文化。
---
@@ -12,7 +12,7 @@
**開發者**Ann Lee
**聯繫郵箱**ann@xumee.com
**聯繫郵箱**ann@xunmee.com
---
+3 -3
View File
@@ -8,7 +8,7 @@
## 引言
尊敬的用戶,歡迎使用 爻 MeeYao(以下簡稱「本應用」),本應用由**個人開發者**(「我」)獨立開發和運營。我致力於保護您的個人隱私,並遵守適用的美國聯邦和州隱私法律,包括《加州消費者隱私法》(CCPA/CPRA)、《兒童在線隱私保護法》(COPPA)、CalOPPA 以及其他美國州隱私法規。
尊敬的用戶,歡迎使用 爻 MeeYao(以下簡稱「本應用」),本應用由**個人開發者**(「我」)獨立開發和運營。我致力於保護您的個人隱私,並遵守適用的美國聯邦和州隱私法律,包括《加州消費者隱私法》(CCPA/CPRA)、《兒童在線隱私保護法》(COPPA)、CalOPPA 以及其他美國州隱私法規。
本隱私政策清晰說明:
@@ -116,7 +116,7 @@
您可以通過唯一指定聯繫方式提交數據請求:
- **聯繫郵箱**ann@xumee.com
- **聯繫郵箱**ann@xunmee.com
我將在 45 天內回覆您的合法請求,並在處理前妥善驗證您的身份以確保數據安全。
@@ -152,7 +152,7 @@
如果您對本隱私政策有任何疑問、建議或隱私相關投訴,請聯繫我:
**開發者郵箱**ann@xumee.com
**開發者郵箱**ann@xunmee.com
如果您是加州居民且對處理結果不滿意,可諮詢當地隱私監管機構。
@@ -6,7 +6,7 @@
## 1. 條款接受
爻 MeeYao(以下簡稱「本應用」)由**個人開發者**(「我」)獨立開發、擁有和運營。
爻 MeeYao(以下簡稱「本應用」)由**個人開發者**(「我」)獨立開發、擁有和運營。
下載、安裝、註冊、訪問或使用本應用,即表示您(「您」或「用戶」)確認已閱讀、理解並無條件同意受本服務條款(「條款」)及我的隱私政策約束。如果您不同意本條款,請勿使用本應用。
@@ -118,4 +118,4 @@
如果您對本條款有疑問、反饋或法律諮詢,請聯繫:
- **開發者**:獨立個人開發者
- **聯繫郵箱**ann@xumee.com
- **聯繫郵箱**ann@xunmee.com
+3 -9
View File
@@ -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<void> init() async {}
+3 -3
View File
@@ -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}")
+33 -6
View File
@@ -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}")
@@ -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):
@@ -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,
@@ -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
@@ -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):
@@ -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")
+41 -26
View File
@@ -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,18 +22,32 @@ 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
_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,
@@ -176,6 +172,8 @@ def _serialize_assistant_context_from_metadata(
def _load_runtime() -> type[Any]:
from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator
return AgentScopeRuntimeOrchestrator
@@ -186,6 +184,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 +218,11 @@ async def _build_recent_context_messages(
runtime_mode: RuntimeMode = RuntimeMode.CHAT,
context_config: "MessageContextConfig",
) -> list[Msg]:
from agentscope.message import Msg
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 +358,16 @@ async def _build_recent_context_messages(
async def run_agentscope_task(command: dict[str, Any]) -> dict[str, object]:
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
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 +500,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)
+1 -9
View File
@@ -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)
+1 -1
View File
@@ -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
+3 -3
View File
@@ -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()
+2 -2
View File
@@ -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,
)
@@ -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,
)
+1 -1
View File
@@ -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,
+15 -7
View File
@@ -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)
-12
View File
@@ -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
-2
View File
@@ -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):
@@ -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
+42 -2
View File
@@ -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,13 +146,35 @@ 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
```
## 更新版本
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
+1 -15
View File
@@ -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
@@ -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 |
@@ -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:
+1 -4
View File
@@ -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"