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
}
+785
View File
@@ -0,0 +1,785 @@
package srv_im
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"sync"
"time"
"91porn-server/common/imclient"
"91porn-server/common/stderr"
"91porn-server/models/v/imusermod"
"91porn-server/models/v/sessionmod"
"91porn-server/models/v/usermod"
"91porn-server/skd/skdg"
"91porn-server/web/webg"
"go.mongodb.org/mongo-driver/bson"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.mongodb.org/mongo-driver/mongo/options"
)
const (
maxIMOnlineStatusBatchSize = 100
defaultIMTokenTTL = int64(86400)
imUserSyncPageSize = int64(500)
)
var webAppTokenCache = struct {
sync.Mutex
token string
expiresAt time.Time
}{}
type UserListReq struct {
UID *uint64 `json:"uid"`
HasIM *bool `json:"hasIm"`
IsUp *bool `json:"isUp"`
PageNum int64 `json:"pageNum"`
PageSize int64 `json:"pageSize"`
}
type UserInfo struct {
UID uint64 `json:"uid"`
ImUserID int64 `json:"imUserId"`
NickName string `json:"nickName"`
Avatar string `json:"avatar"`
IsUp bool `json:"isUp"`
}
type UserListResp struct {
Total int64 `json:"total"`
List []UserInfo `json:"list"`
}
type SyncUsersReq struct {
UIDs []uint64 `json:"uids" binding:"required"`
}
type SyncUsersResp struct {
Total int `json:"total"`
Success int `json:"success"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
}
type FriendAddDirectReq struct {
UID uint64 `json:"uid" binding:"required"`
PeerUID uint64 `json:"peerUid" binding:"required"`
}
type OnlineStatusReq struct {
UIDs []uint64 `json:"uids" binding:"required"`
}
type OnlineStatusInfo struct {
UID uint64 `json:"uid"`
ImUserID int64 `json:"imUserId"`
Online bool `json:"online"`
}
type PassthroughReq struct {
UIDs []uint64 `json:"uids"`
UserIDs []uint64 `json:"userIds"`
PassthroughType string `json:"passthroughType"`
EventType string `json:"eventType"`
Content string `json:"content"`
ExtInfo string `json:"extInfo"`
Payload map[string]interface{} `json:"payload"`
ChannelType string `json:"channelType"`
SenderUID uint64 `json:"senderUid"`
AllApp bool `json:"allApp"`
OnlineOnly bool `json:"onlineOnly"`
}
type PassthroughResp struct {
TargetCount int `json:"targetCount"`
Result *imclient.PassthroughResult `json:"result,omitempty"`
}
type SendMessageReq struct {
UID uint64 `json:"uid" binding:"required"`
PeerUID uint64 `json:"peerUid" binding:"required"`
Content string `json:"content" binding:"required"`
}
type SendMessageResp struct {
UID uint64 `json:"uid"`
ImUserID int64 `json:"imUserId"`
PeerUID uint64 `json:"peerUid"`
PeerImUserID int64 `json:"peerImUserId"`
Content string `json:"content"`
MessageType int `json:"messageType"`
}
type SyncDialogUsersStats struct {
Enabled bool `json:"enabled"`
Total int `json:"total"`
Skipped int `json:"skipped"`
Success int `json:"success"`
Failed int `json:"failed"`
FriendSuccess int `json:"friendSuccess"`
FriendFailed int `json:"friendFailed"`
}
type imFriendPair struct {
UID uint64
PeerUID uint64
}
func ListUsers(req UserListReq) (UserListResp, stderr.Code, string) {
if req.PageNum <= 0 {
req.PageNum = 1
}
if req.PageSize <= 0 || req.PageSize > 100 {
req.PageSize = 20
}
filter := buildUserListFilter(req)
skip := (req.PageNum - 1) * req.PageSize
opts := options.Find().
SetSort(bson.M{"uid": -1}).
SetSkip(skip).
SetLimit(req.PageSize)
var total int64
users, _, err := usermod.FetchList(filter, opts, &total)
if err != nil {
return UserListResp{}, stderr.Failure, err.Error()
}
resp := UserListResp{Total: total, List: make([]UserInfo, 0, len(users))}
for _, user := range users {
resp.List = append(resp.List, userInfoFromUser(user))
}
return resp, stderr.Success, ""
}
func SyncUsers(req SyncUsersReq) (SyncUsersResp, stderr.Code, string) {
uids := normalizeUIDs(req.UIDs)
resp := SyncUsersResp{Total: len(uids)}
if len(uids) == 0 {
return resp, stderr.ErrParamError, "uids不能为空"
}
c := newWebSDKClient()
if !c.Enabled() {
return resp, stderr.Failure, "IM 未配置"
}
for _, uid := range uids {
user, err := usermod.FindUserByUID(uid)
if err != nil || user == nil || user.UID == 0 {
resp.Failed++
continue
}
if imusermod.IMUserIDByUID(user.UID) > 0 {
resp.Skipped++
continue
}
if _, err = ensureUserRegistered(c, user); err != nil {
resp.Failed++
continue
}
resp.Success++
}
return resp, stderr.Success, ""
}
func AddFriendDirect(req FriendAddDirectReq) (stderr.Code, string) {
if req.UID == 0 || req.PeerUID == 0 || req.UID == req.PeerUID {
return stderr.ErrParamError, "uid/peerUid错误"
}
c := newWebSDKClient()
userIMID, peerIMID, err := ensurePairRegistered(c, req.UID, req.PeerUID)
if err != nil {
return stderr.Failure, err.Error()
}
if err = ensureFriendDirect(c, userIMID, peerIMID); err != nil {
return stderr.Failure, err.Error()
}
if err = ensureFriendDirect(c, peerIMID, userIMID); err != nil {
return stderr.Failure, err.Error()
}
return stderr.Success, ""
}
func BatchOnlineStatus(req OnlineStatusReq) ([]OnlineStatusInfo, stderr.Code, string) {
uids := normalizeUIDs(req.UIDs)
if len(uids) == 0 {
return []OnlineStatusInfo{}, stderr.Success, ""
}
mappings, err := imusermod.FindByUIDsQuiet(uids)
if err != nil {
return nil, stderr.Failure, err.Error()
}
imIDs := make([]int64, 0, len(mappings))
byIMID := make(map[int64]uint64, len(mappings))
seen := make(map[int64]struct{}, len(mappings))
for _, m := range mappings {
if m.IMUserID <= 0 {
continue
}
if _, ok := seen[m.IMUserID]; ok {
continue
}
seen[m.IMUserID] = struct{}{}
imIDs = append(imIDs, m.IMUserID)
byIMID[m.IMUserID] = m.UID
}
if len(imIDs) == 0 {
return []OnlineStatusInfo{}, stderr.Success, ""
}
c := newWebSDKClient()
statuses := make([]imclient.OnlineStatus, 0, len(imIDs))
for _, batch := range chunkInt64s(imIDs, maxIMOnlineStatusBatchSize) {
var items []imclient.OnlineStatus
err := withAppToken(c, func(token string) error {
var batchErr error
items, batchErr = c.BatchOnlineStatus(imclient.BatchOnlineStatusRequest{UserIDs: batch}, token)
return batchErr
})
if err != nil {
return nil, stderr.Failure, err.Error()
}
statuses = append(statuses, items...)
}
resp := make([]OnlineStatusInfo, 0, len(statuses))
for _, item := range statuses {
resp = append(resp, OnlineStatusInfo{
UID: byIMID[item.UserID],
ImUserID: item.UserID,
Online: item.Online,
})
}
return resp, stderr.Success, ""
}
func SendPassthrough(req PassthroughReq) (PassthroughResp, stderr.Code, string) {
passthroughType := strings.TrimSpace(req.PassthroughType)
if passthroughType == "" {
passthroughType = strings.TrimSpace(req.EventType)
}
if passthroughType == "" {
return PassthroughResp{}, stderr.ErrParamError, "passthroughType不能为空"
}
c := newWebSDKClient()
content := strings.TrimSpace(req.Content)
extInfo := strings.TrimSpace(req.ExtInfo)
if req.Payload != nil {
payloadBytes, err := json.Marshal(req.Payload)
if err != nil {
return PassthroughResp{}, stderr.ErrParamError, err.Error()
}
if content == "" {
content = string(payloadBytes)
}
if extInfo == "" {
extInfo = string(payloadBytes)
}
}
if extInfo == "" {
extInfo = "{}"
}
senderID, err := resolveSenderIMID(c, req.SenderUID)
if err != nil {
return PassthroughResp{}, stderr.Failure, err.Error()
}
if req.AllApp {
var result *imclient.PassthroughResult
err := withAppToken(c, func(token string) error {
var sendErr error
result, sendErr = c.SendAppPassthrough(imclient.AppPassthroughRequest{
SenderID: senderID,
PassthroughType: passthroughType,
Content: content,
ExtInfo: extInfo,
ChannelType: req.ChannelType,
}, token)
return sendErr
})
if err != nil {
return PassthroughResp{}, stderr.Failure, err.Error()
}
return PassthroughResp{Result: result}, stderr.Success, ""
}
uids := normalizeUIDs(append(req.UIDs, req.UserIDs...))
if len(uids) == 0 {
return PassthroughResp{}, stderr.ErrParamError, "uids不能为空;全应用透传需显式传 allApp=true"
}
imIDs, err := loadRegisteredIMIDs(uids)
if err != nil {
return PassthroughResp{}, stderr.Failure, err.Error()
}
if len(imIDs) == 0 {
return PassthroughResp{}, stderr.ErrParamError, "没有匹配到可发送透传的用户"
}
var result *imclient.PassthroughResult
err = withAppToken(c, func(token string) error {
var sendErr error
result, sendErr = c.SendOnlinePassthrough(imclient.OnlinePassthroughRequest{
SenderID: senderID,
ReceiverIDSet: imIDs,
PassthroughType: passthroughType,
Content: content,
ExtInfo: extInfo,
ChannelType: req.ChannelType,
}, token)
return sendErr
})
if err != nil {
return PassthroughResp{}, stderr.Failure, err.Error()
}
return PassthroughResp{TargetCount: len(imIDs), Result: result}, stderr.Success, ""
}
func SendMessage(req SendMessageReq) (SendMessageResp, stderr.Code, string) {
content := strings.TrimSpace(req.Content)
if content == "" {
return SendMessageResp{}, stderr.ErrParamError, "content不能为空"
}
if req.UID == 0 || req.PeerUID == 0 || req.UID == req.PeerUID {
return SendMessageResp{}, stderr.ErrParamError, "uid/peerUid错误"
}
c := newWebSDKClient()
userIMID, peerIMID, err := ensurePairRegistered(c, req.UID, req.PeerUID)
if err != nil {
return SendMessageResp{}, stderr.Failure, err.Error()
}
if err = ensureFriendDirect(c, userIMID, peerIMID); err != nil {
return SendMessageResp{}, stderr.Failure, err.Error()
}
if err = ensureFriendDirect(c, peerIMID, userIMID); err != nil {
return SendMessageResp{}, stderr.Failure, err.Error()
}
messageType := imclient.MessageTypeText
if err = withAppToken(c, func(token string) error {
return c.SendMessage(imclient.SendMessageRequest{
SenderID: userIMID,
ReceiverID: peerIMID,
Content: content,
MessageType: messageType,
}, token)
}); err != nil {
return SendMessageResp{}, stderr.Failure, err.Error()
}
return SendMessageResp{
UID: req.UID,
ImUserID: userIMID,
PeerUID: req.PeerUID,
PeerImUserID: peerIMID,
Content: content,
MessageType: messageType,
}, stderr.Success, ""
}
func SyncDialogUsers() (SyncDialogUsersStats, error) {
c := newWebSDKClient()
if !c.Enabled() {
return SyncDialogUsersStats{Enabled: false}, nil
}
syncIDs, pairs, err := CollectDialogUserIDs()
if err != nil {
return SyncDialogUsersStats{Enabled: true}, err
}
stats := SyncDialogUsersStats{Enabled: true, Total: len(syncIDs)}
imIDs := make(map[uint64]int64, len(syncIDs))
for uid := range syncIDs {
user, err := usermod.FindUserByUID(uid)
if err != nil || user == nil || user.UID == 0 {
stats.Failed++
continue
}
if imid := imusermod.IMUserIDByUID(user.UID); imid > 0 {
imIDs[user.UID] = imid
stats.Skipped++
continue
}
imUserID, err := ensureUserRegistered(c, user)
if err != nil {
stats.Failed++
continue
}
imIDs[user.UID] = imUserID
stats.Success++
time.Sleep(100 * time.Millisecond)
}
for _, pair := range pairs {
userIMID := imIDs[pair.UID]
peerIMID := imIDs[pair.PeerUID]
if userIMID <= 0 || peerIMID <= 0 {
stats.FriendFailed++
continue
}
if err := ensureFriendDirect(c, userIMID, peerIMID); err != nil {
stats.FriendFailed++
continue
}
stats.FriendSuccess++
time.Sleep(100 * time.Millisecond)
}
return stats, nil
}
func CollectDialogUserIDs() (map[uint64]struct{}, []imFriendPair, error) {
syncIDs := make(map[uint64]struct{})
pairSet := make(map[string]struct{})
pairs := make([]imFriendPair, 0)
var lastID primitive.ObjectID
for {
filter := bson.M{}
if !lastID.IsZero() {
filter["_id"] = bson.M{"$gt": lastID}
}
opts := options.Find().
SetProjection(bson.M{"_id": 1, "sendUid": 1, "takeUid": 1}).
SetSort(bson.M{"_id": 1}).
SetLimit(imUserSyncPageSize)
sessions, err := sessionmod.FindManyByFilter(filter, opts)
if err != nil {
return nil, nil, err
}
if len(sessions) == 0 {
break
}
for _, session := range sessions {
if session.SendUid > 0 {
syncIDs[session.SendUid] = struct{}{}
}
if session.TakeUid > 0 {
syncIDs[session.TakeUid] = struct{}{}
}
addIMFriendPair(pairSet, &pairs, session.SendUid, session.TakeUid)
}
lastID = sessions[len(sessions)-1].ID
if int64(len(sessions)) < imUserSyncPageSize {
break
}
}
return syncIDs, pairs, nil
}
func buildUserListFilter(req UserListReq) bson.M {
filter := bson.M{}
andFilters := bson.A{}
if req.UID != nil && *req.UID > 0 {
filter["uid"] = *req.UID
}
if req.HasIM != nil {
if *req.HasIM {
filter["imUserId"] = bson.M{"$gt": 0}
} else {
andFilters = append(andFilters, bson.M{"$or": bson.A{
bson.M{"imUserId": bson.M{"$exists": false}},
bson.M{"imUserId": bson.M{"$lte": 0}},
}})
}
}
if req.IsUp != nil {
if *req.IsUp {
andFilters = append(andFilters, creatorFilter())
} else {
andFilters = append(andFilters, bson.M{"$nor": bson.A{creatorFilter()}})
}
}
if len(andFilters) > 0 {
filter["$and"] = andFilters
}
return filter
}
func creatorFilter() bson.M {
return bson.M{"$or": bson.A{
bson.M{"originalUp": true},
bson.M{"officialCert": true},
bson.M{"superUser": true},
bson.M{"merchantUser": bson.M{"$gt": 0}},
bson.M{"vidUploadCount": bson.M{"$gt": 0}},
bson.M{"coverUploadCount": bson.M{"$gt": 0}},
bson.M{"upTag": bson.M{"$ne": ""}},
}}
}
func userInfoFromUser(user *usermod.User) UserInfo {
if user == nil {
return UserInfo{}
}
return UserInfo{
UID: user.UID,
ImUserID: imusermod.IMUserIDByUID(user.UID),
NickName: user.Name,
Avatar: user.Portrait,
IsUp: isCreator(user),
}
}
func isCreator(user *usermod.User) bool {
return user != nil && (user.OfficialCert || user.SuperUser ||
user.MerchantUser > 0 || user.VidUploadCount > 0 || user.CoverUploadCount > 0 || user.UpTag != "")
}
func ensurePairRegistered(c *imclient.Client, uid, peerUID uint64) (int64, int64, error) {
user, err := usermod.FindUserByUID(uid)
if err != nil {
return 0, 0, err
}
peer, err := usermod.FindUserByUID(peerUID)
if err != nil {
return 0, 0, err
}
userIMID, err := ensureUserRegistered(c, user)
if err != nil {
return 0, 0, fmt.Errorf("sync user im id failed: %w", err)
}
peerIMID, err := ensureUserRegistered(c, peer)
if err != nil {
return 0, 0, fmt.Errorf("sync peer im id failed: %w", err)
}
return userIMID, peerIMID, nil
}
func ensureUserRegistered(c *imclient.Client, user *usermod.User) (int64, error) {
if user == nil || user.UID == 0 {
return 0, fmt.Errorf("user is empty")
}
// 以 imusermod 为准:有映射即已注册
if mapping, err := imusermod.FindByUIDQuiet(user.UID); err == nil && mapping.IMUserID > 0 {
return mapping.IMUserID, nil
}
thirdPartyID := strconv.FormatUint(user.UID, 10)
var imUserID int64
err := withAppToken(c, func(token string) error {
var registerErr error
imUserID, registerErr = c.Register(imclient.RegisterRequest{
ThirdPartyID: thirdPartyID,
Password: sdkPassword(user.UID),
Nickname: user.Name,
Avatar: user.Portrait,
}, token)
return registerErr
})
if err != nil {
return 0, err
}
if imUserID <= 0 {
return 0, fmt.Errorf("im register returned empty user id")
}
if err = imusermod.UpsertByUID(user.UID, imUserID, thirdPartyID); err != nil {
return 0, err
}
return imUserID, nil
}
func resolveSenderIMID(c *imclient.Client, senderUID uint64) (int64, error) {
if senderUID == 0 {
return 0, nil
}
user, err := usermod.FindUserByUID(senderUID)
if err != nil {
return 0, err
}
return ensureUserRegistered(c, user)
}
func loadRegisteredIMIDs(uids []uint64) ([]int64, error) {
mappings, err := imusermod.FindByUIDsQuiet(normalizeUIDs(uids))
if err != nil {
return nil, err
}
imIDs := make([]int64, 0, len(mappings))
seen := make(map[int64]struct{}, len(mappings))
for _, m := range mappings {
if m.IMUserID <= 0 {
continue
}
if _, ok := seen[m.IMUserID]; ok {
continue
}
seen[m.IMUserID] = struct{}{}
imIDs = append(imIDs, m.IMUserID)
}
return imIDs, nil
}
func ensureFriendDirect(c *imclient.Client, userIMID, friendIMID int64) error {
if userIMID <= 0 || friendIMID <= 0 || userIMID == friendIMID {
return nil
}
err := withAppToken(c, func(token string) error {
return c.DirectAddFriend(imclient.DirectAddFriendRequest{
UserID: userIMID,
FriendID: friendIMID,
Archive: true,
}, token)
})
if err == nil || isDuplicateFriendError(err) {
return nil
}
return err
}
func isDuplicateFriendError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "already") ||
strings.Contains(msg, "exist") ||
strings.Contains(msg, "重复") ||
strings.Contains(msg, "已是好友") ||
strings.Contains(msg, "好友关系")
}
func addIMFriendPair(pairSet map[string]struct{}, pairs *[]imFriendPair, uid, peerUID uint64) {
addDirectedIMFriendPair(pairSet, pairs, uid, peerUID)
addDirectedIMFriendPair(pairSet, pairs, peerUID, uid)
}
func addDirectedIMFriendPair(pairSet map[string]struct{}, pairs *[]imFriendPair, uid, peerUID uint64) {
if uid == 0 || peerUID == 0 || uid == peerUID {
return
}
key := fmt.Sprintf("%d:%d", uid, peerUID)
if _, ok := pairSet[key]; ok {
return
}
pairSet[key] = struct{}{}
*pairs = append(*pairs, imFriendPair{UID: uid, PeerUID: peerUID})
}
func normalizeUIDs(uids []uint64) []uint64 {
seen := make(map[uint64]struct{}, len(uids))
normalized := make([]uint64, 0, len(uids))
for _, uid := range uids {
if uid == 0 {
continue
}
if _, ok := seen[uid]; ok {
continue
}
seen[uid] = struct{}{}
normalized = append(normalized, uid)
}
return normalized
}
func chunkInt64s(ids []int64, size int) [][]int64 {
if size <= 0 {
size = maxIMOnlineStatusBatchSize
}
if len(ids) == 0 {
return nil
}
chunks := make([][]int64, 0, (len(ids)+size-1)/size)
for start := 0; start < len(ids); start += size {
end := start + size
if end > len(ids) {
end = len(ids)
}
chunks = append(chunks, ids[start:end])
}
return chunks
}
// srvImV2Cfg 拿当前进程可用的 ImV2 配置:
// skd cron 调用 SendAdNotify 时 webg.Conf 为 nil,必须用 skdg.Conf
// web 服务调用反之。两侧 imv2 段必须配同样的内容。
type srvImV2 struct {
BaseURL string
DynamicConfigDomain string
SocketURL string
MerchantCode string
TenantCode string
AppKey string
ClientID string
ClientSecret string
}
func srvImV2Cfg() srvImV2 {
if skdg.Conf != nil {
c := skdg.Conf.ImV2
if c.BaseURL != "" || c.AppKey != "" {
return srvImV2{
BaseURL: c.BaseURL, DynamicConfigDomain: c.DynamicConfigDomain, SocketURL: c.SocketURL,
MerchantCode: c.MerchantCode, TenantCode: c.TenantCode, AppKey: c.AppKey,
ClientID: c.ClientID, ClientSecret: c.ClientSecret,
}
}
}
if webg.Conf != nil {
c := webg.Conf.ImV2
return srvImV2{
BaseURL: c.BaseURL, DynamicConfigDomain: c.DynamicConfigDomain, SocketURL: c.SocketURL,
MerchantCode: c.MerchantCode, TenantCode: c.TenantCode, AppKey: c.AppKey,
ClientID: c.ClientID, ClientSecret: c.ClientSecret,
}
}
return srvImV2{}
}
func newWebSDKClient() *imclient.Client {
cfg := srvImV2Cfg()
return imclient.New(imclient.Config{
Enable: true,
BaseURL: cfg.BaseURL,
MerchantCode: cfg.MerchantCode,
TenantCode: cfg.TenantCode,
AppKey: cfg.AppKey,
ClientID: cfg.ClientID,
ClientSecret: cfg.ClientSecret,
SignKey: imclient.DefaultSignKey,
AESKey: imclient.DefaultAESKey,
EnableSign: true,
EncryptTimestamp: true,
TokenTTL: defaultIMTokenTTL,
})
}
func appToken(c *imclient.Client) (string, error) {
if !c.Enabled() {
return "", fmt.Errorf("IM 未配置")
}
webAppTokenCache.Lock()
defer webAppTokenCache.Unlock()
if webAppTokenCache.token != "" && time.Now().Before(webAppTokenCache.expiresAt) {
return webAppTokenCache.token, nil
}
token, err := c.AppToken()
if err != nil {
return "", err
}
webAppTokenCache.token = token
cacheSeconds := defaultIMTokenTTL - 60
if cacheSeconds < 60 {
cacheSeconds = defaultIMTokenTTL
}
webAppTokenCache.expiresAt = time.Now().Add(time.Duration(cacheSeconds) * time.Second)
return token, nil
}
func invalidateAppToken() {
webAppTokenCache.Lock()
defer webAppTokenCache.Unlock()
webAppTokenCache.token = ""
webAppTokenCache.expiresAt = time.Time{}
}
func withAppToken(c *imclient.Client, fn func(token string) error) error {
for attempt := 0; attempt < 2; attempt++ {
token, err := appToken(c)
if err != nil {
return err
}
err = fn(token)
if err == nil {
return nil
}
if attempt == 0 && imclient.IsSessionExpired(err) {
invalidateAppToken()
continue
}
return err
}
return fmt.Errorf("im app token retry exhausted")
}
func sdkPassword(uid uint64) string {
return fmt.Sprintf("hjll%014d", uid%100000000000000)
}