feat: 新增 site_submissions 审核表, 提交先进审核再写入sites

This commit is contained in:
mediabot-pt
2026-06-30 08:57:10 +08:00
parent aa1e3e9b28
commit 657e0c1a78
10 changed files with 292 additions and 91 deletions

View File

@@ -266,26 +266,26 @@ public class AdminController {
} }
@GetMapping("/sites/pending") @GetMapping("/sites/pending")
public ApiResponse<List<com.par.core.dto.SiteDTO>> listPendingSites(HttpServletRequest request) { public ApiResponse<List<com.par.core.dto.SiteSubmissionDTO>> listPendingSubmissions(HttpServletRequest request) {
checkAdmin(request); checkAdmin(request);
return ApiResponse.success(siteService.listPendingSites().stream() return ApiResponse.success(siteService.listPendingSubmissions());
.map(siteService::toDTO).toList());
} }
@PostMapping("/sites/{id}/approve") @PostMapping("/submissions/{id}/approve")
public ApiResponse<Void> approveSite(@PathVariable Long id, HttpServletRequest request) { public ApiResponse<Void> approveSubmission(@PathVariable Long id, HttpServletRequest request) {
checkAdmin(request); checkAdmin(request);
Long reviewerId = (Long) request.getAttribute("accountId"); Long reviewerId = (Long) request.getAttribute("accountId");
siteService.approveSite(id, reviewerId); siteService.approveSubmission(id, reviewerId);
log.info("Site approved: id={}, reviewer={}", id, reviewerId); log.info("Submission approved: id={}, reviewer={}", id, reviewerId);
return ApiResponse.success(); return ApiResponse.success();
} }
@PostMapping("/sites/{id}/reject") @PostMapping("/submissions/{id}/reject")
public ApiResponse<Void> rejectSite(@PathVariable Long id, HttpServletRequest request) { public ApiResponse<Void> rejectSubmission(@PathVariable Long id, HttpServletRequest request) {
checkAdmin(request); checkAdmin(request);
siteService.rejectSite(id); Long reviewerId = (Long) request.getAttribute("accountId");
log.info("Site rejected: id={}", id); siteService.rejectSubmission(id, reviewerId);
log.info("Submission rejected: id={}", id);
return ApiResponse.success(); return ApiResponse.success();
} }

View File

@@ -115,7 +115,7 @@ public class SiteController {
String notes = (String) payload.get("notes"); String notes = (String) payload.get("notes");
Long submitterId = (Long) request.getAttribute("accountId"); Long submitterId = (Long) request.getAttribute("accountId");
Site site = siteService.updateSiteInfo(siteId, status, names, urls, notes, submitterId); siteService.submitSiteInfo(siteId, status, names, urls, notes, submitterId);
return ApiResponse.success(siteService.toDTO(site)); return ApiResponse.success(Map.of("message", "站点信息已提交审核"));
} }
} }

View File

@@ -0,0 +1,22 @@
package com.par.core.dto;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
@Data
public class SiteSubmissionDTO {
private Long id;
private String siteId;
private Long submitterId;
private List<String> names;
private List<String> urls;
private String notes;
private String siteStatus; // 正常/关停/维护中/已迁移
private String status; // pending/approved/rejected
private Long reviewerId;
private String reviewComment;
private LocalDateTime reviewedAt;
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,41 @@
package com.par.core.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.time.LocalDateTime;
@Data
@TableName("site_submissions")
public class SiteSubmission {
@TableId(type = IdType.AUTO)
private Long id;
@TableField("site_id")
private String siteId;
@TableField("submitter_id")
private Long submitterId;
@TableField("raw_data")
private String rawData;
@TableField("status")
private String status; // pending / approved / rejected
@TableField("reviewer_id")
private Long reviewerId;
@TableField("review_comment")
private String reviewComment;
@TableField("reviewed_at")
private LocalDateTime reviewedAt;
@TableField("site_record_id")
private Long siteRecordId;
@TableField(value = "created_at", fill = FieldFill.INSERT)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,19 @@
package com.par.core.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.par.core.entity.SiteSubmission;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import java.util.List;
@Mapper
public interface SiteSubmissionMapper extends BaseMapper<SiteSubmission> {
@Select("SELECT * FROM site_submissions WHERE status = #{status} ORDER BY created_at DESC")
List<SiteSubmission> selectByStatus(@Param("status") String status);
@Select("SELECT * FROM site_submissions WHERE site_id = #{siteId} ORDER BY created_at DESC")
List<SiteSubmission> selectBySiteId(@Param("siteId") String siteId);
}

View File

@@ -1,6 +1,7 @@
package com.par.core.service; package com.par.core.service;
import com.par.core.dto.SiteDTO; import com.par.core.dto.SiteDTO;
import com.par.core.dto.SiteSubmissionDTO;
import com.par.core.entity.Site; import com.par.core.entity.Site;
import java.util.List; import java.util.List;
@@ -32,9 +33,9 @@ public interface SiteService {
String iconUrl, String siteType, Long submitterId); String iconUrl, String siteType, Long submitterId);
/** /**
* 提交站点信息反馈(状态、多名称、多地址、备注) * 提交站点信息反馈 → 写入 site_submissions 审核表
*/ */
Site updateSiteInfo(String siteId, String status, List<String> names, void submitSiteInfo(String siteId, String status, List<String> names,
List<String> urls, String notes, Long submitterId); List<String> urls, String notes, Long submitterId);
/** /**
@@ -46,12 +47,12 @@ public interface SiteService {
/** 所有站点列表(管理员用) */ /** 所有站点列表(管理员用) */
List<Site> listAllSites(); List<Site> listAllSites();
/** 待审核站点列表 */ /** 待审核提交列表 */
List<Site> listPendingSites(); List<SiteSubmissionDTO> listPendingSubmissions();
/** 审核通过 */ /** 审核通过 → 写入 sites 表 */
void approveSite(Long id, Long reviewerId); Site approveSubmission(Long submissionId, Long reviewerId);
/** 拒绝(删除) */ /** 拒绝提交 */
void rejectSite(Long id); void rejectSubmission(Long submissionId, Long reviewerId);
} }

