辣评管理员代替投稿功能(二十)

 

功能背景

在辣评平台的运营过程中,我们发现有些用户因为技术问题或其他原因无法自行完成投稿。为了解决这个问题,我们开发了管理员代替用户投稿的功能。

功能需求

  1. 代替投稿
    • 管理员可以代替任何用户创建投稿
    • 投稿归属于指定用户
    • 记录操作日志
  2. 权限控制
    • 仅管理员可以使用此功能
    • 需要验证管理员身份
    • 防止权限滥用
  3. 用户提示
    • 明确标识代投稿件
    • 用户可以查看代投记录
    • 提供编辑和删除权限

功能需求分析

使用场景

  1. 技术支持场景
    • 用户不熟悉系统操作
    • 用户遇到技术问题
    • 紧急情况需要快速投稿
  2. 数据迁移场景
    • 从其他平台迁移数据
    • 批量导入历史投稿
    • 数据修复和补充
  3. 特殊情况处理
    • 用户账号问题
    • 截止日期临近
    • 其他特殊情况

功能边界

允许的操作:

  • 代替用户创建投稿
  • 代替用户编辑投稿
  • 查看代投记录

不允许的操作:

  • 代替用户删除投稿(需用户自己操作)
  • 代替用户评论(评论必须真实)
  • 修改投稿归属

权限控制设计

权限验证流程

// cmd/server/middleware/admin.go
func RequireAdmin() gin.HandlerFunc {
    return func(c *gin.Context) {
        // 1. 验证用户已登录
        userInterface, exists := c.Get("user")
        if !exists {
            c.JSON(401, gin.H{
                "code":    401,
                "message": "未认证",
            })
            c.Abort()
            return
        }

        user, ok := userInterface.(*models.User)
        if !ok {
            c.JSON(500, gin.H{
                "code":    500,
                "message": "用户信息错误",
            })
            c.Abort()
            return
        }

        // 2. 验证是否为管理员
        if !user.IsAdmin() {
            c.JSON(403, gin.H{
                "code":    403,
                "message": "权限不足,仅管理员可以执行此操作",
            })
            c.Abort()
            return
        }

        // 3. 记录管理员操作
        c.Set("adminID", user.ID)
        c.Set("adminUsername", user.Username)

        c.Next()
    }
}

操作日志记录

// cmd/server/models/models.go
type AdminOperationLog struct {
    ID          uint      `gorm:"primaryKey"`
    AdminID     uint      `gorm:"index"`
    TargetUserID uint     `gorm:"index"`
    Operation   string    // 操作类型:create_submission, edit_submission
    TargetID    uint      // 目标ID(投稿ID)
    Details     string    `gorm:"type:text"` // 操作详情(JSON)
    IPAddress   string
    UserAgent   string
    CreatedAt   time.Time

    // 关联
    Admin      *User `gorm:"foreignKey:AdminID"`
    TargetUser *User `gorm:"foreignKey:TargetUserID"`
}

// 记录操作日志
func LogAdminOperation(db *gorm.DB, adminID, targetUserID uint, operation string, targetID uint, details interface{}) error {
    detailsJSON, _ := json.Marshal(details)

    log := &AdminOperationLog{
        AdminID:      adminID,
        TargetUserID: targetUserID,
        Operation:    operation,
        TargetID:     targetID,
        Details:      string(detailsJSON),
    }

    return db.Create(log).Error
}

投稿流程改造

后端接口实现

// cmd/server/handlers/submission_handler.go

