feat: 设备管理(PRD §6.1) - #36
Merged
Merged
Conversation
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}
Contributor
There was a problem hiding this comment.
Pull request overview
本 PR 落地 PRD §6.1 的“设备管理”,将内部“登录会话(token family)”显式呈现为“设备”,提供设备列表与登出指定设备能力,并在登录/刷新/登出/改密/重置/管理员会话撤销等生命周期节点上同步维护 Redis 侧设备记录(含 5 台上限淘汰与审计)。
Changes:
- 新增
GET /user/devices与DELETE /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
previously approved these changes
Aug 4, 2026
This reverts commit be2de46.
s3loy
approved these changes
Aug 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
实现 PRD 附录 C 最后一项待办:设备管理。把"登录会话"概念显式化为"设备"——用户在设备列表能看到自己所有登录态,也能从列表登出指定设备。
Redis Lua 原子原语支撑,
device_id复用 tokenfamily_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_*。evict_device),避免"最多 5 台"沦为显示约束;幻影成员(Hash 丢失但集合成员仍活)不再占名额、挤掉真设备。会话生命周期挂钩
device_id = family_id,设备记录与会话状态严格同步:sessionsRevoked标志避免len(entries)误判evict_device审计带触发请求的 client IP / user agent防御性细节
HSET重建永活孤儿)RemoveDevice注释固化"调用方必须先证明归属"不变量其他修复
:idpath param,%20走 40400 而非 40000RATE_LIMIT_DEVICE_RPM/WINDOW(RPM=0静默失效问题)cmd/api/routes_test.go补两路由断言文档同步
logout_device/evict_device审计表(action + detail 两表)deviceHashKeyPrefix注释补 Redis Cluster 单实例假设说明测试
%20路径)golangci-lint run ./...0 issues;go test ./...32 包全过