功能背景
在辣评平台的运营过程中,我们发现有些用户因为技术问题或其他原因无法自行完成投稿。为了解决这个问题,我们开发了管理员代替用户投稿的功能。
功能需求
- 代替投稿
- 管理员可以代替任何用户创建投稿
- 投稿归属于指定用户
- 记录操作日志
- 权限控制
- 仅管理员可以使用此功能
- 需要验证管理员身份
- 防止权限滥用
- 用户提示
- 明确标识代投稿件
- 用户可以查看代投记录
- 提供编辑和删除权限
功能需求分析
使用场景
- 技术支持场景
- 用户不熟悉系统操作
- 用户遇到技术问题
- 紧急情况需要快速投稿
- 数据迁移场景
- 从其他平台迁移数据
- 批量导入历史投稿
- 数据修复和补充
- 特殊情况处理
- 用户账号问题
- 截止日期临近
- 其他特殊情况
功能边界
允许的操作:
- 代替用户创建投稿
- 代替用户编辑投稿
- 查看代投记录
不允许的操作:
- 代替用户删除投稿(需用户自己操作)
- 代替用户评论(评论必须真实)
- 修改投稿归属
权限控制设计
权限验证流程
// 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>
测试与验证
功能测试清单
- 权限验证
- ✅ 非管理员无法访问代投功能
- ✅ 管理员可以正常使用
- ✅ 操作被正确记录
- 数据验证
- ✅ 目标用户必须存在
- ✅ 比赛必须存在
- ✅ 投稿内容符合要求
- 功能验证
- ✅ 投稿成功创建
- ✅ 投稿归属正确
- ✅ 代投标识正确显示
- 日志验证
- ✅ 操作日志正确记录
- ✅ 包含完整的操作信息
- ✅ 可以追溯操作历史
总结
管理员代替投稿功能为辣评平台提供了灵活的用户支持能力,在保证权限控制和操作透明的前提下,帮助用户解决投稿问题。
关键特性
- 完善的权限控制
- 详细的操作日志
- 清晰的用户提示
- 灵活的使用场景
安全措施
- 仅管理员可用
- 二次确认机制
- 操作日志记录
- 代投标识明确
用户体验
- 界面友好易用
- 提示信息清晰
- 操作流程简单
- 支持批量处理
这个功能在保证系统安全性的同时,提升了平台的服务质量和用户满意度。