fix: support integer widths in website Excel imports

This commit is contained in:
samwaf
2026-10-27 14:42:10 +08:00
parent 9908d5124a
commit 2707b5350e
5 changed files with 860 additions and 251 deletions
+37 -221
View File
@@ -1,14 +1,10 @@
package api
import (
"SamWaf/common/uuid"
"SamWaf/customtype"
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/model/common/response"
"SamWaf/model/request"
"errors"
"SamWaf/service/waf_service"
"fmt"
"log"
"net/http"
@@ -23,11 +19,6 @@ import (
type WafCommonApi struct {
}
type ReturnImportData struct {
SuccessInt int
FailInt int
Msg string
}
// 导出excel
func (w *WafCommonApi) ExportExcelApi(c *gin.Context) {
@@ -67,7 +58,19 @@ func (w *WafCommonApi) ExportExcelApi(c *gin.Context) {
if field.Name == "BaseOrm" {
f.SetCellValue(sheetName, fmt.Sprintf("%s%d", w.GetColumnName(j), rowNum), "")
} else {
colValue := rowValue.Field(j).Interface()
value := rowValue.Field(j)
colValue := value.Interface()
// Excel 数值仅保留 15 位有效数字,大整数使用文本单元格。
switch value.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
if n := value.Int(); n > 999999999999999 || n < -999999999999999 {
colValue = strconv.FormatInt(n, 10)
}
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
if n := value.Uint(); n > 999999999999999 {
colValue = strconv.FormatUint(n, 10)
}
}
f.SetCellValue(sheetName, fmt.Sprintf("%s%d", w.GetColumnName(j), rowNum), colValue)
}
}
@@ -93,45 +96,39 @@ func (w *WafCommonApi) ExportExcelApi(c *gin.Context) {
}
}
func (w *WafCommonApi) ImportExcelApi(c *gin.Context) {
file, err := c.FormFile("file") // "excelFile" 对应前端上传的文件字段名
importTable, ok := c.GetPostForm("import_table")
if !ok || importTable != "hosts" {
c.JSON(http.StatusBadRequest, gin.H{"error": "import_table 仅支持 hosts"})
return
}
importCodeStrategy, ok := c.GetPostForm("import_code_strategy")
if !ok || (importCodeStrategy != "0" && importCodeStrategy != "1") {
c.JSON(http.StatusBadRequest, gin.H{"error": "import_code_strategy 仅支持 0 或 1"})
return
}
file, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
c.JSON(http.StatusBadRequest, gin.H{"error": "读取上传文件失败"})
return
}
// 生成文件名
fileName := time.Now().Format("20060102150405") + "import.xlsx"
if err := c.SaveUploadedFile(file, fileName); // 保存文件到指定路径
err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
importTable, succResult := c.GetPostForm("import_table")
if !succResult {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
importCodeStrategy, succResult := c.GetPostForm("import_code_strategy")
if !succResult {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
// 创建 Excel 文件
f, err := excelize.OpenFile(fileName)
reader, err := file.Open()
if err != nil {
fmt.Println(err)
c.JSON(http.StatusBadRequest, gin.H{"error": "打开上传文件失败"})
return
}
// Get all the rows in the Sheet1.
rows, err := f.GetRows("Sheet1")
defer reader.Close()
f, err := excelize.OpenReader(reader)
if err != nil {
fmt.Println(err)
c.JSON(http.StatusBadRequest, gin.H{"error": "解析 Excel 文件失败"})
return
}
ret := saveDataToDatabase(importTable, rows, importCodeStrategy)
// 删除临时文件
if err := os.Remove(fileName); err != nil {
log.Println("无法删除临时文件:", err)
defer f.Close()
rows, err := f.GetRows("Sheet1", excelize.Options{RawCellValue: true})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "读取 Sheet1 失败"})
return
}
ret := waf_service.ImportHostsExcel(rows, importCodeStrategy)
response.OkWithDetailed(ret, "上传完成", c)
}
@@ -160,187 +157,6 @@ func (w *WafCommonApi) GetColumnName(colIdx int) string {
return colName
}
// 通用数据插入函数
func saveDataToDatabase(tableName string, rows [][]string, importCodeStrategy string) ReturnImportData {
successInt := 0
failInt := 0
msg := ""
processImportData(&model.Hosts{}, tableName, rows, &successInt, &failInt, &msg, importCodeStrategy)
return ReturnImportData{
SuccessInt: successInt,
FailInt: failInt,
Msg: msg,
}
}
// processImportData 是通用的导入数据函数
func processImportData(dataType interface{}, tableName string, rows [][]string, successInt, failInt *int, msg *string, importCodeStrategy string) {
var header []string
needJumpFristCol := false
rowNumber := 0
var dataMap map[string]string
// 获取结构体类型和字段信息
dataValue := reflect.ValueOf(dataType).Elem()
dataTypeFields := dataValue.Type()
// 获取表头
for _, row := range rows {
for _, colCell := range row {
if colCell == " - " {
needJumpFristCol = true
continue
}
header = append(header, colCell)
}
break
}
isBadData := false
// 处理数据 获取数据并插入数据库
for _, row := range rows {
isBadData = false
if rowNumber == 0 && needJumpFristCol {
rowNumber++
continue
}
// 建立一个 map 来存储数据
dataMap = make(map[string]string)
colNumber := 0
for _, colCell := range row {
if colNumber == 0 && needJumpFristCol {
colNumber++
continue
}
headerNumber := colNumber
if needJumpFristCol {
headerNumber = headerNumber - 1
}
dataMap[header[headerNumber]] = colCell
colNumber++
}
// 动态创建结构体实例,并映射数据
newInstance := reflect.New(dataValue.Type()).Elem()
for fieldIdx := 0; fieldIdx < dataTypeFields.NumField(); fieldIdx++ {
field := dataTypeFields.Field(fieldIdx)
fieldName := field.Name
jsonTag := field.Tag.Get("json")
// 如果 dataMap 中有匹配的字段
if val, exists := dataMap[jsonTag]; exists {
//排除一些特定数据
if tableName == "hosts" && fieldName == "Host" && (val == "全局网站" || val == "") {
isBadData = true
break
}
//检查数据是否已经存在
if tableName == "hosts" && fieldName == "Host" {
if importCodeStrategy == "1" {
errMsg, err := checkHostCodeData(dataMap["code"])
if err != nil {
*msg += fmt.Sprintf("行 %d, 检测数据合法性时候 出错: %v |", rowNumber, errMsg)
*failInt++
isBadData = true
break
}
}
errMsg, err := checkHostPortData(dataMap["host"], dataMap["port"])
if err != nil {
*msg += fmt.Sprintf("行 %d, 检测数据合法性时候 出错: %v |", rowNumber, errMsg)
*failInt++
isBadData = true
break
}
}
// 将字段值设置到结构体中
fieldVal := newInstance.Field(fieldIdx)
// 转换并设置字段的值
switch fieldVal.Kind() {
case reflect.String:
if tableName == "hosts" {
if importCodeStrategy == "0" && fieldName == "Code" {
fieldVal.SetString(uuid.GenUUID())
} else {
fieldVal.SetString(val)
}
} else {
fieldVal.SetString(val)
}
case reflect.Int:
intVal, err := strconv.Atoi(val)
if err != nil {
*msg += fmt.Sprintf("行 %d, 字段 %s 转换为 int 错误: %v |", rowNumber, fieldName, err)
*failInt++
isBadData = true
break
}
fieldVal.SetInt(int64(intVal))
default:
*msg += fmt.Sprintf("不支持的字段类型: %s |", fieldVal.Kind())
*failInt++
isBadData = true
break
}
} else if fieldName == "BaseOrm" {
fieldVal := newInstance.Field(fieldIdx)
// 给 BaseOrm 赋值
baseOrm := baseorm.BaseOrm{
Id: uuid.GenUUID(), // 新生成的 ID
USER_CODE: global.GWAF_USER_CODE,
Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: customtype.JsonTime(time.Now()),
UPDATE_TIME: customtype.JsonTime(time.Now()),
}
// 将 BaseOrm 设置到结构体字段
fieldVal.Set(reflect.ValueOf(baseOrm))
} else {
*msg += fmt.Sprintf("行 %d, 缺少字段 %s 数据 |", rowNumber, fieldName)
*failInt++
isBadData = true
break
}
}
if isBadData == false {
if err := global.GWAF_LOCAL_DB.Create(newInstance.Interface()); err != nil {
errGorm := err.Error
if errGorm != nil {
*msg += fmt.Sprintf("行 %d 插入失败: %v |", rowNumber, err.Error)
*failInt++
} else {
*successInt++
}
}
}
rowNumber++
}
}
// checkHostData 检查host信息是否合法
func checkHostCodeData(code string) (string, error) {
// 唯一性校验:检查 `Code` 是否已存在
if wafHostService.GetDetailByCodeApi(code).Code != "" {
errorMsg := "Code 数据已存在不进行插入"
// 数据已存在,不插入
return errorMsg, errors.New(errorMsg)
}
return "数据正常", nil
}
func checkHostPortData(host string, port string) (string, error) {
//唯一性校验:检查 `Host` 和 `Port` 的组合是否已存在
if err := wafHostService.CheckIsExist(host, port); err == nil {
errorMsg := "Host+Port 数据已存在不进行插入"
// 数据已存在,不插入
return errorMsg, errors.New(errorMsg)
}
return "数据正常", nil
}
// HeartbeatApi 心跳数据
func (w *WafCommonApi) HeartbeatApi(c *gin.Context) {
response.Ok(c)
+407 -30
View File
@@ -2,49 +2,426 @@ package api
import (
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/baseorm"
"SamWaf/service/waf_service"
"bytes"
"encoding/json"
"fmt"
"github.com/gin-gonic/gin"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"reflect"
"strconv"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/xuri/excelize/v2"
)
// 测试用例 ExportExcelApi
func TestExportExcelApi(t *testing.T) {
// 创建一个基于 Gin 的引擎
r := gin.Default()
global.GWAF_RELEASE_VERSION = "111"
r.GET("/api/v1/export", new(WafCommonApi).ExportExcelApi)
// 创建一个模拟的 HTTP 请求 WafCommonReq 对象
// 创建一个模拟的 HTTP 请求
queryParams := url.Values{}
queryParams.Set("table_name", "hosts")
req, err := http.NewRequest(http.MethodGet, "/api/v1/export?"+queryParams.Encode(), nil)
if err != nil {
t.Fatalf("无法创建请求:%v", err)
// 每次从当前 Hosts 生成样本;新增类型必须明确提供非零测试值。
func excelHostFixture(t *testing.T) model.Hosts {
t.Helper()
host := model.Hosts{}
v := reflect.ValueOf(&host).Elem()
for i := 0; i < v.NumField(); i++ {
field := v.Type().Field(i)
if field.Name == "BaseOrm" {
continue
}
if tag := field.Tag.Get("json"); tag == "" || tag == "-" || strings.Contains(tag, ",") {
t.Fatalf("Hosts.%s needs an explicit Excel field contract: %q", field.Name, tag)
}
switch value := v.Field(i); value.Kind() {
case reflect.String:
value.SetString("测试-" + field.Name)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32:
value.SetInt(1)
case reflect.Int64:
value.SetInt(9223372036854775807)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
value.SetUint(1)
case reflect.Bool:
value.SetBool(true)
case reflect.Float32, reflect.Float64:
value.SetFloat(1.25)
default:
t.Fatalf("add Excel round-trip coverage for Hosts.%s (%s)", field.Name, field.Type)
}
}
host.BaseOrm = baseorm.BaseOrm{Id: "export-source", USER_CODE: global.GWAF_USER_CODE, Tenant_ID: global.GWAF_TENANT_ID}
host.Host, host.Port, host.Code = "import.example", 9112, "export-source-code"
host.GLOBAL_HOST = 0
return host
}
// 创建一个响应记录器
func excelHostRows(host model.Hosts) [][]string {
v := reflect.ValueOf(host)
header, row := make([]string, v.NumField()), make([]string, v.NumField())
for i := range header {
if v.Type().Field(i).Name == "BaseOrm" {
header[i] = " - "
continue
}
header[i] = v.Type().Field(i).Tag.Get("json")
row[i] = fmt.Sprint(v.Field(i).Interface())
}
return [][]string{header, row}
}
func setExcelHostCell(t *testing.T, rows [][]string, field, value string) {
t.Helper()
for i, header := range rows[0] {
if header == field {
rows[1][i] = value
return
}
}
t.Fatalf("missing fixture column %s", field)
}
func assertExcelImportedHost(t *testing.T, want model.Hosts, strategy string) {
t.Helper()
var hosts []model.Hosts
if err := global.GWAF_LOCAL_DB.Find(&hosts).Error; err != nil {
t.Fatal(err)
}
if len(hosts) != 1 {
t.Fatalf("want exactly one imported host, got %d", len(hosts))
}
got := hosts[0]
if got.Id == "" || got.Id == want.Id || got.USER_CODE != global.GWAF_USER_CODE || got.Tenant_ID != global.GWAF_TENANT_ID || time.Time(got.CREATE_TIME).IsZero() || time.Time(got.UPDATE_TIME).IsZero() {
t.Fatalf("imported BaseOrm metadata was not generated: %+v", got.BaseOrm)
}
gv, wv := reflect.ValueOf(got), reflect.ValueOf(want)
for i := 0; i < wv.NumField(); i++ {
name := wv.Type().Field(i).Name
if name == "BaseOrm" {
continue
}
if name == "Code" && strategy == "0" {
if got.Code == "" || got.Code == want.Code {
t.Fatal("strategy 0 must generate a new host code")
}
continue
}
if !reflect.DeepEqual(gv.Field(i).Interface(), wv.Field(i).Interface()) {
t.Errorf("Hosts.%s changed during import: want %v, got %v", name, wv.Field(i), gv.Field(i))
}
}
}
func exportExcelHost(t *testing.T, host model.Hosts) []byte {
t.Helper()
if err := global.GWAF_LOCAL_DB.Create(&host).Error; err != nil {
t.Fatal(err)
}
r := gin.New()
r.GET("/export", new(WafCommonApi).ExportExcelApi)
rec := httptest.NewRecorder()
// 将模拟的请求发送到测试的 API 路由
r.ServeHTTP(rec, req)
// 检查响应状态码是否为 200
r.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/export?table_name=hosts", nil))
if rec.Code != http.StatusOK {
t.Errorf("期望的状态码:%d,实际状态码:%d", http.StatusOK, rec.Code)
t.Fatalf("export HTTP %d: %s", rec.Code, rec.Body.String())
}
f, err := excelize.OpenReader(bytes.NewReader(rec.Body.Bytes()))
if err != nil {
t.Fatalf("response is not an Excel workbook: %v", err)
}
defer f.Close()
rows, err := f.GetRows("Sheet1")
if err != nil {
t.Fatal(err)
}
if want := excelHostRows(host); !reflect.DeepEqual(rows, want) {
t.Fatalf("exported columns or cell values differ from current Hosts fields\nwant: %v\ngot: %v", want, rows)
}
for i, field := range rows[0] {
if field == "emergency_until" && host.EmergencyUntil > 999999999999999 {
cell, err := excelize.CoordinatesToCellName(i+1, 2)
if err != nil {
t.Fatal(err)
}
kind, err := f.GetCellType("Sheet1", cell)
if err != nil || (kind != excelize.CellTypeSharedString && kind != excelize.CellTypeInlineString) {
t.Fatalf("large integer must be exported as text: %v %v", kind, err)
}
}
}
return append([]byte(nil), rec.Body.Bytes()...)
}
func importExcelRequest(t *testing.T, workbook []byte, table, strategy string, includeFile bool) *httptest.ResponseRecorder {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if includeFile {
part, err := writer.CreateFormFile("file", "hosts.xlsx")
if err != nil {
t.Fatal(err)
}
if _, err := part.Write(workbook); err != nil {
t.Fatal(err)
}
}
for key, value := range map[string]string{"import_table": table, "import_code_strategy": strategy} {
if value != "" {
if err := writer.WriteField(key, value); err != nil {
t.Fatal(err)
}
}
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
r := gin.New()
r.POST("/import", func(c *gin.Context) {
c.Set("is_openapi", true)
new(WafCommonApi).ImportExcelApi(c)
})
req := httptest.NewRequest(http.MethodPost, "/import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
return rec
}
func TestExportExcelApi(t *testing.T) {
setupNotifyTestEnv(t, &model.Hosts{})
t.Chdir(t.TempDir())
exportExcelHost(t, excelHostFixture(t))
}
func TestHostsExcelRoundTripAllFields(t *testing.T) {
for _, strategy := range []string{"0", "1"} {
t.Run("strategy_"+strategy, func(t *testing.T) {
setupNotifyTestEnv(t, &model.Hosts{})
t.Chdir(t.TempDir())
host := excelHostFixture(t)
workbook := exportExcelHost(t, host)
if err := global.GWAF_LOCAL_DB.Where("id = ?", host.Id).Delete(&model.Hosts{}).Error; err != nil {
t.Fatal(err)
}
rec := importExcelRequest(t, workbook, "hosts", strategy, true)
var response struct {
Code int
Data waf_service.ReturnImportData
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatalf("decode import result: %v: %s", err, rec.Body.String())
}
if rec.Code != http.StatusOK || response.Code != 0 || response.Data.SuccessInt != 1 || response.Data.FailInt != 0 || response.Data.Msg != "" {
t.Fatalf("round-trip failed: HTTP %d %s", rec.Code, rec.Body.String())
}
assertExcelImportedHost(t, host, strategy)
})
}
}
func TestHostsExcelImportRawNumericCell(t *testing.T) {
setupNotifyTestEnv(t, &model.Hosts{})
t.Chdir(t.TempDir())
host := excelHostFixture(t)
workbook := exportExcelHost(t, host)
f, err := excelize.OpenReader(bytes.NewReader(workbook))
if err != nil {
t.Fatal(err)
}
defer f.Close()
rows, err := f.GetRows("Sheet1")
if err != nil {
t.Fatal(err)
}
for i, field := range rows[0] {
if field == "emergency_until" {
cell, err := excelize.CoordinatesToCellName(i+1, 2)
if err != nil {
t.Fatal(err)
}
if err := f.SetCellValue("Sheet1", cell, host.EmergencyUntil); err != nil {
t.Fatal(err)
}
}
}
buffer, err := f.WriteToBuffer()
if err != nil {
t.Fatal(err)
}
if err := global.GWAF_LOCAL_DB.Where("id = ?", host.Id).Delete(&model.Hosts{}).Error; err != nil {
t.Fatal(err)
}
rec := importExcelRequest(t, buffer.Bytes(), "hosts", "1", true)
var response struct {
Code int
Data waf_service.ReturnImportData
}
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusOK || response.Code != 0 || response.Data.SuccessInt != 1 || response.Data.FailInt != 0 || response.Data.Msg != "" {
t.Fatalf("raw numeric cell import failed: HTTP %d %s", rec.Code, rec.Body.String())
}
assertExcelImportedHost(t, host, "1")
}
func TestHostsExcelImportRows(t *testing.T) {
cases := []struct {
name string
edit func(*testing.T, [][]string) [][]string
fail bool
message string
}{
{"int64_zero", func(t *testing.T, r [][]string) [][]string { setExcelHostCell(t, r, "emergency_until", "0"); return r }, false, ""},
{"int64_max", func(t *testing.T, r [][]string) [][]string {
setExcelHostCell(t, r, "emergency_until", "9223372036854775807")
return r
}, false, ""},
{"int64_invalid", func(t *testing.T, r [][]string) [][]string {
setExcelHostCell(t, r, "emergency_until", "bad")
return r
}, true, "EmergencyUntil"},
{"int64_overflow", func(t *testing.T, r [][]string) [][]string {
setExcelHostCell(t, r, "emergency_until", "9223372036854775808")
return r
}, true, "EmergencyUntil"},
{"two_invalid_cells_one_failure", func(t *testing.T, r [][]string) [][]string {
setExcelHostCell(t, r, "ssl", "bad")
setExcelHostCell(t, r, "remote_port", "bad")
return r
}, true, "Ssl"},
{"without_base_column", func(t *testing.T, r [][]string) [][]string { return [][]string{r[0][1:], r[1][1:]} }, false, ""},
{"missing_model_column", func(t *testing.T, r [][]string) [][]string {
for i, field := range r[0] {
if field == "emergency_until" {
return [][]string{append(r[0][:i], r[0][i+1:]...), append(r[1][:i], r[1][i+1:]...)}
}
}
t.Fatal("emergency_until column missing")
return nil
}, true, "EmergencyUntil"},
{"extra_nonempty_cell", func(t *testing.T, r [][]string) [][]string { r[1] = append(r[1], "extra"); return r }, true, ""},
{"duplicate_header", func(t *testing.T, r [][]string) [][]string {
r[0] = append(r[0], "emergency_until")
r[1] = append(r[1], "0")
return r
}, true, "EmergencyUntil"},
{"reordered_columns", func(t *testing.T, r [][]string) [][]string {
for i, j := 0, len(r[0])-1; i < j; i, j = i+1, j-1 {
r[0][i], r[0][j] = r[0][j], r[0][i]
r[1][i], r[1][j] = r[1][j], r[1][i]
}
return r
}, false, ""},
{"trailing_empty_string", func(t *testing.T, r [][]string) [][]string {
for i, field := range r[0] {
if field == "remarks" {
r[0][i], r[0][len(r[0])-1] = r[0][len(r[0])-1], r[0][i]
r[1][i] = r[1][len(r[1])-1]
r[1] = r[1][:len(r[1])-1]
return r
}
}
t.Fatal("remarks column missing")
return nil
}, false, ""},
{"empty_workbook", func(t *testing.T, r [][]string) [][]string { return nil }, true, ""},
{"blank_row", func(t *testing.T, r [][]string) [][]string { return [][]string{r[0], {}, r[1]} }, false, ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
setupNotifyTestEnv(t, &model.Hosts{})
host := excelHostFixture(t)
rows := tc.edit(t, excelHostRows(host))
result := waf_service.ImportHostsExcel(rows, "1")
var count int64
if err := global.GWAF_LOCAL_DB.Model(&model.Hosts{}).Count(&count).Error; err != nil {
t.Fatal(err)
}
if tc.fail {
if result.SuccessInt != 0 || result.FailInt != 1 || count != 0 || !strings.Contains(result.Msg, tc.message) {
t.Fatalf("invalid import must fail once without insertion: %+v, count=%d", result, count)
}
return
}
if result.SuccessInt != 1 || result.FailInt != 0 || result.Msg != "" || count != 1 {
t.Fatalf("valid import failed: %+v count=%d", result, count)
}
if tc.name == "int64_zero" {
host.EmergencyUntil = 0
}
if tc.name == "int64_max" {
host.EmergencyUntil = 9223372036854775807
}
if tc.name == "trailing_empty_string" {
host.REMARKS = ""
}
assertExcelImportedHost(t, host, "1")
})
}
}
func TestHostsExcelImportDuplicatesAndBatch(t *testing.T) {
for _, duplicate := range []string{"code", "host_port"} {
t.Run(duplicate, func(t *testing.T) {
setupNotifyTestEnv(t, &model.Hosts{})
host := excelHostFixture(t)
existing := host
if duplicate == "code" {
existing.Host = "other.example"
} else {
existing.Code = "other-code"
}
if err := global.GWAF_LOCAL_DB.Create(&existing).Error; err != nil {
t.Fatal(err)
}
result := waf_service.ImportHostsExcel(excelHostRows(host), "1")
if result.FailInt != 1 || result.SuccessInt != 0 {
t.Fatalf("duplicate accepted: %+v", result)
}
})
}
t.Run("invalid_row_then_valid", func(t *testing.T) {
setupNotifyTestEnv(t, &model.Hosts{})
host := excelHostFixture(t)
rows := excelHostRows(host)
valid := append([]string(nil), rows[1]...)
setExcelHostCell(t, rows, "emergency_until", "invalid")
rows = append(rows, valid)
result := waf_service.ImportHostsExcel(rows, "1")
if result.SuccessInt != 1 || result.FailInt != 1 || !strings.Contains(result.Msg, "2") {
t.Fatalf("batch accounting incorrect: %+v", result)
}
assertExcelImportedHost(t, host, "1")
})
}
func TestHostsExcelImportBadRequests(t *testing.T) {
for _, tc := range []struct {
name, table, strategy string
file bool
}{
{"no_file", "hosts", "0", false},
{"no_table", "", "0", true},
{"unknown_table", "unknown", "0", true},
{"no_strategy", "hosts", "", true},
{"unknown_strategy", "hosts", "2", true},
{"invalid_workbook", "hosts", "0", true},
} {
t.Run(tc.name, func(t *testing.T) {
rec := importExcelRequest(t, []byte("invalid workbook"), tc.table, tc.strategy, tc.file)
if rec.Code != http.StatusBadRequest || rec.Body.Len() == 0 {
t.Fatalf("want explained HTTP400, got %d: %s", rec.Code, rec.Body.String())
}
})
}
}
func TestGetColumnName(t *testing.T) {
colIdx := 500
colName := ""
for colIdx >= 0 {
colName = fmt.Sprintf("%c", 'A'+colIdx%26) + colName
colIdx = colIdx/26 - 1
api := new(WafCommonApi)
for index, want := range map[int]string{0: "A", 25: "Z", 26: "AA", 51: "AZ", 52: "BA", 500: "SG"} {
if got := api.GetColumnName(index); got != want {
t.Errorf("column %s: want %s, got %s", strconv.Itoa(index), want, got)
}
}
fmt.Println(colName)
}
+215
View File
@@ -0,0 +1,215 @@
package waf_service
import (
"SamWaf/common/uuid"
"SamWaf/customtype"
"SamWaf/global"
"SamWaf/model"
"SamWaf/model/baseorm"
"errors"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"time"
)
type ReturnImportData struct {
SuccessInt int
FailInt int
Msg string
}
// ImportHostsExcel 导入包含完整 Hosts 表头的网站数据。
func ImportHostsExcel(rows [][]string, importCodeStrategy string) ReturnImportData {
var result ReturnImportData
if importCodeStrategy != "0" && importCodeStrategy != "1" {
result.FailInt = 1
result.Msg = "导入策略无效,仅支持 0 或 1"
return result
}
processImportData(rows, importCodeStrategy, &result)
return result
}
func processImportData(rows [][]string, strategy string, result *ReturnImportData) {
if len(rows) == 0 {
result.FailInt = 1
result.Msg = "行 1, 缺少表头 |"
return
}
typ := reflect.TypeOf(model.Hosts{})
columns := make(map[string]int)
for column, name := range rows[0] {
if name == " - " || name == "" {
continue
}
if _, exists := columns[name]; exists {
// 只输出模型字段信息,不回显表头文本。
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.Name != "BaseOrm" && field.Tag.Get("json") == name {
result.FailInt = 1
result.Msg = fmt.Sprintf("行 1, 字段 %s (%s) 表头重复 |", field.Name, field.Type)
return
}
}
result.FailInt = 1
result.Msg = fmt.Sprintf("行 1, 第 %d 列表头重复 |", column+1)
return
}
columns[name] = column
}
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.Name == "BaseOrm" {
continue
}
if _, exists := columns[field.Tag.Get("json")]; !exists {
result.FailInt = 1
result.Msg = fmt.Sprintf("行 1, 缺少字段 %s (%s) 表头 |", field.Name, field.Type)
return
}
}
for index, row := range rows[1:] {
rowNumber := index + 2
if importRowEmpty(row) {
continue
}
var rowErr error
for column := len(rows[0]); column < len(row); column++ {
if strings.TrimSpace(row[column]) != "" {
rowErr = fmt.Errorf("第 %d 列超出表头范围", column+1)
break
}
}
if rowErr != nil {
result.addRowFailure(rowNumber, rowErr)
continue
}
hostName := importCell(row, columns["host"])
if strings.TrimSpace(hostName) == "" || hostName == "全局网站" {
continue
}
var host model.Hosts
value := reflect.ValueOf(&host).Elem()
for i := 0; i < typ.NumField(); i++ {
field := typ.Field(i)
if field.Name == "BaseOrm" {
continue
}
if err := setImportScalar(value.Field(i), importCell(row, columns[field.Tag.Get("json")])); err != nil {
rowErr = fmt.Errorf("字段 %s (%s) %s", field.Name, field.Type, err)
break
}
}
if rowErr != nil {
result.addRowFailure(rowNumber, rowErr)
continue
}
if strategy == "0" {
host.Code = uuid.GenUUID()
} else if err := checkHostCodeData(host.Code); err != nil {
result.addRowFailure(rowNumber, err)
continue
}
if err := checkHostPortData(host.Host, host.Port); err != nil {
result.addRowFailure(rowNumber, err)
continue
}
now := customtype.JsonTime(time.Now())
host.BaseOrm = baseorm.BaseOrm{
Id: uuid.GenUUID(), USER_CODE: global.GWAF_USER_CODE, Tenant_ID: global.GWAF_TENANT_ID,
CREATE_TIME: now, UPDATE_TIME: now,
}
if err := global.GWAF_LOCAL_DB.Create(&host).Error; err != nil {
result.addRowFailure(rowNumber, errors.New("插入失败"))
continue
}
result.SuccessInt++
}
}
func (r *ReturnImportData) addRowFailure(rowNumber int, err error) {
r.FailInt++
r.Msg += fmt.Sprintf("行 %d, %s |", rowNumber, err)
}
func importCell(row []string, column int) string {
if column >= len(row) {
return ""
}
return row[column]
}
func importRowEmpty(row []string) bool {
for _, cell := range row {
if strings.TrimSpace(cell) != "" {
return false
}
}
return true
}
// setImportScalar 转换成功后才写入字段;错误不包含单元格内容。
func setImportScalar(field reflect.Value, text string) error {
if !field.IsValid() || !field.CanSet() {
return errors.New("字段不可写")
}
switch field.Kind() {
case reflect.String:
field.SetString(text)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
n, err := strconv.ParseInt(text, 10, field.Type().Bits())
if err != nil {
return errors.New("整数无效或超出范围")
}
field.SetInt(n)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
n, err := strconv.ParseUint(text, 10, field.Type().Bits())
if err != nil {
return errors.New("无符号整数无效或超出范围")
}
field.SetUint(n)
case reflect.Bool:
b, err := strconv.ParseBool(text)
if err != nil {
return errors.New("布尔值无效")
}
field.SetBool(b)
case reflect.Float32, reflect.Float64:
n, err := strconv.ParseFloat(text, field.Type().Bits())
if err != nil || math.IsNaN(n) || math.IsInf(n, 0) {
return errors.New("浮点数无效或超出范围")
}
field.SetFloat(n)
default:
return errors.New("不支持的字段类型")
}
return nil
}
func checkHostCodeData(code string) error {
var count int64
if global.GWAF_LOCAL_DB == nil || global.GWAF_LOCAL_DB.Model(&model.Hosts{}).Where("code = ?", code).Count(&count).Error != nil {
return errors.New("字段 Code (string) 重复检查失败")
}
if count > 0 {
return errors.New("字段 Code (string) 数据已存在不进行插入")
}
return nil
}
func checkHostPortData(host string, port int) error {
var count int64
if global.GWAF_LOCAL_DB == nil || global.GWAF_LOCAL_DB.Model(&model.Hosts{}).Where("host = ? AND port = ?", host, port).Count(&count).Error != nil {
return errors.New("字段 Host (string) + Port (int) 重复检查失败")
}
if count > 0 {
return errors.New("字段 Host (string) + Port (int) 数据已存在不进行插入")
}
return nil
}
@@ -0,0 +1,185 @@
package waf_service
import (
"math"
"math/big"
"reflect"
"strconv"
"strings"
"testing"
)
func TestSetImportScalarSignedWidths(t *testing.T) {
for _, sample := range []interface{}{int(0), int8(0), int16(0), int32(0), int64(0)} {
typ := reflect.TypeOf(sample)
t.Run(typ.String(), func(t *testing.T) {
limit := new(big.Int).Lsh(big.NewInt(1), uint(typ.Bits()-1))
min := new(big.Int).Neg(new(big.Int).Set(limit))
max := new(big.Int).Sub(new(big.Int).Set(limit), big.NewInt(1))
for _, valid := range []string{min.String(), max.String(), "0", "-1", "+1"} {
field := reflect.New(typ).Elem()
if err := setImportScalar(field, valid); err != nil {
t.Fatalf("valid boundary rejected: %v", err)
}
want, _ := strconv.ParseInt(valid, 10, typ.Bits())
if field.Int() != want {
t.Fatalf("got %d, want %d", field.Int(), want)
}
}
for _, invalid := range []string{
new(big.Int).Sub(min, big.NewInt(1)).String(),
new(big.Int).Add(max, big.NewInt(1)).String(),
"", "invalid-secret", "1.5", "1e3", "0x10", " 1", "1 ",
} {
assertImportScalarRejected(t, typ, invalid)
}
})
}
}
func TestSetImportScalarUnsignedWidths(t *testing.T) {
for _, sample := range []interface{}{uint(0), uint8(0), uint16(0), uint32(0), uint64(0), uintptr(0)} {
typ := reflect.TypeOf(sample)
t.Run(typ.String(), func(t *testing.T) {
limit := new(big.Int).Lsh(big.NewInt(1), uint(typ.Bits()))
max := new(big.Int).Sub(new(big.Int).Set(limit), big.NewInt(1))
for _, valid := range []string{"0", "1", max.String()} {
field := reflect.New(typ).Elem()
if err := setImportScalar(field, valid); err != nil {
t.Fatalf("valid boundary rejected: %v", err)
}
want, _ := strconv.ParseUint(valid, 10, typ.Bits())
if field.Uint() != want {
t.Fatalf("got %d, want %d", field.Uint(), want)
}
}
for _, invalid := range []string{limit.String(), "-1", "", "invalid-secret", "1.5", "0x10", " 1"} {
assertImportScalarRejected(t, typ, invalid)
}
})
}
}
func TestSetImportScalarStringAndBool(t *testing.T) {
var text string
for _, valid := range []string{"", " original text ", "中文"} {
if err := setImportScalar(reflect.ValueOf(&text).Elem(), valid); err != nil || text != valid {
t.Fatal("string value was not preserved")
}
}
for _, valid := range []string{"true", "false", "1", "0", "TRUE", "FALSE"} {
var value bool
if err := setImportScalar(reflect.ValueOf(&value).Elem(), valid); err != nil {
t.Fatalf("valid bool rejected: %v", err)
}
want, _ := strconv.ParseBool(valid)
if value != want {
t.Fatalf("got %v, want %v", value, want)
}
}
for _, invalid := range []string{"", "2", "invalid-secret", " true"} {
assertImportScalarRejected(t, reflect.TypeOf(false), invalid)
}
}
func TestSetImportScalarFloatWidths(t *testing.T) {
for _, sample := range []interface{}{float32(0), float64(0)} {
typ := reflect.TypeOf(sample)
t.Run(typ.String(), func(t *testing.T) {
max, smallest := math.MaxFloat64, math.SmallestNonzeroFloat64
overflow := "1e309"
if typ.Bits() == 32 {
max, smallest, overflow = math.MaxFloat32, math.SmallestNonzeroFloat32, "1e39"
}
for _, valid := range []string{
"0", "-1.25", "1.5e2",
strconv.FormatFloat(max, 'g', -1, typ.Bits()),
strconv.FormatFloat(-max, 'g', -1, typ.Bits()),
strconv.FormatFloat(smallest, 'g', -1, typ.Bits()),
} {
field := reflect.New(typ).Elem()
if err := setImportScalar(field, valid); err != nil {
t.Fatalf("valid float rejected: %v", err)
}
want, _ := strconv.ParseFloat(valid, typ.Bits())
if field.Float() != want {
t.Fatalf("got %v, want %v", field.Float(), want)
}
}
for _, invalid := range []string{"", "invalid-secret", overflow, "-" + overflow, "NaN", "Inf", "+Inf", "-Inf", " 1"} {
assertImportScalarRejected(t, typ, invalid)
}
})
}
}
func TestSetImportScalarUnsupportedAndUnwritable(t *testing.T) {
for _, sample := range []interface{}{
struct{}{}, []string{}, [1]int{}, map[string]string{}, (*int)(nil), complex64(0), complex128(0),
make(chan int), (func())(nil),
} {
typ := reflect.TypeOf(sample)
t.Run(typ.String(), func(t *testing.T) {
assertImportScalarRejected(t, typ, "invalid-secret")
})
}
var value interface{}
if err := setImportScalar(reflect.ValueOf(&value).Elem(), "invalid-secret"); err == nil {
t.Fatal("interface field accepted")
}
for _, field := range []reflect.Value{reflect.Value{}, reflect.ValueOf(1)} {
if err := setImportScalar(field, "invalid-secret"); err == nil {
t.Fatal("unwritable field accepted")
}
}
}
func TestSetImportScalarNamedTypes(t *testing.T) {
type signed int64
type unsigned uint16
type text string
type flag bool
type number float32
for _, tc := range []struct {
initial interface{}
input string
want interface{}
}{
{signed(0), "9223372036854775807", signed(math.MaxInt64)},
{unsigned(0), "65535", unsigned(math.MaxUint16)},
{text(""), "unchanged", text("unchanged")},
{flag(false), "true", flag(true)},
{number(0), "1.25", number(1.25)},
} {
field := reflect.New(reflect.TypeOf(tc.initial)).Elem()
if err := setImportScalar(field, tc.input); err != nil || !reflect.DeepEqual(field.Interface(), tc.want) {
t.Fatalf("named scalar conversion failed: %v", err)
}
}
}
func assertImportScalarRejected(t *testing.T, typ reflect.Type, input string) {
t.Helper()
field := reflect.New(typ).Elem()
switch field.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
field.SetInt(7)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64, reflect.Uintptr:
field.SetUint(7)
case reflect.Bool:
field.SetBool(true)
case reflect.Float32, reflect.Float64:
field.SetFloat(7)
}
before := field.Interface()
err := setImportScalar(field, input)
if err == nil {
t.Fatal("invalid value or unsupported type accepted")
}
if input != "" && strings.Contains(err.Error(), input) {
t.Fatal("error contains the input value")
}
if !reflect.DeepEqual(field.Interface(), before) {
t.Fatal("failed conversion changed the field")
}
}
+16
View File
@@ -12,6 +12,22 @@
# 5. fresh_install: true 的条目只在"全新安装"时生成,不参与版本区间计算
notes:
- id: v1_3_25_hosts_excel_integer_import
version: v1.3.25
kind: check
level: low
page: /waf-host/wafhost
apply:
type: navigate
zh:
title: 网站 Excel 导入支持各位宽整数
detail: 网站 Excel 导入支持包括紧急模式到期时间在内的整数字段,转换错误会标明行号与字段,每行最多计一次失败。导入仍要求完整字段表头;旧文件缺少新增列时,请使用当前版本重新导出后填写。
revert: 无需调整配置;导入前请确认文件字段完整,并核对导入结果中的成功数和失败原因。
en:
title: Website Excel import supports all integer widths
detail: Website Excel import supports integer fields including the emergency-mode expiry time. Conversion errors identify the row and field, with at most one failure per row. A complete column header is still required; if an older file lacks new columns, export a fresh file from the current version before filling it in.
revert: No configuration changes are needed. Check that the file has all columns before import, then review the success count and failure reasons.
- id: v1_3_25_website_access_diagnostic
version: v1.3.25
kind: check