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")