Files
rootandClaude Opus 5 8679200f41 Initial commit
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-15 13:57:10 +08:00

711 lines
21 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 显式 headerX-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
}