spark-server/internal/module/square/logic/publish.go
DESKTOP-L8OUL9F\gfl0034 715ace7ac6 feat(square): 广场 Bot/RSS 发布与媒体校验
支持广场机器人发帖与媒体类型检查,并补充相关迁移与 i18n。
2026-09-24 23:07:06 +08:00

150 lines
4.7 KiB
Go

package logic
import (
"context"
"strconv"
"strings"
"time"
"unicode/utf8"
"spark-server/internal/module/square/entity"
"spark-server/internal/pkg/errs"
"spark-server/internal/pkg/i18n"
"spark-server/internal/pkg/reqctx"
"spark-server/internal/pkg/validate"
)
type PublishInput struct {
Text string `json:"text" validate:"omitempty,max=1000" label:"field.square_text" errmsg:"max:validate.square.text_invalid"`
Images []*entity.Image `json:"images" validate:"omitempty,max=9,dive"`
Video *entity.Video `json:"video"`
Visibility entity.Visibility `json:"visibility" validate:"omitempty,oneof=0 1 2 3" label:"field.square_visibility" errmsg:"oneof:validate.square.visibility_invalid"`
AudienceUIDs []int64 `json:"audience_uids" validate:"omitempty,max=200,dive,gt=0" label:"field.square_audience"`
POI *entity.POI `json:"poi"`
}
func (s *Service) Publish(ctx context.Context, userID int64, in *PublishInput) (*View, error) {
if !s.ready() || userID <= 0 {
return nil, errs.ErrUnauthorized
}
if in == nil {
return nil, errs.ErrInvalidArgument
}
if err := validate.StructLang(reqctx.Lang(ctx), in); err != nil {
return nil, err
}
if !s.allowPublish(ctx, userID) {
return nil, errs.ErrRateLimited
}
content, kind, err := buildContent(ctx, in)
if err != nil {
return nil, err
}
if err := s.checkMediaMeta(ctx, in.Images, in.Video); err != nil {
return nil, err
}
raw, err := content.Marshal()
if err != nil {
return nil, errs.ErrInvalidArgument.WithMsg(i18n.T(reqctx.Lang(ctx), i18n.KeySquareContentInvalid, nil))
}
if err := s.checkAudience(ctx, userID, in); err != nil {
return nil, err
}
id, err := s.ids.NextID()
if err != nil {
return nil, err
}
now := time.Now()
vis := in.Visibility
if !vis.Valid() {
vis = entity.VisibilityPublic
}
post := &entity.Post{
ID: id, UserID: userID, Kind: kind, Text: strings.TrimSpace(in.Text), Content: raw,
Visibility: vis, AuditStatus: entity.AuditApproved, Status: entity.StatusNormal,
CreatedAtMS: now.UnixMilli(),
}
if in.POI != nil {
post.POI = *in.POI
}
if err := s.repo.CreatePost(ctx, post, uniqueIDs(in.AudienceUIDs)); err != nil {
return nil, err
}
return s.viewOf(ctx, userID, post, true)
}
func (s *Service) allowPublish(ctx context.Context, userID int64) bool {
key := strconv.FormatInt(userID, 10) + ":" + reqctx.RealIP(ctx)
return s.burst.allow("p1:"+key, time.Minute, 2) && s.burst.allow("pd:"+key, 24*time.Hour, 20)
}
func (s *Service) allowInteract(ctx context.Context, userID int64) bool {
key := strconv.FormatInt(userID, 10) + ":" + reqctx.RealIP(ctx)
return s.burst.allow("i:"+key, time.Minute, 60)
}
func buildContent(ctx context.Context, in *PublishInput) (*entity.Content, entity.Kind, error) {
lang := reqctx.Lang(ctx)
text := strings.TrimSpace(in.Text)
if utf8.RuneCountInString(text) > entity.MaxTextRunes {
return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareTextInvalid, nil))
}
if len(in.Images) > 0 && in.Video != nil {
return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareMediaConflict, nil))
}
if text == "" && len(in.Images) == 0 && in.Video == nil {
return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareEmpty, nil))
}
c := &entity.Content{Images: in.Images, Video: in.Video}
if err := c.Validate(); err != nil {
return nil, 0, errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareContentInvalid, nil))
}
return c, c.Kind(), nil
}
func (s *Service) checkAudience(ctx context.Context, userID int64, in *PublishInput) error {
lang := reqctx.Lang(ctx)
if !in.Visibility.Valid() {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareVisibilityInvalid, nil))
}
uids := uniqueIDs(in.AudienceUIDs)
if !in.Visibility.NeedsAudience() {
if len(uids) > 0 {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareAudienceInvalid, nil))
}
return nil
}
if len(uids) == 0 || len(uids) > entity.MaxAudience {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareAudienceInvalid, nil))
}
for _, peer := range uids {
if peer == userID {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareAudienceInvalid, nil))
}
ok, err := s.friends.AreFriends(ctx, userID, peer)
if err != nil {
return err
}
if !ok {
return errs.ErrInvalidArgument.WithMsg(i18n.T(lang, i18n.KeySquareAudienceInvalid, nil))
}
}
return nil
}
func uniqueIDs(ids []int64) []int64 {
seen := make(map[int64]struct{}, len(ids))
out := make([]int64, 0, len(ids))
for _, id := range ids {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
out = append(out, id)
}
return out
}