package appuser import ( "context" "fmt" "strconv" "strings" "time" "hyapp-admin-server/internal/appctx" "hyapp-admin-server/internal/integration/userclient" userv1 "hyapp.local/api/proto/user/v1" ) const maxContentBlacklistIdentifierRunes = 64 // AppUserContentBlacklistEntry 是管理端当前态读模型。 // userId 始终按字符串输出,避免 19 位 Snowflake ID 穿过 JavaScript Number 后失真。 type AppUserContentBlacklistEntry struct { Target AppUserBrief `json:"target"` UserStatus string `json:"userStatus"` OperatorAdminID string `json:"operatorAdminId"` Reason string `json:"reason"` CreatedAtMs int64 `json:"createdAtMs"` UpdatedAtMs int64 `json:"updatedAtMs"` } type AppUserContentBlacklistCandidate struct { Target AppUserBrief `json:"target"` UserStatus string `json:"userStatus"` Blacklisted bool `json:"blacklisted"` } type contentBlacklistMutationRequest struct { UserIdentifier string `json:"user_id"` Reason string `json:"reason"` } // ListUserContentBlacklist 通过 owner service 分页读取,不允许 admin 用直连 userDB 的方式复制治理事实。 func (s *Service) ListUserContentBlacklist(ctx context.Context, page int, pageSize int, requestID string) ([]AppUserContentBlacklistEntry, int64, error) { if s.contentBlacklistClient == nil { return nil, 0, fmt.Errorf("user content blacklist client is not configured") } if page < 1 { page = 1 } if pageSize <= 0 { pageSize = 50 } if pageSize > 200 { pageSize = 200 } response, err := s.contentBlacklistClient.ListUserContentBlacklist(ctx, &userv1.ListUserContentBlacklistRequest{ Meta: contentBlacklistRequestMeta(ctx, requestID), Page: int32(page), PageSize: int32(pageSize), }) if err != nil { return nil, 0, err } items := make([]AppUserContentBlacklistEntry, 0, len(response.GetItems())) for _, item := range response.GetItems() { if item == nil { continue } items = append(items, appUserContentBlacklistEntryFromProto(item)) } return items, response.GetTotal(), nil } // ResolveUserContentBlacklistCandidate 复用 user-service 的后台通用 ID 解析: // 永久默认短号、当前有效展示号、靓号和内部长 user_id 都收敛到同一个真实 user_id。 func (s *Service) ResolveUserContentBlacklistCandidate(ctx context.Context, identifier string, requestID string) (AppUserContentBlacklistCandidate, error) { identifier = strings.TrimSpace(identifier) if identifier == "" || len([]rune(identifier)) > maxContentBlacklistIdentifierRunes { return AppUserContentBlacklistCandidate{}, fmt.Errorf("%w: 用户 ID 参数不正确", ErrInvalidArgument) } if s.contentBlacklistClient == nil { return AppUserContentBlacklistCandidate{}, fmt.Errorf("user content blacklist client is not configured") } identity, err := s.contentBlacklistClient.ResolveAdminUserIdentifier(ctx, userclient.ResolveAdminUserIdentifierRequest{ RequestID: requestID, Caller: "hyapp-admin-server", UserIdentifier: identifier, }) if err != nil { return AppUserContentBlacklistCandidate{}, err } if identity == nil || identity.UserID <= 0 { return AppUserContentBlacklistCandidate{}, ErrNotFound } user, err := s.userClient.GetUser(ctx, userclient.GetUserRequest{ RequestID: requestID, Caller: "hyapp-admin-server", UserID: identity.UserID, }) if err != nil { return AppUserContentBlacklistCandidate{}, err } if user == nil { return AppUserContentBlacklistCandidate{}, ErrNotFound } return AppUserContentBlacklistCandidate{ Target: AppUserBrief{ UserID: strconv.FormatInt(user.UserID, 10), DisplayUserID: user.DisplayUserID, Username: user.Username, Avatar: user.Avatar, }, UserStatus: user.Status, Blacklisted: user.ContentBlacklisted, }, nil } // AddUserContentBlacklist 先用通用 ID 契约解析真实用户,再仅把真实 user_id 发送给治理 RPC。 // 这样前端输入的短号即使以后过期,也不会成为黑名单持久键。 func (s *Service) AddUserContentBlacklist(ctx context.Context, identifier string, operatorAdminID int64, reason string, requestID string) (AppUserContentBlacklistEntry, error) { if operatorAdminID <= 0 { return AppUserContentBlacklistEntry{}, fmt.Errorf("%w: 操作管理员参数不正确", ErrInvalidArgument) } candidate, err := s.ResolveUserContentBlacklistCandidate(ctx, identifier, requestID) if err != nil { return AppUserContentBlacklistEntry{}, err } userID, err := strconv.ParseInt(candidate.Target.UserID, 10, 64) if err != nil || userID <= 0 { return AppUserContentBlacklistEntry{}, fmt.Errorf("%w: 用户 ID 参数不正确", ErrInvalidArgument) } reason = strings.TrimSpace(reason) if reason == "" { reason = "admin_content_blacklist" } response, err := s.contentBlacklistClient.AdminAddUserContentBlacklist(ctx, &userv1.AdminAddUserContentBlacklistRequest{ Meta: contentBlacklistRequestMeta(ctx, requestID), UserId: userID, OperatorAdminId: operatorAdminID, Reason: reason, }) if err != nil { return AppUserContentBlacklistEntry{}, err } if response.GetItem() == nil { return AppUserContentBlacklistEntry{}, fmt.Errorf("user-service returned empty content blacklist entry") } return appUserContentBlacklistEntryFromProto(response.GetItem()), nil } // RemoveUserContentBlacklist 以内部真实 user_id 移除当前态;重复移除由 owner service 返回 removed=false, // handler 仍按幂等成功处理,避免管理员因网络重试得到伪失败。 func (s *Service) RemoveUserContentBlacklist(ctx context.Context, userID int64, operatorAdminID int64, reason string, requestID string) (bool, error) { if s.contentBlacklistClient == nil { return false, fmt.Errorf("user content blacklist client is not configured") } if userID <= 0 || operatorAdminID <= 0 { return false, fmt.Errorf("%w: 黑名单移除参数不正确", ErrInvalidArgument) } reason = strings.TrimSpace(reason) if reason == "" { reason = "admin_remove_content_blacklist" } response, err := s.contentBlacklistClient.AdminRemoveUserContentBlacklist(ctx, &userv1.AdminRemoveUserContentBlacklistRequest{ Meta: contentBlacklistRequestMeta(ctx, requestID), UserId: userID, OperatorAdminId: operatorAdminID, Reason: reason, }) if err != nil { return false, err } return response.GetRemoved(), nil } func contentBlacklistRequestMeta(ctx context.Context, requestID string) *userv1.RequestMeta { return &userv1.RequestMeta{ RequestId: strings.TrimSpace(requestID), Caller: "hyapp-admin-server", SentAtMs: time.Now().UTC().UnixMilli(), AppCode: appctx.FromContext(ctx), } } func appUserContentBlacklistEntryFromProto(item *userv1.UserContentBlacklistEntry) AppUserContentBlacklistEntry { return AppUserContentBlacklistEntry{ Target: AppUserBrief{ UserID: strconv.FormatInt(item.GetUserId(), 10), DisplayUserID: item.GetDisplayUserId(), Username: item.GetUsername(), Avatar: item.GetAvatar(), }, UserStatus: contentBlacklistUserStatus(item.GetUserStatus()), OperatorAdminID: strconv.FormatInt(item.GetOperatorAdminId(), 10), Reason: item.GetReason(), CreatedAtMs: item.GetCreatedAtMs(), UpdatedAtMs: item.GetUpdatedAtMs(), } } func contentBlacklistUserStatus(status userv1.UserStatus) string { switch status { case userv1.UserStatus_USER_STATUS_ACTIVE: return "active" case userv1.UserStatus_USER_STATUS_DISABLED: return "disabled" case userv1.UserStatus_USER_STATUS_BANNED: return "banned" default: return "" } }