feat(ECR-012–016): 合规、题库、时辰刷新、头像、MBTI OEJTS 与埋点
落地输入合规、探索题库、报告日/时辰刷新、账号头像、OEJTS 量表,并补齐 H5 埋点与 Admin 漏斗;同步 ESS 工件、切至自建 Git、清理 GitHub Actions。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -2,6 +2,7 @@ package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -11,6 +12,7 @@ import (
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
|
||||
asksvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
@@ -67,6 +69,9 @@ func (h *AskHandler) CreateThread(c *gin.Context) {
|
||||
ProfileID: pid, Scene: req.Scene,
|
||||
})
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40010, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -142,6 +147,9 @@ func (h *AskHandler) SendMessage(c *gin.Context) {
|
||||
|
||||
out, err := h.Svc.SendMessage(c.Request.Context(), userID, tid, req.Content)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
if asksvc.IsQuotaExhausted(err) {
|
||||
response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可购买额度或开通成长会员")
|
||||
return
|
||||
@@ -180,7 +188,9 @@ func (h *AskHandler) sendMessageStream(c *gin.Context, userID, tid uuid.UUID, co
|
||||
if err != nil {
|
||||
msg := err.Error()
|
||||
code := 40011
|
||||
if asksvc.IsQuotaExhausted(err) {
|
||||
if errors.Is(err, textsafe.ErrRejected) {
|
||||
code = 40060
|
||||
} else if asksvc.IsQuotaExhausted(err) {
|
||||
msg = "问答次数已用完,可购买额度或开通成长会员"
|
||||
code = 40210
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ func (h *AuthHandler) Register(api *gin.RouterGroup) {
|
||||
g.POST("/logout", h.Logout)
|
||||
g.GET("/me", h.Me)
|
||||
g.PATCH("/me", h.PatchMe)
|
||||
g.POST("/me/avatar", h.UploadAvatar)
|
||||
}
|
||||
|
||||
type authBody struct {
|
||||
@@ -47,6 +48,9 @@ func (h *AuthHandler) RegisterAccount(c *gin.Context) {
|
||||
deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
|
||||
res, err := h.Svc.Register(c.Request.Context(), userID, deviceKey, body.Phone, body.Password, body.Nickname)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40110, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -116,12 +120,35 @@ func (h *AuthHandler) PatchMe(c *gin.Context) {
|
||||
}
|
||||
me, err := h.Svc.UpdateNickname(c.Request.Context(), userID, body.Nickname)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 40113, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, me)
|
||||
}
|
||||
|
||||
// UploadAvatar handles POST /auth/me/avatar (multipart file).
|
||||
func (h *AuthHandler) UploadAvatar(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "请选择图片")
|
||||
return
|
||||
}
|
||||
me, err := h.Svc.UpdateAvatar(c.Request.Context(), userID, fh)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 40114, err.Error())
|
||||
return
|
||||
}
|
||||
response.OK(c, me)
|
||||
}
|
||||
|
||||
func bearerToken(c *gin.Context) string {
|
||||
h := c.GetHeader("Authorization")
|
||||
if strings.HasPrefix(strings.ToLower(h), "bearer ") {
|
||||
|
||||
@@ -55,6 +55,9 @@ func (h *CompanionHandler) SaveMood(c *gin.Context) {
|
||||
}
|
||||
m, err := h.Svc.SaveMood(c.Request.Context(), userID, in)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 30010, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -6,11 +6,14 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/explore"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/service/scale"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// ExploreHandler serves the explore catalog.
|
||||
type ExploreHandler struct{}
|
||||
type ExploreHandler struct {
|
||||
Scales *scale.Service
|
||||
}
|
||||
|
||||
// Register mounts explore routes.
|
||||
func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
|
||||
@@ -20,7 +23,13 @@ func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
|
||||
|
||||
// Catalog handles GET /explore/catalog.
|
||||
func (h *ExploreHandler) Catalog(c *gin.Context) {
|
||||
response.OK(c, gin.H{"categories": explore.Catalog()})
|
||||
cats := explore.Catalog()
|
||||
if h.Scales != nil {
|
||||
if items, err := h.Scales.List(c.Request.Context()); err == nil {
|
||||
cats = explore.WithPublishedScales(cats, items)
|
||||
}
|
||||
}
|
||||
response.OK(c, gin.H{"categories": cats})
|
||||
}
|
||||
|
||||
// Category handles GET /explore/catalog/:key.
|
||||
@@ -30,5 +39,14 @@ func (h *ExploreHandler) Category(c *gin.Context) {
|
||||
response.Fail(c, http.StatusNotFound, 40402, "category not found")
|
||||
return
|
||||
}
|
||||
if cat.Key == "tests" && h.Scales != nil {
|
||||
if items, err := h.Scales.List(c.Request.Context()); err == nil {
|
||||
enriched := explore.WithPublishedScales([]explore.Category{*cat}, items)
|
||||
if len(enriched) > 0 {
|
||||
response.OK(c, enriched[0])
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
response.OK(c, cat)
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -10,6 +9,7 @@ import (
|
||||
|
||||
"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/textsafe"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
@@ -57,12 +57,17 @@ func (h *GrowthHandler) CreatePlan(c *gin.Context) {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
|
||||
return
|
||||
}
|
||||
title := strings.TrimSpace(req.Title)
|
||||
if title == "" || len([]rune(title)) > 40 {
|
||||
response.Fail(c, http.StatusBadRequest, 10000, "invalid title")
|
||||
title, err := textsafe.Check(textsafe.Title, req.Title)
|
||||
if err != nil {
|
||||
_ = failTextCompliance(c, err)
|
||||
return
|
||||
}
|
||||
p, err := h.Plans.CreatePlan(c.Request.Context(), userID, title, strings.TrimSpace(req.Focus))
|
||||
focus, err := textsafe.Check(textsafe.Focus, req.Focus)
|
||||
if err != nil {
|
||||
_ = failTextCompliance(c, err)
|
||||
return
|
||||
}
|
||||
p, err := h.Plans.CreatePlan(c.Request.Context(), userID, title, focus)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 30020, err.Error())
|
||||
return
|
||||
@@ -86,6 +91,18 @@ func (h *GrowthHandler) Checkin(c *gin.Context) {
|
||||
Note *string `json:"note"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.Note != nil {
|
||||
n, err := textsafe.Check(textsafe.Note, *req.Note)
|
||||
if err != nil {
|
||||
_ = failTextCompliance(c, err)
|
||||
return
|
||||
}
|
||||
if n == "" {
|
||||
req.Note = nil
|
||||
} else {
|
||||
req.Note = &n
|
||||
}
|
||||
}
|
||||
out, err := h.Plans.Checkin(c.Request.Context(), userID, pid, time.Now(), req.Note)
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusBadRequest, 30021, err.Error())
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/avatar"
|
||||
)
|
||||
|
||||
// MediaHandler serves uploaded media files.
|
||||
type MediaHandler struct {
|
||||
AvatarDir string
|
||||
}
|
||||
|
||||
// Register mounts public media routes.
|
||||
func (h *MediaHandler) Register(api *gin.RouterGroup) {
|
||||
api.GET("/media/avatars/:file", h.ServeAvatar)
|
||||
}
|
||||
|
||||
// ServeAvatar streams a stored avatar image.
|
||||
func (h *MediaHandler) ServeAvatar(c *gin.Context) {
|
||||
dir := h.AvatarDir
|
||||
if dir == "" {
|
||||
dir = "data/avatars"
|
||||
}
|
||||
full, err := avatar.ResolveAbs(dir, c.Param("file"))
|
||||
if err != nil {
|
||||
c.Status(http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
c.Header("Cache-Control", "public, max-age=86400")
|
||||
c.File(full)
|
||||
_ = filepath.Ext(full)
|
||||
}
|
||||
@@ -57,6 +57,9 @@ func (h *ProfileHandler) Create(c *gin.Context) {
|
||||
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
|
||||
})
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
if errors.Is(err, profile.ErrSelfExists) {
|
||||
response.Fail(c, http.StatusConflict, 40902, err.Error())
|
||||
return
|
||||
@@ -122,6 +125,9 @@ func (h *ProfileHandler) Update(c *gin.Context) {
|
||||
GeoLat: req.GeoLat, GeoLng: req.GeoLng, GeoVisible: req.GeoVisible,
|
||||
})
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusNotFound, 40401, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,10 +20,33 @@ type ScaleHandler struct {
|
||||
// Register mounts routes.
|
||||
func (h *ScaleHandler) Register(rg *gin.RouterGroup) {
|
||||
rg.GET("/scales", h.List)
|
||||
rg.GET("/scale-bank/catalog", h.BankCatalog)
|
||||
rg.GET("/scale-bank/categories/:key", h.BankCategory)
|
||||
rg.GET("/scales/:slug", h.Get)
|
||||
rg.GET("/scales/:slug/result", h.LatestResult)
|
||||
rg.POST("/scales/:slug/result", h.Submit)
|
||||
}
|
||||
|
||||
// BankCatalog handles GET /scale-bank/catalog.
|
||||
func (h *ScaleHandler) BankCatalog(c *gin.Context) {
|
||||
out, err := h.Svc.BankCatalog()
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusInternalServerError, 50003, "bank catalog failed")
|
||||
return
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// BankCategory handles GET /scale-bank/categories/:key.
|
||||
func (h *ScaleHandler) BankCategory(c *gin.Context) {
|
||||
cat, items, err := h.Svc.BankCategory(c.Param("key"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 40402, "category not found")
|
||||
return
|
||||
}
|
||||
response.OK(c, gin.H{"category": cat, "items": items})
|
||||
}
|
||||
|
||||
// List handles GET /scales.
|
||||
func (h *ScaleHandler) List(c *gin.Context) {
|
||||
items, err := h.Svc.List(c.Request.Context())
|
||||
@@ -35,7 +59,8 @@ func (h *ScaleHandler) List(c *gin.Context) {
|
||||
|
||||
// Get handles GET /scales/:slug.
|
||||
func (h *ScaleHandler) Get(c *gin.Context) {
|
||||
d, err := h.Svc.Get(c.Request.Context(), c.Param("slug"))
|
||||
userID, _ := middleware.UserIDFromContext(c)
|
||||
d, err := h.Svc.Get(c.Request.Context(), userID, c.Param("slug"))
|
||||
if err != nil {
|
||||
response.Fail(c, http.StatusNotFound, 40402, err.Error())
|
||||
return
|
||||
@@ -43,6 +68,25 @@ func (h *ScaleHandler) Get(c *gin.Context) {
|
||||
response.OK(c, d)
|
||||
}
|
||||
|
||||
// LatestResult handles GET /scales/:slug/result.
|
||||
func (h *ScaleHandler) LatestResult(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
if !ok {
|
||||
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.LatestResult(c.Request.Context(), userID, c.Param("slug"))
|
||||
if err != nil {
|
||||
if err.Error() == "scale not found" {
|
||||
response.Fail(c, http.StatusNotFound, 40402, err.Error())
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusNotFound, 40411, "result not found")
|
||||
return
|
||||
}
|
||||
response.OK(c, out)
|
||||
}
|
||||
|
||||
// Submit handles POST /scales/:slug/result.
|
||||
func (h *ScaleHandler) Submit(c *gin.Context) {
|
||||
userID, ok := middleware.UserIDFromContext(c)
|
||||
@@ -67,6 +111,10 @@ func (h *ScaleHandler) Submit(c *gin.Context) {
|
||||
ProfileID: pid, Answers: req.Answers,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, scale.ErrMembershipRequired) {
|
||||
response.Fail(c, http.StatusForbidden, 40310, "需要成长会员")
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 30006, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -116,6 +116,9 @@ func (h *SynastryHandler) AcceptInvite(c *gin.Context) {
|
||||
}
|
||||
rep, err := h.Svc.AcceptInvite(c.Request.Context(), userID, c.Param("token"), req.DisplayName, req.BirthDate, req.BirthTime, req.BirthPlace)
|
||||
if err != nil {
|
||||
if failTextCompliance(c, err) {
|
||||
return
|
||||
}
|
||||
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"github.com/yuxingu/digital-psychology/apps/api/internal/textsafe"
|
||||
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
|
||||
)
|
||||
|
||||
// failTextCompliance maps textsafe rejections to HTTP 400 / code 40060.
|
||||
func failTextCompliance(c *gin.Context, err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, textsafe.ErrRejected) {
|
||||
response.Fail(c, http.StatusBadRequest, 40060, err.Error())
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user