已完成用户APP首期功能开发

交付用户端首期页面、配套接口、后台资源及测试文档。用户APP构建、静态分析和三个管理后台构建通过;完整测试仍有2项失败,后端模型注释检查未通过,详见交付记录。
This commit is contained in:
czl231
2026-09-13 00:57:32 +08:00
parent a0a4bb1218
commit 3ef33b531d
793 changed files with 40959 additions and 1204 deletions

View File

@@ -4,7 +4,9 @@ package upload
import (
"bytes"
"crypto/sha256"
"errors"
"fmt"
"image"
_ "image/jpeg"
_ "image/png"
@@ -30,6 +32,11 @@ const (
// UploadAvatar 接收 JPG/PNG 头像,验证真实图片内容后写入受控目录。
func UploadAvatar(ctx *gin.Context) {
claims, parseErr := sdkmiddleware.ParseAuth(ctx)
if parseErr != nil || claims.Identity == "" || claims.Client == "" {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
ctx.Request.Body = http.MaxBytesReader(ctx.Writer, ctx.Request.Body, maxAvatarSize+(256<<10))
fileHeader, err := ctx.FormFile("file")
if err != nil || fileHeader == nil || fileHeader.Size <= 0 || fileHeader.Size > maxAvatarSize {
@@ -55,7 +62,8 @@ func UploadAvatar(ctx *gin.Context) {
return
}
datePath := time.Now().Format("2006/01/02")
// 将上传文件绑定到登录主体,后续资料更新不能引用其他账户的头像。
datePath := avatarOwnerDirectory(claims.Client, claims.Identity) + "/" + time.Now().Format("2006/01/02")
filename := models.NewIdentity() + extension
directory := filepath.Join(uploadRoot(), "avatars", filepath.FromSlash(datePath))
if err := os.MkdirAll(directory, 0o750); err != nil {
@@ -92,6 +100,25 @@ func UploadAvatar(ctx *gin.Context) {
}
}
// avatarOwnerDirectory 对客户端和账户组合取摘要,避免身份内容成为文件系统路径。
func avatarOwnerDirectory(client, identity string) string {
return fmt.Sprintf("owners/%x", sha256.Sum256([]byte(client+"\x00"+identity)))
}
// OwnsAvatar 验证规范 URI 属于当前账户且指向已上传的普通文件。
func OwnsAvatar(client, identity, uri string) bool {
prefix := "/uploads/avatars/" + avatarOwnerDirectory(client, identity) + "/"
if !strings.HasPrefix(uri, prefix) || strings.Contains(uri, "\\") || strings.Contains(uri, "..") {
return false
}
path, err := avatarPathFromURI(uri)
if err != nil {
return false
}
info, err := os.Lstat(path)
return err == nil && info.Mode().IsRegular()
}
// ServeAvatar 仅从头像受控目录读取文件,拒绝外部 URL 与目录穿越路径。
func ServeAvatar(ctx *gin.Context, uri string) {
path, err := avatarPathFromURI(uri)

View File

@@ -7,6 +7,7 @@ import (
"image"
"image/color"
"image/png"
"os"
"path/filepath"
"strings"
"testing"
@@ -24,6 +25,33 @@ func pngBytes(t *testing.T, width, height int) []byte {
return buffer.Bytes()
}
// TestAvatarOwnership 拒绝跨用户、跨客户端及穿越伪造路径,兼容读取旧头像路径。
func TestAvatarOwnership(t *testing.T) {
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
uri := "/uploads/avatars/" + avatarOwnerDirectory("user_app", "alice") + "/2026/09/07/avatar.png"
path, err := avatarPathFromURI(uri)
if err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, pngBytes(t, 2, 2), 0o640); err != nil {
t.Fatal(err)
}
if !OwnsAvatar("user_app", "alice", uri) {
t.Fatal("当前用户上传资源未识别")
}
if OwnsAvatar("user_app", "bob", uri) || OwnsAvatar("platform_admin", "alice", uri) {
t.Fatal("允许跨账户引用")
}
for _, unsafe := range []string{uri + "/../avatar.png", strings.ReplaceAll(uri, "/", "\\"), "/uploads/avatars/2026/09/07/avatar.png"} {
if OwnsAvatar("user_app", "alice", unsafe) {
t.Fatalf("接受未绑定 URI:%s", unsafe)
}
}
}
// TestValidateAvatarAcceptsRealPNG 验证真实 PNG 可通过并规范化类型。
func TestValidateAvatarAcceptsRealPNG(t *testing.T) {
extension, contentType, err := validateAvatar("头像.PNG", pngBytes(t, 2, 2))

View File

@@ -0,0 +1,88 @@
// 功能描述:后台商品图片上传及公开读取,独立于私有头像和工单照片;版本:1.0.0。
package upload
import (
"git.apinb.com/bsm-sdk/core/errcode"
"git.apinb.com/bsm-sdk/core/infra"
sdkmiddleware "git.apinb.com/bsm-sdk/core/middleware"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
"io"
"net/http"
"os"
"path/filepath"
"regexp"
)
var productImageName = regexp.MustCompile(`^[a-fA-F0-9-]{36}\.(jpg|png)$`)
// UploadProductImage 仅平台后台上传公开商品素材,限制2MB和4096像素并完整解码。
func UploadProductImage(ctx *gin.Context) {
claims, err := sdkmiddleware.ParseAuth(ctx)
if err != nil || claims.Client != "platform_admin" || claims.Identity == "" {
infra.Response.Error(ctx, errcode.ErrPermissionDenied)
return
}
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()
data, err := io.ReadAll(io.LimitReader(file, maxAvatarSize+1))
if err != nil || int64(len(data)) > maxAvatarSize {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
ext, mime, err := validateAvatar(header.Filename, data)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
name := models.NewIdentity() + ext
directory := filepath.Join(uploadRoot(), "product-images")
if err := os.MkdirAll(directory, 0750); err != nil {
infra.Response.Error(ctx, err)
return
}
path := filepath.Join(directory, name)
target, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0640)
if err != nil {
infra.Response.Error(ctx, err)
return
}
_, writeErr := target.Write(data)
closeErr := target.Close()
if writeErr != nil || closeErr != nil {
_ = os.Remove(path)
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
infra.Response.Success(ctx, UploadFileReply{URI: "/uploads/product-images/" + name, OriginalName: header.Filename, ContentType: mime, Size: int64(len(data))})
}
// ServeProductImage 仅公开独立商品图片目录内的普通文件,不暴露通用上传根目录。
func ServeProductImage(ctx *gin.Context) {
name := ctx.Param("name")
if !productImageName.MatchString(name) {
ctx.Status(http.StatusNotFound)
return
}
path := filepath.Join(uploadRoot(), "product-images", name)
info, err := os.Lstat(path)
if err != nil || !info.Mode().IsRegular() {
ctx.Status(http.StatusNotFound)
return
}
ctx.Header("X-Content-Type-Options", "nosniff")
// 普通img请求也必须按Origin区分缓存,避免无跨域头的缓存被Flutter跨域下载复用。
ctx.Header("Vary", "Origin")
ctx.Header("Cache-Control", "public, max-age=86400")
ctx.File(path)
}

View File

@@ -0,0 +1,82 @@
// 功能描述:商品图片上传读取闭环及权限边界;版本:1.0.0。
package upload
import (
"bytes"
"encoding/json"
"git.apinb.com/bsm-sdk/core/types"
"github.com/gin-gonic/gin"
"mime/multipart"
"net/http/httptest"
"path/filepath"
"testing"
)
// TestProductImageRoundTrip 验证后台上传后匿名图片读取字节一致,普通用户不能上传。
func TestProductImageRoundTrip(t *testing.T) {
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
picture := pngBytes(t, 8, 8)
for _, client := range []string{"user_app", "platform_admin"} {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, _ := writer.CreateFormFile("file", "product.png")
_, _ = part.Write(picture)
_ = writer.Close()
response := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(response)
ctx.Set("Auth", &types.JwtClaims{Client: client, Identity: "test-admin"})
ctx.Request = httptest.NewRequest("POST", "/upload/product-image", &body)
ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
UploadProductImage(ctx)
var reply struct {
Code int `json:"code"`
Details json.RawMessage `json:"details"`
}
if err := json.Unmarshal(response.Body.Bytes(), &reply); err != nil {
t.Fatal(err)
}
if client == "user_app" {
if reply.Code == 0 {
t.Fatal("普通用户上传成功")
}
continue
}
if reply.Code != 0 {
t.Fatal(response.Body.String())
}
var uploaded UploadFileReply
if err := json.Unmarshal(reply.Details, &uploaded); err != nil {
t.Fatal(err)
}
readResponse := httptest.NewRecorder()
readContext, _ := gin.CreateTestContext(readResponse)
readContext.Request = httptest.NewRequest("GET", uploaded.URI, nil)
readContext.Params = gin.Params{{Key: "name", Value: filepath.Base(uploaded.URI)}}
ServeProductImage(readContext)
if readResponse.Header().Get("Vary") != "Origin" {
t.Fatal("无Origin的图片请求也必须区分跨域缓存")
}
if readResponse.Code != 200 || !bytes.Equal(readResponse.Body.Bytes(), picture) {
t.Fatal("商品图片读取不一致")
}
if readResponse.Header().Get("X-Content-Type-Options") != "nosniff" {
t.Fatal("缺少类型保护")
}
}
}
// TestProductImageRejectsForeignPath 商品公开入口不能读取头像、工单或任意磁盘路径。
func TestProductImageRejectsForeignPath(t *testing.T) {
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
for _, name := range []string{"../avatars/private.png", `..\ticket-photos\private.png`, "C:\\private.png", "photo.svg", ""} {
response := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(response)
ctx.Request = httptest.NewRequest("GET", "/uploads/product-images/invalid", nil)
ctx.Params = gin.Params{{Key: "name", Value: name}}
ServeProductImage(ctx)
ctx.Writer.WriteHeaderNow()
if response.Code != 404 || response.Body.Len() != 0 {
t.Fatalf("错误读取非商品资源: %q", name)
}
}
}

View File

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

View File

@@ -0,0 +1,119 @@
// 功能描述:报修上传的内容幂等、格式、跨账户和路径边界回归。
// 版本:1.0.0。
package upload
import (
"bytes"
"encoding/json"
"github.com/gin-gonic/gin"
"mime/multipart"
"net/http/httptest"
"path"
"strings"
"testing"
)
func uploadPhotoForTest(t *testing.T, owner, filename string, content []byte) (string, int) {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
part.Write(content)
writer.Close()
response := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(response)
ctx.Request = httptest.NewRequest("POST", "/ticket-photos", &body)
ctx.Request.Header.Set("Content-Type", writer.FormDataContentType())
UploadTicketPhoto(ctx, owner)
var result struct {
Code int `json:"code"`
Details json.RawMessage `json:"details"`
}
if err := json.Unmarshal(response.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
var details struct {
URI string `json:"uri"`
}
if result.Code == 0 {
if err := json.Unmarshal(result.Details, &details); err != nil {
t.Fatal(err)
}
}
return details.URI, result.Code
}
// TestDraftPhotoRead 验证草稿读取只能访问当前账户已上传的文件名。
func TestDraftPhotoRead(t *testing.T) {
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
picture := pngBytes(t, 2, 2)
uri, code := uploadPhotoForTest(t, "alice", "image.png", picture)
if code != 0 {
t.Fatal("上传失败", code)
}
for _, scenario := range []struct {
owner, name string
status int
}{
{"alice", path.Base(uri), 200},
{"bob", path.Base(uri), 404},
{"alice", "../" + path.Base(uri), 404},
{"alice", "unknown.png", 404},
} {
response := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(response)
ctx.Request = httptest.NewRequest("GET", "/ticket-photos/file", nil)
ServeOwnedTicketPhoto(ctx, scenario.owner, scenario.name)
ctx.Writer.WriteHeaderNow()
if response.Code != scenario.status {
t.Fatalf("%s: 状态 %d", scenario.owner, response.Code)
}
if scenario.status == 200 && !bytes.Equal(response.Body.Bytes(), picture) {
t.Fatal("图片内容不一致")
}
}
}
func TestTicketPhotoStorage(t *testing.T) {
t.Setenv("HEQI_UPLOAD_DIR", t.TempDir())
picture := pngBytes(t, 2, 2)
first, code := uploadPhotoForTest(t, "alice", "image.png", picture)
if code != 0 || !OwnsTicketPhoto("alice", first) {
t.Fatal("上传失败", code)
}
second, code := uploadPhotoForTest(t, "alice", "other.png", picture)
if code != 0 || first != second {
t.Fatal("重复上传未复用")
}
if OwnsTicketPhoto("bob", first) {
t.Fatal("跨账户引用")
}
for _, uri := range []string{first + "/../secret.png", strings.ReplaceAll(first, "/", "\\"), "https://example.com/a.png", "/uploads/avatars/a.png"} {
if OwnsTicketPhoto("alice", uri) {
t.Fatal("非法路径", uri)
}
}
for _, data := range [][]byte{[]byte("fake"), pngBytes(t, 4097, 1), make([]byte, (2<<20)+1)} {
if _, code := uploadPhotoForTest(t, "alice", "x.png", data); code == 0 {
t.Fatal("错误图片被接受")
}
}
response := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(response)
ctx.Request = httptest.NewRequest("GET", "/photo", nil)
ServeTicketPhoto(ctx, "alice", first)
if response.Code != 200 || response.Header().Get("Cache-Control") != "private, no-store" || !bytes.Equal(response.Body.Bytes(), picture) {
t.Fatal("读取或缓存保护失败")
}
response = httptest.NewRecorder()
ctx, _ = gin.CreateTestContext(response)
ctx.Request = httptest.NewRequest("GET", "/photo", nil)
ServeTicketPhoto(ctx, "bob", first)
ctx.Writer.WriteHeaderNow()
if response.Code != 404 {
t.Fatal("越权读取")
}
}