The Go module name has been changed from `carrot_bbs` to `with_you`, which requires all import paths to be updated throughout the codebase and by external consumers. BREAKING CHANGE: Module name changed from carrot_bbs to with_you. All imports and dependencies must be updated from carrot_bbs/* to with_you/*.
396 lines
12 KiB
Go
396 lines
12 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"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 {
|
|
log.Printf("[ERROR] Panic in avatar moderation async flow, audit=%s panic=%v", auditID, 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 {
|
|
log.Printf("[ERROR] Panic in cover moderation async flow, audit=%s panic=%v", auditID, 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 {
|
|
log.Printf("[ERROR] Panic in bio moderation async flow, audit=%s panic=%v", auditID, 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)
|
|
}
|