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:
jackyu66git
2026-08-13 01:27:58 +08:00
co-authored by Cursor
parent 13860bf1ef
commit 89756f65b4
227 changed files with 7405 additions and 1407 deletions
+11 -1
View File
@@ -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
}
+27
View File
@@ -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 ") {
+3
View File
@@ -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
}
+20 -2
View File
@@ -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)
}
+22 -5
View File
@@ -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())
+36
View File
@@ -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)
}
+6
View File
@@ -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
}
+49 -1
View File
@@ -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
}
+3
View File
@@ -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
}
+23
View File
@@ -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
}