登录时间,以及发货
This commit is contained in:
parent
f839e44375
commit
ff9321a1e2
@ -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,
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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 := "+"
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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}"
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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 != "" {
|
||||
|
||||
@ -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())
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user