447 lines
18 KiB
Go
447 lines
18 KiB
Go
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
|
||
}
|