增加用户信息

This commit is contained in:
zhx 2026-07-12 14:17:45 +08:00
parent 902cf46516
commit 4794507044
42 changed files with 6382 additions and 4803 deletions

File diff suppressed because it is too large Load Diff

View File

@ -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

View File

@ -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 tabIMEI 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 tokenrefresh
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);

View File

@ -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,

View File

@ -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)
}

View 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
}

View 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 必须同时写入显式字段和 metauser-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 返回 NotFoundservice 层原样透传,由 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")
}
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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 暴露三方绑定 openidprovider_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
}

View File

@ -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,
})
}
}

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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"}

View File

@ -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"),
}
}

View File

@ -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"}

View File

@ -0,0 +1,68 @@
package http
import (
"net/http/httptest"
"strings"
"testing"
"hyapp/services/gateway-service/internal/transport/http/httpkit"
)
// 设备资料头是 App 全局注入的,两个 meta builderhttpkit 通用版与 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)
}
}
}

View File

@ -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"),
}
}

View File

@ -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 {

View File

@ -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)

View File

@ -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 返回命令类型。

View File

@ -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))

View File

@ -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)

View File

@ -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 '设备 IMEIAndroid 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='用户设备档案表,认证成功后按设备聚合最新上报资料';

View File

@ -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 '设备 IMEIAndroid 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='用户设备档案表,认证成功后按设备聚合最新上报资料';

View File

@ -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 是三方平台内稳定身份。

View File

@ -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 是客户端可获取时上报的设备 IMEIAndroid 10+ 无特权权限拿不到,预期常为空。
IMEI string
}
func (s *Service) audit(ctx context.Context, meta Meta, userID int64, loginType string, provider string, result string, failureCode string) {

View File

@ -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

View File

@ -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

View File

@ -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 租约认领待处理风控任务。

View File

@ -245,6 +245,8 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
}
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultSuccess, "")
// 老用户换机三方登录也要立即出现在设备 tabheader 缺失时回退 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

View File

@ -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) == "" {

View 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-effortdevice_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])
}

View File

@ -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) {
// 无活跃会话时必须返回 NotFoundtoken 是无状态 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)
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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)

View File

@ -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(),
}
}

View File

@ -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())