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) } }() }