feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-11 18:54:59 +08:00
co-authored by Cursor
parent 0ee4d4f5ae
commit 5ceb3ce749
85 changed files with 2098 additions and 103 deletions
+13
View File
@@ -45,6 +45,7 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
authed.GET("/analytics/clicks", h.AnalyticsClicks)
authed.GET("/analytics/funnel", h.AnalyticsFunnel)
h.registerContent(authed)
h.registerSystem(authed)
}
func (h *AdminHandler) Login(c *gin.Context) {
@@ -143,6 +144,10 @@ func (h *AdminHandler) GrantMembership(c *gin.Context) {
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
@@ -175,6 +180,10 @@ func (h *AdminHandler) GrantAskQuota(c *gin.Context) {
}
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
@@ -239,6 +248,10 @@ func (h *AdminHandler) PutPlanPrices(c *gin.Context) {
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
}
@@ -43,6 +43,10 @@ func (h *AdminHandler) ReplaceHomeTools(c *gin.Context) {
return
}
if err := h.Svc.ReplaceHomeTools(c.Request.Context(), adminID, body.Items); err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
if errors.Is(err, homesvc.ErrInvalidTools) || errors.Is(err, homesvc.ErrTooManyTools) {
response.Fail(c, http.StatusBadRequest, 40041, err.Error())
return
@@ -81,6 +85,10 @@ func (h *AdminHandler) PatchScale(c *gin.Context) {
return
}
if err := h.Svc.PatchScaleStatus(c.Request.Context(), adminID, id, body.Status); err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
if errors.Is(err, admin.ErrInvalidScaleStatus) {
response.Fail(c, http.StatusBadRequest, 40044, "invalid status")
return
+201
View File
@@ -0,0 +1,201 @@
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"
)
func (h *AdminHandler) registerSystem(authed *gin.RouterGroup) {
authed.POST("/users/:id/ban", h.BanUser)
authed.POST("/users/:id/unban", h.UnbanUser)
authed.GET("/admins", h.ListAdmins)
authed.PATCH("/admins/:id", h.PatchAdmin)
authed.GET("/push-jobs", h.ListPushJobs)
authed.POST("/push-jobs", h.CreatePushJob)
authed.PATCH("/push-jobs/:id", h.PatchPushJob)
}
func (h *AdminHandler) BanUser(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
uid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40050, "invalid user id")
return
}
if err := h.Svc.BanUser(c.Request.Context(), adminID, uid); err != nil {
if errors.Is(err, admin.ErrUserNotFound) {
response.Fail(c, http.StatusNotFound, 40401, "user not found")
return
}
response.Fail(c, http.StatusInternalServerError, 50040, "ban failed")
return
}
response.OK(c, gin.H{"ok": true})
}
func (h *AdminHandler) UnbanUser(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
uid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40050, "invalid user id")
return
}
if err := h.Svc.UnbanUser(c.Request.Context(), adminID, uid); err != nil {
if errors.Is(err, admin.ErrUserNotFound) {
response.Fail(c, http.StatusNotFound, 40401, "user not found")
return
}
response.Fail(c, http.StatusInternalServerError, 50041, "unban failed")
return
}
response.OK(c, gin.H{"ok": true})
}
func (h *AdminHandler) ListAdmins(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
items, err := h.Svc.ListAdmins(c.Request.Context(), adminID)
if err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
response.Fail(c, http.StatusInternalServerError, 50042, "list admins failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) PatchAdmin(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
targetID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40051, "invalid admin id")
return
}
var body struct {
Role string `json:"role"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Role == "" {
response.Fail(c, http.StatusBadRequest, 40052, "role required")
return
}
if err := h.Svc.UpdateAdminRole(c.Request.Context(), adminID, targetID, body.Role); err != nil {
if errors.Is(err, admin.ErrForbidden) {
response.Fail(c, http.StatusForbidden, 40301, "forbidden")
return
}
if errors.Is(err, admin.ErrInvalidRole) {
response.Fail(c, http.StatusBadRequest, 40053, "invalid role")
return
}
if errors.Is(err, admin.ErrLastSuper) {
response.Fail(c, http.StatusConflict, 40901, "cannot demote last super")
return
}
if errors.Is(err, admin.ErrAdminNotFound) {
response.Fail(c, http.StatusNotFound, 40420, "admin not found")
return
}
response.Fail(c, http.StatusInternalServerError, 50043, "patch admin failed")
return
}
response.OK(c, gin.H{"ok": true})
}
func (h *AdminHandler) ListPushJobs(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
items, err := h.Svc.ListPushJobs(c.Request.Context(), limit, offset)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50044, "list push jobs failed")
return
}
response.OK(c, gin.H{"items": items})
}
func (h *AdminHandler) CreatePushJob(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
var body struct {
Title string `json:"title"`
Body string `json:"body"`
Audience string `json:"audience"`
}
if err := c.ShouldBindJSON(&body); err != nil || body.Title == "" {
response.Fail(c, http.StatusBadRequest, 40054, "title required")
return
}
job, err := h.Svc.CreatePushJob(c.Request.Context(), adminID, body.Title, body.Body, body.Audience)
if err != nil {
if errors.Is(err, admin.ErrInvalidPush) {
response.Fail(c, http.StatusBadRequest, 40055, "invalid push job")
return
}
response.Fail(c, http.StatusInternalServerError, 50045, "create push job failed")
return
}
response.OK(c, job)
}
func (h *AdminHandler) PatchPushJob(c *gin.Context) {
adminID, ok := middleware.AdminIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
return
}
jobID, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 40056, "invalid job id")
return
}
var body struct {
Title *string `json:"title"`
Body *string `json:"body"`
Status *string `json:"status"`
}
if err := c.ShouldBindJSON(&body); err != nil {
response.Fail(c, http.StatusBadRequest, 40057, "invalid body")
return
}
job, err := h.Svc.UpdatePushJob(c.Request.Context(), adminID, jobID, body.Title, body.Body, body.Status)
if err != nil {
if errors.Is(err, admin.ErrPushNotFound) {
response.Fail(c, http.StatusNotFound, 40430, "push job not found")
return
}
if errors.Is(err, admin.ErrInvalidPush) {
response.Fail(c, http.StatusBadRequest, 40058, "invalid push job")
return
}
response.Fail(c, http.StatusInternalServerError, 50046, "patch push job failed")
return
}
response.OK(c, job)
}
+2 -1
View File
@@ -77,7 +77,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
// Logout handles POST /auth/logout.
func (h *AuthHandler) Logout(c *gin.Context) {
tok := bearerToken(c)
_ = h.Svc.Logout(c.Request.Context(), tok)
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
_ = h.Svc.Logout(c.Request.Context(), tok, deviceKey)
response.OK(c, gin.H{"ok": true})
}