Commit Graph

31 Commits

Author SHA1 Message Date
wangjia 3159dd75c1 refactor(routing): 暴露 system_locked_domains + 服务端 follow-up 清理(FK/Validate/dedup/exclude单源/共用Store/Builtin保留) 2026-07-29 06:38:52 +08:00
wangjia 04b02da78d feat(routing): routing_profiles 表 + Profile 类型 + store(双方言) 2026-07-28 07:24:01 +08:00
wangjia a62a2b1797 fix(server/pay): promo 限购 settle 侧幂等复查 + 000027 部分唯一索引(TOCTOU)
ci-pangolin / Lint — shellcheck (pull_request) Successful in 11s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 25s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 19s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 41s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 20s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 4s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 5s
ci-pangolin / Go — build + test (pull_request) Failing after 14s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 11s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 4m44s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 20s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 11m55s
CreateOrder 下单时的 HasPaidPurchase 只是裸 SELECT 无锁,并发/多挂起单可绕过
promo SKU「每账号限购一次」。两层修:
① webhook.go settle 在锁行、开通前对 item.Promo 的 SKU 复查一次(排除本单),
   命中说明另一笔同 user+SKU 订单已抢先 settle,跳过发放(不二次 +N 天)、
   仍 ack(否则 pay 无限重投)。新增 store.HasPaidPurchaseExcludingTx /
   MarkDuplicatePromoTx——重复单标记 canceled 而非 paid,避免自撞下面的
   唯一索引、也避免整笔 500 触发死循环重投。
② migration 000027(sqlite):部分唯一索引 ux_pay_promo_paid ON
   pay_purchases(user_id, sku) WHERE status='paid' AND sku='pro_month_promo',
   兜底防止任何路径把同一用户的 promo 单二次写成 paid。mysql 8 不支持部分
   索引,000027 mysql 侧是 no-op 占位(仅对齐编号),该场景 mysql 只靠①的
   应用层复查兜底——两库防线强度不同,已在迁移文件与代码注释中记录。

