first commit
This commit is contained in:
@@ -0,0 +1,472 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/classification"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/matting"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-backend/pkg/upload"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
// imageProcessSem 限制异步抠图处理的并发数,避免瞬时大量上传打满下游抠图/分类服务。
|
||||
var imageProcessSem = make(chan struct{}, 10)
|
||||
|
||||
// ClothesService 衣物业务服务。
|
||||
type ClothesService struct {
|
||||
clothesDao *mysqldao.ClothesDao
|
||||
userDao *mysqldao.UserDao
|
||||
}
|
||||
|
||||
// NewClothesService 创建衣物服务。
|
||||
func NewClothesService() *ClothesService {
|
||||
return &ClothesService{
|
||||
clothesDao: mysqldao.NewClothesDao(),
|
||||
userDao: mysqldao.NewUserDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// Upload 上传衣物图片:同步完成校验、扣额度、建记录后立即返回,
|
||||
// 抠图与分类在后台 goroutine 异步执行并回填状态。
|
||||
func (s *ClothesService) Upload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.ClothesUploadResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpload")
|
||||
defer span.End()
|
||||
|
||||
if fileHeader == nil || fileHeader.Size == 0 {
|
||||
return nil, errcode.UploadFileEmpty
|
||||
}
|
||||
|
||||
// 1. 校验扩展名与大小。
|
||||
ext := upload.FileGetExt(fileHeader.Filename)
|
||||
if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
|
||||
return nil, errcode.UploadFileExtNotAllow
|
||||
}
|
||||
if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
|
||||
return nil, errcode.UploadFileTooLarge
|
||||
}
|
||||
|
||||
// 2. 读取原图字节(必须同步读取,请求返回后 multipart 临时文件会被清理)。
|
||||
src, oerr := fileHeader.Open()
|
||||
if oerr != nil {
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
imageData, rerr := io.ReadAll(src)
|
||||
_ = src.Close()
|
||||
if rerr != nil {
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
|
||||
// 3. 校验文件内容为真实图片(magic bytes),防止伪造扩展名上传恶意文件。
|
||||
if !upload.FileCheckImageMagic(imageData) {
|
||||
return nil, errcode.UploadFileExtNotAllow
|
||||
}
|
||||
|
||||
// 4. 扣减 COS 上传额度(权威扣减,先到期凭证先用,再扣免费额度)。
|
||||
if qerr := consumeCosQuota(ctx, userID, 1, 0, "上传衣物消耗"); qerr != nil {
|
||||
return nil, qerr
|
||||
}
|
||||
|
||||
// 5. 创建衣物记录,初始状态为排队中。
|
||||
clothesUUID := genUUID()
|
||||
clothes := &mysqlmodel.Clothes{
|
||||
ClothesUUID: clothesUUID,
|
||||
UserID: userID,
|
||||
ImageSize: fileHeader.Size / 1024,
|
||||
ImageProcessStatus: ImageProcessQueuing,
|
||||
}
|
||||
if err := s.clothesDao.ClothesCreate(ctx, clothes); err != nil {
|
||||
// 建记录失败,回退已扣额度。
|
||||
refundCosQuota(ctx, userID, 1, 0, "上传失败回退")
|
||||
return nil, errcode.ClothesCreateFailed
|
||||
}
|
||||
|
||||
// 6. 异步抠图 + 分类。
|
||||
go s.processImageAsync(ctx, userID, clothesUUID, imageData)
|
||||
|
||||
return &dto.ClothesUploadResponse{
|
||||
ClothesUUID: clothesUUID,
|
||||
ImageProcessStatus: clothes.ImageProcessStatus,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// processImageAsync 后台执行抠图、上传 COS、分类,并回填衣物记录。
|
||||
func (s *ClothesService) processImageAsync(parentCtx context.Context, userID int64, clothesUUID string, imageData []byte) {
|
||||
// 限流:获取信号量,超出并发上限时阻塞等待。
|
||||
imageProcessSem <- struct{}{}
|
||||
defer func() { <-imageProcessSem }()
|
||||
|
||||
// 派生独立 ctx:继承父 trace 信息,但切断取消信号,避免复用已结束的请求 ctx。
|
||||
ctx, cancel := context.WithTimeout(trace.ContextWithSpanContext(context.Background(), trace.SpanContextFromContext(parentCtx)), 3*time.Minute)
|
||||
defer cancel()
|
||||
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessImageAsync")
|
||||
defer span.End()
|
||||
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
slog.ErrorContext(ctx, "clothes image process panic", "clothes_uuid", clothesUUID, "recover", r)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "内部错误")
|
||||
}
|
||||
}()
|
||||
|
||||
dao := s.clothesDao
|
||||
|
||||
// 1. 抠图。
|
||||
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessMatting})
|
||||
mattingClient := matting.NewClient()
|
||||
processedData, err := retryCall(ctx, global.MattingModelSetting.MaxRetry, "matting", func() ([]byte, error) {
|
||||
return mattingClient.Matting(ctx, imageData)
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "matting failed", "clothes_uuid", clothesUUID, "error", err)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片抠图失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 2. 处理结果落临时目录并上传 COS。对象键结构:用户UUID/用户ID/图片文件名。
|
||||
processedName := genUUID() + ".png"
|
||||
// 获取用户 UUID,作为 COS 对象键第一层(uuid/userid/图片)。
|
||||
user, gerr := s.userDao.UserGetByID(ctx, userID)
|
||||
if gerr != nil {
|
||||
slog.ErrorContext(ctx, "get user for cos key failed", "clothes_uuid", clothesUUID, "error", gerr)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "获取用户信息失败")
|
||||
return
|
||||
}
|
||||
subDir := fmt.Sprintf("%s/%d", user.UserUUID, userID)
|
||||
if _, serr := upload.FileSaveTmpBytes(ctx, processedData, processedName, subDir); serr != nil {
|
||||
slog.ErrorContext(ctx, "save processed image failed", "clothes_uuid", clothesUUID, "error", serr)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片保存失败")
|
||||
return
|
||||
}
|
||||
cosKey := fmt.Sprintf("%s/%s", subDir, processedName)
|
||||
if uerr := upload.FileUploadCos(ctx, cosKey); uerr != nil {
|
||||
slog.ErrorContext(ctx, "upload processed image to cos failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片上传失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 上传成功后回填前检查衣物是否仍存在:用户可能在处理期间删除衣物,
|
||||
// 若已删除则清理刚上传的对象,避免 COS 残留(额度已由删除事务回退,无需再处理)。
|
||||
if _, gerr := dao.ClothesGetByUUID(ctx, clothesUUID); isNotFound(gerr) {
|
||||
if derr := upload.FileDeleteCos(ctx, cosKey); derr != nil {
|
||||
slog.ErrorContext(ctx, "delete orphan cos image failed", "clothes_uuid", clothesUUID, "cos_key", cosKey, "error", derr)
|
||||
}
|
||||
slog.InfoContext(ctx, "clothes deleted during processing, cleaned uploaded image", "clothes_uuid", clothesUUID)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 分类。
|
||||
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"image_process_status": ImageProcessClassifying})
|
||||
classifyClient := classification.NewClient()
|
||||
result, cerr := retryCall(ctx, global.ClassificationModelSetting.MaxRetry, "classify", func() (*classification.ClassifyResult, error) {
|
||||
return classifyClient.Classify(ctx, processedData)
|
||||
})
|
||||
if cerr != nil {
|
||||
slog.ErrorContext(ctx, "classify failed", "clothes_uuid", clothesUUID, "error", cerr)
|
||||
// 分类失败仍保留已抠图结果,但条件标记失败并回退以便用户手动补全。
|
||||
_ = dao.ClothesUpdateByUUID(ctx, clothesUUID, map[string]any{"processed_image": cosKey})
|
||||
s.markProcessFailed(ctx, userID, clothesUUID, "图片分类失败")
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 回填分类结果与处理后图片,标记成功。注意主色列名为 color。
|
||||
updates := map[string]any{
|
||||
"category_main": result.CategoryMain,
|
||||
"category_sub": result.CategorySub,
|
||||
"style": result.Style,
|
||||
"name": buildDefaultClothesName(result),
|
||||
"color": result.ColorMain,
|
||||
"color_sub": result.ColorSub,
|
||||
"thickness": result.Thickness,
|
||||
"version": result.Version,
|
||||
"material": result.Material,
|
||||
"pattern": result.Pattern,
|
||||
"sleeve_type": result.SleeveType,
|
||||
"clothes_type": result.ClothesType,
|
||||
"neckline_type": result.NecklineType,
|
||||
"formality": result.Formality,
|
||||
"occasions": result.Occasions,
|
||||
"visual_impact": result.VisualImpact,
|
||||
"processed_image": cosKey,
|
||||
"image_process_status": ImageProcessSuccess,
|
||||
"fail_reason": "",
|
||||
}
|
||||
if uerr := dao.ClothesUpdateByUUID(ctx, clothesUUID, updates); uerr != nil {
|
||||
slog.ErrorContext(ctx, "update clothes after classify failed", "clothes_uuid", clothesUUID, "error", uerr)
|
||||
}
|
||||
}
|
||||
|
||||
// buildDefaultClothesName 根据分类结果生成衣服默认名称(风格+小类+大类,空字段跳过)。
|
||||
func buildDefaultClothesName(result *classification.ClassifyResult) string {
|
||||
name := ""
|
||||
if result.Style != "" {
|
||||
name += result.Style
|
||||
}
|
||||
if result.CategorySub != "" {
|
||||
name += result.CategorySub
|
||||
}
|
||||
if result.CategoryMain != "" {
|
||||
name += result.CategoryMain
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// retryCall 对 fn 进行最多 maxRetry 次额外重试(总尝试 maxRetry+1 次)。
|
||||
// 重试间隔 1s;ctx 到期则提前终止并返回 ctx.Err()。
|
||||
func retryCall[T any](ctx context.Context, maxRetry int, name string, fn func() (T, error)) (T, error) {
|
||||
var zero T
|
||||
var err error
|
||||
for attempt := 0; attempt <= maxRetry; attempt++ {
|
||||
result, e := fn()
|
||||
if e == nil {
|
||||
return result, nil
|
||||
}
|
||||
err = e
|
||||
if attempt >= maxRetry {
|
||||
break
|
||||
}
|
||||
slog.WarnContext(ctx, "model call failed, retrying", "name", name, "attempt", attempt+1, "error", e)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return zero, ctx.Err()
|
||||
case <-time.After(time.Second):
|
||||
}
|
||||
}
|
||||
return zero, err
|
||||
}
|
||||
|
||||
// markProcessFailed 标记处理失败并回退上传额度。
|
||||
// 单事务(行锁 -> 置失败 -> 回退额度 + 写流水),与删除事务(FOR UPDATE)通过行锁互斥:
|
||||
// 两路径串行化保证「处理失败回退」与「删除回退」恰好执行一次,消除漏退/双退窗口。
|
||||
func (s *ClothesService) markProcessFailed(ctx context.Context, userID int64, clothesUUID, reason string) {
|
||||
vipExpire := getUserVIPExpire(ctx, userID)
|
||||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var c mysqlmodel.Clothes
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("clothes_uuid = ?", clothesUUID).
|
||||
First(&c).Error; err != nil {
|
||||
if isNotFound(err) {
|
||||
// 衣物已删除:删除事务已回退额度,跳过。
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// 已是失败态:说明失败回退已处理过,跳过。
|
||||
if c.ImageProcessStatus == ImageProcessFailed {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 置失败。
|
||||
if err := tx.Model(&mysqlmodel.Clothes{}).
|
||||
Where("clothes_uuid = ?", clothesUUID).
|
||||
Updates(map[string]any{"image_process_status": ImageProcessFailed, "fail_reason": reason}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 同事务回退额度(优先免费额度,不足回退凭证)。
|
||||
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "衣物处理失败回退")
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "mark process failed transaction error", "clothes_uuid", clothesUUID, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessStatusGetByUUID 查询衣物图片处理状态。
|
||||
func (s *ClothesService) ProcessStatusGetByUUID(ctx context.Context, userID int64, request *dto.ClothesProcessStatusGetByUUIDRequest) (*dto.ClothesProcessStatusGetByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesProcessStatusGetByUUID")
|
||||
defer span.End()
|
||||
|
||||
clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
return &dto.ClothesProcessStatusGetByUUIDResponse{
|
||||
ImageProcessStatus: clothes.ImageProcessStatus,
|
||||
FailReason: clothes.FailReason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetByUUID 获取衣物详情。
|
||||
func (s *ClothesService) GetByUUID(ctx context.Context, userID int64, request *dto.ClothesGetByUUIDRequest) (*dto.ClothesGetByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesGetByUUID")
|
||||
defer span.End()
|
||||
|
||||
clothes, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID)
|
||||
if cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
response := buildClothesResponse(clothes)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
// UpdateByUUID 修改衣物属性(仅更新非空字段)。
|
||||
func (s *ClothesService) UpdateByUUID(ctx context.Context, userID int64, request *dto.ClothesUpdateByUUIDRequest) (*dto.ClothesUpdateByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesUpdateByUUID")
|
||||
defer span.End()
|
||||
|
||||
if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
updates := map[string]any{}
|
||||
setIfNotEmpty(updates, "category_main", request.CategoryMain)
|
||||
setIfNotEmpty(updates, "category_sub", request.CategorySub)
|
||||
setIfNotEmpty(updates, "style", request.Style)
|
||||
setIfNotEmpty(updates, "color", request.ColorMain) // 主色列名为 color
|
||||
setIfNotEmpty(updates, "color_sub", request.ColorSub)
|
||||
setIfNotEmpty(updates, "thickness", request.Thickness)
|
||||
setIfNotEmpty(updates, "version", request.Version)
|
||||
setIfNotEmpty(updates, "material", request.Material)
|
||||
setIfNotEmpty(updates, "pattern", request.Pattern)
|
||||
setIfNotEmpty(updates, "sleeve_type", request.SleeveType)
|
||||
setIfNotEmpty(updates, "clothes_type", request.ClothesType)
|
||||
setIfNotEmpty(updates, "neckline_type", request.NecklineType)
|
||||
setIfNotEmpty(updates, "formality", request.Formality)
|
||||
setIfNotEmpty(updates, "occasions", request.Occasions)
|
||||
setIfNotEmpty(updates, "visual_impact", request.VisualImpact)
|
||||
setIfNotEmpty(updates, "name", request.Name)
|
||||
setIfNotEmpty(updates, "remark", request.Remark)
|
||||
|
||||
if len(updates) == 0 {
|
||||
return &dto.ClothesUpdateByUUIDResponse{}, nil
|
||||
}
|
||||
if err := s.clothesDao.ClothesUpdateByUUID(ctx, request.ClothesUUID, updates); err != nil {
|
||||
return nil, errcode.ClothesUpdateFailed
|
||||
}
|
||||
return &dto.ClothesUpdateByUUIDResponse{}, nil
|
||||
}
|
||||
|
||||
// DeleteByUUID 删除衣物(软删除)。
|
||||
func (s *ClothesService) DeleteByUUID(ctx context.Context, userID int64, request *dto.ClothesDeleteByUUIDRequest) (*dto.ClothesDeleteByUUIDResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesDeleteByUUID")
|
||||
defer span.End()
|
||||
|
||||
if _, cerr := s.getOwnedClothes(ctx, request.ClothesUUID, userID); cerr != nil {
|
||||
return nil, cerr
|
||||
}
|
||||
|
||||
// 事务内锁定衣物行 -> 读最新状态 -> 软删除 -> 条件回退额度 -> 写回退流水,
|
||||
// 数值变更与流水同事务强一致,并避免与异步处理失败回退并发导致漏退/双退。
|
||||
vipExpire := getUserVIPExpire(ctx, userID)
|
||||
var processedImage string
|
||||
err := global.MysqlEngine.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var c mysqlmodel.Clothes
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("clothes_uuid = ?", request.ClothesUUID).
|
||||
First(&c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
processedImage = c.ProcessedImage
|
||||
if err := tx.Where("clothes_uuid = ?", request.ClothesUUID).Delete(&mysqlmodel.Clothes{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 失败状态已在处理失败路径回退,不再回退,避免双重回退。
|
||||
if c.ImageProcessStatus == ImageProcessFailed {
|
||||
return nil
|
||||
}
|
||||
// 同事务回退额度(优先免费额度,不足回退凭证)。
|
||||
return refundCosQuotaTx(tx, userID, vipExpire, 1, 0, "删除衣物回退")
|
||||
})
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.ClothesNotFound
|
||||
}
|
||||
return nil, errcode.ClothesDeleteFailed
|
||||
}
|
||||
|
||||
// 事务提交成功后清理 COS 图片(尽力而为,失败仅记录日志,不影响删除结果)。
|
||||
if processedImage != "" {
|
||||
if derr := upload.FileDeleteCos(ctx, processedImage); derr != nil {
|
||||
slog.WarnContext(ctx, "delete clothes cos image failed", "clothes_uuid", request.ClothesUUID, "cos_key", processedImage, "error", derr)
|
||||
}
|
||||
}
|
||||
|
||||
return &dto.ClothesDeleteByUUIDResponse{}, nil
|
||||
}
|
||||
|
||||
// List 分页查询用户衣物。
|
||||
func (s *ClothesService) List(ctx context.Context, userID int64, request *dto.ClothesListRequest) (*dto.ClothesListResponse, *errcode.Error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "svc.ClothesList")
|
||||
defer span.End()
|
||||
|
||||
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||
|
||||
list, total, err := s.clothesDao.ClothesListByUserID(ctx, userID, request.CategoryMain, request.CategorySub, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
items := make([]dto.ClothesGetByUUIDResponse, 0, len(list))
|
||||
for i := range list {
|
||||
items = append(items, buildClothesResponse(&list[i]))
|
||||
}
|
||||
|
||||
return &dto.ClothesListResponse{
|
||||
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||
List: items,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// getOwnedClothes 查询衣物并校验归属。
|
||||
func (s *ClothesService) getOwnedClothes(ctx context.Context, clothesUUID string, userID int64) (*mysqlmodel.Clothes, *errcode.Error) {
|
||||
clothes, err := s.clothesDao.ClothesGetByUUID(ctx, clothesUUID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return nil, errcode.ClothesNotFound
|
||||
}
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if clothes.UserID != userID {
|
||||
return nil, errcode.ClothesNoPermission
|
||||
}
|
||||
return clothes, nil
|
||||
}
|
||||
|
||||
// buildClothesResponse 将衣物模型映射为响应,processed_image 转为可访问 URL。
|
||||
func buildClothesResponse(c *mysqlmodel.Clothes) dto.ClothesGetByUUIDResponse {
|
||||
processed := c.ProcessedImage
|
||||
if processed != "" {
|
||||
processed = upload.GetCosUrl(processed)
|
||||
}
|
||||
return dto.ClothesGetByUUIDResponse{
|
||||
ClothesUUID: c.ClothesUUID,
|
||||
CategoryMain: c.CategoryMain,
|
||||
CategorySub: c.CategorySub,
|
||||
Style: c.Style,
|
||||
ColorMain: c.ColorMain,
|
||||
ColorSub: c.ColorSub,
|
||||
Thickness: c.Thickness,
|
||||
Version: c.Version,
|
||||
Material: c.Material,
|
||||
Pattern: c.Pattern,
|
||||
SleeveType: c.SleeveType,
|
||||
ClothesType: c.ClothesType,
|
||||
NecklineType: c.NecklineType,
|
||||
Formality: c.Formality,
|
||||
Occasions: c.Occasions,
|
||||
VisualImpact: c.VisualImpact,
|
||||
Name: c.Name,
|
||||
Remark: c.Remark,
|
||||
ProcessedImage: processed,
|
||||
}
|
||||
}
|
||||
|
||||
// setIfNotEmpty 当值非空时写入 updates map。
|
||||
func setIfNotEmpty(m map[string]any, key, val string) {
|
||||
if val != "" {
|
||||
m[key] = val
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user