From 9ef50288e92be3bb1245eec41475305f95c1fcc8 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E9=99=88=E5=AD=90=E9=BB=98?= <925456043@qq.com>
Date: Fri, 20 Mar 2026 12:43:21 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E6=90=AD=E5=BB=BA=E5=90=8E=E7=AB=AF?=
=?UTF-8?q?=E5=A4=9A=E7=A7=9F=E6=88=B7=E5=90=8D=E7=89=87=E6=9C=8D=E5=8A=A1?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 初始化 Spring Boot 多模块工程与通用基础能力
- 增加租户、组织、用户、名片、文件、统计等业务模块
- 补充数据库迁移脚本与基础测试
---
backend/Dockerfile | 42 +
backend/easycard-boot/pom.xml | 119 +++
.../boot/EasycardBootApplication.java | 14 +
.../boot/config/EasycardCorsProperties.java | 88 ++
.../easycard/boot/config/OpenApiConfig.java | 18 +
.../easycard/boot/config/SecurityConfig.java | 171 ++++
.../src/main/resources/application-docker.yml | 10 +
.../src/main/resources/application-local.yml | 5 +
.../src/main/resources/application.yml | 78 ++
.../resources/db/migration/mysql/README.md | 51 ++
.../mysql/V1__create_core_schema.sql | 443 ++++++++++
.../mysql/V2__seed_platform_base_data.sql | 104 +++
.../mysql/V3__seed_demo_mvp_data.sql | 216 +++++
.../mysql/V4__allow_null_miniapp_app_id.sql | 2 +
...__drop_org_firm_profile_display_status.sql | 2 +
...dd_member_sort_and_user_status_cleanup.sql | 45 +
.../boot/EasycardBootApplicationTests.java | 32 +
.../config/EasycardCorsPropertiesTests.java | 74 ++
backend/easycard-common/pom.xml | 50 ++
.../com/easycard/common/api/ApiResponse.java | 16 +
.../easycard/common/auth/JwtTokenService.java | 80 ++
.../com/easycard/common/auth/LoginUser.java | 14 +
.../easycard/common/auth/SecurityUtils.java | 25 +
.../easycard/common/config/JacksonConfig.java | 19 +
.../common/exception/BusinessException.java | 15 +
.../common/storage/StorageUrlUtils.java | 48 +
.../easycard/common/tenant/TenantContext.java | 4 +
.../common/tenant/TenantContextHolder.java | 31 +
.../common/web/ClientRequestUtils.java | 22 +
.../common/web/GlobalExceptionHandler.java | 40 +
backend/easycard-module-card/pom.xml | 50 ++
.../controller/OpenMiniappController.java | 67 ++
.../card/controller/TenantCardController.java | 129 +++
.../module/card/dal/entity/CardProfileDO.java | 46 +
.../dal/entity/CardProfileSpecialtyDO.java | 28 +
.../card/dal/mapper/CardProfileMapper.java | 7 +
.../mapper/CardProfileSpecialtyMapper.java | 22 +
.../card/service/CardProfileService.java | 823 ++++++++++++++++++
backend/easycard-module-file/pom.xml | 40 +
.../file/controller/FileAssetController.java | 36 +
.../module/file/dal/entity/FileAssetDO.java | 35 +
.../file/dal/entity/FileAssetUsageDO.java | 28 +
.../file/dal/mapper/FileAssetMapper.java | 7 +
.../file/dal/mapper/FileAssetUsageMapper.java | 7 +
.../module/file/service/FileAssetService.java | 134 +++
backend/easycard-module-org/pom.xml | 30 +
.../org/controller/TenantOrgController.java | 123 +++
.../org/dal/entity/FileAssetLiteDO.java | 20 +
.../org/dal/entity/OrgDepartmentDO.java | 33 +
.../org/dal/entity/OrgFirmPracticeAreaDO.java | 28 +
.../org/dal/entity/OrgFirmProfileDO.java | 37 +
.../org/dal/mapper/FileAssetLiteMapper.java | 7 +
.../org/dal/mapper/OrgDepartmentMapper.java | 7 +
.../dal/mapper/OrgFirmPracticeAreaMapper.java | 7 +
.../org/dal/mapper/OrgFirmProfileMapper.java | 7 +
.../module/org/service/TenantOrgService.java | 303 +++++++
backend/easycard-module-stat/pom.xml | 30 +
.../stat/dal/entity/CardShareLogDO.java | 23 +
.../stat/dal/entity/CardStatDailyDO.java | 25 +
.../module/stat/dal/entity/CardViewLogDO.java | 25 +
.../stat/dal/mapper/CardShareLogMapper.java | 7 +
.../stat/dal/mapper/CardStatDailyMapper.java | 7 +
.../stat/dal/mapper/CardViewLogMapper.java | 7 +
.../module/stat/service/CardEventService.java | 77 ++
backend/easycard-module-system/pom.xml | 22 +
.../controller/SystemPingController.java | 29 +
backend/easycard-module-tenant/pom.xml | 50 ++
.../controller/PlatformMiniappController.java | 37 +
.../controller/PlatformTenantController.java | 140 +++
.../controller/TenantMiniappController.java | 36 +
.../module/tenant/dal/entity/TenantDO.java | 168 ++++
.../dal/entity/TenantMiniappConfigDO.java | 186 ++++
.../tenant/dal/mapper/TenantMapper.java | 7 +
.../dal/mapper/TenantMiniappConfigMapper.java | 7 +
.../tenant/service/PlatformTenantService.java | 90 ++
.../impl/PlatformTenantServiceImpl.java | 573 ++++++++++++
.../module/tenant/vo/TenantDetailResp.java | 105 +++
.../web/MiniappTenantContextFilter.java | 70 ++
backend/easycard-module-user/pom.xml | 35 +
.../user/controller/AuthController.java | 63 ++
.../user/controller/TenantUserController.java | 110 +++
.../module/user/dal/entity/SysLoginLogDO.java | 25 +
.../user/dal/entity/SysOperationLogDO.java | 30 +
.../module/user/dal/entity/SysRoleDO.java | 31 +
.../module/user/dal/entity/SysUserDO.java | 41 +
.../module/user/dal/entity/SysUserRoleDO.java | 21 +
.../user/dal/mapper/SysLoginLogMapper.java | 7 +
.../dal/mapper/SysOperationLogMapper.java | 7 +
.../module/user/dal/mapper/SysRoleMapper.java | 7 +
.../module/user/dal/mapper/SysUserMapper.java | 7 +
.../user/dal/mapper/SysUserRoleMapper.java | 7 +
.../module/user/service/AuthService.java | 186 ++++
.../user/service/TenantUserService.java | 343 ++++++++
.../module/user/service/UserAuditService.java | 48 +
backend/pom.xml | 94 ++
95 files changed, 6722 insertions(+)
create mode 100644 backend/Dockerfile
create mode 100644 backend/easycard-boot/pom.xml
create mode 100644 backend/easycard-boot/src/main/java/com/easycard/boot/EasycardBootApplication.java
create mode 100644 backend/easycard-boot/src/main/java/com/easycard/boot/config/EasycardCorsProperties.java
create mode 100644 backend/easycard-boot/src/main/java/com/easycard/boot/config/OpenApiConfig.java
create mode 100644 backend/easycard-boot/src/main/java/com/easycard/boot/config/SecurityConfig.java
create mode 100644 backend/easycard-boot/src/main/resources/application-docker.yml
create mode 100644 backend/easycard-boot/src/main/resources/application-local.yml
create mode 100644 backend/easycard-boot/src/main/resources/application.yml
create mode 100644 backend/easycard-boot/src/main/resources/db/migration/mysql/README.md
create mode 100644 backend/easycard-boot/src/main/resources/db/migration/mysql/V1__create_core_schema.sql
create mode 100644 backend/easycard-boot/src/main/resources/db/migration/mysql/V2__seed_platform_base_data.sql
create mode 100644 backend/easycard-boot/src/main/resources/db/migration/mysql/V3__seed_demo_mvp_data.sql
create mode 100644 backend/easycard-boot/src/main/resources/db/migration/mysql/V4__allow_null_miniapp_app_id.sql
create mode 100644 backend/easycard-boot/src/main/resources/db/migration/mysql/V5__drop_org_firm_profile_display_status.sql
create mode 100644 backend/easycard-boot/src/main/resources/db/migration/mysql/V6__add_member_sort_and_user_status_cleanup.sql
create mode 100644 backend/easycard-boot/src/test/java/com/easycard/boot/EasycardBootApplicationTests.java
create mode 100644 backend/easycard-boot/src/test/java/com/easycard/boot/config/EasycardCorsPropertiesTests.java
create mode 100644 backend/easycard-common/pom.xml
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/api/ApiResponse.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/auth/JwtTokenService.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/auth/LoginUser.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/auth/SecurityUtils.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/config/JacksonConfig.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/exception/BusinessException.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/storage/StorageUrlUtils.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/tenant/TenantContext.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/tenant/TenantContextHolder.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/web/ClientRequestUtils.java
create mode 100644 backend/easycard-common/src/main/java/com/easycard/common/web/GlobalExceptionHandler.java
create mode 100644 backend/easycard-module-card/pom.xml
create mode 100644 backend/easycard-module-card/src/main/java/com/easycard/module/card/controller/OpenMiniappController.java
create mode 100644 backend/easycard-module-card/src/main/java/com/easycard/module/card/controller/TenantCardController.java
create mode 100644 backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/entity/CardProfileDO.java
create mode 100644 backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/entity/CardProfileSpecialtyDO.java
create mode 100644 backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/mapper/CardProfileMapper.java
create mode 100644 backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/mapper/CardProfileSpecialtyMapper.java
create mode 100644 backend/easycard-module-card/src/main/java/com/easycard/module/card/service/CardProfileService.java
create mode 100644 backend/easycard-module-file/pom.xml
create mode 100644 backend/easycard-module-file/src/main/java/com/easycard/module/file/controller/FileAssetController.java
create mode 100644 backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/entity/FileAssetDO.java
create mode 100644 backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/entity/FileAssetUsageDO.java
create mode 100644 backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/mapper/FileAssetMapper.java
create mode 100644 backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/mapper/FileAssetUsageMapper.java
create mode 100644 backend/easycard-module-file/src/main/java/com/easycard/module/file/service/FileAssetService.java
create mode 100644 backend/easycard-module-org/pom.xml
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/controller/TenantOrgController.java
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/FileAssetLiteDO.java
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgDepartmentDO.java
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgFirmPracticeAreaDO.java
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgFirmProfileDO.java
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/FileAssetLiteMapper.java
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgDepartmentMapper.java
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgFirmPracticeAreaMapper.java
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgFirmProfileMapper.java
create mode 100644 backend/easycard-module-org/src/main/java/com/easycard/module/org/service/TenantOrgService.java
create mode 100644 backend/easycard-module-stat/pom.xml
create mode 100644 backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardShareLogDO.java
create mode 100644 backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardStatDailyDO.java
create mode 100644 backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardViewLogDO.java
create mode 100644 backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardShareLogMapper.java
create mode 100644 backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardStatDailyMapper.java
create mode 100644 backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardViewLogMapper.java
create mode 100644 backend/easycard-module-stat/src/main/java/com/easycard/module/stat/service/CardEventService.java
create mode 100644 backend/easycard-module-system/pom.xml
create mode 100644 backend/easycard-module-system/src/main/java/com/easycard/module/system/controller/SystemPingController.java
create mode 100644 backend/easycard-module-tenant/pom.xml
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/controller/PlatformMiniappController.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/controller/PlatformTenantController.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/controller/TenantMiniappController.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/dal/entity/TenantDO.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/dal/entity/TenantMiniappConfigDO.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/dal/mapper/TenantMapper.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/dal/mapper/TenantMiniappConfigMapper.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/service/PlatformTenantService.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/service/impl/PlatformTenantServiceImpl.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/vo/TenantDetailResp.java
create mode 100644 backend/easycard-module-tenant/src/main/java/com/easycard/module/tenant/web/MiniappTenantContextFilter.java
create mode 100644 backend/easycard-module-user/pom.xml
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/controller/AuthController.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/controller/TenantUserController.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/entity/SysLoginLogDO.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/entity/SysOperationLogDO.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/entity/SysRoleDO.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/entity/SysUserDO.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/entity/SysUserRoleDO.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/mapper/SysLoginLogMapper.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/mapper/SysOperationLogMapper.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/mapper/SysRoleMapper.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/mapper/SysUserMapper.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/dal/mapper/SysUserRoleMapper.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/service/AuthService.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/service/TenantUserService.java
create mode 100644 backend/easycard-module-user/src/main/java/com/easycard/module/user/service/UserAuditService.java
create mode 100644 backend/pom.xml
diff --git a/backend/Dockerfile b/backend/Dockerfile
new file mode 100644
index 0000000..44c571b
--- /dev/null
+++ b/backend/Dockerfile
@@ -0,0 +1,42 @@
+# syntax=docker/dockerfile:1.7
+
+FROM --platform=${BUILDPLATFORM} maven:3.9.9-eclipse-temurin-21 AS builder
+
+WORKDIR /build
+
+COPY backend/pom.xml ./pom.xml
+COPY backend/easycard-common/pom.xml ./easycard-common/pom.xml
+COPY backend/easycard-module-system/pom.xml ./easycard-module-system/pom.xml
+COPY backend/easycard-module-tenant/pom.xml ./easycard-module-tenant/pom.xml
+COPY backend/easycard-module-org/pom.xml ./easycard-module-org/pom.xml
+COPY backend/easycard-module-user/pom.xml ./easycard-module-user/pom.xml
+COPY backend/easycard-module-card/pom.xml ./easycard-module-card/pom.xml
+COPY backend/easycard-module-file/pom.xml ./easycard-module-file/pom.xml
+COPY backend/easycard-module-stat/pom.xml ./easycard-module-stat/pom.xml
+COPY backend/easycard-boot/pom.xml ./easycard-boot/pom.xml
+
+RUN mvn -q -DskipTests dependency:go-offline
+
+COPY backend/. .
+
+RUN mvn -q -DskipTests -pl easycard-boot -am clean package \
+ && cp /build/easycard-boot/target/easycard-boot-0.1.0-SNAPSHOT.jar /tmp/app.jar \
+ && rm -rf /tmp/manifest-check \
+ && mkdir -p /tmp/manifest-check \
+ && cd /tmp/manifest-check \
+ && jar xf /tmp/app.jar META-INF/MANIFEST.MF \
+ && grep -q "Main-Class: org.springframework.boot.loader.launch.JarLauncher" META-INF/MANIFEST.MF \
+ && grep -q "Start-Class: com.easycard.boot.EasycardBootApplication" META-INF/MANIFEST.MF
+
+FROM --platform=${TARGETPLATFORM} eclipse-temurin:21-jre
+
+WORKDIR /app
+
+ENV TZ=Asia/Shanghai
+ENV JAVA_OPTS=""
+
+COPY --from=builder /tmp/app.jar /app/app.jar
+
+EXPOSE 8112
+
+ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/app.jar"]
diff --git a/backend/easycard-boot/pom.xml b/backend/easycard-boot/pom.xml
new file mode 100644
index 0000000..05c7ee7
--- /dev/null
+++ b/backend/easycard-boot/pom.xml
@@ -0,0 +1,119 @@
+
+ 4.0.0
+
+
+ com.easycard
+ easycard-parent
+ 0.1.0-SNAPSHOT
+
+
+ easycard-boot
+ easycard-boot
+
+
+
+ com.easycard
+ easycard-common
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-system
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-tenant
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-org
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-user
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-card
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-file
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-stat
+ ${project.version}
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-starter-security
+
+
+ org.springframework.boot
+ spring-boot-starter-actuator
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+ org.flywaydb
+ flyway-mysql
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+ org.springdoc
+ springdoc-openapi-starter-webmvc-ui
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ repackage
+
+
+
+
+ com.easycard.boot.EasycardBootApplication
+
+
+
+
+
diff --git a/backend/easycard-boot/src/main/java/com/easycard/boot/EasycardBootApplication.java b/backend/easycard-boot/src/main/java/com/easycard/boot/EasycardBootApplication.java
new file mode 100644
index 0000000..2dd544d
--- /dev/null
+++ b/backend/easycard-boot/src/main/java/com/easycard/boot/EasycardBootApplication.java
@@ -0,0 +1,14 @@
+package com.easycard.boot;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication(scanBasePackages = "com.easycard")
+@MapperScan(basePackages = "com.easycard.module")
+public class EasycardBootApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(EasycardBootApplication.class, args);
+ }
+}
diff --git a/backend/easycard-boot/src/main/java/com/easycard/boot/config/EasycardCorsProperties.java b/backend/easycard-boot/src/main/java/com/easycard/boot/config/EasycardCorsProperties.java
new file mode 100644
index 0000000..91ff806
--- /dev/null
+++ b/backend/easycard-boot/src/main/java/com/easycard/boot/config/EasycardCorsProperties.java
@@ -0,0 +1,88 @@
+package com.easycard.boot.config;
+
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.http.HttpHeaders;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@ConfigurationProperties(prefix = "easycard.web.cors")
+public class EasycardCorsProperties {
+
+ private List allowedOrigins = new ArrayList<>(List.of(
+ "http://localhost:5173",
+ "http://127.0.0.1:5173",
+ "http://localhost:8081",
+ "http://127.0.0.1:8081"
+ ));
+
+ private List allowedOriginPatterns = new ArrayList<>();
+
+ private List allowedMethods = new ArrayList<>(List.of(
+ "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
+ ));
+
+ private List allowedHeaders = new ArrayList<>(List.of("*"));
+
+ private List exposedHeaders = new ArrayList<>(List.of(HttpHeaders.AUTHORIZATION));
+
+ private boolean allowCredentials = true;
+
+ private long maxAge = 1800;
+
+ public List getAllowedOrigins() {
+ return allowedOrigins;
+ }
+
+ public void setAllowedOrigins(List allowedOrigins) {
+ this.allowedOrigins = allowedOrigins;
+ }
+
+ public List getAllowedOriginPatterns() {
+ return allowedOriginPatterns;
+ }
+
+ public void setAllowedOriginPatterns(List allowedOriginPatterns) {
+ this.allowedOriginPatterns = allowedOriginPatterns;
+ }
+
+ public List getAllowedMethods() {
+ return allowedMethods;
+ }
+
+ public void setAllowedMethods(List allowedMethods) {
+ this.allowedMethods = allowedMethods;
+ }
+
+ public List getAllowedHeaders() {
+ return allowedHeaders;
+ }
+
+ public void setAllowedHeaders(List allowedHeaders) {
+ this.allowedHeaders = allowedHeaders;
+ }
+
+ public List getExposedHeaders() {
+ return exposedHeaders;
+ }
+
+ public void setExposedHeaders(List exposedHeaders) {
+ this.exposedHeaders = exposedHeaders;
+ }
+
+ public boolean isAllowCredentials() {
+ return allowCredentials;
+ }
+
+ public void setAllowCredentials(boolean allowCredentials) {
+ this.allowCredentials = allowCredentials;
+ }
+
+ public long getMaxAge() {
+ return maxAge;
+ }
+
+ public void setMaxAge(long maxAge) {
+ this.maxAge = maxAge;
+ }
+}
diff --git a/backend/easycard-boot/src/main/java/com/easycard/boot/config/OpenApiConfig.java b/backend/easycard-boot/src/main/java/com/easycard/boot/config/OpenApiConfig.java
new file mode 100644
index 0000000..adbe8d1
--- /dev/null
+++ b/backend/easycard-boot/src/main/java/com/easycard/boot/config/OpenApiConfig.java
@@ -0,0 +1,18 @@
+package com.easycard.boot.config;
+
+import io.swagger.v3.oas.models.OpenAPI;
+import io.swagger.v3.oas.models.info.Info;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class OpenApiConfig {
+
+ @Bean
+ public OpenAPI openAPI() {
+ return new OpenAPI().info(new Info()
+ .title("Easycard Backend API")
+ .version("v0.1.0")
+ .description("电子名片系统后端接口文档"));
+ }
+}
diff --git a/backend/easycard-boot/src/main/java/com/easycard/boot/config/SecurityConfig.java b/backend/easycard-boot/src/main/java/com/easycard/boot/config/SecurityConfig.java
new file mode 100644
index 0000000..34459f0
--- /dev/null
+++ b/backend/easycard-boot/src/main/java/com/easycard/boot/config/SecurityConfig.java
@@ -0,0 +1,171 @@
+package com.easycard.boot.config;
+
+import com.easycard.common.api.ApiResponse;
+import com.easycard.common.auth.JwtTokenService;
+import com.easycard.common.auth.LoginUser;
+import com.easycard.common.tenant.TenantContext;
+import com.easycard.common.tenant.TenantContextHolder;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import jakarta.servlet.FilterChain;
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.config.Customizer;
+import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
+import org.springframework.security.config.annotation.web.builders.HttpSecurity;
+import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
+import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.authority.SimpleGrantedAuthority;
+import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.security.web.SecurityFilterChain;
+import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.OncePerRequestFilter;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Configuration
+@EnableConfigurationProperties(EasycardCorsProperties.class)
+@EnableMethodSecurity
+public class SecurityConfig {
+
+ @Bean
+ public PasswordEncoder passwordEncoder() {
+ return new BCryptPasswordEncoder();
+ }
+
+ @Bean
+ public CorsConfigurationSource corsConfigurationSource(EasycardCorsProperties corsProperties) {
+ CorsConfiguration configuration = new CorsConfiguration();
+ List allowedOrigins = sanitize(corsProperties.getAllowedOrigins());
+ List allowedOriginPatterns = sanitize(corsProperties.getAllowedOriginPatterns());
+ if (!allowedOrigins.isEmpty()) {
+ configuration.setAllowedOrigins(allowedOrigins);
+ }
+ if (!allowedOriginPatterns.isEmpty()) {
+ configuration.setAllowedOriginPatterns(allowedOriginPatterns);
+ }
+ configuration.setAllowedMethods(sanitize(corsProperties.getAllowedMethods()));
+ configuration.setAllowedHeaders(sanitize(corsProperties.getAllowedHeaders()));
+ configuration.setAllowCredentials(corsProperties.isAllowCredentials());
+ configuration.setExposedHeaders(sanitize(corsProperties.getExposedHeaders()));
+ configuration.setMaxAge(corsProperties.getMaxAge());
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", configuration);
+ return source;
+ }
+
+ private static List sanitize(List values) {
+ if (values == null) {
+ return List.of();
+ }
+ return values.stream()
+ .filter(StringUtils::hasText)
+ .map(String::trim)
+ .toList();
+ }
+
+ @Bean
+ public SecurityFilterChain securityFilterChain(
+ HttpSecurity http,
+ JwtAuthenticationFilter jwtAuthenticationFilter
+ ) throws Exception {
+ http
+ .csrf(AbstractHttpConfigurer::disable)
+ .httpBasic(AbstractHttpConfigurer::disable)
+ .formLogin(AbstractHttpConfigurer::disable)
+ .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
+ .authorizeHttpRequests(authorize -> authorize
+ .requestMatchers(
+ "/actuator/health",
+ "/actuator/info",
+ "/v3/api-docs/**",
+ "/swagger-ui/**",
+ "/swagger-ui.html",
+ "/api/v1/system/ping",
+ "/api/v1/auth/login",
+ "/api/open/**"
+ ).permitAll()
+ .anyRequest().authenticated())
+ .exceptionHandling(configurer -> configurer.authenticationEntryPoint((request, response, exception) -> {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.getWriter().write("{\"code\":\"UNAUTHORIZED\",\"message\":\"未登录或登录已失效\",\"data\":null}");
+ }))
+ .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
+ .cors(Customizer.withDefaults());
+ return http.build();
+ }
+}
+
+@Component
+class JwtAuthenticationFilter extends OncePerRequestFilter {
+
+ private final JwtTokenService jwtTokenService;
+ private final ObjectMapper objectMapper;
+
+ JwtAuthenticationFilter(JwtTokenService jwtTokenService, ObjectMapper objectMapper) {
+ this.jwtTokenService = jwtTokenService;
+ this.objectMapper = objectMapper;
+ }
+
+ @Override
+ protected boolean shouldNotFilter(HttpServletRequest request) {
+ String uri = request.getRequestURI();
+ return uri.startsWith("/api/open/") || "/api/v1/auth/login".equals(uri);
+ }
+
+ @Override
+ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
+ throws ServletException, IOException {
+ String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
+ if (authorization != null && authorization.startsWith("Bearer ")) {
+ String token = authorization.substring(7);
+ LoginUser loginUser = jwtTokenService.parseToken(token).orElse(null);
+ if (loginUser != null) {
+ UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(
+ loginUser,
+ null,
+ loginUser.roleCodes().stream().map(SimpleGrantedAuthority::new).collect(Collectors.toList())
+ );
+ org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(authenticationToken);
+ if (loginUser.tenantId() != null && loginUser.tenantId() > 0) {
+ TenantContextHolder.set(new TenantContext(loginUser.tenantId(), null, null));
+ }
+ } else {
+ writeUnauthorized(response);
+ return;
+ }
+ }
+
+ try {
+ filterChain.doFilter(request, response);
+ } finally {
+ TenantContextHolder.clear();
+ org.springframework.security.core.context.SecurityContextHolder.clearContext();
+ }
+ }
+
+ private void writeUnauthorized(HttpServletResponse response) throws IOException {
+ response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ response.setCharacterEncoding(StandardCharsets.UTF_8.name());
+ response.setContentType(MediaType.APPLICATION_JSON_VALUE);
+ response.getWriter().write(objectMapper.writeValueAsString(ApiResponse.fail("UNAUTHORIZED", "令牌无效或已过期")));
+ }
+}
diff --git a/backend/easycard-boot/src/main/resources/application-docker.yml b/backend/easycard-boot/src/main/resources/application-docker.yml
new file mode 100644
index 0000000..7ba3e7d
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/application-docker.yml
@@ -0,0 +1,10 @@
+spring:
+ flyway:
+ connect-retries: 30
+
+logging:
+ file:
+ path: ${LOG_PATH:/app/logs}
+ level:
+ root: INFO
+ com.easycard: INFO
diff --git a/backend/easycard-boot/src/main/resources/application-local.yml b/backend/easycard-boot/src/main/resources/application-local.yml
new file mode 100644
index 0000000..93ac852
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/application-local.yml
@@ -0,0 +1,5 @@
+logging:
+ level:
+ root: INFO
+ com.easycard: INFO
+ org.flywaydb: INFO
diff --git a/backend/easycard-boot/src/main/resources/application.yml b/backend/easycard-boot/src/main/resources/application.yml
new file mode 100644
index 0000000..bc6ecfb
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/application.yml
@@ -0,0 +1,78 @@
+spring:
+ application:
+ name: easycard-backend
+ profiles:
+ active: local
+ servlet:
+ multipart:
+ max-file-size: 8MB
+ max-request-size: 8MB
+ datasource:
+ url: jdbc:mysql://${DB_HOST:127.0.0.1}:${DB_PORT:23306}/${DB_NAME:easycard}?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
+ username: ${DB_USERNAME:root}
+ password: ${DB_PASSWORD:root}
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ data:
+ redis:
+ host: ${REDIS_HOST:127.0.0.1}
+ port: ${REDIS_PORT:6379}
+ database: ${REDIS_DATABASE:3}
+ password: ${REDIS_PASSWORD:123456}
+ flyway:
+ enabled: true
+ locations: classpath:db/migration/mysql
+ baseline-on-migrate: true
+ encoding: UTF-8
+ jackson:
+ time-zone: Asia/Shanghai
+
+server:
+ port: ${SERVER_PORT:8112}
+ forward-headers-strategy: framework
+ servlet:
+ context-path: /
+
+management:
+ endpoints:
+ web:
+ exposure:
+ include: health,info
+ endpoint:
+ health:
+ show-details: always
+
+springdoc:
+ api-docs:
+ enabled: true
+ swagger-ui:
+ path: /swagger-ui.html
+
+mybatis-plus:
+ configuration:
+ map-underscore-to-camel-case: true
+ global-config:
+ db-config:
+ id-type: auto
+ logic-delete-field: deleted
+ logic-delete-value: 1
+ logic-not-delete-value: 0
+
+easycard:
+ security:
+ jwt-secret: ${JWT_SECRET:change-me-in-production}
+ jwt-expire-hours: ${JWT_EXPIRE_HOURS:24}
+ web:
+ cors:
+ allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://127.0.0.1:5173,http://localhost:8081,http://127.0.0.1:8081}
+ allowed-origin-patterns: ${CORS_ALLOWED_ORIGIN_PATTERNS:}
+ allowed-methods: ${CORS_ALLOWED_METHODS:GET,POST,PUT,PATCH,DELETE,OPTIONS}
+ allowed-headers: ${CORS_ALLOWED_HEADERS:*}
+ exposed-headers: ${CORS_EXPOSED_HEADERS:Authorization}
+ allow-credentials: ${CORS_ALLOW_CREDENTIALS:true}
+ max-age: ${CORS_MAX_AGE:1800}
+ storage:
+ endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
+ public-endpoint: ${MINIO_PUBLIC_ENDPOINT:${MINIO_ENDPOINT:http://127.0.0.1:9000}}
+ access-key: ${MINIO_ACCESS_KEY:minioadmin}
+ secret-key: ${MINIO_SECRET_KEY:minioadmin}
+ bucket: ${MINIO_BUCKET:easycard}
diff --git a/backend/easycard-boot/src/main/resources/db/migration/mysql/README.md b/backend/easycard-boot/src/main/resources/db/migration/mysql/README.md
new file mode 100644
index 0000000..aa58b83
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/db/migration/mysql/README.md
@@ -0,0 +1,51 @@
+# Flyway 迁移说明
+
+## 目录说明
+
+当前项目约定 MySQL 迁移脚本放在:
+
+`backend/easycard-boot/src/main/resources/db/migration/mysql`
+
+建议 Spring Boot 配置:
+
+```yaml
+spring:
+ flyway:
+ enabled: true
+ locations: classpath:db/migration/mysql
+ baseline-on-migrate: true
+ encoding: UTF-8
+```
+
+## 命名规则
+
+- 版本迁移:`V{版本号}__{说明}.sql`
+- 可重复执行脚本:`R__{说明}.sql`
+
+示例:
+
+- `V1__create_core_schema.sql`
+- `V2__seed_platform_base_data.sql`
+- `V3__add_card_audit_table.sql`
+- `R__refresh_report_view.sql`
+
+## 当前脚本职责
+
+- `V1__create_core_schema.sql`
+ 创建第一阶段核心表结构
+
+- `V2__seed_platform_base_data.sql`
+ 初始化平台角色、基础菜单、字典类型和字典项
+
+## 后续迁移规则
+
+- 不要回改已执行的版本脚本
+- 新字段、新索引、新表一律追加新版本
+- 租户初始化数据不要写进平台全局迁移,改由业务代码在“创建租户”流程中生成
+- 大批量修复历史数据时,单独建迁移脚本,不要混入结构变更
+
+## 与初始化脚本关系
+
+仓库中的 [easycard_init.sql](/Users/slience/postgraduate/easycard/database/mysql/easycard_init.sql) 当前只负责建库。
+
+表结构、平台初始化数据、演示数据统一以当前目录下的 Flyway 版本脚本为准,不再额外维护一份完整的本地建表脚本。
diff --git a/backend/easycard-boot/src/main/resources/db/migration/mysql/V1__create_core_schema.sql b/backend/easycard-boot/src/main/resources/db/migration/mysql/V1__create_core_schema.sql
new file mode 100644
index 0000000..7d31dd5
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/db/migration/mysql/V1__create_core_schema.sql
@@ -0,0 +1,443 @@
+CREATE TABLE `sys_tenant` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_code` VARCHAR(64) NOT NULL COMMENT '租户编码',
+ `tenant_name` VARCHAR(128) NOT NULL COMMENT '租户名称',
+ `tenant_short_name` VARCHAR(64) DEFAULT NULL COMMENT '租户简称',
+ `contact_name` VARCHAR(64) DEFAULT NULL COMMENT '联系人',
+ `contact_phone` VARCHAR(32) DEFAULT NULL COMMENT '联系人电话',
+ `contact_email` VARCHAR(128) DEFAULT NULL COMMENT '联系人邮箱',
+ `tenant_status` VARCHAR(32) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED/EXPIRED',
+ `expire_at` DATETIME(3) DEFAULT NULL COMMENT '到期时间',
+ `user_limit` INT UNSIGNED NOT NULL DEFAULT 20 COMMENT '用户数量上限',
+ `storage_limit_mb` INT UNSIGNED NOT NULL DEFAULT 1024 COMMENT '存储空间上限,单位MB',
+ `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_sys_tenant_code_deleted` (`tenant_code`, `deleted`),
+ KEY `idx_sys_tenant_status` (`tenant_status`),
+ KEY `idx_sys_tenant_expire_at` (`expire_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户表';
+
+CREATE TABLE `tenant_miniapp_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `env_code` VARCHAR(16) NOT NULL DEFAULT 'PROD' COMMENT '环境:DEV/TEST/PROD',
+ `miniapp_app_id` VARCHAR(64) NOT NULL COMMENT '小程序AppID',
+ `miniapp_app_secret` VARCHAR(255) NOT NULL COMMENT '小程序AppSecret,建议应用层加密后存储',
+ `miniapp_name` VARCHAR(128) NOT NULL COMMENT '小程序名称',
+ `miniapp_original_id` VARCHAR(64) DEFAULT NULL COMMENT '小程序原始ID',
+ `request_domain` VARCHAR(255) DEFAULT NULL COMMENT '接口请求域名',
+ `upload_domain` VARCHAR(255) DEFAULT NULL COMMENT '上传域名',
+ `download_domain` VARCHAR(255) DEFAULT NULL COMMENT '下载域名',
+ `version_tag` VARCHAR(64) DEFAULT NULL COMMENT '当前版本号',
+ `publish_status` VARCHAR(32) NOT NULL DEFAULT 'UNCONFIGURED' COMMENT '发布状态:UNCONFIGURED/DRAFT/PUBLISHED/DISABLED',
+ `last_published_at` DATETIME(3) DEFAULT NULL COMMENT '最近发布时间',
+ `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_miniapp_app_id_deleted` (`miniapp_app_id`, `deleted`),
+ UNIQUE KEY `uk_tenant_miniapp_env_deleted` (`tenant_id`, `env_code`, `deleted`),
+ KEY `idx_tenant_miniapp_status` (`tenant_id`, `publish_status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='租户小程序配置';
+
+CREATE TABLE `sys_user` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID,平台用户固定为0',
+ `user_type` VARCHAR(16) NOT NULL COMMENT '用户类型:PLATFORM/TENANT',
+ `username` VARCHAR(64) NOT NULL COMMENT '登录账号',
+ `password_hash` VARCHAR(255) NOT NULL COMMENT '密码哈希',
+ `real_name` VARCHAR(64) NOT NULL COMMENT '真实姓名',
+ `nick_name` VARCHAR(64) DEFAULT NULL COMMENT '昵称',
+ `gender` VARCHAR(16) DEFAULT NULL COMMENT '性别:MALE/FEMALE/UNKNOWN',
+ `mobile` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
+ `email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
+ `avatar_asset_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '头像素材ID',
+ `dept_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '所属组织ID',
+ `job_title` VARCHAR(128) DEFAULT NULL COMMENT '岗位/职务',
+ `user_status` VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED/LOCKED',
+ `must_update_password` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否首次登录强制改密',
+ `last_login_at` DATETIME(3) DEFAULT NULL COMMENT '最近登录时间',
+ `last_login_ip` VARCHAR(64) DEFAULT NULL COMMENT '最近登录IP',
+ `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_sys_user_tenant_username_deleted` (`tenant_id`, `username`, `deleted`),
+ KEY `idx_sys_user_tenant_status` (`tenant_id`, `user_status`),
+ KEY `idx_sys_user_dept` (`tenant_id`, `dept_id`),
+ KEY `idx_sys_user_mobile` (`mobile`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统用户表';
+
+CREATE TABLE `sys_role` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID,平台角色固定为0',
+ `role_scope` VARCHAR(16) NOT NULL COMMENT '角色范围:PLATFORM/TENANT',
+ `role_code` VARCHAR(64) NOT NULL COMMENT '角色编码',
+ `role_name` VARCHAR(128) NOT NULL COMMENT '角色名称',
+ `data_scope` VARCHAR(16) NOT NULL DEFAULT 'SELF' COMMENT '数据范围:ALL/TENANT/DEPT/SELF',
+ `is_builtin` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否内置角色',
+ `role_status` VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED',
+ `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_sys_role_tenant_code_deleted` (`tenant_id`, `role_code`, `deleted`),
+ KEY `idx_sys_role_scope_status` (`role_scope`, `role_status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色表';
+
+CREATE TABLE `sys_menu` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `parent_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父级菜单ID',
+ `menu_scope` VARCHAR(16) NOT NULL DEFAULT 'ALL' COMMENT '适用范围:PLATFORM/TENANT/ALL',
+ `menu_type` VARCHAR(16) NOT NULL COMMENT '类型:DIRECTORY/MENU/BUTTON',
+ `menu_name` VARCHAR(128) NOT NULL COMMENT '菜单名称',
+ `route_path` VARCHAR(255) DEFAULT NULL COMMENT '前端路由路径',
+ `component_path` VARCHAR(255) DEFAULT NULL COMMENT '前端组件路径',
+ `permission_code` VARCHAR(128) DEFAULT NULL COMMENT '权限标识',
+ `icon` VARCHAR(64) DEFAULT NULL COMMENT '图标',
+ `display_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
+ `is_visible` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否可见',
+ `is_keep_alive` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否缓存',
+ `menu_status` VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_sys_menu_permission_deleted` (`permission_code`, `deleted`),
+ KEY `idx_sys_menu_parent` (`parent_id`),
+ KEY `idx_sys_menu_scope_status` (`menu_scope`, `menu_status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='菜单权限表';
+
+CREATE TABLE `sys_role_menu` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `role_id` BIGINT UNSIGNED NOT NULL COMMENT '角色ID',
+ `menu_id` BIGINT UNSIGNED NOT NULL COMMENT '菜单ID',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_sys_role_menu` (`role_id`, `menu_id`),
+ KEY `idx_sys_role_menu_menu` (`menu_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='角色菜单关联表';
+
+CREATE TABLE `sys_user_role` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID,平台用户固定为0',
+ `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
+ `role_id` BIGINT UNSIGNED NOT NULL COMMENT '角色ID',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_sys_user_role` (`user_id`, `role_id`),
+ KEY `idx_sys_user_role_tenant` (`tenant_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='用户角色关联表';
+
+CREATE TABLE `sys_dict_type` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `dict_type_code` VARCHAR(64) NOT NULL COMMENT '字典类型编码',
+ `dict_type_name` VARCHAR(128) NOT NULL COMMENT '字典类型名称',
+ `dict_status` VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED',
+ `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_sys_dict_type_code_deleted` (`dict_type_code`, `deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='字典类型表';
+
+CREATE TABLE `sys_dict_item` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `dict_type_id` BIGINT UNSIGNED NOT NULL COMMENT '字典类型ID',
+ `item_label` VARCHAR(128) NOT NULL COMMENT '字典标签',
+ `item_value` VARCHAR(128) NOT NULL COMMENT '字典值',
+ `item_sort` INT NOT NULL DEFAULT 0 COMMENT '排序',
+ `item_status` VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED',
+ `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_sys_dict_item_type_value_deleted` (`dict_type_id`, `item_value`, `deleted`),
+ KEY `idx_sys_dict_item_type_sort` (`dict_type_id`, `item_sort`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='字典项表';
+
+CREATE TABLE `sys_config` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `scope_type` VARCHAR(16) NOT NULL COMMENT '配置范围:PLATFORM/TENANT',
+ `tenant_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID,平台配置固定为0',
+ `config_key` VARCHAR(128) NOT NULL COMMENT '配置键',
+ `config_value` TEXT COMMENT '配置值',
+ `value_type` VARCHAR(16) NOT NULL DEFAULT 'STRING' COMMENT '值类型:STRING/NUMBER/BOOLEAN/JSON',
+ `remark` VARCHAR(500) DEFAULT NULL COMMENT '备注',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_sys_config_scope_key_deleted` (`scope_type`, `tenant_id`, `config_key`, `deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='系统参数配置表';
+
+CREATE TABLE `org_department` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `parent_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '父级组织ID',
+ `dept_code` VARCHAR(64) NOT NULL COMMENT '组织编码',
+ `dept_name` VARCHAR(128) NOT NULL COMMENT '组织名称',
+ `dept_type` VARCHAR(32) NOT NULL COMMENT '组织类型:HEADQUARTERS/BRANCH/DEPARTMENT/GROUP',
+ `leader_user_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '负责人用户ID',
+ `contact_phone` VARCHAR(32) DEFAULT NULL COMMENT '联系电话',
+ `address` VARCHAR(255) DEFAULT NULL COMMENT '组织地址',
+ `display_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
+ `dept_status` VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_org_department_code_deleted` (`tenant_id`, `dept_code`, `deleted`),
+ KEY `idx_org_department_parent` (`tenant_id`, `parent_id`),
+ KEY `idx_org_department_type_status` (`tenant_id`, `dept_type`, `dept_status`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='组织架构表';
+
+CREATE TABLE `org_firm_profile` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `firm_name` VARCHAR(128) NOT NULL COMMENT '事务所名称',
+ `firm_short_name` VARCHAR(64) DEFAULT NULL COMMENT '事务所简称',
+ `english_name` VARCHAR(128) DEFAULT NULL COMMENT '英文名称',
+ `logo_asset_id` BIGINT UNSIGNED DEFAULT NULL COMMENT 'Logo素材ID',
+ `hero_asset_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '封面图素材ID',
+ `intro` TEXT COMMENT '事务所简介',
+ `hotline_phone` VARCHAR(32) DEFAULT NULL COMMENT '咨询电话',
+ `website_url` VARCHAR(255) DEFAULT NULL COMMENT '官网地址',
+ `wechat_official_account` VARCHAR(128) DEFAULT NULL COMMENT '公众号名称',
+ `hq_address` VARCHAR(255) DEFAULT NULL COMMENT '总部地址',
+ `hq_latitude` DECIMAL(10, 7) DEFAULT NULL COMMENT '总部纬度',
+ `hq_longitude` DECIMAL(10, 7) DEFAULT NULL COMMENT '总部经度',
+ `display_status` VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '展示状态:ENABLED/DISABLED',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_org_firm_profile_tenant_deleted` (`tenant_id`, `deleted`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='事务所主页信息表';
+
+CREATE TABLE `org_firm_practice_area` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `area_code` VARCHAR(64) NOT NULL COMMENT '专业领域编码',
+ `area_name` VARCHAR(128) NOT NULL COMMENT '专业领域名称',
+ `display_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
+ `area_status` VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_org_practice_area_code_deleted` (`tenant_id`, `area_code`, `deleted`),
+ KEY `idx_org_practice_area_status_sort` (`tenant_id`, `area_status`, `display_order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='事务所专业领域表';
+
+CREATE TABLE `card_profile` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `user_id` BIGINT UNSIGNED NOT NULL COMMENT '用户ID',
+ `dept_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '组织ID',
+ `card_name` VARCHAR(64) NOT NULL COMMENT '名片展示姓名',
+ `card_title` VARCHAR(128) DEFAULT NULL COMMENT '职务/头衔',
+ `mobile` VARCHAR(32) DEFAULT NULL COMMENT '手机号',
+ `telephone` VARCHAR(32) DEFAULT NULL COMMENT '座机',
+ `email` VARCHAR(128) DEFAULT NULL COMMENT '邮箱',
+ `office_address` VARCHAR(255) DEFAULT NULL COMMENT '办公地址',
+ `avatar_asset_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '头像素材ID',
+ `cover_asset_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '封面素材ID',
+ `wechat_qr_asset_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '微信二维码素材ID',
+ `bio` TEXT COMMENT '个人简介',
+ `certificate_no` VARCHAR(128) DEFAULT NULL COMMENT '执业证号',
+ `education_info` TEXT COMMENT '教育经历',
+ `honor_info` TEXT COMMENT '荣誉信息',
+ `is_public` TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否公开',
+ `is_recommended` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否推荐',
+ `publish_status` VARCHAR(16) NOT NULL DEFAULT 'DRAFT' COMMENT '发布状态:DRAFT/PUBLISHED/OFFLINE',
+ `display_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
+ `view_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计浏览次数',
+ `share_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '累计分享次数',
+ `last_published_at` DATETIME(3) DEFAULT NULL COMMENT '最近发布时间',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_card_profile_tenant_user_deleted` (`tenant_id`, `user_id`, `deleted`),
+ KEY `idx_card_profile_publish` (`tenant_id`, `publish_status`, `is_public`),
+ KEY `idx_card_profile_dept` (`tenant_id`, `dept_id`),
+ KEY `idx_card_profile_sort` (`tenant_id`, `display_order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='电子名片主表';
+
+CREATE TABLE `card_profile_specialty` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `card_id` BIGINT UNSIGNED NOT NULL COMMENT '名片ID',
+ `practice_area_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '专业领域ID',
+ `specialty_name` VARCHAR(128) NOT NULL COMMENT '专业领域名称快照',
+ `display_order` INT NOT NULL DEFAULT 0 COMMENT '排序',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_card_specialty_name_deleted` (`card_id`, `specialty_name`, `deleted`),
+ KEY `idx_card_specialty_practice_area` (`tenant_id`, `practice_area_id`),
+ KEY `idx_card_specialty_card_sort` (`tenant_id`, `card_id`, `display_order`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='名片专业领域关联表';
+
+CREATE TABLE `file_asset` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `upload_user_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '上传人ID',
+ `storage_provider` VARCHAR(32) NOT NULL DEFAULT 'MINIO' COMMENT '存储提供方',
+ `bucket_name` VARCHAR(128) NOT NULL COMMENT '桶名称',
+ `object_key` VARCHAR(255) NOT NULL COMMENT '对象存储Key',
+ `original_name` VARCHAR(255) NOT NULL COMMENT '原始文件名',
+ `file_ext` VARCHAR(32) DEFAULT NULL COMMENT '扩展名',
+ `mime_type` VARCHAR(128) DEFAULT NULL COMMENT 'MIME类型',
+ `file_size` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '文件大小',
+ `file_hash` VARCHAR(128) DEFAULT NULL COMMENT '文件哈希',
+ `access_url` VARCHAR(500) DEFAULT NULL COMMENT '访问地址',
+ `asset_status` VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:UPLOADED/ACTIVE/ARCHIVED/DELETED',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_file_asset_object_key_deleted` (`object_key`, `deleted`),
+ KEY `idx_file_asset_tenant_status` (`tenant_id`, `asset_status`),
+ KEY `idx_file_asset_hash` (`tenant_id`, `file_hash`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件素材表';
+
+CREATE TABLE `file_asset_usage` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `asset_id` BIGINT UNSIGNED NOT NULL COMMENT '素材ID',
+ `biz_type` VARCHAR(64) NOT NULL COMMENT '业务类型,如FIRM_PROFILE/CARD_PROFILE/USER_AVATAR',
+ `biz_id` BIGINT UNSIGNED NOT NULL COMMENT '业务主键ID',
+ `field_name` VARCHAR(64) NOT NULL COMMENT '业务字段名',
+ `created_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '创建人',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_by` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '更新人',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ `deleted` TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_file_asset_usage_deleted` (`asset_id`, `biz_type`, `biz_id`, `field_name`, `deleted`),
+ KEY `idx_file_asset_usage_biz` (`tenant_id`, `biz_type`, `biz_id`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='文件素材引用表';
+
+CREATE TABLE `card_view_log` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `miniapp_app_id` VARCHAR(64) NOT NULL COMMENT '小程序AppID',
+ `card_id` BIGINT UNSIGNED NOT NULL COMMENT '名片ID',
+ `viewer_open_id` VARCHAR(128) DEFAULT NULL COMMENT '访客OpenID',
+ `viewer_ip` VARCHAR(64) DEFAULT NULL COMMENT '访客IP',
+ `source_type` VARCHAR(32) DEFAULT NULL COMMENT '来源:DIRECT/SHARE/QRCODE/HISTORY',
+ `share_from_card_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '分享来源名片ID',
+ `page_path` VARCHAR(255) DEFAULT NULL COMMENT '页面路径',
+ `viewed_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '浏览时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_card_view_log_tenant_card_time` (`tenant_id`, `card_id`, `viewed_at`),
+ KEY `idx_card_view_log_appid_time` (`miniapp_app_id`, `viewed_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='名片浏览日志';
+
+CREATE TABLE `card_share_log` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `miniapp_app_id` VARCHAR(64) NOT NULL COMMENT '小程序AppID',
+ `card_id` BIGINT UNSIGNED NOT NULL COMMENT '名片ID',
+ `share_channel` VARCHAR(32) DEFAULT NULL COMMENT '分享渠道:WECHAT_FRIEND/WECHAT_GROUP/POSTER/QRCODE',
+ `share_path` VARCHAR(255) DEFAULT NULL COMMENT '分享路径',
+ `share_by_open_id` VARCHAR(128) DEFAULT NULL COMMENT '分享人OpenID',
+ `shared_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '分享时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_card_share_log_tenant_card_time` (`tenant_id`, `card_id`, `shared_at`),
+ KEY `idx_card_share_log_appid_time` (`miniapp_app_id`, `shared_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='名片分享日志';
+
+CREATE TABLE `card_stat_daily` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL COMMENT '租户ID',
+ `card_id` BIGINT UNSIGNED NOT NULL COMMENT '名片ID',
+ `stat_date` DATE NOT NULL COMMENT '统计日期',
+ `view_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '浏览次数',
+ `share_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '分享次数',
+ `unique_visitor_count` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '独立访客数',
+ `created_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
+ `updated_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
+ PRIMARY KEY (`id`),
+ UNIQUE KEY `uk_card_stat_daily` (`tenant_id`, `card_id`, `stat_date`),
+ KEY `idx_card_stat_daily_date` (`tenant_id`, `stat_date`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='名片按日统计表';
+
+CREATE TABLE `sys_login_log` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID,平台登录为0',
+ `user_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '用户ID',
+ `user_type` VARCHAR(16) NOT NULL COMMENT '用户类型:PLATFORM/TENANT',
+ `login_type` VARCHAR(32) NOT NULL COMMENT '登录类型:PASSWORD/SMS/MINIAPP_BIND',
+ `login_status` VARCHAR(16) NOT NULL COMMENT '结果:SUCCESS/FAIL',
+ `client_ip` VARCHAR(64) DEFAULT NULL COMMENT '客户端IP',
+ `user_agent` VARCHAR(500) DEFAULT NULL COMMENT '客户端UA',
+ `fail_reason` VARCHAR(500) DEFAULT NULL COMMENT '失败原因',
+ `login_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登录时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_sys_login_log_tenant_user_time` (`tenant_id`, `user_id`, `login_at`),
+ KEY `idx_sys_login_log_status_time` (`login_status`, `login_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='登录日志表';
+
+CREATE TABLE `sys_operation_log` (
+ `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键',
+ `tenant_id` BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT '租户ID,平台操作为0',
+ `user_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '操作人ID',
+ `user_name` VARCHAR(64) DEFAULT NULL COMMENT '操作人名称',
+ `module_name` VARCHAR(64) NOT NULL COMMENT '模块名称',
+ `biz_type` VARCHAR(64) DEFAULT NULL COMMENT '业务类型',
+ `biz_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '业务ID',
+ `operation_type` VARCHAR(32) NOT NULL COMMENT '操作类型:CREATE/UPDATE/DELETE/PUBLISH/LOGIN/EXPORT等',
+ `request_method` VARCHAR(16) DEFAULT NULL COMMENT '请求方法',
+ `request_uri` VARCHAR(255) DEFAULT NULL COMMENT '请求地址',
+ `request_body` LONGTEXT COMMENT '请求参数',
+ `response_code` VARCHAR(32) DEFAULT NULL COMMENT '响应码',
+ `response_message` VARCHAR(500) DEFAULT NULL COMMENT '响应信息',
+ `client_ip` VARCHAR(64) DEFAULT NULL COMMENT '客户端IP',
+ `operated_at` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间',
+ PRIMARY KEY (`id`),
+ KEY `idx_sys_operation_log_tenant_time` (`tenant_id`, `operated_at`),
+ KEY `idx_sys_operation_log_user_time` (`user_id`, `operated_at`),
+ KEY `idx_sys_operation_log_module_time` (`module_name`, `operated_at`)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='操作日志表';
diff --git a/backend/easycard-boot/src/main/resources/db/migration/mysql/V2__seed_platform_base_data.sql b/backend/easycard-boot/src/main/resources/db/migration/mysql/V2__seed_platform_base_data.sql
new file mode 100644
index 0000000..99301da
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/db/migration/mysql/V2__seed_platform_base_data.sql
@@ -0,0 +1,104 @@
+INSERT INTO `sys_role`
+(`tenant_id`, `role_scope`, `role_code`, `role_name`, `data_scope`, `is_builtin`, `role_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT 0, 'PLATFORM', 'PLATFORM_SUPER_ADMIN', '超级管理员', 'ALL', 1, 'ENABLED', '平台内置超级管理员角色', 0, 0, 0
+WHERE NOT EXISTS (
+ SELECT 1 FROM `sys_role` WHERE `tenant_id` = 0 AND `role_code` = 'PLATFORM_SUPER_ADMIN' AND `deleted` = 0
+);
+
+INSERT INTO `sys_menu`
+(`parent_id`, `menu_scope`, `menu_type`, `menu_name`, `route_path`, `component_path`, `permission_code`, `icon`, `display_order`, `is_visible`, `is_keep_alive`, `menu_status`, `created_by`, `updated_by`, `deleted`)
+SELECT 0, 'PLATFORM', 'DIRECTORY', '平台管理', '/platform', NULL, 'platform:root', 'Setting', 10, 1, 0, 'ENABLED', 0, 0, 0
+WHERE NOT EXISTS (
+ SELECT 1 FROM `sys_menu` WHERE `permission_code` = 'platform:root' AND `deleted` = 0
+);
+
+INSERT INTO `sys_menu`
+(`parent_id`, `menu_scope`, `menu_type`, `menu_name`, `route_path`, `component_path`, `permission_code`, `icon`, `display_order`, `is_visible`, `is_keep_alive`, `menu_status`, `created_by`, `updated_by`, `deleted`)
+SELECT 0, 'TENANT', 'DIRECTORY', '租户管理台', '/tenant', NULL, 'tenant:root', 'OfficeBuilding', 20, 1, 0, 'ENABLED', 0, 0, 0
+WHERE NOT EXISTS (
+ SELECT 1 FROM `sys_menu` WHERE `permission_code` = 'tenant:root' AND `deleted` = 0
+);
+
+INSERT INTO `sys_dict_type`
+(`dict_type_code`, `dict_type_name`, `dict_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT 'tenant_status', '租户状态', 'ENABLED', '租户状态字典', 0, 0, 0
+WHERE NOT EXISTS (
+ SELECT 1 FROM `sys_dict_type` WHERE `dict_type_code` = 'tenant_status' AND `deleted` = 0
+);
+
+INSERT INTO `sys_dict_type`
+(`dict_type_code`, `dict_type_name`, `dict_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT 'card_publish_status', '名片发布状态', 'ENABLED', '名片发布状态字典', 0, 0, 0
+WHERE NOT EXISTS (
+ SELECT 1 FROM `sys_dict_type` WHERE `dict_type_code` = 'card_publish_status' AND `deleted` = 0
+);
+
+INSERT INTO `sys_dict_item`
+(`dict_type_id`, `item_label`, `item_value`, `item_sort`, `item_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT dt.id, '启用', 'ENABLED', 10, 'ENABLED', '租户启用状态', 0, 0, 0
+FROM `sys_dict_type` dt
+WHERE dt.`dict_type_code` = 'tenant_status'
+ AND dt.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_dict_item` di
+ WHERE di.`dict_type_id` = dt.`id` AND di.`item_value` = 'ENABLED' AND di.`deleted` = 0
+ );
+
+INSERT INTO `sys_dict_item`
+(`dict_type_id`, `item_label`, `item_value`, `item_sort`, `item_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT dt.id, '停用', 'DISABLED', 20, 'ENABLED', '租户停用状态', 0, 0, 0
+FROM `sys_dict_type` dt
+WHERE dt.`dict_type_code` = 'tenant_status'
+ AND dt.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_dict_item` di
+ WHERE di.`dict_type_id` = dt.`id` AND di.`item_value` = 'DISABLED' AND di.`deleted` = 0
+ );
+
+INSERT INTO `sys_dict_item`
+(`dict_type_id`, `item_label`, `item_value`, `item_sort`, `item_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT dt.id, '已过期', 'EXPIRED', 30, 'ENABLED', '租户过期状态', 0, 0, 0
+FROM `sys_dict_type` dt
+WHERE dt.`dict_type_code` = 'tenant_status'
+ AND dt.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_dict_item` di
+ WHERE di.`dict_type_id` = dt.`id` AND di.`item_value` = 'EXPIRED' AND di.`deleted` = 0
+ );
+
+INSERT INTO `sys_dict_item`
+(`dict_type_id`, `item_label`, `item_value`, `item_sort`, `item_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT dt.id, '草稿', 'DRAFT', 10, 'ENABLED', '名片草稿状态', 0, 0, 0
+FROM `sys_dict_type` dt
+WHERE dt.`dict_type_code` = 'card_publish_status'
+ AND dt.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_dict_item` di
+ WHERE di.`dict_type_id` = dt.`id` AND di.`item_value` = 'DRAFT' AND di.`deleted` = 0
+ );
+
+INSERT INTO `sys_dict_item`
+(`dict_type_id`, `item_label`, `item_value`, `item_sort`, `item_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT dt.id, '已发布', 'PUBLISHED', 20, 'ENABLED', '名片发布状态', 0, 0, 0
+FROM `sys_dict_type` dt
+WHERE dt.`dict_type_code` = 'card_publish_status'
+ AND dt.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_dict_item` di
+ WHERE di.`dict_type_id` = dt.`id` AND di.`item_value` = 'PUBLISHED' AND di.`deleted` = 0
+ );
+
+INSERT INTO `sys_dict_item`
+(`dict_type_id`, `item_label`, `item_value`, `item_sort`, `item_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT dt.id, '已下架', 'OFFLINE', 30, 'ENABLED', '名片下架状态', 0, 0, 0
+FROM `sys_dict_type` dt
+WHERE dt.`dict_type_code` = 'card_publish_status'
+ AND dt.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_dict_item` di
+ WHERE di.`dict_type_id` = dt.`id` AND di.`item_value` = 'OFFLINE' AND di.`deleted` = 0
+ );
+
+-- 租户内置角色建议在创建租户时自动初始化:
+-- 1. TENANT_ADMIN:租户管理员,数据范围 TENANT
+-- 2. TENANT_USER:普通用户,数据范围 SELF
diff --git a/backend/easycard-boot/src/main/resources/db/migration/mysql/V3__seed_demo_mvp_data.sql b/backend/easycard-boot/src/main/resources/db/migration/mysql/V3__seed_demo_mvp_data.sql
new file mode 100644
index 0000000..7b27a3a
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/db/migration/mysql/V3__seed_demo_mvp_data.sql
@@ -0,0 +1,216 @@
+INSERT INTO `sys_user`
+(`tenant_id`, `user_type`, `username`, `password_hash`, `real_name`, `nick_name`, `gender`, `mobile`, `email`, `job_title`, `user_status`, `must_update_password`, `created_by`, `updated_by`, `deleted`)
+SELECT 0, 'PLATFORM', 'admin', '$2a$10$h21lXc21EZ7U8PWklTLdFeXKNI23.e8R3KyERgnF4lDsu2duzoBsG', '平台管理员', '平台管理员', 'UNKNOWN', '13800000000', 'platform@easycard.local', '平台超级管理员', 'ENABLED', 0, 0, 0, 0
+WHERE NOT EXISTS (
+ SELECT 1 FROM `sys_user` WHERE `tenant_id` = 0 AND `username` = 'admin' AND `deleted` = 0
+);
+
+INSERT INTO `sys_user_role`
+(`tenant_id`, `user_id`, `role_id`, `created_by`)
+SELECT 0, u.id, r.id, 0
+FROM `sys_user` u
+JOIN `sys_role` r ON r.`tenant_id` = 0 AND r.`role_code` = 'PLATFORM_SUPER_ADMIN' AND r.`deleted` = 0
+WHERE u.`tenant_id` = 0 AND u.`username` = 'admin' AND u.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_user_role` ur WHERE ur.`user_id` = u.`id` AND ur.`role_id` = r.`id`
+ );
+
+INSERT INTO `sys_tenant`
+(`tenant_code`, `tenant_name`, `tenant_short_name`, `contact_name`, `contact_phone`, `contact_email`, `tenant_status`, `expire_at`, `user_limit`, `storage_limit_mb`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT 'hengzhi-law', '衡知律师事务所', '衡知律所', '李主任', '13800138000', 'admin@hengzhi-law.com', 'ENABLED', '2027-03-31 23:59:59.000', 20, 10240, '演示租户', 1, 1, 0
+WHERE NOT EXISTS (
+ SELECT 1 FROM `sys_tenant` WHERE `tenant_code` = 'hengzhi-law' AND `deleted` = 0
+);
+
+INSERT INTO `tenant_miniapp_config`
+(`tenant_id`, `env_code`, `miniapp_app_id`, `miniapp_app_secret`, `miniapp_name`, `miniapp_original_id`, `request_domain`, `upload_domain`, `download_domain`, `version_tag`, `publish_status`, `last_published_at`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 'PROD', 'wx8b1c6d1f0a1e0001', 'demo-app-secret', '衡知律师事务所电子名片', 'gh_hengzhi_demo', 'http://127.0.0.1:8112', 'http://127.0.0.1:8112', 'http://127.0.0.1:8112', 'v1.0.0', 'PUBLISHED', CURRENT_TIMESTAMP(3), '演示小程序配置', 1, 1, 0
+FROM `sys_tenant` t
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `tenant_miniapp_config` c WHERE c.`tenant_id` = t.`id` AND c.`env_code` = 'PROD' AND c.`deleted` = 0
+ );
+
+INSERT INTO `sys_role`
+(`tenant_id`, `role_scope`, `role_code`, `role_name`, `data_scope`, `is_builtin`, `role_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 'TENANT', 'TENANT_ADMIN', '租户管理员', 'TENANT', 1, 'ENABLED', '租户内置管理员角色', 1, 1, 0
+FROM `sys_tenant` t
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_role` r WHERE r.`tenant_id` = t.`id` AND r.`role_code` = 'TENANT_ADMIN' AND r.`deleted` = 0
+ );
+
+INSERT INTO `sys_role`
+(`tenant_id`, `role_scope`, `role_code`, `role_name`, `data_scope`, `is_builtin`, `role_status`, `remark`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 'TENANT', 'TENANT_USER', '普通用户', 'SELF', 1, 'ENABLED', '租户内置普通用户角色', 1, 1, 0
+FROM `sys_tenant` t
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_role` r WHERE r.`tenant_id` = t.`id` AND r.`role_code` = 'TENANT_USER' AND r.`deleted` = 0
+ );
+
+INSERT INTO `org_department`
+(`tenant_id`, `parent_id`, `dept_code`, `dept_name`, `dept_type`, `contact_phone`, `address`, `display_order`, `dept_status`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 0, 'nj-headquarters', '南京总部', 'HEADQUARTERS', '025-88886666', '南京市江宁区紫金研创中心', 10, 'ENABLED', 1, 1, 0
+FROM `sys_tenant` t
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `org_department` d WHERE d.`tenant_id` = t.`id` AND d.`dept_code` = 'nj-headquarters' AND d.`deleted` = 0
+ );
+
+INSERT INTO `org_department`
+(`tenant_id`, `parent_id`, `dept_code`, `dept_name`, `dept_type`, `contact_phone`, `address`, `display_order`, `dept_status`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 0, 'sh-branch', '上海分所', 'BRANCH', '021-66668888', '上海市浦东新区陆家嘴', 20, 'ENABLED', 1, 1, 0
+FROM `sys_tenant` t
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `org_department` d WHERE d.`tenant_id` = t.`id` AND d.`dept_code` = 'sh-branch' AND d.`deleted` = 0
+ );
+
+INSERT INTO `org_firm_profile`
+(`tenant_id`, `firm_name`, `firm_short_name`, `english_name`, `intro`, `hotline_phone`, `website_url`, `wechat_official_account`, `hq_address`, `hq_latitude`, `hq_longitude`, `display_status`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, '衡知律师事务所', '衡知律所', 'Hengzhi Law Firm', '衡知律师事务所是一家聚焦企业合规、民商事争议解决与知识产权服务的综合性律师事务所。', '13800138000', 'https://hengzhi-law.example.com', '衡知律师事务所', '南京市江宁区紫金研创中心', 31.9320000, 118.8250000, 'ENABLED', 1, 1, 0
+FROM `sys_tenant` t
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `org_firm_profile` p WHERE p.`tenant_id` = t.`id` AND p.`deleted` = 0
+ );
+
+INSERT INTO `org_firm_practice_area`
+(`tenant_id`, `area_code`, `area_name`, `display_order`, `area_status`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 'corporate-compliance', '企业合规', 10, 'ENABLED', 1, 1, 0
+FROM `sys_tenant` t
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `org_firm_practice_area` a WHERE a.`tenant_id` = t.`id` AND a.`area_code` = 'corporate-compliance' AND a.`deleted` = 0
+ );
+
+INSERT INTO `org_firm_practice_area`
+(`tenant_id`, `area_code`, `area_name`, `display_order`, `area_status`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 'civil-commercial', '民商事争议', 20, 'ENABLED', 1, 1, 0
+FROM `sys_tenant` t
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `org_firm_practice_area` a WHERE a.`tenant_id` = t.`id` AND a.`area_code` = 'civil-commercial' AND a.`deleted` = 0
+ );
+
+INSERT INTO `org_firm_practice_area`
+(`tenant_id`, `area_code`, `area_name`, `display_order`, `area_status`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 'intellectual-property', '知识产权', 30, 'ENABLED', 1, 1, 0
+FROM `sys_tenant` t
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `org_firm_practice_area` a WHERE a.`tenant_id` = t.`id` AND a.`area_code` = 'intellectual-property' AND a.`deleted` = 0
+ );
+
+INSERT INTO `sys_user`
+(`tenant_id`, `user_type`, `username`, `password_hash`, `real_name`, `nick_name`, `gender`, `mobile`, `email`, `dept_id`, `job_title`, `user_status`, `must_update_password`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 'TENANT', 'tenant.admin', '$2a$10$jlXVQhz5FRZwl8VETGJbP.0ZwsaeRhR0tW/HGvLGinR1YhYSl7Fo2', '王律师', '王律师', 'UNKNOWN', '13800138001', 'tenant.admin@hengzhi-law.com', d.id, '租户管理员', 'ENABLED', 0, 1, 1, 0
+FROM `sys_tenant` t
+JOIN `org_department` d ON d.`tenant_id` = t.`id` AND d.`dept_code` = 'nj-headquarters' AND d.`deleted` = 0
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_user` u WHERE u.`tenant_id` = t.`id` AND u.`username` = 'tenant.admin' AND u.`deleted` = 0
+ );
+
+INSERT INTO `sys_user`
+(`tenant_id`, `user_type`, `username`, `password_hash`, `real_name`, `nick_name`, `gender`, `mobile`, `email`, `dept_id`, `job_title`, `user_status`, `must_update_password`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 'TENANT', 'li.qin', '$2a$10$jlXVQhz5FRZwl8VETGJbP.0ZwsaeRhR0tW/HGvLGinR1YhYSl7Fo2', '李勤', '李勤', 'UNKNOWN', '13800138002', 'li.qin@hengzhi-law.com', d.id, '高级合伙人', 'ENABLED', 0, 1, 1, 0
+FROM `sys_tenant` t
+JOIN `org_department` d ON d.`tenant_id` = t.`id` AND d.`dept_code` = 'nj-headquarters' AND d.`deleted` = 0
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_user` u WHERE u.`tenant_id` = t.`id` AND u.`username` = 'li.qin' AND u.`deleted` = 0
+ );
+
+INSERT INTO `sys_user`
+(`tenant_id`, `user_type`, `username`, `password_hash`, `real_name`, `nick_name`, `gender`, `mobile`, `email`, `dept_id`, `job_title`, `user_status`, `must_update_password`, `created_by`, `updated_by`, `deleted`)
+SELECT t.id, 'TENANT', 'zhou.lin', '$2a$10$jlXVQhz5FRZwl8VETGJbP.0ZwsaeRhR0tW/HGvLGinR1YhYSl7Fo2', '周林', '周林', 'UNKNOWN', '13800138003', 'zhou.lin@hengzhi-law.com', d.id, '律师', 'ENABLED', 0, 1, 1, 0
+FROM `sys_tenant` t
+JOIN `org_department` d ON d.`tenant_id` = t.`id` AND d.`dept_code` = 'sh-branch' AND d.`deleted` = 0
+WHERE t.`tenant_code` = 'hengzhi-law' AND t.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_user` u WHERE u.`tenant_id` = t.`id` AND u.`username` = 'zhou.lin' AND u.`deleted` = 0
+ );
+
+INSERT INTO `sys_user_role`
+(`tenant_id`, `user_id`, `role_id`, `created_by`)
+SELECT u.`tenant_id`, u.`id`, r.`id`, 1
+FROM `sys_user` u
+JOIN `sys_role` r ON r.`tenant_id` = u.`tenant_id` AND r.`role_code` = 'TENANT_ADMIN' AND r.`deleted` = 0
+WHERE u.`username` = 'tenant.admin' AND u.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_user_role` ur WHERE ur.`user_id` = u.`id` AND ur.`role_id` = r.`id`
+ );
+
+INSERT INTO `sys_user_role`
+(`tenant_id`, `user_id`, `role_id`, `created_by`)
+SELECT u.`tenant_id`, u.`id`, r.`id`, 1
+FROM `sys_user` u
+JOIN `sys_role` r ON r.`tenant_id` = u.`tenant_id` AND r.`role_code` = 'TENANT_USER' AND r.`deleted` = 0
+WHERE u.`username` IN ('li.qin', 'zhou.lin') AND u.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `sys_user_role` ur WHERE ur.`user_id` = u.`id` AND ur.`role_id` = r.`id`
+ );
+
+INSERT INTO `card_profile`
+(`tenant_id`, `user_id`, `dept_id`, `card_name`, `card_title`, `mobile`, `email`, `office_address`, `bio`, `certificate_no`, `education_info`, `honor_info`, `is_public`, `is_recommended`, `publish_status`, `display_order`, `view_count`, `share_count`, `last_published_at`, `created_by`, `updated_by`, `deleted`)
+SELECT u.`tenant_id`, u.`id`, u.`dept_id`, '李勤', '高级合伙人', u.`mobile`, u.`email`, '南京市江宁区紫金研创中心', '长期服务于企业客户,擅长企业合规、公司治理与争议解决。', 'A123456789', '中国政法大学 法学硕士', '南京市优秀律师', 1, 1, 'PUBLISHED', 10, 18, 6, CURRENT_TIMESTAMP(3), 1, 1, 0
+FROM `sys_user` u
+WHERE u.`username` = 'li.qin' AND u.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `card_profile` c WHERE c.`tenant_id` = u.`tenant_id` AND c.`user_id` = u.`id` AND c.`deleted` = 0
+ );
+
+INSERT INTO `card_profile`
+(`tenant_id`, `user_id`, `dept_id`, `card_name`, `card_title`, `mobile`, `email`, `office_address`, `bio`, `certificate_no`, `education_info`, `honor_info`, `is_public`, `is_recommended`, `publish_status`, `display_order`, `view_count`, `share_count`, `last_published_at`, `created_by`, `updated_by`, `deleted`)
+SELECT u.`tenant_id`, u.`id`, u.`dept_id`, '周林', '律师', u.`mobile`, u.`email`, '上海市浦东新区陆家嘴', '专注民商事争议与知识产权合规,常年服务成长型企业。', 'B987654321', '华东政法大学 法学学士', '青年律师业务能手', 1, 0, 'PUBLISHED', 20, 9, 2, CURRENT_TIMESTAMP(3), 1, 1, 0
+FROM `sys_user` u
+WHERE u.`username` = 'zhou.lin' AND u.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `card_profile` c WHERE c.`tenant_id` = u.`tenant_id` AND c.`user_id` = u.`id` AND c.`deleted` = 0
+ );
+
+INSERT INTO `card_profile_specialty`
+(`tenant_id`, `card_id`, `practice_area_id`, `specialty_name`, `display_order`, `created_by`, `updated_by`, `deleted`)
+SELECT c.`tenant_id`, c.`id`, a.`id`, a.`area_name`, 10, 1, 1, 0
+FROM `card_profile` c
+JOIN `sys_user` u ON u.`id` = c.`user_id` AND u.`deleted` = 0
+JOIN `org_firm_practice_area` a ON a.`tenant_id` = c.`tenant_id` AND a.`area_code` = 'corporate-compliance' AND a.`deleted` = 0
+WHERE u.`username` = 'li.qin' AND c.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `card_profile_specialty` s WHERE s.`card_id` = c.`id` AND s.`specialty_name` = a.`area_name` AND s.`deleted` = 0
+ );
+
+INSERT INTO `card_profile_specialty`
+(`tenant_id`, `card_id`, `practice_area_id`, `specialty_name`, `display_order`, `created_by`, `updated_by`, `deleted`)
+SELECT c.`tenant_id`, c.`id`, a.`id`, a.`area_name`, 20, 1, 1, 0
+FROM `card_profile` c
+JOIN `sys_user` u ON u.`id` = c.`user_id` AND u.`deleted` = 0
+JOIN `org_firm_practice_area` a ON a.`tenant_id` = c.`tenant_id` AND a.`area_code` = 'civil-commercial' AND a.`deleted` = 0
+WHERE u.`username` = 'li.qin' AND c.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `card_profile_specialty` s WHERE s.`card_id` = c.`id` AND s.`specialty_name` = a.`area_name` AND s.`deleted` = 0
+ );
+
+INSERT INTO `card_profile_specialty`
+(`tenant_id`, `card_id`, `practice_area_id`, `specialty_name`, `display_order`, `created_by`, `updated_by`, `deleted`)
+SELECT c.`tenant_id`, c.`id`, a.`id`, a.`area_name`, 10, 1, 1, 0
+FROM `card_profile` c
+JOIN `sys_user` u ON u.`id` = c.`user_id` AND u.`deleted` = 0
+JOIN `org_firm_practice_area` a ON a.`tenant_id` = c.`tenant_id` AND a.`area_code` = 'civil-commercial' AND a.`deleted` = 0
+WHERE u.`username` = 'zhou.lin' AND c.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `card_profile_specialty` s WHERE s.`card_id` = c.`id` AND s.`specialty_name` = a.`area_name` AND s.`deleted` = 0
+ );
+
+INSERT INTO `card_profile_specialty`
+(`tenant_id`, `card_id`, `practice_area_id`, `specialty_name`, `display_order`, `created_by`, `updated_by`, `deleted`)
+SELECT c.`tenant_id`, c.`id`, a.`id`, a.`area_name`, 20, 1, 1, 0
+FROM `card_profile` c
+JOIN `sys_user` u ON u.`id` = c.`user_id` AND u.`deleted` = 0
+JOIN `org_firm_practice_area` a ON a.`tenant_id` = c.`tenant_id` AND a.`area_code` = 'intellectual-property' AND a.`deleted` = 0
+WHERE u.`username` = 'zhou.lin' AND c.`deleted` = 0
+ AND NOT EXISTS (
+ SELECT 1 FROM `card_profile_specialty` s WHERE s.`card_id` = c.`id` AND s.`specialty_name` = a.`area_name` AND s.`deleted` = 0
+ );
diff --git a/backend/easycard-boot/src/main/resources/db/migration/mysql/V4__allow_null_miniapp_app_id.sql b/backend/easycard-boot/src/main/resources/db/migration/mysql/V4__allow_null_miniapp_app_id.sql
new file mode 100644
index 0000000..3cfabc1
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/db/migration/mysql/V4__allow_null_miniapp_app_id.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `tenant_miniapp_config`
+ MODIFY COLUMN `miniapp_app_id` VARCHAR(64) NULL COMMENT '小程序AppID';
diff --git a/backend/easycard-boot/src/main/resources/db/migration/mysql/V5__drop_org_firm_profile_display_status.sql b/backend/easycard-boot/src/main/resources/db/migration/mysql/V5__drop_org_firm_profile_display_status.sql
new file mode 100644
index 0000000..1bfe4d3
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/db/migration/mysql/V5__drop_org_firm_profile_display_status.sql
@@ -0,0 +1,2 @@
+ALTER TABLE `org_firm_profile`
+ DROP COLUMN `display_status`;
diff --git a/backend/easycard-boot/src/main/resources/db/migration/mysql/V6__add_member_sort_and_user_status_cleanup.sql b/backend/easycard-boot/src/main/resources/db/migration/mysql/V6__add_member_sort_and_user_status_cleanup.sql
new file mode 100644
index 0000000..50f3c86
--- /dev/null
+++ b/backend/easycard-boot/src/main/resources/db/migration/mysql/V6__add_member_sort_and_user_status_cleanup.sql
@@ -0,0 +1,45 @@
+ALTER TABLE `sys_user`
+ ADD COLUMN `member_sort` INT NOT NULL DEFAULT 0 COMMENT '成员展示排序值' AFTER `user_status`,
+ ADD KEY `idx_sys_user_tenant_member_sort` (`tenant_id`, `member_sort`);
+
+ALTER TABLE `sys_user`
+ MODIFY COLUMN `user_status` VARCHAR(16) NOT NULL DEFAULT 'ENABLED' COMMENT '状态:ENABLED/DISABLED';
+
+UPDATE `sys_user`
+SET `user_status` = 'DISABLED'
+WHERE `deleted` = 0
+ AND `user_status` = 'LOCKED';
+
+UPDATE `sys_user` u
+LEFT JOIN `card_profile` c
+ ON c.`tenant_id` = u.`tenant_id`
+ AND c.`user_id` = u.`id`
+ AND c.`deleted` = 0
+SET u.`member_sort` = COALESCE(c.`display_order`, 0)
+WHERE u.`deleted` = 0;
+
+WITH tenant_sort_base AS (
+ SELECT
+ u.`tenant_id`,
+ COALESCE(MAX(u.`member_sort`), 0) AS base_sort
+ FROM `sys_user` u
+ WHERE u.`deleted` = 0
+ GROUP BY u.`tenant_id`
+),
+users_without_card AS (
+ SELECT
+ u.`id`,
+ u.`tenant_id`,
+ ROW_NUMBER() OVER (PARTITION BY u.`tenant_id` ORDER BY u.`id` DESC) AS rn
+ FROM `sys_user` u
+ LEFT JOIN `card_profile` c
+ ON c.`tenant_id` = u.`tenant_id`
+ AND c.`user_id` = u.`id`
+ AND c.`deleted` = 0
+ WHERE u.`deleted` = 0
+ AND c.`id` IS NULL
+)
+UPDATE `sys_user` u
+JOIN users_without_card w ON w.`id` = u.`id`
+JOIN tenant_sort_base b ON b.`tenant_id` = w.`tenant_id`
+SET u.`member_sort` = b.base_sort + (w.rn * 10);
diff --git a/backend/easycard-boot/src/test/java/com/easycard/boot/EasycardBootApplicationTests.java b/backend/easycard-boot/src/test/java/com/easycard/boot/EasycardBootApplicationTests.java
new file mode 100644
index 0000000..a431d94
--- /dev/null
+++ b/backend/easycard-boot/src/test/java/com/easycard/boot/EasycardBootApplicationTests.java
@@ -0,0 +1,32 @@
+package com.easycard.boot;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.SpringBootConfiguration;
+import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
+import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
+import org.springframework.boot.autoconfigure.flyway.FlywayAutoConfiguration;
+import org.springframework.boot.autoconfigure.jdbc.DataSourceAutoConfiguration;
+import org.springframework.boot.test.context.SpringBootTest;
+
+import com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration;
+
+@SpringBootTest(
+ classes = EasycardBootApplicationTests.TestApplication.class,
+ webEnvironment = SpringBootTest.WebEnvironment.NONE
+)
+class EasycardBootApplicationTests {
+
+ @SpringBootConfiguration
+ @EnableAutoConfiguration(exclude = {
+ DataSourceAutoConfiguration.class,
+ RedisAutoConfiguration.class,
+ FlywayAutoConfiguration.class,
+ MybatisPlusAutoConfiguration.class
+ })
+ static class TestApplication {
+ }
+
+ @Test
+ void contextLoads() {
+ }
+}
diff --git a/backend/easycard-boot/src/test/java/com/easycard/boot/config/EasycardCorsPropertiesTests.java b/backend/easycard-boot/src/test/java/com/easycard/boot/config/EasycardCorsPropertiesTests.java
new file mode 100644
index 0000000..9b5459d
--- /dev/null
+++ b/backend/easycard-boot/src/test/java/com/easycard/boot/config/EasycardCorsPropertiesTests.java
@@ -0,0 +1,74 @@
+package com.easycard.boot.config;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
+import org.springframework.boot.test.context.runner.ApplicationContextRunner;
+import org.springframework.mock.web.MockHttpServletRequest;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.CorsConfigurationSource;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class EasycardCorsPropertiesTests {
+
+ private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
+ .withUserConfiguration(CorsPropertiesTestConfiguration.class)
+ .withPropertyValues(
+ "easycard.web.cors.allowed-origins=http://admin.example.com,http://114.66.22.180:3112",
+ "easycard.web.cors.allowed-origin-patterns=https://*.example.com",
+ "easycard.web.cors.allowed-methods=GET,POST,OPTIONS",
+ "easycard.web.cors.allowed-headers=Authorization,Content-Type",
+ "easycard.web.cors.exposed-headers=Authorization,X-Trace-Id",
+ "easycard.web.cors.allow-credentials=true",
+ "easycard.web.cors.max-age=600"
+ );
+
+ @Test
+ void shouldBindCorsPropertiesFromCommaSeparatedValues() {
+ contextRunner.run(context -> {
+ EasycardCorsProperties properties = context.getBean(EasycardCorsProperties.class);
+
+ assertThat(properties.getAllowedOrigins())
+ .containsExactly("http://admin.example.com", "http://114.66.22.180:3112");
+ assertThat(properties.getAllowedOriginPatterns())
+ .containsExactly("https://*.example.com");
+ assertThat(properties.getAllowedMethods())
+ .containsExactly("GET", "POST", "OPTIONS");
+ assertThat(properties.getAllowedHeaders())
+ .containsExactly("Authorization", "Content-Type");
+ assertThat(properties.getExposedHeaders())
+ .containsExactly("Authorization", "X-Trace-Id");
+ assertThat(properties.isAllowCredentials()).isTrue();
+ assertThat(properties.getMaxAge()).isEqualTo(600);
+ });
+ }
+
+ @Test
+ void shouldBuildCorsConfigurationFromProperties() {
+ contextRunner.run(context -> {
+ EasycardCorsProperties properties = context.getBean(EasycardCorsProperties.class);
+ CorsConfigurationSource source = new SecurityConfig().corsConfigurationSource(properties);
+ MockHttpServletRequest request = new MockHttpServletRequest("OPTIONS", "/api/v1/auth/login");
+
+ CorsConfiguration configuration = source.getCorsConfiguration(request);
+
+ assertThat(configuration).isNotNull();
+ assertThat(configuration.getAllowedOrigins())
+ .containsExactly("http://admin.example.com", "http://114.66.22.180:3112");
+ assertThat(configuration.getAllowedOriginPatterns())
+ .containsExactly("https://*.example.com");
+ assertThat(configuration.getAllowedMethods())
+ .containsExactly("GET", "POST", "OPTIONS");
+ assertThat(configuration.getAllowedHeaders())
+ .containsExactly("Authorization", "Content-Type");
+ assertThat(configuration.getExposedHeaders())
+ .containsExactly("Authorization", "X-Trace-Id");
+ assertThat(configuration.getAllowCredentials()).isTrue();
+ assertThat(configuration.getMaxAge()).isEqualTo(600);
+ });
+ }
+
+ @EnableConfigurationProperties(EasycardCorsProperties.class)
+ static class CorsPropertiesTestConfiguration {
+ }
+}
diff --git a/backend/easycard-common/pom.xml b/backend/easycard-common/pom.xml
new file mode 100644
index 0000000..18da8ae
--- /dev/null
+++ b/backend/easycard-common/pom.xml
@@ -0,0 +1,50 @@
+
+ 4.0.0
+
+
+ com.easycard
+ easycard-parent
+ 0.1.0-SNAPSHOT
+
+
+ easycard-common
+ easycard-common
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.security
+ spring-security-core
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-autoconfigure
+
+
+ io.jsonwebtoken
+ jjwt-api
+ 0.12.6
+
+
+ io.jsonwebtoken
+ jjwt-impl
+ 0.12.6
+ runtime
+
+
+ io.jsonwebtoken
+ jjwt-jackson
+ 0.12.6
+ runtime
+
+
+
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/api/ApiResponse.java b/backend/easycard-common/src/main/java/com/easycard/common/api/ApiResponse.java
new file mode 100644
index 0000000..39a25f9
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/api/ApiResponse.java
@@ -0,0 +1,16 @@
+package com.easycard.common.api;
+
+public record ApiResponse(String code, String message, T data) {
+
+ public static ApiResponse success(T data) {
+ return new ApiResponse<>("0", "success", data);
+ }
+
+ public static ApiResponse success(String message, T data) {
+ return new ApiResponse<>("0", message, data);
+ }
+
+ public static ApiResponse fail(String code, String message) {
+ return new ApiResponse<>(code, message, null);
+ }
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/auth/JwtTokenService.java b/backend/easycard-common/src/main/java/com/easycard/common/auth/JwtTokenService.java
new file mode 100644
index 0000000..75a2f89
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/auth/JwtTokenService.java
@@ -0,0 +1,80 @@
+package com.easycard.common.auth;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.security.Keys;
+import jakarta.annotation.PostConstruct;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+
+import javax.crypto.SecretKey;
+import java.nio.charset.StandardCharsets;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+@Component
+public class JwtTokenService {
+
+ private final String jwtSecret;
+ private SecretKey secretKey;
+
+ public JwtTokenService(@Value("${easycard.security.jwt-secret}") String jwtSecret) {
+ this.jwtSecret = jwtSecret;
+ }
+
+ @PostConstruct
+ public void init() {
+ String normalized = jwtSecret;
+ if (jwtSecret.length() < 32) {
+ normalized = String.format("%-32s", jwtSecret).replace(' ', '0');
+ }
+ byte[] secretBytes = normalized.getBytes(StandardCharsets.UTF_8);
+ this.secretKey = Keys.hmacShaKeyFor(secretBytes);
+ }
+
+ public String generateToken(LoginUser loginUser, Duration ttl) {
+ Instant now = Instant.now();
+ return Jwts.builder()
+ .subject(String.valueOf(loginUser.userId()))
+ .issuedAt(Date.from(now))
+ .expiration(Date.from(now.plus(ttl)))
+ .claims(Map.of(
+ "tenantId", loginUser.tenantId(),
+ "username", loginUser.username(),
+ "realName", loginUser.realName(),
+ "userType", loginUser.userType(),
+ "roles", loginUser.roleCodes()
+ ))
+ .signWith(secretKey)
+ .compact();
+ }
+
+ @SuppressWarnings("unchecked")
+ public Optional parseToken(String token) {
+ if (!StringUtils.hasText(token)) {
+ return Optional.empty();
+ }
+ try {
+ Claims claims = Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
+ Object rolesObject = claims.get("roles");
+ List roles = rolesObject instanceof List> list
+ ? list.stream().map(String::valueOf).toList()
+ : List.of();
+ return Optional.of(new LoginUser(
+ Long.valueOf(claims.getSubject()),
+ Long.valueOf(String.valueOf(claims.get("tenantId"))),
+ String.valueOf(claims.get("username")),
+ String.valueOf(claims.get("realName")),
+ String.valueOf(claims.get("userType")),
+ roles
+ ));
+ } catch (Exception exception) {
+ return Optional.empty();
+ }
+ }
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/auth/LoginUser.java b/backend/easycard-common/src/main/java/com/easycard/common/auth/LoginUser.java
new file mode 100644
index 0000000..997f709
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/auth/LoginUser.java
@@ -0,0 +1,14 @@
+package com.easycard.common.auth;
+
+import java.io.Serializable;
+import java.util.List;
+
+public record LoginUser(
+ Long userId,
+ Long tenantId,
+ String username,
+ String realName,
+ String userType,
+ List roleCodes
+) implements Serializable {
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/auth/SecurityUtils.java b/backend/easycard-common/src/main/java/com/easycard/common/auth/SecurityUtils.java
new file mode 100644
index 0000000..2aac8ad
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/auth/SecurityUtils.java
@@ -0,0 +1,25 @@
+package com.easycard.common.auth;
+
+import com.easycard.common.exception.BusinessException;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
+
+public final class SecurityUtils {
+
+ private SecurityUtils() {
+ }
+
+ public static LoginUser getRequiredLoginUser() {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ if (authentication == null || !(authentication.getPrincipal() instanceof LoginUser loginUser)) {
+ throw new BusinessException("UNAUTHORIZED", "登录状态已失效");
+ }
+ return loginUser;
+ }
+
+ public static boolean hasRole(String roleCode) {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ return authentication != null
+ && authentication.getAuthorities().stream().anyMatch(item -> roleCode.equals(item.getAuthority()));
+ }
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/config/JacksonConfig.java b/backend/easycard-common/src/main/java/com/easycard/common/config/JacksonConfig.java
new file mode 100644
index 0000000..d1c1f19
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/config/JacksonConfig.java
@@ -0,0 +1,19 @@
+package com.easycard.common.config;
+
+import com.fasterxml.jackson.databind.SerializationFeature;
+import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.TimeZone;
+
+@Configuration
+public class JacksonConfig {
+
+ @Bean
+ public Jackson2ObjectMapperBuilderCustomizer jackson2ObjectMapperBuilderCustomizer() {
+ return builder -> builder
+ .timeZone(TimeZone.getTimeZone("Asia/Shanghai"))
+ .featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
+ }
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/exception/BusinessException.java b/backend/easycard-common/src/main/java/com/easycard/common/exception/BusinessException.java
new file mode 100644
index 0000000..d527b03
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/exception/BusinessException.java
@@ -0,0 +1,15 @@
+package com.easycard.common.exception;
+
+public class BusinessException extends RuntimeException {
+
+ private final String code;
+
+ public BusinessException(String code, String message) {
+ super(message);
+ this.code = code;
+ }
+
+ public String getCode() {
+ return code;
+ }
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/storage/StorageUrlUtils.java b/backend/easycard-common/src/main/java/com/easycard/common/storage/StorageUrlUtils.java
new file mode 100644
index 0000000..ad57d25
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/storage/StorageUrlUtils.java
@@ -0,0 +1,48 @@
+package com.easycard.common.storage;
+
+public final class StorageUrlUtils {
+
+ private StorageUrlUtils() {
+ }
+
+ public static String buildPublicUrl(String publicEndpoint, String bucketName, String objectKey, String fallbackUrl) {
+ if (isBlank(bucketName) || isBlank(objectKey)) {
+ return fallbackUrl == null ? "" : fallbackUrl;
+ }
+ String endpoint = trimTrailingSlash(publicEndpoint);
+ if (endpoint.isEmpty()) {
+ return fallbackUrl == null ? "" : fallbackUrl;
+ }
+ return endpoint + "/" + trimSlashes(bucketName) + "/" + trimLeadingSlash(objectKey);
+ }
+
+ private static boolean isBlank(String value) {
+ return value == null || value.isBlank();
+ }
+
+ private static String trimTrailingSlash(String value) {
+ if (value == null) {
+ return "";
+ }
+ int end = value.length();
+ while (end > 0 && value.charAt(end - 1) == '/') {
+ end--;
+ }
+ return value.substring(0, end);
+ }
+
+ private static String trimLeadingSlash(String value) {
+ if (value == null) {
+ return "";
+ }
+ int start = 0;
+ while (start < value.length() && value.charAt(start) == '/') {
+ start++;
+ }
+ return value.substring(start);
+ }
+
+ private static String trimSlashes(String value) {
+ return trimTrailingSlash(trimLeadingSlash(value));
+ }
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/tenant/TenantContext.java b/backend/easycard-common/src/main/java/com/easycard/common/tenant/TenantContext.java
new file mode 100644
index 0000000..39ce04e
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/tenant/TenantContext.java
@@ -0,0 +1,4 @@
+package com.easycard.common.tenant;
+
+public record TenantContext(Long tenantId, String tenantCode, String miniappAppId) {
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/tenant/TenantContextHolder.java b/backend/easycard-common/src/main/java/com/easycard/common/tenant/TenantContextHolder.java
new file mode 100644
index 0000000..76c5dcc
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/tenant/TenantContextHolder.java
@@ -0,0 +1,31 @@
+package com.easycard.common.tenant;
+
+import java.util.Optional;
+
+public final class TenantContextHolder {
+
+ private static final ThreadLocal CONTEXT = new ThreadLocal<>();
+
+ private TenantContextHolder() {
+ }
+
+ public static void set(TenantContext tenantContext) {
+ CONTEXT.set(tenantContext);
+ }
+
+ public static Optional getOptional() {
+ return Optional.ofNullable(CONTEXT.get());
+ }
+
+ public static TenantContext getRequired() {
+ TenantContext tenantContext = CONTEXT.get();
+ if (tenantContext == null) {
+ throw new IllegalStateException("租户上下文不存在");
+ }
+ return tenantContext;
+ }
+
+ public static void clear() {
+ CONTEXT.remove();
+ }
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/web/ClientRequestUtils.java b/backend/easycard-common/src/main/java/com/easycard/common/web/ClientRequestUtils.java
new file mode 100644
index 0000000..8dcfc73
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/web/ClientRequestUtils.java
@@ -0,0 +1,22 @@
+package com.easycard.common.web;
+
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.util.StringUtils;
+
+public final class ClientRequestUtils {
+
+ private ClientRequestUtils() {
+ }
+
+ public static String getClientIp(HttpServletRequest request) {
+ String forwardedFor = request.getHeader("X-Forwarded-For");
+ if (StringUtils.hasText(forwardedFor)) {
+ return forwardedFor.split(",")[0].trim();
+ }
+ String realIp = request.getHeader("X-Real-IP");
+ if (StringUtils.hasText(realIp)) {
+ return realIp.trim();
+ }
+ return request.getRemoteAddr();
+ }
+}
diff --git a/backend/easycard-common/src/main/java/com/easycard/common/web/GlobalExceptionHandler.java b/backend/easycard-common/src/main/java/com/easycard/common/web/GlobalExceptionHandler.java
new file mode 100644
index 0000000..cdf3d4e
--- /dev/null
+++ b/backend/easycard-common/src/main/java/com/easycard/common/web/GlobalExceptionHandler.java
@@ -0,0 +1,40 @@
+package com.easycard.common.web;
+
+import com.easycard.common.api.ApiResponse;
+import com.easycard.common.exception.BusinessException;
+import jakarta.validation.ConstraintViolationException;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.BindException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
+
+@RestControllerAdvice
+public class GlobalExceptionHandler {
+
+ @ExceptionHandler(BusinessException.class)
+ public ApiResponse handleBusinessException(BusinessException exception) {
+ return ApiResponse.fail(exception.getCode(), exception.getMessage());
+ }
+
+ @ExceptionHandler({
+ MethodArgumentNotValidException.class,
+ BindException.class,
+ ConstraintViolationException.class,
+ HttpMessageNotReadableException.class
+ })
+ public ApiResponse handleValidationException(Exception exception) {
+ return ApiResponse.fail("VALIDATION_ERROR", exception.getMessage());
+ }
+
+ @ExceptionHandler(MaxUploadSizeExceededException.class)
+ public ApiResponse handleMaxUploadSizeExceededException(MaxUploadSizeExceededException exception) {
+ return ApiResponse.fail("FILE_TOO_LARGE", "上传图片不能超过 5MB");
+ }
+
+ @ExceptionHandler(Exception.class)
+ public ApiResponse handleException(Exception exception) {
+ return ApiResponse.fail("INTERNAL_SERVER_ERROR", exception.getMessage());
+ }
+}
diff --git a/backend/easycard-module-card/pom.xml b/backend/easycard-module-card/pom.xml
new file mode 100644
index 0000000..f938218
--- /dev/null
+++ b/backend/easycard-module-card/pom.xml
@@ -0,0 +1,50 @@
+
+ 4.0.0
+
+
+ com.easycard
+ easycard-parent
+ 0.1.0-SNAPSHOT
+
+
+ easycard-module-card
+ easycard-module-card
+
+
+
+ com.easycard
+ easycard-common
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-org
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-user
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-stat
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-file
+ ${project.version}
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
diff --git a/backend/easycard-module-card/src/main/java/com/easycard/module/card/controller/OpenMiniappController.java b/backend/easycard-module-card/src/main/java/com/easycard/module/card/controller/OpenMiniappController.java
new file mode 100644
index 0000000..20a1497
--- /dev/null
+++ b/backend/easycard-module-card/src/main/java/com/easycard/module/card/controller/OpenMiniappController.java
@@ -0,0 +1,67 @@
+package com.easycard.module.card.controller;
+
+import com.easycard.common.api.ApiResponse;
+import com.easycard.common.web.ClientRequestUtils;
+import com.easycard.module.card.service.CardProfileService;
+import jakarta.servlet.http.HttpServletRequest;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/open")
+public class OpenMiniappController {
+
+ private final CardProfileService cardProfileService;
+
+ public OpenMiniappController(CardProfileService cardProfileService) {
+ this.cardProfileService = cardProfileService;
+ }
+
+ @GetMapping("/firm/profile")
+ public ApiResponse getFirmProfile() {
+ return ApiResponse.success(cardProfileService.getOpenFirmProfile());
+ }
+
+ @GetMapping("/cards")
+ public ApiResponse> listCards(
+ @RequestParam(required = false) String keyword,
+ @RequestParam(required = false) String office,
+ @RequestParam(required = false) String practiceArea
+ ) {
+ return ApiResponse.success(cardProfileService.listOpenCards(keyword, office, practiceArea));
+ }
+
+ @GetMapping("/cards/{cardId}")
+ public ApiResponse getCardDetail(
+ @PathVariable Long cardId,
+ @RequestParam(defaultValue = "DIRECT") String sourceType,
+ @RequestParam(required = false) Long shareFromCardId,
+ HttpServletRequest request
+ ) {
+ return ApiResponse.success(cardProfileService.getOpenCardDetail(
+ cardId,
+ sourceType,
+ shareFromCardId,
+ ClientRequestUtils.getClientIp(request),
+ request.getRequestURI()
+ ));
+ }
+
+ @PostMapping("/cards/{cardId}/share")
+ public ApiResponse share(@PathVariable Long cardId, @RequestBody(required = false) ShareRequest request) {
+ String shareChannel = request == null || request.shareChannel() == null ? "WECHAT_FRIEND" : request.shareChannel();
+ String sharePath = request == null ? null : request.sharePath();
+ cardProfileService.recordShare(cardId, shareChannel, sharePath);
+ return ApiResponse.success("记录成功", null);
+ }
+}
+
+record ShareRequest(String shareChannel, String sharePath) {
+}
diff --git a/backend/easycard-module-card/src/main/java/com/easycard/module/card/controller/TenantCardController.java b/backend/easycard-module-card/src/main/java/com/easycard/module/card/controller/TenantCardController.java
new file mode 100644
index 0000000..40505fa
--- /dev/null
+++ b/backend/easycard-module-card/src/main/java/com/easycard/module/card/controller/TenantCardController.java
@@ -0,0 +1,129 @@
+package com.easycard.module.card.controller;
+
+import com.easycard.common.api.ApiResponse;
+import com.easycard.module.card.service.CardProfileService;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/tenant")
+public class TenantCardController {
+
+ private final CardProfileService cardProfileService;
+
+ public TenantCardController(CardProfileService cardProfileService) {
+ this.cardProfileService = cardProfileService;
+ }
+
+ @GetMapping("/cards")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse> listCards(@RequestParam(required = false) String keyword) {
+ return ApiResponse.success(cardProfileService.listTenantCards(keyword));
+ }
+
+ @PostMapping("/cards")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse createCard(@Valid @RequestBody UpsertCardCommand request) {
+ return ApiResponse.success(cardProfileService.createCard(request.toServiceRequest()));
+ }
+
+ @GetMapping("/cards/{cardId}")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse getCard(@PathVariable Long cardId) {
+ return ApiResponse.success(cardProfileService.getCardDetail(cardId));
+ }
+
+ @PutMapping("/cards/{cardId}")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse updateCard(@PathVariable Long cardId, @Valid @RequestBody UpsertCardCommand request) {
+ return ApiResponse.success(cardProfileService.saveCard(cardId, request.toServiceRequest()));
+ }
+
+ @DeleteMapping("/cards/{cardId}")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse deleteCard(@PathVariable Long cardId) {
+ cardProfileService.deleteCard(cardId);
+ return ApiResponse.success("删除成功", null);
+ }
+
+ @PutMapping("/cards/sort")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse sortCards(@Valid @RequestBody SortCardsRequest request) {
+ cardProfileService.sortCards(request.cardIds());
+ return ApiResponse.success("排序已生效", null);
+ }
+
+ @GetMapping("/cards/me")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse getMyCard() {
+ return ApiResponse.success(cardProfileService.getMyCard());
+ }
+
+ @PutMapping("/cards/me")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse saveMyCard(@Valid @RequestBody UpsertCardCommand request) {
+ return ApiResponse.success(cardProfileService.saveMyCard(request.toServiceRequest()));
+ }
+
+ @GetMapping("/stats/overview")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse overview() {
+ return ApiResponse.success(cardProfileService.getDashboardStats());
+ }
+
+ @GetMapping("/stats/trend")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse> trend(@RequestParam(defaultValue = "7") int days) {
+ return ApiResponse.success(cardProfileService.getTrend(days));
+ }
+}
+
+record UpsertCardCommand(
+ Long userId,
+ Long deptId,
+ @NotBlank(message = "名片姓名不能为空") String cardName,
+ String cardTitle,
+ String mobile,
+ String telephone,
+ String email,
+ String officeAddress,
+ Long avatarAssetId,
+ Long coverAssetId,
+ Long wechatQrAssetId,
+ String bio,
+ String certificateNo,
+ String educationInfo,
+ String honorInfo,
+ @NotNull(message = "是否公开不能为空") Integer isPublic,
+ @NotNull(message = "是否推荐不能为空") Integer isRecommended,
+ @NotBlank(message = "发布状态不能为空") String publishStatus,
+ Integer displayOrder,
+ List specialties
+) {
+ CardProfileService.UpsertCardRequest toServiceRequest() {
+ return new CardProfileService.UpsertCardRequest(
+ userId, deptId, cardName, cardTitle, mobile, telephone, email, officeAddress,
+ avatarAssetId, coverAssetId, wechatQrAssetId, bio, certificateNo, educationInfo,
+ honorInfo, isPublic, isRecommended, publishStatus, displayOrder == null ? 0 : displayOrder, specialties
+ );
+ }
+}
+
+record SortCardsRequest(
+ @Size(min = 1, message = "排序名片不能为空") List<@NotNull(message = "名片ID不能为空") Long> cardIds
+) {
+}
diff --git a/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/entity/CardProfileDO.java b/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/entity/CardProfileDO.java
new file mode 100644
index 0000000..3a70f0d
--- /dev/null
+++ b/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/entity/CardProfileDO.java
@@ -0,0 +1,46 @@
+package com.easycard.module.card.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("card_profile")
+public class CardProfileDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private Long userId;
+ private Long deptId;
+ private String cardName;
+ private String cardTitle;
+ private String mobile;
+ private String telephone;
+ private String email;
+ private String officeAddress;
+ private Long avatarAssetId;
+ private Long coverAssetId;
+ private Long wechatQrAssetId;
+ private String bio;
+ private String certificateNo;
+ private String educationInfo;
+ private String honorInfo;
+ private Integer isPublic;
+ private Integer isRecommended;
+ private String publishStatus;
+ private Integer displayOrder;
+ private Long viewCount;
+ private Long shareCount;
+ private LocalDateTime lastPublishedAt;
+ private Long createdBy;
+ private LocalDateTime createdTime;
+ private Long updatedBy;
+ private LocalDateTime updatedTime;
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/entity/CardProfileSpecialtyDO.java b/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/entity/CardProfileSpecialtyDO.java
new file mode 100644
index 0000000..c1329e2
--- /dev/null
+++ b/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/entity/CardProfileSpecialtyDO.java
@@ -0,0 +1,28 @@
+package com.easycard.module.card.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("card_profile_specialty")
+public class CardProfileSpecialtyDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private Long cardId;
+ private Long practiceAreaId;
+ private String specialtyName;
+ private Integer displayOrder;
+ private Long createdBy;
+ private LocalDateTime createdTime;
+ private Long updatedBy;
+ private LocalDateTime updatedTime;
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/mapper/CardProfileMapper.java b/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/mapper/CardProfileMapper.java
new file mode 100644
index 0000000..0bf7078
--- /dev/null
+++ b/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/mapper/CardProfileMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.card.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.card.dal.entity.CardProfileDO;
+
+public interface CardProfileMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/mapper/CardProfileSpecialtyMapper.java b/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/mapper/CardProfileSpecialtyMapper.java
new file mode 100644
index 0000000..e2ba14c
--- /dev/null
+++ b/backend/easycard-module-card/src/main/java/com/easycard/module/card/dal/mapper/CardProfileSpecialtyMapper.java
@@ -0,0 +1,22 @@
+package com.easycard.module.card.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.card.dal.entity.CardProfileSpecialtyDO;
+import org.apache.ibatis.annotations.Delete;
+import org.apache.ibatis.annotations.Param;
+
+public interface CardProfileSpecialtyMapper extends BaseMapper {
+
+ @Delete("""
+ DELETE FROM card_profile_specialty
+ WHERE tenant_id = #{tenantId}
+ AND card_id = #{cardId}
+ """)
+ int deleteForceByTenantIdAndCardId(@Param("tenantId") Long tenantId, @Param("cardId") Long cardId);
+
+ @Delete("""
+ DELETE FROM card_profile_specialty
+ WHERE tenant_id = #{tenantId}
+ """)
+ int deleteForceByTenantId(@Param("tenantId") Long tenantId);
+}
diff --git a/backend/easycard-module-card/src/main/java/com/easycard/module/card/service/CardProfileService.java b/backend/easycard-module-card/src/main/java/com/easycard/module/card/service/CardProfileService.java
new file mode 100644
index 0000000..f07e9af
--- /dev/null
+++ b/backend/easycard-module-card/src/main/java/com/easycard/module/card/service/CardProfileService.java
@@ -0,0 +1,823 @@
+package com.easycard.module.card.service;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.easycard.common.auth.LoginUser;
+import com.easycard.common.auth.SecurityUtils;
+import com.easycard.common.exception.BusinessException;
+import com.easycard.common.storage.StorageUrlUtils;
+import com.easycard.common.tenant.TenantContext;
+import com.easycard.common.tenant.TenantContextHolder;
+import com.easycard.module.card.dal.entity.CardProfileDO;
+import com.easycard.module.card.dal.entity.CardProfileSpecialtyDO;
+import com.easycard.module.card.dal.mapper.CardProfileMapper;
+import com.easycard.module.card.dal.mapper.CardProfileSpecialtyMapper;
+import com.easycard.module.file.dal.entity.FileAssetDO;
+import com.easycard.module.file.dal.mapper.FileAssetMapper;
+import com.easycard.module.org.dal.entity.OrgDepartmentDO;
+import com.easycard.module.org.dal.entity.OrgFirmProfileDO;
+import com.easycard.module.org.dal.mapper.OrgDepartmentMapper;
+import com.easycard.module.org.dal.mapper.OrgFirmProfileMapper;
+import com.easycard.module.stat.dal.entity.CardStatDailyDO;
+import com.easycard.module.stat.dal.mapper.CardStatDailyMapper;
+import com.easycard.module.stat.service.CardEventService;
+import com.easycard.module.user.dal.entity.SysRoleDO;
+import com.easycard.module.user.dal.entity.SysUserDO;
+import com.easycard.module.user.dal.entity.SysUserRoleDO;
+import com.easycard.module.user.dal.mapper.SysRoleMapper;
+import com.easycard.module.user.dal.mapper.SysUserMapper;
+import com.easycard.module.user.dal.mapper.SysUserRoleMapper;
+import com.easycard.module.user.service.UserAuditService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.util.StringUtils;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.util.HashSet;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.UUID;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Service
+@RequiredArgsConstructor
+public class CardProfileService {
+
+ private static final String AUTO_MANAGED_LAWYER_REMARK = "AUTO_MANAGED_LAWYER";
+ private static final String AUTO_MANAGED_ROLE_CODE = "TENANT_USER";
+
+ @Value("${easycard.storage.public-endpoint:${easycard.storage.endpoint}}")
+ private String publicEndpoint;
+
+ private final CardProfileMapper cardProfileMapper;
+ private final CardProfileSpecialtyMapper cardProfileSpecialtyMapper;
+ private final SysUserMapper sysUserMapper;
+ private final OrgDepartmentMapper orgDepartmentMapper;
+ private final OrgFirmProfileMapper orgFirmProfileMapper;
+ private final FileAssetMapper fileAssetMapper;
+ private final CardStatDailyMapper cardStatDailyMapper;
+ private final CardEventService cardEventService;
+ private final SysRoleMapper sysRoleMapper;
+ private final SysUserRoleMapper sysUserRoleMapper;
+ private final PasswordEncoder passwordEncoder;
+ private final UserAuditService userAuditService;
+
+ public List listTenantCards(String keyword) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ List cards = cardProfileMapper.selectList(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, loginUser.tenantId())
+ .eq(CardProfileDO::getDeleted, 0)
+ .like(StringUtils.hasText(keyword), CardProfileDO::getCardName, keyword)
+ .orderByAsc(CardProfileDO::getDisplayOrder, CardProfileDO::getId));
+ return filterLawyerCards(loginUser.tenantId(), cards).stream().map(this::toSummaryView).toList();
+ }
+
+ @Transactional
+ public CardDetailView createCard(UpsertCardRequest request) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ validateDepartment(loginUser.tenantId(), request.deptId());
+ SysUserDO hiddenUser = createHiddenLawyerUser(loginUser, request);
+
+ CardProfileDO card = new CardProfileDO();
+ card.setTenantId(loginUser.tenantId());
+ card.setUserId(hiddenUser.getId());
+ card.setCreatedBy(loginUser.userId());
+ card.setDeleted(0);
+ card.setViewCount(0L);
+ card.setShareCount(0L);
+ applyUpsertRequest(card, request, loginUser.userId(), true);
+ card.setDisplayOrder(nextCardDisplayOrder(loginUser.tenantId()));
+ cardProfileMapper.insert(card);
+ replaceSpecialties(card.getTenantId(), card.getId(), request.specialties(), loginUser.userId());
+ return toDetailView(card);
+ }
+
+ public CardDetailView getCardDetail(Long cardId) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ CardProfileDO card = getRequiredTenantCard(cardId, loginUser.tenantId());
+ return toDetailView(card);
+ }
+
+ public CardDetailView getMyCard() {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ CardProfileDO card = cardProfileMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, loginUser.tenantId())
+ .eq(CardProfileDO::getUserId, loginUser.userId())
+ .eq(CardProfileDO::getDeleted, 0)
+ .last("LIMIT 1"));
+ if (card == null) {
+ SysUserDO user = sysUserMapper.selectById(loginUser.userId());
+ card = new CardProfileDO();
+ card.setTenantId(loginUser.tenantId());
+ card.setUserId(loginUser.userId());
+ card.setDeptId(user == null ? null : user.getDeptId());
+ card.setCardName(user == null ? loginUser.realName() : user.getRealName());
+ card.setCardTitle(user == null ? "" : user.getJobTitle());
+ card.setMobile(user == null ? "" : user.getMobile());
+ card.setEmail(user == null ? "" : user.getEmail());
+ card.setOfficeAddress("");
+ card.setIsPublic(1);
+ card.setIsRecommended(0);
+ card.setPublishStatus("DRAFT");
+ card.setDisplayOrder(999);
+ card.setViewCount(0L);
+ card.setShareCount(0L);
+ card.setCreatedBy(loginUser.userId());
+ card.setUpdatedBy(loginUser.userId());
+ card.setDeleted(0);
+ cardProfileMapper.insert(card);
+ }
+ return toDetailView(card);
+ }
+
+ @Transactional
+ public CardDetailView saveMyCard(UpsertCardRequest request) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ CardProfileDO card = cardProfileMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, loginUser.tenantId())
+ .eq(CardProfileDO::getUserId, loginUser.userId())
+ .eq(CardProfileDO::getDeleted, 0)
+ .last("LIMIT 1"));
+ if (card == null) {
+ card = new CardProfileDO();
+ card.setTenantId(loginUser.tenantId());
+ card.setUserId(loginUser.userId());
+ card.setCreatedBy(loginUser.userId());
+ card.setDeleted(0);
+ card.setViewCount(0L);
+ card.setShareCount(0L);
+ }
+ applyUpsertRequest(card, request, loginUser.userId(), false);
+ if (card.getId() == null) {
+ cardProfileMapper.insert(card);
+ } else {
+ cardProfileMapper.updateById(card);
+ }
+ replaceSpecialties(card.getTenantId(), card.getId(), request.specialties(), loginUser.userId());
+ return toDetailView(card);
+ }
+
+ @Transactional
+ public CardDetailView saveCard(Long cardId, UpsertCardRequest request) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ CardProfileDO card = getRequiredTenantCard(cardId, loginUser.tenantId());
+ validateDepartment(loginUser.tenantId(), request.deptId());
+ applyUpsertRequest(card, request, loginUser.userId(), true);
+ cardProfileMapper.updateById(card);
+ replaceSpecialties(card.getTenantId(), card.getId(), request.specialties(), loginUser.userId());
+ return toDetailView(card);
+ }
+
+ @Transactional
+ public void deleteCard(Long cardId) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ CardProfileDO card = getRequiredTenantCard(cardId, loginUser.tenantId());
+ Long userId = card.getUserId();
+
+ card.setDeleted(1);
+ card.setUpdatedBy(loginUser.userId());
+ cardProfileMapper.updateById(card);
+ cardProfileSpecialtyMapper.deleteForceByTenantIdAndCardId(loginUser.tenantId(), cardId);
+
+ if (userId != null) {
+ SysUserDO user = sysUserMapper.selectById(userId);
+ if (user != null
+ && loginUser.tenantId().equals(user.getTenantId())
+ && Integer.valueOf(0).equals(user.getDeleted())
+ && AUTO_MANAGED_LAWYER_REMARK.equals(user.getRemark())) {
+ sysUserRoleMapper.delete(Wrappers.lambdaQuery()
+ .eq(SysUserRoleDO::getTenantId, loginUser.tenantId())
+ .eq(SysUserRoleDO::getUserId, userId));
+ user.setDeleted(1);
+ user.setUpdatedBy(loginUser.userId());
+ sysUserMapper.updateById(user);
+ }
+ }
+
+ userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "CARD", "CARD_PROFILE", cardId, "DELETE");
+ }
+
+ @Transactional
+ public void sortCards(List cardIds) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ if (cardIds == null || cardIds.isEmpty()) {
+ throw new BusinessException("CARD_SORT_INVALID", "排序数据不能为空");
+ }
+ Set uniqueCardIds = new HashSet<>(cardIds);
+ if (uniqueCardIds.size() != cardIds.size()) {
+ throw new BusinessException("CARD_SORT_INVALID", "排序数据存在重复名片");
+ }
+
+ List tenantCards = filterLawyerCards(loginUser.tenantId(), cardProfileMapper.selectList(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, loginUser.tenantId())
+ .eq(CardProfileDO::getDeleted, 0)
+ .select(CardProfileDO::getId, CardProfileDO::getUserId)));
+ Set expectedCardIds = tenantCards.stream().map(CardProfileDO::getId).collect(Collectors.toSet());
+ if (expectedCardIds.size() != uniqueCardIds.size() || !expectedCardIds.equals(uniqueCardIds)) {
+ throw new BusinessException("CARD_SORT_INVALID", "排序数据必须包含当前租户全部律师名片");
+ }
+
+ Map cardMap = cardProfileMapper.selectBatchIds(cardIds).stream()
+ .filter(item -> loginUser.tenantId().equals(item.getTenantId()) && Integer.valueOf(0).equals(item.getDeleted()))
+ .collect(Collectors.toMap(CardProfileDO::getId, Function.identity()));
+ for (int i = 0; i < cardIds.size(); i++) {
+ Long cardId = cardIds.get(i);
+ CardProfileDO card = cardMap.get(cardId);
+ if (card == null) {
+ throw new BusinessException("CARD_SORT_INVALID", "律师名片不存在或不属于当前租户");
+ }
+ card.setDisplayOrder((i + 1) * 10);
+ card.setUpdatedBy(loginUser.userId());
+ cardProfileMapper.updateById(card);
+ }
+
+ userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "CARD", "CARD_PROFILE", 0L, "SORT");
+ }
+
+ public DashboardStatsView getDashboardStats() {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ List cards = filterLawyerCards(loginUser.tenantId(), cardProfileMapper.selectList(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, loginUser.tenantId())
+ .eq(CardProfileDO::getDeleted, 0)));
+ long publishedCount = cards.stream().filter(item -> "PUBLISHED".equals(item.getPublishStatus()) && Integer.valueOf(1).equals(item.getIsPublic())).count();
+ long totalViews = cards.stream().mapToLong(item -> item.getViewCount() == null ? 0L : item.getViewCount()).sum();
+ long totalShares = cards.stream().mapToLong(item -> item.getShareCount() == null ? 0L : item.getShareCount()).sum();
+ Set lawyerCardIds = cards.stream().map(CardProfileDO::getId).collect(Collectors.toSet());
+ long todayViews = cardStatDailyMapper.selectList(Wrappers.lambdaQuery()
+ .eq(CardStatDailyDO::getTenantId, loginUser.tenantId())
+ .eq(CardStatDailyDO::getStatDate, LocalDate.now()))
+ .stream()
+ .filter(item -> lawyerCardIds.contains(item.getCardId()))
+ .mapToLong(item -> item.getViewCount() == null ? 0L : item.getViewCount())
+ .sum();
+ return new DashboardStatsView(cards.size(), publishedCount, totalViews, totalShares, todayViews);
+ }
+
+ public List getTrend(int days) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ LocalDate start = LocalDate.now().minusDays(Math.max(days - 1, 0));
+ return cardStatDailyMapper.selectList(Wrappers.lambdaQuery()
+ .eq(CardStatDailyDO::getTenantId, loginUser.tenantId())
+ .ge(CardStatDailyDO::getStatDate, start)
+ .orderByAsc(CardStatDailyDO::getStatDate))
+ .stream()
+ .collect(Collectors.groupingBy(CardStatDailyDO::getStatDate))
+ .entrySet()
+ .stream()
+ .sorted(Map.Entry.comparingByKey())
+ .map(entry -> new CardTrendView(
+ entry.getKey().toString(),
+ entry.getValue().stream().mapToLong(item -> item.getViewCount() == null ? 0L : item.getViewCount()).sum(),
+ entry.getValue().stream().mapToLong(item -> item.getShareCount() == null ? 0L : item.getShareCount()).sum()
+ ))
+ .toList();
+ }
+
+ public OpenFirmView getOpenFirmProfile() {
+ TenantContext tenantContext = TenantContextHolder.getRequired();
+ OrgFirmProfileDO profile = orgFirmProfileMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(OrgFirmProfileDO::getTenantId, tenantContext.tenantId())
+ .eq(OrgFirmProfileDO::getDeleted, 0)
+ .last("LIMIT 1"));
+ if (profile == null) {
+ throw new BusinessException("FIRM_PROFILE_NOT_FOUND", "事务所主页未配置");
+ }
+ List offices = orgDepartmentMapper.selectList(Wrappers.lambdaQuery()
+ .eq(OrgDepartmentDO::getTenantId, tenantContext.tenantId())
+ .eq(OrgDepartmentDO::getDeleted, 0)
+ .eq(OrgDepartmentDO::getDeptStatus, "ENABLED")
+ .orderByAsc(OrgDepartmentDO::getDisplayOrder, OrgDepartmentDO::getId))
+ .stream()
+ .map(OrgDepartmentDO::getDeptName)
+ .toList();
+ List areas = cardProfileSpecialtyMapper.selectList(Wrappers.lambdaQuery()
+ .eq(CardProfileSpecialtyDO::getTenantId, tenantContext.tenantId())
+ .eq(CardProfileSpecialtyDO::getDeleted, 0))
+ .stream()
+ .map(CardProfileSpecialtyDO::getSpecialtyName)
+ .distinct()
+ .toList();
+ long lawyerCount = filterLawyerCards(tenantContext.tenantId(), cardProfileMapper.selectList(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, tenantContext.tenantId())
+ .eq(CardProfileDO::getDeleted, 0)
+ .eq(CardProfileDO::getPublishStatus, "PUBLISHED")
+ .eq(CardProfileDO::getIsPublic, 1)))
+ .size();
+ return new OpenFirmView(
+ profile.getFirmName(),
+ resolveAssetUrl(profile.getLogoAssetId()),
+ resolveAssetUrl(profile.getHeroAssetId()),
+ profile.getIntro(),
+ profile.getHotlinePhone(),
+ profile.getHqAddress(),
+ profile.getHqLatitude() == null ? null : profile.getHqLatitude().doubleValue(),
+ profile.getHqLongitude() == null ? null : profile.getHqLongitude().doubleValue(),
+ (long) offices.size(),
+ lawyerCount,
+ offices,
+ areas
+ );
+ }
+
+ public List listOpenCards(String keyword, String office, String practiceArea) {
+ TenantContext tenantContext = TenantContextHolder.getRequired();
+ List cards = filterLawyerCards(tenantContext.tenantId(), cardProfileMapper.selectList(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, tenantContext.tenantId())
+ .eq(CardProfileDO::getDeleted, 0)
+ .eq(CardProfileDO::getPublishStatus, "PUBLISHED")
+ .eq(CardProfileDO::getIsPublic, 1)
+ .orderByAsc(CardProfileDO::getDisplayOrder, CardProfileDO::getId)));
+ if (cards.isEmpty()) {
+ return List.of();
+ }
+ List deptIds = cards.stream().map(CardProfileDO::getDeptId).filter(id -> id != null && id > 0).distinct().toList();
+ Map deptMap = deptIds.isEmpty() ? Map.of() : orgDepartmentMapper.selectBatchIds(deptIds)
+ .stream()
+ .collect(Collectors.toMap(OrgDepartmentDO::getId, Function.identity()));
+ Map> specialtyMap = loadSpecialtyMap(cards.stream().map(CardProfileDO::getId).toList());
+ return cards.stream()
+ .filter(card -> {
+ String deptName = deptMap.containsKey(card.getDeptId()) ? deptMap.get(card.getDeptId()).getDeptName() : "";
+ List specialties = specialtyMap.getOrDefault(card.getId(), List.of());
+ boolean keywordMatched = !StringUtils.hasText(keyword)
+ || card.getCardName().contains(keyword)
+ || deptName.contains(keyword)
+ || specialties.stream().anyMatch(item -> item.contains(keyword));
+ boolean officeMatched = !StringUtils.hasText(office) || office.equals(deptName);
+ boolean areaMatched = !StringUtils.hasText(practiceArea) || specialties.stream().anyMatch(item -> item.equals(practiceArea));
+ return keywordMatched && officeMatched && areaMatched;
+ })
+ .map(card -> {
+ String deptName = deptMap.containsKey(card.getDeptId()) ? deptMap.get(card.getDeptId()).getDeptName() : "";
+ return new OpenCardListItem(
+ card.getId(),
+ card.getCardName(),
+ card.getCardTitle(),
+ deptName,
+ card.getMobile(),
+ card.getEmail(),
+ resolveAssetUrl(card.getAvatarAssetId()),
+ specialtyMap.getOrDefault(card.getId(), List.of())
+ );
+ })
+ .toList();
+ }
+
+ @Transactional
+ public OpenCardDetailView getOpenCardDetail(Long cardId, String sourceType, Long shareFromCardId, String viewerIp, String pagePath) {
+ TenantContext tenantContext = TenantContextHolder.getRequired();
+ CardProfileDO card = cardProfileMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, tenantContext.tenantId())
+ .eq(CardProfileDO::getId, cardId)
+ .eq(CardProfileDO::getDeleted, 0)
+ .eq(CardProfileDO::getPublishStatus, "PUBLISHED")
+ .eq(CardProfileDO::getIsPublic, 1)
+ .last("LIMIT 1"));
+ if (card == null || !hasLawyerRole(tenantContext.tenantId(), card.getUserId())) {
+ throw new BusinessException("CARD_NOT_FOUND", "名片不存在或未公开");
+ }
+ card.setViewCount((card.getViewCount() == null ? 0L : card.getViewCount()) + 1);
+ cardProfileMapper.updateById(card);
+ cardEventService.recordView(tenantContext.tenantId(), tenantContext.miniappAppId(), card.getId(), sourceType, shareFromCardId, viewerIp, pagePath);
+ OrgDepartmentDO department = card.getDeptId() == null ? null : orgDepartmentMapper.selectById(card.getDeptId());
+ OrgFirmProfileDO profile = orgFirmProfileMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(OrgFirmProfileDO::getTenantId, tenantContext.tenantId())
+ .eq(OrgFirmProfileDO::getDeleted, 0)
+ .last("LIMIT 1"));
+ return new OpenCardDetailView(
+ card.getId(),
+ card.getCardName(),
+ card.getCardTitle(),
+ department == null ? "" : department.getDeptName(),
+ card.getMobile(),
+ card.getEmail(),
+ card.getOfficeAddress(),
+ resolveAssetUrl(card.getAvatarAssetId()),
+ resolveAssetUrl(card.getCoverAssetId()),
+ resolveAssetUrl(card.getWechatQrAssetId()),
+ card.getBio(),
+ loadSpecialtyMap(List.of(card.getId())).getOrDefault(card.getId(), List.of()),
+ profile == null ? "" : profile.getFirmName(),
+ profile == null ? "" : profile.getHqAddress(),
+ profile == null || profile.getHqLatitude() == null ? null : profile.getHqLatitude().doubleValue(),
+ profile == null || profile.getHqLongitude() == null ? null : profile.getHqLongitude().doubleValue()
+ );
+ }
+
+ @Transactional
+ public void recordShare(Long cardId, String shareChannel, String sharePath) {
+ TenantContext tenantContext = TenantContextHolder.getRequired();
+ CardProfileDO card = cardProfileMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, tenantContext.tenantId())
+ .eq(CardProfileDO::getId, cardId)
+ .eq(CardProfileDO::getDeleted, 0)
+ .last("LIMIT 1"));
+ if (card == null || !hasLawyerRole(tenantContext.tenantId(), card.getUserId())) {
+ throw new BusinessException("CARD_NOT_FOUND", "名片不存在");
+ }
+ card.setShareCount((card.getShareCount() == null ? 0L : card.getShareCount()) + 1);
+ cardProfileMapper.updateById(card);
+ cardEventService.recordShare(tenantContext.tenantId(), tenantContext.miniappAppId(), cardId, shareChannel, sharePath);
+ }
+
+ private CardProfileDO getRequiredTenantCard(Long cardId, Long tenantId) {
+ CardProfileDO card = cardProfileMapper.selectById(cardId);
+ if (card == null || Integer.valueOf(1).equals(card.getDeleted()) || !tenantId.equals(card.getTenantId())) {
+ throw new BusinessException("CARD_NOT_FOUND", "名片不存在");
+ }
+ return card;
+ }
+
+ private void validateDepartment(Long tenantId, Long deptId) {
+ if (deptId == null) {
+ return;
+ }
+ OrgDepartmentDO department = orgDepartmentMapper.selectById(deptId);
+ if (department == null || Integer.valueOf(1).equals(department.getDeleted()) || !tenantId.equals(department.getTenantId())) {
+ throw new BusinessException("DEPARTMENT_NOT_FOUND", "所属组织不存在");
+ }
+ }
+
+ private List filterLawyerCards(Long tenantId, List cards) {
+ if (cards == null || cards.isEmpty()) {
+ return List.of();
+ }
+ Map roleCodeMap = loadUserRoleCodeMap(
+ tenantId,
+ cards.stream()
+ .map(CardProfileDO::getUserId)
+ .filter(userId -> userId != null && userId > 0)
+ .distinct()
+ .toList()
+ );
+ return cards.stream()
+ .filter(card -> isLawyerRoleCode(roleCodeMap.get(card.getUserId())))
+ .toList();
+ }
+
+ private boolean hasLawyerRole(Long tenantId, Long userId) {
+ if (userId == null || userId <= 0) {
+ return false;
+ }
+ return isLawyerRoleCode(loadUserRoleCodeMap(tenantId, List.of(userId)).get(userId));
+ }
+
+ private Map loadUserRoleCodeMap(Long tenantId, List userIds) {
+ if (userIds == null || userIds.isEmpty()) {
+ return Map.of();
+ }
+ List userRoles = sysUserRoleMapper.selectList(Wrappers.lambdaQuery()
+ .eq(SysUserRoleDO::getTenantId, tenantId)
+ .in(SysUserRoleDO::getUserId, userIds));
+ if (userRoles.isEmpty()) {
+ return Map.of();
+ }
+ List roleIds = userRoles.stream().map(SysUserRoleDO::getRoleId).distinct().toList();
+ Map roleIdCodeMap = sysRoleMapper.selectList(Wrappers.lambdaQuery()
+ .eq(SysRoleDO::getTenantId, tenantId)
+ .eq(SysRoleDO::getDeleted, 0)
+ .in(SysRoleDO::getId, roleIds))
+ .stream()
+ .collect(Collectors.toMap(SysRoleDO::getId, SysRoleDO::getRoleCode));
+ return userRoles.stream().collect(Collectors.toMap(
+ SysUserRoleDO::getUserId,
+ item -> roleIdCodeMap.getOrDefault(item.getRoleId(), ""),
+ (left, right) -> left
+ ));
+ }
+
+ private boolean isLawyerRoleCode(String roleCode) {
+ return AUTO_MANAGED_ROLE_CODE.equals(roleCode);
+ }
+
+ private SysUserDO createHiddenLawyerUser(LoginUser loginUser, UpsertCardRequest request) {
+ SysRoleDO role = getRequiredTenantRole(loginUser.tenantId(), AUTO_MANAGED_ROLE_CODE);
+ SysUserDO user = new SysUserDO();
+ user.setTenantId(loginUser.tenantId());
+ user.setUserType("TENANT");
+ user.setUsername(generateHiddenLawyerUsername(loginUser.tenantId()));
+ user.setPasswordHash(passwordEncoder.encode(UUID.randomUUID().toString()));
+ user.setRealName(request.cardName());
+ user.setNickName(request.cardName());
+ user.setGender("UNKNOWN");
+ user.setMobile(request.mobile());
+ user.setEmail(request.email());
+ user.setDeptId(request.deptId());
+ user.setJobTitle(request.cardTitle());
+ user.setUserStatus("DISABLED");
+ user.setMustUpdatePassword(0);
+ user.setRemark(AUTO_MANAGED_LAWYER_REMARK);
+ user.setCreatedBy(loginUser.userId());
+ user.setUpdatedBy(loginUser.userId());
+ user.setDeleted(0);
+ sysUserMapper.insert(user);
+
+ SysUserRoleDO userRole = new SysUserRoleDO();
+ userRole.setTenantId(loginUser.tenantId());
+ userRole.setUserId(user.getId());
+ userRole.setRoleId(role.getId());
+ userRole.setCreatedBy(loginUser.userId());
+ sysUserRoleMapper.insert(userRole);
+ return user;
+ }
+
+ private String generateHiddenLawyerUsername(Long tenantId) {
+ String base = "lawyer_" + tenantId + "_" + System.currentTimeMillis();
+ for (int i = 0; i < 10; i++) {
+ String candidate = i == 0 ? base : base + "_" + i;
+ Long count = sysUserMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(SysUserDO::getTenantId, tenantId)
+ .eq(SysUserDO::getUsername, candidate)
+ .eq(SysUserDO::getDeleted, 0));
+ if (count == 0) {
+ return candidate;
+ }
+ }
+ return base + "_" + UUID.randomUUID().toString().replace("-", "").substring(0, 8);
+ }
+
+ private SysRoleDO getRequiredTenantRole(Long tenantId, String roleCode) {
+ SysRoleDO role = sysRoleMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(SysRoleDO::getTenantId, tenantId)
+ .eq(SysRoleDO::getRoleCode, roleCode)
+ .eq(SysRoleDO::getDeleted, 0)
+ .eq(SysRoleDO::getRoleStatus, "ENABLED")
+ .last("LIMIT 1"));
+ if (role == null) {
+ throw new BusinessException("ROLE_NOT_FOUND", "角色不存在");
+ }
+ return role;
+ }
+
+ private Integer nextCardDisplayOrder(Long tenantId) {
+ CardProfileDO lastCard = cardProfileMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(CardProfileDO::getTenantId, tenantId)
+ .eq(CardProfileDO::getDeleted, 0)
+ .orderByDesc(CardProfileDO::getDisplayOrder)
+ .orderByDesc(CardProfileDO::getId)
+ .last("LIMIT 1"));
+ int baseSort = lastCard == null || lastCard.getDisplayOrder() == null ? 0 : lastCard.getDisplayOrder();
+ return baseSort + 10;
+ }
+
+ private void applyUpsertRequest(CardProfileDO card, UpsertCardRequest request, Long operatorId, boolean allowChangeUser) {
+ if (allowChangeUser && request.userId() != null) {
+ card.setUserId(request.userId());
+ }
+ card.setDeptId(request.deptId());
+ card.setCardName(request.cardName());
+ card.setCardTitle(request.cardTitle());
+ card.setMobile(request.mobile());
+ card.setTelephone(request.telephone());
+ card.setEmail(request.email());
+ card.setOfficeAddress(request.officeAddress());
+ card.setAvatarAssetId(request.avatarAssetId());
+ card.setCoverAssetId(request.coverAssetId());
+ card.setWechatQrAssetId(request.wechatQrAssetId());
+ card.setBio(request.bio());
+ card.setCertificateNo(request.certificateNo());
+ card.setEducationInfo(request.educationInfo());
+ card.setHonorInfo(request.honorInfo());
+ card.setIsPublic(request.isPublic());
+ card.setIsRecommended(request.isRecommended());
+ card.setPublishStatus(request.publishStatus());
+ card.setDisplayOrder(request.displayOrder());
+ if ("PUBLISHED".equals(request.publishStatus())) {
+ card.setLastPublishedAt(LocalDateTime.now());
+ }
+ card.setUpdatedBy(operatorId);
+ }
+
+ private void replaceSpecialties(Long tenantId, Long cardId, List specialties, Long operatorId) {
+ cardProfileSpecialtyMapper.deleteForceByTenantIdAndCardId(tenantId, cardId);
+ List normalizedSpecialties = normalizeSpecialties(specialties);
+ if (normalizedSpecialties.isEmpty()) {
+ return;
+ }
+ for (int i = 0; i < normalizedSpecialties.size(); i++) {
+ String specialty = normalizedSpecialties.get(i);
+ CardProfileSpecialtyDO specialtyDO = new CardProfileSpecialtyDO();
+ specialtyDO.setTenantId(tenantId);
+ specialtyDO.setCardId(cardId);
+ specialtyDO.setSpecialtyName(specialty);
+ specialtyDO.setDisplayOrder((i + 1) * 10);
+ specialtyDO.setCreatedBy(operatorId);
+ specialtyDO.setUpdatedBy(operatorId);
+ specialtyDO.setDeleted(0);
+ cardProfileSpecialtyMapper.insert(specialtyDO);
+ }
+ }
+
+ private List normalizeSpecialties(List specialties) {
+ if (specialties == null || specialties.isEmpty()) {
+ return List.of();
+ }
+ Set normalized = new LinkedHashSet<>();
+ for (String specialty : specialties) {
+ if (!StringUtils.hasText(specialty)) {
+ continue;
+ }
+ normalized.add(specialty.trim());
+ }
+ return List.copyOf(normalized);
+ }
+
+ private CardSummaryView toSummaryView(CardProfileDO card) {
+ return new CardSummaryView(
+ card.getId(),
+ card.getUserId(),
+ card.getCardName(),
+ card.getCardTitle(),
+ card.getMobile(),
+ card.getEmail(),
+ card.getPublishStatus(),
+ card.getDisplayOrder(),
+ card.getCreatedTime(),
+ card.getUpdatedTime()
+ );
+ }
+
+ private CardDetailView toDetailView(CardProfileDO card) {
+ SysUserDO user = sysUserMapper.selectById(card.getUserId());
+ OrgDepartmentDO department = card.getDeptId() == null ? null : orgDepartmentMapper.selectById(card.getDeptId());
+ return new CardDetailView(
+ card.getId(),
+ card.getUserId(),
+ user == null ? "" : user.getUsername(),
+ card.getDeptId(),
+ department == null ? "" : department.getDeptName(),
+ card.getCardName(),
+ card.getCardTitle(),
+ card.getMobile(),
+ card.getTelephone(),
+ card.getEmail(),
+ card.getOfficeAddress(),
+ card.getAvatarAssetId(),
+ card.getCoverAssetId(),
+ card.getWechatQrAssetId(),
+ resolveAssetUrl(card.getAvatarAssetId()),
+ resolveAssetUrl(card.getCoverAssetId()),
+ resolveAssetUrl(card.getWechatQrAssetId()),
+ card.getBio(),
+ card.getCertificateNo(),
+ card.getEducationInfo(),
+ card.getHonorInfo(),
+ card.getIsPublic(),
+ card.getIsRecommended(),
+ card.getPublishStatus(),
+ card.getDisplayOrder(),
+ loadSpecialtyMap(List.of(card.getId())).getOrDefault(card.getId(), List.of())
+ );
+ }
+
+ private Map> loadSpecialtyMap(List cardIds) {
+ if (cardIds.isEmpty()) {
+ return Map.of();
+ }
+ return cardProfileSpecialtyMapper.selectList(Wrappers.lambdaQuery()
+ .in(CardProfileSpecialtyDO::getCardId, cardIds)
+ .eq(CardProfileSpecialtyDO::getDeleted, 0)
+ .orderByAsc(CardProfileSpecialtyDO::getDisplayOrder, CardProfileSpecialtyDO::getId))
+ .stream()
+ .collect(Collectors.groupingBy(
+ CardProfileSpecialtyDO::getCardId,
+ Collectors.mapping(CardProfileSpecialtyDO::getSpecialtyName, Collectors.toList())
+ ));
+ }
+
+ private String resolveAssetUrl(Long assetId) {
+ if (assetId == null) {
+ return "";
+ }
+ FileAssetDO asset = fileAssetMapper.selectById(assetId);
+ return asset == null ? "" : StorageUrlUtils.buildPublicUrl(publicEndpoint, asset.getBucketName(), asset.getObjectKey(), asset.getAccessUrl());
+ }
+
+ public record UpsertCardRequest(
+ Long userId,
+ Long deptId,
+ String cardName,
+ String cardTitle,
+ String mobile,
+ String telephone,
+ String email,
+ String officeAddress,
+ Long avatarAssetId,
+ Long coverAssetId,
+ Long wechatQrAssetId,
+ String bio,
+ String certificateNo,
+ String educationInfo,
+ String honorInfo,
+ Integer isPublic,
+ Integer isRecommended,
+ String publishStatus,
+ Integer displayOrder,
+ List specialties
+ ) {
+ }
+
+ public record CardSummaryView(
+ Long id,
+ Long userId,
+ String cardName,
+ String cardTitle,
+ String mobile,
+ String email,
+ String publishStatus,
+ Integer displayOrder,
+ LocalDateTime createdTime,
+ LocalDateTime updatedTime
+ ) {
+ }
+
+ public record CardDetailView(
+ Long id,
+ Long userId,
+ String username,
+ Long deptId,
+ String deptName,
+ String cardName,
+ String cardTitle,
+ String mobile,
+ String telephone,
+ String email,
+ String officeAddress,
+ Long avatarAssetId,
+ Long coverAssetId,
+ Long wechatQrAssetId,
+ String avatarUrl,
+ String coverUrl,
+ String wechatQrUrl,
+ String bio,
+ String certificateNo,
+ String educationInfo,
+ String honorInfo,
+ Integer isPublic,
+ Integer isRecommended,
+ String publishStatus,
+ Integer displayOrder,
+ List specialties
+ ) {
+ }
+
+ public record DashboardStatsView(Integer totalCards, Long publishedCards, Long totalViews, Long totalShares, Long todayViews) {
+ }
+
+ public record CardTrendView(String statDate, Long viewCount, Long shareCount) {
+ }
+
+ public record OpenFirmView(
+ String name,
+ String logo,
+ String heroImage,
+ String intro,
+ String hotlinePhone,
+ String hqAddress,
+ Double hqLatitude,
+ Double hqLongitude,
+ Long officeCount,
+ Long lawyerCount,
+ List officeList,
+ List practiceAreas
+ ) {
+ }
+
+ public record OpenCardListItem(
+ Long id,
+ String name,
+ String title,
+ String office,
+ String phone,
+ String email,
+ String avatar,
+ List specialties
+ ) {
+ }
+
+ public record OpenCardDetailView(
+ Long id,
+ String name,
+ String title,
+ String office,
+ String phone,
+ String email,
+ String address,
+ String avatar,
+ String coverImage,
+ String wechatQrImage,
+ String bio,
+ List specialties,
+ String firmName,
+ String firmAddress,
+ Double firmLatitude,
+ Double firmLongitude
+ ) {
+ }
+}
diff --git a/backend/easycard-module-file/pom.xml b/backend/easycard-module-file/pom.xml
new file mode 100644
index 0000000..752793e
--- /dev/null
+++ b/backend/easycard-module-file/pom.xml
@@ -0,0 +1,40 @@
+
+ 4.0.0
+
+
+ com.easycard
+ easycard-parent
+ 0.1.0-SNAPSHOT
+
+
+ easycard-module-file
+ easycard-module-file
+
+
+
+ com.easycard
+ easycard-common
+ ${project.version}
+
+
+ com.easycard
+ easycard-module-user
+ ${project.version}
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ io.minio
+ minio
+ 8.5.17
+
+
+
diff --git a/backend/easycard-module-file/src/main/java/com/easycard/module/file/controller/FileAssetController.java b/backend/easycard-module-file/src/main/java/com/easycard/module/file/controller/FileAssetController.java
new file mode 100644
index 0000000..772f565
--- /dev/null
+++ b/backend/easycard-module-file/src/main/java/com/easycard/module/file/controller/FileAssetController.java
@@ -0,0 +1,36 @@
+package com.easycard.module.file.controller;
+
+import com.easycard.common.api.ApiResponse;
+import com.easycard.module.file.service.FileAssetService;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/files")
+public class FileAssetController {
+
+ private final FileAssetService fileAssetService;
+
+ public FileAssetController(FileAssetService fileAssetService) {
+ this.fileAssetService = fileAssetService;
+ }
+
+ @PostMapping("/upload")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse upload(@RequestParam("file") MultipartFile file) {
+ return ApiResponse.success(fileAssetService.upload(file));
+ }
+
+ @GetMapping
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse> list() {
+ return ApiResponse.success(fileAssetService.listAssets());
+ }
+}
diff --git a/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/entity/FileAssetDO.java b/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/entity/FileAssetDO.java
new file mode 100644
index 0000000..08f89db
--- /dev/null
+++ b/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/entity/FileAssetDO.java
@@ -0,0 +1,35 @@
+package com.easycard.module.file.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("file_asset")
+public class FileAssetDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private Long uploadUserId;
+ private String storageProvider;
+ private String bucketName;
+ private String objectKey;
+ private String originalName;
+ private String fileExt;
+ private String mimeType;
+ private Long fileSize;
+ private String fileHash;
+ private String accessUrl;
+ private String assetStatus;
+ private Long createdBy;
+ private LocalDateTime createdTime;
+ private Long updatedBy;
+ private LocalDateTime updatedTime;
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/entity/FileAssetUsageDO.java b/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/entity/FileAssetUsageDO.java
new file mode 100644
index 0000000..905c728
--- /dev/null
+++ b/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/entity/FileAssetUsageDO.java
@@ -0,0 +1,28 @@
+package com.easycard.module.file.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("file_asset_usage")
+public class FileAssetUsageDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private Long assetId;
+ private String bizType;
+ private Long bizId;
+ private String fieldName;
+ private Long createdBy;
+ private LocalDateTime createdTime;
+ private Long updatedBy;
+ private LocalDateTime updatedTime;
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/mapper/FileAssetMapper.java b/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/mapper/FileAssetMapper.java
new file mode 100644
index 0000000..5693fa8
--- /dev/null
+++ b/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/mapper/FileAssetMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.file.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.file.dal.entity.FileAssetDO;
+
+public interface FileAssetMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/mapper/FileAssetUsageMapper.java b/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/mapper/FileAssetUsageMapper.java
new file mode 100644
index 0000000..d7d8f87
--- /dev/null
+++ b/backend/easycard-module-file/src/main/java/com/easycard/module/file/dal/mapper/FileAssetUsageMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.file.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.file.dal.entity.FileAssetUsageDO;
+
+public interface FileAssetUsageMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-file/src/main/java/com/easycard/module/file/service/FileAssetService.java b/backend/easycard-module-file/src/main/java/com/easycard/module/file/service/FileAssetService.java
new file mode 100644
index 0000000..21927f0
--- /dev/null
+++ b/backend/easycard-module-file/src/main/java/com/easycard/module/file/service/FileAssetService.java
@@ -0,0 +1,134 @@
+package com.easycard.module.file.service;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.easycard.common.auth.LoginUser;
+import com.easycard.common.auth.SecurityUtils;
+import com.easycard.common.exception.BusinessException;
+import com.easycard.common.storage.StorageUrlUtils;
+import com.easycard.module.file.dal.entity.FileAssetDO;
+import com.easycard.module.file.dal.mapper.FileAssetMapper;
+import io.minio.MinioClient;
+import io.minio.PutObjectArgs;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.InputStream;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Locale;
+import java.util.UUID;
+
+@Service
+@RequiredArgsConstructor
+public class FileAssetService {
+
+ private final FileAssetMapper fileAssetMapper;
+
+ @Value("${easycard.storage.endpoint}")
+ private String endpoint;
+
+ @Value("${easycard.storage.public-endpoint:${easycard.storage.endpoint}}")
+ private String publicEndpoint;
+
+ @Value("${easycard.storage.access-key}")
+ private String accessKey;
+
+ @Value("${easycard.storage.secret-key}")
+ private String secretKey;
+
+ @Value("${easycard.storage.bucket}")
+ private String bucket;
+
+ @Transactional
+ public FileAssetView upload(MultipartFile file) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ if (file == null || file.isEmpty()) {
+ throw new BusinessException("FILE_EMPTY", "上传文件不能为空");
+ }
+ if (file.getSize() > 5 * 1024 * 1024L) {
+ throw new BusinessException("FILE_TOO_LARGE", "图片大小不能超过 5MB");
+ }
+ String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT);
+ if (!contentType.startsWith("image/")) {
+ throw new BusinessException("FILE_TYPE_INVALID", "仅支持图片上传");
+ }
+
+ String extension = resolveExtension(file.getOriginalFilename());
+ String objectKey = loginUser.tenantId() + "/" + UUID.randomUUID() + extension;
+ try (InputStream inputStream = file.getInputStream()) {
+ MinioClient minioClient = MinioClient.builder()
+ .endpoint(endpoint)
+ .credentials(accessKey, secretKey)
+ .build();
+ minioClient.putObject(PutObjectArgs.builder()
+ .bucket(bucket)
+ .object(objectKey)
+ .stream(inputStream, file.getSize(), -1)
+ .contentType(file.getContentType())
+ .build());
+ } catch (Exception exception) {
+ throw new BusinessException("FILE_UPLOAD_FAILED", "上传文件失败: " + exception.getMessage());
+ }
+
+ FileAssetDO asset = new FileAssetDO();
+ asset.setTenantId(loginUser.tenantId());
+ asset.setUploadUserId(loginUser.userId());
+ asset.setStorageProvider("MINIO");
+ asset.setBucketName(bucket);
+ asset.setObjectKey(objectKey);
+ asset.setOriginalName(file.getOriginalFilename());
+ asset.setFileExt(extension);
+ asset.setMimeType(file.getContentType());
+ asset.setFileSize(file.getSize());
+ asset.setAccessUrl(StorageUrlUtils.buildPublicUrl(publicEndpoint, bucket, objectKey, ""));
+ asset.setAssetStatus("ACTIVE");
+ asset.setCreatedBy(loginUser.userId());
+ asset.setUpdatedBy(loginUser.userId());
+ asset.setDeleted(0);
+ fileAssetMapper.insert(asset);
+ return toView(asset);
+ }
+
+ public List listAssets() {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ return fileAssetMapper.selectList(Wrappers.lambdaQuery()
+ .eq(FileAssetDO::getTenantId, loginUser.tenantId())
+ .eq(FileAssetDO::getDeleted, 0)
+ .orderByDesc(FileAssetDO::getId))
+ .stream()
+ .map(this::toView)
+ .toList();
+ }
+
+ public FileAssetView getAsset(Long assetId) {
+ FileAssetDO asset = fileAssetMapper.selectById(assetId);
+ if (asset == null || Integer.valueOf(1).equals(asset.getDeleted())) {
+ throw new BusinessException("ASSET_NOT_FOUND", "素材不存在");
+ }
+ return toView(asset);
+ }
+
+ private FileAssetView toView(FileAssetDO asset) {
+ return new FileAssetView(
+ asset.getId(),
+ asset.getOriginalName(),
+ asset.getMimeType(),
+ asset.getFileSize(),
+ StorageUrlUtils.buildPublicUrl(publicEndpoint, asset.getBucketName(), asset.getObjectKey(), asset.getAccessUrl()),
+ asset.getCreatedTime() == null ? LocalDateTime.now() : asset.getCreatedTime()
+ );
+ }
+
+ private String resolveExtension(String originalName) {
+ if (originalName == null || !originalName.contains(".")) {
+ return "";
+ }
+ return originalName.substring(originalName.lastIndexOf('.'));
+ }
+
+ public record FileAssetView(Long id, String originalName, String mimeType, Long fileSize, String accessUrl, LocalDateTime createdTime) {
+ }
+}
diff --git a/backend/easycard-module-org/pom.xml b/backend/easycard-module-org/pom.xml
new file mode 100644
index 0000000..85f1593
--- /dev/null
+++ b/backend/easycard-module-org/pom.xml
@@ -0,0 +1,30 @@
+
+ 4.0.0
+
+
+ com.easycard
+ easycard-parent
+ 0.1.0-SNAPSHOT
+
+
+ easycard-module-org
+ easycard-module-org
+
+
+
+ com.easycard
+ easycard-common
+ ${project.version}
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/controller/TenantOrgController.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/controller/TenantOrgController.java
new file mode 100644
index 0000000..80e7069
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/controller/TenantOrgController.java
@@ -0,0 +1,123 @@
+package com.easycard.module.org.controller;
+
+import com.easycard.common.api.ApiResponse;
+import com.easycard.module.org.service.TenantOrgService;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+@RestController
+@RequestMapping("/api/v1/tenant")
+public class TenantOrgController {
+
+ private final TenantOrgService tenantOrgService;
+
+ public TenantOrgController(TenantOrgService tenantOrgService) {
+ this.tenantOrgService = tenantOrgService;
+ }
+
+ @GetMapping("/firm-profile")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse getFirmProfile() {
+ return ApiResponse.success(tenantOrgService.getFirmProfile());
+ }
+
+ @PutMapping("/firm-profile")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse saveFirmProfile(@Valid @RequestBody UpsertFirmProfileCommand request) {
+ return ApiResponse.success(tenantOrgService.saveFirmProfile(request.toServiceRequest()));
+ }
+
+ @GetMapping("/practice-areas")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse> listPracticeAreas() {
+ return ApiResponse.success(tenantOrgService.listPracticeAreas());
+ }
+
+ @PostMapping("/practice-areas")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse createPracticeArea(@Valid @RequestBody UpsertPracticeAreaCommand request) {
+ return ApiResponse.success(tenantOrgService.createPracticeArea(request.toServiceRequest()));
+ }
+
+ @PutMapping("/practice-areas/{areaId}")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse updatePracticeArea(@PathVariable Long areaId, @Valid @RequestBody UpsertPracticeAreaCommand request) {
+ return ApiResponse.success(tenantOrgService.updatePracticeArea(areaId, request.toServiceRequest()));
+ }
+
+ @GetMapping("/departments")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse> listDepartments() {
+ return ApiResponse.success(tenantOrgService.listDepartments());
+ }
+
+ @PostMapping("/departments")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse createDepartment(@Valid @RequestBody UpsertDepartmentCommand request) {
+ return ApiResponse.success(tenantOrgService.createDepartment(request.toServiceRequest()));
+ }
+
+ @PutMapping("/departments/{deptId}")
+ @PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
+ public ApiResponse updateDepartment(@PathVariable Long deptId, @Valid @RequestBody UpsertDepartmentCommand request) {
+ return ApiResponse.success(tenantOrgService.updateDepartment(deptId, request.toServiceRequest()));
+ }
+}
+
+record UpsertFirmProfileCommand(
+ @NotBlank(message = "事务所名称不能为空") String firmName,
+ String firmShortName,
+ String englishName,
+ Long logoAssetId,
+ Long heroAssetId,
+ String intro,
+ String hotlinePhone,
+ String websiteUrl,
+ String wechatOfficialAccount,
+ String hqAddress,
+ String hqLatitude,
+ String hqLongitude
+) {
+ TenantOrgService.UpsertFirmProfileRequest toServiceRequest() {
+ return new TenantOrgService.UpsertFirmProfileRequest(
+ firmName, firmShortName, englishName, logoAssetId, heroAssetId, intro, hotlinePhone,
+ websiteUrl, wechatOfficialAccount, hqAddress, hqLatitude, hqLongitude
+ );
+ }
+}
+
+record UpsertPracticeAreaCommand(
+ @NotBlank(message = "专业编码不能为空") String areaCode,
+ @NotBlank(message = "专业名称不能为空") String areaName,
+ Integer displayOrder,
+ @NotBlank(message = "状态不能为空") String areaStatus
+) {
+ TenantOrgService.UpsertPracticeAreaRequest toServiceRequest() {
+ return new TenantOrgService.UpsertPracticeAreaRequest(areaCode, areaName, displayOrder == null ? 0 : displayOrder, areaStatus);
+ }
+}
+
+record UpsertDepartmentCommand(
+ Long parentId,
+ @NotBlank(message = "组织编码不能为空") String deptCode,
+ @NotBlank(message = "组织名称不能为空") String deptName,
+ @NotBlank(message = "组织类型不能为空") String deptType,
+ String contactPhone,
+ String address,
+ Integer displayOrder,
+ @NotBlank(message = "状态不能为空") String deptStatus
+) {
+ TenantOrgService.UpsertDepartmentRequest toServiceRequest() {
+ return new TenantOrgService.UpsertDepartmentRequest(parentId, deptCode, deptName, deptType, contactPhone, address, displayOrder == null ? 0 : displayOrder, deptStatus);
+ }
+}
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/FileAssetLiteDO.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/FileAssetLiteDO.java
new file mode 100644
index 0000000..c04d628
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/FileAssetLiteDO.java
@@ -0,0 +1,20 @@
+package com.easycard.module.org.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+@Data
+@TableName("file_asset")
+public class FileAssetLiteDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private String bucketName;
+ private String objectKey;
+ private String accessUrl;
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgDepartmentDO.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgDepartmentDO.java
new file mode 100644
index 0000000..1096a08
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgDepartmentDO.java
@@ -0,0 +1,33 @@
+package com.easycard.module.org.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("org_department")
+public class OrgDepartmentDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private Long parentId;
+ private String deptCode;
+ private String deptName;
+ private String deptType;
+ private Long leaderUserId;
+ private String contactPhone;
+ private String address;
+ private Integer displayOrder;
+ private String deptStatus;
+ private Long createdBy;
+ private LocalDateTime createdTime;
+ private Long updatedBy;
+ private LocalDateTime updatedTime;
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgFirmPracticeAreaDO.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgFirmPracticeAreaDO.java
new file mode 100644
index 0000000..06f2230
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgFirmPracticeAreaDO.java
@@ -0,0 +1,28 @@
+package com.easycard.module.org.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("org_firm_practice_area")
+public class OrgFirmPracticeAreaDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private String areaCode;
+ private String areaName;
+ private Integer displayOrder;
+ private String areaStatus;
+ private Long createdBy;
+ private LocalDateTime createdTime;
+ private Long updatedBy;
+ private LocalDateTime updatedTime;
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgFirmProfileDO.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgFirmProfileDO.java
new file mode 100644
index 0000000..c960ece
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/entity/OrgFirmProfileDO.java
@@ -0,0 +1,37 @@
+package com.easycard.module.org.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableLogic;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.math.BigDecimal;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("org_firm_profile")
+public class OrgFirmProfileDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private String firmName;
+ private String firmShortName;
+ private String englishName;
+ private Long logoAssetId;
+ private Long heroAssetId;
+ private String intro;
+ private String hotlinePhone;
+ private String websiteUrl;
+ private String wechatOfficialAccount;
+ private String hqAddress;
+ private BigDecimal hqLatitude;
+ private BigDecimal hqLongitude;
+ private Long createdBy;
+ private LocalDateTime createdTime;
+ private Long updatedBy;
+ private LocalDateTime updatedTime;
+ @TableLogic
+ private Integer deleted;
+}
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/FileAssetLiteMapper.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/FileAssetLiteMapper.java
new file mode 100644
index 0000000..37c6ac0
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/FileAssetLiteMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.org.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.org.dal.entity.FileAssetLiteDO;
+
+public interface FileAssetLiteMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgDepartmentMapper.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgDepartmentMapper.java
new file mode 100644
index 0000000..7fa964d
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgDepartmentMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.org.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.org.dal.entity.OrgDepartmentDO;
+
+public interface OrgDepartmentMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgFirmPracticeAreaMapper.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgFirmPracticeAreaMapper.java
new file mode 100644
index 0000000..6029248
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgFirmPracticeAreaMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.org.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.org.dal.entity.OrgFirmPracticeAreaDO;
+
+public interface OrgFirmPracticeAreaMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgFirmProfileMapper.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgFirmProfileMapper.java
new file mode 100644
index 0000000..618e194
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/dal/mapper/OrgFirmProfileMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.org.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.org.dal.entity.OrgFirmProfileDO;
+
+public interface OrgFirmProfileMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-org/src/main/java/com/easycard/module/org/service/TenantOrgService.java b/backend/easycard-module-org/src/main/java/com/easycard/module/org/service/TenantOrgService.java
new file mode 100644
index 0000000..9ff6358
--- /dev/null
+++ b/backend/easycard-module-org/src/main/java/com/easycard/module/org/service/TenantOrgService.java
@@ -0,0 +1,303 @@
+package com.easycard.module.org.service;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.easycard.common.auth.LoginUser;
+import com.easycard.common.auth.SecurityUtils;
+import com.easycard.common.exception.BusinessException;
+import com.easycard.common.storage.StorageUrlUtils;
+import com.easycard.module.org.dal.entity.FileAssetLiteDO;
+import com.easycard.module.org.dal.mapper.FileAssetLiteMapper;
+import com.easycard.module.org.dal.entity.OrgDepartmentDO;
+import com.easycard.module.org.dal.entity.OrgFirmPracticeAreaDO;
+import com.easycard.module.org.dal.entity.OrgFirmProfileDO;
+import com.easycard.module.org.dal.mapper.OrgDepartmentMapper;
+import com.easycard.module.org.dal.mapper.OrgFirmPracticeAreaMapper;
+import com.easycard.module.org.dal.mapper.OrgFirmProfileMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.math.BigDecimal;
+import java.util.List;
+
+@Service
+@RequiredArgsConstructor
+public class TenantOrgService {
+
+ @Value("${easycard.storage.public-endpoint:${easycard.storage.endpoint}}")
+ private String publicEndpoint;
+
+ private final OrgFirmProfileMapper orgFirmProfileMapper;
+ private final OrgFirmPracticeAreaMapper orgFirmPracticeAreaMapper;
+ private final OrgDepartmentMapper orgDepartmentMapper;
+ private final FileAssetLiteMapper fileAssetLiteMapper;
+
+ public FirmProfileView getFirmProfile() {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ OrgFirmProfileDO profile = orgFirmProfileMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(OrgFirmProfileDO::getTenantId, loginUser.tenantId())
+ .eq(OrgFirmProfileDO::getDeleted, 0)
+ .last("LIMIT 1"));
+ if (profile == null) {
+ return new FirmProfileView(null, "", "", "", null, "", null, "", "", "", "", "", "", null, null);
+ }
+ return toView(profile);
+ }
+
+ @Transactional
+ public FirmProfileView saveFirmProfile(UpsertFirmProfileRequest request) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ OrgFirmProfileDO profile = orgFirmProfileMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(OrgFirmProfileDO::getTenantId, loginUser.tenantId())
+ .eq(OrgFirmProfileDO::getDeleted, 0)
+ .last("LIMIT 1"));
+ if (profile == null) {
+ profile = new OrgFirmProfileDO();
+ profile.setTenantId(loginUser.tenantId());
+ profile.setCreatedBy(loginUser.userId());
+ profile.setDeleted(0);
+ }
+ profile.setFirmName(request.firmName());
+ profile.setFirmShortName(request.firmShortName());
+ profile.setEnglishName(request.englishName());
+ profile.setLogoAssetId(request.logoAssetId());
+ profile.setHeroAssetId(request.heroAssetId());
+ profile.setIntro(request.intro());
+ profile.setHotlinePhone(request.hotlinePhone());
+ profile.setWebsiteUrl(request.websiteUrl());
+ profile.setWechatOfficialAccount(request.wechatOfficialAccount());
+ profile.setHqAddress(request.hqAddress());
+ profile.setHqLatitude(toBigDecimal(request.hqLatitude()));
+ profile.setHqLongitude(toBigDecimal(request.hqLongitude()));
+ profile.setUpdatedBy(loginUser.userId());
+ if (profile.getId() == null) {
+ orgFirmProfileMapper.insert(profile);
+ } else {
+ orgFirmProfileMapper.updateById(profile);
+ }
+ return toView(profile);
+ }
+
+ public List listPracticeAreas() {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ return orgFirmPracticeAreaMapper.selectList(Wrappers.lambdaQuery()
+ .eq(OrgFirmPracticeAreaDO::getTenantId, loginUser.tenantId())
+ .eq(OrgFirmPracticeAreaDO::getDeleted, 0)
+ .orderByAsc(OrgFirmPracticeAreaDO::getDisplayOrder, OrgFirmPracticeAreaDO::getId))
+ .stream()
+ .map(item -> new PracticeAreaView(item.getId(), item.getAreaCode(), item.getAreaName(), item.getDisplayOrder(), item.getAreaStatus()))
+ .toList();
+ }
+
+ @Transactional
+ public PracticeAreaView createPracticeArea(UpsertPracticeAreaRequest request) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ OrgFirmPracticeAreaDO existed = orgFirmPracticeAreaMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(OrgFirmPracticeAreaDO::getTenantId, loginUser.tenantId())
+ .eq(OrgFirmPracticeAreaDO::getAreaCode, request.areaCode())
+ .eq(OrgFirmPracticeAreaDO::getDeleted, 0)
+ .last("LIMIT 1"));
+ if (existed != null) {
+ throw new BusinessException("AREA_CODE_DUPLICATED", "专业领域编码已存在");
+ }
+ OrgFirmPracticeAreaDO area = new OrgFirmPracticeAreaDO();
+ area.setTenantId(loginUser.tenantId());
+ area.setAreaCode(request.areaCode());
+ area.setAreaName(request.areaName());
+ area.setDisplayOrder(request.displayOrder());
+ area.setAreaStatus(request.areaStatus());
+ area.setCreatedBy(loginUser.userId());
+ area.setUpdatedBy(loginUser.userId());
+ area.setDeleted(0);
+ orgFirmPracticeAreaMapper.insert(area);
+ return new PracticeAreaView(area.getId(), area.getAreaCode(), area.getAreaName(), area.getDisplayOrder(), area.getAreaStatus());
+ }
+
+ @Transactional
+ public PracticeAreaView updatePracticeArea(Long areaId, UpsertPracticeAreaRequest request) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ OrgFirmPracticeAreaDO area = getPracticeArea(areaId, loginUser.tenantId());
+ area.setAreaName(request.areaName());
+ area.setDisplayOrder(request.displayOrder());
+ area.setAreaStatus(request.areaStatus());
+ area.setUpdatedBy(loginUser.userId());
+ orgFirmPracticeAreaMapper.updateById(area);
+ return new PracticeAreaView(area.getId(), area.getAreaCode(), area.getAreaName(), area.getDisplayOrder(), area.getAreaStatus());
+ }
+
+ public List listDepartments() {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ return orgDepartmentMapper.selectList(Wrappers.lambdaQuery()
+ .eq(OrgDepartmentDO::getTenantId, loginUser.tenantId())
+ .eq(OrgDepartmentDO::getDeleted, 0)
+ .orderByAsc(OrgDepartmentDO::getDisplayOrder, OrgDepartmentDO::getId))
+ .stream()
+ .map(item -> new DepartmentView(item.getId(), item.getParentId(), item.getDeptCode(), item.getDeptName(), item.getDeptType(), item.getContactPhone(), item.getAddress(), item.getDisplayOrder(), item.getDeptStatus()))
+ .toList();
+ }
+
+ @Transactional
+ public DepartmentView createDepartment(UpsertDepartmentRequest request) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ OrgDepartmentDO existed = orgDepartmentMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(OrgDepartmentDO::getTenantId, loginUser.tenantId())
+ .eq(OrgDepartmentDO::getDeptCode, request.deptCode())
+ .eq(OrgDepartmentDO::getDeleted, 0)
+ .last("LIMIT 1"));
+ if (existed != null) {
+ throw new BusinessException("DEPT_CODE_DUPLICATED", "组织编码已存在");
+ }
+ OrgDepartmentDO department = new OrgDepartmentDO();
+ department.setTenantId(loginUser.tenantId());
+ department.setParentId(request.parentId() == null ? 0L : request.parentId());
+ department.setDeptCode(request.deptCode());
+ department.setDeptName(request.deptName());
+ department.setDeptType(request.deptType());
+ department.setContactPhone(request.contactPhone());
+ department.setAddress(request.address());
+ department.setDisplayOrder(request.displayOrder());
+ department.setDeptStatus(request.deptStatus());
+ department.setCreatedBy(loginUser.userId());
+ department.setUpdatedBy(loginUser.userId());
+ department.setDeleted(0);
+ orgDepartmentMapper.insert(department);
+ return new DepartmentView(department.getId(), department.getParentId(), department.getDeptCode(), department.getDeptName(), department.getDeptType(), department.getContactPhone(), department.getAddress(), department.getDisplayOrder(), department.getDeptStatus());
+ }
+
+ @Transactional
+ public DepartmentView updateDepartment(Long deptId, UpsertDepartmentRequest request) {
+ LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
+ OrgDepartmentDO department = getDepartment(deptId, loginUser.tenantId());
+ department.setParentId(request.parentId() == null ? 0L : request.parentId());
+ department.setDeptName(request.deptName());
+ department.setDeptType(request.deptType());
+ department.setContactPhone(request.contactPhone());
+ department.setAddress(request.address());
+ department.setDisplayOrder(request.displayOrder());
+ department.setDeptStatus(request.deptStatus());
+ department.setUpdatedBy(loginUser.userId());
+ orgDepartmentMapper.updateById(department);
+ return new DepartmentView(department.getId(), department.getParentId(), department.getDeptCode(), department.getDeptName(), department.getDeptType(), department.getContactPhone(), department.getAddress(), department.getDisplayOrder(), department.getDeptStatus());
+ }
+
+ private OrgDepartmentDO getDepartment(Long deptId, Long tenantId) {
+ OrgDepartmentDO department = orgDepartmentMapper.selectById(deptId);
+ if (department == null || Integer.valueOf(1).equals(department.getDeleted()) || !tenantId.equals(department.getTenantId())) {
+ throw new BusinessException("DEPARTMENT_NOT_FOUND", "组织不存在");
+ }
+ return department;
+ }
+
+ private OrgFirmPracticeAreaDO getPracticeArea(Long areaId, Long tenantId) {
+ OrgFirmPracticeAreaDO area = orgFirmPracticeAreaMapper.selectById(areaId);
+ if (area == null || Integer.valueOf(1).equals(area.getDeleted()) || !tenantId.equals(area.getTenantId())) {
+ throw new BusinessException("PRACTICE_AREA_NOT_FOUND", "专业领域不存在");
+ }
+ return area;
+ }
+
+ private FirmProfileView toView(OrgFirmProfileDO profile) {
+ return new FirmProfileView(
+ profile.getId(),
+ profile.getFirmName(),
+ profile.getFirmShortName(),
+ profile.getEnglishName(),
+ profile.getLogoAssetId(),
+ resolveAssetUrl(profile.getLogoAssetId()),
+ profile.getHeroAssetId(),
+ resolveAssetUrl(profile.getHeroAssetId()),
+ profile.getIntro(),
+ profile.getHotlinePhone(),
+ profile.getWebsiteUrl(),
+ profile.getWechatOfficialAccount(),
+ profile.getHqAddress(),
+ profile.getHqLatitude(),
+ profile.getHqLongitude()
+ );
+ }
+
+ private BigDecimal toBigDecimal(String value) {
+ if (value == null || value.isBlank()) {
+ return null;
+ }
+ return new BigDecimal(value);
+ }
+
+ private String resolveAssetUrl(Long assetId) {
+ if (assetId == null) {
+ return "";
+ }
+ FileAssetLiteDO asset = fileAssetLiteMapper.selectById(assetId);
+ if (asset == null || Integer.valueOf(1).equals(asset.getDeleted())) {
+ return "";
+ }
+ return StorageUrlUtils.buildPublicUrl(publicEndpoint, asset.getBucketName(), asset.getObjectKey(), asset.getAccessUrl());
+ }
+
+ public record FirmProfileView(
+ Long id,
+ String firmName,
+ String firmShortName,
+ String englishName,
+ Long logoAssetId,
+ String logoUrl,
+ Long heroAssetId,
+ String heroUrl,
+ String intro,
+ String hotlinePhone,
+ String websiteUrl,
+ String wechatOfficialAccount,
+ String hqAddress,
+ BigDecimal hqLatitude,
+ BigDecimal hqLongitude
+ ) {
+ }
+
+ public record UpsertFirmProfileRequest(
+ String firmName,
+ String firmShortName,
+ String englishName,
+ Long logoAssetId,
+ Long heroAssetId,
+ String intro,
+ String hotlinePhone,
+ String websiteUrl,
+ String wechatOfficialAccount,
+ String hqAddress,
+ String hqLatitude,
+ String hqLongitude
+ ) {
+ }
+
+ public record PracticeAreaView(Long id, String areaCode, String areaName, Integer displayOrder, String areaStatus) {
+ }
+
+ public record UpsertPracticeAreaRequest(String areaCode, String areaName, Integer displayOrder, String areaStatus) {
+ }
+
+ public record DepartmentView(
+ Long id,
+ Long parentId,
+ String deptCode,
+ String deptName,
+ String deptType,
+ String contactPhone,
+ String address,
+ Integer displayOrder,
+ String deptStatus
+ ) {
+ }
+
+ public record UpsertDepartmentRequest(
+ Long parentId,
+ String deptCode,
+ String deptName,
+ String deptType,
+ String contactPhone,
+ String address,
+ Integer displayOrder,
+ String deptStatus
+ ) {
+ }
+}
diff --git a/backend/easycard-module-stat/pom.xml b/backend/easycard-module-stat/pom.xml
new file mode 100644
index 0000000..e088337
--- /dev/null
+++ b/backend/easycard-module-stat/pom.xml
@@ -0,0 +1,30 @@
+
+ 4.0.0
+
+
+ com.easycard
+ easycard-parent
+ 0.1.0-SNAPSHOT
+
+
+ easycard-module-stat
+ easycard-module-stat
+
+
+
+ com.easycard
+ easycard-common
+ ${project.version}
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
diff --git a/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardShareLogDO.java b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardShareLogDO.java
new file mode 100644
index 0000000..af1ee18
--- /dev/null
+++ b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardShareLogDO.java
@@ -0,0 +1,23 @@
+package com.easycard.module.stat.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("card_share_log")
+public class CardShareLogDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private String miniappAppId;
+ private Long cardId;
+ private String shareChannel;
+ private String sharePath;
+ private String shareByOpenId;
+ private LocalDateTime sharedAt;
+}
diff --git a/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardStatDailyDO.java b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardStatDailyDO.java
new file mode 100644
index 0000000..750e372
--- /dev/null
+++ b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardStatDailyDO.java
@@ -0,0 +1,25 @@
+package com.easycard.module.stat.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Data
+@TableName("card_stat_daily")
+public class CardStatDailyDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private Long cardId;
+ private LocalDate statDate;
+ private Long viewCount;
+ private Long shareCount;
+ private Long uniqueVisitorCount;
+ private LocalDateTime createdTime;
+ private LocalDateTime updatedTime;
+}
diff --git a/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardViewLogDO.java b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardViewLogDO.java
new file mode 100644
index 0000000..2d8dd23
--- /dev/null
+++ b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/entity/CardViewLogDO.java
@@ -0,0 +1,25 @@
+package com.easycard.module.stat.dal.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.time.LocalDateTime;
+
+@Data
+@TableName("card_view_log")
+public class CardViewLogDO {
+
+ @TableId(type = IdType.AUTO)
+ private Long id;
+ private Long tenantId;
+ private String miniappAppId;
+ private Long cardId;
+ private String viewerOpenId;
+ private String viewerIp;
+ private String sourceType;
+ private Long shareFromCardId;
+ private String pagePath;
+ private LocalDateTime viewedAt;
+}
diff --git a/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardShareLogMapper.java b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardShareLogMapper.java
new file mode 100644
index 0000000..1aa2cf4
--- /dev/null
+++ b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardShareLogMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.stat.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.stat.dal.entity.CardShareLogDO;
+
+public interface CardShareLogMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardStatDailyMapper.java b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardStatDailyMapper.java
new file mode 100644
index 0000000..74c4d44
--- /dev/null
+++ b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardStatDailyMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.stat.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.stat.dal.entity.CardStatDailyDO;
+
+public interface CardStatDailyMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardViewLogMapper.java b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardViewLogMapper.java
new file mode 100644
index 0000000..4c51ecd
--- /dev/null
+++ b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/dal/mapper/CardViewLogMapper.java
@@ -0,0 +1,7 @@
+package com.easycard.module.stat.dal.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.easycard.module.stat.dal.entity.CardViewLogDO;
+
+public interface CardViewLogMapper extends BaseMapper {
+}
diff --git a/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/service/CardEventService.java b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/service/CardEventService.java
new file mode 100644
index 0000000..0fe2b82
--- /dev/null
+++ b/backend/easycard-module-stat/src/main/java/com/easycard/module/stat/service/CardEventService.java
@@ -0,0 +1,77 @@
+package com.easycard.module.stat.service;
+
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.easycard.module.stat.dal.entity.CardShareLogDO;
+import com.easycard.module.stat.dal.entity.CardStatDailyDO;
+import com.easycard.module.stat.dal.entity.CardViewLogDO;
+import com.easycard.module.stat.dal.mapper.CardShareLogMapper;
+import com.easycard.module.stat.dal.mapper.CardStatDailyMapper;
+import com.easycard.module.stat.dal.mapper.CardViewLogMapper;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+
+@Service
+@RequiredArgsConstructor
+public class CardEventService {
+
+ private final CardViewLogMapper cardViewLogMapper;
+ private final CardShareLogMapper cardShareLogMapper;
+ private final CardStatDailyMapper cardStatDailyMapper;
+
+ @Transactional
+ public void recordView(Long tenantId, String appId, Long cardId, String sourceType, Long shareFromCardId, String viewerIp, String pagePath) {
+ CardViewLogDO viewLog = new CardViewLogDO();
+ viewLog.setTenantId(tenantId);
+ viewLog.setMiniappAppId(appId);
+ viewLog.setCardId(cardId);
+ viewLog.setSourceType(sourceType);
+ viewLog.setShareFromCardId(shareFromCardId);
+ viewLog.setViewerIp(viewerIp);
+ viewLog.setPagePath(pagePath);
+ viewLog.setViewedAt(LocalDateTime.now());
+ cardViewLogMapper.insert(viewLog);
+ updateDailyStats(tenantId, cardId, true);
+ }
+
+ @Transactional
+ public void recordShare(Long tenantId, String appId, Long cardId, String shareChannel, String sharePath) {
+ CardShareLogDO shareLog = new CardShareLogDO();
+ shareLog.setTenantId(tenantId);
+ shareLog.setMiniappAppId(appId);
+ shareLog.setCardId(cardId);
+ shareLog.setShareChannel(shareChannel);
+ shareLog.setSharePath(sharePath);
+ shareLog.setSharedAt(LocalDateTime.now());
+ cardShareLogMapper.insert(shareLog);
+ updateDailyStats(tenantId, cardId, false);
+ }
+
+ private void updateDailyStats(Long tenantId, Long cardId, boolean viewIncrement) {
+ LocalDate statDate = LocalDate.now();
+ CardStatDailyDO statDaily = cardStatDailyMapper.selectOne(Wrappers.lambdaQuery()
+ .eq(CardStatDailyDO::getTenantId, tenantId)
+ .eq(CardStatDailyDO::getCardId, cardId)
+ .eq(CardStatDailyDO::getStatDate, statDate)
+ .last("LIMIT 1"));
+ if (statDaily == null) {
+ statDaily = new CardStatDailyDO();
+ statDaily.setTenantId(tenantId);
+ statDaily.setCardId(cardId);
+ statDaily.setStatDate(statDate);
+ statDaily.setViewCount(0L);
+ statDaily.setShareCount(0L);
+ statDaily.setUniqueVisitorCount(0L);
+ cardStatDailyMapper.insert(statDaily);
+ }
+ if (viewIncrement) {
+ statDaily.setViewCount(statDaily.getViewCount() + 1);
+ } else {
+ statDaily.setShareCount(statDaily.getShareCount() + 1);
+ }
+ cardStatDailyMapper.updateById(statDaily);
+ }
+}
diff --git a/backend/easycard-module-system/pom.xml b/backend/easycard-module-system/pom.xml
new file mode 100644
index 0000000..7ce9290
--- /dev/null
+++ b/backend/easycard-module-system/pom.xml
@@ -0,0 +1,22 @@
+
+ 4.0.0
+
+
+ com.easycard
+ easycard-parent
+ 0.1.0-SNAPSHOT
+
+
+ easycard-module-system
+ easycard-module-system
+
+
+
+ com.easycard
+ easycard-common
+ ${project.version}
+
+
+
diff --git a/backend/easycard-module-system/src/main/java/com/easycard/module/system/controller/SystemPingController.java b/backend/easycard-module-system/src/main/java/com/easycard/module/system/controller/SystemPingController.java
new file mode 100644
index 0000000..2da1076
--- /dev/null
+++ b/backend/easycard-module-system/src/main/java/com/easycard/module/system/controller/SystemPingController.java
@@ -0,0 +1,29 @@
+package com.easycard.module.system.controller;
+
+import com.easycard.common.api.ApiResponse;
+import com.easycard.common.tenant.TenantContextHolder;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.LinkedHashMap;
+import java.util.Map;
+
+@RestController
+public class SystemPingController {
+
+ @GetMapping("/api/v1/system/ping")
+ public ApiResponse