Files
platforms/backend/api/internal/logic/upload/ticket_photo.go
czl231 3ef33b531d 已完成用户APP首期功能开发
交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
2026-09-13 00:57:32 +08:00

115 lines
4.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 功能描述:独立的报修照片受控存储,内容摘要使同一账户重复上传幂等。
// 版本:1.0.0。
package upload
import (
"crypto/sha256"
"fmt"
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
"github.com/gin-gonic/gin"
"io"
"log"
"net/http"
"os"
"path/filepath"
"regexp"
"strings"
)
var ticketPhotoName = regexp.MustCompile(`^[a-f0-9]{64}\.(jpg|png)$`)
// ServeOwnedTicketPhoto 恢复尚未关联工单的照片,只解析当前账户目录内的摘要文件名。
func ServeOwnedTicketPhoto(ctx *gin.Context, identity, name string) {
if !ticketPhotoName.MatchString(name) {
ctx.Status(http.StatusNotFound)
return
}
ServeTicketPhoto(ctx, identity, "/uploads/ticket-photos/"+avatarOwnerDirectory("user_app", identity)+"/"+name)
}
// ticketPhotoPath 只接受当前账户目录中的摘要文件名,不允许用户指定路径。
func ticketPhotoPath(identity, uri string) (string, bool) {
prefix := "/uploads/ticket-photos/" + avatarOwnerDirectory("user_app", identity) + "/"
name := strings.TrimPrefix(uri, prefix)
if !strings.HasPrefix(uri, prefix) || !ticketPhotoName.MatchString(name) {
return "", false
}
return filepath.Join(uploadRoot(), "ticket-photos", filepath.FromSlash(avatarOwnerDirectory("user_app", identity)), name), true
}
// OwnsTicketPhoto 校验工单创建引用的资源归属和完整普通文件。
func OwnsTicketPhoto(identity, uri string) bool {
path, ok := ticketPhotoPath(identity, uri)
if !ok {
return false
}
info, err := os.Lstat(path)
return err == nil && info.Mode().IsRegular() && info.Size() > 0 && info.Size() <= maxAvatarSize
}
// UploadTicketPhoto 由用户Client鉴权入口调用,仅复用图片字节校验,不复用头像存储或权限。
func UploadTicketPhoto(ctx *gin.Context, identity string) {
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarSize+(256<<10))
header, err := ctx.FormFile("file")
if err != nil || header.Size <= 0 || header.Size > maxAvatarSize {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
file, err := header.Open()
if err != nil {
infra.Response.Error(ctx, err)
return
}
defer file.Close()
content, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
if err != nil || int64(len(content)) > maxAvatarSize {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
extension, contentType, err := validateAvatar(header.Filename, content)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
name := fmt.Sprintf("%x%s", sha256.Sum256(content), extension)
uri := "/uploads/ticket-photos/" + avatarOwnerDirectory("user_app", identity) + "/" + name
path, _ := ticketPhotoPath(identity, uri)
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
infra.Response.Error(ctx, err)
return
}
// 先完整写临时文件再原子移动,避免并发重试读到半张图片。
temp, err := os.CreateTemp(filepath.Dir(path), ".pending-")
if err != nil {
infra.Response.Error(ctx, err)
return
}
defer os.Remove(temp.Name())
_, writeErr := temp.Write(content)
closeErr := temp.Close()
if writeErr != nil || closeErr != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
if err := os.Rename(temp.Name(), path); err != nil && !OwnsTicketPhoto(identity, uri) {
infra.Response.Error(ctx, err)
return
}
infra.Response.Success(ctx, UploadFileReply{URI: uri, ContentType: contentType, Size: int64(len(content))})
log.Printf("ticket photo upload account=%s digest=%s bytes=%d", identity, name, len(content))
}
// ServeTicketPhoto 在工单归属和证据关联已经校验后读取,禁止公开缓存和MIME嗅探。
func ServeTicketPhoto(ctx *gin.Context, identity, uri string) {
if !OwnsTicketPhoto(identity, uri) {
ctx.Status(http.StatusNotFound)
return
}
path, _ := ticketPhotoPath(identity, uri)
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("X-Content-Type-Options", "nosniff")
ctx.File(path)
log.Printf("ticket photo read account=%s digest=%s", identity, filepath.Base(path))
}