Merge dev into main: production deployment automation
Build production Docker image / build-backend-image (push) Waiting to run
Build production Docker image / deploy-production (push) Has been cancelled

Enable production rollout through Gitea Actions and include latest app configuration updates.
This commit was merged in pull request #3.
This commit is contained in:
qzl
2026-04-30 11:08:37 +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_POINTS=60
ERYAO_POINTS_POLICY__REGISTER_BONUS_HMAC_KEY=replace-with-strong-random-key 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 配置 # CORS 配置
############ ############
@@ -112,13 +106,9 @@ ERYAO_TEST__CODE=123456
# Apple IAP 配置 # Apple IAP 配置
############ ############
ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen
# Apple IAP 环境识别。auto 表示以后端验签后的 Apple transaction environment 为准。
ERYAO_APPLE_IAP__ENVIRONMENT=auto
# Server API 密钥(可选,用于主动查询交易状态) # Server API 密钥(可选,用于主动查询交易状态)
ERYAO_APPLE_IAP__SERVER_API_KEY_ID= ERYAO_APPLE_IAP__SERVER_API_KEY_ID=
ERYAO_APPLE_IAP__SERVER_API_PRIVATE_KEY= ERYAO_APPLE_IAP__SERVER_API_PRIVATE_KEY=
ERYAO_APPLE_IAP__SERVER_API_ISSUER_ID= 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_REGION }}"
test -n "${{ secrets.AWS_ACCOUNT_ID }}" test -n "${{ secrets.AWS_ACCOUNT_ID }}"
test -n "${{ secrets.ECR_REPOSITORY }}" test -n "${{ secrets.ECR_REPOSITORY }}"
test -n "${{ secrets.DEPLOY_SSH_KEY }}"
- name: Build backend production image - name: Build backend production image
run: | run: |
@@ -33,7 +34,6 @@ jobs:
--load \ --load \
--file backend/Dockerfile \ --file backend/Dockerfile \
--tag ${IMAGE_NAME}:prod-${GITHUB_SHA} \ --tag ${IMAGE_NAME}:prod-${GITHUB_SHA} \
--tag ${IMAGE_NAME}:prod-latest \
. .
- name: Check image size budget - name: Check image size budget
@@ -88,7 +88,81 @@ jobs:
aws ecr get-login-password --region "${AWS_REGION}" \ aws ecr get-login-password --region "${AWS_REGION}" \
| docker login --username AWS --password-stdin "${ecr_registry}" | 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 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" 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/ env.bak/
venv.bak/ venv.bak/
# Local deployment secrets
*.pem
deploy_eryao_ci
deploy_eryao_ci.pub
# Spyder project settings # Spyder project settings
.spyderproject .spyderproject
.spyproject .spyproject
+9 -6
View File
@@ -2,9 +2,15 @@
Flutter client for `觅爻签问`. 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` - Dart read path: `lib/core/config/env.dart`
- Injection key: `BACKEND_URL` - 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 ./tool/run-dev.sh --backend-url http://192.168.1.100:5775
``` ```
If `BACKEND_URL` is not provided, fallback is: If `BACKEND_URL` is not provided, the app uses the production backend URL above.
- Android emulator: `http://10.0.2.2:5775`
- Others: `http://localhost:5775`
+1 -1
View File
@@ -12,7 +12,7 @@ MeeYao Divination is designed based on traditional oriental culture. Our core go
**Developer:** Ann Lee **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: 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. 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: 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. 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: If you have questions, feedback or legal inquiries about these Terms, please contact:
- **Developer**: Individual Independent Developer - **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 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 天内回复您的合法请求,并在处理前妥善验证您的身份以确保数据安全。 我将在 45 天内回复您的合法请求,并在处理前妥善验证您的身份以确保数据安全。
@@ -152,7 +152,7 @@
如果您对本隐私政策有任何疑问、建议或隐私相关投诉,请联系我: 如果您对本隐私政策有任何疑问、建议或隐私相关投诉,请联系我:
**开发者邮箱**ann@xumee.com **开发者邮箱**ann@xunmee.com
如果您是加州居民且对处理结果不满意,可咨询当地隐私监管机构。 如果您是加州居民且对处理结果不满意,可咨询当地隐私监管机构。
+2 -2
View File
@@ -6,7 +6,7 @@
## 1. 条款接受 ## 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 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 天內回覆您的合法請求,並在處理前妥善驗證您的身份以確保數據安全。 我將在 45 天內回覆您的合法請求,並在處理前妥善驗證您的身份以確保數據安全。
@@ -152,7 +152,7 @@
如果您對本隱私政策有任何疑問、建議或隱私相關投訴,請聯繫我: 如果您對本隱私政策有任何疑問、建議或隱私相關投訴,請聯繫我:
**開發者郵箱**ann@xumee.com **開發者郵箱**ann@xunmee.com
如果您是加州居民且對處理結果不滿意,可諮詢當地隱私監管機構。 如果您是加州居民且對處理結果不滿意,可諮詢當地隱私監管機構。
@@ -6,7 +6,7 @@
## 1. 條款接受 ## 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 { class Env {
static const _productionBackendUrl = 'https://api.meeyao.com';
static String get backendUrl { static String get backendUrl {
final injected = const String.fromEnvironment('BACKEND_URL'); final injected = const String.fromEnvironment('BACKEND_URL');
if (injected.isNotEmpty && injected != 'false') { if (injected.isNotEmpty && injected != 'false') {
return injected; return injected;
} }
if (Platform.isAndroid) { return _productionBackendUrl;
return 'http://10.0.2.2:5775';
}
if (Platform.isIOS) {
return 'http://192.168.1.63:5775';
}
return 'http://localhost:5775';
} }
static Future<void> init() async {} 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 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")) 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 from v1.feedback.tasks import generate_daily_feedback_report
def main(): def main():
task = generate_daily_feedback_report.kiq() 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}") 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__ = [ __all__ = [
"AgentScopeAgUiCodec", "AgentScopeAgUiCodec",
"AgentScopeEventPipeline", "AgentScopeEventPipeline",
@@ -13,3 +7,36 @@ __all__ = [
"to_agui_wire_event", "to_agui_wire_event",
"to_sse_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 agentscope.message import Msg
from pydantic import BaseModel 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): class JsonReActAgent(ReActAgent):
@@ -1,17 +1,18 @@
from __future__ import annotations from __future__ import annotations
import asyncio 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 ag_ui.core.types import RunAgentInput
from agentscope.message import Msg
from openai import APIConnectionError from openai import APIConnectionError
from core.agentscope.runtime.runner import AgentScopeRunner
from core.agentscope.runtime.protocols import PipelineLike from core.agentscope.runtime.protocols import PipelineLike
from core.logging import get_logger from core.logging import get_logger
from schemas.agent.runtime_config import RuntimeConfig from schemas.agent.runtime_config import RuntimeConfig
from schemas.shared.user import UserContext from schemas.shared.user import UserContext
if TYPE_CHECKING:
from agentscope.message import Msg
logger = get_logger("core.agentscope.runtime.orchestrator") logger = get_logger("core.agentscope.runtime.orchestrator")
@@ -38,8 +39,12 @@ class AgentScopeRuntimeOrchestrator:
pipeline: PipelineLike, pipeline: PipelineLike,
runner: RunnerLike | None = None, runner: RunnerLike | None = None,
) -> None: ) -> None:
if runner is None:
from core.agentscope.runtime.runner import AgentScopeRunner
runner = AgentScopeRunner()
self._pipeline = pipeline self._pipeline = pipeline
self._runner = runner or AgentScopeRunner() self._runner = runner
async def run( async def run(
self, self,
@@ -22,7 +22,7 @@ from core.divination import derive_divination
from core.agentscope.runtime.json_react_agent import JsonReActAgent from core.agentscope.runtime.json_react_agent import JsonReActAgent
from core.agentscope.runtime.model_tracking import TrackingChatModel from core.agentscope.runtime.model_tracking import TrackingChatModel
from core.agentscope.runtime.stage_emitter import PipelineStageEmitter 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.agentscope.utils.json_finalize import finalize_json_response
from core.config.settings import config from core.config.settings import config
from core.db.session import AsyncSessionLocal from core.db.session import AsyncSessionLocal
@@ -5,7 +5,7 @@ from uuid import uuid4
from agentscope.message import Msg 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): 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 asyncio
import base64 import base64
import json import json
from typing import Any, cast from typing import TYPE_CHECKING, Any, cast
from uuid import UUID from uuid import UUID
from agentscope.message import Msg
from pydantic import TypeAdapter 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.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.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 ( from schemas.agent.forwarded_props import (
RuntimeMode, RuntimeMode,
parse_forwarded_props_runtime_mode, 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 UserContext
from schemas.shared.user import parse_profile_settings 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.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") logger = get_logger("core.agentscope.runtime.tasks")
_MAX_CONTEXT_ATTACHMENTS = 3 _MAX_CONTEXT_ATTACHMENTS = 3
_RUNTIME_AGENT_OUTPUT_ADAPTER = TypeAdapter(RuntimeAgentOutput) _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( def _serialize_tool_agent_output(
*, *,
metadata: AgentChatMessageMetadata | None, metadata: AgentChatMessageMetadata | None,
@@ -176,6 +172,8 @@ def _serialize_assistant_context_from_metadata(
def _load_runtime() -> type[Any]: def _load_runtime() -> type[Any]:
from core.agentscope.runtime.orchestrator import AgentScopeRuntimeOrchestrator
return AgentScopeRuntimeOrchestrator return AgentScopeRuntimeOrchestrator
@@ -186,6 +184,8 @@ async def _build_user_context(
session: Any, session: Any,
session_id: str, session_id: str,
) -> UserContext: ) -> UserContext:
from core.agentscope.caches import create_user_context_cache
cache = create_user_context_cache() cache = create_user_context_cache()
cached = await cache.get(session_id=UUID(session_id)) cached = await cache.get(session_id=UUID(session_id))
if cached: if cached:
@@ -218,6 +218,11 @@ async def _build_recent_context_messages(
runtime_mode: RuntimeMode = RuntimeMode.CHAT, runtime_mode: RuntimeMode = RuntimeMode.CHAT,
context_config: "MessageContextConfig", context_config: "MessageContextConfig",
) -> list[Msg]: ) -> 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() context_cache = create_context_messages_cache()
attachment_cache = create_attachment_content_cache() attachment_cache = create_attachment_content_cache()
raw_messages = await context_cache.get( 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]: 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() command_type = str(command.get("command", "run")).strip().lower()
raw_owner_id = command.get("owner_id") raw_owner_id = command.get("owner_id")
raw_owner_email = command.get("owner_email") 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) 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]: async def run_command_task_general(command: dict[str, object]) -> dict[str, object]:
return await run_agentscope_task(command) 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): class TestSettings(BaseModel):
email: str = "" email: str = ""
code: str = "" code: str = ""
@@ -232,12 +227,10 @@ class AppleIapSettings(BaseModel):
bundle_id: str = Field(default="com.meeyao.qianwen", min_length=1) bundle_id: str = Field(default="com.meeyao.qianwen", min_length=1)
root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer" root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer"
jws_x5c_cert_url: str = "https://api.storekit.itunes.apple.com/v1/verificationKeys" 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_issuer_id: str | None = None
server_api_key_id: str | None = None server_api_key_id: str | None = None
server_api_private_key: SecretStr | 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]: def _resolve_env_files() -> list[str]:
@@ -283,7 +276,6 @@ class Settings(BaseSettings):
storage: StorageSettings = StorageSettings() storage: StorageSettings = StorageSettings()
llm: LlmSettings = LlmSettings() llm: LlmSettings = LlmSettings()
database: DatabaseSettings = DatabaseSettings() database: DatabaseSettings = DatabaseSettings()
sensitive_word: SensitiveWordSettings = Field(default_factory=SensitiveWordSettings)
test: TestSettings = Field(default_factory=TestSettings) test: TestSettings = Field(default_factory=TestSettings)
taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings) taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings)
agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings) 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_agent_broker = _build_broker("agent")
worker_general_broker = _build_broker("general") worker_general_broker = worker_agent_broker
broker = worker_agent_broker broker = worker_agent_broker
+3 -3
View File
@@ -3,9 +3,6 @@ from __future__ import annotations
import asyncio import asyncio
from typing import Any from typing import Any
import dashscope
from dashscope.audio.asr import Recognition, RecognitionCallback
from core.config.settings import config from core.config.settings import config
from core.logging import get_logger from core.logging import get_logger
@@ -28,6 +25,9 @@ class AsrService:
async def transcribe_file(self, file_path: str, filename: str) -> str: async def transcribe_file(self, file_path: str, filename: str) -> str:
try: try:
import dashscope
from dashscope.audio.asr import Recognition, RecognitionCallback
dashscope.api_key = self._get_api_key() dashscope.api_key = self._get_api_key()
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
+2 -2
View File
@@ -9,7 +9,7 @@ from fastapi import Depends
from redis.asyncio import Redis from redis.asyncio import Redis
from sqlalchemy.ext.asyncio import AsyncSession 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 ( from core.agentscope.tools.tool_result_storage import (
create_tool_result_storage, create_tool_result_storage,
) )
@@ -48,7 +48,7 @@ class TaskiqQueueClient:
@staticmethod @staticmethod
def _select_queue_task(command: dict[str, object]) -> Any: 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_agent,
run_command_task_general, run_command_task_general,
) )
+1 -1
View File
@@ -9,7 +9,7 @@ from typing import Annotated
from ag_ui.core import RunAgentInput from ag_ui.core import RunAgentInput
from core.http.errors import ApiProblemError, problem_payload 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 ( from core.agentscope.schemas.agui_input import (
parse_run_input, parse_run_input,
validate_run_request_messages_contract, validate_run_request_messages_contract,
+15 -7
View File
@@ -5,15 +5,11 @@ from pathlib import Path
from sqlalchemy import select from sqlalchemy import select
from structlog import get_logger from structlog import get_logger
from taskiq_redis import RedisScheduleSource
from core.config.settings import config from core.config.settings import config
from core.db.session import AsyncSessionLocal from core.db.session import AsyncSessionLocal
from core.email.sender import EmailAttachment, EmailMessage, EmailSender from core.taskiq.app import worker_agent_broker
from core.email.template_loader import load_template
from core.taskiq.app import worker_general_broker
from models.user_feedback import UserFeedback from models.user_feedback import UserFeedback
from v1.feedback.report import generate_feedback_report
logger = get_logger("v1.feedback.tasks") logger = get_logger("v1.feedback.tasks")
@@ -52,6 +48,8 @@ def _build_report_email_html(
end_time: datetime, end_time: datetime,
push_hour: int, push_hour: int,
) -> str: ) -> str:
from core.email.template_loader import load_template
template = load_template("feedback", "daily_report.html") template = load_template("feedback", "daily_report.html")
return template.substitute( return template.substitute(
start_date=start_time.strftime("%Y-%m-%d"), start_date=start_time.strftime("%Y-%m-%d"),
@@ -70,6 +68,8 @@ def _build_no_feedback_email_html(
end_time: datetime, end_time: datetime,
push_hour: int, push_hour: int,
) -> str: ) -> str:
from core.email.template_loader import load_template
template = load_template("feedback", "no_feedback.html") template = load_template("feedback", "no_feedback.html")
return template.substitute( return template.substitute(
start_date=start_time.strftime("%Y-%m-%d"), start_date=start_time.strftime("%Y-%m-%d"),
@@ -87,6 +87,8 @@ async def _send_feedback_email(
push_hour: int, push_hour: int,
report_path: Path | None = None, report_path: Path | None = None,
) -> bool: ) -> bool:
from core.email.sender import EmailAttachment, EmailMessage, EmailSender
sender = EmailSender() sender = EmailSender()
if feedbacks: if feedbacks:
@@ -123,12 +125,14 @@ async def _send_feedback_email(
# type: ignore reportArgumentType for taskiq decorator # 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: async def _register_feedback_report_schedule() -> None:
if not config.feedback_report.enabled: if not config.feedback_report.enabled:
logger.info("Feedback report scheduling disabled") logger.info("Feedback report scheduling disabled")
return return
from taskiq_redis import RedisScheduleSource
schedule_source = RedisScheduleSource( schedule_source = RedisScheduleSource(
url=config.taskiq_broker_url, url=config.taskiq_broker_url,
prefix="schedule:feedback", 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: async def generate_daily_feedback_report() -> str | None:
if not config.feedback_report.enabled: if not config.feedback_report.enabled:
logger.info("Feedback report is disabled, skipping") logger.info("Feedback report is disabled, skipping")
return None return None
from v1.feedback.report import generate_feedback_report
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
push_hour = 10 push_hour = 10
end_time = now.replace(hour=push_hour, minute=0, second=0, microsecond=0) 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: async def generate_all_feedback_report() -> Path:
from v1.feedback.report import generate_feedback_report
async with AsyncSessionLocal() as session: async with AsyncSessionLocal() as session:
stmt = select(UserFeedback).order_by(UserFeedback.created_at.desc()) stmt = select(UserFeedback).order_by(UserFeedback.created_at.desc())
result = await session.execute(stmt) result = await session.execute(stmt)
-12
View File
@@ -47,7 +47,6 @@ class AppleJwsVerifier:
*, *,
expected_bundle_id: str, expected_bundle_id: str,
expected_product_id: str, expected_product_id: str,
expected_environment: str,
) -> VerifiedTransaction | VerificationError: ) -> VerifiedTransaction | VerificationError:
try: try:
unverified_header = jwt.get_unverified_header(signed_transaction_info) unverified_header = jwt.get_unverified_header(signed_transaction_info)
@@ -148,17 +147,6 @@ class AppleJwsVerifier:
detail=f"Invalid environment: {environment}", 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_raw = payload.get("revocationDate")
revocation_date: int | None = ( revocation_date: int | None = (
int(revocation_date_raw) if revocation_date_raw is not None else 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_bundle_id = config.apple_iap.bundle_id
expected_environment = "Sandbox" if config.runtime.environment != "prod" else "Production"
result = self._verifier.verify_signed_transaction( result = self._verifier.verify_signed_transaction(
request.signed_transaction_info, request.signed_transaction_info,
expected_bundle_id=expected_bundle_id, expected_bundle_id=expected_bundle_id,
expected_product_id=product_mapping.app_store_product_id, expected_product_id=product_mapping.app_store_product_id,
expected_environment=expected_environment,
) )
if isinstance(result, VerificationError): if isinstance(result, VerificationError):
@@ -82,10 +82,8 @@ class _FakeVerifier:
*, *,
expected_bundle_id: str, expected_bundle_id: str,
expected_product_id: str, expected_product_id: str,
expected_environment: str,
) -> VerifiedTransaction | VerificationError: ) -> VerifiedTransaction | VerificationError:
del signed_transaction_info, expected_bundle_id, expected_product_id del signed_transaction_info, expected_bundle_id, expected_product_id
del expected_environment
return self._result return self._result
@@ -290,6 +288,32 @@ class TestPaymentServiceSuccessfulGrant:
assert len(points_repo.appended_ledger) == 1 assert len(points_repo.appended_ledger) == 1
assert len(payment_repo.inserted_transactions) == 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: class TestPaymentServiceStarterPackIneligible:
@pytest.mark.asyncio @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 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 ## 登录 ECR
进入部署目录,并把 `.env` 加载到当前 shell 进入部署目录,并把 `.env` 加载到当前 shell
@@ -128,13 +146,35 @@ cd deploy
docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers ps docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers ps
docker logs -f eryao-prod-backend docker logs -f eryao-prod-backend
docker logs -f eryao-prod-worker-agent docker logs -f eryao-prod-worker-agent
docker logs -f eryao-prod-worker-general
docker logs -f eryao-prod-redis 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 ```bash
cd deploy cd deploy
+1 -15
View File
@@ -34,21 +34,7 @@ services:
command: command:
- sh - sh
- -c - -c
- exec taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks --workers ${ERYAO_WORKER__GROUPS__AGENT__CONCURRENCY:-2} - exec taskiq worker core.taskiq.app:worker_agent_broker core.agentscope.runtime.tasks v1.feedback.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}
redis: redis:
image: redis:7.4.2-alpine 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_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_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_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_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 | | `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`). - Current strategy: additive evolution (`backward-compatible`).
- Breaking change requires explicit migration + rollback notes (`requires-migration`). - 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 ## 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_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_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_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_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 | | `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` - Backend tracks purchase via `register_bonus_claims.has_purchased_starter_pack`
- If already purchased, returns `PAYMENT_STARTER_PACK_INELIGIBLE` (409) - 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 ## Ledger integration
- Successful purchases create a ledger entry with: - 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}" 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_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}"
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}"
echo "Starting tmux web process in session '$SESSION_NAME'..." 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-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-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 ""
echo "=== App Started ===" echo "=== App Started ==="
@@ -170,7 +168,6 @@ start() {
echo "Log files will be created in logs/ directory:" echo "Log files will be created in logs/ directory:"
echo " - web.log, web.error.log" echo " - web.log, web.error.log"
echo " - worker-agent.log, worker-agent.error.log" echo " - worker-agent.log, worker-agent.error.log"
echo " - worker-general.log, worker-general.error.log"
echo "" echo ""
echo "tmux attach -t $SESSION_NAME" echo "tmux attach -t $SESSION_NAME"
echo "tmux list-windows -t $SESSION_NAME" echo "tmux list-windows -t $SESSION_NAME"