2026-07-12 14:17:45 +08:00

232 lines
8.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}