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