diff --git a/docs/flutter对接/Google三方注册Flutter对接.md b/docs/flutter对接/Google三方注册Flutter对接.md index d0fd4e05..4ef1a975 100644 --- a/docs/flutter对接/Google三方注册Flutter对接.md +++ b/docs/flutter对接/Google三方注册Flutter对接.md @@ -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, diff --git a/docs/服务基础技术设计.md b/docs/服务基础技术设计.md index 3241ead3..2f6122d1 100644 --- a/docs/服务基础技术设计.md +++ b/docs/服务基础技术设计.md @@ -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" diff --git a/docs/登录注册技术设计.md b/docs/登录注册技术设计.md index 8f96a2bb..a4a881dd 100644 --- a/docs/登录注册技术设计.md +++ b/docs/登录注册技术设计.md @@ -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" } ``` diff --git a/server/admin/cmd/server/main.go b/server/admin/cmd/server/main.go index 57f9b3ae..4f192767 100644 --- a/server/admin/cmd/server/main.go +++ b/server/admin/cmd/server/main.go @@ -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{} diff --git a/server/admin/internal/modules/payment/legacy_bill_source.go b/server/admin/internal/modules/payment/legacy_bill_source.go index 740feb6a..922f4fd1 100644 --- a/server/admin/internal/modules/payment/legacy_bill_source.go +++ b/server/admin/internal/modules/payment/legacy_bill_source.go @@ -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 App(Yumi 等 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_recharge,Aslan 的金币代理发货金额来自 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 非空时调用方必须叠加 legacyRegionLookupStages:likei 账单不带国家码,区域归属按付款用户资料判定。 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_details;dashboard/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 解析账单的区域归属国家:优先账单自身 countryCode(Aslan 的 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 := "+" diff --git a/server/admin/internal/modules/payment/legacy_bill_source_test.go b/server/admin/internal/modules/payment/legacy_bill_source_test.go index d8fa96ce..8ed7249c 100644 --- a/server/admin/internal/modules/payment/legacy_bill_source_test.go +++ b/server/admin/internal/modules/payment/legacy_bill_source_test.go @@ -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) diff --git a/server/admin/internal/modules/payment/recharge_bill_overview.go b/server/admin/internal/modules/payment/recharge_bill_overview.go index 8fc5c6ed..d31bff43 100644 --- a/server/admin/internal/modules/payment/recharge_bill_overview.go +++ b/server/admin/internal/modules/payment/recharge_bill_overview.go @@ -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 { diff --git a/server/admin/internal/modules/payment/recharge_bill_stats.go b/server/admin/internal/modules/payment/recharge_bill_stats.go index 8ed9d295..3b7470e4 100644 --- a/server/admin/internal/modules/payment/recharge_bill_stats.go +++ b/server/admin/internal/modules/payment/recharge_bill_stats.go @@ -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 diff --git a/services/gateway-service/internal/transport/http/game_handler_test.go b/services/gateway-service/internal/transport/http/game_handler_test.go index 6b293754..d3d81c72 100644 --- a/services/gateway-service/internal/transport/http/game_handler_test.go +++ b/services/gateway-service/internal/transport/http/game_handler_test.go @@ -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()) } } diff --git a/services/gateway-service/internal/transport/http/login_risk.go b/services/gateway-service/internal/transport/http/login_risk.go index 9b29185e..b0fbde05 100644 --- a/services/gateway-service/internal/transport/http/login_risk.go +++ b/services/gateway-service/internal/transport/http/login_risk.go @@ -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 } diff --git a/services/gateway-service/internal/transport/http/middleware.go b/services/gateway-service/internal/transport/http/middleware.go index 91447584..52e5f669 100644 --- a/services/gateway-service/internal/transport/http/middleware.go +++ b/services/gateway-service/internal/transport/http/middleware.go @@ -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) diff --git a/services/gateway-service/internal/transport/http/response_test.go b/services/gateway-service/internal/transport/http/response_test.go index 4d2cdee8..76482728 100644 --- a/services/gateway-service/internal/transport/http/response_test.go +++ b/services/gateway-service/internal/transport/http/response_test.go @@ -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) diff --git a/services/user-service/configs/config.docker.yaml b/services/user-service/configs/config.docker.yaml index c577448f..a501c021 100644 --- a/services/user-service/configs/config.docker.yaml +++ b/services/user-service/configs/config.docker.yaml @@ -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" diff --git a/services/user-service/configs/config.tencent.example.yaml b/services/user-service/configs/config.tencent.example.yaml index 03f34ba6..7c14226d 100644 --- a/services/user-service/configs/config.tencent.example.yaml +++ b/services/user-service/configs/config.tencent.example.yaml @@ -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}" diff --git a/services/user-service/configs/config.yaml b/services/user-service/configs/config.yaml index 33f04562..12870cf8 100644 --- a/services/user-service/configs/config.yaml +++ b/services/user-service/configs/config.yaml @@ -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" diff --git a/services/user-service/internal/config/config.go b/services/user-service/internal/config/config.go index f63c6668..41db61fa 100644 --- a/services/user-service/internal/config/config.go +++ b/services/user-service/internal/config/config.go @@ -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", diff --git a/services/user-service/internal/service/auth/password.go b/services/user-service/internal/service/auth/password.go index cb08f6fb..c179c23b 100644 --- a/services/user-service/internal/service/auth/password.go +++ b/services/user-service/internal/service/auth/password.go @@ -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 } diff --git a/services/user-service/internal/service/auth/quick_account.go b/services/user-service/internal/service/auth/quick_account.go index ce48e89f..4cc0d30f 100644 --- a/services/user-service/internal/service/auth/quick_account.go +++ b/services/user-service/internal/service/auth/quick_account.go @@ -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 } diff --git a/services/user-service/internal/service/auth/service.go b/services/user-service/internal/service/auth/service.go index 82a1153a..019b78da 100644 --- a/services/user-service/internal/service/auth/service.go +++ b/services/user-service/internal/service/auth/service.go @@ -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 } diff --git a/services/user-service/internal/service/auth/service_test.go b/services/user-service/internal/service/auth/service_test.go index d844fe8f..255555f4 100644 --- a/services/user-service/internal/service/auth/service_test.go +++ b/services/user-service/internal/service/auth/service_test.go @@ -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 denylist;Redis 失败时不能消耗 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_token;denylist 分区必须来自持久化 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() diff --git a/services/user-service/internal/service/auth/third_party.go b/services/user-service/internal/service/auth/third_party.go index e8504b47..f6902c07 100644 --- a/services/user-service/internal/service/auth/third_party.go +++ b/services/user-service/internal/service/auth/third_party.go @@ -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 } diff --git a/services/user-service/internal/service/auth/token.go b/services/user-service/internal/service/auth/token.go index 273a63f7..e0c52cd2 100644 --- a/services/user-service/internal/service/auth/token.go +++ b/services/user-service/internal/service/auth/token.go @@ -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 != "" { diff --git a/services/user-service/internal/transport/grpc/server_test.go b/services/user-service/internal/transport/grpc/server_test.go index 3b05e12c..70c0d259 100644 --- a/services/user-service/internal/transport/grpc/server_test.go +++ b/services/user-service/internal/transport/grpc/server_test.go @@ -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()) }