init: 毛孩子计划 小程序 + Go 后端 + 内嵌后台

- pets-fe: 微信原生小程序(首页/计划/记录/报告/社区/引导),
  服务端驱动、无假数据;弹层改用 scroll-view,打开时隐藏自定义 tabBar
- pets-be: Gin + GORM(MySQL, sundynix_ 前缀) + MinIO,统一响应/分页,
  微信 code2session 登录,provider-neutral AI(DeepSeek),go:embed React 后台
- 修复:分段选择类型不匹配(字符串 vs 数字)导致选不中

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-03 15:33:31 +08:00
commit 609f7d06cf
180 changed files with 15259 additions and 0 deletions
+141
View File
@@ -0,0 +1,141 @@
package service
import (
"encoding/json"
"fmt"
"strings"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/model"
)
// 养宠助手安全护栏:不诊断、只做观察与就医前信息整理、始终给就医提示
const aiSafetyPrompt = `你是「毛孩子计划」的养宠助手,服务新手猫狗主人。要求:
1. 只提供日常照护建议、观察要点和就医前信息整理,绝不做医学诊断或开具体药物剂量。
2. 任何异常都要说明"何时需要尽快就医"(如持续超过24小时、便血、频繁呕吐、精神明显变差等)。
3. 基于用户提供的宠物档案和记录作答,不要泛泛而谈。
4. 语气亲切、简洁,用中文,避免长篇大论。`
// petBrief 组装宠物档案 + 近期记录的上下文文本
func (s *Service) petBrief(petID uint) string {
var pet model.Pet
if err := s.db.First(&pet, petID).Error; err != nil {
return ""
}
var records []model.HealthRecord
s.db.Where("pet_id = ?", petID).Order("occurred_at desc").Limit(8).Find(&records)
var b strings.Builder
fmt.Fprintf(&b, "宠物档案:%s%s%s%s,当前体重%s)。\n",
pet.Name, pet.Type, pet.Gender, pet.Stage, pet.Weight)
if len(records) > 0 {
b.WriteString("近期记录:")
items := make([]string, 0, len(records))
for _, r := range records {
items = append(items, r.Title)
}
b.WriteString(strings.Join(items, ""))
b.WriteString("。")
}
return b.String()
}
// llmChat 真实模型聊天回复
func (s *Service) llmChat(petID *uint, text string) (string, error) {
system := aiSafetyPrompt
if petID != nil {
if brief := s.petBrief(*petID); brief != "" {
system += "\n\n" + brief
}
}
return s.ai.Complete(system, []ai.Message{{Role: "user", Content: text}}, ai.Options{Temperature: -1})
}
// SymptomInput 异常观察入参
type SymptomInput struct {
Symptoms []string
Duration string
Spirit string
}
// SymptomResult 风险评估结果(对应前端 risk 弹层)
type SymptomResult struct {
RiskLevel string `json:"risk_level"` // 低/中/高
Causes string `json:"causes"`
Suggestion string `json:"suggestion"`
SeekCare string `json:"seek_care"`
}
// AssessSymptom 异常风险评估:启用模型则结构化输出,否则规则化
func (s *Service) AssessSymptom(userID, petID uint, in SymptomInput) (*SymptomResult, error) {
if _, err := s.ownedPet(userID, petID); err != nil {
return nil, err
}
// 规则化兜底
fallback := &SymptomResult{
RiskLevel: "中",
Causes: "可能与换粮、应激或消化不适有关。这不是诊断结论。",
Suggestion: "继续观察精神、食欲和排便;暂停新食物;记录呕吐或腹泻次数。",
SeekCare: "如果持续超过 24 小时,或伴随便血、精神明显变差、频繁呕吐,建议尽快就医。",
}
if s.ai == nil || !s.ai.Enabled() {
return fallback, nil
}
system := aiSafetyPrompt + "\n\n" + s.petBrief(petID) +
`\n请根据以下异常信息评估,严格返回 JSON:{"risk_level":"低|中|高","causes":"可能原因(非诊断)","suggestion":"观察与照护建议","seek_care":"何时需要就医"}`
user := fmt.Sprintf("异常表现:%s;持续时间:%s;精神状态:%s。",
strings.Join(in.Symptoms, "、"), in.Duration, in.Spirit)
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return fallback, nil // 模型故障不阻断,回退
}
var res SymptomResult
if err := json.Unmarshal([]byte(extractJSON(out)), &res); err != nil || res.RiskLevel == "" {
return fallback, nil
}
return &res, nil
}
// aiPlanExtract 用模型提取计划信息 + 生成任务;失败返回 ok=false 交由规则化处理
type aiPlanExtracted struct {
Stage string `json:"stage"`
Risk string `json:"risk"`
Priority string `json:"priority"`
Reminder string `json:"reminder"`
Tasks []struct {
DayLabel string `json:"day_label"`
Title string `json:"title"`
Description string `json:"description"`
} `json:"tasks"`
}
func (s *Service) aiPlanExtract(pet *model.Pet, input string) (*aiPlanExtracted, bool) {
if s.ai == nil || !s.ai.Enabled() {
return nil, false
}
system := aiSafetyPrompt + "\n\n" + s.petBrief(pet.ID) +
`\n请把用户描述提炼为可执行的养宠计划,严格返回 JSON:` +
`{"stage":"阶段","risk":"近期风险","priority":"观察重点","reminder":"重点提醒",` +
`"tasks":[{"day_label":"如 Day 1-2","title":"任务标题","description":"要点"}]}tasks 3-5 条。`
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: input}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return nil, false
}
var ex aiPlanExtracted
if err := json.Unmarshal([]byte(extractJSON(out)), &ex); err != nil || len(ex.Tasks) == 0 {
return nil, false
}
return &ex, true
}
// extractJSON 容错:从可能含前后缀的文本中截取第一个 { 到最后一个 }
func extractJSON(s string) string {
i := strings.IndexByte(s, '{')
j := strings.LastIndexByte(s, '}')
if i >= 0 && j > i {
return s[i : j+1]
}
return s
}