444 lines
18 KiB
Go
444 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"`
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
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{
|
|
{Action: messagedomain.ActionConfirmActionReject, Text: "Reject"},
|
|
{Action: messagedomain.ActionConfirmActionAccept, Text: "Accept"},
|
|
},
|
|
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
|
|
}
|