2f5dd6eefc
后端
- 主键改雪花字符串 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>
248 lines
8.0 KiB
Go
248 lines
8.0 KiB
Go
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)
|
||
}
|
||
}()
|
||
}
|