feat(server/devices): users.max_devices_override 单用户设备上限覆盖(NULL走套餐默认)+ 迁移000025
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 16s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 39s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 19s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 36s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 5m8s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 13m37s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 16s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 39s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 19s
ci-pangolin / Flutter — analyze + test (pull_request) Successful in 36s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 3s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 4s
ci-pangolin / Go — build + test (pull_request) Failing after 13s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Failing after 10s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Failing after 5m8s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Failing after 19s
ci-pangolin / Lint — shellcheck (pull_request) Failing after 13m37s
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01P9G7E3wmAYL9KeYCVZVsqu
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package devices
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/config"
|
||||
"github.com/wangjia/pangolin/server/internal/store"
|
||||
)
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// SQLite fixture for migration 000025 (users.max_devices_override). No
|
||||
// container needed — modernc.org/sqlite is pure Go — so these run in normal
|
||||
// CI alongside run_sqlite_test.sh's data-layer tests.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func openOverrideTestDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
cfg := &config.Config{Driver: "sqlite", DSN: ":memory:"}
|
||||
db, err := store.Open(cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("store.Open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { db.Close() })
|
||||
if err := store.MigrateUp(db, "sqlite"); err != nil {
|
||||
t.Fatalf("MigrateUp: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
// insertOverrideTestUser inserts an active user with an optional
|
||||
// max_devices_override (NULL when override.Valid is false).
|
||||
func insertOverrideTestUser(t *testing.T, db *sql.DB, email string, override sql.NullInt64) int64 {
|
||||
t.Helper()
|
||||
var (
|
||||
res sql.Result
|
||||
err error
|
||||
)
|
||||
if override.Valid {
|
||||
res, err = db.Exec(
|
||||
`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status, max_devices_override)
|
||||
VALUES (?, ?, 'x', ?, 'active', ?)`,
|
||||
"uuid-"+email, email, "dp-"+email, override.Int64)
|
||||
} else {
|
||||
res, err = db.Exec(
|
||||
`INSERT INTO users (uuid, email, pw_hash, dp_uuid, status)
|
||||
VALUES (?, ?, 'x', ?, 'active')`,
|
||||
"uuid-"+email, email, "dp-"+email)
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("insert user %s: %v", email, err)
|
||||
}
|
||||
id, err := res.LastInsertId()
|
||||
if err != nil {
|
||||
t.Fatalf("last insert id: %v", err)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
// giveOverrideTestSubscription inserts an active subscription for userID on
|
||||
// the given seeded plan code (free/pro/team, from migration 000007).
|
||||
func giveOverrideTestSubscription(t *testing.T, db *sql.DB, userID int64, planCode, source string, expiresAt time.Time) {
|
||||
t.Helper()
|
||||
var planID int64
|
||||
if err := db.QueryRow(`SELECT id FROM plans WHERE code=?`, planCode).Scan(&planID); err != nil {
|
||||
t.Fatalf("plan lookup %s: %v", planCode, 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("give subscription: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// insertOverrideTestDevice inserts an active (recently-seen) device row so it
|
||||
// counts toward CountActiveDevices.
|
||||
func insertOverrideTestDevice(t *testing.T, db *sql.DB, userID int64, uuidSuffix string) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(
|
||||
`INSERT INTO devices (uuid, user_id, name, platform, last_seen) VALUES (?, ?, ?, ?, ?)`,
|
||||
"dev-"+uuidSuffix, userID, "Device "+uuidSuffix, "ios", time.Now().UTC()); err != nil {
|
||||
t.Fatalf("insert device: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// ResolvePlan: override applied at the single funnel (both the free fallback
|
||||
// and the subscription path flow through it).
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
// TestResolvePlan_FreeUserNoOverride: a free user with no override set gets
|
||||
// the plan's default cap (free = 1, per migration 000007 seed).
|
||||
func TestResolvePlan_FreeUserNoOverride(t *testing.T) {
|
||||
db := openOverrideTestDB(t)
|
||||
svc := NewService(NewStore(db), nil)
|
||||
userID := insertOverrideTestUser(t, db, "free-none@example.com", sql.NullInt64{})
|
||||
|
||||
plan, apiErr := svc.ResolvePlan(context.Background(), userID)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("ResolvePlan: %v", apiErr)
|
||||
}
|
||||
if plan.PlanCode != "free" {
|
||||
t.Errorf("PlanCode = %q, want free", plan.PlanCode)
|
||||
}
|
||||
if plan.MaxDevices != 1 {
|
||||
t.Errorf("MaxDevices = %d, want 1 (free plan default, no override)", plan.MaxDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePlan_FreeUserWithOverride: same free user, override=6 → the
|
||||
// free-fallback path (GetFreePlan) must be overridden too.
|
||||
func TestResolvePlan_FreeUserWithOverride(t *testing.T) {
|
||||
db := openOverrideTestDB(t)
|
||||
svc := NewService(NewStore(db), nil)
|
||||
userID := insertOverrideTestUser(t, db, "free-override@example.com", sql.NullInt64{Int64: 6, Valid: true})
|
||||
|
||||
plan, apiErr := svc.ResolvePlan(context.Background(), userID)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("ResolvePlan: %v", apiErr)
|
||||
}
|
||||
if plan.PlanCode != "free" {
|
||||
t.Errorf("PlanCode = %q, want free (override must not change plan identity)", plan.PlanCode)
|
||||
}
|
||||
if plan.MaxDevices != 6 {
|
||||
t.Errorf("MaxDevices = %d, want 6 (override)", plan.MaxDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePlan_ProSubscriptionOverrideWins: a pro-subscription user
|
||||
// (plan default max_devices=3) with override=2 — the subscription path
|
||||
// (resolveEffectivePlan's "best" branch) must also be overridden, even
|
||||
// though the override is *smaller* than the plan's own cap.
|
||||
func TestResolvePlan_ProSubscriptionOverrideWins(t *testing.T) {
|
||||
db := openOverrideTestDB(t)
|
||||
svc := NewService(NewStore(db), nil)
|
||||
userID := insertOverrideTestUser(t, db, "pro-override@example.com", sql.NullInt64{Int64: 2, Valid: true})
|
||||
giveOverrideTestSubscription(t, db, userID, "pro", "trial", time.Now().UTC().Add(24*time.Hour))
|
||||
|
||||
plan, apiErr := svc.ResolvePlan(context.Background(), userID)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("ResolvePlan: %v", apiErr)
|
||||
}
|
||||
if plan.PlanCode != "pro" {
|
||||
t.Errorf("PlanCode = %q, want pro", plan.PlanCode)
|
||||
}
|
||||
if plan.MaxDevices != 2 {
|
||||
t.Errorf("MaxDevices = %d, want 2 (override wins over pro plan's own cap of 3)", plan.MaxDevices)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolvePlan_NullOrZeroOverrideDoesNotApply: NULL and 0 both mean
|
||||
// "no override" — the plan-derived cap must stand.
|
||||
func TestResolvePlan_NullOrZeroOverrideDoesNotApply(t *testing.T) {
|
||||
db := openOverrideTestDB(t)
|
||||
svc := NewService(NewStore(db), nil)
|
||||
|
||||
nullUser := insertOverrideTestUser(t, db, "null-override@example.com", sql.NullInt64{})
|
||||
zeroUser := insertOverrideTestUser(t, db, "zero-override@example.com", sql.NullInt64{Int64: 0, Valid: true})
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
userID int64
|
||||
}{
|
||||
{"NULL", nullUser},
|
||||
{"zero", zeroUser},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
plan, apiErr := svc.ResolvePlan(context.Background(), tc.userID)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("ResolvePlan: %v", apiErr)
|
||||
}
|
||||
if plan.MaxDevices != 1 {
|
||||
t.Errorf("MaxDevices = %d, want 1 (no override should apply)", plan.MaxDevices)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// CheckDeviceLimit: behavioral assertion using the existing rig (it calls
|
||||
// ResolvePlan internally) — override raises the effective cap so a device
|
||||
// count that would be rejected under the free default is no longer flagged.
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
func TestCheckDeviceLimit_OverrideAllowsMoreDevicesThanFreeCap(t *testing.T) {
|
||||
db := openOverrideTestDB(t)
|
||||
svc := NewService(NewStore(db), nil)
|
||||
userID := insertOverrideTestUser(t, db, "backstop@example.com", sql.NullInt64{Int64: 6, Valid: true})
|
||||
|
||||
// Free plan cap is 1; without the override 3 active devices would be
|
||||
// rejected (Over=true). The override raises the cap to 6.
|
||||
for i := 0; i < 3; i++ {
|
||||
insertOverrideTestDevice(t, db, userID, fmt.Sprintf("%d", i))
|
||||
}
|
||||
|
||||
status, apiErr := svc.CheckDeviceLimit(context.Background(), userID)
|
||||
if apiErr != nil {
|
||||
t.Fatalf("CheckDeviceLimit: %v", apiErr)
|
||||
}
|
||||
if status.MaxDevices != 6 {
|
||||
t.Errorf("MaxDevices = %d, want 6", status.MaxDevices)
|
||||
}
|
||||
if status.Over {
|
||||
t.Errorf("Over = true, want false (override=6 covers 3 active devices; free default of 1 would reject them)")
|
||||
}
|
||||
}
|
||||
@@ -450,7 +450,21 @@ func (svc *Service) ResolvePlan(ctx context.Context, userID int64) (Plan, *apier
|
||||
if err != nil {
|
||||
return Plan{}, apierr.ErrInternal
|
||||
}
|
||||
return resolveEffectivePlan(time.Now().UTC(), status, subs, free)
|
||||
plan, apiErr := resolveEffectivePlan(time.Now().UTC(), status, subs, free)
|
||||
if apiErr != nil {
|
||||
return Plan{}, apiErr
|
||||
}
|
||||
// Per-user device-cap override (migration 000025, users.max_devices_override):
|
||||
// NULL/0 = no override, keep the plan-derived cap resolved above. Applied
|
||||
// once here — the single funnel both the subscription path and the free
|
||||
// fallback flow through — so every consumer (subscription middleware,
|
||||
// /v1/me, CheckDeviceLimit) sees it uniformly.
|
||||
if override, ok, err := svc.store.GetMaxDevicesOverride(ctx, userID); err != nil {
|
||||
return Plan{}, apierr.ErrInternal
|
||||
} else if ok {
|
||||
plan.MaxDevices = override
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// CheckDeviceLimit prunes stale devices (best-effort churn cleanup), resolves the
|
||||
|
||||
@@ -299,6 +299,26 @@ func (s *Store) GetFreePlan(ctx context.Context) (Plan, error) {
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetMaxDevicesOverride returns the per-user device-cap override set by
|
||||
// migration 000025 (users.max_devices_override). NULL or <=0 means "no
|
||||
// override" (ok=false) — the caller should fall back to the plan-derived cap.
|
||||
// A positive value always wins, even if it is *smaller* than the plan's cap,
|
||||
// so operators can also tighten a specific account.
|
||||
func (s *Store) GetMaxDevicesOverride(ctx context.Context, userID int64) (int, bool, error) {
|
||||
var override sql.NullInt64
|
||||
row := s.db.QueryRowContext(ctx, `SELECT max_devices_override FROM users WHERE id=?`, userID)
|
||||
if err := row.Scan(&override); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return 0, false, nil
|
||||
}
|
||||
return 0, false, fmt.Errorf("store.GetMaxDevicesOverride: %w", err)
|
||||
}
|
||||
if !override.Valid || override.Int64 <= 0 {
|
||||
return 0, false, nil
|
||||
}
|
||||
return int(override.Int64), true, nil
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Audit log
|
||||
// --------------------------------------------------------------------------
|
||||
|
||||
@@ -104,10 +104,14 @@ func TestCodesLibMigrateRoundTrip(t *testing.T) {
|
||||
// the reviewer's repro hit: the lib's `codes` table collides with the
|
||||
// name 000022's down script renames legacy_codes back to.
|
||||
m := newSQLiteStepper(t, db)
|
||||
// 000024 (invite_rewards) and 000023 (pay_purchases/source-enum) now sit
|
||||
// on top of 000022 (codes_lib_legacy_rename) and are unrelated to this
|
||||
// collision — step them back down first so we land exactly on the
|
||||
// 000022 boundary the test targets.
|
||||
// 000025 (user_device_limit_override), 000024 (invite_rewards) and 000023
|
||||
// (pay_purchases/source-enum) now sit on top of 000022
|
||||
// (codes_lib_legacy_rename) and are unrelated to this collision — step
|
||||
// them back down first so we land exactly on the 000022 boundary the
|
||||
// test targets.
|
||||
if err := m.Steps(-1); err != nil {
|
||||
t.Fatalf("step 000025 down: %v", err)
|
||||
}
|
||||
if err := m.Steps(-1); err != nil {
|
||||
t.Fatalf("step 000024 down: %v", err)
|
||||
}
|
||||
|
||||
@@ -29,8 +29,8 @@ func TestSQLiteMigrateUpDown(t *testing.T) {
|
||||
if dirty {
|
||||
t.Fatalf("schema dirty after MigrateUp")
|
||||
}
|
||||
if v != 24 {
|
||||
t.Errorf("version = %d, want 24", v)
|
||||
if v != 25 {
|
||||
t.Errorf("version = %d, want 25", v)
|
||||
}
|
||||
|
||||
// 2. Core tables exist.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users DROP COLUMN max_devices_override;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 单用户设备上限覆盖:NULL=走套餐(plans.max_devices)默认;设为正整数则以它为准。
|
||||
ALTER TABLE users ADD COLUMN max_devices_override INT NULL;
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users DROP COLUMN max_devices_override;
|
||||
@@ -0,0 +1,2 @@
|
||||
-- 单用户设备上限覆盖:NULL=走套餐(plans.max_devices)默认;设为正整数则以它为准。
|
||||
ALTER TABLE users ADD COLUMN max_devices_override INTEGER;
|
||||
Reference in New Issue
Block a user