This commit is contained in:
Mrx
2026-05-27 13:00:51 +08:00
parent b7fb2a73c9
commit 5cee8ff035
15 changed files with 569 additions and 146 deletions

View File

@@ -47,4 +47,7 @@ type ProductApplicationService interface {
CreateProductApiConfig(ctx context.Context, productID string, config *responses.ProductApiConfigResponse) error CreateProductApiConfig(ctx context.Context, productID string, config *responses.ProductApiConfigResponse) error
UpdateProductApiConfig(ctx context.Context, configID string, config *responses.ProductApiConfigResponse) error UpdateProductApiConfig(ctx context.Context, configID string, config *responses.ProductApiConfigResponse) error
DeleteProductApiConfig(ctx context.Context, configID string) error DeleteProductApiConfig(ctx context.Context, configID string) error
// 产品字典导出
ExportProductDictionary(ctx context.Context, format string) ([]byte, error)
} }

View File

@@ -16,6 +16,7 @@ import (
api_services "tyapi-server/internal/domains/api/services" api_services "tyapi-server/internal/domains/api/services"
"tyapi-server/internal/domains/product/entities" "tyapi-server/internal/domains/product/entities"
product_service "tyapi-server/internal/domains/product/services" product_service "tyapi-server/internal/domains/product/services"
"tyapi-server/internal/shared/export"
"tyapi-server/internal/shared/interfaces" "tyapi-server/internal/shared/interfaces"
) )
@@ -28,6 +29,7 @@ type ProductApplicationServiceImpl struct {
documentationAppService DocumentationApplicationServiceInterface documentationAppService DocumentationApplicationServiceInterface
formConfigService api_services.FormConfigService formConfigService api_services.FormConfigService
logger *zap.Logger logger *zap.Logger
exportManager *export.ExportManager
} }
// NewProductApplicationService 创建产品应用服务 // NewProductApplicationService 创建产品应用服务
@@ -38,6 +40,7 @@ func NewProductApplicationService(
documentationAppService DocumentationApplicationServiceInterface, documentationAppService DocumentationApplicationServiceInterface,
formConfigService api_services.FormConfigService, formConfigService api_services.FormConfigService,
logger *zap.Logger, logger *zap.Logger,
exportManager *export.ExportManager,
) ProductApplicationService { ) ProductApplicationService {
return &ProductApplicationServiceImpl{ return &ProductApplicationServiceImpl{
productManagementService: productManagementService, productManagementService: productManagementService,
@@ -46,6 +49,7 @@ func NewProductApplicationService(
documentationAppService: documentationAppService, documentationAppService: documentationAppService,
formConfigService: formConfigService, formConfigService: formConfigService,
logger: logger, logger: logger,
exportManager: exportManager,
} }
} }
@@ -1240,3 +1244,105 @@ func (s *ProductApplicationServiceImpl) mapFieldTypeToDocType(frontendType strin
return "string" return "string"
} }
} }
// ExportProductDictionary 导出产品字典
func (s *ProductApplicationServiceImpl) ExportProductDictionary(ctx context.Context, format string) ([]byte, error) {
// 查询所有启用且可见的产品及其分类信息
products, err := s.productManagementService.GetAllProductsForDictionary(ctx)
if err != nil {
s.logger.Error("获取产品字典数据失败", zap.Error(err))
return nil, err
}
if len(products) == 0 {
return nil, fmt.Errorf("没有找到符合条件的产品数据")
}
// 按分类分组整理数据
categoryGroups := make(map[string][]map[string]interface{})
categoryOrder := []string{} // 保持分类顺序
for _, product := range products {
// 获取分类名称
categoryName := "未分类"
if product.Category != nil {
categoryName = product.Category.Name
// 如果有二级分类,添加到分类名称中
if product.SubCategory != nil && product.SubCategory.Name != "" {
categoryName = categoryName + " / " + product.SubCategory.Name
}
}
// 如果分类不存在,初始化并添加到顺序列表
if _, exists := categoryGroups[categoryName]; !exists {
categoryGroups[categoryName] = []map[string]interface{}{}
categoryOrder = append(categoryOrder, categoryName)
}
// 添加产品到对应分类
productInfo := map[string]interface{}{
"category": categoryName,
"product_code": product.Code,
"product_name": product.Name,
"description": product.Description,
}
categoryGroups[categoryName] = append(categoryGroups[categoryName], productInfo)
}
// 准备导出数据
headers := []string{"分类", "产品编码", "产品名称", "产品简介"}
columnWidths := []float64{25, 15, 20, 40}
// 构建数据行
var data [][]interface{}
for _, categoryName := range categoryOrder {
productsInCategory := categoryGroups[categoryName]
for i, product := range productsInCategory {
// 只有每个分类的第一个产品才显示分类名称
var categoryNameForRow interface{}
if i == 0 {
categoryNameForRow = product["category"]
} else {
categoryNameForRow = ""
}
row := []interface{}{
categoryNameForRow,
product["product_code"],
product["product_name"],
product["description"],
}
data = append(data, row)
}
}
// 计算需要合并的行
mergedRegions := [][]int{}
currentRow := 1 // 从第1行开始第0行是表头
for _, categoryName := range categoryOrder {
productsCount := len(categoryGroups[categoryName])
if productsCount > 1 {
// 合并相同分类的单元格从当前行开始合并productsCount行第0列
// Excel格式[startRow, startCol, endRow, endCol]
mergedRegions = append(mergedRegions, []int{
currentRow, // startRow
0, // startCol (分类列)
currentRow + productsCount - 1, // endRow
0, // endCol
})
}
currentRow += productsCount
}
// 创建导出配置
config := &export.ExportConfig{
SheetName: "产品字典",
Headers: headers,
Data: data,
ColumnWidths: columnWidths,
MergedRegions: mergedRegions,
}
// 使用导出管理器生成文件
return s.exportManager.Export(ctx, config, format)
}

