Browse Source

新增风控与审计页面

- 新增 src/views/risk/index.vue:风控预警 Tab(异常行为检测、自报自审检测)+ 审计日志 Tab(筛选/分页/展开前后数据)

- 新增 src/api/risk/index.js:audit/log 与 anti/farm 检测接口封装

- 风控预警列表与处置无后端接口,保留占位

- 同步更新 PAGES_TODO / CURRENT_STATUS / DEVELOPMENT_LOG;提交前 eslint 与 build:prod 通过
master
DU 4 days ago
parent
commit
c1481ca6c6
  1. 1
      docs/development/CURRENT_STATUS.md
  2. 8
      docs/development/DEVELOPMENT_LOG.md
  3. 4
      docs/development/PAGES_TODO.md
  4. 25
      src/api/risk/index.js
  5. 372
      src/views/risk/index.vue

1
docs/development/CURRENT_STATUS.md

@ -41,6 +41,7 @@ @@ -41,6 +41,7 @@
| 申诉台账 | `src/views/points/appeal/index.vue` | `points/appeal/index` | 已开发(2026-10-15),待测试。独立页(抄隐患台账 + 审批):列表 `appeal/todoList`、详情 `appeal/detail/{id}`、审批 `appeal/completeFlow`(taskId 由 `flowable/busiInfo/getProcessDetail` 补全)。待联调确认:completeFlow 的 taskId 来源、改判补分 `adjustPoints` 传参 |
| 安全兑换管理 | `src/views/exchange/security/index.vue` | `exchange/security/index` | 已开发(2026-10-15),待测试。安全校验总开关 + 兑换限额 + 预警与大额审批 + 防刷配置;接口 `exchange/config/*`、`anti/farm/*`。“校验配置”为前端本地一致性校验 |
| 数据分析 | `src/views/analysis/index.vue` | `analysis/index` | 已开发(2026-10-15),待测试。处理时效(按级别)与积分排行(积分/上报/兑换)左右一行;分类/级别分布用 echarts;月度运营报告 + 导出。按甲方要求无隐患热力图、无顶部筛选列;“超期未处理表”无接口未做 |
| 风控与审计 | `src/views/risk/index.vue` | `risk/index` | 部分开发(2026-10-15),待测试。审计日志 Tab 完整(`audit/log/getListByPage`);风控预警 Tab 提供异常行为检测 / 自报自审检测,**预警列表与处置无接口保留占位** |
| 隐患流程配置 | `src/views/flowable/hazardConfig/index.vue` | `flowable/hazardConfig/index` | 已完成列表、筛选、分页、新增、修改、状态 Tag、删除和厂区权限展示;新增“流程类型”字段(`bizType`,字典 `flow_biz_type`)并在列表以 Tag 展示;分页按真实 `data.list/data.total` 解析 |
| 切换日志 | `src/views/flowable/switchLog/index.vue` | `flowable/switchLog/index` | 空白页面,无明确列表接口 |

8
docs/development/DEVELOPMENT_LOG.md

@ -1,5 +1,13 @@ @@ -1,5 +1,13 @@
# 开发日志
## 2026-10-15:新增 Web 风控与审计页面(W8)
- 新增 `src/views/risk/index.vue` + `src/api/risk/index.js`,两个 Tab:
- **风控预警**:异常行为检测(`anti/farm/detectAll`,按用户返回上报/积分获取/兑换三项校验 + 综合结论)、自报自审检测(`anti/farm/detectSelfAudit`,上报人+审核人)。**预警列表与处置(放行/冻结 24h/黑名单)后端无接口,保留占位说明**;防刷 4 维度开关已在「安全兑换管理」维护,此处不重复。
- **审计日志**:`audit/log/getListByPage`,筛选(操作类型/操作人/业务单号)+ 分页 + 展开显示操作前/后 JSON。
- 菜单(需后端配置):一级「分析与风控」→「风控与审计」,路由 `risk/index`,组件 `risk/index`(`/risk/index`)。
- 验证:`eslint --no-ignore` 通过、`npm run build:prod` 通过(仅既有 warning)。
## 2026-10-15:新增 Web 数据分析页面(W6)
- 新增 `src/views/analysis/index.vue` + `src/api/analysis/index.js`。

