feat: 新增账户资料页与头像上传

将工作人员和用户的详情、编辑改为独立账户资料页。

增加受控头像上传与读取、图片安全校验、接口测试,并优化只读及编辑布局。

同步更新平台需求、接口安全说明、项目文档和操作日志。
This commit is contained in:
czl231
2026-08-10 22:35:47 +08:00
parent cef7223841
commit 7242048abf
20 changed files with 1351 additions and 13 deletions

View File

@@ -7,6 +7,7 @@ import (
"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/logic/upload"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
@@ -47,6 +48,16 @@ func ListStaff(ctx *gin.Context) {
// GetStaff 查询一个服务人员档案。
func GetStaff(ctx *gin.Context) { common.GetByIdentity[models.StaffAccount](ctx) }
// GetStaffAvatar 返回已鉴权工作人员资料页使用的头像二进制内容。
func GetStaffAvatar(ctx *gin.Context) {
var account models.StaffAccount
if err := common.ActiveRecords(impl.DBService).Select("avatar").Where("identity = ?", ctx.Param("identity")).First(&account).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
upload.ServeAvatar(ctx, account.Avatar)
}
// CreateStaff 创建服务人员档案。
func CreateStaff(ctx *gin.Context) {
var request struct {
@@ -103,13 +114,13 @@ func CreateStaff(ctx *gin.Context) {
// UpdateStaff 更新服务人员档案。
func UpdateStaff(ctx *gin.Context) {
var request struct {
Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"`
Avatar string `json:"avatar" binding:"max=512"`
RoleCode string `json:"role_code" binding:"max=64"`
GasBasicIdentity string `json:"gas_basic_identity"`
DeliveryBasicIdentity string `json:"delivery_basic_identity"`
WorkStatus string `json:"work_status" binding:"max=32"`
Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"`
Avatar *string `json:"avatar" binding:"omitempty,max=512"`
RoleCode string `json:"role_code" binding:"max=64"`
GasBasicIdentity string `json:"gas_basic_identity"`
DeliveryBasicIdentity string `json:"delivery_basic_identity"`
WorkStatus string `json:"work_status" binding:"max=32"`
}
if err := ctx.ShouldBindJSON(&request); err != nil || !validWorkStatus(request.WorkStatus) || !validStaffRole(request.RoleCode) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
@@ -129,7 +140,12 @@ func UpdateStaff(ctx *gin.Context) {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
common.UpdateAllowedByIdentity(ctx, &models.StaffAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "role_code": request.RoleCode, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "work_status": request.WorkStatus}, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"})
values := gin.H{"name": request.Name, "phone": request.Phone, "role_code": request.RoleCode, "gas_basic_id": gasBasicID, "delivery_basic_id": deliveryBasicID, "work_status": request.WorkStatus}
// 未选择新头像时不提交 avatar避免普通资料编辑误清空现有头像。
if request.Avatar != nil {
values["avatar"] = *request.Avatar
}
common.UpdateAllowedByIdentity(ctx, &models.StaffAccount{}, values, []string{"name", "phone", "avatar", "role_code", "gas_basic_id", "delivery_basic_id", "work_status"})
}
func validWorkStatus(status string) bool { return status == "on_duty" || status == "off_duty" }

View File

@@ -5,6 +5,7 @@ import (
"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/logic/upload"
"git.apinb.com/heqiapp/platforms/backend/api/internal/models"
"github.com/gin-gonic/gin"
)
@@ -15,6 +16,16 @@ func ListUser(ctx *gin.Context) { common.ListPage[models.UserAccount](ctx) }
// GetUser 查询一个业主客户档案。
func GetUser(ctx *gin.Context) { common.GetByIdentity[models.UserAccount](ctx) }
// GetUserAvatar 返回已鉴权用户资料页使用的头像二进制内容。
func GetUserAvatar(ctx *gin.Context) {
var account models.UserAccount
if err := common.ActiveRecords(impl.DBService).Select("avatar").Where("identity = ?", ctx.Param("identity")).First(&account).Error; err != nil {
common.RespondRecordError(ctx, err)
return
}
upload.ServeAvatar(ctx, account.Avatar)
}
// CreateUser 创建业主客户档案。
func CreateUser(ctx *gin.Context) {
var request struct {
@@ -49,14 +60,19 @@ func CreateUser(ctx *gin.Context) {
// UpdateUser 更新业主客户档案。
func UpdateUser(ctx *gin.Context) {
var request struct {
Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"`
Avatar string `json:"avatar" binding:"max=512"`
RealName string `json:"real_name" binding:"max=64"`
Name string `json:"name" binding:"required,max=64"`
Phone string `json:"phone" binding:"max=32"`
Avatar *string `json:"avatar" binding:"omitempty,max=512"`
RealName string `json:"real_name" binding:"max=64"`
}
if err := ctx.ShouldBindJSON(&request); err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
common.UpdateAllowedByIdentity(ctx, &models.UserAccount{}, gin.H{"name": request.Name, "phone": request.Phone, "avatar": request.Avatar, "real_name": request.RealName}, []string{"name", "phone", "avatar", "real_name"})
values := gin.H{"name": request.Name, "phone": request.Phone, "real_name": request.RealName}
// 未选择新头像时不提交 avatar避免普通资料编辑误清空现有头像。
if request.Avatar != nil {
values["avatar"] = *request.Avatar
}
common.UpdateAllowedByIdentity(ctx, &models.UserAccount{}, values, []string{"name", "phone", "avatar", "real_name"})
}

View File

@@ -0,0 +1,171 @@
// Package upload 提供受控头像文件的上传、校验与读取能力。
// 版本v1.0.0
package upload
import (
"bytes"
"errors"
"image"
_ "image/jpeg"
_ "image/png"
"io"
"log"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"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"
)
const (
maxAvatarSize int64 = 2 << 20
maxAvatarDimension = 4096
)
// UploadAvatar 接收 JPG/PNG 头像,验证真实图片内容后写入受控目录。
func UploadAvatar(ctx *gin.Context) {
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 {
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, maxAvatarSize+1))
if err != nil || int64(len(content)) > maxAvatarSize {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
extension, contentType, err := validateAvatar(fileHeader.Filename, content)
if err != nil {
infra.Response.Error(ctx, errcode.ErrInvalidArgument)
return
}
datePath := 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 {
infra.Response.Error(ctx, err)
return
}
targetPath := filepath.Join(directory, filename)
target, err := os.OpenFile(targetPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o640)
if err != nil {
infra.Response.Error(ctx, err)
return
}
if _, err = target.Write(content); err != nil {
target.Close()
_ = os.Remove(targetPath)
infra.Response.Error(ctx, err)
return
}
if err = target.Close(); err != nil {
_ = os.Remove(targetPath)
infra.Response.Error(ctx, err)
return
}
uri := "/uploads/avatars/" + datePath + "/" + filename
infra.Response.Success(ctx, UploadFileReply{
URI: uri,
OriginalName: fileHeader.Filename,
ContentType: contentType,
Size: int64(len(content)),
})
if claims, parseErr := sdkmiddleware.ParseAuth(ctx); parseErr == nil {
log.Printf("avatar upload client=%s account=%s type=%s size=%d uri=%s", claims.Client, claims.Identity, contentType, len(content), uri)
}
}
// ServeAvatar 仅从头像受控目录读取文件,拒绝外部 URL 与目录穿越路径。
func ServeAvatar(ctx *gin.Context, uri string) {
path, err := avatarPathFromURI(uri)
if err != nil {
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
}
ctx.Header("Cache-Control", "private, no-store")
ctx.Header("X-Content-Type-Options", "nosniff")
http.ServeContent(ctx.Writer, ctx.Request, filepath.Base(path), info.ModTime(), file)
}
// validateAvatar 验证扩展名、MIME、尺寸和完整解码结果返回规范化扩展名。
func validateAvatar(filename string, content []byte) (string, string, error) {
extension := strings.ToLower(filepath.Ext(filename))
if extension != ".jpg" && extension != ".jpeg" && extension != ".png" {
return "", "", errors.New("unsupported avatar extension")
}
contentType := http.DetectContentType(content)
expectedType := "image/jpeg"
normalizedExtension := ".jpg"
if extension == ".png" {
expectedType = "image/png"
normalizedExtension = ".png"
}
if contentType != expectedType {
return "", "", errors.New("avatar content type mismatch")
}
config, format, err := image.DecodeConfig(bytes.NewReader(content))
if err != nil || config.Width <= 0 || config.Height <= 0 ||
config.Width > maxAvatarDimension || config.Height > maxAvatarDimension {
return "", "", errors.New("invalid avatar dimensions")
}
if (expectedType == "image/jpeg" && format != "jpeg") || (expectedType == "image/png" && format != "png") {
return "", "", errors.New("avatar format mismatch")
}
if _, _, err := image.Decode(bytes.NewReader(content)); err != nil {
return "", "", errors.New("invalid avatar content")
}
return normalizedExtension, expectedType, nil
}
// avatarPathFromURI 将数据库中的受控 URI 映射为头像目录内的真实文件路径。
func avatarPathFromURI(uri string) (string, error) {
const prefix = "/uploads/avatars/"
if !strings.HasPrefix(uri, prefix) {
return "", errors.New("avatar URI is not controlled")
}
root, err := filepath.Abs(filepath.Join(uploadRoot(), "avatars"))
if err != nil {
return "", err
}
relative := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(uri, "/uploads/avatars/")))
if relative == "." || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", errors.New("invalid avatar 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("avatar path escapes root")
}
return candidate, nil
}

View File

@@ -0,0 +1,70 @@
// Package upload 测试头像上传的格式、尺寸与路径安全边界。
// 版本v1.0.0
package upload
import (
"bytes"
"image"
"image/color"
"image/png"
"path/filepath"
"strings"
"testing"
)
// pngBytes 生成指定尺寸的有效 PNG 测试图片。
func pngBytes(t *testing.T, width, height int) []byte {
t.Helper()
picture := image.NewRGBA(image.Rect(0, 0, width, height))
picture.Set(0, 0, color.RGBA{R: 32, G: 96, B: 192, A: 255})
var buffer bytes.Buffer
if err := png.Encode(&buffer, picture); err != nil {
t.Fatalf("encode PNG: %v", err)
}
return buffer.Bytes()
}
// TestValidateAvatarAcceptsRealPNG 验证真实 PNG 可通过并规范化类型。
func TestValidateAvatarAcceptsRealPNG(t *testing.T) {
extension, contentType, err := validateAvatar("头像.PNG", pngBytes(t, 2, 2))
if err != nil {
t.Fatalf("valid PNG was rejected: %v", err)
}
if extension != ".png" || contentType != "image/png" {
t.Fatalf("avatar metadata = (%q, %q)", extension, contentType)
}
}
// TestValidateAvatarRejectsSpoofedAndOversizedImage 验证伪造扩展名与超大像素尺寸会被拒绝。
func TestValidateAvatarRejectsSpoofedAndOversizedImage(t *testing.T) {
if _, _, err := validateAvatar("fake.png", []byte("not an image")); err == nil {
t.Fatal("spoofed PNG was accepted")
}
if _, _, err := validateAvatar("wide.png", pngBytes(t, maxAvatarDimension+1, 1)); err == nil {
t.Fatal("oversized image dimensions were accepted")
}
}
// TestAvatarPathFromURIStaysInsideControlledRoot 验证头像 URI 不能逃逸受控目录。
func TestAvatarPathFromURIStaysInsideControlledRoot(t *testing.T) {
root := t.TempDir()
t.Setenv("HEQI_UPLOAD_DIR", root)
path, err := avatarPathFromURI("/uploads/avatars/2026/08/10/example.png")
if err != nil {
t.Fatalf("controlled URI was rejected: %v", err)
}
expectedRoot := filepath.Join(root, "avatars")
relative, err := filepath.Rel(expectedRoot, path)
if err != nil || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
t.Fatalf("avatar path escaped root: %q", path)
}
for _, uri := range []string{
"https://example.com/avatar.png",
"/uploads/example.png",
"/uploads/avatars/../../secret.png",
} {
if _, err := avatarPathFromURI(uri); err == nil {
t.Fatalf("unsafe avatar URI was accepted: %q", uri)
}
}
}

View File

@@ -186,11 +186,13 @@ func registerCommerceRoute(group *gin.RouterGroup) {
func registerStaffRoute(group *gin.RouterGroup) {
registerWritableResource(group, "/staff_account", staff.ListStaff, staff.CreateStaff, staff.GetStaff, staff.UpdateStaff, &models.StaffAccount{})
group.GET("/staff_account/:identity/avatar", staff.GetStaffAvatar)
registerWritableResource(group, "/staff_credential", staff.ListStaffCredential, staff.CreateStaffCredential, staff.GetStaffCredential, staff.UpdateStaffCredential, &models.StaffCredential{})
}
func registerUserRoute(group *gin.RouterGroup) {
registerWritableResource(group, "/user_account", userlogic.ListUser, userlogic.CreateUser, userlogic.GetUser, userlogic.UpdateUser, &models.UserAccount{})
group.GET("/user_account/:identity/avatar", userlogic.GetUserAvatar)
registerWritableResource(group, "/user_address", userlogic.ListUserAddress, userlogic.CreateUserAddress, userlogic.GetUserAddress, userlogic.UpdateUserAddress, &models.UserAddress{})
registerWritableResource(group, "/user_service_relation", userlogic.ListUserServiceRelation, userlogic.CreateUserServiceRelation, userlogic.GetUserServiceRelation, userlogic.UpdateUserServiceRelation, &models.UserServiceRelation{})
}

View File

@@ -109,6 +109,8 @@ func TestPlatformOrganizationAndAccountRoutesExposeResourceCRUD(t *testing.T) {
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platform_menu", http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platform_menu/:identity", http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
assertRouteMethods(t, routes, "/heqi/platform/v1/platform_role/:identity/menu", http.MethodGet, http.MethodPut)
assertRouteMethods(t, routes, "/heqi/platform/v1/staff_account/:identity/avatar", http.MethodGet)
assertRouteMethods(t, routes, "/heqi/platform/v1/user_account/:identity/avatar", http.MethodGet)
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platform_role/:identity/menus", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
assertNoRouteMethods(t, routes, "/heqi/platform/v1/platfrom_account", http.MethodGet, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete)
}

View File

@@ -11,4 +11,5 @@ func registerUploadRoute(serviceKey string, engine *gin.Engine) {
authorized := engine.Group("/upload")
authorized.Use(sdkmiddleware.JwtAuth(true))
authorized.POST("/file", upload.UploadFile)
authorized.POST("/avatar", upload.UploadAvatar)
}

View File

@@ -0,0 +1,25 @@
// Package routers 测试受鉴权上传端点的路由注册。
// 版本v1.0.0
package routers
import (
"net/http"
"testing"
"github.com/gin-gonic/gin"
)
// TestUploadRoutesExposeDedicatedAvatarEndpoint 验证专用头像上传路由已注册且不改变原文件上传路由。
func TestUploadRoutesExposeDedicatedAvatarEndpoint(t *testing.T) {
engine := gin.New()
registerUploadRoute("heqi", engine)
routes := make(map[string]map[string]bool)
for _, route := range engine.Routes() {
if routes[route.Path] == nil {
routes[route.Path] = make(map[string]bool)
}
routes[route.Path][route.Method] = true
}
assertRouteMethods(t, routes, "/upload/file", http.MethodPost)
assertRouteMethods(t, routes, "/upload/avatar", http.MethodPost)
}