feat: 搭建后端多租户名片服务

- 初始化 Spring Boot 多模块工程与通用基础能力

- 增加租户、组织、用户、名片、文件、统计等业务模块

- 补充数据库迁移脚本与基础测试
This commit is contained in:
2026-03-20 12:43:21 +08:00
parent 1a2a078c0f
commit 9ef50288e9
95 changed files with 6722 additions and 0 deletions

42
backend/Dockerfile Normal file
View File

@@ -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"]

View File

@@ -0,0 +1,119 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-boot</artifactId>
<name>easycard-boot</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-system</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-tenant</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-org</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-user</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-card</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-file</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-stat</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-mysql</artifactId>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<mainClass>com.easycard.boot.EasycardBootApplication</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -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);
}
}

View File

@@ -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<String> 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<String> allowedOriginPatterns = new ArrayList<>();
private List<String> allowedMethods = new ArrayList<>(List.of(
"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
));
private List<String> allowedHeaders = new ArrayList<>(List.of("*"));
private List<String> exposedHeaders = new ArrayList<>(List.of(HttpHeaders.AUTHORIZATION));
private boolean allowCredentials = true;
private long maxAge = 1800;
public List<String> getAllowedOrigins() {
return allowedOrigins;
}
public void setAllowedOrigins(List<String> allowedOrigins) {
this.allowedOrigins = allowedOrigins;
}
public List<String> getAllowedOriginPatterns() {
return allowedOriginPatterns;
}
public void setAllowedOriginPatterns(List<String> allowedOriginPatterns) {
this.allowedOriginPatterns = allowedOriginPatterns;
}
public List<String> getAllowedMethods() {
return allowedMethods;
}
public void setAllowedMethods(List<String> allowedMethods) {
this.allowedMethods = allowedMethods;
}
public List<String> getAllowedHeaders() {
return allowedHeaders;
}
public void setAllowedHeaders(List<String> allowedHeaders) {
this.allowedHeaders = allowedHeaders;
}
public List<String> getExposedHeaders() {
return exposedHeaders;
}
public void setExposedHeaders(List<String> 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;
}
}

View File

@@ -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("电子名片系统后端接口文档"));
}
}

View File

@@ -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<String> allowedOrigins = sanitize(corsProperties.getAllowedOrigins());
List<String> 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<String> sanitize(List<String> 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", "令牌无效或已过期")));
}
}

View File

@@ -0,0 +1,10 @@
spring:
flyway:
connect-retries: 30
logging:
file:
path: ${LOG_PATH:/app/logs}
level:
root: INFO
com.easycard: INFO

View File

@@ -0,0 +1,5 @@
logging:
level:
root: INFO
com.easycard: INFO
org.flywaydb: INFO

View File

@@ -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}

View File

@@ -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 版本脚本为准,不再额外维护一份完整的本地建表脚本。

View File

@@ -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='操作日志表';

View File

@@ -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

View File

@@ -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
);

View File

@@ -0,0 +1,2 @@
ALTER TABLE `tenant_miniapp_config`
MODIFY COLUMN `miniapp_app_id` VARCHAR(64) NULL COMMENT '小程序AppID';

View File

@@ -0,0 +1,2 @@
ALTER TABLE `org_firm_profile`
DROP COLUMN `display_status`;

View File

@@ -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);

View File

@@ -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() {
}
}

View File

@@ -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 {
}
}

View File

@@ -0,0 +1,50 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-common</artifactId>
<name>easycard-common</name>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-autoconfigure</artifactId>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,16 @@
package com.easycard.common.api;
public record ApiResponse<T>(String code, String message, T data) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>("0", "success", data);
}
public static <T> ApiResponse<T> success(String message, T data) {
return new ApiResponse<>("0", message, data);
}
public static <T> ApiResponse<T> fail(String code, String message) {
return new ApiResponse<>(code, message, null);
}
}

View File

@@ -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<LoginUser> 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<String> 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();
}
}
}

View File

@@ -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<String> roleCodes
) implements Serializable {
}

View File

@@ -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()));
}
}

View File

@@ -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);
}
}

View File

@@ -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;
}
}

View File

@@ -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));
}
}

View File

@@ -0,0 +1,4 @@
package com.easycard.common.tenant;
public record TenantContext(Long tenantId, String tenantCode, String miniappAppId) {
}

View File

@@ -0,0 +1,31 @@
package com.easycard.common.tenant;
import java.util.Optional;
public final class TenantContextHolder {
private static final ThreadLocal<TenantContext> CONTEXT = new ThreadLocal<>();
private TenantContextHolder() {
}
public static void set(TenantContext tenantContext) {
CONTEXT.set(tenantContext);
}
public static Optional<TenantContext> 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();
}
}

View File

@@ -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();
}
}

View File

@@ -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<Void> handleBusinessException(BusinessException exception) {
return ApiResponse.fail(exception.getCode(), exception.getMessage());
}
@ExceptionHandler({
MethodArgumentNotValidException.class,
BindException.class,
ConstraintViolationException.class,
HttpMessageNotReadableException.class
})
public ApiResponse<Void> handleValidationException(Exception exception) {
return ApiResponse.fail("VALIDATION_ERROR", exception.getMessage());
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ApiResponse<Void> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException exception) {
return ApiResponse.fail("FILE_TOO_LARGE", "上传图片不能超过 5MB");
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception exception) {
return ApiResponse.fail("INTERNAL_SERVER_ERROR", exception.getMessage());
}
}

View File

@@ -0,0 +1,50 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-card</artifactId>
<name>easycard-module-card</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-org</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-user</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-stat</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-file</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -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<CardProfileService.OpenFirmView> getFirmProfile() {
return ApiResponse.success(cardProfileService.getOpenFirmProfile());
}
@GetMapping("/cards")
public ApiResponse<List<CardProfileService.OpenCardListItem>> 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<CardProfileService.OpenCardDetailView> 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<Void> 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) {
}

View File

@@ -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<List<CardProfileService.CardSummaryView>> listCards(@RequestParam(required = false) String keyword) {
return ApiResponse.success(cardProfileService.listTenantCards(keyword));
}
@PostMapping("/cards")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<CardProfileService.CardDetailView> 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<CardProfileService.CardDetailView> getCard(@PathVariable Long cardId) {
return ApiResponse.success(cardProfileService.getCardDetail(cardId));
}
@PutMapping("/cards/{cardId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<CardProfileService.CardDetailView> 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<Void> deleteCard(@PathVariable Long cardId) {
cardProfileService.deleteCard(cardId);
return ApiResponse.success("删除成功", null);
}
@PutMapping("/cards/sort")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<Void> 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<CardProfileService.CardDetailView> getMyCard() {
return ApiResponse.success(cardProfileService.getMyCard());
}
@PutMapping("/cards/me")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<CardProfileService.CardDetailView> 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<CardProfileService.DashboardStatsView> overview() {
return ApiResponse.success(cardProfileService.getDashboardStats());
}
@GetMapping("/stats/trend")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<CardProfileService.CardTrendView>> 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<String> 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
) {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<CardProfileDO> {
}

View File

@@ -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<CardProfileSpecialtyDO> {
@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);
}

View File

