cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。 - 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。 - 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。 - 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。 - 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。 架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
"commilitia.net/cdrop/internal/hub"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("transfer: session not found")
|
||||
ErrForbidden = errors.New("transfer: not your session")
|
||||
ErrInvalidTransition = errors.New("transfer: invalid state transition")
|
||||
ErrRelayUnavailable = errors.New("transfer: relay unavailable")
|
||||
)
|
||||
|
||||
// SessionView is the session shape pushed to clients (omits internal columns).
|
||||
type SessionView struct {
|
||||
ID string `json:"id"`
|
||||
SenderName string `json:"sender_name"`
|
||||
FileName string `json:"file_name"`
|
||||
FileSize int64 `json:"file_size"`
|
||||
FileSHA256 string `json:"file_sha256,omitempty"`
|
||||
}
|
||||
|
||||
// RelayController is the subset of *relay.Manager the transfer service drives:
|
||||
// it reserves a relay slot when a transfer enters RELAY_ACTIVE (Register) and
|
||||
// frees it when the transfer reaches a terminal state (Release). Declared as an
|
||||
// interface so the transfer package doesn't gain a hard import of relay (avoids
|
||||
// cycles in tests).
|
||||
type RelayController interface {
|
||||
Register(id, userID, sender, receiver string) error
|
||||
Release(sessionID string)
|
||||
}
|
||||
|
||||
type nopRelay struct{}
|
||||
|
||||
func (nopRelay) Register(_, _, _, _ string) error { return nil }
|
||||
func (nopRelay) Release(string) {}
|
||||
|
||||
type Service struct {
|
||||
queries *db.Queries
|
||||
hub *hub.Hub
|
||||
relay RelayController
|
||||
|
||||
now func() time.Time
|
||||
newID func() string
|
||||
}
|
||||
|
||||
func NewService(queries *db.Queries, h *hub.Hub, r RelayController) *Service {
|
||||
if r == nil {
|
||||
r = nopRelay{}
|
||||
}
|
||||
return &Service{
|
||||
queries: queries,
|
||||
hub: h,
|
||||
relay: r,
|
||||
now: time.Now,
|
||||
newID: defaultNewID,
|
||||
}
|
||||
}
|
||||
|
||||
func defaultNewID() string {
|
||||
var b [16]byte
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// crypto/rand only fails if the OS RNG is broken; panicking is correct.
|
||||
panic(fmt.Sprintf("crypto/rand: %v", err))
|
||||
}
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// InitParams are the inputs to Init.
|
||||
type InitParams struct {
|
||||
UserID string
|
||||
SenderName string
|
||||
ReceiverName string
|
||||
FileName string
|
||||
FileSize int64
|
||||
FileSHA256 string
|
||||
}
|
||||
|
||||
// Init creates a new PENDING session and pushes transfer:incoming to the receiver.
|
||||
// Returns the new session ID. Receiver offline does not fail Init — the receiver
|
||||
// will see the session next time it connects via /api/devices or a fresh init.
|
||||
func (s *Service) Init(ctx context.Context, p InitParams) (string, error) {
|
||||
if p.SenderName == "" || p.ReceiverName == "" || p.FileName == "" {
|
||||
return "", errors.New("init: sender_name, receiver_name, file_name required")
|
||||
}
|
||||
if p.SenderName == p.ReceiverName {
|
||||
return "", errors.New("init: sender and receiver must differ")
|
||||
}
|
||||
// brief §2 explicitly says "不限文件大小"; 0-byte is legal (empty file
|
||||
// transfer still needs control frames). Negatives are nonsense.
|
||||
if p.FileSize < 0 {
|
||||
return "", errors.New("init: file_size cannot be negative")
|
||||
}
|
||||
|
||||
id := s.newID()
|
||||
createdAt := s.now().Unix()
|
||||
|
||||
var sha *string
|
||||
if p.FileSHA256 != "" {
|
||||
v := p.FileSHA256
|
||||
sha = &v
|
||||
}
|
||||
|
||||
if err := s.queries.CreateTransferSession(ctx, db.CreateTransferSessionParams{
|
||||
ID: id,
|
||||
UserID: p.UserID,
|
||||
SenderName: p.SenderName,
|
||||
ReceiverName: p.ReceiverName,
|
||||
FileName: p.FileName,
|
||||
FileSize: p.FileSize,
|
||||
FileSha256: sha,
|
||||
CreatedAt: createdAt,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("create session: %w", err)
|
||||
}
|
||||
|
||||
view := SessionView{
|
||||
ID: id,
|
||||
SenderName: p.SenderName,
|
||||
FileName: p.FileName,
|
||||
FileSize: p.FileSize,
|
||||
FileSHA256: p.FileSHA256,
|
||||
}
|
||||
autoAccept := p.FileSize <= AutoAcceptThreshold
|
||||
|
||||
s.hub.SendTo(p.UserID, p.ReceiverName, hub.Event{
|
||||
Type: "transfer:incoming",
|
||||
Data: map[string]any{
|
||||
"session": view,
|
||||
"auto_accept": autoAccept,
|
||||
},
|
||||
})
|
||||
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// Transition validates and applies a state change, then broadcasts transfer:state
|
||||
// to both sender and receiver. When entering RELAY_ACTIVE, additionally emits
|
||||
// transfer:relay_ready with the chunk/stream URLs (brief §5). When entering a
|
||||
// terminal state, releases the relay session if any.
|
||||
func (s *Service) Transition(
|
||||
ctx context.Context,
|
||||
userID, sessionID, target string,
|
||||
mode, reason string,
|
||||
bytes int64,
|
||||
) error {
|
||||
sess, err := s.queries.GetTransferSession(ctx, sessionID)
|
||||
if err != nil {
|
||||
return ErrNotFound
|
||||
}
|
||||
if sess.UserID != userID {
|
||||
return ErrForbidden
|
||||
}
|
||||
if !IsValidTransition(sess.State, target) {
|
||||
return fmt.Errorf("%w: %s→%s", ErrInvalidTransition, sess.State, target)
|
||||
}
|
||||
|
||||
// Reserve the relay slot BEFORE committing the state change: if the relay is
|
||||
// full (global or per-user cap) the transfer must not advance into a relay
|
||||
// mode it has no slot for — surface a retryable error instead. Registering
|
||||
// here also records the two legitimate participants, which is what later lets
|
||||
// the relay endpoints reject anyone who isn't the sender or receiver (R1/G2).
|
||||
if target == StateRelayActive {
|
||||
if err := s.relay.Register(sessionID, userID, sess.SenderName, sess.ReceiverName); err != nil {
|
||||
return fmt.Errorf("%w: %v", ErrRelayUnavailable, err)
|
||||
}
|
||||
}
|
||||
|
||||
args := db.UpdateTransferStateParams{
|
||||
State: target,
|
||||
ID: sessionID,
|
||||
}
|
||||
if mode != "" {
|
||||
v := mode
|
||||
args.Mode = &v
|
||||
}
|
||||
if reason != "" {
|
||||
v := reason
|
||||
args.FailReason = &v
|
||||
}
|
||||
if IsTerminal(target) {
|
||||
t := s.now().Unix()
|
||||
args.FinishedAt = &t
|
||||
}
|
||||
if bytes > 0 {
|
||||
v := bytes
|
||||
args.BytesTransferred = &v
|
||||
}
|
||||
|
||||
if _, err := s.queries.UpdateTransferState(ctx, args); err != nil {
|
||||
return fmt.Errorf("update state: %w", err)
|
||||
}
|
||||
|
||||
stateEv := hub.Event{
|
||||
Type: "transfer:state",
|
||||
Data: stateEventPayload(sessionID, target, mode, reason),
|
||||
}
|
||||
s.hub.SendTo(userID, sess.SenderName, stateEv)
|
||||
s.hub.SendTo(userID, sess.ReceiverName, stateEv)
|
||||
|
||||
if target == StateRelayActive {
|
||||
readyEv := hub.Event{
|
||||
Type: "transfer:relay_ready",
|
||||
Data: map[string]any{
|
||||
"session_id": sessionID,
|
||||
"sender_url": fmt.Sprintf("/api/relay/%s/chunk", sessionID),
|
||||
"receiver_url": fmt.Sprintf("/api/relay/%s/stream", sessionID),
|
||||
},
|
||||
}
|
||||
s.hub.SendTo(userID, sess.SenderName, readyEv)
|
||||
s.hub.SendTo(userID, sess.ReceiverName, readyEv)
|
||||
}
|
||||
|
||||
if IsTerminal(target) {
|
||||
s.relay.Release(sessionID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func stateEventPayload(sessionID, state, mode, reason string) map[string]any {
|
||||
p := map[string]any{
|
||||
"session_id": sessionID,
|
||||
"state": state,
|
||||
}
|
||||
if mode != "" {
|
||||
p["mode"] = mode
|
||||
}
|
||||
if reason != "" {
|
||||
p["reason"] = reason
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// ExpirePending cancels any PENDING sessions older than now-ttl and pushes
|
||||
// transfer:state to both peers. Returns the number of expired sessions.
|
||||
func (s *Service) ExpirePending(ctx context.Context, ttl time.Duration) (int, error) {
|
||||
now := s.now()
|
||||
cutoff := now.Add(-ttl).Unix()
|
||||
finishedAt := now.Unix()
|
||||
|
||||
rows, err := s.queries.ExpirePendingSessions(ctx, db.ExpirePendingSessionsParams{
|
||||
FinishedAt: &finishedAt,
|
||||
Cutoff: cutoff,
|
||||
})
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("expire pending: %w", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
ev := hub.Event{
|
||||
Type: "transfer:state",
|
||||
Data: stateEventPayload(r.ID, StateCancelled, "", "pending_timeout"),
|
||||
}
|
||||
s.hub.SendTo(r.UserID, r.SenderName, ev)
|
||||
s.hub.SendTo(r.UserID, r.ReceiverName, ev)
|
||||
s.relay.Release(r.ID)
|
||||
}
|
||||
return len(rows), nil
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"commilitia.net/cdrop/internal/db"
|
||||
"commilitia.net/cdrop/internal/hub"
|
||||
)
|
||||
|
||||
// recHub records SendTo calls without spinning up real SSE clients.
|
||||
// We can't easily mock *hub.Hub directly because it's a concrete type — instead
|
||||
// we feed it a fake DeviceLister and ignore the broadcast side; the fact that
|
||||
// SendTo to a non-connected client returns false is acceptable here.
|
||||
type recordingLister struct {
|
||||
mu sync.Mutex
|
||||
calls int
|
||||
}
|
||||
|
||||
func (r *recordingLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
r.calls++
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func newTestHub() *hub.Hub {
|
||||
return hub.New(&recordingLister{})
|
||||
}
|
||||
|
||||
func openTestDB(t *testing.T) *db.Queries {
|
||||
t.Helper()
|
||||
dbPath := filepath.Join(t.TempDir(), "x.db")
|
||||
conn, err := db.Open(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
if err := db.Bootstrap(context.Background(), conn); err != nil {
|
||||
t.Fatalf("bootstrap: %v", err)
|
||||
}
|
||||
return db.New(conn)
|
||||
}
|
||||
|
||||
func newTestService(t *testing.T) *Service {
|
||||
t.Helper()
|
||||
q := openTestDB(t)
|
||||
h := newTestHub()
|
||||
t.Cleanup(h.Close)
|
||||
return NewService(q, h, nil)
|
||||
}
|
||||
|
||||
// fakeRelay records Register/Release calls and can be primed to fail Register,
|
||||
// standing in for *relay.Manager so the transfer wiring can be exercised without
|
||||
// importing relay.
|
||||
type fakeRelay struct {
|
||||
registers [][4]string // {id, userID, sender, receiver}
|
||||
releases []string
|
||||
registerErr error
|
||||
}
|
||||
|
||||
func (f *fakeRelay) Register(id, userID, sender, receiver string) error {
|
||||
if f.registerErr != nil {
|
||||
return f.registerErr
|
||||
}
|
||||
f.registers = append(f.registers, [4]string{id, userID, sender, receiver})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRelay) Release(id string) { f.releases = append(f.releases, id) }
|
||||
|
||||
func newTestServiceWithRelay(t *testing.T, r RelayController) *Service {
|
||||
t.Helper()
|
||||
q := openTestDB(t)
|
||||
h := newTestHub()
|
||||
t.Cleanup(h.Close)
|
||||
return NewService(q, h, r)
|
||||
}
|
||||
|
||||
func TestInit_AllowsZeroByteFile(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
if _, err := svc.Init(context.Background(), InitParams{
|
||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "empty.txt", FileSize: 0,
|
||||
}); err != nil {
|
||||
t.Errorf("0-byte file must be accepted (brief §2 不限大小); got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_PersistsPending(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
id, err := svc.Init(context.Background(), InitParams{
|
||||
UserID: "alice",
|
||||
SenderName: "tab-1",
|
||||
ReceiverName: "tab-2",
|
||||
FileName: "doc.pdf",
|
||||
FileSize: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
if id == "" {
|
||||
t.Fatal("expected non-empty id")
|
||||
}
|
||||
|
||||
sess, err := svc.queries.GetTransferSession(context.Background(), id)
|
||||
if err != nil {
|
||||
t.Fatalf("get: %v", err)
|
||||
}
|
||||
if sess.State != StatePending {
|
||||
t.Errorf("state: got %q, want %q", sess.State, StatePending)
|
||||
}
|
||||
if sess.UserID != "alice" || sess.SenderName != "tab-1" || sess.ReceiverName != "tab-2" {
|
||||
t.Errorf("unexpected fields: %+v", sess)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInit_RejectsBadInput(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
bad := []InitParams{
|
||||
{UserID: "u", SenderName: "", ReceiverName: "r", FileName: "f", FileSize: 1},
|
||||
{UserID: "u", SenderName: "s", ReceiverName: "", FileName: "f", FileSize: 1},
|
||||
{UserID: "u", SenderName: "s", ReceiverName: "s", FileName: "f", FileSize: 1}, // self
|
||||
{UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "", FileSize: 1},
|
||||
{UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "f", FileSize: -1},
|
||||
}
|
||||
for i, p := range bad {
|
||||
if _, err := svc.Init(context.Background(), p); err == nil {
|
||||
t.Errorf("case %d should error: %+v", i, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransition_HappyPath_PendingToDone(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
id, err := svc.Init(ctx, InitParams{
|
||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "f", FileSize: 1024,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("init: %v", err)
|
||||
}
|
||||
|
||||
steps := []struct {
|
||||
state, mode string
|
||||
}{
|
||||
{StateAccepted, ""},
|
||||
{StateP2PActive, ModeP2P},
|
||||
{StateDone, ""},
|
||||
}
|
||||
for _, step := range steps {
|
||||
if err := svc.Transition(ctx, "u", id, step.state, step.mode, "", 1024); err != nil {
|
||||
t.Fatalf("transition to %s: %v", step.state, err)
|
||||
}
|
||||
}
|
||||
sess, _ := svc.queries.GetTransferSession(ctx, id)
|
||||
if sess.State != StateDone {
|
||||
t.Errorf("final state: got %q, want %q", sess.State, StateDone)
|
||||
}
|
||||
if sess.Mode == nil || *sess.Mode != ModeP2P {
|
||||
t.Errorf("mode: got %v, want %q", sess.Mode, ModeP2P)
|
||||
}
|
||||
if sess.FinishedAt == nil {
|
||||
t.Error("FinishedAt should be set on terminal state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransition_FallbackToRelayThenDone(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
id, _ := svc.Init(ctx, InitParams{
|
||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "f", FileSize: 10,
|
||||
})
|
||||
for _, st := range []string{StateAccepted, StateP2PActive, StateRelayActive, StateDone} {
|
||||
mode := ""
|
||||
switch st {
|
||||
case StateP2PActive:
|
||||
mode = ModeP2P
|
||||
case StateRelayActive:
|
||||
mode = ModeRelay
|
||||
}
|
||||
if err := svc.Transition(ctx, "u", id, st, mode, "", 0); err != nil {
|
||||
t.Fatalf("transition to %s: %v", st, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Entering RELAY_ACTIVE must reserve a relay slot, recording the transfer's two
|
||||
// participants so the relay endpoints can later reject anyone else (R1/G2).
|
||||
func TestTransition_RegistersRelayOnFallback(t *testing.T) {
|
||||
fr := &fakeRelay{}
|
||||
svc := newTestServiceWithRelay(t, fr)
|
||||
ctx := context.Background()
|
||||
id, _ := svc.Init(ctx, InitParams{
|
||||
UserID: "u", SenderName: "laptop", ReceiverName: "phone",
|
||||
FileName: "f", FileSize: 10,
|
||||
})
|
||||
if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
if err := svc.Transition(ctx, "u", id, StateRelayActive, ModeRelay, "", 0); err != nil {
|
||||
t.Fatalf("fallback: %v", err)
|
||||
}
|
||||
if len(fr.registers) != 1 {
|
||||
t.Fatalf("expected 1 Register call, got %d", len(fr.registers))
|
||||
}
|
||||
if got := fr.registers[0]; got != [4]string{id, "u", "laptop", "phone"} {
|
||||
t.Errorf("Register args: got %v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// When the relay is full, the fallback must NOT advance the transfer into relay
|
||||
// mode: Transition surfaces ErrRelayUnavailable and the persisted state is
|
||||
// unchanged so the client can retry.
|
||||
func TestTransition_RelayFullSurfacesUnavailable(t *testing.T) {
|
||||
fr := &fakeRelay{registerErr: errors.New("too many")}
|
||||
svc := newTestServiceWithRelay(t, fr)
|
||||
ctx := context.Background()
|
||||
id, _ := svc.Init(ctx, InitParams{
|
||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "f", FileSize: 10,
|
||||
})
|
||||
if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil {
|
||||
t.Fatalf("accept: %v", err)
|
||||
}
|
||||
err := svc.Transition(ctx, "u", id, StateRelayActive, ModeRelay, "", 0)
|
||||
if !errors.Is(err, ErrRelayUnavailable) {
|
||||
t.Fatalf("expected ErrRelayUnavailable, got %v", err)
|
||||
}
|
||||
sess, _ := svc.queries.GetTransferSession(ctx, id)
|
||||
if sess.State != StateAccepted {
|
||||
t.Errorf("state must be unchanged on relay-full; got %q, want ACCEPTED", sess.State)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransition_RejectsInvalidJump(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
id, _ := svc.Init(ctx, InitParams{
|
||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "f", FileSize: 10,
|
||||
})
|
||||
// PENDING → DONE 仍非法(必须先 ACCEPTED 或 P2P_ACTIVE / RELAY_ACTIVE 至少一步)
|
||||
if err := svc.Transition(ctx, "u", id, StateDone, "", "", 0); err == nil {
|
||||
t.Error("PENDING→DONE should be rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransition_PendingToActiveAllowed(t *testing.T) {
|
||||
// Race tolerance: sender may call /p2p before receiver's /accept commits.
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
id, _ := svc.Init(ctx, InitParams{
|
||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "f", FileSize: 10,
|
||||
})
|
||||
if err := svc.Transition(ctx, "u", id, StateP2PActive, ModeP2P, "", 0); err != nil {
|
||||
t.Errorf("PENDING→P2P_ACTIVE should be allowed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransition_AcceptedToDoneAllowed(t *testing.T) {
|
||||
// Race tolerance: receiver may finish receiving (call /done) before /p2p ever fired.
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
id, _ := svc.Init(ctx, InitParams{
|
||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "f", FileSize: 10,
|
||||
})
|
||||
if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil {
|
||||
t.Fatalf("PENDING→ACCEPTED: %v", err)
|
||||
}
|
||||
if err := svc.Transition(ctx, "u", id, StateDone, "", "", 10); err != nil {
|
||||
t.Errorf("ACCEPTED→DONE should be allowed (race-tolerant): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransition_RejectsForeignUser(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
id, _ := svc.Init(ctx, InitParams{
|
||||
UserID: "alice", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "f", FileSize: 10,
|
||||
})
|
||||
if err := svc.Transition(ctx, "mallory", id, StateAccepted, "", "", 0); err == nil {
|
||||
t.Error("foreign user must be forbidden")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTransition_NotFound(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
if err := svc.Transition(context.Background(), "u", "nope", StateAccepted, "", "", 0); err == nil {
|
||||
t.Error("unknown id should error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpirePending_CancelsOldSessions(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
|
||||
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
svc.now = func() time.Time { return old }
|
||||
|
||||
id, _ := svc.Init(ctx, InitParams{
|
||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "f", FileSize: 10,
|
||||
})
|
||||
|
||||
// jump 200s into the future; PendingTTL is 120s so this should expire.
|
||||
svc.now = func() time.Time { return old.Add(200 * time.Second) }
|
||||
n, err := svc.ExpirePending(ctx, PendingTTL)
|
||||
if err != nil {
|
||||
t.Fatalf("expire: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("expired count: got %d, want 1", n)
|
||||
}
|
||||
sess, _ := svc.queries.GetTransferSession(ctx, id)
|
||||
if sess.State != StateCancelled {
|
||||
t.Errorf("state: got %q, want %q", sess.State, StateCancelled)
|
||||
}
|
||||
if sess.FailReason == nil || *sess.FailReason != "pending_timeout" {
|
||||
t.Errorf("fail_reason: got %v, want pending_timeout", sess.FailReason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExpirePending_LeavesFreshAlone(t *testing.T) {
|
||||
svc := newTestService(t)
|
||||
ctx := context.Background()
|
||||
id, _ := svc.Init(ctx, InitParams{
|
||||
UserID: "u", SenderName: "s", ReceiverName: "r",
|
||||
FileName: "f", FileSize: 10,
|
||||
})
|
||||
n, err := svc.ExpirePending(ctx, PendingTTL)
|
||||
if err != nil {
|
||||
t.Fatalf("expire: %v", err)
|
||||
}
|
||||
if n != 0 {
|
||||
t.Errorf("expired count: got %d, want 0", n)
|
||||
}
|
||||
sess, _ := svc.queries.GetTransferSession(ctx, id)
|
||||
if sess.State != StatePending {
|
||||
t.Errorf("state: got %q, want PENDING", sess.State)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package transfer
|
||||
|
||||
const (
|
||||
StatePending = "PENDING"
|
||||
StateAccepted = "ACCEPTED"
|
||||
StateP2PActive = "P2P_ACTIVE"
|
||||
StateRelayActive = "RELAY_ACTIVE"
|
||||
StateDone = "DONE"
|
||||
StateFailed = "FAILED"
|
||||
StateCancelled = "CANCELLED"
|
||||
)
|
||||
|
||||
const (
|
||||
ModeP2P = "p2p"
|
||||
ModeRelay = "relay"
|
||||
)
|
||||
|
||||
// AutoAcceptThreshold is the file size at/under which the receiver may
|
||||
// silently auto-accept a transfer. brief §2: "> 100 MB 二次确认".
|
||||
const AutoAcceptThreshold = 100 * 1024 * 1024
|
||||
|
||||
// IsValidTransition reports whether a transfer may move from→to.
|
||||
//
|
||||
// Forward-only: any state can advance to a "later" state, but never go back.
|
||||
// We're permissive because client→server timing races are real:
|
||||
// - sender's /p2p can land before receiver's /accept commits → PENDING → P2P_ACTIVE
|
||||
// - either side may call /done before /p2p ever fired → ACCEPTED → DONE
|
||||
// Strict ordering would surface as 409s the user can't recover from.
|
||||
//
|
||||
// Terminal states (DONE / FAILED / CANCELLED) accept no further transitions.
|
||||
func IsValidTransition(from, to string) bool {
|
||||
switch from {
|
||||
case StatePending:
|
||||
return to == StateAccepted || to == StateP2PActive || to == StateRelayActive ||
|
||||
to == StateCancelled || to == StateFailed
|
||||
case StateAccepted:
|
||||
return to == StateP2PActive || to == StateRelayActive ||
|
||||
to == StateDone || to == StateCancelled || to == StateFailed
|
||||
case StateP2PActive:
|
||||
// P2P 失败自动 fallback 到 Relay (brief §2).
|
||||
return to == StateRelayActive || to == StateDone ||
|
||||
to == StateFailed || to == StateCancelled
|
||||
case StateRelayActive:
|
||||
return to == StateDone || to == StateFailed || to == StateCancelled
|
||||
case StateDone, StateFailed, StateCancelled:
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// IsTerminal reports whether the state is final (no further transitions).
|
||||
func IsTerminal(state string) bool {
|
||||
return state == StateDone || state == StateFailed || state == StateCancelled
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package transfer
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsValidTransition(t *testing.T) {
|
||||
allowed := map[string][]string{
|
||||
StatePending: {StateAccepted, StateP2PActive, StateRelayActive, StateCancelled, StateFailed},
|
||||
StateAccepted: {StateP2PActive, StateRelayActive, StateDone, StateCancelled, StateFailed},
|
||||
StateP2PActive: {StateRelayActive, StateDone, StateFailed, StateCancelled},
|
||||
StateRelayActive: {StateDone, StateFailed, StateCancelled},
|
||||
}
|
||||
all := []string{StatePending, StateAccepted, StateP2PActive, StateRelayActive,
|
||||
StateDone, StateFailed, StateCancelled}
|
||||
|
||||
for from, tos := range allowed {
|
||||
ok := map[string]bool{}
|
||||
for _, to := range tos {
|
||||
ok[to] = true
|
||||
}
|
||||
for _, to := range all {
|
||||
got := IsValidTransition(from, to)
|
||||
want := ok[to]
|
||||
if got != want {
|
||||
t.Errorf("transition %s→%s: got %v, want %v", from, to, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Terminals reject all outbound.
|
||||
for _, term := range []string{StateDone, StateFailed, StateCancelled} {
|
||||
for _, to := range all {
|
||||
if IsValidTransition(term, to) {
|
||||
t.Errorf("terminal %s should not allow transition to %s", term, to)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsTerminal(t *testing.T) {
|
||||
cases := map[string]bool{
|
||||
StatePending: false,
|
||||
StateAccepted: false,
|
||||
StateP2PActive: false,
|
||||
StateRelayActive: false,
|
||||
StateDone: true,
|
||||
StateFailed: true,
|
||||
StateCancelled: true,
|
||||
}
|
||||
for s, want := range cases {
|
||||
if got := IsTerminal(s); got != want {
|
||||
t.Errorf("IsTerminal(%s): got %v, want %v", s, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package transfer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
// PendingTTL is the wall-clock window after which a PENDING session
|
||||
// is auto-cancelled by the sweeper (plan §4.5 待决策清单 #5).
|
||||
const PendingTTL = 120 * time.Second
|
||||
|
||||
// SweepInterval is how often the sweeper goroutine wakes up.
|
||||
const SweepInterval = 30 * time.Second
|
||||
|
||||
// RunSweeper blocks until ctx is cancelled, periodically expiring
|
||||
// stale PENDING sessions.
|
||||
func RunSweeper(ctx context.Context, svc *Service) {
|
||||
t := time.NewTicker(SweepInterval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
n, err := svc.ExpirePending(ctx, PendingTTL)
|
||||
if err != nil {
|
||||
slog.Error("sweeper: expire pending failed", "err", err)
|
||||
continue
|
||||
}
|
||||
if n > 0 {
|
||||
slog.Info("sweeper: expired pending sessions", "count", n)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user