修复bug
This commit is contained in:
1 parent
1850ac640e
commit
892ba5886d
20 files changed
+1074
-174
No files matched your search
@@ -45,10 +45,20 @@ export function deleteReimbursement(id) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function submitReimbursement(id) {
|
export function submitReimbursement(id, comment = '') {
|
||||||
return request({
|
return request({
|
||||||
url: `/backend/reimbursements/${id}/submit`,
|
url: `/backend/reimbursements/${id}/submit`,
|
||||||
method: 'post'
|
method: 'post',
|
||||||
|
data: { comment }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 撤回审批中的报销单:撤掉运行中的审批实例并回到草稿,撤回后可继续编辑、再次提交
|
||||||
|
export function withdrawReimbursement(id, comment = '') {
|
||||||
|
return request({
|
||||||
|
url: `/backend/reimbursements/${id}/withdraw`,
|
||||||
|
method: 'post',
|
||||||
|
data: { comment }
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createComponentLoader } from '@/utils/pathResolver';
|
import { createComponentLoader } from '@/utils/pathResolver';
|
||||||
|
import { pickDefaultChild } from './menuDefaults';
|
||||||
|
|
||||||
function computeFullPath(menuPath, parentPath) {
|
function computeFullPath(menuPath, parentPath) {
|
||||||
if (!menuPath) return parentPath || '';
|
if (!menuPath) return parentPath || '';
|
||||||
@@ -105,7 +106,7 @@ function nestedMenuToRoute(menu, layoutAbsPath) {
|
|||||||
component: () => import('@/views/layouts/EmptyLayout.vue'),
|
component: () => import('@/views/layouts/EmptyLayout.vue'),
|
||||||
children: convertNestedMenuChildren(menu.children, childAbs),
|
children: convertNestedMenuChildren(menu.children, childAbs),
|
||||||
};
|
};
|
||||||
const firstChild = menu.children[0];
|
const firstChild = pickDefaultChild(menu.children);
|
||||||
if (firstChild && firstChild.path) {
|
if (firstChild && firstChild.path) {
|
||||||
const firstAbs = computeFullPath(firstChild.path, childAbs);
|
const firstAbs = computeFullPath(firstChild.path, childAbs);
|
||||||
const firstRel = toRelativeChildPath(childAbs, firstAbs);
|
const firstRel = toRelativeChildPath(childAbs, firstAbs);
|
||||||
@@ -193,9 +194,11 @@ export function convertMenusToRoutes(menus, parentPath = '') {
|
|||||||
} else if (hasChildren) {
|
} else if (hasChildren) {
|
||||||
route.component = () => import('@/views/layouts/EmptyLayout.vue');
|
route.component = () => import('@/views/layouts/EmptyLayout.vue');
|
||||||
route.children = convertMenusToRoutes(menu.children, fullPath);
|
route.children = convertMenusToRoutes(menu.children, fullPath);
|
||||||
const defaultChild = menu.children.find((child) =>
|
// CMS 内容统计是内置默认页,保持原行为;其余模块统一优先「工作台」
|
||||||
child.path === '/apps/cms/analytics/content' || child.path === 'analytics/content'
|
const defaultChild =
|
||||||
) || menu.children[0];
|
menu.children.find(
|
||||||
|
(child) => child.path === '/apps/cms/analytics/content' || child.path === 'analytics/content'
|
||||||
|
) || pickDefaultChild(menu.children);
|
||||||
if (defaultChild && defaultChild.path) {
|
if (defaultChild && defaultChild.path) {
|
||||||
const childFullPath = defaultChild.path.startsWith('/')
|
const childFullPath = defaultChild.path.startsWith('/')
|
||||||
? defaultChild.path
|
? defaultChild.path
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* 菜单默认落地页规则。
|
||||||
|
*
|
||||||
|
* 模块根菜单本身通常是目录,点击模块(如首页的 ERP 卡片)后由这里的规则
|
||||||
|
* 决定跳到哪个子菜单。规则按「工作台优先」收敛,避免依赖后端返回顺序
|
||||||
|
* 而落到某个先创建的页面(例如 ERP 的采购订单)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** 工作台/仪表盘入口的识别规则(标题、菜单路径、组件路径) */
|
||||||
|
const WORKBENCH_TITLE_RE = /工作台|仪表盘/;
|
||||||
|
const WORKBENCH_PATH_RE = /(^|\/)(dashboard|workbench)(\/|\.|$)/i;
|
||||||
|
|
||||||
|
export function isWorkbenchMenu(menu) {
|
||||||
|
if (!menu) return false;
|
||||||
|
return (
|
||||||
|
WORKBENCH_TITLE_RE.test(String(menu.title || '')) ||
|
||||||
|
WORKBENCH_PATH_RE.test(String(menu.path || '')) ||
|
||||||
|
WORKBENCH_PATH_RE.test(String(menu.component_path || ''))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 按 sort(其次 id)升序:后端菜单按 ID 返回,与侧栏按 sort 展示的顺序可能不一致 */
|
||||||
|
export function sortByMenuOrder(menus) {
|
||||||
|
return [...menus].sort((a, b) => {
|
||||||
|
const sortA = Number(a.sort) || 0;
|
||||||
|
const sortB = Number(b.sort) || 0;
|
||||||
|
if (sortA !== sortB) return sortA - sortB;
|
||||||
|
return (Number(a.id) || 0) - (Number(b.id) || 0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模块默认落地页:统一优先「工作台 / 仪表盘」,其次侧栏排在最前的子菜单。
|
||||||
|
*
|
||||||
|
* 直接取 children[0] 不可靠:后端按 ID 顺序返回,而侧栏是按 sort 展示的。
|
||||||
|
* 早期先创建的页面(例如 ERP 的采购订单)会因此成为模块默认页,
|
||||||
|
* 即使「工作台」已经排在 sort=1。
|
||||||
|
*/
|
||||||
|
export function pickDefaultChild(children) {
|
||||||
|
if (!children || children.length === 0) return null;
|
||||||
|
const sorted = sortByMenuOrder(children);
|
||||||
|
const workbench =
|
||||||
|
sorted.find(isWorkbenchMenu) ||
|
||||||
|
// 工作台被放成二级目录(如 ERP:工作台 -> 仪表盘)时,选这个目录,
|
||||||
|
// 由该目录自己的 redirect 落到具体工作台页面
|
||||||
|
sorted.find((child) => Array.isArray(child.children) && child.children.some(isWorkbenchMenu));
|
||||||
|
return workbench || sorted[0];
|
||||||
|
}
|
||||||
@@ -3,6 +3,14 @@ import { ref, computed } from 'vue'
|
|||||||
// import { getUserInfo } from '@/utils/auth'
|
// import { getUserInfo } from '@/utils/auth'
|
||||||
import { getMenus } from '@/api/menu';
|
import { getMenus } from '@/api/menu';
|
||||||
|
|
||||||
|
// 是否属于「登录态失效」类错误。
|
||||||
|
// request.js 已给鉴权错误打上 status=401,同时兼容历史文案与 axios 原始错误。
|
||||||
|
const isAuthError = (err) =>
|
||||||
|
err?.status === 401 ||
|
||||||
|
err?.response?.status === 401 ||
|
||||||
|
err?.isAuthError === true ||
|
||||||
|
/token已过期|token无效|无效的token/.test(err?.message || '');
|
||||||
|
|
||||||
export const useMenuStore = defineStore('menu', () => {
|
export const useMenuStore = defineStore('menu', () => {
|
||||||
// 菜单数据
|
// 菜单数据
|
||||||
const menus = ref([]);
|
const menus = ref([]);
|
||||||
@@ -167,6 +175,18 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
throw new Error(res.message || '获取菜单失败:响应格式错误');
|
throw new Error(res.message || '获取菜单失败:响应格式错误');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error.value = err.message || '获取菜单失败';
|
error.value = err.message || '获取菜单失败';
|
||||||
|
|
||||||
|
// 鉴权失败(令牌过期/无效、会话被吊销):清掉缓存并继续抛出,
|
||||||
|
// 由 request.js 静默续期或跳认证中心。
|
||||||
|
// 不能回退到缓存:否则令牌过期时会用旧菜单把登录态问题掩盖掉,
|
||||||
|
// 用户看到的是一份已经失效的权限菜单。
|
||||||
|
if (isAuthError(err)) {
|
||||||
|
clearCache();
|
||||||
|
menus.value = [];
|
||||||
|
console.warn('获取菜单失败(登录状态失效):', err.message);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
console.error('获取菜单失败:', err);
|
console.error('获取菜单失败:', err);
|
||||||
console.error('错误详情:', {
|
console.error('错误详情:', {
|
||||||
message: err.message,
|
message: err.message,
|
||||||
@@ -174,13 +194,6 @@ export const useMenuStore = defineStore('menu', () => {
|
|||||||
stack: err.stack
|
stack: err.stack
|
||||||
});
|
});
|
||||||
|
|
||||||
// 如果是 token 无效错误,不使用缓存,直接抛出
|
|
||||||
if (err.message === 'token无效' || err.response?.status === 401) {
|
|
||||||
clearCache();
|
|
||||||
menus.value = [];
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果出错,尝试使用缓存数据
|
// 如果出错,尝试使用缓存数据
|
||||||
const cachedMenus = loadFromCache();
|
const cachedMenus = loadFromCache();
|
||||||
if (cachedMenus && cachedMenus.length > 0) {
|
if (cachedMenus && cachedMenus.length > 0) {
|
||||||
|
|||||||
@@ -14,6 +14,72 @@ const service = axios.create({
|
|||||||
withCredentials: false // JWT 不需要 Cookie
|
withCredentials: false // JWT 不需要 Cookie
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 构造鉴权失败错误:带上 status,调用方(如菜单 store)可直接按 401 判断,
|
||||||
|
// 不必依赖中文文案;否则文案一改,登录态判断就静默失效了。
|
||||||
|
function authError(message) {
|
||||||
|
const err = new Error(message || '登录状态已失效,请重新登录');
|
||||||
|
err.status = 401;
|
||||||
|
err.isAuthError = true;
|
||||||
|
return err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空登录态并回认证中心重新登录(只跳一次,避免并发请求反复跳转)
|
||||||
|
function redirectToLoginOnce() {
|
||||||
|
clearTokens();
|
||||||
|
localStorage.removeItem('userInfo');
|
||||||
|
if (ssoRedirecting) return;
|
||||||
|
ssoRedirecting = true;
|
||||||
|
redirectToAuthorize().catch((err) => {
|
||||||
|
console.error('跳转统一认证失败:', err);
|
||||||
|
ssoRedirecting = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 401 统一处理:
|
||||||
|
// 阶段一:并发场景下,本请求带的是旧令牌,而别的请求已换成新令牌 → 直接用最新令牌重放;
|
||||||
|
// 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次;
|
||||||
|
// 阶段三:续期失败 / 续期后仍 401 → 清登录态回认证中心。
|
||||||
|
// 「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,只挡一次;
|
||||||
|
// 否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。
|
||||||
|
async function recoverAuth(config, bizMsg) {
|
||||||
|
const latestToken = localStorage.getItem('token') || '';
|
||||||
|
const sentToken = String(config?.headers?.Authorization || '').replace(/^Bearer\s+/i, '');
|
||||||
|
if (!config?._replayedWithLatest && latestToken && latestToken !== sentToken) {
|
||||||
|
console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', config?.url);
|
||||||
|
config.headers['Authorization'] = `Bearer ${latestToken}`;
|
||||||
|
config._replayedWithLatest = true;
|
||||||
|
return service.request(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config?._refreshed) {
|
||||||
|
// 续期拿到的令牌仍被判定过期/无效:继续留在页面只会一路报错,直接回认证中心
|
||||||
|
redirectToLoginOnce();
|
||||||
|
return Promise.reject(authError(bizMsg));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const newToken = await refreshAccessToken();
|
||||||
|
config.headers['Authorization'] = `Bearer ${newToken}`;
|
||||||
|
config._refreshed = true;
|
||||||
|
return service.request(config);
|
||||||
|
} catch (e) {
|
||||||
|
redirectToLoginOnce();
|
||||||
|
return Promise.reject(authError('登录状态已失效,请重新登录'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 令牌类错误提示。后端部分接口(全局鉴权中间件为 warn 模式时控制器自行鉴权)
|
||||||
|
// 会在 HTTP 200 的 body 里返回 code:401,需要按提示词识别。
|
||||||
|
// 不能把所有 body code:401 都当令牌问题:个别接口(如新增用户时校验原密码)
|
||||||
|
// 也用 401 表示业务失败,那类响应重放没有意义,更不能因此把用户登出。
|
||||||
|
const TOKEN_ERROR_HINTS = ['token已过期', '无效的token', '未提供认证信息', '认证信息格式错误'];
|
||||||
|
|
||||||
|
function isTokenFailure(body) {
|
||||||
|
if (!body || Number(body.code) !== 401) return false;
|
||||||
|
const msg = String(body.msg || body.message || '');
|
||||||
|
return TOKEN_ERROR_HINTS.some((hint) => msg.includes(hint));
|
||||||
|
}
|
||||||
|
|
||||||
// 请求拦截器
|
// 请求拦截器
|
||||||
service.interceptors.request.use(
|
service.interceptors.request.use(
|
||||||
config => {
|
config => {
|
||||||
@@ -42,7 +108,12 @@ service.interceptors.request.use(
|
|||||||
// 响应拦截器
|
// 响应拦截器
|
||||||
service.interceptors.response.use(
|
service.interceptors.response.use(
|
||||||
response => {
|
response => {
|
||||||
return response.data;
|
const data = response.data;
|
||||||
|
// 兜住 HTTP 200 + body code:401 的令牌失效响应,让它走与真实 401 相同的续期流程
|
||||||
|
if (isTokenFailure(data)) {
|
||||||
|
return recoverAuth(response.config, data.msg || data.message);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
},
|
},
|
||||||
async error => {
|
async error => {
|
||||||
if (error.response) {
|
if (error.response) {
|
||||||
@@ -50,43 +121,8 @@ service.interceptors.response.use(
|
|||||||
// 避免页面只显示 "Request failed with status code xxx"
|
// 避免页面只显示 "Request failed with status code xxx"
|
||||||
const bizMsg = error.response.data?.msg || error.response.data?.message;
|
const bizMsg = error.response.data?.msg || error.response.data?.message;
|
||||||
switch (error.response.status) {
|
switch (error.response.status) {
|
||||||
case 401: {
|
case 401:
|
||||||
// 阶段一:并发场景下,本请求可能带的是「旧令牌」,而别的请求已经把令牌换成新的
|
return recoverAuth(error.config, bizMsg);
|
||||||
// (登录回跳、另一请求的静默续期都会改 localStorage)。
|
|
||||||
// 此时直接用最新令牌重放,免得白跑一次续期。
|
|
||||||
const latestToken = localStorage.getItem('token') || '';
|
|
||||||
const sentToken = String(error.config?.headers?.Authorization || '').replace(/^Bearer\s+/i, '');
|
|
||||||
if (!error.config?._replayedWithLatest && latestToken && latestToken !== sentToken) {
|
|
||||||
console.warn('[auth] 请求携带的令牌已过期,改用最新令牌重放:', error.config?.url);
|
|
||||||
error.config.headers['Authorization'] = `Bearer ${latestToken}`;
|
|
||||||
error.config._replayedWithLatest = true;
|
|
||||||
return service.request(error.config);
|
|
||||||
}
|
|
||||||
// 阶段二:仍 401 → 用 refresh_token 静默续期后重放一次。
|
|
||||||
// 注意「重放」与「续期」是两个独立阶段:前者不消耗 refresh_token,
|
|
||||||
// 只挡一次;否则令牌真的过期时就永远等不到续期,只会一路报「无效的token」。
|
|
||||||
if (error.config?._refreshed) {
|
|
||||||
return Promise.reject(new Error(bizMsg || 'token无效,请重新登录'));
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const newToken = await refreshAccessToken();
|
|
||||||
error.config.headers['Authorization'] = `Bearer ${newToken}`;
|
|
||||||
error.config._refreshed = true;
|
|
||||||
return service.request(error.config);
|
|
||||||
} catch (e) {
|
|
||||||
// 续期失败:清空登录态并跳认证中心重新登录(只跳一次,避免死循环)
|
|
||||||
clearTokens();
|
|
||||||
localStorage.removeItem('userInfo');
|
|
||||||
if (!ssoRedirecting) {
|
|
||||||
ssoRedirecting = true;
|
|
||||||
redirectToAuthorize().catch((err) => {
|
|
||||||
console.error('跳转统一认证失败:', err);
|
|
||||||
ssoRedirecting = false;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return Promise.reject(new Error('token无效,请重新登录'));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
case 404:
|
case 404:
|
||||||
console.error('请求的资源不存在');
|
console.error('请求的资源不存在');
|
||||||
break;
|
break;
|
||||||
|
|||||||
@@ -20,10 +20,19 @@
|
|||||||
<el-button size="small" :loading="amountComparing" @click="handleCompareAmounts">
|
<el-button size="small" :loading="amountComparing" @click="handleCompareAmounts">
|
||||||
核对发票金额
|
核对发票金额
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button v-if="isDraft" type="primary" size="small" @click="openAddItem">添加费用</el-button>
|
<el-button type="primary" size="small" :disabled="!isEditable" @click="openAddItem">添加费用</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<el-alert
|
||||||
|
v-if="!isEditable"
|
||||||
|
class="edit-lock-tip"
|
||||||
|
type="info"
|
||||||
|
:closable="false"
|
||||||
|
show-icon
|
||||||
|
:title="editLockTip"
|
||||||
|
/>
|
||||||
|
|
||||||
<div v-if="amountCompare" class="amount-compare-card">
|
<div v-if="amountCompare" class="amount-compare-card">
|
||||||
<div class="amount-compare-summary">
|
<div class="amount-compare-summary">
|
||||||
<div class="compare-item">
|
<div class="compare-item">
|
||||||
@@ -75,7 +84,7 @@
|
|||||||
</el-table>
|
</el-table>
|
||||||
</div>
|
</div>
|
||||||
<div class="compare-actions">
|
<div class="compare-actions">
|
||||||
<el-button v-if="isDraft" type="primary" size="small" :loading="amountSaving" @click="handleSaveReceiptAmounts">
|
<el-button v-if="isEditable" type="primary" size="small" :loading="amountSaving" @click="handleSaveReceiptAmounts">
|
||||||
保存发票金额
|
保存发票金额
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button size="small" @click="closeAmountCompare">取消</el-button>
|
<el-button size="small" @click="closeAmountCompare">取消</el-button>
|
||||||
@@ -105,7 +114,7 @@
|
|||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
<el-table-column prop="description" label="描述" min-width="160" show-overflow-tooltip />
|
||||||
<el-table-column label="操作" :width="isDraft ? 176 : 70" fixed="right">
|
<el-table-column label="操作" width="176" fixed="right">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<div class="item-actions">
|
<div class="item-actions">
|
||||||
<el-button
|
<el-button
|
||||||
@@ -118,26 +127,38 @@
|
|||||||
>
|
>
|
||||||
<i class="fa-solid fa-eye"></i>
|
<i class="fa-solid fa-eye"></i>
|
||||||
</el-button>
|
</el-button>
|
||||||
<template v-if="isDraft">
|
|
||||||
<el-button
|
<el-button
|
||||||
link
|
link
|
||||||
type="success"
|
type="success"
|
||||||
title="上传发票(可多选,追加到当前费用项)"
|
:title="isEditable ? '上传发票(可多选,追加到当前费用项)' : '当前状态不可编辑费用明细'"
|
||||||
aria-label="上传发票"
|
aria-label="上传发票"
|
||||||
:disabled="batchUploading"
|
:disabled="!isEditable || batchUploading"
|
||||||
@click="openInvoiceFilePicker(row)"
|
@click="openInvoiceFilePicker(row)"
|
||||||
>
|
>
|
||||||
<i
|
<i
|
||||||
:class="batchUploading ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-file-arrow-up'"
|
:class="batchUploading ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-file-arrow-up'"
|
||||||
></i>
|
></i>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="primary" title="编辑" aria-label="编辑" @click="openEditItem(row)">
|
<el-button
|
||||||
|
link
|
||||||
|
type="primary"
|
||||||
|
:title="isEditable ? '编辑' : '当前状态不可编辑费用明细'"
|
||||||
|
aria-label="编辑"
|
||||||
|
:disabled="!isEditable"
|
||||||
|
@click="openEditItem(row)"
|
||||||
|
>
|
||||||
<i class="fa-solid fa-pen-to-square"></i>
|
<i class="fa-solid fa-pen-to-square"></i>
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button link type="danger" title="删除" aria-label="删除" @click="removeItem(row)">
|
<el-button
|
||||||
|
link
|
||||||
|
type="danger"
|
||||||
|
:title="isEditable ? '删除' : '当前状态不可编辑费用明细'"
|
||||||
|
aria-label="删除"
|
||||||
|
:disabled="!isEditable"
|
||||||
|
@click="removeItem(row)"
|
||||||
|
>
|
||||||
<i class="fa-regular fa-trash-can"></i>
|
<i class="fa-regular fa-trash-can"></i>
|
||||||
</el-button>
|
</el-button>
|
||||||
</template>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -191,7 +212,7 @@
|
|||||||
<i v-else class="fa-regular fa-file-pdf receipt-thumb-pdf"></i>
|
<i v-else class="fa-regular fa-file-pdf receipt-thumb-pdf"></i>
|
||||||
<span class="receipt-thumb-name" :title="receipt.name">{{ receipt.name }}</span>
|
<span class="receipt-thumb-name" :title="receipt.name">{{ receipt.name }}</span>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isDraft"
|
v-if="isEditable"
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
class="receipt-delete-btn"
|
class="receipt-delete-btn"
|
||||||
@@ -227,8 +248,11 @@
|
|||||||
:timestamp="formatDateTime(record.create_time)"
|
:timestamp="formatDateTime(record.create_time)"
|
||||||
placement="top"
|
placement="top"
|
||||||
>
|
>
|
||||||
<strong>{{ actionLabel(record.action) }} · {{ record.approver_name || '-' }}</strong>
|
<div class="record-head">
|
||||||
<div v-if="record.comment" class="record-comment">{{ record.comment }}</div>
|
<strong>{{ actionLabel(record.action) }}</strong>
|
||||||
|
<span class="record-operator">操作人:{{ record.approver_name || '-' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="record-comment">操作理由:{{ record.comment || defaultRecordReason(record) }}</div>
|
||||||
</el-timeline-item>
|
</el-timeline-item>
|
||||||
</el-timeline>
|
</el-timeline>
|
||||||
<el-empty v-else description="暂无审批记录" :image-size="60" />
|
<el-empty v-else description="暂无审批记录" :image-size="60" />
|
||||||
@@ -244,6 +268,14 @@
|
|||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="detail-toolbar">
|
<div class="detail-toolbar">
|
||||||
|
<el-button
|
||||||
|
v-if="isApproving"
|
||||||
|
type="warning"
|
||||||
|
:loading="withdrawing"
|
||||||
|
@click="handleWithdraw"
|
||||||
|
>
|
||||||
|
撤回并编辑
|
||||||
|
</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isApproved"
|
v-if="isApproved"
|
||||||
type="success"
|
type="success"
|
||||||
@@ -254,12 +286,12 @@
|
|||||||
</el-button>
|
</el-button>
|
||||||
<div class="toolbar-right">
|
<div class="toolbar-right">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="isDraft"
|
v-if="isEditable"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="submitting"
|
:loading="submitting"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
提交审核
|
{{ isDraft ? "提交审核" : "重新提交" }}
|
||||||
</el-button>
|
</el-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -271,7 +303,7 @@
|
|||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { ElMessage, ElMessageBox } from 'element-plus'
|
import { ElMessage, ElMessageBox } from 'element-plus'
|
||||||
import { uploadFile } from '@/api/file'
|
import { uploadFile } from '@/api/file'
|
||||||
import { deleteReimbursementItem, submitReimbursement, updateReimbursementItem, updateReimbursementStatus } from '@/api/reimburse'
|
import { deleteReimbursementItem, submitReimbursement, updateReimbursementItem, updateReimbursementStatus, withdrawReimbursement } from '@/api/reimburse'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import {
|
import {
|
||||||
compareReimbursementAmounts,
|
compareReimbursementAmounts,
|
||||||
@@ -302,16 +334,34 @@ const statusOptions = [
|
|||||||
{ value: 4, label: '已撤回' },
|
{ value: 4, label: '已撤回' },
|
||||||
{ value: 5, label: '已打款' }
|
{ value: 5, label: '已打款' }
|
||||||
]
|
]
|
||||||
const statusLabel = value => statusOptions.find(item => item.value === value)?.label || '未知'
|
const statusLabel = value => statusOptions.find(item => item.value === Number(value))?.label || '未知'
|
||||||
const statusType = value => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'success' })[value] || 'info'
|
const statusType = value => ({ 0: 'info', 1: 'warning', 2: 'success', 3: 'danger', 4: 'info', 5: 'success' })[Number(value)] || 'info'
|
||||||
|
|
||||||
const visible = computed({
|
const visible = computed({
|
||||||
get: () => props.modelValue,
|
get: () => props.modelValue,
|
||||||
set: value => emit('update:modelValue', value)
|
set: value => emit('update:modelValue', value)
|
||||||
})
|
})
|
||||||
const isDraft = computed(() => props.detail?.status === 0)
|
const isDraft = computed(() => Number(props.detail?.status) === 0)
|
||||||
|
// 可编辑状态:草稿、已驳回、已撤回;审批中需先撤回
|
||||||
|
const isEditable = computed(() => [0, 3, 4].includes(Number(props.detail?.status)))
|
||||||
|
// 审批中:可撤回到草稿后继续编辑
|
||||||
|
const isApproving = computed(() => Number(props.detail?.status) === 1)
|
||||||
// 审批通过(等待报销人确认款项到账)
|
// 审批通过(等待报销人确认款项到账)
|
||||||
const isApproved = computed(() => props.detail?.status === 2)
|
const isApproved = computed(() => Number(props.detail?.status) === 2)
|
||||||
|
|
||||||
|
// 非可编辑状态时说明原因,避免按钮置灰后无从判断
|
||||||
|
const editLockTip = computed(() => {
|
||||||
|
switch (Number(props.detail?.status)) {
|
||||||
|
case 1:
|
||||||
|
return '审批中不可编辑费用明细;如需修改,请点击底部「撤回并编辑」退回草稿。'
|
||||||
|
case 2:
|
||||||
|
return '审批已通过,费用明细不可编辑。'
|
||||||
|
case 5:
|
||||||
|
return '款项已打款,费用明细不可编辑。'
|
||||||
|
default:
|
||||||
|
return '当前状态不可编辑费用明细。'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const detailApplicantName = computed(() => {
|
const detailApplicantName = computed(() => {
|
||||||
const name = props.detail?.user_name
|
const name = props.detail?.user_name
|
||||||
@@ -326,6 +376,7 @@ const detailApplicantName = computed(() => {
|
|||||||
return '-'
|
return '-'
|
||||||
})
|
})
|
||||||
const submitting = ref(false)
|
const submitting = ref(false)
|
||||||
|
const withdrawing = ref(false)
|
||||||
const manualSaving = ref(false)
|
const manualSaving = ref(false)
|
||||||
const itemVisible = ref(false)
|
const itemVisible = ref(false)
|
||||||
const batchUploading = ref(false)
|
const batchUploading = ref(false)
|
||||||
@@ -681,9 +732,19 @@ const handleItemsSaved = reimbursementId => {
|
|||||||
const handleSubmit = async () => {
|
const handleSubmit = async () => {
|
||||||
if (!props.detail?.id) return
|
if (!props.detail?.id) return
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm('提交后将进入审批流程,确认提交吗?', '提示', { type: 'warning' })
|
const { value: comment } = await ElMessageBox.prompt(
|
||||||
|
'提交后将进入审批流程,可填写提交说明,将记录在审批时间线中。',
|
||||||
|
'提交审核',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '提交',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputPlaceholder: '请输入提交说明(选填)'
|
||||||
|
}
|
||||||
|
)
|
||||||
submitting.value = true
|
submitting.value = true
|
||||||
await submitReimbursement(props.detail.id)
|
await submitReimbursement(props.detail.id, comment || '')
|
||||||
ElMessage.success('提交成功')
|
ElMessage.success('提交成功')
|
||||||
emit('changed', props.detail.id)
|
emit('changed', props.detail.id)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -695,6 +756,35 @@ const handleSubmit = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 撤回审批中的单据:回到草稿后可继续编辑费用条目,再重新提交
|
||||||
|
const handleWithdraw = async () => {
|
||||||
|
if (!props.detail?.id) return
|
||||||
|
try {
|
||||||
|
const { value: comment } = await ElMessageBox.prompt(
|
||||||
|
'撤回后单据将回到草稿,可继续编辑费用条目并重新提交。',
|
||||||
|
'撤回确认',
|
||||||
|
{
|
||||||
|
type: 'warning',
|
||||||
|
confirmButtonText: '确认撤回',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
inputType: 'textarea',
|
||||||
|
inputPlaceholder: '请输入撤回原因',
|
||||||
|
inputValidator: value => (String(value || '').trim() ? true : '请输入撤回原因')
|
||||||
|
}
|
||||||
|
)
|
||||||
|
withdrawing.value = true
|
||||||
|
await withdrawReimbursement(props.detail.id, String(comment || '').trim())
|
||||||
|
ElMessage.success('已撤回,可继续编辑')
|
||||||
|
emit('changed', props.detail.id)
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== 'cancel' && error !== 'close') {
|
||||||
|
ElMessage.error(error?.message || '撤回失败')
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
withdrawing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 审批通过后由报销人本人确认款项到账,单据置为「已打款」
|
// 审批通过后由报销人本人确认款项到账,单据置为「已打款」
|
||||||
const confirmPaid = async () => {
|
const confirmPaid = async () => {
|
||||||
if (!props.detail?.id) return
|
if (!props.detail?.id) return
|
||||||
@@ -899,6 +989,23 @@ const removeReceipt = async receipt => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const actionLabel = value => ({ 1: '提交申请', 2: '审批通过', 3: '审批驳回', 4: '转审', 5: '撤回' })[value] || '审批操作'
|
const actionLabel = value => ({ 1: '提交申请', 2: '审批通过', 3: '审批驳回', 4: '转审', 5: '撤回' })[value] || '审批操作'
|
||||||
|
// 老记录没有审批意见时,用动作补一条可读的说明,避免时间线只显示 “-”
|
||||||
|
const defaultRecordReason = record => {
|
||||||
|
switch (Number(record?.action)) {
|
||||||
|
case 1:
|
||||||
|
return '提交报销申请'
|
||||||
|
case 2:
|
||||||
|
return '审批通过'
|
||||||
|
case 3:
|
||||||
|
return '审批驳回'
|
||||||
|
case 4:
|
||||||
|
return '转交他人审批'
|
||||||
|
case 5:
|
||||||
|
return '报销人主动撤回'
|
||||||
|
default:
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
}
|
||||||
const money = value => Number(value || 0).toFixed(2)
|
const money = value => Number(value || 0).toFixed(2)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -916,14 +1023,32 @@ const money = value => Number(value || 0).toFixed(2)
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.edit-lock-tip {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.detail-title {
|
.detail-title {
|
||||||
margin: 0 0 12px;
|
margin: 0 0 12px;
|
||||||
color: #303133;
|
color: #303133;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.record-operator {
|
||||||
|
color: #909399;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
.record-comment {
|
.record-comment {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
color: #606266;
|
color: #606266;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.receipt-preview {
|
.receipt-preview {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
title="保存报销单后,可在详情中逐条添加报销事项。"
|
title="保存后为草稿,不会提交审批;可在详情中逐条添加报销事项,确认无误后再到列表操作栏点「提交审核」。"
|
||||||
type="info"
|
type="info"
|
||||||
:closable="false"
|
:closable="false"
|
||||||
show-icon
|
show-icon
|
||||||
@@ -56,7 +56,7 @@ const cloneForm = data => ({
|
|||||||
|
|
||||||
const form = reactive(cloneForm(props.formData))
|
const form = reactive(cloneForm(props.formData))
|
||||||
const formRef = ref()
|
const formRef = ref()
|
||||||
const submitLabel = computed(() => (props.isEdit ? '保存' : '创建'))
|
const submitLabel = computed(() => (props.isEdit ? '保存' : '保存草稿'))
|
||||||
const formRules = {
|
const formRules = {
|
||||||
apply_date: [{ required: true, message: '请选择申请日期', trigger: 'change' }]
|
apply_date: [{ required: true, message: '请选择申请日期', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,7 +93,14 @@
|
|||||||
>详情</el-button
|
>详情</el-button
|
||||||
>
|
>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.status === 2"
|
v-if="isStatus(row, 1)"
|
||||||
|
link
|
||||||
|
type="warning"
|
||||||
|
@click="handleWithdraw(row)"
|
||||||
|
>撤回</el-button
|
||||||
|
>
|
||||||
|
<el-button
|
||||||
|
v-if="isStatus(row, 2)"
|
||||||
link
|
link
|
||||||
type="success"
|
type="success"
|
||||||
@click="handleConfirmPaid(row)"
|
@click="handleConfirmPaid(row)"
|
||||||
@@ -107,21 +114,21 @@
|
|||||||
>导出</el-button
|
>导出</el-button
|
||||||
>
|
>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.status === 0"
|
v-if="isEditable(row)"
|
||||||
link
|
link
|
||||||
type="primary"
|
type="primary"
|
||||||
@click="openEdit(row.id)"
|
@click="openEdit(row.id)"
|
||||||
>编辑</el-button
|
>编辑</el-button
|
||||||
>
|
>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.status === 0"
|
v-if="isEditable(row)"
|
||||||
link
|
link
|
||||||
type="success"
|
type="success"
|
||||||
@click="handleSubmit(row)"
|
@click="handleSubmit(row)"
|
||||||
>提交</el-button
|
>{{ isStatus(row, 0) ? "提交审核" : "重新提交" }}</el-button
|
||||||
>
|
>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="row.status === 0"
|
v-if="isStatus(row, 0)"
|
||||||
link
|
link
|
||||||
type="danger"
|
type="danger"
|
||||||
@click="handleDelete(row)"
|
@click="handleDelete(row)"
|
||||||
@@ -189,6 +196,7 @@ import {
|
|||||||
submitReimbursement,
|
submitReimbursement,
|
||||||
updateReimbursement,
|
updateReimbursement,
|
||||||
updateReimbursementStatus,
|
updateReimbursementStatus,
|
||||||
|
withdrawReimbursement,
|
||||||
} from "@/api/reimburse";
|
} from "@/api/reimburse";
|
||||||
|
|
||||||
const statusOptions = [
|
const statusOptions = [
|
||||||
@@ -286,11 +294,22 @@ const openEdit = async (id) => {
|
|||||||
const saveForm = async (payload) => {
|
const saveForm = async (payload) => {
|
||||||
saving.value = true;
|
saving.value = true;
|
||||||
try {
|
try {
|
||||||
if (editingId.value) await updateReimbursement(editingId.value, payload);
|
let createdId = 0;
|
||||||
else await createReimbursement(payload);
|
if (editingId.value) {
|
||||||
ElMessage.success("保存成功");
|
await updateReimbursement(editingId.value, payload);
|
||||||
|
} else {
|
||||||
|
// 新建只保存为草稿,不提交审批;审核需在列表操作栏点「提交审核」
|
||||||
|
const res = await createReimbursement(payload);
|
||||||
|
createdId = Number(res?.data?.id || res?.id || 0);
|
||||||
|
}
|
||||||
|
ElMessage.success(editingId.value ? "保存成功" : "已保存为草稿,可继续添加费用明细");
|
||||||
formVisible.value = false;
|
formVisible.value = false;
|
||||||
loadList();
|
if (createdId) {
|
||||||
|
// 直接打开详情继续编辑,草稿状态下可反复修改
|
||||||
|
await openDetail(createdId);
|
||||||
|
} else {
|
||||||
|
loadList();
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error(error?.message || "保存失败");
|
ElMessage.error(error?.message || "保存失败");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -300,10 +319,18 @@ const saveForm = async (payload) => {
|
|||||||
|
|
||||||
const handleSubmit = async (row) => {
|
const handleSubmit = async (row) => {
|
||||||
try {
|
try {
|
||||||
await ElMessageBox.confirm("提交后将进入审批流程,确认提交吗?", "提示", {
|
const { value: comment } = await ElMessageBox.prompt(
|
||||||
type: "warning",
|
"提交后将进入审批流程,可填写提交说明,将记录在审批时间线中。",
|
||||||
});
|
"提交审核",
|
||||||
await submitReimbursement(row.id);
|
{
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "提交",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
inputType: "textarea",
|
||||||
|
inputPlaceholder: "请输入提交说明(选填)",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await submitReimbursement(row.id, comment || "");
|
||||||
ElMessage.success("提交成功");
|
ElMessage.success("提交成功");
|
||||||
loadList();
|
loadList();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -312,6 +339,31 @@ const handleSubmit = async (row) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 撤回审批中的单据:回到草稿后可继续编辑费用条目,再重新提交
|
||||||
|
const handleWithdraw = async (row) => {
|
||||||
|
try {
|
||||||
|
const { value: comment } = await ElMessageBox.prompt(
|
||||||
|
"撤回后单据将回到草稿,可继续编辑费用条目并重新提交。",
|
||||||
|
"撤回确认",
|
||||||
|
{
|
||||||
|
type: "warning",
|
||||||
|
confirmButtonText: "确认撤回",
|
||||||
|
cancelButtonText: "取消",
|
||||||
|
inputType: "textarea",
|
||||||
|
inputPlaceholder: "请输入撤回原因",
|
||||||
|
inputValidator: (value) =>
|
||||||
|
String(value || "").trim() ? true : "请输入撤回原因",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
await withdrawReimbursement(row.id, String(comment || "").trim());
|
||||||
|
ElMessage.success("已撤回,可继续编辑");
|
||||||
|
loadList();
|
||||||
|
} catch (error) {
|
||||||
|
if (error !== "cancel" && error !== "close")
|
||||||
|
ElMessage.error(error?.message || "撤回失败");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// 审批通过后由报销人本人确认款项到账,确认后状态变为「已打款」
|
// 审批通过后由报销人本人确认款项到账,确认后状态变为「已打款」
|
||||||
const handleConfirmPaid = async (row) => {
|
const handleConfirmPaid = async (row) => {
|
||||||
try {
|
try {
|
||||||
@@ -384,7 +436,11 @@ const formatDate = (value) => {
|
|||||||
return formatDateUtil(value);
|
return formatDateUtil(value);
|
||||||
};
|
};
|
||||||
const statusLabel = (value) =>
|
const statusLabel = (value) =>
|
||||||
statusOptions.find((item) => item.value === value)?.label || "未知";
|
statusOptions.find((item) => item.value === Number(value))?.label || "未知";
|
||||||
|
// 兼容后端把状态序列化为字符串的情况,避免编辑/提交按钮被误隐藏
|
||||||
|
const isStatus = (row, status) => Number(row?.status) === status;
|
||||||
|
// 可编辑状态:草稿、已驳回、已撤回;审批中需先撤回
|
||||||
|
const isEditable = (row) => [0, 3, 4].includes(Number(row?.status));
|
||||||
const statusType = (value) =>
|
const statusType = (value) =>
|
||||||
({
|
({
|
||||||
0: "info",
|
0: "info",
|
||||||
@@ -393,7 +449,7 @@ const statusType = (value) =>
|
|||||||
3: "danger",
|
3: "danger",
|
||||||
4: "info",
|
4: "info",
|
||||||
5: "success",
|
5: "success",
|
||||||
})[value] || "info";
|
})[Number(value)] || "info";
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
checkMobile();
|
checkMobile();
|
||||||
window.addEventListener("resize", checkMobile);
|
window.addEventListener("resize", checkMobile);
|
||||||
|
|||||||
+37
-30
@@ -669,41 +669,48 @@ func (c *AppOaController) oaDashboardReimburse(tid, uid uint64) map[string]inter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
expenseTypes := []map[string]interface{}{}
|
expenseTypes := []map[string]interface{}{}
|
||||||
|
paidIDList := make([]uint64, 0, len(paidIDs))
|
||||||
|
for id := range paidIDs {
|
||||||
|
paidIDList = append(paidIDList, id)
|
||||||
|
}
|
||||||
var items []models.BackendReimbursementItem
|
var items []models.BackendReimbursementItem
|
||||||
if _, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
if len(paidIDList) > 0 {
|
||||||
Filter("tid", tid).Filter("uid", uid).Filter("is_deleted", 0).
|
// 按已打款主单 ID 查明细,不依赖明细行的 tid/uid(历史数据可能不一致)
|
||||||
All(&items); err == nil {
|
if _, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
||||||
typeAgg := map[string]*struct {
|
Filter("reimbursement_id__in", paidIDList).Filter("is_deleted", 0).
|
||||||
count int
|
All(&items); err == nil {
|
||||||
amount float64
|
typeAgg := map[string]*struct {
|
||||||
}{}
|
count int
|
||||||
for i := range items {
|
amount float64
|
||||||
if !paidIDs[items[i].ReimbursementID] {
|
}{}
|
||||||
continue
|
for i := range items {
|
||||||
|
if !paidIDs[items[i].ReimbursementID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(items[i].ExpenseType)
|
||||||
|
if name == "" {
|
||||||
|
name = "未分类"
|
||||||
|
}
|
||||||
|
agg, ok := typeAgg[name]
|
||||||
|
if !ok {
|
||||||
|
agg = &struct {
|
||||||
|
count int
|
||||||
|
amount float64
|
||||||
|
}{}
|
||||||
|
typeAgg[name] = agg
|
||||||
|
}
|
||||||
|
agg.count++
|
||||||
|
agg.amount += items[i].Amount
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(items[i].ExpenseType)
|
for name, agg := range typeAgg {
|
||||||
if name == "" {
|
expenseTypes = append(expenseTypes, map[string]interface{}{
|
||||||
name = "未分类"
|
"name": name, "count": agg.count, "amount": agg.amount,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
agg, ok := typeAgg[name]
|
sort.Slice(expenseTypes, func(i, j int) bool {
|
||||||
if !ok {
|
return expenseTypes[i]["amount"].(float64) > expenseTypes[j]["amount"].(float64)
|
||||||
agg = &struct {
|
|
||||||
count int
|
|
||||||
amount float64
|
|
||||||
}{}
|
|
||||||
typeAgg[name] = agg
|
|
||||||
}
|
|
||||||
agg.count++
|
|
||||||
agg.amount += items[i].Amount
|
|
||||||
}
|
|
||||||
for name, agg := range typeAgg {
|
|
||||||
expenseTypes = append(expenseTypes, map[string]interface{}{
|
|
||||||
"name": name, "count": agg.count, "amount": agg.amount,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
sort.Slice(expenseTypes, func(i, j int) bool {
|
|
||||||
return expenseTypes[i]["amount"].(float64) > expenseTypes[j]["amount"].(float64)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return map[string]interface{}{
|
return map[string]interface{}{
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ func (c *AppOaController) ExpenseSubmit() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
code, msg := reimburseSubmitCore(claims, id)
|
code, msg := reimburseSubmitCore(claims, id, readReimburseComment(&c.Controller))
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
c.oaErr(code, code, msg)
|
c.oaErr(code, code, msg)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -24,6 +24,10 @@ type AdminMenuController = BackendMenuController
|
|||||||
// - 写接口(增删改、改状态)只有平台端能用,租户端能改菜单等于给自己开功能;
|
// - 写接口(增删改、改状态)只有平台端能用,租户端能改菜单等于给自己开功能;
|
||||||
// - 读接口两端都能用,但租户端只能读租户端那一份(cid 强制为 2)。
|
// - 读接口两端都能用,但租户端只能读租户端那一份(cid 强制为 2)。
|
||||||
func (c *BackendMenuController) denyMenu(code int, msg string) {
|
func (c *BackendMenuController) denyMenu(code int, msg string) {
|
||||||
|
// 必须同时设置 HTTP 状态码:只把 code 写进 body 而返回 200 时,
|
||||||
|
// 前端 axios 拦截器会把这次鉴权失败当成成功响应,既不静默续期也不跳登录,
|
||||||
|
// 菜单等页面只会停在「token已过期」的报错里。
|
||||||
|
c.Ctx.Output.SetStatus(code)
|
||||||
c.Data["json"] = map[string]interface{}{"code": code, "msg": msg}
|
c.Data["json"] = map[string]interface{}{"code": code, "msg": msg}
|
||||||
_ = c.ServeJSON()
|
_ = c.ServeJSON()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -454,8 +454,10 @@ func reimburseDetailData(claims *jwtutil.Claims, id uint64, scheme, host string)
|
|||||||
return nil, 404, "报销单不存在"
|
return nil, 404, "报销单不存在"
|
||||||
}
|
}
|
||||||
var items []models.BackendReimbursementItem
|
var items []models.BackendReimbursementItem
|
||||||
|
// 只按 reimbursement_id 查明细:主单已按 tid + 申请人校验过。
|
||||||
|
// 历史单据存在明细 tid 与主单不一致的情况,再按 tid 过滤会导致明细「消失」。
|
||||||
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
||||||
Filter("reimbursement_id", id).Filter("tid", claims.TenantId).Filter("is_deleted", 0).
|
Filter("reimbursement_id", id).Filter("is_deleted", 0).
|
||||||
OrderBy("id").All(&items)
|
OrderBy("id").All(&items)
|
||||||
if err != nil && err != orm.ErrNoRows {
|
if err != nil && err != orm.ErrNoRows {
|
||||||
return nil, 500, "查询明细失败: " + err.Error()
|
return nil, 500, "查询明细失败: " + err.Error()
|
||||||
@@ -533,6 +535,10 @@ func (c *BackendReimburseController) Create() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reimburseCreateCore 创建报销单(草稿)共享逻辑。
|
// reimburseCreateCore 创建报销单(草稿)共享逻辑。
|
||||||
|
//
|
||||||
|
// 创建只落草稿:不接受客户端传入的状态,也不启动审批流程。
|
||||||
|
// 提交审批必须由用户显式调用 Submit(Web 端为列表操作栏的「提交审核」),
|
||||||
|
// 在此之前草稿可反复编辑,满足周期性报销多次修改的需求。
|
||||||
func reimburseCreateCore(claims *jwtutil.Claims, payload reimbursementPayload) (uint64, int, string) {
|
func reimburseCreateCore(claims *jwtutil.Claims, payload reimbursementPayload) (uint64, int, string) {
|
||||||
applyDate, err := parseReimburseDate(payload.ApplyDate)
|
applyDate, err := parseReimburseDate(payload.ApplyDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -541,7 +547,8 @@ func reimburseCreateCore(claims *jwtutil.Claims, payload reimbursementPayload) (
|
|||||||
uid := uint64(claims.UserID)
|
uid := uint64(claims.UserID)
|
||||||
row := &models.BackendReimbursement{
|
row := &models.BackendReimbursement{
|
||||||
Tid: uint64(claims.TenantId), UID: &uid, UserID: uid, DepartmentID: payload.DepartmentID,
|
Tid: uint64(claims.TenantId), UID: &uid, UserID: uid, DepartmentID: payload.DepartmentID,
|
||||||
TotalAmount: 0, Status: 0, ApplyDate: applyDate,
|
TotalAmount: 0, Status: reimbursementStatusDraft, ApplyDate: applyDate,
|
||||||
|
CurrentStep: 0, CurrentApproverID: 0,
|
||||||
Description: nullableReimburseString(payload.Description),
|
Description: nullableReimburseString(payload.Description),
|
||||||
}
|
}
|
||||||
if err := saveReimbursement(row); err != nil {
|
if err := saveReimbursement(row); err != nil {
|
||||||
@@ -575,14 +582,14 @@ func (c *BackendReimburseController) Update() {
|
|||||||
c.reply(200, "success", map[string]interface{}{"id": id})
|
c.reply(200, "success", map[string]interface{}{"id": id})
|
||||||
}
|
}
|
||||||
|
|
||||||
// reimburseUpdateCore 编辑报销单(仅草稿)共享逻辑。
|
// reimburseUpdateCore 编辑报销单(草稿/已驳回/已撤回)共享逻辑。
|
||||||
func reimburseUpdateCore(claims *jwtutil.Claims, id uint64, payload reimbursementPayload) (int, string) {
|
func reimburseUpdateCore(claims *jwtutil.Claims, id uint64, payload reimbursementPayload) (int, string) {
|
||||||
row, err := findOwnedReimbursement(claims, id)
|
row, err := findOwnedReimbursement(claims, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 404, "报销单不存在"
|
return 404, "报销单不存在"
|
||||||
}
|
}
|
||||||
if row.Status != 0 {
|
if !canEditReimbursement(row.Status) {
|
||||||
return 400, "只有草稿可以编辑"
|
return 400, "当前状态不可编辑,请先撤回"
|
||||||
}
|
}
|
||||||
applyDate, err := parseReimburseDate(payload.ApplyDate)
|
applyDate, err := parseReimburseDate(payload.ApplyDate)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -621,7 +628,7 @@ func reimburseDeleteCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return 404, "报销单不存在"
|
return 404, "报销单不存在"
|
||||||
}
|
}
|
||||||
if row.Status != 0 {
|
if row.Status != reimbursementStatusDraft {
|
||||||
return 400, "只有草稿可以删除"
|
return 400, "只有草稿可以删除"
|
||||||
}
|
}
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
@@ -651,13 +658,13 @@ func (c *BackendReimburseController) getItemID() (uint64, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// findOwnedReimbursementItem 查询本人报销单下的费用明细(包级共享,移动端复用)。
|
// findOwnedReimbursementItem 查询本人报销单下的费用明细(包级共享,移动端复用)。
|
||||||
func findOwnedReimbursementItem(claims *jwtutil.Claims, id, reimbursementID uint64) (*models.BackendReimbursementItem, error) {
|
// 主单归属已由 ensureEditableReimbursement 按 tid + 申请人校验,这里只按
|
||||||
|
// id + reimbursement_id 定位,避免历史明细 tid/uid 不一致时无法编辑。
|
||||||
|
func findOwnedReimbursementItem(_ *jwtutil.Claims, id, reimbursementID uint64) (*models.BackendReimbursementItem, error) {
|
||||||
item := &models.BackendReimbursementItem{}
|
item := &models.BackendReimbursementItem{}
|
||||||
err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
||||||
Filter("id", id).
|
Filter("id", id).
|
||||||
Filter("reimbursement_id", reimbursementID).
|
Filter("reimbursement_id", reimbursementID).
|
||||||
Filter("tid", claims.TenantId).
|
|
||||||
Filter("uid", claims.UserID).
|
|
||||||
Filter("is_deleted", 0).
|
Filter("is_deleted", 0).
|
||||||
One(item)
|
One(item)
|
||||||
return item, err
|
return item, err
|
||||||
@@ -677,15 +684,82 @@ func validateReimbursementItem(item reimbursementItemPayload) (time.Time, error)
|
|||||||
return date, nil
|
return date, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureDraftReimbursement 校验报销单存在且为草稿(包级共享,移动端复用)。
|
// canEditReimbursement 可编辑状态:草稿、已驳回、已撤回。
|
||||||
|
// 审批中需先撤回;已通过/已打款不可编辑,保证审批结果与打款金额不被改动。
|
||||||
|
func canEditReimbursement(status int8) bool {
|
||||||
|
switch status {
|
||||||
|
case reimbursementStatusDraft, reimbursementStatusRejected, reimbursementStatusWithdrawn:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveReimburseUserName 按 identity_id 解析姓名:
|
||||||
|
// 认证中心企业绑定表 → 身份表 → 老租户用户表。历史令牌/导入数据可能缺映射,逐级兜底。
|
||||||
|
func resolveReimburseUserName(tid, userID uint64) string {
|
||||||
|
if userID == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if tid != 0 {
|
||||||
|
var bind models.AuthTenantUser
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthTenantUser)).
|
||||||
|
Filter("tid", tid).
|
||||||
|
Filter("identity_id", userID).
|
||||||
|
Filter("delete_time__isnull", true).
|
||||||
|
One(&bind); err == nil {
|
||||||
|
if bind.Name != nil && strings.TrimSpace(*bind.Name) != "" {
|
||||||
|
return strings.TrimSpace(*bind.Name)
|
||||||
|
}
|
||||||
|
if bind.Account != nil && strings.TrimSpace(*bind.Account) != "" {
|
||||||
|
return strings.TrimSpace(*bind.Account)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var ident models.AuthIdentity
|
||||||
|
if err := models.Orm.QueryTable(new(models.AuthIdentity)).
|
||||||
|
Filter("id", userID).
|
||||||
|
One(&ident); err == nil {
|
||||||
|
if ident.Nickname != nil && strings.TrimSpace(*ident.Nickname) != "" {
|
||||||
|
return strings.TrimSpace(*ident.Nickname)
|
||||||
|
}
|
||||||
|
if ident.Account != nil && strings.TrimSpace(*ident.Account) != "" {
|
||||||
|
return strings.TrimSpace(*ident.Account)
|
||||||
|
}
|
||||||
|
if ident.Mobile != nil && strings.TrimSpace(*ident.Mobile) != "" {
|
||||||
|
return strings.TrimSpace(*ident.Mobile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
account, name := services.OperationLogUser(&tid, userID)
|
||||||
|
if strings.TrimSpace(name) != "" {
|
||||||
|
return strings.TrimSpace(name)
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(account)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reimburseActorName 审批时间线的操作人名称:
|
||||||
|
// 优先令牌中的 username,缺失时回落到认证中心/租户用户表。
|
||||||
|
func reimburseActorName(tid uint64, claims *jwtutil.Claims) string {
|
||||||
|
if claims == nil {
|
||||||
|
return "系统"
|
||||||
|
}
|
||||||
|
if name := strings.TrimSpace(claims.Username); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
if name := resolveReimburseUserName(tid, uint64(claims.UserID)); name != "" {
|
||||||
|
return name
|
||||||
|
}
|
||||||
|
return "系统"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ensureEditableReimbursement 校验报销单存在且处于可编辑状态(包级共享,移动端复用)。
|
||||||
// 返回 (报销单, HTTP 状态码, 错误信息),错误信息为空串表示通过。
|
// 返回 (报销单, HTTP 状态码, 错误信息),错误信息为空串表示通过。
|
||||||
func ensureDraftReimbursement(claims *jwtutil.Claims, id uint64) (*models.BackendReimbursement, int, string) {
|
func ensureEditableReimbursement(claims *jwtutil.Claims, id uint64) (*models.BackendReimbursement, int, string) {
|
||||||
row, err := findOwnedReimbursement(claims, id)
|
row, err := findOwnedReimbursement(claims, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 404, "报销单不存在"
|
return nil, 404, "报销单不存在"
|
||||||
}
|
}
|
||||||
if row.Status != 0 {
|
if !canEditReimbursement(row.Status) {
|
||||||
return nil, 400, "只有草稿可以维护费用明细"
|
return nil, 400, "当前状态不可编辑费用明细,请先撤回"
|
||||||
}
|
}
|
||||||
return row, 200, ""
|
return row, 200, ""
|
||||||
}
|
}
|
||||||
@@ -737,7 +811,7 @@ func (c *BackendReimburseController) AddItem() {
|
|||||||
|
|
||||||
// reimburseAddItemCore 新增费用明细共享逻辑(仅草稿;自动重算报销总额)。
|
// reimburseAddItemCore 新增费用明细共享逻辑(仅草稿;自动重算报销总额)。
|
||||||
func reimburseAddItemCore(claims *jwtutil.Claims, reimbursementID uint64, payload reimbursementItemPayload, scheme, host string) (map[string]interface{}, int, string) {
|
func reimburseAddItemCore(claims *jwtutil.Claims, reimbursementID uint64, payload reimbursementItemPayload, scheme, host string) (map[string]interface{}, int, string) {
|
||||||
if _, _, msg := ensureDraftReimbursement(claims, reimbursementID); msg != "" {
|
if _, _, msg := ensureEditableReimbursement(claims, reimbursementID); msg != "" {
|
||||||
code := 400
|
code := 400
|
||||||
if msg == "报销单不存在" {
|
if msg == "报销单不存在" {
|
||||||
code = 404
|
code = 404
|
||||||
@@ -802,7 +876,7 @@ func (c *BackendReimburseController) UpdateItem() {
|
|||||||
|
|
||||||
// reimburseUpdateItemCore 更新费用明细共享逻辑(仅草稿;自动重算报销总额)。
|
// reimburseUpdateItemCore 更新费用明细共享逻辑(仅草稿;自动重算报销总额)。
|
||||||
func reimburseUpdateItemCore(claims *jwtutil.Claims, reimbursementID, itemID uint64, payload reimbursementItemPayload, scheme, host string) (map[string]interface{}, int, string) {
|
func reimburseUpdateItemCore(claims *jwtutil.Claims, reimbursementID, itemID uint64, payload reimbursementItemPayload, scheme, host string) (map[string]interface{}, int, string) {
|
||||||
if _, _, msg := ensureDraftReimbursement(claims, reimbursementID); msg != "" {
|
if _, _, msg := ensureEditableReimbursement(claims, reimbursementID); msg != "" {
|
||||||
code := 400
|
code := 400
|
||||||
if msg == "报销单不存在" {
|
if msg == "报销单不存在" {
|
||||||
code = 404
|
code = 404
|
||||||
@@ -864,7 +938,7 @@ func (c *BackendReimburseController) DeleteItem() {
|
|||||||
|
|
||||||
// reimburseDeleteItemCore 删除费用明细共享逻辑(仅草稿;自动重算报销总额)。
|
// reimburseDeleteItemCore 删除费用明细共享逻辑(仅草稿;自动重算报销总额)。
|
||||||
func reimburseDeleteItemCore(claims *jwtutil.Claims, reimbursementID, itemID uint64) (int, string) {
|
func reimburseDeleteItemCore(claims *jwtutil.Claims, reimbursementID, itemID uint64) (int, string) {
|
||||||
if _, _, msg := ensureDraftReimbursement(claims, reimbursementID); msg != "" {
|
if _, _, msg := ensureEditableReimbursement(claims, reimbursementID); msg != "" {
|
||||||
if msg == "报销单不存在" {
|
if msg == "报销单不存在" {
|
||||||
return 404, msg
|
return 404, msg
|
||||||
}
|
}
|
||||||
@@ -898,7 +972,7 @@ func (c *BackendReimburseController) Submit() {
|
|||||||
c.reply(400, "无效ID", nil)
|
c.reply(400, "无效ID", nil)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
code, msg := reimburseSubmitCore(claims, id)
|
code, msg := reimburseSubmitCore(claims, id, c.readReimburseActPayload())
|
||||||
if msg != "" {
|
if msg != "" {
|
||||||
c.reply(code, msg, nil)
|
c.reply(code, msg, nil)
|
||||||
return
|
return
|
||||||
@@ -906,21 +980,104 @@ func (c *BackendReimburseController) Submit() {
|
|||||||
c.reply(200, "success", nil)
|
c.reply(200, "success", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Withdraw POST /backend/reimbursements/:id/withdraw
|
||||||
|
//
|
||||||
|
// 报销人撤回审批中的单据:撤掉运行中的审批实例,并把单据退回草稿,
|
||||||
|
// 撤回后可在详情继续编辑费用条目,确认无误后再次提交审核。
|
||||||
|
func (c *BackendReimburseController) Withdraw() {
|
||||||
|
claims, ok := c.claims()
|
||||||
|
if !ok {
|
||||||
|
c.reply(401, "未登录或无权限", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
id, ok := c.getID()
|
||||||
|
if !ok {
|
||||||
|
c.reply(400, "无效ID", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code, msg := reimburseWithdrawCore(claims, id, c.readReimburseActPayload())
|
||||||
|
if msg != "" {
|
||||||
|
c.reply(code, msg, nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.reply(200, "success", nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// reimburseWithdrawCore 撤回审批中的报销单并退回草稿。
|
||||||
|
//
|
||||||
|
// 与「审批中心」的发起人撤回一致:运行中的实例置为已撤回、待办任务置为已跳过;
|
||||||
|
// 区别是报销单本身退回草稿(0)而不是已撤回(4),这样用户能继续编辑条目并重新提交。
|
||||||
|
func reimburseWithdrawCore(claims *jwtutil.Claims, id uint64, comment string) (int, string) {
|
||||||
|
row, err := findOwnedReimbursement(claims, id)
|
||||||
|
if err != nil {
|
||||||
|
return 404, "报销单不存在"
|
||||||
|
}
|
||||||
|
if row.Status != reimbursementStatusApproving {
|
||||||
|
return 400, "只有审批中的报销单可以撤回"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 历史单据可能没有审批实例:没有实例时直接退回草稿,保证仍可编辑
|
||||||
|
inst, ok := findReimburseInstance(row.Tid, row.ID)
|
||||||
|
if ok {
|
||||||
|
if inst.Status != models.ApprovalInstanceRunning {
|
||||||
|
return 400, "审批已结束,无法撤回"
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if _, err = models.Orm.QueryTable(new(models.ApprovalInstance)).
|
||||||
|
Filter("id", inst.ID).
|
||||||
|
Update(orm.Params{
|
||||||
|
"status": models.ApprovalInstanceWithdrawn,
|
||||||
|
"finish_time": now,
|
||||||
|
"update_time": now,
|
||||||
|
}); err != nil {
|
||||||
|
return 500, "撤回审批失败"
|
||||||
|
}
|
||||||
|
_, _ = models.Orm.QueryTable(new(models.ApprovalTask)).
|
||||||
|
Filter("instance_id", inst.ID).
|
||||||
|
Filter("status", models.ApprovalTaskPending).
|
||||||
|
Update(orm.Params{"status": models.ApprovalTaskSkipped, "update_time": now})
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now()
|
||||||
|
if _, err = models.Orm.QueryTable(new(models.BackendReimbursement)).
|
||||||
|
Filter("id", id).Filter("tid", claims.TenantId).
|
||||||
|
Update(orm.Params{
|
||||||
|
"status": reimbursementStatusDraft, "current_step": 0,
|
||||||
|
"current_approver_id": 0, "update_time": now,
|
||||||
|
}); err != nil {
|
||||||
|
return 500, "撤回失败"
|
||||||
|
}
|
||||||
|
// 时间线补一条撤回记录(不调用引擎 SyncBizResult,避免把单据同步成「已撤回」)
|
||||||
|
_, _ = models.Orm.Insert(&models.BackendApprovalRecord{
|
||||||
|
Tid: uint64(claims.TenantId), UID: uint64Ptr(uint64(claims.UserID)),
|
||||||
|
ReimbursementID: id, ApproverID: uint64(claims.UserID),
|
||||||
|
ApproverName: reimburseActorName(uint64(claims.TenantId), claims),
|
||||||
|
Action: models.ApprovalActionWithdraw, Comment: nullableReimburseString(comment),
|
||||||
|
Step: row.CurrentStep, PrevStep: row.CurrentStep,
|
||||||
|
})
|
||||||
|
return 200, ""
|
||||||
|
}
|
||||||
|
|
||||||
// reimburseActPayload 审批操作请求体。
|
// reimburseActPayload 审批操作请求体。
|
||||||
type reimburseActPayload struct {
|
type reimburseActPayload struct {
|
||||||
Comment string `json:"comment"`
|
Comment string `json:"comment"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// readReimburseActPayload 读取审批意见(可空)。
|
// readReimburseComment 读取审批/撤回意见(可空),Web 与移动端共用。
|
||||||
func (c *BackendReimburseController) readReimburseActPayload() string {
|
func readReimburseComment(ctrl *beego.Controller) string {
|
||||||
var payload reimburseActPayload
|
var payload reimburseActPayload
|
||||||
raw, err := io.ReadAll(c.Ctx.Request.Body)
|
raw, err := io.ReadAll(ctrl.Ctx.Request.Body)
|
||||||
if err == nil && len(raw) > 0 {
|
if err == nil && len(raw) > 0 {
|
||||||
_ = json.Unmarshal(raw, &payload)
|
_ = json.Unmarshal(raw, &payload)
|
||||||
}
|
}
|
||||||
return strings.TrimSpace(payload.Comment)
|
return strings.TrimSpace(payload.Comment)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readReimburseActPayload 读取审批意见(可空)。
|
||||||
|
func (c *BackendReimburseController) readReimburseActPayload() string {
|
||||||
|
return readReimburseComment(&c.Controller)
|
||||||
|
}
|
||||||
|
|
||||||
// Approve POST /backend/reimbursements/approve/:id 通过当前审批节点(body: {comment})。
|
// Approve POST /backend/reimbursements/approve/:id 通过当前审批节点(body: {comment})。
|
||||||
func (c *BackendReimburseController) Approve() {
|
func (c *BackendReimburseController) Approve() {
|
||||||
claims, ok := c.claims()
|
claims, ok := c.claims()
|
||||||
@@ -1000,12 +1157,12 @@ var reimburseFallbackBizTypes = []string{models.ApprovalBizReimburse, "oa"}
|
|||||||
// 审批最终结果由 services/approval.SyncBizResult 回写到报销单状态,保证
|
// 审批最终结果由 services/approval.SyncBizResult 回写到报销单状态,保证
|
||||||
// 「审批中心」与「报销管理」看到的状态一致。
|
// 「审批中心」与「报销管理」看到的状态一致。
|
||||||
// 提交后报销单置为审批中(1),并写入 action=1 的审批记录供业务详情时间线展示。
|
// 提交后报销单置为审批中(1),并写入 action=1 的审批记录供业务详情时间线展示。
|
||||||
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
func reimburseSubmitCore(claims *jwtutil.Claims, id uint64, comment string) (int, string) {
|
||||||
row, err := findOwnedReimbursement(claims, id)
|
row, err := findOwnedReimbursement(claims, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 404, "报销单不存在"
|
return 404, "报销单不存在"
|
||||||
}
|
}
|
||||||
if row.Status != 0 {
|
if !canEditReimbursement(row.Status) {
|
||||||
return 400, "当前状态不可提交"
|
return 400, "当前状态不可提交"
|
||||||
}
|
}
|
||||||
tid := uint64(claims.TenantId)
|
tid := uint64(claims.TenantId)
|
||||||
@@ -1013,6 +1170,7 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
|||||||
if flowID == 0 {
|
if flowID == 0 {
|
||||||
return 400, "尚未配置审批流程"
|
return 400, "尚未配置审批流程"
|
||||||
}
|
}
|
||||||
|
actorName := reimburseActorName(tid, claims)
|
||||||
|
|
||||||
instID, err := approvalEngine.Submit(approvalEngine.SubmitInput{
|
instID, err := approvalEngine.Submit(approvalEngine.SubmitInput{
|
||||||
Scope: models.ApprovalScopeTenant,
|
Scope: models.ApprovalScopeTenant,
|
||||||
@@ -1022,7 +1180,7 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
|||||||
BizID: id,
|
BizID: id,
|
||||||
BizTitle: reimburseBizTitle(row),
|
BizTitle: reimburseBizTitle(row),
|
||||||
SubmitUID: uint64(claims.UserID),
|
SubmitUID: uint64(claims.UserID),
|
||||||
SubmitName: claims.Username,
|
SubmitName: actorName,
|
||||||
DeptID: reimburseSubmitDeptID(tid, uint64(claims.UserID)),
|
DeptID: reimburseSubmitDeptID(tid, uint64(claims.UserID)),
|
||||||
FormData: map[string]interface{}{
|
FormData: map[string]interface{}{
|
||||||
"id": id,
|
"id": id,
|
||||||
@@ -1037,7 +1195,7 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 流程未配置审批节点时引擎会直接通过,此时报销单同步为已通过(2)
|
// 流程未配置审批节点时引擎会直接通过,此时报销单同步为已通过(2)
|
||||||
status := int8(1)
|
status := reimbursementStatusApproving
|
||||||
var inst models.ApprovalInstance
|
var inst models.ApprovalInstance
|
||||||
if e := models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id", instID).One(&inst); e == nil {
|
if e := models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id", instID).One(&inst); e == nil {
|
||||||
switch inst.Status {
|
switch inst.Status {
|
||||||
@@ -1057,7 +1215,8 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
|
|||||||
}
|
}
|
||||||
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
|
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
|
||||||
Tid: tid, UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
|
Tid: tid, UID: uint64Ptr(uint64(claims.UserID)), ReimbursementID: id,
|
||||||
ApproverID: uint64(claims.UserID), ApproverName: claims.Username, Action: models.ApprovalActionSubmit,
|
ApproverID: uint64(claims.UserID), ApproverName: actorName,
|
||||||
|
Action: models.ApprovalActionSubmit, Comment: nullableReimburseString(comment),
|
||||||
Step: 0, PrevStep: 0,
|
Step: 0, PrevStep: 0,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -1118,8 +1277,14 @@ type reimbursementStatusPayload struct {
|
|||||||
|
|
||||||
// 报销单状态(0草稿 1审批中 2已通过 3已驳回 4已撤回 5已打款)
|
// 报销单状态(0草稿 1审批中 2已通过 3已驳回 4已撤回 5已打款)
|
||||||
const (
|
const (
|
||||||
reimbursementStatusApproved int8 = 2 // 已通过
|
// reimbursementStatusDraft 草稿:新建后的唯一初始状态。
|
||||||
reimbursementStatusPaid int8 = 5 // 已打款
|
// 草稿可反复编辑/增删明细,只有用户在列表点「提交审核」才会进入审批流。
|
||||||
|
reimbursementStatusDraft int8 = 0
|
||||||
|
reimbursementStatusApproving int8 = 1 // 审批中
|
||||||
|
reimbursementStatusApproved int8 = 2 // 已通过
|
||||||
|
reimbursementStatusRejected int8 = 3 // 已驳回
|
||||||
|
reimbursementStatusWithdrawn int8 = 4 // 已撤回
|
||||||
|
reimbursementStatusPaid int8 = 5 // 已打款
|
||||||
)
|
)
|
||||||
|
|
||||||
// UpdateStatus POST /backend/reimbursements/:id/status — 报销人确认款项到账(置为「已打款」)。
|
// UpdateStatus POST /backend/reimbursements/:id/status — 报销人确认款项到账(置为「已打款」)。
|
||||||
@@ -1217,6 +1382,18 @@ func reimburseRecordsData(claims *jwtutil.Claims, id uint64) ([]models.BackendAp
|
|||||||
if rows == nil {
|
if rows == nil {
|
||||||
rows = []models.BackendApprovalRecord{}
|
rows = []models.BackendApprovalRecord{}
|
||||||
}
|
}
|
||||||
|
// 历史记录的 approver_name 可能为空(旧令牌没有 username 字段),读取时补齐
|
||||||
|
tid := uint64(claims.TenantId)
|
||||||
|
for i := range rows {
|
||||||
|
if strings.TrimSpace(rows[i].ApproverName) != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name := resolveReimburseUserName(tid, rows[i].ApproverID)
|
||||||
|
if name == "" {
|
||||||
|
name = "系统"
|
||||||
|
}
|
||||||
|
rows[i].ApproverName = name
|
||||||
|
}
|
||||||
return rows, 200, ""
|
return rows, 200, ""
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1603,38 +1780,44 @@ func (c *BackendReimburseController) Dashboard() {
|
|||||||
paidIDs[rows[i].ID] = true
|
paidIDs[rows[i].ID] = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
var items []models.BackendReimbursementItem
|
paidIDList := make([]uint64, 0, len(paidIDs))
|
||||||
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
for id := range paidIDs {
|
||||||
Filter("tid", claims.TenantId).
|
paidIDList = append(paidIDList, id)
|
||||||
Filter("uid", claims.UserID).
|
|
||||||
Filter("is_deleted", 0).
|
|
||||||
All(&items)
|
|
||||||
if err != nil {
|
|
||||||
c.reply(500, "查询失败", nil)
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
typeAgg := map[string]*struct {
|
typeAgg := map[string]*struct {
|
||||||
count int
|
count int
|
||||||
amount float64
|
amount float64
|
||||||
}{}
|
}{}
|
||||||
for i := range items {
|
if len(paidIDList) > 0 {
|
||||||
if !paidIDs[items[i].ReimbursementID] {
|
// 按已打款主单 ID 查明细,不依赖明细行的 tid/uid(历史数据可能不一致)
|
||||||
continue
|
var items []models.BackendReimbursementItem
|
||||||
|
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
|
||||||
|
Filter("reimbursement_id__in", paidIDList).
|
||||||
|
Filter("is_deleted", 0).
|
||||||
|
All(&items)
|
||||||
|
if err != nil {
|
||||||
|
c.reply(500, "查询失败", nil)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
name := strings.TrimSpace(items[i].ExpenseType)
|
for i := range items {
|
||||||
if name == "" {
|
if !paidIDs[items[i].ReimbursementID] {
|
||||||
name = "未分类"
|
continue
|
||||||
|
}
|
||||||
|
name := strings.TrimSpace(items[i].ExpenseType)
|
||||||
|
if name == "" {
|
||||||
|
name = "未分类"
|
||||||
|
}
|
||||||
|
agg, ok := typeAgg[name]
|
||||||
|
if !ok {
|
||||||
|
agg = &struct {
|
||||||
|
count int
|
||||||
|
amount float64
|
||||||
|
}{}
|
||||||
|
typeAgg[name] = agg
|
||||||
|
}
|
||||||
|
agg.count++
|
||||||
|
agg.amount += items[i].Amount
|
||||||
}
|
}
|
||||||
agg, ok := typeAgg[name]
|
|
||||||
if !ok {
|
|
||||||
agg = &struct {
|
|
||||||
count int
|
|
||||||
amount float64
|
|
||||||
}{}
|
|
||||||
typeAgg[name] = agg
|
|
||||||
}
|
|
||||||
agg.count++
|
|
||||||
agg.amount += items[i].Amount
|
|
||||||
}
|
}
|
||||||
expenseTypes := make([]map[string]interface{}, 0, len(typeAgg))
|
expenseTypes := make([]map[string]interface{}, 0, len(typeAgg))
|
||||||
for name, agg := range typeAgg {
|
for name, agg := range typeAgg {
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package controllers
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestCanEditReimbursement(t *testing.T) {
|
||||||
|
editable := []int8{
|
||||||
|
reimbursementStatusDraft,
|
||||||
|
reimbursementStatusRejected,
|
||||||
|
reimbursementStatusWithdrawn,
|
||||||
|
}
|
||||||
|
for _, status := range editable {
|
||||||
|
if !canEditReimbursement(status) {
|
||||||
|
t.Errorf("status %d should be editable", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
locked := []int8{
|
||||||
|
reimbursementStatusApproving,
|
||||||
|
reimbursementStatusApproved,
|
||||||
|
reimbursementStatusPaid,
|
||||||
|
}
|
||||||
|
for _, status := range locked {
|
||||||
|
if canEditReimbursement(status) {
|
||||||
|
t.Errorf("status %d should not be editable", status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,7 +82,7 @@ func reimburseActCore(claims *jwtutil.Claims, id uint64, pass bool, comment stri
|
|||||||
}
|
}
|
||||||
|
|
||||||
applyReimburseApprovalResult(uint64(claims.TenantId), id,
|
applyReimburseApprovalResult(uint64(claims.TenantId), id,
|
||||||
uint64(claims.UserID), claims.Username, task.Sort,
|
uint64(claims.UserID), reimburseActorName(uint64(claims.TenantId), claims), task.Sort,
|
||||||
pass, comment, result.Finished, result.InstanceStat)
|
pass, comment, result.Finished, result.InstanceStat)
|
||||||
|
|
||||||
hint := result.Message
|
hint := result.Message
|
||||||
|
|||||||
@@ -37,5 +37,9 @@ func main() {
|
|||||||
// 启动支付补偿任务(超时关单 / 业务订单同步重试,5 分钟一轮)
|
// 启动支付补偿任务(超时关单 / 业务订单同步重试,5 分钟一轮)
|
||||||
payment.StartPaymentScheduler(make(chan struct{}))
|
payment.StartPaymentScheduler(make(chan struct{}))
|
||||||
|
|
||||||
|
// 保留 dev 模式原有的 `[D] router.go` 访问日志样式,
|
||||||
|
// 但改走带探针过滤的 console 适配器(见 middleware/security_probe.go)。
|
||||||
|
beego.BConfig.Log.Outputs = map[string]string{"filterconsole": ""}
|
||||||
|
|
||||||
beego.Run()
|
beego.Run()
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/beego/beego/v2/core/logs"
|
||||||
|
beego "github.com/beego/beego/v2/server/web"
|
||||||
|
"github.com/beego/beego/v2/server/web/context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// security_probe.go 扫描器探针的日志降噪与泄露检测。
|
||||||
|
//
|
||||||
|
// 公网服务器每天都会收到大量针对 .env / config.js / credentials.json 等
|
||||||
|
// 敏感文件的自动化探测。它们全部 404 时属于背景噪音,逐条打印访问日志只会淹没真正的告警。
|
||||||
|
// 这里接管 Beego 的访问日志过滤器:
|
||||||
|
// - 探针路径不再逐条打印;
|
||||||
|
// - 持续探测但均未命中时,每 10 分钟汇总提示一次「未发现泄露」;
|
||||||
|
// - 一旦探针路径返回 2xx(文件真实可访问),立即输出 [security][leak] 告警。
|
||||||
|
//
|
||||||
|
// 注意:该过滤器依赖 Beego 的 access log 流程,app.conf 中不要开启
|
||||||
|
// EnableStaticLogs=true,否则 Beego 会跳过 DefaultAccessLogFilter。
|
||||||
|
|
||||||
|
// filterconsole 是带探针过滤的 console 日志适配器。
|
||||||
|
// 在 main.go 里把 BConfig.Log.Outputs 指向它,既保留 Beego dev 模式原有的
|
||||||
|
// `[D] router.go` 访问日志样式,又能把探针/根路径 404 从日志里过滤掉。
|
||||||
|
func init() {
|
||||||
|
logs.Register("filterconsole", func() logs.Logger {
|
||||||
|
return &probeFilterLogger{inner: logs.NewConsole()}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type probeFilterLogger struct {
|
||||||
|
inner logs.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *probeFilterLogger) Init(config string) error { return l.inner.Init(config) }
|
||||||
|
|
||||||
|
func (l *probeFilterLogger) WriteMsg(lm *logs.LogMsg) error {
|
||||||
|
if shouldSuppressAccessLogMsg(lm.Msg) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return l.inner.WriteMsg(lm)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *probeFilterLogger) Destroy() { l.inner.Destroy() }
|
||||||
|
func (l *probeFilterLogger) Flush() { l.inner.Flush() }
|
||||||
|
|
||||||
|
func (l *probeFilterLogger) SetFormatter(f logs.LogFormatter) { l.inner.SetFormatter(f) }
|
||||||
|
|
||||||
|
var (
|
||||||
|
ansiColorRe = regexp.MustCompile(`\x1b\[[0-9;]*m`)
|
||||||
|
accessStatusRe = regexp.MustCompile(`\|\s*(\d{3})\s*\|`)
|
||||||
|
accessMethodPath = regexp.MustCompile(`\|\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S+)`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// shouldSuppressAccessLogMsg 解析 Beego dev 访问日志行,过滤探针与根路径 404。
|
||||||
|
func shouldSuppressAccessLogMsg(msg string) bool {
|
||||||
|
clean := ansiColorRe.ReplaceAllString(msg, "")
|
||||||
|
m := accessMethodPath.FindStringSubmatch(clean)
|
||||||
|
if len(m) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p := path.Clean(m[2])
|
||||||
|
status := 0
|
||||||
|
if sm := accessStatusRe.FindStringSubmatch(clean); len(sm) == 2 {
|
||||||
|
status, _ = strconv.Atoi(sm[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
if p == "/" && status == 404 {
|
||||||
|
recordRootNoise()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if IsScannerProbe(p) {
|
||||||
|
recordProbeNoise()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// scannerProbeFiles 常见扫描器探测的完整文件名(小写)。
|
||||||
|
var scannerProbeFiles = map[string]struct{}{
|
||||||
|
".env": {},
|
||||||
|
"env": {},
|
||||||
|
"env.js": {},
|
||||||
|
"env.txt": {},
|
||||||
|
"config.js": {},
|
||||||
|
"config.json": {},
|
||||||
|
"config.yaml": {},
|
||||||
|
"config.yml": {},
|
||||||
|
"config.php": {},
|
||||||
|
"credentials": {},
|
||||||
|
"credentials.json": {},
|
||||||
|
"credentials.ini": {},
|
||||||
|
"secrets.json": {},
|
||||||
|
"lithic.json": {},
|
||||||
|
"sardine.json": {},
|
||||||
|
"database.sql": {},
|
||||||
|
"phpinfo": {},
|
||||||
|
"phpinfo.php": {},
|
||||||
|
"info.php": {},
|
||||||
|
"wp-config.php": {},
|
||||||
|
"wp-config.php.bak": {},
|
||||||
|
"wp-login.php": {},
|
||||||
|
"docker-compose.yml": {},
|
||||||
|
"docker-compose.yaml": {},
|
||||||
|
"composer.json": {},
|
||||||
|
"gitconfig": {},
|
||||||
|
"runtime-config.js": {},
|
||||||
|
"settings.json": {},
|
||||||
|
"index.js": {},
|
||||||
|
"aws.yml": {},
|
||||||
|
"aws.json": {},
|
||||||
|
}
|
||||||
|
|
||||||
|
// scannerProbeDirs 目录型探针(源代码/凭证/WordPress 目录)。
|
||||||
|
var scannerProbeDirs = []string{
|
||||||
|
"/.git",
|
||||||
|
"/.svn",
|
||||||
|
"/.aws",
|
||||||
|
"/.ssh",
|
||||||
|
"/vendor/composer",
|
||||||
|
"/wp-content",
|
||||||
|
"/wp-admin",
|
||||||
|
// Go 源码目录:日志里出现的 /middleware、/controllers、/routers 等
|
||||||
|
"/middleware",
|
||||||
|
"/routers",
|
||||||
|
"/controllers",
|
||||||
|
"/models",
|
||||||
|
"/services",
|
||||||
|
"/pkg",
|
||||||
|
"/scripts",
|
||||||
|
"/conf",
|
||||||
|
"/database",
|
||||||
|
"/docs",
|
||||||
|
"/cmd",
|
||||||
|
"/version",
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsScannerProbe 判断路径是否属于常见的敏感文件扫描探针。
|
||||||
|
func IsScannerProbe(rawPath string) bool {
|
||||||
|
p := strings.ToLower(path.Clean("/" + strings.TrimPrefix(rawPath, "/")))
|
||||||
|
if p == "/" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
base := path.Base(p)
|
||||||
|
if _, ok := scannerProbeFiles[base]; ok {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// .env / .env.local / sendgrid.env / config.env 等变体
|
||||||
|
if base == ".env" || base == "env" ||
|
||||||
|
strings.HasPrefix(base, ".env.") || strings.HasSuffix(base, ".env") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, dir := range scannerProbeDirs {
|
||||||
|
if p == dir || strings.HasPrefix(p, dir+"/") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 源码/配置文件后缀:/middleware/security_probe.go、/controllers/backend_reimburse.go 等
|
||||||
|
switch path.Ext(base) {
|
||||||
|
case ".go", ".vue", ".ts", ".sql", ".conf":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// WordPress 常见备份文件,如 wp-config.php.bak
|
||||||
|
if strings.HasPrefix(base, "wp-") && strings.HasSuffix(base, ".bak") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const probeSummaryInterval = 10 * time.Minute
|
||||||
|
|
||||||
|
var probeLogState = struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
suppressed int
|
||||||
|
lastSummary time.Time
|
||||||
|
rootSuppressed int
|
||||||
|
lastRootSummary time.Time
|
||||||
|
leakLogged map[string]time.Time
|
||||||
|
}{
|
||||||
|
leakLogged: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
|
||||||
|
// probeAccessLogFilter 包装 Beego 默认访问日志过滤器。
|
||||||
|
// Filter 返回 true 时 Beego 跳过该请求的访问日志。
|
||||||
|
type probeAccessLogFilter struct {
|
||||||
|
fallback beego.FilterHandler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSecurityProbeLogFilter 创建扫描探针访问日志过滤器。
|
||||||
|
func NewSecurityProbeLogFilter(fallback beego.FilterHandler) beego.FilterHandler {
|
||||||
|
return &probeAccessLogFilter{fallback: fallback}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *probeAccessLogFilter) Filter(ctx *context.Context) bool {
|
||||||
|
p := path.Clean(ctx.Request.URL.Path)
|
||||||
|
status := ctx.ResponseWriter.Status
|
||||||
|
|
||||||
|
// 当前进程只提供后端 API / 认证中心,根路径没有绑定站点;
|
||||||
|
// 公网扫描器/爬虫大量请求 / 得到 404,单独聚合,不逐条刷日志。
|
||||||
|
if p == "/" && status == 404 {
|
||||||
|
recordRootNoise()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if !IsScannerProbe(p) {
|
||||||
|
if f.fallback != nil {
|
||||||
|
return f.fallback.Filter(ctx)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台后台 SPA 对未知路径会兜底返回 index.html(200),
|
||||||
|
// 这不代表敏感文件可访问,不能当成泄露。
|
||||||
|
spaFallback, _ := ctx.Input.GetData("spa_fallback").(bool)
|
||||||
|
contentType := strings.TrimSpace(ctx.ResponseWriter.Header().Get("Content-Type"))
|
||||||
|
|
||||||
|
// 只有「明确 2xx + 有内容类型」才判定为可访问资源。
|
||||||
|
// status=0 或 Content-Type 为空多为 SPA 兜底/空响应,避免误报泄漏。
|
||||||
|
if status >= 200 && status < 300 && !spaFallback && contentType != "" {
|
||||||
|
recordProbeLeak(ctx, status, contentType)
|
||||||
|
} else {
|
||||||
|
recordProbeNoise()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 探针请求不进入常规访问日志
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordProbeNoise() {
|
||||||
|
probeLogState.mu.Lock()
|
||||||
|
defer probeLogState.mu.Unlock()
|
||||||
|
|
||||||
|
probeLogState.suppressed++
|
||||||
|
if !probeLogState.lastSummary.IsZero() &&
|
||||||
|
time.Since(probeLogState.lastSummary) < probeSummaryInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count := probeLogState.suppressed
|
||||||
|
probeLogState.suppressed = 0
|
||||||
|
probeLogState.lastSummary = time.Now()
|
||||||
|
log.Printf("[security] 已忽略 %d 次敏感文件探测请求(.env/config/credentials 等),未发现泄漏", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordRootNoise() {
|
||||||
|
probeLogState.mu.Lock()
|
||||||
|
defer probeLogState.mu.Unlock()
|
||||||
|
|
||||||
|
probeLogState.rootSuppressed++
|
||||||
|
if !probeLogState.lastRootSummary.IsZero() &&
|
||||||
|
time.Since(probeLogState.lastRootSummary) < probeSummaryInterval {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
count := probeLogState.rootSuppressed
|
||||||
|
probeLogState.rootSuppressed = 0
|
||||||
|
probeLogState.lastRootSummary = time.Now()
|
||||||
|
log.Printf("[http] 已忽略 %d 次根路径 404(当前 Host 未绑定站点),如需排查请用正式域名访问", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
func recordProbeLeak(ctx *context.Context, status int, contentType string) {
|
||||||
|
key := ctx.Request.Method + " " + ctx.Request.URL.Path
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
probeLogState.mu.Lock()
|
||||||
|
last := probeLogState.leakLogged[key]
|
||||||
|
if !last.IsZero() && now.Sub(last) < probeSummaryInterval {
|
||||||
|
probeLogState.mu.Unlock()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
probeLogState.leakLogged[key] = now
|
||||||
|
probeLogState.mu.Unlock()
|
||||||
|
|
||||||
|
log.Printf(
|
||||||
|
"[security][leak] 敏感文件探测命中可访问资源: %s %s -> %d (Content-Type: %s),疑似泄漏,请立即检查该路径是否应公开",
|
||||||
|
ctx.Request.Method, ctx.Request.URL.Path, status, contentType,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestShouldSuppressAccessLogMsg(t *testing.T) {
|
||||||
|
probeLine := "| 127.0.0.1| 404 | 221.723µs| nomatch| GET /middleware/security_probe.go"
|
||||||
|
if !shouldSuppressAccessLogMsg(probeLine) {
|
||||||
|
t.Fatalf("probe access line should be suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
rootLine := "| 127.0.0.1| 404 | 219.710µs| nomatch| GET /"
|
||||||
|
if !shouldSuppressAccessLogMsg(rootLine) {
|
||||||
|
t.Fatalf("root 404 access line should be suppressed")
|
||||||
|
}
|
||||||
|
|
||||||
|
normalLine := "| 127.0.0.1| 200 | 1.649ms| match| GET /auth/login r:/auth/login"
|
||||||
|
if shouldSuppressAccessLogMsg(normalLine) {
|
||||||
|
t.Fatalf("normal access line should not be suppressed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsScannerProbe(t *testing.T) {
|
||||||
|
probes := []string{
|
||||||
|
"/config.js",
|
||||||
|
"/config.json",
|
||||||
|
"/config.yaml",
|
||||||
|
"/config.php",
|
||||||
|
"/config/config.js",
|
||||||
|
"/database.sql",
|
||||||
|
"/.env",
|
||||||
|
"/demo/.env",
|
||||||
|
"/sendgrid.env",
|
||||||
|
"/config.env",
|
||||||
|
"/.env.lithic",
|
||||||
|
"/credentials",
|
||||||
|
"/credentials.json",
|
||||||
|
"/credentials.ini",
|
||||||
|
"/secrets.json",
|
||||||
|
"/lithic.json",
|
||||||
|
"/sardine.json",
|
||||||
|
"/phpinfo",
|
||||||
|
"/phpinfo.php",
|
||||||
|
"/info.php",
|
||||||
|
"/wp-config.php",
|
||||||
|
"/wp-config.php.bak",
|
||||||
|
"/wp-content/debug.log",
|
||||||
|
"/wp-login.php",
|
||||||
|
"/vendor/composer/installed.json",
|
||||||
|
"/docker-compose.yml",
|
||||||
|
"/composer.json",
|
||||||
|
"/gitconfig",
|
||||||
|
"/runtime-config.js",
|
||||||
|
"/settings.json",
|
||||||
|
"/index.js",
|
||||||
|
"/config/aws.yml",
|
||||||
|
"/v1/.env",
|
||||||
|
"/demo/../.env",
|
||||||
|
"/middleware",
|
||||||
|
"/middleware/security_probe.go",
|
||||||
|
"/controllers/backend_reimburse.go",
|
||||||
|
"/routers",
|
||||||
|
"/routers/spa.go",
|
||||||
|
"/models/reimburse.go",
|
||||||
|
"/services/approval/engine.go",
|
||||||
|
"/scripts/uidmigrate/migrate_uid.go",
|
||||||
|
"/conf/app.conf",
|
||||||
|
}
|
||||||
|
for _, p := range probes {
|
||||||
|
if !IsScannerProbe(p) {
|
||||||
|
t.Errorf("IsScannerProbe(%q) = false, want true", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
normal := []string{
|
||||||
|
"/",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/backend/reimbursements",
|
||||||
|
"/platform/menu/1",
|
||||||
|
"/site/setting",
|
||||||
|
"/site/news/1",
|
||||||
|
"/uploads/logo.png",
|
||||||
|
"/themes/default/index.html",
|
||||||
|
"/static/js/app.js",
|
||||||
|
"/news",
|
||||||
|
"/page/about",
|
||||||
|
}
|
||||||
|
for _, p := range normal {
|
||||||
|
if IsScannerProbe(p) {
|
||||||
|
t.Errorf("IsScannerProbe(%q) = true, want false", p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -310,6 +310,7 @@ func RegisterAuthRoutes() {
|
|||||||
beego.Router("/backend/reimbursements/:id/items/:item_id", &controllers.BackendReimburseController{}, "post:UpdateItem;delete:DeleteItem")
|
beego.Router("/backend/reimbursements/:id/items/:item_id", &controllers.BackendReimburseController{}, "post:UpdateItem;delete:DeleteItem")
|
||||||
beego.Router("/backend/reimbursements/:id", &controllers.BackendReimburseController{}, "get:Detail;post:Update;delete:Delete")
|
beego.Router("/backend/reimbursements/:id", &controllers.BackendReimburseController{}, "get:Detail;post:Update;delete:Delete")
|
||||||
beego.Router("/backend/reimbursements/:id/submit", &controllers.BackendReimburseController{}, "post:Submit")
|
beego.Router("/backend/reimbursements/:id/submit", &controllers.BackendReimburseController{}, "post:Submit")
|
||||||
|
beego.Router("/backend/reimbursements/:id/withdraw", &controllers.BackendReimburseController{}, "post:Withdraw")
|
||||||
beego.Router("/backend/reimbursements/:id/status", &controllers.BackendReimburseController{}, "post:UpdateStatus")
|
beego.Router("/backend/reimbursements/:id/status", &controllers.BackendReimburseController{}, "post:UpdateStatus")
|
||||||
beego.Router("/backend/reimbursements/:id/records", &controllers.BackendReimburseController{}, "get:Records")
|
beego.Router("/backend/reimbursements/:id/records", &controllers.BackendReimburseController{}, "get:Records")
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ import (
|
|||||||
|
|
||||||
// 初始化路由(精简版)
|
// 初始化路由(精简版)
|
||||||
func init() {
|
func init() {
|
||||||
|
// 扫描器探针降噪:.env / config.js / credentials.json / Go 源码路径等探测请求不再逐条打印,
|
||||||
|
// 全部 404 时只定期汇总「未发现泄露」,一旦返回 2xx 立即告警具体路径。
|
||||||
|
// dev 模式的 `[D] router.go` 访问日志由 middleware 的 filterconsole 适配器过滤,
|
||||||
|
// 这里保留 DefaultAccessLogFilter 仅用于 AccessLogs 开启的场景。
|
||||||
|
beego.DefaultAccessLogFilter = middleware.NewSecurityProbeLogFilter(beego.DefaultAccessLogFilter)
|
||||||
|
|
||||||
// 全局 CORS 处理 + 预检请求
|
// 全局 CORS 处理 + 预检请求
|
||||||
// 注意:Allow-Origin 为 * 时不能同时设置 Allow-Credentials: true,否则浏览器会拒绝带 Authorization 的预检(上传/接口跨域常见现象)。
|
// 注意:Allow-Origin 为 * 时不能同时设置 Allow-Credentials: true,否则浏览器会拒绝带 Authorization 的预检(上传/接口跨域常见现象)。
|
||||||
// 当前 JWT 走 Header、前端 axios withCredentials=false,无需携带 Cookie,故不返回 Allow-Credentials。
|
// 当前 JWT 走 Header、前端 axios withCredentials=false,无需携带 Cookie,故不返回 Allow-Credentials。
|
||||||
|
|||||||
+3
-1
@@ -75,7 +75,9 @@ func RegisterPlatformSPA(spaDir string, hosts ...string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// SPA fallback:未知路径(hash 路由直接访问 /xxx/yyy)回 index.html
|
// SPA fallback:未知路径(hash 路由直接访问 /xxx/yyy)回 index.html。
|
||||||
|
// 标记一下,避免安全探针过滤器把「兜底返回首页」误判成敏感文件泄露。
|
||||||
|
ctx.Input.SetData("spa_fallback", true)
|
||||||
http.ServeFile(ctx.ResponseWriter, ctx.Request, spaDir+"/index.html")
|
http.ServeFile(ctx.ResponseWriter, ctx.Request, spaDir+"/index.html")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user