副作用:新增迁移把 sqlite 迁移顶点从 26 推到 27,同步更新
internal/store/sqlite_migrate_test.go 的版本断言,以及
internal/store/codes_lib_migrate_test.go 手动 Steps(-1) 序列(补一步跳过
000027,才能精确落在 000022 边界,这条测试是硬编码步数的)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-13 15:53:56 +08:00
wangjia 21a30aa4c8 feat(server/migrate): 000026 notices 表 + users.notices_read_at 已读水位
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-13 13:32:23 +08:00
wangjia 07c339c18e feat(server/devices): users.max_devices_override 单用户设备上限覆盖(NULL走套餐默认)+ 迁移000025
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 16s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 39s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 19s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 36s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 5m8s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 13m37s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
2026-07-13 10:15:07 +08:00
wangjia 52b5e90d92 fix(server/migrate): 000024 sqlite 重建 subscriptions 后补回 idx_subs_user_exp 索引 + 回归断言
000024 迁移重建 subscriptions 表(source CHECK 扩容)时未重新创建
idx_subs_user_exp(user_id, expires_at)索引,导致 SQLite(生产 pangolin1
实际驱动)上订阅按 user_id/expires_at 查询退化为全表扫描。up/down 均补回
CREATE INDEX,并在 TestSQLiteMigrateUpDown 里加断言防止再次静默丢失。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-13 07:08:26 +08:00
wangjia 9b858c5224 feat(server/migrate): 000024 邀请奖励表 + subscriptions.source 扩 invite/task 2026-07-13 06:59:52 +08:00
wangjia 1198ecc556 fix(migrations): 恢复线性迁移编号,与生产已应用的对齐
集成分支曾把已上线的 000020_ad_bonus_minutes(#21)/000021_devices_user_scoped_uuid(#27)
两个迁移错误覆盖成 codes_lib_legacy_rename/pay_purchases,与生产(schema_migrations=21)
的实际血统冲突,导致 golang-migrate 在生产上找不到 version 21 文件而报错。
恢复为:020 ad_bonus / 021 devices / 022 codes_lib_legacy_rename / 023 pay_purchases。
生产库副本完整试跑通过:version→23,pay_purchases 建好,subscriptions CHECK 加 'pay',
codes 库表就位,3 用户/3 订阅数据零丢失。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-11 14:25:09 +08:00
wangjia 85140edf1c feat(server): 迁移 000021 pay_purchases 台账 + subscriptions.source 扩 pay
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 20:46:13 +08:00
wangjia 7a4c9b97d0 fix(server): migrate down 兼容 codes 库自建表(先 DROP 再改名回)+ 真实接线回环测试(#codes-lib)
Reviewer 复现:cmd/migrate up 接线 ApplyCodesLibMigrations 后,codes 库自建的
codes/codes_batches/codes_audit_log/codes_schema_migrations 四张表不受
golang-migrate 追踪;000020 的 down 脚本做 legacy_codes -> codes 改名回时,
撞上库自建的同名 codes 表,报 "table already exists" 硬失败。

000020 的 sqlite/mysql down 脚本改名前先 DROP TABLE IF EXISTS 掉库自建四张表,
并注明:down = 完整回滚「rename + 库建表」组合,库表数据随 down 销毁属预期
(权威数据仍在 legacy_* 表,回填是后续任务,回填落地后 down 语义需重新审视)。

新增 TestCodesLibMigrateRoundTrip 走真实接线路径(MigrateUp ->
ApplyCodesLibMigrations -> 单步撤销 000020 断言 legacy 复原/库表清空 ->
撤销回去 -> 再跑 ApplyCodesLibMigrations 验幂等 -> 全量 store.MigrateDown 即
cmd/migrate down 的真实调用路径,确认不再硬失败)。先用 git stash 掉修复验证
测试能精确复现 reviewer 报的 "table already exists" 报错(RED),再恢复修复
转绿(GREEN)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013nMthbVEmQquxBRKb9Fj8u
2026-07-10 14:06:01 +08:00
wangjia 5a9b5f1d8e feat(server): cmd/migrate up 接入 codes 库自建迁移(ApplyCodesLibMigrations)(#codes-lib)
顺带修 000020 sqlite 索引名冲突:ALTER TABLE RENAME 不会带着重命名索引,
legacy_codes 上遗留的 idx_codes_status 与 codes 库自己迁移里同名索引撞名,
一并改成 idx_legacy_codes_status(up/down 对称)。
2026-07-10 13:55:14 +08:00
wangjia 665749a90b feat(server): 迁移000020 旧码表更名封存 legacy_*(为 codes 库表让位)(#codes-lib)
RENAME codes/code_batches -> legacy_codes/legacy_code_batches(双方言),
为共享库 github.com/wangjia/codes 的同名 codes 表让位。sqlite_migrate_test.go
同步到 version=20 + legacy_* 表清单。

顺带 skip TestSQLite_CodesRedeemFlow(internal/store/sqlite_stores_test.go):
该测试直调 internal/codes.Store 的低层方法(CreateBatch/CreateCode/
FindCodeByHashForUpdate/MarkRedeemed),现在指向被更名的旧表。计划本身在
Task 5(Store 换芯 + Redeem 走库)显式删除此测试、由 Service 级 sqlite 用例
接管等价覆盖 —— 这里先 skip 而非改写/删除,保持 go test ./... 全绿,避免
在 Task 1 提前动到 Task 4/5 范围内的 internal/codes/store.go。
2026-07-10 13:46:17 +08:00
wangjia 27dc59ed63 feat(server): pro 套餐设备上限 5 → 3
- seed(sqlite/mysql 000007)+ 001_init:pro max_devices 5→3(全新库直接对)
- 新增迁移 000019(sqlite/mysql,up/down):UPDATE plans SET max_devices=3
  WHERE code='pro' —— 已迁移的线上库靠它更新;down 回退 5
- 000002/001 注释同步(free 1 / pro 3 / team 10)
- 测试:sqlite_migrate 期望版本 18→19;devices 集成测试 pro 断言 5→3
- free(1)/team(10)不变

注:设备上限当前仍未强制执行(登录注册 MaxDevices=0),仅数据/展示口径;
真正启用+超限 UX 见 todo #16。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 18:07:03 +08:00
wangjia e333f59cb3 feat(stats): #10 第②层 —— 统计页按设备过滤(per-device 本地日曲线)
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 19s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Failing after 14s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 6s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m8s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s
镜像账户级时区曲线到「按设备」:
- migration 000018 `usage_device_hourly`(每设备 UTC 小时桶,稀疏)。
- ReportUsage deviceID>0 时加 AccumulateDeviceHourly;NodeStore 接口+impl+mock。
- usage.Store.DeviceHourlyRange(JOIN devices 校验归属+解析 uuid→id);UsageCurve
  增 deviceUUID 形参:空=账户级,非空=该设备本地日曲线(分桶逻辑复用)。
- /v1/usage?device=<uuid>;客户端 account_api.usage(device)、usageProvider key 改
  记录 (days,device)、stats_page 接 statsDeviceProvider → 选设备即重取该设备曲线。
测试:per-device 曲线隔离+归属校验(A的设备不进B)、UsageCurve(deviceUUID)、
migration v18、客户端 widget(选设备→/v1/usage 带 device=)。全量 go/flutter 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 08:04:33 +08:00
wangjia f2499a64e1 fix(usage): 统计按客户端本地日聚合 —— 修「29号却显示28号数据」时区 bug
ci-pangolin / Lint — shellcheck (push) Successful in 7s
ci-pangolin / OpenAPI Sync Check (push) Successful in 16s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 4s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m8s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
根因:服务端用量全按 UTC 预切天,UTC+8 用户在中国 0-8 点时 UTC 还是前一天,
「今天」就显示前一天数据。

改法(用户拍板:小时桶+查询带时区偏移):
- migration 000017 `usage_hourly`(UTC 纪元小时整数桶,稀疏存储,空小时不落行)。
- ReportUsage 同时累加 usage_hourly(按 windowEnd 的 UTC 小时);usage_daily 保留作配额/今日。
- `UsageCurve(days, offsetMin)`:读 UTC 小时桶,按客户端时区偏移**重新聚合成本地日**曲线。
- `/v1/usage?tz_offset=分钟`;客户端 `usage()` 带 `DateTime.now().timeZoneOffset.inMinutes`。
- 客户端早已「取曲线最后一点当今日」,故只改服务端聚合 + 带偏移即可。

测试:store 时区分桶证明(UTC 18:00 在 UTC vs UTC+8 落不同本地日,锁住 bug 修复)+
migration v17 + usage_hourly 表;集成测试改喂 hourly;全量 go/flutter test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 07:02:04 +08:00
wangjia bcc114088c feat(devices): P3 强制退出 + 清除增强(per-device 凭证吊销)
ci-pangolin / Lint — shellcheck (push) Successful in 9s
ci-pangolin / OpenAPI Sync Check (push) Successful in 17s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 5s
ci-pangolin / Flutter — analyze + test (push) Successful in 26s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 12s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 15s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m4s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s
后端:新端点 POST /v1/me/devices/{uuid}/logout(ForceLogout:吊销该设备会话+
丢 Redis JTI,设备留列表)。DeleteDevice 增强:先吊销会话再删设备(FK ON DELETE
CASCADE 清理会话行)+ 按 dp_uuid 吊销数据面凭证。CredentialRevoker 接口改
per-device RevokeDevice(dpUUID),由 nodes.Service 实现(查 connect_credentials
持有节点→推 CommandTypeRevoke + 删凭证行),main 注入替 NoopRevoker;devices 注入
SessionPort/JTIRevoker。修 SQLite 跨连接死锁(会话吊销移到 delete tx 之前)。
migration 000016 sessions FK 加 ON DELETE CASCADE。
客户端:account_api.forceLogout + devicesProvider.forceLogout(UI 留 P6)。
测试:ForceLogout(吊销会话+JTI+设备保留+403/404)+ DeleteDevice(级联+按 dp_uuid
吊销);NoopRevoker 改 dp_uuid;全量 server/flutter 测试绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 06:01:41 +08:00
wangjia 2f298f0a0a feat(devices): P2 sessions 表 + 在线/最后登录/客户端版本
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 18s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 6s
ci-pangolin / Flutter — analyze + test (push) Successful in 24s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 4s
ci-pangolin / Go — build + test (push) Successful in 11s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m13s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
migration 000016(mysql+sqlite,含 down):新增 sessions 表(绑 device+refresh JTI)
+ devices 加 client_version/totp_trusted_until。devices 唯一键改 + platform CHECK
加 linux(需 SQLite 表重建)拆出后续迁移,降风险。

后端:新 internal/sessions Store(Create/Rotate/Revoke/RevokeByDevice/
LastLoginByDevice);TokenManager 外露 refresh JTI(IssueWithJTI/RefreshWithJTI/
ParseRefreshJTI);auth.Service 注入 SessionStore——登录建会话、刷新轮换、登出吊销;
DeviceRegistrar 返回 deviceID;ReportUsage 心跳 touch devices.last_seen(在线判定);
devices.ListDevices 经 LastLoginSource 注入返回 online(last_seen<3min)/client_version/
last_login;RegisterIfAbsent 存 client_version。
客户端:Device model 加 online/clientVersion/lastLogin(fromJson 自动解析)。
测试:sessions store 3 例 + ListDevices 在线/最后登录 + device model 2 例 +
migration v16;全量 go test/flutter test 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:50:24 +08:00
wangjia 636a3bbf2f feat(server/stats): stats-overhaul Phase2 — 每设备归因 + GB 综合配额
服务端记账从「账户」细到「每设备」(每设备独立 dp_uuid),配额单位分钟→GB
按账户综合卡控。000015_per_device_usage 迁移(mysql+sqlite 双份):devices.dp_uuid
+ usage_device_daily 表 + plans.daily_mb。handler_grpc 按 dp_uuid 回映射
(user_id,device_id) 双写账户+每设备;usage 服务/handler 暴露 /v1/usage(/devices)。
含 sqlite_per_device / usage handler 测试。

注:此迁移 prod 已 migrate up 运行、部署二进制已内嵌;本次补提交使 git 与线上一致。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 18:12:00 +08:00
wangjia 3d87bffbe9 feat(server): P2 后端补字段 — 套餐价格 + 节点 host(#6 6B)
- 迁移 000014_plan_pricing(mysql+sqlite 双套):plans 加 price_cents/currency/
  period,seed pro=2500 team=9900(¥25/¥99);ListPlans 返回价格字段。
- /v1/nodes 暴露 host+port(取自 node.Endpoint,无 schema 变更),供客户端实测
  真实 per-client 延迟(服务端无法代知)。
- 修复 account.go 的 GetMe/weeklyGB 残留 MySQL 专属 UTC_DATE()/INTERVAL
  (#1 漏网):改 Go 端算日期传 ?,否则 SQLite 节点今日/周流量恒 0。
- sqlite 迁移 up/down 测试期望版本 13→14。

go build/vet 干净;sqlite 迁移 up/down + httpapi 测试通过。节点 tag 暂不加
(无真实数据,不造空字段;客户端将不显示伪造 tag)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 23:42:23 +08:00
wangjia f3471ae139 feat(server/db): 数据层多库支持(1/4)— 连接分派 + 双方言迁移管线
- config 增 DB_DRIVER(mysql 默认 | sqlite);DSN 对 sqlite 为文件路径
- db.OpenDriver 按驱动分派:sqlite 用 modernc(纯 Go 免 CGO)+ WAL/
  busy_timeout/foreign_keys/_txlock=immediate;mysql 路径不变
- store.Open 分派;mysql 保留 UTC/collation 断言,sqlite 跳过
- 迁移拆 migrations/{mysql,sqlite}/ 双套,embed 双 FS,migrate 按驱动选源
  与 golang-migrate 驱动;修复 m.Close() 误关调用方 *sql.DB 的坑
- cmd/migrate 串入 DB_DRIVER;集成测试 MigrateUp 签名更新
- 新增 SQLite 时间往返 smoke 测试与端到端迁移测试(免 docker)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:01:03 +08:00
wangjia 009dbb8d07 feat(server): web 用户中心后端(1/3) — GetMe 补字段 + logout + redeem 路径别名 + 用户表 totp/sub_token
为 web 用户中心接通真后端做准备(保持 snake_case 不破 app):
- migration 000013:users 加 sub_token / totp_secret_enc / totp_enabled。
- GetMe 扩展:补 devices_used/devices_max/quota_today_min/data_today_gb/
  weekly_gb/totp_enabled/expires_at(聚合 plans+usage_daily+devices+totp 列),
  保留原 expire_at 等字段不破 app。
- POST /v1/auth/logout:X-Refresh-Token 头 → RevokeRefresh,幂等 204。
- /v1/me/redeem 别名(web 用),保留 /v1/redeem(app 用)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 08:35:09 +08:00
wangjia cadd527680 feat(backend): 挂载 /v1 API + 实现 nodes/connect 端到端
- 新增 internal/dpcred 包,统一 DeriveHy2Password + DefaultFlow
  agentd 与 HTTP connect handler 共享同一实现
- 新增迁移 000011:nodes 表拆分 reality_prk 私钥 / reality_pbk 公钥
  reality_short_id;修正 handler_grpc.go 使用私钥字段
- 新增迁移 000012:connect_credentials 持久化凭证
  实现 CredentialsForNode 修复 agent 重连 resync 原先返回空的桩
- 扩展 NodeStore 接口:ListUp / EntitlementForUser /
  PersistCredential / DeleteCredential;同步 grpc_test.go mock
- 新增 httpapi/nodes.go:GET /nodes、POST /nodes/id/connect
  Hub.Push + PersistCredential + 渲染完整 sing-box client 配置 JSON
  POST /nodes/id/disconnect
- 新增 httpapi/account.go:GET /me、GET /plans、GET /notices
- 新增 httpapi/clientconfig.go:BuildClientConfig 服务端渲染
- 重写 cmd/server/main.go:手写 chi public/protected 分组
  nodes.Service/Hub 在 main 构造并共享;SMTPMailer/LogMailer

go build ./... && go vet ./... && go test ./... 全部通过

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-15 23:09:56 +08:00
wangjia 88757b2ac4 feat(detect): 判定引擎 signals+rules+streak (tsk_OYEiDCzM9_0Y)
新建 server/internal/scheduler/detect/ 包,实现任务 15D:

## 核心模块

**lifecycle.go**
- 定义 LifecycleService Go interface(ListNodes / TransitionStatus / SetWeight /
  BumpVersion / GetLoad / GetLoadHistory),供 #5 真实实现,内含乐观锁语义(0 行
  受影响即本周期放弃,幂等可重入)
- 提供 MockLifecycle 内存 stub(SetConflict / Events 等测试辅助方法)

**signals.go**
- computeSignals:读 SnapshotsByNode,归一化为 NodeSignal
  - domesticFailISPs:按 ISP 聚合(stripPrefix "3rd-" 合并 15B/15C 同 ISP 视角),
    L3 失败权重最高(L3 挂即记该 ISP 失败,即使 L1/L2 通),无数据 vantage 不计分母
  - overseasOK:境外对照点全部 L1 通为真
  - trafficDropPct:15min 前后半段在线数降幅百分比
- ProbeSnapshotter interface(解耦 probe.Store,便于 mock)

**rules.go**
- DetectConfig:全量阈值常量(DomesticFailNumerator/Denominator=2/3、
  SuspectStreakMin=2、TrafficDropThreshold=80%、TrafficBaselineMin=20、
  ConfirmedStreakMin=6、RecoverStreakMin=2、SuspectWeight=10)
- isSuspectTriggered:整数算术避免浮点误差(fail×den >= total×num)
- isFault / effectiveSuspectStreakMin / isTrafficWarning 规则助手

**streak.go**
- StreakStore:Redis hash detect:streak:{node}(fail_streak / suspect_streak /
  recover_streak),TTL 2h(宽于 6×5min=30min),进程重启后自动恢复

**engine.go**
- Engine.Tick(ctx):列举 up/blocked_suspect 节点,按节点依序运行五条规则
- Rule 1 suspect:≥2/3 ISP 失败 + 境外正常 → 连续 2 周期 → up→blocked_suspect,
  weight→10,写 probe:freq:{node}=60(1min 提频标记),BumpVersion
- Rule 2 流量预警:trafficDrop≥80% + 基线≥20 → suspect 门槛放宽至 1 周期
- Rule 3 confirmed:blocked_suspect 连续 6 周期 → blocked_confirmed → down(跳过
  draining),入队 detect:replace:queue(JSON: nodeId + replacementUuid)
- Rule 4 recover:suspect 期间境内连续 2 周期恢复 → up(weight 交 15E warmup)
- Rule 5 fault:境内+境外同时失败 → 不迁移,调 Notifier.NotifyFault(log stub)
- Notifier interface + LogNotifier stub(15G 就绪前输出 slog.Warn)

**engine_test.go**(19 个测试,全部通过)
- 表驱动覆盖:恰好 2/3 失败、1/3 失败不触发、9min vs 10min streak、
  流量预警 1 周期即 suspect、suspect 第 6 周期 confirmed、境内外同挂 fault 不迁移、
  恢复 2 周期回 up、3rd- 前缀合并、L3 覆盖 L1/L2
- 乐观锁冲突:SetConflict → 放弃本周期 → 无副作用 → 解冲突后正常重试
- 进程重启:同一 miniredis,新 Engine 读取已存在 streak 继续计数不误清零
- 产出验证:replace queue JSON 结构、probe:freq 标记、weight 变更、事件 detail 字段

**migrations/000010_node_blocked_statuses.{up,down}.sql**
- 扩展 nodes.status ENUM 加入 blocked_suspect / blocked_confirmed

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 20:15:13 +08:00
wangjia 20a1f8e2af merge: 管理端最小后台 + TOTP 双因素 [tsk_SCMtcGF4F434]
以 main 现有 cmd/server 设计为准(保留 probe + /v1 路由),叠加分支新增:internal/admin/ 自包含后台(session/2FA/IP 白名单/审计/模板)、internal/totp/ + cmd/adminctl/(TOTP + 管理员创建 CLI)、cmd/server/main.go 的 startAdminIfConfigured()(opt-in:ADMIN_SECRET_KEY+DB_DSN 才起)。统一 getenvDefault 命名;go.mod 取 HEAD + go mod tidy(x/term 提为直接依赖);admins 迁移与 main 既有 000008_provision 撞号,重排为 000009_admins。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:47:38 +08:00
wangjia 03b00b9a0f merge: config + store(UTC DSN) + 自动迁移 [tsk_zRA6fGU1JuHj]
以 main 现有 config 设计为准(FromEnv/*Config/单 DSN/RS256),移植分支新增的 store 层:mysql.go 的 Open(*config.Config) 改用 cfg.DSN、结构化强制 UTC + 会话时区断言;migrate.go + migrations/embed.go 提供 golang-migrate 嵌入式迁移;cmd/migrate/main.go 实现 migrate CLI(读 DB_DSN)。未采用分支的 Load()/PANGOLIN_ 前缀/MySQL 拆分字段(与 main 设计冲突)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 18:42:46 +08:00
wangjia 52857d1d55 feat(1e): config PANGOLIN_ prefix + store.Open UTC DSN + go:embed migrations (tsk_zRA6fGU1JuHj)
- internal/config: rewrite Config with PANGOLIN_ prefix fields
  (HTTPAddr, AdminAddr, GRPCAddr, MySQL DSN/fields, RedisAddr,
  AutoMigrate, JWTSecret placeholder, WebhookSecret); Load() replaces
  FromEnv(); missing required MySQL vars return named-field error.

- internal/store/mysql.go: single Open(cfg) entry point; uses
  mysql.ParseDSN to structurally override ParseTime=true, Loc=UTC,
  Collation=utf8mb4_unicode_ci, Params[time_zone]='+00:00'; asserts
  SELECT @@session.time_zone=+00:00 after Ping (startup fatal).

- migrations/embed.go: //go:embed *.sql exposes var FS embed.FS.

- internal/store/migrate.go: MigrateUp/MigrateDown/MigrateVersion
  backed by golang-migrate iofs source + mysql driver; ErrNoChange
  treated as success.

- cmd/migrate/main.go: filled — up/down/version subcommands, reads
  config.Load() + store.Open.

- cmd/server/main.go: startup sequence Load → store.Open (UTC assert)
  → MigrateUp (if PANGOLIN_AUTO_MIGRATE=true) → HTTP listen;
  structured slog output at each step.

- internal/store/mysql_test.go: pure-function unit tests for buildDSN
  (empty-fields case + conflicting params overridden case); both pass.
- internal/store/mysql_integration_test.go: //go:build integration;
  testcontainers mysql:8 — UTC assertion + MigrateUp idempotency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 14:52:27 +08:00
wangjia 6e99e32285 feat(admin): 管理端最小后台(独立监听 + 白名单 + 2FA)tsk_SCMtcGF4F434
实现「一个二进制三个监听」中的管理端:

- 独立监听 ADMIN_LISTEN(默认 127.0.0.1:9443,FromEnv 拒绝 0.0.0.0/:: 等公网绑定)
- 中间件链:mw_ipallow(CIDR 白名单,仅信任 RemoteAddr,默认内网段)→
  mw_session(HttpOnly+Secure+SameSite=Strict cookie,Redis 30min 滑动 TTL)
- 登录双因素:argon2id 密码 + RFC6238 TOTP;失败限流与临时锁定;
  TOTP 密钥 AES-GCM 加密入库;migration 000008 增补 admins 表;
  首个管理员由 cmd/adminctl create 创建(终端输出 otpauth URI/Secret)
- 功能三块(html/template + embed 静态资源,原生 JS 二次确认):
  1. 码批次:表单生成→明文仅在本次 CSV 下载出现(不落盘/不入日志);
     批次列表 + 整批作废(复用 #3 codes,新增 Store.ListBatches/VoidBatch)
  2. 节点操作:列表 + 近期 node_events;replace 经 #14 ProvisionService、
     draining/up 经 #5 Lifecycle(二者未就绪→注入 stub,UI 置灰);二次确认 + CSRF
  3. audit_log 查看:actor/action/target/时间范围过滤分页;同页查 node_events
- 所有写操作写 audit_log(actor=用户名,meta 不含明文);管理端仅记安全事件
  (登录失败/锁定/白名单拦截),不记常规访问日志
- internal/totp 复用包(与 doc/05 用户中心 2FA 同算法,纯标准库)

测试:totp RFC6238 向量、argon2/AES 往返、白名单放行/拦截、会话滑动过期、
登录成功/密码错/TOTP 错/未知用户/锁定、批次 CSV 含明文且 audit 不泄露、
作废/节点操作的二次确认与 CSRF 缺失被拒、mock 断言 service 调用参数、审计过滤。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:49:32 +08:00
wangjia 3d5bac66b4 feat(provision): 弹性节点基建 Terraform + 一键更换 (tsk_6u0FxmbC7Yeq)
IaC 面 (infra/) 与控制面 (server/internal/provision) 双产出,落地 doc/04 §4
「节点是牲口」弹性拓扑与 make-before-break 一键更换。

server/internal/provision:
- CloudAdapter 适配层 + Registry(首发 vultr 消耗品池 / hetzner 精品池各一);
  厂商凭证仅从 PROVISION_<VENDOR>_* env 注入,不入库不入 git。
- ProvisionService:CreateNode(幂等键重放不重复开机)、DestroyNode(幂等)、
  RotateIP(换 IP 不换机 + version bump)、ListProviders。
- Replace 一键更换:先建后拆,新机 up 先于旧机 draining(容量不下降),
  replacement_uuid 幂等键 + replacements 表分步记录,崩溃可续跑不重复。
- RotatePool:池内滚动轮换,并发度 1–2。
- cmd/nodectl CLI:create/destroy/rotate-ip/replace/rotate-pool/providers。
- 单测(mock 厂商 API + 内存 Store):幂等重放、make-before-break 时序断言、
  开机失败/探活超时→destroyed+失败计数+告警钩子、崩溃续跑、RotatePool。

infra/:
- terraform/:探针机 + 控制面基线模块化(probe / control-plane)+ README,
  低频基线进 state,节点不进 Terraform。
- cloud-init/node.yaml.tmpl:节点引导模板(注入一次性 bootstrap token,task #5)。
- identity-isolation.md:身份隔离登记表(doc/06 §2 红线,无任何凭证)。

migrations/000008:nodes 增 provider_instance_id/elastic_ip_id、node_events
增 ip_rotated、provision_idempotency / replacements 表(附加式,不动现网)。

红线:仅面向新厂商池,绝不纳管现网生产 EC2(deploy/ marzban)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 14:23:39 +08:00
wangjia 787151245e merge: maestro/tsk_tFMU7-hKzfOf [tsk_tFMU7-hKzfOf] codes 激活码模块
解决与 1A 骨架的冲突:module 统一为 github.com/wangjia/pangolin/server
(codes 分支原用 pangolin/server,7 个源文件 import 已改写);
go.mod require 并集(redis 取 9.20.1);Makefile 以骨架为基底并入
build-codegen/test-unit/test-integration target。
go build/vet 通过,codes 单测通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-13 03:03:11 +08:00
wangjia afcd7b325c feat(codes): implement activation-code module (tsk_tFMU7-hKzfOf)
Implements server/internal/codes/ with all required functionality:

## Generator (generator.go)
- Crockford Base32 16-char codes (15 data + 1 Crockford mod-37 check char)
- crypto/rand for unbiased random generation with rejection sampling
- Canonicalize(): I/L→1, O→0 folding, hyphen/space stripping
- Hash(): SHA-256 of canonical plaintext (only value stored in DB)
- Algorithm hard-coded; check char detects all single-char substitution errors

## Store (store.go)
- MySQL-backed via database/sql
- CreateBatch / CreateCode with ErrDuplicate on UNIQUE conflict
- FindCodeByHashForUpdate: SELECT … FOR UPDATE for row-level concurrency control
- MarkRedeemed, ExtendSubscription, CreateSubscription, GetActiveSubscriptions
- WriteAuditLog, CodeExistsByHash
- BeginTx at READ COMMITTED (FOR UPDATE provides row exclusivity)

## Service (service.go)
- Redeem(): 9-step flow with full idempotency and concurrency safety
  - isLocked / recordFail / clearFail via Redis key redeem:fail:{user_id}
  - SELECT … FOR UPDATE → single winner under N-concurrent redemptions
  - Same-plan: extends existing subscription (max(expires_at,now)+days)
  - Cross-plan: creates new subscription (max(now,latest)+days)
  - Idempotent: same user re-submits → 200 without re-applying
  - 5 failures → ACCOUNT_LOCKED for 1 hour (sliding window via Redis)
- CreateBatch(): generates N codes, stores hashes, returns plaintext once
  - Automatic retry on hash collision (birthday probability ≈10⁻⁸)

## Webhook (webhook.go)
- POST /webhook/store/codes — outside /v1, no JWT required
- HMAC-SHA256 with hmac.Equal constant-time comparison
- ±5 min timestamp window
- Redis SetNX nonce deduplication (15-min TTL)
- Idempotent: duplicate nonce → 200; duplicate code_hash → 200

## HTTP Handler (handler.go)
- POST /v1/redeem endpoint wired to Service.Redeem
- Reads userID from context key (set by JWT middleware from auth module)
- Bilingual error responses {code, message_zh, message_en}

## Export (export.go)
- ExportCSV(): streams plaintext codes to io.Writer as CSV
- Plaintext NEVER stored in DB; only SHA-256 hash persists

## CLI (cmd/codegen/main.go)
- codegen -plan -days -count -channel -note -dsn [-out]
- Transition tool until admin panel (#8) is ready
- Outputs CSV to stdout or file; warns operator about plaintext sensitivity

## Infrastructure (skeleton)
- internal/config/config.go: env-var configuration
- internal/db/db.go: MySQL connection pool helper
- internal/redisutil/redis.go: Redis client constructor
- internal/apierr/apierr.go: bilingual error types
- migrations/001_init.sql: DDL for codes module tables
- go.mod with all dependencies

## Tests
- generator_test.go (unit, no deps):
  - Format, uniqueness (10k codes, zero collisions), normalization,
    check-char detection of all single-char errors, hash consistency
- webhook_test.go (unit, no deps):
  - Signature rejection, missing signature, stale/future timestamp,
    missing nonce, constant-time HMAC comparison
- service_test.go (//go:build integration, testcontainers):
  - N=20 concurrent redeemers → exactly 1 winner
  - Idempotent redeem returns success for same user
  - Other-user redemption → CODE_REDEEMED + failure counted
  - 5 failures → ACCOUNT_LOCKED on 6th attempt
  - Same-plan extension: expires_at precision assertion
  - Cross-plan creation: new subscription row
  - Audit log written on every successful redemption
  - CSV export: no plaintext in DB, hash present
  - Webhook nonce replay: idempotent 200
  - Webhook same-hash: idempotent 200, single DB row

Run unit tests: make test
Run integration tests: make test-integration (requires Docker)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 02:16:16 +08:00
wangjia b4114c93ee feat(db): add MySQL migrations 000001-000007 covering all 12 tables + seed [tsk_paiqu21eZIuw]
- 000001: users + devices(账户 / 设备,含 argon2id pw_hash、dp_uuid)
- 000002: plans + subscriptions(套餐口径来自 design/CLAUDE.md §7)
- 000003: code_batches + codes(SHA-256 code_hash,明文不入库)
- 000004: usage_daily + audit_log(最小数据原则 + JSON meta)
- 000005: providers + nodes(providers 先建,nodes FK 引用)
- 000006: node_events(9 值 ENUM)+ directory_version(CHECK id=1,MySQL ≥ 8.0.16)
- 000007: plans 三行 seed + directory_version(1,1),ON DUPLICATE KEY UPDATE 保可重入

所有表 ENGINE=InnoDB DEFAULT CHARSET=utf8mb4,时间列 DATETIME(6),
主键 BIGINT UNSIGNED AUTO_INCREMENT + 对外 uuid CHAR(36) UNIQUE。
每对 down 文件逆序 DROP(子表先于父表)。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 01:28:01 +08:00