登录时间,以及发货

This commit is contained in:
zhx 2026-07-06 11:10:19 +08:00
parent f839e44375
commit ff9321a1e2
23 changed files with 984 additions and 72 deletions

View File

@ -206,7 +206,7 @@ Headers
"session_id": "sess_xxx",
"access_token": "access_token",
"refresh_token": "refresh_token",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer",
"is_new_user": true,
"profile_completed": true,

View File

@ -91,7 +91,7 @@ mysql_auto_migrate: false
```yaml
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800
access_token_ttl_sec: 604800
refresh_token_ttl_sec: 2592000
signing_alg: "HS256"
signing_secret: "dev-secret"

View File

@ -111,7 +111,7 @@ Response `data`:
"session_id": "sess_xxx",
"access_token": "jwt_xxx",
"refresh_token": "refresh_xxx",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer",
"app_code": "lalu",
"is_new_user": true,
@ -313,7 +313,7 @@ Response `data`:
"user_id": "918274650129384448",
"session_id": "sess_xxx",
"access_token": "jwt_xxx",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer",
"display_user_id": "163000",
"profile_completed": true,
@ -441,7 +441,7 @@ Response `data`:
"session_id": "sess_xxx",
"access_token": "jwt_xxx",
"refresh_token": "refresh_xxx",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer"
}
```
@ -479,7 +479,7 @@ Response `data`:
"session_id": "sess_xxx",
"access_token": "jwt_xxx",
"refresh_token": "refresh_new_xxx",
"expires_in_sec": 1800,
"expires_in_sec": 604800,
"token_type": "Bearer"
}
```
@ -955,9 +955,11 @@ Refresh 返回当前 `display_user_id`,避免客户端持有旧短号。临时
v1 使用:
- `access_token`: JWT短有效期,默认 30 分钟
- `access_token`: JWT默认 168 小时,即 604800 秒
- `refresh_token`: opaque random token默认 30 天,只保存 hash。
- `session_id`: 服务端会话 ID用于 logout、审计和多端管理。
- access token 重签时不得超过对应 refresh session 的 `expires_at_ms`;靠近 30 天 session 到期时,`expires_in_sec` 会按剩余 session 生命周期缩短。
- refresh 轮换和 logout 成功前必须把旧 `session_id` 写入 gateway 可读的 Redis denylist缺少 denylist 写入能力时不能返回成功,否则旧 JWT 会在 168 小时窗口内继续可用。
JWT claims
@ -974,7 +976,7 @@ JWT claims
"app_code": "lalu",
"sid": "sess_xxx",
"iat": 1777000000,
"exp": 1777001800,
"exp": 1777604800,
"typ": "access"
}
```

View File

