Files
digital-psychology/apps/api/internal/handler/admin.go
T
jackyu66gitandCursor 4889ff5916 merge: 合入本地 Ops 扩展与 origin/main(ECR-009–016)
保留远程用户侧 ECR-009–016 与本地 Ops 目录/RBAC/CMS/危机等能力;文档标注分叉期间 ECR 编号冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 01:45:53 +08:00

391 lines
12 KiB
Go

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/repository"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/analytics"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// AdminHandler serves /api/v1/admin/* (no DeviceAuth).
type AdminHandler struct {
Svc *admin.Service
Analytics *analytics.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("/stats", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.Stats)
authed.GET("/users", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.ListUsers)
authed.GET("/users/:id", middleware.RequireAdminPermission(h.Svc, admin.PermUsersRead), h.GetUser)
authed.POST("/users/:id/membership/grant", middleware.RequireAdminPermission(h.Svc, admin.PermMembershipGrant), h.GrantMembership)
authed.POST("/users/:id/ask-quota/grant", middleware.RequireAdminPermission(h.Svc, admin.PermAskQuotaGrant), h.GrantAskQuota)
authed.GET("/orders", middleware.RequireAdminPermission(h.Svc, admin.PermOrdersRead), h.ListOrders)
authed.GET("/membership/plan-prices", h.ListPlanPrices)
authed.PUT("/membership/plan-prices", h.PutPlanPrices)
authed.GET("/audit-logs", middleware.RequireAdminPermission(h.Svc, admin.PermAuditRead), h.ListAudit)
authed.GET("/analytics/overview", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsOverview)
authed.GET("/analytics/pages", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsPages)
authed.GET("/analytics/exits", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsExits)
authed.GET("/analytics/clicks", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsClicks)
authed.GET("/analytics/funnel", middleware.RequireAdminPermission(h.Svc, admin.PermAnalyticsRead), h.AnalyticsFunnel)
h.registerContent(authed)
h.registerSystem(authed)
h.registerRBAC(authed)
h.registerLifecycle(authed)
h.registerMembershipPlans(authed)
h.registerRedemption(authed)
h.registerInsight(authed)
h.registerAskOps(authed)
h.registerQualityFeedback(authed)
h.registerEntitlement(authed)
h.registerContentSafety(authed)
h.registerAIConfig(authed)
h.registerCrisis(authed)
h.registerCMS(authed)
h.registerCMSPublications(authed)
h.registerKnowledgeChunks(authed)
h.registerToolDefinitions(authed)
h.registerBlockPolicies(authed)
h.registerModerationCases(authed)
h.registerCrisisEvents(authed)
h.registerInterventionOutcomes(authed)
h.registerHandoffCases(authed)
h.registerPrivacyRequests(authed)
h.registerStarConfigs(authed)
h.registerRhythmConfigs(authed)
h.registerImageCardDecks(authed)
h.registerReportTemplates(authed)
h.registerFunnelDefinitions(authed)
h.registerExploreScales(authed)
}
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) Stats(c *gin.Context) {
stats, err := h.Svc.DashboardStats(c.Request.Context())
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50017, "stats failed")
return
}
response.OK(c, stats)
}
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.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
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) GrantAskQuota(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.GrantAskQuotaInput
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40005, "delta required")
return
}
left, err := h.Svc.GrantAskQuota(c.Request.Context(), adminID, userID, body.Delta)
if err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
if errors.Is(err, admin.ErrInvalidAskDelta) {
response.Fail(c, http.StatusBadRequest, 40006, "invalid delta")
return
}
if errors.Is(err, admin.ErrUserNotFound) {
response.Fail(c, http.StatusNotFound, 40401, "user not found")
return
}
response.Fail(c, http.StatusInternalServerError, 50018, "grant ask quota failed")
return
}
response.OK(c, gin.H{"ok": true, "ask_paid_quota_left": left})
}
func (h *AdminHandler) ListOrders(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
f := admin.OrderFilterFromQuery(
c.Query("user_id"),
c.Query("status"),
c.Query("kind"),
c.Query("from"),
c.Query("to"),
limit,
offset,
)
items, err := h.Svc.ListOrders(c.Request.Context(), f)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50015, "list orders failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) ListPlanPrices(c *gin.Context) {
items, err := h.Svc.ListPlanPrices(c.Request.Context())
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50017, "list plan prices failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) PutPlanPrices(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
var body struct {
Items []struct {
Plan string `json:"plan"`
DisplayCents int `json:"display_cents"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil || len(body.Items) == 0 {
response.Fail(c, http.StatusBadRequest, 40040, "items required")
return
}
prices := make([]repository.PlanPrice, 0, len(body.Items))
for _, it := range body.Items {
prices = append(prices, repository.PlanPrice{Plan: it.Plan, DisplayCents: it.DisplayCents})
}
if err := h.Svc.UpsertPlanPrices(c.Request.Context(), adminID, prices); err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
response.Fail(c, http.StatusBadRequest, 40041, "upsert plan prices failed")
return
}
response.OK(c, gin.H{"ok": true})
}
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})
}
func (h *AdminHandler) AnalyticsOverview(c *gin.Context) {
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
return
}
if !h.requireAnalytics(c) {
return
}
data, err := h.Analytics.Overview(c.Request.Context(), from, to)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50022, "overview failed")
return
}
response.OK(c, data)
}
func (h *AdminHandler) AnalyticsPages(c *gin.Context) {
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
return
}
if !h.requireAnalytics(c) {
return
}
items, err := h.Analytics.Pages(c.Request.Context(), from, to)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50023, "pages failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) AnalyticsExits(c *gin.Context) {
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
return
}
if !h.requireAnalytics(c) {
return
}
items, err := h.Analytics.Exits(c.Request.Context(), from, to)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50024, "exits failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) AnalyticsClicks(c *gin.Context) {
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
return
}
if !h.requireAnalytics(c) {
return
}
items, err := h.Analytics.Clicks(c.Request.Context(), from, to)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50025, "clicks failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) AnalyticsFunnel(c *gin.Context) {
from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
return
}
if !h.requireAnalytics(c) {
return
}
steps, err := h.Analytics.Funnel(c.Request.Context(), from, to)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50026, "funnel failed")
return
}
response.OK(c, gin.H{"steps": steps})
}
func (h *AdminHandler) requireAnalytics(c *gin.Context) bool {
if h.Analytics == nil {
response.Fail(c, http.StatusInternalServerError, 50021, "analytics unavailable")
return false
}
return true
}