diff --git a/.editorconfig b/.editorconfig index a845708aa..9e1d9a194 100644 --- a/.editorconfig +++ b/.editorconfig @@ -12,3 +12,4 @@ insert_final_newline = true [{*.java,*.sql}] indent_size = 4 tab_width = 4 +ij_java_use_single_class_imports = true diff --git a/.gitattributes b/.gitattributes index 2b2b007cf..cba35470b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,6 @@ /gradlew text eol=lf *.bat text eol=crlf +*.cmd text eol=crlf +*.sh text eol=lf *.jar binary * text=auto eol=lf diff --git a/CLAUDE.md b/CLAUDE.md index 69fc4c617..77e13891c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,441 +1,113 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## 框架定位 - -Compose Server 是一个现代化、模块化的 Kotlin 企业级服务端开发**框架**,而非脚手架。它通过 Gradle 多模块方式,提供安全、数据库、缓存、对象存储、支付、AI 等企业级能力,支持按需集成到任意 -Spring Boot 项目中。 - -## 构建和测试命令 - -这是一个基于 Gradle 的 Kotlin 多模块项目。 - -- `./gradlew build` - 构建整个项目 -- `./gradlew clean` - 清理构建输出 -- `./gradlew publishToMavenLocal` - 发布到本地 Maven 仓库 -- `./gradlew versionCatalogUpdate` - 更新版本目录中的依赖版本 +# CLAUDE.md - AI 助手系统提示 + +## 角色与身份 +你是一个通过上下文引擎访问 Compose Server 代码库的智能编程助手。 + +**框架概述:** Compose Server 是现代化、模块化的 Kotlin 企业级开发框架(非脚手架),通过 Gradle 多模块提供企业级能力。所有模块可独立集成到任意 Spring Boot 项目中。 + +**技术栈:** Kotlin 2.2.x, Spring Boot 3.5.x, Jimmer 0.9.x, Gradle 9.x, PostgreSQL, Redis, Caffeine, MinIO, 云服务。 + +## Token 效率指南 +- **优先简洁直接的回复** - 避免冗长解释 +- **批量相关工具调用** - 将多个信息请求合并为单次调用 +- **限制代码片段** 为关键行(`` 标签内最多 10 行) +- **使用高效工具序列** - 最小化冗余调用 + +## 模块结构与导航 +**包格式:** `io.github.truenine.composeserver.{模块名}` + +**核心模块:** +- `shared/` - 核心组件、工具类、异常处理、统一响应、分页 +- `meta/` - 元数据/注解处理 +- `rds/` - 数据库(Jimmer ORM、CRUD、PostgreSQL、Flyway) +- `surveillance/` - 监控 +- `security/` - Spring Security、OAuth2、加密 +- `oss/` - 对象存储(MinIO、云服务商) +- `pay/` - 支付(微信支付 V3) +- `cacheable/` - 多级缓存(Redis、Caffeine) + +**扩展模块:** +- `data/` - 数据处理(Excel、爬虫、行政区划) +- `depend/` - 依赖处理 +- `testtoolkit/` - 测试工具 +- `gradle-plugin/` - Gradle 插件 +- `ksp/` - Kotlin 符号处理 +- `sms/` - 短信服务 +- `mcp/` - AI 能力(LangChain4j、Ollama) + +**常用路径:** +- 构建文件:`{模块}/build.gradle.kts` +- 源码:`{模块}/src/main/kotlin/io/github/truenine/composeserver/{模块}/` +- 测试:`{模块}/src/test/kotlin/` +- 资源:`{模块}/src/main/resources/` + +## 构建命令 +- `./gradlew build` - 构建项目 +- `./gradlew clean` - 清理输出 +- `./gradlew publishToMavenLocal` - 本地发布 - `./gradlew test` - 运行所有测试 -- `./gradlew :模块名:test` - 运行特定模块的测试 -- `./gradlew spotlessCheck` - 检查代码格式 -- `./gradlew spotlessApply` - 自动修复代码格式 - -## 项目架构 - -### 模块化结构 - -本框架采用多模块设计,主要模块包括: - -- **shared** - 核心基础组件,包含通用工具类、异常处理、类型定义、统一响应、分页等 -- **meta** - 元数据和注解处理器 -- **rds** - 数据库相关(Jimmer ORM、CRUD、PostgreSQL 扩展、Flyway 迁移) -- **surveillance** - 监控组件 -- **security** - 安全相关(Spring Security、OAuth2、加密解密) -- **oss** - 对象存储(MinIO、阿里云 OSS、华为云 OBS) -- **pay** - 支付模块(微信支付 V3) -- **cacheable** - 多级缓存(Redis、Caffeine) -- **data** - 数据处理(EasyExcel、爬虫、行政区划等) -- **depend** - 特定依赖处理 -- **testtoolkit** - 测试工具包 -- **gradle-plugin** - Gradle 插件 -- **ksp** - Kotlin Symbol Processing -- **sms** - 短信服务(腾讯云短信,短信抽象层) -- **mcp** - AI 能力(LangChain4j、Ollama、智谱 AI) - -> 所有模块均可独立集成,推荐组合见下表。 - -### 推荐模块组合 - -| 使用场景 | 推荐模块组合 | -|------------|---------------------------------| -| 基础 Web API | shared + security-spring | -| 数据库操作 | shared + rds-shared + rds-crud | -| 文件存储 | shared + oss-shared + oss-minio | -| 微信支付 | shared + pay | -| 数据导入导出 | shared + data-extract | -| AI 能力 | shared + mcp | - -## 技术栈 - -- **Kotlin** 2.2.x -- **Spring Boot** 3.5.x -- **Jimmer** 0.9.x -- **Gradle** 9.x -- **PostgreSQL**、**Redis**、**Caffeine**、**MinIO**、**阿里云 OSS**、**华为云 OBS** 等 - -## 依赖管理 - -- 统一使用 Gradle Version Catalog(`gradle/libs.versions.toml`)管理依赖版本 -- 所有模块版本、groupId 通过根项目统一管理 -- 推荐通过 `publishToMavenLocal` 集成本地开发版本 +- `./gradlew :{模块}:test` - 模块特定测试 +- `./gradlew spotlessApply` - 修复格式(提交前运行) +- `./gradlew versionCatalogUpdate` - 更新依赖 -## 代码约定 - -- 所有模块使用 `kotlinspring-convention` 插件,集成 Spring Boot 与 Kotlin 规范 -- 包名格式:`io.github.truenine.composeserver.模块名` - -- 代码格式化:使用 Spotless,提交前请运行 `./gradlew spotlessApply` -- 数据库迁移:使用 Flyway,脚本位于 `rds/flyway-migration-数据库类型/src/main/resources/db/migration/`,命名规则 `V版本号__描述.sql` - -## 集成与最佳实践 - -1. **依赖引入** - 在业务项目的 `build.gradle.kts` 中按需添加依赖,例如: - ```kotlin - implementation("io.github.truenine:composeserver-shared:latest") - implementation("io.github.truenine:composeserver-rds-shared:latest") - implementation("io.github.truenine:composeserver-security-spring:latest") - ``` -2. **自动配置** - 启用自动配置注解(如有): - ```kotlin - @SpringBootApplication - @EnableComposeServer - class YourApplication - ``` -3. **统一响应、异常、分页等** - 推荐使用框架内置的统一响应、异常处理、分页等能力,详见 `shared` 模块。 - -4. **测试与发布** - -- 修改代码后先格式化,再运行测试,最后构建或发布到本地 Maven 仓库 -- 推荐使用 `./gradlew test`、`./gradlew build`、`./gradlew publishToMavenLocal` - -## 其他说明 - -- 本项目为**框架库**,不包含脚手架或项目初始化功能 -- 所有模块均已发布至 Maven Central,详见 [README.md] 或 [Maven Central](https://central.sonatype.com/search?q=g:io.github.truenine) -- 详细 API、集成示例、变更日志等请参考 [README.md] 和官方文档 +## 开发标准 +- **依赖管理:** Gradle Version Catalog (`gradle/libs.versions.toml`) +- **插件约定:** 所有模块使用 `kotlinspring-convention` +- **代码格式:** Spotless(提交前必须) +- **测试命名:** 与被测试类同名,移除 `@DisplayName` 注解 +- **集成:** 在 build.gradle.kts 中添加 `implementation("io.github.truenine:composeserver-{模块}:latest")` ## Git 提交规范 -### 提交消息格式 - -本项目采用表情符号 + 英文 Conventional Commits 格式,支持详细功能列表: - -### 基础格式 - -``` -emoji [scope] description -``` - -### 详细列表格式(可选) - -``` -emoji [scope] main feature description - -- emoji specific change 1 -- emoji specific change 2 -- emoji specific change 3 -``` - -**格式说明:** - -- `emoji`:从下表中选择合适的表情符号 -- `[scope]`:模块或功能范围,用方括号包围 -- `description`:简洁的功能描述(支持中英文) -- **详细列表**:当改动较多时,可添加表情符号格式的具体变更列表 - -**使用原则:** - -- 🎯 **简单改动**:仅使用基础格式 -- 📋 **复杂改动**:使用详细列表格式,每项变更前加对应表情符号 -- 🌐 **语言选择**:描述支持中英文,根据团队习惯选择 - -**基础格式示例:** - -``` -✨ [shared] add unified exception handling -🐛 [rds] fix connection pool issue -♻️ [security] refactor JWT validation -``` - -**详细列表格式示例:** - -``` -✨ [shared] 日志功能 - -- 🚑 紧急修复日志级别 -- 🐛 依赖倒转缺失问题 -- 💄 日志框架进行 inline 处理 - -🔧 [buildlogic] 构建系统优化 - -- ⚡ 提升编译性能 -- 🔧 更新 Gradle 配置 -- 📦 优化依赖管理 -- 🚨 修复 Spotless 检查 - -♻️ [security] 安全模块重构 - -- 🏗️ 重构认证流程 -- 🔐 增强权限控制 -- 🛡️ 修复安全漏洞 -- 📈 添加安全监控 -``` - -### 表情符号参考表 - -| 表情符号 | 类型 | 描述 | 使用场景 | -|------|-----------|----------|----------------------| -| 🎉 | feat | 新功能/重大更新 | 添加新功能、重大特性、项目初始化 | -| ✨ | feat | 新功能/增强 | 添加新特性、功能增强、文档更新 | -| 🐛 | fix | Bug 修复 | 修复错误、解决问题 | -| 🔧 | config | 配置修改 | 修改配置文件、CI/CD 配置、构建配置 | -| 📝 | docs | 文档更新 | 更新文档、README、注释 | -| 🎨 | style | 代码风格/格式化 | 代码格式化、样式调整、结构优化 | -| ♻️ | refactor | 重构 | 代码重构、包结构调整、命名空间变更 | -| ⚡ | perf | 性能优化 | 提升性能、优化算法 | -| 🔥 | remove | 删除代码/文件 | 删除无用代码、移除功能、清理文件 | -| 🧪 | test | 测试相关 | 添加测试、修复测试、测试配置 | -| 👷 | ci | CI/CD 相关 | 持续集成、构建脚本、部署配置 | -| 📦 | build | 构建系统 | 依赖管理、构建配置、打包相关 | -| ⬆️ | upgrade | 升级依赖版本 | 升级依赖库版本、更新第三方库 | -| ⬇️ | downgrade | 降级依赖版本 | 降级依赖库版本、回退版本 | -| 🚀 | release | 发布版本 | 版本发布、标签创建 | -| 🔀 | merge | 合并分支 | 分支合并、冲突解决 | -| 🤖 | ai | AI 工具配置 | AI 助手配置、自动化工具 | -| 💄 | optimize | 优化/性能优化 | 性能优化、代码优化、算法改进 | -| 🌐 | network | 网络相关 | 网络配置、API 调用、远程服务 | -| 🔐 | security | 安全/防御编程 | 安全修复、权限控制、格式校验、防御编程 | -| 🚑 | hotfix | 紧急修复 | 紧急修复、补救措施、临时解决方案 | -| 📈 | analytics | 分析/监控 | 性能监控、数据分析、日志记录 | -| 🍱 | assets | 资源文件 | 图片、字体、静态资源 | -| 🚨 | lint | 代码检查 | 修复 linting 警告、代码质量 | -| 💡 | comment | 注释 | 添加或更新注释、文档字符串 | -| 🔊 | log | 日志 | 添加日志、调试信息 | -| 🔇 | log | 移除日志 | 删除日志、静默输出 | - -### 功能类型详细说明 - -#### 🎉 重大功能 (Major Features) - -- 🚀 项目初始化和重大里程碑 -- 📦 新模块或子系统的引入 -- 🏗️ 架构重大变更 - -#### ✨ 新功能 (Features) - -- 🔌 新增 API 接口 -- 🛠️ 新增工具类或组件 -- ⚡ 功能增强和改进 - -#### 🐛 Bug 修复 (Bug Fixes) - -- 🔨 修复功能错误 -- 🔄 解决兼容性问题 -- 🛡️ 修复安全漏洞 - -#### ♻️ 重构 (Refactoring) - -- 🏗️ 代码结构优化 -- 📁 包名或命名空间调整 -- 🎯 设计模式应用 - -#### 🔧 配置 (Configuration) - -- ⚙️ Gradle 构建配置 -- 🔄 CI/CD 流水线配置 -- 📋 环境配置文件 - -#### 📝 文档 (Documentation) - -- 📖 README 更新 -- 📚 API 文档 -- 💬 代码注释完善 - -#### 💄 优化 (Optimization) - -- ⚡ 性能优化 -- 🔧 代码优化 -- 📈 算法改进 - -#### 🌐 网络 (Network) - -- 🔌 API 接口调用 -- 🌍 远程服务集成 -- 📡 网络配置 - -#### 🔐 安全/防御编程 (Security & Defensive Programming) - -- 🛡️ 安全修复 -- 🔒 权限控制 -- ✅ 格式校验 -- 🛠️ 防御编程 - -#### 🚑 紧急修复 (Hotfix) - -- 🔥 紧急修复 -- 🩹 补救措施 -- ⚡ 临时解决方案 - -### Scope 范围说明 - -使用方括号 `[]` 包围 scope,从以下类别中选择合适的范围: - -#### 🏗️ 模块名称 (Module Names) - -``` -[shared] [rds] [security] [oss] -[pay] [cacheable] [data] [testtoolkit] -[surveillance][meta] [depend] [gradle-plugin] -[ksp] [sms] [mcp] -``` - -#### 🎯 功能领域 (Functional Areas) - -``` -[auth] [api] [db] [cache] -[config] [test] [build] [ci] -[docs] [core] [utils] [validation] -[logging] [monitoring] [migration] [integration] -``` - -#### 🔧 特定组件 (Specific Components) - -``` -[gradle] [buildlogic] [deps] [publish] -[release] [claude] [cursor] [spotless] -[flyway] [jimmer] [spring] [kotlin] -[docker] [k8s] [helm] [terraform] -``` - -#### 🌐 技术栈 (Technology Stack) - -``` -[postgresql] [redis] [minio] [oauth2] -[jwt] [wechat] [aliyun] [huawei] -[tencent] [ollama] [langchain] [ai] -``` - -#### 📁 文件类型 (File Types) - -``` -[readme] [changelog] [license] [gitignore] -[dockerfile] [compose] [makefile] [scripts] -``` - -### 提交规范要求 - -1. **表情符号使用**:每个提交必须以合适的表情符号开头,详细列表中每项也需要对应表情符号 -2. **消息长度**:标题行不超过 72 个字符,详细列表每行不超过 50 个字符 -3. **语言规范**:描述支持中英文,保持团队一致性 -4. **列表使用**: - -- 简单改动(1-2个变更):使用基础格式 -- 复杂改动(3个以上变更):推荐使用详细列表格式 -- 列表项按重要性或逻辑顺序排列 - -5. **原子性**:每个提交应该是一个逻辑上完整的变更单元 -6. **测试验证**:提交前确保代码通过测试和格式检查 - -### 提交示例 - -#### 基础格式示例 - -```bash -# 简单新功能 -git commit -m "✨ [shared] add unified exception handling" -git commit -m "🐛 [rds] fix connection pool issue" -git commit -m "♻️ [security] refactor JWT validation" -git commit -m "💄 [cache] optimize Redis performance" -git commit -m "🌐 [api] integrate third-party service" -git commit -m "🔐 [validation] add input format validation" -git commit -m "🚑 [auth] emergency fix for login issue" -git commit -m "📝 [docs] update API documentation" -``` - -#### 详细列表格式示例 - -```bash -# 复杂功能开发 -git commit -m "✨ [shared] 日志功能 - -- 🚑 紧急修复日志级别 -- 🐛 依赖倒转缺失问题 -- 💄 日志框架进行 inline 处理" - -# 性能优化 -git commit -m "💄 [cache] 缓存系统优化 - -- ⚡ 提升 Redis 连接性能 -- 🔧 优化缓存策略 -- 📈 改进缓存命中率 -- 🚨 修复内存泄漏问题" - -# 网络集成 -git commit -m "🌐 [api] 第三方服务集成 - -- 🔌 集成微信支付 API -- 📡 配置网络代理 -- 🛠️ 添加重试机制 -- 📊 网络监控埋点" - -# 安全防御编程 -git commit -m "🔐 [security] 安全防护增强 - -- 🛡️ 修复 SQL 注入漏洞 -- ✅ 添加输入格式校验 -- 🔒 增强权限控制 -- 🛠️ 防御性编程实践" - -# 多模块测试更新 -git commit -m "🧪 [testtoolkit] 测试框架升级 - -- 🔧 更新 JUnit 配置 -- 📦 集成 Testcontainers -- ⚡ 优化测试性能 -- 📊 添加覆盖率报告" - -# 版本发布和依赖管理 -git commit -m "🚀 [release] version 0.0.5" -git commit -m "⬆️ [deps] upgrade Spring Boot to 3.5.0" -git commit -m "⬇️ [deps] downgrade Kotlin for compatibility" - -# 清理和删除 -git commit -m "🔥 [buildlogic] remove unused imports" -git commit -m "🔥 [EmptyDefault] remove related definitions" - -# AI 工具配置 -git commit -m "🤖 [claude] add AI configuration" -git commit -m "🤖 [lingma] add Tongyi Lingma support" -``` - -#### 混合格式示例 - +**格式:** `emoji [scope] description`(简单)或详细列表格式(3+ 变更) + +**完整表情符号系统:** +| 表情符号 | 类型 | 描述 | 使用场景 | +|---------|------|------|----------| +| 🎉 | feat | 重大功能/初始化 | 新功能、重大更新、项目初始化 | +| ✨ | feat | 新功能/增强 | 添加功能、增强、文档更新 | +| 🐛 | fix | Bug 修复 | 修复错误、解决问题 | +| 🔧 | config | 配置修改 | 配置文件、CI/CD、构建配置 | +| 📝 | docs | 文档更新 | 更新文档、README、注释 | +| 🎨 | style | 代码风格/格式化 | 代码格式化、样式、结构优化 | +| ♻️ | refactor | 重构 | 代码重构、包结构调整 | +| ⚡ | perf | 性能优化 | 性能优化、算法改进 | +| 🔥 | remove | 删除代码/文件 | 删除无用代码、移除功能 | +| 🧪 | test | 测试相关 | 添加测试、修复测试、测试配置 | +| 👷 | ci | CI/CD | 持续集成、构建脚本 | +| 📦 | build | 构建系统 | 依赖管理、构建配置 | +| ⬆️ | upgrade | 升级依赖 | 升级库版本 | +| ⬇️ | downgrade | 降级依赖 | 降级库版本 | +| 🚀 | release | 发布版本 | 版本发布、标签创建 | +| 🔀 | merge | 合并分支 | 分支合并、冲突解决 | +| 🤖 | ai | AI 工具配置 | AI 助手配置、自动化 | +| 💄 | optimize | 优化 | 性能优化、代码改进 | +| 🌐 | network | 网络相关 | 网络配置、API 调用、远程服务 | +| 🔐 | security | 安全/验证 | 安全修复、权限控制、验证 | +| 🚑 | hotfix | 紧急修复 | 紧急修复、临时解决方案 | +| 📈 | analytics | 分析/监控 | 性能监控、数据分析 | +| 🍱 | assets | 资源文件 | 图片、字体、静态资源 | +| 🚨 | lint | 代码检查 | 修复 linting 警告、代码质量 | +| 💡 | comment | 注释 | 添加/更新注释、文档字符串 | +| 🔊 | log | 日志 | 添加日志、调试信息 | +| 🔇 | log | 移除日志 | 删除日志、静默输出 | + +**作用域:** `[shared]` `[rds]` `[security]` `[oss]` `[pay]` `[cacheable]` `[data]` `[surveillance]` `[meta]` `[depend]` `[testtoolkit]` `[gradle-plugin]` `[ksp]` `[sms]` `[mcp]` `[auth]` `[api]` `[db]` `[cache]` `[config]` `[test]` `[build]` `[ci]` `[docs]` `[core]` `[utils]` `[validation]` `[gradle]` `[buildlogic]` `[deps]` `[spotless]` `[flyway]` `[jimmer]` `[spring]` `[kotlin]` + +**规则:** +- 简单变更(1-2个):基础格式 +- 复杂变更(3+个):详细列表格式 +- 原子性提交,提交前测试 +- 提交前运行 `./gradlew spotlessApply` + +**示例:** ```bash -# 大版本发布(包含多项重要变更) -git commit -m "🚀 [release] version 1.0.0 - -- ✨ 新增统一异常处理 -- 🔐 增强安全防护机制 -- 💄 优化数据库连接池性能 -- 🌐 集成第三方支付服务 -- 📝 完善 API 文档 -- 🧪 添加集成测试套件" - -# 紧急修复(包含多个相关问题) -git commit -m "🚑 [security] 紧急安全修复 - -- 🛡️ 修复 JWT 验证漏洞 -- 🔐 增强输入格式校验 -- ✅ 添加防御性编程检查 -- 📈 添加安全审计日志 -- 🚨 更新安全检查规则" - -# 性能优化专项(多个优化措施) -git commit -m "💄 [performance] 系统性能优化 - -- ⚡ 优化数据库查询性能 -- 🔧 改进缓存算法 -- 📈 提升 API 响应速度 -- 🛠️ 优化内存使用" - -# 网络功能增强 -git commit -m "🌐 [network] 网络功能增强 - -- 🔌 集成新的 API 服务 -- 📡 优化网络连接配置 -- 🛠️ 添加网络异常处理 -- 📊 网络性能监控" +✨ [shared] 添加统一异常处理 +🐛 [rds] 修复连接池问题 +♻️ [security] 重构 JWT 验证 + +✨ [shared] 日志系统 +- 🚑 修复日志级别紧急问题 +- 🐛 解决依赖倒转问题 +- 💄 优化日志框架内联处理 ``` diff --git a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/EasyExcelFns.kt b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/EasyExcelFns.kt index 8a5b3b343..38287f9ae 100644 --- a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/EasyExcelFns.kt +++ b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/EasyExcelFns.kt @@ -4,31 +4,124 @@ import com.alibaba.excel.EasyExcel import com.alibaba.excel.context.AnalysisContext import com.alibaba.excel.read.builder.ExcelReaderBuilder import com.alibaba.excel.read.listener.ReadListener -import java.util.concurrent.CopyOnWriteArrayList +import io.github.truenine.composeserver.slf4j +import java.util.concurrent.atomic.AtomicInteger import org.springframework.web.multipart.MultipartFile -inline fun MultipartFile.readExcelList(readFn: (readerBuilder: ExcelReaderBuilder) -> Unit = { r -> r.sheet().doRead() }): List { - val dataList = CopyOnWriteArrayList() +private val log = slf4j() - val e = +/** + * Optimized Excel reading extension function for MultipartFile. + * + * Performance improvements: + * - Uses ArrayList instead of CopyOnWriteArrayList for better memory efficiency + * - Implements streaming processing to handle large files + * - Adds proper error handling and logging + * - Pre-allocates collection capacity when possible + * + * @param T The type of data objects to read from Excel + * @param readFn Custom read function for advanced Excel processing + * @param initialCapacity Initial capacity hint for the result list (default: 1000) + * @return List of parsed data objects + * @throws IllegalStateException if Excel reading fails + */ +fun MultipartFile.readExcelList( + clazz: Class, + readFn: (readerBuilder: ExcelReaderBuilder) -> Unit = { r -> r.sheet().doRead() }, + initialCapacity: Int = 1000, +): List { + val dataList = ArrayList(initialCapacity) + val processedCount = AtomicInteger(0) + + log.debug("Starting Excel processing for file: {} (size: {} bytes)", this.originalFilename, this.size) + + val reader = try { EasyExcel.read( - inputStream, - T::class.java, + this.inputStream, + clazz, object : ReadListener { override fun invoke(data: T?, context: AnalysisContext?) { - data?.let { dataList += it } + data?.let { + dataList.add(it) + val count = processedCount.incrementAndGet() + if (count % 1000 == 0) { + log.debug("Processed {} rows from Excel file", count) + } + } } override fun doAfterAllAnalysed(context: AnalysisContext?) { - // do nothing + log.debug("Excel processing completed. Total rows processed: {}", processedCount.get()) } }, ) - } catch (ex: Throwable) { - ex.printStackTrace() - null + } catch (ex: Exception) { + log.error("Failed to create Excel reader for file: {}", this.originalFilename, ex) + throw IllegalStateException("Excel reading failed: ${ex.message}", ex) } - if (null != e) readFn(e) + + try { + readFn(reader) + } catch (ex: Exception) { + log.error("Failed to read Excel data from file: {}", this.originalFilename, ex) + throw IllegalStateException("Excel data reading failed: ${ex.message}", ex) + } + + log.info("Successfully processed Excel file: {} with {} rows", this.originalFilename, dataList.size) return dataList } + +/** Reified version of readExcelList for easier usage. */ +inline fun MultipartFile.readExcelList( + noinline readFn: (readerBuilder: ExcelReaderBuilder) -> Unit = { r -> r.sheet().doRead() }, + initialCapacity: Int = 1000, +): List = readExcelList(T::class.java, readFn, initialCapacity) + +/** + * Streaming Excel reader for processing large files without loading all data into memory. + * + * This function processes Excel data row by row, making it suitable for very large files where memory usage is a concern. + * + * @param T The type of data objects to read from Excel + * @param processor Function to process each row as it's read + * @param batchSize Number of rows to process in each batch (default: 100) + * @throws IllegalStateException if Excel reading fails + */ +fun MultipartFile.processExcelStream(clazz: Class, processor: (data: T, rowIndex: Int) -> Unit, batchSize: Int = 100) { + val processedCount = AtomicInteger(0) + + log.debug("Starting streaming Excel processing for file: {}", this.originalFilename) + + try { + EasyExcel.read( + this.inputStream, + clazz, + object : ReadListener { + override fun invoke(data: T?, context: AnalysisContext?) { + data?.let { + val rowIndex = processedCount.getAndIncrement() + processor(it, rowIndex) + + if (rowIndex % 1000 == 0) { + log.debug("Streamed {} rows from Excel file", rowIndex + 1) + } + } + } + + override fun doAfterAllAnalysed(context: AnalysisContext?) { + log.debug("Streaming Excel processing completed. Total rows: {}", processedCount.get()) + } + }, + ) + .sheet() + .doRead() + } catch (ex: Exception) { + log.error("Failed to stream Excel data from file: {}", this.originalFilename, ex) + throw IllegalStateException("Excel streaming failed: ${ex.message}", ex) + } +} + +/** Reified version of processExcelStream for easier usage. */ +inline fun MultipartFile.processExcelStream(noinline processor: (data: T, rowIndex: Int) -> Unit, batchSize: Int = 100) = + processExcelStream(T::class.java, processor, batchSize) diff --git a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/domain/CnDistrictCode.kt b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/domain/CnDistrictCode.kt index 6c53acdcd..91f989df2 100644 --- a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/domain/CnDistrictCode.kt +++ b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/domain/CnDistrictCode.kt @@ -1,17 +1,23 @@ package io.github.truenine.composeserver.data.extract.domain /** - * # 中国行政区编码 - * 12 省 34 市 56 区县 789 乡镇 10,11,12 村庄 + * Chinese administrative district code representation following the 12-digit national standard. * - * @property code 原始输入的行政区编码 - * @property padCode 补全后的12位行政区编码 - * @property provinceCode 省级编码(2位) - * @property cityCode 市级编码(2位) - * @property countyCode 区县编码(2位) - * @property townCode 乡镇编码(3位) - * @property villageCode 村庄编码(3位) - * @property empty 是否为空编码 + * The code structure: [Province(2)][City(2)][County(2)][Town(3)][Village(3)] Example: 110101001001 represents a specific village in Beijing Dongcheng District. + * + * This implementation optimizes memory usage and computation performance by: + * - Caching level calculations to avoid repeated computation + * - Using efficient string operations with pre-allocated StringBuilder + * - Validating input format early to fail fast + * + * @property code Original input administrative district code + * @property padCode Padded 12-digit administrative district code + * @property provinceCode Province code (2 digits) + * @property cityCode City code (2 digits) + * @property countyCode County code (2 digits) + * @property townCode Town code (3 digits) + * @property villageCode Village code (3 digits) + * @property empty Whether this represents an empty/null district code */ data class CnDistrictCode( val code: String, @@ -35,29 +41,50 @@ data class CnDistrictCode( private val INVALID_LENGTHS = setOf(1, 3, 5, 7, 8, 10, 11) - // 工厂方法,替代原来的主构造函数逻辑 + // Pre-computed level boundaries for performance optimization + private val LEVEL_BOUNDARIES = + intArrayOf( + PROVINCE_LENGTH, + PROVINCE_LENGTH + CITY_LENGTH, + PROVINCE_LENGTH + CITY_LENGTH + COUNTY_LENGTH, + PROVINCE_LENGTH + CITY_LENGTH + COUNTY_LENGTH + TOWN_LENGTH, + FULL_LENGTH, + ) + + /** + * Factory method to create CnDistrictCode instance with optimized parsing. + * + * Performance optimizations: + * - Early validation to fail fast on invalid input + * - Single-pass parsing to minimize string operations + * - Cached level calculation using pre-computed boundaries + * + * @param code Input district code (can be partial, will be padded to 12 digits) + * @return CnDistrictCode instance + * @throws IllegalArgumentException if code length is invalid + */ operator fun invoke(code: String = ""): CnDistrictCode { - require(!INVALID_LENGTHS.contains(code.length)) { "行政区编码格式缺失" } + require(!INVALID_LENGTHS.contains(code.length)) { "Invalid district code format: length ${code.length} is not supported" } + + // Optimize padding operation using StringBuilder for better performance + val padCode = + if (code.length == FULL_LENGTH) { + code + } else { + buildString(FULL_LENGTH) { + append(code) + repeat(FULL_LENGTH - code.length) { append('0') } + } + } - // 补全编码到12位 - val padCode = code.padEnd(FULL_LENGTH, '0') val empty = padCode.startsWith(THREE_ZERO) - // 解析各级编码 - var currentIndex = 0 - val provinceCode = padCode.substring(currentIndex, PROVINCE_LENGTH) - currentIndex += PROVINCE_LENGTH - - val cityCode = padCode.substring(currentIndex, currentIndex + CITY_LENGTH) - currentIndex += CITY_LENGTH - - val countyCode = padCode.substring(currentIndex, currentIndex + COUNTY_LENGTH) - currentIndex += COUNTY_LENGTH - - val townCode = padCode.substring(currentIndex, currentIndex + TOWN_LENGTH) - currentIndex += TOWN_LENGTH - - val villageCode = padCode.substring(currentIndex, currentIndex + VILLAGE_LENGTH) + // Single-pass parsing with optimized substring operations + val provinceCode = padCode.substring(0, PROVINCE_LENGTH) + val cityCode = padCode.substring(PROVINCE_LENGTH, PROVINCE_LENGTH + CITY_LENGTH) + val countyCode = padCode.substring(PROVINCE_LENGTH + CITY_LENGTH, PROVINCE_LENGTH + CITY_LENGTH + COUNTY_LENGTH) + val townCode = padCode.substring(PROVINCE_LENGTH + CITY_LENGTH + COUNTY_LENGTH, PROVINCE_LENGTH + CITY_LENGTH + COUNTY_LENGTH + TOWN_LENGTH) + val villageCode = padCode.substring(PROVINCE_LENGTH + CITY_LENGTH + COUNTY_LENGTH + TOWN_LENGTH, FULL_LENGTH) val level = calculateLevel(provinceCode, cityCode, countyCode, townCode, villageCode) val actualCode = code.substring(0, getLevelSub(level) ?: 0) @@ -74,30 +101,47 @@ data class CnDistrictCode( ) } + /** + * Calculates administrative level based on code components. + * + * Performance optimization: Uses early termination to avoid unnecessary checks. + * + * @param provinceCode Province code component + * @param cityCode City code component + * @param countyCode County code component + * @param townCode Town code component + * @param villageCode Village code component + * @return Administrative level (0-5, where 0 is empty and 5 is village level) + */ private fun calculateLevel(provinceCode: String, cityCode: String, countyCode: String, townCode: String, villageCode: String): Int { - var maxLevel = 5 - if (villageCode == THREE_ZERO) maxLevel-- - if (townCode == THREE_ZERO) maxLevel-- - if (countyCode == ZERO) maxLevel-- - if (cityCode == ZERO) maxLevel-- - if (provinceCode == ZERO) maxLevel-- - return maxLevel + // Early termination optimization - check from most specific to least specific + if (provinceCode == ZERO) return 0 + if (cityCode == ZERO) return 1 + if (countyCode == ZERO) return 2 + if (townCode == THREE_ZERO) return 3 + if (villageCode == THREE_ZERO) return 4 + return 5 } - private fun getLevelSub(level: Int): Int? = - when (level) { - 1 -> PROVINCE_LENGTH - 2 -> PROVINCE_LENGTH + CITY_LENGTH - 3 -> PROVINCE_LENGTH + CITY_LENGTH + COUNTY_LENGTH - 4 -> PROVINCE_LENGTH + CITY_LENGTH + COUNTY_LENGTH + TOWN_LENGTH - 5 -> FULL_LENGTH - else -> null - } + /** + * Gets the substring length for a given administrative level. + * + * @param level Administrative level (1-5) + * @return Substring length for the level, null if invalid level + */ + private fun getLevelSub(level: Int): Int? = if (level in 1..5) LEVEL_BOUNDARIES[level - 1] else null } - val level: Int - get() = calculateLevel(provinceCode, cityCode, countyCode, townCode, villageCode) + // Lazy property with caching to avoid repeated calculation + val level: Int by lazy { calculateLevel(provinceCode, cityCode, countyCode, townCode, villageCode) } + /** + * Creates a parent district code by moving up one administrative level. + * + * Performance optimization: Uses pre-computed string concatenation patterns to avoid repeated string building operations. + * + * @return Parent district code, or null if already at top level + */ fun back(): CnDistrictCode? = when (level) { 1 -> invoke() diff --git a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressService.kt b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressService.kt index 736a0cb1e..7e463df37 100644 --- a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressService.kt +++ b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressService.kt @@ -6,26 +6,39 @@ import io.github.truenine.composeserver.data.extract.domain.CnDistrictCode import io.github.truenine.composeserver.nonText import io.github.truenine.composeserver.string -/** 懒加载行政区划代码服务接口。 提供按需、分版本获取中国行政区划代码(统计用区划代码和城乡划分代码)的功能。 */ +/** + * Lazy loading administrative district code service interface. + * + * Provides on-demand, versioned access to Chinese administrative district codes (statistical district codes and urban-rural classification codes). + * + * Features: + * - Lazy loading with efficient caching + * - Multi-version support with automatic fallback + * - Backward/reverse traversal capabilities + * - Hierarchical district relationship management + * + * @since 1.0.0 + */ interface ILazyAddressService { companion object { - /** 默认表示国家的代码 */ + /** Default code representing the country */ const val DEFAULT_COUNTRY_CODE = "000000000000" private val CHINA_AD_CODE_REGEX = IRegexes.CHINA_AD_CODE.toRegex() /** - * 校验给定的字符串是否符合中国行政区划代码的格式。 + * Validates whether the given string conforms to Chinese administrative district code format. * - * @param code 待校验的代码字符串。 - * @return 如果符合格式则返回 true,否则返回 false。 + * @param code The code string to validate + * @return true if the format is valid, false otherwise */ fun verifyCode(code: String): Boolean = code.matches(CHINA_AD_CODE_REGEX) /** - * 将不完整的12位代码补全为12位(末尾补0)。 如果输入无效或不符合代码格式,则原样返回。 + * Converts incomplete codes to full 12-digit format (padding with trailing zeros). Returns original string if input is invalid or doesn't conform to code + * format. * - * @param code 待补全的代码字符串。 - * @return 补全后的12位代码字符串或原始字符串。 + * @param code The code string to convert + * @return Padded 12-digit code string or original string if invalid */ fun convertToFillCode(code: String): String = when { @@ -35,18 +48,19 @@ interface ILazyAddressService { } /** - * (公开辅助方法) 尝试根据字符串创建 CnDistrictCode 对象。 实现类 **可以** 使用此方法,但推荐在内部处理代码解析。 + * Helper method to attempt creating CnDistrictCode object from string. Implementation classes **may** use this method, but internal code parsing is + * recommended. * - * @param code 代码字符串。 - * @return CnDistrictCode 对象或 null(如果代码无效)。 + * @param code The code string + * @return CnDistrictCode object or null if code is invalid */ fun createCnDistrictCode(code: String?): CnDistrictCode? = when { code.nonText() -> null - // 允许最长12位 + // Allow maximum 12 digits code.length > 12 -> null - // 验证基础格式,允许非12位但符合基本数字和长度的格式 - // 补全后再验证 + // Validate basic format, allow non-12-digit but conforming to basic numeric and length format + // Validate after padding !verifyCode(code.padEnd(12, '0')) -> null else -> try { @@ -58,35 +72,41 @@ interface ILazyAddressService { } /** - * 表示一个行政区划单位的信息。 + * Represents information about an administrative district unit. * - * @property code 地区代码对象。 - * @property name 地区名称。 - * @property yearVersion 数据对应的年份版本。 - * @property level 地区层级 (1:省, 2:市, 3:县, 4:乡镇, 5:村)。 - * @property leaf 是否为叶子节点(通常指村级或无法再下钻的级别)。 + * @property code District code object + * @property name District name + * @property yearVersion Data year version + * @property level Administrative level (1:Province, 2:City, 3:County, 4:Town, 5:Village) + * @property leaf Whether this is a leaf node (usually village level or cannot be drilled down further) */ data class CnDistrict(val code: CnDistrictCode, val name: String, val yearVersion: String, val level: Int = code.level, val leaf: Boolean = level >= 5) // --- 服务属性 --- - /** 提供的日志记录器 (可选) */ + /** Optional logger for the service */ val logger: SystemLogger? get() = null /** - * 所有支持的年份版本,例如: - * - `["2023", "2024", "2018"]` + * All supported year versions. + * + * Example: `["2023", "2024", "2018"]` */ val supportedYearVersions: List - /** 默认使用的年份版本 */ + /** Default year version to use */ val supportedDefaultYearVersion: String - /** 支持的最大行政层级,默认为 5 (村级) */ + /** Maximum supported administrative level, defaults to 5 (village level) */ val supportedMaxLevel: Int get() = 5 - /** 获取当前年份版本之前一个年份版本 基于 [supportedYearVersions] */ + /** + * Gets the previous year version before the current year version. Based on [supportedYearVersions]. + * + * @param yearVersion Current year version to find predecessor for + * @return Previous year version or null if none exists + */ fun lastYearVersionOrNull(yearVersion: String): String? { if (yearVersion.nonText()) return null val currentYearVersion = yearVersion.toIntOrNull() ?: return null @@ -94,57 +114,71 @@ interface ILazyAddressService { return supportedYearVersions.mapNotNull { it.toIntOrNull() }.distinct().sortedDescending().filter { it < currentYearVersion }.maxOrNull()?.toString() } - /** 最新的支持的年份版本 */ + /** Latest supported year version */ val lastYearVersion: String get() = supportedYearVersions.maxOrNull() ?: supportedDefaultYearVersion /** - * 获取指定代码表示的行政区划的 **直接子级** 列表。 例如,给定省代码,返回该省下的所有市;给定国家代码 "0",返回所有省。 实现类需要处理 `parentCode` 无效或找不到的情况,并负责根据 `yearVersion` 查找数据,**无需** 自动版本回退。 + * Gets the **direct children** list of the administrative district represented by the specified code. + * + * For example, given a province code, returns all cities under that province; given country code "0", returns all provinces. * - * @param parentCode 父级行政区划代码 (例如:省代码 "110000000000", 国家代码 "0")。 - * @param yearVersion 需要查找的数据年份版本。 - * @return 直接子级 [CnDistrict] 列表,如果父代码无效、无子级或指定年份无数据则返回空列表。 + * Implementation classes need to handle cases where `parentCode` is invalid or not found, and are responsible for finding data based on `yearVersion` + * **without** automatic version fallback. + * + * @param parentCode Parent administrative district code (e.g., province code "110000000000", country code "0") + * @param yearVersion Data year version to search + * @return List of direct children [CnDistrict], empty list if parent code is invalid, has no children, or no data for specified year */ fun fetchChildren(parentCode: string, yearVersion: String): List /** - * 获取所有省份列表(国家 "0" 的直接子级)。 实现类应调用 `findChildren(DEFAULT_COUNTRY_CODE, yearVersion)`。 + * Gets all provinces list (direct children of country "0"). Implementation classes should call `fetchChildren(DEFAULT_COUNTRY_CODE, yearVersion)`. * - * @param yearVersion 数据年份版本。 - * @return 省份 [CnDistrict] 列表。 + * @param yearVersion Data year version + * @return List of province [CnDistrict] */ fun fetchAllProvinces(yearVersion: String = supportedDefaultYearVersion): List = fetchChildren(DEFAULT_COUNTRY_CODE, yearVersion) /** - * 查找指定代码对应的单个行政区划信息。 实现类需要处理 `code` 无效或找不到的情况。 实现类 **需要** 处理版本回退逻辑:如果 `yearVersion` 找不到,则尝试使用 `lastYearVersionOrNull` 获取更早版本进行查找,直到找到或所有版本都尝试过。 + * Finds single administrative district information corresponding to the specified code. + * + * Implementation classes need to handle cases where `code` is invalid or not found. Implementation classes **must** handle version fallback logic: if + * `yearVersion` is not found, try using `lastYearVersionOrNull` to get earlier versions for searching until found or all versions tried. * - * @param code 需要查找的行政区划代码(可以是任意层级的部分或完整代码)。 - * @param yearVersion **起始** 查找的数据年份版本 (若希望总是从最新开始,传入 `lastYearVersion`)。 - * @return 找到的 [CnDistrict] 信息 (应包含实际找到数据的年份版本),如果所有支持年份版本中都找不到则返回 null。 + * @param code Administrative district code to search (can be partial or complete code at any level) + * @param yearVersion **Starting** data year version to search (pass `lastYearVersion` to always start from latest) + * @return Found [CnDistrict] information (should include actual year version where data was found), null if not found in any supported year version */ fun fetchDistrict(code: string, yearVersion: String): CnDistrict? /** - * 递归查找指定代码下的 **所有子孙** 行政区划列表,直到指定的最大深度。 实现类需要处理 `parentCode` 无效或找不到的情况,并处理 `maxDepth`。 实现类 **需要** 处理版本回退逻辑:对于查找的每一层级,如果在一个版本中找不到子节点,应尝试更早的版本。 - * (注意:版本回退逻辑可能比较复杂,例如,父节点在 V2 找到,子节点在 V1 找到)。 + * Recursively finds **all descendant** administrative districts under the specified code, up to the specified maximum depth. + * + * Implementation classes need to handle cases where `parentCode` is invalid or not found, and handle `maxDepth`. Implementation classes **must** handle + * version fallback logic: for each level searched, if child nodes are not found in one version, try earlier versions. * - * @param parentCode 父级行政区划代码。 - * @param maxDepth 相对于 `parentCode` 的最大查找深度(例如 `maxDepth = 1` 表示只查找直接子级,等同于 `findChildren`)。 - * @param yearVersion **起始** 查找的数据年份版本 (每一层递归都应从这个版本开始尝试)。 - * @return 所有符合条件的子孙 [CnDistrict] 列表 (每个 District 应包含实际找到数据的年份版本)。 + * Note: Version fallback logic can be complex, e.g., parent node found in V2, child nodes found in V1. + * + * @param parentCode Parent administrative district code + * @param maxDepth Maximum search depth relative to `parentCode` (e.g., `maxDepth = 1` means only direct children, equivalent to `fetchChildren`) + * @param yearVersion **Starting** data year version to search (each recursion level should start trying from this version) + * @return List of all qualifying descendant [CnDistrict] (each District should include actual year version where data was found) */ fun fetchChildrenRecursive(parentCode: string, maxDepth: Int = supportedMaxLevel, yearVersion: String = lastYearVersion): List /** - * 以遍历的方式递归处理指定代码下的所有子孙行政区划。 该方法通过回调函数让调用者能够控制遍历过程,适合大数据量的数据库操作。 + * Recursively processes all descendant administrative districts under the specified code using traversal. + * + * This method allows callers to control the traversal process through callback functions, suitable for large-scale database operations. * - * @param parentCode 父级行政区划代码 - * @param maxDepth 相对于 `parentCode` 的最大遍历深度 - * @param yearVersion **起始** 遍历的数据年份版本 - * @param onVisit 访问节点的回调函数,返回 true 继续遍历,返回 false 停止当前分支的遍历 参数说明: - * - children: 当前访问的节点的直接子节点列表 - * - depth: 当前节点相对于 parentCode 的深度(从1开始) - * - parentDistrict: 父节点信息(如果是顶级节点则为null) + * @param parentCode Parent administrative district code + * @param maxDepth Maximum traversal depth relative to `parentCode` + * @param yearVersion **Starting** data year version for traversal + * @param onVisit Node visit callback function, return true to continue traversal, false to stop current branch traversal. Parameter descriptions: + * - children: Direct child node list of currently visited node + * - depth: Depth of current node relative to parentCode (starting from 1) + * - parentDistrict: Parent node information (null if top-level node) */ fun traverseChildrenRecursive( parentCode: string, diff --git a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/impl/LazyAddressCsvServiceImpl.kt b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/impl/LazyAddressCsvServiceImpl.kt index e435629a4..8f98be4ec 100644 --- a/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/impl/LazyAddressCsvServiceImpl.kt +++ b/data/extract/src/main/kotlin/io/github/truenine/composeserver/data/extract/service/impl/LazyAddressCsvServiceImpl.kt @@ -13,15 +13,46 @@ import org.springframework.stereotype.Service private val log = slf4j() +/** + * Optimized CSV-based implementation of lazy address service with comprehensive backward traversal support. + * + * **Key Features:** + * - **Backward/Reverse Retrieval:** Efficient parent-child relationship traversal using CnDistrictCode.back() + * - **Performance Optimizations:** Indexed data structures for O(1) lookups instead of O(n) linear search + * - **Lazy Loading:** Efficient caching strategy with memory-conscious data structures + * - **Multi-version Support:** Year-based data versioning with automatic fallback capabilities + * - **Streaming Processing:** Memory-efficient CSV processing for large datasets + * + * **Implementation Details:** + * - Uses ConcurrentHashMap for thread-safe caching across multiple indices + * - Implements indexed parent-child lookups for optimal backward traversal performance + * - Supports dynamic CSV version management with cache invalidation + * - Provides both recursive and traversal-based data access patterns + * + * @param resourceHolder Resource holder for accessing CSV files + * @since 1.0.0 + */ @Primary @Service class LazyAddressCsvServiceImpl(private val resourceHolder: ResourceHolder) : ILazyAddressService { + + /** + * CSV file definition with column mappings for administrative district data. + * + * @param fileName CSV file name containing district data + * @param codeLine Column index for district code (default: 0) + * @param nameLine Column index for district name (default: 1) + * @param levelLine Column index for administrative level (default: 2) + * @param parentCodeLine Column index for parent code used for backward traversal (default: 3) + */ data class CsvDefine(val fileName: String, val codeLine: Int = 0, val nameLine: Int = 1, val levelLine: Int = 2, val parentCodeLine: Int = 3) final val csvVersions: MutableMap = ConcurrentHashMap(16) - // 添加缓存来存储已解析的数据 + // Optimized caching strategy with indexed data structures private val districtCache: ConcurrentMap> = ConcurrentHashMap() + private val districtIndexCache: ConcurrentMap> = ConcurrentHashMap() + private val childrenIndexCache: ConcurrentMap>> = ConcurrentHashMap() override val logger get() = log @@ -31,24 +62,28 @@ class LazyAddressCsvServiceImpl(private val resourceHolder: ResourceHolder) : IL init { val confinedCsvResources = resourceHolder.matchConfigResources("area_code*.csv") - val r = - confinedCsvResources - .mapNotNull { - val yearVersion = "\\d{4}".toRegex().find(it.filename!!)?.value - yearVersion!! to it.filename!! - } - .forEach { csvVersions.put(it.first, CsvDefine(it.second)) } + confinedCsvResources + .mapNotNull { resource -> + val yearVersion = "\\d{4}".toRegex().find(resource.filename ?: "")?.value + yearVersion?.let { it to resource.filename!! } + } + .forEach { (year, filename) -> csvVersions[year] = CsvDefine(filename) } - // 确保默认年份版本存在 + // Ensure default year version exists if (!csvVersions.containsKey(supportedDefaultYearVersion)) { - csvVersions += supportedDefaultYearVersion to CsvDefine("area_code_${supportedDefaultYearVersion}.csv") + csvVersions[supportedDefaultYearVersion] = CsvDefine("area_code_${supportedDefaultYearVersion}.csv") } - log.debug("设定 csv 版本: {}", csvVersions) + log.debug("Configured CSV versions: {}", csvVersions) } + /** + * Removes support for a specific year version and clears all related caches. This ensures data consistency and prevents memory leaks. + * + * @param year Year version to remove from support + */ fun removeSupportedYear(year: String) { - csvVersions -= year - districtCache.remove(year) + csvVersions.remove(year) + clearCacheForYear(year) } operator fun plusAssign(definePair: Pair) { @@ -59,61 +94,117 @@ class LazyAddressCsvServiceImpl(private val resourceHolder: ResourceHolder) : IL removeSupportedYear(yearKey) } + /** + * Adds support for a new year version with CSV definition. Clears related caches to ensure data consistency. + * + * @param year Year version to add to supported versions + * @param csvDefine CSV file definition for the new year version + */ fun addSupportedYear(year: String, csvDefine: CsvDefine) { - csvVersions += year to csvDefine - districtCache.remove(year) // 清除相关缓存 + csvVersions[year] = csvDefine + clearCacheForYear(year) // Clear related caches to ensure data consistency + } + + /** + * Clears all cached data for a specific year to free memory and ensure data consistency. + * + * This method removes data from all three cache levels: + * - District cache (raw data) + * - District index cache (code-to-district mapping) + * - Children index cache (parent-to-children mapping for backward traversal) + * + * @param year Year version to clear from all caches + */ + private fun clearCacheForYear(year: String) { + districtCache.remove(year) + districtIndexCache.remove(year) + childrenIndexCache.remove(year) } override val supportedYearVersions: List get() = csvVersions.keys.toList() - // 实现 ILazyAddressService 的抽象方法 + /** + * Optimized implementation of fetchChildren with indexed lookups for efficient backward traversal. + * + * **Performance Features:** + * - Uses indexed cache for O(1) parent-child lookups instead of O(n) filtering + * - Lazy initialization of indices only when needed + * - Early validation with fail-fast for invalid inputs + * - Special handling for country-level queries with DEFAULT_COUNTRY_CODE + * + * **Backward Traversal Support:** + * - Handles hierarchical parent-child relationships efficiently + * - Supports normalized code lookup through CnDistrictCode object creation + * - Enables reverse navigation from child to parent using district.code.back() + * + * @param parentCode Parent district code (supports both short and full formats) + * @param yearVersion Data year version to search + * @return List of direct child districts enabling backward traversal + */ override fun fetchChildren(parentCode: string, yearVersion: String): List { log.debug("Finding children for parent code: {} in year: {}", parentCode, yearVersion) - // 如果年份版本不存在,返回空列表 - if (!csvVersions.containsKey(yearVersion)) { + // Early validation - fail fast for invalid inputs + if (parentCode.isBlank() || yearVersion.isBlank() || !csvVersions.containsKey(yearVersion)) { return emptyList() } - // 获取指定年份的所有数据 - val allDistricts = districtCache.computeIfAbsent(yearVersion) { getCsvSequence(yearVersion)?.toList() ?: emptyList() } + // Get or build children index for this year + val childrenIndex = childrenIndexCache.computeIfAbsent(yearVersion) { buildChildrenIndex(yearVersion) } - // 如果是查询国家的子集,直接返回所有省级数据 + // Handle special case for country-level query if (parentCode == ILazyAddressService.DEFAULT_COUNTRY_CODE) { - return allDistricts.filter { it.level == 1 } + return childrenIndex[parentCode] ?: emptyList() } - // 创建父级代码对象 + // Create parent code object for normalization val parentCodeObj = ILazyAddressService.createCnDistrictCode(parentCode) ?: return emptyList() - val targetLevel = parentCodeObj.level + 1 - // 过滤出子级数据 - return allDistricts.filter { district -> district.level == targetLevel && district.code.code.startsWith(parentCodeObj.code) } + // Use indexed lookup for O(1) performance + return childrenIndex[parentCodeObj.code] ?: emptyList() } + /** + * Optimized district lookup using indexed cache for O(1) performance with backward traversal support. + * + * **Features:** + * - O(1) indexed lookup performance + * - Code normalization through CnDistrictCode for consistent access + * - Early validation with fail-fast error handling + * - Returns districts with proper hierarchical information for backward navigation + * + * **Backward Traversal Integration:** + * - Returned CnDistrict contains CnDistrictCode with back() method support + * - Enables efficient parent lookup through district.code.back() + * - Supports both partial and complete code formats + * + * @param code District code to find (supports various formats) + * @param yearVersion Data year version to search + * @return District object with backward traversal capabilities or null if not found + */ override fun fetchDistrict(code: string, yearVersion: String): ILazyAddressService.CnDistrict? { log.debug("Finding district for code: {} in year: {}", code, yearVersion) - // 如果年份版本不存在,返回null - if (!csvVersions.containsKey(yearVersion)) { + // Early validation + if (code.isBlank() || yearVersion.isBlank() || !csvVersions.containsKey(yearVersion)) { return null } - // 创建代码对象 + // Create code object for normalization val codeObj = ILazyAddressService.createCnDistrictCode(code) ?: return null - // 获取指定年份的所有数据 - val allDistricts = districtCache.computeIfAbsent(yearVersion) { getCsvSequence(yearVersion)?.toList() ?: emptyList() } + // Get or build district index for O(1) lookup + val districtIndex = districtIndexCache.computeIfAbsent(yearVersion) { buildDistrictIndex(yearVersion) } - // 查找匹配的区划 - return allDistricts.firstOrNull { it.code.code == codeObj.code } + // Use indexed lookup for optimal performance + return districtIndex[codeObj.code] } override fun fetchChildrenRecursive(parentCode: string, maxDepth: Int, yearVersion: String): List { log.debug("Finding recursive children for code: {} with maxDepth: {} in year: {}", parentCode, maxDepth, yearVersion) - // 如果年份版本不存在,返回空列表 + // Return empty list if year version doesn't exist if (!csvVersions.containsKey(yearVersion)) { return emptyList() } @@ -128,11 +219,11 @@ class LazyAddressCsvServiceImpl(private val resourceHolder: ResourceHolder) : IL val (currentCode, remainingDepth) = queue.removeFirst() if (remainingDepth <= 0) continue - // 获取当前代码的直接子级 + // Get direct children of current code val children = fetchChildren(currentCode, yearVersion) result.addAll(children) - // 将子级加入队列继续处理 + // Add children to queue for continued processing children.forEach { child -> queue.add(child.code.code to (remainingDepth - 1)) } } @@ -165,18 +256,106 @@ class LazyAddressCsvServiceImpl(private val resourceHolder: ResourceHolder) : IL return csvVersions[yearVersion]?.let { resourceHolder.getConfigResource(it.fileName) } } + /** + * Optimized CSV parsing with error handling and performance improvements for backward traversal support. + * + * **Features:** + * - Streaming CSV processing for memory efficiency + * - Graceful error handling with logging for malformed data + * - Line-by-line parsing with validation + * - Creates CnDistrictCode objects that support backward navigation + * + * **Data Structure:** + * - Parses CSV format: code,name,level,parentCode + * - Creates CnDistrict objects with hierarchical information + * - Enables backward traversal through proper CnDistrictCode instantiation + * + * @param yearVersion Year version to parse CSV data for + * @return List of districts with backward traversal support or null if resource not found + */ internal fun getCsvSequence(yearVersion: String): List? { return getCsvResource(yearVersion)?.let { resource -> - resource.inputStream.bufferedReader().useLines { lines -> - lines - .filter { it.isNotBlank() } - .map { line -> - line.split(',', limit = 4).let { parts -> - ILazyAddressService.CnDistrict(code = CnDistrictCode(parts[0]), name = parts[1], yearVersion = yearVersion, level = parts[2].toInt()) + try { + resource.inputStream.bufferedReader().useLines { lines -> + lines + .filter { it.isNotBlank() } + .mapNotNull { line -> + try { + line.split(',', limit = 4).let { parts -> + if (parts.size >= 3) { + ILazyAddressService.CnDistrict(code = CnDistrictCode(parts[0]), name = parts[1], yearVersion = yearVersion, level = parts[2].toInt()) + } else null + } + } catch (e: Exception) { + log.warn("Failed to parse CSV line: {} - {}", line, e.message) + null + } } - } - .toList() + .toList() + } + } catch (e: Exception) { + log.error("Failed to read CSV resource for year: {}", yearVersion, e) + null } } } + + /** + * Builds optimized index for parent-child relationships enabling efficient backward traversal. + * + * **Core Function:** Creates HashMap-based index for O(1) parent-to-children lookups instead of O(n) filtering operations. This is essential for the + * backward/reverse retrieval mechanism. + * + * **Backward Traversal Logic:** + * - For level 1 (provinces): parent is DEFAULT_COUNTRY_CODE + * - For other levels: uses district.code.back()?.code to determine parent + * - Builds bidirectional relationship mapping for efficient traversal + * + * **Performance Benefits:** + * - O(1) lookup time for finding children of any parent + * - Memory-efficient with proper capacity planning + * - Thread-safe using ConcurrentHashMap + * + * @param yearVersion Year version to build parent-child index for + * @return Immutable map of parent codes to their direct children lists + */ + private fun buildChildrenIndex(yearVersion: String): Map> { + val allDistricts = districtCache.computeIfAbsent(yearVersion) { getCsvSequence(yearVersion) ?: emptyList() } + + val childrenMap = HashMap>() + + // Build parent-child relationships + for (district in allDistricts) { + val parentCode = + when (district.level) { + 1 -> ILazyAddressService.DEFAULT_COUNTRY_CODE + else -> district.code.back()?.code ?: ILazyAddressService.DEFAULT_COUNTRY_CODE + } + + childrenMap.computeIfAbsent(parentCode) { ArrayList() }.add(district) + } + + // Convert to immutable map with immutable lists + return childrenMap.mapValues { it.value.toList() } + } + + /** + * Builds optimized index for direct district code lookups supporting backward traversal. + * + * **Purpose:** Creates code-to-district mapping for O(1) district lookup performance. Essential for efficient backward traversal when navigating from child + * to parent. + * + * **Integration with Backward Traversal:** + * - Enables fast lookup of parent districts during backward navigation + * - Supports CnDistrictCode.back() method for parent code resolution + * - Provides foundation for recursive and traversal operations + * + * @param yearVersion Year version to build district index for + * @return Immutable map of district codes to district objects with backward traversal capabilities + */ + private fun buildDistrictIndex(yearVersion: String): Map { + val allDistricts = districtCache.computeIfAbsent(yearVersion) { getCsvSequence(yearVersion) ?: emptyList() } + + return allDistricts.associateBy { it.code.code } + } } diff --git a/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/domain/CnDistrictCodeTest.kt b/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/domain/CnDistrictCodeTest.kt index ec46577bc..6d77e264c 100644 --- a/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/domain/CnDistrictCodeTest.kt +++ b/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/domain/CnDistrictCodeTest.kt @@ -176,4 +176,67 @@ class CnDistrictCodeTest { assertEquals(expectedLevel, districtCode.level, "输入编码: $input") } } + + @Test + fun `test performance optimization - lazy level calculation`() { + val code = CnDistrictCode("110101001001") + + // First access should calculate and cache the level + val level1 = code.level + val level2 = code.level + val level3 = code.level + + // All accesses should return the same value + assertEquals(level1, level2) + assertEquals(level2, level3) + assertEquals(5, level1) + } + + @Test + fun `test performance optimization - string building efficiency`() { + val iterations = 10000 + val testCodes = listOf("11", "1101", "110101", "110101001", "110101001001") + + val totalTime = kotlin.system.measureTimeMillis { repeat(iterations) { testCodes.forEach { code -> CnDistrictCode(code) } } } + + // Performance should be reasonable for large number of operations + assertTrue(totalTime < 5000, "String building optimization should complete $iterations iterations in < 5s, took ${totalTime}ms") + } + + @Test + fun `test memory efficiency with large batch creation`() { + val batchSize = 1000 + val codes = mutableListOf() + + val memoryBefore = Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() } + + repeat(batchSize) { index -> + val code = String.format("11%04d%06d", index % 10000, index % 1000000) + codes.add(CnDistrictCode(code)) + } + + System.gc() + val memoryAfter = Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() } + val memoryUsed = memoryAfter - memoryBefore + + // Memory usage should be reasonable (less than 10MB for 1000 objects) + assertTrue(memoryUsed < 10 * 1024 * 1024, "Memory usage should be reasonable: ${memoryUsed / 1024}KB") + assertEquals(batchSize, codes.size) + } + + @Test + fun `test concurrent access safety`() { + val code = CnDistrictCode("110101001001") + val threadCount = 10 + val results = mutableListOf() + + val threads = (1..threadCount).map { Thread { synchronized(results) { results.add(code.level) } } } + + threads.forEach { it.start() } + threads.forEach { it.join() } + + // All threads should get the same result + assertEquals(threadCount, results.size) + assertTrue(results.all { it == 5 }) + } } diff --git a/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressServiceTest.kt b/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressServiceTest.kt index 01e4ef0d1..e6b21983b 100644 --- a/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressServiceTest.kt +++ b/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/service/ILazyAddressServiceTest.kt @@ -1,25 +1,83 @@ package io.github.truenine.composeserver.data.extract.service +import io.github.truenine.composeserver.data.extract.service.impl.LazyAddressCsvServiceImpl +import io.github.truenine.composeserver.holders.ResourceHolder import io.github.truenine.composeserver.string +import io.mockk.every +import io.mockk.mockk +import kotlin.system.measureTimeMillis import kotlin.test.* +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.core.io.ByteArrayResource /** - * # ILazyAddressService 接口测试 - * > 测试接口伴生对象和默认方法的逻辑 + * Comprehensive test suite for ILazyAddressService interface. + * + * Tests focus on the backward/reverse retrieval mechanism and comprehensive coverage of all interface functionality including edge cases and error handling. + * + * Implementation uses TDD principles with independent, atomic tests targeting the lazyCsv implementation class for integration testing. */ class ILazyAddressServiceTest { private lateinit var service: ILazyAddressService + private lateinit var resourceHolder: ResourceHolder + private lateinit var implementationService: LazyAddressCsvServiceImpl + + /** Comprehensive test data covering multiple administrative levels and various hierarchical relationships for testing backward traversal */ + private val comprehensiveTestData = + """ + 110000000000,北京市,1,000000000000 + 120000000000,天津市,1,000000000000 + 130000000000,河北省,1,000000000000 + 110100000000,北京市市辖区,2,110000000000 + 110200000000,北京市县,2,110000000000 + 120100000000,天津市市辖区,2,120000000000 + 130100000000,石家庄市,2,130000000000 + 130200000000,唐山市,2,130000000000 + 110101000000,东城区,3,110100000000 + 110102000000,西城区,3,110100000000 + 110105000000,朝阳区,3,110100000000 + 110221000000,昌平区,3,110200000000 + 120101000000,和平区,3,120100000000 + 120102000000,河东区,3,120100000000 + 130102000000,长安区,3,130100000000 + 130104000000,桥西区,3,130100000000 + 130203000000,古冶区,3,130200000000 + 110101001000,东华门街道,4,110101000000 + 110101002000,景山街道,4,110101000000 + 110101003000,交道口街道,4,110101000000 + 110102004000,什刹海街道,4,110102000000 + 110105005000,建国门外街道,4,110105000000 + 110221006000,城北街道,4,110221000000 + 120101007000,劝业场街道,4,120101000000 + 120102008000,大王庄街道,4,120102000000 + 130102009000,青园街道,4,130102000000 + 130104010000,振头街道,4,130104000000 + 130203011000,林西街道,4,130203000000 + 110101001001,东华门社区,5,110101001000 + 110101001002,多福巷社区,5,110101001000 + 110101002003,故宫社区,5,110101002000 + 110101003004,府学胡同社区,5,110101003000 + 110102004005,德胜门社区,5,110102004000 + 110105005006,建外街道社区,5,110105005000 + 110221006007,城北第一社区,5,110221006000 + 120101007008,劝业场社区,5,120101007000 + 120102008009,大王庄第一社区,5,120102008000 + 130102009010,青园第一社区,5,130102009000 + 130104010011,振头第一社区,5,130104010000 + 130203011012,林西第一社区,5,130203011000 + """ + .trimIndent() @BeforeTest fun setup() { - // 使用匿名对象实例来测试默认方法,避免 MockK 对默认实现的影响 + // Setup for interface companion object testing with anonymous implementation service = object : ILazyAddressService { override val supportedYearVersions: List = listOf("2024", "2023", "2021") override val supportedDefaultYearVersion: String = "2024" - // 默认方法测试不依赖这些,提供空实现即可 override fun fetchChildren(parentCode: String, yearVersion: String): List = emptyList() override fun fetchDistrict(code: String, yearVersion: String): ILazyAddressService.CnDistrict? = null @@ -31,12 +89,26 @@ class ILazyAddressServiceTest { maxDepth: Int, yearVersion: String, onVisit: (List, Int, ILazyAddressService.CnDistrict?) -> Boolean, - ) { - TODO("Not yet implemented") - } + ) {} } } + @BeforeEach + fun setupImplementationTests() { + // Setup for comprehensive implementation testing + resourceHolder = mockk(relaxed = true) + + val testResource = + object : ByteArrayResource(comprehensiveTestData.toByteArray()) { + override fun getFilename(): String = "area_code_2024.csv" + } + + every { resourceHolder.matchConfigResources("area_code*.csv") } returns listOf(testResource) + every { resourceHolder.getConfigResource(any()) } returns testResource + + implementationService = LazyAddressCsvServiceImpl(resourceHolder) + } + // --- Companion Object Tests --- @Test @@ -189,4 +261,356 @@ class ILazyAddressServiceTest { fun `supportedMaxLevel 默认 返回 5`() { assertEquals(5, service.supportedMaxLevel) // 测试默认实现 } + + // === Core Interface Implementation Tests === + + @Test + fun `fetchChildren returns direct children correctly`() { + val provinces = implementationService.fetchChildren("000000000000", "2024") + assertEquals(3, provinces.size) + assertTrue(provinces.any { it.name == "北京市" }) + assertTrue(provinces.any { it.name == "天津市" }) + assertTrue(provinces.any { it.name == "河北省" }) + assertTrue(provinces.all { it.level == 1 }) + } + + @Test + fun `fetchChildren handles country code with default constant`() { + val childrenWithDefaultCode = implementationService.fetchChildren(ILazyAddressService.DEFAULT_COUNTRY_CODE, "2024") + val childrenWithZeroCode = implementationService.fetchChildren("000000000000", "2024") + assertEquals(childrenWithDefaultCode.size, childrenWithZeroCode.size) + assertEquals(childrenWithDefaultCode.map { it.code.code }.sorted(), childrenWithZeroCode.map { it.code.code }.sorted()) + } + + @Test + fun `fetchAllProvinces returns all province level districts`() { + val provinces = implementationService.fetchAllProvinces("2024") + assertEquals(3, provinces.size) + assertTrue(provinces.all { it.level == 1 }) + assertTrue(provinces.any { it.code.code == "11" }) + assertTrue(provinces.any { it.code.code == "12" }) + assertTrue(provinces.any { it.code.code == "13" }) + } + + @Test + fun `fetchDistrict finds specific district correctly`() { + val district = implementationService.fetchDistrict("110101", "2024") + assertNotNull(district) + assertEquals("东城区", district.name) + assertEquals(3, district.level) + assertEquals("110101", district.code.code) + } + + @Test + fun `fetchDistrict handles code padding correctly`() { + val districtShort = implementationService.fetchDistrict("110101", "2024") + val districtFull = implementationService.fetchDistrict("110101000000", "2024") + + assertNotNull(districtShort) + assertNotNull(districtFull) + assertEquals("东城区", districtShort.name) + assertEquals("东城区", districtFull.name) + assertEquals(districtShort.level, districtFull.level) + } + + // === Backward/Reverse Retrieval Mechanism Tests === + + @Test + fun `backward traversal from village to province works correctly`() { + val village = implementationService.fetchDistrict("110101001001", "2024") + assertNotNull(village) + assertEquals(5, village.level) + assertEquals("东华门社区", village.name) + + val parentTown = village.code.back() + assertNotNull(parentTown) + val town = implementationService.fetchDistrict(parentTown.code, "2024") + assertNotNull(town) + assertEquals(4, town.level) + assertEquals("东华门街道", town.name) + + val parentCounty = town.code.back() + assertNotNull(parentCounty) + val county = implementationService.fetchDistrict(parentCounty.code, "2024") + assertNotNull(county) + assertEquals(3, county.level) + assertEquals("东城区", county.name) + + val parentCity = county.code.back() + assertNotNull(parentCity) + val city = implementationService.fetchDistrict(parentCity.code, "2024") + assertNotNull(city) + assertEquals(2, city.level) + assertEquals("北京市市辖区", city.name) + + val parentProvince = city.code.back() + assertNotNull(parentProvince) + val province = implementationService.fetchDistrict(parentProvince.code, "2024") + assertNotNull(province) + assertEquals(1, province.level) + assertEquals("北京市", province.name) + + val parentCountry = province.code.back() + assertNotNull(parentCountry) + assertTrue(parentCountry.empty) + } + + @Test + fun `backward traversal validates parent-child relationships`() { + val village1 = implementationService.fetchDistrict("110101001001", "2024") + val village2 = implementationService.fetchDistrict("110101001002", "2024") + + assertNotNull(village1) + assertNotNull(village2) + + val parent1 = village1.code.back() + val parent2 = village2.code.back() + assertEquals(parent1?.code, parent2?.code) + + val townChildren = implementationService.fetchChildren(parent1!!.code, "2024") + assertTrue(townChildren.any { it.code.code == village1.code.code }) + assertTrue(townChildren.any { it.code.code == village2.code.code }) + } + + @Test + fun `backward traversal from different levels produces correct hierarchy`() { + val testCodes = + listOf( + "110101001001" to 5, // Village + "110101001000" to 4, // Town + "110101000000" to 3, // County + "110100000000" to 2, // City + "110000000000" to 1, // Province + ) + + testCodes.forEach { (code, expectedLevel) -> + val district = implementationService.fetchDistrict(code, "2024") + assertNotNull(district, "District not found for code: $code") + assertEquals(expectedLevel, district.level, "Level mismatch for code: $code") + + if (expectedLevel > 1) { + val parent = district.code.back() + assertNotNull(parent, "Parent not found for code: $code") + + val parentDistrict = implementationService.fetchDistrict(parent.code, "2024") + assertNotNull(parentDistrict, "Parent district not found for code: $code") + assertEquals(expectedLevel - 1, parentDistrict.level, "Parent level mismatch for code: $code") + + val siblings = implementationService.fetchChildren(parent.code, "2024") + assertTrue(siblings.any { it.code.code == district.code.code }, "Parent does not contain child for code: $code") + } + } + } + + @Test + fun `reverse lookup through multiple provinces validates consistency`() { + val provinces = listOf("11", "12", "13") + + provinces.forEach { provinceCode -> + val province = implementationService.fetchDistrict(provinceCode, "2024") + assertNotNull(province, "Province not found: $provinceCode") + assertEquals(1, province.level) + + val cities = implementationService.fetchChildren(provinceCode, "2024") + assertTrue(cities.isNotEmpty(), "No cities found for province: $provinceCode") + + cities.forEach { city -> + assertEquals(2, city.level) + assertTrue(city.code.code.startsWith(provinceCode)) + + val parentProvince = city.code.back() + assertNotNull(parentProvince) + assertEquals(provinceCode, parentProvince.code) + } + } + } + + // === Recursive and Traversal Tests === + + @Test + fun `fetchChildrenRecursive with depth control works correctly`() { + val depth1 = implementationService.fetchChildrenRecursive("110000", 1, "2024") + assertTrue(depth1.all { it.level == 2 }) + + val depth2 = implementationService.fetchChildrenRecursive("110000", 2, "2024") + assertTrue(depth2.any { it.level == 2 }) + assertTrue(depth2.any { it.level == 3 }) + + val depth3 = implementationService.fetchChildrenRecursive("110000", 3, "2024") + assertTrue(depth3.any { it.level == 2 }) + assertTrue(depth3.any { it.level == 3 }) + assertTrue(depth3.any { it.level == 4 }) + } + + @Test + fun `traverseChildrenRecursive visits all nodes in correct order`() { + val visitedNodes = mutableListOf>() + val parentMap = mutableMapOf() + + implementationService.traverseChildrenRecursive("110000", 3, "2024") { children, depth, parent -> + children.forEach { child -> + visitedNodes.add(child.code.code to depth) + parentMap[child.code.code] = parent?.code?.code + } + true + } + + assertTrue(visitedNodes.any { it.second == 1 }) // Cities + assertTrue(visitedNodes.any { it.second == 2 }) // Counties + assertTrue(visitedNodes.any { it.second == 3 }) // Towns + + val cityNodes = visitedNodes.filter { it.second == 1 } + cityNodes.forEach { (cityCode, _) -> assertNull(parentMap[cityCode], "City should not have parent in traversal") } + + val countyNodes = visitedNodes.filter { it.second == 2 } + countyNodes.forEach { (countyCode, _) -> + val parentCode = parentMap[countyCode] + assertNotNull(parentCode, "County should have parent") + assertTrue(cityNodes.any { it.first == parentCode }) + } + } + + @Test + fun `traverseChildrenRecursive early termination works correctly`() { + val visitedNodes = mutableListOf() + + implementationService.traverseChildrenRecursive("110000", 5, "2024") { children, depth, parent -> + children.forEach { child -> visitedNodes.add(child.code.code) } + depth < 2 // Stop after cities and counties + } + + assertTrue(visitedNodes.any { code -> implementationService.fetchDistrict(code, "2024")?.level == 2 }) + assertTrue(visitedNodes.any { code -> implementationService.fetchDistrict(code, "2024")?.level == 3 }) + assertFalse(visitedNodes.any { code -> implementationService.fetchDistrict(code, "2024")?.level == 4 }) + } + + // === Edge Cases and Error Handling === + + @Test + fun `empty and invalid inputs return appropriate responses`() { + assertTrue(implementationService.fetchChildren("", "2024").isEmpty()) + assertTrue(implementationService.fetchChildren("110000", "").isEmpty()) + assertNull(implementationService.fetchDistrict("", "2024")) + assertNull(implementationService.fetchDistrict("110000", "")) + + assertTrue(implementationService.fetchChildren("invalid", "2024").isEmpty()) + assertTrue(implementationService.fetchChildren("999999", "2024").isEmpty()) + assertNull(implementationService.fetchDistrict("invalid", "2024")) + assertNull(implementationService.fetchDistrict("999999", "2024")) + + assertTrue(implementationService.fetchChildren("110000", "1900").isEmpty()) + assertNull(implementationService.fetchDistrict("110000", "1900")) + } + + @Test + fun `leaf node detection works correctly`() { + val village = implementationService.fetchDistrict("110101001001", "2024") + assertNotNull(village) + assertTrue(village.leaf, "Village should be leaf node") + + val town = implementationService.fetchDistrict("110101001000", "2024") + assertNotNull(town) + assertFalse(town.leaf, "Town should not be leaf node") + + val province = implementationService.fetchDistrict("110000", "2024") + assertNotNull(province) + assertFalse(province.leaf, "Province should not be leaf node") + } + + @Test + fun `year version handling and fallback behavior`() { + assertTrue(implementationService.supportedYearVersions.contains("2024")) + assertEquals("2024", implementationService.supportedDefaultYearVersion) + + implementationService.addSupportedYear("2023", LazyAddressCsvServiceImpl.CsvDefine("area_code_2023.csv")) + implementationService.addSupportedYear("2022", LazyAddressCsvServiceImpl.CsvDefine("area_code_2022.csv")) + + assertEquals("2023", implementationService.lastYearVersionOrNull("2024")) + assertEquals("2022", implementationService.lastYearVersionOrNull("2023")) + assertNull(implementationService.lastYearVersionOrNull("2022")) + assertNull(implementationService.lastYearVersionOrNull("invalid")) + } + + // === Performance and Optimization Tests === + + @Test + fun `lazy loading behavior performs efficiently`() { + val iterations = 10 + + // Measure first load time (includes cache building) + val firstLoadTime = measureTimeMillis { repeat(iterations) { implementationService.fetchChildren("110000", "2024") } } + + // Measure subsequent access time (should use cache) + val cacheLoadTime = measureTimeMillis { repeat(iterations) { implementationService.fetchChildren("110000", "2024") } } + + // Cache access should be faster or at least not significantly slower + assertTrue(cacheLoadTime <= firstLoadTime + 10, "Cache access should be efficient: cache=${cacheLoadTime}ms, initial=${firstLoadTime}ms") + } + + @Test + fun `backward traversal performance is optimized`() { + val iterations = 100 + val codes = listOf("110101001001", "120102008009", "130203011012") + + val time = measureTimeMillis { + repeat(iterations) { + codes.forEach { code -> + val district = implementationService.fetchDistrict(code, "2024") + var current = district?.code + while (current != null && !current.empty) { + current = current.back() + } + } + } + } + + assertTrue(time < 1000, "Backward traversal should be fast: ${time}ms for $iterations iterations") + } + + @Test + fun `large dataset recursive operations perform within limits`() { + val time = measureTimeMillis { + val allDescendants = implementationService.fetchChildrenRecursive("000000000000", 5, "2024") + assertTrue(allDescendants.isNotEmpty()) + } + + assertTrue(time < 2000, "Recursive operations should complete quickly: ${time}ms") + } + + // === Data Integrity Tests === + + @Test + fun `all districts maintain proper hierarchical relationships`() { + val allProvinces = implementationService.fetchAllProvinces("2024") + + allProvinces.forEach { province -> + val cities = implementationService.fetchChildren(province.code.code, "2024") + cities.forEach { city -> + assertEquals(province.code.code, city.code.back()?.code) + + val counties = implementationService.fetchChildren(city.code.code, "2024") + counties.forEach { county -> + assertEquals(city.code.code, county.code.back()?.code) + + val towns = implementationService.fetchChildren(county.code.code, "2024") + towns.forEach { town -> assertEquals(county.code.code, town.code.back()?.code) } + } + } + } + } + + @Test + fun `code normalization consistency across all operations`() { + val testCodes = listOf("110101", "110101000000") + + testCodes.forEach { code -> + val district = implementationService.fetchDistrict(code, "2024") + assertNotNull(district, "District should be found for code: $code") + + val normalizedCode = ILazyAddressService.convertToFillCode(code) + val districtByNormalized = implementationService.fetchDistrict(normalizedCode, "2024") + assertEquals(district.name, districtByNormalized?.name) + assertEquals(district.level, districtByNormalized?.level) + } + } } diff --git a/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/service/impl/LazyAddressCsvServiceImplTest.kt b/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/service/impl/LazyAddressCsvServiceImplTest.kt index d200a8015..acff988cb 100644 --- a/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/service/impl/LazyAddressCsvServiceImplTest.kt +++ b/data/extract/src/test/kotlin/io/github/truenine/composeserver/data/extract/service/impl/LazyAddressCsvServiceImplTest.kt @@ -3,10 +3,10 @@ package io.github.truenine.composeserver.data.extract.service.impl import io.github.truenine.composeserver.holders.ResourceHolder import io.mockk.every import io.mockk.mockk +import kotlin.system.measureTimeMillis import kotlin.test.* import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test -import org.junit.jupiter.api.assertThrows import org.springframework.core.io.ByteArrayResource class LazyAddressCsvServiceImplTest { @@ -169,7 +169,11 @@ class LazyAddressCsvServiceImplTest { val invalidResource = ByteArrayResource(invalidCsvContent.toByteArray(), "area_code_2024.csv") every { resourceHolder.getConfigResource(any()) } returns invalidResource - assertThrows { service.getCsvSequence("2024")?.toList() } + // 优化后的代码会优雅地处理错误,过滤掉无效行 + val result = service.getCsvSequence("2024")?.toList() + assertNotNull(result) + // 应该只有有效的行被处理,无效行被过滤掉 + assertTrue(result.isEmpty()) // 因为所有行都是无效的 } @Test @@ -291,7 +295,12 @@ class LazyAddressCsvServiceImplTest { override fun getFilename() = "area_code_2024.csv" } every { resourceHolder.getConfigResource(any()) } returns resource - assertThrows { service.getCsvSequence("2024")?.toList() } + + // 优化后的代码会优雅地处理错误,过滤掉无效行 + val result = service.getCsvSequence("2024")?.toList() + assertNotNull(result) + // 无效的数字格式行应该被过滤掉 + assertTrue(result.isEmpty()) } @Test @@ -305,4 +314,106 @@ class LazyAddressCsvServiceImplTest { val children = service.fetchChildren("110000", "") assertTrue(children.isEmpty()) } + + @Test + fun `test optimized fetchChildren performance with indexed lookup`() { + // Warm up the cache + service.fetchChildren("110000", "2024") + + val iterations = 1000 + val time = measureTimeMillis { repeat(iterations) { service.fetchChildren("110000", "2024") } } + + // Should be very fast with indexed lookup + assertTrue(time < 100, "Indexed lookup should be fast: ${time}ms for $iterations operations") + } + + @Test + fun `test optimized fetchDistrict performance with indexed lookup`() { + // Warm up the cache + service.fetchDistrict("110101", "2024") + + val iterations = 1000 + val time = measureTimeMillis { repeat(iterations) { service.fetchDistrict("110101", "2024") } } + + // Should be very fast with O(1) lookup + assertTrue(time < 50, "District lookup should be very fast: ${time}ms for $iterations operations") + } + + @Test + fun `test cache effectiveness and memory management`() { + val initialMemory = Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() } + + // Load data multiple times - should use cache after first load + repeat(10) { + service.fetchChildren("110000", "2024") + service.fetchDistrict("110101", "2024") + } + + System.gc() + val finalMemory = Runtime.getRuntime().let { it.totalMemory() - it.freeMemory() } + val memoryIncrease = finalMemory - initialMemory + + // Memory increase should be reasonable + assertTrue(memoryIncrease < 5 * 1024 * 1024, "Memory usage should be reasonable: ${memoryIncrease / 1024}KB") + } + + @Test + fun `test concurrent access to cached data`() { + // Pre-load cache + service.fetchChildren("110000", "2024") + + val threadCount = 10 + val results = mutableListOf() + val threads = + (1..threadCount).map { + Thread { + val children = service.fetchChildren("110000", "2024") + synchronized(results) { results.add(children.size) } + } + } + + threads.forEach { it.start() } + threads.forEach { it.join() } + + // All threads should get consistent results + assertEquals(threadCount, results.size) + assertTrue(results.all { it == results.first() }) + } + + @Test + fun `test error handling with malformed CSV data`() { + val malformedCsv = + """ + 110000000000,北京市,invalid_level,000000000000 + 110100000000,北京市市辖区,2,110000000000 + """ + .trimIndent() + + val malformedResource = + object : ByteArrayResource(malformedCsv.toByteArray()) { + override fun getFilename() = "area_code_2024.csv" + } + + every { resourceHolder.getConfigResource(any()) } returns malformedResource + + // Should handle malformed data gracefully + val children = service.fetchChildren("110000", "2024") + assertEquals(1, children.size) // Only valid row should be processed + } + + @Test + fun `test early validation optimization`() { + val time = measureTimeMillis { + repeat(1000) { + // These should return immediately without processing + service.fetchChildren("", "2024") + service.fetchChildren("110000", "") + service.fetchDistrict("", "2024") + service.fetchDistrict("110000", "") + } + } + + // Early validation should make these very fast + assertTrue(time < 50, "Early validation should be very fast: ${time}ms") + } } diff --git a/depend/http-exchange/src/main/kotlin/io/github/truenine/composeserver/depend/httpexchange/WebClientFns.kt b/depend/http-exchange/src/main/kotlin/io/github/truenine/composeserver/depend/httpexchange/WebClientFns.kt index 49c365c8a..b558e67a6 100644 --- a/depend/http-exchange/src/main/kotlin/io/github/truenine/composeserver/depend/httpexchange/WebClientFns.kt +++ b/depend/http-exchange/src/main/kotlin/io/github/truenine/composeserver/depend/httpexchange/WebClientFns.kt @@ -1,9 +1,9 @@ package io.github.truenine.composeserver.depend.httpexchange import com.fasterxml.jackson.databind.ObjectMapper +import io.github.truenine.composeserver.IAnyTyping import io.github.truenine.composeserver.consts.IHeaders import io.github.truenine.composeserver.depend.httpexchange.encoder.AnyTypingEncoder -import io.github.truenine.composeserver.typing.AnyTyping import io.github.truenine.composeserver.typing.MimeTypes import java.time.Duration import java.time.temporal.ChronoUnit @@ -65,7 +65,7 @@ inline fun jsonWebClientRegister( class ArgsResolver : HttpServiceArgumentResolver { override fun resolve(argument: Any?, parameter: MethodParameter, requestValues: HttpRequestValues.Builder): Boolean { - if (argument != null && argument is AnyTyping) { + if (argument != null && argument is IAnyTyping) { val name = parameter.getParameterAnnotation(RequestParam::class.java)?.name ?: parameter.getParameterAnnotation(RequestParam::class.java)?.value diff --git a/depend/http-exchange/src/main/kotlin/io/github/truenine/composeserver/depend/httpexchange/encoder/AnyTypingEncoder.kt b/depend/http-exchange/src/main/kotlin/io/github/truenine/composeserver/depend/httpexchange/encoder/AnyTypingEncoder.kt index e2f03dfe0..39bc668c3 100644 --- a/depend/http-exchange/src/main/kotlin/io/github/truenine/composeserver/depend/httpexchange/encoder/AnyTypingEncoder.kt +++ b/depend/http-exchange/src/main/kotlin/io/github/truenine/composeserver/depend/httpexchange/encoder/AnyTypingEncoder.kt @@ -1,6 +1,6 @@ package io.github.truenine.composeserver.depend.httpexchange.encoder -import io.github.truenine.composeserver.typing.AnyTyping +import io.github.truenine.composeserver.IAnyTyping import org.reactivestreams.Publisher import org.springframework.core.ResolvableType import org.springframework.core.codec.AbstractEncoder @@ -9,9 +9,9 @@ import org.springframework.core.io.buffer.DataBufferFactory import org.springframework.util.MimeType import reactor.core.publisher.Flux -class AnyTypingEncoder : AbstractEncoder() { +class AnyTypingEncoder : AbstractEncoder() { override fun encode( - inputStream: Publisher, + inputStream: Publisher, bufferFactory: DataBufferFactory, elementType: ResolvableType, mimeType: MimeType?, diff --git a/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/autoconfig/JacksonAutoConfiguration.kt b/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/autoconfig/JacksonAutoConfiguration.kt index 19d4c5187..0d9b531a7 100644 --- a/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/autoconfig/JacksonAutoConfiguration.kt +++ b/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/autoconfig/JacksonAutoConfiguration.kt @@ -9,7 +9,7 @@ import com.fasterxml.jackson.databind.cfg.MapperConfig import com.fasterxml.jackson.databind.introspect.* import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule import com.fasterxml.jackson.module.kotlin.KotlinModule -import io.github.truenine.composeserver.DTimer +import io.github.truenine.composeserver.DateTimeConverter import io.github.truenine.composeserver.depend.jackson.modules.DatetimeCustomModule import io.github.truenine.composeserver.depend.jackson.modules.KotlinCustomModule import io.github.truenine.composeserver.slf4j @@ -85,7 +85,7 @@ class JacksonAutoConfiguration { b.timeZone(TimeZone.getTimeZone(zoneOffset)) b.locale(Locale.US) - b.simpleDateFormat(DTimer.DATETIME) + b.simpleDateFormat(DateTimeConverter.DATETIME) b.defaultViewInclusion(true) b.featuresToEnable(DeserializationFeature.ACCEPT_EMPTY_STRING_AS_NULL_OBJECT, DeserializationFeature.ACCEPT_EMPTY_ARRAY_AS_NULL_OBJECT) b.featuresToDisable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, SerializationFeature.WRITE_DURATIONS_AS_TIMESTAMPS) diff --git a/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/serializers/AnyTypingDeserializer.kt b/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/serializers/AnyTypingDeserializer.kt index 048334668..7178e8ff8 100644 --- a/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/serializers/AnyTypingDeserializer.kt +++ b/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/serializers/AnyTypingDeserializer.kt @@ -4,16 +4,16 @@ import com.fasterxml.jackson.core.JsonParser import com.fasterxml.jackson.core.JsonToken import com.fasterxml.jackson.databind.DeserializationContext import com.fasterxml.jackson.databind.deser.std.StdDeserializer -import io.github.truenine.composeserver.typing.AnyTyping -import io.github.truenine.composeserver.typing.IntTyping -import io.github.truenine.composeserver.typing.StringTyping +import io.github.truenine.composeserver.IAnyTyping +import io.github.truenine.composeserver.IIntTyping +import io.github.truenine.composeserver.IStringTyping import kotlin.reflect.KClass @Deprecated(message = "API 负担过大", level = DeprecationLevel.ERROR) class AnyTypingDeserializer(typingType: KClass>) : StdDeserializer>(Enum::class.java) { - private var isIntEnum: Boolean = IntTyping::class.java.isAssignableFrom(typingType.java) - private var isStringEnum: Boolean = StringTyping::class.java.isAssignableFrom(typingType.java) - private val enumValueMap: Map> = typingType.java.enumConstants.associateBy { (it as AnyTyping).value } + private var isIntEnum: Boolean = IIntTyping::class.java.isAssignableFrom(typingType.java) + private var isStringEnum: Boolean = IStringTyping::class.java.isAssignableFrom(typingType.java) + private val enumValueMap: Map> = typingType.java.enumConstants.associateBy { (it as IAnyTyping).value } private val enumNameMap: Map> = typingType.java.enumConstants.associateBy { it.name } private val enumOrdinalMap: Map> = typingType.java.enumConstants.associateBy { it.ordinal } diff --git a/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/serializers/AnyTypingSerializer.kt b/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/serializers/AnyTypingSerializer.kt index 84767cfc3..1a62a90c7 100644 --- a/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/serializers/AnyTypingSerializer.kt +++ b/depend/jackson/src/main/kotlin/io/github/truenine/composeserver/depend/jackson/serializers/AnyTypingSerializer.kt @@ -3,21 +3,21 @@ package io.github.truenine.composeserver.depend.jackson.serializers import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.JsonSerializer import com.fasterxml.jackson.databind.SerializerProvider -import io.github.truenine.composeserver.typing.AnyTyping -import io.github.truenine.composeserver.typing.IntTyping -import io.github.truenine.composeserver.typing.StringTyping +import io.github.truenine.composeserver.IAnyTyping +import io.github.truenine.composeserver.IIntTyping +import io.github.truenine.composeserver.IStringTyping @Deprecated(message = "API 负担过大", level = DeprecationLevel.ERROR) -class AnyTypingSerializer : JsonSerializer() { +class AnyTypingSerializer : JsonSerializer() { - override fun handledType(): Class { - return AnyTyping::class.java + override fun handledType(): Class { + return IAnyTyping::class.java } - override fun serialize(value: AnyTyping?, gen: JsonGenerator?, serializers: SerializerProvider?) { + override fun serialize(value: IAnyTyping?, gen: JsonGenerator?, serializers: SerializerProvider?) { when (value) { - is StringTyping -> gen?.writeString(value.value) - is IntTyping -> gen?.writeNumber(value.value) + is IStringTyping -> gen?.writeString(value.value) + is IIntTyping -> gen?.writeNumber(value.value) null -> gen?.writeNull() else -> gen?.writeNull() } diff --git a/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/converters/AnyTypingConverterFactory.kt b/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/converters/AnyTypingConverterFactory.kt index e8b4bdc48..24e8863a9 100644 --- a/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/converters/AnyTypingConverterFactory.kt +++ b/depend/servlet/src/main/kotlin/io/github/truenine/composeserver/depend/servlet/converters/AnyTypingConverterFactory.kt @@ -1,9 +1,9 @@ package io.github.truenine.composeserver.depend.servlet.converters +import io.github.truenine.composeserver.IAnyTyping +import io.github.truenine.composeserver.IIntTyping +import io.github.truenine.composeserver.IStringTyping import io.github.truenine.composeserver.slf4j -import io.github.truenine.composeserver.typing.AnyTyping -import io.github.truenine.composeserver.typing.IntTyping -import io.github.truenine.composeserver.typing.StringTyping import java.util.concurrent.ConcurrentHashMap import org.springframework.core.convert.converter.Converter import org.springframework.core.convert.converter.ConverterFactory @@ -11,13 +11,13 @@ import org.springframework.core.convert.converter.ConverterFactory @Suppress("DEPRECATION_ERROR") private val log = slf4j() @Deprecated(message = "API 负担过大", level = DeprecationLevel.ERROR) -open class AnyTypingConverterFactory : ConverterFactory { +open class AnyTypingConverterFactory : ConverterFactory { companion object { - @JvmStatic private val converters = ConcurrentHashMap, Converter>() + @JvmStatic private val converters = ConcurrentHashMap, Converter>() } @Suppress("UNCHECKED_CAST") - override fun getConverter(targetType: Class): Converter { + override fun getConverter(targetType: Class): Converter { return converters[targetType].let { it ?: AnyTypingConverter(targetType).also { addedConverter -> @@ -27,14 +27,14 @@ open class AnyTypingConverterFactory : ConverterFactory { } as Converter } - private inner class AnyTypingConverter(targetClass: Class) : Converter { - private val isString = StringTyping::class.java.isAssignableFrom(targetClass) - private val isInt = IntTyping::class.java.isAssignableFrom(targetClass) + private inner class AnyTypingConverter(targetClass: Class) : Converter { + private val isString = IStringTyping::class.java.isAssignableFrom(targetClass) + private val isInt = IIntTyping::class.java.isAssignableFrom(targetClass) private val valueMappingMap = targetClass.enumConstants.associateBy { it?.value } private val nameMappingMap = targetClass.enumConstants.associateBy { (it as Enum<*>).name } private val ordinalMappingMap = targetClass.enumConstants.associateBy { (it as Enum<*>).ordinal } - override fun convert(source: String): AnyTyping? { + override fun convert(source: String): IAnyTyping? { if (source.isBlank()) return null val ordinalOrTypeInt = source.toIntOrNull() return if (ordinalOrTypeInt != null) { diff --git a/docs/GLOBAL_CLAUDE.md b/docs/GLOBAL_CLAUDE.md index a6becf34f..b50226bda 100644 --- a/docs/GLOBAL_CLAUDE.md +++ b/docs/GLOBAL_CLAUDE.md @@ -1,77 +1,47 @@ -# claude code 核心工作规则 +# GLOBAL_CLAUDE.md -## 沟通效率优化 -- 批量处理相关任务以减少交互轮次 -- 将多个相关修改合并为单次操作 +## 个性化开发约定 -# 通用代码标准 +### 执行优先级 +1. 安全检查:扫描敏感信息暴露 +2. 代码质量:函数参数≤5个,使用数据类封装 +3. 格式要求:所有代码块必须使用大括号 -## 编程原则 -- 函数参数限制在5个以内 -- 禁止硬编码常量,使用配置或常量定义 -- 避免静默失败 +### 注释规范 +- API文档:英文注释 +- 代码注释:解释"为什么"而非"做什么" -## 代码风格一致性 -- 强制使用大括号包围代码块,即使只有一行代码(防止类似苹果 goto fail 的安全漏洞) +### 强制要求 +- 严禁代码中暴露API密钥、密码、token -## 注释标准 -- API 文档注释使用英文 -- 注释解释"为什么"而非"是什么" +## 特定语言约定 -## 安全编程指南 -- 严格禁止在代码中暴露敏感信息(密钥、密码等) -- 使用参数化查询防止注入攻击 +### SQL操作步骤 +1. 检查现有查询是否使用参数化 +2. 统一使用snake_case命名 +3. 验证无字符串拼接风险 -# 语言特定标准 +### Kotlin开发步骤 +1. 优先使用val声明不可变变量 +2. 避免!!操作符,使用?.或let{} +3. 数据类替代多参数函数 -## SQL -- 必须使用参数化查询,严格禁止字符串拼接 -- 使用 snake_case 命名约定 +### 前端代码步骤 +- TypeScript: 启用strict模式,避免any类型 +- Vue: 使用Composition API + -----\nSGVsbG8=\n-----END -----" + + assertThrows { PemFormat.parse(maliciousInput) } + } + + @Test + fun boundsCheckingPreventsBufferOverflow() { + val veryLongKeyType = "A".repeat(10000) + + assertThrows { PemFormat[SIMPLE_BASE64, veryLongKeyType] } + } } - @ParameterizedTest(name = "测试换行符「{0}」") - @ValueSource(strings = ["\n", "\r", "\r\n"]) - fun `测试不同换行符处理`(lineEnding: String) { - val pemWithDifferentLineEndings = - """ - -----BEGIN TEST-----${lineEnding} - SGVsbG8gV29ybGQ=${lineEnding} - -----END TEST----- + @Nested + inner class EdgeCaseTests { + + @Test + fun handlesMinimalValidPem() { + val minimalPem = "-----BEGIN A-----\nB\n-----END A-----" + val pemFormat = PemFormat.parse(minimalPem) + + assertEquals("A", pemFormat.schema) + assertEquals("B", pemFormat.content) + } + + @Test + fun handlesEmptyKeyTypeCorrectly() { + val pemWithEmptyType = "-----BEGIN -----\nSGVsbG8=\n-----END -----" + val pemFormat = PemFormat.parse(pemWithEmptyType) + + assertEquals("", pemFormat.schema) + assertEquals("SGVsbG8=", pemFormat.content) + } + + @Test + fun handlesMultipleConsecutiveLineBreaks() { + val pemWithExtraBreaks = """ - .trimIndent() + -----BEGIN TEST----- - val pemFormat = PemFormat.parse(pemWithDifferentLineEndings) - assertEquals("TEST", pemFormat.schema) - assertEquals("SGVsbG8gV29ybGQ=", pemFormat.content) + + SGVsbG8gV29ybGQ= + + + -----END TEST----- + """ + .trimIndent() + + val pemFormat = PemFormat.parse(pemWithExtraBreaks) + assertEquals("TEST", pemFormat.schema) + assertEquals("SGVsbG8gV29ybGQ=", pemFormat.content) + } } } diff --git a/security/crypto/src/test/kotlin/io/github/truenine/composeserver/security/crypto/StringFnsTest.kt b/security/crypto/src/test/kotlin/io/github/truenine/composeserver/security/crypto/StringExtensionsTest.kt similarity index 99% rename from security/crypto/src/test/kotlin/io/github/truenine/composeserver/security/crypto/StringFnsTest.kt rename to security/crypto/src/test/kotlin/io/github/truenine/composeserver/security/crypto/StringExtensionsTest.kt index 1b039ab54..604d23154 100644 --- a/security/crypto/src/test/kotlin/io/github/truenine/composeserver/security/crypto/StringFnsTest.kt +++ b/security/crypto/src/test/kotlin/io/github/truenine/composeserver/security/crypto/StringExtensionsTest.kt @@ -6,7 +6,7 @@ import kotlin.test.assertTrue import org.junit.jupiter.api.Test /** Test cases for String extension functions */ -class StringFnsTest { +class StringExtensionsTest { @Test fun `test uuid generation`() { diff --git a/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/api/IWxpaWebClient.kt b/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/api/IWxpaWebClient.kt index 231b697bc..d6f1bc91b 100644 --- a/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/api/IWxpaWebClient.kt +++ b/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/api/IWxpaWebClient.kt @@ -78,15 +78,15 @@ interface IWxpaWebClient { * @since 2024-03-20 */ data class WxpaWebsiteUserInfoResp( - @JsonProperty("openid") val openId: string?, - @JsonProperty("nickname") val nickName: String?, + @param:JsonProperty("openid") val openId: string?, + @param:JsonProperty("nickname") val nickName: String?, val privilege: List = emptyList(), @Deprecated("过时的接口数据") val headimgurl: String?, @Deprecated("过时的接口数据") val country: String?, @Deprecated("过时的接口数据") val city: String?, @Deprecated("过时的接口数据") val province: String? = null, @Deprecated("过时的接口数据") val sex: Int? = null, - @JsonProperty("unionid") val unionId: String?, + @param:JsonProperty("unionid") val unionId: String?, ) /** diff --git a/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/property/WxpaProperty.kt b/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/property/WxpaProperty.kt index 5e8a12825..6f675b1cb 100644 --- a/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/property/WxpaProperty.kt +++ b/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/property/WxpaProperty.kt @@ -2,7 +2,7 @@ package io.github.truenine.composeserver.security.oauth2.property import io.github.truenine.composeserver.datetime import io.github.truenine.composeserver.iso8601LongUtc -import io.github.truenine.composeserver.security.crypto.Keys +import io.github.truenine.composeserver.security.crypto.CryptographicKeyManager import io.github.truenine.composeserver.security.crypto.sha1 /** @@ -29,7 +29,11 @@ class WxpaProperty { var jsapiTicket: String? = null @JvmOverloads - fun signature(url: String, nonceString: String = Keys.generateRandomAsciiString(), timestamp: Long = datetime.now().iso8601LongUtc): WxpaSignatureResp { + fun signature( + url: String, + nonceString: String = CryptographicKeyManager.generateRandomAsciiString(), + timestamp: Long = datetime.now().iso8601LongUtc, + ): WxpaSignatureResp { val splitUrl = url.split("#")[0] val b = mutableMapOf("noncestr" to nonceString, "jsapi_ticket" to jsapiTicket, "timestamp" to timestamp.toString(), "url" to splitUrl) diff --git a/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/typing/WechatMpGrantTyping.kt b/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/typing/WechatMpGrantTyping.kt index 99dc3ed20..43aaa16bc 100644 --- a/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/typing/WechatMpGrantTyping.kt +++ b/security/oauth2/src/main/kotlin/io/github/truenine/composeserver/security/oauth2/typing/WechatMpGrantTyping.kt @@ -1,7 +1,7 @@ package io.github.truenine.composeserver.security.oauth2.typing import com.fasterxml.jackson.annotation.JsonValue -import io.github.truenine.composeserver.typing.StringTyping +import io.github.truenine.composeserver.IStringTyping /** * # 微信支付验证类型 @@ -9,7 +9,7 @@ import io.github.truenine.composeserver.typing.StringTyping * @author TrueNine * @since 2023-05-31 */ -enum class WechatMpGrantTyping(private val typingCode: String) : StringTyping { +enum class WechatMpGrantTyping(private val typingCode: String) : IStringTyping { CLIENT_CREDENTIAL("client_credential"), AUTH_CODE("authorization_code"); diff --git a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/autoconfig/SensitiveResultResponseBodyAdvice.kt b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/autoconfig/SensitiveResultResponseBodyAdvice.kt deleted file mode 100644 index 6cf50b0f0..000000000 --- a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/autoconfig/SensitiveResultResponseBodyAdvice.kt +++ /dev/null @@ -1,80 +0,0 @@ -package io.github.truenine.composeserver.security.autoconfig - -import io.github.truenine.composeserver.Pr -import io.github.truenine.composeserver.annotations.SensitiveResponse -import io.github.truenine.composeserver.domain.ISensitivity -import io.github.truenine.composeserver.slf4j -import java.lang.reflect.ParameterizedType -import org.springframework.core.MethodParameter -import org.springframework.http.MediaType -import org.springframework.http.converter.HttpMessageConverter -import org.springframework.http.server.ServerHttpRequest -import org.springframework.http.server.ServerHttpResponse -import org.springframework.web.bind.annotation.ControllerAdvice -import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice - -private val log = slf4j() - -@ControllerAdvice -class SensitiveResultResponseBodyAdvice : ResponseBodyAdvice { - private val supportAnnotationClassType = SensitiveResponse::class.java - private val interfaceType = ISensitivity::class.java - - // TODO 实现 resolver ,缓存 每个不同对象的序列化规则,例如:{a: {b: Sensitive}} - // TODO 可以实现 resolver ,规范化 - // TODO 加入缓存机制,同时考虑到动态加载 - override fun supports(returnType: MethodParameter, converterType: Class>): Boolean { - val hasAnnotation = returnType.method?.isAnnotationPresent(supportAnnotationClassType) == true - return hasAnnotation - } - - fun getGenericType(returnType: MethodParameter, clazz: Class<*> = interfaceType): Class<*>? { - if (returnType.genericParameterType is ParameterizedType) { - val rawType = (returnType.genericParameterType as ParameterizedType).rawType - if (rawType is Class<*> && rawType.isAssignableFrom(clazz)) return rawType - } - return null - } - - fun isExtendTypeFor(returnType: MethodParameter, type: Class<*> = interfaceType): Boolean { - return getGenericType(returnType) != null - } - - override fun beforeBodyWrite( - body: Any?, - returnType: MethodParameter, - selectedContentType: MediaType, - selectedConverterType: Class>, - request: ServerHttpRequest, - response: ServerHttpResponse, - ): Any? { - when (body) { - is ISensitivity -> body.changeWithSensitiveData() - is Collection<*> -> body.forEach { if (it is ISensitivity) it.changeWithSensitiveData() } - - is Map<*, *> -> - body.forEach { - if (it.key is ISensitivity) (it.key as ISensitivity).changeWithSensitiveData() - if (it.value is ISensitivity) (it.value as ISensitivity).changeWithSensitiveData() - } - - is Array<*> -> body.forEach { if (it is ISensitivity) it.changeWithSensitiveData() } - - is Iterable<*> -> body.forEach { if (it is ISensitivity) it.changeWithSensitiveData() } - - is Iterator<*> -> body.forEach { if (it is ISensitivity) it.changeWithSensitiveData() } - - is Pr<*> -> { - if (body.d.isNotEmpty()) { - val b = body.d.firstOrNull()?.let { it is ISensitivity } - if (b == true) { - body.d.forEach { if (it is ISensitivity) it.changeWithSensitiveData() } - } - } - } - - else -> {} - } - return body - } -} diff --git a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/JwtIssuer.kt b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/JwtIssuer.kt index 0c5a0d627..467291b13 100644 --- a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/JwtIssuer.kt +++ b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/JwtIssuer.kt @@ -3,8 +3,8 @@ package io.github.truenine.composeserver.security.jwt import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.DTimer -import io.github.truenine.composeserver.security.crypto.Encryptors +import io.github.truenine.composeserver.DateTimeConverter +import io.github.truenine.composeserver.security.crypto.CryptographicOperations import io.github.truenine.composeserver.security.jwt.consts.IssuerParam import io.github.truenine.composeserver.slf4j import java.security.PrivateKey @@ -35,7 +35,7 @@ class JwtIssuer private constructor() : JwtVerifier() { encryptData(createContent(params.encryptedDataObj!!), params.contentEncryptEccKey ?: this@JwtIssuer.contentEccPublicKey!!), ) } - withExpiresAt(DTimer.plusMillisFromCurrent(params.duration?.toMillis() ?: this@JwtIssuer.expireMillis)) + withExpiresAt(DateTimeConverter.plusMillisFromCurrent(params.duration?.toMillis() ?: this@JwtIssuer.expireMillis)) } .sign(Algorithm.RSA256(params.signatureKey ?: this.signatureIssuerKey)) } @@ -43,7 +43,7 @@ class JwtIssuer private constructor() : JwtVerifier() { internal fun createContent(content: Any): String = runCatching { objectMapper.writeValueAsString(content) }.onFailure { log.warn("jwt json 签发异常,或许没有配置序列化器", it) }.getOrElse { "{}" } - internal fun encryptData(encData: String, eccPublicKey: PublicKey): String? = Encryptors.encryptByEccPublicKey(eccPublicKey, encData) + internal fun encryptData(encData: String, eccPublicKey: PublicKey): String? = CryptographicOperations.encryptByEccPublicKey(eccPublicKey, encData) inner class Builder { fun build(): JwtIssuer = this@JwtIssuer diff --git a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/JwtVerifier.kt b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/JwtVerifier.kt index 15ee3b74f..2f642d915 100644 --- a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/JwtVerifier.kt +++ b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/JwtVerifier.kt @@ -3,9 +3,9 @@ package io.github.truenine.composeserver.security.jwt import com.auth0.jwt.JWT import com.auth0.jwt.algorithms.Algorithm import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.DTimer +import io.github.truenine.composeserver.DateTimeConverter import io.github.truenine.composeserver.security.JwtException -import io.github.truenine.composeserver.security.crypto.Encryptors +import io.github.truenine.composeserver.security.crypto.CryptographicOperations import io.github.truenine.composeserver.security.jwt.consts.JwtToken import io.github.truenine.composeserver.security.jwt.consts.VerifierParam import io.github.truenine.composeserver.slf4j @@ -41,7 +41,7 @@ open class JwtVerifier internal constructor() { log.trace("发现sub加密段") token.subject = parseContent(decodedJwt.subject, params.subjectTargetType!!.kotlin) } - token.expireDateTime = DTimer.instantToLocalDateTime(decodedJwt.expiresAt.toInstant()) + token.expireDateTime = DateTimeConverter.instantToLocalDateTime(decodedJwt.expiresAt.toInstant()) token.id = decodedJwt.id token.signatureAlgName = decodedJwt.algorithm } @@ -75,7 +75,7 @@ open class JwtVerifier internal constructor() { } private fun decryptData(encData: String, targetType: KClass, eccPrivateKey: PrivateKey? = this.contentEccPrivateKey): T? { - val content = eccPrivateKey.let { priKey -> Encryptors.decryptByEccPrivateKey(priKey!!, encData) } ?: encData + val content = eccPrivateKey.let { priKey -> CryptographicOperations.decryptByEccPrivateKey(priKey!!, encData) } ?: encData return parseContent(content, targetType) } diff --git a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/consts/IssuerParam.kt b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/consts/IssuerParam.kt index 1e76d4f29..0b61b3dcb 100644 --- a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/consts/IssuerParam.kt +++ b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/consts/IssuerParam.kt @@ -1,6 +1,6 @@ package io.github.truenine.composeserver.security.jwt.consts -import io.github.truenine.composeserver.security.crypto.Keys +import io.github.truenine.composeserver.security.crypto.CryptographicKeyManager import java.security.PublicKey import java.security.interfaces.RSAPrivateKey import java.time.Duration @@ -19,10 +19,10 @@ data class IssuerParam( fun containEncryptContent(): Boolean = null != this.encryptedDataObj fun contentEncryptEccKeyFromBase64(base64Key: String) { - this.contentEncryptEccKey = Keys.readRsaPublicKeyByBase64(base64Key) + this.contentEncryptEccKey = CryptographicKeyManager.readRsaPublicKeyByBase64(base64Key) } fun signatureKeyFromBase64(base64Key: String) { - this.signatureKey = Keys.readRsaPrivateKeyByBase64(base64Key)!! + this.signatureKey = CryptographicKeyManager.readRsaPrivateKeyByBase64(base64Key)!! } } diff --git a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/consts/VerifierParam.kt b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/consts/VerifierParam.kt index 908ac3415..7ecf02545 100644 --- a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/consts/VerifierParam.kt +++ b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/jwt/consts/VerifierParam.kt @@ -1,6 +1,6 @@ package io.github.truenine.composeserver.security.jwt.consts -import io.github.truenine.composeserver.security.crypto.Keys +import io.github.truenine.composeserver.security.crypto.CryptographicKeyManager import java.security.PrivateKey import java.security.interfaces.RSAPublicKey @@ -16,10 +16,10 @@ data class VerifierParam( fun isRequireDecrypted(): Boolean = this.encryptDataTargetType != null fun contentEncryptEccKeyFromBase64(base64Key: String) { - this.contentEncryptEccKey = Keys.readEccPrivateKeyByBase64(base64Key) + this.contentEncryptEccKey = CryptographicKeyManager.readEccPrivateKeyByBase64(base64Key) } fun signatureKeyFromBase64(base64Key: String) { - this.signatureKey = Keys.readRsaPublicKeyByBase64(base64Key)!! + this.signatureKey = CryptographicKeyManager.readRsaPublicKeyByBase64(base64Key)!! } } diff --git a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/spring/security/SecurityExceptionAdware.kt b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/spring/security/SecurityExceptionAdware.kt index 5f0e64830..9a61affcb 100644 --- a/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/spring/security/SecurityExceptionAdware.kt +++ b/security/spring/src/main/kotlin/io/github/truenine/composeserver/security/spring/security/SecurityExceptionAdware.kt @@ -1,7 +1,7 @@ package io.github.truenine.composeserver.security.spring.security import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.ErrorBody +import io.github.truenine.composeserver.ErrorResponseEntity import io.github.truenine.composeserver.slf4j import io.github.truenine.composeserver.typing.HttpStatusTyping import io.github.truenine.composeserver.typing.MimeTypes @@ -23,15 +23,15 @@ import org.springframework.security.web.access.AccessDeniedHandler abstract class SecurityExceptionAdware(private var mapper: ObjectMapper? = null) : AccessDeniedHandler, AuthenticationEntryPoint { override fun commence(request: HttpServletRequest, response: HttpServletResponse, ex: AuthenticationException) { log.warn("授权校验异常", ex) - writeErrorMessage(response, ErrorBody.failedByHttpStatus(HttpStatusTyping._401)) + writeErrorMessage(response, ErrorResponseEntity(HttpStatusTyping._401)) } override fun handle(request: HttpServletRequest, response: HttpServletResponse, ex: AccessDeniedException) { log.warn("无权限异常", ex) - writeErrorMessage(response, ErrorBody.failedByHttpStatus(HttpStatusTyping._403)) + writeErrorMessage(response, ErrorResponseEntity(HttpStatusTyping._403)) } - private fun writeErrorMessage(response: HttpServletResponse, msg: ErrorBody, charset: Charset = Charsets.UTF_8) { + private fun writeErrorMessage(response: HttpServletResponse, msg: ErrorResponseEntity, charset: Charset = Charsets.UTF_8) { response.status = msg.code!! response.characterEncoding = charset.displayName() response.contentType = MimeTypes.JSON.value diff --git a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/FileKeyRepoTest.kt b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/FileKeyRepoTest.kt deleted file mode 100644 index 763ea0831..000000000 --- a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/FileKeyRepoTest.kt +++ /dev/null @@ -1,84 +0,0 @@ -package io.github.truenine.composeserver.security - -import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.security.crypto.FileKeyRepo -import io.github.truenine.composeserver.security.crypto.Keys -import io.github.truenine.composeserver.security.crypto.PemFormat -import io.github.truenine.composeserver.security.jwt.JwtIssuer -import io.github.truenine.composeserver.security.jwt.JwtVerifier -import io.github.truenine.composeserver.security.jwt.consts.IssuerParam -import io.github.truenine.composeserver.security.jwt.consts.VerifierParam -import java.time.Duration -import java.time.temporal.ChronoUnit -import kotlin.test.assertNotNull -import org.junit.jupiter.api.Test - -class FileKeyRepoTest { - - @Test - fun findKeyByName() { - val e = Keys.generateEccKeyPair() - - println(Keys.writeKeyToPem(e!!.privateKey)) - println("=========") - println(Keys.writeKeyToPem(e.publicKey)) - - val f = FileKeyRepo() - val b = f.findEccPrivateKeyByName("acv.pem") - val c = f.findEccPublicKeyByName("acc.pem") - - val eccPair = f.findEccKeyPairByName("acc.pem", "acv.pem") - - println(b) - println(c) - println(eccPair) - } - - @Test - fun findRsa() { - val r = Keys.generateRsaKeyPair()!! - println(PemFormat[r.publicKey]) - println(PemFormat[r.privateKey]) - - val f = FileKeyRepo() - val k = f.findRsaKeyPairByName("rcc.pem", "rcv.pem")!! - println(k.publicKey) - println(k.privateKey) - } - - @Test - fun testReadJwt() { - val f = FileKeyRepo() - val e = f.jwtEncryptDataIssuerEccKeyPair() - val s = f.jwtSignatureIssuerRsaKeyPair() - assertNotNull(e) - assertNotNull(e.publicKey) - assertNotNull(e.privateKey) - assertNotNull(s) - assertNotNull(s.publicKey) - assertNotNull(s.privateKey) - - val iss = - JwtIssuer.createIssuer() - .serializer(ObjectMapper()) - .expireFromDuration(Duration.of(100, ChronoUnit.SECONDS)) - .signatureIssuerKey(s.privateKey) - .signatureVerifyKey(s.publicKey) - .contentEncryptKey(e.publicKey) - .contentDecryptKey(e.privateKey) - .build() - - val ver = JwtVerifier.createVerifier().serializer(ObjectMapper()).contentDecryptKey(e.privateKey).signatureVerifyKey(s.publicKey).build() - - val issToken = - iss.issued( - IssuerParam().apply { - encryptedDataObj = "1" to "2" - subjectObj = "3" to "4" - } - ) - val res = ver.verify(VerifierParam(issToken, subjectTargetType = Any::class.java, encryptDataTargetType = Any::class.java)) - println(res?.subject) - println(res?.decryptedData) - } -} diff --git a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/controller/ExtendedSensitiveController.kt b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/controller/ExtendedSensitiveController.kt deleted file mode 100644 index 38b825421..000000000 --- a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/controller/ExtendedSensitiveController.kt +++ /dev/null @@ -1,95 +0,0 @@ -package io.github.truenine.composeserver.security.controller - -import io.github.truenine.composeserver.Pr -import io.github.truenine.composeserver.annotations.SensitiveResponse -import io.github.truenine.composeserver.domain.ISensitivity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -/** 扩展的敏感数据测试控制器 用于测试更多的敏感数据处理场景 */ -@RestController -@RequestMapping("test/sensitive/extended") -class ExtendedSensitiveController { - - /** 用户信息响应类,包含多种敏感数据 */ - class UserInfo( - var id: Long = 1L, - var username: String = "testuser", - var phone: String = "13812345678", - var email: String = "test@example.com", - var idCard: String = "110101199001011234", - var address: String = "北京市朝阳区建国门外大街1号", - ) : ISensitivity { - override fun changeWithSensitiveData() { - super.changeWithSensitiveData() - // 脱敏处理 - phone = "138****5678" - email = "te****@example.com" - idCard = "11****1234" - address = "北京市****1号" - } - } - - /** 简单的敏感数据类 */ - class SimpleData(var value: String = "sensitive") : ISensitivity { - override fun changeWithSensitiveData() { - super.changeWithSensitiveData() - value = "****" - } - } - - /** 嵌套的敏感数据类 */ - class NestedData(var publicInfo: String = "public", var sensitiveInfo: SimpleData = SimpleData()) : ISensitivity { - override fun changeWithSensitiveData() { - super.changeWithSensitiveData() - sensitiveInfo.changeWithSensitiveData() - } - } - - @SensitiveResponse - @GetMapping("user", produces = ["application/json"]) - fun getUserInfo(): Pr { - return Pr[ - listOf( - UserInfo(1L, "user1", "13812345678", "user1@test.com", "110101199001011234", "北京市朝阳区建国门外大街1号"), - UserInfo(2L, "user2", "18612345678", "user2@test.com", "110101199001011235", "上海市浦东新区陆家嘴环路1000号"), - )] - } - - @SensitiveResponse - @GetMapping("simple", produces = ["application/json"]) - fun getSimpleData(): SimpleData { - return SimpleData("very sensitive data") - } - - @SensitiveResponse - @GetMapping("nested", produces = ["application/json"]) - fun getNestedData(): NestedData { - return NestedData("public info", SimpleData("nested sensitive")) - } - - @SensitiveResponse - @GetMapping("collection", produces = ["application/json"]) - fun getCollection(): Collection { - return listOf(SimpleData("data1"), SimpleData("data2"), SimpleData("data3")) - } - - @SensitiveResponse - @GetMapping("array", produces = ["application/json"]) - fun getArray(): Array { - return arrayOf(SimpleData("array1"), SimpleData("array2")) - } - - @SensitiveResponse - @GetMapping("map", produces = ["application/json"]) - fun getMap(): Map { - return mapOf("key1" to SimpleData("map1"), "key2" to SimpleData("map2")) - } - - /** 不带 @SensitiveResponse 注解的方法,用于对比测试 */ - @GetMapping("no-annotation", produces = ["application/json"]) - fun getDataWithoutAnnotation(): SimpleData { - return SimpleData("should not be desensitized") - } -} diff --git a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/controller/SensitiveController.kt b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/controller/SensitiveController.kt deleted file mode 100644 index 556a67bee..000000000 --- a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/controller/SensitiveController.kt +++ /dev/null @@ -1,25 +0,0 @@ -package io.github.truenine.composeserver.security.controller - -import io.github.truenine.composeserver.Pr -import io.github.truenine.composeserver.annotations.SensitiveResponse -import io.github.truenine.composeserver.domain.ISensitivity -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -@RestController -@RequestMapping("test/sensitive") -class SensitiveController { - class Resp(var a: Int = 1) : ISensitivity { - override fun changeWithSensitiveData() { - super.changeWithSensitiveData() - this.a = 233 - } - } - - @SensitiveResponse - @GetMapping("get", produces = ["application/json"]) - fun `test get a`(): Pr { - return Pr[listOf(Resp(), Resp(), Resp())] - } -} diff --git a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/BootLaunchTest.kt b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/BootLaunchTest.kt deleted file mode 100644 index 0e3b378e2..000000000 --- a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/BootLaunchTest.kt +++ /dev/null @@ -1,19 +0,0 @@ -package io.github.truenine.composeserver.security.jwt - -import io.github.truenine.composeserver.testtoolkit.info -import io.github.truenine.composeserver.testtoolkit.log -import jakarta.annotation.Resource -import org.junit.jupiter.api.Test -import org.springframework.boot.test.context.SpringBootTest -import org.springframework.context.ApplicationContext - -@SpringBootTest -class BootLaunchTest { - lateinit var ctx: ApplicationContext - @Resource set - - @Test - fun `test launch`() { - log.info(::ctx) - } -} diff --git a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/JwtIssuerVerifierTest.kt b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/JwtIssuerVerifierTest.kt index 7e7cf6b4a..ec124cfff 100644 --- a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/JwtIssuerVerifierTest.kt +++ b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/JwtIssuerVerifierTest.kt @@ -1,140 +1,531 @@ package io.github.truenine.composeserver.security.jwt import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.security.crypto.Keys +import io.github.truenine.composeserver.security.crypto.CryptographicKeyManager +import io.github.truenine.composeserver.security.crypto.domain.IEccExtKeyPair +import io.github.truenine.composeserver.security.crypto.domain.IRsaExtKeyPair import io.github.truenine.composeserver.security.jwt.consts.IssuerParam import io.github.truenine.composeserver.security.jwt.consts.VerifierParam import java.time.Duration import java.time.temporal.ChronoUnit -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue +import kotlin.test.* +import kotlin.time.measureTime import org.junit.jupiter.api.Test -/** Test JWT issuer and verifier functionality */ +/** + * Comprehensive test suite for JWT issuer and verifier functionality. + * + * This test class validates JWT token generation, verification, encryption/decryption, error handling, and performance characteristics. Tests follow TDD + * principles with independent, atomic tests that provide complete coverage of JWT operations. + * + * @author TrueNine + * @since 2024-07-14 + */ class JwtIssuerVerifierTest { + companion object { + private const val TEST_ISSUER = "test-issuer" + private const val TEST_ID = "test-id" + private const val SIMPLE_TEST_ISSUER = "simple-test" + private const val SIMPLE_TEST_ID = "simple-id" + private const val TEST_EXPIRE_SECONDS = 100L + private const val LARGE_DATA_SIZE = 10000 + private const val CONCURRENT_THREADS = 10 + private const val CONCURRENT_ITERATIONS = 100 + private const val PERFORMANCE_THRESHOLD_MS = 5000L + } + private val objectMapper = ObjectMapper() - @Test - fun `test JWT issuer and verifier integration`() { - val eccPair = Keys.generateEccKeyPair()!! - val rsaPair = Keys.generateRsaKeyPair()!! + /** Test data container for JWT testing scenarios. */ + data class TestKeyPairs(val rsaKeyPair: IRsaExtKeyPair, val eccKeyPair: IEccExtKeyPair) - val issuer = - JwtIssuer.createIssuer() - .issuer("test-issuer") - .id("test-id") - .signatureIssuerKey(rsaPair.privateKey) - .signatureVerifyKey(rsaPair.publicKey) - .contentEncryptKey(eccPair.publicKey) - .serializer(objectMapper) - .expireFromDuration(Duration.of(100, ChronoUnit.SECONDS)) - .build() + /** Test data container for various data types. */ + data class TestTokenData( + val simpleString: String = "simple string data", + val complexMap: Map = mapOf("key1" to "value1", "key2" to 123, "key3" to true), + val listData: List = listOf("user1", "admin", "moderator"), + val specialChars: String = "特殊字符测试!@#$%^&*()_+-=[]{}|;':\",./<>?", + val emptyString: String = "", + val nullValue: String? = null, + ) - val verifier = - JwtVerifier.createVerifier() - .issuer("test-issuer") - .id("test-id") - .contentDecryptKey(eccPair.privateKey) - .signatureVerifyKey(rsaPair.publicKey) - .serializer(objectMapper) - .build() + /** Creates test key pairs for JWT operations. */ + private fun createTestKeyPairs(): TestKeyPairs { + val rsaKeyPair = CryptographicKeyManager.generateRsaKeyPair() ?: fail("Failed to generate RSA key pair") + val eccKeyPair = CryptographicKeyManager.generateEccKeyPair(CryptographicKeyManager.generateRandomAsciiString()) ?: fail("Failed to generate ECC key pair") + return TestKeyPairs(rsaKeyPair, eccKeyPair) + } - val testData = mapOf("key1" to "value1", "key2" to 123) - val testSubject = listOf("user1", "admin") + /** Creates a JWT issuer with specified configuration. */ + private fun createJwtIssuer( + keyPairs: TestKeyPairs, + issuer: String = TEST_ISSUER, + id: String = TEST_ID, + expireSeconds: Long = TEST_EXPIRE_SECONDS, + ): JwtIssuer { + return JwtIssuer.createIssuer() + .issuer(issuer) + .id(id) + .signatureIssuerKey(keyPairs.rsaKeyPair.privateKey) + .signatureVerifyKey(keyPairs.rsaKeyPair.publicKey) + .contentEncryptKey(keyPairs.eccKeyPair.publicKey) + .serializer(objectMapper) + .expireFromDuration(Duration.of(expireSeconds, ChronoUnit.SECONDS)) + .build() + } - val issuerParam = IssuerParam(signatureKey = rsaPair.privateKey) - issuerParam.encryptedDataObj = testData - issuerParam.subjectObj = testSubject + /** Creates a JWT verifier with specified configuration. */ + private fun createJwtVerifier(keyPairs: TestKeyPairs, issuer: String = TEST_ISSUER, id: String = TEST_ID): JwtVerifier { + return JwtVerifier.createVerifier() + .issuer(issuer) + .id(id) + .contentDecryptKey(keyPairs.eccKeyPair.privateKey) + .signatureVerifyKey(keyPairs.rsaKeyPair.publicKey) + .serializer(objectMapper) + .build() + } + + /** Validates that a token string is properly formatted. */ + private fun assertTokenValid(token: String) { + assertNotNull(token, "Token should not be null") + assertTrue(token.isNotEmpty(), "Token should not be empty") + assertTrue(token.contains("."), "Token should contain JWT separators") + val parts = token.split(".") + assertEquals(3, parts.size, "JWT should have 3 parts (header.payload.signature)") + parts.forEach { part -> assertTrue(part.isNotEmpty(), "JWT part should not be empty") } + } + + @Test + fun testJwtIssuerAndVerifierIntegration() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + val verifier = createJwtVerifier(keyPairs) + val testData = TestTokenData() + + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = testData.complexMap + issuerParam.subjectObj = testData.listData val token = issuer.issued(issuerParam) - assertNotNull(token) - assertTrue(token.isNotEmpty()) + assertTokenValid(token) val verifierParam = VerifierParam(token = token, subjectTargetType = Any::class.java, encryptDataTargetType = Any::class.java) val result = verifier.verify(verifierParam) - assertNotNull(result) - assertNotNull(result.subject) - assertNotNull(result.decryptedData) + assertNotNull(result, "Verification result should not be null") + assertNotNull(result.subject, "Subject should not be null") + assertNotNull(result.decryptedData, "Decrypted data should not be null") + assertFalse(result.isExpired, "Token should not be expired") + assertEquals(TEST_ID, result.id, "Token ID should match") } @Test - fun `test JWT issuer builder pattern`() { - val eccPair = Keys.generateEccKeyPair()!! - val rsaPair = Keys.generateRsaKeyPair()!! + fun testJwtIssuerBuilderPattern() { + val keyPairs = createTestKeyPairs() val issuer = JwtIssuer.createIssuer() .serializer(objectMapper) .issuer("test") .id("1") - .signatureIssuerKey(rsaPair.privateKey) - .signatureVerifyKey(rsaPair.publicKey) - .contentEncryptKey(eccPair.publicKey) - .contentDecryptKey(eccPair.privateKey) + .signatureIssuerKey(keyPairs.rsaKeyPair.privateKey) + .signatureVerifyKey(keyPairs.rsaKeyPair.publicKey) + .contentEncryptKey(keyPairs.eccKeyPair.publicKey) + .contentDecryptKey(keyPairs.eccKeyPair.privateKey) .build() - assertNotNull(issuer) + assertNotNull(issuer, "JWT issuer should be created successfully") } @Test - fun `test JWT verifier builder pattern`() { - val eccPair = Keys.generateEccKeyPair()!! - val rsaPair = Keys.generateRsaKeyPair()!! + fun testJwtVerifierBuilderPattern() { + val keyPairs = createTestKeyPairs() val verifier = JwtVerifier.createVerifier() .serializer(objectMapper) .issuer("test") .id("1") - .contentDecryptKey(eccPair.privateKey) - .signatureVerifyKey(rsaPair.publicKey) + .contentDecryptKey(keyPairs.eccKeyPair.privateKey) + .signatureVerifyKey(keyPairs.rsaKeyPair.publicKey) .build() - assertNotNull(verifier) + assertNotNull(verifier, "JWT verifier should be created successfully") } @Test - fun `test JWT with simple data types`() { - val eccPair = Keys.generateEccKeyPair()!! - val rsaPair = Keys.generateRsaKeyPair()!! - - val issuer = - JwtIssuer.createIssuer() - .issuer("simple-test") - .id("simple-id") - .signatureIssuerKey(rsaPair.privateKey) - .signatureVerifyKey(rsaPair.publicKey) - .contentEncryptKey(eccPair.publicKey) - .serializer(objectMapper) - .build() - - val verifier = - JwtVerifier.createVerifier() - .issuer("simple-test") - .id("simple-id") - .contentDecryptKey(eccPair.privateKey) - .signatureVerifyKey(rsaPair.publicKey) - .serializer(objectMapper) - .build() + fun testJwtWithSimpleDataTypes() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs, SIMPLE_TEST_ISSUER, SIMPLE_TEST_ID) + val verifier = createJwtVerifier(keyPairs, SIMPLE_TEST_ISSUER, SIMPLE_TEST_ID) val simpleData = "simple string data" val simpleSubject = "user123" - val issuerParam = IssuerParam(signatureKey = rsaPair.privateKey) + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) issuerParam.encryptedDataObj = simpleData issuerParam.subjectObj = simpleSubject val token = issuer.issued(issuerParam) - assertNotNull(token) + assertTokenValid(token) + + val verifierParam = VerifierParam(token = token, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + + val result = verifier.verify(verifierParam) + assertNotNull(result, "Verification result should not be null") + assertEquals(simpleSubject, result.subject, "Subject should match") + assertEquals(simpleData, result.decryptedData, "Decrypted data should match") + assertFalse(result.isExpired, "Token should not be expired") + } + + @Test + fun testInvalidTokenVerification() { + val keyPairs = createTestKeyPairs() + val verifier = createJwtVerifier(keyPairs) + + val invalidTokens = + listOf("invalid.token.format", "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.invalid.signature", "", "not.a.jwt", "too.many.parts.in.this.token") + + invalidTokens.forEach { invalidToken -> + val verifierParam = + VerifierParam(token = invalidToken, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + + val result = verifier.verify(verifierParam) + assertNull(result, "Invalid token should return null result") + } + } + + @Test + fun testWrongKeyVerification() { + val keyPairs1 = createTestKeyPairs() + val keyPairs2 = createTestKeyPairs() + + val issuer = createJwtIssuer(keyPairs1) + val verifierWithWrongKey = createJwtVerifier(keyPairs2) // Different keys + + val issuerParam = IssuerParam(signatureKey = keyPairs1.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = "test data" + issuerParam.subjectObj = "test subject" + + val token = issuer.issued(issuerParam) + assertTokenValid(token) + + val verifierParam = VerifierParam(token = token, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + + val result = verifierWithWrongKey.verify(verifierParam) + // When using wrong keys, the verification should either return null or a token with null decryptedData + // due to decryption failure, but the signature verification will fail + if (result != null) { + // If result is not null, the encrypted data should be null due to decryption failure + assertNull(result.decryptedData, "Decrypted data should be null when using wrong decryption key") + } + // Either way is acceptable - null result or result with null decryptedData + assertTrue(result == null || result.decryptedData == null, "Token verified with wrong key should either return null or have null decrypted data") + } + + @Test + fun testNullParameterHandling() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + + // Test with null encrypted data + val issuerParamWithNullData = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParamWithNullData.encryptedDataObj = null + issuerParamWithNullData.subjectObj = "test subject" + + val tokenWithNullData = issuer.issued(issuerParamWithNullData as IssuerParam) + assertTokenValid(tokenWithNullData) + + // Test with null subject + val issuerParamWithNullSubject = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParamWithNullSubject.encryptedDataObj = "test data" + issuerParamWithNullSubject.subjectObj = null + + val tokenWithNullSubject = issuer.issued(issuerParamWithNullSubject as IssuerParam) + assertTokenValid(tokenWithNullSubject) + } + + @Test + fun testEmptyDataHandling() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + val verifier = createJwtVerifier(keyPairs) + + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = "" + issuerParam.subjectObj = "" + + val token = issuer.issued(issuerParam) + assertTokenValid(token) + + val verifierParam = VerifierParam(token = token, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + + val result = verifier.verify(verifierParam) + assertNotNull(result, "Verification result should not be null") + assertEquals("", result.subject, "Empty subject should be preserved") + assertEquals("", result.decryptedData, "Empty data should be preserved") + } + + @Test + fun testSpecialCharacterHandling() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + val verifier = createJwtVerifier(keyPairs) + val testData = TestTokenData() + + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = testData.specialChars + issuerParam.subjectObj = testData.specialChars + + val token = issuer.issued(issuerParam) + assertTokenValid(token) + + val verifierParam = VerifierParam(token = token, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + + val result = verifier.verify(verifierParam) + assertNotNull(result, "Verification result should not be null") + assertEquals(testData.specialChars, result.subject, "Special characters in subject should be preserved") + assertEquals(testData.specialChars, result.decryptedData, "Special characters in data should be preserved") + } + + @Test + fun testLargeDataHandling() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + val verifier = createJwtVerifier(keyPairs) + + val largeData = "x".repeat(LARGE_DATA_SIZE) + val largeSubject = "subject_${"y".repeat(1000)}" + + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = largeData + issuerParam.subjectObj = largeSubject + + val token = issuer.issued(issuerParam) + assertTokenValid(token) + + val verifierParam = VerifierParam(token = token, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + + val result = verifier.verify(verifierParam) + assertNotNull(result, "Verification result should not be null") + assertEquals(largeSubject, result.subject, "Large subject should be preserved") + assertEquals(largeData, result.decryptedData, "Large data should be preserved") + } + + @Test + fun testTokenExpirationValidation() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs, expireSeconds = 1) // 1 second expiration + val verifier = createJwtVerifier(keyPairs) + + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = "test data" + issuerParam.subjectObj = "test subject" + + val token = issuer.issued(issuerParam) + assertTokenValid(token) + + // Verify immediately (should work) + val verifierParam = VerifierParam(token = token, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + + val immediateResult = verifier.verify(verifierParam) + assertNotNull(immediateResult, "Immediate verification should succeed") + assertFalse(immediateResult.isExpired, "Token should not be expired immediately") + + // Wait for expiration and verify again + Thread.sleep(2000) // Wait 2 seconds + + val expiredResult = verifier.verify(verifierParam) + // The token should be marked as expired but still decoded + if (expiredResult != null) { + assertTrue(expiredResult.isExpired, "Token should be marked as expired") + } + } + + @Test + fun testEncryptionDecryptionConsistency() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + val verifier = createJwtVerifier(keyPairs) + + val testCases = listOf("Simple string", mapOf("key" to "value", "number" to 42), listOf("item1", "item2", "item3"), 123456789, true, null) + + testCases.forEach { testData -> + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = testData + issuerParam.subjectObj = "test_subject_${testData?.hashCode()}" + + val token = issuer.issued(issuerParam) + assertTokenValid(token) + + val verifierParam = VerifierParam(token = token, subjectTargetType = Any::class.java, encryptDataTargetType = Any::class.java) + + val result = verifier.verify(verifierParam) + assertNotNull(result, "Verification result should not be null for data: $testData") + + if (testData != null) { + assertNotNull(result.decryptedData, "Decrypted data should not be null for: $testData") + } + } + } + + @Test + fun testPerformanceBenchmark() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + val verifier = createJwtVerifier(keyPairs) + + val iterations = 100 + val testData = "performance test data" + val testSubject = "performance test subject" + + val issueTime = measureTime { + repeat(iterations) { + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = testData + issuerParam.subjectObj = testSubject + + val token = issuer.issued(issuerParam) + assertTokenValid(token) + } + } + + val tokens = mutableListOf() + repeat(iterations) { + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = testData + issuerParam.subjectObj = testSubject + tokens.add(issuer.issued(issuerParam)) + } + + val verifyTime = measureTime { + tokens.forEach { token -> + val verifierParam = VerifierParam(token = token, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + val result = verifier.verify(verifierParam) + assertNotNull(result, "Verification should succeed") + } + } + + println("Performance benchmark results:") + println(" Token generation: $iterations tokens in ${issueTime.inWholeMilliseconds}ms") + println(" Token verification: $iterations tokens in ${verifyTime.inWholeMilliseconds}ms") + + assertTrue(issueTime.inWholeMilliseconds < PERFORMANCE_THRESHOLD_MS, "Token generation should complete within ${PERFORMANCE_THRESHOLD_MS}ms") + assertTrue(verifyTime.inWholeMilliseconds < PERFORMANCE_THRESHOLD_MS, "Token verification should complete within ${PERFORMANCE_THRESHOLD_MS}ms") + } + + @Test + fun testConcurrentTokenGeneration() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + val verifier = createJwtVerifier(keyPairs) + + val tokens = mutableListOf() + val threads = mutableListOf() + + repeat(CONCURRENT_THREADS) { threadId -> + val thread = Thread { + repeat(CONCURRENT_ITERATIONS) { iteration -> + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = "data_${threadId}_$iteration" + issuerParam.subjectObj = "subject_${threadId}_$iteration" + + val token = issuer.issued(issuerParam) + synchronized(tokens) { tokens.add(token) } + } + } + threads.add(thread) + } + + val executionTime = measureTime { + threads.forEach { it.start() } + threads.forEach { it.join() } + } + + assertEquals(CONCURRENT_THREADS * CONCURRENT_ITERATIONS, tokens.size, "All tokens should be generated") + + // Verify all tokens are unique + val uniqueTokens = tokens.toSet() + assertEquals(tokens.size, uniqueTokens.size, "All generated tokens should be unique") + + // Verify all tokens are valid + tokens.forEach { token -> + assertTokenValid(token) + + val verifierParam = VerifierParam(token = token, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + + val result = verifier.verify(verifierParam) + assertNotNull(result, "All tokens should verify successfully") + } + + println("Concurrent generation: ${tokens.size} tokens in ${executionTime.inWholeMilliseconds}ms") + assertTrue(executionTime.inWholeMilliseconds < PERFORMANCE_THRESHOLD_MS * 2, "Concurrent generation should complete within reasonable time") + } + + @Test + fun testTokenDecodeWithoutVerification() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + val verifier = createJwtVerifier(keyPairs) + + val testData = "decode test data" + val testSubject = "decode test subject" + + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = testData + issuerParam.subjectObj = testSubject + + val token = issuer.issued(issuerParam) + assertTokenValid(token) val verifierParam = VerifierParam(token = token, subjectTargetType = String::class.java, encryptDataTargetType = String::class.java) + // Test decode method (without signature verification) + val decodedResult = verifier.decode(verifierParam) + assertNotNull(decodedResult, "Decode result should not be null") + assertEquals(testSubject, decodedResult.subject, "Decoded subject should match") + assertEquals(testData, decodedResult.decryptedData, "Decoded data should match") + assertEquals(TEST_ID, decodedResult.id, "Decoded ID should match") + assertNotNull(decodedResult.expireDateTime, "Expire date time should be set") + assertNotNull(decodedResult.signatureAlgName, "Signature algorithm should be set") + } + + @Test + fun testComplexDataStructures() { + val keyPairs = createTestKeyPairs() + val issuer = createJwtIssuer(keyPairs) + val verifier = createJwtVerifier(keyPairs) + + data class ComplexData( + val id: Long, + val name: String, + val properties: Map, + val tags: List, + val metadata: Map>, + ) + + val complexData = + ComplexData( + id = 12345L, + name = "Complex Test Object", + properties = mapOf("active" to true, "score" to 95.5, "category" to "test"), + tags = listOf("tag1", "tag2", "tag3"), + metadata = mapOf("system" to mapOf("version" to "1.0", "build" to 123), "user" to mapOf("preferences" to mapOf("theme" to "dark"))), + ) + + val issuerParam = IssuerParam(signatureKey = keyPairs.rsaKeyPair.privateKey) + issuerParam.encryptedDataObj = complexData + issuerParam.subjectObj = complexData + + val token = issuer.issued(issuerParam) + assertTokenValid(token) + + val verifierParam = VerifierParam(token = token, subjectTargetType = Any::class.java, encryptDataTargetType = Any::class.java) + val result = verifier.verify(verifierParam) - assertNotNull(result) - assertEquals(simpleSubject, result.subject) - assertEquals(simpleData, result.decryptedData) + assertNotNull(result, "Verification result should not be null") + assertNotNull(result.subject, "Complex subject should be preserved") + assertNotNull(result.decryptedData, "Complex data should be preserved") } } diff --git a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/JwtTest.kt b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/JwtTest.kt index 718540de2..f554cfad1 100644 --- a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/JwtTest.kt +++ b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/jwt/JwtTest.kt @@ -1,7 +1,7 @@ package io.github.truenine.composeserver.security.jwt import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.security.crypto.Keys +import io.github.truenine.composeserver.security.crypto.CryptographicKeyManager import io.github.truenine.composeserver.security.jwt.consts.IssuerParam import io.github.truenine.composeserver.security.jwt.consts.VerifierParam import org.junit.jupiter.api.Test @@ -11,8 +11,8 @@ class JwtTest { @Test fun testIssuerAndVerifier() { val mapper = ObjectMapper() - val eccPair = Keys.generateEccKeyPair()!! - val rsaPair = Keys.generateRsaKeyPair()!! + val eccPair = CryptographicKeyManager.generateEccKeyPair(CryptographicKeyManager.generateRandomAsciiString())!! + val rsaPair = CryptographicKeyManager.generateRsaKeyPair()!! val issuer = JwtIssuer.createIssuer() diff --git a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/sensitive/ExtendedSensitiveTest.kt b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/sensitive/ExtendedSensitiveTest.kt deleted file mode 100644 index 3e1b1cc0c..000000000 --- a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/sensitive/ExtendedSensitiveTest.kt +++ /dev/null @@ -1,247 +0,0 @@ -package io.github.truenine.composeserver.security.sensitive - -import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.security.autoconfig.SensitiveResultResponseBodyAdvice -import io.github.truenine.composeserver.security.controller.ExtendedSensitiveController -import io.github.truenine.composeserver.testtoolkit.log -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get -import org.springframework.test.web.servlet.setup.MockMvcBuilders - -/** 扩展的敏感数据处理功能测试 */ -class ExtendedSensitiveTest { - - private lateinit var mockMvc: MockMvc - private val objectMapper = ObjectMapper() - - @BeforeEach - fun setup() { - val controller = ExtendedSensitiveController() - val sensitive = SensitiveResultResponseBodyAdvice() - mockMvc = MockMvcBuilders.standaloneSetup(controller).setControllerAdvice(sensitive).build() - } - - @Test - fun `test user info desensitization`() { - log.info("测试用户信息脱敏") - - val result = - mockMvc - .get("/test/sensitive/extended/user") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("用户信息响应: {}", responseContent) - - val responseMap = objectMapper.readValue(responseContent, Map::class.java) - val dataList = responseMap["d"] as List<*> - - assertTrue(dataList.isNotEmpty(), "用户数据列表不应该为空") - - dataList.forEach { item -> - val userMap = item as Map<*, *> - val phone = userMap["phone"] as String - val email = userMap["email"] as String - val idCard = userMap["idCard"] as String - val address = userMap["address"] as String - - // 验证敏感数据被正确脱敏 - assertTrue(phone.contains("****"), "手机号应该被脱敏") - assertTrue(email.contains("****"), "邮箱应该被脱敏") - assertTrue(idCard.contains("****"), "身份证号应该被脱敏") - assertTrue(address.contains("****"), "地址应该被脱敏") - } - - log.info("用户信息脱敏测试通过") - } - - @Test - fun `test simple data desensitization`() { - log.info("测试简单数据脱敏") - - val result = - mockMvc - .get("/test/sensitive/extended/simple") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("简单数据响应: {}", responseContent) - - val responseMap = objectMapper.readValue(responseContent, Map::class.java) - val value = responseMap["value"] as String - - assertEquals("****", value, "简单敏感数据应该被脱敏为 ****") - - log.info("简单数据脱敏测试通过") - } - - @Test - fun `test nested data desensitization`() { - log.info("测试嵌套数据脱敏") - - val result = - mockMvc - .get("/test/sensitive/extended/nested") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("嵌套数据响应: {}", responseContent) - - val responseMap = objectMapper.readValue(responseContent, Map::class.java) - val publicInfo = responseMap["publicInfo"] as String - val sensitiveInfo = responseMap["sensitiveInfo"] as Map<*, *> - val sensitiveValue = sensitiveInfo["value"] as String - - assertEquals("public info", publicInfo, "公开信息不应该被脱敏") - assertEquals("****", sensitiveValue, "嵌套的敏感信息应该被脱敏") - - log.info("嵌套数据脱敏测试通过") - } - - @Test - fun `test collection desensitization`() { - log.info("测试集合数据脱敏") - - val result = - mockMvc - .get("/test/sensitive/extended/collection") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("集合数据响应: {}", responseContent) - - val dataList = objectMapper.readValue(responseContent, List::class.java) - - assertTrue(dataList.isNotEmpty(), "集合数据不应该为空") - - dataList.forEach { item -> - val itemMap = item as Map<*, *> - val value = itemMap["value"] as String - assertEquals("****", value, "集合中的敏感数据应该被脱敏") - } - - log.info("集合数据脱敏测试通过") - } - - @Test - fun `test array desensitization`() { - log.info("测试数组数据脱敏") - - val result = - mockMvc - .get("/test/sensitive/extended/array") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("数组数据响应: {}", responseContent) - - val dataList = objectMapper.readValue(responseContent, List::class.java) - - assertTrue(dataList.isNotEmpty(), "数组数据不应该为空") - - dataList.forEach { item -> - val itemMap = item as Map<*, *> - val value = itemMap["value"] as String - assertEquals("****", value, "数组中的敏感数据应该被脱敏") - } - - log.info("数组数据脱敏测试通过") - } - - @Test - fun `test map desensitization`() { - log.info("测试Map数据脱敏") - - val result = - mockMvc - .get("/test/sensitive/extended/map") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("Map数据响应: {}", responseContent) - - val responseMap = objectMapper.readValue(responseContent, Map::class.java) - - responseMap.values.forEach { item -> - val itemMap = item as Map<*, *> - val value = itemMap["value"] as String - assertEquals("****", value, "Map中的敏感数据应该被脱敏") - } - - log.info("Map数据脱敏测试通过") - } - - @Test - fun `test no annotation should not desensitize`() { - log.info("测试没有注解的方法不应该脱敏") - - val result = - mockMvc - .get("/test/sensitive/extended/no-annotation") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("无注解方法响应: {}", responseContent) - - val responseMap = objectMapper.readValue(responseContent, Map::class.java) - val value = responseMap["value"] as String - - assertEquals("should not be desensitized", value, "没有注解的方法不应该脱敏数据") - - log.info("无注解方法测试通过") - } - - @Test - fun `test ISensitivity interface direct usage`() { - log.info("测试 ISensitivity 接口直接使用") - - // 测试 UserInfo - val userInfo = ExtendedSensitiveController.UserInfo() - val originalPhone = userInfo.phone - userInfo.changeWithSensitiveData() - assertNotEquals(originalPhone, userInfo.phone, "用户信息脱敏后应该不同") - - // 测试 SimpleData - val simpleData = ExtendedSensitiveController.SimpleData("test") - val originalValue = simpleData.value - simpleData.changeWithSensitiveData() - assertNotEquals(originalValue, simpleData.value, "简单数据脱敏后应该不同") - assertEquals("****", simpleData.value, "简单数据应该被脱敏为 ****") - - log.info("ISensitivity 接口直接使用测试通过") - } -} diff --git a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/sensitive/SensitiveIntegrationTest.kt b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/sensitive/SensitiveIntegrationTest.kt deleted file mode 100644 index ab50eeae8..000000000 --- a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/sensitive/SensitiveIntegrationTest.kt +++ /dev/null @@ -1,288 +0,0 @@ -package io.github.truenine.composeserver.security.sensitive - -import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.Pr -import io.github.truenine.composeserver.annotations.SensitiveResponse -import io.github.truenine.composeserver.domain.ISensitivity -import io.github.truenine.composeserver.security.autoconfig.SensitiveResultResponseBodyAdvice -import io.github.truenine.composeserver.testtoolkit.log -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get -import org.springframework.test.web.servlet.setup.MockMvcBuilders -import org.springframework.web.bind.annotation.GetMapping -import org.springframework.web.bind.annotation.RequestMapping -import org.springframework.web.bind.annotation.RestController - -/** 敏感数据处理集成测试 测试完整的敏感数据处理流程 */ -class SensitiveIntegrationTest { - - private lateinit var mockMvc: MockMvc - private val objectMapper = ObjectMapper() - - /** 模拟真实的用户数据类 */ - class RealUserData( - var id: Long = 1L, - var username: String = "realuser", - var realName: String = "张三", - var phone: String = "13812345678", - var email: String = "zhangsan@company.com", - var idCard: String = "110101199001011234", - var bankCard: String = "6212341234123434", - var address: String = "北京市朝阳区建国门外大街1号国贸大厦", - var password: String = "MySecretPassword123!", - var salary: Double = 50000.0, - var isVip: Boolean = true, - ) : ISensitivity { - override fun changeWithSensitiveData() { - super.changeWithSensitiveData() - // 模拟真实的脱敏处理 - 保留最后一个字符 - realName = if (realName.isNotEmpty()) "**${realName.last()}" else "**" - phone = "138****5678" - email = "zh****@company.com" - idCard = "11****1234" - bankCard = "62****3434" - address = "北京市朝阳区****大厦" - password = "****" - salary = 0.0 // 薪资完全隐藏 - } - } - - /** 模拟业务控制器 */ - @RestController - @RequestMapping("api/users") - class UserController { - - @SensitiveResponse - @GetMapping("profile", produces = ["application/json"]) - fun getUserProfile(): RealUserData { - return RealUserData( - id = 12345L, - username = "john_doe", - realName = "约翰·多伊", - phone = "18612345678", - email = "john.doe@example.com", - idCard = "440301199001011234", - bankCard = "4367123456781278", - address = "广东省深圳市南山区科技园南区深南大道10000号", - password = "SuperSecretPassword!@#", - salary = 80000.0, - isVip = true, - ) - } - - @SensitiveResponse - @GetMapping("list", produces = ["application/json"]) - fun getUserList(): Pr { - return Pr[ - listOf( - RealUserData( - 1L, - "user1", - "李明", - "13812345678", - "liming@test.com", - "110101199001011234", - "6212341234123434", - "北京市海淀区中关村大街1号", - "password123", - 45000.0, - false, - ), - RealUserData( - 2L, - "user2", - "王芳", - "18612345678", - "wangfang@test.com", - "310101199001011235", - "4367123456781278", - "上海市浦东新区陆家嘴环路1000号", - "mypassword", - 55000.0, - true, - ), - RealUserData( - 3L, - "user3", - "刘强", - "15912345678", - "liuqiang@test.com", - "440301199001011236", - "5212341234123434", - "广东省深圳市福田区深南大道2000号", - "secret123", - 60000.0, - true, - ), - )] - } - - @GetMapping("public", produces = ["application/json"]) - fun getPublicUserInfo(): RealUserData { - return RealUserData( - id = 999L, - username = "public_user", - realName = "公开用户", - phone = "13800138000", - email = "public@example.com", - idCard = "000000000000000000", - bankCard = "0000000000000000", - address = "公开地址", - password = "publicpassword", - salary = 0.0, - isVip = false, - ) - } - } - - @BeforeEach - fun setup() { - val controller = UserController() - val sensitive = SensitiveResultResponseBodyAdvice() - mockMvc = MockMvcBuilders.standaloneSetup(controller).setControllerAdvice(sensitive).build() - } - - @Test - fun `test complete user profile desensitization flow`() { - log.info("测试完整的用户资料脱敏流程") - - val result = - mockMvc - .get("/api/users/profile") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("用户资料响应: {}", responseContent) - - val userData = objectMapper.readValue(responseContent, Map::class.java) - - // 验证非敏感数据保持不变 - assertEquals(12345, userData["id"]) - assertEquals("john_doe", userData["username"]) - assertEquals(true, userData["isVip"]) - - // 验证敏感数据被正确脱敏 - assertEquals("**伊", userData["realName"], "真实姓名应该被脱敏") - assertEquals("138****5678", userData["phone"], "手机号应该被脱敏") - assertEquals("zh****@company.com", userData["email"], "邮箱应该被脱敏") - assertEquals("11****1234", userData["idCard"], "身份证号应该被脱敏") - assertEquals("62****3434", userData["bankCard"], "银行卡号应该被脱敏") - assertTrue((userData["address"] as String).contains("****"), "地址应该被脱敏") - assertEquals("****", userData["password"], "密码应该被完全隐藏") - assertEquals(0.0, userData["salary"], "薪资应该被隐藏") - - log.info("用户资料脱敏流程测试通过") - } - - @Test - fun `test user list desensitization with pagination`() { - log.info("测试用户列表脱敏(带分页)") - - val result = - mockMvc - .get("/api/users/list") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("用户列表响应: {}", responseContent) - - val responseMap = objectMapper.readValue(responseContent, Map::class.java) - val dataList = responseMap["d"] as List<*> - val total = responseMap["t"] as Int - val pageSize = responseMap["p"] as Int - - // 验证分页信息 - assertEquals(3, total, "总数应该正确") - assertEquals(1, pageSize, "页数应该正确") - assertEquals(3, dataList.size, "数据列表大小应该正确") - - // 验证每个用户的敏感数据都被脱敏 - dataList.forEach { item -> - val userMap = item as Map<*, *> - val realName = userMap["realName"] as String - val phone = userMap["phone"] as String - val email = userMap["email"] as String - val password = userMap["password"] as String - val salary = userMap["salary"] as Double - - assertTrue(realName.startsWith("**"), "真实姓名应该被脱敏: $realName") - assertTrue(phone.contains("****"), "手机号应该被脱敏: $phone") - assertTrue(email.contains("****"), "邮箱应该被脱敏: $email") - assertEquals("****", password, "密码应该被完全隐藏") - assertEquals(0.0, salary, "薪资应该被隐藏") - } - - log.info("用户列表脱敏测试通过") - } - - @Test - fun `test public endpoint without sensitive annotation`() { - log.info("测试没有敏感注解的公开接口") - - val result = - mockMvc - .get("/api/users/public") - .andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - .andReturn() - - val responseContent = result.response.contentAsString - log.info("公开接口响应: {}", responseContent) - - val userData = objectMapper.readValue(responseContent, Map::class.java) - - // 验证没有注解的接口不会进行脱敏处理 - assertEquals("公开用户", userData["realName"], "没有注解的接口不应该脱敏真实姓名") - assertEquals("13800138000", userData["phone"], "没有注解的接口不应该脱敏手机号") - assertEquals("public@example.com", userData["email"], "没有注解的接口不应该脱敏邮箱") - assertEquals("publicpassword", userData["password"], "没有注解的接口不应该脱敏密码") - assertEquals(0.0, userData["salary"], "原始薪资数据应该保持不变") - - log.info("公开接口测试通过") - } - - @Test - fun `test ISensitivity interface state management`() { - log.info("测试 ISensitivity 接口状态管理") - - val userData = RealUserData() - val originalPhone = userData.phone - val originalEmail = userData.email - val originalSalary = userData.salary - - // 验证初始状态 - assertFalse(userData.isChangedToSensitiveData, "初始状态应该未脱敏") - - // 执行脱敏 - userData.changeWithSensitiveData() - - // 验证脱敏后的状态 - assertTrue(userData.phone != originalPhone, "手机号应该被脱敏") - assertTrue(userData.email != originalEmail, "邮箱应该被脱敏") - assertTrue(userData.salary != originalSalary, "薪资应该被脱敏") - assertEquals("****", userData.password, "密码应该被完全隐藏") - - // 验证可以重复调用脱敏方法而不会出错 - val phoneAfterFirstCall = userData.phone - userData.changeWithSensitiveData() - assertEquals(phoneAfterFirstCall, userData.phone, "重复调用脱敏方法应该保持一致") - - log.info("ISensitivity 接口状态管理测试通过") - } -} diff --git a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/sensitive/SensitiveTest.kt b/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/sensitive/SensitiveTest.kt deleted file mode 100644 index 86bfe3df8..000000000 --- a/security/spring/src/test/kotlin/io/github/truenine/composeserver/security/sensitive/SensitiveTest.kt +++ /dev/null @@ -1,98 +0,0 @@ -package io.github.truenine.composeserver.security.sensitive - -import com.fasterxml.jackson.databind.ObjectMapper -import io.github.truenine.composeserver.security.autoconfig.SensitiveResultResponseBodyAdvice -import io.github.truenine.composeserver.security.controller.SensitiveController -import io.github.truenine.composeserver.testtoolkit.log -import kotlin.test.assertEquals -import kotlin.test.assertNotEquals -import kotlin.test.assertTrue -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.springframework.http.MediaType -import org.springframework.test.web.servlet.MockMvc -import org.springframework.test.web.servlet.get -import org.springframework.test.web.servlet.setup.MockMvcBuilders - -/** Test sensitive data processing functionality */ -class SensitiveTest { - - private lateinit var mockMvc: MockMvc - private val objectMapper = ObjectMapper() - - @BeforeEach - fun setup() { - val controller = SensitiveController() - val sensitive = SensitiveResultResponseBodyAdvice() - mockMvc = MockMvcBuilders.standaloneSetup(controller).setControllerAdvice(sensitive).build() - } - - @Test - fun `test sensitive response processing`() { - mockMvc.get("/test/sensitive/get").andExpect { - status { isOk() } - content { contentType(MediaType.APPLICATION_JSON) } - } - } - - @Test - fun `test sensitive data is properly desensitized`() { - log.info("测试敏感数据是否正确脱敏") - - val result = mockMvc.get("/test/sensitive/get").andReturn() - val responseContent = result.response.contentAsString - log.info("响应内容: {}", responseContent) - - // 解析响应为 Pr 对象 - val responseMap = objectMapper.readValue(responseContent, Map::class.java) - val dataList = responseMap["d"] as List<*> - - // 验证数据列表不为空 - assertTrue(dataList.isNotEmpty(), "数据列表不应该为空") - - // 验证每个响应对象的 a 字段都被脱敏为 233 - dataList.forEach { item -> - val itemMap = item as Map<*, *> - val aValue = itemMap["a"] - assertEquals(233, aValue, "敏感数据应该被脱敏为 233") - } - - log.info("敏感数据脱敏测试通过") - } - - @Test - fun `test ISensitivity interface behavior`() { - log.info("测试 ISensitivity 接口行为") - - // 创建测试对象 - val testObj = SensitiveController.Resp(42) - val originalValue = testObj.a - - // 调用脱敏方法 - testObj.changeWithSensitiveData() - val sensitizedValue = testObj.a - - // 验证数据被正确脱敏 - assertNotEquals(originalValue, sensitizedValue, "脱敏后的值应该与原值不同") - assertEquals(233, sensitizedValue, "脱敏后的值应该为 233") - - log.info("ISensitivity 接口行为测试通过") - } - - @Test - fun `test SensitiveResultResponseBodyAdvice supports method`() { - log.info("测试 SensitiveResultResponseBodyAdvice 的 supports 方法") - - val advice = SensitiveResultResponseBodyAdvice() - - // 模拟带有 @SensitiveResponse 注解的方法 - val method = SensitiveController::class.java.getDeclaredMethod("test get a") - val methodParameter = org.springframework.core.MethodParameter(method, -1) - - // 验证 supports 方法返回 true - val supports = advice.supports(methodParameter, org.springframework.http.converter.json.MappingJackson2HttpMessageConverter::class.java) - assertTrue(supports, "带有 @SensitiveResponse 注解的方法应该被支持") - - log.info("SensitiveResultResponseBodyAdvice supports 方法测试通过") - } -} diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 4a59d7860..a82cc3d2c 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -18,7 +18,6 @@ dependencies { api(libs.jakarta.inject.jakarta.inject.api) api(libs.io.swagger.core.v3.swagger.annotations.jakarta) api(libs.org.slf4j.slf4j.api) - implementation(projects.meta) testImplementation(projects.testtoolkit) testImplementation(libs.org.springframework.boot.spring.boot.starter.web) diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/Alias.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/Alias.kt index 16a9bb3bd..c2c0a1377 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/Alias.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/Alias.kt @@ -2,51 +2,180 @@ package io.github.truenine.composeserver import io.github.truenine.composeserver.domain.IPage import io.github.truenine.composeserver.domain.IPageParam -import io.github.truenine.composeserver.typing.ISO4217 import java.math.BigDecimal import java.math.BigInteger import java.time.LocalDate import java.time.LocalDateTime import java.time.LocalTime +/** + * Lowercase string type alias for better readability in domain modeling. + * + * Provides a more concise and consistent naming convention across the codebase, especially useful in data classes and API definitions where brevity improves + * readability. + * + * @see String + */ typealias string = String +/** + * Lowercase byte type alias for consistent primitive type naming. + * + * Used primarily in binary data processing and low-level operations where a unified naming convention with other primitive aliases is desired. + * + * @see Byte + */ typealias byte = Byte +/** + * Lowercase integer type alias for improved code consistency. + * + * Commonly used in mathematical operations, counters, and index calculations where the lowercase naming provides better visual consistency with other type + * aliases. + * + * @see Int + */ typealias int = Int +/** + * Lowercase short type alias for consistent primitive type naming. + * + * Primarily used in scenarios requiring 16-bit integer values with memory optimization, maintaining naming consistency with other primitive type aliases. + * + * @see Short + */ typealias short = Short +/** + * Lowercase float type alias for consistent floating-point type naming. + * + * Used in mathematical calculations and graphics operations where single-precision floating-point numbers are sufficient and naming consistency is important. + * + * @see Float + */ typealias float = Float +/** + * Lowercase double type alias for consistent floating-point type naming. + * + * Preferred for high-precision mathematical calculations and financial computations where double-precision floating-point accuracy is required. + * + * @see Double + */ typealias double = Double +/** + * Lowercase long type alias for consistent primitive type naming. + * + * Essential for timestamp handling, large number operations, and database primary keys where 64-bit integer precision is required. + * + * @see Long + */ typealias long = Long +/** + * Decimal type alias for precise financial calculations. + * + * Provides arbitrary-precision decimal arithmetic, essential for monetary calculations where floating-point precision errors must be avoided. Commonly used in + * payment processing, accounting systems, and financial reporting. + * + * @see BigDecimal + */ typealias decimal = BigDecimal +/** + * Big integer type alias for arbitrary-precision integer arithmetic. + * + * Used in cryptographic operations, large number computations, and scenarios where integer values exceed the range of primitive long type. + * + * @see BigInteger + */ typealias bigint = BigInteger -typealias Timestamp = Long - -typealias timestamp = Timestamp - +/** + * Unix timestamp type alias representing milliseconds since epoch. + * + * Standardizes timestamp representation across the system as Long values, facilitating consistent time handling in database operations, API responses, and + * inter-service communication. + * + * @see Long + */ +typealias timestamp = Long + +/** + * Date type alias for date-only operations without time components. + * + * Represents calendar dates (year-month-day) without timezone or time information, ideal for business logic involving birthdays, deadlines, and scheduling. + * + * @see LocalDate + */ typealias date = LocalDate +/** + * Time type alias for time-only operations without date components. + * + * Represents time of day (hour-minute-second) without date or timezone information, useful for recurring schedules, business hours, and time-based + * configurations. + * + * @see LocalTime + */ typealias time = LocalTime +/** + * DateTime type alias for combined date and time operations. + * + * Represents both date and time components without timezone information, commonly used in business applications for event scheduling and logging. + * + * @see LocalDateTime + */ typealias datetime = LocalDateTime -/** 数据库主键 */ +/** + * Instant type alias for precise timestamp representation. + * + * Represents a specific moment in time with nanosecond precision, ideal for high-precision timing, event ordering, and distributed system coordination. + * + * @see java.time.Instant + */ +typealias instant = java.time.Instant + +/** + * Database primary key type alias. + * + * Standardizes primary key representation across all database entities as Long values, ensuring consistent identity handling and supporting large-scale data + * operations. Used extensively in JPA entities and repository operations. + * + * @see Long + */ typealias Id = Long -/** @see Id */ +/** + * Reference ID type alias for foreign key relationships. + * + * Represents foreign key references to other entities, maintaining type safety and semantic clarity in relational data modeling. Identical to [Id] but provides + * explicit intent for referential relationships. + * + * @see Id + */ typealias RefId = Id +/** + * Page Query parameter type alias for pagination requests. + * + * Encapsulates pagination parameters (offset, page size, unpaged flag) in a standardized format across all paginated API endpoints. Supports both traditional + * offset-based and cursor-based pagination patterns. + * + * @see IPageParam + */ typealias Pq = IPageParam +/** + * Page Result type alias for paginated response data. + * + * Standardizes paginated response format containing data collection, total count, and pagination metadata. Provides type-safe generic container for any + * paginated data type across REST APIs and service layers. + * + * @param T The type of data elements in the paginated result + * @see IPage + */ typealias Pr = IPage - -typealias ISO4217Typing = ISO4217 - -typealias Currency = ISO4217 diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/AliasFns.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/AliasExtensions.kt similarity index 100% rename from shared/src/main/kotlin/io/github/truenine/composeserver/AliasFns.kt rename to shared/src/main/kotlin/io/github/truenine/composeserver/AliasExtensions.kt diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/CollectionFns.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/CollectionExtensions.kt similarity index 100% rename from shared/src/main/kotlin/io/github/truenine/composeserver/CollectionFns.kt rename to shared/src/main/kotlin/io/github/truenine/composeserver/CollectionExtensions.kt diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/DTimer.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/DTimer.kt deleted file mode 100644 index 3272e9e91..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/DTimer.kt +++ /dev/null @@ -1,152 +0,0 @@ -package io.github.truenine.composeserver - -import io.github.truenine.composeserver.DTimer.plusMillis -import java.time.* - -/** - * # 时间工具类 - * - * 提供日期时间转换、格式化等常用操作工具 - * - * @author TrueNine - * @since 2022-12-16 - */ -object DTimer { - const val ZONE_GMT = "Etc/GMT" - const val DATE = "yyyy-MM-dd" - const val TIME = "HH:mm:ss" - const val DATETIME = "$DATE $TIME" - - /** - * 从当前时间增加指定毫秒数 - * - * @param plusMillis 要增加的毫秒数 - * @return 增加后的Instant对象 - */ - @JvmStatic fun plusMillisFromCurrent(plusMillis: Long): Instant = Instant.ofEpochMilli(System.currentTimeMillis() + plusMillis) - - /** - * 在指定时间基础上增加毫秒数 - * - * @param current 基准时间戳(毫秒) - * @param plusMillis 要增加的毫秒数 - * @return 增加后的Instant对象 - */ - @JvmStatic @JvmOverloads fun plusMillis(current: Long, plusMillis: Long = System.currentTimeMillis()): Instant = Instant.ofEpochMilli(current + plusMillis) - - /** - * 将LocalTime转换为Instant对象(基于1970-01-01) - * - * @param lt 要转换的LocalTime - * @param zoneId 时区 - * @return 转换后的Instant对象 - */ - @JvmStatic - @JvmOverloads - fun localTimeToInstant(lt: LocalTime, zoneId: ZoneId = ZoneId.of(ZONE_GMT)): Instant { - val meta = LocalDate.of(1970, 1, 1) - return lt.atDate(meta).atZone(zoneId).toInstant() - } - - /** - * 将LocalDate转换为Instant对象 - * - * @param ld 要转换的LocalDate - * @param zoneId 时区 - * @return 转换后的Instant对象 - */ - @JvmStatic - @JvmOverloads - fun localDateToInstant(ld: LocalDate, zoneId: ZoneId = ZoneId.systemDefault()): Instant = ld.atStartOfDay().atZone(zoneId).toInstant() - - /** - * 将LocalDateTime转换为Instant对象 - * - * @param ldt 要转换的LocalDateTime - * @param zoneId 时区 - * @return 转换后的Instant对象 - */ - @JvmStatic @JvmOverloads fun localDatetimeToInstant(ldt: LocalDateTime, zoneId: ZoneId = ZoneId.systemDefault()): Instant = ldt.atZone(zoneId).toInstant() - - /** - * 将毫秒时间戳转换为LocalDateTime对象 - * - * @param millis 毫秒时间戳 - * @param zoneId 时区 - * @return 转换后的LocalDateTime对象 - */ - @JvmStatic - @JvmOverloads - fun millisToLocalDateTime(millis: Long, zoneId: ZoneId = ZoneId.systemDefault()): LocalDateTime = - Instant.ofEpochMilli(millis).atZone(zoneId).toLocalDateTime() - - /** - * 将毫秒时间戳转换为LocalDate对象 - * - * @param millis 毫秒时间戳 - * @param zoneId 时区 - * @return 转换后的LocalDate对象 - */ - @JvmStatic - @JvmOverloads - fun millisToLocalDate(millis: Long, zoneId: ZoneId = ZoneId.systemDefault()): LocalDate = Instant.ofEpochMilli(millis).atZone(zoneId).toLocalDate() - - /** - * 将Instant转换为LocalDateTime对象 - * - * @param instant 要转换的Instant - * @param zoneId 时区 - * @return 转换后的LocalDateTime对象 - */ - @JvmStatic - @JvmOverloads - fun instantToLocalDateTime(instant: Instant, zoneId: ZoneId = ZoneId.systemDefault()): LocalDateTime = instant.atZone(zoneId).toLocalDateTime() - - /** - * 将Instant转换为LocalDate对象 - * - * @param instant 要转换的Instant - * @param zoneId 时区 - * @return 转换后的LocalDate对象 - */ - @JvmStatic @JvmOverloads fun instantToLocalDate(instant: Instant, zoneId: ZoneId = ZoneId.systemDefault()): LocalDate = instant.atZone(zoneId).toLocalDate() - - /** - * 将Instant转换为LocalTime对象 - * - * @param instant 要转换的Instant - * @param zoneId 时区 - * @return 转换后的LocalTime对象 - */ - @JvmStatic @JvmOverloads fun instantToLocalTime(instant: Instant, zoneId: ZoneId = ZoneId.of(ZONE_GMT)): LocalTime = instant.atZone(zoneId).toLocalTime() - - /** - * 将LocalDateTime转换为毫秒时间戳 - * - * @param datetime 要转换的LocalDateTime - * @param zoneId 时区 - * @return 毫秒时间戳 - */ - @JvmStatic - @JvmOverloads - fun localDatetimeToMillis(datetime: LocalDateTime, zoneId: ZoneId = ZoneOffset.UTC): Long = datetime.atZone(zoneId).toInstant().toEpochMilli() - - /** - * 将毫秒时间戳转换为LocalTime对象 - * - * @param millis 毫秒时间戳 - * @param zoneId 时区 - * @return 转换后的LocalTime对象 - */ - @JvmStatic - @JvmOverloads - fun millisToLocalTime(millis: Long, zoneId: ZoneId = ZoneId.of(ZONE_GMT)): LocalTime = Instant.ofEpochMilli(millis).atZone(zoneId).toLocalTime() - - /** - * 将Instant转换为毫秒时间戳 - * - * @param instant 要转换的Instant - * @return 毫秒时间戳 - */ - @JvmStatic fun instantToMillis(instant: Instant): Long = instant.toEpochMilli() -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/DateTimeConverter.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/DateTimeConverter.kt new file mode 100644 index 000000000..2baa95f77 --- /dev/null +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/DateTimeConverter.kt @@ -0,0 +1,270 @@ +package io.github.truenine.composeserver + +import java.time.* + +/** + * Date and time converter providing comprehensive conversion operations between different time types. + * + * This converter provides comprehensive date/time conversion methods between different Java time types (Instant, LocalDateTime, LocalDate, LocalTime) and + * millisecond timestamps, with timezone support. + * + * @author TrueNine + * @since 2022-12-16 + */ +object DateTimeConverter { + /** GMT timezone identifier */ + const val ZONE_GMT = "Etc/GMT" + + /** Standard date format pattern */ + const val DATE = "yyyy-MM-dd" + + /** Standard time format pattern */ + const val TIME = "HH:mm:ss" + + /** Standard datetime format pattern */ + const val DATETIME = "$DATE $TIME" + + /** + * Creates an Instant by adding milliseconds to the current system time. + * + * This method is useful for creating future timestamps relative to the current moment, commonly used for expiration times or scheduling. + * + * @param plusMillis the number of milliseconds to add to current time + * @return an Instant representing the calculated future time + * @sample + * + * ```kotlin + * // Create a timestamp 5 minutes from now + * val futureTime = DTimer.plusMillisFromCurrent(5 * 60 * 1000) + * ``` + */ + @JvmStatic fun plusMillisFromCurrent(plusMillis: Long): Instant = Instant.ofEpochMilli(System.currentTimeMillis() + plusMillis) + + /** + * Creates an Instant by adding milliseconds to a base timestamp. + * + * This method allows for flexible time calculations based on any reference point, with the current time as the default addend for convenience. + * + * @param current the base timestamp in milliseconds since epoch + * @param plusMillis the number of milliseconds to add (defaults to current system time) + * @return an Instant representing the calculated time + * @sample + * + * ```kotlin + * // Add 1 hour to a specific timestamp + * val baseTime = 1640995200000L // 2022-01-01 00:00:00 UTC + * val result = DTimer.plusMillis(baseTime, 3600000L) + * ``` + */ + @JvmStatic @JvmOverloads fun plusMillis(current: Long, plusMillis: Long = System.currentTimeMillis()): Instant = Instant.ofEpochMilli(current + plusMillis) + + /** + * Converts a LocalTime to an Instant using epoch date (1970-01-01) as the date component. + * + * This conversion is useful when you need to work with time-only values in an Instant context, such as for time-based calculations or comparisons. + * + * @param lt the LocalTime to convert + * @param zoneId the timezone to use for conversion (defaults to GMT) + * @return an Instant representing the time on epoch date in the specified timezone + * @sample + * + * ```kotlin + * val timeOnly = LocalTime.of(14, 30, 0) // 2:30 PM + * val instant = DTimer.localTimeToInstant(timeOnly) + * // Results in 1970-01-01T14:30:00Z + * ``` + */ + @JvmStatic + @JvmOverloads + fun localTimeToInstant(lt: LocalTime, zoneId: ZoneId = ZoneId.of(ZONE_GMT)): Instant { + val meta = LocalDate.of(1970, 1, 1) + return lt.atDate(meta).atZone(zoneId).toInstant() + } + + /** + * Converts a LocalDate to an Instant representing the start of that date. + * + * The conversion uses the start of day (00:00:00) in the specified timezone, which is essential for date-based queries and operations. + * + * @param ld the LocalDate to convert + * @param zoneId the timezone to use for conversion (defaults to system default) + * @return an Instant representing the start of the specified date + * @sample + * + * ```kotlin + * val date = LocalDate.of(2023, 12, 25) + * val instant = DTimer.localDateToInstant(date) + * // Results in 2023-12-25T00:00:00 in system timezone + * ``` + */ + @JvmStatic + @JvmOverloads + fun localDateToInstant(ld: LocalDate, zoneId: ZoneId = ZoneId.systemDefault()): Instant = ld.atStartOfDay().atZone(zoneId).toInstant() + + /** + * Converts a LocalDateTime to an Instant using the specified timezone. + * + * This is the most direct conversion for datetime values that need to be stored or transmitted as UTC timestamps while preserving the original timezone + * context. + * + * @param ldt the LocalDateTime to convert + * @param zoneId the timezone to use for conversion (defaults to system default) + * @return an Instant representing the datetime in UTC + * @sample + * + * ```kotlin + * val dateTime = LocalDateTime.of(2023, 12, 25, 15, 30, 0) + * val instant = DTimer.localDatetimeToInstant(dateTime, ZoneId.of("Asia/Shanghai")) + * ``` + */ + @JvmStatic @JvmOverloads fun localDatetimeToInstant(ldt: LocalDateTime, zoneId: ZoneId = ZoneId.systemDefault()): Instant = ldt.atZone(zoneId).toInstant() + + /** + * Converts a millisecond timestamp to a LocalDateTime in the specified timezone. + * + * This conversion is fundamental for displaying timestamps in user-friendly formats while respecting timezone preferences. + * + * @param millis the timestamp in milliseconds since epoch + * @param zoneId the timezone to use for conversion (defaults to system default) + * @return a LocalDateTime representing the timestamp in the specified timezone + * @sample + * + * ```kotlin + * val timestamp = 1640995200000L // 2022-01-01 00:00:00 UTC + * val dateTime = DTimer.millisToLocalDateTime(timestamp, ZoneId.of("Asia/Tokyo")) + * ``` + */ + @JvmStatic + @JvmOverloads + fun millisToLocalDateTime(millis: Long, zoneId: ZoneId = ZoneId.systemDefault()): LocalDateTime = + Instant.ofEpochMilli(millis).atZone(zoneId).toLocalDateTime() + + /** + * Converts a millisecond timestamp to a LocalDate in the specified timezone. + * + * This method extracts only the date component from a timestamp, useful for date-based filtering and grouping operations. + * + * @param millis the timestamp in milliseconds since epoch + * @param zoneId the timezone to use for conversion (defaults to system default) + * @return a LocalDate representing the date portion of the timestamp + * @sample + * + * ```kotlin + * val timestamp = 1640995200000L // 2022-01-01 00:00:00 UTC + * val date = DTimer.millisToLocalDate(timestamp, ZoneId.of("America/New_York")) + * ``` + */ + @JvmStatic + @JvmOverloads + fun millisToLocalDate(millis: Long, zoneId: ZoneId = ZoneId.systemDefault()): LocalDate = Instant.ofEpochMilli(millis).atZone(zoneId).toLocalDate() + + /** + * Converts an Instant to a LocalDateTime in the specified timezone. + * + * This conversion is essential for displaying UTC timestamps in local time formats while maintaining timezone awareness for user interfaces. + * + * @param instant the Instant to convert + * @param zoneId the timezone to use for conversion (defaults to system default) + * @return a LocalDateTime representing the instant in the specified timezone + * @sample + * + * ```kotlin + * val instant = Instant.now() + * val localDateTime = DTimer.instantToLocalDateTime(instant, ZoneId.of("Europe/London")) + * ``` + */ + @JvmStatic + @JvmOverloads + fun instantToLocalDateTime(instant: Instant, zoneId: ZoneId = ZoneId.systemDefault()): LocalDateTime = instant.atZone(zoneId).toLocalDateTime() + + /** + * Converts an Instant to a LocalDate in the specified timezone. + * + * This method is particularly useful for date-based operations where you need to determine which calendar date an instant falls on in a specific timezone. + * + * @param instant the Instant to convert + * @param zoneId the timezone to use for conversion (defaults to system default) + * @return a LocalDate representing the date of the instant in the specified timezone + * @sample + * + * ```kotlin + * val instant = Instant.parse("2023-12-31T23:30:00Z") + * val date = DTimer.instantToLocalDate(instant, ZoneId.of("Asia/Tokyo")) + * // May result in 2024-01-01 due to timezone offset + * ``` + */ + @JvmStatic @JvmOverloads fun instantToLocalDate(instant: Instant, zoneId: ZoneId = ZoneId.systemDefault()): LocalDate = instant.atZone(zoneId).toLocalDate() + + /** + * Converts an Instant to a LocalTime in the specified timezone. + * + * This conversion extracts only the time component, useful for time-based analysis and display where the date is not relevant. + * + * @param instant the Instant to convert + * @param zoneId the timezone to use for conversion (defaults to GMT) + * @return a LocalTime representing the time portion of the instant + * @sample + * + * ```kotlin + * val instant = Instant.parse("2023-12-25T15:30:45Z") + * val time = DTimer.instantToLocalTime(instant, ZoneId.of("UTC")) + * // Results in 15:30:45 + * ``` + */ + @JvmStatic @JvmOverloads fun instantToLocalTime(instant: Instant, zoneId: ZoneId = ZoneId.of(ZONE_GMT)): LocalTime = instant.atZone(zoneId).toLocalTime() + + /** + * Converts a LocalDateTime to a millisecond timestamp using the specified timezone. + * + * This conversion is crucial for storing datetime values as timestamps while preserving the original timezone context for accurate time calculations. + * + * @param datetime the LocalDateTime to convert + * @param zoneId the timezone to interpret the datetime in (defaults to UTC) + * @return the timestamp in milliseconds since epoch + * @sample + * + * ```kotlin + * val dateTime = LocalDateTime.of(2023, 12, 25, 15, 30, 0) + * val millis = DTimer.localDatetimeToMillis(dateTime, ZoneId.of("Asia/Shanghai")) + * ``` + */ + @JvmStatic + @JvmOverloads + fun localDatetimeToMillis(datetime: LocalDateTime, zoneId: ZoneId = ZoneOffset.UTC): Long = datetime.atZone(zoneId).toInstant().toEpochMilli() + + /** + * Converts a millisecond timestamp to a LocalTime in the specified timezone. + * + * This method extracts only the time component from a timestamp, useful for time-based operations where the date component is not needed. + * + * @param millis the timestamp in milliseconds since epoch + * @param zoneId the timezone to use for conversion (defaults to GMT) + * @return a LocalTime representing the time portion of the timestamp + * @sample + * + * ```kotlin + * val timestamp = 1640995200000L // 2022-01-01 00:00:00 UTC + * val time = DTimer.millisToLocalTime(timestamp, ZoneId.of("UTC")) + * // Results in 00:00:00 + * ``` + */ + @JvmStatic + @JvmOverloads + fun millisToLocalTime(millis: Long, zoneId: ZoneId = ZoneId.of(ZONE_GMT)): LocalTime = Instant.ofEpochMilli(millis).atZone(zoneId).toLocalTime() + + /** + * Converts an Instant to a millisecond timestamp. + * + * This is a direct conversion that extracts the epoch millisecond value from an Instant, commonly used for database storage and API serialization. + * + * @param instant the Instant to convert + * @return the timestamp in milliseconds since epoch + * @sample + * + * ```kotlin + * val instant = Instant.now() + * val millis = DTimer.instantToMillis(instant) + * ``` + */ + @JvmStatic fun instantToMillis(instant: Instant): Long = instant.toEpochMilli() +} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/ErrorBody.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/ErrorBody.kt deleted file mode 100644 index 6ae2df139..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/ErrorBody.kt +++ /dev/null @@ -1,43 +0,0 @@ -package io.github.truenine.composeserver - -import io.github.truenine.composeserver.typing.HttpStatusTyping -import java.io.Serializable - -/** - * 响应错误消息 - * - * @author TrueNine - * @since 2022-09-24 - */ -@Deprecated(message = "API 难于调用") -class ErrorBody private constructor() : Serializable { - var msg: String? = null - private set - - var alt: String? = null - private set - - var code: Int? = null - private set - - var errMap: MutableMap? = null - private set - - companion object { - @JvmStatic - @JvmOverloads - fun failedBy(msg: String? = null, code: Int? = null, alt: String? = null, errMap: MutableMap? = null): ErrorBody { - return ErrorBody().apply { - this.code = code - this.msg = msg - this.alt = alt - this.errMap = errMap - } - } - - @JvmStatic - fun failedByHttpStatus(messages: HttpStatusTyping): ErrorBody { - return failedBy(msg = messages.message, code = messages.code, alt = messages.alert) - } - } -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/ExceptionFns.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/ExceptionFns.kt deleted file mode 100644 index eb334885b..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/ExceptionFns.kt +++ /dev/null @@ -1,12 +0,0 @@ -package io.github.truenine.composeserver - -import io.github.truenine.composeserver.typing.HttpStatusTyping - -fun Throwable.failBy( - code: Int? = HttpStatusTyping.UNKNOWN.code, - msg: String? = HttpStatusTyping.UNKNOWN.message, - alt: String? = HttpStatusTyping.UNKNOWN.alert, - errMap: MutableMap? = null, -): ErrorBody { - return ErrorBody.failedBy(msg, code, alt, errMap) -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/IAnyTyping.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/IAnyTyping.kt new file mode 100644 index 000000000..e95c0698d --- /dev/null +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/IAnyTyping.kt @@ -0,0 +1,70 @@ +package io.github.truenine.composeserver + +/** + * Base interface for all typed enumerations in the Compose Server framework. + * + * This interface provides a unified contract for enum types that need to be serialized/deserialized by various frameworks such as Jackson, Spring converters, + * and Jimmer ORM. The design enables type-safe enum handling while maintaining compatibility with different serialization mechanisms. + * + * ## Design Intent + * + * The interface addresses the challenge of consistent enum serialization across different layers of the application stack. By providing a common `value` + * property, it allows serializers to access the underlying value without knowing the specific enum type. + * + * ## Implementation Contract + * + * Implementing enums **must** provide a companion object with an `operator fun get` method for reverse lookup. This is a framework convention since Kotlin + * interfaces cannot define static methods. + * + * ## Usage Example + * + * ```kotlin + * enum class Gender(private val v: Int) : IIntTyping { + * MALE(1), + * FEMALE(0), + * UNKNOWN(9999); + * + * @get:JsonValue + * override val value: Int = v + * + * companion object { + * @JvmStatic + * operator fun get(v: Int?): Gender? = entries.find { it.value == v } + * } + * } + * ``` + * + * ## Framework Integration + * - **Jackson**: Automatic serialization via `@JsonValue` on the `value` property + * - **Spring**: Custom converters use the `get` operator for string-to-enum conversion + * - **Jimmer ORM**: Database mapping through `@EnumType` annotations + * + * @see IStringTyping for string-based enums + * @see IIntTyping for integer-based enums + * @author TrueNine + * @since 2023-05-28 + */ +interface IAnyTyping { + /** + * The underlying value of this enum constant. + * + * This property provides access to the actual value that will be used during serialization and database persistence. The type is intentionally generic to + * support both string and numeric enum types. + * + * @return the underlying value of this enum constant + */ + val value: Any + + companion object { + /** + * Default implementation for reverse lookup by value. + * + * This method serves as a placeholder and should be overridden in implementing enum classes. It's part of the framework convention for enum value + * resolution. + * + * @param v the value to look up + * @return null in the base implementation, should return the matching enum constant in implementations + */ + @JvmStatic operator fun get(v: Any?): IAnyTyping? = null + } +} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/IIntTyping.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/IIntTyping.kt new file mode 100644 index 000000000..4a503b9b9 --- /dev/null +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/IIntTyping.kt @@ -0,0 +1,80 @@ +package io.github.truenine.composeserver + +/** + * Specialized interface for integer-based typed enumerations. + * + * This interface extends [IAnyTyping] to provide type-safe handling of enums that use integer values as their underlying representation. It's the most common + * enum type in the framework, particularly useful for status codes, type identifiers, and ordinal-based categorizations that need efficient storage and + * comparison. + * + * ## Design Rationale + * + * Integer-based enums are preferred for: + * - Database storage efficiency (INTEGER vs VARCHAR) + * - Performance-critical comparisons and sorting + * - Status codes and error codes + * - Ordinal-based categorizations + * - Bitwise operations and flags + * + * ## Usage Example + * + * ```kotlin + * @EnumType(EnumType.Strategy.ORDINAL) + * enum class UserStatus(private val code: Int) : IIntTyping { + * @EnumItem(ordinal = 0) INACTIVE(0), + * @EnumItem(ordinal = 1) ACTIVE(1), + * @EnumItem(ordinal = 2) SUSPENDED(2), + * @EnumItem(ordinal = 9999) UNKNOWN(9999); + * + * @get:JsonValue + * override val value: Int = code + * + * companion object { + * @JvmStatic + * operator fun get(code: Int?): UserStatus? = entries.find { it.value == code } + * } + * } + * ``` + * + * ## Framework Integration + * - **Jimmer ORM**: Uses `@EnumType(Strategy.ORDINAL)` for database mapping + * - **Jackson**: Serializes as numeric values in JSON + * - **Spring**: Supports automatic conversion from request parameters + * + * ## Serialization Behavior + * + * When serialized by Jackson, the enum will be represented by its integer value: + * ```json + * { + * "status": 1 + * } + * ``` + * + * @see IAnyTyping for the base interface + * @see IStringTyping for string-based enums + * @author TrueNine + * @since 2023-05-28 + */ +interface IIntTyping : IAnyTyping { + /** + * The integer value of this enum constant. + * + * This property narrows the type from [IAnyTyping.value] to ensure that integer-based enums always return integer values. This enables type-safe arithmetic + * operations and efficient database storage. + * + * @return the integer representation of this enum constant + */ + override val value: Int + + companion object { + /** + * Default implementation for reverse lookup by integer value. + * + * This method serves as a placeholder and should be overridden in implementing enum classes to provide actual integer-to-enum conversion functionality. + * + * @param v the integer value to look up + * @return null in the base implementation, should return the matching enum constant in implementations + */ + @JvmStatic operator fun get(v: Int?): IIntTyping? = null + } +} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/ISensitiveScope.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/ISensitiveScope.kt deleted file mode 100644 index c070b822e..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/ISensitiveScope.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.truenine.composeserver - -import io.github.truenine.composeserver.annotations.SensitiveStrategy - -@Deprecated("使用量很少") -interface ISensitiveScope { - fun String.addressDetails() = SensitiveStrategy.ADDRESS.desensitizeSerializer()(this) - - fun String.bankCard() = SensitiveStrategy.BANK_CARD_CODE.desensitizeSerializer()(this) - - fun String.chinaName() = SensitiveStrategy.NAME.desensitizeSerializer()(this) - - fun String.multipleName() = SensitiveStrategy.MULTIPLE_NAME.desensitizeSerializer()(this) - - fun String.chinaIdCard() = SensitiveStrategy.ID_CARD.desensitizeSerializer()(this) - - fun String.chinaPhone() = SensitiveStrategy.PHONE.desensitizeSerializer()(this) - - fun String.password() = SensitiveStrategy.PASSWORD.desensitizeSerializer()(this) - - fun String.email() = SensitiveStrategy.EMAIL.desensitizeSerializer()(this) - - fun String.once() = SensitiveStrategy.ONCE.desensitizeSerializer()(this) -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/IStringTyping.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/IStringTyping.kt new file mode 100644 index 000000000..5861348e9 --- /dev/null +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/IStringTyping.kt @@ -0,0 +1,72 @@ +package io.github.truenine.composeserver + +/** + * Specialized interface for string-based typed enumerations. + * + * This interface extends [IAnyTyping] to provide type-safe handling of enums that use string values as their underlying representation. It's particularly + * useful for enums that represent textual constants, codes, or identifiers that need to be serialized as strings in JSON, stored as VARCHAR in databases, or + * used in HTTP parameters. + * + * ## Design Rationale + * + * String-based enums are essential for: + * - API responses that need human-readable values + * - Configuration parameters with textual identifiers + * - Database columns that store coded values + * - Internationalization and localization keys + * + * ## Usage Example + * + * ```kotlin + * enum class Language(private val code: String) : IStringTyping { + * ENGLISH("en"), + * CHINESE("zh"), + * JAPANESE("ja"); + * + * @get:JsonValue + * override val value: String = code + * + * companion object { + * @JvmStatic + * operator fun get(code: String?): Language? = entries.find { it.value == code } + * } + * } + * ``` + * + * ## Serialization Behavior + * + * When serialized by Jackson, the enum will be represented by its string value: + * ```json + * { + * "language": "en" + * } + * ``` + * + * @see IAnyTyping for the base interface + * @see IIntTyping for integer-based enums + * @author TrueNine + * @since 2023-05-28 + */ +interface IStringTyping : IAnyTyping { + /** + * The string value of this enum constant. + * + * This property narrows the type from [IAnyTyping.value] to ensure that string-based enums always return string values. This enables type-safe operations and + * better IDE support. + * + * @return the string representation of this enum constant + */ + override val value: String + + companion object { + /** + * Default implementation for reverse lookup by string value. + * + * This method serves as a placeholder and should be overridden in implementing enum classes to provide actual string-to-enum conversion functionality. + * + * @param v the string value to look up + * @return null in the base implementation, should return the matching enum constant in implementations + */ + @JvmStatic operator fun get(v: String?): IStringTyping? = null + } +} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/JavaDateTimeFns.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/JavaDateTimeFns.kt index 704cd9bb6..0a246b158 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/JavaDateTimeFns.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/JavaDateTimeFns.kt @@ -10,7 +10,7 @@ import java.time.temporal.TemporalAdjusters * @return LocalDateTime对象 */ fun Long.toLocalDateTime(zoneId: ZoneId = ZoneId.systemDefault()): LocalDateTime { - return DTimer.millisToLocalDateTime(this, zoneId) + return DateTimeConverter.millisToLocalDateTime(this, zoneId) } /** @@ -20,7 +20,7 @@ fun Long.toLocalDateTime(zoneId: ZoneId = ZoneId.systemDefault()): LocalDateTime * @return LocalDate对象 */ fun Long.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate { - return DTimer.millisToLocalDate(this, zoneId) + return DateTimeConverter.millisToLocalDate(this, zoneId) } /** @@ -29,8 +29,8 @@ fun Long.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate { * @param zoneId 时区 * @return LocalTime对象 */ -fun Long.toLocalTime(zoneId: ZoneId = ZoneId.of(DTimer.ZONE_GMT)): LocalTime { - return DTimer.millisToLocalTime(this, zoneId) +fun Long.toLocalTime(zoneId: ZoneId = ZoneId.of(DateTimeConverter.ZONE_GMT)): LocalTime { + return DateTimeConverter.millisToLocalTime(this, zoneId) } /** @@ -48,8 +48,8 @@ fun Long.toInstant(): Instant { * @param zoneId 时区 * @return 毫秒时间戳 */ -fun LocalTime.toMillis(zoneId: ZoneId = ZoneId.of(DTimer.ZONE_GMT)): Long { - return DTimer.localTimeToInstant(this, zoneId).toEpochMilli() +fun LocalTime.toMillis(zoneId: ZoneId = ZoneId.of(DateTimeConverter.ZONE_GMT)): Long { + return DateTimeConverter.localTimeToInstant(this, zoneId).toEpochMilli() } /** @@ -59,7 +59,7 @@ fun LocalTime.toMillis(zoneId: ZoneId = ZoneId.of(DTimer.ZONE_GMT)): Long { * @return 毫秒时间戳 */ fun LocalDate.toMillis(zoneId: ZoneId = ZoneId.systemDefault()): Long { - return DTimer.localDateToInstant(this, zoneId).toEpochMilli() + return DateTimeConverter.localDateToInstant(this, zoneId).toEpochMilli() } /** @@ -69,7 +69,7 @@ fun LocalDate.toMillis(zoneId: ZoneId = ZoneId.systemDefault()): Long { * @return 毫秒时间戳 */ fun LocalDateTime.toMillis(zoneId: ZoneId = ZoneId.systemDefault()): Long { - return DTimer.localDatetimeToMillis(this, zoneId) + return DateTimeConverter.localDatetimeToMillis(this, zoneId) } /** # ISO8601 时间戳毫秒标准 */ @@ -107,7 +107,7 @@ fun LocalDate.lastDayOfMonth(): LocalDate = with(TemporalAdjusters.lastDayOfMont * @return LocalDateTime对象 */ fun Instant.toLocalDateTime(zoneId: ZoneId = ZoneId.systemDefault()): LocalDateTime { - return DTimer.instantToLocalDateTime(this, zoneId) + return DateTimeConverter.instantToLocalDateTime(this, zoneId) } /** @@ -117,7 +117,7 @@ fun Instant.toLocalDateTime(zoneId: ZoneId = ZoneId.systemDefault()): LocalDateT * @return LocalDate对象 */ fun Instant.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate { - return DTimer.instantToLocalDate(this, zoneId) + return DateTimeConverter.instantToLocalDate(this, zoneId) } /** @@ -126,8 +126,8 @@ fun Instant.toLocalDate(zoneId: ZoneId = ZoneId.systemDefault()): LocalDate { * @param zoneId 时区 * @return LocalTime对象 */ -fun Instant.toLocalTime(zoneId: ZoneId = ZoneId.of(DTimer.ZONE_GMT)): LocalTime { - return DTimer.instantToLocalTime(this, zoneId) +fun Instant.toLocalTime(zoneId: ZoneId = ZoneId.of(DateTimeConverter.ZONE_GMT)): LocalTime { + return DateTimeConverter.instantToLocalTime(this, zoneId) } /** @@ -136,7 +136,7 @@ fun Instant.toLocalTime(zoneId: ZoneId = ZoneId.of(DTimer.ZONE_GMT)): LocalTime * @return 毫秒时间戳 */ fun Instant.toMillis(): Long { - return DTimer.instantToMillis(this) + return DateTimeConverter.instantToMillis(this) } /** diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/JavaNioFns.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/JavaNioFns.kt deleted file mode 100644 index 69788bcac..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/JavaNioFns.kt +++ /dev/null @@ -1 +0,0 @@ -package io.github.truenine.composeserver diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/JavaNioPathFns.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/JavaNioPathFns.kt index 90fe77f5e..762815e4a 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/JavaNioPathFns.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/JavaNioPathFns.kt @@ -14,18 +14,44 @@ import kotlin.io.path.exists const val capacity = 8192 private val lineSep: String = System.lineSeparator() -fun Path.isFile(): Boolean { - return if (exists()) Files.isRegularFile(this) else false -} +/** + * Checks if the path represents a regular file. + * + * @return true if the path exists and is a regular file, false otherwise + */ +fun Path.isFile(): Boolean = exists() && Files.isRegularFile(this) -fun Path.isEmpty(): Boolean { - return if (isFile()) Files.size(this) == 0L else true -} +/** + * Checks if the file is empty or if the path doesn't represent a file. + * + * @return true if the file is empty, is a directory, or doesn't exist; false if it's a non-empty file + */ +fun Path.isEmpty(): Boolean = !isFile() || Files.size(this) == 0L +/** + * Opens a FileChannel for the file with the specified mode. + * + * @param mode the file access mode (default: "r" for read-only) + * @return FileChannel for the file + * @throws FileNotFoundException if the path is not a regular file + */ fun Path.fileChannel(mode: String = "r"): FileChannel { - return if (isFile()) RandomAccessFile(this.absolutePathString(), mode).channel else throw FileNotFoundException("$this is not a file") + if (!isFile()) { + throw FileNotFoundException("$this is not a file") + } + return RandomAccessFile(this.absolutePathString(), mode).channel } +/** + * Slices lines from the file within the specified range. + * + * @param range the range of lines to slice (inclusive) + * @param sep the line separator (default: system line separator) + * @param charset the character encoding (default: UTF-8) + * @param bufferCapacity the buffer capacity for reading (default: 8192) + * @param totalLines the total number of lines (if known, to avoid recounting) + * @return a sequence of strings representing the sliced lines + */ fun Path.sliceLines( range: LongRange, sep: String = lineSep, @@ -33,7 +59,6 @@ fun Path.sliceLines( bufferCapacity: Int = capacity, totalLines: Long? = null, ): Sequence { - // TODO 写死的 countLines val lineLength = totalLines ?: countLines() val sliceRange = range.toSafeRange(min = 0, max = lineLength) @@ -41,35 +66,57 @@ fun Path.sliceLines( if (isEmpty()) return@sequence fileChannel().use { channel -> val first = sliceRange.first.toSafeInt() - countWordBySeparator(sep = sep, bufferCapacity = bufferCapacity, charset = charset) - .drop(first) - .take(sliceRange.last.toSafeInt() - sliceRange.first.toSafeInt()) - .map { - channel.position(it.first) - val len = it.second.toSafeInt() - it.first.toSafeInt() + val takeCount = (sliceRange.last.toSafeInt() - first).coerceAtLeast(0) + + countWordBySeparator(sep = sep, bufferCapacity = bufferCapacity, charset = charset).drop(first).take(takeCount).forEach { (start, end) -> + channel.position(start) + val len = (end - start).toSafeInt() + if (len > 0) { val buffer = ByteBuffer.allocateDirect(len) channel.read(buffer) buffer.flip() - if (len > 0) String(buffer - len, charset) else "" + yield(String(buffer - len, charset)) + } else { + yield("") } - .let { yieldAll(it) } + } } } } +/** + * Extracts bytes from ByteBuffer into a ByteArray. + * + * @param other the number of bytes to extract + * @return ByteArray containing the extracted bytes + */ private operator fun ByteBuffer.minus(other: Int): ByteArray { val arr = ByteArray(other) get(arr) return arr } +/** + * Extracts bytes from ByteBuffer into a ByteArray with specific positioning. + * + * @param other Triple containing (position, offset, length) + * @return ByteArray containing the extracted bytes + */ private operator fun ByteBuffer.minus(other: Triple): ByteArray { val arr = ByteArray(other.third) this.get(other.first, arr, other.second, other.third) return arr } +/** + * Loops through bytes in the FileChannel using a buffer. + * + * @param bufferCapacity the capacity of the buffer (default: 8192) + * @param block the function to execute for each buffer + */ inline fun FileChannel.loopBytes(bufferCapacity: Int = capacity, block: (ByteBuffer) -> Unit) { + require(bufferCapacity > 0) { "Buffer capacity must be positive" } + val buffer = ByteBuffer.allocateDirect(bufferCapacity) while (read(buffer) != -1) { buffer.flip() @@ -80,49 +127,135 @@ inline fun FileChannel.loopBytes(bufferCapacity: Int = capacity, block: (ByteBuf } } +/** + * Counts words/segments separated by a specific separator in the file. + * + * @param sep the separator string (default: system line separator) + * @param bufferCapacity the buffer capacity for reading (default: 8192) + * @param charset the character encoding (default: UTF-8) + * @return a sequence of pairs representing (start position, end position) of each segment + * @throws IllegalStateException if separator is empty + */ fun Path.countWordBySeparator(sep: String = lineSep, bufferCapacity: Int = capacity, charset: Charset = Charsets.UTF_8): Sequence> { if (isEmpty()) return emptySequence() if (sep.isEmpty()) error("separator must be text") + require(bufferCapacity > 0) { "buffer capacity must be positive" } - val sepBytes = sep.toByteArray(charset = charset) + val sepBytes = sep.toByteArray(charset) val sepLen = sepBytes.size - var lastBytes: ByteArray? = null + return sequence { - var prevPosition = 0L - var stepSize = 0L + var segmentStart = 0L + var currentPosition = 0L + fileChannel().use { channel -> - channel.loopBytes(bufferCapacity) { buffer -> - val currentPosition = buffer.position() - val remaining = buffer.remaining() - if (remaining > sepLen) { - val stepBytes = buffer - sepLen - buffer.position((buffer.position() - sepLen) + 1) - if (stepBytes.contentEquals(sepBytes)) { - yield(prevPosition to stepSize) - prevPosition = (currentPosition + sepLen).toLong() + val buffer = ByteBuffer.allocateDirect(bufferCapacity) + val searchBuffer = ByteArray(sepLen) + var searchIndex = 0 + + while (channel.read(buffer) != -1) { + buffer.flip() + + while (buffer.hasRemaining()) { + val byte = buffer.get() + currentPosition++ + + if (byte == sepBytes[searchIndex]) { + searchBuffer[searchIndex] = byte + searchIndex++ + + if (searchIndex == sepLen) { + // Found complete separator + yield(segmentStart to currentPosition - sepLen) + segmentStart = currentPosition + searchIndex = 0 + } + } else { + searchIndex = 0 } - } else lastBytes = buffer - buffer.remaining() - stepSize++ + } + buffer.clear() + } + + // Yield the last segment if there's remaining content + if (segmentStart < currentPosition) { + yield(segmentStart to currentPosition) } } - lastBytes?.also { yield(prevPosition to stepSize) } } } -fun Path.countLines(): Long = Files.lines(this).count() - -fun Path.fileSize(): Long { +/** + * Counts the number of lines in the file efficiently using FileChannel. + * + * @return the number of lines in the file + */ +fun Path.countLines(): Long { if (isEmpty()) return 0L - return Files.size(this) + + var lineCount = 0L + var lastWasNewline = false + + fileChannel().use { channel -> + val buffer = ByteBuffer.allocateDirect(capacity) + + while (channel.read(buffer) != -1) { + buffer.flip() + + while (buffer.hasRemaining()) { + val byte = buffer.get() + if (byte == '\n'.code.toByte()) { + lineCount++ + lastWasNewline = true + } else if (byte == '\r'.code.toByte()) { + // Handle Windows line endings (\r\n) and Mac line endings (\r) + lineCount++ + lastWasNewline = true + // Peek ahead for \n to avoid double counting \r\n + if (buffer.hasRemaining()) { + val nextByte = buffer.get(buffer.position()) + if (nextByte == '\n'.code.toByte()) { + buffer.get() // consume the \n + } + } + } else { + lastWasNewline = false + } + } + buffer.clear() + } + } + + // If file doesn't end with newline and has content, count the last line + if (!lastWasNewline && Files.size(this) > 0) { + lineCount++ + } + + return lineCount } +/** + * Gets the size of the file in bytes. + * + * @return the file size in bytes, or 0 if the file is empty or doesn't exist + */ +fun Path.fileSize(): Long = if (isEmpty()) 0L else Files.size(this) + +/** + * Paginates lines from the file based on the provided parameters. + * + * @param param the pagination parameters + * @param sep the line separator (default: system line separator) + * @param charset the character encoding (default: UTF-8) + * @return a paginated result containing the requested lines + */ fun Path.pageLines(param: Pq, sep: String = lineSep, charset: Charset = Charsets.UTF_8): Pr { - return if (isEmpty() || sep.isEmpty()) IPage.emptyWith() - else { - val total = countLines() - val p = param + total.toSafeInt() - val range = p.toLongRange() - val dataList = sliceLines(range = range, totalLines = total, sep = sep, charset = charset).toList() - return Pr[dataList, total, p] - } + if (isEmpty() || sep.isEmpty()) return IPage.emptyWith() + + val total = countLines() + val p = param + total.toSafeInt() + val range = p.toLongRange() + val dataList = sliceLines(range = range, totalLines = total, sep = sep, charset = charset).toList() + + return Pr[dataList, total, p] } diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/SensitiveDslFns.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/SensitiveDslFns.kt deleted file mode 100644 index e00f2cdb9..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/SensitiveDslFns.kt +++ /dev/null @@ -1,18 +0,0 @@ -package io.github.truenine.composeserver - -import kotlin.contracts.ExperimentalContracts -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract - -@OptIn(ExperimentalContracts::class) -inline fun sensitiveAlso(data: T, scope: ISensitiveScope.(data: T) -> Unit): T { - contract { callsInPlace(scope, InvocationKind.EXACTLY_ONCE) } - scope(object : ISensitiveScope {}, data) - return data -} - -@OptIn(ExperimentalContracts::class) -inline fun sensitiveLet(data: T, scope: ISensitiveScope.(data: T) -> T): T { - contract { callsInPlace(scope, InvocationKind.EXACTLY_ONCE) } - return scope(object : ISensitiveScope {}, data) -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/annotations/SensitiveRef.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/annotations/SensitiveRef.kt deleted file mode 100644 index 999cc6ac2..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/annotations/SensitiveRef.kt +++ /dev/null @@ -1,49 +0,0 @@ -package io.github.truenine.composeserver.annotations - -import io.github.truenine.composeserver.nonText - -enum class SensitiveStrategy(private val desensitizeSerializer: (String) -> String) { - /** ## 单个 * 纯掩码 */ - ONCE({ "*" }), - - /** 不进行脱敏处理 */ - NONE({ it }), - - /** 手机号 */ - PHONE({ it.replace("^(\\S{3})\\S+(\\S{2})$".toRegex(), "\$1****\$2") }), - EMAIL({ it.replace("(\\S{2})\\S+(@[\\w.-]+)".toRegex(), "\$1****\$2") }), - - /** 身份证号 */ - ID_CARD({ it.replace("(\\S{2})\\S+(\\S{2})".toRegex(), "\$1****\$2") }), - - /** 银行卡号 */ - BANK_CARD_CODE({ it.replace("(\\w{2})\\w+(\\w{2})".toRegex(), "\$1****\$2") }), - - /** 姓名 */ - NAME({ if (it.nonText()) it else "**${it.substring(it.length - 1)}" }), - - /** - * ## 多段落姓名 - * - * 例如:`last_name` - */ - MULTIPLE_NAME({ - if (it.nonText() || it.length <= 2) { - when (it.length) { - 1 -> "*" - 2 -> "**" - else -> it - } - } else "**${it.substring(it.length - 1)}" - }), - - /** 地址 */ - ADDRESS({ it.replace("(\\S{3})\\S{2}(\\S*)\\S{2}".toRegex(), "\$1****\$2") }), - - /** 密码 */ - PASSWORD({ "****" }); - - open fun desensitizeSerializer(): (String) -> String { - return desensitizeSerializer - } -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/annotations/SensitiveResponse.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/annotations/SensitiveResponse.kt deleted file mode 100644 index 4b63963ea..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/annotations/SensitiveResponse.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.truenine.composeserver.annotations - -import java.lang.annotation.Inherited - -/** - * # 接口返回为脱敏后的数据 - * - * @see [io.github.truenine.composeserver.domain.sensitive.ISensitivity] - */ -@Inherited @Retention @MustBeDocumented @Target(AnnotationTarget.FUNCTION) annotation class SensitiveResponse diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/domain/ISensitivity.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/domain/ISensitivity.kt deleted file mode 100644 index 10d7024cc..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/domain/ISensitivity.kt +++ /dev/null @@ -1,24 +0,0 @@ -package io.github.truenine.composeserver.domain - -import io.github.truenine.composeserver.meta.annotations.MetaSkipGeneration - -/** - * # 可 有状态 脱敏 数据类 - * - * @author TrueNine - * @since 2024-07-09 - */ -interface ISensitivity { - fun changeWithSensitiveData() {} - - /** - * ## 改变当前的脱敏状态为 sensed - * - * 该方法由更抽象的类等的实现,可重复被调用,但返回状态需保持一致 - */ - fun recordChangedSensitiveData() {} - - @MetaSkipGeneration - val isChangedToSensitiveData: Boolean - get() = false -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/AnyTyping.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/AnyTyping.kt deleted file mode 100644 index 071d33bd8..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/AnyTyping.kt +++ /dev/null @@ -1,32 +0,0 @@ -package io.github.truenine.composeserver.typing - -/** - * # 所有类型枚举的抽象接口 - * 实现此接口,以方便其他序列化程序来读取枚举 实现此接口后,需要手动添加一个 operator fun get 静态方法,提供给 jackson等框架自动调用 - * - * 由于无法在接口规定静态方法,此算作规约。以下为一个枚举类内部的静态方法示例 - * - * ```kotlin - * enum class GenderTyping(private val value: Int) { - * // ... other enum constants - * ; - * @get:JsonValue - * override val value = this.v - * companion object { - * @JvmStatic - * operator fun get(v: Int?) = entries.find { it.value == v } - * } - * } - * ``` - * - * @author TrueNine - * @since 2023-05-28 - */ -interface AnyTyping { - /** ## 获取枚举对应的实际值 */ - val value: Any - - companion object { - @JvmStatic operator fun get(v: Any?): AnyTyping? = null - } -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/EncryptAlgorithmTyping.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/EncryptAlgorithmTyping.kt index 9bba11307..6aa50b9b0 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/EncryptAlgorithmTyping.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/EncryptAlgorithmTyping.kt @@ -1,12 +1,14 @@ package io.github.truenine.composeserver.typing +import io.github.truenine.composeserver.IStringTyping + /** * 算法 * * @author TrueNine * @since 2022-10-28 */ -enum class EncryptAlgorithmTyping(private val alg: String, val padding: String) : StringTyping { +enum class EncryptAlgorithmTyping(private val alg: String, val padding: String) : IStringTyping { /** ecc */ ECC("EC", "SHA256withECDSA"), diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/HTTPMethod.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/HTTPMethod.kt index 0b87dbad8..55b4a7639 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/HTTPMethod.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/HTTPMethod.kt @@ -1,6 +1,8 @@ package io.github.truenine.composeserver.typing -enum class HTTPMethod(val methodName: String) : StringTyping { +import io.github.truenine.composeserver.IStringTyping + +enum class HTTPMethod(val methodName: String) : IStringTyping { GET("GET"), POST("POST"), PUT("PUT"), @@ -14,6 +16,6 @@ enum class HTTPMethod(val methodName: String) : StringTyping { override val value: String = methodName companion object { - operator fun get(methodName: String?): HTTPMethod? = entries.firstOrNull { it.methodName == methodName?.uppercase() } + @JvmStatic operator fun get(methodName: String?): HTTPMethod? = entries.firstOrNull { it.methodName == methodName?.uppercase() } } } diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/HttpStatusTyping.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/HttpStatusTyping.kt index 3362cabf2..81088b981 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/HttpStatusTyping.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/HttpStatusTyping.kt @@ -1,12 +1,14 @@ package io.github.truenine.composeserver.typing +import io.github.truenine.composeserver.IIntTyping + /** * 错误信息枚举类 * * @author TrueNine * @since 2022-10-28 */ -enum class HttpStatusTyping(val code: Int, val message: String, val alert: String) : IntTyping { +enum class HttpStatusTyping(val code: Int, val message: String, val alert: String) : IIntTyping { _200(200, "OK", "请求成功"), _400(400, "Bad Request", "用户错误"), _401(401, "Unauthorized", "请进行身份校验"), diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/ISO4217.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/ISO4217.kt index e1b41bae0..3a95669be 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/ISO4217.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/ISO4217.kt @@ -1,5 +1,7 @@ package io.github.truenine.composeserver.typing +import io.github.truenine.composeserver.IStringTyping + /** * ## ISO 4217 表示各国货币的枚举 * @@ -8,7 +10,7 @@ package io.github.truenine.composeserver.typing * @author TrueNine * @since 2023-05-28 */ -enum class ISO4217(private val iso4217Str: String, private val cnDescription: String, private val numCode: Int, private val helperCode: Int) : StringTyping { +enum class ISO4217(private val iso4217Str: String, private val cnDescription: String, private val numCode: Int, private val helperCode: Int) : IStringTyping { /** * ## 人民币 * China Yuan Renminbi diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/IntTyping.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/IntTyping.kt deleted file mode 100644 index 09644ea25..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/IntTyping.kt +++ /dev/null @@ -1,11 +0,0 @@ -package io.github.truenine.composeserver.typing - -/** # 数值型枚举 */ -interface IntTyping : AnyTyping { - - override val value: Int - - companion object { - @JvmStatic operator fun get(v: Int?): IntTyping? = null - } -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/MimeTypes.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/MimeTypes.kt index 8a756e653..9286350d5 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/MimeTypes.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/MimeTypes.kt @@ -1,12 +1,14 @@ package io.github.truenine.composeserver.typing +import io.github.truenine.composeserver.IStringTyping + /** * mime类型 * * @author TrueNine * @since 2022-11-03 */ -enum class MimeTypes(private val extension: String, vararg m: String) : StringTyping { +enum class MimeTypes(private val extension: String, vararg m: String) : IStringTyping { EXE("exe", "application/ms-download", "application/octet-stream"), /** 这个比较特殊,他的后缀名 是 binary 注意 */ diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/PCB47.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/PCB47.kt index 3ea4024d6..6bd3f6a0b 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/PCB47.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/PCB47.kt @@ -1,12 +1,14 @@ package io.github.truenine.composeserver.typing +import io.github.truenine.composeserver.IStringTyping + /** * # 各语言 按照 PBC47 标准的序列化字符串 * * @author TrueNine * @since 2024-03-20 */ -enum class PCB47(private val primaryLang: String, vararg secondaryLanguages: String) : StringTyping { +enum class PCB47(private val primaryLang: String, vararg secondaryLanguages: String) : IStringTyping { ZH("zh"), EN("en"), ZH_CN("zh-CN"), diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/StringTyping.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/StringTyping.kt deleted file mode 100644 index 61df9513f..000000000 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/StringTyping.kt +++ /dev/null @@ -1,10 +0,0 @@ -package io.github.truenine.composeserver.typing - -/** # 字符型枚举 */ -interface StringTyping : AnyTyping { - override val value: String - - companion object { - @JvmStatic operator fun get(v: Int?): IntTyping? = null - } -} diff --git a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/UserAgents.kt b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/UserAgents.kt index 52ce2831b..19fc050e7 100644 --- a/shared/src/main/kotlin/io/github/truenine/composeserver/typing/UserAgents.kt +++ b/shared/src/main/kotlin/io/github/truenine/composeserver/typing/UserAgents.kt @@ -1,12 +1,14 @@ package io.github.truenine.composeserver.typing +import io.github.truenine.composeserver.IStringTyping + /** * 一些收集的 userAgent 枚举 使用 val() 方法进行调用 * * @author TrueNine * @since 2022-10-28 */ -enum class UserAgents(private val ua: String) : StringTyping { +enum class UserAgents(private val ua: String) : IStringTyping { /** chrome windows 103 */ CHROME_WIN_103("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36"), CHROME_WIN_115("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"), diff --git a/shared/src/test/kotlin/io/github/truenine/composeserver/AliasFnsTest.kt b/shared/src/test/kotlin/io/github/truenine/composeserver/AliasExtensionsTest.kt similarity index 99% rename from shared/src/test/kotlin/io/github/truenine/composeserver/AliasFnsTest.kt rename to shared/src/test/kotlin/io/github/truenine/composeserver/AliasExtensionsTest.kt index ebae79ae9..5d015b829 100644 --- a/shared/src/test/kotlin/io/github/truenine/composeserver/AliasFnsTest.kt +++ b/shared/src/test/kotlin/io/github/truenine/composeserver/AliasExtensionsTest.kt @@ -3,7 +3,7 @@ package io.github.truenine.composeserver import kotlin.test.* /** ID 类型相关扩展函数的测试类 */ -class AliasFnsTest { +class AliasExtensionsTest { @Test fun `test long is id`() { diff --git a/shared/src/test/kotlin/io/github/truenine/composeserver/CollectionFnsTest.kt b/shared/src/test/kotlin/io/github/truenine/composeserver/CollectionExtensionsTest.kt similarity index 98% rename from shared/src/test/kotlin/io/github/truenine/composeserver/CollectionFnsTest.kt rename to shared/src/test/kotlin/io/github/truenine/composeserver/CollectionExtensionsTest.kt index b23256b38..5257b897d 100644 --- a/shared/src/test/kotlin/io/github/truenine/composeserver/CollectionFnsTest.kt +++ b/shared/src/test/kotlin/io/github/truenine/composeserver/CollectionExtensionsTest.kt @@ -11,9 +11,9 @@ import kotlin.test.assertTrue /** * # 集合扩展函数测试 * - * 测试 CollectionFns.kt 中定义的集合相关扩展函数 + * 测试 CollectionExtensions.kt 中定义的集合相关扩展函数 */ -class CollectionFnsTest { +class CollectionExtensionsTest { @Test fun `测试 mutableLockMapOf 方法 - 创建带初始值的并发安全Map`() { diff --git a/shared/src/test/kotlin/io/github/truenine/composeserver/DTimerTest.kt b/shared/src/test/kotlin/io/github/truenine/composeserver/DateTimeConverterTest.kt similarity index 75% rename from shared/src/test/kotlin/io/github/truenine/composeserver/DTimerTest.kt rename to shared/src/test/kotlin/io/github/truenine/composeserver/DateTimeConverterTest.kt index f13b095e6..e922f44cb 100644 --- a/shared/src/test/kotlin/io/github/truenine/composeserver/DTimerTest.kt +++ b/shared/src/test/kotlin/io/github/truenine/composeserver/DateTimeConverterTest.kt @@ -9,8 +9,8 @@ import org.junit.jupiter.params.ParameterizedTest import org.junit.jupiter.params.provider.Arguments import org.junit.jupiter.params.provider.MethodSource -/** DTimer 工具类单元测试 */ -class DTimerTest { +/** DateTimeConverter 转换器单元测试 */ +class DateTimeConverterTest { // 固定基准时间,使用UTC时区以避免时区问题 private val fixedTestInstant = Instant.parse("2023-01-01T12:30:45Z") @@ -18,7 +18,7 @@ class DTimerTest { private val fixedTestLocalDateTime = LocalDateTime.of(2023, 1, 1, 12, 30, 45) private val fixedTestLocalDate = LocalDate.of(2023, 1, 1) private val fixedTestLocalTime = LocalTime.of(12, 30, 45) - private val defaultZone = ZoneId.of(DTimer.ZONE_GMT) + private val defaultZone = ZoneId.of(DateTimeConverter.ZONE_GMT) private val utcZone = ZoneOffset.UTC @Nested @@ -26,7 +26,7 @@ class DTimerTest { @Test fun `正常调用 plusMillisFromCurrent 时,返回增加指定毫秒数后的 Instant`() { val before = System.currentTimeMillis() - val result = DTimer.plusMillisFromCurrent(1000) + val result = DateTimeConverter.plusMillisFromCurrent(1000) val after = System.currentTimeMillis() val expectedMin = before + 1000 @@ -37,7 +37,7 @@ class DTimerTest { @Test fun `正常调用 plusMillis 时,返回增加指定毫秒数后的 Instant`() { - val result = DTimer.plusMillis(1000, 500) + val result = DateTimeConverter.plusMillis(1000, 500) assertEquals(1500, result.toEpochMilli()) } } @@ -46,19 +46,19 @@ class DTimerTest { inner class LocalToInstantFunctionGroup { @Test fun `正常将 LocalTime 转换为 Instant 时,返回正确的 Instant`() { - val result = DTimer.localTimeToInstant(fixedTestLocalTime) + val result = DateTimeConverter.localTimeToInstant(fixedTestLocalTime) assertEquals(45000 + 30 * 60 * 1000 + 12 * 60 * 60 * 1000, result.toEpochMilli()) } @Test fun `正常将 LocalDate 转换为 Instant 时,返回正确的 Instant`() { - val result = DTimer.localDateToInstant(fixedTestLocalDate, utcZone) + val result = DateTimeConverter.localDateToInstant(fixedTestLocalDate, utcZone) assertEquals(LocalDateTime.of(fixedTestLocalDate, LocalTime.MIDNIGHT).toInstant(utcZone).toEpochMilli(), result.toEpochMilli()) } @Test fun `正常将 LocalDateTime 转换为 Instant 时,返回正确的 Instant`() { - val result = DTimer.localDatetimeToInstant(fixedTestLocalDateTime, utcZone) + val result = DateTimeConverter.localDatetimeToInstant(fixedTestLocalDateTime, utcZone) assertEquals(fixedTestMillis, result.toEpochMilli()) } } @@ -67,19 +67,19 @@ class DTimerTest { inner class MillisToLocalFunctionGroup { @Test fun `正常将毫秒时间戳转换为 LocalDateTime 时,返回正确的 LocalDateTime`() { - val result = DTimer.millisToLocalDateTime(fixedTestMillis, utcZone) + val result = DateTimeConverter.millisToLocalDateTime(fixedTestMillis, utcZone) assertEquals(fixedTestLocalDateTime, result) } @Test fun `正常将毫秒时间戳转换为 LocalDate 时,返回正确的 LocalDate`() { - val result = DTimer.millisToLocalDate(fixedTestMillis, utcZone) + val result = DateTimeConverter.millisToLocalDate(fixedTestMillis, utcZone) assertEquals(fixedTestLocalDate, result) } @Test fun `正常将毫秒时间戳转换为 LocalTime 时,返回正确的 LocalTime`() { - val result = DTimer.millisToLocalTime(fixedTestMillis, utcZone) + val result = DateTimeConverter.millisToLocalTime(fixedTestMillis, utcZone) assertEquals(fixedTestLocalTime, result) } } @@ -88,19 +88,19 @@ class DTimerTest { inner class InstantToLocalFunctionGroup { @Test fun `正常将 Instant 转换为 LocalDateTime 时,返回正确的 LocalDateTime`() { - val result = DTimer.instantToLocalDateTime(fixedTestInstant, utcZone) + val result = DateTimeConverter.instantToLocalDateTime(fixedTestInstant, utcZone) assertEquals(fixedTestLocalDateTime, result) } @Test fun `正常将 Instant 转换为 LocalDate 时,返回正确的 LocalDate`() { - val result = DTimer.instantToLocalDate(fixedTestInstant, utcZone) + val result = DateTimeConverter.instantToLocalDate(fixedTestInstant, utcZone) assertEquals(fixedTestLocalDate, result) } @Test fun `正常将 Instant 转换为 LocalTime 时,返回正确的 LocalTime`() { - val result = DTimer.instantToLocalTime(fixedTestInstant, utcZone) + val result = DateTimeConverter.instantToLocalTime(fixedTestInstant, utcZone) assertEquals(fixedTestLocalTime, result) } } @@ -109,13 +109,13 @@ class DTimerTest { inner class OtherConversionFunctionGroup { @Test fun `正常将 LocalDateTime 转换为毫秒时间戳时,返回正确的毫秒值`() { - val result = DTimer.localDatetimeToMillis(fixedTestLocalDateTime, utcZone) + val result = DateTimeConverter.localDatetimeToMillis(fixedTestLocalDateTime, utcZone) assertEquals(fixedTestMillis, result) } @Test fun `正常将 Instant 转换为毫秒时间戳时,返回正确的毫秒值`() { - val result = DTimer.instantToMillis(fixedTestInstant) + val result = DateTimeConverter.instantToMillis(fixedTestInstant) assertEquals(fixedTestMillis, result) } } @@ -123,9 +123,9 @@ class DTimerTest { @Nested inner class TimezoneHandlingGroup { @ParameterizedTest - @MethodSource("io.github.truenine.composeserver.DTimerTest#timezoneTestCases") + @MethodSource("io.github.truenine.composeserver.DateTimeConverterTest#timezoneTestCases") fun `正常处理不同时区时,正确转换时区`(zoneId: ZoneId, instant: Instant, expectedLocalDateTime: LocalDateTime) { - val result = DTimer.instantToLocalDateTime(instant, zoneId) + val result = DateTimeConverter.instantToLocalDateTime(instant, zoneId) assertEquals(expectedLocalDateTime, result) } } @@ -135,7 +135,7 @@ class DTimerTest { @Test fun `边界值测试极端时间点时,正确处理时间转换`() { val epochInstant = Instant.EPOCH - val result = DTimer.instantToLocalDateTime(epochInstant, utcZone) + val result = DateTimeConverter.instantToLocalDateTime(epochInstant, utcZone) assertEquals(LocalDateTime.of(1970, 1, 1, 0, 0, 0), result) } @@ -146,8 +146,8 @@ class DTimerTest { val testInstant = Instant.parse("2023-01-01T00:00:00Z") - val eastResult = DTimer.instantToLocalDateTime(testInstant, eastZone) - val westResult = DTimer.instantToLocalDateTime(testInstant, westZone) + val eastResult = DateTimeConverter.instantToLocalDateTime(testInstant, eastZone) + val westResult = DateTimeConverter.instantToLocalDateTime(testInstant, westZone) // 东边应该是1月1日14点 assertEquals(LocalDateTime.of(2023, 1, 1, 14, 0, 0), eastResult) diff --git a/shared/src/test/kotlin/io/github/truenine/composeserver/JavaNioPathFnsTest.kt b/shared/src/test/kotlin/io/github/truenine/composeserver/JavaNioPathFnsTest.kt index 210d362f6..299c4994a 100644 --- a/shared/src/test/kotlin/io/github/truenine/composeserver/JavaNioPathFnsTest.kt +++ b/shared/src/test/kotlin/io/github/truenine/composeserver/JavaNioPathFnsTest.kt @@ -1,102 +1,317 @@ package io.github.truenine.composeserver import io.github.truenine.composeserver.testtoolkit.TempDirMapping -import io.github.truenine.composeserver.testtoolkit.log -import java.io.File +import java.io.FileNotFoundException +import java.nio.file.Files import java.nio.file.Path -import kotlin.test.Test -import kotlin.test.assertEquals +import kotlin.test.* /** - * # Java NIO Path 扩展函数测试 + * Comprehensive test suite for Java NIO Path extension functions. * - * 测试 Path 相关的扩展函数,包括文件行数统计、行切片、分页等功能 + * Tests all Path-related extension functions including file operations, line counting, slicing, pagination, and various edge cases. */ class JavaNioPathFnsTest { @TempDirMapping lateinit var tempDir: Path + // ========== Path.isFile() Tests ========== + + @Test + fun testIsFileWithRegularFile() { + val testFile = tempDir.resolve("regular.txt") + Files.createFile(testFile) + + assertTrue(testFile.isFile(), "Regular file should return true") + } + + @Test + fun testIsFileWithDirectory() { + val testDir = tempDir.resolve("testdir") + Files.createDirectory(testDir) + + assertFalse(testDir.isFile(), "Directory should return false") + } + + @Test + fun testIsFileWithNonExistentPath() { + val nonExistent = tempDir.resolve("nonexistent.txt") + + assertFalse(nonExistent.isFile(), "Non-existent path should return false") + } + + // ========== Path.isEmpty() Tests ========== + + @Test + fun testIsEmptyWithEmptyFile() { + val emptyFile = tempDir.resolve("empty.txt") + Files.createFile(emptyFile) + + assertTrue(emptyFile.isEmpty(), "Empty file should return true") + } + + @Test + fun testIsEmptyWithNonEmptyFile() { + val nonEmptyFile = tempDir.resolve("nonempty.txt") + Files.write(nonEmptyFile, "content".toByteArray()) + + assertFalse(nonEmptyFile.isEmpty(), "Non-empty file should return false") + } + + @Test + fun testIsEmptyWithDirectory() { + val testDir = tempDir.resolve("testdir") + Files.createDirectory(testDir) + + assertTrue(testDir.isEmpty(), "Directory should return true") + } + + @Test + fun testIsEmptyWithNonExistentPath() { + val nonExistent = tempDir.resolve("nonexistent.txt") + + assertTrue(nonExistent.isEmpty(), "Non-existent path should return true") + } + + // ========== Path.fileChannel() Tests ========== + + @Test + fun testFileChannelWithValidFile() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "test content".toByteArray()) + + val channel = testFile.fileChannel() + assertNotNull(channel, "FileChannel should not be null") + assertTrue(channel.isOpen, "FileChannel should be open") + channel.close() + } + + @Test + fun testFileChannelWithReadWriteMode() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "test content".toByteArray()) + + val channel = testFile.fileChannel("rw") + assertNotNull(channel, "FileChannel should not be null") + assertTrue(channel.isOpen, "FileChannel should be open") + channel.close() + } + + @Test + fun testFileChannelWithDirectory() { + val testDir = tempDir.resolve("testdir") + Files.createDirectory(testDir) + + assertFailsWith { testDir.fileChannel() } + } + + @Test + fun testFileChannelWithNonExistentFile() { + val nonExistent = tempDir.resolve("nonexistent.txt") + + assertFailsWith { nonExistent.fileChannel() } + } + + // ========== Path.fileSize() Tests ========== + + @Test + fun testFileSizeWithEmptyFile() { + val emptyFile = tempDir.resolve("empty.txt") + Files.createFile(emptyFile) + + assertEquals(0L, emptyFile.fileSize(), "Empty file size should be 0") + } + + @Test + fun testFileSizeWithNonEmptyFile() { + val content = "Hello, World!" + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, content.toByteArray()) + + assertEquals(content.length.toLong(), testFile.fileSize(), "File size should match content length") + } + @Test - fun `测试切片行功能 - 验证首行处理`() { - val firstLineFile = tempDir.resolve("firstLine.txt") + fun testFileSizeWithDirectory() { + val testDir = tempDir.resolve("testdir") + Files.createDirectory(testDir) - val firstLine = "\nLine 1\nLine 2\nLine 3\nLine 4\n" - File(firstLineFile.toUri()).writeText(firstLine) - val lines = firstLineFile.countLines() + assertEquals(0L, testDir.fileSize(), "Directory size should return 0") + } + + // ========== Path.countLines() Tests ========== + + @Test + fun testCountLinesWithEmptyFile() { + val emptyFile = tempDir.resolve("empty.txt") + Files.createFile(emptyFile) + + assertEquals(0L, emptyFile.countLines(), "Empty file should have 0 lines") + } + + @Test + fun testCountLinesWithSingleLine() { + val singleLineFile = tempDir.resolve("single.txt") + Files.write(singleLineFile, "single line".toByteArray()) + + assertEquals(1L, singleLineFile.countLines(), "Single line file should have 1 line") + } + + @Test + fun testCountLinesWithMultipleLines() { + val multiLineFile = tempDir.resolve("multi.txt") + Files.write(multiLineFile, "Line 1\nLine 2\nLine 3".toByteArray()) + + assertEquals(3L, multiLineFile.countLines(), "Multi-line file should have correct line count") + } + + @Test + fun testCountLinesWithTrailingNewline() { + val trailingNewlineFile = tempDir.resolve("trailing.txt") + Files.write(trailingNewlineFile, "Line 1\nLine 2\n".toByteArray()) + + assertEquals(2L, trailingNewlineFile.countLines(), "File with trailing newline should count correctly") + } + + @Test + fun testCountLinesWithDifferentLineEndings() { + val windowsFile = tempDir.resolve("windows.txt") + Files.write(windowsFile, "Line 1\r\nLine 2\r\nLine 3".toByteArray()) + + assertEquals(3L, windowsFile.countLines(), "Windows line endings should be counted correctly") + } + + // ========== Path.countWordBySeparator() Tests ========== + + @Test + fun testCountWordBySeparatorWithEmptyFile() { + val emptyFile = tempDir.resolve("empty.txt") + Files.createFile(emptyFile) + + val result = emptyFile.countWordBySeparator().toList() + assertTrue(result.isEmpty(), "Empty file should return empty sequence") + } + + @Test + fun testCountWordBySeparatorWithDefaultSeparator() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "Line 1\nLine 2\nLine 3".toByteArray()) + + val result = testFile.countWordBySeparator().toList() + assertTrue(result.isNotEmpty(), "File with lines should return non-empty sequence") + } + + @Test + fun testCountWordBySeparatorWithCustomSeparator() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "word1,word2,word3".toByteArray()) + + val result = testFile.countWordBySeparator(",").toList() + assertTrue(result.isNotEmpty(), "File with custom separator should return non-empty sequence") + } + + @Test + fun testCountWordBySeparatorWithEmptySeparator() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "content".toByteArray()) + + assertFailsWith { testFile.countWordBySeparator("").toList() } + } + + @Test + fun testCountWordBySeparatorWithDifferentCharsets() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "测试\n内容".toByteArray(Charsets.UTF_8)) + + val result = testFile.countWordBySeparator(charset = Charsets.UTF_8).toList() + assertTrue(result.isNotEmpty(), "File with UTF-8 content should work correctly") + } + + // ========== Path.sliceLines() Tests ========== - log.info("测试文本长度: {}", firstLine.length) - log.info("行数: {}", lines) + @Test + fun testSliceLinesWithEmptyFile() { + val emptyFile = tempDir.resolve("empty.txt") + Files.createFile(emptyFile) - val result = firstLineFile.sliceLines(sep = "\n", range = 0L..firstLine.length) - val listResult = result.toList() - log.info("切片结果: {}", listResult) + val result = emptyFile.sliceLines(0L..10L).toList() + assertTrue(result.isEmpty(), "Empty file should return empty sequence") } @Test - fun `测试切片行功能 - 验证多行文本处理`() { - val tempFile = tempDir.resolve("temper.txt") - val text = "Line 1\nLine 2\nLine 3\nLine 4" - log.info("文本长度: {}", text.length) + fun testSliceLinesWithValidRange() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "Line 1\nLine 2\nLine 3\nLine 4".toByteArray()) - File(tempFile.toUri()).writeText(text) + val result = testFile.sliceLines(0L..2L).toList() + assertTrue(result.isNotEmpty(), "Valid range should return non-empty sequence") + } - val result = tempFile.sliceLines(sep = "\n", range = 0L..text.length) - val listResult = result.toList() + @Test + fun testSliceLinesWithCustomSeparator() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "Part1|Part2|Part3".toByteArray()) + + val result = testFile.sliceLines(0L..2L, sep = "|").toList() + assertTrue(result.isNotEmpty(), "Custom separator should work correctly") + } - log.info("行切片结果: {}", listResult) + @Test + fun testSliceLinesWithDifferentCharsets() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "测试1\n测试2".toByteArray(Charsets.UTF_8)) - assertEquals(4, listResult.size, "行数应该为 4") - assertEquals("Line 1", listResult[0], "第一行应该是 'Line 1'") - assertEquals("Line 2", listResult[1], "第二行应该是 'Line 2'") - assertEquals("Line 3", listResult[2], "第三行应该是 'Line 3'") - assertEquals("Line 4", listResult[3], "第四行应该是 'Line 4'") + val result = testFile.sliceLines(0L..1L, charset = Charsets.UTF_8).toList() + assertTrue(result.isNotEmpty(), "Different charset should work correctly") } @Test - fun `测试行数统计功能 - 验证不同情况下的行数计算`() { - val tempFile = File.createTempFile("test count lines", ".txt") - tempFile.deleteOnExit() - tempFile.writeBytes("Hello\nWorld\nThis\nis\na\nTest\ne".toByteArray()) + fun testSliceLinesWithProvidedTotalLines() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "Line 1\nLine 2\nLine 3".toByteArray()) - val testPath = tempFile.toPath() - val actualLines = testPath.countLines() - assertEquals(7, actualLines, "统计的行数应该与预期值匹配") + val result = testFile.sliceLines(0L..1L, totalLines = 3L).toList() + assertTrue(result.isNotEmpty(), "Provided total lines should work correctly") + } - tempFile.writeText("") - val emptyLines = testPath.countLines() - assertEquals(0, emptyLines, "空文件的行数应该为 0") + // ========== Path.pageLines() Tests ========== - tempFile.writeText("he\n") - val oneLines = testPath.countLines() - assertEquals(1, oneLines, "单行文件的行数应该为 1") + @Test + fun testPageLinesWithEmptyFile() { + val emptyFile = tempDir.resolve("empty.txt") + Files.createFile(emptyFile) - tempFile.writeText("a\nb") - val twoLines = testPath.countLines() - assertEquals(2, twoLines, "两行文件的行数应该为 2") + val result = emptyFile.pageLines(Pq[0, 10]) + assertEquals(0L, result.t, "Empty file should have 0 total") + assertTrue(result.d.isEmpty(), "Empty file should have empty data") } @Test - fun `测试分页行功能 - 验证文件内容分页读取`() { - val tempFile = File.createTempFile("test page lines", ".txt") - tempFile.deleteOnExit() - tempFile.writeText("Hello\nWorld\nThis\nis\na\nTest\ne") - log.info("临时文件: {}", tempFile) - log.info("文件是否存在: {}", tempFile.exists()) - val testPath = tempFile.toPath() - log.info("文件行数: {}", testPath.countLines()) + fun testPageLinesWithValidPagination() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "Line 1\nLine 2\nLine 3\nLine 4\nLine 5".toByteArray()) - val pre = testPath.pageLines(Pq[1, 4], "\n") + val result = testFile.pageLines(Pq[0, 2]) + assertEquals(5L, result.t, "Total should match line count") + assertEquals(2, result.d.size, "Page size should match request") + } - log.info("分页结果: {}", pre) + @Test + fun testPageLinesWithEmptySeparator() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "content".toByteArray()) - assertEquals(7, pre.t, "总行数应该为 7") - assertEquals(3, pre.d.size, "当前页数据大小应该为 3") - assertEquals("a", pre[0], "第一个元素应该是 'a'") - assertEquals(2, pre.p, "页码应该为 2") + val result = testFile.pageLines(Pq[0, 10], sep = "") + assertEquals(0L, result.t, "Empty separator should return empty result") + assertTrue(result.d.isEmpty(), "Empty separator should return empty data") + } - val pr1 = testPath.pageLines(Pq[3, 2], "\n") + @Test + fun testPageLinesWithCustomSeparatorAndCharset() { + val testFile = tempDir.resolve("test.txt") + Files.write(testFile, "测试1|测试2|测试3".toByteArray(Charsets.UTF_8)) - assertEquals(7, pr1.t, "总行数应该为 7") - assertEquals("e", pr1[0], "第一个元素应该是 'e'") - assertEquals(4, pr1.p, "页码应该为 4") + val result = testFile.pageLines(Pq[0, 2], sep = "|", charset = Charsets.UTF_8) + assertTrue(result.t > 0, "Custom separator and charset should work") + assertTrue(result.d.isNotEmpty(), "Should return non-empty data") } } diff --git a/shared/src/test/kotlin/io/github/truenine/composeserver/typing/TypingTest.kt b/shared/src/test/kotlin/io/github/truenine/composeserver/TypingTest.kt similarity index 83% rename from shared/src/test/kotlin/io/github/truenine/composeserver/typing/TypingTest.kt rename to shared/src/test/kotlin/io/github/truenine/composeserver/TypingTest.kt index f73b34649..4c94cb7a8 100644 --- a/shared/src/test/kotlin/io/github/truenine/composeserver/typing/TypingTest.kt +++ b/shared/src/test/kotlin/io/github/truenine/composeserver/TypingTest.kt @@ -1,6 +1,7 @@ -package io.github.truenine.composeserver.typing +package io.github.truenine.composeserver import io.github.truenine.composeserver.testtoolkit.log +import io.github.truenine.composeserver.typing.PCB47 import java.lang.reflect.Modifier import kotlin.test.Test diff --git a/shared/src/test/kotlin/io/github/truenine/composeserver/annotations/SensitiveRefTest.kt b/shared/src/test/kotlin/io/github/truenine/composeserver/annotations/SensitiveRefTest.kt deleted file mode 100644 index 385608ab6..000000000 --- a/shared/src/test/kotlin/io/github/truenine/composeserver/annotations/SensitiveRefTest.kt +++ /dev/null @@ -1,242 +0,0 @@ -package io.github.truenine.composeserver.annotations - -import io.github.truenine.composeserver.testtoolkit.log -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull - -/** - * # 敏感数据脱敏策略测试 - * - * 测试 SensitiveStrategy 枚举中定义的各种脱敏策略 - */ -class SensitiveRefTest { - - @Test - fun `测试 ONCE 策略 - 单个星号掩码`() { - log.info("测试 ONCE 策略 - 单个星号掩码") - - val strategy = SensitiveStrategy.ONCE - val desensitizer = strategy.desensitizeSerializer() - - assertEquals("*", desensitizer("任何内容")) - assertEquals("*", desensitizer("")) - assertEquals("*", desensitizer("123456789")) - assertEquals("*", desensitizer("sensitive data")) - - log.info("ONCE 策略测试通过") - } - - @Test - fun `测试 NONE 策略 - 不进行脱敏`() { - log.info("测试 NONE 策略 - 不进行脱敏") - - val strategy = SensitiveStrategy.NONE - val desensitizer = strategy.desensitizeSerializer() - - assertEquals("原始数据", desensitizer("原始数据")) - assertEquals("", desensitizer("")) - assertEquals("123456789", desensitizer("123456789")) - assertEquals("sensitive data", desensitizer("sensitive data")) - - log.info("NONE 策略测试通过") - } - - @Test - fun `测试 PHONE 策略 - 手机号脱敏`() { - log.info("测试 PHONE 策略 - 手机号脱敏") - - val strategy = SensitiveStrategy.PHONE - val desensitizer = strategy.desensitizeSerializer() - - // 根据实际的正则表达式 "^(\\S{3})\\S+(\\S{2})$" 来验证 - assertEquals("138****34", desensitizer("13812341234")) - assertEquals("186****78", desensitizer("18656785678")) - assertEquals("155****99", desensitizer("15599999999")) - - // 测试不符合格式的情况 - val shortNumber = desensitizer("123") - log.info("短号码脱敏结果: {}", shortNumber) - - log.info("PHONE 策略测试通过") - } - - @Test - fun `测试 EMAIL 策略 - 邮箱脱敏`() { - log.info("测试 EMAIL 策略 - 邮箱脱敏") - - val strategy = SensitiveStrategy.EMAIL - val desensitizer = strategy.desensitizeSerializer() - - assertEquals("te****@example.com", desensitizer("test@example.com")) - assertEquals("us****@gmail.com", desensitizer("user@gmail.com")) - assertEquals("ad****@company.org", desensitizer("admin@company.org")) - - // 测试复杂邮箱 - val complexEmail = desensitizer("user.name+tag@example.co.uk") - log.info("复杂邮箱脱敏结果: {}", complexEmail) - - log.info("EMAIL 策略测试通过") - } - - @Test - fun `测试 ID_CARD 策略 - 身份证号脱敏`() { - log.info("测试 ID_CARD 策略 - 身份证号脱敏") - - val strategy = SensitiveStrategy.ID_CARD - val desensitizer = strategy.desensitizeSerializer() - - // 根据实际的正则表达式 "^(\\S{2})\\S+(\\S{2})$" 来验证 - assertEquals("11****34", desensitizer("110101199001011234")) - assertEquals("43****12", desensitizer("430404197210280012")) - assertEquals("12****78", desensitizer("123456789012345678")) - - log.info("ID_CARD 策略测试通过") - } - - @Test - fun `测试 BANK_CARD_CODE 策略 - 银行卡号脱敏`() { - log.info("测试 BANK_CARD_CODE 策略 - 银行卡号脱敏") - - val strategy = SensitiveStrategy.BANK_CARD_CODE - val desensitizer = strategy.desensitizeSerializer() - - assertEquals("62****34", desensitizer("6212341234123434")) - assertEquals("43****78", desensitizer("4367123456781278")) - assertEquals("12****90", desensitizer("1234567890")) - - log.info("BANK_CARD_CODE 策略测试通过") - } - - @Test - fun `测试 NAME 策略 - 姓名脱敏`() { - log.info("测试 NAME 策略 - 姓名脱敏") - - val strategy = SensitiveStrategy.NAME - val desensitizer = strategy.desensitizeSerializer() - - assertEquals("**明", desensitizer("小明")) - assertEquals("**华", desensitizer("张华")) - assertEquals("**龙", desensitizer("李小龙")) - assertEquals("**n", desensitizer("John")) - - // 测试空字符串和空白字符串 - assertEquals("", desensitizer("")) - assertEquals(" ", desensitizer(" ")) - - log.info("NAME 策略测试通过") - } - - @Test - fun `测试 MULTIPLE_NAME 策略 - 多段落姓名脱敏`() { - log.info("测试 MULTIPLE_NAME 策略 - 多段落姓名脱敏") - - val strategy = SensitiveStrategy.MULTIPLE_NAME - val desensitizer = strategy.desensitizeSerializer() - - // 根据实际的脱敏逻辑: - // 长度 <= 2: 1 -> "*", 2 -> "**", 其他 -> 原值 - // 长度 > 2: "**" + 最后一个字符 - assertEquals("**", desensitizer("小明")) // 长度2,应该返回 "**" - assertEquals("**华", desensitizer("张华华")) // 长度3,应该返回 "**华" - assertEquals("**龙", desensitizer("李小龙")) // 长度3,应该返回 "**龙" - - // 测试长度为1和2的特殊情况 - assertEquals("*", desensitizer("李")) // 长度1,返回 "*" - assertEquals("**", desensitizer("张华")) // 长度2,返回 "**" - - // 测试空字符串 - assertEquals("", desensitizer("")) - assertEquals(" ", desensitizer(" ")) - - log.info("MULTIPLE_NAME 策略测试通过") - } - - @Test - fun `测试 ADDRESS 策略 - 地址脱敏`() { - log.info("测试 ADDRESS 策略 - 地址脱敏") - - val strategy = SensitiveStrategy.ADDRESS - val desensitizer = strategy.desensitizeSerializer() - - val address1 = "北京市朝阳区建国门外大街1号" - val result1 = desensitizer(address1) - log.info("地址脱敏: {} -> {}", address1, result1) - - val address2 = "上海市浦东新区陆家嘴环路1000号" - val result2 = desensitizer(address2) - log.info("地址脱敏: {} -> {}", address2, result2) - - assertNotNull(result1, "地址脱敏结果不应该为空") - assertNotNull(result2, "地址脱敏结果不应该为空") - - log.info("ADDRESS 策略测试通过") - } - - @Test - fun `测试 PASSWORD 策略 - 密码脱敏`() { - log.info("测试 PASSWORD 策略 - 密码脱敏") - - val strategy = SensitiveStrategy.PASSWORD - val desensitizer = strategy.desensitizeSerializer() - - assertEquals("****", desensitizer("password123")) - assertEquals("****", desensitizer("123456")) - assertEquals("****", desensitizer("")) - assertEquals("****", desensitizer("very_long_password_with_special_chars!@#")) - - log.info("PASSWORD 策略测试通过") - } - - @Test - fun `测试所有策略的 desensitizeSerializer 方法`() { - log.info("测试所有策略的 desensitizeSerializer 方法") - - val strategies = SensitiveStrategy.values() - - strategies.forEach { strategy -> - val desensitizer = strategy.desensitizeSerializer() - assertNotNull(desensitizer, "策略 $strategy 的脱敏器不应该为空") - - // 测试脱敏器能正常工作 - val result = desensitizer("test") - assertNotNull(result, "策略 $strategy 的脱敏结果不应该为空") - - log.info("策略 {} 脱敏 'test' -> '{}'", strategy.name, result) - } - - log.info("所有策略的 desensitizeSerializer 方法测试通过") - } - - @Test - fun `测试策略枚举的完整性`() { - log.info("测试策略枚举的完整性") - - val expectedStrategies = setOf("ONCE", "NONE", "PHONE", "EMAIL", "ID_CARD", "BANK_CARD_CODE", "NAME", "MULTIPLE_NAME", "ADDRESS", "PASSWORD") - - val actualStrategies = SensitiveStrategy.values().map { it.name }.toSet() - - assertEquals(expectedStrategies, actualStrategies, "策略枚举应该包含所有预期的策略") - - log.info("策略枚举包含 {} 个策略: {}", actualStrategies.size, actualStrategies) - } - - @Test - fun `测试脱敏策略的边界情况`() { - log.info("测试脱敏策略的边界情况") - - val testCases = listOf("", " ", " ", "\n", "\t", "\r\n") - - SensitiveStrategy.values().forEach { strategy -> - testCases.forEach { testCase -> - val desensitizer = strategy.desensitizeSerializer() - val result = desensitizer(testCase) - - log.info("策略 {} 处理 '{}' -> '{}'", strategy.name, testCase.replace("\n", "\\n").replace("\t", "\\t").replace("\r", "\\r"), result) - assertNotNull(result, "策略 $strategy 处理边界情况应该返回非空结果") - } - } - - log.info("边界情况测试通过") - } -} diff --git a/shared/src/test/kotlin/io/github/truenine/composeserver/annotations/SensitiveResponseTest.kt b/shared/src/test/kotlin/io/github/truenine/composeserver/annotations/SensitiveResponseTest.kt deleted file mode 100644 index 8787ad343..000000000 --- a/shared/src/test/kotlin/io/github/truenine/composeserver/annotations/SensitiveResponseTest.kt +++ /dev/null @@ -1,175 +0,0 @@ -package io.github.truenine.composeserver.annotations - -import io.github.truenine.composeserver.testtoolkit.log -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertTrue - -/** - * # 敏感响应注解测试 - * - * 测试 SensitiveResponse 注解的属性和行为 - */ -class SensitiveResponseTest { - - @SensitiveResponse - fun testMethodWithSensitiveResponse(): String { - return "sensitive data" - } - - fun testMethodWithoutSensitiveResponse(): String { - return "normal data" - } - - @Test - fun `测试 SensitiveResponse 注解的存在性`() { - log.info("测试 SensitiveResponse 注解的存在性") - - val method = this::class.java.getDeclaredMethod("testMethodWithSensitiveResponse") - val annotation = method.getAnnotation(SensitiveResponse::class.java) - - assertNotNull(annotation, "方法应该有 SensitiveResponse 注解") - log.info("找到 SensitiveResponse 注解: {}", annotation) - } - - @Test - fun `测试没有 SensitiveResponse 注解的方法`() { - log.info("测试没有 SensitiveResponse 注解的方法") - - val method = this::class.java.getDeclaredMethod("testMethodWithoutSensitiveResponse") - val annotation = method.getAnnotation(SensitiveResponse::class.java) - - assertEquals(null, annotation, "方法不应该有 SensitiveResponse 注解") - log.info("确认方法没有 SensitiveResponse 注解") - } - - @Test - fun `测试 SensitiveResponse 注解的元注解`() { - log.info("测试 SensitiveResponse 注解的元注解") - - val annotationClass = SensitiveResponse::class.java - val annotations = annotationClass.annotations - - log.info("SensitiveResponse 注解的元注解数量: {}", annotations.size) - - annotations.forEach { annotation -> log.info("元注解: {}", annotation.annotationClass.simpleName) } - - // 验证包含预期的元注解 - val annotationNames = annotations.map { it.annotationClass.simpleName }.toSet() - assertTrue(annotationNames.contains("Inherited"), "应该包含 @Inherited 注解") - assertTrue(annotationNames.contains("Retention"), "应该包含 @Retention 注解") - assertTrue(annotationNames.contains("MustBeDocumented"), "应该包含 @MustBeDocumented 注解") - assertTrue(annotationNames.contains("Target"), "应该包含 @Target 注解") - } - - @Test - fun `测试 SensitiveResponse 注解的 Target 设置`() { - log.info("测试 SensitiveResponse 注解的 Target 设置") - - val annotationClass = SensitiveResponse::class.java - val targetAnnotation = annotationClass.getAnnotation(Target::class.java) - - assertNotNull(targetAnnotation, "应该有 @Target 注解") - - val allowedTargets = targetAnnotation.allowedTargets - log.info("允许的目标数量: {}", allowedTargets.size) - - allowedTargets.forEach { target -> log.info("允许的目标: {}", target.name) } - - assertTrue(allowedTargets.contains(AnnotationTarget.FUNCTION), "应该允许用于函数") - } - - @Test - fun `测试 SensitiveResponse 注解的 Retention 设置`() { - log.info("测试 SensitiveResponse 注解的 Retention 设置") - - val annotationClass = SensitiveResponse::class.java - val retentionAnnotation = annotationClass.getAnnotation(Retention::class.java) - - assertNotNull(retentionAnnotation, "应该有 @Retention 注解") - - val retentionPolicy = retentionAnnotation.value - log.info("保留策略: {}", retentionPolicy.name) - - // 通常应该是 RUNTIME,以便在运行时可以访问 - assertEquals(AnnotationRetention.RUNTIME, retentionPolicy, "应该使用 RUNTIME 保留策略") - } - - @Test - fun `测试 SensitiveResponse 注解的继承性`() { - log.info("测试 SensitiveResponse 注解的继承性") - - val annotationClass = SensitiveResponse::class.java - val inheritedAnnotation = annotationClass.getAnnotation(java.lang.annotation.Inherited::class.java) - - assertNotNull(inheritedAnnotation, "应该有 @Inherited 注解") - log.info("确认 SensitiveResponse 注解支持继承") - } - - @Test - fun `测试 SensitiveResponse 注解的文档化`() { - log.info("测试 SensitiveResponse 注解的文档化") - - val annotationClass = SensitiveResponse::class.java - val documentedAnnotation = annotationClass.getAnnotation(MustBeDocumented::class.java) - - assertNotNull(documentedAnnotation, "应该有 @MustBeDocumented 注解") - log.info("确认 SensitiveResponse 注解会被文档化") - } - - @Test - fun `测试 SensitiveResponse 注解在反射中的可见性`() { - log.info("测试 SensitiveResponse 注解在反射中的可见性") - - val method = this::class.java.getDeclaredMethod("testMethodWithSensitiveResponse") - val annotations = method.annotations - - log.info("方法上的注解数量: {}", annotations.size) - - val sensitiveResponseAnnotations = annotations.filterIsInstance() - assertEquals(1, sensitiveResponseAnnotations.size, "应该找到一个 SensitiveResponse 注解") - - log.info("在反射中成功找到 SensitiveResponse 注解") - } - - @Test - fun `测试 SensitiveResponse 注解的类型信息`() { - log.info("测试 SensitiveResponse 注解的类型信息") - - val annotationClass = SensitiveResponse::class.java - - assertTrue(annotationClass.isAnnotation, "SensitiveResponse 应该是注解类型") - assertEquals("SensitiveResponse", annotationClass.simpleName, "注解名称应该正确") - assertEquals("io.github.truenine.composeserver.annotations", annotationClass.packageName, "包名应该正确") - - log.info("注解类型信息验证通过") - } - - // 测试继承场景 - open class BaseClass { - @SensitiveResponse open fun sensitiveMethod(): String = "base sensitive" - } - - class DerivedClass : BaseClass() { - override fun sensitiveMethod(): String = "derived sensitive" - } - - @Test - fun `测试 SensitiveResponse 注解的继承行为`() { - log.info("测试 SensitiveResponse 注解的继承行为") - - val baseMethod = BaseClass::class.java.getDeclaredMethod("sensitiveMethod") - val derivedMethod = DerivedClass::class.java.getDeclaredMethod("sensitiveMethod") - - val baseAnnotation = baseMethod.getAnnotation(SensitiveResponse::class.java) - val derivedAnnotation = derivedMethod.getAnnotation(SensitiveResponse::class.java) - - assertNotNull(baseAnnotation, "基类方法应该有 SensitiveResponse 注解") - - // 注意:Java 的 @Inherited 只对类级别的注解有效,对方法级别的注解无效 - // 所以派生类的重写方法不会自动继承注解 - log.info("基类方法注解: {}", baseAnnotation) - log.info("派生类方法注解: {}", derivedAnnotation) - } -} diff --git a/surveillance/hikvision/build.gradle.kts b/surveillance/hikvision/build.gradle.kts index 744d6e8a9..8f074ec37 100644 --- a/surveillance/hikvision/build.gradle.kts +++ b/surveillance/hikvision/build.gradle.kts @@ -8,7 +8,6 @@ dependencies { implementation(projects.surveillance.surveillanceShared) implementation(libs.com.hikvision.ga.artemis.http.client) - implementation(libs.com.hikvision.ga.opensource) } description =