@ -187,16 +187,17 @@ func main() {
fatalRuntime("connect_money_region_sources_failed", err)
}
defer closeMoneyRegionSources(context.Background())
financeBillSources, closeFinanceBillSources, err := connectFinanceBillSources(context.Background(), cfg.FinanceBillSources, moneyRegionSources)
if err != nil {
fatalRuntime("connect_finance_bill_sources_failed", err)
}
defer closeFinanceBillSources(context.Background())
dashboardExternalSources, closeDashboardExternalSources, err := connectDashboardExternalSources(context.Background(), cfg.DashboardExternalSources, dashboardRegionResolvers(moneyRegionSources)...)
if err != nil {
fatalRuntime("connect_dashboard_external_sources_failed", err)
}
defer closeDashboardExternalSources()
financeBillSources, closeFinanceBillSources, err := connectFinanceBillSources(context.Background(), cfg.FinanceBillSources, moneyRegionSources, dashboardExternalSourceIndex(dashboardExternalSources))
if err != nil {
closeDashboardExternalSources()
fatalRuntime("connect_finance_bill_sources_failed", err)
}
defer closeFinanceBillSources(context.Background())
store := repository.New(db)
if cfg.MySQLAutoMigrate {
@ -454,7 +455,7 @@ func connectMoneyRegionSources(ctx context.Context, configs []config.MoneyRegion
}
// connectFinanceBillSources 连接 legacy App 的账单 Mongo启动阶段 ping 失败直接退出,避免财务明细静默漏掉 Yumi 账单。
func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBillSourceConfig, regionSources []paymentmodule.MoneyRegionSource) ([]paymentmodule.RechargeBillSource, func(context.Context), error) {
func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBillSourceConfig, regionSources []paymentmodule.MoneyRegionSource, dashboardSources map[string]dashboardmodule.ExternalDashboardSource) ([]paymentmodule.RechargeBillSource, func(context.Context), error) {
sources := []paymentmodule.RechargeBillSource{}
clients := []*mongo.Client{}
cleanup := func(closeCtx context.Context) {
@ -485,12 +486,33 @@ func connectFinanceBillSources(ctx context.Context, configs []config.FinanceBill
clients = append(clients, client)
source := paymentmodule.NewMongoRechargeBillSource(client.Database(sourceConfig.MongoDatabase), sourceConfig, regionSources...)
if source != nil {
dashboardSource := dashboardSources[source.AppCode()]
if dashboardSource == nil {
cleanup(ctx)
return nil, nil, fmt.Errorf("finance bill source %s requires matching dashboard_external_sources entry for coin seller stats", source.AppCode())
}
source.SetDashboardCoinSellerSource(dashboardSource)
sources = append(sources, source)
}
}
return sources, cleanup, nil
}
func dashboardExternalSourceIndex(sources []dashboardmodule.ExternalDashboardSource) map[string]dashboardmodule.ExternalDashboardSource {
index := map[string]dashboardmodule.ExternalDashboardSource{}
for _, source := range sources {
if source == nil {
continue
}
appCode := strings.ToLower(strings.TrimSpace(source.AppCode()))
if appCode == "" {
continue
}
index[appCode] = source
}
return index
}
// wireLegacyGooglePaidSync 为配置了 Play Console 包名与服务账号的 legacy 账单源开启谷歌实付同步。
func wireLegacyGooglePaidSync(sources []paymentmodule.RechargeBillSource, configs []config.FinanceBillSourceConfig, store *repository.Store) {
configsByApp := map[string]config.FinanceBillSourceConfig{}

View File

@ -2,6 +2,7 @@ package payment
import (
"context"
"encoding/json"
"errors"
"fmt"
"math"
@ -14,6 +15,7 @@ import (
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/integration/googleorders"
"hyapp-admin-server/internal/model"
"hyapp-admin-server/internal/modules/dashboard"
"hyapp-admin-server/internal/repository"
"go.mongodb.org/mongo-driver/v2/bson"
@ -23,15 +25,18 @@ import (
// legacyRechargeBillQuery 是 legacy 账单源支持的筛选子集;用户/币商筛选属于 hyapp 语义legacy 源不适用。
type legacyRechargeBillQuery struct {
Keyword string
// RechargeType 支持 google_play_recharge / third_party 两个口径legacy 平台没有币商分支。
// RechargeType 支持 google_play_recharge / third_party 的 Mongo 明细口径;
// coin_seller 没有 legacy 原始账单列表summary/overview 只能从 dashboard 聚合事实补充。
RechargeType string
// PaidState=unsynced 只看谷歌实付未同步的账单。
PaidState string
RegionID int64
StartAtMS int64
EndAtMS int64
Page int
PageSize int
// TzOffsetMinutes 只影响 dashboard 聚合币商充值的自然日校验0 保持财务页默认中国时区。
TzOffsetMinutes int32
Page int
PageSize int
}
// RechargeBillSource 是 legacy AppYumi 等 likei 平台)充值账单的外部只读来源;
@ -69,6 +74,8 @@ type legacyGoogleOrdersClient interface {
// errLegacyGooglePaidUnsupported 表示该账单源未配置谷歌包名/服务账号或实付缓存存储。
var errLegacyGooglePaidUnsupported = errors.New("legacy google paid sync is not configured")
const defaultLegacyFinanceTZOffsetMinutes int32 = 480
// MongoRechargeBillSource 读取 likei Mongo 的 in_app_purchase_details 集合作为充值账单事实。
type MongoRechargeBillSource struct {
collection *mongo.Collection
@ -78,6 +85,7 @@ type MongoRechargeBillSource struct {
regionSources []MoneyRegionSource
paidStore LegacyGooglePaidStore
googleOrders legacyGoogleOrdersClient
coinSellerStats legacyCoinSellerRechargeStatsSource
}
// NewMongoRechargeBillSource 创建 legacy 账单源regionSources 用于把后台区域 ID 解析成国家码过滤条件。
@ -114,6 +122,18 @@ func (s *MongoRechargeBillSource) SetGooglePaidSync(paidStore LegacyGooglePaidSt
s.googleOrders = googleOrders
}
// SetDashboardCoinSellerSource 复用 databi/social 的外接 dashboard 聚合源补齐 legacy finance 的币商充值。
// Yumi 的币商金额来自 dashboard-cdc-worker dealer_rechargeAslan 的金币代理发货金额来自 Aslan dashboard 已补齐的 legacy 事实。
func (s *MongoRechargeBillSource) SetDashboardCoinSellerSource(source dashboard.ExternalDashboardSource) {
if s == nil || source == nil {
return
}
if appctx.Normalize(source.AppCode()) != s.config.AppCode {
return
}
s.coinSellerStats = dashboardCoinSellerRechargeStatsSource{source: source}
}
// SupportsGooglePaidSync 供 handler 判定该源能否响应谷歌实付刷新。
func (s *MongoRechargeBillSource) SupportsGooglePaidSync() bool {
return s != nil && s.paidStore != nil && s.googleOrders != nil
@ -130,6 +150,67 @@ func (s *MongoRechargeBillSource) AppName() string {
return s.config.AppCode
}
type legacyCoinSellerRechargeStats struct {
TotalUSDMinor int64
DailyUSDMinor map[string]int64
CountryUSDMinor map[string]int64
}
type legacyCoinSellerRechargeStatsSource interface {
CoinSellerRechargeStats(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) (legacyCoinSellerRechargeStats, error)
}
type dashboardCoinSellerRechargeStatsSource struct {
source dashboard.ExternalDashboardSource
}
func (s dashboardCoinSellerRechargeStatsSource) CoinSellerRechargeStats(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) (legacyCoinSellerRechargeStats, error) {
stats := legacyCoinSellerRechargeStats{
DailyUSDMinor: map[string]int64{},
CountryUSDMinor: map[string]int64{},
}
if s.source == nil || !legacyCoinSellerAggregateFilterSupported(query) {
return stats, nil
}
statTimezone, startMS, endMS, err := legacyDashboardDayRange(query, tzOffsetMinutes)
if err != nil {
return stats, err
}
overview, err := s.source.StatisticsOverview(ctx, dashboard.StatisticsQuery{
AppCode: s.source.AppCode(),
StatTZ: statTimezone,
StartMS: startMS,
EndMS: endMS,
RegionID: query.RegionID,
})
if err != nil {
return stats, err
}
total, err := legacyDashboardRequiredInt64(overview, "coin_seller_recharge_usd_minor")
if err != nil {
return stats, err
}
stats.TotalUSDMinor = total
for _, row := range legacyMapRows(overview["daily_series"]) {
statDay := legacyMapString(row, "stat_day")
if statDay == "" {
statDay = legacyMapString(row, "label")
}
if statDay == "" {
continue
}
stats.DailyUSDMinor[statDay] += legacyMapInt64(row["coin_seller_recharge_usd_minor"])
}
for _, row := range legacyMapRows(overview["country_breakdown"]) {
countryCode := strings.ToUpper(strings.TrimSpace(legacyMapString(row, "country_code")))
if countryCode == "" {
continue
}
stats.CountryUSDMinor[countryCode] += legacyMapInt64(row["coin_seller_recharge_usd_minor"])
}
return stats, nil
}
// legacyPurchaseDocument 对应 chatapp3-java 的 InAppPurchaseDetails Mongo 文档(只取展示需要的字段)。
type legacyPurchaseDocument struct {
ID any `bson:"_id"`
@ -420,6 +501,14 @@ func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, qu
if s == nil || s.collection == nil {
return rechargeBillSummaryDTO{}, fmt.Errorf("legacy bill source is not configured")
}
if legacyQueryRechargeType(query) == "coin_seller" {
summary := rechargeBillSummaryDTO{}
if err := s.mergeCoinSellerSummary(ctx, query, legacySummaryTZOffset(query), &summary); err != nil {
return rechargeBillSummaryDTO{}, err
}
summary.Total = summary.CoinSeller
return summary, nil
}
filter, regionCodes, matchable, err := s.billFilter(ctx, query)
if err != nil {
return rechargeBillSummaryDTO{}, err
@ -483,14 +572,38 @@ func (s *MongoRechargeBillSource) SummarizeRechargeBills(ctx context.Context, qu
if err := cursor.Err(); err != nil {
return rechargeBillSummaryDTO{}, fmt.Errorf("iterate legacy recharge summary for %s: %w", s.config.AppCode, err)
}
summary.Total = rechargeBillSummaryBucketDTO{
BillCount: summary.GooglePlay.BillCount + summary.ThirdParty.BillCount,
CoinAmount: summary.GooglePlay.CoinAmount + summary.ThirdParty.CoinAmount,
USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.ThirdParty.USDMinorAmount,
if legacyQueryRechargeType(query) == "" {
if err := s.mergeCoinSellerSummary(ctx, query, legacySummaryTZOffset(query), &summary); err != nil {
return rechargeBillSummaryDTO{}, err
}
}
legacyRecomputeSummaryTotal(&summary)
return summary, nil
}
func (s *MongoRechargeBillSource) mergeCoinSellerSummary(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32, summary *rechargeBillSummaryDTO) error {
if s == nil || s.coinSellerStats == nil || summary == nil || !legacyCoinSellerAggregateRequested(query) {
return nil
}
stats, err := s.coinSellerStats.CoinSellerRechargeStats(ctx, query, tzOffsetMinutes)
if err != nil {
return fmt.Errorf("load legacy coin seller recharge stats for %s: %w", s.config.AppCode, err)
}
summary.CoinSeller.USDMinorAmount += stats.TotalUSDMinor
return nil
}
func legacyRecomputeSummaryTotal(summary *rechargeBillSummaryDTO) {
if summary == nil {
return
}
summary.Total = rechargeBillSummaryBucketDTO{
BillCount: summary.GooglePlay.BillCount + summary.ThirdParty.BillCount + summary.CoinSeller.BillCount,
CoinAmount: summary.GooglePlay.CoinAmount + summary.ThirdParty.CoinAmount + summary.CoinSeller.CoinAmount,
USDMinorAmount: summary.GooglePlay.USDMinorAmount + summary.ThirdParty.USDMinorAmount + summary.CoinSeller.USDMinorAmount,
}
}
// billFilter 构造与 hyapp 账单列表同口径的 Mongo 过滤条件;返回 matchable=false 表示筛选注定无结果。
// regionCodes 非空时调用方必须叠加 legacyRegionLookupStageslikei 账单不带国家码,区域归属按付款用户资料判定。
func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRechargeBillQuery) (bson.M, []string, bool, error) {
@ -523,9 +636,12 @@ func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRe
case "third_party":
filter["factory.factoryCode"] = bson.M{"$ne": "GOOGLE"}
case "coin_seller":
// legacy 平台没有币商进货口径
// legacy 平台没有可分页的币商原始账单summary/overview 走 dashboard 聚合补充
return nil, nil, false, nil
}
// Aslan 金币代理“发货”历史上也落过 in_app_purchase_detailsdashboard/social 已把它归入币商充值,
// finance 的普通 Google/三方内购必须排除这些商品,避免总额重复且三方口径偏大。
excludeLegacyFreightGoldRecharge(filter)
if strings.ToLower(strings.TrimSpace(query.PaidState)) == "unsynced" {
filter["factory.factoryCode"] = "GOOGLE"
if s.paidStore != nil {
@ -555,6 +671,25 @@ func (s *MongoRechargeBillSource) billFilter(ctx context.Context, query legacyRe
return filter, nil, true, nil
}
var (
legacyFreightCommodityTypes = bson.A{"FREIGHT_GOLD", "FREIGHT_GOLD_SUPER"}
legacyFreightCommodityFields = []string{"trackCommodityType", "products.code", "products.name", "products.content"}
)
func excludeLegacyFreightGoldRecharge(filter bson.M) {
if filter == nil {
return
}
nor := bson.A{}
if existing, ok := filter["$nor"].(bson.A); ok {
nor = append(nor, existing...)
}
for _, field := range legacyFreightCommodityFields {
nor = append(nor, bson.M{field: bson.M{"$in": legacyFreightCommodityTypes}})
}
filter["$nor"] = nor
}
const legacyUserProfileCollection = "user_run_profile"
// legacyCountryResolveStages 解析账单的区域归属国家:优先账单自身 countryCodeAslan 的 MifaPay 单会带),
@ -708,6 +843,12 @@ func (s *MongoRechargeBillSource) Overview(ctx context.Context, query legacyRech
if err := s.overviewWithdrawal(ctx, query, &overview); err != nil {
return overview, err
}
if legacyQueryRechargeType(query) == "coin_seller" {
if err := s.mergeCoinSellerOverview(ctx, query, tzOffsetMinutes, &overview); err != nil {
return overview, err
}
return overview, nil
}
filter, regionCodes, matchable, err := s.billFilter(ctx, query)
if err != nil {
return overview, err
@ -726,9 +867,72 @@ func (s *MongoRechargeBillSource) Overview(ctx context.Context, query legacyRech
if err := s.overviewGooglePaid(ctx, filter, query, &overview); err != nil {
return overview, err
}
if legacyQueryRechargeType(query) == "" {
if err := s.mergeCoinSellerOverview(ctx, query, tzOffsetMinutes, &overview); err != nil {
return overview, err
}
}
return overview, nil
}
func (s *MongoRechargeBillSource) mergeCoinSellerOverview(ctx context.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32, overview *rechargeBillOverviewDTO) error {
if s == nil || s.coinSellerStats == nil || overview == nil || !legacyCoinSellerAggregateRequested(query) {
return nil
}
stats, err := s.coinSellerStats.CoinSellerRechargeStats(ctx, query, tzOffsetMinutes)
if err != nil {
return fmt.Errorf("load legacy coin seller overview for %s: %w", s.config.AppCode, err)
}
if len(stats.DailyUSDMinor) > 0 {
byDate := map[string]int{}
for i := range overview.Daily {
byDate[overview.Daily[i].Date] = i
}
for date, usdMinor := range stats.DailyUSDMinor {
if date == "" || usdMinor == 0 {
continue
}
if index, ok := byDate[date]; ok {
overview.Daily[index].CoinSellerUsdMinor += usdMinor
continue
}
overview.Daily = append(overview.Daily, rechargeBillDailyBucketDTO{Date: date, CoinSellerUsdMinor: usdMinor})
byDate[date] = len(overview.Daily) - 1
}
sort.Slice(overview.Daily, func(i, j int) bool {
return overview.Daily[i].Date < overview.Daily[j].Date
})
}
if len(stats.CountryUSDMinor) == 0 {
return nil
}
countryToRegion := s.legacyCountryRegionIndex(ctx)
byRegion := map[int64]int{}
for i := range overview.Regions {
byRegion[overview.Regions[i].RegionID] = i
}
for countryCode, usdMinor := range stats.CountryUSDMinor {
if usdMinor == 0 {
continue
}
region, ok := countryToRegion[strings.ToUpper(strings.TrimSpace(countryCode))]
if !ok {
continue
}
index, exists := byRegion[region.RegionID]
if !exists {
overview.Regions = append(overview.Regions, rechargeBillRegionBucketDTO{RegionID: region.RegionID, Name: region.Name})
index = len(overview.Regions) - 1
byRegion[region.RegionID] = index
}
overview.Regions[index].UsdMinorAmount += usdMinor
}
sort.Slice(overview.Regions, func(i, j int) bool {
return overview.Regions[i].UsdMinorAmount > overview.Regions[j].UsdMinorAmount
})
return nil
}
func (s *MongoRechargeBillSource) overviewDaily(ctx context.Context, base mongo.Pipeline, tzOffsetMinutes int32, overview *rechargeBillOverviewDTO) error {
coinExpr := bson.M{"$reduce": bson.M{
"input": bson.M{"$filter": bson.M{
@ -967,6 +1171,133 @@ func (s *MongoRechargeBillSource) overviewGooglePaid(ctx context.Context, filter
return nil
}
func legacyQueryRechargeType(query legacyRechargeBillQuery) string {
return strings.ToLower(strings.TrimSpace(query.RechargeType))
}
func legacySummaryTZOffset(query legacyRechargeBillQuery) int32 {
if query.TzOffsetMinutes != 0 {
return query.TzOffsetMinutes
}
return defaultLegacyFinanceTZOffsetMinutes
}
func legacyCoinSellerAggregateRequested(query legacyRechargeBillQuery) bool {
rechargeType := legacyQueryRechargeType(query)
return rechargeType == "" || rechargeType == "coin_seller"
}
func legacyCoinSellerAggregateFilterSupported(query legacyRechargeBillQuery) bool {
// dashboard 外部源只有按 App/区域/时间的预聚合事实;关键词和谷歌实付同步状态是账单明细筛选,不能反推到币商聚合。
return strings.TrimSpace(query.Keyword) == "" && strings.TrimSpace(query.PaidState) == ""
}
func legacyDashboardStatTimezone(tzOffsetMinutes int32) string {
switch tzOffsetMinutes {
case 480:
return "Asia/Shanghai"
case 180:
return "Asia/Riyadh"
default:
return ""
}
}
func legacyDashboardDayRange(query legacyRechargeBillQuery, tzOffsetMinutes int32) (string, int64, int64, error) {
statTimezone := legacyDashboardStatTimezone(tzOffsetMinutes)
if statTimezone == "" {
return "", 0, 0, fmt.Errorf("legacy coin seller dashboard stats do not support tz offset %d", tzOffsetMinutes)
}
if query.StartAtMS <= 0 || query.EndAtMS <= query.StartAtMS {
return "", 0, 0, fmt.Errorf("legacy coin seller dashboard stats require explicit day range")
}
location, err := time.LoadLocation(statTimezone)
if err != nil {
return "", 0, 0, fmt.Errorf("load legacy coin seller timezone %s: %w", statTimezone, err)
}
start := time.UnixMilli(query.StartAtMS).In(location)
end := time.UnixMilli(query.EndAtMS).In(location)
if !start.Equal(legacyLocalMidnight(start, location)) || !end.Equal(legacyLocalMidnight(end, location)) {
return "", 0, 0, fmt.Errorf("legacy coin seller dashboard stats require whole-day range in %s", statTimezone)
}
return statTimezone, query.StartAtMS, query.EndAtMS, nil
}
func legacyLocalMidnight(value time.Time, location *time.Location) time.Time {
local := value.In(location)
return time.Date(local.Year(), local.Month(), local.Day(), 0, 0, 0, 0, location)
}
func legacyDashboardRequiredInt64(row map[string]any, key string) (int64, error) {
if row == nil {
return 0, fmt.Errorf("legacy dashboard response is empty")
}
value, ok := row[key]
if !ok || value == nil {
return 0, fmt.Errorf("legacy dashboard response missing required field %s", key)
}
return legacyMapInt64(value), nil
}
func legacyMapRows(value any) []map[string]any {
switch typed := value.(type) {
case []map[string]any:
return typed
case []any:
rows := make([]map[string]any, 0, len(typed))
for _, item := range typed {
if row, ok := item.(map[string]any); ok {
rows = append(rows, row)
}
}
return rows
default:
return nil
}
}
func legacyMapString(row map[string]any, key string) string {
if row == nil {
return ""
}
switch typed := row[key].(type) {
case string:
return strings.TrimSpace(typed)
case fmt.Stringer:
return strings.TrimSpace(typed.String())
case nil:
return ""
default:
return strings.TrimSpace(fmt.Sprint(typed))
}
}
func legacyMapInt64(value any) int64 {
switch typed := value.(type) {
case nil:
return 0
case int64:
return typed
case int:
return int64(typed)
case int32:
return int64(typed)
case float64:
return int64(math.Round(typed))
case float32:
return int64(math.Round(float64(typed)))
case json.Number:
parsed, _ := typed.Int64()
return parsed
case string:
parsed, _ := strconv.ParseInt(strings.TrimSpace(typed), 10, 64)
return parsed
default:
parsed, _ := strconv.ParseInt(strings.TrimSpace(fmt.Sprint(typed)), 10, 64)
return parsed
}
}
// legacyTimezoneOffset 把分钟偏移转成 Mongo $dateToString 的时区串,如 480 → "+08:00"。
func legacyTimezoneOffset(tzOffsetMinutes int32) string {
sign := "+"

View File

@ -6,6 +6,7 @@ import (
"time"
"hyapp-admin-server/internal/config"
"hyapp-admin-server/internal/modules/dashboard"
"go.mongodb.org/mongo-driver/v2/bson"
)
@ -162,6 +163,30 @@ func TestLegacyBillFilterBuildsQuery(t *testing.T) {
}
}
func TestLegacyBillFilterExcludesFreightGoldRecharge(t *testing.T) {
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
filter, _, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{RechargeType: "third_party"})
if err != nil || !matchable {
t.Fatalf("billFilter err=%v matchable=%v", err, matchable)
}
nor, ok := filter["$nor"].(bson.A)
if !ok || len(nor) != len(legacyFreightCommodityFields) {
t.Fatalf("freight exclusion missing: %v", filter)
}
for _, condition := range nor {
item, ok := condition.(bson.M)
if !ok {
t.Fatalf("unexpected freight condition: %#v", condition)
}
for _, value := range item {
typed, ok := value.(bson.M)
if !ok || typed["$in"] == nil {
t.Fatalf("unexpected freight condition value: %#v", value)
}
}
}
}
func TestLegacyBillFilterUnresolvedRegionMatchesNothing(t *testing.T) {
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
_, _, matchable, err := source.billFilter(context.Background(), legacyRechargeBillQuery{RegionID: 123})
@ -173,6 +198,177 @@ func TestLegacyBillFilterUnresolvedRegionMatchesNothing(t *testing.T) {
}
}
type fakeDashboardRechargeSource struct {
appCode string
overview map[string]any
query dashboard.StatisticsQuery
calls int
}
func (s *fakeDashboardRechargeSource) AppCode() string {
return s.appCode
}
func (s *fakeDashboardRechargeSource) StatisticsOverview(_ context.Context, query dashboard.StatisticsQuery) (map[string]any, error) {
s.query = query
s.calls++
return s.overview, nil
}
func TestLegacyCoinSellerDashboardStatsMergeSummary(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "Yumi",
overview: map[string]any{
"coin_seller_recharge_usd_minor": int64(350_010),
},
}
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
source.SetDashboardCoinSellerSource(dashboardSource)
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai)
summary := rechargeBillSummaryDTO{
GooglePlay: rechargeBillSummaryBucketDTO{BillCount: 2, USDMinorAmount: 10_000},
ThirdParty: rechargeBillSummaryBucketDTO{BillCount: 3, USDMinorAmount: 20_000},
}
if err := source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{
StartAtMS: start.UnixMilli(),
EndAtMS: start.AddDate(0, 0, 1).UnixMilli(),
}, 480, &summary); err != nil {
t.Fatalf("mergeCoinSellerSummary: %v", err)
}
legacyRecomputeSummaryTotal(&summary)
if summary.CoinSeller.USDMinorAmount != 350_010 {
t.Fatalf("coin seller usd = %d, want 350010", summary.CoinSeller.USDMinorAmount)
}
if summary.Total.USDMinorAmount != 380_010 || summary.Total.BillCount != 5 {
t.Fatalf("unexpected total after coin seller merge: %+v", summary.Total)
}
if dashboardSource.calls != 1 || dashboardSource.query.StatTZ != "Asia/Shanghai" {
t.Fatalf("dashboard query mismatch: calls=%d query=%+v", dashboardSource.calls, dashboardSource.query)
}
}
func TestLegacyCoinSellerDashboardStatsRejectsPartialDayRange(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "yumi",
overview: map[string]any{"coin_seller_recharge_usd_minor": int64(100)},
}
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
source.SetDashboardCoinSellerSource(dashboardSource)
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 7, 5, 10, 0, 0, 0, shanghai)
summary := rechargeBillSummaryDTO{}
err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{
StartAtMS: start.UnixMilli(),
EndAtMS: start.Add(2 * time.Hour).UnixMilli(),
}, 480, &summary)
if err == nil {
t.Fatalf("partial day range should fail")
}
if dashboardSource.calls != 0 {
t.Fatalf("dashboard source should not be called for partial day range")
}
err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{}, 480, &summary)
if err == nil {
t.Fatalf("missing day range should fail")
}
}
func TestLegacyCoinSellerDashboardStatsRequiresMetricField(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "yumi",
overview: map[string]any{"coin_seller_recharge_usd_minor": nil},
}
source := &MongoRechargeBillSource{config: yumiBillSourceConfig()}
source.SetDashboardCoinSellerSource(dashboardSource)
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai)
summary := rechargeBillSummaryDTO{}
err = source.mergeCoinSellerSummary(context.Background(), legacyRechargeBillQuery{
StartAtMS: start.UnixMilli(),
EndAtMS: start.AddDate(0, 0, 1).UnixMilli(),
}, 480, &summary)
if err == nil {
t.Fatalf("missing dashboard coin seller field should fail")
}
if dashboardSource.calls != 1 {
t.Fatalf("dashboard source calls = %d, want 1", dashboardSource.calls)
}
}
func TestLegacyCoinSellerDashboardStatsMergeOverview(t *testing.T) {
dashboardSource := &fakeDashboardRechargeSource{
appCode: "yumi",
overview: map[string]any{
"coin_seller_recharge_usd_minor": int64(5_000),
"daily_series": []map[string]any{
{"stat_day": "2026-07-05", "coin_seller_recharge_usd_minor": int64(4_200)},
{"stat_day": "2026-07-06", "coin_seller_recharge_usd_minor": int64(800)},
},
"country_breakdown": []map[string]any{
{"country_code": "SA", "coin_seller_recharge_usd_minor": int64(4_200)},
{"country_code": "AE", "coin_seller_recharge_usd_minor": int64(800)},
},
},
}
source := &MongoRechargeBillSource{
config: yumiBillSourceConfig(),
regionSources: []MoneyRegionSource{&staticMoneyRegionSource{regions: []moneyRegionDTO{{
AppCode: "yumi",
RegionID: 101,
Name: "Gulf",
Countries: []string{"SA", "AE"},
}}}},
}
source.SetDashboardCoinSellerSource(dashboardSource)
shanghai, err := time.LoadLocation("Asia/Shanghai")
if err != nil {
t.Fatalf("load location: %v", err)
}
start := time.Date(2026, 7, 5, 0, 0, 0, 0, shanghai)
overview := rechargeBillOverviewDTO{
Daily: []rechargeBillDailyBucketDTO{{Date: "2026-07-05", ThirdPartyUsdMinor: 2_000}},
Regions: []rechargeBillRegionBucketDTO{{
RegionID: 101,
Name: "Gulf",
UsdMinorAmount: 2_000,
}},
}
if err := source.mergeCoinSellerOverview(context.Background(), legacyRechargeBillQuery{
StartAtMS: start.UnixMilli(),
EndAtMS: start.AddDate(0, 0, 2).UnixMilli(),
}, 480, &overview); err != nil {
t.Fatalf("mergeCoinSellerOverview: %v", err)
}
if len(overview.Daily) != 2 {
t.Fatalf("daily buckets = %d, want 2: %+v", len(overview.Daily), overview.Daily)
}
if overview.Daily[0].Date != "2026-07-05" || overview.Daily[0].CoinSellerUsdMinor != 4_200 {
t.Fatalf("existing day not merged: %+v", overview.Daily)
}
if overview.Daily[1].Date != "2026-07-06" || overview.Daily[1].CoinSellerUsdMinor != 800 {
t.Fatalf("new day not appended: %+v", overview.Daily)
}
if len(overview.Regions) != 1 || overview.Regions[0].UsdMinorAmount != 7_000 {
t.Fatalf("region not merged: %+v", overview.Regions)
}
}
func TestLegacyRegionLookupStages(t *testing.T) {
if stages := legacyRegionLookupStages(nil); len(stages) != 0 {
t.Fatalf("empty codes should not add stages: %v", stages)

View File

@ -67,13 +67,18 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
startAtMS := queryInt64(c, "start_at_ms", "startAtMs")
endAtMS := queryInt64(c, "end_at_ms", "endAtMs")
if source, ok := h.billSources[appCode]; ok {
overview, err := source.Overview(c.Request.Context(), legacyRechargeBillQuery{
query := legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: startAtMS,
EndAtMS: endAtMS,
}, tzOffsetMinutes)
}
if !h.requireLegacyCoinSellerDayRange(c, query, tzOffsetMinutes) {
return
}
overview, err := source.Overview(c.Request.Context(), query, tzOffsetMinutes)
if err != nil {
response.ServerError(c, "获取财务概览失败")
return
@ -151,6 +156,17 @@ func (h *Handler) GetRechargeBillOverview(c *gin.Context) {
response.OK(c, overview)
}
func (h *Handler) requireLegacyCoinSellerDayRange(c *gin.Context, query legacyRechargeBillQuery, tzOffsetMinutes int32) bool {
if !legacyCoinSellerAggregateRequested(query) || !legacyCoinSellerAggregateFilterSupported(query) {
return true
}
if _, _, _, err := legacyDashboardDayRange(query, tzOffsetMinutes); err != nil {
response.BadRequest(c, "币商统计仅支持明确整日时间范围")
return false
}
return true
}
// fillApprovedWithdrawalStats 把 admin 库的“审核通过提现申请”并入用户提现口径;查询失败只降级该卡片,不阻断概览。
func (h *Handler) fillApprovedWithdrawalStats(overview *rechargeBillOverviewDTO, appCode string, startAtMS int64, endAtMS int64) {
if h.store != nil {

View File

@ -73,15 +73,25 @@ func (h *Handler) listLegacyRechargeBills(c *gin.Context, source RechargeBillSou
func (h *Handler) GetRechargeBillSummary(c *gin.Context) {
options := shared.ListOptions(c)
appCode := appctx.FromContext(c.Request.Context())
tzOffsetMinutes := int32(queryInt64(c, "tz_offset_minutes", "tzOffsetMinutes"))
if tzOffsetMinutes == 0 {
// legacy finance 的币商补充来自 dashboard 自然日聚合,默认沿用财务页中国时区。
tzOffsetMinutes = defaultLegacyFinanceTZOffsetMinutes
}
if source, ok := h.billSources[appCode]; ok {
summary, err := source.SummarizeRechargeBills(c.Request.Context(), legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
})
query := legacyRechargeBillQuery{
Keyword: options.Keyword,
RechargeType: strings.TrimSpace(firstQuery(c, "recharge_type", "rechargeType")),
PaidState: strings.TrimSpace(firstQuery(c, "paid_state", "paidState")),
RegionID: queryInt64(c, "region_id", "regionId"),
StartAtMS: queryInt64(c, "start_at_ms", "startAtMs"),
EndAtMS: queryInt64(c, "end_at_ms", "endAtMs"),
TzOffsetMinutes: tzOffsetMinutes,
}
if !h.requireLegacyCoinSellerDayRange(c, query, tzOffsetMinutes) {
return
}
summary, err := source.SummarizeRechargeBills(c.Request.Context(), query)
if err != nil {
response.ServerError(c, "获取充值汇总失败")
return

View File

@ -228,7 +228,7 @@ func TestDiceMatchRenewsAccessTokenWhenTokenNearExpiry(t *testing.T) {
if userClient.lastAppHeartbeat.GetMeta().GetDeviceId() != "dev-dice" {
t.Fatalf("dice match must forward device meta during token renew: %+v", userClient.lastAppHeartbeat.GetMeta())
}
if recorder.Header().Get("X-Hyapp-Access-Token") != "access-heartbeat" || recorder.Header().Get("X-Hyapp-Expires-In") != "1800" {
if recorder.Header().Get("X-Hyapp-Access-Token") != "access-heartbeat" || recorder.Header().Get("X-Hyapp-Expires-In") != strconv.FormatInt(testAppAccessTokenTTLSec, 10) {
t.Fatalf("dice match must expose renewed token headers: %+v", recorder.Header())
}
}

View File

@ -69,9 +69,10 @@ type redisLoginRiskCache struct {
}
type memoryLoginRiskCache struct {
mu sync.Mutex
ip map[string]loginRiskDecision
revoked map[string]bool
mu sync.Mutex
ip map[string]loginRiskDecision
revoked map[string]bool
revokedErr error
}
func defaultLoginRiskConfig() LoginRiskConfig {
@ -147,6 +148,9 @@ func (c *memoryLoginRiskCache) GetIPDecision(_ context.Context, appCode string,
func (c *memoryLoginRiskCache) RevokedSessionExists(_ context.Context, appCode string, sessionID string) (bool, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.revokedErr != nil {
return false, c.revokedErr
}
return c.revoked[revokedSessionKey(appCode, sessionID)], nil
}

View File

@ -54,6 +54,8 @@ func (h *Handler) withAuth(jwtVerifier *auth.Verifier, next http.HandlerFunc) ht
revoked, err := h.revokedSession(ctx, claims.AppCode, claims.SessionID)
if err != nil {
logx.Warn(ctx, "session_denylist_read_failed", slog.String("component", "gateway_auth"), slog.String("session_id", claims.SessionID), slog.String("error", err.Error()))
httpkit.WriteError(writer, request.WithContext(ctx), http.StatusBadGateway, httpkit.CodeUpstreamError, "upstream service error")
return
}
if revoked {
httpkit.WriteError(writer, request.WithContext(ctx), http.StatusUnauthorized, string(xerr.SessionRevoked), "unauthorized")
@ -80,6 +82,8 @@ func (h *Handler) withOptionalAuth(jwtVerifier *auth.Verifier, next http.Handler
revoked, err := h.revokedSession(ctx, claims.AppCode, claims.SessionID)
if err != nil {
logx.Warn(ctx, "optional_session_denylist_read_failed", slog.String("component", "gateway_public_auth"), slog.String("session_id", claims.SessionID), slog.String("error", err.Error()))
next.ServeHTTP(writer, request)
return
}
if revoked {
next.ServeHTTP(writer, request)

View File

@ -35,6 +35,8 @@ import (
"hyapp/services/gateway-service/internal/financewithdrawal"
)
const testAppAccessTokenTTLSec int64 = 604800
// fakeRoomClient 只承接 gateway transport 测试需要观察的 gRPC 入参。
// 测试目标是 HTTP envelope 和 RequestMeta 传递,不验证 room-service 业务行为。
type fakeRoomClient struct {
@ -771,7 +773,17 @@ func (f *fakeUserAuthClient) LoginPassword(_ context.Context, req *userv1.LoginP
return nil, f.loginErr
}
return &userv1.AuthResponse{Token: &userv1.AuthToken{UserId: 10001}}, nil
return &userv1.AuthResponse{Token: &userv1.AuthToken{
UserId: 10001,
DisplayUserId: "100001",
SessionId: "sess-password",
AccessToken: "access-password",
RefreshToken: "refresh-password",
ExpiresInSec: testAppAccessTokenTTLSec,
TokenType: "Bearer",
ProfileCompleted: true,
OnboardingStatus: "completed",
}}, nil
}
func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.LoginThirdPartyRequest) (*userv1.AuthResponse, error) {
@ -791,7 +803,7 @@ func (f *fakeUserAuthClient) LoginThirdParty(_ context.Context, req *userv1.Logi
SessionId: "sess-1",
AccessToken: "access-1",
RefreshToken: "refresh-1",
ExpiresInSec: 1800,
ExpiresInSec: testAppAccessTokenTTLSec,
TokenType: "Bearer",
ProfileCompleted: false,
OnboardingStatus: "profile_required",
@ -816,7 +828,7 @@ func (f *fakeUserAuthClient) RegisterThirdParty(_ context.Context, req *userv1.R
SessionId: "sess-register",
AccessToken: "access-register",
RefreshToken: "refresh-register",
ExpiresInSec: 1800,
ExpiresInSec: testAppAccessTokenTTLSec,
TokenType: "Bearer",
ProfileCompleted: true,
OnboardingStatus: "completed",
@ -848,7 +860,7 @@ func (f *fakeUserAuthClient) QuickCreateAccount(_ context.Context, req *userv1.Q
SessionId: "sess-quick",
AccessToken: "access-quick",
RefreshToken: "refresh-quick",
ExpiresInSec: 1800,
ExpiresInSec: testAppAccessTokenTTLSec,
TokenType: "Bearer",
ProfileCompleted: true,
OnboardingStatus: "completed",
@ -858,7 +870,7 @@ func (f *fakeUserAuthClient) QuickCreateAccount(_ context.Context, req *userv1.Q
func (f *fakeUserAuthClient) RefreshToken(_ context.Context, req *userv1.RefreshTokenRequest) (*userv1.RefreshTokenResponse, error) {
f.lastRefresh = req
f.refreshCount++
return &userv1.RefreshTokenResponse{Token: &userv1.AuthToken{UserId: 10001, AccessToken: "access-2", RefreshToken: "refresh-2", ExpiresInSec: 1800, TokenType: "Bearer", ProfileCompleted: true, OnboardingStatus: "completed"}}, nil
return &userv1.RefreshTokenResponse{Token: &userv1.AuthToken{UserId: 10001, AccessToken: "access-2", RefreshToken: "refresh-2", ExpiresInSec: testAppAccessTokenTTLSec, TokenType: "Bearer", ProfileCompleted: true, OnboardingStatus: "completed"}}, nil
}
func (f *fakeUserAuthClient) Logout(_ context.Context, req *userv1.LogoutRequest) (*userv1.LogoutResponse, error) {
@ -884,7 +896,7 @@ func (f *fakeUserAuthClient) AppHeartbeat(_ context.Context, req *userv1.AppHear
UserId: req.GetUserId(),
SessionId: req.GetSessionId(),
AccessToken: "access-heartbeat",
ExpiresInSec: 1800,
ExpiresInSec: testAppAccessTokenTTLSec,
TokenType: "Bearer",
ProfileCompleted: true,
OnboardingStatus: "completed",
@ -1036,7 +1048,7 @@ func (f *fakeUserProfileClient) CompleteOnboarding(_ context.Context, req *userv
UserId: req.GetUserId(),
SessionId: req.GetMeta().GetSessionId(),
AccessToken: "access-completed",
ExpiresInSec: 1800,
ExpiresInSec: testAppAccessTokenTTLSec,
TokenType: "Bearer",
DisplayUserId: "100001",
ProfileCompleted: true,
@ -3971,6 +3983,9 @@ func TestAppHeartbeatUsesAuthenticatedSession(t *testing.T) {
if recorder.Header().Get("X-Hyapp-Access-Token") != "access-heartbeat" || data["access_token"] != "access-heartbeat" {
t.Fatalf("app heartbeat must expose renewed access token: header=%q data=%+v", recorder.Header().Get("X-Hyapp-Access-Token"), data)
}
if recorder.Header().Get("X-Hyapp-Expires-In") != strconv.FormatInt(testAppAccessTokenTTLSec, 10) || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
t.Fatalf("app heartbeat must expose renewed access token TTL: header=%q data=%+v", recorder.Header().Get("X-Hyapp-Expires-In"), data)
}
}
func TestReportAppEventsUsesAuthenticatedUserDimensions(t *testing.T) {
@ -6595,6 +6610,10 @@ func TestThirdPartyLoginUsesPublicEnvelopeAndPropagatesRequestID(t *testing.T) {
if response.Code != httpkit.CodeOK || response.RequestID != generatedRequestID {
t.Fatalf("unexpected envelope: %+v", response)
}
data, ok := response.Data.(map[string]any)
if !ok || data["access_token"] != "access-1" || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
t.Fatalf("third-party login must expose access token TTL: %+v", response.Data)
}
req := userClient.lastLoginThirdParty
if req == nil {
t.Fatal("LoginThirdParty request was not sent")
@ -6641,7 +6660,7 @@ func TestThirdPartyRegisterUsesPublicEnvelopeAndReturnsCompletedToken(t *testing
t.Fatalf("unexpected envelope: %+v", response)
}
data, ok := response.Data.(map[string]any)
if !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" || data["access_token"] != "access-register" {
if !ok || data["profile_completed"] != true || data["onboarding_status"] != "completed" || data["access_token"] != "access-register" || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
t.Fatalf("register must return completed token data: %+v", response.Data)
}
req := userClient.lastRegisterThird
@ -6779,7 +6798,20 @@ func TestAccountLoginAcceptsAccountField(t *testing.T) {
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusOK, httpkit.CodeOK, "req-account-login")
if recorder.Code != http.StatusOK {
t.Fatalf("status mismatch: got %d body=%s", recorder.Code, recorder.Body.String())
}
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode account login response failed: %v", err)
}
if response.Code != httpkit.CodeOK || response.RequestID != assertGeneratedRequestID(t, recorder, "req-account-login") {
t.Fatalf("unexpected envelope: %+v", response)
}
data, ok := response.Data.(map[string]any)
if !ok || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
t.Fatalf("account login must expose access token TTL: %+v", response.Data)
}
if userClient.lastLoginPassword == nil || userClient.lastLoginPassword.GetDisplayUserId() != "123456" {
t.Fatalf("account was not propagated as display_user_id: %+v", userClient.lastLoginPassword)
}
@ -6833,6 +6865,10 @@ func TestQuickCreateAccountCreatesCompletedPasswordAccount(t *testing.T) {
if !ok || data["uid"] != "100001" || data["account"] != "100001" || data["access_token"] != "access-quick" || data["password_set"] != true {
t.Fatalf("quick create response mismatch: %+v", response.Data)
}
token, ok := data["token"].(map[string]any)
if !ok || token["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
t.Fatalf("quick create token TTL mismatch: %+v", data["token"])
}
}
func TestLoginRiskFastGuardBlocksLanguage(t *testing.T) {
@ -6929,6 +6965,22 @@ func TestSessionDenylistRejectsAuthenticatedRequest(t *testing.T) {
assertEnvelope(t, recorder, http.StatusUnauthorized, string(xerr.SessionRevoked), "req-session-revoked")
}
func TestSessionDenylistReadErrorFailsAuthenticatedRequest(t *testing.T) {
cache := newMemoryLoginRiskCache()
cache.revokedErr = errors.New("redis unavailable")
handler := NewHandlerWithClients(&fakeRoomClient{}, nil, nil, &fakeUserProfileClient{})
handler.SetLoginRiskCache(defaultLoginRiskConfig(), cache)
router := handler.Routes(auth.NewVerifier("secret"))
request := httptest.NewRequest(http.MethodGet, "/api/v1/users/me", nil)
request.Header.Set("Authorization", "Bearer "+signGatewayToken(t, "secret", 42))
request.Header.Set("X-Request-ID", "req-session-denylist-error")
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, request)
assertEnvelope(t, recorder, http.StatusBadGateway, httpkit.CodeUpstreamError, "req-session-denylist-error")
}
func TestRefreshRateLimitByDeviceID(t *testing.T) {
userClient := &fakeUserAuthClient{}
handler := NewHandler(&fakeRoomClient{}, userClient)
@ -6949,6 +7001,16 @@ func TestRefreshRateLimitByDeviceID(t *testing.T) {
if i == 0 && recorder.Code != http.StatusOK {
t.Fatalf("first request should pass: status=%d body=%s", recorder.Code, recorder.Body.String())
}
if i == 0 {
var response httpkit.ResponseEnvelope
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode refresh response failed: %v", err)
}
data, ok := response.Data.(map[string]any)
if response.Code != httpkit.CodeOK || !ok || data["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
t.Fatalf("refresh must expose access token TTL: %+v", response)
}
}
if i == 1 {
assertEnvelope(t, recorder, http.StatusTooManyRequests, httpkit.CodeRateLimited, "req-refresh-rate")
}
@ -9963,7 +10025,7 @@ func TestCompleteOnboardingAllowsIncompleteProfileAndUsesAuthenticatedUserID(t *
t.Fatalf("unexpected onboarding response: %+v", response)
}
token, ok := data["token"].(map[string]any)
if !ok || token["access_token"] != "access-completed" || token["profile_completed"] != true {
if !ok || token["access_token"] != "access-completed" || token["profile_completed"] != true || token["expires_in_sec"].(float64) != float64(testAppAccessTokenTTLSec) {
t.Fatalf("complete onboarding must return replacement access token: %+v", data["token"])
}
invite, ok := data["invite"].(map[string]any)

View File

@ -106,7 +106,7 @@ tencent_im:
request_timeout_ms: 5000
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800
access_token_ttl_sec: 604800
refresh_token_ttl_sec: 2592000
signing_alg: "HS256"
signing_secret: "dev-shared-secret"

View File

@ -111,7 +111,7 @@ tencent_im:
request_timeout_ms: 5000
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800
access_token_ttl_sec: 604800
refresh_token_ttl_sec: 2592000
signing_alg: "HS256"
signing_secret: "${USER_SERVICE_JWT_SECRET}"

View File

@ -107,7 +107,7 @@ tencent_im:
request_timeout_ms: 5000
jwt:
issuer: "hyapp"
access_token_ttl_sec: 1800
access_token_ttl_sec: 604800
refresh_token_ttl_sec: 2592000
signing_alg: "HS256"
signing_secret: "dev-shared-secret"

View File

@ -250,7 +250,7 @@ type UserOutboxMQConfig struct {
type JWTConfig struct {
// Issuer 写入 access token iss claim。
Issuer string `yaml:"issuer"`
// AccessTokenTTLSec 是短期 access token 有效期
// AccessTokenTTLSec 是 App 登录 access token 有效期;默认 168 小时,减少正常用户被动重登
AccessTokenTTLSec int64 `yaml:"access_token_ttl_sec"`
// RefreshTokenTTLSec 是服务端 session/refresh token 有效期。
RefreshTokenTTLSec int64 `yaml:"refresh_token_ttl_sec"`
@ -340,7 +340,7 @@ func Default() Config {
},
JWT: JWTConfig{
Issuer: "hyapp",
AccessTokenTTLSec: 1800,
AccessTokenTTLSec: 604800,
RefreshTokenTTLSec: 2592000,
SigningAlg: "HS256",
SigningSecret: "dev-shared-secret",

View File

@ -67,7 +67,7 @@ func (s *Service) LoginPassword(ctx context.Context, displayUserID string, passw
}
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
token, err := s.issueToken(user, session.SessionID, refreshToken)
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
if err != nil {
return authdomain.Token{}, err
}

View File

@ -119,7 +119,7 @@ func (s *Service) QuickCreateAccount(ctx context.Context, password string, regis
}, nil
}
s.audit(ctx, meta, user.UserID, loginPassword, "", resultSuccess, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken)
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil {
return authdomain.Token{}, tokenErr
}

View File

@ -31,6 +31,10 @@ const (
resultSuccess = "success"
// resultFailed 是审计失败结果。
resultFailed = "failed"
// revokeReasonRefreshRotated 表示 refresh token 已被成功轮换,旧 access token 必须立刻失效。
revokeReasonRefreshRotated = "REFRESH_ROTATED"
// revokeReasonUserLogout 表示用户主动登出,旧 access token 必须立刻失效。
revokeReasonUserLogout = "USER_LOGOUT"
)
// AuthRepository 是认证用例的持久化边界。
@ -150,7 +154,7 @@ type IMAccountImporter interface {
type Config struct {
// Issuer 写入 access token 的 iss claim。
Issuer string
// AccessTokenTTLSec 是短期 access token 有效期。
// AccessTokenTTLSec 是 App 登录 access token 有效期。
AccessTokenTTLSec int64
// RefreshTokenTTLSec 是服务端 refresh session 有效期。
RefreshTokenTTLSec int64
@ -281,7 +285,7 @@ func DefaultLoginRiskPolicy() LoginRiskPolicy {
AllowedTTL: 7 * 24 * time.Hour,
UnknownTTL: 30 * time.Minute,
ProviderTimeout: 800 * time.Millisecond,
DenylistTTL: 31 * time.Minute,
DenylistTTL: 7*24*time.Hour + time.Minute,
}
}
@ -301,7 +305,10 @@ func WithLoginRiskPolicy(policy LoginRiskPolicy) Option {
policy.ProviderTimeout = defaults.ProviderTimeout
}
if policy.DenylistTTL <= 0 {
policy.DenylistTTL = time.Duration(s.cfg.AccessTokenTTLSec+60) * time.Second
policy.DenylistTTL = defaults.DenylistTTL
if s.cfg.AccessTokenTTLSec > 0 {
policy.DenylistTTL = time.Duration(s.cfg.AccessTokenTTLSec+60) * time.Second
}
}
s.loginRiskPolicy = policy
}

View File

@ -4,6 +4,7 @@ package auth_test
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
@ -17,21 +18,44 @@ import (
authservice "hyapp/services/user-service/internal/service/auth"
userservice "hyapp/services/user-service/internal/service/user"
"hyapp/services/user-service/internal/testutil/mysqltest"
jwt "github.com/golang-jwt/jwt/v5"
)
const testAccessTokenTTLSec int64 = 604800
func accessTokenExpUnix(t *testing.T, accessToken string) int64 {
t.Helper()
parser := jwt.NewParser(jwt.WithValidMethods([]string{"HS256"}), jwt.WithoutClaimsValidation())
parsed, err := parser.Parse(accessToken, func(token *jwt.Token) (any, error) {
return []byte("test-secret"), nil
})
if err != nil || !parsed.Valid {
t.Fatalf("parse access token failed: token=%q err=%v", accessToken, err)
}
exp, err := parsed.Claims.GetExpirationTime()
if err != nil || exp == nil {
t.Fatalf("access token missing exp: claims=%+v err=%v", parsed.Claims, err)
}
return exp.Unix()
}
type memoryDecisionCache struct {
mu sync.Mutex
ip map[string]authservice.IPDecision
revoked map[string]string
revTTL map[string]time.Duration
ips map[string][]string
users map[string][]int64
userTTL map[string]time.Duration
err error
}
func newMemoryDecisionCache() *memoryDecisionCache {
return &memoryDecisionCache{
ip: make(map[string]authservice.IPDecision),
revoked: make(map[string]string),
revTTL: make(map[string]time.Duration),
ips: make(map[string][]string),
users: make(map[string][]int64),
userTTL: make(map[string]time.Duration),
@ -52,10 +76,15 @@ func (c *memoryDecisionCache) SetIPDecision(_ context.Context, appCode string, c
return nil
}
func (c *memoryDecisionCache) SetRevokedSession(_ context.Context, appCode string, sessionID string, reason string, _ time.Duration) error {
func (c *memoryDecisionCache) SetRevokedSession(_ context.Context, appCode string, sessionID string, reason string, ttl time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.revoked[appCode+":"+sessionID] = reason
if c.err != nil {
return c.err
}
key := appCode + ":" + sessionID
c.revoked[key] = reason
c.revTTL[key] = ttl
return nil
}
@ -155,11 +184,12 @@ func newAuthService(repository *mysqltest.Repository, now *time.Time, ids []int6
authservice.WithDisplayUserIDAllocator(sequenceDisplayUserIDAllocator{values: displayIDs}),
authservice.WithClock(func() time.Time { return *now }),
authservice.WithThirdPartyVerifier(authservice.NewStaticThirdPartyVerifier([]string{"wechat"})),
authservice.WithIPDecisionCache(newMemoryDecisionCache()),
}
options = append(options, extra...)
return authservice.New(authservice.Config{
Issuer: "hyapp-test",
AccessTokenTTLSec: 1800,
AccessTokenTTLSec: testAccessTokenTTLSec,
RefreshTokenTTLSec: 2592000,
SigningAlg: "HS256",
SigningSecret: "test-secret",
@ -282,7 +312,8 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"})
cache := newMemoryDecisionCache()
svc := newAuthService(repository, &now, []int64{900001}, []string{"100001"}, authservice.WithIPDecisionCache(cache))
thirdToken, isNewUser, err := svc.LoginThirdParty(ctx, "wechat", "openid-1", thirdPartyRegistration("ios"), authservice.Meta{RequestID: "req-third"})
if err != nil {
@ -293,6 +324,12 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if !isNewUser || thirdToken.UserID != 900001 || displayUserID == "" || thirdToken.RefreshToken == "" || thirdToken.AccessToken == "" {
t.Fatalf("unexpected third-party token: token=%+v isNew=%v", thirdToken, isNewUser)
}
if thirdToken.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("third-party access token TTL mismatch: %+v", thirdToken)
}
if got, want := accessTokenExpUnix(t, thirdToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("third-party access token exp mismatch: got=%d want=%d", got, want)
}
if thirdToken.ProfileCompleted || thirdToken.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) {
t.Fatalf("new third-party user must require onboarding: %+v", thirdToken)
}
@ -318,6 +355,12 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if loginToken.UserID != thirdToken.UserID || loginToken.DisplayUserID != displayUserID || loginToken.RefreshToken == thirdToken.RefreshToken {
t.Fatalf("unexpected login token: %+v", loginToken)
}
if loginToken.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("password login access token TTL mismatch: %+v", loginToken)
}
if got, want := accessTokenExpUnix(t, loginToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("password login access token exp mismatch: got=%d want=%d", got, want)
}
if _, err := svc.LoginPassword(ctx, displayUserID, "wrong", "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.AuthFailed) {
t.Fatalf("expected AUTH_FAILED for wrong password, got %v", err)
@ -332,6 +375,18 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if refreshToken.RefreshToken == loginToken.RefreshToken || refreshToken.DisplayUserID != displayUserID {
t.Fatalf("refresh token must rotate")
}
if refreshToken.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("refresh access token TTL mismatch: %+v", refreshToken)
}
if got, want := accessTokenExpUnix(t, refreshToken.AccessToken), now.Unix()+testAccessTokenTTLSec; got != want {
t.Fatalf("refresh access token exp mismatch: got=%d want=%d", got, want)
}
if got := cache.revoked["lalu:"+loginToken.SessionID]; got != "REFRESH_ROTATED" {
t.Fatalf("refresh must denylist rotated access session, got %q", got)
}
if got, want := cache.revTTL["lalu:"+loginToken.SessionID], time.Duration(testAccessTokenTTLSec+60)*time.Second; got != want {
t.Fatalf("refresh denylist TTL mismatch: got=%s want=%s", got, want)
}
if _, err := svc.RefreshToken(ctx, loginToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) {
t.Fatalf("expected old refresh token revoked, got %v", err)
@ -344,6 +399,90 @@ func TestThirdPartyUserSetsPasswordThenLogsIn(t *testing.T) {
if _, err := svc.RefreshToken(ctx, refreshToken.RefreshToken, "ios", authservice.Meta{}); !xerr.IsCode(err, xerr.SessionRevoked) {
t.Fatalf("expected logout refresh token revoked, got %v", err)
}
if got := cache.revoked["lalu:"+refreshToken.SessionID]; got != "USER_LOGOUT" {
t.Fatalf("logout must denylist access session, got %q", got)
}
if got, want := cache.revTTL["lalu:"+refreshToken.SessionID], time.Duration(testAccessTokenTTLSec+60)*time.Second; got != want {
t.Fatalf("logout denylist TTL mismatch: got=%s want=%s", got, want)
}
}
func TestRefreshTokenDenylistFailureDoesNotConsumeSession(t *testing.T) {
// refresh 轮换前必须先让旧 sid 进入 gateway denylistRedis 失败时不能消耗 refresh token。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
cache := newMemoryDecisionCache()
svc := newAuthService(repository, &now, []int64{900012}, []string{"100012"}, authservice.WithIPDecisionCache(cache))
token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-denylist-failure", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
cache.err = errors.New("redis unavailable")
if _, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-fail"}); err == nil {
t.Fatal("RefreshToken should fail when access denylist write fails")
}
cache.err = nil
refreshed, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh-retry"})
if err != nil {
t.Fatalf("RefreshToken retry should still use original refresh token: %v", err)
}
if refreshed.SessionID == token.SessionID || refreshed.RefreshToken == token.RefreshToken {
t.Fatalf("retry must rotate session and refresh token: old=%+v new=%+v", token, refreshed)
}
if got := cache.revoked["lalu:"+token.SessionID]; got != "REFRESH_ROTATED" {
t.Fatalf("retry must denylist original session, got %q", got)
}
}
func TestRefreshAndLogoutRequireSessionDenylistCache(t *testing.T) {
// 168 小时 access token 下,缺少 gateway denylist 写入能力时不能假装 refresh/logout 成功。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
svc := newAuthService(repository, &now, []int64{900014}, []string{"100014"}, authservice.WithIPDecisionCache(nil))
token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-missing-denylist", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
if _, err := svc.RefreshToken(ctx, token.RefreshToken, "dev-ios", authservice.Meta{AppCode: "lalu", RequestID: "req-refresh"}); !xerr.IsCode(err, xerr.Unavailable) {
t.Fatalf("RefreshToken must fail without session denylist cache, got %v", err)
}
if revoked, err := svc.Logout(ctx, token.SessionID, token.RefreshToken, authservice.Meta{AppCode: "lalu", RequestID: "req-logout"}); err == nil || !xerr.IsCode(err, xerr.Unavailable) || revoked {
t.Fatalf("Logout must fail without session denylist cache, revoked=%v err=%v", revoked, err)
}
}
func TestLogoutByRefreshTokenUsesSessionAppCodeForDenylist(t *testing.T) {
// logout 是公开接口,旧客户端可能只带 refresh_tokendenylist 分区必须来自持久化 session而不是依赖请求头。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
cache := newMemoryDecisionCache()
svc := newAuthService(repository, &now, []int64{900015}, []string{"100015"}, authservice.WithIPDecisionCache(cache))
token, _, err := svc.LoginThirdParty(ctx, "wechat", "openid-refresh-only-logout", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
revoked, err := svc.Logout(ctx, "", token.RefreshToken, authservice.Meta{RequestID: "req-logout-refresh-only"})
if err != nil || !revoked {
t.Fatalf("Logout by refresh token failed: revoked=%v err=%v", revoked, err)
}
if got := cache.revoked["lalu:"+token.SessionID]; got != "USER_LOGOUT" {
t.Fatalf("logout must use session app_code for denylist, got %q", got)
}
if _, ok := cache.revoked[":"+token.SessionID]; ok {
t.Fatal("logout must not write denylist under empty app_code")
}
}
func TestCompleteOnboardingEmitsUserRegisteredOutboxWithSelectedCountry(t *testing.T) {
@ -407,6 +546,9 @@ func TestQuickCreateAccountCreatesCompletedPasswordLogin(t *testing.T) {
if token.UserID != 900011 || token.DisplayUserID == "" || token.AccessToken == "" || token.RefreshToken == "" {
t.Fatalf("unexpected quick token: %+v", token)
}
if token.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("quick account access token TTL mismatch: %+v", token)
}
if !token.ProfileCompleted || token.OnboardingStatus != string(userdomain.OnboardingStatusCompleted) {
t.Fatalf("quick account must be completed: %+v", token)
}
@ -1016,11 +1158,49 @@ func TestIssueAccessTokenForSessionCarriesProfileRequiredOnboardingStatusWithout
if reissued.ProfileCompleted || reissued.OnboardingStatus != string(userdomain.OnboardingStatusProfileRequired) || reissued.AccessToken == "" {
t.Fatalf("reissued token must carry profile-required onboarding: %+v", reissued)
}
if reissued.ExpiresInSec != testAccessTokenTTLSec {
t.Fatalf("reissued access token TTL mismatch: %+v", reissued)
}
if reissued.SessionID != token.SessionID || reissued.RefreshToken != "" {
t.Fatalf("access resign must reuse session and not mint refresh token: %+v", reissued)
}
}
func TestIssueAccessTokenForSessionCapsTTLAtSessionExpiry(t *testing.T) {
// access token 不能因为心跳或资料完成重签而越过 refresh session 的服务端硬过期时间。
ctx := context.Background()
repository := mysqltest.NewRepository(t)
seedCountry(t, repository, "SG")
now := time.UnixMilli(1000)
authSvc := newAuthService(repository, &now, []int64{900013}, []string{"100013"})
token, _, err := authSvc.LoginThirdParty(ctx, "wechat", "openid-session-expiry-cap", thirdPartyRegistration("ios"), authservice.Meta{AppCode: "lalu", RequestID: "req-login"})
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
now = time.UnixMilli(3000)
sessionExpiresAt := now.Add(2 * time.Hour)
if _, err := repository.RawDB().ExecContext(ctx, `
UPDATE auth_sessions
SET expires_at_ms = ?
WHERE app_code = 'lalu' AND session_id = ?
`, sessionExpiresAt.UnixMilli(), token.SessionID); err != nil {
t.Fatalf("shorten session expiry failed: %v", err)
}
reissued, err := authSvc.IssueAccessTokenForSession(ctx, token.UserID, token.SessionID, authservice.Meta{AppCode: "lalu", RequestID: "req-resign"})
if err != nil {
t.Fatalf("IssueAccessTokenForSession failed: %v", err)
}
if reissued.ExpiresInSec != int64((2 * time.Hour).Seconds()) {
t.Fatalf("reissued token must be capped by session expiry: %+v", reissued)
}
if got, want := accessTokenExpUnix(t, reissued.AccessToken), sessionExpiresAt.Unix(); got != want {
t.Fatalf("reissued access token exp mismatch: got=%d want=%d", got, want)
}
}
func TestAppHeartbeatRefreshesCurrentSessionOnly(t *testing.T) {
// APP 心跳是登录会话在线状态,不应该创建房间 presence也不能续住其他用户或其他 session。
ctx := context.Background()

View File

@ -242,7 +242,7 @@ func (s *Service) loginExistingThirdParty(ctx context.Context, identity authdoma
s.audit(ctx, meta, user.UserID, loginThird, identity.Provider, resultSuccess, "")
// isNewUser=false 表示本次只创建 session没有创建用户。
token, err := s.issueToken(user, session.SessionID, refreshToken)
token, err := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
if err != nil {
return authdomain.Token{}, false, err
}
@ -292,7 +292,7 @@ func (s *Service) registerExistingThirdParty(ctx context.Context, identity authd
}
s.audit(ctx, meta, updated.UserID, loginThird, identity.Provider, resultSuccess, "")
token, err := s.issueToken(updated, session.SessionID, refreshToken)
token, err := s.issueToken(updated, session.SessionID, refreshToken, session.ExpiresAtMs)
if err != nil {
return authdomain.Token{}, false, err
}
@ -350,7 +350,7 @@ func (s *Service) createThirdPartyUser(ctx context.Context, provider string, pro
if err == nil {
// repository 事务成功后,用户、默认短号、三方绑定和首个 session 同时存在。
s.audit(ctx, meta, user.UserID, loginThird, provider, resultSuccess, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken)
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil {
return authdomain.Token{}, false, tokenErr
}
@ -432,7 +432,7 @@ 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, "")
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken)
token, tokenErr := s.issueToken(user, session.SessionID, refreshToken, session.ExpiresAtMs)
if tokenErr != nil {
return authdomain.Token{}, false, tokenErr
}

View File

@ -68,13 +68,19 @@ func (s *Service) RefreshToken(ctx context.Context, refreshToken string, deviceI
if err != nil {
return authdomain.Token{}, err
}
if err := s.writeRevokedAccessSession(ctx, session.AppCode, session.SessionID, revokeReasonRefreshRotated); err != nil {
// 7 天 access token 下refresh 轮换后旧 JWT 必须立刻进入 gateway denylist。
// 否则客户端或泄漏方仍可拿旧 sid 调业务接口直到 JWT 自然过期。
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, err
}
if err := s.authRepository.ReplaceSession(ctx, session.SessionID, newSession, nowMs); err != nil {
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultFailed, string(xerr.CodeOf(err)))
return authdomain.Token{}, err
}
s.audit(ctx, meta, session.UserID, loginRefresh, "", resultSuccess, "")
return s.issueToken(user, newSession.SessionID, newRefreshToken)
return s.issueToken(user, newSession.SessionID, newRefreshToken, newSession.ExpiresAtMs)
}
// IssueAccessTokenForSession 基于当前 active session 重签 access token不轮换 refresh token。
@ -116,7 +122,7 @@ func (s *Service) IssueAccessTokenForSession(ctx context.Context, userID int64,
}
s.audit(ctx, meta, userID, loginAccessResign, "", resultSuccess, "")
return s.issueToken(user, session.SessionID, "")
return s.issueToken(user, session.SessionID, "", session.ExpiresAtMs)
}
// Logout 失效指定 session 或 refresh token。
@ -129,8 +135,42 @@ func (s *Service) Logout(ctx context.Context, sessionID string, refreshToken str
return false, xerr.New(xerr.Unavailable, "auth repository is not configured")
}
nowMs := s.now().UnixMilli()
resolvedSessionID := strings.TrimSpace(sessionID)
resolvedAppCode := appcode.Normalize(meta.AppCode)
if resolvedSessionID == "" && strings.TrimSpace(refreshToken) != "" {
// 仅带 refresh token 的 logout 需要先解析 sid才能让已签出的 access token 在 gateway 立刻失效。
session, err := s.authRepository.FindActiveSessionByRefreshHash(ctx, hashRefreshToken(refreshToken), nowMs)
if err == nil {
resolvedSessionID = session.SessionID
resolvedAppCode = session.AppCode
} else if !xerr.IsCode(err, xerr.SessionExpired) && !xerr.IsCode(err, xerr.SessionRevoked) {
s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err)))
return false, err
}
}
if resolvedSessionID != "" {
if resolvedAppCode == "" {
// 兼容只传 session_id、且请求没有 App 头的旧调用active session 自身才是 denylist 分区的可信来源。
session, err := s.authRepository.FindActiveSessionByID(ctx, resolvedSessionID, nowMs)
if err == nil {
resolvedAppCode = session.AppCode
} else if !xerr.IsCode(err, xerr.SessionExpired) && !xerr.IsCode(err, xerr.SessionRevoked) {
s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err)))
return false, err
}
}
if resolvedAppCode != "" {
if err := s.writeRevokedAccessSession(ctx, resolvedAppCode, resolvedSessionID, revokeReasonUserLogout); err != nil {
// 先写 Redis denylist 再改 MySQL避免 Redis 不可用时出现“refresh/session 已吊销但旧 JWT 仍可用 7 天”的状态。
s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err)))
return false, err
}
}
}
// refreshToken 可能为空hashRefreshToken 会保持空值,支持仅用 session_id 登出。
revoked, err := s.authRepository.RevokeSession(ctx, sessionID, hashRefreshToken(refreshToken), s.now().UnixMilli())
revoked, err := s.authRepository.RevokeSession(ctx, sessionID, hashRefreshToken(refreshToken), nowMs)
if err != nil {
s.audit(ctx, meta, 0, "logout", "", resultFailed, string(xerr.CodeOf(err)))
return false, err
@ -192,9 +232,24 @@ func (s *Service) newSession(appCode string, userID int64, deviceID string) (aut
}, refreshToken, nil
}
func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string) (authdomain.Token, error) {
func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToken string, sessionExpiresAtMs int64) (authdomain.Token, error) {
now := s.now()
expiresAt := now.Add(time.Duration(s.cfg.AccessTokenTTLSec) * time.Second)
expiresInSec := s.cfg.AccessTokenTTLSec
expiresAt := now.Add(time.Duration(expiresInSec) * time.Second)
if sessionExpiresAtMs > 0 {
sessionExpiresAt := time.UnixMilli(sessionExpiresAtMs)
if !sessionExpiresAt.After(now) {
// access token 不能越过服务端 refresh session 的生命周期;已到期 session 必须重新登录。
return authdomain.Token{}, xerr.New(xerr.SessionExpired, "session expired")
}
if sessionExpiresAt.Before(expiresAt) {
expiresAt = sessionExpiresAt
expiresInSec = int64(expiresAt.Sub(now) / time.Second)
if expiresInSec <= 0 {
return authdomain.Token{}, xerr.New(xerr.SessionExpired, "session expired")
}
}
}
onboardingStatus := tokenOnboardingStatus(user)
// JWT 只携带内部 user_id客户端展示和人工输入统一使用 display_user_id。
// user_id 必须写成字符串:雪花 ID 超过 JS/JSON number 的安全整数范围,写成 number 会在 gateway 解析时丢精度。
@ -234,11 +289,26 @@ func (s *Service) issueToken(user userdomain.User, sessionID string, refreshToke
SessionID: sessionID,
AccessToken: accessToken,
RefreshToken: refreshToken,
ExpiresInSec: s.cfg.AccessTokenTTLSec,
ExpiresInSec: expiresInSec,
TokenType: tokenType,
}, nil
}
func (s *Service) writeRevokedAccessSession(ctx context.Context, appCode string, sessionID string, reason string) error {
if strings.TrimSpace(sessionID) == "" {
return nil
}
if s.ipDecisionCache == nil {
// refresh/logout 一旦成功gateway 必须能立刻拒绝旧 access token缺少 denylist 不能假装吊销成功。
return xerr.New(xerr.Unavailable, "session denylist is not configured")
}
ttl := s.loginRiskPolicy.DenylistTTL
if ttl <= 0 {
ttl = time.Duration(s.cfg.AccessTokenTTLSec+60) * time.Second
}
return s.ipDecisionCache.SetRevokedSession(ctx, appcode.Normalize(appCode), sessionID, reason, ttl)
}
func tokenOnboardingStatus(user userdomain.User) string {
// 旧测试数据或手工种子可能没有显式状态token 按 profile_completed 推导安全默认值。
if user.OnboardingStatus != "" {

View File

@ -15,6 +15,8 @@ import (
grpcserver "hyapp/services/user-service/internal/transport/grpc"
)
const testAccessTokenTTLSec int64 = 604800
func newUserService(repository *mysqltest.Repository, options ...userservice.Option) *userservice.Service {
base := []userservice.Option{
userservice.WithIdentityRepository(repository),
@ -63,7 +65,7 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) {
seedCountry(t, repository, "US")
authSvc := authservice.New(authservice.Config{
Issuer: "hyapp-test",
AccessTokenTTLSec: 1800,
AccessTokenTTLSec: testAccessTokenTTLSec,
RefreshTokenTTLSec: 2592000,
SigningAlg: "HS256",
SigningSecret: "test-secret",
@ -122,6 +124,9 @@ func TestLoginThirdPartyMapsRegistrationProfile(t *testing.T) {
if err != nil {
t.Fatalf("LoginThirdParty failed: %v", err)
}
if resp.GetToken().GetExpiresInSec() != testAccessTokenTTLSec {
t.Fatalf("login third-party access token TTL mismatch: %+v", resp.GetToken())
}
user, err := repository.GetUser(ctx, resp.GetToken().GetUserId())
if err != nil {
@ -142,7 +147,7 @@ func TestCompleteOnboardingReturnsReplacementAccessToken(t *testing.T) {
seedCountry(t, repository, "SG")
authSvc := authservice.New(authservice.Config{
Issuer: "hyapp-test",
AccessTokenTTLSec: 1800,
AccessTokenTTLSec: testAccessTokenTTLSec,
RefreshTokenTTLSec: 2592000,
SigningAlg: "HS256",
SigningSecret: "test-secret",
@ -181,6 +186,9 @@ func TestCompleteOnboardingReturnsReplacementAccessToken(t *testing.T) {
if !resp.GetProfileCompleted() || resp.GetToken().GetAccessToken() == "" || !resp.GetToken().GetProfileCompleted() {
t.Fatalf("complete onboarding must return completed replacement token: %+v", resp)
}
if resp.GetToken().GetExpiresInSec() != testAccessTokenTTLSec {
t.Fatalf("complete onboarding access token TTL mismatch: %+v", resp.GetToken())
}
if resp.GetToken().GetSessionId() != loginResp.GetToken().GetSessionId() || resp.GetToken().GetRefreshToken() != "" {
t.Fatalf("complete onboarding must reuse session without rotating refresh token: %+v", resp.GetToken())
}