package appuser import ( "context" "database/sql" "errors" "fmt" "hyapp-admin-server/internal/appctx" userv1 "hyapp.local/api/proto/user/v1" mysqlDriver "github.com/go-sql-driver/mysql" ) // AppUserThirdPartyIdentity 暴露三方绑定 openid 事实:openid = third_party_identities.provider_subject。 type AppUserThirdPartyIdentity struct { Provider string `json:"provider"` OpenID string `json:"openid"` UnionID string `json:"unionId"` CreatedAtMs int64 `json:"createdAtMs"` } // AppUserDevice 是设备 tab 的一行设备档案,来自 user_devices 认证成功聚合。 type AppUserDevice struct { DeviceID string `json:"deviceId"` Platform string `json:"platform"` DeviceModel string `json:"deviceModel"` Manufacturer string `json:"manufacturer"` OSVersion string `json:"osVersion"` IMEI string `json:"imei"` AppVersion string `json:"appVersion"` BuildNumber string `json:"buildNumber"` FirstSeenAtMs int64 `json:"firstSeenAtMs"` LastSeenAtMs int64 `json:"lastSeenAtMs"` } // AppUserAccessToken 是后台按需重签的 access token 结果;不含 refresh token,取用行为已写操作日志。 type AppUserAccessToken struct { AccessToken string `json:"accessToken"` TokenType string `json:"tokenType"` ExpiresInSec int64 `json:"expiresInSec"` SessionID string `json:"sessionId"` DeviceID string `json:"deviceId"` SessionLastHeartbeatAtMs int64 `json:"sessionLastHeartbeatAtMs"` } // accessTokenIssuerClient 只暴露后台重签 token 的 RPC;与 userAdminProjectionClient 相同, // 通过类型断言探测能力,避免为单一后台功能扩大 userclient.Client 主契约。 type accessTokenIssuerClient interface { AdminIssueUserAccessToken(context.Context, *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error) } // loginAuditSnapshotSQL 按单个 login_type 等值命中 idx_login_audit_latest_success 后取最新一条。 // 不能对多个 login_type 用 IN + 全局 ORDER BY:在线用户 access_resign 行每天数千条,索引范围必须逐类型收敛。 const loginAuditSnapshotSQL = ` (SELECT COALESCE(audit.client_ip, '') AS client_ip, audit.ip_country_code, audit.app_version, audit.build_number, audit.created_at_ms, audit.id FROM login_audit AS audit WHERE audit.app_code = ? AND audit.user_id = ? AND audit.result = 'success' AND audit.blocked = 0 AND audit.login_type = ? ORDER BY audit.created_at_ms DESC, audit.id DESC LIMIT 1)` // realLoginTypes 与 dashboard 用户版本统计口径一致:只有真实登录/续期链路携带客户端版本头。 // access_resign 是 25s 心跳重签,app_version 恒为空,只用于 IP 新鲜度。 var realLoginTypes = []string{"password", "third_party", "refresh"} // ipLoginTypes 在真实登录之外追加 access_resign:在线用户的最新 IP 由心跳审计行提供(≤25s 旧)。 var ipLoginTypes = []string{"password", "third_party", "refresh", "access_resign"} // fillLoginSnapshots 填充详情页的最新登录 IP 和当前 App 版本快照,两者口径不同必须分开取: // IP 含 access_resign(新鲜度优先),版本排除 access_resign(心跳行版本恒为空,会把口径打回 unknown)。 func (s *Service) fillLoginSnapshots(ctx context.Context, item *AppUser, userID int64) error { if s.userDB == nil || item == nil { return nil } appCode := appctx.FromContext(ctx) ip, ipCountry, _, _, ipAtMs, err := s.queryLatestLoginAudit(ctx, appCode, userID, ipLoginTypes) if err != nil { return err } item.LastLoginIP = ip item.LastLoginIPCountryCode = ipCountry item.LastLoginIPAtMs = ipAtMs _, _, appVersion, buildNumber, versionAtMs, err := s.queryLatestLoginAudit(ctx, appCode, userID, realLoginTypes) if err != nil { return err } item.AppVersion = appVersion item.BuildNumber = buildNumber item.AppVersionAtMs = versionAtMs return nil } // queryLatestLoginAudit 对给定登录类型集合做“每类型 LIMIT 1 + UNION ALL 再取最新”的单用户查询。 // 没有任何成功登录时返回零值而不是错误:老用户/被清审计的用户详情页仍要能打开。 func (s *Service) queryLatestLoginAudit(ctx context.Context, appCode string, userID int64, loginTypes []string) (ip string, ipCountry string, appVersion string, buildNumber string, createdAtMs int64, err error) { if len(loginTypes) == 0 { return "", "", "", "", 0, nil } querySQL := "" args := make([]any, 0, len(loginTypes)*3) for i, loginType := range loginTypes { if i > 0 { querySQL += "\n\tUNION ALL\n" } querySQL += loginAuditSnapshotSQL args = append(args, appCode, userID, loginType) } // UNION ALL 的列名取第一个子查询的别名;外层只按 (created_at_ms, id) 在最多 len(loginTypes) 条候选里挑最新。 querySQL = ` SELECT candidate.client_ip, candidate.ip_country_code, candidate.app_version, candidate.build_number, candidate.created_at_ms FROM (` + querySQL + ` ) AS candidate ORDER BY candidate.created_at_ms DESC, candidate.id DESC LIMIT 1` scanErr := s.userDB.QueryRowContext(ctx, querySQL, args...).Scan(&ip, &ipCountry, &appVersion, &buildNumber, &createdAtMs) if errors.Is(scanErr, sql.ErrNoRows) { return "", "", "", "", 0, nil } if scanErr != nil { return "", "", "", "", 0, scanErr } return ip, ipCountry, appVersion, buildNumber, createdAtMs, nil } // fillThirdPartyIdentities 读取用户全部三方绑定;idx_third_party_user_id(app_code, user_id) 覆盖查询。 func (s *Service) fillThirdPartyIdentities(ctx context.Context, item *AppUser, userID int64) error { if s.userDB == nil || item == nil { return nil } rows, err := s.userDB.QueryContext(ctx, ` SELECT provider, provider_subject, COALESCE(provider_union_id, ''), created_at_ms FROM third_party_identities WHERE app_code = ? AND user_id = ? ORDER BY provider ASC, created_at_ms ASC `, appctx.FromContext(ctx), userID) if err != nil { return err } defer rows.Close() identities := make([]AppUserThirdPartyIdentity, 0, 2) for rows.Next() { var identity AppUserThirdPartyIdentity if err := rows.Scan(&identity.Provider, &identity.OpenID, &identity.UnionID, &identity.CreatedAtMs); err != nil { return err } identities = append(identities, identity) } item.ThirdPartyIdentities = identities return rows.Err() } // fillDevices 读取设备档案。user_devices 由 user-service 迁移 013 引入,线上 DDL 人工执行, // 表尚未创建(Error 1146)时按空列表处理,不能让整个用户详情 500。 func (s *Service) fillDevices(ctx context.Context, item *AppUser, userID int64) error { if s.userDB == nil || item == nil { return nil } rows, err := s.userDB.QueryContext(ctx, ` SELECT device_id, platform, device_model, manufacturer, os_version, imei, app_version, build_number, first_seen_at_ms, last_seen_at_ms FROM user_devices WHERE app_code = ? AND user_id = ? ORDER BY last_seen_at_ms DESC LIMIT 20 `, appctx.FromContext(ctx), userID) if err != nil { var mysqlErr *mysqlDriver.MySQLError if errors.As(err, &mysqlErr) && mysqlErr.Number == 1146 { item.Devices = []AppUserDevice{} return nil } return err } defer rows.Close() devices := make([]AppUserDevice, 0, 4) for rows.Next() { var device AppUserDevice if err := rows.Scan( &device.DeviceID, &device.Platform, &device.DeviceModel, &device.Manufacturer, &device.OSVersion, &device.IMEI, &device.AppVersion, &device.BuildNumber, &device.FirstSeenAtMs, &device.LastSeenAtMs, ); err != nil { return err } devices = append(devices, device) } item.Devices = devices return rows.Err() } // IssueAccessToken 调 user-service 按用户最新 active session 重签 access token。 // NotFound(无活跃会话)原样向上抛,由 handler 映射成前端可直接展示的 404 文案。 func (s *Service) IssueAccessToken(ctx context.Context, userID int64) (AppUserAccessToken, error) { if s.tokenIssuerClient == nil { return AppUserAccessToken{}, fmt.Errorf("user client is not configured") } nowMs := nowMillis() appCode := appctx.FromContext(ctx) resp, err := s.tokenIssuerClient.AdminIssueUserAccessToken(ctx, &userv1.AdminIssueUserAccessTokenRequest{ Meta: &userv1.RequestMeta{ RequestId: fmt.Sprintf("admin-app-user-token-%d", nowMs), Caller: "hyapp-admin-server", SentAtMs: nowMs, AppCode: appCode, }, AppCode: appCode, UserId: userID, }) if err != nil { return AppUserAccessToken{}, err } return AppUserAccessToken{ AccessToken: resp.GetAccessToken(), TokenType: resp.GetTokenType(), ExpiresInSec: resp.GetExpiresInSec(), SessionID: resp.GetSessionId(), DeviceID: resp.GetDeviceId(), SessionLastHeartbeatAtMs: resp.GetSessionLastHeartbeatAtMs(), }, nil }