Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
+710
View File
@@ -0,0 +1,710 @@
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
}