bcc114088c
ci-pangolin / Lint — shellcheck (push) Successful in 9s
ci-pangolin / OpenAPI Sync Check (push) Successful in 17s
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (push) Successful in 5s
ci-pangolin / Flutter — analyze + test (push) Successful in 26s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (push) Successful in 5s
ci-pangolin / Codegen Drift — token 生成物未漂移 (push) Successful in 5s
ci-pangolin / Go — build + test (push) Successful in 12s
ci-pangolin / E2E Smoke — L4 进程级端到端 (push) Successful in 15s
ci-pangolin / Go — integration (mysql/redis testcontainers) (push) Failing after 4m4s
ci-pangolin / Golden — 视觉回归 (components + auth) (push) Successful in 15s
后端:新端点 POST /v1/me/devices/{uuid}/logout(ForceLogout:吊销该设备会话+
丢 Redis JTI,设备留列表)。DeleteDevice 增强:先吊销会话再删设备(FK ON DELETE
CASCADE 清理会话行)+ 按 dp_uuid 吊销数据面凭证。CredentialRevoker 接口改
per-device RevokeDevice(dpUUID),由 nodes.Service 实现(查 connect_credentials
持有节点→推 CommandTypeRevoke + 删凭证行),main 注入替 NoopRevoker;devices 注入
SessionPort/JTIRevoker。修 SQLite 跨连接死锁(会话吊销移到 delete tx 之前)。
migration 000016 sessions FK 加 ON DELETE CASCADE。
客户端:account_api.forceLogout + devicesProvider.forceLogout(UI 留 P6)。
测试:ForceLogout(吊销会话+JTI+设备保留+403/404)+ DeleteDevice(级联+按 dp_uuid
吊销);NoopRevoker 改 dp_uuid;全量 server/flutter 测试绿。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
270 lines
9.4 KiB
Go
270 lines
9.4 KiB
Go
package devices
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"fmt"
|
|
"time"
|
|
|
|
dbx "github.com/wangjia/pangolin/server/internal/db"
|
|
)
|
|
|
|
// 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
|
|
ClientVersion sql.NullString // 000016: latest reported app version
|
|
DpUUID sql.NullString // per-device data-plane credential (000015)
|
|
}
|
|
|
|
// 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
|
|
dialect dbx.Dialect
|
|
}
|
|
|
|
// NewStore creates a Store backed by the given connection pool (MySQL or SQLite).
|
|
func NewStore(db *sql.DB) *Store { return &Store{db: db, dialect: dbx.DialectForDB(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, client_version
|
|
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, &d.ClientVersion); 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, client_version, dp_uuid
|
|
FROM devices WHERE uuid=? `+s.dialect.LockForUpdate(), uuid)
|
|
return scanDeviceRow(row)
|
|
}
|
|
|
|
// FindByUUID looks up a device by UUID (non-tx). Returns (nil, nil) if absent.
|
|
// Used by force-logout/delete to resolve ownership + dp_uuid.
|
|
func (s *Store) FindByUUID(ctx context.Context, uuid string) (*DeviceRow, error) {
|
|
row := s.db.QueryRowContext(ctx,
|
|
`SELECT id, uuid, user_id, name, platform, last_seen, created_at, client_version, dp_uuid
|
|
FROM devices WHERE uuid=?`, uuid)
|
|
return scanDeviceRow(row)
|
|
}
|
|
|
|
type rowScanner interface {
|
|
Scan(dest ...any) error
|
|
}
|
|
|
|
func scanDeviceRow(row rowScanner) (*DeviceRow, error) {
|
|
var d DeviceRow
|
|
if err := row.Scan(&d.ID, &d.UUID, &d.UserID, &d.Name, &d.Platform,
|
|
&d.LastSeen, &d.CreatedAt, &d.ClientVersion, &d.DpUUID); err == sql.ErrNoRows {
|
|
return nil, nil
|
|
} else if err != nil {
|
|
return nil, fmt.Errorf("store.scanDeviceRow: %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=? `+s.dialect.LockForUpdate(), 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, clientVersion string) (*DeviceRow, error) {
|
|
now := time.Now().UTC()
|
|
cv := nullStr(clientVersion)
|
|
res, err := tx.ExecContext(ctx,
|
|
`INSERT INTO devices (uuid, user_id, name, platform, last_seen, created_at, client_version)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
uuid, userID, name, platform, now, now, cv)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("store.insertDeviceTx: %w", err)
|
|
}
|
|
id, _ := res.LastInsertId()
|
|
// 回填 last_seen/created_at:库里已写入 now,返回值也要带上,否则刚注册的
|
|
// 设备 API 响应 last_seen=null 与库不一致(集成测试 TestFullChain 据此把关)。
|
|
return &DeviceRow{
|
|
ID: id, UUID: uuid, UserID: userID, Name: name, Platform: platform,
|
|
LastSeen: sql.NullTime{Time: now, Valid: true},
|
|
CreatedAt: now,
|
|
ClientVersion: sql.NullString{String: clientVersion, Valid: clientVersion != ""},
|
|
}, nil
|
|
}
|
|
|
|
// touchLastSeenTx updates a device's last_seen to now inside tx, and refreshes
|
|
// client_version when a non-empty one is supplied (latest reported wins).
|
|
func (s *Store) touchLastSeenTx(ctx context.Context, tx *sql.Tx, deviceID int64, clientVersion string) error {
|
|
now := time.Now().UTC()
|
|
var err error
|
|
if clientVersion != "" {
|
|
_, err = tx.ExecContext(ctx,
|
|
`UPDATE devices SET last_seen=?, client_version=? WHERE id=?`, now, clientVersion, deviceID)
|
|
} else {
|
|
_, err = tx.ExecContext(ctx,
|
|
`UPDATE devices SET last_seen=? WHERE id=?`, now, deviceID)
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("store.touchLastSeenTx: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// nullStr maps "" to SQL NULL.
|
|
func nullStr(s string) any {
|
|
if s == "" {
|
|
return nil
|
|
}
|
|
return s
|
|
}
|
|
|
|
// 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 (?, ?, ?, ?, ?)`,
|
|
actor, action, target, metaJSON, time.Now().UTC())
|
|
if err != nil {
|
|
return fmt.Errorf("store.writeAuditLogTx: %w", err)
|
|
}
|
|
return nil
|
|
}
|