View File

@@ -1026,6 +1026,7 @@ func NewContainer() *Container {
documentationAppService product.DocumentationApplicationServiceInterface, documentationAppService product.DocumentationApplicationServiceInterface,
formConfigService api_services.FormConfigService, formConfigService api_services.FormConfigService,
logger *zap.Logger, logger *zap.Logger,
exportManager *export.ExportManager,
) product.ProductApplicationService { ) product.ProductApplicationService {
return product.NewProductApplicationService( return product.NewProductApplicationService(
productManagementService, productManagementService,
@@ -1034,6 +1035,7 @@ func NewContainer() *Container {
documentationAppService, documentationAppService,
formConfigService, formConfigService,
logger, logger,
exportManager,
) )
}, },
fx.As(new(product.ProductApplicationService)), fx.As(new(product.ProductApplicationService)),

View File

@@ -488,6 +488,24 @@ type IVYZ2A8BReq struct {
Authorized string `json:"authorized" validate:"required,oneof=0 1"` Authorized string `json:"authorized" validate:"required,oneof=0 1"`
} }
type QYGL4YABReq struct {
EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"`
EntCode string `json:"ent_code" validate:"required,validUSCI"`
IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"`
}
type QYGL3YSBReq struct {
Name string `json:"name" validate:"required,min=1,validName"`
EntCode string `json:"ent_code" validate:"required,validUSCI"`
EntName string `json:"ent_name" validate:"required,min=1,validEnterpriseName"`
}
type QYGL2YSBReq struct {
EntCode string `json:"ent_code" validate:"required,validUSCI"`
Name string `json:"name" validate:"required,min=1,validName"`
}
type IVYZ7C9DReq struct { type IVYZ7C9DReq struct {
IDCard string `json:"id_card" validate:"required,validIDCard"` IDCard string `json:"id_card" validate:"required,validIDCard"`
Name string `json:"name" validate:"required,min=1,validName"` Name string `json:"name" validate:"required,min=1,validName"`

View File

@@ -256,7 +256,9 @@ func registerAllProcessors(combService *comb.CombService) {
"QYGL8848": qygl.ProcessQYGL8848Request, //企业税收违法核查 "QYGL8848": qygl.ProcessQYGL8848Request, //企业税收违法核查
"QYGLDJ33": qygl.ProcessQYGLDJ33Request, //企业年报信息核验 "QYGLDJ33": qygl.ProcessQYGLDJ33Request, //企业年报信息核验
"QYGLBH7Y": qygl.ProcessQYGLBH7YRequest, //企业涉诉案件查询汇博 "QYGLBH7Y": qygl.ProcessQYGLBH7YRequest, //企业涉诉案件查询汇博
"QYGL4YAB": qygl.ProcessQYGL4YABRequest, //企业四要素认证shumai
"QYGL3YSB": qygl.ProcessQYGL3YSBRequest, //企业三要素认证shumai
"QYGL2YSB": qygl.ProcessQYGL2YSBRequest, //企业二要素认证shumai
// YYSY系列处理器 // YYSY系列处理器
"YYSY35TA": yysy.ProcessYYSY35TARequest, //运营商归属地数卖 "YYSY35TA": yysy.ProcessYYSY35TARequest, //运营商归属地数卖
"YYSYD50F": yysy.ProcessYYSYD50FRequest, "YYSYD50F": yysy.ProcessYYSYD50FRequest,

View File

@@ -283,6 +283,9 @@ func (s *FormConfigServiceImpl) getDTOStruct(ctx context.Context, apiCode string
"IVYZ2MN7": &dto.IVYZ2MN6Req{}, //学历Bzhicha "IVYZ2MN7": &dto.IVYZ2MN6Req{}, //学历Bzhicha
"FLXGHB4F": &dto.FLXGHB4FReq{}, //个人涉诉案件查询汇博 "FLXGHB4F": &dto.FLXGHB4FReq{}, //个人涉诉案件查询汇博
"QYGLBH7Y": &dto.QYGLBH7YReq{}, //企业涉诉案件查询汇博 "QYGLBH7Y": &dto.QYGLBH7YReq{}, //企业涉诉案件查询汇博
"QYGL4YAB": &dto.QYGL4YABReq{}, //企业四要素认证shumai
"QYGL3YSB": &dto.QYGL3YSBReq{}, //企业三要素认证shumai
"QYGL2YSB": &dto.QYGL2YSBReq{}, //企业二要素认证shumai
} }
// 优先返回已配置的DTO // 优先返回已配置的DTO

View File

@@ -0,0 +1,47 @@
package qygl
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/shumai"
)
// ProcessQYGL2YSBRequest QYGL2YSB API处理方法 - 企业二要素认证
func ProcessQYGL2YSBRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QYGL2YSBReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
reqFormData := map[string]interface{}{
"legalPerson": paramsDto.Name,
"creditNo": paramsDto.EntCode,
}
apiPath := "/v4/company/two/check" // 接口路径,根据数脉文档填写(如 v4/xxx
respBytes, err := deps.ShumaiService.CallAPIForm(ctx, apiPath, reqFormData)
if err != nil {
if errors.Is(err, shumai.ErrDatasource) {
// 数据源错误
return nil, errors.Join(processors.ErrDatasource, err)
} else if errors.Is(err, shumai.ErrSystem) {
// 系统错误
return nil, errors.Join(processors.ErrSystem, err)
} else if errors.Is(err, shumai.ErrNotFound) {
// 查无记录
return nil, errors.Join(processors.ErrNotFound, err)
} else {
// 其他未知错误
return nil, errors.Join(processors.ErrSystem, err)
}
}
return respBytes, nil
}