// CreateSubmissionForUser 管理员代替用户创建投稿
func (h *SubmissionHandler) CreateSubmissionForUser(c *gin.Context) {
    // 1. 验证管理员权限(通过中间件已验证)
    adminID := c.GetUint("adminID")
    adminUsername := c.GetString("adminUsername")

    // 2. 解析请求参数
    var req struct {
        UserID        uint   `json:"userId" binding:"required"`
        CompetitionID uint   `json:"competitionId" binding:"required"`
        Title         string `json:"title" binding:"required"`
        Content       string `json:"content" binding:"required"`
        NovelType     string `json:"novelType" binding:"required"`
        AuthorBio     string `json:"authorBio"`
    }

    if err := c.ShouldBindJSON(&req); err != nil {
        c.JSON(400, gin.H{
            "code":    400,
            "message": "参数错误",
            "error":   err.Error(),
        })
        return
    }

    // 3. 验证目标用户存在
    var targetUser models.User
    if err := h.db.First(&targetUser, req.UserID).Error; err != nil {
        c.JSON(404, gin.H{
            "code":    404,
            "message": "目标用户不存在",
        })
        return
    }

    // 4. 验证比赛存在
    var competition models.Competition
    if err := h.db.First(&competition, req.CompetitionID).Error; err != nil {
        c.JSON(404, gin.H{
            "code":    404,
            "message": "比赛不存在",
        })
        return
    }

    // 5. 创建投稿
    submission := &models.Submission{
        UserID:        req.UserID,
        CompetitionID: req.CompetitionID,
        Title:         req.Title,
        Content:       req.Content,
        NovelType:     req.NovelType,
        AuthorBio:     req.AuthorBio,
        WordCount:     len([]rune(req.Content)),
        Status:        "submitted",
        CreatedBy:     adminID, // 记录创建者
        IsProxySubmit: true,    // 标记为代投
    }

    if err := h.db.Create(submission).Error; err != nil {
        c.JSON(500, gin.H{
            "code":    500,
            "message": "创建投稿失败",
            "error":   err.Error(),
        })
        return
    }

    // 6. 记录操作日志
    LogAdminOperation(h.db, adminID, req.UserID, "create_submission", submission.ID, map[string]interface{}{
        "title":         req.Title,
        "competitionId": req.CompetitionID,
        "adminUsername": adminUsername,
        "targetUsername": targetUser.Username,
    })

    // 7. 返回成功响应
    c.JSON(200, gin.H{
        "code":    200,
        "message": "投稿创建成功",
        "data": gin.H{
            "id":             submission.ID,
            "userId":         submission.UserID,
            "title":          submission.Title,
            "isProxySubmit":  submission.IsProxySubmit,
            "createdBy":      adminUsername,
        },
    })
}

数据模型扩展

// cmd/server/models/models.go
type Submission struct {
    ID              uint      `gorm:"primaryKey"`
    UserID          uint      `gorm:"index"`
    CompetitionID   uint      `gorm:"index"`
    Title           string    `gorm:"index"`
    Content         string    `gorm:"type:longtext"`
    NovelType       string
    AuthorBio       string    `gorm:"type:text"`
    WordCount       int
    Status          string
    IsProxySubmit   bool      `gorm:"default:false"` // 是否为代投
    CreatedBy       uint      `gorm:"index"`         // 创建者ID(管理员)
    CreatedAt       time.Time
    UpdatedAt       time.Time
    DeletedAt       gorm.DeletedAt `gorm:"index"`

    // 关联
    User        *User
    Competition *Competition
    Creator     *User `gorm:"foreignKey:CreatedBy"` // 创建者(管理员)
}

前端界面实现

管理员代投表单

