fix(material-import): 导入上限下调为 1MB/10000 行,同步提示、文档与测试

This commit is contained in:
文睿 张 2026-10-12 16:51:58 +08:00
parent d864294dde
commit 62372abeb6
8 changed files with 17 additions and 17 deletions

View File

@ -416,9 +416,9 @@
上传填好的 xlsx,**校验通过即在同一个请求内入库**——没有「预览确认」环节,也没有暂存任务 ID。 上传填好的 xlsx,**校验通过即在同一个请求内入库**——没有「预览确认」环节,也没有暂存任务 ID。
**请求:** `multipart/form-data`,文件字段名 `file`。仅支持 `.xlsx`,单文件不超过 5 MB,数据区不超过 20,000 行(不含表头)。 **请求:** `multipart/form-data`,文件字段名 `file`。仅支持 `.xlsx`,单文件不超过 1 MB,数据区不超过 10,000 行(不含表头)。
服务端在 multipart 解析前限制请求体为 5 MiB + 64 KiB(表单开销),文件本身仍限制为 5 MiB;超大请求在读到上限时提前拒绝。Excel 使用逐行迭代读取,发现第 20,001 条非空数据时立即停止,表头和空行不计数;较大的工作表 XML 使用临时文件,避免将整张表的所有行缓存到内存。 服务端在 multipart 解析前限制请求体为 1 MiB + 64 KiB(表单开销),文件本身仍限制为 1 MiB;超大请求在读到上限时提前拒绝。Excel 使用逐行迭代读取,发现第 10,001 条非空数据时立即停止,表头和空行不计数;较大的工作表 XML 使用临时文件,避免将整张表的所有行缓存到内存。
**逐行校验规则:** **逐行校验规则:**
@ -463,7 +463,7 @@
**状态码:** **状态码:**
- `201 Created` — 全部行通过并入库 - `201 Created` — 全部行通过并入库
- `400 Bad Request` — 存在校验未通过的行(整批不入库);模板不合法(「模板格式不正确,请重新下载最新模板」)、行数超限(「导入行数超过 20000 行上限」)、未选文件(「请选择要导入的 xlsx 文件」)、文件超过 5 MB(「文件超过 5MB 上限」) - `400 Bad Request` — 存在校验未通过的行(整批不入库);模板不合法(「模板格式不正确,请重新下载最新模板」)、行数超限(「导入行数超过 10000 行上限」)、未选文件(「请选择要导入的 xlsx 文件」)、文件超过 1 MB(「文件超过 1MB 上限」)
- `500 Internal Server Error` — 入库失败,事务已回滚,整批未落库 - `500 Internal Server Error` — 入库失败,事务已回滚,整批未落库
**行为说明:** **行为说明:**

View File

@ -262,9 +262,9 @@
**测试内容:** 验证无效文件拦截和大小、行数限制。 **测试内容:** 验证无效文件拦截和大小、行数限制。
**操作步骤:** 分别尝试上传 `.xls`、`.csv`、超过 5 MiB(5×1024×1024 字节)的文件、损坏的 `.xlsx`、缺少「物料数据」工作表或必填表头的文件,并尝试取消文件选择。再分别上传 20000 行和 20001 行合法非空数据的 `.xlsx`,两份文件均不超过 5 MiB,记录耗时和结果。 **操作步骤:** 分别尝试上传 `.xls`、`.csv`、超过 1 MiB(1024×1024 字节)的文件、损坏的 `.xlsx`、缺少「物料数据」工作表或必填表头的文件,并尝试取消文件选择。再分别上传 10000 行和 10001 行合法非空数据的 `.xlsx`,两份文件均不超过 1 MiB,记录耗时和结果。
**判断指标:** 不支持的格式、超限大小和错误模板不能新增物料,并有可理解的提示;取消选择无新增。20000 行在允许范围内,应完整导入;20001 行被整批拒绝,表头和空行不计入数量。若出现超时,记录实际提示并刷新查询是否已导入,确认结果后再决定是否重传,避免重复新增。 **判断指标:** 不支持的格式、超限大小和错误模板不能新增物料,并有可理解的提示;取消选择无新增。10000 行在允许范围内,应完整导入;10001 行被整批拒绝,表头和空行不计入数量。若出现超时,记录实际提示并刷新查询是否已导入,确认结果后再决定是否重传,避免重复新增。
### TC-26 重复数据与重复上传 ### TC-26 重复数据与重复上传

