This commit is contained in:
hy001 2026-05-22 16:05:12 +08:00
parent 8e8878a038
commit 747d4700df
15 changed files with 1444 additions and 23 deletions

View File

@ -15,6 +15,7 @@ import (
"chatapp3-golang/internal/service/apppopup"
"chatapp3-golang/internal/service/baishun"
"chatapp3-golang/internal/service/binancerecharge"
"chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/errorlog"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/gameopen"
@ -71,6 +72,7 @@ func main() {
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
baishunService.SetHighWinBroadcaster(regionIMGroupService)
luckyGiftService.SetHighWinBroadcaster(regionIMGroupService)
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
@ -99,6 +101,9 @@ func main() {
if err := firstRechargeRewardService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start first recharge reward message consumer failed: %v", err)
}
if err := cpRelationBroadcastService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start cp relation broadcast message consumer failed: %v", err)
}
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
log.Fatalf("start voice room rocket workers failed: %v", err)
}

View File

@ -2,6 +2,7 @@ package main
import (
"chatapp3-golang/internal/bootstrap"
"chatapp3-golang/internal/service/cprelationbroadcast"
"chatapp3-golang/internal/service/firstrechargereward"
"chatapp3-golang/internal/service/regionimgroup"
"chatapp3-golang/internal/service/registerreward"
@ -30,6 +31,7 @@ func main() {
taskCenterService := taskcenter.NewService(app.Config, app.Repository.DB, &app.Gateways)
firstRechargeRewardService := firstrechargereward.NewService(app.Config, app.Repository.DB, &app.Gateways)
regionIMGroupService := regionimgroup.NewService(app.Config, app.Repository.DB, &app.Gateways)
cpRelationBroadcastService := cprelationbroadcast.NewService(app.Config, app.Repository.DB, regionIMGroupService)
voiceRoomRedPacketService := voiceroomredpacket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, app.Gateways.Wallet, regionIMGroupService, app.Gateways.Room)
voiceRoomRedPacketService.SetRegionResolver(&app.Gateways)
voiceRoomRocketService := voiceroomrocket.NewService(app.Config, app.Repository.DB, app.Repository.Redis, &app.Gateways)
@ -50,6 +52,9 @@ func main() {
if err := firstRechargeRewardService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start first recharge reward message consumer failed: %v", err)
}
if err := cpRelationBroadcastService.StartMessageConsumer(workerCtx); err != nil {
log.Fatalf("start cp relation broadcast message consumer failed: %v", err)
}
if err := voiceRoomRocketService.Start(workerCtx); err != nil {
log.Fatalf("start voice room rocket workers failed: %v", err)
}

View File

@ -22,6 +22,7 @@ type Config struct {
RoomTurnoverReward RoomTurnoverRewardConfig
VoiceRoomRedPacket VoiceRoomRedPacketConfig
VoiceRoomRocket VoiceRoomRocketConfig
CPRelationBroadcast CPRelationBroadcastConfig
TencentIM TencentIMConfig
Baishun BaishunConfig
Lingxian LingxianConfig
@ -212,6 +213,25 @@ type VoiceRoomRocketMQConfig struct {
Tag string
}
// CPRelationBroadcastConfig 保存 CP/兄弟/姐妹关系区域飘屏配置。
type CPRelationBroadcastConfig struct {
DefaultSysOrigin string
MQ CPRelationBroadcastMQConfig
}
// CPRelationBroadcastMQConfig 保存 CP 关系成功 MQ 消费配置。
type CPRelationBroadcastMQConfig struct {
Enabled bool
Endpoint string
Namespace string
AccessKey string
AccessSecret string
SecurityToken string
ConsumerGroup string
Topic string
Tag string
}
// TencentIMConfig 保存腾讯 IM REST API 配置。
type TencentIMConfig struct {
AppID int64
@ -291,6 +311,9 @@ func Load() Config {
voiceRoomRocketMQEndpoint := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
voiceRoomRocketMQAccessKey := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
voiceRoomRocketMQAccessSecret := getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_ACCESS_SECRET", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
cpRelationBroadcastMQEndpoint := getEnvAny([]string{"CHATAPP_CP_RELATION_BROADCAST_MQ_ENDPOINT", "CP_RELATION_BROADCAST_MQ_ENDPOINT", "LIKEI_ROCKETMQ_ENDPOINT"}, "")
cpRelationBroadcastMQAccessKey := getEnvAny([]string{"CHATAPP_CP_RELATION_BROADCAST_MQ_ACCESS_KEY", "CP_RELATION_BROADCAST_MQ_ACCESS_KEY", "LIKEI_ROCKETMQ_ACCESS_KEY"}, "")
cpRelationBroadcastMQAccessSecret := getEnvAny([]string{"CHATAPP_CP_RELATION_BROADCAST_MQ_ACCESS_SECRET", "CP_RELATION_BROADCAST_MQ_SECRET_KEY", "LIKEI_ROCKETMQ_SECRET_KEY"}, "")
return Config{
HTTP: HTTPConfig{
@ -526,6 +549,23 @@ func Load() Config {
Tag: getEnvAny([]string{"CHATAPP_VOICE_ROOM_ROCKET_MQ_TAG", "VOICE_ROOM_ROCKET_MQ_TAG"}, "give_gift_v3"),
},
},
CPRelationBroadcast: CPRelationBroadcastConfig{
DefaultSysOrigin: strings.ToUpper(getEnvAny(
[]string{"CHATAPP_CP_RELATION_BROADCAST_DEFAULT_SYS_ORIGIN", "CP_RELATION_BROADCAST_DEFAULT_SYS_ORIGIN", "CHATAPP_WEEK_STAR_DEFAULT_SYS_ORIGIN", "WEEK_STAR_DEFAULT_SYS_ORIGIN"},
"LIKEI",
)),
MQ: CPRelationBroadcastMQConfig{
Enabled: getEnvBoolAny([]string{"CHATAPP_CP_RELATION_BROADCAST_MQ_ENABLED", "CP_RELATION_BROADCAST_MQ_ENABLED"}, false),
Endpoint: cpRelationBroadcastMQEndpoint,
Namespace: getEnvAny([]string{"CHATAPP_CP_RELATION_BROADCAST_MQ_NAMESPACE", "CP_RELATION_BROADCAST_MQ_NAMESPACE"}, ""),
AccessKey: cpRelationBroadcastMQAccessKey,
AccessSecret: cpRelationBroadcastMQAccessSecret,
SecurityToken: getEnvAny([]string{"CHATAPP_CP_RELATION_BROADCAST_MQ_SECURITY_TOKEN", "CP_RELATION_BROADCAST_MQ_SECURITY_TOKEN"}, ""),
ConsumerGroup: getEnvAny([]string{"CHATAPP_CP_RELATION_BROADCAST_MQ_CONSUMER_GROUP", "CP_RELATION_BROADCAST_MQ_CONSUMER_GROUP"}, "cp-relation-broadcast"),
Topic: getEnvAny([]string{"CHATAPP_CP_RELATION_BROADCAST_MQ_TOPIC", "CP_RELATION_BROADCAST_MQ_TOPIC"}, "RC_DEFAULT_APP_ORDINARY"),
Tag: getEnvAny([]string{"CHATAPP_CP_RELATION_BROADCAST_MQ_TAG", "CP_RELATION_BROADCAST_MQ_TAG"}, "cp_relation_broadcast"),
},
},
TencentIM: TencentIMConfig{
AppID: getEnvInt64Any(
[]string{"CHATAPP_TENCENT_IM_APP_ID", "TENCENT_IM_APP_ID", "LIKEI_IM_APP_ID"},

View File

@ -102,6 +102,27 @@ func TestLoadTaskCenterMQUsesSharedRocketMQCredentials(t *testing.T) {
}
}
func TestLoadCPRelationBroadcastMQUsesSharedRocketMQCredentials(t *testing.T) {
t.Setenv("CHATAPP_CP_RELATION_BROADCAST_MQ_ENABLED", "true")
t.Setenv("LIKEI_ROCKETMQ_ENDPOINT", "rmq.example.com:8081")
t.Setenv("LIKEI_ROCKETMQ_ACCESS_KEY", "access-key")
t.Setenv("LIKEI_ROCKETMQ_SECRET_KEY", "access-secret")
cfg := Load()
if !cfg.CPRelationBroadcast.MQ.Enabled {
t.Fatalf("expected cp relation broadcast mq enabled")
}
if got := cfg.CPRelationBroadcast.MQ.Endpoint; got != "rmq.example.com:8081" {
t.Fatalf("cp relation broadcast mq endpoint = %q", got)
}
if got := cfg.CPRelationBroadcast.MQ.Topic; got != "RC_DEFAULT_APP_ORDINARY" {
t.Fatalf("cp relation broadcast mq topic = %q", got)
}
if got := cfg.CPRelationBroadcast.MQ.Tag; got != "cp_relation_broadcast" {
t.Fatalf("cp relation broadcast mq tag = %q", got)
}
}
func TestLoadTaskCenterArchiveUsesSharedCOSCredentials(t *testing.T) {
t.Setenv("CHATAPP_TASK_CENTER_ARCHIVE_ENABLED", "true")
t.Setenv("LIKEI_TENCENT_COS_BUCKET", "bucket-123")

View File

@ -60,6 +60,29 @@ func registerManagerCenterRoutes(engine *gin.Engine, javaClient authGateway, ser
writeOK(c, resp)
})
group.GET("/bd-leaders", func(c *gin.Context) {
resp, err := service.ListBDLeaders(c.Request.Context(), mustAuthUser(c))
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/bd-leaders", func(c *gin.Context) {
var req managercenter.AddBDLeaderRequest
if err := c.ShouldBindJSON(&req); err != nil {
writeError(c, managercenter.NewAppError(http.StatusBadRequest, "bad_request", err.Error()))
return
}
resp, err := service.AddBDLeader(c.Request.Context(), mustAuthUser(c), req)
if err != nil {
writeError(c, err)
return
}
writeOK(c, resp)
})
group.POST("/users/ban", func(c *gin.Context) {
var req managercenter.BanUserRequest
if err := c.ShouldBindJSON(&req); err != nil {

View File

@ -0,0 +1,327 @@
package cprelationbroadcast
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
"gorm.io/gorm"
)
// ProcessPayload 解码 Java MQ 消息并发送区域飘屏。
func (s *Service) ProcessPayload(ctx context.Context, payload string) (*regionimgroup.RegionBroadcastResponse, error) {
req, err := decodeEventPayload(payload, s.cfg.CPRelationBroadcast.MQ.Tag)
if err != nil {
return nil, err
}
if req.isEmpty() {
return nil, nil
}
return s.ProcessEvent(ctx, req)
}
// ProcessEvent 处理标准化后的 CP 关系事件。
func (s *Service) ProcessEvent(ctx context.Context, req EventRequest) (*regionimgroup.RegionBroadcastResponse, error) {
if s == nil || s.broadcaster == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "cp_relation_broadcaster_missing", "cp relation broadcaster is missing")
}
if s.db == nil {
return nil, common.NewAppError(http.StatusInternalServerError, "cp_relation_db_missing", "cp relation db is missing")
}
req = normalizeEventRequest(req)
userID := req.UserID.Int64()
cpUserID := req.CpUserID.Int64()
if userID <= 0 || cpUserID <= 0 {
return nil, common.NewAppError(http.StatusBadRequest, "invalid_cp_relation_users", "userId and cpUserId are required")
}
relationType, err := normalizeRelationType(req.RelationType)
if err != nil {
return nil, err
}
sysOrigin := normalizeSysOrigin(req.SysOrigin, s.cfg.CPRelationBroadcast.DefaultSysOrigin)
user, err := s.loadEventUser(ctx, userID, req.User)
if err != nil {
return nil, err
}
cpUser, err := s.loadEventUser(ctx, cpUserID, req.CpUser)
if err != nil {
return nil, err
}
msg := relationMessage(relationType, user.displayName(), cpUser.displayName())
data := buildBroadcastData(req, sysOrigin, relationType, user, cpUser, msg)
return s.broadcaster.SendRegionBroadcast(ctx, regionimgroup.RegionBroadcastRequest{
SysOrigin: sysOrigin,
Type: MessageTypeCPRelationBroadcast,
Data: data,
})
}
func (s *Service) loadEventUser(ctx context.Context, userID int64, provided *EventUser) (EventUser, error) {
var user model.UserBaseInfo
err := s.db.WithContext(ctx).Where("id = ?", userID).First(&user).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
account := strconv.FormatInt(userID, 10)
user = model.UserBaseInfo{
ID: userID,
Account: account,
UserNickname: account,
}
} else if err != nil {
return EventUser{}, err
}
if strings.TrimSpace(user.Account) == "" {
user.Account = strconv.FormatInt(userID, 10)
}
if strings.TrimSpace(user.UserNickname) == "" {
user.UserNickname = user.Account
}
return mergeEventUser(userID, user, provided), nil
}
func buildBroadcastData(req EventRequest, sysOrigin, relationType string, user EventUser, cpUser EventUser, msg string) map[string]any {
userID := user.normalizedID()
cpUserID := cpUser.normalizedID()
userIDText := strconv.FormatInt(userID, 10)
cpUserIDText := strconv.FormatInt(cpUserID, 10)
data := map[string]any{
"sysOrigin": sysOrigin,
"type": MessageTypeCPRelationBroadcast,
"relationType": relationType,
"relationLabel": relationLabel(relationType),
"msg": msg,
"userId": userIDText,
"sendUserId": userIDText,
"senderUserId": userIDText,
"cpUserId": cpUserIDText,
"acceptUserId": cpUserIDText,
"toUserId": cpUserIDText,
"user": user.toMap(),
"cpUser": cpUser.toMap(),
"account": user.Account,
"actualAccount": user.Account,
"userNickname": user.displayName(),
"nickname": user.displayName(),
"userAvatar": firstNonBlank(user.UserAvatar, user.Avatar),
"countryCode": user.CountryCode,
"countryName": user.CountryName,
"cpUserAccount": cpUser.Account,
"cpUserNickname": cpUser.displayName(),
"cpUserAvatar": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
"cpUserCountryCode": cpUser.CountryCode,
"cpUserCountryName": cpUser.CountryName,
"acceptAccount": cpUser.Account,
"acceptNickname": cpUser.displayName(),
"acceptUserAvatar": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
"toUserName": cpUser.displayName(),
"toUserAvatarUrl": firstNonBlank(cpUser.UserAvatar, cpUser.Avatar),
}
if eventID := strings.TrimSpace(req.EventID); eventID != "" {
data["eventId"] = eventID
}
if applyID := req.ApplyID.Int64(); applyID > 0 {
data["applyId"] = strconv.FormatInt(applyID, 10)
}
if occurredAt := strings.TrimSpace(req.OccurredAt); occurredAt != "" {
data["occurredAt"] = occurredAt
}
if len(req.Payload) > 0 {
data["payload"] = req.Payload
}
return data
}
func normalizeEventRequest(req EventRequest) EventRequest {
if req.UserID.Int64() <= 0 {
req.UserID = req.SendUserID
}
if req.CpUserID.Int64() <= 0 {
req.CpUserID = req.AcceptUserID
}
if req.UserID.Int64() <= 0 && req.User != nil {
req.UserID = flexibleInt64(req.User.normalizedID())
}
if req.CpUserID.Int64() <= 0 && req.CpUser != nil {
req.CpUserID = flexibleInt64(req.CpUser.normalizedID())
}
return req
}
func (req EventRequest) isEmpty() bool {
return strings.TrimSpace(req.EventID) == "" &&
strings.TrimSpace(req.RelationType) == "" &&
req.UserID.Int64() <= 0 &&
req.CpUserID.Int64() <= 0 &&
req.SendUserID.Int64() <= 0 &&
req.AcceptUserID.Int64() <= 0
}
func normalizeRelationType(value string) (string, error) {
normalized := strings.ToUpper(strings.TrimSpace(value))
if normalized == "" {
return RelationTypeCP, nil
}
switch normalized {
case RelationTypeCP:
return RelationTypeCP, nil
case RelationTypeBrother, "BROTHERS":
return RelationTypeBrother, nil
case RelationTypeSisters, "SISTER":
return RelationTypeSisters, nil
default:
return "", common.NewAppError(http.StatusBadRequest, "invalid_cp_relation_type", fmt.Sprintf("invalid cp relationType: %s", value))
}
}
func relationLabel(relationType string) string {
switch relationType {
case RelationTypeBrother:
return "brothers"
case RelationTypeSisters:
return "sisters"
default:
return "cp_relation"
}
}
func relationMessage(relationType, userName, cpUserName string) string {
switch relationType {
case RelationTypeBrother:
return fmt.Sprintf("%s与%s结为兄弟", userName, cpUserName)
case RelationTypeSisters:
return fmt.Sprintf("%s与%s结为姐妹", userName, cpUserName)
default:
return fmt.Sprintf("%s与%s get cp relation", userName, cpUserName)
}
}
func normalizeSysOrigin(values ...string) string {
for _, value := range values {
if text := strings.ToUpper(strings.TrimSpace(value)); text != "" {
return text
}
}
return defaultSysOrigin
}
func decodeEventPayload(payload string, expectedTag string) (EventRequest, error) {
payload = strings.TrimSpace(payload)
if payload == "" {
return EventRequest{}, nil
}
req, err := decodeEventObject([]byte(payload))
if err != nil {
return EventRequest{}, err
}
if !req.isEmpty() {
return req, nil
}
var envelope messageEventEnvelope
if err := json.Unmarshal([]byte(payload), &envelope); err != nil {
return EventRequest{}, err
}
if shouldSkipEnvelope(envelope.Tag, expectedTag) {
return EventRequest{}, nil
}
bodyPayload, ok, err := envelope.bodyPayload()
if err != nil || !ok {
return EventRequest{}, err
}
return decodeEventObject([]byte(bodyPayload))
}
type rawEventRequest struct {
EventRequest
SysOriginSnake string `json:"sys_origin"`
EventIDSnake string `json:"event_id"`
ApplyIDSnake flexibleInt64 `json:"apply_id"`
RelationTypeSnake string `json:"relation_type"`
UserIDSnake flexibleInt64 `json:"user_id"`
CpUserIDSnake flexibleInt64 `json:"cp_user_id"`
SendUserIDSnake flexibleInt64 `json:"send_user_id"`
AcceptUserIDSnake flexibleInt64 `json:"accept_user_id"`
OccurredAtSnake string `json:"occurred_at"`
TargetUserID flexibleInt64 `json:"targetUserId"`
TargetUserIDSnake flexibleInt64 `json:"target_user_id"`
ToUserIDSnake flexibleInt64 `json:"to_user_id"`
}
func decodeEventObject(data []byte) (EventRequest, error) {
var raw rawEventRequest
if err := json.Unmarshal(data, &raw); err != nil {
return EventRequest{}, err
}
req := raw.EventRequest
req.SysOrigin = firstNonBlank(req.SysOrigin, raw.SysOriginSnake)
req.EventID = firstNonBlank(req.EventID, raw.EventIDSnake)
req.RelationType = firstNonBlank(req.RelationType, raw.RelationTypeSnake)
req.OccurredAt = firstNonBlank(req.OccurredAt, raw.OccurredAtSnake)
if req.ApplyID.Int64() <= 0 {
req.ApplyID = raw.ApplyIDSnake
}
if req.UserID.Int64() <= 0 {
req.UserID = firstFlexible(raw.UserIDSnake, req.SendUserID, raw.SendUserIDSnake)
}
if req.CpUserID.Int64() <= 0 {
req.CpUserID = firstFlexible(raw.CpUserIDSnake, req.AcceptUserID, raw.AcceptUserIDSnake, raw.TargetUserID, raw.TargetUserIDSnake, raw.ToUserIDSnake)
}
if req.SendUserID.Int64() <= 0 {
req.SendUserID = firstFlexible(req.UserID, raw.SendUserIDSnake)
}
if req.AcceptUserID.Int64() <= 0 {
req.AcceptUserID = firstFlexible(req.CpUserID, raw.AcceptUserIDSnake, raw.TargetUserID, raw.TargetUserIDSnake, raw.ToUserIDSnake)
}
return req, nil
}
func firstFlexible(values ...flexibleInt64) flexibleInt64 {
for _, value := range values {
if value.Int64() > 0 {
return value
}
}
return 0
}
type messageEventEnvelope struct {
Tag string `json:"tag"`
Body json.RawMessage `json:"body"`
}
func (e messageEventEnvelope) bodyPayload() (string, bool, error) {
raw := strings.TrimSpace(string(e.Body))
if raw == "" || raw == "null" {
return "", false, nil
}
if strings.HasPrefix(raw, "\"") {
var body string
if err := json.Unmarshal(e.Body, &body); err != nil {
return "", false, err
}
body = strings.TrimSpace(body)
return body, body != "", nil
}
return raw, true, nil
}
func shouldSkipEnvelope(actualTag, expectedTag string) bool {
actualTag = strings.TrimSpace(actualTag)
expectedTag = strings.TrimSpace(expectedTag)
if actualTag == "" || expectedTag == "" || expectedTag == "*" {
return false
}
return actualTag != expectedTag
}

View File

@ -0,0 +1,142 @@
package cprelationbroadcast
import (
"context"
"testing"
"time"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestProcessEventBuildsCPRelationBroadcastPayload(t *testing.T) {
service, broadcaster := newCPRelationBroadcastTestService(t)
_, err := service.ProcessEvent(context.Background(), EventRequest{
SysOrigin: "LIKEI",
EventID: "CP_RELATION:9001",
ApplyID: flexibleInt64(9001),
RelationType: RelationTypeCP,
UserID: flexibleInt64(1001),
CpUserID: flexibleInt64(1002),
OccurredAt: "2026-05-22T12:00:00Z",
})
if err != nil {
t.Fatalf("ProcessEvent() error = %v", err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.Type != MessageTypeCPRelationBroadcast {
t.Fatalf("type = %q", req.Type)
}
data := req.Data
if data["msg"] != "Alice与Bob get cp relation" ||
data["relationType"] != RelationTypeCP ||
data["userId"] != "1001" ||
data["cpUserId"] != "1002" {
t.Fatalf("payload = %+v", data)
}
user, ok := data["user"].(map[string]any)
if !ok || user["userNickname"] != "Alice" || user["account"] != "alice" {
t.Fatalf("user payload = %+v", data["user"])
}
cpUser, ok := data["cpUser"].(map[string]any)
if !ok || cpUser["userNickname"] != "Bob" || cpUser["account"] != "bob" {
t.Fatalf("cpUser payload = %+v", data["cpUser"])
}
}
func TestProcessEventBuildsBrotherAndSistersMessages(t *testing.T) {
service, broadcaster := newCPRelationBroadcastTestService(t)
cases := []struct {
relationType string
wantMsg string
}{
{RelationTypeBrother, "Alice与Bob结为兄弟"},
{RelationTypeSisters, "Alice与Bob结为姐妹"},
}
for _, tc := range cases {
broadcaster.requests = nil
_, err := service.ProcessEvent(context.Background(), EventRequest{
RelationType: tc.relationType,
UserID: flexibleInt64(1001),
CpUserID: flexibleInt64(1002),
})
if err != nil {
t.Fatalf("ProcessEvent(%s) error = %v", tc.relationType, err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
if got := broadcaster.requests[0].Data["msg"]; got != tc.wantMsg {
t.Fatalf("msg = %v, want %s", got, tc.wantMsg)
}
}
}
func TestProcessPayloadDecodesMessageEventEnvelopeAndAliases(t *testing.T) {
service, broadcaster := newCPRelationBroadcastTestService(t)
service.cfg.CPRelationBroadcast.MQ.Tag = "cp_relation_broadcast"
payload := `{"tag":"cp_relation_broadcast","body":"{\"sys_origin\":\"likei\",\"event_id\":\"CP_RELATION:9002\",\"apply_id\":\"9002\",\"relation_type\":\"BROTHERS\",\"send_user_id\":\"1001\",\"accept_user_id\":\"1002\"}"}`
_, err := service.ProcessPayload(context.Background(), payload)
if err != nil {
t.Fatalf("ProcessPayload() error = %v", err)
}
if len(broadcaster.requests) != 1 {
t.Fatalf("requests = %d, want 1", len(broadcaster.requests))
}
req := broadcaster.requests[0]
if req.SysOrigin != "LIKEI" {
t.Fatalf("sysOrigin = %q", req.SysOrigin)
}
if req.Data["eventId"] != "CP_RELATION:9002" ||
req.Data["applyId"] != "9002" ||
req.Data["relationType"] != RelationTypeBrother ||
req.Data["msg"] != "Alice与Bob结为兄弟" {
t.Fatalf("payload = %+v", req.Data)
}
}
func newCPRelationBroadcastTestService(t *testing.T) (*Service, *fakeCPRegionBroadcaster) {
t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(&model.UserBaseInfo{}); err != nil {
t.Fatalf("auto migrate: %v", err)
}
now := time.Now()
if err := db.Create(&[]model.UserBaseInfo{
{ID: 1001, Account: "alice", UserNickname: "Alice", UserAvatar: "alice.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
{ID: 1002, Account: "bob", UserNickname: "Bob", UserAvatar: "bob.png", OriginSys: "LIKEI", CountryCode: "SA", CountryName: "Saudi Arabia", CreateTime: now},
}).Error; err != nil {
t.Fatalf("seed users: %v", err)
}
broadcaster := &fakeCPRegionBroadcaster{}
return NewService(config.Config{
CPRelationBroadcast: config.CPRelationBroadcastConfig{DefaultSysOrigin: "LIKEI"},
}, db, broadcaster), broadcaster
}
type fakeCPRegionBroadcaster struct {
requests []regionimgroup.RegionBroadcastRequest
}
func (f *fakeCPRegionBroadcaster) SendRegionBroadcast(_ context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error) {
f.requests = append(f.requests, req)
return &regionimgroup.RegionBroadcastResponse{
SysOrigin: req.SysOrigin,
RegionCode: "AR",
GroupID: "@region-ar",
Type: req.Type,
}, nil
}

View File

@ -0,0 +1,139 @@
package cprelationbroadcast
import (
"context"
"encoding/json"
"errors"
"log"
"net/http"
"strings"
"time"
"chatapp3-golang/internal/common"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"github.com/apache/rocketmq-clients/golang/v5/credentials"
)
// StartMessageConsumer 启动 CP 关系成功 MQ 消费链路。
func (s *Service) StartMessageConsumer(ctx context.Context) error {
if !s.cfg.CPRelationBroadcast.MQ.Enabled {
log.Printf("cp relation broadcast rocketmq consumer disabled")
return nil
}
if strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Endpoint) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.AccessKey) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.AccessSecret) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Topic) == "" ||
strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.ConsumerGroup) == "" {
log.Printf("cp relation broadcast rocketmq consumer skipped: endpoint, credentials, topic or consumer group missing")
return nil
}
go s.startRocketMQConsumerWithRetry(ctx)
return nil
}
func (s *Service) startRocketMQConsumerWithRetry(ctx context.Context) {
retryDelay := 5 * time.Second
for {
if ctx.Err() != nil {
return
}
if err := s.startRocketMQConsumer(ctx); err != nil {
log.Printf("start cp relation broadcast rocketmq consumer failed: %v; retry in %s", err, retryDelay)
timer := time.NewTimer(retryDelay)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
if retryDelay < time.Minute {
retryDelay *= 2
if retryDelay > time.Minute {
retryDelay = time.Minute
}
}
continue
}
return
}
}
func (s *Service) startRocketMQConsumer(ctx context.Context) error {
filter := rmq.SUB_ALL
if tag := strings.TrimSpace(s.cfg.CPRelationBroadcast.MQ.Tag); tag != "" && tag != "*" {
filter = rmq.NewFilterExpression(tag)
}
consumer, err := rmq.NewPushConsumer(&rmq.Config{
Endpoint: s.cfg.CPRelationBroadcast.MQ.Endpoint,
NameSpace: s.cfg.CPRelationBroadcast.MQ.Namespace,
ConsumerGroup: s.cfg.CPRelationBroadcast.MQ.ConsumerGroup,
Credentials: &credentials.SessionCredentials{
AccessKey: s.cfg.CPRelationBroadcast.MQ.AccessKey,
AccessSecret: s.cfg.CPRelationBroadcast.MQ.AccessSecret,
SecurityToken: s.cfg.CPRelationBroadcast.MQ.SecurityToken,
},
},
rmq.WithPushSubscriptionExpressions(map[string]*rmq.FilterExpression{
s.cfg.CPRelationBroadcast.MQ.Topic: filter,
}),
rmq.WithPushConsumptionThreadCount(4),
rmq.WithPushMaxCacheMessageCount(256),
rmq.WithPushMessageListener(&rmq.FuncMessageListener{
Consume: func(messageView *rmq.MessageView) rmq.ConsumerResult {
if messageView == nil {
return rmq.SUCCESS
}
if err := s.processRocketMQMessage(context.Background(), messageView); err != nil {
log.Printf("cp relation broadcast mq process failed. messageId=%s err=%v", messageView.GetMessageId(), err)
return rmq.FAILURE
}
return rmq.SUCCESS
},
}),
)
if err != nil {
return err
}
if err := consumer.Start(); err != nil {
return err
}
s.rocketConsumer = consumer
log.Printf("cp relation broadcast rocketmq consumer started. topic=%s group=%s tag=%s",
s.cfg.CPRelationBroadcast.MQ.Topic,
s.cfg.CPRelationBroadcast.MQ.ConsumerGroup,
s.cfg.CPRelationBroadcast.MQ.Tag,
)
go func() {
<-ctx.Done()
if s.rocketConsumer != nil {
_ = s.rocketConsumer.GracefulStop()
}
}()
return nil
}
func (s *Service) processRocketMQMessage(ctx context.Context, messageView *rmq.MessageView) error {
payload := strings.TrimSpace(string(messageView.GetBody()))
if payload == "" {
return nil
}
if _, err := s.ProcessPayload(ctx, payload); err != nil {
var syntaxErr *json.SyntaxError
var typeErr *json.UnmarshalTypeError
if errors.As(err, &syntaxErr) || errors.As(err, &typeErr) {
log.Printf("drop malformed cp relation broadcast message. messageId=%s err=%v", messageView.GetMessageId(), err)
return nil
}
var appErr *common.AppError
if errors.As(err, &appErr) && appErr.Status >= http.StatusBadRequest && appErr.Status < http.StatusInternalServerError {
log.Printf("drop invalid cp relation broadcast message. messageId=%s code=%s err=%v", messageView.GetMessageId(), appErr.Code, err)
return nil
}
return err
}
return nil
}

View File

@ -0,0 +1,199 @@
package cprelationbroadcast
import (
"context"
"fmt"
"strconv"
"strings"
"chatapp3-golang/internal/config"
"chatapp3-golang/internal/model"
"chatapp3-golang/internal/service/regionimgroup"
rmq "github.com/apache/rocketmq-clients/golang/v5"
"gorm.io/gorm"
)
const (
MessageTypeCPRelationBroadcast = "CP_RELATION_BROADCAST"
RelationTypeCP = "CP"
RelationTypeBrother = "BROTHER"
RelationTypeSisters = "SISTERS"
defaultSysOrigin = "LIKEI"
)
type dbHandle interface {
WithContext(ctx context.Context) *gorm.DB
}
type regionBroadcaster interface {
SendRegionBroadcast(ctx context.Context, req regionimgroup.RegionBroadcastRequest) (*regionimgroup.RegionBroadcastResponse, error)
}
// Service 消费 Java CP 关系成功 MQ并复用区域 IM 广播发送飘屏。
type Service struct {
cfg config.Config
db dbHandle
broadcaster regionBroadcaster
rocketConsumer rmq.PushConsumer
}
func NewService(cfg config.Config, db dbHandle, broadcaster regionBroadcaster) *Service {
return &Service{cfg: cfg, db: db, broadcaster: broadcaster}
}
// EventRequest 是 Java 在 CP/兄弟/姐妹关系达成后投递给 Go 的 MQ 事件。
type EventRequest struct {
SysOrigin string `json:"sysOrigin"`
EventID string `json:"eventId"`
ApplyID flexibleInt64 `json:"applyId"`
RelationType string `json:"relationType"`
UserID flexibleInt64 `json:"userId"`
CpUserID flexibleInt64 `json:"cpUserId"`
SendUserID flexibleInt64 `json:"sendUserId"`
AcceptUserID flexibleInt64 `json:"acceptUserId"`
OccurredAt string `json:"occurredAt"`
User *EventUser `json:"user"`
CpUser *EventUser `json:"cpUser"`
Payload map[string]any `json:"payload,omitempty"`
}
type EventUser struct {
ID flexibleInt64 `json:"id"`
UserID flexibleInt64 `json:"userId"`
Account string `json:"account"`
Nickname string `json:"nickname"`
UserNickname string `json:"userNickname"`
Avatar string `json:"avatar"`
UserAvatar string `json:"userAvatar"`
CountryCode string `json:"countryCode"`
CountryName string `json:"countryName"`
OriginSys string `json:"originSys"`
}
func (u EventUser) normalizedID() int64 {
if id := u.UserID.Int64(); id > 0 {
return id
}
return u.ID.Int64()
}
func (u EventUser) displayName() string {
for _, value := range []string{u.UserNickname, u.Nickname, u.Account} {
if text := strings.TrimSpace(value); text != "" {
return text
}
}
if id := u.normalizedID(); id > 0 {
return strconv.FormatInt(id, 10)
}
return ""
}
func (u EventUser) toMap() map[string]any {
id := u.normalizedID()
data := map[string]any{}
if id > 0 {
data["userId"] = strconv.FormatInt(id, 10)
data["id"] = strconv.FormatInt(id, 10)
}
if account := strings.TrimSpace(u.Account); account != "" {
data["account"] = account
}
if nickname := strings.TrimSpace(firstNonBlank(u.UserNickname, u.Nickname)); nickname != "" {
data["userNickname"] = nickname
data["nickname"] = nickname
}
if avatar := strings.TrimSpace(firstNonBlank(u.UserAvatar, u.Avatar)); avatar != "" {
data["userAvatar"] = avatar
data["avatar"] = avatar
}
if countryCode := strings.TrimSpace(u.CountryCode); countryCode != "" {
data["countryCode"] = countryCode
}
if countryName := strings.TrimSpace(u.CountryName); countryName != "" {
data["countryName"] = countryName
}
if originSys := strings.TrimSpace(u.OriginSys); originSys != "" {
data["originSys"] = originSys
}
return data
}
func eventUserFromModel(user model.UserBaseInfo) EventUser {
return EventUser{
ID: flexibleInt64(user.ID),
UserID: flexibleInt64(user.ID),
Account: user.Account,
Nickname: user.UserNickname,
UserNickname: user.UserNickname,
Avatar: user.UserAvatar,
UserAvatar: user.UserAvatar,
CountryCode: user.CountryCode,
CountryName: user.CountryName,
OriginSys: user.OriginSys,
}
}
func mergeEventUser(id int64, dbUser model.UserBaseInfo, provided *EventUser) EventUser {
merged := eventUserFromModel(dbUser)
if id > 0 {
merged.ID = flexibleInt64(id)
merged.UserID = flexibleInt64(id)
}
if provided == nil {
return merged
}
if provided.normalizedID() > 0 {
merged.ID = flexibleInt64(provided.normalizedID())
merged.UserID = flexibleInt64(provided.normalizedID())
}
merged.Account = firstNonBlank(provided.Account, merged.Account)
merged.UserNickname = firstNonBlank(provided.UserNickname, provided.Nickname, merged.UserNickname)
merged.Nickname = merged.UserNickname
merged.UserAvatar = firstNonBlank(provided.UserAvatar, provided.Avatar, merged.UserAvatar)
merged.Avatar = merged.UserAvatar
merged.CountryCode = firstNonBlank(provided.CountryCode, merged.CountryCode)
merged.CountryName = firstNonBlank(provided.CountryName, merged.CountryName)
merged.OriginSys = firstNonBlank(provided.OriginSys, merged.OriginSys)
return merged
}
type flexibleInt64 int64
func (v *flexibleInt64) UnmarshalJSON(data []byte) error {
raw := strings.TrimSpace(string(data))
switch raw {
case "", "null", `""`:
*v = 0
return nil
}
if strings.HasPrefix(raw, `"`) && strings.HasSuffix(raw, `"`) {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return err
}
raw = strings.TrimSpace(unquoted)
}
parsed, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return fmt.Errorf("invalid int64 value: %w", err)
}
*v = flexibleInt64(parsed)
return nil
}
func (v flexibleInt64) Int64() int64 {
return int64(v)
}
func firstNonBlank(values ...string) string {
for _, value := range values {
if text := strings.TrimSpace(value); text != "" {
return text
}
}
return ""
}

View File

@ -0,0 +1,367 @@
package managercenter
import (
"context"
"errors"
"strings"
"time"
"chatapp3-golang/internal/utils"
"gorm.io/gorm"
)
type bdLeaderRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Origin string `gorm:"column:sys_origin"`
UserID int64 `gorm:"column:user_id"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
}
func (bdLeaderRow) TableName() string {
return "room_bd_lead_base_info"
}
type businessDevelopmentBaseInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"`
Origin string `gorm:"column:sys_origin"`
BDLeadUserID *int64 `gorm:"column:bd_lead_user_id"`
UserID int64 `gorm:"column:user_id"`
Contact string `gorm:"column:contact"`
Region string `gorm:"column:region"`
MemberQuantity int64 `gorm:"column:member_quantity"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
}
func (businessDevelopmentBaseInfoRow) TableName() string {
return "room_business_development_base_info"
}
type userHistoryIdentityRow struct {
UserID int64 `gorm:"column:user_id;primaryKey"`
IsBD bool `gorm:"column:is_bd"`
IsAgent bool `gorm:"column:is_agent"`
IsHost bool `gorm:"column:is_host"`
IsManager bool `gorm:"column:is_manager"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
}
func (userHistoryIdentityRow) TableName() string {
return "user_history_identity"
}
type administratorRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
Status bool `gorm:"column:status"`
}
func (administratorRow) TableName() string {
return "sys_administrator"
}
type bdLeaderListRow struct {
ID int64 `gorm:"column:id"`
UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
CreateTime time.Time `gorm:"column:create_time"`
UpdateTime time.Time `gorm:"column:update_time"`
Account string `gorm:"column:account"`
UserAvatar string `gorm:"column:user_avatar"`
UserNickname string `gorm:"column:user_nickname"`
}
func (s *Service) ListBDLeaders(ctx context.Context, user AuthUser) (*BDLeaderListResponse, error) {
manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
return nil, err
}
query := s.db.WithContext(ctx).
Table("room_bd_lead_base_info AS leader").
Select(strings.Join([]string{
"leader.id",
"leader.user_id",
"leader.sys_origin",
"leader.contact",
"leader.region_id",
"leader.create_time",
"leader.update_time",
"user_info.account",
"user_info.user_avatar",
"user_info.user_nickname",
}, ", ")).
Joins("LEFT JOIN user_base_info AS user_info ON user_info.id = leader.user_id AND user_info.is_del = ?", false).
Where("leader.create_user = ?", user.UserID)
if origin := managerSysOrigin(manager, user); origin != "" {
query = query.Where("leader.sys_origin = ?", origin)
}
var rows []bdLeaderListRow
if err := query.Order("leader.create_time DESC, leader.id DESC").Find(&rows).Error; err != nil {
return nil, serverError("bd_leader_query_failed", err.Error())
}
records := make([]BDLeaderView, 0, len(rows))
for _, row := range rows {
records = append(records, bdLeaderListRowToView(row))
}
return &BDLeaderListResponse{Count: len(records), Records: records}, nil
}
func (s *Service) AddBDLeader(ctx context.Context, user AuthUser, req AddBDLeaderRequest) (*AddBDLeaderResponse, error) {
manager, err := s.requireManagerInfo(ctx, user)
if err != nil {
return nil, err
}
if req.UserID.Int64() <= 0 {
return nil, badRequest("user_required", "userId is required")
}
target, err := s.loadUserRowByID(ctx, req.UserID.Int64())
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, notFound("user_not_found", "user not found")
}
return nil, serverError("user_query_failed", err.Error())
}
sysOrigin := managerSysOrigin(manager, user)
if sysOrigin == "" {
sysOrigin = normalizeSysOrigin(target.OriginSys)
}
if target.OriginSys != "" && sysOrigin != "" && !strings.EqualFold(target.OriginSys, sysOrigin) {
return nil, badRequest("bd_leader_origin_mismatch", "target user sysOrigin does not match manager")
}
regionID := strings.TrimSpace(req.RegionID)
if regionID == "" {
regionID = strings.TrimSpace(manager.RegionID)
}
if regionID == "" {
return nil, badRequest("manager_region_required", "manager region is required")
}
if exists, err := s.userIsAdministrator(ctx, target.ID); err != nil {
return nil, err
} else if exists {
return nil, badRequest("user_is_admin", "administrator cannot be added as BD leader")
}
if exists, err := s.userIsBDLeader(ctx, target.ID); err != nil {
return nil, err
} else if exists {
return nil, badRequest("user_already_bd_leader", "user is already a BD leader")
}
leaderID, err := utils.NextID()
if err != nil {
return nil, serverError("id_generate_failed", err.Error())
}
now := time.Now()
contact := strings.TrimSpace(req.Contact)
leader := bdLeaderRow{
ID: leaderID,
Origin: sysOrigin,
UserID: target.ID,
Contact: contact,
RegionID: regionID,
CreateTime: now,
UpdateTime: now,
CreateUser: user.UserID,
UpdateUser: user.UserID,
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(&leader).Error; err != nil {
return serverError("bd_leader_create_failed", err.Error())
}
if err := upsertBDBaseInfo(tx, leader, user.UserID, now); err != nil {
return err
}
if err := upsertUserHistoryBD(tx, target.ID, user.UserID, now); err != nil {
return err
}
return nil
}); err != nil {
return nil, err
}
return &AddBDLeaderResponse{
Success: true,
Leader: bdLeaderRowToView(leader, target),
}, nil
}
func (s *Service) requireManagerInfo(ctx context.Context, user AuthUser) (teamManagerInfoRow, error) {
if user.UserID <= 0 {
return teamManagerInfoRow{}, unauthorized("invalid_user", "authenticated user is required")
}
if s.db == nil {
return teamManagerInfoRow{}, serviceUnavailable("database_unavailable", "database is unavailable")
}
var row teamManagerInfoRow
err := s.db.WithContext(ctx).
Table("team_manager_base_info").
Where("user_id = ?", user.UserID).
First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return teamManagerInfoRow{}, forbidden("not_manager", "user is not manager")
}
if err != nil {
return teamManagerInfoRow{}, serverError("manager_query_failed", err.Error())
}
return row, nil
}
func managerSysOrigin(manager teamManagerInfoRow, user AuthUser) string {
if origin := strings.TrimSpace(manager.Origin); origin != "" {
return origin
}
return strings.TrimSpace(user.SysOrigin)
}
func (s *Service) userIsAdministrator(ctx context.Context, userID int64) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("sys_administrator").
Where("user_id = ? AND status = ?", userID, true).
Count(&count).Error; err != nil {
return false, serverError("administrator_query_failed", err.Error())
}
return count > 0, nil
}
func (s *Service) userIsBDLeader(ctx context.Context, userID int64) (bool, error) {
var count int64
if err := s.db.WithContext(ctx).
Table("room_bd_lead_base_info").
Where("user_id = ?", userID).
Count(&count).Error; err != nil {
return false, serverError("bd_leader_query_failed", err.Error())
}
return count > 0, nil
}
func upsertBDBaseInfo(tx *gorm.DB, leader bdLeaderRow, operatorID int64, now time.Time) error {
var row businessDevelopmentBaseInfoRow
err := tx.Where("user_id = ?", leader.UserID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
id, idErr := utils.NextID()
if idErr != nil {
return serverError("id_generate_failed", idErr.Error())
}
leadUserID := leader.UserID
return tx.Create(&businessDevelopmentBaseInfoRow{
ID: id,
Origin: leader.Origin,
BDLeadUserID: &leadUserID,
UserID: leader.UserID,
Contact: leader.Contact,
Region: leader.RegionID,
MemberQuantity: 0,
CreateTime: now,
UpdateTime: now,
CreateUser: operatorID,
UpdateUser: operatorID,
}).Error
}
if err != nil {
return serverError("bd_info_query_failed", err.Error())
}
if row.BDLeadUserID != nil && *row.BDLeadUserID > 0 {
return badRequest("user_already_bound_bd_leader", "user already has a BD leader")
}
updates := map[string]any{
"bd_lead_user_id": leader.UserID,
"region": leader.RegionID,
"update_time": now,
"update_user": operatorID,
}
if leader.Contact != "" {
updates["contact"] = leader.Contact
}
if leader.Origin != "" {
updates["sys_origin"] = leader.Origin
}
if err := tx.Model(&businessDevelopmentBaseInfoRow{}).
Where("id = ?", row.ID).
Updates(updates).Error; err != nil {
return serverError("bd_info_update_failed", err.Error())
}
return nil
}
func upsertUserHistoryBD(tx *gorm.DB, userID int64, operatorID int64, now time.Time) error {
var row userHistoryIdentityRow
err := tx.Where("user_id = ?", userID).First(&row).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return tx.Create(&userHistoryIdentityRow{
UserID: userID,
IsBD: true,
CreateTime: now,
UpdateTime: now,
CreateUser: operatorID,
UpdateUser: operatorID,
}).Error
}
if err != nil {
return serverError("history_identity_query_failed", err.Error())
}
if err := tx.Model(&userHistoryIdentityRow{}).
Where("user_id = ?", userID).
Updates(map[string]any{
"is_bd": true,
"update_time": now,
"update_user": operatorID,
}).Error; err != nil {
return serverError("history_identity_update_failed", err.Error())
}
return nil
}
func bdLeaderRowToView(leader bdLeaderRow, user userBaseInfoRow) BDLeaderView {
return BDLeaderView{
ID: ID(leader.ID),
UserID: ID(leader.UserID),
Account: user.Account,
UserAvatar: user.UserAvatar,
UserNickname: user.UserNickname,
Contact: leader.Contact,
RegionID: leader.RegionID,
OriginSys: leader.Origin,
CreateTime: leader.CreateTime,
UpdateTime: leader.UpdateTime,
}
}
func bdLeaderListRowToView(row bdLeaderListRow) BDLeaderView {
return BDLeaderView{
ID: ID(row.ID),
UserID: ID(row.UserID),
Account: row.Account,
UserAvatar: row.UserAvatar,
UserNickname: row.UserNickname,
Contact: row.Contact,
RegionID: row.RegionID,
OriginSys: row.Origin,
CreateTime: row.CreateTime,
UpdateTime: row.UpdateTime,
}
}

