c9e266b89a
ci-pangolin / Redline Scan — 脱敏 (UI 文案) (pull_request) Successful in 23s
ci-pangolin / Cleartext Scan — Android 禁明文 (pull_request) Successful in 20s
ci-pangolin / Portable SQL — 可移植性 (mysql/sqlite) (pull_request) Successful in 20s
ci-pangolin / Lint — shellcheck (pull_request) Successful in 7s
ci-pangolin / OpenAPI Sync Check (pull_request) Successful in 33s
ci-pangolin / Flutter — analyze + test (pull_request) Failing after 16s
ci-pangolin / Codegen Drift — token 生成物未漂移 (pull_request) Successful in 4s
ci-pangolin / DS-flow — 原型/跨端同源/代码色单源闸 (pull_request) Successful in 5s
ci-pangolin / Go — build + test (pull_request) Successful in 8s
ci-pangolin / E2E Smoke — L4 进程级端到端 (pull_request) Successful in 8s
ci-pangolin / Go — integration (mysql/redis testcontainers) (pull_request) Successful in 4m33s
ci-pangolin / Golden — 视觉回归 (全量:components/auth/desktop/tablet) (pull_request) Successful in 19s
DinD 修复后暴露的两个 CI job,诊断: Go integration(真 test-drift bug,早前会话改动遗留): - auth/integration_test:Register/Login 补 ip + DeviceMeta 参数(sessions/ device-meta 改动后陈旧调用,构建失败)。 - usage/usage_integration_test:手写测试 schema 补 ad_bonus_minutes 列 (migration 000020 加的);重写 TestIntAdsUnlockAccumulates 断言对齐 ad-unlock 转累加式(UnlockAd→AddAdBonusMinutes,不再 stamp ad_unlocked_at)。 - devices/devices_integration_test:套餐种子 pro=5→3(migration 000019 改的)。 - devices/context.go(生产 1 行):CtxKeyUserID 别名到 codes.CtxKeyUserID——原为 独立 devices.ctxKey 类型,与 auth 注入的 codes.ctxKey 类型不同→context 取键 失配(休眠 bug,中间件目前仅测试接线)。go build 通过。 E2E(环境问题,非脚本):删 ci.yml 里多余的 apt-get(openssl/curl/python3 已在 golang:1.25 镜像内;原 apt 走 Docker Desktop 代理→本机死口,徒增脆性)。脚本 本身本机直跑通过。 验证:go test -tags integration -count=1 -p 1 ./... 全 ok;go build ./... clean。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
85 lines
2.8 KiB
Go
85 lines
2.8 KiB
Go
package devices
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/wangjia/pangolin/server/internal/codes"
|
|
)
|
|
|
|
// 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 (auth.RequireAuth).
|
|
//
|
|
// It is the single canonical key shared with the codes and auth modules
|
|
// (auth.UserIDFromContext reads codes.CtxKeyUserID). Aliasing it here — rather
|
|
// than declaring a distinct devices.ctxKey("user_id") — ensures the devices
|
|
// middleware/handlers resolve the same value the auth middleware injects.
|
|
const CtxKeyUserID = codes.CtxKeyUserID
|
|
|
|
// 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
|
|
}
|
|
}
|