// 功能描述:独立的报修照片受控存储,内容摘要使同一账户重复上传幂等。 // 版本: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)) }