@@ -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<CardSummaryView> listTenantCards(String keyword) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
List<CardProfileDO> cards = cardProfileMapper.selectList(Wrappers.<CardProfileDO>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.<CardProfileDO>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.<CardProfileDO>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.<SysUserRoleDO>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<Long> cardIds) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
if (cardIds == null || cardIds.isEmpty()) {
throw new BusinessException("CARD_SORT_INVALID", "排序数据不能为空");
}
Set<Long> uniqueCardIds = new HashSet<>(cardIds);
if (uniqueCardIds.size() != cardIds.size()) {
throw new BusinessException("CARD_SORT_INVALID", "排序数据存在重复名片");
}
List<CardProfileDO> tenantCards = filterLawyerCards(loginUser.tenantId(), cardProfileMapper.selectList(Wrappers.<CardProfileDO>lambdaQuery()
.eq(CardProfileDO::getTenantId, loginUser.tenantId())
.eq(CardProfileDO::getDeleted, 0)
.select(CardProfileDO::getId, CardProfileDO::getUserId)));
Set<Long> expectedCardIds = tenantCards.stream().map(CardProfileDO::getId).collect(Collectors.toSet());
if (expectedCardIds.size() != uniqueCardIds.size() || !expectedCardIds.equals(uniqueCardIds)) {
throw new BusinessException("CARD_SORT_INVALID", "排序数据必须包含当前租户全部律师名片");
}
Map<Long, CardProfileDO> 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<CardProfileDO> cards = filterLawyerCards(loginUser.tenantId(), cardProfileMapper.selectList(Wrappers.<CardProfileDO>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<Long> lawyerCardIds = cards.stream().map(CardProfileDO::getId).collect(Collectors.toSet());
long todayViews = cardStatDailyMapper.selectList(Wrappers.<CardStatDailyDO>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<CardTrendView> getTrend(int days) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
LocalDate start = LocalDate.now().minusDays(Math.max(days - 1, 0));
return cardStatDailyMapper.selectList(Wrappers.<CardStatDailyDO>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.<OrgFirmProfileDO>lambdaQuery()
.eq(OrgFirmProfileDO::getTenantId, tenantContext.tenantId())
.eq(OrgFirmProfileDO::getDeleted, 0)
.last("LIMIT 1"));
if (profile == null) {
throw new BusinessException("FIRM_PROFILE_NOT_FOUND", "事务所主页未配置");
}
List<String> offices = orgDepartmentMapper.selectList(Wrappers.<OrgDepartmentDO>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<String> areas = cardProfileSpecialtyMapper.selectList(Wrappers.<CardProfileSpecialtyDO>lambdaQuery()
.eq(CardProfileSpecialtyDO::getTenantId, tenantContext.tenantId())
.eq(CardProfileSpecialtyDO::getDeleted, 0))
.stream()
.map(CardProfileSpecialtyDO::getSpecialtyName)
.distinct()
.toList();
long lawyerCount = filterLawyerCards(tenantContext.tenantId(), cardProfileMapper.selectList(Wrappers.<CardProfileDO>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<OpenCardListItem> listOpenCards(String keyword, String office, String practiceArea) {
TenantContext tenantContext = TenantContextHolder.getRequired();
List<CardProfileDO> cards = filterLawyerCards(tenantContext.tenantId(), cardProfileMapper.selectList(Wrappers.<CardProfileDO>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<Long> deptIds = cards.stream().map(CardProfileDO::getDeptId).filter(id -> id != null && id > 0).distinct().toList();
Map<Long, OrgDepartmentDO> deptMap = deptIds.isEmpty() ? Map.of() : orgDepartmentMapper.selectBatchIds(deptIds)
.stream()
.collect(Collectors.toMap(OrgDepartmentDO::getId, Function.identity()));
Map<Long, List<String>> 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<String> 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.<CardProfileDO>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.<OrgFirmProfileDO>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.<CardProfileDO>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<CardProfileDO> filterLawyerCards(Long tenantId, List<CardProfileDO> cards) {
if (cards == null || cards.isEmpty()) {
return List.of();
}
Map<Long, String> 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<Long, String> loadUserRoleCodeMap(Long tenantId, List<Long> userIds) {
if (userIds == null || userIds.isEmpty()) {
return Map.of();
}
List<SysUserRoleDO> userRoles = sysUserRoleMapper.selectList(Wrappers.<SysUserRoleDO>lambdaQuery()
.eq(SysUserRoleDO::getTenantId, tenantId)
.in(SysUserRoleDO::getUserId, userIds));
if (userRoles.isEmpty()) {
return Map.of();
}
List<Long> roleIds = userRoles.stream().map(SysUserRoleDO::getRoleId).distinct().toList();
Map<Long, String> roleIdCodeMap = sysRoleMapper.selectList(Wrappers.<SysRoleDO>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.<SysUserDO>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.<SysRoleDO>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.<CardProfileDO>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<String> specialties, Long operatorId) {
cardProfileSpecialtyMapper.deleteForceByTenantIdAndCardId(tenantId, cardId);
List<String> 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<String> normalizeSpecialties(List<String> specialties) {
if (specialties == null || specialties.isEmpty()) {
return List.of();
}
Set<String> 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<Long, List<String>> loadSpecialtyMap(List<Long> cardIds) {
if (cardIds.isEmpty()) {
return Map.of();
}
return cardProfileSpecialtyMapper.selectList(Wrappers.<CardProfileSpecialtyDO>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<String> 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<String> 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<String> officeList,
List<String> practiceAreas
) {
}
public record OpenCardListItem(
Long id,
String name,
String title,
String office,
String phone,
String email,
String avatar,
List<String> 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<String> specialties,
String firmName,
String firmAddress,
Double firmLatitude,
Double firmLongitude
) {
}
}

View File

@@ -0,0 +1,40 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-file</artifactId>
<name>easycard-module-file</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-user</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>io.minio</groupId>
<artifactId>minio</artifactId>
<version>8.5.17</version>
</dependency>
</dependencies>
</project>

View File

@@ -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<FileAssetService.FileAssetView> upload(@RequestParam("file") MultipartFile file) {
return ApiResponse.success(fileAssetService.upload(file));
}
@GetMapping
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<FileAssetService.FileAssetView>> list() {
return ApiResponse.success(fileAssetService.listAssets());
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<FileAssetDO> {
}

View File

@@ -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<FileAssetUsageDO> {
}

View File

@@ -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<FileAssetView> listAssets() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
return fileAssetMapper.selectList(Wrappers.<FileAssetDO>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) {
}
}

View File

@@ -0,0 +1,30 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-org</artifactId>
<name>easycard-module-org</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -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<TenantOrgService.FirmProfileView> getFirmProfile() {
return ApiResponse.success(tenantOrgService.getFirmProfile());
}
@PutMapping("/firm-profile")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.FirmProfileView> 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<List<TenantOrgService.PracticeAreaView>> listPracticeAreas() {
return ApiResponse.success(tenantOrgService.listPracticeAreas());
}
@PostMapping("/practice-areas")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.PracticeAreaView> 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<TenantOrgService.PracticeAreaView> 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<List<TenantOrgService.DepartmentView>> listDepartments() {
return ApiResponse.success(tenantOrgService.listDepartments());
}
@PostMapping("/departments")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.DepartmentView> createDepartment(@Valid @RequestBody UpsertDepartmentCommand request) {
return ApiResponse.success(tenantOrgService.createDepartment(request.toServiceRequest()));
}
@PutMapping("/departments/{deptId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantOrgService.DepartmentView> 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);
}
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<FileAssetLiteDO> {
}

View File

@@ -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<OrgDepartmentDO> {
}

View File

@@ -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<OrgFirmPracticeAreaDO> {
}

View File

@@ -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<OrgFirmProfileDO> {
}

View File

@@ -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.<OrgFirmProfileDO>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.<OrgFirmProfileDO>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<PracticeAreaView> listPracticeAreas() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
return orgFirmPracticeAreaMapper.selectList(Wrappers.<OrgFirmPracticeAreaDO>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.<OrgFirmPracticeAreaDO>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<DepartmentView> listDepartments() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
return orgDepartmentMapper.selectList(Wrappers.<OrgDepartmentDO>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.<OrgDepartmentDO>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
) {
}
}

View File

@@ -0,0 +1,30 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-stat</artifactId>
<name>easycard-module-stat</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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<CardShareLogDO> {
}

View File

@@ -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<CardStatDailyDO> {
}

View File

@@ -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<CardViewLogDO> {
}

View File

@@ -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.<CardStatDailyDO>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);
}
}

View File

@@ -0,0 +1,22 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-system</artifactId>
<name>easycard-module-system</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</project>

View File

@@ -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<Map<String, Object>> ping() {
return ApiResponse.success(Map.of(
"service", "easycard-backend",
"status", "UP"
));
}
@GetMapping("/api/open/ping")
public ApiResponse<Map<String, Object>> openPing() {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("service", "easycard-open-api");
payload.put("tenant", TenantContextHolder.getOptional().orElse(null));
return ApiResponse.success(payload);
}
}

View File

@@ -0,0 +1,50 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-tenant</artifactId>
<name>easycard-module-tenant</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-user</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-card</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-file</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-stat</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,37 @@
package com.easycard.module.tenant.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.module.tenant.service.PlatformTenantService;
import jakarta.validation.Valid;
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.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/platform/miniapps")
public class PlatformMiniappController {
private final PlatformTenantService platformTenantService;
public PlatformMiniappController(PlatformTenantService platformTenantService) {
this.platformTenantService = platformTenantService;
}
@GetMapping
@PreAuthorize("hasAuthority('PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<PlatformTenantService.MiniappConfigView>> listMiniapps() {
return ApiResponse.success(platformTenantService.listMiniappConfigs());
}
@PutMapping("/{tenantId}")
@PreAuthorize("hasAuthority('PLATFORM_SUPER_ADMIN')")
public ApiResponse<PlatformTenantService.MiniappConfigView> updateMiniapp(@PathVariable Long tenantId, @Valid @RequestBody MiniappConfigRequest request) {
return ApiResponse.success(platformTenantService.updateMiniappConfig(tenantId, request.toCommand()));
}
}

View File

@@ -0,0 +1,140 @@
package com.easycard.module.tenant.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.common.exception.BusinessException;
import com.easycard.module.tenant.service.PlatformTenantService;
import com.easycard.module.tenant.vo.TenantDetailResp;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/v1/platform/tenants")
public class PlatformTenantController {
private final PlatformTenantService platformTenantService;
public PlatformTenantController(PlatformTenantService platformTenantService) {
this.platformTenantService = platformTenantService;
}
@GetMapping
@PreAuthorize("hasAuthority('PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<TenantDetailResp>> listTenants() {
return ApiResponse.success(platformTenantService.listTenants());
}
@GetMapping("/{tenantId}")
@PreAuthorize("hasAuthority('PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantDetailResp> getTenant(@PathVariable Long tenantId) {
TenantDetailResp tenant = platformTenantService.getTenantById(tenantId)
.orElseThrow(() -> new BusinessException("TENANT_NOT_FOUND", "租户不存在"));
return ApiResponse.success(tenant);
}
@PostMapping
@PreAuthorize("hasAuthority('PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantDetailResp> createTenant(@Valid @RequestBody CreateTenantRequest request) {
return ApiResponse.success(platformTenantService.createTenant(request.toCommand()));
}
@PutMapping("/{tenantId}")
@PreAuthorize("hasAuthority('PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantDetailResp> updateTenant(@PathVariable Long tenantId, @Valid @RequestBody UpdateTenantRequest request) {
return ApiResponse.success(platformTenantService.updateTenant(tenantId, request.toCommand()));
}
@DeleteMapping("/{tenantId}")
@PreAuthorize("hasAuthority('PLATFORM_SUPER_ADMIN')")
public ApiResponse<Void> deleteTenant(@PathVariable Long tenantId) {
platformTenantService.deleteTenant(tenantId);
return ApiResponse.success(null);
}
}
record CreateTenantRequest(
@NotBlank(message = "租户名称不能为空") String tenantName,
@NotBlank(message = "管理员账号不能为空") String adminUsername,
@NotBlank(message = "管理员密码不能为空") String adminPassword,
String miniappAppId
) {
PlatformTenantService.CreateTenantCommand toCommand() {
return new PlatformTenantService.CreateTenantCommand(
null,
tenantName,
adminUsername,
adminPassword,
null,
null,
null,
null,
20,
1024,
null,
new PlatformTenantService.MiniappConfigCommand(
"PROD",
miniappAppId,
"",
tenantName + "电子名片",
null,
null,
null,
null,
"v1.0.0",
"DRAFT",
null
)
);
}
}
record UpdateTenantRequest(
@NotBlank(message = "租户名称不能为空") String tenantName,
String miniappAppId
) {
PlatformTenantService.UpdateTenantCommand toCommand() {
return new PlatformTenantService.UpdateTenantCommand(
tenantName, miniappAppId, null, null, null, null, null, null, null
);
}
}
record MiniappConfigRequest(
String envCode,
String miniappAppId,
String miniappAppSecret,
String miniappName,
String miniappOriginalId,
String requestDomain,
String uploadDomain,
String downloadDomain,
String versionTag,
String publishStatus,
String remark
) {
PlatformTenantService.MiniappConfigCommand toCommand() {
return new PlatformTenantService.MiniappConfigCommand(
envCode == null ? "PROD" : envCode,
miniappAppId,
miniappAppSecret,
miniappName,
miniappOriginalId,
requestDomain,
uploadDomain,
downloadDomain,
versionTag,
publishStatus == null ? "DRAFT" : publishStatus,
remark
);
}
}

View File

@@ -0,0 +1,36 @@
package com.easycard.module.tenant.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.module.tenant.service.PlatformTenantService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
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;
@RestController
@RequestMapping("/api/v1/tenant/miniapp-config")
public class TenantMiniappController {
private final PlatformTenantService platformTenantService;
public TenantMiniappController(PlatformTenantService platformTenantService) {
this.platformTenantService = platformTenantService;
}
@GetMapping
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
public ApiResponse<PlatformTenantService.MiniappConfigView> getCurrentMiniappConfig() {
return ApiResponse.success(platformTenantService.getCurrentTenantMiniappConfig());
}
@PutMapping
@PreAuthorize("hasAuthority('TENANT_ADMIN')")
public ApiResponse<PlatformTenantService.MiniappConfigView> updateCurrentMiniappConfig(@RequestBody UpdateTenantMiniappConfigRequest request) {
return ApiResponse.success(platformTenantService.updateCurrentTenantMiniappConfig(request == null ? null : request.miniappAppId()));
}
}
record UpdateTenantMiniappConfigRequest(String miniappAppId) {
}

View File

@@ -0,0 +1,168 @@
package com.easycard.module.tenant.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 java.time.LocalDateTime;
@TableName("sys_tenant")
public class TenantDO {
@TableId(type = IdType.AUTO)
private Long id;
private String tenantCode;
private String tenantName;
private String tenantShortName;
private String contactName;
private String contactPhone;
private String contactEmail;
private String tenantStatus;
private LocalDateTime expireAt;
private Integer userLimit;
private Integer storageLimitMb;
private String remark;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTenantCode() {
return tenantCode;
}
public void setTenantCode(String tenantCode) {
this.tenantCode = tenantCode;
}
public String getTenantName() {
return tenantName;
}
public void setTenantName(String tenantName) {
this.tenantName = tenantName;
}
public String getTenantShortName() {
return tenantShortName;
}
public void setTenantShortName(String tenantShortName) {
this.tenantShortName = tenantShortName;
}
public String getContactName() {
return contactName;
}
public void setContactName(String contactName) {
this.contactName = contactName;
}
public String getContactPhone() {
return contactPhone;
}
public void setContactPhone(String contactPhone) {
this.contactPhone = contactPhone;
}
public String getContactEmail() {
return contactEmail;
}
public void setContactEmail(String contactEmail) {
this.contactEmail = contactEmail;
}
public String getTenantStatus() {
return tenantStatus;
}
public void setTenantStatus(String tenantStatus) {
this.tenantStatus = tenantStatus;
}
public LocalDateTime getExpireAt() {
return expireAt;
}
public void setExpireAt(LocalDateTime expireAt) {
this.expireAt = expireAt;
}
public Integer getUserLimit() {
return userLimit;
}
public void setUserLimit(Integer userLimit) {
this.userLimit = userLimit;
}
public Integer getStorageLimitMb() {
return storageLimitMb;
}
public void setStorageLimitMb(Integer storageLimitMb) {
this.storageLimitMb = storageLimitMb;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
public Long getCreatedBy() {
return createdBy;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
public LocalDateTime getCreatedTime() {
return createdTime;
}
public void setCreatedTime(LocalDateTime createdTime) {
this.createdTime = createdTime;
}
public Long getUpdatedBy() {
return updatedBy;
}
public void setUpdatedBy(Long updatedBy) {
this.updatedBy = updatedBy;
}
public LocalDateTime getUpdatedTime() {
return updatedTime;
}
public void setUpdatedTime(LocalDateTime updatedTime) {
this.updatedTime = updatedTime;
}
public Integer getDeleted() {
return deleted;
}
public void setDeleted(Integer deleted) {
this.deleted = deleted;
}
}

View File

@@ -0,0 +1,186 @@
package com.easycard.module.tenant.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 java.time.LocalDateTime;
@TableName("tenant_miniapp_config")
public class TenantMiniappConfigDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private String envCode;
private String miniappAppId;
private String miniappAppSecret;
private String miniappName;
private String miniappOriginalId;
private String requestDomain;
private String uploadDomain;
private String downloadDomain;
private String versionTag;
private String publishStatus;
private LocalDateTime lastPublishedAt;
private String remark;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public Long getTenantId() {
return tenantId;
}
public void setTenantId(Long tenantId) {
this.tenantId = tenantId;
}
public String getEnvCode() {
return envCode;
}
public void setEnvCode(String envCode) {
this.envCode = envCode;
}
public String getMiniappAppId() {
return miniappAppId;
}
public void setMiniappAppId(String miniappAppId) {
this.miniappAppId = miniappAppId;
}
public String getMiniappAppSecret() {
return miniappAppSecret;
}
public void setMiniappAppSecret(String miniappAppSecret) {
this.miniappAppSecret = miniappAppSecret;
}
public String getMiniappName() {
return miniappName;
}
public void setMiniappName(String miniappName) {
this.miniappName = miniappName;
}
public String getMiniappOriginalId() {
return miniappOriginalId;
}
public void setMiniappOriginalId(String miniappOriginalId) {
this.miniappOriginalId = miniappOriginalId;
}
public String getRequestDomain() {
return requestDomain;
}
public void setRequestDomain(String requestDomain) {
this.requestDomain = requestDomain;
}
public String getUploadDomain() {
return uploadDomain;
}
public void setUploadDomain(String uploadDomain) {
this.uploadDomain = uploadDomain;
}
public String getDownloadDomain() {
return downloadDomain;
}
public void setDownloadDomain(String downloadDomain) {
this.downloadDomain = downloadDomain;
}
public String getVersionTag() {
return versionTag;
}
public void setVersionTag(String versionTag) {
this.versionTag = versionTag;
}
public String getPublishStatus() {
return publishStatus;
}
public void setPublishStatus(String publishStatus) {
this.publishStatus = publishStatus;
}
public LocalDateTime getLastPublishedAt() {
return lastPublishedAt;
}
public void setLastPublishedAt(LocalDateTime lastPublishedAt) {
this.lastPublishedAt = lastPublishedAt;
}
public String getRemark() {
return remark;
}
public void setRemark(String remark) {
this.remark = remark;
}
public Long getCreatedBy() {
return createdBy;
}
public void setCreatedBy(Long createdBy) {
this.createdBy = createdBy;
}
public LocalDateTime getCreatedTime() {
return createdTime;
}
public void setCreatedTime(LocalDateTime createdTime) {
this.createdTime = createdTime;
}
public Long getUpdatedBy() {
return updatedBy;
}
public void setUpdatedBy(Long updatedBy) {
this.updatedBy = updatedBy;
}
public LocalDateTime getUpdatedTime() {
return updatedTime;
}
public void setUpdatedTime(LocalDateTime updatedTime) {
this.updatedTime = updatedTime;
}
public Integer getDeleted() {
return deleted;
}
public void setDeleted(Integer deleted) {
this.deleted = deleted;
}
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.tenant.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.tenant.dal.entity.TenantDO;
public interface TenantMapper extends BaseMapper<TenantDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.tenant.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.tenant.dal.entity.TenantMiniappConfigDO;
public interface TenantMiniappConfigMapper extends BaseMapper<TenantMiniappConfigDO> {
}

View File

@@ -0,0 +1,90 @@
package com.easycard.module.tenant.service;
import com.easycard.common.tenant.TenantContext;
import com.easycard.module.tenant.vo.TenantDetailResp;
import java.util.List;
import java.util.Optional;
public interface PlatformTenantService {
List<TenantDetailResp> listTenants();
Optional<TenantDetailResp> getTenantById(Long tenantId);
TenantDetailResp createTenant(CreateTenantCommand command);
TenantDetailResp updateTenant(Long tenantId, UpdateTenantCommand command);
void deleteTenant(Long tenantId);
List<MiniappConfigView> listMiniappConfigs();
MiniappConfigView updateMiniappConfig(Long tenantId, MiniappConfigCommand command);
MiniappConfigView getCurrentTenantMiniappConfig();
MiniappConfigView updateCurrentTenantMiniappConfig(String miniappAppId);
Optional<TenantContext> resolveTenantContextByMiniappAppId(String miniappAppId);
record CreateTenantCommand(
String tenantCode,
String tenantName,
String adminUsername,
String adminPassword,
String tenantShortName,
String contactName,
String contactPhone,
String contactEmail,
Integer userLimit,
Integer storageLimitMb,
String remark,
MiniappConfigCommand miniappConfig
) {
}
record UpdateTenantCommand(
String tenantName,
String miniappAppId,
String tenantShortName,
String contactName,
String contactPhone,
String contactEmail,
Integer userLimit,
Integer storageLimitMb,
String remark
) {
}
record MiniappConfigCommand(
String envCode,
String miniappAppId,
String miniappAppSecret,
String miniappName,
String miniappOriginalId,
String requestDomain,
String uploadDomain,
String downloadDomain,
String versionTag,
String publishStatus,
String remark
) {
}
record MiniappConfigView(
Long tenantId,
String tenantCode,
String tenantName,
String envCode,
String miniappAppId,
String miniappName,
String miniappOriginalId,
String requestDomain,
String uploadDomain,
String downloadDomain,
String versionTag,
String publishStatus
) {
}
}

View File

@@ -0,0 +1,573 @@
package com.easycard.module.tenant.service.impl;
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.tenant.TenantContext;
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.entity.FileAssetUsageDO;
import com.easycard.module.file.dal.mapper.FileAssetMapper;
import com.easycard.module.file.dal.mapper.FileAssetUsageMapper;
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 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 com.easycard.module.tenant.dal.entity.TenantDO;
import com.easycard.module.tenant.dal.entity.TenantMiniappConfigDO;
import com.easycard.module.tenant.dal.mapper.TenantMapper;
import com.easycard.module.tenant.dal.mapper.TenantMiniappConfigMapper;
import com.easycard.module.tenant.service.PlatformTenantService;
import com.easycard.module.tenant.vo.TenantDetailResp;
import com.easycard.module.user.dal.entity.SysLoginLogDO;
import com.easycard.module.user.dal.entity.SysOperationLogDO;
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.SysLoginLogMapper;
import com.easycard.module.user.dal.mapper.SysOperationLogMapper;
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 org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
@Service
@Primary
public class PlatformTenantServiceImpl implements PlatformTenantService {
private final TenantMapper tenantMapper;
private final TenantMiniappConfigMapper tenantMiniappConfigMapper;
private final SysUserMapper sysUserMapper;
private final SysRoleMapper sysRoleMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final SysLoginLogMapper sysLoginLogMapper;
private final SysOperationLogMapper sysOperationLogMapper;
private final OrgDepartmentMapper orgDepartmentMapper;
private final OrgFirmProfileMapper orgFirmProfileMapper;
private final OrgFirmPracticeAreaMapper orgFirmPracticeAreaMapper;
private final CardProfileMapper cardProfileMapper;
private final CardProfileSpecialtyMapper cardProfileSpecialtyMapper;
private final FileAssetMapper fileAssetMapper;
private final FileAssetUsageMapper fileAssetUsageMapper;
private final CardViewLogMapper cardViewLogMapper;
private final CardShareLogMapper cardShareLogMapper;
private final CardStatDailyMapper cardStatDailyMapper;
private final PasswordEncoder passwordEncoder;
public PlatformTenantServiceImpl(
TenantMapper tenantMapper,
TenantMiniappConfigMapper tenantMiniappConfigMapper,
SysUserMapper sysUserMapper,
SysRoleMapper sysRoleMapper,
SysUserRoleMapper sysUserRoleMapper,
SysLoginLogMapper sysLoginLogMapper,
SysOperationLogMapper sysOperationLogMapper,
OrgDepartmentMapper orgDepartmentMapper,
OrgFirmProfileMapper orgFirmProfileMapper,
OrgFirmPracticeAreaMapper orgFirmPracticeAreaMapper,
CardProfileMapper cardProfileMapper,
CardProfileSpecialtyMapper cardProfileSpecialtyMapper,
FileAssetMapper fileAssetMapper,
FileAssetUsageMapper fileAssetUsageMapper,
CardViewLogMapper cardViewLogMapper,
CardShareLogMapper cardShareLogMapper,
CardStatDailyMapper cardStatDailyMapper,
PasswordEncoder passwordEncoder
) {
this.tenantMapper = tenantMapper;
this.tenantMiniappConfigMapper = tenantMiniappConfigMapper;
this.sysUserMapper = sysUserMapper;
this.sysRoleMapper = sysRoleMapper;
this.sysUserRoleMapper = sysUserRoleMapper;
this.sysLoginLogMapper = sysLoginLogMapper;
this.sysOperationLogMapper = sysOperationLogMapper;
this.orgDepartmentMapper = orgDepartmentMapper;
this.orgFirmProfileMapper = orgFirmProfileMapper;
this.orgFirmPracticeAreaMapper = orgFirmPracticeAreaMapper;
this.cardProfileMapper = cardProfileMapper;
this.cardProfileSpecialtyMapper = cardProfileSpecialtyMapper;
this.fileAssetMapper = fileAssetMapper;
this.fileAssetUsageMapper = fileAssetUsageMapper;
this.cardViewLogMapper = cardViewLogMapper;
this.cardShareLogMapper = cardShareLogMapper;
this.cardStatDailyMapper = cardStatDailyMapper;
this.passwordEncoder = passwordEncoder;
}
@Override
public List<TenantDetailResp> listTenants() {
List<TenantDO> tenants = tenantMapper.selectList(
Wrappers.<TenantDO>lambdaQuery().eq(TenantDO::getDeleted, 0).orderByDesc(TenantDO::getId)
);
if (tenants.isEmpty()) {
return List.of();
}
List<Long> tenantIds = tenants.stream().map(TenantDO::getId).toList();
List<TenantMiniappConfigDO> miniappConfigs = tenantMiniappConfigMapper.selectList(
Wrappers.<TenantMiniappConfigDO>lambdaQuery()
.in(TenantMiniappConfigDO::getTenantId, tenantIds)
.eq(TenantMiniappConfigDO::getDeleted, 0)
.eq(TenantMiniappConfigDO::getEnvCode, "PROD")
);
Map<Long, TenantMiniappConfigDO> miniappConfigMap = new HashMap<>();
for (TenantMiniappConfigDO miniappConfig : miniappConfigs) {
miniappConfigMap.put(miniappConfig.getTenantId(), miniappConfig);
}
return tenants.stream()
.map(tenant -> toResp(tenant, miniappConfigMap.get(tenant.getId())))
.toList();
}
@Override
public Optional<TenantDetailResp> getTenantById(Long tenantId) {
TenantDO tenant = tenantMapper.selectById(tenantId);
if (tenant == null || Integer.valueOf(1).equals(tenant.getDeleted())) {
return Optional.empty();
}
TenantMiniappConfigDO miniappConfig = tenantMiniappConfigMapper.selectOne(
Wrappers.<TenantMiniappConfigDO>lambdaQuery()
.eq(TenantMiniappConfigDO::getTenantId, tenantId)
.eq(TenantMiniappConfigDO::getEnvCode, "PROD")
.eq(TenantMiniappConfigDO::getDeleted, 0)
.last("LIMIT 1")
);
return Optional.of(toResp(tenant, miniappConfig));
}
@Override
@Transactional
public TenantDetailResp createTenant(CreateTenantCommand command) {
String tenantCode = StringUtils.hasText(command.tenantCode())
? command.tenantCode()
: generateTenantCode(command.tenantName(), command.miniappConfig() == null ? null : command.miniappConfig().miniappAppId());
validateTenantUniqueness(tenantCode, command.miniappConfig() == null ? null : command.miniappConfig().miniappAppId(), null);
TenantDO tenant = new TenantDO();
tenant.setTenantCode(tenantCode);
tenant.setTenantName(command.tenantName());
tenant.setTenantShortName(command.tenantShortName());
tenant.setContactName(command.contactName());
tenant.setContactPhone(command.contactPhone());
tenant.setContactEmail(command.contactEmail());
tenant.setTenantStatus("ENABLED");
tenant.setExpireAt(null);
tenant.setUserLimit(command.userLimit());
tenant.setStorageLimitMb(command.storageLimitMb());
tenant.setRemark(command.remark());
tenant.setCreatedBy(1L);
tenant.setUpdatedBy(1L);
tenant.setDeleted(0);
tenantMapper.insert(tenant);
createBuiltinRoles(tenant.getId());
createTenantAdminUser(tenant.getId(), command.adminUsername(), command.adminPassword(), command.tenantName());
TenantMiniappConfigDO miniappConfig = saveMiniappConfigInternal(tenant.getId(), command.miniappConfig(), null, 1L);
return toResp(tenant, miniappConfig);
}
@Override
@Transactional
public TenantDetailResp updateTenant(Long tenantId, UpdateTenantCommand command) {
TenantDO tenant = getRequiredTenant(tenantId);
String miniappAppId = StringUtils.hasText(command.miniappAppId()) ? command.miniappAppId().trim() : null;
validateTenantUniqueness(null, miniappAppId, tenantId);
tenant.setTenantName(command.tenantName());
tenant.setTenantShortName(command.tenantShortName());
tenant.setContactName(command.contactName());
tenant.setContactPhone(command.contactPhone());
tenant.setContactEmail(command.contactEmail());
tenant.setUserLimit(command.userLimit());
tenant.setStorageLimitMb(command.storageLimitMb());
tenant.setRemark(command.remark());
tenant.setUpdatedBy(1L);
tenantMapper.updateById(tenant);
TenantMiniappConfigDO miniappConfig = tenantMiniappConfigMapper.selectOne(Wrappers.<TenantMiniappConfigDO>lambdaQuery()
.eq(TenantMiniappConfigDO::getTenantId, tenantId)
.eq(TenantMiniappConfigDO::getEnvCode, "PROD")
.eq(TenantMiniappConfigDO::getDeleted, 0)
.last("LIMIT 1"));
if (miniappConfig != null || miniappAppId != null) {
miniappConfig = saveMiniappConfigInternal(
tenantId,
new MiniappConfigCommand(
miniappConfig == null ? "PROD" : miniappConfig.getEnvCode(),
miniappAppId,
miniappConfig == null ? null : miniappConfig.getMiniappAppSecret(),
miniappConfig == null ? tenant.getTenantName() + "电子名片" : miniappConfig.getMiniappName(),
miniappConfig == null ? null : miniappConfig.getMiniappOriginalId(),
miniappConfig == null ? null : miniappConfig.getRequestDomain(),
miniappConfig == null ? null : miniappConfig.getUploadDomain(),
miniappConfig == null ? null : miniappConfig.getDownloadDomain(),
miniappConfig == null ? "v1.0.0" : miniappConfig.getVersionTag(),
miniappConfig == null ? "DRAFT" : miniappConfig.getPublishStatus(),
miniappConfig == null ? null : miniappConfig.getRemark()
),
miniappConfig,
1L
);
}
return toResp(tenant, miniappConfig);
}
@Override
@Transactional
public void deleteTenant(Long tenantId) {
TenantDO tenant = getRequiredTenant(tenantId);
sysUserRoleMapper.delete(Wrappers.<SysUserRoleDO>lambdaQuery().eq(SysUserRoleDO::getTenantId, tenantId));
sysLoginLogMapper.delete(Wrappers.<SysLoginLogDO>lambdaQuery().eq(SysLoginLogDO::getTenantId, tenantId));
sysOperationLogMapper.delete(Wrappers.<SysOperationLogDO>lambdaQuery().eq(SysOperationLogDO::getTenantId, tenantId));
cardViewLogMapper.delete(Wrappers.<CardViewLogDO>lambdaQuery().eq(CardViewLogDO::getTenantId, tenantId));
cardShareLogMapper.delete(Wrappers.<CardShareLogDO>lambdaQuery().eq(CardShareLogDO::getTenantId, tenantId));
cardStatDailyMapper.delete(Wrappers.<CardStatDailyDO>lambdaQuery().eq(CardStatDailyDO::getTenantId, tenantId));
cardProfileSpecialtyMapper.deleteForceByTenantId(tenantId);
cardProfileMapper.delete(Wrappers.<CardProfileDO>lambdaQuery().eq(CardProfileDO::getTenantId, tenantId));
fileAssetUsageMapper.delete(Wrappers.<FileAssetUsageDO>lambdaQuery().eq(FileAssetUsageDO::getTenantId, tenantId));
fileAssetMapper.delete(Wrappers.<FileAssetDO>lambdaQuery().eq(FileAssetDO::getTenantId, tenantId));
orgDepartmentMapper.delete(Wrappers.<OrgDepartmentDO>lambdaQuery().eq(OrgDepartmentDO::getTenantId, tenantId));
orgFirmPracticeAreaMapper.delete(Wrappers.<OrgFirmPracticeAreaDO>lambdaQuery().eq(OrgFirmPracticeAreaDO::getTenantId, tenantId));
orgFirmProfileMapper.delete(Wrappers.<OrgFirmProfileDO>lambdaQuery().eq(OrgFirmProfileDO::getTenantId, tenantId));
sysUserMapper.delete(Wrappers.<SysUserDO>lambdaQuery().eq(SysUserDO::getTenantId, tenantId));
sysRoleMapper.delete(Wrappers.<SysRoleDO>lambdaQuery().eq(SysRoleDO::getTenantId, tenantId));
tenantMiniappConfigMapper.delete(Wrappers.<TenantMiniappConfigDO>lambdaQuery().eq(TenantMiniappConfigDO::getTenantId, tenantId));
tenantMapper.deleteById(tenant.getId());
}
@Override
public List<MiniappConfigView> listMiniappConfigs() {
List<TenantMiniappConfigDO> configs = tenantMiniappConfigMapper.selectList(Wrappers.<TenantMiniappConfigDO>lambdaQuery()
.eq(TenantMiniappConfigDO::getDeleted, 0)
.orderByDesc(TenantMiniappConfigDO::getId));
if (configs.isEmpty()) {
return List.of();
}
List<Long> tenantIds = configs.stream().map(TenantMiniappConfigDO::getTenantId).distinct().toList();
Map<Long, TenantDO> tenantMap = tenantMapper.selectBatchIds(tenantIds).stream()
.collect(HashMap::new, (map, item) -> map.put(item.getId(), item), HashMap::putAll);
return configs.stream().map(config -> {
TenantDO tenant = tenantMap.get(config.getTenantId());
return new MiniappConfigView(
config.getTenantId(),
tenant == null ? "" : tenant.getTenantCode(),
tenant == null ? "" : tenant.getTenantName(),
config.getEnvCode(),
config.getMiniappAppId(),
config.getMiniappName(),
config.getMiniappOriginalId(),
config.getRequestDomain(),
config.getUploadDomain(),
config.getDownloadDomain(),
config.getVersionTag(),
config.getPublishStatus()
);
}).toList();
}
@Override
@Transactional
public MiniappConfigView updateMiniappConfig(Long tenantId, MiniappConfigCommand command) {
TenantDO tenant = getRequiredTenant(tenantId);
validateTenantUniqueness(null, command.miniappAppId(), tenantId);
TenantMiniappConfigDO config = saveMiniappConfigInternal(tenantId, command, getTenantMiniappConfig(tenantId, command.envCode()), 1L);
return toMiniappConfigView(tenant, config);
}
@Override
public MiniappConfigView getCurrentTenantMiniappConfig() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
Long tenantId = getRequiredCurrentTenantId(loginUser);
TenantDO tenant = getRequiredTenant(tenantId);
return toMiniappConfigView(tenant, getTenantMiniappConfig(tenantId, "PROD"));
}
@Override
@Transactional
public MiniappConfigView updateCurrentTenantMiniappConfig(String miniappAppId) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
Long tenantId = getRequiredCurrentTenantId(loginUser);
TenantDO tenant = getRequiredTenant(tenantId);
String normalizedAppId = StringUtils.hasText(miniappAppId) ? miniappAppId.trim() : null;
validateTenantUniqueness(null, normalizedAppId, tenantId);
TenantMiniappConfigDO existed = getTenantMiniappConfig(tenantId, "PROD");
TenantMiniappConfigDO saved = saveMiniappConfigInternal(
tenantId,
new MiniappConfigCommand(
"PROD",
normalizedAppId,
existed == null ? null : existed.getMiniappAppSecret(),
existed == null ? tenant.getTenantName() + "电子名片" : existed.getMiniappName(),
existed == null ? null : existed.getMiniappOriginalId(),
existed == null ? null : existed.getRequestDomain(),
existed == null ? null : existed.getUploadDomain(),
existed == null ? null : existed.getDownloadDomain(),
existed == null ? "v1.0.0" : existed.getVersionTag(),
existed == null ? "DRAFT" : existed.getPublishStatus(),
existed == null ? null : existed.getRemark()
),
existed,
loginUser.userId()
);
return toMiniappConfigView(tenant, saved);
}
@Override
public Optional<TenantContext> resolveTenantContextByMiniappAppId(String miniappAppId) {
if (!StringUtils.hasText(miniappAppId)) {
return Optional.empty();
}
TenantMiniappConfigDO miniappConfig = tenantMiniappConfigMapper.selectOne(
Wrappers.<TenantMiniappConfigDO>lambdaQuery()
.eq(TenantMiniappConfigDO::getMiniappAppId, miniappAppId)
.eq(TenantMiniappConfigDO::getDeleted, 0)
.last("LIMIT 1")
);
if (miniappConfig == null) {
return Optional.empty();
}
TenantDO tenant = tenantMapper.selectById(miniappConfig.getTenantId());
if (tenant == null || Integer.valueOf(1).equals(tenant.getDeleted())) {
return Optional.empty();
}
return Optional.of(new TenantContext(tenant.getId(), tenant.getTenantCode(), miniappAppId));
}
private void validateTenantUniqueness(String tenantCode, String miniappAppId, Long currentTenantId) {
if (StringUtils.hasText(tenantCode)) {
TenantDO existedTenant = tenantMapper.selectOne(Wrappers.<TenantDO>lambdaQuery()
.eq(TenantDO::getTenantCode, tenantCode)
.eq(TenantDO::getDeleted, 0)
.last("LIMIT 1"));
if (existedTenant != null && !existedTenant.getId().equals(currentTenantId)) {
throw new BusinessException("TENANT_CODE_DUPLICATED", "租户编码已存在");
}
}
if (StringUtils.hasText(miniappAppId)) {
TenantMiniappConfigDO existedConfig = tenantMiniappConfigMapper.selectOne(Wrappers.<TenantMiniappConfigDO>lambdaQuery()
.eq(TenantMiniappConfigDO::getMiniappAppId, miniappAppId)
.eq(TenantMiniappConfigDO::getDeleted, 0)
.last("LIMIT 1"));
if (existedConfig != null && !existedConfig.getTenantId().equals(currentTenantId)) {
throw new BusinessException("MINIAPP_APP_ID_DUPLICATED", "小程序 AppID 已存在");
}
}
}
private TenantDO getRequiredTenant(Long tenantId) {
TenantDO tenant = tenantMapper.selectById(tenantId);
if (tenant == null || Integer.valueOf(1).equals(tenant.getDeleted())) {
throw new BusinessException("TENANT_NOT_FOUND", "租户不存在");
}
return tenant;
}
private Long getRequiredCurrentTenantId(LoginUser loginUser) {
if (loginUser.tenantId() == null) {
throw new BusinessException("TENANT_CONTEXT_MISSING", "当前账号未绑定租户");
}
return loginUser.tenantId();
}
private TenantMiniappConfigDO getTenantMiniappConfig(Long tenantId, String envCode) {
return tenantMiniappConfigMapper.selectOne(Wrappers.<TenantMiniappConfigDO>lambdaQuery()
.eq(TenantMiniappConfigDO::getTenantId, tenantId)
.eq(TenantMiniappConfigDO::getEnvCode, envCode)
.eq(TenantMiniappConfigDO::getDeleted, 0)
.last("LIMIT 1"));
}
private TenantMiniappConfigDO saveMiniappConfigInternal(Long tenantId, MiniappConfigCommand command, TenantMiniappConfigDO config, Long operatorId) {
if (command == null) {
return null;
}
if (config == null) {
config = new TenantMiniappConfigDO();
config.setTenantId(tenantId);
config.setCreatedBy(operatorId);
config.setDeleted(0);
}
config.setEnvCode(StringUtils.hasText(command.envCode()) ? command.envCode() : "PROD");
config.setMiniappAppId(StringUtils.hasText(command.miniappAppId()) ? command.miniappAppId().trim() : null);
config.setMiniappAppSecret(command.miniappAppSecret());
config.setMiniappName(command.miniappName());
config.setMiniappOriginalId(command.miniappOriginalId());
config.setRequestDomain(command.requestDomain());
config.setUploadDomain(command.uploadDomain());
config.setDownloadDomain(command.downloadDomain());
config.setVersionTag(command.versionTag());
config.setPublishStatus(command.publishStatus());
config.setRemark(command.remark());
config.setUpdatedBy(operatorId);
if ("PUBLISHED".equals(command.publishStatus())) {
config.setLastPublishedAt(LocalDateTime.now());
}
if (config.getId() == null) {
tenantMiniappConfigMapper.insert(config);
} else {
tenantMiniappConfigMapper.updateById(config);
}
return config;
}
private MiniappConfigView toMiniappConfigView(TenantDO tenant, TenantMiniappConfigDO config) {
if (config == null) {
return new MiniappConfigView(
tenant.getId(),
emptyIfNull(tenant.getTenantCode()),
emptyIfNull(tenant.getTenantName()),
"PROD",
"",
"",
"",
"",
"",
"",
"v1.0.0",
"UNCONFIGURED"
);
}
return new MiniappConfigView(
tenant.getId(),
emptyIfNull(tenant.getTenantCode()),
emptyIfNull(tenant.getTenantName()),
StringUtils.hasText(config.getEnvCode()) ? config.getEnvCode() : "PROD",
emptyIfNull(config.getMiniappAppId()),
emptyIfNull(config.getMiniappName()),
emptyIfNull(config.getMiniappOriginalId()),
emptyIfNull(config.getRequestDomain()),
emptyIfNull(config.getUploadDomain()),
emptyIfNull(config.getDownloadDomain()),
StringUtils.hasText(config.getVersionTag()) ? config.getVersionTag() : "v1.0.0",
StringUtils.hasText(config.getPublishStatus()) ? config.getPublishStatus() : "DRAFT"
);
}
private String emptyIfNull(String value) {
return value == null ? "" : value;
}
private void createBuiltinRoles(Long tenantId) {
insertRoleIfAbsent(tenantId, "TENANT_ADMIN", "租户管理员", "TENANT");
insertRoleIfAbsent(tenantId, "TENANT_USER", "普通用户", "SELF");
}
private void insertRoleIfAbsent(Long tenantId, String roleCode, String roleName, String dataScope) {
SysRoleDO existed = sysRoleMapper.selectOne(Wrappers.<SysRoleDO>lambdaQuery()
.eq(SysRoleDO::getTenantId, tenantId)
.eq(SysRoleDO::getRoleCode, roleCode)
.eq(SysRoleDO::getDeleted, 0)
.last("LIMIT 1"));
if (existed != null) {
return;
}
SysRoleDO role = new SysRoleDO();
role.setTenantId(tenantId);
role.setRoleScope("TENANT");
role.setRoleCode(roleCode);
role.setRoleName(roleName);
role.setDataScope(dataScope);
role.setIsBuiltin(1);
role.setRoleStatus("ENABLED");
role.setCreatedBy(1L);
role.setUpdatedBy(1L);
role.setDeleted(0);
sysRoleMapper.insert(role);
}
private void createTenantAdminUser(Long tenantId, String adminUsername, String adminPassword, String tenantName) {
String username = StringUtils.hasText(adminUsername) ? adminUsername.trim() : "tenant.admin";
String rawPassword = StringUtils.hasText(adminPassword) ? adminPassword : "123456";
SysUserDO existed = sysUserMapper.selectOne(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, tenantId)
.eq(SysUserDO::getUsername, username)
.eq(SysUserDO::getDeleted, 0)
.last("LIMIT 1"));
if (existed != null) {
return;
}
SysUserDO user = new SysUserDO();
user.setTenantId(tenantId);
user.setUserType("TENANT");
user.setUsername(username);
user.setPasswordHash(passwordEncoder.encode(rawPassword));
user.setRealName(resolveAdminDisplayName(tenantName));
user.setNickName(resolveAdminDisplayName(tenantName));
user.setGender("UNKNOWN");
user.setUserStatus("ENABLED");
user.setMustUpdatePassword(1);
user.setCreatedBy(1L);
user.setUpdatedBy(1L);
user.setDeleted(0);
sysUserMapper.insert(user);
SysRoleDO adminRole = sysRoleMapper.selectOne(Wrappers.<SysRoleDO>lambdaQuery()
.eq(SysRoleDO::getTenantId, tenantId)
.eq(SysRoleDO::getRoleCode, "TENANT_ADMIN")
.eq(SysRoleDO::getDeleted, 0)
.last("LIMIT 1"));
if (adminRole != null) {
SysUserRoleDO userRole = new SysUserRoleDO();
userRole.setTenantId(tenantId);
userRole.setUserId(user.getId());
userRole.setRoleId(adminRole.getId());
userRole.setCreatedBy(1L);
sysUserRoleMapper.insert(userRole);
}
}
private String generateTenantCode(String tenantName, String miniappAppId) {
if (StringUtils.hasText(miniappAppId)) {
return "tenant-" + miniappAppId.trim().toLowerCase().replaceAll("[^a-z0-9]", "");
}
return "tenant-" + System.currentTimeMillis();
}
private String resolveAdminDisplayName(String tenantName) {
if (!StringUtils.hasText(tenantName)) {
return "租户管理员";
}
return tenantName.length() > 24 ? tenantName.substring(0, 24) : tenantName;
}
private TenantDetailResp toResp(TenantDO tenant, TenantMiniappConfigDO miniappConfig) {
TenantDetailResp resp = new TenantDetailResp();
resp.setId(tenant.getId());
resp.setTenantCode(tenant.getTenantCode());
resp.setTenantName(tenant.getTenantName());
resp.setTenantShortName(tenant.getTenantShortName());
resp.setUserLimit(tenant.getUserLimit());
resp.setStorageLimitMb(tenant.getStorageLimitMb());
resp.setContactName(tenant.getContactName());
resp.setContactPhone(tenant.getContactPhone());
if (miniappConfig != null) {
resp.setMiniappAppId(miniappConfig.getMiniappAppId());
resp.setMiniappName(miniappConfig.getMiniappName());
resp.setMiniappOriginalId(miniappConfig.getMiniappOriginalId());
}
return resp;
}
}

View File

@@ -0,0 +1,105 @@
package com.easycard.module.tenant.vo;
public class TenantDetailResp {
private Long id;
private String tenantCode;
private String tenantName;
private String tenantShortName;
private Integer userLimit;
private Integer storageLimitMb;
private String contactName;
private String contactPhone;
private String miniappAppId;
private String miniappName;
private String miniappOriginalId;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getTenantCode() {
return tenantCode;
}
public void setTenantCode(String tenantCode) {
this.tenantCode = tenantCode;
}
public String getTenantName() {
return tenantName;
}
public void setTenantName(String tenantName) {
this.tenantName = tenantName;
}
public String getTenantShortName() {
return tenantShortName;
}
public void setTenantShortName(String tenantShortName) {
this.tenantShortName = tenantShortName;
}
public Integer getUserLimit() {
return userLimit;
}
public void setUserLimit(Integer userLimit) {
this.userLimit = userLimit;
}
public Integer getStorageLimitMb() {
return storageLimitMb;
}
public void setStorageLimitMb(Integer storageLimitMb) {
this.storageLimitMb = storageLimitMb;
}
public String getContactName() {
return contactName;
}
public void setContactName(String contactName) {
this.contactName = contactName;
}
public String getContactPhone() {
return contactPhone;
}
public void setContactPhone(String contactPhone) {
this.contactPhone = contactPhone;
}
public String getMiniappAppId() {
return miniappAppId;
}
public void setMiniappAppId(String miniappAppId) {
this.miniappAppId = miniappAppId;
}
public String getMiniappName() {
return miniappName;
}
public void setMiniappName(String miniappName) {
this.miniappName = miniappName;
}
public String getMiniappOriginalId() {
return miniappOriginalId;
}
public void setMiniappOriginalId(String miniappOriginalId) {
this.miniappOriginalId = miniappOriginalId;
}
}

View File

@@ -0,0 +1,70 @@
package com.easycard.module.tenant.web;
import com.easycard.common.api.ApiResponse;
import com.easycard.common.tenant.TenantContext;
import com.easycard.common.tenant.TenantContextHolder;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.easycard.module.tenant.service.PlatformTenantService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Optional;
@Component
public class MiniappTenantContextFilter extends OncePerRequestFilter {
private static final String MINIAPP_APP_ID_HEADER = "X-Miniapp-Appid";
private final PlatformTenantService platformTenantService;
private final ObjectMapper objectMapper;
public MiniappTenantContextFilter(PlatformTenantService platformTenantService, ObjectMapper objectMapper) {
this.platformTenantService = platformTenantService;
this.objectMapper = objectMapper;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
return !request.getRequestURI().startsWith("/api/open/");
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
String miniappAppId = request.getHeader(MINIAPP_APP_ID_HEADER);
if (!StringUtils.hasText(miniappAppId)) {
writeError(response, HttpServletResponse.SC_BAD_REQUEST,
ApiResponse.fail("MINIAPP_APP_ID_MISSING", "缺少请求头 X-Miniapp-Appid"));
return;
}
Optional<TenantContext> tenantContextOptional = platformTenantService.resolveTenantContextByMiniappAppId(miniappAppId);
if (tenantContextOptional.isEmpty()) {
writeError(response, HttpServletResponse.SC_NOT_FOUND,
ApiResponse.fail("TENANT_MINIAPP_NOT_FOUND", "未找到对应的小程序租户配置"));
return;
}
try {
TenantContextHolder.set(tenantContextOptional.get());
filterChain.doFilter(request, response);
} finally {
TenantContextHolder.clear();
}
}
private void writeError(HttpServletResponse response, int status, ApiResponse<Void> apiResponse) throws IOException {
response.setStatus(status);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write(objectMapper.writeValueAsString(apiResponse));
}
}

View File

@@ -0,0 +1,35 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
</parent>
<artifactId>easycard-module-user</artifactId>
<name>easycard-module-user</name>
<dependencies>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-common</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.easycard</groupId>
<artifactId>easycard-module-org</artifactId>
<version>${project.version}</version>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>

View File

@@ -0,0 +1,63 @@
package com.easycard.module.user.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.common.auth.SecurityUtils;
import com.easycard.common.web.ClientRequestUtils;
import com.easycard.module.user.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import org.springframework.web.bind.annotation.GetMapping;
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.RestController;
@RestController
@RequestMapping("/api/v1/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public ApiResponse<AuthService.LoginResult> login(@Valid @RequestBody LoginRequest request, HttpServletRequest servletRequest) {
return ApiResponse.success(authService.login(
request.username(),
request.password(),
ClientRequestUtils.getClientIp(servletRequest),
servletRequest.getHeader("User-Agent")
));
}
@PostMapping("/logout")
public ApiResponse<Void> logout() {
return ApiResponse.success("退出成功", null);
}
@GetMapping("/me")
public ApiResponse<AuthService.CurrentUserView> me() {
return ApiResponse.success(authService.getCurrentUser());
}
@PostMapping("/change-password")
public ApiResponse<Void> changePassword(@Valid @RequestBody ChangePasswordRequest request) {
authService.changePassword(request.oldPassword(), request.newPassword());
return ApiResponse.success("修改成功", null);
}
}
record LoginRequest(
@NotBlank(message = "账号不能为空") String username,
@NotBlank(message = "密码不能为空") String password
) {
}
record ChangePasswordRequest(
@NotBlank(message = "原密码不能为空") String oldPassword,
@NotBlank(message = "新密码不能为空") String newPassword
) {
}

View File

@@ -0,0 +1,110 @@
package com.easycard.module.user.controller;
import com.easycard.common.api.ApiResponse;
import com.easycard.module.user.service.TenantUserService;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Email;
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.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
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/users")
public class TenantUserController {
private final TenantUserService tenantUserService;
public TenantUserController(TenantUserService tenantUserService) {
this.tenantUserService = tenantUserService;
}
@GetMapping
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<List<TenantUserService.TenantUserView>> listUsers(@RequestParam(required = false) String keyword) {
return ApiResponse.success(tenantUserService.listUsers(keyword));
}
@PostMapping
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantUserService.TenantUserView> createUser(@Valid @RequestBody UpsertTenantUserRequest request) {
return ApiResponse.success(tenantUserService.createUser(request.toServiceRequest()));
}
@PutMapping("/{userId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantUserService.TenantUserView> updateUser(@PathVariable Long userId, @Valid @RequestBody UpsertTenantUserRequest request) {
return ApiResponse.success(tenantUserService.updateUser(userId, request.toServiceRequest()));
}
@PatchMapping("/{userId}/status")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantUserService.TenantUserView> updateUserStatus(@PathVariable Long userId, @Valid @RequestBody UpdateTenantUserStatusRequest request) {
return ApiResponse.success(tenantUserService.updateUserStatus(userId, request.userStatus()));
}
@PutMapping("/sort")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<Void> sortUsers(@Valid @RequestBody SortTenantUsersRequest request) {
tenantUserService.sortUsers(request.userIds());
return ApiResponse.success("排序已生效", null);
}
@PostMapping("/{userId}/reset-password")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','PLATFORM_SUPER_ADMIN')")
public ApiResponse<Void> resetPassword(@PathVariable Long userId) {
tenantUserService.resetPassword(userId);
return ApiResponse.success("重置成功", null);
}
@GetMapping("/{userId}")
@PreAuthorize("hasAnyAuthority('TENANT_ADMIN','TENANT_USER','PLATFORM_SUPER_ADMIN')")
public ApiResponse<TenantUserService.TenantUserView> getUser(@PathVariable Long userId) {
return ApiResponse.success(tenantUserService.getUser(userId));
}
}
record UpsertTenantUserRequest(
@NotBlank(message = "账号不能为空") String username,
@NotBlank(message = "姓名不能为空") String realName,
String mobile,
@Email(message = "邮箱格式错误") String email,
Long deptId,
String jobTitle,
@NotBlank(message = "状态不能为空") String userStatus,
@NotBlank(message = "角色不能为空") String roleCode
) {
TenantUserService.CreateOrUpdateUserRequest toServiceRequest() {
return new TenantUserService.CreateOrUpdateUserRequest(
username,
realName,
mobile,
email,
deptId,
jobTitle,
userStatus,
roleCode
);
}
}
record UpdateTenantUserStatusRequest(
@NotBlank(message = "状态不能为空") String userStatus
) {
}
record SortTenantUsersRequest(
@Size(min = 1, message = "排序成员不能为空") List<@NotNull(message = "成员ID不能为空") Long> userIds
) {
}

View File

@@ -0,0 +1,25 @@
package com.easycard.module.user.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("sys_login_log")
public class SysLoginLogDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long userId;
private String userType;
private String loginType;
private String loginStatus;
private String clientIp;
private String userAgent;
private String failReason;
private LocalDateTime loginAt;
}

View File

@@ -0,0 +1,30 @@
package com.easycard.module.user.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("sys_operation_log")
public class SysOperationLogDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long userId;
private String userName;
private String moduleName;
private String bizType;
private Long bizId;
private String operationType;
private String requestMethod;
private String requestUri;
private String requestBody;
private String responseCode;
private String responseMessage;
private String clientIp;
private LocalDateTime operatedAt;
}

View File

@@ -0,0 +1,31 @@
package com.easycard.module.user.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("sys_role")
public class SysRoleDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private String roleScope;
private String roleCode;
private String roleName;
private String dataScope;
private Integer isBuiltin;
private String roleStatus;
private String remark;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,41 @@
package com.easycard.module.user.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("sys_user")
public class SysUserDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private String userType;
private String username;
private String passwordHash;
private String realName;
private String nickName;
private String gender;
private String mobile;
private String email;
private Long avatarAssetId;
private Long deptId;
private String jobTitle;
private String userStatus;
private Integer memberSort;
private Integer mustUpdatePassword;
private LocalDateTime lastLoginAt;
private String lastLoginIp;
private String remark;
private Long createdBy;
private LocalDateTime createdTime;
private Long updatedBy;
private LocalDateTime updatedTime;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,21 @@
package com.easycard.module.user.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("sys_user_role")
public class SysUserRoleDO {
@TableId(type = IdType.AUTO)
private Long id;
private Long tenantId;
private Long userId;
private Long roleId;
private Long createdBy;
private LocalDateTime createdTime;
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysLoginLogDO;
public interface SysLoginLogMapper extends BaseMapper<SysLoginLogDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysOperationLogDO;
public interface SysOperationLogMapper extends BaseMapper<SysOperationLogDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysRoleDO;
public interface SysRoleMapper extends BaseMapper<SysRoleDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysUserDO;
public interface SysUserMapper extends BaseMapper<SysUserDO> {
}

View File

@@ -0,0 +1,7 @@
package com.easycard.module.user.dal.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.easycard.module.user.dal.entity.SysUserRoleDO;
public interface SysUserRoleMapper extends BaseMapper<SysUserRoleDO> {
}

View File

@@ -0,0 +1,186 @@
package com.easycard.module.user.service;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.easycard.common.auth.JwtTokenService;
import com.easycard.common.auth.LoginUser;
import com.easycard.common.auth.SecurityUtils;
import com.easycard.common.exception.BusinessException;
import com.easycard.module.org.dal.entity.OrgDepartmentDO;
import com.easycard.module.org.dal.mapper.OrgDepartmentMapper;
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 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.jdbc.core.JdbcTemplate;
import org.springframework.util.StringUtils;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class AuthService {
private final SysUserMapper sysUserMapper;
private final SysRoleMapper sysRoleMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final OrgDepartmentMapper orgDepartmentMapper;
private final JdbcTemplate jdbcTemplate;
private final PasswordEncoder passwordEncoder;
private final JwtTokenService jwtTokenService;
private final UserAuditService userAuditService;
@Value("${easycard.security.jwt-expire-hours:24}")
private long jwtExpireHours;
@Transactional
public LoginResult login(String username, String password, String clientIp, String userAgent) {
SysUserDO user = sysUserMapper.selectOne(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getUsername, username)
.eq(SysUserDO::getDeleted, 0)
.last("LIMIT 1"));
if (user == null) {
userAuditService.recordLogin(0L, null, "UNKNOWN", "FAIL", clientIp, userAgent, "账号不存在");
throw new BusinessException("AUTH_FAILED", "账号或密码错误");
}
if (!"ENABLED".equals(user.getUserStatus())) {
userAuditService.recordLogin(user.getTenantId(), user.getId(), user.getUserType(), "FAIL", clientIp, userAgent, "账号已停用");
throw new BusinessException("AUTH_FAILED", "账号已停用");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
userAuditService.recordLogin(user.getTenantId(), user.getId(), user.getUserType(), "FAIL", clientIp, userAgent, "密码错误");
throw new BusinessException("AUTH_FAILED", "账号或密码错误");
}
List<String> roleCodes = getRoleCodes(user.getId(), user.getTenantId());
LoginUser loginUser = new LoginUser(
user.getId(),
user.getTenantId(),
user.getUsername(),
user.getRealName(),
user.getUserType(),
roleCodes
);
user.setLastLoginAt(LocalDateTime.now());
user.setLastLoginIp(clientIp);
user.setUpdatedBy(user.getId());
sysUserMapper.updateById(user);
userAuditService.recordLogin(user.getTenantId(), user.getId(), user.getUserType(), "SUCCESS", clientIp, userAgent, null);
return new LoginResult(jwtTokenService.generateToken(loginUser, Duration.ofHours(jwtExpireHours)), buildCurrentUser(user, roleCodes));
}
public CurrentUserView getCurrentUser() {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getRequiredUser(loginUser.userId());
return buildCurrentUser(user, loginUser.roleCodes());
}
@Transactional
public void changePassword(String oldPassword, String newPassword) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getRequiredUser(loginUser.userId());
if (!passwordEncoder.matches(oldPassword, user.getPasswordHash())) {
throw new BusinessException("PASSWORD_INVALID", "原密码错误");
}
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setMustUpdatePassword(0);
user.setUpdatedBy(user.getId());
sysUserMapper.updateById(user);
userAuditService.recordOperation(user.getTenantId(), user.getId(), user.getRealName(), "AUTH", "PASSWORD", user.getId(), "CHANGE_PASSWORD");
}
public SysUserDO getRequiredUser(Long userId) {
SysUserDO user = sysUserMapper.selectById(userId);
if (user == null || Integer.valueOf(1).equals(user.getDeleted())) {
throw new BusinessException("USER_NOT_FOUND", "用户不存在");
}
return user;
}
public List<String> getRoleCodes(Long userId, Long tenantId) {
List<SysUserRoleDO> userRoles = sysUserRoleMapper.selectList(Wrappers.<SysUserRoleDO>lambdaQuery()
.eq(SysUserRoleDO::getUserId, userId)
.eq(SysUserRoleDO::getTenantId, tenantId));
if (userRoles.isEmpty()) {
return List.of();
}
List<Long> roleIds = userRoles.stream().map(SysUserRoleDO::getRoleId).toList();
return sysRoleMapper.selectList(Wrappers.<SysRoleDO>lambdaQuery()
.in(SysRoleDO::getId, roleIds)
.eq(SysRoleDO::getDeleted, 0)
.eq(SysRoleDO::getRoleStatus, "ENABLED"))
.stream()
.map(SysRoleDO::getRoleCode)
.toList();
}
private CurrentUserView buildCurrentUser(SysUserDO user, List<String> roleCodes) {
String tenantName = "平台";
if (user.getTenantId() != null && user.getTenantId() > 0) {
tenantName = resolveTenantName(user.getTenantId());
}
String roleName = roleCodes.isEmpty() ? "未分配角色" : roleCodes.get(0);
String deptName = "";
if (user.getDeptId() != null) {
OrgDepartmentDO department = orgDepartmentMapper.selectById(user.getDeptId());
deptName = department == null ? "" : department.getDeptName();
}
return new CurrentUserView(
user.getId(),
user.getTenantId(),
user.getUsername(),
user.getRealName(),
user.getUserType(),
tenantName,
roleName,
roleCodes,
deptName,
user.getJobTitle(),
user.getMustUpdatePassword() != null && user.getMustUpdatePassword() == 1
);
}
private String resolveTenantName(Long tenantId) {
List<String> tenantNames = jdbcTemplate.query(
"""
SELECT tenant_name
FROM sys_tenant
WHERE id = ?
AND deleted = 0
LIMIT 1
""",
(rs, rowNum) -> rs.getString("tenant_name"),
tenantId
);
String tenantName = tenantNames.isEmpty() ? "" : tenantNames.getFirst();
return StringUtils.hasText(tenantName) ? tenantName : "当前租户";
}
public record LoginResult(String accessToken, CurrentUserView currentUser) {
}
public record CurrentUserView(
Long id,
Long tenantId,
String username,
String realName,
String userType,
String tenantName,
String roleName,
List<String> roleCodes,
String deptName,
String jobTitle,
boolean mustUpdatePassword
) {
}
}

View File

@@ -0,0 +1,343 @@
package com.easycard.module.user.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.module.org.dal.entity.OrgDepartmentDO;
import com.easycard.module.org.dal.mapper.OrgDepartmentMapper;
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 lombok.RequiredArgsConstructor;
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.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
@Service
@RequiredArgsConstructor
public class TenantUserService {
private final SysUserMapper sysUserMapper;
private final SysRoleMapper sysRoleMapper;
private final SysUserRoleMapper sysUserRoleMapper;
private final OrgDepartmentMapper orgDepartmentMapper;
private final PasswordEncoder passwordEncoder;
private final UserAuditService userAuditService;
public List<TenantUserView> listUsers(String keyword) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
Long tenantId = loginUser.tenantId();
List<SysUserDO> users = sysUserMapper.selectList(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, tenantId)
.eq(SysUserDO::getDeleted, 0)
.like(StringUtils.hasText(keyword), SysUserDO::getRealName, keyword)
.orderByAsc(SysUserDO::getMemberSort)
.orderByDesc(SysUserDO::getId));
if (users.isEmpty()) {
return List.of();
}
Map<Long, OrgDepartmentDO> deptMap = orgDepartmentMapper.selectList(Wrappers.<OrgDepartmentDO>lambdaQuery()
.eq(OrgDepartmentDO::getTenantId, tenantId)
.eq(OrgDepartmentDO::getDeleted, 0))
.stream()
.collect(Collectors.toMap(OrgDepartmentDO::getId, Function.identity()));
Map<Long, String> roleCodeMap = loadPrimaryRoleMap(users, tenantId);
return users.stream().map(user -> new TenantUserView(
user.getId(),
user.getUsername(),
user.getRealName(),
user.getMobile(),
user.getEmail(),
user.getJobTitle(),
user.getUserStatus(),
user.getMemberSort(),
user.getDeptId(),
deptMap.containsKey(user.getDeptId()) ? deptMap.get(user.getDeptId()).getDeptName() : "",
roleCodeMap.getOrDefault(user.getId(), "")
)).toList();
}
@Transactional
public TenantUserView createUser(CreateOrUpdateUserRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
validateDepartment(loginUser.tenantId(), request.deptId());
SysUserDO existedUser = sysUserMapper.selectOne(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, loginUser.tenantId())
.eq(SysUserDO::getUsername, request.username())
.eq(SysUserDO::getDeleted, 0)
.last("LIMIT 1"));
if (existedUser != null) {
throw new BusinessException("USERNAME_DUPLICATED", "登录账号已存在");
}
validateUserStatus(request.userStatus());
SysRoleDO role = getRoleByCode(loginUser.tenantId(), request.roleCode());
SysUserDO user = new SysUserDO();
user.setTenantId(loginUser.tenantId());
user.setUserType("TENANT");
user.setUsername(request.username());
user.setPasswordHash(passwordEncoder.encode("123456"));
user.setRealName(request.realName());
user.setNickName(request.realName());
user.setGender("UNKNOWN");
user.setMobile(request.mobile());
user.setEmail(request.email());
user.setDeptId(request.deptId());
user.setJobTitle(request.jobTitle());
user.setUserStatus(request.userStatus());
user.setMemberSort(nextMemberSort(loginUser.tenantId()));
user.setMustUpdatePassword(1);
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);
userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "USER", "SYS_USER", user.getId(), "CREATE");
return getUser(user.getId());
}
@Transactional
public TenantUserView updateUser(Long userId, CreateOrUpdateUserRequest request) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getTenantUser(userId, loginUser.tenantId());
validateDepartment(loginUser.tenantId(), request.deptId());
validateUserStatus(request.userStatus());
SysRoleDO role = getRoleByCode(loginUser.tenantId(), request.roleCode());
user.setRealName(request.realName());
user.setNickName(request.realName());
user.setMobile(request.mobile());
user.setEmail(request.email());
user.setDeptId(request.deptId());
user.setJobTitle(request.jobTitle());
user.setUserStatus(request.userStatus());
user.setUpdatedBy(loginUser.userId());
sysUserMapper.updateById(user);
sysUserRoleMapper.delete(Wrappers.<SysUserRoleDO>lambdaQuery()
.eq(SysUserRoleDO::getTenantId, loginUser.tenantId())
.eq(SysUserRoleDO::getUserId, userId));
SysUserRoleDO userRole = new SysUserRoleDO();
userRole.setTenantId(loginUser.tenantId());
userRole.setUserId(user.getId());
userRole.setRoleId(role.getId());
userRole.setCreatedBy(loginUser.userId());
sysUserRoleMapper.insert(userRole);
userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "USER", "SYS_USER", user.getId(), "UPDATE");
return getUser(userId);
}
@Transactional
public TenantUserView updateUserStatus(Long userId, String userStatus) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
validateUserStatus(userStatus);
SysUserDO user = getTenantUser(userId, loginUser.tenantId());
if (userStatus.equals(user.getUserStatus())) {
return getUser(userId);
}
user.setUserStatus(userStatus);
user.setUpdatedBy(loginUser.userId());
sysUserMapper.updateById(user);
userAuditService.recordOperation(
loginUser.tenantId(),
loginUser.userId(),
loginUser.realName(),
"USER",
"SYS_USER",
user.getId(),
"UPDATE_STATUS"
);
return getUser(userId);
}
@Transactional
public void sortUsers(List<Long> userIds) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
if (userIds == null || userIds.isEmpty()) {
throw new BusinessException("USER_SORT_INVALID", "排序数据不能为空");
}
Set<Long> uniqueUserIds = new HashSet<>(userIds);
if (uniqueUserIds.size() != userIds.size()) {
throw new BusinessException("USER_SORT_INVALID", "排序数据存在重复成员");
}
List<SysUserDO> tenantUsers = sysUserMapper.selectList(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, loginUser.tenantId())
.eq(SysUserDO::getDeleted, 0)
.select(SysUserDO::getId));
Set<Long> expectedUserIds = tenantUsers.stream().map(SysUserDO::getId).collect(Collectors.toSet());
if (expectedUserIds.size() != uniqueUserIds.size() || !expectedUserIds.equals(uniqueUserIds)) {
throw new BusinessException("USER_SORT_INVALID", "排序数据必须包含当前租户全部成员");
}
Map<Long, SysUserDO> userMap = sysUserMapper.selectBatchIds(userIds).stream()
.filter(item -> loginUser.tenantId().equals(item.getTenantId()) && Integer.valueOf(0).equals(item.getDeleted()))
.collect(Collectors.toMap(SysUserDO::getId, Function.identity()));
for (int i = 0; i < userIds.size(); i++) {
Long sortedUserId = userIds.get(i);
SysUserDO user = userMap.get(sortedUserId);
if (user == null) {
throw new BusinessException("USER_SORT_INVALID", "成员不存在或不属于当前租户");
}
user.setMemberSort((i + 1) * 10);
user.setUpdatedBy(loginUser.userId());
sysUserMapper.updateById(user);
}
userAuditService.recordOperation(
loginUser.tenantId(),
loginUser.userId(),
loginUser.realName(),
"USER",
"SYS_USER",
0L,
"SORT"
);
}
@Transactional
public void resetPassword(Long userId) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getTenantUser(userId, loginUser.tenantId());
user.setPasswordHash(passwordEncoder.encode("123456"));
user.setMustUpdatePassword(1);
user.setUpdatedBy(loginUser.userId());
sysUserMapper.updateById(user);
userAuditService.recordOperation(loginUser.tenantId(), loginUser.userId(), loginUser.realName(), "USER", "SYS_USER", user.getId(), "RESET_PASSWORD");
}
public TenantUserView getUser(Long userId) {
LoginUser loginUser = SecurityUtils.getRequiredLoginUser();
SysUserDO user = getTenantUser(userId, loginUser.tenantId());
Map<Long, String> roleMap = loadPrimaryRoleMap(List.of(user), loginUser.tenantId());
String deptName = "";
if (user.getDeptId() != null) {
OrgDepartmentDO department = orgDepartmentMapper.selectById(user.getDeptId());
deptName = department == null ? "" : department.getDeptName();
}
return new TenantUserView(
user.getId(),
user.getUsername(),
user.getRealName(),
user.getMobile(),
user.getEmail(),
user.getJobTitle(),
user.getUserStatus(),
user.getMemberSort(),
user.getDeptId(),
deptName,
roleMap.getOrDefault(user.getId(), "")
);
}
private Map<Long, String> loadPrimaryRoleMap(List<SysUserDO> users, Long tenantId) {
List<Long> userIds = users.stream().map(SysUserDO::getId).toList();
List<SysUserRoleDO> userRoles = sysUserRoleMapper.selectList(Wrappers.<SysUserRoleDO>lambdaQuery()
.eq(SysUserRoleDO::getTenantId, tenantId)
.in(SysUserRoleDO::getUserId, userIds));
if (userRoles.isEmpty()) {
return Map.of();
}
List<Long> roleIds = userRoles.stream().map(SysUserRoleDO::getRoleId).distinct().toList();
Map<Long, SysRoleDO> roleMap = sysRoleMapper.selectList(Wrappers.<SysRoleDO>lambdaQuery()
.in(SysRoleDO::getId, roleIds)
.eq(SysRoleDO::getDeleted, 0))
.stream()
.collect(Collectors.toMap(SysRoleDO::getId, Function.identity()));
return userRoles.stream()
.sorted(Comparator.comparing(SysUserRoleDO::getRoleId))
.collect(Collectors.toMap(
SysUserRoleDO::getUserId,
item -> roleMap.containsKey(item.getRoleId()) ? roleMap.get(item.getRoleId()).getRoleCode() : "",
(left, right) -> left
));
}
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 void validateUserStatus(String userStatus) {
if (!"ENABLED".equals(userStatus) && !"DISABLED".equals(userStatus)) {
throw new BusinessException("USER_STATUS_INVALID", "成员状态仅支持启用或停用");
}
}
private Integer nextMemberSort(Long tenantId) {
SysUserDO lastUser = sysUserMapper.selectOne(Wrappers.<SysUserDO>lambdaQuery()
.eq(SysUserDO::getTenantId, tenantId)
.eq(SysUserDO::getDeleted, 0)
.orderByDesc(SysUserDO::getMemberSort)
.orderByDesc(SysUserDO::getId)
.last("LIMIT 1"));
int baseSort = lastUser == null || lastUser.getMemberSort() == null ? 0 : lastUser.getMemberSort();
return baseSort + 10;
}
private SysRoleDO getRoleByCode(Long tenantId, String roleCode) {
SysRoleDO role = sysRoleMapper.selectOne(Wrappers.<SysRoleDO>lambdaQuery()
.eq(SysRoleDO::getTenantId, tenantId)
.eq(SysRoleDO::getRoleCode, roleCode)
.eq(SysRoleDO::getDeleted, 0)
.last("LIMIT 1"));
if (role == null) {
throw new BusinessException("ROLE_NOT_FOUND", "角色不存在");
}
return role;
}
private SysUserDO getTenantUser(Long userId, Long tenantId) {
SysUserDO user = sysUserMapper.selectById(userId);
if (user == null || Integer.valueOf(1).equals(user.getDeleted()) || !tenantId.equals(user.getTenantId())) {
throw new BusinessException("USER_NOT_FOUND", "用户不存在");
}
return user;
}
public record CreateOrUpdateUserRequest(
String username,
String realName,
String mobile,
String email,
Long deptId,
String jobTitle,
String userStatus,
String roleCode
) {
}
public record TenantUserView(
Long id,
String username,
String realName,
String mobile,
String email,
String jobTitle,
String userStatus,
Integer memberSort,
Long deptId,
String deptName,
String roleCode
) {
}
}

