Files
backend/internal/service/user_profile_audit_service.go
lan c630cbf4d0
All checks were successful
Build Backend / build (push) Successful in 2m0s
Build Backend / build-docker (push) Successful in 1m26s
feat(ws): implement WebSocket cluster mode with Redis Pub/Sub
Introduce a new WebSocket messaging architecture that supports both standalone and cluster modes. This allows for horizontal scaling of WebSocket servers by using Redis Pub/Sub to synchronize messages across multiple instances.

Key changes:
- Added `ws.MessagePublisher` interface to abstract message distribution.
- Implemented `ws.Bus` to handle cluster-mode messaging via Redis.
- Added `ws.OnlineTracker` to manage user online status across the cluster.
- Refactored multiple services (Chat, Group, Push, Call, etc.) to use the new `MessagePublisher` instead of a concrete `ws.Hub`.
- Added WebSocket configuration options (mode, instance ID, channel, TTL, heartbeat) to `config.yaml` and `config.go`.
- Updated dependency injection with Wire to support the new publisher and Redis client.
- Improved logging by replacing standard `log` with `zap` in several service components.
2026-05-06 12:39:11 +08:00

404 lines
12 KiB
Go

package service
import (
"context"
"time"
"with_you/internal/model"
"with_you/internal/pkg/hook"
"with_you/internal/repository"
"go.uber.org/zap"
)
type UserProfileAuditService interface {
SubmitAvatarAudit(ctx context.Context, userID, avatarURL string) (*model.UserProfileAudit, error)
SubmitCoverAudit(ctx context.Context, userID, coverURL string) (*model.UserProfileAudit, error)
SubmitBioAudit(ctx context.Context, userID, bio string) (*model.UserProfileAudit, error)
GetPendingAudit(ctx context.Context, userID string, contentType model.UserProfileContentType) (*model.UserProfileAudit, error)
GetAuditStatus(ctx context.Context, userID string) (*ProfileAuditStatusResponse, error)
ModerateAudit(ctx context.Context, auditID string, status model.UserProfileAuditStatus, reason, reviewedBy string) error
BatchModerateAudits(ctx context.Context, ids []string, status model.UserProfileAuditStatus, reason, reviewedBy string) error
GetPendingList(ctx context.Context, contentType model.UserProfileContentType, page, pageSize int) ([]*model.UserProfileAudit, int64, error)
GetPendingCounts(ctx context.Context) (int64, int64, int64, error)
}
type ProfileAuditStatusResponse struct {
AvatarStatus string `json:"avatar_status"`
CoverStatus string `json:"cover_status"`
BioStatus string `json:"bio_status"`
}
type userProfileAuditServiceImpl struct {
auditRepo repository.UserProfileAuditRepository
userRepo repository.UserRepository
hookManager *hook.Manager
moderationHooks *hook.ModerationHooks
}
func NewUserProfileAuditService(
auditRepo repository.UserProfileAuditRepository,
userRepo repository.UserRepository,
hookManager *hook.Manager,
moderationHooks *hook.ModerationHooks,
) UserProfileAuditService {
return &userProfileAuditServiceImpl{
auditRepo: auditRepo,
userRepo: userRepo,
hookManager: hookManager,
moderationHooks: moderationHooks,
}
}
func (s *userProfileAuditServiceImpl) SubmitAvatarAudit(ctx context.Context, userID, avatarURL string) (*model.UserProfileAudit, error) {
audit := &model.UserProfileAudit{
UserID: userID,
ContentType: model.UserProfileContentTypeAvatar,
Content: avatarURL,
Status: model.UserProfileAuditStatusPending,
}
if err := s.auditRepo.Create(audit); err != nil {
return nil, err
}
go s.reviewAvatarAsync(audit.ID, userID, avatarURL)
return audit, nil
}
func (s *userProfileAuditServiceImpl) SubmitCoverAudit(ctx context.Context, userID, coverURL string) (*model.UserProfileAudit, error) {
audit := &model.UserProfileAudit{
UserID: userID,
ContentType: model.UserProfileContentTypeCover,
Content: coverURL,
Status: model.UserProfileAuditStatusPending,
}
if err := s.auditRepo.Create(audit); err != nil {
return nil, err
}
go s.reviewCoverAsync(audit.ID, userID, coverURL)
return audit, nil
}
func (s *userProfileAuditServiceImpl) SubmitBioAudit(ctx context.Context, userID, bio string) (*model.UserProfileAudit, error) {
audit := &model.UserProfileAudit{
UserID: userID,
ContentType: model.UserProfileContentTypeBio,
Content: bio,
Status: model.UserProfileAuditStatusPending,
}
if err := s.auditRepo.Create(audit); err != nil {
return nil, err
}
go s.reviewBioAsync(audit.ID, userID, bio)
return audit, nil
}
func (s *userProfileAuditServiceImpl) GetPendingAudit(ctx context.Context, userID string, contentType model.UserProfileContentType) (*model.UserProfileAudit, error) {
return s.auditRepo.GetPendingByUserID(userID, contentType)
}
func (s *userProfileAuditServiceImpl) GetAuditStatus(ctx context.Context, userID string) (*ProfileAuditStatusResponse, error) {
resp := &ProfileAuditStatusResponse{
AvatarStatus: string(model.UserProfileAuditStatusApproved),
CoverStatus: string(model.UserProfileAuditStatusApproved),
BioStatus: string(model.UserProfileAuditStatusApproved),
}
if audit, err := s.auditRepo.GetByUserID(userID, model.UserProfileContentTypeAvatar); err == nil {
resp.AvatarStatus = string(audit.Status)
}
if audit, err := s.auditRepo.GetByUserID(userID, model.UserProfileContentTypeCover); err == nil {
resp.CoverStatus = string(audit.Status)
}
if audit, err := s.auditRepo.GetByUserID(userID, model.UserProfileContentTypeBio); err == nil {
resp.BioStatus = string(audit.Status)
}
return resp, nil
}
func (s *userProfileAuditServiceImpl) ModerateAudit(ctx context.Context, auditID string, status model.UserProfileAuditStatus, reason, reviewedBy string) error {
audit, err := s.auditRepo.GetByID(auditID)
if err != nil {
return err
}
now := time.Now()
audit.Status = status
audit.ReviewedAt = &now
audit.ReviewedBy = reviewedBy
audit.RejectReason = reason
if err := s.auditRepo.Update(audit); err != nil {
return err
}
if status == model.UserProfileAuditStatusApproved {
if err := s.applyAuditContent(audit); err != nil {
zap.L().Error("Failed to apply approved audit content",
zap.String("audit_id", audit.ID),
zap.String("user_id", audit.UserID),
zap.Error(err),
)
}
}
return nil
}
func (s *userProfileAuditServiceImpl) BatchModerateAudits(ctx context.Context, ids []string, status model.UserProfileAuditStatus, reason string, reviewedBy string) error {
for _, id := range ids {
if err := s.ModerateAudit(ctx, id, status, reason, reviewedBy); err != nil {
zap.L().Warn("Failed to moderate audit in batch",
zap.String("audit_id", id),
zap.Error(err),
)
}
}
return nil
}
func (s *userProfileAuditServiceImpl) GetPendingList(ctx context.Context, contentType model.UserProfileContentType, page, pageSize int) ([]*model.UserProfileAudit, int64, error) {
return s.auditRepo.GetPendingList(contentType, page, pageSize)
}
func (s *userProfileAuditServiceImpl) GetPendingCounts(ctx context.Context) (int64, int64, int64, error) {
return s.auditRepo.CountPendingAll()
}
func (s *userProfileAuditServiceImpl) reviewAvatarAsync(auditID, userID, avatarURL string) {
defer func() {
if r := recover(); r != nil {
zap.L().Error("Panic in avatar moderation async flow",
zap.String("audit_id", auditID),
zap.Any("panic", r),
)
}
}()
result := &hook.ModerationResult{}
if s.moderationHooks != nil && s.hookManager != nil {
result = s.moderationHooks.ModerateAvatar(context.Background(), s.hookManager, &hook.UserAvatarModerateHookData{
UserID: userID,
AvatarURL: avatarURL,
})
} else {
result.Approved = true
result.ReviewedBy = "system"
}
audit, err := s.auditRepo.GetByID(auditID)
if err != nil {
zap.L().Error("Failed to get audit for avatar moderation result",
zap.String("audit_id", auditID),
zap.Error(err),
)
return
}
now := time.Now()
if !result.Approved {
if result.NeedsReview {
audit.ReviewedBy = "ai"
zap.L().Info("User avatar flagged for manual review",
zap.String("audit_id", auditID),
zap.String("user_id", userID),
)
} else {
audit.Status = model.UserProfileAuditStatusRejected
audit.RejectReason = result.RejectReason
audit.ReviewedBy = result.ReviewedBy
audit.ReviewedAt = &now
zap.L().Info("User avatar rejected by moderation",
zap.String("audit_id", auditID),
zap.String("user_id", userID),
zap.String("reason", result.RejectReason),
)
}
} else {
audit.Status = model.UserProfileAuditStatusApproved
audit.ReviewedBy = result.ReviewedBy
audit.ReviewedAt = &now
if err := s.applyAuditContent(audit); err != nil {
zap.L().Error("Failed to apply approved avatar",
zap.String("audit_id", auditID),
zap.Error(err),
)
}
}
if err := s.auditRepo.Update(audit); err != nil {
zap.L().Error("Failed to update avatar audit status",
zap.String("audit_id", auditID),
zap.Error(err),
)
}
}
func (s *userProfileAuditServiceImpl) reviewCoverAsync(auditID, userID, coverURL string) {
defer func() {
if r := recover(); r != nil {
zap.L().Error("Panic in cover moderation async flow",
zap.String("audit_id", auditID),
zap.Any("panic", r),
)
}
}()
result := &hook.ModerationResult{}
if s.moderationHooks != nil && s.hookManager != nil {
result = s.moderationHooks.ModerateCover(context.Background(), s.hookManager, &hook.UserCoverModerateHookData{
UserID: userID,
CoverURL: coverURL,
})
} else {
result.Approved = true
result.ReviewedBy = "system"
}
audit, err := s.auditRepo.GetByID(auditID)
if err != nil {
zap.L().Error("Failed to get audit for cover moderation result",
zap.String("audit_id", auditID),
zap.Error(err),
)
return
}
now := time.Now()
if !result.Approved {
if result.NeedsReview {
audit.ReviewedBy = "ai"
zap.L().Info("User cover flagged for manual review",
zap.String("audit_id", auditID),
zap.String("user_id", userID),
)
} else {
audit.Status = model.UserProfileAuditStatusRejected
audit.RejectReason = result.RejectReason
audit.ReviewedBy = result.ReviewedBy
audit.ReviewedAt = &now
zap.L().Info("User cover rejected by moderation",
zap.String("audit_id", auditID),
zap.String("user_id", userID),
zap.String("reason", result.RejectReason),
)
}
} else {
audit.Status = model.UserProfileAuditStatusApproved
audit.ReviewedBy = result.ReviewedBy
audit.ReviewedAt = &now
if err := s.applyAuditContent(audit); err != nil {
zap.L().Error("Failed to apply approved cover",
zap.String("audit_id", auditID),
zap.Error(err),
)
}
}
if err := s.auditRepo.Update(audit); err != nil {
zap.L().Error("Failed to update cover audit status",
zap.String("audit_id", auditID),
zap.Error(err),
)
}
}
func (s *userProfileAuditServiceImpl) reviewBioAsync(auditID, userID, bio string) {
defer func() {
if r := recover(); r != nil {
zap.L().Error("Panic in bio moderation async flow",
zap.String("audit_id", auditID),
zap.Any("panic", r),
)
}
}()
result := &hook.ModerationResult{}
if s.moderationHooks != nil && s.hookManager != nil {
result = s.moderationHooks.ModerateBio(context.Background(), s.hookManager, &hook.UserBioModerateHookData{
UserID: userID,
Bio: bio,
})
} else {
result.Approved = true
result.ReviewedBy = "system"
}
audit, err := s.auditRepo.GetByID(auditID)
if err != nil {
zap.L().Error("Failed to get audit for bio moderation result",
zap.String("audit_id", auditID),
zap.Error(err),
)
return
}
now := time.Now()
if !result.Approved {
if result.NeedsReview {
audit.ReviewedBy = "ai"
zap.L().Info("User bio flagged for manual review",
zap.String("audit_id", auditID),
zap.String("user_id", userID),
)
} else {
audit.Status = model.UserProfileAuditStatusRejected
audit.RejectReason = result.RejectReason
audit.ReviewedBy = result.ReviewedBy
audit.ReviewedAt = &now
zap.L().Info("User bio rejected by moderation",
zap.String("audit_id", auditID),
zap.String("user_id", userID),
zap.String("reason", result.RejectReason),
)
}
} else {
audit.Status = model.UserProfileAuditStatusApproved
audit.ReviewedBy = result.ReviewedBy
audit.ReviewedAt = &now
if err := s.applyAuditContent(audit); err != nil {
zap.L().Error("Failed to apply approved bio",
zap.String("audit_id", auditID),
zap.Error(err),
)
}
}
if err := s.auditRepo.Update(audit); err != nil {
zap.L().Error("Failed to update bio audit status",
zap.String("audit_id", auditID),
zap.Error(err),
)
}
}
func (s *userProfileAuditServiceImpl) applyAuditContent(audit *model.UserProfileAudit) error {
user, err := s.userRepo.GetByID(audit.UserID)
if err != nil {
return err
}
switch audit.ContentType {
case model.UserProfileContentTypeAvatar:
user.Avatar = audit.Content
case model.UserProfileContentTypeCover:
user.CoverURL = audit.Content
case model.UserProfileContentTypeBio:
user.Bio = audit.Content
default:
return nil
}
return s.userRepo.Update(user)
}