2026-05-19 19:50:50 +08:00

158 lines
3.7 KiB
Go

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)
}