Commit Graph

15 Commits

Author SHA1 Message Date
wangjia 038e5b6ab5 fix(server): 被移除的设备会话轮询判活 —— dev 行不存在应判「失效」而非「有效」(bug)
SessionActive 在设备被 DeleteDevice 删行后 FindByUUID 返回 nil,此前一律 return true
(「未知/非本人设备不据此登出」)。结果:被移除的设备轮询 /me/session 永远拿到
active=true → 不登出、不弹框,只表现为数据面凭证被召回→隧道断→客户端误报「节点异常」。
改为:dev==nil(自身 device_id 查无此行,已登录客户端必然注册过 → 只能是被移除)→
return false,让其登出;dev.UserID!=userID(他人 uuid)仍 return true 兜底。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 01:54:53 +08:00
wangjia 6bac7fd2f0 feat(server): 设备上限登录闸(超限非硬拒登,返回 device_limit 信号)#16
启用设备数量限制的服务端部分。登录照常成功签发 token(非硬拒登),但若账户活跃
设备数超套餐上限,登录响应带 device_limit 信号,客户端据此弹「移除设备」页。

- devices/store.go:CountActiveDevices(last_seen 近 staleWindow)+ PruneStaleDevices
  (删超期僵尸行,免费版重装 churn 自愈)
- devices/service.go:staleWindow=30d;DeviceLimitStatus + CheckDeviceLimit
  (best-effort prune → ResolvePlan → 活跃 count > cap 即 Over,附活跃设备列表)
- auth:DeviceRegistrar 加 CheckDeviceLimit;recordLogin 回传 *DeviceLimit;
  LoginOutcome.DeviceLimit;Login 透传;handler tokenPairResponse.device_limit(omitempty)
- main.go:authDeviceRegistrar 适配 devices.CheckDeviceLimit → auth.DeviceLimit
- 测试:auth 登录透传超限信号(仍签发 token);devices within/over/prune-stale

连接侧 backstop(服务端硬拦)本轮从简未做,作为后续硬化(登录闸为客户端可信信号)。
DB 无 schema 变更。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-01 19:16:10 +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 f0af3bcc94 fix(server): 强退设备立即离线 — online 须含活跃会话 + 撤销会话不刷 last_seen
trick bug:强退后被踢设备的下一次会话轮询会先刷 last_seen 再发现 active=false,那一刷
把它顶成「在线」直到 90s 窗口过期。修:① online = last_seen 新 且 有未撤销会话
(ActiveSessionDeviceIDs);被强退设备无活跃会话 → 立即离线。② SessionActive 仅在 active
时才 TouchLastSeen,撤销会话的轮询不再刷新。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 08:51:10 +08:00
wangjia 3167ca8a74 fix(server): 设备「在线」准确反映 app 运行中 — 会话轮询刷 last_seen + 窗口缩到 90s
原 online=last_seen 在 3min 内,而 last_seen 只在连接/用量上报时刷 → app 运行≠刷新,
显示成'最近连过'而非'正在运行';关 app 后还'在线'到窗口过期。修:SessionActive(每 15s
轮询)顺便 TouchLastSeen → 运行中的 app 持续刷新;onlineWindow 3min→90s,关 app ~90s 转离线。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:55:17 +08:00
wangjia 0b33f12400 feat: 近实时远程下线 — 客户端轮询会话有效性(~15s),服务端 GET /v1/me/session
控制面无推送通道,access token 是无状态 JWT(15min),强制退出后被踢设备要等 token 过期
(≤15min)才登出。改成客户端每 15s 轮询会话是否仍有效,被强制退出即登出 → 延迟压到 ~15s。
- 服务端:sessions.HasActiveSession(user,device) + devices.SessionActive(按 UUID,fail-open)
  + GET /v1/me/session?device_id= 返回 {active}(恒 200,判据在 body)。无新迁移。
- 客户端:account_api.sessionActive + main.dart _RootFlowState 15s 轮询,active=false 即 logout
  (网络/鉴权异常不据此登出,fail-safe)。
