feat(ops): ECR-007 行为分析与 ECR-008 内容运营后台
ci / h5 (push) Canceled after 0s
ci / api (push) Canceled after 0s
ci / ess-docs (push) Canceled after 0s

落地埋点 ingest/数据看板、首页宫格 CMS 与测评上下架;含账号引导、问答流式与免责声明去重,以及 review P1 审计同事务修复。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
jackyu66git
2026-08-07 02:26:16 +08:00
co-authored by Cursor
parent 7e9023f0a8
commit 7ab9add5dd
132 changed files with 8276 additions and 491 deletions
+321 -42
View File
@@ -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)先回应用户当下问题(23 句)
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.