diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..765adfe --- /dev/null +++ b/.dockerignore @@ -0,0 +1,21 @@ +.git +.gitea +.github +.trellis +.venv +.env +.env.* +__pycache__ +*.py[cod] +.pytest_cache +.ruff_cache +.mypy_cache +.pyright +logs +midscene_run +apps/.dart_tool +apps/build +apps/.pub +apps/.gradle +backend/.ruff_cache +infra/docker/supabase/volumes diff --git a/.env.example b/.env.example index 0ec5098..9cdf0f7 100644 --- a/.env.example +++ b/.env.example @@ -107,3 +107,18 @@ ERYAO_CORS__ALLOW_ORIGINS=["http://localhost", "http://localhost:3000"] ############ ERYAO_TEST__EMAIL=test@example.com ERYAO_TEST__CODE=123456 + +############ +# Apple IAP 配置 +############ +ERYAO_APPLE_IAP__BUNDLE_ID=com.meeyao.qianwen +# 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/.gitea/workflows/build-production-docker.yml b/.gitea/workflows/build-production-docker.yml new file mode 100644 index 0000000..264bbbb --- /dev/null +++ b/.gitea/workflows/build-production-docker.yml @@ -0,0 +1,94 @@ +name: Build production Docker image + +on: + push: + branches: + - main + workflow_dispatch: + +jobs: + build-backend-image: + runs-on: wsl2-docker-host + env: + IMAGE_NAME: eryao-backend + IMAGE_SIZE_LIMIT_BYTES: 500000000 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Validate ECR configuration + run: | + set -euo pipefail + test -n "${{ secrets.AWS_ACCESS_KEY_ID }}" + test -n "${{ secrets.AWS_SECRET_ACCESS_KEY }}" + test -n "${{ secrets.AWS_REGION }}" + test -n "${{ secrets.AWS_ACCOUNT_ID }}" + test -n "${{ secrets.ECR_REPOSITORY }}" + + - name: Build backend production image + run: | + set -euo pipefail + docker buildx build \ + --provenance=false \ + --load \ + --file backend/Dockerfile \ + --tag ${IMAGE_NAME}:prod-${GITHUB_SHA} \ + --tag ${IMAGE_NAME}:prod-latest \ + . + + - name: Check image size budget + run: | + set -euo pipefail + image_size_bytes="$(docker image inspect ${IMAGE_NAME}:prod-${GITHUB_SHA} --format '{{.Size}}')" + echo "Image size: ${image_size_bytes} bytes" + if [ "${image_size_bytes}" -gt "${IMAGE_SIZE_LIMIT_BYTES}" ]; then + echo "Image exceeds ${IMAGE_SIZE_LIMIT_BYTES} bytes" >&2 + exit 1 + fi + + - name: Smoke test backend image + run: | + set -euo pipefail + docker run --rm \ + -e ERYAO_RUNTIME__ENVIRONMENT=prod \ + -e ERYAO_SUPABASE__PUBLIC_URL=http://localhost:8001 \ + -e ERYAO_POINTS_POLICY__REGISTER_BONUS_HMAC_KEY=ci-smoke-test-key \ + --entrypoint python \ + ${IMAGE_NAME}:prod-${GITHUB_SHA} \ + -c "import app; print(app.app.title)" + + - name: Push backend image to ECR + 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 }} + AWS_ACCOUNT_ID: ${{ secrets.AWS_ACCOUNT_ID }} + ECR_REPOSITORY: ${{ secrets.ECR_REPOSITORY }} + run: | + set -euo pipefail + caller_account_id="$(aws sts get-caller-identity --query Account --output text)" + if [ "${caller_account_id}" != "${AWS_ACCOUNT_ID}" ]; then + echo "AWS_ACCOUNT_ID does not match caller identity" >&2 + exit 1 + fi + + ecr_registry="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" + ecr_image="${ecr_registry}/${ECR_REPOSITORY}" + + aws ecr describe-repositories \ + --region "${AWS_REGION}" \ + --repository-names "${ECR_REPOSITORY}" >/dev/null 2>&1 \ + || aws ecr create-repository \ + --region "${AWS_REGION}" \ + --repository-name "${ECR_REPOSITORY}" \ + --image-scanning-configuration scanOnPush=true \ + --encryption-configuration encryptionType=AES256 >/dev/null + + 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}" + docker push "${ecr_image}:latest" diff --git a/.gitignore b/.gitignore index 03e6949..49887dd 100644 --- a/.gitignore +++ b/.gitignore @@ -306,6 +306,7 @@ infra/docker/supabase/volumes/storage/ # OpenCode local config # .opencode/ is now tracked - see .opencode/.gitignore for exclusions +.opencode/opencode.json midscene_run/ # Local git worktrees diff --git a/.opencode/opencode.json b/.opencode/opencode.json.example similarity index 92% rename from .opencode/opencode.json rename to .opencode/opencode.json.example index d571d20..63f11c1 100644 --- a/.opencode/opencode.json +++ b/.opencode/opencode.json.example @@ -17,7 +17,7 @@ "supabase": { "type": "remote", "enabled": true, - "url": "http://localhost:8001/mcp" + "url": "" } } } diff --git a/.opencode/opencode.json.old b/.opencode/opencode.json.old deleted file mode 100644 index 8cd9d3c..0000000 --- a/.opencode/opencode.json.old +++ /dev/null @@ -1,20 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.json", - "mcp": { - "supabase": { - "type": "local", - "enabled": true, - "command": [ - "npx", - "-y", - "@aliyun-rds/supabase-mcp-server", - "--supabase-url", - "http://47.112.66.83", - "--supabase-anon-key", - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJhbm9uIiwiaWF0IjoxNzczMDI3NDE5LCJleHAiOjEzMjgzNjY3NDE5fQ.NVXDla5_nYPdcJk_81fc3k1UrnNTrNne_trMqt6Hg4g", - "--supabase-service-role-key", - "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJvbGUiOiJzZXJ2aWNlX3JvbGUiLCJpYXQiOjE3NzMwMjc0MTksImV4cCI6MTMyODM2Njc0MTl9.RzQBia-3QcjupsHnqaxgDWB7wnY9R7Ms9R8pMokyvLY" - ] - } - } -} diff --git a/.opencode/package-lock.json b/.opencode/package-lock.json new file mode 100644 index 0000000..28ed456 --- /dev/null +++ b/.opencode/package-lock.json @@ -0,0 +1,376 @@ +{ + "name": ".opencode", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "dependencies": { + "@opencode-ai/plugin": "1.14.22" + } + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", + "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz", + "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz", + "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz", + "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz", + "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz", + "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@opencode-ai/plugin": { + "version": "1.14.22", + "resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.14.22.tgz", + "integrity": "sha512-lJlukegf5ECEHm9Y0NxCjXNfUArpPSUHP6hc+M4VCJ3NFk8uzzVsIXAzPS9Hvf2ltzjEYD/ulCOTi6pleeZ6yw==", + "license": "MIT", + "dependencies": { + "@opencode-ai/sdk": "1.14.22", + "effect": "4.0.0-beta.48", + "zod": "4.1.8" + }, + "peerDependencies": { + "@opentui/core": ">=0.1.99", + "@opentui/solid": ">=0.1.99" + }, + "peerDependenciesMeta": { + "@opentui/core": { + "optional": true + }, + "@opentui/solid": { + "optional": true + } + } + }, + "node_modules/@opencode-ai/sdk": { + "version": "1.14.22", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.14.22.tgz", + "integrity": "sha512-1PjkrZRAwm9ocfTwOleP/e31HYtLVODb2E1hYTRHMmvF2rmAdCm7lztguYVkAPn/B6koGpFvhslTQH7j+38Fjw==", + "license": "MIT", + "dependencies": { + "cross-spawn": "7.0.6" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "optional": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/effect": { + "version": "4.0.0-beta.48", + "resolved": "https://registry.npmjs.org/effect/-/effect-4.0.0-beta.48.tgz", + "integrity": "sha512-MMAM/ZabuNdNmgXiin+BAanQXK7qM8mlt7nfXDoJ/Gn9V8i89JlCq+2N0AiWmqFLXjGLA0u3FjiOjSOYQk5uMw==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "fast-check": "^4.6.0", + "find-my-way-ts": "^0.1.6", + "ini": "^6.0.0", + "kubernetes-types": "^1.30.0", + "msgpackr": "^1.11.9", + "multipasta": "^0.2.7", + "toml": "^4.1.1", + "uuid": "^13.0.0", + "yaml": "^2.8.3" + } + }, + "node_modules/fast-check": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/fast-check/-/fast-check-4.7.0.tgz", + "integrity": "sha512-NsZRtqvSSoCP0HbNjUD+r1JH8zqZalyp6gLY9e7OYs7NK9b6AHOs2baBFeBG7bVNsuoukh89x2Yg3rPsul8ziQ==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^8.0.0" + }, + "engines": { + "node": ">=12.17.0" + } + }, + "node_modules/find-my-way-ts": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/find-my-way-ts/-/find-my-way-ts-0.1.6.tgz", + "integrity": "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA==", + "license": "MIT" + }, + "node_modules/ini": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ini/-/ini-6.0.0.tgz", + "integrity": "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ==", + "license": "ISC", + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/kubernetes-types": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/kubernetes-types/-/kubernetes-types-1.30.0.tgz", + "integrity": "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q==", + "license": "Apache-2.0" + }, + "node_modules/msgpackr": { + "version": "1.11.10", + "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.10.tgz", + "integrity": "sha512-iCZNq+HszvF+fC3anCm4nBmWEnbeIAfpDs6IStAEKhQ2YSgkjzVG2FF9XJqwwQh5bH3N9OUTUt4QwVN6MLMLtA==", + "license": "MIT", + "optionalDependencies": { + "msgpackr-extract": "^3.0.2" + } + }, + "node_modules/msgpackr-extract": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz", + "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==", + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "dependencies": { + "node-gyp-build-optional-packages": "5.2.2" + }, + "bin": { + "download-msgpackr-prebuilds": "bin/download-prebuilds.js" + }, + "optionalDependencies": { + "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", + "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" + } + }, + "node_modules/multipasta": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/multipasta/-/multipasta-0.2.7.tgz", + "integrity": "sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==", + "license": "MIT" + }, + "node_modules/node-gyp-build-optional-packages": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", + "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==", + "license": "MIT", + "optional": true, + "dependencies": { + "detect-libc": "^2.0.1" + }, + "bin": { + "node-gyp-build-optional-packages": "bin.js", + "node-gyp-build-optional-packages-optional": "optional.js", + "node-gyp-build-optional-packages-test": "build-test.js" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pure-rand": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-8.4.0.tgz", + "integrity": "sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/toml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/toml/-/toml-4.1.1.tgz", + "integrity": "sha512-EBJnVBr3dTXdA89WVFoAIPUqkBjxPMwRqsfuo1r240tKFHXv3zgca4+NJib/h6TyvGF7vOawz0jGuryJCdNHrw==", + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/yaml": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + }, + "node_modules/zod": { + "version": "4.1.8", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + } + } +} diff --git a/.trellis/spec/backend/database-guidelines.md b/.trellis/spec/backend/database-guidelines.md index a99ba1a..17d0cbc 100644 --- a/.trellis/spec/backend/database-guidelines.md +++ b/.trellis/spec/backend/database-guidelines.md @@ -9,10 +9,25 @@ This project uses: - **SQLAlchemy 2.0** with async support (`asyncpg` driver) - **Alembic** for schema migrations -- **Supabase Auth** as identity source -- **PostgreSQL** as primary database +- **Supabase Cloud** for Auth, Database, and Storage +- **PostgreSQL** as primary database (via Supabase connection pooler) - **Soft delete** pattern with `deleted_at` column +### Cloud Supabase Connection + +The project uses **Supabase Cloud** with connection pooling: + +```bash +# .env configuration +ERYAO_DATABASE__HOST=aws-1-us-east-2.pooler.supabase.com +ERYAO_DATABASE__PORT=5432 # Session pooler (IPv4 compatible) +ERYAO_DATABASE__NAME=postgres +ERYAO_DATABASE__USER=postgres. +ERYAO_DATABASE__PASSWORD= +``` + +**Note:** Direct database connection (port 5432 on `db..supabase.co`) requires IPv6 and is not suitable for most development environments. Use the connection pooler instead. + --- ## Query Patterns @@ -133,6 +148,8 @@ def downgrade() -> None: ./infra/scripts/dev-migrate.sh bootstrap ``` +**Note:** Cloud Supabase migrations may be slower than local due to network latency. Use appropriate timeouts for migration scripts. + **Alembic is the ONLY source of truth for schema changes.** --- diff --git a/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/IMPLEMENTATION_PLAN.md b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..959ac40 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/IMPLEMENTATION_PLAN.md @@ -0,0 +1,340 @@ +# iOS Apple Pay 落实计划 + +## 当前状态 + +### 已完成 +- [x] 协议文档更新(`user-points-chat-data-protocol.md`、`http-error-codes.md`) +- [x] PRD 退款扣回策略已明确 +- [x] 套餐配置 YAML 已正确命名(`new_user_pack`, `starter_pack` 等) +- [x] **Phase 1: 数据库与枚举**(2026-04-27 完成) +- [x] **Phase 2: 后端支付服务**(2026-04-27 完成) +- [x] **Phase 3: iOS / Flutter IAP 接入**(2026-04-27 完成) + +### 待实现 +- [ ] 联调与发布准备(Phase 4) + +--- + +## Phase 1: 数据库与枚举(后端基础)✅ 已完成 + +### 1.1 枚举扩展 ✅ + +**文件**: `backend/src/schemas/enums.py` + +- [x] `PointsChangeType` 新增 `PURCHASE`, `REFUND`,移除 `GRANT` +- [x] `PointsBizType` 新增 `PAYMENT` + +**文件**: `backend/src/schemas/domain/points.py` +- [x] 更新 `ApplyPointsChangeCommand` 验证逻辑,支持 `purchase/refund` +- [x] 移除 `GrantLedgerMetadata` + +### 1.2 数据库迁移 ✅ + +**迁移文件**: `backend/alembic/versions/20260427_0001_apple_iap_transactions.py` + +- [x] 创建 `apple_iap_transactions` 表(按 PRD 5.3 定义) +- [x] 更新 `points_ledger` check constraints: + - `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')` + - `biz_type is null or biz_type in ('chat', 'payment')` + - 更新 `ck_points_ledger_biz_binding` 支持 `purchase/refund` + - 更新 `ck_points_ledger_direction_by_change_type` 支持 `purchase(direction=1)` 和 `refund(direction=-1)` + - 新增 `ck_points_ledger_metadata_payment_shape` 和 `ck_points_ledger_metadata_refund_shape` + - 更新 `ck_points_ledger_metadata_adjust_shape`(`ticket_id` -> `reason`) +- [x] 更新 `points_audit_ledger` check constraints 同步变更 + +**模型文件**: +- [x] `backend/src/models/points_ledger.py` - 同步更新 SQLAlchemy CheckConstraint +- [x] `backend/src/models/apple_iap_transaction.py` - 新建模型 +- [x] `backend/src/models/__init__.py` - 导出新模型 + +### 1.3 验证 ✅ +- [x] 迁移已应用到数据库 +- [x] `apple_iap_transactions` 表已创建 +- [x] `points_ledger` 约束已更新 + +--- + +## Phase 2: 后端支付服务 ✅ 已完成 + +### 2.1 配置扩展 ✅ + +- [x] `backend/src/core/config/settings.py` - 新增 `AppleIapSettings` 和 `apple_iap` 配置项 +- [x] `backend/src/core/config/static/packages/mapping.yaml` - productCode -> App Store Product ID 映射 + +### 2.2 API Schemas ✅ + +- [x] `backend/src/v1/payments/schemas.py` - `VerifyTransactionRequest` / `VerifyTransactionResponse` +- [x] `backend/src/schemas/domain/points.py` - 新增 `PurchaseLedgerMetadata` + +### 2.3 Apple JWS 验签器 ✅ + +- [x] `backend/src/v1/payments/apple_verifier.py` + - JWS x5c 证书链验证(root fingerprint + issuer/subject chain) + - bundleId / productId / environment / revocationDate 验证 + - 返回 `VerifiedTransaction | VerificationError` + +### 2.4 支付数据仓库 ✅ + +- [x] `backend/src/v1/payments/repository.py` - `PaymentRepository` + - `get_or_create_user_points_for_update` + - `get_user_points_for_update` (for refund, no auto-create) + - `get_transaction_by_transaction_id` + - `insert_transaction` + - `get_register_bonus_claim` + - `upsert_register_bonus_claim_for_starter_pack` + +### 2.5 支付服务 ✅ + +- [x] `backend/src/v1/payments/service.py` - `PaymentService` + - `verify_and_grant`: productCode / appStoreProductId 校验, Apple JWS 验签, transaction_id 幂等, 新手包资格检查, 积分入账 + points_ledger + register_bonus_claims + - `process_refund`: 退款扣回积分, 余额不足时扣到 0 并标记 `refunded_insufficient`, 幂等处理 + - `handle_server_notification`: 解析 Apple Server Notifications V2, REFUND/REVOKE 触发退款, DID_RENEW 记录日志 + +### 2.6 API 路由 + 依赖注入 ✅ + +- [x] `backend/src/v1/payments/router.py` + - `POST /api/v1/payments/apple/transactions/verify` + - `POST /api/v1/payments/apple/notifications` +- [x] `backend/src/v1/payments/dependencies.py` - DI wiring +- [x] `backend/src/v1/router.py` - 注册 payments_router + +### 2.7 测试 ✅ + +- [x] `backend/tests/unit/payments/test_payment_service.py` - 16 个测试全部通过 + - 验证流程: product_not_found / product_mismatch / verification_failed / already_granted / transaction_conflict / successful_grant / starter_pack_ineligible / starter_pack_success + - 退款流程: refund_unknown / refund_not_granted / refund_sufficient_balance / refund_insufficient_balance / refund_idempotency + - 通知处理: notification_refund / notification_empty / notification_non_refund +- [x] `backend/tests/unit/payments/__init__.py` - verifier 基础测试 +- [x] `backend/tests/integration/payments/test_verify_flow.py` - 集成测试骨架 +- [x] basedpyright 类型检查通过(0 errors) +- [x] 所有模块 import 正常 + +### 未实现(后续迭代) + +- `GET /api/v1/payments/apple/transactions/{transactionId}` 查询接口 +- `apple_client.py` Apple Server API 主动查询客户端(可选) + +### 2.2 核心实现 + +#### 2.2.1 Apple JWS 验签器 + +**文件**: `backend/src/v1/payments/apple_verifier.py` + +职责: +- 下载/缓存 Apple 根证书链 +- 验证 JWS 签名 +- 解析 payload 并验证字段:`bundleId`, `productId`, `transactionId`, `environment`, `revocationDate` +- 返回结构化验证结果 + +#### 2.2.2 支付服务 + +**文件**: `backend/src/v1/payments/service.py` + +核心方法: +- `verify_and_grant(user_id, request) -> VerifyResponse` + - 1. 验证 productCode 存在且启用 + - 2. 验证 appStoreProductId 与映射匹配 + - 3. 调用 Apple verifier 验签 + - 4. 检查 transaction 幂等(已发放返回 `already_granted`) + - 5. 检查新手包资格 + - 6. 事务:创建/更新 `apple_iap_transactions` + 更新 `user_points` + 写入 `points_ledger` + 更新 `register_bonus_claims` + +- `process_refund(transaction_id) -> None` + - 1. 查询 `apple_iap_transactions` + - 2. 事务:扣减积分 + 写入 `refund` 流水 + 更新状态 + - 3. 余额不足时设置 `refunded_insufficient` 并告警 + +#### 2.2.3 API 接口 + +**文件**: `backend/src/v1/payments/router.py` + +``` +POST /api/v1/payments/apple/transactions/verify +POST /api/v1/payments/apple/notifications # App Store Server Notifications V2 +GET /api/v1/payments/apple/transactions/{transactionId} # 可选 +``` + +### 2.3 配置扩展 + +**文件**: `backend/src/core/config/settings.py` + +新增配置项: +```python +apple_iap: AppleIapSettings + +class AppleIapSettings: + bundle_id: str + root_cert_url: str = "https://www.apple.com/certificateauthority/AppleIncRootCertificate.cer" + jws_issuer_id: str | None = None # Server API (可选) + jws_key_id: str | None = None + jws_private_key: str | None = None +``` + +**文件**: `backend/src/core/config/static/packages/mapping.yaml` (新建) + +```yaml +product_mappings: + new_user_pack: + app_store_product_id: com.meeyao.qianwen.new_user_pack + credits: 60 + type: starter + starter_pack: + app_store_product_id: com.meeyao.qianwen.starter_pack + credits: 100 + type: regular + popular_pack: + app_store_product_id: com.meeyao.qianwen.popular_pack + credits: 210 + type: regular + premium_pack: + app_store_product_id: com.meeyao.qianwen.premium_pack + credits: 415 + type: regular +``` + +### 2.4 测试 + +**单元测试**: +- `backend/tests/unit/payments/test_apple_verifier.py` +- `backend/tests/unit/payments/test_payment_service.py` + +**集成测试**: +- `backend/tests/integration/payments/test_verify_flow.py` + +--- + +## Phase 3: iOS / Flutter 接入 ✅ 已完成 + +### 3.1 依赖添加 ✅ + +- [x] `apps/pubspec.yaml` — `in_app_purchase: ^3.2.3` + `in_app_purchase_storekit: ^0.4.8` + `crypto: ^3.0.7` + +### 3.2 后端配合变更 ✅ + +- [x] `backend/src/v1/points/schemas.py` — `PackageInfo` 新增 `appStoreProductId` 字段 +- [x] `backend/src/v1/points/service.py` — `PackageInfoResult` 新增 `app_store_product_id`,`get_available_packages` 从 mapping.yaml 加载映射 +- [x] `backend/src/v1/points/router.py` — 响应中包含 `appStoreProductId` + +### 3.3 前端目录结构 ✅ + +``` +apps/lib/features/payments/ +├── data/ +│ ├── apis/ +│ │ └── apple_payment_api.dart # 后端 verify 接口 +│ ├── models/ +│ │ └── apple_purchase_models.dart # VerifyTransactionRequest/Response +│ └── services/ +│ └── apple_iap_service.dart # StoreKit 集成服务 +``` + +### 3.4 核心实现 ✅ + +- [x] `apple_purchase_models.dart` — VerifyTransactionRequest(含 appAccountToken)/ VerifyTransactionResponse(含 newBalance/ledgerEventId) +- [x] `apple_payment_api.dart` — POST /api/v1/payments/apple/transactions/verify +- [x] `apple_iap_service.dart` — AppleIapService (ChangeNotifier): + - 初始化 purchaseStream 监听 + - queryProductDetails 查询 StoreKit 商品 + - `buyConsumable` 传递 `applicationUserName`(appAccountToken = userId MD5 hash) + - 购买成功 → 发送 JWS + appAccountToken 到后端验证 → completePurchase + - 可重试错误(5xx/网络)不 complete,下次启动自动重试 + - 不可重试错误(4xx)complete 并暴露 ApiProblem 供 UI 映射 l10n + - 暴露 `lastApiProblem` 供错误码映射 +- [x] `package_info.dart` — 新增 `appStoreProductId` 字段 +- [x] `settings_section_widgets.dart` — CoinPackageCard 新增 `onPurchase`/`isPurchasing`/`isAvailable`/`unavailableMessage` +- [x] `coin_center_screen.dart` — 集成 AppleIapService: + - 接收 `userId`/`onBalanceChanged` 参数 + - StoreKit 价格覆盖后端参考价格 + - 商品不可用时禁用卡片并显示提示 + - pending 状态显示 "Apple 正在处理中" + - 购买成功后调用 `_refreshBalance` 并回调 `onBalanceChanged` + - 使用 `mapApiProblemToMessage` 映射错误码到 l10n + +### 3.5 调用链更新 ✅ + +- [x] `app.dart` — HomeScreen 传递 `userId` + `onBalanceChanged` +- [x] `home_screen.dart` — `_ProfileTab` 传递 `userId` + `onBalanceChanged` +- [x] `settings_screen.dart` — 传递 `userId` + `onBalanceChanged` 给 CoinCenterScreen + +### 3.6 错误处理与本地化 ✅ + +- [x] `api_problem_mapper.dart` — 6 个支付错误码映射 +- [x] 3 个 ARB 文件新增 7 个 l10n key: + - `paymentSuccess` / `paymentVerifyFailed` / `paymentProductNotFound` / `paymentStarterPackIneligible` + - `paymentProductUnavailable` / `paymentPending` +- [x] `flutter gen-l10n` 生成通过 + +### 3.7 前端测试 ✅ + +- [x] `apps/test/features/payments/data/models/apple_purchase_models_test.dart` + - VerifyTransactionRequest.toJson 含/不含 appAccountToken + - VerifyTransactionResponse 解析 granted / already_granted +- [x] 4 个测试全部通过 + +### 3.8 验证 ✅ + +- [x] `flutter analyze` 0 issues +- [x] 后端 basedpyright 0 errors +- [x] 后端 16 个单元测试全部通过 + +--- + +## Phase 4: 联调与发布准备 + +### 4.1 App Store Connect 配置 + +- [x] 创建 4 个消耗型 IAP 商品(Product ID 已确认与映射表一致) +- [x] Product ID 与映射表一致 + - `com.meeyao.qianwen.new_user_pack` — 新手包 + - `com.meeyao.qianwen.starter_pack` — 入门包 + - `com.meeyao.qianwen.popular_pack` — 热门包 + - `com.meeyao.qianwen.premium_pack` — 高级包 +- [ ] 配置价格和描述 +- [x] 创建沙盒测试账号:`qiuzhiliang@xunmee.com` +- [ ] 配置 Server Notifications V2 URL(生产环境公网 URL) + +### 4.2 后端配置 ✅ + +- [x] 环境变量:`APPLE_IAP_BUNDLE_ID=com.meeyao.qianwen` +- [x] Server API 密钥配置(`T6M7J28MAQ` / `862a2cd0-ad6e-47c8-ac5a-bef5676c470b`) +- [x] 日志检查:不打印完整 JWS +- [x] 环境判断:根据 `ERYAO_RUNTIME__ENVIRONMENT` 自动切换 Sandbox/Production + +### 4.3 开发环境准备 ✅ + +- [x] `.env.example` 更新 Apple IAP 配置模板 +- [x] `.env` 写入实际配置值 +- [x] `AppleIapSettings` 支持所有配置字段 +- [x] `EryaoProducts.storekit` Xcode 本地测试配置文件 + +### 4.4 测试清单 + +- [ ] Xcode StoreKit Configuration 本地测试 +- [ ] Sandbox 购买成功验证 +- [ ] Sandbox 退款测试 +- [ ] 网络中断后重启恢复 +- [ ] 新手包重复购买阻断 +- [ ] TestFlight 环境验证 + +--- + +## 预估工时 + +| Phase | 工作项 | 预估时间 | +|-------|--------|----------| +| 1 | 枚举扩展 + 数据库迁移 | 2-3h | +| 2 | 后端支付服务 + 测试 | 1-2 天 | +| 3 | Flutter IAP 接入 | 1 天 | +| 4 | 联调与发布准备 | 0.5-1 天 | + +**总计**: 3-4 天 + +--- + +## 风险与依赖 + +| 风险/依赖 | 缓解措施 | +|----------|---------| +| App Store Connect 配置权限 | 提前确认账号权限,Phase 1 同步申请 | +| `in_app_purchase` 插件无法暴露 signedData | 预研后确定是否需要 platform channel | +| Apple 服务不可用 | 返回 `PAYMENT_APPLE_UNAVAILABLE`,前端保留交易 | diff --git a/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/check.jsonl b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/check.jsonl new file mode 100644 index 0000000..4c92f87 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/check.jsonl @@ -0,0 +1,12 @@ +{"role": "system", "content": "You are checking the iOS Apple Pay implementation. Verify all acceptance criteria are met."} +{"role": "user", "content": "Run checks for the iOS Apple Pay integration feature."} +{"role": "assistant", "content": "Running verification checks..."} +{"role": "assistant", "action": "check", "name": "Backend module structure", "command": "ls -la backend/src/v1/payments/", "expected": "Should have __init__.py, schemas.py, service.py, repository.py, router.py, dependencies.py"} +{"role": "assistant", "action": "check", "name": "Router registration", "command": "grep -r 'payments_router' backend/src/v1/router.py", "expected": "payments_router should be registered in main v1 router"} +{"role": "assistant", "action": "check", "name": "Error codes documentation", "command": "grep -E 'PAYMENT_|PAYMENT_RECEIPT_|PAYMENT_ALREADY_|PAYMENT_PRODUCT_' docs/protocols/common/http-error-codes.md", "expected": "Should have payment-related error codes documented"} +{"role": "assistant", "action": "check", "name": "Points integration", "command": "grep -r 'PointsChangeType.PURCHASE' backend/src/", "expected": "Should have PURCHASE change type for points ledger"} +{"role": "assistant", "action": "check", "name": "iOS StoreKit module", "command": "ls -la apps/lib/features/payments/", "expected": "Should have payments module with data/ and presentation/ subdirs"} +{"role": "assistant", "action": "check", "name": "StoreKit integration", "command": "grep -r 'StoreKit\\|InAppPurchase' apps/lib/features/payments/", "expected": "Should use StoreKit2 for IAP"} +{"role": "assistant", "action": "check", "name": "Type checking", "command": "cd backend && uv run basedpyright src/v1/payments/", "expected": "No type errors"} +{"role": "assistant", "action": "check", "name": "Lint check", "command": "cd backend && uv run ruff check src/v1/payments/", "expected": "No lint errors"} +{"role": "assistant", "action": "check", "name": "Unit tests", "command": "cd backend && uv run pytest tests/unit/test_apple_payment.py -v", "expected": "All tests pass"} diff --git a/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/implement.jsonl b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/implement.jsonl new file mode 100644 index 0000000..37aeb01 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/implement.jsonl @@ -0,0 +1,12 @@ +{"role": "system", "content": "You are implementing iOS Apple Pay integration. Follow the task.json requirements. Reference existing code patterns from v1/points/ and features/points/."} +{"role": "user", "content": "Start implementing the iOS Apple Pay integration feature."} +{"role": "assistant", "content": "I'll implement this feature in phases:\n\n**Phase 1: Backend - Apple Receipt Validation**\n1. Create `v1/payments/` module with Apple IAP endpoints\n2. Implement receipt validation with Apple's servers\n3. Integrate with existing PointsService for crediting\n4. Add purchase tracking\n\n**Phase 2: iOS - StoreKit2 Integration**\n1. Create `features/payments/` module in Flutter\n2. Implement StoreKit2 product fetching\n3. Implement purchase flow\n4. Connect to backend validation endpoint\n\n**Phase 3: Error Handling & Testing**\n1. Add error codes to http-error-codes.md\n2. Handle edge cases (network, cancelled, pending)\n3. Add integration tests\n\nLet me start with Phase 1."} +{"role": "assistant", "action": "create_file", "path": "backend/src/v1/payments/__init__.py", "content": "from v1.payments.router import router as payments_router\n\n__all__ = [\"payments_router\"]"} +{"role": "assistant", "action": "create_file", "path": "backend/src/v1/payments/schemas.py", "content": "from __future__ import annotations\n\nfrom pydantic import BaseModel, ConfigDict, Field\n\n\nclass ApplePurchaseRequest(BaseModel):\n model_config = ConfigDict(extra=\"forbid\")\n\n product_id: str = Field(min_length=1, max_length=100)\n transaction_id: str = Field(min_length=1, max_length=200)\n receipt_data: str = Field(min_length=1)\n\n\nclass ApplePurchaseResponse(BaseModel):\n success: bool\n credits_added: int\n new_balance: int\n transaction_id: str"} +{"role": "assistant", "action": "create_file", "path": "backend/src/v1/payments/service.py", "content": "from __future__ import annotations\n\nimport hashlib\nfrom dataclasses import dataclass\nfrom decimal import Decimal\nfrom typing import TYPE_CHECKING\nfrom uuid import UUID\n\nfrom core.config.packages import get_packages_config_for_region +from core.http.errors import ApiProblemError, problem_payload +from schemas.enums import PointsChangeType, PointsBizType +from schemas.domain.points import ApplyPointsChangeCommand, PurchaseLedgerMetadata +from v1.points.repository import PointsRepository + +if TYPE_CHECKING:\n pass\n\n\n@dataclass(frozen=True)\nclass AppleReceiptValidationResult:\n valid: bool\n product_id: str\n transaction_id: str\n purchase_date_ms: int\n quantity: int = 1\n\n\nclass ApplePaymentService:\n def __init__(self, repository: PointsRepository) -> None:\n self._repository = repository\n\n async def validate_and_process_purchase(\n self,\n *,\n user_id: UUID,\n user_email: str,\n product_id: str,\n transaction_id: str,\n receipt_data: str,\n country: str = \"US\",\n ) -> ApplePurchaseResponse:\n \"\"\"Validate Apple receipt and credit points.\"\"\"\n # Step 1: Validate receipt with Apple\n validation = await self._validate_receipt_with_apple(\n receipt_data=receipt_data,\n product_id=product_id,\n transaction_id=transaction_id,\n )\n \n if not validation.valid:\n raise ApiProblemError(\n status_code=400,\n detail=problem_payload(\n code=\"PAYMENT_RECEIPT_INVALID\",\n detail=\"Apple receipt validation failed\",\n ),\n )\n\n # Step 2: Check for duplicate transaction\n event_id = f\"apple.iap:{validation.transaction_id}\"\n if await self._repository.has_ledger_event(\n user_id=user_id, event_id=event_id\n ):\n raise ApiProblemError(\n status_code=409,\n detail=problem_payload(\n code=\"PAYMENT_ALREADY_PROCESSED\",\n detail=\"Transaction already processed\",\n ),\n )\n\n # Step 3: Get credits for product\n pkg_config = get_packages_config_for_region(country)\n credits = self._get_credits_for_product(\n packages=pkg_config.packages,\n product_id=product_id,\n )\n \n if credits <= 0:\n raise ApiProblemError(\n status_code=400,\n detail=problem_payload(\n code=\"PAYMENT_PRODUCT_NOT_FOUND\",\n detail=\"Product not found in catalog\",\n ),\n )\n\n # Step 4: Credit points\n account = await self._repository.get_or_create_user_points_for_update(\n user_id=user_id\n )\n account.balance = int(account.balance) + credits\n account.lifetime_earned = int(account.lifetime_earned) + credits\n account.version = int(account.version) + 1\n\n # Step 5: Record ledger\n metadata = PurchaseLedgerMetadata(\n platform=\"apple\",\n product_id=product_id,\n transaction_id=validation.transaction_id,\n )\n command = ApplyPointsChangeCommand(\n user_id=user_id,\n change_type=PointsChangeType.PURCHASE,\n biz_type=PointsBizType.PAYMENT,\n event_id=event_id,\n amount=credits,\n direction=1,\n operator_id=None,\n metadata=metadata,\n )\n await self._repository.append_ledger(\n command=command,\n balance_after=int(account.balance),\n )\n\n return ApplePurchaseResponse(\n success=True,\n credits_added=credits,\n new_balance=int(account.balance),\n transaction_id=validation.transaction_id,\n )\n\n async def _validate_receipt_with_apple(\n self,\n *,\n receipt_data: str,\n product_id: str,\n transaction_id: str,\n ) -> AppleReceiptValidationResult:\n \"\"\"Validate receipt with Apple's servers.\"\"\"\n # TODO: Implement actual Apple server verification\n # For now, return mock validation for development\n # Production: Call https://buy.itunes.apple.com/verifyReceipt\n # Sandbox: Call https://sandbox.itunes.apple.com/verifyReceipt\n return AppleReceiptValidationResult(\n valid=True,\n product_id=product_id,\n transaction_id=transaction_id,\n purchase_date_ms=0,\n )\n\n def _get_credits_for_product(\n self,\n packages: list,\n product_id: str,\n ) -> int:\n \"\"\"Get credits amount for a product ID.\"\"\"\n for pkg in packages:\n if pkg.product_code == product_id and pkg.enabled:\n return pkg.credits\n return 0"} diff --git a/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/prd.md b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/prd.md new file mode 100644 index 0000000..f5363f0 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/prd.md @@ -0,0 +1,549 @@ +# PRD:iOS Apple 内购接入积分购买 + +## 1. 背景与目标 + +当前应用已经有积分账户、积分流水、注册赠送积分和积分套餐展示能力。用户可以在积分中心看到套餐,但还不能通过 iOS 完成真实购买。本任务目标是在 iOS 端接入 Apple In-App Purchase(IAP),购买成功后由后端验证 Apple 签名交易并给用户发放积分。 + +本 PRD 同时明确支付数据如何落库、哪些现有表可以复用、哪些表必须新增,以及 iOS 到后端的完整闭环。 + +## 2. 当前事实 + +### 2.1 后端现状 + +- 积分账户表:`user_points` +- 积分业务流水:`points_ledger` +- 成本/审计流水:`points_audit_ledger` +- 注册奖励与新手包资格表:`register_bonus_claims` +- 积分套餐配置:`backend/src/core/config/static/packages/*.yaml` +- 当前套餐:`new_user_pack`、`starter_pack`、`popular_pack`、`premium_pack` +- 当前套餐接口:`GET /api/v1/points/packages` +- 当前积分余额接口:`GET /api/v1/points/balance` + +### 2.2 前端现状 + +- Flutter 应用已有积分中心:`apps/lib/features/settings/presentation/screens/coin_center_screen.dart` +- 当前积分中心只加载后端套餐并展示卡片,没有真实购买动作。 +- `apps/pubspec.yaml` 还没有 IAP 相关依赖。 + +### 2.3 关键约束 + +- Apple 官方文档已标记旧 `verifyReceipt` 端点为 deprecated。新实现不应以 `receipt_data + verifyReceipt` 作为主方案。 +- StoreKit2 的交易以 Apple 签名的 JWS 形式表达。后端应验证交易签名与交易内容,而不是信任客户端传来的商品、金额或积分数。 +- 当前 `points_ledger` 的约束只允许 `change_type in ('register', 'consume', 'grant', 'adjust')`,`biz_type` 只允许 `chat`。因此它不能直接合法记录“购买入账”。 + +## 3. 需求范围 + +### 3.1 必须实现 + +- iOS 积分中心展示 App Store Connect 中可购买的消耗型 IAP 商品。 +- 用户点击套餐后通过 Apple 支付完成购买。 +- 后端验证 Apple StoreKit2 signed transaction JWS。 +- 后端保证 Apple transaction 只发放一次积分。 +- 后端把积分入账到 `user_points`。 +- 后端把积分入账记录写入 `points_ledger`。 +- 新手包购买后更新 `register_bonus_claims.has_purchased_starter_pack = true`。 +- 购买完成后前端刷新积分余额和套餐列表。 +- 支持购买失败、取消、pending、后端验证失败、网络中断后的恢复处理。 + +### 3.2 本期不做 + +- Android / Google Play Billing。 +- 订阅型商品。 +- 非消耗型商品。 +- 后台运营发放、优惠券、折扣码。 +- Web 支付。 + +## 4. 产品定义 + +### 4.1 商品类型 + +所有积分包均为 Apple 消耗型商品(consumable)。 + +| 后端 `product_code` | App Store Product ID | 类型 | 积分 | 备注 | +|---|---|---|---:|---| +| `new_user_pack` | `com.meeyao.qianwen.new_user_pack` | starter | 60 | 每个邮箱身份只允许购买一次 | +| `starter_pack` | `com.meeyao.qianwen.starter_pack` | regular | 100 | 可重复购买 | +| `popular_pack` | `com.meeyao.qianwen.popular_pack` | regular | 210 | 可重复购买 | +| `premium_pack` | `com.meeyao.qianwen.premium_pack` | regular | 415 | 可重复购买 | + +价格以 App Store Connect 为准。后端 YAML 中的价格仅用于非商店展示参考;iOS 支付页最终展示应以 StoreKit 返回的本地化价格为准。 + +### 4.2 新手包资格 + +- 后端继续使用 `register_bonus_claims.has_purchased_starter_pack` 作为新手包资格的源头。 +- `GET /api/v1/points/packages` 中,如果该字段为 `true`,不返回 `new_user_pack`。 +- 后端支付校验时必须再次检查资格,不能只依赖前端是否展示。 +- 如果用户删除账号后用同一邮箱重新注册,`register_bonus_claims` 的邮箱哈希仍应阻止再次购买新手包。 + +## 5. 数据保存方案 + +### 5.1 结论 + +不能只复用现有数据库表。应采用“复用积分账户和资格表 + 新增 Apple 支付交易表 + 最小扩展积分流水约束”的方案。 + +复用现有表: + +- `user_points`:继续保存余额、累计获得、累计消耗。 +- `register_bonus_claims`:继续保存新手包是否已购买。 +- `points_ledger`:继续作为用户积分变动的权威流水,但需要扩展枚举和约束来支持购买入账。 + +必须新增表: + +- `apple_iap_transactions`:保存 Apple 交易校验、幂等、发放状态、退款/撤销状态和原始签名快照。 + +### 5.2 为什么必须新增支付交易表 + +`points_ledger` 只回答“用户积分为什么变了”,不适合作为支付交易系统的唯一事实表,原因如下: + +- 支付交易有独立生命周期:`received -> verified -> granted -> failed -> refunded/revoked`。 +- 支付幂等需要按 Apple `transactionId` 全局去重,而 `points_ledger` 当前唯一约束是 `(user_id, event_id)`。 +- 支付需要保存 Apple 交易环境、原始交易 ID、签名 JWS、购买时间、撤销时间、bundle id、appAccountToken 等审计字段。 +- 支付成功但积分入账失败时,需要能补偿重试;单靠 `points_ledger` 无法表达“已验证未发放”。 +- 后续退款、客服核查、对账、App Store Server Notifications V2 都需要独立交易表承载。 + +### 5.3 `apple_iap_transactions` 表设计 + +建议新增 Alembic 迁移创建表: + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | UUID PK | 内部交易记录 ID | +| `user_id` | UUID not null | 当前购买归属用户,来自后端 JWT,不接受客户端传入 | +| `product_code` | varchar not null | 后端套餐码,例如 `starter_pack` | +| `app_store_product_id` | varchar not null | Apple 商品 ID | +| `transaction_id` | varchar not null unique | Apple 交易 ID,核心幂等键 | +| `original_transaction_id` | varchar null | Apple 原始交易 ID | +| `web_order_line_item_id` | varchar null | Apple 可选订单行 ID | +| `environment` | varchar not null | `Sandbox` 或 `Production` | +| `bundle_id` | varchar not null | 必须等于当前 iOS bundle id | +| `app_account_token` | UUID null | 客户端购买时传入的用户绑定 token,用于降低串单风险 | +| `purchase_date` | timestamptz not null | Apple 交易购买时间 | +| `revocation_date` | timestamptz null | Apple 撤销/退款时间 | +| `status` | varchar not null | `received`、`verified`、`granted`、`failed`、`refunded`、`refunded_insufficient`、`revoked` | +| `credits` | bigint not null | 本次应发积分,由后端套餐配置决定 | +| `currency` | varchar null | Apple 或后端记录的币种 | +| `price_milliunits` | bigint null | 如 Apple JWS 提供则保存,单位按 Apple 字段定义 | +| `ledger_event_id` | varchar null unique | 对应 `points_ledger.event_id` | +| `signed_transaction_info` | text not null | 客户端提交或服务器查询到的 Apple signed transaction JWS | +| `apple_payload` | jsonb not null default `{}` | 验签后的交易 payload 快照 | +| `failure_code` | varchar null | 验证或入账失败原因码 | +| `created_at` / `updated_at` | timestamptz | 时间戳 | + +约束与索引: + +- `transaction_id` 全局唯一。 +- `(user_id, created_at desc)` 索引用于用户购买记录查询。 +- `(status, updated_at)` 索引用于补偿任务扫描。 +- `status` 使用 check constraint 限定合法值。 +- `environment` 使用 check constraint 限定 `Sandbox` / `Production`。 + +### 5.4 `points_ledger` 最小扩展 + +新增积分变更类型: + +- `PointsChangeType.PURCHASE = 'purchase'` +- `PointsChangeType.REFUND = 'refund'` + +新增业务类型: + +- `PointsBizType.PAYMENT = 'payment'` + +约束调整: + +- `change_type` 允许 `purchase`、`refund`。 +- `biz_type` 允许 `payment`。 +- `purchase` 必须 `direction = 1`。 +- `refund` 必须 `direction = -1`。 +- `purchase/refund` 必须 `biz_type = 'payment'` 且 `biz_id` 不为空。 +- `purchase/refund` 的 `biz_id` 保存 `apple_iap_transactions.id`。 +- `purchase` 的 `event_id` 建议为 `payment.apple_iap:{transaction_id}`。 +- `refund` 的 `event_id` 建议为 `refund.apple_iap:{transaction_id}`。 + +`points_ledger.metadata.ext` 建议保存: + +```json +{ + "source": "apple_iap", + "platform": "ios", + "product_code": "starter_pack", + "app_store_product_id": "com.meeyao.qianwen.starter_pack", + "transaction_id": "1000000123456789", + "original_transaction_id": "1000000123456789", + "environment": "Production", + "apple_iap_transaction_id": "uuid" +} +``` + +对于 `refund`,额外保存: + +```json +{ + "original_event_id": "payment.apple_iap:1000000123456789", + "refund_reason": "CUSTOMER_REQUEST", + "overdue_amount": 0 +} +``` + +### 5.5 `points_audit_ledger` 是否扩展 + +本期不强制把购买写入 `points_audit_ledger`。支付审计以 `apple_iap_transactions` 为准,用户积分变更以 `points_ledger` 为准。 + +如果后续希望所有积分变动都统一进入 `points_audit_ledger`,再单独扩展它的 `change_type/biz_type` 约束。不要在本期为了“统一”扩大实现范围。 + +### 5.6 `register_bonus_claims` 使用方式 + +新手包入账成功后: + +- 按当前用户邮箱计算 `email_hash`。 +- 如果对应 claim 已存在,设置 `has_purchased_starter_pack = true`。 +- 如果不存在但用户购买的是新手包,后端应创建 claim,并写入 `email_hash`、`user_email_snapshot`、`first_user_id_snapshot`、`grant_event_id`。 +- 该操作必须与积分入账在同一个数据库事务中完成。 + +## 6. 后端接口设计 + +### 6.1 新增接口 + +`POST /api/v1/payments/apple/transactions/verify` + +请求: + +```json +{ + "productCode": "starter_pack", + "appStoreProductId": "com.meeyao.qianwen.starter_pack", + "transactionId": "1000000123456789", + "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIs...", + "appAccountToken": "7c4c7a82-2f6f-4e70-b57a-8b0a7f2e9b72" +} +``` + +字段规则: + +- `productCode` 必须是后端套餐配置中的合法值。 +- `appStoreProductId` 必须与后端映射表匹配。 +- `transactionId` 必须与验签后的 Apple payload 中的 `transactionId` 一致。 +- `signedTransactionInfo` 必须是 Apple 签名 JWS。 +- `appAccountToken` 如果存在,必须与后端为当前用户生成/约定的 token 一致。 + +响应 200: + +```json +{ + "status": "granted", + "productCode": "starter_pack", + "transactionId": "1000000123456789", + "creditsAdded": 100, + "newBalance": 180, + "ledgerEventId": "payment.apple_iap:1000000123456789" +} +``` + +重复提交同一已发放交易时仍返回 200: + +```json +{ + "status": "already_granted", + "productCode": "starter_pack", + "transactionId": "1000000123456789", + "creditsAdded": 0, + "newBalance": 180, + "ledgerEventId": "payment.apple_iap:1000000123456789" +} +``` + +### 6.2 可选接口 + +`GET /api/v1/payments/apple/transactions/{transactionId}` + +用途:前端在网络中断或 App 重启后查询交易是否已经入账。可延后实现;如果本期不做,前端应通过重新提交 `signedTransactionInfo` 实现幂等恢复。 + +### 6.3 错误码 + +新增到 `docs/protocols/common/http-error-codes.md`: + +| code | status | 说明 | 前端处理 | +|---|---:|---|---| +| `PAYMENT_PRODUCT_NOT_FOUND` | 404 | `productCode` 不存在或未启用 | 刷新套餐并提示商品不可用 | +| `PAYMENT_PRODUCT_MISMATCH` | 422 | 客户端商品 ID 与后端/Apple 验证结果不一致 | 阻断入账并提示重试 | +| `PAYMENT_TRANSACTION_INVALID` | 422 | Apple signed transaction 无效、验签失败或 payload 不合法 | 提示购买验证失败 | +| `PAYMENT_TRANSACTION_REVOKED` | 409 | 交易已撤销或退款,不允许入账 | 提示购买不可用 | +| `PAYMENT_TRANSACTION_CONFLICT` | 409 | 交易已由另一个用户或冲突状态处理 | 提示联系客服或刷新余额 | +| `PAYMENT_STARTER_PACK_INELIGIBLE` | 409 | 当前邮箱身份已购买过新手包 | 刷新套餐并隐藏新手包 | +| `PAYMENT_APPLE_UNAVAILABLE` | 503 | Apple Server API 或证书获取不可用 | 提示稍后重试,前端不要 finish/complete 交易 | +| `PAYMENT_GRANT_FAILED` | 500 | 验证成功但入账事务失败 | 提示稍后重试,保留交易用于补偿 | + +注:如果重复提交同一用户已完成发放的交易,应返回 200 `already_granted`,不应返回错误。 + +## 7. 后端处理流程 + +### 7.1 主流程 + +1. 前端收到 StoreKit 购买成功事件。 +2. 前端把 `signedTransactionInfo`、`transactionId`、`productCode`、`appStoreProductId` 发送到后端。 +3. 后端从 JWT 获取 `user_id` 和邮箱。 +4. 后端校验 `productCode` 是否启用,并映射到期望的 Apple Product ID。 +5. 后端验证 Apple JWS 签名、证书链、bundle id、environment、transaction id、product id、购买时间、撤销状态。 +6. 后端按 `transaction_id` 查 `apple_iap_transactions`。 +7. 如果交易已经由当前用户成功发放,返回 `already_granted`。 +8. 如果交易已绑定其他用户或处于冲突状态,返回结构化错误。 +9. 后端开启数据库事务。 +10. 后端插入或锁定 `apple_iap_transactions` 记录。 +11. 后端锁定 `user_points` 行。 +12. 后端按套餐配置计算积分,不信任客户端传入的积分数。 +13. 如果是新手包,锁定/创建 `register_bonus_claims` 并检查 `has_purchased_starter_pack`。 +14. 后端增加 `user_points.balance` 和 `lifetime_earned`。 +15. 后端写入 `points_ledger` 的 `purchase` 流水。 +16. 后端更新 `apple_iap_transactions.status = 'granted'`、写入 `ledger_event_id`。 +17. 如果是新手包,设置 `has_purchased_starter_pack = true`。 +18. 事务提交。 +19. 后端返回新增积分和新余额。 +20. 前端收到成功响应后调用 `completePurchase` / finish transaction。 +21. 前端刷新余额和套餐列表。 + +### 7.2 幂等策略 + +- Apple `transactionId` 是支付发放的全局幂等键。 +- `apple_iap_transactions.transaction_id` 必须唯一。 +- `points_ledger.event_id = payment.apple_iap:{transaction_id}`。 +- 同一用户重复提交已完成交易返回 200 `already_granted`。 +- 不同用户提交同一 transaction 必须拒绝,避免串单。 + +### 7.3 补偿策略 + +- 如果 Apple 已扣款、客户端未收到后端成功响应,前端下次启动或交易流恢复时重新提交同一交易。 +- 后端接口必须可重复调用。 +- 如果后端已插入 `apple_iap_transactions` 但未完成 `points_ledger`,后台补偿任务可扫描 `status='verified'` 或 `status='failed'` 且具备有效 Apple payload 的记录重试入账。 +- 前端只有在后端确认 `granted/already_granted` 后才完成 StoreKit 交易,避免交易被提前 finish 后丢失发放机会。 + +### 7.4 退款与撤销 + +当收到 Apple 退款/撤销通知时,必须扣回已发放积分,避免积分被白嫖。 + +**处理流程:** + +1. 后台任务或 App Store Server Notifications V2 收到退款/撤销事件。 +2. 按 `transactionId` 查询 `apple_iap_transactions`。 +3. 如果 `status != 'granted'`,忽略(未发放无需扣回)。 +4. 开启数据库事务: + - 锁定 `user_points` 行。 + - 如果 `balance < credits`(余额不足以扣回): + - 设置 `apple_iap_transactions.status = 'refunded_insufficient'`。 + - 记录 `failure_code = 'INSUFFICIENT_BALANCE'`。 + - 写入 `points_ledger` 的 `refund` 流水,`amount = balance`(扣到 0 为止)。 + - 事务提交后触发告警,等待人工处理。 + - 如果 `balance >= credits`: + - 扣减 `user_points.balance` 和 `lifetime_earned`。 + - 写入 `points_ledger` 的 `refund` 流水。 + - 更新 `apple_iap_transactions.status = 'refunded'`。 + - 事务提交。 +5. `points_ledger` 的 `refund` 记录: + - `change_type = 'refund'` + - `direction = -1` + - `biz_type = 'payment'` + - `biz_id = apple_iap_transactions.id` + - `event_id = refund.apple_iap:{transaction_id}` + - `metadata.ext` 保存退款相关快照。 + +**App Store Server Notifications V2 接入(建议):** + +- 配置 App Store Connect 的 Server Notifications URL。 +- 后端实现 `POST /api/v1/payments/apple/notifications` 接收 Apple 推送。 +- 解析 notification type:`REFUND`、`REVOKE` 等。 +- 按上述流程处理退款/撤销。 + +**余额不足时的处理策略:** + +- 不允许用户余额变为负数。 +- 扣到 0 为止,剩余欠款记录在 `apple_iap_transactions.metadata.overdue_amount`。 +- 触发运营告警,人工决定是否追偿或标记坏账。 +- 后续用户充值时,可考虑优先补扣欠款(需要产品决策,本期不实现)。 + +## 8. iOS / Flutter 接入设计 + +### 8.1 技术选择 + +优先使用 Flutter 官方 `in_app_purchase` 插件及 iOS StoreKit 实现。若当前插件版本无法稳定提供 StoreKit2 `Transaction.signedData`,则增加最小 iOS platform channel 获取 `signedTransactionInfo`,不要把旧 `verifyReceipt` 作为主验证方案。 + +建议依赖: + +- `in_app_purchase` +- 如需 iOS 细节能力,再引入官方 StoreKit 平台包。 + +### 8.2 前端模块建议 + +```text +apps/lib/features/payments/ +├── data/ +│ ├── apis/ +│ │ └── apple_payment_api.dart +│ ├── models/ +│ │ └── apple_purchase_models.dart +│ └── services/ +│ └── apple_iap_service.dart +└── presentation/ + └── bloc/ + └── payment_bloc.dart +``` + +如果实现时发现只服务积分中心,`PaymentBloc` 可以先保持轻量,不要过度抽象。 + +### 8.3 前端购买流程 + +1. 积分中心加载后端套餐。 +2. 前端把后端 `productCode` 映射为 App Store Product ID。 +3. 前端调用 StoreKit 查询商品详情。 +4. UI 用 StoreKit 返回的本地化价格覆盖后端展示价格。 +5. 用户点击购买。 +6. 前端构造购买参数,带上当前用户绑定用的 `appAccountToken`。 +7. 前端调用 consumable purchase。 +8. 前端监听 purchase stream。 +9. 状态为 `pending`:展示处理中,不调用后端入账。 +10. 状态为 `error` 或 `canceled`:展示失败/取消,不调用后端入账。 +11. 状态为 `purchased` 且含可验证数据:调用后端验证接口。消耗型商品不依赖 restore 作为补发入口,补发应通过未完成交易重新提交实现。 +12. 后端返回 `granted/already_granted` 后,前端调用 `completePurchase`。 +13. 前端刷新 `GET /api/v1/points/balance` 和 `GET /api/v1/points/packages`。 + +### 8.4 UI 状态 + +- 套餐加载中:使用项目统一加载组件。 +- 商品不可用:禁用对应卡片并提示稍后再试。 +- 购买中:当前卡片按钮进入 loading,避免重复点击。 +- pending:显示 Apple 正在处理,不发放积分。 +- 成功:刷新余额;正常成功路径可不打扰,必要时显示轻量成功反馈。 +- 失败:按后端 RFC7807 `code` 映射本地化错误。 + +## 9. App Store Connect 配置 + +上线前必须完成: + +1. iOS App ID 开启 In-App Purchase capability。 +2. Xcode Runner target 开启 In-App Purchase capability。 +3. App Store Connect 创建 4 个 consumable 商品。 +4. Product ID 与本 PRD 表格完全一致。 +5. 配置价格、展示名称、描述和审核截图。 +6. 配置沙盒测试账号。 +7. 后端配置 Apple 验签所需 bundle id、Apple 根证书/JWS 证书链校验策略;如果需要主动调用 App Store Server API,再配置 issuer/key id/private key。 +8. 确认后端区分 Sandbox / Production,但业务逻辑保持一致。 + +## 10. 协议与文档更新要求 + +实现前必须先更新协议文档: + +- 新增支付协议文档:`docs/protocols/payments/apple-iap-protocol.md` +- 更新错误码文档:`docs/protocols/common/http-error-codes.md` +- 更新积分数据协议:`docs/protocols/common/user-points-chat-data-protocol.md` + +需要修正的现有协议不一致点: + +- `docs/protocols/common/user-points-chat-data-protocol.md` 中套餐示例仍使用旧字段 `priceUsd`、`badge`、`new_user_pack_099_60`,但当前后端实际返回 `price`、`productCode` 等字段,应同步修正。 +- 该文档仍描述 `points_ledger.biz_id -> sessions.id`,但当前模型和迁移已取消该外键,应同步修正为快照引用。 + +## 11. 安全要求 + +- 后端永远从 JWT 获取用户身份,客户端不得传 `userId`。 +- 后端不信任客户端传入的积分、价格、币种。 +- 后端必须验证 Apple signed transaction JWS。 +- 后端必须验证 `bundleId`、`productId`、`transactionId`、`environment`、`revocationDate`。 +- 后端必须按 `transactionId` 全局幂等。 +- 后端日志禁止打印完整 JWS、用户邮箱、访问令牌或 Apple 私钥。 +- Apple 私钥只能通过 `core.config.settings` 读取配置,不允许散落 `os.getenv`。 +- 支付入账必须在数据库事务中完成。 + +## 12. 测试策略 + +### 12.1 后端单元测试 + +- 验签成功后创建 `apple_iap_transactions`。 +- 商品不存在返回 `PAYMENT_PRODUCT_NOT_FOUND`。 +- product id 不匹配返回 `PAYMENT_PRODUCT_MISMATCH`。 +- revoked transaction 不发放积分。 +- 同一 transaction 重复提交只发放一次。 +- 同一 transaction 被不同用户提交时拒绝。 +- 新手包第一次购买成功,第二次返回 `PAYMENT_STARTER_PACK_INELIGIBLE`。 +- 入账后 `user_points.balance/lifetime_earned` 正确增加。 +- 入账后 `points_ledger` 写入 `purchase/payment`。 +- 退款时余额充足:正确扣减积分,写入 `refund` 流水,状态变为 `refunded`。 +- 退款时余额不足:扣到 0,状态变为 `refunded_insufficient`,触发告警路径。 +- 重复退款通知:幂等处理,不重复扣减。 + +### 12.2 后端集成测试 + +- 使用伪造的 Apple verifier 依赖注入,避免测试依赖真实 Apple 网络。 +- 覆盖事务回滚:支付记录、积分账户、流水、新手包标记必须一致提交或一致回滚。 +- 覆盖并发重复提交:只有一个请求成功入账。 + +### 12.3 前端测试 + +- 套餐接口与 StoreKit 商品映射正确。 +- 购买成功后调用后端验证并刷新余额。 +- 后端返回 `already_granted` 时仍完成 StoreKit 交易并刷新余额。 +- pending/error/canceled 不调用入账接口。 +- 后端错误码正确映射本地化提示。 + +### 12.4 手工测试 + +- Xcode StoreKit Configuration 本地测试。 +- App Store Connect Sandbox 账号测试。 +- 网络中断后重启 App,未完成交易可重新提交并发放。 +- TestFlight 环境测试。 +- 新手包隐藏与重复购买阻断。 + +## 13. 实施阶段 + +### Phase 1:协议与数据库 + +1. 更新协议文档和错误码。 +2. 新增 `apple_iap_transactions` 模型与 Alembic 迁移。 +3. 扩展 `PointsChangeType`、`PointsBizType` 与 `points_ledger` check constraints。 + +### Phase 2:后端支付服务 + +1. 更新协议文档和错误码。 +2. 新增 `apple_iap_transactions` 模型与 Alembic 迁移。 +3. 扩展 `PointsChangeType`、`PointsBizType` 与 `points_ledger` check constraints。 +4. 新增 `v1/payments/` 模块。 +5. 实现 Apple transaction verifier 抽象。 +6. 实现支付校验、幂等和积分入账服务。 +7. 实现退款/撤销处理服务(扣回积分)。 +8. 接入 `v1/router.py`。 +9. 添加单元测试和集成测试。 + +### Phase 3:iOS / Flutter + +1. 添加 IAP 依赖。 +2. 实现商品查询和购买流监听。 +3. 实现后端验证 API client。 +4. 接入 `CoinCenterScreen`。 +5. 添加前端测试。 + +### Phase 4:联调与发布准备 + +1. 配置 App Store Connect 商品。 +2. 配置后端 Apple 验签参数。 +3. 配置 App Store Server Notifications V2 URL。 +4. Sandbox 和 TestFlight 测试。 +5. 测试退款/撤销流程(使用 Sandbox 退款功能)。 +6. 检查日志、错误码、补偿路径。 + +## 14. 风险与缓解 + +| 风险 | 缓解 | +|---|---| +| 客户端支付成功但后端未入账 | 后端幂等接口 + 前端不提前 completePurchase + 重启后重新提交 | +| 重复发放积分 | `apple_iap_transactions.transaction_id` 唯一 + `points_ledger.event_id` 幂等 | +| 商品配置不一致 | 后端维护 `productCode -> App Store Product ID` 映射并强校验 | +| 新手包被重复购买 | 后端事务内锁定/更新 `register_bonus_claims` | +| Apple 服务临时不可用 | 返回 `PAYMENT_APPLE_UNAVAILABLE`,前端保留交易稍后重试 | +| 退款时余额不足扣回 | 扣到 0 为止,记录欠款,触发告警等待人工处理 | +| 退款通知丢失或延迟 | 后台定时任务查询 Apple Server API 获取交易状态变更 | +| Flutter 插件无法暴露 StoreKit2 signedData | 增加最小 iOS platform channel 获取 `Transaction.signedData` | + +## 15. 成功标准 + +- iOS Sandbox 可完成 4 个积分包购买。 +- 每笔 Apple transaction 最多发放一次积分。 +- 新手包同一邮箱身份只能发放一次。 +- 支付入账后 `user_points`、`points_ledger`、`apple_iap_transactions` 数据一致。 +- 网络中断/重复提交不会导致漏发或重复发放。 +- 退款/撤销时正确扣回积分,余额不足时正确记录并告警。 +- 新增/修改的协议文档、错误码、迁移、后端测试、前端测试均完成。 diff --git a/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/task.json b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/task.json new file mode 100644 index 0000000..b5c4575 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-27-feat-ios-apple-pay/task.json @@ -0,0 +1,35 @@ +{ + "title": "iOS Apple Pay Integration for Points Purchase", + "slug": "feat-ios-apple-pay", + "branch": "worktree/feat-ios-apple-pay", + "status": "completed", + "priority": "high", + "dev_type": "fullstack", + "created_at": "2026-04-27", + "updated_at": "2026-04-27", + "assignee": null, + "labels": [ + "ios", + "payment", + "apple-iap", + "points", + "backend" + ], + "description": "Integrate Apple In-App Purchase (IAP) for iOS points purchase flow, connecting frontend StoreKit2 with backend receipt validation.", + "acceptance_criteria": [ + "iOS app can display available packages via StoreKit2", + "User can purchase points through Apple IAP", + "Backend validates Apple receipt and credits points", + "Purchase history is recorded in points_ledger", + "Error handling for failed/cancelled purchases", + "Starter pack purchase tracked in register_bonus_claims" + ], + "dependencies": [], + "blocks": [], + "related_docs": [ + "docs/protocols/common/http-error-codes.md", + "backend/src/v1/points/service.py", + "apps/lib/features/points/data/models/package_info.dart" + ], + "completedAt": "2026-04-28" +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/IMPLEMENTATION_PLAN.md b/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..e5f3e6b --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/IMPLEMENTATION_PLAN.md @@ -0,0 +1,404 @@ +# Implementation Plan: App启动时语言和时区自动设置 + +## Phase 1: 前端 - 读取系统语言/时区 + +### 1.1 创建系统Locale工具函数 + +**文件**: `apps/lib/app/locale_utils.dart` (新建) + +**Locale映射规则** (基于项目现有约定): + +| 系统Locale | Flutter Locale | 存储Tag | +|-----------|----------------|---------| +| en, en-US, en-GB, ... | `Locale('en')` | `en-US` | +| zh, zh-CN, zh-SG, zh-Hans-* | `Locale('zh')` | `zh-CN` | +| zh-Hant, zh-TW, zh-HK, zh-MO | `Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant')` | `zh-Hant` | +| 其他 | null (回退默认) | `zh-CN` | + +```dart +import 'dart:ui'; +import 'package:flutter/material.dart'; + +/// 从系统Locale映射到App支持的Locale +/// 返回 null 表示不支持,调用方应使用默认值 +Locale? resolveSystemLocale(Locale systemLocale) { + final lang = systemLocale.languageCode.toLowerCase(); + final script = systemLocale.scriptCode; + final country = systemLocale.countryCode; + + // 英文: en, en-US, en-GB, ... → Locale('en') → 存储 en-US + if (lang == 'en') { + return const Locale('en'); + } + + // 中文处理 + if (lang == 'zh') { + // 繁体: zh-Hant, zh-TW, zh-HK, zh-MO → Locale(zh, Hant) → 存储 zh-Hant + if (script == 'Hant' || country == 'TW' || country == 'HK' || country == 'MO') { + return const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); + } + // 简体: zh, zh-CN, zh-SG, zh-Hans-* → Locale('zh') → 存储 zh-CN + return const Locale('zh'); + } + + // 其他语言不支持,回退到默认 + return null; +} + +/// 获取系统首选Locale +Locale getSystemLocale() { + return PlatformDispatcher.instance.locale; +} +``` + +### 1.2 创建系统时区工具函数 + +**文件**: `apps/lib/app/timezone_utils.dart` (新建) + +**方案选择**: +- 方案1: 使用 `flutter_timezone` 包 (推荐,直接返回IANA ID) +- 方案2: 使用 `DateTime.now().timeZoneName` + 映射表 (无额外依赖,但需维护映射) + +```dart +import 'package:flutter_timezone/flutter_timezone.dart'; + +/// 获取系统时区IANA ID +/// 例如: Asia/Shanghai, America/New_York, Europe/London +Future getSystemTimezone() async { + try { + final timezone = await FlutterTimezone.getLocalTimezone(); + // 验证是否为有效IANA ID + if (timezone.isNotEmpty) { + return timezone; + } + } catch (_) { + // ignore + } + // 回退到默认 + return 'Asia/Shanghai'; +} +``` + +**依赖添加** (`apps/pubspec.yaml`): +```yaml +dependencies: + flutter_timezone: ^3.0.1 +``` + +### 1.3 扩展SessionStore + +**文件**: `apps/lib/core/auth/session_store.dart` + +```dart +// 新增 +static const String _timezoneKey = 'selected_timezone'; + +Future saveTimezone(String timezone) async { + await _kvStore.setString(_timezoneKey, timezone); +} + +Future getTimezone() async { + return _kvStore.getString(_timezoneKey); +} +``` + +### 1.4 修改App启动流程 + +**文件**: `apps/lib/app/app.dart` + +**步骤1**: 添加 `_timezone` 状态变量声明 + +```dart +// 在 State 类顶部添加 +String _timezone = 'Asia/Shanghai'; +``` + +**步骤2**: 修改 `_bootstrap()` 方法 +```dart +Future _bootstrap() async { + // 1. 语言处理 + final savedLocaleTag = await _sessionStore.getLocaleTag(); + final Locale locale; + if (savedLocaleTag != null) { + locale = localeFromLanguageTag(savedLocaleTag); + } else { + final systemLocale = getSystemLocale(); + locale = resolveSystemLocale(systemLocale) ?? const Locale('zh'); + await _sessionStore.saveLocaleTag(languageTagFromLocale(locale)); + } + + // 2. 时区处理 + final savedTimezone = await _sessionStore.getTimezone(); + final String timezone; + if (savedTimezone != null) { + timezone = savedTimezone; + } else { + timezone = await getSystemTimezone(); + await _sessionStore.saveTimezone(timezone); + } + + // 3. 设置状态 + setState(() { + _locale = locale; + _timezone = timezone; + }); + + // 4. 启动认证 + await _authBloc.start(); +} +``` + +--- + +## Phase 2: 前端 - 注册时传递语言/时区 + +### 2.1 扩展AuthApi + +**文件**: `apps/lib/features/auth/data/apis/auth_api.dart` + +```dart +Future createEmailSession({ + required String email, + required String token, + String? language, + String? timezone, +}) async { + final data = { + 'email': email, + 'token': token, + }; + if (language != null) data['language'] = language; + if (timezone != null) data['timezone'] = timezone; + + final json = await _apiClient.postJson('/api/v1/auth/email-session', data: data); + return SessionResponse.fromJson(json); +} +``` + +### 2.2 扩展AuthRepository + +**文件**: `apps/lib/features/auth/data/repositories/auth_repository.dart` + +```dart +// 接口定义 +abstract class AuthRepository { + // ... 现有方法 ... + Future loginWithEmailOtp({ + required String email, + required String otp, + String? language, + String? timezone, + }); +} + +// 实现 +class AuthRepositoryImpl implements AuthRepository { + // ... 现有代码 ... + + @override + Future loginWithEmailOtp({ + required String email, + required String otp, + String? language, + String? timezone, + }) async { + final response = await _authApi.createEmailSession( + email: email, + token: otp, + language: language, + timezone: timezone, + ); + // ... 现有的 session 处理逻辑 ... + } +} +``` + +### 2.3 扩展AuthBloc + +**文件**: `apps/lib/features/auth/presentation/bloc/auth_bloc.dart` + +```dart +Future loginWithOtp({ + required String email, + required String otp, + String? language, + String? timezone, +}) async { + final user = await _repository.loginWithEmailOtp( + email: email, + otp: otp, + language: language, + timezone: timezone, + ); + // ... +} +``` + +### 2.4 修改App调用点 + +**文件**: `apps/lib/app/app.dart` + +```dart +// LoginScreen 的 onLoginWithOtp 回调 +onLoginWithOtp: (email, otp) { + return _authBloc.loginWithOtp( + email: email, + otp: otp, + language: languageTagFromLocale(_locale), + timezone: _timezone, + ); +}, +``` + +--- + +## Phase 3: 后端 - 接收语言/时区 + +### 3.1 扩展Schema + +**文件**: `backend/src/v1/auth/schemas.py` + +```python +class EmailSessionCreateRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + email: str = Field(pattern=SUPABASE_EMAIL_PATTERN) + token: str = Field(min_length=6, max_length=6) + language: str | None = Field(default=None, max_length=20) + timezone: str | None = Field(default=None, max_length=50) +``` + +### 3.2 扩展AuthService + +**文件**: `backend/src/v1/auth/service.py` + +```python +async def create_email_session( + self, + payload: EmailSessionCreateRequest, +) -> SessionResponse: + # ... 现有的 session 创建逻辑 ... + + # 如果提供了语言/时区,更新Profile + if payload.language or payload.timezone: + await self._update_profile_preferences( + user_id=user.id, + language=payload.language, + timezone=payload.timezone, + ) + + return result + +async def _update_profile_preferences( + self, + user_id: UUID, + language: str | None, + timezone: str | None, +) -> None: + """更新用户Profile的语言/时区偏好设置""" + profile = await self.profile_repository.get_profile_by_user_id(user_id) + if profile is None: + return + + settings = profile.settings or {} + preferences = settings.get("preferences", {}) + + if language is not None: + preferences["language"] = language + if timezone is not None: + preferences["timezone"] = timezone + + settings["preferences"] = preferences + profile.settings = settings + + await self.profile_repository.save() +``` + +### 3.3 AuthService依赖注入 + +**文件**: `backend/src/v1/auth/service.py` + +AuthService 需要注入 `ProfileRepository` 或 `UserRepository` 以便更新 Profile。 + +```python +@dataclass +class AuthService: + # ... 现有依赖 ... + profile_repository: SQLAlchemyUserRepository # 新增 +``` + +**或**: 复用现有的 `v1/users/service.py` 中的 `UserService.update_settings()` 方法。 + +--- + +## Phase 4: 前端 - 登录后同步时区 + +### 4.1 修改_refreshProfile + +**文件**: `apps/lib/app/app.dart` + +```dart +Future _refreshProfile({required String userEmail}) async { + // ... 现有逻辑 ... + + final serverLanguage = profile.preferences.language; + final serverTimezone = profile.preferences.timezone; + + // 同步语言 + await _sessionStore.saveLocaleTag(serverLanguage); + + // 同步时区 + await _sessionStore.saveTimezone(serverTimezone); + + setState(() { + _locale = localeFromLanguageTag(serverLanguage); + _timezone = serverTimezone; + _profileSettings = profile; + // ... + }); +} +``` + +--- + +## Phase 5: 验证和测试 + +### 5.1 单元测试 + +- [ ] `resolveSystemLocale` 映射正确性 +- [ ] `getSystemTimezone` 返回有效IANA ID +- [ ] `SessionStore` 时区存取 + +### 5.2 集成测试 + +- [ ] 新设备首次打开 → 系统语言/时区生效 +- [ ] 新用户注册 → 后端Profile正确 +- [ ] 已有用户登录 → 服务器值同步 +- [ ] 有本地存储 → 使用本地值 + +### 5.3 手动测试 + +- [ ] iOS系统语言设为英文 → App显示英文 +- [ ] iOS系统语言设为繁体中文 → App显示繁体 +- [ ] iOS系统时区设为非上海 → App时区正确 + +--- + +## 实施顺序 + +1. **Phase 1.3**: 扩展SessionStore(最小改动,无依赖) +2. **Phase 1.1-1.2**: 创建工具函数 +3. **Phase 1.4**: 修改启动流程 +4. **Phase 3.1**: 后端Schema扩展 +5. **Phase 3.2-3.3**: 后端Service逻辑 +6. **Phase 2**: 前端注册流程扩展 +7. **Phase 4**: 登录后同步时区 +8. **Phase 5**: 测试验证 + +--- + +## 依赖 + +- 可选: `flutter_timezone` 包(如需精确时区ID) + - 添加到 `apps/pubspec.yaml` + - 或使用 `DateTime.now().timeZoneName` + 映射表(无依赖) diff --git a/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/prd.md b/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/prd.md new file mode 100644 index 0000000..cc51912 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/prd.md @@ -0,0 +1,106 @@ +# PRD: App启动时语言和时区自动设置 + +## 背景 + +当前App启动时语言硬编码为 `zh`,不读取iOS系统语言;时区完全不处理。需要实现三种场景的自动设置逻辑。 + +## 需求 + +### 场景1:新用户首次打开App → 注册时写入后端 + +1. App无本地存储信息(首次打开) +2. 读取iOS系统语言和时区 +3. 自动设置App界面语言和时区 +4. 用户注册时,将语言和时区作为请求参数传给后端 +5. 后端创建用户Profile时使用传入值 + +### 场景2:已有用户在新设备首次登录 → 从服务器同步 + +1. App无本地存储信息(新设备首次打开) +2. 读取iOS系统语言和时区作为临时值 +3. 自动设置App界面语言和时区(临时) +4. 用户登录成功后,从服务器拉取Profile中的语言和时区 +5. 用服务器值更新本地存储和App设置 + +### 场景3:有本地存储 → 使用本地值 + +1. App有本地存储的语言和时区信息 +2. 直接使用本地值,不读取系统语言和时区 +3. 登录后仍从服务器同步(确保一致性) + +## 技术方案 + +### 前端 + +1. **读取系统语言** + - 使用 `PlatformDispatcher.instance.locale` 获取系统首选语言 + - 通过 `resolveSystemLocale()` 映射到 App 支持的 Locale + +2. **读取系统时区** + - 使用 `flutter_timezone` 包的 `getLocalTimezone()` 获取 IANA 时区 ID + - 直接使用返回值,后端会验证有效性 + +3. **本地存储扩展** + - `SessionStore` 新增 `saveTimezone()` / `getTimezone()` + - 现有 `saveLocaleTag()` / `getLocaleTag()` 已支持语言 + +4. **启动流程修改** (`app.dart:_bootstrap`) + ``` + if (本地有locale) { + 使用本地locale + } else { + 读取系统locale → 保存到本地 + } + + if (本地有时区) { + 使用本地时区 + } else { + 读取系统时区 → 保存到本地 + } + ``` + +5. **注册请求扩展** + - `AuthApi.createEmailSession` 新增 `language` 和 `timezone` 参数 + - `EmailSessionCreateRequest` schema 扩展 + +6. **登录后同步** + - 现有 `_refreshProfile()` 已同步语言 + - 扩展同步时区 + +### 后端 + +1. **Schema扩展** (`v1/auth/schemas.py`) + ```python + class EmailSessionCreateRequest(BaseModel): + email: str + token: str + language: str | None = None # BCP-47 tag + timezone: str | None = None # IANA timezone + ``` + +2. **Service逻辑** (`v1/auth/service.py`) + - `create_email_session` 接收语言/时区 + - 创建/更新Profile时使用传入值(如果提供) + +3. **Profile默认值保持不变** + - `language: str = "zh-CN"` + - `timezone: str = "Asia/Shanghai"` + +## 验收标准 + +1. 新设备首次打开App,界面语言与iOS系统语言一致 +2. 新用户注册后,后端Profile的语言/时区与App一致 +3. 已有用户登录后,App语言/时区与服务器Profile一致 +4. 有本地存储时,App启动不请求系统语言/时区 +5. 支持的语言映射: + - iOS `en` / `en-US` / `en-GB` → Flutter `Locale('en')` → 存储 `en-US` + - iOS `zh` / `zh-CN` / `zh-SG` / `zh-Hans-*` → Flutter `Locale('zh')` → 存储 `zh-CN` + - iOS `zh-Hant` / `zh-TW` / `zh-HK` / `zh-MO` → Flutter `Locale(zh, Hant)` → 存储 `zh-Hant` +6. 时区为有效IANA ID (如 `Asia/Shanghai`, `America/New_York`) + +## 风险 + +1. iOS系统语言可能不在支持列表中 → 回退到 `zh-CN` +2. `flutter_timezone` 包可能返回无效时区 → 后端验证 + 前端回退默认值 +3. 用户修改系统语言/时区后,App不会自动更新(符合场景3设计) +4. 新用户注册时网络请求失败 → Profile 使用后端默认值,用户可在设置中修改 diff --git a/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/task.json b/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/task.json new file mode 100644 index 0000000..446daae --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-feat-locale-timezone-bootstrap/task.json @@ -0,0 +1,54 @@ +{ + "title": "App启动时语言和时区自动设置", + "slug": "04-28-feat-locale-timezone-bootstrap", + "status": "completed", + "created_at": "2026-04-28", + "phases": [ + { + "name": "Phase 1: 前端读取系统语言/时区", + "status": "pending", + "items": [ + "1.1 创建系统Locale工具函数", + "1.2 创建系统时区工具函数", + "1.3 扩展SessionStore存储时区", + "1.4 修改App启动流程_bootstrap" + ] + }, + { + "name": "Phase 2: 前端注册时传递语言/时区", + "status": "pending", + "items": [ + "2.1 扩展AuthApi.createEmailSession", + "2.2 扩展AuthRepository", + "2.3 扩展AuthBloc.loginWithOtp", + "2.4 修改App调用点传递语言/时区" + ] + }, + { + "name": "Phase 3: 后端接收语言/时区", + "status": "pending", + "items": [ + "3.1 扩展EmailSessionCreateRequest Schema", + "3.2 扩展AuthService.create_email_session", + "3.3 新增Profile更新方法" + ] + }, + { + "name": "Phase 4: 前端登录后同步时区", + "status": "pending", + "items": [ + "4.1 修改_refreshProfile同步时区" + ] + }, + { + "name": "Phase 5: 测试验证", + "status": "pending", + "items": [ + "5.1 单元测试", + "5.2 集成测试", + "5.3 手动测试" + ] + } + ], + "completedAt": "2026-04-28" +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/IMPLEMENTATION_PLAN.md b/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..02196b4 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/IMPLEMENTATION_PLAN.md @@ -0,0 +1,334 @@ +# IMPLEMENTATION_PLAN:积分流水列表功能 + +## 概述 + +本计划按 trellis 工作流制定,遵循 `schema → repository → service → router` 后端分层和 `data → presentation` 前端分层原则。 + +## 前置条件确认 + +| 条件 | 状态 | 说明 | +|------|------|------| +| `points_ledger` 表存在 | ✅ | 结构完整,有索引支持分页 | +| Repository 写入方法存在 | ✅ | `append_ledger()` 已实现 | +| 积分中心页面存在 | ✅ | `CoinCenterScreen` 可添加入口 | +| 前端 points feature 存在 | ✅ | `features/points/` 目录已存在 | + +## 实现步骤 + +### Step 1: 后端 Schema 定义 + +**文件**:`backend/src/v1/points/schemas.py` + +**新增内容**: +```python +class LedgerItem(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + id: str + direction: int # 1=收入, -1=支出 + amount: int = Field(ge=1) + balance_after: int = Field(alias="balanceAfter", ge=0) + change_type: str = Field(alias="changeType") + display_text: str = Field(alias="displayText") + created_at: str = Field(alias="createdAt") + + +class LedgerListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + items: list[LedgerItem] + next_cursor: str | None = Field(alias="nextCursor", default=None) + has_more: bool = Field(alias="hasMore") +``` + +### Step 2: 后端 Repository 方法 + +**文件**:`backend/src/v1/points/repository.py` + +**新增方法**: +```python +async def list_ledger( + self, + *, + user_id: UUID, + limit: int, + cursor: datetime | None = None, +) -> tuple[list[PointsLedger], bool]: + # 按 created_at DESC 分页查询 + # cursor 为上一页最后一条的 created_at + # 多查一条判断 has_more +``` + +### Step 3: 后端 Service 方法 + +**文件**:`backend/src/v1/points/service.py` + +**新增方法**: +```python +async def get_ledger_list( + self, + *, + user_id: UUID, + limit: int = 20, + cursor: str | None = None, +) -> tuple[list[LedgerItem], str | None, bool]: + # 1. 解析 cursor 为 datetime + # 2. 调用 repository.list_ledger() + # 3. 组装 display_text(根据 change_type) + # 4. 返回 (items, next_cursor, has_more) +``` + +**display_text 映射**: +```python +CHANGE_TYPE_TEXT = { + "register": ("注册赠送", "Registration bonus"), + "purchase": ("购买积分包", "Purchase credits"), + "consume": ("AI 对话消耗", "AI chat cost"), + "adjust": ("系统调整", "System adjustment"), + "refund": ("退款", "Refund"), +} +``` + +### Step 4: 后端 Router 端点 + +**文件**:`backend/src/v1/points/router.py` + +**新增端点**: +```python +@router.get("/ledger", response_model=LedgerListResponse) +async def get_points_ledger( + service: Annotated[PointsService, Depends(get_points_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], + limit: int = Query(default=20, ge=1, le=100), + cursor: str | None = Query(default=None), +) -> LedgerListResponse: + ... +``` + +### Step 5: 前端 Model 定义 + +**文件**:`apps/lib/features/points/data/models/ledger_item.dart` + +```dart +class LedgerItem { + const LedgerItem({ + required this.id, + required this.direction, + required this.amount, + required this.balanceAfter, + required this.changeType, + required this.displayText, + required this.createdAt, + }); + + final String id; + final int direction; // 1=收入, -1=支出 + final int amount; + final int balanceAfter; + final String changeType; + final String displayText; + final String createdAt; + + factory LedgerItem.fromJson(Map json) => ...; +} + +class LedgerListResult { + const LedgerListResult({ + required this.items, + this.nextCursor, + required this.hasMore, + }); + + final List items; + final String? nextCursor; + final bool hasMore; +} +``` + +### Step 6: 前端 API 方法 + +**文件**:`apps/lib/features/points/data/apis/points_api.dart` + +**新增方法**: +```dart +Future getLedger({ + int limit = 20, + String? cursor, +}) async { + final query = {'limit': limit}; + if (cursor != null) query['cursor'] = cursor; + final response = await _dio.get('/api/v1/points/ledger', queryParameters: query); + return LedgerListResult.fromJson(response.data); +} +``` + +### Step 7: 前端流水列表页面 + +**文件**:`apps/lib/features/points/presentation/screens/points_ledger_screen.dart` + +**功能**: +- 标题:积分流水 +- 列表项:类型图标 + 文案 + 金额(颜色区分) + 时间 +- 分页加载:滚动到底部加载更多 +- 空状态:暂无流水记录 +- Loading 状态 + +### Step 8: 重命名 AccountDeleteScreen 为 AccountDataScreen + +**文件操作**: +1. 重命名文件:`account_delete_screen.dart` → `account_data_screen.dart` +2. 重命名类:`AccountDeleteScreen` → `AccountDataScreen` +3. 更新类内状态名:`_AccountDeleteScreenState` → `_AccountDataScreenState` + +**页面结构变更**: +```dart +// 之前:只有删除账号入口 +body: ListView( + children: [ + SettingsGroupCard( + children: [ + SettingsMenuTile(icon: Icons.delete_outline_rounded, ...), + ], + ), + ], +), + +// 之后:积分流水 + 删除账号 +body: ListView( + children: [ + SettingsGroupCard( + children: [ + SettingsMenuTile( + icon: Icons.receipt_long_rounded, + title: l10n.pointsLedgerTitle, // 积分流水 + tint: colors.primary, + background: colors.surfaceContainerHighest, + onTap: _openPointsLedger, + ), + SettingsMenuTile( + icon: Icons.delete_outline_rounded, + title: l10n.settingsDeleteAccountTitle, + tint: colors.error, + background: colors.surfaceContainerHighest, + titleColor: colors.error, + showDivider: false, + onTap: _confirmDelete, + ), + ], + ), + ], +), +``` + +**新增方法**: +```dart +Future _openPointsLedger() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PointsLedgerScreen( + apiClient: widget.apiClient, + userId: widget.userId, + ), + ), + ); +} +``` + +**新增 widget 参数**: +```dart +class AccountDataScreen extends StatefulWidget { + const AccountDataScreen({ + super.key, + required this.onDeleteAccount, + required this.apiClient, // 新增 + required this.userId, // 新增 + }); + ... +} +``` + +### Step 9: 更新 SettingsScreen 导入和调用 + +**文件**:`apps/lib/features/settings/presentation/screens/settings_screen.dart` + +**修改**: +1. 更新导入:`import 'account_data_screen.dart';`(替换原 `account_delete_screen.dart`) +2. 更新 `_openAccountDelete()` 方法,传递新增参数: +```dart +Future _openAccountData() async { + final deleted = await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => AccountDataScreen( + onDeleteAccount: widget.onDeleteAccount, + apiClient: widget.apiClient, + userId: widget.userId, + ), + ), + ); + ... +} +``` +3. 更新菜单项标题(如需要) + +### Step 10: 添加 i18n 文案 + +**文件**: +- `apps/lib/l10n/app_zh.arb` +- `apps/lib/l10n/app_en.arb` +- `apps/lib/l10n/app_zh_hant.arb` + +**新增文案**: +```json +"pointsLedgerTitle": "积分流水", +"pointsLedgerEmpty": "暂无流水记录", +"pointsLedgerLoadingMore": "加载中..." +``` + +## 验证步骤 + +### 后端验证 + +```bash +# 启动后端 +./infra/scripts/app.sh start + +# 测试 API +curl -H "Authorization: Bearer " \ + "http://localhost:8000/api/v1/points/ledger?limit=10" +``` + +### 前端验证 + +```bash +cd apps +flutter run +# 导航到设置 -> 积分中心 -> 点击「查看流水」 +``` + +## 风险与注意事项 + +1. **RLS 策略**:`points_ledger` 表已有 RLS,确保查询只返回当前用户数据 +2. **游标分页**:使用 `created_at` 作为游标,注意处理相同时间戳的情况 +3. **性能**:索引 `ix_points_ledger_user_created_at` 已存在,查询性能有保障 +4. **空状态**:新用户可能无流水,需处理空列表情况 + +## 文件变更清单 + +### 后端新增/修改 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `backend/src/v1/points/schemas.py` | 修改 | 新增 LedgerItem、LedgerListResponse | +| `backend/src/v1/points/repository.py` | 修改 | 新增 list_ledger() | +| `backend/src/v1/points/service.py` | 修改 | 新增 get_ledger_list() | +| `backend/src/v1/points/router.py` | 修改 | 新增 GET /ledger 端点 | + +### 前端新增/修改 + +| 文件 | 操作 | 说明 | +|------|------|------| +| `apps/lib/features/points/data/models/ledger_item.dart` | 新增 | 流水项模型 | +| `apps/lib/features/points/data/apis/points_api.dart` | 修改 | 新增 getLedger() | +| `apps/lib/features/points/presentation/screens/points_ledger_screen.dart` | 新增 | 流水列表页面 | +| `apps/lib/features/settings/presentation/screens/coin_center_screen.dart` | 修改 | 添加入口按钮 | +| `apps/lib/l10n/app_*.arb` | 修改 | 新增文案 | diff --git a/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/prd.md b/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/prd.md new file mode 100644 index 0000000..1fcace7 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/prd.md @@ -0,0 +1,201 @@ +# PRD:积分流水列表功能 + +## 1. 背景与目标 + +当前应用已有完整的积分账户系统: +- 积分账户表:`user_points` +- 积分业务流水:`points_ledger`(记录所有积分变动) +- 积分余额查询接口:`GET /api/v1/points/balance` +- 积分中心页面:`CoinCenterScreen` + +用户在积分中心可以看到余额和购买套餐,但无法查看积分的收支明细。本任务目标是增加积分流水列表功能,让用户了解自己的积分变动历史。 + +## 2. 当前事实 + +### 2.1 后端现状 + +**已有的 `points_ledger` 表结构**: +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | UUID | 主键 | +| `user_id` | UUID | 用户 ID | +| `direction` | smallint | 方向:1=收入,-1=支出 | +| `amount` | bigint | 变动数量(正数) | +| `balance_after` | bigint | 变动后余额 | +| `change_type` | varchar | 类型:register/consume/adjust/purchase/refund | +| `biz_type` | varchar | 业务类型:chat/payment(可空) | +| `biz_id` | UUID | 业务 ID(可空) | +| `event_id` | varchar | 幂等事件 ID | +| `operator_id` | UUID | 操作者 ID(可空) | +| `metadata` | jsonb | 扩展元数据 | +| `created_at` | timestamptz | 创建时间 | + +**已有的索引**: +- `ix_points_ledger_user_created_at`:支持按用户+时间倒序查询 +- `uq_points_ledger_user_event`:用户+事件唯一约束 + +**已有的 Repository 方法**: +- `append_ledger()`:写入流水 +- `has_ledger_event()`:检查事件是否存在 + +**缺失**: +- 无分页查询流水列表方法 +- 无 HTTP API 端点 + +### 2.2 前端现状 + +**已有的积分中心页面**: +- `apps/lib/features/settings/presentation/screens/coin_center_screen.dart` +- 显示余额和套餐卡片 + +**已有的 API 调用**: +- `apps/lib/features/points/data/apis/points_api.dart`:仅支持 `getPackages()` + +**缺失**: +- 无流水列表 API 调用 +- 无流水列表页面/组件 + +## 3. 需求范围 + +### 3.1 必须实现 + +**后端**: +- 新增 `GET /api/v1/points/ledger` 接口 +- 支持分页(游标分页,按 `created_at` 倒序) +- 返回流水列表,包含:时间、类型、金额、余额、描述 + +**前端**: +- 在积分中心页面添加「查看流水」入口 +- 新建流水列表页面,支持分页加载 +- 流水项展示:类型图标、类型文案、金额(+绿色/-红色)、时间、余额 + +### 3.2 本期不做 + +- 按类型筛选(可作为后续优化) +- 导出流水 +- 流水详情页 + +## 4. 数据契约 + +### 4.1 API 接口 + +**请求**: +``` +GET /api/v1/points/ledger?limit=20&cursor={created_at_iso} +``` + +**响应**: +```json +{ + "items": [ + { + "id": "uuid", + "direction": 1, + "amount": 100, + "balanceAfter": 500, + "changeType": "purchase", + "displayText": "购买积分包", + "createdAt": "2026-04-28T10:00:00Z" + } + ], + "nextCursor": "2026-04-27T10:00:00Z", + "hasMore": true +} +``` + +### 4.2 change_type 对应展示文案 + +| change_type | direction | 展示文案(中文) | 展示文案(英文) | +|-------------|-----------|------------------|------------------| +| register | 1 | 注册赠送 | Registration bonus | +| purchase | 1 | 购买积分包 | Purchase credits | +| consume | -1 | AI 对话消耗 | AI chat cost | +| adjust | ±1 | 系统调整 | System adjustment | +| refund | -1 | 退款 | Refund | + +## 5. 技术方案 + +### 5.1 后端实现 + +**Repository 层**(`v1/points/repository.py`): +```python +async def list_ledger( + self, + *, + user_id: UUID, + limit: int, + cursor: datetime | None = None, +) -> tuple[list[PointsLedger], bool]: + # 查询 points_ledger 表 + # 按 created_at DESC 分页 + # 返回 (items, has_more) +``` + +**Service 层**(`v1/points/service.py`): +```python +async def get_ledger_list( + self, + *, + user_id: UUID, + limit: int = 20, + cursor: str | None = None, +) -> LedgerListResult: + # 调用 repository + # 组装 display_text + # 返回响应 +``` + +**Router 层**(`v1/points/router.py`): +```python +@router.get("/ledger", response_model=LedgerListResponse) +async def get_points_ledger(...): + ... +``` + +### 5.2 前端实现 + +**目录结构**: +``` +apps/lib/features/points/ +├── data/ +│ ├── apis/ +│ │ └── points_api.dart # 新增 getLedger() +│ └── models/ +│ ├── package_info.dart # 已有 +│ └── ledger_item.dart # 新增 +└── presentation/ + └── screens/ + └── points_ledger_screen.dart # 新增 +``` + +**入口**: +- 在 `CoinCenterScreen` 的余额卡片下方添加「查看流水」按钮 +- 点击后导航到 `PointsLedgerScreen` + +## 6. 实现步骤 + +### Phase 1: 后端 API + +1. 新增 Schema:`LedgerItem`、`LedgerListResponse`(`v1/points/schemas.py`) +2. 新增 Repository 方法:`list_ledger()`(`v1/points/repository.py`) +3. 新增 Service 方法:`get_ledger_list()`(`v1/points/service.py`) +4. 新增 Router 端点:`GET /ledger`(`v1/points/router.py`) +5. 编写单元测试 + +### Phase 2: 前端 UI + +1. 新增 Model:`LedgerItem`(`features/points/data/models/ledger_item.dart`) +2. 新增 API 方法:`getLedger()`(`features/points/data/apis/points_api.dart`) +3. 新增页面:`PointsLedgerScreen`(`features/points/presentation/screens/points_ledger_screen.dart`) +4. 修改 `CoinCenterScreen`,添加入口按钮 +5. 添加 i18n 文案(`app_zh.arb`、`app_en.arb`、`app_zh_hant.arb`) + +## 7. 验收标准 + +- [ ] 后端 API 返回正确的分页数据 +- [ ] 前端能正确加载并展示流水列表 +- [ ] 流水类型显示对应文案 +- [ ] 金额按收支方向显示不同颜色 +- [ ] 分页加载正常工作 +- [ ] 无数据时显示空状态 +- [ ] 加载中显示 loading 状态 diff --git a/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/task.json b/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/task.json new file mode 100644 index 0000000..0e91c31 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-feat-points-ledger/task.json @@ -0,0 +1,85 @@ +{ + "title": "feat: 积分流水列表功能", + "slug": "feat-points-ledger", + "status": "completed", + "created_at": "2026-04-28", + "developer": "opencode", + "description": "增加积分流水列表功能,入口放在「账号与数据」页面", + "prd": "prd.md", + "implementation_plan": "IMPLEMENTATION_PLAN.md", + "checklist": [ + { + "phase": "backend", + "items": [ + { + "task": "新增 LedgerItem、LedgerListResponse Schema", + "file": "backend/src/v1/points/schemas.py", + "done": true + }, + { + "task": "新增 list_ledger() Repository 方法", + "file": "backend/src/v1/points/repository.py", + "done": true + }, + { + "task": "新增 get_ledger_list() Service 方法", + "file": "backend/src/v1/points/service.py", + "done": true + }, + { + "task": "新增 GET /ledger Router 端点", + "file": "backend/src/v1/points/router.py", + "done": true + }, + { + "task": "后端 API 测试通过", + "done": true + } + ] + }, + { + "phase": "frontend", + "items": [ + { + "task": "新增 LedgerItem 模型", + "file": "apps/lib/features/points/data/models/ledger_item.dart", + "done": true + }, + { + "task": "新增 getLedger() API 方法", + "file": "apps/lib/features/points/data/apis/points_api.dart", + "done": true + }, + { + "task": "新增 PointsLedgerScreen 页面", + "file": "apps/lib/features/points/presentation/screens/points_ledger_screen.dart", + "done": true + }, + { + "task": "重命名 AccountDeleteScreen → AccountDataScreen,添加积分流水入口", + "file": "apps/lib/features/settings/presentation/screens/account_data_screen.dart", + "done": true + }, + { + "task": "更新 SettingsScreen 导入和调用", + "file": "apps/lib/features/settings/presentation/screens/settings_screen.dart", + "done": true + }, + { + "task": "添加 i18n 文案", + "file": "apps/lib/l10n/app_*.arb", + "done": true + }, + { + "task": "运行 flutter gen-l10n 生成代码", + "done": true + }, + { + "task": "前端功能测试通过", + "done": true + } + ] + } + ], + "completedAt": "2026-04-28" +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/IMPLEMENTATION_PLAN.md b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..f383b2e --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/IMPLEMENTATION_PLAN.md @@ -0,0 +1,329 @@ +# IMPLEMENTATION PLAN:修复 AI 英文输出失效 + +## 前置条件 + +| 条件 | 状态 | +|------|------| +| `language` 参数已从 runner 正确读取 | ✅ | +| `_build_output_rules` 正确注入语言要求 | ✅ | +| `get_worker_output_rules` 按 language 分发 | ✅ | +| 已知 role playing 忽略 language | ✅ 确认为根因 | + +## 实现步骤 + +### Step 1: 新增英文版角色扮演提示词 + +**文件**: `backend/src/core/agentscope/prompts/worker_rules.py` + +```python +_WORKER_ROLE_PLAYING_EN = """\ +You are a Liu Yao (Six Lines) divination master who strictly follows the logic of Five Elements (Wu Xing) generation-restriction and hexagram imagery. Your sole task is to produce rule-based professional interpretations based on the structured hexagram data provided. + +[Boundaries & Prohibitions] +- Only deduce from the six-line information in the input data. Never fabricate data. +- Never introduce external systems such as astrology, Tarot, Ba Zi (Eight Characters), or Zi Wei. +- Never quote long passages from the original I Ching text. Liu Yao centers on Five Elements generation and restriction. + +[Deduction Axioms] (in descending priority) +1. Hexagram Primacy Rule: First determine the hexagram type (Six-Clash hexagram → matters dissolve quickly and scatter; Six-Union hexagram → matters progress slowly and converge). This is the irreversible background tone. Then examine line changes. +2. Movement-Stillness Rule: Still lines cannot form special patterns (Three Union, Six Union) among themselves unless there is a moving line or Day-Month induction. A changing line whose transformed line encounters Void, Break, Tomb, or Severed is treated as movement without result — the matter falls through. +3. Generation-Restriction Priority: All generation and restriction is ultimately adjudicated by Month Branch prosperity/decline and Day Stem generation/restriction. The Five Element statuses in the input data are established facts and must not be altered. + +[Six Relatives (Liu Qin) Category Mapping] +Based on the question type, the Six Relatives map as follows: + +Career/Work questions: +- Officer (Guan Gui): supervisor, work pressure, position, authority +- Parent (Fu Mu): documents, contracts, projects, organization, credentials +- Wealth (Qi Cai): salary, income, resources +- Children (Zi Sun): subordinates, skills, relief from trouble +- Sibling (Xiong Di): colleagues, competitors + +Wealth/Investment questions: +- Wealth (Qi Cai): financial resources, earnings, capital (primary Yong Shen) +- Sibling (Xiong Di): wealth-draining, competition, risk +- Children (Zi Sun): source of wealth, blessings +- Parent (Fu Mu): documents, licenses, platforms +- Officer (Guan Gui): wealth depletion, pressure + +Relationships/Marriage questions: +- Male querent: Wealth line represents the partner; Officer line represents romantic rival +- Female querent: Officer line represents the partner; Wealth line represents romantic rival +- Parent (Fu Mu): marriage contract, documents, family +- Children (Zi Sun): children, relief + +Health/Illness questions: +- Officer (Guan Gui): illness, pathology (Ji Shen / feared spirit) +- Children (Zi Sun): medicine, doctor, relief spirit (Yong Shen / useful spirit) +- Parent (Fu Mu): hospital, elders +- Sibling (Xiong Di): peers, support + +[Thinking Chain Requirement] +You must explicitly output your reasoning in the following order: + +1. Hexagram Classification: Determine the hexagram type (Six-Clash / Six-Union / Returning Spirit / Wandering Spirit) and establish the macro backdrop. +2. Yong Shen Identification: Based on the question, identify the Yong Shen (useful spirit) and Ji Shen (feared spirit). Check whether they appear in the hexagram and whether they are changing lines. +3. Prosperity & Void: Month Branch determines prosperity/decline (Prosperous / Strong / Resting / Imprisoned / Dead). Day Stem determines generation/restriction (Twelve Growth Stages and Clash/Union). Movement and change determine substance vs. void (advancing / retreating / turning void / turning break). +4. Generation-Restriction Chains: List the specific generation-restriction chains between Self Line, Response Line, moving lines, changing lines, Day, and Month. Explain each one's effect on the Yong Shen. +5. Special Combinations: Only when permitted by the Movement-Stillness Rule, assess hidden movement, Three Union patterns, reverse generation/restriction, etc. +6. Comprehensive Verdict: Combine the hexagram backdrop with the line dynamics to produce the trend conclusion, core risk points, and turning-point conditions. + +[Strength Hierarchy] +- When a changing line generates or restricts in reverse, the changing line's strength exceeds the original line +- Self-Response > Moving Lines > Changing Lines > Day-Month > Still Lines + +[Expression Style] +Professional, precise, restrained. Speak like someone who truly reads hexagrams. +Do not write literary prose, do not pile on vague words, do not feign profundity. +You may explain, but all explanation must be anchored to the hexagram image itself. +Your goal is not to 'sound like' a divination reading, but to actually interpret according to Liu Yao rules. + +[Sign-Level Reference Anchoring] +Sign-level assessment should integrate hexagram backdrop and movement/change auspiciousness, referencing the following principles: + +- Top-Top (Shang Shang): Six-Union hexagram or non-Six-Clash hexagram + Yong Shen prosperous + moving line generates Self/Yong Shen with strength + no reverse restriction, void, or break. +- Upper-Middle (Zhong Shang): Non-Six-Clash hexagram + Yong Shen has vitality + minor obstructions exist (e.g. Yong Shen still and unmoving, or Ji Shen secretly moves but can be restrained). +- Lower-Middle (Zhong Xia): Six-Clash hexagram with inauspicious backdrop / Yong Shen weak / Yong Shen receives restriction but still has rescue / moving-then-reverse-restriction but Self Line unharmed. +- Bottom-Bottom (Xia Xia): Six-Clash hexagram + Yong Shen monthly break and void + moving line reverse-restricts Self/restricts Yong Shen + Day and Month offer no help. + +When the hexagram shows mixed auspicious and inauspicious signs, the 'hexagram backdrop' takes first weight and the 'Self Line's safety' takes second weight. +""" +``` + +更新 `get_worker_role_playing`: + +```python +def get_worker_role_playing(language: str) -> str: + if language.startswith("en"): + return _WORKER_ROLE_PLAYING_EN + if language.startswith("zh-Hant") or language.startswith("zh_Hant"): + return _WORKER_ROLE_PLAYING # 目前暂用简体版,后续可补充繁体版 + return _WORKER_ROLE_PLAYING +``` + +--- + +### Step 2: 安全规则支持中英文 + +**文件**: `backend/src/core/agentscope/prompts/system_prompt.py` + +在 `_build_safety_section` 中增加英文版: + +```python +_SAFETY_RULES_ZH = "\n".join([ + "[Safety Rules]", + "- 你是六爻解卦助手,只回答与六爻占卜、卦象分析、易理探讨相关的问题。遇到无关提问时,明确告知超出服务范围,不做任何妥协或绕行。", + "- 拒绝回答任何与六爻无关的问题,包括但不限于:政治、军事、违法活动、个人隐私窃取、有害信息等。", + "- Never expose secrets, tokens, credentials, or private identifiers.", + "- Do not invent tool outputs, user data, or system state.", + "- Never bypass schema constraints (enum/type/required/extra fields).", + "- If required data is missing, ask minimal clarification or return constrained safe output.", +]) + +_SAFETY_RULES_EN = "\n".join([ + "[Safety Rules]", + "- You are a Liu Yao (Six Lines) divination assistant. Only answer questions related to Liu Yao divination, hexagram analysis, and I Ching philosophy. When encountering unrelated questions, clearly state the scope limitation without compromise or circumvention.", + "- Refuse to answer any questions unrelated to Liu Yao, including but not limited to: politics, military, illegal activities, personal privacy theft, harmful information, etc.", + "- Never expose secrets, tokens, credentials, or private identifiers.", + "- Do not invent tool outputs, user data, or system state.", + "- Never bypass schema constraints (enum/type/required/extra fields).", + "- If required data is missing, ask minimal clarification or return constrained safe output.", +]) + +def _build_safety_section(*, language: str) -> str: + if language.startswith("en"): + return wrap_section("safety", _SAFETY_RULES_EN) + return wrap_section("safety", _SAFETY_RULES_ZH) +``` + +`build_system_prompt` 调用改为 `_build_safety_section(language=language)`。 + +--- + +### Step 3: User Prompt 国际化 + +**文件**: `backend/src/core/agentscope/prompts/user_prompt.py` + +将硬编码的中文字段标签抽取为映射表,根据 `language` 选择: + +```python +_ZH_FIELDS = { + "user_question": "用户问题", + "question_type": "问题类型", + "divination_method": "起卦方式", + "divination_time": "起卦时间", + "ben_gua": "【本卦】", + "gua_name_tpl": "卦名:{name}(上{upper}下{lower})", + "gua_xiang": "卦象", + "bian_gua": "【变卦】", + "ganzhi": "【干支】", + "year_pillar": "年柱", "month_pillar": "月柱", "day_pillar": "日柱", "time_pillar": "时柱", + "yue_jian": "月建", "ri_chen": "日辰", "yue_po": "月破", "ri_chong": "日冲", + "year_kong": "年空亡", "month_kong": "月空亡", "day_kong": "日空亡", "time_kong": "时空亡", + "wu_xing": "【五行旺衰】", + "ben_yao": "【本卦爻象】", + "yao_position": "第{pos}爻", + "yang_yao": "阳", "yin_yao": "阴", + "dong_mark": "(动)", "shi_mark": "世", "ying_mark": "应", + "bian_yao": "【变卦爻象】", + "fushen": "【伏神】", + "special_status": "【特殊状态标注】", + "interactions": "【全局冲合提示】", + "time_effect": "【时令关键点】", + "ri_chen_zhang_sheng": "【日辰十二长生】", + "closing": "——以上为起卦所得完整数据,请据此进行六爻解读。", +} + +_EN_FIELDS = { + "user_question": "User Question", + "question_type": "Question Type", + "divination_method": "Divination Method", + "divination_time": "Divination Time", + "ben_gua": "[Original Hexagram]", + "gua_name_tpl": "Name: {name} (Upper: {upper}, Lower: {lower})", + "gua_xiang": "Trigram Code", + "bian_gua": "[Changed Hexagram]", + "ganzhi": "[Stems & Branches]", + "year_pillar": "Year Pillar", "month_pillar": "Month Pillar", "day_pillar": "Day Pillar", "time_pillar": "Time Pillar", + "yue_jian": "Month Branch", "ri_chen": "Day Stem", "yue_po": "Month Break", "ri_chong": "Day Clash", + "year_kong": "Year Void", "month_kong": "Month Void", "day_kong": "Day Void", "time_kong": "Time Void", + "wu_xing": "[Five Element Status]", + "ben_yao": "[Original Hexagram Lines]", + "yao_position": "Line {pos}", + "yang_yao": "Yang", "yin_yao": "Yin", + "dong_mark": " (changing)", "shi_mark": " Self", "ying_mark": " Response", + "bian_yao": "[Changed Hexagram Lines]", + "fushen": "[Hidden Lines (Fu Shen)]", + "special_status": "[Special Status Annotations]", + "interactions": "[Global Clash/Union Notes]", + "time_effect": "[Seasonal Key Points]", + "ri_chen_zhang_sheng": "[Day Stem Twelve Growth Stages]", + "closing": "—— End of hexagram data. Please interpret according to Liu Yao principles.", +} + +def _get_field_map(language: str) -> dict[str, str]: + if language.startswith("en"): + return _EN_FIELDS + return _ZH_FIELDS +``` + +`build_divination_user_prompt` 签名变为 `build_divination_user_prompt(*, derived: DerivedDivinationData, language: str = "zh-CN")`。 + +--- + +### Step 4: Runner 调用链传递 language + +**文件**: `backend/src/core/agentscope/runtime/runner.py` + +1. `_build_worker_input_messages` 增加 `language` 参数并传递给 `build_divination_user_prompt`: + +```python +def _build_worker_input_messages( + self, + *, + context_messages: list[Msg], + run_input: RunAgentInput, + derived_divination: DerivedDivinationData | None, + language: str, +) -> list[Msg]: + if derived_divination is not None: + user_text = build_divination_user_prompt(derived=derived_divination, language=language) + else: + user_text, _ = extract_latest_user_payload(run_input) + ... +``` + +2. `_execute_worker_step` 调用 `_build_worker_input_messages` 时传入 `language`。 + +--- + +### Step 5: 新增/更新测试 + +**文件**: `backend/tests/unit/test_agentscope_prompts.py` + +新增测试用例: + +```python +def test_system_prompt_en_has_english_role_playing() -> None: + """English system prompt should contain English role playing content.""" + prompt = build_system_prompt( + agent_type=AgentType.WORKER, + language="en-US", + llm_config=SystemAgentLLMConfig(), + ) + assert "Liu Yao" in prompt or "divination master" in prompt + assert "[Boundaries" in prompt or "Career/Work" in prompt + + +def test_system_prompt_en_safety_is_english() -> None: + """English safety section should be in English.""" + prompt = build_system_prompt( + agent_type=AgentType.WORKER, + language="en-US", + llm_config=SystemAgentLLMConfig(), + ) + assert "scope limitation" in prompt + + +def test_system_prompt_zh_cn_role_playing_unchanged() -> None: + """Chinese system prompt should retain original Chinese role playing.""" + prompt = build_system_prompt( + agent_type=AgentType.WORKER, + language="zh-CN", + llm_config=SystemAgentLLMConfig(), + ) + assert "六爻解卦师" in prompt + assert "推演公理" in prompt + + +def test_agent_prompt_en_has_english_output_rules() -> None: + """English agent prompt should contain English output rules.""" + prompt = build_agent_prompt( + agent_type=AgentType.WORKER, + language="en-US", + llm_config=SystemAgentLLMConfig(), + ) + assert "focus_points" in prompt + assert "opening paragraph must state" in prompt + assert "段间用" not in prompt # No Chinese formatting rules + + +def test_agent_prompt_zh_cn_unchanged() -> None: + """Chinese agent prompt should remain unchanged.""" + prompt = build_agent_prompt( + agent_type=AgentType.WORKER, + language="zh-CN", + llm_config=SystemAgentLLMConfig(), + ) + assert "段间用\\n\\n" in prompt + assert "六爻解卦师" in prompt + + +def test_user_prompt_en_has_english_labels() -> None: + """English user prompt should have English field labels.""" + from core.agentscope.prompts.user_prompt import build_divination_user_prompt + # Need a minimal DerivedDivinationData for testing + # ... assert "User Question" in result + + +def test_user_prompt_zh_cn_unchanged() -> None: + """Chinese user prompt should remain unchanged.""" + from core.agentscope.prompts.user_prompt import build_divination_user_prompt + # ... assert "用户问题" in result +``` + +--- + +## 验证 + +```bash +# 运行测试 +cd backend +uv run pytest tests/unit/test_agentscope_prompts.py -v + +# 类型检查 +uv run basedpyright src/core/agentscope/ + +# Lint +uv run ruff check src/core/agentscope/ +``` diff --git a/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/check.jsonl b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/check.jsonl new file mode 100644 index 0000000..7c579ba --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/check.jsonl @@ -0,0 +1,2 @@ +{"file": ".opencode/commands/trellis/finish-work.md", "reason": "Finish work checklist"} +{"file": ".opencode/commands/trellis/check-backend.md", "reason": "Backend check spec"} diff --git a/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/debug.jsonl b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/debug.jsonl new file mode 100644 index 0000000..6df9a74 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/debug.jsonl @@ -0,0 +1 @@ +{"file": ".opencode/commands/trellis/check-backend.md", "reason": "Backend check spec"} diff --git a/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/implement.jsonl b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/implement.jsonl new file mode 100644 index 0000000..b746bbb --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/implement.jsonl @@ -0,0 +1,8 @@ +{"file": ".trellis/workflow.md", "reason": "Project workflow and conventions"} +{"file": ".trellis/spec/backend/index.md", "reason": "Backend development guide"} +{"file": "backend/src/core/agentscope/prompts/worker_rules.py", "reason": "Core fix: role playing language dispatch"} +{"file": "backend/src/core/agentscope/prompts/system_prompt.py", "reason": "Safety section i18n"} +{"file": "backend/src/core/agentscope/prompts/user_prompt.py", "reason": "User prompt i18n"} +{"file": "backend/src/core/agentscope/prompts/agent_prompt.py", "reason": "Agent prompt composition"} +{"file": "backend/src/core/agentscope/runtime/runner.py", "reason": "Language parameter passing"} +{"file": "backend/tests/unit/test_agentscope_prompts.py", "reason": "Test cases"} diff --git a/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/prd.md b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/prd.md new file mode 100644 index 0000000..5107ced --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/prd.md @@ -0,0 +1,76 @@ +# PRD:修复 AI 英文输出失效 + +## 1. 背景 + +用户选择 English 作为语言设置后,AI 仍然输出中文。已在上一轮分析中确认根因: + +- `system_prompt.py:_build_output_rules` 正确注入了 `"You MUST respond in English"` 指令 +- 但 **`worker_rules.py:get_worker_role_playing` 完全忽略 `language` 参数**,始终返回中文版 `_WORKER_ROLE_PLAYING` +- 角色扮演提示词是系统提示词中体量最大的部分,其全部中文内容形成了压倒性的中文 priming 效应 +- 此外 `user_prompt.py` 的排盘数据也硬编码为中文,`_build_safety_section` 同样硬编码中文 + +## 2. 目标 + +1. 当 `language` 为英文时,AI 的角色扮演提示词也应为英文 +2. 英文模式的 safety section 应包含英文版规则 +3. 英文模式的 user prompt 字段应包含英文标签 +4. 最小化影响范围——不改变任何业务逻辑或数据流 + +## 3. 变更内容 + +### 3.1 Worker Rules(核心修复) + +**`worker_rules.py`**: +- 新增 `_WORKER_ROLE_PLAYING_EN` 英文版角色扮演提示词 +- `get_worker_role_playing(language)` 根据 language 前缀分发中/英文版本(新增繁体分支以备后用) + +### 3.2 System Prompt + +**`system_prompt.py`**: +- `_build_safety_section` 接受 `language` 参数,提供英文版安全规则 +- 保持现有中文版不变,在 `language.startswith("en")` 时返回英文版 + +### 3.3 User Prompt + +**`user_prompt.py`**: +- 新增 `_BUILD_FIELD_MAP_EN` 英文版字段标签 +- `build_divination_user_prompt` 接受 `language` 参数 +- 当 `language` 为英文时使用英文标签 + +### 3.4 Runner 调用链 + +**`runner.py`**: +- `_build_worker_input_messages` 接受 `language` 参数,传递给 `build_divination_user_prompt` +- `_run_worker_stage` 将 `language` 传入 `_build_worker_input_messages` + +### 3.5 测试 + +**`test_agentscope_prompts.py`**: +- 新增测试:`language="en-US"` 时 system prompt 不含大量中文 +- 新增测试:`language="en-US"` 时 worker role playing 为英文 +- 新增测试:`language="zh-CN"` 时行为不变 + +## 4. 文件变更清单 + +| 文件 | 变更 | +|------|------| +| `backend/src/core/agentscope/prompts/worker_rules.py` | 新增 `_WORKER_ROLE_PLAYING_EN`,`get_worker_role_playing` 分发 | +| `backend/src/core/agentscope/prompts/system_prompt.py` | `_build_safety_section(language)`,`build_system_prompt` 传参 | +| `backend/src/core/agentscope/prompts/user_prompt.py` | `build_divination_user_prompt(language=)`,新增英文字段标签 | +| `backend/src/core/agentscope/runtime/runner.py` | `_build_worker_input_messages(language=)`,传递 language | +| `backend/tests/unit/test_agentscope_prompts.py` | 新增语言切换相关测试 | + +## 5. 不在此范围的变更 + +- **不修改** `get_worker_output_rules` — 已正确按 language 分发(EN/ZH-Hant/ZH-CN) +- **不修改** `_build_output_rules` — 已正确工作 +- **不涉及前端** — 前端之前的重构已完成 `language` 统一,无需改动 +- **不涉及协议文档** — 协议已定义 language 字段 +- **不涉及数据库迁移** — 数据结构已正确 + +## 6. 验收标准 + +1. `language="en-US"` 时,完整 system prompt 的语境以英文为主(role playing + safety 均为英文) +2. `language="en-US"` 时,user prompt 中排盘数据的字段标签为英文 +3. `language="zh-CN"` 时,所有表现与修复前完全一致(回归) +4. 所有现有测试通过 diff --git a/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/task.json b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/task.json new file mode 100644 index 0000000..f8ee5bf --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-fix-ai-english-output/task.json @@ -0,0 +1,44 @@ +{ + "id": "fix-ai-english-output", + "name": "fix-ai-english-output", + "title": "Fix AI English Output - Worker Role Playing Ignores Language Parameter", + "description": "Worker role-playing prompt always returns Chinese regardless of language parameter, causing AI to output Chinese even when user selects English.", + "status": "completed", + "dev_type": null, + "scope": null, + "priority": "P0", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-04-28", + "completedAt": "2026-04-29", + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "current_phase": 0, + "next_action": [ + { + "phase": 1, + "action": "implement" + }, + { + "phase": 2, + "action": "check" + }, + { + "phase": 3, + "action": "finish" + }, + { + "phase": 4, + "action": "create-pr" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [], + "notes": "", + "meta": {} +} \ No newline at end of file diff --git a/.trellis/tasks/archive/2026-04/04-28-refactor-unify-language/IMPLEMENTATION_PLAN.md b/.trellis/tasks/archive/2026-04/04-28-refactor-unify-language/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..628c77b --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-refactor-unify-language/IMPLEMENTATION_PLAN.md @@ -0,0 +1,294 @@ +# IMPLEMENTATION_PLAN:统一语言设置 + +## 前置条件 + +| 条件 | 状态 | +|------|------| +| 后端 Schema 定义清晰 | ✅ | +| 前端 Model 定义清晰 | ✅ | +| AI 运行时使用 `ai_language` | ✅ | +| 协议文档存在 | ✅ | + +## 实现步骤 + +### Step 1: 更新协议文档 + +**文件**: `docs/protocols/profile/profile-protocol.md` + +- 将 `interface_language` 和 `ai_language` 合并为 `language` +- 更新示例 JSON + +--- + +### Step 2: 后端 Schema + +**文件**: `backend/src/schemas/shared/user.py` + +```python +class PreferenceSettings(BaseModel): + language: str = "zh-CN" + timezone: str = "Asia/Shanghai" + country: str = "US" + + @field_validator("language") + @classmethod + def validate_language(cls, value: str) -> str: + if not _BCP47_PATTERN.fullmatch(value): + raise ValueError("language must be a valid BCP-47 tag") + return value +``` + +--- + +### Step 3: 后端 AI 运行时 + +**文件**: `backend/src/core/agentscope/runtime/runner.py` + +```python +# 第 268-276 行 +language = "zh-CN" +if user_context.settings is not None: + prefs = getattr(user_context.settings, "preferences", None) + if prefs is not None: + language = getattr(prefs, "language", "zh-CN") or "zh-CN" + +system_prompt = build_system_prompt( + agent_type=stage_config.agent_type, + language=language, + llm_config=stage_config.llm_config, + tools=None, + now_utc=datetime.now(timezone.utc), +) +``` + +--- + +### Step 4: 后端 Prompt 构建 + +**文件**: `backend/src/core/agentscope/prompts/system_prompt.py` + +```python +def _build_output_rules(*, language: str) -> str: + lang_label = _get_language_label(language) + ... + +def build_system_prompt( + *, + agent_type: AgentType, + language: str, + llm_config: LlmConfig, + tools: list[ToolSchema] | None, + now_utc: datetime, +) -> str: + ... + _build_output_rules(language=language), +``` + +**文件**: `backend/src/core/agentscope/prompts/worker_rules.py` + +```python +def get_worker_role_playing(language: str) -> str: + _ = language + ... + +def get_worker_output_rules(language: str) -> str: + if language.startswith("en"): + ... +``` + +**文件**: `backend/src/core/agentscope/prompts/agent_prompt.py` + +```python +def build_agent_prompt( + *, + language: str = "zh-CN", +) -> str: + role_playing = get_worker_role_playing(language) + output_rules = get_worker_output_rules(language) + ... +``` + +--- + +### Step 5: 后端测试 + +**文件**: `backend/tests/unit/test_parse_profile_settings.py` + +- 字段名 `ai_language` → `language` +- 字段名 `interface_language` → `language` + +**文件**: `backend/tests/unit/test_agentscope_prompts.py` + +- 参数名 `ai_language` → `language` + +--- + +### Step 6: 数据库数据更新 + +直接用 Supabase MCP 执行 SQL(无需迁移脚本): + +```sql +UPDATE profiles +SET settings = jsonb_set( + settings - 'interface_language' - 'ai_language', + '{preferences,language}', + COALESCE( + settings->'preferences'->>'interface_language', + settings->'preferences'->>'ai_language', + '"zh-CN"' + )::jsonb +) +WHERE settings->'preferences' ?| array['interface_language', 'ai_language']; +``` + +--- + +### Step 7: 前端 Model + +**文件**: `apps/lib/features/settings/data/models/profile_settings.dart` + +```dart +class PreferenceSettings { + const PreferenceSettings({ + this.language = 'zh-CN', + this.timezone = 'Asia/Shanghai', + this.country = 'US', + }); + + final String language; + final String timezone; + final String country; + + PreferenceSettings copyWith({ + String? language, + String? timezone, + String? country, + }) { + return PreferenceSettings( + language: language ?? this.language, + timezone: timezone ?? this.timezone, + country: country ?? this.country, + ); + } +} + +// 更新 defaultsForLocale +factory ProfileSettingsV1.defaultsForLocale(Locale locale) { + final tag = languageTagFromLocale(locale); + return ProfileSettingsV1( + preferences: PreferenceSettings(language: tag), + ); +} +``` + +--- + +### Step 8: 前端 API + +**文件**: `apps/lib/features/settings/data/apis/profile_api.dart` + +```dart +// 序列化 (第 45 行) +'language': settings.preferences.language, + +// 反序列化 (第 114 行) +language: (preferencesRaw['language'] as String?) ?? 'zh-CN', +``` + +--- + +### Step 9: 前端设置界面 + +**文件**: `apps/lib/features/settings/presentation/screens/general_settings_screen.dart` + +移除第 75-95 行的 AI 语言 `SettingsMenuTile`,修改剩余的语言选项: + +```dart +SettingsMenuTile( + icon: Icons.language_rounded, + title: l10n.settingsLanguage, + subtitle: displayLanguageLabel( + l10n, + _settings.preferences.language, + ), + tint: colors.primary, + background: colors.surfaceContainerHighest, + showDivider: false, + onTap: () => _selectLanguage( + _settings.preferences.language, + (lang) => setState(() { + _settings = _settings.copyWith( + preferences: _settings.preferences.copyWith( + language: lang, + ), + ); + }), + ), +), +``` + +--- + +### Step 10: 前端 i18n + +**文件**: `apps/lib/l10n/app_zh.arb` + +```diff +- "settingsInterfaceLanguage": "界面语言", +- "settingsAiLanguage": "AI回复语言", +- "settingsAiLanguageHint": "该字段将对齐..." ++ "settingsLanguage": "语言", +``` + +**文件**: `apps/lib/l10n/app_en.arb` + +```diff +- "settingsInterfaceLanguage": "Interface Language", +- "settingsAiLanguage": "AI Response Language", +- "settingsAiLanguageHint": "This field will align..." ++ "settingsLanguage": "Language", +``` + +**文件**: `apps/lib/l10n/app_zh_hant.arb` + +```diff +- "settingsInterfaceLanguage": "介面語言", +- "settingsAiLanguage": "AI 回覆語言", ++ "settingsLanguage": "語言", +``` + +--- + +### Step 11: 重新生成 l10n + +```bash +cd apps && flutter gen-l10n +``` + +--- + +### Step 12: 更新其他协议文档 + +**文件**: `docs/protocols/divination/divination-run-protocol.md` + +第 240 行: +```diff +- - Language rule: `conclusion`, `focus_points`, `advice`, `keywords`, `answer` should follow user `ai_language` preference unless user explicitly requests otherwise. ++ - Language rule: `conclusion`, `focus_points`, `advice`, `keywords`, `answer` should follow user `language` preference unless user explicitly requests otherwise. +``` + +--- + +## 验证 + +```bash +# 后端 +cd backend +uv run pytest tests/unit/test_parse_profile_settings.py tests/unit/test_agentscope_prompts.py -v +./infra/scripts/dev-migrate.sh migrate + +# 前端 +cd apps +flutter gen-l10n +flutter analyze +``` diff --git a/.trellis/tasks/archive/2026-04/04-28-refactor-unify-language/prd.md b/.trellis/tasks/archive/2026-04/04-28-refactor-unify-language/prd.md new file mode 100644 index 0000000..39ee361 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-28-refactor-unify-language/prd.md @@ -0,0 +1,93 @@ +# PRD:统一语言设置 + +## 1. 背景 + +当前存在两个独立语言设置:`interface_language`(界面语言)和 `ai_language`(AI 回复语言),用户需分别设置。本任务将其统一为单一 `language` 字段。 + +## 2. 目标 + +1. 合并 `interface_language` + `ai_language` → `language` +2. 后端 AI 统一使用 `language` 作为回复语言 +3. 前端移除 AI 语言 UI,设置界面只保留一个"语言"选项 +4. 文案从"界面语言"改为"语言" + +## 3. 变更内容 + +### 3.1 Schema 变更 + +**变更前**: +```json +{ + "preferences": { + "interface_language": "zh-CN", + "ai_language": "zh-CN" + } +} +``` + +**变更后**: +```json +{ + "preferences": { + "language": "zh-CN" + } +} +``` + +### 3.2 UI 变更 + +**变更前**: 两个独立选项 +``` +界面语言 简体中文 > +AI回复语言 简体中文 > +``` + +**变更后**: 一个选项 +``` +语言 简体中文 > +``` + +## 4. 文件变更清单 + +### 后端 (7 文件) + +| 文件 | 变更 | +|------|------| +| `backend/src/schemas/shared/user.py` | 字段重命名 | +| `backend/src/core/agentscope/runtime/runner.py` | 读取 `language` | +| `backend/src/core/agentscope/prompts/system_prompt.py` | 参数重命名 | +| `backend/src/core/agentscope/prompts/worker_rules.py` | 参数重命名 | +| `backend/src/core/agentscope/prompts/agent_prompt.py` | 参数重命名 | +| `backend/tests/unit/test_parse_profile_settings.py` | 测试更新 | +| `backend/tests/unit/test_agentscope_prompts.py` | 测试更新 | + +### 数据库 (SQL 直接执行) + +| 操作 | 说明 | +|------|------| +| Supabase MCP SQL | 更新现有 profiles.settings JSON 结构 | + +### 前端 (6 文件) + +| 文件 | 变更 | +|------|------| +| `apps/lib/features/settings/data/models/profile_settings.dart` | 字段重命名 | +| `apps/lib/features/settings/data/apis/profile_api.dart` | 序列化字段 | +| `apps/lib/features/settings/presentation/screens/general_settings_screen.dart` | 移除 AI 语言 UI | +| `apps/lib/l10n/app_zh.arb` | 删除旧文案,添加新文案 | +| `apps/lib/l10n/app_en.arb` | 删除旧文案,添加新文案 | +| `apps/lib/l10n/app_zh_hant.arb` | 删除旧文案,添加新文案 | + +### 协议文档 (2 文件) + +| 文件 | 变更 | +|------|------| +| `docs/protocols/profile/profile-protocol.md` | 更新字段定义 | +| `docs/protocols/divination/divination-run-protocol.md` | 更新语言规则 | + +## 5. 验收标准 + +1. 后端 API 只返回 `language` 字段 +2. 前端设置界面只有一个语言选项 +3. AI 回复语言跟随 `language` 设置 +4. 所有测试通过 diff --git a/.trellis/tasks/archive/2026-04/04-29-cicd-ecr-deployment-flow/IMPLEMENTATION_PLAN.md b/.trellis/tasks/archive/2026-04/04-29-cicd-ecr-deployment-flow/IMPLEMENTATION_PLAN.md new file mode 100644 index 0000000..6a75c11 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-29-cicd-ecr-deployment-flow/IMPLEMENTATION_PLAN.md @@ -0,0 +1,27 @@ +# CI/CD ECR Deployment Flow Completion + +## Completed + +- Production backend Docker image workflow exists at `.gitea/workflows/build-production-docker.yml`. +- Workflow trigger is configured for push to `main` and manual `workflow_dispatch`. +- Workflow builds `backend/Dockerfile` with Docker Buildx, validates image size, and runs a smoke test. +- Workflow logs in to ECR, creates the repository if missing, and pushes both `${GITHUB_SHA}` and `latest` tags. +- Production Docker Compose file exists at `deploy/docker-compose.prod.yml` and pulls images from ECR instead of building locally. +- Production deploy guide exists at `deploy/README.md` with EC2-side ECR login, Compose pull/up, health check, logs, and stop commands. +- Cloudflare IPv4 ingress rules were added to AWS security group `sg-064bf6675c881fde3` for `tcp/80` and `tcp/443`. + +## Deferred Intentionally + +- EC2 will not auto-pull and restart yet. The operator will log in to the single EC2 host and start Docker Compose manually after ECR image confirmation. +- Public `0.0.0.0/0` ingress for `tcp/80` and `tcp/443` remains until `https://api.meeyao.com` or the agreed health endpoint is confirmed healthy. +- Gitea workflow does not yet include SSH or SSM deployment steps. + +## Verification To Perform After PR Merge + +1. Confirm the PR is merged to `main` or otherwise pushed to `main`. +2. Confirm Gitea Actions runs the production Docker workflow successfully. +3. Confirm ECR contains the backend image tagged with the commit SHA and `latest`. +4. Operator manually logs in to EC2 and runs the documented Compose deployment commands. +5. Confirm local EC2 health check returns `{"status":"ok"}`. +6. Confirm external API health through Cloudflare. +7. Remove `0.0.0.0/0` ingress for `tcp/80` and `tcp/443` only after external health is confirmed. diff --git a/.trellis/tasks/archive/2026-04/04-29-cicd-ecr-deployment-flow/prd.md b/.trellis/tasks/archive/2026-04/04-29-cicd-ecr-deployment-flow/prd.md new file mode 100644 index 0000000..4f4bd3d --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-29-cicd-ecr-deployment-flow/prd.md @@ -0,0 +1,27 @@ +# CI/CD ECR Deployment Flow Record + +## Goal + +Record the current production CI/CD state for the backend Docker deployment path and preserve the handoff point before EC2 manual service startup. + +## Scope + +- Document that pushes to `main` trigger the Gitea workflow to build the backend Docker image. +- Document that the workflow validates the image and pushes `${GITHUB_SHA}` and `latest` tags to AWS ECR. +- Document that Cloudflare IPv4 CIDR ingress rules were added for `tcp/80` and `tcp/443` on security group `sg-064bf6675c881fde3` in `us-east-2`. +- Document that the open `0.0.0.0/0` ingress rules for `tcp/80` and `tcp/443` remain in place until the API is healthy. +- Document that final EC2 service startup is intentionally manual: the operator will log in to the single EC2 host and run Docker Compose after confirming the image exists in ECR. + +## Out of Scope + +- Automated SSH or SSM deployment to EC2. +- ECS task definition or service deployment. +- Removing the public `0.0.0.0/0` security group rules before API health is confirmed. + +## Acceptance Criteria + +- Trellis task records the completed CI/CD preparation work. +- The task is archived after recording completion. +- The temporary root-level `DEPLOYMENT_REPORT.md` is removed. +- Current repository changes are committed on `dev`, pushed, and proposed for merge to `main`. +- After merge or main push triggers CI, ECR is checked for the uploaded backend image. diff --git a/.trellis/tasks/archive/2026-04/04-29-cicd-ecr-deployment-flow/task.json b/.trellis/tasks/archive/2026-04/04-29-cicd-ecr-deployment-flow/task.json new file mode 100644 index 0000000..2d416c2 --- /dev/null +++ b/.trellis/tasks/archive/2026-04/04-29-cicd-ecr-deployment-flow/task.json @@ -0,0 +1,49 @@ +{ + "id": "cicd-ecr-deployment-flow", + "name": "cicd-ecr-deployment-flow", + "title": "Record CI/CD ECR deployment flow", + "description": "Record completed backend Docker CI/CD preparation through ECR push and the remaining manual EC2 Docker Compose startup step.", + "status": "completed", + "dev_type": "docs", + "scope": "deployment", + "priority": "P2", + "creator": "zl-q", + "assignee": "zl-q", + "createdAt": "2026-04-29", + "completedAt": "2026-04-29", + "branch": null, + "base_branch": "dev", + "worktree_path": null, + "current_phase": 0, + "next_action": [ + { + "phase": 1, + "action": "implement" + }, + { + "phase": 2, + "action": "check" + }, + { + "phase": 3, + "action": "finish" + }, + { + "phase": 4, + "action": "create-pr" + } + ], + "commit": null, + "pr_url": null, + "subtasks": [], + "children": [], + "parent": null, + "relatedFiles": [ + ".gitea/workflows/build-production-docker.yml", + "backend/Dockerfile", + "deploy/docker-compose.prod.yml", + "deploy/README.md" + ], + "notes": "CI/CD is complete through ECR image push. EC2 remains single-host Docker Compose and will be started manually after ECR image confirmation. Cloudflare IPv4 ingress was added; public 0.0.0.0/0 ingress remains until API health is confirmed.", + "meta": {} +} diff --git a/.trellis/workflow.md b/.trellis/workflow.md index d1fe61e..fd01e35 100644 --- a/.trellis/workflow.md +++ b/.trellis/workflow.md @@ -101,6 +101,34 @@ cat .trellis/spec/backend/logging-guidelines.md # For logging 4. **Record Promptly** - Update tracking files immediately after completion 5. **Document Limits** - [!] **Max 2000 lines per journal document** +### Development Environment + +**Cloud Supabase Setup:** + +1. Configure `.env` with Supabase Cloud credentials: + ```bash + ERYAO_SUPABASE__PUBLIC_URL=https://.supabase.co + ERYAO_DATABASE__HOST=aws-1-us-east-2.pooler.supabase.com + ERYAO_DATABASE__PORT=5432 # Session pooler + ``` + +2. Start Redis (required for backend): + ```bash + cd infra/docker && docker compose up -d redis + ``` + +3. Run database migrations: + ```bash + ./infra/scripts/dev-migrate.sh bootstrap + ``` + +4. Start backend server: + ```bash + ./infra/scripts/app.sh start + ``` + +**Note:** Local Supabase is no longer used. All development uses Supabase Cloud. + ### File System ``` diff --git a/.trellis/workspace/opencode/index.md b/.trellis/workspace/opencode/index.md new file mode 100644 index 0000000..c4a1ce0 --- /dev/null +++ b/.trellis/workspace/opencode/index.md @@ -0,0 +1,41 @@ +# Workspace Index - opencode + +> Journal tracking for AI development sessions. + +--- + +## Current Status + + +- **Active File**: `journal-1.md` +- **Total Sessions**: 1 +- **Last Active**: 2026-04-28 + + +--- + +## Active Documents + + +| File | Lines | Status | +|------|-------|--------| +| `journal-1.md` | ~61 | Active | + + +--- + +## Session History + + +| # | Date | Title | Commits | +|---|------|-------|---------| +| 1 | 2026-04-28 | 任务归档与代码模块化提交 | `dd48d1d`, `85023a6`, `295dbc0`, `a940f2e`, `b9617ae`, `940c67e`, `a83001d`, `14752cd`, `c7a75a6`, `8de0331`, `9bc24fa` | + + +--- + +## Notes + +- Sessions are appended to journal files +- New journal file created when current exceeds 2000 lines +- Use `add_session.py` to record sessions \ No newline at end of file diff --git a/.trellis/workspace/opencode/journal-1.md b/.trellis/workspace/opencode/journal-1.md new file mode 100644 index 0000000..49881b5 --- /dev/null +++ b/.trellis/workspace/opencode/journal-1.md @@ -0,0 +1,61 @@ +# Journal - opencode (Part 1) + +> AI development session journal +> Started: 2026-04-27 + +--- + + + +## Session 1: 任务归档与代码模块化提交 + +**Date**: 2026-04-28 +**Task**: 任务归档与代码模块化提交 + +### Summary + +(Add summary) + +### Main Changes + +| 任务 | 说明 | +|------|------| +| 归档 | 04-27-feat-ios-apple-pay, 04-28-feat-locale-timezone-bootstrap, 04-28-feat-points-ledger, 04-28-refactor-unify-language | +| feat(locale) | App 启动时语言和时区自动设置 | +| feat(points) | 积分流水列表功能 | +| refactor(settings) | 统一语言设置,合并 interface_language 和 ai_language | +| feat(notification) | 通知标题和正文支持多语言 | +| feat(payment) | 优化套餐配置和支付服务 | +| refactor(divination) | 优化占卜界面和组件 | +| chore | 更新主题、反馈页面和测试用例 | + +**提交策略**: 按功能模块分类提交,每个任务/功能独立 commit + + +### Git Commits + +| Hash | Message | +|------|---------| +| `dd48d1d` | (see git log) | +| `85023a6` | (see git log) | +| `295dbc0` | (see git log) | +| `a940f2e` | (see git log) | +| `b9617ae` | (see git log) | +| `940c67e` | (see git log) | +| `a83001d` | (see git log) | +| `14752cd` | (see git log) | +| `c7a75a6` | (see git log) | +| `8de0331` | (see git log) | +| `9bc24fa` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete diff --git a/.trellis/workspace/zl-q/index.md b/.trellis/workspace/zl-q/index.md index 75fc279..3488851 100644 --- a/.trellis/workspace/zl-q/index.md +++ b/.trellis/workspace/zl-q/index.md @@ -8,8 +8,8 @@ - **Active File**: `journal-1.md` -- **Total Sessions**: 5 -- **Last Active**: 2026-04-20 +- **Total Sessions**: 6 +- **Last Active**: 2026-04-29 --- @@ -19,7 +19,7 @@ | File | Lines | Status | |------|-------|--------| -| `journal-1.md` | ~288 | Active | +| `journal-1.md` | ~335 | Active | --- @@ -29,6 +29,7 @@ | # | Date | Title | Commits | |---|------|-------|---------| +| 6 | 2026-04-29 | 新增追问模式和英文输出优化 | `16cb47e` | | 5 | 2026-04-20 | Do Not Sell My Personal Information 开关 | `913ed26f8de434a64d795a68a5326e2aa34ad358` | | 4 | 2026-04-20 | 用户反馈投送功能 | `6a2a9d2c87e1e1dd35a23ff2e184b6ed9f04d03d` | | 3 | 2026-04-16 | 修复通知 targets 约束、合并注册脚本、路径缩短及清理重复索引 | `c79c773` | diff --git a/.trellis/workspace/zl-q/journal-1.md b/.trellis/workspace/zl-q/journal-1.md index a4cfeb8..9799cb9 100644 --- a/.trellis/workspace/zl-q/journal-1.md +++ b/.trellis/workspace/zl-q/journal-1.md @@ -286,3 +286,50 @@ ### Next Steps - None - task complete + + +## Session 6: 新增追问模式和英文输出优化 + +**Date**: 2026-04-29 +**Task**: 新增追问模式和英文输出优化 + +### Summary + +(Add summary) + +### Main Changes + +| Feature | Description | +|---------|-------------| +| 追问模式 | 新增 follow_up runtime mode,支持用户对解卦结果追问 | +| 英文输出优化 | 重构 worker_rules.py,英文输出面向普通读者,最小化中文字符 | +| iOS 本地化 | 新增 en/zh-Hans/zh-Hant 的 InfoPlist.strings | +| 国际化术语 | 添加五行、六神、地支等术语的多语言翻译 | +| 输出模型重构 | AgentOutput → PersistedAgentOutput,新增 SignLevel 枚举 | + +**Updated Files**: +- `backend/src/core/agentscope/prompts/worker_rules.py` - 重构英文输出规则 +- `backend/src/core/agentscope/prompts/user_prompt.py` - 优化语言约束 +- `backend/src/core/agentscope/prompts/agent_prompt.py` - 支持 follow_up 模式 +- `backend/src/schemas/agent/runtime_models.py` - 重构输出模型 +- `apps/lib/l10n/*.arb` - 新增五行、六神等术语翻译 +- `apps/ios/Runner/*.lproj/InfoPlist.strings` - iOS 本地化 + + +### Git Commits + +| Hash | Message | +|------|---------| +| `16cb47e` | (see git log) | + +### Testing + +- [OK] (Add test results) + +### Status + +[OK] **Completed** + +### Next Steps + +- None - task complete diff --git a/apps/AGENTS.md b/apps/AGENTS.md index 6dad690..7835c43 100644 --- a/apps/AGENTS.md +++ b/apps/AGENTS.md @@ -65,12 +65,12 @@ appBar: AppBar( - When a repeated pattern emerges, extract a reusable component into `shared/widgets/` instead of building `AppBar` independently in each page. -## Divination Terminology (Must) +## Divination Terminology -- Divination domain terminology must use fixed Chinese terms in code contracts, protocol fields, and UI semantic labels. -- Do not localize or translate canonical terms such as: 六爻、爻、动爻、静爻、六亲、六神、世爻、应爻、伏神、月建、日辰、月破、日冲、空亡、五行旺衰。 -- Signature level labels (`上上签/中上签/中下签`) may be localized for UI display only, while protocol/storage values remain canonical Chinese. -- l10n can translate explanatory copy, but must not alter canonical divination terminology semantics. +- Protocol/storage values and backend field names remain canonical Chinese (e.g. 官鬼, 妻财, 世, 应). +- Frontend hexagram detail MUST localize for English users: translate section headers, five-element labels, status values, and provide a legend mapping Chinese symbols to English equivalents. +- Yao row cells keep Chinese characters (compact display); a legend card above the rows provides English translations. +- AI English output uses Chinese parenthetical notes for key Liu Yao terms (e.g. "Officer (官鬼) line") so users can cross-reference with the Chinese characters shown in the hexagram detail. ## Localization Generation Rules (Must) diff --git a/apps/assets/legal/en/about_us.md b/apps/assets/legal/en/about_us.md index c7e26cf..da7bf64 100644 --- a/apps/assets/legal/en/about_us.md +++ b/apps/assets/legal/en/about_us.md @@ -1,25 +1,23 @@ # About Us -Welcome to MeiYao Divination, an AI-assisted platform for interpreting traditional Six-Line divination and opening a window into classical Chinese wisdom. +Welcome to MeeYao Divination, an AI-assisted cultural reference app focused on traditional Six-Line culture and the traditional wisdom of the I Ching. -Six-Line divination comes from the deep philosophical system of the *I Ching*. It reflects the ancient view that intention, time, and the changing world are connected. Once a hexagram is formed, it can be interpreted together with line texts and rules such as the Five Elements and GanZhi interactions to understand possible trends and outcomes. +Six-Line culture originates from the profound philosophical system of the I Ching. It embodies the traditional viewpoint of the connection between personal thoughts, timing and natural changes. By combining hexagram culture, traditional five-element theories and traditional GanZhi cultural concepts, users can explore traditional cultural connotations and life reference perspectives. -MeiYao Divination is built on this idea. Its core value is to help users step outside narrow thinking, understand contradictions, opportunities, and risks from a broader trend perspective, and make calmer, more thoughtful decisions. We hope AI can become a modern bridge to this traditional wisdom. +MeeYao Divination is designed based on traditional oriental culture. Our core goal is to help users broaden their thinking horizons, view daily choices and life status from a diverse cultural perspective, and maintain a rational and peaceful mindset in daily life. We hope modern AI technology can serve as a convenient way for everyone to understand and experience traditional Chinese culture. --- ## Company Info -**Developer:** Xunmee Technology (Shenzhen) Co., Ltd. +**Developer:** Ann Lee -**Contact Email:** xuyunlong@xunmee.com - -**Privacy Policy:** Available at Settings → Privacy → Privacy Policy in the App +**Contact Email:** ann@xumee.com --- ## Important Disclaimer -All divination interpretations are generated by AI and for entertainment and reference only. They are not intended to replace professional guidance in business, finance, medical, legal, or any other formal fields. We do not guarantee the accuracy or reliability of the content, and users shall take full responsibility for any decisions they make. +All AI-generated content and cultural interpretation materials are for **entertainment, cultural appreciation and reference only**. This app does not provide professional advice of any kind, including but not limited to business, finance, investment, medical treatment, psychology, law, career or life decision-making. All generated content shall not be regarded as factual basis or decision-making guidance. The developer does not assume any responsibility for users' personal choices, behaviors and related consequences. Please treat traditional culture rationally and use this app with a rational attitude. -© 2026 MeiYao Divination. All Rights Reserved. +© 2026 Ann Lee. All Rights Reserved. diff --git a/apps/assets/legal/en/privacy_policy.md b/apps/assets/legal/en/privacy_policy.md index 2e02d28..bf93fb2 100644 --- a/apps/assets/legal/en/privacy_policy.md +++ b/apps/assets/legal/en/privacy_policy.md @@ -1,251 +1,163 @@ # Privacy Policy -**Last Updated**: April 17, 2026 +**Last Updated**: April 27, 2026 -**Effective Date**: April 17, 2026 +**Effective Date**: April 27, 2026 --- -## 1. Who We Are +## Introduction -MeiYao Divination ("we," "us," or "our") is a mobile application developed and operated by Xunmee Technology (Shenzhen) Co., Ltd. This Privacy Policy explains how we collect, use, disclose, and protect your personal information when you use our mobile application (the "App"). +Dear User, Welcome to MeeYao Divination (the "App"), independently developed and operated by an **individual developer** ("I", "me", "my"). I am committed to protecting your personal privacy and complying with applicable U.S. federal and state privacy laws, including the California Consumer Privacy Act (CCPA/CPRA), the Children's Online Privacy Protection Act (COPPA), CalOPPA, and other U.S. state privacy regulations. -**Contact Information:** -- Company: Xunmee Technology (Shenzhen) Co., Ltd. -- Email: privacy@xunmee.com -- Support Email: xuyunlong@xunmee.com +This Privacy Policy clearly explains: + +- What personal information I collect +- How your data is used, stored and shared +- Your legal privacy rights under U.S. regulations +- How you can submit data requests + +This policy applies to all users of this App. California residents are granted additional rights specified in Section 5. --- -## 2. Information We Collect +## 1. Information We Collect -### 2.1 Personal Information We Collect +I only collect necessary data to provide, maintain and optimize App cultural reference functions. All data is classified as Personal Information and Sensitive Personal Information (SPI) in accordance with CCPA/CPRA. -We collect information that you provide directly to us and information that is collected automatically when you use our App. +### 1.1 Information You Provide Directly -#### Information You Provide +- **Account Information**: Email address, verification code (required for account registration and security verification) +- **Profile Information**: Optional nickname or display name voluntarily set by you +- **Personal Content**: Your input questions, cultural interpretation records and local session content +- **Support Information**: Feedback, consultation messages you send for user assistance -- **Account Information**: When you create an account, we collect your email address and verification code. -- **Divination Content**: When you use our divination services, we collect your questions, inputs, divination results, and session history. -- **Customer Support**: If you contact us for support, we collect the information you provide in your message. +### 1.2 Information Collected Automatically -#### Information Collected Automatically +When you use the App, limited automatic data will be collected to ensure normal operation: -When you use our App, we and our third-party partners automatically collect: - -- **Device Information**: Device model, operating system version, device settings -- **Usage Information**: Divination frequency, session duration, session history -- **Technical Information**: Access timestamps, time zone information -- **Attribution Data**: We use AppsFlyer and SKAdNetwork for attribution and marketing analytics. These services may collect device identifiers, IP addresses, and other information for advertising attribution purposes. - -### 2.2 Categories of Personal Information Collected (Past 12 Months) - -In the past 12 months, we have collected the following categories of personal information: - -| Category | Collected | Examples | -|----------|-----------|----------| -| **A. Identifiers** | Yes | Email address | -| **B. Personal Information Categories** | No | N/A | -| **C. Protected Classification Characteristics** | No | N/A | -| **D. Commercial Information** | No | N/A | -| **E. Biometric Information** | No | N/A | -| **F. Internet or Other Electronic Network Activity Information** | Yes | Session duration, divination frequency, device information | -| **G. Geolocation Data** | No | N/A | -| **H. Sensory Data** | No | N/A | -| **I. Professional or Employment Information** | No | N/A | -| **J. Education Information** | No | N/A | -| **K. Inferences** | No | N/A | +- **Device Information**: Device model, operating system version, unique device identifier, device configuration +- **Technical Data**: IP address (for rough regional access recognition), access time, crash logs and operation performance data +- **Usage Data**: Function usage records, app stay duration and in-app interaction behavior --- -## 3. How We Use Your Personal Information +## 2. How We Use Your Information -We use the personal information we collect for the following business purposes: +Your information will only be used for the following legitimate and limited purposes: -- **To provide our services**: Process your divination queries, generate AI interpretations, store your session history, and deliver the core functionality of the App -- **To communicate with you**: Send you service-related communications, respond to your inquiries, and provide customer support -- **To improve our services**: Analyze usage patterns to enhance divination accuracy, fix bugs, and develop new features -- **To protect our services**: Detect and prevent fraud, abuse, and security issues -- **To comply with legal obligations**: Meet applicable legal, tax, and regulatory requirements +1. **Provide Core Functions**: Process your input content, generate AI cultural interpretation content, and record local usage records. +2. **Account Security**: Complete user verification, prevent abnormal login and protect your account security. +3. **Product Optimization**: Analyze anonymous usage data to fix bugs, optimize operation experience and improve product performance. +4. **User Assistance**: Reply to your feedback and solve your use problems. +5. **Service Reminders**: Push necessary system notices and policy update reminders. +6. **Legal Compliance**: Meet statutory compliance requirements and official platform review rules. + +I will **not** use your personal sensitive content for commercial advertising or unauthorized marketing without your explicit consent. --- -## 4. How We Share Your Personal Information +## 3. Data Storage, Retention & Cross-Border Transfers -### 4.1 Service Providers +### 3.1 Storage Location -We share your personal information with third-party service providers who perform services on our behalf: +User data collected through this App may be stored on secure third-party cloud servers located in the United States. All cross-border data transmission adopts encrypted transmission protocols to ensure data security. -| Service Provider | Purpose | Category of Information Shared | -|------------------|---------|-------------------------------| -| **Amazon Web Services (AWS)** | Cloud hosting and infrastructure | All personal information stored in our systems | -| **AppsFlyer** | Attribution and marketing analytics | Device identifiers, IP addresses, usage information | +### 3.2 Retention Period -These service providers are contractually obligated to use your personal information only for the purposes we specify and are prohibited from selling or sharing it for their own purposes. +Data will only be retained within the necessary time limit: -### 4.2 Disclosure for Business Purposes - -In the past 12 months, we have disclosed the following categories of personal information for business purposes: - -| Category of Personal Information | Disclosed | Recipient Categories | -|---------------------------------|-----------|---------------------| -| **A. Identifiers** | Yes | Cloud hosting providers, analytics providers | -| **F. Internet or Other Electronic Network Activity Information** | Yes | Cloud hosting providers, analytics providers | - -### 4.3 Sale of Personal Information - -**We do not sell personal information.** In the past 12 months, we have not sold any personal information. - -### 4.4 Sharing of Personal Information for Cross-Context Behavioral Advertising - -**We do not share personal information for cross-context behavioral advertising.** In the past 12 months, we have not shared any personal information for cross-context behavioral advertising. +- **Account data**: Retained during your active use, and cleaned up reasonably after you cancel your account. +- **Personal content records**: Reserved within a reasonable cycle and regularly cleaned or anonymized. +- **Device and log data**: Automatically deleted after a limited period. --- -## 5. Your Privacy Rights (California Residents) +## 4. Sharing & Disclosure of Information -If you are a California resident, you have the following rights under the California Consumer Privacy Act (CCPA) and California Privacy Rights Act (CPRA): +### 4.1 Sale of Personal Information -### 5.1 Right to Know +**I do not sell, rent or trade your personal information** in any form, and will never sell your data for commercial benefits. -You have the right to request that we disclose what personal information we collect, use, disclose, and sell. +### 4.2 Sharing with Third-Party Service Providers -### 5.2 Right to Delete +I only share data with trusted third-party service providers necessary for App operation, and sign strict data protection restrictions: -You have the right to request that we delete any of your personal information that we have collected and retained, subject to certain exceptions. +- Cloud storage and server services +- App operation analysis, crash monitoring tools +- Apple official push and system service capabilities -### 5.3 Right to Correct +All third parties are prohibited from using your data for independent commercial purposes. -You have the right to request that we correct inaccurate personal information that we maintain about you. +### 4.3 Legal Disclosure -### 5.4 Right to Data Portability +Your data may be disclosed only in the following situations: -You have the right to request that we provide you with your personal information in a readily usable format that allows you to transmit the information to another entity without hindrance. - -### 5.5 Right to Opt-Out of Sale or Sharing - -**We do not sell or share personal information for cross-context behavioral advertising.** Therefore, you do not need to exercise this right with us. - -### 5.6 Right to Limit Use of Sensitive Personal Information - -We do not collect sensitive personal information as defined under CCPA/CPRA. Therefore, you do not need to exercise this right with us. - -### 5.7 Right to Non-Discrimination - -We will not discriminate against you for exercising any of your CCPA/CPRA rights. +- Required by laws, regulations, court orders or official government requests +- With your clear voluntary authorization and consent +- To protect personal legitimate rights and public safety --- -## 6. How to Exercise Your Rights +## 5. Your U.S. Privacy Rights (California Residents Included) -### 6.1 Submitting Requests +In accordance with CCPA/CPRA and U.S. local privacy laws, you enjoy the following rights: -To exercise your rights under the CCPA/CPRA, you may: +1. **Right to Know**: Inquire about the type and scope of personal data collected. +2. **Right to Access**: Obtain a copy of your personal usage data. +3. **Right to Deletion**: Apply to delete your account and related personal data. +4. **Right to Correction**: Modify incorrect personal information. +5. **Right to Data Portability**: Obtain your data in a readable format. +6. **Right to Opt-Out**: Reject non-essential data collection and irrelevant recommendation. +7. **Right to Limit Sensitive Data**: Restrict the use of your personal sensitive content. +8. **Right to Non-Discrimination**: No differential treatment for you to exercise privacy rights. -- **Email us**: privacy@xunmee.com -- **Use in-app features**: Settings → Privacy → Data Requests +### How to Exercise Your Rights -You do not need to create an account to submit a request. +You can submit data requests through the only dedicated contact method: -### 6.2 Verifying Your Identity +- **Contact Email**: ann@xumee.com -To protect your privacy and security, we will take reasonable steps to verify your identity before processing your request. This may include asking you to provide information that matches what we have on file for your account. - -### 6.3 Response Time - -We will respond to your request within **45 days** of receipt. If we need more time (up to 90 days total), we will inform you of the reason and extension period in writing. - -### 6.4 Authorized Agent - -You may designate an authorized agent to submit requests on your behalf. We may require the authorized agent to provide proof of authorization and verify their identity. +I will respond to your legitimate request within 45 days, and properly verify your identity to ensure data security before processing. --- -## 7. Data Retention +## 6. Children's Privacy (COPPA Compliance) -We retain your personal information for as long as necessary to fulfill the purposes for which it was collected, including: +This App is not oriented to users under the age of 13. I do not intentionally collect any personal information of minors under 13 years old. -- **Account Information**: Retained while your account is active and for 90 days after account deletion (to comply with legal obligations) -- **Divination Records**: Retained for 12 months, then deleted or anonymized -- **Technical/Usage Data**: Retained for 90 days, then anonymized - -When your personal information is no longer needed, we will securely delete or anonymize it. +If you find that minor information has been improperly collected, please contact me via email in a timely manner, and I will completely delete the relevant data in accordance with COPPA regulations. Users aged 13–17 need to use this App under the supervision and consent of their guardians. --- -## 8. Data Security +## 7. Data Security -We implement reasonable security measures to protect your personal information from unauthorized access, use, or disclosure, including: +I adopt industry-standard technical protection measures to protect your data: -- Encryption of data in transit using secure HTTPS/TLS protocols -- Access controls and authentication mechanisms for user data -- Regular security reviews and monitoring +- Encrypted storage and encrypted transmission to prevent data leakage +- Strict access restrictions and daily security management +- Regular abnormal monitoring and risk checking -However, no security system is 100% secure, and we cannot guarantee the absolute security of your personal information. +Please note that no network storage system can achieve absolute security, and I will always maintain the highest level of data protection measures. --- -## 9. Children's Privacy +## 8. Policy Changes -Our App is not intended for children under the age of 13. We do not knowingly collect personal information from children under 13. - -If you believe we have collected personal information from a child under 13, please contact us immediately at privacy@xunmee.com. We will take steps to delete such information promptly. - -Users between the ages of 13 and 17 must have parental or guardian consent to use our App. +This Privacy Policy may be updated irregularly to adapt to platform rules and legal adjustments. Important content changes will be notified through in-app prompts or email reminders in advance. Your continued use of the App after the update takes effect means that you agree to the revised policy. --- -## 10. Cross-Border Data Transfers +## 9. Contact Us -Your personal information may be transferred to and processed in the United States. By using our App, you consent to the transfer of your personal information to the United States. +If you have any questions, suggestions or privacy-related complaints about this Privacy Policy, please contact me: -We ensure that appropriate safeguards are in place for cross-border transfers, including: +**Developer Email**: ann@xumee.com -- Secure data transmission using encryption -- Contractual protections with service providers -- Compliance with applicable data protection laws +If you are a California resident and dissatisfied with the processing result, you can consult the local privacy regulatory authority. --- -## 11. Changes to This Privacy Policy +**Independent Individual Developer** -We may update this Privacy Policy from time to time. If we make material changes, we will notify you by: - -- Posting the updated Privacy Policy in the App -- Updating the "Last Updated" date at the top of this policy - -We encourage you to review this Privacy Policy periodically to stay informed about how we collect, use, and protect your personal information. - ---- - -## 12. Contact Us - -If you have any questions about this Privacy Policy or our privacy practices, please contact us at: - -**Xunmee Technology (Shenzhen) Co., Ltd.** - -- **Privacy Officer**: privacy@xunmee.com -- **Support Email**: xuyunlong@xunmee.com -- **Address**: [Full Street Address], Shenzhen, Guangdong, China - ---- - -## 13. Additional Rights for California Residents - -### 13.1 Right to Equal Service and Price - -You have the right not to receive discriminatory treatment for exercising your privacy rights under the CCPA/CPRA. - -### 13.2 Financial Incentive Programs - -We do not currently offer any financial incentive programs for the collection, sale, or deletion of personal information. - -### 13.3 Accessibility - -This Privacy Policy and our "Do Not Sell My Personal Information" link (if applicable) are accessible to users with disabilities. - ---- - -**Last Updated**: April 17, 2026 - -**Xunmee Technology (Shenzhen) Co., Ltd.** +**Last Updated**: April 27, 2026 diff --git a/apps/assets/legal/en/terms_of_service.md b/apps/assets/legal/en/terms_of_service.md index dabb960..e18bf46 100644 --- a/apps/assets/legal/en/terms_of_service.md +++ b/apps/assets/legal/en/terms_of_service.md @@ -1,97 +1,121 @@ -# Terms of Service (US Compliant Version) +# Terms of Service -**Last Updated:** April 15, 2026 +**Last Updated:** April 27, 2026 + +--- ## 1. Acceptance of Terms -MeiYao Divination (the "App") is developed, operated, and maintained by **Xunmee Technology (Shenzhen) Co., Ltd.** ("we," "us," or "our"). +MeiYao Divination (the "App") is independently developed, owned and operated by an **individual developer** ("I", "me", "my"). -By downloading, installing, registering, accessing, or using the App, you ("you" or "user") acknowledge that you have read, understood, and **unconditionally agree** to be bound by these Terms of Service ("Terms") and our **Privacy Policy**. If you do not agree, you may not use the App. +By downloading, installing, registering, accessing, or using the App, you ("you" or "user") acknowledge that you have read, understood, and unconditionally agree to be bound by these Terms of Service ("Terms") and my Privacy Policy. If you do not agree to these Terms, you must not use this App. + +--- ## 2. Age Requirement & COPPA Compliance -You represent and warrant that you are **at least 13 years of age** to use this App. +You represent and warrant that you are at least 13 years of age to use this App. -- This App is **not intended for use by children under 13**. -- We do not knowingly collect personal information from users under 13. If we become aware that a child under 13 has provided us with personal data, we will delete such information immediately. +- This App is not intended for children under 13 years old. +- I do not knowingly collect personal information from users under the age of 13. If I become aware that a minor under 13 has submitted personal data, I will take immediate action to delete such information. + +--- ## 3. Service Description -The App provides **AI-assisted Six-Line divination interpretation services** (including manual and automatic hexagram casting), rooted in traditional *I Ching* philosophy. +This App provides AI-assisted cultural interpretation content related to traditional I Ching and Six-Line culture, for daily reference and cultural appreciation only. -- All divination results and interpretations are **AI-generated and for entertainment and reference purposes only**. -- They are **not intended as professional advice** (financial, legal, medical, business, or otherwise). -- We do not guarantee accuracy, completeness, or reliability of any AI-generated content. -- Temporary service interruptions due to maintenance, technical failures, force majeure, or third-party issues do not constitute a breach of these Terms. +- All AI-generated content and cultural reference materials are for entertainment and personal reference purposes solely. +- Content shall not be regarded as professional advice, including without limitation finance, investment, law, medical treatment, career or business decision-making. +- I do not guarantee the accuracy, completeness or practicality of any AI-generated content within the App. +- Temporary service suspension caused by system maintenance, technical exceptions, network failure or force majeure shall not be deemed a breach of these Terms. + +--- ## 4. User Accounts & Data Privacy -- You must provide **true, accurate, and complete registration information** and keep it updated. -- You are responsible for maintaining the confidentiality of your account credentials and for all activities under your account. -- We collect and process necessary personal information as described in our **Privacy Policy**, in compliance with applicable laws including **CCPA/CPRA (California Consumer Privacy Act)**. -- California users have the right to access, delete, and opt out of the sale of their personal data as set forth in our Privacy Policy. +- You shall provide true, accurate and complete registration information and keep your information updated. +- You are solely responsible for safeguarding your account login credentials and for all activities conducted under your account. +- I collect and process user personal data strictly in accordance with the published Privacy Policy and comply with applicable U.S. privacy laws, including CCPA/CPRA. +- California residents hold relevant data access, deletion and privacy rights as stated in the Privacy Policy. + +--- ## 5. Intellectual Property -All content within the App—including software code, text, images, audio, video, trademarks, service marks, and design—is the exclusive property of Xunmee Technology (Shenzhen) Co., Ltd. and protected under **U.S. copyright law (DMCA)**, trademark law, and international treaties. +All intellectual property rights within the App, including but not limited to program code, text copy, graphic design, interface content, logos and visual elements, are exclusively owned by the individual developer and protected by U.S. copyright law (DMCA), trademark regulations and international intellectual property conventions. You may not: -- Copy, modify, distribute, transmit, display, or create derivative works of the App or its content. -- Reverse engineer, decompile, disassemble, or attempt to extract source code from the App. -- Remove any copyright, trademark, or proprietary notices from the App. +- Copy, modify, edit, distribute, reproduce or create derivative works based on the App and its internal content. +- Reverse engineer, decompile, disassemble, crack or attempt to obtain the App's source code. +- Delete, cover or alter any copyright notice, proprietary mark and intellectual property statement in the App. + +--- ## 6. Prohibited User Conduct You agree not to: -- Use the App for **illegal, harmful, fraudulent, or infringing activities**. -- Post or transmit content that is unlawful, defamatory, obscene, threatening, or violates third-party rights. -- Interfere with or disrupt the App's servers, networks, or security measures. -- Exploit any vulnerabilities for unauthorized access or commercial use. -- Make false or misleading claims regarding the App's divination results. +- Use the App for illegal, malicious, fraudulent or infringing behaviors. +- Publish or spread illegal, defamatory, obscene, threatening, violent or third-party infringing content. +- Attack, interfere or disrupt the App's operating environment, server and network stability. +- Exploit system vulnerabilities for unauthorized access, commercial profit or improper use. +- Exaggerate or falsely promote the functional effect and reference value of in-app content. -We reserve the right to **warn, restrict, suspend, or terminate accounts** that violate these rules, without prior notice, and may pursue legal action where appropriate. +I reserve the right to issue warnings, restrict functions, suspend or terminate your account without prior notice if you violate the above provisions, and reserve the right to pursue legal liability when necessary. + +--- ## 7. Disclaimer of Warranties (US Standard) -THE APP AND ALL SERVICES ARE PROVIDED **"AS IS" AND "AS AVAILABLE"**, WITHOUT WARRANTIES OF ANY KIND—EXPRESS OR IMPLIED—INCLUDING BUT NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, OR NON-INFRINGEMENT. +THE APP AND ALL IN-APP FUNCTIONS ARE PROVIDED ON AN "AS IS" AND "AS AVAILABLE" BASIS, WITH NO EXPRESS OR IMPLIED WARRANTIES OF ANY KIND. THIS INCLUDES BUT IS NOT LIMITED TO IMPLIED WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NON-INFRINGEMENT OF THIRD-PARTY RIGHTS. -WE DO NOT WARRANT THAT: +I DO NOT WARRANT THAT: -- The App will be uninterrupted, secure, error-free, or accurate. -- Results from the App will meet your expectations. -- AI-generated content is reliable or suitable for any purpose. +- The App will operate continuously, securely, error-free or without interruption. +- All generated cultural reference content will fully meet your expectations. +- The App and its functions are completely stable, virus-free or defect-free. + +--- ## 8. Limitation of Liability -TO THE FULLEST EXTENT PERMITTED BY LAW: +To the fullest extent permitted by applicable U.S. laws: -- IN NO EVENT SHALL XUNMEE TECHNOLOGY, ITS DIRECTORS, EMPLOYEES, OR AFFILIATES BE LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR PUNITIVE DAMAGES ARISING FROM YOUR USE OF THE APP. -- OUR TOTAL AGGREGATE LIABILITY FOR ANY CLAIM RELATED TO THE APP SHALL NOT EXCEED THE TOTAL AMOUNT YOU PAID TO US (IF ANY) IN THE PAST 12 MONTHS. +- I shall not be liable for any indirect, incidental, special, consequential or compensatory damages arising from your use of the App. +- Under no circumstances shall I bear excessive liability for disputes, losses or risks caused by your independent judgment and personal decisions. +- As a free individual development application, no paid transaction relationship exists; all use risks shall be borne by the user. + +--- ## 9. Indemnification -You agree to **indemnify and hold harmless** Xunmee Technology from all claims, damages, losses, and expenses (including legal fees) arising from: +You agree to indemnify and hold the individual developer harmless from all claims, damages, losses, costs and reasonable legal expenses arising from: -- Your violation of these Terms. -- Your use or misuse of the App. -- Your infringement of third-party rights. +- Your violation of these Terms of Service. +- Improper use, abuse or unauthorized operation of the App. +- Any infringement of third-party intellectual property and legal rights caused by your published content. + +--- ## 10. Governing Law & Dispute Resolution -These Terms are governed by the **laws of the State of California, U.S.A.**, without regard to its conflict of laws principles. +These Terms shall be governed by and construed in accordance with the laws of the State of California, United States, excluding conflict of law rules. -Any dispute arising from these Terms shall first be resolved through good-faith negotiation. If unresolved, disputes shall be settled in the **state or federal courts located in Los Angeles County, California**, and you consent to the personal jurisdiction of such courts. +In case of any dispute arising from the use of this App, both parties shall first resolve the matter through friendly negotiation. If negotiation fails, disputes shall be submitted to the competent courts in Los Angeles County, California, for resolution. + +--- ## 11. Modifications to Terms -We reserve the right to **update or modify these Terms at any time**. We will notify users of material changes via in-app notice, email, or prominent posting. Your continued use after the effective date constitutes acceptance of the revised Terms. +I reserve the right to revise and update these Terms of Service at any time. Material rule changes will be notified via in-app reminders or official contact email. Your continued use of the App after the update takes effect means you fully accept the revised Terms. + +--- ## 12. Contact Information -For questions, concerns, or notices regarding these Terms: +If you have questions, feedback or legal inquiries about these Terms, please contact: -- **Company Name:** Xunmee Technology (Shenzhen) Co., Ltd. -- **Contact Email:** xuyunlong@xunmee.com -- **Privacy Policy:** [Insert your Privacy Policy URL here] +- **Developer**: Individual Independent Developer +- **Contact Email**: ann@xumee.com diff --git a/apps/assets/legal/zh/about_us.md b/apps/assets/legal/zh/about_us.md index 2b8fc27..f8c1d46 100644 --- a/apps/assets/legal/zh/about_us.md +++ b/apps/assets/legal/zh/about_us.md @@ -1,25 +1,23 @@ # 关于我们 -欢迎来到觅爻签问,这是一个借助于 AI 解读传统六爻占卜的平台,为用户了解中国传统易学文化提供一个窗口。 +欢迎使用 觅爻 MeeYao,一款依托 AI 技术、以传统六爻文化与易经智慧为核心的传统文化参考工具。 -六爻占卜源于《周易》深邃的哲学体系,是古人探索世界运行规律的一种独特方法。古人认为宇宙万物相互关联,在你起卦时,你的心念与时空信息会凝结成卦象的方式呈现出来。得到卦象后,再结合《易经》中的爻辞和某些特定规律,如五行生克、干支冲合等,分析各要素间的发展趋势,最终推断出事物可能的走向。 +六爻文化源自博大精深的易经哲学体系,承载着古人对于心念、时序与天地变化相生相融的传统认知。结合卦象文化、五行理论及干支传统人文理念,帮助用户探索东方传统文化内涵,获得多元的生活参考视角。 -觅爻签问就是基于这样的思路而开发出来的平台,它的核心价值在于帮助你跳出局限思维,从事物全局和演变趋势的角度看清现状的矛盾、潜在机会和风险点,为你的判断和行动提供多一个维度的参考信息,让你能更理性、更周全地做决定。我们希望 AI 能成为连接这份古老智慧的现代桥梁。 +觅爻 MeeYao 根植于东方传统文脉,核心初衷是帮助用户跳出固有思维局限,以更开阔的视角看待日常抉择与生活状态,保持理性平和的心态。我们希望借助现代 AI 技术,让大众更轻松地了解、感受与体验中华传统经典文化。 --- -## 公司信息 +## 开发者信息 -**开发者**:洵觅科技(深圳)有限公司 +**开发者**:Ann Lee -**联系邮箱**:xuyunlong@xunmee.com - -**隐私政策**:[隐私政策链接] +**联系邮箱**:ann@xumee.com --- -## 免责声明 +## 重要免责声明 -所有卦象解读结果均由 AI 生成,仅供娱乐和参考,不应用作商业、金融、医疗、法律或其他专业领域的决策依据。我们不对内容的准确性或可靠性做出保证,用户须对自己所做的任何决策承担全部责任。 +本 App 所有 AI 生成内容与文化解读资料,仅作娱乐、文化赏析与个人参考使用。本应用不提供任何专业指导建议,包括但不限于商业、金融、投资、医疗、心理、法律、职业及人生决策等领域。所有生成内容不得作为事实依据或行动决策的唯一标准。开发者不对用户的个人选择、行为及衍生后果承担任何法律责任。请理性看待传统文化,理性使用本应用。 -© 2026 觅爻签问。保留所有权利。 +© 2026 Ann Lee 保留所有权利 diff --git a/apps/assets/legal/zh/privacy_policy.md b/apps/assets/legal/zh/privacy_policy.md index 3648675..7632d21 100644 --- a/apps/assets/legal/zh/privacy_policy.md +++ b/apps/assets/legal/zh/privacy_policy.md @@ -1,25 +1,163 @@ # 隐私政策 -**注意:本文档正在更新中** +**最后更新日期**:2026年4月27日 -英文正式稿已更新完成,简体中文版本将在英文稿确认后进行翻译。 - -请参考英文版本: -- [English Version](../en/privacy_policy.md) +**生效日期**:2026年4月27日 --- -**最后更新日期**:待定 +## 引言 -**生效日期**:待定 +尊敬的用户,欢迎使用 米爻 MeeYao(以下简称"本应用"),本应用由**个人开发者**("我")独立开发和运营。我致力于保护您的个人隐私,并遵守适用的美国联邦和州隐私法律,包括《加州消费者隐私法》(CCPA/CPRA)、《儿童在线隐私保护法》(COPPA)、CalOPPA 以及其他美国州隐私法规。 + +本隐私政策清晰说明: + +- 我收集哪些个人信息 +- 您的数据如何被使用、存储和共享 +- 您在美国法规下的法定隐私权利 +- 如何提交数据请求 + +本政策适用于本应用的所有用户。加州居民享有第5节规定的额外权利。 --- -本文档将在英文隐私政策正式稿确认后,按照以下原则进行翻译: +## 1. 我收集的信息 -1. 保持与英文版本结构一致 -2. 使用标准法律术语 -3. 保留专业术语(如 CCPA、CPRA 等) -4. 符合中国法律文书规范 +我只收集为提供、维护和优化应用文化参考功能所需的数据。所有数据根据 CCPA/CPRA 分类为个人信息和敏感个人信息(SPI)。 -如有疑问,请联系:xuyunlong@xunmee.com +### 1.1 您直接提供的信息 + +- **账户信息**:电子邮箱地址、验证码(账户注册和安全验证所需) +- **个人资料**:您自愿设置的昵称或显示名称 +- **个人内容**:您输入的问题、文化解读记录和本地会话内容 +- **支持信息**:您发送的反馈、咨询消息 + +### 1.2 自动收集的信息 + +当您使用本应用时,将收集有限的自动数据以确保正常运行: + +- **设备信息**:设备型号、操作系统版本、唯一设备标识符、设备配置 +- **技术数据**:IP 地址(用于粗略区域访问识别)、访问时间、崩溃日志和操作性能数据 +- **使用数据**:功能使用记录、应用停留时长和应用内交互行为 + +--- + +## 2. 我如何使用您的信息 + +您的信息仅用于以下合法且有限的目的: + +1. **提供核心功能**:处理您的输入内容,生成 AI 文化解读内容,记录本地使用记录。 +2. **账户安全**:完成用户验证,防止异常登录,保护您的账户安全。 +3. **产品优化**:分析匿名使用数据以修复错误、优化操作体验和提升产品性能。 +4. **用户协助**:回复您的反馈,解决您的使用问题。 +5. **服务提醒**:推送必要的系统通知和政策更新提醒。 +6. **法律合规**:满足法定合规要求和官方平台审核规则。 + +未经您的明确同意,我**不会**将您的个人敏感内容用于商业广告或未经授权的营销。 + +--- + +## 3. 数据存储、保留与跨境传输 + +### 3.1 存储位置 + +通过本应用收集的用户数据可能存储于位于美国的第三方安全云服务器上。所有跨境数据传输均采用加密传输协议以确保数据安全。 + +### 3.2 保留期限 + +数据仅在必要时限内保留: + +- **账户数据**:在您活跃使用期间保留,注销账户后合理清理。 +- **个人内容记录**:在合理周期内保留,定期清理或匿名化。 +- **设备和日志数据**:在有限期限后自动删除。 + +--- + +## 4. 信息共享与披露 + +### 4.1 个人信息的出售 + +我**不以任何形式出售、出租或交易您的个人信息**,绝不会为商业利益出售您的数据。 + +### 4.2 与第三方服务提供商共享 + +我只与应用运行所需的可信赖第三方服务提供商共享数据,并签署严格的数据保护限制: + +- 云存储和服务器服务 +- 应用运营分析、崩溃监控工具 +- 苹果官方推送和系统服务能力 + +所有第三方均被禁止将您的数据用于独立的商业目的。 + +### 4.3 法律披露 + +仅在以下情况下您的数据可能被披露: + +- 法律、法规、法院命令或官方政府要求所规定 +- 经您明确自愿授权和同意 +- 为保护个人合法权益和公共安全 + +--- + +## 5. 您的美国隐私权利(包括加州居民) + +根据 CCPA/CPRA 和美国地方隐私法律,您享有以下权利: + +1. **知情权**:查询收集的个人数据类型和范围。 +2. **访问权**:获取您的个人使用数据副本。 +3. **删除权**:申请删除您的账户及相关个人数据。 +4. **更正权**:修改不正确的个人信息。 +5. **数据携带权**:以可读格式获取您的数据。 +6. **选择退出权**:拒绝非必要数据收集和无关推荐。 +7. **限制敏感数据权**:限制使用您的个人敏感内容。 +8. **不受歧视权**:行使隐私权利不受差别待遇。 + +### 如何行使您的权利 + +您可以通过唯一指定联系方式提交数据请求: + +- **联系邮箱**:ann@xumee.com + +我将在 45 天内回复您的合法请求,并在处理前妥善验证您的身份以确保数据安全。 + +--- + +## 6. 儿童隐私(COPPA 合规) + +本应用不面向 13 岁以下用户。我不会故意收集 13 岁以下未成年人的任何个人信息。 + +如果您发现未成年人信息被不当收集,请及时通过邮箱联系我,我将按照 COPPA 法规完全删除相关数据。13-17 岁用户需在监护人监督和同意下使用本应用。 + +--- + +## 7. 数据安全 + +我采取行业标准的技术保护措施保护您的数据: + +- 加密存储和加密传输以防止数据泄露 +- 严格的访问限制和日常安全管理 +- 定期异常监控和风险检查 + +请注意,没有任何网络存储系统能实现绝对安全,我将始终保持最高级别的数据保护措施。 + +--- + +## 8. 政策变更 + +本隐私政策可能会不定期更新以适应平台规则和法律调整。重要内容变更将通过应用内提示或邮件提醒提前通知。更新生效后您继续使用本应用,即表示您同意修订后的政策。 + +--- + +## 9. 联系我们 + +如果您对本隐私政策有任何疑问、建议或隐私相关投诉,请联系我: + +**开发者邮箱**:ann@xumee.com + +如果您是加州居民且对处理结果不满意,可咨询当地隐私监管机构。 + +--- + +**独立个人开发者** + +**最后更新日期**:2026年4月27日 diff --git a/apps/assets/legal/zh/terms_of_service.md b/apps/assets/legal/zh/terms_of_service.md index 98a599f..b3504b0 100644 --- a/apps/assets/legal/zh/terms_of_service.md +++ b/apps/assets/legal/zh/terms_of_service.md @@ -1,97 +1,121 @@ -# 服务条款(美国合规版本) +# 用户服务条款 -**最后更新日期**:2026 年 4 月 15 日 +**最后更新日期**:2026年4月27日 -## 1. 接受条款 +--- -觅爻签问(以下简称"本 APP")由**洵觅科技(深圳)有限公司**(以下简称"我们"、"我们公司"或"我们的")开发、运营和维护。 +## 1. 条款接受 -通过下载、安装、注册、访问或使用本 APP,您(以下简称"您"或"用户")确认您已阅读、理解并**无条件同意**受这些服务条款(以下简称"条款")和我们的**隐私政策**约束。如果您不同意,您不得使用本 APP。 +米爻 MeeYao(以下简称"本应用")由**个人开发者**("我")独立开发、拥有和运营。 + +下载、安装、注册、访问或使用本应用,即表示您("您"或"用户")确认已阅读、理解并无条件同意受本服务条款("条款")及我的隐私政策约束。如果您不同意本条款,请勿使用本应用。 + +--- ## 2. 年龄要求与 COPPA 合规 -您声明并保证您使用本 APP 时**年满 13 周岁**。 +您声明并保证您年满 13 岁方可使用本应用。 -- 本 APP**不适用于 13 岁以下儿童**。 -- 我们不会故意收集 13 岁以下用户的个人信息。如果我们发现 13 岁以下儿童向我们提供了个人数据,我们将立即删除此类信息。 +- 本应用不面向 13 岁以下儿童。 +- 我不会故意收集 13 岁以下用户的个人信息。如发现 13 岁以下未成年人提交了个人数据,我将立即采取行动删除该信息。 + +--- ## 3. 服务说明 -本 APP 提供**AI 辅助六爻占卜解读服务**(包括手动和自动起卦流程),基于传统《易经》哲学。 +本应用提供与传统易经和六爻文化相关的 AI 辅助文化解读内容,仅供日常参考和文化赏析。 -- 所有占卜结果和解读均为 **AI 生成,仅供娱乐和参考目的**。 -- 它们**不作为专业建议**(金融、法律、医疗、商业或其他)。 -- 我们不保证任何 AI 生成内容的准确性、完整性或可靠性。 -- 由于维护、技术故障、不可抗力或第三方问题导致的临时服务中断不构成违反本条款。 +- 所有 AI 生成内容和文化参考资料仅供娱乐和个人参考目的。 +- 内容不得视为专业建议,包括但不限于金融、投资、法律、医疗、职业或商业决策。 +- 我不保证本应用内任何 AI 生成内容的准确性、完整性或实用性。 +- 因系统维护、技术异常、网络故障或不可抗力导致的临时服务中断不视为违反本条款。 + +--- ## 4. 用户账户与数据隐私 -- 您必须提供**真实、准确和完整的注册信息**并保持更新。 -- 您有责任维护账户凭证的保密性,并对您账户下的所有活动负责。 -- 我们按照我们的**隐私政策**收集和处理必要的个人信息,遵守适用的法律,包括**加州消费者隐私法案(CCPA/CPRA)**。 -- 加州用户有权按照隐私政策的规定访问、删除和选择不出售其个人数据。 +- 您应提供真实、准确和完整的注册信息,并保持信息更新。 +- 您对保护账户登录凭据和账户下进行的所有活动负全责。 +- 我严格按照公布的隐私政策收集和处理用户个人数据,并遵守适用的美国隐私法律,包括 CCPA/CPRA。 +- 加州居民享有隐私政策中规定的数据访问、删除和隐私权利。 + +--- ## 5. 知识产权 -本 APP 内的所有内容——包括软件代码、文本、图像、音频、视频、商标、服务标志和设计——均为洵觅科技(深圳)有限公司的专有财产,受**美国版权法(DMCA)**、商标法和国际条约保护。 +本应用内的所有知识产权,包括但不限于程序代码、文字内容、图形设计、界面内容、标识和视觉元素,均为个人开发者独有,受美国版权法(DMCA)、商标法规和国际知识产权公约保护。 您不得: -- 复制、修改、分发、传输、展示或创建本 APP 或其内容的衍生作品。 -- 对本 APP 进行反向工程、反编译、反汇编或尝试提取源代码。 -- 删除本 APP 上的任何版权、商标或专有通知。 +- 复制、修改、编辑、分发、复制或基于本应用及其内部内容创作衍生作品。 +- 反向工程、反编译、反汇编、破解或试图获取本应用源代码。 +- 删除、覆盖或更改本应用中的任何版权声明、专有标记和知识产权声明。 + +--- ## 6. 禁止的用户行为 -您同意不: +您同意不会: -- 将本 APP 用于**非法、有害、欺诈或侵权活动**。 -- 发布或传输非法、诽谤、淫秽、威胁或侵犯第三方权利的内容。 -- 干扰或破坏本 APP 的服务器、网络或安全措施。 -- 利用任何漏洞进行未经授权的访问或商业使用。 -- 对本 APP 的占卜结果做出虚假或误导性声明。 +- 将本应用用于非法、恶意、欺诈或侵权行为。 +- 发布或传播非法、诽谤、淫秽、威胁、暴力或侵犯第三方的内容。 +- 攻击、干扰或破坏本应用的运行环境、服务器和网络稳定性。 +- 利用系统漏洞进行未授权访问、商业牟利或不当使用。 +- 夸大或虚假宣传应用内内容的功能效果和参考价值。 -我们保留权利**警告、限制、暂停或终止**违反这些规则的账户,无需事先通知,并可在适当情况下追究法律责任。 +如您违反上述规定,我保留不经事先通知发出警告、限制功能、暂停或终止您账户的权利,并保留必要时追究法律责任的权利。 + +--- ## 7. 免责声明(美国标准) -本 APP 和所有服务均按**"原样"和"可用"**提供,不提供任何形式的明示或暗示保证——包括但不限于适销性、适用于特定用途或不侵权的暗示保证。 +本应用及所有应用功能按"现状"和"可用"基础提供,不提供任何形式的明示或默示保证。包括但不限于适销性、特定用途适用性和不侵犯第三方权利的默示保证。 -我们不保证: +我不保证: -- 本 APP 将不间断、安全、无错误或准确。 -- 本 APP 的结果将满足您的期望。 -- AI 生成的内容可靠或适用于任何目的。 +- 本应用将连续、安全、无错误或不间断运行。 +- 所有生成的文化参考内容将完全符合您的期望。 +- 本应用及其功能完全稳定、无病毒或无缺陷。 + +--- ## 8. 责任限制 -在法律允许的最大范围内: +在适用美国法律允许的最大范围内: -- 在任何情况下,洵觅科技、其董事、员工或关联公司均不对因您使用本 APP 而产生的任何间接、偶然、特殊、后果性或惩罚性损害承担责任。 -- 我们对与本 APP 相关的任何索赔的总累积责任不得超过您在过去 12 个月内向我们支付的总额(如有)。 +- 我不对您使用本应用产生的任何间接、偶然、特殊、后果性或补偿性损害承担责任。 +- 在任何情况下,我都不对因您的独立判断和个人决定引起的争议、损失或风险承担过度责任。 +- 作为免费个人开发应用,不存在付费交易关系;所有使用风险由用户自行承担。 + +--- ## 9. 赔偿 -您同意**赔偿并使洵觅科技免受损害**所有索赔、损害、损失和费用(包括法律费用),这些索赔、损害、损失和费用源于: +您同意赔偿并使个人开发者免受因以下原因产生的所有索赔、损害、损失、费用和合理法律费用: -- 您违反这些条款。 -- 您使用或滥用本 APP。 -- 您侵犯第三方权利。 +- 您违反本服务条款。 +- 不当使用、滥用或未经授权操作本应用。 +- 您发布的内容导致的第三方知识产权和法律权利侵犯。 + +--- ## 10. 适用法律与争议解决 -这些条款受**美国加利福尼亚州法律**管辖,不考虑其法律冲突原则。 +本条款受美国加利福尼亚州法律管辖并据其解释,排除法律冲突规则。 -因这些条款引起的任何争议应首先通过真诚协商解决。如果未解决,争议应在位于加利福尼亚州洛杉矶县的**州或联邦法院**解决,您同意此类法院的个人管辖权。 +因使用本应用产生的任何争议,双方应首先通过友好协商解决。协商不成的,争议应提交加利福尼亚州洛杉矶县有管辖权的法院解决。 -## 11. 条款的修改 +--- -我们保留权利**随时更新或修改这些条款**。我们将通过应用内通知、电子邮件或突出发布通知用户新的重大变更。生效日期后您继续使用即构成接受修订后的条款。 +## 11. 条款修改 -## 12. 联系信息 +我保留随时修订和更新本服务条款的权利。重大规则变更将通过应用内提醒或官方联系邮箱通知。更新生效后您继续使用本应用,即表示您完全接受修订后的条款。 -如需有关这些条款的问题、担忧或通知: +--- -- **公司名称**:洵觅科技(深圳)有限公司 -- **联系邮箱**:xuyunlong@xunmee.com -- **隐私政策**:[在此插入您的隐私政策 URL] +## 12. 联系方式 + +如果您对本条款有疑问、反馈或法律咨询,请联系: + +- **开发者**:独立个人开发者 +- **联系邮箱**:ann@xumee.com diff --git a/apps/assets/legal/zh_Hant/about_us.md b/apps/assets/legal/zh_Hant/about_us.md index 8a68554..009e578 100644 --- a/apps/assets/legal/zh_Hant/about_us.md +++ b/apps/assets/legal/zh_Hant/about_us.md @@ -1,25 +1,23 @@ # 關於我們 -歡迎來到覓爻簽問,這是一個借助於 AI 解讀傳統六爻占卜的平台,為用戶了解中國傳統易學文化提供一個窗口。 +歡迎使用 觅爻 MeeYao,一款依託 AI 技術、以傳統六爻文化與易經智慧為核心的傳統文化參考工具。 -六爻占卜源於《周易》深邃的哲學體系,是古人探索世界運行規律的一種獨特方法。古人認為宇宙萬物相互關聯,在你起卦時,你的心念與時空信息會凝結成卦象的方式呈現出來。得到卦象後,再結合《易經》中的爻辭和某些特定規律,如五行生剋、干支冲合等,分析各要素間的發展趨勢,最終推斷出事物可能的走向。 +六爻文化源自博大精深的易經哲學體系,承載著古人對於心念、時序與天地變化相生相融的傳統認知。結合卦象文化、五行理論及干支傳統人文理念,幫助用戶探索東方傳統文化內涵,獲得多元的生活參考視角。 -覓爻簽問就是基於這樣的思路而開發出來的平台,它的核心價值在於幫助你跳出局限思維,從事物全局和演變趨勢的角度看清現狀的矛盾、潛在機會和風險點,為你的判斷和行動提供多一個維度的參考信息,讓你能更理性、更周全地做決定。我們希望 AI 能成為連接這份古老智慧的現代橋樑。 +觅爻 MeeYao 根植於東方傳統文脈,核心初衷是幫助用戶跳出固有思維局限,以更開闊的視角看待日常抉擇與生活狀態,保持理性平和的心態。我們希望借助現代 AI 技術,讓大眾更輕鬆地了解、感受與體驗中華傳統經典文化。 --- -## 公司信息 +## 開發者信息 -**開發者**:洵覓科技(深圳)有限公司 +**開發者**:Ann Lee -**聯繫郵箱**:xuyunlong@xunmee.com - -**隱私政策**:[隱私政策連結] +**聯繫郵箱**:ann@xumee.com --- -## 免責聲明 +## 重要免責聲明 -所有卦象解讀結果均由 AI 生成,僅供娛樂和參考,不應用作商業、金融、醫療、法律或其他專業領域的決策依據。我們不對內容的準確性或可靠性做出保證,用戶須對自己所做的任何決策承擔全部責任。 +本 App 所有 AI 生成內容與文化解讀資料,僅作娛樂、文化賞析與個人參考使用。本應用不提供任何專業指導建議,包括但不限於商業、金融、投資、醫療、心理、法律、職業及人生決策等領域。所有生成內容不得作為事實依據或行動決策的唯一標準。開發者不對用戶的個人選擇、行為及衍生後果承擔任何法律責任。請理性看待傳統文化,理性使用本應用。 -© 2026 覓爻簽問。保留所有權利。 +© 2026 Ann Lee 保留所有權利 diff --git a/apps/assets/legal/zh_Hant/privacy_policy.md b/apps/assets/legal/zh_Hant/privacy_policy.md index 25fd575..ca577b3 100644 --- a/apps/assets/legal/zh_Hant/privacy_policy.md +++ b/apps/assets/legal/zh_Hant/privacy_policy.md @@ -1,25 +1,163 @@ # 隱私政策 -**注意:本文檔正在更新中** +**最後更新日期**:2026年4月27日 -英文正式稿已更新完成,繁體中文版本將在英文稿確認後進行翻譯。 - -請參考英文版本: -- [English Version](../en/privacy_policy.md) +**生效日期**:2026年4月27日 --- -**最後更新日期**:待定 +## 引言 -**生效日期**:待定 +尊敬的用戶,歡迎使用 米爻 MeeYao(以下簡稱「本應用」),本應用由**個人開發者**(「我」)獨立開發和運營。我致力於保護您的個人隱私,並遵守適用的美國聯邦和州隱私法律,包括《加州消費者隱私法》(CCPA/CPRA)、《兒童在線隱私保護法》(COPPA)、CalOPPA 以及其他美國州隱私法規。 + +本隱私政策清晰說明: + +- 我收集哪些個人信息 +- 您的數據如何被使用、存儲和共享 +- 您在美國法規下的法定隱私權利 +- 如何提交數據請求 + +本政策適用於本應用的所有用戶。加州居民享有第5節規定的額外權利。 --- -本文檔將在英文隱私政策正式稿確認後,按照以下原則進行翻譯: +## 1. 我收集的信息 -1. 保持與英文版本結構一致 -2. 使用標準法律術語 -3. 保留專業術語(如 CCPA、CPRA 等) -4. 符合繁體中文法律文書規範 +我只收集為提供、維護和優化應用文化參考功能所需的數據。所有數據根據 CCPA/CPRA 分類為個人信息和敏感個人信息(SPI)。 -如有疑問,請聯繫:xuyunlong@xunmee.com +### 1.1 您直接提供的信息 + +- **賬戶信息**:電子郵箱地址、驗證驗證碼(賬戶註冊和安全驗證所需) +- **個人資料**:您自願設置的暱稱或顯示名稱 +- **個人內容**:您輸入的問題、文化解讀記錄和本地會話內容 +- **支持信息**:您發送的反饋、諮詢消息 + +### 1.2 自動收集的信息 + +當您使用本應用時,將收集有限的自動數據以確保正常運行: + +- **設備信息**:設備型號、操作系統版本、唯一設備標識符、設備配置 +- **技術數據**:IP 地址(用於粗略區域訪問識別)、訪問時間、崩潰日誌和操作性能數據 +- **使用數據**:功能使用記錄、應用停留時長和應用內交互行為 + +--- + +## 2. 我如何使用您的信息 + +您的信息僅用於以下合法且有限的目的: + +1. **提供核心功能**:處理您的輸入內容,生成 AI 文化解讀內容,記錄本地使用記錄。 +2. **賬戶安全**:完成用戶驗證,防止異常登錄,保護您的賬戶安全。 +3. **產品優化**:分析匿名使用數據以修復錯誤、優化操作體驗和提升產品性能。 +4. **用戶協助**:回覆您的反饋,解決您的使用問題。 +5. **服務提醒**:推送必要的系統通知和政策更新提醒。 +6. **法律合規**:滿足法定合規要求和官方平台審核規則。 + +未經您的明確同意,我**不會**將您的個人敏感內容用於商業廣告或未經授權的營銷。 + +--- + +## 3. 數據存儲、保留與跨境傳輸 + +### 3.1 存儲位置 + +通過本應用收集的用戶數據可能存儲於位於美國的第三方安全雲服務器上。所有跨境數據傳輸均採用加密傳輸協議以確保數據安全。 + +### 3.2 保留期限 + +數據僅在必要時限內保留: + +- **賬戶數據**:在您活躍使用期間保留,註銷賬戶後合理清理。 +- **個人內容記錄**:在合理週期內保留,定期清理或匿名化。 +- **設備和日誌數據**:在有限期限後自動刪除。 + +--- + +## 4. 信息共享與披露 + +### 4.1 個人信息的出售 + +我**不以任何形式出售、出租或交易您的個人信息**,絕不會為商業利益出售您的數據。 + +### 4.2 與第三方服務提供商共享 + +我只與應用運行所需的可信賴第三方服務提供商共享數據,並簽署嚴格的數據保護限制: + +- 雲存儲和服務器服務 +- 應用運營分析、崩潰監控工具 +- 蘋果官方推送和系統服務能力 + +所有第三方均被禁止將您的數據用於獨立的商業目的。 + +### 4.3 法律披露 + +僅在以下情況下您的數據可能被披露: + +- 法律、法規、法院命令或官方政府要求所規定 +- 經您明確自願授權和同意 +- 為保護個人合法權益和公共安全 + +--- + +## 5. 您的美國隱私權利(包括加州居民) + +根據 CCPA/CPRA 和美國地方隱私法律,您享有以下權利: + +1. **知情權**:查詢收集的個人數據類型和範圍。 +2. **訪問權**:獲取您的個人使用數據副本。 +3. **刪除權**:申請刪除您的賬戶及相關個人數據。 +4. **更正權**:修改不正確的個人信息。 +5. **數據攜帶權**:以可讀格式獲取您的數據。 +6. **選擇退出權**:拒絕非必要數據收集和無關推薦。 +7. **限制敏感數據權**:限制使用您的個人敏感內容。 +8. **不受歧視權**:行使隱私權利不受差別待遇。 + +### 如何行使您的權利 + +您可以通過唯一指定聯繫方式提交數據請求: + +- **聯繫郵箱**:ann@xumee.com + +我將在 45 天內回覆您的合法請求,並在處理前妥善驗證您的身份以確保數據安全。 + +--- + +## 6. 兒童隱私(COPPA 合規) + +本應用不面向 13 歲以下用戶。我不會故意收集 13 歲以下未成年人的任何個人信息。 + +如果您發現未成年人信息被不當收集,請及時通過郵箱聯繫我,我將按照 COPPA 法規完全刪除相關數據。13-17 歲用戶需在監護人監督和同意下使用本應用。 + +--- + +## 7. 數據安全 + +我採取行業標準的技術保護措施保護您的數據: + +- 加密存儲和加密傳輸以防止數據洩露 +- 嚴格的訪問限制和日常安全管理 +- 定期異常監控和風險檢查 + +請注意,沒有任何網絡存儲系統能實現絕對安全,我將始終保持最高級別的數據保護措施。 + +--- + +## 8. 政策變更 + +本隱私政策可能會不定期更新以適應平台規則和法律調整。重要內容變更將通過應用內提示或郵件提醒提前通知。更新生效後您繼續使用本應用,即表示您同意修訂後的政策。 + +--- + +## 9. 聯繫我們 + +如果您對本隱私政策有任何疑問、建議或隱私相關投訴,請聯繫我: + +**開發者郵箱**:ann@xumee.com + +如果您是加州居民且對處理結果不滿意,可諮詢當地隱私監管機構。 + +--- + +**獨立個人開發者** + +**最後更新日期**:2026年4月27日 diff --git a/apps/assets/legal/zh_Hant/terms_of_service.md b/apps/assets/legal/zh_Hant/terms_of_service.md index ef9f85e..08af393 100644 --- a/apps/assets/legal/zh_Hant/terms_of_service.md +++ b/apps/assets/legal/zh_Hant/terms_of_service.md @@ -1,97 +1,121 @@ -# 服務條款(美國合規版本) +# 用戶服務條款 -**最後更新日期**:2026 年 4 月 15 日 +**最後更新日期**:2026年4月27日 -## 1. 接受條款 +--- -覓爻簽問(以下簡稱"本 APP")由**洵覓科技(深圳)有限公司**(以下簡稱"我們"、"我們公司"或"我們的")開發、運營和維護。 +## 1. 條款接受 -通過下載、安裝、註冊、訪問或使用本 APP,您(以下簡稱"您"或"用戶")確認您已閱讀、理解並**無條件同意**受這些服務條款(以下簡稱"條款")和我們的**隱私政策**約束。如果您不同意,您不得使用本 APP。 +米爻 MeeYao(以下簡稱「本應用」)由**個人開發者**(「我」)獨立開發、擁有和運營。 + +下載、安裝、註冊、訪問或使用本應用,即表示您(「您」或「用戶」)確認已閱讀、理解並無條件同意受本服務條款(「條款」)及我的隱私政策約束。如果您不同意本條款,請勿使用本應用。 + +--- ## 2. 年齡要求與 COPPA 合規 -您聲明並保證您使用本 APP 時**年滿 13 週歲**。 +您聲明並保證您年滿 13 歲方可使用本應用。 -- 本 APP**不適用於 13 歲以下兒童**。 -- 我們不會故意收集 13 歲以下用戶的個人信息。如果我們發現 13 歲以下兒童向我們提供了個人數據,我們將立即刪除此類信息。 +- 本應用不面向 13 歲以下兒童。 +- 我不會故意收集 13 歲以下用戶的個人信息。如發現 13 歲以下未成年人提交了個人數據,我將立即採取行動刪除該信息。 + +--- ## 3. 服務說明 -本 APP 提供**AI 輔助六爻占卜解讀服務**(包括手動和自動起卦流程),基於傳統《易經》哲學。 +本應用提供與傳統易經和六爻文化相關的 AI 輔助文化解讀內容,僅供日常參考和文化賞析。 -- 所有占卜結果和解讀均為 **AI 生成,僅供娛樂和參考目的**。 -- 它們**不作為專業建議**(金融、法律、醫療、商業或其他)。 -- 我們不保證任何 AI 生成內容的準確性、完整性或可靠性。 -- 由於維護、技術故障、不可抗力或第三方問題導致的臨時服務中斷不構成違反本條款。 +- 所有 AI 生成內容和文化參考資料僅供娛樂和個人參考目的。 +- 內容不得視為專業建議,包括但不限於金融、投資、法律、醫療、職業或商業決策。 +- 我不保證本應用內任何 AI 生成內容的準確性、完整性或實用性。 +- 因系統維護、技術異常、網絡故障或不可抗力導致的臨時服務中斷不視為違反本條款。 -## 4. 用戶帳戶與數據隱私 +--- -- 您必須提供**真實、準確和完整的註冊信息**並保持更新。 -- 您有責任維護帳戶憑證的保密性,並對您帳戶下的所有活動負責。 -- 我們按照我們的**隱私政策**收集和處理必要的個人信息,遵守適用的法律,包括**加州消費者隱私法案(CCPA/CPRA)**。 -- 加州用戶有權按照隱私政策的規定訪問、刪除和選擇不出售其個人數據。 +## 4. 用戶賬戶與數據隱私 + +- 您應提供真實、準確和完整的註冊信息,並保持信息更新。 +- 您對保護賬戶登錄憑據和賬戶下進行的所有活動負全責。 +- 我嚴格按照公布的隱私政策收集和處理用戶個人數據,並遵守適用的美國隱私法律,包括 CCPA/CPRA。 +- 加州居民享有隱私政策中規定的數據訪問、刪除和隱私權利。 + +--- ## 5. 知識產權 -本 APP 內的所有內容——包括軟體代碼、文本、圖像、音頻、視頻、商標、服務標誌和設計——均為洵覓科技(深圳)有限公司的專有財產,受**美國版權法(DMCA)**、商標法和國際條約保護。 +本應用內的所有知識產權,包括但不限於程序代碼、文字內容、圖形設計、界面內容、標識和視覺元素,均為個人開發者獨有,受美國版權法(DMCA)、商標法規和國際知識產權公約保護。 您不得: -- 複製、修改、分發、傳輸、展示或創建本 APP 或其內容的衍生作品。 -- 對本 APP 進行反向工程、反編譯、反彙編或嘗試提取源代碼。 -- 刪除本 APP 上的任何版權、商標或專有通知。 +- 複製、修改、編輯、分發、複製或基於本應用及其內部內容創作衍生作品。 +- 反向工程、反編譯、反彙編、破解或試圖獲取本應用源代碼。 +- 刪除、覆蓋或更改本應用中的任何版權聲明、專有標記和知識產權聲明。 + +--- ## 6. 禁止的用戶行為 -您同意不: +您同意不會: -- 將本 APP 用於**非法、有害、欺詐或侵權活動**。 -- 發布或傳輸非法、誹謗、淫穢、威脅或侵犯第三方權利的內容。 -- 干擾或破壞本 APP 的伺服器、網路或安全措施。 -- 利用任何漏洞進行未經授權的訪問或商業使用。 -- 對本 APP 的占卜結果做出虛假或誤導性聲明。 +- 將本應用用於非法、惡意、欺詐或侵權行為。 +- 發布或傳播非法、誹謗、淫穢、威脅、暴力或侵犯第三方的內容。 +- 攻擊、干擾或破壞本應用的運行環境、服務器和網絡穩定性。 +- 利用系統漏洞進行未授權訪問、商業牟利或不當使用。 +- 誇大或虛假宣傳應用內內容的功能效果和參考價值。 -我們保留權利**警告、限制、暫停或終止**違反這些規則的帳戶,無需事先通知,並可在適當情況下追究法律責任。 +如您違反上述規定,我保留不經事先通知發出警告、限制功能、暫停或終止您賬戶的權利,並保留必要時追究法律責任的權利。 + +--- ## 7. 免責聲明(美國標準) -本 APP 和所有服務均按**"原樣"和"可用"**提供,不提供任何形式的明示或暗示保證——包括但不限於適銷性、適用於特定用途或不侵權的暗示保證。 +本應用及所有應用功能按「現狀」和「可用」基礎提供,不提供任何形式的明示或默示保證。包括但不限於適銷性、特定用途適用性和不侵犯第三方權利的默示保證。 -我們不保證: +我不保證: -- 本 APP 將不間斷、安全、無錯誤或準確。 -- 本 APP 的結果將滿足您的期望。 -- AI 生成內容可靠或適用於任何目的。 +- 本應用將連續、安全、無錯誤或不間斷運行。 +- 所有生成的文化參考內容將完全符合您的期望。 +- 本應用及其功能完全穩定、無病毒或無缺陷。 + +--- ## 8. 責任限制 -在法律允許的最大範圍內: +在適用美國法律允許的最大範圍內: -- 在任何情況下,洵覓科技、其董事、員工或關聯公司均不對因您使用本 APP 而產生的任何間接、偶然、特殊、後果性或懲罰性損害承擔責任。 -- 我們對與本 APP 相關的任何索賠的總累積責任不得超過您在過去 12 個月內向我們支付的總額(如有)。 +- 我不對您使用本應用產生的任何間接、偶然、特殊、後果性或補償性損害承擔責任。 +- 在任何情況下,我都不對因您的獨立判斷和個人決定引起的爭議、損失或風險承擔過度責任。 +- 作為免費個人開發應用,不存在付費交易關係;所有使用風險由用戶自行承擔。 + +--- ## 9. 賠償 -您同意**賠償並使洵覓科技免受損害**所有索賠、損害、損失和費用(包括法律費用),這些索賠、損害、損失和費用源於: +您同意賠償並使個人開發者免受因以下原因產生的所有索賠、損害、損失、費用和合理法律費用: -- 您違反這些條款。 -- 您使用或濫用本 APP。 -- 您侵犯第三方權利。 +- 您違反本服務條款。 +- 不當使用、濫用或未經授權操作本應用。 +- 您發布的內容導致的第三方知識產權和法律權利侵犯。 + +--- ## 10. 適用法律與爭議解決 -這些條款受**美國加利福尼亞州法律**管轄,不考慮其法律衝突原則。 +本條款受美國加利福尼亞州法律管轄並據其解釋,排除法律衝突規則。 -因這些條款引起的任何爭議應首先通過誠信協商解決。如果未解決,爭議應在位於加利福尼亞州洛杉磯縣的**州或聯邦法院**解決,您同意此類法院的個人管轄權。 +因使用本應用產生的任何爭議,雙方應首先通過友好協商解決。協商不成的,爭議應提交加利福尼亞州洛杉磯縣有管轄權的法院解決。 -## 11. 條款的修改 +--- -我們保留權利**隨時更新或修改這些條款**。我們將通過應用內通知、電子郵件或突出發布通知用戶新的重大變更。生效日期後您繼續使用即構成接受修訂後的條款。 +## 11. 條款修改 -## 12. 聯繫信息 +我保留隨時修訂和更新本服務條款的權利。重大規則變更將通過應用內提醒或官方聯繫郵箱通知。更新生效後您繼續使用本應用,即表示您完全接受修訂後的條款。 -如需有關這些條款的問題、擔憂或通知: +--- -- **公司名稱**:洵覓科技(深圳)有限公司 -- **聯繫郵箱**:xuyunlong@xunmee.com -- **隱私政策**:[在此插入您的隱私政策 URL] +## 12. 聯繫方式 + +如果您對本條款有疑問、反饋或法律諮詢,請聯繫: + +- **開發者**:獨立個人開發者 +- **聯繫郵箱**:ann@xumee.com diff --git a/apps/ios/EryaoProducts.storekit b/apps/ios/EryaoProducts.storekit new file mode 100644 index 0000000..1ad1326 --- /dev/null +++ b/apps/ios/EryaoProducts.storekit @@ -0,0 +1,134 @@ +{ + "identifier" : "EryaoProducts", + "nonRenewingSubscriptions" : [ + + ], + "products" : [ + { + "displayPrice" : "0.99", + "familyShareable" : false, + "internalID" : "new_user_pack_001", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "新用户专属优惠套餐", + "displayName" : "新手包", + "locale" : "zh_CN" + } + ], + "productID" : "com.meeyao.qianwen.new_user_pack", + "referenceName" : "新手包", + "type" : "Consumable" + }, + { + "displayPrice" : "6.00", + "familyShareable" : false, + "internalID" : "starter_pack_001", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "入门信用点套餐", + "displayName" : "入门包", + "locale" : "zh_CN" + } + ], + "productID" : "com.meeyao.qianwen.starter_pack", + "referenceName" : "入门包", + "type" : "Consumable" + }, + { + "displayPrice" : "18.00", + "familyShareable" : false, + "internalID" : "popular_pack_001", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "热门信用点套餐", + "displayName" : "热门包", + "locale" : "zh_CN" + } + ], + "productID" : "com.meeyao.qianwen.popular_pack", + "referenceName" : "热门包", + "type" : "Consumable" + }, + { + "displayPrice" : "68.00", + "familyShareable" : false, + "internalID" : "premium_pack_001", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "高级信用点套餐", + "displayName" : "高级包", + "locale" : "zh_CN" + } + ], + "productID" : "com.meeyao.qianwen.premium_pack", + "referenceName" : "高级包", + "type" : "Consumable" + } + ], + "settings" : { + "_applicationInternalID" : "6738123456", + "_developerTeamID" : "YOUR_TEAM_ID", + "_failTransactionsEnabled" : false, + "_lastSynchronizedDate" : 756460800, + "_locale" : "zh_CN", + "_storefront" : "CHN", + "_storeKitErrors" : [ + { + "current" : null, + "enabled" : false, + "name" : "Load Products" + }, + { + "current" : null, + "enabled" : false, + "name" : "Purchase" + }, + { + "current" : null, + "enabled" : false, + "name" : "Verification" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Store Sync" + }, + { + "current" : null, + "enabled" : false, + "name" : "Subscription Status" + }, + { + "current" : null, + "enabled" : false, + "name" : "App Transaction" + }, + { + "current" : null, + "enabled" : false, + "name" : "Manage Subscriptions Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Refund Request Sheet" + }, + { + "current" : null, + "enabled" : false, + "name" : "Offer Code Redeem Sheet" + } + ] + }, + "subscriptionGroups" : [ + + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/apps/ios/Runner/Info.plist b/apps/ios/Runner/Info.plist index e6d3f08..c79086c 100644 --- a/apps/ios/Runner/Info.plist +++ b/apps/ios/Runner/Info.plist @@ -7,7 +7,7 @@ CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleDisplayName - Meeyao Qianwen + Meeyao CFBundleExecutable $(EXECUTABLE_NAME) CFBundleIdentifier @@ -32,6 +32,12 @@ 需要将头像处理结果保存到您的相册 NSMicrophoneUsageDescription 需要麦克风权限用于语音追问 + NSLocalNetworkUsageDescription + 用于开发调试时连接本地调试服务。 + NSBonjourServices + + _dartobservatory._tcp + UIApplicationSceneManifest UIApplicationSupportsMultipleScenes diff --git a/apps/ios/Runner/en.lproj/InfoPlist.strings b/apps/ios/Runner/en.lproj/InfoPlist.strings new file mode 100644 index 0000000..3cbbd20 --- /dev/null +++ b/apps/ios/Runner/en.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +CFBundleDisplayName = "Meeyao"; +NSPhotoLibraryUsageDescription = "Access your photo library to select and upload an avatar"; +NSPhotoLibraryAddUsageDescription = "Save avatar processing results to your photo library"; +NSMicrophoneUsageDescription = "Microphone access is required for voice follow-up questions"; diff --git a/apps/ios/Runner/zh-Hans.lproj/InfoPlist.strings b/apps/ios/Runner/zh-Hans.lproj/InfoPlist.strings new file mode 100644 index 0000000..69de0d2 --- /dev/null +++ b/apps/ios/Runner/zh-Hans.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +CFBundleDisplayName = "觅爻"; +NSPhotoLibraryUsageDescription = "需要访问您的相册以选择并上传头像"; +NSPhotoLibraryAddUsageDescription = "需要将头像处理结果保存到您的相册"; +NSMicrophoneUsageDescription = "需要麦克风权限用于语音追问"; diff --git a/apps/ios/Runner/zh-Hant.lproj/InfoPlist.strings b/apps/ios/Runner/zh-Hant.lproj/InfoPlist.strings new file mode 100644 index 0000000..c7b2de8 --- /dev/null +++ b/apps/ios/Runner/zh-Hant.lproj/InfoPlist.strings @@ -0,0 +1,4 @@ +CFBundleDisplayName = "覓爻"; +NSPhotoLibraryUsageDescription = "需要存取您的相簿以選擇並上傳頭像"; +NSPhotoLibraryAddUsageDescription = "需要將頭像處理結果儲存到您的相簿"; +NSMicrophoneUsageDescription = "需要麥克風權限用於語音追問"; diff --git a/apps/lib/app/app.dart b/apps/lib/app/app.dart index cf64fbc..ad98b26 100644 --- a/apps/lib/app/app.dart +++ b/apps/lib/app/app.dart @@ -4,7 +4,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import '../core/auth/session_store.dart'; +import '../core/localization/system_locale.dart'; import '../core/logging/logger.dart'; +import '../core/timezone/system_timezone.dart'; import '../data/network/api_client.dart'; import '../data/storage/local_kv_store.dart'; import '../features/auth/data/apis/auth_api.dart'; @@ -42,6 +44,7 @@ class _EryaoAppState extends State { late final NotificationRepository _notificationRepository; late final NotificationBloc _notificationBloc; Locale _locale = const Locale('zh'); + String _timezone = 'Asia/Shanghai'; ProfileSettingsV1 _profileSettings = ProfileSettingsV1.defaultsForLocale( const Locale('zh'), ); @@ -300,11 +303,14 @@ class _EryaoAppState extends State { if (!mounted) { return; } - final serverLanguage = profile.preferences.interfaceLanguage; + final serverLanguage = profile.preferences.language; + final serverTimezone = profile.preferences.timezone; final serverLocale = localeFromLanguageTag(serverLanguage); await _sessionStore.saveLocaleTag(serverLanguage); + await _sessionStore.saveTimezone(serverTimezone); setState(() { _locale = serverLocale; + _timezone = serverTimezone; _profileSettings = profile; _loadedProfileUserEmail = userEmail; }); @@ -352,8 +358,8 @@ class _EryaoAppState extends State { Future _saveProfileSettings(ProfileSettingsV1 next) async { try { - final oldLanguage = _profileSettings.preferences.interfaceLanguage; - final newLanguage = next.preferences.interfaceLanguage; + final oldLanguage = _profileSettings.preferences.language; + final newLanguage = next.preferences.language; final saved = await _profileApi.updateSettings(next); if (!mounted) { return; @@ -383,13 +389,29 @@ class _EryaoAppState extends State { } Future _bootstrap() async { - final localeTag = await _sessionStore.getLocaleTag(); - final locale = localeTag != null - ? localeFromLanguageTag(localeTag) - : const Locale('zh'); + final savedLocaleTag = await _sessionStore.getLocaleTag(); + final Locale locale; + if (savedLocaleTag != null) { + locale = localeFromLanguageTag(savedLocaleTag); + } else { + final systemLocale = getSystemLocale(); + locale = resolveSystemLocale(systemLocale) ?? const Locale('zh'); + await _sessionStore.saveLocaleTag(languageTagFromLocale(locale)); + } + + final savedTimezone = await _sessionStore.getTimezone(); + final String timezone; + if (savedTimezone != null) { + timezone = savedTimezone; + } else { + timezone = await getSystemTimezone(); + await _sessionStore.saveTimezone(timezone); + } + if (mounted) { setState(() { _locale = locale; + _timezone = timezone; _profileSettings = ProfileSettingsV1.defaultsForLocale(locale); }); } @@ -406,12 +428,21 @@ class _EryaoAppState extends State { _locale = locale; _profileSettings = _profileSettings.copyWith( preferences: _profileSettings.preferences.copyWith( - interfaceLanguage: languageTag, + language: languageTag, ), ); }); } + void _handleBalanceChanged(int newBalance) { + if (!mounted) { + return; + } + setState(() { + _creditsBalance = newBalance; + }); + } + @override Widget build(BuildContext context) { return AnimatedBuilder( @@ -451,6 +482,7 @@ class _EryaoAppState extends State { _refreshProfile(userEmail: state.user!.email); return HomeScreen( account: state.user!.email, + userId: state.user!.id, sessionStore: _sessionStore, currentLocale: _locale, profileSettings: _profileSettings, @@ -467,6 +499,7 @@ class _EryaoAppState extends State { onDeleteHistorySession: _handleHistorySessionDeleted, onLogout: _authBloc.logout, onDeleteAccount: _deleteAccount, + onBalanceChanged: _handleBalanceChanged, ); } @@ -475,7 +508,12 @@ class _EryaoAppState extends State { onLocaleChanged: (_) {}, onRequestOtp: _authBloc.sendOtp, onLoginWithOtp: (email, otp) { - return _authBloc.loginWithOtp(email: email, otp: otp); + return _authBloc.loginWithOtp( + email: email, + otp: otp, + language: languageTagFromLocale(_locale), + timezone: _timezone, + ); }, ); } diff --git a/apps/lib/app/app_theme.dart b/apps/lib/app/app_theme.dart index f6d0088..22d422f 100644 --- a/apps/lib/app/app_theme.dart +++ b/apps/lib/app/app_theme.dart @@ -69,6 +69,8 @@ class AppTheme { warning: Color(0xFFF57C00), warningContainer: Color(0xFFFFF3E0), onWarningContainer: Color(0xFF8A4B00), + incomeGreenBg: Color(0xFFE8F5E9), + incomeGreenText: Color(0xFF2E7D32), ), ], ); diff --git a/apps/lib/core/auth/session_store.dart b/apps/lib/core/auth/session_store.dart index 83f111d..12c7e0f 100644 --- a/apps/lib/core/auth/session_store.dart +++ b/apps/lib/core/auth/session_store.dart @@ -14,6 +14,7 @@ class SessionStore { static const String _emailKey = 'saved_email'; static const String _welcomeReadKey = 'has_seen_welcome_dialog'; static const String _localeKey = 'selected_locale'; + static const String _timezoneKey = 'selected_timezone'; Future saveToken(String token) async { await _secureStorage.write(key: _tokenKey, value: token); @@ -66,4 +67,12 @@ class SessionStore { Future getLocaleTag() async { return _kvStore.getString(_localeKey); } + + Future saveTimezone(String timezone) async { + await _kvStore.setString(_timezoneKey, timezone); + } + + Future getTimezone() async { + return _kvStore.getString(_timezoneKey); + } } diff --git a/apps/lib/core/config/env.dart b/apps/lib/core/config/env.dart index 0bcfe22..27c0d9c 100644 --- a/apps/lib/core/config/env.dart +++ b/apps/lib/core/config/env.dart @@ -10,6 +10,9 @@ class Env { if (Platform.isAndroid) { return 'http://10.0.2.2:5775'; } + if (Platform.isIOS) { + return 'http://192.168.1.63:5775'; + } return 'http://localhost:5775'; } diff --git a/apps/lib/core/localization/system_locale.dart b/apps/lib/core/localization/system_locale.dart new file mode 100644 index 0000000..273a32b --- /dev/null +++ b/apps/lib/core/localization/system_locale.dart @@ -0,0 +1,27 @@ +import 'dart:ui'; + +Locale? resolveSystemLocale(Locale systemLocale) { + final lang = systemLocale.languageCode.toLowerCase(); + final script = systemLocale.scriptCode; + final country = systemLocale.countryCode; + + if (lang == 'en') { + return const Locale('en'); + } + + if (lang == 'zh') { + if (script == 'Hant' || + country == 'TW' || + country == 'HK' || + country == 'MO') { + return const Locale.fromSubtags(languageCode: 'zh', scriptCode: 'Hant'); + } + return const Locale('zh'); + } + + return null; +} + +Locale getSystemLocale() { + return PlatformDispatcher.instance.locale; +} diff --git a/apps/lib/core/network/api_problem_mapper.dart b/apps/lib/core/network/api_problem_mapper.dart index 6224c0e..71c613b 100644 --- a/apps/lib/core/network/api_problem_mapper.dart +++ b/apps/lib/core/network/api_problem_mapper.dart @@ -31,6 +31,20 @@ String mapApiProblemToMessage(ApiProblem problem, AppLocalizations l10n) { return l10n.errorAsrUnavailable; case 'PROFILE_DELETE_FAILED': return l10n.errorProfileDeleteFailed; + case 'PAYMENT_PRODUCT_NOT_FOUND': + return l10n.paymentProductNotFound; + case 'PAYMENT_PRODUCT_MISMATCH': + return l10n.paymentVerifyFailed; + case 'PAYMENT_ENVIRONMENT_MISMATCH': + return l10n.paymentVerifyFailed; + case 'PAYMENT_TRANSACTION_INVALID': + return l10n.paymentVerifyFailed; + case 'PAYMENT_TRANSACTION_REVOKED': + return l10n.paymentVerifyFailed; + case 'PAYMENT_TRANSACTION_CONFLICT': + return l10n.paymentVerifyFailed; + case 'PAYMENT_STARTER_PACK_INELIGIBLE': + return l10n.paymentStarterPackIneligible; default: break; } diff --git a/apps/lib/core/timezone/system_timezone.dart b/apps/lib/core/timezone/system_timezone.dart new file mode 100644 index 0000000..0a2c62c --- /dev/null +++ b/apps/lib/core/timezone/system_timezone.dart @@ -0,0 +1,5 @@ +import 'package:flutter_timezone/flutter_timezone.dart'; + +Future getSystemTimezone() async { + return FlutterTimezone.getLocalTimezone(); +} diff --git a/apps/lib/features/auth/data/apis/auth_api.dart b/apps/lib/features/auth/data/apis/auth_api.dart index 4647c2b..bfaa7a5 100644 --- a/apps/lib/features/auth/data/apis/auth_api.dart +++ b/apps/lib/features/auth/data/apis/auth_api.dart @@ -19,10 +19,18 @@ class AuthApi { Future createEmailSession({ required String email, required String token, + String? language, + String? timezone, }) async { + final data = { + 'email': email, + 'token': token, + }; + if (language != null) data['language'] = language; + if (timezone != null) data['timezone'] = timezone; final json = await _apiClient.postJson( '/api/v1/auth/email-session', - data: {'email': email, 'token': token}, + data: data, ); return SessionResponse.fromJson(json); } diff --git a/apps/lib/features/auth/data/repositories/auth_repository.dart b/apps/lib/features/auth/data/repositories/auth_repository.dart index 5e4141b..d497fd3 100644 --- a/apps/lib/features/auth/data/repositories/auth_repository.dart +++ b/apps/lib/features/auth/data/repositories/auth_repository.dart @@ -9,6 +9,8 @@ abstract class AuthRepository { Future loginWithEmailOtp({ required String email, required String otp, + String? language, + String? timezone, }); Future recoverSession(); @@ -37,8 +39,15 @@ class AuthRepositoryImpl implements AuthRepository { Future loginWithEmailOtp({ required String email, required String otp, + String? language, + String? timezone, }) async { - final session = await _authApi.createEmailSession(email: email, token: otp); + final session = await _authApi.createEmailSession( + email: email, + token: otp, + language: language, + timezone: timezone, + ); await _sessionStore.saveToken(session.accessToken); await _sessionStore.saveRefreshToken(session.refreshToken); await _sessionStore.saveEmail(email); diff --git a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart index 86a0715..8730bff 100644 --- a/apps/lib/features/auth/presentation/bloc/auth_bloc.dart +++ b/apps/lib/features/auth/presentation/bloc/auth_bloc.dart @@ -50,8 +50,15 @@ class AuthBloc extends ChangeNotifier { Future loginWithOtp({ required String email, required String otp, + String? language, + String? timezone, }) async { - final user = await _repository.loginWithEmailOtp(email: email, otp: otp); + final user = await _repository.loginWithEmailOtp( + email: email, + otp: otp, + language: language, + timezone: timezone, + ); _logger.info(message: 'User logged in', extra: {'user_id': user.id}); _state = AuthState(status: AuthStatus.authenticated, user: user); notifyListeners(); diff --git a/apps/lib/features/divination/data/apis/divination_api.dart b/apps/lib/features/divination/data/apis/divination_api.dart index a2a2cb2..14ba036 100644 --- a/apps/lib/features/divination/data/apis/divination_api.dart +++ b/apps/lib/features/divination/data/apis/divination_api.dart @@ -93,6 +93,7 @@ class DivinationApi { advice: _asStringList(agentOutputRaw['advice']), keywords: _asStringList(agentOutputRaw['keywords']), answer: _asString(agentOutputRaw['answer']), + status: _parseStatus(agentOutputRaw['status']), ); records.add(aggregate.toViewData(params)); } catch (error, stackTrace) { @@ -472,12 +473,24 @@ String _asString(Object? value) { return value is String ? value : ''; } -List _asStringList(Object? value) { - if (value is! List) { - return const []; + List _asStringList(Object? value) { + if (value is! List) { + return const []; + } + return value.whereType().toList(growable: false); + } + + DivinationRunStatus _parseStatus(Object? value) { + if (value is! String) { + return DivinationRunStatus.success; + } + return switch (value) { + 'success' => DivinationRunStatus.success, + 'failed' => DivinationRunStatus.failed, + 'refused' => DivinationRunStatus.refused, + _ => DivinationRunStatus.success, + }; } - return value.whereType().toList(growable: false); -} String _yaoTypeToText(YaoType type) { return switch (type) { diff --git a/apps/lib/features/divination/data/models/divination_backend_models.dart b/apps/lib/features/divination/data/models/divination_backend_models.dart index 3b0d505..8afc542 100644 --- a/apps/lib/features/divination/data/models/divination_backend_models.dart +++ b/apps/lib/features/divination/data/models/divination_backend_models.dart @@ -60,6 +60,7 @@ class DivinationRunAggregate { required this.advice, required this.keywords, required this.answer, + this.status = DivinationRunStatus.success, }); final DerivedDivinationData derived; @@ -70,6 +71,7 @@ class DivinationRunAggregate { final List advice; final List keywords; final String answer; + final DivinationRunStatus status; DivinationResultData toViewData(DivinationParams params) { return DivinationResultData( @@ -84,9 +86,9 @@ class DivinationRunAggregate { signType: signLevel, keywords: keywords.join('、'), focusPoints: focusPoints, - conclusion: _asBullet(conclusion), + conclusion: conclusion.join('\n'), analysis: answer, - suggestion: _asBullet(advice), + suggestion: advice.join('\n'), ganzhi: GanzhiData( yearGanZhi: derived.ganzhi.yearGanZhi, monthGanZhi: derived.ganzhi.monthGanZhi, @@ -108,19 +110,9 @@ class DivinationRunAggregate { targetYaoLines: derived.targetYaoInfoList .map((line) => line.toViewModel()) .toList(growable: false), + status: status, ); } - - String _asBullet(List lines) { - if (lines.isEmpty) { - return ''; - } - return List.generate( - lines.length, - (i) => '${i + 1}. ${lines[i]}', - growable: false, - ).join('\n'); - } } class DerivedDivinationData { diff --git a/apps/lib/features/divination/data/models/divination_result.dart b/apps/lib/features/divination/data/models/divination_result.dart index e545d69..f74c2a8 100644 --- a/apps/lib/features/divination/data/models/divination_result.dart +++ b/apps/lib/features/divination/data/models/divination_result.dart @@ -1,5 +1,7 @@ import 'divination_params.dart'; +enum DivinationRunStatus { success, failed, refused } + class DivinationResultData { const DivinationResultData({ this.threadId, @@ -20,6 +22,7 @@ class DivinationResultData { required this.wuXingStatus, required this.yaoLines, required this.targetYaoLines, + this.status = DivinationRunStatus.success, }); final DivinationParams params; @@ -40,8 +43,10 @@ class DivinationResultData { final Map wuXingStatus; final List yaoLines; final List targetYaoLines; + final DivinationRunStatus status; bool get hasChangingYao => binaryCode != changedBinaryCode; + bool get isSuccess => status == DivinationRunStatus.success; Map toJson() { return { @@ -65,6 +70,7 @@ class DivinationResultData { 'targetYaoLines': targetYaoLines .map((line) => line.toJson()) .toList(growable: false), + 'status': status.name, }; } @@ -117,8 +123,21 @@ class DivinationResultData { return YaoLineData.fromJson(raw); }) .toList(growable: false), + status: _parseStatus(json['status']), ); } + + static DivinationRunStatus _parseStatus(Object? value) { + if (value is! String) { + return DivinationRunStatus.success; + } + return switch (value) { + 'success' => DivinationRunStatus.success, + 'failed' => DivinationRunStatus.failed, + 'refused' => DivinationRunStatus.refused, + _ => DivinationRunStatus.success, + }; + } } List _requiredStringList(Map json, String key) { diff --git a/apps/lib/features/divination/data/services/divination_run_service.dart b/apps/lib/features/divination/data/services/divination_run_service.dart index d28d45f..d35a2e6 100644 --- a/apps/lib/features/divination/data/services/divination_run_service.dart +++ b/apps/lib/features/divination/data/services/divination_run_service.dart @@ -5,6 +5,7 @@ import '../../../../core/network/api_problem.dart'; import '../apis/divination_api.dart'; import '../models/divination_backend_models.dart'; import '../models/divination_params.dart'; +import '../models/divination_result.dart'; class DivinationRunService { const DivinationRunService({required DivinationApi api}) : _api = api; @@ -38,6 +39,7 @@ class DivinationRunService { List advice = const []; List keywords = const []; String answer = ''; + DivinationRunStatus status = DivinationRunStatus.success; await for (final event in _api.streamEvents( threadId: threadId, @@ -68,6 +70,7 @@ class DivinationRunService { advice = _requiredStringList(event, 'advice'); keywords = _requiredStringList(event, 'keywords'); answer = _requiredString(event, 'answer'); + status = _parseStatus(event['status']); onTextMessageEnd?.call(); continue; } @@ -111,9 +114,22 @@ class DivinationRunService { advice: advice, keywords: keywords, answer: answer, + status: status, ); } + DivinationRunStatus _parseStatus(Object? value) { + if (value is! String) { + return DivinationRunStatus.success; + } + return switch (value) { + 'success' => DivinationRunStatus.success, + 'failed' => DivinationRunStatus.failed, + 'refused' => DivinationRunStatus.refused, + _ => DivinationRunStatus.success, + }; + } + String _requiredString(Map json, String key) { final value = json[key]; if (value is! String || value.isEmpty) { diff --git a/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart b/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart index 3d68cb4..401be7e 100644 --- a/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_processing_screen.dart @@ -149,7 +149,7 @@ class _DivinationProcessingScreenState extends State if (data == null) { return; } - Navigator.of(context).pushReplacement( + Navigator.of(context).pushAndRemoveUntil( MaterialPageRoute( builder: (_) => DivinationResultScreen( data: data, @@ -157,6 +157,7 @@ class _DivinationProcessingScreenState extends State enableIntroTransition: true, ), ), + (route) => route.isFirst, ); } diff --git a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart index 51fa8ca..9c67f15 100644 --- a/apps/lib/features/divination/presentation/screens/divination_result_screen.dart +++ b/apps/lib/features/divination/presentation/screens/divination_result_screen.dart @@ -169,13 +169,8 @@ class _DivinationResultScreenState extends State { final palette = Theme.of(context).extension()!; final l10n = AppLocalizations.of(context)!; return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - if (didPop) { - return; - } - _backToHome(); - }, + canPop: true, + onPopInvokedWithResult: (didPop, result) {}, child: Scaffold( backgroundColor: colors.surface, appBar: AppBar( @@ -212,8 +207,6 @@ class _DivinationResultScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - _ResultHeader(data: widget.data), - const SizedBox(height: AppSpacing.md), _SignCard( key: _finalSignCardKey, signType: widget.data.signType, @@ -226,17 +219,17 @@ class _DivinationResultScreenState extends State { content: widget.data.conclusion, ), const SizedBox(height: AppSpacing.md), - _FocusPointsCard(points: widget.data.focusPoints), + _AnalysisCard( + title: l10n.resultSuggestion, + content: widget.data.suggestion, + ), const SizedBox(height: AppSpacing.md), _AnalysisCard( title: l10n.resultAnalysis, content: widget.data.analysis, ), const SizedBox(height: AppSpacing.md), - _AnalysisCard( - title: l10n.resultSuggestion, - content: widget.data.suggestion, - ), + _FocusPointsCard(points: widget.data.focusPoints), const SizedBox(height: AppSpacing.md), Container( width: double.infinity, @@ -275,13 +268,16 @@ class _DivinationResultScreenState extends State { ), const SizedBox(height: AppSpacing.md), _InfoCard(data: widget.data), - const SizedBox(height: AppSpacing.xl), - Text( - l10n.resultHexagramDetail, - style: Theme.of(context).textTheme.titleLarge, - ), - const SizedBox(height: AppSpacing.md), - _HexagramDetailCard(data: widget.data), + if (widget.data.isSuccess) ...[ + const SizedBox(height: AppSpacing.xl), + Text( + l10n.resultHexagramDetail, + style: Theme.of(context).textTheme.titleLarge, + ), + const SizedBox(height: AppSpacing.md), + _HexagramDetailCard(data: widget.data), + ] else + _HexagramDetailPlaceholder(status: widget.data.status), ], ), ), @@ -417,27 +413,6 @@ class _DivinationResultScreenState extends State { } } -class _ResultHeader extends StatelessWidget { - const _ResultHeader({required this.data}); - - final DivinationResultData data; - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - return Row( - children: [ - Text( - l10n.resultAIAnalysis, - style: Theme.of( - context, - ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), - ), - ], - ); - } -} - class _SignCard extends StatelessWidget { const _SignCard({super.key, required this.signType}); @@ -548,8 +523,7 @@ class _FocusPointsCard extends StatelessWidget { Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - final languageCode = Localizations.localeOf(context).languageCode; - final title = languageCode == 'en' ? 'Focus Points' : '断卦要点'; + final title = l10n.resultFocusPoints; if (points.isEmpty) { return const SizedBox.shrink(); } @@ -577,11 +551,7 @@ class _FocusPointsCard extends StatelessWidget { const Spacer(), TextButton( onPressed: () { - final content = points - .asMap() - .entries - .map((e) => '${e.key + 1}. ${e.value}') - .join('\n'); + final content = points.join('\n'); Clipboard.setData(ClipboardData(text: content)); Toast.show( context, @@ -594,28 +564,12 @@ class _FocusPointsCard extends StatelessWidget { ], ), const SizedBox(height: AppSpacing.sm), - ...List.generate(points.length, (index) { + ...points.map((point) { return Padding( padding: const EdgeInsets.only(bottom: AppSpacing.xs), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${index + 1}. ', - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.primary, - fontWeight: FontWeight.w700, - ), - ), - Expanded( - child: Text( - points[index], - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(height: 1.55), - ), - ), - ], + child: Text( + point, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(height: 1.55), ), ); }), @@ -786,6 +740,46 @@ class _InfoCard extends StatelessWidget { } } +class _HexagramDetailPlaceholder extends StatelessWidget { + const _HexagramDetailPlaceholder({required this.status}); + + final DivinationRunStatus status; + + @override + Widget build(BuildContext context) { + final colors = Theme.of(context).colorScheme; + final l10n = AppLocalizations.of(context)!; + final message = switch (status) { + DivinationRunStatus.failed => l10n.resultHexagramDetailFailed, + DivinationRunStatus.refused => l10n.resultHexagramDetailRefused, + DivinationRunStatus.success => '', + }; + return Padding( + padding: const EdgeInsets.only(top: AppSpacing.xl), + child: Card( + margin: EdgeInsets.zero, + color: colors.surfaceContainerHighest, + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Padding( + padding: const EdgeInsets.all(AppSpacing.xl), + child: Center( + child: Text( + message, + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ), + ), + ), + ); + } +} + class _HexagramDetailCard extends StatelessWidget { const _HexagramDetailCard({required this.data}); diff --git a/apps/lib/features/home/presentation/screens/home_screen.dart b/apps/lib/features/home/presentation/screens/home_screen.dart index 8bff2ad..257119f 100644 --- a/apps/lib/features/home/presentation/screens/home_screen.dart +++ b/apps/lib/features/home/presentation/screens/home_screen.dart @@ -27,6 +27,7 @@ class HomeScreen extends StatefulWidget { const HomeScreen({ super.key, required this.account, + required this.userId, required this.sessionStore, required this.currentLocale, required this.profileSettings, @@ -43,9 +44,11 @@ class HomeScreen extends StatefulWidget { required this.onDeleteHistorySession, required this.onLogout, required this.onDeleteAccount, + required this.onBalanceChanged, }); final String account; + final String userId; final SessionStore sessionStore; final Locale currentLocale; final ProfileSettingsV1 profileSettings; @@ -56,15 +59,16 @@ class HomeScreen extends StatefulWidget { final NotificationRepository notificationRepository; final Future Function(String languageTag) onLocaleChanged; final Future Function(ProfileSettingsV1 settings) - onProfileSettingsChanged; + onProfileSettingsChanged; final Future Function(ProfileSettingsV1 updated) - onSaveProfile; + onSaveProfile; final Future Function(String filePath) onUploadAvatar; final Future Function(DivinationResultData result) - onDivinationCompleted; + onDivinationCompleted; final Future Function(String threadId) onDeleteHistorySession; final Future Function() onLogout; final Future Function() onDeleteAccount; + final void Function(int newBalance) onBalanceChanged; @override State createState() => _HomeScreenState(); @@ -132,6 +136,7 @@ class _HomeScreenState extends State { ), _ProfileTab( account: widget.account, + userId: widget.userId, settings: widget.profileSettings, coinBalance: widget.coinBalance, inviteRepository: _inviteRepository, @@ -142,6 +147,7 @@ class _HomeScreenState extends State { onUploadAvatar: widget.onUploadAvatar, onLogout: widget.onLogout, onDeleteAccount: widget.onDeleteAccount, + onBalanceChanged: widget.onBalanceChanged, ), ], ), @@ -561,6 +567,7 @@ class _DivinationHistoryScreenState extends State { class _ProfileTab extends StatelessWidget { const _ProfileTab({ required this.account, + required this.userId, required this.settings, required this.coinBalance, required this.inviteRepository, @@ -571,9 +578,11 @@ class _ProfileTab extends StatelessWidget { required this.onUploadAvatar, required this.onLogout, required this.onDeleteAccount, + required this.onBalanceChanged, }); final String account; + final String userId; final ProfileSettingsV1 settings; final int coinBalance; final InviteRepository inviteRepository; @@ -581,15 +590,17 @@ class _ProfileTab extends StatelessWidget { final Future Function(String languageTag) onLocaleChanged; final Future Function(ProfileSettingsV1 settings) onSettingsChanged; final Future Function(ProfileSettingsV1 updated) - onSaveProfile; + onSaveProfile; final Future Function(String filePath) onUploadAvatar; final Future Function() onLogout; final Future Function() onDeleteAccount; + final void Function(int newBalance) onBalanceChanged; @override Widget build(BuildContext context) { return SettingsScreen( account: account, + userId: userId, settings: settings, coinBalance: coinBalance, inviteRepository: inviteRepository, @@ -600,6 +611,7 @@ class _ProfileTab extends StatelessWidget { onUploadAvatar: onUploadAvatar, onLogout: onLogout, onDeleteAccount: onDeleteAccount, + onBalanceChanged: onBalanceChanged, ); } } @@ -703,14 +715,23 @@ class _WelcomeDialog extends StatefulWidget { class _WelcomeDialogState extends State<_WelcomeDialog> { final ScrollController _scrollController = ScrollController(); bool _hasScrolledToBottom = false; + bool _hasCheckedInitialScroll = false; @override void initState() { super.initState(); _scrollController.addListener(_handleScroll); - WidgetsBinding.instance.addPostFrameCallback((_) { - _syncScrollState(); - }); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (!_hasCheckedInitialScroll) { + _hasCheckedInitialScroll = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + _syncScrollState(); + }); + } } @override @@ -730,7 +751,7 @@ class _WelcomeDialogState extends State<_WelcomeDialog> { } final max = _scrollController.position.maxScrollExtent; final current = _scrollController.offset; - final canReadAll = max <= AppSpacing.xs || current >= max - AppSpacing.md; + final canReadAll = max <= 50.0 || current >= max - AppSpacing.md; if (_hasScrolledToBottom == canReadAll) { return; } diff --git a/apps/lib/features/notifications/data/apis/notification_api.dart b/apps/lib/features/notifications/data/apis/notification_api.dart index 32d08ec..696645f 100644 --- a/apps/lib/features/notifications/data/apis/notification_api.dart +++ b/apps/lib/features/notifications/data/apis/notification_api.dart @@ -15,15 +15,19 @@ class NotificationApi { Future listNotifications({ int limit = 20, String? cursor, + String locale = 'zh', }) async { - final queryParts = ['limit=$limit']; + final queryParameters = {'limit': limit, 'locale': locale}; if (cursor != null) { - queryParts.add('cursor=$cursor'); + queryParameters['cursor'] = cursor; } - final path = '/api/v1/notifications?${queryParts.join("&")}'; try { - final json = await _apiClient.getJson(path); + final response = await _apiClient.rawDio.get>( + '/api/v1/notifications', + queryParameters: queryParameters, + ); + final json = response.data ?? {}; final itemsJson = json['items'] as List? ?? []; final items = itemsJson .map((e) => parseNotificationItem(e as Map)) @@ -59,21 +63,16 @@ class NotificationApi { } } - Future markRead({required String notificationId}) async { - _logger.info( - message: 'Mark read request started', - extra: {'notification_id': notificationId}, - ); + Future markRead({ + required String notificationId, + String locale = 'zh', + }) async { try { final response = await _apiClient.rawDio.patch>( '/api/v1/notifications/$notificationId/read', + queryParameters: {'locale': locale}, ); - final item = parseNotificationItem(response.data!); - _logger.info( - message: 'Mark read request succeeded', - extra: {'notification_id': notificationId, 'is_read': item.isRead}, - ); - return item; + return parseNotificationItem(response.data!); } on DioException catch (error, stackTrace) { _logger.error( message: 'Mark read failed', @@ -85,17 +84,11 @@ class NotificationApi { } Future markAllRead() async { - _logger.info(message: 'Mark all read request started'); try { final response = await _apiClient.rawDio.patch>( '/api/v1/notifications/mark-all-read', ); - final updatedCount = response.data?['updatedCount'] as int? ?? 0; - _logger.info( - message: 'Mark all read request succeeded', - extra: {'updated_count': updatedCount}, - ); - return updatedCount; + return response.data?['updatedCount'] as int? ?? 0; } on DioException catch (error, stackTrace) { _logger.error( message: 'Mark all read failed', diff --git a/apps/lib/features/notifications/data/repositories/notification_repository.dart b/apps/lib/features/notifications/data/repositories/notification_repository.dart index cc6ed8d..d7facf6 100644 --- a/apps/lib/features/notifications/data/repositories/notification_repository.dart +++ b/apps/lib/features/notifications/data/repositories/notification_repository.dart @@ -6,11 +6,15 @@ abstract class NotificationRepository { Future listNotifications({ int limit = 20, String? cursor, + String locale = 'zh', }); Future getUnreadCount(); - Future markRead({required String notificationId}); + Future markRead({ + required String notificationId, + String locale = 'zh', + }); Future markAllRead(); } @@ -25,8 +29,13 @@ class NotificationRepositoryImpl implements NotificationRepository { Future listNotifications({ int limit = 20, String? cursor, + String locale = 'zh', }) async { - return _notificationApi.listNotifications(limit: limit, cursor: cursor); + return _notificationApi.listNotifications( + limit: limit, + cursor: cursor, + locale: locale, + ); } @override @@ -35,8 +44,14 @@ class NotificationRepositoryImpl implements NotificationRepository { } @override - Future markRead({required String notificationId}) async { - return _notificationApi.markRead(notificationId: notificationId); + Future markRead({ + required String notificationId, + String locale = 'zh', + }) async { + return _notificationApi.markRead( + notificationId: notificationId, + locale: locale, + ); } @override diff --git a/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart b/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart index 95ec8fe..a9bd8c5 100644 --- a/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart +++ b/apps/lib/features/notifications/presentation/bloc/notification_bloc.dart @@ -15,6 +15,7 @@ class NotificationState { this.unreadCount = 0, this.hasMore = false, this.nextCursor, + this.isLoadingMore = false, this.errorMessage, }); @@ -23,6 +24,7 @@ class NotificationState { final int unreadCount; final bool hasMore; final String? nextCursor; + final bool isLoadingMore; final String? errorMessage; NotificationState copyWith({ @@ -31,6 +33,7 @@ class NotificationState { int? unreadCount, bool? hasMore, String? nextCursor, + bool? isLoadingMore, String? errorMessage, }) { return NotificationState( @@ -39,6 +42,7 @@ class NotificationState { unreadCount: unreadCount ?? this.unreadCount, hasMore: hasMore ?? this.hasMore, nextCursor: nextCursor ?? this.nextCursor, + isLoadingMore: isLoadingMore ?? this.isLoadingMore, errorMessage: errorMessage ?? this.errorMessage, ); } @@ -81,10 +85,13 @@ final class NotificationRevokedEvent extends NotificationEvent { } class NotificationBloc extends ChangeNotifier { - NotificationBloc({required NotificationRepository repository}) - : _repository = repository; + NotificationBloc({ + required NotificationRepository repository, + this.locale = 'zh', + }) : _repository = repository; final NotificationRepository _repository; + final String locale; final Logger _logger = getLogger('features.notifications.bloc'); NotificationState _state = const NotificationState(); @@ -119,7 +126,10 @@ class NotificationBloc extends ChangeNotifier { notifyListeners(); try { - final result = await _repository.listNotifications(limit: 20); + final result = await _repository.listNotifications( + limit: 20, + locale: locale, + ); _state = _state.copyWith( status: NotificationStatus.loaded, items: result.items, @@ -143,7 +153,10 @@ class NotificationBloc extends ChangeNotifier { Future _refreshNotifications() async { try { - final result = await _repository.listNotifications(limit: 20); + final result = await _repository.listNotifications( + limit: 20, + locale: locale, + ); _state = _state.copyWith( status: NotificationStatus.loaded, items: result.items, @@ -161,18 +174,25 @@ class NotificationBloc extends ChangeNotifier { } Future _loadMore() async { - if (!_state.hasMore || _state.nextCursor == null) return; + if (_state.isLoadingMore || !_state.hasMore || _state.nextCursor == null) { + return; + } + + _state = _state.copyWith(isLoadingMore: true); + notifyListeners(); try { final result = await _repository.listNotifications( limit: 20, cursor: _state.nextCursor, + locale: locale, ); final allItems = [..._state.items, ...result.items]; _state = _state.copyWith( items: allItems, hasMore: result.hasMore, nextCursor: result.nextCursor, + isLoadingMore: false, ); notifyListeners(); } catch (error, stackTrace) { @@ -181,6 +201,8 @@ class NotificationBloc extends ChangeNotifier { error: error, stackTrace: stackTrace, ); + _state = _state.copyWith(isLoadingMore: false); + notifyListeners(); } } @@ -197,6 +219,7 @@ class NotificationBloc extends ChangeNotifier { try { final updated = await _repository.markRead( notificationId: notificationId, + locale: locale, ); final targetIndex = _state.items.indexWhere( (item) => item.id == updated.id, diff --git a/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart b/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart index 9b38c74..c91ada8 100644 --- a/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart +++ b/apps/lib/features/notifications/presentation/screens/notification_center_screen.dart @@ -2,7 +2,9 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/app_loading_indicator.dart'; import '../../../../shared/widgets/notification/notification_detail_bottom_sheet.dart'; import '../../data/models/notification_item.dart'; import '../../data/models/notification_payload.dart'; @@ -31,54 +33,93 @@ class NotificationCenterScreen extends StatefulWidget { } class _NotificationCenterScreenState extends State { - late NotificationBloc _bloc; + NotificationBloc? _bloc; + late final ScrollController _scrollController; + + String get _currentLocale { + final locale = Localizations.localeOf(context); + if (locale.scriptCode == 'Hant') return 'zh_Hant'; + return locale.languageCode; + } @override void initState() { super.initState(); - _bloc = NotificationBloc(repository: widget.repository); - _bloc.handleEvent(LoadNotifications()); - _bloc.addListener(_onStateChanged); + _scrollController = ScrollController()..addListener(_onScroll); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_bloc == null) { + _bloc = NotificationBloc( + repository: widget.repository, + locale: _currentLocale, + ); + _bloc!.handleEvent(LoadNotifications()); + _bloc!.addListener(_onStateChanged); + } } void _onStateChanged() { setState(() {}); } + void _onScroll() { + if (!_scrollController.hasClients || _bloc == null) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 240) { + unawaited(_bloc!.handleEvent(LoadMoreNotifications())); + } + } + @override void dispose() { - _bloc.removeListener(_onStateChanged); - _bloc.dispose(); + _bloc?.removeListener(_onStateChanged); + _bloc?.dispose(); + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; - final state = _bloc.state; + final l10n = AppLocalizations.of(context)!; + final state = _bloc!.state; return Scaffold( + backgroundColor: colors.surfaceContainerLow, appBar: AppBar( - title: const Text('通知'), + title: Text(l10n.notifyCenterTitle), centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, actions: [ if (state.items.any((item) => !item.isRead)) TextButton( onPressed: _onMarkAllRead, - child: Text('全部已读', style: TextStyle(color: colors.primary)), + child: Text( + l10n.notifyMarkAllRead, + style: TextStyle(color: colors.primary), + ), ), ], ), body: RefreshIndicator( - onRefresh: () => _bloc.handleEvent(RefreshNotifications()), - child: _buildBody(state, colors), + onRefresh: () => _bloc!.handleEvent(RefreshNotifications()), + child: _buildBody(state, colors, l10n), ), ); } - Widget _buildBody(NotificationState state, ColorScheme colors) { + Widget _buildBody( + NotificationState state, + ColorScheme colors, + AppLocalizations l10n, + ) { if (state.status == NotificationStatus.loading && state.items.isEmpty) { - return const Center(child: CircularProgressIndicator()); + return const Center(child: AppLoadingIndicator()); } if (state.status == NotificationStatus.error && state.items.isEmpty) { @@ -88,11 +129,14 @@ class _NotificationCenterScreenState extends State { children: [ Icon(Icons.error_outline, size: 48, color: colors.error), const SizedBox(height: AppSpacing.md), - Text('加载失败', style: TextStyle(color: colors.onSurfaceVariant)), + Text( + l10n.notifyLoadFailed, + style: TextStyle(color: colors.onSurfaceVariant), + ), const SizedBox(height: AppSpacing.sm), FilledButton( - onPressed: () => _bloc.handleEvent(LoadNotifications()), - child: const Text('重试'), + onPressed: () => _bloc!.handleEvent(LoadNotifications()), + child: Text(l10n.notifyRetry), ), ], ), @@ -115,7 +159,7 @@ class _NotificationCenterScreenState extends State { ), const SizedBox(height: AppSpacing.md), Text( - '暂无通知', + l10n.notifyEmpty, style: TextStyle( color: colors.onSurfaceVariant, fontSize: 16, @@ -130,13 +174,13 @@ class _NotificationCenterScreenState extends State { } return ListView.builder( + controller: _scrollController, itemCount: state.items.length + (state.hasMore ? 1 : 0), itemBuilder: (context, index) { if (index == state.items.length && state.hasMore) { - _bloc.handleEvent(LoadMoreNotifications()); return const Padding( padding: EdgeInsets.all(AppSpacing.lg), - child: Center(child: CircularProgressIndicator()), + child: Center(child: AppLoadingIndicator()), ); } final item = state.items[index]; @@ -154,11 +198,13 @@ class _NotificationCenterScreenState extends State { ) async { final wasUnread = !item.isRead; if (!item.isRead) { - await _bloc.handleEvent(MarkNotificationRead(notificationId: item.id)); - final updatedIndex = _bloc.state.items.indexWhere((n) => n.id == item.id); + await _bloc!.handleEvent(MarkNotificationRead(notificationId: item.id)); + final updatedIndex = _bloc!.state.items.indexWhere( + (n) => n.id == item.id, + ); if (wasUnread && updatedIndex >= 0 && - _bloc.state.items[updatedIndex].isRead) { + _bloc!.state.items[updatedIndex].isRead) { await widget.onUnreadCountChanged?.call(); } } @@ -188,9 +234,9 @@ class _NotificationCenterScreenState extends State { } Future _markAllRead() async { - final unreadBefore = _bloc.state.items.any((item) => !item.isRead); - await _bloc.handleEvent(MarkAllNotificationsRead()); - final unreadAfter = _bloc.state.items.any((item) => !item.isRead); + final unreadBefore = _bloc!.state.items.any((item) => !item.isRead); + await _bloc!.handleEvent(MarkAllNotificationsRead()); + final unreadAfter = _bloc!.state.items.any((item) => !item.isRead); if (unreadBefore && !unreadAfter) { await widget.onUnreadCountChanged?.call(); } diff --git a/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart b/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart index 978ddcf..dd21a26 100644 --- a/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart +++ b/apps/lib/features/notifications/presentation/widgets/notification_list_item.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/utils/time_format.dart'; import '../../data/models/notification_item.dart'; class NotificationListItem extends StatelessWidget { @@ -18,90 +19,76 @@ class NotificationListItem extends StatelessWidget { final colors = Theme.of(context).colorScheme; final textTheme = Theme.of(context).textTheme; - return IntrinsicHeight( - child: InkWell( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: AppSpacing.lg, - vertical: AppSpacing.md, - ), - decoration: BoxDecoration( - color: item.isRead - ? colors.surface - : colors.surfaceContainerHighest, - border: Border( - bottom: BorderSide( - color: colors.outlineVariant.withValues(alpha: 0.3), - width: 0.5, - ), + return InkWell( + onTap: onTap, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.lg, + vertical: AppSpacing.md, + ), + decoration: BoxDecoration( + color: item.isRead ? colors.surface : colors.surfaceContainerHighest, + border: Border( + bottom: BorderSide( + color: colors.outlineVariant.withValues(alpha: 0.3), + width: 0.5, ), ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (!item.isRead) - Container( - margin: const EdgeInsets.only( - top: AppSpacing.sm, - right: AppSpacing.sm, - ), - width: 8, - height: 8, - decoration: BoxDecoration( - color: colors.primary, - shape: BoxShape.circle, - ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!item.isRead) + Container( + margin: const EdgeInsets.only( + top: AppSpacing.sm, + right: AppSpacing.sm, ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - item.title, - style: textTheme.bodyMedium?.copyWith( - fontWeight: item.isRead - ? FontWeight.normal - : FontWeight.w600, - color: colors.onSurface, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: AppSpacing.xs), - Text( - item.body, - style: textTheme.bodySmall?.copyWith( - color: colors.onSurfaceVariant, - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: AppSpacing.xs), - Text( - _formatTime(item.createdAt), - style: textTheme.labelSmall?.copyWith( - color: colors.outline, - ), - ), - ], + width: 8, + height: 8, + decoration: BoxDecoration( + color: colors.primary, + shape: BoxShape.circle, ), ), - ], - ), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + item.title, + style: textTheme.bodyMedium?.copyWith( + fontWeight: item.isRead + ? FontWeight.normal + : FontWeight.w600, + color: colors.onSurface, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.xs), + Text( + item.body, + style: textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: AppSpacing.xs), + Text( + formatRelativeTime(context, item.createdAt), + style: textTheme.labelSmall?.copyWith( + color: colors.outline, + ), + ), + ], + ), + ), + ], ), ), ); } - - String _formatTime(DateTime dt) { - final now = DateTime.now(); - final diff = now.difference(dt); - if (diff.inMinutes < 1) return '刚刚'; - if (diff.inHours < 1) return '${diff.inMinutes}分钟前'; - if (diff.inDays < 1) return '${diff.inHours}小时前'; - if (diff.inDays < 30) return '${diff.inDays}天前'; - return '${dt.month}/${dt.day}'; - } } diff --git a/apps/lib/features/payments/data/apis/apple_payment_api.dart b/apps/lib/features/payments/data/apis/apple_payment_api.dart new file mode 100644 index 0000000..673e4af --- /dev/null +++ b/apps/lib/features/payments/data/apis/apple_payment_api.dart @@ -0,0 +1,18 @@ +import '../../../../data/network/api_client.dart'; +import '../models/apple_purchase_models.dart'; + +class ApplePaymentApi { + const ApplePaymentApi({required ApiClient apiClient}) : _apiClient = apiClient; + + final ApiClient _apiClient; + + Future verifyTransaction( + VerifyTransactionRequest request, + ) async { + final json = await _apiClient.postJson( + '/api/v1/payments/apple/transactions/verify', + data: request.toJson(), + ); + return VerifyTransactionResponse.fromJson(json); + } +} diff --git a/apps/lib/features/payments/data/models/apple_purchase_models.dart b/apps/lib/features/payments/data/models/apple_purchase_models.dart new file mode 100644 index 0000000..71df8a8 --- /dev/null +++ b/apps/lib/features/payments/data/models/apple_purchase_models.dart @@ -0,0 +1,56 @@ +class VerifyTransactionRequest { + const VerifyTransactionRequest({ + required this.productCode, + required this.appStoreProductId, + required this.transactionId, + required this.signedTransactionInfo, + this.appAccountToken, + }); + + final String productCode; + final String appStoreProductId; + final String transactionId; + final String signedTransactionInfo; + final String? appAccountToken; + + Map toJson() => { + 'productCode': productCode, + 'appStoreProductId': appStoreProductId, + 'transactionId': transactionId, + 'signedTransactionInfo': signedTransactionInfo, + if (appAccountToken != null) 'appAccountToken': appAccountToken, + }; +} + +enum VerifyTransactionStatus { granted, alreadyGranted } + +class VerifyTransactionResponse { + const VerifyTransactionResponse({ + required this.status, + required this.productCode, + required this.transactionId, + required this.creditsAdded, + required this.newBalance, + required this.ledgerEventId, + }); + + final VerifyTransactionStatus status; + final String productCode; + final String transactionId; + final int creditsAdded; + final int newBalance; + final String ledgerEventId; + + factory VerifyTransactionResponse.fromJson(Map json) { + return VerifyTransactionResponse( + status: json['status'] == 'already_granted' + ? VerifyTransactionStatus.alreadyGranted + : VerifyTransactionStatus.granted, + productCode: json['productCode'] as String, + transactionId: json['transactionId'] as String, + creditsAdded: json['creditsAdded'] as int, + newBalance: json['newBalance'] as int, + ledgerEventId: json['ledgerEventId'] as String, + ); + } +} diff --git a/apps/lib/features/payments/data/services/apple_iap_service.dart b/apps/lib/features/payments/data/services/apple_iap_service.dart new file mode 100644 index 0000000..c938134 --- /dev/null +++ b/apps/lib/features/payments/data/services/apple_iap_service.dart @@ -0,0 +1,289 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; + +import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem.dart'; +import '../../../../data/network/api_client.dart'; +import '../../../points/data/models/package_info.dart'; +import '../apis/apple_payment_api.dart'; +import '../models/apple_purchase_models.dart'; + +enum PurchaseFlowState { idle, purchasing, verifying, success, failed } + +class AppleIapService with ChangeNotifier { + AppleIapService({required ApiClient apiClient, required String userId}) + : _paymentApi = ApplePaymentApi(apiClient: apiClient), + _inAppPurchase = InAppPurchase.instance, + _appAccountToken = _hashUserId(userId); + + final ApplePaymentApi _paymentApi; + final InAppPurchase _inAppPurchase; + final String? _appAccountToken; + final Logger _logger = getLogger('features.payments.apple_iap_service'); + + static String? _hashUserId(String userId) { + if (userId.isEmpty) return null; + final bytes = utf8.encode(userId); + final digest = md5.convert(bytes); + return digest.toString(); + } + + StreamSubscription>? _subscription; + Map _storeKitProducts = {}; + PurchaseFlowState _state = PurchaseFlowState.idle; + String? _lastError; + ApiProblem? _lastApiProblem; + + PurchaseFlowState get state => _state; + String? get lastError => _lastError; + ApiProblem? get lastApiProblem => _lastApiProblem; + + void init() { + final Stream> purchaseUpdated; + purchaseUpdated = _inAppPurchase.purchaseStream; + _subscription = purchaseUpdated.listen(_onPurchaseUpdated); + } + + Future loadStoreKitProducts(List packages) async { + final ids = packages + .map((p) => p.appStoreProductId) + .where((id) => id.isNotEmpty) + .toSet(); + + if (ids.isEmpty) return; + + final response = await _inAppPurchase.queryProductDetails(ids); + if (response.notFoundIDs.isNotEmpty) { + _logger.warning( + message: 'Some StoreKit products not found', + extra: {'notFound': response.notFoundIDs.join(', ')}, + ); + } + + final products = {}; + for (final detail in response.productDetails) { + products[detail.id] = detail; + } + _storeKitProducts = products; + } + + ProductDetails? getStoreKitProduct(String appStoreProductId) { + return _storeKitProducts[appStoreProductId]; + } + + Future purchase(PackageInfo package) async { + if (_state == PurchaseFlowState.purchasing || + _state == PurchaseFlowState.verifying) { + return false; + } + + final product = _storeKitProducts[package.appStoreProductId]; + if (product == null) { + _logger.warning( + message: 'StoreKit product not found for purchase', + extra: {'productId': package.appStoreProductId}, + ); + _setError('Product not available'); + return false; + } + + _setState(PurchaseFlowState.purchasing); + + final purchaseParam = PurchaseParam( + productDetails: product, + applicationUserName: _appAccountToken, + ); + final bought = await _inAppPurchase.buyConsumable( + purchaseParam: purchaseParam, + ); + + if (!bought) { + _setState(PurchaseFlowState.idle); + return false; + } + + return true; + } + + void _onPurchaseUpdated(List purchases) { + for (final purchase in purchases) { + _handlePurchase(purchase); + } + } + + Future _handlePurchase(PurchaseDetails purchase) async { + try { + switch (purchase.status) { + case PurchaseStatus.purchased: + await _verifyAndComplete(purchase); + case PurchaseStatus.canceled: + await _completePurchaseSafely(purchase); + _setState(PurchaseFlowState.idle); + case PurchaseStatus.error: + final errorCode = purchase.error?.code; + final isUserCancel = errorCode == '2'; + if (isUserCancel) { + await _completePurchaseSafely(purchase); + _setState(PurchaseFlowState.idle); + } else { + _logger.warning( + message: 'Purchase error', + extra: { + 'errorCode': errorCode, + 'errorMessage': purchase.error?.message, + }, + ); + await _completePurchaseSafely(purchase); + _setError(purchase.error?.message ?? 'Purchase failed'); + } + case PurchaseStatus.pending: + _setState(PurchaseFlowState.purchasing); + case PurchaseStatus.restored: + await _completePurchaseSafely(purchase); + } + } catch (e, stackTrace) { + _logger.error( + message: 'Failed to handle purchase', + error: e, + stackTrace: stackTrace, + extra: { + 'productID': purchase.productID, + 'purchaseID': purchase.purchaseID, + 'status': purchase.status.name, + }, + ); + _setState(PurchaseFlowState.idle); + } + } + + Future _completePurchaseSafely(PurchaseDetails purchase) async { + try { + await _inAppPurchase.completePurchase(purchase); + } catch (e, stackTrace) { + _logger.warning( + message: 'completePurchase failed (likely null purchaseID): $e', + stackTrace: stackTrace, + extra: { + 'productID': purchase.productID, + 'purchaseID': purchase.purchaseID, + }, + ); + } + } + + Future _verifyAndComplete(PurchaseDetails purchase) async { + _setState(PurchaseFlowState.verifying); + + try { + final request = VerifyTransactionRequest( + productCode: _productCodeFromStoreKitId(purchase.productID), + appStoreProductId: purchase.productID, + transactionId: purchase.purchaseID ?? '', + signedTransactionInfo: purchase.verificationData.serverVerificationData, + appAccountToken: _appAccountToken, + ); + + final response = await _paymentApi.verifyTransaction(request); + await _inAppPurchase.completePurchase(purchase); + + _state = PurchaseFlowState.success; + _lastError = null; + _lastApiProblem = null; + _logger.info( + message: 'Purchase verified and completed', + extra: { + 'transactionId': purchase.purchaseID, + 'creditsAdded': response.creditsAdded, + 'status': response.status.name, + }, + ); + notifyListeners(); + } catch (e, stackTrace) { + _logger.error( + message: 'Purchase verification failed', + error: e, + stackTrace: stackTrace, + extra: { + 'transactionId': purchase.purchaseID, + 'productID': purchase.productID, + 'errorType': e.runtimeType.toString(), + 'errorMessage': e.toString(), + }, + ); + + if (_isRetryableError(e)) { + _setState(PurchaseFlowState.idle); + return; + } + + await _inAppPurchase.completePurchase(purchase); + if (e is ApiProblem) { + _lastApiProblem = e; + _lastError = e.detail.isNotEmpty ? e.detail : 'Verification failed'; + } else { + _lastApiProblem = null; + _lastError = _extractErrorMessage(e); + } + _state = PurchaseFlowState.failed; + notifyListeners(); + } + } + + bool _isRetryableError(Object error) { + if (error is ApiProblem) { + return error.status >= 500 || error.status == 0; + } + return true; + } + + String _extractErrorMessage(Object error) { + if (error is ApiProblem) { + return error.detail.isNotEmpty ? error.detail : 'Verification failed'; + } + return 'Verification failed'; + } + + String _productCodeFromStoreKitId(String storeKitId) { + return switch (storeKitId) { + 'com.meeyao.qianwen.new_user_pack' => 'new_user_pack', + 'com.meeyao.qianwen.starter_pack' => 'starter_pack', + 'com.meeyao.qianwen.popular_pack' => 'popular_pack', + 'com.meeyao.qianwen.premium_pack' => 'premium_pack', + _ => storeKitId, + }; + } + + void _setState(PurchaseFlowState state) { + _state = state; + if (state != PurchaseFlowState.failed) { + _lastError = null; + _lastApiProblem = null; + } + notifyListeners(); + } + + void _setError(String message) { + _state = PurchaseFlowState.failed; + _lastError = message; + _lastApiProblem = null; + _logger.warning(message: 'Purchase flow error', extra: {'error': message}); + notifyListeners(); + } + + void resetState() { + _state = PurchaseFlowState.idle; + _lastError = null; + _lastApiProblem = null; + notifyListeners(); + } + + @override + void dispose() { + _subscription?.cancel(); + super.dispose(); + } +} diff --git a/apps/lib/features/points/data/apis/points_api.dart b/apps/lib/features/points/data/apis/points_api.dart index d53b085..621d915 100644 --- a/apps/lib/features/points/data/apis/points_api.dart +++ b/apps/lib/features/points/data/apis/points_api.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; +import '../models/ledger_item.dart'; import '../models/package_info.dart'; class PointsApi { @@ -11,4 +12,19 @@ class PointsApi { final response = await _dio.get('/api/v1/points/packages'); return PackagesResult.fromJson(response.data as Map); } + + Future getLedger({ + int limit = 20, + String? cursor, + }) async { + final query = {'limit': limit}; + if (cursor != null) { + query['cursor'] = cursor; + } + final response = await _dio.get( + '/api/v1/points/ledger', + queryParameters: query, + ); + return LedgerListResult.fromJson(response.data as Map); + } } diff --git a/apps/lib/features/points/data/models/ledger_item.dart b/apps/lib/features/points/data/models/ledger_item.dart new file mode 100644 index 0000000..0f50d20 --- /dev/null +++ b/apps/lib/features/points/data/models/ledger_item.dart @@ -0,0 +1,50 @@ +class LedgerItem { + const LedgerItem({ + required this.id, + required this.direction, + required this.amount, + required this.balanceAfter, + required this.changeType, + required this.createdAt, + }); + + final String id; + final int direction; + final int amount; + final int balanceAfter; + final String changeType; + final String createdAt; + + factory LedgerItem.fromJson(Map json) { + return LedgerItem( + id: json['id'] as String, + direction: json['direction'] as int, + amount: json['amount'] as int, + balanceAfter: json['balanceAfter'] as int, + changeType: json['changeType'] as String, + createdAt: json['createdAt'] as String, + ); + } +} + +class LedgerListResult { + const LedgerListResult({ + required this.items, + this.nextCursor, + required this.hasMore, + }); + + final List items; + final String? nextCursor; + final bool hasMore; + + factory LedgerListResult.fromJson(Map json) { + return LedgerListResult( + items: (json['items'] as List) + .map((e) => LedgerItem.fromJson(e as Map)) + .toList(), + nextCursor: json['nextCursor'] as String?, + hasMore: json['hasMore'] as bool, + ); + } +} diff --git a/apps/lib/features/points/data/models/package_info.dart b/apps/lib/features/points/data/models/package_info.dart index ef540d6..aea1fae 100644 --- a/apps/lib/features/points/data/models/package_info.dart +++ b/apps/lib/features/points/data/models/package_info.dart @@ -1,12 +1,12 @@ -enum ProductCode { newUserPack, basicPack, popularPack, premiumPack } +enum ProductCode { newUserPack, starterPack, popularPack, premiumPack } enum PackageType { starter, regular } class PackageInfo { const PackageInfo({ required this.productCode, + required this.appStoreProductId, required this.type, - required this.price, required this.credits, required this.isStarter, required this.starterEligible, @@ -14,8 +14,8 @@ class PackageInfo { }); final ProductCode productCode; + final String appStoreProductId; final PackageType type; - final double price; final int credits; final bool isStarter; final bool starterEligible; @@ -24,10 +24,10 @@ class PackageInfo { factory PackageInfo.fromJson(Map json) { return PackageInfo( productCode: _parseProductCode(json['productCode'] as String), + appStoreProductId: json['appStoreProductId'] as String, type: json['type'] == 'starter' ? PackageType.starter : PackageType.regular, - price: (json['price'] as num).toDouble(), credits: json['credits'] as int, isStarter: json['isStarter'] as bool, starterEligible: json['starterEligible'] as bool, @@ -38,31 +38,23 @@ class PackageInfo { static ProductCode _parseProductCode(String code) { return switch (code) { 'new_user_pack' => ProductCode.newUserPack, - 'basic_pack' => ProductCode.basicPack, + 'starter_pack' => ProductCode.starterPack, 'popular_pack' => ProductCode.popularPack, 'premium_pack' => ProductCode.premiumPack, _ => throw ArgumentError('Unknown product code: $code'), }; } - - String get priceDisplay => '\$${price.toStringAsFixed(2)}'; } class PackagesResult { const PackagesResult({ - required this.region, - required this.currency, required this.packages, }); - final String region; - final String currency; final List packages; factory PackagesResult.fromJson(Map json) { return PackagesResult( - region: json['region'] as String, - currency: json['currency'] as String, packages: (json['packages'] as List) .map((e) => PackageInfo.fromJson(e as Map)) .toList(), diff --git a/apps/lib/features/points/presentation/screens/points_ledger_screen.dart b/apps/lib/features/points/presentation/screens/points_ledger_screen.dart new file mode 100644 index 0000000..6ffeed6 --- /dev/null +++ b/apps/lib/features/points/presentation/screens/points_ledger_screen.dart @@ -0,0 +1,346 @@ +import 'package:flutter/material.dart'; + +import '../../../../app/di/injection.dart'; +import '../../../../core/auth/session_store.dart'; +import '../../../../core/logging/logger.dart'; +import '../../../../data/network/api_client.dart'; +import '../../../../data/storage/local_kv_store.dart'; +import '../../../../l10n/app_localizations.dart'; +import '../../../../shared/theme/app_color_palette.dart'; +import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/utils/time_format.dart'; +import '../../../../shared/widgets/app_loading_indicator.dart'; +import '../../data/apis/points_api.dart'; +import '../../data/models/ledger_item.dart'; + +class PointsLedgerScreen extends StatefulWidget { + const PointsLedgerScreen({super.key}); + + @override + State createState() => _PointsLedgerScreenState(); +} + +class _PointsLedgerScreenState extends State { + final Logger _logger = getLogger('features.points.ledger_screen'); + late final ScrollController _scrollController; + late final PointsApi _api; + final List _items = []; + String? _nextCursor; + bool _hasMore = false; + bool _isLoading = false; + bool _isLoadingMore = false; + Object? _error; + + @override + void initState() { + super.initState(); + _scrollController = ScrollController()..addListener(_onScroll); + final sessionStore = SessionStore(LocalKvStore()); + final apiClient = ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: sessionStore.getToken, + ); + _api = PointsApi(apiClient.rawDio); + _loadInitial(); + } + + @override + void dispose() { + _scrollController.removeListener(_onScroll); + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final position = _scrollController.position; + if (position.pixels >= position.maxScrollExtent - 240) { + _loadMore(); + } + } + + Future _loadInitial() async { + if (_isLoading) return; + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final result = await _api.getLedger(); + + if (mounted) { + setState(() { + _items.clear(); + _items.addAll(result.items); + _nextCursor = result.nextCursor; + _hasMore = result.hasMore; + _isLoading = false; + }); + } + } catch (e, stackTrace) { + _logger.error( + message: 'Failed to load ledger', + error: e, + stackTrace: stackTrace, + ); + if (mounted) { + setState(() { + _error = e; + _isLoading = false; + }); + } + } + } + + Future _loadMore() async { + if (_isLoadingMore || !_hasMore || _nextCursor == null) return; + + setState(() { + _isLoadingMore = true; + }); + + try { + final result = await _api.getLedger(cursor: _nextCursor); + + if (mounted) { + setState(() { + _items.addAll(result.items); + _nextCursor = result.nextCursor; + _hasMore = result.hasMore; + _isLoadingMore = false; + }); + } + } catch (e, stackTrace) { + _logger.error( + message: 'Failed to load more ledger items', + error: e, + stackTrace: stackTrace, + ); + if (mounted) { + setState(() { + _isLoadingMore = false; + }); + } + } + } + + String _getDisplayText(AppLocalizations l10n, String changeType) { + return switch (changeType) { + 'register' => l10n.pointsLedgerTypeRegister, + 'purchase' => l10n.pointsLedgerTypePurchase, + 'consume' => l10n.pointsLedgerTypeConsume, + 'adjust' => l10n.pointsLedgerTypeAdjust, + 'refund' => l10n.pointsLedgerTypeRefund, + _ => changeType, + }; + } + + IconData _getIcon(String changeType) { + return switch (changeType) { + 'register' => Icons.card_giftcard_rounded, + 'purchase' => Icons.shopping_bag_rounded, + 'consume' => Icons.chat_bubble_outline_rounded, + 'adjust' => Icons.tune_rounded, + 'refund' => Icons.replay_rounded, + _ => Icons.swap_vert_rounded, + }; + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final palette = Theme.of(context).extension()!; + + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + title: Text(l10n.pointsLedgerTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: _buildBody(l10n, colors, palette), + ); + } + + Widget _buildBody( + AppLocalizations l10n, + ColorScheme colors, + AppColorPalette palette, + ) { + if (_isLoading) { + return const Center(child: AppLoadingIndicator()); + } + + if (_error != null && _items.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.error_outline, size: 48, color: colors.error), + const SizedBox(height: AppSpacing.md), + Text( + l10n.errorRequestGeneric, + style: Theme.of(context).textTheme.bodyMedium, + ), + const SizedBox(height: AppSpacing.lg), + FilledButton(onPressed: _loadInitial, child: Text(l10n.retry)), + ], + ), + ); + } + + if (_items.isEmpty) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Icons.receipt_long_outlined, + size: 64, + color: colors.onSurfaceVariant.withValues(alpha: 0.5), + ), + const SizedBox(height: AppSpacing.md), + Text( + l10n.pointsLedgerEmpty, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ); + } + + return ListView.builder( + controller: _scrollController, + padding: const EdgeInsets.all(AppSpacing.lg), + itemCount: _items.length + (_hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index == _items.length) { + if (_isLoadingMore) { + return const Padding( + padding: EdgeInsets.all(AppSpacing.lg), + child: Center(child: AppLoadingIndicator()), + ); + } + return const SizedBox.shrink(); + } + + final item = _items[index]; + final isIncome = item.direction == 1; + final amountText = isIncome ? '+${item.amount}' : '-${item.amount}'; + + return _LedgerItemCard( + icon: _getIcon(item.changeType), + displayText: _getDisplayText(l10n, item.changeType), + amountText: amountText, + isIncome: isIncome, + balanceAfter: item.balanceAfter, + balanceText: l10n.pointsLedgerBalance(item.balanceAfter), + dateTime: formatCompactLocalDateTime(item.createdAt), + colors: colors, + palette: palette, + ); + }, + ); + } +} + +class _LedgerItemCard extends StatelessWidget { + const _LedgerItemCard({ + required this.icon, + required this.displayText, + required this.amountText, + required this.isIncome, + required this.balanceAfter, + required this.balanceText, + required this.dateTime, + required this.colors, + required this.palette, + }); + + final IconData icon; + final String displayText; + final String amountText; + final bool isIncome; + final int balanceAfter; + final String balanceText; + final String dateTime; + final ColorScheme colors; + final AppColorPalette palette; + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: AppSpacing.sm), + padding: const EdgeInsets.all(AppSpacing.md), + decoration: BoxDecoration( + color: colors.surface, + borderRadius: BorderRadius.circular(AppRadius.lg), + border: Border.all(color: colors.outlineVariant), + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: isIncome + ? palette.incomeGreenBg.withValues(alpha: 0.15) + : colors.errorContainer.withValues(alpha: 0.5), + borderRadius: BorderRadius.circular(AppRadius.md), + ), + child: Icon( + icon, + size: 22, + color: isIncome ? palette.incomeGreenText : colors.error, + ), + ), + const SizedBox(width: AppSpacing.md), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + displayText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600), + ), + const SizedBox(height: 2), + Text( + dateTime, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.onSurfaceVariant, + ), + ), + ], + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + amountText, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w700, + color: isIncome ? palette.incomeGreenText : colors.error, + ), + ), + const SizedBox(height: 2), + Text( + balanceText, + style: Theme.of( + context, + ).textTheme.bodySmall?.copyWith(color: colors.onSurfaceVariant), + ), + ], + ), + ], + ), + ); + } +} diff --git a/apps/lib/features/settings/data/apis/profile_api.dart b/apps/lib/features/settings/data/apis/profile_api.dart index 44687ec..0ad3d94 100644 --- a/apps/lib/features/settings/data/apis/profile_api.dart +++ b/apps/lib/features/settings/data/apis/profile_api.dart @@ -41,10 +41,8 @@ class ProfileApi { 'settings': { 'version': settings.version, 'preferences': { - 'interface_language': settings.preferences.interfaceLanguage, - 'ai_language': settings.preferences.aiLanguage, + 'language': settings.preferences.language, 'timezone': settings.preferences.timezone, - 'country': settings.preferences.country, }, 'privacy': { 'can_sell': settings.privacy.canSell, @@ -109,12 +107,9 @@ class ProfileApi { : null; final preferences = preferencesRaw is Map ? PreferenceSettings( - interfaceLanguage: - (preferencesRaw['interface_language'] as String?) ?? 'zh-CN', - aiLanguage: (preferencesRaw['ai_language'] as String?) ?? 'zh-CN', + language: (preferencesRaw['language'] as String?) ?? 'zh-CN', timezone: (preferencesRaw['timezone'] as String?) ?? 'Asia/Shanghai', - country: (preferencesRaw['country'] as String?) ?? 'US', ) : const PreferenceSettings(); diff --git a/apps/lib/features/settings/data/models/profile_settings.dart b/apps/lib/features/settings/data/models/profile_settings.dart index 3edf2ab..f643c53 100644 --- a/apps/lib/features/settings/data/models/profile_settings.dart +++ b/apps/lib/features/settings/data/models/profile_settings.dart @@ -13,28 +13,20 @@ String displayLanguageLabel(AppLocalizations l10n, String languageTag) { class PreferenceSettings { const PreferenceSettings({ - this.interfaceLanguage = 'zh-CN', - this.aiLanguage = 'zh-CN', + this.language = 'zh-CN', this.timezone = 'Asia/Shanghai', - this.country = 'US', }); - final String interfaceLanguage; - final String aiLanguage; + final String language; final String timezone; - final String country; PreferenceSettings copyWith({ - String? interfaceLanguage, - String? aiLanguage, + String? language, String? timezone, - String? country, }) { return PreferenceSettings( - interfaceLanguage: interfaceLanguage ?? this.interfaceLanguage, - aiLanguage: aiLanguage ?? this.aiLanguage, + language: language ?? this.language, timezone: timezone ?? this.timezone, - country: country ?? this.country, ); } } @@ -151,7 +143,7 @@ class ProfileSettingsV1 { factory ProfileSettingsV1.defaultsForLocale(Locale locale) { final tag = languageTagFromLocale(locale); return ProfileSettingsV1( - preferences: PreferenceSettings(interfaceLanguage: tag, aiLanguage: tag), + preferences: PreferenceSettings(language: tag), ); } } diff --git a/apps/lib/features/settings/presentation/screens/account_delete_screen.dart b/apps/lib/features/settings/presentation/screens/account_data_screen.dart similarity index 87% rename from apps/lib/features/settings/presentation/screens/account_delete_screen.dart rename to apps/lib/features/settings/presentation/screens/account_data_screen.dart index 0f9df41..92e0d52 100644 --- a/apps/lib/features/settings/presentation/screens/account_delete_screen.dart +++ b/apps/lib/features/settings/presentation/screens/account_data_screen.dart @@ -9,19 +9,20 @@ import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/design_tokens.dart'; import '../../../../shared/widgets/toast/toast.dart'; import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../../points/presentation/screens/points_ledger_screen.dart'; import '../widgets/settings_section_widgets.dart'; -class AccountDeleteScreen extends StatefulWidget { - const AccountDeleteScreen({super.key, required this.onDeleteAccount}); +class AccountDataScreen extends StatefulWidget { + const AccountDataScreen({super.key, required this.onDeleteAccount}); final Future Function() onDeleteAccount; @override - State createState() => _AccountDeleteScreenState(); + State createState() => _AccountDataScreenState(); } -class _AccountDeleteScreenState extends State { - final Logger _logger = getLogger('features.settings.account_delete'); +class _AccountDataScreenState extends State { + final Logger _logger = getLogger('features.settings.account_data'); bool _isDeleting = false; @override @@ -42,6 +43,13 @@ class _AccountDeleteScreenState extends State { children: [ SettingsGroupCard( children: [ + SettingsMenuTile( + icon: Icons.receipt_long_rounded, + title: l10n.pointsLedgerTitle, + tint: colors.primary, + background: colors.surfaceContainerHighest, + onTap: _openPointsLedger, + ), SettingsMenuTile( icon: Icons.delete_outline_rounded, title: l10n.settingsDeleteAccountTitle, @@ -58,6 +66,14 @@ class _AccountDeleteScreenState extends State { ); } + Future _openPointsLedger() async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => const PointsLedgerScreen(), + ), + ); + } + Future _confirmDelete() async { final confirmed = await showDialog( context: context, @@ -194,8 +210,8 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { child: Text( l10n.settingsDeleteAccountDialogTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + fontWeight: FontWeight.w700, + ), ), ), ], @@ -204,9 +220,9 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { Text( l10n.settingsDeleteAccountWarningBody, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.onSurfaceVariant, - height: 1.45, - ), + color: colors.onSurfaceVariant, + height: 1.45, + ), ), const SizedBox(height: AppSpacing.sm), Container( @@ -220,10 +236,10 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { child: Text( l10n.settingsDeleteAccountReRegisterNotice, style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: colors.onErrorContainer, - fontWeight: FontWeight.w700, - height: 1.35, - ), + color: colors.onErrorContainer, + fontWeight: FontWeight.w700, + height: 1.35, + ), ), ), const SizedBox(height: AppSpacing.md), @@ -232,9 +248,9 @@ class _DeleteConfirmDialogState extends State<_DeleteConfirmDialog> { ? l10n.settingsDeleteAccountWaitAction(_secondsLeft) : l10n.settingsDeleteAccountDialogBody, style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: colors.error, - fontWeight: FontWeight.w600, - ), + color: colors.error, + fontWeight: FontWeight.w600, + ), ), const SizedBox(height: AppSpacing.lg), Row( diff --git a/apps/lib/features/settings/presentation/screens/coin_center_screen.dart b/apps/lib/features/settings/presentation/screens/coin_center_screen.dart index e2020d2..04ff07a 100644 --- a/apps/lib/features/settings/presentation/screens/coin_center_screen.dart +++ b/apps/lib/features/settings/presentation/screens/coin_center_screen.dart @@ -1,21 +1,35 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; +import 'package:in_app_purchase/in_app_purchase.dart'; import '../../../../app/di/injection.dart'; import '../../../../core/auth/session_store.dart'; import '../../../../core/logging/logger.dart'; +import '../../../../core/network/api_problem_mapper.dart'; import '../../../../data/network/api_client.dart'; import '../../../../data/storage/local_kv_store.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; +import '../../../../shared/widgets/toast/toast.dart'; +import '../../../../shared/widgets/toast/toast_type.dart'; +import '../../../payments/data/services/apple_iap_service.dart'; import '../../../points/data/apis/points_api.dart'; import '../../../points/data/models/package_info.dart'; import '../widgets/settings_section_widgets.dart'; class CoinCenterScreen extends StatefulWidget { - const CoinCenterScreen({super.key, required this.balance}); + const CoinCenterScreen({ + super.key, + required this.balance, + required this.userId, + required this.onBalanceChanged, + }); final int balance; + final String userId; + final void Function(int newBalance) onBalanceChanged; @override State createState() => _CoinCenterScreenState(); @@ -25,6 +39,7 @@ class _CoinCenterScreenState extends State { final Logger _logger = getLogger('features.settings.coin_center_screen'); List? _packages; bool _isLoading = true; + AppleIapService? _iapService; @override void initState() { @@ -39,12 +54,26 @@ class _CoinCenterScreenState extends State { baseUrl: appDependencies.backendUrl, tokenProvider: sessionStore.getToken, ); + final api = PointsApi(apiClient.rawDio); final result = await api.getPackages(); + + final service = AppleIapService( + apiClient: apiClient, + userId: widget.userId, + ); + service.init(); + service.addListener(_onPurchaseStateChanged); + + if (await InAppPurchase.instance.isAvailable()) { + await service.loadStoreKitProducts(result.packages); + } + if (mounted) { setState(() { _packages = result.packages; _isLoading = false; + _iapService = service; }); } } catch (e, stackTrace) { @@ -61,6 +90,87 @@ class _CoinCenterScreenState extends State { } } + Future _reloadPackages() async { + try { + final sessionStore = SessionStore(LocalKvStore()); + final apiClient = ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: sessionStore.getToken, + ); + + final api = PointsApi(apiClient.rawDio); + final result = await api.getPackages(); + + await _iapService?.loadStoreKitProducts(result.packages); + + if (mounted) { + setState(() { + _packages = result.packages; + }); + } + } catch (e) { + _logger.warning( + message: 'Failed to reload packages after purchase', + extra: {'error': e.toString()}, + ); + } + } + + void _onPurchaseStateChanged() { + final service = _iapService; + if (service == null || !mounted) return; + + final l10n = AppLocalizations.of(context)!; + + switch (service.state) { + case PurchaseFlowState.success: + Toast.show(context, l10n.paymentSuccess, type: ToastType.success); + _refreshBalance(); + _reloadPackages(); + service.resetState(); + break; + case PurchaseFlowState.failed: + final apiProblem = service.lastApiProblem; + final error = apiProblem != null + ? mapApiProblemToMessage(apiProblem, l10n) + : (service.lastError ?? l10n.paymentVerifyFailed); + Toast.show(context, error, type: ToastType.error); + service.resetState(); + break; + case PurchaseFlowState.purchasing: + case PurchaseFlowState.verifying: + case PurchaseFlowState.idle: + break; + } + + setState(() {}); + } + + Future _refreshBalance() async { + try { + final sessionStore = SessionStore(LocalKvStore()); + final apiClient = ApiClient( + baseUrl: appDependencies.backendUrl, + tokenProvider: sessionStore.getToken, + ); + final response = await apiClient.getJson('/api/v1/points/balance'); + final newBalance = response['availableBalance'] as int; + widget.onBalanceChanged(newBalance); + } catch (e, stackTrace) { + _logger.warning( + message: 'Failed to refresh balance after purchase: $e', + extra: {'stackTrace': stackTrace.toString()}, + ); + } + } + + @override + void dispose() { + _iapService?.removeListener(_onPurchaseStateChanged); + _iapService?.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -120,13 +230,13 @@ class _CoinCenterScreenState extends State { ), const SizedBox(height: AppSpacing.xl), SectionLabel(text: l10n.settingsCoinRechargeSection), - ..._buildPackageCards(l10n), + ..._buildPackageCards(l10n, colors), ], ), ); } - List _buildPackageCards(AppLocalizations l10n) { + List _buildPackageCards(AppLocalizations l10n, ColorScheme colors) { if (_isLoading) { return [ const Padding( @@ -140,23 +250,70 @@ class _CoinCenterScreenState extends State { return []; } + final isBusy = _iapService?.state == PurchaseFlowState.purchasing || + _iapService?.state == PurchaseFlowState.verifying; + final isPending = _iapService?.state == PurchaseFlowState.purchasing; + return List.generate(_packages!.length, (index) { final pkg = _packages![index]; + final storeKitProduct = _iapService?.getStoreKitProduct(pkg.appStoreProductId); + final isAvailable = storeKitProduct != null; + return Column( children: [ if (index > 0) const SizedBox(height: AppSpacing.md), + if (isPending && !isBusy) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: Text( + l10n.paymentPending, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.primary, + ), + ), + ), CoinPackageCard( title: _getPackageTitle(pkg, l10n), - price: pkg.priceDisplay, + price: _getDisplayPrice(pkg), amount: pkg.credits, badge: _getPackageBadge(pkg, l10n), + onPurchase: () => _handlePurchase(pkg), + isPurchasing: isBusy, + isAvailable: isAvailable, + unavailableMessage: isAvailable ? null : l10n.paymentProductUnavailable, ), ], ); }); } + String _getDisplayPrice(PackageInfo pkg) { + final product = _iapService?.getStoreKitProduct(pkg.appStoreProductId); + if (product != null) { + return product.price; + } + return ''; + } + + Future _handlePurchase(PackageInfo pkg) async { + final l10n = AppLocalizations.of(context)!; + if (_iapService == null) { + Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error); + return; + } + + if (!Platform.isIOS) { + Toast.show(context, l10n.paymentVerifyFailed, type: ToastType.error); + return; + } + + await _iapService!.purchase(pkg); + } + String? _getPackageBadge(PackageInfo pkg, AppLocalizations l10n) { + if (pkg.productCode == ProductCode.newUserPack) { + return l10n.settingsCoinPackNewUserBadge; + } if (pkg.productCode == ProductCode.popularPack) { return l10n.settingsCoinPackPopularBadge; } @@ -166,7 +323,7 @@ class _CoinCenterScreenState extends State { String _getPackageTitle(PackageInfo pkg, AppLocalizations l10n) { return switch (pkg.productCode) { ProductCode.newUserPack => l10n.settingsCoinPackStarter, - ProductCode.basicPack => l10n.settingsCoinPackBasic, + ProductCode.starterPack => l10n.settingsCoinPackBasic, ProductCode.popularPack => l10n.settingsCoinPackPopular, ProductCode.premiumPack => l10n.settingsCoinPackPremium, }; diff --git a/apps/lib/features/settings/presentation/screens/feedback_screen.dart b/apps/lib/features/settings/presentation/screens/feedback_screen.dart index 60eeb50..8e4a572 100644 --- a/apps/lib/features/settings/presentation/screens/feedback_screen.dart +++ b/apps/lib/features/settings/presentation/screens/feedback_screen.dart @@ -50,15 +50,17 @@ class _FeedbackScreenState extends State { final colors = Theme.of(context).colorScheme; final l10n = AppLocalizations.of(context)!; - return Scaffold( - backgroundColor: colors.surfaceContainerLow, - appBar: AppBar( - title: Text(l10n.feedbackTitle), - centerTitle: true, + return GestureDetector( + onTap: () => FocusScope.of(context).unfocus(), + child: Scaffold( backgroundColor: colors.surfaceContainerLow, - surfaceTintColor: colors.surfaceContainerLow, - ), - body: ListView( + appBar: AppBar( + title: Text(l10n.feedbackTitle), + centerTitle: true, + backgroundColor: colors.surfaceContainerLow, + surfaceTintColor: colors.surfaceContainerLow, + ), + body: ListView( padding: const EdgeInsets.fromLTRB( AppSpacing.lg, AppSpacing.md, @@ -154,6 +156,7 @@ class _FeedbackScreenState extends State { ), ), ], + ), ), ); } diff --git a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart index 5cc5fd2..237a5be 100644 --- a/apps/lib/features/settings/presentation/screens/general_settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/general_settings_screen.dart @@ -34,117 +34,88 @@ class _GeneralSettingsScreenState extends State { final l10n = AppLocalizations.of(context)!; final colors = Theme.of(context).colorScheme; - return PopScope( - canPop: false, - onPopInvokedWithResult: (didPop, result) { - if (didPop) { - return; - } - Navigator.of(context).pop(_settings); - }, - child: Scaffold( + return Scaffold( + backgroundColor: colors.surfaceContainerLow, + appBar: AppBar( + leading: IconButton( + onPressed: () => Navigator.of(context).pop(), + icon: const Icon(Icons.arrow_back_ios_new_rounded), + ), + title: Text(l10n.settingsGeneralTitle), + centerTitle: true, backgroundColor: colors.surfaceContainerLow, - appBar: AppBar( - leading: IconButton( - onPressed: () => Navigator.of(context).pop(_settings), - icon: const Icon(Icons.arrow_back_ios_new_rounded), + surfaceTintColor: colors.surfaceContainerLow, + ), + body: ListView( + padding: const EdgeInsets.all(AppSpacing.lg), + children: [ + SectionLabel(text: l10n.settingsSectionGeneral), + SettingsGroupCard( + children: [ + SettingsMenuTile( + icon: Icons.language_rounded, + title: l10n.settingsLanguage, + subtitle: displayLanguageLabel( + l10n, + _settings.preferences.language, + ), + tint: colors.primary, + background: colors.surfaceContainerHighest, + showDivider: false, + onTap: () => _selectLanguage( + _settings.preferences.language, + (lang) => setState(() { + _settings = _settings.copyWith( + preferences: _settings.preferences.copyWith( + language: lang, + ), + ); + }), + ), + ), + ], ), - title: Text(l10n.settingsGeneralTitle), - centerTitle: true, - backgroundColor: colors.surfaceContainerLow, - surfaceTintColor: colors.surfaceContainerLow, - ), - body: ListView( - padding: const EdgeInsets.all(AppSpacing.lg), - children: [ - SectionLabel(text: l10n.settingsSectionGeneral), - SettingsGroupCard( - children: [ - SettingsMenuTile( - icon: Icons.language_rounded, - title: l10n.settingsInterfaceLanguage, - subtitle: displayLanguageLabel( - l10n, - _settings.preferences.interfaceLanguage, - ), - tint: colors.primary, - background: colors.surfaceContainerHighest, - onTap: () => _selectLanguage( - _settings.preferences.interfaceLanguage, - (lang) => setState(() { - _settings = _settings.copyWith( - preferences: _settings.preferences.copyWith( - interfaceLanguage: lang, - ), - ); - }), - ), - ), - SettingsMenuTile( - icon: Icons.smart_toy_rounded, - title: l10n.settingsAiLanguage, - subtitle: displayLanguageLabel( - l10n, - _settings.preferences.aiLanguage, - ), - tint: colors.primary, - background: colors.surfaceContainerHighest, - showDivider: false, - onTap: () => _selectLanguage( - _settings.preferences.aiLanguage, - (lang) => setState(() { - _settings = _settings.copyWith( - preferences: _settings.preferences.copyWith( - aiLanguage: lang, - ), - ); - }), - ), - ), - ], - ), - const SizedBox(height: AppSpacing.lg), - SectionLabel(text: l10n.settingsSectionPrivacy), - SettingsGroupCard( - children: [ - SettingsSwitchTile( - icon: Icons.security_rounded, - title: l10n.settingsDoNotSellTitle, - value: _settings.privacy.canSell, - tint: colors.primary, - background: colors.surfaceContainerHighest, - showDivider: false, - onChanged: (value) => _updatePrivacy(canSell: value), - ), - ], - ), - const SizedBox(height: AppSpacing.lg), - SectionLabel(text: l10n.settingsSectionNotification), - SettingsGroupCard( - children: [ - SettingsSwitchTile( - icon: Icons.notifications_rounded, - title: l10n.settingsNotificationAllow, - value: _settings.notification.allowNotifications, - tint: colors.primary, - background: colors.surfaceContainerHighest, - onChanged: (value) => - _updateNotification(allowNotifications: value), - ), - SettingsSwitchTile( - icon: Icons.vibration_rounded, - title: l10n.settingsNotificationVibration, - value: _settings.notification.allowVibration, - tint: colors.primary, - background: colors.surfaceContainerHighest, - showDivider: false, - onChanged: (value) => - _updateNotification(allowVibration: value), - ), - ], - ), - ], - ), + const SizedBox(height: AppSpacing.lg), + SectionLabel(text: l10n.settingsSectionPrivacy), + SettingsGroupCard( + children: [ + SettingsSwitchTile( + icon: Icons.security_rounded, + title: l10n.settingsDoNotSellTitle, + value: _settings.privacy.canSell, + tint: colors.primary, + background: colors.surfaceContainerHighest, + showDivider: false, + onChanged: (value) => _updatePrivacy(canSell: value), + ), + ], + ), + const SizedBox(height: AppSpacing.lg), + SectionLabel(text: l10n.settingsSectionNotification), + SettingsGroupCard( + children: [ + SettingsSwitchTile( + icon: Icons.notifications_rounded, + title: l10n.settingsNotificationAllow, + value: _settings.notification.allowNotifications, + tint: colors.primary, + background: colors.surfaceContainerHighest, + onChanged: (value) => + _updateNotification(allowNotifications: value), + ), + SettingsSwitchTile( + icon: Icons.vibration_rounded, + title: l10n.settingsNotificationVibration, + value: _settings.notification.allowVibration, + tint: colors.primary, + background: colors.surfaceContainerHighest, + showDivider: false, + onChanged: (value) => + _updateNotification(allowVibration: value), + ), + ], + ), + ], ), ); } diff --git a/apps/lib/features/settings/presentation/screens/invite_screen.dart b/apps/lib/features/settings/presentation/screens/invite_screen.dart index 0ccfbbb..a4c7cd3 100644 --- a/apps/lib/features/settings/presentation/screens/invite_screen.dart +++ b/apps/lib/features/settings/presentation/screens/invite_screen.dart @@ -405,7 +405,7 @@ class _BindCodeSection extends StatelessWidget { SizedBox( width: double.infinity, child: FilledButton( - onPressed: isBinding ? null : onBind, + onPressed: null, style: FilledButton.styleFrom( elevation: 0, padding: const EdgeInsets.symmetric( @@ -415,16 +415,7 @@ class _BindCodeSection extends StatelessWidget { borderRadius: BorderRadius.circular(AppRadius.full), ), ), - child: isBinding - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text(l10n.settingsInviteBindButton), + child: Text(l10n.settingsComingSoon), ), ), ], diff --git a/apps/lib/features/settings/presentation/screens/legal_center_screen.dart b/apps/lib/features/settings/presentation/screens/legal_center_screen.dart deleted file mode 100644 index 9e98011..0000000 --- a/apps/lib/features/settings/presentation/screens/legal_center_screen.dart +++ /dev/null @@ -1,61 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../l10n/app_localizations.dart'; -import '../../../../shared/theme/design_tokens.dart'; -import '../models/legal_document_type.dart'; -import '../utils/legal_document_assets.dart'; -import '../widgets/settings_section_widgets.dart'; -import 'legal_document_screen.dart'; - -class LegalCenterScreen extends StatelessWidget { - const LegalCenterScreen({super.key}); - - @override - Widget build(BuildContext context) { - final l10n = AppLocalizations.of(context)!; - final colors = Theme.of(context).colorScheme; - final locale = Localizations.localeOf(context); - final documents = [ - LegalDocumentType.aboutUs, - LegalDocumentType.privacyPolicy, - LegalDocumentType.termsOfService, - ]; - - return Scaffold( - backgroundColor: colors.surfaceContainerLow, - appBar: AppBar( - title: Text(l10n.settingsLegalCenterTitle), - centerTitle: true, - backgroundColor: colors.surfaceContainerLow, - surfaceTintColor: colors.surfaceContainerLow, - ), - body: ListView( - padding: const EdgeInsets.all(AppSpacing.lg), - children: [ - SectionLabel(text: l10n.settingsSectionAbout), - SettingsGroupCard( - children: [ - for (int i = 0; i < documents.length; i++) - SettingsMenuTile( - icon: legalDocumentIcon(documents[i]), - title: legalDocumentTitle(l10n, documents[i]), - subtitle: legalDocumentSubtitle(l10n, documents[i]), - tint: colors.primary, - background: colors.surfaceContainerHighest, - showDivider: i != documents.length - 1, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => LegalDocumentScreen( - title: legalDocumentTitle(l10n, documents[i]), - assetPath: legalDocumentAssetPath(locale, documents[i]), - ), - ), - ), - ), - ], - ), - ], - ), - ); - } -} diff --git a/apps/lib/features/settings/presentation/screens/settings_screen.dart b/apps/lib/features/settings/presentation/screens/settings_screen.dart index dd8370b..bf9dd60 100644 --- a/apps/lib/features/settings/presentation/screens/settings_screen.dart +++ b/apps/lib/features/settings/presentation/screens/settings_screen.dart @@ -7,19 +7,25 @@ import '../../../../shared/widgets/app_modal_dialog.dart'; import '../../../../shared/widgets/gua_icon.dart'; import '../../data/models/profile_settings.dart'; import '../../data/repositories/invite_repository.dart'; -import 'account_delete_screen.dart'; +import '../models/legal_document_type.dart'; +import '../utils/legal_document_assets.dart'; +import 'account_data_screen.dart'; import '../widgets/settings_section_widgets.dart'; import 'coin_center_screen.dart'; import 'feedback_screen.dart'; import 'general_settings_screen.dart'; import 'invite_screen.dart'; -import 'legal_center_screen.dart'; +import 'legal_document_screen.dart'; import 'profile_edit_screen.dart'; +// 临时标志位:我的邀请逻辑施工完毕后删除此标志位,并恢复入口与子页面访问。 +final bool _showInviteEntry = false; + class SettingsScreen extends StatefulWidget { const SettingsScreen({ super.key, required this.account, + required this.userId, required this.settings, required this.coinBalance, required this.inviteRepository, @@ -30,9 +36,11 @@ class SettingsScreen extends StatefulWidget { required this.onLogout, required this.onDeleteAccount, required this.onSaveProfile, + required this.onBalanceChanged, }); final String account; + final String userId; final ProfileSettingsV1 settings; final int coinBalance; final InviteRepository inviteRepository; @@ -44,6 +52,7 @@ class SettingsScreen extends StatefulWidget { final Future Function() onDeleteAccount; final Future Function(ProfileSettingsV1 updated) onSaveProfile; + final void Function(int newBalance) onBalanceChanged; @override State createState() => _SettingsScreenState(); @@ -112,20 +121,32 @@ class _SettingsScreenState extends State { background: colors.surfaceContainerHighest, onTap: _openGeneralSettings, ), + if (_showInviteEntry) + SettingsMenuTile( + icon: Icons.card_giftcard_rounded, + title: l10n.settingsInviteTitle, + tint: colors.primary, + background: colors.surfaceContainerHighest, + onTap: _openInvite, + ), SettingsMenuTile( - icon: Icons.card_giftcard_rounded, - title: l10n.settingsInviteTitle, + icon: Icons.feedback_outlined, + title: l10n.settingsFeedbackTitle, tint: colors.primary, background: colors.surfaceContainerHighest, - onTap: _openInvite, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => FeedbackScreen(apiClient: widget.apiClient), + ), + ), ), SettingsMenuTile( - icon: Icons.description_outlined, - title: l10n.settingsLegalCenterTitle, - tint: colors.secondary, + icon: Icons.person_rounded, + title: l10n.settingsAccountAndDataTitle, + tint: colors.primary, background: colors.surfaceContainerHighest, showDivider: false, - onTap: _openLegalCenter, + onTap: _openAccountData, ), ], ), @@ -133,28 +154,28 @@ class _SettingsScreenState extends State { SettingsGroupCard( children: [ SettingsMenuTile( - icon: Icons.feedback_outlined, - title: l10n.settingsFeedbackTitle, - tint: colors.primary, + icon: Icons.info_outline_rounded, + title: l10n.aboutUs, + tint: colors.secondary, background: colors.surfaceContainerHighest, - showDivider: false, - onTap: () => Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => FeedbackScreen(apiClient: widget.apiClient), - ), - ), + onTap: () => _openLegalDocument(LegalDocumentType.aboutUs), ), - ], - ), - SettingsGroupCard( - children: [ SettingsMenuTile( - icon: Icons.person_rounded, - title: l10n.settingsAccountAndDataTitle, - tint: colors.primary, + icon: Icons.security_rounded, + title: l10n.privacyPolicy, + tint: colors.secondary, + background: colors.surfaceContainerHighest, + onTap: () => + _openLegalDocument(LegalDocumentType.privacyPolicy), + ), + SettingsMenuTile( + icon: Icons.description_outlined, + title: l10n.termsOfService, + tint: colors.secondary, background: colors.surfaceContainerHighest, showDivider: false, - onTap: _openAccountDelete, + onTap: () => + _openLegalDocument(LegalDocumentType.termsOfService), ), ], ), @@ -180,7 +201,11 @@ class _SettingsScreenState extends State { Future _openCoinCenter() async { await Navigator.of(context).push( MaterialPageRoute( - builder: (_) => CoinCenterScreen(balance: widget.coinBalance), + builder: (_) => CoinCenterScreen( + balance: widget.coinBalance, + userId: widget.userId, + onBalanceChanged: widget.onBalanceChanged, + ), ), ); } @@ -203,6 +228,9 @@ class _SettingsScreenState extends State { } Future _openInvite() async { + if (!_showInviteEntry) { + return; + } await Navigator.of(context).push( MaterialPageRoute( builder: (_) => InviteScreen(inviteRepository: widget.inviteRepository), @@ -229,17 +257,24 @@ class _SettingsScreenState extends State { }); } - Future _openLegalCenter() async { - await Navigator.of(context).push( - MaterialPageRoute(builder: (_) => const LegalCenterScreen()), + void _openLegalDocument(LegalDocumentType type) { + final l10n = AppLocalizations.of(context)!; + final locale = Localizations.localeOf(context); + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => LegalDocumentScreen( + title: legalDocumentTitle(l10n, type), + assetPath: legalDocumentAssetPath(locale, type), + ), + ), ); } - Future _openAccountDelete() async { + Future _openAccountData() async { final deleted = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => - AccountDeleteScreen(onDeleteAccount: widget.onDeleteAccount), + AccountDataScreen(onDeleteAccount: widget.onDeleteAccount), ), ); if (deleted != true) { diff --git a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart index 58f9b0c..106d30b 100644 --- a/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart +++ b/apps/lib/features/settings/presentation/widgets/settings_section_widgets.dart @@ -3,8 +3,6 @@ import 'package:flutter/material.dart'; import '../../../../l10n/app_localizations.dart'; import '../../../../shared/theme/app_color_palette.dart'; import '../../../../shared/theme/design_tokens.dart'; -import '../../../../shared/widgets/toast/toast.dart'; -import '../../../../shared/widgets/toast/toast_type.dart'; class SectionLabel extends StatelessWidget { const SectionLabel({super.key, required this.text}); @@ -420,12 +418,20 @@ class CoinPackageCard extends StatelessWidget { required this.price, required this.amount, this.badge, + this.onPurchase, + this.isPurchasing = false, + this.isAvailable = true, + this.unavailableMessage, }); final String title; final String price; final int amount; final String? badge; + final VoidCallback? onPurchase; + final bool isPurchasing; + final bool isAvailable; + final String? unavailableMessage; @override Widget build(BuildContext context) { @@ -483,32 +489,43 @@ class CoinPackageCard extends StatelessWidget { ], ), const SizedBox(height: AppSpacing.lg), - Row( - children: [ - Text( - price, - style: Theme.of( - context, - ).textTheme.headlineMedium?.copyWith(color: colors.primary), + if (!isAvailable && unavailableMessage != null) + Text( + unavailableMessage!, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: colors.error, ), - const Spacer(), - FilledButton( - onPressed: () { - Toast.show( + ) + else + Row( + children: [ + Text( + price, + style: Theme.of( context, - l10n.settingsPurchasePending, - type: ToastType.info, - ); - }, - style: FilledButton.styleFrom( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(AppRadius.full), - ), + ).textTheme.headlineMedium?.copyWith(color: colors.primary), ), - child: Text(l10n.settingsPurchaseButton), - ), - ], - ), + const Spacer(), + FilledButton( + onPressed: isPurchasing || !isAvailable ? null : onPurchase, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.full), + ), + ), + child: isPurchasing + ? SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colors.onPrimary, + ), + ) + : Text(l10n.settingsPurchaseButton), + ), + ], + ), ], ), ), diff --git a/apps/lib/l10n/app_en.arb b/apps/lib/l10n/app_en.arb index 7c1780f..5a5ec9a 100644 --- a/apps/lib/l10n/app_en.arb +++ b/apps/lib/l10n/app_en.arb @@ -83,8 +83,7 @@ "settingsSectionPrivacy": "Privacy", "settingsSectionNotification": "Notification Settings", "settingsAccountAndDataTitle": "Account Data", - "settingsInterfaceLanguage": "Interface Language", - "settingsAiLanguage": "AI Response Language", + "settingsLanguage": "Language", "settingsNotificationAllow": "Allow Notifications", "settingsNotificationVibration": "Allow Vibration", "settingsSectionAbout": "About", @@ -111,12 +110,8 @@ } }, "settingsCoinHeroSubtitle": "Credits will be used for casting and related services later.", - "settingsAiLanguage": "AI Response Language", - "settingsAiLanguageHint": "This field will align with profiles.settings.preferences.ai_language once the real preference flow is connected.", "settingsTimezone": "Time Zone", "settingsTimezoneHint": "This field will align with profiles.settings.preferences.timezone and later provide a real time zone picker.", - "settingsCountry": "Country/Region", - "settingsCountryHint": "This field will align with profiles.settings.preferences.country and later provide a region picker.", "settingsPrivacyProfileVisibility": "Profile Visibility", "settingsPrivacyPersonalization": "Personalization", "settingsPrivacyHistoryVisibility": "History Visibility", @@ -195,6 +190,7 @@ "settingsCoinPackPopular": "Popular Pack", "settingsCoinPackPremium": "Premium Pack", "settingsCoinPackPopularBadge": "Popular", + "settingsCoinPackNewUserBadge": "Only Once", "settingsPurchaseButton": "Pay Now", "settingsPurchasePending": "", "settingsCoinAmount": "{amount} credits", @@ -386,9 +382,9 @@ "processingCardGenQuote": "Stillness at the proper time keeps one centered and steady in place.", "processingCardKunTitle": "Kun • The Receptive Earth", "processingCardKunQuote": "The Earth's condition is devoted receptivity; the noble one carries all with broad virtue.", - "ganZhiInfo": "干支信息", - "wuXingWangShuai": "五行旺衰", - "ganZhiKongWang": "空亡信息", + "ganZhiInfo": "Stem-Branch", + "wuXingWangShuai": "Five Elements", + "ganZhiKongWang": "Void", "resultPillarColumn": "四柱", "resultYearPillar": "年柱", "resultMonthPillar": "月柱", @@ -505,5 +501,77 @@ "feedbackContentRequired": "Please enter feedback content", "feedbackContentTooLong": "Feedback content cannot exceed 500 characters", "feedbackTooManyImages": "Maximum 3 images allowed", - "feedbackImageTooLarge": "Image size cannot exceed 5MB" + "feedbackImageTooLarge": "Image size cannot exceed 5MB", + "paymentSuccess": "Purchase successful", + "paymentVerifyFailed": "Purchase verification failed, please try again later", + "paymentProductNotFound": "Product temporarily unavailable", + "paymentStarterPackIneligible": "Starter pack is limited to one purchase per user", + "paymentProductUnavailable": "Product temporarily unavailable", + "paymentPending": "Apple is processing, please wait", + "notifyCenterTitle": "Notifications", + "notifyMarkAllRead": "Mark all read", + "notifyLoadFailed": "Failed to load", + "notifyRetry": "Retry", + "notifyEmpty": "No notifications", + "timeJustNow": "Just now", + "timeMinutesAgo": "{minutes}m ago", + "@timeMinutesAgo": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "timeHoursAgo": "{hours}h ago", + "@timeHoursAgo": { + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "timeDaysAgo": "{days}d ago", + "@timeDaysAgo": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "pointsLedgerTitle": "Points Ledger", + "pointsLedgerEmpty": "No records yet", + "pointsLedgerTypeRegister": "Registration bonus", + "pointsLedgerTypePurchase": "Purchase credits", + "pointsLedgerTypeConsume": "AI chat cost", + "pointsLedgerTypeAdjust": "System adjustment", + "pointsLedgerTypeRefund": "Refund", + "pointsLedgerBalance": "Balance {balance}", + "@pointsLedgerBalance": { + "placeholders": { + "balance": { + "type": "int" + } + } + }, + "retry": "Retry", + "resultFocusPoints": "Focus Points", + "wuXingMu": "Wood", + "wuXingHuo": "Fire", + "wuXingTu": "Earth", + "wuXingJin": "Metal", + "wuXingShui": "Water", + "wuXingWang": "Prosperous", + "wuXingXiang": "Strong", + "wuXingXiu": "Resting", + "wuXingQiu": "Imprisoned", + "wuXingSi": "Dead", + "yaoLegendTitle": "Symbol Guide", + "yaoColSpirit": "Spirit", + "yaoColRelation": "Relation", + "yaoColBranch": "Branch", + "yaoColElement": "Element", + "yaoColChange": "Chg", + "yaoColMark": "Mark", + "resultHexagramDetailFailed": "Interpretation failed. Hexagram details are unavailable.", + "resultHexagramDetailRefused": "Interpretation not supported. Please adjust your question and try again." } diff --git a/apps/lib/l10n/app_localizations.dart b/apps/lib/l10n/app_localizations.dart index 7e0c2d8..5eaf6bd 100644 --- a/apps/lib/l10n/app_localizations.dart +++ b/apps/lib/l10n/app_localizations.dart @@ -513,17 +513,11 @@ abstract class AppLocalizations { /// **'账号数据'** String get settingsAccountAndDataTitle; - /// No description provided for @settingsInterfaceLanguage. + /// No description provided for @settingsLanguage. /// /// In zh, this message translates to: - /// **'界面语言'** - String get settingsInterfaceLanguage; - - /// No description provided for @settingsAiLanguage. - /// - /// In zh, this message translates to: - /// **'AI 回复语言'** - String get settingsAiLanguage; + /// **'语言'** + String get settingsLanguage; /// No description provided for @settingsNotificationAllow. /// @@ -597,12 +591,6 @@ abstract class AppLocalizations { /// **'点数可用于后续起卦与相关服务消费'** String get settingsCoinHeroSubtitle; - /// No description provided for @settingsAiLanguageHint. - /// - /// In zh, this message translates to: - /// **'该字段将对齐 profiles.settings.preferences.ai_language,后续接入真实偏好设置。'** - String get settingsAiLanguageHint; - /// No description provided for @settingsTimezone. /// /// In zh, this message translates to: @@ -615,18 +603,6 @@ abstract class AppLocalizations { /// **'该字段将对齐 profiles.settings.preferences.timezone,后续提供时区选择。'** String get settingsTimezoneHint; - /// No description provided for @settingsCountry. - /// - /// In zh, this message translates to: - /// **'国家/地区'** - String get settingsCountry; - - /// No description provided for @settingsCountryHint. - /// - /// In zh, this message translates to: - /// **'该字段将对齐 profiles.settings.preferences.country,后续提供国家或地区选择。'** - String get settingsCountryHint; - /// No description provided for @settingsPrivacyProfileVisibility. /// /// In zh, this message translates to: @@ -969,6 +945,12 @@ abstract class AppLocalizations { /// **'推荐'** String get settingsCoinPackPopularBadge; + /// No description provided for @settingsCoinPackNewUserBadge. + /// + /// In zh, this message translates to: + /// **'限购一次'** + String get settingsCoinPackNewUserBadge; + /// No description provided for @settingsPurchaseButton. /// /// In zh, this message translates to: @@ -2432,6 +2414,270 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'图片大小不能超过5MB'** String get feedbackImageTooLarge; + + /// No description provided for @paymentSuccess. + /// + /// In zh, this message translates to: + /// **'购买成功'** + String get paymentSuccess; + + /// No description provided for @paymentVerifyFailed. + /// + /// In zh, this message translates to: + /// **'购买验证失败,请稍后重试'** + String get paymentVerifyFailed; + + /// No description provided for @paymentProductNotFound. + /// + /// In zh, this message translates to: + /// **'商品暂时不可用'** + String get paymentProductNotFound; + + /// No description provided for @paymentStarterPackIneligible. + /// + /// In zh, this message translates to: + /// **'新手包每位用户仅限购买一次'** + String get paymentStarterPackIneligible; + + /// No description provided for @paymentProductUnavailable. + /// + /// In zh, this message translates to: + /// **'商品暂时不可用'** + String get paymentProductUnavailable; + + /// No description provided for @paymentPending. + /// + /// In zh, this message translates to: + /// **'Apple 正在处理中,请稍候'** + String get paymentPending; + + /// No description provided for @notifyCenterTitle. + /// + /// In zh, this message translates to: + /// **'通知'** + String get notifyCenterTitle; + + /// No description provided for @notifyMarkAllRead. + /// + /// In zh, this message translates to: + /// **'全部已读'** + String get notifyMarkAllRead; + + /// No description provided for @notifyLoadFailed. + /// + /// In zh, this message translates to: + /// **'加载失败'** + String get notifyLoadFailed; + + /// No description provided for @notifyRetry. + /// + /// In zh, this message translates to: + /// **'重试'** + String get notifyRetry; + + /// No description provided for @notifyEmpty. + /// + /// In zh, this message translates to: + /// **'暂无通知'** + String get notifyEmpty; + + /// No description provided for @timeJustNow. + /// + /// In zh, this message translates to: + /// **'刚刚'** + String get timeJustNow; + + /// No description provided for @timeMinutesAgo. + /// + /// In zh, this message translates to: + /// **'{minutes}分钟前'** + String timeMinutesAgo(int minutes); + + /// No description provided for @timeHoursAgo. + /// + /// In zh, this message translates to: + /// **'{hours}小时前'** + String timeHoursAgo(int hours); + + /// No description provided for @timeDaysAgo. + /// + /// In zh, this message translates to: + /// **'{days}天前'** + String timeDaysAgo(int days); + + /// No description provided for @pointsLedgerTitle. + /// + /// In zh, this message translates to: + /// **'积分流水'** + String get pointsLedgerTitle; + + /// No description provided for @pointsLedgerEmpty. + /// + /// In zh, this message translates to: + /// **'暂无流水记录'** + String get pointsLedgerEmpty; + + /// No description provided for @pointsLedgerTypeRegister. + /// + /// In zh, this message translates to: + /// **'注册赠送'** + String get pointsLedgerTypeRegister; + + /// No description provided for @pointsLedgerTypePurchase. + /// + /// In zh, this message translates to: + /// **'购买积分包'** + String get pointsLedgerTypePurchase; + + /// No description provided for @pointsLedgerTypeConsume. + /// + /// In zh, this message translates to: + /// **'AI 对话消耗'** + String get pointsLedgerTypeConsume; + + /// No description provided for @pointsLedgerTypeAdjust. + /// + /// In zh, this message translates to: + /// **'系统调整'** + String get pointsLedgerTypeAdjust; + + /// No description provided for @pointsLedgerTypeRefund. + /// + /// In zh, this message translates to: + /// **'退款'** + String get pointsLedgerTypeRefund; + + /// No description provided for @pointsLedgerBalance. + /// + /// In zh, this message translates to: + /// **'余额 {balance}'** + String pointsLedgerBalance(int balance); + + /// No description provided for @retry. + /// + /// In zh, this message translates to: + /// **'重试'** + String get retry; + + /// No description provided for @resultFocusPoints. + /// + /// In zh, this message translates to: + /// **'断卦要点'** + String get resultFocusPoints; + + /// No description provided for @wuXingMu. + /// + /// In zh, this message translates to: + /// **'木'** + String get wuXingMu; + + /// No description provided for @wuXingHuo. + /// + /// In zh, this message translates to: + /// **'火'** + String get wuXingHuo; + + /// No description provided for @wuXingTu. + /// + /// In zh, this message translates to: + /// **'土'** + String get wuXingTu; + + /// No description provided for @wuXingJin. + /// + /// In zh, this message translates to: + /// **'金'** + String get wuXingJin; + + /// No description provided for @wuXingShui. + /// + /// In zh, this message translates to: + /// **'水'** + String get wuXingShui; + + /// No description provided for @wuXingWang. + /// + /// In zh, this message translates to: + /// **'旺'** + String get wuXingWang; + + /// No description provided for @wuXingXiang. + /// + /// In zh, this message translates to: + /// **'相'** + String get wuXingXiang; + + /// No description provided for @wuXingXiu. + /// + /// In zh, this message translates to: + /// **'休'** + String get wuXingXiu; + + /// No description provided for @wuXingQiu. + /// + /// In zh, this message translates to: + /// **'囚'** + String get wuXingQiu; + + /// No description provided for @wuXingSi. + /// + /// In zh, this message translates to: + /// **'死'** + String get wuXingSi; + + /// No description provided for @yaoLegendTitle. + /// + /// In zh, this message translates to: + /// **'符号对照'** + String get yaoLegendTitle; + + /// No description provided for @yaoColSpirit. + /// + /// In zh, this message translates to: + /// **'六神'** + String get yaoColSpirit; + + /// No description provided for @yaoColRelation. + /// + /// In zh, this message translates to: + /// **'六亲'** + String get yaoColRelation; + + /// No description provided for @yaoColBranch. + /// + /// In zh, this message translates to: + /// **'地支'** + String get yaoColBranch; + + /// No description provided for @yaoColElement. + /// + /// In zh, this message translates to: + /// **'五行'** + String get yaoColElement; + + /// No description provided for @yaoColChange. + /// + /// In zh, this message translates to: + /// **'动'** + String get yaoColChange; + + /// No description provided for @yaoColMark. + /// + /// In zh, this message translates to: + /// **'标'** + String get yaoColMark; + + /// No description provided for @resultHexagramDetailFailed. + /// + /// In zh, this message translates to: + /// **'解卦失败,卦象详情暂不可用'** + String get resultHexagramDetailFailed; + + /// No description provided for @resultHexagramDetailRefused. + /// + /// In zh, this message translates to: + /// **'暂不支持解卦,请调整问题后重试'** + String get resultHexagramDetailRefused; } class _AppLocalizationsDelegate diff --git a/apps/lib/l10n/app_localizations_en.dart b/apps/lib/l10n/app_localizations_en.dart index ede602b..cecbfb6 100644 --- a/apps/lib/l10n/app_localizations_en.dart +++ b/apps/lib/l10n/app_localizations_en.dart @@ -226,10 +226,7 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsAccountAndDataTitle => 'Account Data'; @override - String get settingsInterfaceLanguage => 'Interface Language'; - - @override - String get settingsAiLanguage => 'AI Response Language'; + String get settingsLanguage => 'Language'; @override String get settingsNotificationAllow => 'Allow Notifications'; @@ -274,10 +271,6 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsCoinHeroSubtitle => 'Credits will be used for casting and related services later.'; - @override - String get settingsAiLanguageHint => - 'This field will align with profiles.settings.preferences.ai_language once the real preference flow is connected.'; - @override String get settingsTimezone => 'Time Zone'; @@ -285,13 +278,6 @@ class AppLocalizationsEn extends AppLocalizations { String get settingsTimezoneHint => 'This field will align with profiles.settings.preferences.timezone and later provide a real time zone picker.'; - @override - String get settingsCountry => 'Country/Region'; - - @override - String get settingsCountryHint => - 'This field will align with profiles.settings.preferences.country and later provide a region picker.'; - @override String get settingsPrivacyProfileVisibility => 'Profile Visibility'; @@ -484,6 +470,9 @@ class AppLocalizationsEn extends AppLocalizations { @override String get settingsCoinPackPopularBadge => 'Popular'; + @override + String get settingsCoinPackNewUserBadge => 'Only Once'; + @override String get settingsPurchaseButton => 'Pay Now'; @@ -991,13 +980,13 @@ class AppLocalizationsEn extends AppLocalizations { 'The Earth\'s condition is devoted receptivity; the noble one carries all with broad virtue.'; @override - String get ganZhiInfo => '干支信息'; + String get ganZhiInfo => 'Stem-Branch'; @override - String get wuXingWangShuai => '五行旺衰'; + String get wuXingWangShuai => 'Five Elements'; @override - String get ganZhiKongWang => '空亡信息'; + String get ganZhiKongWang => 'Void'; @override String get resultPillarColumn => '四柱'; @@ -1282,4 +1271,148 @@ class AppLocalizationsEn extends AppLocalizations { @override String get feedbackImageTooLarge => 'Image size cannot exceed 5MB'; + + @override + String get paymentSuccess => 'Purchase successful'; + + @override + String get paymentVerifyFailed => + 'Purchase verification failed, please try again later'; + + @override + String get paymentProductNotFound => 'Product temporarily unavailable'; + + @override + String get paymentStarterPackIneligible => + 'Starter pack is limited to one purchase per user'; + + @override + String get paymentProductUnavailable => 'Product temporarily unavailable'; + + @override + String get paymentPending => 'Apple is processing, please wait'; + + @override + String get notifyCenterTitle => 'Notifications'; + + @override + String get notifyMarkAllRead => 'Mark all read'; + + @override + String get notifyLoadFailed => 'Failed to load'; + + @override + String get notifyRetry => 'Retry'; + + @override + String get notifyEmpty => 'No notifications'; + + @override + String get timeJustNow => 'Just now'; + + @override + String timeMinutesAgo(int minutes) { + return '${minutes}m ago'; + } + + @override + String timeHoursAgo(int hours) { + return '${hours}h ago'; + } + + @override + String timeDaysAgo(int days) { + return '${days}d ago'; + } + + @override + String get pointsLedgerTitle => 'Points Ledger'; + + @override + String get pointsLedgerEmpty => 'No records yet'; + + @override + String get pointsLedgerTypeRegister => 'Registration bonus'; + + @override + String get pointsLedgerTypePurchase => 'Purchase credits'; + + @override + String get pointsLedgerTypeConsume => 'AI chat cost'; + + @override + String get pointsLedgerTypeAdjust => 'System adjustment'; + + @override + String get pointsLedgerTypeRefund => 'Refund'; + + @override + String pointsLedgerBalance(int balance) { + return 'Balance $balance'; + } + + @override + String get retry => 'Retry'; + + @override + String get resultFocusPoints => 'Focus Points'; + + @override + String get wuXingMu => 'Wood'; + + @override + String get wuXingHuo => 'Fire'; + + @override + String get wuXingTu => 'Earth'; + + @override + String get wuXingJin => 'Metal'; + + @override + String get wuXingShui => 'Water'; + + @override + String get wuXingWang => 'Prosperous'; + + @override + String get wuXingXiang => 'Strong'; + + @override + String get wuXingXiu => 'Resting'; + + @override + String get wuXingQiu => 'Imprisoned'; + + @override + String get wuXingSi => 'Dead'; + + @override + String get yaoLegendTitle => 'Symbol Guide'; + + @override + String get yaoColSpirit => 'Spirit'; + + @override + String get yaoColRelation => 'Relation'; + + @override + String get yaoColBranch => 'Branch'; + + @override + String get yaoColElement => 'Element'; + + @override + String get yaoColChange => 'Chg'; + + @override + String get yaoColMark => 'Mark'; + + @override + String get resultHexagramDetailFailed => + 'Interpretation failed. Hexagram details are unavailable.'; + + @override + String get resultHexagramDetailRefused => + 'Interpretation not supported. Please adjust your question and try again.'; } diff --git a/apps/lib/l10n/app_localizations_zh.dart b/apps/lib/l10n/app_localizations_zh.dart index 3961153..45a84f8 100644 --- a/apps/lib/l10n/app_localizations_zh.dart +++ b/apps/lib/l10n/app_localizations_zh.dart @@ -224,10 +224,7 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsAccountAndDataTitle => '账号数据'; @override - String get settingsInterfaceLanguage => '界面语言'; - - @override - String get settingsAiLanguage => 'AI 回复语言'; + String get settingsLanguage => '语言'; @override String get settingsNotificationAllow => '允许通知'; @@ -270,10 +267,6 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsCoinHeroSubtitle => '点数可用于后续起卦与相关服务消费'; - @override - String get settingsAiLanguageHint => - '该字段将对齐 profiles.settings.preferences.ai_language,后续接入真实偏好设置。'; - @override String get settingsTimezone => '时区'; @@ -281,13 +274,6 @@ class AppLocalizationsZh extends AppLocalizations { String get settingsTimezoneHint => '该字段将对齐 profiles.settings.preferences.timezone,后续提供时区选择。'; - @override - String get settingsCountry => '国家/地区'; - - @override - String get settingsCountryHint => - '该字段将对齐 profiles.settings.preferences.country,后续提供国家或地区选择。'; - @override String get settingsPrivacyProfileVisibility => '资料可见性'; @@ -469,6 +455,9 @@ class AppLocalizationsZh extends AppLocalizations { @override String get settingsCoinPackPopularBadge => '推荐'; + @override + String get settingsCoinPackNewUserBadge => '限购一次'; + @override String get settingsPurchaseButton => '立即支付'; @@ -1225,6 +1214,146 @@ class AppLocalizationsZh extends AppLocalizations { @override String get feedbackImageTooLarge => '图片大小不能超过5MB'; + + @override + String get paymentSuccess => '购买成功'; + + @override + String get paymentVerifyFailed => '购买验证失败,请稍后重试'; + + @override + String get paymentProductNotFound => '商品暂时不可用'; + + @override + String get paymentStarterPackIneligible => '新手包每位用户仅限购买一次'; + + @override + String get paymentProductUnavailable => '商品暂时不可用'; + + @override + String get paymentPending => 'Apple 正在处理中,请稍候'; + + @override + String get notifyCenterTitle => '通知'; + + @override + String get notifyMarkAllRead => '全部已读'; + + @override + String get notifyLoadFailed => '加载失败'; + + @override + String get notifyRetry => '重试'; + + @override + String get notifyEmpty => '暂无通知'; + + @override + String get timeJustNow => '刚刚'; + + @override + String timeMinutesAgo(int minutes) { + return '$minutes分钟前'; + } + + @override + String timeHoursAgo(int hours) { + return '$hours小时前'; + } + + @override + String timeDaysAgo(int days) { + return '$days天前'; + } + + @override + String get pointsLedgerTitle => '积分流水'; + + @override + String get pointsLedgerEmpty => '暂无流水记录'; + + @override + String get pointsLedgerTypeRegister => '注册赠送'; + + @override + String get pointsLedgerTypePurchase => '购买积分包'; + + @override + String get pointsLedgerTypeConsume => 'AI 对话消耗'; + + @override + String get pointsLedgerTypeAdjust => '系统调整'; + + @override + String get pointsLedgerTypeRefund => '退款'; + + @override + String pointsLedgerBalance(int balance) { + return '余额 $balance'; + } + + @override + String get retry => '重试'; + + @override + String get resultFocusPoints => '断卦要点'; + + @override + String get wuXingMu => '木'; + + @override + String get wuXingHuo => '火'; + + @override + String get wuXingTu => '土'; + + @override + String get wuXingJin => '金'; + + @override + String get wuXingShui => '水'; + + @override + String get wuXingWang => '旺'; + + @override + String get wuXingXiang => '相'; + + @override + String get wuXingXiu => '休'; + + @override + String get wuXingQiu => '囚'; + + @override + String get wuXingSi => '死'; + + @override + String get yaoLegendTitle => '符号对照'; + + @override + String get yaoColSpirit => '六神'; + + @override + String get yaoColRelation => '六亲'; + + @override + String get yaoColBranch => '地支'; + + @override + String get yaoColElement => '五行'; + + @override + String get yaoColChange => '动'; + + @override + String get yaoColMark => '标'; + + @override + String get resultHexagramDetailFailed => '解卦失败,卦象详情暂不可用'; + + @override + String get resultHexagramDetailRefused => '暂不支持解卦,请调整问题后重试'; } /// The translations for Chinese, using the Han script (`zh_Hant`). @@ -1379,10 +1508,7 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { String get settingsAccountAndDataTitle => '帳號資料'; @override - String get settingsInterfaceLanguage => '介面語言'; - - @override - String get settingsAiLanguage => 'AI 回覆語言'; + String get settingsLanguage => '語言'; @override String get settingsNotificationAllow => '允許通知'; @@ -1544,6 +1670,9 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get settingsCoinPackPopularBadge => '推薦'; + @override + String get settingsCoinPackNewUserBadge => '限購一次'; + @override String get settingsPurchaseButton => '立即支付'; @@ -2204,4 +2333,144 @@ class AppLocalizationsZhHant extends AppLocalizationsZh { @override String get feedbackImageTooLarge => '圖片大小不能超過5MB'; + + @override + String get paymentSuccess => '購買成功'; + + @override + String get paymentVerifyFailed => '購買驗證失敗,請稍後重試'; + + @override + String get paymentProductNotFound => '商品暫時不可用'; + + @override + String get paymentStarterPackIneligible => '新手包每位用戶僅限購買一次'; + + @override + String get paymentProductUnavailable => '商品暫時不可用'; + + @override + String get paymentPending => 'Apple 正在處理中,請稍候'; + + @override + String get notifyCenterTitle => '通知'; + + @override + String get notifyMarkAllRead => '全部已讀'; + + @override + String get notifyLoadFailed => '載入失敗'; + + @override + String get notifyRetry => '重試'; + + @override + String get notifyEmpty => '暫無通知'; + + @override + String get timeJustNow => '剛剛'; + + @override + String timeMinutesAgo(int minutes) { + return '$minutes分鐘前'; + } + + @override + String timeHoursAgo(int hours) { + return '$hours小時前'; + } + + @override + String timeDaysAgo(int days) { + return '$days天前'; + } + + @override + String get pointsLedgerTitle => '積分流水'; + + @override + String get pointsLedgerEmpty => '暫無流水記錄'; + + @override + String get pointsLedgerTypeRegister => '註冊贈送'; + + @override + String get pointsLedgerTypePurchase => '購買積分包'; + + @override + String get pointsLedgerTypeConsume => 'AI 對話消耗'; + + @override + String get pointsLedgerTypeAdjust => '系統調整'; + + @override + String get pointsLedgerTypeRefund => '退款'; + + @override + String pointsLedgerBalance(int balance) { + return '餘額 $balance'; + } + + @override + String get retry => '重試'; + + @override + String get resultFocusPoints => '斷卦要點'; + + @override + String get wuXingMu => '木'; + + @override + String get wuXingHuo => '火'; + + @override + String get wuXingTu => '土'; + + @override + String get wuXingJin => '金'; + + @override + String get wuXingShui => '水'; + + @override + String get wuXingWang => '旺'; + + @override + String get wuXingXiang => '相'; + + @override + String get wuXingXiu => '休'; + + @override + String get wuXingQiu => '囚'; + + @override + String get wuXingSi => '死'; + + @override + String get yaoLegendTitle => '符號對照'; + + @override + String get yaoColSpirit => '六神'; + + @override + String get yaoColRelation => '六親'; + + @override + String get yaoColBranch => '地支'; + + @override + String get yaoColElement => '五行'; + + @override + String get yaoColChange => '動'; + + @override + String get yaoColMark => '標'; + + @override + String get resultHexagramDetailFailed => '解卦失敗,卦象詳情暫不可用'; + + @override + String get resultHexagramDetailRefused => '暫不支持解卦,請調整問題後重試'; } diff --git a/apps/lib/l10n/app_zh.arb b/apps/lib/l10n/app_zh.arb index bd14dc0..6ac901a 100644 --- a/apps/lib/l10n/app_zh.arb +++ b/apps/lib/l10n/app_zh.arb @@ -83,8 +83,7 @@ "settingsSectionPrivacy": "隐私设置", "settingsSectionNotification": "通知设置", "settingsAccountAndDataTitle": "账号数据", - "settingsInterfaceLanguage": "界面语言", - "settingsAiLanguage": "AI回复语言", + "settingsLanguage": "语言", "settingsNotificationAllow": "允许通知", "settingsNotificationVibration": "允许振动", "settingsSectionAbout": "关于", @@ -111,12 +110,8 @@ } }, "settingsCoinHeroSubtitle": "点数可用于后续起卦与相关服务消费", - "settingsAiLanguage": "AI 回复语言", - "settingsAiLanguageHint": "该字段将对齐 profiles.settings.preferences.ai_language,后续接入真实偏好设置。", "settingsTimezone": "时区", "settingsTimezoneHint": "该字段将对齐 profiles.settings.preferences.timezone,后续提供时区选择。", - "settingsCountry": "国家/地区", - "settingsCountryHint": "该字段将对齐 profiles.settings.preferences.country,后续提供国家或地区选择。", "settingsPrivacyProfileVisibility": "资料可见性", "settingsPrivacyPersonalization": "个性化推荐", "settingsPrivacyHistoryVisibility": "历史记录展示", @@ -195,6 +190,7 @@ "settingsCoinPackPopular": "常用加量包", "settingsCoinPackPremium": "高频进阶包", "settingsCoinPackPopularBadge": "推荐", + "settingsCoinPackNewUserBadge": "限购一次", "settingsPurchaseButton": "立即支付", "settingsPurchasePending": "", "settingsCoinAmount": "{amount} 点数", @@ -505,5 +501,77 @@ "feedbackContentRequired": "请输入反馈内容", "feedbackContentTooLong": "反馈内容不能超过500字", "feedbackTooManyImages": "最多只能上传3张图片", - "feedbackImageTooLarge": "图片大小不能超过5MB" + "feedbackImageTooLarge": "图片大小不能超过5MB", + "paymentSuccess": "购买成功", + "paymentVerifyFailed": "购买验证失败,请稍后重试", + "paymentProductNotFound": "商品暂时不可用", + "paymentStarterPackIneligible": "新手包每位用户仅限购买一次", + "paymentProductUnavailable": "商品暂时不可用", + "paymentPending": "Apple 正在处理中,请稍候", + "notifyCenterTitle": "通知", + "notifyMarkAllRead": "全部已读", + "notifyLoadFailed": "加载失败", + "notifyRetry": "重试", + "notifyEmpty": "暂无通知", + "timeJustNow": "刚刚", + "timeMinutesAgo": "{minutes}分钟前", + "@timeMinutesAgo": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, + "timeHoursAgo": "{hours}小时前", + "@timeHoursAgo": { + "placeholders": { + "hours": { + "type": "int" + } + } + }, + "timeDaysAgo": "{days}天前", + "@timeDaysAgo": { + "placeholders": { + "days": { + "type": "int" + } + } + }, + "pointsLedgerTitle": "积分流水", + "pointsLedgerEmpty": "暂无流水记录", + "pointsLedgerTypeRegister": "注册赠送", + "pointsLedgerTypePurchase": "购买积分包", + "pointsLedgerTypeConsume": "AI 对话消耗", + "pointsLedgerTypeAdjust": "系统调整", + "pointsLedgerTypeRefund": "退款", + "pointsLedgerBalance": "余额 {balance}", + "@pointsLedgerBalance": { + "placeholders": { + "balance": { + "type": "int" + } + } + }, + "retry": "重试", + "resultFocusPoints": "断卦要点", + "wuXingMu": "木", + "wuXingHuo": "火", + "wuXingTu": "土", + "wuXingJin": "金", + "wuXingShui": "水", + "wuXingWang": "旺", + "wuXingXiang": "相", + "wuXingXiu": "休", + "wuXingQiu": "囚", + "wuXingSi": "死", + "yaoLegendTitle": "符号对照", + "yaoColSpirit": "六神", + "yaoColRelation": "六亲", + "yaoColBranch": "地支", + "yaoColElement": "五行", + "yaoColChange": "动", + "yaoColMark": "标", + "resultHexagramDetailFailed": "解卦失败,卦象详情暂不可用", + "resultHexagramDetailRefused": "暂不支持解卦,请调整问题后重试" } diff --git a/apps/lib/l10n/app_zh_hant.arb b/apps/lib/l10n/app_zh_hant.arb index da15b66..2338999 100644 --- a/apps/lib/l10n/app_zh_hant.arb +++ b/apps/lib/l10n/app_zh_hant.arb @@ -55,8 +55,7 @@ "settingsSectionPrivacy": "隱私設定", "settingsSectionNotification": "通知設定", "settingsAccountAndDataTitle": "帳號資料", - "settingsInterfaceLanguage": "介面語言", - "settingsAiLanguage": "AI 回覆語言", + "settingsLanguage": "語言", "settingsNotificationAllow": "允許通知", "settingsNotificationVibration": "允許振動", "settingsSectionAbout": "關於", @@ -73,7 +72,6 @@ } }, "settingsCoinHeroSubtitle": "點數可用於後續起卦與相關服務消費", - "settingsAiLanguage": "AI 回覆語言", "settingsPrivacyProfileVisibility": "資料可見性", "settingsPrivacyPersonalization": "個人化推薦", "settingsPrivacyHistoryVisibility": "歷史記錄展示", @@ -128,6 +126,7 @@ "settingsCoinPackPopular": "常用加量包", "settingsCoinPackPremium": "高頻進階包", "settingsCoinPackPopularBadge": "推薦", + "settingsCoinPackNewUserBadge": "限購一次", "settingsPurchaseButton": "立即支付", "settingsPurchasePending": "", "settingsCoinAmount": "{amount} 點數", @@ -367,16 +366,25 @@ "timeMinutesAgo": "{minutes}分鐘前", "@timeMinutesAgo": { "placeholders": { + "minutes": { + "type": "int" + } } }, "timeHoursAgo": "{hours}小時前", "@timeHoursAgo": { "placeholders": { + "hours": { + "type": "int" + } } }, "timeDaysAgo": "{days}天前", "@timeDaysAgo": { "placeholders": { + "days": { + "type": "int" + } } }, "resultFocusPoints": "斷卦要點", @@ -407,5 +415,40 @@ "feedbackContentRequired": "請輸入回饋內容", "feedbackContentTooLong": "回饋內容不能超過500字", "feedbackTooManyImages": "最多只能上傳3張圖片", - "feedbackImageTooLarge": "圖片大小不能超過5MB" + "feedbackImageTooLarge": "圖片大小不能超過5MB", + "paymentSuccess": "購買成功", + "paymentVerifyFailed": "購買驗證失敗,請稍後重試", + "paymentProductNotFound": "商品暫時不可用", + "paymentStarterPackIneligible": "新手包每位用戶僅限購買一次", + "paymentProductUnavailable": "商品暫時不可用", + "paymentPending": "Apple 正在處理中,請稍候", + "pointsLedgerTitle": "積分流水", + "pointsLedgerEmpty": "暫無流水記錄", + "pointsLedgerTypeRegister": "註冊贈送", + "pointsLedgerTypePurchase": "購買積分包", + "pointsLedgerTypeConsume": "AI 對話消耗", + "pointsLedgerTypeAdjust": "系統調整", + "pointsLedgerTypeRefund": "退款", + "pointsLedgerBalance": "餘額 {balance}", + "@pointsLedgerBalance": { + "placeholders": { + "balance": { + "type": "int" + } + } + }, + "wuXingWang": "旺", + "wuXingXiang": "相", + "wuXingXiu": "休", + "wuXingQiu": "囚", + "wuXingSi": "死", + "yaoLegendTitle": "符號對照", + "yaoColSpirit": "六神", + "yaoColRelation": "六親", + "yaoColBranch": "地支", + "yaoColElement": "五行", + "yaoColChange": "動", + "yaoColMark": "標", + "resultHexagramDetailFailed": "解卦失敗,卦象詳情暫不可用", + "resultHexagramDetailRefused": "暫不支持解卦,請調整問題後重試" } diff --git a/apps/lib/shared/theme/app_color_palette.dart b/apps/lib/shared/theme/app_color_palette.dart index 0d2721c..46d8c08 100644 --- a/apps/lib/shared/theme/app_color_palette.dart +++ b/apps/lib/shared/theme/app_color_palette.dart @@ -20,6 +20,8 @@ class AppColorPalette extends ThemeExtension { required this.warning, required this.warningContainer, required this.onWarningContainer, + required this.incomeGreenBg, + required this.incomeGreenText, }); final Color accentPurple; @@ -39,6 +41,8 @@ class AppColorPalette extends ThemeExtension { final Color warning; final Color warningContainer; final Color onWarningContainer; + final Color incomeGreenBg; + final Color incomeGreenText; @override ThemeExtension copyWith({ @@ -59,6 +63,8 @@ class AppColorPalette extends ThemeExtension { Color? warning, Color? warningContainer, Color? onWarningContainer, + Color? incomeGreenBg, + Color? incomeGreenText, }) { return AppColorPalette( accentPurple: accentPurple ?? this.accentPurple, @@ -78,6 +84,8 @@ class AppColorPalette extends ThemeExtension { warning: warning ?? this.warning, warningContainer: warningContainer ?? this.warningContainer, onWarningContainer: onWarningContainer ?? this.onWarningContainer, + incomeGreenBg: incomeGreenBg ?? this.incomeGreenBg, + incomeGreenText: incomeGreenText ?? this.incomeGreenText, ); } @@ -131,6 +139,8 @@ class AppColorPalette extends ThemeExtension { other.onWarningContainer, t, )!, + incomeGreenBg: Color.lerp(incomeGreenBg, other.incomeGreenBg, t)!, + incomeGreenText: Color.lerp(incomeGreenText, other.incomeGreenText, t)!, ); } } diff --git a/apps/lib/shared/utils/time_format.dart b/apps/lib/shared/utils/time_format.dart new file mode 100644 index 0000000..ca56e15 --- /dev/null +++ b/apps/lib/shared/utils/time_format.dart @@ -0,0 +1,30 @@ +import 'package:flutter/widgets.dart'; + +import '../../l10n/app_localizations.dart'; + +String formatRelativeTime(BuildContext context, DateTime dateTime) { + final l10n = AppLocalizations.of(context)!; + final diff = DateTime.now().difference(dateTime); + if (diff.inMinutes < 1) return l10n.timeJustNow; + if (diff.inHours < 1) return l10n.timeMinutesAgo(diff.inMinutes); + if (diff.inDays < 1) return l10n.timeHoursAgo(diff.inHours); + if (diff.inDays < 30) return l10n.timeDaysAgo(diff.inDays); + return '${dateTime.month}/${dateTime.day}'; +} + +String formatCompactLocalDateTime(String isoString) { + final dateTime = DateTime.parse(isoString).toLocal(); + final now = DateTime.now(); + final timeText = + '${dateTime.hour.toString().padLeft(2, '0')}:${dateTime.minute.toString().padLeft(2, '0')}'; + + if (now.year == dateTime.year && + now.month == dateTime.month && + now.day == dateTime.day) { + return timeText; + } + + final dateText = + '${dateTime.year}-${dateTime.month.toString().padLeft(2, '0')}-${dateTime.day.toString().padLeft(2, '0')}'; + return '$dateText $timeText'; +} diff --git a/apps/lib/shared/widgets/divination/divination_shared_widgets.dart b/apps/lib/shared/widgets/divination/divination_shared_widgets.dart index 85fbe5e..d317bc5 100644 --- a/apps/lib/shared/widgets/divination/divination_shared_widgets.dart +++ b/apps/lib/shared/widgets/divination/divination_shared_widgets.dart @@ -96,6 +96,8 @@ class _DivinationGuideDialogState extends State { final PageController _pageController = PageController(); int _currentPage = 0; + bool get _isLastPage => _currentPage == widget.guideImages.length - 1; + @override void dispose() { _pageController.dispose(); @@ -105,6 +107,8 @@ class _DivinationGuideDialogState extends State { @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; + final colors = Theme.of(context).colorScheme; + final swipeHint = _guideSwipeHint(Localizations.localeOf(context)); return Dialog( child: SizedBox( width: 360, @@ -139,6 +143,10 @@ class _DivinationGuideDialogState extends State { child: Text( widget.instructions[_currentPage], textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: colors.onSurfaceVariant, + height: 1.45, + ), ), ), Padding( @@ -151,8 +159,14 @@ class _DivinationGuideDialogState extends State { child: SizedBox( width: double.infinity, child: FilledButton( - onPressed: () => Navigator.of(context).pop(), - child: Text(l10n.divinationIAcknowledge), + onPressed: _isLastPage + ? () => Navigator.of(context).pop() + : null, + child: Text( + _isLastPage + ? l10n.divinationIAcknowledge + : '${_currentPage + 1} / ${widget.guideImages.length} · $swipeHint', + ), ), ), ), @@ -161,4 +175,11 @@ class _DivinationGuideDialogState extends State { ), ); } + + String _guideSwipeHint(Locale locale) { + if (locale.languageCode == 'en') { + return 'Swipe left for the next step'; + } + return '左滑查看下一步'; + } } diff --git a/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart b/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart index 04b5761..b81c0a3 100644 --- a/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart +++ b/apps/lib/shared/widgets/notification/notification_detail_bottom_sheet.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../features/notifications/data/models/notification_item.dart'; import '../../theme/design_tokens.dart'; +import '../../utils/time_format.dart'; class NotificationDetailBottomSheet extends StatefulWidget { const NotificationDetailBottomSheet({ @@ -76,7 +77,7 @@ class _NotificationDetailBottomSheetState Padding( padding: const EdgeInsets.symmetric(horizontal: AppSpacing.lg), child: Text( - _formatTime(widget.item.createdAt), + formatRelativeTime(context, widget.item.createdAt), style: textTheme.labelSmall?.copyWith(color: colors.outline), ), ), @@ -97,16 +98,6 @@ class _NotificationDetailBottomSheetState ), ); } - - String _formatTime(DateTime dt) { - final now = DateTime.now(); - final diff = now.difference(dt); - if (diff.inMinutes < 1) return '刚刚'; - if (diff.inHours < 1) return '${diff.inMinutes}分钟前'; - if (diff.inDays < 1) return '${diff.inHours}小时前'; - if (diff.inDays < 30) return '${diff.inDays}天前'; - return '${dt.month}/${dt.day}'; - } } Future showNotificationDetailBottomSheet({ diff --git a/apps/pubspec.yaml b/apps/pubspec.yaml index 7c3e1e6..b085238 100644 --- a/apps/pubspec.yaml +++ b/apps/pubspec.yaml @@ -43,12 +43,16 @@ dependencies: onboarding_overlay: ^3.2.3 image_picker: ^1.1.2 record: ^6.1.1 + flutter_timezone: ^3.0.1 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 device_info_plus: ^12.4.0 package_info_plus: ^9.0.1 + in_app_purchase: ^3.2.3 + in_app_purchase_storekit: ^0.4.8 + crypto: ^3.0.7 dev_dependencies: flutter_test: diff --git a/apps/test/features/auth/auth_bloc_test.dart b/apps/test/features/auth/auth_bloc_test.dart index 4e4d1f9..76ae34d 100644 --- a/apps/test/features/auth/auth_bloc_test.dart +++ b/apps/test/features/auth/auth_bloc_test.dart @@ -21,6 +21,8 @@ class _FakeAuthRepository implements AuthRepository { Future loginWithEmailOtp({ required String email, required String otp, + String? language, + String? timezone, }) async { return AuthUser(id: 'u1', email: email); } diff --git a/apps/test/features/notifications/notification_bloc_test.dart b/apps/test/features/notifications/notification_bloc_test.dart index 5a00728..072a0e3 100644 --- a/apps/test/features/notifications/notification_bloc_test.dart +++ b/apps/test/features/notifications/notification_bloc_test.dart @@ -17,6 +17,7 @@ class _FakeNotificationRepository implements NotificationRepository { Future listNotifications({ int limit = 20, String? cursor, + String locale = 'zh', }) async { return NotificationListResult( items: items, @@ -29,7 +30,10 @@ class _FakeNotificationRepository implements NotificationRepository { Future getUnreadCount() async => unreadCount; @override - Future markRead({required String notificationId}) async { + Future markRead({ + required String notificationId, + String locale = 'zh', + }) async { if (failMarkRead) { throw Exception('Mark read failed'); } diff --git a/apps/test/features/payments/data/models/apple_purchase_models_test.dart b/apps/test/features/payments/data/models/apple_purchase_models_test.dart new file mode 100644 index 0000000..fa628ff --- /dev/null +++ b/apps/test/features/payments/data/models/apple_purchase_models_test.dart @@ -0,0 +1,75 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meeyao_qianwen/features/payments/data/models/apple_purchase_models.dart'; + +void main() { + group('VerifyTransactionRequest', () { + test('toJson includes all required fields', () { + const request = VerifyTransactionRequest( + productCode: 'starter_pack', + appStoreProductId: 'com.meeyao.qianwen.starter_pack', + transactionId: '1000000123456789', + signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...', + ); + + final json = request.toJson(); + + expect(json['productCode'], 'starter_pack'); + expect(json['appStoreProductId'], 'com.meeyao.qianwen.starter_pack'); + expect(json['transactionId'], '1000000123456789'); + expect(json['signedTransactionInfo'], 'eyJhbGciOiJFUzI1NiIs...'); + expect(json.containsKey('appAccountToken'), false); + }); + + test('toJson includes appAccountToken when provided', () { + const request = VerifyTransactionRequest( + productCode: 'starter_pack', + appStoreProductId: 'com.meeyao.qianwen.starter_pack', + transactionId: '1000000123456789', + signedTransactionInfo: 'eyJhbGciOiJFUzI1NiIs...', + appAccountToken: 'abc123def456', + ); + + final json = request.toJson(); + + expect(json['appAccountToken'], 'abc123def456'); + }); + }); + + group('VerifyTransactionResponse', () { + test('parses granted status correctly', () { + final json = { + 'status': 'granted', + 'productCode': 'starter_pack', + 'transactionId': '1000000123456789', + 'creditsAdded': 100, + 'newBalance': 180, + 'ledgerEventId': 'payment.apple_iap:1000000123456789', + }; + + final response = VerifyTransactionResponse.fromJson(json); + + expect(response.status, VerifyTransactionStatus.granted); + expect(response.productCode, 'starter_pack'); + expect(response.transactionId, '1000000123456789'); + expect(response.creditsAdded, 100); + expect(response.newBalance, 180); + expect(response.ledgerEventId, 'payment.apple_iap:1000000123456789'); + }); + + test('parses already_granted status correctly', () { + final json = { + 'status': 'already_granted', + 'productCode': 'starter_pack', + 'transactionId': '1000000123456789', + 'creditsAdded': 0, + 'newBalance': 180, + 'ledgerEventId': 'payment.apple_iap:1000000123456789', + }; + + final response = VerifyTransactionResponse.fromJson(json); + + expect(response.status, VerifyTransactionStatus.alreadyGranted); + expect(response.creditsAdded, 0); + }); + }); +} diff --git a/backend/AGENTS.md b/backend/AGENTS.md index 7b0ada7..e37e4c2 100644 --- a/backend/AGENTS.md +++ b/backend/AGENTS.md @@ -13,6 +13,7 @@ This file governs `backend/**` only. Keep it minimal, enforceable, and non-dupli - Python commands must use `uv` (`uv run`, `uv add`). - Backend startup/shutdown must use `./infra/scripts/app.sh`. - Check runtime logs from `./logs/*.log`. +- Docker Compose usage: see `infra/docker/README.md` for environment-based service activation. ## Code Quality Baseline diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..37f3640 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,31 @@ +FROM ghcr.io/astral-sh/uv:python3.12-bookworm-slim AS builder + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + UV_LINK_MODE=copy + +WORKDIR /app + +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project --no-cache +RUN find /app/.venv -type d \( -name __pycache__ -o -name test -o -name tests \) -prune -exec rm -rf {} + \ + && if command -v strip >/dev/null 2>&1; then \ + find /app/.venv -type f -name "*.so" -exec strip --strip-unneeded {} +; \ + fi + +FROM python:3.12-slim-bookworm + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PYTHONPATH=/app/backend/src \ + PATH="/app/.venv/bin:$PATH" + +WORKDIR /app + +COPY --from=builder /app/.venv ./.venv + +COPY backend ./backend + +EXPOSE 5775 + +CMD ["sh", "-c", "exec uvicorn app:app --host ${ERYAO_WEB__HOST:-0.0.0.0} --port ${ERYAO_WEB__PORT:-5775} --workers ${ERYAO_WEB__WORKERS:-2} --log-level $(printf '%s' ${ERYAO_RUNTIME__LOG_LEVEL:-info} | tr '[:upper:]' '[:lower:]')"] diff --git a/backend/alembic/versions/20260411_0001_core_llm_schema.py b/backend/alembic/versions/20260411_0001_core_llm_schema.py new file mode 100644 index 0000000..67708a0 --- /dev/null +++ b/backend/alembic/versions/20260411_0001_core_llm_schema.py @@ -0,0 +1,98 @@ +"""Create core LLM configuration tables. + +Revision ID: 20260428_squash_0001 +Revises: +Create Date: 2026-04-11 00:01:00 + +Squashed history: replaces the original initial LLM migration plus the later +duplicate-index cleanup by creating only the final target schema. +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260428_squash_0001" +down_revision: Union[str, Sequence[str], None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "llm_factory", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("name", sa.String(length=50), nullable=False), + sa.Column("request_url", sa.String(length=255), nullable=False), + sa.Column("avatar", sa.Text(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("name"), + ) + _enable_service_only_rls("llm_factory") + + op.create_table( + "llms", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("factory_id", sa.UUID(), nullable=False), + sa.Column("model_code", sa.String(length=50), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(["factory_id"], ["llm_factory.id"], name="fk_llms_factory_id", ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("model_code"), + ) + op.create_index("ix_llms_factory_id", "llms", ["factory_id"]) + _enable_service_only_rls("llms") + + op.create_table( + "system_agents", + sa.Column("agent_type", sa.String(length=20), nullable=False), + sa.Column("llm_id", sa.UUID(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("config", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["llm_id"], ["llms.id"], name="fk_system_agents_llm_id", ondelete="RESTRICT"), + sa.PrimaryKeyConstraint("agent_type"), + ) + _enable_service_only_rls("system_agents") + + op.execute("REVOKE ALL ON TABLE public.alembic_version FROM anon") + op.execute("REVOKE ALL ON TABLE public.alembic_version FROM authenticated") + + +def downgrade() -> None: + _drop_service_only_rls("system_agents") + op.drop_table("system_agents") + + _drop_service_only_rls("llms") + op.drop_index("ix_llms_factory_id", table_name="llms") + op.drop_table("llms") + + _drop_service_only_rls("llm_factory") + op.drop_table("llm_factory") + + +def _enable_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") + op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + + +def _drop_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260411_0001_initial_llm_schema.py b/backend/alembic/versions/20260411_0001_initial_llm_schema.py deleted file mode 100644 index e792c31..0000000 --- a/backend/alembic/versions/20260411_0001_initial_llm_schema.py +++ /dev/null @@ -1,162 +0,0 @@ -"""initial llm/factory/system_agents schema - -Revision ID: 20260411_0001 -Revises: -Create Date: 2026-04-11 00:01:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -revision: str = "20260411_0001" -down_revision: Union[str, Sequence[str], None] = None -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "llm_factory", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("name", sa.String(length=50), nullable=False), - sa.Column("request_url", sa.String(length=255), nullable=False), - sa.Column("avatar", sa.Text(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("name"), - ) - op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True) - _enable_rls("llm_factory") - - op.create_table( - "llms", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("factory_id", sa.UUID(), nullable=False), - sa.Column("model_code", sa.String(length=50), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("model_code"), - ) - op.create_index("ix_llms_factory_id", "llms", ["factory_id"], unique=False) - op.create_index("ix_llms_model_code", "llms", ["model_code"], unique=True) - op.create_foreign_key( - "fk_llms_factory_id", - "llms", - "llm_factory", - ["factory_id"], - ["id"], - ondelete="RESTRICT", - ) - _enable_rls("llms") - - op.create_table( - "system_agents", - sa.Column("agent_type", sa.String(length=20), nullable=False), - sa.Column("llm_id", sa.UUID(), nullable=False), - sa.Column("status", sa.String(length=20), nullable=False), - sa.Column( - "config", - postgresql.JSONB(astext_type=sa.Text()), - server_default="{}", - nullable=False, - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("agent_type"), - ) - op.create_foreign_key( - "fk_system_agents_llm_id", - "system_agents", - "llms", - ["llm_id"], - ["id"], - ondelete="RESTRICT", - ) - _enable_rls("system_agents") - - op.execute("REVOKE ALL ON TABLE public.alembic_version FROM anon") - op.execute("REVOKE ALL ON TABLE public.alembic_version FROM authenticated") - - -def downgrade() -> None: - _drop_rls("system_agents") - op.drop_constraint("fk_system_agents_llm_id", "system_agents", type_="foreignkey") - op.drop_table("system_agents") - - _drop_rls("llms") - op.drop_constraint("fk_llms_factory_id", "llms", type_="foreignkey") - op.drop_index("ix_llms_model_code", table_name="llms") - op.drop_index("ix_llms_factory_id", table_name="llms") - op.drop_table("llms") - - _drop_rls("llm_factory") - op.drop_index("ix_llm_factory_name", table_name="llm_factory") - op.drop_table("llm_factory") - - -def _enable_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") - for role in ["anon", "authenticated"]: - op.execute( - f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" - ) - op.execute( - f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" - ) - - -def _drop_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260411_0002_chat_points_invite_schema.py b/backend/alembic/versions/20260411_0002_chat_points_invite_schema.py deleted file mode 100644 index daf3901..0000000 --- a/backend/alembic/versions/20260411_0002_chat_points_invite_schema.py +++ /dev/null @@ -1,579 +0,0 @@ -"""add chat, points, and invite schema - -Revision ID: 20260411_0002 -Revises: 20260411_0001 -Create Date: 2026-04-11 00:10:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -revision: str = "20260411_0002" -down_revision: Union[str, Sequence[str], None] = "20260411_0001" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "profiles", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("username", sa.String(length=30), nullable=False), - sa.Column("avatar_url", sa.Text(), nullable=True), - sa.Column("bio", sa.String(length=200), nullable=True), - sa.Column( - "settings", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - server_default=sa.text("'{}'::jsonb"), - ), - sa.Column("referred_by", sa.UUID(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.CheckConstraint( - "char_length(username) >= 1", name="ck_profiles_username_non_empty" - ), - sa.ForeignKeyConstraint(["id"], ["auth.users.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint(["referred_by"], ["profiles.id"], ondelete="SET NULL"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_profiles_username", "profiles", ["username"], unique=False) - op.create_index( - "ix_profiles_settings_gin", - "profiles", - ["settings"], - unique=False, - postgresql_using="gin", - ) - op.create_index( - "ix_profiles_referred_by", "profiles", ["referred_by"], unique=False - ) - _enable_rls("profiles") - - op.create_table( - "sessions", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("user_id", sa.UUID(), nullable=False), - sa.Column("session_type", sa.String(length=20), nullable=False), - sa.Column("job_id", sa.UUID(), nullable=True), - sa.Column("title", sa.String(length=255), nullable=True), - sa.Column("status", sa.String(length=20), nullable=False), - sa.Column( - "last_activity_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "message_count", - sa.Integer(), - server_default=sa.text("0"), - nullable=False, - ), - sa.Column( - "total_tokens", - sa.Integer(), - server_default=sa.text("0"), - nullable=False, - ), - sa.Column( - "total_cost", - sa.Numeric(12, 6), - server_default=sa.text("0"), - nullable=False, - ), - sa.Column( - "state_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.CheckConstraint( - "session_type in ('chat', 'automation')", name="ck_sessions_session_type" - ), - sa.CheckConstraint( - "status in ('pending', 'running', 'completed', 'failed')", - name="ck_sessions_status", - ), - sa.CheckConstraint( - "message_count >= 0", name="ck_sessions_message_count_non_negative" - ), - sa.CheckConstraint( - "total_tokens >= 0", name="ck_sessions_total_tokens_non_negative" - ), - sa.CheckConstraint( - "total_cost >= 0", name="ck_sessions_total_cost_non_negative" - ), - sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index("ix_sessions_user_id", "sessions", ["user_id"], unique=False) - op.create_index( - "ix_sessions_user_activity", - "sessions", - ["user_id", "last_activity_at"], - unique=False, - ) - _enable_rls("sessions") - - op.create_table( - "messages", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("session_id", sa.UUID(), nullable=False), - sa.Column("seq", sa.Integer(), nullable=False), - sa.Column("role", sa.String(length=20), nullable=False), - sa.Column("content", sa.Text(), nullable=False), - sa.Column("model_code", sa.String(length=50), nullable=True), - sa.Column("tool_name", sa.String(length=100), nullable=True), - sa.Column( - "input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False - ), - sa.Column( - "output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False - ), - sa.Column( - "cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False - ), - sa.Column("latency_ms", sa.Integer(), nullable=True), - sa.Column( - "visibility_mask", - sa.BigInteger(), - server_default=sa.text("0"), - nullable=False, - ), - sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.CheckConstraint("seq > 0", name="ck_messages_seq_positive"), - sa.CheckConstraint( - "role in ('user', 'assistant', 'system', 'tool')", name="ck_messages_role" - ), - sa.CheckConstraint( - "input_tokens >= 0", name="ck_messages_input_tokens_non_negative" - ), - sa.CheckConstraint( - "output_tokens >= 0", name="ck_messages_output_tokens_non_negative" - ), - sa.CheckConstraint("cost >= 0", name="ck_messages_cost_non_negative"), - sa.CheckConstraint( - "latency_ms is null or latency_ms >= 0", - name="ck_messages_latency_non_negative", - ), - sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("session_id", "seq", name="uq_messages_session_seq"), - ) - op.create_index("ix_messages_session_id", "messages", ["session_id"], unique=False) - op.create_index( - "ix_messages_session_seq_visibility", - "messages", - ["session_id", "seq", "visibility_mask"], - unique=False, - ) - _enable_rls("messages") - - op.create_table( - "user_points", - sa.Column("user_id", sa.UUID(), nullable=False), - sa.Column( - "balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False - ), - sa.Column( - "frozen_balance", - sa.BigInteger(), - server_default=sa.text("0"), - nullable=False, - ), - sa.Column( - "lifetime_earned", - sa.BigInteger(), - server_default=sa.text("0"), - nullable=False, - ), - sa.Column( - "lifetime_spent", - sa.BigInteger(), - server_default=sa.text("0"), - nullable=False, - ), - sa.Column("version", sa.Integer(), server_default=sa.text("0"), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.CheckConstraint("balance >= 0", name="ck_user_points_balance_non_negative"), - sa.CheckConstraint( - "frozen_balance >= 0", name="ck_user_points_frozen_balance_non_negative" - ), - sa.CheckConstraint( - "lifetime_earned >= 0", name="ck_user_points_lifetime_earned_non_negative" - ), - sa.CheckConstraint( - "lifetime_spent >= 0", name="ck_user_points_lifetime_spent_non_negative" - ), - sa.CheckConstraint( - "frozen_balance <= balance", name="ck_user_points_frozen_le_balance" - ), - sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("user_id"), - ) - _enable_rls("user_points") - - op.create_table( - "points_ledger", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("user_id", sa.UUID(), nullable=False), - sa.Column("direction", sa.SmallInteger(), nullable=False), - sa.Column("amount", sa.BigInteger(), nullable=False), - sa.Column("balance_after", sa.BigInteger(), nullable=False), - sa.Column("change_type", sa.String(length=16), nullable=False), - sa.Column("biz_type", sa.String(length=16), nullable=True), - sa.Column("biz_id", sa.UUID(), nullable=True), - sa.Column("event_id", sa.String(length=64), nullable=False), - sa.Column("operator_id", sa.UUID(), nullable=True), - sa.Column( - "metadata", - postgresql.JSONB(astext_type=sa.Text()), - nullable=False, - server_default=sa.text("'{}'::jsonb"), - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.CheckConstraint("amount > 0", name="ck_points_ledger_amount_positive"), - sa.CheckConstraint( - "direction in (1, -1)", name="ck_points_ledger_direction_valid" - ), - sa.CheckConstraint( - "balance_after >= 0", name="ck_points_ledger_balance_after_non_negative" - ), - sa.CheckConstraint( - "change_type in ('register', 'consume', 'grant', 'adjust')", - name="ck_points_ledger_change_type", - ), - sa.CheckConstraint( - "biz_type is null or biz_type = 'chat'", name="ck_points_ledger_biz_type" - ), - sa.CheckConstraint( - "((change_type = 'register' and biz_type is null and biz_id is null) or " - "(change_type in ('consume', 'grant', 'adjust') and biz_type = 'chat' and biz_id is not null))", - name="ck_points_ledger_biz_binding", - ), - sa.CheckConstraint( - "((change_type in ('register', 'grant') and direction = 1) or " - "(change_type = 'consume' and direction = -1) or " - "(change_type = 'adjust' and direction in (1, -1)))", - name="ck_points_ledger_direction_by_change_type", - ), - sa.CheckConstraint( - "jsonb_typeof(metadata) = 'object'", name="ck_points_ledger_metadata_object" - ), - sa.CheckConstraint( - "metadata->>'schema_version' = '1' and " - "metadata->>'operator_type' in ('user', 'system', 'admin') and " - "coalesce(metadata->>'run_id', '') <> '' and " - "(not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')", - name="ck_points_ledger_metadata_common", - ), - sa.CheckConstraint( - "(change_type <> 'register' or not (metadata ? 'charge'))", - name="ck_points_ledger_metadata_register_shape", - ), - sa.CheckConstraint( - "(change_type <> 'consume' or ((metadata ? 'charge') and jsonb_typeof(metadata->'charge') = 'object' and " - "(metadata->'charge' ? 'message_id') and (metadata->'charge' ? 'message_seq') and " - "(metadata->'charge' ? 'model_code') and (metadata->'charge' ? 'input_tokens') and " - "(metadata->'charge' ? 'output_tokens') and (metadata->'charge' ? 'cost')))", - name="ck_points_ledger_metadata_consume_shape", - ), - sa.CheckConstraint( - "(change_type <> 'adjust' or ((metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and " - "coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))", - name="ck_points_ledger_metadata_adjust_shape", - ), - sa.ForeignKeyConstraint(["biz_id"], ["sessions.id"], ondelete="RESTRICT"), - sa.ForeignKeyConstraint( - ["operator_id"], ["auth.users.id"], ondelete="SET NULL" - ), - sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"), - ) - op.create_index( - "ix_points_ledger_user_created_at", - "points_ledger", - ["user_id", sa.text("created_at DESC")], - unique=False, - ) - op.create_index( - "ix_points_ledger_biz_type_biz_id", - "points_ledger", - ["biz_type", "biz_id"], - unique=False, - ) - _enable_rls("points_ledger") - - op.execute( - """ - CREATE TABLE invite_codes ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(6) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$'), - owner_id UUID REFERENCES profiles(id) ON DELETE SET NULL, - status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'expired')), - used_count INTEGER NOT NULL DEFAULT 0 CHECK (used_count >= 0), - max_uses INTEGER CHECK (max_uses IS NULL OR max_uses >= 1), - expires_at TIMESTAMPTZ NULL, - reward_config JSONB NOT NULL DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() - ) - """ - ) - op.execute("CREATE INDEX ix_invite_codes_owner_id ON invite_codes(owner_id)") - op.execute( - "CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'" - ) - _enable_rls("invite_codes") - - op.execute( - """ - CREATE OR REPLACE FUNCTION public.generate_invite_code() - RETURNS TEXT - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = '' - AS $$ - DECLARE - chars TEXT := 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; - result TEXT := ''; - i INT; - BEGIN - FOR i IN 1..6 LOOP - result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1); - END LOOP; - RETURN result; - END; - $$; - """ - ) - - op.execute( - """ - CREATE OR REPLACE FUNCTION public.initialize_profile_and_invite_code_on_signup() - RETURNS trigger - LANGUAGE plpgsql - SECURITY DEFINER - SET search_path = public - AS $$ - DECLARE - v_username text; - v_invite_code text; - v_referrer_id uuid; - v_attempts int := 0; - invite_code_value text; - BEGIN - v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); - - INSERT INTO public.profiles (id, username, avatar_url, bio, settings) - VALUES ( - new.id, - v_username, - null, - null, - jsonb_build_object( - 'version', 1, - 'preferences', jsonb_build_object( - 'interface_language', 'zh-CN', - 'ai_language', 'zh-CN', - 'timezone', 'Asia/Shanghai', - 'country', 'CN' - ), - 'privacy', jsonb_build_object('profile_visibility', 'public'), - 'notification', jsonb_build_object( - 'allow_notifications', true, - 'allow_vibration', true - ) - ) - ) - ON CONFLICT (id) DO NOTHING; - - LOOP - BEGIN - v_invite_code := public.generate_invite_code(); - INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config) - VALUES ( - v_invite_code, - new.id, - 'active', - 0, - NULL, - NULL, - '{}'::jsonb - ); - EXIT; - EXCEPTION WHEN unique_violation THEN - v_attempts := v_attempts + 1; - IF v_attempts >= 100 THEN - RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts'; - END IF; - END; - END LOOP; - - invite_code_value := new.raw_user_meta_data ->> 'invite_code'; - IF invite_code_value IS NOT NULL AND length(invite_code_value) = 6 THEN - invite_code_value := upper(invite_code_value); - IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$' THEN - UPDATE public.invite_codes - SET used_count = used_count + 1 - WHERE code = invite_code_value - AND status = 'active' - AND (max_uses IS NULL OR used_count < max_uses) - AND (expires_at IS NULL OR expires_at > NOW()) - RETURNING owner_id INTO v_referrer_id; - - IF v_referrer_id IS NOT NULL THEN - UPDATE public.profiles - SET referred_by = v_referrer_id - WHERE id = new.id; - END IF; - END IF; - END IF; - - RETURN NEW; - END; - $$; - """ - ) - - op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") - op.execute( - "DROP TRIGGER IF EXISTS trg_initialize_profile_and_points_on_signup ON auth.users" - ) - op.execute( - """ - CREATE TRIGGER on_auth_user_created - AFTER INSERT ON auth.users - FOR EACH ROW - EXECUTE FUNCTION public.initialize_profile_and_invite_code_on_signup() - """ - ) - - -def downgrade() -> None: - op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") - op.execute( - "DROP FUNCTION IF EXISTS public.initialize_profile_and_invite_code_on_signup()" - ) - op.execute("DROP FUNCTION IF EXISTS public.generate_invite_code()") - - _drop_rls("invite_codes") - op.execute("DROP INDEX IF EXISTS ix_invite_codes_code") - op.execute("DROP INDEX IF EXISTS ix_invite_codes_owner_id") - op.execute("DROP TABLE IF EXISTS invite_codes") - - _drop_rls("points_ledger") - op.drop_index("ix_points_ledger_biz_type_biz_id", table_name="points_ledger") - op.drop_index("ix_points_ledger_user_created_at", table_name="points_ledger") - op.drop_table("points_ledger") - - _drop_rls("user_points") - op.drop_table("user_points") - - _drop_rls("messages") - op.drop_index("ix_messages_session_seq_visibility", table_name="messages") - op.drop_index("ix_messages_session_id", table_name="messages") - op.drop_table("messages") - - _drop_rls("sessions") - op.drop_index("ix_sessions_user_activity", table_name="sessions") - op.drop_index("ix_sessions_user_id", table_name="sessions") - op.drop_table("sessions") - - _drop_rls("profiles") - op.drop_index("ix_profiles_referred_by", table_name="profiles") - op.drop_index("ix_profiles_settings_gin", table_name="profiles") - op.drop_index("ix_profiles_username", table_name="profiles") - op.drop_table("profiles") - - -def _enable_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") - for role in ["anon", "authenticated"]: - op.execute( - f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" - ) - op.execute( - f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" - ) - - -def _drop_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260411_0002_users_chat_points_invites.py b/backend/alembic/versions/20260411_0002_users_chat_points_invites.py new file mode 100644 index 0000000..4481707 --- /dev/null +++ b/backend/alembic/versions/20260411_0002_users_chat_points_invites.py @@ -0,0 +1,392 @@ +"""Create user, chat, points, and invite schema. + +Revision ID: 20260428_squash_0002 +Revises: 20260428_squash_0001 +Create Date: 2026-04-11 00:10:00 + +Squashed history: builds the final profiles settings shape directly, removes +the obsolete points_ledger.biz_id FK, and creates the final signup trigger. +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260428_squash_0002" +down_revision: Union[str, Sequence[str], None] = "20260428_squash_0001" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + _create_profiles() + _create_chat_tables() + _create_points_tables() + _create_invite_codes() + _create_signup_helpers() + + +def downgrade() -> None: + op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") + op.execute("DROP FUNCTION IF EXISTS public.initialize_profile_and_invite_code_on_signup()") + op.execute("DROP FUNCTION IF EXISTS public.generate_invite_code()") + + for table_name in [ + "invite_codes", + "register_bonus_claims", + "points_audit_ledger", + "points_ledger", + "user_points", + "messages", + "sessions", + "profiles", + ]: + _drop_service_only_rls(table_name) + op.drop_table("invite_codes") + op.drop_table("register_bonus_claims") + op.drop_table("points_audit_ledger") + op.drop_table("points_ledger") + op.drop_table("user_points") + op.drop_table("messages") + op.drop_table("sessions") + op.drop_table("profiles") + + +def _create_profiles() -> None: + op.create_table( + "profiles", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("username", sa.String(length=30), nullable=False), + sa.Column("avatar_url", sa.Text(), nullable=True), + sa.Column("bio", sa.String(length=200), nullable=True), + sa.Column("settings", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("referred_by", sa.UUID(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("char_length(username) >= 1", name="ck_profiles_username_non_empty"), + sa.ForeignKeyConstraint(["id"], ["auth.users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["referred_by"], ["profiles.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_profiles_username", "profiles", ["username"]) + op.create_index("ix_profiles_settings_gin", "profiles", ["settings"], postgresql_using="gin") + op.create_index("ix_profiles_referred_by", "profiles", ["referred_by"]) + _enable_service_only_rls("profiles") + + +def _create_chat_tables() -> None: + op.create_table( + "sessions", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("session_type", sa.String(length=20), nullable=False), + sa.Column("job_id", sa.UUID(), nullable=True), + sa.Column("title", sa.String(length=255), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("last_activity_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("message_count", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("total_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("total_cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False), + sa.Column("state_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("session_type in ('chat', 'automation')", name="ck_sessions_session_type"), + sa.CheckConstraint("status in ('pending', 'running', 'completed', 'failed')", name="ck_sessions_status"), + sa.CheckConstraint("message_count >= 0", name="ck_sessions_message_count_non_negative"), + sa.CheckConstraint("total_tokens >= 0", name="ck_sessions_total_tokens_non_negative"), + sa.CheckConstraint("total_cost >= 0", name="ck_sessions_total_cost_non_negative"), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_sessions_user_id", "sessions", ["user_id"]) + op.create_index("ix_sessions_user_activity", "sessions", ["user_id", "last_activity_at"]) + _enable_service_only_rls("sessions") + + op.create_table( + "messages", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("session_id", sa.UUID(), nullable=False), + sa.Column("seq", sa.Integer(), nullable=False), + sa.Column("role", sa.String(length=20), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("model_code", sa.String(length=50), nullable=True), + sa.Column("tool_name", sa.String(length=100), nullable=True), + sa.Column("input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False), + sa.Column("latency_ms", sa.Integer(), nullable=True), + sa.Column("visibility_mask", sa.BigInteger(), server_default=sa.text("0"), nullable=False), + sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("seq > 0", name="ck_messages_seq_positive"), + sa.CheckConstraint("role in ('user', 'assistant', 'system', 'tool')", name="ck_messages_role"), + sa.CheckConstraint("input_tokens >= 0", name="ck_messages_input_tokens_non_negative"), + sa.CheckConstraint("output_tokens >= 0", name="ck_messages_output_tokens_non_negative"), + sa.CheckConstraint("cost >= 0", name="ck_messages_cost_non_negative"), + sa.CheckConstraint("latency_ms is null or latency_ms >= 0", name="ck_messages_latency_non_negative"), + sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("session_id", "seq", name="uq_messages_session_seq"), + ) + op.create_index("ix_messages_session_id", "messages", ["session_id"]) + op.create_index("ix_messages_session_seq_visibility", "messages", ["session_id", "seq", "visibility_mask"]) + _enable_service_only_rls("messages") + + +def _create_points_tables() -> None: + op.create_table( + "user_points", + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False), + sa.Column("frozen_balance", sa.BigInteger(), server_default=sa.text("0"), nullable=False), + sa.Column("lifetime_earned", sa.BigInteger(), server_default=sa.text("0"), nullable=False), + sa.Column("lifetime_spent", sa.BigInteger(), server_default=sa.text("0"), nullable=False), + sa.Column("version", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("balance >= 0", name="ck_user_points_balance_non_negative"), + sa.CheckConstraint("frozen_balance >= 0", name="ck_user_points_frozen_balance_non_negative"), + sa.CheckConstraint("lifetime_earned >= 0", name="ck_user_points_lifetime_earned_non_negative"), + sa.CheckConstraint("lifetime_spent >= 0", name="ck_user_points_lifetime_spent_non_negative"), + sa.CheckConstraint("frozen_balance <= balance", name="ck_user_points_frozen_le_balance"), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("user_id"), + ) + _enable_service_only_rls("user_points") + + op.create_table( + "points_ledger", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("direction", sa.SmallInteger(), nullable=False), + sa.Column("amount", sa.BigInteger(), nullable=False), + sa.Column("balance_after", sa.BigInteger(), nullable=False), + sa.Column("change_type", sa.String(length=16), nullable=False), + sa.Column("biz_type", sa.String(length=16), nullable=True), + sa.Column("biz_id", sa.UUID(), nullable=True), + sa.Column("event_id", sa.String(length=64), nullable=False), + sa.Column("operator_id", sa.UUID(), nullable=True), + sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("amount > 0", name="ck_points_ledger_amount_positive"), + sa.CheckConstraint("direction in (1, -1)", name="ck_points_ledger_direction_valid"), + sa.CheckConstraint("balance_after >= 0", name="ck_points_ledger_balance_after_non_negative"), + sa.CheckConstraint("change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_ledger_change_type"), + sa.CheckConstraint("biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_ledger_biz_type"), + sa.CheckConstraint("((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or (change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or (change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))", name="ck_points_ledger_biz_binding"), + sa.CheckConstraint("((change_type in ('register', 'purchase') and direction = 1) or (change_type in ('consume', 'refund') and direction = -1) or (change_type = 'adjust' and direction in (1, -1)))", name="ck_points_ledger_direction_by_change_type"), + sa.CheckConstraint("jsonb_typeof(metadata) = 'object'", name="ck_points_ledger_metadata_object"), + sa.CheckConstraint("metadata->>'schema_version' = '1' and metadata->>'operator_type' in ('user', 'system', 'admin') and coalesce(metadata->>'run_id', '') <> '' and (not (metadata ? 'ext') or jsonb_typeof(metadata->'ext') = 'object')", name="ck_points_ledger_metadata_common"), + sa.CheckConstraint("(change_type <> 'register' or not (metadata ? 'charge'))", name="ck_points_ledger_metadata_register_shape"), + sa.CheckConstraint("(change_type <> 'consume' or ((metadata ? 'charge') and jsonb_typeof(metadata->'charge') = 'object' and (metadata->'charge' ? 'message_id') and (metadata->'charge' ? 'message_seq') and (metadata->'charge' ? 'model_code') and (metadata->'charge' ? 'input_tokens') and (metadata->'charge' ? 'output_tokens') and (metadata->'charge' ? 'cost')))", name="ck_points_ledger_metadata_consume_shape"), + sa.CheckConstraint("(change_type <> 'adjust' or ((metadata ? 'ext') and (metadata->'ext' ? 'reason') and coalesce(metadata #>> '{ext,reason}', '') <> ''))", name="ck_points_ledger_metadata_adjust_shape"), + sa.CheckConstraint("(change_type not in ('purchase', 'refund') or ((metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and (metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and coalesce(metadata #>> '{ext,source}', '') <> '' and coalesce(metadata #>> '{ext,platform}', '') <> '' and coalesce(metadata #>> '{ext,product_code}', '') <> '' and coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))", name="ck_points_ledger_metadata_payment_shape"), + sa.CheckConstraint("(change_type <> 'refund' or ((metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))", name="ck_points_ledger_metadata_refund_shape"), + sa.ForeignKeyConstraint(["operator_id"], ["auth.users.id"], ondelete="SET NULL"), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"), + ) + op.create_index("ix_points_ledger_user_created_at", "points_ledger", ["user_id", sa.text("created_at DESC")]) + op.create_index("ix_points_ledger_biz_type_biz_id", "points_ledger", ["biz_type", "biz_id"]) + _enable_service_only_rls("points_ledger") + + op.create_table( + "points_audit_ledger", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("event_id", sa.String(length=64), nullable=False), + sa.Column("user_id_snapshot", sa.UUID(), nullable=True), + sa.Column("user_email_snapshot", sa.Text(), nullable=True), + sa.Column("change_type", sa.String(length=16), nullable=False), + sa.Column("biz_type", sa.String(length=16), nullable=True), + sa.Column("biz_id", sa.UUID(), nullable=True), + sa.Column("direction", sa.SmallInteger(), nullable=False), + sa.Column("amount", sa.BigInteger(), nullable=False), + sa.Column("balance_after", sa.BigInteger(), nullable=False), + sa.Column("billed_to", sa.String(length=16), nullable=False), + sa.Column("run_id", sa.String(length=128), nullable=True), + sa.Column("request_id", sa.String(length=128), nullable=True), + sa.Column("input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False), + sa.Column("cost", sa.Numeric(12, 6), server_default=sa.text("0"), nullable=False), + sa.Column("metadata", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("amount >= 0", name="ck_points_audit_ledger_amount_non_negative"), + sa.CheckConstraint("direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid"), + sa.CheckConstraint("balance_after >= 0", name="ck_points_audit_ledger_balance_after_non_negative"), + sa.CheckConstraint("change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_audit_ledger_change_type"), + sa.CheckConstraint("biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_audit_ledger_biz_type"), + sa.CheckConstraint("billed_to in ('user', 'platform')", name="ck_points_audit_ledger_billed_to"), + sa.CheckConstraint("jsonb_typeof(metadata) = 'object'", name="ck_points_audit_ledger_metadata_object"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("event_id", name="uq_points_audit_ledger_event_id"), + ) + op.create_index("ix_points_audit_ledger_user_id_created_at", "points_audit_ledger", ["user_id_snapshot", sa.text("created_at DESC")]) + op.create_index("ix_points_audit_ledger_change_type_created_at", "points_audit_ledger", ["change_type", sa.text("created_at DESC")]) + _enable_service_only_rls("points_audit_ledger") + + op.create_table( + "register_bonus_claims", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("email_hash", sa.String(length=64), nullable=False), + sa.Column("user_email_snapshot", sa.Text(), nullable=False), + sa.Column("first_user_id_snapshot", sa.UUID(), nullable=True), + sa.Column("balance_snapshot", sa.BigInteger(), nullable=True), + sa.Column("grant_event_id", sa.String(length=64), nullable=False), + sa.Column("has_purchased_starter_pack", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("email_hash", name="uq_register_bonus_claims_email_hash"), + sa.UniqueConstraint("grant_event_id", name="uq_register_bonus_claims_grant_event_id"), + ) + _enable_service_only_rls("register_bonus_claims") + + +def _create_invite_codes() -> None: + op.execute( + """ + CREATE TABLE invite_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(6) NOT NULL UNIQUE CHECK (code ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$'), + owner_id UUID REFERENCES profiles(id) ON DELETE SET NULL, + status VARCHAR(20) NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'disabled', 'expired')), + used_count INTEGER NOT NULL DEFAULT 0 CHECK (used_count >= 0), + max_uses INTEGER CHECK (max_uses IS NULL OR max_uses >= 1), + expires_at TIMESTAMPTZ NULL, + reward_config JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + ) + """ + ) + op.execute("CREATE INDEX ix_invite_codes_owner_id ON invite_codes(owner_id)") + op.execute("CREATE INDEX ix_invite_codes_code ON invite_codes(code) WHERE status = 'active'") + _enable_service_only_rls("invite_codes") + + +def _create_signup_helpers() -> None: + op.execute( + """ + CREATE OR REPLACE FUNCTION public.generate_invite_code() + RETURNS TEXT + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = '' + AS $$ + DECLARE + chars TEXT := 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + result TEXT := ''; + i INT; + BEGIN + FOR i IN 1..6 LOOP + result := result || substr(chars, floor(random() * length(chars) + 1)::int, 1); + END LOOP; + RETURN result; + END; + $$; + """ + ) + op.execute( + """ + CREATE OR REPLACE FUNCTION public.initialize_profile_and_invite_code_on_signup() + RETURNS trigger + LANGUAGE plpgsql + SECURITY DEFINER + SET search_path = public + AS $$ + DECLARE + v_username text; + v_invite_code text; + v_referrer_id uuid; + v_attempts int := 0; + invite_code_value text; + BEGIN + v_username := 'user_' || substring(md5(new.id::text || clock_timestamp()::text || random()::text) from 1 for 6); + + INSERT INTO public.profiles (id, username, avatar_url, bio, settings) + VALUES ( + new.id, + v_username, + null, + null, + jsonb_build_object( + 'version', 1, + 'preferences', jsonb_build_object('language', 'zh-CN', 'timezone', 'Asia/Shanghai'), + 'privacy', jsonb_build_object('can_sell', false, 'profile_visibility', 'public'), + 'notification', jsonb_build_object('allow_notifications', true, 'allow_vibration', true), + 'divination_tutorial', jsonb_build_object( + 'divination_entry_shown', false, + 'auto_divination_shown', false, + 'manual_divination_shown', false + ) + ) + ) + ON CONFLICT (id) DO NOTHING; + + LOOP + BEGIN + v_invite_code := public.generate_invite_code(); + INSERT INTO public.invite_codes (code, owner_id, status, used_count, max_uses, expires_at, reward_config) + VALUES (v_invite_code, new.id, 'active', 0, NULL, NULL, '{}'::jsonb); + EXIT; + EXCEPTION WHEN unique_violation THEN + v_attempts := v_attempts + 1; + IF v_attempts >= 100 THEN + RAISE EXCEPTION 'Failed to generate unique invite code after 100 attempts'; + END IF; + END; + END LOOP; + + invite_code_value := new.raw_user_meta_data ->> 'invite_code'; + IF invite_code_value IS NOT NULL AND length(invite_code_value) = 6 THEN + invite_code_value := upper(invite_code_value); + IF invite_code_value ~ '^[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}$' THEN + UPDATE public.invite_codes + SET used_count = used_count + 1 + WHERE code = invite_code_value + AND status = 'active' + AND (max_uses IS NULL OR used_count < max_uses) + AND (expires_at IS NULL OR expires_at > NOW()) + RETURNING owner_id INTO v_referrer_id; + + IF v_referrer_id IS NOT NULL THEN + UPDATE public.profiles SET referred_by = v_referrer_id WHERE id = new.id; + END IF; + END IF; + END IF; + + RETURN NEW; + END; + $$; + """ + ) + op.execute("DROP TRIGGER IF EXISTS on_auth_user_created ON auth.users") + op.execute("CREATE TRIGGER on_auth_user_created AFTER INSERT ON auth.users FOR EACH ROW EXECUTE FUNCTION public.initialize_profile_and_invite_code_on_signup()") + + +def _enable_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") + op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + + +def _drop_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260411_0003_notifications.py b/backend/alembic/versions/20260411_0003_notifications.py new file mode 100644 index 0000000..6e441a7 --- /dev/null +++ b/backend/alembic/versions/20260411_0003_notifications.py @@ -0,0 +1,101 @@ +"""Create notification inbox schema. + +Revision ID: 20260428_squash_0003 +Revises: 20260428_squash_0002 +Create Date: 2026-04-11 12:00:00 + +Squashed history: creates notifications with static-sync fields, target_mode, +and final i18n jsonb title/body columns in one step. +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260428_squash_0003" +down_revision: Union[str, Sequence[str], None] = "20260428_squash_0002" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "notifications", + sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("type", sa.String(length=32), server_default=sa.text("'system'"), nullable=False), + sa.Column("source", sa.String(length=32), server_default=sa.text("'manual'"), nullable=False), + sa.Column("source_key", sa.String(length=128), nullable=True), + sa.Column("source_version", sa.Integer(), nullable=True), + sa.Column("content_hash", sa.String(length=64), nullable=True), + sa.Column("title", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("body", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("status", sa.String(length=16), server_default=sa.text("'published'"), nullable=False), + sa.Column("target_mode", sa.String(length=32), server_default=sa.text("'all_users'"), nullable=False), + sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), + sa.CheckConstraint("status IN ('draft', 'published', 'revoked')", name="ck_notifications_status"), + sa.CheckConstraint("target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids')", name="ck_notifications_target_mode"), + sa.CheckConstraint("jsonb_typeof(payload) = 'object'", name="ck_notifications_payload_object"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_notifications_status_created_at", "notifications", ["status", sa.text("created_at DESC")]) + op.create_index("ix_notifications_published_at", "notifications", [sa.text("published_at DESC")]) + op.create_index( + "uq_notifications_source_source_key", + "notifications", + ["source", "source_key"], + unique=True, + postgresql_where=sa.text("source_key IS NOT NULL"), + ) + _enable_service_only_rls("notifications") + + op.create_table( + "user_notifications", + sa.Column("id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("user_id", sa.UUID(), nullable=False), + sa.Column("notification_id", sa.UUID(), nullable=False), + sa.Column("is_read", sa.Boolean(), server_default=sa.text("false"), nullable=False), + sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["notification_id"], ["notifications.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "notification_id", name="uq_user_notifications_user_notification"), + ) + op.create_index("ix_user_notifications_user_created_at", "user_notifications", ["user_id", sa.text("created_at DESC")]) + op.create_index("ix_user_notifications_user_unread", "user_notifications", ["user_id", "is_read"]) + _enable_service_only_rls("user_notifications") + + +def downgrade() -> None: + _drop_service_only_rls("user_notifications") + op.drop_table("user_notifications") + + _drop_service_only_rls("notifications") + op.drop_table("notifications") + + +def _enable_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") + op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + + +def _drop_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260411_0003_points_audit_and_register_bonus_claims.py b/backend/alembic/versions/20260411_0003_points_audit_and_register_bonus_claims.py deleted file mode 100644 index 3481953..0000000 --- a/backend/alembic/versions/20260411_0003_points_audit_and_register_bonus_claims.py +++ /dev/null @@ -1,190 +0,0 @@ -"""add points audit ledger and register bonus claims - -Revision ID: 20260411_0003 -Revises: 20260411_0002 -Create Date: 2026-04-11 00:20:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -revision: str = "20260411_0003" -down_revision: Union[str, Sequence[str], None] = "20260411_0002" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.execute( - "DROP TRIGGER IF EXISTS trg_initialize_profile_and_points_on_signup ON auth.users" - ) - op.execute( - "DROP FUNCTION IF EXISTS public.initialize_profile_and_points_on_signup()" - ) - - op.create_table( - "points_audit_ledger", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("event_id", sa.String(length=64), nullable=False), - sa.Column("user_id_snapshot", sa.UUID(), nullable=True), - sa.Column("user_email_snapshot", sa.Text(), nullable=True), - sa.Column("change_type", sa.String(length=16), nullable=False), - sa.Column("biz_type", sa.String(length=16), nullable=True), - sa.Column("biz_id", sa.UUID(), nullable=True), - sa.Column("direction", sa.SmallInteger(), nullable=False), - sa.Column("amount", sa.BigInteger(), nullable=False), - sa.Column("balance_after", sa.BigInteger(), nullable=False), - sa.Column("billed_to", sa.String(length=16), nullable=False), - sa.Column("run_id", sa.String(length=128), nullable=True), - sa.Column("request_id", sa.String(length=128), nullable=True), - sa.Column( - "input_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False - ), - sa.Column( - "output_tokens", sa.Integer(), server_default=sa.text("0"), nullable=False - ), - sa.Column( - "cost", - sa.Numeric(precision=12, scale=6), - server_default=sa.text("0"), - nullable=False, - ), - sa.Column( - "metadata", - postgresql.JSONB(astext_type=sa.Text()), - server_default=sa.text("'{}'::jsonb"), - nullable=False, - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.CheckConstraint( - "amount >= 0", name="ck_points_audit_ledger_amount_non_negative" - ), - sa.CheckConstraint( - "direction in (1, 0, -1)", name="ck_points_audit_ledger_direction_valid" - ), - sa.CheckConstraint( - "balance_after >= 0", - name="ck_points_audit_ledger_balance_after_non_negative", - ), - sa.CheckConstraint( - "change_type in ('register', 'consume', 'grant', 'adjust')", - name="ck_points_audit_ledger_change_type", - ), - sa.CheckConstraint( - "biz_type is null or biz_type = 'chat'", - name="ck_points_audit_ledger_biz_type", - ), - sa.CheckConstraint( - "billed_to in ('user', 'platform')", name="ck_points_audit_ledger_billed_to" - ), - sa.CheckConstraint( - "jsonb_typeof(metadata) = 'object'", - name="ck_points_audit_ledger_metadata_object", - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("event_id", name="uq_points_audit_ledger_event_id"), - ) - op.create_index( - "ix_points_audit_ledger_user_id_created_at", - "points_audit_ledger", - ["user_id_snapshot", sa.text("created_at DESC")], - unique=False, - ) - op.create_index( - "ix_points_audit_ledger_change_type_created_at", - "points_audit_ledger", - ["change_type", sa.text("created_at DESC")], - unique=False, - ) - _enable_rls("points_audit_ledger") - - op.create_table( - "register_bonus_claims", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("email_hash", sa.String(length=64), nullable=False), - sa.Column("user_email_snapshot", sa.Text(), nullable=False), - sa.Column("first_user_id", sa.UUID(), nullable=True), - sa.Column("grant_event_id", sa.String(length=64), nullable=False), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint( - ["first_user_id"], ["auth.users.id"], ondelete="SET NULL" - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint("email_hash", name="uq_register_bonus_claims_email_hash"), - sa.UniqueConstraint( - "grant_event_id", name="uq_register_bonus_claims_grant_event_id" - ), - ) - _enable_rls("register_bonus_claims") - - -def downgrade() -> None: - _drop_rls("register_bonus_claims") - op.drop_table("register_bonus_claims") - - _drop_rls("points_audit_ledger") - op.drop_index( - "ix_points_audit_ledger_change_type_created_at", - table_name="points_audit_ledger", - ) - op.drop_index( - "ix_points_audit_ledger_user_id_created_at", - table_name="points_audit_ledger", - ) - op.drop_table("points_audit_ledger") - - -def _enable_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") - for role in ["anon", "authenticated"]: - op.execute( - f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" - ) - op.execute( - f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" - ) - - -def _drop_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260411_0004_add_notifications_tables.py b/backend/alembic/versions/20260411_0004_add_notifications_tables.py deleted file mode 100644 index 1bab5d4..0000000 --- a/backend/alembic/versions/20260411_0004_add_notifications_tables.py +++ /dev/null @@ -1,170 +0,0 @@ -"""add notifications and user_notifications tables - -Revision ID: 20260411_0004 -Revises: 20260411_0003 -Create Date: 2026-04-11 12:00:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -revision: str = "20260411_0004" -down_revision: Union[str, Sequence[str], None] = "20260411_0003" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "notifications", - sa.Column( - "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False - ), - sa.Column( - "type", - sa.String(length=32), - server_default=sa.text("'system'"), - nullable=False, - ), - sa.Column("title", sa.Text(), nullable=False), - sa.Column("body", sa.Text(), nullable=False), - sa.Column( - "payload", - postgresql.JSONB(astext_type=sa.Text()), - server_default=sa.text("'{}'::jsonb"), - nullable=False, - ), - sa.Column( - "status", - sa.String(length=16), - server_default=sa.text("'published'"), - nullable=False, - ), - sa.Column("published_at", sa.DateTime(timezone=True), nullable=True), - sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column("deleted_at", sa.DateTime(timezone=True), nullable=True), - sa.CheckConstraint( - "status IN ('draft', 'published', 'revoked')", - name="ck_notifications_status", - ), - sa.CheckConstraint( - "jsonb_typeof(payload) = 'object'", - name="ck_notifications_payload_object", - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "ix_notifications_status_created_at", - "notifications", - ["status", sa.text("created_at DESC")], - ) - op.create_index( - "ix_notifications_published_at", - "notifications", - [sa.text("published_at DESC")], - ) - _enable_rls("notifications") - - op.create_table( - "user_notifications", - sa.Column( - "id", sa.UUID(), server_default=sa.text("gen_random_uuid()"), nullable=False - ), - sa.Column("user_id", sa.UUID(), nullable=False), - sa.Column("notification_id", sa.UUID(), nullable=False), - sa.Column( - "is_read", sa.Boolean(), server_default=sa.text("false"), nullable=False - ), - sa.Column("read_at", sa.DateTime(timezone=True), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="CASCADE"), - sa.ForeignKeyConstraint( - ["notification_id"], ["notifications.id"], ondelete="CASCADE" - ), - sa.PrimaryKeyConstraint("id"), - sa.UniqueConstraint( - "user_id", "notification_id", name="uq_user_notifications_user_notification" - ), - ) - op.create_index( - "ix_user_notifications_user_created_at", - "user_notifications", - ["user_id", sa.text("created_at DESC")], - ) - op.create_index( - "ix_user_notifications_user_unread", - "user_notifications", - ["user_id", "is_read"], - ) - _enable_rls("user_notifications") - - -def downgrade() -> None: - _drop_rls("user_notifications") - op.drop_index("ix_user_notifications_user_unread", table_name="user_notifications") - op.drop_index( - "ix_user_notifications_user_created_at", table_name="user_notifications" - ) - op.drop_table("user_notifications") - - _drop_rls("notifications") - op.drop_index("ix_notifications_published_at", table_name="notifications") - op.drop_index("ix_notifications_status_created_at", table_name="notifications") - op.drop_table("notifications") - - -def _enable_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") - for role in ["anon", "authenticated"]: - op.execute( - f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)" - ) - op.execute( - f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)" - ) - op.execute( - f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)" - ) - - -def _drop_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260411_0005_add_notification_static_sync_fields.py b/backend/alembic/versions/20260411_0005_add_notification_static_sync_fields.py deleted file mode 100644 index 60bef9b..0000000 --- a/backend/alembic/versions/20260411_0005_add_notification_static_sync_fields.py +++ /dev/null @@ -1,55 +0,0 @@ -"""add notification static sync fields - -Revision ID: 20260411_0005 -Revises: 20260411_0004 -Create Date: 2026-04-11 16:00:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -revision: str = "20260411_0005" -down_revision: Union[str, Sequence[str], None] = "20260411_0004" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "notifications", - sa.Column( - "source", - sa.String(length=32), - server_default=sa.text("'manual'"), - nullable=False, - ), - ) - op.add_column( - "notifications", - sa.Column("source_key", sa.String(length=128), nullable=True), - ) - op.add_column( - "notifications", - sa.Column("source_version", sa.Integer(), nullable=True), - ) - op.add_column( - "notifications", - sa.Column("content_hash", sa.String(length=64), nullable=True), - ) - op.create_index( - "uq_notifications_source_source_key", - "notifications", - ["source", "source_key"], - unique=True, - postgresql_where=sa.text("source_key IS NOT NULL"), - ) - - -def downgrade() -> None: - op.drop_index("uq_notifications_source_source_key", table_name="notifications") - op.drop_column("notifications", "content_hash") - op.drop_column("notifications", "source_version") - op.drop_column("notifications", "source_key") - op.drop_column("notifications", "source") diff --git a/backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py b/backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py deleted file mode 100644 index 14979d9..0000000 --- a/backend/alembic/versions/20260413_0004_register_bonus_claims_snapshot.py +++ /dev/null @@ -1,50 +0,0 @@ -"""store register bonus balance snapshot and remove first_user_id fk - -Revision ID: 20260413_0004 -Revises: 20260411_0005 -Create Date: 2026-04-13 00:10:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -revision: str = "20260413_0004" -down_revision: Union[str, Sequence[str], None] = "20260411_0005" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.execute( - "ALTER TABLE register_bonus_claims DROP CONSTRAINT IF EXISTS register_bonus_claims_first_user_id_fkey" - ) - op.drop_column("register_bonus_claims", "first_user_id") - op.add_column( - "register_bonus_claims", - sa.Column("first_user_id_snapshot", sa.UUID(), nullable=True), - ) - op.add_column( - "register_bonus_claims", - sa.Column("balance_snapshot", sa.BigInteger(), nullable=True), - ) - - -def downgrade() -> None: - op.drop_column("register_bonus_claims", "balance_snapshot") - op.drop_column("register_bonus_claims", "first_user_id_snapshot") - op.add_column( - "register_bonus_claims", - sa.Column("first_user_id", sa.UUID(), nullable=True), - ) - op.create_foreign_key( - "register_bonus_claims_first_user_id_fkey", - "register_bonus_claims", - "users", - ["first_user_id"], - ["id"], - source_schema="public", - referent_schema="auth", - ondelete="SET NULL", - ) diff --git a/backend/alembic/versions/20260415_0001_anonymous_session_snapshots.py b/backend/alembic/versions/20260415_0001_anonymous_session_snapshots.py deleted file mode 100644 index 54b5bef..0000000 --- a/backend/alembic/versions/20260415_0001_anonymous_session_snapshots.py +++ /dev/null @@ -1,111 +0,0 @@ -"""add anonymous_session_snapshots table for iOS compliance - -Revision ID: 20260415_0001 -Revises: 20260413_0004 -Create Date: 2026-04-15 00:10:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -revision: str = "20260415_0001" -down_revision: Union[str, Sequence[str], None] = "20260413_0004" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "anonymous_session_snapshots", - sa.Column("id", sa.UUID(), nullable=False), - sa.Column("anonymous_id", sa.UUID(), nullable=False), - sa.Column("session_type", sa.String(length=20), nullable=False), - sa.Column("message_count", sa.Integer(), nullable=True), - sa.Column("status", sa.String(length=20), nullable=True), - sa.Column("question_type", sa.String(length=50), nullable=True), - sa.Column("tool_name", sa.String(length=100), nullable=True), - sa.Column("gua_name", sa.String(length=50), nullable=True), - sa.Column("gua_name_hant", sa.String(length=50), nullable=True), - sa.Column("target_gua_name", sa.String(length=50), nullable=True), - sa.Column("has_changing_yao", sa.Boolean(), nullable=True), - sa.Column("sign_level", sa.String(length=20), nullable=True), - sa.Column("keywords", postgresql.ARRAY(sa.Text()), nullable=True), - sa.Column("model_code", sa.String(length=50), nullable=True), - sa.Column("total_tokens", sa.Integer(), nullable=True), - sa.Column("total_cost", sa.Numeric(12, 6), nullable=True), - sa.Column("total_latency_ms", sa.Integer(), nullable=True), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - nullable=False, - ), - sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True), - sa.Column( - "anonymized_at", - sa.DateTime(timezone=True), - server_default=sa.text("now()"), - nullable=False, - ), - sa.PrimaryKeyConstraint("id"), - ) - op.create_index( - "ix_anonymous_session_snapshots_anonymous_id", - "anonymous_session_snapshots", - ["anonymous_id"], - unique=False, - ) - op.create_index( - "ix_anonymous_session_snapshots_created_at", - "anonymous_session_snapshots", - ["created_at"], - unique=False, - ) - op.create_index( - "ix_anonymous_session_snapshots_question_type", - "anonymous_session_snapshots", - ["question_type"], - unique=False, - ) - _enable_service_role_only_rls("anonymous_session_snapshots") - - -def downgrade() -> None: - _drop_rls("anonymous_session_snapshots") - op.drop_index( - "ix_anonymous_session_snapshots_question_type", - table_name="anonymous_session_snapshots", - ) - op.drop_index( - "ix_anonymous_session_snapshots_created_at", - table_name="anonymous_session_snapshots", - ) - op.drop_index( - "ix_anonymous_session_snapshots_anonymous_id", - table_name="anonymous_session_snapshots", - ) - op.drop_table("anonymous_session_snapshots") - - -def _enable_service_role_only_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") - op.execute( - f"CREATE POLICY service_role_all_{table_name} ON {table_name} FOR ALL TO service_role USING (true) WITH CHECK (true)" - ) - - -def _drop_rls(table_name: str) -> None: - for role in ["anon", "authenticated"]: - for action in ["select", "insert", "update", "delete"]: - op.execute( - f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}" - ) - op.execute(f"DROP POLICY IF EXISTS service_role_all_{table_name} ON {table_name}") - op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260415_0001_compliance_and_feedback.py b/backend/alembic/versions/20260415_0001_compliance_and_feedback.py new file mode 100644 index 0000000..382117b --- /dev/null +++ b/backend/alembic/versions/20260415_0001_compliance_and_feedback.py @@ -0,0 +1,97 @@ +"""Create compliance snapshot and feedback tables. + +Revision ID: 20260428_squash_0004 +Revises: 20260428_squash_0003 +Create Date: 2026-04-15 00:10:00 + +Squashed history: keeps iOS anonymous-session snapshots and user feedback as +separate product surfaces while removing the intervening unrelated revisions. +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260428_squash_0004" +down_revision: Union[str, Sequence[str], None] = "20260428_squash_0003" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "anonymous_session_snapshots", + sa.Column("id", sa.UUID(), nullable=False), + sa.Column("anonymous_id", sa.UUID(), nullable=False), + sa.Column("session_type", sa.String(length=20), nullable=False), + sa.Column("message_count", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=True), + sa.Column("question_type", sa.String(length=50), nullable=True), + sa.Column("tool_name", sa.String(length=100), nullable=True), + sa.Column("gua_name", sa.String(length=50), nullable=True), + sa.Column("gua_name_hant", sa.String(length=50), nullable=True), + sa.Column("target_gua_name", sa.String(length=50), nullable=True), + sa.Column("has_changing_yao", sa.Boolean(), nullable=True), + sa.Column("sign_level", sa.String(length=20), nullable=True), + sa.Column("keywords", postgresql.ARRAY(sa.Text()), nullable=True), + sa.Column("model_code", sa.String(length=50), nullable=True), + sa.Column("total_tokens", sa.Integer(), nullable=True), + sa.Column("total_cost", sa.Numeric(12, 6), nullable=True), + sa.Column("total_latency_ms", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("last_activity_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("anonymized_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_anonymous_session_snapshots_anonymous_id", "anonymous_session_snapshots", ["anonymous_id"]) + op.create_index("ix_anonymous_session_snapshots_created_at", "anonymous_session_snapshots", ["created_at"]) + op.create_index("ix_anonymous_session_snapshots_question_type", "anonymous_session_snapshots", ["question_type"]) + _enable_service_role_all_rls("anonymous_session_snapshots") + + op.create_table( + "user_feedback", + sa.Column("id", postgresql.UUID(as_uuid=True), server_default=sa.text("gen_random_uuid()"), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("feedback_type", sa.String(length=20), server_default=sa.text("'other'"), nullable=False), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("images", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'[]'::jsonb"), nullable=False), + sa.Column("device_info", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("app_version", sa.String(length=20), nullable=False), + sa.Column("os_version", sa.String(length=50), nullable=False), + sa.Column("status", sa.String(length=20), server_default=sa.text("'pending'"), nullable=False), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["auth.users.id"], ondelete="SET NULL"), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index("ix_user_feedback_user_id", "user_feedback", ["user_id"]) + op.create_index("ix_user_feedback_created_at", "user_feedback", ["created_at"]) + op.create_index("ix_user_feedback_status", "user_feedback", ["status"]) + op.execute("COMMENT ON TABLE user_feedback IS '用户反馈表'") + op.execute("COMMENT ON COLUMN user_feedback.user_id IS '用户ID,NULL表示匿名(勾选不上传我的个人信息)'") + op.execute("COMMENT ON COLUMN user_feedback.feedback_type IS '反馈类型: bug/suggestion/other'") + op.execute("COMMENT ON COLUMN user_feedback.content IS '反馈内容,最多500字'") + op.execute("COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'") + op.execute("COMMENT ON COLUMN user_feedback.device_info IS '设备信息JSON,匿名时照样采集(不涉及隐私)'") + op.execute("COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'") + _enable_service_role_all_rls("user_feedback") + + +def downgrade() -> None: + _drop_service_role_all_rls("user_feedback") + op.drop_table("user_feedback") + + _drop_service_role_all_rls("anonymous_session_snapshots") + op.drop_table("anonymous_session_snapshots") + + +def _enable_service_role_all_rls(table_name: str) -> None: + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + op.execute(f"CREATE POLICY service_role_all_{table_name} ON {table_name} FOR ALL TO service_role USING (true) WITH CHECK (true)") + + +def _drop_service_role_all_rls(table_name: str) -> None: + op.execute(f"DROP POLICY IF EXISTS service_role_all_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/alembic/versions/20260415_0002_drop_points_ledger_biz_id_fkey.py b/backend/alembic/versions/20260415_0002_drop_points_ledger_biz_id_fkey.py deleted file mode 100644 index 7eaf3eb..0000000 --- a/backend/alembic/versions/20260415_0002_drop_points_ledger_biz_id_fkey.py +++ /dev/null @@ -1,40 +0,0 @@ -"""drop points_ledger.biz_id foreign key for snapshot-style reference - -Revision ID: 20260415_0002 -Revises: 20260415_0001 -Create Date: 2026-04-15 10:00:00 - -points_ledger.biz_id stores a snapshot reference to sessions.id for audit purposes. -This allows sessions to be deleted while preserving the biz_id value in points_ledger -for user-facing transaction history. - -The FK constraint is removed because: -1. Users need to see their points transaction history even after session deletion -2. Session deletion (anonymization for iOS compliance) should not cascade delete - points_ledger records -3. biz_id becomes a "snapshot" reference - the value is kept but no FK enforcement -""" - -from typing import Sequence, Union - -from alembic import op - -revision: str = "20260415_0002" -down_revision: Union[str, Sequence[str], None] = "20260415_0001" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.drop_constraint("points_ledger_biz_id_fkey", "points_ledger", type_="foreignkey") - - -def downgrade() -> None: - op.create_foreign_key( - "points_ledger_biz_id_fkey", - "points_ledger", - "sessions", - ["biz_id"], - ["id"], - ondelete="SET NULL", - ) diff --git a/backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py b/backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py deleted file mode 100644 index 81ea199..0000000 --- a/backend/alembic/versions/20260416_0001_add_starter_pack_tracking.py +++ /dev/null @@ -1,32 +0,0 @@ -"""add has_purchased_starter_pack to register_bonus_claims - -Revision ID: 20260416_0001 -Revises: 20260413_0004 -Create Date: 2026-04-16 12:00:00 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -revision: str = "20260416_0001" -down_revision: Union[str, Sequence[str], None] = "20260415_0002" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "register_bonus_claims", - sa.Column( - "has_purchased_starter_pack", - sa.Boolean(), - nullable=False, - server_default=sa.text("false"), - ), - ) - - -def downgrade() -> None: - op.drop_column("register_bonus_claims", "has_purchased_starter_pack") diff --git a/backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py b/backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py deleted file mode 100644 index 1e36ddc..0000000 --- a/backend/alembic/versions/20260416_0002_drop_duplicate_llm_indexes.py +++ /dev/null @@ -1,25 +0,0 @@ -"""drop duplicate indexes on llm_factory.name and llms.model_code - -Revision ID: 20260416_0002 -Revises: 20260416_0001 -Create Date: 2026-04-16 -""" - -from typing import Sequence, Union - -from alembic import op - -revision: str = "20260416_0002" -down_revision: Union[str, Sequence[str], None] = "20260416_0001" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.drop_index("ix_llm_factory_name", table_name="llm_factory") - op.drop_index("ix_llms_model_code", table_name="llms") - - -def downgrade() -> None: - op.create_index("ix_llm_factory_name", "llm_factory", ["name"], unique=True) - op.create_index("ix_llms_model_code", "llms", ["model_code"], unique=True) diff --git a/backend/alembic/versions/20260416_0003_add_notification_target_mode.py b/backend/alembic/versions/20260416_0003_add_notification_target_mode.py deleted file mode 100644 index 6bf1711..0000000 --- a/backend/alembic/versions/20260416_0003_add_notification_target_mode.py +++ /dev/null @@ -1,37 +0,0 @@ -"""add target_mode to notifications - -Revision ID: 20260416_0003 -Revises: 20260416_0002 -Create Date: 2026-04-16 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa - -revision: str = "20260416_0003" -down_revision: Union[str, Sequence[str], None] = "20260416_0002" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.add_column( - "notifications", - sa.Column( - "target_mode", - sa.String(32), - nullable=False, - server_default="all_users", - ), - ) - op.execute( - "ALTER TABLE notifications ADD CONSTRAINT ck_notifications_target_mode " - "CHECK (target_mode IN ('new_users', 'exist_users', 'all_users', 'user_ids'))" - ) - - -def downgrade() -> None: - op.execute("ALTER TABLE notifications DROP CONSTRAINT ck_notifications_target_mode") - op.drop_column("notifications", "target_mode") diff --git a/backend/alembic/versions/20260417_0001_create_user_feedback.py b/backend/alembic/versions/20260417_0001_create_user_feedback.py deleted file mode 100644 index 2b3fa7e..0000000 --- a/backend/alembic/versions/20260417_0001_create_user_feedback.py +++ /dev/null @@ -1,118 +0,0 @@ -"""create user_feedback table - -Revision ID: 20260417_0001 -Revises: 20260416_0003 -Create Date: 2026-04-17 -""" - -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects.postgresql import JSONB, UUID - -revision: str = "20260417_0001" -down_revision: Union[str, Sequence[str], None] = "20260416_0003" -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - op.create_table( - "user_feedback", - sa.Column( - "id", - UUID(as_uuid=True), - server_default=sa.text("gen_random_uuid()"), - primary_key=True, - ), - sa.Column( - "user_id", - UUID(as_uuid=True), - sa.ForeignKey("auth.users.id", ondelete="SET NULL"), - nullable=True, - ), - sa.Column( - "feedback_type", - sa.String(20), - nullable=False, - server_default="other", - ), - sa.Column("content", sa.Text, nullable=False), - sa.Column( - "images", - JSONB, - nullable=False, - server_default=sa.text("'[]'::jsonb"), - ), - sa.Column( - "device_info", - JSONB, - nullable=False, - server_default=sa.text("'{}'::jsonb"), - ), - sa.Column("app_version", sa.String(20), nullable=False), - sa.Column("os_version", sa.String(50), nullable=False), - sa.Column( - "status", - sa.String(20), - nullable=False, - server_default="pending", - ), - sa.Column( - "created_at", - sa.DateTime(timezone=True), - server_default=sa.func.now(), - nullable=False, - ), - sa.Column( - "updated_at", - sa.DateTime(timezone=True), - server_default=sa.func.now(), - nullable=False, - ), - ) - - op.create_index("ix_user_feedback_user_id", "user_feedback", ["user_id"]) - op.create_index("ix_user_feedback_created_at", "user_feedback", ["created_at"]) - op.create_index("ix_user_feedback_status", "user_feedback", ["status"]) - - op.execute("COMMENT ON TABLE user_feedback IS '用户反馈表'") - op.execute( - "COMMENT ON COLUMN user_feedback.user_id IS " - "'用户ID,NULL表示匿名(勾选不上传我的个人信息)'" - ) - op.execute( - "COMMENT ON COLUMN user_feedback.feedback_type IS " - "'反馈类型: bug/suggestion/other'" - ) - op.execute("COMMENT ON COLUMN user_feedback.content IS '反馈内容,最多500字'") - op.execute( - "COMMENT ON COLUMN user_feedback.images IS '图片Storage路径列表,最多3张'" - ) - op.execute( - "COMMENT ON COLUMN user_feedback.device_info IS " - "'设备信息JSON,匿名时照样采集(不涉及隐私)'" - ) - op.execute( - "COMMENT ON COLUMN user_feedback.status IS '处理状态: pending/processed'" - ) - - op.execute("ALTER TABLE public.user_feedback ENABLE ROW LEVEL SECURITY") - - op.execute(""" - CREATE POLICY "Service role full access on user_feedback" - ON public.user_feedback - FOR ALL - TO service_role - USING (true) - WITH CHECK (true) - """) - - -def downgrade() -> None: - op.execute( - 'DROP POLICY IF EXISTS "Service role full access on user_feedback" ON public.user_feedback' - ) - op.execute("ALTER TABLE public.user_feedback DISABLE ROW LEVEL SECURITY") - op.drop_table("user_feedback") diff --git a/backend/alembic/versions/20260428_0004_apple_iap_final_head.py b/backend/alembic/versions/20260428_0004_apple_iap_final_head.py new file mode 100644 index 0000000..1182ada --- /dev/null +++ b/backend/alembic/versions/20260428_0004_apple_iap_final_head.py @@ -0,0 +1,81 @@ +"""Create Apple IAP schema and mark the squashed chain head. + +Revision ID: 20260428_0004 +Revises: 20260428_squash_0004 +Create Date: 2026-04-28 00:04:00 + +Squashed history: creates the Apple IAP table with the corrected RLS policy +from the start. The revision id intentionally remains the previous head so +databases already stamped at 20260428_0004 stay recognized as current. +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +revision: str = "20260428_0004" +down_revision: Union[str, Sequence[str], None] = "20260428_squash_0004" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + "apple_iap_transactions", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("user_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("product_code", sa.String(length=32), nullable=False), + sa.Column("app_store_product_id", sa.String(length=128), nullable=False), + sa.Column("transaction_id", sa.String(length=64), nullable=False), + sa.Column("original_transaction_id", sa.String(length=64), nullable=True), + sa.Column("web_order_line_item_id", sa.String(length=64), nullable=True), + sa.Column("environment", sa.String(length=16), nullable=False), + sa.Column("bundle_id", sa.String(length=128), nullable=False), + sa.Column("app_account_token", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("purchase_date", sa.Text(), nullable=False), + sa.Column("revocation_date", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=24), nullable=False), + sa.Column("credits", sa.BigInteger(), nullable=False), + sa.Column("currency", sa.String(length=8), nullable=True), + sa.Column("price_milliunits", sa.BigInteger(), nullable=True), + sa.Column("ledger_event_id", sa.String(length=64), nullable=True), + sa.Column("signed_transaction_info", sa.Text(), nullable=False), + sa.Column("apple_payload", postgresql.JSONB(astext_type=sa.Text()), server_default=sa.text("'{}'::jsonb"), nullable=False), + sa.Column("failure_code", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.CheckConstraint("environment in ('Sandbox', 'Production')", name="ck_apple_iap_transactions_environment"), + sa.CheckConstraint("status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')", name="ck_apple_iap_transactions_status"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("transaction_id", name="uq_apple_iap_transactions_transaction_id"), + sa.UniqueConstraint("ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id"), + ) + op.create_index("ix_apple_iap_transactions_user_created_at", "apple_iap_transactions", ["user_id", sa.text("created_at DESC")]) + op.create_index("ix_apple_iap_transactions_status_updated_at", "apple_iap_transactions", ["status", sa.text("updated_at DESC")]) + _enable_service_only_rls("apple_iap_transactions") + + +def downgrade() -> None: + _drop_service_only_rls("apple_iap_transactions") + op.drop_table("apple_iap_transactions") + + +def _enable_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} ENABLE ROW LEVEL SECURITY") + for role in ["anon", "authenticated"]: + op.execute(f"CREATE POLICY {role}_select_{table_name} ON {table_name} FOR SELECT TO {role} USING (false)") + op.execute(f"CREATE POLICY {role}_insert_{table_name} ON {table_name} FOR INSERT TO {role} WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_update_{table_name} ON {table_name} FOR UPDATE TO {role} USING (false) WITH CHECK (false)") + op.execute(f"CREATE POLICY {role}_delete_{table_name} ON {table_name} FOR DELETE TO {role} USING (false)") + + +def _drop_service_only_rls(table_name: str) -> None: + for role in ["anon", "authenticated"]: + for action in ["select", "insert", "update", "delete"]: + op.execute(f"DROP POLICY IF EXISTS {role}_{action}_{table_name} ON {table_name}") + op.execute(f"ALTER TABLE {table_name} DISABLE ROW LEVEL SECURITY") diff --git a/backend/src/core/agentscope/events/store.py b/backend/src/core/agentscope/events/store.py index 80df05c..aa0121a 100644 --- a/backend/src/core/agentscope/events/store.py +++ b/backend/src/core/agentscope/events/store.py @@ -13,7 +13,7 @@ from core.logging import get_logger from schemas.agent.forwarded_props import RuntimeMode from schemas.enums import AgentChatMessageRole, AgentChatSessionStatus from schemas.agent.system_agent import AgentType -from schemas.agent.runtime_models import AgentOutput, FollowUpOutput, ToolAgentOutput +from schemas.agent.runtime_models import FollowUpOutput, PersistedAgentOutput, ToolAgentOutput from schemas.agent.visibility import SystemVisibilityBit, bit_mask from schemas.domain.chat_message import AgentChatMessageMetadata from schemas.domain.chat_session import SessionStateSnapshot @@ -107,6 +107,11 @@ class SqlAlchemyEventStore: content_value = self._event_value(event, "answer") content = content_value if isinstance(content_value, str) else "" if not content: + self._logger.warning( + "text_message_end skipped: empty answer", + run_id=self._event_value(event, "runId"), + status=self._event_value(event, "status"), + ) return input_tokens = self._to_int(self._event_value(event, "inputTokens")) @@ -148,7 +153,7 @@ class SqlAlchemyEventStore: return if runtime_mode == RuntimeMode.CHAT.value: - worker_output = AgentOutput.model_validate(worker_output_payload) + worker_output = PersistedAgentOutput.model_validate(worker_output_payload) else: worker_output = FollowUpOutput.model_validate(worker_output_payload) agent_type = AgentType.WORKER diff --git a/backend/src/core/agentscope/prompts/agent_prompt.py b/backend/src/core/agentscope/prompts/agent_prompt.py index afa6f05..20770ad 100644 --- a/backend/src/core/agentscope/prompts/agent_prompt.py +++ b/backend/src/core/agentscope/prompts/agent_prompt.py @@ -2,6 +2,8 @@ from __future__ import annotations from core.agentscope.prompts.sections import wrap_section from core.agentscope.prompts.worker_rules import ( + get_follow_up_output_rules, + get_follow_up_role_playing, get_worker_output_rules, get_worker_role_playing, ) @@ -12,10 +14,15 @@ def build_agent_prompt( *, agent_type: AgentType, llm_config: SystemAgentLLMConfig | None = None, - ai_language: str = "zh-CN", + language: str = "zh-CN", + runtime_mode: str = "chat", ) -> str: _ = agent_type, llm_config - role_playing = get_worker_role_playing(ai_language) - output_rules = get_worker_output_rules(ai_language) + if runtime_mode == "follow_up": + role_playing = get_follow_up_role_playing(language) + output_rules = get_follow_up_output_rules(language) + else: + role_playing = get_worker_role_playing(language) + output_rules = get_worker_output_rules(language) content = f"[role_playing]\n{role_playing}\n\n[output_json_rules]\n{output_rules}" return wrap_section("agent", content) diff --git a/backend/src/core/agentscope/prompts/system_prompt.py b/backend/src/core/agentscope/prompts/system_prompt.py index 081e273..6a11ef9 100644 --- a/backend/src/core/agentscope/prompts/system_prompt.py +++ b/backend/src/core/agentscope/prompts/system_prompt.py @@ -10,44 +10,83 @@ from core.agentscope.prompts.sections import wrap_section from core.agentscope.prompts.tool_prompt import build_tools_prompt from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig -_LANGUAGE_LABELS: dict[str, str] = { - "zh-CN": "简体中文", - "zh-Hant": "繁體中文", - "en-US": "English", - "en": "English", -} - -def _get_language_label(tag: str) -> str: - return _LANGUAGE_LABELS.get(tag, tag) - - -def _build_safety_section() -> str: - return wrap_section( - "safety", - "\n".join( +def _build_safety_section(*, language: str) -> str: + if language.startswith("en"): + content = "\n".join( [ - "[Safety Rules]", - "- 你是六爻解卦助手,只回答与六爻占卜、卦象分析、易理探讨相关的问题。遇到无关提问时,明确告知超出服务范围,不做任何妥协或绕行。", - "- 拒绝回答任何与六爻无关的问题,包括但不限于:政治、军事、违法活动、个人隐私窃取、有害信息等。", + "═══════════════════════════════════════════════════════════════════════════════", + "CRITICAL SCOPE RULE - DO NOT IGNORE", + "═══════════════════════════════════════════════════════════════════════════════", + "", + "You can ONLY respond to Liu Yao (六爻) divination questions.", + "", + "═══════════════════════════════════════════════════════════════════════════════", + "MANDATORY REFUSAL CONDITIONS", + "═══════════════════════════════════════════════════════════════════════════════", + "", + "You MUST refuse (set status=\"refused\") if ANY of these conditions are true:", + "", + "1. QUESTION TYPE MISMATCH (MOST IMPORTANT):", + " - User asks about Tarot (塔罗) -> REFUSE, suggest Tarot reading", + " - User asks about Ba Zi (八字) -> REFUSE, suggest Ba Zi analysis", + " - User asks about Zi Wei (紫微) -> REFUSE, suggest Zi Wei Dou Shu", + " - User asks about Western Astrology (星座) -> REFUSE, suggest astrology", + " - User asks about non-divination topics (programming, weather, etc.) -> REFUSE", + "", + "2. CRITICAL: Having hexagram data does NOT override question type!", + " - Even if user provides Liu Yao hexagram data", + " - If they ask a Tarot/Ba Zi/programming question -> STILL REFUSE", + " - The QUESTION determines if you should answer, not the hexagram data", + "", + "═══════════════════════════════════════════════════════════════════════════════", + "OTHER SAFETY RULES", + "═══════════════════════════════════════════════════════════════════════════════", + "", + "- For investment, lottery, medical, life-or-death questions, provide only symbolic Liu Yao reference. Never give guaranteed conclusions.", "- Never expose secrets, tokens, credentials, or private identifiers.", "- Do not invent tool outputs, user data, or system state.", "- Never bypass schema constraints (enum/type/required/extra fields).", - "- If required data is missing, ask minimal clarification or return constrained safe output.", ] - ), - ) - - -def _build_output_rules(*, ai_language: str) -> str: - lang_label = _get_language_label(ai_language) - rules = [ - "[Language Requirement]", - f"- You MUST respond in {lang_label}.", - f"- IGNORE the language of user's question - you must still answer in {lang_label}.", - "- Do NOT switch to another language even if the user asks in a different language.", - ] - return wrap_section("output", "\n".join(rules)) + ) + else: + content = "\n".join( + [ + "═══════════════════════════════════════════════════════════════════════════════", + "关键范围规则 - 切勿忽略", + "═══════════════════════════════════════════════════════════════════════════════", + "", + "你只能回答六爻占卜相关问题。", + "", + "═══════════════════════════════════════════════════════════════════════════════", + "必须拒答的条件", + "═══════════════════════════════════════════════════════════════════════════════", + "", + "如果满足以下任一条件,必须拒绝(设置 status=\"refused\"):", + "", + "1. 问题类型不匹配(最重要):", + " - 用户询问塔罗 -> 拒答,建议咨询塔罗师", + " - 用户询问八字 -> 拒答,建议咨询八字命理师", + " - 用户询问紫微 -> 拒答,建议咨询紫微斗数专家", + " - 用户询问星座 -> 拒答,建议咨询占星师", + " - 用户询问非占卜话题(编程、天气等) -> 拒答", + "", + "2. 关键:有卦象数据也不能覆盖问题类型判断!", + " - 即使用户提供了六爻卦象数据", + " - 如果问的是塔罗/八字/编程问题 -> 仍然拒答", + " - 决定是否回答的是问题本身,而非卦象数据", + "", + "═══════════════════════════════════════════════════════════════════════════════", + "其他安全规则", + "═══════════════════════════════════════════════════════════════════════════════", + "", + "- 涉及投资、彩票、医疗、生死等高风险问题时,只能作六爻象意参考,不得给出保证性结论。", + "- Never expose secrets, tokens, credentials, or private identifiers.", + "- Do not invent tool outputs, user data, or system state.", + "- Never bypass schema constraints (enum/type/required/extra fields).", + ] + ) + return wrap_section("safety", content) def _build_time_context(*, now_utc: datetime | None) -> str: @@ -68,20 +107,21 @@ def _build_time_context(*, now_utc: datetime | None) -> str: def build_system_prompt( *, agent_type: AgentType, - ai_language: str, + language: str, llm_config: SystemAgentLLMConfig | None = None, tools: Sequence[Tool | dict[str, Any]] | None = None, now_utc: datetime | None = None, + runtime_mode: str = "chat", ) -> str: sections: list[str | None] = [ _build_time_context(now_utc=now_utc), - _build_safety_section(), + _build_safety_section(language=language), build_agent_prompt( agent_type=agent_type, llm_config=llm_config, - ai_language=ai_language, + language=language, + runtime_mode=runtime_mode, ), build_tools_prompt(tools=tools) if tools else None, - _build_output_rules(ai_language=ai_language), ] return "\n\n".join(item for item in sections if item).strip() diff --git a/backend/src/core/agentscope/prompts/user_prompt.py b/backend/src/core/agentscope/prompts/user_prompt.py index 6f64434..7c19db0 100644 --- a/backend/src/core/agentscope/prompts/user_prompt.py +++ b/backend/src/core/agentscope/prompts/user_prompt.py @@ -3,101 +3,480 @@ from __future__ import annotations from schemas.domain.divination import DerivedDivinationData -def build_divination_user_prompt(*, derived: DerivedDivinationData) -> str: +_LANGUAGE_INSTRUCTIONS = { + "en": ( + "═══════════════════════════════════════════════════════════════════════════════\n" + "CRITICAL: YOUR ENTIRE RESPONSE MUST BE IN ENGLISH\n" + "═══════════════════════════════════════════════════════════════════════════════\n" + "- ALL text in answer, conclusion, advice, focus_points, keywords MUST be English\n" + "- Write in natural, idiomatic American English that any ordinary person can understand\n" + "- The ONLY Chinese characters allowed in your response:\n" + " * sign_level enum values (上上签 / 中上签 / 中下签 / 下下签)\n" + " * Hexagram name on first mention only (e.g. 风山渐)\n" + "- If you write Chinese sentences in your English response, you have FAILED this task\n" + "═══════════════════════════════════════════════════════════════════════════════", + "═══════════════════════════════════════════════════════════════════════════════\n" + "REMINDER: Respond in English. Minimize Chinese characters. Write for ordinary people.\n" + "═══════════════════════════════════════════════════════════════════════════════", + ), + "zh-Hant": ( + "═══════════════════════════════════════════════════════════════════════════════\n" + "關鍵:必須全程使用繁體中文回答\n" + "═══════════════════════════════════════════════════════════════════════════════", + "═══════════════════════════════════════════════════════════════════════════════\n" + "提醒:使用繁體中文回答。sign_level 必須是:上上簽/中上簽/中下簽/下下簽\n" + "═══════════════════════════════════════════════════════════════════════════════", + ), + "zh-CN": ( + "═══════════════════════════════════════════════════════════════════════════════\n" + "关键:必须全程使用简体中文回答\n" + "═══════════════════════════════════════════════════════════════════════════════", + "═══════════════════════════════════════════════════════════════════════════════\n" + "提醒:使用简体中文回答。sign_level 必须是:上上签/中上签/中下签/下下签\n" + "═══════════════════════════════════════════════════════════════════════════════", + ), +} + +_SCOPE_INSTRUCTIONS = { + "en": ( + "[SCOPE CHECK - REFUSE IF:]\n" + "1. Question is NOT about Liu Yao divination\n" + "2. Question asks for Tarot, Ba Zi, Zi Wei, astrology, or other methods\n" + "3. Question is about non-divination topics (programming, weather, etc.)\n" + "\n" + "WHEN REFUSING:\n" + "- Set status=\"refused\"\n" + "- Set sign_level=\"下下签\"\n" + "- Set answer to a brief explanation of why you cannot answer (in English)\n" + "- Leave conclusion, focus_points, advice, keywords as empty lists" + ), + "zh-Hant": ( + "【範圍檢查 - 以下情況請拒絕:】\n" + "1. 問題不是關於六爻占卜\n" + "2. 請求塔羅、八字、紫微、星座等其他方法\n" + "3. 問題與占卜無關(編程、天氣等)\n" + "\n" + "拒答時:\n" + "- 設置 status=\"refused\"\n" + "- 設置 sign_level=\"下下签\"\n" + "- 設置 answer 為拒答原因簡述(使用繁體中文)\n" + "- conclusion、focus_points、advice、keywords 留空列表" + ), + "zh-CN": ( + "【范围检查 - 以下情况请拒绝:】\n" + "1. 问题不是关于六爻占卜\n" + "2. 请求塔罗、八字、紫微、星座等其他方法\n" + "3. 问题与占卜无关(编程、天气等)\n" + "\n" + "拒答时:\n" + "- 设置 status=\"refused\"\n" + "- 设置 sign_level=\"下下签\"\n" + "- 设置 answer 为拒答原因简述(使用简体中文)\n" + "- conclusion、focus_points、advice、keywords 留空列表" + ), +} + + +def _language_key(language: str) -> str: + if language.startswith("en"): + return "en" + if language.startswith("zh-Hant") or language.startswith("zh_Hant"): + return "zh-Hant" + return "zh-CN" + + +_ZH = { + "user_question": "用户问题", + "question_type": "问题类型", + "divination_method": "起卦方式", + "divination_time": "起卦时间", + "ben_gua": "【本卦】", + "gua_name_tpl": "卦名:{name}(上{upper}下{lower})", + "gua_name_simple_tpl": "卦名:{name}", + "gua_xiang": "卦象", + "bian_gua": "【变卦】", + "ganzhi": "【干支】", + "year_pillar": "年柱", + "month_pillar": "月柱", + "day_pillar": "日柱", + "time_pillar": "时柱", + "yue_jian": "月建", + "ri_chen": "日辰", + "yue_po": "月破", + "ri_chong": "日冲", + "year_kong": "年空亡", + "month_kong": "月空亡", + "day_kong": "日空亡", + "time_kong": "时空亡", + "wu_xing": "【五行旺衰】", + "ben_yao": "【本卦爻象】", + "yao_line_tpl": "第{pos}爻:{spirit} {relation} {tigan}{element} {yang_yin}爻{dong}{special}", + "yao_line_tpl_static": "第{pos}爻:{spirit} {relation} {tigan}{element} {yang_yin}爻{special}", + "yang_label": "阳", + "yin_label": "阴", + "dong_mark": "(动)", + "shi_label": "世", + "ying_label": "应", + "bian_yao": "【变卦爻象】", + "fushen": "【伏神】", + "fushen_line_tpl": "第{pos}爻:{relation} {tigan}{element}", + "special_status": "【特殊状态标注】", + "interactions": "【全局冲合提示】", + "time_effect": "【时令关键点】", + "ri_chen_zhang_sheng": "【日辰十二长生】", + "closing": "——以上为起卦所得完整数据,请据此进行六爻解读。", +} + +_ZH_HANT = { + "user_question": "使用者問題", + "question_type": "問題類型", + "divination_method": "起卦方式", + "divination_time": "起卦時間", + "ben_gua": "【本卦】", + "gua_name_tpl": "卦名:{name}(上{upper}下{lower})", + "gua_name_simple_tpl": "卦名:{name}", + "gua_xiang": "卦象", + "bian_gua": "【變卦】", + "ganzhi": "【干支】", + "year_pillar": "年柱", + "month_pillar": "月柱", + "day_pillar": "日柱", + "time_pillar": "時柱", + "yue_jian": "月建", + "ri_chen": "日辰", + "yue_po": "月破", + "ri_chong": "日沖", + "year_kong": "年空亡", + "month_kong": "月空亡", + "day_kong": "日空亡", + "time_kong": "時空亡", + "wu_xing": "【五行旺衰】", + "ben_yao": "【本卦爻象】", + "yao_line_tpl": "第{pos}爻:{spirit} {relation} {tigan}{element} {yang_yin}爻{dong}{special}", + "yao_line_tpl_static": "第{pos}爻:{spirit} {relation} {tigan}{element} {yang_yin}爻{special}", + "yang_label": "陽", + "yin_label": "陰", + "dong_mark": "(動)", + "shi_label": "世", + "ying_label": "應", + "bian_yao": "【變卦爻象】", + "fushen": "【伏神】", + "fushen_line_tpl": "第{pos}爻:{relation} {tigan}{element}", + "special_status": "【特殊狀態標註】", + "interactions": "【全局沖合提示】", + "time_effect": "【時令關鍵點】", + "ri_chen_zhang_sheng": "【日辰十二長生】", + "closing": "——以上為起卦所得完整資料,請據此進行六爻解讀。", +} + +_EN = { + "user_question": "User Question", + "question_type": "Question Type", + "divination_method": "Divination Method", + "divination_time": "Divination Time", + "ben_gua": "[Original Hexagram]", + "gua_name_tpl": "Name: {name} (Upper: {upper}, Lower: {lower})", + "gua_name_simple_tpl": "Name: {name}", + "gua_xiang": "Trigram Code", + "bian_gua": "[Resulting Hexagram]", + "ganzhi": "[Stems & Branches]", + "year_pillar": "Year Pillar", + "month_pillar": "Month Pillar", + "day_pillar": "Day Pillar", + "time_pillar": "Time Pillar", + "yue_jian": "Month Branch", + "ri_chen": "Day Branch", + "yue_po": "Month Break", + "ri_chong": "Day Clash", + "year_kong": "Year Void", + "month_kong": "Month Void", + "day_kong": "Day Void", + "time_kong": "Time Void", + "wu_xing": "[Five Element Status]", + "ben_yao": "[Original Hexagram Lines]", + "yao_line_tpl": "Line {pos}: {spirit} {relation} {tigan}{element} {yang_yin}{dong}{special}", + "yao_line_tpl_static": "Line {pos}: {spirit} {relation} {tigan}{element} {yang_yin}{special}", + "yang_label": "Yang", + "yin_label": "Yin", + "dong_mark": " (changing)", + "shi_label": " Self", + "ying_label": " Response", + "bian_yao": "[Resulting Hexagram Lines]", + "fushen": "[Hidden Lines (Fu Shen)]", + "fushen_line_tpl": "Line {pos}: {relation} {tigan}{element}", + "special_status": "[Special Status Annotations]", + "interactions": "[Global Clash/Union Notes]", + "time_effect": "[Seasonal Key Points]", + "ri_chen_zhang_sheng": "[Day Stem Twelve Growth Stages]", + "closing": "—— End of hexagram data. Please interpret according to Liu Yao principles.", +} + + +def _get_fields(language: str) -> dict[str, str]: + if language.startswith("en"): + return _EN + if language.startswith("zh-Hant") or language.startswith("zh_Hant"): + return _ZH_HANT + return _ZH + + +_HANT_TEXT_REPLACEMENTS = str.maketrans( + { + "龙": "龍", + "财": "財", + "孙": "孫", + "应": "應", + "腾": "騰", + "陈": "陳", + "为": "為", + "兑": "兌", + "离": "離", + "冲": "沖", + "动": "動", + "静": "靜", + "与": "與", + "关": "關", + "时": "時", + "态": "態", + "标": "標", + "注": "註", + "数": "數", + "据": "據", + "资": "資", + "料": "料", + "头": "頭", + "克": "剋", + "绝": "絕", + "医": "醫", + "药": "藥", + } +) + + +def _is_hant(language: str) -> bool: + return language.startswith("zh-Hant") or language.startswith("zh_Hant") + + +def _display_text(value: str, *, language: str, hant_value: str = "") -> str: + if not _is_hant(language): + return value + return (hant_value or value).translate(_HANT_TEXT_REPLACEMENTS) + + +def _special_mark_label(world_response: str, f: dict[str, str]) -> str: + if world_response == "世": + return f["shi_label"] + if world_response == "应": + return f["ying_label"] + return "" + + +def build_divination_user_prompt( + *, derived: DerivedDivinationData, language: str = "zh-CN" +) -> str: + key = _language_key(language) + prefix, suffix = _LANGUAGE_INSTRUCTIONS[key] + scope = _SCOPE_INSTRUCTIONS[key] + f = _get_fields(language) lines: list[str] = [] - lines.append(f"用户问题:{derived.question}") - lines.append(f"问题类型:{derived.question_type}") - lines.append(f"起卦方式:{derived.divination_method.value}") - lines.append(f"起卦时间:{derived.divination_time}") + + lines.append(prefix) + lines.append("") + lines.append(scope) lines.append("") - lines.append("【本卦】") + lines.append(f"{f['user_question']}:{derived.question}") lines.append( - f"卦名:{derived.gua_name}(上{derived.upper_name}下{derived.lower_name})" + f"{f['question_type']}:{_display_text(derived.question_type, language=language)}" ) - lines.append(f"卦象:{derived.binary_code}") + lines.append( + f"{f['divination_method']}:{_display_text(derived.divination_method.value, language=language)}" + ) + lines.append( + f"{f['divination_time']}:{_display_text(derived.divination_time, language=language)}" + ) + lines.append("") + + lines.append(f["ben_gua"]) + lines.append( + f["gua_name_tpl"].format( + name=_display_text( + derived.gua_name, language=language, hant_value=derived.gua_name_hant + ), + upper=_display_text(derived.upper_name, language=language), + lower=_display_text(derived.lower_name, language=language), + ) + ) + lines.append(f"{f['gua_xiang']}:{derived.binary_code}") lines.append("") if derived.has_changing_yao: - lines.append("【变卦】") - lines.append(f"卦名:{derived.target_gua_name}") - lines.append(f"卦象:{derived.changed_binary_code}") + lines.append(f["bian_gua"]) + lines.append( + f["gua_name_simple_tpl"].format( + name=_display_text( + derived.target_gua_name, + language=language, + hant_value=derived.target_gua_name_hant, + ) + ) + ) + lines.append(f"{f['gua_xiang']}:{derived.changed_binary_code}") lines.append("") - lines.append("【干支】") + lines.append(f["ganzhi"]) g = derived.ganzhi lines.append( - f"年柱:{g.year_gan_zhi} 月柱:{g.month_gan_zhi} 日柱:{g.day_gan_zhi} 时柱:{g.time_gan_zhi}" + f"{f['year_pillar']}:{g.year_gan_zhi} {f['month_pillar']}:{g.month_gan_zhi}" + + f" {f['day_pillar']}:{g.day_gan_zhi} {f['time_pillar']}:{g.time_gan_zhi}" ) - lines.append(f"月建:{g.yue_jian} 日辰:{g.ri_chen}") - lines.append(f"月破:{g.yue_po} 日冲:{g.ri_chong}") lines.append( - f"年空亡:{g.year_kong_wang} 月空亡:{g.month_kong_wang}" - f" 日空亡:{g.day_kong_wang} 时空亡:{g.time_kong_wang}" + f"{f['yue_jian']}:{_display_text(g.yue_jian, language=language)}" + + f" {f['ri_chen']}:{_display_text(g.ri_chen, language=language)}" + ) + lines.append( + f"{f['yue_po']}:{_display_text(g.yue_po, language=language)}" + + f" {f['ri_chong']}:{_display_text(g.ri_chong, language=language)}" + ) + lines.append( + f"{f['year_kong']}:{g.year_kong_wang} {f['month_kong']}:{g.month_kong_wang}" + + f" {f['day_kong']}:{g.day_kong_wang} {f['time_kong']}:{g.time_kong_wang}" ) lines.append("") - lines.append("【五行旺衰】") + lines.append(f["wu_xing"]) for element, status in derived.wu_xing_statuses.items(): - lines.append(f"{element}:{status}") - lines.append("") - - lines.append("【本卦爻象】") - for yao in derived.yao_info_list: - changing_mark = "(动)" if yao.is_changing else "" - world_response = yao.special_mark lines.append( - f"第{yao.position}爻:{yao.spirit_name} {yao.relation_name} " - f"{yao.tigan_name}{yao.element_name} " - f"{'阳' if yao.is_yang else '阴'}爻{changing_mark}" - f"{' 世' if world_response == '世' else ' 应' if world_response == '应' else ''}" + f"{_display_text(element, language=language)}:{_display_text(status, language=language)}" ) lines.append("") - if derived.has_changing_yao and derived.target_yao_info_list: - lines.append("【变卦爻象】") - for yao in derived.target_yao_info_list: - world_response = yao.special_mark + lines.append(f["ben_yao"]) + for yao in derived.yao_info_list: + yang_yin = f["yang_label"] if yao.is_yang else f["yin_label"] + special = _special_mark_label(yao.special_mark, f) + spirit = _display_text( + yao.spirit_name, language=language, hant_value=yao.spirit_name_hant + ) + relation = _display_text( + yao.relation_name, language=language, hant_value=yao.relation_name_hant + ) + tigan = _display_text(yao.tigan_name, language=language) + element = _display_text(yao.element_name, language=language) + if yao.is_changing: lines.append( - f"第{yao.position}爻:{yao.spirit_name} {yao.relation_name} " - f"{yao.tigan_name}{yao.element_name} " - f"{'阳' if yao.is_yang else '阴'}爻" - f"{' 世' if world_response == '世' else ' 应' if world_response == '应' else ''}" + f["yao_line_tpl"].format( + pos=yao.position, + spirit=spirit, + relation=relation, + tigan=tigan, + element=element, + yang_yin=yang_yin, + dong=f["dong_mark"], + special=special, + ) + ) + else: + lines.append( + f["yao_line_tpl_static"].format( + pos=yao.position, + spirit=spirit, + relation=relation, + tigan=tigan, + element=element, + yang_yin=yang_yin, + special=special, + ) + ) + lines.append("") + + if derived.has_changing_yao and derived.target_yao_info_list: + lines.append(f["bian_yao"]) + for yao in derived.target_yao_info_list: + yang_yin = f["yang_label"] if yao.is_yang else f["yin_label"] + special = _special_mark_label(yao.special_mark, f) + spirit = _display_text( + yao.spirit_name, language=language, hant_value=yao.spirit_name_hant + ) + relation = _display_text( + yao.relation_name, language=language, hant_value=yao.relation_name_hant + ) + lines.append( + f["yao_line_tpl_static"].format( + pos=yao.position, + spirit=spirit, + relation=relation, + tigan=_display_text(yao.tigan_name, language=language), + element=_display_text(yao.element_name, language=language), + yang_yin=yang_yin, + special=special, + ) ) lines.append("") if derived.fushen_info_list: - lines.append("【伏神】") + lines.append(f["fushen"]) for fs in derived.fushen_info_list: lines.append( - f"第{fs.position}爻:{fs.relation_name} {fs.tigan_name}{fs.element_name}" + f["fushen_line_tpl"].format( + pos=fs.position, + relation=_display_text( + fs.relation_name, + language=language, + hant_value=fs.relation_name_hant, + ), + tigan=_display_text(fs.tigan_name, language=language), + element=_display_text(fs.element_name, language=language), + ) ) lines.append("") if derived.special_status: - lines.append("【特殊状态标注】") + lines.append(f["special_status"]) for status in derived.special_status: - lines.append(f"- {status}") + lines.append(f"- {_display_text(status, language=language)}") lines.append("") if derived.interactions: - lines.append("【全局冲合提示】") + lines.append(f["interactions"]) for interaction in derived.interactions: - lines.append(f"- {interaction}") + lines.append(f"- {_display_text(interaction, language=language)}") lines.append("") if derived.time_effect: - lines.append("【时令关键点】") + lines.append(f["time_effect"]) for effect in derived.time_effect: - lines.append(f"- {effect}") + lines.append(f"- {_display_text(effect, language=language)}") lines.append("") if derived.ri_chen_zhang_sheng: - lines.append("【日辰十二长生】") + lines.append(f["ri_chen_zhang_sheng"]) for zs in derived.ri_chen_zhang_sheng: - lines.append(f"- {zs}") + lines.append(f"- {_display_text(zs, language=language)}") lines.append("") - lines.append("——以上为起卦所得完整数据,请据此进行六爻解读。") + lines.append(f["closing"]) + lines.append("") + lines.append(suffix) + + return "\n".join(lines) + + +def build_follow_up_user_prompt(*, question: str, language: str = "zh-CN") -> str: + key = _language_key(language) + prefix, suffix = _LANGUAGE_INSTRUCTIONS[key] + scope = _SCOPE_INSTRUCTIONS[key] + + lines = [prefix, "", scope, ""] + + if key == "en": + lines.append(f"User Question: {question}") + elif key == "zh-Hant": + lines.append(f"使用者問題:{question}") + else: + lines.append(f"用户问题:{question}") + + lines.append("") + lines.append(suffix) return "\n".join(lines) diff --git a/backend/src/core/agentscope/prompts/worker_rules.py b/backend/src/core/agentscope/prompts/worker_rules.py index 910f228..3d732d4 100644 --- a/backend/src/core/agentscope/prompts/worker_rules.py +++ b/backend/src/core/agentscope/prompts/worker_rules.py @@ -1,12 +1,14 @@ from __future__ import annotations -_WORKER_ROLE_PLAYING = """\ +_WORKER_ROLE_PLAYING_ZH = """\ 你是一名严格遵循五行生克与卦象逻辑的六爻解卦师。你的唯一任务是依据提供的结构化排盘数据,输出基于规则的专业推断。 【边界与禁令】 +- 只回答与六爻占卜、卦象分析、六爻所需易理说明直接相关的问题;用户提出无关问题时,必须直接拒绝,不回答无关内容。 - 仅使用输入数据中的六爻信息推演,严禁编造数据。 - 严禁引入星座、塔罗、八字、紫微等外体系内容。 - 严禁大段引用《周易》原文辞句。六爻以五行生克制化为核心。 +- 涉及投资、彩票、医疗、生死等高风险问题时,只能作六爻象意参考,不得给出保证性、绝对性或替代专业意见的结论。 【推演公理】(优先级由高到低) 1. 卦爻主从律:先断本卦卦象属性(六冲卦主事散速,六合卦主事缓滞),此为不可逆之背景底色;次观爻象变化。 @@ -60,7 +62,7 @@ _WORKER_ROLE_PLAYING = """\ 专业、明确、克制,像真正会看六爻的人说话。 不要写成文学散文,不要堆砌模糊词,不要故弄高深。 你可以解释,但解释必须围绕卦象本身展开。 -你的目标不是“像在算卦”,而是“真的按六爻规则解卦”。 +你的目标不是"像在算卦",而是"真的按六爻规则解卦"。 【签级参考锚定】 签级评定应综合卦象底色与动变吉凶,参考以下原则: @@ -70,45 +72,293 @@ _WORKER_ROLE_PLAYING = """\ - 中下签:六冲卦底色凶 / 用神衰弱 / 用神受克但尚有解救 / 动变回头克但世爻不伤。 - 下下签:六冲卦 + 用神月破空亡 + 动爻回头克世/克用 + 日月无助。 -若卦象吉凶参半,应以“卦象底色”为第一权重,以“世爻安危”为第二权重。 -""" +若卦象吉凶参半,应以"卦象底色"为第一权重,以"世爻安危"为第二权重。""" + +_WORKER_ROLE_PLAYING_ZH_HANT = """\ +你是一名嚴格遵循五行生剋與卦象邏輯的六爻解卦師。你的唯一任務是依據提供的結構化排盤資料,輸出基於規則的專業推斷。 + +【邊界與禁令】 +- 只回答與六爻占卜、卦象分析、六爻所需易理說明直接相關的問題;使用者提出無關問題時,必須直接拒絕,不回答無關內容。 +- 僅使用輸入資料中的六爻資訊推演,嚴禁編造資料。 +- 嚴禁引入星座、塔羅、八字、紫微等外體系內容。 +- 嚴禁大段引用《周易》原文辭句。六爻以五行生剋制化為核心。 +- 涉及投資、彩票、醫療、生死等高風險問題時,只能作六爻象意參考,不得給出保證性、絕對性或替代專業意見的結論。 + +【推演公理】(優先級由高到低) +1. 卦爻主從律:先斷本卦卦象屬性(六沖卦主事散速,六合卦主事緩滯),此為不可逆之背景底色;次觀爻象變化。 +2. 動靜虛實律:靜爻之間不構成特殊格局(如三合、六合局,除非有動爻或日月引化);動爻所化之變爻若逢空、破、墓、絕,則動而無果,事主落空。 +3. 生剋本位律:一切生剋以月建旺衰與日辰生剋為最高裁決。輸入資料中的五行狀態為既定事實,不得篡改。 + +【六親類象映射】 +根據問題類型,六親指向如下: + +問事業/工作: +- 官鬼:上司、工作壓力、職位、權力 +- 父母:文書、合同、項目、單位、資質 +- 妻財:薪水、收入、資源 +- 子孫:下屬、技能、解憂之神 +- 兄弟:同事、競爭者 + +問財運/投資: +- 妻財:財源、收益、資金(主用神) +- 兄弟:劫財、競爭、風險 +- 子孫:生財之源、福氣 +- 父母:文書、證件、平台 +- 官鬼:耗財、壓力 + +問感情/婚姻: +- 男測:妻財為對方,官鬼為情敵 +- 女測:官鬼為對方,妻財為情敵 +- 父母:婚約、文書、家庭 +- 子孫:子女、解憂 + +問健康/疾病: +- 官鬼:病症、病灶(忌神) +- 子孫:醫孫:醫藥、醫生、解災之神(用神) +- 父母:醫院、長輩 +- 兄弟:同輩、助力 + +【思考鏈要求】 +你必須按以下順序顯式輸出推理過程: + +1. 卦象定性:判斷本卦屬性(六沖/六合/歸魂/遊魂),明確宏觀底色。 +2. 用神定位:根據問題確定用神與忌神,查看是否上卦、是否發動。 +3. 旺衰虛實:月建斷旺衰(旺相休囚死),日辰斷生剋(十二長生狀態及沖合),動變斷虛實(化進/化退/化空/化破)。 +4. 生剋路線:列舉世應、動變、日月的具體生剋鏈條,逐條說明對用神的影響。 +5. 特殊組合:僅在符合動靜虛實律的前提下,評估暗動、三合局、回頭生剋等。 +6. 綜合裁決:結合卦象底色與爻象生剋,給出趨勢結論、核心風險點與轉機條件。 + +【力量優先級】 +- 變爻回頭生剋時,變爻力量強於本爻 +- 世應 > 動爻 > 變爻 > 日月 > 靜爻 + +【表達風格】 +專業、明確、克制,像真正會看六爻的人說話。 +不要寫成文學散文,不要堆砌模糊詞,不要故弄高深。 +你可以解釋,但解釋必須圍繞卦象本身展開。 +你的目標不是「像在算卦」,而是「真的按六爻規則解卦」。 + +【簽級參考錨定】 +簽級評定應綜合卦象底色與動變吉凶,參考以下原則: + +- 上上簽:六合卦或非六沖卦 + 用神旺相 + 動爻生世/用神有力 + 無回頭剋及空破。 +- 中上簽:非六沖卦 + 用神有氣 + 存在輕微阻礙(如用神靜而不動、或忌神暗動但可制)。 +- 中下簽:六沖卦底色凶 / 用神衰弱 / 用神受剋但尚有解救 / 動變回頭剋但世爻不傷。 +- 下下簽:六沖卦 + 用神月破空亡 + 動爻回頭剋世/剋用 + 日月無助。 + +若卦象吉凶參半,應以「卦象底色」為第一權重,以「世爻安危」為第二權重。""" + +_WORKER_ROLE_PLAYING_EN = """\ +You are a Liu Yao (Six Lines) divination master who strictly follows the logic of Five Elements (Wu Xing) generation-restriction and hexagram imagery. Your sole task is to produce rule-based professional interpretations based on the structured hexagram data provided. + +[Boundaries & Prohibitions] +- Only answer questions directly related to Liu Yao divination, hexagram analysis, or I Ching principles needed for Liu Yao interpretation. If the user asks an unrelated question, refuse directly and do not answer the unrelated content. +- Only deduce from the six-line information in the input data. Never fabricate data. +- Never introduce external systems such as astrology, Tarot, Ba Zi (Eight Characters), or Zi Wei. +- Never quote long passages from the original I Ching text. Liu Yao centers on Five Elements generation and restriction. +- For investment, lottery, medical, life-or-death, or other high-risk questions, provide only symbolic Liu Yao reference. Never give guaranteed, absolute, or professional-advice-replacing conclusions. + +[Deduction Axioms] (in descending priority) +1. Hexagram Backdrop Rule: First determine the hexagram type (Six-Clash hexagram indicates scattered/quick events, Six-Union hexagram indicates delayed/slow events). This is the irreversible background. Then observe line changes. +2. Movement-Stillness Rule: Still lines do not form special patterns (such as Three Union or Six Union) unless activated by moving lines or Day-Month induction. If a moving line transforms into Void, Break, Tomb, or Exhaustion, the movement yields no result. +3. Generation-Restriction Priority: All generation and restriction is adjudicated by Month Branch prosperity/decline and Day Branch generation/restriction. The Five Element statuses in the input data are established facts and must not be altered. + +[Six Relatives (Liu Qin) Category Mapping] +Based on the question type, the Six Relatives map as follows: + +Career/Work questions: +- Officer (Guan Gui): supervisor, work pressure, position, authority +- Parent (Fu Mu): documents, contracts, projects, organization, credentials +- Wealth (Qi Cai): salary, income, resources +- Children (Zi Sun): subordinates, skills, relief from trouble +- Sibling (Xiong Di): colleagues, competitors + +Wealth/Investment questions: +- Wealth (Qi Cai): financial resources, earnings, capital (primary Yong Shen) +- Sibling (Xiong Di): wealth-draining, competition, risk +- Children (Zi Sun): source of wealth, blessings +- Parent (Fu Mu): documents, licenses, platforms +- Officer (Guan Gui): wealth depletion, pressure + +Relationships/Marriage questions: +- Male querent: Wealth line represents the partner; Officer line represents romantic rival +- Female querent: Officer line represents the partner; Wealth line represents romantic rival +- Parent (Fu Mu): marriage contract, documents, family +- Children (Zi Sun): children, relief + +Health/Illness questions: +- Officer (Guan Gui): illness, pathology (Ji Shen / feared spirit) +- Children (Zi Sun): medicine, doctor, relief spirit (Yong Shen / useful spirit) +- Parent (Fu Mu): hospital, elders +- Sibling (Xiong Di): peers, support + +[Thinking Chain Requirement] +You must explicitly output your reasoning in the following order: + +1. Hexagram Classification: Determine the hexagram type (Six-Clash / Six-Union / Returning Spirit / Wandering Spirit) and establish the macro backdrop. +2. Yong Shen Identification: Based on the question, identify the Yong Shen (useful spirit) and Ji Shen (feared spirit). Check whether they appear in the hexagram and whether they are changing lines. +3. Prosperity & Void: Month Branch determines prosperity/decline (Prosperous / Strong / Resting / Imprisoned / Dead). Day Branch determines day-level generation/restriction, clash, and union. Movement and change determine substance vs. void. +4. Generation-Restriction Chains: List the specific generation-restriction chains between Self Line, Response Line, moving lines, changing lines, Day, and Month. Explain each one's effect on the Yong Shen. +5. Special Combinations: Only when permitted by the Movement-Stillness Rule, assess hidden movement, Three Union patterns, reverse generation/restriction, etc. +6. Comprehensive Verdict: Combine the hexagram backdrop with the line dynamics to produce the trend conclusion, core risk points, and turning-point conditions. + +[Strength Hierarchy] +- When a changing line generates or restricts in reverse, the changing line's strength exceeds the original line +- Self/Response > moving lines > changing lines > Day/Month > still lines + +[Expression Style] +Write in natural, idiomatic American English. Speak like a wise, plain-spoken advisor — not a mystical fortune teller. +Do not use fortune-cookie language, vague metaphors, or poetic mysticism. +Every explanation must be grounded in the hexagram data, but expressed in terms an ordinary American would understand. +Your goal is to make ancient Chinese wisdom accessible and useful to a modern Western reader, not to sound esoteric or profound. + +[Sign-Level Reference Anchoring] +Sign-level assessment should integrate hexagram backdrop and movement/change auspiciousness, referencing the following principles: + +- Top-Top (Shang Shang): Six-Union hexagram or non-Six-Clash hexagram + Yong Shen prosperous + moving line generates Self/Yong Shen with strength + no reverse restriction, void, or break. +- Upper-Middle (Zhong Shang): Non-Six-Clash hexagram + Yong Shen has vitality + minor obstructions exist (e.g. Yong Shen still and unmoving, or Ji Shen secretly moves but can be restrained). +- Lower-Middle (Zhong Xia): Six-Clash hexagram with inauspicious backdrop / Yong Shen weak / Yong Shen receives restriction but still has rescue / moving-then-reverse-restriction but Self Line unharmed. +- Bottom-Bottom (Xia Xia): Six-Clash hexagram + Yong Shen monthly break and void + moving line reverse-restricts Self/restricts Yong Shen + Day and Month offer no help. + +When the hexagram shows mixed auspicious and inauspicious signs, weigh "hexagram backdrop" as the primary factor and "Self Line safety" as the secondary factor.""" _WORKER_OUTPUT_RULES_ZH_CN = """\ 按输出要求严格返回对应的json对象。 -conclusion:必须结合本卦变卦与关键爻位,不可空谈,至少给出2-4条关键依据。 -focus_points:本次解读的核心关注点列表,每项为简短陈述,3-5项适中,应从卦象关键信息中提炼。 -advice:必须逐条对应卦象依据(哪一爻、何种生克冲合旺衰),给出可执行动作,优先回答:最该防什么、最该做什么、何时可推进、何时应暂缓。 -keywords:中文优先四字,必须来自本次卦象核心判断。 -answer:必须是完整解读,覆盖总体判断、当前态势、最终趋势、风险点、转机条件、行动优先级,多段文本段间用\\n\\n分隔,首段直指核心态势(偏吉/偏凶/先难后易/成中有阻等)。 +conclusion:分条输出(至少3条),用语朴实直白,像一个正常人在说话。 + 第1条:先说明这是什么卦,这个卦一般代表什么含义。用白话解释,不要引用古文或卦辞。 + 第2条:结合卦象变动,说明可能有哪些变数和关键影响因素。 + 后续条:给出一个正常人可以听懂的结论,如这件事能不能成、有利因素是什么、风险在哪里。可以用"可能""大概率""倾向于""暂时不太理想"等词汇下判断,不要模棱两可。 +focus_points:断卦要点,告诉读者这个卦具体该怎么看。每项一句话,3-5项。允许使用专业术语(如用神、忌神、世应、动爻、月建、日辰、旺衰、生克冲合等),但每条要点必须既有专业判断也有通俗结论。例如:"用神妻财旺相持世,财运根基稳固""忌神兄弟动而受制,竞争阻碍可化解""变爻回头生世,最终趋势向好"。不要写成建议或泛泛的分析。 +advice:行动建议+卦象原因。每条建议先给出最直白的行动指引(该做什么、怎么做),然后简要附上卦象依据说明为什么这样建议。每条至少2-3句话,要详实具体。覆盖方向:最该做什么、最该防什么、合适时机、何时暂缓。参考写法:"先从自己家里或办公室开始找,重点检查沙发缝隙和抽屉底部,因为变卦父母子水为世爻,暗示钱可能被覆盖物盖住或掉进角落。""回忆一下丢钱前接触过哪些人,尤其是同事或家人,因为兄弟爻主同辈,可能有人无意中捡到但还没还,可以委婉问一圈。" +keywords:中文四字词语,用词通俗、生活化,符合正常人的阅读理解。例如"可以推进""时机未到""注意风险""贵人相助"等。不要用玄奥冷僻的词。 +answer:具体解析,必须用分点形式输出(和conclusion、advice一样分条)。这里可以引入对卦象爻位的深度解读,但要把专业概念用白话讲透,让没学过六爻的人也能看懂意思。不要云里雾里,不要大段古文引用。 sign_level:枚举值固定,必须且只能填以下四个值之一:上上签/中上签/中下签/下下签。""" _WORKER_OUTPUT_RULES_ZH_HANT = """\ 按輸出要求嚴格返回對應的json對象。 -conclusion:必須結合本卦變卦與關鍵爻位,不可空談,至少給出2-4條關鍵依據。 -focus_points:本次解讀的核心關注點列表,每項為簡短陳述,3-5項適中,應從卦象關鍵信息中提煉。 -advice:必須逐條對應卦象依據(哪一爻、何種生剋沖合旺衰),給出可執行動作,優先回答:最該防什麼、最該做什麼、何時可推進、何時應暫緩。 -keywords:繁體中文優先四字,必須來自本次卦象核心判斷。 -answer:必須是完整解讀,覆蓋總體判斷、當前態勢、最終趨勢、風險點、轉機條件、行動優先級,多段文本段間用\\n\\n分隔,首段直指核心態勢(偏吉/偏凶/先難後易/成中有阻等)。 -sign_level:枚舉值固定,必須且只能填以下四個值之一:上上签/中上签/中下签/下下签(必須使用簡體簽字,不可用繁體簽)。""" +conclusion:分條輸出(至少3條),用語樸實直白,像一個正常人在說話。 + 第1條:先說明這是什麼卦,這個卦一般代表什麼含義。用白話解釋,不要引用古文或卦辭。 + 第2條:結合卦象變動,說明可能有哪些變數和關鍵影響因素。 + 後續條:給出一個正常人可以聽懂的結論,如這件事能不能成、有利因素是什麼、風險在哪裡。可以用"可能""大概率""傾向於""暫時不太理想"等詞彙下判斷,不要模稜兩可。 +focus_points:斷卦要點,告訴讀者這個卦具體該怎麼看。每項一句話,3-5項。允許使用專業術語(如用神、忌神、世應、動爻、月建、日辰、旺衰、生剋沖合等),但每條要點必須既有專業判斷也有通俗結論。例如:"用神妻財旺相持世,財運根基穩固""忌神兄弟動而受制,競爭阻礙可化解""變爻回頭生世,最終趨勢向好"。不要寫成建議或泛泛的分析。 +advice:行動建議+卦象原因。每條建議先給出最直白的行動指引(該做什麼、怎麼做),然後簡要附上卦象依據說明為什麼這樣建議。每條至少2-3句話,要詳實具體。覆蓋方向:最該做什麼、最該防什麼、合適時機、何時暫緩。參考寫法:"先從自己家裡或辦公室開始找,重點檢查沙發縫隙和抽屜底部,因為變卦父母子水為世爻,暗示錢可能被覆蓋物蓋住或掉進角落。""回憶一下丟錢前接觸過哪些人,尤其是同事或家人,因為兄弟爻主同輩,可能有人無意中撿到但還沒還,可以委婉問一圈。" +keywords:中文四字詞語,用詞通俗、生活化,符合正常人的閱讀理解。例如"可以推進""時機未到""注意風險""貴人相助"等。不要用玄奧冷僻的詞。 +answer:具體解析,必須用分點形式輸出(和conclusion、advice一樣分條)。這裡可以引入對卦象爻位的深度解讀,但要把專業概念用白話講透,讓沒學過六爻的人也能看懂意思。不要雲裡霧裡,不要大段古文引用。 +sign_level:此欄位為後端協議枚舉,必須且只能填以下四個值之一:上上签/中上签/中下签/下下签;其他文字內容仍必須使用繁體中文。""" _WORKER_OUTPUT_RULES_EN = """\ Return the JSON object strictly following the output schema. -conclusion: Must tie back to the hexagram, changed lines, and key line positions. No vague claims. Provide 2-4 key findings. -focus_points: Core points of this reading, each as a brief statement. 3-5 items, distilled from the most significant chart elements. -advice: Each item must cite a specific chart element (which line, what element interaction or strength condition). Prioritize: biggest risk, top action, favorable timing, when to hold back. -keywords: 2-4 short divinatory phrases drawn from this reading, using terms familiar in esoteric traditions (e.g. karmic crossroads, slender hope, unseen obstacle, waxing fortune, hidden pivot, narrow passage, turning tide, fading twilight). Avoid generic filler. -answer: A complete reading covering overall judgment, current situation, final trend, risk points, turning conditions, and action priorities. Separate paragraphs with \\n\\n. The opening paragraph must state the core verdict directly (e.g. leaning auspicious / leaning inauspicious / difficulty-then-ease / success-with-obstacles). -sign_level: Must be exactly one of: 上上签 / 中上签 / 中下签 / 下下签. Always use the Chinese enum value regardless of language.""" + +═══════════════════════════════════════════════════════════════════════════════ +CRITICAL: WRITE FOR ORDINARY AMERICAN READERS +═══════════════════════════════════════════════════════════════════════════════ +- This is a divination app for everyday people, not scholars of Chinese metaphysics. +- Write in natural, idiomatic American English. Every sentence must make sense to someone who knows nothing about I Ching, Five Elements, or Chinese philosophy. +- DO NOT use Chinese characters anywhere except: + (a) the sign_level enum values (上上签 / 中上签 / 中下签 / 下下签) + (b) the hexagram name when first mentioned (e.g. "Hexagram 53 - 风山渐 (Gradual Progress)") +- NEVER write Chinese in parentheses to explain terms. Instead of "Strong(旺)", just say "strong" or explain "the element 旺, meaning strong or vigorous in this context". +- NEVER transliterate Chinese terms. Write concepts directly in English. +- Minimize ALL Chinese characters in your output. If it's not a sign_level or hexagram name, it should be English. +═══════════════════════════════════════════════════════════════════════════════ + +conclusion: A numbered list (at least 3 items), written in plain, conversational English. + Item 1: Name the hexagram and explain in everyday language what it generally suggests — like you are explaining it to a friend over coffee. + Item 2: Describe what key dynamics or possible changes the reading reveals, using ordinary concepts anyone can follow. + Item 3+: Give a clear, honest assessment an ordinary person can understand. Is the situation likely to work out? What could change things? What should they watch for? Use natural qualifiers like "likely", "looks promising", "may be difficult", "there is a real chance", "the odds favor", etc. Do not sound mystical, absolute, or like a fortune cookie. + +focus_points: Judgment points — tell the reader specifically what the key interpretive elements are in this hexagram reading. 3-5 items, each one sentence. Use professional hexagram terms from the detail chart (six relations like Officer/Wealth/Parent/Children/Sibling, Self/Response, moving lines, Month Branch, Day Branch, prosperity/weakness, clash/union, generation/restriction). Each point must combine a technical observation with a plain-language conclusion. Examples: "The Wealth line is strong at the Self position — your financial foundation is solid." "The Sibling line moves but is constrained — competition will fade." "A changing line turns back to support the Self line — the overall outcome leans positive." Do NOT write these as advice or vague analysis. + +advice: PURE action advice — tell the user what to DO, with ZERO hexagram content. Foreign users cannot understand Liu Yao terminology, so give only plain, direct life advice. EVERY ONE of the following is STRICTLY FORBIDDEN in advice: + - Any hexagram terminology: Officer, Wealth, Parent, Children, Sibling, Self line, Response line, moving line, changing line, line positions, Month Branch, Day Branch, Five Elements, prosperity/weakness, clash/union, generation/restriction, void, hidden, etc. + - Any hexagram-referencing phrases: "this hexagram shows/suggests/indicates...", "according to the lines...", "based on the reading...", "because the X line is...", "the hexagram type suggests..." + - Any Chinese characters, pinyin, or transliterated terms. + CORRECT example (pure advice, what we want): "Start by searching your home thoroughly — focus on low places, under furniture, and inside drawers. Ask family members or roommates if they've seen it. If nothing turns up right away, wait a day and check the same spots again, as things can surface where you least expect." + WRONG example (mixed with hexagram, what we must avoid): "Since the Wealth line is changing and moving toward the Self line, you should search around water sources or places covered by papers. The Sibling line suggests asking colleagues." + Each piece of advice must be at least 2-3 detailed, concrete sentences. Cover: what to actually do, what to watch out for, good timing, when to hold back. + +keywords: 2-4 plain English phrases (3-6 words each), using everyday vocabulary. They should feel like natural takeaways, such as "A good time to act", "Watch your spending", "Help is coming", "Wait and see". Nothing mystical or jargon-like. + +answer: A detailed breakdown in numbered sections (same format as conclusion and advice). Go deeper into the reading here, explaining the hexagram patterns and what they mean. CRITICAL RULE: if you use any technical concept (e.g. a line is "strong" or "weak", a clash, a hidden influence), you MUST immediately explain what that concept means in plain English. The reading should feel insightful and clear, not obscure. Write for someone curious who genuinely wants to understand, not for a fellow practitioner of Chinese metaphysics. + +sign_level: Must be exactly one of: 上上签 / 中上签 / 中下签 / 下下签. Always use these Chinese enum values.""" + +_FOLLOW_UP_ROLE_PLAYING_ZH = """\ +你是一名六爻解卦师,正在回答用户对上一轮解卦结果的追问。用户已经收到过完整的卦象解读,现在对这个结果有后续疑问。这是唯一的一轮追问,你必须给出明确的最终回答。 + +【核心原则】 +- 直接回答用户的追问,给出明确结论,不要模棱两可。 +- 可以引用上一轮的卦象信息来辅助回答,但不要复述整个卦象解读。 +- 不要反问用户,不要以"你想了解哪方面?"等方式回复——这是唯一一次追问机会,直接给出你的判断。 +- 保持专业但平实的语气,不要故弄玄虚。 +""" + +_FOLLOW_UP_ROLE_PLAYING_ZH_HANT = """\ +你是一名六爻解卦師,正在回答使用者對上一輪解卦結果的追問。使用者已經收到過完整的卦象解讀,現在對這個結果有後續疑問。這是唯一的一輪追問,你必須給出明確的最終回答。 + +【核心原則】 +- 直接回答使用者的追問,給出明確結論,不要模稜兩可。 +- 可以引用上一輪的卦象資訊來輔助回答,但不要複述整個卦象解讀。 +- 不要反問使用者,不要以"你想了解哪方面?"等方式回覆——這是唯一一次追問機會,直接給出你的判斷。 +- 保持專業但平實的語氣,不要故弄玄虛。 +""" + +_FOLLOW_UP_ROLE_PLAYING_EN = """\ +You are a Liu Yao divination advisor responding to a follow-up question. The user has already received a complete hexagram reading. They now have a follow-up question about that reading or its implications. This is the ONLY round of follow-up — you MUST give a definitive, final answer. + +[Core Principles] +- Answer the user's follow-up question directly and give a clear, unambiguous conclusion. +- You may reference the previous reading when helpful, but do NOT repeat the entire hexagram analysis. +- Do NOT ask the user follow-up questions or say things like "what aspect would you like to know more about?" — this is their only follow-up chance, so give your judgment directly. +- Keep your tone professional but plain-spoken. Write in natural American English. Do not sound mystical. +""" + +_FOLLOW_UP_OUTPUT_RULES_ZH_CN = """\ +按输出要求返回对应的json对象。这是追问模式,只需要填 status 和 answer。 +answer:直接回答用户的追问,给出明确的最终判断。不要重新做一遍完整解卦。不要反问用户。重点回答用户问什么,篇幅控制在必要范围内。 +status:正常回答填"success",拒绝回答填"refused"。 +""" + +_FOLLOW_UP_OUTPUT_RULES_ZH_HANT = """\ +按輸出要求返回對應的json對象。這是追問模式,只需要填 status 和 answer。 +answer:直接回答使用者的追問,給出明確的最終判斷。不要重新做一遍完整解卦。不要反問使用者。重點回答使用者問什麼,篇幅控制在必要範圍內。 +status:正常回答填"success",拒絕回答填"refused"。 +""" + +_FOLLOW_UP_OUTPUT_RULES_EN = """\ +Return the JSON object. This is follow-up mode — only status and answer are needed. +answer: Answer the user's follow-up question directly with a clear, definitive judgment. Do NOT redo the full hexagram analysis. Do NOT ask the user follow-up questions. Focus on what the user asked, keep it concise but thorough. +status: Use "success" for normal responses, "refused" if the question should not be answered. +""" -def get_worker_role_playing(ai_language: str) -> str: - _ = ai_language - return _WORKER_ROLE_PLAYING +def get_worker_role_playing(language: str) -> str: + if language.startswith("en"): + return _WORKER_ROLE_PLAYING_EN + if language.startswith("zh-Hant") or language.startswith("zh_Hant"): + return _WORKER_ROLE_PLAYING_ZH_HANT + return _WORKER_ROLE_PLAYING_ZH -def get_worker_output_rules(ai_language: str) -> str: - if ai_language.startswith("en"): +def get_worker_output_rules(language: str) -> str: + if language.startswith("en"): return _WORKER_OUTPUT_RULES_EN - if ai_language.startswith("zh-Hant") or ai_language.startswith("zh_Hant"): + if language.startswith("zh-Hant") or language.startswith("zh_Hant"): return _WORKER_OUTPUT_RULES_ZH_HANT return _WORKER_OUTPUT_RULES_ZH_CN + + +def get_follow_up_role_playing(language: str) -> str: + if language.startswith("en"): + return _FOLLOW_UP_ROLE_PLAYING_EN + if language.startswith("zh-Hant") or language.startswith("zh_Hant"): + return _FOLLOW_UP_ROLE_PLAYING_ZH_HANT + return _FOLLOW_UP_ROLE_PLAYING_ZH + + +def get_follow_up_output_rules(language: str) -> str: + if language.startswith("en"): + return _FOLLOW_UP_OUTPUT_RULES_EN + if language.startswith("zh-Hant") or language.startswith("zh_Hant"): + return _FOLLOW_UP_OUTPUT_RULES_ZH_HANT + return _FOLLOW_UP_OUTPUT_RULES_ZH_CN diff --git a/backend/src/core/agentscope/runtime/json_react_agent.py b/backend/src/core/agentscope/runtime/json_react_agent.py index 1b48fc3..45f4d6a 100644 --- a/backend/src/core/agentscope/runtime/json_react_agent.py +++ b/backend/src/core/agentscope/runtime/json_react_agent.py @@ -46,7 +46,7 @@ class JsonReActAgent(ReActAgent): *, output_model: type[BaseModel], ) -> dict[str, Any]: - _, payload = await finalize_json_response( + _, result = await finalize_json_response( model=self.model, formatter=self.formatter, base_messages=[ @@ -56,4 +56,4 @@ class JsonReActAgent(ReActAgent): output_model=output_model, retries=self._finalize_retries, ) - return payload + return result.model_dump(mode="json", by_alias=True) diff --git a/backend/src/core/agentscope/runtime/runner.py b/backend/src/core/agentscope/runtime/runner.py index c4def52..23dd5d5 100644 --- a/backend/src/core/agentscope/runtime/runner.py +++ b/backend/src/core/agentscope/runtime/runner.py @@ -13,7 +13,10 @@ from agentscope.message import Msg from agentscope.tool import Toolkit from agentscope.model import OpenAIChatModel from core.agentscope.prompts.system_prompt import build_system_prompt -from core.agentscope.prompts.user_prompt import build_divination_user_prompt +from core.agentscope.prompts.user_prompt import ( + build_divination_user_prompt, + build_follow_up_user_prompt, +) from core.agentscope.schemas.agui_input import extract_latest_user_payload from core.divination import derive_divination from core.agentscope.runtime.json_react_agent import JsonReActAgent @@ -52,7 +55,7 @@ from core.agentscope.runtime.protocols import PipelineLike @dataclass(frozen=True) class StageExecutionResult: message: Msg - payload: dict[str, Any] + validated_output: WorkerAgentOutputLite | FollowUpOutput response_metadata: dict[str, Any] @@ -209,6 +212,12 @@ class AgentScopeRunner: worker_output_model = resolve_worker_output_model( runtime_mode=runtime_mode.value ) + language = "zh-CN" + if user_context.settings is not None: + prefs = getattr(user_context.settings, "preferences", None) + if prefs is not None: + language = getattr(prefs, "language", "zh-CN") or "zh-CN" + await self._emit_step_event( pipeline=pipeline, run_input=run_input, @@ -222,6 +231,7 @@ class AgentScopeRunner: context_messages=context_messages, run_input=run_input, derived_divination=derived_divination, + language=language, ), toolkit=toolkit, run_input=run_input, @@ -230,8 +240,9 @@ class AgentScopeRunner: pipeline=pipeline, runtime_mode=runtime_mode, derived_divination=derived_divination, + language=language, ) - worker_output = worker_output_model.model_validate(worker_result.payload) + worker_output = worker_result.validated_output await self._emit_step_event( pipeline=pipeline, run_input=run_input, @@ -253,6 +264,7 @@ class AgentScopeRunner: pipeline: PipelineLike, runtime_mode: RuntimeMode, derived_divination: DerivedDivinationData | None, + language: str, ) -> StageExecutionResult: tracking_model = self._build_model(stage_config=stage_config) formatter = OpenAIChatFormatter() @@ -265,28 +277,24 @@ class AgentScopeRunner: emit_text_events=True, emit_tool_events=False, ) - ai_language = "zh-CN" - if user_context.settings is not None: - prefs = getattr(user_context.settings, "preferences", None) - if prefs is not None: - ai_language = getattr(prefs, "ai_language", "zh-CN") or "zh-CN" system_prompt = build_system_prompt( agent_type=stage_config.agent_type, - ai_language=ai_language, + language=language, llm_config=stage_config.llm_config, tools=None, now_utc=datetime.now(timezone.utc), + runtime_mode=runtime_mode.value, ) - _, worker_payload_raw = await finalize_json_response( + _, worker_payload = await finalize_json_response( model=tracking_model, formatter=formatter, base_messages=[Msg("system", system_prompt, "system"), *input_messages], output_model=worker_output_model, retries=2, + language=language, ) - worker_payload = worker_output_model.model_validate(worker_payload_raw) response_metadata = self._llm_pricing_service.build_usage_metadata( model=stage_config.model_code, usage_summary=tracking_model.usage_summary(), @@ -304,9 +312,8 @@ class AgentScopeRunner: name=stage_config.agent_type.value, role="assistant", content=worker_payload.answer, - metadata=worker_payload.model_dump(mode="json", exclude_none=True), ), - payload=worker_payload.model_dump(mode="json", exclude_none=True), + validated_output=worker_payload, response_metadata=response_metadata, ) @@ -316,13 +323,19 @@ class AgentScopeRunner: context_messages: list[Msg], run_input: RunAgentInput, derived_divination: DerivedDivinationData | None, + language: str = "zh-CN", ) -> list[Msg]: if derived_divination is not None: - user_text = build_divination_user_prompt(derived=derived_divination) + user_text = build_divination_user_prompt( + derived=derived_divination, language=language + ) else: - user_text, _ = extract_latest_user_payload(run_input) + raw_user_text, _ = extract_latest_user_payload(run_input) + user_text = build_follow_up_user_prompt( + question=raw_user_text, language=language + ) - if derived_divination is not None and context_messages: + if context_messages: last = context_messages[-1] if last.role == "user": context_messages[-1] = Msg( @@ -332,11 +345,6 @@ class AgentScopeRunner: ) return context_messages - if context_messages: - last = context_messages[-1] - if last.role == "user": - return context_messages - user_msg = Msg(name="user", role="user", content=user_text) return [*context_messages, user_msg] @@ -356,7 +364,7 @@ class AgentScopeRunner: ) -> TrackingChatModel: generate_kwargs: dict[str, Any] = { "timeout": stage_config.llm_config.timeout_seconds, - "extra_body": {"enable_thinking": False}, + "extra_body": {"thinking": {"type": "disabled"}}, } if stage_config.llm_config.temperature is not None: generate_kwargs["temperature"] = stage_config.llm_config.temperature diff --git a/backend/src/core/agentscope/utils/json_finalize.py b/backend/src/core/agentscope/utils/json_finalize.py index 61a71a4..d44fa1c 100644 --- a/backend/src/core/agentscope/utils/json_finalize.py +++ b/backend/src/core/agentscope/utils/json_finalize.py @@ -2,12 +2,18 @@ from __future__ import annotations import json from collections.abc import Awaitable -from typing import Any, Protocol +from typing import Any, Protocol, TypeVar from core.agentscope.utils.parsing import extract_text_content, parse_json_dict from pydantic import BaseModel, ValidationError from agentscope.message import Msg +from core.logging import get_logger + + +logger = get_logger("core.agentscope.utils.json_finalize") + +T = TypeVar("T", bound=BaseModel) class FormatterProtocol(Protocol): @@ -19,6 +25,7 @@ def build_json_finalize_instruction( schema_json: str, attempt: int, validation_error: str = "", + language: str | None = None, ) -> str: error_part = ( "" @@ -29,12 +36,36 @@ def build_json_finalize_instruction( "Fix all missing/invalid fields and regenerate." ) ) + + language_part = "" + if language is not None: + if language.startswith("en"): + language_part = """ + +════════════════════════════════════════════════════════════════════════════════ +ENGLISH OUTPUT REQUIRED - MINIMIZE CHINESE CHARACTERS +════════════════════════════════════════════════════════════════════════════════ +Return JSON only. All string values (except sign_level and hexagram name) must be in English. +Write in natural American English for ordinary readers. +The sign_level MUST be one of: 上上签 / 中上签 / 中下签 / 下下签 +════════════════════════════════════════════════════════════════════════════════""" + elif language.startswith("zh-Hant"): + language_part = """ + +返回 JSON。使用繁體中文。 +sign_level 必須是:上上签 / 中上签 / 中下签 / 下下签(必須使用簡體簽字)""" + else: + language_part = """ + +返回 JSON。使用简体中文。 +sign_level 必须是:上上签 / 中上签 / 中下签 / 下下签""" + return ( "Return JSON only. Do not output markdown, prose, or code fences. " "Follow this JSON Schema exactly and include all required fields. " "Do not call tools.\n\n" - f"[输出结构Output Schema]\n{schema_json}\n\n" - f"[Attempt]\n{attempt}{error_part}" + f"[Output Schema]\n{schema_json}\n\n" + f"[Attempt]\n{attempt}{language_part}{error_part}" ) @@ -43,9 +74,10 @@ async def finalize_json_response( model: Any, formatter: FormatterProtocol, base_messages: list[Msg], - output_model: type[BaseModel], + output_model: type[T], retries: int, -) -> tuple[Any, dict[str, Any]]: + language: str | None = None, +) -> tuple[Any, T]: schema_json = json.dumps( output_model.model_json_schema(), ensure_ascii=True, @@ -63,6 +95,7 @@ async def finalize_json_response( schema_json=schema_json, attempt=attempt, validation_error=last_error, + language=language, ), "user", ), @@ -87,9 +120,7 @@ async def finalize_json_response( try: validated = output_model.model_validate(payload) - return response, validated.model_dump( - mode="json", by_alias=True, exclude_none=True - ) + return response, validated except ValidationError as exc: last_error = str(exc) diff --git a/backend/src/core/config/notification/static_schema.py b/backend/src/core/config/notification/static_schema.py index 0479012..05b1259 100644 --- a/backend/src/core/config/notification/static_schema.py +++ b/backend/src/core/config/notification/static_schema.py @@ -7,7 +7,14 @@ from typing import Literal from uuid import UUID import yaml -from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator +from pydantic import ( + BaseModel, + ConfigDict, + Field, + ValidationError, + field_validator, + model_validator, +) from backend.src.schemas.shared.notification import ( NotificationPayload, @@ -18,6 +25,7 @@ from schemas.enums import NotificationTargetMode class StaticNotificationDefinition(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") + supported_locale_keys: ClassVar[set[str]] = {"zh", "zh_Hant", "en"} source_key: str = Field(min_length=1, max_length=128) version: int = Field(ge=1) @@ -25,10 +33,22 @@ class StaticNotificationDefinition(BaseModel): status: Literal["draft", "published", "revoked"] deleted: bool = False published_at: datetime | None = None - title: str = Field(min_length=1) - body: str = Field(min_length=1) + title: dict[str, str] = Field(min_length=1) + body: dict[str, str] = Field(min_length=1) payload: NotificationPayload = NotificationPayloadNone(action="none") + @field_validator("title", "body") + @classmethod + def validate_i18n_text(cls, value: dict[str, str]) -> dict[str, str]: + invalid_keys = set(value) - cls.supported_locale_keys + if invalid_keys: + raise ValueError("i18n keys must be one of zh, zh_Hant, en") + if "zh" not in value: + raise ValueError("i18n text must include zh") + if any(not text.strip() for text in value.values()): + raise ValueError("i18n text values must be non-empty") + return value + class StaticNotificationTargets(BaseModel): model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") diff --git a/backend/src/core/config/packages/__init__.py b/backend/src/core/config/packages/__init__.py index 34903f7..ab25fd6 100644 --- a/backend/src/core/config/packages/__init__.py +++ b/backend/src/core/config/packages/__init__.py @@ -1,21 +1,9 @@ -from core.config.packages.registry import ( - clear_packages_cache, - get_packages_config_for_region, -) from core.config.packages.schema import ( - PackageConfig, PackageType, ProductCode, - RegionPackagesConfig, - load_packages_config, ) __all__ = [ - "clear_packages_cache", - "get_packages_config_for_region", - "load_packages_config", - "PackageConfig", "PackageType", "ProductCode", - "RegionPackagesConfig", ] diff --git a/backend/src/core/config/packages/registry.py b/backend/src/core/config/packages/registry.py index b6cd208..d723b87 100644 --- a/backend/src/core/config/packages/registry.py +++ b/backend/src/core/config/packages/registry.py @@ -1,34 +1,5 @@ from __future__ import annotations -from core.config.packages.schema import ( - RegionPackagesConfig, - load_packages_config, -) -from utils.paths import get_default_package_config_path, get_package_config_path - - -_CONFIG_CACHE: dict[str, RegionPackagesConfig] = {} - - -def get_packages_config_for_region(country: str) -> RegionPackagesConfig: - if country in _CONFIG_CACHE: - return _CONFIG_CACHE[country] - - region_file = get_package_config_path(country) - if region_file.exists(): - config = load_packages_config(region_file) - _CONFIG_CACHE[country] = config - return config - - default_file = get_default_package_config_path() - if not default_file.exists(): - raise RuntimeError(f"No default packages config found: {default_file}") - - config = load_packages_config(default_file) - _CONFIG_CACHE[country] = config - return config - def clear_packages_cache() -> None: - global _CONFIG_CACHE - _CONFIG_CACHE = {} + pass diff --git a/backend/src/core/config/packages/schema.py b/backend/src/core/config/packages/schema.py index aa07f21..7f66bae 100644 --- a/backend/src/core/config/packages/schema.py +++ b/backend/src/core/config/packages/schema.py @@ -1,11 +1,7 @@ from __future__ import annotations from enum import Enum -from pathlib import Path -from typing import ClassVar, Literal - -import yaml -from pydantic import BaseModel, ConfigDict, Field, ValidationError +from typing import Literal class PackageType(str, Enum): @@ -15,37 +11,7 @@ class PackageType(str, Enum): ProductCode = Literal[ "new_user_pack", - "basic_pack", + "starter_pack", "popular_pack", "premium_pack", ] - - -class PackageConfig(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - product_code: ProductCode - type: PackageType - price: float = Field(ge=0) - credits: int = Field(ge=1) - sort_order: int = Field(default=0, ge=0) - enabled: bool = Field(default=True) - - -class RegionPackagesConfig(BaseModel): - model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid") - - region: str = Field(min_length=1, max_length=8) - currency: str = Field(min_length=1, max_length=8) - packages: list[PackageConfig] = Field(min_length=1) - - -def load_packages_config(path: Path) -> RegionPackagesConfig: - with path.open("r", encoding="utf-8") as file: - loaded: object = yaml.safe_load(file) or {} - if not isinstance(loaded, dict): - raise ValueError(f"Invalid packages config format: {path}") - try: - return RegionPackagesConfig.model_validate(loaded) - except ValidationError as exc: - raise ValueError(f"Invalid packages config data: {path}") from exc diff --git a/backend/src/core/config/settings.py b/backend/src/core/config/settings.py index 3728718..7c945c5 100644 --- a/backend/src/core/config/settings.py +++ b/backend/src/core/config/settings.py @@ -189,8 +189,8 @@ class SensitiveWordSettings(BaseModel): class TestSettings(BaseModel): - phone: str = "" - password: str = "" + email: str = "" + code: str = "" class TaskiqSettings(BaseModel): @@ -228,13 +228,30 @@ class PointsPolicySettings(BaseModel): return self -def _resolve_env_file() -> str: +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" + 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]: + """Resolve env files in order: .env.local overrides .env""" current = Path(__file__).resolve() for parent in [current, *current.parents]: - candidate = parent / ".env" - if candidate.is_file(): - return str(candidate) - return ".env" + env_file = parent / ".env" + if env_file.is_file(): + files = [str(env_file)] + env_local = parent / ".env.local" + if env_local.is_file(): + files.append(str(env_local)) + return files + return [".env"] PROJECT_ROOT = _resolve_project_root() @@ -271,6 +288,7 @@ class Settings(BaseSettings): taskiq: TaskiqSettings = Field(default_factory=TaskiqSettings) agent_runtime: AgentRuntimeSettings = Field(default_factory=AgentRuntimeSettings) points_policy: PointsPolicySettings = Field(default_factory=PointsPolicySettings) + apple_iap: AppleIapSettings = Field(default_factory=AppleIapSettings) feedback_report: FeedbackReportSettings = Field( default_factory=FeedbackReportSettings ) @@ -292,7 +310,7 @@ class Settings(BaseSettings): return self.taskiq.result_backend_url or self.redis.url model_config: ClassVar[SettingsConfigDict] = SettingsConfigDict( - env_file=_resolve_env_file(), + env_file=_resolve_env_files(), env_prefix="ERYAO_", env_nested_delimiter="__", case_sensitive=False, diff --git a/backend/src/core/config/static/database/llm_catalog.yaml b/backend/src/core/config/static/database/llm_catalog.yaml index 0ab2857..4e6f77a 100644 --- a/backend/src/core/config/static/database/llm_catalog.yaml +++ b/backend/src/core/config/static/database/llm_catalog.yaml @@ -41,30 +41,10 @@ llms: output_cost_per_token: 0.000012 cache_hit_cost_per_token: 0.00000012 - - model_code: qwen3.5-35b-a3b - factory_name: dashscope - pricing_tiers: - - max_prompt_tokens: 128000 - input_cost_per_token: 0.0000004 - output_cost_per_token: 0.0000032 - - max_prompt_tokens: 256000 - input_cost_per_token: 0.0000016 - output_cost_per_token: 0.0000128 - - - model_code: deepseek-chat + - model_code: deepseek-v4-flash factory_name: deepseek pricing_tiers: - - max_prompt_tokens: 128000 - input_cost_per_token: 0.000002 - output_cost_per_token: 0.000003 - cache_hit_cost_per_token: 0.0000002 - - - model_code: qwen3.5-27b - factory_name: dashscope - pricing_tiers: - - max_prompt_tokens: 128000 - input_cost_per_token: 0.0000006 - output_cost_per_token: 0.0000048 - - max_prompt_tokens: 256000 - input_cost_per_token: 0.0000018 - output_cost_per_token: 0.0000144 + - max_prompt_tokens: 1000000 + input_cost_per_token: 0.000001 + output_cost_per_token: 0.000002 + cache_hit_cost_per_token: 0.00000002 diff --git a/backend/src/core/config/static/database/system_agents.yaml b/backend/src/core/config/static/database/system_agents.yaml index f5a9538..8476920 100644 --- a/backend/src/core/config/static/database/system_agents.yaml +++ b/backend/src/core/config/static/database/system_agents.yaml @@ -1,24 +1,24 @@ agents: - - agent_type: router - llm_model_code: qwen3.5-flash - status: active - config: - temperature: 0.7 - max_tokens: null - timeout_seconds: 30 - context_messages: - mode: day - count: 2 - enabled_tools: [] + - agent_type: router + llm_model_code: qwen3.5-flash + status: active + config: + temperature: 0.7 + max_tokens: null + timeout_seconds: 30 + context_messages: + mode: day + count: 2 + enabled_tools: [] - - agent_type: worker - llm_model_code: deepseek-chat - status: active - config: - temperature: 0.7 - max_tokens: 2048 - timeout_seconds: 120 - context_messages: - mode: number - count: 20 - enabled_tools: [] + - agent_type: worker + llm_model_code: deepseek-v4-flash + status: active + config: + temperature: 0.7 + max_tokens: 2048 + timeout_seconds: 120 + context_messages: + mode: number + count: 20 + enabled_tools: [] diff --git a/backend/src/core/config/static/notifications/welcome_points.yaml b/backend/src/core/config/static/notifications/welcome_points.yaml index d6c4a61..b9e2da1 100644 --- a/backend/src/core/config/static/notifications/welcome_points.yaml +++ b/backend/src/core/config/static/notifications/welcome_points.yaml @@ -1,10 +1,16 @@ notification: source_key: welcome_points - version: 1 + version: 2 type: system status: published - title: 欢迎来到觅爻 - body: 你已获得新用户奖励,点击前往积分页查看当前余额。 + title: + zh: 欢迎来到觅爻 + zh_Hant: 歡迎來到覓爻 + en: Welcome to MeiYao + body: + zh: 你已获得新用户奖励,点击前往积分页查看当前余额。 + zh_Hant: 你已獲得新用戶獎勵,點擊前往積分頁查看當前餘額。 + en: You have received a new user reward. Tap to check your points balance. payload: action: open_route route: /points diff --git a/backend/src/core/config/static/packages/default.yaml b/backend/src/core/config/static/packages/default.yaml deleted file mode 100644 index a053c1e..0000000 --- a/backend/src/core/config/static/packages/default.yaml +++ /dev/null @@ -1,30 +0,0 @@ -region: DEFAULT -currency: USD -packages: - - product_code: new_user_pack - type: starter - price: 0.99 - credits: 60 - sort_order: 0 - enabled: true - - - product_code: basic_pack - type: regular - price: 4.99 - credits: 100 - sort_order: 10 - enabled: true - - - product_code: popular_pack - type: regular - price: 7.99 - credits: 210 - sort_order: 20 - enabled: true - - - product_code: premium_pack - type: regular - price: 12.99 - credits: 415 - sort_order: 30 - enabled: true diff --git a/backend/src/core/config/static/packages/mapping.yaml b/backend/src/core/config/static/packages/mapping.yaml new file mode 100644 index 0000000..8faa7f6 --- /dev/null +++ b/backend/src/core/config/static/packages/mapping.yaml @@ -0,0 +1,25 @@ +product_mappings: + new_user_pack: + app_store_product_id: com.meeyao.qianwen.new_user_pack + credits: 60 + type: starter + sort_order: 0 + enabled: true + starter_pack: + app_store_product_id: com.meeyao.qianwen.starter_pack + credits: 100 + type: regular + sort_order: 10 + enabled: true + popular_pack: + app_store_product_id: com.meeyao.qianwen.popular_pack + credits: 210 + type: regular + sort_order: 20 + enabled: true + premium_pack: + app_store_product_id: com.meeyao.qianwen.premium_pack + credits: 415 + type: regular + sort_order: 30 + enabled: true diff --git a/backend/src/core/config/static/packages/us.yaml b/backend/src/core/config/static/packages/us.yaml deleted file mode 100644 index f9eaac8..0000000 --- a/backend/src/core/config/static/packages/us.yaml +++ /dev/null @@ -1,30 +0,0 @@ -region: US -currency: USD -packages: - - product_code: new_user_pack - type: starter - price: 0.99 - credits: 60 - sort_order: 0 - enabled: true - - - product_code: basic_pack - type: regular - price: 4.99 - credits: 100 - sort_order: 10 - enabled: true - - - product_code: popular_pack - type: regular - price: 7.99 - credits: 210 - sort_order: 20 - enabled: true - - - product_code: premium_pack - type: regular - price: 12.99 - credits: 415 - sort_order: 30 - enabled: true diff --git a/backend/src/models/__init__.py b/backend/src/models/__init__.py index d2982c9..69dbe90 100644 --- a/backend/src/models/__init__.py +++ b/backend/src/models/__init__.py @@ -3,6 +3,7 @@ from __future__ import annotations from .agent_chat_message import AgentChatMessage from .agent_chat_session import AgentChatSession from .anonymous_session_snapshot import AnonymousSessionSnapshot +from .apple_iap_transaction import AppleIapTransaction from .auth_user import AuthUser from .invite_code import InviteCode from .llm import Llm @@ -20,6 +21,7 @@ __all__ = [ "AgentChatMessage", "AgentChatSession", "AnonymousSessionSnapshot", + "AppleIapTransaction", "AuthUser", "InviteCode", "Llm", diff --git a/backend/src/models/apple_iap_transaction.py b/backend/src/models/apple_iap_transaction.py new file mode 100644 index 0000000..583d03e --- /dev/null +++ b/backend/src/models/apple_iap_transaction.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import uuid + +from sqlalchemy import ( + BigInteger, + CheckConstraint, + Index, + JSON, + String, + Text, + UniqueConstraint, + text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID +from sqlalchemy.orm import Mapped, mapped_column + +from core.db.base import Base, TimestampMixin + + +class AppleIapTransaction(TimestampMixin, Base): + __tablename__ = "apple_iap_transactions" + __table_args__ = ( + CheckConstraint( + "environment in ('Sandbox', 'Production')", + name="ck_apple_iap_transactions_environment", + ), + CheckConstraint( + "status in ('received', 'verified', 'granted', 'failed', 'refunded', 'refunded_insufficient', 'revoked')", + name="ck_apple_iap_transactions_status", + ), + UniqueConstraint( + "transaction_id", name="uq_apple_iap_transactions_transaction_id" + ), + UniqueConstraint( + "ledger_event_id", name="uq_apple_iap_transactions_ledger_event_id" + ), + Index( + "ix_apple_iap_transactions_user_created_at", + "user_id", + text("created_at DESC"), + ), + Index( + "ix_apple_iap_transactions_status_updated_at", + "status", + text("updated_at DESC"), + ), + ) + + id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + primary_key=True, + default=uuid.uuid4, + ) + user_id: Mapped[uuid.UUID] = mapped_column( + UUID(as_uuid=True), + nullable=False, + ) + product_code: Mapped[str] = mapped_column(String(32), nullable=False) + app_store_product_id: Mapped[str] = mapped_column(String(128), nullable=False) + transaction_id: Mapped[str] = mapped_column(String(64), nullable=False) + original_transaction_id: Mapped[str | None] = mapped_column( + String(64), nullable=True + ) + web_order_line_item_id: Mapped[str | None] = mapped_column( + String(64), nullable=True + ) + environment: Mapped[str] = mapped_column(String(16), nullable=False) + bundle_id: Mapped[str] = mapped_column(String(128), nullable=False) + app_account_token: Mapped[uuid.UUID | None] = mapped_column( + UUID(as_uuid=True), nullable=True + ) + purchase_date: Mapped[str] = mapped_column( + Text, + nullable=False, + ) + revocation_date: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(24), nullable=False) + credits: Mapped[int] = mapped_column(BigInteger, nullable=False) + currency: Mapped[str | None] = mapped_column(String(8), nullable=True) + price_milliunits: Mapped[int | None] = mapped_column(BigInteger, nullable=True) + ledger_event_id: Mapped[str | None] = mapped_column(String(64), nullable=True) + signed_transaction_info: Mapped[str] = mapped_column(Text, nullable=False) + apple_payload_json: Mapped[dict[str, object]] = mapped_column( + "apple_payload", + JSON().with_variant(JSONB, "postgresql"), + nullable=False, + server_default=text("'{}'::jsonb"), + default=dict, + ) + failure_code: Mapped[str | None] = mapped_column(String(64), nullable=True) diff --git a/backend/src/models/notification.py b/backend/src/models/notification.py index db5a976..c00c927 100644 --- a/backend/src/models/notification.py +++ b/backend/src/models/notification.py @@ -3,12 +3,12 @@ from __future__ import annotations import uuid from datetime import datetime -from sqlalchemy import CheckConstraint, DateTime, Index, String, Text, text +from sqlalchemy import CheckConstraint, DateTime, Index, String, text from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column from core.db.base import Base, SoftDeleteMixin, TimestampMixin -from core.db.types import json_jsonb +from core.db.types import json_jsonb as jsonb from schemas.enums import NotificationTargetMode @@ -57,10 +57,14 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base): source_key: Mapped[str | None] = mapped_column(String(128), nullable=True) source_version: Mapped[int | None] = mapped_column(nullable=True) content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True) - title: Mapped[str] = mapped_column(Text, nullable=False) - body: Mapped[str] = mapped_column(Text, nullable=False) + title: Mapped[dict[str, str]] = mapped_column( + jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict, + ) + body: Mapped[dict[str, str]] = mapped_column( + jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict, + ) payload: Mapped[dict[str, object]] = mapped_column( - json_jsonb, + jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict, diff --git a/backend/src/models/points_audit_ledger.py b/backend/src/models/points_audit_ledger.py index c93e9f5..6b218df 100644 --- a/backend/src/models/points_audit_ledger.py +++ b/backend/src/models/points_audit_ledger.py @@ -36,11 +36,11 @@ class PointsAuditLedger(TimestampMixin, Base): name="ck_points_audit_ledger_balance_after_non_negative", ), CheckConstraint( - "change_type in ('register', 'consume', 'grant', 'adjust')", + "change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_audit_ledger_change_type", ), CheckConstraint( - "biz_type is null or biz_type = 'chat'", + "biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_audit_ledger_biz_type", ), CheckConstraint( diff --git a/backend/src/models/points_ledger.py b/backend/src/models/points_ledger.py index 5e8c9af..aa546c1 100644 --- a/backend/src/models/points_ledger.py +++ b/backend/src/models/points_ledger.py @@ -29,21 +29,22 @@ class PointsLedger(TimestampMixin, Base): "balance_after >= 0", name="ck_points_ledger_balance_after_non_negative" ), CheckConstraint( - "change_type in ('register', 'consume', 'grant', 'adjust')", + "change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')", name="ck_points_ledger_change_type", ), CheckConstraint( - "biz_type is null or biz_type = 'chat'", + "biz_type is null or biz_type in ('chat', 'payment')", name="ck_points_ledger_biz_type", ), CheckConstraint( - "((change_type = 'register' and biz_type is null and biz_id is null) or " - "(change_type in ('consume', 'grant', 'adjust') and biz_type = 'chat' and biz_id is not null))", + "((change_type in ('register', 'adjust') and biz_type is null and biz_id is null) or " + "(change_type = 'consume' and biz_type = 'chat' and biz_id is not null) or " + "(change_type in ('purchase', 'refund') and biz_type = 'payment' and biz_id is not null))", name="ck_points_ledger_biz_binding", ), CheckConstraint( - "((change_type in ('register', 'grant') and direction = 1) or " - "(change_type = 'consume' and direction = -1) or " + "((change_type in ('register', 'purchase') and direction = 1) or " + "(change_type in ('consume', 'refund') and direction = -1) or " "(change_type = 'adjust' and direction in (1, -1)))", name="ck_points_ledger_direction_by_change_type", ), @@ -72,10 +73,26 @@ class PointsLedger(TimestampMixin, Base): ), CheckConstraint( "(change_type <> 'adjust' or (" - "(metadata ? 'ext') and (metadata->'ext' ? 'ticket_id') and " - "coalesce(metadata #>> '{ext,ticket_id}', '') <> ''))", + "(metadata ? 'ext') and (metadata->'ext' ? 'reason') and " + "coalesce(metadata #>> '{ext,reason}', '') <> ''))", name="ck_points_ledger_metadata_adjust_shape", ), + CheckConstraint( + "(change_type not in ('purchase', 'refund') or (" + "(metadata ? 'ext') and (metadata->'ext' ? 'source') and (metadata->'ext' ? 'platform') and " + "(metadata->'ext' ? 'product_code') and (metadata->'ext' ? 'transaction_id') and " + "coalesce(metadata #>> '{ext,source}', '') <> '' and " + "coalesce(metadata #>> '{ext,platform}', '') <> '' and " + "coalesce(metadata #>> '{ext,product_code}', '') <> '' and " + "coalesce(metadata #>> '{ext,transaction_id}', '') <> ''))", + name="ck_points_ledger_metadata_payment_shape", + ), + CheckConstraint( + "(change_type <> 'refund' or (" + "(metadata ? 'ext') and (metadata->'ext' ? 'original_event_id') and " + "coalesce(metadata #>> '{ext,original_event_id}', '') <> ''))", + name="ck_points_ledger_metadata_refund_shape", + ), UniqueConstraint("user_id", "event_id", name="uq_points_ledger_user_event"), Index("ix_points_ledger_user_created_at", "user_id", text("created_at DESC")), Index("ix_points_ledger_biz_type_biz_id", "biz_type", "biz_id"), diff --git a/backend/src/schemas/agent/__init__.py b/backend/src/schemas/agent/__init__.py index f3acdb5..970c22f 100644 --- a/backend/src/schemas/agent/__init__.py +++ b/backend/src/schemas/agent/__init__.py @@ -6,8 +6,9 @@ from schemas.agent.forwarded_props import ( ) from schemas.agent.forwarded_props import RuntimeMode from schemas.agent.runtime_models import ( - AgentOutput, + PersistedAgentOutput, RunStatus, + SignLevel, ToolAgentOutput, ToolStatus, WorkerAgentOutputLite, @@ -18,11 +19,12 @@ from schemas.agent.visibility import SystemVisibilityBit, VisibilityMask, bit_ma __all__ = [ "AgentType", - "AgentOutput", + "PersistedAgentOutput", "ForwardedPropsPayload", "ClientTimeContext", "RunStatus", "RuntimeMode", + "SignLevel", "SystemAgentLLMConfig", "SystemVisibilityBit", "ToolAgentOutput", diff --git a/backend/src/schemas/agent/runtime_models.py b/backend/src/schemas/agent/runtime_models.py index 1ae725a..b493339 100644 --- a/backend/src/schemas/agent/runtime_models.py +++ b/backend/src/schemas/agent/runtime_models.py @@ -1,7 +1,7 @@ from __future__ import annotations from enum import Enum -from typing import Any, Literal +from typing import Any from pydantic import BaseModel, ConfigDict, Field @@ -11,6 +11,7 @@ from schemas.domain.divination import DerivedDivinationData class RunStatus(str, Enum): SUCCESS = "success" FAILED = "failed" + REFUSED = "refused" class ToolStatus(str, Enum): @@ -19,6 +20,13 @@ class ToolStatus(str, Enum): PARTIAL = "partial" +class SignLevel(str, Enum): + BEST = "上上签" + GOOD = "中上签" + MODERATE = "中下签" + WORST = "下下签" + + class ErrorInfo(BaseModel): model_config = ConfigDict(extra="forbid") @@ -42,13 +50,13 @@ class ToolAgentOutput(BaseModel): class WorkerAgentOutputLite(BaseModel): model_config = ConfigDict(extra="forbid") - status: RunStatus = RunStatus.SUCCESS - sign_level: Literal["上上签", "中上签", "中下签", "下下签"] - conclusion: list[str] = Field(min_length=1, max_length=6) - focus_points: list[str] = Field(default_factory=list, max_length=6) - advice: list[str] = Field(min_length=1, max_length=6) - keywords: list[str] = Field(min_length=3, max_length=8) + status: RunStatus + sign_level: SignLevel answer: str = Field(min_length=1, max_length=4000) + conclusion: list[str] = Field(default_factory=list, max_length=6) + focus_points: list[str] = Field(default_factory=list, max_length=6) + advice: list[str] = Field(default_factory=list, max_length=6) + keywords: list[str] = Field(default_factory=list, max_length=8) error: ErrorInfo | None = None @@ -60,14 +68,21 @@ class FollowUpOutput(BaseModel): error: ErrorInfo | None = None -class AgentOutput(WorkerAgentOutputLite): +class PersistedAgentOutput(BaseModel): model_config = ConfigDict(extra="forbid") + status: RunStatus + sign_level: SignLevel + answer: str = Field(min_length=1, max_length=4000) + conclusion: list[str] = Field(default_factory=list, max_length=6) + focus_points: list[str] = Field(default_factory=list, max_length=6) + advice: list[str] = Field(default_factory=list, max_length=6) + keywords: list[str] = Field(default_factory=list, max_length=8) + error: ErrorInfo | None = None divination_derived: DerivedDivinationData | None = None -WorkerAgentOutput = WorkerAgentOutputLite -RuntimeAgentOutput = AgentOutput | FollowUpOutput +RuntimeAgentOutput = PersistedAgentOutput | FollowUpOutput def resolve_worker_output_model( diff --git a/backend/src/schemas/domain/chat_message.py b/backend/src/schemas/domain/chat_message.py index 234591a..153ed6c 100644 --- a/backend/src/schemas/domain/chat_message.py +++ b/backend/src/schemas/domain/chat_message.py @@ -6,7 +6,7 @@ from typing import Any, ClassVar from uuid import UUID from pydantic import BaseModel, ConfigDict, Field -from schemas.agent.runtime_models import AgentOutput, FollowUpOutput +from schemas.agent.runtime_models import FollowUpOutput, PersistedAgentOutput from ..agent import AgentType, ToolAgentOutput @@ -25,7 +25,7 @@ class AgentChatMessageMetadata(BaseModel): agent_type: AgentType | None = None user_message_attachments: list[UserMessageAttachment] | None = None tool_agent_output: ToolAgentOutput | None = None - agent_output: AgentOutput | FollowUpOutput | None = None + agent_output: PersistedAgentOutput | FollowUpOutput | None = None class AgentChatMessage(BaseModel): diff --git a/backend/src/schemas/domain/points.py b/backend/src/schemas/domain/points.py index 0c7f81f..b78d63a 100644 --- a/backend/src/schemas/domain/points.py +++ b/backend/src/schemas/domain/points.py @@ -43,26 +43,26 @@ class ConsumeLedgerMetadata(PointsLedgerMetadataBase): charge: PointsChargeSnapshot -class GrantLedgerMetadata(PointsLedgerMetadataBase): - charge: PointsChargeSnapshot | None = None - - class AdjustLedgerMetadata(PointsLedgerMetadataBase): charge: PointsChargeSnapshot | None = None @model_validator(mode="after") - def validate_ticket(self) -> "AdjustLedgerMetadata": - ticket_id = self.ext.get("ticket_id") - if not isinstance(ticket_id, str) or not ticket_id.strip(): - raise ValueError("ext.ticket_id is required for adjust") + def validate_reason(self) -> "AdjustLedgerMetadata": + reason = self.ext.get("reason") + if not isinstance(reason, str) or not reason.strip(): + raise ValueError("ext.reason is required for adjust") return self +class PurchaseLedgerMetadata(PointsLedgerMetadataBase): + pass + + PointsLedgerMetadata = ( RegisterLedgerMetadata | ConsumeLedgerMetadata - | GrantLedgerMetadata | AdjustLedgerMetadata + | PurchaseLedgerMetadata ) @@ -75,8 +75,6 @@ def parse_points_ledger_metadata( return RegisterLedgerMetadata.model_validate(payload) if change_type == PointsChangeType.CONSUME: return ConsumeLedgerMetadata.model_validate(payload) - if change_type == PointsChangeType.GRANT: - return GrantLedgerMetadata.model_validate(payload) return AdjustLedgerMetadata.model_validate(payload) @@ -114,17 +112,29 @@ class ApplyPointsChangeCommand(BaseModel): raise ValueError("consume must use direction=-1 and chat binding") return self - if self.change_type == PointsChangeType.GRANT: - if ( - self.direction != 1 - or self.biz_type != PointsBizType.CHAT - or self.biz_id is None - ): - raise ValueError("grant must use direction=1 and chat binding") + if self.change_type == PointsChangeType.ADJUST: + if self.biz_type is not None or self.biz_id is not None: + raise ValueError("adjust must not have biz binding") + return self + + if self.change_type == PointsChangeType.PURCHASE: + if ( + self.direction != 1 + or self.biz_type != PointsBizType.PAYMENT + or self.biz_id is None + ): + raise ValueError("purchase must use direction=1 and payment binding") + return self + + if self.change_type == PointsChangeType.REFUND: + if ( + self.direction != -1 + or self.biz_type != PointsBizType.PAYMENT + or self.biz_id is None + ): + raise ValueError("refund must use direction=-1 and payment binding") return self - if self.biz_type != PointsBizType.CHAT or self.biz_id is None: - raise ValueError("adjust must use chat binding") return self diff --git a/backend/src/schemas/enums.py b/backend/src/schemas/enums.py index a87615a..c8b9ba7 100644 --- a/backend/src/schemas/enums.py +++ b/backend/src/schemas/enums.py @@ -69,12 +69,14 @@ class SessionType(str, Enum): class PointsChangeType(str, Enum): REGISTER = "register" CONSUME = "consume" - GRANT = "grant" ADJUST = "adjust" + PURCHASE = "purchase" + REFUND = "refund" class PointsBizType(str, Enum): CHAT = "chat" + PAYMENT = "payment" class PointsOperatorType(str, Enum): diff --git a/backend/src/schemas/shared/user.py b/backend/src/schemas/shared/user.py index 01dcc1c..bf7e5e0 100644 --- a/backend/src/schemas/shared/user.py +++ b/backend/src/schemas/shared/user.py @@ -7,16 +7,13 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from pydantic import BaseModel, ConfigDict, Field, field_validator _BCP47_PATTERN = re.compile(r"^[A-Za-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$") -_COUNTRY_PATTERN = re.compile(r"^[A-Z]{2}$") class PreferenceSettings(BaseModel): - interface_language: str = "zh-CN" - ai_language: str = "zh-CN" + language: str = "zh-CN" timezone: str = "Asia/Shanghai" - country: str = "US" - @field_validator("interface_language", "ai_language") + @field_validator("language") @classmethod def validate_language(cls, value: str) -> str: if not _BCP47_PATTERN.fullmatch(value): @@ -32,14 +29,6 @@ class PreferenceSettings(BaseModel): raise ValueError("timezone must be a valid IANA timezone") from exc return value - @field_validator("country") - @classmethod - def validate_country(cls, value: str) -> str: - normalized = value.upper() - if not _COUNTRY_PATTERN.fullmatch(normalized): - raise ValueError("country must be an ISO 3166-1 alpha-2 code") - return normalized - class NotificationSettings(BaseModel): allow_notifications: bool = True diff --git a/backend/src/utils/__init__.py b/backend/src/utils/__init__.py index aec8823..93fc284 100644 --- a/backend/src/utils/__init__.py +++ b/backend/src/utils/__init__.py @@ -6,7 +6,6 @@ from utils.paths import ( get_gua_catalog_path, get_llm_catalog_config_path, get_notification_config_dir, - get_package_config_path, get_packages_config_dir, get_src_root, get_static_config_dir, @@ -21,7 +20,6 @@ __all__ = [ "get_gua_catalog_path", "get_llm_catalog_config_path", "get_notification_config_dir", - "get_package_config_path", "get_packages_config_dir", "get_src_root", "get_static_config_dir", diff --git a/backend/src/utils/paths.py b/backend/src/utils/paths.py index 61704a9..49e135e 100644 --- a/backend/src/utils/paths.py +++ b/backend/src/utils/paths.py @@ -19,6 +19,10 @@ def get_packages_config_dir() -> Path: return get_static_config_dir() / "packages" +def get_default_package_config_path() -> Path: + return get_packages_config_dir() / "default.yaml" + + def get_database_config_dir() -> Path: return get_static_config_dir() / "database" @@ -31,14 +35,6 @@ def get_divination_data_dir() -> Path: return get_src_root() / "core/divination/data" -def get_package_config_path(country: str) -> Path: - return get_packages_config_dir() / f"{country.lower()}.yaml" - - -def get_default_package_config_path() -> Path: - return get_packages_config_dir() / "default.yaml" - - def get_llm_catalog_config_path() -> Path: return get_database_config_dir() / "llm_catalog.yaml" diff --git a/backend/src/v1/agent/schemas.py b/backend/src/v1/agent/schemas.py index 50c67b2..fb23535 100644 --- a/backend/src/v1/agent/schemas.py +++ b/backend/src/v1/agent/schemas.py @@ -7,7 +7,7 @@ from uuid import UUID from pydantic import BaseModel, ConfigDict, Field -from schemas.agent.runtime_models import ErrorInfo +from schemas.agent.runtime_models import ErrorInfo, RunStatus, SignLevel from schemas.domain.chat_message import AgentChatMessage from schemas.domain.divination import DerivedDivinationData @@ -220,8 +220,8 @@ class HistoryMessage(BaseModel): class HistoryAgentOutput(BaseModel): model_config = ConfigDict(extra="forbid") - status: Literal["success", "failed"] | None = None - sign_level: Literal["上上签", "中上签", "中下签", "下下签"] | None = None + status: RunStatus | None = None + sign_level: SignLevel | None = None conclusion: list[str] = Field(default_factory=list) focus_points: list[str] = Field(default_factory=list) advice: list[str] = Field(default_factory=list) diff --git a/backend/src/v1/auth/gateway.py b/backend/src/v1/auth/gateway.py index 76a6c4d..ece45d3 100644 --- a/backend/src/v1/auth/gateway.py +++ b/backend/src/v1/auth/gateway.py @@ -78,15 +78,18 @@ class SupabaseAuthGateway(AuthServiceGateway): async def create_email_session( self, request: EmailSessionCreateRequest ) -> SessionResponse: - if config.runtime.environment == "dev": - return await create_dev_email_session( - request=request, - client=self._get_client(), - admin_client=self._get_admin_client(), - auth_unavailable_detail=AUTH_UNAVAILABLE_DETAIL, - is_auth_upstream_unavailable=_is_auth_upstream_unavailable, - map_auth_response=_map_auth_response, - ) + test_email = config.test.email.strip() + test_code = config.test.code.strip() + if test_email and test_code: + if request.email == test_email and request.token == test_code: + return await create_dev_email_session( + request=request, + client=self._get_client(), + admin_client=self._get_admin_client(), + auth_unavailable_detail=AUTH_UNAVAILABLE_DETAIL, + is_auth_upstream_unavailable=_is_auth_upstream_unavailable, + map_auth_response=_map_auth_response, + ) client = self._get_client() payload: dict[str, Any] = { diff --git a/backend/src/v1/auth/router.py b/backend/src/v1/auth/router.py index de106ad..645349d 100644 --- a/backend/src/v1/auth/router.py +++ b/backend/src/v1/auth/router.py @@ -22,6 +22,7 @@ from v1.auth.schemas import ( from v1.auth.service import AuthService from v1.points.repository import PointsRepository from v1.points.service import PointsService +from v1.users.repository import SQLAlchemyUserRepository router = APIRouter(prefix="/auth", tags=["auth"]) @@ -72,14 +73,30 @@ async def create_email_session( window_seconds=300, ) result = await service.create_email_session(payload) + user_id = UUID(result.user.id) + + if payload.language is not None or payload.timezone is not None: + user_repository = SQLAlchemyUserRepository(session) + profile = await user_repository.get_profile_by_user_id(user_id=user_id) + if profile is not None: + settings: dict[str, object] = dict(profile.settings or {}) + prefs_raw = settings.get("preferences", {}) + preferences: dict[str, object] = dict(prefs_raw) if isinstance(prefs_raw, dict) else {} + if payload.language is not None: + preferences["language"] = payload.language + if payload.timezone is not None: + preferences["timezone"] = payload.timezone + settings["preferences"] = preferences + profile.settings = settings + points_service = PointsService(repository=PointsRepository(session)) bonus_result = await points_service.grant_register_bonus_if_eligible( - user_id=UUID(result.user.id), + user_id=user_id, user_email=result.user.email, ) notification_service = NotificationService(NotificationRepository(session)) linked_count = await notification_service.link_notifications_for_registered_user( - user_id=UUID(result.user.id), + user_id=user_id, is_first_registration=bonus_result.is_first_registration, ) await session.commit() diff --git a/backend/src/v1/auth/schemas.py b/backend/src/v1/auth/schemas.py index 3a3240a..655c07a 100644 --- a/backend/src/v1/auth/schemas.py +++ b/backend/src/v1/auth/schemas.py @@ -17,6 +17,8 @@ class EmailSessionCreateRequest(BaseModel): email: str = Field(pattern=SUPABASE_EMAIL_PATTERN) token: str = Field(min_length=6, max_length=6) + language: str | None = Field(default=None, max_length=20) + timezone: str | None = Field(default=None, max_length=50) class SessionRefreshRequest(BaseModel): diff --git a/backend/src/v1/notifications/router.py b/backend/src/v1/notifications/router.py index 35418cb..0051c2c 100644 --- a/backend/src/v1/notifications/router.py +++ b/backend/src/v1/notifications/router.py @@ -1,11 +1,13 @@ from __future__ import annotations +from datetime import datetime from typing import Annotated from fastapi import APIRouter, Depends, Query from core.logging import get_logger from core.auth.models import CurrentUser +from core.http.errors import ApiProblemError, problem_payload from v1.notifications.dependencies import get_notification_service from v1.notifications.schemas import ( MarkAllReadResponse, @@ -13,33 +15,42 @@ from v1.notifications.schemas import ( NotificationListResponse, UnreadCountResponse, ) -from v1.notifications.service import NotificationService +from v1.notifications.service import NotificationService, normalize_locale from v1.users.dependencies import get_current_user router = APIRouter(prefix="/notifications", tags=["notifications"]) logger = get_logger("v1.notifications.router") +def _parse_cursor(cursor: str | None) -> datetime | None: + if cursor is None: + return None + try: + return datetime.fromisoformat(cursor.replace("Z", "+00:00")) + except ValueError as exc: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="NOTIFICATION_INVALID_CURSOR", + detail="Notification cursor must be an ISO 8601 datetime", + params={"cursor": cursor}, + ), + ) from exc + + @router.get("", response_model=NotificationListResponse) async def list_notifications( service: Annotated[NotificationService, Depends(get_notification_service)], current_user: Annotated[CurrentUser, Depends(get_current_user)], limit: int = Query(default=20, ge=1, le=50), cursor: str | None = Query(default=None), + locale: str | None = Query(default=None), ) -> NotificationListResponse: - from datetime import datetime - - parsed_cursor = None - if cursor is not None: - try: - parsed_cursor = datetime.fromisoformat(cursor.replace("Z", "+00:00")) - except (ValueError, AttributeError): - parsed_cursor = None - result = await service.list_notifications( user_id=current_user.id, limit=limit, - cursor=parsed_cursor, + cursor=_parse_cursor(cursor), + locale=normalize_locale(locale), ) logger.info( "Notification list fetched", @@ -89,14 +100,13 @@ async def mark_notification_read( notification_id: str, service: Annotated[NotificationService, Depends(get_notification_service)], current_user: Annotated[CurrentUser, Depends(get_current_user)], + locale: str | None = Query(default=None), ) -> NotificationItemResponse: from uuid import UUID try: uid = UUID(notification_id) except ValueError: - from core.http.errors import ApiProblemError, problem_payload - raise ApiProblemError( status_code=404, detail=problem_payload( @@ -108,6 +118,7 @@ async def mark_notification_read( item = await service.mark_read( user_notification_id=uid, user_id=current_user.id, + locale=normalize_locale(locale), ) logger.info( "Notification marked as read", diff --git a/backend/src/v1/notifications/service.py b/backend/src/v1/notifications/service.py index 3c48537..2136713 100644 --- a/backend/src/v1/notifications/service.py +++ b/backend/src/v1/notifications/service.py @@ -13,6 +13,35 @@ from v1.notifications.schemas import ( NotificationPayload, ) +DEFAULT_LOCALE = "zh" +SUPPORTED_LOCALES = frozenset({"zh", "zh_Hant", "en"}) + + +def resolve_i18n_text(i18n_dict: dict[str, str], locale: str) -> str: + if not i18n_dict: + return "" + if locale in i18n_dict: + return i18n_dict[locale] + if DEFAULT_LOCALE in i18n_dict: + return i18n_dict[DEFAULT_LOCALE] + return "" + + +def normalize_locale(raw: str | None) -> str: + if raw is None: + return DEFAULT_LOCALE + locale = raw.strip() + if locale in SUPPORTED_LOCALES: + return locale + lower = locale.lower().replace("-", "_") + if lower in ("zh_cn", "zh_hans", "zh"): + return "zh" + if lower in ("zh_tw", "zh_hant", "zh_hk"): + return "zh_Hant" + if lower.startswith("en"): + return "en" + return DEFAULT_LOCALE + @dataclass(frozen=True) class NotificationListItem: @@ -44,6 +73,7 @@ class NotificationService: user_id: UUID, limit: int = 20, cursor: datetime | None = None, + locale: str = DEFAULT_LOCALE, ) -> NotificationListResult: actual_limit = min(limit, 50) rows = await self._repository.list_notifications( @@ -65,8 +95,8 @@ class NotificationService: id=un.id, notification_id=n.id, type=n.type, - title=n.title, - body=n.body, + title=resolve_i18n_text(n.title, locale), + body=resolve_i18n_text(n.body, locale), payload=payload, is_read=un.is_read, read_at=un.read_at, @@ -83,7 +113,11 @@ class NotificationService: return await self._repository.get_unread_count(user_id=user_id) async def mark_read( - self, *, user_notification_id: UUID, user_id: UUID + self, + *, + user_notification_id: UUID, + user_id: UUID, + locale: str = DEFAULT_LOCALE, ) -> NotificationListItem: result = await self._repository.get_user_notification( user_notification_id=user_notification_id, @@ -109,8 +143,8 @@ class NotificationService: id=un.id, notification_id=n.id, type=n.type, - title=n.title, - body=n.body, + title=resolve_i18n_text(n.title, locale), + body=resolve_i18n_text(n.body, locale), payload=payload, is_read=True, read_at=un.read_at or datetime.now(timezone.utc), diff --git a/backend/src/v1/payments/__init__.py b/backend/src/v1/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/v1/payments/apple_verifier.py b/backend/src/v1/payments/apple_verifier.py new file mode 100644 index 0000000..b7ffca0 --- /dev/null +++ b/backend/src/v1/payments/apple_verifier.py @@ -0,0 +1,219 @@ +from __future__ import annotations + +import base64 +import hashlib +import logging +from dataclasses import dataclass +from typing import Any + +import jwt +from cryptography.x509 import load_der_x509_certificate +from cryptography.hazmat.primitives.asymmetric.ec import EllipticCurvePublicKey +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPublicKey + +logger = logging.getLogger(__name__) + +_ALLOWED_KEY_TYPES = (EllipticCurvePublicKey, RSAPublicKey) + +_APPLE_ROOT_CA_G3_FINGERPRINT = ( + "b52cb02fd567e0359fe8fa4d4c41037970fe01b0" +) + + +@dataclass(frozen=True) +class VerifiedTransaction: + transaction_id: str + original_transaction_id: str + web_order_line_item_id: str | None + bundle_id: str + product_id: str + purchase_date: int + revocation_date: int | None + environment: str + app_account_token: str | None + raw_payload: dict[str, Any] + + +@dataclass(frozen=True) +class VerificationError: + code: str + detail: str + + +class AppleJwsVerifier: + def verify_signed_transaction( + self, + signed_transaction_info: str, + *, + expected_bundle_id: str, + expected_product_id: str, + expected_environment: str, + ) -> VerifiedTransaction | VerificationError: + try: + unverified_header = jwt.get_unverified_header(signed_transaction_info) + except jwt.exceptions.DecodeError as e: + logger.error("Failed to decode JWS header: %s", str(e)) + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="Failed to decode JWS header", + ) + + x5c_raw = unverified_header.get("x5c") + if not x5c_raw or not isinstance(x5c_raw, list) or len(x5c_raw) < 3: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="JWS x5c chain missing or incomplete", + ) + + x5c: list[str] = x5c_raw + + root_der = base64.b64decode(x5c[-1]) + root_fingerprint = hashlib.sha1(root_der).hexdigest().lower() + if root_fingerprint != _APPLE_ROOT_CA_G3_FINGERPRINT: + logger.warning( + "Apple root cert fingerprint mismatch: expected=%s got=%s", + _APPLE_ROOT_CA_G3_FINGERPRINT, + root_fingerprint, + ) + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="Apple root certificate fingerprint mismatch", + ) + + chain_error = self._verify_cert_chain_issuer_subject(x5c) + if chain_error is not None: + return chain_error + + cert_der = base64.b64decode(x5c[0]) + cert = load_der_x509_certificate(cert_der) + public_key = cert.public_key() + if not isinstance(public_key, _ALLOWED_KEY_TYPES): + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="Unsupported certificate key type", + ) + + try: + payload: dict[str, Any] = jwt.decode( + signed_transaction_info, + public_key, + algorithms=["ES256"], + options={ + "verify_exp": False, + "verify_aud": False, + "verify_iss": False, + "verify_sub": False, + }, + ) + except jwt.exceptions.InvalidSignatureError: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="JWS signature verification failed", + ) + except jwt.exceptions.DecodeError: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="JWS payload decode failed", + ) + + bundle_id: str = str(payload.get("bundleId", "")) + if bundle_id != expected_bundle_id: + logger.error( + "bundleId mismatch: expected=%s got=%s", + expected_bundle_id, + bundle_id, + ) + return VerificationError( + code="PAYMENT_PRODUCT_MISMATCH", + detail=f"bundleId mismatch: expected={expected_bundle_id} got={bundle_id}", + ) + + product_id: str = str(payload.get("productId", "")) + if product_id != expected_product_id: + logger.error( + "productId mismatch: expected=%s got=%s", + expected_product_id, + product_id, + ) + return VerificationError( + code="PAYMENT_PRODUCT_MISMATCH", + detail=f"productId mismatch: expected={expected_product_id} got={product_id}", + ) + + environment: str = str(payload.get("environment", "")) + if environment not in ("Sandbox", "Production"): + logger.error("Invalid environment: %s", environment) + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + 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 + ) + if revocation_date is not None and revocation_date > 0: + return VerificationError( + code="PAYMENT_TRANSACTION_REVOKED", + detail="Transaction has been revoked", + ) + + transaction_id = str(payload.get("transactionId", "")) + original_transaction_id = str(payload.get("originalTransactionId", "")) + web_order_line_item_id_raw = payload.get("webOrderLineItemId") + purchase_date = int(payload.get("purchaseDate", 0)) + app_account_token_raw = payload.get("appAccountToken") + + if not transaction_id: + logger.error("Missing transactionId in payload") + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="Missing transactionId in payload", + ) + + return VerifiedTransaction( + transaction_id=transaction_id, + original_transaction_id=original_transaction_id, + web_order_line_item_id=( + str(web_order_line_item_id_raw) if web_order_line_item_id_raw else None + ), + bundle_id=bundle_id, + product_id=product_id, + purchase_date=purchase_date, + revocation_date=revocation_date, + environment=environment, + app_account_token=( + str(app_account_token_raw) if app_account_token_raw else None + ), + raw_payload=payload, + ) + + def _verify_cert_chain_issuer_subject( + self, x5c: list[str] + ) -> VerificationError | None: + certs = [] + for i, b64_der in enumerate(x5c): + der = base64.b64decode(b64_der) + certs.append(load_der_x509_certificate(der)) + + for i in range(len(certs) - 1): + child = certs[i] + parent = certs[i + 1] + if child.issuer != parent.subject: + return VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail=f"Certificate chain issuer/subject mismatch at index {i}", + ) + + return None diff --git a/backend/src/v1/payments/dependencies.py b/backend/src/v1/payments/dependencies.py new file mode 100644 index 0000000..f7b6915 --- /dev/null +++ b/backend/src/v1/payments/dependencies.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from fastapi import Depends +from sqlalchemy.ext.asyncio import AsyncSession + +from core.db import get_db +from v1.payments.apple_verifier import AppleJwsVerifier +from v1.payments.repository import PaymentRepository +from v1.payments.service import PaymentService +from v1.points.repository import PointsRepository + + +def get_payment_service(session: AsyncSession = Depends(get_db)) -> PaymentService: + payment_repo = PaymentRepository(session) + points_repo = PointsRepository(session) + verifier = AppleJwsVerifier() + return PaymentService( + payment_repo=payment_repo, + points_repo=points_repo, + verifier=verifier, + ) diff --git a/backend/src/v1/payments/repository.py b/backend/src/v1/payments/repository.py new file mode 100644 index 0000000..bac5a65 --- /dev/null +++ b/backend/src/v1/payments/repository.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from uuid import UUID + +from sqlalchemy import select +from sqlalchemy.dialects.postgresql import insert +from sqlalchemy.ext.asyncio import AsyncSession + +from models.apple_iap_transaction import AppleIapTransaction +from models.register_bonus_claims import RegisterBonusClaims +from models.user_points import UserPoints + + +class PaymentRepository: + def __init__(self, session: AsyncSession) -> None: + self._session: AsyncSession = session + + async def get_or_create_user_points_for_update( + self, *, user_id: UUID + ) -> UserPoints: + insert_stmt = ( + insert(UserPoints) + .values(user_id=user_id) + .on_conflict_do_nothing(index_elements=[UserPoints.user_id]) + ) + _ = await self._session.execute(insert_stmt) + + stmt = select(UserPoints).where(UserPoints.user_id == user_id).with_for_update() + return (await self._session.execute(stmt)).scalar_one() + + async def get_user_points_for_update(self, *, user_id: UUID) -> UserPoints | None: + stmt = select(UserPoints).where(UserPoints.user_id == user_id).with_for_update() + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def get_transaction_by_transaction_id( + self, *, transaction_id: str + ) -> AppleIapTransaction | None: + stmt = select(AppleIapTransaction).where( + AppleIapTransaction.transaction_id == transaction_id + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None: + self._session.add(transaction) + await self._session.flush() + + async def get_register_bonus_claim( + self, *, email_hash: str + ) -> RegisterBonusClaims | None: + stmt = ( + select(RegisterBonusClaims) + .where(RegisterBonusClaims.email_hash == email_hash) + .limit(1) + ) + return (await self._session.execute(stmt)).scalar_one_or_none() + + async def upsert_register_bonus_claim_for_starter_pack( + self, + *, + email_hash: str, + user_email_snapshot: str, + first_user_id_snapshot: UUID, + ) -> RegisterBonusClaims: + claim = await self.get_register_bonus_claim(email_hash=email_hash) + if claim is not None: + claim.has_purchased_starter_pack = True + await self._session.flush() + return claim + + insert_stmt = ( + insert(RegisterBonusClaims) + .values( + email_hash=email_hash, + user_email_snapshot=user_email_snapshot, + first_user_id_snapshot=first_user_id_snapshot, + grant_event_id=f"starter_pack_purchase:{email_hash[:16]}", + has_purchased_starter_pack=True, + ) + .on_conflict_do_nothing(index_elements=[RegisterBonusClaims.email_hash]) + ) + _ = await self._session.execute(insert_stmt) + claim = await self.get_register_bonus_claim(email_hash=email_hash) + if claim is None: + raise RuntimeError("Failed to upsert register bonus claim") + return claim + + async def commit(self) -> None: + await self._session.commit() diff --git a/backend/src/v1/payments/router.py b/backend/src/v1/payments/router.py new file mode 100644 index 0000000..f84d9fe --- /dev/null +++ b/backend/src/v1/payments/router.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, Response + +from core.auth.models import CurrentUser +from v1.payments.dependencies import get_payment_service +from v1.payments.schemas import ( + AppleServerNotificationRequest, + VerifyTransactionRequest, + VerifyTransactionResponse, +) +from v1.payments.service import PaymentService +from v1.users.dependencies import get_current_user + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/payments", tags=["payments"]) + + +@router.post( + "/apple/transactions/verify", + response_model=VerifyTransactionResponse, +) +async def verify_apple_transaction( + request: VerifyTransactionRequest, + service: Annotated[PaymentService, Depends(get_payment_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], +) -> VerifyTransactionResponse: + return await service.verify_and_grant( + user_id=current_user.id, + user_email=current_user.email or "", + request=request, + ) + + +@router.post("/apple/notifications", status_code=200) +async def handle_apple_server_notification( + request: AppleServerNotificationRequest, + service: Annotated[PaymentService, Depends(get_payment_service)], +) -> Response: + await service.handle_server_notification(signed_payload=request.signed_payload) + return Response(status_code=200) diff --git a/backend/src/v1/payments/schemas.py b/backend/src/v1/payments/schemas.py new file mode 100644 index 0000000..8411734 --- /dev/null +++ b/backend/src/v1/payments/schemas.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +from typing import Literal +from uuid import UUID + +from pydantic import BaseModel, ConfigDict, Field + + +class VerifyTransactionRequest(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + product_code: str = Field(alias="productCode", min_length=1, max_length=32) + app_store_product_id: str = Field( + alias="appStoreProductId", min_length=1, max_length=128 + ) + transaction_id: str = Field(alias="transactionId", min_length=1, max_length=64) + signed_transaction_info: str = Field( + alias="signedTransactionInfo", min_length=1 + ) + app_account_token: UUID | None = Field( + alias="appAccountToken", default=None + ) + + +class VerifyTransactionResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, extra="forbid") + + status: Literal["granted", "already_granted"] + product_code: str = Field(alias="productCode") + transaction_id: str = Field(alias="transactionId") + credits_added: int = Field(alias="creditsAdded", ge=0) + new_balance: int = Field(alias="newBalance", ge=0) + ledger_event_id: str = Field(alias="ledgerEventId") + + +class AppleNotificationPayload(BaseModel): + model_config = ConfigDict(extra="allow") + + notification_type: str = Field(alias="notificationType", default="") + subtype: str | None = Field(alias="subtype", default=None) + signed_payload: str = Field(alias="signedPayload", default="") + + +class AppleServerNotificationRequest(BaseModel): + model_config = ConfigDict(extra="allow") + + signed_payload: str = Field(alias="signedPayload", default="") diff --git a/backend/src/v1/payments/service.py b/backend/src/v1/payments/service.py new file mode 100644 index 0000000..2a48ff4 --- /dev/null +++ b/backend/src/v1/payments/service.py @@ -0,0 +1,490 @@ +from __future__ import annotations + +import hashlib +import hmac +import logging +from dataclasses import dataclass +from pathlib import Path +from typing import Any +from uuid import UUID, uuid4 + +import yaml + +from core.config.settings import config +from core.http.errors import ApiProblemError, problem_payload +from models.apple_iap_transaction import AppleIapTransaction +from schemas.domain.points import ( + ApplyPointsChangeCommand, + PurchaseLedgerMetadata, +) +from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType +from v1.payments.apple_verifier import ( + AppleJwsVerifier, + VerificationError, + VerifiedTransaction, +) +from v1.payments.repository import PaymentRepository +from v1.payments.schemas import VerifyTransactionRequest, VerifyTransactionResponse +from v1.points.repository import PointsRepository + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class ProductMapping: + app_store_product_id: str + credits: int + type: str + sort_order: int = 0 + enabled: bool = True + + +_product_mappings_cache: dict[str, ProductMapping] | None = None + + +def _load_product_mappings() -> dict[str, ProductMapping]: + global _product_mappings_cache + if _product_mappings_cache is not None: + return _product_mappings_cache + + mapping_path = ( + Path(__file__).parent.parent.parent + / "core/config/static/packages/mapping.yaml" + ) + with mapping_path.open("r", encoding="utf-8") as f: + raw: Any = yaml.safe_load(f) or {} + + mappings: dict[str, ProductMapping] = {} + product_mappings: Any = raw.get("product_mappings", {}) + for code, entry in product_mappings.items(): + mappings[str(code)] = ProductMapping( + app_store_product_id=str(entry["app_store_product_id"]), + credits=int(entry["credits"]), + type=str(entry["type"]), + sort_order=int(entry.get("sort_order", 0)), + enabled=bool(entry.get("enabled", True)), + ) + + _product_mappings_cache = mappings + return mappings + + +def clear_product_mappings_cache() -> None: + global _product_mappings_cache + _product_mappings_cache = None + + +class PaymentService: + def __init__( + self, + *, + payment_repo: PaymentRepository, + points_repo: PointsRepository, + verifier: AppleJwsVerifier, + ) -> None: + self._payment_repo: PaymentRepository = payment_repo + self._points_repo: PointsRepository = points_repo + self._verifier: AppleJwsVerifier = verifier + + async def verify_and_grant( + self, + *, + user_id: UUID, + user_email: str, + request: VerifyTransactionRequest, + ) -> VerifyTransactionResponse: + mappings = _load_product_mappings() + product_mapping = mappings.get(request.product_code) + if product_mapping is None: + raise ApiProblemError( + status_code=404, + detail=problem_payload( + code="PAYMENT_PRODUCT_NOT_FOUND", + detail=f"Product not found: {request.product_code}", + ), + ) + + if request.app_store_product_id != product_mapping.app_store_product_id: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PAYMENT_PRODUCT_MISMATCH", + detail="appStoreProductId does not match backend mapping", + ), + ) + + 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): + logger.error( + "Transaction verification failed: code=%s detail=%s transaction_id=%s", + result.code, + result.detail, + request.transaction_id, + ) + status_code = 422 + if result.code == "PAYMENT_TRANSACTION_REVOKED": + status_code = 409 + elif result.code == "PAYMENT_PRODUCT_MISMATCH": + status_code = 422 + raise ApiProblemError( + status_code=status_code, + detail=problem_payload(code=result.code, detail=result.detail), + ) + + verified: VerifiedTransaction = result + + if str(verified.transaction_id) != request.transaction_id: + logger.error( + "transactionId mismatch: request=%s verified=%s", + request.transaction_id, + verified.transaction_id, + ) + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PAYMENT_TRANSACTION_INVALID", + detail="transactionId does not match verified payload", + ), + ) + + existing = await self._payment_repo.get_transaction_by_transaction_id( + transaction_id=verified.transaction_id + ) + + if existing is not None: + if existing.user_id == user_id and existing.status == "granted": + account = await self._payment_repo.get_or_create_user_points_for_update( + user_id=user_id + ) + return VerifyTransactionResponse( + status="already_granted", + productCode=request.product_code, + transactionId=verified.transaction_id, + creditsAdded=0, + newBalance=int(account.balance), + ledgerEventId=existing.ledger_event_id or "", + ) + if existing.user_id != user_id: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="PAYMENT_TRANSACTION_CONFLICT", + detail="Transaction belongs to another user", + ), + ) + if existing.status in ("refunded", "refunded_insufficient", "revoked"): + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="PAYMENT_TRANSACTION_REVOKED", + detail="Transaction has been refunded or revoked", + ), + ) + + is_starter = product_mapping.type == "starter" + normalized_email = user_email.strip().lower() + email_hash = ( + self._build_email_hash(normalized_email) if normalized_email else None + ) + + if is_starter: + if not email_hash: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="PAYMENT_STARTER_PACK_INELIGIBLE", + detail="Email required for starter pack purchase", + ), + ) + claim = await self._payment_repo.get_register_bonus_claim( + email_hash=email_hash + ) + if claim is not None and claim.has_purchased_starter_pack: + raise ApiProblemError( + status_code=409, + detail=problem_payload( + code="PAYMENT_STARTER_PACK_INELIGIBLE", + detail="Starter pack already purchased for this email", + ), + ) + + transaction_record = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code=request.product_code, + app_store_product_id=product_mapping.app_store_product_id, + transaction_id=verified.transaction_id, + original_transaction_id=verified.original_transaction_id, + web_order_line_item_id=verified.web_order_line_item_id, + environment=verified.environment, + bundle_id=verified.bundle_id, + app_account_token=request.app_account_token, + purchase_date=str(verified.purchase_date), + revocation_date=( + str(verified.revocation_date) if verified.revocation_date else None + ), + status="verified", + credits=product_mapping.credits, + currency=None, + price_milliunits=None, + signed_transaction_info=request.signed_transaction_info, + apple_payload_json=verified.raw_payload, + ) + + await self._payment_repo.insert_transaction(transaction=transaction_record) + + account = await self._payment_repo.get_or_create_user_points_for_update( + user_id=user_id + ) + + credits = product_mapping.credits + event_id = f"payment.apple_iap:{verified.transaction_id}" + balance = int(account.balance) + new_balance = balance + credits + + account.balance = new_balance + account.lifetime_earned = int(account.lifetime_earned) + credits + account.version = int(account.version) + 1 + + metadata = PurchaseLedgerMetadata( + operator_type=PointsOperatorType.SYSTEM, + run_id=event_id, + ext={ + "source": "apple_iap", + "platform": "ios", + "product_code": request.product_code, + "app_store_product_id": product_mapping.app_store_product_id, + "transaction_id": verified.transaction_id, + "original_transaction_id": verified.original_transaction_id, + "environment": verified.environment, + "apple_iap_transaction_id": str(transaction_record.id), + }, + ) + + ledger_command = ApplyPointsChangeCommand( + user_id=user_id, + change_type=PointsChangeType.PURCHASE, + biz_type=PointsBizType.PAYMENT, + biz_id=transaction_record.id, + event_id=event_id, + amount=credits, + direction=1, + operator_id=None, + metadata=metadata, + ) + await self._points_repo.append_ledger( + command=ledger_command, + balance_after=new_balance, + ) + + transaction_record.status = "granted" + transaction_record.ledger_event_id = event_id + + logger.info( + "Transaction granted: user_id=%s transaction_id=%s product_code=%s credits=%d new_balance=%d", + user_id, + verified.transaction_id, + request.product_code, + credits, + new_balance, + ) + + if is_starter and email_hash and normalized_email: + _ = await self._payment_repo.upsert_register_bonus_claim_for_starter_pack( + email_hash=email_hash, + user_email_snapshot=normalized_email, + first_user_id_snapshot=user_id, + ) + + await self._payment_repo.commit() + + return VerifyTransactionResponse( + status="granted", + productCode=request.product_code, + transactionId=verified.transaction_id, + creditsAdded=credits, + newBalance=new_balance, + ledgerEventId=event_id, + ) + + @staticmethod + def _build_email_hash(normalized_email: str) -> str: + key = config.points_policy.register_bonus_hmac_key.get_secret_value().strip() + digest = hmac.new( + key=key.encode("utf-8"), + msg=normalized_email.encode("utf-8"), + digestmod=hashlib.sha256, + ) + return digest.hexdigest() + + async def process_refund( + self, + *, + transaction_id: str, + refund_reason: str = "CUSTOMER_REQUEST", + ) -> None: + txn = await self._payment_repo.get_transaction_by_transaction_id( + transaction_id=transaction_id + ) + if txn is None: + logger.warning("Refund requested for unknown transaction: %s", transaction_id) + return + + if txn.status not in ("granted",): + return + + user_id = txn.user_id + credits = txn.credits + + account = await self._payment_repo.get_user_points_for_update(user_id=user_id) + if account is None: + logger.warning( + "Refund failed: no user_points for user %s on transaction %s", + user_id, + transaction_id, + ) + txn.status = "failed" + txn.failure_code = "USER_POINTS_NOT_FOUND" + return + + balance = int(account.balance) + + if balance < credits: + refund_amount = balance + txn.status = "refunded_insufficient" + txn.failure_code = "INSUFFICIENT_BALANCE" + logger.warning( + "Refund insufficient balance: user=%s credits=%d balance=%d txn=%s", + user_id, + credits, + balance, + transaction_id, + ) + else: + refund_amount = credits + txn.status = "refunded" + + new_balance = balance - refund_amount + account.balance = new_balance + account.lifetime_earned = int(account.lifetime_earned) - refund_amount + account.version = int(account.version) + 1 + + refund_event_id = f"refund.apple_iap:{transaction_id}" + original_event_id = txn.ledger_event_id or f"payment.apple_iap:{transaction_id}" + + metadata = PurchaseLedgerMetadata( + operator_type=PointsOperatorType.SYSTEM, + run_id=refund_event_id, + ext={ + "source": "apple_iap", + "platform": "ios", + "product_code": txn.product_code, + "app_store_product_id": txn.app_store_product_id, + "transaction_id": transaction_id, + "original_transaction_id": txn.original_transaction_id or "", + "environment": txn.environment, + "apple_iap_transaction_id": str(txn.id), + "original_event_id": original_event_id, + "refund_reason": refund_reason, + "overdue_amount": credits - refund_amount, + }, + ) + + if refund_amount > 0: + ledger_command = ApplyPointsChangeCommand( + user_id=user_id, + change_type=PointsChangeType.REFUND, + biz_type=PointsBizType.PAYMENT, + biz_id=txn.id, + event_id=refund_event_id, + amount=refund_amount, + direction=-1, + operator_id=None, + metadata=metadata, + ) + await self._points_repo.append_ledger( + command=ledger_command, + balance_after=new_balance, + ) + + txn.ledger_event_id = refund_event_id + + logger.info( + "Refund processed: txn=%s user=%s refund_amount=%d new_balance=%d status=%s", + transaction_id, + user_id, + refund_amount, + new_balance, + txn.status, + ) + + await self._payment_repo.commit() + + async def handle_server_notification(self, *, signed_payload: str) -> None: + if not signed_payload: + logger.warning("Empty Apple server notification payload") + return + + try: + parts = signed_payload.split(".") + if len(parts) < 2: + logger.warning("Malformed Apple notification signed_payload") + return + + import base64 + import json + + payload_bytes = parts[1] + "=" * (-len(parts[1]) % 4) + decoded = base64.urlsafe_b64decode(payload_bytes) + notification_data: Any = json.loads(decoded) + except Exception: + logger.exception("Failed to decode Apple server notification payload") + return + + notification_type = str(notification_data.get("notificationType", "")) + subtype = notification_data.get("subtype") + + signed_transaction = notification_data.get("data", {}).get( + "signedTransactionInfo", "" + ) + + transaction_id: str | None = None + if signed_transaction: + try: + txn_parts = signed_transaction.split(".") + if len(txn_parts) >= 2: + txn_payload_bytes = txn_parts[1] + "=" * (-len(txn_parts[1]) % 4) + txn_decoded = base64.urlsafe_b64decode(txn_payload_bytes) + txn_data: Any = json.loads(txn_decoded) + transaction_id = str(txn_data.get("transactionId", "")) + except Exception: + logger.exception("Failed to decode signed transaction from notification") + + logger.info( + "Apple notification received: type=%s subtype=%s transaction_id=%s", + notification_type, + subtype, + transaction_id, + ) + + refund_types = {"REFUND", "REVOKE", "DID_FAIL_TO_RENEW"} + if notification_type in refund_types and transaction_id: + refund_reason = notification_type + if subtype: + refund_reason = f"{notification_type}:{subtype}" + await self.process_refund( + transaction_id=transaction_id, + refund_reason=refund_reason, + ) + return + + if notification_type == "DID_RENEW" and transaction_id: + return diff --git a/backend/src/v1/points/repository.py b/backend/src/v1/points/repository.py index eef55bc..2264f79 100644 --- a/backend/src/v1/points/repository.py +++ b/backend/src/v1/points/repository.py @@ -1,5 +1,6 @@ from __future__ import annotations +from datetime import datetime from decimal import Decimal from uuid import UUID @@ -209,3 +210,19 @@ class PointsRepository: stmt = select(Profile.settings).where(Profile.id == user_id).limit(1) row = (await self._session.execute(stmt)).scalar_one_or_none() return row + + async def list_ledger( + self, + *, + user_id: UUID, + limit: int, + cursor: datetime | None = None, + ) -> tuple[list[PointsLedger], bool]: + stmt = select(PointsLedger).where(PointsLedger.user_id == user_id) + if cursor is not None: + stmt = stmt.where(PointsLedger.created_at < cursor) + stmt = stmt.order_by(PointsLedger.created_at.desc()).limit(limit + 1) + rows = list((await self._session.execute(stmt)).scalars().all()) + has_more = len(rows) > limit + items = rows[:limit] + return (items, has_more) diff --git a/backend/src/v1/points/router.py b/backend/src/v1/points/router.py index 03c936c..63ce42a 100644 --- a/backend/src/v1/points/router.py +++ b/backend/src/v1/points/router.py @@ -1,18 +1,42 @@ from __future__ import annotations +from datetime import datetime from typing import Annotated -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query from core.auth.models import CurrentUser +from core.http.errors import ApiProblemError, problem_payload from v1.points.dependencies import get_points_service -from v1.points.schemas import PackagesResponse, PackageInfo, PointsBalanceResponse +from v1.points.schemas import ( + PackagesResponse, + PackageInfo, + PointsBalanceResponse, + LedgerListResponse, + LedgerItem, +) from v1.points.service import PointsService from v1.users.dependencies import get_current_user router = APIRouter(prefix="/points", tags=["points"]) +def _parse_cursor(cursor: str | None) -> datetime | None: + if cursor is None: + return None + try: + return datetime.fromisoformat(cursor.replace("Z", "+00:00")) + except ValueError as exc: + raise ApiProblemError( + status_code=422, + detail=problem_payload( + code="POINTS_INVALID_CURSOR", + detail="Points ledger cursor must be an ISO 8601 datetime", + params={"cursor": cursor}, + ), + ) from exc + + @router.get("/balance", response_model=PointsBalanceResponse) async def get_points_balance( service: Annotated[PointsService, Depends(get_points_service)], @@ -39,13 +63,11 @@ async def get_available_packages( ) return PackagesResponse( - region=result.region, - currency=result.currency, packages=[ PackageInfo( productCode=pkg.product_code, - type=pkg.type.value, - price=pkg.price, + appStoreProductId=pkg.app_store_product_id, + type=pkg.type, credits=pkg.credits, isStarter=pkg.is_starter, starterEligible=pkg.starter_eligible, @@ -54,3 +76,32 @@ async def get_available_packages( for pkg in result.packages ], ) + + +@router.get("/ledger", response_model=LedgerListResponse) +async def get_points_ledger( + service: Annotated[PointsService, Depends(get_points_service)], + current_user: Annotated[CurrentUser, Depends(get_current_user)], + limit: Annotated[int, Query(ge=1, le=100)] = 20, + cursor: str | None = None, +) -> LedgerListResponse: + items, next_cursor, has_more = await service.get_ledger_list( + user_id=current_user.id, + limit=limit, + cursor=_parse_cursor(cursor), + ) + return LedgerListResponse( + items=[ + LedgerItem( + id=item.id, + direction=item.direction, + amount=item.amount, + balanceAfter=item.balance_after, + changeType=item.change_type, + createdAt=item.created_at, + ) + for item in items + ], + nextCursor=next_cursor, + hasMore=has_more, + ) diff --git a/backend/src/v1/points/schemas.py b/backend/src/v1/points/schemas.py index 77d41f0..3f774ae 100644 --- a/backend/src/v1/points/schemas.py +++ b/backend/src/v1/points/schemas.py @@ -19,8 +19,10 @@ class PackageInfo(BaseModel): model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) product_code: str = Field(alias="productCode", min_length=1, max_length=128) + app_store_product_id: str = Field( + alias="appStoreProductId", min_length=1, max_length=256 + ) type: Literal["starter", "regular"] - price: float = Field(ge=0) credits: int = Field(ge=1) is_starter: bool = Field(alias="isStarter") starter_eligible: bool = Field(alias="starterEligible") @@ -30,6 +32,23 @@ class PackageInfo(BaseModel): class PackagesResponse(BaseModel): model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) - region: str = Field(min_length=1, max_length=8) - currency: str = Field(min_length=1, max_length=8) packages: list[PackageInfo] + + +class LedgerItem(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + id: str + direction: int + amount: int = Field(ge=1) + balance_after: int = Field(alias="balanceAfter", ge=0) + change_type: str = Field(alias="changeType") + created_at: str = Field(alias="createdAt") + + +class LedgerListResponse(BaseModel): + model_config = ConfigDict(populate_by_name=True, serialize_by_alias=True) + + items: list[LedgerItem] + next_cursor: str | None = Field(alias="nextCursor", default=None) + has_more: bool = Field(alias="hasMore") diff --git a/backend/src/v1/points/service.py b/backend/src/v1/points/service.py index 02b5eee..c16d1bc 100644 --- a/backend/src/v1/points/service.py +++ b/backend/src/v1/points/service.py @@ -1,16 +1,13 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime from decimal import Decimal import hashlib import hmac from typing import TYPE_CHECKING, Literal from uuid import UUID, uuid4 -from core.config.packages import ( - PackageType, - get_packages_config_for_region, -) from core.config.settings import config from core.http.errors import ApiProblemError, problem_payload from schemas.domain.points import ( @@ -22,8 +19,9 @@ from schemas.domain.points import ( ) from schemas.enums import PointsBizType, PointsChangeType, PointsOperatorType from schemas.domain.points import ApplyPointsChangeCommand -from schemas.shared.user import parse_profile_settings +from v1.payments.service import _load_product_mappings from v1.points.repository import PointsRepository +from v1.points.schemas import LedgerItem if TYPE_CHECKING: pass @@ -67,8 +65,8 @@ class RegisterBonusResult: @dataclass(frozen=True) class PackageInfoResult: product_code: str - type: PackageType - price: float + app_store_product_id: str + type: Literal["starter", "regular"] credits: int sort_order: int is_starter: bool @@ -77,8 +75,6 @@ class PackageInfoResult: @dataclass(frozen=True) class PackagesResult: - region: str - currency: str packages: list[PackageInfoResult] @@ -447,11 +443,6 @@ class PointsService: user_id: UUID, user_email: str, ) -> PackagesResult: - settings_raw = await self._repository.get_profile_settings(user_id=user_id) - settings = parse_profile_settings(settings_raw) - country = settings.preferences.country - - pkg_config = get_packages_config_for_region(country) normalized_email = self._normalize_email(user_email) has_starter = False @@ -461,29 +452,62 @@ class PointsService: email_hash=email_hash ) + product_mappings = _load_product_mappings() + packages: list[PackageInfoResult] = [] - for pkg in pkg_config.packages: - if not pkg.enabled: + for product_code, mapping in product_mappings.items(): + if not mapping.enabled: continue - if pkg.type == PackageType.STARTER and has_starter: + pkg_type: Literal["starter", "regular"] = ( + "starter" if mapping.type == "starter" else "regular" + ) + if pkg_type == "starter" and has_starter: continue packages.append( PackageInfoResult( - product_code=pkg.product_code, - type=pkg.type, - price=pkg.price, - credits=pkg.credits, - sort_order=pkg.sort_order, - is_starter=pkg.type == PackageType.STARTER, - starter_eligible=( - pkg.type == PackageType.STARTER and not has_starter - ), + product_code=product_code, + app_store_product_id=mapping.app_store_product_id, + type=pkg_type, + credits=mapping.credits, + sort_order=mapping.sort_order, + is_starter=pkg_type == "starter", + starter_eligible=(pkg_type == "starter" and not has_starter), ) ) return PackagesResult( - region=pkg_config.region, - currency=pkg_config.currency, packages=sorted(packages, key=lambda p: p.sort_order), ) + + async def get_ledger_list( + self, + *, + user_id: UUID, + limit: int = 20, + cursor: datetime | None = None, + ) -> tuple[list[LedgerItem], str | None, bool]: + rows, has_more = await self._repository.list_ledger( + user_id=user_id, + limit=limit, + cursor=cursor, + ) + + items: list[LedgerItem] = [] + for row in rows: + items.append( + LedgerItem( + id=str(row.id), + direction=row.direction, + amount=row.amount, + balanceAfter=row.balance_after, + changeType=row.change_type, + createdAt=row.created_at.isoformat(), + ) + ) + + next_cursor: str | None = None + if has_more and items: + next_cursor = items[-1].created_at + + return (items, next_cursor, has_more) diff --git a/backend/src/v1/router.py b/backend/src/v1/router.py index 7317eb3..cb93594 100644 --- a/backend/src/v1/router.py +++ b/backend/src/v1/router.py @@ -7,6 +7,7 @@ from v1.auth.router import router as auth_router from v1.feedback.router import router as feedback_router from v1.invite.router import router as invite_router from v1.notifications.router import router as notifications_router +from v1.payments.router import router as payments_router from v1.points.router import router as points_router from v1.users.router import router as users_router @@ -17,5 +18,6 @@ router.include_router(agent_router) router.include_router(feedback_router) router.include_router(invite_router) router.include_router(notifications_router) +router.include_router(payments_router) router.include_router(points_router) router.include_router(users_router) diff --git a/backend/tests/integration/conftest.py b/backend/tests/integration/conftest.py index c3c6892..1b8dc93 100644 --- a/backend/tests/integration/conftest.py +++ b/backend/tests/integration/conftest.py @@ -4,7 +4,6 @@ from collections.abc import AsyncIterator import hashlib import hmac import os -import time import httpx import pytest @@ -37,18 +36,13 @@ def test_verify_code() -> str: @pytest.fixture -def unique_test_email() -> str: - base_email = os.environ.get("ERYAO_TEST__EMAIL", "test@example.com").strip().lower() - if "@" in base_email: - name, domain = base_email.split("@", 1) - else: - name, domain = base_email, "example.com" - return f"{name}+it{int(time.time() * 1000)}@{domain}" +def test_email() -> str: + return os.environ.get("ERYAO_TEST__EMAIL", "test@example.com").strip().lower() @pytest.fixture -def test_identity(unique_test_email: str, test_verify_code: str) -> dict[str, str]: - return {"email": unique_test_email, "code": test_verify_code} +def test_identity(test_email: str, test_verify_code: str) -> dict[str, str]: + return {"email": test_email, "code": test_verify_code} @pytest.fixture diff --git a/backend/tests/integration/payments/__init__.py b/backend/tests/integration/payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/payments/test_verify_flow.py b/backend/tests/integration/payments/test_verify_flow.py new file mode 100644 index 0000000..5eae655 --- /dev/null +++ b/backend/tests/integration/payments/test_verify_flow.py @@ -0,0 +1,47 @@ +""" +Integration tests for Apple IAP payment verify flow. + +Prerequisite: backend must be running via `./infra/scripts/app.sh restart`. +These tests hit the live HTTP API against the test database. +""" + +from __future__ import annotations + +import pytest + + +@pytest.mark.asyncio +async def test_verify_endpoint_returns_401_without_auth() -> None: + import httpx + + base_url = "http://localhost:8000" + try: + async with httpx.AsyncClient(base_url=base_url, timeout=5) as client: + response = await client.post( + "/api/v1/payments/apple/transactions/verify", + json={ + "productCode": "starter_pack", + "appStoreProductId": "com.meeyao.qianwen.starter_pack", + "transactionId": "0000000000000001", + "signedTransactionInfo": "fake_jws", + }, + ) + assert response.status_code in (401, 403) + except httpx.ConnectError: + pytest.skip("Backend not running, skipping integration test") + + +@pytest.mark.asyncio +async def test_notifications_endpoint_returns_200() -> None: + import httpx + + base_url = "http://localhost:8000" + try: + async with httpx.AsyncClient(base_url=base_url, timeout=5) as client: + response = await client.post( + "/api/v1/payments/apple/notifications", + json={"signedPayload": ""}, + ) + assert response.status_code == 200 + except httpx.ConnectError: + pytest.skip("Backend not running, skipping integration test") diff --git a/backend/tests/integration/test_locale_timezone_bootstrap.py b/backend/tests/integration/test_locale_timezone_bootstrap.py new file mode 100644 index 0000000..320613c --- /dev/null +++ b/backend/tests/integration/test_locale_timezone_bootstrap.py @@ -0,0 +1,247 @@ +from __future__ import annotations + +import time + +import httpx +import pytest + +from core.db.session import AsyncSessionLocal +from models.profile import Profile +from sqlalchemy import select + + +async def _create_email_session( + client: httpx.AsyncClient, + *, + email: str, + code: str, + language: str | None = None, + timezone: str | None = None, +) -> dict: + payload: dict = {"email": email, "token": code} + if language is not None: + payload["language"] = language + if timezone is not None: + payload["timezone"] = timezone + resp = await client.post( + "/api/v1/auth/email-session", + json=payload, + ) + resp.raise_for_status() + return resp.json() + + +async def _get_profile( + client: httpx.AsyncClient, + *, + access_token: str, +) -> dict: + headers = {"Authorization": f"Bearer {access_token}"} + resp = await client.get("/api/v1/users/me/profile", headers=headers) + resp.raise_for_status() + return resp.json() + + +@pytest.mark.integration +async def test_new_user_registration_with_language_timezone( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """场景1: 新用户首次打开App,注册时语言和时区写入后端""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + language="en-US", + timezone="America/New_York", + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "en-US", f"Expected en-US, got {preferences['language']}" + assert preferences["timezone"] == "America/New_York", f"Expected America/New_York, got {preferences['timezone']}" + + +@pytest.mark.integration +async def test_new_user_registration_with_zh_hant( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """场景1变体: 新用户注册时使用繁体中文""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + language="zh-Hant", + timezone="Asia/Taipei", + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "zh-Hant" + assert preferences["timezone"] == "Asia/Taipei" + + +@pytest.mark.integration +async def test_new_user_registration_without_language_timezone( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """场景2: 新用户注册时不传语言时区,使用后端默认值""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "zh-CN", "Default language should be zh-CN" + assert preferences["timezone"] == "Asia/Shanghai", "Default timezone should be Asia/Shanghai" + + +@pytest.mark.integration +async def test_existing_user_login_sync_from_server( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """场景2: 已有用户在新设备登录,从服务器同步语言时区""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session1 = await _create_email_session( + api_client, + email=email, + code=code, + language="en-US", + timezone="Europe/London", + ) + access_token1 = str(session1["access_token"]) + + profile1 = await _get_profile(api_client, access_token=access_token1) + preferences1 = profile1["settings"]["preferences"] + assert preferences1["language"] == "en-US" + assert preferences1["timezone"] == "Europe/London" + + time.sleep(1) + + session2 = await _create_email_session( + api_client, + email=email, + code=code, + ) + access_token2 = str(session2["access_token"]) + + profile2 = await _get_profile(api_client, access_token=access_token2) + preferences2 = profile2["settings"]["preferences"] + + assert preferences2["language"] == "en-US", "Should sync language from server" + assert preferences2["timezone"] == "Europe/London", "Should sync timezone from server" + + +@pytest.mark.integration +async def test_partial_language_only( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """边界情况: 只传语言不传时区""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + language="zh-Hant", + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "zh-Hant" + assert preferences["timezone"] == "Asia/Shanghai", "Timezone should use default" + + +@pytest.mark.integration +async def test_partial_timezone_only( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """边界情况: 只传时区不传语言""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + timezone="Australia/Sydney", + ) + access_token = str(session_data["access_token"]) + + profile = await _get_profile(api_client, access_token=access_token) + preferences = profile["settings"]["preferences"] + + assert preferences["language"] == "zh-CN", "Language should use default" + assert preferences["timezone"] == "Australia/Sydney" + + +@pytest.mark.integration +async def test_profile_settings_persisted_in_database( + api_client: httpx.AsyncClient, + test_identity: dict[str, str], + db_cleanup: list[str], +) -> None: + """验证语言时区正确持久化到数据库""" + email = test_identity["email"] + code = test_identity["code"] + db_cleanup.append(email) + + session_data = await _create_email_session( + api_client, + email=email, + code=code, + language="en-US", + timezone="America/Los_Angeles", + ) + user_id = str(session_data["user"]["id"]) + + async with AsyncSessionLocal() as db_session: + stmt = select(Profile).where(Profile.id == user_id) + result = await db_session.execute(stmt) + profile = result.scalar_one_or_none() + + assert profile is not None, "Profile should exist in database" + settings = profile.settings + assert settings is not None + preferences = settings.get("preferences", {}) + assert preferences.get("language") == "en-US" + assert preferences.get("timezone") == "America/Los_Angeles" diff --git a/backend/tests/unit/payments/__init__.py b/backend/tests/unit/payments/__init__.py new file mode 100644 index 0000000..1d3cf25 --- /dev/null +++ b/backend/tests/unit/payments/__init__.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import base64 +import json + +from v1.payments.apple_verifier import ( + AppleJwsVerifier, + VerificationError, +) + + +def _make_jws_parts(header: dict[str, object], payload: dict[str, object]) -> tuple[str, str]: + h = base64.urlsafe_b64encode(json.dumps(header).encode()).rstrip(b"=").decode() + p = base64.urlsafe_b64encode(json.dumps(payload).encode()).rstrip(b"=").decode() + return h, p + + +class TestAppleJwsVerifierInvalidInput: + def test_invalid_header_returns_error(self) -> None: + verifier = AppleJwsVerifier() + result = verifier.verify_signed_transaction( + "not-a-jws", + expected_bundle_id="com.meeyao.qianwen", + expected_product_id="com.meeyao.qianwen.starter_pack", + ) + assert isinstance(result, VerificationError) + assert result.code == "PAYMENT_TRANSACTION_INVALID" + assert "decode" in result.detail.lower() or "header" in result.detail.lower() + + def test_missing_x5c_returns_error(self) -> None: + verifier = AppleJwsVerifier() + h, p = _make_jws_parts({"alg": "ES256"}, {"bundleId": "test"}) + result = verifier.verify_signed_transaction( + f"{h}.{p}.fake", + expected_bundle_id="com.meeyao.qianwen", + expected_product_id="com.meeyao.qianwen.starter_pack", + ) + assert isinstance(result, VerificationError) + assert "x5c" in result.detail + + def test_short_x5c_returns_error(self) -> None: + verifier = AppleJwsVerifier() + h, p = _make_jws_parts({"alg": "ES256", "x5c": ["one"]}, {"bundleId": "test"}) + result = verifier.verify_signed_transaction( + f"{h}.{p}.fake", + expected_bundle_id="com.meeyao.qianwen", + expected_product_id="com.meeyao.qianwen.starter_pack", + ) + assert isinstance(result, VerificationError) + assert "x5c" in result.detail + + def test_issuer_subject_mismatch_returns_error(self) -> None: + verifier = AppleJwsVerifier() + leaf_cert_b64 = base64.b64encode(b"fake_leaf_cert").decode() + intermediate_cert_b64 = base64.b64encode(b"fake_intermediate_cert").decode() + root_cert_b64 = base64.b64encode(b"fake_root_cert").decode() + h, p = _make_jws_parts( + {"alg": "ES256", "x5c": [leaf_cert_b64, intermediate_cert_b64, root_cert_b64]}, + {"bundleId": "com.meeyao.qianwen"}, + ) + result = verifier.verify_signed_transaction( + f"{h}.{p}.fake", + expected_bundle_id="com.meeyao.qianwen", + expected_product_id="com.meeyao.qianwen.starter_pack", + ) + assert isinstance(result, VerificationError) + assert "fingerprint" in result.detail or "issuer" in result.detail or "subject" in result.detail diff --git a/backend/tests/unit/payments/test_payment_service.py b/backend/tests/unit/payments/test_payment_service.py new file mode 100644 index 0000000..a25c8fa --- /dev/null +++ b/backend/tests/unit/payments/test_payment_service.py @@ -0,0 +1,627 @@ +from __future__ import annotations + +from dataclasses import dataclass +from uuid import UUID, uuid4 + +import pytest + +from core.http.errors import ApiProblemError +from models.apple_iap_transaction import AppleIapTransaction +from models.register_bonus_claims import RegisterBonusClaims +from schemas.domain.points import ApplyPointsChangeCommand +from v1.payments.apple_verifier import VerificationError, VerifiedTransaction +from v1.payments.schemas import VerifyTransactionRequest +from v1.payments.service import PaymentService + + +@dataclass +class _FakeAccount: + balance: int = 0 + frozen_balance: int = 0 + lifetime_earned: int = 0 + lifetime_spent: int = 0 + version: int = 0 + + +class _FakePaymentRepository: + def __init__(self, *, existing_transaction: AppleIapTransaction | None = None) -> None: + self.account = _FakeAccount() + self.existing_transaction = existing_transaction + self.inserted_transactions: list[AppleIapTransaction] = [] + self.claim: RegisterBonusClaims | None = None + self.claim_starter_pack_called: bool = False + self.commit_count = 0 + + async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccount: + return self.account + + async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None: + return self.existing_transaction + + async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None: + self.inserted_transactions.append(transaction) + + async def get_register_bonus_claim(self, *, email_hash: str) -> RegisterBonusClaims | None: + return self.claim + + async def upsert_register_bonus_claim_for_starter_pack( + self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID + ) -> RegisterBonusClaims: + self.claim_starter_pack_called = True + if self.claim is None: + self.claim = RegisterBonusClaims( + email_hash=email_hash, + user_email_snapshot=user_email_snapshot, + first_user_id_snapshot=first_user_id_snapshot, + grant_event_id="starter_pack_purchase:test", + has_purchased_starter_pack=True, + ) + else: + self.claim.has_purchased_starter_pack = True + return self.claim + + async def commit(self) -> None: + self.commit_count += 1 + + +class _FakePointsRepository: + def __init__(self) -> None: + self.appended_ledger: list[ApplyPointsChangeCommand] = [] + + async def append_ledger(self, *, command: ApplyPointsChangeCommand, balance_after: int) -> None: + self.appended_ledger.append(command) + + +class _FakeVerifier: + def __init__(self, *, result: VerifiedTransaction | VerificationError) -> None: + self._result = result + + def verify_signed_transaction( + self, + signed_transaction_info: str, + *, + 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 + + +def _make_verified_transaction( + *, + transaction_id: str = "2000000123456789", + product_id: str = "com.meeyao.qianwen.starter_pack", + environment: str = "Sandbox", +) -> VerifiedTransaction: + return VerifiedTransaction( + transaction_id=transaction_id, + original_transaction_id=transaction_id, + web_order_line_item_id=None, + bundle_id="com.meeyao.qianwen", + product_id=product_id, + purchase_date=1700000000000, + revocation_date=None, + environment=environment, + app_account_token=None, + raw_payload={}, + ) + + +class TestPaymentServiceProductNotFound: + @pytest.mark.asyncio + async def test_raises_product_not_found(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepository(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + request = VerifyTransactionRequest( + productCode="nonexistent_pack", + appStoreProductId="com.meeyao.qianwen.nonexistent", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_PRODUCT_NOT_FOUND" + + +class TestPaymentServiceProductMismatch: + @pytest.mark.asyncio + async def test_raises_product_mismatch_when_ids_differ(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepository(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + request = VerifyTransactionRequest( + productCode="starter_pack", + appStoreProductId="com.meeyao.qianwen.wrong_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_PRODUCT_MISMATCH" + + +class TestPaymentServiceVerificationFailed: + @pytest.mark.asyncio + async def test_raises_when_verifier_returns_error(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepository(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier( + result=VerificationError( + code="PAYMENT_TRANSACTION_INVALID", + detail="bad signature", + ) + ), + ) + request = VerifyTransactionRequest( + productCode="starter_pack", + appStoreProductId="com.meeyao.qianwen.starter_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_TRANSACTION_INVALID" + + +class TestPaymentServiceAlreadyGranted: + @pytest.mark.asyncio + async def test_returns_already_granted_for_same_user(self) -> None: + user_id = uuid4() + existing = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", + transaction_id="2000000123456789", + original_transaction_id="2000000123456789", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000123456789", + ) + service = PaymentService( + payment_repo=_FakePaymentRepository(existing_transaction=existing), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + request = VerifyTransactionRequest( + productCode="starter_pack", + appStoreProductId="com.meeyao.qianwen.starter_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + result = await service.verify_and_grant( + user_id=user_id, + user_email="test@example.com", + request=request, + ) + assert result.status == "already_granted" + assert result.credits_added == 0 + + +class TestPaymentServiceTransactionConflict: + @pytest.mark.asyncio + async def test_raises_conflict_for_different_user(self) -> None: + existing = AppleIapTransaction( + id=uuid4(), + user_id=uuid4(), + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", + transaction_id="2000000123456789", + original_transaction_id="2000000123456789", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000123456789", + ) + service = PaymentService( + payment_repo=_FakePaymentRepository(existing_transaction=existing), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + request = VerifyTransactionRequest( + productCode="starter_pack", + appStoreProductId="com.meeyao.qianwen.starter_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_TRANSACTION_CONFLICT" + + +class TestPaymentServiceSuccessfulGrant: + @pytest.mark.asyncio + async def test_grants_credits_for_new_transaction(self) -> None: + payment_repo = _FakePaymentRepository() + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=payment_repo, + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + 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 result.credits_added == 100 + assert result.new_balance == 100 + assert result.ledger_event_id == "payment.apple_iap:2000000123456789" + assert len(points_repo.appended_ledger) == 1 + assert len(payment_repo.inserted_transactions) == 1 + + +class TestPaymentServiceStarterPackIneligible: + @pytest.mark.asyncio + async def test_raises_when_starter_pack_already_purchased(self) -> None: + claim = RegisterBonusClaims( + email_hash="fake_hash", + user_email_snapshot="test@example.com", + first_user_id_snapshot=uuid4(), + grant_event_id="register.bonus:test", + has_purchased_starter_pack=True, + ) + payment_repo = _FakePaymentRepository() + payment_repo.claim = claim + service = PaymentService( + payment_repo=payment_repo, + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier( + result=_make_verified_transaction( + product_id="com.meeyao.qianwen.new_user_pack" + ) + ), + ) + request = VerifyTransactionRequest( + productCode="new_user_pack", + appStoreProductId="com.meeyao.qianwen.new_user_pack", + transactionId="2000000123456789", + signedTransactionInfo="fake_jws", + ) + with pytest.raises(ApiProblemError) as exc_info: + await service.verify_and_grant( + user_id=uuid4(), + user_email="test@example.com", + request=request, + ) + assert exc_info.value.code == "PAYMENT_STARTER_PACK_INELIGIBLE" + + +class TestPaymentServiceStarterPackSuccess: + @pytest.mark.asyncio + async def test_grants_starter_pack_and_updates_claim(self) -> None: + payment_repo = _FakePaymentRepository() + service = PaymentService( + payment_repo=payment_repo, + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier( + result=_make_verified_transaction( + product_id="com.meeyao.qianwen.new_user_pack" + ) + ), + ) + request = VerifyTransactionRequest( + productCode="new_user_pack", + appStoreProductId="com.meeyao.qianwen.new_user_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 result.credits_added == 60 + assert payment_repo.claim_starter_pack_called + + +class _FakeAccountForRefund: + def __init__(self, balance: int = 100, lifetime_earned: int = 100) -> None: + self.balance: int = balance + self.frozen_balance: int = 0 + self.lifetime_earned: int = lifetime_earned + self.lifetime_spent: int = 0 + self.version: int = 1 + + +class _FakePaymentRepoForRefund: + def __init__( + self, + *, + transaction: AppleIapTransaction | None = None, + account: _FakeAccountForRefund | None = None, + ) -> None: + self._transaction = transaction + self.account = account or _FakeAccountForRefund() + self.inserted_transactions: list[AppleIapTransaction] = [] + self.commit_count = 0 + + async def get_transaction_by_transaction_id(self, *, transaction_id: str) -> AppleIapTransaction | None: + return self._transaction + + async def get_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund: + return self.account + + async def get_or_create_user_points_for_update(self, *, user_id: UUID) -> _FakeAccountForRefund: + return self.account + + async def insert_transaction(self, *, transaction: AppleIapTransaction) -> None: + self.inserted_transactions.append(transaction) + + async def get_register_bonus_claim(self, *, email_hash: str) -> None: + return None + + async def upsert_register_bonus_claim_for_starter_pack( + self, *, email_hash: str, user_email_snapshot: str, first_user_id_snapshot: UUID + ) -> None: + pass + + async def commit(self) -> None: + self.commit_count += 1 + + +class TestProcessRefundUnknownTransaction: + @pytest.mark.asyncio + async def test_skips_silently_for_unknown_transaction(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=None), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="nonexistent") + + +class TestProcessRefundNotGranted: + @pytest.mark.asyncio + async def test_skips_for_non_granted_transaction(self) -> None: + txn = AppleIapTransaction( + id=uuid4(), + user_id=uuid4(), + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", + transaction_id="2000000999999999", + original_transaction_id="2000000999999999", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="verified", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ) + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="2000000999999999") + assert txn.status == "verified" + + +class TestProcessRefundSufficientBalance: + @pytest.mark.asyncio + async def test_deducts_credits_and_writes_refund_ledger(self) -> None: + user_id = uuid4() + txn = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", + transaction_id="2000000999999999", + original_transaction_id="2000000999999999", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000999999999", + ) + account = _FakeAccountForRefund(balance=150, lifetime_earned=200) + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account), + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="2000000999999999") + assert txn.status == "refunded" + assert account.balance == 50 + assert account.lifetime_earned == 100 + assert len(points_repo.appended_ledger) == 1 + ledger = points_repo.appended_ledger[0] + assert ledger.change_type.value == "refund" + assert ledger.direction == -1 + assert ledger.amount == 100 + + +class TestProcessRefundInsufficientBalance: + @pytest.mark.asyncio + async def test_deducts_to_zero_and_sets_insufficient_status(self) -> None: + user_id = uuid4() + txn = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", + transaction_id="2000000999999998", + original_transaction_id="2000000999999998", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000999999998", + ) + account = _FakeAccountForRefund(balance=30, lifetime_earned=100) + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account), + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="2000000999999998") + assert txn.status == "refunded_insufficient" + assert txn.failure_code == "INSUFFICIENT_BALANCE" + assert account.balance == 0 + assert len(points_repo.appended_ledger) == 1 + ledger = points_repo.appended_ledger[0] + assert ledger.amount == 30 + + +class TestProcessRefundIdempotency: + @pytest.mark.asyncio + async def test_second_refund_is_noop(self) -> None: + user_id = uuid4() + txn = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", + transaction_id="2000000999999997", + original_transaction_id="2000000999999997", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="refunded", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ) + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=_FakeAccountForRefund(balance=50)), + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.process_refund(transaction_id="2000000999999997") + assert len(points_repo.appended_ledger) == 0 + assert txn.status == "refunded" + + +class TestHandleServerNotificationRefund: + @pytest.mark.asyncio + async def test_processes_refund_notification(self) -> None: + user_id = uuid4() + txn = AppleIapTransaction( + id=uuid4(), + user_id=user_id, + product_code="starter_pack", + app_store_product_id="com.meeyao.qianwen.starter_pack", + transaction_id="2000000999999001", + original_transaction_id="2000000999999001", + environment="Sandbox", + bundle_id="com.meeyao.qianwen", + purchase_date="1700000000000", + status="granted", + credits=100, + signed_transaction_info="fake", + apple_payload_json={}, + ledger_event_id="payment.apple_iap:2000000999999001", + ) + account = _FakeAccountForRefund(balance=200, lifetime_earned=200) + points_repo = _FakePointsRepository() + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(transaction=txn, account=account), + points_repo=points_repo, + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + + import json + + signed_txn = _make_fake_signed_transaction(transaction_id="2000000999999001") + notification_payload = json.dumps({ + "notificationType": "REFUND", + "data": {"signedTransactionInfo": signed_txn}, + }) + signed_payload = _make_fake_jws(notification_payload) + + await service.handle_server_notification(signed_payload=signed_payload) + assert txn.status == "refunded" + assert account.balance == 100 + + @pytest.mark.asyncio + async def test_ignores_empty_payload(self) -> None: + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.handle_server_notification(signed_payload="") + + @pytest.mark.asyncio + async def test_ignores_non_refund_notification(self) -> None: + import json + + notification_payload = json.dumps({ + "notificationType": "DID_RENEW", + "data": {}, + }) + signed_payload = _make_fake_jws(notification_payload) + service = PaymentService( + payment_repo=_FakePaymentRepoForRefund(), + points_repo=_FakePointsRepository(), + verifier=_FakeVerifier(result=_make_verified_transaction()), + ) + await service.handle_server_notification(signed_payload=signed_payload) + + +def _make_fake_jws(payload_str: str) -> str: + import base64 + h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode() + p = base64.urlsafe_b64encode(payload_str.encode()).rstrip(b"=").decode() + return f"{h}.{p}.fake_signature" + + +def _make_fake_signed_transaction(transaction_id: str) -> str: + import base64 + import json + + txn_payload = json.dumps({"transactionId": transaction_id}) + h = base64.urlsafe_b64encode(b'{"alg":"ES256"}').rstrip(b"=").decode() + p = base64.urlsafe_b64encode(txn_payload.encode()).rstrip(b"=").decode() + return f"{h}.{p}.fake_signature" diff --git a/backend/tests/unit/test_agentscope_prompts.py b/backend/tests/unit/test_agentscope_prompts.py index 9d8123d..9a93f28 100644 --- a/backend/tests/unit/test_agentscope_prompts.py +++ b/backend/tests/unit/test_agentscope_prompts.py @@ -2,46 +2,61 @@ from __future__ import annotations from core.agentscope.prompts.agent_prompt import build_agent_prompt from core.agentscope.prompts.system_prompt import build_system_prompt +from core.agentscope.prompts.user_prompt import build_follow_up_user_prompt from schemas.agent.system_agent import AgentType, SystemAgentLLMConfig -def test_system_prompt_enforces_ai_language_en() -> None: +def test_system_prompt_safety_has_refusal_rules_en() -> None: prompt = build_system_prompt( agent_type=AgentType.WORKER, - ai_language="en-US", + language="en-US", llm_config=SystemAgentLLMConfig(), ) - assert "English" in prompt assert "" in prompt - assert "" in prompt + assert "-> REFUSE" in prompt + assert "Tarot" in prompt + assert "Ba Zi" in prompt -def test_system_prompt_enforces_ai_language_zh_cn() -> None: +def test_system_prompt_safety_has_refusal_rules_zh() -> None: prompt = build_system_prompt( agent_type=AgentType.WORKER, - ai_language="zh-CN", + language="zh-CN", llm_config=SystemAgentLLMConfig(), ) - assert "简体中文" in prompt assert "" in prompt + assert "必须拒绝" in prompt + assert "塔罗" in prompt + assert "八字" in prompt + + +def test_system_prompt_no_language_constraint_in_system() -> None: + prompt = build_system_prompt( + agent_type=AgentType.WORKER, + language="en-US", + llm_config=SystemAgentLLMConfig(), + ) + + assert "" not in prompt + assert "MUST respond in" not in prompt def test_system_prompt_safety_restricts_to_divination() -> None: prompt = build_system_prompt( agent_type=AgentType.WORKER, - ai_language="zh-CN", + language="zh-CN", llm_config=SystemAgentLLMConfig(), ) - assert "只回答与六爻占卜" in prompt or "解卦" in prompt + assert "六爻" in prompt or "解卦" in prompt def test_system_prompt_does_not_contain_env_section() -> None: prompt = build_system_prompt( agent_type=AgentType.WORKER, - ai_language="zh-CN", + language="zh-CN", llm_config=SystemAgentLLMConfig(), ) @@ -54,10 +69,11 @@ def test_agent_prompt_keeps_only_identity_and_domain_flow() -> None: prompt = build_agent_prompt( agent_type=AgentType.WORKER, llm_config=SystemAgentLLMConfig(), + language="zh-CN", ) assert "focus_points" in prompt - assert "段间用\\n\\n" in prompt + assert "断卦要点" in prompt assert "[role_playing]" in prompt assert "[output_json_rules]" in prompt @@ -65,19 +81,35 @@ def test_agent_prompt_keeps_only_identity_and_domain_flow() -> None: def test_system_prompt_sections_are_not_duplicated() -> None: prompt = build_system_prompt( agent_type=AgentType.WORKER, - ai_language="zh-CN", + language="zh-CN", llm_config=SystemAgentLLMConfig(), ) assert prompt.count("") == 1 assert prompt.count("") == 1 - assert prompt.count("") == 1 def test_system_prompt_requires_paragraph_breaks_for_answer() -> None: prompt = build_agent_prompt( agent_type=AgentType.WORKER, llm_config=SystemAgentLLMConfig(), + language="zh-CN", ) - assert "段间用\\n\\n" in prompt + assert "具体解析" in prompt + + +def test_user_prompt_has_language_constraint_en() -> None: + prompt = build_follow_up_user_prompt(question="test question", language="en-US") + + assert "CRITICAL: YOUR ENTIRE RESPONSE MUST BE IN ENGLISH" in prompt + assert "═══════════════════════════════════════════════════════════════════════════════" in prompt + assert "[SCOPE CHECK - REFUSE IF:]" in prompt + + +def test_user_prompt_has_language_constraint_zh() -> None: + prompt = build_follow_up_user_prompt(question="test question", language="zh-CN") + + assert "关键:必须全程使用简体中文回答" in prompt + assert "═══════════════════════════════════════════════════════════════════════════════" in prompt + assert "【范围检查" in prompt diff --git a/backend/tests/unit/test_auth_gateway_dev_session.py b/backend/tests/unit/test_auth_gateway_dev_session.py index 0555817..61ae770 100644 --- a/backend/tests/unit/test_auth_gateway_dev_session.py +++ b/backend/tests/unit/test_auth_gateway_dev_session.py @@ -10,7 +10,7 @@ from v1.auth.schemas import AuthUser, EmailSessionCreateRequest, SessionResponse @pytest.mark.asyncio -async def test_create_email_session_uses_dev_bypass( +async def test_create_email_session_uses_test_account_bypass( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = SupabaseAuthGateway() @@ -28,7 +28,8 @@ async def test_create_email_session_uses_dev_bypass( calls.update(kwargs) return expected - monkeypatch.setattr(gateway_module.config.runtime, "environment", "dev") + monkeypatch.setattr(gateway_module.config.test, "email", "test@example.com") + monkeypatch.setattr(gateway_module.config.test, "code", "123456") monkeypatch.setattr( gateway_module, "create_dev_email_session", _fake_create_dev_email_session ) @@ -47,7 +48,7 @@ async def test_create_email_session_uses_dev_bypass( @pytest.mark.asyncio -async def test_create_email_session_uses_verify_otp_in_non_dev( +async def test_create_email_session_uses_verify_otp_when_test_account_not_configured( monkeypatch: pytest.MonkeyPatch, ) -> None: gateway = SupabaseAuthGateway() @@ -66,7 +67,8 @@ async def test_create_email_session_uses_verify_otp_in_non_dev( user=SimpleNamespace(id="user-2", email="test@example.com"), ) - monkeypatch.setattr(gateway_module.config.runtime, "environment", "prod") + monkeypatch.setattr(gateway_module.config.test, "email", "") + monkeypatch.setattr(gateway_module.config.test, "code", "") monkeypatch.setattr( gateway, "_get_client", @@ -81,3 +83,79 @@ async def test_create_email_session_uses_verify_otp_in_non_dev( "token": "123456", } assert response.user.email == "test@example.com" + + +@pytest.mark.asyncio +async def test_create_email_session_uses_verify_otp_when_email_mismatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = SupabaseAuthGateway() + request = EmailSessionCreateRequest(email="other@example.com", token="123456") + captured_payload: dict[str, str] = {} + + def _verify_otp(payload: dict[str, str]) -> SimpleNamespace: + captured_payload.update(payload) + return SimpleNamespace( + session=SimpleNamespace( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + ), + user=SimpleNamespace(id="user-3", email="other@example.com"), + ) + + monkeypatch.setattr(gateway_module.config.test, "email", "test@example.com") + monkeypatch.setattr(gateway_module.config.test, "code", "123456") + monkeypatch.setattr( + gateway, + "_get_client", + lambda: SimpleNamespace(auth=SimpleNamespace(verify_otp=_verify_otp)), + ) + + response = await gateway.create_email_session(request) + + assert captured_payload == { + "type": "email", + "email": "other@example.com", + "token": "123456", + } + assert response.user.email == "other@example.com" + + +@pytest.mark.asyncio +async def test_create_email_session_uses_verify_otp_when_code_mismatch( + monkeypatch: pytest.MonkeyPatch, +) -> None: + gateway = SupabaseAuthGateway() + request = EmailSessionCreateRequest(email="test@example.com", token="654321") + captured_payload: dict[str, str] = {} + + def _verify_otp(payload: dict[str, str]) -> SimpleNamespace: + captured_payload.update(payload) + return SimpleNamespace( + session=SimpleNamespace( + access_token="access", + refresh_token="refresh", + expires_in=3600, + token_type="bearer", + ), + user=SimpleNamespace(id="user-4", email="test@example.com"), + ) + + monkeypatch.setattr(gateway_module.config.test, "email", "test@example.com") + monkeypatch.setattr(gateway_module.config.test, "code", "123456") + monkeypatch.setattr( + gateway, + "_get_client", + lambda: SimpleNamespace(auth=SimpleNamespace(verify_otp=_verify_otp)), + ) + + response = await gateway.create_email_session(request) + + assert captured_payload == { + "type": "email", + "email": "test@example.com", + "token": "654321", + } + assert response.user.email == "test@example.com" diff --git a/backend/tests/unit/test_json_finalize.py b/backend/tests/unit/test_json_finalize.py index c6c43a9..6950814 100644 --- a/backend/tests/unit/test_json_finalize.py +++ b/backend/tests/unit/test_json_finalize.py @@ -47,18 +47,47 @@ class _Model: return _Response(self._payload) -def test_build_instruction_uses_output_schema_title() -> None: +def test_build_instruction_has_schema() -> None: instruction = build_json_finalize_instruction( schema_json="{}", attempt=1, ) - assert "[输出结构Output Schema]" in instruction + assert "[Output Schema]" in instruction + + +def test_build_instruction_has_language_constraint_en() -> None: + instruction = build_json_finalize_instruction( + schema_json="{}", + attempt=1, + language="en-US", + ) + assert "ENGLISH OUTPUT REQUIRED" in instruction + assert "═══════════════════════════════════════════════════════════════════════════════" in instruction + + +def test_build_instruction_has_language_constraint_zh() -> None: + instruction = build_json_finalize_instruction( + schema_json="{}", + attempt=1, + language="zh-CN", + ) + assert "返回 JSON。使用简体中文" in instruction + + +def test_build_instruction_no_language_constraint_when_none() -> None: + instruction = build_json_finalize_instruction( + schema_json="{}", + attempt=1, + language=None, + ) + assert "ENGLISH OUTPUT REQUIRED" not in instruction + assert "返回 JSON" not in instruction @pytest.mark.asyncio async def test_finalize_json_response_returns_alias_keys() -> None: model = _Model(payload={"ganzhi": {"yearGanZhi": "丙午"}}) - _, payload = await finalize_json_response( + _, result = await finalize_json_response( model=model, formatter=_Formatter(), base_messages=[], @@ -66,4 +95,4 @@ async def test_finalize_json_response_returns_alias_keys() -> None: retries=0, ) - assert payload == {"ganzhi": {"yearGanZhi": "丙午"}} + assert result.model_dump(mode="json", by_alias=True) == {"ganzhi": {"yearGanZhi": "丙午"}} diff --git a/backend/tests/unit/test_notification_service.py b/backend/tests/unit/test_notification_service.py index d16410d..df895ac 100644 --- a/backend/tests/unit/test_notification_service.py +++ b/backend/tests/unit/test_notification_service.py @@ -5,7 +5,12 @@ from uuid import UUID, uuid4 import pytest -from v1.notifications.service import NotificationService, _parse_payload +from v1.notifications.service import ( + NotificationService, + _parse_payload, + resolve_i18n_text, + normalize_locale, +) from v1.notifications.schemas import ( NotificationPayloadNone, NotificationPayloadRoute, @@ -39,8 +44,8 @@ class _FakeNotification: *, id: UUID, type: str = "system", - title: str = "Test", - body: str = "Test body", + title: dict[str, str] | None = None, + body: dict[str, str] | None = None, payload: dict | None = None, status: str = "published", deleted_at: datetime | None = None, @@ -48,8 +53,8 @@ class _FakeNotification: ): self.id = id self.type = type - self.title = title - self.body = body + self.title = title or {"zh": "Test"} + self.body = body or {"zh": "Test body"} self.payload = payload or {"action": "none"} self.status = status self.deleted_at = deleted_at @@ -154,8 +159,8 @@ def _make_notification( notification_id: UUID | None = None, is_read: bool = False, read_at: datetime | None = None, - title: str = "Test", - body: str = "Test body", + title: dict[str, str] | None = None, + body: dict[str, str] | None = None, payload: dict | None = None, status: str = "published", deleted_at: datetime | None = None, @@ -185,8 +190,12 @@ class TestListNotifications: async def test_returns_only_user_a_notifications( self, service: NotificationService, fake_repo: _FakeNotificationRepository ): - un_a, n_a = _make_notification(user_id=USER_A, title="A1") - un_b, n_b = _make_notification(user_id=USER_B, title="B1") + un_a, n_a = _make_notification( + user_id=USER_A, title={"zh": "A1"}, body={"zh": "A1 body"}, + ) + un_b, n_b = _make_notification( + user_id=USER_B, title={"zh": "B1"}, body={"zh": "B1 body"}, + ) fake_repo.add_item(un_a, n_a) fake_repo.add_item(un_b, n_b) @@ -219,7 +228,9 @@ class TestListNotifications: self, service: NotificationService, fake_repo: _FakeNotificationRepository ): for i in range(3): - un, n = _make_notification(user_id=USER_A, title=f"N{i}") + un, n = _make_notification( + user_id=USER_A, title={"zh": f"N{i}"}, body={"zh": f"N{i} body"}, + ) fake_repo.add_item(un, n) result = await service.list_notifications(user_id=USER_A, limit=2) @@ -383,3 +394,75 @@ class TestParsePayload: assert payload.route == "/settings" assert payload.entity_id is None assert payload.tab is None + + +class TestResolveI18nText: + def test_exact_locale_match(self): + text = resolve_i18n_text({"zh": "你好", "en": "Hello"}, "en") + assert text == "Hello" + + def test_falls_back_to_default(self): + text = resolve_i18n_text({"zh": "你好", "en": "Hello"}, "zh_Hant") + assert text == "你好" + + def test_returns_empty_when_default_missing(self): + text = resolve_i18n_text({"en": "Hello"}, "zh_Hant") + assert text == "" + + def test_empty_dict(self): + text = resolve_i18n_text({}, "en") + assert text == "" + + +class TestNormalizeLocale: + def test_known_locale_passthrough(self): + assert normalize_locale("zh") == "zh" + assert normalize_locale("zh_Hant") == "zh_Hant" + assert normalize_locale("en") == "en" + + def test_none_returns_default(self): + assert normalize_locale(None) == "zh" + + def test_zh_cn_maps_to_zh(self): + assert normalize_locale("zh_CN") == "zh" + assert normalize_locale("zh_Hans") == "zh" + + def test_zh_tw_maps_to_hant(self): + assert normalize_locale("zh_TW") == "zh_Hant" + assert normalize_locale("zh-Hant") == "zh_Hant" + + def test_unknown_returns_default(self): + assert normalize_locale("fr") == "zh" + assert normalize_locale("ja") == "zh" + + +class TestListNotificationsI18n: + @pytest.mark.asyncio + async def test_locale_en_returns_english( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification( + user_id=USER_A, + title={"zh": "你好", "en": "Hello"}, + body={"zh": "正文", "en": "Body"}, + ) + fake_repo.add_item(un, n) + + result = await service.list_notifications(user_id=USER_A, locale="en") + assert result.items[0].title == "Hello" + assert result.items[0].body == "Body" + + @pytest.mark.asyncio + async def test_locale_zh_hant_falls_back_to_zh( + self, service: NotificationService, fake_repo: _FakeNotificationRepository + ): + un, n = _make_notification( + user_id=USER_A, + title={"zh": "你好", "en": "Hello"}, + body={"zh": "正文", "en": "Body"}, + ) + fake_repo.add_item(un, n) + + result = await service.list_notifications(user_id=USER_A, locale="zh_Hant") + assert result.items[0].title == "你好" + assert result.items[0].body == "正文" diff --git a/backend/tests/unit/test_parse_profile_settings.py b/backend/tests/unit/test_parse_profile_settings.py index 613aab2..1d7ab11 100644 --- a/backend/tests/unit/test_parse_profile_settings.py +++ b/backend/tests/unit/test_parse_profile_settings.py @@ -15,10 +15,8 @@ class TestParseProfileSettings: raw = { "version": 1, "preferences": { - "interface_language": "en-US", - "ai_language": "en-US", + "language": "en-US", "timezone": "America/New_York", - "country": "US", }, "privacy": {"profile_visibility": "private"}, "notification": { @@ -31,10 +29,8 @@ class TestParseProfileSettings: assert isinstance(result, ProfileSettingsV1) assert result.version == 1 assert isinstance(result.preferences, PreferenceSettings) - assert result.preferences.interface_language == "en-US" - assert result.preferences.ai_language == "en-US" + assert result.preferences.language == "en-US" assert result.preferences.timezone == "America/New_York" - assert result.preferences.country == "US" assert isinstance(result.notification, NotificationSettings) assert result.notification.allow_notifications is True assert result.notification.allow_vibration is False @@ -45,10 +41,8 @@ class TestParseProfileSettings: assert isinstance(result, ProfileSettingsV1) assert result.version == 1 assert isinstance(result.preferences, PreferenceSettings) - assert result.preferences.interface_language == "zh-CN" - assert result.preferences.ai_language == "zh-CN" + assert result.preferences.language == "zh-CN" assert result.preferences.timezone == "Asia/Shanghai" - assert result.preferences.country == "US" assert isinstance(result.notification, NotificationSettings) assert result.notification.allow_notifications is True assert result.notification.allow_vibration is True @@ -56,15 +50,13 @@ class TestParseProfileSettings: def test_parse_profile_settings_with_partial_preferences(self) -> None: raw = { "preferences": { - "interface_language": "en-US", + "language": "en-US", }, } result = parse_profile_settings(raw) - assert result.preferences.interface_language == "en-US" - assert result.preferences.ai_language == "zh-CN" + assert result.preferences.language == "en-US" assert result.preferences.timezone == "Asia/Shanghai" - assert result.preferences.country == "US" def test_parse_profile_settings_with_partial_notification(self) -> None: raw = { @@ -93,7 +85,7 @@ class TestParseProfileSettings: def test_parse_profile_settings_invalid_language_uses_default(self) -> None: raw = { "preferences": { - "interface_language": "invalid-language", + "language": "invalid-language", }, } with pytest.raises(ValueError, match="language must be a valid BCP-47 tag"): @@ -108,22 +100,11 @@ class TestParseProfileSettings: with pytest.raises(ValueError, match="timezone must be a valid IANA timezone"): parse_profile_settings(raw) - def test_parse_profile_settings_country_normalized_to_uppercase(self) -> None: - raw = { - "preferences": { - "country": "us", - }, - } - result = parse_profile_settings(raw) - assert result.preferences.country == "US" - def test_profile_settings_v1_model_dump(self) -> None: settings = ProfileSettingsV1( preferences=PreferenceSettings( - interface_language="en-US", - ai_language="en-US", + language="en-US", timezone="UTC", - country="US", ), notification=NotificationSettings( allow_notifications=True, @@ -132,6 +113,6 @@ class TestParseProfileSettings: ) dumped = settings.model_dump(mode="json") - assert dumped["preferences"]["interface_language"] == "en-US" + assert dumped["preferences"]["language"] == "en-US" assert dumped["notification"]["allow_notifications"] is True assert dumped["notification"]["allow_vibration"] is False diff --git a/backend/tests/unit/test_points_service_audit.py b/backend/tests/unit/test_points_service_audit.py index b357beb..e4e4c60 100644 --- a/backend/tests/unit/test_points_service_audit.py +++ b/backend/tests/unit/test_points_service_audit.py @@ -1,6 +1,7 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime, timezone from decimal import Decimal from uuid import UUID, uuid4 @@ -22,12 +23,23 @@ class _FakeAccount: version: int = 0 +@dataclass +class _FakeLedgerRow: + id: UUID + direction: int + amount: int + balance_after: int + change_type: str + created_at: datetime + + class _FakePointsRepository: def __init__(self, *, usage_snapshot: PointsChargeSnapshot | None) -> None: self.account = _FakeAccount() self.usage_snapshot = usage_snapshot self.appended_ledger: list[ApplyPointsChangeCommand] = [] self.appended_audit: list[AppendAuditLedgerCommand] = [] + self.ledger_rows: list[_FakeLedgerRow] = [] self.claimed: bool = False self.claim: RegisterBonusClaims | None = None @@ -88,6 +100,16 @@ class _FakePointsRepository: del email_hash return self.claim + async def list_ledger( + self, + *, + user_id: UUID, + limit: int, + cursor: datetime | None = None, + ) -> tuple[list[_FakeLedgerRow], bool]: + del user_id, cursor + return (self.ledger_rows[:limit], len(self.ledger_rows) > limit) + @pytest.mark.asyncio async def test_consume_successful_run_points_writes_real_usage_to_audit() -> None: @@ -225,3 +247,68 @@ async def test_grant_register_bonus_if_eligible_restores_balance_snapshot() -> N assert repository.claimed is False assert len(repository.appended_ledger) == 0 assert len(repository.appended_audit) == 0 + + +@pytest.mark.asyncio +async def test_get_ledger_list_returns_items_and_next_cursor() -> None: + created_at = datetime(2026, 4, 28, 8, 30, tzinfo=timezone.utc) + repository = _FakePointsRepository(usage_snapshot=None) + repository.ledger_rows = [ + _FakeLedgerRow( + id=uuid4(), + direction=1, + amount=60, + balance_after=160, + change_type="purchase", + created_at=created_at, + ), + ] + service = PointsService(repository=repository) # type: ignore[arg-type] + + items, next_cursor, has_more = await service.get_ledger_list( + user_id=uuid4(), + limit=1, + ) + + assert has_more is False + assert next_cursor is None + assert len(items) == 1 + assert items[0].amount == 60 + assert items[0].balance_after == 160 + assert items[0].change_type == "purchase" + assert items[0].created_at == created_at.isoformat() + + +@pytest.mark.asyncio +async def test_get_ledger_list_sets_next_cursor_when_more_rows_exist() -> None: + first_created_at = datetime(2026, 4, 28, 8, 30, tzinfo=timezone.utc) + second_created_at = datetime(2026, 4, 27, 8, 30, tzinfo=timezone.utc) + repository = _FakePointsRepository(usage_snapshot=None) + repository.ledger_rows = [ + _FakeLedgerRow( + id=uuid4(), + direction=1, + amount=60, + balance_after=160, + change_type="purchase", + created_at=first_created_at, + ), + _FakeLedgerRow( + id=uuid4(), + direction=-1, + amount=20, + balance_after=140, + change_type="consume", + created_at=second_created_at, + ), + ] + service = PointsService(repository=repository) # type: ignore[arg-type] + + items, next_cursor, has_more = await service.get_ledger_list( + user_id=uuid4(), + limit=1, + ) + + assert has_more is True + assert len(items) == 1 + assert next_cursor == first_created_at.isoformat() diff --git a/backend/tests/unit/test_static_notification_sync.py b/backend/tests/unit/test_static_notification_sync.py index 2267624..a2ead40 100644 --- a/backend/tests/unit/test_static_notification_sync.py +++ b/backend/tests/unit/test_static_notification_sync.py @@ -27,8 +27,12 @@ def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None version: 1 type: system status: published - title: Welcome - body: Welcome to the app. + title: + zh: 欢迎 + en: Welcome + body: + zh: 欢迎使用 + en: Welcome to the app. payload: action: open_route route: /points @@ -43,6 +47,8 @@ def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None loaded = load_static_notification_file(file_path) assert loaded.notification.source_key == "welcome_bonus" + assert loaded.notification.title == {"zh": "欢迎", "en": "Welcome"} + assert loaded.notification.body == {"zh": "欢迎使用", "en": "Welcome to the app."} assert loaded.notification.payload.action == "open_route" assert loaded.targets.mode == NotificationTargetMode.USER_IDS assert len(loaded.targets.user_ids or []) == 1 @@ -58,8 +64,10 @@ def test_load_static_notification_file_parses_new_users(tmp_path: Path) -> None: version: 1 type: system status: published - title: Welcome - body: You got points. + title: + zh: 欢迎 + body: + zh: 你好 payload: action: open_route route: /points @@ -85,8 +93,10 @@ def test_load_static_notification_file_parses_exist_users(tmp_path: Path) -> Non version: 1 type: system status: published - title: Come back - body: We miss you. + title: + zh: 回来吧 + body: + zh: 想你 payload: action: none targets: @@ -110,8 +120,10 @@ def test_load_static_notification_file_parses_all_users(tmp_path: Path) -> None: version: 1 type: system status: published - title: Announcement - body: Maintenance at midnight. + title: + zh: 公告 + body: + zh: 午夜维护 payload: action: none targets: @@ -134,8 +146,10 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) - version: 1 type: system status: published - title: Invalid - body: Invalid targets. + title: + zh: 无效 + body: + zh: 无效 payload: action: none targets: @@ -149,6 +163,88 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) - load_static_notification_file(file_path) +def test_load_static_notification_file_rejects_i18n_without_zh( + tmp_path: Path, +) -> None: + file_path = tmp_path / "missing_zh.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: missing_zh + version: 1 + type: system + status: published + title: + en: Welcome + body: + zh: 正文 + payload: + action: none + targets: + mode: all_users + """, + ) + + with pytest.raises(ValueError, match="Invalid static notification data"): + load_static_notification_file(file_path) + + +def test_load_static_notification_file_rejects_empty_i18n_text( + tmp_path: Path, +) -> None: + file_path = tmp_path / "empty_i18n.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: empty_i18n + version: 1 + type: system + status: published + title: + zh: "" + body: + zh: 正文 + payload: + action: none + targets: + mode: all_users + """, + ) + + with pytest.raises(ValueError, match="Invalid static notification data"): + load_static_notification_file(file_path) + + +def test_load_static_notification_file_rejects_unknown_i18n_locale( + tmp_path: Path, +) -> None: + file_path = tmp_path / "unknown_locale.yaml" + _write_yaml( + file_path, + """ + notification: + source_key: unknown_locale + version: 1 + type: system + status: published + title: + zh: 标题 + ja: タイトル + body: + zh: 正文 + payload: + action: none + targets: + mode: all_users + """, + ) + + with pytest.raises(ValueError, match="Invalid static notification data"): + load_static_notification_file(file_path) + + def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> None: file_path = tmp_path / "bad_mode.yaml" _write_yaml( @@ -159,8 +255,10 @@ def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> N version: 1 type: system status: published - title: Bad - body: Bad mode. + title: + zh: 坏 + body: + zh: 坏 payload: action: none targets: @@ -184,8 +282,10 @@ def test_load_static_notification_file_rejects_new_users_with_user_ids( version: 1 type: system status: published - title: Bad - body: Bad. + title: + zh: 坏 + body: + zh: 坏 payload: action: none targets: @@ -211,8 +311,10 @@ def test_load_static_notification_file_rejects_user_ids_without_list( version: 1 type: system status: published - title: Bad - body: Bad. + title: + zh: 坏 + body: + zh: 坏 payload: action: none targets: @@ -235,8 +337,10 @@ def test_load_static_notification_documents_rejects_duplicate_source_key( version: 1 type: system status: published - title: First - body: First body. + title: + zh: 第一 + body: + zh: 第一 payload: action: none targets: @@ -251,8 +355,10 @@ def test_load_static_notification_documents_rejects_duplicate_source_key( version: 1 type: system status: published - title: Second - body: Second body. + title: + zh: 第二 + body: + zh: 第二 payload: action: none targets: @@ -275,8 +381,10 @@ def test_content_hash_changes_when_notification_changes(tmp_path: Path) -> None: version: 1 type: system status: published - title: Title A - body: Body A. + title: + zh: 标题A + body: + zh: 正文A payload: action: none targets: @@ -291,8 +399,10 @@ def test_content_hash_changes_when_notification_changes(tmp_path: Path) -> None: version: 1 type: system status: published - title: Title B - body: Body A. + title: + zh: 标题B + body: + zh: 正文A payload: action: none targets: @@ -319,8 +429,10 @@ def test_load_static_notification_file_supports_deleted_flag(tmp_path: Path) -> type: system status: revoked deleted: true - title: Deleted - body: Deleted body. + title: + zh: 已删 + body: + zh: 已删 payload: action: none targets: diff --git a/deploy/README.md b/deploy/README.md new file mode 100644 index 0000000..1116d8f --- /dev/null +++ b/deploy/README.md @@ -0,0 +1,159 @@ +# 觅爻生产部署指南 + +## 目录说明 + +`deploy/` 用于存放生产环境启动所需文件: + +- `docker-compose.prod.yml`:生产 Docker Compose 启动配置,只拉取已有镜像,不负责构建。 +- `.env`:生产环境变量文件,本文件包含敏感信息,不应提交到 Git。 + +## 前置条件 + +生产机器需要安装: + +- Docker +- Docker Compose v2 +- AWS CLI v2 + +确认命令: + +```bash +docker --version +docker compose version +aws --version +``` + +## 环境变量 + +`docker-compose.prod.yml` 默认从当前目录读取 `.env`: + +```bash +deploy/.env +``` + +必须包含 AWS ECR 镜像定位变量: + +```text +AWS_ACCOUNT_ID=<你的 AWS 账号 ID> +AWS_REGION= +ECR_REPOSITORY= +``` + +如果本目录下的 `.env` 是从项目根目录 `.env` 复制过来的,通常还需要手动追加以上三个变量。 + +默认镜像地址会拼接为: + +```text +${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY}:latest +``` + +如果要手动指定完整镜像地址,可以在 `.env` 中设置: + +```text +ERYAO_BACKEND_IMAGE=<完整镜像地址> +``` + +Web 服务端口使用项目环境变量: + +```text +ERYAO_WEB__PORT=5775 +``` + +默认只绑定本机回环地址: + +```text +ERYAO_DEPLOY_BIND_HOST=127.0.0.1 +``` + +如果生产机器没有 Nginx、ALB 或其他反向代理,需要直接对外暴露端口,可改为: + +```text +ERYAO_DEPLOY_BIND_HOST=0.0.0.0 +``` + +## 登录 ECR + +进入部署目录,并把 `.env` 加载到当前 shell: + +```bash +cd deploy +set -a +. ./.env +set +a +``` + +在生产机器上配置好 AWS 凭据后执行: + +```bash +aws ecr get-login-password --region "$AWS_REGION" \ + | docker login --username AWS --password-stdin \ + "${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com" +``` + +## 启动服务 + +启动 Web、Redis 和 worker: + +```bash +cd deploy +docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers pull +docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers up -d +``` + +只启动 Web 和 Redis: + +```bash +cd deploy +docker compose --env-file ./.env -f docker-compose.prod.yml up -d +``` + +## 健康检查 + +如果 `ERYAO_WEB__PORT=5775`: + +```bash +curl http://127.0.0.1:5775/health +``` + +期望返回: + +```json +{"status":"ok"} +``` + +## 查看状态和日志 + +```bash +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 后,在生产机器执行: + +```bash +cd deploy +docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers pull +docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers up -d +``` + +## 停止服务 + +```bash +cd deploy +docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers down +``` + +如需连 Redis 数据卷一起删除: + +```bash +cd deploy +docker compose --env-file ./.env -f docker-compose.prod.yml --profile workers down -v +``` + +谨慎使用 `down -v`,它会删除 Redis 持久化数据。 diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml new file mode 100644 index 0000000..1284570 --- /dev/null +++ b/deploy/docker-compose.prod.yml @@ -0,0 +1,79 @@ +name: eryao-prod + +x-backend-common: &backend-common + image: ${ERYAO_BACKEND_IMAGE:-${AWS_ACCOUNT_ID:?AWS_ACCOUNT_ID is required}.dkr.ecr.${AWS_REGION:?AWS_REGION is required}.amazonaws.com/${ECR_REPOSITORY:?ECR_REPOSITORY is required}:latest} + env_file: + - path: ./.env + required: true + depends_on: + redis: + condition: service_healthy + restart: unless-stopped + +services: + backend: + <<: *backend-common + container_name: eryao-prod-backend + environment: + ERYAO_RUNTIME__ENVIRONMENT: prod + ERYAO_RUNTIME__SERVICE_NAME: web + ERYAO_REDIS__HOST: redis + ERYAO_REDIS__PORT: 6379 + ports: + - "${ERYAO_DEPLOY_BIND_HOST:-127.0.0.1}:${ERYAO_WEB__PORT:-5775}:${ERYAO_WEB__PORT:-5775}" + + worker-agent: + <<: *backend-common + container_name: eryao-prod-worker-agent + profiles: ["workers"] + environment: + ERYAO_RUNTIME__ENVIRONMENT: prod + ERYAO_RUNTIME__SERVICE_NAME: worker-agent + ERYAO_REDIS__HOST: redis + ERYAO_REDIS__PORT: 6379 + 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} + + redis: + image: redis:7.4.2-alpine + container_name: eryao-prod-redis + env_file: + - path: ./.env + required: true + environment: + REDIS_PASSWORD: ${ERYAO_REDIS__PASSWORD:-} + command: > + sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"; else redis-server --appendonly yes; fi' + volumes: + - redis_data:/data + healthcheck: + test: + [ + "CMD", + "sh", + "-c", + 'if [ -n "$$REDIS_PASSWORD" ]; then redis-cli -a "$$REDIS_PASSWORD" ping; else redis-cli ping; fi', + ] + interval: 5s + timeout: 3s + retries: 5 + restart: unless-stopped + +volumes: + redis_data: diff --git a/docs/protocols/auth/session-auth-protocol.md b/docs/protocols/auth/session-auth-protocol.md index 4e7bce7..8b575b7 100644 --- a/docs/protocols/auth/session-auth-protocol.md +++ b/docs/protocols/auth/session-auth-protocol.md @@ -73,10 +73,18 @@ Request: { "email": "user@example.com", "token": "123456" } ``` +Optional fields: + +```json +{ "email": "user@example.com", "token": "123456", "language": "zh-CN", "timezone": "Asia/Shanghai" } +``` + Validation (backend): - `email` must match `SUPABASE_EMAIL_PATTERN` - `token` must be exactly 6 chars +- `language` (optional): max 20 chars, updates profile settings.preferences.language if provided +- `timezone` (optional): max 50 chars, updates profile settings.preferences.timezone if provided Response: diff --git a/docs/protocols/common/http-error-codes.md b/docs/protocols/common/http-error-codes.md index ed7bb1b..1a8bae7 100644 --- a/docs/protocols/common/http-error-codes.md +++ b/docs/protocols/common/http-error-codes.md @@ -19,6 +19,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed | code | status | meaning | frontend handling | |---|---:|---|---| | `POINTS_INSUFFICIENT_BALANCE` | 402 | Not enough points to start this run | Show recharge/insufficient-points prompt | +| `POINTS_INVALID_CURSOR` | 422 | Points ledger pagination cursor is not a valid ISO 8601 datetime | Show invalid-request message and reload from first page | ## Agent Session @@ -82,6 +83,7 @@ This document is the source of truth for backend RFC7807 `code` values consumed | code | status | meaning | frontend handling | |---|---:|---|---| | `NOTIFICATION_NOT_FOUND` | 404 | Notification not found or not owned by current user | Show not-found message and refresh list | +| `NOTIFICATION_INVALID_CURSOR` | 422 | Notification pagination cursor is not a valid ISO 8601 datetime | Show invalid-request message and reload from first page | ## Invite @@ -100,6 +102,21 @@ This document is the source of truth for backend RFC7807 `code` values consumed | `FEEDBACK_INVALID_IMAGE_TYPE` | 400 | Image type not supported (only jpg/png) | Show supported format hint | | `FEEDBACK_SUBMIT_FAILED` | 500 | Feedback submission failed | Show retry prompt | +## Payment (Apple IAP) + +| code | status | meaning | frontend handling | +|---|---:|---|---| +| `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 | +| `PAYMENT_STARTER_PACK_INELIGIBLE` | 409 | Current email identity has already purchased starter pack | Refresh packages and hide starter pack | +| `PAYMENT_APPLE_UNAVAILABLE` | 503 | (RESERVED) Apple Server API or certificate fetch unavailable | Show retry-later message; do NOT complete/finish transaction | +| `PAYMENT_GRANT_FAILED` | 500 | (RESERVED) Verification succeeded but grant transaction failed | Show retry-later message; retain transaction for compensation | +| `PAYMENT_REFUND_INSUFFICIENT_BALANCE` | 409 | (RESERVED) User has insufficient balance for refund clawback | Log for manual review; do not auto-clawback | + ## Global | code | status | meaning | frontend handling | diff --git a/docs/protocols/common/user-points-chat-data-protocol.md b/docs/protocols/common/user-points-chat-data-protocol.md index fa1a09c..ea5bf8b 100644 --- a/docs/protocols/common/user-points-chat-data-protocol.md +++ b/docs/protocols/common/user-points-chat-data-protocol.md @@ -34,6 +34,25 @@ Protocol verification status: - Billing idempotency key for per-run consume: `chat.run.success:{sha1(session_id:run_id)}`. - Failed/canceled runs do not deduct user points. If real provider cost is observed, audit record is written with `billed_to='platform'`. +## Points Change Types (change_type) + +| Type | Direction | Meaning | biz_type | Description | +|------|-----------|---------|----------|-------------| +| `register` | +1 | 注册奖励 | `null` | 新用户注册赠送积分 | +| `consume` | -1 | 消费扣减 | `chat` | 用户占卜消耗积分 | +| `adjust` | ±1 | 手动调整 | `null` | 系统或管理员手动调整积分,通用调整不绑定业务场景 | +| `purchase` | +1 | 购买入账 | `payment` | 用户支付购买积分 | +| `refund` | -1 | 退款扣回 | `payment` | 退款后扣回积分 | + +## Points Business Types (biz_type) + +| Type | Meaning | Associated change_type | +|------|---------|------------------------| +| `chat` | 聊天/占卜业务 | `consume` | +| `payment` | 支付业务 | `purchase`, `refund` | + +Note: `register` and `adjust` do not bind to any `biz_type` (they are `null`). + ## Table contract ### profiles @@ -62,21 +81,23 @@ Protocol verification status: - PK: `id` - FK: - `user_id -> auth.users.id` (`on delete cascade`) - - `biz_id -> sessions.id` (`on delete restrict`, nullable) + - `biz_id -> sessions.id` (`on delete restrict`, nullable) — only for `biz_type='chat'` - `operator_id -> auth.users.id` (`on delete set null`) - Core fields: `direction`, `amount`, `balance_after`, `change_type`, `biz_type`, `biz_id`, `event_id`, `operator_id`, `metadata`, `created_at`, `updated_at` - Constraints: - `amount > 0` - `direction in (1, -1)` - `balance_after >= 0` - - `change_type in ('register', 'consume', 'grant', 'adjust')` - - `biz_type is null or biz_type='chat'` + - `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')` + - `biz_type is null or biz_type in ('chat', 'payment')` - biz binding: - `register => biz_type is null and biz_id is null` - - `consume/grant/adjust => biz_type='chat' and biz_id not null` + - `consume => biz_type='chat' and biz_id not null` + - `adjust => biz_type is null and biz_id is null` (通用调整,不绑定业务场景) + - `purchase/refund => biz_type='payment' and biz_id not null` (biz_id references `apple_iap_transactions.id` as logical FK, not database FK) - direction and change_type coupling: - - `register/grant => direction = 1` - - `consume => direction = -1` + - `register/purchase => direction = 1` + - `consume/refund => direction = -1` - `adjust => direction in (1, -1)` - idempotency: `unique (user_id, event_id)` @@ -89,8 +110,8 @@ Protocol verification status: - `amount >= 0` - `direction in (1, 0, -1)` - `balance_after >= 0` - - `change_type in ('register', 'consume', 'grant', 'adjust')` - - `biz_type is null or biz_type='chat'` + - `change_type in ('register', 'consume', 'adjust', 'purchase', 'refund')` + - `biz_type is null or biz_type in ('chat', 'payment')` - `billed_to in ('user', 'platform')` - metadata must be object - idempotency: `unique (event_id)` @@ -141,8 +162,9 @@ JSON constraints: - Per `change_type`: - `register`: no `charge`, and no chat binding (`biz_type/biz_id` both null) - `consume`: requires `charge` object with required fields - - `grant`: no extra metadata shape requirement - - `adjust`: requires `ext.ticket_id` non-empty + - `adjust`: requires `ext.reason` non-empty (通用调整,系统或管理员均可操作,不绑定业务) + - `purchase`: requires `ext.source`, `ext.platform`, `ext.product_code`, `ext.transaction_id` + - `refund`: requires `ext.source`, `ext.platform`, `ext.product_code`, `ext.transaction_id`, `ext.original_event_id` ## Signup initialization contract @@ -208,11 +230,54 @@ Managed by `python -m core.runtime.cli sync-notifications [flags]`: Run after migrations on fresh environments or after adding new notification YAML definitions. Not included in `bootstrap` to keep bootstrap fast and free of unintended side effects. +## Points Ledger API + +### GET /api/v1/points/ledger + +Returns the authenticated user's points ledger in reverse chronological order. + +**Request:** +- Auth: Required (JWT) +- Query: + - `limit`: integer, `1..100`, default `20` + - `cursor`: optional ISO 8601 datetime returned by the previous response `nextCursor` + +**Response:** +```json +{ + "items": [ + { + "id": "9cfd5d1d-0dd8-4b30-88ce-6e4a63d22d76", + "direction": 1, + "amount": 60, + "balanceAfter": 160, + "changeType": "purchase", + "createdAt": "2026-04-28T08:30:00+00:00" + } + ], + "nextCursor": "2026-04-28T08:30:00+00:00", + "hasMore": true +} +``` + +**Fields:** +- `items`: ledger rows ordered by `createdAt desc` +- `direction`: `1` for income, `-1` for spending/deduction +- `amount`: positive points delta +- `balanceAfter`: account balance after the ledger event +- `changeType`: one of `register`, `purchase`, `consume`, `adjust`, `refund` +- `createdAt`: ISO 8601 datetime for display and pagination +- `nextCursor`: last returned row `createdAt` when `hasMore=true`; otherwise `null` +- `hasMore`: whether another page is available + +**Errors:** +- `POINTS_INVALID_CURSOR` (`422`): `cursor` is not a valid ISO 8601 datetime + ## Packages API ### GET /api/v1/points/packages -Returns available purchase packages for the current user's region, including starter pack eligibility. +Returns available purchase packages for the current user, including starter pack eligibility. **Request:** - Auth: Required (JWT) @@ -221,25 +286,21 @@ Returns available purchase packages for the current user's region, including sta **Response:** ```json { - "region": "US", - "currency": "USD", "packages": [ { - "productCode": "new_user_pack_099_60", + "productCode": "new_user_pack", + "appStoreProductId": "com.meeyao.qianwen.new_user_pack", "type": "starter", - "priceUsd": "0.99", "credits": 60, - "badge": null, "isStarter": true, "starterEligible": true, "sortOrder": 0 }, { - "productCode": "basic_pack_499_100", + "productCode": "starter_pack", + "appStoreProductId": "com.meeyao.qianwen.starter_pack", "type": "regular", - "priceUsd": "4.99", "credits": 100, - "badge": null, "isStarter": false, "starterEligible": false, "sortOrder": 10 @@ -249,51 +310,42 @@ Returns available purchase packages for the current user's region, including sta ``` **Fields:** -- `region`: ISO 3166-1 alpha-2 country code (e.g., "US", "CN") -- `currency`: ISO 4217 currency code (e.g., "USD") - `packages`: List of available packages - - `productCode`: Unique product identifier - - `type`: "starter" (new user pack) or "regular" - - `priceUsd`: Price in USD (decimal string) - - `credits`: Number of credits - - `badge`: Optional badge text (e.g., "Popular") - - `isStarter`: Whether this is a starter pack - - `starterEligible`: Whether user is eligible to purchase starter pack - - `sortOrder`: Display order (ascending) +- `productCode`: Unique product identifier (e.g., `new_user_pack`, `starter_pack`, `popular_pack`, `premium_pack`) +- `appStoreProductId`: Apple App Store product identifier used for StoreKit purchase +- `type`: "starter" (new user pack) or "regular" +- `credits`: Number of credits +- `isStarter`: Whether this is a starter pack +- `starterEligible`: Whether user is eligible to purchase starter pack +- `sortOrder`: Display order (ascending) **Business Logic:** -1. Determine user's region from `profile.settings.preferences.country` (default: "US") -2. Load package configuration from `backend/src/core/config/static/packages/{country}.yaml` (fallback to `default.yaml`) -3. Check starter pack eligibility: - - If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response - - Otherwise, include starter pack with `starterEligible: true` +1. Load package mapping from `backend/src/core/config/static/packages/mapping.yaml` +2. Check starter pack eligibility: + - If `register_bonus_claims.has_purchased_starter_pack = true`, exclude starter pack from response + - Otherwise, include starter pack with `starterEligible: true` **Configuration Files:** - Path: `backend/src/core/config/static/packages/` - Format: YAML -- Example: `us.yaml` +- Example: `mapping.yaml` ```yaml -region: US -currency: USD -packages: - - product_code: new_user_pack_099_60 - type: starter - price_usd: "0.99" +product_mappings: + new_user_pack: + app_store_product_id: com.meeyao.qianwen.new_user_pack credits: 60 - badge: null + type: starter sort_order: 0 enabled: true - - product_code: basic_pack_499_100 - type: regular - price_usd: "4.99" + starter_pack: + app_store_product_id: com.meeyao.qianwen.starter_pack credits: 100 - badge: null + type: regular sort_order: 10 enabled: true ``` -**Country/Region Codes:** -- Uses ISO 3166-1 alpha-2 standard -- Default: `US` (United States) -- Examples: `CN` (China), `TW` (Taiwan), `HK` (Hong Kong), `JP` (Japan) +**Compatibility Note:** +- Previous protocol version documented `region` and `currency` fields that were never implemented. These have been removed from the specification. +- Strategy: `backward-compatible` — clients that expect these fields should handle their absence gracefully. diff --git a/docs/protocols/divination/divination-run-protocol.md b/docs/protocols/divination/divination-run-protocol.md index a771e01..53bbc3c 100644 --- a/docs/protocols/divination/divination-run-protocol.md +++ b/docs/protocols/divination/divination-run-protocol.md @@ -237,7 +237,7 @@ Field notes: - `runtime_mode=chat` fields: `status`, `sign_level`, `conclusion`, `focus_points`, `advice`, `keywords`, `answer`, `error`, `divination_derived`. - `runtime_mode=follow_up` fields: `status`, `answer`, `error`. - `runtime_mode=follow_up` MUST NOT include `sign_level`, `conclusion`, `focus_points`, `advice`, `keywords`, `divination_derived`. -- Language rule: `conclusion`, `focus_points`, `advice`, `keywords`, `answer` should follow user `ai_language` preference unless user explicitly requests otherwise. +- Language rule: `conclusion`, `focus_points`, `advice`, `keywords`, `answer` should follow user `language` preference unless user explicitly requests otherwise. - Canonical six-yao terms remain Chinese in protocol text (for example: 世爻、应爻、动爻、静爻、六亲、六神、伏神、月建、日辰、月破、日冲、空亡、五行旺衰). Frontend should combine: diff --git a/docs/protocols/notification/notification-inbox-protocol.md b/docs/protocols/notification/notification-inbox-protocol.md index 6300467..76078ff 100644 --- a/docs/protocols/notification/notification-inbox-protocol.md +++ b/docs/protocols/notification/notification-inbox-protocol.md @@ -27,6 +27,7 @@ List notifications for the current user. - `limit` (optional, integer, default 20, max 50): number of items per page - `cursor` (optional, string): pagination cursor (ISO 8601 timestamp of last item's `created_at`) +- `locale` (optional, string): requested locale for title/body resolution. Supported values: `zh` (default), `zh_Hant`, `en`. If the requested locale is not available in the notification's i18n dict, falls back to `zh`. **Response (200)**: @@ -61,6 +62,7 @@ Field rules: - `payload`: discriminated union (see Payload section below) - `isRead`: boolean - `readAt`: ISO 8601 timestamp or `null` +- `title` and `body`: resolved plain strings based on the `locale` parameter. The database stores these as i18n JSONB objects (`{"zh": "...", "zh_Hant": "...", "en": "..."}`); the API resolves the best match before returning. - Results are filtered: `notifications.status = 'published'` and `notifications.deleted_at IS NULL` ### GET /api/v1/notifications/unread-count @@ -92,6 +94,10 @@ Mark a single notification as read. Idempotent. - `notification_id`: UUID of the `user_notifications` record +**Query parameters**: + +- `locale` (optional, string): requested locale for title/body resolution (same rules as list endpoint) + **Response (200)**: ```json diff --git a/docs/protocols/notification/static-notification-sync-protocol.md b/docs/protocols/notification/static-notification-sync-protocol.md index 7fe1bc8..c1b622e 100644 --- a/docs/protocols/notification/static-notification-sync-protocol.md +++ b/docs/protocols/notification/static-notification-sync-protocol.md @@ -41,8 +41,14 @@ notification: type: system status: published published_at: 2026-04-10T08:00:00Z - title: 新用户欢迎通知 - body: 你已获得注册奖励,可前往积分中心查看。 + title: + zh: 新用户欢迎通知 + zh_Hant: 新用戶歡迎通知 + en: Welcome + body: + zh: 你已获得注册奖励,可前往积分中心查看。 + zh_Hant: 你已獲得註冊獎勵,可前往積分中心查看。 + en: You have received a registration reward. Check your points. payload: action: open_route route: /points @@ -60,8 +66,8 @@ targets: - `status`: required, one of `draft`, `published`, `revoked` - `deleted`: optional, boolean, default `false`, soft-delete this notification - `published_at`: optional ISO 8601 timestamp -- `title`: required, non-empty string -- `body`: required, non-empty string +- `title`: required, non-empty dict mapping locale codes to translated strings. Must include at least `zh`. Supported keys: `zh`, `zh_Hant`, `en`. +- `body`: required, non-empty dict mapping locale codes to translated strings. Must include at least `zh`. Supported keys: `zh`, `zh_Hant`, `en`. - `payload`: required, must follow the notification payload protocol ### targets diff --git a/docs/protocols/payments/apple-iap-protocol.md b/docs/protocols/payments/apple-iap-protocol.md new file mode 100644 index 0000000..ee488d2 --- /dev/null +++ b/docs/protocols/payments/apple-iap-protocol.md @@ -0,0 +1,138 @@ +# Apple IAP Protocol (Frontend <-> Backend) + +This document defines the Apple In-App Purchase verification and grant contract for Eryao Flutter app. + +Protocol verification status: + +- Backend route source: `backend/src/v1/payments/router.py` +- Backend service source: `backend/src/v1/payments/service.py` +- Backend verifier source: `backend/src/v1/payments/apple_verifier.py` +- Backend schema source: `backend/src/v1/payments/schemas.py` +- Current status: aligned + +## Compatibility strategy + +- Current strategy: additive evolution (`backward-compatible`). +- Breaking change requires explicit migration + rollback notes (`requires-migration`). + +## Route overview + +- Verify transaction: `POST /api/v1/payments/apple/transactions/verify` +- Apple server notification: `POST /api/v1/payments/apple/notifications` (server-to-server) + +## Verify transaction + +### `POST /api/v1/payments/apple/transactions/verify` + +Verify and grant credits for an Apple IAP transaction. + +**Authorization**: Requires authenticated session. User identity from JWT `sub`. + +**Request:** + +```json +{ + "productCode": "new_user_pack", + "appStoreProductId": "com.meeyao.qianwen.new_user_pack", + "transactionId": "2000000123456789", + "signedTransactionInfo": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQeF...", + "appAccountToken": "uuid-or-null" +} +``` + +**Fields:** +- `productCode` (required): string, max 32 chars, product code from packages API +- `appStoreProductId` (required): string, max 128 chars, Apple product ID +- `transactionId` (required): string, max 64 chars, Apple transaction ID +- `signedTransactionInfo` (required): string, JWS signed transaction info from Apple +- `appAccountToken` (optional): UUID, app account token for user association + +**Response (200):** + +```json +{ + "status": "granted", + "productCode": "new_user_pack", + "transactionId": "2000000123456789", + "creditsAdded": 60, + "newBalance": 160, + "ledgerEventId": "payment.apple_iap:2000000123456789" +} +``` + +**Status values:** +- `granted`: Transaction verified and credits added +- `already_granted`: Transaction already processed for this user + +**Error codes:** + +| code | status | meaning | +|---|---:|---| +| `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 | +| `PAYMENT_STARTER_PACK_INELIGIBLE` | 409 | Current email identity has already purchased starter pack | + +## Apple server notification + +### `POST /api/v1/payments/apple/notifications` + +Server-to-server notification from Apple for refund/revoke events. + +**Authorization**: None (Apple server origin). + +**Request:** + +```json +{ + "signedPayload": "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsIng1YyI6WyJNSUlFTURDQ0E3YWdBd0lCQWdJUWFQeF..." +} +``` + +**Response (200):** Empty success response. + +**Behavior:** +- Parses notification type and transaction info +- For `REFUND`, `REVOKE`, `DID_FAIL_TO_RENEW` notifications, processes refund clawback +- Refund reduces user balance by original purchase amount (or remaining balance if insufficient) + +## Product mapping + +Products are configured in `backend/src/core/config/static/packages/mapping.yaml`: + +```yaml +product_mappings: + new_user_pack: + app_store_product_id: com.meeyao.qianwen.new_user_pack + credits: 60 + type: starter + sort_order: 0 + enabled: true +``` + +## Starter pack eligibility + +- Starter pack (`type: starter`) can only be purchased once per email identity +- Backend tracks purchase via `register_bonus_claims.has_purchased_starter_pack` +- If already purchased, returns `PAYMENT_STARTER_PACK_INELIGIBLE` (409) + +## Ledger integration + +- Successful purchases create a ledger entry with: + - `change_type: purchase` + - `biz_type: payment` + - `biz_id: apple_iap_transactions.id` + - `metadata.ext` containing Apple IAP details + +- Refunds create a ledger entry with: + - `change_type: refund` + - `biz_type: payment` + - `metadata.ext.original_event_id` referencing original purchase event + +## Error contract linkage + +- All errors use RFC7807 with extension `code` and optional `params`. +- Error code registry source: `docs/protocols/common/http-error-codes.md`. diff --git a/docs/protocols/profile/profile-protocol.md b/docs/protocols/profile/profile-protocol.md index 46520d1..80efea6 100644 --- a/docs/protocols/profile/profile-protocol.md +++ b/docs/protocols/profile/profile-protocol.md @@ -46,10 +46,8 @@ Response: "settings": { "version": 1, "preferences": { - "interface_language": "zh-CN", - "ai_language": "zh-CN", - "timezone": "Asia/Shanghai", - "country": "CN" + "language": "zh-CN", + "timezone": "Asia/Shanghai" }, "privacy": { "can_sell": false, @@ -111,10 +109,8 @@ Request: "settings": { "version": 1, "preferences": { - "interface_language": "zh-CN", - "ai_language": "zh-CN", - "timezone": "Asia/Shanghai", - "country": "CN" + "language": "zh-CN", + "timezone": "Asia/Shanghai" }, "privacy": { "can_sell": false, diff --git a/infra/docker/README.md b/infra/docker/README.md new file mode 100644 index 0000000..e49c8d6 --- /dev/null +++ b/infra/docker/README.md @@ -0,0 +1,126 @@ +# Docker Compose Infrastructure + +> Local development infrastructure orchestration. + +--- + +## Overview + +This directory contains Docker Compose configurations for local development: + +- `docker-compose.yml` - Base services (Redis, etc.) +- `supabase/docker-compose.yml` - Local Supabase stack (PostgreSQL, Auth, Storage, Studio, etc.) + +--- + +## Environment-Based Service Activation + +Services are conditionally enabled based on `ERYAO_RUNTIME__ENVIRONMENT`: + +| Environment | Services | +|-------------|----------| +| `dev` | Redis + Local Supabase | +| `prod` | Redis only (use cloud Supabase) | + +### How It Works + +Supabase services use `profiles: [dev]`. They only start when the `dev` profile is activated. + +--- + +## Usage + +### Development (with local Supabase) + +```bash +cd infra/docker +docker compose --profile dev up -d +``` + +Or with environment variable: + +```bash +ERYAO_RUNTIME__ENVIRONMENT=dev docker compose --profile dev up -d +``` + +### Production (cloud Supabase) + +```bash +cd infra/docker +docker compose up -d +``` + +--- + +## Services Reference + +### Base Services (always started) + +| Service | Port | Description | +|---------|------|-------------| +| Redis | 6379 | Caching and queue backend | + +### Dev-Only Services (profile: dev) + +| Service | Port | Description | +|---------|------|-------------| +| PostgreSQL | 5432 | Local database | +| GoTrue (Auth) | 9999 | Authentication service | +| PostgREST | 3000 | REST API for database | +| Storage | 5000 | File storage service | +| Studio | 3000 | Supabase dashboard UI | +| Kong | 8001, 8443 | API gateway | + +--- + +## Configuration + +All services read configuration from `.env` (symlinked to project root `.env`). + +Required environment variables: + +```bash +# Runtime +ERYAO_RUNTIME__ENVIRONMENT=dev + +# Database +ERYAO_DATABASE__PORT=5432 +ERYAO_DATABASE__NAME=postgres +ERYAO_DATABASE__PASSWORD=your-password + +# Supabase +ERYAO_SUPABASE__JWT_SECRET=your-jwt-secret +ERYAO_SUPABASE__ANON_KEY=your-anon-key +ERYAO_SUPABASE__SERVICE_ROLE_KEY=your-service-role-key +ERYAO_SUPABASE__PUBLIC_URL=http://localhost:8001 + +# Redis +ERYAO_REDIS__PORT=6379 +ERYAO_REDIS__PASSWORD=your-redis-password +``` + +--- + +## Troubleshooting + +### Supabase services not starting + +1. Check profile is activated: `docker compose --profile dev config` +2. Verify environment variables in `.env` +3. Check logs: `docker compose logs ` + +### Port conflicts + +If ports are already in use, override in `.env`: + +```bash +ERYAO_DATABASE__PORT=5433 +ERYAO_REDIS__PORT=6380 +``` + +### Reset local database + +```bash +docker compose --profile dev down -v +docker compose --profile dev up -d +``` diff --git a/infra/docker/docker-compose.yml b/infra/docker/docker-compose.yml index 443ab6c..d6f002a 100644 --- a/infra/docker/docker-compose.yml +++ b/infra/docker/docker-compose.yml @@ -1,32 +1,32 @@ name: eryao-local -# include: -# - ./supabase/docker-compose.yml +include: + - ./supabase/docker-compose.yml services: - redis: - image: redis:7.4.2-alpine - container_name: eryao-local-redis - restart: unless-stopped - ports: - - "127.0.0.1:${ERYAO_REDIS__PORT:-6379}:6379" - volumes: - - redis_data:/data - environment: - REDIS_PASSWORD: ${ERYAO_REDIS__PASSWORD:-} - command: > - sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"; else redis-server --appendonly yes; fi' - healthcheck: - test: - [ - "CMD", - "sh", - "-c", - "if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli -a \"$$REDIS_PASSWORD\" ping; else redis-cli ping; fi", - ] - interval: 5s - timeout: 3s - retries: 5 + redis: + image: redis:7.4.2-alpine + container_name: eryao-local-redis + restart: unless-stopped + ports: + - "127.0.0.1:${ERYAO_REDIS__PORT:-6379}:6379" + volumes: + - redis_data:/data + environment: + REDIS_PASSWORD: ${ERYAO_REDIS__PASSWORD:-} + command: > + sh -c 'if [ -n "$$REDIS_PASSWORD" ]; then redis-server --appendonly yes --requirepass "$$REDIS_PASSWORD"; else redis-server --appendonly yes; fi' + healthcheck: + test: + [ + "CMD", + "sh", + "-c", + 'if [ -n "$$REDIS_PASSWORD" ]; then redis-cli -a "$$REDIS_PASSWORD" ping; else redis-cli ping; fi', + ] + interval: 5s + timeout: 3s + retries: 5 volumes: - redis_data: + redis_data: diff --git a/infra/docker/supabase/docker-compose.yml b/infra/docker/supabase/docker-compose.yml index 3d3c929..b7815e2 100644 --- a/infra/docker/supabase/docker-compose.yml +++ b/infra/docker/supabase/docker-compose.yml @@ -2,6 +2,7 @@ name: eryao-supabase services: db: + profiles: [dev] container_name: eryao-supabase-db image: supabase/postgres:15.8.1.085 restart: unless-stopped @@ -33,6 +34,7 @@ services: - 127.0.0.1:${ERYAO_DATABASE__PORT:-5432}:5432 auth: + profiles: [dev] container_name: eryao-supabase-auth image: supabase/gotrue:v2.186.0 restart: unless-stopped @@ -73,6 +75,7 @@ services: GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify rest: + profiles: [dev] container_name: eryao-supabase-rest image: postgrest/postgrest:v14.8 restart: unless-stopped @@ -91,6 +94,7 @@ services: PGRST_APP_SETTINGS_JWT_EXP: 3600 storage: + profiles: [dev] container_name: eryao-supabase-storage image: supabase/storage-api:v1.48.26 restart: unless-stopped @@ -124,6 +128,7 @@ services: - storage-data:/var/lib/storage meta: + profiles: [dev] container_name: eryao-supabase-meta image: supabase/postgres-meta:v0.96.3 restart: unless-stopped @@ -145,6 +150,7 @@ services: retries: 1 studio: + profiles: [dev] container_name: eryao-supabase-studio image: supabase/studio:2026.04.08-sha-205cbe7 restart: unless-stopped @@ -180,6 +186,7 @@ services: - studio-functions:/var/lib/functions kong: + profiles: [dev] container_name: eryao-supabase-kong image: kong/kong:3.9.1 restart: unless-stopped diff --git a/infra/scripts/app.sh b/infra/scripts/app.sh index b10670e..6233b95 100755 --- a/infra/scripts/app.sh +++ b/infra/scripts/app.sh @@ -20,6 +20,7 @@ load_env_if_exists() { # shellcheck disable=SC1090 . "$ENV_LOADER" load_env_file "$ENV_FILE" + load_env_file "$ROOT_DIR/.env.local" } is_port_in_use() { @@ -163,6 +164,9 @@ start() { echo "" echo "=== App Started ===" + echo "Web server running on: http://localhost:${WEB_PORT}" + echo "Health check: http://localhost:${WEB_PORT}/health" + echo "" echo "Log files will be created in logs/ directory:" echo " - web.log, web.error.log" echo " - worker-agent.log, worker-agent.error.log" @@ -187,8 +191,8 @@ stop() { echo "Checking for orphaned processes..." - kill_matching_processes "uvicorn" "uv run uvicorn app:app" - kill_matching_processes "taskiq workers" "uv run taskiq worker core.taskiq.app:" + kill_matching_processes "uvicorn" "uvicorn app:app" + kill_matching_processes "taskiq workers" "taskiq worker.*core\.taskiq\.app:" kill_listening_processes "port ${WEB_PORT} listeners" "$WEB_PORT" diff --git a/infra/scripts/dev-migrate.sh b/infra/scripts/dev-migrate.sh index 2b00643..d548287 100755 --- a/infra/scripts/dev-migrate.sh +++ b/infra/scripts/dev-migrate.sh @@ -26,6 +26,7 @@ fi # shellcheck disable=SC1090 . "$ENV_LOADER" load_env_file "$ENV_FILE" +load_env_file "$ROOT_DIR/.env.local" cd "$ROOT_DIR" diff --git a/pyproject.toml b/pyproject.toml index fb099ce..26a4a06 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -53,6 +53,7 @@ markers = ["integration: integration test requiring live backend and workers"] dev = [ "basedpyright==1.38.2", "pre-commit==4.5.1", + "ruff>=0.15.12", ] [tool.basedpyright]