4
docs/development/PAGES_TODO.md

@ -40,7 +40,7 @@ @@ -40,7 +40,7 @@
| 16 | 积分与商城 | 商城运营 › 日常积分兑换专区 | ❌ | ⛔ | ⛔ | 同上 |
| 17 | 安全兑换管理 | 安全兑换管理(总开关/限额/大额审批/防刷配置) | ⚠️ 待测试 | ✅ | ✅ | **已开发(2026-10-15)**:`src/views/exchange/security/index.vue` + `src/api/exchange/config.js`。接口 `exchange/config/active`·`switch/status`·`switch/toggle`·`add`·`updateById`、`anti/farm/active`·`updateById`。注:“校验配置”为前端本地一致性校验(无后端校验配置接口) |
| 18 | 分析与风控 | 数据分析(时效/排行/分类分布/级别分布/月度报告导出;无热力图、无顶部筛选) | ⚠️ 待测试 | ✅ | ✅ | **已开发(2026-10-15)**:`src/views/analysis/index.vue` + `src/api/analysis/index.js`。接口 `stats/category`·`stats/level`·`timeliness/level`·`ranking/points|report|exchange`·`report/monthly`(+`/export`)。“超期未处理表”无接口,暂未做 |
| 19 | 分析与风控 | 风控与审计(Tab1 风控预警 + Tab2 审计日志) | ❌ | ⚠️ | ⚠️ | 审计日志 ✅:`audit/log/getListByPage`、`audit/log/biz/{bizId}`([操作审计日志](../api/modules/16-audit-log.md));**风控预警列表 + 处置(放行/冻结 24h/黑名单)无接口**,Tab1 需等后端 |
| 19 | 分析与风控 | 风控与审计(Tab1 风控预警 + Tab2 审计日志) | ⚠️ 待测试 | ⚠️ | ⚠️ | **部分已开发(2026-10-15)**:`src/views/risk/index.vue` + `src/api/risk/index.js`。① 风控预警 Tab:异常行为检测 `anti/farm/detectAll`、自报自审检测 `anti/farm/detectSelfAudit`;**预警列表 + 处置(放行/冻结 24h/黑名单)无接口,保留占位**。② 审计日志 Tab:`audit/log/getListByPage` 完整可用 |
| 20 | 流程管理 | 隐患流程配置 | ✅ | ✅ | — | [flowable/hazardConfig](../../src/views/flowable/hazardConfig/index.vue) |
| 21 | 流程管理 | 隐患上报(流程发起) | ✅ | ✅ | — | [flowable/task/myProcess](../../src/views/flowable/task/myProcess) |
| 22 | 流程管理 | 隐患审批(待办/已办) | ✅ | ✅ | — | [flowable/task/todo](../../src/views/flowable/task/todo) |
@ -215,6 +215,6 @@ @@ -215,6 +215,6 @@
| 编号 | 端 | 功能 / 页面 | 关键接口 | 建议文件(页面 / 封装) | 优先级 | 状态 | 依赖 / 备注 |
| --- | --- | --- | --- | --- | --- | --- | --- |
| W7 | Web | 商城运营(订单主体) | `exchange/order/getListByPage`·`info`·`refund/{orderId}`、`points/order/track/list/{orderId}`、`exchange/item/*` | `src/views/exchange/mall/index.vue` + `src/api/exchange/order.js` | 中 | ☐ | “惠服对接状态卡”无接口,后续再补,不阻塞订单部分 |
| W8 | Web | 风控与审计(审计日志 Tab) | `audit/log/getListByPage`、`audit/log/biz/{bizId}` | `src/views/analysis/risk/index.vue` + `src/api/audit/log.js` | 中 | ☐ | 风控预警 Tab 待后端接口 |
| W8 | Web | 风控与审计 | `audit/log/getListByPage`、`anti/farm/detectAll`、`anti/farm/detectSelfAudit` | `src/views/risk/index.vue` + `src/api/risk/index.js` | 中 | ◐ 待测试 | **部分已开发(2026-10-15)**,待测试。审计日志 Tab 完整;风控预警 Tab 提供异常检测/自报自审检测,**预警列表与处置无接口(占位)**。菜单:一级「分析与风控」→「风控与审计」,路由 `risk/index`,组件 `risk/index`(`/risk/index`) |
> 约定:每完成一项 → 勾选状态 → 同步更新第三节状态、[DEVELOPMENT_LOG.md](./DEVELOPMENT_LOG.md) 与 [page-api-map.md](../api/page-api-map.md)。

