Compare commits

..

No commits in common. "480c052ff1944b45ddc40e7294061ecf3f6a6eb8" and "2765f3f26563895e48d99045ade37cc83034bcc4" have entirely different histories.

164 changed files with 1043 additions and 3721 deletions

View File

@ -1,50 +0,0 @@
# 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
View File

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

View File

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

View File

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

View File

@ -184,11 +184,9 @@ services:
REDIS_HOST: redis
REDIS_PORT: 6379
JAVA_OPTS: -Xms256m -Xmx512m
# 租户 ID - 共享实例(空值,所有租户可用)
# TENANT_ID: ""
# 租户 ID - 多租户专属实例(逗号分隔,服务多个租户)
TENANT_ID: "VIP_001,VIP_002,VIP_003"
# 租户元数据 - 共享实例(无特定租户组,所有租户可用)
TENANT_ID: "1"
TENANT_GROUP: ""
ports:
- "8100:8100"
depends_on:
@ -230,8 +228,9 @@ services:
REDIS_HOST: redis
REDIS_PORT: 6379
JAVA_OPTS: -Xms256m -Xmx512m
# 租户 ID - 单租户专属实例
TENANT_ID: "VIP_004"
# 租户元数据 - VIP_001 专属实例
TENANT_ID: "1001"
TENANT_GROUP: "TENANT_VIP_001"
ports:
- "8101:8101"
depends_on:
@ -273,8 +272,9 @@ services:
# REDIS_HOST: redis
# REDIS_PORT: 6379
# JAVA_OPTS: -Xms256m -Xmx512m
# # 租户 ID - VIP_002 专属实例
# TENANT_ID: "VIP_002"
# # 租户元数据 - VIP_002 专属实例
# TENANT_ID: "1002"
# TENANT_GROUP: "TENANT_VIP_002"
# ports:
# - "8102:8102"
# depends_on:
@ -510,54 +510,6 @@ services:
networks:
- 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:
fund-network:

View File

@ -1,31 +0,0 @@
# 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

View File

@ -1,61 +0,0 @@
# 资金服务平台管理后台 - 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;"]

View File

@ -1,54 +0,0 @@
# 资金服务平台管理后台 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,39 +62,3 @@ export function rejectExpense(id: number, comment: 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 || ''}`)
}
// 导出支出明细
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,39 +49,3 @@ export function getReceiptById(id: number) {
export function createReceipt(data: any) {
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,7 +40,6 @@
<el-card shadow="never" style="margin-top: 10px">
<div style="margin-bottom: 15px">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增支出</el-button>
<el-button type="success" :icon="Download" @click="handleExport" :loading="exporting">导出Excel</el-button>
</div>
<el-table :data="tableData" v-loading="loading" border stripe>
@ -263,7 +262,7 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Search, Refresh, Plus, Edit, Delete, View, Check, Document, Download } from '@element-plus/icons-vue'
import { Search, Refresh, Plus, Edit, Delete, View, Check, Document } from '@element-plus/icons-vue'
import {
getExpenseList,
createExpense,
@ -271,8 +270,7 @@ import {
deleteExpense,
submitExpense,
approveExpense,
rejectExpense,
exportExpense
rejectExpense
} from '@/api/expense'
import { getExpenseTypeTree } from '@/api/expense'
import { getProjectList } from '@/api/project'
@ -287,7 +285,6 @@ const fileList = ref<any[]>([])
const loading = ref(false)
const submitLoading = ref(false)
const exporting = ref(false)
const tableData = ref<any[]>([])
const total = ref(0)
const expenseTypeList = ref<any[]>([])
@ -561,34 +558,6 @@ const resetForm = () => {
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(() => {
fetchData()
fetchExpenseTypes()

View File

@ -27,7 +27,6 @@
<div style="margin-bottom: 15px">
<el-button type="primary" :icon="Plus" @click="handleAdd">新增应收款</el-button>
<el-button type="success" :icon="Money">批量收款</el-button>
<el-button type="success" :icon="Download" @click="handleExport" :loading="exporting">导出Excel</el-button>
</div>
<el-table :data="tableData" v-loading="loading" border stripe>
@ -263,15 +262,14 @@
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { Search, Refresh, Plus, Edit, Delete, View, Money, Download } from '@element-plus/icons-vue'
import { Search, Refresh, Plus, Edit, Delete, View, Money } from '@element-plus/icons-vue'
import {
getReceivableList,
createReceivable,
updateReceivable,
deleteReceivable,
recordReceipt,
getReceiptRecords,
exportReceivable
getReceiptRecords
} from '@/api/receivable'
import { getProjectList } from '@/api/project'
import { getCustomerList } from '@/api/customer'
@ -279,7 +277,6 @@ import { getCustomerList } from '@/api/customer'
const loading = ref(false)
const submitLoading = ref(false)
const receiptLoading = ref(false)
const exporting = ref(false)
const tableData = ref<any[]>([])
const total = ref(0)
const projectList = ref<any[]>([])
@ -503,31 +500,6 @@ const resetForm = () => {
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(() => {
fetchData()
fetchProjects()

View File

@ -3,31 +3,23 @@ package com.fundplatform.common.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.Arrays;
import java.util.List;
import java.util.*;
/**
* 多租户路由配置属性
*
* <p>租户路由基于 Nacos 服务实例的 metadata.tenant-id 元数据进行匹配</p>
* <p>支持从 YAML 配置文件读取租户路由配置</p>
*
* <h3>工作原理</h3>
* <pre>
* 1. 服务实例注册到 Nacos metadata 中声明 tenant-id
* - 共享实例: tenant-id 为空或不存在
* - VIP 实例: tenant-id = "VIP_001"
*
* 2. 负载均衡器根据请求中的 tenantId 匹配实例
* - 找到匹配的 tenant-id VIP 专属实例
* - 找不到 回退到共享实例
* </pre>
*
* <h3>配置示例</h3>
* <p>配置示例</p>
* <pre>
* tenant:
* routing:
* enabled: true
* tenant-header: X-Tenant-Id
* default-tenant-id: 1
* services:
* fund-sys:
* vip-tenants:
* - TENANT_VIP_001
* fallback-to-shared: true
* </pre>
*/
@ -41,6 +33,12 @@ public class TenantRoutingProperties {
/** 租户 ID 请求头 */
private String tenantHeader = "X-Tenant-Id";
/** 租户组请求头 */
private String tenantGroupHeader = "X-Tenant-Group";
/** 服务组分隔符 */
private String groupSeparator = "TENANT_";
/** 默认租户 ID当未指定时使用 */
private String defaultTenantId = "1";
@ -51,8 +49,13 @@ public class TenantRoutingProperties {
"fund-file"
);
/** 是否回退到共享实例(当找不到租户专属实例时) */
private boolean fallbackToShared = true;
/** 租户服务配置映射 */
private Map<String, TenantServiceConfig> services = new HashMap<>();
/** 租户服务配置(旧版兼容) */
private Map<String, TenantServiceConfig> tenantConfigs = new HashMap<>();
// Getters and Setters
public boolean isEnabled() {
return enabled;
@ -70,6 +73,22 @@ public class TenantRoutingProperties {
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() {
return defaultTenantId;
}
@ -86,6 +105,101 @@ public class TenantRoutingProperties {
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() {
return fallbackToShared;
}
@ -94,10 +208,130 @@ public class TenantRoutingProperties {
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 boolean isSharedService(String serviceName) {
return sharedServices != null && sharedServices.contains(serviceName);
public static class ServiceInstanceConfig {
/** 服务名 */
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,8 +14,6 @@ import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBal
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ThreadLocalRandom;
@ -26,22 +24,17 @@ import java.util.stream.Collectors;
*
* <p>根据租户 ID 进行服务实例路由支持</p>
* <ul>
* <li>租户专属实例优先metadata.tenant-id 匹配</li>
* <li>共享实例回退metadata.tenant-id 为空</li>
* <li>随机负载均衡</li>
* <li>租户专属实例优先</li>
* <li>共享实例回退</li>
* <li>轮询负载均衡</li>
* <li>混合模式VIP 租户专属 + 普通租户共享</li>
* </ul>
*
* <h3>路由规则</h3>
* <h3>使用场景</h3>
* <pre>
* 1. 查找 metadata.tenant-id 包含请求 tenantId 的实例 专属实例
* 2. 找不到 回退到共享实例metadata.tenant-id 为空
* </pre>
*
* <h3>实例配置</h3>
* <pre>
* 共享实例: metadata.tenant-id = "" ()
* 单租户专属: metadata.tenant-id = "VIP_001"
* 多租户专属: metadata.tenant-id = "VIP_001,VIP_002,VIP_003"
* 混合模式部署
* - VIP 客户独立部署服务实例 tenant-group 标签
* - 普通客户共享服务实例 tenant-group 标签
* </pre>
*/
public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalancer {
@ -68,8 +61,9 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
return getDefaultResponse();
}
// 从请求上下文获取租户 ID
// 从请求上下文获取租户信息
String tenantId = getTenantIdFromRequest(request);
String tenantGroup = buildTenantGroup(tenantId);
if (supplierProvider == null) {
logger.warn("[TenantLB] ServiceInstanceListSupplier 未提供");
@ -83,7 +77,7 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
}
return supplier.get().next()
.map(instances -> filterByTenantId(instances, tenantId))
.map(instances -> filterByTenantGroup(instances, tenantGroup))
.map(this::getInstanceResponse);
}
@ -129,22 +123,34 @@ 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>
* <ol>
* <li>优先选择租户专属实例metadata.tenant-id 包含请求的 tenantId</li>
* <li>回退到共享实例metadata.tenant-id 为空或不存在</li>
* <li>优先选择租户专属实例metadata.tenant-group 匹配</li>
* <li>回退到共享实例 tenant-group 标签</li>
* </ol>
*
* <p>tenant-id 支持逗号分隔的多个租户 ID</p>
*/
List<ServiceInstance> filterByTenantId(List<ServiceInstance> instances, String tenantId) {
List<ServiceInstance> filterByTenantGroup(List<ServiceInstance> instances, String tenantGroup) {
if (instances == null || instances.isEmpty()) {
return instances;
}
logger.debug("[TenantLB] 租户 ID{},候选实例数:{}", tenantId, instances.size());
logger.debug("[TenantLB] 租户组:{},候选实例数:{}", tenantGroup, instances.size());
// 检查是否为共享服务不需要租户路由
if (routingProperties != null && routingProperties.isSharedService(serviceId)) {
@ -153,38 +159,40 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
}
// 优先选择租户专属实例
if (tenantId != null && !tenantId.isEmpty()) {
List<ServiceInstance> tenantInstances = instances.stream()
.filter(inst -> {
List<String> allowedTenants = parseTenantIds(inst);
if (allowedTenants.contains(tenantId)) {
logger.debug("[TenantLB] 匹配租户专属实例:{}:{} (允许租户: {})",
inst.getHost(), inst.getPort(), allowedTenants);
return true;
Map<String, String> metadata = inst.getMetadata();
if (metadata == null) return false;
String instanceGroup = metadata.get("tenant-group");
boolean match = tenantGroup.equals(instanceGroup);
if (match) {
logger.debug("[TenantLB] 匹配租户实例:{}:{}", inst.getHost(), inst.getPort());
}
return false;
return match;
})
.collect(Collectors.toList());
if (!tenantInstances.isEmpty()) {
logger.info("[TenantLB] 找到 {} 个租户专属实例,租户 ID{}", tenantInstances.size(), tenantId);
logger.info("[TenantLB] 找到 {} 个租户专属实例,租户{}", tenantInstances.size(), tenantGroup);
return tenantInstances;
}
}
// 检查是否启用回退到共享实例
boolean fallbackEnabled = routingProperties == null || routingProperties.isFallbackToShared();
boolean fallbackEnabled = true;
if (routingProperties != null) {
fallbackEnabled = routingProperties.isFallbackToShared(serviceId);
}
if (!fallbackEnabled) {
logger.warn("[TenantLB] 服务 {} 未启用共享实例回退,返回空列表", serviceId);
return Collections.emptyList();
return List.of();
}
// 回退到共享实例metadata.tenant-id 为空或不存在
// 回退到共享实例 tenant-group 标签
List<ServiceInstance> sharedInstances = instances.stream()
.filter(inst -> {
List<String> allowedTenants = parseTenantIds(inst);
return allowedTenants.isEmpty(); // 空列表表示共享实例
Map<String, String> metadata = inst.getMetadata();
return metadata == null || !metadata.containsKey("tenant-group");
})
.collect(Collectors.toList());
@ -192,30 +200,6 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
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());
}
/**
* 从实例列表中选择一个随机
*/
@ -225,9 +209,10 @@ public class TenantAwareLoadBalancer implements ReactorServiceInstanceLoadBalanc
return new EmptyResponse();
}
// 随机选择
int index = ThreadLocalRandom.current().nextInt(instances.size());
ServiceInstance chosen = instances.get(index);
logger.info("[TenantLB] 选择实例:{}:{}", chosen.getHost(), chosen.getPort());
logger.info("[TenantLB] 选择实例:{}:{} (index={})", chosen.getHost(), chosen.getPort(), index);
return new DefaultResponse(chosen);
}

View File

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

View File

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

View File

@ -1,4 +1,4 @@
-------------------------------------------------------------------------------
Test set: 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
Tests run: 3, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.567 s -- in com.fundplatform.common.loadbalancer.TenantAwareLoadBalancerTest

View File

@ -1,51 +0,0 @@
# 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,10 +13,6 @@ spring:
group: DEFAULT_GROUP
username: nacos
password: nacos
# 租户路由元数据
# tenant-id: 空值=共享实例,单值=单租户专属,多值(逗号分隔)=多租户专属
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@ -41,17 +37,14 @@ mybatis-plus:
logic-delete-value: 1
logic-not-delete-value: 0
# Feign配置
feign:
fund-sys:
url: http://localhost:8100
logging:
level:
com.fundplatform.cust: 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,10 +13,6 @@ spring:
group: DEFAULT_GROUP
username: nacos
password: nacos
# 租户路由元数据
# tenant-id: 空值=共享实例,单值=单租户专属,多值(逗号分隔)=多租户专属
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@ -41,17 +37,14 @@ mybatis-plus:
logic-delete-value: 1
logic-not-delete-value: 0
# Feign配置
feign:
fund-sys:
url: http://localhost:8100
logging:
level:
com.fundplatform.cust: 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

@ -2,18 +2,12 @@ package com.fundplatform.exp.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
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.service.FundExpenseService;
import com.fundplatform.exp.vo.FundExpenseVO;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.stream.Collectors;
/**
* 支出管理Controller
*
@ -170,32 +164,4 @@ public class FundExpenseController {
public Result<java.util.List<java.util.Map<String, Object>>> 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

@ -1,122 +0,0 @@
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,11 +14,6 @@ public interface FundExpenseService {
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);
/**

View File

@ -135,19 +135,6 @@ public class FundExpenseServiceImpl implements FundExpenseService {
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
@Transactional(rollbackFor = Exception.class)
public boolean deleteExpense(Long id) {

View File

@ -1,51 +0,0 @@
# 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,9 +13,6 @@ spring:
group: DEFAULT_GROUP
username: nacos
password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@ -43,13 +40,3 @@ mybatis-plus:
logging:
level:
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,9 +13,6 @@ spring:
group: DEFAULT_GROUP
username: nacos
password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@ -43,13 +40,3 @@ mybatis-plus:
logging:
level:
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,7 +12,6 @@
/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/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/vo/ExpenseTypeVO.java
/home/along/MyCode/wanjiabuluo/fundplatform/fund-exp/src/main/java/com/fundplatform/exp/dto/ExpenseTypeDTO.java

View File

@ -1,63 +0,0 @@
# 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,6 +5,12 @@ spring:
application:
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:
nacos:
discovery:
@ -13,13 +19,6 @@ spring:
group: DEFAULT_GROUP
username: 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:
@ -41,14 +40,3 @@ file:
upload:
path: ./uploads
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,6 +5,12 @@ spring:
application:
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:
nacos:
discovery:
@ -13,13 +19,6 @@ spring:
group: DEFAULT_GROUP
username: 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:
@ -41,14 +40,3 @@ file:
upload:
path: ./uploads
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,114 +1,166 @@
# Docker 环境配置
# Docker 环境配置 - Gateway
server:
port: ${SERVER_PORT:8000}
spring:
application:
name: fund-gateway
profiles:
active: ${SPRING_PROFILES_ACTIVE:docker}
cloud:
nacos:
discovery:
server-addr: ${NACOS_SERVER_ADDR:nacos:8848}
namespace: fund-platform
group: DEFAULT_GROUP
username: ${NACOS_USERNAME:nacos}
password: ${NACOS_PASSWORD:nacos}
sentinel:
transport:
dashboard: ${SENTINEL_DASHBOARD:}:8080
port: 8719
eager: true
discovery:
namespace: ${NACOS_NAMESPACE:}
group: DEFAULT_GROUP
enabled: true
metadata:
service-type: gateway
gateway:
default-filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 100
redis-rate-limiter.burstCapacity: 200
key-resolver: "#{@ipKeyResolver}"
discovery:
locator:
enabled: true
lower-case-service-id: true
# 全局跨域配置
globalcors:
cors-configurations:
'[/**]':
allowedOriginPatterns: "*"
allowedMethods: "*"
allowedHeaders: "*"
allowCredentials: true
maxAge: 3600
allowed-origins: "*"
allowed-methods: "*"
allowed-headers: "*"
allow-credentials: true
routes:
- id: fund-sys
uri: lb://fund-sys
predicates:
- Path=/sys/**
- Path=/api/sys/**
filters:
- StripPrefix=1
# 租户感知负载均衡(自动添加 tenant-group 请求头)
- id: fund-cust
uri: lb://fund-cust
predicates:
- Path=/cust/**
- Path=/api/cust/**
filters:
- StripPrefix=1
- id: fund-proj
uri: lb://fund-proj
predicates:
- Path=/proj/**
- Path=/api/proj/**
filters:
- StripPrefix=1
- id: fund-req
uri: lb://fund-req
predicates:
- Path=/req/**
- Path=/api/req/**
filters:
- StripPrefix=1
- id: fund-exp
uri: lb://fund-exp
predicates:
- Path=/exp/**
- Path=/api/exp/**
filters:
- StripPrefix=1
- id: fund-receipt
uri: lb://fund-receipt
predicates:
- Path=/receipt/**
- Path=/api/receipt/**
filters:
- StripPrefix=1
- id: fund-report
uri: lb://fund-report
predicates:
- Path=/report/**
- Path=/api/report/**
filters:
- StripPrefix=1
- id: fund-file
uri: lb://fund-file
predicates:
- Path=/file/**
- Path=/api/file/**
filters:
- StripPrefix=1
data:
redis:
host: ${REDIS_HOST:redis}
port: ${REDIS_PORT:6379}
password: ${REDIS_PASSWORD:}
database: 1
# JWT 配置
jwt:
secret: ${JWT_SECRET:YourSecretKeyForJWTTokenGenerationMustBeAtLeast256BitsLong}
expiration: ${JWT_EXPIRATION:86400000}
logging:
level:
org.springframework.cloud.gateway: INFO
com.fundplatform.common: INFO
# ==================== 多租户混合模式配置 ====================
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: when_authorized
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,16 +128,29 @@ logging:
org.springframework.cloud.gateway: DEBUG
com.fundplatform.common.loadbalancer: DEBUG
# 多租户路由配置Gateway 全局配置)
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
tenant-group-header: X-Tenant-Group
group-separator: TENANT_
default-tenant-id: "1"
# 共享服务列表(不需要租户路由的服务)
shared-services:
- fund-gateway
- fund-report
- 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

View File

@ -0,0 +1,166 @@
# 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,16 +128,29 @@ logging:
org.springframework.cloud.gateway: DEBUG
com.fundplatform.common.loadbalancer: DEBUG
# 多租户路由配置Gateway 全局配置)
# 多租户路由配置
tenant:
routing:
enabled: true
tenant-header: X-Tenant-Id
tenant-group-header: X-Tenant-Group
group-separator: TENANT_
default-tenant-id: "1"
# 共享服务列表(不需要租户路由的服务)
shared-services:
- fund-gateway
- fund-report
- 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

View File

@ -1,31 +0,0 @@
# 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

View File

@ -1,61 +0,0 @@
# 资金服务平台移动端 - 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;"]

View File

@ -1,54 +0,0 @@
# 资金服务平台移动端 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

@ -1,68 +0,0 @@
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,18 +21,6 @@ const router = createRouter({
component: () => import('@/views/receivable/List.vue'),
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',
name: 'My',

View File

@ -61,18 +61,6 @@
</div>
<div class="quick-text">应收款</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-icon">
<van-icon name="user-o" />

View File

@ -1,186 +0,0 @@
<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

@ -1,205 +0,0 @@
<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

@ -1,51 +0,0 @@
# 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,9 +13,6 @@ spring:
group: DEFAULT_GROUP
username: nacos
password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@ -46,11 +43,3 @@ logging:
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,9 +13,6 @@ spring:
group: DEFAULT_GROUP
username: nacos
password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@ -46,11 +43,3 @@ logging:
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

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

View File

@ -2,19 +2,15 @@ package com.fundplatform.receipt.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fundplatform.common.core.Result;
import com.fundplatform.common.util.ExcelUtil;
import com.fundplatform.receipt.dto.ReceivableDTO;
import com.fundplatform.receipt.dto.ReceivableExcel;
import com.fundplatform.receipt.service.ReceivableService;
import com.fundplatform.receipt.vo.FundReceiptVO;
import com.fundplatform.receipt.vo.ReceivableVO;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.util.List;
import java.util.stream.Collectors;
/**
* 应收款管理Controller
@ -150,43 +146,4 @@ public class ReceivableController {
public Result<Integer> 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

@ -1,134 +0,0 @@
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,11 +33,6 @@ public interface ReceivableService {
*/
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);
/**
* 确认应收款
*/
@ -84,10 +79,4 @@ public interface ReceivableService {
* 获取逾期应收款数量
*/
Integer getOverdueCount();
/**
* 获取即将到期的应收款列表
* @param daysWithin 未来多少天内到期
*/
List<ReceivableVO> getUpcomingDueList(int daysWithin);
}

View File

@ -137,19 +137,6 @@ public class ReceivableServiceImpl implements ReceivableService {
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
@Transactional(rollbackFor = Exception.class)
public boolean confirmReceivable(Long id, Long confirmBy) {
@ -403,21 +390,4 @@ public class ReceivableServiceImpl implements ReceivableService {
.eq(Receivable::getStatus, STATUS_OVERDUE);
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

@ -1,74 +0,0 @@
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

@ -1,56 +0,0 @@
# 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,9 +13,6 @@ spring:
group: DEFAULT_GROUP
username: nacos
password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@ -43,24 +40,3 @@ mybatis-plus:
logging:
level:
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,9 +13,6 @@ spring:
group: DEFAULT_GROUP
username: nacos
password: nacos
# 租户路由元数据
metadata:
tenant-id: ${TENANT_ID:}
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
@ -43,24 +40,3 @@ mybatis-plus:
logging:
level:
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,9 +9,11 @@ com/fundplatform/receipt/data/service/FundReceiptDataService.class
com/fundplatform/receipt/controller/HealthController.class
com/fundplatform/receipt/data/service/ReceivableDataService.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/ReceiptApplication.class
com/fundplatform/receipt/service/FundReceiptService.class
com/fundplatform/receipt/aop/ApiLogAspect.class
com/fundplatform/receipt/dto/ReceivableDTO.class
com/fundplatform/receipt/controller/ReceivableController.class
com/fundplatform/receipt/service/ReceivableService.class

View File

@ -1,11 +1,9 @@
/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/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/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/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/service/ReceivableService.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