10 Commits

Author SHA1 Message Date
mediabot-pt
af4b2bde75 refactor: 移除风险规则 siteType fallback 逻辑,仅按站点 ID 查找专属规则 2026-07-12 23:21:14 +08:00
mediabot-pt
8b004e5c45 feat: 风险规则缓刑天数可配置
- DRATIO/RATIO 编辑区域增加缓刑天数输入框
- 规则摘要和卡片头部的缓刑天数文案改为动态读取 graceDays 字段
- DRATIO 预设模板增加 graceDays: 7 默认值
- 不填时默认7天,向后兼容
2026-07-12 23:18:55 +08:00
mediabot-pt
efd693e8e9 fix: 修复快照风险规则串站问题
buildSnapshot() 原先仅按 siteType 查询风险规则,导致同类型站点共享同一套规则。
改为优先按 siteId 查找站点专属规则,无则 fallback 到 siteType 兜底。
同时用 selectOne+limit 1 替代 selectList 优化性能,并增加 debug 日志。
2026-07-12 22:59:05 +08:00
mediabot-pt
b95c42e6e5 fix: 修复 RiskRules.vue ruleText 函数中 DRATIO 分支缺少 if 条件导致编译错误 2026-07-11 23:31:30 +08:00
mediabot-pt
a68aa94fc2 feat: 页面配置入口加 ↗ 打开按钮,可直接跳转站点页面 2026-07-10 18:48:29 +08:00
mediabot-pt
6c40505b73 fix: TRAFFIC 规则两个条件共用同一个阈值天数 2026-07-10 15:57:52 +08:00
mediabot-pt
dee204c99b feat: TRAFFIC 规则增加连续未登录天数输入,预设默认90天 2026-07-10 15:56:23 +08:00
mediabot-pt
cebe8c67c2 refactor: 分享率 DRATIO 预设默认值改为下载量>0且分享率<1.0 2026-07-10 11:10:18 +08:00
mediabot-pt
c714086f2d refactor: 前端defaultUrls+compose key sync→index 2026-07-10 10:58:43 +08:00
mediabot-pt
eb3458cb1b refactor: PageKey枚举 SYNC→INDEX 2026-07-10 10:54:12 +08:00
4 changed files with 97 additions and 35 deletions

View File

