通用消息
This commit is contained in:
parent
c169dfb5e7
commit
8518a7a2b1
File diff suppressed because it is too large
Load Diff
@ -74,6 +74,8 @@ message InboxMessage {
|
|||||||
repeated string image_urls = 12;
|
repeated string image_urls = 12;
|
||||||
// reward_items 是活动发奖的有序结构化快照;客户端不解析 body/metadata_json 猜测数量和有效天数。
|
// reward_items 是活动发奖的有序结构化快照;客户端不解析 body/metadata_json 猜测数量和有效天数。
|
||||||
repeated InboxRewardItem reward_items = 13;
|
repeated InboxRewardItem reward_items = 13;
|
||||||
|
// actions 最多两个;每个按钮的文案、样式、API 和参数均由后端快照提供。
|
||||||
|
repeated InboxAction actions = 14;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListInboxMessagesRequest 按 section 和游标分页读取当前用户 inbox。
|
// ListInboxMessagesRequest 按 section 和游标分页读取当前用户 inbox。
|
||||||
@ -159,6 +161,8 @@ message CreateInboxMessageRequest {
|
|||||||
repeated string image_urls = 22;
|
repeated string image_urls = 22;
|
||||||
// reward_items 与 image_urls 一样由 inbox owner 写入受控 metadata,避免扩表并保持强类型输出。
|
// reward_items 与 image_urls 一样由 inbox owner 写入受控 metadata,避免扩表并保持强类型输出。
|
||||||
repeated InboxRewardItem reward_items = 23;
|
repeated InboxRewardItem reward_items = 23;
|
||||||
|
// actions 由 inbox owner 校验并写入 metadata JSON;客户端只消费强类型输出。
|
||||||
|
repeated InboxAction actions = 24;
|
||||||
}
|
}
|
||||||
|
|
||||||
// CreateInboxMessageResponse 返回入箱结果;重复同一 producer_event_id 时 created=false。
|
// CreateInboxMessageResponse 返回入箱结果;重复同一 producer_event_id 时 created=false。
|
||||||
@ -3027,6 +3031,23 @@ message InboxRewardItem {
|
|||||||
int64 duration_days = 5;
|
int64 duration_days = 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InboxActionAPI 是后端下发的同源 App API 请求;Flutter 只执行相对 /api/v1 路径,
|
||||||
|
// 不接收 headers、host 或自定义 scheme,避免通知内容绕过统一鉴权和租户请求头。
|
||||||
|
message InboxActionAPI {
|
||||||
|
string method = 1;
|
||||||
|
string path = 2;
|
||||||
|
string query_json = 3;
|
||||||
|
string body_json = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboxAction 与 C2C im_confirm.actions 保持相同的按钮展示语义;label 完全由后端提供。
|
||||||
|
message InboxAction {
|
||||||
|
string action = 1;
|
||||||
|
string label = 2;
|
||||||
|
string style = 3;
|
||||||
|
InboxActionAPI api = 4;
|
||||||
|
}
|
||||||
|
|
||||||
// ActivityService 只暴露同步查询,不把事件消费伪装成 RPC。
|
// ActivityService 只暴露同步查询,不把事件消费伪装成 RPC。
|
||||||
service ActivityService {
|
service ActivityService {
|
||||||
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
|
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);
|
||||||
|
|||||||
@ -1162,7 +1162,7 @@ PATCH:
|
|||||||
- `GET /api/v1/vip/coin-rebates/current`:查当前 UTC 日资格。
|
- `GET /api/v1/vip/coin-rebates/current`:查当前 UTC 日资格。
|
||||||
- `POST /api/v1/vip/coin-rebates/statuses`:批量恢复系统消息中的真实状态,或分页查历史。
|
- `POST /api/v1/vip/coin-rebates/statuses`:批量恢复系统消息中的真实状态,或分页查历史。
|
||||||
- `POST /api/v1/vip/coin-rebates/{rebate_id}/claim`:手动领取。
|
- `POST /api/v1/vip/coin-rebates/{rebate_id}/claim`:手动领取。
|
||||||
- `GET /api/v1/messages?section=system&page_size=20&page_token=`:查“VIP金币返现”系统消息。
|
- `GET /api/v1/messages?section=system&page_size=20&page_token=`:查 “VIP Coin Cashback” 系统消息。
|
||||||
|
|
||||||
### 参数
|
### 参数
|
||||||
|
|
||||||
@ -1312,9 +1312,9 @@ claim:
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message_id": "msg_vip_rebate_xxx",
|
"message_id": "msg_vip_rebate_xxx",
|
||||||
"title": "VIP金币返现",
|
"title": "VIP Coin Cashback",
|
||||||
"summary": "尊敬的VIP用户,平台今日返现100金币,请及时领取。",
|
"summary": "Your daily VIP cashback of 100 coins is ready. Claim it before it expires.",
|
||||||
"body": "尊敬的VIP用户,平台今日返现100金币,请及时领取。",
|
"body": "Your daily VIP cashback of 100 coins is ready. Claim it before it expires.",
|
||||||
"icon_url": "",
|
"icon_url": "",
|
||||||
"image_url": "",
|
"image_url": "",
|
||||||
"action_type": "vip_coin_rebate_claim",
|
"action_type": "vip_coin_rebate_claim",
|
||||||
|
|||||||
@ -110,7 +110,7 @@ Fami 的 VIP1–VIP9 初始全部为 `disabled`。30 天只是不产生零时长
|
|||||||
1. 首个成功批次固化当时的 `config_version` 和等级金额矩阵;后续分页不重读新配置。
|
1. 首个成功批次固化当时的 `config_version` 和等级金额矩阵;后续分页不重读新配置。
|
||||||
2. wallet 根据会员历史还原 UTC 0 点时的付费 VIP;0 点后才购买或升级的状态从次日起参与。
|
2. wallet 根据会员历史还原 UTC 0 点时的付费 VIP;0 点后才购买或升级的状态从次日起参与。
|
||||||
3. 生成 `vip_daily_coin_rebates` 资格,领取窗口固定为 `[UTC 当日0点,UTC 次日0点)`。
|
3. 生成 `vip_daily_coin_rebates` 资格,领取窗口固定为 `[UTC 当日0点,UTC 次日0点)`。
|
||||||
4. 同事务写 `VipDailyCoinRebateAvailable` outbox;activity-service 幂等投影为“VIP金币返现”系统消息。
|
4. 同事务写 `VipDailyCoinRebateAvailable` outbox;activity-service 幂等投影为 “VIP Coin Cashback” 系统消息。
|
||||||
5. 用户手动领取时,返现行锁、COIN 余额、交易、分录和 `WalletBalanceChanged` outbox 在一个事务内提交。
|
5. 用户手动领取时,返现行锁、COIN 余额、交易、分录和 `WalletBalanceChanged` outbox 在一个事务内提交。
|
||||||
|
|
||||||
“日切资格”严格按 UTC 0 点;“金额配置”是当日首个成功 run 的快照,不伪造系统尚未保存的 0 点历史配置。正常情况下 cron 每分钟触发;如零点后 wallet/cron 长时间不可用,恢复前的后台金额修改会进入当日快照。运营若要保证修改次日生效,应在当日 run 已创建后修改。
|
“日切资格”严格按 UTC 0 点;“金额配置”是当日首个成功 run 的快照,不伪造系统尚未保存的 0 点历史配置。正常情况下 cron 每分钟触发;如零点后 wallet/cron 长时间不可用,恢复前的后台金额修改会进入当日快照。运营若要保证修改次日生效,应在当日 run 已创建后修改。
|
||||||
|
|||||||
@ -1334,9 +1334,9 @@ Flutter 使用 `event_id` 去重,直接使用事件内的用户、VIP 和头
|
|||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message_id": "msg_vip_rebate_xxx",
|
"message_id": "msg_vip_rebate_xxx",
|
||||||
"title": "VIP金币返现",
|
"title": "VIP Coin Cashback",
|
||||||
"summary": "尊敬的VIP用户,平台今日返现100金币,请及时领取。",
|
"summary": "Your daily VIP cashback of 100 coins is ready. Claim it before it expires.",
|
||||||
"body": "尊敬的VIP用户,平台今日返现100金币,请及时领取。",
|
"body": "Your daily VIP cashback of 100 coins is ready. Claim it before it expires.",
|
||||||
"icon_url": "",
|
"icon_url": "",
|
||||||
"image_url": "",
|
"image_url": "",
|
||||||
"action_type": "vip_coin_rebate_claim",
|
"action_type": "vip_coin_rebate_claim",
|
||||||
|
|||||||
@ -44,30 +44,45 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fanoutRequest struct {
|
type fanoutRequest struct {
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
MessageType string `json:"message_type"`
|
MessageType string `json:"message_type"`
|
||||||
TargetScope string `json:"target_scope"`
|
TargetScope string `json:"target_scope"`
|
||||||
TargetUserID int64 `json:"target_user_id"`
|
TargetUserID int64 `json:"target_user_id"`
|
||||||
UserIDs int64List `json:"user_ids"`
|
UserIDs int64List `json:"user_ids"`
|
||||||
RegionID int64 `json:"region_id"`
|
RegionID int64 `json:"region_id"`
|
||||||
Country string `json:"country"`
|
Country string `json:"country"`
|
||||||
ProducerEventType string `json:"producer_event_type"`
|
ProducerEventType string `json:"producer_event_type"`
|
||||||
AggregateType string `json:"aggregate_type"`
|
AggregateType string `json:"aggregate_type"`
|
||||||
AggregateID string `json:"aggregate_id"`
|
AggregateID string `json:"aggregate_id"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
TemplateVersion string `json:"template_version"`
|
TemplateVersion string `json:"template_version"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
IconURL string `json:"icon_url"`
|
IconURL string `json:"icon_url"`
|
||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
ActionType string `json:"action_type"`
|
Actions []inboxActionRequest `json:"actions"`
|
||||||
ActionParam string `json:"action_param"`
|
ActionType string `json:"action_type"`
|
||||||
Priority int32 `json:"priority"`
|
ActionParam string `json:"action_param"`
|
||||||
SentAtMS int64 `json:"sent_at_ms"`
|
Priority int32 `json:"priority"`
|
||||||
ExpireAtMS int64 `json:"expire_at_ms"`
|
SentAtMS int64 `json:"sent_at_ms"`
|
||||||
MetadataJSON string `json:"metadata_json"`
|
ExpireAtMS int64 `json:"expire_at_ms"`
|
||||||
BatchSize int32 `json:"batch_size"`
|
MetadataJSON string `json:"metadata_json"`
|
||||||
|
BatchSize int32 `json:"batch_size"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inboxActionRequest struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
API inboxActionAPIRequest `json:"api"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inboxActionAPIRequest struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Query map[string]any `json:"query,omitempty"`
|
||||||
|
Body any `json:"body,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type fanoutDTO struct {
|
type fanoutDTO struct {
|
||||||
@ -168,6 +183,13 @@ func normalizeFanoutRequest(req fanoutRequest, adminID int64) (fanoutRequest, er
|
|||||||
req.Body = strings.TrimSpace(req.Body)
|
req.Body = strings.TrimSpace(req.Body)
|
||||||
req.IconURL = strings.TrimSpace(req.IconURL)
|
req.IconURL = strings.TrimSpace(req.IconURL)
|
||||||
req.ImageURL = strings.TrimSpace(req.ImageURL)
|
req.ImageURL = strings.TrimSpace(req.ImageURL)
|
||||||
|
for index := range req.Actions {
|
||||||
|
req.Actions[index].Action = strings.ToLower(strings.TrimSpace(req.Actions[index].Action))
|
||||||
|
req.Actions[index].Label = strings.TrimSpace(req.Actions[index].Label)
|
||||||
|
req.Actions[index].Style = strings.ToLower(strings.TrimSpace(req.Actions[index].Style))
|
||||||
|
req.Actions[index].API.Method = strings.ToUpper(strings.TrimSpace(req.Actions[index].API.Method))
|
||||||
|
req.Actions[index].API.Path = strings.TrimSpace(req.Actions[index].API.Path)
|
||||||
|
}
|
||||||
req.ActionType = strings.TrimSpace(req.ActionType)
|
req.ActionType = strings.TrimSpace(req.ActionType)
|
||||||
req.ActionParam = strings.TrimSpace(req.ActionParam)
|
req.ActionParam = strings.TrimSpace(req.ActionParam)
|
||||||
req.ProducerEventType = firstText(req.ProducerEventType, defaultProducerEventType)
|
req.ProducerEventType = firstText(req.ProducerEventType, defaultProducerEventType)
|
||||||
@ -247,6 +269,7 @@ func buildTemplateSnapshotJSON(req fanoutRequest) (string, error) {
|
|||||||
"body": req.Body,
|
"body": req.Body,
|
||||||
"icon_url": req.IconURL,
|
"icon_url": req.IconURL,
|
||||||
"image_url": req.ImageURL,
|
"image_url": req.ImageURL,
|
||||||
|
"actions": req.Actions,
|
||||||
"action_type": req.ActionType,
|
"action_type": req.ActionType,
|
||||||
"action_param": req.ActionParam,
|
"action_param": req.ActionParam,
|
||||||
"priority": req.Priority,
|
"priority": req.Priority,
|
||||||
|
|||||||
@ -7,11 +7,21 @@ import (
|
|||||||
|
|
||||||
func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
|
func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
|
||||||
req, err := normalizeFanoutRequest(fanoutRequest{
|
req, err := normalizeFanoutRequest(fanoutRequest{
|
||||||
MessageType: "activity",
|
MessageType: "activity",
|
||||||
TargetScope: "user_ids",
|
TargetScope: "user_ids",
|
||||||
UserIDs: int64List{42, 43, 42, 0},
|
UserIDs: int64List{42, 43, 42, 0},
|
||||||
Title: "活动上线",
|
Title: "活动上线",
|
||||||
Summary: "新活动已上线。",
|
Summary: "新活动已上线。",
|
||||||
|
Actions: []inboxActionRequest{{
|
||||||
|
Action: " JOIN ",
|
||||||
|
Label: " Join now ",
|
||||||
|
Style: " PRIMARY ",
|
||||||
|
API: inboxActionAPIRequest{
|
||||||
|
Method: " post ",
|
||||||
|
Path: " /api/v1/activities/act_01/join ",
|
||||||
|
Body: map[string]any{"campaign_id": "act_01"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
ActionType: "activity_detail",
|
ActionType: "activity_detail",
|
||||||
ActionParam: "activity_id=act_01",
|
ActionParam: "activity_id=act_01",
|
||||||
MetadataJSON: `{"source":"admin"}`,
|
MetadataJSON: `{"source":"admin"}`,
|
||||||
@ -31,7 +41,7 @@ func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("buildTemplateSnapshotJSON failed: %v", err)
|
t.Fatalf("buildTemplateSnapshotJSON failed: %v", err)
|
||||||
}
|
}
|
||||||
for _, required := range []string{`"title":"活动上线"`, `"action_type":"activity_detail"`, `"metadata_json":{"source":"admin"}`} {
|
for _, required := range []string{`"title":"活动上线"`, `"action":"join"`, `"label":"Join now"`, `"method":"POST"`, `"path":"/api/v1/activities/act_01/join"`, `"action_type":"activity_detail"`, `"metadata_json":{"source":"admin"}`} {
|
||||||
if !strings.Contains(templateSnapshotJSON, required) {
|
if !strings.Contains(templateSnapshotJSON, required) {
|
||||||
t.Fatalf("template snapshot %s missing %s", templateSnapshotJSON, required)
|
t.Fatalf("template snapshot %s missing %s", templateSnapshotJSON, required)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,13 +3,13 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"hyapp/pkg/appcode"
|
"hyapp/pkg/appcode"
|
||||||
"hyapp/pkg/walletmq"
|
"hyapp/pkg/walletmq"
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
|
messagedomain "hyapp/services/activity-service/internal/domain/message"
|
||||||
messageservice "hyapp/services/activity-service/internal/service/message"
|
messageservice "hyapp/services/activity-service/internal/service/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -85,30 +85,21 @@ func createVIPRebateSystemNotice(ctx context.Context, service *messageservice.Se
|
|||||||
if service == nil {
|
if service == nil {
|
||||||
return xerr.New(xerr.Unavailable, "message service is not configured")
|
return xerr.New(xerr.Unavailable, "message service is not configured")
|
||||||
}
|
}
|
||||||
actionParam, err := json.Marshal(map[string]any{
|
// 返现资格尚未入账,英文文案必须表达“可领取”而不是“已到账”,避免客户端展示与钱包事实冲突。
|
||||||
"rebate_id": event.RebateID,
|
title, summary := messageservice.VIPDailyCoinRebateNoticeCopy(event.CoinAmount)
|
||||||
"task_day": event.TaskDay,
|
_, err := service.CreateSystemNotice(ctx, messageservice.NoticeCommand{
|
||||||
"coin_amount": event.CoinAmount,
|
|
||||||
"expires_at_ms": event.ExpiresAtMS,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
summary := fmt.Sprintf("尊敬的VIP用户,平台今日返现%d金币,请及时领取。", event.CoinAmount)
|
|
||||||
_, err = service.CreateSystemNotice(ctx, messageservice.NoticeCommand{
|
|
||||||
TargetUserID: event.UserID,
|
TargetUserID: event.UserID,
|
||||||
Producer: "wallet-service",
|
Producer: "wallet-service",
|
||||||
ProducerEventID: event.EventID,
|
ProducerEventID: event.EventID,
|
||||||
ProducerEventType: vipDailyCoinRebateAvailableEventType,
|
ProducerEventType: vipDailyCoinRebateAvailableEventType,
|
||||||
AggregateType: "vip_daily_coin_rebate",
|
AggregateType: "vip_daily_coin_rebate",
|
||||||
AggregateID: event.RebateID,
|
AggregateID: event.RebateID,
|
||||||
TemplateID: "vip_daily_coin_rebate_available",
|
TemplateID: messageservice.VIPDailyCoinRebateNoticeTemplateID,
|
||||||
TemplateVersion: "v1",
|
TemplateVersion: messageservice.VIPDailyCoinRebateNoticeTemplateVersion,
|
||||||
Title: "VIP金币返现",
|
Title: title,
|
||||||
Summary: summary,
|
Summary: summary,
|
||||||
Body: summary,
|
Body: summary,
|
||||||
ActionType: "vip_coin_rebate_claim",
|
Actions: []messagedomain.InboxAction{messageservice.VIPDailyCoinRebateNoticeAction(event.RebateID)},
|
||||||
ActionParam: string(actionParam),
|
|
||||||
Priority: 50,
|
Priority: 50,
|
||||||
SentAtMS: event.AvailableAtMS,
|
SentAtMS: event.AvailableAtMS,
|
||||||
ExpireAtMS: event.ExpiresAtMS,
|
ExpireAtMS: event.ExpiresAtMS,
|
||||||
|
|||||||
@ -65,9 +65,30 @@ func TestVIPRebateWalletEventCreatesOneIdempotentSystemNotice(t *testing.T) {
|
|||||||
t.Fatalf("system inbox mismatch: items=%+v err=%v", items, err)
|
t.Fatalf("system inbox mismatch: items=%+v err=%v", items, err)
|
||||||
}
|
}
|
||||||
item := items[0]
|
item := items[0]
|
||||||
if item.Title != "VIP金币返现" || item.ActionType != "vip_coin_rebate_claim" || item.AggregateID != event.RebateID || item.ExpireAtMS != event.ExpiresAtMS || item.ActionParam == "" {
|
wantSummary := "Your daily VIP cashback of 100000 coins is ready. Claim it before it expires."
|
||||||
|
if item.Title != "VIP Coin Cashback" || item.Summary != wantSummary || item.Body != wantSummary || item.TemplateVersion != "v2" || item.AggregateID != event.RebateID || item.ExpireAtMS != event.ExpiresAtMS || len(item.Actions) != 1 {
|
||||||
t.Fatalf("VIP rebate notice mismatch: %+v", item)
|
t.Fatalf("VIP rebate notice mismatch: %+v", item)
|
||||||
}
|
}
|
||||||
|
action := item.Actions[0]
|
||||||
|
if action.Action != "claim" || action.Label != "Claim" || action.Style != "primary" || action.API.Method != "POST" || action.API.Path != "/api/v1/vip/coin-rebates/viprebate_fami_2027-01-15_42/claim" || action.API.Body == nil {
|
||||||
|
t.Fatalf("VIP rebate action mismatch: %+v", action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当天部署前已经落库的 v1 中文消息仍在有效窗口内;列表读取必须立即投影为同一英文文案。
|
||||||
|
legacyActionParam := `{"coin_amount":88,"rebate_id":"viprebate_fami_2027-01-15_43"}`
|
||||||
|
if _, err := service.CreateSystemNotice(ctx, messageservice.NoticeCommand{
|
||||||
|
TargetUserID: 43, Producer: "wallet-service", ProducerEventID: "wev-vip-rebate-legacy",
|
||||||
|
TemplateID: "vip_daily_coin_rebate_available", TemplateVersion: "v1",
|
||||||
|
Title: "VIP金币返现", Summary: "尊敬的VIP用户,平台今日返现88金币,请及时领取。",
|
||||||
|
Body: "尊敬的VIP用户,平台今日返现88金币,请及时领取。", ActionType: "vip_coin_rebate_claim",
|
||||||
|
ActionParam: legacyActionParam, SentAtMS: event.AvailableAtMS, ExpireAtMS: event.ExpiresAtMS,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("create legacy system notice failed: %v", err)
|
||||||
|
}
|
||||||
|
legacyItems, _, err := service.ListInboxMessages(ctx, 43, "system", 20, "")
|
||||||
|
if err != nil || len(legacyItems) != 1 || legacyItems[0].Title != "VIP Coin Cashback" || legacyItems[0].Summary != "Your daily VIP cashback of 88 coins is ready. Claim it before it expires." || legacyItems[0].Body != legacyItems[0].Summary || legacyItems[0].ActionType != "" || len(legacyItems[0].Actions) != 1 || legacyItems[0].Actions[0].Label != "Claim" {
|
||||||
|
t.Fatalf("legacy VIP rebate notice must return English copy: items=%+v err=%v", legacyItems, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func vipRebateWalletMessage(t *testing.T, eventType string, userID int64, payload string) []byte {
|
func vipRebateWalletMessage(t *testing.T, eventType string, userID int64, payload string) []byte {
|
||||||
|
|||||||
@ -48,6 +48,22 @@ type RewardItem struct {
|
|||||||
DurationDays int64 `json:"duration_days,omitempty"`
|
DurationDays int64 `json:"duration_days,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// InboxActionAPI 只描述 gateway 同源 API;headers 和绝对 URL 不进入消息事实。
|
||||||
|
type InboxActionAPI struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Query map[string]any `json:"query,omitempty"`
|
||||||
|
Body any `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// InboxAction 是 system/activity 卡片的通用操作按钮快照,展示文案完全由生产者提供。
|
||||||
|
type InboxAction struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
API InboxActionAPI `json:"api"`
|
||||||
|
}
|
||||||
|
|
||||||
// InboxMessage is the user-visible snapshot stored in user_inbox_messages.
|
// InboxMessage is the user-visible snapshot stored in user_inbox_messages.
|
||||||
type InboxMessage struct {
|
type InboxMessage struct {
|
||||||
AppCode string
|
AppCode string
|
||||||
@ -69,7 +85,9 @@ type InboxMessage struct {
|
|||||||
// ImageURLs 是一张通知卡片内的有序图片列表;ImageURL 保留首图兼容旧客户端。
|
// ImageURLs 是一张通知卡片内的有序图片列表;ImageURL 保留首图兼容旧客户端。
|
||||||
ImageURLs []string
|
ImageURLs []string
|
||||||
// RewardItems 从 metadata_json 还原为强类型响应;历史消息没有该字段时保持空列表。
|
// RewardItems 从 metadata_json 还原为强类型响应;历史消息没有该字段时保持空列表。
|
||||||
RewardItems []RewardItem
|
RewardItems []RewardItem
|
||||||
|
// Actions 与图片/奖励一样存入 metadata_json,避免为展示协议扩表。
|
||||||
|
Actions []InboxAction
|
||||||
ActionType string
|
ActionType string
|
||||||
ActionParam string
|
ActionParam string
|
||||||
Priority int32
|
Priority int32
|
||||||
@ -137,7 +155,9 @@ type CreateInput struct {
|
|||||||
// ImageURLs 会由 service 写入受控 metadata 快照,MySQL 继续使用现有 JSON 列持久化。
|
// ImageURLs 会由 service 写入受控 metadata 快照,MySQL 继续使用现有 JSON 列持久化。
|
||||||
ImageURLs []string
|
ImageURLs []string
|
||||||
// RewardItems 与发奖事实一起固化,防止资源改名、换图或改时长后历史消息漂移。
|
// RewardItems 与发奖事实一起固化,防止资源改名、换图或改时长后历史消息漂移。
|
||||||
RewardItems []RewardItem
|
RewardItems []RewardItem
|
||||||
|
// Actions 最多两个,owner service 会校验按钮文案、样式和同源 API。
|
||||||
|
Actions []InboxAction
|
||||||
ActionType string
|
ActionType string
|
||||||
ActionParam string
|
ActionParam string
|
||||||
Priority int32
|
Priority int32
|
||||||
|
|||||||
@ -40,22 +40,23 @@ type fanoutTargetFilter struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type fanoutTemplateSnapshot struct {
|
type fanoutTemplateSnapshot struct {
|
||||||
ProducerEventType string `json:"producer_event_type"`
|
ProducerEventType string `json:"producer_event_type"`
|
||||||
AggregateType string `json:"aggregate_type"`
|
AggregateType string `json:"aggregate_type"`
|
||||||
AggregateID string `json:"aggregate_id"`
|
AggregateID string `json:"aggregate_id"`
|
||||||
TemplateID string `json:"template_id"`
|
TemplateID string `json:"template_id"`
|
||||||
TemplateVersion string `json:"template_version"`
|
TemplateVersion string `json:"template_version"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Summary string `json:"summary"`
|
Summary string `json:"summary"`
|
||||||
Body string `json:"body"`
|
Body string `json:"body"`
|
||||||
IconURL string `json:"icon_url"`
|
IconURL string `json:"icon_url"`
|
||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
ActionType string `json:"action_type"`
|
Actions []messagedomain.InboxAction `json:"actions"`
|
||||||
ActionParam string `json:"action_param"`
|
ActionType string `json:"action_type"`
|
||||||
Priority int32 `json:"priority"`
|
ActionParam string `json:"action_param"`
|
||||||
SentAtMS int64 `json:"sent_at_ms"`
|
Priority int32 `json:"priority"`
|
||||||
ExpireAtMS int64 `json:"expire_at_ms"`
|
SentAtMS int64 `json:"sent_at_ms"`
|
||||||
MetadataJSON json.RawMessage `json:"metadata_json"`
|
ExpireAtMS int64 `json:"expire_at_ms"`
|
||||||
|
MetadataJSON json.RawMessage `json:"metadata_json"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessNextFanoutJob claims one job, processes one target page, then commits progress.
|
// ProcessNextFanoutJob claims one job, processes one target page, then commits progress.
|
||||||
@ -125,6 +126,7 @@ func (s *Service) processFanoutJobPage(ctx context.Context, job messagedomain.Fa
|
|||||||
Body: snapshot.Body,
|
Body: snapshot.Body,
|
||||||
IconURL: snapshot.IconURL,
|
IconURL: snapshot.IconURL,
|
||||||
ImageURL: snapshot.ImageURL,
|
ImageURL: snapshot.ImageURL,
|
||||||
|
Actions: snapshot.Actions,
|
||||||
ActionType: snapshot.ActionType,
|
ActionType: snapshot.ActionType,
|
||||||
ActionParam: snapshot.ActionParam,
|
ActionParam: snapshot.ActionParam,
|
||||||
Priority: snapshot.Priority,
|
Priority: snapshot.Priority,
|
||||||
|
|||||||
@ -3,13 +3,21 @@ package message
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"hyapp/pkg/xerr"
|
"hyapp/pkg/xerr"
|
||||||
messagedomain "hyapp/services/activity-service/internal/domain/message"
|
messagedomain "hyapp/services/activity-service/internal/domain/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
const defaultNoticeProducer = "activity-service"
|
const (
|
||||||
|
defaultNoticeProducer = "activity-service"
|
||||||
|
|
||||||
|
VIPDailyCoinRebateNoticeTemplateID = "vip_daily_coin_rebate_available"
|
||||||
|
VIPDailyCoinRebateNoticeTemplateVersion = "v2"
|
||||||
|
VIPDailyCoinRebateNoticeTitle = "VIP Coin Cashback"
|
||||||
|
)
|
||||||
|
|
||||||
// NoticeCommand is the common producer-facing command for one user-visible system/activity message.
|
// NoticeCommand is the common producer-facing command for one user-visible system/activity message.
|
||||||
type NoticeCommand struct {
|
type NoticeCommand struct {
|
||||||
@ -28,6 +36,7 @@ type NoticeCommand struct {
|
|||||||
ImageURL string
|
ImageURL string
|
||||||
ImageURLs []string
|
ImageURLs []string
|
||||||
RewardItems []messagedomain.RewardItem
|
RewardItems []messagedomain.RewardItem
|
||||||
|
Actions []messagedomain.InboxAction
|
||||||
ActionType string
|
ActionType string
|
||||||
ActionParam string
|
ActionParam string
|
||||||
Priority int32
|
Priority int32
|
||||||
@ -43,6 +52,54 @@ type NoticeResult struct {
|
|||||||
Created bool
|
Created bool
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// VIPDailyCoinRebateNoticeCopy 使用钱包金额事实生成统一英文快照;金额缺失时仍返回英文兜底,
|
||||||
|
// 供读取历史 v1 中文消息时关闭旧文案泄漏,而不伪造未知的返现金额。
|
||||||
|
func VIPDailyCoinRebateNoticeCopy(coinAmount int64) (string, string) {
|
||||||
|
if coinAmount <= 0 {
|
||||||
|
return VIPDailyCoinRebateNoticeTitle, "Your daily VIP cashback is ready. Claim it before it expires."
|
||||||
|
}
|
||||||
|
return VIPDailyCoinRebateNoticeTitle, fmt.Sprintf("Your daily VIP cashback of %d coins is ready. Claim it before it expires.", coinAmount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeVIPDailyCoinRebateNoticeCopy keeps already-persisted v1 inbox snapshots compatible with
|
||||||
|
// the English-only client contract. The rebate ID and amount still come from the immutable wallet event.
|
||||||
|
func normalizeVIPDailyCoinRebateNoticeCopy(message messagedomain.InboxMessage) messagedomain.InboxMessage {
|
||||||
|
if message.TemplateID != VIPDailyCoinRebateNoticeTemplateID || message.ActionType != "vip_coin_rebate_claim" {
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
var actionParam struct {
|
||||||
|
CoinAmount int64 `json:"coin_amount"`
|
||||||
|
RebateID string `json:"rebate_id"`
|
||||||
|
}
|
||||||
|
_ = json.Unmarshal([]byte(message.ActionParam), &actionParam)
|
||||||
|
message.Title, message.Summary = VIPDailyCoinRebateNoticeCopy(actionParam.CoinAmount)
|
||||||
|
message.Body = message.Summary
|
||||||
|
if strings.TrimSpace(actionParam.RebateID) != "" {
|
||||||
|
message.Actions = []messagedomain.InboxAction{VIPDailyCoinRebateNoticeAction(actionParam.RebateID)}
|
||||||
|
message.ActionType = ""
|
||||||
|
message.ActionParam = ""
|
||||||
|
}
|
||||||
|
return message
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIPDailyCoinRebateNoticeAction 把 VIP 返现映射成普通 inbox 操作;Flutter 不识别 VIP,
|
||||||
|
// 只展示后端 label 并执行同源 API。稳定 command_id 让超时重试仍命中钱包幂等结果。
|
||||||
|
func VIPDailyCoinRebateNoticeAction(rebateID string) messagedomain.InboxAction {
|
||||||
|
rebateID = strings.TrimSpace(rebateID)
|
||||||
|
return messagedomain.InboxAction{
|
||||||
|
Action: "claim",
|
||||||
|
Label: "Claim",
|
||||||
|
Style: "primary",
|
||||||
|
API: messagedomain.InboxActionAPI{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/v1/vip/coin-rebates/" + url.PathEscape(rebateID) + "/claim",
|
||||||
|
Body: map[string]any{
|
||||||
|
"command_id": "vip_rebate_claim_" + rebateID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// NoticeFanoutCommand is the common command for background system/activity broadcasts.
|
// NoticeFanoutCommand is the common command for background system/activity broadcasts.
|
||||||
type NoticeFanoutCommand struct {
|
type NoticeFanoutCommand struct {
|
||||||
CommandID string
|
CommandID string
|
||||||
@ -62,6 +119,7 @@ type NoticeFanoutCommand struct {
|
|||||||
Body string
|
Body string
|
||||||
IconURL string
|
IconURL string
|
||||||
ImageURL string
|
ImageURL string
|
||||||
|
Actions []messagedomain.InboxAction
|
||||||
ActionType string
|
ActionType string
|
||||||
ActionParam string
|
ActionParam string
|
||||||
Priority int32
|
Priority int32
|
||||||
@ -108,6 +166,7 @@ func (s *Service) CreateNotice(ctx context.Context, messageType string, cmd Noti
|
|||||||
ImageURL: cmd.ImageURL,
|
ImageURL: cmd.ImageURL,
|
||||||
ImageURLs: cmd.ImageURLs,
|
ImageURLs: cmd.ImageURLs,
|
||||||
RewardItems: cmd.RewardItems,
|
RewardItems: cmd.RewardItems,
|
||||||
|
Actions: cmd.Actions,
|
||||||
ActionType: cmd.ActionType,
|
ActionType: cmd.ActionType,
|
||||||
ActionParam: cmd.ActionParam,
|
ActionParam: cmd.ActionParam,
|
||||||
Priority: cmd.Priority,
|
Priority: cmd.Priority,
|
||||||
@ -202,6 +261,7 @@ func noticeTemplateSnapshotJSON(cmd NoticeFanoutCommand) (string, error) {
|
|||||||
"body": strings.TrimSpace(cmd.Body),
|
"body": strings.TrimSpace(cmd.Body),
|
||||||
"icon_url": strings.TrimSpace(cmd.IconURL),
|
"icon_url": strings.TrimSpace(cmd.IconURL),
|
||||||
"image_url": strings.TrimSpace(cmd.ImageURL),
|
"image_url": strings.TrimSpace(cmd.ImageURL),
|
||||||
|
"actions": cmd.Actions,
|
||||||
"action_type": strings.TrimSpace(cmd.ActionType),
|
"action_type": strings.TrimSpace(cmd.ActionType),
|
||||||
"action_param": strings.TrimSpace(cmd.ActionParam),
|
"action_param": strings.TrimSpace(cmd.ActionParam),
|
||||||
"priority": cmd.Priority,
|
"priority": cmd.Priority,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"net/url"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@ -149,6 +150,9 @@ func (s *Service) ListInboxMessages(ctx context.Context, userID int64, section s
|
|||||||
for index := range messages {
|
for index := range messages {
|
||||||
messages[index].ImageURLs = inboxImageURLs(messages[index].MetadataJSON, messages[index].ImageURL)
|
messages[index].ImageURLs = inboxImageURLs(messages[index].MetadataJSON, messages[index].ImageURL)
|
||||||
messages[index].RewardItems = inboxRewardItems(messages[index].MetadataJSON)
|
messages[index].RewardItems = inboxRewardItems(messages[index].MetadataJSON)
|
||||||
|
messages[index].Actions = inboxActions(messages[index].MetadataJSON)
|
||||||
|
// 旧返现消息没有 actions;读取投影补齐英文文案和后端驱动按钮,不回写历史钱包事实。
|
||||||
|
messages[index] = normalizeVIPDailyCoinRebateNoticeCopy(messages[index])
|
||||||
}
|
}
|
||||||
if len(messages) <= limit {
|
if len(messages) <= limit {
|
||||||
return messages, "", nil
|
return messages, "", nil
|
||||||
@ -226,6 +230,7 @@ func (s *Service) CreateInboxMessage(ctx context.Context, input messagedomain.Cr
|
|||||||
ImageURL: input.ImageURL,
|
ImageURL: input.ImageURL,
|
||||||
ImageURLs: append([]string(nil), input.ImageURLs...),
|
ImageURLs: append([]string(nil), input.ImageURLs...),
|
||||||
RewardItems: append([]messagedomain.RewardItem(nil), input.RewardItems...),
|
RewardItems: append([]messagedomain.RewardItem(nil), input.RewardItems...),
|
||||||
|
Actions: append([]messagedomain.InboxAction(nil), input.Actions...),
|
||||||
ActionType: input.ActionType,
|
ActionType: input.ActionType,
|
||||||
ActionParam: input.ActionParam,
|
ActionParam: input.ActionParam,
|
||||||
Priority: input.Priority,
|
Priority: input.Priority,
|
||||||
@ -246,6 +251,7 @@ func (s *Service) CreateInboxMessage(ctx context.Context, input messagedomain.Cr
|
|||||||
}
|
}
|
||||||
existing.ImageURLs = inboxImageURLs(existing.MetadataJSON, existing.ImageURL)
|
existing.ImageURLs = inboxImageURLs(existing.MetadataJSON, existing.ImageURL)
|
||||||
existing.RewardItems = inboxRewardItems(existing.MetadataJSON)
|
existing.RewardItems = inboxRewardItems(existing.MetadataJSON)
|
||||||
|
existing.Actions = inboxActions(existing.MetadataJSON)
|
||||||
return existing, false, nil
|
return existing, false, nil
|
||||||
}
|
}
|
||||||
return existing, true, nil
|
return existing, true, nil
|
||||||
@ -367,11 +373,12 @@ func normalizeCreateInput(input messagedomain.CreateInput) messagedomain.CreateI
|
|||||||
input.ImageURL = strings.TrimSpace(input.ImageURL)
|
input.ImageURL = strings.TrimSpace(input.ImageURL)
|
||||||
input.ImageURLs = normalizeInboxImageURLs(input.ImageURLs, input.ImageURL)
|
input.ImageURLs = normalizeInboxImageURLs(input.ImageURLs, input.ImageURL)
|
||||||
input.RewardItems = normalizeInboxRewardItems(input.RewardItems)
|
input.RewardItems = normalizeInboxRewardItems(input.RewardItems)
|
||||||
|
input.Actions = normalizeInboxActions(input.Actions)
|
||||||
if input.ImageURL == "" && len(input.ImageURLs) > 0 {
|
if input.ImageURL == "" && len(input.ImageURLs) > 0 {
|
||||||
input.ImageURL = input.ImageURLs[0]
|
input.ImageURL = input.ImageURLs[0]
|
||||||
}
|
}
|
||||||
// 图片和奖励项都写入 inbox owner 的受控 metadata 快照,避免扩表,同时对客户端仍输出强类型字段。
|
// 图片、奖励和操作按钮都写入 inbox owner 的受控 metadata 快照,避免扩表,同时输出强类型字段。
|
||||||
input.MetadataJSON = inboxMetadataWithPresentation(input.MetadataJSON, input.ImageURLs, input.RewardItems)
|
input.MetadataJSON = inboxMetadataWithPresentation(input.MetadataJSON, input.ImageURLs, input.RewardItems, input.Actions)
|
||||||
input.ActionType = strings.TrimSpace(input.ActionType)
|
input.ActionType = strings.TrimSpace(input.ActionType)
|
||||||
input.ActionParam = strings.TrimSpace(input.ActionParam)
|
input.ActionParam = strings.TrimSpace(input.ActionParam)
|
||||||
input.MetadataJSON = strings.TrimSpace(input.MetadataJSON)
|
input.MetadataJSON = strings.TrimSpace(input.MetadataJSON)
|
||||||
@ -431,6 +438,9 @@ func validateCreateInput(input messagedomain.CreateInput) error {
|
|||||||
return xerr.New(xerr.InvalidArgument, "reward_items values cannot be negative")
|
return xerr.New(xerr.InvalidArgument, "reward_items values cannot be negative")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if err := validateInboxActions(input.Actions); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
if input.Title == "" || input.Summary == "" {
|
if input.Title == "" || input.Summary == "" {
|
||||||
return xerr.New(xerr.InvalidArgument, "title and summary are required")
|
return xerr.New(xerr.InvalidArgument, "title and summary are required")
|
||||||
}
|
}
|
||||||
@ -449,6 +459,9 @@ func validateCreateInput(input messagedomain.CreateInput) error {
|
|||||||
if len(input.RewardItems) > 0 && !slices.Equal(inboxRewardItems(input.MetadataJSON), input.RewardItems) {
|
if len(input.RewardItems) > 0 && !slices.Equal(inboxRewardItems(input.MetadataJSON), input.RewardItems) {
|
||||||
return xerr.New(xerr.InvalidArgument, "metadata_json must be an object when reward_items is present")
|
return xerr.New(xerr.InvalidArgument, "metadata_json must be an object when reward_items is present")
|
||||||
}
|
}
|
||||||
|
if len(input.Actions) > 0 && !sameJSONValue(inboxActions(input.MetadataJSON), input.Actions) {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "metadata_json must be an object when actions are present")
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -478,6 +491,15 @@ func validateFanoutInput(input messagedomain.CreateFanoutInput) error {
|
|||||||
if input.TemplateSnapshotJSON == "" || !json.Valid([]byte(input.TemplateSnapshotJSON)) {
|
if input.TemplateSnapshotJSON == "" || !json.Valid([]byte(input.TemplateSnapshotJSON)) {
|
||||||
return xerr.New(xerr.InvalidArgument, "template_snapshot_json is invalid")
|
return xerr.New(xerr.InvalidArgument, "template_snapshot_json is invalid")
|
||||||
}
|
}
|
||||||
|
var snapshot struct {
|
||||||
|
Actions []messagedomain.InboxAction `json:"actions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(input.TemplateSnapshotJSON), &snapshot); err != nil {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "template_snapshot_json is invalid")
|
||||||
|
}
|
||||||
|
if err := validateInboxActions(normalizeInboxActions(snapshot.Actions)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -545,6 +567,8 @@ func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.In
|
|||||||
next.ImageURLs = inboxImageURLs(next.MetadataJSON, next.ImageURL)
|
next.ImageURLs = inboxImageURLs(next.MetadataJSON, next.ImageURL)
|
||||||
existing.RewardItems = inboxRewardItems(existing.MetadataJSON)
|
existing.RewardItems = inboxRewardItems(existing.MetadataJSON)
|
||||||
next.RewardItems = inboxRewardItems(next.MetadataJSON)
|
next.RewardItems = inboxRewardItems(next.MetadataJSON)
|
||||||
|
existing.Actions = inboxActions(existing.MetadataJSON)
|
||||||
|
next.Actions = inboxActions(next.MetadataJSON)
|
||||||
return existing.UserID == next.UserID &&
|
return existing.UserID == next.UserID &&
|
||||||
existing.MessageType == next.MessageType &&
|
existing.MessageType == next.MessageType &&
|
||||||
existing.Producer == next.Producer &&
|
existing.Producer == next.Producer &&
|
||||||
@ -584,7 +608,7 @@ func normalizeInboxImageURLs(values []string, legacyImageURL string) []string {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func inboxMetadataWithPresentation(raw string, imageURLs []string, rewardItems []messagedomain.RewardItem) string {
|
func inboxMetadataWithPresentation(raw string, imageURLs []string, rewardItems []messagedomain.RewardItem, actions []messagedomain.InboxAction) string {
|
||||||
raw = strings.TrimSpace(raw)
|
raw = strings.TrimSpace(raw)
|
||||||
metadata := map[string]any{}
|
metadata := map[string]any{}
|
||||||
if raw != "" {
|
if raw != "" {
|
||||||
@ -606,6 +630,11 @@ func inboxMetadataWithPresentation(raw string, imageURLs []string, rewardItems [
|
|||||||
} else {
|
} else {
|
||||||
metadata["reward_items"] = rewardItems
|
metadata["reward_items"] = rewardItems
|
||||||
}
|
}
|
||||||
|
if len(actions) == 0 {
|
||||||
|
delete(metadata, "actions")
|
||||||
|
} else {
|
||||||
|
metadata["actions"] = actions
|
||||||
|
}
|
||||||
if len(metadata) == 0 {
|
if len(metadata) == 0 {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@ -650,6 +679,81 @@ func inboxImageURLs(raw string, legacyImageURL string) []string {
|
|||||||
return normalizeInboxImageURLs(metadata.ImageURLs, legacyImageURL)
|
return normalizeInboxImageURLs(metadata.ImageURLs, legacyImageURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeInboxActions(values []messagedomain.InboxAction) []messagedomain.InboxAction {
|
||||||
|
result := make([]messagedomain.InboxAction, 0, len(values))
|
||||||
|
for _, value := range values {
|
||||||
|
value.Action = strings.ToLower(strings.TrimSpace(value.Action))
|
||||||
|
value.Label = strings.TrimSpace(value.Label)
|
||||||
|
value.Style = strings.ToLower(strings.TrimSpace(value.Style))
|
||||||
|
if value.Style == "" {
|
||||||
|
value.Style = "primary"
|
||||||
|
}
|
||||||
|
value.API.Method = strings.ToUpper(strings.TrimSpace(value.API.Method))
|
||||||
|
if value.API.Method == "" {
|
||||||
|
value.API.Method = "POST"
|
||||||
|
}
|
||||||
|
value.API.Path = strings.TrimSpace(value.API.Path)
|
||||||
|
result = append(result, value)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboxActions(raw string) []messagedomain.InboxAction {
|
||||||
|
var metadata struct {
|
||||||
|
Actions []messagedomain.InboxAction `json:"actions"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(strings.TrimSpace(raw)), &metadata); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return normalizeInboxActions(metadata.Actions)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateInboxActions(actions []messagedomain.InboxAction) error {
|
||||||
|
if len(actions) > 2 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "actions has too many items")
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(actions))
|
||||||
|
for _, action := range actions {
|
||||||
|
if action.Action == "" || action.Label == "" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "actions action and label are required")
|
||||||
|
}
|
||||||
|
if utf8.RuneCountInString(action.Action) > 64 || utf8.RuneCountInString(action.Label) > 64 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "actions action or label is too long")
|
||||||
|
}
|
||||||
|
if _, exists := seen[action.Action]; exists {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "actions action must be unique")
|
||||||
|
}
|
||||||
|
seen[action.Action] = struct{}{}
|
||||||
|
if action.Style != "primary" && action.Style != "secondary" {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "actions style is invalid")
|
||||||
|
}
|
||||||
|
switch action.API.Method {
|
||||||
|
case "GET", "POST", "PUT", "PATCH", "DELETE":
|
||||||
|
default:
|
||||||
|
return xerr.New(xerr.InvalidArgument, "actions api method is invalid")
|
||||||
|
}
|
||||||
|
parsed, err := url.Parse(action.API.Path)
|
||||||
|
if err != nil || parsed.IsAbs() || parsed.Host != "" || parsed.RawQuery != "" || parsed.Fragment != "" ||
|
||||||
|
!strings.HasPrefix(parsed.Path, "/api/v1/") || strings.Contains(parsed.Path, "..") {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "actions api path is invalid")
|
||||||
|
}
|
||||||
|
if (action.API.Method == "GET" || action.API.Method == "DELETE") && action.API.Body != nil {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "actions api body is not supported for method")
|
||||||
|
}
|
||||||
|
payload, err := json.Marshal(action)
|
||||||
|
if err != nil || len(payload) > 8192 {
|
||||||
|
return xerr.New(xerr.InvalidArgument, "actions item is too large")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sameJSONValue(left any, right any) bool {
|
||||||
|
leftBody, leftErr := json.Marshal(left)
|
||||||
|
rightBody, rightErr := json.Marshal(right)
|
||||||
|
return leftErr == nil && rightErr == nil && sameJSONText(string(leftBody), string(rightBody))
|
||||||
|
}
|
||||||
|
|
||||||
func sameFanoutPayload(existing messagedomain.FanoutJob, next messagedomain.FanoutJob) bool {
|
func sameFanoutPayload(existing messagedomain.FanoutJob, next messagedomain.FanoutJob) bool {
|
||||||
return existing.CommandID == next.CommandID &&
|
return existing.CommandID == next.CommandID &&
|
||||||
existing.MessageType == next.MessageType &&
|
existing.MessageType == next.MessageType &&
|
||||||
|
|||||||
@ -42,9 +42,31 @@ func TestInboxMessageLifecycleUsesMySQLFacts(t *testing.T) {
|
|||||||
MessageType: messagedomain.SectionActivity,
|
MessageType: messagedomain.SectionActivity,
|
||||||
Title: "活动奖励到账",
|
Title: "活动奖励到账",
|
||||||
Summary: "你的活动奖励已发放。",
|
Summary: "你的活动奖励已发放。",
|
||||||
ActionType: "activity_detail",
|
Actions: []messagedomain.InboxAction{
|
||||||
ActionParam: "activity_id=act_01",
|
{
|
||||||
SentAtMS: 1_800_000_000_100,
|
Action: "dismiss",
|
||||||
|
Label: "Dismiss",
|
||||||
|
Style: "secondary",
|
||||||
|
API: messagedomain.InboxActionAPI{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/v1/activities/act_01/dismiss",
|
||||||
|
Body: map[string]any{"source": "inbox"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Action: "join",
|
||||||
|
Label: "Join now",
|
||||||
|
Style: "primary",
|
||||||
|
API: messagedomain.InboxActionAPI{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/v1/activities/act_01/join",
|
||||||
|
Body: map[string]any{"campaign_id": "act_01"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ActionType: "activity_detail",
|
||||||
|
ActionParam: "activity_id=act_01",
|
||||||
|
SentAtMS: 1_800_000_000_100,
|
||||||
})
|
})
|
||||||
if err != nil || !created {
|
if err != nil || !created {
|
||||||
t.Fatalf("CreateInboxMessage activity failed: created=%v message=%+v err=%v", created, second, err)
|
t.Fatalf("CreateInboxMessage activity failed: created=%v message=%+v err=%v", created, second, err)
|
||||||
@ -192,11 +214,31 @@ func TestNoticeHelpersCreateInboxAndFanoutPayloads(t *testing.T) {
|
|||||||
AggregateID: "act_01",
|
AggregateID: "act_01",
|
||||||
Title: "活动奖励到账",
|
Title: "活动奖励到账",
|
||||||
Summary: "你的活动奖励已发放。",
|
Summary: "你的活动奖励已发放。",
|
||||||
ActionType: "activity_detail",
|
Actions: []messagedomain.InboxAction{
|
||||||
ActionParam: "activity_id=act_01",
|
{
|
||||||
Metadata: map[string]any{"activity_id": "act_01"},
|
Action: "dismiss",
|
||||||
|
Label: "Dismiss",
|
||||||
|
Style: "secondary",
|
||||||
|
API: messagedomain.InboxActionAPI{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/v1/activities/act_01/dismiss",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Action: "join",
|
||||||
|
Label: "Join now",
|
||||||
|
Style: "primary",
|
||||||
|
API: messagedomain.InboxActionAPI{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/v1/activities/act_01/join",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
ActionType: "activity_detail",
|
||||||
|
ActionParam: "activity_id=act_01",
|
||||||
|
Metadata: map[string]any{"activity_id": "act_01"},
|
||||||
})
|
})
|
||||||
if err != nil || !notice.Created || notice.Message.MessageType != messagedomain.SectionActivity || notice.Message.Producer != "activity-service" {
|
if err != nil || !notice.Created || notice.Message.MessageType != messagedomain.SectionActivity || notice.Message.Producer != "activity-service" || len(notice.Message.Actions) != 2 || notice.Message.Actions[1].Label != "Join now" {
|
||||||
t.Fatalf("CreateActivityNotice failed: notice=%+v err=%v", notice, err)
|
t.Fatalf("CreateActivityNotice failed: notice=%+v err=%v", notice, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,6 +2,7 @@ package grpc
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
activityv1 "hyapp.local/api/proto/activity/v1"
|
activityv1 "hyapp.local/api/proto/activity/v1"
|
||||||
@ -82,6 +83,10 @@ func (s *MessageServer) DeleteInboxMessage(ctx context.Context, req *activityv1.
|
|||||||
|
|
||||||
func (s *MessageServer) CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) {
|
func (s *MessageServer) CreateInboxMessage(ctx context.Context, req *activityv1.CreateInboxMessageRequest) (*activityv1.CreateInboxMessageResponse, error) {
|
||||||
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
ctx = appcode.WithContext(ctx, req.GetMeta().GetAppCode())
|
||||||
|
actions, err := inboxActionsFromProto(req.GetActions())
|
||||||
|
if err != nil {
|
||||||
|
return nil, xerr.ToGRPCError(xerr.New(xerr.InvalidArgument, "actions JSON is invalid"))
|
||||||
|
}
|
||||||
message, created, err := s.svc.CreateInboxMessage(ctx, messagedomain.CreateInput{
|
message, created, err := s.svc.CreateInboxMessage(ctx, messagedomain.CreateInput{
|
||||||
TargetUserID: req.GetTargetUserId(),
|
TargetUserID: req.GetTargetUserId(),
|
||||||
Producer: req.GetProducer(),
|
Producer: req.GetProducer(),
|
||||||
@ -99,6 +104,7 @@ func (s *MessageServer) CreateInboxMessage(ctx context.Context, req *activityv1.
|
|||||||
ImageURL: req.GetImageUrl(),
|
ImageURL: req.GetImageUrl(),
|
||||||
ImageURLs: req.GetImageUrls(),
|
ImageURLs: req.GetImageUrls(),
|
||||||
RewardItems: inboxRewardItemsFromProto(req.GetRewardItems()),
|
RewardItems: inboxRewardItemsFromProto(req.GetRewardItems()),
|
||||||
|
Actions: actions,
|
||||||
ActionType: req.GetActionType(),
|
ActionType: req.GetActionType(),
|
||||||
ActionParam: req.GetActionParam(),
|
ActionParam: req.GetActionParam(),
|
||||||
Priority: req.GetPriority(),
|
Priority: req.GetPriority(),
|
||||||
@ -144,6 +150,7 @@ func inboxMessageProto(message messagedomain.InboxMessage) *activityv1.InboxMess
|
|||||||
ImageUrl: message.ImageURL,
|
ImageUrl: message.ImageURL,
|
||||||
ImageUrls: imageURLs,
|
ImageUrls: imageURLs,
|
||||||
RewardItems: inboxRewardItemsToProto(message.RewardItems),
|
RewardItems: inboxRewardItemsToProto(message.RewardItems),
|
||||||
|
Actions: inboxActionsToProto(message.Actions),
|
||||||
ActionType: message.ActionType,
|
ActionType: message.ActionType,
|
||||||
ActionParam: message.ActionParam,
|
ActionParam: message.ActionParam,
|
||||||
Read: message.Read(),
|
Read: message.Read(),
|
||||||
@ -151,6 +158,69 @@ func inboxMessageProto(message messagedomain.InboxMessage) *activityv1.InboxMess
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inboxActionsFromProto(items []*activityv1.InboxAction) ([]messagedomain.InboxAction, error) {
|
||||||
|
result := make([]messagedomain.InboxAction, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
query := map[string]any{}
|
||||||
|
if raw := strings.TrimSpace(item.GetApi().GetQueryJson()); raw != "" {
|
||||||
|
if err := json.Unmarshal([]byte(raw), &query); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var body any
|
||||||
|
if raw := strings.TrimSpace(item.GetApi().GetBodyJson()); raw != "" {
|
||||||
|
if err := json.Unmarshal([]byte(raw), &body); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, messagedomain.InboxAction{
|
||||||
|
Action: item.GetAction(),
|
||||||
|
Label: item.GetLabel(),
|
||||||
|
Style: item.GetStyle(),
|
||||||
|
API: messagedomain.InboxActionAPI{
|
||||||
|
Method: item.GetApi().GetMethod(),
|
||||||
|
Path: item.GetApi().GetPath(),
|
||||||
|
Query: query,
|
||||||
|
Body: body,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboxActionsToProto(items []messagedomain.InboxAction) []*activityv1.InboxAction {
|
||||||
|
result := make([]*activityv1.InboxAction, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
queryJSON := ""
|
||||||
|
if len(item.API.Query) > 0 {
|
||||||
|
if encoded, err := json.Marshal(item.API.Query); err == nil {
|
||||||
|
queryJSON = string(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bodyJSON := ""
|
||||||
|
if item.API.Body != nil {
|
||||||
|
if encoded, err := json.Marshal(item.API.Body); err == nil {
|
||||||
|
bodyJSON = string(encoded)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result = append(result, &activityv1.InboxAction{
|
||||||
|
Action: item.Action,
|
||||||
|
Label: item.Label,
|
||||||
|
Style: item.Style,
|
||||||
|
Api: &activityv1.InboxActionAPI{
|
||||||
|
Method: item.API.Method,
|
||||||
|
Path: item.API.Path,
|
||||||
|
QueryJson: queryJSON,
|
||||||
|
BodyJson: bodyJSON,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
func inboxRewardItemsFromProto(items []*activityv1.InboxRewardItem) []messagedomain.RewardItem {
|
func inboxRewardItemsFromProto(items []*activityv1.InboxRewardItem) []messagedomain.RewardItem {
|
||||||
result := make([]messagedomain.RewardItem, 0, len(items))
|
result := make([]messagedomain.RewardItem, 0, len(items))
|
||||||
for _, item := range items {
|
for _, item := range items {
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
package messageapi
|
package messageapi
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
@ -29,12 +30,27 @@ type inboxMessageData struct {
|
|||||||
ImageURL string `json:"image_url"`
|
ImageURL string `json:"image_url"`
|
||||||
ImageURLs []string `json:"image_urls"`
|
ImageURLs []string `json:"image_urls"`
|
||||||
RewardItems []inboxRewardItemData `json:"reward_items"`
|
RewardItems []inboxRewardItemData `json:"reward_items"`
|
||||||
|
Actions []inboxActionData `json:"actions"`
|
||||||
ActionType string `json:"action_type"`
|
ActionType string `json:"action_type"`
|
||||||
ActionParam string `json:"action_param"`
|
ActionParam string `json:"action_param"`
|
||||||
Read bool `json:"read"`
|
Read bool `json:"read"`
|
||||||
SentAtMS int64 `json:"sent_at_ms"`
|
SentAtMS int64 `json:"sent_at_ms"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type inboxActionData struct {
|
||||||
|
Action string `json:"action"`
|
||||||
|
Label string `json:"label"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
API inboxActionAPIData `json:"api"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type inboxActionAPIData struct {
|
||||||
|
Method string `json:"method"`
|
||||||
|
Path string `json:"path"`
|
||||||
|
Query map[string]any `json:"query,omitempty"`
|
||||||
|
Body any `json:"body,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
type inboxRewardItemData struct {
|
type inboxRewardItemData struct {
|
||||||
RewardType string `json:"reward_type"`
|
RewardType string `json:"reward_type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@ -130,6 +146,23 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re
|
|||||||
DurationDays: reward.GetDurationDays(),
|
DurationDays: reward.GetDurationDays(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
actions := make([]inboxActionData, 0, len(item.GetActions()))
|
||||||
|
for _, action := range item.GetActions() {
|
||||||
|
if action == nil || action.GetApi() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
actions = append(actions, inboxActionData{
|
||||||
|
Action: action.GetAction(),
|
||||||
|
Label: action.GetLabel(),
|
||||||
|
Style: action.GetStyle(),
|
||||||
|
API: inboxActionAPIData{
|
||||||
|
Method: action.GetApi().GetMethod(),
|
||||||
|
Path: action.GetApi().GetPath(),
|
||||||
|
Query: inboxActionJSONMap(action.GetApi().GetQueryJson()),
|
||||||
|
Body: inboxActionJSONValue(action.GetApi().GetBodyJson()),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
items = append(items, inboxMessageData{
|
items = append(items, inboxMessageData{
|
||||||
MessageID: item.GetMessageId(),
|
MessageID: item.GetMessageId(),
|
||||||
Title: item.GetTitle(),
|
Title: item.GetTitle(),
|
||||||
@ -139,6 +172,7 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re
|
|||||||
ImageURL: item.GetImageUrl(),
|
ImageURL: item.GetImageUrl(),
|
||||||
ImageURLs: item.GetImageUrls(),
|
ImageURLs: item.GetImageUrls(),
|
||||||
RewardItems: rewardItems,
|
RewardItems: rewardItems,
|
||||||
|
Actions: actions,
|
||||||
ActionType: item.GetActionType(),
|
ActionType: item.GetActionType(),
|
||||||
ActionParam: item.GetActionParam(),
|
ActionParam: item.GetActionParam(),
|
||||||
Read: item.GetRead(),
|
Read: item.GetRead(),
|
||||||
@ -148,6 +182,28 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re
|
|||||||
httpkit.WriteOK(writer, request, map[string]any{"section": resp.GetSection(), "items": items, "next_page_token": resp.GetNextPageToken()})
|
httpkit.WriteOK(writer, request, map[string]any{"section": resp.GetSection(), "items": items, "next_page_token": resp.GetNextPageToken()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inboxActionJSONMap(raw string) map[string]any {
|
||||||
|
result := map[string]any{}
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||||
|
return map[string]any{}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func inboxActionJSONValue(raw string) any {
|
||||||
|
if strings.TrimSpace(raw) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var result any
|
||||||
|
if err := json.Unmarshal([]byte(raw), &result); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// markInboxSectionRead marks all current visible unread messages in one backend-owned section.
|
// markInboxSectionRead marks all current visible unread messages in one backend-owned section.
|
||||||
func (h *Handler) markInboxSectionRead(writer http.ResponseWriter, request *http.Request) {
|
func (h *Handler) markInboxSectionRead(writer http.ResponseWriter, request *http.Request) {
|
||||||
if h.messageClient == nil {
|
if h.messageClient == nil {
|
||||||
|
|||||||
@ -8441,9 +8441,19 @@ func TestMessageTabsAndInboxRoutesUseAuthenticatedUser(t *testing.T) {
|
|||||||
}}, listResp: &activityv1.ListInboxMessagesResponse{
|
}}, listResp: &activityv1.ListInboxMessagesResponse{
|
||||||
Section: "system",
|
Section: "system",
|
||||||
Items: []*activityv1.InboxMessage{{
|
Items: []*activityv1.InboxMessage{{
|
||||||
MessageId: "msg-1",
|
MessageId: "msg-1",
|
||||||
Title: "提现审核通过",
|
Title: "提现审核通过",
|
||||||
Summary: "你的提现申请已通过。",
|
Summary: "你的提现申请已通过。",
|
||||||
|
Actions: []*activityv1.InboxAction{{
|
||||||
|
Action: "claim",
|
||||||
|
Label: "Claim",
|
||||||
|
Style: "primary",
|
||||||
|
Api: &activityv1.InboxActionAPI{
|
||||||
|
Method: "POST",
|
||||||
|
Path: "/api/v1/rewards/reward_01/claim",
|
||||||
|
BodyJson: `{"command_id":"claim_reward_01"}`,
|
||||||
|
},
|
||||||
|
}},
|
||||||
ActionType: "wallet_withdraw_detail",
|
ActionType: "wallet_withdraw_detail",
|
||||||
ActionParam: "withdrawal_id=wd_01",
|
ActionParam: "withdrawal_id=wd_01",
|
||||||
SentAtMs: 1777996800000,
|
SentAtMs: 1777996800000,
|
||||||
@ -8485,6 +8495,24 @@ func TestMessageTabsAndInboxRoutesUseAuthenticatedUser(t *testing.T) {
|
|||||||
if response.Code != httpkit.CodeOK || !ok || data["section"] != "system" || data["next_page_token"] != "cursor-2" {
|
if response.Code != httpkit.CodeOK || !ok || data["section"] != "system" || data["next_page_token"] != "cursor-2" {
|
||||||
t.Fatalf("message list envelope mismatch: %+v", response)
|
t.Fatalf("message list envelope mismatch: %+v", response)
|
||||||
}
|
}
|
||||||
|
items, ok := data["items"].([]any)
|
||||||
|
if !ok || len(items) != 1 {
|
||||||
|
t.Fatalf("message list items mismatch: %+v", data["items"])
|
||||||
|
}
|
||||||
|
item, ok := items[0].(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("message list item type mismatch: %+v", items[0])
|
||||||
|
}
|
||||||
|
actions, ok := item["actions"].([]any)
|
||||||
|
if !ok || len(actions) != 1 {
|
||||||
|
t.Fatalf("message actions mismatch: %+v", item["actions"])
|
||||||
|
}
|
||||||
|
action, ok := actions[0].(map[string]any)
|
||||||
|
api, apiOK := action["api"].(map[string]any)
|
||||||
|
body, bodyOK := api["body"].(map[string]any)
|
||||||
|
if !ok || !apiOK || !bodyOK || action["label"] != "Claim" || api["method"] != "POST" || api["path"] != "/api/v1/rewards/reward_01/claim" || body["command_id"] != "claim_reward_01" {
|
||||||
|
t.Fatalf("message action response mismatch: %+v", action)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestTaskTabsAndClaimRoutesUseAuthenticatedUser(t *testing.T) {
|
func TestTaskTabsAndClaimRoutesUseAuthenticatedUser(t *testing.T) {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user