View File

@@ -0,0 +1,48 @@
package qygl
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/shumai"
)
// ProcessQYGL3YSBRequest QYGL3YSB API处理方法 - 企业三要素认证
func ProcessQYGL3YSBRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QYGL3YSBReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
reqFormData := map[string]interface{}{
"legalPerson": paramsDto.Name,
"companyName": paramsDto.EntName,
"creditNo": paramsDto.EntCode,
}
apiPath := "/v4/company-three/check" // 接口路径,根据数脉文档填写(如 v4/xxx
respBytes, err := deps.ShumaiService.CallAPIForm(ctx, apiPath, reqFormData)
if err != nil {
if errors.Is(err, shumai.ErrDatasource) {
// 数据源错误
return nil, errors.Join(processors.ErrDatasource, err)
} else if errors.Is(err, shumai.ErrSystem) {
// 系统错误
return nil, errors.Join(processors.ErrSystem, err)
} else if errors.Is(err, shumai.ErrNotFound) {
// 查无记录
return nil, errors.Join(processors.ErrNotFound, err)
} else {
// 其他未知错误
return nil, errors.Join(processors.ErrSystem, err)
}
}
return respBytes, nil
}

View File

@@ -0,0 +1,49 @@
package qygl
import (
"context"
"encoding/json"
"errors"
"tyapi-server/internal/domains/api/dto"
"tyapi-server/internal/domains/api/services/processors"
"tyapi-server/internal/infrastructure/external/shumai"
)
// ProcessQYGL4YABRequest QYGL4YAB API处理方法 - 企业四要素认证
func ProcessQYGL4YABRequest(ctx context.Context, params []byte, deps *processors.ProcessorDependencies) ([]byte, error) {
var paramsDto dto.QYGL4YABReq
if err := json.Unmarshal(params, &paramsDto); err != nil {
return nil, errors.Join(processors.ErrSystem, err)
}
if err := deps.Validator.ValidateStruct(paramsDto); err != nil {
return nil, errors.Join(processors.ErrInvalidParam, err)
}
reqFormData := map[string]interface{}{
"idCard": paramsDto.IDCard,
"legalPerson": paramsDto.Name,
"companyName": paramsDto.EntName,
"creditNo": paramsDto.EntCode,
}
apiPath := "/v4/company-four/check" // 接口路径,根据数脉文档填写(如 v4/xxx
respBytes, err := deps.ShumaiService.CallAPIForm(ctx, apiPath, reqFormData)
if err != nil {
if errors.Is(err, shumai.ErrDatasource) {
// 数据源错误
return nil, errors.Join(processors.ErrDatasource, err)
} else if errors.Is(err, shumai.ErrSystem) {
// 系统错误
return nil, errors.Join(processors.ErrSystem, err)
} else if errors.Is(err, shumai.ErrNotFound) {
// 查无记录
return nil, errors.Join(processors.ErrNotFound, err)
} else {
// 其他未知错误
return nil, errors.Join(processors.ErrSystem, err)
}
}
return respBytes, nil
}

