Skip to content

feat: 设备管理(PRD §6.1) - #36

Merged
s3loy merged 16 commits into
mainfrom
feature/device-management
Aug 4, 2026
Merged

feat: 设备管理(PRD §6.1)#36
s3loy merged 16 commits into
mainfrom
feature/device-management

Conversation

@Ptilopsi4

@Ptilopsi4 Ptilopsi4 commented Aug 4, 2026

Copy link
Copy Markdown
Member

Summary

实现 PRD 附录 C 最后一项待办:设备管理。把"登录会话"概念显式化为"设备"——用户在设备列表能看到自己所有登录态,也能从列表登出指定设备。

Redis Lua 原子原语支撑,device_id 复用 token family_id(UUID v4),设备生命周期与会话生命周期天然同步。

核心功能

  • GET /user/devices:按最近登录排序的设备列表(device_id / ua / ip / login_time / last_seen),Redis 不可用时降级空数组(fail-open)。
  • DELETE /user/devices/:id:登出指定设备——Redis 归属校验(fail-closed)→ 撤销该 family 全部 token → 删记录 → 审计 logout_device,按用户限流 RATE_LIMIT_DEVICE_*
  • 5 台上限:超出时淘汰最旧设备并撤销其 family(RevokeFamily + blacklist 投递 + 审计 evict_device),避免"最多 5 台"沦为显示约束;幻影成员(Hash 丢失但集合成员仍活)不再占名额、挤掉真设备。

会话生命周期挂钩

device_id = family_id,设备记录与会话状态严格同步:

  • 会话建立:密码登录、注册、GitHub/Lark 登录(ExchangeCode)均登记设备
  • 会话终止:登出、登出指定设备、改密、重置密码、刷新重放/轮换失败/过期、淘汰撤销——任一路径终止会话即清设备记录(fail-open,DB 撤销已持久化)
  • 管理员操作:角色降级(触发会话撤销时)与注销账号清空该用户全部设备记录;repository 提供权威 sessionsRevoked 标志避免 len(entries) 误判
  • 过期复活:30 天不活跃的设备记录过期后,刷新会重新登记并受 5 台上限约束;TTL 守卫 + Hash 缺失完整重建,避免永不过期孤儿与残缺记录
  • 审计 IP/UAevict_device 审计带触发请求的 client IP / user agent

防御性细节

  • TouchDevice 移到 RotateRefreshToken 之前,闭合"terminate 与 refresh 竞态导致死会话复活占槽位"窗口
  • 存活分支 TTL 缺失补发(防 Hash 被驱逐后 HSET 重建永活孤儿)
  • 淘汰/复活/列表全量清扫幻影成员
  • RemoveDevice 注释固化"调用方必须先证明归属"不变量

其他修复

  • 设备 40400 message 透传 service 消息(修复被"绑定记录不存在"劫持的历史缺口)
  • handler 层 trim :id path param,%20 走 40400 而非 40000
  • 配置校验 RATE_LIMIT_DEVICE_RPM/WINDOWRPM=0 静默失效问题)
  • cmd/api/routes_test.go 补两路由断言

文档同步

  • PRD §4.13 补 logout_device / evict_device 审计表(action + detail 两表)
  • PRD §6.1 写清"会话终止即清记录"语义 + 审计清单 + OAuth provider 边界
  • PRD §4.6 登录流程步骤更新(pair 提交 → audit → fail-open 设备登记)
  • API 文档 / README / CLAUDE.md 同步
  • deviceHashKeyPrefix 注释补 Redis Cluster 单实例假设说明

测试

  • Redis 层 12 个集成用例(淘汰边界、不续 TTL、TTL 缺失补发、复活、复活淘汰、幻影清扫×3、Remove/RemoveAll/归属/空集)
  • session 服务层 22 个用例(挂点断言、fail-open×6、fail-closed、淘汰撤销+审计、限流 subject)
  • handler + e2e 覆盖(真实 PG + Redis,含淘汰撤销家族、登出指定设备互不影响、%20 路径)
  • golangci-lint run ./... 0 issues;go test ./... 32 包全过