View File

@ -682,7 +682,7 @@ describe('批量导入', () => {
test.each([ test.each([
[400, '模板格式不正确,请重新下载最新模板', '模板格式不正确,请重新下载最新模板'], [400, '模板格式不正确,请重新下载最新模板', '模板格式不正确,请重新下载最新模板'],
[400, '文件超过 5MB 上限', '文件超过 5MB 上限'], [400, '文件超过 1MB 上限', '文件超过 1MB 上限'],
[500, '物料编码已存在', '物料编码已存在'], [500, '物料编码已存在', '物料编码已存在'],
[500, 'Internal Server Error', '批量导入失败'], [500, 'Internal Server Error', '批量导入失败'],
[500, undefined, '批量导入失败'] [500, undefined, '批量导入失败']

View File

@ -289,8 +289,8 @@ async function handleImportFile(event) {
ElMessage.warning('只支持 .xlsx 文件') ElMessage.warning('只支持 .xlsx 文件')
return return
} }
if (file.size > 5 * 1024 * 1024) { if (file.size > 1 * 1024 * 1024) {
ElMessage.warning('文件不能超过 5MB') ElMessage.warning('文件不能超过 1MB')
return return
} }

View File

@ -36,7 +36,7 @@ func (h *Handler) ImportMaterials(c *gin.Context) {
// 在 multipart 解析前限制读取;文件限额之外仅给表单头部预留少量空间。 // 在 multipart 解析前限制读取;文件限额之外仅给表单头部预留少量空间。
const maxRequestBytes = service.MaterialImportMaxBytes + (64 << 10) const maxRequestBytes = service.MaterialImportMaxBytes + (64 << 10)
if c.Request.ContentLength > maxRequestBytes { if c.Request.ContentLength > maxRequestBytes {
response.BadRequest(c, "文件超过 5MB 上限或表单数据过大") response.BadRequest(c, "文件超过 1MB 上限或表单数据过大")
return return
} }
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBytes) c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxRequestBytes)
@ -51,14 +51,14 @@ func (h *Handler) ImportMaterials(c *gin.Context) {
if err != nil { if err != nil {
var sizeErr *http.MaxBytesError var sizeErr *http.MaxBytesError
if errors.As(err, &sizeErr) { if errors.As(err, &sizeErr) {
response.BadRequest(c, "文件超过 5MB 上限或表单数据过大") response.BadRequest(c, "文件超过 1MB 上限或表单数据过大")
return return
} }
response.BadRequest(c, "请选择要导入的 xlsx 文件") response.BadRequest(c, "请选择要导入的 xlsx 文件")
return return
} }
if file.Size > service.MaterialImportMaxBytes { if file.Size > service.MaterialImportMaxBytes {
response.BadRequest(c, "文件超过 5MB 上限") response.BadRequest(c, "文件超过 1MB 上限")
return return
} }

View File

@ -53,7 +53,7 @@ func TestMaterialImportRejectsLargeBodyBeforeReadingItAll(t *testing.T) {
c.Request.ContentLength = contentLength c.Request.ContentLength = contentLength
c.Request.Header.Set("Content-Type", writer.FormDataContentType()) c.Request.Header.Set("Content-Type", writer.FormDataContentType())
(&Handler{}).ImportMaterials(c) (&Handler{}).ImportMaterials(c)
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "5MB") { if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), "1MB") {
t.Fatalf("status=%d, body=%s", recorder.Code, recorder.Body.String()) t.Fatalf("status=%d, body=%s", recorder.Code, recorder.Body.String())
} }
if body.read > service.MaterialImportMaxBytes+(64<<10)+1 { if body.read > service.MaterialImportMaxBytes+(64<<10)+1 {
@ -87,7 +87,7 @@ func TestMaterialImportFileSizeBoundaryAllowsMultipartOverhead(t *testing.T) {
(&Handler{materialImportSvc: service.NewMaterialImportService()}).ImportMaterials(c) (&Handler{materialImportSvc: service.NewMaterialImportService()}).ImportMaterials(c)
want := "模板格式不正确" want := "模板格式不正确"
if size > service.MaterialImportMaxBytes { if size > service.MaterialImportMaxBytes {
want = "文件超过 5MB 上限" want = "文件超过 1MB 上限"
} }
if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), want) { if recorder.Code != http.StatusBadRequest || !strings.Contains(recorder.Body.String(), want) {
t.Fatalf("status=%d, body=%s, want %s", recorder.Code, recorder.Body.String(), want) t.Fatalf("status=%d, body=%s, want %s", recorder.Code, recorder.Body.String(), want)

View File

@ -16,9 +16,9 @@ const (
// MaterialImportSheetGuide 是填写说明的表名。 // MaterialImportSheetGuide 是填写说明的表名。
MaterialImportSheetGuide = "填写说明" MaterialImportSheetGuide = "填写说明"
// MaterialImportMaxRows 是数据区最大行数(不含表头)。 // MaterialImportMaxRows 是数据区最大行数(不含表头)。
MaterialImportMaxRows = 20000 MaterialImportMaxRows = 10000
// MaterialImportMaxBytes 是上传文件大小上限。 // MaterialImportMaxBytes 是上传文件大小上限。
MaterialImportMaxBytes = 5 << 20 MaterialImportMaxBytes = 1 << 20
) )
var ( var (
@ -61,7 +61,7 @@ func BuildMaterialImportTemplate() ([]byte, error) {
14: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。", 14: "2. 在「物料数据」从第 2 行开始填写;第 1 行表头及工作表名称请保留。",
15: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。", 15: "3. 保存为 .xlsx 文件,回到「物料」页面点击「批量导入」并选择文件。",
16: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。", 16: "4. 有错误时查看行号和原因,可导出错误明细;修正原 .xlsx 后重新上传。",
19: "文件限制:仅支持 .xlsx,大小不超过 5MB;非空数据最多 20000 行,表头和全空行不计数。", 19: "文件限制:仅支持 .xlsx,大小不超过 1MB;非空数据最多 10000 行,表头和全空行不计数。",
20: "整批校验:任意一行校验失败,本次所有物料均不导入;请全部修正后重传。", 20: "整批校验:任意一行校验失败,本次所有物料均不导入;请全部修正后重传。",
21: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。", 21: "编码与库存:物料编码由系统生成,无需填写;仅新增物料,不增加库存,也不更新已有物料。",
22: "重复导入:同名同规格不会自动去重;成功后重复上传同一文件,会再次新增物料。", 22: "重复导入:同名同规格不会自动去重;成功后重复上传同一文件,会再次新增物料。",

View File

@ -190,7 +190,7 @@ func TestBuildMaterialImportTemplateRoundTrips(t *testing.T) {
for _, row := range guide { for _, row := range guide {
guideText.WriteString(strings.Join(row, " ")) guideText.WriteString(strings.Join(row, " "))
} }
for _, rule := range []string{"编码 名称", "5MB", "20000", "整批", "重复", "库存", "第 2 行"} { for _, rule := range []string{"编码 名称", "1MB", "10000", "整批", "重复", "库存", "第 2 行"} {
if !strings.Contains(guideText.String(), rule) { if !strings.Contains(guideText.String(), rule) {
t.Fatalf("填写说明缺少规则:%s", rule) t.Fatalf("填写说明缺少规则:%s", rule)
} }