View File

@ -14,9 +14,13 @@ import (
)
type teamManagerInfoRow struct {
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"`
ID int64 `gorm:"column:id;primaryKey"`
UserID int64 `gorm:"column:user_id"`
Origin string `gorm:"column:sys_origin"`
Contact string `gorm:"column:contact"`
RegionID string `gorm:"column:region_id"`
CreateUser int64 `gorm:"column:create_user"`
UpdateUser int64 `gorm:"column:update_user"`
}
func (teamManagerInfoRow) TableName() string {
@ -324,25 +328,8 @@ func (s *Service) UnbanUser(ctx context.Context, user AuthUser, req UnbanUserReq
}
func (s *Service) ensureManager(ctx context.Context, user AuthUser) error {
if user.UserID <= 0 {
return unauthorized("invalid_user", "authenticated user is required")
}
if s.db == nil {
return serviceUnavailable("database_unavailable", "database is unavailable")
}
var count int64
err := s.db.WithContext(ctx).
Table("team_manager_base_info").
Where("user_id = ?", user.UserID).
Count(&count).Error
if err != nil {
return serverError("manager_query_failed", err.Error())
}
if count == 0 {
return forbidden("not_manager", "user is not manager")
}
return nil
_, err := s.requireManagerInfo(ctx, user)
return err
}
func (s *Service) findUserView(ctx context.Context, account string) (UserView, error) {

View File

@ -167,6 +167,73 @@ func TestGetProfileToleratesRechargeQueryFailure(t *testing.T) {
}
}
func TestAddBDLeaderCreatesLeaderAndListEntry(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db,
userBaseInfoRow{ID: 2, Account: "2002", UserNickname: "leader", OriginSys: "LIKEI"},
)
resp, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{
UserID: ID(2),
Contact: "whatsapp",
})
if err != nil {
t.Fatalf("AddBDLeader() error = %v", err)
}
if !resp.Success || resp.Leader.UserID.Int64() != 2 || resp.Leader.RegionID != "SA" {
t.Fatalf("resp = %+v, want created leader for user 2 in manager region", resp)
}
var bdInfo businessDevelopmentBaseInfoRow
if err := db.Where("user_id = ?", int64(2)).First(&bdInfo).Error; err != nil {
t.Fatalf("load bd info: %v", err)
}
if bdInfo.BDLeadUserID == nil || *bdInfo.BDLeadUserID != 2 || bdInfo.Region != "SA" {
t.Fatalf("bd info = %+v, want self-linked BD leader info", bdInfo)
}
var identity userHistoryIdentityRow
if err := db.Where("user_id = ?", int64(2)).First(&identity).Error; err != nil {
t.Fatalf("load identity: %v", err)
}
if !identity.IsBD {
t.Fatalf("identity = %+v, want is_bd=true", identity)
}
list, err := service.ListBDLeaders(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"})
if err != nil {
t.Fatalf("ListBDLeaders() error = %v", err)
}
if list.Count != 1 || list.Records[0].UserID.Int64() != 2 || list.Records[0].Account != "2002" {
t.Fatalf("list = %+v, want one created leader", list)
}
}
func TestAddBDLeaderRejectsExistingLeader(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "LIKEI"})
if err := db.Create(&bdLeaderRow{ID: 10, UserID: 2, Origin: "LIKEI", RegionID: "SA", CreateUser: 1}).Error; err != nil {
t.Fatalf("seed bd leader: %v", err)
}
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "user_already_bd_leader")
}
func TestAddBDLeaderRejectsTargetFromOtherOrigin(t *testing.T) {
service, _ := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
seedManager(t, db, 1)
seedUsers(t, db, userBaseInfoRow{ID: 2, Account: "2002", OriginSys: "ATYOU"})
_, err := service.AddBDLeader(context.Background(), AuthUser{UserID: 1, SysOrigin: "LIKEI"}, AddBDLeaderRequest{UserID: ID(2)})
assertAppErrorCode(t, err, "bd_leader_origin_mismatch")
}
func TestSendPropsRejectsNonAdminFree(t *testing.T) {
service, fake := newManagerCenterTestService(t)
db := service.db.(*gorm.DB)
@ -480,6 +547,10 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
if err := db.AutoMigrate(
&teamManagerInfoRow{},
&userBaseInfoRow{},
&bdLeaderRow{},
&businessDevelopmentBaseInfoRow{},
&userHistoryIdentityRow{},
&administratorRow{},
&propsSourceRecordRow{},
&propsNobleVIPAbilityRow{},
&model.VipLevelConfig{},
@ -498,7 +569,7 @@ func newManagerCenterTestService(t *testing.T) (*Service, *fakeManagerGateway) {
func seedManager(t *testing.T, db *gorm.DB, userID int64) {
t.Helper()
if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI"}).Error; err != nil {
if err := db.Create(&teamManagerInfoRow{ID: userID, UserID: userID, Origin: "LIKEI", RegionID: "SA"}).Error; err != nil {
t.Fatalf("seed manager: %v", err)
}
}

View File

@ -6,6 +6,7 @@ import (
"net/http"
"strconv"
"strings"
"time"
"chatapp3-golang/internal/common"
"chatapp3-golang/internal/integration"
@ -178,6 +179,35 @@ type UnbanUserResponse struct {
AccountStatus string `json:"accountStatus"`
}
type BDLeaderListResponse struct {
Count int `json:"count"`
Records []BDLeaderView `json:"records"`
}
type BDLeaderView struct {
ID ID `json:"id"`
UserID ID `json:"userId"`
Account string `json:"account"`
UserAvatar string `json:"userAvatar"`
UserNickname string `json:"userNickname"`
Contact string `json:"contact,omitempty"`
RegionID string `json:"regionId,omitempty"`
OriginSys string `json:"originSys,omitempty"`
CreateTime time.Time `json:"createTime,omitempty"`
UpdateTime time.Time `json:"updateTime,omitempty"`
}
type AddBDLeaderRequest struct {
UserID ID `json:"userId"`
Contact string `json:"contact"`
RegionID string `json:"regionId"`
}
type AddBDLeaderResponse struct {
Success bool `json:"success"`
Leader BDLeaderView `json:"leader"`
}
func badRequest(code, message string) error {
return NewAppError(http.StatusBadRequest, code, message)
}

View File

@ -67,6 +67,7 @@ func (s *Service) SendRegionBroadcast(ctx context.Context, req RegionBroadcastRe
}
}
refreshHighWinMessage(data, messageType)
refreshCPRelationMessage(data, messageType)
body := map[string]any{
"type": messageType,
@ -208,6 +209,25 @@ func highWinPlayTarget(messageType string) string {
}
}
func refreshCPRelationMessage(data map[string]any, messageType string) {
if strings.TrimSpace(messageType) != "CP_RELATION_BROADCAST" {
return
}
user := firstTextValue(data["userNickname"], data["nickname"], data["account"], data["actualAccount"], data["userId"])
cpUser := firstTextValue(data["cpUserNickname"], data["acceptNickname"], data["toUserName"], data["cpUserAccount"], data["acceptAccount"], data["cpUserId"], data["acceptUserId"])
if user == "" || cpUser == "" {
return
}
switch strings.ToUpper(strings.TrimSpace(firstTextValue(data["relationType"]))) {
case "BROTHER", "BROTHERS":
data["msg"] = fmt.Sprintf("%s与%s结为兄弟", user, cpUser)
case "SISTERS", "SISTER":
data["msg"] = fmt.Sprintf("%s与%s结为姐妹", user, cpUser)
default:
data["msg"] = fmt.Sprintf("%s与%s get cp relation", user, cpUser)
}
}
func firstTextValue(values ...any) string {
for _, value := range values {
text := strings.TrimSpace(fmt.Sprint(value))

View File

@ -179,6 +179,51 @@ func TestSendRegionBroadcastRefreshesHighWinMessageWithProfile(t *testing.T) {
}
}
func TestSendRegionBroadcastRefreshesCPRelationMessageWithProfiles(t *testing.T) {
service, db := newRegionIMGroupTestService(t)
fakeIM := &fakeIMGateway{}
service.im = fakeIM
now := time.Now()
if err := db.Create(&model.VoiceRoomRegionIMGroup{
ID: 3011,
SysOrigin: "LIKEI",
RegionCode: "AR",
RegionName: "Arab",
GroupID: "@region-ar",
GroupName: "region-ar",
Status: imGroupStatusActive,
CreateTime: now,
UpdateTime: now,
}).Error; err != nil {
t.Fatalf("seed im group: %v", err)
}
_, err := service.SendRegionBroadcast(context.Background(), RegionBroadcastRequest{
SysOrigin: "LIKEI",
Type: "CP_RELATION_BROADCAST",
Data: map[string]any{
"userId": "1001",
"acceptUserId": "1002",
"relationType": "SISTERS",
"msg": "1001与1002结为姐妹",
},
})
if err != nil {
t.Fatalf("SendRegionBroadcast() error = %v", err)
}
body, ok := fakeIM.sent[0].body.(map[string]any)
if !ok {
t.Fatalf("sent body type = %T", fakeIM.sent[0].body)
}
data, ok := body["data"].(map[string]any)
if !ok {
t.Fatalf("sent data type = %T", body["data"])
}
if data["msg"] != "sender与claimer结为姐妹" {
t.Fatalf("msg = %v", data["msg"])
}
}
func TestSendRegionBroadcastPrefersJavaRegionResolver(t *testing.T) {
service, db := newRegionIMGroupTestService(t)
service.regionResolver = fakeRegionResolver{