fix: 发现流补上已有机器人的短帖

发现列表之前没有机器人帖。打开广场发现时,给已有机器人各写一条原创短帖。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
DESKTOP-L8OUL9F\gfl0034 2026-10-18 22:40:51 +08:00
parent cfa30e270b
commit d604cffe84
5 changed files with 162 additions and 9 deletions

View File

@ -55,7 +55,7 @@ func (s *Service) PublishBotMedia(ctx context.Context, userID int64, in *BotPubl
}
now := time.Now()
post := &entity.Post{
ID: id, UserID: userID, Kind: content.Kind(), Text: text, Content: raw,
ID: id, UserID: userID, Channel: entity.ChannelSquare, Kind: content.Kind(), Text: text, Content: raw,
Visibility: entity.VisibilityPublic, AuditStatus: entity.AuditApproved,
Status: entity.StatusNormal, Source: entity.SourceBotRSS, SourceURL: url,
ExpiresAtMS: now.Add(ttl).UnixMilli(), CreatedAtMS: now.UnixMilli(),

View File

@ -0,0 +1,93 @@
package logic
import (
"context"
"errors"
"fmt"
"strings"
"time"
"go.uber.org/zap"
"spark-server/internal/module/square/entity"
userentity "spark-server/internal/module/user/entity"
"spark-server/internal/pkg/errs"
)
const discoverBotURLPrefix = "https://spark.wyrs365.com/square/discover/bot/"
// ensureDiscoverBots puts one original short post from each existing robot
// onto the square discovery feed. It does not fetch or copy outside articles.
func (s *Service) ensureDiscoverBots(ctx context.Context, channel entity.Channel, category entity.Category) {
if s == nil || channel != entity.ChannelSquare || category.IsTag() || s.discoverReady.Load() {
return
}
s.discoverMu.Lock()
defer s.discoverMu.Unlock()
if s.discoverReady.Load() {
return
}
if err := s.fillDiscoverBots(ctx); err != nil {
if s.log != nil {
s.log.Warn("square discover bots", zap.Error(err))
}
return
}
s.discoverReady.Store(true)
}
func (s *Service) fillDiscoverBots(ctx context.Context) error {
var failed bool
for i := 1; i <= userentity.BotCount; i++ {
email := userentity.BotEmail(i)
user, err := s.users.GetByEmail(ctx, email)
if err != nil {
if errors.Is(err, errs.ErrNotFound) {
continue
}
failed = true
continue
}
if user == nil || user.ID <= 0 || user.Status != userentity.StatusActive {
continue
}
_, err = s.PublishBotMedia(ctx, user.ID, &BotPublishInput{
Text: discoverBotText(i, user.Nickname),
SourceURL: discoverBotURL(i),
TTL: 15 * 24 * time.Hour,
})
if err == nil || errors.Is(err, errs.ErrDuplicate) {
continue
}
failed = true
}
if failed {
return errs.ErrInner
}
return nil
}
func discoverBotURL(index int) string {
if index < 1 {
return ""
}
return fmt.Sprintf("%s%03d", discoverBotURLPrefix, index)
}
func discoverBotText(index int, nickname string) string {
name := strings.TrimSpace(nickname)
if name == "" {
name = fmt.Sprintf("bot%03d", index)
}
lines := []string{
"%s:把今天看到的光记在发现里。",
"%s:路过广场,留一句给后来的人。",
"%s:这条是给发现页的,不跟上一句重复。",
"%s:风很小,适合把一句话放在这里。",
"%s:发现页空着的时候,补一条短的。",
"%s:同一条不连发,换个人再看下一句。",
"%s:文字先到,配图要有合法的对象键才附上。",
"%s:推荐流里见,不占别的广场频道。",
}
return fmt.Sprintf(lines[(index-1)%len(lines)], name)
}

View File

@ -0,0 +1,54 @@
package logic
import (
"context"
"strings"
"testing"
"spark-server/internal/module/square/entity"
userentity "spark-server/internal/module/user/entity"
userlogic "spark-server/internal/module/user/logic"
)
func TestDiscoverFeedReturnsExistingBotPost(t *testing.T) {
svc, _, users := testSquare(t)
ctx := t.Context()
bot := &userentity.User{ID: 88001, Nickname: "小光", Status: userentity.StatusActive, SparkID: "sparkbot001"}
svc.users = &discoverUsers{inner: users, bot: bot}
viewer, err := users.AuthenticateEmail(ctx, "test01@spark.com", "1", &userentity.LoginDevice{DeviceID: 1, DeviceType: userentity.DeviceIOS})
if err != nil {
t.Fatal(err)
}
viewCtx := ctxUser(t, viewer.ID, "10.0.0.8")
out, err := svc.Feed(viewCtx, viewer.ID, entity.ChannelSquare, FeedQuery{Category: entity.CategoryRecommend, Limit: 20})
if err != nil || len(out.Items) != 1 {
t.Fatalf("items %d %v", len(out.Items), err)
}
item := out.Items[0]
if item.User.UID != bot.ID || item.Channel != entity.ChannelSquare || !strings.Contains(item.Text, "发现") {
t.Fatalf("post %+v", item)
}
again, err := svc.Feed(viewCtx, viewer.ID, entity.ChannelSquare, FeedQuery{Category: entity.CategoryRecommend, Limit: 20})
if err != nil || len(again.Items) != 1 || again.Items[0].ID != item.ID {
t.Fatalf("repeat %+v %v", again, err)
}
}
type discoverUsers struct {
inner *userlogic.Service
bot *userentity.User
}
func (d *discoverUsers) Get(ctx context.Context, id int64) (*userentity.User, error) {
if d.bot != nil && id == d.bot.ID {
return d.bot, nil
}
return d.inner.Get(ctx, id)
}
func (d *discoverUsers) GetByEmail(ctx context.Context, email string) (*userentity.User, error) {
if email == "bot001@spark.bot" {
return d.bot, nil
}
return d.inner.GetByEmail(ctx, email)
}

View File

@ -27,6 +27,7 @@ func (s *Service) Feed(ctx context.Context, viewerID int64, channel entity.Chann
if !q.Category.Valid() {
return nil, errs.ErrInvalidArgument
}
s.ensureDiscoverBots(ctx, channel, q.Category)
limit := entity.ClampLimit(q.Limit)
picked := make([]*entity.Post, 0, limit)
scan := q.Cursor

View File

@ -2,6 +2,8 @@ package logic
import (
"context"
"sync"
"sync/atomic"
friendentity "spark-server/internal/module/friend/entity"
"spark-server/internal/module/square/entity"
@ -49,6 +51,7 @@ type Friends interface {
// Users loads profile cards.
type Users interface {
Get(ctx context.Context, id int64) (*userentity.User, error)
GetByEmail(ctx context.Context, email string) (*userentity.User, error)
}
// Objects deletes square media. Nil skips GC.
@ -63,14 +66,16 @@ type IDGen interface {
// Service is the square use-case layer.
type Service struct {
repo Repository
friends Friends
users Users
objects Objects
ids IDGen
burst *burst
media media.Policy
log *zap.Logger
repo Repository
friends Friends
users Users
objects Objects
ids IDGen
burst *burst
media media.Policy
log *zap.Logger
discoverMu sync.Mutex
discoverReady atomic.Bool
}
func NewService(repo Repository, friends Friends, users Users, objects Objects, ids IDGen) *Service {