Files
Blizzard 2f5dd6eefc 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>
2026-07-06 08:48:15 +08:00

248 lines
8.0 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}()
}