修复bug

This commit is contained in:
hero920103 committed 2026-10-19 17:34:11 +08:00
1 parent 1850ac640e
commit 892ba5886d
20 files changed
+1078 -178

No files matched your search

+37 -30
View File
@@ -669,41 +669,48 @@ func (c *AppOaController) oaDashboardReimburse(tid, uid uint64) map[string]inter
}
}
expenseTypes := []map[string]interface{}{}
paidIDList := make([]uint64, 0, len(paidIDs))
for id := range paidIDs {
paidIDList = append(paidIDList, id)
}
var items []models.BackendReimbursementItem
if _, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("tid", tid).Filter("uid", uid).Filter("is_deleted", 0).
All(&items); err == nil {
typeAgg := map[string]*struct {
count int
amount float64
}{}
for i := range items {
if !paidIDs[items[i].ReimbursementID] {
continue
if len(paidIDList) > 0 {
// 按已打款主单 ID 查明细,不依赖明细行的 tid/uid(历史数据可能不一致)
if _, err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("reimbursement_id__in", paidIDList).Filter("is_deleted", 0).
All(&items); err == nil {
typeAgg := map[string]*struct {
count int
amount float64
}{}
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)
if name == "" {
name = "未分类"
for name, agg := range typeAgg {
expenseTypes = append(expenseTypes, map[string]interface{}{
"name": name, "count": agg.count, "amount": agg.amount,
})
}
agg, ok := typeAgg[name]
if !ok {
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)
})
}
sort.Slice(expenseTypes, func(i, j int) bool {
return expenseTypes[i]["amount"].(float64) > expenseTypes[j]["amount"].(float64)
})
}
return map[string]interface{}{
+1 -1
View File
@@ -258,7 +258,7 @@ func (c *AppOaController) ExpenseSubmit() {
return
}
code, msg := reimburseSubmitCore(claims, id)
code, msg := reimburseSubmitCore(claims, id, readReimburseComment(&c.Controller))
if msg != "" {
c.oaErr(code, code, msg)
return
+4
View File
@@ -24,6 +24,10 @@ type AdminMenuController = BackendMenuController
// - 写接口(增删改、改状态)只有平台端能用,租户端能改菜单等于给自己开功能;
// - 读接口两端都能用,但租户端只能读租户端那一份(cid 强制为 2)。
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.ServeJSON()
}
+235 -52
View File
@@ -454,8 +454,10 @@ func reimburseDetailData(claims *jwtutil.Claims, id uint64, scheme, host string)
return nil, 404, "报销单不存在"
}
var items []models.BackendReimbursementItem
// 只按 reimbursement_id 查明细:主单已按 tid + 申请人校验过。
// 历史单据存在明细 tid 与主单不一致的情况,再按 tid 过滤会导致明细「消失」。
_, 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)
if err != nil && err != orm.ErrNoRows {
return nil, 500, "查询明细失败: " + err.Error()
@@ -533,6 +535,10 @@ func (c *BackendReimburseController) Create() {
}
// reimburseCreateCore 创建报销单(草稿)共享逻辑。
//
// 创建只落草稿:不接受客户端传入的状态,也不启动审批流程。
// 提交审批必须由用户显式调用 Submit(Web 端为列表操作栏的「提交审核」),
// 在此之前草稿可反复编辑,满足周期性报销多次修改的需求。
func reimburseCreateCore(claims *jwtutil.Claims, payload reimbursementPayload) (uint64, int, string) {
applyDate, err := parseReimburseDate(payload.ApplyDate)
if err != nil {
@@ -541,7 +547,8 @@ func reimburseCreateCore(claims *jwtutil.Claims, payload reimbursementPayload) (
uid := uint64(claims.UserID)
row := &models.BackendReimbursement{
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),
}
if err := saveReimbursement(row); err != nil {
@@ -575,14 +582,14 @@ func (c *BackendReimburseController) Update() {
c.reply(200, "success", map[string]interface{}{"id": id})
}
// reimburseUpdateCore 编辑报销单(仅草稿)共享逻辑。
// reimburseUpdateCore 编辑报销单(草稿/已驳回/已撤回)共享逻辑。
func reimburseUpdateCore(claims *jwtutil.Claims, id uint64, payload reimbursementPayload) (int, string) {
row, err := findOwnedReimbursement(claims, id)
if err != nil {
return 404, "报销单不存在"
}
if row.Status != 0 {
return 400, "只有草稿可以编辑"
if !canEditReimbursement(row.Status) {
return 400, "当前状态不可编辑,请先撤回"
}
applyDate, err := parseReimburseDate(payload.ApplyDate)
if err != nil {
@@ -621,7 +628,7 @@ func reimburseDeleteCore(claims *jwtutil.Claims, id uint64) (int, string) {
if err != nil {
return 404, "报销单不存在"
}
if row.Status != 0 {
if row.Status != reimbursementStatusDraft {
return 400, "只有草稿可以删除"
}
now := time.Now()
@@ -651,13 +658,13 @@ func (c *BackendReimburseController) getItemID() (uint64, bool) {
}
// 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{}
err := models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("id", id).
Filter("reimbursement_id", reimbursementID).
Filter("tid", claims.TenantId).
Filter("uid", claims.UserID).
Filter("is_deleted", 0).
One(item)
return item, err
@@ -677,15 +684,82 @@ func validateReimbursementItem(item reimbursementItemPayload) (time.Time, error)
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 状态码, 错误信息),错误信息为空串表示通过。
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)
if err != nil {
return nil, 404, "报销单不存在"
}
if row.Status != 0 {
return nil, 400, "只有草稿可以维护费用明细"
if !canEditReimbursement(row.Status) {
return nil, 400, "当前状态不可编辑费用明细,请先撤回"
}
return row, 200, ""
}
@@ -737,7 +811,7 @@ func (c *BackendReimburseController) AddItem() {
// reimburseAddItemCore 新增费用明细共享逻辑(仅草稿;自动重算报销总额)。
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
if msg == "报销单不存在" {
code = 404
@@ -802,7 +876,7 @@ func (c *BackendReimburseController) UpdateItem() {
// reimburseUpdateItemCore 更新费用明细共享逻辑(仅草稿;自动重算报销总额)。
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
if msg == "报销单不存在" {
code = 404
@@ -864,7 +938,7 @@ func (c *BackendReimburseController) DeleteItem() {
// reimburseDeleteItemCore 删除费用明细共享逻辑(仅草稿;自动重算报销总额)。
func reimburseDeleteItemCore(claims *jwtutil.Claims, reimbursementID, itemID uint64) (int, string) {
if _, _, msg := ensureDraftReimbursement(claims, reimbursementID); msg != "" {
if _, _, msg := ensureEditableReimbursement(claims, reimbursementID); msg != "" {
if msg == "报销单不存在" {
return 404, msg
}
@@ -898,7 +972,7 @@ func (c *BackendReimburseController) Submit() {
c.reply(400, "无效ID", nil)
return
}
code, msg := reimburseSubmitCore(claims, id)
code, msg := reimburseSubmitCore(claims, id, c.readReimburseActPayload())
if msg != "" {
c.reply(code, msg, nil)
return
@@ -906,21 +980,104 @@ func (c *BackendReimburseController) Submit() {
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 审批操作请求体。
type reimburseActPayload struct {
Comment string `json:"comment"`
}
// readReimburseActPayload 读取审批意见(可空)。
func (c *BackendReimburseController) readReimburseActPayload() string {
// readReimburseComment 读取审批/撤回意见(可空),Web 与移动端共用。
func readReimburseComment(ctrl *beego.Controller) string {
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 {
_ = json.Unmarshal(raw, &payload)
}
return strings.TrimSpace(payload.Comment)
}
// readReimburseActPayload 读取审批意见(可空)。
func (c *BackendReimburseController) readReimburseActPayload() string {
return readReimburseComment(&c.Controller)
}
// Approve POST /backend/reimbursements/approve/:id 通过当前审批节点(body: {comment})。
func (c *BackendReimburseController) Approve() {
claims, ok := c.claims()
@@ -1000,12 +1157,12 @@ var reimburseFallbackBizTypes = []string{models.ApprovalBizReimburse, "oa"}
// 审批最终结果由 services/approval.SyncBizResult 回写到报销单状态,保证
// 「审批中心」与「报销管理」看到的状态一致。
// 提交后报销单置为审批中(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)
if err != nil {
return 404, "报销单不存在"
}
if row.Status != 0 {
if !canEditReimbursement(row.Status) {
return 400, "当前状态不可提交"
}
tid := uint64(claims.TenantId)
@@ -1013,6 +1170,7 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
if flowID == 0 {
return 400, "尚未配置审批流程"
}
actorName := reimburseActorName(tid, claims)
instID, err := approvalEngine.Submit(approvalEngine.SubmitInput{
Scope: models.ApprovalScopeTenant,
@@ -1022,7 +1180,7 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
BizID: id,
BizTitle: reimburseBizTitle(row),
SubmitUID: uint64(claims.UserID),
SubmitName: claims.Username,
SubmitName: actorName,
DeptID: reimburseSubmitDeptID(tid, uint64(claims.UserID)),
FormData: map[string]interface{}{
"id": id,
@@ -1037,7 +1195,7 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
}
// 流程未配置审批节点时引擎会直接通过,此时报销单同步为已通过(2)
status := int8(1)
status := reimbursementStatusApproving
var inst models.ApprovalInstance
if e := models.Orm.QueryTable(new(models.ApprovalInstance)).Filter("id", instID).One(&inst); e == nil {
switch inst.Status {
@@ -1057,7 +1215,8 @@ func reimburseSubmitCore(claims *jwtutil.Claims, id uint64) (int, string) {
}
_, err = models.Orm.Insert(&models.BackendApprovalRecord{
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,
})
if err != nil {
@@ -1118,8 +1277,14 @@ type reimbursementStatusPayload struct {
// 报销单状态(0草稿 1审批中 2已通过 3已驳回 4已撤回 5已打款)
const (
reimbursementStatusApproved int8 = 2 // 已通过
reimbursementStatusPaid int8 = 5 // 已打款
// reimbursementStatusDraft 草稿:新建后的唯一初始状态。
// 草稿可反复编辑/增删明细,只有用户在列表点「提交审核」才会进入审批流。
reimbursementStatusDraft int8 = 0
reimbursementStatusApproving int8 = 1 // 审批中
reimbursementStatusApproved int8 = 2 // 已通过
reimbursementStatusRejected int8 = 3 // 已驳回
reimbursementStatusWithdrawn int8 = 4 // 已撤回
reimbursementStatusPaid int8 = 5 // 已打款
)
// UpdateStatus POST /backend/reimbursements/:id/status — 报销人确认款项到账(置为「已打款」)。
@@ -1217,6 +1382,18 @@ func reimburseRecordsData(claims *jwtutil.Claims, id uint64) ([]models.BackendAp
if rows == nil {
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, ""
}
@@ -1603,38 +1780,44 @@ func (c *BackendReimburseController) Dashboard() {
paidIDs[rows[i].ID] = true
}
}
var items []models.BackendReimbursementItem
_, err = models.Orm.QueryTable(new(models.BackendReimbursementItem)).
Filter("tid", claims.TenantId).
Filter("uid", claims.UserID).
Filter("is_deleted", 0).
All(&items)
if err != nil {
c.reply(500, "查询失败", nil)
return
paidIDList := make([]uint64, 0, len(paidIDs))
for id := range paidIDs {
paidIDList = append(paidIDList, id)
}
typeAgg := map[string]*struct {
count int
amount float64
}{}
for i := range items {
if !paidIDs[items[i].ReimbursementID] {
continue
if len(paidIDList) > 0 {
// 按已打款主单 ID 查明细,不依赖明细行的 tid/uid(历史数据可能不一致)
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)
if name == "" {
name = "未分类"
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
}
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))
for name, agg := range typeAgg {
+27
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -82,7 +82,7 @@ func reimburseActCore(claims *jwtutil.Claims, id uint64, pass bool, comment stri
}
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)
hint := result.Message
+4
View File
@@ -37,5 +37,9 @@ func main() {
// 启动支付补偿任务(超时关单 / 业务订单同步重试,5 分钟一轮)
payment.StartPaymentScheduler(make(chan struct{}))
// 保留 dev 模式原有的 `[D] router.go` 访问日志样式,
// 但改走带探针过滤的 console 适配器(见 middleware/security_probe.go)。
beego.BConfig.Log.Outputs = map[string]string{"filterconsole": ""}
beego.Run()
}
+283
View File
@@ -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,
)
}
+92
View File
@@ -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)
}
}
}
+1
View File
@@ -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", &controllers.BackendReimburseController{}, "get:Detail;post:Update;delete:Delete")
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/records", &controllers.BackendReimburseController{}, "get:Records")
+6
View File
@@ -18,6 +18,12 @@ import (
// 初始化路由(精简版)
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 处理 + 预检请求
// 注意:Allow-Origin 为 * 时不能同时设置 Allow-Credentials: true,否则浏览器会拒绝带 Authorization 的预检(上传/接口跨域常见现象)。
// 当前 JWT 走 Header、前端 axios withCredentials=false,无需携带 Cookie,故不返回 Allow-Credentials。
+3 -1
View File
@@ -75,7 +75,9 @@ func RegisterPlatformSPA(spaDir string, hosts ...string) {
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")
})
}