feat: 养护模板/社区运营/体重趋势/计划日历 + 雪花ID重构 + 文件表与图片上传

后端
- 主键改雪花字符串 ID(pkg/idgen + Base.BeforeCreate),全表外键/JWT/中间件随之调整
- 新增 files 表:/api/upload 按 MD5 去重,返回 {id,url,md5},服务端只收图片
- 头像/记录附图/帖子图改 file_id 关联,读取解析为 URL
- 养护模板(物种×阶段)后台可配 + AI 生成草稿;建档按模板生成任务/计划/提醒
- 社区 AI 运营:虚拟账号池 + 每日定时/手动生成,帖子带 AI 标
- 计划路线图节点带真实日期;首页周历与计划日历同源;新增 day-plan 当日安排
- 体重趋势接口带备注;记录列表分页

小程序
- 公共图片上传 utils/upload.js(仅图片);记录拍照/我的头像/社区发图三处接入
- 首页日历点选查当日任务;记录页体重趋势可点看备注 + 时间轴分页折叠
- 计划页日历点选查当日计划;自定义 tabBar 高度调整

后台(React)
- 新增「养护模板」「社区运营」页;用户管理加机器人筛选

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Blizzard
2026-07-06 08:48:15 +08:00
parent 609f7d06cf
commit 2f5dd6eefc
83 changed files with 2253 additions and 355 deletions
+247
View File
@@ -0,0 +1,247 @@
package service
import (
"encoding/json"
"fmt"
"math/rand"
"time"
"gorm.io/datatypes"
"github.com/sundynix/pets-be/internal/ai"
"github.com/sundynix/pets-be/internal/model"
)
const botTickMinutes = 15
// 话题种子,增加生成多样性
var botTopics = []string{
"新手第一次给猫换粮的经历和注意点",
"幼犬疫苗期间的日常照护",
"猫咪软便观察和调理的经验",
"遛狗时防止捡食的小技巧",
"换季掉毛严重怎么护理",
"刚到家的猫如何度过应激期",
"给狗狗刷牙、清口腔的经验",
"记录体重时发现的小变化",
"驱虫到期提醒和实操",
"多猫家庭的相处磨合",
"老年猫饮水和肾脏健康关注点",
"幼猫社会化和玩耍引导",
}
// 默认虚拟宠友账号池(昵称 + emoji 头像)
var botSeeds = []struct{ Name, Emoji string }{
{"团子的日常", "🐱"}, {"布丁妈妈", "🐶"}, {"三花小报告", "🐱"},
{"柯基屁股蛋", "🐶"}, {"橘座驾到", "🐱"}, {"奶牛猫饲养员", "🐮"},
{"金毛暖暖", "🦮"}, {"布偶少爷", "😺"}, {"英短小灰灰", "🐱"},
{"泰迪不乖", "🐩"}, {"狸花猫巡逻队", "🐈"}, {"雪纳瑞老张", "🐶"},
{"银渐层日记", "😸"}, {"哈士奇拆家现场", "🐺"}, {"暹罗小王子", "🐱"},
{"边牧上学记", "🐕"}, {"美短起司", "🧀"}, {"柴犬麻薯", "🐕"},
{"无毛猫斯芬克斯", "🐱"}, {"比熊棉花糖", "🐶"}, {"缅因大橘", "🦁"},
{"腊肠一米五", "🌭"}, {"加菲脸圆圆", "😻"}, {"萨摩耶微笑", "🐻‍❄️"},
{"蓝猫布加迪", "🐱"}, {"法斗打呼噜", "🐶"}, {"折耳软软", "🐱"},
{"阿拉斯加大脸", "🐺"},
}
// 兜底文案(AI 未开启/失败时用)
var fallbackPosts = []string{
"换粮第3天,软便基本好了,7天过渡法真的有用,分享给同样在换粮的姐妹~",
"幼猫社会化真的重要,每天陪玩20分钟,现在一点都不怕生人了🐱",
"提醒大家驱虫别忘记,我差点漏了,手机日历设个提醒最保险。",
"金毛换季掉毛太夸张了,每天梳一次+补点卵磷脂,情况好多了。",
}
// getBotConfig 取单例配置(不存在返回内置默认,不写库)
func (s *Service) getBotConfig() model.CommunityBotConfig {
var c model.CommunityBotConfig
if err := s.db.First(&c, "1").Error; err != nil {
return model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
}
return c
}
// GetCommunityBotConfig 后台读取配置
func (s *Service) GetCommunityBotConfig() model.CommunityBotConfig {
return s.getBotConfig()
}
// SaveCommunityBotConfig 后台保存配置(固定 id=1 upsert
func (s *Service) SaveCommunityBotConfig(in model.CommunityBotConfig) (model.CommunityBotConfig, error) {
if in.DailyCount < 0 {
in.DailyCount = 0
}
if in.StartHour < 0 || in.StartHour > 23 {
in.StartHour = 9
}
if in.EndHour < 1 || in.EndHour > 24 {
in.EndHour = 21
}
if in.EndHour <= in.StartHour {
in.EndHour = in.StartHour + 1
}
in.ID = "1"
if err := s.db.Save(&in).Error; err != nil {
return in, err
}
return in, nil
}
// SeedCommunityBot 启动幂等 seed:虚拟账号池(按昵称补齐新增账号)+ 配置单例
func (s *Service) SeedCommunityBot() error {
// 迁移前的旧用户 is_bot 为 NULL,回填为 false 便于筛选
if err := s.db.Model(&model.User{}).Where("is_bot IS NULL").Update("is_bot", false).Error; err != nil {
return err
}
for _, b := range botSeeds {
var n int64
if err := s.db.Model(&model.User{}).Where("nickname = ? AND is_bot = ?", b.Name, true).Count(&n).Error; err != nil {
return err
}
if n == 0 {
u := model.User{Nickname: b.Name, Avatar: b.Emoji, IsBot: true, Onboarded: true}
if err := s.db.Create(&u).Error; err != nil {
return err
}
}
}
var cfgCount int64
if err := s.db.Model(&model.CommunityBotConfig{}).Count(&cfgCount).Error; err != nil {
return err
}
if cfgCount == 0 {
c := model.CommunityBotConfig{Enabled: true, DailyCount: 3, StartHour: 9, EndHour: 21}
c.ID = "1"
if err := s.db.Create(&c).Error; err != nil {
return err
}
}
return nil
}
// countAIPostsToday 今天已生成的 AI 帖数
func (s *Service) countAIPostsToday() int64 {
now := time.Now()
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
var n int64
s.db.Model(&model.Post{}).Where("is_ai = ? AND created_at >= ?", true, start).Count(&n)
return n
}
// GenerateCommunityPosts 生成 n 条 AI 帖并入库,返回实际生成数
func (s *Service) GenerateCommunityPosts(n int) (int, error) {
if n <= 0 {
return 0, nil
}
var bots []model.User
if err := s.db.Where("is_bot = ?", true).Find(&bots).Error; err != nil {
return 0, err
}
if len(bots) == 0 {
return 0, nil
}
made := 0
for i := 0; i < n; i++ {
bot := bots[rand.Intn(len(bots))]
content, tag := s.genOnePostContent()
if content == "" {
continue
}
tagsJSON, _ := json.Marshal([]string{tag})
imagesJSON, _ := json.Marshal(pickPhotos())
p := model.Post{
UserID: bot.ID, AuthorName: bot.Nickname, AuthorEmoji: bot.Avatar,
Identity: "petName", Content: content, Tags: datatypes.JSON(tagsJSON),
Images: datatypes.JSON(imagesJSON),
LikeCount: rand.Intn(40), Status: model.PostPublished, IsAI: true,
}
if err := s.db.Create(&p).Error; err != nil {
return made, err
}
made++
}
return made, nil
}
// 配图 emoji 池(App 用 emoji 当照片渲染),当作帖子的「图」
var photoPool = []string{"🐱", "🐶", "🐈", "🐕", "🍚", "🦴", "🧶", "🪀", "🏠", "🌿", "🛁", "💊", "🐾", "🍗", "🥩", "🧸", "☀️", "🛏️"}
// pickPhotos ~65% 概率随机贴 1-3 张配图,其余纯文字(更自然)
func pickPhotos() []string {
if rand.Float64() > 0.65 {
return []string{}
}
n := 1 + rand.Intn(3)
out := make([]string, 0, n)
for i := 0; i < n; i++ {
out = append(out, photoPool[rand.Intn(len(photoPool))])
}
return out
}
// genOnePostContent 生成一条文案 + 标签;AI 不可用/失败回退内置文案
func (s *Service) genOnePostContent() (string, string) {
tags := []string{"晒宠", "求助", "经验", "避坑"}
tag := tags[rand.Intn(len(tags))]
if !s.ai.Enabled() {
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
}
topic := botTopics[rand.Intn(len(botTopics))]
system := aiSafetyPrompt + `
你在为宠物社区「宠友圈」生成一条真实、口语化的养宠动态,像普通铲屎官发的朋友圈。
要求:40-120 字;真实有细节,可带 1-2 个 emoji;涉及异常要顺带提醒何时就医;不要标题、不要话题标签、不要营销口吻。
只输出 JSON{"content":"","tag":"晒宠|求助|经验|避坑"}`
user := fmt.Sprintf("主题:%s。请生成一条。", topic)
out, err := s.ai.Complete(system, []ai.Message{{Role: "user", Content: user}}, ai.Options{JSON: true, Temperature: -1})
if err != nil {
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
}
var r struct {
Content string `json:"content"`
Tag string `json:"tag"`
}
if err := json.Unmarshal([]byte(extractJSON(out)), &r); err != nil || r.Content == "" {
return fallbackPosts[rand.Intn(len(fallbackPosts))], tag
}
if r.Tag != "" {
tag = r.Tag
}
return r.Content, tag
}
// RunCommunityBotTick 定时器每次调用:白天窗口内按剩余名额概率投放,天然打散在白天
func (s *Service) RunCommunityBotTick() {
cfg := s.getBotConfig()
if !cfg.Enabled || cfg.DailyCount <= 0 {
return
}
now := time.Now()
h := now.Hour()
if h < cfg.StartHour || h >= cfg.EndHour {
return
}
today := s.countAIPostsToday()
if today >= int64(cfg.DailyCount) {
return
}
remaining := cfg.DailyCount - int(today)
minsLeft := (cfg.EndHour-h)*60 - now.Minute()
ticksLeft := minsLeft / botTickMinutes
if ticksLeft < 1 {
ticksLeft = 1
}
if rand.Float64() <= float64(remaining)/float64(ticksLeft) {
_, _ = s.GenerateCommunityPosts(1)
}
}
// StartCommunityBot 启动后台定时投放协程
func (s *Service) StartCommunityBot() {
go func() {
for {
s.RunCommunityBotTick()
time.Sleep(botTickMinutes * time.Minute)
}
}()
}