25
src/api/risk/index.js

@ -0,0 +1,25 @@ @@ -0,0 +1,25 @@
import request from '@/utils/request'
// 审计日志分页查询(操作类型 / 操作人 / 业务单号)
export function listAuditLogs(params) {
return request({ url: '/business/audit/log/getListByPage', method: 'get', params })
}
// 按业务单号查询关联审计日志(时间倒序)
export function listAuditLogsByBiz(bizId) {
return request({ url: '/business/audit/log/biz/' + bizId, method: 'get' })
}
// 异常行为一键检测(指定用户,聚合上报/积分获取/兑换 3 类校验)
export function detectAbnormal(userId) {
return request({ url: '/business/points/anti/farm/detectAll', method: 'get', params: { userId }})
}
// 自报自审行为检测(上报人 = 审核人?)
export function detectSelfAudit(reporterId, auditorId) {
return request({
url: '/business/points/anti/farm/detectSelfAudit',
method: 'get',
params: { reporterId, auditorId }
})
}

372
src/views/risk/index.vue

@ -0,0 +1,372 @@ @@ -0,0 +1,372 @@
<template>
<div class="app-container risk-audit">
<el-tabs v-model="activeTab">
<!-- 风控预警 -->
<el-tab-pane label="风控预警" name="alert">
<div class="panel">
<div class="panel-title">异常行为检测</div>
<el-form :inline="true" size="small">
<el-form-item label="检测用户">
<select-tree
ref="detectUserTree"
type="tree_person"
:chenk-parent-node="false"
:echo-back="detectForm.userId"
placeholder="请选择要检测的用户"
style="width:240px"
@OrgNodeClick="handleDetectUserSelect"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" size="mini" :loading="detecting" @click="handleDetect">执行异常行为检测</el-button>
</el-form-item>
</el-form>
<div v-if="detectResult" class="detect-result">
<div class="detect-result__overall" :class="detectResult.overallPassed ? 'is-pass' : 'is-fail'">
综合结果:{{ detectResult.overallPassed ? '未发现异常' : '存在异常' }}
</div>
<div v-for="check in detectChecks" :key="check.key" class="detect-result__item">
<span class="detect-result__name">{{ check.name }}</span>
<el-tag :type="check.passed ? 'success' : 'danger'" size="small">{{ check.passed ? '通过' : '异常' }}</el-tag>
<span class="detect-result__reason">{{ check.reason || '—' }}</span>
</div>
</div>
<div v-else class="panel-note">选择用户后点击检测,返回上报防刷、积分获取防刷、兑换防刷三类校验结果。</div>
</div>
<div class="panel">
<div class="panel-title">自报自审检测</div>
<el-form :inline="true" size="small">
<el-form-item label="上报人">
<select-tree
ref="reporterTree"
type="tree_person"
:chenk-parent-node="false"
:echo-back="selfAuditForm.reporterId"
placeholder="请选择上报人"
style="width:220px"
@OrgNodeClick="node => handleSelfAuditSelect('reporterId', node)"
/>
</el-form-item>
<el-form-item label="审核人">
<select-tree
ref="auditorTree"
type="tree_person"
:chenk-parent-node="false"
:echo-back="selfAuditForm.auditorId"
placeholder="请选择审核人"
style="width:220px"
@OrgNodeClick="node => handleSelfAuditSelect('auditorId', node)"
/>
</el-form-item>
<el-form-item>
<el-button type="primary" size="mini" :loading="selfAuditing" @click="handleSelfAudit">检测</el-button>
</el-form-item>
</el-form>
<div v-if="selfAuditResult" class="panel-note">检测结果:{{ selfAuditResult }}</div>
</div>
<div class="panel">
<div class="panel-title">预警列表</div>
<el-alert
title="预警列表与处置(放行 / 冻结 24h / 黑名单)后端暂未提供接口,待补齐后再接入。"
type="warning"
:closable="false"
show-icon
/>
<div class="panel-note">防刷 4 维度开关已在「安全兑换管理」页维护,此处不重复。</div>
</div>
</el-tab-pane>
<!-- 审计日志 -->
<el-tab-pane label="审计日志" name="log">
<data-list-panel modern-table>
<template #filters>
<el-form :inline="true" size="small" class="data-list-filter-form">
<el-form-item label="操作类型">
<el-select v-model="queryParams.operationType" clearable placeholder="全部类型">
<el-option v-for="item in operationTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
</el-form-item>
<el-form-item label="操作人">
<el-input v-model="queryParams.operator" clearable placeholder="操作人" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item label="业务单号">
<el-input v-model="queryParams.bizId" clearable placeholder="业务单号" @keyup.enter.native="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleQuery">搜索</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="resetQuery">重置</el-button>
</el-form-item>
</el-form>
</template>
<template #actions>
<el-button icon="el-icon-refresh" size="mini" @click="getLogs">刷新</el-button>
</template>
<el-table v-loading="logLoading" :data="logList" size="mini" class="data-list-table">
<el-table-column type="expand">
<template slot-scope="scope">
<div class="log-detail">
<div><b>操作前:</b><pre>{{ formatJson(scope.row.beforeData) }}</pre></div>
<div><b>操作后:</b><pre>{{ formatJson(scope.row.afterData) }}</pre></div>
</div>
</template>
</el-table-column>
<el-table-column label="操作时间" width="170">
<template slot-scope="scope">{{ formatTime(scope.row.createTime) }}</template>
</el-table-column>
<el-table-column label="操作人" prop="operator" width="120" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.operator || '—' }}</template>
</el-table-column>
<el-table-column label="操作 IP" prop="operatorIp" width="140" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.operatorIp || '—' }}</template>
</el-table-column>
<el-table-column label="操作类型" width="130" align="center">
<template slot-scope="scope">
<el-tag :type="operationTypeTag(scope.row)" size="small">{{ operationTypeLabel(scope.row) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="业务单号" prop="bizId" min-width="150" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.bizId || '—' }}</template>
</el-table-column>
<el-table-column label="备注" prop="remark" min-width="180" show-overflow-tooltip>
<template slot-scope="scope">{{ scope.row.remark || '—' }}</template>
</el-table-column>
</el-table>
<template #footer>
<el-pagination
background
layout="total, sizes, prev, pager, next, jumper"
:total="logTotal"
:current-page.sync="queryParams.pageNum"
:page-size.sync="queryParams.pageSize"
:page-sizes="[10, 20, 50]"
@size-change="getLogs"
@current-change="getLogs"
/>
</template>
</data-list-panel>
</el-tab-pane>
</el-tabs>
</div>
</template>
<script>
import SelectTree from '@/components/SelectTree'
import { listAuditLogs, detectAbnormal, detectSelfAudit } from '@/api/risk'
const OPERATION_TYPES = [
{ value: 'AUDIT_PASS', label: '审核通过' },
{ value: 'POINTS_ADJUST', label: '积分调整' },
{ value: 'EXCHANGE_REFUND', label: '兑换退款' }
]
export default {
name: 'RiskAudit',
components: { SelectTree },
data() {
return {
activeTab: 'alert',
detectForm: { userId: '' },
selfAuditForm: { reporterId: '', auditorId: '' },
detecting: false,
selfAuditing: false,
detectResult: null,
selfAuditResult: '',
operationTypeOptions: OPERATION_TYPES,
logLoading: false,
logList: [],
logTotal: 0,
queryParams: { pageNum: 1, pageSize: 10, operationType: undefined, operator: undefined, bizId: undefined }
}
},
computed: {
detectChecks() {
const result = this.detectResult || {}
return [
{ key: 'report', name: '上报防刷', ...(result.reportCheck || {}) },
{ key: 'points', name: '积分获取防刷', ...(result.pointsEarnCheck || {}) },
{ key: 'exchange', name: '兑换防刷', ...(result.exchangeCheck || {}) }
]
}
},
created() {
this.getLogs()
},
methods: {
handleDetectUserSelect(node) {
this.$set(this.detectForm, 'userId', node.id)
},
handleSelfAuditSelect(field, node) {
this.$set(this.selfAuditForm, field, node.id)
},
handleDetect() {
if (!this.detectForm.userId) {
this.$modal.msgWarning('请先选择要检测的用户')
return
}
this.detecting = true
detectAbnormal(this.detectForm.userId)
.then(response => {
this.detectResult = response.data || response || {}
})
.catch(() => {
this.detectResult = null
this.$modal.msgError('异常行为检测失败,请稍后重试')
})
.finally(() => {
this.detecting = false
})
},
handleSelfAudit() {
if (!this.selfAuditForm.reporterId || !this.selfAuditForm.auditorId) {
this.$modal.msgWarning('请选择上报人和审核人')
return
}
this.selfAuditing = true
detectSelfAudit(this.selfAuditForm.reporterId, this.selfAuditForm.auditorId)
.then(response => {
this.selfAuditResult = response.data || response || '—'
})
.catch(() => {
this.selfAuditResult = ''
this.$modal.msgError('自报自审检测失败,请稍后重试')
})
.finally(() => {
this.selfAuditing = false
})
},
getLogs() {
this.logLoading = true
listAuditLogs(this.queryParams)
.then(response => {
const page = response.data || response
this.logList = page.list || page.rows || page.records || []
this.logTotal = Number(page.total || page.totalCount || this.logList.length)
})
.catch(() => {
this.logList = []
this.logTotal = 0
this.$modal.msgError('审计日志加载失败,请检查后端服务和登录状态')
})
.finally(() => {
this.logLoading = false
})
},
handleQuery() {
this.queryParams.pageNum = 1
this.getLogs()
},
resetQuery() {
this.queryParams = { pageNum: 1, pageSize: this.queryParams.pageSize, operationType: undefined, operator: undefined, bizId: undefined }
this.getLogs()
},
operationTypeLabel(row) {
const item = OPERATION_TYPES.find(option => option.value === row.operationType)
return item ? item.label : (row.operationType || '—')
},
operationTypeTag(row) {
const map = { AUDIT_PASS: 'primary', POINTS_ADJUST: 'warning', EXCHANGE_REFUND: 'success' }
return map[row.operationType] || 'info'
},
formatJson(value) {
if (!value) return '—'
try {
return JSON.stringify(JSON.parse(value), null, 2)
} catch (error) {
return String(value)
}
},
formatTime(value) {
if (!value) return '—'
return String(value).replace('T', ' ').slice(0, 19)
}
}
}
</script>
<style lang="scss" scoped>
.risk-audit {
min-height: calc(100vh - 52px);
}
.panel {
margin-bottom: 14px;
padding: 16px 18px;
background: var(--prototype-panel, #fff);
border: 1px solid var(--prototype-divider, #f0f0f2);
border-radius: var(--prototype-radius, 8px);
}
.panel-title {
margin-bottom: 12px;
color: var(--prototype-text, #1a1a2e);
font-size: 14px;
font-weight: 600;
}
.panel-note {
margin-top: 10px;
color: var(--prototype-text-muted, #9898ae);
font-size: 12px;
line-height: 20px;
}
.detect-result {
margin-top: 12px;
padding: 12px 14px;
border-radius: 8px;
background: var(--prototype-panel-soft, #fafbfc);
}
.detect-result__overall {
margin-bottom: 10px;
font-size: 13px;
font-weight: 600;
}
.detect-result__overall.is-pass {
color: #2d8a4e;
}
.detect-result__overall.is-fail {
color: #d64550;
}
.detect-result__item {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
font-size: 13px;
}
.detect-result__name {
width: 110px;
color: var(--prototype-text-secondary, #5a5a72);
}
.detect-result__reason {
color: var(--prototype-text-muted, #9898ae);
}
.log-detail {
padding: 8px 16px;
}
.log-detail pre {
margin: 4px 0 12px;
padding: 8px 10px;
max-height: 220px;
overflow: auto;
background: #f7f8fa;
border-radius: 6px;
color: var(--prototype-text, #1a1a2e);
font-size: 12px;
white-space: pre-wrap;
word-break: break-all;
}
</style>
Loading…
Cancel
Save