View File

@@ -0,0 +1,48 @@
package com.easycard.module.user.service;
import com.easycard.module.user.dal.entity.SysLoginLogDO;
import com.easycard.module.user.dal.entity.SysOperationLogDO;
import com.easycard.module.user.dal.mapper.SysLoginLogMapper;
import com.easycard.module.user.dal.mapper.SysOperationLogMapper;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
@Service
public class UserAuditService {
private final SysLoginLogMapper sysLoginLogMapper;
private final SysOperationLogMapper sysOperationLogMapper;
public UserAuditService(SysLoginLogMapper sysLoginLogMapper, SysOperationLogMapper sysOperationLogMapper) {
this.sysLoginLogMapper = sysLoginLogMapper;
this.sysOperationLogMapper = sysOperationLogMapper;
}
public void recordLogin(Long tenantId, Long userId, String userType, String loginStatus, String clientIp, String userAgent, String failReason) {
SysLoginLogDO loginLog = new SysLoginLogDO();
loginLog.setTenantId(tenantId == null ? 0L : tenantId);
loginLog.setUserId(userId);
loginLog.setUserType(userType == null ? "TENANT" : userType);
loginLog.setLoginType("PASSWORD");
loginLog.setLoginStatus(loginStatus);
loginLog.setClientIp(clientIp);
loginLog.setUserAgent(userAgent);
loginLog.setFailReason(failReason);
loginLog.setLoginAt(LocalDateTime.now());
sysLoginLogMapper.insert(loginLog);
}
public void recordOperation(Long tenantId, Long userId, String userName, String moduleName, String bizType, Long bizId, String operationType) {
SysOperationLogDO operationLog = new SysOperationLogDO();
operationLog.setTenantId(tenantId == null ? 0L : tenantId);
operationLog.setUserId(userId);
operationLog.setUserName(userName);
operationLog.setModuleName(moduleName);
operationLog.setBizType(bizType);
operationLog.setBizId(bizId);
operationLog.setOperationType(operationType);
operationLog.setOperatedAt(LocalDateTime.now());
sysOperationLogMapper.insert(operationLog);
}
}

94
backend/pom.xml Normal file
View File

@@ -0,0 +1,94 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.easycard</groupId>
<artifactId>easycard-parent</artifactId>
<version>0.1.0-SNAPSHOT</version>
<packaging>pom</packaging>
<name>easycard-parent</name>
<description>Easycard backend parent project</description>
<modules>
<module>easycard-common</module>
<module>easycard-module-system</module>
<module>easycard-module-tenant</module>
<module>easycard-module-org</module>
<module>easycard-module-user</module>
<module>easycard-module-card</module>
<module>easycard-module-file</module>
<module>easycard-module-stat</module>
<module>easycard-boot</module>
</modules>
<properties>
<java.version>21</java.version>
<maven.compiler.release>${java.version}</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<spring.boot.version>3.3.13</spring.boot.version>
<mybatis.plus.version>3.5.7</mybatis.plus.version>
<springdoc.version>2.6.0</springdoc.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring.boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>${springdoc.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.34</version>
<scope>provided</scope>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>${java.version}</release>
<parameters>true</parameters>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
</plugins>
</pluginManagement>
</build>
</project>