2026-06-29 15:46:08 +08:00

447 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package message
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"hyapp/pkg/idgen"
"hyapp/pkg/xerr"
messagedomain "hyapp/services/activity-service/internal/domain/message"
)
const (
actionConfirmDefaultLockTTL = 30 * time.Second
actionConfirmDefaultLimit = 50
actionConfirmMaxLimit = 100
)
// ActionConfirmRepository is the durable boundary for confirm rows and their IM outbox.
type ActionConfirmRepository interface {
SaveActionConfirmWithOutbox(ctx context.Context, confirm messagedomain.ActionConfirm, outbox messagedomain.ActionConfirmOutbox) (bool, messagedomain.ActionConfirm, error)
ClaimActionConfirm(ctx context.Context, confirmID string, actorUserID int64, action string, commandID string, workerID string, nowMS int64, lockUntilMS int64) (messagedomain.ActionConfirm, bool, error)
CompleteActionConfirm(ctx context.Context, confirmID string, commandID string, status string, action string, processedAtMS int64) (messagedomain.ActionConfirm, error)
ReleaseActionConfirm(ctx context.Context, confirmID string, commandID string, errorMessage string, nowMS int64) error
BatchGetActionConfirms(ctx context.Context, userID int64, confirmIDs []string) ([]messagedomain.ActionConfirm, error)
ListConversationActionConfirms(ctx context.Context, userID int64, peerUserID int64, limit int, cursorCreatedAtMS int64, cursorConfirmID string) ([]messagedomain.ActionConfirm, error)
ClaimPendingActionConfirmOutbox(ctx context.Context, workerID string, nowMS int64, lockUntilMS int64, batchSize int) ([]messagedomain.ActionConfirmOutbox, error)
MarkActionConfirmOutboxDelivered(ctx context.Context, eventID string, nowMS int64) error
MarkActionConfirmOutboxRetryable(ctx context.Context, eventID string, lastErr string, nextRetryAtMS int64, nowMS int64) error
}
// RoleInvitationClient is the owner-service boundary used when a confirm action maps to a role invitation.
type RoleInvitationClient interface {
ProcessRoleInvitation(ctx context.Context, input ProcessRoleInvitationInput) (RoleInvitationState, error)
GetRoleInvitation(ctx context.Context, actorUserID int64, invitationID int64) (RoleInvitationState, error)
}
// ProcessRoleInvitationInput is the normalized owner-service command assembled from a confirm row.
type ProcessRoleInvitationInput struct {
CommandID string
ActorUserID int64
InvitationID int64
Action string
Reason string
}
// RoleInvitationState is the small role-invitation projection needed to reconcile confirm status.
type RoleInvitationState struct {
InvitationID int64
InvitationType string
Status string
}
// ActionConfirmService owns IM confirm status and delegates business mutations back to owner services.
type ActionConfirmService struct {
cfg Config
repository ActionConfirmRepository
roleInvitationClient RoleInvitationClient
now func() time.Time
}
// NewActionConfirm creates the generic confirm-message service.
func NewActionConfirm(cfg Config, repository ActionConfirmRepository, roleInvitationClient RoleInvitationClient) *ActionConfirmService {
return &ActionConfirmService{cfg: cfg, repository: repository, roleInvitationClient: roleInvitationClient, now: time.Now}
}
// SetClock injects deterministic time for tests.
func (s *ActionConfirmService) SetClock(now func() time.Time) {
if now != nil {
s.now = now
}
}
// ConsumeRoleInvitationCreated materializes one user-service invitation fact into an actionable IM confirm.
func (s *ActionConfirmService) ConsumeRoleInvitationCreated(ctx context.Context, event messagedomain.RoleInvitationConfirmEvent) (messagedomain.ActionConfirm, bool, error) {
if err := s.requireRepository(); err != nil {
return messagedomain.ActionConfirm{}, false, err
}
if event.SourceEventID == "" || event.InvitationID <= 0 || event.InviterUserID <= 0 || event.TargetUserID <= 0 {
return messagedomain.ActionConfirm{}, false, xerr.New(xerr.InvalidArgument, "role invitation confirm event is incomplete")
}
subtype := strings.TrimSpace(event.InvitationType)
if subtype == "" {
return messagedomain.ActionConfirm{}, false, xerr.New(xerr.InvalidArgument, "invitation_type is required")
}
nowMS := firstPositiveInt64(event.CreatedAtMS, s.now().UnixMilli())
messageText := roleInvitationMessageText(event)
metadataJSON := strings.TrimSpace(event.RawPayloadJSON)
if metadataJSON == "" || !json.Valid([]byte(metadataJSON)) {
metadataJSON = "{}"
}
confirmID := idgen.New("cfm")
payloadJSON, err := confirmIMPayloadJSON(confirmID, event, messageText, nowMS)
if err != nil {
return messagedomain.ActionConfirm{}, false, err
}
confirm := messagedomain.ActionConfirm{
ConfirmID: confirmID,
SourceName: messagedomain.ActionConfirmSourceUserOutbox,
SourceEventID: event.SourceEventID,
BusinessType: messagedomain.ActionConfirmBusinessRoleInvitation,
BusinessSubtype: subtype,
BusinessID: strconv.FormatInt(event.InvitationID, 10),
SenderUserID: event.InviterUserID,
ReceiverUserID: event.TargetUserID,
MessageText: messageText,
Status: messagedomain.ActionConfirmStatusPending,
MetadataJSON: metadataJSON,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
outbox := messagedomain.ActionConfirmOutbox{
EventID: idgen.New("maout"),
ConfirmID: confirm.ConfirmID,
EventType: messagedomain.ActionConfirmEventCreated,
Status: "pending",
PayloadJSON: payloadJSON,
CreatedAtMS: nowMS,
UpdatedAtMS: nowMS,
}
created, saved, err := s.repository.SaveActionConfirmWithOutbox(ctx, confirm, outbox)
return saved, created, err
}
// AcceptActionConfirm executes the accept branch for the current receiver.
func (s *ActionConfirmService) AcceptActionConfirm(ctx context.Context, actorUserID int64, confirmID string, commandID string) (messagedomain.ActionConfirm, error) {
return s.processAction(ctx, actorUserID, confirmID, commandID, messagedomain.ActionConfirmActionAccept, "")
}
// RejectActionConfirm executes the reject branch for the current receiver.
func (s *ActionConfirmService) RejectActionConfirm(ctx context.Context, actorUserID int64, confirmID string, commandID string, reason string) (messagedomain.ActionConfirm, error) {
return s.processAction(ctx, actorUserID, confirmID, commandID, messagedomain.ActionConfirmActionReject, reason)
}
func (s *ActionConfirmService) processAction(ctx context.Context, actorUserID int64, confirmID string, commandID string, action string, reason string) (messagedomain.ActionConfirm, error) {
if err := s.requireRepository(); err != nil {
return messagedomain.ActionConfirm{}, err
}
if s.roleInvitationClient == nil {
return messagedomain.ActionConfirm{}, xerr.New(xerr.Unavailable, "role invitation client is not configured")
}
confirmID = strings.TrimSpace(confirmID)
commandID = strings.TrimSpace(commandID)
if actorUserID <= 0 || confirmID == "" || commandID == "" {
return messagedomain.ActionConfirm{}, xerr.New(xerr.InvalidArgument, "actor_user_id, confirm_id and command_id are required")
}
nowMS := s.now().UnixMilli()
claimed, ok, err := s.repository.ClaimActionConfirm(ctx, confirmID, actorUserID, action, commandID, s.workerID(), nowMS, nowMS+actionConfirmDefaultLockTTL.Milliseconds())
if err != nil || !ok {
return claimed, err
}
if isTerminalConfirmStatus(claimed.Status) {
return claimed, nil
}
invitationID, err := strconv.ParseInt(claimed.BusinessID, 10, 64)
if err != nil || invitationID <= 0 {
_ = s.repository.ReleaseActionConfirm(ctx, confirmID, commandID, "invalid business_id", s.now().UnixMilli())
return messagedomain.ActionConfirm{}, xerr.New(xerr.InvalidArgument, "confirm business_id is invalid")
}
if claimed.BusinessType != messagedomain.ActionConfirmBusinessRoleInvitation {
_ = s.repository.ReleaseActionConfirm(ctx, confirmID, commandID, "unsupported business_type", s.now().UnixMilli())
return messagedomain.ActionConfirm{}, xerr.New(xerr.InvalidArgument, "confirm business_type is unsupported")
}
state, err := s.roleInvitationClient.ProcessRoleInvitation(ctx, ProcessRoleInvitationInput{
CommandID: commandID,
ActorUserID: actorUserID,
InvitationID: invitationID,
Action: action,
Reason: strings.TrimSpace(reason),
})
if err != nil {
reconciled, reconcileErr := s.reconcileRoleInvitation(ctx, claimed, actorUserID, commandID, action)
if reconcileErr == nil && isTerminalConfirmStatus(reconciled.Status) {
return reconciled, nil
}
_ = s.repository.ReleaseActionConfirm(ctx, confirmID, commandID, err.Error(), s.now().UnixMilli())
return messagedomain.ActionConfirm{}, err
}
return s.repository.CompleteActionConfirm(ctx, confirmID, commandID, confirmStatusFromRoleInvitation(state.Status), actionFromRoleInvitationStatus(state.Status, action), s.now().UnixMilli())
}
func (s *ActionConfirmService) reconcileRoleInvitation(ctx context.Context, confirm messagedomain.ActionConfirm, actorUserID int64, commandID string, action string) (messagedomain.ActionConfirm, error) {
invitationID, err := strconv.ParseInt(confirm.BusinessID, 10, 64)
if err != nil || invitationID <= 0 {
return messagedomain.ActionConfirm{}, xerr.New(xerr.InvalidArgument, "confirm business_id is invalid")
}
state, err := s.roleInvitationClient.GetRoleInvitation(ctx, actorUserID, invitationID)
if err != nil {
return messagedomain.ActionConfirm{}, err
}
status := confirmStatusFromRoleInvitation(state.Status)
if !isTerminalConfirmStatus(status) {
return messagedomain.ActionConfirm{}, xerr.New(xerr.Conflict, "role invitation is not terminal")
}
return s.repository.CompleteActionConfirm(ctx, confirm.ConfirmID, commandID, status, actionFromRoleInvitationStatus(state.Status, action), s.now().UnixMilli())
}
// BatchGetActionConfirmStatus returns only confirms visible to the current user.
func (s *ActionConfirmService) BatchGetActionConfirmStatus(ctx context.Context, userID int64, confirmIDs []string) ([]messagedomain.ActionConfirm, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
if userID <= 0 {
return nil, xerr.New(xerr.InvalidArgument, "user_id is required")
}
normalized := make([]string, 0, len(confirmIDs))
seen := make(map[string]struct{}, len(confirmIDs))
for _, confirmID := range confirmIDs {
confirmID = strings.TrimSpace(confirmID)
if confirmID == "" {
continue
}
if _, ok := seen[confirmID]; ok {
continue
}
seen[confirmID] = struct{}{}
normalized = append(normalized, confirmID)
}
if len(normalized) == 0 {
return []messagedomain.ActionConfirm{}, nil
}
if len(normalized) > 100 {
return nil, xerr.New(xerr.InvalidArgument, "confirm_ids is too large")
}
return s.repository.BatchGetActionConfirms(ctx, userID, normalized)
}
// ListConversationActionConfirms returns confirm states for one C2C peer conversation.
func (s *ActionConfirmService) ListConversationActionConfirms(ctx context.Context, userID int64, peerUserID int64, pageSize int32, pageToken string) ([]messagedomain.ActionConfirm, string, error) {
if err := s.requireRepository(); err != nil {
return nil, "", err
}
if userID <= 0 || peerUserID <= 0 {
return nil, "", xerr.New(xerr.InvalidArgument, "user_id and peer_user_id are required")
}
limit := int(pageSize)
if limit <= 0 {
limit = actionConfirmDefaultLimit
}
if limit > actionConfirmMaxLimit {
limit = actionConfirmMaxLimit
}
cursor, err := decodeActionConfirmCursor(pageToken)
if err != nil {
return nil, "", err
}
items, err := s.repository.ListConversationActionConfirms(ctx, userID, peerUserID, limit+1, cursor.CreatedAtMS, cursor.ConfirmID)
if err != nil {
return nil, "", err
}
if len(items) <= limit {
return items, "", nil
}
page := items[:limit]
return page, encodeActionConfirmCursor(page[len(page)-1]), nil
}
// ClaimPendingOutbox locks pending action-message outbox rows for the MQ publisher.
func (s *ActionConfirmService) ClaimPendingOutbox(ctx context.Context, batchSize int, lockTTL time.Duration) ([]messagedomain.ActionConfirmOutbox, error) {
if err := s.requireRepository(); err != nil {
return nil, err
}
if batchSize <= 0 {
batchSize = 100
}
if lockTTL <= 0 {
lockTTL = 30 * time.Second
}
nowMS := s.now().UnixMilli()
return s.repository.ClaimPendingActionConfirmOutbox(ctx, s.workerID(), nowMS, nowMS+lockTTL.Milliseconds(), batchSize)
}
func (s *ActionConfirmService) MarkOutboxDelivered(ctx context.Context, eventID string) error {
return s.repository.MarkActionConfirmOutboxDelivered(ctx, eventID, s.now().UnixMilli())
}
func (s *ActionConfirmService) MarkOutboxRetryable(ctx context.Context, eventID string, cause error, nextRetryAtMS int64) error {
message := ""
if cause != nil {
message = cause.Error()
}
return s.repository.MarkActionConfirmOutboxRetryable(ctx, eventID, message, nextRetryAtMS, s.now().UnixMilli())
}
func (s *ActionConfirmService) requireRepository() error {
if s == nil || s.repository == nil {
return xerr.New(xerr.Unavailable, "action confirm repository is not configured")
}
return nil
}
func (s *ActionConfirmService) workerID() string {
nodeID := strings.TrimSpace(s.cfg.NodeID)
if nodeID == "" {
nodeID = "activity"
}
return "message-action-confirm-" + nodeID
}
type confirmIMPayload struct {
Type string `json:"type"`
Version int `json:"version"`
ConfirmID string `json:"confirm_id"`
BusinessType string `json:"business_type"`
BusinessSubtype string `json:"business_subtype"`
SenderUserID string `json:"sender_user_id"`
ReceiverUserID string `json:"receiver_user_id"`
Msg string `json:"msg"`
Actions []confirmIMAction `json:"actions"`
CreatedAtMS int64 `json:"created_at_ms"`
}
type confirmIMAction struct {
Action string `json:"action"`
Label string `json:"label"`
Text string `json:"text"`
Style string `json:"style,omitempty"`
}
func confirmIMPayloadJSON(confirmID string, event messagedomain.RoleInvitationConfirmEvent, messageText string, createdAtMS int64) (string, error) {
payload := confirmIMPayload{
Type: "im_confirm",
Version: 1,
ConfirmID: confirmID,
BusinessType: messagedomain.ActionConfirmBusinessRoleInvitation,
BusinessSubtype: event.InvitationType,
SenderUserID: strconv.FormatInt(event.InviterUserID, 10),
ReceiverUserID: strconv.FormatInt(event.TargetUserID, 10),
Msg: messageText,
Actions: []confirmIMAction{
// label 是当前 App 渲染按钮的字段text 保留给早期自定义消息解析,避免协议兼容回退。
{Action: messagedomain.ActionConfirmActionReject, Label: "Reject", Text: "Reject", Style: "secondary"},
{Action: messagedomain.ActionConfirmActionAccept, Label: "Accept", Text: "Accept", Style: "primary"},
},
CreatedAtMS: createdAtMS,
}
raw, err := json.Marshal(payload)
if err != nil {
return "", err
}
return string(raw), nil
}
func roleInvitationMessageText(event messagedomain.RoleInvitationConfirmEvent) string {
name := firstNonEmptyText(event.Inviter.Username, event.Inviter.DisplayUserID, strconv.FormatInt(event.InviterUserID, 10))
agencyName := firstNonEmptyText(event.AgencyName, "guild")
switch event.InvitationType {
case "host":
return fmt.Sprintf("%s has invited you to join the %s guild", name, agencyName)
case "agency":
return fmt.Sprintf("%s has invited you to create the %s guild", name, agencyName)
case "bd":
return fmt.Sprintf("%s has invited you to become BD", name)
default:
return fmt.Sprintf("%s has sent you a confirmation request", name)
}
}
func confirmStatusFromRoleInvitation(status string) string {
switch strings.TrimSpace(status) {
case "accepted":
return messagedomain.ActionConfirmStatusAccepted
case "rejected":
return messagedomain.ActionConfirmStatusRejected
case "cancelled":
return messagedomain.ActionConfirmStatusCanceled
case "expired":
return messagedomain.ActionConfirmStatusExpired
default:
return messagedomain.ActionConfirmStatusPending
}
}
func actionFromRoleInvitationStatus(status string, fallback string) string {
switch strings.TrimSpace(status) {
case "accepted":
return messagedomain.ActionConfirmActionAccept
case "rejected":
return messagedomain.ActionConfirmActionReject
default:
return fallback
}
}
func isTerminalConfirmStatus(status string) bool {
switch status {
case messagedomain.ActionConfirmStatusAccepted, messagedomain.ActionConfirmStatusRejected, messagedomain.ActionConfirmStatusExpired, messagedomain.ActionConfirmStatusCanceled, messagedomain.ActionConfirmStatusFailed:
return true
default:
return false
}
}
func firstNonEmptyText(values ...string) string {
for _, value := range values {
if value = strings.TrimSpace(value); value != "" {
return value
}
}
return ""
}
func firstPositiveInt64(values ...int64) int64 {
for _, value := range values {
if value > 0 {
return value
}
}
return 0
}
type actionConfirmCursor struct {
CreatedAtMS int64 `json:"created_at_ms"`
ConfirmID string `json:"confirm_id"`
}
func encodeActionConfirmCursor(confirm messagedomain.ActionConfirm) string {
raw, err := json.Marshal(actionConfirmCursor{CreatedAtMS: confirm.CreatedAtMS, ConfirmID: confirm.ConfirmID})
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(raw)
}
func decodeActionConfirmCursor(value string) (actionConfirmCursor, error) {
value = strings.TrimSpace(value)
if value == "" {
return actionConfirmCursor{}, nil
}
raw, err := base64.RawURLEncoding.DecodeString(value)
if err != nil {
return actionConfirmCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
}
var cursor actionConfirmCursor
if err := json.Unmarshal(raw, &cursor); err != nil {
return actionConfirmCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
}
if cursor.CreatedAtMS <= 0 || strings.TrimSpace(cursor.ConfirmID) == "" {
return actionConfirmCursor{}, xerr.New(xerr.PageTokenInvalid, "page token is invalid")
}
return cursor, nil
}