WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+112
View File
@@ -0,0 +1,112 @@
package resumable
import (
"encoding/json"
"errors"
"net/http"
"strconv"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
func respond(c *gin.Context, status int, value any, err error) {
if err == nil {
c.JSON(status, gin.H{"success": true, "data": value})
return
}
message := publicError(err)
switch {
case errors.Is(err, gorm.ErrRecordNotFound):
status = 404
message = "上传不存在或不属于当前用户"
case errors.Is(err, ErrForbidden):
status = 403
case errors.Is(err, ErrInvalid):
status = 400
case errors.Is(err, ErrConflict):
status = 409
case errors.Is(err, ErrQuota):
status = 413
default:
var input UserError
if errors.As(err, &input) {
status = 400
} else {
status = 500
logger.Errorf(c.Request.Context(), "Upload request failed: %v", err)
}
}
c.AbortWithStatusJSON(status, gin.H{"success": false, "message": message})
}
// Register requires the existing authenticated API group. Every operation also
// resolves tenant and real-user identity and enforces upload ownership itself.
func (s *Service) Register(group *gin.RouterGroup) {
r := group.Group("/uploads")
r.GET("", func(c *gin.Context) {
rows, e := s.List(c.Request.Context(), c.Query("kind"), c.Query("target_id"))
if e == nil && c.Query("view") == "summary" {
for i := range rows {
rows[i].Result = uploadResultSummary(rows[i].Result)
}
}
respond(c, 200, rows, e)
})
r.POST("", func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 40<<10)
var in CreateInput
if c.ShouldBindJSON(&in) != nil {
respond(c, 400, nil, ErrInvalid)
return
}
row, e := s.Create(c.Request.Context(), in)
respond(c, 201, row, e)
})
r.GET("/:upload_id", func(c *gin.Context) {
row, e := s.Get(c.Request.Context(), c.Param("upload_id"))
respond(c, 200, row, e)
})
r.GET("/:upload_id/chunks", func(c *gin.Context) {
offset, e := strconv.ParseInt(c.DefaultQuery("offset", "0"), 10, 64)
if e != nil {
respond(c, 400, nil, ErrInvalid)
return
}
chunks, e := s.Chunks(c.Request.Context(), c.Param("upload_id"), offset)
respond(c, 200, chunks, e)
})
r.PUT("/:upload_id/chunks", func(c *gin.Context) {
offset, e := strconv.ParseInt(c.GetHeader("Upload-Offset"), 10, 64)
if e != nil {
respond(c, 400, nil, ErrInvalid)
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, ChunkSize)
row, e := s.Put(c.Request.Context(), c.Param("upload_id"), offset, c.Request.ContentLength, c.GetHeader("Upload-Checksum"), c.Request.Body)
respond(c, 200, row, e)
})
r.POST("/:upload_id/finalize", func(c *gin.Context) {
row, e := s.Finalize(c.Request.Context(), c.Param("upload_id"))
respond(c, 202, row, e)
})
r.DELETE("/:upload_id", func(c *gin.Context) {
e := s.Cancel(c.Request.Context(), c.Param("upload_id"))
respond(c, 200, gin.H{"cancelled": e == nil}, e)
})
}
// Shell polling needs progress and the extraction mode, not extracted source
// text. Full results remain available on the owned upload's detail endpoint.
func uploadResultSummary(result types.JSON) types.JSON {
var summary struct {
Selection string `json:"selection,omitempty"`
}
if json.Unmarshal(result, &summary) != nil || (summary.Selection != "full" && summary.Selection != "sampled") {
return nil
}
encoded, _ := json.Marshal(summary)
return types.JSON(encoded)
}
+78
View File
@@ -0,0 +1,78 @@
package resumable
import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestUploadSummaryKeepsProgressAndScopeWithoutSourcePayload(t *testing.T) {
s := uploadService(t)
secretSource := strings.Repeat("private-source-body-", 5000)
result, err := json.Marshal(map[string]any{"selection": "sampled", "source": map[string]string{"name": "source.txt", "text": secretSource}, "knowledge_ids": []string{"private-document"}})
require.NoError(t, err)
for _, fixture := range []struct {
id, owner, target string
tenant uint64
}{
{"owned", "alice", "allowed", 1}, {"other-user", "bob", "allowed", 1},
{"other-tenant", "alice", "allowed", 2}, {"revoked", "alice", "revoked", 1},
} {
require.NoError(t, s.db.Create(&Upload{ID: fixture.id, TenantID: fixture.tenant, OwnerID: fixture.owner,
Kind: "digital-human", TargetID: fixture.target, Name: fixture.id + ".txt", Size: 100,
Offset: 100, IngestOffset: 100, State: "completed", Fingerprint: "fingerprint",
Options: types.JSON(`{"purpose":"distillation"}`), Result: result, ExpiresAt: time.Now().Add(time.Hour)}).Error)
}
s.authorize = func(_ context.Context, _, target string) error {
if target == "revoked" {
return ErrForbidden
}
return nil
}
router := gin.New()
group := router.Group("/api/v1", func(c *gin.Context) { c.Request = c.Request.WithContext(uploadContext(1, "alice")) })
s.Register(group)
request := func(route string) []byte {
response := httptest.NewRecorder()
router.ServeHTTP(response, httptest.NewRequest("GET", route, nil))
require.Equal(t, 200, response.Code)
return response.Body.Bytes()
}
full := request("/api/v1/uploads")
summary := request("/api/v1/uploads?view=summary")
var envelope struct {
Data []Upload `json:"data"`
}
require.NoError(t, json.Unmarshal(summary, &envelope))
require.Len(t, envelope.Data, 1)
row := envelope.Data[0]
require.Equal(t, "owned", row.ID)
require.Equal(t, "completed", row.State)
require.EqualValues(t, 100, row.Offset)
require.EqualValues(t, 100, row.IngestOffset)
require.Equal(t, "fingerprint", row.Fingerprint)
require.JSONEq(t, `{"purpose":"distillation"}`, string(row.Options))
require.JSONEq(t, `{"selection":"sampled"}`, string(row.Result))
require.NotContains(t, string(summary), "private-source-body")
require.NotContains(t, string(summary), "private-document")
require.Less(t, len(summary)*10, len(full))
require.Contains(t, string(full), secretSource)
require.Contains(t, string(request("/api/v1/uploads/owned")), secretSource)
stored, err := s.Get(uploadContext(1, "alice"), "owned")
require.NoError(t, err)
require.JSONEq(t, string(result), string(stored.Result))
}
func TestUploadResultSummaryOmitsUnknownOrInvalidPayload(t *testing.T) {
for _, raw := range []string{`{}`, `null`, `[]`, `{"selection":"unexpected","source":{"text":"private"}}`, `{"selection":3}`, `invalid`} {
require.Nil(t, uploadResultSummary(types.JSON(raw)), raw)
}
require.JSONEq(t, `{"selection":"full"}`, string(uploadResultSummary(types.JSON(`{"selection":"full","source":{"text":"private"}}`))))
}
+95
View File
@@ -0,0 +1,95 @@
package resumable
import (
"context"
"errors"
"github.com/Tencent/WeKnora/internal/types"
"time"
)
const MaxFileSize int64 = 20 << 30
const ChunkSize int64 = 4 << 20
const UserQuota int64 = 40 << 30
const TenantQuota int64 = 100 << 30
const MaxActive = 20
var ErrInvalid = errors.New("上传参数或校验和无效")
var ErrConflict = errors.New("上传状态或分块偏移已变化,请刷新后继续")
var ErrQuota = errors.New("上传暂存配额不足,请取消其他上传或等待过期清理")
var ErrForbidden = errors.New("没有此上传或目标资源的权限")
// UserError contains only an intentionally public explanation written by the
// upload implementation. Driver, filesystem and provider errors are never
// promoted to this type and remain confined to server logs.
type UserError string
func (e UserError) Error() string { return string(e) }
func publicError(err error) string {
var public UserError
if errors.As(err, &public) {
return public.Error()
}
var input *types.DigitalHumanInputError
if errors.As(err, &input) {
return input.Error()
}
for _, sentinel := range []error{ErrInvalid, ErrConflict, ErrQuota, ErrForbidden} {
if errors.Is(err, sentinel) {
return sentinel.Error()
}
}
return "文件处理暂不可用,请稍后重试或联系管理员检查服务日志"
}
type Upload struct {
ID string `json:"id" gorm:"primaryKey;type:varchar(36)"`
TenantID uint64 `json:"-" gorm:"index"`
OwnerID string `json:"-" gorm:"type:varchar(36);index"`
Kind string `json:"kind"`
TargetID string `json:"target_id"`
Name string `json:"name"`
Size int64 `json:"size"`
Offset int64 `json:"offset"`
ChunkSize int64 `json:"chunk_size"`
SHA256 string `json:"sha256"`
Fingerprint string `json:"fingerprint"`
State string `json:"state" gorm:"index"`
Error string `json:"error,omitempty"`
Options types.JSON `json:"options" gorm:"type:jsonb"`
Result types.JSON `json:"result,omitempty" gorm:"type:jsonb"`
IngestOffset int64 `json:"ingest_offset"`
Part int `json:"part"`
ExpiresAt time.Time `json:"expires_at" gorm:"index"`
LeaseUntil *time.Time `json:"-"`
LeaseToken string `json:"-"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Upload) TableName() string { return "resumable_uploads" }
type Chunk struct {
UploadID string `json:"-" gorm:"primaryKey;type:varchar(36)"`
Offset int64 `json:"offset" gorm:"primaryKey"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
func (Chunk) TableName() string { return "resumable_upload_chunks" }
type CreateInput struct {
Kind string `json:"kind"`
TargetID string `json:"target_id"`
Name string `json:"name"`
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
Fingerprint string `json:"fingerprint"`
Options types.JSON `json:"options"`
}
// Import processes one bounded unit, returning a durable checkpoint. The worker
// renews the lease around each unit and never queues the entire file at once.
type ImportFunc func(context.Context, *Upload, string) (offset int64, result types.JSON, done bool, err error)
type ContextFunc func(context.Context, *Upload) (context.Context, error)
type AuthorizeFunc func(context.Context, string, string) error
+335
View File
@@ -0,0 +1,335 @@
package resumable
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
db *gorm.DB
root string
authorize AuthorizeFunc
workerContext ContextFunc
ingest ImportFunc
once sync.Once
}
func NewService(db *gorm.DB, root string, authorize AuthorizeFunc, workerContext ContextFunc, ingest ImportFunc) (*Service, error) {
if root == "" {
root = filepath.Join("data", "resumable-uploads")
}
root, err := filepath.Abs(root)
if err != nil {
return nil, err
}
if err = os.MkdirAll(root, 0700); err != nil {
return nil, err
}
return &Service{db: db, root: root, authorize: authorize, workerContext: workerContext, ingest: ingest}, nil
}
func identity(ctx context.Context) (uint64, string, error) {
t, ok := types.TenantIDFromContext(ctx)
u, userOK := types.UserIDFromContext(ctx)
if !ok || t == 0 || !userOK || u == "" || types.IsSyntheticUserID(u) {
return 0, "", ErrForbidden
}
return t, u, nil
}
func (s *Service) scoped(ctx context.Context) (*gorm.DB, error) {
t, u, e := identity(ctx)
if e != nil {
return nil, e
}
return s.db.WithContext(ctx).Where("tenant_id=? AND owner_id=?", t, u), nil
}
func validHash(v string) bool {
b, e := hex.DecodeString(v)
return e == nil && len(b) == 32 && strings.ToLower(v) == v
}
func (s *Service) path(id string) string { return filepath.Join(s.root, id+".upload") }
func (s *Service) Create(ctx context.Context, in CreateInput) (*Upload, error) {
t, u, e := identity(ctx)
if e != nil {
return nil, e
}
if (in.Kind != "knowledge" && in.Kind != "digital-human") || len(in.TargetID) > 36 || in.TargetID == "" || in.Size < 1 || in.Size > MaxFileSize || len(in.Name) > 500 || strings.TrimSpace(in.Name) == "" || strings.ContainsAny(in.Name, "\x00\r\n") || len(in.Fingerprint) > 128 || (in.SHA256 != "" && !validHash(in.SHA256)) || len(in.Options) > 32<<10 {
return nil, ErrInvalid
}
if s.authorize == nil {
return nil, ErrForbidden
}
if e = s.authorize(ctx, in.Kind, in.TargetID); e != nil {
return nil, e
}
if len(in.Options) == 0 {
in.Options = types.JSON(`{}`)
}
row := &Upload{ID: uuid.NewString(), TenantID: t, OwnerID: u, Kind: in.Kind, TargetID: in.TargetID, Name: filepath.Base(in.Name), Size: in.Size, ChunkSize: ChunkSize, SHA256: in.SHA256, Fingerprint: in.Fingerprint, Options: in.Options, Result: types.JSON(`{}`), State: "uploading", ExpiresAt: time.Now().Add(7 * 24 * time.Hour)}
e = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// Serialize reservations across every user in this tenant.
var tenant types.Tenant
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", t).Take(&tenant).Error; e != nil {
return e
}
var totals struct {
TenantBytes int64
UserBytes int64
Active int64
}
e := tx.Model(&Upload{}).Select("COALESCE(SUM(size),0) AS tenant_bytes, COALESCE(SUM(CASE WHEN owner_id=? THEN size ELSE 0 END),0) AS user_bytes, COALESCE(SUM(CASE WHEN owner_id=? AND state IN ('uploading','queued','verifying','processing') THEN 1 ELSE 0 END),0) AS active", u, u).Where("tenant_id=? AND state NOT IN ('cancelled','expired')", t).Scan(&totals).Error
if e != nil {
return e
}
if totals.Active >= MaxActive || totals.UserBytes+in.Size > UserQuota || totals.TenantBytes+in.Size > TenantQuota {
return ErrQuota
}
if tenant.StorageQuota > 0 && tenant.StorageUsed+totals.TenantBytes+in.Size > tenant.StorageQuota {
return ErrQuota
}
return tx.Create(row).Error
})
return row, e
}
func (s *Service) Get(ctx context.Context, id string) (*Upload, error) {
q, e := s.scoped(ctx)
if e != nil {
return nil, e
}
var row Upload
e = q.Where("id=?", id).Take(&row).Error
if e == nil {
if s.authorize == nil {
return nil, ErrForbidden
}
if e = s.authorize(ctx, row.Kind, row.TargetID); e != nil {
return nil, e
}
}
if e == nil && row.ExpiresAt.Before(time.Now()) && row.State == "uploading" {
row.State = "expired"
}
return &row, e
}
// Chunks exposes bounded digests so a reselected local file can verify every
// committed byte before sending its remaining suffix.
func (s *Service) Chunks(ctx context.Context, id string, offset int64) ([]Chunk, error) {
row, err := s.Get(ctx, id)
if err != nil {
return nil, err
}
if offset < 0 || offset > row.Offset || row.ChunkSize <= 0 || (offset != row.Offset && offset%row.ChunkSize != 0) {
return nil, ErrInvalid
}
chunks := []Chunk{}
err = s.db.WithContext(ctx).Where("upload_id=? AND \"offset\">=? AND \"offset\"<?", row.ID, offset, row.Offset).Order("\"offset\"").Limit(128).Find(&chunks).Error
return chunks, err
}
func (s *Service) List(ctx context.Context, kind, target string) ([]Upload, error) {
q, e := s.scoped(ctx)
if e != nil {
return nil, e
}
rows := []Upload{}
q = q.Where("state NOT IN ('cancelled','expired')")
if kind != "" {
q = q.Where("kind=?", kind)
}
if target != "" {
q = q.Where("target_id=?", target)
}
e = q.Order("created_at DESC").Limit(100).Find(&rows).Error
if e != nil {
return nil, e
}
visible := make([]Upload, 0, len(rows))
for _, row := range rows {
if s.authorize != nil && s.authorize(ctx, row.Kind, row.TargetID) == nil {
visible = append(visible, row)
}
}
return visible, nil
}
// Put uses a bounded buffer and fsyncs before committing the offset. A retry is
// accepted only for the exact persisted chunk (including size and checksum).
func (s *Service) Put(ctx context.Context, id string, offset, size int64, checksum string, body io.Reader) (*Upload, error) {
if offset < 0 || size < 1 || size > ChunkSize || !validHash(checksum) {
return nil, ErrInvalid
}
t, u, e := identity(ctx)
if e != nil {
return nil, e
}
var row Upload
e = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND tenant_id=? AND owner_id=?", id, t, u).Take(&row).Error; e != nil {
return e
}
if row.State != "uploading" || row.ExpiresAt.Before(time.Now()) {
return ErrConflict
}
if s.authorize == nil {
return ErrForbidden
}
if e := s.authorize(ctx, row.Kind, row.TargetID); e != nil {
return e
}
if row.ChunkSize <= 0 || offset%row.ChunkSize != 0 || size != min(row.ChunkSize, row.Size-offset) {
return ErrInvalid
}
if offset < row.Offset {
var chunk Chunk
if e := tx.Where("upload_id=? AND \"offset\"=?", id, offset).Take(&chunk).Error; e != nil {
return ErrConflict
}
if chunk.Size != size || chunk.SHA256 != checksum {
return ErrConflict
}
h := sha256.New()
n, e := io.Copy(h, io.LimitReader(body, size+1))
if e != nil {
return e
}
if n != size || hex.EncodeToString(h.Sum(nil)) != checksum {
return ErrInvalid
}
return nil
}
if offset != row.Offset || offset+size > row.Size {
return ErrConflict
}
f, e := os.OpenFile(s.path(id), os.O_CREATE|os.O_RDWR, 0600)
if e != nil {
return e
}
defer f.Close()
stat, e := f.Stat()
if e != nil {
return e
}
if stat.Size() < offset {
return fmt.Errorf("上传暂存文件缺失,请重新上传")
}
if e = f.Truncate(offset); e != nil {
return e
}
if _, e = f.Seek(offset, io.SeekStart); e != nil {
return e
}
h := sha256.New()
n, e := io.Copy(io.MultiWriter(f, h), io.LimitReader(body, size+1))
if e != nil || n != size || hex.EncodeToString(h.Sum(nil)) != checksum {
_ = f.Truncate(offset)
if e != nil {
return e
}
return ErrInvalid
}
if e = f.Sync(); e != nil {
return e
}
if e = tx.Create(&Chunk{UploadID: id, Offset: offset, Size: size, SHA256: checksum}).Error; e != nil {
return e
}
row.Offset += size
row.ExpiresAt = time.Now().Add(7 * 24 * time.Hour)
return tx.Model(&row).Updates(map[string]any{"offset": row.Offset, "expires_at": row.ExpiresAt, "updated_at": time.Now()}).Error
})
return &row, e
}
func (s *Service) Finalize(ctx context.Context, id string) (*Upload, error) {
t, u, e := identity(ctx)
if e != nil {
return nil, e
}
var row Upload
e = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND tenant_id=? AND owner_id=?", id, t, u).Take(&row).Error; e != nil {
return e
}
if e := s.authorize(ctx, row.Kind, row.TargetID); e != nil {
return e
}
if row.State == "queued" || row.State == "verifying" || row.State == "processing" || row.State == "completed" {
return nil
}
if (row.State != "uploading" && row.State != "failed") || row.Offset != row.Size || row.ExpiresAt.Before(time.Now()) {
return ErrConflict
}
row.State = "queued"
row.Error = ""
row.ExpiresAt = time.Now().Add(7 * 24 * time.Hour)
return tx.Model(&row).Updates(map[string]any{"state": row.State, "error": "", "expires_at": row.ExpiresAt, "updated_at": time.Now()}).Error
})
return &row, e
}
func (s *Service) Cancel(ctx context.Context, id string) error {
t, u, e := identity(ctx)
if e != nil {
return e
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var row Upload
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND tenant_id=? AND owner_id=?", id, t, u).Take(&row).Error; e != nil {
return e
}
if row.State == "verifying" || row.State == "processing" {
return ErrConflict
}
if e := os.Remove(s.path(id)); e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
if e := tx.Where("upload_id=?", id).Delete(&Chunk{}).Error; e != nil {
return e
}
return tx.Model(&row).Updates(map[string]any{"state": "cancelled", "result": types.JSON(`{}`), "updated_at": time.Now()}).Error
})
}
func (s *Service) Cleanup(ctx context.Context) error {
var rows []Upload
if e := s.db.WithContext(ctx).Where("expires_at < ? AND (lease_until IS NULL OR lease_until < ?) AND state NOT IN ('expired','cancelled')", time.Now(), time.Now()).Limit(100).Find(&rows).Error; e != nil {
return e
}
for _, row := range rows {
if e := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current Upload
if e := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=?", row.ID).Take(&current).Error; e != nil {
return e
}
if current.ExpiresAt.After(time.Now()) || (current.LeaseUntil != nil && current.LeaseUntil.After(time.Now())) {
return nil
}
if e := os.Remove(s.path(row.ID)); e != nil && !errors.Is(e, os.ErrNotExist) {
return e
}
if e := tx.Where("upload_id=?", row.ID).Delete(&Chunk{}).Error; e != nil {
return e
}
return tx.Model(&current).Updates(map[string]any{"state": "expired", "result": types.JSON(`{}`), "updated_at": time.Now()}).Error
}); e != nil {
return e
}
}
return nil
}
+281
View File
@@ -0,0 +1,281 @@
package resumable
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func uploadContext(tenant uint64, user string) context.Context {
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, tenant)
return context.WithValue(ctx, types.UserIDContextKey, user)
}
func uploadService(t *testing.T) *Service {
t.Helper()
db, e := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "uploads.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
require.NoError(t, e)
require.NoError(t, db.AutoMigrate(&Upload{}, &Chunk{}))
require.NoError(t, db.Exec("CREATE TABLE tenants(id INTEGER PRIMARY KEY,storage_quota BIGINT,storage_used BIGINT,deleted_at DATETIME)").Error)
require.NoError(t, db.Exec("INSERT INTO tenants(id,storage_quota,storage_used) VALUES(1,0,0),(2,0,0)").Error)
sql, e := db.DB()
require.NoError(t, e)
sql.SetMaxOpenConns(1)
t.Cleanup(func() { _ = sql.Close() })
s, e := NewService(db, t.TempDir(), func(context.Context, string, string) error { return nil }, func(ctx context.Context, _ *Upload) (context.Context, error) { return ctx, nil }, func(_ context.Context, u *Upload, _ string) (int64, types.JSON, bool, error) {
return u.Size, types.JSON(`{"ok":true}`), true, nil
})
require.NoError(t, e)
return s
}
func sum(data []byte) string { h := sha256.Sum256(data); return hex.EncodeToString(h[:]) }
func create(t *testing.T, s *Service, data []byte) *Upload {
t.Helper()
u, e := s.Create(uploadContext(1, "alice"), CreateInput{Kind: "knowledge", TargetID: "kb", Name: "fixture.txt", Size: int64(len(data)), SHA256: sum(data)})
require.NoError(t, e)
return u
}
func TestUploadResumeChecksumOwnershipAndCrashRecovery(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
data := []byte("abcdef")
u := create(t, s, data)
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Update("chunk_size", 3).Error)
_, e := s.Put(ctx, u.ID, 0, 3, sum([]byte("wrong")), bytes.NewReader(data[:3]))
require.ErrorIs(t, e, ErrInvalid)
row, e := s.Get(ctx, u.ID)
require.NoError(t, e)
require.Zero(t, row.Offset)
_, e = s.Put(uploadContext(1, "bob"), u.ID, 0, 3, sum(data[:3]), bytes.NewReader(data[:3]))
require.ErrorIs(t, e, gorm.ErrRecordNotFound)
_, e = s.Get(uploadContext(2, "alice"), u.ID)
require.ErrorIs(t, e, gorm.ErrRecordNotFound)
row, e = s.Put(ctx, u.ID, 0, 3, sum(data[:3]), bytes.NewReader(data[:3]))
require.NoError(t, e)
require.EqualValues(t, 3, row.Offset)
chunks, e := s.Chunks(ctx, u.ID, 0)
require.NoError(t, e)
require.Len(t, chunks, 1)
require.Equal(t, sum(data[:3]), chunks[0].SHA256)
_, e = s.Chunks(uploadContext(1, "bob"), u.ID, 0)
require.ErrorIs(t, e, gorm.ErrRecordNotFound)
_, e = s.Chunks(ctx, u.ID, 1)
require.ErrorIs(t, e, ErrInvalid)
// A response lost after commit may replay exactly the same chunk.
row, e = s.Put(ctx, u.ID, 0, 3, sum(data[:3]), bytes.NewReader(data[:3]))
require.NoError(t, e)
require.EqualValues(t, 3, row.Offset)
_, e = s.Put(ctx, u.ID, 0, 3, sum(data[3:]), bytes.NewReader(data[3:]))
require.ErrorIs(t, e, ErrConflict)
_, e = s.Finalize(ctx, u.ID)
require.ErrorIs(t, e, ErrConflict)
// Bytes written before a killed transaction must not survive the resume.
f, e := os.OpenFile(s.path(u.ID), os.O_APPEND|os.O_WRONLY, 0600)
require.NoError(t, e)
_, e = f.WriteString("uncommitted")
require.NoError(t, e)
require.NoError(t, f.Close())
restarted, e := NewService(s.db, s.root, s.authorize, s.workerContext, s.ingest)
require.NoError(t, e)
row, e = restarted.Put(ctx, u.ID, 3, 3, sum(data[3:]), bytes.NewReader(data[3:]))
require.NoError(t, e)
require.EqualValues(t, 6, row.Offset)
stored, e := os.ReadFile(s.path(u.ID))
require.NoError(t, e)
require.Equal(t, data, stored)
row, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "queued", row.State)
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
worked, e := s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e = s.Get(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "completed", row.State)
require.EqualValues(t, 6, row.IngestOffset)
}
func TestUploadBoundsQuotaCancelAndExpiry(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
in := CreateInput{Kind: "knowledge", TargetID: "kb", Name: "large.txt", Size: MaxFileSize + 1}
_, e := s.Create(ctx, in)
require.ErrorIs(t, e, ErrInvalid)
in.Size = MaxFileSize
first, e := s.Create(ctx, in)
require.NoError(t, e)
require.Equal(t, MaxFileSize, first.Size)
_, e = s.Create(ctx, in)
require.NoError(t, e)
in.Size = 1
_, e = s.Create(ctx, in)
require.ErrorIs(t, e, ErrQuota)
require.NoError(t, s.Cancel(ctx, first.ID))
small, e := s.Create(ctx, in)
require.NoError(t, e)
_, e = s.Put(ctx, small.ID, 0, ChunkSize+1, strings.Repeat("a", 64), strings.NewReader("x"))
require.ErrorIs(t, e, ErrInvalid)
_, e = s.Put(ctx, small.ID, 0, 1, sum([]byte("x")), strings.NewReader("xx"))
require.ErrorIs(t, e, ErrInvalid)
_, e = s.Put(ctx, small.ID, 0, 1, sum([]byte("x")), strings.NewReader("x"))
require.NoError(t, e)
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", small.ID).Update("expires_at", time.Now().Add(-time.Hour)).Error)
_, e = s.Finalize(ctx, small.ID)
require.ErrorIs(t, e, ErrConflict)
require.NoError(t, s.Cleanup(ctx))
_, e = os.Stat(s.path(small.ID))
require.True(t, os.IsNotExist(e))
row, e := s.Get(ctx, small.ID)
require.NoError(t, e)
require.Equal(t, "expired", row.State)
var chunks int64
require.NoError(t, s.db.Model(&Chunk{}).Where("upload_id=?", small.ID).Count(&chunks).Error)
require.Zero(t, chunks)
}
func TestUploadWorkerChecksWholeFileAndReauthorizes(t *testing.T) {
for _, scenario := range []string{"corruption", "revoked"} {
t.Run(scenario, func(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
data := []byte("source")
u := create(t, s, data)
_, e := s.Put(ctx, u.ID, 0, int64(len(data)), sum(data), bytes.NewReader(data))
require.NoError(t, e)
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
called := false
s.ingest = func(context.Context, *Upload, string) (int64, types.JSON, bool, error) {
called = true
return 6, types.JSON(`{}`), true, nil
}
if scenario == "corruption" {
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Update("sha256", "").Error)
require.NoError(t, os.WriteFile(s.path(u.ID), []byte("edited"), 0600))
} else {
s.authorize = func(context.Context, string, string) error { return ErrForbidden }
}
worked, e := s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
var row Upload
require.NoError(t, s.db.Where("id=?", u.ID).Take(&row).Error)
require.Equal(t, "failed", row.State)
require.False(t, called)
})
}
}
func TestUploadRevocationHidesEvidenceAndStopsWritesButAllowsCleanup(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
u := create(t, s, []byte("source"))
_, e := s.Put(ctx, u.ID, 0, 6, sum([]byte("source")), strings.NewReader("source"))
require.NoError(t, e)
s.authorize = func(context.Context, string, string) error { return ErrForbidden }
_, e = s.Get(ctx, u.ID)
require.ErrorIs(t, e, ErrForbidden)
_, e = s.Put(ctx, u.ID, 0, 6, sum([]byte("source")), strings.NewReader("source"))
require.ErrorIs(t, e, ErrForbidden)
_, e = s.Finalize(ctx, u.ID)
require.ErrorIs(t, e, ErrForbidden)
rows, e := s.List(ctx, "", "")
require.NoError(t, e)
require.Empty(t, rows)
require.NoError(t, s.Cancel(ctx, u.ID))
var count int64
require.NoError(t, s.db.Model(&Chunk{}).Where("upload_id=?", u.ID).Count(&count).Error)
require.Zero(t, count)
_, e = os.Stat(s.path(u.ID))
require.True(t, os.IsNotExist(e))
}
func TestUploadWorkerDurableUnitsAndExpiredLease(t *testing.T) {
s := uploadService(t)
ctx := uploadContext(1, "alice")
u := create(t, s, []byte("ab"))
_, e := s.Put(ctx, u.ID, 0, 2, sum([]byte("ab")), strings.NewReader("ab"))
require.NoError(t, e)
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
s.ingest = func(_ context.Context, row *Upload, _ string) (int64, types.JSON, bool, error) {
return row.IngestOffset + 1, types.JSON(`{"checkpoint":true}`), row.IngestOffset == 1, nil
}
worked, e := s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e := s.Get(ctx, u.ID)
require.NoError(t, e)
require.EqualValues(t, 1, row.IngestOffset)
require.Equal(t, "processing", row.State)
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Updates(map[string]any{"lease_until": time.Now().Add(time.Hour), "lease_token": "crashed-worker"}).Error)
worked, e = s.ProcessNext(context.Background())
require.NoError(t, e)
require.False(t, worked)
require.NoError(t, s.db.Model(&Upload{}).Where("id=?", u.ID).Update("lease_until", time.Now().Add(-time.Minute)).Error)
worked, e = s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e = s.Get(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "completed", row.State)
require.EqualValues(t, 2, row.IngestOffset)
}
func TestUploadWorkerAndHTTPNeverExposeInternalFailures(t *testing.T) {
const secret = "postgres://private:credential-sentinel@internal-host/database"
s := uploadService(t)
ctx := uploadContext(1, "alice")
u := create(t, s, []byte("ok"))
_, e := s.Put(ctx, u.ID, 0, 2, sum([]byte("ok")), strings.NewReader("ok"))
require.NoError(t, e)
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
s.ingest = func(context.Context, *Upload, string) (int64, types.JSON, bool, error) {
return 0, nil, false, errors.New(secret)
}
worked, e := s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e := s.Get(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "failed", row.State)
require.NotContains(t, row.Error, "credential-sentinel")
require.Contains(t, row.Error, "服务日志")
r := gin.New()
r.GET("/error", func(c *gin.Context) { respond(c, 200, nil, errors.New(secret)) })
w := httptest.NewRecorder()
r.ServeHTTP(w, httptest.NewRequest("GET", "/error", nil))
require.Equal(t, 500, w.Code)
require.NotContains(t, w.Body.String(), "credential-sentinel")
// A retry preserves the completed transport and checkpoint.
_, e = s.Finalize(ctx, u.ID)
require.NoError(t, e)
s.ingest = func(_ context.Context, row *Upload, _ string) (int64, types.JSON, bool, error) {
return row.Size, types.JSON(`{}`), true, nil
}
worked, e = s.ProcessNext(context.Background())
require.NoError(t, e)
require.True(t, worked)
row, e = s.Get(ctx, u.ID)
require.NoError(t, e)
require.Equal(t, "completed", row.State)
require.Empty(t, row.Error)
}
+229
View File
@@ -0,0 +1,229 @@
package resumable
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"io"
"os"
"time"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// Start runs one bounded ingest worker per process. Database leases support
// recovery after restarts and prevent the same upload running on two workers.
func (s *Service) Start(ctx context.Context) {
s.once.Do(func() {
go func() {
timer := time.NewTicker(2 * time.Second)
defer timer.Stop()
lastCleanup := time.Time{}
for {
if ctx.Err() != nil {
return
}
if time.Since(lastCleanup) > time.Minute {
if e := s.Cleanup(ctx); e != nil {
logger.Errorf(ctx, "Upload cleanup failed: %v", e)
}
lastCleanup = time.Now()
}
worked, e := s.ProcessNext(ctx)
if e != nil {
logger.Errorf(ctx, "Upload worker failed: %v", e)
}
if worked && e == nil {
continue
}
select {
case <-ctx.Done():
return
case <-timer.C:
}
}
}()
})
}
func (s *Service) ProcessNext(ctx context.Context) (bool, error) {
if s.ingest == nil || s.workerContext == nil {
return false, nil
}
var row Upload
e := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var rows []Upload
e := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("state IN ('queued','verifying','processing') AND expires_at > ? AND (lease_until IS NULL OR lease_until < ?)", time.Now(), time.Now()).Order("updated_at, created_at").Limit(1).Find(&rows).Error
if e != nil {
return e
}
if len(rows) == 0 {
return gorm.ErrRecordNotFound
}
row = rows[0]
lease := time.Now().Add(2 * time.Minute)
row.LeaseUntil = &lease
row.LeaseToken = uuid.NewString()
if row.State == "queued" {
row.State = "verifying"
}
return tx.Model(&row).Updates(map[string]any{"state": row.State, "lease_until": lease, "lease_token": row.LeaseToken, "updated_at": time.Now()}).Error
})
if errors.Is(e, gorm.ErrRecordNotFound) {
return false, nil
}
if e != nil {
return false, e
}
s.process(ctx, &row)
return true, nil
}
func (s *Service) process(parent context.Context, row *Upload) {
ctx, cancel := context.WithTimeout(parent, 20*time.Minute)
defer cancel()
heartbeatDone := make(chan struct{})
go func() {
defer close(heartbeatDone)
ticker := time.NewTicker(20 * time.Second)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r := s.db.WithContext(ctx).Model(&Upload{}).Where("id=? AND lease_token=? AND state IN ('verifying','processing')", row.ID, row.LeaseToken).Updates(map[string]any{"lease_until": time.Now().Add(2 * time.Minute), "expires_at": time.Now().Add(7 * 24 * time.Hour), "updated_at": time.Now()})
if r.Error != nil || r.RowsAffected != 1 {
cancel()
return
}
}
}
}()
defer func() { cancel(); <-heartbeatDone }()
finish := func(failure error) {
finishCtx, done := context.WithTimeout(context.WithoutCancel(parent), 10*time.Second)
defer done()
logger.Errorf(finishCtx, "Upload %s processing failed: %v", row.ID, failure)
detail := publicError(failure)
if len(detail) > 2000 {
detail = detail[:2000]
}
if e := s.db.WithContext(finishCtx).Model(&Upload{}).Where("id=? AND lease_token=?", row.ID, row.LeaseToken).Updates(map[string]any{"state": "failed", "error": detail, "lease_until": nil, "lease_token": "", "updated_at": time.Now()}).Error; e != nil {
logger.Errorf(finishCtx, "Upload finalization failed: %v", e)
}
}
defer func() {
if recovered := recover(); recovered != nil {
logger.Errorf(ctx, "Upload worker panicked: %v", recovered)
finish(UserError("上传解析意外中断,请重新提交"))
}
}()
workerCtx, e := s.workerContext(ctx, row)
if e != nil {
finish(e)
return
}
if e = s.authorize(workerCtx, row.Kind, row.TargetID); e != nil {
finish(e)
return
}
if row.State == "verifying" {
hash, e := s.verifyFile(ctx, row)
if e != nil {
finish(e)
return
}
if row.SHA256 != "" && row.SHA256 != hash {
finish(UserError("完整文件校验失败,请重新上传"))
return
}
row.SHA256 = hash
row.State = "processing"
result := s.db.WithContext(ctx).Model(&Upload{}).Where("id=? AND lease_token=?", row.ID, row.LeaseToken).Updates(map[string]any{"state": "processing", "sha256": hash, "updated_at": time.Now()})
if result.Error != nil {
finish(result.Error)
return
}
if result.RowsAffected != 1 {
return
}
}
offset, result, done, e := s.ingest(workerCtx, row, s.path(row.ID))
if e != nil {
finish(e)
return
}
if offset < row.IngestOffset || offset > row.Size || (!done && offset == row.IngestOffset) {
finish(UserError("解析进度无效"))
return
}
state := "processing"
if done {
state = "completed"
}
update := s.db.WithContext(ctx).Model(&Upload{}).Where("id=? AND lease_token=?", row.ID, row.LeaseToken).Updates(map[string]any{"state": state, "ingest_offset": offset, "result": result, "part": row.Part + 1, "lease_until": nil, "lease_token": "", "expires_at": time.Now().Add(7 * 24 * time.Hour), "updated_at": time.Now()})
if update.Error != nil {
finish(update.Error)
}
}
// Revalidate the persisted chunks while computing the full digest. This also
// detects storage corruption when a browser cannot provide a whole-file digest
// without first reading 20 GiB. Chunk metadata is fetched in bounded pages.
func (s *Service) verifyFile(ctx context.Context, row *Upload) (string, error) {
f, err := os.Open(s.path(row.ID))
if err != nil {
return "", err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return "", err
}
if stat.Size() != row.Size {
return "", UserError("完整文件长度校验失败,请重新上传")
}
whole := sha256.New()
offset := int64(0)
for offset < row.Size {
var chunks []Chunk
if err := s.db.WithContext(ctx).Where("upload_id=? AND \"offset\">=?", row.ID, offset).Order("\"offset\"").Limit(128).Find(&chunks).Error; err != nil {
return "", err
}
if len(chunks) == 0 {
return "", UserError("上传分块记录缺失,请重新上传")
}
for _, chunk := range chunks {
if chunk.Offset != offset || chunk.Size != min(row.ChunkSize, row.Size-offset) {
return "", UserError("上传分块长度不一致,请重新上传")
}
h := sha256.New()
n, err := io.CopyN(io.MultiWriter(whole, h), &contextReader{ctx: ctx, r: f}, chunk.Size)
if err != nil {
return "", err
}
if n != chunk.Size || hex.EncodeToString(h.Sum(nil)) != chunk.SHA256 {
return "", UserError("上传分块校验失败,请重新上传")
}
offset += n
}
}
return hex.EncodeToString(whole.Sum(nil)), nil
}
type contextReader struct {
ctx context.Context
r io.Reader
}
func (r *contextReader) Read(p []byte) (int, error) {
if e := r.ctx.Err(); e != nil {
return 0, e
}
return r.r.Read(p)
}