feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -10,12 +10,14 @@ import (
|
||||
|
||||
"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/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
|
||||
Svc *admin.Service
|
||||
Analytics *analytics.Service
|
||||
}
|
||||
|
||||
// Register mounts public login + authed admin routes.
|
||||
@@ -27,11 +29,19 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
|
||||
authed.Use(middleware.AdminAuth(h.Svc))
|
||||
authed.POST("/auth/logout", h.Logout)
|
||||
authed.GET("/me", h.Me)
|
||||
authed.GET("/stats", h.Stats)
|
||||
authed.GET("/users", h.ListUsers)
|
||||
authed.GET("/users/:id", h.GetUser)
|
||||
authed.POST("/users/:id/membership/grant", h.GrantMembership)
|
||||
authed.POST("/users/:id/ask-quota/grant", h.GrantAskQuota)
|
||||
authed.GET("/orders", h.ListOrders)
|
||||
authed.GET("/audit-logs", h.ListAudit)
|
||||
authed.GET("/analytics/overview", h.AnalyticsOverview)
|
||||
authed.GET("/analytics/pages", h.AnalyticsPages)
|
||||
authed.GET("/analytics/exits", h.AnalyticsExits)
|
||||
authed.GET("/analytics/clicks", h.AnalyticsClicks)
|
||||
authed.GET("/analytics/funnel", h.AnalyticsFunnel)
|
||||
h.registerContent(authed)
|
||||
}
|
||||
|
||||
func (h *AdminHandler) Login(c *gin.Context) {
|
||||
@@ -75,6 +85,15 @@ func (h *AdminHandler) Me(c *gin.Context) {
|
||||
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"))
|
||||
@@ -135,6 +154,38 @@ func (h *AdminHandler) GrantMembership(c *gin.Context) {
|
||||
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.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"))
|
||||
@@ -156,3 +207,96 @@ func (h *AdminHandler) ListAudit(c *gin.Context) {
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user