feat: 新增配送合同附件安全上传功能

- 增加合同专用 PDF 上传、鉴权预览和失败清理接口
- 支持草稿附件替换移除、并发保护和启用完整性校验
- 修复循环模板引用导致文件选择器无法打开的问题
- 补充专项测试、中文项目文档和操作日志
This commit is contained in:
czl231
2026-08-12 21:38:49 +08:00
parent deea31d79e
commit 99324acc89
16 changed files with 1379 additions and 16 deletions

View File

@@ -0,0 +1,370 @@
// Package gasorder 提供配送合同附件的受控上传、绑定、预览与清理能力。
// 版本v1.0.0
package gasorder
import (
"bytes"
"crypto/hmac"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"git.apinb.com/bsm-sdk/core/env"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"git.apinb.com/heqiapp/platforms/backend/api/internal/impl"
"git.apinb.com/heqiapp/platforms/backend/api/internal/logic/common"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
const (
contractAttachmentMaxSize int64 = 10 << 20
contractReceiptLifetime = 30 * time.Minute
contractUploadPrefix = "/uploads/contracts/"
contractTempPrefix = "/uploads/contracts/temp/"
)
// contractAttachmentReceipt 是临时附件绑定与清理凭证的签名载荷。
type contractAttachmentReceipt struct {
URI string `json:"uri"`
Operator string `json:"operator"`
Expires int64 `json:"expires"`
}
// contractAttachmentMetadata 是不暴露存储 URI 的合同附件展示信息。
type contractAttachmentMetadata struct {
HasFile bool `json:"has_file"`
Available bool `json:"available"`
RequiresReupload bool `json:"requires_reupload"`
DisplayName string `json:"display_name"`
Version string `json:"version"`
}
// contractAttachmentUploadReply 返回临时上传结果及受签名保护的后续操作凭证。
type contractAttachmentUploadReply struct {
Receipt string `json:"receipt"`
CleanupToken string `json:"cleanup_token"`
DisplayName string `json:"display_name"`
Size int64 `json:"size"`
}
// UploadGasorderContractAttachment 校验 PDF 后写入合同临时目录。
func UploadGasorderContractAttachment(ctx *gin.Context) {
operator, _ := common.PlatformOperator(ctx)
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, contractAttachmentMaxSize+(256<<10))
fileHeader, err := ctx.FormFile("file")
if err != nil || fileHeader == nil || fileHeader.Size <= 0 || fileHeader.Size > contractAttachmentMaxSize {
logContractAttachment(operator, "", "upload", "rejected", "")
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
file, err := fileHeader.Open()
if err != nil {
infra.Response.Error(ctx, err)
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, contractAttachmentMaxSize+1))
if err != nil || int64(len(content)) > contractAttachmentMaxSize || !validateContractPDF(fileHeader.Filename, fileHeader.Header.Get("Content-Type"), content) {
logContractAttachment(operator, "", "upload", "rejected", "")
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
filename := models.NewIdentity() + ".pdf"
directory := filepath.Join(contractAttachmentRoot(), "temp")
if err := os.MkdirAll(directory, 0o750); err != nil {
infra.Response.Error(ctx, err)
return
}
targetPath := filepath.Join(directory, filename)
if err := os.WriteFile(targetPath, content, 0o640); err != nil {
infra.Response.Error(ctx, err)
return
}
uri := contractTempPrefix + filename
token, err := signContractAttachmentReceipt(contractAttachmentReceipt{
URI: uri, Operator: operator, Expires: time.Now().Add(contractReceiptLifetime).Unix(),
}, attachmentSigningSecret())
if err != nil {
removeContractFileWithRetry(targetPath)
infra.Response.Error(ctx, err)
return
}
logContractAttachment(operator, "", "upload", "success", uri)
infra.Response.Success(ctx, contractAttachmentUploadReply{
Receipt: token, CleanupToken: token, DisplayName: "合同附件.pdf", Size: int64(len(content)),
})
}
// CleanupGasorderContractAttachment 删除仍位于临时目录的未绑定附件。
func CleanupGasorderContractAttachment(ctx *gin.Context) {
var request struct {
CleanupToken string `json:"cleanup_token" binding:"required"`
}
operator, _ := common.PlatformOperator(ctx)
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
receipt, err := verifyContractAttachmentReceipt(request.CleanupToken, operator, attachmentSigningSecret())
if err != nil {
logContractAttachment(operator, "", "cleanup", "rejected", "")
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
path, err := contractAttachmentPath(receipt.URI, true)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := removeContractFileWithRetry(path); err != nil && !errors.Is(err, os.ErrNotExist) {
logContractAttachment(operator, "", "cleanup", "failed", receipt.URI)
infra.Response.Error(ctx, err)
return
}
logContractAttachment(operator, "", "cleanup", "success", receipt.URI)
infra.Response.Success(ctx, gin.H{"deleted": true})
}
// ServeGasorderContractAttachment 按合同标识鉴权读取正式 PDF不接受客户端文件路径。
func ServeGasorderContractAttachment(ctx *gin.Context) {
operator, _ := common.PlatformOperator(ctx)
var contract models.GasorderContract
if err := common.ActiveRecords(impl.DBService).Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil {
logContractAttachment(operator, ctx.Param("identity"), "download", "not_found", "")
common.RespondRecordError(ctx, err)
return
}
path, err := contractAttachmentPath(contract.FileURI, false)
if err != nil || !validStoredContractPDF(path) {
logContractAttachment(operator, contract.Identity, "download", "unavailable", contract.FileURI)
ctx.Status(http.StatusNotFound)
return
}
file, err := os.Open(path)
if err != nil {
ctx.Status(http.StatusNotFound)
return
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() {
ctx.Status(http.StatusNotFound)
return
}
filename := sanitizeDownloadName(contract.ContractNo) + "_合同附件.pdf"
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("Content-Type", "application/pdf")
ctx.Header("Content-Disposition", fmt.Sprintf("inline; filename*=UTF-8''%s", url.PathEscape(filename)))
ctx.Header("Content-Security-Policy", "sandbox")
ctx.Header("X-Content-Type-Options", "nosniff")
logContractAttachment(operator, contract.Identity, "download", "success", contract.FileURI)
http.ServeContent(ctx.Writer, ctx.Request, filename, info.ModTime(), file)
}
// bindContractAttachment 将签名临时文件移动到正式目录并返回最终 URI 与路径。
func bindContractAttachment(receiptToken, operator string) (string, string, error) {
receipt, err := verifyContractAttachmentReceipt(receiptToken, operator, attachmentSigningSecret())
if err != nil {
return "", "", err
}
source, err := contractAttachmentPath(receipt.URI, true)
if err != nil || !validStoredContractPDF(source) {
return "", "", errors.New("contract attachment is unavailable")
}
datePath := time.Now().Format("2006/01/02")
directory := filepath.Join(contractAttachmentRoot(), filepath.FromSlash(datePath))
if err := os.MkdirAll(directory, 0o750); err != nil {
return "", "", err
}
filename := models.NewIdentity() + ".pdf"
target := filepath.Join(directory, filename)
if err := os.Rename(source, target); err != nil {
return "", "", err
}
return contractUploadPrefix + datePath + "/" + filename, target, nil
}
// contractAttachmentInfo 根据现有 URI 生成前端展示状态和并发版本凭证。
func contractAttachmentInfo(contract models.GasorderContract) contractAttachmentMetadata {
hasFile := strings.TrimSpace(contract.FileURI) != ""
path, err := contractAttachmentPath(contract.FileURI, false)
available := err == nil && validStoredContractPDF(path)
return contractAttachmentMetadata{
HasFile: hasFile, Available: available, RequiresReupload: hasFile && !available,
DisplayName: "合同附件.pdf", Version: contractAttachmentVersion(contract.Identity, contract.FileURI),
}
}
// contractAttachmentVersion 生成不暴露 URI 的并发校验标识。
func contractAttachmentVersion(identity, uri string) string {
mac := hmac.New(sha256.New, []byte(attachmentSigningSecret()))
_, _ = mac.Write([]byte(identity + "\x00" + uri))
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
}
// validateContractAttachmentVersion 校验页面加载时的附件版本是否仍为当前版本。
func validateContractAttachmentVersion(contract models.GasorderContract, version string) bool {
actual := contractAttachmentVersion(contract.Identity, contract.FileURI)
return version != "" && hmac.Equal([]byte(actual), []byte(version))
}
// validateContractPDF 校验扩展名、声明类型、PDF 文件头与结束标记。
func validateContractPDF(filename, declaredType string, content []byte) bool {
if strings.ToLower(filepath.Ext(filename)) != ".pdf" || declaredType != "application/pdf" || len(content) < 8 {
return false
}
if http.DetectContentType(content) != "application/pdf" || !bytes.HasPrefix(content, []byte("%PDF-")) {
return false
}
tail := content
if len(tail) > 2048 {
tail = tail[len(tail)-2048:]
}
return bytes.Contains(tail, []byte("%%EOF"))
}
// validStoredContractPDF 验证正式或临时文件仍存在且内容为 PDF。
func validStoredContractPDF(path string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
info, err := file.Stat()
if err != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > contractAttachmentMaxSize {
return false
}
head := make([]byte, 512)
n, err := file.Read(head)
return err == nil && http.DetectContentType(head[:n]) == "application/pdf" && bytes.HasPrefix(head[:n], []byte("%PDF-"))
}
// contractAttachmentPath 将受控 URI 映射到合同附件目录内的绝对路径。
func contractAttachmentPath(uri string, temporary bool) (string, error) {
prefix := contractUploadPrefix
if temporary {
prefix = contractTempPrefix
} else if strings.HasPrefix(uri, contractTempPrefix) {
return "", errors.New("temporary attachment is not downloadable")
}
if !strings.HasPrefix(uri, prefix) {
return "", errors.New("contract attachment URI is not controlled")
}
root, err := filepath.Abs(contractAttachmentRoot())
if err != nil {
return "", err
}
relative := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(uri, contractUploadPrefix)))
if relative == "." || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", errors.New("invalid contract attachment path")
}
candidate, err := filepath.Abs(filepath.Join(root, relative))
if err != nil {
return "", err
}
relativeToRoot, err := filepath.Rel(root, candidate)
if err != nil || relativeToRoot == ".." || strings.HasPrefix(relativeToRoot, ".."+string(filepath.Separator)) {
return "", errors.New("contract attachment path escapes root")
}
return candidate, nil
}
// contractAttachmentRoot 返回合同附件专用存储根目录。
func contractAttachmentRoot() string {
root := strings.TrimSpace(os.Getenv("HEQI_UPLOAD_DIR"))
if root == "" {
root = filepath.Join("runtime", "uploads")
}
return filepath.Join(root, "contracts")
}
// signContractAttachmentReceipt 使用服务端密钥签发临时附件凭证。
func signContractAttachmentReceipt(receipt contractAttachmentReceipt, secret string) (string, error) {
if strings.TrimSpace(secret) == "" {
return "", errors.New("attachment signing secret is empty")
}
payload, err := json.Marshal(receipt)
if err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(payload)
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(encoded))
return encoded + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil)), nil
}
// verifyContractAttachmentReceipt 校验签名、有效期、操作人和临时目录范围。
func verifyContractAttachmentReceipt(token, operator, secret string) (contractAttachmentReceipt, error) {
var receipt contractAttachmentReceipt
parts := strings.Split(token, ".")
if len(parts) != 2 || strings.TrimSpace(secret) == "" {
return receipt, errors.New("invalid attachment receipt")
}
signature, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return receipt, err
}
mac := hmac.New(sha256.New, []byte(secret))
_, _ = mac.Write([]byte(parts[0]))
if !hmac.Equal(signature, mac.Sum(nil)) {
return receipt, errors.New("invalid attachment receipt signature")
}
payload, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil || json.Unmarshal(payload, &receipt) != nil || receipt.Expires < time.Now().Unix() || receipt.Operator != operator || !strings.HasPrefix(receipt.URI, contractTempPrefix) {
return contractAttachmentReceipt{}, errors.New("expired or invalid attachment receipt")
}
return receipt, nil
}
// attachmentSigningSecret 复用 JWT 服务端密钥;配置尚未加载时返回空值并由签名入口拒绝操作。
func attachmentSigningSecret() string {
if env.Runtime == nil {
return ""
}
return env.Runtime.JwtSecretKey
}
// removeContractFileWithRetry 在当前操作内最多重试三次,不启动后台扫描任务。
func removeContractFileWithRetry(path string) error {
var err error
for attempt := 0; attempt < 3; attempt++ {
err = os.Remove(path)
if err == nil || errors.Is(err, os.ErrNotExist) {
return err
}
time.Sleep(time.Duration(attempt+1) * 20 * time.Millisecond)
}
return err
}
// sanitizeDownloadName 清理响应文件名中的控制字符和路径符号。
func sanitizeDownloadName(value string) string {
cleaned := strings.Map(func(char rune) rune {
if char < 32 || strings.ContainsRune(`/\\:*?"<>|`, char) {
return '_'
}
return char
}, strings.TrimSpace(value))
if cleaned == "" {
return "合同"
}
return cleaned
}
// logContractAttachment 记录合同附件关键行为,不记录文件内容。
func logContractAttachment(operator, contractIdentity, action, result, uri string) {
log.Printf("contract_attachment operator=%s contract=%s action=%s result=%s uri=%s", operator, contractIdentity, action, result, uri)
}