View File

@@ -435,3 +435,46 @@ func (s *ProductManagementService) ListProductsWithSubscriptionStatus(ctx contex
return products, subscriptionStatusMap, total, nil return products, subscriptionStatusMap, total, nil
} }
// GetAllProductsForDictionary 获取所有启用且可见的产品(用于导出产品字典)
func (s *ProductManagementService) GetAllProductsForDictionary(ctx context.Context) ([]*entities.Product, error) {
// 构建查询条件:启用且可见
isEnabled := true
isVisible := true
filters := map[string]interface{}{
"is_enabled": isEnabled,
"is_visible": isVisible,
}
options := interfaces.ListOptions{
Page: 1,
PageSize: 1000, // 获取所有产品
Sort: "sort", // 使用 products 表的 sort 字段,排序后再按分类分组
Order: "asc",
}
// 获取产品列表
products, _, err := s.ListProducts(ctx, filters, options)
if err != nil {
s.logger.Error("获取产品字典数据失败", zap.Error(err))
return nil, fmt.Errorf("获取产品字典数据失败: %w", err)
}
// 预加载分类信息
for _, product := range products {
if product.CategoryID != "" {
category, _ := s.categoryRepo.GetByID(ctx, product.CategoryID)
product.Category = &category
}
if product.SubCategoryID != nil && *product.SubCategoryID != "" {
subCategory, err := s.subCategoryRepo.GetByID(ctx, *product.SubCategoryID)
if err == nil && subCategory != nil {
product.SubCategory = subCategory
}
}
}
return products, nil
}

