chore: 优化本地开发环境配置

- 添加 .env.local 支持,app.sh 和 dev-migrate.sh 自动覆盖
- Docker Compose 使用 profiles 区分 dev/prod 环境
- 改进认证 dev session 判断逻辑,使用 test account 配置
- 修复 CoinPackageCard 重复代码问题
- 清理 opencode 配置,移除敏感信息
- 新增 infra/docker/README.md 文档
- 修复 ruff/pyright/flutter lint 错误
- 更新测试用例移除已删除的 country 字段
This commit is contained in:
qzl
2026-04-28 18:49:38 +08:00
parent 86062d5e78
commit dab47f0cb3
21 changed files with 642 additions and 155 deletions
+1
View File
@@ -306,6 +306,7 @@ infra/docker/supabase/volumes/storage/
# OpenCode local config # OpenCode local config
# .opencode/ is now tracked - see .opencode/.gitignore for exclusions # .opencode/ is now tracked - see .opencode/.gitignore for exclusions
.opencode/opencode.json
midscene_run/ midscene_run/
# Local git worktrees # Local git worktrees
@@ -17,7 +17,7 @@
"supabase": { "supabase": {
"type": "remote", "type": "remote",
"enabled": true, "enabled": true,
"url": "http://localhost:8001/mcp" "url": ""
} }
} }
} }
-20
View File
@@ -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"
]
}
}
}
+376
View File
@@ -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"
}
}
}
}
@@ -108,7 +108,7 @@ class _CoinCenterScreenState extends State<CoinCenterScreen> {
_packages = result.packages; _packages = result.packages;
}); });
} }
} catch (e, stackTrace) { } catch (e) {
_logger.warning( _logger.warning(
message: 'Failed to reload packages after purchase', message: 'Failed to reload packages after purchase',
extra: {'error': e.toString()}, extra: {'error': e.toString()},
@@ -445,74 +445,6 @@ class CoinPackageCard extends StatelessWidget {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.xl), borderRadius: BorderRadius.circular(AppRadius.xl),
), ),
child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpacing.xs),
Text(
l10n.settingsCoinAmount(amount),
style: Theme.of(context).textTheme.bodyMedium,
),
],
),
),
if (badge != null)
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.sm,
vertical: AppSpacing.xs,
),
decoration: BoxDecoration(
color: palette.historyGoldBg,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
badge!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: palette.historyGoldText,
fontWeight: FontWeight.w700,
),
),
),
],
),
const SizedBox(height: AppSpacing.lg),
if (!isAvailable && unavailableMessage != null)
Text(
unavailableMessage!,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: colors.error,
),
)
else
Row(
children: [
Text(
price,
style: Theme.of(
context,
).textTheme.headlineMedium?.copyWith(color: colors.primary),
),
const Spacer(),
FilledButton(
onPressed: isPurchasing || !isAvailable ? null : onPurchase,
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppRadius.full),
),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(AppSpacing.lg), padding: const EdgeInsets.all(AppSpacing.lg),
child: Column( child: Column(
+1
View File
@@ -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`). - Python commands must use `uv` (`uv run`, `uv add`).
- Backend startup/shutdown must use `./infra/scripts/app.sh`. - Backend startup/shutdown must use `./infra/scripts/app.sh`.
- Check runtime logs from `./logs/*.log`. - Check runtime logs from `./logs/*.log`.
- Docker Compose usage: see `infra/docker/README.md` for environment-based service activation.
## Code Quality Baseline ## Code Quality Baseline
+2 -2
View File
@@ -189,8 +189,8 @@ class SensitiveWordSettings(BaseModel):
class TestSettings(BaseModel): class TestSettings(BaseModel):
phone: str = "" email: str = ""
password: str = "" code: str = ""
class TaskiqSettings(BaseModel): class TaskiqSettings(BaseModel):
+4 -1
View File
@@ -78,7 +78,10 @@ class SupabaseAuthGateway(AuthServiceGateway):
async def create_email_session( async def create_email_session(
self, request: EmailSessionCreateRequest self, request: EmailSessionCreateRequest
) -> SessionResponse: ) -> SessionResponse:
if config.runtime.environment == "dev": 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( return await create_dev_email_session(
request=request, request=request,
client=self._get_client(), client=self._get_client(),
+2 -5
View File
@@ -434,19 +434,16 @@ class PaymentService:
return return
try: try:
import jwt as pyjwt
parts = signed_payload.split(".") parts = signed_payload.split(".")
if len(parts) < 2: if len(parts) < 2:
logger.warning("Malformed Apple notification signed_payload") logger.warning("Malformed Apple notification signed_payload")
return return
payload_bytes = parts[1] + "=" * (-len(parts[1]) % 4)
import base64 import base64
decoded = base64.urlsafe_b64decode(payload_bytes)
import json import json
payload_bytes = parts[1] + "=" * (-len(parts[1]) % 4)
decoded = base64.urlsafe_b64decode(payload_bytes)
notification_data: Any = json.loads(decoded) notification_data: Any = json.loads(decoded)
except Exception: except Exception:
logger.exception("Failed to decode Apple server notification payload") logger.exception("Failed to decode Apple server notification payload")
+3 -3
View File
@@ -500,9 +500,9 @@ class PointsService:
id=str(row.id), id=str(row.id),
direction=row.direction, direction=row.direction,
amount=row.amount, amount=row.amount,
balance_after=row.balance_after, balanceAfter=row.balance_after,
change_type=row.change_type, changeType=row.change_type,
created_at=row.created_at.isoformat(), createdAt=row.created_at.isoformat(),
) )
) )
@@ -1,5 +1,3 @@
from __future__ import annotations
""" """
Integration tests for Apple IAP payment verify flow. Integration tests for Apple IAP payment verify flow.
@@ -7,6 +5,8 @@ Prerequisite: backend must be running via `./infra/scripts/app.sh restart`.
These tests hit the live HTTP API against the test database. These tests hit the live HTTP API against the test database.
""" """
from __future__ import annotations
import pytest import pytest
-1
View File
@@ -6,7 +6,6 @@ import json
from v1.payments.apple_verifier import ( from v1.payments.apple_verifier import (
AppleJwsVerifier, AppleJwsVerifier,
VerificationError, VerificationError,
VerifiedTransaction,
) )
@@ -571,7 +571,6 @@ class TestHandleServerNotificationRefund:
verifier=_FakeVerifier(result=_make_verified_transaction()), verifier=_FakeVerifier(result=_make_verified_transaction()),
) )
import base64
import json import json
signed_txn = _make_fake_signed_transaction(transaction_id="2000000999999001") signed_txn = _make_fake_signed_transaction(transaction_id="2000000999999001")
@@ -10,7 +10,7 @@ from v1.auth.schemas import AuthUser, EmailSessionCreateRequest, SessionResponse
@pytest.mark.asyncio @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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
gateway = SupabaseAuthGateway() gateway = SupabaseAuthGateway()
@@ -28,7 +28,8 @@ async def test_create_email_session_uses_dev_bypass(
calls.update(kwargs) calls.update(kwargs)
return expected 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( monkeypatch.setattr(
gateway_module, "create_dev_email_session", _fake_create_dev_email_session 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 @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, monkeypatch: pytest.MonkeyPatch,
) -> None: ) -> None:
gateway = SupabaseAuthGateway() 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"), 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( monkeypatch.setattr(
gateway, gateway,
"_get_client", "_get_client",
@@ -81,3 +83,79 @@ async def test_create_email_session_uses_verify_otp_in_non_dev(
"token": "123456", "token": "123456",
} }
assert response.user.email == "test@example.com" 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"
@@ -17,7 +17,6 @@ class TestParseProfileSettings:
"preferences": { "preferences": {
"language": "en-US", "language": "en-US",
"timezone": "America/New_York", "timezone": "America/New_York",
"country": "US",
}, },
"privacy": {"profile_visibility": "private"}, "privacy": {"profile_visibility": "private"},
"notification": { "notification": {
@@ -32,7 +31,6 @@ class TestParseProfileSettings:
assert isinstance(result.preferences, PreferenceSettings) assert isinstance(result.preferences, PreferenceSettings)
assert result.preferences.language == "en-US" assert result.preferences.language == "en-US"
assert result.preferences.timezone == "America/New_York" assert result.preferences.timezone == "America/New_York"
assert result.preferences.country == "US"
assert isinstance(result.notification, NotificationSettings) assert isinstance(result.notification, NotificationSettings)
assert result.notification.allow_notifications is True assert result.notification.allow_notifications is True
assert result.notification.allow_vibration is False assert result.notification.allow_vibration is False
@@ -45,7 +43,6 @@ class TestParseProfileSettings:
assert isinstance(result.preferences, PreferenceSettings) assert isinstance(result.preferences, PreferenceSettings)
assert result.preferences.language == "zh-CN" assert result.preferences.language == "zh-CN"
assert result.preferences.timezone == "Asia/Shanghai" assert result.preferences.timezone == "Asia/Shanghai"
assert result.preferences.country == "US"
assert isinstance(result.notification, NotificationSettings) assert isinstance(result.notification, NotificationSettings)
assert result.notification.allow_notifications is True assert result.notification.allow_notifications is True
assert result.notification.allow_vibration is True assert result.notification.allow_vibration is True
@@ -60,7 +57,6 @@ class TestParseProfileSettings:
assert result.preferences.language == "en-US" assert result.preferences.language == "en-US"
assert result.preferences.timezone == "Asia/Shanghai" assert result.preferences.timezone == "Asia/Shanghai"
assert result.preferences.country == "US"
def test_parse_profile_settings_with_partial_notification(self) -> None: def test_parse_profile_settings_with_partial_notification(self) -> None:
raw = { raw = {
@@ -104,21 +100,11 @@ class TestParseProfileSettings:
with pytest.raises(ValueError, match="timezone must be a valid IANA timezone"): with pytest.raises(ValueError, match="timezone must be a valid IANA timezone"):
parse_profile_settings(raw) 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: def test_profile_settings_v1_model_dump(self) -> None:
settings = ProfileSettingsV1( settings = ProfileSettingsV1(
preferences=PreferenceSettings( preferences=PreferenceSettings(
language="en-US", language="en-US",
timezone="UTC", timezone="UTC",
country="US",
), ),
notification=NotificationSettings( notification=NotificationSettings(
allow_notifications=True, allow_notifications=True,
+126
View File
@@ -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 <service-name>`
### 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
```
+1 -1
View File
@@ -22,7 +22,7 @@ services:
"CMD", "CMD",
"sh", "sh",
"-c", "-c",
"if [ -n \"$$REDIS_PASSWORD\" ]; then redis-cli -a \"$$REDIS_PASSWORD\" ping; else redis-cli ping; fi", 'if [ -n "$$REDIS_PASSWORD" ]; then redis-cli -a "$$REDIS_PASSWORD" ping; else redis-cli ping; fi',
] ]
interval: 5s interval: 5s
timeout: 3s timeout: 3s
+7
View File
@@ -2,6 +2,7 @@ name: eryao-supabase
services: services:
db: db:
profiles: [dev]
container_name: eryao-supabase-db container_name: eryao-supabase-db
image: supabase/postgres:15.8.1.085 image: supabase/postgres:15.8.1.085
restart: unless-stopped restart: unless-stopped
@@ -33,6 +34,7 @@ services:
- 127.0.0.1:${ERYAO_DATABASE__PORT:-5432}:5432 - 127.0.0.1:${ERYAO_DATABASE__PORT:-5432}:5432
auth: auth:
profiles: [dev]
container_name: eryao-supabase-auth container_name: eryao-supabase-auth
image: supabase/gotrue:v2.186.0 image: supabase/gotrue:v2.186.0
restart: unless-stopped restart: unless-stopped
@@ -73,6 +75,7 @@ services:
GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/v1/verify
rest: rest:
profiles: [dev]
container_name: eryao-supabase-rest container_name: eryao-supabase-rest
image: postgrest/postgrest:v14.8 image: postgrest/postgrest:v14.8
restart: unless-stopped restart: unless-stopped
@@ -91,6 +94,7 @@ services:
PGRST_APP_SETTINGS_JWT_EXP: 3600 PGRST_APP_SETTINGS_JWT_EXP: 3600
storage: storage:
profiles: [dev]
container_name: eryao-supabase-storage container_name: eryao-supabase-storage
image: supabase/storage-api:v1.48.26 image: supabase/storage-api:v1.48.26
restart: unless-stopped restart: unless-stopped
@@ -124,6 +128,7 @@ services:
- storage-data:/var/lib/storage - storage-data:/var/lib/storage
meta: meta:
profiles: [dev]
container_name: eryao-supabase-meta container_name: eryao-supabase-meta
image: supabase/postgres-meta:v0.96.3 image: supabase/postgres-meta:v0.96.3
restart: unless-stopped restart: unless-stopped
@@ -145,6 +150,7 @@ services:
retries: 1 retries: 1
studio: studio:
profiles: [dev]
container_name: eryao-supabase-studio container_name: eryao-supabase-studio
image: supabase/studio:2026.04.08-sha-205cbe7 image: supabase/studio:2026.04.08-sha-205cbe7
restart: unless-stopped restart: unless-stopped
@@ -180,6 +186,7 @@ services:
- studio-functions:/var/lib/functions - studio-functions:/var/lib/functions
kong: kong:
profiles: [dev]
container_name: eryao-supabase-kong container_name: eryao-supabase-kong
image: kong/kong:3.9.1 image: kong/kong:3.9.1
restart: unless-stopped restart: unless-stopped
+1
View File
@@ -20,6 +20,7 @@ load_env_if_exists() {
# shellcheck disable=SC1090 # shellcheck disable=SC1090
. "$ENV_LOADER" . "$ENV_LOADER"
load_env_file "$ENV_FILE" load_env_file "$ENV_FILE"
load_env_file "$ROOT_DIR/.env.local"
} }
is_port_in_use() { is_port_in_use() {
+1
View File
@@ -26,6 +26,7 @@ fi
# shellcheck disable=SC1090 # shellcheck disable=SC1090
. "$ENV_LOADER" . "$ENV_LOADER"
load_env_file "$ENV_FILE" load_env_file "$ENV_FILE"
load_env_file "$ROOT_DIR/.env.local"
cd "$ROOT_DIR" cd "$ROOT_DIR"