232 lines
8.8 KiB
Go
232 lines
8.8 KiB
Go
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
|
||
}
|