feat: P1 合盘/星座/问答与测测完整设计包及模拟器取证工具

落地 synastry/star/ask API 与 H5 页面,补齐 cece-frontend-re complete-design 证据文档,并加入 Android 模拟器截图抓取脚本。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-03 11:37:53 +08:00
co-authored by Cursor
parent 15a9db374a
commit bd22d9dddd
248 changed files with 26309 additions and 842 deletions
+121
View File
@@ -0,0 +1,121 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"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/pkg/response"
)
// AskHandler exposes AI 成长助手 APIs.
type AskHandler struct {
Svc *asksvc.Service
}
// Register mounts ask routes.
func (h *AskHandler) Register(rg *gin.RouterGroup) {
rg.GET("/ask/quota", h.GetQuota)
rg.POST("/ask/threads", h.CreateThread)
rg.GET("/ask/threads/:id/messages", h.ListMessages)
rg.POST("/ask/threads/:id/messages", h.SendMessage)
}
// GetQuota handles GET /ask/quota.
func (h *AskHandler) GetQuota(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
q, err := h.Svc.GetQuota(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, q)
}
// CreateThread handles POST /ask/threads.
func (h *AskHandler) CreateThread(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
ProfileID string `json:"profile_id" binding:"required"`
Scene string `json:"scene"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
pid, err := uuid.Parse(req.ProfileID)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id")
return
}
th, err := h.Svc.CreateThread(c.Request.Context(), userID, asksvc.CreateThreadInput{
ProfileID: pid, Scene: req.Scene,
})
if err != nil {
response.Fail(c, http.StatusBadRequest, 40010, err.Error())
return
}
response.OK(c, th)
}
// ListMessages handles GET /ask/threads/:id/messages.
func (h *AskHandler) ListMessages(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
tid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
items, err := h.Svc.ListMessages(c.Request.Context(), userID, tid)
if err != nil {
response.Fail(c, http.StatusNotFound, 40410, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// SendMessage handles POST /ask/threads/:id/messages.
func (h *AskHandler) SendMessage(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
tid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
var req struct {
Content string `json:"content" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
out, err := h.Svc.SendMessage(c.Request.Context(), userID, tid, req.Content)
if err != nil {
if asksvc.IsQuotaExhausted(err) {
response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可开通成长会员获取更多次数")
return
}
response.Fail(c, http.StatusBadRequest, 40011, err.Error())
return
}
response.OK(c, out)
}
+77
View File
@@ -0,0 +1,77 @@
package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// CompanionHandler exposes solar terms and moods.
type CompanionHandler struct {
Svc *companionsvc.Service
}
// Register mounts companion routes.
func (h *CompanionHandler) Register(rg *gin.RouterGroup) {
rg.GET("/solar-terms/today", h.TodaySolar)
rg.POST("/moods", h.SaveMood)
rg.GET("/moods/today", h.TodayMood)
}
// TodaySolar handles GET /solar-terms/today (auth optional via group).
func (h *CompanionHandler) TodaySolar(c *gin.Context) {
response.OK(c, h.Svc.TodaySolar())
}
// SaveMood handles POST /moods.
func (h *CompanionHandler) SaveMood(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
Score *int `json:"score"`
Note *string `json:"note"`
Day *string `json:"day"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
in := companionsvc.SaveMoodInput{Score: req.Score, Note: req.Note}
if req.Day != nil && *req.Day != "" {
d, err := time.Parse("2006-01-02", *req.Day)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid day")
return
}
in.Day = &d
}
m, err := h.Svc.SaveMood(c.Request.Context(), userID, in)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30010, err.Error())
return
}
response.OK(c, m)
}
// TodayMood handles GET /moods/today.
func (h *CompanionHandler) TodayMood(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
m, err := h.Svc.GetTodayMood(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"mood": m})
}
+34
View File
@@ -0,0 +1,34 @@
package handler
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/yuxingu/digital-psychology/apps/api/internal/explore"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// ExploreHandler serves the explore catalog.
type ExploreHandler struct{}
// Register mounts explore routes.
func (h *ExploreHandler) Register(rg *gin.RouterGroup) {
rg.GET("/explore/catalog", h.Catalog)
rg.GET("/explore/catalog/:key", h.Category)
}
// Catalog handles GET /explore/catalog.
func (h *ExploreHandler) Catalog(c *gin.Context) {
response.OK(c, gin.H{"categories": explore.Catalog()})
}
// Category handles GET /explore/catalog/:key.
func (h *ExploreHandler) Category(c *gin.Context) {
cat := explore.CategoryByKey(c.Param("key"))
if cat == nil {
response.Fail(c, http.StatusNotFound, 40402, "category not found")
return
}
response.OK(c, cat)
}
+130
View File
@@ -0,0 +1,130 @@
package handler
import (
"net/http"
"strings"
"time"
"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/repository"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// GrowthHandler exposes growth plans and mood trail.
type GrowthHandler struct {
Plans *repository.GrowthRepo
}
// Register mounts growth routes.
func (h *GrowthHandler) Register(rg *gin.RouterGroup) {
rg.GET("/growth/plans", h.ListPlans)
rg.POST("/growth/plans", h.CreatePlan)
rg.POST("/growth/plans/:id/checkin", h.Checkin)
rg.GET("/growth/plans/:id/checkins", h.ListCheckins)
rg.GET("/moods/recent", h.MoodsRecent)
}
// ListPlans handles GET /growth/plans.
func (h *GrowthHandler) ListPlans(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
items, err := h.Plans.ListPlans(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// CreatePlan handles POST /growth/plans.
func (h *GrowthHandler) CreatePlan(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
Title string `json:"title" binding:"required"`
Focus string `json:"focus"`
}
if err := c.ShouldBindJSON(&req); err != nil {
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")
return
}
p, err := h.Plans.CreatePlan(c.Request.Context(), userID, title, strings.TrimSpace(req.Focus))
if err != nil {
response.Fail(c, http.StatusBadRequest, 30020, err.Error())
return
}
response.OK(c, p)
}
// Checkin handles POST /growth/plans/:id/checkin.
func (h *GrowthHandler) Checkin(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
var req struct {
Note *string `json:"note"`
}
_ = c.ShouldBindJSON(&req)
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())
return
}
response.OK(c, out)
}
// ListCheckins handles GET /growth/plans/:id/checkins.
func (h *GrowthHandler) ListCheckins(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
items, err := h.Plans.ListCheckinsRecent(c.Request.Context(), userID, pid, 14)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// MoodsRecent handles GET /moods/recent.
func (h *GrowthHandler) MoodsRecent(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
items, err := h.Plans.ListMoodsRecent(c.Request.Context(), userID, 7)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
+81
View File
@@ -0,0 +1,81 @@
package handler
import (
"errors"
"net/http"
"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/repository"
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// ImageCardHandler exposes 意象卡片 APIs.
type ImageCardHandler struct {
Svc *imagecardsvc.Service
}
// Register mounts image-card routes.
func (h *ImageCardHandler) Register(rg *gin.RouterGroup) {
rg.GET("/image-cards/scenes", h.Scenes)
rg.GET("/image-cards/quota", h.Quota)
rg.POST("/image-cards/draw", h.Draw)
}
// Scenes handles GET /image-cards/scenes.
func (h *ImageCardHandler) Scenes(c *gin.Context) {
response.OK(c, gin.H{"items": h.Svc.Scenes()})
}
// Quota handles GET /image-cards/quota.
func (h *ImageCardHandler) Quota(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
q, err := h.Svc.Quota(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, q)
}
// Draw handles POST /image-cards/draw.
func (h *ImageCardHandler) Draw(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
Scene string `json:"scene"`
ProfileID string `json:"profile_id" binding:"required"`
Depth bool `json:"depth"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
pid, err := uuid.Parse(req.ProfileID)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id")
return
}
out, err := h.Svc.Draw(c.Request.Context(), userID, imagecardsvc.DrawInput{
Scene: req.Scene, ProfileID: pid, Depth: req.Depth,
})
if err != nil {
if errors.Is(err, repository.ErrQuotaExhausted) {
response.Fail(c, http.StatusPaymentRequired, 40201, err.Error())
return
}
response.Fail(c, http.StatusBadRequest, 30011, err.Error())
return
}
response.OK(c, out)
}
+72 -1
View File
@@ -5,6 +5,7 @@ import (
"time"
"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/profile"
@@ -20,6 +21,8 @@ type ProfileHandler struct {
func (h *ProfileHandler) Register(rg *gin.RouterGroup) {
rg.GET("/profiles", h.List)
rg.POST("/profiles", h.Create)
rg.PATCH("/profiles/:id", h.Update)
rg.DELETE("/profiles/:id", h.Delete)
}
type createProfileReq struct {
@@ -27,6 +30,8 @@ type createProfileReq struct {
DisplayName string `json:"display_name"`
BirthDate string `json:"birth_date" binding:"required"`
RelationType *string `json:"relation_type"`
BirthTime *string `json:"birth_time"`
BirthPlace *string `json:"birth_place"`
}
// Create handles POST /profiles.
@@ -47,7 +52,8 @@ func (h *ProfileHandler) Create(c *gin.Context) {
return
}
p, err := h.Svc.Create(c.Request.Context(), userID, profile.CreateInput{
Relation: req.Relation, DisplayName: req.DisplayName, BirthDate: birth, RelationType: req.RelationType,
Relation: req.Relation, DisplayName: req.DisplayName, BirthDate: birth,
RelationType: req.RelationType, BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
})
if err != nil {
response.Fail(c, http.StatusBadRequest, 30001, err.Error())
@@ -70,3 +76,68 @@ func (h *ProfileHandler) List(c *gin.Context) {
}
response.OK(c, gin.H{"items": list})
}
// Update handles PATCH /profiles/:id.
func (h *ProfileHandler) Update(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
var req struct {
DisplayName string `json:"display_name"`
BirthDate string `json:"birth_date"`
RelationType *string `json:"relation_type"`
BirthTime *string `json:"birth_time"`
BirthPlace *string `json:"birth_place"`
GeoLat *float64 `json:"geo_lat"`
GeoLng *float64 `json:"geo_lng"`
GeoVisible *bool `json:"geo_visible"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
var birth time.Time
if req.BirthDate != "" {
birth, err = time.Parse("2006-01-02", req.BirthDate)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "birth_date must be YYYY-MM-DD")
return
}
}
p, err := h.Svc.Update(c.Request.Context(), userID, pid, profile.UpdateInput{
DisplayName: req.DisplayName, BirthDate: birth, RelationType: req.RelationType,
BirthTime: req.BirthTime, BirthPlace: req.BirthPlace,
GeoLat: req.GeoLat, GeoLng: req.GeoLng, GeoVisible: req.GeoVisible,
})
if err != nil {
response.Fail(c, http.StatusNotFound, 40401, err.Error())
return
}
response.OK(c, p)
}
// Delete handles DELETE /profiles/:id.
func (h *ProfileHandler) Delete(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
pid, err := uuid.Parse(c.Param("id"))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid id")
return
}
if err := h.Svc.Delete(c.Request.Context(), userID, pid); err != nil {
response.Fail(c, http.StatusNotFound, 40401, err.Error())
return
}
response.OK(c, gin.H{"deleted": true})
}
+137
View File
@@ -2,6 +2,7 @@ package handler
import (
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -19,7 +20,12 @@ type ReportHandler struct {
// Register mounts report/commerce routes.
func (h *ReportHandler) Register(rg *gin.RouterGroup) {
rg.POST("/reports/portrait", h.CreatePortrait)
rg.POST("/reports/star", h.CreateStar)
rg.POST("/reports/synastry", h.CreateSynastry)
rg.POST("/reports/rhythm", h.CreateRhythm)
rg.GET("/reports", h.List)
rg.GET("/reports/:id", h.Get)
rg.GET("/membership/me", h.GetMembership)
rg.POST("/orders", h.CreateOrder)
rg.POST("/orders/:id/pay-mock", h.PayMock)
}
@@ -51,6 +57,122 @@ func (h *ReportHandler) CreatePortrait(c *gin.Context) {
response.OK(c, rep)
}
// CreateStar handles POST /reports/star (星象性格).
func (h *ReportHandler) CreateStar(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
ProfileID string `json:"profile_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
pid, err := uuid.Parse(req.ProfileID)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id")
return
}
rep, err := h.Svc.CreateStar(c.Request.Context(), userID, pid)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, rep)
}
// CreateSynastry handles POST /reports/synastry (五主盘 + 推运合盘).
func (h *ReportHandler) CreateSynastry(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
ProfileIDA string `json:"profile_id_a" binding:"required"`
ProfileIDB string `json:"profile_id_b" binding:"required"`
AsOf *string `json:"as_of"` // YYYY-MM-DD optional
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
aid, err := uuid.Parse(req.ProfileIDA)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id_a")
return
}
bid, err := uuid.Parse(req.ProfileIDB)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id_b")
return
}
if aid == bid {
response.Fail(c, http.StatusBadRequest, 10000, "需要两个不同档案")
return
}
var asOfPtr *time.Time
if req.AsOf != nil && *req.AsOf != "" {
t, err := time.ParseInLocation("2006-01-02", *req.AsOf, time.FixedZone("CST", 8*3600))
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid as_of")
return
}
asOfPtr = &t
}
rep, err := h.Svc.CreateSynastry(c.Request.Context(), userID, aid, bid, asOfPtr)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, rep)
}
// CreateRhythm handles POST /reports/rhythm (身心节律) — wired when service ready.
func (h *ReportHandler) CreateRhythm(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
ProfileID string `json:"profile_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
pid, err := uuid.Parse(req.ProfileID)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id")
return
}
rep, err := h.Svc.CreateRhythm(c.Request.Context(), userID, pid)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, rep)
}
// List handles GET /reports.
func (h *ReportHandler) List(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
items, err := h.Svc.List(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// Get handles GET /reports/:id.
func (h *ReportHandler) Get(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
@@ -71,6 +193,21 @@ func (h *ReportHandler) Get(c *gin.Context) {
response.OK(c, rep)
}
// GetMembership handles GET /membership/me.
func (h *ReportHandler) GetMembership(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
me, err := h.Svc.GetMembership(c.Request.Context(), userID)
if err != nil {
response.Fail(c, http.StatusInternalServerError, 50000, err.Error())
return
}
response.OK(c, me)
}
// CreateOrder handles POST /orders.
func (h *ReportHandler) CreateOrder(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
+123
View File
@@ -0,0 +1,123 @@
package handler
import (
"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/report"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// SynastryHandler exposes nearby + invite social APIs.
type SynastryHandler struct {
Svc *report.Service
}
// Register mounts synastry social routes.
func (h *SynastryHandler) Register(rg *gin.RouterGroup) {
rg.GET("/synastry/nearby", h.Nearby)
rg.POST("/synastry/invites", h.CreateInvite)
rg.GET("/synastry/invites/:token", h.GetInvite)
rg.POST("/synastry/invites/:token/accept", h.AcceptInvite)
}
// Nearby handles GET /synastry/nearby?lat=&lng=&radius_km=
func (h *SynastryHandler) Nearby(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
lat, err1 := strconv.ParseFloat(c.Query("lat"), 64)
lng, err2 := strconv.ParseFloat(c.Query("lng"), 64)
if err1 != nil || err2 != nil {
response.Fail(c, http.StatusBadRequest, 10000, "lat/lng required")
return
}
radius := 50.0
if v := c.Query("radius_km"); v != "" {
if r, err := strconv.ParseFloat(v, 64); err == nil {
radius = r
}
}
items, err := h.Svc.Nearby(c.Request.Context(), userID, lat, lng, radius)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, gin.H{"items": items})
}
// CreateInvite handles POST /synastry/invites
func (h *SynastryHandler) CreateInvite(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
ProfileID string `json:"profile_id" binding:"required"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
pid, err := uuid.Parse(req.ProfileID)
if err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid profile_id")
return
}
inv, err := h.Svc.CreateInvite(c.Request.Context(), userID, pid)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, gin.H{
"token": inv.Token,
"expires_at": inv.ExpiresAt,
"path": "/synastry/invite/" + inv.Token,
})
}
// GetInvite handles GET /synastry/invites/:token
func (h *SynastryHandler) GetInvite(c *gin.Context) {
if _, ok := middleware.UserIDFromContext(c); !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
meta, err := h.Svc.GetInvite(c.Request.Context(), c.Param("token"))
if err != nil {
response.Fail(c, http.StatusNotFound, 40401, err.Error())
return
}
response.OK(c, meta)
}
// AcceptInvite handles POST /synastry/invites/:token/accept
func (h *SynastryHandler) AcceptInvite(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
return
}
var req struct {
DisplayName string `json:"display_name"`
BirthDate string `json:"birth_date" binding:"required"`
BirthTime *string `json:"birth_time"`
BirthPlace *string `json:"birth_place"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
rep, err := h.Svc.AcceptInvite(c.Request.Context(), userID, c.Param("token"), req.DisplayName, req.BirthDate, req.BirthTime, req.BirthPlace)
if err != nil {
response.Fail(c, http.StatusBadRequest, 30002, err.Error())
return
}
response.OK(c, rep)
}