Initial commit

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 13:57:10 +08:00
co-authored by Claude Opus 5
commit 8679200f41
1897 changed files with 257900 additions and 0 deletions
@@ -0,0 +1,373 @@
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())
}