H-193: add durable transfer acceptance (#31)

Co-authored-by: Rogee <rogee@ipao.vip>
This commit is contained in:
Rogee
2026-08-15 15:22:05 +08:00
committed by GitHub
co-authored by rogee
parent fe0fb6c6b2
commit 67eb80cb2b
10 changed files with 227 additions and 5 deletions
+4 -1
View File
@@ -75,9 +75,12 @@ http://shangwutong:9100/webhooks/gochat/v1
GET /healthz
GET /readyz
GET /metrics
POST /internal/reconcile # 仅 loopback
POST /internal/reconcile # 仅 loopback
POST /internal/operations/accept-transfer # 仅 loopback
```
接管请求体为 `{"inbox_id":10,"sid":"...","event_id":"...","occurred_at":"2026-08-15T07:00:00Z"}`。相同请求返回同一队列记录;只有 operation 进入 `delivered` 才证明 `oc/accepttransfer.aspx` 返回了 `r=ok`,最终坐席归属继续以现有 `swt_state=transfer_accepted``swt_assignee_name` 同步结果为准。
```bash
shangwutong migrate up
shangwutong migrate status
@@ -0,0 +1,22 @@
CREATE TABLE outbound_operations_old (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
swt_sid TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
operation TEXT NOT NULL CHECK(operation IN ('end_conversation', 'change_contact_name')),
payload TEXT NOT NULL,
occurred_at DATETIME NOT NULL,
delivery_status TEXT NOT NULL DEFAULT 'pending' CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed')),
claimed_at DATETIME,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at DATETIME,
last_error TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
);
-- Preserve the source table if accept_transfer rows make rollback unsafe.
INSERT INTO outbound_operations_old SELECT * FROM outbound_operations;
DROP TABLE outbound_operations;
ALTER TABLE outbound_operations_old RENAME TO outbound_operations;
CREATE INDEX idx_outbound_operations_ready ON outbound_operations(delivery_status, next_attempt_at, account_id, id);
CREATE UNIQUE INDEX idx_outbound_one_delivering_operation_per_account ON outbound_operations(account_id) WHERE delivery_status = 'delivering';
@@ -0,0 +1,23 @@
CREATE TABLE outbound_operations_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES accounts(id),
swt_sid TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE,
operation TEXT NOT NULL,
payload TEXT NOT NULL,
occurred_at DATETIME NOT NULL,
delivery_status TEXT NOT NULL DEFAULT 'pending',
claimed_at DATETIME,
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at DATETIME,
last_error TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CHECK(operation IN ('end_conversation', 'change_contact_name', 'accept_transfer')),
CHECK(delivery_status IN ('pending', 'delivering', 'delivered', 'uncertain', 'failed'))
);
INSERT INTO outbound_operations_new SELECT * FROM outbound_operations;
DROP TABLE outbound_operations;
ALTER TABLE outbound_operations_new RENAME TO outbound_operations;
CREATE INDEX idx_outbound_operations_ready ON outbound_operations(delivery_status, next_attempt_at, account_id, id);
CREATE UNIQUE INDEX idx_outbound_one_delivering_operation_per_account ON outbound_operations(account_id) WHERE delivery_status = 'delivering';
@@ -87,7 +87,7 @@ func TestOperationalCommands(t *testing.T) {
t.Fatalf("%v produced no output", args)
}
}
if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 3 {
if version, err := store.InspectDatabase(context.Background(), backupPath); err != nil || version != 4 {
t.Fatalf("backup version = %d, %v", version, err)
}
@@ -37,6 +37,7 @@ type MessageSender interface {
SendVoice(context.Context, swt.Session, string, swt.Upload) (swt.SendResult, error)
EndConversation(context.Context, swt.Session, string) error
ChangeContactName(context.Context, swt.Session, string, string, string) error
AcceptTransfer(context.Context, swt.Session, string) error
}
type ResultClient interface {
@@ -410,6 +411,8 @@ func (o *Outbound) processOperation(ctx context.Context) (bool, error) {
switch operation.Operation {
case "end_conversation":
return o.sender.EndConversation(ctx, session, operation.SwtSid)
case "accept_transfer":
return o.sender.AcceptTransfer(ctx, session, operation.SwtSid)
case "change_contact_name":
var envelope struct {
Data struct {
@@ -160,6 +160,42 @@ func TestOutboundConversationEndOperationUsesSameDurableWorker(t *testing.T) {
}
}
func TestOutboundAcceptTransferPersistsSuccessAndRetry(t *testing.T) {
ctx := context.Background()
database, account := deliveryDatabase(t, ctx)
first, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{
AccountID: account.ID, SWTSessionID: "visitor", EventID: "accept-transfer:visitor:1",
Operation: "accept_transfer", Payload: `{}`, OccurredAt: time.Now(),
})
if err != nil {
t.Fatal(err)
}
sender := &operationSender{}
worker, _ := NewOutbound(database, sessionStub{}, sender, &resultRecorder{}, nil, 1)
if worked, err := worker.processOperation(ctx); err != nil || !worked {
t.Fatalf("success = %v, %v", worked, err)
}
loaded, err := database.Reader().GetOutboundOperationByEventID(ctx, first.EventID)
if err != nil || loaded.DeliveryStatus != "delivered" || sender.acceptSID != "visitor" {
t.Fatalf("loaded=%#v sender=%#v err=%v", loaded, sender, err)
}
second, _, err := database.EnqueueOutboundOperation(ctx, store.OutboundOperationInput{
AccountID: account.ID, SWTSessionID: "visitor", EventID: "accept-transfer:visitor:2",
Operation: "accept_transfer", Payload: `{}`, OccurredAt: time.Now().Add(time.Second),
})
if err != nil {
t.Fatal(err)
}
sender.err = &swt.Error{Operation: "accept_transfer", Code: "server_err", Retryable: true, Err: errors.New("retry")}
if worked, err := worker.processOperation(ctx); err != nil || !worked {
t.Fatalf("retry = %v, %v", worked, err)
}
loaded, err = database.Reader().GetOutboundOperationByEventID(ctx, second.EventID)
if err != nil || loaded.DeliveryStatus != "pending" || loaded.Attempts != 1 || loaded.NextAttemptAt == nil || loaded.LastError == nil {
t.Fatalf("retried = %#v, %v", loaded, err)
}
}
func TestOutboundRetrySkipsAlreadyDeliveredParts(t *testing.T) {
ctx := context.Background()
database, account := deliveryDatabase(t, ctx)
@@ -317,7 +353,13 @@ func (s senderStub) ChangeContactName(context.Context, swt.Session, string, stri
return s.err
}
type operationSender struct{ sid string }
func (s senderStub) AcceptTransfer(context.Context, swt.Session, string) error { return s.err }
type operationSender struct {
sid string
acceptSID string
err error
}
func (*operationSender) SendText(context.Context, swt.Session, string, string) (swt.SendResult, error) {
return swt.SendResult{}, nil
@@ -343,6 +385,11 @@ func (*operationSender) ChangeContactName(context.Context, swt.Session, string,
return nil
}
func (s *operationSender) AcceptTransfer(_ context.Context, _ swt.Session, sid string) error {
s.acceptSID = sid
return s.err
}
type partialSender struct {
textCalls int
imageCalls int
@@ -375,6 +422,8 @@ func (*partialSender) ChangeContactName(context.Context, swt.Session, string, st
return nil
}
func (*partialSender) AcceptTransfer(context.Context, swt.Session, string) error { return nil }
func testUploadFetcher(t *testing.T) mediaFetcher {
t.Helper()
path := filepath.Join(t.TempDir(), "image.png")
@@ -184,9 +184,56 @@ func (s *Server) registerRoutes() {
}
return c.Status(http.StatusAccepted).JSON(fiber.Map{"accepted": true})
})
s.app.Post("/internal/operations/accept-transfer", s.acceptTransfer)
s.app.Post("/webhooks/gochat/v1", s.handleWebhook)
}
type acceptTransferRequest struct {
InboxID int64 `json:"inbox_id"`
SID string `json:"sid"`
EventID string `json:"event_id"`
OccurredAt time.Time `json:"occurred_at"`
}
func (s *Server) acceptTransfer(c fiber.Ctx) error {
if ip := net.ParseIP(c.IP()); ip == nil || !ip.IsLoopback() {
return s.writeError(c, http.StatusForbidden, "forbidden", "loopback access required", false)
}
var request acceptTransferRequest
if err := json.Unmarshal(c.Body(), &request); err != nil || request.InboxID <= 0 || strings.TrimSpace(request.SID) == "" || strings.TrimSpace(request.EventID) == "" || request.OccurredAt.IsZero() {
return s.writeError(c, http.StatusUnprocessableEntity, "invalid_accept_transfer", "inbox_id, sid, event_id and occurred_at are required", false)
}
request.SID, request.EventID = strings.TrimSpace(request.SID), strings.TrimSpace(request.EventID)
payload, _ := json.Marshal(request)
account, err := s.store.Reader().GetAccountByInboxID(c.Context(), request.InboxID)
if errors.Is(err, sql.ErrNoRows) {
return s.writeError(c, http.StatusNotFound, "account_not_found", "connector account was not found", false)
}
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "sqlite_unavailable", "account lookup failed", true)
}
if !store.AccountRunnable(account) {
return s.writeError(c, http.StatusConflict, "account_unavailable", "connector account is disabled or offline", false)
}
queued, duplicate, err := s.store.EnqueueOutboundOperation(c.Context(), store.OutboundOperationInput{
AccountID: account.ID, SWTSessionID: request.SID, EventID: request.EventID,
Operation: "accept_transfer", Payload: string(payload), OccurredAt: request.OccurredAt,
})
if errors.Is(err, store.ErrOutboundConflict) {
return s.writeError(c, http.StatusConflict, "idempotency_conflict", "accept transfer operation conflicts with an existing request", false)
}
if err != nil {
return s.writeError(c, http.StatusServiceUnavailable, "queue_failed", "accept transfer operation persistence failed", true)
}
status := http.StatusAccepted
if duplicate {
status = http.StatusOK
}
return c.Status(status).JSON(fiber.Map{
"accepted": true, "duplicate": duplicate, "event_id": request.EventID, "queue_id": queued.ID,
})
}
func (s *Server) handleWebhook(c fiber.Ctx) error {
body := append([]byte(nil), c.Body()...)
envelope, err := gochat.DecodeWebhook(body)
@@ -8,6 +8,7 @@ import (
"encoding/hex"
"encoding/json"
"io"
"net"
"net/http"
"path/filepath"
"strconv"
@@ -269,6 +270,49 @@ func TestConversationResolveWebhookQueuesDurableEndOperation(t *testing.T) {
}
}
func TestAcceptTransferRequestQueuesDurablyAndIsIdempotent(t *testing.T) {
server, database, now := newTestServer(t)
lifecycle := webhookBody(t, now, "inbox_created", map[string]any{"channel_type": "shangwutong", "config_version": 1})
if response := doWebhook(t, server, lifecycle, now, "secret"); response.StatusCode != http.StatusAccepted {
t.Fatalf("bootstrap status = %d", response.StatusCode)
}
payload, _ := json.Marshal(acceptTransferRequest{InboxID: 10, SID: "visitor", EventID: "accept-transfer:visitor:1", OccurredAt: now})
listener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
served := make(chan error, 1)
go func() { served <- server.App().Listener(listener) }()
t.Cleanup(func() {
_ = server.Shutdown(context.Background())
<-served
})
request := func(body []byte) *http.Response {
req, _ := http.NewRequest(http.MethodPost, "http://"+listener.Addr().String()+"/internal/operations/accept-transfer", bytes.NewReader(body))
response, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
return response
}
first := request(payload)
if body := readBody(first); first.StatusCode != http.StatusAccepted || !strings.Contains(body, `"duplicate":false`) {
t.Fatalf("first status=%d body=%s", first.StatusCode, body)
}
second := request(payload)
if body := readBody(second); second.StatusCode != http.StatusOK || !strings.Contains(body, `"duplicate":true`) {
t.Fatalf("duplicate status=%d body=%s", second.StatusCode, body)
}
operation, err := database.Reader().GetOutboundOperationByEventID(context.Background(), "accept-transfer:visitor:1")
if err != nil || operation.Operation != "accept_transfer" || operation.SwtSid != "visitor" {
t.Fatalf("operation = %#v, %v", operation, err)
}
invalid := request([]byte(`{"inbox_id":10,"event_id":"missing-sid","occurred_at":"2026-08-15T07:00:00Z"}`))
if body := readBody(invalid); invalid.StatusCode != http.StatusUnprocessableEntity || !strings.Contains(body, `"code":"invalid_accept_transfer"`) {
t.Fatalf("invalid status=%d body=%s", invalid.StatusCode, body)
}
}
func TestHealthMiddlewareAddsRequestIDAndRejectsLargeBodies(t *testing.T) {
server, _, _ := newTestServer(t)
request, _ := http.NewRequest(http.MethodGet, "/healthz", nil)
@@ -127,7 +127,8 @@ type OutboundOperationInput struct {
}
func (s *Store) EnqueueOutboundOperation(ctx context.Context, input OutboundOperationInput) (*dbgen.OutboundOperation, bool, error) {
if input.AccountID <= 0 || strings.TrimSpace(input.SWTSessionID) == "" || strings.TrimSpace(input.EventID) == "" || (input.Operation != "end_conversation" && input.Operation != "change_contact_name") || input.OccurredAt.IsZero() {
if input.AccountID <= 0 || strings.TrimSpace(input.SWTSessionID) == "" || strings.TrimSpace(input.EventID) == "" ||
(input.Operation != "end_conversation" && input.Operation != "change_contact_name" && input.Operation != "accept_transfer") || input.OccurredAt.IsZero() {
return nil, false, errors.New("valid outbound operation fields are required")
}
existing, err := s.writerQueries.GetOutboundOperationByEventID(ctx, input.EventID)
@@ -181,6 +181,36 @@ func TestRollbackMigrationRefusesRenameOperations(t *testing.T) {
}
}
func TestRollbackMigrationRefusesAcceptTransferOperations(t *testing.T) {
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "rollback.db"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec(`CREATE TABLE outbound_operations (
id INTEGER PRIMARY KEY AUTOINCREMENT, account_id INTEGER NOT NULL, swt_sid TEXT NOT NULL,
event_id TEXT NOT NULL UNIQUE, operation TEXT NOT NULL, payload TEXT NOT NULL,
occurred_at DATETIME NOT NULL, delivery_status TEXT NOT NULL, claimed_at DATETIME,
attempts INTEGER NOT NULL, next_attempt_at DATETIME, last_error TEXT,
created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL)`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO outbound_operations(account_id,swt_sid,event_id,operation,payload,occurred_at,delivery_status,attempts,created_at,updated_at) VALUES (1,'sid','event','accept_transfer','{}',CURRENT_TIMESTAMP,'pending',0,CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)`); err != nil {
t.Fatal(err)
}
rollback, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", "004_add_accept_transfer_operation.down.sql"))
if err != nil {
t.Fatal(err)
}
if _, err := db.Exec(string(rollback)); err == nil {
t.Fatal("expected rollback to reject accept_transfer")
}
var count int
if err := db.QueryRow("SELECT COUNT(*) FROM outbound_operations").Scan(&count); err != nil || count != 1 {
t.Fatalf("operation count = %d, err=%v", count, err)
}
}
func TestRollbackMigrationKeepsOldOperationsWhenRenameIsAbsent(t *testing.T) {
db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "rollback.db"))
if err != nil {
@@ -560,7 +590,7 @@ func TestOnlineBackupCanBeOpenedReadOnly(t *testing.T) {
t.Fatalf("backup mode = %v", info.Mode().Perm())
}
version, err := InspectDatabase(ctx, backup)
if err != nil || version != 3 {
if err != nil || version != 4 {
t.Fatalf("backup version = %d, %v", version, err)
}
}