近订单
无订单
- | ID | 类型 | 状态 | 金额 |
+ | 类型 | 状态 | 金额 | 时间 |
- | {{ o.id }} |
{{ o.kind }} {{ o.plan || '' }} |
{{ o.status }} |
{{ (o.amount_cents / 100).toFixed(2) }} |
+ {{ fmtTime(o.created_at) }} |
@@ -94,9 +173,19 @@ onMounted(load)
diff --git a/apps/admin-h5/src/pages/UsersPage.vue b/apps/admin-h5/src/pages/UsersPage.vue
index 01ecfd3..33860ab 100644
--- a/apps/admin-h5/src/pages/UsersPage.vue
+++ b/apps/admin-h5/src/pages/UsersPage.vue
@@ -1,12 +1,12 @@
- 用户
+
+
用户
+
支持手机号、昵称、UUID 搜索
+
@@ -37,25 +52,68 @@ onMounted(load)
{{ error }}
暂无用户
-
-
- | ID | 状态 | 创建时间 |
-
-
-
- | {{ u.id }} |
- {{ u.status }} |
- {{ u.created_at }} |
-
-
-
+
+
+
+
+ | 用户 |
+ 会员 |
+ 问答余量 |
+ 档案 |
+ 创建 |
+
+
+
+
+ |
+
+ {{ u.nickname || u.phone || shortID(u.id) }}
+
+ {{ u.phone || '未绑定手机' }} · {{ shortID(u.id) }}…
+ |
+
+
+ {{ u.membership_active ? (u.membership_plan || '有效') : '未开通' }}
+
+ |
+ {{ u.ask_paid_quota_left }} |
+ {{ u.profile_count }} |
+ {{ fmtTime(u.created_at) }} |
+
+
+
+
diff --git a/apps/admin-h5/src/router/index.ts b/apps/admin-h5/src/router/index.ts
index 8ca1d81..825919f 100644
--- a/apps/admin-h5/src/router/index.ts
+++ b/apps/admin-h5/src/router/index.ts
@@ -2,14 +2,17 @@ import { createRouter, createWebHistory } from 'vue-router'
import { getToken } from '@/api/client'
const router = createRouter({
- history: createWebHistory(),
+ history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/login', name: 'login', component: () => import('@/pages/LoginPage.vue'), meta: { public: true } },
{
path: '/',
component: () => import('@/layouts/AdminShell.vue'),
children: [
- { path: '', name: 'users', component: () => import('@/pages/UsersPage.vue') },
+ { path: '', name: 'dashboard', component: () => import('@/pages/DashboardPage.vue') },
+ { path: 'analytics', name: 'analytics', component: () => import('@/pages/AnalyticsPage.vue') },
+ { path: 'content', name: 'content', component: () => import('@/pages/ContentPage.vue') },
+ { path: 'users', name: 'users', component: () => import('@/pages/UsersPage.vue') },
{ path: 'users/:id', name: 'user', component: () => import('@/pages/UserDetailPage.vue') },
{ path: 'orders', name: 'orders', component: () => import('@/pages/OrdersPage.vue') },
{ path: 'audit', name: 'audit', component: () => import('@/pages/AuditPage.vue') },
diff --git a/apps/admin-h5/src/styles.css b/apps/admin-h5/src/styles.css
index 0925698..67cccb6 100644
--- a/apps/admin-h5/src/styles.css
+++ b/apps/admin-h5/src/styles.css
@@ -1,36 +1,76 @@
+/* 愈心谷运营后台 — 对齐 packages/ui tokens */
:root {
- --bg: #f3f0eb;
- --panel: #fffdf9;
- --ink: #1c1917;
- --muted: #78716c;
- --line: #e7e5e4;
- --accent: #c45c4a;
+ --color-primary: #e54d42;
+ --color-primary-hover: #d44338;
+ --color-primary-soft: #ffe4e4;
+ --color-bg-start: #ffd1c7;
+ --color-bg-end: #ffc8b5;
+ --color-bg-sheet: #fff9f7;
+ --color-surface: #ffffff;
+ --color-text-primary: #333333;
+ --color-text-secondary: #999999;
+ --color-border: #f0e6e2;
+ --color-accent-gold: #c8923a;
+ --color-accent-blue: #4a90e2;
+ --color-accent-green: #5cb85c;
+ --color-error: #e54d42;
+
+ --bg: linear-gradient(165deg, #ffe8e0 0%, #fff5f1 42%, #ffece5 100%);
+ --panel: #fffdfb;
+ --ink: #333333;
+ --muted: #9a8f8b;
+ --line: #f0e6e2;
+ --accent: #e54d42;
--accent-ink: #fff;
- --danger: #b91c1c;
- font-family: "IBM Plex Sans", "PingFang SC", "Noto Sans SC", sans-serif;
+ --danger: #e54d42;
+ --radius: 14px;
+ --shadow: 0 8px 28px rgba(229, 77, 66, 0.08);
+
+ font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
color: var(--ink);
- background: var(--bg);
+ background: var(--color-bg-sheet);
}
* { box-sizing: border-box; }
-body { margin: 0; min-height: 100vh; background: linear-gradient(160deg, #f7f3ee 0%, #ebe6df 45%, #f3efea 100%); }
+body {
+ margin: 0;
+ min-height: 100vh;
+ background: var(--bg);
+ background-attachment: fixed;
+}
a { color: inherit; text-decoration: none; }
button, input, select { font: inherit; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.65rem 0.75rem; border-bottom: 1px solid var(--line); font-size: 0.92rem; }
th { color: var(--muted); font-weight: 600; }
.btn {
- border: 0; border-radius: 8px; padding: 0.55rem 1rem; cursor: pointer;
- background: var(--accent); color: var(--accent-ink);
+ border: 0; border-radius: 12px; padding: 0.55rem 1rem; cursor: pointer;
+ background: linear-gradient(135deg, #ff7a6e, var(--accent));
+ color: var(--accent-ink);
+ box-shadow: 0 6px 16px rgba(229, 77, 66, 0.22);
+ font-weight: 650;
}
-.btn.ghost { background: transparent; color: var(--ink); border: 1px solid var(--line); }
-.btn:disabled { opacity: 0.55; cursor: not-allowed; }
+.btn.ghost {
+ background: transparent; color: var(--ink);
+ border: 1px solid var(--line); box-shadow: none; font-weight: 500;
+}
+.btn:disabled { opacity: 0.55; cursor: not-allowed; box-shadow: none; }
.field { display: flex; flex-direction: column; gap: 0.35rem; margin-bottom: 0.9rem; }
.field input, .field select {
- border: 1px solid var(--line); border-radius: 8px; padding: 0.6rem 0.75rem; background: #fff;
+ border: 1.5px solid var(--line); border-radius: 12px; padding: 0.6rem 0.75rem;
+ background: #fdfaf8;
+}
+.field input:focus, .field select:focus {
+ outline: none; border-color: #f0b8b0;
+ box-shadow: 0 0 0 3px rgba(229, 77, 66, 0.1);
}
.err { color: var(--danger); font-size: 0.9rem; }
.muted { color: var(--muted); }
.card {
- background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.1rem 1.2rem;
+ background: var(--panel);
+ border: 1px solid rgba(255, 255, 255, 0.9);
+ border-radius: var(--radius);
+ padding: 1.1rem 1.2rem;
+ box-shadow: var(--shadow);
}
+code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
diff --git a/apps/admin-h5/vite.config.ts b/apps/admin-h5/vite.config.ts
index b84e0fa..360548d 100644
--- a/apps/admin-h5/vite.config.ts
+++ b/apps/admin-h5/vite.config.ts
@@ -4,7 +4,8 @@ import { fileURLToPath, URL } from 'node:url'
export default defineConfig({
plugins: [vue()],
- base: '/',
+ // Production is mounted at https://…/psy/admin/ ; local `vite` still uses `/`.
+ base: process.env.VITE_BASE || (process.env.NODE_ENV === 'production' ? '/psy/admin/' : '/'),
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url)),
@@ -17,6 +18,11 @@ export default defineConfig({
target: 'http://127.0.0.1:8080',
changeOrigin: true,
},
+ '/psy/api': {
+ target: 'http://127.0.0.1:8080',
+ changeOrigin: true,
+ rewrite: (p) => p.replace(/^\/psy\/api/, '/api'),
+ },
},
},
})
diff --git a/apps/api/internal/ask/reply.go b/apps/api/internal/ask/reply.go
index e46374b..ef1f8fa 100644
--- a/apps/api/internal/ask/reply.go
+++ b/apps/api/internal/ask/reply.go
@@ -78,11 +78,10 @@ func BuildReply(in ReplyInput) string {
)
}
- disclaimer := "以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
if sceneHint != "" {
- return sceneHint + "\n\n" + strings.TrimSpace(body) + "\n\n" + disclaimer
+ return sceneHint + "\n\n" + strings.TrimSpace(body)
}
- return strings.TrimSpace(body) + "\n\n" + disclaimer
+ return strings.TrimSpace(body)
}
func sectionByFocus(detail map[string]any, focus string) string {
diff --git a/apps/api/internal/ask/reply_test.go b/apps/api/internal/ask/reply_test.go
index ff6974b..e9c46d5 100644
--- a/apps/api/internal/ask/reply_test.go
+++ b/apps/api/internal/ask/reply_test.go
@@ -21,8 +21,8 @@ func TestBuildReply_profileAware(t *testing.T) {
if strings.Contains(out, "算命") || strings.Contains(out, "运势") || strings.Contains(out, "吉凶") {
t.Fatalf("forbidden lexicon in reply: %s", out)
}
- if !strings.Contains(out, "不构成") {
- t.Fatalf("expected disclaimer: %s", out)
+ if strings.Contains(out, "不构成医疗") || strings.Contains(out, "占卜预测") {
+ t.Fatalf("disclaimer should stay in UI only: %s", out)
}
}
diff --git a/apps/api/internal/handler/admin.go b/apps/api/internal/handler/admin.go
index 621445d..eec2d38 100644
--- a/apps/api/internal/handler/admin.go
+++ b/apps/api/internal/handler/admin.go
@@ -10,12 +10,14 @@ import (
"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/internal/service/analytics"
"github.com/yuxingu/digital-psychology/apps/api/pkg/response"
)
// AdminHandler serves /api/v1/admin/* (no DeviceAuth).
type AdminHandler struct {
- Svc *admin.Service
+ Svc *admin.Service
+ Analytics *analytics.Service
}
// Register mounts public login + authed admin routes.
@@ -27,11 +29,19 @@ func (h *AdminHandler) Register(api *gin.RouterGroup) {
authed.Use(middleware.AdminAuth(h.Svc))
authed.POST("/auth/logout", h.Logout)
authed.GET("/me", h.Me)
+ authed.GET("/stats", h.Stats)
authed.GET("/users", h.ListUsers)
authed.GET("/users/:id", h.GetUser)
authed.POST("/users/:id/membership/grant", h.GrantMembership)
+ authed.POST("/users/:id/ask-quota/grant", h.GrantAskQuota)
authed.GET("/orders", h.ListOrders)
authed.GET("/audit-logs", h.ListAudit)
+ authed.GET("/analytics/overview", h.AnalyticsOverview)
+ authed.GET("/analytics/pages", h.AnalyticsPages)
+ authed.GET("/analytics/exits", h.AnalyticsExits)
+ authed.GET("/analytics/clicks", h.AnalyticsClicks)
+ authed.GET("/analytics/funnel", h.AnalyticsFunnel)
+ h.registerContent(authed)
}
func (h *AdminHandler) Login(c *gin.Context) {
@@ -75,6 +85,15 @@ func (h *AdminHandler) Me(c *gin.Context) {
response.OK(c, me)
}
+func (h *AdminHandler) Stats(c *gin.Context) {
+ stats, err := h.Svc.DashboardStats(c.Request.Context())
+ if err != nil {
+ response.Fail(c, http.StatusInternalServerError, 50017, "stats failed")
+ return
+ }
+ response.OK(c, stats)
+}
+
func (h *AdminHandler) ListUsers(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
@@ -135,6 +154,38 @@ func (h *AdminHandler) GrantMembership(c *gin.Context) {
response.OK(c, gin.H{"ok": true})
}
+func (h *AdminHandler) GrantAskQuota(c *gin.Context) {
+ adminID, ok := middleware.AdminIDFromContext(c)
+ if !ok {
+ response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
+ return
+ }
+ userID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 40002, "invalid user id")
+ return
+ }
+ var body admin.GrantAskQuotaInput
+ if err := c.ShouldBindJSON(&body); err != nil {
+ response.Fail(c, http.StatusBadRequest, 40005, "delta required")
+ return
+ }
+ left, err := h.Svc.GrantAskQuota(c.Request.Context(), adminID, userID, body.Delta)
+ if err != nil {
+ if errors.Is(err, admin.ErrInvalidAskDelta) {
+ response.Fail(c, http.StatusBadRequest, 40006, "invalid delta")
+ return
+ }
+ if errors.Is(err, admin.ErrUserNotFound) {
+ response.Fail(c, http.StatusNotFound, 40401, "user not found")
+ return
+ }
+ response.Fail(c, http.StatusInternalServerError, 50018, "grant ask quota failed")
+ return
+ }
+ response.OK(c, gin.H{"ok": true, "ask_paid_quota_left": left})
+}
+
func (h *AdminHandler) ListOrders(c *gin.Context) {
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
offset, _ := strconv.Atoi(c.DefaultQuery("offset", "0"))
@@ -156,3 +207,96 @@ func (h *AdminHandler) ListAudit(c *gin.Context) {
}
response.OK(c, gin.H{"items": items})
}
+
+func (h *AdminHandler) AnalyticsOverview(c *gin.Context) {
+ from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
+ return
+ }
+ if !h.requireAnalytics(c) {
+ return
+ }
+ data, err := h.Analytics.Overview(c.Request.Context(), from, to)
+ if err != nil {
+ response.Fail(c, http.StatusInternalServerError, 50022, "overview failed")
+ return
+ }
+ response.OK(c, data)
+}
+
+func (h *AdminHandler) AnalyticsPages(c *gin.Context) {
+ from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
+ return
+ }
+ if !h.requireAnalytics(c) {
+ return
+ }
+ items, err := h.Analytics.Pages(c.Request.Context(), from, to)
+ if err != nil {
+ response.Fail(c, http.StatusInternalServerError, 50023, "pages failed")
+ return
+ }
+ response.OK(c, gin.H{"items": items})
+}
+
+func (h *AdminHandler) AnalyticsExits(c *gin.Context) {
+ from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
+ return
+ }
+ if !h.requireAnalytics(c) {
+ return
+ }
+ items, err := h.Analytics.Exits(c.Request.Context(), from, to)
+ if err != nil {
+ response.Fail(c, http.StatusInternalServerError, 50024, "exits failed")
+ return
+ }
+ response.OK(c, gin.H{"items": items})
+}
+
+func (h *AdminHandler) AnalyticsClicks(c *gin.Context) {
+ from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
+ return
+ }
+ if !h.requireAnalytics(c) {
+ return
+ }
+ items, err := h.Analytics.Clicks(c.Request.Context(), from, to)
+ if err != nil {
+ response.Fail(c, http.StatusInternalServerError, 50025, "clicks failed")
+ return
+ }
+ response.OK(c, gin.H{"items": items})
+}
+
+func (h *AdminHandler) AnalyticsFunnel(c *gin.Context) {
+ from, to, err := analytics.ParseDayRange(c.Query("from"), c.Query("to"))
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 40030, "invalid from/to")
+ return
+ }
+ if !h.requireAnalytics(c) {
+ return
+ }
+ steps, err := h.Analytics.Funnel(c.Request.Context(), from, to)
+ if err != nil {
+ response.Fail(c, http.StatusInternalServerError, 50026, "funnel failed")
+ return
+ }
+ response.OK(c, gin.H{"steps": steps})
+}
+
+func (h *AdminHandler) requireAnalytics(c *gin.Context) bool {
+ if h.Analytics == nil {
+ response.Fail(c, http.StatusInternalServerError, 50021, "analytics unavailable")
+ return false
+ }
+ return true
+}
diff --git a/apps/api/internal/handler/admin_content.go b/apps/api/internal/handler/admin_content.go
new file mode 100644
index 0000000..ea0a0ba
--- /dev/null
+++ b/apps/api/internal/handler/admin_content.go
@@ -0,0 +1,96 @@
+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/service/admin"
+ homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
+ "github.com/yuxingu/digital-psychology/apps/api/pkg/response"
+)
+
+func (h *AdminHandler) registerContent(authed *gin.RouterGroup) {
+ authed.GET("/home/tools", h.ListHomeTools)
+ authed.PUT("/home/tools", h.ReplaceHomeTools)
+ authed.GET("/scales", h.ListScales)
+ authed.PATCH("/scales/:id", h.PatchScale)
+}
+
+func (h *AdminHandler) ListHomeTools(c *gin.Context) {
+ items, err := h.Svc.ListHomeTools(c.Request.Context())
+ if err != nil {
+ response.Fail(c, http.StatusInternalServerError, 50031, "list home tools failed")
+ return
+ }
+ response.OK(c, gin.H{"items": items})
+}
+
+func (h *AdminHandler) ReplaceHomeTools(c *gin.Context) {
+ adminID, ok := middleware.AdminIDFromContext(c)
+ if !ok {
+ response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
+ return
+ }
+ var body struct {
+ Items []homesvc.ReplaceInput `json:"items"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ response.Fail(c, http.StatusBadRequest, 40040, "invalid body")
+ return
+ }
+ if err := h.Svc.ReplaceHomeTools(c.Request.Context(), adminID, body.Items); err != nil {
+ if errors.Is(err, homesvc.ErrInvalidTools) || errors.Is(err, homesvc.ErrTooManyTools) {
+ response.Fail(c, http.StatusBadRequest, 40041, err.Error())
+ return
+ }
+ response.Fail(c, http.StatusInternalServerError, 50032, "replace home tools failed")
+ return
+ }
+ response.OK(c, gin.H{"ok": true})
+}
+
+func (h *AdminHandler) ListScales(c *gin.Context) {
+ items, err := h.Svc.ListScalesAdmin(c.Request.Context())
+ if err != nil {
+ response.Fail(c, http.StatusInternalServerError, 50033, "list scales failed")
+ return
+ }
+ response.OK(c, gin.H{"items": items})
+}
+
+func (h *AdminHandler) PatchScale(c *gin.Context) {
+ adminID, ok := middleware.AdminIDFromContext(c)
+ if !ok {
+ response.Fail(c, http.StatusUnauthorized, 40101, "admin auth required")
+ return
+ }
+ id, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 40042, "invalid scale id")
+ return
+ }
+ var body struct {
+ Status string `json:"status"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil || body.Status == "" {
+ response.Fail(c, http.StatusBadRequest, 40043, "status required")
+ return
+ }
+ if err := h.Svc.PatchScaleStatus(c.Request.Context(), adminID, id, body.Status); err != nil {
+ if errors.Is(err, admin.ErrInvalidScaleStatus) {
+ response.Fail(c, http.StatusBadRequest, 40044, "invalid status")
+ return
+ }
+ if errors.Is(err, admin.ErrScaleNotFound) {
+ response.Fail(c, http.StatusNotFound, 40410, "scale not found")
+ return
+ }
+ response.Fail(c, http.StatusInternalServerError, 50034, "patch scale failed")
+ return
+ }
+ response.OK(c, gin.H{"ok": true})
+}
diff --git a/apps/api/internal/handler/analytics.go b/apps/api/internal/handler/analytics.go
new file mode 100644
index 0000000..eeeefdf
--- /dev/null
+++ b/apps/api/internal/handler/analytics.go
@@ -0,0 +1,57 @@
+package handler
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/service/analytics"
+ "github.com/yuxingu/digital-psychology/apps/api/pkg/response"
+)
+
+// AnalyticsHandler serves POST /api/v1/analytics/events (DeviceAuth).
+type AnalyticsHandler struct {
+ Svc *analytics.Service
+}
+
+// Register mounts analytics routes on a DeviceAuth group.
+func (h *AnalyticsHandler) Register(api *gin.RouterGroup) {
+ g := api.Group("/analytics")
+ g.POST("/events", h.Ingest)
+}
+
+func (h *AnalyticsHandler) Ingest(c *gin.Context) {
+ userID, ok := middleware.UserIDFromContext(c)
+ if !ok {
+ response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
+ return
+ }
+ deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
+ if deviceKey == "" {
+ response.Fail(c, http.StatusBadRequest, 40020, "device key required")
+ return
+ }
+ var body struct {
+ Items []analytics.EventIn `json:"items"`
+ }
+ if err := c.ShouldBindJSON(&body); err != nil {
+ response.Fail(c, http.StatusBadRequest, 40021, "invalid body")
+ return
+ }
+ res, err := h.Svc.Ingest(c.Request.Context(), userID, deviceKey, body.Items)
+ if err != nil {
+ if errors.Is(err, analytics.ErrTooManyItems) {
+ response.Fail(c, http.StatusBadRequest, 40022, "too many items")
+ return
+ }
+ if errors.Is(err, analytics.ErrInvalidBatch) {
+ response.Fail(c, http.StatusBadRequest, 40023, "invalid batch")
+ return
+ }
+ response.Fail(c, http.StatusInternalServerError, 50020, "ingest failed")
+ return
+ }
+ response.OK(c, res)
+}
diff --git a/apps/api/internal/handler/ask.go b/apps/api/internal/handler/ask.go
index 15cf4bd..f69e4f1 100644
--- a/apps/api/internal/handler/ask.go
+++ b/apps/api/internal/handler/ask.go
@@ -1,7 +1,10 @@
package handler
import (
+ "encoding/json"
+ "fmt"
"net/http"
+ "strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -20,6 +23,7 @@ type AskHandler struct {
func (h *AskHandler) Register(rg *gin.RouterGroup) {
rg.GET("/ask/quota", h.GetQuota)
rg.POST("/ask/threads", h.CreateThread)
+ rg.DELETE("/ask/threads/:id", h.ClearThread)
rg.GET("/ask/threads/:id/messages", h.ListMessages)
rg.POST("/ask/threads/:id/messages", h.SendMessage)
}
@@ -69,6 +73,25 @@ func (h *AskHandler) CreateThread(c *gin.Context) {
response.OK(c, th)
}
+// ClearThread handles DELETE /ask/threads/:id.
+func (h *AskHandler) ClearThread(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
+ }
+ if err := h.Svc.ClearThread(c.Request.Context(), userID, tid); err != nil {
+ response.Fail(c, http.StatusNotFound, 40410, err.Error())
+ return
+ }
+ response.OK(c, gin.H{"cleared": true})
+}
+
// ListMessages handles GET /ask/threads/:id/messages.
func (h *AskHandler) ListMessages(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
@@ -90,6 +113,7 @@ func (h *AskHandler) ListMessages(c *gin.Context) {
}
// SendMessage handles POST /ask/threads/:id/messages.
+// Use ?stream=1 (or Accept: text/event-stream) for SSE streaming.
func (h *AskHandler) SendMessage(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
if !ok {
@@ -108,10 +132,18 @@ func (h *AskHandler) SendMessage(c *gin.Context) {
response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
return
}
+
+ wantStream := c.Query("stream") == "1" ||
+ strings.Contains(c.GetHeader("Accept"), "text/event-stream")
+ if wantStream {
+ h.sendMessageStream(c, userID, tid, req.Content)
+ 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, "问答次数已用完,可开通成长会员获取更多次数")
+ response.Fail(c, http.StatusPaymentRequired, 40210, "问答次数已用完,可购买额度或开通成长会员")
return
}
response.Fail(c, http.StatusBadRequest, 40011, err.Error())
@@ -119,3 +151,39 @@ func (h *AskHandler) SendMessage(c *gin.Context) {
}
response.OK(c, out)
}
+
+func (h *AskHandler) sendMessageStream(c *gin.Context, userID, tid uuid.UUID, content string) {
+ c.Writer.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
+ c.Writer.Header().Set("Cache-Control", "no-cache, no-transform")
+ c.Writer.Header().Set("Connection", "keep-alive")
+ c.Writer.Header().Set("X-Accel-Buffering", "no")
+ c.Status(http.StatusOK)
+ flusher, ok := c.Writer.(http.Flusher)
+ if !ok {
+ response.Fail(c, http.StatusInternalServerError, 50000, "stream unsupported")
+ return
+ }
+
+ writeEvent := func(event string, payload any) error {
+ raw, err := json.Marshal(payload)
+ if err != nil {
+ return err
+ }
+ if _, err := fmt.Fprintf(c.Writer, "event: %s\ndata: %s\n\n", event, raw); err != nil {
+ return err
+ }
+ flusher.Flush()
+ return nil
+ }
+
+ err := h.Svc.StreamMessage(c.Request.Context(), userID, tid, content, writeEvent)
+ if err != nil {
+ msg := err.Error()
+ code := 40011
+ if asksvc.IsQuotaExhausted(err) {
+ msg = "问答次数已用完,可购买额度或开通成长会员"
+ code = 40210
+ }
+ _ = writeEvent("error", map[string]any{"code": code, "message": msg})
+ }
+}
diff --git a/apps/api/internal/handler/auth.go b/apps/api/internal/handler/auth.go
new file mode 100644
index 0000000..e200787
--- /dev/null
+++ b/apps/api/internal/handler/auth.go
@@ -0,0 +1,105 @@
+package handler
+
+import (
+ "net/http"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/service/auth"
+ "github.com/yuxingu/digital-psychology/apps/api/pkg/response"
+)
+
+// AuthHandler serves /api/v1/auth/*.
+type AuthHandler struct {
+ Svc *auth.Service
+}
+
+// Register mounts auth routes. Public register/login; me/logout need device (+ session).
+func (h *AuthHandler) Register(api *gin.RouterGroup) {
+ g := api.Group("/auth")
+ g.POST("/register", h.RegisterAccount)
+ g.POST("/login", h.Login)
+ g.POST("/logout", h.Logout)
+ g.GET("/me", h.Me)
+}
+
+type authBody struct {
+ Phone string `json:"phone"`
+ Password string `json:"password"`
+ Nickname string `json:"nickname"`
+}
+
+// RegisterAccount handles POST /auth/register (DeviceAuth required on group).
+func (h *AuthHandler) RegisterAccount(c *gin.Context) {
+ userID, ok := middleware.UserIDFromContext(c)
+ if !ok {
+ response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
+ return
+ }
+ var body authBody
+ if err := c.ShouldBindJSON(&body); err != nil {
+ response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
+ return
+ }
+ deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
+ res, err := h.Svc.Register(c.Request.Context(), userID, deviceKey, body.Phone, body.Password, body.Nickname)
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 40110, err.Error())
+ return
+ }
+ response.OK(c, res)
+}
+
+// Login handles POST /auth/login (open mode: any phone+password).
+func (h *AuthHandler) Login(c *gin.Context) {
+ userID, ok := middleware.UserIDFromContext(c)
+ if !ok {
+ response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
+ return
+ }
+ var body authBody
+ if err := c.ShouldBindJSON(&body); err != nil {
+ response.Fail(c, http.StatusBadRequest, 10000, "invalid request")
+ return
+ }
+ deviceKey := c.GetHeader(middleware.DeviceKeyHeader)
+ res, err := h.Svc.Login(c.Request.Context(), userID, deviceKey, body.Phone, body.Password)
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 40111, err.Error())
+ return
+ }
+ c.Set(string(middleware.UserIDKey), res.User.ID)
+ response.OK(c, res)
+}
+
+// Logout handles POST /auth/logout.
+func (h *AuthHandler) Logout(c *gin.Context) {
+ tok := bearerToken(c)
+ _ = h.Svc.Logout(c.Request.Context(), tok)
+ response.OK(c, gin.H{"ok": true})
+}
+
+// Me handles GET /auth/me.
+func (h *AuthHandler) Me(c *gin.Context) {
+ userID, ok := middleware.UserIDFromContext(c)
+ if !ok {
+ response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
+ return
+ }
+ me, err := h.Svc.Me(c.Request.Context(), userID)
+ if err != nil {
+ response.Fail(c, http.StatusUnauthorized, 40112, "请先登录")
+ return
+ }
+ response.OK(c, me)
+}
+
+func bearerToken(c *gin.Context) string {
+ h := c.GetHeader("Authorization")
+ if strings.HasPrefix(strings.ToLower(h), "bearer ") {
+ return strings.TrimSpace(h[7:])
+ }
+ return ""
+}
diff --git a/apps/api/internal/handler/home.go b/apps/api/internal/handler/home.go
new file mode 100644
index 0000000..06b8de8
--- /dev/null
+++ b/apps/api/internal/handler/home.go
@@ -0,0 +1,34 @@
+package handler
+
+import (
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+
+ "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
+ "github.com/yuxingu/digital-psychology/apps/api/pkg/response"
+)
+
+// HomeHandler serves GET /api/v1/home/* (DeviceAuth).
+type HomeHandler struct {
+ Svc *home.Service
+}
+
+// Register mounts home routes.
+func (h *HomeHandler) Register(api *gin.RouterGroup) {
+ g := api.Group("/home")
+ g.GET("/tools", h.Tools)
+}
+
+func (h *HomeHandler) Tools(c *gin.Context) {
+ items, err := h.Svc.ListPublic(c.Request.Context())
+ if err != nil {
+ response.Fail(c, http.StatusInternalServerError, 50030, "home tools failed")
+ return
+ }
+ if items == nil {
+ items = []repository.HomeTool{}
+ }
+ response.OK(c, gin.H{"items": items})
+}
diff --git a/apps/api/internal/handler/report.go b/apps/api/internal/handler/report.go
index 3e01a8d..ba24c53 100644
--- a/apps/api/internal/handler/report.go
+++ b/apps/api/internal/handler/report.go
@@ -34,12 +34,47 @@ func (h *ReportHandler) Register(rg *gin.RouterGroup) {
rg.POST("/reports/synastry", h.CreateSynastry)
rg.POST("/reports/rhythm", h.CreateRhythm)
rg.GET("/reports", h.List)
+ rg.GET("/reports/latest", h.GetLatest)
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)
}
+// GetLatest handles GET /reports/latest?profile_id=&type=&peer_profile_id=.
+func (h *ReportHandler) GetLatest(c *gin.Context) {
+ userID, ok := middleware.UserIDFromContext(c)
+ if !ok {
+ response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
+ return
+ }
+ pid, err := uuid.Parse(c.Query("profile_id"))
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 10000, "profile_id required")
+ return
+ }
+ typ := c.Query("type")
+ if typ == "" {
+ response.Fail(c, http.StatusBadRequest, 10000, "type required")
+ return
+ }
+ var peer *uuid.UUID
+ if ps := c.Query("peer_profile_id"); ps != "" {
+ id, err := uuid.Parse(ps)
+ if err != nil {
+ response.Fail(c, http.StatusBadRequest, 10000, "invalid peer_profile_id")
+ return
+ }
+ peer = &id
+ }
+ rep, err := h.Svc.GetLatest(c.Request.Context(), userID, pid, typ, peer)
+ if err != nil {
+ response.Fail(c, http.StatusNotFound, 40402, err.Error())
+ return
+ }
+ response.OK(c, rep)
+}
+
// CreatePortrait handles POST /reports/portrait.
func (h *ReportHandler) CreatePortrait(c *gin.Context) {
userID, ok := middleware.UserIDFromContext(c)
diff --git a/apps/api/internal/httpserver/router.go b/apps/api/internal/httpserver/router.go
index d271ddd..8588d28 100644
--- a/apps/api/internal/httpserver/router.go
+++ b/apps/api/internal/httpserver/router.go
@@ -14,8 +14,12 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/middleware"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
adminsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/admin"
+ analyticssvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/analytics"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/ask"
+ authsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/auth"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
companionsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/companion"
+ homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
imagecardsvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/imagecard"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/membership"
"github.com/yuxingu/digital-psychology/apps/api/internal/service/profile"
@@ -32,13 +36,16 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
relationRepo := &repository.RelationRepo{Pool: pool}
askRepo := &repository.AskRepo{Pool: pool}
adminRepo := &repository.AdminRepo{Pool: pool}
+ analyticsRepo := &repository.AnalyticsRepo{Pool: pool}
+ authRepo := &repository.AuthRepo{Pool: pool}
var llm *deepseek.Client
if cfg.DeepSeek.Enabled() {
llm = deepseek.New(cfg.DeepSeek)
}
- profileSvc := &profile.Service{Repo: profileRepo}
+ bootSvc := &bootstrap.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
+ profileSvc := &profile.Service{Repo: profileRepo, Reports: reportRepo, Bootstrap: bootSvc}
reportSvc := &report.Service{
Profiles: profileRepo,
Reports: reportRepo,
@@ -46,7 +53,8 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
}
membershipSvc := &membership.Service{Reports: reportRepo}
relationSvc := &relation.Service{Profiles: profileRepo, Reports: reportRepo, Relations: relationRepo}
- scaleSvc := &scale.Service{Repo: &repository.ScaleRepo{Pool: pool}, Profiles: profileRepo}
+ scaleRepo := &repository.ScaleRepo{Pool: pool}
+ scaleSvc := &scale.Service{Repo: scaleRepo, Profiles: profileRepo}
askSvc := &ask.Service{Profiles: profileRepo, Reports: reportRepo, Ask: askRepo, LLM: llm}
companionSvc := &companionsvc.Service{Moods: &repository.MoodRepo{Pool: pool}}
imageCardSvc := &imagecardsvc.Service{
@@ -54,7 +62,12 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
Reports: reportRepo,
Quotas: &repository.ImageCardRepo{Pool: pool},
}
- adminSvc := &adminsvc.Service{Repo: adminRepo, Reports: reportRepo}
+ homeSvc := &homesvc.Service{Repo: &repository.HomeToolsRepo{Pool: pool}}
+ analyticsSvc := &analyticssvc.Service{Repo: analyticsRepo}
+ adminSvc := &adminsvc.Service{
+ Repo: adminRepo, Reports: reportRepo, Home: homeSvc, Scales: scaleRepo,
+ }
+ authSvc := &authsvc.Service{Repo: authRepo}
if err := adminSvc.EnsureBootstrap(context.Background(), adminsvc.BootstrapConfig{
Username: cfg.Admin.BootstrapUsername,
Password: cfg.Admin.BootstrapPassword,
@@ -74,22 +87,28 @@ func NewRouter(pool *pgxpool.Pool, cfg config.Config) *gin.Engine {
api.GET("/ping", func(c *gin.Context) {
response.OK(c, gin.H{"pong": true})
})
- (&handler.AdminHandler{Svc: adminSvc}).Register(api)
+ (&handler.AdminHandler{Svc: adminSvc, Analytics: analyticsSvc}).Register(api)
authed := api.Group("")
authed.Use(middleware.DeviceAuth(pool))
- (&handler.ProfileHandler{Svc: profileSvc}).Register(authed)
- (&handler.ReportHandler{Svc: reportSvc, Membership: membershipSvc}).Register(authed)
- (&handler.SynastryHandler{Svc: reportSvc}).Register(authed)
- (&handler.RelationHandler{Svc: relationSvc}).Register(authed)
- (&handler.ScaleHandler{Svc: scaleSvc}).Register(authed)
- (&handler.AskHandler{Svc: askSvc}).Register(authed)
- (&handler.CompanionHandler{Svc: companionSvc}).Register(authed)
- (&handler.ImageCardHandler{Svc: imageCardSvc}).Register(authed)
- (&handler.ExploreHandler{}).Register(authed)
+ (&handler.AuthHandler{Svc: authSvc}).Register(authed)
+ (&handler.AnalyticsHandler{Svc: analyticsSvc}).Register(authed)
+ (&handler.HomeHandler{Svc: homeSvc}).Register(authed)
+
+ gated := authed.Group("")
+ gated.Use(middleware.RequireRegistered(pool))
+ (&handler.ProfileHandler{Svc: profileSvc}).Register(gated)
+ (&handler.ReportHandler{Svc: reportSvc, Membership: membershipSvc}).Register(gated)
+ (&handler.SynastryHandler{Svc: reportSvc}).Register(gated)
+ (&handler.RelationHandler{Svc: relationSvc}).Register(gated)
+ (&handler.ScaleHandler{Svc: scaleSvc}).Register(gated)
+ (&handler.AskHandler{Svc: askSvc}).Register(gated)
+ (&handler.CompanionHandler{Svc: companionSvc}).Register(gated)
+ (&handler.ImageCardHandler{Svc: imageCardSvc}).Register(gated)
+ (&handler.ExploreHandler{}).Register(gated)
(&handler.GrowthHandler{
Plans: &repository.GrowthRepo{Pool: pool},
- }).Register(authed)
+ }).Register(gated)
return r
}
diff --git a/apps/api/internal/integration/admin_ops_test.go b/apps/api/internal/integration/admin_ops_test.go
index c33dd8b..83a5bba 100644
--- a/apps/api/internal/integration/admin_ops_test.go
+++ b/apps/api/internal/integration/admin_ops_test.go
@@ -59,7 +59,7 @@ func TestAdminOpsPhaseA(t *testing.T) {
t.Fatalf("login token missing: %v %s", err, env.Data)
}
- _, _ = doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
+ _ = mustRegister(t, r)
env, code = doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/users", nil, login.Token)
if code != 200 || env.Code != 0 {
diff --git a/apps/api/internal/integration/analytics_ops_test.go b/apps/api/internal/integration/analytics_ops_test.go
new file mode 100644
index 0000000..f751cf5
--- /dev/null
+++ b/apps/api/internal/integration/analytics_ops_test.go
@@ -0,0 +1,89 @@
+package integration_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+ "time"
+)
+
+func TestAnalyticsOpsB(t *testing.T) {
+ r, _ := setupAPI(t)
+ sid := "s_test_" + time.Now().Format("150405")
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+
+ env, key := doJSON(t, r, http.MethodPost, "/api/v1/analytics/events", map[string]any{
+ "items": []map[string]any{
+ {"name": "session_start", "session_id": sid, "client_ts": now, "props": map[string]any{"cold": true}},
+ {"name": "page_view", "session_id": sid, "page_path": "/portrait", "client_ts": now},
+ {"name": "page_view", "session_id": sid, "page_path": "/ask", "client_ts": now},
+ {"name": "page_leave", "session_id": sid, "page_path": "/portrait", "client_ts": now,
+ "props": map[string]any{"dwell_ms": 1500}},
+ {"name": "ui_click", "session_id": sid, "client_ts": now,
+ "props": map[string]any{"element_id": "home_cta", "page_path": "/"}},
+ {"name": "portrait_completed", "session_id": sid, "client_ts": now},
+ {"name": "session_end", "session_id": sid, "client_ts": now,
+ "props": map[string]any{"exit_page": "/ask", "duration_ms": 8000}},
+ },
+ }, "")
+ var accepted struct {
+ Accepted int `json:"accepted"`
+ }
+ if err := json.Unmarshal(env.Data, &accepted); err != nil || accepted.Accepted < 6 {
+ t.Fatalf("accepted=%v err=%v body=%s", accepted, err, string(env.Data))
+ }
+ _ = key
+
+ loginEnv, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
+ "username": "admin", "password": "change-me",
+ }, "")
+ if code != http.StatusOK || loginEnv.Code != 0 {
+ t.Fatalf("admin login http=%d code=%d", code, loginEnv.Code)
+ }
+ var login struct {
+ Token string `json:"token"`
+ }
+ _ = json.Unmarshal(loginEnv.Data, &login)
+ if login.Token == "" {
+ t.Fatal("missing admin token")
+ }
+
+ from := time.Now().UTC().Add(-24 * time.Hour).Format("2006-01-02")
+ to := time.Now().UTC().Format("2006-01-02")
+ q := "?from=" + from + "&to=" + to
+
+ ov, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/overview"+q, nil, login.Token)
+ if code != http.StatusOK || ov.Code != 0 {
+ t.Fatalf("overview http=%d code=%d msg=%s", code, ov.Code, ov.Message)
+ }
+ var overview map[string]any
+ _ = json.Unmarshal(ov.Data, &overview)
+ if sessions, _ := overview["sessions"].(float64); sessions < 1 {
+ t.Fatalf("expected sessions>=1 got %v", overview)
+ }
+
+ pg, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/pages"+q, nil, login.Token)
+ if code != http.StatusOK || pg.Code != 0 {
+ t.Fatalf("pages http=%d code=%d", code, pg.Code)
+ }
+ ex, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/analytics/exits"+q, nil, login.Token)
+ if code != http.StatusOK || ex.Code != 0 {
+ t.Fatalf("exits http=%d code=%d", code, ex.Code)
+ }
+ var exits struct {
+ Items []struct {
+ ExitPage string `json:"exit_page"`
+ Count int `json:"count"`
+ } `json:"items"`
+ }
+ _ = json.Unmarshal(ex.Data, &exits)
+ found := false
+ for _, it := range exits.Items {
+ if it.ExitPage == "/ask" && it.Count >= 1 {
+ found = true
+ }
+ }
+ if !found {
+ t.Fatalf("expected exit /ask in %v", exits.Items)
+ }
+}
diff --git a/apps/api/internal/integration/explore_catalog_test.go b/apps/api/internal/integration/explore_catalog_test.go
index 6874bf3..ed06b5f 100644
--- a/apps/api/internal/integration/explore_catalog_test.go
+++ b/apps/api/internal/integration/explore_catalog_test.go
@@ -7,7 +7,7 @@ import (
func TestExploreCatalog(t *testing.T) {
r, _ := setupAPI(t)
- var key string
+ key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodGet, "/api/v1/explore/catalog", nil, key)
data := decodeData[map[string]any](t, env.Data)
cats, ok := data["categories"].([]any)
@@ -24,7 +24,7 @@ func TestExploreCatalog(t *testing.T) {
func TestGrowthPlanCheckin(t *testing.T) {
r, _ := setupAPI(t)
- var key string
+ key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/growth/plans", map[string]any{
"title": "每晚早睡", "focus": "保护睡眠",
}, key)
diff --git a/apps/api/internal/integration/ops_content_test.go b/apps/api/internal/integration/ops_content_test.go
new file mode 100644
index 0000000..b4da0e3
--- /dev/null
+++ b/apps/api/internal/integration/ops_content_test.go
@@ -0,0 +1,127 @@
+package integration_test
+
+import (
+ "encoding/json"
+ "net/http"
+ "testing"
+)
+
+func TestOpsContentPhaseC(t *testing.T) {
+ r, _ := setupAPI(t)
+
+ env, _ := doJSON(t, r, http.MethodGet, "/api/v1/home/tools", nil, "")
+ var home struct {
+ Items []map[string]any `json:"items"`
+ }
+ if err := json.Unmarshal(env.Data, &home); err != nil || len(home.Items) < 6 {
+ t.Fatalf("home tools seed: %v len=%d", err, len(home.Items))
+ }
+
+ loginEnv, code := doAdminJSON(t, r, http.MethodPost, "/api/v1/admin/auth/login", map[string]string{
+ "username": "admin", "password": "change-me",
+ }, "")
+ if code != http.StatusOK {
+ t.Fatalf("login http=%d", code)
+ }
+ var login struct {
+ Token string `json:"token"`
+ }
+ _ = json.Unmarshal(loginEnv.Data, &login)
+
+ adminTools, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/home/tools", nil, login.Token)
+ if code != http.StatusOK || adminTools.Code != 0 {
+ t.Fatalf("admin tools http=%d code=%d", code, adminTools.Code)
+ }
+ var all struct {
+ Items []map[string]any `json:"items"`
+ }
+ _ = json.Unmarshal(adminTools.Data, &all)
+ if len(all.Items) == 0 {
+ t.Fatal("expected seeded tools")
+ }
+ // disable first tool
+ all.Items[0]["enabled"] = false
+ putEnv, code := doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/home/tools", map[string]any{
+ "items": all.Items,
+ }, login.Token)
+ if code != http.StatusOK || putEnv.Code != 0 {
+ t.Fatalf("put tools http=%d code=%d msg=%s", code, putEnv.Code, putEnv.Message)
+ }
+
+ auditEnv, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/audit-logs?limit=5", nil, login.Token)
+ if code != http.StatusOK {
+ t.Fatalf("audit http=%d", code)
+ }
+ var audits struct {
+ Items []struct {
+ Action string `json:"action"`
+ } `json:"items"`
+ }
+ _ = json.Unmarshal(auditEnv.Data, &audits)
+ foundAudit := false
+ for _, a := range audits.Items {
+ if a.Action == "home_tools.replace" {
+ foundAudit = true
+ break
+ }
+ }
+ if !foundAudit {
+ t.Fatalf("expected home_tools.replace audit, got %+v", audits.Items)
+ }
+
+ env, _ = doJSON(t, r, http.MethodGet, "/api/v1/home/tools", nil, "")
+ _ = json.Unmarshal(env.Data, &home)
+ enabledN := len(home.Items)
+ if enabledN >= len(all.Items) {
+ t.Fatalf("expected fewer enabled tools after disable, pub=%d admin=%d", enabledN, len(all.Items))
+ }
+
+ // restore all enabled for shared DB
+ for i := range all.Items {
+ all.Items[i]["enabled"] = true
+ }
+ _, _ = doAdminJSON(t, r, http.MethodPut, "/api/v1/admin/home/tools", map[string]any{"items": all.Items}, login.Token)
+
+ scalesEnv, code := doAdminJSON(t, r, http.MethodGet, "/api/v1/admin/scales", nil, login.Token)
+ if code != http.StatusOK {
+ t.Fatalf("scales http=%d", code)
+ }
+ var scales struct {
+ Items []struct {
+ ID string `json:"id"`
+ Slug string `json:"slug"`
+ Status string `json:"status"`
+ } `json:"items"`
+ }
+ _ = json.Unmarshal(scalesEnv.Data, &scales)
+ if len(scales.Items) == 0 {
+ t.Fatal("no scales")
+ }
+ target := scales.Items[0]
+ patch, code := doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/scales/"+target.ID, map[string]string{
+ "status": "draft",
+ }, login.Token)
+ if code != http.StatusOK || patch.Code != 0 {
+ t.Fatalf("patch http=%d code=%d msg=%s", code, patch.Code, patch.Message)
+ }
+
+ // published list should not include draft slug — needs registered user
+ key := mustRegister(t, r)
+ listEnv, _ := doJSON(t, r, http.MethodGet, "/api/v1/scales", nil, key)
+ var pub struct {
+ Items []struct {
+ Slug string `json:"slug"`
+ } `json:"items"`
+ }
+ _ = json.Unmarshal(listEnv.Data, &pub)
+ for _, it := range pub.Items {
+ if it.Slug == target.Slug {
+ t.Fatalf("draft scale %s still in published list", target.Slug)
+ }
+ }
+
+ // restore published so other tests aren't flaky if shared DB
+ _, _ = doAdminJSON(t, r, http.MethodPatch, "/api/v1/admin/scales/"+target.ID, map[string]string{
+ "status": "published",
+ }, login.Token)
+}
diff --git a/apps/api/internal/integration/p1_flows_test.go b/apps/api/internal/integration/p1_flows_test.go
index a41e4ed..4b32777 100644
--- a/apps/api/internal/integration/p1_flows_test.go
+++ b/apps/api/internal/integration/p1_flows_test.go
@@ -4,6 +4,7 @@ import (
"bytes"
"context"
"encoding/json"
+ "fmt"
"net/http"
"net/http/httptest"
"path/filepath"
@@ -60,6 +61,9 @@ func doJSON(t *testing.T, r http.Handler, method, path string, body any, deviceK
if deviceKey != "" {
req.Header.Set("X-Device-Key", deviceKey)
}
+ if testBearer != "" {
+ req.Header.Set("Authorization", "Bearer "+testBearer)
+ }
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code >= 500 {
@@ -79,6 +83,25 @@ func doJSON(t *testing.T, r http.Handler, method, path string, body any, deviceK
return env, key
}
+var testBearer string
+
+func mustRegister(t *testing.T, r http.Handler) string {
+ t.Helper()
+ testBearer = ""
+ phone := fmt.Sprintf("1%010d", time.Now().UnixNano()%10_000_000_000)
+ env, key := doJSON(t, r, http.MethodPost, "/api/v1/auth/register", map[string]any{
+ "phone": phone, "password": "secret12", "nickname": "测",
+ }, "")
+ sess := decodeData[map[string]any](t, env.Data)
+ tok, _ := sess["token"].(string)
+ if tok == "" {
+ t.Fatal("missing token from register")
+ }
+ testBearer = tok
+ t.Cleanup(func() { testBearer = "" })
+ return key
+}
+
func decodeData[T any](t *testing.T, raw json.RawMessage) T {
t.Helper()
var v T
@@ -91,7 +114,7 @@ func decodeData[T any](t *testing.T, raw json.RawMessage) T {
// Flow 1: create profile → portrait → deep_access mock → detail visible
func TestFlowPortraitDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
- var key string
+ key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
@@ -136,7 +159,7 @@ func TestFlowPortraitDeepAccess(t *testing.T) {
// Flow 2: two profiles → relation insight → deep_access → tips visible
func TestFlowRelationDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
- var key string
+ key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1988-03-01", "display_name": "我",
@@ -189,7 +212,7 @@ func TestFlowRelationDeepAccess(t *testing.T) {
// Flow 3: membership mock → entitlement → portrait detail without per-report deep_access
func TestFlowMembershipUnlock(t *testing.T) {
r, _ := setupAPI(t)
- var key string
+ key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1995-11-07", "display_name": "我",
@@ -232,7 +255,7 @@ func TestFlowMembershipUnlock(t *testing.T) {
// Flow 5: profile update + soft
func TestFlowProfileUpdateDelete(t *testing.T) {
r, _ := setupAPI(t)
- var key string
+ key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "other", "birth_date": "1993-04-04", "display_name": "旧名", "relation_type": "friend",
@@ -262,7 +285,7 @@ func TestFlowProfileUpdateDelete(t *testing.T) {
// Flow 4: profile → ask thread → message → assistant reply + quota
func TestFlowAskThread(t *testing.T) {
r, _ := setupAPI(t)
- var key string
+ key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1991-02-14", "display_name": "我",
@@ -308,3 +331,56 @@ func TestFlowAskThread(t *testing.T) {
t.Fatalf("expected user+assistant history, got %d", len(items))
}
}
+
+// Flow 4b: exhaust free ask quota → buy ask_pack → can ask again
+func TestFlowAskPackPurchase(t *testing.T) {
+ r, _ := setupAPI(t)
+ key := mustRegister(t, r)
+
+ env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
+ "relation": "self", "birth_date": "1991-02-14", "display_name": "我",
+ }, key)
+ profileID := decodeData[map[string]any](t, env.Data)["id"].(string)
+
+ env, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads", map[string]any{
+ "profile_id": profileID, "scene": "self",
+ }, key)
+ threadID := decodeData[map[string]any](t, env.Data)["id"].(string)
+
+ for i := 0; i < 3; i++ {
+ _, key = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
+ "content": "第几问",
+ }, key)
+ }
+
+ env, key, status := doJSONExpect(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
+ "content": "应该没额度了",
+ }, key, 40210)
+ if status != http.StatusPaymentRequired {
+ t.Fatalf("expected 402 after free exhaust, got %d %#v", status, env)
+ }
+
+ env, key = doJSON(t, r, http.MethodPost, "/api/v1/orders", map[string]any{
+ "kind": "ask_pack", "plan": "pack10",
+ }, key)
+ orderID := decodeData[map[string]any](t, env.Data)["order_id"].(string)
+ _, key = doJSON(t, r, http.MethodPost, "/api/v1/orders/"+orderID+"/pay-mock", nil, key)
+
+ env, key = doJSON(t, r, http.MethodGet, "/api/v1/ask/quota", nil, key)
+ q := decodeData[map[string]any](t, env.Data)
+ rem, _ := q["remaining"].(float64)
+ paid, _ := q["paid_left"].(float64)
+ if rem < 10 || paid < 10 {
+ t.Fatalf("expected paid pack quota, got %#v", q)
+ }
+
+ env, _ = doJSON(t, r, http.MethodPost, "/api/v1/ask/threads/"+threadID+"/messages", map[string]any{
+ "content": "买完额度继续问",
+ }, key)
+ out := decodeData[map[string]any](t, env.Data)
+ q2, _ := out["quota"].(map[string]any)
+ rem2, _ := q2["remaining"].(float64)
+ if rem2 != rem-1 {
+ t.Fatalf("paid quota should decrease: before=%v after=%v", rem, rem2)
+ }
+}
diff --git a/apps/api/internal/integration/p2_flows_test.go b/apps/api/internal/integration/p2_flows_test.go
index 4a2dd2e..958c159 100644
--- a/apps/api/internal/integration/p2_flows_test.go
+++ b/apps/api/internal/integration/p2_flows_test.go
@@ -22,6 +22,9 @@ func doJSONExpect(t *testing.T, r http.Handler, method, path string, body any, d
if deviceKey != "" {
req.Header.Set("X-Device-Key", deviceKey)
}
+ if testBearer != "" {
+ req.Header.Set("Authorization", "Bearer "+testBearer)
+ }
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code >= 500 {
@@ -61,7 +64,8 @@ func createSelfProfile(t *testing.T, r http.Handler, birth, key string) (profile
// Flow: star report → gated detail → mock deep unlock
func TestFlowStarDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
- pid, key := createSelfProfile(t, r, "1983-06-06", "")
+ key := mustRegister(t, r)
+ pid, key := createSelfProfile(t, r, "1983-06-06", key)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/star", map[string]any{
"profile_id": pid,
@@ -106,7 +110,8 @@ func TestFlowStarDeepAccess(t *testing.T) {
// Flow: rhythm report gated + unlock
func TestFlowRhythmDeepAccess(t *testing.T) {
r, _ := setupAPI(t)
- pid, key := createSelfProfile(t, r, "1992-06-08", "")
+ key := mustRegister(t, r)
+ pid, key := createSelfProfile(t, r, "1992-06-08", key)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/reports/rhythm", map[string]any{
"profile_id": pid,
@@ -140,7 +145,8 @@ func TestFlowRhythmDeepAccess(t *testing.T) {
// Flow: image card scenes → draw → quota exhaust → depth unlock via mock pay
func TestFlowImageCardQuotaAndDepth(t *testing.T) {
r, _ := setupAPI(t)
- pid, key := createSelfProfile(t, r, "1990-01-01", "")
+ key := mustRegister(t, r)
+ pid, key := createSelfProfile(t, r, "1990-01-01", key)
env, key := doJSON(t, r, http.MethodGet, "/api/v1/image-cards/scenes", nil, key)
scenes := decodeData[map[string]any](t, env.Data)
@@ -199,7 +205,7 @@ func TestFlowImageCardQuotaAndDepth(t *testing.T) {
// Flow: solar terms + mood save/read
func TestFlowCompanionMood(t *testing.T) {
r, _ := setupAPI(t)
- var key string
+ key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodGet, "/api/v1/solar-terms/today", nil, key)
term := decodeData[map[string]any](t, env.Data)
diff --git a/apps/api/internal/integration/synastry_test.go b/apps/api/internal/integration/synastry_test.go
index 89d5208..247b29c 100644
--- a/apps/api/internal/integration/synastry_test.go
+++ b/apps/api/internal/integration/synastry_test.go
@@ -7,7 +7,7 @@ import (
func TestFlowSynastryMultiChartsAndInvite(t *testing.T) {
r, _ := setupAPI(t)
- var key string
+ key := mustRegister(t, r)
env, key := doJSON(t, r, http.MethodPost, "/api/v1/profiles", map[string]any{
"relation": "self", "birth_date": "1990-05-12", "display_name": "我",
@@ -67,9 +67,8 @@ func TestFlowSynastryMultiChartsAndInvite(t *testing.T) {
t.Fatal("empty token")
}
- env2, key2 := doJSON(t, r, http.MethodGet, "/api/v1/profiles", nil, "")
- _ = env2
- env2, key2 = doJSON(t, r, http.MethodGet, "/api/v1/synastry/invites/"+token, nil, key2)
+ env2Key := mustRegister(t, r)
+ env2, key2 := doJSON(t, r, http.MethodGet, "/api/v1/synastry/invites/"+token, nil, env2Key)
meta := decodeData[map[string]any](t, env2.Data)
if meta["host_name"] == nil {
t.Fatal("host_name missing")
diff --git a/apps/api/internal/llm/deepseek/client.go b/apps/api/internal/llm/deepseek/client.go
index f851d82..46eab09 100644
--- a/apps/api/internal/llm/deepseek/client.go
+++ b/apps/api/internal/llm/deepseek/client.go
@@ -2,9 +2,11 @@
package deepseek
import (
+ "bufio"
"bytes"
"context"
"encoding/json"
+ "errors"
"fmt"
"io"
"net/http"
@@ -60,6 +62,18 @@ type chatResponse struct {
} `json:"error"`
}
+type streamChunk struct {
+ Choices []struct {
+ Delta struct {
+ Content string `json:"content"`
+ } `json:"delta"`
+ FinishReason *string `json:"finish_reason"`
+ } `json:"choices"`
+ Error *struct {
+ Message string `json:"message"`
+ } `json:"error"`
+}
+
// Chat sends messages and returns assistant text.
func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) {
if !c.Enabled() {
@@ -110,6 +124,100 @@ func (c *Client) Chat(ctx context.Context, messages []Message) (string, error) {
return strings.TrimSpace(out.Choices[0].Message.Content), nil
}
+// ChatStream streams assistant text; onDelta is called for each content piece.
+// Returns the full assembled reply.
+func (c *Client) ChatStream(ctx context.Context, messages []Message, onDelta func(delta string) error) (string, error) {
+ if !c.Enabled() {
+ return "", fmt.Errorf("deepseek api_key not configured")
+ }
+ if onDelta == nil {
+ onDelta = func(string) error { return nil }
+ }
+ base := c.cfg.BaseURL
+ if base == "" {
+ base = "https://api.deepseek.com"
+ }
+ model := c.cfg.Model
+ if model == "" {
+ model = "deepseek-chat"
+ }
+
+ body, err := json.Marshal(chatRequest{Model: model, Messages: messages, Stream: true})
+ if err != nil {
+ return "", err
+ }
+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, base+"/chat/completions", bytes.NewReader(body))
+ if err != nil {
+ return "", err
+ }
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer "+c.cfg.APIKey)
+ req.Header.Set("Accept", "text/event-stream")
+
+ // Stream may outlive the default client timeout; use a dedicated client.
+ sec := c.cfg.TimeoutSec
+ if sec <= 0 {
+ sec = 90
+ }
+ httpClient := &http.Client{Timeout: time.Duration(sec) * time.Second}
+ res, err := httpClient.Do(req)
+ if err != nil {
+ return "", err
+ }
+ defer res.Body.Close()
+
+ if res.StatusCode >= 300 {
+ raw, _ := io.ReadAll(io.LimitReader(res.Body, 2<<20))
+ return "", fmt.Errorf("deepseek HTTP %d: %s", res.StatusCode, truncate(string(raw), 200))
+ }
+
+ reader := bufio.NewReader(res.Body)
+ var full strings.Builder
+ for {
+ line, err := reader.ReadString('\n')
+ if err != nil {
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ return full.String(), err
+ }
+ line = strings.TrimRight(line, "\r\n")
+ if line == "" || strings.HasPrefix(line, ":") {
+ continue
+ }
+ if !strings.HasPrefix(line, "data:") {
+ continue
+ }
+ payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+ if payload == "[DONE]" {
+ break
+ }
+ var chunk streamChunk
+ if err := json.Unmarshal([]byte(payload), &chunk); err != nil {
+ continue
+ }
+ if chunk.Error != nil && chunk.Error.Message != "" {
+ return full.String(), fmt.Errorf("deepseek: %s", chunk.Error.Message)
+ }
+ if len(chunk.Choices) == 0 {
+ continue
+ }
+ delta := chunk.Choices[0].Delta.Content
+ if delta == "" {
+ continue
+ }
+ full.WriteString(delta)
+ if err := onDelta(delta); err != nil {
+ return full.String(), err
+ }
+ }
+ out := strings.TrimSpace(full.String())
+ if out == "" {
+ return "", fmt.Errorf("deepseek empty stream")
+ }
+ return out, nil
+}
+
func truncate(s string, n int) string {
if len(s) <= n {
return s
diff --git a/apps/api/internal/llm/deepseek/client_test.go b/apps/api/internal/llm/deepseek/client_test.go
index e6d417c..3fe2468 100644
--- a/apps/api/internal/llm/deepseek/client_test.go
+++ b/apps/api/internal/llm/deepseek/client_test.go
@@ -40,8 +40,36 @@ func TestChat_success(t *testing.T) {
}
}
-func TestEnabled(t *testing.T) {
- if New(config.DeepSeekConfig{}).Enabled() {
- t.Fatal("empty key should disable")
+func TestChatStream_success(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ var req chatRequest
+ _ = json.NewDecoder(r.Body).Decode(&req)
+ if !req.Stream {
+ t.Fatal("expected stream=true")
+ }
+ w.Header().Set("Content-Type", "text/event-stream")
+ flusher := w.(http.Flusher)
+ _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"你好\"}}]}\n\n"))
+ flusher.Flush()
+ _, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"世界\"}}]}\n\n"))
+ flusher.Flush()
+ _, _ = w.Write([]byte("data: [DONE]\n\n"))
+ flusher.Flush()
+ }))
+ defer srv.Close()
+
+ c := New(config.DeepSeekConfig{
+ APIKey: "test-key", BaseURL: srv.URL, Model: "deepseek-chat", TimeoutSec: 5,
+ })
+ var got string
+ out, err := c.ChatStream(context.Background(), []Message{{Role: "user", Content: "hi"}}, func(d string) error {
+ got += d
+ return nil
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if out != "你好世界" || got != "你好世界" {
+ t.Fatalf("out=%q got=%q", out, got)
}
}
diff --git a/apps/api/internal/middleware/device.go b/apps/api/internal/middleware/device.go
index 2ffc3c4..07ca4a4 100644
--- a/apps/api/internal/middleware/device.go
+++ b/apps/api/internal/middleware/device.go
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"errors"
"net/http"
+ "strings"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
@@ -21,12 +22,33 @@ const UserIDKey ctxKey = "user_id"
const DeviceKeyHeader = "X-Device-Key"
// DeviceAuth resolves or creates a Visitor→User via device key.
+// If Authorization Bearer session is valid, that account user wins and device rebinds.
func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
return func(c *gin.Context) {
key := c.GetHeader(DeviceKeyHeader)
if key == "" {
key = newDeviceKey()
c.Header(DeviceKeyHeader, key)
+ c.Request.Header.Set(DeviceKeyHeader, key)
+ }
+ if tok := bearerFromHeader(c.GetHeader("Authorization")); tok != "" {
+ var uid uuid.UUID
+ err := pool.QueryRow(c.Request.Context(), `
+ SELECT user_id FROM user_sessions
+ WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, tok,
+ ).Scan(&uid)
+ if err == nil {
+ _, _ = pool.Exec(c.Request.Context(), `
+ 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`,
+ key, uid,
+ )
+ c.Set(string(UserIDKey), uid.String())
+ c.Header(DeviceKeyHeader, key)
+ c.Next()
+ return
+ }
}
userID, err := ensureUser(c.Request.Context(), pool, key)
if err != nil {
@@ -40,6 +62,39 @@ func DeviceAuth(pool *pgxpool.Pool) gin.HandlerFunc {
}
}
+func bearerFromHeader(h string) string {
+ if len(h) < 8 {
+ return ""
+ }
+ if strings.EqualFold(h[:7], "bearer ") {
+ return strings.TrimSpace(h[7:])
+ }
+ return ""
+}
+
+// RequireRegistered rejects anonymous (no phone) users.
+func RequireRegistered(pool *pgxpool.Pool) gin.HandlerFunc {
+ return func(c *gin.Context) {
+ userID, ok := UserIDFromContext(c)
+ if !ok {
+ response.Fail(c, http.StatusUnauthorized, 40100, "unauthorized")
+ c.Abort()
+ return
+ }
+ var okReg bool
+ err := pool.QueryRow(c.Request.Context(), `
+ SELECT EXISTS(
+ SELECT 1 FROM users WHERE id=$1 AND phone IS NOT NULL AND deleted_at IS NULL
+ )`, userID).Scan(&okReg)
+ if err != nil || !okReg {
+ response.Fail(c, http.StatusUnauthorized, 40112, "请先登录后再使用")
+ c.Abort()
+ return
+ }
+ c.Next()
+ }
+}
+
// UserIDFromContext returns the authenticated user id.
func UserIDFromContext(c *gin.Context) (uuid.UUID, bool) {
v, ok := c.Get(string(UserIDKey))
diff --git a/apps/api/internal/model/report.go b/apps/api/internal/model/report.go
index dd688aa..a28dd44 100644
--- a/apps/api/internal/model/report.go
+++ b/apps/api/internal/model/report.go
@@ -9,12 +9,13 @@ import (
// GrowthReport is a deliverable with free summary and gated detail.
type GrowthReport struct {
- ID uuid.UUID `json:"id"`
- UserID uuid.UUID `json:"user_id"`
- ProfileID uuid.UUID `json:"profile_id"`
- Type string `json:"type"`
- Summary json.RawMessage `json:"summary"`
- Detail json.RawMessage `json:"detail,omitempty"`
- HasDeep bool `json:"has_deep_access"`
- CreatedAt time.Time `json:"created_at"`
+ ID uuid.UUID `json:"id"`
+ UserID uuid.UUID `json:"user_id"`
+ ProfileID uuid.UUID `json:"profile_id"`
+ PeerProfileID *uuid.UUID `json:"peer_profile_id,omitempty"`
+ Type string `json:"type"`
+ Summary json.RawMessage `json:"summary"`
+ Detail json.RawMessage `json:"detail,omitempty"`
+ HasDeep bool `json:"has_deep_access"`
+ CreatedAt time.Time `json:"created_at"`
}
diff --git a/apps/api/internal/repository/admin_repo.go b/apps/api/internal/repository/admin_repo.go
index ded25c4..7247c68 100644
--- a/apps/api/internal/repository/admin_repo.go
+++ b/apps/api/internal/repository/admin_repo.go
@@ -115,12 +115,18 @@ func (r *AdminRepo) InsertAudit(ctx context.Context, adminID uuid.UUID, action,
// UserListItem is a compact user row for admin tables.
type UserListItem struct {
- ID uuid.UUID `json:"id"`
- Status string `json:"status"`
- CreatedAt time.Time `json:"created_at"`
+ ID uuid.UUID `json:"id"`
+ Status string `json:"status"`
+ Phone *string `json:"phone,omitempty"`
+ Nickname *string `json:"nickname,omitempty"`
+ AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
+ ProfileCount int `json:"profile_count"`
+ MembershipActive bool `json:"membership_active"`
+ MembershipPlan *string `json:"membership_plan,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
}
-// ListUsers returns users newest first; q matches id when UUID.
+// ListUsers returns users newest first; q matches id / phone / nickname.
func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int) ([]UserListItem, error) {
if limit <= 0 || limit > 100 {
limit = 20
@@ -129,10 +135,26 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
offset = 0
}
rows, err := r.Pool.Query(ctx, `
- SELECT id, status, created_at FROM users
- WHERE deleted_at IS NULL
- AND ($1 = '' OR id::text = $1)
- ORDER BY created_at DESC
+ SELECT u.id, u.status, u.phone, u.nickname, u.ask_paid_quota_left, u.created_at,
+ (SELECT count(*) FROM profiles p WHERE p.user_id=u.id AND p.deleted_at IS NULL) AS profile_count,
+ EXISTS(
+ SELECT 1 FROM memberships m
+ WHERE m.user_id=u.id AND m.deleted_at IS NULL AND m.status='active' AND m.expires_at > now()
+ ) AS membership_active,
+ (
+ SELECT m.plan FROM memberships m
+ WHERE m.user_id=u.id AND m.deleted_at IS NULL
+ LIMIT 1
+ ) AS membership_plan
+ FROM users u
+ WHERE u.deleted_at IS NULL
+ AND (
+ $1 = ''
+ OR u.id::text = $1
+ OR COALESCE(u.phone,'') ILIKE '%' || $1 || '%'
+ OR COALESCE(u.nickname,'') ILIKE '%' || $1 || '%'
+ )
+ ORDER BY u.created_at DESC
LIMIT $2 OFFSET $3`, q, limit, offset)
if err != nil {
return nil, err
@@ -141,7 +163,10 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
var out []UserListItem
for rows.Next() {
var u UserListItem
- if err := rows.Scan(&u.ID, &u.Status, &u.CreatedAt); err != nil {
+ if err := rows.Scan(
+ &u.ID, &u.Status, &u.Phone, &u.Nickname, &u.AskPaidQuotaLeft, &u.CreatedAt,
+ &u.ProfileCount, &u.MembershipActive, &u.MembershipPlan,
+ ); err != nil {
return nil, err
}
out = append(out, u)
@@ -149,6 +174,126 @@ func (r *AdminRepo) ListUsers(ctx context.Context, q string, limit, offset int)
return out, rows.Err()
}
+// DashboardStats is ops overview counters.
+type DashboardStats struct {
+ UsersTotal int `json:"users_total"`
+ MembershipActive int `json:"membership_active"`
+ OrdersToday int `json:"orders_today"`
+ PaidCentsToday int `json:"paid_cents_today"`
+ AskRepliesToday int `json:"ask_replies_today"`
+ ProfilesTotal int `json:"profiles_total"`
+ ReportsTotal int `json:"reports_total"`
+ Series []DashboardDay `json:"series"`
+ ReportsByType []ReportTypeCnt `json:"reports_by_type"`
+}
+
+// DashboardDay is one day of trend metrics.
+type DashboardDay struct {
+ Day string `json:"day"`
+ NewUsers int `json:"new_users"`
+ Orders int `json:"orders"`
+ PaidCents int `json:"paid_cents"`
+ AskReplies int `json:"ask_replies"`
+}
+
+// ReportTypeCnt counts reports by type.
+type ReportTypeCnt struct {
+ Type string `json:"type"`
+ Count int `json:"count"`
+}
+
+// GetDashboardStats aggregates key ops metrics.
+func (r *AdminRepo) GetDashboardStats(ctx context.Context) (*DashboardStats, error) {
+ s := &DashboardStats{}
+ if err := r.Pool.QueryRow(ctx, `
+ SELECT count(*) FROM users WHERE deleted_at IS NULL`).Scan(&s.UsersTotal); err != nil {
+ return nil, err
+ }
+ if err := r.Pool.QueryRow(ctx, `
+ SELECT count(*) FROM memberships
+ WHERE deleted_at IS NULL AND status='active' AND expires_at > now()`).Scan(&s.MembershipActive); err != nil {
+ return nil, err
+ }
+ if err := r.Pool.QueryRow(ctx, `
+ SELECT count(*) FROM orders
+ WHERE deleted_at IS NULL AND created_at >= date_trunc('day', now())`).Scan(&s.OrdersToday); err != nil {
+ return nil, err
+ }
+ if err := r.Pool.QueryRow(ctx, `
+ SELECT coalesce(sum(amount_cents),0) FROM orders
+ WHERE deleted_at IS NULL AND status='paid' AND created_at >= date_trunc('day', now())`).Scan(&s.PaidCentsToday); err != nil {
+ return nil, err
+ }
+ if err := r.Pool.QueryRow(ctx, `
+ SELECT count(*) FROM ask_messages m
+ JOIN ask_threads t ON t.id=m.thread_id AND t.deleted_at IS NULL
+ WHERE m.deleted_at IS NULL AND m.role='assistant'
+ AND m.created_at >= date_trunc('day', now())`).Scan(&s.AskRepliesToday); err != nil {
+ return nil, err
+ }
+ if err := r.Pool.QueryRow(ctx, `
+ SELECT count(*) FROM profiles WHERE deleted_at IS NULL`).Scan(&s.ProfilesTotal); err != nil {
+ return nil, err
+ }
+ if err := r.Pool.QueryRow(ctx, `
+ SELECT count(*) FROM growth_reports WHERE deleted_at IS NULL`).Scan(&s.ReportsTotal); err != nil {
+ return nil, err
+ }
+
+ rows, err := r.Pool.Query(ctx, `
+ WITH days AS (
+ SELECT generate_series(
+ date_trunc('day', now()) - interval '6 day',
+ date_trunc('day', now()),
+ interval '1 day'
+ )::date AS d
+ )
+ SELECT to_char(d.d, 'YYYY-MM-DD') AS day,
+ (SELECT count(*) FROM users u
+ WHERE u.deleted_at IS NULL AND u.created_at::date = d.d) AS new_users,
+ (SELECT count(*) FROM orders o
+ WHERE o.deleted_at IS NULL AND o.created_at::date = d.d) AS orders,
+ (SELECT coalesce(sum(o.amount_cents),0) FROM orders o
+ WHERE o.deleted_at IS NULL AND o.status='paid' AND o.created_at::date = d.d) AS paid_cents,
+ (SELECT count(*) FROM ask_messages m
+ JOIN ask_threads t ON t.id=m.thread_id AND t.deleted_at IS NULL
+ WHERE m.deleted_at IS NULL AND m.role='assistant' AND m.created_at::date = d.d) AS ask_replies
+ FROM days d
+ ORDER BY d.d ASC`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var p DashboardDay
+ if err := rows.Scan(&p.Day, &p.NewUsers, &p.Orders, &p.PaidCents, &p.AskReplies); err != nil {
+ return nil, err
+ }
+ s.Series = append(s.Series, p)
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+
+ trows, err := r.Pool.Query(ctx, `
+ SELECT type, count(*) FROM growth_reports
+ WHERE deleted_at IS NULL
+ GROUP BY type
+ ORDER BY count(*) DESC`)
+ if err != nil {
+ return nil, err
+ }
+ defer trows.Close()
+ for trows.Next() {
+ var c ReportTypeCnt
+ if err := trows.Scan(&c.Type, &c.Count); err != nil {
+ return nil, err
+ }
+ s.ReportsByType = append(s.ReportsByType, c)
+ }
+ return s, trows.Err()
+}
+
// UserExists reports whether user id is present.
func (r *AdminRepo) UserExists(ctx context.Context, id uuid.UUID) (bool, error) {
var n int
@@ -165,12 +310,14 @@ type ProfileBrief struct {
ID uuid.UUID `json:"id"`
Relation string `json:"relation"`
DisplayName string `json:"display_name"`
+ BirthDate string `json:"birth_date,omitempty"`
}
// ListProfilesForUser returns profile briefs.
func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) ([]ProfileBrief, error) {
rows, err := r.Pool.Query(ctx, `
- SELECT id, relation, display_name FROM profiles
+ SELECT id, relation, display_name, to_char(birth_date, 'YYYY-MM-DD')
+ FROM profiles
WHERE user_id=$1 AND deleted_at IS NULL
ORDER BY created_at ASC`, userID)
if err != nil {
@@ -180,7 +327,7 @@ func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) (
var out []ProfileBrief
for rows.Next() {
var p ProfileBrief
- if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName); err != nil {
+ if err := rows.Scan(&p.ID, &p.Relation, &p.DisplayName, &p.BirthDate); err != nil {
return nil, err
}
out = append(out, p)
@@ -188,6 +335,83 @@ func (r *AdminRepo) ListProfilesForUser(ctx context.Context, userID uuid.UUID) (
return out, rows.Err()
}
+// ReportBrief for admin user detail.
+type ReportBrief struct {
+ ID uuid.UUID `json:"id"`
+ Type string `json:"type"`
+ CreatedAt time.Time `json:"created_at"`
+}
+
+// ListReportsForUser returns recent growth reports.
+func (r *AdminRepo) ListReportsForUser(ctx context.Context, userID uuid.UUID, limit int) ([]ReportBrief, error) {
+ if limit <= 0 || limit > 50 {
+ limit = 20
+ }
+ rows, err := r.Pool.Query(ctx, `
+ SELECT id, type, created_at FROM growth_reports
+ WHERE user_id=$1 AND deleted_at IS NULL
+ ORDER BY created_at DESC
+ LIMIT $2`, userID, limit)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []ReportBrief
+ for rows.Next() {
+ var rep ReportBrief
+ if err := rows.Scan(&rep.ID, &rep.Type, &rep.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, rep)
+ }
+ return out, rows.Err()
+}
+
+// GetUserAccount loads phone/nickname/paid ask quota for one user.
+func (r *AdminRepo) GetUserAccount(ctx context.Context, userID uuid.UUID) (phone, nickname *string, paidLeft int, status string, createdAt time.Time, err error) {
+ err = r.Pool.QueryRow(ctx, `
+ SELECT phone, nickname, ask_paid_quota_left, status, created_at
+ FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
+ ).Scan(&phone, &nickname, &paidLeft, &status, &createdAt)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil, 0, "", time.Time{}, err
+ }
+ return
+}
+
+// GrantAskQuotaWithAudit adds paid ask quota and writes audit.
+func (r *AdminRepo) GrantAskQuotaWithAudit(ctx context.Context, adminID, userID uuid.UUID, delta int, meta json.RawMessage) (int, error) {
+ if delta <= 0 {
+ return 0, errors.New("delta must be positive")
+ }
+ tx, err := r.Pool.Begin(ctx)
+ if err != nil {
+ return 0, err
+ }
+ defer tx.Rollback(ctx)
+ var left int
+ err = tx.QueryRow(ctx, `
+ UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
+ WHERE id=$1 AND deleted_at IS NULL
+ RETURNING ask_paid_quota_left`, userID, delta,
+ ).Scan(&left)
+ if err != nil {
+ return 0, 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,'ask_quota.grant','user',$2,$3)`, adminID, userID.String(), meta); err != nil {
+ return 0, err
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return 0, err
+ }
+ return left, nil
+}
+
// OrderListItem for admin order tables.
type OrderListItem struct {
ID uuid.UUID `json:"id"`
diff --git a/apps/api/internal/repository/analytics_repo.go b/apps/api/internal/repository/analytics_repo.go
new file mode 100644
index 0000000..18c9677
--- /dev/null
+++ b/apps/api/internal/repository/analytics_repo.go
@@ -0,0 +1,313 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// AnalyticsRepo persists behavior events and sessions.
+type AnalyticsRepo struct {
+ Pool *pgxpool.Pool
+}
+
+// AnalyticsEventRow is one ingest event after validation.
+type AnalyticsEventRow struct {
+ SessionID string
+ UserID uuid.UUID
+ Name string
+ PagePath string
+ Props json.RawMessage
+ ClientTS time.Time
+}
+
+// UpsertSession creates or refreshes a session row.
+func (r *AnalyticsRepo) UpsertSession(
+ ctx context.Context,
+ sessionID, deviceKey string,
+ userID uuid.UUID,
+ startedAt time.Time,
+) error {
+ _, err := r.Pool.Exec(ctx, `
+ INSERT INTO analytics_sessions(session_id, device_key, user_id, started_at)
+ VALUES ($1,$2,$3,$4)
+ ON CONFLICT (session_id) DO UPDATE SET
+ device_key = EXCLUDED.device_key,
+ user_id = COALESCE(EXCLUDED.user_id, analytics_sessions.user_id)`,
+ sessionID, deviceKey, userID, startedAt,
+ )
+ return err
+}
+
+// EndSession updates session end fields.
+func (r *AnalyticsRepo) EndSession(
+ ctx context.Context,
+ sessionID string,
+ endedAt time.Time,
+ exitPage string,
+ durationMs int,
+) error {
+ _, err := r.Pool.Exec(ctx, `
+ UPDATE analytics_sessions
+ SET ended_at=$2, exit_page=$3, duration_ms=$4
+ WHERE session_id=$1`,
+ sessionID, endedAt, emptyToNil(exitPage), durationMs,
+ )
+ return err
+}
+
+// InsertEvents bulk-inserts event rows.
+func (r *AnalyticsRepo) InsertEvents(ctx context.Context, rows []AnalyticsEventRow) error {
+ if len(rows) == 0 {
+ return nil
+ }
+ for _, row := range rows {
+ uid := interface{}(nil)
+ if row.UserID != uuid.Nil {
+ uid = row.UserID
+ }
+ page := emptyToNil(row.PagePath)
+ props := row.Props
+ if len(props) == 0 {
+ props = []byte("{}")
+ }
+ if _, err := r.Pool.Exec(ctx, `
+ INSERT INTO analytics_events(session_id, user_id, name, page_path, props, client_ts)
+ VALUES ($1,$2,$3,$4,$5,$6)`,
+ row.SessionID, uid, row.Name, page, props, row.ClientTS,
+ ); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// OverviewAgg is admin overview metrics.
+type OverviewAgg struct {
+ DAU int `json:"dau"`
+ NewUsers int `json:"new_users"`
+ Sessions int `json:"sessions"`
+ AvgSessionMs float64 `json:"avg_session_ms"`
+ Series []DayPoint `json:"series"`
+}
+
+// DayPoint is one day in a trend series.
+type DayPoint struct {
+ Day string `json:"day"`
+ DAU int `json:"dau"`
+ Sessions int `json:"sessions"`
+}
+
+// PageAgg is per-page metrics.
+type PageAgg struct {
+ PagePath string `json:"page_path"`
+ PV int `json:"pv"`
+ UV int `json:"uv"`
+ AvgDwellMs float64 `json:"avg_dwell_ms"`
+ ExitCount int `json:"exit_count"`
+}
+
+// ExitAgg is exit page ranking.
+type ExitAgg struct {
+ ExitPage string `json:"exit_page"`
+ Count int `json:"count"`
+}
+
+// ClickAgg is click ranking.
+type ClickAgg struct {
+ ElementID string `json:"element_id"`
+ Count int `json:"count"`
+}
+
+// FunnelStep is one named funnel count.
+type FunnelStep struct {
+ Name string `json:"name"`
+ Count int `json:"count"`
+}
+
+func (r *AnalyticsRepo) Overview(ctx context.Context, from, to time.Time) (*OverviewAgg, error) {
+ out := &OverviewAgg{}
+ err := r.Pool.QueryRow(ctx, `
+ SELECT count(DISTINCT user_id)::int
+ FROM analytics_events
+ WHERE received_at >= $1 AND received_at < $2 AND user_id IS NOT NULL`,
+ from, to,
+ ).Scan(&out.DAU)
+ if err != nil {
+ return nil, err
+ }
+ err = r.Pool.QueryRow(ctx, `
+ SELECT count(*)::int FROM users
+ WHERE created_at >= $1 AND created_at < $2 AND deleted_at IS NULL`,
+ from, to,
+ ).Scan(&out.NewUsers)
+ if err != nil {
+ return nil, err
+ }
+ err = r.Pool.QueryRow(ctx, `
+ SELECT count(*)::int,
+ COALESCE(avg(duration_ms) FILTER (WHERE duration_ms IS NOT NULL), 0)::float8
+ FROM analytics_sessions
+ WHERE started_at >= $1 AND started_at < $2`,
+ from, to,
+ ).Scan(&out.Sessions, &out.AvgSessionMs)
+ if err != nil {
+ return nil, err
+ }
+ rows, err := r.Pool.Query(ctx, `
+ SELECT to_char(d, 'YYYY-MM-DD') AS day,
+ COALESCE((
+ SELECT count(DISTINCT e.user_id)::int FROM analytics_events e
+ WHERE e.received_at >= d AND e.received_at < d + interval '1 day'
+ AND e.user_id IS NOT NULL
+ ), 0) AS dau,
+ COALESCE((
+ SELECT count(*)::int FROM analytics_sessions s
+ WHERE s.started_at >= d AND s.started_at < d + interval '1 day'
+ ), 0) AS sessions
+ FROM generate_series($1::timestamptz, $2::timestamptz - interval '1 day', interval '1 day') AS d
+ ORDER BY d`,
+ from, to,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var p DayPoint
+ if err := rows.Scan(&p.Day, &p.DAU, &p.Sessions); err != nil {
+ return nil, err
+ }
+ out.Series = append(out.Series, p)
+ }
+ return out, rows.Err()
+}
+
+func (r *AnalyticsRepo) Pages(ctx context.Context, from, to time.Time) ([]PageAgg, error) {
+ rows, err := r.Pool.Query(ctx, `
+ WITH views AS (
+ SELECT coalesce(nullif(page_path,''), props->>'page_path') AS path,
+ count(*)::int AS pv,
+ count(DISTINCT user_id)::int AS uv
+ FROM analytics_events
+ WHERE name='page_view' AND received_at >= $1 AND received_at < $2
+ GROUP BY 1
+ ),
+ dwells AS (
+ SELECT coalesce(nullif(page_path,''), props->>'page_path') AS path,
+ avg(NULLIF((props->>'dwell_ms')::float8, 'NaN'))::float8 AS avg_dwell
+ FROM analytics_events
+ WHERE name='page_leave' AND received_at >= $1 AND received_at < $2
+ GROUP BY 1
+ ),
+ exits AS (
+ SELECT coalesce(nullif(exit_page,''), '') AS path, count(*)::int AS n
+ FROM analytics_sessions
+ WHERE ended_at >= $1 AND ended_at < $2 AND exit_page IS NOT NULL AND exit_page <> ''
+ GROUP BY 1
+ )
+ SELECT coalesce(v.path, d.path, e.path) AS page_path,
+ coalesce(v.pv, 0), coalesce(v.uv, 0),
+ coalesce(d.avg_dwell, 0), coalesce(e.n, 0)
+ FROM views v
+ FULL OUTER JOIN dwells d ON v.path = d.path
+ FULL OUTER JOIN exits e ON coalesce(v.path, d.path) = e.path
+ WHERE coalesce(v.path, d.path, e.path) IS NOT NULL
+ AND coalesce(v.path, d.path, e.path) <> ''
+ ORDER BY coalesce(v.pv, 0) DESC
+ LIMIT 50`,
+ from, to,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []PageAgg
+ for rows.Next() {
+ var p PageAgg
+ if err := rows.Scan(&p.PagePath, &p.PV, &p.UV, &p.AvgDwellMs, &p.ExitCount); err != nil {
+ return nil, err
+ }
+ out = append(out, p)
+ }
+ return out, rows.Err()
+}
+
+func (r *AnalyticsRepo) Exits(ctx context.Context, from, to time.Time) ([]ExitAgg, error) {
+ rows, err := r.Pool.Query(ctx, `
+ SELECT exit_page, count(*)::int
+ FROM analytics_sessions
+ WHERE ended_at >= $1 AND ended_at < $2
+ AND exit_page IS NOT NULL AND exit_page <> ''
+ GROUP BY exit_page
+ ORDER BY count(*) DESC
+ LIMIT 30`,
+ from, to,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []ExitAgg
+ for rows.Next() {
+ var e ExitAgg
+ if err := rows.Scan(&e.ExitPage, &e.Count); err != nil {
+ return nil, err
+ }
+ out = append(out, e)
+ }
+ return out, rows.Err()
+}
+
+func (r *AnalyticsRepo) Clicks(ctx context.Context, from, to time.Time) ([]ClickAgg, error) {
+ rows, err := r.Pool.Query(ctx, `
+ SELECT coalesce(props->>'element_id', '') AS eid, count(*)::int
+ FROM analytics_events
+ WHERE name='ui_click' AND received_at >= $1 AND received_at < $2
+ AND coalesce(props->>'element_id','') <> ''
+ GROUP BY 1
+ ORDER BY count(*) DESC
+ LIMIT 30`,
+ from, to,
+ )
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []ClickAgg
+ for rows.Next() {
+ var c ClickAgg
+ if err := rows.Scan(&c.ElementID, &c.Count); err != nil {
+ return nil, err
+ }
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
+func (r *AnalyticsRepo) Funnel(ctx context.Context, from, to time.Time, names []string) ([]FunnelStep, error) {
+ out := make([]FunnelStep, 0, len(names))
+ for _, name := range names {
+ var n int
+ err := r.Pool.QueryRow(ctx, `
+ SELECT count(*)::int FROM analytics_events
+ WHERE name=$1 AND received_at >= $2 AND received_at < $3`,
+ name, from, to,
+ ).Scan(&n)
+ if err != nil {
+ return nil, err
+ }
+ out = append(out, FunnelStep{Name: name, Count: n})
+ }
+ return out, nil
+}
+
+func emptyToNil(s string) interface{} {
+ if s == "" {
+ return nil
+ }
+ return s
+}
diff --git a/apps/api/internal/repository/ask_repo.go b/apps/api/internal/repository/ask_repo.go
index 13bc48d..e162b05 100644
--- a/apps/api/internal/repository/ask_repo.go
+++ b/apps/api/internal/repository/ask_repo.go
@@ -102,3 +102,62 @@ func (r *AskRepo) ConsumeMembershipQuota(ctx context.Context, userID uuid.UUID)
}
return true, left, nil
}
+
+// GetAskPaidQuota returns purchased ask pack remaining.
+func (r *AskRepo) GetAskPaidQuota(ctx context.Context, userID uuid.UUID) (int, error) {
+ var n int
+ err := r.Pool.QueryRow(ctx, `
+ SELECT ask_paid_quota_left FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
+ ).Scan(&n)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return 0, nil
+ }
+ return n, err
+}
+
+// AddAskPaidQuota increments purchased ask pack remaining.
+func (r *AskRepo) AddAskPaidQuota(ctx context.Context, userID uuid.UUID, delta int) (int, error) {
+ if delta <= 0 {
+ return 0, errors.New("delta must be positive")
+ }
+ var n int
+ err := r.Pool.QueryRow(ctx, `
+ UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
+ WHERE id=$1 AND deleted_at IS NULL
+ RETURNING ask_paid_quota_left`, userID, delta,
+ ).Scan(&n)
+ return n, err
+}
+
+// ConsumeAskPaidQuota decrements purchased ask pack remaining.
+func (r *AskRepo) ConsumeAskPaidQuota(ctx context.Context, userID uuid.UUID) (ok bool, left int, err error) {
+ err = r.Pool.QueryRow(ctx, `
+ UPDATE users SET ask_paid_quota_left = ask_paid_quota_left - 1, updated_at=now()
+ WHERE id=$1 AND deleted_at IS NULL AND ask_paid_quota_left > 0
+ RETURNING ask_paid_quota_left`, userID,
+ ).Scan(&left)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return false, 0, nil
+ }
+ if err != nil {
+ return false, 0, err
+ }
+ return true, left, nil
+}
+
+// SoftDeleteThread marks a thread and its messages deleted for the owner.
+func (r *AskRepo) SoftDeleteThread(ctx context.Context, userID, threadID uuid.UUID) error {
+ ct, err := r.Pool.Exec(ctx, `
+ UPDATE ask_threads SET deleted_at=now(), updated_at=now()
+ WHERE id=$1 AND user_id=$2 AND deleted_at IS NULL`, threadID, userID)
+ if err != nil {
+ return err
+ }
+ if ct.RowsAffected() == 0 {
+ return errors.New("thread not found")
+ }
+ _, err = r.Pool.Exec(ctx, `
+ UPDATE ask_messages SET deleted_at=now()
+ WHERE thread_id=$1 AND deleted_at IS NULL`, threadID)
+ return err
+}
diff --git a/apps/api/internal/repository/auth_repo.go b/apps/api/internal/repository/auth_repo.go
new file mode 100644
index 0000000..7c7128c
--- /dev/null
+++ b/apps/api/internal/repository/auth_repo.go
@@ -0,0 +1,153 @@
+package repository
+
+import (
+ "context"
+ "errors"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// AuthRepo persists account credentials and sessions.
+type AuthRepo struct {
+ Pool *pgxpool.Pool
+}
+
+// AccountRow is a registered user snapshot.
+type AccountRow struct {
+ ID uuid.UUID
+ Phone string
+ PasswordHash string
+ Nickname string
+ Status string
+}
+
+// GetByPhone loads a registered user by phone.
+func (r *AuthRepo) GetByPhone(ctx context.Context, phone string) (*AccountRow, error) {
+ row := &AccountRow{}
+ var nick *string
+ err := r.Pool.QueryRow(ctx, `
+ SELECT id, phone, password_hash, COALESCE(nickname,''), status
+ FROM users
+ WHERE phone=$1 AND deleted_at IS NULL`, phone,
+ ).Scan(&row.ID, &row.Phone, &row.PasswordHash, &nick, &row.Status)
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, err
+ }
+ if err != nil {
+ return nil, err
+ }
+ if nick != nil {
+ row.Nickname = *nick
+ }
+ return row, nil
+}
+
+// GetAccount loads account fields for a user id.
+func (r *AuthRepo) GetAccount(ctx context.Context, userID uuid.UUID) (*AccountRow, error) {
+ row := &AccountRow{}
+ var phone, hash, nick *string
+ err := r.Pool.QueryRow(ctx, `
+ SELECT id, phone, password_hash, nickname, status
+ FROM users WHERE id=$1 AND deleted_at IS NULL`, userID,
+ ).Scan(&row.ID, &phone, &hash, &nick, &row.Status)
+ if err != nil {
+ return nil, err
+ }
+ if phone != nil {
+ row.Phone = *phone
+ }
+ if hash != nil {
+ row.PasswordHash = *hash
+ }
+ if nick != nil {
+ row.Nickname = *nick
+ }
+ return row, nil
+}
+
+// RegisterOnUser upgrades an anonymous user with phone credentials.
+func (r *AuthRepo) RegisterOnUser(ctx context.Context, userID uuid.UUID, phone, hash, nickname string) error {
+ tag, err := r.Pool.Exec(ctx, `
+ UPDATE users
+ SET phone=$2, password_hash=$3, nickname=NULLIF($4,''), updated_at=now()
+ WHERE id=$1 AND deleted_at IS NULL AND phone IS NULL`,
+ userID, phone, hash, nickname,
+ )
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return errString("register conflict")
+ }
+ return nil
+}
+
+// CreateUserWithPhone inserts a new registered user.
+func (r *AuthRepo) CreateUserWithPhone(ctx context.Context, phone, hash, nickname string) (uuid.UUID, error) {
+ var id uuid.UUID
+ err := r.Pool.QueryRow(ctx, `
+ INSERT INTO users(phone, password_hash, nickname)
+ VALUES ($1,$2,NULLIF($3,''))
+ RETURNING id`,
+ phone, hash, nickname,
+ ).Scan(&id)
+ return id, err
+}
+
+// TouchPassword updates stored password hash (open-login record).
+func (r *AuthRepo) TouchPassword(ctx context.Context, userID uuid.UUID, hash string) error {
+ _, err := r.Pool.Exec(ctx, `
+ UPDATE users SET password_hash=$2, updated_at=now()
+ WHERE id=$1 AND deleted_at IS NULL`, userID, hash)
+ return err
+}
+
+// BindDevice sets device_identities.user_id to account.
+func (r *AuthRepo) BindDevice(ctx context.Context, deviceKey string, userID uuid.UUID) error {
+ _, err := r.Pool.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, userID,
+ )
+ return err
+}
+
+// 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, `
+ INSERT INTO user_sessions(user_id, token, expires_at) VALUES ($1,$2,$3)`,
+ userID, token, expires,
+ )
+ return err
+}
+
+// UserIDByToken resolves a live session.
+func (r *AuthRepo) UserIDByToken(ctx context.Context, token string) (uuid.UUID, error) {
+ var id uuid.UUID
+ err := r.Pool.QueryRow(ctx, `
+ SELECT user_id FROM user_sessions
+ WHERE token=$1 AND revoked_at IS NULL AND expires_at > now()`, token,
+ ).Scan(&id)
+ return id, err
+}
+
+// RevokeSession marks token revoked.
+func (r *AuthRepo) RevokeSession(ctx context.Context, token string) error {
+ _, err := r.Pool.Exec(ctx, `
+ UPDATE user_sessions SET revoked_at=now() WHERE token=$1 AND revoked_at IS NULL`, token)
+ return err
+}
+
+// IsRegistered reports whether user has phone.
+func (r *AuthRepo) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) {
+ var ok bool
+ err := r.Pool.QueryRow(ctx, `
+ SELECT EXISTS(
+ SELECT 1 FROM users WHERE id=$1 AND phone IS NOT NULL AND deleted_at IS NULL
+ )`, userID).Scan(&ok)
+ return ok, err
+}
diff --git a/apps/api/internal/repository/home_tools_repo.go b/apps/api/internal/repository/home_tools_repo.go
new file mode 100644
index 0000000..e42d5b7
--- /dev/null
+++ b/apps/api/internal/repository/home_tools_repo.go
@@ -0,0 +1,113 @@
+package repository
+
+import (
+ "context"
+ "encoding/json"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5/pgxpool"
+)
+
+// HomeTool is one homepage grid entry.
+type HomeTool struct {
+ ID uuid.UUID `json:"id"`
+ RowIndex int `json:"row_index"`
+ SortOrder int `json:"sort_order"`
+ Path string `json:"path"`
+ Icon string `json:"icon"`
+ Label string `json:"label"`
+ Badge *string `json:"badge,omitempty"`
+ BadgeTone *string `json:"badge_tone,omitempty"`
+ Enabled bool `json:"enabled"`
+ UpdatedAt time.Time `json:"updated_at,omitempty"`
+}
+
+// HomeToolsRepo persists homepage grid tools.
+type HomeToolsRepo struct {
+ Pool *pgxpool.Pool
+}
+
+// ListAll returns all tools ordered by row then sort.
+func (r *HomeToolsRepo) ListAll(ctx context.Context) ([]HomeTool, error) {
+ return r.query(ctx, false)
+}
+
+// ListEnabled returns enabled tools for C-end.
+func (r *HomeToolsRepo) ListEnabled(ctx context.Context) ([]HomeTool, error) {
+ return r.query(ctx, true)
+}
+
+func (r *HomeToolsRepo) query(ctx context.Context, onlyEnabled bool) ([]HomeTool, error) {
+ q := `
+ SELECT id, row_index, sort_order, path, icon, label, badge, badge_tone, enabled, updated_at
+ FROM home_tools`
+ if onlyEnabled {
+ q += ` WHERE enabled = true`
+ }
+ q += ` ORDER BY row_index, sort_order, label`
+ rows, err := r.Pool.Query(ctx, q)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []HomeTool
+ for rows.Next() {
+ var t HomeTool
+ if err := rows.Scan(
+ &t.ID, &t.RowIndex, &t.SortOrder, &t.Path, &t.Icon, &t.Label,
+ &t.Badge, &t.BadgeTone, &t.Enabled, &t.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ out = append(out, t)
+ }
+ return out, rows.Err()
+}
+
+// ReplaceAll deletes all rows and inserts items in one transaction.
+func (r *HomeToolsRepo) ReplaceAll(ctx context.Context, items []HomeTool) error {
+ return r.ReplaceAllWithAudit(ctx, items, uuid.Nil, nil)
+}
+
+// ReplaceAllWithAudit replaces tools and optionally writes admin_audit_logs in one tx.
+// If adminID is uuid.Nil, skips audit insert.
+func (r *HomeToolsRepo) ReplaceAllWithAudit(
+ ctx context.Context,
+ items []HomeTool,
+ adminID uuid.UUID,
+ meta json.RawMessage,
+) error {
+ tx, err := r.Pool.Begin(ctx)
+ if err != nil {
+ return err
+ }
+ defer tx.Rollback(ctx)
+ if _, err := tx.Exec(ctx, `DELETE FROM home_tools`); err != nil {
+ return err
+ }
+ for _, it := range items {
+ id := it.ID
+ if id == uuid.Nil {
+ id = uuid.New()
+ }
+ if _, err := tx.Exec(ctx, `
+ INSERT INTO home_tools(id, row_index, sort_order, path, icon, label, badge, badge_tone, enabled, updated_at)
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,now())`,
+ id, it.RowIndex, it.SortOrder, it.Path, it.Icon, it.Label, it.Badge, it.BadgeTone, it.Enabled,
+ ); err != nil {
+ return err
+ }
+ }
+ if adminID != uuid.Nil {
+ 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,'home_tools.replace','home_tools','all',$2)`, adminID, meta); err != nil {
+ return err
+ }
+ }
+ return tx.Commit(ctx)
+}
diff --git a/apps/api/internal/repository/report_repo.go b/apps/api/internal/repository/report_repo.go
index 40630cb..3e5fe88 100644
--- a/apps/api/internal/repository/report_repo.go
+++ b/apps/api/internal/repository/report_repo.go
@@ -18,16 +18,98 @@ type ReportRepo struct {
Pool *pgxpool.Pool
}
-// Create inserts a growth report.
+// Create inserts a growth report (optional peer for pair reports).
func (r *ReportRepo) Create(ctx context.Context, userID, profileID uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
+ return r.CreateWithPeer(ctx, userID, profileID, nil, typ, summary, detail)
+}
+
+// CreateWithPeer inserts a report with optional peer_profile_id.
+func (r *ReportRepo) CreateWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
rep := &model.GrowthReport{}
err := r.Pool.QueryRow(ctx, `
- INSERT INTO growth_reports(user_id, profile_id, type, summary, detail)
- VALUES ($1,$2,$3,$4,$5)
+ INSERT INTO growth_reports(user_id, profile_id, peer_profile_id, type, summary, detail)
+ VALUES ($1,$2,$3,$4,$5,$6)
RETURNING id, user_id, profile_id, type, summary, detail, created_at`,
- userID, profileID, typ, summary, detail,
+ userID, profileID, peer, typ, summary, detail,
).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
- return rep, err
+ if err != nil {
+ return nil, err
+ }
+ rep.PeerProfileID = peer
+ return rep, nil
+}
+
+// SoftDeleteMatching soft-deletes prior reports for overwrite semantics.
+func (r *ReportRepo) SoftDeleteMatching(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string) error {
+ if peer == nil {
+ _, err := r.Pool.Exec(ctx, `
+ UPDATE growth_reports SET deleted_at=now(), updated_at=now()
+ WHERE user_id=$1 AND profile_id=$2 AND type=$3
+ AND peer_profile_id IS NULL AND deleted_at IS NULL`,
+ userID, profileID, typ)
+ return err
+ }
+ _, err := r.Pool.Exec(ctx, `
+ UPDATE growth_reports SET deleted_at=now(), updated_at=now()
+ WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
+ AND (
+ (profile_id=$3 AND peer_profile_id=$4) OR
+ (profile_id=$4 AND peer_profile_id=$3)
+ )`,
+ userID, typ, profileID, *peer)
+ return err
+}
+
+// UpsertWithPeer soft-deletes matching then inserts.
+func (r *ReportRepo) UpsertWithPeer(ctx context.Context, userID, profileID uuid.UUID, peer *uuid.UUID, typ string, summary, detail json.RawMessage) (*model.GrowthReport, error) {
+ if err := r.SoftDeleteMatching(ctx, userID, profileID, peer, typ); err != nil {
+ return nil, err
+ }
+ return r.CreateWithPeer(ctx, userID, profileID, peer, typ, summary, detail)
+}
+
+// GetLatest returns newest non-deleted report for profile+type(+peer).
+func (r *ReportRepo) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
+ rep := &model.GrowthReport{}
+ var peerOut *uuid.UUID
+ var err error
+ if peer == nil {
+ err = r.Pool.QueryRow(ctx, `
+ SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
+ FROM growth_reports
+ WHERE user_id=$1 AND profile_id=$2 AND type=$3
+ AND peer_profile_id IS NULL AND deleted_at IS NULL
+ ORDER BY created_at DESC LIMIT 1`,
+ userID, profileID, typ,
+ ).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
+ } else {
+ err = r.Pool.QueryRow(ctx, `
+ SELECT id, user_id, profile_id, peer_profile_id, type, summary, detail, created_at
+ FROM growth_reports
+ WHERE user_id=$1 AND type=$2 AND deleted_at IS NULL
+ AND (
+ (profile_id=$3 AND peer_profile_id=$4) OR
+ (profile_id=$4 AND peer_profile_id=$3)
+ )
+ ORDER BY created_at DESC LIMIT 1`,
+ userID, typ, profileID, *peer,
+ ).Scan(&rep.ID, &rep.UserID, &rep.ProfileID, &peerOut, &rep.Type, &rep.Summary, &rep.Detail, &rep.CreatedAt)
+ }
+ if err != nil {
+ return nil, err
+ }
+ rep.PeerProfileID = peerOut
+ return rep, nil
+}
+
+// SoftDeleteForProfile marks all reports involving a profile as deleted.
+func (r *ReportRepo) SoftDeleteForProfile(ctx context.Context, userID, profileID uuid.UUID) error {
+ _, err := r.Pool.Exec(ctx, `
+ UPDATE growth_reports SET deleted_at=now(), updated_at=now()
+ WHERE user_id=$1 AND deleted_at IS NULL
+ AND (profile_id=$2 OR peer_profile_id=$2)`,
+ userID, profileID)
+ return err
}
// GetForUser loads a report owned by user.
@@ -188,10 +270,52 @@ func (r *ReportRepo) PayMock(ctx context.Context, userID, orderID uuid.UUID) err
userID, p, days); err != nil {
return err
}
+ case "ask_pack":
+ p := "pack10"
+ if plan != nil && *plan != "" {
+ p = *plan
+ }
+ delta := AskPackQuota(p)
+ if delta <= 0 {
+ return errString("invalid ask_pack plan")
+ }
+ if _, err := tx.Exec(ctx, `
+ UPDATE users SET ask_paid_quota_left = ask_paid_quota_left + $2, updated_at=now()
+ WHERE id=$1 AND deleted_at IS NULL`, userID, delta); err != nil {
+ return err
+ }
}
return tx.Commit(ctx)
}
+// AskPackQuota returns how many ask replies a pack plan grants.
+func AskPackQuota(plan string) int {
+ switch plan {
+ case "pack10":
+ return 10
+ case "pack30":
+ return 30
+ case "pack100":
+ return 100
+ default:
+ return 0
+ }
+}
+
+// AskPackAmountCents is mock price for an ask pack plan.
+func AskPackAmountCents(plan string) int {
+ switch plan {
+ case "pack10":
+ return 990
+ case "pack30":
+ return 1980
+ case "pack100":
+ return 4990
+ default:
+ return 0
+ }
+}
+
var errMissingReport = errString("report_id required for deep_access")
type errString string
diff --git a/apps/api/internal/repository/scale_repo.go b/apps/api/internal/repository/scale_repo.go
index 07052b3..491f466 100644
--- a/apps/api/internal/repository/scale_repo.go
+++ b/apps/api/internal/repository/scale_repo.go
@@ -5,6 +5,7 @@ import (
"encoding/json"
"github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -62,13 +63,13 @@ func (r *ScaleRepo) ListPublished(ctx context.Context) ([]ScaleListItem, error)
return out, rows.Err()
}
-// GetBySlug loads scale with questions.
+// GetBySlug loads a published scale with questions.
func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, error) {
d := &ScaleDetail{Slug: slug}
var scaleID uuid.UUID
err := r.Pool.QueryRow(ctx, `
SELECT id, title, description FROM scales
- WHERE slug=$1 AND deleted_at IS NULL`, slug,
+ WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug,
).Scan(&scaleID, &d.Title, &d.Description)
if err != nil {
return nil, err
@@ -90,6 +91,75 @@ func (r *ScaleRepo) GetBySlug(ctx context.Context, slug string) (*ScaleDetail, e
return d, rows.Err()
}
+// ScaleAdminItem is a scale row for ops.
+type ScaleAdminItem struct {
+ ID uuid.UUID `json:"id"`
+ Slug string `json:"slug"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+}
+
+// ListAllAdmin returns all non-deleted scales.
+func (r *ScaleRepo) ListAllAdmin(ctx context.Context) ([]ScaleAdminItem, error) {
+ rows, err := r.Pool.Query(ctx, `
+ SELECT id, slug, title, description, status FROM scales
+ WHERE deleted_at IS NULL ORDER BY created_at`)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+ var out []ScaleAdminItem
+ for rows.Next() {
+ var it ScaleAdminItem
+ if err := rows.Scan(&it.ID, &it.Slug, &it.Title, &it.Description, &it.Status); err != nil {
+ return nil, err
+ }
+ out = append(out, it)
+ }
+ return out, rows.Err()
+}
+
+// UpdateStatus sets published|draft.
+func (r *ScaleRepo) UpdateStatus(ctx context.Context, id uuid.UUID, status string) error {
+ return r.UpdateStatusWithAudit(ctx, id, status, uuid.Nil, nil)
+}
+
+// UpdateStatusWithAudit updates status and optionally writes audit in one tx.
+func (r *ScaleRepo) UpdateStatusWithAudit(
+ ctx context.Context,
+ id uuid.UUID,
+ status string,
+ adminID uuid.UUID,
+ 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 scales SET status=$2, updated_at=now()
+ WHERE id=$1 AND deleted_at IS NULL`, id, status)
+ if err != nil {
+ return err
+ }
+ if tag.RowsAffected() == 0 {
+ return pgx.ErrNoRows
+ }
+ if adminID != uuid.Nil {
+ 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,'scale.status','scale',$2,$3)`, adminID, id.String(), meta); err != nil {
+ return err
+ }
+ }
+ return tx.Commit(ctx)
+}
+
// SaveResult stores scoring output.
func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID uuid.UUID, answers, result json.RawMessage) (uuid.UUID, error) {
var id uuid.UUID
@@ -101,10 +171,11 @@ func (r *ScaleRepo) SaveResult(ctx context.Context, userID, scaleID, profileID u
return id, err
}
-// ScaleIDBySlug resolves id.
+// ScaleIDBySlug resolves id for a published scale.
func (r *ScaleRepo) ScaleIDBySlug(ctx context.Context, slug string) (uuid.UUID, error) {
var id uuid.UUID
err := r.Pool.QueryRow(ctx, `
- SELECT id FROM scales WHERE slug=$1 AND deleted_at IS NULL`, slug).Scan(&id)
+ SELECT id FROM scales
+ WHERE slug=$1 AND status='published' AND deleted_at IS NULL`, slug).Scan(&id)
return id, err
}
diff --git a/apps/api/internal/service/admin/content.go b/apps/api/internal/service/admin/content.go
new file mode 100644
index 0000000..8da6e01
--- /dev/null
+++ b/apps/api/internal/service/admin/content.go
@@ -0,0 +1,61 @@
+package admin
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+
+ "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
+ homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
+)
+
+var (
+ ErrInvalidScaleStatus = errors.New("invalid scale status")
+ ErrScaleNotFound = errors.New("scale not found")
+)
+
+// ListHomeTools returns all grid tools.
+func (s *Service) ListHomeTools(ctx context.Context) ([]repository.HomeTool, error) {
+ if s.Home == nil {
+ return nil, errors.New("home unavailable")
+ }
+ return s.Home.ListAdmin(ctx)
+}
+
+// ReplaceHomeTools replaces grid and audits in one transaction.
+func (s *Service) ReplaceHomeTools(ctx context.Context, adminID uuid.UUID, items []homesvc.ReplaceInput) error {
+ if s.Home == nil {
+ return errors.New("home unavailable")
+ }
+ meta, _ := json.Marshal(map[string]any{"count": len(items)})
+ return s.Home.ReplaceWithAudit(ctx, adminID, items, meta)
+}
+
+// ListScalesAdmin returns all scales.
+func (s *Service) ListScalesAdmin(ctx context.Context) ([]repository.ScaleAdminItem, error) {
+ if s.Scales == nil {
+ return nil, errors.New("scales unavailable")
+ }
+ return s.Scales.ListAllAdmin(ctx)
+}
+
+// PatchScaleStatus updates published|draft and audits in one transaction.
+func (s *Service) PatchScaleStatus(ctx context.Context, adminID, scaleID uuid.UUID, status string) error {
+ if status != "published" && status != "draft" {
+ return ErrInvalidScaleStatus
+ }
+ if s.Scales == nil {
+ return errors.New("scales unavailable")
+ }
+ meta, _ := json.Marshal(map[string]any{"status": status})
+ if err := s.Scales.UpdateStatusWithAudit(ctx, scaleID, status, adminID, meta); err != nil {
+ if errors.Is(err, pgx.ErrNoRows) {
+ return ErrScaleNotFound
+ }
+ return err
+ }
+ return nil
+}
diff --git a/apps/api/internal/service/admin/service.go b/apps/api/internal/service/admin/service.go
index c90037b..9e2739b 100644
--- a/apps/api/internal/service/admin/service.go
+++ b/apps/api/internal/service/admin/service.go
@@ -13,12 +13,15 @@ import (
"golang.org/x/crypto/bcrypt"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
+ homesvc "github.com/yuxingu/digital-psychology/apps/api/internal/service/home"
)
// Service is ops-admin application layer.
type Service struct {
Repo *repository.AdminRepo
Reports *repository.ReportRepo
+ Home *homesvc.Service
+ Scales *repository.ScaleRepo
}
// BootstrapConfig seeds the first admin when table is empty.
@@ -121,30 +124,43 @@ func (s *Service) ListUsers(ctx context.Context, q string, limit, offset int) ([
return s.Repo.ListUsers(ctx, q, limit, offset)
}
+// DashboardStats exposes ops overview.
+func (s *Service) DashboardStats(ctx context.Context) (*repository.DashboardStats, error) {
+ return s.Repo.GetDashboardStats(ctx)
+}
+
// UserDetail is admin view of one user.
type UserDetail struct {
- ID uuid.UUID `json:"id"`
- Status string `json:"status"`
- CreatedAt time.Time `json:"created_at"`
- Profiles []repository.ProfileBrief `json:"profiles"`
- Membership *repository.MembershipRow `json:"membership"`
- Orders []repository.OrderListItem `json:"recent_orders"`
+ ID uuid.UUID `json:"id"`
+ Status string `json:"status"`
+ Phone *string `json:"phone,omitempty"`
+ Nickname *string `json:"nickname,omitempty"`
+ AskPaidQuotaLeft int `json:"ask_paid_quota_left"`
+ CreatedAt time.Time `json:"created_at"`
+ Profiles []repository.ProfileBrief `json:"profiles"`
+ Reports []repository.ReportBrief `json:"reports"`
+ Membership *repository.MembershipRow `json:"membership"`
+ Orders []repository.OrderListItem `json:"recent_orders"`
}
// GetUser loads user detail for admin.
func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, error) {
- ok, err := s.Repo.UserExists(ctx, userID)
+ phone, nickname, paidLeft, status, createdAt, err := s.Repo.GetUserAccount(ctx, userID)
+ if err != nil {
+ ok, e2 := s.Repo.UserExists(ctx, userID)
+ if e2 != nil {
+ return nil, e2
+ }
+ if !ok {
+ return nil, ErrUserNotFound
+ }
+ return nil, err
+ }
+ profiles, err := s.Repo.ListProfilesForUser(ctx, userID)
if err != nil {
return nil, err
}
- if !ok {
- return nil, ErrUserNotFound
- }
- users, err := s.Repo.ListUsers(ctx, userID.String(), 1, 0)
- if err != nil || len(users) == 0 {
- return nil, ErrUserNotFound
- }
- profiles, err := s.Repo.ListProfilesForUser(ctx, userID)
+ reports, err := s.Repo.ListReportsForUser(ctx, userID, 20)
if err != nil {
return nil, err
}
@@ -157,12 +173,16 @@ func (s *Service) GetUser(ctx context.Context, userID uuid.UUID) (*UserDetail, e
return nil, err
}
return &UserDetail{
- ID: users[0].ID,
- Status: users[0].Status,
- CreatedAt: users[0].CreatedAt,
- Profiles: profiles,
- Membership: mem,
- Orders: orders,
+ ID: userID,
+ Status: status,
+ Phone: phone,
+ Nickname: nickname,
+ AskPaidQuotaLeft: paidLeft,
+ CreatedAt: createdAt,
+ Profiles: profiles,
+ Reports: reports,
+ Membership: mem,
+ Orders: orders,
}, nil
}
@@ -188,6 +208,29 @@ func (s *Service) GrantMembership(ctx context.Context, adminID, userID uuid.UUID
return s.Repo.GrantMembershipWithAudit(ctx, adminID, userID, plan, days, meta)
}
+// GrantAskQuotaInput adds paid ask replies.
+type GrantAskQuotaInput struct {
+ Delta int `json:"delta"`
+}
+
+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 delta <= 0 || delta > 1000 {
+ return 0, ErrInvalidAskDelta
+ }
+ ok, err := s.Repo.UserExists(ctx, userID)
+ if err != nil {
+ return 0, err
+ }
+ if !ok {
+ return 0, ErrUserNotFound
+ }
+ meta, _ := json.Marshal(map[string]any{"delta": delta})
+ return s.Repo.GrantAskQuotaWithAudit(ctx, adminID, userID, delta, meta)
+}
+
// ListOrders lists commerce orders.
func (s *Service) ListOrders(ctx context.Context, limit, offset int) ([]repository.OrderListItem, error) {
return s.Repo.ListOrders(ctx, nil, limit, offset)
diff --git a/apps/api/internal/service/analytics/service.go b/apps/api/internal/service/analytics/service.go
new file mode 100644
index 0000000..e99fd0e
--- /dev/null
+++ b/apps/api/internal/service/analytics/service.go
@@ -0,0 +1,273 @@
+package analytics
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
+)
+
+var (
+ ErrTooManyItems = errors.New("too many items")
+ ErrInvalidBatch = errors.New("invalid batch")
+)
+
+const maxBatch = 100
+
+// Service handles ingest validation and admin aggregates.
+type Service struct {
+ Repo *repository.AnalyticsRepo
+}
+
+// EventIn is one client event.
+type EventIn struct {
+ Name string `json:"name"`
+ SessionID string `json:"session_id"`
+ PagePath string `json:"page_path"`
+ ClientTS any `json:"client_ts"`
+ Props map[string]interface{} `json:"props"`
+}
+
+// IngestResult summarizes a successful write.
+type IngestResult struct {
+ Accepted int `json:"accepted"`
+}
+
+var allowedNames = map[string]struct{}{
+ "session_start": {}, "session_end": {}, "page_view": {}, "page_leave": {}, "ui_click": {},
+ "home_cta_portrait": {}, "portrait_completed": {}, "deep_access_clicked": {},
+ "purchase_completed": {}, "relation_completed": {}, "synastry_completed": {},
+ "synastry_invite_created": {}, "synastry_invite_accepted": {}, "synastry_nearby_opened": {},
+ "star_wheel_viewed": {}, "companion_viewed": {}, "mood_saved": {},
+ "cards_scene_selected": {}, "cards_drawn": {}, "cards_quota_exhausted": {},
+}
+
+var allowedPropKeys = map[string]struct{}{
+ "page_path": {}, "page_title": {}, "referrer_path": {}, "dwell_ms": {},
+ "element_id": {}, "exit_page": {}, "duration_ms": {}, "cold": {}, "app_ver": {},
+ "source": {}, "kind": {}, "surface": {}, "label": {}, "plan": {}, "count": {},
+ "depth": {}, "scene": {}, "score": {}, "planet": {}, "report_id": {},
+}
+
+var funnelDefault = []string{
+ "portrait_completed", "deep_access_clicked", "purchase_completed",
+}
+
+// Ingest validates and persists a batch. Invalid batch → ErrInvalidBatch (整批 400).
+func (s *Service) Ingest(
+ ctx context.Context,
+ userID uuid.UUID,
+ deviceKey string,
+ items []EventIn,
+) (*IngestResult, error) {
+ if len(items) == 0 {
+ return nil, ErrInvalidBatch
+ }
+ if len(items) > maxBatch {
+ return nil, ErrTooManyItems
+ }
+ rows := make([]repository.AnalyticsEventRow, 0, len(items))
+ for i := range items {
+ row, end, err := normalizeItem(&items[i])
+ if err != nil {
+ return nil, ErrInvalidBatch
+ }
+ if err := s.Repo.UpsertSession(ctx, row.SessionID, deviceKey, userID, row.ClientTS); err != nil {
+ return nil, err
+ }
+ if end != nil {
+ if err := s.Repo.EndSession(ctx, row.SessionID, row.ClientTS, end.ExitPage, end.DurationMs); err != nil {
+ return nil, err
+ }
+ }
+ row.UserID = userID
+ rows = append(rows, *row)
+ }
+ if err := s.Repo.InsertEvents(ctx, rows); err != nil {
+ return nil, err
+ }
+ return &IngestResult{Accepted: len(rows)}, nil
+}
+
+type sessionEnd struct {
+ ExitPage string
+ DurationMs int
+}
+
+func normalizeItem(in *EventIn) (*repository.AnalyticsEventRow, *sessionEnd, error) {
+ name := strings.TrimSpace(in.Name)
+ sid := strings.TrimSpace(in.SessionID)
+ if name == "" || sid == "" || len(sid) > 64 {
+ return nil, nil, ErrInvalidBatch
+ }
+ if _, ok := allowedNames[name]; !ok {
+ return nil, nil, ErrInvalidBatch
+ }
+ ts, err := parseClientTS(in.ClientTS)
+ if err != nil {
+ return nil, nil, ErrInvalidBatch
+ }
+ page := strings.TrimSpace(in.PagePath)
+ if page == "" && in.Props != nil {
+ if v, ok := in.Props["page_path"].(string); ok {
+ page = strings.TrimSpace(v)
+ }
+ }
+ props, end, err := scrubProps(name, in.Props)
+ if err != nil {
+ return nil, nil, err
+ }
+ raw, err := json.Marshal(props)
+ if err != nil {
+ return nil, nil, ErrInvalidBatch
+ }
+ return &repository.AnalyticsEventRow{
+ SessionID: sid,
+ Name: name,
+ PagePath: page,
+ Props: raw,
+ ClientTS: ts,
+ }, end, nil
+}
+
+func scrubProps(name string, in map[string]interface{}) (map[string]interface{}, *sessionEnd, error) {
+ out := map[string]interface{}{}
+ var end *sessionEnd
+ if name == "session_end" {
+ end = &sessionEnd{}
+ }
+ for k, v := range in {
+ key := strings.TrimSpace(k)
+ if _, ok := allowedPropKeys[key]; !ok {
+ continue
+ }
+ if key == "dwell_ms" || key == "duration_ms" {
+ n, ok := asNonNegInt(v)
+ if !ok {
+ return nil, nil, ErrInvalidBatch
+ }
+ out[key] = n
+ if key == "duration_ms" && end != nil {
+ end.DurationMs = n
+ }
+ continue
+ }
+ if key == "exit_page" {
+ s, _ := v.(string)
+ s = strings.TrimSpace(s)
+ out[key] = s
+ if end != nil {
+ end.ExitPage = s
+ }
+ continue
+ }
+ switch t := v.(type) {
+ case string:
+ if len(t) > 256 {
+ t = t[:256]
+ }
+ out[key] = t
+ case float64:
+ out[key] = t
+ case bool:
+ out[key] = t
+ case int:
+ out[key] = t
+ }
+ }
+ return out, end, nil
+}
+
+func asNonNegInt(v interface{}) (int, bool) {
+ switch t := v.(type) {
+ case float64:
+ if t < 0 || t > 1e9 {
+ return 0, false
+ }
+ return int(t), true
+ case int:
+ if t < 0 {
+ return 0, false
+ }
+ return t, true
+ case string:
+ n, err := strconv.Atoi(t)
+ if err != nil || n < 0 {
+ return 0, false
+ }
+ return n, true
+ default:
+ return 0, false
+ }
+}
+
+func parseClientTS(v any) (time.Time, error) {
+ switch t := v.(type) {
+ case string:
+ ts, err := time.Parse(time.RFC3339Nano, t)
+ if err != nil {
+ ts, err = time.Parse(time.RFC3339, t)
+ }
+ if err != nil {
+ return time.Time{}, err
+ }
+ return ts.UTC(), nil
+ case float64:
+ if t > 1e12 {
+ return time.UnixMilli(int64(t)).UTC(), nil
+ }
+ return time.Unix(int64(t), 0).UTC(), nil
+ case nil:
+ return time.Now().UTC(), nil
+ default:
+ return time.Time{}, ErrInvalidBatch
+ }
+}
+
+// ParseDayRange parses from/to (YYYY-MM-DD inclusive from, exclusive to+1day).
+func ParseDayRange(fromStr, toStr string) (time.Time, time.Time, error) {
+ if fromStr == "" || toStr == "" {
+ to := time.Now().UTC().Truncate(24 * time.Hour).Add(24 * time.Hour)
+ from := to.Add(-7 * 24 * time.Hour)
+ return from, to, nil
+ }
+ from, err := time.ParseInLocation("2006-01-02", fromStr, time.UTC)
+ if err != nil {
+ return time.Time{}, time.Time{}, err
+ }
+ toDay, err := time.ParseInLocation("2006-01-02", toStr, time.UTC)
+ if err != nil {
+ return time.Time{}, time.Time{}, err
+ }
+ to := toDay.Add(24 * time.Hour)
+ if !to.After(from) || to.Sub(from) > 93*24*time.Hour {
+ return time.Time{}, time.Time{}, ErrInvalidBatch
+ }
+ return from, to, nil
+}
+
+func (s *Service) Overview(ctx context.Context, from, to time.Time) (*repository.OverviewAgg, error) {
+ return s.Repo.Overview(ctx, from, to)
+}
+
+func (s *Service) Pages(ctx context.Context, from, to time.Time) ([]repository.PageAgg, error) {
+ return s.Repo.Pages(ctx, from, to)
+}
+
+func (s *Service) Exits(ctx context.Context, from, to time.Time) ([]repository.ExitAgg, error) {
+ return s.Repo.Exits(ctx, from, to)
+}
+
+func (s *Service) Clicks(ctx context.Context, from, to time.Time) ([]repository.ClickAgg, error) {
+ return s.Repo.Clicks(ctx, from, to)
+}
+
+func (s *Service) Funnel(ctx context.Context, from, to time.Time) ([]repository.FunnelStep, error) {
+ return s.Repo.Funnel(ctx, from, to, funnelDefault)
+}
diff --git a/apps/api/internal/service/analytics/service_test.go b/apps/api/internal/service/analytics/service_test.go
new file mode 100644
index 0000000..3a6b86f
--- /dev/null
+++ b/apps/api/internal/service/analytics/service_test.go
@@ -0,0 +1,45 @@
+package analytics
+
+import "testing"
+
+func TestScrubDwellNonNeg(t *testing.T) {
+ _, end, err := scrubProps("page_leave", map[string]interface{}{
+ "dwell_ms": float64(1200),
+ "phone": "13800000000",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if end != nil {
+ t.Fatal("page_leave should not produce session end")
+ }
+ _, end, err = scrubProps("session_end", map[string]interface{}{
+ "exit_page": "/ask",
+ "duration_ms": float64(5000),
+ "birthday": "1990-01-01",
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if end == nil || end.ExitPage != "/ask" || end.DurationMs != 5000 {
+ t.Fatalf("bad end: %+v", end)
+ }
+ _, _, err = scrubProps("page_leave", map[string]interface{}{"dwell_ms": float64(-1)})
+ if err != ErrInvalidBatch {
+ t.Fatalf("want ErrInvalidBatch for negative dwell, got %v", err)
+ }
+}
+
+func TestParseDayRange(t *testing.T) {
+ from, to, err := ParseDayRange("2026-08-01", "2026-08-07")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if to.Sub(from).Hours() != 7*24 {
+ t.Fatalf("range=%v", to.Sub(from))
+ }
+ _, _, err = ParseDayRange("2026-08-07", "2026-08-01")
+ if err != ErrInvalidBatch {
+ t.Fatalf("want invalid, got %v", err)
+ }
+}
diff --git a/apps/api/internal/service/ask/service.go b/apps/api/internal/service/ask/service.go
index b79cc26..5da01d5 100644
--- a/apps/api/internal/service/ask/service.go
+++ b/apps/api/internal/service/ask/service.go
@@ -2,6 +2,7 @@ package ask
import (
"context"
+ "encoding/json"
"errors"
"fmt"
"log"
@@ -47,16 +48,26 @@ func (s *Service) CreateThread(ctx context.Context, userID uuid.UUID, in CreateT
return s.Ask.CreateThread(ctx, userID, in.ProfileID, scene)
}
+// ClearThread soft-deletes an owned thread and its messages.
+func (s *Service) ClearThread(ctx context.Context, userID, threadID uuid.UUID) error {
+ return s.Ask.SoftDeleteThread(ctx, userID, threadID)
+}
+
// QuotaStatus describes remaining ask allowance.
type QuotaStatus struct {
ActiveMembership bool `json:"active_membership"`
Remaining int `json:"remaining"`
FreeLimit int `json:"free_limit"`
- Source string `json:"source"` // membership | free
+ PaidLeft int `json:"paid_left"`
+ Source string `json:"source"` // membership | free | paid | mixed
}
// GetQuota returns remaining ask replies.
func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus, error) {
+ paid, err := s.Ask.GetAskPaidQuota(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
vip, err := s.Reports.HasActiveMembership(ctx, userID)
if err != nil {
return nil, err
@@ -66,26 +77,46 @@ func (s *Service) GetQuota(ctx context.Context, userID uuid.UUID) (*QuotaStatus,
if err != nil {
return nil, err
}
+ src := "membership"
+ if paid > 0 && me.AskQuotaLeft > 0 {
+ src = "mixed"
+ } else if me.AskQuotaLeft <= 0 && paid > 0 {
+ src = "paid"
+ }
return &QuotaStatus{
ActiveMembership: true,
- Remaining: me.AskQuotaLeft,
+ Remaining: me.AskQuotaLeft + paid,
FreeLimit: FreeQuota,
- Source: "membership",
+ PaidLeft: paid,
+ Source: src,
}, nil
}
used, err := s.Ask.CountUserAssistantMessages(ctx, userID)
if err != nil {
return nil, err
}
+ // Free tier only counts assistant replies that were not covered by paid packs.
+ // Approximate: free used = min(used, FreeQuota) when no paid history is tracked separately.
+ // Paid replies decrement ask_paid_quota_left; free replies increase assistant count.
+ // Remaining free = max(0, FreeQuota - max(0, used - lifetimePaidConsumed)).
+ // Without lifetime paid consumed counter, treat free as: FreeQuota - used, floored at 0,
+ // and add current paid left (purchased top-ups work after free exhausted).
left := FreeQuota - used
if left < 0 {
left = 0
}
+ src := "free"
+ if left == 0 && paid > 0 {
+ src = "paid"
+ } else if left > 0 && paid > 0 {
+ src = "mixed"
+ }
return &QuotaStatus{
ActiveMembership: false,
- Remaining: left,
+ Remaining: left + paid,
FreeLimit: FreeQuota,
- Source: "free",
+ PaidLeft: paid,
+ Source: src,
}, nil
}
@@ -131,6 +162,11 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
return nil, ErrQuotaExhausted
}
+ bucket, err := s.pickQuotaBucket(ctx, userID, quota)
+ if err != nil {
+ return nil, err
+ }
+
userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content)
if err != nil {
return nil, err
@@ -142,19 +178,15 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
}
hist, _ := s.Ask.ListMessages(ctx, threadID)
- reply := s.generateReply(ctx, profile, scene, content, hist)
+ reply := s.generateReply(ctx, userID, profile, scene, content, hist)
asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply)
if err != nil {
return nil, err
}
- if quota.ActiveMembership {
- if ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID); err != nil {
- return nil, err
- } else if !ok {
- // race
- }
+ if err := s.consumeQuotaBucket(ctx, userID, bucket); err != nil {
+ return nil, err
}
q2, err := s.GetQuota(ctx, userID)
@@ -164,7 +196,171 @@ func (s *Service) SendMessage(ctx context.Context, userID, threadID uuid.UUID, c
return &SendResult{UserMessage: userMsg, AssistantMessage: asst, Quota: q2}, nil
}
-func (s *Service) generateReply(ctx context.Context, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string {
+type quotaBucket string
+
+const (
+ bucketFree quotaBucket = "free"
+ bucketMembership quotaBucket = "membership"
+ bucketPaid quotaBucket = "paid"
+)
+
+func (s *Service) pickQuotaBucket(ctx context.Context, userID uuid.UUID, quota *QuotaStatus) (quotaBucket, error) {
+ if quota.ActiveMembership {
+ me, err := s.Reports.GetMembership(ctx, userID)
+ if err != nil {
+ return "", err
+ }
+ if me.AskQuotaLeft > 0 {
+ return bucketMembership, nil
+ }
+ if quota.PaidLeft > 0 {
+ return bucketPaid, nil
+ }
+ return "", ErrQuotaExhausted
+ }
+ used, err := s.Ask.CountUserAssistantMessages(ctx, userID)
+ if err != nil {
+ return "", err
+ }
+ if FreeQuota-used > 0 {
+ return bucketFree, nil
+ }
+ if quota.PaidLeft > 0 {
+ return bucketPaid, nil
+ }
+ return "", ErrQuotaExhausted
+}
+
+func (s *Service) consumeQuotaBucket(ctx context.Context, userID uuid.UUID, bucket quotaBucket) error {
+ switch bucket {
+ case bucketFree:
+ return nil // counted by assistant message total
+ case bucketMembership:
+ ok, _, err := s.Ask.ConsumeMembershipQuota(ctx, userID)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ // race: fall through to paid if possible
+ ok2, _, err2 := s.Ask.ConsumeAskPaidQuota(ctx, userID)
+ if err2 != nil {
+ return err2
+ }
+ if !ok2 {
+ return ErrQuotaExhausted
+ }
+ }
+ return nil
+ case bucketPaid:
+ ok, _, err := s.Ask.ConsumeAskPaidQuota(ctx, userID)
+ if err != nil {
+ return err
+ }
+ if !ok {
+ return ErrQuotaExhausted
+ }
+ return nil
+ default:
+ return ErrQuotaExhausted
+ }
+}
+
+func (s *Service) generateReply(ctx context.Context, userID uuid.UUID, profile *model.Profile, scene, userContent string, hist []model.AskMessage) string {
+ out, err := s.generateReplyStream(ctx, userID, profile, scene, userContent, hist, nil)
+ if err != nil {
+ log.Printf("ask: generateReply: %v", err)
+ }
+ return out
+}
+
+// StreamEmit writes one SSE-style event to the client.
+type StreamEmit func(event string, payload any) error
+
+// StreamMessage is like SendMessage but streams assistant deltas via emit.
+func (s *Service) StreamMessage(ctx context.Context, userID, threadID uuid.UUID, content string, emit StreamEmit) error {
+ if emit == nil {
+ return errors.New("emit required")
+ }
+ content = strings.TrimSpace(content)
+ if content == "" {
+ return errors.New("content required")
+ }
+ if len([]rune(content)) > 2000 {
+ return errors.New("content too long")
+ }
+
+ thread, err := s.Ask.GetThreadForUser(ctx, userID, threadID)
+ if err != nil {
+ return errors.New("thread not found")
+ }
+ profile, err := s.Profiles.GetForUser(ctx, userID, thread.ProfileID)
+ if err != nil {
+ return errors.New("profile not found")
+ }
+
+ quota, err := s.GetQuota(ctx, userID)
+ if err != nil {
+ return err
+ }
+ if quota.Remaining <= 0 {
+ return ErrQuotaExhausted
+ }
+
+ bucket, err := s.pickQuotaBucket(ctx, userID, quota)
+ if err != nil {
+ return err
+ }
+
+ userMsg, err := s.Ask.InsertMessage(ctx, threadID, "user", content)
+ if err != nil {
+ return err
+ }
+ if err := emit("meta", map[string]any{"user_message": userMsg}); err != nil {
+ return err
+ }
+
+ scene := ""
+ if thread.Scene != nil {
+ scene = *thread.Scene
+ }
+ hist, _ := s.Ask.ListMessages(ctx, threadID)
+
+ reply, err := s.generateReplyStream(ctx, userID, profile, scene, content, hist, func(delta string) error {
+ return emit("delta", map[string]any{"text": delta})
+ })
+ if err != nil {
+ return err
+ }
+ if strings.TrimSpace(reply) == "" {
+ reply = "暂时没能生成回复,请稍后再试。"
+ _ = emit("delta", map[string]any{"text": reply})
+ }
+
+ asst, err := s.Ask.InsertMessage(ctx, threadID, "assistant", reply)
+ if err != nil {
+ return err
+ }
+ if err := s.consumeQuotaBucket(ctx, userID, bucket); err != nil {
+ return err
+ }
+ q2, err := s.GetQuota(ctx, userID)
+ if err != nil {
+ return err
+ }
+ return emit("done", map[string]any{
+ "assistant_message": asst,
+ "quota": q2,
+ })
+}
+
+func (s *Service) generateReplyStream(
+ ctx context.Context,
+ userID uuid.UUID,
+ profile *model.Profile,
+ scene, userContent string,
+ hist []model.AskMessage,
+ onDelta func(string) error,
+) (string, error) {
fallback := eng.BuildReply(eng.ReplyInput{
DisplayName: profile.DisplayName,
BirthDate: profile.BirthDate,
@@ -172,12 +368,18 @@ func (s *Service) generateReply(ctx context.Context, profile *model.Profile, sce
Scene: scene,
UserMessage: userContent,
})
- if s.LLM == nil || !s.LLM.Enabled() {
- return fallback
+ emitFallback := func(text string) (string, error) {
+ if onDelta == nil {
+ return text, nil
+ }
+ return text, streamFake(ctx, text, onDelta)
}
- msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene)}}
- // history excluding the just-inserted user message duplicate handling: include prior + current user
+ if s.LLM == nil || !s.LLM.Enabled() {
+ return emitFallback(fallback)
+ }
+
+ msgs := []deepseek.Message{{Role: "system", Content: systemPrompt(profile, scene, s.profileContext(ctx, userID, profile))}}
start := 0
if len(hist) > historyLimit*2 {
start = len(hist) - historyLimit*2
@@ -189,20 +391,88 @@ func (s *Service) generateReply(ctx context.Context, profile *model.Profile, sce
}
msgs = append(msgs, deepseek.Message{Role: role, Content: m.Content})
}
- // hist already contains the new user message from InsertMessage
- out, err := s.LLM.Chat(ctx, msgs)
+ var assembled strings.Builder
+ out, err := s.LLM.ChatStream(ctx, msgs, func(delta string) error {
+ assembled.WriteString(delta)
+ if onDelta != nil {
+ return onDelta(delta)
+ }
+ return nil
+ })
if err != nil {
- log.Printf("ask: deepseek failed, fallback to rules: %v", err)
- return fallback
+ if assembled.Len() > 0 {
+ return assembled.String(), nil
+ }
+ log.Printf("ask: deepseek stream failed, fallback to rules: %v", err)
+ return emitFallback(fallback)
}
- if !strings.Contains(out, "不构成") && !strings.Contains(out, "参考") {
- out = out + "\n\n以上是自我探索与生活方式参考,不构成医疗或占卜预测。"
+ if out == "" {
+ out = assembled.String()
}
- return out
+ if onDelta != nil && assembled.Len() == 0 && out != "" {
+ _ = streamFake(ctx, out, onDelta)
+ }
+ return out, nil
}
-func systemPrompt(profile *model.Profile, scene string) string {
+// streamFake chunks text for rule-engine replies so UI still feels streamed.
+func streamFake(ctx context.Context, text string, onDelta func(string) error) error {
+ runes := []rune(text)
+ const chunk = 2
+ for i := 0; i < len(runes); i += chunk {
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ default:
+ }
+ end := i + chunk
+ if end > len(runes) {
+ end = len(runes)
+ }
+ if err := onDelta(string(runes[i:end])); err != nil {
+ return err
+ }
+ time.Sleep(18 * time.Millisecond)
+ }
+ return nil
+}
+
+func (s *Service) profileContext(ctx context.Context, userID uuid.UUID, profile *model.Profile) string {
+ if s.Reports == nil || profile == nil {
+ return ""
+ }
+ var parts []string
+ for _, typ := range []string{"portrait", "star", "rhythm"} {
+ rep, err := s.Reports.GetLatest(ctx, userID, profile.ID, typ, nil)
+ if err != nil || rep == nil || len(rep.Summary) == 0 {
+ continue
+ }
+ var sum map[string]any
+ if json.Unmarshal(rep.Summary, &sum) != nil {
+ continue
+ }
+ label := map[string]string{"portrait": "愈心解码", "star": "星座探索", "rhythm": "身心节律"}[typ]
+ line := strings.TrimSpace(fmt.Sprintf("%s|%v|%v",
+ label, sum["headline"], sum["one_liner"]))
+ if kw, ok := sum["keywords"].([]any); ok && len(kw) > 0 {
+ var ks []string
+ for i, k := range kw {
+ if i >= 5 {
+ break
+ }
+ ks = append(ks, fmt.Sprint(k))
+ }
+ if len(ks) > 0 {
+ line += "|关键词:" + strings.Join(ks, "、")
+ }
+ }
+ parts = append(parts, line)
+ }
+ return strings.Join(parts, "\n")
+}
+
+func systemPrompt(profile *model.Profile, scene, reportCtx string) string {
name := profile.DisplayName
if name == "" {
if profile.Relation == "other" {
@@ -212,28 +482,37 @@ func systemPrompt(profile *model.Profile, scene string) string {
}
}
birth := profile.BirthDate.Format("2006-01-02")
- rel := "我的档案"
+ who := "用户本人的个人档案"
if profile.Relation == "other" {
- rel = "TA 的档案"
+ who = "用户添加的关系对象(TA)档案"
}
- sc := scene
+ sc := strings.TrimSpace(scene)
if sc == "" {
- sc = "自我探索"
+ sc = "综合成长对话"
+ }
+ ctxBlock := "(暂无已生成的解码/星座/节律摘要;请主要依据生日与对话内容温和探索,不要假装已经测过完整报告。)"
+ if strings.TrimSpace(reportCtx) != "" {
+ ctxBlock = reportCtx
}
- return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手,了解用户的智能伙伴。
-定位:帮助认识自己、理解关系、整理情绪与生活节奏——像一位细致的成长顾问,而不是算命师。
-禁止:算命、运势、吉凶、预测未来、改命、合盘/合婚话术、医疗诊断或疗效承诺、恐吓话术。
-推荐用语:了解、探索、分析、建议、成长方向、生活建议、沟通方式、情绪调节。
-当前解读对象:%s(%s),生日 %s,场景倾向:%s。
-请用中文做「有结构的详细回复」(约 280–450 字),建议结构:
-1)先回应用户当下问题(2–3 句)
-2)结合档案风格做一层分析(性格/沟通/关系/情绪/生活节奏中相关的 1–2 维)
-3)给出 2–4 条可执行小建议(尽量具体到本周可做)
-4)如合适,给一句可直接说出口的对话示例
-语气温暖、具体、不空洞;避免鸡汤套话与玄学预测。
-结尾提醒:内容为自我探索与生活方式参考,不构成医疗或占卜预测。
-今天是 %s。`, name, rel, birth, sc, time.Now().Format("2006-01-02"))
+ return fmt.Sprintf(`你是「愈心谷」的 AI 成长助手。
+
+【定位】
+把愈心解码、星座、人格匹配、身心节律等探索结果,转成短而可执行的陪伴。你不是占卜师/医生;禁止吉凶断语、改命恐吓、医疗诊断。
+
+【当前对象】
+称呼:%s|档案:%s|生日:%s|场景:%s
+摘要(有则引用,无则勿编):
+%s
+
+【回复(务必短)】
+1. 先用 1 句接住情绪/问题。
+2. 只挑与问题最相关的 1 个档案洞察,讲清即可。
+3. 给 1–2 条本周就能做的小行动(可附一句可说出口的话)。
+4. 用中文;全文控制在 80–160 字;不要分大段、不要列表堆砌、不要长篇铺垫。
+5. 不要在文末重复免责声明(界面已展示)。
+
+今天是 %s。`, name, who, birth, sc, ctxBlock, time.Now().Format("2006-01-02"))
}
// ErrQuotaExhausted when free or membership ask quota is 0.
diff --git a/apps/api/internal/service/auth/service.go b/apps/api/internal/service/auth/service.go
new file mode 100644
index 0000000..39ad04c
--- /dev/null
+++ b/apps/api/internal/service/auth/service.go
@@ -0,0 +1,149 @@
+package auth
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/hex"
+ "errors"
+ "strings"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+ "golang.org/x/crypto/bcrypt"
+
+ "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
+)
+
+// Service handles register/login sessions.
+// Temporary open mode: any non-empty phone+password can enter; missing accounts are created.
+type Service struct {
+ Repo *repository.AuthRepo
+}
+
+// Me is the public account payload.
+type Me struct {
+ ID string `json:"id"`
+ Phone string `json:"phone"`
+ Nickname string `json:"nickname"`
+}
+
+// SessionResult is returned after register/login.
+type SessionResult struct {
+ Token string `json:"token"`
+ ExpiresAt time.Time `json:"expires_at"`
+ User Me `json:"user"`
+}
+
+// Register upgrades or opens an account (same open rules as Login).
+func (s *Service) Register(ctx context.Context, userID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) {
+ return s.OpenLogin(ctx, userID, deviceKey, phone, password, nickname)
+}
+
+// Login authenticates in open mode (no password check; auto-create).
+func (s *Service) Login(ctx context.Context, userID uuid.UUID, deviceKey, phone, password string) (*SessionResult, error) {
+ return s.OpenLogin(ctx, userID, deviceKey, phone, password, "")
+}
+
+// OpenLogin: any phone+password accepted; persist account; issue session.
+func (s *Service) OpenLogin(ctx context.Context, deviceUserID uuid.UUID, deviceKey, phone, password, nickname string) (*SessionResult, error) {
+ phone = strings.TrimSpace(phone)
+ if phone == "" {
+ return nil, errors.New("请填写手机号")
+ }
+ nickname = strings.TrimSpace(nickname)
+ hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+ if err != nil {
+ return nil, err
+ }
+ hashStr := string(hash)
+
+ acc, err := s.Repo.GetByPhone(ctx, phone)
+ if err == nil {
+ _ = s.Repo.TouchPassword(ctx, acc.ID, hashStr)
+ if deviceKey != "" {
+ _ = s.Repo.BindDevice(ctx, deviceKey, acc.ID)
+ }
+ nick := acc.Nickname
+ if nickname != "" {
+ nick = nickname
+ }
+ return s.issue(ctx, acc.ID, acc.Phone, nick)
+ }
+ if !errors.Is(err, pgx.ErrNoRows) {
+ return nil, err
+ }
+
+ // New phone: prefer upgrading anonymous device user.
+ cur, curErr := s.Repo.GetAccount(ctx, deviceUserID)
+ uid := deviceUserID
+ if curErr == nil && cur.Phone == "" {
+ if err := s.Repo.RegisterOnUser(ctx, deviceUserID, phone, hashStr, nickname); err != nil {
+ return nil, errors.New("登录失败,请重试")
+ }
+ } else {
+ uid, err = s.Repo.CreateUserWithPhone(ctx, phone, hashStr, nickname)
+ if err != nil {
+ return nil, errors.New("登录失败,请重试")
+ }
+ }
+ if deviceKey != "" {
+ _ = s.Repo.BindDevice(ctx, deviceKey, uid)
+ }
+ 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
+ }
+ return s.Repo.RevokeSession(ctx, token)
+}
+
+// Me returns account if registered.
+func (s *Service) Me(ctx context.Context, userID uuid.UUID) (*Me, error) {
+ acc, err := s.Repo.GetAccount(ctx, userID)
+ if err != nil {
+ return nil, err
+ }
+ if acc.Phone == "" {
+ return nil, errors.New("未登录")
+ }
+ return &Me{ID: acc.ID.String(), Phone: maskPhone(acc.Phone), Nickname: acc.Nickname}, nil
+}
+
+// ResolveSessionUser returns user id for a live token.
+func (s *Service) ResolveSessionUser(ctx context.Context, token string) (uuid.UUID, error) {
+ return s.Repo.UserIDByToken(ctx, token)
+}
+
+// IsRegistered checks phone present.
+func (s *Service) IsRegistered(ctx context.Context, userID uuid.UUID) (bool, error) {
+ return s.Repo.IsRegistered(ctx, userID)
+}
+
+func (s *Service) issue(ctx context.Context, userID uuid.UUID, phone, nickname string) (*SessionResult, error) {
+ tok := "usr_" + randomHex(24)
+ exp := time.Now().Add(30 * 24 * time.Hour)
+ if err := s.Repo.CreateSession(ctx, userID, tok, exp); err != nil {
+ return nil, err
+ }
+ return &SessionResult{
+ Token: tok, ExpiresAt: exp,
+ User: Me{ID: userID.String(), Phone: maskPhone(phone), Nickname: nickname},
+ }, nil
+}
+
+func maskPhone(p string) string {
+ if len(p) != 11 {
+ return p
+ }
+ return p[:3] + "****" + p[7:]
+}
+
+func randomHex(n int) string {
+ b := make([]byte, n)
+ _, _ = rand.Read(b)
+ return hex.EncodeToString(b)
+}
diff --git a/apps/api/internal/service/bootstrap/service.go b/apps/api/internal/service/bootstrap/service.go
new file mode 100644
index 0000000..02d345b
--- /dev/null
+++ b/apps/api/internal/service/bootstrap/service.go
@@ -0,0 +1,131 @@
+package bootstrap
+
+import (
+ "context"
+ "encoding/json"
+ "log"
+ "time"
+
+ "github.com/google/uuid"
+
+ "github.com/yuxingu/digital-psychology/apps/api/internal/model"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/portrait"
+ releng "github.com/yuxingu/digital-psychology/apps/api/internal/relation"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/rhythm"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/star"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/star/synastry"
+)
+
+// Service generates birthday-derived report bundles for a profile.
+type Service struct {
+ Profiles *repository.ProfileRepo
+ Reports *repository.ReportRepo
+ Relations *repository.RelationRepo
+}
+
+// GenerateForProfile rebuilds solo reports for p and pair reports with counterparts.
+func (s *Service) GenerateForProfile(ctx context.Context, userID uuid.UUID, p *model.Profile) {
+ if p == nil {
+ return
+ }
+ s.genSolo(ctx, userID, p)
+ list, err := s.Profiles.ListByUser(ctx, userID)
+ if err != nil {
+ log.Printf("bootstrap list profiles: %v", err)
+ return
+ }
+ var self *model.Profile
+ others := make([]*model.Profile, 0)
+ for i := range list {
+ item := list[i]
+ if item.Relation == "self" {
+ cp := item
+ self = &cp
+ } else if item.Relation == "other" {
+ cp := item
+ others = append(others, &cp)
+ }
+ }
+ if self == nil {
+ return
+ }
+ if p.Relation == "self" {
+ for _, o := range others {
+ s.genPair(ctx, userID, self, o)
+ }
+ return
+ }
+ s.genPair(ctx, userID, self, p)
+}
+
+func (s *Service) genSolo(ctx context.Context, userID uuid.UUID, p *model.Profile) {
+ outP := portrait.Build(p.BirthDate, p.DisplayName)
+ sum, _ := json.Marshal(outP.Summary)
+ det, _ := json.Marshal(outP.Detail)
+ if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "portrait", sum, det); err != nil {
+ log.Printf("bootstrap portrait: %v", err)
+ }
+
+ outS, err := star.BuildWith(star.BuildOpts{
+ Birth: p.BirthDate, BirthTime: p.BirthTime, BirthPlace: p.BirthPlace, Name: p.DisplayName,
+ })
+ if err != nil {
+ log.Printf("bootstrap star: %v", err)
+ } else {
+ sum, _ = json.Marshal(outS.Summary)
+ det, _ = json.Marshal(outS.Detail)
+ if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "star", sum, det); err != nil {
+ log.Printf("bootstrap star save: %v", err)
+ }
+ }
+
+ outR := rhythm.Build(p.BirthDate, p.DisplayName)
+ sum, _ = json.Marshal(outR.Summary)
+ det, _ = json.Marshal(outR.Detail)
+ if _, err := s.Reports.UpsertWithPeer(ctx, userID, p.ID, nil, "rhythm", sum, det); err != nil {
+ log.Printf("bootstrap rhythm: %v", err)
+ }
+}
+
+func (s *Service) genPair(ctx context.Context, userID uuid.UUID, self, other *model.Profile) {
+ if self == nil || other == nil || self.ID == other.ID {
+ return
+ }
+ peer := other.ID
+ relType := ""
+ if other.RelationType != nil {
+ relType = *other.RelationType
+ }
+ rel := releng.BuildFull(self.BirthDate, other.BirthDate, self.BirthTime, other.BirthTime, self.BirthPlace, other.BirthPlace, self.DisplayName, other.DisplayName, relType)
+ sum, _ := json.Marshal(rel.Summary)
+ det, _ := json.Marshal(rel.Detail)
+ rep, err := s.Reports.UpsertWithPeer(ctx, userID, self.ID, &peer, "relation", sum, det)
+ if err != nil {
+ log.Printf("bootstrap relation: %v", err)
+ } else if s.Relations != nil {
+ _, _ = s.Relations.Create(ctx, userID, self.ID, other.ID, sum, rep.ID)
+ }
+
+ ca, err := star.NatalChart(self.BirthDate, self.BirthTime, self.BirthPlace)
+ if err != nil {
+ log.Printf("bootstrap synastry natal a: %v", err)
+ return
+ }
+ cb, err := star.NatalChart(other.BirthDate, other.BirthTime, other.BirthPlace)
+ if err != nil {
+ log.Printf("bootstrap synastry natal b: %v", err)
+ return
+ }
+ when := time.Now().In(time.FixedZone("CST", 8*3600))
+ out, err := synastry.BuildReport(ca, cb, self.DisplayName, other.DisplayName, when)
+ if err != nil {
+ log.Printf("bootstrap synastry: %v", err)
+ return
+ }
+ sum, _ = json.Marshal(out.Summary)
+ det, _ = json.Marshal(out.Detail)
+ if _, err := s.Reports.UpsertWithPeer(ctx, userID, self.ID, &peer, "synastry", sum, det); err != nil {
+ log.Printf("bootstrap synastry save: %v", err)
+ }
+}
diff --git a/apps/api/internal/service/home/service.go b/apps/api/internal/service/home/service.go
new file mode 100644
index 0000000..39a2b25
--- /dev/null
+++ b/apps/api/internal/service/home/service.go
@@ -0,0 +1,133 @@
+package home
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "regexp"
+ "strings"
+ "unicode/utf8"
+
+ "github.com/google/uuid"
+
+ "github.com/yuxingu/digital-psychology/apps/api/internal/repository"
+)
+
+var (
+ ErrInvalidTools = errors.New("invalid home tools")
+ ErrTooManyTools = errors.New("too many home tools")
+)
+
+var pathRe = regexp.MustCompile(`^/[a-zA-Z0-9_./-]{1,120}$`)
+
+var allowedIcons = map[string]struct{}{
+ "mbti": {}, "star": {}, "portrait": {}, "rhythm": {}, "synastry": {}, "astro": {},
+ "companion": {}, "ask": {}, "cards": {}, "reports": {}, "growth": {}, "relation": {},
+}
+
+// Service serves homepage tool catalog.
+type Service struct {
+ Repo *repository.HomeToolsRepo
+}
+
+// ListPublic returns enabled tools.
+func (s *Service) ListPublic(ctx context.Context) ([]repository.HomeTool, error) {
+ return s.Repo.ListEnabled(ctx)
+}
+
+// ListAdmin returns all tools.
+func (s *Service) ListAdmin(ctx context.Context) ([]repository.HomeTool, error) {
+ return s.Repo.ListAll(ctx)
+}
+
+// ReplaceInput is one tool in a PUT body (id optional).
+type ReplaceInput struct {
+ ID string `json:"id"`
+ RowIndex int `json:"row_index"`
+ SortOrder int `json:"sort_order"`
+ Path string `json:"path"`
+ Icon string `json:"icon"`
+ Label string `json:"label"`
+ Badge *string `json:"badge"`
+ BadgeTone *string `json:"badge_tone"`
+ Enabled bool `json:"enabled"`
+}
+
+// Replace validates and replaces all tools.
+func (s *Service) Replace(ctx context.Context, items []ReplaceInput) error {
+ return s.ReplaceWithAudit(ctx, uuid.Nil, items, nil)
+}
+
+// ReplaceWithAudit validates, replaces, and audits in one transaction when adminID set.
+func (s *Service) ReplaceWithAudit(
+ ctx context.Context,
+ adminID uuid.UUID,
+ items []ReplaceInput,
+ meta json.RawMessage,
+) error {
+ if len(items) == 0 {
+ return ErrInvalidTools
+ }
+ if len(items) > 24 {
+ return ErrTooManyTools
+ }
+ rows := make([]repository.HomeTool, 0, len(items))
+ for _, in := range items {
+ t, err := normalizeTool(in)
+ if err != nil {
+ return err
+ }
+ rows = append(rows, *t)
+ }
+ return s.Repo.ReplaceAllWithAudit(ctx, rows, adminID, meta)
+}
+
+func normalizeTool(in ReplaceInput) (*repository.HomeTool, error) {
+ if in.RowIndex != 1 && in.RowIndex != 2 {
+ return nil, ErrInvalidTools
+ }
+ path := strings.TrimSpace(in.Path)
+ if !pathRe.MatchString(path) || strings.Contains(path, "..") {
+ return nil, ErrInvalidTools
+ }
+ icon := strings.TrimSpace(in.Icon)
+ if _, ok := allowedIcons[icon]; !ok {
+ return nil, ErrInvalidTools
+ }
+ label := strings.TrimSpace(in.Label)
+ n := utf8.RuneCountInString(label)
+ if n < 1 || n > 16 {
+ return nil, ErrInvalidTools
+ }
+ var badge, tone *string
+ if in.Badge != nil {
+ b := strings.TrimSpace(*in.Badge)
+ if b != "" {
+ if utf8.RuneCountInString(b) > 4 {
+ return nil, ErrInvalidTools
+ }
+ badge = &b
+ }
+ }
+ if in.BadgeTone != nil {
+ t := strings.TrimSpace(*in.BadgeTone)
+ if t != "" && t != "hot" && t != "new" {
+ return nil, ErrInvalidTools
+ }
+ if t != "" {
+ tone = &t
+ }
+ }
+ id := uuid.Nil
+ if strings.TrimSpace(in.ID) != "" {
+ parsed, err := uuid.Parse(in.ID)
+ if err != nil {
+ return nil, ErrInvalidTools
+ }
+ id = parsed
+ }
+ return &repository.HomeTool{
+ ID: id, RowIndex: in.RowIndex, SortOrder: in.SortOrder,
+ Path: path, Icon: icon, Label: label, Badge: badge, BadgeTone: tone, Enabled: in.Enabled,
+ }, nil
+}
diff --git a/apps/api/internal/service/home/service_test.go b/apps/api/internal/service/home/service_test.go
new file mode 100644
index 0000000..0067e46
--- /dev/null
+++ b/apps/api/internal/service/home/service_test.go
@@ -0,0 +1,30 @@
+package home
+
+import "testing"
+
+func TestNormalizeToolOK(t *testing.T) {
+ b := "热"
+ tone := "hot"
+ got, err := normalizeTool(ReplaceInput{
+ RowIndex: 1, SortOrder: 1, Path: "/portrait", Icon: "portrait", Label: "愈心解码",
+ Badge: &b, BadgeTone: &tone, Enabled: true,
+ })
+ if err != nil || got.Label != "愈心解码" {
+ t.Fatalf("got=%+v err=%v", got, err)
+ }
+}
+
+func TestNormalizeToolRejects(t *testing.T) {
+ cases := []ReplaceInput{
+ {RowIndex: 3, Path: "/a", Icon: "ask", Label: "x"},
+ {RowIndex: 1, Path: "https://evil.com", Icon: "ask", Label: "x"},
+ {RowIndex: 1, Path: "/../etc", Icon: "ask", Label: "x"},
+ {RowIndex: 1, Path: "/ask", Icon: "nope", Label: "x"},
+ {RowIndex: 1, Path: "/ask", Icon: "ask", Label: ""},
+ }
+ for i, c := range cases {
+ if _, err := normalizeTool(c); err != ErrInvalidTools {
+ t.Fatalf("case %d want ErrInvalidTools got %v", i, err)
+ }
+ }
+}
diff --git a/apps/api/internal/service/membership/service.go b/apps/api/internal/service/membership/service.go
index bda8b84..befc34e 100644
--- a/apps/api/internal/service/membership/service.go
+++ b/apps/api/internal/service/membership/service.go
@@ -23,19 +23,29 @@ type CreateOrderInput struct {
ReportID *uuid.UUID
}
-// CreateOrder starts membership or deep_access order.
+// CreateOrder starts membership, deep_access, or ask_pack order.
func (s *Service) CreateOrder(ctx context.Context, userID uuid.UUID, in CreateOrderInput) (uuid.UUID, error) {
- if in.Kind != "membership" && in.Kind != "deep_access" {
+ if in.Kind != "membership" && in.Kind != "deep_access" && in.Kind != "ask_pack" {
return uuid.Nil, errors.New("invalid kind")
}
if in.Kind == "deep_access" && in.ReportID == nil {
return uuid.Nil, errors.New("report_id required")
}
amount := 990
+ plan := in.Plan
if in.Kind == "membership" {
amount = 2500
}
- return s.Reports.CreateOrder(ctx, userID, in.Kind, in.Plan, in.ReportID, amount)
+ if in.Kind == "ask_pack" {
+ if plan == "" {
+ plan = "pack10"
+ }
+ amount = repository.AskPackAmountCents(plan)
+ if amount <= 0 || repository.AskPackQuota(plan) <= 0 {
+ return uuid.Nil, errors.New("invalid ask_pack plan")
+ }
+ }
+ return s.Reports.CreateOrder(ctx, userID, in.Kind, plan, in.ReportID, amount)
}
// PayMock completes mock payment.
diff --git a/apps/api/internal/service/profile/service.go b/apps/api/internal/service/profile/service.go
index 70dc835..a5d24c4 100644
--- a/apps/api/internal/service/profile/service.go
+++ b/apps/api/internal/service/profile/service.go
@@ -10,11 +10,14 @@ import (
"github.com/yuxingu/digital-psychology/apps/api/internal/model"
"github.com/yuxingu/digital-psychology/apps/api/internal/repository"
+ "github.com/yuxingu/digital-psychology/apps/api/internal/service/bootstrap"
)
// Service manages personal archives.
type Service struct {
- Repo *repository.ProfileRepo
+ Repo *repository.ProfileRepo
+ Reports *repository.ReportRepo
+ Bootstrap *bootstrap.Service
}
// CreateInput is validated create payload.
@@ -43,7 +46,14 @@ func (s *Service) Create(ctx context.Context, userID uuid.UUID, in CreateInput)
name = "TA"
}
}
- return s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
+ p, err := s.Repo.Create(ctx, userID, in.Relation, name, in.BirthDate, in.RelationType, in.BirthTime, in.BirthPlace)
+ if err != nil {
+ return nil, err
+ }
+ if s.Bootstrap != nil {
+ s.Bootstrap.GenerateForProfile(ctx, userID, p)
+ }
+ return p, nil
}
// List returns user's profiles.
@@ -89,7 +99,31 @@ func (s *Service) Update(ctx context.Context, userID, profileID uuid.UUID, in Up
if bp == nil {
bp = cur.BirthPlace
}
- return s.Repo.UpdateForUser(ctx, userID, profileID, name, birth, rt, bt, bp, in.GeoLat, in.GeoLng, in.GeoVisible)
+ birthChanged := !in.BirthDate.IsZero() && !sameDay(in.BirthDate, cur.BirthDate)
+ timeChanged := in.BirthTime != nil && strPtr(in.BirthTime) != strPtr(cur.BirthTime)
+ placeChanged := in.BirthPlace != nil && strPtr(in.BirthPlace) != strPtr(cur.BirthPlace)
+
+ p, err := s.Repo.UpdateForUser(ctx, userID, profileID, name, birth, rt, bt, bp, in.GeoLat, in.GeoLng, in.GeoVisible)
+ if err != nil {
+ return nil, err
+ }
+ if s.Bootstrap != nil && (birthChanged || timeChanged || placeChanged) {
+ s.Bootstrap.GenerateForProfile(ctx, userID, p)
+ }
+ return p, nil
+}
+
+func sameDay(a, b time.Time) bool {
+ ay, am, ad := a.Date()
+ by, bm, bd := b.Date()
+ return ay == by && am == bm && ad == bd
+}
+
+func strPtr(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
}
// Delete soft-deletes an owned profile.
@@ -97,5 +131,8 @@ func (s *Service) Delete(ctx context.Context, userID, profileID uuid.UUID) error
if _, err := s.Repo.GetForUser(ctx, userID, profileID); err != nil {
return errors.New("profile not found")
}
+ if s.Reports != nil {
+ _ = s.Reports.SoftDeleteForProfile(ctx, userID, profileID)
+ }
return s.Repo.SoftDeleteForUser(ctx, userID, profileID)
}
diff --git a/apps/api/internal/service/relation/service.go b/apps/api/internal/service/relation/service.go
index efd0acd..71340ea 100644
--- a/apps/api/internal/service/relation/service.go
+++ b/apps/api/internal/service/relation/service.go
@@ -45,7 +45,8 @@ func (s *Service) Create(ctx context.Context, userID, aID, bID uuid.UUID) (*Crea
out := releng.BuildFull(pa.BirthDate, pb.BirthDate, pa.BirthTime, pb.BirthTime, pa.BirthPlace, pb.BirthPlace, pa.DisplayName, pb.DisplayName, relType)
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
- rep, err := s.Reports.Create(ctx, userID, aID, "relation", sum, det)
+ peer := bID
+ rep, err := s.Reports.UpsertWithPeer(ctx, userID, aID, &peer, "relation", sum, det)
if err != nil {
return nil, err
}
diff --git a/apps/api/internal/service/report/service.go b/apps/api/internal/service/report/service.go
index b6c8682..eefb145 100644
--- a/apps/api/internal/service/report/service.go
+++ b/apps/api/internal/service/report/service.go
@@ -132,7 +132,7 @@ func (s *Service) CreatePortrait(ctx context.Context, userID, profileID uuid.UUI
out := portrait.Build(p.BirthDate, p.DisplayName)
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
- rep, err := s.Reports.Create(ctx, userID, profileID, "portrait", sum, det)
+ rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "portrait", sum, det)
if err != nil {
return nil, err
}
@@ -153,7 +153,7 @@ func (s *Service) CreateStar(ctx context.Context, userID, profileID uuid.UUID) (
}
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
- rep, err := s.Reports.Create(ctx, userID, profileID, "star", sum, det)
+ rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "star", sum, det)
if err != nil {
return nil, err
}
@@ -193,7 +193,8 @@ func (s *Service) CreateSynastry(ctx context.Context, userID, profileAID, profil
}
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
- rep, err := s.Reports.Create(ctx, userID, profileAID, "synastry", sum, det)
+ peer := profileBID
+ rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileAID, &peer, "synastry", sum, det)
if err != nil {
return nil, err
}
@@ -209,13 +210,22 @@ func (s *Service) CreateRhythm(ctx context.Context, userID, profileID uuid.UUID)
out := rhythm.Build(p.BirthDate, p.DisplayName)
sum, _ := json.Marshal(out.Summary)
det, _ := json.Marshal(out.Detail)
- rep, err := s.Reports.Create(ctx, userID, profileID, "rhythm", sum, det)
+ rep, err := s.Reports.UpsertWithPeer(ctx, userID, profileID, nil, "rhythm", sum, det)
if err != nil {
return nil, err
}
return s.applyEntitlement(ctx, userID, rep)
}
+// GetLatest returns cached report by profile+type(+peer).
+func (s *Service) GetLatest(ctx context.Context, userID, profileID uuid.UUID, typ string, peer *uuid.UUID) (*model.GrowthReport, error) {
+ rep, err := s.Reports.GetLatest(ctx, userID, profileID, typ, peer)
+ if err != nil {
+ return nil, errors.New("report not found")
+ }
+ return s.applyEntitlement(ctx, userID, rep)
+}
+
// Get returns a report with detail gated.
func (s *Service) Get(ctx context.Context, userID, reportID uuid.UUID) (*model.GrowthReport, error) {
rep, err := s.Reports.GetForUser(ctx, userID, reportID)
diff --git a/apps/api/migrations/000011_account_bootstrap.down.sql b/apps/api/migrations/000011_account_bootstrap.down.sql
new file mode 100644
index 0000000..e79a564
--- /dev/null
+++ b/apps/api/migrations/000011_account_bootstrap.down.sql
@@ -0,0 +1,10 @@
+DROP INDEX IF EXISTS idx_growth_reports_latest_pair;
+DROP INDEX IF EXISTS idx_growth_reports_latest_solo;
+ALTER TABLE growth_reports DROP COLUMN IF EXISTS peer_profile_id;
+DROP INDEX IF EXISTS idx_user_sessions_token;
+DROP INDEX IF EXISTS idx_user_sessions_user_id;
+DROP TABLE IF EXISTS user_sessions;
+DROP INDEX IF EXISTS idx_users_phone_unique;
+ALTER TABLE users DROP COLUMN IF EXISTS nickname;
+ALTER TABLE users DROP COLUMN IF EXISTS password_hash;
+ALTER TABLE users DROP COLUMN IF EXISTS phone;
diff --git a/apps/api/migrations/000011_account_bootstrap.up.sql b/apps/api/migrations/000011_account_bootstrap.up.sql
new file mode 100644
index 0000000..a7192f5
--- /dev/null
+++ b/apps/api/migrations/000011_account_bootstrap.up.sql
@@ -0,0 +1,29 @@
+-- Account auth + report peer for birthday bootstrap bundles
+
+ALTER TABLE users ADD COLUMN IF NOT EXISTS phone varchar(20) NULL;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS password_hash text NULL;
+ALTER TABLE users ADD COLUMN IF NOT EXISTS nickname varchar(64) NULL;
+
+CREATE UNIQUE INDEX IF NOT EXISTS idx_users_phone_unique
+ ON users(phone) WHERE phone IS NOT NULL AND deleted_at IS NULL;
+
+CREATE TABLE IF NOT EXISTS user_sessions (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ user_id uuid NOT NULL REFERENCES users(id),
+ token varchar(128) NOT NULL UNIQUE,
+ expires_at timestamptz NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ revoked_at timestamptz NULL
+);
+CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id);
+CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(token) WHERE revoked_at IS NULL;
+
+ALTER TABLE growth_reports ADD COLUMN IF NOT EXISTS peer_profile_id uuid NULL REFERENCES profiles(id);
+
+CREATE INDEX IF NOT EXISTS idx_growth_reports_latest_solo
+ ON growth_reports(user_id, profile_id, type, created_at DESC)
+ WHERE deleted_at IS NULL AND peer_profile_id IS NULL;
+
+CREATE INDEX IF NOT EXISTS idx_growth_reports_latest_pair
+ ON growth_reports(user_id, profile_id, type, peer_profile_id, created_at DESC)
+ WHERE deleted_at IS NULL AND peer_profile_id IS NOT NULL;
diff --git a/apps/api/migrations/000012_ask_paid_quota.down.sql b/apps/api/migrations/000012_ask_paid_quota.down.sql
new file mode 100644
index 0000000..185c22b
--- /dev/null
+++ b/apps/api/migrations/000012_ask_paid_quota.down.sql
@@ -0,0 +1 @@
+ALTER TABLE users DROP COLUMN IF EXISTS ask_paid_quota_left;
diff --git a/apps/api/migrations/000012_ask_paid_quota.up.sql b/apps/api/migrations/000012_ask_paid_quota.up.sql
new file mode 100644
index 0000000..15db598
--- /dev/null
+++ b/apps/api/migrations/000012_ask_paid_quota.up.sql
@@ -0,0 +1,3 @@
+-- Purchased ask quota packs (independent of membership deep-access)
+
+ALTER TABLE users ADD COLUMN IF NOT EXISTS ask_paid_quota_left int NOT NULL DEFAULT 0;
diff --git a/apps/api/migrations/000013_analytics_ops.down.sql b/apps/api/migrations/000013_analytics_ops.down.sql
new file mode 100644
index 0000000..952b460
--- /dev/null
+++ b/apps/api/migrations/000013_analytics_ops.down.sql
@@ -0,0 +1,2 @@
+DROP TABLE IF EXISTS analytics_events;
+DROP TABLE IF EXISTS analytics_sessions;
diff --git a/apps/api/migrations/000013_analytics_ops.up.sql b/apps/api/migrations/000013_analytics_ops.up.sql
new file mode 100644
index 0000000..7636792
--- /dev/null
+++ b/apps/api/migrations/000013_analytics_ops.up.sql
@@ -0,0 +1,29 @@
+-- Ops-B analytics (ECR-007)
+
+CREATE TABLE IF NOT EXISTS analytics_sessions (
+ session_id varchar(64) PRIMARY KEY,
+ device_key varchar(128) NOT NULL,
+ user_id uuid NULL REFERENCES users(id),
+ started_at timestamptz NOT NULL DEFAULT now(),
+ ended_at timestamptz NULL,
+ exit_page text NULL,
+ duration_ms int NULL
+);
+CREATE INDEX IF NOT EXISTS idx_analytics_sessions_started ON analytics_sessions(started_at DESC);
+CREATE INDEX IF NOT EXISTS idx_analytics_sessions_user ON analytics_sessions(user_id);
+CREATE INDEX IF NOT EXISTS idx_analytics_sessions_device ON analytics_sessions(device_key);
+
+CREATE TABLE IF NOT EXISTS analytics_events (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ session_id varchar(64) NOT NULL REFERENCES analytics_sessions(session_id),
+ user_id uuid NULL REFERENCES users(id),
+ name varchar(64) NOT NULL,
+ page_path text NULL,
+ props jsonb NOT NULL DEFAULT '{}',
+ client_ts timestamptz NOT NULL,
+ received_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_analytics_events_received ON analytics_events(received_at DESC);
+CREATE INDEX IF NOT EXISTS idx_analytics_events_name_day ON analytics_events(name, ((received_at AT TIME ZONE 'UTC')::date));
+CREATE INDEX IF NOT EXISTS idx_analytics_events_page ON analytics_events(page_path) WHERE page_path IS NOT NULL;
+CREATE INDEX IF NOT EXISTS idx_analytics_events_session ON analytics_events(session_id);
diff --git a/apps/api/migrations/000014_ops_content.down.sql b/apps/api/migrations/000014_ops_content.down.sql
new file mode 100644
index 0000000..cb09882
--- /dev/null
+++ b/apps/api/migrations/000014_ops_content.down.sql
@@ -0,0 +1 @@
+DROP TABLE IF EXISTS home_tools;
diff --git a/apps/api/migrations/000014_ops_content.up.sql b/apps/api/migrations/000014_ops_content.up.sql
new file mode 100644
index 0000000..a2f39b9
--- /dev/null
+++ b/apps/api/migrations/000014_ops_content.up.sql
@@ -0,0 +1,29 @@
+-- Ops-C home tools CMS (ECR-008)
+
+CREATE TABLE IF NOT EXISTS home_tools (
+ id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+ row_index smallint NOT NULL CHECK (row_index IN (1, 2)),
+ sort_order int NOT NULL DEFAULT 0,
+ path text NOT NULL,
+ icon varchar(32) NOT NULL,
+ label varchar(32) NOT NULL,
+ badge varchar(8) NULL,
+ badge_tone varchar(8) NULL,
+ enabled boolean NOT NULL DEFAULT true,
+ updated_at timestamptz NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_home_tools_row_sort ON home_tools(row_index, sort_order);
+
+INSERT INTO home_tools (row_index, sort_order, path, icon, label, badge, badge_tone, enabled) VALUES
+(1, 1, '/scales/mbti-lite', 'mbti', '人格测试', NULL, NULL, true),
+(1, 2, '/star', 'star', '星座', NULL, NULL, true),
+(1, 3, '/portrait', 'portrait', '愈心解码', '热', 'hot', true),
+(1, 4, '/rhythm', 'rhythm', '身心节律', NULL, NULL, true),
+(1, 5, '/synastry', 'synastry', '合盘', '新', 'new', true),
+(1, 6, '/star', 'astro', '星象性格', NULL, NULL, true),
+(2, 1, '/companion', 'companion', '节气陪伴', NULL, NULL, true),
+(2, 2, '/ask', 'ask', 'AI问答', NULL, NULL, true),
+(2, 3, '/cards', 'cards', '意象卡片', NULL, NULL, true),
+(2, 4, '/reports', 'reports', '成长报告', '新', 'new', true),
+(2, 5, '/growth-plan', 'growth', '成长计划', NULL, NULL, true),
+(2, 6, '/relation', 'relation', '人格匹配', NULL, NULL, true);
diff --git a/apps/user-h5/src/components/ask/AskAiPane.vue b/apps/user-h5/src/components/ask/AskAiPane.vue
index 5bcff24..1787288 100644
--- a/apps/user-h5/src/components/ask/AskAiPane.vue
+++ b/apps/user-h5/src/components/ask/AskAiPane.vue
@@ -22,8 +22,12 @@
{{ sendError }}
- 开通成长会员
+
diff --git a/apps/user-h5/src/components/ask/AskEmpty.vue b/apps/user-h5/src/components/ask/AskEmpty.vue
index 5102463..92df154 100644
--- a/apps/user-h5/src/components/ask/AskEmpty.vue
+++ b/apps/user-h5/src/components/ask/AskEmpty.vue
@@ -1,8 +1,12 @@
-
-
-
还没有个人档案。完成愈心解码后再来提问,回答会更贴合你。
+
+
+
+
+
先建立你的个人档案
+
完成愈心解码后,问答会结合你的性格与节律来回答,更贴合你。
去愈心解码
+
或手动创建档案
@@ -12,30 +16,62 @@ import HomeToolIcon from '../HomeToolIcon.vue'
diff --git a/apps/user-h5/src/components/ask/AskGreet.vue b/apps/user-h5/src/components/ask/AskGreet.vue
index 5cd694e..f18644e 100644
--- a/apps/user-h5/src/components/ask/AskGreet.vue
+++ b/apps/user-h5/src/components/ask/AskGreet.vue
@@ -1,21 +1,27 @@
-
+
+
+
Hi,我是愈心 AI
-
最近有什么事,都可以和我聊聊
+
懂你一点的成长伙伴 · 认识自己 · 理解关系 · 整理情绪
+
今天想从哪里开始