Ptilopsi4 and others added 14 commits August 3, 2026 22:45
Per-user device records live in a ZSET (score = login timestamp) plus a
per-device Hash (ua/ip/login_time/last_seen), 30d TTL, capped at 5 devices
with the oldest evicted. Registration, eviction, touch, removal, listing and
ownership checks are Lua-atomic so concurrent logins cannot overshoot the cap;
touch updates last_seen without extending TTL and only while the device is
still a set member, so an evicted device's refresh cannot resurrect a
TTL-less orphan Hash.
DeviceStore port keyed by the token family ID: a device is exactly one token
family, so device lifecycle and session lifecycle stay in lockstep. Login and
registration register the new family as a device (after the audit, so the
compensate path never leaves an orphan record); refresh touches last_seen;
logout removes the single device after the family revoke; password
change/reset clears every device. All session-flow writes are fail-open (WARN
only), while ListDevices degrades to an empty list and LogoutDevice gates the
family revoke behind a fail-closed ownership check (DeviceOwnedBy) so an
unreadable store can never authorize a cross-user revoke.
Thin port adapter carrying the PRD constants: 30d device TTL and the 5-device
per-user cap.
GET /user/devices lists the caller's devices newest-first (empty array when
the device store is unavailable, fail-open). DELETE /user/devices/:id logs
one device out: ownership check in Redis first, then the family revoke via the
existing token infrastructure, then the record cleanup and a logout_device
audit row. Unknown or foreign device IDs answer 40400 indistinguishably; the
endpoint is rate limited per user (RATE_LIMIT_DEVICE_*, 3/min default).
openapi.yaml gains /user/devices and /user/devices/{id} with Device /
DevicesListResponse / DeviceLogoutResponse schemas; the API doc gains §3.5
device list and §3.6 device logout with fail-open/fail-closed semantics,
rate limits and error codes. The PRD notes that device_id reuses the token
family_id (§6.1), marks the module complete in the status tracker and §11;
README and CLAUDE.md inventory the endpoints and the Redis data model.
Eviction under the 5-device cap now revokes the displaced family (RevokeFamily
+ blacklist delivery + record cleanup + evict_device audit) in both the login
and register hooks, so the cap is a session limit, not a list limit.

A refresh of an expired record resurrects it within the cap (re-entering the
per-user cap and evicting the oldest member when full), so an active session
never becomes invisible and unmanageable; resurrecting and registering sweep
phantom members (Hash lost, set member alive) without revoking their families,
so ghosts never occupy cap slots. The touch script re-applies a TTL only when
one is missing and rebuilds a lost Hash fully, closing the never-expiring
orphan hole. The touch now runs before the rotation commit, closing the race
where a terminating path interleaving with a refresh resurrected a dead
session.

Every session-termination path in Refresh (replay detection, rotation
failure, expiry) now removes the device record; evict_device audits carry the
triggering request's client IP and user agent.
GitHub/Lark logins are sessions like any password login: POST
/oauth/exchange-code now registers the issued family as a device in the same
Redis store (duck-typed DeviceStore port, same adapter), so third-party
sessions count against the 5-device cap, appear in the device list and can be
logged out from it. Eviction revokes the displaced family with blacklist
delivery and evict_device audit; a failed record write never breaks the
login that just succeeded (fail-open), and the eviction survives a partial
write error.
Role demotion and account close revoke every session of the user; the device
set must die with them so the list never shows logins that can no longer
authenticate. UpdateAdminUser now returns an authoritative sessionsRevoked
flag from inside its transaction — len(entries) is not a proxy for it, since
entries only collect still-live access tokens for blacklist delivery and a
demotion of a user idle for over an hour revokes every refresh token while
returning zero entries.
Wire the shared sessionredis.DeviceStore into the oauthlogin and adminuser
services (third-party login registration, admin session revokes), and pass
through the service-level message for 40400 so device logout answers
"设备不存在" instead of the unbind path's "绑定记录不存在". The route
inventory test now asserts both device endpoints.
RATE_LIMIT_DEVICE_RPM must be positive and RATE_LIMIT_DEVICE_WINDOW at least
1s, matching the other eight RATE_LIMIT_* groups; an unvalidated RPM of 0
would silently disable the per-user device logout limiter.
PRD §6.1: eviction revokes the displaced family, expired records resurrect on
refresh within the cap, every session-termination path clears the record and
writes an audit event, the OAuth provider authorization-code flow is out of
scope, and the §4.6 login steps reflect the actual pair-commit-then-register
order. API docs, README and CLAUDE.md mirror the same guarantees.
…cisely

