feat: 新增配送合同附件安全上传功能
- 增加合同专用 PDF 上传、鉴权预览和失败清理接口 - 支持草稿附件替换移除、并发保护和启用完整性校验 - 修复循环模板引用导致文件选择器无法打开的问题 - 补充专项测试、中文项目文档和操作日志
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user