View File

@@ -0,0 +1,88 @@
// Package gasorder 测试合同附件的 PDF 校验、签名凭证与受控路径边界。
// 版本v1.0.0
package gasorder
import (
"os"
"path/filepath"
"testing"
"time"
)
// TestValidateContractPDF 验证扩展名、MIME、文件头和结束标记均参与校验。
func TestValidateContractPDF(t *testing.T) {
valid := []byte("%PDF-1.7\n1 0 obj\n<<>>\nendobj\n%%EOF\n")
if !validateContractPDF("signed.pdf", "application/pdf", valid) {
t.Fatal("valid PDF must pass validation")
}
for name, test := range map[string]struct {
filename, contentType string
content []byte
}{
"extension": {"signed.txt", "application/pdf", valid},
"mime": {"signed.pdf", "text/plain", valid},
"header": {"signed.pdf", "application/pdf", []byte("plain text %%EOF")},
"eof": {"signed.pdf", "application/pdf", []byte("%PDF-1.7 without end")},
} {
t.Run(name, func(t *testing.T) {
if validateContractPDF(test.filename, test.contentType, test.content) {
t.Fatal("invalid PDF must be rejected")
}
})
}
}
// TestContractAttachmentReceipt 验证凭证绑定操作人、有效期和签名。
func TestContractAttachmentReceipt(t *testing.T) {
secret := "test-contract-attachment-secret"
receipt := contractAttachmentReceipt{
URI: contractTempPrefix + "example.pdf", Operator: "operator-1", Expires: time.Now().Add(time.Minute).Unix(),
}
token, err := signContractAttachmentReceipt(receipt, secret)
if err != nil {
t.Fatal(err)
}
if _, err := verifyContractAttachmentReceipt(token, "operator-1", secret); err != nil {
t.Fatalf("valid receipt was rejected: %v", err)
}
if _, err := verifyContractAttachmentReceipt(token, "operator-2", secret); err == nil {
t.Fatal("receipt must not be transferable between operators")
}
if _, err := verifyContractAttachmentReceipt(token+"x", "operator-1", secret); err == nil {
t.Fatal("tampered receipt must be rejected")
}
expired, _ := signContractAttachmentReceipt(contractAttachmentReceipt{
URI: receipt.URI, Operator: receipt.Operator, Expires: time.Now().Add(-time.Minute).Unix(),
}, secret)
if _, err := verifyContractAttachmentReceipt(expired, "operator-1", secret); err == nil {
t.Fatal("expired receipt must be rejected")
}
}
// TestContractAttachmentPathRejectsTraversal 验证受控 URI 不能逃逸合同附件目录。
func TestContractAttachmentPathRejectsTraversal(t *testing.T) {
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
if _, err := contractAttachmentPath("/uploads/contracts/../../secret.pdf", false); err == nil {
t.Fatal("path traversal URI must be rejected")
}
if _, err := contractAttachmentPath("https://example.com/contract.pdf", false); err == nil {
t.Fatal("external URL must be rejected")
}
path, err := contractAttachmentPath("/uploads/contracts/2026/08/12/example.pdf", false)
if err != nil {
t.Fatal(err)
}
expected := filepath.Join(os.Getenv("HEQI_UPLOAD_DIR"), "contracts", "2026", "08", "12", "example.pdf")
if path != expected {
t.Fatalf("unexpected controlled path: %s", path)
}
}
// TestContractAttachmentVersion 验证并发版本不暴露 URI 且能识别附件变化。
func TestContractAttachmentVersion(t *testing.T) {
versionA := contractAttachmentVersion("contract-1", "/uploads/contracts/a.pdf")
versionB := contractAttachmentVersion("contract-1", "/uploads/contracts/b.pdf")
if versionA == versionB || versionA == "/uploads/contracts/a.pdf" {
t.Fatal("attachment version must be opaque and change with URI")
}
}