An encoded blank (%20) in DELETE /user/devices/:id now takes the same 40400
path as an empty id instead of a 40000, matching the handler's contract that
any non-device string is "设备不存在". The deviceHashKeyPrefix documents its
single-instance assumption and the Redis Cluster hash-tag requirement for the
derived member keys. PRD §4.13 now lists logout_device and evict_device in
both the action and detail tables (already referenced by §6.1), and the
"封禁" wording is corrected: state changes never revoke sessions (is_deleted
is refused by the endpoint), so only role demotions that revoke and account
closure clear device records.
…ffects

Address code-review findings on the device-management feature:
- adminuser: report RevokedSessions from the repository's sessionsRevoked
  flag instead of len(blacklist entries), so an idle-user demotion that
  revokes every refresh token no longer reports "no sessions revoked"
- session.Refresh: defer the touch-eviction family revoke until the rotation
  commits, so a doomed refresh cannot evict and revoke a healthy device's
  family as collateral (with a regression test for the doomed-refresh case
  and the residual ghost-record cost documented in the comment)
- device_store: rebuild a lost device Hash's login_time from the ZSET score
  so the list sort matches the displayed timestamp
- oauthlogin: carry resource_id on evict_device audit rows, matching the
  session service
- CLAUDE.md: classify device records as fail-open under the Redis anchors,
  with the DeviceOwnedBy gate the explicit fail-closed exception
- docs: drop the unreachable 40000 from DELETE /user/devices/{id}

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

本 PR 落地 PRD §6.1 的“设备管理”,将内部“登录会话(token family)”显式呈现为“设备”,提供设备列表与登出指定设备能力,并在登录/刷新/登出/改密/重置/管理员会话撤销等生命周期节点上同步维护 Redis 侧设备记录(含 5 台上限淘汰与审计)。

Changes:

  • 新增 GET /user/devicesDELETE /user/devices/:id(JWT 保护路由),并打通 handler → session service → Redis 适配器链路
  • 实现 Redis 设备存储(ZSET+Hash)与 Lua 原子脚本:登记/触达/列表/删除/清空/归属校验,支持 5 台上限淘汰与幻影成员清扫
  • 补齐文档与测试:OpenAPI / API 文档 / PRD / README / CLAUDE.md / .env.example,同步新增单测、集成测试与端到端测试覆盖

Reviewed changes

Copilot reviewed 40 out of 40 changed files in this pull request and generated no comments.

