feat: 档2 接真后端 — mac app 真注册/登录/拉节点

dev 本机置备(新增 dev/):
- docker-compose.yml: MySQL :13306 + Redis :16379(避让本机已占的 3306/6379)
- run-local.sh 一键: 起容器→openssl 生成密钥(dev/.local gitignore)→migrate→
  seed→启动 server :18080(避让 8080);LogMailer 把验证码打日志
- seed.sql(1 provider + 1 HK 节点)、run-local.md 手册、.gitignore

后端两处修复(本地无 gRPC 场景,注释意图与代码不符的 bug):
- main.go: 无 gRPC 时真正构造 Hub-only nodeSvc(原只建 hub 没赋值),/v1/nodes 才能挂
- main.go: /me 改 Route 子路由根 Get,修 Get("/me")+Route("/me") 冲突致 404

客户端接真后端:
- token_store: MacOsOptions(useDataProtectionKeyChain:false) 修 keychain -34018
- auth_screen: 删 dev 旁路(test 账户内存登录),所有登录走真 API

验收: 后端 curl 端到端全通(验证码→注册→登录→/nodes→/me);
server go build/vet/test 通过, client analyze 0 + test 84

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-06-16 13:27:58 +08:00
parent fe16f89bc3
commit 6979411c1d
9 changed files with 175 additions and 31 deletions
+5 -1
View File
@@ -3,7 +3,11 @@ import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class TokenStore {
const TokenStore({FlutterSecureStorage? storage})
: _storage = storage ?? const FlutterSecureStorage();
: _storage = storage ?? const FlutterSecureStorage(
// macOS 用文件式 keychain,避免未签名 app 缺 keychain-access-groups
// entitlement 时 write 报 -34018。
mOptions: MacOsOptions(useDataProtectionKeyChain: false),
);
final FlutterSecureStorage _storage;
-24
View File
@@ -1,5 +1,4 @@
// auth_screen.dart — 登录 / 注册页(邮箱 + 验证码流程)
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -41,22 +40,6 @@ class _AuthScreenState extends ConsumerState<AuthScreen> {
late final AuthApi _api = AuthApi(baseUrl: _kApiUrl);
// ── Dev-only 测试账户旁路(仅 debug build)─────────────────────
// 后端 /v1/auth 未就绪时,用此账户直接进入主界面浏览 UI。
// 登录后节点列表回退 kDemoNodes,连接键走 mock 动画。
static const _devEmail = 'test@pangolin.dev';
static const _devPassword = 'test1234';
@override
void initState() {
super.initState();
// debug 下预填测试账户,方便直接点登录。
if (kDebugMode) {
_email.text = _devEmail;
_pw.text = _devPassword;
}
}
bool get _emailValid => RegExp(r'\S+@\S+\.\S+').hasMatch(_email.text);
@override
@@ -97,13 +80,6 @@ class _AuthScreenState extends ConsumerState<AuthScreen> {
Future<void> _doLogin() async {
setState(() { _loading = true; _errorZh = null; });
// Dev 旁路:debug build 下用测试账户跳过后端直接登录。
// 用 devLogin 只设内存态,不写 keychain(规避 -34018 entitlement 问题)。
if (kDebugMode && _email.text.trim() == _devEmail && _pw.text == _devPassword) {
ref.read(authProvider.notifier).devLogin('dev-access-token');
if (mounted) widget.onDone();
return;
}
try {
final tokens = await _api.login(
email: _email.text.trim(),
+2
View File
@@ -0,0 +1,2 @@
# 本地 dev 密钥(JWT 私钥/公钥、webhook secret、derive key),绝不进 git。
.local/
+27
View File
@@ -0,0 +1,27 @@
# 本地开发用 MySQL + Redis(与生产隔离)。
# 用法:docker compose -f dev/docker-compose.yml up -d
services:
mysql:
image: mysql:8.0
container_name: pangolin-dev-mysql
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: pangolin
ports:
- "13306:3306" # 避让本机已占用的 3306
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-proot"]
interval: 3s
timeout: 3s
retries: 20
volumes:
- pangolin-dev-mysql:/var/lib/mysql
redis:
image: redis:7-alpine
container_name: pangolin-dev-redis
ports:
- "16379:6379" # 避让本机已占用的 6379
volumes:
pangolin-dev-mysql:
+61
View File
@@ -0,0 +1,61 @@
# 本地开发:一键起后端
让后端在本机真跑起来(auth 注册/登录 + 节点列表),供 mac app 接真后端调试。
## 前置
- Docker(起 MySQL + Redis
- Go`/opt/homebrew/bin` 在 PATH,脚本已内置)
- openssl(生成 JWT 密钥)
## 一键启动
```bash
cd <项目根> # pangolin/
bash dev/run-local.sh
```
脚本做的事:
1. `docker compose``pangolin-dev-mysql`:13306+ `pangolin-dev-redis`(:16379,避让本机已占用的 3306/6379),等 MySQL 就绪
2. 首次生成密钥到 `dev/.local/`gitignore):RS256 JWT 私钥/公钥、`webhook_secret``derive_key`
3. 组装 env`DB_DSN`/`WEBHOOK_SECRET`/`NODE_DERIVE_KEY`/`JWT_*`),不配 SMTP → 验证码打日志
4. `go run ./cmd/migrate up`(建表 + plans seed),`dev/seed.sql` 插一个 HK 测试节点
5. 前台启动 `go run ./cmd/server`:18080)—— **注册验证码会打印在终端日志**
停止:`Ctrl+C`(停 server);`docker compose -f dev/docker-compose.yml down` 停容器(加 `-v` 清库)。
## 端到端验证(curl
```bash
# 1) 发验证码(看 server 终端日志读 6 位码)
curl -XPOST localhost:18080/v1/auth/code -H 'Content-Type: application/json' \
-d '{"email":"me@x.com"}'
# 2) 注册(填上一步日志里的码)
curl -XPOST localhost:18080/v1/auth/register -H 'Content-Type: application/json' \
-d '{"email":"me@x.com","code":"<码>","password":"pass1234"}'
# → {"access_token":"...","refresh_token":"..."}
# 3) 拉节点列表(带 access token
curl localhost:18080/v1/nodes -H 'Authorization: Bearer <access>'
# → 含 HK 测试节点
# 4) 当前用户
curl localhost:18080/v1/me -H 'Authorization: Bearer <access>'
```
## mac app 接它
```bash
cd client
# 客户端默认连 :8080;本机 8080 被占用,dev 后端在 18080,故显式指定:
flutter run -d macos --dart-define=PANGOLIN_API_URL=http://localhost:18080
```
注册页输真邮箱 → 发验证码(后端日志读码)→ 填码+密码 → 进主界面 → 节点页显示真节点(HK·测试)。
## 说明
- **密钥纯本地玩具**`dev/.local/` 不进 git;换机重跑脚本会重新生成。
- **节点 endpoint 占位**`127.0.0.1:11443`):档 2 只验证列表 + connect 配置渲染,**不真连**。真连接出网见档 3。
- 不连 EC2 生产 MySQL(本地隔离,避免污染线上)。
+51
View File
@@ -0,0 +1,51 @@
#!/usr/bin/env bash
# run-local.sh — 一键起本地后端(DB/Redis + 密钥 + 迁移 + seed + server)。
# 从项目根运行: bash dev/run-local.sh
# 验证码:未配 SMTP,注册验证码会打到本终端的 server 日志里。
# 密钥:首次生成到 dev/.local/gitignore);标量密钥用固定/随机 dev 值,纯本地玩具。
set -euo pipefail
export PATH="/opt/homebrew/bin:$PATH"
ROOT="$PWD"
LOCAL="$ROOT/dev/.local"
mkdir -p "$LOCAL"
# ── 1. 起 MySQL + Redis ──────────────────────────────────────────────
echo "[run-local] 起 MySQL + Redis ..."
docker compose -f dev/docker-compose.yml up -d
echo "[run-local] 等待 MySQL 就绪 ..."
until docker exec pangolin-dev-mysql mysqladmin ping -proot --silent >/dev/null 2>&1; do
sleep 1
done
echo "[run-local] MySQL 就绪。"
# ── 2. 生成密钥(仅当缺失)────────────────────────────────────────────
if [ ! -f "$LOCAL/jwt_private.pem" ]; then
echo "[run-local] 生成 RS256 JWT 密钥对 ..."
openssl genrsa -out "$LOCAL/jwt_private.pem" 2048
openssl rsa -in "$LOCAL/jwt_private.pem" -pubout -out "$LOCAL/jwt_public.pem"
fi
[ -f "$LOCAL/webhook_secret" ] || openssl rand -hex 32 > "$LOCAL/webhook_secret"
[ -f "$LOCAL/derive_key" ] || openssl rand -hex 32 > "$LOCAL/derive_key"
# ── 3. 组装环境变量 ──────────────────────────────────────────────────
read -r WEBHOOK_SECRET < "$LOCAL/webhook_secret"
read -r NODE_DERIVE_KEY < "$LOCAL/derive_key"
export WEBHOOK_SECRET NODE_DERIVE_KEY
export DB_DSN="root:root@tcp(127.0.0.1:13306)/pangolin?parseTime=true&loc=UTC&multiStatements=true"
export REDIS_ADDR="127.0.0.1:16379"
export JWT_PRIVATE_KEY_PATH="$LOCAL/jwt_private.pem"
export JWT_KEY_ID="dev-key-1"
export JWT_PUBLIC_KEYS="dev-key-1:$LOCAL/jwt_public.pem"
export ADDR=":18080" # 避让本机已占用的 8080
# 不设 SMTP_* → LogMailer(验证码打日志);不设 GRPC_* → 不启 agent server。
# ── 4. 迁移 + seed ───────────────────────────────────────────────────
echo "[run-local] 数据库迁移 ..."
( cd server && go run ./cmd/migrate up )
echo "[run-local] seed 节点 ..."
docker exec -i pangolin-dev-mysql mysql -uroot -proot pangolin < dev/seed.sql
# ── 5. 启动后端(前台,验证码看本终端日志)──────────────────────────
echo "[run-local] 启动后端 :18080 —— 注册验证码会打印在下方日志。Ctrl+C 停止。"
( cd server && go run ./cmd/server )
+22
View File
@@ -0,0 +1,22 @@
-- 本地开发 seed1 provider + 1 个 status='up' 节点,供 GET /v1/nodes 返回。
-- 幂等:固定 uuid,重跑先删后插。endpoint/reality_* 为占位(档 2 不真连,档 3 再指真实数据面)。
DELETE FROM nodes WHERE uuid = '11111111-1111-1111-1111-111111111111';
DELETE FROM providers WHERE name = 'dev-local';
INSERT INTO providers (name, api_kind, regions, pool, enabled)
VALUES ('dev-local', 'manual', '["HK"]', 'consumable', TRUE);
INSERT INTO nodes
(uuid, region, name_zh, name_en, role, tier, endpoint, hy2_port,
reality_pbk, reality_prk, reality_short_id, reality_sni, provider_id, status, weight)
VALUES (
'11111111-1111-1111-1111-111111111111',
'HK', '香港 · 测试', 'Hong Kong (dev)',
'exit', 'free',
'127.0.0.1:11443', 443,
'devPlaceholderRealityPublicKey00000000000000', '', '6eb28f1a',
'www.apple.com',
(SELECT id FROM providers WHERE name = 'dev-local' LIMIT 1),
'up', 100
);
+7 -6
View File
@@ -102,11 +102,12 @@ func main() {
"GRPC_CERT_PATH_set", grpcCertPath != "",
"GRPC_KEY_PATH_set", grpcKeyPath != "")
}
// Even without gRPC, build a Hub-only nodeSvc for local/test use.
// nil CA means Enroll will panic if called — acceptable when gRPC is off.
hub := nodes.NewHub(rdb)
hub.Start(context.Background())
log.Printf("nodes.Service: gRPC not configured; Hub active for command queueing")
// Even without gRPC, build a Hub-only nodeSvc so the HTTP /v1/nodes
// routes (ListNodes/Connect) work locally. nil CA/tokens means
// agent Enroll would panic if called — acceptable when gRPC is off.
nodeSvc = nodes.NewService(nil, nil, rdb, nodeStore)
nodeSvc.Hub().Start(context.Background())
log.Printf("nodes.Service: gRPC not configured; Hub active for command queueing (HTTP /v1/nodes enabled)")
}
}
@@ -237,8 +238,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
v1.Group(func(protected chi.Router) {
protected.Use(auth.RequireAuth(tm))
protected.Get("/me", accountAPI.GetMe)
protected.Route("/me", func(me chi.Router) {
me.Get("/", accountAPI.GetMe) // 子路由根,避免与 Route("/me") 冲突致 404
devicesHandler.RegisterRoutes(me)
})
protected.Post("/redeem", redeemHandler.ServeHTTP)
Executable
BIN
View File
Binary file not shown.