<template>
  <el-dialog
    v-model="visible"
    title="代替用户投稿"
    width="800px"
    :close-on-click-modal="false"
  >
    <el-alert
      title="提示"
      type="warning"
      :closable="false"
      show-icon
      style="margin-bottom: 20px"
    >
      您正在以管理员身份代替用户创建投稿,此操作将被记录。
    </el-alert>

    <el-form
      ref="formRef"
      :model="form"
      :rules="rules"
      label-width="100px"
    >
      <el-form-item label="目标用户" prop="userId">
        <el-select
          v-model="form.userId"
          placeholder="选择用户"
          filterable
          remote
          :remote-method="searchUsers"
          :loading="userLoading"
          style="width: 100%"
        >
          <el-option
            v-for="user in users"
            :key="user.id"
            :label="`${user.username} (${user.email})`"
            :value="user.id"
          />
        </el-select>
      </el-form-item>

      <el-form-item label="比赛届次" prop="competitionId">
        <el-select
          v-model="form.competitionId"
          placeholder="选择届次"
          style="width: 100%"
        >
          <el-option
            v-for="comp in competitions"
            :key="comp.id"
            :label="`第${comp.number}届`"
            :value="comp.id"
          />
        </el-select>
      </el-form-item>

      <el-form-item label="作品标题" prop="title">
        <el-input
          v-model="form.title"
          placeholder="请输入作品标题"
          maxlength="100"
          show-word-limit
        />
      </el-form-item>

      <el-form-item label="小说类型" prop="novelType">
        <el-select
          v-model="form.novelType"
          placeholder="选择类型"
          style="width: 100%"
        >
          <el-option label="科幻" value="sci-fi" />
          <el-option label="悬疑" value="mystery" />
          <el-option label="奇幻" value="fantasy" />
          <el-option label="其他" value="other" />
        </el-select>
      </el-form-item>

      <el-form-item label="作品内容" prop="content">
        <el-input
          v-model="form.content"
          type="textarea"
          :rows="10"
          placeholder="请输入作品内容"
          maxlength="50000"
          show-word-limit
        />
        <div class="word-count">
          字数:
        </div>
      </el-form-item>

      <el-form-item label="作者自述" prop="authorBio">
        <el-input
          v-model="form.authorBio"
          type="textarea"
          :rows="4"
          placeholder="请输入作者自述(可选)"
          maxlength="1000"
          show-word-limit
        />
      </el-form-item>
    </el-form>

    <template #footer>
      <div class="dialog-footer">
        <el-button @click="visible = false">取消</el-button>
        <el-button type="primary" @click="handleSubmit" :loading="submitting">
          确认创建
        </el-button>
      </div>
    </template>
  </el-dialog>
</template>

<script setup>
import { ref, computed, watch } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { searchUsers, getCompetitions } from '@/api'
import { createSubmissionForUser } from '@/api/admin'

const props = defineProps({
  modelValue: Boolean
})

const emit = defineEmits(['update:modelValue', 'success'])

const visible = ref(props.modelValue)
const formRef = ref(null)
const submitting = ref(false)
const userLoading = ref(false)

const form = ref({
  userId: null,
  competitionId: null,
  title: '',
  content: '',
  novelType: '',
  authorBio: ''
})

const users = ref([])
const competitions = ref([])

const rules = {
  userId: [
    { required: true, message: '请选择目标用户', trigger: 'change' }
  ],
  competitionId: [
    { required: true, message: '请选择比赛届次', trigger: 'change' }
  ],
  title: [
    { required: true, message: '请输入作品标题', trigger: 'blur' },
    { min: 2, max: 100, message: '标题长度在 2 到 100 个字符', trigger: 'blur' }
  ],
  content: [
    { required: true, message: '请输入作品内容', trigger: 'blur' },
    { min: 100, message: '内容至少 100 字', trigger: 'blur' }
  ],
  novelType: [
    { required: true, message: '请选择小说类型', trigger: 'change' }
  ]
}

// 计算字数
const wordCount = computed(() => {
  return form.value.content.length
})

watch(() => props.modelValue, (val) => {
  visible.value = val
  if (val) {
    loadCompetitions()
  }
})

watch(visible, (val) => {
  emit('update:modelValue', val)
  if (!val) {
    resetForm()
  }
})

// 搜索用户
const searchUsers = async (query) => {
  if (!query) {
    users.value = []
    return
  }

  userLoading.value = true
  try {
    const res = await searchUsers({ keyword: query })
    users.value = res.data
  } catch (error) {
    ElMessage.error('搜索用户失败')
  } finally {
    userLoading.value = false
  }
}

