package idempotency import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "strings" "time" "chatapp3-golang/internal/model" "chatapp3-golang/internal/utils" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( StatusProcessing = "PROCESSING" StatusSucceeded = "SUCCEEDED" StatusFailed = "FAILED" MaxKeyLength = 128 ) var ( ErrEmptyKey = errors.New("idempotency: key is empty") ErrKeyTooLong = errors.New("idempotency: key is too long") ErrRequestMismatch = errors.New("idempotency: request hash mismatch") ) type DB interface { WithContext(ctx context.Context) *gorm.DB } type Store struct { db DB } type BeginRequest struct { Activity string SysOrigin string UserID int64 IdempotencyKey string RequestHash string } type BeginResult struct { Record model.ActivityIdempotency Created bool } func NewStore(db DB) Store { return Store{db: db} } func NormalizeKey(key string) (string, error) { key = strings.TrimSpace(key) if key == "" { return "", ErrEmptyKey } if len(key) > MaxKeyLength { return "", ErrKeyTooLong } return key, nil } func Hash(parts ...any) string { payload, _ := json.Marshal(parts) sum := sha256.Sum256(payload) return hex.EncodeToString(sum[:]) } func (s Store) Begin(ctx context.Context, req BeginRequest) (BeginResult, error) { key, err := NormalizeKey(req.IdempotencyKey) if err != nil { return BeginResult{}, err } now := time.Now() id, err := utils.NextID() if err != nil { return BeginResult{}, err } row := model.ActivityIdempotency{ ID: id, Activity: strings.ToUpper(strings.TrimSpace(req.Activity)), SysOrigin: strings.ToUpper(strings.TrimSpace(req.SysOrigin)), UserID: req.UserID, IdempotencyKey: key, RequestHash: req.RequestHash, Status: StatusProcessing, CreateTime: now, UpdateTime: now, } result := s.db.WithContext(ctx).Clauses(clause.OnConflict{DoNothing: true}).Create(&row) if result.Error != nil { return BeginResult{}, result.Error } if result.RowsAffected == 1 { return BeginResult{Record: row, Created: true}, nil } var existing model.ActivityIdempotency err = s.db.WithContext(ctx). Where("activity = ? AND sys_origin = ? AND user_id = ? AND idempotency_key = ?", row.Activity, row.SysOrigin, row.UserID, row.IdempotencyKey). First(&existing).Error if err != nil { return BeginResult{}, err } if existing.RequestHash != req.RequestHash { return BeginResult{}, ErrRequestMismatch } return BeginResult{Record: existing, Created: false}, nil } func (s Store) MarkSucceeded(ctx context.Context, id int64, businessNo string, response any) error { responseJSON, err := json.Marshal(response) if err != nil { return err } return s.db.WithContext(ctx).Model(&model.ActivityIdempotency{}). Where("id = ?", id). Updates(map[string]any{ "business_no": strings.TrimSpace(businessNo), "status": StatusSucceeded, "response_json": string(responseJSON), "error_message": "", "update_time": time.Now(), }).Error } func (s Store) MarkFailed(ctx context.Context, id int64, businessNo string, err error) error { message := "" if err != nil { message = err.Error() } if len(message) > 1024 { message = message[:1024] } return s.db.WithContext(ctx).Model(&model.ActivityIdempotency{}). Where("id = ?", id). Updates(map[string]any{ "business_no": strings.TrimSpace(businessNo), "status": StatusFailed, "error_message": message, "update_time": time.Now(), }).Error } func DecodeResponse(record model.ActivityIdempotency, out any) error { if strings.TrimSpace(record.ResponseJSON) == "" { return fmt.Errorf("idempotency response is empty") } return json.Unmarshal([]byte(record.ResponseJSON), out) }