增加首冲
This commit is contained in:
parent
e738fbc44a
commit
b53313f62d
@ -4,11 +4,12 @@ import "time"
|
||||
|
||||
// FirstRechargeRewardConfig stores first recharge reward config per system.
|
||||
type FirstRechargeRewardConfig struct {
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_sys_origin"`
|
||||
Enabled bool `gorm:"column:enabled;index:idx_first_recharge_reward_enabled"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
ID int64 `gorm:"column:id;primaryKey"`
|
||||
SysOrigin string `gorm:"column:sys_origin;size:32;uniqueIndex:uk_first_recharge_reward_sys_origin"`
|
||||
Enabled bool `gorm:"column:enabled;index:idx_first_recharge_reward_enabled"`
|
||||
MinAppVersion string `gorm:"column:min_app_version;size:32"`
|
||||
CreateTime time.Time `gorm:"column:create_time"`
|
||||
UpdateTime time.Time `gorm:"column:update_time"`
|
||||
}
|
||||
|
||||
func (FirstRechargeRewardConfig) TableName() string { return "first_recharge_reward_config" }
|
||||
@ -19,6 +20,7 @@ type FirstRechargeRewardLevel struct {
|
||||
ConfigID int64 `gorm:"column:config_id;uniqueIndex:uk_first_recharge_reward_level,priority:1;uniqueIndex:uk_first_recharge_reward_amount,priority:1;index:idx_first_recharge_reward_level_config"`
|
||||
Level int `gorm:"column:level;uniqueIndex:uk_first_recharge_reward_level,priority:2"`
|
||||
RechargeAmountCents int64 `gorm:"column:recharge_amount_cents;uniqueIndex:uk_first_recharge_reward_amount,priority:2"`
|
||||
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_level_google_product"`
|
||||
RewardGroupID int64 `gorm:"column:reward_group_id"`
|
||||
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
|
||||
Enabled bool `gorm:"column:enabled"`
|
||||
@ -46,6 +48,7 @@ type FirstRechargeRewardGrantRecord struct {
|
||||
Level int `gorm:"column:level"`
|
||||
PayPlatform string `gorm:"column:pay_platform;size:64"`
|
||||
PaymentMethod string `gorm:"column:payment_method;size:32"`
|
||||
GoogleProductID string `gorm:"column:google_product_id;size:128;index:idx_first_recharge_reward_grant_google_product"`
|
||||
SourceOrderID string `gorm:"column:source_order_id;size:128;index:idx_first_recharge_reward_order"`
|
||||
RewardGroupID int64 `gorm:"column:reward_group_id"`
|
||||
RewardGroupName string `gorm:"column:reward_group_name;size:255"`
|
||||
|
||||
@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"chatapp3-golang/internal/service/firstrechargereward"
|
||||
|
||||
@ -60,7 +61,7 @@ func registerFirstRechargeRewardRoutes(engine *gin.Engine, javaClient authGatewa
|
||||
func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient authGateway, service *firstrechargereward.Service) {
|
||||
group.Use(authMiddleware(javaClient))
|
||||
group.GET("/home", func(c *gin.Context) {
|
||||
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c))
|
||||
resp, err := service.GetHome(c.Request.Context(), mustAuthUser(c), firstRechargeRewardRequestVersion(c))
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@ -68,3 +69,33 @@ func registerFirstRechargeRewardAppGroup(group *gin.RouterGroup, javaClient auth
|
||||
writeOK(c, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func firstRechargeRewardRequestVersion(c *gin.Context) string {
|
||||
return firstNonEmptyString(
|
||||
c.Query("appVersion"),
|
||||
c.Query("version"),
|
||||
c.GetHeader("req-version"),
|
||||
c.GetHeader("Req-Version"),
|
||||
versionFromReqAppIntel(c.GetHeader("req-app-intel")),
|
||||
versionFromReqAppIntel(c.GetHeader("Req-App-Intel")),
|
||||
)
|
||||
}
|
||||
|
||||
func versionFromReqAppIntel(value string) string {
|
||||
for _, item := range strings.Split(value, ";") {
|
||||
parts := strings.SplitN(item, "=", 2)
|
||||
if len(parts) == 2 && strings.EqualFold(strings.TrimSpace(parts[0]), "version") {
|
||||
return strings.TrimSpace(parts[1])
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstNonEmptyString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if text := strings.TrimSpace(value); text != "" {
|
||||
return text
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@ -37,12 +37,13 @@ func (s *Service) GetConfig(ctx context.Context, sysOrigin string) (*ConfigRespo
|
||||
}
|
||||
rewardItems := s.loadRewardItemsMap(ctx, levels)
|
||||
return &ConfigResponse{
|
||||
Configured: true,
|
||||
ID: configRow.ID,
|
||||
SysOrigin: configRow.SysOrigin,
|
||||
Enabled: configRow.Enabled,
|
||||
UpdateTime: formatDateTime(configRow.UpdateTime),
|
||||
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true),
|
||||
Configured: true,
|
||||
ID: configRow.ID,
|
||||
SysOrigin: configRow.SysOrigin,
|
||||
Enabled: configRow.Enabled,
|
||||
MinAppVersion: strings.TrimSpace(configRow.MinAppVersion),
|
||||
UpdateTime: formatDateTime(configRow.UpdateTime),
|
||||
LevelConfigs: buildLevelPayloads(levels, rewardItems, 0, 0, true),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -56,6 +57,10 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
||||
if req.Enabled && !hasEnabledLevel(levels) {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_level_configs", "enabled config must contain at least one enabled level")
|
||||
}
|
||||
minAppVersion := strings.TrimSpace(req.MinAppVersion)
|
||||
if req.Enabled && minAppVersion == "" {
|
||||
return nil, NewAppError(http.StatusBadRequest, "invalid_min_app_version", "minAppVersion is required when enabled")
|
||||
}
|
||||
|
||||
var savedConfigID int64
|
||||
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
@ -91,6 +96,7 @@ func (s *Service) SaveConfig(ctx context.Context, req SaveConfigRequest) (*Confi
|
||||
|
||||
configRow.SysOrigin = sysOrigin
|
||||
configRow.Enabled = req.Enabled
|
||||
configRow.MinAppVersion = minAppVersion
|
||||
configRow.UpdateTime = now
|
||||
if configRow.CreateTime.IsZero() {
|
||||
configRow.CreateTime = now
|
||||
@ -126,6 +132,7 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
|
||||
}
|
||||
seenLevels := make(map[int]struct{}, len(inputs))
|
||||
seenAmounts := make(map[int64]struct{}, len(inputs))
|
||||
seenGoogleProductIDs := make(map[string]struct{}, len(inputs))
|
||||
rows := make([]model.FirstRechargeRewardLevel, 0, len(inputs))
|
||||
for _, item := range inputs {
|
||||
if item.Level <= 0 {
|
||||
@ -148,6 +155,13 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
|
||||
if _, exists := seenAmounts[amountCents]; exists {
|
||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_recharge_amount", "levelConfigs must not contain duplicate rechargeAmount")
|
||||
}
|
||||
googleProductID := normalizeGoogleProductID(item.GoogleProductID)
|
||||
if googleProductID != "" {
|
||||
if _, exists := seenGoogleProductIDs[googleProductID]; exists {
|
||||
return nil, NewAppError(http.StatusBadRequest, "duplicate_google_product_id", "levelConfigs must not contain duplicate googleProductId")
|
||||
}
|
||||
seenGoogleProductIDs[googleProductID] = struct{}{}
|
||||
}
|
||||
seenLevels[item.Level] = struct{}{}
|
||||
seenAmounts[amountCents] = struct{}{}
|
||||
id, err := utils.NextID()
|
||||
@ -158,6 +172,7 @@ func (s *Service) normalizeLevelInputs(inputs []LevelInput) ([]model.FirstRechar
|
||||
ID: id,
|
||||
Level: item.Level,
|
||||
RechargeAmountCents: amountCents,
|
||||
GoogleProductID: googleProductID,
|
||||
RewardGroupID: rewardGroupID,
|
||||
RewardGroupName: strings.TrimSpace(item.RewardGroupName),
|
||||
Enabled: item.Enabled,
|
||||
|
||||
@ -41,9 +41,6 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion) {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
|
||||
}
|
||||
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
|
||||
}
|
||||
@ -61,9 +58,12 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
|
||||
if !bundle.Config.Enabled {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "config_disabled"}, nil
|
||||
}
|
||||
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents)
|
||||
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion, bundle.Config.MinAppVersion) {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
|
||||
}
|
||||
matched, ok := pickMatchedLevel(bundle.Levels, normalized.AmountCents, normalized.GoogleProductID)
|
||||
if !ok {
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "amount_not_reached"}, nil
|
||||
return &ProcessRechargeResponse{Processed: false, Reason: "level_not_matched"}, nil
|
||||
}
|
||||
|
||||
record, terminal, err := s.getOrCreateGrantRecord(ctx, normalized, bundle.Config, matched)
|
||||
@ -112,13 +112,14 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
|
||||
if amountCents <= 0 {
|
||||
amountCents = firstPositiveAmountCents(event.Amount.String(), event.AmountUSD.String(), event.USDAmount.String())
|
||||
}
|
||||
if amountCents <= 0 {
|
||||
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_amount", "amountCents or amount is required")
|
||||
}
|
||||
|
||||
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
|
||||
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
|
||||
appVersion := normalizeRechargeAppVersion(event)
|
||||
googleProductID := normalizeRechargeGoogleProductID(event)
|
||||
if amountCents <= 0 && googleProductID == "" {
|
||||
return normalizedRechargeEvent{}, NewAppError(http.StatusBadRequest, "invalid_recharge_identity", "amountCents, amount, or googleProductId is required")
|
||||
}
|
||||
sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String())
|
||||
eventID := strings.TrimSpace(event.EventID)
|
||||
if eventID == "" && sourceOrderID != "" {
|
||||
@ -132,16 +133,17 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
|
||||
occurredAt = time.Now()
|
||||
}
|
||||
return normalizedRechargeEvent{
|
||||
EventID: eventID,
|
||||
SysOrigin: sysOrigin,
|
||||
UserID: userID,
|
||||
AmountCents: amountCents,
|
||||
Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"),
|
||||
PayPlatform: payPlatform,
|
||||
PaymentMethod: paymentMethod,
|
||||
AppVersion: appVersion,
|
||||
SourceOrderID: sourceOrderID,
|
||||
OccurredAt: occurredAt,
|
||||
EventID: eventID,
|
||||
SysOrigin: sysOrigin,
|
||||
UserID: userID,
|
||||
AmountCents: amountCents,
|
||||
Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"),
|
||||
PayPlatform: payPlatform,
|
||||
PaymentMethod: paymentMethod,
|
||||
AppVersion: appVersion,
|
||||
GoogleProductID: googleProductID,
|
||||
SourceOrderID: sourceOrderID,
|
||||
OccurredAt: occurredAt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@ -187,6 +189,7 @@ func (s *Service) getOrCreateGrantRecord(
|
||||
Level: level.Level,
|
||||
PayPlatform: event.PayPlatform,
|
||||
PaymentMethod: event.PaymentMethod,
|
||||
GoogleProductID: event.GoogleProductID,
|
||||
SourceOrderID: event.SourceOrderID,
|
||||
RewardGroupID: level.RewardGroupID,
|
||||
RewardGroupName: level.RewardGroupName,
|
||||
@ -290,6 +293,7 @@ func (s *Service) pushRewardNotice(ctx context.Context, record *model.FirstRecha
|
||||
"rewardGroupName": record.RewardGroupName,
|
||||
"payPlatform": record.PayPlatform,
|
||||
"paymentMethod": record.PaymentMethod,
|
||||
"googleProductId": record.GoogleProductID,
|
||||
"noticeType": firstRechargeRewardNoticeType,
|
||||
}
|
||||
return s.java.SendOfficialNoticeCustomize(ctx, integration.OfficialNoticeCustomizeRequest{
|
||||
@ -372,6 +376,18 @@ func normalizeRechargeAppVersion(event RechargeEvent) string {
|
||||
)
|
||||
}
|
||||
|
||||
func normalizeRechargeGoogleProductID(event RechargeEvent) string {
|
||||
return normalizeGoogleProductID(firstNonEmpty(
|
||||
event.GoogleProductID,
|
||||
event.ProductID.String(),
|
||||
rechargePayloadString(event.Payload, "googleProductId", "productId", "product_id"),
|
||||
))
|
||||
}
|
||||
|
||||
func normalizeGoogleProductID(value string) string {
|
||||
return strings.TrimSpace(value)
|
||||
}
|
||||
|
||||
func rechargePayloadString(payload json.RawMessage, keys ...string) string {
|
||||
if len(payload) == 0 {
|
||||
return ""
|
||||
|
||||
@ -20,7 +20,7 @@ const (
|
||||
)
|
||||
|
||||
// GetHome returns app-facing first recharge reward config and the user's grant state.
|
||||
func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, error) {
|
||||
func (s *Service) GetHome(ctx context.Context, user AuthUser, appVersion string) (*HomeResponse, error) {
|
||||
sysOrigin, err := requireUser(user)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -52,12 +52,17 @@ func (s *Service) GetHome(ctx context.Context, user AuthUser) (*HomeResponse, er
|
||||
ActivityStatus: activityStatusOngoing,
|
||||
UserID: user.UserID,
|
||||
SysOrigin: bundle.Config.SysOrigin,
|
||||
MinAppVersion: bundle.Config.MinAppVersion,
|
||||
LevelConfigs: buildLevelPayloads(bundle.Levels, rewardItems, 0, 0, false),
|
||||
HasRewardRecord: false,
|
||||
}
|
||||
if !bundle.Config.Enabled {
|
||||
resp.ActivityStatus = activityStatusDisabled
|
||||
}
|
||||
if bundle.Config.Enabled && !isFirstRechargeRewardAppVersionSupported(appVersion, bundle.Config.MinAppVersion) {
|
||||
resp.Enabled = false
|
||||
resp.ActivityStatus = activityStatusDisabled
|
||||
}
|
||||
|
||||
record, ok, err := s.loadUserGrantRecord(ctx, bundle.Config.SysOrigin, user.UserID)
|
||||
if err != nil {
|
||||
|
||||
@ -12,10 +12,11 @@ import (
|
||||
|
||||
func configSnapshotFromModel(row model.FirstRechargeRewardConfig) configSnapshot {
|
||||
return configSnapshot{
|
||||
ID: row.ID,
|
||||
SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)),
|
||||
Enabled: row.Enabled,
|
||||
UpdateTime: row.UpdateTime,
|
||||
ID: row.ID,
|
||||
SysOrigin: strings.ToUpper(strings.TrimSpace(row.SysOrigin)),
|
||||
Enabled: row.Enabled,
|
||||
MinAppVersion: strings.TrimSpace(row.MinAppVersion),
|
||||
UpdateTime: row.UpdateTime,
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,6 +25,7 @@ func levelSnapshotFromModel(row model.FirstRechargeRewardLevel) levelSnapshot {
|
||||
ID: row.ID,
|
||||
Level: row.Level,
|
||||
RechargeAmountCents: row.RechargeAmountCents,
|
||||
GoogleProductID: normalizeGoogleProductID(row.GoogleProductID),
|
||||
RewardGroupID: row.RewardGroupID,
|
||||
RewardGroupName: strings.TrimSpace(row.RewardGroupName),
|
||||
Enabled: row.Enabled,
|
||||
@ -51,6 +53,7 @@ func buildLevelPayloads(
|
||||
Level: level.Level,
|
||||
RechargeAmount: formatAmountCents(level.RechargeAmountCents),
|
||||
RechargeAmountCents: level.RechargeAmountCents,
|
||||
GoogleProductID: level.GoogleProductID,
|
||||
RewardGroupID: level.RewardGroupID,
|
||||
RewardGroupName: level.RewardGroupName,
|
||||
RewardItems: rewardItems[level.RewardGroupID],
|
||||
@ -62,19 +65,27 @@ func buildLevelPayloads(
|
||||
return payloads
|
||||
}
|
||||
|
||||
func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64) (levelSnapshot, bool) {
|
||||
var matched levelSnapshot
|
||||
ok := false
|
||||
func pickMatchedLevel(levels []levelSnapshot, rechargeAmountCents int64, googleProductID string) (levelSnapshot, bool) {
|
||||
googleProductID = normalizeGoogleProductID(googleProductID)
|
||||
if googleProductID != "" {
|
||||
for _, level := range levels {
|
||||
if !level.Enabled || level.RewardGroupID <= 0 || level.GoogleProductID == "" {
|
||||
continue
|
||||
}
|
||||
if level.GoogleProductID == googleProductID {
|
||||
return level, true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, level := range levels {
|
||||
if !level.Enabled || level.RechargeAmountCents <= 0 || level.RewardGroupID <= 0 {
|
||||
continue
|
||||
}
|
||||
if rechargeAmountCents >= level.RechargeAmountCents && (!ok || level.RechargeAmountCents > matched.RechargeAmountCents) {
|
||||
matched = level
|
||||
ok = true
|
||||
if rechargeAmountCents == level.RechargeAmountCents {
|
||||
return level, true
|
||||
}
|
||||
}
|
||||
return matched, ok
|
||||
return levelSnapshot{}, false
|
||||
}
|
||||
|
||||
func sortLevels(levels []levelSnapshot) {
|
||||
|
||||
@ -92,6 +92,7 @@ func grantRecordViewFromModel(row model.FirstRechargeRewardGrantRecord, rewardIt
|
||||
Level: row.Level,
|
||||
PayPlatform: row.PayPlatform,
|
||||
PaymentMethod: row.PaymentMethod,
|
||||
GoogleProductID: row.GoogleProductID,
|
||||
SourceOrderID: row.SourceOrderID,
|
||||
RewardGroupID: row.RewardGroupID,
|
||||
RewardGroupName: row.RewardGroupName,
|
||||
|
||||
@ -20,8 +20,9 @@ func TestSaveConfigReturnsRewardItems(t *testing.T) {
|
||||
req := mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
]
|
||||
}`)
|
||||
resp, err := service.SaveConfig(context.Background(), req)
|
||||
@ -38,6 +39,9 @@ func TestSaveConfigReturnsRewardItems(t *testing.T) {
|
||||
if level.RechargeAmountCents != 999 || level.RewardGroupID != 1001 {
|
||||
t.Fatalf("level payload = %+v", level)
|
||||
}
|
||||
if level.GoogleProductID != "gold_999" {
|
||||
t.Fatalf("google product id = %q", level.GoogleProductID)
|
||||
}
|
||||
if len(level.RewardItems) != 1 || level.RewardItems[0].Name != "首冲礼包奖励" {
|
||||
t.Fatalf("reward items = %+v", level.RewardItems)
|
||||
}
|
||||
@ -66,6 +70,7 @@ func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
|
||||
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "2001", "rewardGroupName": "VIP礼包", "enabled": true}
|
||||
]
|
||||
@ -115,6 +120,7 @@ func TestRewardItemsReturnConcretePropsType(t *testing.T) {
|
||||
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
|
||||
]
|
||||
@ -147,8 +153,9 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
|
||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
@ -185,7 +192,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("low amount ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if lowAmount.Processed || lowAmount.Reason != "amount_not_reached" {
|
||||
if lowAmount.Processed || lowAmount.Reason != "level_not_matched" {
|
||||
t.Fatalf("low amount response = %+v", lowAmount)
|
||||
}
|
||||
|
||||
@ -292,8 +299,9 @@ func TestProcessRechargeEventStartsCountingAtSupportedAppVersion(t *testing.T) {
|
||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
@ -380,13 +388,86 @@ func TestFirstRechargeRewardAppVersionSupported(t *testing.T) {
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion); got != tt.want {
|
||||
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion, "1.5.0"); got != tt.want {
|
||||
t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHomeRequiresConfiguredMinAppVersion(t *testing.T) {
|
||||
service, gateway, _ := newTestService(t)
|
||||
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
|
||||
|
||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "2.0.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
oldVersion, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "1.9.9")
|
||||
if err != nil {
|
||||
t.Fatalf("old version GetHome() error = %v", err)
|
||||
}
|
||||
if oldVersion.Enabled || oldVersion.ActivityStatus != activityStatusDisabled {
|
||||
t.Fatalf("old version home = %+v", oldVersion)
|
||||
}
|
||||
|
||||
supported, err := service.GetHome(context.Background(), AuthUser{UserID: 123, SysOrigin: "LIKEI"}, "2.0.0")
|
||||
if err != nil {
|
||||
t.Fatalf("supported GetHome() error = %v", err)
|
||||
}
|
||||
if !supported.Enabled || supported.ActivityStatus != activityStatusOngoing {
|
||||
t.Fatalf("supported home = %+v", supported)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessRechargeEventMatchesGoogleProductIDBeforeAmount(t *testing.T) {
|
||||
service, gateway, _ := newTestService(t)
|
||||
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
|
||||
gateway.groups[1002] = testRewardGroup(1002, "Google 首冲礼包")
|
||||
|
||||
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
|
||||
"sysOrigin": "LIKEI",
|
||||
"enabled": true,
|
||||
"minAppVersion": "1.5.0",
|
||||
"levelConfigs": [
|
||||
{"level": 1, "rechargeAmount": "9.99", "googleProductId": "gold_999", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true},
|
||||
{"level": 2, "rechargeAmount": "19.99", "googleProductId": "first_recharge_google_1999", "rewardGroupId": "1002", "rewardGroupName": "Google 首冲礼包", "enabled": true}
|
||||
]
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("SaveConfig() error = %v", err)
|
||||
}
|
||||
|
||||
granted, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
|
||||
"eventId": "RECHARGE_SUCCESS:GOOGLE:PRODUCT_ID",
|
||||
"sysOrigin": "LIKEI",
|
||||
"userId": "123",
|
||||
"amountCents": "999",
|
||||
"payPlatform": "GOOGLE",
|
||||
"paymentMethod": "GOOGLE",
|
||||
"googleProductId": "first_recharge_google_1999",
|
||||
"appVersion": "1.5.0",
|
||||
"sourceOrderId": "PRODUCT_ID"
|
||||
}`))
|
||||
if err != nil {
|
||||
t.Fatalf("product id ProcessRechargeEvent() error = %v", err)
|
||||
}
|
||||
if !granted.Processed || granted.Status != statusSuccess || granted.Level != 2 {
|
||||
t.Fatalf("product id response = %+v", granted)
|
||||
}
|
||||
if len(gateway.rewardRequests) != 1 || gateway.rewardRequests[0].SourceGroupID != 1002 {
|
||||
t.Fatalf("reward requests = %+v", gateway.rewardRequests)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
|
||||
|
||||
@ -65,6 +65,7 @@ type LevelPayload struct {
|
||||
Level int `json:"level"`
|
||||
RechargeAmount string `json:"rechargeAmount"`
|
||||
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
||||
GoogleProductID string `json:"googleProductId,omitempty"`
|
||||
RewardGroupID int64 `json:"rewardGroupId,string"`
|
||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||
RewardItems []RewardItem `json:"rewardItems"`
|
||||
@ -78,25 +79,28 @@ type LevelInput struct {
|
||||
Level int `json:"level"`
|
||||
RechargeAmount flexibleAmount `json:"rechargeAmount"`
|
||||
RechargeAmountCents int64 `json:"rechargeAmountCents"`
|
||||
GoogleProductID string `json:"googleProductId"`
|
||||
RewardGroupID flexibleInt64 `json:"rewardGroupId"`
|
||||
RewardGroupName string `json:"rewardGroupName"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
|
||||
type ConfigResponse struct {
|
||||
Configured bool `json:"configured"`
|
||||
ID int64 `json:"id,string"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
UpdateTime string `json:"updateTime,omitempty"`
|
||||
LevelConfigs []LevelPayload `json:"levelConfigs"`
|
||||
Configured bool `json:"configured"`
|
||||
ID int64 `json:"id,string"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
UpdateTime string `json:"updateTime,omitempty"`
|
||||
LevelConfigs []LevelPayload `json:"levelConfigs"`
|
||||
}
|
||||
|
||||
type SaveConfigRequest struct {
|
||||
ID flexibleInt64 `json:"id"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
LevelConfigs []LevelInput `json:"levelConfigs"`
|
||||
ID flexibleInt64 `json:"id"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MinAppVersion string `json:"minAppVersion"`
|
||||
LevelConfigs []LevelInput `json:"levelConfigs"`
|
||||
}
|
||||
|
||||
type HomeResponse struct {
|
||||
@ -108,6 +112,7 @@ type HomeResponse struct {
|
||||
HasRewardRecord bool `json:"hasRewardRecord"`
|
||||
RewardStatus string `json:"rewardStatus,omitempty"`
|
||||
Rewarded bool `json:"rewarded"`
|
||||
MinAppVersion string `json:"minAppVersion,omitempty"`
|
||||
MatchedLevel int `json:"matchedLevel,omitempty"`
|
||||
RechargeAmount string `json:"rechargeAmount,omitempty"`
|
||||
RechargeAmountCents int64 `json:"rechargeAmountCents,omitempty"`
|
||||
@ -141,6 +146,7 @@ type GrantRecordView struct {
|
||||
Level int `json:"level"`
|
||||
PayPlatform string `json:"payPlatform"`
|
||||
PaymentMethod string `json:"paymentMethod"`
|
||||
GoogleProductID string `json:"googleProductId,omitempty"`
|
||||
SourceOrderID string `json:"sourceOrderId,omitempty"`
|
||||
RewardGroupID int64 `json:"rewardGroupId,string"`
|
||||
RewardGroupName string `json:"rewardGroupName,omitempty"`
|
||||
@ -166,24 +172,26 @@ type RewardItem struct {
|
||||
}
|
||||
|
||||
type RechargeEvent struct {
|
||||
EventID string `json:"eventId"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
UserID flexibleInt64 `json:"userId"`
|
||||
AmountCents flexibleInt64 `json:"amountCents"`
|
||||
Amount flexibleString `json:"amount"`
|
||||
AmountUSD flexibleString `json:"amountUsd"`
|
||||
USDAmount flexibleString `json:"usdAmount"`
|
||||
Currency string `json:"currency"`
|
||||
PayPlatform string `json:"payPlatform"`
|
||||
PaymentMethod string `json:"paymentMethod"`
|
||||
AppVersion flexibleString `json:"appVersion"`
|
||||
ClientVersion flexibleString `json:"clientVersion"`
|
||||
Version flexibleString `json:"version"`
|
||||
ReqVersion flexibleString `json:"reqVersion"`
|
||||
SourceOrderID flexibleString `json:"sourceOrderId"`
|
||||
OrderID flexibleString `json:"orderId"`
|
||||
OccurredAt flexibleString `json:"occurredAt"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
EventID string `json:"eventId"`
|
||||
SysOrigin string `json:"sysOrigin"`
|
||||
UserID flexibleInt64 `json:"userId"`
|
||||
AmountCents flexibleInt64 `json:"amountCents"`
|
||||
Amount flexibleString `json:"amount"`
|
||||
AmountUSD flexibleString `json:"amountUsd"`
|
||||
USDAmount flexibleString `json:"usdAmount"`
|
||||
Currency string `json:"currency"`
|
||||
PayPlatform string `json:"payPlatform"`
|
||||
PaymentMethod string `json:"paymentMethod"`
|
||||
GoogleProductID string `json:"googleProductId"`
|
||||
ProductID flexibleString `json:"productId"`
|
||||
AppVersion flexibleString `json:"appVersion"`
|
||||
ClientVersion flexibleString `json:"clientVersion"`
|
||||
Version flexibleString `json:"version"`
|
||||
ReqVersion flexibleString `json:"reqVersion"`
|
||||
SourceOrderID flexibleString `json:"sourceOrderId"`
|
||||
OrderID flexibleString `json:"orderId"`
|
||||
OccurredAt flexibleString `json:"occurredAt"`
|
||||
Payload json.RawMessage `json:"payload,omitempty"`
|
||||
}
|
||||
|
||||
type ProcessRechargeResponse struct {
|
||||
@ -200,32 +208,35 @@ type configBundle struct {
|
||||
}
|
||||
|
||||
type configSnapshot struct {
|
||||
ID int64
|
||||
SysOrigin string
|
||||
Enabled bool
|
||||
UpdateTime time.Time
|
||||
ID int64
|
||||
SysOrigin string
|
||||
Enabled bool
|
||||
MinAppVersion string
|
||||
UpdateTime time.Time
|
||||
}
|
||||
|
||||
type levelSnapshot struct {
|
||||
ID int64
|
||||
Level int
|
||||
RechargeAmountCents int64
|
||||
GoogleProductID string
|
||||
RewardGroupID int64
|
||||
RewardGroupName string
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
type normalizedRechargeEvent struct {
|
||||
EventID string
|
||||
SysOrigin string
|
||||
UserID int64
|
||||
AmountCents int64
|
||||
Currency string
|
||||
PayPlatform string
|
||||
PaymentMethod string
|
||||
AppVersion string
|
||||
SourceOrderID string
|
||||
OccurredAt time.Time
|
||||
EventID string
|
||||
SysOrigin string
|
||||
UserID int64
|
||||
AmountCents int64
|
||||
Currency string
|
||||
PayPlatform string
|
||||
PaymentMethod string
|
||||
AppVersion string
|
||||
GoogleProductID string
|
||||
SourceOrderID string
|
||||
OccurredAt time.Time
|
||||
}
|
||||
|
||||
type flexibleInt64 int64
|
||||
|
||||
@ -5,14 +5,18 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
const firstRechargeRewardMinAppVersion = "1.5.0"
|
||||
const defaultFirstRechargeRewardMinAppVersion = "1.5.0"
|
||||
|
||||
func isFirstRechargeRewardAppVersionSupported(appVersion string) bool {
|
||||
func isFirstRechargeRewardAppVersionSupported(appVersion string, minAppVersion string) bool {
|
||||
appVersion = strings.TrimSpace(appVersion)
|
||||
minAppVersion = strings.TrimSpace(minAppVersion)
|
||||
if minAppVersion == "" {
|
||||
minAppVersion = defaultFirstRechargeRewardMinAppVersion
|
||||
}
|
||||
if appVersion == "" {
|
||||
return false
|
||||
}
|
||||
return compareAppVersion(appVersion, firstRechargeRewardMinAppVersion) >= 0
|
||||
return compareAppVersion(appVersion, minAppVersion) >= 0
|
||||
}
|
||||
|
||||
func compareAppVersion(left, right string) int {
|
||||
|
||||
@ -2,6 +2,7 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_config` (
|
||||
`id` bigint NOT NULL COMMENT '主键ID',
|
||||
`sys_origin` varchar(32) NOT NULL COMMENT '来源系统',
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否启用',
|
||||
`min_app_version` varchar(32) NOT NULL DEFAULT '1.5.0' COMMENT '最低生效App版本',
|
||||
`create_time` datetime(3) DEFAULT NULL COMMENT '创建时间',
|
||||
`update_time` datetime(3) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
@ -14,6 +15,7 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
|
||||
`config_id` bigint NOT NULL COMMENT '首冲奖励主配置ID',
|
||||
`level` int NOT NULL COMMENT '档位',
|
||||
`recharge_amount_cents` bigint NOT NULL DEFAULT '0' COMMENT '充值金额门槛,单位为分',
|
||||
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
|
||||
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
|
||||
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
|
||||
`enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用',
|
||||
@ -22,6 +24,7 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_level` (
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_first_recharge_reward_level` (`config_id`, `level`),
|
||||
UNIQUE KEY `uk_first_recharge_reward_amount` (`config_id`, `recharge_amount_cents`),
|
||||
KEY `idx_first_recharge_reward_level_google_product` (`google_product_id`),
|
||||
KEY `idx_first_recharge_reward_level_config` (`config_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励充值档位';
|
||||
|
||||
@ -42,6 +45,7 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
|
||||
`level` int NOT NULL COMMENT '命中档位',
|
||||
`pay_platform` varchar(64) NOT NULL DEFAULT '' COMMENT '支付平台:GOOGLE/MIFA_PAY/PAYER_MAX等',
|
||||
`payment_method` varchar(32) NOT NULL DEFAULT '' COMMENT '充值方式:GOOGLE/THIRD_PARTY',
|
||||
`google_product_id` varchar(128) NOT NULL DEFAULT '' COMMENT 'Google商品ID',
|
||||
`source_order_id` varchar(128) NOT NULL DEFAULT '' COMMENT '来源订单ID',
|
||||
`reward_group_id` bigint NOT NULL DEFAULT '0' COMMENT '奖励资源组ID',
|
||||
`reward_group_name` varchar(255) NOT NULL DEFAULT '' COMMENT '奖励资源组名称快照',
|
||||
@ -58,5 +62,6 @@ CREATE TABLE IF NOT EXISTS `first_recharge_reward_grant_record` (
|
||||
UNIQUE KEY `uk_first_recharge_reward_event` (`event_id`),
|
||||
UNIQUE KEY `uk_first_recharge_reward_user` (`sys_origin`, `user_id`),
|
||||
KEY `idx_first_recharge_reward_status` (`sys_origin`, `user_id`, `status`),
|
||||
KEY `idx_first_recharge_reward_grant_google_product` (`google_product_id`),
|
||||
KEY `idx_first_recharge_reward_order` (`source_order_id`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='首冲奖励发放记录';
|
||||
|
||||
@ -0,0 +1,71 @@
|
||||
SET @current_schema := DATABASE();
|
||||
|
||||
SET @add_first_recharge_reward_min_app_version_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_config'
|
||||
AND column_name = 'min_app_version'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_config` ADD COLUMN `min_app_version` varchar(32) NOT NULL DEFAULT ''1.5.0'' COMMENT ''最低生效App版本'' AFTER `enabled`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_min_app_version_stmt FROM @add_first_recharge_reward_min_app_version_sql;
|
||||
EXECUTE add_first_recharge_reward_min_app_version_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_min_app_version_stmt;
|
||||
|
||||
SET @add_first_recharge_reward_level_google_product_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_level'
|
||||
AND column_name = 'google_product_id'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_level` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `recharge_amount_cents`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_level_google_product_stmt FROM @add_first_recharge_reward_level_google_product_sql;
|
||||
EXECUTE add_first_recharge_reward_level_google_product_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_stmt;
|
||||
|
||||
SET @add_first_recharge_reward_record_google_product_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_grant_record'
|
||||
AND column_name = 'google_product_id'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_grant_record` ADD COLUMN `google_product_id` varchar(128) NOT NULL DEFAULT '''' COMMENT ''Google商品ID'' AFTER `payment_method`',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_record_google_product_stmt FROM @add_first_recharge_reward_record_google_product_sql;
|
||||
EXECUTE add_first_recharge_reward_record_google_product_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_stmt;
|
||||
|
||||
SET @add_first_recharge_reward_level_google_product_idx_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_level'
|
||||
AND index_name = 'idx_first_recharge_reward_level_google_product'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_level` ADD INDEX `idx_first_recharge_reward_level_google_product` (`google_product_id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_level_google_product_idx_stmt FROM @add_first_recharge_reward_level_google_product_idx_sql;
|
||||
EXECUTE add_first_recharge_reward_level_google_product_idx_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_level_google_product_idx_stmt;
|
||||
|
||||
SET @add_first_recharge_reward_record_google_product_idx_sql := IF(
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.statistics
|
||||
WHERE table_schema = @current_schema
|
||||
AND table_name = 'first_recharge_reward_grant_record'
|
||||
AND index_name = 'idx_first_recharge_reward_grant_google_product'
|
||||
),
|
||||
'ALTER TABLE `first_recharge_reward_grant_record` ADD INDEX `idx_first_recharge_reward_grant_google_product` (`google_product_id`)',
|
||||
'SELECT 1'
|
||||
);
|
||||
PREPARE add_first_recharge_reward_record_google_product_idx_stmt FROM @add_first_recharge_reward_record_google_product_idx_sql;
|
||||
EXECUTE add_first_recharge_reward_record_google_product_idx_stmt;
|
||||
DEALLOCATE PREPARE add_first_recharge_reward_record_google_product_idx_stmt;
|
||||
Loading…
x
Reference in New Issue
Block a user