@@ -6,7 +6,7 @@ import com.fasterxml.jackson.annotation.JsonValue;
* 站点页面入口 key 枚举 — 统一命名,脚本据此分发解析逻辑
*/
public enum PageKey {
SYNC("sync", "同步", "GET"),
INDEX("index", "首页", "GET"),
SIGN_IN("signIn", "签到", "POST"),
SEARCH("search", "搜索", "GET"),
DOWNLOAD("download", "下载", "GET"),

View File

@@ -100,20 +100,21 @@ public class SiteSnapshotServiceImpl implements SiteSnapshotService {
snapshot.put("pagesSource", pages.containsKey("source") ? pages.get("source") : "custom");
}
// 3. 风险规则
if (siteType != null) {
var rules = riskRuleMapper.selectList(
// 3. 风险规则(按站点 ID 查找专属规则)
if (siteId != null) {
var matchedRule = riskRuleMapper.selectOne(
new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper<RiskRule>()
.eq(RiskRule::getSiteType, siteType)
.eq(RiskRule::getSiteId, siteId)
.eq(RiskRule::getDeleted, 0)
.orderByDesc(RiskRule::getId));
if (!rules.isEmpty()) {
var latestRule = rules.get(0);
.orderByDesc(RiskRule::getId)
.last("limit 1"));
if (matchedRule != null) {
try {
List<Map<String, Object>> rulesList = om.readValue(latestRule.getRulesJson(),
List<Map<String, Object>> rulesList = om.readValue(matchedRule.getRulesJson(),
new TypeReference<List<Map<String, Object>>>() {});
snapshot.put("riskRules", rulesList);
snapshot.put("riskRulesVersion", latestRule.getVersion());
snapshot.put("riskRulesVersion", matchedRule.getVersion());
log.debug("快照风险规则: siteId={} ruleVersion={}", siteId, matchedRule.getVersion());
} catch (Exception ignored) {}
}
}

View File

@@ -94,11 +94,12 @@
<el-icon class="drag-handle"><Rank /></el-icon>
<span class="type-tag">{{ typeLabel(rule.type) }}</span>
<span v-if="rule.type==='TASK'" class="rule-fixed-text">考核周期 {{ rule.threshold }}天</span>
<span v-else-if="rule.type==='TRAFFIC'" class="rule-fixed-text">无流量且未登录 &gt; {{ rule.threshold }}天</span>
<span v-else-if="rule.type==='DRATIO'" class="rule-fixed-text">下载量分级控制({{ (rule.levels||[]).length }} 级)</span>
<span v-else-if="rule.type==='EXEMPT'" class="rule-fixed-text">永久保留 ≥ {{ rankDisplay(rule.permRank) }} / 封存豁免 ≥ {{ rankDisplay(rule.sealExemptRank) }}</span>
<span v-else class="rule-fixed-text">{{ metricLabel(rule.metric) }} {{ condLabel(rule.condition) }} {{ rule.threshold }}{{ rule.unit || '' }}</span>
<span class="then-arrow" :style="rule.type==='EXEMPT'?'color:#67c23a':rule.type==='RATIO'||rule.type==='DRATIO'?'color:#e6a23c':''">→</span>
<span :style="{fontWeight:'600',fontSize:'13px',color:rule.type==='EXEMPT'?'#67c23a':rule.type==='RATIO'||rule.type==='DRATIO'?'#e6a23c':'#f56c6c'}">{{ rule.type==='EXEMPT' ? '保号' : rule.type==='RATIO'||rule.type==='DRATIO' ? '封号(缓刑7天)' : '封号/删除账号' }}</span>
<span :style="{fontWeight:'600',fontSize:'13px',color:rule.type==='EXEMPT'?'#67c23a':rule.type==='RATIO'||rule.type==='DRATIO'?'#e6a23c':'#f56c6c'}">{{ rule.type==='EXEMPT' ? '保号' : rule.type==='RATIO'||rule.type==='DRATIO' ? '封号(缓刑' + (rule.graceDays || 7) + '天)' : '封号/删除账号' }}</span>
<span v-if="rule.applicable" class="applicable-tag">{{ applicableLabel(rule.applicable) }}</span>
</div>
<div class="card-header-right">
@@ -138,6 +139,12 @@
<el-button size="small" text type="primary" @click="addDRatioLevel(rule)" style="align-self:flex-start">
<el-icon style="margin-right:2px"><Plus /></el-icon>添加等级
</el-button>
<!-- 缓刑天数 -->
<div class="field-row" style="margin-top:4px">
<span class="field-label">缓刑天数</span>
<el-input v-model.number="rule.graceDays" size="small" type="number" placeholder="天数" @input="sync" style="width:100px" />
<span class="field-hint">天(不填则默认7天)</span>
</div>
</template>
<!-- EXEMPT 等级豁免 -->
@@ -158,14 +165,29 @@
</div>
</template>
<!-- TRAFFIC 连续无流量+未登录 -->
<template v-else-if="rule.type==='TRAFFIC'">
<div class="field-row">
<span class="field-label">阈值</span>
<el-input v-model.number="rule.threshold" size="small" type="number" placeholder="天数" @input="sync" style="width:100px" />
<span class="field-hint">天内无上传/下载流量且未登录 → 有风险</span>
</div>
</template>
<!-- 非 TASK:普通规则 -->
<template v-else>
<div class="field-row">
<span class="field-label">阈值</span>
<el-input v-model.number="rule.threshold" size="small" type="number" placeholder="阈值" @input="sync" style="width:120px" />
<span v-if="rule.type==='RATIO'" class="field-hint">(低于此分享率则降级至Peasant,7天缓刑后封号/删除账号)</span>
<span v-if="rule.type==='RATIO'" class="field-hint">(低于此分享率则降级至Peasant,缓刑后封号/删除账号)</span>
<span v-else class="field-hint">{{ rule.unit || '' }}</span>
</div>
<!-- RATIO 缓刑天数 -->
<div v-if="rule.type==='RATIO'" class="field-row" style="margin-top:4px">
<span class="field-label">缓刑天数</span>
<el-input v-model.number="rule.graceDays" size="small" type="number" placeholder="天数" @input="sync" style="width:100px" />
<span class="field-hint">天(不填则默认7天)</span>
</div>
</template>
</div>
</div>
@@ -267,13 +289,13 @@ const presets = [
{ name:'零活跃不登录封号', type:'KILL', metric:'days_since_last_login', threshold:7, unit:'天', condition:'GT', applicable:'new_account_30d', description:'零活跃新用户连续7天不登录即封号' },
{ name:'零活跃注册天数封号', type:'KILL', metric:'days_since_registration', threshold:30, unit:'天', condition:'GT', applicable:'new_account_30d', description:'零活跃新用户注册满30天即封号' },
// 流量监控
{ name:'连续无流量封号', type:'TRAFFIC', metric:'days_since_last_traffic', threshold:90, unit:'天', condition:'GT', description:'连续90天无上传/下载流量即封号' },
{ name:'连续无流量封号', type:'TRAFFIC', metric:'days_since_last_traffic', threshold:90, unit:'天', condition:'GT', description:'连续90天无流量且未登录即封号' },
// H&R
{ name:'H&R 封号', type:'HD', metric:'hnr_count', threshold:5, unit:'次', condition:'GTE', description:'H&R违规次数达到5次即封号' },
// 新人考核
{ name:'新人考核', type:'TASK', threshold:30, unit:'天', description:'新注册用户需在考核周期内完成各项要求' },
// 分享率
{ name:'分享率', type:'DRATIO', levels:[{threshold:100,ratio:0.3},{threshold:500,ratio:0.5},{threshold:1000,ratio:0.8}], description:'下载量越大,允许的最低分享率越低' },
{ name:'分享率', type:'DRATIO', graceDays: 7, levels:[{threshold:0,ratio:1.0}], description:'下载量>0时,分享率<1.0即封号(缓刑7天)' },
// 等级豁免
{ name:'等级豁免', type:'EXEMPT', permRank:'Elite User', sealExemptRank:'Power User', description:'达到等级的账号可豁免清理' },
]
@@ -337,12 +359,17 @@ const ruleText = (r) => {
const metric = metricLabel(r.metric)
const cond = condLabel(r.condition)
const val = `${metric}${cond}${r.threshold}${r.unit||''}`
if (r.type === 'TRAFFIC') {
return `无流量且未登录:${val} → <span style="color:#f56c6c;font-weight:500">封号/删除账号</span>${appText}`
}
if (r.type === 'DRATIO') {
const grace = r.graceDays != null ? `(缓刑${r.graceDays}天)` : '(缓刑7天)'
const levels = (r.levels||[]).map(l => `下载量≥${l.threshold}GB时分享率<${l.ratio}`).join(',')
return `分享率:${levels} → <span style="color:#e6a23c;font-weight:500">封号(缓刑7天)</span>${appText}`
return `分享率:${levels} → <span style="color:#e6a23c;font-weight:500">封号${grace}</span>${appText}`
}
if (r.type === 'RATIO') {
return `分享率:${val} → <span style="color:#e6a23c;font-weight:500">封号(缓刑7天)</span>${appText}`
const grace = r.graceDays != null ? `(缓刑${r.graceDays}天)` : '(缓刑7天)'
return `分享率:${val} → <span style="color:#e6a23c;font-weight:500">封号${grace}</span>${appText}`
}
return `${typeLabel(r.type)}:${val} → <span style="color:#f56c6c;font-weight:500">封号/删除账号</span>${appText}`
}

View File

@@ -104,29 +104,48 @@
<div v-if="!parsed.length" style="text-align:center;padding:40px;color:#909399">暂无,点击添加</div>
<div v-for="(entry, idx) in parsed" :key="idx" style="margin-bottom:12px;padding:12px;border:1px solid #ebeef5;border-radius:4px;background:#fafafa">
<div style="display:flex;gap:8px;align-items:center;margin-bottom:8px">
<el-tag size="small" :type="entry.compose ? 'warning' : 'primary'">{{ entry.key }} - {{ keyLabel(entry.key) }}</el-tag>
<template v-if="entry.compose">
<span style="font-size:12px;color:#b88230;margin:0 4px">→</span>
<el-tag v-for="k in entry.compose" :key="k" size="small" type="warning" effect="plain" style="margin-right:4px">{{ k }}</el-tag>
</template>
<!-- compose 主 key 可编辑下拉框 -->
<el-select v-if="entry.compose" v-model="entry.key" size="small" @change="syncToJson" style="width:150px">
<el-option v-for="pk in allPageKeys" :key="pk.key" :label="`${pk.key} - ${pk.label}`" :value="pk.key" />
</el-select>
<el-tag v-else size="small" type="primary">{{ entry.key }} - {{ keyLabel(entry.key) }}</el-tag>
<el-select v-if="!entry.compose" v-model="entry.method" size="small" @change="syncToJson" style="width:80px">
<el-option label="GET" value="GET"/><el-option label="POST" value="POST"/>
</el-select>
<div style="flex:1"></div>
<el-button size="small" type="danger" text @click="remove(idx)">删除</el-button>
</div>
<div style="display:flex;gap:8px;align-items:flex-start;margin-bottom:6px;max-width:500px">
<el-input v-model="entry.url" size="small" placeholder="URL路径,如 /torrents.php" @input="syncToJson" style="flex:1" />
<el-button size="small" @click="verifyEntry(entry, idx)" :loading="verifying[idx]" :type="verifyResults[idx]?.success===true ? 'success' : verifyResults[idx]?.success===false ? 'danger' : ''">
{{ verifyResults[idx]?.success===true ? '✓' : verifyResults[idx]?.success===false ? '✗' : '验证' }}
<!-- compose 子页面编辑区 -->
<template v-if="entry.compose">
<div style="font-size:12px;color:#606266;font-weight:500;margin-bottom:4px">子页面({uid} 自动替换为用户UID):</div>
<div v-for="(sk, si) in entry.compose" :key="si" style="display:flex;gap:6px;align-items:center;margin-bottom:4px">
<el-select v-model="entry.compose[si]" size="small" @change="syncToJson" style="width:160px">
<el-option v-for="pk in allPageKeys" :key="pk.key" :label="`${pk.key} - ${pk.label}`" :value="pk.key" />
</el-select>
<el-button size="small" type="danger" text @click="removeComposeSub(entry, si)" :icon="Delete" circle />
</div>
<el-button size="small" text type="primary" @click="addComposeSub(entry)" style="align-self:flex-start">
<el-icon style="margin-right:2px"><Plus /></el-icon>添加子页面
</el-button>
</div>
<div v-if="verifyResults[idx]?.message" style="margin-bottom:6px;font-size:12px;padding:4px 8px;border-radius:4px" :style="{background:verifyResults[idx]?.success? '#f0f9eb':'#fef0f0',color:verifyResults[idx]?.success? '#67c23a':'#f56c6c'}">
{{ verifyResults[idx].message }}
<span v-if="verifyResults[idx].responseTimeMs != null" style="margin-left:8px;opacity:0.7">({{ verifyResults[idx].responseTimeMs }}ms)</span>
</div>
<el-input v-model="entry.headers" size="small" type="textarea" :rows="2" placeholder="自定义Header,每行一个 key: value" @input="syncToJson" style="margin-bottom:6px;max-width:500px" />
<el-input v-if="entry.method==='POST'" v-model="entry.body" size="small" type="textarea" :rows="2" placeholder="POST Body(可选)" @input="syncToJson" />
</template>
<!-- 非 compose:URL / 验证 / headers -->
<template v-if="!entry.compose">
<div style="display:flex;gap:8px;align-items:flex-start;margin-bottom:6px;max-width:500px">
<el-input v-model="entry.url" size="small" placeholder="URL路径,如 /torrents.php" @input="syncToJson" style="flex:1" />
<el-button v-if="entry.url" size="small" circle title="打开页面" @click="openPage(entry)">
<span style="font-size:14px">↗</span>
</el-button>
<el-button size="small" @click="verifyEntry(entry, idx)" :loading="verifying[idx]" :type="verifyResults[idx]?.success===true ? 'success' : verifyResults[idx]?.success===false ? 'danger' : ''">
{{ verifyResults[idx]?.success===true ? '✓' : verifyResults[idx]?.success===false ? '✗' : '验证' }}
</el-button>
</div>
<div v-if="verifyResults[idx]?.message" style="margin-bottom:6px;font-size:12px;padding:4px 8px;border-radius:4px" :style="{background:verifyResults[idx]?.success? '#f0f9eb':'#fef0f0',color:verifyResults[idx]?.success? '#67c23a':'#f56c6c'}">
{{ verifyResults[idx].message }}
<span v-if="verifyResults[idx].responseTimeMs != null" style="margin-left:8px;opacity:0.7">({{ verifyResults[idx].responseTimeMs }}ms)</span>
</div>
<el-input v-model="entry.headers" size="small" type="textarea" :rows="2" placeholder="自定义Header,每行一个 key: value" @input="syncToJson" style="margin-bottom:6px;max-width:500px" />
<el-input v-if="entry.method==='POST'" v-model="entry.body" size="small" type="textarea" :rows="2" placeholder="POST Body(可选)" @input="syncToJson" />
</template>
</div>
</div>
</div>
@@ -134,7 +153,7 @@
</template>
<script setup>
import api from '../api'; import { Refresh } from '@element-plus/icons-vue'
import api from '../api'; import { Refresh, Plus, Delete } from '@element-plus/icons-vue'
const loading = ref(false), saving = ref(false), deleting = ref(false), savingPreview = ref(false), publishing = ref(false), discardingDraft = ref(false)
const sites = ref([]), pagesMap = ref({}), sitesList = ref([])
@@ -142,7 +161,7 @@ const editSiteId = ref(''), editorJson = ref('{}'), originalJson = ref('')
const snapshotStatus = ref(null)
const authType = ref('NONE')
const defaultUrls = {
sync: '/index.php',
index: '/index.php',
signIn: '/attendance.php',
search: '/torrents.php?search={keyword}',
download: '/download.php?id={id}',
@@ -171,6 +190,7 @@ const availableKeys = computed(() => {
const used = new Set(parsed.value.map(e => e.key).filter(Boolean))
return pageKeys.value.filter(k => !used.has(k.key))
})
const allPageKeys = computed(() => pageKeys.value)
const onKeyChange = (row) => {
const pk = pageKeys.value.find(k => k.key === row.key)
@@ -194,6 +214,11 @@ const resolveUrl = (url) => {
if (!url || !base) return url || ''
return url.startsWith('/') ? base.replace(/\/$/, '') + url : url
}
const openPage = (entry) => {
const resolved = resolveUrl(entry.url)
if (resolved) window.open(resolved, '_blank')
else ElMessage.warning('请先配置站点地址')
}
const verifyEntry = async (entry, idx) => {
const resolved = resolveUrl(entry.url)
@@ -315,7 +340,16 @@ const addEntryByKey = (k) => {
syncToJson()
}
const addComposeEntry = () => {
parsed.value.push({ key: 'sync', compose: ['profile', 'peerStats'] })
parsed.value.push({ key: 'index', compose: ['userdetail', 'peerStats'] })
syncToJson()
}
const addComposeSub = (entry) => {
if (!entry.compose) entry.compose = []
entry.compose.push('userdetail')
syncToJson()
}
const removeComposeSub = (entry, si) => {
entry.compose.splice(si, 1)
syncToJson()
}
const deletePages = async () => {