增加用户信息
This commit is contained in:
parent
902cf46516
commit
4794507044
File diff suppressed because it is too large
Load Diff
@ -682,6 +682,9 @@ message JoinRoomRequest {
|
||||
RoomUserDisplayProfile actor_display_profile = 8;
|
||||
// response_mode=lite 只裁剪响应快照,不改变 Room Cell 命令提交、outbox 或 IM 事件语义。
|
||||
string response_mode = 9;
|
||||
// presence_recovery 只用于客户端仍在房间但业务 presence 已超时清理的恢复。
|
||||
// 恢复成功只补回 Room Cell 在线状态,不发布 RoomUserJoined IM/outbox,避免重复入场表现和统计。
|
||||
bool presence_recovery = 10;
|
||||
}
|
||||
|
||||
// RoomEffectiveVipSnapshot 是 room-service 在进房时从 wallet-service 固化的轻量 VIP 展示事实。
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -22,6 +22,13 @@ message RequestMeta {
|
||||
// app_version/build_number 记录发起本次 RPC 的客户端构建;认证链路依赖它们把每次成功登录归档到 login_audit。
|
||||
string app_version = 14;
|
||||
string build_number = 15;
|
||||
// device_model/device_manufacturer/os_version/imei 是客户端设备资料快照,来自 gateway 的
|
||||
// X-Device-Model/X-Device-Manufacturer/X-OS-Version/X-Device-IMEI 头;认证成功后按 device_id
|
||||
// 聚合进 user_devices,支撑后台用户详情的设备 tab。非认证链路可留空,IMEI 在 Android 10+ 通常为空。
|
||||
string device_model = 16;
|
||||
string device_manufacturer = 17;
|
||||
string os_version = 18;
|
||||
string imei = 19;
|
||||
}
|
||||
|
||||
// App 是 user-service 维护的 App 注册表投影,用于把客户端包名映射到内部租户键。
|
||||
@ -735,6 +742,26 @@ message BatchGetUserAdminProfilesResponse {
|
||||
map<int64, UserAdminProfile> profiles = 1;
|
||||
}
|
||||
|
||||
// AdminIssueUserAccessTokenRequest 供后台按用户当前最新 active session 重签 access token。
|
||||
// 该链路不写 login_audit,也不修改 session 行——后台取 token 不能污染用户“最近活跃/最近登录”事实;
|
||||
// 审计责任由 admin 侧操作日志承担。
|
||||
message AdminIssueUserAccessTokenRequest {
|
||||
RequestMeta meta = 1;
|
||||
string app_code = 2;
|
||||
int64 user_id = 3;
|
||||
}
|
||||
|
||||
// AdminIssueUserAccessTokenResponse 返回重签的 access token 和所属 session 的定位信息。
|
||||
// 不返回 refresh token:后台没有轮换会话的理由,refresh 原文也不允许离开登录主链路。
|
||||
message AdminIssueUserAccessTokenResponse {
|
||||
string access_token = 1;
|
||||
string token_type = 2;
|
||||
int64 expires_in_sec = 3;
|
||||
string session_id = 4;
|
||||
string device_id = 5;
|
||||
int64 session_last_heartbeat_at_ms = 6;
|
||||
}
|
||||
|
||||
// RoomBasicUser 是房间首屏只需要的用户展示基础资料。
|
||||
message RoomBasicUser {
|
||||
int64 user_id = 1;
|
||||
@ -1433,6 +1460,7 @@ service UserService {
|
||||
rpc GetMyProfileStats(GetMyProfileStatsRequest) returns (GetMyProfileStatsResponse);
|
||||
rpc BatchGetUsers(BatchGetUsersRequest) returns (BatchGetUsersResponse);
|
||||
rpc BatchGetUserAdminProfiles(BatchGetUserAdminProfilesRequest) returns (BatchGetUserAdminProfilesResponse);
|
||||
rpc AdminIssueUserAccessToken(AdminIssueUserAccessTokenRequest) returns (AdminIssueUserAccessTokenResponse);
|
||||
rpc BatchGetRoomBasicUsers(BatchGetRoomBasicUsersRequest) returns (BatchGetRoomBasicUsersResponse);
|
||||
rpc ListUserIDs(ListUserIDsRequest) returns (ListUserIDsResponse);
|
||||
rpc GetUserMicLifetimeStats(GetUserMicLifetimeStatsRequest) returns (GetUserMicLifetimeStatsResponse);
|
||||
|
||||
@ -25,6 +25,7 @@ const (
|
||||
UserService_GetMyProfileStats_FullMethodName = "/hyapp.user.v1.UserService/GetMyProfileStats"
|
||||
UserService_BatchGetUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUsers"
|
||||
UserService_BatchGetUserAdminProfiles_FullMethodName = "/hyapp.user.v1.UserService/BatchGetUserAdminProfiles"
|
||||
UserService_AdminIssueUserAccessToken_FullMethodName = "/hyapp.user.v1.UserService/AdminIssueUserAccessToken"
|
||||
UserService_BatchGetRoomBasicUsers_FullMethodName = "/hyapp.user.v1.UserService/BatchGetRoomBasicUsers"
|
||||
UserService_ListUserIDs_FullMethodName = "/hyapp.user.v1.UserService/ListUserIDs"
|
||||
UserService_GetUserMicLifetimeStats_FullMethodName = "/hyapp.user.v1.UserService/GetUserMicLifetimeStats"
|
||||
@ -57,6 +58,7 @@ type UserServiceClient interface {
|
||||
GetMyProfileStats(ctx context.Context, in *GetMyProfileStatsRequest, opts ...grpc.CallOption) (*GetMyProfileStatsResponse, error)
|
||||
BatchGetUsers(ctx context.Context, in *BatchGetUsersRequest, opts ...grpc.CallOption) (*BatchGetUsersResponse, error)
|
||||
BatchGetUserAdminProfiles(ctx context.Context, in *BatchGetUserAdminProfilesRequest, opts ...grpc.CallOption) (*BatchGetUserAdminProfilesResponse, error)
|
||||
AdminIssueUserAccessToken(ctx context.Context, in *AdminIssueUserAccessTokenRequest, opts ...grpc.CallOption) (*AdminIssueUserAccessTokenResponse, error)
|
||||
BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error)
|
||||
ListUserIDs(ctx context.Context, in *ListUserIDsRequest, opts ...grpc.CallOption) (*ListUserIDsResponse, error)
|
||||
GetUserMicLifetimeStats(ctx context.Context, in *GetUserMicLifetimeStatsRequest, opts ...grpc.CallOption) (*GetUserMicLifetimeStatsResponse, error)
|
||||
@ -145,6 +147,16 @@ func (c *userServiceClient) BatchGetUserAdminProfiles(ctx context.Context, in *B
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) AdminIssueUserAccessToken(ctx context.Context, in *AdminIssueUserAccessTokenRequest, opts ...grpc.CallOption) (*AdminIssueUserAccessTokenResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(AdminIssueUserAccessTokenResponse)
|
||||
err := c.cc.Invoke(ctx, UserService_AdminIssueUserAccessToken_FullMethodName, in, out, cOpts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *userServiceClient) BatchGetRoomBasicUsers(ctx context.Context, in *BatchGetRoomBasicUsersRequest, opts ...grpc.CallOption) (*BatchGetRoomBasicUsersResponse, error) {
|
||||
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
|
||||
out := new(BatchGetRoomBasicUsersResponse)
|
||||
@ -337,6 +349,7 @@ type UserServiceServer interface {
|
||||
GetMyProfileStats(context.Context, *GetMyProfileStatsRequest) (*GetMyProfileStatsResponse, error)
|
||||
BatchGetUsers(context.Context, *BatchGetUsersRequest) (*BatchGetUsersResponse, error)
|
||||
BatchGetUserAdminProfiles(context.Context, *BatchGetUserAdminProfilesRequest) (*BatchGetUserAdminProfilesResponse, error)
|
||||
AdminIssueUserAccessToken(context.Context, *AdminIssueUserAccessTokenRequest) (*AdminIssueUserAccessTokenResponse, error)
|
||||
BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error)
|
||||
ListUserIDs(context.Context, *ListUserIDsRequest) (*ListUserIDsResponse, error)
|
||||
GetUserMicLifetimeStats(context.Context, *GetUserMicLifetimeStatsRequest) (*GetUserMicLifetimeStatsResponse, error)
|
||||
@ -383,6 +396,9 @@ func (UnimplementedUserServiceServer) BatchGetUsers(context.Context, *BatchGetUs
|
||||
func (UnimplementedUserServiceServer) BatchGetUserAdminProfiles(context.Context, *BatchGetUserAdminProfilesRequest) (*BatchGetUserAdminProfilesResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetUserAdminProfiles not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) AdminIssueUserAccessToken(context.Context, *AdminIssueUserAccessTokenRequest) (*AdminIssueUserAccessTokenResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method AdminIssueUserAccessToken not implemented")
|
||||
}
|
||||
func (UnimplementedUserServiceServer) BatchGetRoomBasicUsers(context.Context, *BatchGetRoomBasicUsersRequest) (*BatchGetRoomBasicUsersResponse, error) {
|
||||
return nil, status.Errorf(codes.Unimplemented, "method BatchGetRoomBasicUsers not implemented")
|
||||
}
|
||||
@ -566,6 +582,24 @@ func _UserService_BatchGetUserAdminProfiles_Handler(srv interface{}, ctx context
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_AdminIssueUserAccessToken_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(AdminIssueUserAccessTokenRequest)
|
||||
if err := dec(in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if interceptor == nil {
|
||||
return srv.(UserServiceServer).AdminIssueUserAccessToken(ctx, in)
|
||||
}
|
||||
info := &grpc.UnaryServerInfo{
|
||||
Server: srv,
|
||||
FullMethod: UserService_AdminIssueUserAccessToken_FullMethodName,
|
||||
}
|
||||
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
|
||||
return srv.(UserServiceServer).AdminIssueUserAccessToken(ctx, req.(*AdminIssueUserAccessTokenRequest))
|
||||
}
|
||||
return interceptor(ctx, in, info, handler)
|
||||
}
|
||||
|
||||
func _UserService_BatchGetRoomBasicUsers_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
|
||||
in := new(BatchGetRoomBasicUsersRequest)
|
||||
if err := dec(in); err != nil {
|
||||
@ -921,6 +955,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "BatchGetUserAdminProfiles",
|
||||
Handler: _UserService_BatchGetUserAdminProfiles_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "AdminIssueUserAccessToken",
|
||||
Handler: _UserService_AdminIssueUserAccessToken_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BatchGetRoomBasicUsers",
|
||||
Handler: _UserService_BatchGetRoomBasicUsers_Handler,
|
||||
|
||||
@ -399,6 +399,12 @@ func (c *GRPCClient) BatchGetUserAdminProfiles(ctx context.Context, req *userv1.
|
||||
return c.client.BatchGetUserAdminProfiles(ctx, req)
|
||||
}
|
||||
|
||||
// AdminIssueUserAccessToken 透传后台按用户重签 access token 的 RPC;
|
||||
// user-service 侧刻意不写 login_audit,审计由 admin 操作日志完成。
|
||||
func (c *GRPCClient) AdminIssueUserAccessToken(ctx context.Context, req *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error) {
|
||||
return c.client.AdminIssueUserAccessToken(ctx, req)
|
||||
}
|
||||
|
||||
func (c *GRPCClient) AdminBanUser(ctx context.Context, req *userv1.AdminBanUserRequest) (*userv1.AdminBanUserResponse, error) {
|
||||
return c.client.AdminBanUser(ctx, req)
|
||||
}
|
||||
|
||||
231
server/admin/internal/modules/appuser/detail_extras.go
Normal file
231
server/admin/internal/modules/appuser/detail_extras.go
Normal file
@ -0,0 +1,231 @@
|
||||
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
|
||||
}
|
||||
175
server/admin/internal/modules/appuser/detail_extras_test.go
Normal file
175
server/admin/internal/modules/appuser/detail_extras_test.go
Normal file
@ -0,0 +1,175 @@
|
||||
package appuser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
userv1 "hyapp.local/api/proto/user/v1"
|
||||
|
||||
"github.com/DATA-DOG/go-sqlmock"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestFillLoginSnapshotsSplitsIPAndVersionScopes(t *testing.T) {
|
||||
// IP 口径包含 access_resign(在线用户 25s 心跳行提供最新 IP),版本口径必须排除它(心跳行版本恒为空)。
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := &Service{userDB: db}
|
||||
|
||||
columns := []string{"client_ip", "ip_country_code", "app_version", "build_number", "created_at_ms"}
|
||||
// 第一条查询(IP 口径)带 access_resign 参数;第二条(版本口径)只有三种真实登录类型。
|
||||
mock.ExpectQuery(`FROM \(`).
|
||||
WithArgs("lalu", int64(42), "password", "lalu", int64(42), "third_party", "lalu", int64(42), "refresh", "lalu", int64(42), "access_resign").
|
||||
WillReturnRows(sqlmock.NewRows(columns).AddRow("203.0.113.9", "SA", "", "", int64(9000)))
|
||||
mock.ExpectQuery(`FROM \(`).
|
||||
WithArgs("lalu", int64(42), "password", "lalu", int64(42), "third_party", "lalu", int64(42), "refresh").
|
||||
WillReturnRows(sqlmock.NewRows(columns).AddRow("198.51.100.7", "SA", "1.4.2", "142", int64(7000)))
|
||||
|
||||
item := AppUser{}
|
||||
if err := service.fillLoginSnapshots(context.Background(), &item, 42); err != nil {
|
||||
t.Fatalf("fillLoginSnapshots failed: %v", err)
|
||||
}
|
||||
if item.LastLoginIP != "203.0.113.9" || item.LastLoginIPCountryCode != "SA" || item.LastLoginIPAtMs != 9000 {
|
||||
t.Fatalf("last login ip snapshot mismatch: %+v", item)
|
||||
}
|
||||
if item.AppVersion != "1.4.2" || item.BuildNumber != "142" || item.AppVersionAtMs != 7000 {
|
||||
t.Fatalf("app version snapshot mismatch: %+v", item)
|
||||
}
|
||||
if err := mock.ExpectationsWereMet(); err != nil {
|
||||
t.Fatalf("unmet expectations: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillLoginSnapshotsToleratesUserWithoutAudit(t *testing.T) {
|
||||
// 老用户或审计被清理时详情页仍要能打开,各字段落零值而不是 500。
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := &Service{userDB: db}
|
||||
|
||||
columns := []string{"client_ip", "ip_country_code", "app_version", "build_number", "created_at_ms"}
|
||||
mock.ExpectQuery(`FROM \(`).WillReturnRows(sqlmock.NewRows(columns))
|
||||
mock.ExpectQuery(`FROM \(`).WillReturnRows(sqlmock.NewRows(columns))
|
||||
|
||||
item := AppUser{}
|
||||
if err := service.fillLoginSnapshots(context.Background(), &item, 43); err != nil {
|
||||
t.Fatalf("fillLoginSnapshots failed: %v", err)
|
||||
}
|
||||
if item.LastLoginIP != "" || item.LastLoginIPAtMs != 0 || item.AppVersion != "" || item.AppVersionAtMs != 0 {
|
||||
t.Fatalf("empty audit must produce zero values: %+v", item)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillDevicesToleratesMissingTable(t *testing.T) {
|
||||
// user_devices 由人工执行 DDL 引入;表未创建(Error 1146)时详情页按空列表展示,不能整页失败。
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := &Service{userDB: db}
|
||||
|
||||
mock.ExpectQuery(`FROM user_devices`).WillReturnError(&mysqlDriver.MySQLError{Number: 1146, Message: "Table 'hyapp_user.user_devices' doesn't exist"})
|
||||
|
||||
item := AppUser{}
|
||||
if err := service.fillDevices(context.Background(), &item, 42); err != nil {
|
||||
t.Fatalf("fillDevices must tolerate missing table: %v", err)
|
||||
}
|
||||
if item.Devices == nil || len(item.Devices) != 0 {
|
||||
t.Fatalf("missing table must yield empty device list: %#v", item.Devices)
|
||||
}
|
||||
|
||||
// 其他数据库错误必须继续上抛,不能被 1146 分支吞掉。
|
||||
mock.ExpectQuery(`FROM user_devices`).WillReturnError(&mysqlDriver.MySQLError{Number: 1054, Message: "Unknown column"})
|
||||
if err := service.fillDevices(context.Background(), &item, 42); err == nil {
|
||||
t.Fatalf("non-1146 errors must propagate")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillDevicesScansRowsInLastSeenOrder(t *testing.T) {
|
||||
db, mock, err := sqlmock.New(sqlmock.QueryMatcherOption(sqlmock.QueryMatcherRegexp))
|
||||
if err != nil {
|
||||
t.Fatalf("sqlmock failed: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
service := &Service{userDB: db}
|
||||
|
||||
rows := sqlmock.NewRows([]string{"device_id", "platform", "device_model", "manufacturer", "os_version", "imei", "app_version", "build_number", "first_seen_at_ms", "last_seen_at_ms"}).
|
||||
AddRow("android_abc", "android", "Redmi Note 8 Pro", "Xiaomi", "13", "", "1.4.2", "142", int64(1000), int64(9000)).
|
||||
AddRow("ios_def", "ios", "iPhone17,2", "Apple", "18.1", "", "1.4.0", "140", int64(500), int64(8000))
|
||||
mock.ExpectQuery(`FROM user_devices`).WithArgs("lalu", int64(42)).WillReturnRows(rows)
|
||||
|
||||
item := AppUser{}
|
||||
if err := service.fillDevices(context.Background(), &item, 42); err != nil {
|
||||
t.Fatalf("fillDevices failed: %v", err)
|
||||
}
|
||||
if len(item.Devices) != 2 || item.Devices[0].DeviceID != "android_abc" || item.Devices[1].Manufacturer != "Apple" {
|
||||
t.Fatalf("device rows mismatch: %#v", item.Devices)
|
||||
}
|
||||
if item.Devices[0].LastSeenAtMs != 9000 || item.Devices[0].FirstSeenAtMs != 1000 {
|
||||
t.Fatalf("device timestamps mismatch: %#v", item.Devices[0])
|
||||
}
|
||||
}
|
||||
|
||||
type fakeTokenIssuerClient struct {
|
||||
response *userv1.AdminIssueUserAccessTokenResponse
|
||||
err error
|
||||
lastReq *userv1.AdminIssueUserAccessTokenRequest
|
||||
}
|
||||
|
||||
func (f *fakeTokenIssuerClient) AdminIssueUserAccessToken(_ context.Context, req *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error) {
|
||||
f.lastReq = req
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.response, nil
|
||||
}
|
||||
|
||||
func TestIssueAccessTokenMapsResponseAndAppCode(t *testing.T) {
|
||||
fake := &fakeTokenIssuerClient{response: &userv1.AdminIssueUserAccessTokenResponse{
|
||||
AccessToken: "jwt-token",
|
||||
TokenType: "Bearer",
|
||||
ExpiresInSec: 3600,
|
||||
SessionId: "sess_1",
|
||||
DeviceId: "android_abc",
|
||||
SessionLastHeartbeatAtMs: 123456,
|
||||
}}
|
||||
service := &Service{tokenIssuerClient: fake}
|
||||
|
||||
result, err := service.IssueAccessToken(context.Background(), 42)
|
||||
if err != nil {
|
||||
t.Fatalf("IssueAccessToken failed: %v", err)
|
||||
}
|
||||
if result.AccessToken != "jwt-token" || result.TokenType != "Bearer" || result.ExpiresInSec != 3600 ||
|
||||
result.SessionID != "sess_1" || result.DeviceID != "android_abc" || result.SessionLastHeartbeatAtMs != 123456 {
|
||||
t.Fatalf("token result mismatch: %+v", result)
|
||||
}
|
||||
// app_code 必须同时写入显式字段和 meta,user-service 端优先消费显式字段。
|
||||
if fake.lastReq.GetUserId() != 42 || fake.lastReq.GetAppCode() != "lalu" || fake.lastReq.GetMeta().GetAppCode() != "lalu" {
|
||||
t.Fatalf("request app scope mismatch: %+v", fake.lastReq)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIssueAccessTokenPropagatesNotFound(t *testing.T) {
|
||||
// 无活跃会话时 user-service 返回 NotFound;service 层原样透传,由 handler 映射成 404 中文文案。
|
||||
fake := &fakeTokenIssuerClient{err: status.Error(codes.NotFound, "active session not found")}
|
||||
service := &Service{tokenIssuerClient: fake}
|
||||
|
||||
_, err := service.IssueAccessToken(context.Background(), 42)
|
||||
if grpcStatus, ok := status.FromError(err); !ok || grpcStatus.Code() != codes.NotFound {
|
||||
t.Fatalf("expected NotFound passthrough, got %v", err)
|
||||
}
|
||||
|
||||
var missing *Service = &Service{}
|
||||
if _, err := missing.IssueAccessToken(context.Background(), 42); err == nil || errors.Is(err, nil) {
|
||||
t.Fatalf("nil token client must fail closed")
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@ -194,6 +195,30 @@ func (h *Handler) SetPassword(c *gin.Context) {
|
||||
response.OK(c, gin.H{"passwordSet": true})
|
||||
}
|
||||
|
||||
func (h *Handler) IssueAccessToken(c *gin.Context) {
|
||||
userID, ok := parseInt64ID(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.IssueAccessToken(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
if grpcStatus, ok := status.FromError(err); ok && grpcStatus.Code() == codes.NotFound {
|
||||
// 无活跃会话是明确业务事实:token 是无状态 JWT,没有 active session 就没有可重签对象。
|
||||
response.NotFound(c, "该用户当前没有活跃会话")
|
||||
return
|
||||
}
|
||||
writeMutationError(c, err, "获取用户 Token 失败")
|
||||
return
|
||||
}
|
||||
// 重签出的 token 等价于账号接管能力,且 user-service 侧刻意不写 login_audit——
|
||||
// 后台操作日志是唯一审计痕迹,所以这里 fail-closed:审计落库失败就不返回 token。
|
||||
if err := shared.OperationLogWithResourceIDStrict(c, h.audit, "issue-app-user-token", "app_users", fmt.Sprintf("%d", userID), "success", fmt.Sprintf("session_id=%s", result.SessionID)); err != nil {
|
||||
response.ServerError(c, "操作日志写入失败,未返回 Token")
|
||||
return
|
||||
}
|
||||
response.OK(c, result)
|
||||
}
|
||||
|
||||
func (h *Handler) setUserStatus(c *gin.Context, status string, action string, successMessage string) {
|
||||
userID, ok := parseInt64ID(c, "id")
|
||||
if !ok {
|
||||
@ -313,6 +338,11 @@ func writeReadError(c *gin.Context, err error, fallback string) {
|
||||
response.NotFound(c, "App 用户不存在")
|
||||
return
|
||||
}
|
||||
// 读路径对外只回兜底文案、不透出内部细节;原始错误必须落日志,否则详情页 500 无从排查。
|
||||
slog.ErrorContext(c.Request.Context(), "admin_app_user_read_failed",
|
||||
slog.String("path", c.Request.URL.Path),
|
||||
slog.String("error", err.Error()),
|
||||
)
|
||||
response.ServerError(c, fallback)
|
||||
}
|
||||
|
||||
|
||||
@ -22,4 +22,5 @@ func RegisterRoutes(protected *gin.RouterGroup, h *Handler) {
|
||||
protected.POST("/app/users/:id/ban", middleware.RequirePermission("app-user:status"), h.BanUser)
|
||||
protected.POST("/app/users/:id/unban", middleware.RequirePermission("app-user:status"), h.UnbanUser)
|
||||
protected.POST("/app/users/:id/password", middleware.RequirePermission("app-user:password"), h.SetPassword)
|
||||
protected.POST("/app/users/:id/access-token", middleware.RequirePermission("app-user:token"), h.IssueAccessToken)
|
||||
}
|
||||
|
||||
@ -29,16 +29,18 @@ var (
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
userClient userclient.Client
|
||||
userAdminClient userAdminProjectionClient
|
||||
activityClient activityclient.Client
|
||||
levelAdminClient levelAdminProjectionClient
|
||||
walletClient rechargeStatsClient
|
||||
jobStore appUserExportJobStore
|
||||
jobsConfig config.JobsConfig
|
||||
adminDB *sql.DB
|
||||
userDB *sql.DB
|
||||
walletDB *sql.DB
|
||||
userClient userclient.Client
|
||||
userAdminClient userAdminProjectionClient
|
||||
// tokenIssuerClient 只在 gRPC client 具备后台重签 token 能力时非 nil。
|
||||
tokenIssuerClient accessTokenIssuerClient
|
||||
activityClient activityclient.Client
|
||||
levelAdminClient levelAdminProjectionClient
|
||||
walletClient rechargeStatsClient
|
||||
jobStore appUserExportJobStore
|
||||
jobsConfig config.JobsConfig
|
||||
adminDB *sql.DB
|
||||
userDB *sql.DB
|
||||
walletDB *sql.DB
|
||||
}
|
||||
|
||||
// rechargeStatsClient 只暴露用户后台需要的累计充值聚合;列表不能为展示字段回扫钱包流水。
|
||||
@ -77,11 +79,16 @@ func WithExportJobs(store appUserExportJobStore, cfg config.Config) ServiceOptio
|
||||
}
|
||||
|
||||
type AppUser struct {
|
||||
Age *int32 `json:"age,omitempty"`
|
||||
Age *int32 `json:"age,omitempty"`
|
||||
// AppVersion/BuildNumber/AppVersionAtMs 是最近一次真实登录(password/third_party/refresh,
|
||||
// 不含 access_resign 心跳重签)携带的客户端版本快照,仅在 GetUser 填充,列表不回扫审计表。
|
||||
AppVersion string `json:"appVersion"`
|
||||
AppVersionAtMs int64 `json:"appVersionAtMs"`
|
||||
Avatar string `json:"avatar"`
|
||||
Balances []AppUserAssetBalance `json:"balances,omitempty"`
|
||||
Ban AppUserBan `json:"ban"`
|
||||
Birth string `json:"birth"`
|
||||
BuildNumber string `json:"buildNumber"`
|
||||
Coin int64 `json:"coin"`
|
||||
Country string `json:"country"`
|
||||
CountryDisplayName string `json:"countryDisplayName"`
|
||||
@ -89,27 +96,39 @@ type AppUser struct {
|
||||
CreatedAtMs int64 `json:"createdAtMs"`
|
||||
CumulativeRechargeUSDMinor int64 `json:"cumulativeRechargeUsdMinor"`
|
||||
DefaultDisplayUserID string `json:"defaultDisplayUserId"`
|
||||
Diamond int64 `json:"diamond"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
EquippedResources []AppUserResource `json:"equippedResources,omitempty"`
|
||||
Gender string `json:"gender"`
|
||||
LastActiveAtMs int64 `json:"lastActiveAtMs"`
|
||||
LastLoginAtMs int64 `json:"lastLoginAtMs"`
|
||||
LastOperatedAtMs int64 `json:"lastOperatedAtMs"`
|
||||
LastOperator *AppUserOperator `json:"lastOperator,omitempty"`
|
||||
Levels AppUserLevels `json:"levels"`
|
||||
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
|
||||
PrettyID string `json:"prettyId"`
|
||||
RegisterDevice string `json:"registerDevice"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
RegionName string `json:"regionName"`
|
||||
Resources []AppUserResource `json:"resources,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
VIP AppUserVIP `json:"vip"`
|
||||
// Devices 是设备 tab 数据,来自 user_devices 认证成功聚合;仅在 GetUser 填充,按最近活跃倒序。
|
||||
Devices []AppUserDevice `json:"devices,omitempty"`
|
||||
Diamond int64 `json:"diamond"`
|
||||
DisplayUserID string `json:"displayUserId"`
|
||||
EquippedResources []AppUserResource `json:"equippedResources,omitempty"`
|
||||
Gender string `json:"gender"`
|
||||
LastActiveAtMs int64 `json:"lastActiveAtMs"`
|
||||
LastLoginAtMs int64 `json:"lastLoginAtMs"`
|
||||
// LastLoginIp* 来自 login_audit 全部登录类型(含 access_resign 心跳重签)的最新成功行,
|
||||
// 在线用户可精确到 25s 级新鲜度;仅在 GetUser 填充。
|
||||
LastLoginIP string `json:"lastLoginIp"`
|
||||
LastLoginIPAtMs int64 `json:"lastLoginIpAtMs"`
|
||||
LastLoginIPCountryCode string `json:"lastLoginIpCountryCode"`
|
||||
LastOperatedAtMs int64 `json:"lastOperatedAtMs"`
|
||||
LastOperator *AppUserOperator `json:"lastOperator,omitempty"`
|
||||
Levels AppUserLevels `json:"levels"`
|
||||
PrettyDisplayUserID string `json:"prettyDisplayUserId"`
|
||||
PrettyID string `json:"prettyId"`
|
||||
RegisterDevice string `json:"registerDevice"`
|
||||
// RegisterDeviceID/RegisterOsVersion 是 users 表注册时一次性快照,配合设备 tab 的注册设备区块展示。
|
||||
RegisterDeviceID string `json:"registerDeviceId"`
|
||||
RegisterOsVersion string `json:"registerOsVersion"`
|
||||
RegionID int64 `json:"regionId"`
|
||||
RegionName string `json:"regionName"`
|
||||
Resources []AppUserResource `json:"resources,omitempty"`
|
||||
Roles []string `json:"roles"`
|
||||
Status string `json:"status"`
|
||||
// ThirdPartyIdentities 暴露三方绑定 openid(provider_subject);一个用户可能绑定多个 provider。
|
||||
ThirdPartyIdentities []AppUserThirdPartyIdentity `json:"thirdPartyIdentities,omitempty"`
|
||||
UpdatedAtMs int64 `json:"updatedAtMs"`
|
||||
UserID string `json:"userId"`
|
||||
Username string `json:"username"`
|
||||
VIP AppUserVIP `json:"vip"`
|
||||
}
|
||||
|
||||
// AppUserLevels 固定三个成长轨道,前端无需按数组位置猜测富豪、游戏和魅力列。
|
||||
@ -204,6 +223,9 @@ func NewService(userClient userclient.Client, activityClient activityclient.Clie
|
||||
if adminClient, ok := any(userClient).(userAdminProjectionClient); ok {
|
||||
service.userAdminClient = adminClient
|
||||
}
|
||||
if tokenClient, ok := any(userClient).(accessTokenIssuerClient); ok {
|
||||
service.tokenIssuerClient = tokenClient
|
||||
}
|
||||
if adminClient, ok := any(activityClient).(levelAdminProjectionClient); ok {
|
||||
service.levelAdminClient = adminClient
|
||||
}
|
||||
@ -472,7 +494,9 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
||||
GREATEST(
|
||||
COALESCE((SELECT MAX(s.updated_at_ms) FROM auth_sessions s WHERE s.app_code = u.app_code AND s.user_id = u.user_id), 0),
|
||||
COALESCE((SELECT MAX(l.created_at_ms) FROM login_audit l WHERE l.app_code = u.app_code AND l.user_id = u.user_id), 0)
|
||||
)
|
||||
),
|
||||
COALESCE(u.register_device_id, ''),
|
||||
COALESCE(u.os_version, '')
|
||||
`+appUserPrettySelectColumns()+`
|
||||
FROM users u
|
||||
WHERE u.app_code = ? AND u.user_id = ?
|
||||
@ -492,6 +516,8 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
||||
&item.CreatedAtMs,
|
||||
&item.UpdatedAtMs,
|
||||
&item.LastActiveAtMs,
|
||||
&item.RegisterDeviceID,
|
||||
&item.RegisterOsVersion,
|
||||
&item.PrettyDisplayUserID,
|
||||
&item.PrettyID,
|
||||
)
|
||||
@ -524,6 +550,16 @@ func (s *Service) GetUser(ctx context.Context, userID int64) (AppUser, error) {
|
||||
if err := s.fillUserResources(ctx, items, userID); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
// 状态记录/设备 tab 的补充事实只在详情页读取,列表接口不承担这三组查询。
|
||||
if err := s.fillLoginSnapshots(ctx, &items[0], userID); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
if err := s.fillThirdPartyIdentities(ctx, &items[0], userID); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
if err := s.fillDevices(ctx, &items[0], userID); err != nil {
|
||||
return AppUser{}, err
|
||||
}
|
||||
return items[0], nil
|
||||
}
|
||||
|
||||
|
||||
@ -42,7 +42,25 @@ func OperationLogWithResourceID(c *gin.Context, logger OperationLogger, action s
|
||||
}
|
||||
// MarkAuditLogged keeps middleware from creating a second generic row for this write.
|
||||
middleware.MarkAuditLogged(c)
|
||||
_ = logger.LogOperation(OperationEvent{
|
||||
_ = logger.LogOperation(operationEvent(c, action, resource, resourceID, status, detail))
|
||||
}
|
||||
|
||||
// OperationLogWithResourceIDStrict 是 fail-closed 版本:审计写失败时返回错误、且不抑制
|
||||
// middleware 的兜底审计行。用于“操作本身在别处不留任何痕迹”的高危动作(如重签用户 token)——
|
||||
// 这类动作宁可失败,也不能在没有任何审计记录的情况下成功。
|
||||
func OperationLogWithResourceIDStrict(c *gin.Context, logger OperationLogger, action string, resource string, resourceID string, status string, detail string) error {
|
||||
if logger == nil {
|
||||
return nil
|
||||
}
|
||||
if err := logger.LogOperation(operationEvent(c, action, resource, resourceID, status, detail)); err != nil {
|
||||
return err
|
||||
}
|
||||
middleware.MarkAuditLogged(c)
|
||||
return nil
|
||||
}
|
||||
|
||||
func operationEvent(c *gin.Context, action string, resource string, resourceID string, status string, detail string) OperationEvent {
|
||||
return OperationEvent{
|
||||
RequestID: middleware.CurrentRequestID(c),
|
||||
UserID: middleware.CurrentUserID(c),
|
||||
Username: middleware.CurrentUsername(c),
|
||||
@ -56,5 +74,5 @@ func OperationLogWithResourceID(c *gin.Context, logger OperationLogger, action s
|
||||
Status: status,
|
||||
HTTPStatus: c.Writer.Status(),
|
||||
Detail: detail,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,6 +25,7 @@ var defaultPermissions = []model.Permission{
|
||||
{Name: "App 用户导出", Code: "app-user:export", Kind: "button"},
|
||||
{Name: "App 用户状态", Code: "app-user:status", Kind: "button"},
|
||||
{Name: "App 用户设置密码", Code: "app-user:password", Kind: "button"},
|
||||
{Name: "App 用户获取 Token", Code: "app-user:token", Kind: "button"},
|
||||
{Name: "等级配置查看", Code: "level-config:view", Kind: "menu"},
|
||||
{Name: "等级配置更新", Code: "level-config:update", Kind: "button"},
|
||||
{Name: "靓号管理查看", Code: "pretty-id:view", Kind: "menu"},
|
||||
@ -641,7 +642,7 @@ func defaultRolePermissionCodes(code string) []string {
|
||||
"overview:view",
|
||||
"user:view", "user:status",
|
||||
"team:view", "team:create", "team:update",
|
||||
"app-user:view", "app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password",
|
||||
"app-user:view", "app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password", "app-user:token",
|
||||
"level-config:view", "level-config:update",
|
||||
"pretty-id:view", "pretty-id:update", "pretty-id:generate", "pretty-id:grant",
|
||||
"risk-config:view", "risk-config:update",
|
||||
@ -754,7 +755,7 @@ func defaultRolePermissionMigrationCodes(code string) []string {
|
||||
case "ops-admin":
|
||||
return []string{
|
||||
"team:view", "team:create", "team:update",
|
||||
"app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password",
|
||||
"app-user:update", "app-user:level", "app-user:export", "app-user:status", "app-user:password", "app-user:token",
|
||||
"room:view", "room:update", "room:delete", "room-pin:view", "room-pin:create", "room-pin:cancel", "room-config:view", "room-config:update", "room-whitelist:view", "room-whitelist:update", "room-robot:view", "room-robot:create", "room-robot:update",
|
||||
"app-config:view", "app-config:update",
|
||||
"app-version:view", "app-version:create", "app-version:update", "app-version:delete",
|
||||
|
||||
@ -27,6 +27,10 @@ cors:
|
||||
- "X-Platform"
|
||||
- "X-App-Version"
|
||||
- "X-App-Build-Number"
|
||||
- "X-Device-Model"
|
||||
- "X-Device-Manufacturer"
|
||||
- "X-OS-Version"
|
||||
- "X-Device-IMEI"
|
||||
expose_headers: ["X-Request-ID"]
|
||||
allow_credentials: true
|
||||
max_age_sec: 600
|
||||
|
||||
@ -31,6 +31,10 @@ cors:
|
||||
- "X-Platform"
|
||||
- "X-App-Version"
|
||||
- "X-App-Build-Number"
|
||||
- "X-Device-Model"
|
||||
- "X-Device-Manufacturer"
|
||||
- "X-OS-Version"
|
||||
- "X-Device-IMEI"
|
||||
expose_headers: ["X-Request-ID"]
|
||||
allow_credentials: true
|
||||
max_age_sec: 600
|
||||
|
||||
@ -27,6 +27,10 @@ cors:
|
||||
- "X-Platform"
|
||||
- "X-App-Version"
|
||||
- "X-App-Build-Number"
|
||||
- "X-Device-Model"
|
||||
- "X-Device-Manufacturer"
|
||||
- "X-OS-Version"
|
||||
- "X-Device-IMEI"
|
||||
expose_headers: ["X-Request-ID"]
|
||||
allow_credentials: true
|
||||
max_age_sec: 600
|
||||
|
||||
@ -343,6 +343,10 @@ func Default() Config {
|
||||
"X-Platform",
|
||||
"X-App-Version",
|
||||
"X-App-Build-Number",
|
||||
"X-Device-Model",
|
||||
"X-Device-Manufacturer",
|
||||
"X-OS-Version",
|
||||
"X-Device-IMEI",
|
||||
},
|
||||
ExposeHeaders: []string{"X-Request-ID"},
|
||||
AllowCredentials: true,
|
||||
@ -489,7 +493,8 @@ func (cfg *Config) Normalize() error {
|
||||
cfg.CORS.AllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
||||
}
|
||||
if len(cfg.CORS.AllowedHeaders) == 0 {
|
||||
cfg.CORS.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform", "X-App-Version", "X-App-Build-Number"}
|
||||
// 设备资料头需要进入 CORS 允许集,H5 客户端才能与原生保持同一套设备上报协议。
|
||||
cfg.CORS.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform", "X-App-Version", "X-App-Build-Number", "X-Device-Model", "X-Device-Manufacturer", "X-OS-Version", "X-Device-IMEI"}
|
||||
}
|
||||
if len(cfg.CORS.ExposeHeaders) == 0 {
|
||||
cfg.CORS.ExposeHeaders = []string{"X-Request-ID"}
|
||||
|
||||
@ -177,6 +177,8 @@ func (h *Handler) quickCreateAccount(writer http.ResponseWriter, request *http.R
|
||||
}
|
||||
|
||||
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
|
||||
// 标准客户端不再发 body device/os_version;缺失时回退设备头,让 users.register_device/os_version 不再为空。
|
||||
body.Device, body.OSVersion = deviceFieldsWithHeaderFallback(body.Device, body.OSVersion, meta)
|
||||
resp, err := h.userClient.QuickCreateAccount(request.Context(), &userv1.QuickCreateAccountRequest{
|
||||
Meta: meta,
|
||||
Password: body.Password,
|
||||
@ -261,6 +263,8 @@ func (h *Handler) loginThirdParty(writer http.ResponseWriter, request *http.Requ
|
||||
}
|
||||
|
||||
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
|
||||
// 三方登录即注册路径同样回退设备头,注册快照与设备档案共用同一来源。
|
||||
body.Device, body.OSVersion = deviceFieldsWithHeaderFallback(body.Device, body.OSVersion, meta)
|
||||
resp, err := h.userClient.LoginThirdParty(request.Context(), &userv1.LoginThirdPartyRequest{
|
||||
Meta: meta,
|
||||
Provider: body.Provider,
|
||||
@ -339,6 +343,8 @@ func (h *Handler) registerThirdParty(writer http.ResponseWriter, request *http.R
|
||||
}
|
||||
|
||||
meta := authRequestMetaWithLoginContext(request, body.DeviceID, body.Platform, body.Language, body.Timezone, body.AppVersion, body.BuildNumber)
|
||||
// 注册专用入口的 register_device/os_version 快照缺 body 时回退设备头。
|
||||
body.Device, body.OSVersion = deviceFieldsWithHeaderFallback(body.Device, body.OSVersion, meta)
|
||||
resp, err := h.userClient.RegisterThirdParty(request.Context(), &userv1.RegisterThirdPartyRequest{
|
||||
Meta: meta,
|
||||
Provider: body.Provider,
|
||||
@ -497,6 +503,21 @@ func (h *Handler) logout(writer http.ResponseWriter, request *http.Request) {
|
||||
httpkit.WriteOK(writer, request, map[string]bool{"revoked": resp.GetRevoked()})
|
||||
}
|
||||
|
||||
// deviceFieldsWithHeaderFallback 在 body 未携带设备资料时回退到全局设备头。
|
||||
// body 字段属于旧三方登录协议,标准 Flutter 客户端只发 X-Device-Model/X-OS-Version 头;
|
||||
// 两个来源必须在 gateway 收敛一次,注册快照(users 表)与登录审计设备档案才不会互相矛盾。
|
||||
func deviceFieldsWithHeaderFallback(bodyDevice string, bodyOSVersion string, meta *userv1.RequestMeta) (string, string) {
|
||||
device := strings.TrimSpace(bodyDevice)
|
||||
if device == "" {
|
||||
device = meta.GetDeviceModel()
|
||||
}
|
||||
osVersion := strings.TrimSpace(bodyOSVersion)
|
||||
if osVersion == "" {
|
||||
osVersion = meta.GetOsVersion()
|
||||
}
|
||||
return device, osVersion
|
||||
}
|
||||
|
||||
// authRequestMeta 生成 user-service auth RPC 的统一追踪和审计元信息。
|
||||
func authRequestMeta(request *http.Request, deviceID string) *userv1.RequestMeta {
|
||||
return authRequestMetaWithLoginContext(request, deviceID, "", "", "", "", "")
|
||||
@ -538,6 +559,11 @@ func authRequestMetaWithLoginContext(
|
||||
Timezone: strings.TrimSpace(timezone),
|
||||
AppVersion: appVersion,
|
||||
BuildNumber: buildNumber,
|
||||
// 设备资料头由 App 全局注入;认证成功后 user-service 按 device_id 聚合进 user_devices。
|
||||
DeviceModel: httpkit.FirstHeader(request, "X-Device-Model"),
|
||||
DeviceManufacturer: httpkit.FirstHeader(request, "X-Device-Manufacturer"),
|
||||
OsVersion: httpkit.FirstHeader(request, "X-OS-Version"),
|
||||
Imei: httpkit.FirstHeader(request, "X-Device-IMEI"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -101,7 +101,8 @@ func normalizeCORSConfig(config CORSConfig) CORSConfig {
|
||||
config.AllowedMethods = []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}
|
||||
}
|
||||
if len(config.AllowedHeaders) == 0 {
|
||||
config.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform", "X-App-Version", "X-App-Build-Number"}
|
||||
// 设备资料头进入允许集,H5 客户端才能与原生共用同一套设备上报协议。
|
||||
config.AllowedHeaders = []string{"Authorization", "Content-Type", "X-Request-ID", "X-App-Code", "X-HY-App-Code", "X-App-Package", "X-Package-Name", "X-App-Bundle-ID", "X-Bundle-ID", "X-App-Platform", "X-Platform", "X-App-Version", "X-App-Build-Number", "X-Device-Model", "X-Device-Manufacturer", "X-OS-Version", "X-Device-IMEI"}
|
||||
}
|
||||
if len(config.ExposeHeaders) == 0 {
|
||||
config.ExposeHeaders = []string{"X-Request-ID", "X-Hyapp-Access-Token", "X-Hyapp-Token-Type", "X-Hyapp-Expires-In"}
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"hyapp/services/gateway-service/internal/transport/http/httpkit"
|
||||
)
|
||||
|
||||
// 设备资料头是 App 全局注入的,两个 meta builder(httpkit 通用版与 auth 专用版)都必须透传,
|
||||
// 否则登录链路与业务链路的设备档案会出现来源分叉。
|
||||
func TestRequestMetaBuildersCarryDeviceProfileHeaders(t *testing.T) {
|
||||
request := httptest.NewRequest("POST", "/api/v1/auth/login", nil)
|
||||
request.Header.Set("X-Device-Model", "Redmi Note 8 Pro")
|
||||
request.Header.Set("X-Device-Manufacturer", "Xiaomi")
|
||||
request.Header.Set("X-OS-Version", "13")
|
||||
request.Header.Set("X-Device-IMEI", "123456789012345")
|
||||
|
||||
authMeta := authRequestMetaWithLoginContext(request, "dev-1", "android", "en", "Asia/Riyadh", "", "")
|
||||
if authMeta.GetDeviceModel() != "Redmi Note 8 Pro" || authMeta.GetDeviceManufacturer() != "Xiaomi" ||
|
||||
authMeta.GetOsVersion() != "13" || authMeta.GetImei() != "123456789012345" {
|
||||
t.Fatalf("auth meta must carry device profile headers: %+v", authMeta)
|
||||
}
|
||||
|
||||
userMeta := httpkit.UserMeta(request, "dev-1")
|
||||
if userMeta.GetDeviceModel() != "Redmi Note 8 Pro" || userMeta.GetDeviceManufacturer() != "Xiaomi" ||
|
||||
userMeta.GetOsVersion() != "13" || userMeta.GetImei() != "123456789012345" {
|
||||
t.Fatalf("httpkit user meta must carry device profile headers: %+v", userMeta)
|
||||
}
|
||||
|
||||
// 头缺失时保持空串:user-service 端“非空覆盖”依赖空值不参与合并。
|
||||
bare := httptest.NewRequest("POST", "/api/v1/auth/refresh", nil)
|
||||
bareMeta := authRequestMetaWithLoginContext(bare, "dev-1", "", "", "", "", "")
|
||||
if bareMeta.GetDeviceModel() != "" || bareMeta.GetImei() != "" {
|
||||
t.Fatalf("missing headers must map to empty meta fields: %+v", bareMeta)
|
||||
}
|
||||
}
|
||||
|
||||
// body device/os_version 属于旧协议;标准客户端只发设备头,回退逻辑保证 register 快照不再为空,
|
||||
// 同时 body 显式值仍优先,旧客户端行为不变。
|
||||
func TestDeviceFieldsWithHeaderFallback(t *testing.T) {
|
||||
request := httptest.NewRequest("POST", "/api/v1/auth/register", nil)
|
||||
request.Header.Set("X-Device-Model", "iPhone17,2")
|
||||
request.Header.Set("X-OS-Version", "18.1")
|
||||
meta := authRequestMetaWithLoginContext(request, "dev-2", "ios", "", "", "", "")
|
||||
|
||||
device, osVersion := deviceFieldsWithHeaderFallback("", " ", meta)
|
||||
if device != "iPhone17,2" || osVersion != "18.1" {
|
||||
t.Fatalf("empty body fields must fall back to headers: device=%q os=%q", device, osVersion)
|
||||
}
|
||||
|
||||
device, osVersion = deviceFieldsWithHeaderFallback("Pixel 8", "14", meta)
|
||||
if device != "Pixel 8" || osVersion != "14" {
|
||||
t.Fatalf("body fields must win over headers: device=%q os=%q", device, osVersion)
|
||||
}
|
||||
}
|
||||
|
||||
// H5 与原生共用设备上报协议,新设备头必须落进默认 CORS 允许集,否则浏览器 preflight 直接拦掉。
|
||||
func TestCORSDefaultHeadersAllowDeviceProfile(t *testing.T) {
|
||||
config := normalizeCORSConfig(CORSConfig{})
|
||||
allowedHeaders := "," + strings.ToLower(strings.Join(config.AllowedHeaders, ",")) + ","
|
||||
for _, header := range []string{"X-Device-Model", "X-Device-Manufacturer", "X-OS-Version", "X-Device-IMEI"} {
|
||||
if !strings.Contains(allowedHeaders, ","+strings.ToLower(header)+",") {
|
||||
t.Fatalf("default CORS headers must allow device profile header %q: %+v", header, config.AllowedHeaders)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -265,6 +265,11 @@ func UserMetaWithLoginContext(request *http.Request, deviceID string, platform s
|
||||
Platform: strings.ToLower(strings.TrimSpace(platform)),
|
||||
Language: strings.TrimSpace(language),
|
||||
Timezone: strings.TrimSpace(timezone),
|
||||
// 设备资料头由 App 全局注入到每个请求;user-service 只在认证成功后把它们聚合进 user_devices。
|
||||
DeviceModel: FirstHeader(request, "X-Device-Model"),
|
||||
DeviceManufacturer: FirstHeader(request, "X-Device-Manufacturer"),
|
||||
OsVersion: FirstHeader(request, "X-OS-Version"),
|
||||
Imei: FirstHeader(request, "X-Device-IMEI"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -3702,7 +3702,7 @@ func TestJoinRoomLiteReturnsFirstScreenFieldsWithoutDisplayHydration(t *testing.
|
||||
AppScene: "voice_chat_room",
|
||||
})
|
||||
router := handler.Routes(auth.NewVerifier("secret"))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_lite","command_id":"cmd-join-lite","role":"audience","response_mode":"lite"}`)))
|
||||
request := httptest.NewRequest(http.MethodPost, "/api/v1/rooms/join", bytes.NewReader([]byte(`{"room_id":"room_lite","command_id":"cmd-join-lite","role":"audience","response_mode":"lite","presence_recovery":true}`)))
|
||||
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
|
||||
request.Header.Set("X-Request-ID", "req-join-lite")
|
||||
recorder := httptest.NewRecorder()
|
||||
@ -3712,7 +3712,7 @@ func TestJoinRoomLiteReturnsFirstScreenFieldsWithoutDisplayHydration(t *testing.
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
if roomClient.lastJoin == nil || roomClient.lastJoin.GetResponseMode() != "lite" || roomClient.lastJoin.GetEntryVehicle() != nil {
|
||||
if roomClient.lastJoin == nil || roomClient.lastJoin.GetResponseMode() != "lite" || !roomClient.lastJoin.GetPresenceRecovery() || roomClient.lastJoin.GetEntryVehicle() != nil {
|
||||
t.Fatalf("lite join must pass response_mode and skip entry vehicle: %+v", roomClient.lastJoin)
|
||||
}
|
||||
if len(walletClient.batchEquippedRequests) != 0 {
|
||||
|
||||
@ -1294,6 +1294,8 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
ResponseMode string `json:"response_mode"`
|
||||
// PresenceRecovery 只由房间心跳 NOT_FOUND 后的客户端恢复链路设置。
|
||||
PresenceRecovery bool `json:"presence_recovery"`
|
||||
}
|
||||
|
||||
if !httpkit.Decode(writer, request, &body) {
|
||||
@ -1318,8 +1320,9 @@ func (h *Handler) joinRoom(writer http.ResponseWriter, request *http.Request) {
|
||||
ActorRegionId: actorSnapshot.regionID,
|
||||
ActorDisplayProfile: actorSnapshot.displayProfile,
|
||||
// 座驾快照在进房命令提交前生成;room-service 只保存快照,不回查 wallet。
|
||||
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request, responseMode),
|
||||
ResponseMode: responseMode,
|
||||
EntryVehicle: h.joinRoomEntryVehicleSnapshot(request, responseMode),
|
||||
ResponseMode: responseMode,
|
||||
PresenceRecovery: body.PresenceRecovery,
|
||||
})
|
||||
if err != nil {
|
||||
httpkit.WriteRPCError(writer, request, err)
|
||||
|
||||
@ -137,6 +137,9 @@ type JoinRoom struct {
|
||||
EntryVehicle EntryVehicleSnapshot `json:"entry_vehicle,omitempty"`
|
||||
// ActorDisplayProfile 是 gateway 在进房入口固化的展示快照;只服务 IM/UI 兜底,不参与 presence 权限和统计。
|
||||
ActorDisplayProfile UserDisplayProfile `json:"actor_display_profile,omitempty"`
|
||||
// PresenceRecovery 表示客户端会话仍在,只需恢复被 stale worker 清理的业务 presence。
|
||||
// 该命令不能制造新的进房展示或统计事实。
|
||||
PresenceRecovery bool `json:"presence_recovery,omitempty"`
|
||||
}
|
||||
|
||||
// Type 返回命令类型。
|
||||
|
||||
@ -36,6 +36,7 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
ActorCountryID: req.GetActorCountryId(),
|
||||
ActorRegionID: req.GetActorRegionId(),
|
||||
ActorIsRobot: req.GetActorIsRobot(),
|
||||
PresenceRecovery: req.GetPresenceRecovery(),
|
||||
ActorDisplayProfile: userDisplayProfileFromProto(req.GetActorDisplayProfile()),
|
||||
// entry_vehicle 来自 gateway 入房瞬间的 wallet 快照,写入 command log 后可稳定重放。
|
||||
EntryVehicle: entryVehicleSnapshotFromProto(req.GetEntryVehicle()),
|
||||
@ -98,12 +99,20 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
joinRole = "owner"
|
||||
}
|
||||
if existing, exists := current.OnlineUsers[cmd.ActorUserID()]; exists {
|
||||
// 重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新 last_seen,
|
||||
// 但房间 UI 仍需要一条入房飘窗。这里把事件放进 directIMRecords,不进入 durable outbox,
|
||||
// 避免统计服务把同一个在线用户重复计为进房活跃。
|
||||
existing.LastSeenAtMS = now.UnixMilli()
|
||||
current.Version++
|
||||
if cmd.PresenceRecovery {
|
||||
// 恢复请求与正常 heartbeat 可能并发;presence 已经存在时仍只刷新
|
||||
// last_seen,不能因竞态补发入场飘窗。
|
||||
snapshot := current.ToProto()
|
||||
result.snapshot = snapshot
|
||||
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||||
return result, nil, nil
|
||||
}
|
||||
|
||||
// 普通重复 JoinRoom 表示用户重新打开房间页或重连:业务 presence 只刷新
|
||||
// last_seen,但房间 UI 仍需要一条入房飘窗。该事件只进 direct IM,不进入 durable
|
||||
// outbox,避免统计服务把同一在线用户重复计为进房活跃。
|
||||
joinedDisplayEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, firstNonEmpty(existing.Role, cmd.Role), current.VisibleRegionID, joinVIP))
|
||||
if err != nil {
|
||||
return mutationResult{}, nil, err
|
||||
@ -121,6 +130,15 @@ func (s *Service) JoinRoom(ctx context.Context, req *roomv1.JoinRoomRequest) (*r
|
||||
LastSeenAtMS: now.UnixMilli(),
|
||||
}
|
||||
current.Version++
|
||||
if cmd.PresenceRecovery {
|
||||
// stale worker 只清理了 room-service 的业务 presence,客户端 IM/TRTC 会话仍在。
|
||||
// 这里只补回 Room Cell 状态并返回快照;不创建 sync IM、direct IM 或 durable
|
||||
// outbox,否则全房会重复看到进房横幅/座驾,统计也会把恢复误当新的 RoomUserJoined。
|
||||
snapshot := current.ToProto()
|
||||
result.snapshot = snapshot
|
||||
result.user = findProtoUser(snapshot, cmd.ActorUserID())
|
||||
return result, nil, nil
|
||||
}
|
||||
|
||||
// JoinRoom 成功需要 outbox 事件,腾讯云 IM 房间系统消息由 worker 异步投递。
|
||||
joinedEvent, err := outbox.Build(current.RoomID, "RoomUserJoined", current.Version, now, roomUserJoinedEventFromCommand(cmd, joinRole, current.VisibleRegionID, joinVIP))
|
||||
|
||||
@ -156,6 +156,58 @@ func TestRepeatJoinRoomPublishesEntryBroadcastDirectIMOnly(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPresenceRecoveryRestoresOnlineStateWithoutJoinEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
now := &fixedRoomRocketClock{now: time.Date(2026, 7, 12, 6, 0, 0, 0, time.UTC)}
|
||||
directIM := newRecordingRoomDirectIMPublisher()
|
||||
svc := newRocketTestServiceWithDirectIM(t, repository, &rocketTestWallet{}, now, nil, directIM)
|
||||
|
||||
roomID := "room-presence-recovery-no-entry"
|
||||
userID := int64(1002)
|
||||
createRocketRoom(t, ctx, svc, roomID, 1001, 9001)
|
||||
joinRocketRoom(t, ctx, svc, roomID, userID)
|
||||
waitForRoomDirectIMCounts(t, directIM, map[string]int{"RoomUserJoined": 1})
|
||||
if _, err := svc.LeaveRoom(ctx, &roomv1.LeaveRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, userID),
|
||||
}); err != nil {
|
||||
t.Fatalf("remove stale presence fixture failed: %v", err)
|
||||
}
|
||||
|
||||
joinedBefore := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]
|
||||
directJoinedBefore := directIM.counts()["RoomUserJoined"]
|
||||
resp, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, userID),
|
||||
Role: "audience",
|
||||
PresenceRecovery: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("presence recovery failed: %v", err)
|
||||
}
|
||||
if resp.GetUser().GetUserId() != userID {
|
||||
t.Fatalf("presence recovery must restore current user: %+v", resp.GetUser())
|
||||
}
|
||||
if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != joinedBefore {
|
||||
t.Fatalf("presence recovery must not publish durable RoomUserJoined: before=%d after=%d", joinedBefore, got)
|
||||
}
|
||||
if got := directIM.counts()["RoomUserJoined"]; got != directJoinedBefore {
|
||||
t.Fatalf("presence recovery must not publish entry direct IM: before=%d after=%d", directJoinedBefore, got)
|
||||
}
|
||||
if _, err := svc.JoinRoom(ctx, &roomv1.JoinRoomRequest{
|
||||
Meta: roomservice.NewRequestMeta(roomID, userID),
|
||||
Role: "audience",
|
||||
PresenceRecovery: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("idempotent presence recovery failed: %v", err)
|
||||
}
|
||||
if got := outboxEventCounts(t, ctx, repository)["RoomUserJoined"]; got != joinedBefore {
|
||||
t.Fatalf("recovery racing with restored presence must not publish durable join: before=%d after=%d", joinedBefore, got)
|
||||
}
|
||||
if got := directIM.counts()["RoomUserJoined"]; got != directJoinedBefore {
|
||||
t.Fatalf("recovery racing with restored presence must not publish entry direct IM: before=%d after=%d", directJoinedBefore, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestJoinRoomLiteTrimsResponseSnapshotOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
|
||||
@ -1453,3 +1453,22 @@ CREATE TABLE IF NOT EXISTS login_risk_user_whitelist (
|
||||
KEY idx_login_risk_user_whitelist_enabled (app_code, enabled, user_id),
|
||||
KEY idx_login_risk_user_whitelist_updated (app_code, updated_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='登录风险用户白名单表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_devices (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||
device_id VARCHAR(128) NOT NULL COMMENT '设备 ID,与 auth_sessions.device_id 同源',
|
||||
platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '平台,android/ios',
|
||||
device_model VARCHAR(128) NOT NULL DEFAULT '' COMMENT '设备型号,例如 Redmi Note 8 Pro',
|
||||
manufacturer VARCHAR(64) NOT NULL DEFAULT '' COMMENT '设备厂商,例如 Xiaomi/Apple',
|
||||
os_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '系统版本',
|
||||
imei VARCHAR(64) NOT NULL DEFAULT '' COMMENT '设备 IMEI,Android 10+ 无特权权限时为空',
|
||||
app_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次认证客户端版本',
|
||||
build_number VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次认证客户端构建号',
|
||||
first_seen_at_ms BIGINT NOT NULL COMMENT '首次认证成功时间,UTC epoch ms',
|
||||
last_seen_at_ms BIGINT NOT NULL COMMENT '最近认证成功时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, user_id, device_id),
|
||||
KEY idx_user_devices_user_last_seen (app_code, user_id, last_seen_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户设备档案表,认证成功后按设备聚合最新上报资料';
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||
|
||||
USE hyapp_user;
|
||||
|
||||
-- 后台用户详情设备 tab 需要注册之后的持续设备画像:users.register_device/os_version 只是注册时一次性快照,
|
||||
-- 真实客户端此前从未上报,线上均为空。user_devices 在每次认证成功(登录/注册/refresh)后按
|
||||
-- (app_code, user_id, device_id) 聚合客户端设备头,字段以“非空覆盖”合并,空值不抹掉历史上报。
|
||||
-- CREATE TABLE IF NOT EXISTS 使已通过 initdb 建表的环境可以安全重复执行。
|
||||
CREATE TABLE IF NOT EXISTS user_devices (
|
||||
app_code VARCHAR(32) NOT NULL DEFAULT 'lalu' COMMENT '应用编码,用于多租户隔离',
|
||||
user_id BIGINT NOT NULL COMMENT '用户 ID',
|
||||
device_id VARCHAR(128) NOT NULL COMMENT '设备 ID,与 auth_sessions.device_id 同源',
|
||||
platform VARCHAR(32) NOT NULL DEFAULT '' COMMENT '平台,android/ios',
|
||||
device_model VARCHAR(128) NOT NULL DEFAULT '' COMMENT '设备型号,例如 Redmi Note 8 Pro',
|
||||
manufacturer VARCHAR(64) NOT NULL DEFAULT '' COMMENT '设备厂商,例如 Xiaomi/Apple',
|
||||
os_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '系统版本',
|
||||
imei VARCHAR(64) NOT NULL DEFAULT '' COMMENT '设备 IMEI,Android 10+ 无特权权限时为空',
|
||||
app_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次认证客户端版本',
|
||||
build_number VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最近一次认证客户端构建号',
|
||||
first_seen_at_ms BIGINT NOT NULL COMMENT '首次认证成功时间,UTC epoch ms',
|
||||
last_seen_at_ms BIGINT NOT NULL COMMENT '最近认证成功时间,UTC epoch ms',
|
||||
created_at_ms BIGINT NOT NULL COMMENT '创建时间,UTC epoch ms',
|
||||
updated_at_ms BIGINT NOT NULL COMMENT '更新时间,UTC epoch ms',
|
||||
PRIMARY KEY (app_code, user_id, device_id),
|
||||
KEY idx_user_devices_user_last_seen (app_code, user_id, last_seen_at_ms)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户设备档案表,认证成功后按设备聚合最新上报资料';
|
||||
@ -140,6 +140,39 @@ type LoginAudit struct {
|
||||
CreatedAtMs int64
|
||||
}
|
||||
|
||||
// UserDevice 是认证成功后按 (app_code, user_id, device_id) 聚合的设备档案快照。
|
||||
// 它只服务后台设备画像展示,不参与登录判定;字段允许为空,客户端上报能力决定完整度。
|
||||
type UserDevice struct {
|
||||
// AppCode 是设备档案所属 App 租户键。
|
||||
AppCode string
|
||||
// UserID 是设备归属用户。
|
||||
UserID int64
|
||||
// DeviceID 与 auth_sessions.device_id 同源(android_<ANDROID_ID> / ios_<IDFV>)。
|
||||
DeviceID string
|
||||
// Platform 是客户端平台快照,例如 android/ios。
|
||||
Platform string
|
||||
// DeviceModel 是设备型号,例如 Redmi Note 8 Pro。
|
||||
DeviceModel string
|
||||
// Manufacturer 是设备厂商,例如 Xiaomi/Apple。
|
||||
Manufacturer string
|
||||
// OSVersion 是系统版本,例如 Android 的 "13" 或 iOS 的 "17.5"。
|
||||
OSVersion string
|
||||
// IMEI 在 Android 10+ 无特权权限时为空,字段保留给可获取的旧设备。
|
||||
IMEI string
|
||||
// AppVersion 是最近一次认证成功时的客户端语义版本。
|
||||
AppVersion string
|
||||
// BuildNumber 是最近一次认证成功时的客户端构建号。
|
||||
BuildNumber string
|
||||
// FirstSeenAtMs 是该设备首次通过认证的时间。
|
||||
FirstSeenAtMs int64
|
||||
// LastSeenAtMs 是该设备最近一次认证成功时间,后台按它倒序展示设备列表。
|
||||
LastSeenAtMs int64
|
||||
// CreatedAtMs 是档案创建时间。
|
||||
CreatedAtMs int64
|
||||
// UpdatedAtMs 是档案更新时间。
|
||||
UpdatedAtMs int64
|
||||
}
|
||||
|
||||
// ThirdPartyProfile 是三方凭证校验后的稳定身份结果。
|
||||
type ThirdPartyProfile struct {
|
||||
// ProviderSubject 是三方平台内稳定身份。
|
||||
|
||||
@ -34,6 +34,14 @@ type Meta struct {
|
||||
Language string
|
||||
// Timezone 是客户端 IANA 时区,gateway 只用作弱信号快拦。
|
||||
Timezone string
|
||||
// DeviceModel 是客户端上报的设备型号快照(例如 Redmi Note 8 Pro);只进设备档案,不参与认证判定。
|
||||
DeviceModel string
|
||||
// DeviceManufacturer 是设备厂商快照(例如 Xiaomi/Apple)。
|
||||
DeviceManufacturer string
|
||||
// OSVersion 是客户端系统版本快照(例如 Android 13 的 "13")。
|
||||
OSVersion string
|
||||
// IMEI 是客户端可获取时上报的设备 IMEI;Android 10+ 无特权权限拿不到,预期常为空。
|
||||
IMEI string
|
||||
}
|
||||
|
||||
func (s *Service) audit(ctx context.Context, meta Meta, userID int64, loginType string, provider string, result string, failureCode string) {
|
||||
|
||||
@ -67,6 +67,8 @@ func (s *Service) LoginPassword(ctx context.Context, displayUserID string, passw
|
||||
}
|
||||
|
||||
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
|
||||
// 登录成功即刷新设备档案;写入是 best-effort,不影响 token 返回。
|
||||
s.recordUserDevice(ctx, meta, user.UserID, deviceID)
|
||||
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
||||
if err != nil {
|
||||
return authdomain.Token{}, err
|
||||
|
||||
@ -121,6 +121,8 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
|
||||
}, nil
|
||||
}
|
||||
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
|
||||
// 快捷账号也是可登录主体,设备档案与普通注册保持一致;机器人分支在上方已提前返回。
|
||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
||||
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
||||
if tokenErr != nil {
|
||||
return authdomain.Token{}, tokenErr
|
||||
|
||||
@ -54,6 +54,8 @@ type AuthRepository interface {
|
||||
FindActiveSessionByRefreshHash(ctx context.Context, refreshTokenHash string, nowMs int64) (authdomain.Session, error)
|
||||
// FindActiveSessionByID 通过 session_id 查找未过期未吊销 session,用于只重签 access token 的链路。
|
||||
FindActiveSessionByID(ctx context.Context, sessionID string, nowMs int64) (authdomain.Session, error)
|
||||
// FindLatestActiveSessionByUser 返回用户最新未过期未吊销 session,供后台重签 access token;无会话返回 NotFound。
|
||||
FindLatestActiveSessionByUser(ctx context.Context, userID int64, nowMs int64) (authdomain.Session, error)
|
||||
// ReplaceSession 原子吊销旧 session 并创建新 session。
|
||||
ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error
|
||||
// RevokeSession 按 session_id 或 refresh token hash 吊销 session。
|
||||
@ -80,6 +82,8 @@ type AuthRepository interface {
|
||||
UpsertRegisterRiskConfig(ctx context.Context, config authdomain.RegisterRiskConfig) (authdomain.RegisterRiskConfig, error)
|
||||
// RecordLoginAudit 记录登录审计,主链路不会因为审计失败而失败。
|
||||
RecordLoginAudit(ctx context.Context, audit authdomain.LoginAudit) error
|
||||
// UpsertUserDevice 以“非空覆盖”语义合并设备档案;认证主链路不会因为它失败而失败。
|
||||
UpsertUserDevice(ctx context.Context, device authdomain.UserDevice) error
|
||||
// CreateLoginIPRiskJob 创建登录后异步 IP 风控任务,失败只影响复核能力。
|
||||
CreateLoginIPRiskJob(ctx context.Context, job authdomain.LoginIPRiskJob) error
|
||||
// ClaimLoginIPRiskJobs 按 DB 租约认领待处理风控任务。
|
||||
|
||||
@ -245,6 +245,8 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
|
||||
}
|
||||
|
||||
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultSuccess, "")
|
||||
// 老用户换机三方登录也要立即出现在设备 tab;header 缺失时回退 body 设备字段。
|
||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
||||
// isNewUser=false 表示本次只创建 session,没有创建用户。
|
||||
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
||||
if err != nil {
|
||||
@ -296,6 +298,8 @@ func (s *Service) registerExistingThirdParty(ctx context.Context, identity authd
|
||||
}
|
||||
|
||||
s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "")
|
||||
// 补齐资料完成注册同样属于认证成功,设备档案与 session 创建保持同步。
|
||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), updated.UserID, registration.DeviceID)
|
||||
token, err := s.issueToken(updated, session.SessionID, refreshToken, session.ExpiresAtMs)
|
||||
if err != nil {
|
||||
return authdomain.Token{}, false, err
|
||||
@ -354,6 +358,8 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
|
||||
if err == nil {
|
||||
// repository 事务成功后,用户、默认短号、三方绑定和首个 session 同时存在。
|
||||
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
|
||||
// 新注册用户的首个设备档案与注册事务同请求写入,后台无需等下次登录。
|
||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
||||
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
||||
if tokenErr != nil {
|
||||
return authdomain.Token{}, false, tokenErr
|
||||
@ -436,6 +442,8 @@ func (s *Service) createRegisteredThirdPartyUser(ctx context.Context, provider s
|
||||
err = s.authRepository.CreateThirdPartyUser(ctx, user, displayIdentity, thirdParty, session, inviteBind, maxAccountsPerDevice)
|
||||
if err == nil {
|
||||
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
|
||||
// 注册专用入口创建的 completed 用户同样落首个设备档案。
|
||||
s.recordUserDevice(ctx, deviceMetaWithRegistration(meta, registration), user.UserID, registration.DeviceID)
|
||||
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
|
||||
if tokenErr != nil {
|
||||
return authdomain.Token{}, false, tokenErr
|
||||
|
||||
@ -80,6 +80,8 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceI
|
||||
}
|
||||
|
||||
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultSuccess, "")
|
||||
// refresh 是登录态存续的主路径;老设备升级新客户端后,设备档案主要在这里补齐。
|
||||
s.recordUserDevice(ctx, meta, session.UserID, deviceID)
|
||||
return s.issueToken(user, newSession.SessionID, newRefreshToken, newSession.ExpiresAtMs)
|
||||
}
|
||||
|
||||
@ -125,6 +127,45 @@ func (s *Service) IssueAccessTokenForSession(ctx context.Context, userID int64,
|
||||
return s.issueToken(user, session.SessionID, "", session.ExpiresAtMs)
|
||||
}
|
||||
|
||||
// AdminIssueUserAccessToken 供后台按用户当前最新 active session 重签 access token。
|
||||
// 与 IssueAccessTokenForSession 的关键差异:这里不写 login_audit,也不更新 session 行。
|
||||
// 后台取 token 不是用户行为,写审计会污染“最近活跃 = GREATEST(session.updated, audit.created)”口径;
|
||||
// 取用责任由 admin 侧操作日志承担。签发本身复用 issueToken 的 claim 组装,不复制 JWT 逻辑。
|
||||
func (s *Service) AdminIssueUserAccessToken(ctx context.Context, userID int64, meta Meta) (authdomain.Token, authdomain.Session, error) {
|
||||
if userID <= 0 {
|
||||
return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.InvalidArgument, "user_id is required")
|
||||
}
|
||||
if s.authRepository == nil {
|
||||
return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.Unavailable, "auth repository is not configured")
|
||||
}
|
||||
if !s.TokenConfigValid() {
|
||||
// 签发配置残缺时 fail closed,不能签出 gateway 无法验证的 token。
|
||||
return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.Unavailable, "token config is invalid")
|
||||
}
|
||||
|
||||
nowMs := s.now().UnixMilli()
|
||||
// 没有可信 sid 输入,取最新 active session 近似客户端当前会话;无会话时透传 NotFound 给后台展示。
|
||||
session, err := s.authRepository.FindLatestActiveSessionByUser(ctx, userID, nowMs)
|
||||
if err != nil {
|
||||
return authdomain.Token{}, authdomain.Session{}, err
|
||||
}
|
||||
user, err := s.freshUser(ctx, userID, nowMs, meta.RequestID)
|
||||
if err != nil {
|
||||
return authdomain.Token{}, authdomain.Session{}, err
|
||||
}
|
||||
if !user.CanLogin() {
|
||||
// 禁用用户不能经由后台间接拿到有效 token,语义与所有签发路径一致。
|
||||
return authdomain.Token{}, authdomain.Session{}, xerr.New(xerr.UserDisabled, "user is disabled")
|
||||
}
|
||||
|
||||
// refreshToken 传空:后台没有轮换会话的理由,响应中也绝不携带 refresh 原文。
|
||||
token, err := s.issueToken(user, session.SessionID, "", session.ExpiresAtMs)
|
||||
if err != nil {
|
||||
return authdomain.Token{}, authdomain.Session{}, err
|
||||
}
|
||||
return token, session, nil
|
||||
}
|
||||
|
||||
// Logout 失效指定 session 或 refresh token。
|
||||
func (s *Service) Logout(ctx context.Context, sessionID string, refreshToken string, meta Meta) (bool, error) {
|
||||
if strings.TrimSpace(sessionID) == "" && strings.TrimSpace(refreshToken) == "" {
|
||||
|
||||
73
services/user-service/internal/service/auth/user_device.go
Normal file
73
services/user-service/internal/service/auth/user_device.go
Normal file
@ -0,0 +1,73 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
"hyapp/pkg/logx"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
)
|
||||
|
||||
// recordUserDevice 在认证成功后合并当前设备档案。
|
||||
// 该写入是 best-effort:device_id 为空(老客户端/后台调用)直接跳过;写失败只记日志,
|
||||
// 绝不让已经成功的登录/注册/refresh 因为设备画像落库失败而回吐错误。
|
||||
func (s *Service) recordUserDevice(ctx context.Context, meta Meta, userID int64, deviceID string) {
|
||||
deviceID = strings.TrimSpace(deviceID)
|
||||
if s.authRepository == nil || userID <= 0 || deviceID == "" {
|
||||
return
|
||||
}
|
||||
|
||||
nowMs := s.now().UnixMilli()
|
||||
device := authdomain.UserDevice{
|
||||
AppCode: appcode.Normalize(meta.AppCode),
|
||||
UserID: userID,
|
||||
DeviceID: truncateDeviceField(deviceID, 128),
|
||||
Platform: truncateDeviceField(normalizePlatform(meta.Platform), 32),
|
||||
DeviceModel: truncateDeviceField(meta.DeviceModel, 128),
|
||||
Manufacturer: truncateDeviceField(meta.DeviceManufacturer, 64),
|
||||
OSVersion: truncateDeviceField(meta.OSVersion, 64),
|
||||
IMEI: truncateDeviceField(meta.IMEI, 64),
|
||||
AppVersion: normalizeAuditClientVersion(meta.AppVersion),
|
||||
BuildNumber: normalizeAuditClientVersion(meta.BuildNumber),
|
||||
// first_seen 只在 INSERT 生效;ON DUPLICATE KEY 更新时只有 last_seen/updated 前移。
|
||||
FirstSeenAtMs: nowMs,
|
||||
LastSeenAtMs: nowMs,
|
||||
CreatedAtMs: nowMs,
|
||||
UpdatedAtMs: nowMs,
|
||||
}
|
||||
if err := s.authRepository.UpsertUserDevice(ctx, device); err != nil {
|
||||
// 失败必须可见:设备 tab 静默缺数据比登录多一条错误日志更难排查。
|
||||
logx.Error(
|
||||
logx.With(ctx, slog.String("request_id", meta.RequestID), slog.String("app_code", device.AppCode)),
|
||||
"user_device_upsert_failed",
|
||||
err,
|
||||
slog.String("component", "user_auth"),
|
||||
slog.Int64("user_id", userID),
|
||||
slog.String("device_id", device.DeviceID),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// deviceMetaWithRegistration 用注册 body 的设备字段补齐 Meta 缺口。
|
||||
// 设备资料有两个来源:gateway 设备头(新客户端全局注入)和注册 body(旧协议字段);
|
||||
// 任意一侧可能单独缺失,落档案前统一取“先 header 后 body”的非空值。
|
||||
func deviceMetaWithRegistration(meta Meta, registration authdomain.ThirdPartyRegistration) Meta {
|
||||
meta.Platform = firstNonEmpty(meta.Platform, registration.Platform)
|
||||
meta.DeviceModel = firstNonEmpty(meta.DeviceModel, registration.Device)
|
||||
meta.OSVersion = firstNonEmpty(meta.OSVersion, registration.OSVersion)
|
||||
meta.AppVersion = firstNonEmpty(meta.AppVersion, registration.AppVersion)
|
||||
meta.BuildNumber = firstNonEmpty(meta.BuildNumber, registration.BuildNumber)
|
||||
return meta
|
||||
}
|
||||
|
||||
// truncateDeviceField 按列宽截断客户端可控字符串,超长上报不能让整条设备档案 INSERT 失败。
|
||||
func truncateDeviceField(value string, maxRunes int) string {
|
||||
value = strings.TrimSpace(value)
|
||||
runes := []rune(value)
|
||||
if len(runes) <= maxRunes {
|
||||
return value
|
||||
}
|
||||
return string(runes[:maxRunes])
|
||||
}
|
||||
@ -0,0 +1,246 @@
|
||||
// user_device_admin_token_test 验证认证成功后的设备档案聚合和后台按用户重签 access token 的语义。
|
||||
package auth_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"hyapp/pkg/xerr"
|
||||
authservice "hyapp/services/user-service/internal/service/auth"
|
||||
"hyapp/services/user-service/internal/testutil/mysqltest"
|
||||
)
|
||||
|
||||
type userDeviceRow struct {
|
||||
Platform string
|
||||
DeviceModel string
|
||||
Manufacturer string
|
||||
OSVersion string
|
||||
IMEI string
|
||||
AppVersion string
|
||||
BuildNumber string
|
||||
FirstSeenAtMs int64
|
||||
LastSeenAtMs int64
|
||||
}
|
||||
|
||||
func queryUserDevice(t *testing.T, repository *mysqltest.Repository, userID int64, deviceID string) userDeviceRow {
|
||||
t.Helper()
|
||||
var row userDeviceRow
|
||||
if err := repository.RawDB().QueryRowContext(context.Background(), `
|
||||
SELECT 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 = 'lalu' AND user_id = ? AND device_id = ?
|
||||
`, userID, deviceID).Scan(&row.Platform, &row.DeviceModel, &row.Manufacturer, &row.OSVersion, &row.IMEI, &row.AppVersion, &row.BuildNumber, &row.FirstSeenAtMs, &row.LastSeenAtMs); err != nil {
|
||||
t.Fatalf("query user_devices failed: %v", err)
|
||||
}
|
||||
return row
|
||||
}
|
||||
|
||||
func countUserDevices(t *testing.T, repository *mysqltest.Repository, userID int64) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := repository.RawDB().QueryRowContext(context.Background(), `
|
||||
SELECT COUNT(*) FROM user_devices WHERE app_code = 'lalu' AND user_id = ?
|
||||
`, userID).Scan(&count); err != nil {
|
||||
t.Fatalf("count user_devices failed: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func countLoginAudit(t *testing.T, repository *mysqltest.Repository, userID int64) int {
|
||||
t.Helper()
|
||||
var count int
|
||||
if err := repository.RawDB().QueryRowContext(context.Background(), `
|
||||
SELECT COUNT(*) FROM login_audit WHERE app_code = 'lalu' AND user_id = ?
|
||||
`, userID).Scan(&count); err != nil {
|
||||
t.Fatalf("count login_audit failed: %v", err)
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func TestThirdPartyLoginUpsertsUserDeviceWithNonEmptyWins(t *testing.T) {
|
||||
// 首次注册写全量设备资料;后续登录只带部分字段时,空值不能抹掉已上报的型号/厂商,last_seen 必须前移。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
now := time.UnixMilli(1000)
|
||||
authSvc := newAuthService(repository, &now, []int64{910001, 910002}, []string{"7100001", "7100002"})
|
||||
|
||||
// 与真实链路一致:gateway 把版本头同时写入 Meta 和注册字段(LoginThirdParty 会用注册字段覆盖 Meta)。
|
||||
registration := thirdPartyRegistration("android")
|
||||
registration.AppVersion = "1.2.0"
|
||||
registration.BuildNumber = "120"
|
||||
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-device-upsert", registration, authservice.Meta{
|
||||
AppCode: "lalu",
|
||||
RequestID: "req-device-register",
|
||||
Platform: "android",
|
||||
AppVersion: "1.2.0",
|
||||
BuildNumber: "120",
|
||||
DeviceModel: "Redmi Note 8 Pro",
|
||||
DeviceManufacturer: "Xiaomi",
|
||||
OSVersion: "13",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginThirdParty failed: %v", err)
|
||||
}
|
||||
|
||||
row := queryUserDevice(t, repository, token.UserID, registration.DeviceID)
|
||||
if row.DeviceModel != "Redmi Note 8 Pro" || row.Manufacturer != "Xiaomi" || row.OSVersion != "13" || row.Platform != "android" {
|
||||
t.Fatalf("registered device profile mismatch: %+v", row)
|
||||
}
|
||||
if row.AppVersion != "1.2.0" || row.BuildNumber != "120" || row.IMEI != "" {
|
||||
t.Fatalf("registered device version snapshot mismatch: %+v", row)
|
||||
}
|
||||
if row.FirstSeenAtMs != 1000 || row.LastSeenAtMs != 1000 {
|
||||
t.Fatalf("registered device seen timestamps mismatch: %+v", row)
|
||||
}
|
||||
|
||||
// 二次登录只携带新版本号,设备头缺失:型号/厂商/系统版本保持旧值,版本与 last_seen 前移,first_seen 不变。
|
||||
now = time.UnixMilli(9000)
|
||||
reloginRegistration := registration
|
||||
reloginRegistration.AppVersion = "1.3.0"
|
||||
reloginRegistration.BuildNumber = "130"
|
||||
if _, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-device-upsert", reloginRegistration, authservice.Meta{
|
||||
AppCode: "lalu",
|
||||
RequestID: "req-device-relogin",
|
||||
Platform: "android",
|
||||
AppVersion: "1.3.0",
|
||||
BuildNumber: "130",
|
||||
}); err != nil {
|
||||
t.Fatalf("second LoginThirdParty failed: %v", err)
|
||||
}
|
||||
|
||||
row = queryUserDevice(t, repository, token.UserID, registration.DeviceID)
|
||||
if row.DeviceModel != "Redmi Note 8 Pro" || row.Manufacturer != "Xiaomi" || row.OSVersion != "13" {
|
||||
t.Fatalf("non-empty wins violated: %+v", row)
|
||||
}
|
||||
if row.AppVersion != "1.3.0" || row.BuildNumber != "130" {
|
||||
t.Fatalf("app version must follow latest login: %+v", row)
|
||||
}
|
||||
if row.FirstSeenAtMs != 1000 || row.LastSeenAtMs != 9000 {
|
||||
t.Fatalf("seen timestamps mismatch after relogin: %+v", row)
|
||||
}
|
||||
if count := countUserDevices(t, repository, token.UserID); count != 1 {
|
||||
t.Fatalf("same device must stay one row, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRefreshTokenUpsertsUserDeviceFromMeta(t *testing.T) {
|
||||
// refresh 是登录态存续主路径:升级新客户端后设备头应通过 refresh 补齐设备档案。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
now := time.UnixMilli(1000)
|
||||
authSvc := newAuthService(repository, &now, []int64{910003}, []string{"7100003"})
|
||||
|
||||
registration := thirdPartyRegistration("ios")
|
||||
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-device-refresh", registration, authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginThirdParty failed: %v", err)
|
||||
}
|
||||
|
||||
now = time.UnixMilli(5000)
|
||||
if _, err := authSvc.RefreshToken(ctx, token.RefreshToken, registration.DeviceID, authservice.Meta{
|
||||
AppCode: "lalu",
|
||||
RequestID: "req-refresh",
|
||||
Platform: "ios",
|
||||
AppVersion: "2.0.0",
|
||||
DeviceModel: "iPhone17,2",
|
||||
DeviceManufacturer: "Apple",
|
||||
OSVersion: "18.1",
|
||||
}); err != nil {
|
||||
t.Fatalf("RefreshToken failed: %v", err)
|
||||
}
|
||||
|
||||
row := queryUserDevice(t, repository, token.UserID, registration.DeviceID)
|
||||
if row.DeviceModel != "iPhone17,2" || row.Manufacturer != "Apple" || row.OSVersion != "18.1" || row.AppVersion != "2.0.0" {
|
||||
t.Fatalf("refresh must backfill device profile: %+v", row)
|
||||
}
|
||||
if row.FirstSeenAtMs != 1000 || row.LastSeenAtMs != 5000 {
|
||||
t.Fatalf("refresh seen timestamps mismatch: %+v", row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminIssueUserAccessTokenUsesNewestSessionWithoutAuditOrSessionMutation(t *testing.T) {
|
||||
// 后台重签必须选最新 active session、不写 login_audit、不更新 session 行——
|
||||
// 否则会污染“最近活跃/最近登录”口径,让运营误判用户在线。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
now := time.UnixMilli(1000)
|
||||
authSvc := newAuthService(repository, &now, []int64{910004}, []string{"7100004"})
|
||||
|
||||
registration := thirdPartyRegistration("android")
|
||||
first, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-admin-token", registration, authservice.Meta{AppCode: "lalu", RequestID: "req-login-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("first LoginThirdParty failed: %v", err)
|
||||
}
|
||||
// 同一用户第二台设备再登录,形成两个 active session;最新 created_at_ms 的必须胜出。
|
||||
now = time.UnixMilli(2000)
|
||||
secondRegistration := registration
|
||||
secondRegistration.DeviceID = "dev-android-second"
|
||||
second, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-admin-token", secondRegistration, authservice.Meta{AppCode: "lalu", RequestID: "req-login-2"})
|
||||
if err != nil {
|
||||
t.Fatalf("second LoginThirdParty failed: %v", err)
|
||||
}
|
||||
if first.SessionID == second.SessionID {
|
||||
t.Fatalf("expected two distinct sessions")
|
||||
}
|
||||
|
||||
auditBefore := countLoginAudit(t, repository, first.UserID)
|
||||
var updatedBefore int64
|
||||
if err := repository.RawDB().QueryRowContext(ctx, `
|
||||
SELECT updated_at_ms FROM auth_sessions WHERE app_code = 'lalu' AND session_id = ?
|
||||
`, second.SessionID).Scan(&updatedBefore); err != nil {
|
||||
t.Fatalf("query session updated_at failed: %v", err)
|
||||
}
|
||||
|
||||
now = time.UnixMilli(60000)
|
||||
token, session, err := authSvc.AdminIssueUserAccessToken(ctx, first.UserID, authservice.Meta{AppCode: "lalu", RequestID: "req-admin-token"})
|
||||
if err != nil {
|
||||
t.Fatalf("AdminIssueUserAccessToken failed: %v", err)
|
||||
}
|
||||
if token.SessionID != second.SessionID || session.SessionID != second.SessionID {
|
||||
t.Fatalf("admin token must bind newest active session: token=%s session=%s want=%s", token.SessionID, session.SessionID, second.SessionID)
|
||||
}
|
||||
if session.DeviceID != secondRegistration.DeviceID {
|
||||
t.Fatalf("admin token session device mismatch: %+v", session)
|
||||
}
|
||||
if token.AccessToken == "" || token.RefreshToken != "" || token.TokenType != "Bearer" {
|
||||
t.Fatalf("admin token must be access-only Bearer: %+v", token)
|
||||
}
|
||||
|
||||
if auditAfter := countLoginAudit(t, repository, first.UserID); auditAfter != auditBefore {
|
||||
t.Fatalf("admin issue must not write login_audit: before=%d after=%d", auditBefore, auditAfter)
|
||||
}
|
||||
var updatedAfter int64
|
||||
if err := repository.RawDB().QueryRowContext(ctx, `
|
||||
SELECT updated_at_ms FROM auth_sessions WHERE app_code = 'lalu' AND session_id = ?
|
||||
`, second.SessionID).Scan(&updatedAfter); err != nil {
|
||||
t.Fatalf("query session updated_at failed: %v", err)
|
||||
}
|
||||
if updatedAfter != updatedBefore {
|
||||
t.Fatalf("admin issue must not mutate session row: before=%d after=%d", updatedBefore, updatedAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdminIssueUserAccessTokenReturnsNotFoundWithoutActiveSession(t *testing.T) {
|
||||
// 无活跃会话时必须返回 NotFound:token 是无状态 JWT,没有 session 就没有可重签对象,后台要能明确展示。
|
||||
ctx := context.Background()
|
||||
repository := mysqltest.NewRepository(t)
|
||||
seedCountry(t, repository, "SG")
|
||||
now := time.UnixMilli(1000)
|
||||
authSvc := newAuthService(repository, &now, []int64{910005}, []string{"7100005"})
|
||||
|
||||
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-admin-no-session", thirdPartyRegistration("android"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
|
||||
if err != nil {
|
||||
t.Fatalf("LoginThirdParty failed: %v", err)
|
||||
}
|
||||
if _, err := authSvc.Logout(ctx, token.SessionID, "", authservice.Meta{AppCode: "lalu", RequestID: "req-logout"}); err != nil {
|
||||
t.Fatalf("Logout failed: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := authSvc.AdminIssueUserAccessToken(ctx, token.UserID, authservice.Meta{AppCode: "lalu", RequestID: "req-admin-token"}); !xerr.IsCode(err, xerr.NotFound) {
|
||||
t.Fatalf("expected NotFound without active session, got %v", err)
|
||||
}
|
||||
}
|
||||
@ -63,6 +63,35 @@ func (r *Repository) FindActiveSessionByID(ctx context.Context, sessionID string
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// FindLatestActiveSessionByUser 返回用户当前最新的未过期未吊销 session,供后台按用户重签 access token。
|
||||
// 与 FindActiveSessionByID 不同:这里没有可信 sid 输入,用“最近创建的 active session”近似客户端当前会话;
|
||||
// refresh 轮换后旧 session 已吊销,最新 created_at_ms 即客户端实际持有的会话。
|
||||
|
||||
func (r *Repository) FindLatestActiveSessionByUser(ctx context.Context, userID int64, nowMs int64) (authdomain.Session, error) {
|
||||
// idx_auth_sessions_user_id(app_code, user_id) 先收敛到单用户,再在少量行上过滤排序即可。
|
||||
var session authdomain.Session
|
||||
var revokedAt sql.Null[int64]
|
||||
err := r.db.QueryRowContext(ctx, `
|
||||
SELECT app_code, session_id, user_id, refresh_token_hash, device_id, expires_at_ms, last_heartbeat_at_ms, last_heartbeat_request_id, revoked_at_ms, revoked_reason, revoked_request_id, revoked_by, created_at_ms, updated_at_ms
|
||||
FROM auth_sessions
|
||||
WHERE app_code = ? AND user_id = ? AND revoked_at_ms IS NULL AND expires_at_ms > ?
|
||||
ORDER BY created_at_ms DESC, session_id DESC
|
||||
LIMIT 1
|
||||
`, appcode.FromContext(ctx), userID, nowMs).Scan(&session.AppCode, &session.SessionID, &session.UserID, &session.RefreshTokenHash, &session.DeviceID, &session.ExpiresAtMs, &session.LastHeartbeatAtMs, &session.LastHeartbeatRequestID, &revokedAt, &session.RevokedReason, &session.RevokedRequestID, &session.RevokedBy, &session.CreatedAtMs, &session.UpdatedAtMs)
|
||||
if err == sql.ErrNoRows {
|
||||
// “无活跃会话”是后台要明确展示的业务事实,用 NotFound 与其他存储错误区分。
|
||||
return authdomain.Session{}, xerr.New(xerr.NotFound, "active session not found")
|
||||
}
|
||||
if err != nil {
|
||||
return authdomain.Session{}, err
|
||||
}
|
||||
if revokedAt.Valid {
|
||||
session.RevokedAtMs = revokedAt.V
|
||||
}
|
||||
|
||||
return session, nil
|
||||
}
|
||||
|
||||
// ReplaceSession 原子吊销旧 session 并创建新 session。
|
||||
|
||||
func (r *Repository) ReplaceSession(ctx context.Context, oldSessionID string, newSession authdomain.Session, revokedAtMs int64) error {
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"hyapp/pkg/appcode"
|
||||
authdomain "hyapp/services/user-service/internal/domain/auth"
|
||||
)
|
||||
|
||||
// UpsertUserDevice 以 (app_code, user_id, device_id) 为主键合并设备档案。
|
||||
// 合并语义是“非空覆盖”:客户端可能只在部分请求携带设备头(例如 refresh 只带 device_id),
|
||||
// 空值不能抹掉此前已经上报过的型号/厂商/系统版本;last_seen/updated 每次认证成功都必须前移,
|
||||
// 让后台设备列表能按最近活跃排序。写入失败由调用方决定是否忽略,认证主链路不因它失败。
|
||||
func (r *Repository) UpsertUserDevice(ctx context.Context, device authdomain.UserDevice) error {
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO user_devices (app_code, user_id, device_id, platform, device_model, manufacturer, os_version, imei, app_version, build_number, first_seen_at_ms, last_seen_at_ms, created_at_ms, updated_at_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
platform = IF(VALUES(platform) = '', platform, VALUES(platform)),
|
||||
device_model = IF(VALUES(device_model) = '', device_model, VALUES(device_model)),
|
||||
manufacturer = IF(VALUES(manufacturer) = '', manufacturer, VALUES(manufacturer)),
|
||||
os_version = IF(VALUES(os_version) = '', os_version, VALUES(os_version)),
|
||||
imei = IF(VALUES(imei) = '', imei, VALUES(imei)),
|
||||
app_version = IF(VALUES(app_version) = '', app_version, VALUES(app_version)),
|
||||
build_number = IF(VALUES(build_number) = '', build_number, VALUES(build_number)),
|
||||
last_seen_at_ms = VALUES(last_seen_at_ms),
|
||||
updated_at_ms = VALUES(updated_at_ms)
|
||||
`,
|
||||
appcode.Normalize(device.AppCode),
|
||||
device.UserID,
|
||||
device.DeviceID,
|
||||
device.Platform,
|
||||
device.DeviceModel,
|
||||
device.Manufacturer,
|
||||
device.OSVersion,
|
||||
device.IMEI,
|
||||
device.AppVersion,
|
||||
device.BuildNumber,
|
||||
device.FirstSeenAtMs,
|
||||
device.LastSeenAtMs,
|
||||
device.CreatedAtMs,
|
||||
device.UpdatedAtMs,
|
||||
)
|
||||
return err
|
||||
}
|
||||
@ -354,6 +354,16 @@ func (r *Repository) FindActiveSessionByRefreshHash(ctx context.Context, refresh
|
||||
return r.Repository.AuthRepository().FindActiveSessionByRefreshHash(ctx, refreshTokenHash, nowMs)
|
||||
}
|
||||
|
||||
// FindLatestActiveSessionByUser 让测试 wrapper 继续满足认证接口。
|
||||
func (r *Repository) FindLatestActiveSessionByUser(ctx context.Context, userID int64, nowMs int64) (authdomain.Session, error) {
|
||||
return r.Repository.AuthRepository().FindLatestActiveSessionByUser(ctx, userID, nowMs)
|
||||
}
|
||||
|
||||
// UpsertUserDevice 让测试 wrapper 继续满足认证接口。
|
||||
func (r *Repository) UpsertUserDevice(ctx context.Context, device authdomain.UserDevice) error {
|
||||
return r.Repository.AuthRepository().UpsertUserDevice(ctx, device)
|
||||
}
|
||||
|
||||
// FindActiveSessionByID 让测试 wrapper 继续满足认证接口。
|
||||
func (r *Repository) FindActiveSessionByID(ctx context.Context, sessionID string, nowMs int64) (authdomain.Session, error) {
|
||||
return r.Repository.AuthRepository().FindActiveSessionByID(ctx, sessionID, nowMs)
|
||||
|
||||
@ -443,16 +443,20 @@ func authMeta(meta *userv1.RequestMeta) authservice.Meta {
|
||||
|
||||
// 只提取认证审计需要的字段,device_id 在各 RPC 中单独传入。
|
||||
return authservice.Meta{
|
||||
AppCode: meta.GetAppCode(),
|
||||
RequestID: meta.GetRequestId(),
|
||||
ClientIP: meta.GetClientIp(),
|
||||
UserAgent: meta.GetUserAgent(),
|
||||
CountryByIP: meta.GetCountryByIp(),
|
||||
Platform: meta.GetPlatform(),
|
||||
AppVersion: meta.GetAppVersion(),
|
||||
BuildNumber: meta.GetBuildNumber(),
|
||||
Language: meta.GetLanguage(),
|
||||
Timezone: meta.GetTimezone(),
|
||||
AppCode: meta.GetAppCode(),
|
||||
RequestID: meta.GetRequestId(),
|
||||
ClientIP: meta.GetClientIp(),
|
||||
UserAgent: meta.GetUserAgent(),
|
||||
CountryByIP: meta.GetCountryByIp(),
|
||||
Platform: meta.GetPlatform(),
|
||||
AppVersion: meta.GetAppVersion(),
|
||||
BuildNumber: meta.GetBuildNumber(),
|
||||
Language: meta.GetLanguage(),
|
||||
Timezone: meta.GetTimezone(),
|
||||
DeviceModel: meta.GetDeviceModel(),
|
||||
DeviceManufacturer: meta.GetDeviceManufacturer(),
|
||||
OSVersion: meta.GetOsVersion(),
|
||||
IMEI: meta.GetImei(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -732,6 +732,30 @@ func (s *Server) BatchGetUserAdminProfiles(ctx context.Context, req *userv1.Batc
|
||||
return &userv1.BatchGetUserAdminProfilesResponse{Profiles: result}, nil
|
||||
}
|
||||
|
||||
// AdminIssueUserAccessToken 后台按用户最新 active session 重签 access token。
|
||||
// 不写 login_audit、不动 session 行;取用审计由 admin 操作日志承担。
|
||||
func (s *Server) AdminIssueUserAccessToken(ctx context.Context, req *userv1.AdminIssueUserAccessTokenRequest) (*userv1.AdminIssueUserAccessTokenResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
meta := authMeta(req.GetMeta())
|
||||
if code := strings.TrimSpace(req.GetAppCode()); code != "" {
|
||||
// 显式 app_code 字段优先于 meta,后台调用方不依赖 meta 组装细节。
|
||||
ctx = appcode.WithContext(ctx, code)
|
||||
meta.AppCode = code
|
||||
}
|
||||
token, session, err := s.authSvc.AdminIssueUserAccessToken(ctx, req.GetUserId(), meta)
|
||||
if err != nil {
|
||||
return nil, xerr.ToGRPCError(err)
|
||||
}
|
||||
return &userv1.AdminIssueUserAccessTokenResponse{
|
||||
AccessToken: token.AccessToken,
|
||||
TokenType: token.TokenType,
|
||||
ExpiresInSec: token.ExpiresInSec,
|
||||
SessionId: token.SessionID,
|
||||
DeviceId: session.DeviceID,
|
||||
SessionLastHeartbeatAtMs: session.LastHeartbeatAtMs,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// BatchGetRoomBasicUsers 批量返回房间首屏最小用户展示资料。
|
||||
func (s *Server) BatchGetRoomBasicUsers(ctx context.Context, req *userv1.BatchGetRoomBasicUsersRequest) (*userv1.BatchGetRoomBasicUsersResponse, error) {
|
||||
ctx = contextWithApp(ctx, req.GetMeta())
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user