任务配置

This commit is contained in:
zhx 2026-07-23 17:38:37 +08:00
parent 0439da10f8
commit 2ed7b6bf78
79 changed files with 3622 additions and 2490 deletions

File diff suppressed because it is too large Load Diff

View File

@ -261,6 +261,19 @@ message CronBatchResponse {
bool has_more = 5;
}
// TaskRewardItem wallet
// resource H5
message TaskRewardItem {
string item_type = 1;
int64 resource_id = 2;
string resource_type = 3;
string cover_url = 4;
int64 quantity = 5;
int64 duration_ms = 6;
string wallet_asset_type = 7;
int64 wallet_asset_amount = 8;
}
// TaskItem App /
message TaskItem {
string task_id = 1;
@ -288,6 +301,12 @@ message TaskItem {
string action_payload_json = 23;
string dimension_filter_json = 24;
string reward_asset_type = 25;
string reward_type = 26;
int64 reward_resource_id = 27;
int64 reward_resource_group_id = 28;
int64 reward_quantity = 29;
int64 reward_duration_ms = 30;
repeated TaskRewardItem reward_items = 31;
}
// TaskSection App daily/exclusive
@ -333,6 +352,11 @@ message ClaimTaskRewardResponse {
int64 granted_at_ms = 8;
bool claimed = 9;
string reward_asset_type = 10;
string reward_type = 11;
int64 reward_resource_id = 12;
int64 reward_resource_group_id = 13;
string wallet_resource_grant_id = 14;
repeated TaskRewardItem reward_items = 15;
}
// ConsumeTaskEventRequest outbox consumer
@ -481,6 +505,12 @@ message TaskDefinition {
string action_payload_json = 24;
string dimension_filter_json = 25;
string reward_asset_type = 26;
string reward_type = 27;
int64 reward_resource_id = 28;
int64 reward_resource_group_id = 29;
int64 reward_quantity = 30;
int64 reward_duration_ms = 31;
repeated TaskRewardItem reward_items = 32;
}
// ListTaskDefinitionsRequest
@ -524,6 +554,11 @@ message UpsertTaskDefinitionRequest {
string action_payload_json = 21;
string dimension_filter_json = 22;
string reward_asset_type = 23;
string reward_type = 24;
int64 reward_resource_id = 25;
int64 reward_resource_group_id = 26;
int64 reward_quantity = 27;
int64 reward_duration_ms = 28;
}
message UpsertTaskDefinitionResponse {

View File

@ -144,7 +144,7 @@ curl -X GET 'http://127.0.0.1:13000/api/v1/app/explore-tabs' \
| `tab` | string | App 展示的 tab 文案。 |
| `h5_url` | string | 点击 tab 后加载的 H5 地址。 |
| `enabled` | bool | 是否启用。App 接口只返回 `true` 的记录。 |
| `sort_order` | int | 展示排序,数值越越靠前。 |
| `sort_order` | int | 展示排序,数值越越靠前。 |
| `updated_at_ms` | int | 更新时间Unix epoch milliseconds。 |
### 空列表响应

View File

@ -55,7 +55,7 @@ X-App-Platform: android
| 字段 | 说明 |
| --- | --- |
| `items` | 当前可展示的开屏配置列表,已按 `sort_order ASC, id DESC` 排序。 |
| `items` | 当前可展示的开屏配置列表,已按 `sort_order DESC, id DESC` 排序。 |
| `cover_url` | 开屏图片地址。 |
| `type` | `h5``app`。 |
| `param` | `type=h5` 时是 H5 链接;`type=app` 时由客户端解释。 |

View File

@ -50,7 +50,7 @@ X-App-Code: lalu
| 字段 | 说明 |
| --- | --- |
| `items` | 当前可展示的弹窗配置列表,已按 `sort_order ASC, id DESC` 排序。 |
| `items` | 当前可展示的弹窗配置列表,已按 `sort_order DESC, id DESC` 排序。 |
| `code` | 弹窗编码,同一个 App 下唯一,用于 Flutter 本地频控和埋点识别。 |
| `name` | 弹窗名字。 |
| `image_url` | 弹窗图片地址,可能为空。 |

View File

@ -144,7 +144,7 @@ flowchart LR
| 指标类型 | 是 | 按分类收敛,例如 `mic_online_minutes``gift_spend_coin` |
| 目标值 | 是 | 必须大于 0 |
| 奖励金币 | 是 | 必须大于 0 |
| 排序值 | 是 | 值靠前 |
| 排序值 | 是 | 值靠前 |
| 生效/结束时间 | 否 | 控制任务上下架窗口 |
| 状态 | 是 | draft 保存草稿active 对 App 生效 |

View File

@ -404,7 +404,7 @@ Response:
规则:
- 只返回 `enabled=true` 的国家。
- 按 `sort_order ASC, country_code ASC` 排序。
- 按 `sort_order DESC, country_code ASC` 排序。
- `country_code` 是注册页提交给 `CompleteOnboarding` 的唯一国家值。
- `phone_country_code` 只用于注册页展示或后续手机号能力预填,不参与国家归属判断。
- 该接口可以由 gateway 短 TTL 缓存,但必须在后台启用/禁用国家后尽快失效。

View File

@ -205,7 +205,7 @@ def fetch_resources(conn, app_code: str) -> list[dict[str, Any]]:
sort_order, created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
FROM resources
WHERE app_code = %s AND status = 'active'
ORDER BY sort_order ASC, resource_id ASC
ORDER BY sort_order DESC, resource_id ASC
""",
(app_code,),
)

View File

@ -24,26 +24,31 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
}
type taskRequest struct {
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
AudienceType string `json:"audience_type"`
IconKey string `json:"icon_key"`
IconURL string `json:"icon_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
ActionPayloadJSON string `json:"action_payload_json"`
DimensionFilterJSON string `json:"dimension_filter_json"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
RewardAssetType string `json:"reward_asset_type" binding:"required,oneof=COIN POINT"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
AudienceType string `json:"audience_type"`
IconKey string `json:"icon_key"`
IconURL string `json:"icon_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
ActionPayloadJSON string `json:"action_payload_json"`
DimensionFilterJSON string `json:"dimension_filter_json"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardType string `json:"reward_type"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
RewardAssetType string `json:"reward_asset_type"`
RewardResourceID int64 `json:"reward_resource_id"`
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
RewardQuantity int64 `json:"reward_quantity"`
RewardDurationMS int64 `json:"reward_duration_ms"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
}
type statusRequest struct {
@ -51,32 +56,49 @@ type statusRequest struct {
}
type taskDTO struct {
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
AudienceType string `json:"audience_type"`
IconKey string `json:"icon_key"`
IconURL string `json:"icon_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
ActionPayloadJSON string `json:"action_payload_json"`
DimensionFilterJSON string `json:"dimension_filter_json"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
RewardAssetType string `json:"reward_asset_type"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
Version int64 `json:"version"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
AudienceType string `json:"audience_type"`
IconKey string `json:"icon_key"`
IconURL string `json:"icon_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
ActionPayloadJSON string `json:"action_payload_json"`
DimensionFilterJSON string `json:"dimension_filter_json"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
RewardType string `json:"reward_type"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
RewardAssetType string `json:"reward_asset_type"`
RewardResourceID int64 `json:"reward_resource_id"`
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
RewardQuantity int64 `json:"reward_quantity"`
RewardDurationMS int64 `json:"reward_duration_ms"`
RewardItems []taskRewardItemDTO `json:"reward_items"`
Status string `json:"status"`
SortOrder int32 `json:"sort_order"`
Version int64 `json:"version"`
EffectiveFromMS int64 `json:"effective_from_ms"`
EffectiveToMS int64 `json:"effective_to_ms"`
CreatedByAdminID int64 `json:"created_by_admin_id"`
UpdatedByAdminID int64 `json:"updated_by_admin_id"`
CreatedAtMS int64 `json:"created_at_ms"`
UpdatedAtMS int64 `json:"updated_at_ms"`
}
type taskRewardItemDTO struct {
ItemType string `json:"item_type"`
ResourceID int64 `json:"resource_id"`
ResourceType string `json:"resource_type"`
CoverURL string `json:"cover_url"`
Quantity int64 `json:"quantity"`
DurationMS int64 `json:"duration_ms"`
WalletAssetType string `json:"wallet_asset_type"`
WalletAssetAmount int64 `json:"wallet_asset_amount"`
}
// ListTaskDefinitions 返回活动管理 > 每日任务页的 daily/exclusive 配置列表。
@ -172,29 +194,34 @@ func (r taskRequest) toProto(c *gin.Context, taskID string, meta *activityv1.Req
// admin-server 只做字段裁剪和身份注入任务口径校验、JSON 对象校验和版本生成统一放在 activity-service。
// 图标和跳转属于展示字段直接透传给任务定义App 任务页会按 icon_key/icon_url/action_* 渲染。
return &activityv1.UpsertTaskDefinitionRequest{
Meta: meta,
TaskId: taskID,
TaskType: strings.TrimSpace(r.TaskType),
Category: strings.TrimSpace(r.Category),
MetricType: strings.TrimSpace(r.MetricType),
Title: strings.TrimSpace(r.Title),
Description: strings.TrimSpace(r.Description),
AudienceType: strings.TrimSpace(r.AudienceType),
IconKey: strings.TrimSpace(r.IconKey),
IconUrl: strings.TrimSpace(r.IconURL),
ActionType: strings.TrimSpace(r.ActionType),
ActionParam: strings.TrimSpace(r.ActionParam),
ActionPayloadJson: strings.TrimSpace(r.ActionPayloadJSON),
DimensionFilterJson: strings.TrimSpace(r.DimensionFilterJSON),
TargetValue: r.TargetValue,
TargetUnit: strings.TrimSpace(r.TargetUnit),
RewardCoinAmount: r.RewardCoinAmount,
RewardAssetType: strings.TrimSpace(r.RewardAssetType),
Status: strings.TrimSpace(r.Status),
SortOrder: r.SortOrder,
EffectiveFromMs: r.EffectiveFromMS,
EffectiveToMs: r.EffectiveToMS,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
Meta: meta,
TaskId: taskID,
TaskType: strings.TrimSpace(r.TaskType),
Category: strings.TrimSpace(r.Category),
MetricType: strings.TrimSpace(r.MetricType),
Title: strings.TrimSpace(r.Title),
Description: strings.TrimSpace(r.Description),
AudienceType: strings.TrimSpace(r.AudienceType),
IconKey: strings.TrimSpace(r.IconKey),
IconUrl: strings.TrimSpace(r.IconURL),
ActionType: strings.TrimSpace(r.ActionType),
ActionParam: strings.TrimSpace(r.ActionParam),
ActionPayloadJson: strings.TrimSpace(r.ActionPayloadJSON),
DimensionFilterJson: strings.TrimSpace(r.DimensionFilterJSON),
TargetValue: r.TargetValue,
TargetUnit: strings.TrimSpace(r.TargetUnit),
RewardCoinAmount: r.RewardCoinAmount,
RewardAssetType: strings.TrimSpace(r.RewardAssetType),
RewardType: strings.TrimSpace(r.RewardType),
RewardResourceId: r.RewardResourceID,
RewardResourceGroupId: r.RewardResourceGroupID,
RewardQuantity: r.RewardQuantity,
RewardDurationMs: r.RewardDurationMS,
Status: strings.TrimSpace(r.Status),
SortOrder: r.SortOrder,
EffectiveFromMs: r.EffectiveFromMS,
EffectiveToMs: r.EffectiveToMS,
OperatorAdminId: int64(middleware.CurrentUserID(c)),
}
}
@ -220,35 +247,61 @@ func taskFromProto(item *activityv1.TaskDefinition) taskDTO {
}
// 后台列表返回 activity-service 的完整任务定义,保证编辑弹窗能原样带回维度过滤、图标和跳转配置。
return taskDTO{
TaskID: item.GetTaskId(),
TaskType: item.GetTaskType(),
Category: item.GetCategory(),
MetricType: item.GetMetricType(),
Title: item.GetTitle(),
Description: item.GetDescription(),
AudienceType: item.GetAudienceType(),
IconKey: item.GetIconKey(),
IconURL: item.GetIconUrl(),
ActionType: item.GetActionType(),
ActionParam: item.GetActionParam(),
ActionPayloadJSON: item.GetActionPayloadJson(),
DimensionFilterJSON: item.GetDimensionFilterJson(),
TargetValue: item.GetTargetValue(),
TargetUnit: item.GetTargetUnit(),
RewardCoinAmount: item.GetRewardCoinAmount(),
RewardAssetType: item.GetRewardAssetType(),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Version: item.GetVersion(),
EffectiveFromMS: item.GetEffectiveFromMs(),
EffectiveToMS: item.GetEffectiveToMs(),
CreatedByAdminID: item.GetCreatedByAdminId(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
TaskID: item.GetTaskId(),
TaskType: item.GetTaskType(),
Category: item.GetCategory(),
MetricType: item.GetMetricType(),
Title: item.GetTitle(),
Description: item.GetDescription(),
AudienceType: item.GetAudienceType(),
IconKey: item.GetIconKey(),
IconURL: item.GetIconUrl(),
ActionType: item.GetActionType(),
ActionParam: item.GetActionParam(),
ActionPayloadJSON: item.GetActionPayloadJson(),
DimensionFilterJSON: item.GetDimensionFilterJson(),
TargetValue: item.GetTargetValue(),
TargetUnit: item.GetTargetUnit(),
RewardCoinAmount: item.GetRewardCoinAmount(),
RewardAssetType: item.GetRewardAssetType(),
RewardType: item.GetRewardType(),
RewardResourceID: item.GetRewardResourceId(),
RewardResourceGroupID: item.GetRewardResourceGroupId(),
RewardQuantity: item.GetRewardQuantity(),
RewardDurationMS: item.GetRewardDurationMs(),
RewardItems: taskRewardItemsFromProto(item.GetRewardItems()),
Status: item.GetStatus(),
SortOrder: item.GetSortOrder(),
Version: item.GetVersion(),
EffectiveFromMS: item.GetEffectiveFromMs(),
EffectiveToMS: item.GetEffectiveToMs(),
CreatedByAdminID: item.GetCreatedByAdminId(),
UpdatedByAdminID: item.GetUpdatedByAdminId(),
CreatedAtMS: item.GetCreatedAtMs(),
UpdatedAtMS: item.GetUpdatedAtMs(),
}
}
func taskRewardItemsFromProto(items []*activityv1.TaskRewardItem) []taskRewardItemDTO {
result := make([]taskRewardItemDTO, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
result = append(result, taskRewardItemDTO{
ItemType: item.GetItemType(),
ResourceID: item.GetResourceId(),
ResourceType: item.GetResourceType(),
CoverURL: item.GetCoverUrl(),
Quantity: item.GetQuantity(),
DurationMS: item.GetDurationMs(),
WalletAssetType: item.GetWalletAssetType(),
WalletAssetAmount: item.GetWalletAssetAmount(),
})
}
return result
}
func firstQuery(c *gin.Context, keys ...string) string {
for _, key := range keys {
if value := c.Query(key); value != "" {

View File

@ -91,7 +91,7 @@ func TestRoomRPSConfigRequestDefaultsOpenConfig(t *testing.T) {
if got.GetStatus() != "active" || got.GetChallengeTimeoutMs() != 600000 || got.GetRevealCountdownMs() != 3000 {
t.Fatalf("room rps default config mismatch: %+v", got)
}
if got.GetStakeGifts()[3].GetSortOrder() != 4 {
if got.GetStakeGifts()[0].GetSortOrder() != 4 || got.GetStakeGifts()[3].GetSortOrder() != 1 {
t.Fatalf("default sort order mismatch: %+v", got.GetStakeGifts())
}
}

View File

@ -247,8 +247,8 @@ func (r roomRPSConfigRequest) toProto() (*gamev1.RoomRPSConfig, error) {
seen[giftID] = struct{}{}
sortOrder := gift.SortOrder
if sortOrder <= 0 {
// 未显式给排序时按提交顺序落库,保证前端拖拽顺序和运行时展示顺序一致。
sortOrder = int32(index + 1)
// 未显式给排序时按提交顺序分配递减权重,保证大值优先后仍与前端从上到下的顺序一致。
sortOrder = int32(len(r.StakeGifts) - index)
}
gifts = append(gifts, &gamev1.RoomRPSStakeGift{
GiftId: legacyRoomRPSGiftID(giftID),

View File

@ -328,7 +328,7 @@ func leaderCCCatalogItems(platformCode string, providerItems []leaderCCGameListI
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
SortOrder: defaultCatalogSortOrder(len(providerItems), index),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterLeaderCCV1}, req.Tags...)),
})
}
@ -389,7 +389,7 @@ func zeeoneCatalogItems(platformCode string, config zeeoneSyncConfig, req syncGa
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
SortOrder: defaultCatalogSortOrder(len(keys), index),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterZeeOneV1}, req.Tags...)),
})
}
@ -469,7 +469,7 @@ func vivaGamesCatalogItems(platformCode string, config vivaGamesSyncConfig, req
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
SortOrder: defaultCatalogSortOrder(len(keys), index),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterVivaGamesV1}, req.Tags...)),
})
}
@ -605,7 +605,7 @@ func reyouCatalogItems(platformCode string, config reyouSyncConfig, req syncGame
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
SortOrder: defaultCatalogSortOrder(len(keys), index),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterReyouV1}, req.Tags...)),
})
}
@ -641,7 +641,7 @@ func zgameCatalogItems(platformCode string, config zgameSyncConfig, req syncGame
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
SortOrder: defaultCatalogSortOrder(len(keys), index),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterZGameV1}, req.Tags...)),
})
}
@ -677,7 +677,7 @@ func amgCatalogItems(platformCode string, config amgSyncConfig, req syncGamesReq
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
SortOrder: defaultCatalogSortOrder(len(keys), index),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterAMGV1}, req.Tags...)),
})
}
@ -730,7 +730,7 @@ func yomiCatalogItems(platformCode string, config yomiSyncConfig, req syncGamesR
Orientation: defaultOrientation,
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
SortOrder: defaultCatalogSortOrder(len(keys), index),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterYomiV4}, req.Tags...)),
})
}
@ -986,7 +986,7 @@ func baishunCatalogItems(platformCode string, providerItems []baishunGameListIte
SafeHeight: int32FromJSONValue(providerItem.SafeHeight),
MinCoin: req.MinCoin,
Status: defaulted(req.Status, defaultGameStatus),
SortOrder: int32((index + 1) * 10),
SortOrder: defaultCatalogSortOrder(len(providerItems), index),
Tags: compactTags(append([]string{strings.TrimSpace(platformCode), adapterBaishunV1}, req.Tags...)),
})
}
@ -1175,3 +1175,11 @@ func defaulted(value string, fallback string) string {
}
return strings.TrimSpace(value)
}
func defaultCatalogSortOrder(total int, index int) int32 {
// 厂商列表或稳定 key 列表已经给出默认展示顺序;大值优先后必须从高到低赋权,避免每次同步都把游戏倒序。
if total <= 0 || index < 0 || index >= total {
return 0
}
return int32((total - index) * 10)
}

View File

@ -1444,7 +1444,7 @@ func (r *Reader) ListCoinSellerSalaryRates(ctx context.Context, regionID int64)
SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
FROM coin_seller_salary_exchange_rate_tiers
WHERE app_code = ? AND region_id = ?
ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC
ORDER BY sort_order DESC, min_usd_minor ASC, tier_id ASC
`, appctx.FromContext(ctx), regionID)
if err != nil {
return nil, err
@ -1506,7 +1506,8 @@ func (r *Reader) ReplaceCoinSellerSalaryRates(ctx context.Context, regionID int6
}
sortOrder := tier.SortOrder
if sortOrder == 0 {
sortOrder = (index + 1) * 10
// 请求数组是后台从上到下的档位顺序;缺省排序按递减权重持久化,读回时保持原顺序。
sortOrder = (len(tiers) - index) * 10
}
_, err := tx.ExecContext(ctx, `
INSERT INTO coin_seller_salary_exchange_rate_tiers (

View File

@ -214,7 +214,8 @@ func (s *Service) ReplaceCoinSellerSalaryRates(ctx context.Context, actorID int6
}
sortOrder := item.SortOrder
if sortOrder == 0 {
sortOrder = (index + 1) * 10
// 大值优先后,表单首行必须获得最高缺省权重,避免保存一次就把兑换档位倒置。
sortOrder = (len(req.Tiers) - index) * 10
}
tiers = append(tiers, CoinSellerSalaryRateTier{
RegionID: regionID,

View File

@ -489,7 +489,7 @@ func (h *Handler) listMoneyRegions(ctx context.Context, access repository.MoneyA
ON rc.app_code = r.app_code AND rc.region_id = r.region_id AND rc.status = 'active'
WHERE r.status = 'active' AND `+where+`
GROUP BY r.app_code, r.region_id, r.region_code, r.name, r.status, r.sort_order
ORDER BY r.app_code ASC, r.sort_order ASC, r.name ASC`, args...)
ORDER BY r.app_code ASC, r.sort_order DESC, r.name ASC`, args...)
if err != nil {
return nil, err
}
@ -522,7 +522,7 @@ func (h *Handler) listMoneyCountries(ctx context.Context, access repository.Mone
LEFT JOIN region_countries rc
ON rc.app_code = c.app_code AND rc.country_code = c.country_code AND rc.status = 'active'
WHERE c.enabled = TRUE AND `+where+`
ORDER BY c.app_code ASC, c.sort_order ASC, c.country_name ASC`, args...)
ORDER BY c.app_code ASC, c.sort_order DESC, c.country_name ASC`, args...)
if err != nil {
return nil, err
}

View File

@ -287,8 +287,9 @@ func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfi
RegionCode: regionCode,
Name: regionName,
Status: "active",
SortOrder: index + 1,
Countries: countryCodes,
// 旧文档数组本身是展示顺序;分配递减权重后仍满足全后台“大值靠前”的统一契约。
SortOrder: len(documents) - index,
Countries: countryCodes,
})
for countryIndex, countryCode := range countryCodes {
countries = append(countries, moneyCountryDTO{
@ -298,7 +299,7 @@ func legacyRegionDocumentsToMoneyData(sourceConfig config.MoneyRegionSourceConfi
CountryDisplayName: countryCode,
Enabled: true,
RegionID: regionID,
SortOrder: countryIndex + 1,
SortOrder: len(countryCodes) - countryIndex,
})
}
}

View File

@ -357,7 +357,7 @@ func (h *Handler) listRechargeBillRegions(ctx context.Context, appCode string) (
SELECT region_id, region_code, name
FROM regions
WHERE app_code = ? AND status = 'active'
ORDER BY sort_order ASC, name ASC`, appCode)
ORDER BY sort_order DESC, name ASC`, appCode)
if err != nil {
return nil, err
}

View File

@ -32,7 +32,7 @@ func (s *Service) List(ctx context.Context, appCode string) ([]itemDTO, error) {
status, created_at_ms, updated_at_ms
FROM point_withdrawal_coin_seller_configs
WHERE app_code = ?
ORDER BY sort_order ASC, seller_user_id ASC`, normalizeAppCode(appCode))
ORDER BY sort_order DESC, seller_user_id ASC`, normalizeAppCode(appCode))
if err != nil {
return nil, err
}

View File

@ -404,7 +404,7 @@ func (s *Service) resolveRegionScope(ctx context.Context, appCode string, scope
SELECT region_id
FROM regions
WHERE app_code = ? AND status = 'active'
ORDER BY sort_order ASC, region_id ASC`, appCode)
ORDER BY sort_order DESC, region_id ASC`, appCode)
if err != nil {
return nil, err
}

View File

@ -396,7 +396,7 @@ func resolveCountryCode(ctx context.Context, querier countryCodeQuerier, appCode
OR UPPER(country_code) = ?
OR UPPER(COALESCE(iso_alpha3, '')) = ?
)
ORDER BY enabled DESC, sort_order ASC, country_id ASC
ORDER BY enabled DESC, sort_order DESC, country_id ASC
LIMIT 1
`, appCode, keyword, keyword, upperKeyword, upperKeyword).Scan(&countryCode)
if errors.Is(err, sql.ErrNoRows) {

View File

@ -244,7 +244,8 @@ func (r cycleRequest) toProto() *activityv1.WeeklyStarCycle {
}
} else {
for index, giftID := range r.GiftIDs {
gifts = append(gifts, &activityv1.WeeklyStarGift{GiftId: strings.TrimSpace(giftID), SortOrder: int32(index + 1)})
// 兼容旧 gift_ids 请求:数组首项是页面首项,因此在大值优先语义下分配最高默认权重。
gifts = append(gifts, &activityv1.WeeklyStarGift{GiftId: strings.TrimSpace(giftID), SortOrder: int32(len(r.GiftIDs) - index)})
}
}
rewards := make([]*activityv1.WeeklyStarReward, 0, 3)

View File

@ -38,7 +38,7 @@ func (s *Store) ListAppBanners(options AppBannerListOptions) ([]model.AppBanner,
like := "%" + strings.TrimSpace(options.Keyword) + "%"
query = query.Where("(description LIKE ? OR param LIKE ? OR country_code LIKE ? OR display_scope LIKE ? OR room_small_image_url LIKE ?)", like, like, like, like, like)
}
err := query.Order("sort_order ASC, id DESC").Find(&items).Error
err := query.Order("sort_order DESC, id DESC").Find(&items).Error
return items, err
}

View File

@ -277,7 +277,7 @@ func (s *Store) ListAppExploreTabs(options AppExploreTabListOptions) ([]model.Ap
}
var items []model.AppExploreTab
err := db.Order("sort_order ASC, id DESC").Find(&items).Error
err := db.Order("sort_order DESC, id DESC").Find(&items).Error
return items, err
}

View File

@ -23,7 +23,7 @@ func (s *Store) ListAppPopups(options AppPopupListOptions) ([]model.AppPopup, er
like := "%" + strings.TrimSpace(options.Keyword) + "%"
query = query.Where("(code LIKE ? OR name LIKE ? OR jump_url LIKE ? OR image_url LIKE ?)", like, like, like, like)
}
err := query.Order("sort_order ASC, id DESC").Find(&items).Error
err := query.Order("sort_order DESC, id DESC").Find(&items).Error
return items, err
}

View File

@ -36,7 +36,7 @@ func (s *Store) ListAppSplashScreens(options AppSplashScreenListOptions) ([]mode
// 关键字只查人工可识别的字段,避免把封面 URL 这类长字段带进模糊查询拖慢列表。
query = query.Where("(description LIKE ? OR param LIKE ? OR country_code LIKE ?)", like, like, like)
}
err := query.Order("sort_order ASC, id DESC").Find(&items).Error
err := query.Order("sort_order DESC, id DESC").Find(&items).Error
return items, err
}

View File

@ -10,7 +10,7 @@ import (
func (s *Store) MenusForPermissions(permissionCodes []string) ([]model.Menu, error) {
var menus []model.Menu
query := s.db.Where("visible = ?", true).Order("sort ASC, id ASC")
query := s.db.Where("visible = ?", true).Order("sort DESC, id ASC")
if len(permissionCodes) > 0 {
query = query.Where("permission_code = '' OR permission_code IN ?", permissionCodes)
} else {
@ -23,7 +23,7 @@ func (s *Store) MenusForPermissions(permissionCodes []string) ([]model.Menu, err
func (s *Store) ListMenus() ([]model.Menu, error) {
var menus []model.Menu
err := s.db.Order("sort ASC, id ASC").Find(&menus).Error
err := s.db.Order("sort DESC, id ASC").Find(&menus).Error
return menus, err
}

View File

@ -23,10 +23,10 @@ func (s *Store) ListTeams(keyword string) ([]model.Team, error) {
var teams []model.Team
err := query.
Group("admin_teams.id, admin_teams.parent_id, admin_teams.name, admin_teams.description, admin_teams.sort, admin_teams.created_at_ms, admin_teams.updated_at_ms, parent_team.name").
Order("CASE WHEN admin_teams.parent_id IS NULL THEN admin_teams.sort ELSE COALESCE(parent_team.sort, admin_teams.sort) END ASC").
Order("CASE WHEN admin_teams.parent_id IS NULL THEN admin_teams.sort ELSE COALESCE(parent_team.sort, admin_teams.sort) END DESC").
Order("COALESCE(admin_teams.parent_id, admin_teams.id) ASC").
Order("CASE WHEN admin_teams.parent_id IS NULL THEN 0 ELSE 1 END ASC").
Order("admin_teams.sort ASC, admin_teams.id ASC").
Order("admin_teams.sort DESC, admin_teams.id ASC").
Scan(&teams).Error
return teams, err
}
@ -96,7 +96,7 @@ func (s *Store) TeamAndChildIDs(id uint) ([]uint, error) {
}
ids := []uint{id}
var childIDs []uint
if err := s.db.Model(&model.Team{}).Where("parent_id = ?", id).Order("sort ASC, id ASC").Pluck("id", &childIDs).Error; err != nil {
if err := s.db.Model(&model.Team{}).Where("parent_id = ?", id).Order("sort DESC, id ASC").Pluck("id", &childIDs).Error; err != nil {
return nil, err
}
ids = append(ids, childIDs...)

View File

@ -53,7 +53,7 @@ func TestTeamAndChildIDsReturnsParentAndChildren(t *testing.T) {
store, mock, closeStore := newRepositorySQLMock(t)
defer closeStore()
mock.ExpectQuery("SELECT `id` FROM `admin_teams` WHERE parent_id = \\? ORDER BY sort ASC, id ASC").
mock.ExpectQuery("SELECT `id` FROM `admin_teams` WHERE parent_id = \\? ORDER BY sort DESC, id ASC").
WithArgs(model.TeamOperationsID).
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(11).AddRow(12))

View File

@ -225,8 +225,14 @@ CREATE TABLE IF NOT EXISTS task_definitions (
target_unit VARCHAR(32) NOT NULL COMMENT '目标单位',
reward_coin_amount BIGINT NOT NULL COMMENT '奖励金币数量',
reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '奖励资产类型COIN/POINT',
reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型wallet_asset/resource/resource_group',
reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID',
reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID',
reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量',
reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期毫秒0 为永久',
reward_items_json JSON NULL COMMENT '钱包资源目录解析出的奖励展示快照',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越小越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
version BIGINT NOT NULL COMMENT '版本号',
current_task_version_id BIGINT NOT NULL DEFAULT 0 COMMENT '当前任务版本 ID',
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间UTC epoch ms',
@ -262,6 +268,12 @@ CREATE TABLE IF NOT EXISTS user_task_progress (
target_value BIGINT NOT NULL COMMENT '目标值',
reward_coin_amount BIGINT NOT NULL COMMENT '奖励金币数量',
reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '奖励资产类型快照COIN/POINT',
reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型快照',
reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID 快照',
reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID 快照',
reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量快照',
reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期快照',
reward_items_json JSON NULL COMMENT '奖励展示快照',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
completed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '完成时间UTC epoch ms',
claimed_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '领取时间UTC epoch ms',
@ -300,8 +312,15 @@ CREATE TABLE IF NOT EXISTS task_reward_claims (
cycle_key VARCHAR(32) NOT NULL COMMENT '周期键',
reward_coin_amount BIGINT NOT NULL COMMENT '奖励金币数量',
reward_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '奖励资产类型快照COIN/POINT',
reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型快照',
reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID 快照',
reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID 快照',
reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量快照',
reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期快照',
reward_items_json JSON NULL COMMENT '奖励展示快照',
wallet_command_id VARCHAR(128) NOT NULL COMMENT '钱包命令 ID',
wallet_transaction_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包交易 ID',
wallet_resource_grant_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包资源赠送 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
failure_reason VARCHAR(255) NOT NULL DEFAULT '' COMMENT '失败原因',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -381,7 +400,7 @@ CREATE TABLE IF NOT EXISTS first_recharge_reward_tiers (
discount_percent INT NOT NULL DEFAULT 0 COMMENT '展示优惠百分比,不参与结算',
resource_group_id BIGINT NOT NULL COMMENT '奖励资源分组 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_first_recharge_reward_tier_code (app_code, tier_code),
@ -438,7 +457,7 @@ CREATE TABLE IF NOT EXISTS cumulative_recharge_reward_tiers (
threshold_usd_minor BIGINT NOT NULL COMMENT '达标美元金额,美分',
resource_group_id BIGINT NOT NULL COMMENT '奖励资源分组 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_cumulative_recharge_reward_tier_code (app_code, tier_code),
@ -583,7 +602,7 @@ CREATE TABLE IF NOT EXISTS growth_level_tracks (
track VARCHAR(32) NOT NULL COMMENT '赛道',
name VARCHAR(96) NOT NULL COMMENT '名称',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
display_config_json JSON NULL COMMENT '展示配置 JSON 配置或快照',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -599,7 +618,7 @@ CREATE TABLE IF NOT EXISTS growth_level_rules (
name VARCHAR(96) NOT NULL COMMENT '名称',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '奖励资源分组 ID',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
display_config_json JSON NULL COMMENT '展示配置 JSON 配置或快照',
created_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建管理员 ID',
updated_by_admin_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新管理员 ID',

View File

@ -0,0 +1,34 @@
SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci;
-- 三张任务热表只在末尾追加定长字段和一个可空 JSONMySQL 8 可使用 INSTANT/LOCK=NONE
-- 避免迁移期间复制整表或阻塞任务事件、列表和领奖写入。
ALTER TABLE task_definitions
ADD COLUMN reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型wallet_asset/resource/resource_group',
ADD COLUMN reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID',
ADD COLUMN reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID',
ADD COLUMN reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量',
ADD COLUMN reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期毫秒0 为永久',
ADD COLUMN reward_items_json JSON NULL COMMENT '钱包资源目录解析出的奖励展示快照',
ALGORITHM=INSTANT,
LOCK=NONE;
ALTER TABLE user_task_progress
ADD COLUMN reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型快照',
ADD COLUMN reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID 快照',
ADD COLUMN reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID 快照',
ADD COLUMN reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量快照',
ADD COLUMN reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期快照',
ADD COLUMN reward_items_json JSON NULL COMMENT '奖励展示快照',
ALGORITHM=INSTANT,
LOCK=NONE;
ALTER TABLE task_reward_claims
ADD COLUMN reward_type VARCHAR(32) NOT NULL DEFAULT 'wallet_asset' COMMENT '奖励类型快照',
ADD COLUMN reward_resource_id BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励 ID 快照',
ADD COLUMN reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '资源组奖励 ID 快照',
ADD COLUMN reward_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励数量快照',
ADD COLUMN reward_duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '单资源奖励有效期快照',
ADD COLUMN reward_items_json JSON NULL COMMENT '奖励展示快照',
ADD COLUMN wallet_resource_grant_id VARCHAR(96) NOT NULL DEFAULT '' COMMENT '钱包资源赠送 ID',
ALGORITHM=INSTANT,
LOCK=NONE;

View File

@ -55,55 +55,86 @@ const (
RewardAssetCoin = "COIN"
RewardAssetPoint = "POINT"
RewardTypeWalletAsset = "wallet_asset"
RewardTypeResource = "resource"
RewardTypeResourceGroup = "resource_group"
RewardItemTypeResource = "resource"
RewardItemTypeWalletAsset = "wallet_asset"
)
// RewardItem 是任务定义在保存时固化的奖励展示快照H5 只依赖封面和数量,不从名称反推资源语义。
type RewardItem struct {
ItemType string `json:"item_type"`
ResourceID int64 `json:"resource_id,omitempty"`
ResourceType string `json:"resource_type,omitempty"`
CoverURL string `json:"cover_url,omitempty"`
Quantity int64 `json:"quantity,omitempty"`
DurationMS int64 `json:"duration_ms,omitempty"`
WalletAssetType string `json:"wallet_asset_type,omitempty"`
WalletAssetAmount int64 `json:"wallet_asset_amount,omitempty"`
}
// Definition 是 task_definitions 的当前版本读模型App 查询只读这张表和用户进度表。
type Definition struct {
AppCode string
TaskID string
TaskType string
Category string
MetricType string
Title string
Description string
AudienceType string
IconKey string
IconURL string
ActionType string
ActionParam string
ActionPayloadJSON string
DimensionFilterJSON string
TargetValue int64
TargetUnit string
RewardCoinAmount int64
RewardAssetType string
Status string
SortOrder int32
Version int64
CurrentVersionID int64
EffectiveFromMS int64
EffectiveToMS int64
CreatedByAdminID int64
UpdatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
AppCode string
TaskID string
TaskType string
Category string
MetricType string
Title string
Description string
AudienceType string
IconKey string
IconURL string
ActionType string
ActionParam string
ActionPayloadJSON string
DimensionFilterJSON string
TargetValue int64
TargetUnit string
RewardType string
RewardCoinAmount int64
RewardAssetType string
RewardResourceID int64
RewardResourceGroupID int64
RewardQuantity int64
RewardDurationMS int64
RewardItemsJSON string
Status string
SortOrder int32
Version int64
CurrentVersionID int64
EffectiveFromMS int64
EffectiveToMS int64
CreatedByAdminID int64
UpdatedByAdminID int64
CreatedAtMS int64
UpdatedAtMS int64
}
// Progress 是 user_task_progress 的用户维度状态target/reward 使用创建进度时的任务快照。
type Progress struct {
AppCode string
UserID int64
TaskID string
TaskVersionID int64
CycleKey string
ProgressValue int64
TargetValue int64
RewardCoinAmount int64
RewardAssetType string
Status string
CompletedAtMS int64
ClaimedAtMS int64
UpdatedAtMS int64
AppCode string
UserID int64
TaskID string
TaskVersionID int64
CycleKey string
ProgressValue int64
TargetValue int64
RewardType string
RewardCoinAmount int64
RewardAssetType string
RewardResourceID int64
RewardResourceGroupID int64
RewardQuantity int64
RewardDurationMS int64
RewardItemsJSON string
Status string
CompletedAtMS int64
ClaimedAtMS int64
UpdatedAtMS int64
}
// Item 是 App 任务列表返回前的领域快照。
@ -144,28 +175,34 @@ type DefinitionQuery struct {
// DefinitionCommand 是后台创建/编辑任务定义的命令快照。
type DefinitionCommand struct {
TaskID string
TaskType string
Category string
MetricType string
Title string
Description string
AudienceType string
IconKey string
IconURL string
ActionType string
ActionParam string
ActionPayloadJSON string
DimensionFilterJSON string
TargetValue int64
TargetUnit string
RewardCoinAmount int64
RewardAssetType string
Status string
SortOrder int32
EffectiveFromMS int64
EffectiveToMS int64
OperatorAdminID int64
TaskID string
TaskType string
Category string
MetricType string
Title string
Description string
AudienceType string
IconKey string
IconURL string
ActionType string
ActionParam string
ActionPayloadJSON string
DimensionFilterJSON string
TargetValue int64
TargetUnit string
RewardType string
RewardCoinAmount int64
RewardAssetType string
RewardResourceID int64
RewardResourceGroupID int64
RewardQuantity int64
RewardDurationMS int64
RewardItemsJSON string
Status string
SortOrder int32
EffectiveFromMS int64
EffectiveToMS int64
OperatorAdminID int64
}
// Event 是由 wallet/user/room outbox 派生的任务进度事实。
@ -198,18 +235,25 @@ type ClaimCommand struct {
// Claim 是 task_reward_claims 的领奖事实。
type Claim struct {
ClaimID string
CommandID string
UserID int64
TaskID string
TaskType string
CycleKey string
RewardCoinAmount int64
RewardAssetType string
WalletCommandID string
WalletTransactionID string
Status string
FailureReason string
CreatedAtMS int64
UpdatedAtMS int64
ClaimID string
CommandID string
UserID int64
TaskID string
TaskType string
CycleKey string
RewardType string
RewardCoinAmount int64
RewardAssetType string
RewardResourceID int64
RewardResourceGroupID int64
RewardQuantity int64
RewardDurationMS int64
RewardItemsJSON string
WalletCommandID string
WalletTransactionID string
WalletResourceGrantID string
Status string
FailureReason string
CreatedAtMS int64
UpdatedAtMS int64
}

View File

@ -656,7 +656,7 @@ func normalizeTemplate(item domain.Template) domain.Template {
if item.Gifts[i].SortOrder == item.Gifts[j].SortOrder {
return item.Gifts[i].GiftID < item.Gifts[j].GiftID
}
return item.Gifts[i].SortOrder < item.Gifts[j].SortOrder
return item.Gifts[i].SortOrder > item.Gifts[j].SortOrder
})
for index := range item.Tasks {
item.Tasks[index].TaskKey = strings.ToLower(strings.TrimSpace(item.Tasks[index].TaskKey))
@ -666,7 +666,7 @@ func normalizeTemplate(item domain.Template) domain.Template {
if item.Tasks[i].SortOrder == item.Tasks[j].SortOrder {
return item.Tasks[i].TaskKey < item.Tasks[j].TaskKey
}
return item.Tasks[i].SortOrder < item.Tasks[j].SortOrder
return item.Tasks[i].SortOrder > item.Tasks[j].SortOrder
})
for index := range item.RankRewards {
item.RankRewards[index].BoardType = strings.ToLower(strings.TrimSpace(item.RankRewards[index].BoardType))
@ -696,7 +696,7 @@ func normalizeTemplate(item domain.Template) domain.Template {
if item.Assets[i].Locale != item.Assets[j].Locale {
return item.Assets[i].Locale < item.Assets[j].Locale
}
return item.Assets[i].SortOrder < item.Assets[j].SortOrder
return item.Assets[i].SortOrder > item.Assets[j].SortOrder
})
return item
}

View File

@ -249,7 +249,7 @@ func normalizeTiers(tiers []domain.Tier) []domain.Tier {
if items[i].SortOrder == items[j].SortOrder {
return items[i].ThresholdUSDMinor < items[j].ThresholdUSDMinor
}
return items[i].SortOrder < items[j].SortOrder
return items[i].SortOrder > items[j].SortOrder
})
return items
}
@ -350,7 +350,7 @@ func activeTiers(tiers []domain.Tier) []domain.Tier {
if items[i].SortOrder == items[j].SortOrder {
return items[i].ThresholdUSDMinor < items[j].ThresholdUSDMinor
}
return items[i].SortOrder < items[j].SortOrder
return items[i].SortOrder > items[j].SortOrder
})
return items
}

View File

@ -219,7 +219,7 @@ func normalizeTiers(tiers []domain.Tier) []domain.Tier {
if items[i].SortOrder == items[j].SortOrder {
return items[i].USDMinorAmount < items[j].USDMinorAmount
}
return items[i].SortOrder < items[j].SortOrder
return items[i].SortOrder > items[j].SortOrder
})
return items
}

View File

@ -364,7 +364,7 @@ func normalizeTiers(tiers []domain.Tier) []domain.Tier {
if items[i].SortOrder == items[j].SortOrder {
return thresholdValue(items[i]) < thresholdValue(items[j])
}
return items[i].SortOrder < items[j].SortOrder
return items[i].SortOrder > items[j].SortOrder
}
return items[i].RewardType < items[j].RewardType
})

View File

@ -525,7 +525,7 @@ func sortTiersForDisplay(tiers []domain.Tier) []domain.Tier {
if items[i].SortOrder == items[j].SortOrder {
return items[i].ThresholdCoinSpent < items[j].ThresholdCoinSpent
}
return items[i].SortOrder < items[j].SortOrder
return items[i].SortOrder > items[j].SortOrder
})
return items
}

View File

@ -25,7 +25,7 @@ type Repository interface {
UpsertTaskDefinition(ctx context.Context, command taskdomain.DefinitionCommand, nowMS int64) (taskdomain.Definition, bool, error)
SetTaskDefinitionStatus(ctx context.Context, taskID string, status string, operatorAdminID int64, nowMS int64) (taskdomain.Definition, error)
PrepareTaskClaim(ctx context.Context, command taskdomain.ClaimCommand, nowMS int64) (taskdomain.Claim, error)
MarkTaskClaimGranted(ctx context.Context, claimID string, walletTransactionID string, grantedAtMS int64) (taskdomain.Claim, error)
MarkTaskClaimGranted(ctx context.Context, claimID string, walletTransactionID string, walletResourceGrantID string, grantedAtMS int64) (taskdomain.Claim, error)
MarkTaskClaimFailed(ctx context.Context, claimID string, failureReason string, nowMS int64) error
}
@ -34,16 +34,29 @@ type WalletClient interface {
CreditTaskReward(ctx context.Context, req *walletv1.CreditTaskRewardRequest, opts ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error)
}
// ResourceWalletClient 单独声明资源目录和赠送能力,保留只测试金币链路的轻量 fake。
// 生产 wallet gRPC client 同时实现两个接口;资源任务在缺少该能力时明确失败,不能退化为金币入账。
type ResourceWalletClient interface {
GetResource(ctx context.Context, req *walletv1.GetResourceRequest, opts ...grpc.CallOption) (*walletv1.GetResourceResponse, error)
GetResourceGroup(ctx context.Context, req *walletv1.GetResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error)
GrantResource(ctx context.Context, req *walletv1.GrantResourceRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
GrantResourceGroup(ctx context.Context, req *walletv1.GrantResourceGroupRequest, opts ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error)
}
// Service 承载任务查询、事件消费、领奖和后台配置用例。
type Service struct {
repository Repository
wallet WalletClient
now func() time.Time
repository Repository
wallet WalletClient
resourceWallet ResourceWalletClient
now func() time.Time
}
// New 创建任务服务daily 周期固定按 UTC 自然日切分,不读取机器或客户端时区。
func New(repository Repository, wallet WalletClient) *Service {
return &Service{repository: repository, wallet: wallet, now: time.Now}
service := &Service{repository: repository, wallet: wallet, now: time.Now}
// 生成的 WalletServiceClient 具备完整资源能力;拆分类型断言只为让纯资产测试和降级配置保持最小依赖。
service.resourceWallet, _ = wallet.(ResourceWalletClient)
return service
}
// SetClock 给测试注入稳定时间;生产路径始终使用 time.Now。
@ -135,35 +148,94 @@ func (s *Service) ClaimTaskReward(ctx context.Context, command taskdomain.ClaimC
if claim.Status == taskdomain.ClaimStatusGranted {
return claim, nil
}
if s.wallet == nil {
return taskdomain.Claim{}, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
reason := "daily_task_reward"
if command.TaskType == taskdomain.TypeExclusive {
reason = "exclusive_task_reward"
}
resp, err := s.wallet.CreditTaskReward(ctx, &walletv1.CreditTaskRewardRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
Amount: claim.RewardCoinAmount,
RewardAssetType: claim.RewardAssetType,
TaskType: claim.TaskType,
TaskId: claim.TaskID,
CycleKey: claim.CycleKey,
Reason: reason,
})
walletTransactionID, walletResourceGrantID, grantedAtMS, err := s.grantClaimReward(ctx, claim, reason)
if err != nil {
// 钱包失败时不能把任务进度改成 claimed同一 command_id 重试会复用 claim 和 wallet_command_id。
// 钱包资产或资源发放失败时都不能把任务进度改成 claimed同一 command_id 重试会复用 claim 和 wallet_command_id。
_ = s.repository.MarkTaskClaimFailed(ctx, claim.ClaimID, xerr.MessageOf(err), s.now().UnixMilli())
return taskdomain.Claim{}, err
}
grantedAtMS := resp.GetGrantedAtMs()
if grantedAtMS <= 0 {
grantedAtMS = s.now().UnixMilli()
}
return s.repository.MarkTaskClaimGranted(ctx, claim.ClaimID, resp.GetTransactionId(), grantedAtMS)
return s.repository.MarkTaskClaimGranted(ctx, claim.ClaimID, walletTransactionID, walletResourceGrantID, grantedAtMS)
}
func (s *Service) grantClaimReward(ctx context.Context, claim taskdomain.Claim, reason string) (string, string, int64, error) {
switch normalizeRewardType(claim.RewardType) {
case taskdomain.RewardTypeWalletAsset:
if s.wallet == nil {
return "", "", 0, xerr.New(xerr.Unavailable, "wallet client is not configured")
}
resp, err := s.wallet.CreditTaskReward(ctx, &walletv1.CreditTaskRewardRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
Amount: claim.RewardCoinAmount,
RewardAssetType: claim.RewardAssetType,
TaskType: claim.TaskType,
TaskId: claim.TaskID,
CycleKey: claim.CycleKey,
Reason: reason,
})
if err != nil {
return "", "", 0, err
}
return resp.GetTransactionId(), "", resp.GetGrantedAtMs(), nil
case taskdomain.RewardTypeResource:
if s.resourceWallet == nil {
return "", "", 0, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
}
resp, err := s.resourceWallet.GrantResource(ctx, &walletv1.GrantResourceRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
ResourceId: claim.RewardResourceID,
Quantity: claim.RewardQuantity,
DurationMs: claim.RewardDurationMS,
Reason: reason,
OperatorUserId: claim.UserID,
GrantSource: "daily_task",
})
if err != nil {
return "", "", 0, err
}
return resourceGrantReceipt(resp)
case taskdomain.RewardTypeResourceGroup:
if s.resourceWallet == nil {
return "", "", 0, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
}
resp, err := s.resourceWallet.GrantResourceGroup(ctx, &walletv1.GrantResourceGroupRequest{
CommandId: claim.WalletCommandID,
AppCode: appcode.FromContext(ctx),
TargetUserId: claim.UserID,
GroupId: claim.RewardResourceGroupID,
Reason: reason,
OperatorUserId: claim.UserID,
GrantSource: "daily_task",
})
if err != nil {
return "", "", 0, err
}
return resourceGrantReceipt(resp)
default:
return "", "", 0, xerr.New(xerr.InvalidArgument, "task reward_type is invalid")
}
}
func resourceGrantReceipt(resp *walletv1.ResourceGrantResponse) (string, string, int64, error) {
if resp == nil || resp.GetGrant() == nil || strings.TrimSpace(resp.GetGrant().GetGrantId()) == "" {
return "", "", 0, xerr.New(xerr.Unavailable, "wallet resource grant receipt is empty")
}
grant := resp.GetGrant()
grantedAtMS := grant.GetUpdatedAtMs()
if grantedAtMS <= 0 {
grantedAtMS = grant.GetCreatedAtMs()
}
return "", grant.GetGrantId(), grantedAtMS, nil
}
// ConsumeTaskEvent 把服务端事实事件归属到 occurred_at_ms 所在的任务周期。
@ -312,14 +384,15 @@ func (s *Service) UpsertTaskDefinition(ctx context.Context, command taskdomain.D
if err := s.requireRepository(); err != nil {
return taskdomain.Definition{}, false, err
}
// 奖励资产属于任务定义本身;必须由调用方显式携带,避免旧客户端编辑 POINT 任务时用空字段静默覆盖存量资产。
if strings.TrimSpace(command.RewardAssetType) == "" {
return taskdomain.Definition{}, false, xerr.New(xerr.InvalidArgument, "reward_asset_type is required")
}
command = normalizeDefinitionCommand(command)
if err := validateDefinitionCommand(ctx, command); err != nil {
return taskdomain.Definition{}, false, err
}
var err error
command, err = s.resolveRewardItems(ctx, command)
if err != nil {
return taskdomain.Definition{}, false, err
}
return s.repository.UpsertTaskDefinition(ctx, command, s.now().UnixMilli())
}
@ -350,13 +423,22 @@ func (s *Service) itemFromDefinition(def taskdomain.Definition, progress taskdom
status = progress.Status
progressValue = progress.ProgressValue
def.TargetValue = progress.TargetValue
def.RewardType = normalizeRewardType(progress.RewardType)
def.RewardCoinAmount = progress.RewardCoinAmount
def.RewardAssetType = normalizeRewardAssetType(progress.RewardAssetType)
def.RewardResourceID = progress.RewardResourceID
def.RewardResourceGroupID = progress.RewardResourceGroupID
def.RewardQuantity = progress.RewardQuantity
def.RewardDurationMS = progress.RewardDurationMS
def.RewardItemsJSON = progress.RewardItemsJSON
}
if status == "" {
status = taskdomain.StatusInProgress
}
def.RewardAssetType = normalizeRewardAssetType(def.RewardAssetType)
def.RewardType = normalizeRewardType(def.RewardType)
if def.RewardType == taskdomain.RewardTypeWalletAsset {
def.RewardAssetType = normalizeRewardAssetType(def.RewardAssetType)
}
return taskdomain.Item{
Definition: def,
ProgressValue: progressValue,
@ -392,7 +474,7 @@ func sortTaskItems(items []taskdomain.Item) {
return leftRank < rightRank
}
if items[i].SortOrder != items[j].SortOrder {
return items[i].SortOrder < items[j].SortOrder
return items[i].SortOrder > items[j].SortOrder
}
return items[i].TaskID < items[j].TaskID
})
@ -429,6 +511,7 @@ func normalizeDefinitionCommand(command taskdomain.DefinitionCommand) taskdomain
command.ActionPayloadJSON = normalizeJSONForCommand(command.ActionPayloadJSON)
command.DimensionFilterJSON = normalizeJSONForCommand(command.DimensionFilterJSON)
command.TargetUnit = strings.ToLower(strings.TrimSpace(command.TargetUnit))
command.RewardType = normalizeRewardType(command.RewardType)
command.RewardAssetType = normalizeRewardAssetType(command.RewardAssetType)
command.Status = strings.TrimSpace(command.Status)
if command.Status == "" {
@ -470,11 +553,28 @@ func validateDefinitionCommand(ctx context.Context, command taskdomain.Definitio
if _, err := normalizeObjectJSON(command.DimensionFilterJSON); err != nil {
return xerr.New(xerr.InvalidArgument, "dimension_filter_json is invalid")
}
if command.TargetValue <= 0 || command.RewardCoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "target_value and reward_coin_amount must be positive")
if command.TargetValue <= 0 {
return xerr.New(xerr.InvalidArgument, "target_value must be positive")
}
if !validRewardAssetType(command.RewardAssetType) {
return xerr.New(xerr.InvalidArgument, "reward_asset_type is invalid")
switch command.RewardType {
case taskdomain.RewardTypeWalletAsset:
// 旧 Admin 请求没有 reward_type 时归一为 wallet_asset这条分支仍要求显式资产避免编辑 POINT 任务时空值覆盖。
if strings.TrimSpace(command.RewardAssetType) == "" || command.RewardCoinAmount <= 0 {
return xerr.New(xerr.InvalidArgument, "reward_asset_type and reward_coin_amount are required")
}
if !validRewardAssetType(command.RewardAssetType) {
return xerr.New(xerr.InvalidArgument, "reward_asset_type is invalid")
}
case taskdomain.RewardTypeResource:
if command.RewardResourceID <= 0 || command.RewardQuantity <= 0 || command.RewardDurationMS < 0 {
return xerr.New(xerr.InvalidArgument, "resource reward is incomplete")
}
case taskdomain.RewardTypeResourceGroup:
if command.RewardResourceGroupID <= 0 {
return xerr.New(xerr.InvalidArgument, "reward_resource_group_id is required")
}
default:
return xerr.New(xerr.InvalidArgument, "reward_type is invalid")
}
if !validTargetUnit(command.TargetUnit) {
return xerr.New(xerr.InvalidArgument, "target_unit is invalid")
@ -493,8 +593,14 @@ func validateDefinitionCommand(ctx context.Context, command taskdomain.Definitio
func normalizeRewardAssetType(value string) string {
value = strings.ToUpper(strings.TrimSpace(value))
return value
}
func normalizeRewardType(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
if value == "" {
return taskdomain.RewardAssetCoin
// 没有 reward_type 的历史请求和历史数据都属于原有 COIN/POINT 钱包资产奖励。
return taskdomain.RewardTypeWalletAsset
}
return value
}
@ -509,6 +615,130 @@ func validRewardAssetType(value string) bool {
}
}
func (s *Service) resolveRewardItems(ctx context.Context, command taskdomain.DefinitionCommand) (taskdomain.DefinitionCommand, error) {
switch command.RewardType {
case taskdomain.RewardTypeWalletAsset:
// 非当前奖励类型字段必须清空,避免任务从资源切回金币后旧 ID 继续出现在 Admin/App 响应中。
command.RewardResourceID = 0
command.RewardResourceGroupID = 0
command.RewardQuantity = 0
command.RewardDurationMS = 0
return withRewardItems(command, []taskdomain.RewardItem{{
ItemType: taskdomain.RewardItemTypeWalletAsset,
WalletAssetType: command.RewardAssetType,
WalletAssetAmount: command.RewardCoinAmount,
}})
case taskdomain.RewardTypeResource:
if s.resourceWallet == nil {
return taskdomain.DefinitionCommand{}, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
}
resp, err := s.resourceWallet.GetResource(ctx, &walletv1.GetResourceRequest{
RequestId: rewardCatalogRequestID(command),
AppCode: appcode.FromContext(ctx),
ResourceId: command.RewardResourceID,
})
if err != nil {
return taskdomain.DefinitionCommand{}, err
}
item, err := rewardItemFromResource(resp.GetResource(), command.RewardQuantity, command.RewardDurationMS)
if err != nil {
return taskdomain.DefinitionCommand{}, err
}
command.RewardCoinAmount = 0
command.RewardAssetType = ""
command.RewardResourceGroupID = 0
return withRewardItems(command, []taskdomain.RewardItem{item})
case taskdomain.RewardTypeResourceGroup:
if s.resourceWallet == nil {
return taskdomain.DefinitionCommand{}, xerr.New(xerr.Unavailable, "wallet resource client is not configured")
}
resp, err := s.resourceWallet.GetResourceGroup(ctx, &walletv1.GetResourceGroupRequest{
RequestId: rewardCatalogRequestID(command),
AppCode: appcode.FromContext(ctx),
GroupId: command.RewardResourceGroupID,
})
if err != nil {
return taskdomain.DefinitionCommand{}, err
}
group := resp.GetGroup()
if group == nil || group.GetGroupId() != command.RewardResourceGroupID || strings.ToLower(strings.TrimSpace(group.GetStatus())) != "active" {
return taskdomain.DefinitionCommand{}, xerr.New(xerr.Conflict, "reward resource group is unavailable")
}
if len(group.GetItems()) == 0 {
return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward resource group has no items")
}
items := make([]taskdomain.RewardItem, 0, len(group.GetItems()))
for _, groupItem := range group.GetItems() {
if strings.ToLower(strings.TrimSpace(groupItem.GetItemType())) == taskdomain.RewardItemTypeWalletAsset {
if groupItem.GetWalletAssetType() == "" || groupItem.GetWalletAssetAmount() <= 0 {
return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward resource group contains an invalid wallet asset")
}
items = append(items, taskdomain.RewardItem{
ItemType: taskdomain.RewardItemTypeWalletAsset,
WalletAssetType: strings.ToUpper(strings.TrimSpace(groupItem.GetWalletAssetType())),
WalletAssetAmount: groupItem.GetWalletAssetAmount(),
})
continue
}
item, itemErr := rewardItemFromResource(groupItem.GetResource(), groupItem.GetQuantity(), groupItem.GetDurationMs())
if itemErr != nil {
return taskdomain.DefinitionCommand{}, itemErr
}
items = append(items, item)
}
command.RewardCoinAmount = 0
command.RewardAssetType = ""
command.RewardResourceID = 0
command.RewardQuantity = 0
command.RewardDurationMS = 0
return withRewardItems(command, items)
default:
return taskdomain.DefinitionCommand{}, xerr.New(xerr.InvalidArgument, "reward_type is invalid")
}
}
func rewardItemFromResource(resource *walletv1.Resource, quantity int64, durationMS int64) (taskdomain.RewardItem, error) {
if resource == nil || resource.GetResourceId() <= 0 || strings.ToLower(strings.TrimSpace(resource.GetStatus())) != "active" || !resource.GetGrantable() {
return taskdomain.RewardItem{}, xerr.New(xerr.Conflict, "reward resource is unavailable")
}
coverURL := strings.TrimSpace(resource.GetPreviewUrl())
if coverURL == "" {
coverURL = strings.TrimSpace(resource.GetAssetUrl())
}
if coverURL == "" {
// H5 的奖励区明确只展示封面,不展示名称;没有可渲染封面的资源不能被保存为任务奖励。
return taskdomain.RewardItem{}, xerr.New(xerr.InvalidArgument, "reward resource cover is required")
}
if quantity <= 0 || durationMS < 0 {
return taskdomain.RewardItem{}, xerr.New(xerr.InvalidArgument, "reward resource quantity or duration is invalid")
}
return taskdomain.RewardItem{
ItemType: taskdomain.RewardItemTypeResource,
ResourceID: resource.GetResourceId(),
ResourceType: strings.ToLower(strings.TrimSpace(resource.GetResourceType())),
CoverURL: coverURL,
Quantity: quantity,
DurationMS: durationMS,
}, nil
}
func withRewardItems(command taskdomain.DefinitionCommand, items []taskdomain.RewardItem) (taskdomain.DefinitionCommand, error) {
payload, err := json.Marshal(items)
if err != nil {
return taskdomain.DefinitionCommand{}, err
}
command.RewardItemsJSON = string(payload)
return command, nil
}
func rewardCatalogRequestID(command taskdomain.DefinitionCommand) string {
taskID := strings.TrimSpace(command.TaskID)
if taskID == "" {
taskID = "new"
}
return "task-reward-config-" + taskID
}
func roomGiftTaskDimensions(envelope *roomeventsv1.EventEnvelope, gift *roomeventsv1.RoomGiftSent) (string, error) {
// 维度只记录任务匹配和排障需要的事实字段;后台 dimension_filter_json 可以按 gift_id、pool_id、room_id 等字段精确限定任务。
payload := map[string]any{

View File

@ -2,6 +2,7 @@ package task_test
import (
"context"
"encoding/json"
"testing"
"time"
@ -405,6 +406,161 @@ func TestExplicitPointTaskDefinitionDoesNotDependOnApplicationName(t *testing.T)
}
}
func TestResourceGroupTaskDisplaysEveryCoverAndGrantsGroup(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeWalletClient{
group: &walletv1.ResourceGroup{
GroupId: 9901,
Status: "active",
Items: []*walletv1.ResourceGroupItem{
{
ItemType: "resource",
ResourceId: 8801,
Quantity: 1,
Resource: &walletv1.Resource{
ResourceId: 8801,
ResourceType: "avatar_frame",
Status: "active",
Grantable: true,
PreviewUrl: "https://cdn.example.com/rewards/frame.webp",
},
},
{
ItemType: "resource",
ResourceId: 8802,
Quantity: 2,
Resource: &walletv1.Resource{
ResourceId: 8802,
ResourceType: "badge",
Status: "active",
Grantable: true,
AssetUrl: "https://cdn.example.com/rewards/badge.webp",
},
},
{
ItemType: "wallet_asset",
WalletAssetType: "COIN",
WalletAssetAmount: 50,
},
},
},
}
svc := taskservice.New(repository, wallet)
now := time.Date(2026, 5, 9, 10, 0, 0, 0, time.UTC)
svc.SetClock(func() time.Time { return now })
definition, created, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{
TaskType: taskdomain.TypeDaily,
Category: "gift",
MetricType: taskdomain.MetricGiftSendCount,
Title: "send gift for resource group",
TargetValue: 1,
TargetUnit: "count",
RewardType: taskdomain.RewardTypeResourceGroup,
RewardResourceGroupID: 9901,
Status: taskdomain.StatusActive,
OperatorAdminID: 90001,
})
if err != nil || !created {
t.Fatalf("create resource group task failed: created=%v err=%v", created, err)
}
var rewardItems []taskdomain.RewardItem
if err := json.Unmarshal([]byte(definition.RewardItemsJSON), &rewardItems); err != nil {
t.Fatalf("decode reward items failed: %v", err)
}
if len(rewardItems) != 3 ||
rewardItems[0].CoverURL != "https://cdn.example.com/rewards/frame.webp" ||
rewardItems[1].CoverURL != "https://cdn.example.com/rewards/badge.webp" ||
rewardItems[2].WalletAssetAmount != 50 {
t.Fatalf("resource group display snapshot mismatch: %+v", rewardItems)
}
if _, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
EventID: "evt-resource-group-task",
EventType: "RoomGiftSent",
SourceService: "room-service",
UserID: 10001,
MetricType: taskdomain.MetricGiftSendCount,
Value: 1,
OccurredAtMS: now.UnixMilli(),
}); err != nil {
t.Fatalf("complete resource group task failed: %v", err)
}
claim, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{
UserID: 10001,
TaskID: definition.TaskID,
TaskType: taskdomain.TypeDaily,
CycleKey: "2026-05-09",
CommandID: "cmd-resource-group-task",
})
if err != nil {
t.Fatalf("claim resource group task failed: %v", err)
}
if claim.WalletResourceGrantID != "grant-task-1" || wallet.groupGrantCalls != 1 ||
wallet.lastGroupGrant.GetGroupId() != 9901 {
t.Fatalf("resource group claim mismatch: claim=%+v wallet=%+v", claim, wallet.lastGroupGrant)
}
}
func TestSingleResourceTaskGrantsConfiguredQuantityAndDuration(t *testing.T) {
repository := mysqltest.NewRepository(t)
wallet := &fakeWalletClient{resource: &walletv1.Resource{
ResourceId: 8701,
ResourceType: "vehicle",
Status: "active",
Grantable: true,
PreviewUrl: "https://cdn.example.com/rewards/vehicle.webp",
}}
svc := taskservice.New(repository, wallet)
now := time.Date(2026, 5, 10, 10, 0, 0, 0, time.UTC)
svc.SetClock(func() time.Time { return now })
const durationMS = int64(7 * 24 * time.Hour / time.Millisecond)
definition, _, err := svc.UpsertTaskDefinition(context.Background(), taskdomain.DefinitionCommand{
TaskType: taskdomain.TypeDaily,
Category: "game",
MetricType: taskdomain.MetricGameDicePlayCount,
Title: "play for vehicle",
TargetValue: 1,
TargetUnit: "count",
RewardType: taskdomain.RewardTypeResource,
RewardResourceID: 8701,
RewardQuantity: 2,
RewardDurationMS: durationMS,
Status: taskdomain.StatusActive,
OperatorAdminID: 90001,
})
if err != nil {
t.Fatalf("create resource task failed: %v", err)
}
if _, err := svc.ConsumeTaskEvent(context.Background(), taskdomain.Event{
EventID: "evt-single-resource-task",
EventType: "GameOrderSettled",
SourceService: "game-service",
UserID: 10002,
MetricType: taskdomain.MetricGameDicePlayCount,
Value: 1,
OccurredAtMS: now.UnixMilli(),
}); err != nil {
t.Fatalf("complete resource task failed: %v", err)
}
claim, err := svc.ClaimTaskReward(context.Background(), taskdomain.ClaimCommand{
UserID: 10002,
TaskID: definition.TaskID,
TaskType: taskdomain.TypeDaily,
CycleKey: "2026-05-10",
CommandID: "cmd-single-resource-task",
})
if err != nil {
t.Fatalf("claim resource task failed: %v", err)
}
if claim.WalletResourceGrantID != "grant-task-resource-1" || wallet.resourceGrantCalls != 1 ||
wallet.lastResourceGrant.GetResourceId() != 8701 || wallet.lastResourceGrant.GetQuantity() != 2 ||
wallet.lastResourceGrant.GetDurationMs() != durationMS {
t.Fatalf("single resource grant mismatch: claim=%+v request=%+v", claim, wallet.lastResourceGrant)
}
}
func TestEmptyRewardAssetUpdateDoesNotOverwriteStoredPoint(t *testing.T) {
repository := &fakeTaskDefinitionRepository{storedAsset: taskdomain.RewardAssetPoint}
svc := taskservice.New(repository, &fakeWalletClient{})
@ -515,8 +671,14 @@ func assertRecordedTaskEvents(t *testing.T, got []taskdomain.Event, want []expec
}
type fakeWalletClient struct {
calls int
last *walletv1.CreditTaskRewardRequest
calls int
last *walletv1.CreditTaskRewardRequest
resource *walletv1.Resource
group *walletv1.ResourceGroup
resourceGrantCalls int
groupGrantCalls int
lastResourceGrant *walletv1.GrantResourceRequest
lastGroupGrant *walletv1.GrantResourceGroupRequest
}
func (f *fakeWalletClient) CreditTaskReward(_ context.Context, req *walletv1.CreditTaskRewardRequest, _ ...grpc.CallOption) (*walletv1.CreditTaskRewardResponse, error) {
@ -533,3 +695,31 @@ func (f *fakeWalletClient) CreditTaskReward(_ context.Context, req *walletv1.Cre
Balance: &walletv1.AssetBalance{AssetType: assetType, AvailableAmount: req.GetAmount()},
}, nil
}
func (f *fakeWalletClient) GetResource(_ context.Context, _ *walletv1.GetResourceRequest, _ ...grpc.CallOption) (*walletv1.GetResourceResponse, error) {
return &walletv1.GetResourceResponse{Resource: f.resource}, nil
}
func (f *fakeWalletClient) GetResourceGroup(_ context.Context, _ *walletv1.GetResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.GetResourceGroupResponse, error) {
return &walletv1.GetResourceGroupResponse{Group: f.group}, nil
}
func (f *fakeWalletClient) GrantResource(_ context.Context, req *walletv1.GrantResourceRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
f.resourceGrantCalls++
f.lastResourceGrant = req
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
GrantId: "grant-task-resource-1",
CreatedAtMs: 1778292000000,
UpdatedAtMs: 1778292000000,
}}, nil
}
func (f *fakeWalletClient) GrantResourceGroup(_ context.Context, req *walletv1.GrantResourceGroupRequest, _ ...grpc.CallOption) (*walletv1.ResourceGrantResponse, error) {
f.groupGrantCalls++
f.lastGroupGrant = req
return &walletv1.ResourceGrantResponse{Grant: &walletv1.ResourceGrant{
GrantId: "grant-task-1",
CreatedAtMs: 1778292000000,
UpdatedAtMs: 1778292000000,
}}, nil
}

View File

@ -306,10 +306,12 @@ func normalizeCycle(cycle domain.Cycle) domain.Cycle {
seenGifts[gift.GiftID] = true
gifts = append(gifts, gift)
}
sort.SliceStable(gifts, func(i, j int) bool { return gifts[i].SortOrder < gifts[j].SortOrder })
// sort_order 是运营权重而不是数组下标;全后台统一使用大值优先,避免周星礼物与普通礼物列表语义相反。
sort.SliceStable(gifts, func(i, j int) bool { return gifts[i].SortOrder > gifts[j].SortOrder })
for index := range gifts {
if gifts[index].SortOrder <= 0 {
gifts[index].SortOrder = int32(index + 1)
// 零值兼容旧请求;按当前数组顺序补递减权重,重新读取时不会因 DESC 反转。
gifts[index].SortOrder = int32(len(gifts) - index)
}
}
cycle.Gifts = gifts

View File

@ -526,7 +526,7 @@ func attachActivityTemplateChildren(ctx context.Context, q activityTemplateQueri
}
rows, err = q.QueryContext(ctx, `
SELECT gift_id, sort_order, name, icon_url, coin_price, price_version
FROM activity_template_gifts WHERE app_code = ? AND template_id = ? ORDER BY sort_order, gift_id`, item.AppCode, item.TemplateID)
FROM activity_template_gifts WHERE app_code = ? AND template_id = ? ORDER BY sort_order DESC, gift_id`, item.AppCode, item.TemplateID)
if err != nil {
return err
}
@ -547,7 +547,7 @@ func attachActivityTemplateChildren(ctx context.Context, q activityTemplateQueri
}
rows, err = q.QueryContext(ctx, `
SELECT task_key, task_type, target_value, reward_resource_group_id, sort_order
FROM activity_template_tasks WHERE app_code = ? AND template_id = ? ORDER BY sort_order, task_key`, item.AppCode, item.TemplateID)
FROM activity_template_tasks WHERE app_code = ? AND template_id = ? ORDER BY sort_order DESC, task_key`, item.AppCode, item.TemplateID)
if err != nil {
return err
}
@ -589,7 +589,7 @@ func attachActivityTemplateChildren(ctx context.Context, q activityTemplateQueri
}
rows, err = q.QueryContext(ctx, `
SELECT asset_key, locale, url, media_type, sort_order
FROM activity_template_assets WHERE app_code = ? AND template_id = ? ORDER BY asset_key, locale, sort_order`, item.AppCode, item.TemplateID)
FROM activity_template_assets WHERE app_code = ? AND template_id = ? ORDER BY asset_key, locale, sort_order DESC`, item.AppCode, item.TemplateID)
if err != nil {
return err
}

View File

@ -454,7 +454,7 @@ func (r *Repository) getCumulativeRechargeRewardConfigForUpdate(ctx context.Cont
func (r *Repository) listCumulativeRechargeRewardTiers(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) {
query := cumulativeRechargeRewardTierSelectSQL() + `
WHERE app_code = ?
ORDER BY sort_order ASC, threshold_usd_minor ASC, tier_id ASC`
ORDER BY sort_order DESC, threshold_usd_minor ASC, tier_id ASC`
var rows *sql.Rows
var err error
if tx != nil {
@ -472,7 +472,7 @@ func (r *Repository) listCumulativeRechargeRewardTiers(ctx context.Context, tx *
func (r *Repository) listCumulativeRechargeRewardTiersForUpdate(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) {
rows, err := tx.QueryContext(ctx, cumulativeRechargeRewardTierSelectSQL()+`
WHERE app_code = ?
ORDER BY sort_order ASC, threshold_usd_minor ASC, tier_id ASC
ORDER BY sort_order DESC, threshold_usd_minor ASC, tier_id ASC
FOR UPDATE`,
appcode.FromContext(ctx),
)

View File

@ -349,13 +349,13 @@ func (r *Repository) listFirstRechargeRewardTiers(ctx context.Context, tx *sql.T
if tx != nil {
rows, err = tx.QueryContext(ctx, firstRechargeRewardTierSelectSQL()+`
WHERE app_code = ?
ORDER BY sort_order ASC, usd_minor_amount ASC, tier_id ASC`,
ORDER BY sort_order DESC, usd_minor_amount ASC, tier_id ASC`,
appcode.FromContext(ctx),
)
} else {
rows, err = r.db.QueryContext(ctx, firstRechargeRewardTierSelectSQL()+`
WHERE app_code = ?
ORDER BY sort_order ASC, usd_minor_amount ASC, tier_id ASC`,
ORDER BY sort_order DESC, usd_minor_amount ASC, tier_id ASC`,
appcode.FromContext(ctx),
)
}
@ -369,7 +369,7 @@ func (r *Repository) listFirstRechargeRewardTiers(ctx context.Context, tx *sql.T
func (r *Repository) listFirstRechargeRewardTiersForUpdate(ctx context.Context, tx *sql.Tx) ([]domain.Tier, error) {
rows, err := tx.QueryContext(ctx, firstRechargeRewardTierSelectSQL()+`
WHERE app_code = ?
ORDER BY sort_order ASC, usd_minor_amount ASC, tier_id ASC
ORDER BY sort_order DESC, usd_minor_amount ASC, tier_id ASC
FOR UPDATE`,
appcode.FromContext(ctx),
)

View File

@ -1110,7 +1110,7 @@ func (r *Repository) listActiveLevelTracks(ctx context.Context, q queryer) ([]do
SELECT app_code, track, name, status, sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_at_ms, updated_at_ms
FROM growth_level_tracks
WHERE app_code = ? AND status = 'active'
ORDER BY sort_order ASC, track ASC`,
ORDER BY sort_order DESC, track ASC`,
appcode.FromContext(ctx),
)
if err != nil {
@ -1135,7 +1135,7 @@ func (r *Repository) listLevelTracks(ctx context.Context, q queryer, track strin
SELECT app_code, track, name, status, sort_order, COALESCE(CAST(display_config_json AS CHAR), '{}'), created_at_ms, updated_at_ms
FROM growth_level_tracks
WHERE `+strings.Join(where, " AND ")+`
ORDER BY sort_order ASC, track ASC`, args...)
ORDER BY sort_order DESC, track ASC`, args...)
if err != nil {
return nil, err
}

View File

@ -740,7 +740,7 @@ func (r *Repository) listInviteActivityRewardTiers(ctx context.Context, tx *sql.
}
rows, err := queryer.QueryContext(ctx, inviteActivityRewardTierSelectSQL()+`
`+where+`
ORDER BY reward_type ASC, sort_order ASC, tier_id ASC`, args...)
ORDER BY reward_type ASC, sort_order DESC, tier_id ASC`, args...)
if err != nil {
return nil, err
}

View File

@ -483,7 +483,7 @@ func scanRoomTurnoverRewardConfig(scanner roomTurnoverRewardConfigScanner) (doma
func (r *Repository) listRoomTurnoverRewardTiers(ctx context.Context, tx *sql.Tx, regionID int64) ([]domain.Tier, error) {
query := roomTurnoverRewardTierSelectSQL() + `
WHERE app_code = ? AND region_id = ?
ORDER BY sort_order ASC, threshold_coin_spent ASC, tier_id ASC`
ORDER BY sort_order DESC, threshold_coin_spent ASC, tier_id ASC`
var rows *sql.Rows
var err error
if tx != nil {

View File

@ -26,14 +26,16 @@ func (r *Repository) ListVisibleDefinitions(ctx context.Context, nowMS int64) ([
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, status, sort_order, version,
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions
WHERE app_code = ? AND status = 'active'
AND (effective_from_ms = 0 OR effective_from_ms <= ?)
AND (effective_to_ms = 0 OR effective_to_ms > ?)
ORDER BY sort_order ASC, task_id ASC`,
ORDER BY sort_order DESC, task_id ASC`,
appcode.FromContext(ctx), nowMS, nowMS,
)
if err != nil {
@ -59,7 +61,9 @@ func (r *Repository) ListUserProgress(ctx context.Context, userID int64, cycleKe
}
rows, err := r.db.QueryContext(ctx, `
SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, reward_asset_type, status, completed_at_ms, claimed_at_ms, updated_at_ms
target_value, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, completed_at_ms, claimed_at_ms, updated_at_ms
FROM user_task_progress
WHERE app_code = ? AND user_id = ? AND cycle_key IN (`+placeholders+`)`, args...)
if err != nil {
@ -171,11 +175,13 @@ func (r *Repository) ListTaskDefinitions(ctx context.Context, query taskdomain.D
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, status, sort_order, version,
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions `+where+`
ORDER BY sort_order ASC, updated_at_ms DESC, task_id ASC
ORDER BY sort_order DESC, updated_at_ms DESC, task_id ASC
LIMIT ? OFFSET ?`, args...)
if err != nil {
return nil, 0, err
@ -229,8 +235,14 @@ func (r *Repository) UpsertTaskDefinition(ctx context.Context, command taskdomai
next.DimensionFilterJSON = command.DimensionFilterJSON
next.TargetValue = command.TargetValue
next.TargetUnit = command.TargetUnit
next.RewardType = command.RewardType
next.RewardCoinAmount = command.RewardCoinAmount
next.RewardAssetType = command.RewardAssetType
next.RewardResourceID = command.RewardResourceID
next.RewardResourceGroupID = command.RewardResourceGroupID
next.RewardQuantity = command.RewardQuantity
next.RewardDurationMS = command.RewardDurationMS
next.RewardItemsJSON = command.RewardItemsJSON
next.Status = command.Status
next.SortOrder = command.SortOrder
next.EffectiveFromMS = command.EffectiveFromMS
@ -250,14 +262,18 @@ func (r *Repository) UpsertTaskDefinition(ctx context.Context, command taskdomai
SET task_type = ?, category = ?, metric_type = ?, title = ?, description = ?,
audience_type = ?, icon_key = ?, icon_url = ?, action_type = ?, action_param = ?,
action_payload_json = CAST(? AS JSON), dimension_filter_json = CAST(? AS JSON),
target_value = ?, target_unit = ?, reward_coin_amount = ?, reward_asset_type = ?, status = ?, sort_order = ?,
target_value = ?, target_unit = ?, reward_coin_amount = ?, reward_asset_type = ?, reward_type = ?,
reward_resource_id = ?, reward_resource_group_id = ?, reward_quantity = ?, reward_duration_ms = ?,
reward_items_json = CAST(? AS JSON), status = ?, sort_order = ?,
version = ?, current_task_version_id = ?, effective_from_ms = ?, effective_to_ms = ?,
updated_by_admin_id = ?, updated_at_ms = ?
WHERE app_code = ? AND task_id = ?`,
next.TaskType, next.Category, next.MetricType, next.Title, next.Description,
next.AudienceType, next.IconKey, next.IconURL, next.ActionType, next.ActionParam,
next.ActionPayloadJSON, next.DimensionFilterJSON,
next.TargetValue, next.TargetUnit, next.RewardCoinAmount, next.RewardAssetType, next.Status, next.SortOrder,
next.TargetValue, next.TargetUnit, next.RewardCoinAmount, next.RewardAssetType, next.RewardType,
next.RewardResourceID, next.RewardResourceGroupID, next.RewardQuantity, next.RewardDurationMS,
next.RewardItemsJSON, next.Status, next.SortOrder,
next.Version, next.CurrentVersionID, next.EffectiveFromMS, next.EffectiveToMS,
next.UpdatedByAdminID, next.UpdatedAtMS, appcode.FromContext(ctx), next.TaskID,
); err != nil {
@ -352,26 +368,36 @@ func (r *Repository) PrepareTaskClaim(ctx context.Context, command taskdomain.Cl
}
claim := taskdomain.Claim{
ClaimID: idgen.New("tclaim"),
CommandID: command.CommandID,
UserID: command.UserID,
TaskID: command.TaskID,
TaskType: command.TaskType,
CycleKey: command.CycleKey,
RewardCoinAmount: progress.RewardCoinAmount,
RewardAssetType: progress.RewardAssetType,
Status: taskdomain.ClaimStatusPending,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
ClaimID: idgen.New("tclaim"),
CommandID: command.CommandID,
UserID: command.UserID,
TaskID: command.TaskID,
TaskType: command.TaskType,
CycleKey: command.CycleKey,
RewardType: progress.RewardType,
RewardCoinAmount: progress.RewardCoinAmount,
RewardAssetType: progress.RewardAssetType,
RewardResourceID: progress.RewardResourceID,
RewardResourceGroupID: progress.RewardResourceGroupID,
RewardQuantity: progress.RewardQuantity,
RewardDurationMS: progress.RewardDurationMS,
RewardItemsJSON: progress.RewardItemsJSON,
Status: taskdomain.ClaimStatusPending,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
claim.WalletCommandID = walletTaskCommandID(claim.ClaimID)
if _, err := tx.ExecContext(ctx, `
INSERT INTO task_reward_claims (
app_code, claim_id, command_id, user_id, task_id, task_type, cycle_key,
reward_coin_amount, reward_asset_type, wallet_command_id, status, failure_reason, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?)`,
INSERT INTO task_reward_claims (
app_code, claim_id, command_id, user_id, task_id, task_type, cycle_key,
reward_coin_amount, reward_asset_type, reward_type, reward_resource_id, reward_resource_group_id,
reward_quantity, reward_duration_ms, reward_items_json, wallet_command_id, status,
failure_reason, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, '', ?, ?)`,
appcode.FromContext(ctx), claim.ClaimID, claim.CommandID, claim.UserID, claim.TaskID, claim.TaskType,
claim.CycleKey, claim.RewardCoinAmount, claim.RewardAssetType, claim.WalletCommandID, claim.Status, claim.CreatedAtMS, claim.UpdatedAtMS,
claim.CycleKey, claim.RewardCoinAmount, claim.RewardAssetType, claim.RewardType, claim.RewardResourceID,
claim.RewardResourceGroupID, claim.RewardQuantity, claim.RewardDurationMS, claim.RewardItemsJSON,
claim.WalletCommandID, claim.Status, claim.CreatedAtMS, claim.UpdatedAtMS,
); err != nil {
return taskdomain.Claim{}, err
}
@ -381,8 +407,8 @@ func (r *Repository) PrepareTaskClaim(ctx context.Context, command taskdomain.Cl
return claim, nil
}
// MarkTaskClaimGranted 把钱包成功回执和用户任务 claimed 状态放在同一 activity 事务里。
func (r *Repository) MarkTaskClaimGranted(ctx context.Context, claimID string, walletTransactionID string, grantedAtMS int64) (taskdomain.Claim, error) {
// MarkTaskClaimGranted 把钱包资产交易或资源赠送回执和用户任务 claimed 状态放在同一 activity 事务里。
func (r *Repository) MarkTaskClaimGranted(ctx context.Context, claimID string, walletTransactionID string, walletResourceGrantID string, grantedAtMS int64) (taskdomain.Claim, error) {
tx, err := r.db.BeginTx(ctx, nil)
if err != nil {
return taskdomain.Claim{}, err
@ -401,9 +427,10 @@ func (r *Repository) MarkTaskClaimGranted(ctx context.Context, claimID string, w
}
if _, err := tx.ExecContext(ctx, `
UPDATE task_reward_claims
SET status = 'granted', wallet_transaction_id = ?, failure_reason = '', updated_at_ms = ?
SET status = 'granted', wallet_transaction_id = ?, wallet_resource_grant_id = ?,
failure_reason = '', updated_at_ms = ?
WHERE app_code = ? AND claim_id = ?`,
walletTransactionID, grantedAtMS, appcode.FromContext(ctx), claimID,
walletTransactionID, walletResourceGrantID, grantedAtMS, appcode.FromContext(ctx), claimID,
); err != nil {
return taskdomain.Claim{}, err
}
@ -420,6 +447,7 @@ func (r *Repository) MarkTaskClaimGranted(ctx context.Context, claimID string, w
}
claim.Status = taskdomain.ClaimStatusGranted
claim.WalletTransactionID = walletTransactionID
claim.WalletResourceGrantID = walletResourceGrantID
claim.UpdatedAtMS = grantedAtMS
return claim, nil
}
@ -474,14 +502,16 @@ func (r *Repository) listDefinitionsForEvent(ctx context.Context, tx *sql.Tx, me
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, status, sort_order, version,
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions
WHERE app_code = ? AND status = 'active' AND metric_type = ?
AND (effective_from_ms = 0 OR effective_from_ms <= ?)
AND (effective_to_ms = 0 OR effective_to_ms > ?)
ORDER BY sort_order ASC, task_id ASC
ORDER BY sort_order DESC, task_id ASC
FOR UPDATE`,
appcode.FromContext(ctx), metricType, occurredAtMS, occurredAtMS,
)
@ -498,7 +528,7 @@ func (r *Repository) applyTaskProgressDelta(ctx context.Context, tx *sql.Tx, use
if !errors.Is(err, sql.ErrNoRows) {
return false, err
}
// 首次命中任务时把配置版本、目标值和奖励金额一起快照到用户进度,后续后台改配置不会改变已产生进度的领取口径。
// 首次命中任务时把配置版本、目标值和完整奖励一起快照到用户进度,后续后台改配置不会改变已产生进度的领取口径。
status := taskdomain.StatusInProgress
completedAtMS := int64(0)
if delta >= definition.TargetValue {
@ -506,12 +536,16 @@ func (r *Repository) applyTaskProgressDelta(ctx context.Context, tx *sql.Tx, use
completedAtMS = nowMS
}
_, err := tx.ExecContext(ctx, `
INSERT INTO user_task_progress (
app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, reward_asset_type, status, completed_at_ms, claimed_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`,
INSERT INTO user_task_progress (
app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, reward_asset_type, reward_type, reward_resource_id,
reward_resource_group_id, reward_quantity, reward_duration_ms, reward_items_json,
status, completed_at_ms, claimed_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, 0, ?)`,
appcode.FromContext(ctx), userID, definition.TaskID, definition.CurrentVersionID, cycleKey, delta,
definition.TargetValue, definition.RewardCoinAmount, definition.RewardAssetType, status, completedAtMS, nowMS,
definition.TargetValue, definition.RewardCoinAmount, definition.RewardAssetType, definition.RewardType,
definition.RewardResourceID, definition.RewardResourceGroupID, definition.RewardQuantity,
definition.RewardDurationMS, definition.RewardItemsJSON, status, completedAtMS, nowMS,
)
return err == nil, err
}
@ -538,33 +572,39 @@ func (r *Repository) applyTaskProgressDelta(ctx context.Context, tx *sql.Tx, use
func (r *Repository) createTaskDefinition(ctx context.Context, tx *sql.Tx, command taskdomain.DefinitionCommand, nowMS int64) (taskdomain.Definition, error) {
definition := taskdomain.Definition{
AppCode: appcode.FromContext(ctx),
TaskID: idgen.New("task"),
TaskType: command.TaskType,
Category: command.Category,
MetricType: command.MetricType,
Title: command.Title,
Description: command.Description,
AudienceType: command.AudienceType,
IconKey: command.IconKey,
IconURL: command.IconURL,
ActionType: command.ActionType,
ActionParam: command.ActionParam,
ActionPayloadJSON: command.ActionPayloadJSON,
DimensionFilterJSON: command.DimensionFilterJSON,
TargetValue: command.TargetValue,
TargetUnit: command.TargetUnit,
RewardCoinAmount: command.RewardCoinAmount,
RewardAssetType: command.RewardAssetType,
Status: command.Status,
SortOrder: command.SortOrder,
Version: 1,
EffectiveFromMS: command.EffectiveFromMS,
EffectiveToMS: command.EffectiveToMS,
CreatedByAdminID: command.OperatorAdminID,
UpdatedByAdminID: command.OperatorAdminID,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
AppCode: appcode.FromContext(ctx),
TaskID: idgen.New("task"),
TaskType: command.TaskType,
Category: command.Category,
MetricType: command.MetricType,
Title: command.Title,
Description: command.Description,
AudienceType: command.AudienceType,
IconKey: command.IconKey,
IconURL: command.IconURL,
ActionType: command.ActionType,
ActionParam: command.ActionParam,
ActionPayloadJSON: command.ActionPayloadJSON,
DimensionFilterJSON: command.DimensionFilterJSON,
TargetValue: command.TargetValue,
TargetUnit: command.TargetUnit,
RewardType: command.RewardType,
RewardCoinAmount: command.RewardCoinAmount,
RewardAssetType: command.RewardAssetType,
RewardResourceID: command.RewardResourceID,
RewardResourceGroupID: command.RewardResourceGroupID,
RewardQuantity: command.RewardQuantity,
RewardDurationMS: command.RewardDurationMS,
RewardItemsJSON: command.RewardItemsJSON,
Status: command.Status,
SortOrder: command.SortOrder,
Version: 1,
EffectiveFromMS: command.EffectiveFromMS,
EffectiveToMS: command.EffectiveToMS,
CreatedByAdminID: command.OperatorAdminID,
UpdatedByAdminID: command.OperatorAdminID,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
versionID, err := r.insertTaskDefinitionVersion(ctx, tx, definition, nowMS)
if err != nil {
@ -574,16 +614,20 @@ func (r *Repository) createTaskDefinition(ctx context.Context, tx *sql.Tx, comma
_, err = tx.ExecContext(ctx, `
INSERT INTO task_definitions (
app_code, task_id, task_type, category, metric_type, title, description,
audience_type, icon_key, icon_url, action_type, action_param, action_payload_json, dimension_filter_json,
target_value, target_unit, reward_coin_amount, reward_asset_type, status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
audience_type, icon_key, icon_url, action_type, action_param, action_payload_json, dimension_filter_json,
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type, reward_resource_id,
reward_resource_group_id, reward_quantity, reward_duration_ms, reward_items_json,
status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?, CAST(? AS JSON), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
definition.AppCode, definition.TaskID, definition.TaskType, definition.Category, definition.MetricType,
definition.Title, definition.Description, definition.AudienceType, definition.IconKey, definition.IconURL,
definition.ActionType, definition.ActionParam, definition.ActionPayloadJSON, definition.DimensionFilterJSON,
definition.TargetValue, definition.TargetUnit,
definition.RewardCoinAmount, definition.RewardAssetType, definition.Status, definition.SortOrder, definition.Version,
definition.RewardCoinAmount, definition.RewardAssetType, definition.RewardType, definition.RewardResourceID,
definition.RewardResourceGroupID, definition.RewardQuantity, definition.RewardDurationMS, definition.RewardItemsJSON,
definition.Status, definition.SortOrder, definition.Version,
definition.CurrentVersionID, definition.EffectiveFromMS, definition.EffectiveToMS,
definition.CreatedByAdminID, definition.UpdatedByAdminID, definition.CreatedAtMS, definition.UpdatedAtMS,
)
@ -617,7 +661,9 @@ func (r *Repository) getTaskDefinition(ctx context.Context, taskID string) (task
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, status, sort_order, version,
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions
@ -633,7 +679,9 @@ func (r *Repository) getTaskDefinitionForUpdate(ctx context.Context, tx *sql.Tx,
audience_type, icon_key, icon_url, action_type, action_param,
COALESCE(CAST(action_payload_json AS CHAR), '{}'),
COALESCE(CAST(dimension_filter_json AS CHAR), '{}'),
target_value, target_unit, reward_coin_amount, reward_asset_type, status, sort_order, version,
target_value, target_unit, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, sort_order, version,
current_task_version_id, effective_from_ms, effective_to_ms, created_by_admin_id,
updated_by_admin_id, created_at_ms, updated_at_ms
FROM task_definitions
@ -651,7 +699,9 @@ func (r *Repository) getTaskDefinitionForUpdate(ctx context.Context, tx *sql.Tx,
func (r *Repository) getProgressForUpdate(ctx context.Context, tx *sql.Tx, userID int64, taskID string, cycleKey string) (taskdomain.Progress, error) {
row := tx.QueryRowContext(ctx, `
SELECT app_code, user_id, task_id, task_version_id, cycle_key, progress_value,
target_value, reward_coin_amount, reward_asset_type, status, completed_at_ms, claimed_at_ms, updated_at_ms
target_value, reward_coin_amount, reward_asset_type, reward_type,
reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), status, completed_at_ms, claimed_at_ms, updated_at_ms
FROM user_task_progress
WHERE app_code = ? AND user_id = ? AND task_id = ? AND cycle_key = ?
FOR UPDATE`,
@ -733,6 +783,12 @@ func scanDefinition(row rowScanner) (taskdomain.Definition, error) {
&definition.TargetUnit,
&definition.RewardCoinAmount,
&definition.RewardAssetType,
&definition.RewardType,
&definition.RewardResourceID,
&definition.RewardResourceGroupID,
&definition.RewardQuantity,
&definition.RewardDurationMS,
&definition.RewardItemsJSON,
&definition.Status,
&definition.SortOrder,
&definition.Version,
@ -759,6 +815,12 @@ func scanProgress(row rowScanner) (taskdomain.Progress, error) {
&progress.TargetValue,
&progress.RewardCoinAmount,
&progress.RewardAssetType,
&progress.RewardType,
&progress.RewardResourceID,
&progress.RewardResourceGroupID,
&progress.RewardQuantity,
&progress.RewardDurationMS,
&progress.RewardItemsJSON,
&progress.Status,
&progress.CompletedAtMS,
&progress.ClaimedAtMS,
@ -778,8 +840,15 @@ func scanClaim(row rowScanner) (taskdomain.Claim, error) {
&claim.CycleKey,
&claim.RewardCoinAmount,
&claim.RewardAssetType,
&claim.RewardType,
&claim.RewardResourceID,
&claim.RewardResourceGroupID,
&claim.RewardQuantity,
&claim.RewardDurationMS,
&claim.RewardItemsJSON,
&claim.WalletCommandID,
&claim.WalletTransactionID,
&claim.WalletResourceGrantID,
&claim.Status,
&claim.FailureReason,
&claim.CreatedAtMS,
@ -790,7 +859,9 @@ func scanClaim(row rowScanner) (taskdomain.Claim, error) {
func taskClaimSelectSQL() string {
return `SELECT claim_id, command_id, user_id, task_id, task_type, cycle_key, reward_coin_amount, reward_asset_type,
wallet_command_id, wallet_transaction_id, status, failure_reason, created_at_ms, updated_at_ms
reward_type, reward_resource_id, reward_resource_group_id, reward_quantity, reward_duration_ms,
COALESCE(CAST(reward_items_json AS CHAR), '[]'), wallet_command_id, wallet_transaction_id,
wallet_resource_grant_id, status, failure_reason, created_at_ms, updated_at_ms
FROM task_reward_claims `
}
@ -827,7 +898,13 @@ func taskDefinitionNeedsVersion(current taskdomain.Definition, next taskdomain.D
current.TargetValue != next.TargetValue ||
current.TargetUnit != next.TargetUnit ||
current.RewardCoinAmount != next.RewardCoinAmount ||
current.RewardAssetType != next.RewardAssetType
current.RewardAssetType != next.RewardAssetType ||
current.RewardType != next.RewardType ||
current.RewardResourceID != next.RewardResourceID ||
current.RewardResourceGroupID != next.RewardResourceGroupID ||
current.RewardQuantity != next.RewardQuantity ||
current.RewardDurationMS != next.RewardDurationMS ||
current.RewardItemsJSON != next.RewardItemsJSON
}
func dimensionFilterMatches(filterJSON string, dimensionsJSON string) (bool, error) {

View File

@ -136,7 +136,8 @@ func (r *Repository) UpsertWeeklyStarCycle(ctx context.Context, command domain.C
for index, gift := range cycle.Gifts {
sortOrder := gift.SortOrder
if sortOrder <= 0 {
sortOrder = int32(index + 1)
// repository 仍兼容绕过 service 的调用方;数组首项补最高权重,保证读回的 DESC 顺序不反转。
sortOrder = int32(len(cycle.Gifts) - index)
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO weekly_star_cycle_gifts (app_code, cycle_id, gift_id, sort_order, created_at_ms)
@ -777,7 +778,7 @@ func (r *Repository) listWeeklyStarGifts(ctx context.Context, cycleID string) ([
SELECT app_code, cycle_id, gift_id, sort_order
FROM weekly_star_cycle_gifts
WHERE app_code = ? AND cycle_id = ?
ORDER BY sort_order ASC, gift_id ASC`,
ORDER BY sort_order DESC, gift_id ASC`,
appcode.FromContext(ctx), cycleID,
)
if err != nil {

View File

@ -2,6 +2,7 @@ package grpc
import (
"context"
"encoding/json"
activityv1 "hyapp.local/api/proto/activity/v1"
"hyapp/pkg/appcode"
@ -108,28 +109,33 @@ func (s *AdminTaskServer) ListTaskDefinitions(ctx context.Context, req *activity
func (s *AdminTaskServer) UpsertTaskDefinition(ctx context.Context, req *activityv1.UpsertTaskDefinitionRequest) (*activityv1.UpsertTaskDefinitionResponse, error) {
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
item, created, err := s.svc.UpsertTaskDefinition(ctx, taskdomain.DefinitionCommand{
TaskID: req.GetTaskId(),
TaskType: req.GetTaskType(),
Category: req.GetCategory(),
MetricType: req.GetMetricType(),
Title: req.GetTitle(),
Description: req.GetDescription(),
AudienceType: req.GetAudienceType(),
IconKey: req.GetIconKey(),
IconURL: req.GetIconUrl(),
ActionType: req.GetActionType(),
ActionParam: req.GetActionParam(),
ActionPayloadJSON: req.GetActionPayloadJson(),
DimensionFilterJSON: req.GetDimensionFilterJson(),
TargetValue: req.GetTargetValue(),
TargetUnit: req.GetTargetUnit(),
RewardCoinAmount: req.GetRewardCoinAmount(),
RewardAssetType: req.GetRewardAssetType(),
Status: req.GetStatus(),
SortOrder: req.GetSortOrder(),
EffectiveFromMS: req.GetEffectiveFromMs(),
EffectiveToMS: req.GetEffectiveToMs(),
OperatorAdminID: req.GetOperatorAdminId(),
TaskID: req.GetTaskId(),
TaskType: req.GetTaskType(),
Category: req.GetCategory(),
MetricType: req.GetMetricType(),
Title: req.GetTitle(),
Description: req.GetDescription(),
AudienceType: req.GetAudienceType(),
IconKey: req.GetIconKey(),
IconURL: req.GetIconUrl(),
ActionType: req.GetActionType(),
ActionParam: req.GetActionParam(),
ActionPayloadJSON: req.GetActionPayloadJson(),
DimensionFilterJSON: req.GetDimensionFilterJson(),
TargetValue: req.GetTargetValue(),
TargetUnit: req.GetTargetUnit(),
RewardCoinAmount: req.GetRewardCoinAmount(),
RewardAssetType: req.GetRewardAssetType(),
RewardType: req.GetRewardType(),
RewardResourceID: req.GetRewardResourceId(),
RewardResourceGroupID: req.GetRewardResourceGroupId(),
RewardQuantity: req.GetRewardQuantity(),
RewardDurationMS: req.GetRewardDurationMs(),
Status: req.GetStatus(),
SortOrder: req.GetSortOrder(),
EffectiveFromMS: req.GetEffectiveFromMs(),
EffectiveToMS: req.GetEffectiveToMs(),
OperatorAdminID: req.GetOperatorAdminId(),
})
if err != nil {
return nil, xerr.ToGRPCError(err)
@ -176,76 +182,115 @@ func taskListToProto(result taskdomain.ListResult) *activityv1.ListUserTasksResp
func itemToProto(item taskdomain.Item) *activityv1.TaskItem {
return &activityv1.TaskItem{
TaskId: item.TaskID,
TaskType: item.TaskType,
Category: item.Category,
MetricType: item.MetricType,
Title: item.Title,
Description: item.Description,
AudienceType: item.AudienceType,
IconKey: item.IconKey,
IconUrl: item.IconURL,
ActionType: item.ActionType,
ActionParam: item.ActionParam,
ActionPayloadJson: item.ActionPayloadJSON,
DimensionFilterJson: item.DimensionFilterJSON,
TargetValue: item.TargetValue,
TargetUnit: item.TargetUnit,
ProgressValue: item.ProgressValue,
RewardCoinAmount: item.RewardCoinAmount,
RewardAssetType: item.RewardAssetType,
Status: item.UserStatus,
Claimable: item.Claimable,
TaskDay: item.TaskDay,
ServerTimeMs: item.ServerTimeMS,
NextRefreshAtMs: item.NextRefreshAtMS,
SortOrder: item.SortOrder,
Version: item.Version,
TaskId: item.TaskID,
TaskType: item.TaskType,
Category: item.Category,
MetricType: item.MetricType,
Title: item.Title,
Description: item.Description,
AudienceType: item.AudienceType,
IconKey: item.IconKey,
IconUrl: item.IconURL,
ActionType: item.ActionType,
ActionParam: item.ActionParam,
ActionPayloadJson: item.ActionPayloadJSON,
DimensionFilterJson: item.DimensionFilterJSON,
TargetValue: item.TargetValue,
TargetUnit: item.TargetUnit,
ProgressValue: item.ProgressValue,
RewardCoinAmount: item.RewardCoinAmount,
RewardAssetType: item.RewardAssetType,
RewardType: item.RewardType,
RewardResourceId: item.RewardResourceID,
RewardResourceGroupId: item.RewardResourceGroupID,
RewardQuantity: item.RewardQuantity,
RewardDurationMs: item.RewardDurationMS,
RewardItems: rewardItemsToProto(item.RewardItemsJSON),
Status: item.UserStatus,
Claimable: item.Claimable,
TaskDay: item.TaskDay,
ServerTimeMs: item.ServerTimeMS,
NextRefreshAtMs: item.NextRefreshAtMS,
SortOrder: item.SortOrder,
Version: item.Version,
}
}
func claimToProto(claim taskdomain.Claim) *activityv1.ClaimTaskRewardResponse {
return &activityv1.ClaimTaskRewardResponse{
ClaimId: claim.ClaimID,
TaskId: claim.TaskID,
TaskType: claim.TaskType,
TaskDay: claim.CycleKey,
RewardCoinAmount: claim.RewardCoinAmount,
RewardAssetType: claim.RewardAssetType,
Status: claim.Status,
WalletTransactionId: claim.WalletTransactionID,
GrantedAtMs: claim.UpdatedAtMS,
Claimed: claim.Status == taskdomain.ClaimStatusGranted,
ClaimId: claim.ClaimID,
TaskId: claim.TaskID,
TaskType: claim.TaskType,
TaskDay: claim.CycleKey,
RewardCoinAmount: claim.RewardCoinAmount,
RewardAssetType: claim.RewardAssetType,
RewardType: claim.RewardType,
RewardResourceId: claim.RewardResourceID,
RewardResourceGroupId: claim.RewardResourceGroupID,
WalletResourceGrantId: claim.WalletResourceGrantID,
RewardItems: rewardItemsToProto(claim.RewardItemsJSON),
Status: claim.Status,
WalletTransactionId: claim.WalletTransactionID,
GrantedAtMs: claim.UpdatedAtMS,
Claimed: claim.Status == taskdomain.ClaimStatusGranted,
}
}
func definitionToProto(item taskdomain.Definition) *activityv1.TaskDefinition {
return &activityv1.TaskDefinition{
TaskId: item.TaskID,
TaskType: item.TaskType,
Category: item.Category,
MetricType: item.MetricType,
Title: item.Title,
Description: item.Description,
AudienceType: item.AudienceType,
IconKey: item.IconKey,
IconUrl: item.IconURL,
ActionType: item.ActionType,
ActionParam: item.ActionParam,
ActionPayloadJson: item.ActionPayloadJSON,
DimensionFilterJson: item.DimensionFilterJSON,
TargetValue: item.TargetValue,
TargetUnit: item.TargetUnit,
RewardCoinAmount: item.RewardCoinAmount,
RewardAssetType: item.RewardAssetType,
Status: item.Status,
SortOrder: item.SortOrder,
Version: item.Version,
EffectiveFromMs: item.EffectiveFromMS,
EffectiveToMs: item.EffectiveToMS,
CreatedByAdminId: item.CreatedByAdminID,
UpdatedByAdminId: item.UpdatedByAdminID,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
TaskId: item.TaskID,
TaskType: item.TaskType,
Category: item.Category,
MetricType: item.MetricType,
Title: item.Title,
Description: item.Description,
AudienceType: item.AudienceType,
IconKey: item.IconKey,
IconUrl: item.IconURL,
ActionType: item.ActionType,
ActionParam: item.ActionParam,
ActionPayloadJson: item.ActionPayloadJSON,
DimensionFilterJson: item.DimensionFilterJSON,
TargetValue: item.TargetValue,
TargetUnit: item.TargetUnit,
RewardCoinAmount: item.RewardCoinAmount,
RewardAssetType: item.RewardAssetType,
RewardType: item.RewardType,
RewardResourceId: item.RewardResourceID,
RewardResourceGroupId: item.RewardResourceGroupID,
RewardQuantity: item.RewardQuantity,
RewardDurationMs: item.RewardDurationMS,
RewardItems: rewardItemsToProto(item.RewardItemsJSON),
Status: item.Status,
SortOrder: item.SortOrder,
Version: item.Version,
EffectiveFromMs: item.EffectiveFromMS,
EffectiveToMs: item.EffectiveToMS,
CreatedByAdminId: item.CreatedByAdminID,
UpdatedByAdminId: item.UpdatedByAdminID,
CreatedAtMs: item.CreatedAtMS,
UpdatedAtMs: item.UpdatedAtMS,
}
}
func rewardItemsToProto(snapshotJSON string) []*activityv1.TaskRewardItem {
var items []taskdomain.RewardItem
if err := json.Unmarshal([]byte(snapshotJSON), &items); err != nil {
// 旧任务没有展示快照时返回空数组gateway/H5 会继续使用 reward_coin_amount 的兼容展示。
return []*activityv1.TaskRewardItem{}
}
result := make([]*activityv1.TaskRewardItem, 0, len(items))
for _, item := range items {
result = append(result, &activityv1.TaskRewardItem{
ItemType: item.ItemType,
ResourceId: item.ResourceID,
ResourceType: item.ResourceType,
CoverUrl: item.CoverURL,
Quantity: item.Quantity,
DurationMs: item.DurationMS,
WalletAssetType: item.WalletAssetType,
WalletAssetAmount: item.WalletAssetAmount,
})
}
return result
}

View File

@ -16,7 +16,7 @@ CREATE TABLE IF NOT EXISTS game_platforms (
callback_secret_ciphertext VARCHAR(1024) NOT NULL DEFAULT '' COMMENT '回调密钥密文',
callback_ip_whitelist JSON NULL COMMENT '回调IP白名单',
adapter_config JSON NULL COMMENT '适配器配置',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY(app_code, platform_code)
@ -37,7 +37,7 @@ CREATE TABLE IF NOT EXISTS game_catalog (
min_coin BIGINT NOT NULL DEFAULT 0 COMMENT '最小金币',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
whitelist_enabled TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否仅对白名单用户展示并允许启动',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
tags JSON NULL COMMENT '标签',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
@ -77,7 +77,7 @@ CREATE TABLE IF NOT EXISTS game_display_rules (
platform VARCHAR(16) NOT NULL DEFAULT '' COMMENT '平台',
visible TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可见',
enabled TINYINT(1) NOT NULL DEFAULT 1 COMMENT '启用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY(app_code, rule_id),

View File

@ -272,7 +272,7 @@ func NormalizeStakeOptions(options []StakeOption) []StakeOption {
if out[left].SortOrder == out[right].SortOrder {
return out[left].StakeCoin < out[right].StakeCoin
}
return out[left].SortOrder < out[right].SortOrder
return out[left].SortOrder > out[right].SortOrder
})
if len(out) == 0 {
return NormalizeStakeOptions(nil)

View File

@ -236,11 +236,11 @@ func (s *Service) GetBridgeScript(ctx context.Context, appCode string) (BridgeSc
if err != nil {
return BridgeScriptResult{}, err
}
// admin 会把通用桥接脚本同步写入所有平台;这里仍按 sort/code 排序后读取第一份有效配置,
// admin 会把通用桥接脚本同步写入所有平台;这里仍按“大 sort 优先、code 稳定兜底”读取第一份有效配置,
// 避免数据库返回顺序变化导致 App 在相同配置之间来回切换。
sort.SliceStable(platforms, func(left, right int) bool {
if platforms[left].SortOrder != platforms[right].SortOrder {
return platforms[left].SortOrder < platforms[right].SortOrder
return platforms[left].SortOrder > platforms[right].SortOrder
}
return strings.Compare(platforms[left].PlatformCode, platforms[right].PlatformCode) < 0
})

View File

@ -141,7 +141,7 @@ func TestGetBridgeScriptUsesFirstActivePlatformConfig(t *testing.T) {
if err != nil {
t.Fatalf("GetBridgeScript failed: %v", err)
}
if result.URL != "https://cdn.example.test/bridge.js" || result.Version != "20260608.1" || result.SHA256 != "abc" || result.ServerTimeMS != 1700000000000 {
if result.URL != "https://cdn.example.test/bridge-viva.js" || result.Version != "20260608.2" || result.SHA256 != "def" || result.ServerTimeMS != 1700000000000 {
t.Fatalf("bridge script mismatch: %+v", result)
}
if repo.lastListPlatformsAppCode != "lalu" || repo.lastListPlatformsStatus != gamedomain.StatusActive {

View File

@ -755,7 +755,8 @@ func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev
}
sortOrder := gift.GetSortOrder()
if sortOrder <= 0 {
sortOrder = int32(index + 1)
// 提交数组已经代表后台从上到下的顺序;大值优先后,越靠前的缺省档位必须获得越大的权重。
sortOrder = int32(len(config.GetStakeGifts()) - index)
}
if _, ok := seenSort[sortOrder]; ok {
return nil, xerr.New(xerr.InvalidArgument, "room rps gift sort_order is duplicated")
@ -795,7 +796,8 @@ func (s *Service) normalizeConfig(ctx context.Context, app string, config *gamev
normalized.GiftPriceCoin = int64(normalized.GetSortOrder()) * 100
}
}
sort.Slice(gifts, func(i, j int) bool { return gifts[i].GetSortOrder() < gifts[j].GetSortOrder() })
// 猜拳礼物同样遵循后台统一权重语义;档位身份由礼物和金额表达,不能再把小排序值当作优先级。
sort.Slice(gifts, func(i, j int) bool { return gifts[i].GetSortOrder() > gifts[j].GetSortOrder() })
return &gamev1.RoomRPSConfig{
AppCode: app,
GameId: GameID,

View File

@ -39,14 +39,14 @@ func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) {
t.Fatalf("UpdateConfig() error = %v", err)
}
if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Rose" || got.GetGiftIconUrl() != "https://cdn.example.test/rose.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/rose.mp4" || got.GetGiftPriceCoin() != 100 {
t.Fatalf("first gift was not enriched from wallet catalog: %+v", got)
if got := config.GetStakeGifts()[0]; got.GetGiftName() != "Cup" || got.GetGiftIconUrl() != "https://cdn.example.test/cup.png" || got.GetGiftPriceCoin() != 1000 || got.GetSortOrder() != 4 {
t.Fatalf("highest-sort gift was not enriched from wallet catalog: %+v", got)
}
if got := config.GetStakeGifts()[1]; got.GetGiftIconUrl() != "https://cdn.example.test/bell-preview.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/bell.svga" {
t.Fatalf("second gift should use preview_url as icon and animation_url as animation: %+v", got)
if got := config.GetStakeGifts()[2]; got.GetGiftIconUrl() != "https://cdn.example.test/bell-preview.png" || got.GetGiftAnimationUrl() != "https://cdn.example.test/bell.svga" {
t.Fatalf("sort-2 gift should use preview_url as icon and animation_url as animation: %+v", got)
}
if got := config.GetStakeGifts()[2]; got.GetGiftIconUrl() != "" || got.GetGiftAnimationUrl() != "https://cdn.example.test/crown.svga" {
t.Fatalf("third gift should not use animation_url as display icon: %+v", got)
if got := config.GetStakeGifts()[1]; got.GetGiftIconUrl() != "" || got.GetGiftAnimationUrl() != "https://cdn.example.test/crown.svga" {
t.Fatalf("sort-3 gift should not use animation_url as display icon: %+v", got)
}
if len(catalog.requests) != 4 {
t.Fatalf("expected one exact catalog lookup per missing gift, got %d", len(catalog.requests))
@ -54,7 +54,7 @@ func TestUpdateConfigEnrichesStakeGiftsFromWalletCatalog(t *testing.T) {
if req := catalog.requests[0]; req.GetAppCode() != "lalu" || req.GetKeyword() != "2001" || req.GetPage() != 1 || req.GetPageSize() != 100 {
t.Fatalf("catalog lookup request mismatch: %+v", req)
}
if got := config.GetStakeGifts()[0]; got.GetGiftIdText() != "2001" || got.GetGiftId() != 2001 {
if got := config.GetStakeGifts()[0]; got.GetGiftIdText() != "2004" || got.GetGiftId() != 2004 {
t.Fatalf("numeric gift should keep string primary ID and legacy number: %+v", got)
}
}
@ -244,7 +244,7 @@ func TestRoomRPSConfigPersistsAcrossServiceInstances(t *testing.T) {
if err != nil {
t.Fatalf("GetConfig() after restart error = %v", err)
}
if got := config.GetStakeGifts()[0]; got.GetGiftIdText() != "persist-1" || got.GetGiftPriceCoin() != 11 {
if got := config.GetStakeGifts()[0]; got.GetGiftIdText() != "persist-4" || got.GetGiftPriceCoin() != 44 {
t.Fatalf("persistent config was not reused after restart: %+v", got)
}
}

View File

@ -699,10 +699,11 @@ func isBuiltinSelfGameID(gameID string) bool {
func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQuery) ([]gamedomain.AppGame, error) {
// dice/rock 需要保留 catalog 行给专用匹配入口做可玩性校验,但 App 普通游戏列表只展示三方 H5 游戏,避免自研入口混进运营列表后被误删。
// 同一游戏命中多条区域/语言展示规则时取最大运营权重0 仍表示沿用目录权重,最终按大值优先返回。
rows, err := r.db.QueryContext(ctx,
`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status,
MIN(CASE WHEN r.sort_order = 0 THEN c.sort_order ELSE r.sort_order END) AS display_sort
MAX(CASE WHEN r.sort_order = 0 THEN c.sort_order ELSE r.sort_order END) AS display_sort
FROM game_catalog c
JOIN game_platforms p ON p.app_code = c.app_code AND p.platform_code = c.platform_code
JOIN game_display_rules r ON r.app_code = c.app_code AND r.game_id = c.game_id
@ -723,7 +724,7 @@ func (r *Repository) ListGames(ctx context.Context, query gameservice.ListGamesQ
))
GROUP BY c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status, c.sort_order, p.sort_order
ORDER BY display_sort ASC, p.sort_order ASC, c.sort_order ASC, c.game_id ASC`,
ORDER BY display_sort DESC, p.sort_order DESC, c.sort_order DESC, c.game_id ASC`,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform, query.UserID,
)
if err != nil {
@ -755,7 +756,7 @@ func (r *Repository) ListRecentGames(ctx context.Context, query gameservice.List
rows, err := r.db.QueryContext(ctx,
`SELECT c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status,
MIN(CASE WHEN r.sort_order = 0 THEN c.sort_order ELSE r.sort_order END) AS display_sort,
MAX(CASE WHEN r.sort_order = 0 THEN c.sort_order ELSE r.sort_order END) AS display_sort,
recent.last_played_at_ms
FROM (
SELECT game_id, MAX(created_at_ms) AS last_played_at_ms
@ -785,7 +786,7 @@ func (r *Repository) ListRecentGames(ctx context.Context, query gameservice.List
GROUP BY c.game_id, c.platform_code, c.game_name, c.icon_url, c.cover_url, c.category,
c.launch_mode, c.orientation, c.safe_height, c.min_coin, c.status, p.status,
c.sort_order, p.sort_order, recent.last_played_at_ms
ORDER BY recent.last_played_at_ms DESC, display_sort ASC, p.sort_order ASC, c.sort_order ASC, c.game_id ASC
ORDER BY recent.last_played_at_ms DESC, display_sort DESC, p.sort_order DESC, c.sort_order DESC, c.game_id ASC
LIMIT ?`,
appcode.Normalize(query.AppCode), query.UserID, query.Scene,
appcode.Normalize(query.AppCode), query.Scene, query.RegionID, query.Language, query.ClientPlatform, query.UserID, pageSize,
@ -1601,7 +1602,7 @@ func (r *Repository) ListPlatforms(ctx context.Context, appCode string, status s
query += " AND status = ?"
args = append(args, strings.TrimSpace(status))
}
query += " ORDER BY sort_order ASC, platform_code ASC"
query += " ORDER BY sort_order DESC, platform_code ASC"
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err
@ -1665,7 +1666,7 @@ func (r *Repository) ListCatalog(ctx context.Context, query gameservice.ListCata
sqlText += " AND status = ?"
args = append(args, strings.TrimSpace(query.Status))
}
sqlText += " ORDER BY sort_order ASC, game_id ASC"
sqlText += " ORDER BY sort_order DESC, game_id ASC"
if query.PageSize > 0 {
sqlText += " LIMIT ?"
args = append(args, query.PageSize)

View File

@ -49,7 +49,7 @@ const listExploreTabsSQL = `
SELECT id, app_code, tab, h5_url, enabled, sort_order, updated_at_ms
FROM admin_app_explore_tabs
WHERE app_code = ? AND enabled = TRUE
ORDER BY sort_order ASC, id DESC`
ORDER BY sort_order DESC, id DESC`
const listAppBannersSQL = `
SELECT id, app_code, cover_url, room_small_image_url, banner_type, display_scope, param, platform, sort_order, region_id, country_code, description, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_banners
@ -59,7 +59,7 @@ const listAppBannersSQL = `
AND (country_code = '' OR country_code = ?)
AND (starts_at_ms = 0 OR starts_at_ms <= ?)
AND (ends_at_ms = 0 OR ends_at_ms > ?)
ORDER BY sort_order ASC, id DESC`
ORDER BY sort_order DESC, id DESC`
const listSplashScreensSQL = `
SELECT id, app_code, cover_url, splash_type, param, platform, sort_order, region_id, country_code, description, display_duration_ms, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_splash_screens
@ -69,14 +69,14 @@ const listSplashScreensSQL = `
AND (country_code = '' OR country_code = ?)
AND (starts_at_ms = 0 OR starts_at_ms <= ?)
AND (ends_at_ms = 0 OR ends_at_ms > ?)
ORDER BY sort_order ASC, id DESC`
ORDER BY sort_order DESC, id DESC`
const listPopupsSQL = `
SELECT id, app_code, code, name, image_url, jump_type, jump_url, display_period_days, sort_order, starts_at_ms, ends_at_ms, updated_at_ms
FROM admin_app_popups
WHERE app_code = ? AND status = 'active'
AND (starts_at_ms = 0 OR starts_at_ms <= ?)
AND (ends_at_ms = 0 OR ends_at_ms > ?)
ORDER BY sort_order ASC, id DESC`
ORDER BY sort_order DESC, id DESC`
const latestAppVersionSQL = `
SELECT id, app_code, platform, version, build_number, force_update, download_url, COALESCE(description, ''), updated_at_ms
FROM admin_app_versions

View File

@ -18,45 +18,67 @@ type taskSectionData struct {
}
type taskItemData struct {
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
AudienceType string `json:"audience_type"`
IconKey string `json:"icon_key"`
IconURL string `json:"icon_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
ActionPayloadJSON string `json:"action_payload_json"`
ActionPayload map[string]any `json:"action_payload"`
DimensionFilterJSON string `json:"dimension_filter_json"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
ProgressValue int64 `json:"progress_value"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
RewardAssetType string `json:"reward_asset_type"`
Status string `json:"status"`
Claimable bool `json:"claimable"`
TaskDay string `json:"task_day"`
ServerTimeMS int64 `json:"server_time_ms"`
NextRefreshAtMS int64 `json:"next_refresh_at_ms"`
SortOrder int32 `json:"sort_order"`
Version int64 `json:"version"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
Category string `json:"category"`
MetricType string `json:"metric_type"`
Title string `json:"title"`
Description string `json:"description"`
AudienceType string `json:"audience_type"`
IconKey string `json:"icon_key"`
IconURL string `json:"icon_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
ActionPayloadJSON string `json:"action_payload_json"`
ActionPayload map[string]any `json:"action_payload"`
DimensionFilterJSON string `json:"dimension_filter_json"`
TargetValue int64 `json:"target_value"`
TargetUnit string `json:"target_unit"`
ProgressValue int64 `json:"progress_value"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
RewardAssetType string `json:"reward_asset_type"`
RewardType string `json:"reward_type"`
RewardResourceID int64 `json:"reward_resource_id"`
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
RewardQuantity int64 `json:"reward_quantity"`
RewardDurationMS int64 `json:"reward_duration_ms"`
RewardItems []taskRewardItemData `json:"reward_items"`
Status string `json:"status"`
Claimable bool `json:"claimable"`
TaskDay string `json:"task_day"`
ServerTimeMS int64 `json:"server_time_ms"`
NextRefreshAtMS int64 `json:"next_refresh_at_ms"`
SortOrder int32 `json:"sort_order"`
Version int64 `json:"version"`
}
type taskClaimData struct {
ClaimID string `json:"claim_id"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
TaskDay string `json:"task_day"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
RewardAssetType string `json:"reward_asset_type"`
Status string `json:"status"`
WalletTransactionID string `json:"wallet_transaction_id"`
GrantedAtMS int64 `json:"granted_at_ms"`
Claimed bool `json:"claimed"`
ClaimID string `json:"claim_id"`
TaskID string `json:"task_id"`
TaskType string `json:"task_type"`
TaskDay string `json:"task_day"`
RewardCoinAmount int64 `json:"reward_coin_amount"`
RewardAssetType string `json:"reward_asset_type"`
RewardType string `json:"reward_type"`
RewardResourceID int64 `json:"reward_resource_id"`
RewardResourceGroupID int64 `json:"reward_resource_group_id"`
RewardItems []taskRewardItemData `json:"reward_items"`
Status string `json:"status"`
WalletTransactionID string `json:"wallet_transaction_id"`
WalletResourceGrantID string `json:"wallet_resource_grant_id"`
GrantedAtMS int64 `json:"granted_at_ms"`
Claimed bool `json:"claimed"`
}
type taskRewardItemData struct {
ItemType string `json:"item_type"`
ResourceID int64 `json:"resource_id"`
ResourceType string `json:"resource_type"`
CoverURL string `json:"cover_url"`
Quantity int64 `json:"quantity"`
DurationMS int64 `json:"duration_ms"`
WalletAssetType string `json:"wallet_asset_type"`
WalletAssetAmount int64 `json:"wallet_asset_amount"`
}
type taskClaimRequestBody struct {
@ -164,51 +186,82 @@ func (h *Handler) claimTaskReward(writer http.ResponseWriter, request *http.Requ
return
}
httpkit.WriteOK(writer, request, taskClaimData{
ClaimID: resp.GetClaimId(),
TaskID: resp.GetTaskId(),
TaskType: resp.GetTaskType(),
TaskDay: resp.GetTaskDay(),
RewardCoinAmount: resp.GetRewardCoinAmount(),
RewardAssetType: resp.GetRewardAssetType(),
Status: resp.GetStatus(),
WalletTransactionID: resp.GetWalletTransactionId(),
GrantedAtMS: resp.GetGrantedAtMs(),
Claimed: resp.GetClaimed(),
ClaimID: resp.GetClaimId(),
TaskID: resp.GetTaskId(),
TaskType: resp.GetTaskType(),
TaskDay: resp.GetTaskDay(),
RewardCoinAmount: resp.GetRewardCoinAmount(),
RewardAssetType: resp.GetRewardAssetType(),
RewardType: resp.GetRewardType(),
RewardResourceID: resp.GetRewardResourceId(),
RewardResourceGroupID: resp.GetRewardResourceGroupId(),
RewardItems: taskRewardItemsFromProto(resp.GetRewardItems()),
Status: resp.GetStatus(),
WalletTransactionID: resp.GetWalletTransactionId(),
WalletResourceGrantID: resp.GetWalletResourceGrantId(),
GrantedAtMS: resp.GetGrantedAtMs(),
Claimed: resp.GetClaimed(),
})
}
func taskItemFromProto(item *activityv1.TaskItem) taskItemData {
// gateway 不重新计算任务状态,只把 activity-service 的进度、奖励、图标和跳转元数据转换为 App JSON 合约。
return taskItemData{
TaskID: item.GetTaskId(),
TaskType: item.GetTaskType(),
Category: item.GetCategory(),
MetricType: item.GetMetricType(),
Title: item.GetTitle(),
Description: item.GetDescription(),
AudienceType: item.GetAudienceType(),
IconKey: item.GetIconKey(),
IconURL: item.GetIconUrl(),
ActionType: item.GetActionType(),
ActionParam: item.GetActionParam(),
ActionPayloadJSON: item.GetActionPayloadJson(),
ActionPayload: taskJSONPayload(item.GetActionPayloadJson()),
DimensionFilterJSON: item.GetDimensionFilterJson(),
TargetValue: item.GetTargetValue(),
TargetUnit: item.GetTargetUnit(),
ProgressValue: item.GetProgressValue(),
RewardCoinAmount: item.GetRewardCoinAmount(),
RewardAssetType: item.GetRewardAssetType(),
Status: item.GetStatus(),
Claimable: item.GetClaimable(),
TaskDay: item.GetTaskDay(),
ServerTimeMS: item.GetServerTimeMs(),
NextRefreshAtMS: item.GetNextRefreshAtMs(),
SortOrder: item.GetSortOrder(),
Version: item.GetVersion(),
TaskID: item.GetTaskId(),
TaskType: item.GetTaskType(),
Category: item.GetCategory(),
MetricType: item.GetMetricType(),
Title: item.GetTitle(),
Description: item.GetDescription(),
AudienceType: item.GetAudienceType(),
IconKey: item.GetIconKey(),
IconURL: item.GetIconUrl(),
ActionType: item.GetActionType(),
ActionParam: item.GetActionParam(),
ActionPayloadJSON: item.GetActionPayloadJson(),
ActionPayload: taskJSONPayload(item.GetActionPayloadJson()),
DimensionFilterJSON: item.GetDimensionFilterJson(),
TargetValue: item.GetTargetValue(),
TargetUnit: item.GetTargetUnit(),
ProgressValue: item.GetProgressValue(),
RewardCoinAmount: item.GetRewardCoinAmount(),
RewardAssetType: item.GetRewardAssetType(),
RewardType: item.GetRewardType(),
RewardResourceID: item.GetRewardResourceId(),
RewardResourceGroupID: item.GetRewardResourceGroupId(),
RewardQuantity: item.GetRewardQuantity(),
RewardDurationMS: item.GetRewardDurationMs(),
RewardItems: taskRewardItemsFromProto(item.GetRewardItems()),
Status: item.GetStatus(),
Claimable: item.GetClaimable(),
TaskDay: item.GetTaskDay(),
ServerTimeMS: item.GetServerTimeMs(),
NextRefreshAtMS: item.GetNextRefreshAtMs(),
SortOrder: item.GetSortOrder(),
Version: item.GetVersion(),
}
}
func taskRewardItemsFromProto(items []*activityv1.TaskRewardItem) []taskRewardItemData {
result := make([]taskRewardItemData, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
result = append(result, taskRewardItemData{
ItemType: item.GetItemType(),
ResourceID: item.GetResourceId(),
ResourceType: item.GetResourceType(),
CoverURL: item.GetCoverUrl(),
Quantity: item.GetQuantity(),
DurationMS: item.GetDurationMs(),
WalletAssetType: item.GetWalletAssetType(),
WalletAssetAmount: item.GetWalletAssetAmount(),
})
}
return result
}
func taskJSONPayload(value string) map[string]any {
payload := map[string]any{}
if strings.TrimSpace(value) == "" {

View File

@ -383,7 +383,7 @@ CREATE TABLE IF NOT EXISTS countries (
phone_country_code VARCHAR(8) NULL COMMENT '电话国家编码',
flag VARCHAR(16) NOT NULL DEFAULT '' COMMENT '国旗',
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '启用',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_by_user_id BIGINT NULL COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NULL COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -401,7 +401,7 @@ CREATE TABLE IF NOT EXISTS regions (
region_code VARCHAR(64) NOT NULL COMMENT '区域编码',
name VARCHAR(128) NOT NULL COMMENT '名称',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_by_user_id BIGINT NULL COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NULL COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',

View File

@ -140,7 +140,7 @@ func (r *Repository) ListAvailablePrettyDisplayIDs(ctx context.Context, query us
AND pdi.source = ?
AND pool.status = ?
`+levelWhere+`
ORDER BY pool.sort_order ASC, pdi.created_at_ms ASC, pdi.pretty_id ASC
ORDER BY pool.sort_order DESC, pdi.created_at_ms ASC, pdi.pretty_id ASC
LIMIT ? OFFSET ?
`, selectArgs...)
if err != nil {
@ -696,7 +696,7 @@ func (r *Repository) ListPrettyDisplayIDPools(ctx context.Context, query userdom
created_by_admin_id, updated_by_admin_id, created_at_ms, updated_at_ms
FROM pretty_display_id_pools
`+whereSQL+`
ORDER BY sort_order ASC, created_at_ms DESC, pool_id ASC
ORDER BY sort_order DESC, created_at_ms DESC, pool_id ASC
LIMIT ? OFFSET ?
`, append(args, int(pageSize), int((page-1)*pageSize))...)
if err != nil {

View File

@ -91,7 +91,7 @@ func listCountries(ctx context.Context, q queryer, filter userdomain.CountryFilt
if len(conditions) > 0 {
query += " WHERE " + strings.Join(conditions, " AND ")
}
query += " ORDER BY sort_order ASC, country_code ASC"
query += " ORDER BY sort_order DESC, country_code ASC"
rows, err := queryRows(ctx, q, query, args...)
if err != nil {
return nil, err

View File

@ -121,7 +121,7 @@ func (r *Repository) ListRegions(ctx context.Context, filter userdomain.RegionFi
args = append(args, userdomain.GlobalRegionCode)
conditions = append(conditions, "region_code NOT IN ("+retiredRegionCodeSQLList+")")
query += " WHERE " + strings.Join(conditions, " AND ")
query += " ORDER BY sort_order ASC, region_code ASC"
query += " ORDER BY sort_order DESC, region_code ASC"
rows, err := r.db.QueryContext(ctx, query, args...)
if err != nil {
return nil, err

View File

@ -286,7 +286,7 @@ CREATE TABLE IF NOT EXISTS wallet_policy_instances (
CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
seller_user_id BIGINT NOT NULL COMMENT '币商用户 ID',
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序,越越靠前',
point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母',
seller_coin_amount BIGINT NOT NULL COMMENT '兑换比例中的币商金币分子',
service_country_codes JSON NOT NULL COMMENT '服务国家码数组;空数组表示不限国家',
@ -909,7 +909,7 @@ CREATE TABLE IF NOT EXISTS wallet_recharge_products (
resource_asset_type VARCHAR(32) NOT NULL DEFAULT 'COIN' COMMENT '资源资产类型',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
app_visible BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否在 App 普通充值列表展示;不影响 Google Play 验单',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_by_user_id BIGINT NULL COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NULL COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -1294,7 +1294,7 @@ CREATE TABLE IF NOT EXISTS resources (
preview_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '预览 URL',
animation_url VARCHAR(512) NOT NULL DEFAULT '' COMMENT '动画 URL',
metadata_json JSON NULL COMMENT '扩展元数据 JSON',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -1357,7 +1357,7 @@ CREATE TABLE IF NOT EXISTS resource_groups (
name VARCHAR(128) NOT NULL COMMENT '名称',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
description VARCHAR(512) NOT NULL DEFAULT '' COMMENT '描述信息',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -1376,7 +1376,7 @@ CREATE TABLE IF NOT EXISTS resource_group_items (
wallet_asset_amount BIGINT NOT NULL DEFAULT 0 COMMENT '钱包资产数量',
quantity BIGINT NOT NULL DEFAULT 1 COMMENT '数量',
duration_ms BIGINT NOT NULL DEFAULT 0 COMMENT '持续时长,毫秒',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_resource_group_items_member (app_code, group_id, item_type, resource_id, wallet_asset_type),
@ -1410,7 +1410,7 @@ CREATE TABLE IF NOT EXISTS resource_shop_items (
coin_price BIGINT NOT NULL DEFAULT 0 COMMENT '该售卖天数的独立金币价格;未自定义时复制资源列表价格',
effective_from_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效开始时间UTC epoch ms0 表示立即生效',
effective_to_ms BIGINT NOT NULL DEFAULT 0 COMMENT '生效结束时间UTC epoch ms0 表示长期有效',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -1449,7 +1449,7 @@ CREATE TABLE IF NOT EXISTS gift_type_configs (
name VARCHAR(64) NOT NULL COMMENT '后台展示名称',
tab_key VARCHAR(32) NOT NULL COMMENT 'App 礼物面板 tab 展示 key',
status VARCHAR(32) NOT NULL DEFAULT 'active' COMMENT 'tab 状态active 才在 App 面板展示',
sort_order INT NOT NULL DEFAULT 0 COMMENT 'tab 排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT 'tab 排序权重,数值越越靠前',
created_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '创建人用户 ID',
updated_by_user_id BIGINT NOT NULL DEFAULT 0 COMMENT '更新人用户 ID',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
@ -1466,7 +1466,7 @@ CREATE TABLE IF NOT EXISTS gift_configs (
resource_id BIGINT NOT NULL COMMENT '资源 ID',
status VARCHAR(32) NOT NULL COMMENT '业务状态',
name VARCHAR(128) NOT NULL COMMENT '名称',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
presentation_json JSON NULL COMMENT '展示配置 JSON 配置或快照',
gift_type_code VARCHAR(32) NOT NULL DEFAULT 'normal' COMMENT '礼物类型编码',
vip_level INT NOT NULL DEFAULT 0 COMMENT 'VIP/贵族礼物对应的 VIP 等级,非 VIP 礼物为 0',
@ -1614,7 +1614,7 @@ CREATE TABLE IF NOT EXISTS user_gift_wall (
last_price_version VARCHAR(64) NOT NULL DEFAULT '' COMMENT '最后价格版本',
first_received_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '首次收到时间UTC epoch ms',
last_received_at_ms BIGINT NOT NULL DEFAULT 0 COMMENT '最后收到时间UTC epoch ms',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, user_id, gift_id),
@ -1738,7 +1738,7 @@ CREATE TABLE IF NOT EXISTS wallet_diamond_exchange_rules (
to_asset_type VARCHAR(32) NOT NULL COMMENT '目标资产类型',
from_amount BIGINT NOT NULL COMMENT '源数量',
to_amount BIGINT NOT NULL COMMENT '目标数量',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
UNIQUE KEY uk_diamond_exchange_rule (app_code, exchange_type, from_amount, to_asset_type),
@ -1929,7 +1929,7 @@ CREATE TABLE IF NOT EXISTS vip_levels (
duration_ms BIGINT NOT NULL COMMENT '持续时长,毫秒',
reward_resource_group_id BIGINT NOT NULL DEFAULT 0 COMMENT '奖励资源分组 ID',
required_recharge_coin_amount BIGINT NOT NULL DEFAULT 0 COMMENT '兼容旧 VIP 累充门槛字段,当前不参与购买资格',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序权重,数值越越靠前',
created_at_ms BIGINT NOT NULL COMMENT '创建时间UTC epoch ms',
updated_at_ms BIGINT NOT NULL COMMENT '更新时间UTC epoch ms',
PRIMARY KEY (app_code, level),

View File

@ -80,7 +80,7 @@ func CanonicalResourceGroupJSON(group ResourceGroup) ([]byte, error) {
}
sort.SliceStable(canonical.Items, func(left, right int) bool {
if canonical.Items[left].SortOrder != canonical.Items[right].SortOrder {
return canonical.Items[left].SortOrder < canonical.Items[right].SortOrder
return canonical.Items[left].SortOrder > canonical.Items[right].SortOrder
}
return canonical.Items[left].GroupItemID < canonical.Items[right].GroupItemID
})

View File

@ -14,21 +14,20 @@ type v5PayProductSyncCountry struct {
CountryCode string
CountryName string
CurrencyCode string
SortBase int32
}
var v5PayProductSyncCountries = []v5PayProductSyncCountry{
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP", SortBase: 2000},
{CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR", SortBase: 2300},
{CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP", SortBase: 2400},
{CountryCode: "AE", CountryName: "United Arab Emirates", CurrencyCode: "AED", SortBase: 2500},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR", SortBase: 2600},
{CountryCode: "IN", CountryName: "India", CurrencyCode: "INR", SortBase: 2800},
{CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR", SortBase: 2900},
{CountryCode: "TR", CountryName: "Turkey", CurrencyCode: "TRY", SortBase: 3000},
{CountryCode: "MX", CountryName: "Mexico", CurrencyCode: "MXN", SortBase: 3100},
{CountryCode: "VN", CountryName: "Vietnam", CurrencyCode: "VND", SortBase: 3200},
{CountryCode: "BR", CountryName: "Brazil", CurrencyCode: "BRL", SortBase: 3300},
{CountryCode: "PH", CountryName: "Philippines", CurrencyCode: "PHP"},
{CountryCode: "SA", CountryName: "Saudi Arabia", CurrencyCode: "SAR"},
{CountryCode: "EG", CountryName: "Egypt", CurrencyCode: "EGP"},
{CountryCode: "AE", CountryName: "United Arab Emirates", CurrencyCode: "AED"},
{CountryCode: "ID", CountryName: "Indonesia", CurrencyCode: "IDR"},
{CountryCode: "IN", CountryName: "India", CurrencyCode: "INR"},
{CountryCode: "PK", CountryName: "Pakistan", CurrencyCode: "PKR"},
{CountryCode: "TR", CountryName: "Turkey", CurrencyCode: "TRY"},
{CountryCode: "MX", CountryName: "Mexico", CurrencyCode: "MXN"},
{CountryCode: "VN", CountryName: "Vietnam", CurrencyCode: "VND"},
{CountryCode: "BR", CountryName: "Brazil", CurrencyCode: "BRL"},
}
// SyncThirdPartyPaymentMethods 从支付商实时拉取已开通方式并落本地配置表;当前只有 V5Pay 提供 productTypes 能力。
@ -76,7 +75,7 @@ func (s *Service) fetchV5PayPaymentMethodsForSync(ctx context.Context) ([]ledger
issues := make([]ledger.ThirdPartyPaymentMethodSyncIssue, 0)
skipped := 0
seen := map[string]struct{}{}
for _, country := range v5PayProductSyncCountries {
for countryIndex, country := range v5PayProductSyncCountries {
// V5Pay 的 productTypes 接口必须逐国家/币种查询,没有“全部国家”接口;候选集覆盖当前种子国家和后台已开通截图里的新增国家。
products, err := s.v5Pay.ListProductTypes(ctx, V5PayProductTypesRequest{
CountryCode: country.CountryCode,
@ -86,8 +85,10 @@ func (s *Service) fetchV5PayPaymentMethodsForSync(ctx context.Context) ([]ledger
issues = append(issues, v5PayProductSyncIssue(country, err))
continue
}
// 国家数组与支付商产品数组都是默认展示顺序;分桶后从高到低赋权,确保同步不会反转任一层级。
countrySortBase := int32((len(v5PayProductSyncCountries) - countryIndex) * 1000)
for index, product := range products.PayinList {
method, ok := v5PayProductToPaymentMethod(country, product, index)
method, ok := v5PayProductToPaymentMethod(country, product, countrySortBase, index, len(products.PayinList))
if !ok {
skipped++
continue
@ -104,7 +105,7 @@ func (s *Service) fetchV5PayPaymentMethodsForSync(ctx context.Context) ([]ledger
return methods, skipped, issues
}
func v5PayProductToPaymentMethod(country v5PayProductSyncCountry, product V5PayProductType, index int) (ledger.ThirdPartyPaymentMethod, bool) {
func v5PayProductToPaymentMethod(country v5PayProductSyncCountry, product V5PayProductType, countrySortBase int32, index int, total int) (ledger.ThirdPartyPaymentMethod, bool) {
productType := strings.ToUpper(strings.TrimSpace(product.ProductType))
currency := strings.ToUpper(strings.TrimSpace(product.Currency))
if currency == "" {
@ -128,7 +129,7 @@ func v5PayProductToPaymentMethod(country v5PayProductSyncCountry, product V5PayP
LogoURL: strings.TrimSpace(product.Logo),
Status: ledger.ThirdPartyPaymentStatusActive,
USDToCurrencyRate: "0",
SortOrder: country.SortBase + int32(index+1)*10,
SortOrder: countrySortBase + int32(total-index)*10,
}, true
}

View File

@ -6,6 +6,7 @@ import (
"strings"
"hyapp/pkg/appcode"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
"hyapp/services/wallet-service/internal/domain/ledger"
resourcedomain "hyapp/services/wallet-service/internal/domain/resource"
@ -39,6 +40,11 @@ func (s *Service) CreateResource(ctx context.Context, command resourcedomain.Res
return resourcedomain.Resource{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
// 资源编码是系统业务键,不再要求后台运营手工维护;仅在创建请求未提供编码时生成,
// 以兼容批量导入、初始化脚本等仍需指定稳定编码的内部调用方。
if strings.TrimSpace(command.ResourceCode) == "" {
command.ResourceCode = idgen.New("resource")
}
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.CreateResource(ctx, command)
}
@ -104,6 +110,11 @@ func (s *Service) CreateResourceGroup(ctx context.Context, command resourcedomai
return resourcedomain.ResourceGroup{}, xerr.New(xerr.Unavailable, "wallet repository is not configured")
}
command.AppCode = appcode.Normalize(command.AppCode)
// 资源组编码由 wallet owner 生成,随机熵与数据库唯一键共同保证并发创建安全;
// 显式编码仍保留给初始化和跨租户映射流程使用。
if strings.TrimSpace(command.GroupCode) == "" {
command.GroupCode = idgen.New("resource_group")
}
ctx = appcode.WithContext(ctx, command.AppCode)
return s.repository.CreateResourceGroup(ctx, command)
}

View File

@ -357,7 +357,7 @@ func (r *Repository) ListCoinSellerSalaryExchangeRateTiers(ctx context.Context,
SELECT region_id, min_usd_minor, max_usd_minor, coin_per_usd, status, sort_order, updated_at_ms
FROM coin_seller_salary_exchange_rate_tiers
WHERE app_code = ? AND region_id = ? %s
ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC`, statusClause)
ORDER BY sort_order DESC, min_usd_minor ASC, tier_id ASC`, statusClause)
rows, err := r.db.QueryContext(ctx, query, appcode.FromContext(ctx), regionID)
if err != nil {
return nil, err

View File

@ -27,8 +27,9 @@ func (r *Repository) ListGiftConfigs(ctx context.Context, query resourcedomain.L
`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
// 后台列表与 App 礼物面板共用此顺序,必须按大排序值优先,避免两端看到不同礼物次序。
rows, err := r.db.QueryContext(ctx, giftConfigSelectSQL()+where+`
ORDER BY gc.sort_order ASC, gc.created_at_ms DESC
ORDER BY gc.sort_order DESC, gc.created_at_ms DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
)

View File

@ -357,7 +357,7 @@ func (r *Repository) resolveCoinSellerSalaryExchangeRateTier(ctx context.Context
FROM coin_seller_salary_exchange_rate_tiers
WHERE app_code = ? AND region_id = ? AND status = 'active'
AND min_usd_minor <= ? AND max_usd_minor >= ?
ORDER BY sort_order ASC, min_usd_minor ASC, tier_id ASC
ORDER BY sort_order DESC, min_usd_minor ASC, tier_id ASC
LIMIT 1
FOR UPDATE`,
appcode.FromContext(ctx),

View File

@ -24,7 +24,7 @@ func (r *Repository) ListGiftTypeConfigs(ctx context.Context, query resourcedoma
query = normalizeGiftTypeConfigListQuery(query)
where, args := giftTypeConfigWhereSQL(query)
rows, err := r.db.QueryContext(ctx, giftTypeConfigSelectSQL()+where+`
ORDER BY sort_order ASC, type_code ASC`, args...)
ORDER BY sort_order DESC, type_code ASC`, args...)
if err != nil {
return nil, err
}

View File

@ -34,7 +34,7 @@ func (r *Repository) ListThirdPartyPaymentChannels(ctx context.Context, query le
SELECT app_code, provider_code, provider_name, status, sort_order
FROM third_party_payment_channels
`+where+`
ORDER BY sort_order ASC, provider_code ASC`, args...)
ORDER BY sort_order DESC, provider_code ASC`, args...)
if err != nil {
return nil, err
}
@ -247,7 +247,7 @@ func (r *Repository) listH5ThirdPartyPaymentMethods(ctx context.Context, appCode
}
rows, err := r.db.QueryContext(ctx, thirdPartyPaymentMethodSelectSQL()+`
`+where+`
ORDER BY c.sort_order ASC, CASE WHEN m.country_code = 'GLOBAL' THEN 1 ELSE 0 END ASC, m.sort_order ASC, m.method_id ASC`, args...)
ORDER BY c.sort_order DESC, CASE WHEN m.country_code = 'GLOBAL' THEN 1 ELSE 0 END ASC, m.sort_order DESC, m.method_id ASC`, args...)
if err != nil {
return nil, err
}
@ -278,7 +278,7 @@ func (r *Repository) listThirdPartyPaymentMethods(ctx context.Context, appCode s
}
rows, err := r.db.QueryContext(ctx, thirdPartyPaymentMethodSelectSQL()+`
`+where+`
ORDER BY CASE WHEN m.country_code = 'GLOBAL' THEN 1 ELSE 0 END ASC, m.sort_order ASC, m.method_id ASC`, args...)
ORDER BY CASE WHEN m.country_code = 'GLOBAL' THEN 1 ELSE 0 END ASC, m.sort_order DESC, m.method_id ASC`, args...)
if err != nil {
return nil, err
}

View File

@ -55,7 +55,7 @@ func (r *Repository) ListPointWithdrawalCoinSellers(ctx context.Context, appCode
status, created_at_ms, updated_at_ms
FROM point_withdrawal_coin_seller_configs
WHERE `+where+`
ORDER BY sort_order ASC, seller_user_id ASC`, args...)
ORDER BY sort_order DESC, seller_user_id ASC`, args...)
if err != nil {
return nil, err
}

View File

@ -141,7 +141,7 @@ func (r *Repository) listRedPacketCountTiers(ctx context.Context, querier redPac
SELECT packet_count
FROM red_packet_count_tiers
WHERE app_code = ? AND status = 'active'
ORDER BY sort_order ASC, packet_count ASC`,
ORDER BY sort_order DESC, packet_count ASC`,
appcode.FromContext(ctx),
)
if err != nil {
@ -164,7 +164,7 @@ func (r *Repository) listRedPacketAmountTiers(ctx context.Context, querier redPa
SELECT total_amount
FROM red_packet_amount_tiers
WHERE app_code = ? AND status = 'active'
ORDER BY sort_order ASC, total_amount ASC`,
ORDER BY sort_order DESC, total_amount ASC`,
appcode.FromContext(ctx),
)
if err != nil {

View File

@ -37,9 +37,10 @@ func (r *Repository) ListResources(ctx context.Context, query resourcedomain.Lis
if err != nil {
return nil, 0, err
}
// sort_order 是运营展示权重统一按大值优先resource_id 只负责同权重下的稳定分页。
rows, err := r.db.QueryContext(ctx, resourceSelectSQL()+`
`+where+`
ORDER BY sort_order ASC, resource_id DESC
ORDER BY sort_order DESC, resource_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
)

View File

@ -31,7 +31,7 @@ func (r *Repository) ListResourceGroups(ctx context.Context, query resourcedomai
created_by_user_id, updated_by_user_id, created_at_ms, updated_at_ms
FROM resource_groups
`+where+`
ORDER BY sort_order ASC, group_id DESC
ORDER BY sort_order DESC, group_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
)
@ -292,7 +292,7 @@ func (r *Repository) listResourceGroupItemsWithQuery(ctx context.Context, querie
FROM resource_group_items i
LEFT JOIN resources r ON r.app_code = i.app_code AND r.resource_id = i.resource_id
WHERE i.app_code = ? AND i.group_id = ?
ORDER BY i.sort_order ASC, i.group_item_id ASC`
ORDER BY i.sort_order DESC, i.group_item_id ASC`
rows, err := querier.QueryContext(ctx, query, appcode.FromContext(ctx), groupID)
if err != nil {
return nil, err
@ -314,7 +314,7 @@ func (r *Repository) lockResourceGroupItemRows(ctx context.Context, tx *sql.Tx,
SELECT group_item_id
FROM resource_group_items
WHERE app_code = ? AND group_id = ?
ORDER BY sort_order ASC, group_item_id ASC
ORDER BY sort_order DESC, group_item_id ASC
FOR UPDATE`,
appcode.FromContext(ctx), groupID,
)

View File

@ -33,8 +33,9 @@ func (r *Repository) ListResourceShopItems(ctx context.Context, query resourcedo
`+where, args...).Scan(&total); err != nil {
return nil, 0, err
}
// 商店配置和 App 购买列表共用同一权重语义sort_order 越大越靠前。
rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+where+`
ORDER BY si.sort_order ASC, si.shop_item_id DESC
ORDER BY si.sort_order DESC, si.shop_item_id DESC
LIMIT ? OFFSET ?`,
append(args, query.PageSize, resourceOffset(query.Page, query.PageSize))...,
)
@ -594,7 +595,7 @@ func (r *Repository) listResourceShopItemsByResourceIDs(ctx context.Context, res
args := append([]any{appcode.FromContext(ctx)}, int64AnyArgs(resourceIDs)...)
rows, err := r.db.QueryContext(ctx, resourceShopItemSelectSQL()+`
WHERE si.app_code = ? AND si.resource_id IN (`+placeholders(len(resourceIDs))+`)
ORDER BY si.sort_order ASC, si.shop_item_id DESC`,
ORDER BY si.sort_order DESC, si.shop_item_id DESC`,
args...,
)
if err != nil {

View File

@ -558,7 +558,7 @@ func ensurePointWithdrawalCoinSellerSchema(ctx context.Context, db *sql.DB) erro
CREATE TABLE IF NOT EXISTS point_withdrawal_coin_seller_configs (
app_code VARCHAR(32) NOT NULL COMMENT '应用编码',
seller_user_id BIGINT NOT NULL COMMENT '币商用户 ID',
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序越靠前',
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序越靠前',
point_amount BIGINT NOT NULL COMMENT '兑换比例中的 POINT 分母',
seller_coin_amount BIGINT NOT NULL COMMENT '兑换比例中的币商金币分子',
service_country_codes JSON NOT NULL COMMENT '服务国家码数组空数组表示不限国家',

View File

@ -525,7 +525,7 @@ func (r *Repository) listVipLevelBenefits(ctx context.Context, querier vipBenefi
query += ` AND b.status = ?`
args = append(args, ledger.VipStatusActive)
}
query += ` ORDER BY b.sort_order ASC, b.benefit_code ASC`
query += ` ORDER BY b.sort_order DESC, b.benefit_code ASC`
rows, err := querier.QueryContext(ctx, query, args...)
if err != nil {
return nil, err

View File

@ -113,7 +113,7 @@ func (r *Repository) listVipLevelsFromSnapshot(ctx context.Context, querier vipS
required_recharge_coin_amount, sort_order, created_at_ms, updated_at_ms
FROM vip_levels
WHERE app_code = ? AND status = ? AND level BETWEEN 1 AND ?
ORDER BY sort_order ASC, level ASC`,
ORDER BY sort_order DESC, level ASC`,
appcode.FromContext(ctx), ledger.VipStatusActive, config.LevelCount,
)
if err != nil {

View File

@ -63,7 +63,7 @@ func (r *Repository) GetUserGiftWall(ctx context.Context, userID int64) (ledger.
charge_asset_type, last_price_version, first_received_at_ms, last_received_at_ms, sort_order
FROM user_gift_wall
WHERE app_code = ? AND user_id = ?
ORDER BY sort_order ASC, total_value DESC, last_received_at_ms DESC, gift_id ASC`,
ORDER BY sort_order DESC, total_value DESC, last_received_at_ms DESC, gift_id ASC`,
appCode, userID,
)
if err != nil {