package sensitivewordctrl import ( "fmt" "net/http" "os" "path/filepath" "strings" "time" "91porn-server/common" "91porn-server/common/log" "91porn-server/common/stderr" "91porn-server/models/v/sensitivewordmod" "github.com/gin-gonic/gin" "github.com/tealeg/xlsx" "go.mongodb.org/mongo-driver/bson" "go.mongodb.org/mongo-driver/bson/primitive" ) // 导入/导出模版列表头(顺序即列序) var templateHeaders = []string{"一级分类", "词条"} // List doc // @Summary 敏感词列表 // @Description 分页查询敏感词,支持按分类、风险等级、关键词、状态筛选 // @Tags Web-SensitiveWord // @Accept json // @Produce json // @Param page query int false "页码,默认1" // @Param size query int false "每页条数,默认20" // @Param category query string false "一级分类" // @Param keyword query string false "词条模糊搜索" // @Param status query int false "状态 1-启用 0-禁用" // @Success 200 {string} json "{"msg":"操作成功","data":{"list":[],"total":0}}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/sensitive-word/list [get] func List(ctx *gin.Context) { var req sensitivewordmod.ListReq if err := ctx.ShouldBind(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } list, total, err := sensitivewordmod.List(&req) if err != nil { common.ServeJSON(ctx, stderr.ErrDbQueryError, nil) return } common.ServeJSON(ctx, stderr.Success, gin.H{ "list": list, "total": total, }) } // Add doc // @Summary 新增敏感词 // @Description 新增单条敏感词 // @Tags Web-SensitiveWord // @Accept json // @Produce json // @Param body body sensitivewordmod.AddReq true "敏感词信息" // @Success 200 {string} json "{"msg":"操作成功"}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/sensitive-word/add [post] func Add(ctx *gin.Context) { var req sensitivewordmod.AddReq if err := ctx.ShouldBindJSON(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } w := &sensitivewordmod.SensitiveWord{ Category: req.Category, Word: req.Word, } if err := sensitivewordmod.Add(w); err != nil { common.ServeJSON(ctx, stderr.Failure, err.Error()) return } common.ServeJSON(ctx, stderr.Success, nil) } // Update doc // @Summary 编辑敏感词 // @Description 编辑单条敏感词,支持部分字段更新 // @Tags Web-SensitiveWord // @Accept json // @Produce json // @Param body body sensitivewordmod.UpdateReq true "更新信息,id必传" // @Success 200 {string} json "{"msg":"操作成功"}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/sensitive-word/update [post] func Update(ctx *gin.Context) { var req sensitivewordmod.UpdateReq if err := ctx.ShouldBindJSON(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } id, err := primitive.ObjectIDFromHex(req.ID) if err != nil { common.ServeJSON(ctx, stderr.ErrParamError, "invalid id") return } fields := bson.M{} if req.Category != "" { fields["category"] = req.Category } if req.Word != "" { fields["word"] = req.Word } if req.Status != nil { fields["status"] = *req.Status } if err := sensitivewordmod.Update(id, fields); err != nil { common.ServeJSON(ctx, stderr.Failure, err.Error()) return } common.ServeJSON(ctx, stderr.Success, nil) } // Delete doc // @Summary 批量删除敏感词 // @Description 根据ID列表批量删除敏感词 // @Tags Web-SensitiveWord // @Accept json // @Produce json // @Param body body sensitivewordmod.DeleteReq true "ID列表" // @Success 200 {string} json "{"msg":"操作成功","data":{"deleted":0}}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/sensitive-word/delete [post] func Delete(ctx *gin.Context) { var req sensitivewordmod.DeleteReq if err := ctx.ShouldBindJSON(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } ids := make([]primitive.ObjectID, 0, len(req.IDs)) for _, idStr := range req.IDs { id, err := primitive.ObjectIDFromHex(idStr) if err != nil { continue } ids = append(ids, id) } if len(ids) == 0 { common.ServeJSON(ctx, stderr.ErrParamError, "no valid ids") return } cnt, err := sensitivewordmod.Delete(ids) if err != nil { common.ServeJSON(ctx, stderr.Failure, err.Error()) return } common.ServeJSON(ctx, stderr.Success, gin.H{"deleted": cnt}) } // Import doc // @Summary 批量导入敏感词 // @Description 上传 xlsx 文件批量导入敏感词,列:A-一级分类 B-词条,同词条重复导入覆盖更新 // @Tags Web-SensitiveWord // @Accept multipart/form-data // @Produce json // @Param upload formData file true "xlsx 文件,仅支持 .xls/.xlsx" // @Success 200 {string} json "{"msg":"操作成功","data":{"success":0,"skip":0}}" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/sensitive-word/import [post] func Import(ctx *gin.Context) { file, fHeader, err := ctx.Request.FormFile("upload") if err != nil { common.ServeJSON(ctx, stderr.ErrParamError, "请上传文件") return } ext := strings.ToLower(filepath.Ext(fHeader.Filename)) if ext != ".xlsx" && ext != ".xls" { common.ServeJSON(ctx, stderr.ErrMimeType, "仅支持 .xls/.xlsx 文件") return } pwd, _ := os.Getwd() tmpPath := filepath.Join(pwd, "temp", fmt.Sprintf("sw_import_%d%s", time.Now().UnixNano(), ext)) if err := os.MkdirAll(filepath.Dir(tmpPath), 0755); err != nil { common.ServeJSON(ctx, stderr.ErrServerUnavailable, nil) return } out, err := os.Create(tmpPath) if err != nil { common.ServeJSON(ctx, stderr.ErrServerUnavailable, nil) return } defer os.Remove(tmpPath) if _, err = out.ReadFrom(file); err != nil { out.Close() common.ServeJSON(ctx, stderr.ErrServerUnavailable, nil) return } out.Close() xlFile, err := xlsx.OpenFile(tmpPath) if err != nil { log.Error("sensitiveword import open file fail", log.E(err)) common.ServeJSON(ctx, stderr.ErrParamError, "文件解析失败") return } var success, skip int for _, sheet := range xlFile.Sheets { cols, dataStart := findSensitiveCols(sheet) if dataStart < 0 { log.Warn("sensitiveword import: header row not found", log.Any("sheet", sheet.Name)) continue } for rowIdx, row := range sheet.Rows { if rowIdx < dataStart { continue } category := getCellText(row, cols.Category) word := getCellText(row, cols.Word) // 词条必填;分类缺省视为空字符串(不强制要求) if word == "" { skip++ continue } w := &sensitivewordmod.SensitiveWord{ Category: category, Word: word, } if err := sensitivewordmod.UpsertByWord(w); err != nil { log.Warn("sensitiveword import upsert fail", log.Any("word", word), log.E(err)) skip++ continue } success++ } } common.ServeJSON(ctx, stderr.Success, gin.H{ "success": success, "skip": skip, }) } // Export doc // @Summary 批量导出敏感词 // @Description 导出敏感词为 xlsx 文件,格式与导入一致,支持按分类/风险等级/关键词筛选后导出 // @Tags Web-SensitiveWord // @Accept json // @Produce application/octet-stream // @Param category query string false "一级分类" // @Param keyword query string false "词条模糊搜索" // @Param status query int false "状态 1-启用 0-禁用" // @Success 200 {file} xlsx "xlsx 文件流" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/sensitive-word/export [get] func Export(ctx *gin.Context) { var req sensitivewordmod.ListReq if err := ctx.ShouldBind(&req); err != nil { common.ServeJSON(ctx, stderr.ErrParamError, err.Error()) return } list, err := sensitivewordmod.FindAll(&req) if err != nil { common.ServeJSON(ctx, stderr.ErrDbQueryError, nil) return } f := xlsx.NewFile() sheet, err := f.AddSheet("敏感词库") if err != nil { common.ServeJSON(ctx, stderr.ErrServerUnavailable, nil) return } writeHeaderRow(sheet) for _, w := range list { row := sheet.AddRow() row.AddCell().Value = w.Category row.AddCell().Value = w.Word } fileName := fmt.Sprintf("敏感词库_%s.xlsx", time.Now().Format("20060102150405")) serveXlsx(ctx, f, fileName) } // Template doc // @Summary 下载敏感词导入模版 // @Description 返回仅含表头的 xlsx 模版文件,用户填写后可通过 import 接口导入 // @Tags Web-SensitiveWord // @Produce application/octet-stream // @Success 200 {file} xlsx "xlsx 文件流" // @Failure 400 {string} json "{"msg":"操作失败"}" // @Router /api/web/admin/sensitive-word/template [get] func Template(ctx *gin.Context) { f := xlsx.NewFile() sheet, err := f.AddSheet("敏感词库") if err != nil { common.ServeJSON(ctx, stderr.ErrServerUnavailable, nil) return } writeHeaderRow(sheet) serveXlsx(ctx, f, "敏感词库导入模版.xlsx") } // writeHeaderRow 在 sheet 起始处写入表头行 func writeHeaderRow(sheet *xlsx.Sheet) { row := sheet.AddRow() for _, h := range templateHeaders { row.AddCell().Value = h } } // serveXlsx 把 xlsx 落到临时文件再以附件返回 func serveXlsx(ctx *gin.Context, f *xlsx.File, fileName string) { pwd, _ := os.Getwd() tmpDir := filepath.Join(pwd, "temp") _ = os.MkdirAll(tmpDir, 0755) fpath := filepath.Join(tmpDir, fmt.Sprintf("%d_%s", time.Now().UnixNano(), fileName)) if err := f.Save(fpath); err != nil { log.Error("sensitiveword save xlsx fail", log.E(err)) common.ServeJSON(ctx, stderr.ErrServerUnavailable, nil) return } defer os.Remove(fpath) ctx.Writer.WriteHeader(http.StatusOK) ctx.Header("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) ctx.Header("Content-Type", "application/octet-stream") ctx.File(fpath) } // sensitiveCols 通过表头智能识别出的列索引 type sensitiveCols struct { Category int Word int } // findSensitiveCols 扫描前几行找出各字段所在的列索引 // 返回值: cols 列索引集合; dataStart 数据起始行号;-1 表示未识别到表头 // 兼容 "词条/变体" 旧表头 func findSensitiveCols(sheet *xlsx.Sheet) (sensitiveCols, int) { const maxHeaderScan = 5 // 表头一般在前几行 for rowIdx, row := range sheet.Rows { if rowIdx > maxHeaderScan { break } cols := sensitiveCols{Category: -1, Word: -1} for cellIdx, cell := range row.Cells { text := strings.ReplaceAll(strings.TrimSpace(cell.String()), " ", "") text = strings.ReplaceAll(text, "\n", "") switch text { case "一级分类", "分类": cols.Category = cellIdx case "词条", "词条/变体": cols.Word = cellIdx } } // 词条列识别成功才视为表头行(分类列可选,缺省时入库为空) if cols.Word >= 0 { return cols, rowIdx + 1 } } return sensitiveCols{}, -1 } // getCellText 安全地取出某列的文本,越界/未识别返回空串 func getCellText(row *xlsx.Row, idx int) string { if idx < 0 || idx >= len(row.Cells) { return "" } return strings.TrimSpace(row.Cells[idx].String()) }