View File

@@ -3,6 +3,8 @@ package repositories
import ( import (
"context" "context"
"errors" "errors"
"strings"
"tyapi-server/internal/domains/product/entities" "tyapi-server/internal/domains/product/entities"
"tyapi-server/internal/domains/product/repositories" "tyapi-server/internal/domains/product/repositories"
"tyapi-server/internal/domains/product/repositories/queries" "tyapi-server/internal/domains/product/repositories/queries"
@@ -165,6 +167,25 @@ func (r *GormProductRepository) ListProducts(ctx context.Context, query *queries
// 应用排序 // 应用排序
if query.SortBy != "" { if query.SortBy != "" {
// 检查是否是关联表字段排序
if strings.Contains(query.SortBy, ".") {
parts := strings.Split(query.SortBy, ".")
if len(parts) == 2 {
// 关联表字段排序需要JOIN表
joinTable := parts[0]
sortField := parts[1]
dbQuery = dbQuery.Joins("JOIN "+joinTable+" ON products.category_id = "+joinTable+".id")
order := joinTable + "." + sortField
if query.SortOrder == "desc" {
order += " DESC"
} else {
order += " ASC"
}
dbQuery = dbQuery.Order(order)
}
} else {
// 本表字段排序
order := query.SortBy order := query.SortBy
if query.SortOrder == "desc" { if query.SortOrder == "desc" {
order += " DESC" order += " DESC"
@@ -172,6 +193,7 @@ func (r *GormProductRepository) ListProducts(ctx context.Context, query *queries
order += " ASC" order += " ASC"
} }
dbQuery = dbQuery.Order(order) dbQuery = dbQuery.Order(order)
}
} else { } else {
dbQuery = dbQuery.Order("created_at DESC") dbQuery = dbQuery.Order("created_at DESC")
} }

View File

@@ -1659,3 +1659,54 @@ func (h *ProductAdminHandler) ExportAdminApiCalls(c *gin.Context) {
c.Header("Content-Disposition", "attachment; filename="+filename) c.Header("Content-Disposition", "attachment; filename="+filename)
c.Data(200, contentType, fileData) c.Data(200, contentType, fileData)
} }
// ExportProductDictionary 导出产品字典
// @Summary 导出产品字典
// @Description 导出所有启用且可见的产品字典,按分类分组,左侧分类列合并单元格
// @Tags 产品管理
// @Accept json
// @Produce application/vnd.openxmlformats-officedocument.spreadsheetml.sheet,text/csv
// @Security Bearer
// @Param format query string false "导出格式" Enums(excel, csv) default(excel)
// @Success 200 {file} file "导出文件"
// @Failure 400 {object} map[string]interface{} "请求参数错误"
// @Failure 401 {object} map[string]interface{} "未认证"
// @Failure 500 {object} map[string]interface{} "服务器内部错误"
// @Router /api/v1/admin/products/export-dictionary [get]
func (h *ProductAdminHandler) ExportProductDictionary(c *gin.Context) {
// 获取导出格式默认为excel
format := c.DefaultQuery("format", "excel")
if format != "excel" && format != "csv" {
h.responseBuilder.BadRequest(c, "不支持的导出格式")
return
}
// 调用应用服务导出数据
fileData, err := h.productAppService.ExportProductDictionary(c.Request.Context(), format)
if err != nil {
h.logger.Error("导出产品字典失败", zap.Error(err))
// 根据错误信息返回具体的提示
errMsg := err.Error()
if strings.Contains(errMsg, "没有找到符合条件的产品数据") || strings.Contains(errMsg, "没有数据") {
h.responseBuilder.NotFound(c, "没有找到符合条件的产品数据,请确保有启用且可见的产品")
} else if strings.Contains(errMsg, "参数") || strings.Contains(errMsg, "参数错误") {
h.responseBuilder.BadRequest(c, errMsg)
} else {
h.responseBuilder.BadRequest(c, "导出产品字典失败:"+errMsg)
}
return
}
// 设置响应头
contentType := "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
filename := "产品字典.xlsx"
if format == "csv" {
contentType = "text/csv;charset=utf-8"
filename = "产品字典.csv"
}
c.Header("Content-Type", contentType)
c.Header("Content-Disposition", "attachment; filename="+filename)
c.Data(200, contentType, fileData)
}

View File

@@ -60,6 +60,9 @@ func (r *ProductAdminRoutes) Register(router *sharedhttp.GinRouter) {
products.GET("/:id/documentation", r.handler.GetProductDocumentation) products.GET("/:id/documentation", r.handler.GetProductDocumentation)
products.POST("/:id/documentation", r.handler.CreateOrUpdateProductDocumentation) products.POST("/:id/documentation", r.handler.CreateOrUpdateProductDocumentation)
products.DELETE("/:id/documentation", r.handler.DeleteProductDocumentation) products.DELETE("/:id/documentation", r.handler.DeleteProductDocumentation)
// 产品字典导出
products.GET("/export-dictionary", r.handler.ExportProductDictionary)
} }
// 分类管理 // 分类管理

View File

@@ -15,6 +15,7 @@ type ExportConfig struct {
Headers []string // 表头 Headers []string // 表头
Data [][]interface{} // 导出数据 Data [][]interface{} // 导出数据
ColumnWidths []float64 // 列宽 ColumnWidths []float64 // 列宽
MergedRegions [][]int // 合并单元格配置 [[startRow, startCol, endRow, endCol], ...]
} }
// ExportManager 负责管理不同格式的导出 // ExportManager 负责管理不同格式的导出
@@ -95,6 +96,30 @@ func (m *ExportManager) generateExcel(ctx context.Context, config *ExportConfig)
} }
} }
// 合并单元格
if len(config.MergedRegions) > 0 {
for _, region := range config.MergedRegions {
startRow := region[0]
startCol := region[1]
endRow := region[2]
endCol := region[3]
startCell, err := excelize.CoordinatesToCellName(startCol+1, startRow+1)
if err != nil {
return nil, fmt.Errorf("生成合并区域起始单元格坐标失败: %v", err)
}
endCell, err := excelize.CoordinatesToCellName(endCol+1, endRow+1)
if err != nil {
return nil, fmt.Errorf("生成合并区域结束单元格坐标失败: %v", err)
}
err = f.MergeCell(sheetName, startCell, endCell)
if err != nil {
return nil, fmt.Errorf("合并单元格失败: %v", err)
}
}
}
// 设置列宽 // 设置列宽
for i, width := range config.ColumnWidths { for i, width := range config.ColumnWidths {
col, err := excelize.ColumnNumberToName(i + 1) col, err := excelize.ColumnNumberToName(i + 1)

View File

@@ -15,6 +15,7 @@ type ExportConfig struct {
Headers []string // 表头 Headers []string // 表头
Data [][]interface{} // 导出数据 Data [][]interface{} // 导出数据
ColumnWidths []float64 // 列宽 ColumnWidths []float64 // 列宽
MergedRegions [][]int // 合并单元格配置 [[startRow, startCol, endRow, endCol], ...]
} }
// ExportManager 负责管理不同格式的导出 // ExportManager 负责管理不同格式的导出