feat(ECR-006): 落地运营后台 Phase A(admin API + admin-h5)
新增独立鉴权的 /api/v1/admin 与 Vue 控制台;会员授予与审计同事务,并补集成/单测。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -18,6 +18,13 @@ type Config struct {
|
||||
DatabaseURL string
|
||||
AppEnv string
|
||||
DeepSeek DeepSeekConfig
|
||||
Admin AdminConfig
|
||||
}
|
||||
|
||||
// AdminConfig for ops console bootstrap (ECR-006).
|
||||
type AdminConfig struct {
|
||||
BootstrapUsername string
|
||||
BootstrapPassword string
|
||||
}
|
||||
|
||||
// DeepSeekConfig for Ask LLM.
|
||||
@@ -47,6 +54,10 @@ type fileConfig struct {
|
||||
Model string `yaml:"model"`
|
||||
TimeoutSec int `yaml:"timeout_sec"`
|
||||
} `yaml:"deepseek"`
|
||||
Admin struct {
|
||||
BootstrapUsername string `yaml:"bootstrap_username"`
|
||||
BootstrapPassword string `yaml:"bootstrap_password"`
|
||||
} `yaml:"admin"`
|
||||
}
|
||||
|
||||
// Load reads config.local.yaml (or CONFIG_PATH), then applies env overrides.
|
||||
@@ -123,6 +134,12 @@ func mergeFile(cfg *Config, path string) error {
|
||||
if f.DeepSeek.TimeoutSec > 0 {
|
||||
cfg.DeepSeek.TimeoutSec = f.DeepSeek.TimeoutSec
|
||||
}
|
||||
if f.Admin.BootstrapUsername != "" {
|
||||
cfg.Admin.BootstrapUsername = f.Admin.BootstrapUsername
|
||||
}
|
||||
if f.Admin.BootstrapPassword != "" {
|
||||
cfg.Admin.BootstrapPassword = f.Admin.BootstrapPassword
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -184,6 +201,12 @@ func applyEnv(cfg *Config) {
|
||||
cfg.DeepSeek.TimeoutSec = n
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("ADMIN_BOOTSTRAP_USERNAME"); v != "" {
|
||||
cfg.Admin.BootstrapUsername = v
|
||||
}
|
||||
if v := os.Getenv("ADMIN_BOOTSTRAP_PASSWORD"); v != "" {
|
||||
cfg.Admin.BootstrapPassword = v
|
||||
}
|
||||
}
|
||||
|
||||
// Enabled reports whether DeepSeek can be called.
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// AdminHandler serves /api/v1/admin/* (no DeviceAuth).
|
||||
type AdminHandler struct {
|
||||
Svc *admin.Service
|
||||
}
|
||||
|
||||
// Register mounts public login + authed admin routes.
|
||||
func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
||||
g := api.Group("/admin")
|
||||
g.POST("/auth/login", h.Login)
|
||||
|
||||
authed := g.Group("")
|
||||
authed.Use(middleware.AdminAuth(h.Svc))
|
||||
authed.POST("/auth/logout", h.Logout)
|
||||
authed.GET("/me", h.Me)
|
||||
authed.GET("/users", h.ListUsers)
|
||||
authed.GET("/users/:id", h.GetUser)
|
||||
authed.POST("/users/:id/membership/grant", h.GrantMembership)
|
||||
authed.GET("/orders", h.ListOrders)
|
||||
authed.GET("/audit-logs", h.ListAudit)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
var body struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Username == "" || body.Password == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40001, "username and password required")
|
||||
return
|
||||
}
|
||||
res, err := h.Svc.Login(c.Request.Context(), body.Username, body.Password)
|
||||
if err != nil {
|
||||
if errors.Is(err, admin.ErrBadCredentials) {
|
||||
response.Fail(c, http.StatusUnauthorized, 40103, "invalid credentials")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50010, "login failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, res)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Logout(c *gin.Context) {
|
||||
token := middleware.BearerToken(c.GetHeader("Authorization"))
|
||||
_ = h.Svc.Logout(c.Request.Context(), token)
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Me(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
me, err := h.Svc.Me(c.Request.Context(), adminID)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50011, "me failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, me)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListUsers(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
items, err := h.Svc.ListUsers(c.Request.Context(), c.Query("q"), limit, offset)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50012, "list users failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GetUser(c *gin.Context) {
|
||||
id, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
|
||||
return
|
||||
}
|
||||
detail, err := h.Svc.GetUser(c.Request.Context(), id)
|
||||
if err != nil {
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50013, "get user failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, detail)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) GrantMembership(c *gin.Context) {
|
||||
adminID, ok := middleware.AdminIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
return
|
||||
}
|
||||
userID, err := uuid.Parse(c.Param("id"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
|
||||
return
|
||||
}
|
||||
var body admin.GrantInput
|
||||
if err := c.ShouldBindJSON(&body); err != nil || body.Plan == "" {
|
||||
response.Fail(c, http.StatusBadRequest, 40003, "plan required")
|
||||
return
|
||||
}
|
||||
if err := h.Svc.GrantMembership(c.Request.Context(), adminID, userID, body.Plan); err != nil {
|
||||
if errors.Is(err, admin.ErrInvalidPlan) {
|
||||
response.Fail(c, http.StatusBadRequest, 40004, "invalid plan")
|
||||
return
|
||||
}
|
||||
if errors.Is(err, admin.ErrUserNotFound) {
|
||||
response.Fail(c, http.StatusNotFound, 40401, "user not found")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusInternalServerError, 50014, "grant failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListOrders(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
items, err := h.Svc.ListOrders(c.Request.Context(), limit, offset)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50015, "list orders failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminHandler) ListAudit(c *gin.Context) {
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
||||
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
|
||||
items, err := h.Svc.ListAuditLogs(c.Request.Context(), limit, offset)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50016, "list audit failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"items": items})
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
package httpserver
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
@@ -10,6 +13,7 @@ import (
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/llm/deepseek"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
adminsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
||||
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
|
||||
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
|
||||
@@ -27,6 +31,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
reportRepo := &repository.ReportRepo{Pool: pool}
|
||||
relationRepo := &repository.RelationRepo{Pool: pool}
|
||||
askRepo := &repository.AskRepo{Pool: pool}
|
||||
adminRepo := &repository.AdminRepo{Pool: pool}
|
||||
|
||||
var llm *deepseek.Client
|
||||
if cfg.DeepSeek.Enabled() {
|
||||
@@ -49,6 +54,13 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
Reports: reportRepo,
|
||||
Quotas: &repository.ImageCardRepo{Pool: pool},
|
||||
}
|
||||
adminSvc := &adminsvc.Service{Repo: adminRepo, Reports: reportRepo}
|
||||
if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{
|
||||
Username: cfg.Admin.BootstrapUsername,
|
||||
Password: cfg.Admin.BootstrapPassword,
|
||||
}); err != nil {
|
||||
log.Printf("admin bootstrap failed: %v", err)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
r.Use(gin.Recovery(), gin.Logger(), middleware.RequestID())
|
||||
@@ -62,6 +74,7 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
|
||||
api.GET("/ping", func(c *gin.Context) {
|
||||
response.OK(c, gin.H{"pong": true})
|
||||
})
|
||||
(&handler.AdminHandler{Svc: adminSvc}).Register(api)
|
||||
|
||||
authed := api.Group("")
|
||||
authed.Use(middleware.DeviceAuth(pool))
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func doAdminJSON(t *testing.T, r http.Handler, method, path string, body any, token string) (envelope, int) {
|
||||
t.Helper()
|
||||
auth := ""
|
||||
if token != "" {
|
||||
auth = "Bearer " + token
|
||||
}
|
||||
return doAdminAuth(t, r, method, path, body, auth)
|
||||
}
|
||||
|
||||
func doAdminAuth(t *testing.T, r http.Handler, method, path string, body any, authorization string) (envelope, int) {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
if body != nil {
|
||||
if err := json.NewEncoder(&buf).Encode(body); err != nil {
|
||||
t.Fatalf("encode: %v", err)
|
||||
}
|
||||
}
|
||||
req := httptest.NewRequest(method, path, &buf)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
if authorization != "" {
|
||||
req.Header.Set("Authorization", authorization)
|
||||
}
|
||||
w := httptest.NewRecorder()
|
||||
r.ServeHTTP(w, req)
|
||||
var env envelope
|
||||
_ = json.Unmarshal(w.Body.Bytes(), &env)
|
||||
return env, w.Code
|
||||
}
|
||||
|
||||
func TestAdminOpsPhaseA(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
|
||||
env, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, "")
|
||||
if code != http.StatusUnauthorized || env.Code == 0 {
|
||||
t.Fatalf("expected 401 without token, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
||||
"username": "admin",
|
||||
"password": "change-me",
|
||||
}, "")
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("login failed http=%d code=%d msg=%s body=%s", code, env.Code, env.Message, string(env.Data))
|
||||
}
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
|
||||
t.Fatalf("login token missing: %v %s", err, env.Data)
|
||||
}
|
||||
|
||||
_, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list users failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var list struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &list); err != nil || len(list.Items) == 0 {
|
||||
t.Fatalf("expected users, got %v %s", err, env.Data)
|
||||
}
|
||||
userID := list.Items[0].ID
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/membership/grant", map[string]string{
|
||||
"plan": "month",
|
||||
}, login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("grant failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
|
||||
// Atomicity: membership active AND audit row for same grant.
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users/"+userID, nil, login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("get user failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var detail struct {
|
||||
Membership struct {
|
||||
Active bool `json:"active"`
|
||||
Status string `json:"status"`
|
||||
} `json:"membership"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &detail); err != nil || !detail.Membership.Active {
|
||||
t.Fatalf("expected active membership after grant: %v %s", err, env.Data)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs", nil, login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("audit failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var audit struct {
|
||||
Items []struct {
|
||||
Action string `json:"action"`
|
||||
TargetID string `json:"target_id"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &audit); err != nil || len(audit.Items) == 0 {
|
||||
t.Fatalf("expected audit rows: %v %s", err, env.Data)
|
||||
}
|
||||
if audit.Items[0].Action != "membership.grant" || audit.Items[0].TargetID != userID {
|
||||
t.Fatalf("unexpected audit %#v", audit.Items[0])
|
||||
}
|
||||
|
||||
// Logout with lowercase bearer must invalidate session.
|
||||
env, code = doAdminAuth(t, r, http.MethodPost, "/api/v1/admin/auth/logout", nil, "bearer "+login.Token)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("logout failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, login.Token)
|
||||
if code != http.StatusUnauthorized || env.Code == 0 {
|
||||
t.Fatalf("expected 401 after logout, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ func setupAPI(t *testing.T) (*gin.Engine, string) {
|
||||
t.Cleanup(cancel)
|
||||
|
||||
cfg := config.Load()
|
||||
cfg.Admin.BootstrapUsername = "admin"
|
||||
cfg.Admin.BootstrapPassword = "change-me"
|
||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (run npm run deps:up): %v", err)
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
const AdminIDKey ctxKey = "admin_id"
|
||||
const AdminTokenHeader = "Authorization"
|
||||
|
||||
// AdminSessionResolver looks up a valid admin session by token.
|
||||
type AdminSessionResolver interface {
|
||||
ResolveAdminID(ctx context.Context, token string) (uuid.UUID, error)
|
||||
}
|
||||
|
||||
// AdminAuth requires Bearer token for /admin routes.
|
||||
func AdminAuth(resolver AdminSessionResolver) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
token := BearerToken(c.GetHeader(AdminTokenHeader))
|
||||
if token == "" {
|
||||
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
adminID, err := resolver.ResolveAdminID(c.Request.Context(), token)
|
||||
if err != nil || adminID == uuid.Nil {
|
||||
response.Fail(c, http.StatusUnauthorized, 40102, "admin session invalid")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(string(AdminIDKey), adminID.String())
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// AdminIDFromContext returns the authenticated admin id.
|
||||
func AdminIDFromContext(c *gin.Context) (uuid.UUID, bool) {
|
||||
v, ok := c.Get(string(AdminIDKey))
|
||||
if !ok {
|
||||
return uuid.Nil, false
|
||||
}
|
||||
id, err := uuid.Parse(v.(string))
|
||||
return id, err == nil
|
||||
}
|
||||
|
||||
// BearerToken extracts an opaque token from Authorization (Bearer / bearer).
|
||||
func BearerToken(h string) string {
|
||||
h = strings.TrimSpace(h)
|
||||
if h == "" {
|
||||
return ""
|
||||
}
|
||||
lower := strings.ToLower(h)
|
||||
if strings.HasPrefix(lower, "bearer ") {
|
||||
return strings.TrimSpace(h[len("bearer "):])
|
||||
}
|
||||
return h
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package middleware
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestBearerToken(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"", ""},
|
||||
{"adm_abc", "adm_abc"},
|
||||
{"Bearer adm_x", "adm_x"},
|
||||
{"bearer adm_y", "adm_y"},
|
||||
{"BEARER adm_z", "adm_z"},
|
||||
{" Bearer adm_w ", "adm_w"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := BearerToken(tc.in); got != tc.want {
|
||||
t.Fatalf("BearerToken(%q)=%q want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// AdminRepo persists ops-admin accounts, sessions, and audit logs.
|
||||
type AdminRepo struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
// AdminAccount is an internal operator account.
|
||||
type AdminAccount struct {
|
||||
ID uuid.UUID
|
||||
Username string
|
||||
PasswordHash string
|
||||
Status string
|
||||
}
|
||||
|
||||
// CountAccounts returns non-deleted admin count.
|
||||
func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM admin_accounts WHERE deleted_at IS NULL`).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CreateAccount inserts an admin account.
|
||||
func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash)
|
||||
VALUES ($1,$2) RETURNING id`, username, hash).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
// FindByUsername loads an active admin by username.
|
||||
func (r *AdminRepo) FindByUsername(ctx context.Context, username string) (*AdminAccount, error) {
|
||||
var a AdminAccount
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_hash, status
|
||||
FROM admin_accounts
|
||||
WHERE username=$1 AND deleted_at IS NULL`, username,
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// FindAccountByID loads admin by id.
|
||||
func (r *AdminRepo) FindAccountByID(ctx context.Context, id uuid.UUID) (*AdminAccount, error) {
|
||||
var a AdminAccount
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT id, username, password_hash, status
|
||||
FROM admin_accounts
|
||||
WHERE id=$1 AND deleted_at IS NULL`, id,
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &a, nil
|
||||
}
|
||||
|
||||
// CreateSession stores an opaque admin session token.
|
||||
func (r *AdminRepo) CreateSession(ctx context.Context, adminID uuid.UUID, token string, expires time.Time) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO admin_sessions(admin_id, token, expires_at)
|
||||
VALUES ($1,$2,$3)`, adminID, token, expires)
|
||||
return err
|
||||
}
|
||||
|
||||
// ResolveSession returns admin_id for a valid token.
|
||||
func (r *AdminRepo) ResolveSession(ctx context.Context, token string) (uuid.UUID, error) {
|
||||
var id uuid.UUID
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT s.admin_id FROM admin_sessions s
|
||||
JOIN admin_accounts a ON a.id=s.admin_id AND a.deleted_at IS NULL AND a.status='active'
|
||||
WHERE s.token=$1 AND s.expires_at > now()`, token).Scan(&id)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return uuid.Nil, errors.New("invalid session")
|
||||
}
|
||||
return id, err
|
||||
}
|
||||
|
||||
// DeleteSession removes a session by token.
|
||||
func (r *AdminRepo) DeleteSession(ctx context.Context, token string) error {
|
||||
_, err := r.Pool.Exec(ctx, `DELETE FROM admin_sessions WHERE token=$1`, token)
|
||||
return err
|
||||
}
|
||||
|
||||
// InsertAudit appends an immutable audit row.
|
||||
func (r *AdminRepo) InsertAudit(ctx context.Context, adminID uuid.UUID, action, targetType, targetID string, meta json.RawMessage) error {
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,$2,$3,$4,$5)`, adminID, action, targetType, targetID, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
// UserListItem is a compact user row for admin tables.
|
||||
type UserListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListUsers returns users newest first; q matches id when UUID.
|
||||
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]UserListItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, status, created_at FROM users
|
||||
WHERE deleted_at IS NULL
|
||||
AND ($1 = '' OR id::text = $1)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, q, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []UserListItem
|
||||
for rows.Next() {
|
||||
var u UserListItem
|
||||
if err := rows.Scan(&u.ID, &u.Status, &u.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, u)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UserExists reports whether user id is present.
|
||||
func (r *AdminRepo) UserExists(ctx context.Context, id uuid.UUID) (bool, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT 1 FROM users WHERE id=$1 AND deleted_at IS NULL`, id).Scan(&n)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
// ProfileBrief for admin user detail.
|
||||
type ProfileBrief struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Relation string `json:"relation"`
|
||||
DisplayName string `json:"display_name"`
|
||||
}
|
||||
|
||||
// ListProfilesForUser returns profile briefs.
|
||||
func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) ([]ProfileBrief, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, relation, display_name FROM profiles
|
||||
WHERE user_id=$1 AND deleted_at IS NULL
|
||||
ORDER BY created_at ASC`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []ProfileBrief
|
||||
for rows.Next() {
|
||||
var p ProfileBrief
|
||||
if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// OrderListItem for admin order tables.
|
||||
type OrderListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Kind string `json:"kind"`
|
||||
Plan *string `json:"plan,omitempty"`
|
||||
AmountCents int `json:"amount_cents"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListOrders lists orders; optional user filter.
|
||||
func (r *AdminRepo) ListOrders(ctx context.Context, userID *uuid.UUID, limit, offset int) ([]OrderListItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, user_id, kind, plan, amount_cents, status, created_at
|
||||
FROM orders
|
||||
WHERE deleted_at IS NULL
|
||||
AND ($1::uuid IS NULL OR user_id = $1)
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $2 OFFSET $3`, userID, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []OrderListItem
|
||||
for rows.Next() {
|
||||
var o OrderListItem
|
||||
if err := rows.Scan(&o.ID, &o.UserID, &o.Kind, &o.Plan, &o.AmountCents, &o.Status, &o.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, o)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// GrantMembershipWithAudit upserts membership and appends audit in one transaction.
|
||||
func (r *AdminRepo) GrantMembershipWithAudit(
|
||||
ctx context.Context,
|
||||
adminID, userID uuid.UUID,
|
||||
plan string,
|
||||
days int,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO memberships(user_id, plan, status, expires_at, ask_quota_left)
|
||||
VALUES ($1,$2,'active', now() + ($3 * interval '1 day'), 100)
|
||||
ON CONFLICT (user_id) DO UPDATE SET
|
||||
plan=EXCLUDED.plan, status='active',
|
||||
expires_at=(CASE
|
||||
WHEN memberships.expires_at IS NOT NULL AND memberships.expires_at > now()
|
||||
THEN memberships.expires_at ELSE now()
|
||||
END) + ($3 * interval '1 day'),
|
||||
ask_quota_left=100, updated_at=now()`,
|
||||
userID, plan, days); err != nil {
|
||||
return err
|
||||
}
|
||||
if meta == nil {
|
||||
meta = json.RawMessage(`{}`)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO admin_audit_logs(admin_id, action, target_type, target_id, meta)
|
||||
VALUES ($1,'membership.grant','user',$2,$3)`, adminID, userID.String(), meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// AuditListItem for admin audit table.
|
||||
type AuditListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
AdminID uuid.UUID `json:"admin_id"`
|
||||
Action string `json:"action"`
|
||||
TargetType string `json:"target_type"`
|
||||
TargetID string `json:"target_id"`
|
||||
Meta json.RawMessage `json:"meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// ListAuditLogs returns newest audit rows.
|
||||
func (r *AdminRepo) ListAuditLogs(ctx context.Context, limit, offset int) ([]AuditListItem, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, admin_id, action, target_type, target_id, meta, created_at
|
||||
FROM admin_audit_logs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AuditListItem
|
||||
for rows.Next() {
|
||||
var a AuditListItem
|
||||
if err := rows.Scan(&a.ID, &a.AdminID, &a.Action, &a.TargetType, &a.TargetID, &a.Meta, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -91,11 +91,11 @@ func (r *ReportRepo) HasActiveMembership(ctx context.Context, userID uuid.UUID)
|
||||
|
||||
// MembershipRow is the current membership snapshot for a user.
|
||||
type MembershipRow struct {
|
||||
Plan string
|
||||
Status string
|
||||
ExpiresAt *time.Time
|
||||
AskQuotaLeft int
|
||||
Active bool
|
||||
Plan string `json:"plan,omitempty"`
|
||||
Status string `json:"status"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||
AskQuotaLeft int `json:"ask_quota_left,omitempty"`
|
||||
Active bool `json:"active"`
|
||||
}
|
||||
|
||||
// GetMembership returns membership status; missing row → inactive.
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
// Package admin implements ops-console use cases (ECR-006).
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
// Service is ops-admin application layer.
|
||||
type Service struct {
|
||||
Repo *repository.AdminRepo
|
||||
Reports *repository.ReportRepo
|
||||
}
|
||||
|
||||
// BootstrapConfig seeds the first admin when table is empty.
|
||||
type BootstrapConfig struct {
|
||||
Username string
|
||||
Password string
|
||||
}
|
||||
|
||||
// EnsureBootstrap creates the first admin from config when needed.
|
||||
func (s *Service) EnsureBootstrap(ctx context.Context, cfg BootstrapConfig) error {
|
||||
if cfg.Username == "" || cfg.Password == "" {
|
||||
return nil
|
||||
}
|
||||
n, err := s.Repo.CountAccounts(ctx)
|
||||
if err != nil || n > 0 {
|
||||
return err
|
||||
}
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(cfg.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.Repo.CreateAccount(ctx, cfg.Username, string(hash))
|
||||
return err
|
||||
}
|
||||
|
||||
// LoginResult is returned after successful login.
|
||||
type LoginResult struct {
|
||||
Token string `json:"token"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
Admin AdminMe `json:"admin"`
|
||||
}
|
||||
|
||||
// AdminMe is the public admin profile.
|
||||
type AdminMe struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
}
|
||||
|
||||
var (
|
||||
ErrBadCredentials = errString("invalid credentials")
|
||||
ErrInvalidPlan = errString("invalid plan")
|
||||
ErrUserNotFound = errString("user not found")
|
||||
)
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
// Login verifies password and issues a session token.
|
||||
func (s *Service) Login(ctx context.Context, username, password string) (*LoginResult, error) {
|
||||
acc, err := s.Repo.FindByUsername(ctx, username)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if acc == nil || acc.Status != "active" {
|
||||
return nil, ErrBadCredentials
|
||||
}
|
||||
if bcrypt.CompareHashAndPassword([]byte(acc.PasswordHash), []byte(password)) != nil {
|
||||
return nil, ErrBadCredentials
|
||||
}
|
||||
token, err := newToken()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
exp := time.Now().UTC().Add(12 * time.Hour)
|
||||
if err := s.Repo.CreateSession(ctx, acc.ID, token, exp); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResult{
|
||||
Token: token,
|
||||
ExpiresAt: exp,
|
||||
Admin: AdminMe{ID: acc.ID, Username: acc.Username},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveAdminID implements middleware.AdminSessionResolver.
|
||||
func (s *Service) ResolveAdminID(ctx context.Context, token string) (uuid.UUID, error) {
|
||||
return s.Repo.ResolveSession(ctx, token)
|
||||
}
|
||||
|
||||
// Logout deletes the session for token.
|
||||
func (s *Service) Logout(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
return s.Repo.DeleteSession(ctx, token)
|
||||
}
|
||||
|
||||
// Me returns the current admin profile.
|
||||
func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
|
||||
acc, err := s.Repo.FindAccountByID(ctx, adminID)
|
||||
if err != nil || acc == nil {
|
||||
return nil, errors.New("admin not found")
|
||||
}
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username}, nil
|
||||
}
|
||||
|
||||
// ListUsers lists terminal users.
|
||||
func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([]repository.UserListItem, error) {
|
||||
return s.Repo.ListUsers(ctx, q, limit, offset)
|
||||
}
|
||||
|
||||
// UserDetail is admin view of one user.
|
||||
type UserDetail struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Profiles []repository.ProfileBrief `json:"profiles"`
|
||||
Membership *repository.MembershipRow `json:"membership"`
|
||||
Orders []repository.OrderListItem `json:"recent_orders"`
|
||||
}
|
||||
|
||||
// GetUser loads user detail for admin.
|
||||
func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, error) {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !ok {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
users, err := s.Repo.ListUsers(ctx, userID.String(), 1, 0)
|
||||
if err != nil || len(users) == 0 {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
profiles, err := s.Repo.ListProfilesForUser(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
mem, err := s.Reports.GetMembership(ctx, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
orders, err := s.Repo.ListOrders(ctx, &userID, 10, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UserDetail{
|
||||
ID: users[0].ID,
|
||||
Status: users[0].Status,
|
||||
CreatedAt: users[0].CreatedAt,
|
||||
Profiles: profiles,
|
||||
Membership: mem,
|
||||
Orders: orders,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GrantInput for membership grant.
|
||||
type GrantInput struct {
|
||||
Plan string `json:"plan"`
|
||||
}
|
||||
|
||||
// GrantMembership extends membership and writes audit.
|
||||
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
|
||||
days, err := planDays(plan)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"plan": plan, "days": days})
|
||||
return s.Repo.GrantMembershipWithAudit(ctx, adminID, userID, plan, days, meta)
|
||||
}
|
||||
|
||||
// ListOrders lists commerce orders.
|
||||
func (s *Service) ListOrders(ctx context.Context, limit, offset int) ([]repository.OrderListItem, error) {
|
||||
return s.Repo.ListOrders(ctx, nil, limit, offset)
|
||||
}
|
||||
|
||||
// ListAuditLogs lists audit entries.
|
||||
func (s *Service) ListAuditLogs(ctx context.Context, limit, offset int) ([]repository.AuditListItem, error) {
|
||||
return s.Repo.ListAuditLogs(ctx, limit, offset)
|
||||
}
|
||||
|
||||
func planDays(plan string) (int, error) {
|
||||
switch plan {
|
||||
case "month":
|
||||
return 31, nil
|
||||
case "quarter":
|
||||
return 92, nil
|
||||
case "year":
|
||||
return 366, nil
|
||||
default:
|
||||
return 0, ErrInvalidPlan
|
||||
}
|
||||
}
|
||||
|
||||
func newToken() (string, error) {
|
||||
b := make([]byte, 24)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return "adm_" + hex.EncodeToString(b), nil
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package admin
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPlanDays(t *testing.T) {
|
||||
cases := map[string]int{"month": 31, "quarter": 92, "year": 366}
|
||||
for plan, want := range cases {
|
||||
got, err := planDays(plan)
|
||||
if err != nil || got != want {
|
||||
t.Fatalf("planDays(%s)=%d,%v want %d", plan, got, err, want)
|
||||
}
|
||||
}
|
||||
if _, err := planDays("week"); err == nil {
|
||||
t.Fatal("expected invalid plan")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user