feat(server/devices): 设备管理 + 订阅校验中间件 (tsk_x7wrlA87orsY)
实现 server/internal/devices 模块:
- handler.go: GET /v1/me/devices 列表、DELETE /v1/me/devices/{id} 移除
(chi 路由,挂在 /v1/me 下;JWT 中间件之后)。
- service.go: ListDevices / RegisterIfAbsent(隐式登记,按 plan.max_devices
校验,超限返回双语 DEVICE_LIMIT_EXCEEDED 含上限数字)/ DeleteDevice(事务硬删
+ audit_log → 调 CredentialRevoker.RevokeForUser 按用户回收凭证,dp_uuid 模型)
/ SubscriptionSummary / ResolvePlan / 纯函数 resolveEffectivePlan。
- middleware.go: 订阅校验中间件,解析最高档未过期订阅注入 context;
helpers PlanFromCtx / CheckDeviceQuota / RequirePaidTier;预留 60s Redis 缓存开关。
- store.go: devices/users/subscriptions/plans/audit_log 数据访问,按用户行锁串行化登记。
- context.go: user_id / plan 的 context key 与 helper。
- CredentialRevoker 接口(消费侧定义,避免与 nodes 循环依赖)+ NoopRevoker,
形状对齐 #5 的 Hub.Push(RevokeCredential),#5 落地前注入 no-op。
测试:service_test.go 15 个单测(trial→pro、过期回落 free、banned 拒绝、
最高档/同档最晚到期、UTC 严格边界、设备配额、双语限额、平台/名称归一化)全过;
devices_integration_test.go(testcontainers,build tag integration)覆盖
注册→connect 隐式登记→list→delete 全链路 + 回收断言 + 403/404。
apierr 增加 Unauthorized/Forbidden/NotFound/AccountBanned;
OpenAPI SubscriptionInfo.source 枚举补 free。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,80 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ctxKey is a private type for context keys to avoid collisions.
|
||||
type ctxKey string
|
||||
|
||||
// CtxKeyUserID is the context key under which the authenticated user's
|
||||
// internal int64 ID is stored by the JWT auth middleware (module #2).
|
||||
//
|
||||
// It mirrors the key used by the codes module so that, once the auth
|
||||
// middleware lands, a single canonical key can be reconciled across modules.
|
||||
const CtxKeyUserID ctxKey = "user_id"
|
||||
|
||||
// ctxKeyPlan is the context key under which the resolved subscription Plan is
|
||||
// stored by SubscriptionMiddleware.
|
||||
const ctxKeyPlan ctxKey = "plan"
|
||||
|
||||
// WithUserID returns a copy of ctx carrying the authenticated user ID.
|
||||
// Primarily used in tests and by the auth middleware.
|
||||
func WithUserID(ctx context.Context, userID int64) context.Context {
|
||||
return context.WithValue(ctx, CtxKeyUserID, userID)
|
||||
}
|
||||
|
||||
// UserIDFromContext extracts the authenticated user ID from ctx.
|
||||
// ok is false when no (valid) user ID is present.
|
||||
func UserIDFromContext(ctx context.Context) (int64, bool) {
|
||||
v, ok := ctx.Value(CtxKeyUserID).(int64)
|
||||
if !ok || v == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return v, true
|
||||
}
|
||||
|
||||
// Plan is the resolved effective subscription for a user, injected into the
|
||||
// request context by SubscriptionMiddleware and consumed by nodes (catalogue
|
||||
// filtering, connect), usage (daily caps), and the /v1/me summary.
|
||||
type Plan struct {
|
||||
// PlanCode is one of "free", "pro", "team".
|
||||
PlanCode string
|
||||
// ExpiresAt is the effective subscription expiry (UTC); nil for the free
|
||||
// fallback when the user has no active paid/trial subscription.
|
||||
ExpiresAt *time.Time
|
||||
// MaxDevices is the per-plan device cap (free 1 / pro 5 / team 10).
|
||||
MaxDevices int
|
||||
// DailyMinutes is the free-plan daily time cap in minutes; nil = unlimited.
|
||||
DailyMinutes *int
|
||||
// AdGate is true when the user must watch a rewarded ad to unlock daily use.
|
||||
AdGate bool
|
||||
// Source is the subscription source: "trial", "code", "admin", or "free"
|
||||
// for the no-subscription fallback.
|
||||
Source string
|
||||
}
|
||||
|
||||
// WithPlan returns a copy of ctx carrying the resolved Plan.
|
||||
func WithPlan(ctx context.Context, p Plan) context.Context {
|
||||
return context.WithValue(ctx, ctxKeyPlan, p)
|
||||
}
|
||||
|
||||
// PlanFromCtx extracts the resolved Plan from ctx. ok is false when
|
||||
// SubscriptionMiddleware has not run.
|
||||
func PlanFromCtx(ctx context.Context) (Plan, bool) {
|
||||
p, ok := ctx.Value(ctxKeyPlan).(Plan)
|
||||
return p, ok
|
||||
}
|
||||
|
||||
// planTier maps a plan code to a numeric tier (higher = better) for comparison.
|
||||
func planTier(code string) int {
|
||||
switch code {
|
||||
case "team":
|
||||
return 3
|
||||
case "pro":
|
||||
return 2
|
||||
default: // "free" or unknown
|
||||
return 1
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user