View File

@@ -4,17 +4,22 @@ import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import com.par.core.dto.SiteDTO; import com.par.core.dto.SiteDTO;
import com.par.core.dto.SiteSubmissionDTO;
import com.par.core.entity.Site; import com.par.core.entity.Site;
import com.par.core.entity.SiteSubmission;
import com.par.core.enums.SiteStatus; import com.par.core.enums.SiteStatus;
import com.par.core.enums.SiteType; import com.par.core.enums.SiteType;
import com.par.core.mapper.SiteMapper; import com.par.core.mapper.SiteMapper;
import com.par.core.mapper.SiteSubmissionMapper;
import com.par.core.service.SiteService; import com.par.core.service.SiteService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@Slf4j @Slf4j
@@ -23,6 +28,7 @@ import java.util.stream.Collectors;
public class SiteServiceImpl implements SiteService { public class SiteServiceImpl implements SiteService {
private final SiteMapper siteMapper; private final SiteMapper siteMapper;
private final SiteSubmissionMapper submissionMapper;
private static final ObjectMapper objectMapper = new ObjectMapper(); private static final ObjectMapper objectMapper = new ObjectMapper();
// ==================== 工具方法 ==================== // ==================== 工具方法 ====================
@@ -118,34 +124,25 @@ public class SiteServiceImpl implements SiteService {
} }
@Override @Override
public Site updateSiteInfo(String siteId, String status, List<String> names, public void submitSiteInfo(String siteId, String status, List<String> names,
List<String> urls, String notes, Long submitterId) { List<String> urls, String notes, Long submitterId) {
SiteStatus siteStatus = mapStatus(status); Map<String, Object> raw = new LinkedHashMap<>();
raw.put("names", names != null ? names : List.of());
raw.put("urls", urls != null ? urls : List.of());
raw.put("notes", notes != null ? notes : "");
raw.put("siteStatus", status != null ? status : "正常");
Site existing = siteMapper.selectBySiteId(siteId); SiteSubmission sub = new SiteSubmission();
if (existing != null) { sub.setSiteId(siteId);
if (names != null && !names.isEmpty()) existing.setNamesJson(toJsonArray(names)); sub.setSubmitterId(submitterId);
if (urls != null && !urls.isEmpty()) existing.setUrlsJson(toJsonArray(urls)); sub.setStatus("pending");
if (notes != null && !notes.isBlank()) existing.setNotes(notes); try {
existing.setStatus(siteStatus); sub.setRawData(objectMapper.writeValueAsString(raw));
existing.setSubmitterId(submitterId != null ? submitterId : existing.getSubmitterId()); } catch (JsonProcessingException e) {
siteMapper.updateById(existing); throw new RuntimeException("序列化提交数据失败", e);
log.info("站点信息反馈更新: siteId={} status={}", siteId, siteStatus.getValue());
return existing;
} }
submissionMapper.insert(sub);
Site site = new Site(); log.info("站点信息提交审核: siteId={} submissionId={}", siteId, sub.getId());
site.setSiteId(siteId);
site.setNamesJson(toJsonArray(names != null && !names.isEmpty() ? names : List.of(siteId)));
site.setUrlsJson(toJsonArray(urls != null && !urls.isEmpty() ? urls : List.of()));
site.setNotes(notes);
site.setSiteType(SiteType.UNKNOWN);
site.setSchemaVersion("1.0.0");
site.setStatus(SiteStatus.PENDING);
site.setSubmitterId(submitterId);
siteMapper.insert(site);
log.info("站点信息反馈创建: siteId={} status=PENDING", siteId);
return site;
} }
@Override @Override
@@ -184,32 +181,101 @@ public class SiteServiceImpl implements SiteService {
} }
@Override @Override
public List<Site> listPendingSites() { public List<SiteSubmissionDTO> listPendingSubmissions() {
return siteMapper.selectList( return submissionMapper.selectByStatus("pending").stream()
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<Site>() .map(this::toSubmissionDTO).collect(Collectors.toList());
.eq(Site::getStatus, SiteStatus.PENDING)
.eq(Site::getDeleted, 0));
} }
@Override @Override
public void approveSite(Long id, Long reviewerId) { public Site approveSubmission(Long submissionId, Long reviewerId) {
Site site = siteMapper.selectById(id); SiteSubmission sub = submissionMapper.selectById(submissionId);
if (site != null) { if (sub == null) throw new IllegalArgumentException("提交记录不存在");
site.setStatus(SiteStatus.ACTIVE); if (!"pending".equals(sub.getStatus())) throw new IllegalArgumentException("该提交已处理");
siteMapper.updateById(site);
// 解析提交数据
Map<String, Object> raw;
try {
raw = objectMapper.readValue(sub.getRawData(), new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
throw new RuntimeException("解析提交数据失败", e);
} }
@SuppressWarnings("unchecked")
List<String> names = (List<String>) raw.getOrDefault("names", List.of());
@SuppressWarnings("unchecked")
List<String> urls = (List<String>) raw.getOrDefault("urls", List.of());
String notes = (String) raw.getOrDefault("notes", "");
// 写入 sites 表
Site existing = siteMapper.selectBySiteId(sub.getSiteId());
Site site;
if (existing != null) {
existing.setNamesJson(toJsonArray(names));
existing.setUrlsJson(toJsonArray(urls));
if (notes != null && !notes.isBlank()) existing.setNotes(notes);
existing.setStatus(SiteStatus.ACTIVE);
siteMapper.updateById(existing);
site = existing;
} else {
site = new Site();
site.setSiteId(sub.getSiteId());
site.setNamesJson(toJsonArray(names));
site.setUrlsJson(toJsonArray(urls));
site.setNotes(notes);
site.setSiteType(SiteType.UNKNOWN);
site.setSchemaVersion("1.0.0");
site.setStatus(SiteStatus.ACTIVE);
site.setSubmitterId(sub.getSubmitterId());
siteMapper.insert(site);
}
// 更新提交状态
sub.setStatus("approved");
sub.setReviewerId(reviewerId);
sub.setReviewedAt(java.time.LocalDateTime.now());
sub.setSiteRecordId(site.getId());
submissionMapper.updateById(sub);
log.info("站点审核通过: siteId={} submissionId={}", sub.getSiteId(), submissionId);
return site;
} }
@Override @Override
public void rejectSite(Long id) { public void rejectSubmission(Long submissionId, Long reviewerId) {
siteMapper.deleteById(id); SiteSubmission sub = submissionMapper.selectById(submissionId);
if (sub == null) throw new IllegalArgumentException("提交记录不存在");
if (!"pending".equals(sub.getStatus())) throw new IllegalArgumentException("该提交已处理");
sub.setStatus("rejected");
sub.setReviewerId(reviewerId);
sub.setReviewedAt(java.time.LocalDateTime.now());
submissionMapper.updateById(sub);
log.info("站点审核拒绝: siteId={} submissionId={}", sub.getSiteId(), submissionId);
} }
private static SiteStatus mapStatus(String status) { private SiteSubmissionDTO toSubmissionDTO(SiteSubmission sub) {
if (status == null) return SiteStatus.ACTIVE; SiteSubmissionDTO dto = new SiteSubmissionDTO();
return switch (status) { dto.setId(sub.getId());
case "关停", "已迁移" -> SiteStatus.DEPRECATED; dto.setSiteId(sub.getSiteId());
default -> SiteStatus.ACTIVE; dto.setSubmitterId(sub.getSubmitterId());
}; dto.setStatus(sub.getStatus());
dto.setReviewerId(sub.getReviewerId());
dto.setReviewComment(sub.getReviewComment());
dto.setReviewedAt(sub.getReviewedAt());
dto.setCreatedAt(sub.getCreatedAt());
try {
Map<String, Object> raw = objectMapper.readValue(sub.getRawData(), new TypeReference<Map<String, Object>>() {});
@SuppressWarnings("unchecked")
List<String> names = (List<String>) raw.getOrDefault("names", List.of());
@SuppressWarnings("unchecked")
List<String> urls = (List<String>) raw.getOrDefault("urls", List.of());
dto.setNames(names);
dto.setUrls(urls);
dto.setNotes((String) raw.getOrDefault("notes", ""));
dto.setSiteStatus((String) raw.getOrDefault("siteStatus", "正常"));
} catch (Exception ignored) {
dto.setNames(List.of());
dto.setUrls(List.of());
}
return dto;
} }
} }

