feat(ECR-010): Ops-E 系统运营;修复登出解绑;P2 Complete
落地管理员 RBAC/封禁/推送任务 stub,logout 解绑 device 并统一各页 ensureAccount,同时收口 P2 生日生成与状态文档。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Logout must revoke session AND unbind device so refresh is not still "logged in".
|
||||
func TestAuthLogoutUnbindsDevice(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
key := mustRegister(t, r)
|
||||
|
||||
_, key = doJSON(t, r, http.MethodGet, "/api/v1/auth/me", nil, key)
|
||||
|
||||
_, key = doJSON(t, r, http.MethodPost, "/api/v1/auth/logout", nil, key)
|
||||
testBearer = ""
|
||||
|
||||
_, _, status := doJSONExpect(t, r, http.MethodGet, "/api/v1/auth/me", nil, key, 40112)
|
||||
if status != http.StatusUnauthorized {
|
||||
t.Fatalf("after logout GET /auth/me want HTTP 401, got %d", status)
|
||||
}
|
||||
|
||||
_, key, _ = doJSONExpect(t, r, http.MethodGet, "/api/v1/profiles", nil, key, 40112)
|
||||
_ = key
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package integration_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/config"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/db"
|
||||
)
|
||||
|
||||
func TestOpsESystemRBACBanPush(t *testing.T) {
|
||||
r, _ := setupAPI(t)
|
||||
|
||||
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("super login failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var login struct {
|
||||
Token string `json:"token"`
|
||||
Admin struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
} `json:"admin"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &login); err != nil || login.Token == "" {
|
||||
t.Fatalf("login parse: %v %s", err, env.Data)
|
||||
}
|
||||
if login.Admin.Role != "super" {
|
||||
t.Fatalf("bootstrap admin role want super got %q", login.Admin.Role)
|
||||
}
|
||||
superTok := login.Token
|
||||
|
||||
opsUser := "ops_" + uuid.NewString()[:8]
|
||||
opsPass := "ops-pass-1"
|
||||
insertOpsAdmin(t, opsUser, opsPass)
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
|
||||
"username": opsUser,
|
||||
"password": opsPass,
|
||||
}, "")
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("ops login failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
_ = json.Unmarshal(env.Data, &login)
|
||||
opsTok := login.Token
|
||||
if login.Admin.Role != "ops" {
|
||||
t.Fatalf("ops role want ops got %q", login.Admin.Role)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/membership/plan-prices", map[string]any{
|
||||
"items": []map[string]any{{"plan": "month", "display_cents": 990}},
|
||||
}, opsTok)
|
||||
if code != http.StatusForbidden || env.Code != 40301 {
|
||||
t.Fatalf("ops put prices want 40301, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/admins", nil, opsTok)
|
||||
if code != http.StatusForbidden || env.Code != 40301 {
|
||||
t.Fatalf("ops list admins want 40301, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
key := mustRegister(t, r)
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("ops list users failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
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("users: %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",
|
||||
}, opsTok)
|
||||
if code != http.StatusForbidden || env.Code != 40301 {
|
||||
t.Fatalf("ops grant want 40301, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/ban", nil, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("ban failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
|
||||
env, _, httpStatus := doJSONExpect(t, r, http.MethodGet, "/api/v1/profiles", nil, key, 40310)
|
||||
if httpStatus != http.StatusForbidden {
|
||||
t.Fatalf("banned device want HTTP 403, got %d code=%d", httpStatus, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/users/"+userID+"/unban", nil, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("unban failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
_, key = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, key)
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/push-jobs", map[string]string{
|
||||
"title": "测试推送",
|
||||
"body": "不下发",
|
||||
}, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("create push failed http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
var job struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &job); err != nil || job.ID == "" || job.Status != "draft" {
|
||||
t.Fatalf("push job: %v %s", err, env.Data)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/push-jobs/"+job.ID, map[string]string{
|
||||
"status": "cancelled",
|
||||
}, opsTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("cancel push failed http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/push-jobs", map[string]string{
|
||||
"title": strings.Repeat("超", 129),
|
||||
"body": "too long",
|
||||
}, opsTok)
|
||||
if code != http.StatusBadRequest || env.Code != 40055 {
|
||||
t.Fatalf("long title want 40055, got http=%d code=%d", code, env.Code)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/admins", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("list admins as super failed")
|
||||
}
|
||||
var admins struct {
|
||||
Items []struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &admins); err != nil {
|
||||
t.Fatalf("admins parse: %v", err)
|
||||
}
|
||||
var soleSuper string
|
||||
superCount := 0
|
||||
for _, a := range admins.Items {
|
||||
if a.Role == "super" {
|
||||
superCount++
|
||||
soleSuper = a.ID
|
||||
}
|
||||
}
|
||||
if superCount != 1 || soleSuper == "" {
|
||||
t.Fatalf("want exactly 1 super, got %d", superCount)
|
||||
}
|
||||
env, code = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/admins/"+soleSuper, map[string]string{
|
||||
"role": "ops",
|
||||
}, superTok)
|
||||
if code != http.StatusConflict || env.Code != 40901 {
|
||||
t.Fatalf("demote last super want 40901, got http=%d code=%d msg=%s", code, env.Code, env.Message)
|
||||
}
|
||||
|
||||
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/me", nil, superTok)
|
||||
if code != 200 || env.Code != 0 {
|
||||
t.Fatalf("super me failed")
|
||||
}
|
||||
_ = env
|
||||
}
|
||||
|
||||
func insertOpsAdmin(t *testing.T, username, password string) {
|
||||
t.Helper()
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
cfg := config.Load()
|
||||
pool, err := db.Connect(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
t.Fatalf("hash: %v", err)
|
||||
}
|
||||
_, err = pool.Exec(ctx, `
|
||||
INSERT INTO admin_accounts(username, password_hash, role, status)
|
||||
VALUES ($1,$2,'ops','active')`, username, string(hash))
|
||||
if err != nil {
|
||||
t.Fatalf("insert ops admin: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,15 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, tok,
|
||||
).Scan(&uid)
|
||||
if err == nil {
|
||||
if banned, berr := userIsBanned(c.Request.Context(), pool, uid); berr != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable")
|
||||
c.Abort()
|
||||
return
|
||||
} else if banned {
|
||||
response.Fail(c, http.StatusForbidden, 40310, "账号已封禁")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
_, _ = pool.Exec(c.Request.Context(), `
|
||||
INSERT INTO device_identities(device_key, user_id)
|
||||
VALUES ($1,$2)
|
||||
@@ -56,6 +65,15 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
if banned, berr := userIsBanned(c.Request.Context(), pool, userID); berr != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50001, "identity unavailable")
|
||||
c.Abort()
|
||||
return
|
||||
} else if banned {
|
||||
response.Fail(c, http.StatusForbidden, 40310, "账号已封禁")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
c.Set(string(UserIDKey), userID.String())
|
||||
c.Header(DeviceKeyHeader, key)
|
||||
c.Next()
|
||||
@@ -149,3 +167,16 @@ func newDeviceKey() string {
|
||||
_, _ = rand.Read(b)
|
||||
return "dev_" + hex.EncodeToString(b)
|
||||
}
|
||||
|
||||
func userIsBanned(ctx context.Context, pool *pgxpool.Pool, userID uuid.UUID) (bool, error) {
|
||||
var status string
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID).Scan(&status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return status == "banned", nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
var (
|
||||
// ErrAdminNotFound is returned when an admin account row is missing.
|
||||
ErrAdminNotFound = errors.New("admin not found")
|
||||
// ErrPushJobNotFound is returned when a push_jobs row is missing.
|
||||
ErrPushJobNotFound = errors.New("push job not found")
|
||||
// ErrUserStatusNotFound is returned when users row is missing for status update.
|
||||
ErrUserStatusNotFound = errors.New("user not found")
|
||||
)
|
||||
|
||||
// AdminListItem is a public admin row (no password).
|
||||
type AdminListItem struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
Status string `json:"status"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PushJob is a push campaign stub (never dispatched).
|
||||
type PushJob struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
Audience string `json:"audience"`
|
||||
Status string `json:"status"`
|
||||
CreatedBy uuid.UUID `json:"created_by"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// SetUserStatusWithAudit updates users.status and writes audit.
|
||||
func (r *AdminRepo) SetUserStatusWithAudit(
|
||||
ctx context.Context,
|
||||
adminID, userID uuid.UUID,
|
||||
status, action string,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE users SET status=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, userID, status)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrUserStatusNotFound
|
||||
}
|
||||
if status == "banned" {
|
||||
_, _ = tx.Exec(ctx, `
|
||||
UPDATE user_sessions SET revoked_at=now()
|
||||
WHERE user_id=$1 AND revoked_at IS NULL`, userID)
|
||||
}
|
||||
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,$2,'user',$3,$4)`, adminID, action, userID.String(), meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ListAdmins returns non-deleted admin accounts.
|
||||
func (r *AdminRepo) ListAdmins(ctx context.Context) ([]AdminListItem, error) {
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, username, role, status, created_at
|
||||
FROM admin_accounts
|
||||
WHERE deleted_at IS NULL
|
||||
ORDER BY created_at ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []AdminListItem
|
||||
for rows.Next() {
|
||||
var a AdminListItem
|
||||
if err := rows.Scan(&a.ID, &a.Username, &a.Role, &a.Status, &a.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, a)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CountAdminsByRole counts non-deleted admins with the given role.
|
||||
func (r *AdminRepo) CountAdminsByRole(ctx context.Context, role string) (int, error) {
|
||||
var n int
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT count(*) FROM admin_accounts
|
||||
WHERE deleted_at IS NULL AND role=$1`, role).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// UpdateAdminRoleWithAudit sets role for an admin account.
|
||||
func (r *AdminRepo) UpdateAdminRoleWithAudit(
|
||||
ctx context.Context,
|
||||
actorID, targetID uuid.UUID,
|
||||
role string,
|
||||
meta json.RawMessage,
|
||||
) error {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
tag, err := tx.Exec(ctx, `
|
||||
UPDATE admin_accounts SET role=$2, updated_at=now()
|
||||
WHERE id=$1 AND deleted_at IS NULL`, targetID, role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if tag.RowsAffected() == 0 {
|
||||
return ErrAdminNotFound
|
||||
}
|
||||
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,'admin.role_update','admin',$2,$3)`, actorID, targetID.String(), meta); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// ListPushJobs returns newest push stubs.
|
||||
func (r *AdminRepo) ListPushJobs(ctx context.Context, limit, offset int) ([]PushJob, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 20
|
||||
}
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
rows, err := r.Pool.Query(ctx, `
|
||||
SELECT id, title, body, audience, status, created_by, created_at, updated_at
|
||||
FROM push_jobs
|
||||
ORDER BY created_at DESC
|
||||
LIMIT $1 OFFSET $2`, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []PushJob
|
||||
for rows.Next() {
|
||||
var j PushJob
|
||||
if err := rows.Scan(
|
||||
&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, j)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// CreatePushJobWithAudit inserts a draft push job.
|
||||
func (r *AdminRepo) CreatePushJobWithAudit(
|
||||
ctx context.Context,
|
||||
adminID uuid.UUID,
|
||||
title, body, audience string,
|
||||
meta json.RawMessage,
|
||||
) (*PushJob, error) {
|
||||
if audience == "" {
|
||||
audience = "all"
|
||||
}
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var j PushJob
|
||||
err = tx.QueryRow(ctx, `
|
||||
INSERT INTO push_jobs(title, body, audience, status, created_by)
|
||||
VALUES ($1,$2,$3,'draft',$4)
|
||||
RETURNING id, title, body, audience, status, created_by, created_at, updated_at`,
|
||||
title, body, audience, adminID,
|
||||
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, 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,'push_job.create','push_job',$2,$3)`, adminID, j.ID.String(), meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
// UpdatePushJobWithAudit updates title/body/status (draft|cancelled only).
|
||||
func (r *AdminRepo) UpdatePushJobWithAudit(
|
||||
ctx context.Context,
|
||||
adminID, jobID uuid.UUID,
|
||||
title, body, status *string,
|
||||
meta json.RawMessage,
|
||||
) (*PushJob, error) {
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
var j PushJob
|
||||
err = tx.QueryRow(ctx, `
|
||||
SELECT id, title, body, audience, status, created_by, created_at, updated_at
|
||||
FROM push_jobs WHERE id=$1`, jobID,
|
||||
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, ErrPushJobNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if title != nil {
|
||||
j.Title = *title
|
||||
}
|
||||
if body != nil {
|
||||
j.Body = *body
|
||||
}
|
||||
if status != nil {
|
||||
j.Status = *status
|
||||
}
|
||||
err = tx.QueryRow(ctx, `
|
||||
UPDATE push_jobs SET title=$2, body=$3, status=$4, updated_at=now()
|
||||
WHERE id=$1
|
||||
RETURNING id, title, body, audience, status, created_by, created_at, updated_at`,
|
||||
jobID, j.Title, j.Body, j.Status,
|
||||
).Scan(&j.ID, &j.Title, &j.Body, &j.Audience, &j.Status, &j.CreatedBy, &j.CreatedAt, &j.UpdatedAt)
|
||||
if err != nil {
|
||||
return nil, 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,'push_job.update','push_job',$2,$3)`, adminID, jobID.String(), meta); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &j, nil
|
||||
}
|
||||
|
||||
// UserStatus returns users.status or empty if missing.
|
||||
func (r *AdminRepo) UserStatus(ctx context.Context, userID uuid.UUID) (string, error) {
|
||||
var status string
|
||||
err := r.Pool.QueryRow(ctx, `
|
||||
SELECT status FROM users WHERE id=$1 AND deleted_at IS NULL`, userID).Scan(&status)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", errors.New("user not found")
|
||||
}
|
||||
return status, err
|
||||
}
|
||||
@@ -23,6 +23,7 @@ type AdminAccount struct {
|
||||
Username string
|
||||
PasswordHash string
|
||||
Status string
|
||||
Role string
|
||||
}
|
||||
|
||||
// CountAccounts returns non-deleted admin count.
|
||||
@@ -33,12 +34,12 @@ func (r *AdminRepo) CountAccounts(ctx context.Context) (int, error) {
|
||||
return n, err
|
||||
}
|
||||
|
||||
// CreateAccount inserts an admin account.
|
||||
// CreateAccount inserts an admin account (role defaults to super).
|
||||
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)
|
||||
INSERT INTO admin_accounts(username, password_hash, role)
|
||||
VALUES ($1,$2,'super') RETURNING id`, username, hash).Scan(&id)
|
||||
return id, err
|
||||
}
|
||||
|
||||
@@ -46,10 +47,10 @@ func (r *AdminRepo) CreateAccount(ctx context.Context, username, hash string) (u
|
||||
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
|
||||
SELECT id, username, password_hash, status, role
|
||||
FROM admin_accounts
|
||||
WHERE username=$1 AND deleted_at IS NULL`, username,
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status, &a.Role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -63,10 +64,10 @@ func (r *AdminRepo) FindByUsername(ctx context.Context, username string) (*Admin
|
||||
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
|
||||
SELECT id, username, password_hash, status, role
|
||||
FROM admin_accounts
|
||||
WHERE id=$1 AND deleted_at IS NULL`, id,
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status)
|
||||
).Scan(&a.ID, &a.Username, &a.PasswordHash, &a.Status, &a.Role)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -116,6 +116,33 @@ func (r *AuthRepo) BindDevice(ctx context.Context, deviceKey string, userID uuid
|
||||
return err
|
||||
}
|
||||
|
||||
// RebindDeviceAnonymous creates a fresh anonymous user and points the device at it.
|
||||
// Used on logout so the device is no longer tied to the registered account (Spec R5).
|
||||
func (r *AuthRepo) RebindDeviceAnonymous(ctx context.Context, deviceKey string) error {
|
||||
if deviceKey == "" {
|
||||
return nil
|
||||
}
|
||||
tx, err := r.Pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
|
||||
var uid uuid.UUID
|
||||
if err := tx.QueryRow(ctx, `INSERT INTO users DEFAULT VALUES RETURNING id`).Scan(&uid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO device_identities(device_key, user_id)
|
||||
VALUES ($1,$2)
|
||||
ON CONFLICT (device_key) DO UPDATE SET user_id=$2, updated_at=now(), deleted_at=NULL`,
|
||||
deviceKey, uid,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
// CreateSession inserts a session token.
|
||||
func (r *AuthRepo) CreateSession(ctx context.Context, userID uuid.UUID, token string, expires time.Time) error {
|
||||
_, err := r.Pool.Exec(ctx, `
|
||||
|
||||
@@ -27,6 +27,9 @@ func (s *Service) ListHomeTools(ctx context.Context) ([]repository.HomeTool, err
|
||||
|
||||
// ReplaceHomeTools replaces grid and audits in one transaction.
|
||||
func (s *Service) ReplaceHomeTools(ctx context.Context, adminID uuid.UUID, items []homesvc.ReplaceInput) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Home == nil {
|
||||
return errors.New("home unavailable")
|
||||
}
|
||||
@@ -44,6 +47,9 @@ func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminI
|
||||
|
||||
// PatchScaleStatus updates published|draft and audits in one transaction.
|
||||
func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
if status != "published" && status != "draft" {
|
||||
return ErrInvalidScaleStatus
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ type LoginResult struct {
|
||||
type AdminMe struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -93,7 +94,7 @@ func (s *Service) Login(ctx context.Context, username, password string) (*LoginR
|
||||
return &LoginResult{
|
||||
Token: token,
|
||||
ExpiresAt: exp,
|
||||
Admin: AdminMe{ID: acc.ID, Username: acc.Username},
|
||||
Admin: AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role},
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -116,7 +117,7 @@ func (s *Service) Me(ctx context.Context, adminID uuid.UUID) (*AdminMe, error) {
|
||||
if err != nil || acc == nil {
|
||||
return nil, errors.New("admin not found")
|
||||
}
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username}, nil
|
||||
return &AdminMe{ID: acc.ID, Username: acc.Username, Role: acc.Role}, nil
|
||||
}
|
||||
|
||||
// ListUsers lists terminal users.
|
||||
@@ -197,6 +198,9 @@ type GrantInput struct {
|
||||
|
||||
// GrantMembership extends membership and writes audit.
|
||||
func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID, plan string) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
days, err := planDays(plan)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -221,6 +225,9 @@ var ErrInvalidAskDelta = errString("invalid ask quota delta")
|
||||
|
||||
// GrantAskQuota adds purchased ask quota and audits.
|
||||
func (s *Service) GrantAskQuota(ctx context.Context, adminID, userID uuid.UUID, delta int) (int, error) {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if delta <= 0 || delta > 1000 {
|
||||
return 0, ErrInvalidAskDelta
|
||||
}
|
||||
@@ -247,6 +254,9 @@ func (s *Service) ListPlanPrices(ctx context.Context) ([]repository.PlanPrice, e
|
||||
|
||||
// UpsertPlanPrices updates display prices (not historical order amounts).
|
||||
func (s *Service) UpsertPlanPrices(ctx context.Context, adminID uuid.UUID, items []repository.PlanPrice) error {
|
||||
if err := s.RequireSuper(ctx, adminID); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return ErrInvalidPlan
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
RoleSuper = "super"
|
||||
RoleOps = "ops"
|
||||
|
||||
pushTitleMaxRunes = 128
|
||||
)
|
||||
|
||||
var (
|
||||
ErrForbidden = errString("forbidden")
|
||||
ErrInvalidRole = errString("invalid role")
|
||||
ErrPushNotFound = errString("push job not found")
|
||||
ErrInvalidPush = errString("invalid push job")
|
||||
ErrAdminNotFound = errString("admin not found")
|
||||
ErrLastSuper = errString("cannot demote last super")
|
||||
)
|
||||
|
||||
// RequireSuper returns ErrForbidden unless admin role is super.
|
||||
func (s *Service) RequireSuper(ctx context.Context, adminID uuid.UUID) error {
|
||||
acc, err := s.Repo.FindAccountByID(ctx, adminID)
|
||||
if err != nil || acc == nil {
|
||||
return ErrAdminNotFound
|
||||
}
|
||||
if acc.Role != RoleSuper {
|
||||
return ErrForbidden
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BanUser sets users.status=banned.
|
||||
func (s *Service) BanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"status": "banned"})
|
||||
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "banned", "user.ban", meta)
|
||||
if errors.Is(err, repository.ErrUserStatusNotFound) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UnbanUser sets users.status=active.
|
||||
func (s *Service) UnbanUser(ctx context.Context, adminID, userID uuid.UUID) error {
|
||||
ok, err := s.Repo.UserExists(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"status": "active"})
|
||||
err = s.Repo.SetUserStatusWithAudit(ctx, adminID, userID, "active", "user.unban", meta)
|
||||
if errors.Is(err, repository.ErrUserStatusNotFound) {
|
||||
return ErrUserNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ListAdmins returns admin accounts (super only caller).
|
||||
func (s *Service) ListAdmins(ctx context.Context, actorID uuid.UUID) ([]repository.AdminListItem, error) {
|
||||
if err := s.RequireSuper(ctx, actorID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return s.Repo.ListAdmins(ctx)
|
||||
}
|
||||
|
||||
// UpdateAdminRole changes an admin role (super only).
|
||||
func (s *Service) UpdateAdminRole(ctx context.Context, actorID, targetID uuid.UUID, role string) error {
|
||||
if err := s.RequireSuper(ctx, actorID); err != nil {
|
||||
return err
|
||||
}
|
||||
if role != RoleSuper && role != RoleOps {
|
||||
return ErrInvalidRole
|
||||
}
|
||||
target, err := s.Repo.FindAccountByID(ctx, targetID)
|
||||
if err != nil || target == nil {
|
||||
return ErrAdminNotFound
|
||||
}
|
||||
if target.Role == RoleSuper && role != RoleSuper {
|
||||
n, err := s.Repo.CountAdminsByRole(ctx, RoleSuper)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n <= 1 {
|
||||
return ErrLastSuper
|
||||
}
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"role": role})
|
||||
err = s.Repo.UpdateAdminRoleWithAudit(ctx, actorID, targetID, role, meta)
|
||||
if errors.Is(err, repository.ErrAdminNotFound) {
|
||||
return ErrAdminNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// ListPushJobs lists push stubs.
|
||||
func (s *Service) ListPushJobs(ctx context.Context, limit, offset int) ([]repository.PushJob, error) {
|
||||
return s.Repo.ListPushJobs(ctx, limit, offset)
|
||||
}
|
||||
|
||||
// CreatePushJob creates a draft push stub.
|
||||
func (s *Service) CreatePushJob(ctx context.Context, adminID uuid.UUID, title, body, audience string) (*repository.PushJob, error) {
|
||||
if err := validatePushTitle(title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{"title": title})
|
||||
return s.Repo.CreatePushJobWithAudit(ctx, adminID, title, body, audience, meta)
|
||||
}
|
||||
|
||||
// UpdatePushJob updates a push stub.
|
||||
func (s *Service) UpdatePushJob(
|
||||
ctx context.Context,
|
||||
adminID, jobID uuid.UUID,
|
||||
title, body, status *string,
|
||||
) (*repository.PushJob, error) {
|
||||
if status != nil && *status != "draft" && *status != "cancelled" {
|
||||
return nil, ErrInvalidPush
|
||||
}
|
||||
if title != nil {
|
||||
if err := validatePushTitle(*title); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
meta, _ := json.Marshal(map[string]any{})
|
||||
job, err := s.Repo.UpdatePushJobWithAudit(ctx, adminID, jobID, title, body, status, meta)
|
||||
if errors.Is(err, repository.ErrPushJobNotFound) {
|
||||
return nil, ErrPushNotFound
|
||||
}
|
||||
return job, err
|
||||
}
|
||||
|
||||
func validatePushTitle(title string) error {
|
||||
if title == "" || utf8.RuneCountInString(title) > pushTitleMaxRunes {
|
||||
return ErrInvalidPush
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -93,12 +93,15 @@ func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceK
|
||||
return s.issue(ctx, uid, phone, nickname)
|
||||
}
|
||||
|
||||
// Logout revokes bearer token.
|
||||
func (s *Service) Logout(ctx context.Context, token string) error {
|
||||
if token == "" {
|
||||
return nil
|
||||
// Logout revokes the current bearer session and unbinds the device from the account
|
||||
// so a refresh no longer resolves as logged-in via X-Device-Key (Spec R5).
|
||||
func (s *Service) Logout(ctx context.Context, token, deviceKey string) error {
|
||||
if token != "" {
|
||||
if err := s.Repo.RevokeSession(ctx, token); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.Repo.RevokeSession(ctx, token)
|
||||
return s.Repo.RebindDeviceAnonymous(ctx, deviceKey)
|
||||
}
|
||||
|
||||
// Me returns account if registered.
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
DROP INDEX IF EXISTS idx_push_jobs_created;
|
||||
DROP TABLE IF EXISTS push_jobs;
|
||||
|
||||
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_status_check;
|
||||
|
||||
ALTER TABLE admin_accounts DROP CONSTRAINT IF EXISTS admin_accounts_role_check;
|
||||
ALTER TABLE admin_accounts DROP COLUMN IF EXISTS role;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- Ops-E: RBAC role · user status check · push_jobs stub
|
||||
|
||||
ALTER TABLE admin_accounts
|
||||
ADD COLUMN IF NOT EXISTS role varchar(16) NOT NULL DEFAULT 'super';
|
||||
|
||||
ALTER TABLE admin_accounts
|
||||
DROP CONSTRAINT IF EXISTS admin_accounts_role_check;
|
||||
|
||||
ALTER TABLE admin_accounts
|
||||
ADD CONSTRAINT admin_accounts_role_check
|
||||
CHECK (role IN ('super', 'ops'));
|
||||
|
||||
UPDATE admin_accounts SET role = 'super' WHERE role IS NULL OR role = '';
|
||||
|
||||
ALTER TABLE users
|
||||
DROP CONSTRAINT IF EXISTS users_status_check;
|
||||
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT users_status_check
|
||||
CHECK (status IN ('active', 'banned'));
|
||||
|
||||
CREATE TABLE IF NOT EXISTS push_jobs (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
title varchar(128) NOT NULL,
|
||||
body text NOT NULL DEFAULT '',
|
||||
audience varchar(64) NOT NULL DEFAULT 'all',
|
||||
status varchar(32) NOT NULL DEFAULT 'draft',
|
||||
created_by uuid NOT NULL REFERENCES admin_accounts(id),
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
CONSTRAINT push_jobs_status_check CHECK (status IN ('draft', 'cancelled'))
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_push_jobs_created ON push_jobs(created_at DESC);
|
||||
Reference in New Issue
Block a user