通用消息

This commit is contained in:
zhx 2026-07-23 21:38:43 +08:00
parent c169dfb5e7
commit 8518a7a2b1
17 changed files with 1321 additions and 698 deletions

File diff suppressed because it is too large Load Diff

View File

@ -74,6 +74,8 @@ message InboxMessage {
repeated string image_urls = 12;
// reward_items body/metadata_json
repeated InboxRewardItem reward_items = 13;
// actions API
repeated InboxAction actions = 14;
}
// ListInboxMessagesRequest section inbox
@ -159,6 +161,8 @@ message CreateInboxMessageRequest {
repeated string image_urls = 22;
// reward_items image_urls inbox owner metadata
repeated InboxRewardItem reward_items = 23;
// actions inbox owner metadata JSON
repeated InboxAction actions = 24;
}
// CreateInboxMessageResponse producer_event_id created=false
@ -3027,6 +3031,23 @@ message InboxRewardItem {
int64 duration_days = 5;
}
// InboxActionAPI App API Flutter /api/v1
// headershost 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
service ActivityService {
rpc PingActivity(PingActivityRequest) returns (PingActivityResponse);

View File

@ -1162,7 +1162,7 @@ PATCH
- `GET /api/v1/vip/coin-rebates/current`:查当前 UTC 日资格。
- `POST /api/v1/vip/coin-rebates/statuses`:批量恢复系统消息中的真实状态,或分页查历史。
- `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
{
"message_id": "msg_vip_rebate_xxx",
"title": "VIP金币返现",
"summary": "尊敬的VIP用户平台今日返现100金币请及时领取。",
"body": "尊敬的VIP用户平台今日返现100金币请及时领取。",
"title": "VIP Coin Cashback",
"summary": "Your daily VIP cashback of 100 coins is ready. Claim it before it expires.",
"body": "Your daily VIP cashback of 100 coins is ready. Claim it before it expires.",
"icon_url": "",
"image_url": "",
"action_type": "vip_coin_rebate_claim",

View File

@ -110,7 +110,7 @@ Fami 的 VIP1VIP9 初始全部为 `disabled`。30 天只是不产生零时长
1. 首个成功批次固化当时的 `config_version` 和等级金额矩阵;后续分页不重读新配置。
2. wallet 根据会员历史还原 UTC 0 点时的付费 VIP0 点后才购买或升级的状态从次日起参与。
3. 生成 `vip_daily_coin_rebates` 资格,领取窗口固定为 `[UTC 当日0点,UTC 次日0点)`
4. 同事务写 `VipDailyCoinRebateAvailable` outboxactivity-service 幂等投影为“VIP金币返现”系统消息。
4. 同事务写 `VipDailyCoinRebateAvailable` outboxactivity-service 幂等投影为 “VIP Coin Cashback” 系统消息。
5. 用户手动领取时返现行锁、COIN 余额、交易、分录和 `WalletBalanceChanged` outbox 在一个事务内提交。
“日切资格”严格按 UTC 0 点;“金额配置”是当日首个成功 run 的快照,不伪造系统尚未保存的 0 点历史配置。正常情况下 cron 每分钟触发;如零点后 wallet/cron 长时间不可用,恢复前的后台金额修改会进入当日快照。运营若要保证修改次日生效,应在当日 run 已创建后修改。

View File

@ -1334,9 +1334,9 @@ Flutter 使用 `event_id` 去重直接使用事件内的用户、VIP 和头
```json
{
"message_id": "msg_vip_rebate_xxx",
"title": "VIP金币返现",
"summary": "尊敬的VIP用户平台今日返现100金币请及时领取。",
"body": "尊敬的VIP用户平台今日返现100金币请及时领取。",
"title": "VIP Coin Cashback",
"summary": "Your daily VIP cashback of 100 coins is ready. Claim it before it expires.",
"body": "Your daily VIP cashback of 100 coins is ready. Claim it before it expires.",
"icon_url": "",
"image_url": "",
"action_type": "vip_coin_rebate_claim",

View File

@ -44,30 +44,45 @@ func New(activity activityclient.Client, audit shared.OperationLogger) *Handler
}
type fanoutRequest struct {
CommandID string `json:"command_id"`
MessageType string `json:"message_type"`
TargetScope string `json:"target_scope"`
TargetUserID int64 `json:"target_user_id"`
UserIDs int64List `json:"user_ids"`
RegionID int64 `json:"region_id"`
Country string `json:"country"`
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
MetadataJSON string `json:"metadata_json"`
BatchSize int32 `json:"batch_size"`
CommandID string `json:"command_id"`
MessageType string `json:"message_type"`
TargetScope string `json:"target_scope"`
TargetUserID int64 `json:"target_user_id"`
UserIDs int64List `json:"user_ids"`
RegionID int64 `json:"region_id"`
Country string `json:"country"`
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
Actions []inboxActionRequest `json:"actions"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
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 {
@ -168,6 +183,13 @@ func normalizeFanoutRequest(req fanoutRequest, adminID int64) (fanoutRequest, er
req.Body = strings.TrimSpace(req.Body)
req.IconURL = strings.TrimSpace(req.IconURL)
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.ActionParam = strings.TrimSpace(req.ActionParam)
req.ProducerEventType = firstText(req.ProducerEventType, defaultProducerEventType)
@ -247,6 +269,7 @@ func buildTemplateSnapshotJSON(req fanoutRequest) (string, error) {
"body": req.Body,
"icon_url": req.IconURL,
"image_url": req.ImageURL,
"actions": req.Actions,
"action_type": req.ActionType,
"action_param": req.ActionParam,
"priority": req.Priority,

View File

@ -7,11 +7,21 @@ import (
func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
req, err := normalizeFanoutRequest(fanoutRequest{
MessageType: "activity",
TargetScope: "user_ids",
UserIDs: int64List{42, 43, 42, 0},
Title: "活动上线",
Summary: "新活动已上线。",
MessageType: "activity",
TargetScope: "user_ids",
UserIDs: int64List{42, 43, 42, 0},
Title: "活动上线",
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",
ActionParam: "activity_id=act_01",
MetadataJSON: `{"source":"admin"}`,
@ -31,7 +41,7 @@ func TestBuildFanoutJSONForUserIDScope(t *testing.T) {
if err != nil {
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) {
t.Fatalf("template snapshot %s missing %s", templateSnapshotJSON, required)
}

View File

@ -3,13 +3,13 @@ package app
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"hyapp/pkg/appcode"
"hyapp/pkg/walletmq"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
messageservice "hyapp/services/activity-service/internal/service/message"
)
@ -85,30 +85,21 @@ func createVIPRebateSystemNotice(ctx context.Context, service *messageservice.Se
if service == nil {
return xerr.New(xerr.Unavailable, "message service is not configured")
}
actionParam, err := json.Marshal(map[string]any{
"rebate_id": event.RebateID,
"task_day": event.TaskDay,
"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{
// 返现资格尚未入账,英文文案必须表达“可领取”而不是“已到账”,避免客户端展示与钱包事实冲突。
title, summary := messageservice.VIPDailyCoinRebateNoticeCopy(event.CoinAmount)
_, err := service.CreateSystemNotice(ctx, messageservice.NoticeCommand{
TargetUserID: event.UserID,
Producer: "wallet-service",
ProducerEventID: event.EventID,
ProducerEventType: vipDailyCoinRebateAvailableEventType,
AggregateType: "vip_daily_coin_rebate",
AggregateID: event.RebateID,
TemplateID: "vip_daily_coin_rebate_available",
TemplateVersion: "v1",
Title: "VIP金币返现",
TemplateID: messageservice.VIPDailyCoinRebateNoticeTemplateID,
TemplateVersion: messageservice.VIPDailyCoinRebateNoticeTemplateVersion,
Title: title,
Summary: summary,
Body: summary,
ActionType: "vip_coin_rebate_claim",
ActionParam: string(actionParam),
Actions: []messagedomain.InboxAction{messageservice.VIPDailyCoinRebateNoticeAction(event.RebateID)},
Priority: 50,
SentAtMS: event.AvailableAtMS,
ExpireAtMS: event.ExpiresAtMS,

View File

@ -65,9 +65,30 @@ func TestVIPRebateWalletEventCreatesOneIdempotentSystemNotice(t *testing.T) {
t.Fatalf("system inbox mismatch: items=%+v err=%v", items, err)
}
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)
}
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 {

View File

@ -48,6 +48,22 @@ type RewardItem struct {
DurationDays int64 `json:"duration_days,omitempty"`
}
// InboxActionAPI 只描述 gateway 同源 APIheaders 和绝对 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.
type InboxMessage struct {
AppCode string
@ -69,7 +85,9 @@ type InboxMessage struct {
// ImageURLs 是一张通知卡片内的有序图片列表ImageURL 保留首图兼容旧客户端。
ImageURLs []string
// RewardItems 从 metadata_json 还原为强类型响应;历史消息没有该字段时保持空列表。
RewardItems []RewardItem
RewardItems []RewardItem
// Actions 与图片/奖励一样存入 metadata_json避免为展示协议扩表。
Actions []InboxAction
ActionType string
ActionParam string
Priority int32
@ -137,7 +155,9 @@ type CreateInput struct {
// ImageURLs 会由 service 写入受控 metadata 快照MySQL 继续使用现有 JSON 列持久化。
ImageURLs []string
// RewardItems 与发奖事实一起固化,防止资源改名、换图或改时长后历史消息漂移。
RewardItems []RewardItem
RewardItems []RewardItem
// Actions 最多两个owner service 会校验按钮文案、样式和同源 API。
Actions []InboxAction
ActionType string
ActionParam string
Priority int32

View File

@ -40,22 +40,23 @@ type fanoutTargetFilter struct {
}
type fanoutTemplateSnapshot struct {
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
MetadataJSON json.RawMessage `json:"metadata_json"`
ProducerEventType string `json:"producer_event_type"`
AggregateType string `json:"aggregate_type"`
AggregateID string `json:"aggregate_id"`
TemplateID string `json:"template_id"`
TemplateVersion string `json:"template_version"`
Title string `json:"title"`
Summary string `json:"summary"`
Body string `json:"body"`
IconURL string `json:"icon_url"`
ImageURL string `json:"image_url"`
Actions []messagedomain.InboxAction `json:"actions"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Priority int32 `json:"priority"`
SentAtMS int64 `json:"sent_at_ms"`
ExpireAtMS int64 `json:"expire_at_ms"`
MetadataJSON json.RawMessage `json:"metadata_json"`
}
// 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,
IconURL: snapshot.IconURL,
ImageURL: snapshot.ImageURL,
Actions: snapshot.Actions,
ActionType: snapshot.ActionType,
ActionParam: snapshot.ActionParam,
Priority: snapshot.Priority,

View File

@ -3,13 +3,21 @@ package message
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strings"
"hyapp/pkg/xerr"
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.
type NoticeCommand struct {
@ -28,6 +36,7 @@ type NoticeCommand struct {
ImageURL string
ImageURLs []string
RewardItems []messagedomain.RewardItem
Actions []messagedomain.InboxAction
ActionType string
ActionParam string
Priority int32
@ -43,6 +52,54 @@ type NoticeResult struct {
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.
type NoticeFanoutCommand struct {
CommandID string
@ -62,6 +119,7 @@ type NoticeFanoutCommand struct {
Body string
IconURL string
ImageURL string
Actions []messagedomain.InboxAction
ActionType string
ActionParam string
Priority int32
@ -108,6 +166,7 @@ func (s *Service) CreateNotice(ctx context.Context, messageType string, cmd Noti
ImageURL: cmd.ImageURL,
ImageURLs: cmd.ImageURLs,
RewardItems: cmd.RewardItems,
Actions: cmd.Actions,
ActionType: cmd.ActionType,
ActionParam: cmd.ActionParam,
Priority: cmd.Priority,
@ -202,6 +261,7 @@ func noticeTemplateSnapshotJSON(cmd NoticeFanoutCommand) (string, error) {
"body": strings.TrimSpace(cmd.Body),
"icon_url": strings.TrimSpace(cmd.IconURL),
"image_url": strings.TrimSpace(cmd.ImageURL),
"actions": cmd.Actions,
"action_type": strings.TrimSpace(cmd.ActionType),
"action_param": strings.TrimSpace(cmd.ActionParam),
"priority": cmd.Priority,

View File

@ -5,6 +5,7 @@ import (
"context"
"encoding/base64"
"encoding/json"
"net/url"
"slices"
"strings"
"time"
@ -149,6 +150,9 @@ func (s *Service) ListInboxMessages(ctx context.Context, userID int64, section s
for index := range messages {
messages[index].ImageURLs = inboxImageURLs(messages[index].MetadataJSON, messages[index].ImageURL)
messages[index].RewardItems = inboxRewardItems(messages[index].MetadataJSON)
messages[index].Actions = inboxActions(messages[index].MetadataJSON)
// 旧返现消息没有 actions读取投影补齐英文文案和后端驱动按钮不回写历史钱包事实。
messages[index] = normalizeVIPDailyCoinRebateNoticeCopy(messages[index])
}
if len(messages) <= limit {
return messages, "", nil
@ -226,6 +230,7 @@ func (s *Service) CreateInboxMessage(ctx context.Context, input messagedomain.Cr
ImageURL: input.ImageURL,
ImageURLs: append([]string(nil), input.ImageURLs...),
RewardItems: append([]messagedomain.RewardItem(nil), input.RewardItems...),
Actions: append([]messagedomain.InboxAction(nil), input.Actions...),
ActionType: input.ActionType,
ActionParam: input.ActionParam,
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.RewardItems = inboxRewardItems(existing.MetadataJSON)
existing.Actions = inboxActions(existing.MetadataJSON)
return existing, false, nil
}
return existing, true, nil
@ -367,11 +373,12 @@ func normalizeCreateInput(input messagedomain.CreateInput) messagedomain.CreateI
input.ImageURL = strings.TrimSpace(input.ImageURL)
input.ImageURLs = normalizeInboxImageURLs(input.ImageURLs, input.ImageURL)
input.RewardItems = normalizeInboxRewardItems(input.RewardItems)
input.Actions = normalizeInboxActions(input.Actions)
if input.ImageURL == "" && len(input.ImageURLs) > 0 {
input.ImageURL = input.ImageURLs[0]
}
// 图片和奖励项都写入 inbox owner 的受控 metadata 快照,避免扩表,同时对客户端仍输出强类型字段。
input.MetadataJSON = inboxMetadataWithPresentation(input.MetadataJSON, input.ImageURLs, input.RewardItems)
// 图片、奖励和操作按钮都写入 inbox owner 的受控 metadata 快照,避免扩表,同时输出强类型字段。
input.MetadataJSON = inboxMetadataWithPresentation(input.MetadataJSON, input.ImageURLs, input.RewardItems, input.Actions)
input.ActionType = strings.TrimSpace(input.ActionType)
input.ActionParam = strings.TrimSpace(input.ActionParam)
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")
}
}
if err := validateInboxActions(input.Actions); err != nil {
return err
}
if input.Title == "" || input.Summary == "" {
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) {
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
}
@ -478,6 +491,15 @@ func validateFanoutInput(input messagedomain.CreateFanoutInput) error {
if input.TemplateSnapshotJSON == "" || !json.Valid([]byte(input.TemplateSnapshotJSON)) {
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
}
@ -545,6 +567,8 @@ func sameInboxPayload(existing messagedomain.InboxMessage, next messagedomain.In
next.ImageURLs = inboxImageURLs(next.MetadataJSON, next.ImageURL)
existing.RewardItems = inboxRewardItems(existing.MetadataJSON)
next.RewardItems = inboxRewardItems(next.MetadataJSON)
existing.Actions = inboxActions(existing.MetadataJSON)
next.Actions = inboxActions(next.MetadataJSON)
return existing.UserID == next.UserID &&
existing.MessageType == next.MessageType &&
existing.Producer == next.Producer &&
@ -584,7 +608,7 @@ func normalizeInboxImageURLs(values []string, legacyImageURL string) []string {
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)
metadata := map[string]any{}
if raw != "" {
@ -606,6 +630,11 @@ func inboxMetadataWithPresentation(raw string, imageURLs []string, rewardItems [
} else {
metadata["reward_items"] = rewardItems
}
if len(actions) == 0 {
delete(metadata, "actions")
} else {
metadata["actions"] = actions
}
if len(metadata) == 0 {
return ""
}
@ -650,6 +679,81 @@ func inboxImageURLs(raw string, legacyImageURL string) []string {
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 {
return existing.CommandID == next.CommandID &&
existing.MessageType == next.MessageType &&

View File

@ -42,9 +42,31 @@ func TestInboxMessageLifecycleUsesMySQLFacts(t *testing.T) {
MessageType: messagedomain.SectionActivity,
Title: "活动奖励到账",
Summary: "你的活动奖励已发放。",
ActionType: "activity_detail",
ActionParam: "activity_id=act_01",
SentAtMS: 1_800_000_000_100,
Actions: []messagedomain.InboxAction{
{
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 {
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",
Title: "活动奖励到账",
Summary: "你的活动奖励已发放。",
ActionType: "activity_detail",
ActionParam: "activity_id=act_01",
Metadata: map[string]any{"activity_id": "act_01"},
Actions: []messagedomain.InboxAction{
{
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)
}

View File

@ -2,6 +2,7 @@ package grpc
import (
"context"
"encoding/json"
"strings"
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) {
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{
TargetUserID: req.GetTargetUserId(),
Producer: req.GetProducer(),
@ -99,6 +104,7 @@ func (s *MessageServer) CreateInboxMessage(ctx context.Context, req *activityv1.
ImageURL: req.GetImageUrl(),
ImageURLs: req.GetImageUrls(),
RewardItems: inboxRewardItemsFromProto(req.GetRewardItems()),
Actions: actions,
ActionType: req.GetActionType(),
ActionParam: req.GetActionParam(),
Priority: req.GetPriority(),
@ -144,6 +150,7 @@ func inboxMessageProto(message messagedomain.InboxMessage) *activityv1.InboxMess
ImageUrl: message.ImageURL,
ImageUrls: imageURLs,
RewardItems: inboxRewardItemsToProto(message.RewardItems),
Actions: inboxActionsToProto(message.Actions),
ActionType: message.ActionType,
ActionParam: message.ActionParam,
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 {
result := make([]messagedomain.RewardItem, 0, len(items))
for _, item := range items {

View File

@ -1,6 +1,7 @@
package messageapi
import (
"encoding/json"
"net/http"
"strconv"
"strings"
@ -29,12 +30,27 @@ type inboxMessageData struct {
ImageURL string `json:"image_url"`
ImageURLs []string `json:"image_urls"`
RewardItems []inboxRewardItemData `json:"reward_items"`
Actions []inboxActionData `json:"actions"`
ActionType string `json:"action_type"`
ActionParam string `json:"action_param"`
Read bool `json:"read"`
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 {
RewardType string `json:"reward_type"`
Name string `json:"name"`
@ -130,6 +146,23 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re
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{
MessageID: item.GetMessageId(),
Title: item.GetTitle(),
@ -139,6 +172,7 @@ func (h *Handler) listInboxMessages(writer http.ResponseWriter, request *http.Re
ImageURL: item.GetImageUrl(),
ImageURLs: item.GetImageUrls(),
RewardItems: rewardItems,
Actions: actions,
ActionType: item.GetActionType(),
ActionParam: item.GetActionParam(),
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()})
}
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.
func (h *Handler) markInboxSectionRead(writer http.ResponseWriter, request *http.Request) {
if h.messageClient == nil {

View File

@ -8441,9 +8441,19 @@ func TestMessageTabsAndInboxRoutesUseAuthenticatedUser(t *testing.T) {
}}, listResp: &activityv1.ListInboxMessagesResponse{
Section: "system",
Items: []*activityv1.InboxMessage{{
MessageId: "msg-1",
Title: "提现审核通过",
Summary: "你的提现申请已通过。",
MessageId: "msg-1",
Title: "提现审核通过",
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",
ActionParam: "withdrawal_id=wd_01",
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" {
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) {