View File

@@ -0,0 +1,16 @@
CREATE TABLE site_submissions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
site_id VARCHAR(64) NOT NULL COMMENT '站点标识',
submitter_id BIGINT UNSIGNED COMMENT '提交者账户ID',
raw_data JSON NOT NULL COMMENT '原始提交数据 {names,urls,notes,site_status}',
status VARCHAR(20) NOT NULL DEFAULT 'pending' COMMENT '审核状态: pending/approved/rejected',
reviewer_id BIGINT UNSIGNED COMMENT '审核人ID',
review_comment VARCHAR(500) COMMENT '审核备注',
reviewed_at DATETIME COMMENT '审核时间',
site_record_id BIGINT UNSIGNED COMMENT '审核通过后关联的 sites.id',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_site_id (site_id),
KEY idx_status (status),
KEY idx_submitter (submitter_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='站点提交审核表';

View File

@@ -36,8 +36,9 @@ export default {
get: (id) => api.get(`/sites/${id}`), get: (id) => api.get(`/sites/${id}`),
create: (data) => api.post('/sites', data), create: (data) => api.post('/sites', data),
update: (id, data) => api.put(`/sites/${id}`, data), update: (id, data) => api.put(`/sites/${id}`, data),
approve: (id) => api.post(`/admin/sites/${id}/approve`), pendingSubmissions: () => api.get('/admin/sites/pending'),
reject: (id) => api.post(`/admin/sites/${id}/reject`), approveSubmission: (id) => api.post(`/admin/submissions/${id}/approve`),
rejectSubmission: (id) => api.post(`/admin/submissions/${id}/reject`),
}, },
configs: { configs: {
list: () => api.get('/configs'), list: () => api.get('/configs'),

View File

@@ -1,8 +1,48 @@
<template> <template>
<div> <div>
<h2>站点管理</h2> <h2>站点管理</h2>
<!-- 审核中的提交 -->
<el-card style="margin-top: 20px" v-if="submissions.length > 0">
<template #header><span style="font-weight:bold">待审核</span></template>
<el-table :data="submissions" stripe size="small">
<el-table-column prop="siteId" label="站点ID" width="120" />
<el-table-column label="名称" width="180">
<template #default="scope">
<div v-for="(n, i) in scope.row.names" :key="i" class="text-xs">{{ n }}</div>
</template>
</el-table-column>
<el-table-column label="地址" min-width="200">
<template #default="scope">
<div v-for="(u, i) in scope.row.urls" :key="i" class="text-xs">{{ u }}</div>
</template>
</el-table-column>
<el-table-column label="站点状态" width="90">
<template #default="scope">
<el-tag size="small" :type="scope.row.siteStatus === '正常' ? 'success' : scope.row.siteStatus === '关停' ? 'danger' : 'warning'">
{{ scope.row.siteStatus }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="备注" min-width="120">
<template #default="scope">
<span class="text-xs text-muted">{{ scope.row.notes || '-' }}</span>
</template>
</el-table-column>
<el-table-column prop="createdAt" label="提交时间" width="160" />
<el-table-column label="操作" width="150">
<template #default="scope">
<el-button size="small" type="success" @click="handleApprove(scope.row)">通过</el-button>
<el-button size="small" type="danger" @click="handleReject(scope.row)">拒绝</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 已审核站点 -->
<el-card style="margin-top: 20px"> <el-card style="margin-top: 20px">
<el-table :data="sites" v-loading="loading" stripe> <template #header><span style="font-weight:bold">已审核站点</span></template>
<el-table :data="sites" v-loading="loading" stripe size="small">
<el-table-column prop="siteId" label="站点ID" width="120" /> <el-table-column prop="siteId" label="站点ID" width="120" />
<el-table-column label="名称" width="180"> <el-table-column label="名称" width="180">
<template #default="scope"> <template #default="scope">
@@ -17,20 +57,15 @@
<el-table-column prop="siteType" label="类型" width="100" /> <el-table-column prop="siteType" label="类型" width="100" />
<el-table-column prop="status" label="状态" width="100"> <el-table-column prop="status" label="状态" width="100">
<template #default="scope"> <template #default="scope">
<el-tag :type="statusType(scope.row.status)"> <el-tag size="small" :type="scope.row.status === 'active' ? 'success' : 'info'">
{{ scope.row.status }} {{ scope.row.status }}
</el-tag> </el-tag>
</template> </template>
</el-table-column> </el-table-column>
<el-table-column prop="schemaVersion" label="Schema版本" width="120" /> <el-table-column label="操作" width="150">
<el-table-column label="操作" width="280">
<template #default="scope"> <template #default="scope">
<el-button size="small" @click="handleEdit(scope.row)">编辑</el-button> <el-button size="small" @click="handleEdit(scope.row)">编辑</el-button>
<el-button size="small" @click="handleViewConfig(scope.row)">配置</el-button> <el-button size="small" @click="handleViewConfig(scope.row)">配置</el-button>
<template v-if="scope.row.status === 'pending'">
<el-button size="small" type="success" @click="handleApprove(scope.row)">通过</el-button>
<el-button size="small" type="danger" @click="handleReject(scope.row)">拒绝</el-button>
</template>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -80,6 +115,7 @@
import api from '../api' import api from '../api'
const sites = ref([]) const sites = ref([])
const submissions = ref([])
const loading = ref(false) const loading = ref(false)
const editVisible = ref(false) const editVisible = ref(false)
const saving = ref(false) const saving = ref(false)
@@ -157,19 +193,17 @@ const escapeHtml = (str) => {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
} }
const statusType = (s) => { const fetchAll = async () => {
if (s === 'active') return 'success'
if (s === 'pending') return 'warning'
return 'info'
}
const fetchSites = async () => {
loading.value = true loading.value = true
try { try {
const res = await api.sites.adminList() const [siteRes, subRes] = await Promise.all([
sites.value = res.data || [] api.sites.adminList(),
api.sites.pendingSubmissions(),
])
sites.value = siteRes.data || []
submissions.value = subRes.data || []
} catch (e) { } catch (e) {
ElMessage.error('获取站点列表失败') ElMessage.error('获取数据失败')
} finally { } finally {
loading.value = false loading.value = false
} }
@@ -177,9 +211,9 @@ const fetchSites = async () => {
const handleApprove = async (row) => { const handleApprove = async (row) => {
try { try {
await api.sites.approve(row.id) await api.sites.approveSubmission(row.id)
ElMessage.success('已通过') ElMessage.success('已通过')
fetchSites() fetchAll()
} catch (e) { } catch (e) {
ElMessage.error(e.response?.data?.message || '操作失败') ElMessage.error(e.response?.data?.message || '操作失败')
} }
@@ -187,10 +221,11 @@ const handleApprove = async (row) => {
const handleReject = async (row) => { const handleReject = async (row) => {
try { try {
await ElMessageBox.confirm(`确认拒绝站点 "${row.name}"?此操作将删除该站点。`, '警告', { type: 'warning' }) const name = (row.names || [])[0] || row.siteId
await api.sites.reject(row.id) await ElMessageBox.confirm(`确认拒绝 "${name}" 的提交?`, '警告', { type: 'warning' })
await api.sites.rejectSubmission(row.id)
ElMessage.success('已拒绝') ElMessage.success('已拒绝')
fetchSites() fetchAll()
} catch (e) { } catch (e) {
if (e !== 'cancel') ElMessage.error(e.response?.data?.message || '操作失败') if (e !== 'cancel') ElMessage.error(e.response?.data?.message || '操作失败')
} }
@@ -223,7 +258,7 @@ const handleSave = async () => {
}) })
ElMessage.success('保存成功') ElMessage.success('保存成功')
editVisible.value = false editVisible.value = false
fetchSites() fetchAll()
} catch (e) { } catch (e) {
ElMessage.error(e.response?.data?.message || '保存失败') ElMessage.error(e.response?.data?.message || '保存失败')
} finally { } finally {
@@ -231,7 +266,7 @@ const handleSave = async () => {
} }
} }
onMounted(fetchSites) onMounted(fetchAll)
</script> </script>
<style scoped> <style scoped>