Show a summary per file
File Description
README.md 更新实现状态与设备管理说明
internal/web/sessionhandler/mapping.go 40400(CodeNotFound) 透传 service message,兼容“解绑/设备不存在”两类场景
internal/web/sessionhandler/handler.go 注册设备管理路由并扩展 handler Service 接口
internal/web/sessionhandler/handler_test.go fakeService 补齐设备相关方法以满足接口
internal/web/sessionhandler/device.go 新增设备列表与登出指定设备 handler(含 path param trim 策略)
internal/web/sessionhandler/device_test.go handler 层设备接口的单测覆盖
internal/web/sessionhandler/device_e2e_test.go 真实 PG+Redis 的设备管理端到端测试
internal/service/session/types.go 定义 DeviceRecord 与 DeviceStore 端口接口
internal/service/session/service.go 在 Login/Refresh/Logout/ChangePassword/ResetPassword 等会话路径挂接设备记录维护与清理
internal/service/session/service_test.go 测试加速(共享 RSA key/密码 hash)并补充 refresh 与设备清理相关断言
internal/service/session/errors.go 新增 ErrDeviceNotFound(与 CodeNotFound 对齐)
internal/service/session/device.go 新增 ListDevices/LogoutDevice 以及淘汰撤销辅助逻辑(审计/黑名单投递/清理)
internal/service/session/device_test.go session service 设备管理行为的单测覆盖(fail-open/closed、淘汰撤销等)
internal/service/oauthlogin/types.go OAuth login service 增加 RevokeFamily 与 DeviceStore/Blacklist 端口
internal/service/oauthlogin/service.go ExchangeCode 登录后登记设备并处理淘汰撤销(含审计/黑名单投递)
internal/service/oauthlogin/service_test.go 覆盖第三方登录的设备登记与淘汰撤销行为
internal/service/oauthlogin/helpers.go audit 结构补齐 resource_id 以对齐 session 侧审计形状
internal/service/oauthlogin/fakes_test.go token repo/devices/blacklist 等测试替身扩展
internal/service/oauthlogin/bind.go 调整 audit 调用以适配新签名
internal/service/adminuser/users.go 管理员更新/注销用户时在“会话撤销发生”场景清空设备记录
internal/service/adminuser/users_test.go 覆盖 sessionsRevoked 标志与设备清理联动
internal/service/adminuser/types.go UpdateAdminUser 返回值扩展(entries + sessionsRevoked)并新增 DeviceStore 端口
internal/service/adminuser/service.go admin service 注入 Devices 端口
internal/service/adminuser/fakes_test.go admin 测试替身补齐 devices 清理记录
internal/repository/admin_user.go UpdateAdminUser 返回 sessionsRevoked 标志(不再用 len(entries) 推断)
internal/repository/admin_user_integration_test.go 适配 UpdateAdminUser 新签名
internal/repository/admin_guard_integration_test.go 适配 UpdateAdminUser 新签名
internal/redis/device_store.go 新增 Redis 设备存储与 Lua 脚本(登记/触达/列表/删除/清空/归属校验)
internal/redis/device_store_integration_test.go Redis 设备存储的集成测试覆盖
internal/config/config.go 新增 RATE_LIMIT_DEVICE_* 配置与校验
internal/config/config_test.go 覆盖设备限流配置校验与默认值加载
internal/adapter/redis/session/device.go session 侧 DeviceStore Redis 适配器(TTL=30d、cap=5)
internal/adapter/redis/session/adapter_integration_test.go 适配器 round-trip 集成测试补充
docs/SAST Link v2 PRD.md PRD 同步设备管理语义、审计项与流程步骤
docs/openapi.yaml OpenAPI 新增设备列表/登出设备接口与 schema
docs/API文档.md API 文档新增设备列表与登出指定设备章节
cmd/api/runtime.go wiring:session/oauthlogin/adminuser 注入 Devices/DeviceLimiter/Blacklist 等依赖
cmd/api/routes_test.go 路由断言补齐设备管理 endpoints
CLAUDE.md 开发说明同步设备管理实现与 Redis 降级策略补充
.env.example 增加 RATE_LIMIT_DEVICE_* 示例配置

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

s3loy
s3loy previously approved these changes Aug 4, 2026
@s3loy
s3loy merged commit 7b06e00 into main Aug 4, 2026
8 checks passed
@s3loy
s3loy deleted the feature/device-management branch August 4, 2026 18:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants