Compare commits

..

10 Commits

Author SHA1 Message Date
zhangjf
480c052ff1 feat: Docker容器化部署配置
- 添加所有后端服务的application-docker.yml配置文件
- 添加前端fund-admin和fund-mobile的Dockerfile和nginx配置
- 更新docker-compose.yml添加前端服务
- 添加.dockerignore优化构建
- 添加deploy.sh一键部署脚本
2026-02-20 10:13:52 +08:00
zhangjf
47703e40c4 feat: 移动端完善与操作日志审计功能
1. 移动端fund-mobile完善:
   - 新增项目列表页面 (project/List.vue)
   - 新增客户列表页面 (customer/List.vue)
   - 新增统一API文件 (api/index.ts)
   - 更新路由配置,新增项目和客户路由
   - 首页增加项目和客户快捷入口

2. 操作日志审计功能:
   - OperationLog实体类: 操作日志数据模型
   - OperationLogMapper: MyBatis-Plus Mapper
   - OperationLogService: 日志服务接口和实现
   - OperationLogController: 日志查询API
   - OperationLogAspect: AOP切面自动记录操作日志
   - 支持异步保存,只记录写操作(增删改)

3. 操作日志功能特性:
   - 自动拦截Controller层方法
   - 记录用户ID、用户名、操作描述、请求参数
   - 记录IP、UserAgent、操作时间、耗时
   - 支持成功/失败状态记录
   - 支持分页查询和历史日志清理
2026-02-20 09:16:00 +08:00
zhangjf
eafb783e2b feat: 定时任务与账期逾期管理功能
1. 定时任务框架:
   - 使用Spring @Scheduled实现定时任务(替代XXL-JOB简化部署)
   - @EnableScheduling启用定时任务
   - 条件配置: fund.schedule.enabled

2. 定时任务列表:
   - 逾期状态更新: 每天凌晨1点执行
   - 逾期提醒通知: 每天上午9点执行
   - 账期预警提醒: 每周一上午10点执行

3. 账期管理API:
   - GET /upcoming-due: 获取即将到期应收款列表
   - 支持指定天数内到期的应收款查询

4. 服务层增强:
   - ReceivableService.getUpcomingDueList(): 查询即将到期应收款
   - ReceivableServiceImpl: 完整实现逾期状态更新逻辑
2026-02-20 09:06:40 +08:00
zhangjf
06efab9596 feat: 支出和应收款模块新增Excel导出功能
后端:
- fund-exp: 新增ExpenseExcel导出实体、导出接口和listExpenses方法
- fund-receipt: 新增ReceivableExcel导出实体、导出接口和listReceivables方法

前端:
- fund-admin: 支出管理页面新增导出按钮
- fund-admin: 应收款管理页面新增导出按钮
- fund-admin: 新增exportExpense和exportReceivable API
2026-02-20 08:36:20 +08:00
zhangjf
ad4176ae8a docs: 更新移动端技术栈为 Vue3 + Vite5 + Vant4
- 架构设计文档:更新前端架构说明和接入层架构图
- 需求文档:更新移动端技术栈描述
- 功能清单:更新移动端模块技术栈
- 移除 UniApp 跨端架构,改为独立的 H5 移动端应用
2026-02-20 08:19:46 +08:00
zhangjf
15e7ed6f1b feat: 所有服务统一配置多租户负载均衡,清理无效配置
1. 业务服务配置(fund-cust/proj/req/exp/receipt)
   - 添加 Nacos metadata.tenant-id 配置
   - 添加 tenant.routing 配置
   - 移除无效的 feign.fund-sys.url 配置

2. 共享服务配置(fund-report/file)
   - 添加 tenant.routing.enabled: false
   - 共享服务不需要租户路由

3. Gateway 配置清理
   - 移除过时的 tenant-group-header 配置
   - 移除过时的 group-separator 配置

4. 删除 Docker 配置文件
   - 删除 fund-sys/application-docker.yml
   - 删除 fund-gateway/application-docker.yml

配置规范:
  业务服务: tenant.routing.enabled=true, metadata.tenant-id=${TENANT_ID:}
  共享服务: tenant.routing.enabled=false
2026-02-19 21:46:11 +08:00
zhangjf
8233ff8040 feat: 支持多租户专属实例(逗号分隔的租户ID列表)
功能增强:
- TENANT_ID 支持逗号分隔的多个租户 ID
- 一个实例可以服务多个指定租户

实例类型:
  共享实例:     TENANT_ID = "" (空)
  单租户专属:   TENANT_ID = "VIP_001"
  多租户专属:   TENANT_ID = "VIP_001,VIP_002,VIP_003"

路由逻辑:
  1. 解析实例 metadata.tenant-id 为租户列表
  2. 检查请求 tenantId 是否在列表中
  3. 匹配成功 → 专属实例
  4. 匹配失败 → 回退共享实例

使用场景:
  - 大客户独占实例(单租户)
  - 多个小客户共享一个实例(多租户)
  - 普通客户使用公共实例(共享)
2026-02-19 21:39:30 +08:00
zhangjf
5a2154c1a1 refactor: 简化路由逻辑,直接使用 tenantId 匹配实例
问题:tenantGroup 是 tenantId 的简单转换,造成冗余
  tenantGroup = "TENANT_" + tenantId.toUpperCase()

解决方案:
1. 直接使用 tenantId 匹配实例
   - 移除 tenantGroup 概念
   - 负载均衡器直接匹配 metadata.tenant-id

2. 简化配置
   - JWT 只需 tenantId 一个字段
   - 实例元数据只有 tenant-id

3. 前端简化
   - 请求头只需 X-Tenant-Id
   - 不再需要 X-Tenant-Group

路由规则:
  共享实例: metadata.tenant-id = "" (空)
  VIP实例:  metadata.tenant-id = "VIP_001"
  匹配逻辑: 找到匹配实例 → VIP专属,找不到 → 共享实例
2026-02-19 21:33:51 +08:00
zhangjf
e52e2ba801 refactor: 简化租户元数据,移除冗余的 tenant-id
问题:metadata 中同时配置 tenant-id 和 tenant-group,但 tenant-id 未被使用

分析:
- tenant-id: 用于数据隔离,但实际由 TenantContextHolder 提供,元数据中的值无意义
- tenant-group: 用于服务路由,负载均衡器实际只使用此字段

解决方案:
1. 移除 metadata.tenant-id 配置
2. 只保留 metadata.tenant-group
3. 更新 NacosMetadataConfig,移除 tenant-id 处理
4. 更新 docker-compose.yml,移除 TENANT_ID 环境变量

配置简化:
  修改前: tenant-id + tenant-group 两个字段
  修改后: 只有 tenant-group 一个字段

实例类型:
  共享实例: tenant-group = "" (空)
  VIP实例:  tenant-group = "TENANT_VIP_001"
2026-02-19 21:26:06 +08:00
zhangjf
330ec6dea9 refactor: 简化多租户路由配置,基于 Nacos 元数据动态匹配
问题:tenant.routing.services 配置在每个服务中重复定义 vip-tenants

解决方案:
1. TenantRoutingProperties 简化
   - 移除 services 映射(vip-tenants 列表)
   - 保留全局配置:enabled, fallback-to-shared, shared-services
   - 路由逻辑改为基于实例元数据动态匹配

2. 配置简化
   - Gateway: 只需全局配置,无需定义各服务的 vip-tenants
   - 服务实例: 只需在 Nacos metadata 中声明 tenant-group
   - 负载均衡器: 从实例 metadata 读取 tenant-group 进行匹配

3. 架构变化
   修改前:配置文件定义 vip-tenants 列表
   修改后:实例注册时声明 tenant-group,负载均衡器动态匹配

示例:
  共享实例 metadata: { tenant-group: "" }
  VIP 实例 metadata: { tenant-group: "TENANT_VIP_001" }
  请求匹配 → 路由到对应实例
2026-02-19 21:18:58 +08:00
164 changed files with 3718 additions and 1040 deletions

50
.dockerignore Normal file
View File

@ -0,0 +1,50 @@
# Git
.git
.gitignore
# IDE
.idea
.vscode
*.iml
*.ipr
*.iws
# Node modules
**/node_modules
# Build output
**/dist
**/target
# Logs
**/logs
*.log
# Documentation
*.md
doc/
# Docker
docker/
Dockerfile
docker-compose*.yml
# Test files
**/test
**/__tests__
**/*.test.ts
**/*.test.js
**/*.spec.ts
**/*.spec.js
# Environment files
.env
.env.*
!.env.example
# OS files
.DS_Store
Thumbs.db
# Qoder
.qoder/

266
deploy.sh Executable file
View File

@ -0,0 +1,266 @@
#!/bin/bash
# 资金服务平台 - Docker 部署脚本
set -e
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
# 打印带颜色的消息
log_info() {
echo -e "${GREEN}[INFO]${NC} $1"
}
log_warn() {
echo -e "${YELLOW}[WARN]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 显示帮助
show_help() {
echo "资金服务平台 Docker 部署脚本"
echo ""
echo "用法: $0 <命令> [选项]"
echo ""
echo "命令:"
echo " start 启动所有服务"
echo " stop 停止所有服务"
echo " restart 重启所有服务"
echo " build 构建所有镜像"
echo " rebuild 重新构建所有镜像(不使用缓存)"
echo " logs 查看服务日志"
echo " status 查看服务状态"
echo " clean 清理未使用的镜像和容器"
echo " init 初始化环境(首次部署)"
echo ""
echo "选项:"
echo " --service <name> 指定服务名称"
echo " --no-cache 构建时不使用缓存"
echo ""
echo "示例:"
echo " $0 start # 启动所有服务"
echo " $0 build --no-cache # 重新构建所有镜像"
echo " $0 logs --service fund-sys # 查看系统服务日志"
}
# 检查 Docker 环境
check_docker() {
if ! command -v docker &> /dev/null; then
log_error "Docker 未安装,请先安装 Docker"
exit 1
fi
if ! docker info &> /dev/null; then
log_error "Docker 未运行,请先启动 Docker"
exit 1
fi
if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then
log_error "Docker Compose 未安装"
exit 1
fi
log_info "Docker 环境检查通过"
}
# 使用 docker-compose 命令(兼容 v1 和 v2
compose_cmd() {
if docker compose version &> /dev/null; then
docker compose "$@"
else
docker-compose "$@"
fi
}
# 初始化环境
init_env() {
log_info "初始化部署环境..."
# 复制环境配置
if [ ! -f .env ]; then
cp docker/.env .env
log_info "已创建 .env 文件"
else
log_warn ".env 文件已存在,跳过"
fi
# 创建必要的目录
mkdir -p docker/grafana/dashboards
mkdir -p docker/grafana/provisioning/datasources
mkdir -p docker/grafana/provisioning/dashboards
mkdir -p docker/prometheus/rules
# 设置权限
chmod +x deploy.sh 2>/dev/null || true
log_info "环境初始化完成"
}
# 构建镜像
build_images() {
local no_cache=""
local service=""
while [[ $# -gt 0 ]]; do
case $1 in
--no-cache)
no_cache="--no-cache"
shift
;;
--service)
service="$2"
shift 2
;;
*)
shift
;;
esac
done
log_info "开始构建 Docker 镜像..."
if [ -n "$service" ]; then
compose_cmd build $no_cache "$service"
else
compose_cmd build $no_cache
fi
log_info "镜像构建完成"
}
# 启动服务
start_services() {
log_info "启动服务..."
compose_cmd up -d
log_info "等待服务启动..."
sleep 10
log_info "服务状态:"
compose_cmd ps
}
# 停止服务
stop_services() {
log_info "停止服务..."
compose_cmd down
log_info "服务已停止"
}
# 重启服务
restart_services() {
stop_services
sleep 3
start_services
}
# 查看日志
view_logs() {
local service=""
while [[ $# -gt 0 ]]; do
case $1 in
--service)
service="$2"
shift 2
;;
*)
shift
;;
esac
done
if [ -n "$service" ]; then
compose_cmd logs -f "$service"
else
compose_cmd logs -f
fi
}
# 查看状态
view_status() {
log_info "服务状态:"
compose_cmd ps
echo ""
log_info "健康检查:"
compose_cmd ps --format "table {{.Name}}\t{{.Status}}"
}
# 清理
clean_up() {
log_info "清理未使用的资源..."
# 停止并删除所有容器
compose_cmd down -v --remove-orphans
# 删除悬空镜像
docker image prune -f
# 删除未使用的网络
docker network prune -f
log_info "清理完成"
}
# 主函数
main() {
if [ $# -eq 0 ]; then
show_help
exit 0
fi
check_docker
case $1 in
start)
shift
start_services "$@"
;;
stop)
shift
stop_services "$@"
;;
restart)
shift
restart_services "$@"
;;
build)
shift
build_images "$@"
;;
rebuild)
shift
build_images --no-cache "$@"
;;
logs)
shift
view_logs "$@"
;;
status)
view_status
;;
clean)
clean_up
;;
init)
init_env
;;
-h|--help)
show_help
;;
*)
log_error "未知命令: $1"
show_help
exit 1
;;
esac
}
main "$@"

View File

@ -219,7 +219,7 @@
--- ---
### 4.10 移动端模块 (UniApp) ### 4.10 移动端模块 (H5)
#### 4.10.1 移动端首页 #### 4.10.1 移动端首页
- 数据概览(今日收支、待收款) - 数据概览(今日收支、待收款)
@ -254,7 +254,7 @@
| 端 | 技术栈 | 说明 | | 端 | 技术栈 | 说明 |
| ------------ | --------------------------------- | ----------------------------------- | | ------------ | --------------------------------- | ----------------------------------- |
| **管理后台** | Vue 3 + TypeScript + Element Plus | 响应式设计,组件丰富 | | **管理后台** | Vue 3 + TypeScript + Element Plus | 响应式设计,组件丰富 |
| **移动端** | UniApp + Vue 3 + uView UI | 一套代码多端发布H5、小程序、App | | **移动端** | Vue 3 + Vite 5 + Vant 4 | 移动端H5响应式应用 |
| **图表库** | ECharts 5.x | 数据可视化、报表展示 | | **图表库** | ECharts 5.x | 数据可视化、报表展示 |
| **构建工具** | Vite 4.x | 快速构建、热更新 | | **构建工具** | Vite 4.x | 快速构建、热更新 |

View File

@ -25,7 +25,8 @@
采用 **微服务架构** + **前后端分离** + **多租户架构** 模式: 采用 **微服务架构** + **前后端分离** + **多租户架构** 模式:
- 后端Spring Cloud Alibaba 微服务框架 - 后端Spring Cloud Alibaba 微服务框架
- 前端Vue 3 + UniApp 多端应用 - 前端Vue 3 + Element Plus 管理后台
- 移动端Vue 3 + Vite 5 + Vant 4 移动端应用
- 数据层MySQL + Redis 缓存(支持多租户隔离) - 数据层MySQL + Redis 缓存(支持多租户隔离)
- 基础设施Nacos 服务治理、Nginx 负载均衡 - 基础设施Nacos 服务治理、Nginx 负载均衡
- 可观测性Head 日志追踪 + 全链路监控 - 可观测性Head 日志追踪 + 全链路监控
@ -2448,13 +2449,13 @@ AOP日志以JSON格式输出包含以下字段
``` ```
┌─────────────────────────────────────────────────────────────────────────────┐ ┌─────────────────────────────────────────────────────────────────────────────┐
│ 接入层 (Access Layer) │ │ 接入层 (Access Layer) │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ ┌─────────────────────────────────┐ ┌─────────────────────────────────┐
│ │ Web端 │ │ 移动端 │ │ 小程序 │ │ H5页面 │ │ │ Web管理端 │ │ 移动端H5 │
│ │ (Vue3) │ │ (UniApp) │ │ (UniApp) │ │ (UniApp) │ │ │ (Vue3 + Element Plus) │ │ (Vue3 + Vite5 + Vant) │
│ └──────┬──────┘ └────────────┘ └──────┬──────┘ └────────────┘ │ └──────────────┬──────────────────┘ └──────────────┬──────────────────┘ │
└─────────┼────────────────┼────────────────┼────────────────┼───────────────┘ └─────────────────┼─────────────────────────────────────┼─────────────────────┘
│ │ │
└────────────────┴────────────────┴────────────────┘ └─────────────────────┬───────────────┘
┌────────────────────────────────────┼────────────────────────────────────────┐ ┌────────────────────────────────────┼────────────────────────────────────────┐
│ 网关层 (Gateway Layer) │ │ 网关层 (Gateway Layer) │
@ -2857,9 +2858,13 @@ public class ProjectService {
| | Axios | 1.x | HTTP客户端 | | | Axios | 1.x | HTTP客户端 |
| | ECharts | 5.x | 图表库 | | | ECharts | 5.x | 图表库 |
| | Vite | 5.x | 构建工具 | | | Vite | 5.x | 构建工具 |
| **移动端** | UniApp | 3.x | 跨端框架 | | **移动端** | Vue | 3.4.x | 前端框架 |
| | Vue | 3.x | 前端框架 | | | Vite | 5.x | 构建工具 |
| | uView UI | 2.x | 移动端UI库 | | | TypeScript | 5.x | 类型安全 |
| | Vant | 4.x | 移动端UI库 |
| | Pinia | 2.x | 状态管理 |
| | Vue Router | 4.x | 路由管理 |
| | Axios | 1.x | HTTP客户端 |
### 3.2 架构分层 ### 3.2 架构分层

View File

@ -20,7 +20,7 @@
| **应收账款管理** | 对公司项目的应收账款进行跟踪、确认、收款记录和账期管理 | | **应收账款管理** | 对公司项目的应收账款进行跟踪、确认、收款记录和账期管理 |
| **项目支出管理** | 对公司项目支出进行申请、审批、执行和核销的全流程管理 | | **项目支出管理** | 对公司项目支出进行申请、审批、执行和核销的全流程管理 |
| **数据可视化** | 提供多维度的财务报表和统计分析,辅助决策 | | **数据可视化** | 提供多维度的财务报表和统计分析,辅助决策 |
| **移动办公** | 支持管理后台和手机端UniApp双端访问,提升办公效率 | | **移动办公** | 支持管理后台和移动端H5双端访问,提升办公效率 |
--- ---
@ -322,7 +322,7 @@ flowchart TD
--- ---
### 3.8 移动端模块 (UniApp) ### 3.8 移动端模块 (H5)
#### 3.8.1 移动端首页 #### 3.8.1 移动端首页
- **数据概览** - **数据概览**
@ -663,7 +663,7 @@ flowchart TD
| 端 | 技术栈 | 说明 | | 端 | 技术栈 | 说明 |
| ------------ | --------------------------------- | ----------------------------------- | | ------------ | --------------------------------- | ----------------------------------- |
| **管理后台** | Vue 3 + TypeScript + Element Plus | 响应式设计,组件丰富 | | **管理后台** | Vue 3 + TypeScript + Element Plus | 响应式设计,组件丰富 |
| **移动端** | UniApp + Vue 3 + uView UI | 一套代码多端发布H5、小程序、App | | **移动端** | Vue 3 + Vite 5 + Vant 4 | 移动端H5响应式应用 |
| **图表库** | ECharts 5.x | 数据可视化、报表展示 | | **图表库** | ECharts 5.x | 数据可视化、报表展示 |
| **构建工具** | Vite 4.x | 快速构建、热更新 | | **构建工具** | Vite 4.x | 快速构建、热更新 |
@ -725,12 +725,12 @@ flowchart TD
**新增功能:** **新增功能:**
- 收款管理功能(收款记录、收款凭证) - 收款管理功能(收款记录、收款凭证)
- 账期管理与逾期提醒 - 账期管理与逾期提醒
- 移动端开发(UniApp - 移动端开发(Vue3 + Vant
- 提醒预警机制 - 提醒预警机制
**交付物:** **交付物:**
- 完整的收款管理 - 完整的收款管理
- 移动端应用H5/小程序) - 移动端H5应用
- 消息提醒功能 - 消息提醒功能
--- ---

View File

@ -184,9 +184,11 @@ services:
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PORT: 6379 REDIS_PORT: 6379
JAVA_OPTS: -Xms256m -Xmx512m JAVA_OPTS: -Xms256m -Xmx512m
# 租户元数据 - 共享实例(无特定租户组,所有租户可用) # 租户 ID - 共享实例(空值,所有租户可用)
TENANT_ID: "1" # TENANT_ID: ""
TENANT_GROUP: ""
# 租户 ID - 多租户专属实例(逗号分隔,服务多个租户)
TENANT_ID: "VIP_001,VIP_002,VIP_003"
ports: ports:
- "8100:8100" - "8100:8100"
depends_on: depends_on:
@ -228,9 +230,8 @@ services:
REDIS_HOST: redis REDIS_HOST: redis
REDIS_PORT: 6379 REDIS_PORT: 6379
JAVA_OPTS: -Xms256m -Xmx512m JAVA_OPTS: -Xms256m -Xmx512m
# 租户元数据 - VIP_001 专属实例 # 租户 ID - 单租户专属实例
TENANT_ID: "1001" TENANT_ID: "VIP_004"
TENANT_GROUP: "TENANT_VIP_001"
ports: ports:
- "8101:8101" - "8101:8101"
depends_on: depends_on:
@ -272,9 +273,8 @@ services:
# REDIS_HOST: redis # REDIS_HOST: redis
# REDIS_PORT: 6379 # REDIS_PORT: 6379
# JAVA_OPTS: -Xms256m -Xmx512m # JAVA_OPTS: -Xms256m -Xmx512m
# # 租户元数据 - VIP_002 专属实例 # # 租户 ID - VIP_002 专属实例
# TENANT_ID: "1002" # TENANT_ID: "VIP_002"
# TENANT_GROUP: "TENANT_VIP_002"
# ports: # ports:
# - "8102:8102" # - "8102:8102"
# depends_on: # depends_on:
@ -510,6 +510,54 @@ services:
networks: networks:
- fund-network - fund-network
# ==================== 前端服务 ====================
# 管理后台前端
fund-admin:
build:
context: ./fund-admin
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: http://localhost:8000
container_name: fund-admin
restart: unless-stopped
ports:
- "80:80"
depends_on:
gateway:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- fund-network
# 移动端H5
fund-mobile:
build:
context: ./fund-mobile
dockerfile: Dockerfile
args:
VITE_API_BASE_URL: http://localhost:8000
container_name: fund-mobile
restart: unless-stopped
ports:
- "81:80"
depends_on:
gateway:
condition: service_healthy
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
networks:
- fund-network
# ==================== 网络配置 ==================== # ==================== 网络配置 ====================
networks: networks:
fund-network: fund-network:

31
fund-admin/.dockerignore Normal file
View File

@ -0,0 +1,31 @@
# Dependencies
node_modules
# Build output
dist
# IDE
.idea
.vscode
*.iml
# Git
.git
.gitignore
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.*
!.env.example
# OS
.DS_Store
Thumbs.db
# Test
**/*.test.ts
**/*.spec.ts

61
fund-admin/Dockerfile Normal file
View File

@ -0,0 +1,61 @@
# 资金服务平台管理后台 - Dockerfile
# 多阶段构建Node构建 + Nginx运行
# ==================== 构建阶段 ====================
FROM node:20-alpine AS builder
WORKDIR /app
# 设置npm镜像加速下载
RUN npm config set registry https://registry.npmmirror.com
# 复制package文件利用缓存
COPY package*.json ./
# 安装依赖
RUN npm ci --legacy-peer-deps
# 复制源代码
COPY . .
# 构建参数API网关地址
ARG VITE_API_BASE_URL=http://localhost:8000
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
# 构建生产版本
RUN npm run build
# ==================== 运行阶段 ====================
FROM nginx:alpine
# 安装必要工具
RUN apk add --no-cache curl tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone && \
apk del tzdata
# 删除默认配置
RUN rm -rf /etc/nginx/conf.d/default.conf
# 复制nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 创建非root用户
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
# 暴露端口
EXPOSE 80
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost/ || exit 1
# 启动nginx
CMD ["nginx", "-g", "daemon off;"]

54
fund-admin/nginx.conf Normal file
View File

@ -0,0 +1,54 @@
# 资金服务平台管理后台 Nginx 配置
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API代理到网关
location /api/ {
proxy_pass http://fund-gateway:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时配置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 文件上传大小限制
client_max_body_size 100m;
}
# Vue Router History模式支持
location / {
try_files $uri $uri/ /index.html;
}
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 错误页面
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@ -62,3 +62,39 @@ export function rejectExpense(id: number, comment: string) {
export function confirmPayExpense(id: number, payChannel: string, payVoucher?: string) { export function confirmPayExpense(id: number, payChannel: string, payVoucher?: string) {
return request.put(`/exp/api/v1/exp/expense/${id}/confirm-pay?payChannel=${payChannel}&payVoucher=${payVoucher || ''}`) return request.put(`/exp/api/v1/exp/expense/${id}/confirm-pay?payChannel=${payChannel}&payVoucher=${payVoucher || ''}`)
} }
// 导出支出明细
export function exportExpense(params?: { title?: string; expenseType?: number; approvalStatus?: number; payStatus?: number }) {
const baseUrl = import.meta.env.VITE_API_URL || ''
const token = localStorage.getItem('token')
const tenantId = localStorage.getItem('tenantId') || '1'
const queryParams = new URLSearchParams()
if (params?.title) queryParams.append('title', params.title)
if (params?.expenseType) queryParams.append('expenseType', String(params.expenseType))
if (params?.approvalStatus !== undefined) queryParams.append('approvalStatus', String(params.approvalStatus))
if (params?.payStatus !== undefined) queryParams.append('payStatus', String(params.payStatus))
const queryString = queryParams.toString()
const url = `${baseUrl}/exp/api/v1/exp/expense/export${queryString ? '?' + queryString : ''}`
return fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
'X-Tenant-Id': tenantId
}
}).then(response => {
if (!response.ok) throw new Error('导出失败')
const contentDisposition = response.headers.get('Content-Disposition')
let filename = '支出明细.xlsx'
if (contentDisposition) {
const match = contentDisposition.match(/filename\*=utf-8''(.+)/i)
if (match && match[1]) {
filename = decodeURIComponent(match[1])
}
}
return response.blob().then(blob => ({ blob, filename }))
})
}

View File

@ -49,3 +49,39 @@ export function getReceiptById(id: number) {
export function createReceipt(data: any) { export function createReceipt(data: any) {
return request.post('/receipt/api/v1/receipt/receipt', data) return request.post('/receipt/api/v1/receipt/receipt', data)
} }
// 导出应收款明细
export function exportReceivable(params?: { projectId?: number; customerId?: number; status?: string; confirmStatus?: number }) {
const baseUrl = import.meta.env.VITE_API_URL || ''
const token = localStorage.getItem('token')
const tenantId = localStorage.getItem('tenantId') || '1'
const queryParams = new URLSearchParams()
if (params?.projectId) queryParams.append('projectId', String(params.projectId))
if (params?.customerId) queryParams.append('customerId', String(params.customerId))
if (params?.status) queryParams.append('status', params.status)
if (params?.confirmStatus !== undefined) queryParams.append('confirmStatus', String(params.confirmStatus))
const queryString = queryParams.toString()
const url = `${baseUrl}/receipt/api/v1/receipt/receivable/export${queryString ? '?' + queryString : ''}`
return fetch(url, {
headers: {
Authorization: `Bearer ${token}`,
'X-Tenant-Id': tenantId
}
}).then(response => {
if (!response.ok) throw new Error('导出失败')
const contentDisposition = response.headers.get('Content-Disposition')
let filename = '应收款明细.xlsx'
if (contentDisposition) {
const match = contentDisposition.match(/filename\*=utf-8''(.+)/i)
if (match && match[1]) {
filename = decodeURIComponent(match[1])
}
}
return response.blob().then(blob => ({ blob, filename }))
})
}

View File

@ -40,6 +40,7 @@
<el-card shadow="never" style="margin-top: 10px"> <el-card shadow="never" style="margin-top: 10px">
<div style="margin-bottom: 15px"> <div style="margin-bottom: 15px">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增支出</el-button> <el-button type="primary" :icon="Plus" @click="handleAdd">新增支出</el-button>
<el-button type="success" :icon="Download" @click="handleExport" :loading="exporting">导出Excel</el-button>
</div> </div>
<el-table :data="tableData" v-loading="loading" border stripe> <el-table :data="tableData" v-loading="loading" border stripe>
@ -262,7 +263,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Search, Refresh, Plus, Edit, Delete, View, Check, Document } from '@element-plus/icons-vue' import { Search, Refresh, Plus, Edit, Delete, View, Check, Document, Download } from '@element-plus/icons-vue'
import { import {
getExpenseList, getExpenseList,
createExpense, createExpense,
@ -270,7 +271,8 @@ import {
deleteExpense, deleteExpense,
submitExpense, submitExpense,
approveExpense, approveExpense,
rejectExpense rejectExpense,
exportExpense
} from '@/api/expense' } from '@/api/expense'
import { getExpenseTypeTree } from '@/api/expense' import { getExpenseTypeTree } from '@/api/expense'
import { getProjectList } from '@/api/project' import { getProjectList } from '@/api/project'
@ -285,6 +287,7 @@ const fileList = ref<any[]>([])
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const exporting = ref(false)
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
const total = ref(0) const total = ref(0)
const expenseTypeList = ref<any[]>([]) const expenseTypeList = ref<any[]>([])
@ -558,6 +561,34 @@ const resetForm = () => {
formRef.value?.clearValidate() formRef.value?.clearValidate()
} }
// Excel
const handleExport = async () => {
exporting.value = true
try {
const params: any = {}
if (queryParams.title) params.title = queryParams.title
if (queryParams.expenseTypeId) params.expenseType = queryParams.expenseTypeId
if (queryParams.approvalStatus) params.approvalStatus = parseInt(queryParams.approvalStatus)
if (queryParams.payStatus) params.payStatus = parseInt(queryParams.payStatus)
const { blob, filename } = await exportExpense(params)
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败')
} finally {
exporting.value = false
}
}
onMounted(() => { onMounted(() => {
fetchData() fetchData()
fetchExpenseTypes() fetchExpenseTypes()

View File

@ -27,6 +27,7 @@
<div style="margin-bottom: 15px"> <div style="margin-bottom: 15px">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增应收款</el-button> <el-button type="primary" :icon="Plus" @click="handleAdd">新增应收款</el-button>
<el-button type="success" :icon="Money">批量收款</el-button> <el-button type="success" :icon="Money">批量收款</el-button>
<el-button type="success" :icon="Download" @click="handleExport" :loading="exporting">导出Excel</el-button>
</div> </div>
<el-table :data="tableData" v-loading="loading" border stripe> <el-table :data="tableData" v-loading="loading" border stripe>
@ -262,14 +263,15 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, onMounted } from 'vue' import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Search, Refresh, Plus, Edit, Delete, View, Money } from '@element-plus/icons-vue' import { Search, Refresh, Plus, Edit, Delete, View, Money, Download } from '@element-plus/icons-vue'
import { import {
getReceivableList, getReceivableList,
createReceivable, createReceivable,
updateReceivable, updateReceivable,
deleteReceivable, deleteReceivable,
recordReceipt, recordReceipt,
getReceiptRecords getReceiptRecords,
exportReceivable
} from '@/api/receivable' } from '@/api/receivable'
import { getProjectList } from '@/api/project' import { getProjectList } from '@/api/project'
import { getCustomerList } from '@/api/customer' import { getCustomerList } from '@/api/customer'
@ -277,6 +279,7 @@ import { getCustomerList } from '@/api/customer'
const loading = ref(false) const loading = ref(false)
const submitLoading = ref(false) const submitLoading = ref(false)
const receiptLoading = ref(false) const receiptLoading = ref(false)
const exporting = ref(false)
const tableData = ref<any[]>([]) const tableData = ref<any[]>([])
const total = ref(0) const total = ref(0)
const projectList = ref<any[]>([]) const projectList = ref<any[]>([])
@ -500,6 +503,31 @@ const resetForm = () => {
formRef.value?.clearValidate() formRef.value?.clearValidate()
} }
// Excel
const handleExport = async () => {
exporting.value = true
try {
const params: any = {}
if (queryParams.receiptStatus) params.status = queryParams.receiptStatus
const { blob, filename } = await exportReceivable(params)
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
ElMessage.success('导出成功')
} catch (error) {
console.error('导出失败:', error)
ElMessage.error('导出失败')
} finally {
exporting.value = false
}
}
onMounted(() => { onMounted(() => {
fetchData() fetchData()
fetchProjects() fetchProjects()

View File

@ -3,23 +3,31 @@ package com.fundplatform.common.config;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import java.util.*; import java.util.Arrays;
import java.util.List;
/** /**
* 多租户路由配置属性 * 多租户路由配置属性
* *
* <p>支持从 YAML 配置文件读取租户路由配置</p> * <p>租户路由基于 Nacos 服务实例的 metadata.tenant-id 元数据进行匹配</p>
* *
* <p>配置示例</p> * <h3>工作原理</h3>
* <pre>
* 1. 服务实例注册到 Nacos metadata 中声明 tenant-id
* - 共享实例: tenant-id 为空或不存在
* - VIP 实例: tenant-id = "VIP_001"
*
* 2. 负载均衡器根据请求中的 tenantId 匹配实例
* - 找到匹配的 tenant-id VIP 专属实例
* - 找不到 回退到共享实例
* </pre>
*
* <h3>配置示例</h3>
* <pre> * <pre>
* tenant: * tenant:
* routing: * routing:
* enabled: true * enabled: true
* default-tenant-id: 1 * tenant-header: X-Tenant-Id
* services:
* fund-sys:
* vip-tenants:
* - TENANT_VIP_001
* fallback-to-shared: true * fallback-to-shared: true
* </pre> * </pre>
*/ */
@ -33,12 +41,6 @@ public class TenantRoutingProperties {
/** 租户 ID 请求头 */ /** 租户 ID 请求头 */
private String tenantHeader = "X-Tenant-Id"; private String tenantHeader = "X-Tenant-Id";
/** 租户组请求头 */
private String tenantGroupHeader = "X-Tenant-Group";
/** 服务组分隔符 */
private String groupSeparator = "TENANT_";
/** 默认租户 ID当未指定时使用 */ /** 默认租户 ID当未指定时使用 */
private String defaultTenantId = "1"; private String defaultTenantId = "1";
@ -49,13 +51,8 @@ public class TenantRoutingProperties {
"fund-file" "fund-file"
); );
/** 租户服务配置映射 */ /** 是否回退到共享实例(当找不到租户专属实例时) */
private Map<String, TenantServiceConfig> services = new HashMap<>(); private boolean fallbackToShared = true;
/** 租户服务配置(旧版兼容) */
private Map<String, TenantServiceConfig> tenantConfigs = new HashMap<>();
// Getters and Setters
public boolean isEnabled() { public boolean isEnabled() {
return enabled; return enabled;
@ -73,22 +70,6 @@ public class TenantRoutingProperties {
this.tenantHeader = tenantHeader; this.tenantHeader = tenantHeader;
} }
public String getTenantGroupHeader() {
return tenantGroupHeader;
}
public void setTenantGroupHeader(String tenantGroupHeader) {
this.tenantGroupHeader = tenantGroupHeader;
}
public String getGroupSeparator() {
return groupSeparator;
}
public void setGroupSeparator(String groupSeparator) {
this.groupSeparator = groupSeparator;
}
public String getDefaultTenantId() { public String getDefaultTenantId() {
return defaultTenantId; return defaultTenantId;
} }
@ -105,101 +86,6 @@ public class TenantRoutingProperties {
this.sharedServices = sharedServices; this.sharedServices = sharedServices;
} }
public Map<String, TenantServiceConfig> getServices() {
return services;
}
public void setServices(Map<String, TenantServiceConfig> services) {
this.services = services;
}
public Map<String, TenantServiceConfig> getTenantConfigs() {
return tenantConfigs;
}
public void setTenantConfigs(Map<String, TenantServiceConfig> tenantConfigs) {
this.tenantConfigs = tenantConfigs;
}
/**
* 构建租户组名称
*/
public String buildTenantGroup(String tenantId) {
if (tenantId == null || tenantId.isEmpty()) {
return "DEFAULT";
}
return getGroupSeparator() + tenantId.toUpperCase();
}
/**
* 判断是否为共享服务
*/
public boolean isSharedService(String serviceName) {
return sharedServices.contains(serviceName);
}
/**
* 获取服务的 VIP 租户列表
*/
public List<String> getVipTenants(String serviceName) {
TenantServiceConfig config = services.get(serviceName);
if (config != null && config.getVipTenants() != null) {
return config.getVipTenants();
}
return Collections.emptyList();
}
/**
* 判断租户是否为某个服务的 VIP 租户
*/
public boolean isVipTenant(String serviceName, String tenantGroup) {
if (tenantGroup == null || tenantGroup.isEmpty()) {
return false;
}
List<String> vipTenants = getVipTenants(serviceName);
return vipTenants.contains(tenantGroup);
}
/**
* 判断服务是否启用了共享实例回退
*/
public boolean isFallbackToShared(String serviceName) {
TenantServiceConfig config = services.get(serviceName);
if (config != null) {
return config.isFallbackToShared();
}
return true; // 默认启用回退
}
/**
* 租户服务配置
*/
public static class TenantServiceConfig {
/** VIP 租户列表(优先路由到专属实例) */
private List<String> vipTenants = new ArrayList<>();
/** 是否回退到共享实例 */
private boolean fallbackToShared = true;
/** 租户 ID */
private String tenantId;
/** 服务实例配置 */
private Map<String, ServiceInstanceConfig> instances = new HashMap<>();
/** 数据库配置(一库一租户模式) */
private DatabaseConfig database;
// Getters and Setters
public List<String> getVipTenants() {
return vipTenants;
}
public void setVipTenants(List<String> vipTenants) {
this.vipTenants = vipTenants != null ? vipTenants : new ArrayList<>();
}
public boolean isFallbackToShared() { public boolean isFallbackToShared() {
return fallbackToShared; return fallbackToShared;
} }
@ -208,130 +94,10 @@ public class TenantRoutingProperties {
this.fallbackToShared = fallbackToShared; this.fallbackToShared = fallbackToShared;
} }
public String getTenantId() {
return tenantId;
}
public void setTenantId(String tenantId) {
this.tenantId = tenantId;
}
public Map<String, ServiceInstanceConfig> getInstances() {
return instances;
}
public void setInstances(Map<String, ServiceInstanceConfig> instances) {
this.instances = instances;
}
public DatabaseConfig getDatabase() {
return database;
}
public void setDatabase(DatabaseConfig database) {
this.database = database;
}
}
/** /**
* 服务实例配置 * 判断是否为共享服务
*/ */
public static class ServiceInstanceConfig { public boolean isSharedService(String serviceName) {
/** 服务名 */ return sharedServices != null && sharedServices.contains(serviceName);
private String serviceName;
/** 端口号 */
private int port = 8080;
/** 实例数(用于负载均衡) */
private int replicas = 1;
/** 权重(用于加权负载均衡) */
private int weight = 1;
// Getters and Setters
public String getServiceName() {
return serviceName;
}
public void setServiceName(String serviceName) {
this.serviceName = serviceName;
}
public int getPort() {
return port;
}
public void setPort(int port) {
this.port = port;
}
public int getReplicas() {
return replicas;
}
public void setReplicas(int replicas) {
this.replicas = replicas;
}
public int getWeight() {
return weight;
}
public void setWeight(int weight) {
this.weight = weight;
}
}
/**
* 数据库配置用于一库一租户
*/
public static class DatabaseConfig {
/** JDBC URL */
private String url;
/** 用户名 */
private String username;
/** 密码 */
private String password;
/** 驱动类名 */
private String driverClassName = "com.mysql.cj.jdbc.Driver";
// Getters and Setters
public String getUrl() {
return url;
}
public void setUrl(String url) {
this.url = url;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
public String getDriverClassName() {
return driverClassName;
}
public void setDriverClassName(String driverClassName) {
this.driverClassName = driverClassName;
}
} }
} }

View File

@ -14,6 +14,8 @@ import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBal
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier; import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.ThreadLocalRandom;
@ -24,17 +26,22 @@ import java.util.stream.Collectors;
* *
* <p>根据租户 ID 进行服务实例路由支持</p> * <p>根据租户 ID 进行服务实例路由支持</p>
* <ul> * <ul>
* <li>租户专属实例优先</li> * <li>租户专属实例优先metadata.tenant-id 匹配</li>
* <li>共享实例回退</li> * <li>共享实例回退metadata.tenant-id 为空</li>
* <li>轮询负载均衡</li> * <li>随机负载均衡</li>
* <li>混合模式VIP 租户专属 + 普通租户共享</li>
* </ul> * </ul>
* *
* <h3>使用场景</h3> * <h3>路由规则</h3>
* <pre> * <pre>
* 混合模式部署 * 1. 查找 metadata.tenant-id 包含请求 tenantId 的实例 专属实例
* - VIP 客户独立部署服务实例 tenant-group 标签 * 2. 找不到 回退到共享实例metadata.tenant-id 为空
* - 普通客户共享服务实例 tenant-group 标签 * </pre>
*
* <h3>实例配置</h3>
* <pre>
* 共享实例: metadata.tenant-id = "" ()
* 单租户专属: metadata.tenant-id = "VIP_001"
* 多租户专属: metadata.tenant-id = "VIP_001,VIP_002,VIP_003"
* </pre> * </pre>
*/ */
public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer { public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {
@ -61,9 +68,8 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
return getDefaultResponse(); return getDefaultResponse();
} }
// 从请求上下文获取租户信息 // 从请求上下文获取租户 ID
String tenantId = getTenantIdFromRequest(request); String tenantId = getTenantIdFromRequest(request);
String tenantGroup = buildTenantGroup(tenantId);
if (supplierProvider == null) { if (supplierProvider == null) {
logger.warn("[TenantLB] ServiceInstanceListSupplier 未提供"); logger.warn("[TenantLB] ServiceInstanceListSupplier 未提供");
@ -77,7 +83,7 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
} }
return supplier.get().next() return supplier.get().next()
.map(instances -> filterByTenantGroup(instances, tenantGroup)) .map(instances -> filterByTenantId(instances, tenantId))
.map(this::getInstanceResponse); .map(this::getInstanceResponse);
} }
@ -123,34 +129,22 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
} }
/** /**
* 构建租户组名称 * 根据租户 ID 过滤服务实例
*/
String buildTenantGroup(String tenantId) {
if (routingProperties != null) {
return routingProperties.buildTenantGroup(tenantId);
}
// 默认逻辑
if (tenantId == null || tenantId.isEmpty()) {
return "DEFAULT";
}
return "TENANT_" + tenantId.toUpperCase();
}
/**
* 根据租户组过滤服务实例
* *
* <p>路由策略</p> * <p>路由策略</p>
* <ol> * <ol>
* <li>优先选择租户专属实例metadata.tenant-group 匹配</li> * <li>优先选择租户专属实例metadata.tenant-id 包含请求的 tenantId</li>
* <li>回退到共享实例 tenant-group 标签</li> * <li>回退到共享实例metadata.tenant-id 为空或不存在</li>
* </ol> * </ol>
*
* <p>tenant-id 支持逗号分隔的多个租户 ID</p>
*/ */
List<ServiceInstance> filterByTenantGroup(List<ServiceInstance> instances, String tenantGroup) { List<ServiceInstance> filterByTenantId(List<ServiceInstance> instances, String tenantId) {
if (instances == null || instances.isEmpty()) { if (instances == null || instances.isEmpty()) {
return instances; return instances;
} }
logger.debug("[TenantLB] 租户组:{},候选实例数:{}", tenantGroup, instances.size()); logger.debug("[TenantLB] 租户 ID{},候选实例数:{}", tenantId, instances.size());
// 检查是否为共享服务不需要租户路由 // 检查是否为共享服务不需要租户路由
if (routingProperties != null && routingProperties.isSharedService(serviceId)) { if (routingProperties != null && routingProperties.isSharedService(serviceId)) {
@ -159,40 +153,38 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
} }
// 优先选择租户专属实例 // 优先选择租户专属实例
if (tenantId != null && !tenantId.isEmpty()) {
List<ServiceInstance> tenantInstances = instances.stream() List<ServiceInstance> tenantInstances = instances.stream()
.filter(inst -> { .filter(inst -> {
Map<String, String> metadata = inst.getMetadata(); List<String> allowedTenants = parseTenantIds(inst);
if (metadata == null) return false; if (allowedTenants.contains(tenantId)) {
String instanceGroup = metadata.get("tenant-group"); logger.debug("[TenantLB] 匹配租户专属实例:{}:{} (允许租户: {})",
boolean match = tenantGroup.equals(instanceGroup); inst.getHost(), inst.getPort(), allowedTenants);
if (match) { return true;
logger.debug("[TenantLB] 匹配租户实例:{}:{}", inst.getHost(), inst.getPort());
} }
return match; return false;
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
if (!tenantInstances.isEmpty()) { if (!tenantInstances.isEmpty()) {
logger.info("[TenantLB] 找到 {} 个租户专属实例,租户{}", tenantInstances.size(), tenantGroup); logger.info("[TenantLB] 找到 {} 个租户专属实例,租户 ID{}", tenantInstances.size(), tenantId);
return tenantInstances; return tenantInstances;
} }
}
// 检查是否启用回退到共享实例 // 检查是否启用回退到共享实例
boolean fallbackEnabled = true; boolean fallbackEnabled = routingProperties == null || routingProperties.isFallbackToShared();
if (routingProperties != null) {
fallbackEnabled = routingProperties.isFallbackToShared(serviceId);
}
if (!fallbackEnabled) { if (!fallbackEnabled) {
logger.warn("[TenantLB] 服务 {} 未启用共享实例回退,返回空列表", serviceId); logger.warn("[TenantLB] 服务 {} 未启用共享实例回退,返回空列表", serviceId);
return List.of(); return Collections.emptyList();
} }
// 回退到共享实例 tenant-group 标签 // 回退到共享实例metadata.tenant-id 为空或不存在
List<ServiceInstance> sharedInstances = instances.stream() List<ServiceInstance> sharedInstances = instances.stream()
.filter(inst -> { .filter(inst -> {
Map<String, String> metadata = inst.getMetadata(); List<String> allowedTenants = parseTenantIds(inst);
return metadata == null || !metadata.containsKey("tenant-group"); return allowedTenants.isEmpty(); // 空列表表示共享实例
}) })
.collect(Collectors.toList()); .collect(Collectors.toList());
@ -200,6 +192,30 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
return sharedInstances; return sharedInstances;
} }
/**
* 解析实例元数据中的租户 ID 列表
*
* @param inst 服务实例
* @return 租户 ID 列表空列表表示共享实例
*/
private List<String> parseTenantIds(ServiceInstance inst) {
Map<String, String> metadata = inst.getMetadata();
if (metadata == null) {
return Collections.emptyList();
}
String tenantIdStr = metadata.get("tenant-id");
if (tenantIdStr == null || tenantIdStr.trim().isEmpty()) {
return Collections.emptyList();
}
// 支持逗号分隔的多个租户 ID
return Arrays.stream(tenantIdStr.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
/** /**
* 从实例列表中选择一个随机 * 从实例列表中选择一个随机
*/ */
@ -209,10 +225,9 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
return new EmptyResponse(); return new EmptyResponse();
} }
// 随机选择
int index = ThreadLocalRandom.current().nextInt(instances.size()); int index = ThreadLocalRandom.current().nextInt(instances.size());
ServiceInstance chosen = instances.get(index); ServiceInstance chosen = instances.get(index);
logger.info("[TenantLB] 选择实例:{}:{} (index={})", chosen.getHost(), chosen.getPort(), index); logger.info("[TenantLB] 选择实例:{}:{}", chosen.getHost(), chosen.getPort());
return new DefaultResponse(chosen); return new DefaultResponse(chosen);
} }

View File

@ -8,19 +8,27 @@ import org.springframework.cloud.client.serviceregistry.Registration;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
/** /**
* Nacos 服务注册元数据配置 * Nacos 服务注册元数据配置
* *
* <p>服务启动时自动注册租户标签 Nacos支持租户感知的负载均衡</p> * <p>服务启动时自动注册租户 ID Nacos支持租户感知的负载均衡</p>
* *
* <p>支持两种配置方式</p> * <h3>租户 ID 配置规则</h3>
* <ul> * <pre>
* <li>环境变量TENANT_ID, TENANT_GROUPDocker 环境推荐</li> * 共享实例: tenant-id 为空或不配置 所有租户都可使用
* <li>配置文件spring.cloud.nacos.discovery.metadata.tenant-id/tenant-group</li> * VIP实例: tenant-id = "VIP_001" 仅该租户可用
* </ul> * </pre>
*
* <h3>配置方式</h3>
* <pre>
* # 方式1: 环境变量Docker/K8s 推荐
* TENANT_ID=VIP_001
*
* # 方式2: 配置文件
* spring.cloud.nacos.discovery.metadata.tenant-id=VIP_001
* </pre>
*/ */
@Configuration @Configuration
@ConditionalOnProperty(name = "tenant.routing.enabled", havingValue = "true", matchIfMissing = true) @ConditionalOnProperty(name = "tenant.routing.enabled", havingValue = "true", matchIfMissing = true)
@ -32,74 +40,38 @@ public class NacosMetadataConfig {
private String applicationName; private String applicationName;
/** /**
* 租户 ID优先级环境变量 > 配置文件 > 默认值 * 租户 ID优先级环境变量 > 配置文件
*/
@Value("${TENANT_ID:${spring.cloud.nacos.discovery.metadata.tenant-id:1}}")
private String tenantId;
/**
* 租户组优先级环境变量 > 配置文件 > 自动生成
* 为空表示共享实例供所有租户使用 * 为空表示共享实例供所有租户使用
*/ */
@Value("${TENANT_GROUP:${spring.cloud.nacos.discovery.metadata.tenant-group:}}") @Value("${TENANT_ID:${spring.cloud.nacos.discovery.metadata.tenant-id:}}")
private String tenantGroup; private String tenantId;
/**
* Nacos Registration Bean用于动态添加元数据
*/
private final Registration registration; private final Registration registration;
public NacosMetadataConfig(Registration registration) { public NacosMetadataConfig(Registration registration) {
this.registration = registration; this.registration = registration;
} }
/**
* 初始化 Nacos 元数据
*/
@PostConstruct @PostConstruct
public void init() { public void init() {
logger.info("[Nacos Metadata] 应用名:{}", applicationName);
logger.info("[Nacos Metadata] 租户 ID: {}, 租户组:{}", tenantId, tenantGroup);
// 动态添加租户元数据到服务注册信息
if (registration != null && registration.getMetadata() != null) { if (registration != null && registration.getMetadata() != null) {
Map<String, String> metadata = registration.getMetadata(); Map<String, String> metadata = registration.getMetadata();
// 添加租户 ID // 添加租户 ID 元数据
if (tenantId != null && !tenantId.isEmpty()) { if (tenantId != null && !tenantId.isEmpty()) {
metadata.put("tenant-id", tenantId); metadata.put("tenant-id", tenantId);
} logger.info("[Nacos] {} 注册为 VIP 专属实例,租户 ID{}", applicationName, tenantId);
// 添加租户组VIP 专属实例才有值共享实例为空
if (tenantGroup != null && !tenantGroup.isEmpty()) {
metadata.put("tenant-group", tenantGroup);
logger.info("[Nacos Metadata] 注册为 VIP 专属实例,租户组:{}", tenantGroup);
} else { } else {
logger.info("[Nacos Metadata] 注册为共享实例,供所有租户使用"); logger.info("[Nacos] {} 注册为共享实例,供所有租户使用", applicationName);
} }
logger.info("[Nacos Metadata] 服务元数据:{}", metadata);
} }
} }
/**
* 获取当前租户 ID
*/
public String getTenantId() { public String getTenantId() {
return tenantId; return tenantId;
} }
/**
* 获取当前租户组
*/
public String getTenantGroup() {
return tenantGroup;
}
/**
* 判断是否为 VIP 专属实例
*/
public boolean isVipInstance() { public boolean isVipInstance() {
return tenantGroup != null && !tenantGroup.isEmpty(); return tenantId != null && !tenantId.isEmpty();
} }
} }

View File

@ -4,13 +4,10 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.cloud.client.DefaultServiceInstance; import org.springframework.cloud.client.DefaultServiceInstance;
import org.springframework.cloud.client.ServiceInstance; import org.springframework.cloud.client.ServiceInstance;
import org.springframework.cloud.client.loadbalancer.Request;
import org.springframework.cloud.client.loadbalancer.RequestData;
import java.util.*; import java.util.*;
import static org.junit.jupiter.api.Assertions.*; import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/** /**
* 租户负载均衡器测试 * 租户负载均衡器测试
@ -25,71 +22,138 @@ class TenantAwareLoadBalancerTest {
} }
@Test @Test
void testBuildTenantGroup() { void testSingleTenantInstance() {
// 测试租户组名称构建 // 单租户专属实例
String group1 = invokeBuildTenantGroup(loadBalancer, "1"); List<ServiceInstance> instances = Arrays.asList(
assertEquals("TENANT_1", group1); createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-id", "VIP_001")),
createInstance("fund-sys", "192.168.1.2", 8101, Map.of("tenant-id", "VIP_002")),
createInstance("fund-sys", "192.168.1.3", 8102, Collections.emptyMap()) // 共享实例
);
String group2 = invokeBuildTenantGroup(loadBalancer, "tenant_001"); // VIP_001 匹配专属实例
assertEquals("TENANT_TENANT_001", group2); List<ServiceInstance> vip1Instances = invokeFilterByTenantId(loadBalancer, instances, "VIP_001");
assertEquals(1, vip1Instances.size());
assertEquals("VIP_001", vip1Instances.get(0).getMetadata().get("tenant-id"));
String group3 = invokeBuildTenantGroup(loadBalancer, null); // VIP_002 匹配专属实例
assertEquals("DEFAULT", group3); List<ServiceInstance> vip2Instances = invokeFilterByTenantId(loadBalancer, instances, "VIP_002");
assertEquals(1, vip2Instances.size());
String group4 = invokeBuildTenantGroup(loadBalancer, ""); // 未知租户回退到共享实例
assertEquals("DEFAULT", group4); List<ServiceInstance> unknownInstances = invokeFilterByTenantId(loadBalancer, instances, "VIP_999");
assertEquals(1, unknownInstances.size());
assertFalse(unknownInstances.get(0).getMetadata().containsKey("tenant-id"));
} }
@Test @Test
void testFilterByTenantGroup() { void testMultiTenantInstance() {
// 创建测试实例 // 多租户专属实例一个实例服务多个租户
List<ServiceInstance> instances = Arrays.asList( List<ServiceInstance> instances = Arrays.asList(
createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-group", "TENANT_1")), // 实例1服务 VIP_001, VIP_002, VIP_003
createInstance("fund-sys", "192.168.1.2", 8101, Map.of("tenant-group", "TENANT_1")), createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-id", "VIP_001,VIP_002,VIP_003")),
createInstance("fund-sys", "192.168.1.3", 8102, Map.of("tenant-group", "TENANT_2")), // 实例2服务 VIP_004, VIP_005
createInstance("fund-sys", "192.168.1.4", 8103, Collections.emptyMap()) // 共享实例 createInstance("fund-sys", "192.168.1.2", 8101, Map.of("tenant-id", "VIP_004,VIP_005")),
// 实例3共享实例
createInstance("fund-sys", "192.168.1.10", 8110, Collections.emptyMap())
); );
// 测试租户 1 过滤 // VIP_001 匹配实例1
List<ServiceInstance> tenant1Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_1"); List<ServiceInstance> vip1Instances = invokeFilterByTenantId(loadBalancer, instances, "VIP_001");
assertEquals(2, tenant1Instances.size()); assertEquals(1, vip1Instances.size());
assertTrue(tenant1Instances.stream().allMatch(i -> "TENANT_1".equals(i.getMetadata().get("tenant-group")))); assertEquals("8100", String.valueOf(vip1Instances.get(0).getPort()));
// 测试租户 2 过滤 // VIP_003 也匹配实例1
List<ServiceInstance> tenant2Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_2"); List<ServiceInstance> vip3Instances = invokeFilterByTenantId(loadBalancer, instances, "VIP_003");
assertEquals(1, tenant2Instances.size()); assertEquals(1, vip3Instances.size());
assertEquals("8100", String.valueOf(vip3Instances.get(0).getPort()));
// 测试未知租户回退到共享实例 // VIP_004 匹配实例2
List<ServiceInstance> unknownTenantInstances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_UNKNOWN"); List<ServiceInstance> vip4Instances = invokeFilterByTenantId(loadBalancer, instances, "VIP_004");
assertEquals(1, unknownTenantInstances.size()); assertEquals(1, vip4Instances.size());
assertFalse(unknownTenantInstances.get(0).getMetadata().containsKey("tenant-group")); assertEquals("8101", String.valueOf(vip4Instances.get(0).getPort()));
// VIP_005 匹配实例2
List<ServiceInstance> vip5Instances = invokeFilterByTenantId(loadBalancer, instances, "VIP_005");
assertEquals(1, vip5Instances.size());
assertEquals("8101", String.valueOf(vip5Instances.get(0).getPort()));
// 未知租户回退到共享实例
List<ServiceInstance> unknownInstances = invokeFilterByTenantId(loadBalancer, instances, "VIP_999");
assertEquals(1, unknownInstances.size());
assertEquals("8110", String.valueOf(unknownInstances.get(0).getPort()));
} }
@Test @Test
void testMixedMode() { void testMixedMode() {
// 混合模式VIP 客户有专属实例普通客户使用共享实例 // 混合模式单租户实例 + 多租户实例 + 共享实例
List<ServiceInstance> instances = Arrays.asList( List<ServiceInstance> instances = Arrays.asList(
// VIP 租户 001 的专属实例 // 单租户专属实例
createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-group", "TENANT_VIP_001")), createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-id", "VIP_001")),
createInstance("fund-sys", "192.168.1.2", 8101, Map.of("tenant-group", "TENANT_VIP_001")), // 多租户专属实例VIP_002, VIP_003
// VIP 租户 002 的专属实例 createInstance("fund-sys", "192.168.1.2", 8101, Map.of("tenant-id", "VIP_002,VIP_003")),
createInstance("fund-sys", "192.168.1.3", 8102, Map.of("tenant-group", "TENANT_VIP_002")), // 共享实例
// 共享实例普通租户使用 createInstance("fund-sys", "192.168.1.10", 8110, Collections.emptyMap())
createInstance("fund-sys", "192.168.1.10", 8110, Collections.emptyMap()),
createInstance("fund-sys", "192.168.1.11", 8111, Collections.emptyMap())
); );
// VIP 租户 001 应该路由到专属实例 // VIP_001 走单租户实例
List<ServiceInstance> vip1Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_VIP_001"); List<ServiceInstance> vip1Instances = invokeFilterByTenantId(loadBalancer, instances, "VIP_001");
assertEquals(2, vip1Instances.size()); assertEquals(1, vip1Instances.size());
assertEquals(8100, vip1Instances.get(0).getPort());
// VIP 租户 002 应该路由到专属实例 // VIP_002 走多租户实例
List<ServiceInstance> vip2Instances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_VIP_002"); List<ServiceInstance> vip2Instances = invokeFilterByTenantId(loadBalancer, instances, "VIP_002");
assertEquals(1, vip2Instances.size()); assertEquals(1, vip2Instances.size());
assertEquals(8101, vip2Instances.get(0).getPort());
// 普通租户应该路由到共享实例 // VIP_003 也走多租户实例
List<ServiceInstance> normalInstances = invokeFilterByTenantGroup(loadBalancer, instances, "TENANT_NORMAL"); List<ServiceInstance> vip3Instances = invokeFilterByTenantId(loadBalancer, instances, "VIP_003");
assertEquals(2, normalInstances.size()); assertEquals(1, vip3Instances.size());
assertEquals(8101, vip3Instances.get(0).getPort());
// 普通租户走共享实例
List<ServiceInstance> normalInstances = invokeFilterByTenantId(loadBalancer, instances, "NORMAL_001");
assertEquals(1, normalInstances.size());
assertEquals(8110, normalInstances.get(0).getPort());
}
@Test
void testSharedInstanceFallback() {
// 测试回退到共享实例
List<ServiceInstance> instances = Arrays.asList(
createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-id", "VIP_001")),
createInstance("fund-sys", "192.168.1.2", 8101, Collections.emptyMap()),
createInstance("fund-sys", "192.168.1.3", 8102, Map.of("tenant-id", "")) // 空字符串也是共享
);
// 未知租户应该找到 2 个共享实例
List<ServiceInstance> sharedInstances = invokeFilterByTenantId(loadBalancer, instances, "UNKNOWN");
assertEquals(2, sharedInstances.size());
}
@Test
void testNullTenantId() {
List<ServiceInstance> instances = Arrays.asList(
createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-id", "VIP_001")),
createInstance("fund-sys", "192.168.1.2", 8101, Collections.emptyMap())
);
// null 租户 ID 应该回退到共享实例
List<ServiceInstance> result = invokeFilterByTenantId(loadBalancer, instances, null);
assertEquals(1, result.size());
assertFalse(result.get(0).getMetadata().containsKey("tenant-id"));
}
@Test
void testTenantIdWithSpaces() {
// 测试带空格的租户 ID 配置
List<ServiceInstance> instances = Arrays.asList(
createInstance("fund-sys", "192.168.1.1", 8100, Map.of("tenant-id", "VIP_001, VIP_002 , VIP_003"))
);
// 应该能正确匹配去除空格
assertEquals(1, invokeFilterByTenantId(loadBalancer, instances, "VIP_001").size());
assertEquals(1, invokeFilterByTenantId(loadBalancer, instances, "VIP_002").size());
assertEquals(1, invokeFilterByTenantId(loadBalancer, instances, "VIP_003").size());
} }
// 辅助方法创建服务实例 // 辅助方法创建服务实例
@ -104,24 +168,13 @@ class TenantAwareLoadBalancerTest {
); );
} }
// 辅助方法调用私有方法 buildTenantGroup // 辅助方法调用私有方法 filterByTenantId
private String invokeBuildTenantGroup(TenantAwareLoadBalancer lb, String tenantId) {
try {
var method = TenantAwareLoadBalancer.class.getDeclaredMethod("buildTenantGroup", String.class);
method.setAccessible(true);
return (String) method.invoke(lb, tenantId);
} catch (Exception e) {
throw new RuntimeException(e);
}
}
// 辅助方法调用私有方法 filterByTenantGroup
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private List<ServiceInstance> invokeFilterByTenantGroup(TenantAwareLoadBalancer lb, List<ServiceInstance> instances, String tenantGroup) { private List<ServiceInstance> invokeFilterByTenantId(TenantAwareLoadBalancer lb, List<ServiceInstance> instances, String tenantId) {
try { try {
var method = TenantAwareLoadBalancer.class.getDeclaredMethod("filterByTenantGroup", List.class, String.class); var method = TenantAwareLoadBalancer.class.getDeclaredMethod("filterByTenantId", List.class, String.class);
method.setAccessible(true); method.setAccessible(true);
return (List<ServiceInstance>) method.invoke(lb, instances, tenantGroup); return (List<ServiceInstance>) method.invoke(lb, instances, tenantId);
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException(e); throw new RuntimeException(e);
} }

View File

@ -1,4 +1,4 @@
------------------------------------------------------------------------------- -------------------------------------------------------------------------------
Test set: com.fundplatform.common.loadbalancer.TenantAwareLoadBalancerTest Test set: com.fundplatform.common.loadbalancer.TenantAwareLoadBalancerTest
------------------------------------------------------------------------------- -------------------------------------------------------------------------------
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.567 s -- in com.fundplatform.common.loadbalancer.TenantAwareLoadBalancerTest Tests run: 6, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.505 s -- in com.fundplatform.common.loadbalancer.TenantAwareLoadBalancerTest

View File

@ -0,0 +1,51 @@
# Docker 环境配置
server:
port: ${SERVER_PORT:8200}
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
namespace: fund-platform
group: DEFAULT_GROUP
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/${MYSQL_DB:fund_platform}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: ${MYSQL_USER:root}
password: ${MYSQL_PASSWORD:root123}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
validation-timeout: 5000
leak-detection-threshold: 60000
pool-name: FundCustHikariPool
connection-init-sql: SELECT 1
logging:
level:
com.fundplatform.cust: INFO
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -13,6 +13,10 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 租户路由元数据
# tenant-id: 空值=共享实例,单值=单租户专属,多值(逗号分隔)=多租户专属
metadata:
tenant-id: ${TENANT_ID:}
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
@ -37,14 +41,17 @@ mybatis-plus:
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
# Feign配置
feign:
fund-sys:
url: http://localhost:8100
logging: logging:
level: level:
com.fundplatform.cust: DEBUG com.fundplatform.cust: DEBUG
pattern: pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -13,6 +13,10 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 租户路由元数据
# tenant-id: 空值=共享实例,单值=单租户专属,多值(逗号分隔)=多租户专属
metadata:
tenant-id: ${TENANT_ID:}
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
@ -37,14 +41,17 @@ mybatis-plus:
logic-delete-value: 1 logic-delete-value: 1
logic-not-delete-value: 0 logic-not-delete-value: 0
# Feign配置
feign:
fund-sys:
url: http://localhost:8100
logging: logging:
level: level:
com.fundplatform.cust: DEBUG com.fundplatform.cust: DEBUG
pattern: pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -2,12 +2,18 @@ package com.fundplatform.exp.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fundplatform.common.core.Result; import com.fundplatform.common.core.Result;
import com.fundplatform.common.util.ExcelUtil;
import com.fundplatform.exp.dto.ExpenseExcel;
import com.fundplatform.exp.dto.FundExpenseDTO; import com.fundplatform.exp.dto.FundExpenseDTO;
import com.fundplatform.exp.service.FundExpenseService; import com.fundplatform.exp.service.FundExpenseService;
import com.fundplatform.exp.vo.FundExpenseVO; import com.fundplatform.exp.vo.FundExpenseVO;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/** /**
* 支出管理Controller * 支出管理Controller
* *
@ -164,4 +170,32 @@ public class FundExpenseController {
public Result<java.util.List<java.util.Map<String, Object>>> getTypeDistribution() { public Result<java.util.List<java.util.Map<String, Object>>> getTypeDistribution() {
return Result.success(expenseService.getTypeDistribution()); return Result.success(expenseService.getTypeDistribution());
} }
/**
* 导出支出明细Excel
*/
@GetMapping("/export")
public void exportExcel(
@RequestParam(required = false) String title,
@RequestParam(required = false) Long expenseType,
@RequestParam(required = false) Integer payStatus,
@RequestParam(required = false) Integer approvalStatus,
HttpServletResponse response) {
List<FundExpenseVO> list = expenseService.listExpenses(title, expenseType, payStatus, approvalStatus);
List<ExpenseExcel> excelData = list.stream().map(vo -> {
ExpenseExcel excel = new ExpenseExcel();
excel.setExpenseNo(vo.getExpenseNo());
excel.setTitle(vo.getTitle());
excel.setExpenseTypeName(vo.getExpenseTypeName());
excel.setAmount(vo.getAmount());
excel.setExpenseDate(vo.getExpenseDate());
excel.setPayeeName(vo.getPayeeName());
excel.setApprovalStatus(vo.getApprovalStatus());
excel.setPayStatus(vo.getPayStatus());
excel.setPurpose(vo.getPurpose());
excel.setCreatedTime(vo.getCreatedTime());
return excel;
}).collect(Collectors.toList());
ExcelUtil.exportExcel(excelData, "支出明细", "支出明细", ExpenseExcel.class, response, "支出明细.xlsx");
}
} }

View File

@ -0,0 +1,122 @@
package com.fundplatform.exp.dto;
import cn.afterturn.easypoi.excel.annotation.Excel;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 支出明细Excel导出实体
*/
public class ExpenseExcel {
@Excel(name = "支出编号", width = 15)
private String expenseNo;
@Excel(name = "支出标题", width = 25)
private String title;
@Excel(name = "支出类型", width = 12)
private String expenseTypeName;
@Excel(name = "支出金额", width = 12, type = 10)
private BigDecimal amount;
@Excel(name = "支出日期", width = 12, format = "yyyy-MM-dd")
private LocalDateTime expenseDate;
@Excel(name = "收款人", width = 12)
private String payeeName;
@Excel(name = "审批状态", width = 10, replace = {"草稿_0", "待审批_1", "已通过_2", "已拒绝_3", "已撤回_4"})
private Integer approvalStatus;
@Excel(name = "支付状态", width = 10, replace = {"未支付_0", "已支付_1", "支付失败_2"})
private Integer payStatus;
@Excel(name = "用途说明", width = 30)
private String purpose;
@Excel(name = "创建时间", width = 18, format = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdTime;
public String getExpenseNo() {
return expenseNo;
}
public void setExpenseNo(String expenseNo) {
this.expenseNo = expenseNo;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getExpenseTypeName() {
return expenseTypeName;
}
public void setExpenseTypeName(String expenseTypeName) {
this.expenseTypeName = expenseTypeName;
}
public BigDecimal getAmount() {
return amount;
}
public void setAmount(BigDecimal amount) {
this.amount = amount;
}
public LocalDateTime getExpenseDate() {
return expenseDate;
}
public void setExpenseDate(LocalDateTime expenseDate) {
this.expenseDate = expenseDate;
}
public String getPayeeName() {
return payeeName;
}
public void setPayeeName(String payeeName) {
this.payeeName = payeeName;
}
public Integer getApprovalStatus() {
return approvalStatus;
}
public void setApprovalStatus(Integer approvalStatus) {
this.approvalStatus = approvalStatus;
}
public Integer getPayStatus() {
return payStatus;
}
public void setPayStatus(Integer payStatus) {
this.payStatus = payStatus;
}
public String getPurpose() {
return purpose;
}
public void setPurpose(String purpose) {
this.purpose = purpose;
}
public LocalDateTime getCreatedTime() {
return createdTime;
}
public void setCreatedTime(LocalDateTime createdTime) {
this.createdTime = createdTime;
}
}

View File

@ -14,6 +14,11 @@ public interface FundExpenseService {
Page<FundExpenseVO> pageExpenses(int pageNum, int pageSize, String title, Long expenseType, Integer payStatus, Integer approvalStatus); Page<FundExpenseVO> pageExpenses(int pageNum, int pageSize, String title, Long expenseType, Integer payStatus, Integer approvalStatus);
/**
* 查询支出列表不分页用于导出
*/
java.util.List<FundExpenseVO> listExpenses(String title, Long expenseType, Integer payStatus, Integer approvalStatus);
boolean deleteExpense(Long id); boolean deleteExpense(Long id);
/** /**

View File

@ -135,6 +135,19 @@ public class FundExpenseServiceImpl implements FundExpenseService {
return voPage; return voPage;
} }
@Override
public List<FundExpenseVO> listExpenses(String title, Long expenseType, Integer payStatus, Integer approvalStatus) {
LambdaQueryWrapper<FundExpense> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(FundExpense::getDeleted, 0);
if (StringUtils.hasText(title)) wrapper.like(FundExpense::getTitle, title);
if (expenseType != null) wrapper.eq(FundExpense::getExpenseType, expenseType);
if (payStatus != null) wrapper.eq(FundExpense::getPayStatus, payStatus);
if (approvalStatus != null) wrapper.eq(FundExpense::getApprovalStatus, approvalStatus);
wrapper.orderByDesc(FundExpense::getCreatedTime);
List<FundExpense> list = expenseDataService.list(wrapper);
return list.stream().map(this::convertToVO).toList();
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public boolean deleteExpense(Long id) { public boolean deleteExpense(Long id) {

View File

@ -0,0 +1,51 @@
# Docker 环境配置
server:
port: ${SERVER_PORT:8500}
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
namespace: fund-platform
group: DEFAULT_GROUP
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/${MYSQL_DB:fund_platform}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: ${MYSQL_USER:root}
password: ${MYSQL_PASSWORD:root123}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
validation-timeout: 5000
leak-detection-threshold: 60000
pool-name: FundExpHikariPool
connection-init-sql: SELECT 1
logging:
level:
com.fundplatform.exp: INFO
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -13,6 +13,9 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
@ -40,3 +43,13 @@ mybatis-plus:
logging: logging:
level: level:
com.fundplatform.exp: DEBUG com.fundplatform.exp: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -13,6 +13,9 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
@ -40,3 +43,13 @@ mybatis-plus:
logging: logging:
level: level:
com.fundplatform.exp: DEBUG com.fundplatform.exp: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -12,6 +12,7 @@
/home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/ExpApplication.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/ExpApplication.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/controller/ExpenseTypeController.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/controller/ExpenseTypeController.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/vo/FundExpenseVO.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/vo/FundExpenseVO.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/dto/ExpenseExcel.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/controller/HealthController.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/controller/HealthController.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/vo/ExpenseTypeVO.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/vo/ExpenseTypeVO.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/dto/ExpenseTypeDTO.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/dto/ExpenseTypeDTO.java

View File

@ -0,0 +1,63 @@
# Docker 环境配置
server:
port: ${SERVER_PORT:8800}
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
namespace: fund-platform
group: DEFAULT_GROUP
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/${MYSQL_DB:fund_platform}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: ${MYSQL_USER:root}
password: ${MYSQL_PASSWORD:root123}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
validation-timeout: 5000
leak-detection-threshold: 60000
pool-name: FundFileHikariPool
connection-init-sql: SELECT 1
# 文件存储配置
file:
storage:
type: ${FILE_STORAGE_TYPE:local}
local:
path: ${FILE_STORAGE_PATH:/data/files}
minio:
endpoint: ${MINIO_ENDPOINT:http://minio:9000}
access-key: ${MINIO_ACCESS_KEY:minioadmin}
secret-key: ${MINIO_SECRET_KEY:minioadmin}
bucket: ${MINIO_BUCKET:fund-files}
logging:
level:
com.fundplatform.file: INFO
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -5,12 +5,6 @@ spring:
application: application:
name: fund-file name: fund-file
datasource:
url: jdbc:mysql://localhost:3306/fund_file?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: zjf@123456
driver-class-name: com.mysql.cj.jdbc.Driver
cloud: cloud:
nacos: nacos:
discovery: discovery:
@ -19,6 +13,13 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 共享服务,无需租户路由
datasource:
url: jdbc:mysql://localhost:3306/fund_file?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: ${DB_PASSWORD:zjf@123456}
driver-class-name: com.mysql.cj.jdbc.Driver
# 文件上传配置 # 文件上传配置
servlet: servlet:
@ -40,3 +41,14 @@ file:
upload: upload:
path: ./uploads path: ./uploads
max-size: 52428800 max-size: 52428800
logging:
level:
com.fundplatform.file: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 共享服务,禁用租户路由
tenant:
routing:
enabled: false

View File

@ -5,12 +5,6 @@ spring:
application: application:
name: fund-file name: fund-file
datasource:
url: jdbc:mysql://localhost:3306/fund_file?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: zjf@123456
driver-class-name: com.mysql.cj.jdbc.Driver
cloud: cloud:
nacos: nacos:
discovery: discovery:
@ -19,6 +13,13 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 共享服务,无需租户路由
datasource:
url: jdbc:mysql://localhost:3306/fund_file?useUnicode=true&characterEncoding=utf-8&serverTimezone=Asia/Shanghai
username: root
password: ${DB_PASSWORD:zjf@123456}
driver-class-name: com.mysql.cj.jdbc.Driver
# 文件上传配置 # 文件上传配置
servlet: servlet:
@ -40,3 +41,14 @@ file:
upload: upload:
path: ./uploads path: ./uploads
max-size: 52428800 max-size: 52428800
logging:
level:
com.fundplatform.file: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 共享服务,禁用租户路由
tenant:
routing:
enabled: false

View File

@ -1,166 +1,114 @@
# Docker 环境配置 - Gateway # Docker 环境配置
server: server:
port: ${SERVER_PORT:8000} port: ${SERVER_PORT:8000}
spring: spring:
application:
name: fund-gateway
profiles:
active: ${SPRING_PROFILES_ACTIVE:docker}
cloud: cloud:
nacos: nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848} server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
namespace: fund-platform
group: DEFAULT_GROUP
username: ${NACOS_USERNAME:nacos} username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos} password: ${NACOS_PASSWORD:nacos}
discovery:
namespace: ${NACOS_NAMESPACE:} sentinel:
group: DEFAULT_GROUP transport:
enabled: true dashboard: ${SENTINEL_DASHBOARD:}:8080
metadata: port: 8719
service-type: gateway eager: true
gateway: gateway:
discovery: default-filters:
locator: - name: RequestRateLimiter
enabled: true args:
lower-case-service-id: true redis-rate-limiter.replenishRate: 100
# 全局跨域配置 redis-rate-limiter.burstCapacity: 200
key-resolver: "#{@ipKeyResolver}"
globalcors: globalcors:
cors-configurations: cors-configurations:
'[/**]': '[/**]':
allowed-origins: "*" allowedOriginPatterns: "*"
allowed-methods: "*" allowedMethods: "*"
allowed-headers: "*" allowedHeaders: "*"
allow-credentials: true allowCredentials: true
maxAge: 3600
routes: routes:
- id: fund-sys - id: fund-sys
uri: lb://fund-sys uri: lb://fund-sys
predicates: predicates:
- Path=/api/sys/** - Path=/sys/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
# 租户感知负载均衡(自动添加 tenant-group 请求头)
- id: fund-cust - id: fund-cust
uri: lb://fund-cust uri: lb://fund-cust
predicates: predicates:
- Path=/api/cust/** - Path=/cust/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
- id: fund-proj - id: fund-proj
uri: lb://fund-proj uri: lb://fund-proj
predicates: predicates:
- Path=/api/proj/** - Path=/proj/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
- id: fund-req - id: fund-req
uri: lb://fund-req uri: lb://fund-req
predicates: predicates:
- Path=/api/req/** - Path=/req/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
- id: fund-exp - id: fund-exp
uri: lb://fund-exp uri: lb://fund-exp
predicates: predicates:
- Path=/api/exp/** - Path=/exp/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
- id: fund-receipt - id: fund-receipt
uri: lb://fund-receipt uri: lb://fund-receipt
predicates: predicates:
- Path=/api/receipt/** - Path=/receipt/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
- id: fund-report - id: fund-report
uri: lb://fund-report uri: lb://fund-report
predicates: predicates:
- Path=/api/report/** - Path=/report/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
- id: fund-file - id: fund-file
uri: lb://fund-file uri: lb://fund-file
predicates: predicates:
- Path=/api/file/** - Path=/file/**
filters: filters:
- StripPrefix=1 - StripPrefix=1
# JWT 配置 data:
jwt: redis:
secret: ${JWT_SECRET:YourSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong} host: ${REDIS_HOST:redis}
expiration: ${JWT_EXPIRATION:86400000} port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: 1
# ==================== 多租户混合模式配置 ==================== logging:
tenant: level:
routing: org.springframework.cloud.gateway: INFO
# 启用租户感知负载均衡 com.fundplatform.common: INFO
enabled: true
# 默认租户ID
default-tenant-id: 1
# 租户服务配置定义每个服务的VIP租户
services:
fund-sys:
vip-tenants:
- TENANT_VIP_001
- TENANT_VIP_002
fallback-to-shared: true
fund-cust:
vip-tenants:
- TENANT_VIP_001
fallback-to-shared: true
fund-proj:
vip-tenants:
- TENANT_VIP_001
fallback-to-shared: true
fund-req:
vip-tenants: []
fallback-to-shared: true
fund-exp:
vip-tenants: []
fallback-to-shared: true
fund-receipt:
vip-tenants: []
fallback-to-shared: true
fund-report:
vip-tenants: []
fallback-to-shared: true
fund-file:
vip-tenants: []
fallback-to-shared: true
# Actuator 监控端点配置
management: management:
endpoints: endpoints:
web: web:
exposure: exposure:
include: health,info,metrics,prometheus include: health,info,metrics,prometheus
base-path: /actuator
endpoint: endpoint:
health: health:
show-details: always show-details: when_authorized
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
metrics:
tags:
application: ${spring.application.name}
service-type: gateway
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5,0.95,0.99
# 日志配置
logging:
level:
root: INFO
com.fundplatform: DEBUG
# 多租户负载均衡日志
com.fundplatform.common.loadbalancer: DEBUG
com.fundplatform.gateway.filter: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%X{tenantId}] %-5level %logger{36} - %msg%n"

View File

@ -128,29 +128,16 @@ logging:
org.springframework.cloud.gateway: DEBUG org.springframework.cloud.gateway: DEBUG
com.fundplatform.common.loadbalancer: DEBUG com.fundplatform.common.loadbalancer: DEBUG
# 多租户路由配置 # 多租户路由配置Gateway 全局配置)
tenant: tenant:
routing: routing:
enabled: true enabled: true
tenant-header: X-Tenant-Id tenant-header: X-Tenant-Id
tenant-group-header: X-Tenant-Group
group-separator: TENANT_
default-tenant-id: "1" default-tenant-id: "1"
# 共享服务列表(不需要租户路由的服务)
shared-services: shared-services:
- fund-gateway - fund-gateway
- fund-report - fund-report
- fund-file - fund-file
services: # 默认回退策略
fund-sys:
vip-tenants:
- TENANT_VIP_001
- TENANT_VIP_002
fallback-to-shared: true
fund-cust:
vip-tenants:
- TENANT_VIP_001
fallback-to-shared: true
fund-proj:
vip-tenants:
- TENANT_VIP_001
fallback-to-shared: true fallback-to-shared: true

View File

@ -1,166 +0,0 @@
# Docker 环境配置 - Gateway
server:
port: ${SERVER_PORT:8000}
spring:
application:
name: fund-gateway
profiles:
active: ${SPRING_PROFILES_ACTIVE:docker}
cloud:
nacos:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
discovery:
namespace: ${NACOS_NAMESPACE:}
group: DEFAULT_GROUP
enabled: true
metadata:
service-type: gateway
gateway:
discovery:
locator:
enabled: true
lower-case-service-id: true
# 全局跨域配置
globalcors:
cors-configurations:
'[/**]':
allowed-origins: "*"
allowed-methods: "*"
allowed-headers: "*"
allow-credentials: true
routes:
- id: fund-sys
uri: lb://fund-sys
predicates:
- Path=/api/sys/**
filters:
- StripPrefix=1
# 租户感知负载均衡(自动添加 tenant-group 请求头)
- id: fund-cust
uri: lb://fund-cust
predicates:
- Path=/api/cust/**
filters:
- StripPrefix=1
- id: fund-proj
uri: lb://fund-proj
predicates:
- Path=/api/proj/**
filters:
- StripPrefix=1
- id: fund-req
uri: lb://fund-req
predicates:
- Path=/api/req/**
filters:
- StripPrefix=1
- id: fund-exp
uri: lb://fund-exp
predicates:
- Path=/api/exp/**
filters:
- StripPrefix=1
- id: fund-receipt
uri: lb://fund-receipt
predicates:
- Path=/api/receipt/**
filters:
- StripPrefix=1
- id: fund-report
uri: lb://fund-report
predicates:
- Path=/api/report/**
filters:
- StripPrefix=1
- id: fund-file
uri: lb://fund-file
predicates:
- Path=/api/file/**
filters:
- StripPrefix=1
# JWT 配置
jwt:
secret: ${JWT_SECRET:YourSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong}
expiration: ${JWT_EXPIRATION:86400000}
# ==================== 多租户混合模式配置 ====================
tenant:
routing:
# 启用租户感知负载均衡
enabled: true
# 默认租户ID
default-tenant-id: 1
# 租户服务配置定义每个服务的VIP租户
services:
fund-sys:
vip-tenants:
- TENANT_VIP_001
- TENANT_VIP_002
fallback-to-shared: true
fund-cust:
vip-tenants:
- TENANT_VIP_001
fallback-to-shared: true
fund-proj:
vip-tenants:
- TENANT_VIP_001
fallback-to-shared: true
fund-req:
vip-tenants: []
fallback-to-shared: true
fund-exp:
vip-tenants: []
fallback-to-shared: true
fund-receipt:
vip-tenants: []
fallback-to-shared: true
fund-report:
vip-tenants: []
fallback-to-shared: true
fund-file:
vip-tenants: []
fallback-to-shared: true
# Actuator 监控端点配置
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
base-path: /actuator
endpoint:
health:
show-details: always
probes:
enabled: true
health:
livenessstate:
enabled: true
readinessstate:
enabled: true
metrics:
tags:
application: ${spring.application.name}
service-type: gateway
distribution:
percentiles-histogram:
http.server.requests: true
percentiles:
http.server.requests: 0.5,0.95,0.99
# 日志配置
logging:
level:
root: INFO
com.fundplatform: DEBUG
# 多租户负载均衡日志
com.fundplatform.common.loadbalancer: DEBUG
com.fundplatform.gateway.filter: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] [%X{tenantId}] %-5level %logger{36} - %msg%n"

View File

@ -128,29 +128,16 @@ logging:
org.springframework.cloud.gateway: DEBUG org.springframework.cloud.gateway: DEBUG
com.fundplatform.common.loadbalancer: DEBUG com.fundplatform.common.loadbalancer: DEBUG
# 多租户路由配置 # 多租户路由配置Gateway 全局配置)
tenant: tenant:
routing: routing:
enabled: true enabled: true
tenant-header: X-Tenant-Id tenant-header: X-Tenant-Id
tenant-group-header: X-Tenant-Group
group-separator: TENANT_
default-tenant-id: "1" default-tenant-id: "1"
# 共享服务列表(不需要租户路由的服务)
shared-services: shared-services:
- fund-gateway - fund-gateway
- fund-report - fund-report
- fund-file - fund-file
services: # 默认回退策略
fund-sys:
vip-tenants:
- TENANT_VIP_001
- TENANT_VIP_002
fallback-to-shared: true
fund-cust:
vip-tenants:
- TENANT_VIP_001
fallback-to-shared: true
fund-proj:
vip-tenants:
- TENANT_VIP_001
fallback-to-shared: true fallback-to-shared: true

31
fund-mobile/.dockerignore Normal file
View File

@ -0,0 +1,31 @@
# Dependencies
node_modules
# Build output
dist
# IDE
.idea
.vscode
*.iml
# Git
.git
.gitignore
# Logs
*.log
npm-debug.log*
# Environment
.env
.env.*
!.env.example
# OS
.DS_Store
Thumbs.db
# Test
**/*.test.ts
**/*.spec.ts

61
fund-mobile/Dockerfile Normal file
View File

@ -0,0 +1,61 @@
# 资金服务平台移动端 - Dockerfile
# 多阶段构建Node构建 + Nginx运行
# ==================== 构建阶段 ====================
FROM node:20-alpine AS builder
WORKDIR /app
# 设置npm镜像加速下载
RUN npm config set registry https://registry.npmmirror.com
# 复制package文件利用缓存
COPY package*.json ./
# 安装依赖
RUN npm ci --legacy-peer-deps
# 复制源代码
COPY . .
# 构建参数API网关地址
ARG VITE_API_BASE_URL=http://localhost:8000
ENV VITE_API_BASE_URL=${VITE_API_BASE_URL}
# 构建生产版本
RUN npm run build
# ==================== 运行阶段 ====================
FROM nginx:alpine
# 安装必要工具
RUN apk add --no-cache curl tzdata && \
cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \
echo "Asia/Shanghai" > /etc/timezone && \
apk del tzdata
# 删除默认配置
RUN rm -rf /etc/nginx/conf.d/default.conf
# 复制nginx配置
COPY nginx.conf /etc/nginx/conf.d/default.conf
# 复制构建产物
COPY --from=builder /app/dist /usr/share/nginx/html
# 创建非root用户
RUN chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /var/log/nginx && \
touch /var/run/nginx.pid && \
chown -R nginx:nginx /var/run/nginx.pid
# 暴露端口
EXPOSE 80
# 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=10s --retries=3 \
CMD curl -f http://localhost/ || exit 1
# 启动nginx
CMD ["nginx", "-g", "daemon off;"]

54
fund-mobile/nginx.conf Normal file
View File

@ -0,0 +1,54 @@
# 资金服务平台移动端 Nginx 配置
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip压缩
gzip on;
gzip_min_length 1k;
gzip_comp_level 6;
gzip_types text/plain text/css text/javascript application/json application/javascript application/x-javascript application/xml;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";
# 静态资源缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# API代理到网关
location /api/ {
proxy_pass http://fund-gateway:8000/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时配置
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# 文件上传大小限制
client_max_body_size 100m;
}
# Vue Router History模式支持
location / {
try_files $uri $uri/ /index.html;
}
# 安全头
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
# 错误页面
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
}
}

View File

@ -0,0 +1,68 @@
import request from './request'
// 用户认证
export function login(data: { username: string; password: string }) {
return request.post('/sys/api/v1/auth/login', data)
}
export function getUserInfo() {
return request.get('/sys/api/v1/auth/info')
}
export function logout() {
return request.post('/sys/api/v1/auth/logout')
}
// 项目管理
export function getProjectList(params?: { pageNum: number; pageSize: number; projectName?: string }) {
return request.get('/proj/api/v1/project/page', { params })
}
export function getProjectById(id: number) {
return request.get(`/proj/api/v1/project/${id}`)
}
// 客户管理
export function getCustomerList(params?: { pageNum: number; pageSize: number; customerName?: string }) {
return request.get('/cust/api/v1/customer/page', { params })
}
// 支出管理
export function createExpense(data: any) {
return request.post('/exp/api/v1/exp/expense', data)
}
export function getExpenseList(params: { pageNum: number; pageSize: number }) {
return request.get('/exp/api/v1/exp/expense/page', { params })
}
// 应收款管理
export function getReceivableList(params: { pageNum: number; pageSize: number; status?: string }) {
return request.get('/receipt/api/v1/receipt/receivable/page', { params })
}
export function getUpcomingDueList(daysWithin: number = 7) {
return request.get(`/receipt/api/v1/receipt/receivable/upcoming-due?daysWithin=${daysWithin}`)
}
// 统计数据
export function getTodayIncome() {
return request.get('/receipt/api/v1/receipt/receivable/stats/today-income')
}
export function getTodayExpense() {
return request.get('/exp/api/v1/exp/expense/stats/today-expense')
}
export function getUnpaidAmount() {
return request.get('/receipt/api/v1/receipt/receivable/stats/unpaid-amount')
}
export function getOverdueCount() {
return request.get('/receipt/api/v1/receipt/receivable/stats/overdue-count')
}
// 支出类型
export function getExpenseTypeTree() {
return request.get('/exp/api/v1/exp/expense-type/tree')
}

View File

@ -21,6 +21,18 @@ const router = createRouter({
component: () => import('@/views/receivable/List.vue'), component: () => import('@/views/receivable/List.vue'),
meta: { title: '应收款列表', requiresAuth: true } meta: { title: '应收款列表', requiresAuth: true }
}, },
{
path: '/project',
name: 'ProjectList',
component: () => import('@/views/project/List.vue'),
meta: { title: '项目列表', requiresAuth: true }
},
{
path: '/customer',
name: 'CustomerList',
component: () => import('@/views/customer/List.vue'),
meta: { title: '客户列表', requiresAuth: true }
},
{ {
path: '/my', path: '/my',
name: 'My', name: 'My',

View File

@ -61,6 +61,18 @@
</div> </div>
<div class="quick-text">应收款</div> <div class="quick-text">应收款</div>
</div> </div>
<div class="quick-item" @click="$router.push('/project')">
<div class="quick-icon">
<van-icon name="todo-list-o" />
</div>
<div class="quick-text">项目</div>
</div>
<div class="quick-item" @click="$router.push('/customer')">
<div class="quick-icon">
<van-icon name="friends-o" />
</div>
<div class="quick-text">客户</div>
</div>
<div class="quick-item" @click="$router.push('/my')"> <div class="quick-item" @click="$router.push('/my')">
<div class="quick-icon"> <div class="quick-icon">
<van-icon name="user-o" /> <van-icon name="user-o" />

View File

@ -0,0 +1,186 @@
<template>
<div class="page customer-list">
<van-nav-bar title="客户列表" left-arrow @click-left="$router.back()" />
<!-- 搜索栏 -->
<div class="search-bar">
<van-search v-model="searchText" placeholder="搜索客户名称" @search="handleSearch" />
</div>
<!-- 客户列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div class="customer-card" v-for="item in list" :key="item.customerId">
<div class="customer-header">
<div class="customer-avatar">{{ item.customerName?.charAt(0) || 'C' }}</div>
<div class="customer-info">
<div class="customer-name">{{ item.customerName }}</div>
<div class="customer-short">{{ item.customerShort || '-' }}</div>
</div>
<van-tag :type="getLevelType(item.level)">{{ getLevelText(item.level) }}</van-tag>
</div>
<div class="customer-detail">
<div class="detail-item" v-if="item.phone">
<van-icon name="phone-o" />
<span>{{ item.phone }}</span>
</div>
<div class="detail-item" v-if="item.industry">
<van-icon name="cluster-o" />
<span>{{ item.industry }}</span>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { getCustomerList } from '@/api'
const searchText = ref('')
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const list = ref<any[]>([])
const pageNum = ref(1)
const pageSize = 10
const getLevelType = (level: string): 'primary' | 'success' | 'warning' | 'danger' | 'default' => {
const map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
'A': 'primary',
'B': 'success',
'C': 'warning',
'D': 'danger'
}
return map[level] || 'default'
}
const getLevelText = (level: string) => {
const map: Record<string, string> = {
'A': 'A类',
'B': 'B类',
'C': 'C类',
'D': 'D类'
}
return map[level] || '普通'
}
const loadData = async () => {
try {
const res: any = await getCustomerList({
pageNum: pageNum.value,
pageSize,
customerName: searchText.value || undefined
})
const records = res.data?.records || []
if (pageNum.value === 1) {
list.value = records
} else {
list.value.push(...records)
}
finished.value = records.length < pageSize
} catch (e) {
console.error(e)
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
const onLoad = () => {
pageNum.value++
loadData()
}
const onRefresh = () => {
pageNum.value = 1
finished.value = false
loadData()
}
const handleSearch = () => {
pageNum.value = 1
finished.value = false
list.value = []
loadData()
}
</script>
<style scoped>
.customer-list {
background: #f5f5f5;
min-height: 100vh;
}
.search-bar {
background: #fff;
padding: 8px 0;
}
.customer-card {
background: #fff;
margin: 12px;
padding: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.customer-header {
display: flex;
align-items: center;
gap: 12px;
}
.customer-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
background: linear-gradient(135deg, #007AFF, #5AC8FA);
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
font-weight: 600;
color: #fff;
}
.customer-info {
flex: 1;
}
.customer-name {
font-size: 16px;
font-weight: 600;
color: #333;
}
.customer-short {
font-size: 13px;
color: #999;
margin-top: 2px;
}
.customer-detail {
display: flex;
gap: 16px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.detail-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #666;
}
</style>

View File

@ -0,0 +1,205 @@
<template>
<div class="page project-list">
<van-nav-bar title="项目列表" left-arrow @click-left="$router.back()" />
<!-- 搜索栏 -->
<div class="search-bar">
<van-search v-model="searchText" placeholder="搜索项目名称" @search="handleSearch" />
</div>
<!-- 项目列表 -->
<van-pull-refresh v-model="refreshing" @refresh="onRefresh">
<van-list
v-model:loading="loading"
:finished="finished"
finished-text="没有更多了"
@load="onLoad"
>
<div class="project-card" v-for="item in list" :key="item.projectId" @click="goDetail(item)">
<div class="project-header">
<span class="project-name">{{ item.projectName }}</span>
<van-tag :type="getStatusType(item.status)">{{ getStatusText(item.status) }}</van-tag>
</div>
<div class="project-info">
<div class="info-item">
<van-icon name="user-o" />
<span>{{ item.customerName || '-' }}</span>
</div>
<div class="info-item">
<van-icon name="clock-o" />
<span>{{ item.startDate }}</span>
</div>
</div>
<div class="project-amount">
<div class="amount-item">
<span class="label">合同金额</span>
<span class="value">{{ formatMoney(item.contractAmount) }}</span>
</div>
<div class="amount-item">
<span class="label">预算金额</span>
<span class="value">{{ formatMoney(item.budgetAmount) }}</span>
</div>
</div>
</div>
</van-list>
</van-pull-refresh>
</div>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { getProjectList } from '@/api'
const router = useRouter()
const searchText = ref('')
const loading = ref(false)
const finished = ref(false)
const refreshing = ref(false)
const list = ref<any[]>([])
const pageNum = ref(1)
const pageSize = 10
const getStatusType = (status: string): 'primary' | 'success' | 'warning' | 'danger' | 'default' => {
const map: Record<string, 'primary' | 'success' | 'warning' | 'danger' | 'default'> = {
'ongoing': 'primary',
'completed': 'success',
'preparing': 'warning',
'archived': 'default',
'cancelled': 'danger'
}
return map[status] || 'default'
}
const getStatusText = (status: string) => {
const map: Record<string, string> = {
'ongoing': '进行中',
'completed': '已完成',
'preparing': '筹备中',
'archived': '已归档',
'cancelled': '已取消'
}
return map[status] || status
}
const formatMoney = (value: number) => {
if (!value) return '¥0'
return '¥' + value.toLocaleString()
}
const loadData = async () => {
try {
const res: any = await getProjectList({
pageNum: pageNum.value,
pageSize,
projectName: searchText.value || undefined
})
const records = res.data?.records || []
if (pageNum.value === 1) {
list.value = records
} else {
list.value.push(...records)
}
finished.value = records.length < pageSize
} catch (e) {
console.error(e)
finished.value = true
} finally {
loading.value = false
refreshing.value = false
}
}
const onLoad = () => {
pageNum.value++
loadData()
}
const onRefresh = () => {
pageNum.value = 1
finished.value = false
loadData()
}
const handleSearch = () => {
pageNum.value = 1
finished.value = false
list.value = []
loadData()
}
const goDetail = (item: any) => {
router.push(`/project/${item.projectId}`)
}
</script>
<style scoped>
.project-list {
background: #f5f5f5;
min-height: 100vh;
}
.search-bar {
background: #fff;
padding: 8px 0;
}
.project-card {
background: #fff;
margin: 12px;
padding: 16px;
border-radius: 12px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.project-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.project-name {
font-size: 16px;
font-weight: 600;
color: #333;
}
.project-info {
display: flex;
gap: 16px;
margin-bottom: 12px;
}
.info-item {
display: flex;
align-items: center;
gap: 4px;
font-size: 13px;
color: #666;
}
.project-amount {
display: flex;
gap: 24px;
padding-top: 12px;
border-top: 1px solid #f0f0f0;
}
.amount-item {
display: flex;
flex-direction: column;
}
.amount-item .label {
font-size: 12px;
color: #999;
}
.amount-item .value {
font-size: 15px;
font-weight: 600;
color: #333;
margin-top: 4px;
}
</style>

View File

@ -0,0 +1,51 @@
# Docker 环境配置
server:
port: ${SERVER_PORT:8300}
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
namespace: fund-platform
group: DEFAULT_GROUP
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/${MYSQL_DB:fund_platform}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: ${MYSQL_USER:root}
password: ${MYSQL_PASSWORD:root123}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
validation-timeout: 5000
leak-detection-threshold: 60000
pool-name: FundProjHikariPool
connection-init-sql: SELECT 1
logging:
level:
com.fundplatform.proj: INFO
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -13,6 +13,9 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
@ -43,3 +46,11 @@ logging:
pattern: pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -13,6 +13,9 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
@ -43,3 +46,11 @@ logging:
pattern: pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true

View File

@ -3,9 +3,11 @@ package com.fundplatform.receipt;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient; import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication @SpringBootApplication
@EnableDiscoveryClient @EnableDiscoveryClient
@EnableScheduling
public class ReceiptApplication { public class ReceiptApplication {
public static void main(String[] args) { public static void main(String[] args) {

View File

@ -2,15 +2,19 @@ package com.fundplatform.receipt.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fundplatform.common.core.Result; import com.fundplatform.common.core.Result;
import com.fundplatform.common.util.ExcelUtil;
import com.fundplatform.receipt.dto.ReceivableDTO; import com.fundplatform.receipt.dto.ReceivableDTO;
import com.fundplatform.receipt.dto.ReceivableExcel;
import com.fundplatform.receipt.service.ReceivableService; import com.fundplatform.receipt.service.ReceivableService;
import com.fundplatform.receipt.vo.FundReceiptVO; import com.fundplatform.receipt.vo.FundReceiptVO;
import com.fundplatform.receipt.vo.ReceivableVO; import com.fundplatform.receipt.vo.ReceivableVO;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.List; import java.util.List;
import java.util.stream.Collectors;
/** /**
* 应收款管理Controller * 应收款管理Controller
@ -146,4 +150,43 @@ public class ReceivableController {
public Result<Integer> getOverdueCount() { public Result<Integer> getOverdueCount() {
return Result.success(receivableService.getOverdueCount()); return Result.success(receivableService.getOverdueCount());
} }
/**
* 获取即将到期的应收款列表
* @param daysWithin 未来多少天内到期默认7天
*/
@GetMapping("/upcoming-due")
public Result<List<ReceivableVO>> getUpcomingDue(
@RequestParam(defaultValue = "7") int daysWithin) {
return Result.success(receivableService.getUpcomingDueList(daysWithin));
}
/**
* 导出应收款明细Excel
*/
@GetMapping("/export")
public void exportExcel(
@RequestParam(required = false) Long projectId,
@RequestParam(required = false) Long customerId,
@RequestParam(required = false) String status,
@RequestParam(required = false) Integer confirmStatus,
HttpServletResponse response) {
List<ReceivableVO> list = receivableService.listReceivables(projectId, customerId, status, confirmStatus);
List<ReceivableExcel> excelData = list.stream().map(vo -> {
ReceivableExcel excel = new ReceivableExcel();
excel.setReceivableCode(vo.getReceivableCode());
excel.setProjectName(vo.getProjectName());
excel.setCustomerName(vo.getCustomerName());
excel.setTotalAmount(vo.getReceivableAmount());
excel.setReceivedAmount(vo.getReceivedAmount());
excel.setRemainingAmount(vo.getUnpaidAmount());
excel.setDueDate(vo.getPaymentDueDate());
excel.setReceiptStatus(vo.getStatus());
excel.setConfirmStatus(vo.getConfirmStatus());
excel.setRemark(vo.getRemark());
excel.setCreatedTime(vo.getCreatedTime());
return excel;
}).collect(Collectors.toList());
ExcelUtil.exportExcel(excelData, "应收款明细", "应收款明细", ReceivableExcel.class, response, "应收款明细.xlsx");
}
} }

View File

@ -0,0 +1,134 @@
package com.fundplatform.receipt.dto;
import cn.afterturn.easypoi.excel.annotation.Excel;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 应收款明细Excel导出实体
*/
public class ReceivableExcel {
@Excel(name = "应收款编号", width = 15)
private String receivableCode;
@Excel(name = "项目名称", width = 20)
private String projectName;
@Excel(name = "客户名称", width = 15)
private String customerName;
@Excel(name = "应收金额", width = 12, type = 10)
private BigDecimal totalAmount;
@Excel(name = "已收金额", width = 12, type = 10)
private BigDecimal receivedAmount;
@Excel(name = "未收金额", width = 12, type = 10)
private BigDecimal remainingAmount;
@Excel(name = "应收日期", width = 12, format = "yyyy-MM-dd")
private LocalDate dueDate;
@Excel(name = "收款状态", width = 10, replace = {"未收款_UNPAID", "部分收款_PARTIAL", "已收款_PAID", "已逾期_OVERDUE"})
private String receiptStatus;
@Excel(name = "确认状态", width = 10, replace = {"待确认_0", "已确认_1"})
private Integer confirmStatus;
@Excel(name = "备注", width = 25)
private String remark;
@Excel(name = "创建时间", width = 18, format = "yyyy-MM-dd HH:mm:ss")
private LocalDateTime createdTime;
public String getReceivableCode() {
return receivableCode;
}
public void setReceivableCode(String receivableCode) {
this.receivableCode = receivableCode;
}
public String getProjectName() {
return projectName;
}
public void setProjectName(String projectName) {
this.projectName = projectName;
}
public String getCustomerName() {
return customerName;
}
public void setCustomerName(String customerName) {
this.customerName = customerName;
}
public BigDecimal getTotalAmount() {
return totalAmount;
}
public void setTotalAmount(BigDecimal totalAmount) {
this.totalAmount = totalAmount;
}
public BigDecimal getReceivedAmount() {
return receivedAmount;
}
public void setReceivedAmount(BigDecimal receivedAmount) {
this.receivedAmount = receivedAmount;
}
public BigDecimal getRemainingAmount() {
return remainingAmount;
}
public void setRemainingAmount(BigDecimal remainingAmount) {
this.remainingAmount = remainingAmount;
}
public LocalDate getDueDate() {
return dueDate;
}
public void setDueDate(LocalDate dueDate) {
this.dueDate = dueDate;
}
public String getReceiptStatus() {
return receiptStatus;
}
public void setReceiptStatus(String receiptStatus) {
this.receiptStatus = receiptStatus;
}
public Integer getConfirmStatus() {
return confirmStatus;
}
public void setConfirmStatus(Integer confirmStatus) {
this.confirmStatus = confirmStatus;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
public LocalDateTime getCreatedTime() {
return createdTime;
}
public void setCreatedTime(LocalDateTime createdTime) {
this.createdTime = createdTime;
}
}

View File

@ -33,6 +33,11 @@ public interface ReceivableService {
*/ */
Page<ReceivableVO> pageReceivables(int pageNum, int pageSize, Long projectId, Long customerId, String status, Integer confirmStatus); Page<ReceivableVO> pageReceivables(int pageNum, int pageSize, Long projectId, Long customerId, String status, Integer confirmStatus);
/**
* 查询应收款列表不分页用于导出
*/
List<ReceivableVO> listReceivables(Long projectId, Long customerId, String status, Integer confirmStatus);
/** /**
* 确认应收款 * 确认应收款
*/ */
@ -79,4 +84,10 @@ public interface ReceivableService {
* 获取逾期应收款数量 * 获取逾期应收款数量
*/ */
Integer getOverdueCount(); Integer getOverdueCount();
/**
* 获取即将到期的应收款列表
* @param daysWithin 未来多少天内到期
*/
List<ReceivableVO> getUpcomingDueList(int daysWithin);
} }

View File

@ -137,6 +137,19 @@ public class ReceivableServiceImpl implements ReceivableService {
return voPage; return voPage;
} }
@Override
public List<ReceivableVO> listReceivables(Long projectId, Long customerId, String status, Integer confirmStatus) {
LambdaQueryWrapper<Receivable> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Receivable::getDeleted, 0);
if (projectId != null) wrapper.eq(Receivable::getProjectId, projectId);
if (customerId != null) wrapper.eq(Receivable::getCustomerId, customerId);
if (status != null && !status.isEmpty()) wrapper.eq(Receivable::getStatus, status);
if (confirmStatus != null) wrapper.eq(Receivable::getConfirmStatus, confirmStatus);
wrapper.orderByDesc(Receivable::getCreatedTime);
List<Receivable> list = receivableDataService.list(wrapper);
return list.stream().map(this::convertToVO).toList();
}
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public boolean confirmReceivable(Long id, Long confirmBy) { public boolean confirmReceivable(Long id, Long confirmBy) {
@ -390,4 +403,21 @@ public class ReceivableServiceImpl implements ReceivableService {
.eq(Receivable::getStatus, STATUS_OVERDUE); .eq(Receivable::getStatus, STATUS_OVERDUE);
return (int) receivableDataService.count(wrapper); return (int) receivableDataService.count(wrapper);
} }
@Override
public List<ReceivableVO> getUpcomingDueList(int daysWithin) {
LocalDate today = LocalDate.now();
LocalDate endDate = today.plusDays(daysWithin);
LambdaQueryWrapper<Receivable> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(Receivable::getDeleted, 0)
.eq(Receivable::getConfirmStatus, CONFIRM_CONFIRMED)
.ne(Receivable::getStatus, STATUS_RECEIVED)
.isNotNull(Receivable::getPaymentDueDate)
.between(Receivable::getPaymentDueDate, today, endDate)
.orderByAsc(Receivable::getPaymentDueDate);
List<Receivable> list = receivableDataService.list(wrapper);
return list.stream().map(this::convertToVO).toList();
}
} }

View File

@ -0,0 +1,74 @@
package com.fundplatform.receipt.task;
import com.fundplatform.receipt.service.ReceivableService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 应收款定时任务
* 执行逾期状态更新和提醒
*/
@Component
@ConditionalOnProperty(name = "fund.schedule.enabled", havingValue = "true", matchIfMissing = true)
public class ReceivableScheduledTask {
private static final Logger log = LoggerFactory.getLogger(ReceivableScheduledTask.class);
private final ReceivableService receivableService;
public ReceivableScheduledTask(ReceivableService receivableService) {
this.receivableService = receivableService;
}
/**
* 每天凌晨1点执行逾期状态更新
* cron表达式:
*/
@Scheduled(cron = "0 0 1 * * ?")
public void updateOverdueStatus() {
log.info("开始执行逾期状态更新定时任务...");
try {
receivableService.updateOverdueStatus();
log.info("逾期状态更新定时任务执行完成");
} catch (Exception e) {
log.error("逾期状态更新定时任务执行失败", e);
}
}
/**
* 每天上午9点执行逾期提醒
* 发送逾期预警通知
*/
@Scheduled(cron = "0 0 9 * * ?")
public void sendOverdueReminder() {
log.info("开始执行逾期提醒定时任务...");
try {
// TODO: 集成消息通知服务后实现
// Integer overdueCount = receivableService.getOverdueCount();
// notificationService.sendOverdueAlert(overdueCount);
log.info("逾期提醒定时任务执行完成(待集成消息通知服务)");
} catch (Exception e) {
log.error("逾期提醒定时任务执行失败", e);
}
}
/**
* 每周一上午10点执行账期预警
* 提醒即将到期的应收款
*/
@Scheduled(cron = "0 0 10 ? * MON")
public void sendDueDateWarning() {
log.info("开始执行账期预警定时任务...");
try {
// TODO: 集成消息通知服务后实现
// List<ReceivableVO> upcomingDue = receivableService.getUpcomingDueList(7);
// notificationService.sendDueDateWarning(upcomingDue);
log.info("账期预警定时任务执行完成(待集成消息通知服务)");
} catch (Exception e) {
log.error("账期预警定时任务执行失败", e);
}
}
}

View File

@ -0,0 +1,56 @@
# Docker 环境配置
server:
port: ${SERVER_PORT:8600}
spring:
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
namespace: fund-platform
group: DEFAULT_GROUP
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: jdbc:mysql://${MYSQL_HOST:mysql}:${MYSQL_PORT:3306}/${MYSQL_DB:fund_platform}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: ${MYSQL_USER:root}
password: ${MYSQL_PASSWORD:root123}
hikari:
maximum-pool-size: 20
minimum-idle: 5
connection-timeout: 30000
idle-timeout: 600000
max-lifetime: 1800000
validation-timeout: 5000
leak-detection-threshold: 60000
pool-name: FundReceiptHikariPool
connection-init-sql: SELECT 1
logging:
level:
com.fundplatform.receipt: INFO
management:
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
endpoint:
health:
show-details: when_authorized
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true
# 定时任务配置
fund:
schedule:
enabled: ${SCHEDULE_ENABLED:true}

View File

@ -13,6 +13,9 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
@ -40,3 +43,24 @@ mybatis-plus:
logging: logging:
level: level:
com.fundplatform.receipt: DEBUG com.fundplatform.receipt: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true
# 定时任务配置
fund:
schedule:
enabled: true
# 逾期状态更新每天凌晨1点
overdue-update-cron: "0 0 1 * * ?"
# 逾期提醒每天上午9点
overdue-reminder-cron: "0 0 9 * * ?"
# 账期预警每周一上午10点
due-date-warning-cron: "0 0 10 ? * MON"

View File

@ -13,6 +13,9 @@ spring:
group: DEFAULT_GROUP group: DEFAULT_GROUP
username: nacos username: nacos
password: nacos password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource: datasource:
driver-class-name: com.mysql.cj.jdbc.Driver driver-class-name: com.mysql.cj.jdbc.Driver
@ -40,3 +43,24 @@ mybatis-plus:
logging: logging:
level: level:
com.fundplatform.receipt: DEBUG com.fundplatform.receipt: DEBUG
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
default-tenant-id: "1"
fallback-to-shared: true
# 定时任务配置
fund:
schedule:
enabled: true
# 逾期状态更新每天凌晨1点
overdue-update-cron: "0 0 1 * * ?"
# 逾期提醒每天上午9点
overdue-reminder-cron: "0 0 9 * * ?"
# 账期预警每周一上午10点
due-date-warning-cron: "0 0 10 ? * MON"

View File

@ -9,11 +9,9 @@ com/fundplatform/receipt/data/service/FundReceiptDataService.class
com/fundplatform/receipt/controller/HealthController.class com/fundplatform/receipt/controller/HealthController.class
com/fundplatform/receipt/data/service/ReceivableDataService.class com/fundplatform/receipt/data/service/ReceivableDataService.class
com/fundplatform/receipt/data/mapper/FundReceiptMapper.class com/fundplatform/receipt/data/mapper/FundReceiptMapper.class
com/fundplatform/receipt/service/impl/ReceivableServiceImpl.class
com/fundplatform/receipt/data/entity/FundReceipt.class com/fundplatform/receipt/data/entity/FundReceipt.class
com/fundplatform/receipt/ReceiptApplication.class com/fundplatform/receipt/ReceiptApplication.class
com/fundplatform/receipt/service/FundReceiptService.class com/fundplatform/receipt/service/FundReceiptService.class
com/fundplatform/receipt/aop/ApiLogAspect.class com/fundplatform/receipt/aop/ApiLogAspect.class
com/fundplatform/receipt/dto/ReceivableDTO.class com/fundplatform/receipt/dto/ReceivableDTO.class
com/fundplatform/receipt/controller/ReceivableController.class
com/fundplatform/receipt/service/ReceivableService.class com/fundplatform/receipt/service/ReceivableService.class

View File

@ -1,9 +1,11 @@
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/data/mapper/FundReceiptMapper.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/data/mapper/FundReceiptMapper.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/aop/ApiLogAspect.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/aop/ApiLogAspect.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/data/mapper/ReceivableMapper.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/data/mapper/ReceivableMapper.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/task/ReceivableScheduledTask.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/ReceiptApplication.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/ReceiptApplication.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/data/service/FundReceiptDataService.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/data/service/FundReceiptDataService.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/dto/ReceivableDTO.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/dto/ReceivableDTO.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/dto/ReceivableExcel.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/data/entity/FundReceipt.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/data/entity/FundReceipt.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/service/ReceivableService.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/service/ReceivableService.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/vo/ReceivableVO.java /home/along/MyCode/wanjiabuluo/fundplatform/fund-receipt/src/main/java/com/fundplatform/receipt/vo/ReceivableVO.java

Some files were not shown because too many files have changed in this diff Show More