- 测试:TestSQLite_SessionHasActiveSession(建会话=活跃→RevokeByDevice→非活跃)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 07:25:16 +08:00
wangjia 315c343ed5 feat(devices): 设备默认名「平台简写·主机名」+ 支持重命名(1.0.13)
ci-pangolin / Lint — shellcheck (push) Successful in 7s
ci-pangolin / OpenAPI Sync Check (push) Successful in 17s
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 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 6s
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 4m3s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
① 默认名:DeviceIdentity 改「平台简写(Win/Mac/Linux/iOS/Andr)·主机名」,主机名截
   到 8、总长≈12(替代裸主机名,更短)。
② 重命名:新端点 POST /v1/me/devices/{uuid}/rename(Service.RenameDevice 校验归属+
   截 64);store.UpdateName;客户端 account_api.renameDevice + provider.rename +
   「我的设备」⋯ 菜单加「重命名」+ 输入弹窗;l10n(zh/en)。
③ 版本 1.0.13。
测试:server RenameDevice(归属/空名/404)+ 客户端 rename widget;全量 go/flutter 绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 09:12:36 +08:00
wangjia 8a1db07d19 fix(devices+stats): /v1/me/devices 401 修复 + 设备下拉接已登录设备 + 版本号
ci-pangolin / Lint — shellcheck (push) Successful in 9s
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 15s
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 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 14s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m2s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s
① 「我的设备打不开」根因:devices handler 读 devices.CtxKeyUserID,而鉴权中间件
   把 user id 写在 auth 的 key 下(类型不同→取不到)→ /v1/me/devices 一直 401。
   改 handler 用 auth.UserIDFromContext(与 accountAPI 等一致)。
② 统计页设备下拉改读 devicesProvider(已登录设备),不再只列有用量的设备 →
   windows 设备现在会出现。
③ pubspec version 1.0.2+3 → 1.0.11+12,client_version 不再显示陈旧的 v1.0.2。
测试:devices_screen + stats_device_filter widget(下拉读 /v1/me/devices)+全量绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 08:37:11 +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 c0c4b94e29 feat(devices): P1 设备注册打通 —— 登录/注册即写 devices 表
ci-pangolin / Lint — shellcheck (push) Successful in 8s
ci-pangolin / OpenAPI Sync Check (push) Successful in 16s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 7s
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) Failing after 10s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 15s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m10s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 14s
后端:auth.Service 加 DeviceMeta + DeviceRegistrar 接口(consumer-side 解耦),
Login/Register 成功签发后 best-effort 注册设备(不强制设备上限,避免免费档重装
churn 锁死用户);handler 加 device 请求体;main 用 authDeviceRegistrar 适配
devices.Service 注入;normalizePlatform 加 linux。
客户端:新 device_identity.dart(SecureKV 接缝 + 稳定 UUIDv4 device_id 持久化 +
名称/平台/版本);弃用硬编码 'mac-001';auth_api login/register + connect 携带
device 元数据。加 uuid + device_info_plus 依赖。
测试:auth 设备注册(触发/best-effort/空 meta) + device_identity(生成/持久/
读失败不重生成/UUIDv4 形态);normalizePlatform linux=true。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 00:28:13 +08:00
wangjia 0fd3bce7a5 fix(server): 修复腐烂的集成测试套件 + devices 注册回填 last_seen
这批 -tags integration 测试(usage/auth/codes/devices/store)因依赖冲突
长期编译不过、从未进 CI 跑过,代码静默腐烂。本次逐层修复:

- 依赖:testcontainers-go v0.34→v0.43(原 v0.34 配 docker v28.3.3 编译失败:
  archive.Compression/sockets.DialPipe undefined)。
- auth: LoginOutcome 重构成 Tokens 嵌套后,测试仍引用扁平 RefreshToken;
  users 测试 schema 缺 totp_enabled 列 → GetUserByEmail 报错 → SendCode 500。
- usage/auth/codes/devices: DSN 里 time_zone='+00:00' 作 URL query 透传时 '+'
  被解码成空格 → MySQL Error 1298 ' 00:00';改百分号编码。
- store: 测试 DSN 缺 multiStatements=true → 多语句迁移 Error 1064;
  WithConfigFile("") 在 v0.43 被拒,改写真 my.cnf 设 +08:00 真正考验 UTC 覆盖。