View File

@@ -68,7 +68,10 @@ func getGasorderContract(ctx *gin.Context) {
infra.Response.Error(ctx, err)
return
}
response, err := common.PublicResourceResponse(gin.H{"contract": contract, "products": products, "revisions": revisions})
response, err := common.PublicResourceResponse(gin.H{
"contract": contract, "products": products, "revisions": revisions,
"attachment": contractAttachmentInfo(contract),
})
if err != nil {
infra.Response.Error(ctx, err)
return
@@ -122,6 +125,7 @@ func CreateGasorderContract(ctx *gin.Context) {
Title string `json:"title" binding:"required,max=255"`
Terms string `json:"terms"`
FileURI string `json:"file_uri" binding:"max=512"`
AttachmentReceipt string `json:"attachment_receipt"`
DefaultDeliveryFee int64 `json:"default_delivery_fee"`
SignedAt time.Time `json:"signed_at" binding:"required"`
EffectiveAt time.Time `json:"effective_at" binding:"required"`
@@ -152,16 +156,33 @@ func CreateGasorderContract(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
operatorIdentity, _ := common.PlatformOperator(ctx)
fileURI := request.FileURI
boundPath := ""
if request.AttachmentReceipt != "" {
fileURI, boundPath, err = bindContractAttachment(request.AttachmentReceipt, operatorIdentity)
if err != nil {
logContractAttachment(operatorIdentity, "", "bind", "failed", "")
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
}
contract := models.GasorderContract{
Entity: models.Entity{Identity: models.NewIdentity(), Status: common.StatusEnable}, ContractStatus: common.StatusDraft,
ContractNo: request.ContractNo, UserAccountID: userID, GasBasicID: gasID, DeliveryBasicID: deliveryID,
Title: request.Title, Terms: request.Terms, FileURI: request.FileURI, DefaultDeliveryFee: request.DefaultDeliveryFee,
Title: request.Title, Terms: request.Terms, FileURI: fileURI, DefaultDeliveryFee: request.DefaultDeliveryFee,
SignedAt: request.SignedAt, EffectiveAt: request.EffectiveAt, ExpiredAt: request.ExpiredAt,
}
if err := impl.DBService.Create(&contract).Error; err != nil {
if boundPath != "" {
_ = removeContractFileWithRetry(boundPath)
}
infra.Response.Error(ctx, err)
return
}
if request.AttachmentReceipt != "" {
logContractAttachment(operatorIdentity, contract.Identity, "bind", "success", contract.FileURI)
}
common.RespondCreatedResource(ctx, contract)
}
@@ -170,7 +191,10 @@ func UpdateGasorderContract(ctx *gin.Context) {
DeliveryIdentity string `json:"delivery_basic_identity"`
Title string `json:"title" binding:"required,max=255"`
Terms string `json:"terms"`
FileURI string `json:"file_uri" binding:"max=512"`
FileURI *string `json:"file_uri" binding:"omitempty,max=512"`
AttachmentReceipt string `json:"attachment_receipt"`
AttachmentVersion string `json:"attachment_version"`
RemoveAttachment bool `json:"remove_attachment"`
DefaultDeliveryFee int64 `json:"default_delivery_fee"`
SignedAt time.Time `json:"signed_at" binding:"required"`
EffectiveAt time.Time `json:"effective_at" binding:"required"`
@@ -187,20 +211,73 @@ func UpdateGasorderContract(ctx *gin.Context) {
return
}
var contract models.GasorderContract
if err := impl.DBService.Select("gas_basic_id").Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil ||
if err := impl.DBService.Where("identity = ?", ctx.Param("identity")).First(&contract).Error; err != nil ||
!deliveryBelongsToGas(deliveryID, contract.GasBasicID) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
result := impl.DBService.Model(&models.GasorderContract{}).
Where("identity = ? AND contract_status = ?", ctx.Param("identity"), common.StatusDraft).
Updates(map[string]any{"delivery_basic_id": deliveryID, "title": request.Title, "terms": request.Terms,
"file_uri": request.FileURI, "default_delivery_fee": request.DefaultDeliveryFee,
"signed_at": request.SignedAt, "effective_at": request.EffectiveAt, "expired_at": request.ExpiredAt})
if result.Error != nil || result.RowsAffected != 1 {
if request.RemoveAttachment && request.AttachmentReceipt != "" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
operatorIdentity, _ := common.PlatformOperator(ctx)
nextFileURI := contract.FileURI
newFilePath := ""
attachmentChanged := request.RemoveAttachment || request.AttachmentReceipt != ""
if attachmentChanged && !validateContractAttachmentVersion(contract, request.AttachmentVersion) {
logContractAttachment(operatorIdentity, contract.Identity, "replace", "conflict", "")
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if request.RemoveAttachment {
nextFileURI = ""
} else if request.AttachmentReceipt != "" {
nextFileURI, newFilePath, err = bindContractAttachment(request.AttachmentReceipt, operatorIdentity)
if err != nil {
logContractAttachment(operatorIdentity, contract.Identity, "replace", "failed", "")
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
} else if request.FileURI != nil {
// 兼容既有 JSON 调用方;新版后台不再提交或展示裸 URI。
nextFileURI = strings.TrimSpace(*request.FileURI)
}
query := impl.DBService.Model(&models.GasorderContract{}).
Where("identity = ? AND contract_status = ?", ctx.Param("identity"), common.StatusDraft)
if attachmentChanged {
// 以旧 URI 作为数据库级并发条件,避免两个编辑页面静默覆盖附件。
query = query.Where("file_uri = ?", contract.FileURI)
}
result := query.
Updates(map[string]any{"delivery_basic_id": deliveryID, "title": request.Title, "terms": request.Terms,
"file_uri": nextFileURI, "default_delivery_fee": request.DefaultDeliveryFee,
"signed_at": request.SignedAt, "effective_at": request.EffectiveAt, "expired_at": request.ExpiredAt})
if result.Error != nil || result.RowsAffected != 1 {
if newFilePath != "" {
_ = removeContractFileWithRetry(newFilePath)
}
if attachmentChanged {
logContractAttachment(operatorIdentity, contract.Identity, "replace", "conflict", "")
}
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if attachmentChanged && contract.FileURI != "" && contract.FileURI != nextFileURI {
if oldPath, pathErr := contractAttachmentPath(contract.FileURI, false); pathErr == nil {
if removeErr := removeContractFileWithRetry(oldPath); removeErr != nil {
logContractAttachment(operatorIdentity, contract.Identity, "remove_old", "failed", contract.FileURI)
}
}
}
if attachmentChanged {
action := "replace"
if request.RemoveAttachment {
action = "remove"
} else if contract.FileURI == "" {
action = "bind"
}
logContractAttachment(operatorIdentity, contract.Identity, action, "success", nextFileURI)
}
infra.Response.Success(ctx, gin.H{"updated": true})
}
@@ -266,6 +343,10 @@ func changeGasorderContract(ctx *gin.Context, action string, target int) {
return errors.New("contract cannot be terminated")
}
if target == common.StatusActive {
attachmentPath, attachmentErr := contractAttachmentPath(contract.FileURI, false)
if attachmentErr != nil || !validStoredContractPDF(attachmentPath) {
return errors.New("contract has no valid attachment")
}
now := time.Now()
if contract.EffectiveAt.After(now) || (contract.ExpiredAt != nil && !contract.ExpiredAt.After(now)) {
return errors.New("contract outside effective period")

View File

@@ -70,8 +70,11 @@ func registerGasorderRoute(group *gin.RouterGroup) {
contract := group.Group("/gasorder_contract")
contract.GET("", gasorder.ListGasorderContract)
contract.POST("", gasorder.CreateGasorderContract)
contract.POST("/attachment/upload", gasorder.UploadGasorderContractAttachment)
contract.POST("/attachment/cleanup", gasorder.CleanupGasorderContractAttachment)
contract.GET("/:identity", gasorder.GetGasorderContract)
contract.PUT("/:identity", gasorder.UpdateGasorderContract)
contract.GET("/:identity/attachment", gasorder.ServeGasorderContractAttachment)
contract.POST("/:identity/activate", gasorder.ActivateGasorderContract)
contract.POST("/:identity/renew", gasorder.RenewGasorderContract)
contract.POST("/:identity/terminate", gasorder.TerminateGasorderContract)

View File

@@ -0,0 +1,27 @@
// Package routers 测试平台合同附件专用路由注册。
// 版本v1.0.0
package routers
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
)
// TestPlatformRoutesExposeContractAttachmentEndpoints 验证专用接口独立于通用上传接口注册。
func TestPlatformRoutesExposeContractAttachmentEndpoints(t *testing.T) {
engine := gin.New()
group := engine.Group("/heqi/platform/v1")
registerGasorderRoute(group)
routes := make(map[string]map[string]bool)
for _, route := range engine.Routes() {
if routes[route.Path] == nil {
routes[route.Path] = make(map[string]bool)
}
routes[route.Path][route.Method] = true
}
assertRouteMethods(t, routes, "/heqi/platform/v1/gasorder_contract/attachment/upload", http.MethodPost)
assertRouteMethods(t, routes, "/heqi/platform/v1/gasorder_contract/attachment/cleanup", http.MethodPost)
assertRouteMethods(t, routes, "/heqi/platform/v1/gasorder_contract/:identity/attachment", http.MethodGet)
}

View File

@@ -0,0 +1,102 @@
# 操作日志:配送合同附件上传
操作时间2026-08-12 20:42:47
操作类型:扩展
影响模块:平台总后台配送合同、后端合同业务、受控文件存储
## 操作前状态
- 合同表单将 `file_uri` 渲染为普通“附件地址”文本框,允许手工填写任意字符串。
- 合同接口仅校验 URI 长度,没有专用 PDF 上传、预览或清理能力。
- 通用 `/upload/file` 支持多种图片、PDF 和视频,不能直接收紧而不影响其他模块。
- 合同详情会过滤 `file_uri`,页面无法安全查看附件。
- 合同启用流程不检查签署附件。
## 具体操作
1. 新增合同专用 PDF 上传、临时清理和鉴权预览接口。
2. 使用 HMAC 签名收据绑定上传人与临时 URI有效期为 30 分钟。
3. 创建或更新合同时将临时文件移动到正式目录,并在失败时回滚文件。
4. 使用不透明附件版本和旧 URI 条件处理草稿并发更新。
5. 草稿替换或移除成功后删除旧文件,失败最多重试三次并记录日志。
6. 合同启用前验证受控 URI 和磁盘 PDF 文件。
7. 将前端文本框替换为支持点击和拖拽的 PDF 控件,保存时上传。
8. 新增详情预览、旧地址重新上传提示、移除二次确认和离开页面未保存检测。
9. 补充安全边界、签名凭证、路由注册等自动化测试。
## 操作后状态
- 合同草稿可无附件保存;启用前必须具备真实有效的受控 PDF。
- 单个附件最大 10 MiB仅允许 PDF。
- 草稿可替换和移除,非草稿只可鉴权预览。
- 页面不暴露内部 URI不保存原始文件名统一显示“合同附件.pdf”。
- 历史外部 URI 不删除,但不可预览且不能满足启用条件。
- 不修改数据库表和模型,不新增定时扫描任务。
## 代码变更
- `backend/api/internal/logic/platform/gasorder/contract_attachment.go`:新增 349 行,合同附件核心服务。
- `backend/api/internal/logic/platform/gasorder/contract_attachment_test.go`:新增 83 行,安全与边界测试。
- `backend/api/internal/logic/platform/gasorder/gasorder.go`:约新增 88 行、删除 7 行,接入创建、更新、并发和启用校验。
- `backend/api/internal/routers/platform.go`:新增 3 条专用路由。
- `backend/api/internal/routers/platform_contract_attachment_test.go`:新增 25 行,路由测试。
- `frontend/platform_admin/src/api/contract-attachment.ts`:新增 73 行,附件 API 客户端。
- `frontend/platform_admin/src/views/resource/use-contract-attachment.ts`:新增 147 行,附件页面状态与保存编排。
- `frontend/platform_admin/src/views/resource/ResourceFieldForm.vue`:约新增 145 行、删除 1 行,上传控件和样式。
- `frontend/platform_admin/src/views/resource/ResourceRecordPage.vue`:约新增 81 行、删除 2 行,详情预览和保存集成。
- `frontend/platform_admin/src/api/resources.ts``ResourceDetailContent.vue`:字段类型、动作状态和详情隐藏调整。
## 行为变化
- 变更前:管理员手工输入附件地址;无受控上传和下载。
- 变更后:管理员选择 PDF保存时自动上传并绑定通过合同标识鉴权预览。
- 变更前:无附件也可启用合同。
- 变更后:无有效 PDF 时拒绝启用。
- 兼容性:原 `file_uri` 数据库字段、合同 JSON 字段和通用上传接口保持不变。
## 验证结果
- `go test ./...`:通过。
- `npm.cmd run type:check`:通过。
- `npm.cmd run build`:通过。
- `npm.cmd run contract:check`通过48 个资源。
- `npm.cmd run resource-pages:check`:通过,详情 46 类、新建 25 类、编辑 23 类。
- `git diff --check`:通过。
- 本地页面只读检查:应用可正常打开且无控制台错误;独立测试浏览器因未登录被重定向至登录页,因此未执行需要平台账号和测试合同数据的交互式保存、替换及预览验证。
- `npm.cmd run lint`:未通过;项目已有 2 个错误、165 个警告和 12 个提示,诊断集中在既有文件。对本次 6 个前端改动文件单独执行 Biome lint 无错误,仅有项目现存的模板变量识别类警告。
- 边界测试:伪装 PDF、错误 MIME、缺少结束标记、过期或篡改凭证、跨操作人凭证、路径穿越、外部 URL 和附件版本变化均覆盖。
## 风险评估
- 存储仍为本地文件系统;多实例部署必须共享 `HEQI_UPLOAD_DIR`,否则下载和启用校验可能找不到文件。
- 不运行孤儿文件定时扫描;浏览器关闭或最终删除失败时依赖应用日志由运维人工处理。
- JWT 密钥同时用于附件签名;密钥轮换会使尚未绑定的 30 分钟临时收据失效,已绑定附件不受影响。
- 原始文件名未持久化,这是“不改数据库”约束下的明确取舍。
- 项目已有前后端合同状态展示不一致,本次仅把启用动作可见状态修正为后端支持的草稿和终止状态,未扩展其他状态改动。
## 运行时异常修复补充2026-08-12
### 操作前状态
- `ResourceFieldForm.vue` 在字段 `v-for` 内为文件 input 使用同一个 `attachmentInput` 模板引用。
- Vue 将循环内模板引用解析为元素数组,点击上传区域调用 `.click()` 时抛出 `TypeError: attachmentInput.value?.click is not a function`
### 具体操作
- 新增 `ContractAttachmentField.vue`,将文件 input 和单一模板引用移出字段循环。
- 通用字段表单只负责渲染独立组件并转发选择、预览、移除和取消选择事件。
- 上传区域增加按钮语义、Tab 焦点、Enter/空格键入口和可见焦点样式。
- 操作按钮同时阻止点击与键盘事件冒泡,避免预览或移除时误打开选择器。
- 新增 `scripts/check-contract-attachment-control.mjs``contract-attachment:check` 命令。
### 验证结果
- `npm.cmd run contract-attachment:check`:通过。
- `npm.cmd run type:check`:通过。
- `npm.cmd run build`:通过。
- 独立测试浏览器没有后台登录会话,未执行真实文件选择器弹窗的浏览器交互验证。
### 风险评估
- 未新增测试框架或运行依赖,沿用项目现有静态契约检查方式。
- 修复仅调整前端控件边界,不修改后端、数据库、上传接口或合同保存载荷。

View File

@@ -0,0 +1,134 @@
# 项目文档:配送合同附件上传 v1.0
## 1. 项目概述
本功能将平台总后台配送合同表单中的“附件地址”文本框升级为受控 PDF 上传控件。合同草稿允许不上传附件,但启用合同前必须存在真实、可读取的受控 PDF。功能不修改数据库结构继续复用 `gasorder_contract.file_uri`,并保持原有合同 JSON 接口和通用 `/upload/file` 接口兼容。
主要能力:
- 单文件 PDF 上传,最大 10 MiB
- 点击保存时上传并绑定,失败时立即清理临时文件;
- 草稿合同支持替换和移除,非草稿只能预览;
- 通过合同菜单权限鉴权预览,不暴露内部文件 URI
- 合同启用时验证受控路径和磁盘文件;
- 通过签名收据和附件版本标识防止伪造路径与并发覆盖;
- 上传、绑定、替换、移除、下载和清理写入应用日志。
技术栈Go、Gin、GORM、Vue 3、TypeScript、Arco Design、Vite。
运行要求:后端必须正常加载 JWT 密钥;文件目录由 `HEQI_UPLOAD_DIR` 指定,未指定时使用 `runtime/uploads`
## 2. 目录结构
```text
platforms/
├── backend/api/internal/
│ ├── logic/platform/gasorder/
│ │ ├── contract_attachment.go # 合同附件上传、签名、绑定、预览、清理和审计
│ │ ├── contract_attachment_test.go # PDF、签名、路径和版本边界测试
│ │ └── gasorder.go # 合同创建、草稿更新和启用校验接入
│ └── routers/
│ ├── platform.go # 合同附件专用路由
│ └── platform_contract_attachment_test.go # 路由注册测试
├── frontend/platform_admin/src/
│ ├── api/
│ │ ├── contract-attachment.ts # 上传、清理和预览 API 客户端
│ │ └── resources.ts # 合同附件字段类型和启用动作状态
│ └── views/resource/
│ ├── ContractAttachmentField.vue # 独立 PDF 选择、拖拽、键盘与操作按钮控件
│ ├── ResourceFieldForm.vue # PDF 选择、拖拽、替换和移除控件
│ ├── ResourceRecordPage.vue # 保存编排、预览与失败回滚
│ ├── ResourceDetailContent.vue # 隐藏原始附件元数据对象
│ └── use-contract-attachment.ts # 附件页面状态与上传缓存
└── docs/
├── 项目文档_配送合同附件上传_v1.0.md
└── 操作日志_配送合同附件上传_20260812.md
```
## 3. 核心设计
### 3.1 存储与数据库
- 不新增表和字段,不修改 `GasorderContract` 模型。
- 正式 URI 形如 `/uploads/contracts/YYYY/MM/DD/<随机标识>.pdf`
- 临时 URI 形如 `/uploads/contracts/temp/<随机标识>.pdf`
- 页面只显示固定名称“合同附件.pdf”下载名称为 `{合同编号}_合同附件.pdf`
- 文件输入框封装在独立 `ContractAttachmentField.vue` 中,不位于字段 `v-for` 的模板引用范围内上传区域支持鼠标、Enter、空格和拖拽操作。
### 3.2 保存流程
1. 用户选择或拖拽 PDF前端校验类型与大小不立即上传。
2. 点击保存后调用合同专用上传接口。
3. 服务端验证扩展名、声明 MIME、真实 MIME、PDF 文件头、结束标记和大小,将文件写入临时目录,并返回绑定收据及清理凭证。
4. 合同创建或草稿更新接口验证收据的 HMAC、有效期和操作人将临时文件移动至正式目录再写入 `file_uri`
5. 数据库保存失败时,服务端删除已移动文件;前端同时用清理凭证尝试清理仍在临时目录中的文件。
### 3.3 替换、移除与并发
- 仅草稿合同可通过普通更新接口修改附件。
- 详情接口返回不含 URI 的 `attachment` 元数据,包括存在性、可用性、是否需要重新上传及不透明版本标识。
- 替换或移除必须提交页面加载时的版本标识;后端同时使用旧 `file_uri` 作为更新条件。
- 版本冲突会拒绝覆盖,并删除本次新绑定文件。
- 保存成功后删除旧文件;删除在当前请求内最多重试三次,最终失败写日志并由运维人工处理。
- 不创建定时扫描任务,不查询合同表清理未引用文件。
### 3.4 权限与安全
- 所有专用接口位于 `/heqi/platform/v1/gasorder_contract` 下,复用 JWT 和 `gasorder_contract` 菜单权限。
- 下载接口只接收合同标识,并从合同记录读取 URI不接受任意路径。
- 路径解析验证专用前缀、规范化相对路径和根目录边界。
- 合同启用前同时验证 URI 属于受控目录且磁盘 PDF 存在。
- 预览响应设置 `application/pdf``nosniff``sandbox` 和私有禁缓存响应头。
- 历史外部地址保留在数据库中,但不允许预览,也不能满足启用校验。
## 4. API 说明
### 4.1 上传临时合同附件
`POST /heqi/platform/v1/gasorder_contract/attachment/upload`
- 请求:`multipart/form-data`,文件字段名为 `file`
- 响应:`receipt``cleanup_token``display_name``size`
### 4.2 清理临时合同附件
`POST /heqi/platform/v1/gasorder_contract/attachment/cleanup`
```json
{
"cleanup_token": "签名清理凭证"
}
```
清理接口只能删除当前操作人上传且仍位于临时目录的文件。
### 4.3 预览合同附件
`GET /heqi/platform/v1/gasorder_contract/:identity/attachment`
按合同标识鉴权返回 PDF不返回内部 URI。
### 4.4 合同创建与更新扩展字段
- 创建或替换:`attachment_receipt`
- 替换或移除:`attachment_version`
- 移除:`remove_attachment: true`
-`file_uri` 字段仍被后端兼容,但新版管理后台不再展示或提交裸 URI。
## 5. 维护指南
- 增加新的合同附件格式时,必须同步修改前后端校验、下载响应类型和测试,不能只调整文件选择器。
- 更换对象存储时,应保持受控 URI 语义和合同标识下载接口不变,在后端存储映射层替换实现。
- 排查孤儿文件时,根据 `contract_attachment` 应用日志人工核对;当前设计明确不运行自动扫描任务。
- 调整附件权限时,应扩展平台菜单授权模型,不应新增公开静态目录。
## 6. 变更记录
### v1.02026-08-12
- 新增单 PDF 合同附件上传和受控预览;
- 新增失败清理、草稿替换/移除和并发保护;
- 新增合同启用附件完整性校验;
- 保持数据库结构及通用上传接口不变;
- 已知限制:不保留原始文件名,不保留历史版本,不自动扫描孤儿文件。
- 修复循环内模板引用被解析为元素数组导致文件选择器无法打开的问题,并新增键盘可访问性与专项组件契约检查。

View File

@@ -16,6 +16,7 @@
"resource-pages:check": "node scripts/check-resource-pages.mjs",
"account-roles:check": "node scripts/check-account-role-presentation.mjs",
"avatar-retry:check": "node scripts/check-avatar-upload-cache.mjs",
"contract-attachment:check": "node scripts/check-contract-attachment-control.mjs",
"staff-organization:check": "node scripts/check-staff-organization-linkage.mjs",
"staff-relations:check": "node scripts/check-staff-relation-policy.mjs",
"user-address-display:check": "node scripts/check-user-address-relation-display.mjs",

View File

@@ -0,0 +1,53 @@
/**
* 功能:检查合同附件控件保持独立单一文件输入、键盘可访问及事件转发契约。
* 版本v1.0.0
*/
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
const componentPath = new URL(
'../src/views/resource/ContractAttachmentField.vue',
import.meta.url,
);
const formPath = new URL(
'../src/views/resource/ResourceFieldForm.vue',
import.meta.url,
);
const [component, form] = await Promise.all([
readFile(componentPath, 'utf8'),
readFile(formPath, 'utf8'),
]);
assert.equal(
component.includes('v-for='),
false,
'合同附件组件不得在 v-for 中持有模板引用',
);
assert.match(component, /ref="fileInput"/u, '组件必须持有唯一文件输入引用');
assert.match(
component,
/fileInput\.value\?\.click\(\)/u,
'点击区域必须调用单一文件输入元素',
);
assert.match(component, /@keydown\.enter\.prevent="openPicker"/u);
assert.match(component, /@keydown\.space\.prevent="openPicker"/u);
assert.match(component, /@drop\.prevent="selectDroppedFile"/u);
assert.match(component, /:tabindex="disabled \? -1 : 0"/u);
assert.match(component, /<a-space @click\.stop @keydown\.stop>/u);
assert.match(form, /<ContractAttachmentField/u);
assert.equal(
form.includes('ref="attachmentInput"'),
false,
'通用字段循环不得重新持有文件输入引用',
);
for (const marker of [
'@select="(file) => emit(\'select-attachment\', file)"',
'@remove="emit(\'remove-attachment\')"',
'@clear-selection="emit(\'clear-attachment-selection\')"',
'@preview="emit(\'preview-attachment\')"',
]) {
assert.ok(form.includes(marker), `ResourceFieldForm 缺少事件转发:${marker}`);
}
console.log('合同附件控件检查通过:单一引用、键盘入口、拖拽和事件转发均有效。');

View File

@@ -0,0 +1,74 @@
/**
* 功能:配送合同 PDF 附件的上传、失败清理与鉴权预览客户端。
* 版本v1.0.0
*/
import { getToken } from '@/utils/auth';
const platformApiBaseURL =
import.meta.env.VITE_API_BASE_URL ||
'http://localhost:12426/heqi/platform/v1';
export type ContractAttachmentMetadata = {
has_file: boolean;
available: boolean;
requires_reupload: boolean;
display_name: string;
version: string;
};
export type ContractAttachmentUploadReply = {
receipt: string;
cleanup_token: string;
display_name: string;
size: number;
};
type ApiEnvelope<T> = { code?: number; message?: string; details?: T };
/** 返回当前登录凭证请求头。 */
function authorizationHeaders(): Record<string, string> {
const token = getToken();
return token ? { Authorization: token } : {};
}
/** 上传经过前端预检的单个 PDF服务端仍会验证真实内容。 */
async function upload(file: File): Promise<ContractAttachmentUploadReply> {
const form = new FormData();
form.append('file', file);
const response = await fetch(
`${platformApiBaseURL}/gasorder_contract/attachment/upload`,
{ method: 'POST', headers: authorizationHeaders(), body: form },
);
const payload = (await response.json()) as ApiEnvelope<ContractAttachmentUploadReply>;
if (!response.ok || payload.code !== 0 || !payload.details) {
throw new Error(payload.message || '合同附件上传失败');
}
return payload.details;
}
/** 使用受签名保护的清理凭证删除尚未绑定的临时文件。 */
async function cleanup(cleanupToken: string): Promise<void> {
const response = await fetch(
`${platformApiBaseURL}/gasorder_contract/attachment/cleanup`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authorizationHeaders() },
body: JSON.stringify({ cleanup_token: cleanupToken }),
},
);
if (!response.ok) throw new Error('临时合同附件清理失败');
const payload = (await response.json()) as ApiEnvelope<{ deleted: boolean }>;
if (payload.code !== 0) throw new Error(payload.message || '临时合同附件清理失败');
}
/** 获取鉴权 PDF Blob内部存储 URI 始终不会暴露给浏览器。 */
async function load(identity: string): Promise<Blob> {
const response = await fetch(
`${platformApiBaseURL}/gasorder_contract/${encodeURIComponent(identity)}/attachment`,
{ headers: authorizationHeaders() },
);
if (!response.ok) throw new Error('合同附件不可用,请重新上传');
return response.blob();
}
export const contractAttachmentApi = { upload, cleanup, load };

View File

@@ -22,7 +22,8 @@ export type ResourceFieldType =
| 'date'
| 'datetime'
| 'textarea'
| 'select';
| 'select'
| 'contract-file';
/** 关联下拉的父子联动配置;未配置的资源继续保持独立选择。 */
export type ResourceRelationLinkage = {
@@ -458,10 +459,12 @@ export const resources: ResourceUiDefinition[] = [
filterKey: 'gas_basic_identities', backfillParent: true,
},
}),
f('title', { required: true }), f('terms'), f('file_uri'), f('default_delivery_fee'),
f('title', { required: true }), f('terms'),
f('file_uri', { label: '合同附件', type: 'contract-file' }),
f('default_delivery_fee'),
f('signed_at', { required: true }), f('effective_at', { required: true }), f('expired_at'),
], 'list', [
{ name: '启用合同', resource: '/gasorder_contract/:identity/activate', fields: reason, visibleFor: { field: 'contract_status', values: [10] } },
{ name: '启用合同', resource: '/gasorder_contract/:identity/activate', fields: reason, visibleFor: { field: 'contract_status', values: [0, 13] } },
{ name: '续签合同', resource: '/gasorder_contract/:identity/renew', fields: [f('effective_at', { required: true }), f('expired_at'), ...reason], visibleFor: { field: 'contract_status', values: [11, 12] } },
{ name: '终止合同', resource: '/gasorder_contract/:identity/terminate', danger: true, fields: reason, visibleFor: { field: 'contract_status', values: [11] } },
], { canCreate: true, canEdit: true }),

View File

@@ -0,0 +1,169 @@
<!--
功能提供单个配送合同 PDF 附件的选择拖拽预览和移除控件
版本v1.0.0
-->
<template>
<div
class="contract-file-control"
:class="{ 'contract-file-disabled': disabled }"
:tabindex="disabled ? -1 : 0"
role="button"
:aria-disabled="disabled"
aria-label="选择合同附件 PDF"
@click="openPicker"
@keydown.enter.prevent="openPicker"
@keydown.space.prevent="openPicker"
@dragover.prevent
@drop.prevent="selectDroppedFile"
>
<input
ref="fileInput"
class="contract-file-input"
type="file"
accept=".pdf,application/pdf"
:disabled="disabled"
@change="selectPickedFile"
/>
<div class="contract-file-main">
<icon-upload />
<div>
<strong>{{ title }}</strong>
<div class="contract-file-hint">点击选择或拖拽 PDF最大 10 MiB</div>
<div v-if="attachment.requiresReupload" class="contract-file-warning">
旧附件地址不可用请重新上传
</div>
</div>
</div>
<a-space @click.stop @keydown.stop>
<a-button
v-if="attachment.available && !attachment.selectedFile && !attachment.removed"
size="small"
@click="emit('preview')"
>
<template #icon><icon-eye /></template>预览
</a-button>
<a-button
v-if="attachment.selectedFile"
size="small"
@click="emit('clear-selection')"
>
取消选择
</a-button>
<a-button
v-else-if="attachment.hasExisting && !attachment.removed"
size="small"
status="danger"
@click="emit('remove')"
>
<template #icon><icon-delete /></template>移除
</a-button>
</a-space>
</div>
</template>
<script setup lang="ts">
import { IconDelete, IconEye, IconUpload } from '@arco-design/web-vue/es/icon';
import { computed, ref } from 'vue';
export type ContractAttachmentFieldState = {
selectedFile?: File;
hasExisting: boolean;
available: boolean;
requiresReupload: boolean;
removed: boolean;
};
const props = defineProps<{
attachment: ContractAttachmentFieldState;
disabled?: boolean;
}>();
const emit = defineEmits<{
select: [file: File];
remove: [];
'clear-selection': [];
preview: [];
}>();
const fileInput = ref<HTMLInputElement>();
const title = computed(() => {
if (props.attachment.selectedFile) return props.attachment.selectedFile.name;
if (props.attachment.removed) return '未选择合同附件';
if (props.attachment.hasExisting) return '合同附件.pdf';
return '选择合同附件';
});
/** 打开组件内唯一的原生文件选择器。 */
function openPicker() {
if (!props.disabled) fileInput.value?.click();
}
/** 读取点击选择的单个文件,并允许再次选择同名文件。 */
function selectPickedFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (file) emit('select', file);
input.value = '';
}
/** 接收拖拽到控件上的第一个文件。 */
function selectDroppedFile(event: DragEvent) {
if (props.disabled) return;
const file = event.dataTransfer?.files?.[0];
if (file) emit('select', file);
}
</script>
<style scoped lang="less">
.contract-file-control {
display: flex;
gap: 16px;
align-items: center;
justify-content: space-between;
min-height: 88px;
padding: 16px;
cursor: pointer;
border: 1px dashed var(--color-border-3);
border-radius: 6px;
outline: none;
transition: border-color 0.2s, background 0.2s, box-shadow 0.2s;
}
.contract-file-control:hover,
.contract-file-control:focus-visible {
background: var(--color-fill-1);
border-color: rgb(var(--primary-6));
}
.contract-file-control:focus-visible {
box-shadow: 0 0 0 2px rgba(var(--primary-6), 0.2);
}
.contract-file-disabled {
cursor: not-allowed;
opacity: 0.65;
}
.contract-file-input {
display: none;
}
.contract-file-main {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
}
.contract-file-main strong {
display: block;
overflow: hidden;
color: var(--color-text-1);
text-overflow: ellipsis;
white-space: nowrap;
}
.contract-file-hint {
margin-top: 4px;
color: var(--color-text-3);
font-size: 12px;
}
.contract-file-warning {
margin-top: 4px;
color: rgb(var(--warning-6));
font-size: 12px;
}
</style>

View File

@@ -80,7 +80,13 @@ type DetailEntry = {
const entries = computed<DetailEntry[]>(() => {
const row = primaryRecord(props.detail);
const excluded = new Set(['id', 'password', 'password_hash', 'avatar']);
const excluded = new Set([
'id',
'password',
'password_hash',
'avatar',
'attachment',
]);
if (props.accountSummary) {
for (const key of ['username', 'identity', 'created_at']) excluded.add(key);
}

View File

@@ -41,6 +41,15 @@
:disabled="disabledSet.has(field.key)"
:auto-size="{ minRows: 3, maxRows: 10 }"
/>
<ContractAttachmentField
v-else-if="field.type === 'contract-file'"
:attachment="contractAttachment"
:disabled="disabledSet.has(field.key)"
@select="(file) => emit('select-attachment', file)"
@remove="emit('remove-attachment')"
@clear-selection="emit('clear-attachment-selection')"
@preview="emit('preview-attachment')"
/>
<a-input-password
v-else-if="field.type === 'password'"
v-model="model[field.key]"
@@ -123,6 +132,9 @@ import type { ResourceField } from '@/api/resources';
import type { ResourceRow } from '@/api/resource-page-rules';
import type { PlatformRole } from '@/api/platform';
import IdentityText from '@/components/IdentityText.vue';
import ContractAttachmentField, {
type ContractAttachmentFieldState,
} from './ContractAttachmentField.vue';
const props = withDefaults(
defineProps<{
@@ -132,6 +144,7 @@ const props = withDefaults(
relationOptions?: Record<string, ResourceRow[]>;
relationLoading?: Record<string, boolean>;
roleOptions?: PlatformRole[];
contractAttachment?: ContractAttachmentFieldState;
}>(),
{
requiredKeys: () => [],
@@ -139,12 +152,23 @@ const props = withDefaults(
relationOptions: () => ({}),
relationLoading: () => ({}),
roleOptions: () => [],
contractAttachment: () => ({
selectedFile: undefined,
hasExisting: false,
available: false,
requiresReupload: false,
removed: false,
}),
},
);
const emit = defineEmits<{
'change-relation': [field: ResourceField, value: unknown];
'search-relation': [field: ResourceField, keyword: string];
'select-attachment': [file: File];
'remove-attachment': [];
'clear-attachment-selection': [];
'preview-attachment': [];
}>();
const model = defineModel<Record<string, any>>({ required: true });
const disabledSet = computed(() => new Set(props.disabledKeys));

View File

@@ -52,6 +52,28 @@
:field-options="fieldOptions"
:account-summary="accountSummary"
/>
<a-card
v-if="definition.name === 'gasorder_contract'"
title="合同附件"
:bordered="false"
class="section-card"
>
<a-space>
<span v-if="contractAttachment.available">合同附件.pdf</span>
<a-tag v-else-if="contractAttachment.requiresReupload" color="orange">
旧附件地址不可用请重新上传
</a-tag>
<span v-else>未上传</span>
<a-button
v-if="contractAttachment.available"
type="primary"
:loading="attachmentPreviewing"
@click="previewContractAttachment"
>
预览
</a-button>
</a-space>
</a-card>
<ResourceWalletSummary
v-if="definition.walletOwnerType"
:wallet="wallet"
@@ -103,8 +125,13 @@
:relation-options="relations.options"
:relation-loading="relations.loading"
:role-options="roleOptions"
:contract-attachment="contractAttachmentState"
@change-relation="relationLinkage.change"
@search-relation="relationLinkage.search"
@select-attachment="contractAttachment.select"
@remove-attachment="confirmRemoveContractAttachment"
@clear-attachment-selection="contractAttachment.clearSelection"
@preview-attachment="previewContractAttachment"
/>
</a-form>
<div class="form-actions">
@@ -126,7 +153,7 @@
</template>
<script setup lang="ts">
import { Message } from '@arco-design/web-vue';
import { Message, Modal } from '@arco-design/web-vue';
import { IconEdit, IconLeft } from '@arco-design/web-vue/es/icon';
import { computed, onMounted, reactive, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
@@ -171,6 +198,7 @@ import ResourceWalletSummary from './ResourceWalletSummary.vue';
import { createResourceRecordNavigation } from './resource-record-navigation';
import { loadResourceRecordRelations } from './load-resource-record-relations';
import { useResourceAvatar } from './use-resource-avatar';
import { useContractAttachment } from './use-contract-attachment';
import { useResourceRelationLinkage } from './use-resource-relation-linkage';
import { useStaffCredentialOwnerGuard } from './use-staff-credential-owner-guard';
import { useUnsavedRecord } from './use-unsaved-record';
@@ -193,6 +221,7 @@ const form = reactive<Record<string, any>>({});
const loading = ref(true);
const saving = ref(false);
const statusSaving = ref(false);
const attachmentPreviewing = ref(false);
const errorMessage = ref('');
const errorStatus = ref<'403' | '404' | 'error'>('error');
const wallet = ref<ResourceRow>();
@@ -220,10 +249,18 @@ const relationLinkage = useResourceRelationLinkage(form, relations);
const actionVisible = ref(false);
const activeAction = ref<DetailAction>();
const avatar = useResourceAvatar();
const contractAttachment = useContractAttachment();
const avatarUrl = avatar.url;
const avatarCanClear = avatar.canClear;
const selectAvatar = avatar.select;
const clearAvatar = avatar.clear;
const contractAttachmentState = computed(() => ({
selectedFile: contractAttachment.selectedFile.value,
hasExisting: contractAttachment.hasExisting.value,
available: contractAttachment.available.value,
requiresReupload: contractAttachment.requiresReupload.value,
removed: contractAttachment.removed.value,
}));
const accountSummary = computed(() => usesAccountSummary(definition.value));
const accountSummaryVisible = computed(
@@ -313,7 +350,11 @@ const modeLabel = computed(() =>
const errorTitle = computed(() => recordPageErrorTitle(errorStatus.value));
function snapshot() {
return JSON.stringify({ form, avatar: avatar.marker() });
return JSON.stringify({
form,
avatar: avatar.marker(),
contractAttachment: contractAttachment.marker(),
});
}
const unsaved = useUnsavedRecord(snapshot, () => mode.value !== 'detail');
const { goEdit, viewWallet, goBack, requestBack } =
@@ -350,6 +391,11 @@ async function initialize() {
definition.value.resource,
identity.value,
);
contractAttachment.load(
definition.value.name === 'gasorder_contract'
? (detail.value.attachment as any)
: undefined,
);
if (blockReason.value) {
errorStatus.value = '403';
errorMessage.value = blockReason.value;
@@ -441,6 +487,9 @@ async function save() {
mode.value as 'create' | 'edit',
);
await avatar.applyToPayload(payload);
if (definition.value.name === 'gasorder_contract') {
await contractAttachment.applyToPayload(payload);
}
if (mode.value === 'create') {
const created = await resourceApi.create<ResourceRow>(
definition.value.resource,
@@ -484,6 +533,9 @@ async function save() {
);
}
} catch (error) {
if (definition.value.name === 'gasorder_contract') {
await contractAttachment.rollbackUpload();
}
Message.error((error as Error).message);
} finally {
saving.value = false;
@@ -495,9 +547,36 @@ async function reloadDetail() {
definition.value.resource,
identity.value,
);
if (definition.value.name === 'gasorder_contract') {
contractAttachment.load(detail.value.attachment as any);
}
await Promise.allSettled([loadWallet(), loadAvatar()]);
}
/** 二次确认移除草稿合同附件,真正删除发生在保存成功之后。 */
function confirmRemoveContractAttachment() {
Modal.confirm({
title: '确认移除合同附件?',
content: '移除操作将在保存合同后生效。',
okText: '确认移除',
hideCancel: false,
onOk: () => contractAttachment.remove(),
});
}
/** 获取鉴权 PDF 并在新标签页预览。 */
async function previewContractAttachment() {
if (!recordIdentity.value) return;
attachmentPreviewing.value = true;
try {
await contractAttachment.preview(recordIdentity.value);
} catch (error) {
Message.error((error as Error).message);
} finally {
attachmentPreviewing.value = false;
}
}
async function updateGasStatus(enabled: string | number | boolean) {
statusSaving.value = true;
try {

View File

@@ -0,0 +1,149 @@
/**
* 功能:管理合同附件的本地选择、保存前上传、失败清理和鉴权预览状态。
* 版本v1.0.0
*/
import { Message } from '@arco-design/web-vue';
import { computed, ref } from 'vue';
import {
contractAttachmentApi,
type ContractAttachmentMetadata,
type ContractAttachmentUploadReply,
} from '@/api/contract-attachment';
const maxContractAttachmentSize = 10 * 1024 * 1024;
/** 提供单合同、单 PDF 附件的页面状态与保存编排。 */
export function useContractAttachment() {
const selectedFile = ref<File>();
const metadata = ref<ContractAttachmentMetadata>();
const removed = ref(false);
let uploaded: ContractAttachmentUploadReply | undefined;
let uploadedMarker = '';
const hasExisting = computed(
() => Boolean(metadata.value?.has_file) && !removed.value,
);
const available = computed(
() => Boolean(metadata.value?.available) && !removed.value,
);
const requiresReupload = computed(
() => Boolean(metadata.value?.requires_reupload) && !removed.value,
);
/** 从合同详情恢复附件存在性和并发版本,不读取内部 URI。 */
function load(next?: ContractAttachmentMetadata) {
selectedFile.value = undefined;
metadata.value = next;
removed.value = false;
uploaded = undefined;
uploadedMarker = '';
}
/** 校验并保存用户选择的本地 PDF真正上传发生在点击保存后。 */
function select(file: File) {
if (
file.size <= 0 ||
file.size > maxContractAttachmentSize ||
file.type !== 'application/pdf' ||
!file.name.toLowerCase().endsWith('.pdf')
) {
Message.warning('请选择不超过 10 MiB 的 PDF 文件');
return false;
}
selectedFile.value = file;
removed.value = false;
uploaded = undefined;
uploadedMarker = '';
return true;
}
/** 标记移除已绑定附件;实际删除只在合同保存成功后执行。 */
function remove() {
selectedFile.value = undefined;
uploaded = undefined;
uploadedMarker = '';
removed.value = true;
}
/** 取消尚未保存的新选择,不影响已绑定附件。 */
function clearSelection() {
selectedFile.value = undefined;
uploaded = undefined;
uploadedMarker = '';
}
/** 保存前上传新文件并向合同载荷写入签名收据及并发版本。 */
async function applyToPayload(payload: Record<string, unknown>) {
delete payload.file_uri;
if (selectedFile.value) {
const currentMarker = fileMarker(selectedFile.value);
if (!uploaded || uploadedMarker !== currentMarker) {
uploaded = await contractAttachmentApi.upload(selectedFile.value);
uploadedMarker = currentMarker;
}
payload.attachment_receipt = uploaded.receipt;
if (metadata.value) payload.attachment_version = metadata.value.version;
return;
}
if (removed.value && metadata.value) {
payload.remove_attachment = true;
payload.attachment_version = metadata.value.version;
}
}
/** 合同保存失败时清理本轮临时上传,同时保留本地文件以便重试。 */
async function rollbackUpload() {
const pending = uploaded;
uploaded = undefined;
uploadedMarker = '';
if (!pending) return;
try {
await contractAttachmentApi.cleanup(pending.cleanup_token);
} catch {
// 服务端已记录最终清理错误;页面保留本地文件供用户再次保存。
}
}
/** 在新标签页打开鉴权取得的 PDF Blob。 */
async function preview(identity: string) {
const blob = await contractAttachmentApi.load(identity);
const objectURL = URL.createObjectURL(blob);
const opened = window.open(objectURL, '_blank', 'noopener,noreferrer');
if (!opened) {
URL.revokeObjectURL(objectURL);
throw new Error('浏览器阻止了合同附件预览窗口');
}
window.setTimeout(() => URL.revokeObjectURL(objectURL), 60_000);
}
/** 返回未保存变更标识,供离开页面前确认。 */
function marker() {
return selectedFile.value
? `select:${fileMarker(selectedFile.value)}`
: removed.value
? 'remove'
: 'unchanged';
}
return {
selectedFile,
metadata,
removed,
hasExisting,
available,
requiresReupload,
load,
select,
remove,
clearSelection,
applyToPayload,
rollbackUpload,
preview,
marker,
};
}
/** 生成稳定的本地文件选择标识。 */
function fileMarker(file: File) {
return `${file.name}:${file.size}:${file.lastModified}`;
}