// 加载比赛列表
const loadCompetitions = async () => {
  try {
    const res = await getCompetitions()
    competitions.value = res.data
  } catch (error) {
    ElMessage.error('加载比赛列表失败')
  }
}

// 提交表单
const handleSubmit = async () => {
  try {
    await formRef.value.validate()

    // 二次确认
    await ElMessageBox.confirm(
      `确认代替用户创建投稿吗?此操作将被记录。`,
      '确认操作',
      {
        type: 'warning',
        confirmButtonText: '确认',
        cancelButtonText: '取消'
      }
    )

    submitting.value = true

    await createSubmissionForUser(form.value)

    ElMessage.success('投稿创建成功')
    visible.value = false
    emit('success')
  } catch (error) {
    if (error !== 'cancel') {
      ElMessage.error(error.message || '创建失败')
    }
  } finally {
    submitting.value = false
  }
}

// 重置表单
const resetForm = () => {
  formRef.value?.resetFields()
  form.value = {
    userId: null,
    competitionId: null,
    title: '',
    content: '',
    novelType: '',
    authorBio: ''
  }
}
</script>

<style scoped>
.word-count {
  margin-top: 8px;
  font-size: 12px;
  color: #909399;
  text-align: right;
}
</style>

用户提示优化

投稿列表标识

<template>
  <el-table :data="submissions">
    <el-table-column prop="title" label="标题">
      <template #default="{ row }">
        <div class="title-cell">
          <span></span>
          <el-tag
            v-if="row.isProxySubmit"
            type="warning"
            size="small"
            style="margin-left: 8px"
          >
            代投
          </el-tag>
        </div>
      </template>
    </el-table-column>

    <el-table-column prop="createdBy" label="创建者">
      <template #default="{ row }">
        <span v-if="row.isProxySubmit">
           (管理员代投)
        </span>
        <span v-else>
          
        </span>
      </template>
    </el-table-column>

    <!-- 其他列 -->
  </el-table>
</template>

投稿详情提示

<template>
  <el-card class="submission-detail">
    <el-alert
      v-if="submission.isProxySubmit"
      title="此投稿由管理员代为创建"
      type="info"
      :closable="false"
      show-icon
      style="margin-bottom: 16px"
    >
      <template #default>
        <div>
          创建者:
        </div>
        <div>
          创建时间:
        </div>
      </template>
    </el-alert>

    <!-- 投稿内容 -->
  </el-card>
</template>

测试与验证

功能测试清单

  1. 权限验证
    • ✅ 非管理员无法访问代投功能
    • ✅ 管理员可以正常使用
    • ✅ 操作被正确记录
  2. 数据验证
    • ✅ 目标用户必须存在
    • ✅ 比赛必须存在
    • ✅ 投稿内容符合要求
  3. 功能验证
    • ✅ 投稿成功创建
    • ✅ 投稿归属正确
    • ✅ 代投标识正确显示
  4. 日志验证
    • ✅ 操作日志正确记录
    • ✅ 包含完整的操作信息
    • ✅ 可以追溯操作历史

总结

管理员代替投稿功能为辣评平台提供了灵活的用户支持能力,在保证权限控制和操作透明的前提下,帮助用户解决投稿问题。

关键特性

  • 完善的权限控制
  • 详细的操作日志
  • 清晰的用户提示
  • 灵活的使用场景

安全措施

  • 仅管理员可用
  • 二次确认机制
  • 操作日志记录
  • 代投标识明确

用户体验

  • 界面友好易用
  • 提示信息清晰
  • 操作流程简单
  • 支持批量处理

这个功能在保证系统安全性的同时,提升了平台的服务质量和用户满意度。

本文遵守 Attribution-NonCommercial 4.0 International 许可协议。 Attribution-NonCommercial 4.0 International