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>
This commit is contained in:
wangjia
2026-06-13 15:09:51 +08:00
parent 30e73b31c2
commit d88c1ae647
11 changed files with 1501 additions and 7 deletions
+1 -1
View File
@@ -83,7 +83,7 @@ components:
description: null 表示无限期(免费版)
source:
type: string
enum: [trial, code]
enum: [trial, code, admin, free]
Device:
type: object
+2 -2
View File
@@ -702,8 +702,8 @@ components:
description: 订阅到期时间(UTC ISO-8601),免费版无到期时间时为 null
source:
type: string
enum: [trial, code, admin]
description: 订阅来源(试用 / 激活码 / 管理员)
enum: [trial, code, admin, free]
description: 订阅来源(试用 / 激活码 / 管理员 / 无订阅时的免费回落
TodayUsageSummary:
type: object
+7
View File
@@ -70,6 +70,11 @@ var (
MessageZH: "服务器内部错误,请稍后重试",
MessageEn: "Internal server error, please try again later",
}
ErrAccountBanned = &Error{
Code: "ACCOUNT_BANNED",
MessageZH: "账户已被封禁,无法继续操作",
MessageEn: "This account has been banned",
}
)
// Activation-code errors.
@@ -137,6 +142,8 @@ func StatusFor(e *Error) int {
return http.StatusNotFound
case "CONFLICT":
return http.StatusConflict
case "ACCOUNT_BANNED":
return http.StatusForbidden
case "RATE_LIMITED", "ACCOUNT_LOCKED":
return http.StatusTooManyRequests
case "INTERNAL_ERROR":
+80
View File
@@ -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
}
}
@@ -0,0 +1,362 @@
//go:build integration
package devices_test
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/go-chi/chi/v5"
_ "github.com/go-sql-driver/mysql"
"github.com/testcontainers/testcontainers-go"
tcmysql "github.com/testcontainers/testcontainers-go/modules/mysql"
"github.com/wangjia/pangolin/server/internal/devices"
)
// --------------------------------------------------------------------------
// Container + schema setup
// --------------------------------------------------------------------------
func setupMySQL(t *testing.T) *sql.DB {
t.Helper()
ctx := context.Background()
ctr, err := tcmysql.Run(ctx, "mysql:8.0",
tcmysql.WithDatabase("pangolin_test"),
tcmysql.WithUsername("root"),
tcmysql.WithPassword("test"),
)
testcontainers.CleanupContainer(t, ctr)
if err != nil {
t.Fatalf("mysql container: %v", err)
}
dsn, err := ctr.ConnectionString(ctx, "parseTime=true", "loc=UTC", "time_zone='+00:00'")
if err != nil {
t.Fatalf("mysql dsn: %v", err)
}
db, err := sql.Open("mysql", dsn)
if err != nil {
t.Fatalf("open mysql: %v", err)
}
t.Cleanup(func() { db.Close() })
if err := applySchema(db); err != nil {
t.Fatalf("schema: %v", err)
}
return db
}
func applySchema(db *sql.DB) error {
stmts := []string{
`CREATE TABLE IF NOT EXISTS users (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
pw_hash VARCHAR(255) NOT NULL DEFAULT '',
dp_uuid CHAR(36) NOT NULL,
status ENUM('active','banned') NOT NULL DEFAULT 'active',
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS devices (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
uuid CHAR(36) NOT NULL UNIQUE,
user_id BIGINT UNSIGNED NOT NULL,
name VARCHAR(64) NOT NULL,
platform ENUM('ios','android','windows','macos') NOT NULL,
last_seen DATETIME(6) NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
FOREIGN KEY (user_id) REFERENCES users(id),
INDEX idx_user (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS plans (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
code ENUM('free','pro','team') NOT NULL UNIQUE,
max_devices INT NOT NULL,
daily_minutes INT NULL,
ad_gate BOOLEAN NOT NULL DEFAULT FALSE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS subscriptions (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL,
plan_id BIGINT UNSIGNED NOT NULL,
expires_at DATETIME(6) NOT NULL,
source ENUM('trial','code') NOT NULL,
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (plan_id) REFERENCES plans(id),
INDEX idx_user_exp (user_id, expires_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS audit_log (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
actor VARCHAR(64) NOT NULL,
action VARCHAR(64) NOT NULL,
target VARCHAR(128) NOT NULL,
meta JSON NULL,
at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
INDEX idx_at (at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`INSERT IGNORE INTO plans (code, max_devices, daily_minutes, ad_gate)
VALUES ('free', 1, 10, TRUE), ('pro', 5, NULL, FALSE), ('team', 10, NULL, FALSE)`,
}
for _, stmt := range stmts {
if _, err := db.Exec(stmt); err != nil {
return fmt.Errorf("schema exec: %w\nSQL: %s", err, stmt)
}
}
return nil
}
// createUser inserts a user (status active) and returns its id.
func createUser(t *testing.T, db *sql.DB, email, status string) int64 {
t.Helper()
res, err := db.Exec(
`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status)
VALUES (UUID(), ?, 'x', UUID(), ?)`, email, status)
if err != nil {
t.Fatalf("createUser: %v", err)
}
id, _ := res.LastInsertId()
return id
}
// giveSubscription inserts a subscription for the user.
func giveSubscription(t *testing.T, db *sql.DB, userID int64, plan, source string, expiresAt time.Time) {
t.Helper()
var planID int64
if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, plan).Scan(&planID); err != nil {
t.Fatalf("plan lookup: %v", err)
}
if _, err := db.Exec(
`INSERT INTO subscriptions (user_id, plan_id, expires_at, source) VALUES (?,?,?,?)`,
userID, planID, expiresAt.UTC(), source); err != nil {
t.Fatalf("giveSubscription: %v", err)
}
}
func newUUID(t *testing.T, db *sql.DB) string {
t.Helper()
var u string
if err := db.QueryRow(`SELECT UUID()`).Scan(&u); err != nil {
t.Fatalf("uuid: %v", err)
}
return u
}
// --------------------------------------------------------------------------
// Tests
// --------------------------------------------------------------------------
// TestFullChain exercises register → implicit re-register → list → delete and
// asserts credential revocation is triggered on delete.
func TestFullChain(t *testing.T) {
db := setupMySQL(t)
store := devices.NewStore(db)
revoker := &devices.NoopRevoker{}
svc := devices.NewService(store, revoker)
ctx := context.Background()
userID := createUser(t, db, "chain@example.com", "active")
// 7-day pro trial → effective plan pro (max 5).
giveSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(7*24*time.Hour))
plan, apiErr := svc.ResolvePlan(ctx, userID)
if apiErr != nil {
t.Fatalf("ResolvePlan: %v", apiErr)
}
if plan.PlanCode != "pro" || plan.MaxDevices != 5 {
t.Fatalf("want pro/5, got %s/%d", plan.PlanCode, plan.MaxDevices)
}
devUUID := newUUID(t, db)
in := devices.RegisterInput{UserID: userID, DeviceUUID: devUUID, Name: "iPhone 15 Pro", Platform: "ios", MaxDevices: plan.MaxDevices}
// First sight → insert.
d1, apiErr := svc.RegisterIfAbsent(ctx, in)
if apiErr != nil {
t.Fatalf("RegisterIfAbsent: %v", apiErr)
}
if d1.UUID != devUUID || d1.LastSeen == nil {
t.Fatalf("unexpected device: %+v", d1)
}
// Second sight → idempotent (no new row), last_seen refreshed.
if _, apiErr := svc.RegisterIfAbsent(ctx, in); apiErr != nil {
t.Fatalf("re-register: %v", apiErr)
}
list, apiErr := svc.ListDevices(ctx, userID)
if apiErr != nil {
t.Fatalf("ListDevices: %v", apiErr)
}
if len(list) != 1 {
t.Fatalf("expected 1 device after re-register, got %d", len(list))
}
// Delete → list drops to 0, audit row exists, revoker called.
if apiErr := svc.DeleteDevice(ctx, userID, devUUID); apiErr != nil {
t.Fatalf("DeleteDevice: %v", apiErr)
}
list, _ = svc.ListDevices(ctx, userID)
if len(list) != 0 {
t.Fatalf("expected 0 devices after delete, got %d", len(list))
}
var auditCount int
db.QueryRow(`SELECT COUNT(1) FROM audit_log WHERE action='device.delete' AND actor=? AND target=?`,
fmt.Sprintf("user:%d", userID), "device:"+devUUID).Scan(&auditCount)
if auditCount != 1 {
t.Errorf("expected 1 device.delete audit row, got %d", auditCount)
}
if len(revoker.Calls) != 1 {
t.Fatalf("expected 1 revoke call, got %d", len(revoker.Calls))
}
if revoker.Calls[0].UserID != userID || revoker.Calls[0].Reason != "device_deleted" {
t.Errorf("unexpected revoke call: %+v", revoker.Calls[0])
}
}
// TestDeviceLimitEnforced verifies RegisterIfAbsent rejects when the cap is hit.
func TestDeviceLimitEnforced(t *testing.T) {
db := setupMySQL(t)
svc := devices.NewService(devices.NewStore(db), nil)
ctx := context.Background()
userID := createUser(t, db, "free@example.com", "active")
// No subscription → free plan, max_devices 1.
plan, _ := svc.ResolvePlan(ctx, userID)
if plan.MaxDevices != 1 {
t.Fatalf("expected free max_devices 1, got %d", plan.MaxDevices)
}
first := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "Pixel", Platform: "android", MaxDevices: 1}
if _, apiErr := svc.RegisterIfAbsent(ctx, first); apiErr != nil {
t.Fatalf("first register: %v", apiErr)
}
second := devices.RegisterInput{UserID: userID, DeviceUUID: newUUID(t, db), Name: "iPad", Platform: "ios", MaxDevices: 1}
_, apiErr := svc.RegisterIfAbsent(ctx, second)
if apiErr == nil {
t.Fatal("expected second register to be rejected")
}
if apiErr.Code != "DEVICE_LIMIT_EXCEEDED" {
t.Errorf("want DEVICE_LIMIT_EXCEEDED, got %s", apiErr.Code)
}
}
// TestDeleteOthersDevice verifies ownership enforcement and not-found handling.
func TestDeleteOthersDevice(t *testing.T) {
db := setupMySQL(t)
svc := devices.NewService(devices.NewStore(db), nil)
ctx := context.Background()
owner := createUser(t, db, "owner@example.com", "active")
other := createUser(t, db, "other@example.com", "active")
devUUID := newUUID(t, db)
if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: owner, DeviceUUID: devUUID, Name: "Mac", Platform: "macos", MaxDevices: 5,
}); apiErr != nil {
t.Fatalf("register: %v", apiErr)
}
// Other user cannot delete it → 403 FORBIDDEN.
if apiErr := svc.DeleteDevice(ctx, other, devUUID); apiErr == nil || apiErr.Code != "FORBIDDEN" {
t.Errorf("want FORBIDDEN, got %v", apiErr)
}
// Non-existent device → 404 NOT_FOUND.
if apiErr := svc.DeleteDevice(ctx, owner, newUUID(t, db)); apiErr == nil || apiErr.Code != "NOT_FOUND" {
t.Errorf("want NOT_FOUND, got %v", apiErr)
}
}
// TestBannedUserRejected verifies the resolver/middleware path rejects banned users.
func TestBannedUserRejected(t *testing.T) {
db := setupMySQL(t)
svc := devices.NewService(devices.NewStore(db), nil)
ctx := context.Background()
userID := createUser(t, db, "banned@example.com", "banned")
if _, apiErr := svc.ResolvePlan(ctx, userID); apiErr == nil || apiErr.Code != "ACCOUNT_BANNED" {
t.Errorf("want ACCOUNT_BANNED, got %v", apiErr)
}
}
// TestHTTPHandlers exercises the chi routes end-to-end through the subscription
// middleware (userID injected as the JWT middleware would).
func TestHTTPHandlers(t *testing.T) {
db := setupMySQL(t)
svc := devices.NewService(devices.NewStore(db), &devices.NoopRevoker{})
ctx := context.Background()
userID := createUser(t, db, "http@example.com", "active")
giveSubscription(t, db, userID, "pro", "code", time.Now().UTC().Add(30*24*time.Hour))
devUUID := newUUID(t, db)
if _, apiErr := svc.RegisterIfAbsent(ctx, devices.RegisterInput{
UserID: userID, DeviceUUID: devUUID, Name: "Win", Platform: "windows", MaxDevices: 5,
}); apiErr != nil {
t.Fatalf("register: %v", apiErr)
}
mw := devices.NewMiddleware(svc, nil, 0)
h := devices.NewHandler(svc)
r := chi.NewRouter()
// Simulate the JWT auth middleware setting the user ID.
r.Use(func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
next.ServeHTTP(w, req.WithContext(devices.WithUserID(req.Context(), userID)))
})
})
r.Route("/v1/me", func(r chi.Router) {
r.Use(mw.Handler)
h.RegisterRoutes(r)
})
// GET /v1/me/devices
req := httptest.NewRequest(http.MethodGet, "/v1/me/devices", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("GET devices: want 200, got %d body=%s", w.Code, w.Body.String())
}
var listResp struct {
Devices []devices.Device `json:"devices"`
}
if err := json.Unmarshal(w.Body.Bytes(), &listResp); err != nil {
t.Fatalf("decode list: %v", err)
}
if len(listResp.Devices) != 1 || listResp.Devices[0].UUID != devUUID {
t.Fatalf("unexpected list: %+v", listResp.Devices)
}
// DELETE /v1/me/devices/{id}
req = httptest.NewRequest(http.MethodDelete, "/v1/me/devices/"+devUUID, nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNoContent {
t.Fatalf("DELETE device: want 204, got %d body=%s", w.Code, w.Body.String())
}
// DELETE a non-existent device → 404.
req = httptest.NewRequest(http.MethodDelete, "/v1/me/devices/"+newUUID(t, db), nil)
w = httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("DELETE missing device: want 404, got %d body=%s", w.Code, w.Body.String())
}
}
+17 -4
View File
@@ -1,5 +1,18 @@
// Package devices manages user devices (name, platform, WireGuard public key).
// It enforces per-plan device-count limits and coordinates with the nodes
// package to revoke WireGuard peers when a device is removed or a
// subscription expires.
// Package devices manages user devices (uuid, name, platform, last_seen) and
// owns subscription resolution for the rest of the API.
//
// Devices are registered implicitly on first connect (RegisterIfAbsent) and
// removed via DELETE /v1/me/devices/{id}. Removal hard-deletes the row, writes
// an audit_log entry, and triggers per-user data-plane credential recall
// through the CredentialRevoker interface (satisfied by nodes.Hub in module #5).
// Because the credential model is per-user (users.dp_uuid; the node side has no
// device dimension, doc/02 §3.2), "revoke a device" is realised as recalling
// the owning user's credential.
//
// SubscriptionMiddleware resolves the caller's highest-tier non-expired
// subscription (falling back to the free plan) and injects the plan
// {plan_code, expires_at, max_devices, daily_minutes, ad_gate} into the request
// context, where nodes (catalogue filtering / connect) and usage (daily caps)
// read it via PlanFromCtx. Plan numbers follow design/CLAUDE.md §7:
// free 1 / pro 5 / team 10 devices.
package devices
+68
View File
@@ -0,0 +1,68 @@
package devices
import (
"encoding/json"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// Handler serves the account device endpoints. It assumes the JWT auth
// middleware (module #2) has set CtxKeyUserID on the request context.
type Handler struct {
svc *Service
}
// NewHandler creates a Handler backed by svc.
func NewHandler(svc *Service) *Handler { return &Handler{svc: svc} }
// RegisterRoutes mounts the device routes on r under the caller's chosen prefix
// (the API mounts these beneath /v1/me):
//
// GET /devices
// DELETE /devices/{id}
func (h *Handler) RegisterRoutes(r chi.Router) {
r.Get("/devices", h.ListDevices)
r.Delete("/devices/{id}", h.DeleteDevice)
}
type listDevicesResponse struct {
Devices []Device `json:"devices"`
}
// ListDevices handles GET /v1/me/devices.
func (h *Handler) ListDevices(w http.ResponseWriter, r *http.Request) {
userID, ok := UserIDFromContext(r.Context())
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
devices, apiErr := h.svc.ListDevices(r.Context(), userID)
if apiErr != nil {
apierr.WriteJSON(w, StatusForError(apiErr), apiErr)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_ = json.NewEncoder(w).Encode(listDevicesResponse{Devices: devices})
}
// DeleteDevice handles DELETE /v1/me/devices/{id}.
func (h *Handler) DeleteDevice(w http.ResponseWriter, r *http.Request) {
userID, ok := UserIDFromContext(r.Context())
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
deviceUUID := chi.URLParam(r, "id")
if apiErr := h.svc.DeleteDevice(r.Context(), userID, deviceUUID); apiErr != nil {
apierr.WriteJSON(w, StatusForError(apiErr), apiErr)
return
}
w.WriteHeader(http.StatusNoContent)
}
+102
View File
@@ -0,0 +1,102 @@
package devices
import (
"net/http"
"time"
"github.com/redis/go-redis/v9"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// Middleware resolves the caller's effective subscription and injects it into
// the request context. It MUST run after the JWT auth middleware (module #2),
// which is responsible for setting CtxKeyUserID.
type Middleware struct {
svc *Service
// rdb and cacheTTL reserve a future 60s per-user plan cache. When cacheTTL
// > 0 and rdb != nil the resolved plan could be cached under
// "sub:plan:<userID>"; this is intentionally left disabled (cacheTTL == 0)
// for the initial direct-DB implementation (subscription volume is low).
// NOTE: a cache must not mask account bans — ban status would need a
// separate, uncached check before serving any cached plan.
rdb *redis.Client
cacheTTL time.Duration
}
// NewMiddleware creates the subscription-resolving middleware. Pass rdb=nil and
// cacheTTL=0 to use the direct-DB path (current default).
func NewMiddleware(svc *Service, rdb *redis.Client, cacheTTL time.Duration) *Middleware {
return &Middleware{svc: svc, rdb: rdb, cacheTTL: cacheTTL}
}
// Handler is the net/http middleware. It writes a JSON apierr and stops the
// chain on failure (missing user / banned / internal error); otherwise it
// injects the resolved Plan and calls next.
func (m *Middleware) Handler(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, ok := UserIDFromContext(r.Context())
if !ok {
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
return
}
plan, apiErr := m.svc.ResolvePlan(r.Context(), userID)
if apiErr != nil {
apierr.WriteJSON(w, StatusForError(apiErr), apiErr)
return
}
ctx := WithPlan(r.Context(), plan)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// --------------------------------------------------------------------------
// Helpers consumed by other modules (nodes catalogue filtering / connect, etc.)
// --------------------------------------------------------------------------
// CheckDeviceQuota returns a bilingual error when currentDevices already meets
// or exceeds the plan's device cap, otherwise nil.
func CheckDeviceQuota(p Plan, currentDevices int) *apierr.Error {
if p.MaxDevices > 0 && currentDevices >= p.MaxDevices {
return errDeviceLimit(p.MaxDevices)
}
return nil
}
// RequirePaidTier returns a 403 error when the plan is the free tier; nil for
// pro/team. Used to gate paid-only nodes and features.
func RequirePaidTier(p Plan) *apierr.Error {
if planTier(p.PlanCode) < planTier("pro") {
return &apierr.Error{
Code: "PAID_TIER_REQUIRED",
MessageZH: "该功能仅限付费会员,请升级后使用",
MessageEn: "This feature requires a paid plan. Please upgrade to continue.",
}
}
return nil
}
// StatusForError maps an apierr.Error code to an HTTP status code.
func StatusForError(e *apierr.Error) int {
if e == nil {
return http.StatusOK
}
switch e.Code {
case "UNAUTHORIZED":
return http.StatusUnauthorized
case "FORBIDDEN", "ACCOUNT_BANNED", "PAID_TIER_REQUIRED":
return http.StatusForbidden
case "NOT_FOUND":
return http.StatusNotFound
case "DEVICE_LIMIT_EXCEEDED":
return http.StatusForbidden
case "BAD_REQUEST":
return http.StatusBadRequest
case "INTERNAL_ERROR":
return http.StatusInternalServerError
default:
return http.StatusBadRequest
}
}
+385
View File
@@ -0,0 +1,385 @@
package devices
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/wangjia/pangolin/server/internal/apierr"
)
// CredentialRevoker abstracts the node-side credential recall triggered when a
// device is removed.
//
// The data-plane credential model is per-user (users.dp_uuid, doc/02 §3.2): the
// node side has no device dimension, so "revoke a device" is realised as
// "recall/re-issue the owning user's credential". The interface is defined here
// (consumer side) to avoid an import cycle with the nodes package; module #5's
// nodes.Hub will satisfy it, with RevokeForUser fronting Hub.Push(RevokeCredential).
type CredentialRevoker interface {
// RevokeForUser instructs node agents to recall (and lazily re-issue) the
// data-plane credential for userID. reason is a short machine tag, e.g.
// "device_deleted", used for audit/telemetry on the node side.
RevokeForUser(ctx context.Context, userID int64, reason string) error
}
// NoopRevoker is the default CredentialRevoker used until module #5 lands.
// It records the last call so callers/tests can assert on it.
type NoopRevoker struct {
Calls []RevokeCall
}
// RevokeCall captures the arguments of a RevokeForUser invocation.
type RevokeCall struct {
UserID int64
Reason string
}
// RevokeForUser implements CredentialRevoker by recording the call.
func (n *NoopRevoker) RevokeForUser(_ context.Context, userID int64, reason string) error {
n.Calls = append(n.Calls, RevokeCall{UserID: userID, Reason: reason})
return nil
}
// Service implements the devices business logic.
type Service struct {
store *Store
revoker CredentialRevoker
}
// NewService creates a Service. If revoker is nil a NoopRevoker is used so the
// module can be wired and tested before module #5 (nodes.Hub) is available.
func NewService(store *Store, revoker CredentialRevoker) *Service {
if revoker == nil {
revoker = &NoopRevoker{}
}
return &Service{store: store, revoker: revoker}
}
// Device is the API representation of a registered device.
type Device struct {
UUID string `json:"uuid"`
Name string `json:"name"`
Platform string `json:"platform"`
LastSeen *string `json:"last_seen"` // RFC 3339 UTC; null when never seen
}
func toAPIDevice(d DeviceRow) Device {
out := Device{UUID: d.UUID, Name: d.Name, Platform: d.Platform}
if d.LastSeen.Valid {
s := d.LastSeen.Time.UTC().Format(time.RFC3339)
out.LastSeen = &s
}
return out
}
// ListDevices returns the user's devices.
func (svc *Service) ListDevices(ctx context.Context, userID int64) ([]Device, *apierr.Error) {
rows, err := svc.store.ListByUser(ctx, userID)
if err != nil {
return nil, apierr.ErrInternal
}
out := make([]Device, 0, len(rows))
for _, r := range rows {
out = append(out, toAPIDevice(r))
}
return out, nil
}
// RegisterInput carries the inputs for implicit device registration, called by
// nodes.connect when a client presents a device_id.
type RegisterInput struct {
UserID int64
DeviceUUID string
Name string // client-reported, may come from UA; truncated to 64 runes
Platform string // ios | android | windows | macos
MaxDevices int // plan cap, from the resolved Plan (PlanFromCtx)
}
// RegisterIfAbsent registers a device on first sight and refreshes last_seen on
// subsequent sights. The device count is checked against MaxDevices before
// inserting a brand-new device. Per-user serialization is achieved by locking
// the users row for the duration of the transaction.
func (svc *Service) RegisterIfAbsent(ctx context.Context, in RegisterInput) (*Device, *apierr.Error) {
uuid := strings.TrimSpace(in.DeviceUUID)
if uuid == "" {
return nil, apierr.ErrBadRequest
}
platform, ok := normalizePlatform(in.Platform)
if !ok {
return nil, apierr.ErrBadRequest
}
name := normalizeName(in.Name, platform)
tx, err := svc.store.BeginTx(ctx)
if err != nil {
return nil, apierr.ErrInternal
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
// Lock the owning user to serialize concurrent registrations.
exists, status, err := svc.store.lockUser(ctx, tx, in.UserID)
if err != nil {
return nil, apierr.ErrInternal
}
if !exists {
return nil, apierr.ErrUnauthorized
}
if status == "banned" {
return nil, apierr.ErrAccountBanned
}
existing, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid)
if err != nil {
return nil, apierr.ErrInternal
}
if existing != nil {
if existing.UserID != in.UserID {
// UUID is client-generated; a collision across users is treated as
// a conflict rather than silently rebinding the device.
return nil, apierr.ErrForbidden
}
if err := svc.store.touchLastSeenTx(ctx, tx, existing.ID); err != nil {
return nil, apierr.ErrInternal
}
if err := tx.Commit(); err != nil {
return nil, apierr.ErrInternal
}
committed = true
d := toAPIDevice(*existing)
return &d, nil
}
// New device: enforce the plan device cap.
count, err := svc.store.countDevicesTx(ctx, tx, in.UserID)
if err != nil {
return nil, apierr.ErrInternal
}
if in.MaxDevices > 0 && count >= in.MaxDevices {
return nil, errDeviceLimit(in.MaxDevices)
}
row, err := svc.store.insertDeviceTx(ctx, tx, uuid, in.UserID, name, platform)
if err != nil {
return nil, apierr.ErrInternal
}
if err := svc.store.writeAuditLogTx(ctx, tx,
fmt.Sprintf("user:%d", in.UserID), "device.register", "device:"+uuid,
deviceAuditMeta(name, platform)); err != nil {
_ = err // audit failure must not abort the business transaction
}
if err := tx.Commit(); err != nil {
return nil, apierr.ErrInternal
}
committed = true
d := toAPIDevice(*row)
return &d, nil
}
// DeleteDevice hard-deletes a device (transactionally, with an audit_log entry)
// and then triggers per-user credential recall on the node side.
//
// - device not found → 404 NOT_FOUND
// - device owned by another user → 403 FORBIDDEN (does not delete)
func (svc *Service) DeleteDevice(ctx context.Context, userID int64, deviceUUID string) *apierr.Error {
uuid := strings.TrimSpace(deviceUUID)
if uuid == "" {
return apierr.ErrBadRequest
}
tx, err := svc.store.BeginTx(ctx)
if err != nil {
return apierr.ErrInternal
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
dev, err := svc.store.findDeviceByUUIDTx(ctx, tx, uuid)
if err != nil {
return apierr.ErrInternal
}
if dev == nil {
return apierr.ErrNotFound
}
if dev.UserID != userID {
return apierr.ErrForbidden
}
if err := svc.store.deleteDeviceTx(ctx, tx, dev.ID); err != nil {
return apierr.ErrInternal
}
if err := svc.store.writeAuditLogTx(ctx, tx,
fmt.Sprintf("user:%d", userID), "device.delete", "device:"+uuid,
deviceAuditMeta(dev.Name, dev.Platform)); err != nil {
_ = err // audit failure must not abort the delete
}
if err := tx.Commit(); err != nil {
return apierr.ErrInternal
}
committed = true
// Recall the user's data-plane credential. Per the dp_uuid model this is a
// per-user operation (no device dimension on the node side). Best-effort:
// the device is already removed; a transient revoke failure is reconciled
// by the node agent's periodic resync, so we do not fail the request.
if err := svc.revoker.RevokeForUser(ctx, userID, "device_deleted"); err != nil {
_ = err
}
return nil
}
// SubscriptionInfo is the /v1/me subscription summary provided by this module.
type SubscriptionInfo struct {
PlanCode string `json:"plan_code"`
ExpiresAt *string `json:"expires_at"` // RFC 3339 UTC; null for free fallback
Source string `json:"source"`
}
// SubscriptionSummary resolves the user's effective plan and returns the /v1/me
// subscription summary.
func (svc *Service) SubscriptionSummary(ctx context.Context, userID int64) (*SubscriptionInfo, *apierr.Error) {
p, apiErr := svc.ResolvePlan(ctx, userID)
if apiErr != nil {
return nil, apiErr
}
info := &SubscriptionInfo{PlanCode: p.PlanCode, Source: p.Source}
if p.ExpiresAt != nil {
s := p.ExpiresAt.UTC().Format(time.RFC3339)
info.ExpiresAt = &s
}
return info, nil
}
// ResolvePlan loads the user's status + subscriptions and resolves the effective
// plan. It is the shared entry point used by SubscriptionMiddleware and
// SubscriptionSummary.
func (svc *Service) ResolvePlan(ctx context.Context, userID int64) (Plan, *apierr.Error) {
status, exists, err := svc.store.GetUserStatus(ctx, userID)
if err != nil {
return Plan{}, apierr.ErrInternal
}
if !exists {
return Plan{}, apierr.ErrUnauthorized
}
subs, err := svc.store.GetSubscriptions(ctx, userID)
if err != nil {
return Plan{}, apierr.ErrInternal
}
free, err := svc.store.GetFreePlan(ctx)
if err != nil {
return Plan{}, apierr.ErrInternal
}
return resolveEffectivePlan(time.Now().UTC(), status, subs, free)
}
// resolveEffectivePlan is the pure plan-resolution rule (no I/O), unit-tested
// directly:
//
// - banned account → ErrAccountBanned
// - among subscriptions with expires_at>now, pick the highest tier;
// ties broken by the latest expiry
// - no active subscription → free fallback (ExpiresAt nil)
//
// now must be in UTC. The boundary is strict: expires_at == now counts as expired.
func resolveEffectivePlan(now time.Time, status string, subs []effSub, free Plan) (Plan, *apierr.Error) {
if status == "banned" {
return Plan{}, apierr.ErrAccountBanned
}
var best *effSub
for i := range subs {
s := &subs[i]
if !s.ExpiresAt.After(now) {
continue // expired (strict UTC boundary)
}
if best == nil {
best = s
continue
}
bt, st := planTier(best.PlanCode), planTier(s.PlanCode)
if st > bt || (st == bt && s.ExpiresAt.After(best.ExpiresAt)) {
best = s
}
}
if best == nil {
return free, nil
}
exp := best.ExpiresAt.UTC()
p := Plan{
PlanCode: best.PlanCode,
ExpiresAt: &exp,
MaxDevices: best.MaxDevices,
AdGate: best.AdGate,
Source: best.Source,
}
if best.DailyMinutes.Valid {
m := int(best.DailyMinutes.Int64)
p.DailyMinutes = &m
}
return p, nil
}
// --------------------------------------------------------------------------
// helpers
// --------------------------------------------------------------------------
// normalizePlatform validates/canonicalizes a platform string against the
// devices.platform ENUM (ios/android/windows/macos). Note: the OpenAPI Device
// schema also lists "linux", but the DB ENUM (migration #1) does not include
// it, so "linux" is rejected until the schema is widened.
func normalizePlatform(s string) (string, bool) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "ios":
return "ios", true
case "android":
return "android", true
case "windows":
return "windows", true
case "macos":
return "macos", true
}
return "", false
}
// normalizeName trims the client-reported name, falls back to the platform when
// empty, and truncates to the devices.name VARCHAR(64) limit (by rune).
func normalizeName(name, platform string) string {
name = strings.TrimSpace(name)
if name == "" {
name = platform
}
r := []rune(name)
if len(r) > 64 {
name = string(r[:64])
}
return name
}
// deviceAuditMeta builds a compact JSON meta blob for audit_log.
func deviceAuditMeta(name, platform string) string {
b, _ := json.Marshal(map[string]string{"name": name, "platform": platform})
return string(b)
}
// errDeviceLimit builds a bilingual device-cap error that embeds the limit.
func errDeviceLimit(max int) *apierr.Error {
return &apierr.Error{
Code: "DEVICE_LIMIT_EXCEEDED",
MessageZH: fmt.Sprintf("设备数量已达上限(%d 台),请先在其他设备上退出后重试", max),
MessageEn: fmt.Sprintf("Device limit reached (%d devices). Please remove another device and try again.", max),
}
}
+256
View File
@@ -0,0 +1,256 @@
package devices
import (
"context"
"database/sql"
"strings"
"testing"
"time"
)
// freePlan is the standard free fallback used in resolver tests
// (numbers per design/CLAUDE.md §7).
func freePlan() Plan {
m := 10
return Plan{PlanCode: "free", MaxDevices: 1, DailyMinutes: &m, AdGate: true, Source: "free"}
}
func mkSub(code string, maxDevices int, expiresAt time.Time, source string) effSub {
return effSub{PlanCode: code, MaxDevices: maxDevices, ExpiresAt: expiresAt, Source: source}
}
func TestResolveEffectivePlan_TrialActiveGivesPro(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
subs := []effSub{mkSub("pro", 5, now.Add(24*time.Hour), "trial")}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.PlanCode != "pro" || got.MaxDevices != 5 {
t.Fatalf("want pro/5, got %s/%d", got.PlanCode, got.MaxDevices)
}
if got.Source != "trial" {
t.Errorf("want source trial, got %s", got.Source)
}
if got.ExpiresAt == nil {
t.Fatal("expected non-nil expires_at")
}
}
func TestResolveEffectivePlan_ExpiredFallsBackToFree(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
// pro trial expired one second ago.
subs := []effSub{mkSub("pro", 5, now.Add(-time.Second), "trial")}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.PlanCode != "free" {
t.Fatalf("want free, got %s", got.PlanCode)
}
if got.ExpiresAt != nil {
t.Errorf("free fallback should have nil expires_at, got %v", *got.ExpiresAt)
}
if got.MaxDevices != 1 {
t.Errorf("want free max_devices 1, got %d", got.MaxDevices)
}
}
func TestResolveEffectivePlan_BannedRejected(t *testing.T) {
now := time.Now().UTC()
subs := []effSub{mkSub("pro", 5, now.Add(time.Hour), "trial")}
_, apiErr := resolveEffectivePlan(now, "banned", subs, freePlan())
if apiErr == nil {
t.Fatal("expected banned to be rejected")
}
if apiErr.Code != "ACCOUNT_BANNED" {
t.Errorf("want ACCOUNT_BANNED, got %s", apiErr.Code)
}
}
func TestResolveEffectivePlan_HighestTierWins(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
subs := []effSub{
mkSub("pro", 5, now.Add(100*24*time.Hour), "code"), // longer pro
mkSub("team", 10, now.Add(24*time.Hour), "code"), // shorter team
mkSub("free", 1, now.Add(50*24*time.Hour), "trial"),
}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
// Highest tier (team) wins even though pro expires later.
if got.PlanCode != "team" || got.MaxDevices != 10 {
t.Fatalf("want team/10, got %s/%d", got.PlanCode, got.MaxDevices)
}
}
func TestResolveEffectivePlan_SameTierLatestExpiryWins(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
early := now.Add(24 * time.Hour)
late := now.Add(48 * time.Hour)
subs := []effSub{
mkSub("pro", 5, early, "code"),
mkSub("pro", 5, late, "code"),
}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.ExpiresAt == nil || !got.ExpiresAt.Equal(late) {
t.Fatalf("want latest expiry %v, got %v", late, got.ExpiresAt)
}
}
func TestResolveEffectivePlan_UTCBoundaryStrict(t *testing.T) {
now := time.Date(2026, 6, 13, 12, 0, 0, 0, time.UTC)
// expires_at exactly == now must count as expired (strict >).
subs := []effSub{mkSub("pro", 5, now, "trial")}
got, apiErr := resolveEffectivePlan(now, "active", subs, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.PlanCode != "free" {
t.Fatalf("expires_at == now should be expired; want free, got %s", got.PlanCode)
}
// One microsecond after now is still active.
subs2 := []effSub{mkSub("pro", 5, now.Add(time.Microsecond), "trial")}
got2, _ := resolveEffectivePlan(now, "active", subs2, freePlan())
if got2.PlanCode != "pro" {
t.Fatalf("expires_at just after now should be active; want pro, got %s", got2.PlanCode)
}
}
func TestResolveEffectivePlan_NoSubsGivesFree(t *testing.T) {
got, apiErr := resolveEffectivePlan(time.Now().UTC(), "active", nil, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
if got.PlanCode != "free" || got.Source != "free" {
t.Fatalf("want free/free, got %s/%s", got.PlanCode, got.Source)
}
}
func TestResolveEffectivePlan_DailyMinutesPropagated(t *testing.T) {
now := time.Now().UTC()
s := mkSub("pro", 5, now.Add(time.Hour), "code")
s.DailyMinutes = sql.NullInt64{} // pro: unlimited
got, _ := resolveEffectivePlan(now, "active", []effSub{s}, freePlan())
if got.DailyMinutes != nil {
t.Errorf("pro daily_minutes should be nil (unlimited), got %d", *got.DailyMinutes)
}
free := resolveFree(t, now)
if free.DailyMinutes == nil || *free.DailyMinutes != 10 {
t.Errorf("free daily_minutes should be 10, got %v", free.DailyMinutes)
}
}
func resolveFree(t *testing.T, now time.Time) Plan {
t.Helper()
got, apiErr := resolveEffectivePlan(now, "active", nil, freePlan())
if apiErr != nil {
t.Fatalf("unexpected error: %v", apiErr)
}
return got
}
func TestCheckDeviceQuota(t *testing.T) {
free := Plan{PlanCode: "free", MaxDevices: 1}
pro := Plan{PlanCode: "pro", MaxDevices: 5}
if err := CheckDeviceQuota(free, 0); err != nil {
t.Errorf("0 < 1 should pass, got %v", err)
}
if err := CheckDeviceQuota(free, 1); err == nil {
t.Error("1 >= 1 should be rejected")
} else if err.Code != "DEVICE_LIMIT_EXCEEDED" {
t.Errorf("want DEVICE_LIMIT_EXCEEDED, got %s", err.Code)
}
if err := CheckDeviceQuota(pro, 4); err != nil {
t.Errorf("4 < 5 should pass, got %v", err)
}
if err := CheckDeviceQuota(pro, 5); err == nil {
t.Error("5 >= 5 should be rejected")
}
}
func TestErrDeviceLimitContainsNumber(t *testing.T) {
e := errDeviceLimit(5)
if !strings.Contains(e.MessageZH, "5") {
t.Errorf("zh message should contain the limit 5: %q", e.MessageZH)
}
if !strings.Contains(e.MessageEn, "5") {
t.Errorf("en message should contain the limit 5: %q", e.MessageEn)
}
}
func TestRequirePaidTier(t *testing.T) {
if err := RequirePaidTier(Plan{PlanCode: "free"}); err == nil {
t.Error("free should require paid tier")
} else if err.Code != "PAID_TIER_REQUIRED" {
t.Errorf("want PAID_TIER_REQUIRED, got %s", err.Code)
}
if err := RequirePaidTier(Plan{PlanCode: "pro"}); err != nil {
t.Errorf("pro should pass, got %v", err)
}
if err := RequirePaidTier(Plan{PlanCode: "team"}); err != nil {
t.Errorf("team should pass, got %v", err)
}
}
func TestNormalizePlatform(t *testing.T) {
cases := map[string]bool{
"ios": true, "iOS": true, "ANDROID": true, "windows": true, "macos": true,
"linux": false, "": false, "blackberry": false,
}
for in, wantOK := range cases {
_, ok := normalizePlatform(in)
if ok != wantOK {
t.Errorf("normalizePlatform(%q) ok=%v, want %v", in, ok, wantOK)
}
}
}
func TestNormalizeName(t *testing.T) {
if got := normalizeName("", "ios"); got != "ios" {
t.Errorf("empty name should fall back to platform, got %q", got)
}
if got := normalizeName(" iPhone 15 ", "ios"); got != "iPhone 15" {
t.Errorf("name should be trimmed, got %q", got)
}
long := strings.Repeat("名", 100)
if got := normalizeName(long, "ios"); len([]rune(got)) != 64 {
t.Errorf("name should be truncated to 64 runes, got %d", len([]rune(got)))
}
}
func TestUserIDContextRoundTrip(t *testing.T) {
ctx := WithUserID(context.Background(), 42)
id, ok := UserIDFromContext(ctx)
if !ok || id != 42 {
t.Fatalf("want 42/true, got %d/%v", id, ok)
}
if _, ok := UserIDFromContext(context.Background()); ok {
t.Error("empty context should yield ok=false")
}
// userID 0 is treated as absent.
if _, ok := UserIDFromContext(WithUserID(context.Background(), 0)); ok {
t.Error("userID 0 should be treated as absent")
}
}
func TestNoopRevokerRecordsCalls(t *testing.T) {
n := &NoopRevoker{}
_ = n.RevokeForUser(context.Background(), 7, "device_deleted")
if len(n.Calls) != 1 || n.Calls[0].UserID != 7 || n.Calls[0].Reason != "device_deleted" {
t.Fatalf("unexpected calls: %+v", n.Calls)
}
}
+221
View File
@@ -0,0 +1,221 @@
package devices
import (
"context"
"database/sql"
"fmt"
"time"
)
// DeviceRow mirrors a `devices` table row.
type DeviceRow struct {
ID int64
UUID string
UserID int64
Name string
Platform string
LastSeen sql.NullTime
CreatedAt time.Time
}
// effSub is an active-or-expired subscription joined with its plan, used by the
// pure plan resolver. expiry filtering is performed in Go (resolveEffectivePlan)
// so the UTC boundary logic is unit-testable without a database.
type effSub struct {
PlanCode string
MaxDevices int
DailyMinutes sql.NullInt64
AdGate bool
ExpiresAt time.Time
Source string
}
// Store wraps a *sql.DB and exposes the database operations the devices module
// needs. Methods that take a *sql.Tx run within that transaction.
type Store struct {
db *sql.DB
}
// NewStore creates a Store backed by the given MySQL connection pool.
func NewStore(db *sql.DB) *Store { return &Store{db: db} }
// BeginTx starts a transaction at Read Committed isolation. Per-user
// serialization for device mutations is achieved by locking the users row
// (SELECT ... FOR UPDATE) inside the transaction.
func (s *Store) BeginTx(ctx context.Context) (*sql.Tx, error) {
return s.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
}
// --------------------------------------------------------------------------
// Device queries
// --------------------------------------------------------------------------
// ListByUser returns all devices for userID ordered by creation time.
func (s *Store) ListByUser(ctx context.Context, userID int64) ([]DeviceRow, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT id, uuid, user_id, name, platform, last_seen, created_at
FROM devices WHERE user_id=? ORDER BY created_at ASC`,
userID)
if err != nil {
return nil, fmt.Errorf("store.ListByUser: %w", err)
}
defer rows.Close()
var out []DeviceRow
for rows.Next() {
var d DeviceRow
if err := rows.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt); err != nil {
return nil, fmt.Errorf("store.ListByUser scan: %w", err)
}
out = append(out, d)
}
return out, rows.Err()
}
// findDeviceByUUIDTx looks up a device by UUID with FOR UPDATE inside tx.
// Returns (nil, nil) when the device does not exist.
func (s *Store) findDeviceByUUIDTx(ctx context.Context, tx *sql.Tx, uuid string) (*DeviceRow, error) {
row := tx.QueryRowContext(ctx,
`SELECT id, uuid, user_id, name, platform, last_seen, created_at
FROM devices WHERE uuid=? FOR UPDATE`, uuid)
var d DeviceRow
if err := row.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform, &d.LastSeen, &d.CreatedAt); err == sql.ErrNoRows {
return nil, nil
} else if err != nil {
return nil, fmt.Errorf("store.findDeviceByUUIDTx: %w", err)
}
return &d, nil
}
// lockUser locks the users row to serialize per-user device mutations and
// returns the user's status. Returns (false, "", nil) when the user is absent.
func (s *Store) lockUser(ctx context.Context, tx *sql.Tx, userID int64) (exists bool, status string, err error) {
row := tx.QueryRowContext(ctx, `SELECT status FROM users WHERE id=? FOR UPDATE`, userID)
if e := row.Scan(&status); e == sql.ErrNoRows {
return false, "", nil
} else if e != nil {
return false, "", fmt.Errorf("store.lockUser: %w", e)
}
return true, status, nil
}
// countDevicesTx counts a user's devices inside tx.
func (s *Store) countDevicesTx(ctx context.Context, tx *sql.Tx, userID int64) (int, error) {
var n int
if err := tx.QueryRowContext(ctx, `SELECT COUNT(1) FROM devices WHERE user_id=?`, userID).Scan(&n); err != nil {
return 0, fmt.Errorf("store.countDevicesTx: %w", err)
}
return n, nil
}
// insertDeviceTx inserts a new device row inside tx and returns it.
func (s *Store) insertDeviceTx(ctx context.Context, tx *sql.Tx, uuid string, userID int64, name, platform string) (*DeviceRow, error) {
res, err := tx.ExecContext(ctx,
`INSERT INTO devices (uuid, user_id, name, platform, last_seen, created_at)
VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6))`,
uuid, userID, name, platform)
if err != nil {
return nil, fmt.Errorf("store.insertDeviceTx: %w", err)
}
id, _ := res.LastInsertId()
return &DeviceRow{ID: id, UUID: uuid, UserID: userID, Name: name, Platform: platform}, nil
}
// touchLastSeenTx updates a device's last_seen to now inside tx.
func (s *Store) touchLastSeenTx(ctx context.Context, tx *sql.Tx, deviceID int64) error {
_, err := tx.ExecContext(ctx,
`UPDATE devices SET last_seen=UTC_TIMESTAMP(6) WHERE id=?`, deviceID)
if err != nil {
return fmt.Errorf("store.touchLastSeenTx: %w", err)
}
return nil
}
// deleteDeviceTx hard-deletes a device row inside tx.
func (s *Store) deleteDeviceTx(ctx context.Context, tx *sql.Tx, deviceID int64) error {
if _, err := tx.ExecContext(ctx, `DELETE FROM devices WHERE id=?`, deviceID); err != nil {
return fmt.Errorf("store.deleteDeviceTx: %w", err)
}
return nil
}
// --------------------------------------------------------------------------
// Subscription / plan queries (for the subscription middleware & /me summary)
// --------------------------------------------------------------------------
// GetUserStatus returns a user's account status ("active"/"banned").
// exists is false when no such user row is present.
func (s *Store) GetUserStatus(ctx context.Context, userID int64) (status string, exists bool, err error) {
row := s.db.QueryRowContext(ctx, `SELECT status FROM users WHERE id=?`, userID)
if e := row.Scan(&status); e == sql.ErrNoRows {
return "", false, nil
} else if e != nil {
return "", false, fmt.Errorf("store.GetUserStatus: %w", e)
}
return status, true, nil
}
// GetSubscriptions returns all subscriptions for userID joined with their plan.
// Expiry filtering is intentionally left to resolveEffectivePlan.
func (s *Store) GetSubscriptions(ctx context.Context, userID int64) ([]effSub, error) {
rows, err := s.db.QueryContext(ctx,
`SELECT p.code, p.max_devices, p.daily_minutes, p.ad_gate, s.expires_at, s.source
FROM subscriptions s JOIN plans p ON p.id=s.plan_id
WHERE s.user_id=?`, userID)
if err != nil {
return nil, fmt.Errorf("store.GetSubscriptions: %w", err)
}
defer rows.Close()
var out []effSub
for rows.Next() {
var e effSub
if err := rows.Scan(&e.PlanCode, &e.MaxDevices, &e.DailyMinutes, &e.AdGate, &e.ExpiresAt, &e.Source); err != nil {
return nil, fmt.Errorf("store.GetSubscriptions scan: %w", err)
}
out = append(out, e)
}
return out, rows.Err()
}
// GetFreePlan loads the free plan row (the no-subscription fallback).
func (s *Store) GetFreePlan(ctx context.Context) (Plan, error) {
row := s.db.QueryRowContext(ctx,
`SELECT max_devices, daily_minutes, ad_gate FROM plans WHERE code='free'`)
var maxDevices int
var dailyMinutes sql.NullInt64
var adGate bool
if err := row.Scan(&maxDevices, &dailyMinutes, &adGate); err != nil {
return Plan{}, fmt.Errorf("store.GetFreePlan: %w", err)
}
p := Plan{
PlanCode: "free",
MaxDevices: maxDevices,
AdGate: adGate,
Source: "free",
}
if dailyMinutes.Valid {
m := int(dailyMinutes.Int64)
p.DailyMinutes = &m
}
return p, nil
}
// --------------------------------------------------------------------------
// Audit log
// --------------------------------------------------------------------------
// writeAuditLogTx inserts an audit_log row inside tx.
func (s *Store) writeAuditLogTx(ctx context.Context, tx *sql.Tx, actor, action, target, metaJSON string) error {
if metaJSON == "" {
metaJSON = "null"
}
_, err := tx.ExecContext(ctx,
`INSERT INTO audit_log (actor, action, target, meta, at)
VALUES (?, ?, ?, ?, UTC_TIMESTAMP(6))`,
actor, action, target, metaJSON)
if err != nil {
return fmt.Errorf("store.writeAuditLogTx: %w", err)
}
return nil
}