feat(server): web 用户中心后端(1/3) — GetMe 补字段 + logout + redeem 路径别名 + 用户表 totp/sub_token
为 web 用户中心接通真后端做准备(保持 snake_case 不破 app): - migration 000013:users 加 sub_token / totp_secret_enc / totp_enabled。 - GetMe 扩展:补 devices_used/devices_max/quota_today_min/data_today_gb/ weekly_gb/totp_enabled/expires_at(聚合 plans+usage_daily+devices+totp 列), 保留原 expire_at 等字段不破 app。 - POST /v1/auth/logout:X-Refresh-Token 头 → RevokeRefresh,幂等 204。 - /v1/me/redeem 别名(web 用),保留 /v1/redeem(app 用)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -270,6 +270,7 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
|||||||
v1.Post("/auth/register", authHandler.Register)
|
v1.Post("/auth/register", authHandler.Register)
|
||||||
v1.Post("/auth/login", authHandler.Login)
|
v1.Post("/auth/login", authHandler.Login)
|
||||||
v1.Post("/auth/refresh", authHandler.Refresh)
|
v1.Post("/auth/refresh", authHandler.Refresh)
|
||||||
|
v1.Post("/auth/logout", authHandler.Logout)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Webhook: HMAC-authenticated, no JWT.
|
// Webhook: HMAC-authenticated, no JWT.
|
||||||
@@ -283,6 +284,8 @@ func mountV1(r chi.Router, sqlDB *sql.DB, rdb *redis.Client, nodeSvc *nodes.Serv
|
|||||||
protected.Route("/me", func(me chi.Router) {
|
protected.Route("/me", func(me chi.Router) {
|
||||||
me.Get("/", accountAPI.GetMe) // 子路由根,避免与 Route("/me") 冲突致 404
|
me.Get("/", accountAPI.GetMe) // 子路由根,避免与 Route("/me") 冲突致 404
|
||||||
devicesHandler.RegisterRoutes(me)
|
devicesHandler.RegisterRoutes(me)
|
||||||
|
// Web 用户中心调用 /v1/me/redeem;app 仍用 /v1/redeem。两者同处理器。
|
||||||
|
me.Post("/redeem", redeemHandler.ServeHTTP)
|
||||||
})
|
})
|
||||||
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
protected.Post("/redeem", redeemHandler.ServeHTTP)
|
||||||
protected.Get("/usage", usageHandler.ServeHTTP)
|
protected.Get("/usage", usageHandler.ServeHTTP)
|
||||||
|
|||||||
@@ -28,6 +28,16 @@ func (h *Handler) RegisterRoutes(r chi.Router) {
|
|||||||
r.Post("/auth/register", h.Register)
|
r.Post("/auth/register", h.Register)
|
||||||
r.Post("/auth/login", h.Login)
|
r.Post("/auth/login", h.Login)
|
||||||
r.Post("/auth/refresh", h.Refresh)
|
r.Post("/auth/refresh", h.Refresh)
|
||||||
|
r.Post("/auth/logout", h.Logout)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout handles POST /v1/auth/logout. The refresh token to revoke is taken from
|
||||||
|
// the X-Refresh-Token header (the access token in Authorization is not enough —
|
||||||
|
// revocation keys on the refresh JTI). Always 204; the client clears its session
|
||||||
|
// regardless of the server-side outcome.
|
||||||
|
func (h *Handler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.svc.Logout(r.Context(), r.Header.Get("X-Refresh-Token"))
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- request/response bodies (mirror openapi.yaml) ----
|
// ---- request/response bodies (mirror openapi.yaml) ----
|
||||||
|
|||||||
@@ -261,6 +261,15 @@ func (s *Service) Login(ctx context.Context, rawEmail, password, ip string) (*To
|
|||||||
return pair, 0, nil
|
return pair, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Logout revokes the given refresh token. Idempotent: an empty, unknown, or
|
||||||
|
// malformed token is a no-op (the client clears its own session regardless).
|
||||||
|
func (s *Service) Logout(ctx context.Context, refreshToken string) {
|
||||||
|
if refreshToken == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = s.tokens.RevokeRefresh(ctx, refreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
// Refresh validates and rotates a refresh token.
|
// Refresh validates and rotates a refresh token.
|
||||||
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, *apierr.Error) {
|
func (s *Service) Refresh(ctx context.Context, refreshToken string) (*TokenPair, *apierr.Error) {
|
||||||
if refreshToken == "" {
|
if refreshToken == "" {
|
||||||
|
|||||||
@@ -234,6 +234,17 @@ func (tm *TokenManager) Revoke(ctx context.Context, jti string) error {
|
|||||||
return tm.rdb.Del(ctx, refreshKeyPrefix+jti).Err()
|
return tm.rdb.Del(ctx, refreshKeyPrefix+jti).Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RevokeRefresh parses a refresh token and revokes its JTI. Used by logout.
|
||||||
|
// Returns an error only when the token is structurally invalid; a missing or
|
||||||
|
// already-rotated JTI is a no-op (logout is idempotent).
|
||||||
|
func (tm *TokenManager) RevokeRefresh(ctx context.Context, refreshToken string) error {
|
||||||
|
claims, err := tm.parse(refreshToken, typRefresh)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return tm.Revoke(ctx, claims.ID)
|
||||||
|
}
|
||||||
|
|
||||||
// whitelist stores the refresh JTI with the refresh TTL.
|
// whitelist stores the refresh JTI with the refresh TTL.
|
||||||
func (tm *TokenManager) whitelist(ctx context.Context, jti string, userID int64) error {
|
func (tm *TokenManager) whitelist(ctx context.Context, jti string, userID int64) error {
|
||||||
if err := tm.rdb.Set(ctx, refreshKeyPrefix+jti, userID, tm.refreshTTL).Err(); err != nil {
|
if err := tm.rdb.Set(ctx, refreshKeyPrefix+jti, userID, tm.refreshTTL).Err(); err != nil {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package httpapi
|
package httpapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -21,29 +22,43 @@ func NewAccountAPI(db *sql.DB) *AccountAPI { return &AccountAPI{db: db} }
|
|||||||
// ─── GET /v1/me ──────────────────────────────────────────────────────────────
|
// ─── GET /v1/me ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type meResponse struct {
|
type meResponse struct {
|
||||||
UUID string `json:"uuid"`
|
UUID string `json:"uuid"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
DpUUID string `json:"dp_uuid"`
|
DpUUID string `json:"dp_uuid"`
|
||||||
Plan string `json:"plan"` // "free" | "pro" | "team"
|
Plan string `json:"plan"` // "free" | "pro" | "team"
|
||||||
ExpireAt *string `json:"expire_at"` // RFC3339 UTC, null = no active sub
|
// expire_at kept for the existing app; expires_at is the same value for the
|
||||||
|
// web user-center (both RFC3339 UTC, null = no active sub).
|
||||||
|
ExpireAt *string `json:"expire_at"`
|
||||||
|
ExpiresAt *string `json:"expires_at"`
|
||||||
|
// Web user-center fields.
|
||||||
|
DevicesUsed int `json:"devices_used"`
|
||||||
|
DevicesMax int `json:"devices_max"`
|
||||||
|
QuotaTodayMin *int `json:"quota_today_min"` // null = unlimited (pro/team)
|
||||||
|
DataTodayGB float64 `json:"data_today_gb"`
|
||||||
|
WeeklyGB []float64 `json:"weekly_gb"` // last 7 days, oldest→newest
|
||||||
|
TOTPEnabled bool `json:"totp_enabled"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bytesPerGB = 1024.0 * 1024.0 * 1024.0
|
||||||
|
|
||||||
// GetMe handles GET /v1/me.
|
// GetMe handles GET /v1/me.
|
||||||
func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) {
|
func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||||
uid, ok := auth.UserIDFromContext(r.Context())
|
ctx := r.Context()
|
||||||
|
uid, ok := auth.UserIDFromContext(ctx)
|
||||||
if !ok {
|
if !ok {
|
||||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
uuid string
|
uuid string
|
||||||
email string
|
email string
|
||||||
dpUUID string
|
dpUUID string
|
||||||
|
totpEnabled bool
|
||||||
)
|
)
|
||||||
if err := a.db.QueryRowContext(r.Context(),
|
if err := a.db.QueryRowContext(ctx,
|
||||||
`SELECT uuid, email, dp_uuid FROM users WHERE id = ? AND status = 'active'`, uid,
|
`SELECT uuid, email, dp_uuid, totp_enabled FROM users WHERE id = ? AND status = 'active'`, uid,
|
||||||
).Scan(&uuid, &email, &dpUUID); err == sql.ErrNoRows {
|
).Scan(&uuid, &email, &dpUUID, &totpEnabled); err == sql.ErrNoRows {
|
||||||
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
|
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
|
||||||
return
|
return
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
@@ -51,39 +66,105 @@ func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Best active subscription.
|
// Best active subscription → plan code + limits + expiry. Falls back to free.
|
||||||
var planCode string
|
var (
|
||||||
var expiresAt sql.NullTime
|
planCode string
|
||||||
err := a.db.QueryRowContext(r.Context(), `
|
maxDevices int
|
||||||
SELECT p.code, s.expires_at
|
dailyMinutes sql.NullInt64
|
||||||
|
expiresAt sql.NullTime
|
||||||
|
)
|
||||||
|
err := a.db.QueryRowContext(ctx, `
|
||||||
|
SELECT p.code, p.max_devices, p.daily_minutes, s.expires_at
|
||||||
FROM subscriptions s
|
FROM subscriptions s
|
||||||
JOIN plans p ON p.id = s.plan_id
|
JOIN plans p ON p.id = s.plan_id
|
||||||
WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP()
|
WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP()
|
||||||
ORDER BY s.expires_at DESC
|
ORDER BY s.expires_at DESC
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`, uid).Scan(&planCode, &expiresAt)
|
`, uid).Scan(&planCode, &maxDevices, &dailyMinutes, &expiresAt)
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
planCode = "free"
|
planCode = "free"
|
||||||
|
if err := a.db.QueryRowContext(ctx,
|
||||||
|
`SELECT max_devices, daily_minutes FROM plans WHERE code = 'free'`,
|
||||||
|
).Scan(&maxDevices, &dailyMinutes); err != nil && err != sql.ErrNoRows {
|
||||||
|
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||||
|
return
|
||||||
|
}
|
||||||
} else if err != nil {
|
} else if err != nil {
|
||||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var devicesUsed int
|
||||||
|
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM devices WHERE user_id = ?`, uid).Scan(&devicesUsed)
|
||||||
|
|
||||||
|
// Today's usage (UTC). Missing row → zero.
|
||||||
|
var todayBytes uint64
|
||||||
|
var todayMinutes int
|
||||||
|
_ = a.db.QueryRowContext(ctx, `
|
||||||
|
SELECT COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0), COALESCE(minutes_used, 0)
|
||||||
|
FROM usage_daily WHERE user_id = ? AND date = UTC_DATE()
|
||||||
|
`, uid).Scan(&todayBytes, &todayMinutes)
|
||||||
|
|
||||||
resp := meResponse{
|
resp := meResponse{
|
||||||
UUID: uuid,
|
UUID: uuid,
|
||||||
Email: email,
|
Email: email,
|
||||||
DpUUID: dpUUID,
|
DpUUID: dpUUID,
|
||||||
Plan: planCode,
|
Plan: planCode,
|
||||||
|
DevicesUsed: devicesUsed,
|
||||||
|
DevicesMax: maxDevices,
|
||||||
|
DataTodayGB: float64(todayBytes) / bytesPerGB,
|
||||||
|
WeeklyGB: a.weeklyGB(ctx, uid),
|
||||||
|
TOTPEnabled: totpEnabled,
|
||||||
}
|
}
|
||||||
if expiresAt.Valid {
|
if expiresAt.Valid {
|
||||||
s := expiresAt.Time.UTC().Format(time.RFC3339)
|
s := expiresAt.Time.UTC().Format(time.RFC3339)
|
||||||
resp.ExpireAt = &s
|
resp.ExpireAt = &s
|
||||||
|
resp.ExpiresAt = &s
|
||||||
|
}
|
||||||
|
// quota_today_min: null when the plan is unlimited, else remaining minutes.
|
||||||
|
if dailyMinutes.Valid {
|
||||||
|
rem := int(dailyMinutes.Int64) - todayMinutes
|
||||||
|
if rem < 0 {
|
||||||
|
rem = 0
|
||||||
|
}
|
||||||
|
resp.QuotaTodayMin = &rem
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(resp)
|
_ = json.NewEncoder(w).Encode(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// weeklyGB returns total GB per day for the last 7 UTC days (oldest→newest),
|
||||||
|
// filling days with no usage row as 0.
|
||||||
|
func (a *AccountAPI) weeklyGB(ctx context.Context, uid int64) []float64 {
|
||||||
|
out := make([]float64, 7)
|
||||||
|
rows, err := a.db.QueryContext(ctx, `
|
||||||
|
SELECT date, COALESCE(bytes_up, 0) + COALESCE(bytes_down, 0)
|
||||||
|
FROM usage_daily
|
||||||
|
WHERE user_id = ? AND date >= UTC_DATE() - INTERVAL 6 DAY
|
||||||
|
`, uid)
|
||||||
|
if err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
byDay := make(map[string]uint64, 7)
|
||||||
|
for rows.Next() {
|
||||||
|
var d time.Time
|
||||||
|
var b uint64
|
||||||
|
if err := rows.Scan(&d, &b); err != nil {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
byDay[d.UTC().Format("2006-01-02")] = b
|
||||||
|
}
|
||||||
|
now := time.Now().UTC()
|
||||||
|
for i := 0; i < 7; i++ {
|
||||||
|
day := now.AddDate(0, 0, -(6 - i)).Format("2006-01-02")
|
||||||
|
out[i] = float64(byDay[day]) / bytesPerGB
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// ─── GET /v1/plans ───────────────────────────────────────────────────────────
|
// ─── GET /v1/plans ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
type planResponse struct {
|
type planResponse struct {
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
ALTER TABLE users
|
||||||
|
DROP COLUMN totp_enabled,
|
||||||
|
DROP COLUMN totp_secret_enc,
|
||||||
|
DROP COLUMN sub_token;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
-- Web 用户中心所需的用户级字段:
|
||||||
|
-- sub_token 个人订阅 URL 的不可猜测 token(GET /v1/me/subscription 返回,
|
||||||
|
-- /sub/{token} 据此渲染该用户的 sing-box 订阅配置)。可轮换。
|
||||||
|
-- totp_secret_enc TOTP 密钥(AES-256-GCM 加密存储,复用 admin/crypto)。NULL=未设置。
|
||||||
|
-- totp_enabled 是否已启用两步验证(verify 通过后置 true)。
|
||||||
|
-- 全部可空/带默认,存量用户无需回填即生效(首次访问按需生成 sub_token)。
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN sub_token CHAR(36) NULL UNIQUE AFTER dp_uuid,
|
||||||
|
ADD COLUMN totp_secret_enc VARBINARY(255) NULL AFTER sub_token,
|
||||||
|
ADD COLUMN totp_enabled BOOLEAN NOT NULL DEFAULT FALSE AFTER totp_secret_enc;
|
||||||
Reference in New Issue
Block a user