diff --git a/internal/api/router.go b/internal/api/router.go index ad3794d63..321e16a9a 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -134,6 +134,7 @@ func newGinRouter(ctx context.Context, client discovery.SvcDiscoveryRegistry, co userRouterGroup.POST("/get_users", u.GetUsers) userRouterGroup.POST("/get_users_online_status", u.GetUsersOnlineStatus) userRouterGroup.POST("/get_users_online_token_detail", u.GetUsersOnlineTokenDetail) + userRouterGroup.POST("/get_self_login_platforms", u.GetSelfLoginPlatforms) userRouterGroup.POST("/subscribe_users_status", u.SubscriberStatus) userRouterGroup.POST("/get_users_status", u.GetUserStatus) userRouterGroup.POST("/get_subscribe_users_status", u.GetSubscribeUsersStatus) diff --git a/internal/api/user.go b/internal/api/user.go index 154e6d908..a43766860 100644 --- a/internal/api/user.go +++ b/internal/api/user.go @@ -33,6 +33,16 @@ type UserApi struct { config config.RpcRegisterName } +type GetSelfLoginPlatformsResp struct { + PlatformID int32 `json:"platformID"` + ConnID string `json:"connID"` + IsBackground bool `json:"isBackground"` + LoginTime int64 `json:"loginTime"` + DeviceName string `json:"deviceName"` + DeviceModel string `json:"deviceModel"` + SDKVersion string `json:"sdkVersion"` +} + func NewUserApi(client user.UserClient, discov discovery.SvcDiscoveryRegistry, config config.RpcRegisterName) UserApi { return UserApi{Client: client, discov: discov, config: config} } @@ -191,6 +201,59 @@ func (u *UserApi) GetUsersOnlineTokenDetail(c *gin.Context) { apiresp.GinSuccess(c, respResult) } +// GetSelfLoginPlatforms Get online terminals for current user. +func (u *UserApi) GetSelfLoginPlatforms(c *gin.Context) { + opUserID, ok := c.Get(constant.OpUserID) + if !ok { + apiresp.GinError(c, errs.ErrNoPermission.WrapMsg("operator user id not found")) + return + } + userID, _ := opUserID.(string) + if userID == "" { + apiresp.GinError(c, errs.ErrNoPermission.WrapMsg("operator user id is empty")) + return + } + req := msggateway.GetUsersOnlineStatusReq{ + UserIDs: []string{userID}, + } + conns, err := u.discov.GetConns(c, u.config.MessageGateway) + if err != nil { + apiresp.GinError(c, err) + return + } + log.ZDebug(c, "GetSelfLoginPlatforms", "userID", userID) + result := make([]*GetSelfLoginPlatformsResp, 0, 8) + for _, v := range conns { + msgClient := msggateway.NewMsgGatewayClient(v) + reply, err := msgClient.GetUsersOnlineStatus(c, &req) + if err != nil { + log.ZWarn(c, "GetUsersOnlineStatus rpc err", err) + continue + } + + log.ZDebug(c, "GetSelfLoginPlatforms", "userID", userID, "reply", reply.SuccessResult) + + for _, r := range reply.SuccessResult { + if r.UserID != userID || r.Status != constant.Online { + log.ZDebug(c, "GetUsersOnlineStatus result not match", "userID", r.UserID, "status", r.Status) + continue + } + for _, detail := range r.DetailPlatformStatus { + result = append(result, &GetSelfLoginPlatformsResp{ + PlatformID: detail.PlatformID, + ConnID: detail.ConnID, + IsBackground: detail.IsBackground, + LoginTime: detail.LoginTime, + DeviceName: detail.DeviceName, + DeviceModel: detail.DeviceModel, + SDKVersion: detail.SdkVersion, + }) + } + } + } + apiresp.GinSuccess(c, result) +} + // SubscriberStatus Presence status of subscribed users. func (u *UserApi) SubscriberStatus(c *gin.Context) { a2r.Call(c, user.UserClient.SubscribeOrCancelUsersStatus, u.Client) diff --git a/internal/msggateway/client.go b/internal/msggateway/client.go index 46da524d3..a6871a1f9 100644 --- a/internal/msggateway/client.go +++ b/internal/msggateway/client.go @@ -68,6 +68,10 @@ type Client struct { UserID string `json:"userID"` IsBackground bool `json:"isBackground"` SDKType string `json:"sdkType"` + SDKVersion string `json:"sdkVersion"` + DeviceName string `json:"deviceName"` + DeviceModel string `json:"deviceModel"` + LoginTimestamp int64 `json:"loginTimestamp"` Encoder Encoder ctx *UserConnContext longConnServer LongConnServer @@ -95,6 +99,10 @@ func (c *Client) ResetClient(ctx *UserConnContext, conn ClientConn, longConnServ c.closedErr = nil c.token = ctx.GetToken() c.SDKType = ctx.GetSDKType() + c.SDKVersion = ctx.GetSDKVersion() + c.DeviceName = ctx.GetDeviceName() + c.DeviceModel = ctx.GetDeviceModel() + c.LoginTimestamp = time.Now().Unix() c.hbCtx, c.hbCancel = context.WithCancel(c.ctx) c.subLock = new(sync.Mutex) if c.subUserIDs != nil { diff --git a/internal/msggateway/constant.go b/internal/msggateway/constant.go index 1e7ab3bb7..f625d6d85 100644 --- a/internal/msggateway/constant.go +++ b/internal/msggateway/constant.go @@ -28,6 +28,9 @@ const ( BackgroundStatus = "isBackground" SendResponse = "isMsgResp" SDKType = "sdkType" + DeviceName = "deviceName" + DeviceModel = "deviceModel" + SDKVersion = "sdkVersion" ) const ( diff --git a/internal/msggateway/context.go b/internal/msggateway/context.go index 6883c22a3..d5f120165 100644 --- a/internal/msggateway/context.go +++ b/internal/msggateway/context.go @@ -38,6 +38,9 @@ type UserConnContextInfo struct { SDKType string `json:"sdkType"` SendResponse bool `json:"sendResponse"` Background bool `json:"background"` + DeviceName string `json:"deviceName"` + DeviceModel string `json:"deviceModel"` + SDKVersion string `json:"sdkVersion"` } type UserConnContext struct { @@ -117,6 +120,9 @@ func (c *UserConnContext) parseByQuery(query url.Values, header http.Header) err OperationID: query.Get(OperationID), Compression: query.Get(Compression), SDKType: query.Get(SDKType), + DeviceName: query.Get(DeviceName), + DeviceModel: query.Get(DeviceModel), + SDKVersion: query.Get(SDKVersion), } platformID, err := strconv.Atoi(query.Get(PlatformID)) if err != nil { @@ -260,3 +266,24 @@ func (c *UserConnContext) SetToken(token string) { func (c *UserConnContext) GetBackground() bool { return c != nil && c.info != nil && c.info.Background } + +func (c *UserConnContext) GetDeviceName() string { + if c == nil || c.info == nil { + return "" + } + return c.info.DeviceName +} + +func (c *UserConnContext) GetDeviceModel() string { + if c == nil || c.info == nil { + return "" + } + return c.info.DeviceModel +} + +func (c *UserConnContext) GetSDKVersion() string { + if c == nil || c.info == nil { + return "" + } + return c.info.SDKVersion +} diff --git a/internal/msggateway/hub_server.go b/internal/msggateway/hub_server.go index 94e17291c..51695b303 100644 --- a/internal/msggateway/hub_server.go +++ b/internal/msggateway/hub_server.go @@ -95,13 +95,17 @@ func NewServer(longConnServer LongConnServer, conf *Config, ready func(srv *Serv } func (s *Server) GetUsersOnlineStatus(ctx context.Context, req *msggateway.GetUsersOnlineStatusReq) (*msggateway.GetUsersOnlineStatusResp, error) { - if !authverify.IsAppManagerUid(ctx, s.config.Share.IMAdminUserID) { + opUserID := mcontext.GetOpUserID(ctx) + isSelfQuery := len(req.UserIDs) == 1 && opUserID != "" && req.UserIDs[0] == opUserID + if !authverify.IsAppManagerUid(ctx, s.config.Share.IMAdminUserID) && !isSelfQuery { return nil, errs.ErrNoPermission.WrapMsg("only app manager") } + log.ZDebug(ctx, "GetUsersOnlineStatus", "userIDs", req.UserIDs) var resp msggateway.GetUsersOnlineStatusResp for _, userID := range req.UserIDs { clients, ok := s.LongConnServer.GetUserAllCons(userID) - if !ok { + if !ok || len(clients) == 0 { + log.ZWarn(ctx, "get users online status failed", errs.ErrRecordNotFound.WrapMsg("get client failed"), "userID", userID) continue } @@ -109,6 +113,7 @@ func (s *Server) GetUsersOnlineStatus(ctx context.Context, req *msggateway.GetUs uresp.UserID = userID for _, client := range clients { if client == nil { + log.ZWarn(ctx, "get users online status failed", errs.ErrRecordNotFound.WrapMsg("user client is nil"), "userID", userID) continue } @@ -117,11 +122,17 @@ func (s *Server) GetUsersOnlineStatus(ctx context.Context, req *msggateway.GetUs ps.ConnID = client.ctx.GetConnID() ps.Token = client.token ps.IsBackground = client.IsBackground + ps.LoginTime = client.LoginTimestamp + ps.DeviceName = client.DeviceName + ps.DeviceModel = client.DeviceModel + ps.SdkVersion = client.SDKVersion uresp.Status = constant.Online uresp.DetailPlatformStatus = append(uresp.DetailPlatformStatus, ps) } if uresp.Status == constant.Online { resp.SuccessResult = append(resp.SuccessResult, uresp) + } else { + log.ZWarn(ctx, "get users online status failed", errs.ErrRecordNotFound.WrapMsg("user not online"), "userID", userID) } } return &resp, nil diff --git a/scripts/test/get_self_login_platforms_api_test.sh b/scripts/test/get_self_login_platforms_api_test.sh new file mode 100755 index 000000000..5be65be7b --- /dev/null +++ b/scripts/test/get_self_login_platforms_api_test.sh @@ -0,0 +1,145 @@ +#!/usr/bin/env bash +# ============================================================ +# get_self_login_platforms 接口测试脚本 +# +# 覆盖接口: +# POST /auth/get_user_token +# POST /user/get_self_login_platforms +# +# 说明: +# 本脚本仅做 HTTP 接口测试,不建立 WS 连接。 +# ============================================================ + +set -euo pipefail + +HOST="${HOST:-http://127.0.0.1:10002}" +USER_ID="${USER_ID:-5694418935}" +PLATFORM_ID="${PLATFORM_ID:-2}" +ADMIN_TOKEN="${ADMIN_TOKEN:-}" +OPENIM_SECRET="${OPENIM_SECRET:-openIM123}" +ADMIN_USER_ID="${ADMIN_USER_ID:-imAdmin}" + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) HOST="$2"; shift 2 ;; + --user-id) USER_ID="$2"; shift 2 ;; + --platform-id) PLATFORM_ID="$2"; shift 2 ;; + *) + echo "未知参数: $1" + exit 1 + ;; + esac +done + +need_cmd() { + command -v "$1" >/dev/null 2>&1 || { + echo "缺少依赖命令: $1" + exit 1 + } +} + +need_cmd curl +need_cmd jq + +op_id() { + echo "self-login-platforms-test-$$-$(date +%s%N)" +} + +get_admin_token() { + local uid body resp token last_resp + local -a candidates=("${ADMIN_USER_ID}" "openIM123456" "imAdmin") + last_resp="" + + for uid in "${candidates[@]}"; do + body="{\"secret\":\"${OPENIM_SECRET}\",\"userID\":\"${uid}\"}" + resp="$(curl -sS -X POST "${HOST}/auth/get_admin_token" \ + -H "Content-Type: application/json" \ + -H "operationID: $(op_id)" \ + -d "$body")" + last_resp="$resp" + + token="$(python3 - <<'PY' "$resp" +import json +import sys + +raw = sys.argv[1] +try: + obj = json.loads(raw) +except Exception: + print("") + raise SystemExit(0) + +token = "" +if isinstance(obj, dict): + data = obj.get("data") + if isinstance(data, dict): + token = data.get("token") or data.get("Token") or "" + if not token: + token = obj.get("token") or obj.get("Token") or "" +print(token) +PY +)" + if [[ -n "$token" ]]; then + echo "自动获取管理员 token 成功,userID=${uid}" >&2 + printf '%s' "$token" + return 0 + fi + done + + echo "get_admin_token raw response: $last_resp" >&2 + echo "自动获取管理员 token 失败,请检查 HOST/OPENIM_SECRET/ADMIN_USER_ID 或直接设置 ADMIN_TOKEN" >&2 + exit 1 +} + +if [[ -z "${ADMIN_TOKEN}" ]]; then + echo "==> 1) ADMIN_TOKEN 未设置,尝试自动获取管理员 token" + ADMIN_TOKEN="$(get_admin_token)" +fi + +echo "==> 2) 获取用户 token" +TOKEN_RESP=$(curl -sS -X POST \ + -H "Content-Type: application/json" \ + -H "operationID: $(op_id)" \ + -H "token: ${ADMIN_TOKEN}" \ + -d "{\"userID\":\"${USER_ID}\",\"platformID\":${PLATFORM_ID}}" \ + "${HOST}/auth/get_user_token") + +ERR_CODE=$(echo "${TOKEN_RESP}" | jq -r '.errCode // "null"') +if [[ "${ERR_CODE}" != "0" ]]; then + echo "获取 token 失败: ${TOKEN_RESP}" + exit 1 +fi +TOKEN=$(echo "${TOKEN_RESP}" | jq -r '.data.token // empty') +if [[ -z "${TOKEN}" ]]; then + echo "token 为空: ${TOKEN_RESP}" + exit 1 +fi +echo "token 获取成功, userID=${USER_ID}" + +echo "==> 3) 调用 /user/get_self_login_platforms" +RESP=$(curl -sS -X POST \ + -H "Content-Type: application/json" \ + -H "operationID: $(op_id)" \ + -H "token: ${TOKEN}" \ + -d '{}' \ + "${HOST}/user/get_self_login_platforms") + +echo "原始响应: ${RESP}" +ERR_CODE=$(echo "${RESP}" | jq -r '.errCode // "null"') +if [[ "${ERR_CODE}" != "0" ]]; then + echo "接口调用失败: ${RESP}" + exit 1 +fi + +echo "==> 4) 校验响应结构" +DATA_TYPE=$(echo "${RESP}" | jq -r '(.data | type) // "null"') +if [[ "${DATA_TYPE}" != "array" ]]; then + echo "返回 data 不是数组: ${RESP}" + exit 1 +fi + +echo "结构校验通过(data 为数组)" +echo "返回 data:" +echo "${RESP}" | jq '.data' + +echo "测试通过: get_self_login_platforms"