package srv_im import ( "encoding/json" "errors" "fmt" "net/http" "net/url" "sort" "strings" "time" "91porn-server/app/service/adser" "91porn-server/common" "91porn-server/common/crypt" "91porn-server/common/enum/imad" "91porn-server/common/httputil" "91porn-server/common/imclient" "91porn-server/common/log" "91porn-server/common/stderr" "91porn-server/common/redis" "91porn-server/models/v/imusermod" "91porn-server/skd/skdg" "91porn-server/web/webg" ) // srvImRedis 自适应当前进程可用的 Redis 客户端: // skd cron 调用 SendAdNotify 时 webg.Redis 为 nil,必须用 skdg.Redis; // web 服务调用时反之。两端写的是同一个 Redis 实例。 func srvImRedis() *redis.Client { if skdg.Redis != nil { return skdg.Redis } if webg.Redis != nil { return webg.Redis } return nil } // srvImAdCenterCfg 同样的自适应思路:从当前进程的 Conf 里读 AdCenter 配置。 type srvImAdCenter struct { ApiDomain string MerchantCode string AppCode string AesKey string } func srvImAdCenterCfg() srvImAdCenter { if skdg.Conf != nil { c := skdg.Conf.AdCenter if c.ApiDomain != "" || c.MerchantCode != "" || c.AppCode != "" || c.AesKey != "" { return srvImAdCenter{ApiDomain: c.ApiDomain, MerchantCode: c.MerchantCode, AppCode: c.AppCode, AesKey: c.AesKey} } } if webg.Conf != nil { c := webg.Conf.AdCenter return srvImAdCenter{ApiDomain: c.ApiDomain, MerchantCode: c.MerchantCode, AppCode: c.AppCode, AesKey: c.AesKey} } return srvImAdCenter{} } const ( adNotifyType = "AD_NOTIFY" adNotifyTargetSpecified = "specified" adNotifyTargetOnline = "online" adNotifyOnlineScanLimit = 1000 adNotifyPassthroughBatch = 100 adNotifyVisibleCacheTTL = 24 * time.Hour maxAdNotifyAdsPerSlot = 10 ) type AdNotifyReq struct { UIDs []uint64 `json:"uids"` UserIDs []uint64 `json:"userIds"` Position string `json:"position"` Positions []string `json:"positions"` Target string `json:"target"` TraceID string `json:"traceId"` MaxUsers int `json:"maxUsers"` ChannelType string `json:"channelType"` } type AdNotifyResp struct { TraceID string `json:"traceId"` AdCount int `json:"adCount"` Positions []string `json:"positions"` Target string `json:"target"` CandidateUserCount int `json:"candidateUserCount"` TargetUserCount int `json:"targetUserCount"` OnlineUserCount int `json:"onlineUserCount"` OnlineStatusCheckedCount int `json:"onlineStatusCheckedCount"` OnlineStatusReturnedCount int `json:"onlineStatusReturnedCount"` OnlineStatusMissingCount int `json:"onlineStatusMissingCount"` OnlineStatusInvisibleCount int `json:"onlineStatusInvisibleCount"` MessageID string `json:"messageId"` MessageIDs []string `json:"messageIds"` MessageType int `json:"messageType"` ReceiverCount int `json:"receiverCount"` ImCreatedAt int64 `json:"imCreatedAt"` Sent bool `json:"sent"` } type adNotifyTarget struct { UID uint64 IMUserID int64 } type adNotifyPassthroughSummary struct { MessageID string MessageIDs []string MessageType int ReceiverCount int CreatedAt int64 } type adNotifyOnlineStatusSummary struct { Targets []adNotifyTarget CheckedCount int ReturnedCount int MissingCount int InvisibleCount int } func SendAdNotify(req AdNotifyReq) (AdNotifyResp, stderr.Code, string) { now := time.Now() positionsReq := normalizeAdNotifyPositions(req) for _, position := range positionsReq { if !imad.IsPositionCode(position) { return AdNotifyResp{}, stderr.ErrParamError, "positions 必须为 IM 广告位标识" } } if strings.TrimSpace(req.TraceID) == "" { req.TraceID = fmt.Sprintf("%s_%d", adNotifyType, now.UnixNano()) } targetMode := normalizeAdNotifyTarget(req.Target) if targetMode == "" { return AdNotifyResp{}, stderr.ErrParamError, "target 仅支持 online 或 specified" } positions, adCount := resolveAdNotifyPositions(positionsReq) resp := AdNotifyResp{ TraceID: req.TraceID, AdCount: adCount, Positions: positions, Target: targetMode, } if len(positions) == 0 { return resp, stderr.Success, "" } payload, err := buildAdNotifyPayload(req, positions, now) if err != nil { return resp, stderr.ErrParamError, err.Error() } extInfo, err := json.Marshal(payload) if err != nil { return resp, stderr.ErrParamError, err.Error() } // online 模式:调 SendAppPassthrough 一次性广播给本商户/租户下所有在线用户, // 由 IM 平台自己 fan-out,省掉扫表 + 查在线 + 设可见 + 分批的流程 if targetMode == adNotifyTargetOnline { return sendAdNotifyBroadcast(resp, req, string(extInfo)) } // specified 模式:仍按 uid 拉取目标、查在线状态、分批走 SendOnlinePassthrough targets, code, msg := collectAdNotifyTargets(targetMode, req) if code != stderr.Success { return resp, code, msg } resp.CandidateUserCount = len(targets) resp.TargetUserCount = len(targets) if len(targets) == 0 { return resp, stderr.Success, "" } ensureAdNotifyOnlineStatusVisible(targets) onlineSummary, code, msg := filterOnlineAdNotifyTargets(targets) if code != stderr.Success { return resp, code, msg } onlineTargets := onlineSummary.Targets resp.OnlineUserCount = len(onlineTargets) resp.OnlineStatusCheckedCount = onlineSummary.CheckedCount resp.OnlineStatusReturnedCount = onlineSummary.ReturnedCount resp.OnlineStatusMissingCount = onlineSummary.MissingCount resp.OnlineStatusInvisibleCount = onlineSummary.InvisibleCount if len(onlineTargets) == 0 { return resp, stderr.Success, "" } result, code, msg := sendAdNotifyPassthrough(onlineTargets, imclient.OnlinePassthroughRequest{ PassthroughType: adNotifyType, Content: string(extInfo), ExtInfo: string(extInfo), ChannelType: req.ChannelType, }) if code != stderr.Success { return resp, code, msg } resp.MessageID = result.MessageID resp.MessageIDs = result.MessageIDs resp.MessageType = result.MessageType resp.ReceiverCount = result.ReceiverCount resp.ImCreatedAt = result.CreatedAt resp.Sent = true return resp, stderr.Success, "" } // logAdNotifyBroadcastCurl 把广播请求拼成等价 curl 命令打日志(INFO 级别)。 // 仅包含 IM SDK 显式 header(X-Merchant-Code / X-App-Key / X-Client-Id / token), // 签名相关 header 由 imclient 内部按请求计算,curl 复现时需要去掉签名校验或对接时再加。 func logAdNotifyBroadcastCurl(traceID, token string, body imclient.AppPassthroughRequest) { cfg := srvImV2Cfg() base := strings.TrimRight(cfg.BaseURL, "/") u := fmt.Sprintf("%s/api/endpoint/%s/%s/message/passthrough/send/batch/all", base, url.PathEscape(cfg.MerchantCode), url.PathEscape(cfg.TenantCode)) raw, _ := json.Marshal(body) curl := fmt.Sprintf(`curl -X POST '%s' `+ `-H 'Content-Type: application/json' `+ `-H 'Accept: application/json' `+ `-H 'X-Merchant-Code: %s' `+ `-H 'X-App-Key: %s' `+ `-H 'X-Client-Id: %s' `+ `-H 'token: %s' `+ `-d '%s'`, u, cfg.MerchantCode, cfg.AppKey, cfg.ClientID, token, string(raw)) log.Info("SendAdNotify broadcast curl", log.Any("traceId", traceID), log.Any("curl", curl)) } // sendAdNotifyBroadcast 走 IM 平台的"全在线广播"接口 // POST /api/endpoint/{merchantCode}/{tenantCode}/message/passthrough/send/batch/all // 一次调用就把消息推给本商户/租户下所有在线用户,无需先查用户列表 func sendAdNotifyBroadcast(resp AdNotifyResp, req AdNotifyReq, extInfo string) (AdNotifyResp, stderr.Code, string) { c := newWebSDKClient() body := imclient.AppPassthroughRequest{ PassthroughType: adNotifyType, Content: extInfo, ExtInfo: extInfo, ChannelType: req.ChannelType, } var result *imclient.PassthroughResult err := withAppToken(c, func(token string) error { // 打印等价 curl,方便联调 logAdNotifyBroadcastCurl(req.TraceID, token, body) var sendErr error result, sendErr = c.SendAppPassthrough(body, token) return sendErr }) if err != nil { log.Error("SendAdNotify broadcast failed", log.Any("traceId", req.TraceID), log.E(err)) return resp, stderr.Failure, err.Error() } if result != nil { resp.MessageID = result.MessageID resp.MessageType = result.MessageType resp.ReceiverCount = result.ReceiverCount resp.OnlineUserCount = result.ReceiverCount // 平台返回的就是实际推送到的在线数 resp.ImCreatedAt = result.CreatedAt } resp.Sent = true return resp, stderr.Success, "" } func normalizeAdNotifyTarget(target string) string { switch strings.ToLower(strings.TrimSpace(target)) { case "": return adNotifyTargetSpecified case adNotifyTargetSpecified: return adNotifyTargetSpecified case adNotifyTargetOnline: return adNotifyTargetOnline default: return "" } } func collectAdNotifyTargets(targetMode string, req AdNotifyReq) ([]adNotifyTarget, stderr.Code, string) { switch targetMode { case adNotifyTargetOnline: return scanAdNotifyIMUsers(req.MaxUsers) case adNotifyTargetSpecified: uids := normalizeUIDs(append(req.UIDs, req.UserIDs...)) if len(uids) == 0 { return nil, stderr.ErrParamError, "userIds不能为空;全量在线推送请传 target=online" } return resolveAdNotifyTargetsByUIDs(uids) default: return nil, stderr.ErrParamError, "target 仅支持 online 或 specified" } } func resolveAdNotifyTargetsByUIDs(uids []uint64) ([]adNotifyTarget, stderr.Code, string) { // uid → imUserId 走 imusermod(唯一真源) byUID, err := imusermod.IMUserIDMapByUIDs(uids) if err != nil { return nil, stderr.Failure, err.Error() } targets := make([]adNotifyTarget, 0, len(byUID)) for _, uid := range uids { imUserID, ok := byUID[uid] if !ok { continue } targets = append(targets, adNotifyTarget{UID: uid, IMUserID: imUserID}) } return targets, stderr.Success, "" } func scanAdNotifyIMUsers(maxUsers int) ([]adNotifyTarget, stderr.Code, string) { targets := make([]adNotifyTarget, 0) // 直接扫 im_user 表(只含已注册 IM 的用户),按 uid 游标分页。 // 表本身就小,且天然只有"有 imUserId 的用户",无需过滤 imUserId>0。 var lastUID uint64 for { limit := int64(adNotifyOnlineScanLimit) if maxUsers > 0 { remaining := int64(maxUsers - len(targets)) if remaining <= 0 { break } if remaining < limit { limit = remaining } } list, err := imusermod.ListAfterUID(lastUID, limit) if err != nil { return nil, stderr.Failure, err.Error() } if len(list) == 0 { break } for i := range list { u := list[i] if u.UID == 0 || u.IMUserID <= 0 { continue } targets = append(targets, adNotifyTarget{UID: u.UID, IMUserID: u.IMUserID}) } // 本批末条 uid 即下一页游标(按 uid 升序) lastUID = list[len(list)-1].UID // 本批不满 limit,说明已扫到末尾 if int64(len(list)) < limit { break } } return targets, stderr.Success, "" } func resolveAdNotifyPositions(requested []string) ([]string, int) { if len(requested) > 0 { return requested, 0 } slots, err := webAdSlots() if err != nil { return nil, 0 } positions := listAdPositionsWithAds(slots) adCount := 0 for _, position := range positions { ads := selectAdDetailsByPosition(slots, position) adCount += len(ads) } return positions, adCount } func normalizeAdNotifyPositions(req AdNotifyReq) []string { positions := make([]string, 0, len(req.Positions)+1) if position := strings.TrimSpace(req.Position); position != "" { positions = append(positions, position) } positions = append(positions, req.Positions...) seen := make(map[string]struct{}, len(positions)) normalized := make([]string, 0, len(positions)) for _, position := range positions { position = strings.TrimSpace(position) if position == "" { continue } if _, ok := seen[position]; ok { continue } seen[position] = struct{}{} normalized = append(normalized, position) } return normalized } func buildAdNotifyPayload(req AdNotifyReq, positions []string, now time.Time) (map[string]interface{}, error) { return map[string]interface{}{ "type": adNotifyType, "positions": positions, "traceId": req.TraceID, "createdAt": now.UnixMilli(), }, nil } func ensureAdNotifyOnlineStatusVisible(targets []adNotifyTarget) { if len(targets) == 0 { return } c := newWebSDKClient() seen := make(map[int64]struct{}, len(targets)) successCount := 0 for _, target := range targets { if target.IMUserID <= 0 { continue } if _, ok := seen[target.IMUserID]; ok { continue } seen[target.IMUserID] = struct{}{} if !shouldEnsureAdNotifyOnlineStatusVisible(target.IMUserID) { continue } if err := withAppToken(c, func(token string) error { return c.SetOnlineStatus(imclient.SetOnlineStatusRequest{ UserID: target.IMUserID, ShowOnlineStatus: true, }, token) }); err != nil { log.Warn("SendAdNotify SetOnlineStatusVisible failed", log.Any("uid", target.UID), log.Any("imUserId", target.IMUserID), log.E(err)) continue } markAdNotifyOnlineStatusVisible(target.IMUserID) successCount++ } log.Info("SendAdNotify SetOnlineStatusVisible summary", log.Any("candidateCount", len(seen)), log.Any("successCount", successCount)) } func shouldEnsureAdNotifyOnlineStatusVisible(imUserID int64) bool { r := srvImRedis() if r == nil { return true } cached, err := r.Get(adNotifyVisibleCacheKey(imUserID)) if err != nil { log.Warn("SendAdNotify visible cache get failed", log.Any("imUserId", imUserID), log.E(err)) return true } return cached == nil } func markAdNotifyOnlineStatusVisible(imUserID int64) { r := srvImRedis() if r == nil { return } if err := r.Set(adNotifyVisibleCacheKey(imUserID), 1, adNotifyVisibleCacheTTL); err != nil { log.Warn("SendAdNotify visible cache set failed", log.Any("imUserId", imUserID), log.E(err)) } } func adNotifyVisibleCacheKey(imUserID int64) string { return fmt.Sprintf("im:online_status_visible:%d", imUserID) } func filterOnlineAdNotifyTargets(targets []adNotifyTarget) (adNotifyOnlineStatusSummary, stderr.Code, string) { imUserIDs := make([]int64, 0, len(targets)) seen := make(map[int64]struct{}, len(targets)) for _, target := range targets { if target.IMUserID <= 0 { continue } if _, ok := seen[target.IMUserID]; ok { continue } seen[target.IMUserID] = struct{}{} imUserIDs = append(imUserIDs, target.IMUserID) } summary := adNotifyOnlineStatusSummary{CheckedCount: len(imUserIDs)} statusByIMUserID := make(map[int64]bool, len(imUserIDs)) invisibleCount := 0 c := newWebSDKClient() for _, batch := range chunkInt64s(imUserIDs, maxIMOnlineStatusBatchSize) { var statuses []imclient.OnlineStatus err := withAppToken(c, func(token string) error { var statusErr error statuses, statusErr = c.BatchOnlineStatus(imclient.BatchOnlineStatusRequest{UserIDs: batch}, token) return statusErr }) if err != nil { return summary, stderr.Failure, err.Error() } for _, item := range statuses { if item.UserID <= 0 { continue } statusByIMUserID[item.UserID] = item.Online if !item.Visible { invisibleCount++ } } } summary.ReturnedCount = len(statusByIMUserID) summary.MissingCount = summary.CheckedCount - summary.ReturnedCount if summary.MissingCount < 0 { summary.MissingCount = 0 } summary.InvisibleCount = invisibleCount summary.Targets = onlineAdNotifyTargets(targets, statusByIMUserID) log.Info("SendAdNotify online status summary", log.Any("checkedCount", summary.CheckedCount), log.Any("returnedCount", summary.ReturnedCount), log.Any("missingCount", summary.MissingCount), log.Any("onlineCount", len(summary.Targets)), log.Any("invisibleCount", summary.InvisibleCount), log.Any("sampleIMUserIds", sampleInt64s(imUserIDs, 10)), ) return summary, stderr.Success, "" } func sampleInt64s(values []int64, limit int) []int64 { if limit <= 0 || len(values) == 0 { return nil } if len(values) < limit { limit = len(values) } return append([]int64(nil), values[:limit]...) } func onlineAdNotifyTargets(targets []adNotifyTarget, statusByIMUserID map[int64]bool) []adNotifyTarget { online := make([]adNotifyTarget, 0, len(targets)) seen := make(map[int64]struct{}, len(targets)) for _, target := range targets { if target.IMUserID <= 0 || !statusByIMUserID[target.IMUserID] { continue } if _, ok := seen[target.IMUserID]; ok { continue } seen[target.IMUserID] = struct{}{} online = append(online, target) } return online } func sendAdNotifyPassthrough(targets []adNotifyTarget, req imclient.OnlinePassthroughRequest) (adNotifyPassthroughSummary, stderr.Code, string) { var summary adNotifyPassthroughSummary c := newWebSDKClient() for _, batch := range chunkAdNotifyTargets(targets, adNotifyPassthroughBatch) { receiverIDs := make([]int64, 0, len(batch)) for _, target := range batch { receiverIDs = append(receiverIDs, target.IMUserID) } req.ReceiverIDSet = receiverIDs var result *imclient.PassthroughResult err := withAppToken(c, func(token string) error { var sendErr error result, sendErr = c.SendOnlinePassthrough(req, token) return sendErr }) if err != nil { return summary, stderr.Failure, err.Error() } if result == nil { continue } if summary.MessageID == "" { summary.MessageID = result.MessageID } summary.MessageType = result.MessageType summary.ReceiverCount += result.ReceiverCount summary.CreatedAt = result.CreatedAt summary.MessageIDs = append(summary.MessageIDs, result.MessageID) } return summary, stderr.Success, "" } func chunkAdNotifyTargets(targets []adNotifyTarget, size int) [][]adNotifyTarget { if size <= 0 { size = adNotifyPassthroughBatch } chunks := make([][]adNotifyTarget, 0, (len(targets)+size-1)/size) for start := 0; start < len(targets); start += size { end := start + size if end > len(targets) { end = len(targets) } chunks = append(chunks, targets[start:end]) } return chunks } type webAdCenterResp struct { Code int `json:"code"` Msg string `json:"msg"` Data string `json:"data"` } func webAdSlots() ([]adser.AdSlot, error) { cfg := srvImAdCenterCfg() if cfg.ApiDomain == "" || cfg.MerchantCode == "" || cfg.AppCode == "" || cfg.AesKey == "" { return nil, errors.New("adCenter 未配置") } foreverCacheKey := fmt.Sprintf("jtAdForever-%s-%s", cfg.MerchantCode, cfg.AppCode) redisKey := fmt.Sprintf("jtAd-%s-%s", cfg.MerchantCode, cfg.AppCode) r := srvImRedis() if r != nil { str, err := r.Get(redisKey) if err != nil { log.Error(fmt.Sprintf("IM AdNotify 获取广告缓存异常:%v", err)) } if str != nil { var cached []adser.AdSlot if err = json.Unmarshal([]byte(*str), &cached); err == nil { return cached, nil } } } req := adser.JtAdvertiseReq{ MerchantCode: cfg.MerchantCode, AppCode: cfg.AppCode, AdStatus: 1, } var serverResp *webAdCenterResp body, _ := json.Marshal(req) code, err := httputil.DefaultClientPostJsonWithResp(&serverResp, cfg.ApiDomain+"/openapi/getAdvertiseList", nil, body) if err != nil { return webAdSlotsFromLastTime(foreverCacheKey, err) } if code != http.StatusOK { return webAdSlotsFromLastTime(foreverCacheKey, fmt.Errorf("response status %d", code)) } if serverResp == nil || serverResp.Code != 0 { msg := "" if serverResp != nil { msg = serverResp.Msg } return webAdSlotsFromLastTime(foreverCacheKey, fmt.Errorf("response code error: %s", msg)) } adsData, err := crypt.AdDecrypt(serverResp.Data, cfg.AesKey) if err != nil { return nil, err } var slots []adser.AdSlot if err = json.Unmarshal([]byte(adsData), &slots); err != nil { return nil, err } for i := range slots { sort.Slice(slots[i].AdDetailInfoList, func(j, k int) bool { return slots[i].AdDetailInfoList[j].Sort < slots[i].AdDetailInfoList[k].Sort }) } if r := srvImRedis(); r != nil { common.Go(func() { data, err := json.Marshal(&slots) if err != nil { return } _ = r.Set(foreverCacheKey, data, time.Hour*72) _ = r.Set(redisKey, data, time.Minute) }) } return slots, nil } func webAdSlotsFromLastTime(cacheKey string, fallback error) ([]adser.AdSlot, error) { r := srvImRedis() if r == nil { return nil, fallback } str, err := r.Get(cacheKey) if err != nil || str == nil { return nil, fallback } var slots []adser.AdSlot if err = json.Unmarshal([]byte(*str), &slots); err != nil { return nil, fallback } return slots, nil } func listAdPositionsWithAds(slots []adser.AdSlot) []string { positions := make([]string, 0) seen := make(map[string]struct{}) for _, slot := range slots { code := strings.TrimSpace(slot.AdvertiseLocationCode) if !imad.IsPositionCode(code) || len(slot.AdDetailInfoList) == 0 { continue } if _, ok := seen[code]; ok { continue } seen[code] = struct{}{} positions = append(positions, code) } sort.Strings(positions) return positions } func selectAdDetailsByPosition(slots []adser.AdSlot, position string) []adser.AdDetailInfo { position = strings.TrimSpace(position) matches := make([]adser.AdDetailInfo, 0) for _, slot := range slots { if strings.TrimSpace(slot.AdvertiseLocationCode) != position { continue } matches = append(matches, slot.AdDetailInfoList...) } if len(matches) > maxAdNotifyAdsPerSlot { matches = matches[:maxAdNotifyAdsPerSlot] } return matches }