- devices(产品 bug):insertDeviceTx 把 last_seen 写进库却没回填返回值,
  刚注册的设备 API 响应 last_seen=null 与库不一致;TestFullChain 据此把关。

修复后完整 integration 套件 -p 1 串行全绿(25 包 + 5 testcontainers 包)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-25 21:19:10 +08:00
wangjia 86b67c557b feat(server/db): 多库(3/4)— Dialect 抽象(upsert/锁)+ 合并 directory_version
- db.Dialect:LockForUpdate(mysql "FOR UPDATE" / sqlite "")、Upsert(中性
  EXCLUDED.col → mysql VALUES()/ sqlite excluded.);DialectForDB 从连接驱动推导
- 9 处 ON DUPLICATE KEY、11 处 FOR UPDATE 全走 dialect;sqlite 靠 _txlock=
  immediate 取得 BEGIN IMMEDIATE 悲观锁等价语义
- directory_version 三处重复合并为 store.BumpDirectoryVersion(dialect 感知)
- 这些文件同时含(2/4)的 UTC→Go 改动(与 upsert/锁同语句交错,无法拆分)
- 顺带:usage 的 FIELD()、codes 的 DATE_ADD/GREATEST 续期、nodes 的
  UNIX_TIMESTAMP、NULLIF 等 MySQL 专属构造一并退回 Go/可移植写法

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 00:01:26 +08:00
wangjia d88c1ae647 merge: maestro/tsk_x7wrlA87orsY [devices + 订阅校验中间件] (tsk_VRzw-af__qWx)
手动合并 tsk_x7wrlA87orsY(设备管理 + 订阅校验中间件)到 main:

冲突解决:
- server/internal/apierr/apierr.go:保留 tsk_GXDoc3Cs07Rn 版本(New/StatusFor/
  Middleware/ErrConflict/改善文档),并入 tsk_x7wrlA87orsY 新增的 ErrAccountBanned
  及对应 StatusFor case(→ 403)。

新增文件(来自 tsk_x7wrlA87orsY):
- server/internal/devices/doc.go       package 文档(替换占位 stub)
- server/internal/devices/context.go   CtxKeyUserID / Plan / WithPlan / PlanFromCtx
- server/internal/devices/handler.go   GET /v1/me/devices · DELETE /v1/me/devices/{id}
- server/internal/devices/middleware.go SubscriptionMiddleware · CheckDeviceQuota · RequirePaidTier
- server/internal/devices/service.go   RegisterIfAbsent / DeleteDevice / ResolvePlan + 纯函数 resolveEffectivePlan
- server/internal/devices/store.go     MySQL 数据访问层
- server/internal/devices/service_test.go          15 个单测(全通过)
- server/internal/devices/devices_integration_test.go  testcontainers 集成测试

OpenAPI 更新(来自 tsk_x7wrlA87orsY):
- server/api/openapi.yaml:SubscriptionInfo.source 枚举补 free
- design/server/openapi.yaml:SubscriptionInfo.source 枚举补 admin, free

测试:go build ./... ✓;go test ./internal/apierr/... ✓(8 tests);
      go test ./internal/devices/... ✓(15 tests)。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-13 15:09:51 +08:00
wangjia 1629385c70 feat(server): Go 模块骨架 + 工具链 [tsk_L2k2VrEujof8]
新建 server/ Go 模块:
- go.mod: module github.com/wangjia/pangolin/server, Go 1.22
  依赖: go-chi/chi/v5, google/uuid
- tools.go: //go:build tools 锁定 oapi-codegen / golang-migrate 版本
- cmd/server/main.go: chi router + GET /healthz → {"status":"ok"},监听 :8080
- cmd/migrate/main.go: 占位入口(实逻辑归 1e)
- internal/{config,store,apierr,idgen,auth,codes,devices,nodes,usage,admin}/doc.go
  各包职责说明
- Makefile: build/test/vet/lint/generate/migrate-up/migrate-down
- .golangci.yml: govet/errcheck/staticcheck/revive/gofmt 基线
- README.md: 定位 + 目录树 + make 入口说明
- setup.sh: 首次 go mod tidy 引导脚本(go.sum 需运行后生成)

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