问题:移动网络共享出口 IP 会造成游客取号被误限流。 实现:移除公开取号 IP 限制,改用项目总量与手机号 HMAC 限流,并支持 Retry-After 倒计时。
342 lines
13 KiB
Go
342 lines
13 KiB
Go
package httpapi
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"calllinesystem/server/internal/config"
|
|
"calllinesystem/server/internal/database"
|
|
"calllinesystem/server/internal/model"
|
|
"calllinesystem/server/internal/security"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
func TestPublicCreateTicketIgnoresDuplicateFromEndedSession(t *testing.T) {
|
|
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
db, err := database.Open(ctx, dsn, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer database.Close(db)
|
|
sqlDB, err := database.SQLDB(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.Migrate(ctx, sqlDB, logger); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
server, err := New(db, config.Config{
|
|
Environment: "development",
|
|
EncryptionKey: bytes.Repeat([]byte{0x41}, 32),
|
|
PhoneHMACKey: bytes.Repeat([]byte{0x42}, 32),
|
|
}, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, 7, 28, 1, 0, 0, 0, time.UTC)
|
|
server.now = func() time.Time { return now }
|
|
|
|
var actor model.User
|
|
if err := db.Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projectID := uuid.NewString()
|
|
project := model.Project{
|
|
ID: projectID, Code: strings.ToUpper("PUB" + uuid.NewString()[:6]), Name: "Public duplicate regression",
|
|
Status: model.ProjectRunning, Timezone: "Asia/Shanghai", TicketPrefix: "A",
|
|
CallBatchSize: 5, CallMode: model.CallModeBoth,
|
|
MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100,
|
|
MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5,
|
|
ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 60,
|
|
ContinuousRatePerMinute: 2, ETABufferMinutes: 5, ETAIntervalSeconds: 60,
|
|
VisitorNotice: "", DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&project).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
endedAt := now.Add(-12 * time.Hour)
|
|
oldSession := model.QueueSession{
|
|
ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now.AddDate(0, 0, -1),
|
|
Status: "ENDED", NextTicketNumber: 2, Revision: 1,
|
|
OpenedAt: now.Add(-24 * time.Hour), ClosedAt: &endedAt, CreatedAt: now.Add(-24 * time.Hour), UpdatedAt: endedAt,
|
|
}
|
|
currentSession := model.QueueSession{
|
|
ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now,
|
|
Status: "RUNNING", NextTicketNumber: 1, Revision: 0,
|
|
OpenedAt: now, CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&oldSession).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(¤tSession).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
phone := "13800138000"
|
|
phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
phoneHMAC := server.cipher.Digest(phone)
|
|
oldTicket := model.QueueTicket{
|
|
ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: oldSession.ID,
|
|
TicketNumber: 1, DisplayNumber: "00001", PartySize: 1,
|
|
PublicTokenHash: security.HashToken(uuid.NewString()),
|
|
PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC,
|
|
Honorific: "游客", Status: model.TicketWaiting, JoinedAt: oldSession.OpenedAt,
|
|
PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: actor.ID,
|
|
CreatedAt: oldSession.OpenedAt, UpdatedAt: oldSession.OpenedAt,
|
|
}
|
|
if err := db.Create(&oldTicket).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets",
|
|
strings.NewReader(`{"phone":"13800138000","honorific":"游客","party_size":1,"allow_duplicate":false}`))
|
|
request.Header.Set("Idempotency-Key", "public-ended-session-"+uuid.NewString())
|
|
|
|
server.Handler().ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("status = %d, want 201; body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
|
|
duplicateRecorder := httptest.NewRecorder()
|
|
duplicateRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets",
|
|
strings.NewReader(`{"phone":"13800138000","honorific":"游客","party_size":1,"allow_duplicate":false}`))
|
|
duplicateRequest.Header.Set("Idempotency-Key", "public-current-session-"+uuid.NewString())
|
|
|
|
server.Handler().ServeHTTP(duplicateRecorder, duplicateRequest)
|
|
|
|
if duplicateRecorder.Code != http.StatusConflict || !strings.Contains(duplicateRecorder.Body.String(), `"code":"DUPLICATE_PHONE"`) {
|
|
t.Fatalf("current-session duplicate status = %d, want 409 DUPLICATE_PHONE; body = %s",
|
|
duplicateRecorder.Code, duplicateRecorder.Body.String())
|
|
}
|
|
|
|
calledAt := now
|
|
result := db.Model(&model.QueueTicket{}).
|
|
Where("project_id = ? AND queue_session_id = ? AND phone_hmac = ? AND status = ?",
|
|
projectID, currentSession.ID, phoneHMAC, model.TicketWaiting).
|
|
Updates(map[string]any{"status": model.TicketCalled, "called_at": calledAt, "updated_at": calledAt})
|
|
if result.Error != nil {
|
|
t.Fatal(result.Error)
|
|
}
|
|
if result.RowsAffected != 1 {
|
|
t.Fatalf("called tickets = %d, want 1", result.RowsAffected)
|
|
}
|
|
|
|
afterCallRecorder := httptest.NewRecorder()
|
|
afterCallRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets",
|
|
strings.NewReader(`{"phone":"13800138000","honorific":"游客","party_size":1,"allow_duplicate":false}`))
|
|
afterCallRequest.Header.Set("Idempotency-Key", "public-after-call-"+uuid.NewString())
|
|
|
|
server.Handler().ServeHTTP(afterCallRecorder, afterCallRequest)
|
|
|
|
if afterCallRecorder.Code != http.StatusCreated {
|
|
t.Fatalf("after-call status = %d, want 201; body = %s", afterCallRecorder.Code, afterCallRecorder.Body.String())
|
|
}
|
|
|
|
server.publicTicketProjectLimiter = newQueryLimiter(server.now, 10, time.Minute)
|
|
server.publicTicketPhoneLimiter = newQueryLimiter(server.now, 1, time.Minute)
|
|
var replayBody, replayKey string
|
|
for index := 0; index < 6; index++ {
|
|
body := fmt.Sprintf(`{"phone":"1390000%04d","party_size":1}`, index)
|
|
key := "same-mobile-ip-" + uuid.NewString()
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(body))
|
|
request.RemoteAddr = "198.51.100.27:42000"
|
|
request.Header.Set("Idempotency-Key", key)
|
|
server.Handler().ServeHTTP(recorder, request)
|
|
if recorder.Code != http.StatusCreated {
|
|
t.Fatalf("same-IP distinct phone %d status = %d, want 201; body = %s", index, recorder.Code, recorder.Body.String())
|
|
}
|
|
if index == 0 {
|
|
replayBody, replayKey = body, key
|
|
}
|
|
}
|
|
|
|
replayRecorder := httptest.NewRecorder()
|
|
replayRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(replayBody))
|
|
replayRequest.RemoteAddr = "198.51.100.27:42000"
|
|
replayRequest.Header.Set("Idempotency-Key", replayKey)
|
|
server.Handler().ServeHTTP(replayRecorder, replayRequest)
|
|
if replayRecorder.Code != http.StatusCreated {
|
|
t.Fatalf("idempotent replay status = %d, want 201; body = %s", replayRecorder.Code, replayRecorder.Body.String())
|
|
}
|
|
|
|
limitedRecorder := httptest.NewRecorder()
|
|
limitedRequest := httptest.NewRequest(http.MethodPost, "/api/public/projects/"+projectID+"/tickets", strings.NewReader(replayBody))
|
|
limitedRequest.RemoteAddr = "203.0.113.44:42000"
|
|
limitedRequest.Header.Set("Idempotency-Key", "new-intent-"+uuid.NewString())
|
|
server.Handler().ServeHTTP(limitedRecorder, limitedRequest)
|
|
if limitedRecorder.Code != http.StatusTooManyRequests {
|
|
t.Fatalf("same project/phone new intent status = %d, want 429; body = %s", limitedRecorder.Code, limitedRecorder.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestInternalPhoneLookupUsesLatestActiveSessionPerProject(t *testing.T) {
|
|
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
|
if dsn == "" {
|
|
t.Skip("TEST_DATABASE_URL is not set")
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
db, err := database.Open(ctx, dsn, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer database.Close(db)
|
|
sqlDB, err := database.SQLDB(db)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := database.Migrate(ctx, sqlDB, logger); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
server, err := New(db, config.Config{
|
|
Environment: "production",
|
|
EncryptionKey: bytes.Repeat([]byte{0x51}, 32),
|
|
PhoneHMACKey: bytes.Repeat([]byte{0x52}, 32),
|
|
}, logger)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
now := time.Date(2026, 7, 28, 2, 42, 35, 0, time.UTC)
|
|
server.now = func() time.Time { return now }
|
|
|
|
var actor model.User
|
|
if err := db.Where("username = ?", model.PublicVisitorUsername).First(&actor).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projectID := uuid.NewString()
|
|
project := model.Project{
|
|
ID: projectID, Code: strings.ToUpper("LOOK" + uuid.NewString()[:6]), Name: "Phone lookup duplicate regression",
|
|
Status: model.ProjectRunning, Timezone: "Asia/Shanghai", TicketPrefix: "A",
|
|
CallBatchSize: 5, CallMode: model.CallModeBoth,
|
|
MaxCallTicketCount: 100, DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100,
|
|
MinPartySize: 1, MaxPartySize: 10, GracePeriodMinutes: 5,
|
|
ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 60,
|
|
ContinuousRatePerMinute: 2, ETABufferMinutes: 5, ETAIntervalSeconds: 60,
|
|
VisitorNotice: "", DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&project).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
staleSession := model.QueueSession{
|
|
ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now.AddDate(0, 0, -1),
|
|
Status: "RUNNING", NextTicketNumber: 2, Revision: 1,
|
|
OpenedAt: now.Add(-24 * time.Hour), CreatedAt: now.Add(-24 * time.Hour), UpdatedAt: now.Add(-24 * time.Hour),
|
|
}
|
|
currentSession := model.QueueSession{
|
|
ID: uuid.NewString(), ProjectID: projectID, BusinessDate: now,
|
|
Status: "RUNNING", NextTicketNumber: 3, Revision: 3,
|
|
OpenedAt: now, CreatedAt: now, UpdatedAt: now,
|
|
}
|
|
if err := db.Create(&staleSession).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := db.Create(¤tSession).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
phone := "18286070628"
|
|
phoneCiphertext, phoneNonce, err := server.cipher.Encrypt(phone, []byte("phone:"+projectID))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
phoneHMAC := server.cipher.Digest(phone)
|
|
ticketFixtures := []struct {
|
|
session model.QueueSession
|
|
ticketNumber int
|
|
displayNumber string
|
|
}{
|
|
{session: staleSession, ticketNumber: 1, displayNumber: "00001"},
|
|
{session: currentSession, ticketNumber: 1, displayNumber: "00001"},
|
|
{session: currentSession, ticketNumber: 2, displayNumber: "00002"},
|
|
}
|
|
for _, fixture := range ticketFixtures {
|
|
ticket := model.QueueTicket{
|
|
ID: uuid.NewString(), ProjectID: projectID, QueueSessionID: fixture.session.ID,
|
|
TicketNumber: fixture.ticketNumber, DisplayNumber: fixture.displayNumber, PartySize: 1,
|
|
PublicTokenHash: security.HashToken(uuid.NewString()),
|
|
PhoneCiphertext: phoneCiphertext, PhoneNonce: phoneNonce, PhoneHMAC: &phoneHMAC,
|
|
Honorific: "游客", Status: model.TicketWaiting, JoinedAt: fixture.session.OpenedAt,
|
|
PersonalDataPurgeAt: now.Add(30 * 24 * time.Hour), CreatedBy: actor.ID,
|
|
CreatedAt: fixture.session.OpenedAt, UpdatedAt: fixture.session.OpenedAt,
|
|
}
|
|
if err := db.Create(&ticket).Error; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
recorder := httptest.NewRecorder()
|
|
request := httptest.NewRequest(http.MethodPost, "/api/internal/status/search", strings.NewReader(`{"phone":"18286070628"}`))
|
|
request.RemoteAddr = "10.23.4.5:42000"
|
|
server.Handler().ServeHTTP(recorder, request)
|
|
|
|
if recorder.Code != http.StatusOK {
|
|
t.Fatalf("status = %d, want 200; body = %s", recorder.Code, recorder.Body.String())
|
|
}
|
|
var response struct {
|
|
Tickets []struct {
|
|
TicketNumber string `json:"ticket_number"`
|
|
Revision int64 `json:"revision"`
|
|
} `json:"tickets"`
|
|
}
|
|
if err := json.Unmarshal(recorder.Body.Bytes(), &response); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(response.Tickets) != 2 {
|
|
t.Fatalf("tickets = %#v, want only the two latest-session tickets", response.Tickets)
|
|
}
|
|
if response.Tickets[0].TicketNumber != "00001" || response.Tickets[1].TicketNumber != "00002" {
|
|
t.Fatalf("tickets = %#v, want distinct current-session tickets 00001 and 00002", response.Tickets)
|
|
}
|
|
for _, ticket := range response.Tickets {
|
|
if ticket.Revision != currentSession.Revision {
|
|
t.Fatalf("ticket = %#v, want current session revision %d", ticket, currentSession.Revision)
|
|
}
|
|
}
|
|
|
|
displayRecorder := httptest.NewRecorder()
|
|
displayRequest := httptest.NewRequest(http.MethodGet, "/api/display/"+strings.ToLower(project.Code)+"/snapshot", nil)
|
|
server.Handler().ServeHTTP(displayRecorder, displayRequest)
|
|
if displayRecorder.Code != http.StatusOK {
|
|
t.Fatalf("display status = %d, want 200; body = %s", displayRecorder.Code, displayRecorder.Body.String())
|
|
}
|
|
var displayResponse struct {
|
|
IssuedTicketCount int `json:"issued_ticket_count"`
|
|
LatestTicketNumber *string `json:"latest_ticket_number"`
|
|
}
|
|
if err := json.Unmarshal(displayRecorder.Body.Bytes(), &displayResponse); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if displayResponse.IssuedTicketCount != 2 || displayResponse.LatestTicketNumber == nil || *displayResponse.LatestTicketNumber != "00002" {
|
|
t.Fatalf("display metrics = %#v, want issued 2 and latest 00002", displayResponse)
|
|
}
|
|
}
|