feat(backend): 挂载 /v1 API + 实现 nodes/connect 端到端
- 新增 internal/dpcred 包,统一 DeriveHy2Password + DefaultFlow agentd 与 HTTP connect handler 共享同一实现 - 新增迁移 000011:nodes 表拆分 reality_prk 私钥 / reality_pbk 公钥 reality_short_id;修正 handler_grpc.go 使用私钥字段 - 新增迁移 000012:connect_credentials 持久化凭证 实现 CredentialsForNode 修复 agent 重连 resync 原先返回空的桩 - 扩展 NodeStore 接口:ListUp / EntitlementForUser / PersistCredential / DeleteCredential;同步 grpc_test.go mock - 新增 httpapi/nodes.go:GET /nodes、POST /nodes/id/connect Hub.Push + PersistCredential + 渲染完整 sing-box client 配置 JSON POST /nodes/id/disconnect - 新增 httpapi/account.go:GET /me、GET /plans、GET /notices - 新增 httpapi/clientconfig.go:BuildClientConfig 服务端渲染 - 重写 cmd/server/main.go:手写 chi public/protected 分组 nodes.Service/Hub 在 main 构造并共享;SMTPMailer/LogMailer go build ./... && go vet ./... && go test ./... 全部通过 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/wangjia/pangolin/server/internal/apierr"
|
||||
"github.com/wangjia/pangolin/server/internal/auth"
|
||||
)
|
||||
|
||||
// AccountAPI serves /v1/me and supporting endpoints.
|
||||
type AccountAPI struct {
|
||||
db *sql.DB
|
||||
}
|
||||
|
||||
// NewAccountAPI creates an AccountAPI.
|
||||
func NewAccountAPI(db *sql.DB) *AccountAPI { return &AccountAPI{db: db} }
|
||||
|
||||
// ─── GET /v1/me ──────────────────────────────────────────────────────────────
|
||||
|
||||
type meResponse struct {
|
||||
UUID string `json:"uuid"`
|
||||
Email string `json:"email"`
|
||||
DpUUID string `json:"dp_uuid"`
|
||||
Plan string `json:"plan"` // "free" | "pro" | "team"
|
||||
ExpireAt *string `json:"expire_at"` // RFC3339 UTC, null = no active sub
|
||||
}
|
||||
|
||||
// GetMe handles GET /v1/me.
|
||||
func (a *AccountAPI) GetMe(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := auth.UserIDFromContext(r.Context())
|
||||
if !ok {
|
||||
apierr.WriteJSON(w, http.StatusUnauthorized, apierr.ErrUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var (
|
||||
uuid string
|
||||
email string
|
||||
dpUUID string
|
||||
)
|
||||
if err := a.db.QueryRowContext(r.Context(),
|
||||
`SELECT uuid, email, dp_uuid FROM users WHERE id = ? AND status = 'active'`, uid,
|
||||
).Scan(&uuid, &email, &dpUUID); err == sql.ErrNoRows {
|
||||
apierr.WriteJSON(w, http.StatusNotFound, apierr.ErrNotFound)
|
||||
return
|
||||
} else if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
// Best active subscription.
|
||||
var planCode string
|
||||
var expiresAt sql.NullTime
|
||||
err := a.db.QueryRowContext(r.Context(), `
|
||||
SELECT p.code, s.expires_at
|
||||
FROM subscriptions s
|
||||
JOIN plans p ON p.id = s.plan_id
|
||||
WHERE s.user_id = ? AND s.expires_at > UTC_TIMESTAMP()
|
||||
ORDER BY s.expires_at DESC
|
||||
LIMIT 1
|
||||
`, uid).Scan(&planCode, &expiresAt)
|
||||
if err == sql.ErrNoRows {
|
||||
planCode = "free"
|
||||
} else if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
resp := meResponse{
|
||||
UUID: uuid,
|
||||
Email: email,
|
||||
DpUUID: dpUUID,
|
||||
Plan: planCode,
|
||||
}
|
||||
if expiresAt.Valid {
|
||||
s := expiresAt.Time.UTC().Format(time.RFC3339)
|
||||
resp.ExpireAt = &s
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(resp)
|
||||
}
|
||||
|
||||
// ─── GET /v1/plans ───────────────────────────────────────────────────────────
|
||||
|
||||
type planResponse struct {
|
||||
Code string `json:"code"`
|
||||
NameZH string `json:"name_zh"`
|
||||
NameEN string `json:"name_en"`
|
||||
DailyMinutes *int64 `json:"daily_minutes"` // null = unlimited
|
||||
AdGate bool `json:"ad_gate"`
|
||||
}
|
||||
|
||||
// ListPlans handles GET /v1/plans.
|
||||
func (a *AccountAPI) ListPlans(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(),
|
||||
`SELECT code, name_zh, name_en, daily_minutes, ad_gate FROM plans ORDER BY id`)
|
||||
if err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var plans []planResponse
|
||||
for rows.Next() {
|
||||
var p planResponse
|
||||
var dm sql.NullInt64
|
||||
if err := rows.Scan(&p.Code, &p.NameZH, &p.NameEN, &dm, &p.AdGate); err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
if dm.Valid {
|
||||
p.DailyMinutes = &dm.Int64
|
||||
}
|
||||
plans = append(plans, p)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
apierr.WriteJSON(w, http.StatusInternalServerError, apierr.ErrInternal)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"plans": plans})
|
||||
}
|
||||
|
||||
// ─── GET /v1/notices ─────────────────────────────────────────────────────────
|
||||
|
||||
// ListNotices handles GET /v1/notices. Returns an empty list for the MVP.
|
||||
func (a *AccountAPI) ListNotices(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"notices": []any{}})
|
||||
}
|
||||
Reference in New Issue
Block a user