diff --git a/docs/15-backend-time-governance-plan.md b/docs/15-backend-time-governance-plan.md new file mode 100644 index 00000000..17952e50 --- /dev/null +++ b/docs/15-backend-time-governance-plan.md @@ -0,0 +1,241 @@ +# skillhub 后端日期时间治理计划 + +## 1. 当前结论 + +当前主系统已经基本完成 UTC 语义收口: + +- 核心业务时间字段大多已迁到 `Instant` +- 核心事件时间列大多已迁到 `TIMESTAMPTZ` +- 服务层“当前时间”大多已统一走注入 `Clock` +- 普通 API 和后台 DTO 的绝对时间已基本统一输出 UTC ISO-8601 + +系统当前保留的已经不是大范围混用,而是少量兼容尾项。剩余风险主要集中在: + +- 个别旧接口仍允许无时区字符串输入 +- 新增代码如果重新引入 `LocalDateTime.now()`,可能把系统带回默认时区依赖 +- 缺少跨时区自动化回归时,仍可能遗漏边界问题 + +## 2. 目标 + +治理目标不是“所有地方都只用一种类型”,而是统一时间语义: + +- 绝对时间点:统一使用 UTC 语义,Java 使用 `Instant` +- 面向业务输入的本地时间:只有在需求明确要求“本地日历时间”时才允许保留 `LocalDateTime` +- 数据库存储绝对时间点时,统一使用 `TIMESTAMPTZ` +- 对外 API 返回绝对时间点时,统一输出 ISO-8601 UTC 字符串,例如 `2026-03-18T06:30:00Z` +- 不再把没有时区语义的 `LocalDateTime` 继续向领域层传播 + +这里要明确区分: + +- i18n 解决的是语言、文案、本地化展示 +- 时间统一到 UTC 解决的是跨时区一致性 + +## 3. 目标模型 + +建议把后端时间字段分成三类管理: + +### 3.1 系统事件时间 + +适用字段: + +- `createdAt` +- `updatedAt` +- `publishedAt` +- `submittedAt` +- `reviewedAt` +- `hiddenAt` +- `yankedAt` +- `lastUsedAt` +- `revokedAt` +- `readAt` +- `handledAt` +- `tokenExpiresAt` + +约束: + +- Java 类型统一为 `Instant` +- 数据库列统一为 `TIMESTAMPTZ` +- 读写都按 UTC 绝对时间处理 + +### 3.2 业务输入时间 + +适用场景: + +- 用户手工输入一个“到某天某时截止”的字段 +- 规则明确绑定某个业务时区,而不是系统时区 + +约束: + +- 如果该时间代表真实绝对时刻,入口就应要求带时区或明确时区来源,然后在服务层立刻转换为 `Instant` +- 不允许把用户输入的裸 `yyyy-MM-ddTHH:mm:ss` 长期保存在核心领域模型中 + +### 3.3 纯日期字段 + +适用场景: + +- 生日 +- 账期 +- 结算日 +- 自然日统计 + +约束: + +- 使用 `LocalDate` +- 不参与 UTC/时区转换 + +## 4. 现状问题 + +### 4.1 历史问题已基本清理 + +此前系统的主要问题包括: + +- 领域层大量使用 `LocalDateTime` +- 服务层散落 `LocalDateTime.now()` +- 数据库 DDL 大量使用 `TIMESTAMP` +- 兼容层存在隐式 UTC 假设和冲突解释 + +当前这些问题在主链代码中已基本完成治理,保留它们主要是为了说明为什么迁移顺序必须先做基础设施,再做模型与数据库。 + +### 4.2 当前仍存在的实际问题 + +- `ApiTokenService` 仍兼容裸时间字符串输入 +- 尚未建立静态约束来阻止未来重新引入 `LocalDateTime.now()` +- 尚未形成系统性的跨时区回归基线 + +## 5. 治理原则 + +- 先统一新增代码,再迁移存量代码 +- 先统一领域模型,再迁移数据库,再收口 API +- 所有“当前时间”获取统一从 `Clock` 注入,禁止继续散落 `now()` +- 迁移期间优先保证 API 兼容,避免前端和 CLI 同时破坏 +- 对外只暴露明确语义的时间格式,不暴露“无时区但又默认是 UTC”的灰色状态 + +## 6. 分阶段计划 + +### Phase 0:基线审计 + +产出: + +- 全量时间字段清单 +- `LocalDateTime` / `Instant` / `LocalDate` 使用清单 +- `TIMESTAMP` / `TIMESTAMPTZ` 列清单 +- API 请求与响应中的时间字段清单 +- 兼容层中所有 epoch 转换点清单 + +当前状态: + +- 已完成初版盘点 +- 已同步到当前代码真实进展 + +### Phase 1:统一规范与基础设施 + +执行内容: + +- 新增全局 UTC `Clock` +- 配置 Hibernate JDBC 时区为 UTC +- 配置 Jackson UTC 输出 +- 建立“绝对时间用 `Instant`”规范 + +当前状态: + +- 已完成 + +### Phase 2:代码层迁移到 `Instant` + +执行内容: + +- 实体字段改为 `Instant` +- `LocalDateTime.now()` 改为 `Instant.now(clock)` +- 比较逻辑统一为 `Instant` +- DTO 与服务同步迁移 + +当前状态: + +- 主链已基本完成 +- 生产代码中仅剩极少数兼容解析代码保留 `LocalDateTime` + +### Phase 3:数据库迁移到 `TIMESTAMPTZ` + +执行内容: + +- 为核心表新增 Flyway migration +- 明确历史 `TIMESTAMP` 数据按 UTC 解释 + +当前状态: + +- 主链核心事件时间列已基本完成 +- 已落地 migration `V13` 到 `V23` + +### Phase 4:API 契约收口 + +执行内容: + +- 普通 JSON API 中所有绝对时间字段统一输出 UTC 字符串 +- 禁止接口返回裸 `LocalDateTime.toString()` +- 逐步淘汰无时区输入 + +当前状态: + +- 普通 API 与后台 DTO 已基本完成 UTC 输出收口 +- 剩余兼容重点是旧接口对裸时间字符串输入的处理策略 + +### Phase 5:清理与强约束 + +执行内容: + +- 清理遗留兼容时区假设 +- 增加 ArchUnit 或静态扫描规则 +- 增加跨时区测试,例如 `UTC` 与 `Asia/Shanghai` + +当前状态: + +- 尚未完成 +- 这是下一阶段最有价值的工作 + +## 7. 重点技术决策 + +### 7.1 为什么用 `Clock` 而不是只用 `Instant.now()` + +- `Instant` 解决“时间如何表达” +- `Clock` 解决“当前时间从哪里来” +- 推荐组合是 `Instant.now(clock)` + +这使服务层可测试、可固定时间、可避免机器本地时区干扰。 + +### 7.2 是否统一引入 `OffsetDateTime` + +本项目更适合以 `Instant` 作为核心绝对时间类型,原因是: + +- 多数字段表达的是事件发生时刻 +- 业务侧通常不需要保留原始 offset +- `Instant` 更能防止“看起来像本地时间”的误解 + +只有在必须保留调用方原始 offset 的场景下,才考虑 `OffsetDateTime`。 + +### 7.3 `expiresAt` 这类用户输入字段怎么处理 + +长期目标: + +- API 约定输入为 RFC 3339 / ISO-8601 带时区时间 +- 服务层解析后立即转换为 `Instant` + +短期兼容: + +- 旧接口若仍接受裸字符串,应在 controller 或 service 边界集中兜底 +- 必须明确记录这是兼容逻辑,而不是长期契约 + +## 8. 风险与应对 + +| 风险 | 应对 | +|------|------| +| 历史 `TIMESTAMP` 数据真实语义不一致 | 先做抽样和数据画像,必要时分批迁移 | +| 前端或 CLI 已依赖不带时区的旧格式 | 保留短期兼容解析,同时明确废弃计划 | +| 新代码继续引入 `LocalDateTime.now()` | 加静态扫描和 review 规则阻断 | +| 缺少跨时区回归导致边界问题漏检 | 增加 `UTC` / `Asia/Shanghai` 双时区测试矩阵 | + +## 9. 推荐后续顺序 + +1. 为 `LocalDateTime.now()` 和实体层 `LocalDateTime` 增加静态约束 +2. 增加跨时区回归测试 +3. 梳理并逐步淘汰裸时间字符串输入兼容 +4. 对生产历史数据做一次抽样校验,确认所有 `TIMESTAMPTZ` 迁移都符合 UTC 解释假设 diff --git a/docs/16-backend-time-inventory.md b/docs/16-backend-time-inventory.md new file mode 100644 index 00000000..0f46e93b --- /dev/null +++ b/docs/16-backend-time-inventory.md @@ -0,0 +1,238 @@ +# skillhub 后端时间字段台账 + +## 1. 扫描范围 + +本台账基于 `server/skillhub-app`、`server/skillhub-auth`、`server/skillhub-domain`、`server/skillhub-infra`、`server/skillhub-storage` 的当前生产代码与 Flyway migration。 + +目标已经从“摸底问题分布”转为“记录当前真实进展与剩余尾项”。 + +## 2. 当前代码分布 + +### 2.1 生产代码中的 `LocalDateTime` 已基本清空 + +当前生产代码里只剩 1 处兼容解析保留 `LocalDateTime`: + +- `ApiTokenService` + - 用于兼容旧接口传入的裸时间字符串 + - 当前明确按 UTC 解释后转成 `Instant` + +此前集中使用 `LocalDateTime` 的主链区域已完成迁移或收口: + +- 认证与账号: + - `api_token` + - `account_merge_request` + - `user_account` + - `identity_binding` + - `role` + - `user_role_binding` + - `local_credential` +- 核心领域: + - `namespace` + - `namespace_member` + - `skill` + - `skill_version` + - `skill_file` + - `skill_tag` + - `skill_version_stats` + - `skill_report` + - `skill_star` + - `skill_rating` +- 服务层: + - `AccountMergeService` + - `LocalAuthService` + - `SkillPublishService` + - `SkillGovernanceService` + - `ReviewService` + - `PromotionService` + - `SkillReportService` +- DTO 与接口输出: + - `NamespaceResponse` + - `MemberResponse` + - `SkillSummaryResponse` + - `SkillVersionResponse` + - `SkillVersionDetailResponse` + - `TagResponse` + - `AdminUserSummaryResponse` + - `AdminSkillReportSummaryResponse` + +结论: + +- 主系统核心“事件发生时间”已经基本收口成 UTC 绝对时间 +- 当前剩余工作主要是兼容策略、数据库尾项复核和防回归约束 + +### 2.2 `Instant` 已成为主流绝对时间类型 + +当前已稳定使用 `Instant` 的代表区域: + +- 审计: + - `AuditLog` + - `AuditLogItemResponse` +- 通知: + - `UserNotification` +- 审核流程: + - `ReviewTask` + - `PromotionRequest` + - `ReviewTaskResponse` + - `PromotionResponseDto` +- 幂等: + - `IdempotencyRecord` + - `IdempotencyInterceptor` + - `IdempotencyCleanupTask` +- 技能主链: + - `Skill` + - `SkillVersion` + - `SkillTag` + - `SkillFile` + - `SkillVersionStats` +- 认证主链: + - `ApiToken` + - `AccountMergeRequest` + - `UserAccount` + - `IdentityBinding` + - `Role` + - `UserRoleBinding` + - `LocalCredential` + +## 3. 数据库层分布 + +### 3.1 已完成的 `TIMESTAMPTZ` 迁移 + +- `V12__governance_notifications.sql` + - `user_notification.created_at / read_at` +- `V13__api_token_timestamptz.sql` + - `api_token.expires_at / last_used_at / revoked_at / created_at` +- `V14__account_merge_request_timestamptz.sql` + - `account_merge_request.token_expires_at / completed_at / created_at` +- `V15__skill_version_timestamptz.sql` + - `skill_version.published_at / created_at / yanked_at` +- `V16__skill_hidden_at_timestamptz.sql` + - `skill.hidden_at` +- `V17__skill_created_updated_timestamptz.sql` + - `skill.created_at / updated_at` +- `V18__namespace_timestamptz.sql` + - `namespace.created_at / updated_at` + - `namespace_member.created_at / updated_at` +- `V19__skill_secondary_timestamptz.sql` + - `skill_tag.created_at / updated_at` + - `skill_file.created_at` + - `skill_version_stats.updated_at` +- `V20__social_and_skill_report_timestamptz.sql` + - `skill_star.created_at` + - `skill_rating.created_at / updated_at` + - `skill_report.created_at / handled_at` +- `V21__user_account_timestamptz.sql` + - `user_account.created_at / updated_at` +- `V22__auth_supporting_tables_timestamptz.sql` + - `identity_binding.created_at / updated_at` + - `role.created_at` + - `user_role_binding.created_at` + - `local_credential.locked_until / created_at / updated_at` +- `V23__review_and_idempotency_timestamptz.sql` + - `review_task.submitted_at / reviewed_at` + - `promotion_request.submitted_at / reviewed_at` + - `idempotency_record.created_at / expires_at` + +### 3.2 当前状态 + +- 主链核心事件时间列已基本完成 `TIMESTAMPTZ` 收口 +- 初始建表 migration 中仍然能看到旧 `TIMESTAMP` 定义,但已由后续 Flyway 升级覆盖 +- 后续重点不是“大批量迁移”,而是查漏补缺和约束新增 + +## 4. 已解决的高风险热点 + +### 4.1 兼容层时区解释冲突 + +此前: + +- `ClawHubCompatController` 按 `ZoneOffset.UTC` 转 epoch +- `ClawHubRegistryFacade` 按系统默认时区解释 + +当前: + +- 已统一按 UTC 解释绝对时间 +- `ClawHubRegistryFacade` 的 `LocalDateTime` epoch 转换重载已移除 + +### 4.2 服务层散落的 `now()` + +此前热点包括: + +- `ApiTokenService` +- `AccountMergeService` +- `LocalAuthService` +- `SkillPublishService` +- `SkillGovernanceService` +- `ReviewService` +- `PromotionService` +- `SkillReportService` +- 多个实体 `@PrePersist` / `@PreUpdate` + +当前: + +- 服务层当前时间已基本统一为注入 `Clock` +- 实体回调已基本统一为显式 UTC + +## 5. 分批迁移进展 + +### Batch 1:基础设施与治理链路 + +已完成: + +- UTC `Clock` Bean +- Hibernate UTC 配置 +- Jackson UTC 配置 +- `ApiResponseFactory` +- `IdempotencyInterceptor` +- `IdempotencyCleanupTask` +- 审计、通知、审核、幂等链路 + +### Batch 2:认证与账号链路 + +已完成: + +- `ApiToken` / `ApiTokenService` +- `AccountMergeRequest` / `AccountMergeService` +- `LocalCredential` +- `UserAccount` +- `IdentityBinding` +- `Role` +- `UserRoleBinding` +- `LocalAuthService` + +### Batch 3:技能核心领域 + +已完成: + +- `Skill` +- `SkillVersion` +- `SkillFile` +- `SkillTag` +- `SkillVersionStats` +- `Namespace` +- `NamespaceMember` +- `SkillPublishService` +- `SkillGovernanceService` +- `ReviewService` +- `PromotionService` +- `SkillReport` +- `SkillStar` +- `SkillRating` + +### Batch 4:DTO 与 API 契约收口 + +已完成: + +- `NamespaceResponse` +- `MemberResponse` +- `SkillSummaryResponse` +- `SkillVersionResponse` +- `SkillVersionDetailResponse` +- `TagResponse` +- `AdminUserSummaryResponse` +- `AdminSkillReportSummaryResponse` +- `TokenController` 的 UTC 输出收口 + +## 6. 当前剩余尾项 + +- `ApiTokenService` 仍保留对裸 `LocalDateTime` 字符串的兼容解析 +- 需要补静态扫描或 ArchUnit 约束,防止新增 `LocalDateTime.now()` +- 需要做一轮跨时区回归,把 `UTC` / `Asia/Shanghai` 纳入关键测试 diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java index 8cfe7518..dbd179f4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubCompatController.java @@ -113,7 +113,7 @@ public class ClawHubCompatController { private ClawHubSearchResponse.ClawHubSearchResult toSearchResult(SkillSummaryResponse item) { Long updatedAtEpoch = item.updatedAt() != null - ? item.updatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ? item.updatedAt().toEpochMilli() : null; return new ClawHubSearchResponse.ClawHubSearchResult( mapper.toCanonical(item.namespace(), item.slug()), @@ -264,7 +264,7 @@ public class ClawHubCompatController { private ClawHubSkillListResponse.SkillListItem toSkillListItem(SkillSummaryResponse item) { long createdAt = 0; long updatedAt = item.updatedAt() != null - ? item.updatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ? item.updatedAt().toEpochMilli() : 0; ClawHubSkillListResponse.SkillListItem.LatestVersion latestVersion = null; @@ -320,10 +320,10 @@ public class ClawHubCompatController { if (skill.getId() != null) { long createdAt = skill.getCreatedAt() != null - ? skill.getCreatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ? skill.getCreatedAt().toEpochMilli() : 0; long updatedAt = skill.getUpdatedAt() != null - ? skill.getUpdatedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ? skill.getUpdatedAt().toEpochMilli() : 0; skillInfo = new ClawHubSkillResponse.SkillInfo( mapper.toCanonical(coord.namespace(), coord.slug()), @@ -337,7 +337,7 @@ public class ClawHubCompatController { if (latestVersionEntity != null) { long versionCreatedAt = latestVersionEntity.getPublishedAt() != null - ? latestVersionEntity.getPublishedAt().toInstant(ZoneOffset.UTC).toEpochMilli() + ? latestVersionEntity.getPublishedAt().toEpochMilli() : 0; versionInfo = new ClawHubSkillResponse.VersionInfo( latestVersionEntity.getVersion(), diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java index 0477ea41..1a697f18 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/compat/ClawHubRegistryFacade.java @@ -16,8 +16,7 @@ import com.iflytek.skillhub.domain.skill.service.SkillQueryService; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.dto.SkillSummaryResponse; import com.iflytek.skillhub.service.SkillSearchAppService; -import java.time.LocalDateTime; -import java.time.ZoneId; +import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Optional; @@ -155,7 +154,7 @@ public class ClawHubRegistryFacade { } SkillVersion entity = latestVersion.get(); - LocalDateTime createdAt = entity.getPublishedAt() != null ? entity.getPublishedAt() : entity.getCreatedAt(); + Instant createdAt = entity.getPublishedAt() != null ? entity.getPublishedAt() : entity.getCreatedAt(); return new ClawHubRegistrySkillVersion( entity.getVersion(), toEpochMillis(createdAt), @@ -206,10 +205,10 @@ public class ClawHubRegistryFacade { return Math.max(0.001d, 1.0d - (index * 0.001d)); } - private long toEpochMillis(LocalDateTime timestamp) { + private long toEpochMillis(Instant timestamp) { if (timestamp == null) { return 0L; } - return timestamp.atZone(ZoneId.systemDefault()).toInstant().toEpochMilli(); + return timestamp.toEpochMilli(); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java index 92705731..78fa6199 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/config/DomainBeanConfig.java @@ -6,9 +6,16 @@ import com.iflytek.skillhub.domain.skill.validation.SkillPackageValidator; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.time.Clock; + @Configuration public class DomainBeanConfig { + @Bean + public Clock utcClock() { + return Clock.systemUTC(); + } + @Bean public SkillMetadataParser skillMetadataParser() { return new SkillMetadataParser(); diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java index c44b3e35..673f3544 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/controller/TokenController.java @@ -16,6 +16,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import java.time.Instant; import java.util.List; @RestController @@ -52,8 +53,8 @@ public class TokenController extends BaseApiController { result.entity().getId(), result.entity().getName(), result.entity().getTokenPrefix(), - result.entity().getCreatedAt().toString(), - result.entity().getExpiresAt() != null ? result.entity().getExpiresAt().toString() : "" + formatInstant(result.entity().getCreatedAt()), + formatInstant(result.entity().getExpiresAt()) )); } @@ -67,9 +68,9 @@ public class TokenController extends BaseApiController { t.getId(), t.getName(), t.getTokenPrefix(), - t.getCreatedAt().toString(), - t.getExpiresAt() != null ? t.getExpiresAt().toString() : "", - t.getLastUsedAt() != null ? t.getLastUsedAt().toString() : "" + formatInstant(t.getCreatedAt()), + formatInstant(t.getExpiresAt()), + formatInstant(t.getLastUsedAt()) )); return ok("response.success.read", PageResponse.from(result)); } @@ -92,9 +93,13 @@ public class TokenController extends BaseApiController { token.getId(), token.getName(), token.getTokenPrefix(), - token.getCreatedAt().toString(), - token.getExpiresAt() != null ? token.getExpiresAt().toString() : "", - token.getLastUsedAt() != null ? token.getLastUsedAt().toString() : "" + formatInstant(token.getCreatedAt()), + formatInstant(token.getExpiresAt()), + formatInstant(token.getLastUsedAt()) )); } + + private String formatInstant(Instant value) { + return value == null ? "" : value.toString(); + } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java index 294784e8..91771d01 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminSkillReportSummaryResponse.java @@ -1,6 +1,6 @@ package com.iflytek.skillhub.dto; -import java.time.LocalDateTime; +import java.time.Instant; public record AdminSkillReportSummaryResponse( Long id, @@ -14,6 +14,6 @@ public record AdminSkillReportSummaryResponse( String status, String handledBy, String handleComment, - LocalDateTime createdAt, - LocalDateTime handledAt + Instant createdAt, + Instant handledAt ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java index 3cdbbfd2..6eee40e3 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/AdminUserSummaryResponse.java @@ -1,6 +1,6 @@ package com.iflytek.skillhub.dto; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; public record AdminUserSummaryResponse( @@ -9,6 +9,6 @@ public record AdminUserSummaryResponse( String email, String status, List platformRoles, - LocalDateTime createdAt + Instant createdAt ) { } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java index fa4abf81..720958f4 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/ApiResponseFactory.java @@ -5,28 +5,31 @@ import org.springframework.stereotype.Component; import org.slf4j.MDC; import org.springframework.context.i18n.LocaleContextHolder; +import java.time.Clock; import java.time.Instant; @Component public class ApiResponseFactory { private final MessageSource messageSource; + private final Clock clock; - public ApiResponseFactory(MessageSource messageSource) { + public ApiResponseFactory(MessageSource messageSource, Clock clock) { this.messageSource = messageSource; + this.clock = clock; } public ApiResponse ok(String messageCode, T data, Object... args) { String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale()); - return new ApiResponse<>(0, msg, data, Instant.now(), MDC.get("requestId")); + return new ApiResponse<>(0, msg, data, Instant.now(clock), MDC.get("requestId")); } public ApiResponse error(int code, String messageCode, Object... args) { String msg = messageSource.getMessage(messageCode, args, messageCode, LocaleContextHolder.getLocale()); - return new ApiResponse<>(code, msg, null, Instant.now(), MDC.get("requestId")); + return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId")); } public ApiResponse errorMessage(int code, String msg) { - return new ApiResponse<>(code, msg, null, Instant.now(), MDC.get("requestId")); + return new ApiResponse<>(code, msg, null, Instant.now(clock), MDC.get("requestId")); } } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java index 39c24462..dfd39d35 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MemberResponse.java @@ -3,15 +3,15 @@ package com.iflytek.skillhub.dto; import com.iflytek.skillhub.domain.namespace.NamespaceMember; import com.iflytek.skillhub.domain.namespace.NamespaceRole; -import java.time.LocalDateTime; +import java.time.Instant; public record MemberResponse( Long id, Long namespaceId, String userId, NamespaceRole role, - LocalDateTime createdAt, - LocalDateTime updatedAt + Instant createdAt, + Instant updatedAt ) { public static MemberResponse from(NamespaceMember member) { return new MemberResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java index 5f29c8c0..09640c10 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/MyNamespaceResponse.java @@ -6,7 +6,7 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; -import java.time.LocalDateTime; +import java.time.Instant; public record MyNamespaceResponse( Long id, @@ -17,8 +17,8 @@ public record MyNamespaceResponse( NamespaceType type, String avatarUrl, String createdBy, - LocalDateTime createdAt, - LocalDateTime updatedAt, + Instant createdAt, + Instant updatedAt, NamespaceRole currentUserRole, boolean immutable, boolean canFreeze, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java index 81158662..603a38da 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/NamespaceResponse.java @@ -4,7 +4,7 @@ import com.iflytek.skillhub.domain.namespace.Namespace; import com.iflytek.skillhub.domain.namespace.NamespaceStatus; import com.iflytek.skillhub.domain.namespace.NamespaceType; -import java.time.LocalDateTime; +import java.time.Instant; public record NamespaceResponse( Long id, @@ -15,8 +15,8 @@ public record NamespaceResponse( NamespaceType type, String avatarUrl, String createdBy, - LocalDateTime createdAt, - LocalDateTime updatedAt + Instant createdAt, + Instant updatedAt ) { public static NamespaceResponse from(Namespace namespace) { return new NamespaceResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java index f9cfcdb8..5d699a45 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillSummaryResponse.java @@ -1,7 +1,7 @@ package com.iflytek.skillhub.dto; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; public record SkillSummaryResponse( Long id, @@ -14,7 +14,7 @@ public record SkillSummaryResponse( BigDecimal ratingAvg, Integer ratingCount, String namespace, - LocalDateTime updatedAt, + Instant updatedAt, boolean canSubmitPromotion, SkillLifecycleVersionResponse headlineVersion, SkillLifecycleVersionResponse publishedVersion, diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java index c80f2a7b..8c01be78 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionDetailResponse.java @@ -1,6 +1,6 @@ package com.iflytek.skillhub.dto; -import java.time.LocalDateTime; +import java.time.Instant; public record SkillVersionDetailResponse( Long id, @@ -9,7 +9,7 @@ public record SkillVersionDetailResponse( String changelog, int fileCount, long totalSize, - LocalDateTime publishedAt, + Instant publishedAt, String parsedMetadataJson, String manifestJson ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java index 1e970791..23fcb071 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/SkillVersionResponse.java @@ -1,6 +1,6 @@ package com.iflytek.skillhub.dto; -import java.time.LocalDateTime; +import java.time.Instant; public record SkillVersionResponse( Long id, @@ -9,6 +9,6 @@ public record SkillVersionResponse( String changelog, int fileCount, long totalSize, - LocalDateTime publishedAt, + Instant publishedAt, boolean downloadAvailable ) {} diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java index 40f34ed0..e24cccc9 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/dto/TagResponse.java @@ -2,13 +2,13 @@ package com.iflytek.skillhub.dto; import com.iflytek.skillhub.domain.skill.SkillTag; -import java.time.LocalDateTime; +import java.time.Instant; public record TagResponse( Long id, String tagName, Long versionId, - LocalDateTime createdAt + Instant createdAt ) { public static TagResponse from(SkillTag tag) { return new TagResponse( diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java index 1e6e4d59..e9780f46 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/filter/IdempotencyInterceptor.java @@ -11,6 +11,7 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Component; import org.springframework.web.servlet.HandlerInterceptor; +import java.time.Clock; import java.time.Instant; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -25,13 +26,16 @@ public class IdempotencyInterceptor implements HandlerInterceptor { private final StringRedisTemplate redisTemplate; private final IdempotencyRecordRepository idempotencyRecordRepository; private final ObjectMapper objectMapper; + private final Clock clock; public IdempotencyInterceptor(StringRedisTemplate redisTemplate, IdempotencyRecordRepository idempotencyRecordRepository, - ObjectMapper objectMapper) { + ObjectMapper objectMapper, + Clock clock) { this.redisTemplate = redisTemplate; this.idempotencyRecordRepository = idempotencyRecordRepository; this.objectMapper = objectMapper; + this.clock = clock; } @Override @@ -73,7 +77,7 @@ public class IdempotencyInterceptor implements HandlerInterceptor { } } else { // Create new record - Instant now = Instant.now(); + Instant now = Instant.now(clock); IdempotencyRecord newRecord = new IdempotencyRecord( requestId, (String) null, (Long) null, IdempotencyStatus.PROCESSING, (Integer) null, now, now.plusSeconds(EXPIRY_HOURS * 3600)); @@ -120,7 +124,7 @@ public class IdempotencyInterceptor implements HandlerInterceptor { private void writeDuplicateResponse(HttpServletResponse response) throws Exception { ApiResponse body = new ApiResponse<>(409, "error.request.duplicate", null, - Instant.now(), null); + Instant.now(clock), null); response.setContentType("application/json;charset=UTF-8"); response.getWriter().write(objectMapper.writeValueAsString(body)); } diff --git a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java index b70b0028..935ad253 100644 --- a/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java +++ b/server/skillhub-app/src/main/java/com/iflytek/skillhub/task/IdempotencyCleanupTask.java @@ -7,6 +7,7 @@ import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; import java.time.Instant; @Component @@ -16,15 +17,17 @@ public class IdempotencyCleanupTask { private static final long STALE_THRESHOLD_MINUTES = 30; private final IdempotencyRecordRepository idempotencyRecordRepository; + private final Clock clock; - public IdempotencyCleanupTask(IdempotencyRecordRepository idempotencyRecordRepository) { + public IdempotencyCleanupTask(IdempotencyRecordRepository idempotencyRecordRepository, Clock clock) { this.idempotencyRecordRepository = idempotencyRecordRepository; + this.clock = clock; } @Scheduled(cron = "0 0 2 * * ?") @Transactional public void cleanupExpiredRecords() { - Instant now = Instant.now(); + Instant now = Instant.now(clock); int deleted = idempotencyRecordRepository.deleteExpired(now); logger.info("Cleaned up {} expired idempotency records", deleted); } @@ -32,7 +35,7 @@ public class IdempotencyCleanupTask { @Scheduled(fixedDelay = 300000) @Transactional public void cleanupStaleProcessing() { - Instant threshold = Instant.now().minusSeconds(STALE_THRESHOLD_MINUTES * 60); + Instant threshold = Instant.now(clock).minusSeconds(STALE_THRESHOLD_MINUTES * 60); int updated = idempotencyRecordRepository.markStaleAsFailed(threshold); if (updated > 0) { logger.info("Marked {} stale processing records as failed", updated); diff --git a/server/skillhub-app/src/main/resources/application.yml b/server/skillhub-app/src/main/resources/application.yml index b07f3e05..03c6fcaa 100644 --- a/server/skillhub-app/src/main/resources/application.yml +++ b/server/skillhub-app/src/main/resources/application.yml @@ -24,6 +24,10 @@ spring: properties: hibernate: dialect: org.hibernate.dialect.PostgreSQLDialect + jdbc: + time_zone: UTC + jackson: + time-zone: UTC flyway: enabled: true locations: classpath:db/migration diff --git a/server/skillhub-app/src/main/resources/db/migration/V13__api_token_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V13__api_token_timestamptz.sql new file mode 100644 index 00000000..22c43662 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V13__api_token_timestamptz.sql @@ -0,0 +1,5 @@ +ALTER TABLE api_token + ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC', + ALTER COLUMN last_used_at TYPE TIMESTAMPTZ USING last_used_at AT TIME ZONE 'UTC', + ALTER COLUMN revoked_at TYPE TIMESTAMPTZ USING revoked_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V14__account_merge_request_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V14__account_merge_request_timestamptz.sql new file mode 100644 index 00000000..d70730cb --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V14__account_merge_request_timestamptz.sql @@ -0,0 +1,4 @@ +ALTER TABLE account_merge_request + ALTER COLUMN token_expires_at TYPE TIMESTAMPTZ USING token_expires_at AT TIME ZONE 'UTC', + ALTER COLUMN completed_at TYPE TIMESTAMPTZ USING completed_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_timestamptz.sql new file mode 100644 index 00000000..db3e68dd --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V15__skill_version_timestamptz.sql @@ -0,0 +1,4 @@ +ALTER TABLE skill_version + ALTER COLUMN published_at TYPE TIMESTAMPTZ USING published_at AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN yanked_at TYPE TIMESTAMPTZ USING yanked_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V16__skill_hidden_at_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V16__skill_hidden_at_timestamptz.sql new file mode 100644 index 00000000..f71193ec --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V16__skill_hidden_at_timestamptz.sql @@ -0,0 +1,2 @@ +ALTER TABLE skill + ALTER COLUMN hidden_at TYPE TIMESTAMPTZ USING hidden_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V17__skill_created_updated_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V17__skill_created_updated_timestamptz.sql new file mode 100644 index 00000000..b7db4491 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V17__skill_created_updated_timestamptz.sql @@ -0,0 +1,3 @@ +ALTER TABLE skill + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V18__namespace_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V18__namespace_timestamptz.sql new file mode 100644 index 00000000..f1e26789 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V18__namespace_timestamptz.sql @@ -0,0 +1,7 @@ +ALTER TABLE namespace + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; + +ALTER TABLE namespace_member + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V19__skill_secondary_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V19__skill_secondary_timestamptz.sql new file mode 100644 index 00000000..33058ae7 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V19__skill_secondary_timestamptz.sql @@ -0,0 +1,9 @@ +ALTER TABLE skill_tag + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; + +ALTER TABLE skill_file + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +ALTER TABLE skill_version_stats + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V20__social_and_skill_report_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V20__social_and_skill_report_timestamptz.sql new file mode 100644 index 00000000..de60f745 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V20__social_and_skill_report_timestamptz.sql @@ -0,0 +1,10 @@ +ALTER TABLE skill_star + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +ALTER TABLE skill_rating + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; + +ALTER TABLE skill_report + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN handled_at TYPE TIMESTAMPTZ USING handled_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V21__user_account_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V21__user_account_timestamptz.sql new file mode 100644 index 00000000..1ac3b486 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V21__user_account_timestamptz.sql @@ -0,0 +1,3 @@ +ALTER TABLE user_account + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V22__auth_supporting_tables_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V22__auth_supporting_tables_timestamptz.sql new file mode 100644 index 00000000..2cffad08 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V22__auth_supporting_tables_timestamptz.sql @@ -0,0 +1,14 @@ +ALTER TABLE identity_binding + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; + +ALTER TABLE role + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +ALTER TABLE user_role_binding + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC'; + +ALTER TABLE local_credential + ALTER COLUMN locked_until TYPE TIMESTAMPTZ USING locked_until AT TIME ZONE 'UTC', + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN updated_at TYPE TIMESTAMPTZ USING updated_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/main/resources/db/migration/V23__review_and_idempotency_timestamptz.sql b/server/skillhub-app/src/main/resources/db/migration/V23__review_and_idempotency_timestamptz.sql new file mode 100644 index 00000000..3ea39b11 --- /dev/null +++ b/server/skillhub-app/src/main/resources/db/migration/V23__review_and_idempotency_timestamptz.sql @@ -0,0 +1,11 @@ +ALTER TABLE review_task + ALTER COLUMN submitted_at TYPE TIMESTAMPTZ USING submitted_at AT TIME ZONE 'UTC', + ALTER COLUMN reviewed_at TYPE TIMESTAMPTZ USING reviewed_at AT TIME ZONE 'UTC'; + +ALTER TABLE promotion_request + ALTER COLUMN submitted_at TYPE TIMESTAMPTZ USING submitted_at AT TIME ZONE 'UTC', + ALTER COLUMN reviewed_at TYPE TIMESTAMPTZ USING reviewed_at AT TIME ZONE 'UTC'; + +ALTER TABLE idempotency_record + ALTER COLUMN created_at TYPE TIMESTAMPTZ USING created_at AT TIME ZONE 'UTC', + ALTER COLUMN expires_at TYPE TIMESTAMPTZ USING expires_at AT TIME ZONE 'UTC'; diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java index c1f00fb9..c024cf08 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubCompatControllerTest.java @@ -20,7 +20,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Instant; import static org.mockito.Mockito.when; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; @@ -63,7 +63,7 @@ class ClawHubCompatControllerTest { BigDecimal.valueOf(4.5), 2, "global", - LocalDateTime.of(2026, 3, 13, 9, 0), + Instant.parse("2026-03-13T09:00:00Z"), false, new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"), new SkillLifecycleVersionResponse(11L, "1.2.0", "PUBLISHED"), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java new file mode 100644 index 00000000..d3342b77 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/compat/ClawHubRegistryFacadeTest.java @@ -0,0 +1,75 @@ +package com.iflytek.skillhub.compat; + +import com.iflytek.skillhub.compat.dto.ClawHubRegistrySearchResponse; +import com.iflytek.skillhub.domain.namespace.NamespaceRole; +import com.iflytek.skillhub.domain.skill.SkillRepository; +import com.iflytek.skillhub.domain.skill.SkillVersionRepository; +import com.iflytek.skillhub.domain.skill.service.SkillQueryService; +import com.iflytek.skillhub.domain.user.UserAccountRepository; +import com.iflytek.skillhub.dto.SkillLifecycleVersionResponse; +import com.iflytek.skillhub.dto.SkillSummaryResponse; +import com.iflytek.skillhub.service.SkillSearchAppService; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class ClawHubRegistryFacadeTest { + + @Test + void search_mapsInstantToEpochMillis() { + CanonicalSlugMapper canonicalSlugMapper = new CanonicalSlugMapper(); + SkillSearchAppService skillSearchAppService = mock(SkillSearchAppService.class); + SkillQueryService skillQueryService = mock(SkillQueryService.class); + SkillRepository skillRepository = mock(SkillRepository.class); + SkillVersionRepository skillVersionRepository = mock(SkillVersionRepository.class); + UserAccountRepository userAccountRepository = mock(UserAccountRepository.class); + + ClawHubRegistryFacade facade = new ClawHubRegistryFacade( + canonicalSlugMapper, + skillSearchAppService, + skillQueryService, + skillRepository, + skillVersionRepository, + userAccountRepository + ); + + Instant updatedAt = Instant.parse("2026-03-18T09:00:00Z"); + when(skillSearchAppService.search("agent", null, "relevance", 0, 20, null, Map.of())) + .thenReturn(new SkillSearchAppService.SearchResponse( + List.of(new SkillSummaryResponse( + 1L, + "time-skill", + "Time Skill", + "summary", + "ACTIVE", + 12L, + 3, + BigDecimal.valueOf(4.5), + 2, + "global", + updatedAt, + false, + new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), + new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), + null, + "PUBLISHED" + )), + 1, + 0, + 20 + )); + + ClawHubRegistrySearchResponse result = facade.search("agent", 20, null, Map.of()); + + assertThat(result.results()).hasSize(1); + assertThat(result.results().get(0).updatedAt()) + .isEqualTo(updatedAt.toEpochMilli()); + } +} diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java index 7d0afcd6..e0d5677b 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/AccountMergeControllerTest.java @@ -11,7 +11,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers. import com.iflytek.skillhub.auth.merge.AccountMergeService; import com.iflytek.skillhub.auth.rbac.PlatformPrincipal; import com.iflytek.skillhub.domain.namespace.NamespaceMemberRepository; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; @@ -44,7 +44,7 @@ class AccountMergeControllerTest { PlatformPrincipal principal = new PlatformPrincipal("usr_primary", "primary", "p@example.com", "", "local", Set.of()); var auth = new UsernamePasswordAuthenticationToken(principal, null, List.of()); given(accountMergeService.initiate("usr_primary", "secondary")) - .willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", "merge-token", LocalDateTime.parse("2026-03-12T22:30:00"))); + .willReturn(new AccountMergeService.InitiationResult(1L, "usr_secondary", "merge-token", Instant.parse("2026-03-12T22:30:00Z"))); mockMvc.perform(post("/api/v1/account/merge/initiate") .with(authentication(auth)) @@ -57,7 +57,8 @@ class AccountMergeControllerTest { .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.mergeRequestId").value(1)) .andExpect(jsonPath("$.data.secondaryUserId").value("usr_secondary")) - .andExpect(jsonPath("$.data.verificationToken").value("merge-token")); + .andExpect(jsonPath("$.data.verificationToken").value("merge-token")) + .andExpect(jsonPath("$.data.expiresAt").value("2026-03-12T22:30:00Z")); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java index 2206116b..01baae69 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/GovernanceControllerTest.java @@ -22,6 +22,7 @@ import java.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TimeZone; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; @@ -120,7 +121,14 @@ class GovernanceControllerTest { @Test void notifications_returnsCurrentUserNotifications() throws Exception { - UserNotification notification = new UserNotification("admin", "REVIEW", "REVIEW_TASK", 99L, "Review approved", "{}"); + UserNotification notification = new UserNotification( + "admin", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review approved", + "{}", + Instant.parse("2026-03-18T00:00:00Z")); when(governanceNotificationService.listNotifications("admin")).thenReturn(List.of(notification)); mockMvc.perform(get("/api/v1/governance/notifications").with(auth("admin", Set.of("SKILL_ADMIN")))) @@ -128,9 +136,41 @@ class GovernanceControllerTest { .andExpect(jsonPath("$.data[0].category").value("REVIEW")); } + @Test + void notifications_remainUtcAcrossJvmDefaultTimeZones() throws Exception { + UserNotification notification = new UserNotification( + "admin", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review approved", + "{}", + Instant.parse("2026-03-18T00:00:00Z")); + when(governanceNotificationService.listNotifications("admin")).thenReturn(List.of(notification)); + + TimeZone original = TimeZone.getDefault(); + try { + for (String zoneId : List.of("Asia/Shanghai", "America/Los_Angeles")) { + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + mockMvc.perform(get("/api/v1/governance/notifications").with(auth("admin", Set.of("SKILL_ADMIN")))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data[0].createdAt").value("2026-03-18T00:00:00Z")); + } + } finally { + TimeZone.setDefault(original); + } + } + @Test void markRead_returnsUpdatedNotification() throws Exception { - UserNotification notification = new UserNotification("admin", "REVIEW", "REVIEW_TASK", 99L, "Review approved", "{}"); + UserNotification notification = new UserNotification( + "admin", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review approved", + "{}", + Instant.parse("2026-03-18T00:00:00Z")); when(governanceNotificationService.markRead(10L, "admin")).thenReturn(notification); mockMvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/api/v1/governance/notifications/10/read") diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java index 035205ac..4ecad2e3 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/MeControllerTest.java @@ -19,7 +19,7 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Set; @@ -69,7 +69,7 @@ class MeControllerTest { null, 0, "team-ai", - LocalDateTime.of(2026, 3, 17, 12, 0), + Instant.parse("2026-03-17T12:00:00Z"), false, new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), new SkillLifecycleVersionResponse(11L, "1.0.0", "PUBLISHED"), diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java index 5809dd66..6be4e061 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/SkillControllerTest.java @@ -15,9 +15,10 @@ import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.TimeZone; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.when; @@ -58,7 +59,7 @@ class SkillControllerTest { "initial", 2, 128L, - LocalDateTime.of(2026, 3, 12, 12, 0), + Instant.parse("2026-03-12T12:00:00Z"), "{\"name\":\"demo\"}", "[{\"path\":\"SKILL.md\"}]" )); @@ -73,6 +74,39 @@ class SkillControllerTest { .andExpect(jsonPath("$.requestId").isNotEmpty()); } + @Test + void getVersionDetailShouldRemainUtcAcrossJvmDefaultTimeZones() throws Exception { + when(skillQueryService.getVersionDetail( + eq("team"), + eq("demo"), + eq("1.0.0"), + eq((String) null), + eq(Map.of()))) + .thenReturn(new SkillQueryService.SkillVersionDetailDTO( + 10L, + "1.0.0", + "PUBLISHED", + "initial", + 2, + 128L, + Instant.parse("2026-03-12T12:00:00Z"), + "{\"name\":\"demo\"}", + "[{\"path\":\"SKILL.md\"}]" + )); + + TimeZone original = TimeZone.getDefault(); + try { + for (String zoneId : List.of("Asia/Shanghai", "America/Los_Angeles")) { + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + mockMvc.perform(get("/api/v1/skills/team/demo/versions/1.0.0")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.publishedAt").value("2026-03-12T12:00:00Z")); + } + } finally { + TimeZone.setDefault(original); + } + } + @Test void resolveVersionShouldReturnUnifiedEnvelope() throws Exception { when(skillQueryService.resolveVersion( @@ -124,8 +158,8 @@ class SkillControllerTest { 0, false, 1L, - LocalDateTime.of(2026, 3, 15, 10, 0), - LocalDateTime.of(2026, 3, 15, 10, 0), + Instant.parse("2026-03-15T10:00:00Z"), + Instant.parse("2026-03-15T10:00:00Z"), true, false, false, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java index 3fbd648c..d97b47f3 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/TokenControllerTest.java @@ -19,6 +19,7 @@ import org.springframework.test.web.servlet.MockMvc; import java.util.List; import java.util.Set; +import java.util.TimeZone; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -127,8 +128,8 @@ class TokenControllerTest { ); var token = new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"); org.springframework.test.util.ReflectionTestUtils.setField(token, "id", 7L); - org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.LocalDateTime.of(2026, 3, 15, 12, 0)); - token.setExpiresAt(java.time.LocalDateTime.of(2026, 4, 15, 12, 0)); + org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.Instant.parse("2026-03-15T12:00:00Z")); + token.setExpiresAt(java.time.Instant.parse("2026-04-15T12:00:00Z")); given(apiTokenService.rotateToken("user-42", "cli", "[\"skill:read\",\"skill:publish\"]", "2026-04-15T12:00:00")) .willReturn(new ApiTokenService.TokenCreateResult("sk_raw", token)); @@ -137,12 +138,12 @@ class TokenControllerTest { .with(authentication(auth)) .with(csrf()) .contentType("application/json") - .content(""" + .content(""" {"name":"cli","expiresAt":"2026-04-15T12:00:00"} """)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) - .andExpect(jsonPath("$.data.expiresAt").value("2026-04-15T12:00")); + .andExpect(jsonPath("$.data.expiresAt").value("2026-04-15T12:00:00Z")); } @Test @@ -164,9 +165,9 @@ class TokenControllerTest { var first = tokenPage.getContent().get(0); var second = tokenPage.getContent().get(1); org.springframework.test.util.ReflectionTestUtils.setField(first, "id", 7L); - org.springframework.test.util.ReflectionTestUtils.setField(first, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 10, 0)); + org.springframework.test.util.ReflectionTestUtils.setField(first, "createdAt", java.time.Instant.parse("2026-03-14T10:00:00Z")); org.springframework.test.util.ReflectionTestUtils.setField(second, "id", 8L); - org.springframework.test.util.ReflectionTestUtils.setField(second, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 11, 0)); + org.springframework.test.util.ReflectionTestUtils.setField(second, "createdAt", java.time.Instant.parse("2026-03-14T11:00:00Z")); given(apiTokenService.listActiveTokens("user-42", 1, 10)).willReturn(tokenPage); @@ -193,8 +194,8 @@ class TokenControllerTest { ); var token = new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]"); org.springframework.test.util.ReflectionTestUtils.setField(token, "id", 7L); - org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.LocalDateTime.of(2026, 3, 14, 10, 0)); - token.setExpiresAt(java.time.LocalDateTime.of(2026, 5, 1, 9, 30)); + org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.Instant.parse("2026-03-14T10:00:00Z")); + token.setExpiresAt(java.time.Instant.parse("2026-05-01T09:30:00Z")); given(apiTokenService.updateExpiration(7L, "user-42", "2026-05-01T09:30")) .willReturn(token); @@ -209,6 +210,43 @@ class TokenControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.id").value(7)) - .andExpect(jsonPath("$.data.expiresAt").value("2026-05-01T09:30")); + .andExpect(jsonPath("$.data.expiresAt").value("2026-05-01T09:30:00Z")); + } + + @Test + void list_returnsUtcTimestamps_evenWhenJvmDefaultTimeZoneChanges() throws Exception { + PlatformPrincipal principal = new PlatformPrincipal( + "user-42", "tester", "tester@example.com", "", "github", Set.of("USER") + ); + var auth = new UsernamePasswordAuthenticationToken( + principal, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) + ); + var tokenPage = new PageImpl<>( + List.of(new com.iflytek.skillhub.auth.entity.ApiToken("user-42", "cli", "sk_123456", "hash-1", "[]")), + PageRequest.of(0, 10), + 1 + ); + var token = tokenPage.getContent().getFirst(); + org.springframework.test.util.ReflectionTestUtils.setField(token, "id", 7L); + org.springframework.test.util.ReflectionTestUtils.setField(token, "createdAt", java.time.Instant.parse("2026-03-14T10:00:00Z")); + token.setExpiresAt(java.time.Instant.parse("2026-05-01T09:30:00Z")); + + given(apiTokenService.listActiveTokens("user-42", 0, 10)).willReturn(tokenPage); + + TimeZone original = TimeZone.getDefault(); + try { + for (String zoneId : List.of("Asia/Shanghai", "America/Los_Angeles")) { + TimeZone.setDefault(TimeZone.getTimeZone(zoneId)); + mockMvc.perform(get("/api/v1/tokens") + .with(authentication(auth)) + .param("page", "0") + .param("size", "10")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.items[0].createdAt").value("2026-03-14T10:00:00Z")) + .andExpect(jsonPath("$.data.items[0].expiresAt").value("2026-05-01T09:30:00Z")); + } + } finally { + TimeZone.setDefault(original); + } } } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java index 0ff84bb4..6782ae65 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/AdminSkillReportControllerTest.java @@ -19,7 +19,7 @@ import com.iflytek.skillhub.domain.report.SkillReportService; import com.iflytek.skillhub.dto.AdminSkillReportSummaryResponse; import com.iflytek.skillhub.dto.PageResponse; import com.iflytek.skillhub.service.AdminSkillReportAppService; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Set; import org.junit.jupiter.api.Test; @@ -71,7 +71,7 @@ class AdminSkillReportControllerTest { "PENDING", null, null, - LocalDateTime.of(2026, 3, 15, 12, 0), + Instant.parse("2026-03-15T12:00:00Z"), null )), 1, @@ -85,7 +85,8 @@ class AdminSkillReportControllerTest { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(0)) .andExpect(jsonPath("$.data.items[0].id").value(99)) - .andExpect(jsonPath("$.data.items[0].skillSlug").value("demo-skill")); + .andExpect(jsonPath("$.data.items[0].skillSlug").value("demo-skill")) + .andExpect(jsonPath("$.data.items[0].createdAt").value("2026-03-15T12:00:00Z")); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java index 5b55e6b1..822a06f5 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/controller/admin/UserManagementControllerTest.java @@ -19,9 +19,9 @@ import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.web.servlet.MockMvc; +import java.time.Instant; import java.util.List; import java.util.Set; -import java.time.LocalDateTime; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication; import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; @@ -74,7 +74,7 @@ class UserManagementControllerTest { "alice@example.com", "ACTIVE", List.of("AUDITOR"), - LocalDateTime.of(2026, 3, 13, 9, 0))), + Instant.parse("2026-03-13T09:00:00Z"))), 1, 0, 20)); @@ -86,6 +86,7 @@ class UserManagementControllerTest { .andExpect(jsonPath("$.data.total").value(1)) .andExpect(jsonPath("$.data.items[0].id").value("user-1")) .andExpect(jsonPath("$.data.items[0].email").value("alice@example.com")) + .andExpect(jsonPath("$.data.items[0].createdAt").value("2026-03-13T09:00:00Z")) .andExpect(jsonPath("$.data.items[0].platformRoles[0]").value("AUDITOR")); } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java index 5b3a1fc8..80f761ea 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/AuthContextFilterTest.java @@ -12,6 +12,9 @@ import com.iflytek.skillhub.domain.user.UserStatus; import com.iflytek.skillhub.dto.ApiResponseFactory; import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpSession; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.Locale; import java.util.Set; @@ -41,7 +44,8 @@ class AuthContextFilterTest { AuthContextFilterTest() { StaticMessageSource messageSource = new StaticMessageSource(); messageSource.addMessage("error.auth.local.accountDisabled", Locale.ENGLISH, "This account has been disabled"); - ApiResponseFactory apiResponseFactory = new ApiResponseFactory(messageSource); + Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); + ApiResponseFactory apiResponseFactory = new ApiResponseFactory(messageSource, clock); filter = new AuthContextFilter( namespaceMemberRepository, userAccountRepository, diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java index 7c50a88d..485534fb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/filter/IdempotencyInterceptorTest.java @@ -17,7 +17,9 @@ import org.springframework.data.redis.core.ValueOperations; import java.io.PrintWriter; import java.io.StringWriter; +import java.time.Clock; import java.time.Instant; +import java.time.ZoneOffset; import java.util.Optional; import java.util.concurrent.TimeUnit; @@ -44,12 +46,14 @@ class IdempotencyInterceptorTest { private HttpServletResponse response; private IdempotencyInterceptor interceptor; + private Clock clock; @BeforeEach void setUp() { ObjectMapper objectMapper = new ObjectMapper(); objectMapper.registerModule(new JavaTimeModule()); - interceptor = new IdempotencyInterceptor(redisTemplate, idempotencyRecordRepository, objectMapper); + clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); + interceptor = new IdempotencyInterceptor(redisTemplate, idempotencyRecordRepository, objectMapper, clock); } @Test @@ -115,7 +119,7 @@ class IdempotencyInterceptorTest { IdempotencyRecord record = new IdempotencyRecord( "req-789", (String) null, (Long) null, IdempotencyStatus.PROCESSING, (Integer) null, - Instant.now(), Instant.now().plusSeconds(86400) + Instant.now(clock), Instant.now(clock).plusSeconds(86400) ); when(idempotencyRecordRepository.findByRequestId("req-789")).thenReturn(Optional.of(record)); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java index 64f3fbfa..4dd22e9c 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/AdminUserAppServiceTest.java @@ -18,7 +18,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.test.util.ReflectionTestUtils; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.List; import java.util.Optional; import java.util.Set; @@ -147,8 +147,8 @@ class AdminUserAppServiceTest { private UserAccount user(String id, String displayName, String email, UserStatus status) { UserAccount user = new UserAccount(id, displayName, email, null); user.setStatus(status); - ReflectionTestUtils.setField(user, "createdAt", LocalDateTime.of(2026, 3, 13, 9, 0)); - ReflectionTestUtils.setField(user, "updatedAt", LocalDateTime.of(2026, 3, 13, 9, 0)); + ReflectionTestUtils.setField(user, "createdAt", Instant.parse("2026-03-13T09:00:00Z")); + ReflectionTestUtils.setField(user, "updatedAt", Instant.parse("2026-03-13T09:00:00Z")); return user; } diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java index dfa191a6..ca178dfb 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/service/MySkillAppServiceTest.java @@ -24,6 +24,7 @@ import org.springframework.data.domain.PageRequest; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; +import java.time.Instant; import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -82,7 +83,7 @@ class MySkillAppServiceTest { ReflectionTestUtils.setField(firstSkill, "id", 1L); ReflectionTestUtils.setField(firstSkill, "starCount", 1); ReflectionTestUtils.setField(firstSkill, "namespaceId", 101L); - ReflectionTestUtils.setField(firstSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 10, 0)); + ReflectionTestUtils.setField(firstSkill, "updatedAt", Instant.parse("2026-03-14T10:00:00Z")); Skill secondSkill = new Skill(2L, "second-skill", "user-1", SkillVisibility.PUBLIC); secondSkill.setDisplayName("Second Skill"); @@ -90,7 +91,7 @@ class MySkillAppServiceTest { ReflectionTestUtils.setField(secondSkill, "id", 2L); ReflectionTestUtils.setField(secondSkill, "starCount", 2); ReflectionTestUtils.setField(secondSkill, "namespaceId", 101L); - ReflectionTestUtils.setField(secondSkill, "updatedAt", LocalDateTime.of(2026, 3, 14, 11, 0)); + ReflectionTestUtils.setField(secondSkill, "updatedAt", Instant.parse("2026-03-14T11:00:00Z")); given(skillRepository.findByIdIn(List.of(2L))).willReturn(List.of(secondSkill)); given(skillVersionRepository.findBySkillIdAndStatus(2L, SkillVersionStatus.PUBLISHED)).willReturn(List.of()); @@ -110,12 +111,12 @@ class MySkillAppServiceTest { skill.setDisplayName("Draft Skill"); skill.setSummary("pending review"); ReflectionTestUtils.setField(skill, "id", 1L); - ReflectionTestUtils.setField(skill, "updatedAt", LocalDateTime.of(2026, 3, 15, 10, 0)); + ReflectionTestUtils.setField(skill, "updatedAt", Instant.parse("2026-03-15T10:00:00Z")); SkillVersion pendingVersion = new SkillVersion(1L, "1.0.0", "user-1"); pendingVersion.setStatus(SkillVersionStatus.PENDING_REVIEW); ReflectionTestUtils.setField(pendingVersion, "id", 11L); - ReflectionTestUtils.setField(pendingVersion, "createdAt", LocalDateTime.of(2026, 3, 15, 9, 30)); + ReflectionTestUtils.setField(pendingVersion, "createdAt", Instant.parse("2026-03-15T09:30:00Z")); given(skillRepository.findByOwnerId("user-1", PageRequest.of(0, 10))) .willReturn(new PageImpl<>(List.of(skill), PageRequest.of(0, 10), 1)); @@ -140,12 +141,12 @@ class MySkillAppServiceTest { skill.setDisplayName("Team Skill"); skill.setSummary("published"); ReflectionTestUtils.setField(skill, "id", 2L); - ReflectionTestUtils.setField(skill, "updatedAt", LocalDateTime.of(2026, 3, 15, 11, 0)); + ReflectionTestUtils.setField(skill, "updatedAt", Instant.parse("2026-03-15T11:00:00Z")); SkillVersion publishedVersion = new SkillVersion(2L, "1.2.0", "user-1"); publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); ReflectionTestUtils.setField(publishedVersion, "id", 22L); - ReflectionTestUtils.setField(publishedVersion, "createdAt", LocalDateTime.of(2026, 3, 15, 10, 30)); + ReflectionTestUtils.setField(publishedVersion, "createdAt", Instant.parse("2026-03-15T10:30:00Z")); Namespace namespace = new Namespace("team-ai", "Team AI", "user-1"); ReflectionTestUtils.setField(namespace, "id", 101L); @@ -176,7 +177,7 @@ class MySkillAppServiceTest { SkillVersion publishedVersion = new SkillVersion(2L, "1.2.0", "user-1"); publishedVersion.setStatus(SkillVersionStatus.PUBLISHED); ReflectionTestUtils.setField(publishedVersion, "id", 22L); - ReflectionTestUtils.setField(publishedVersion, "createdAt", LocalDateTime.of(2026, 3, 15, 10, 30)); + ReflectionTestUtils.setField(publishedVersion, "createdAt", Instant.parse("2026-03-15T10:30:00Z")); Namespace namespace = new Namespace("team-ai", "Team AI", "user-1"); ReflectionTestUtils.setField(namespace, "id", 101L); diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java index 67294b40..95f0f0ee 100644 --- a/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/task/IdempotencyCleanupTaskTest.java @@ -7,7 +7,9 @@ import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import java.time.Clock; import java.time.Instant; +import java.time.ZoneOffset; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -22,7 +24,8 @@ class IdempotencyCleanupTaskTest { @BeforeEach void setUp() { - cleanupTask = new IdempotencyCleanupTask(idempotencyRecordRepository); + Clock clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); + cleanupTask = new IdempotencyCleanupTask(idempotencyRecordRepository, clock); } @Test diff --git a/server/skillhub-app/src/test/java/com/iflytek/skillhub/time/BackendTimeGuardrailTest.java b/server/skillhub-app/src/test/java/com/iflytek/skillhub/time/BackendTimeGuardrailTest.java new file mode 100644 index 00000000..c1ec8c21 --- /dev/null +++ b/server/skillhub-app/src/test/java/com/iflytek/skillhub/time/BackendTimeGuardrailTest.java @@ -0,0 +1,73 @@ +package com.iflytek.skillhub.time; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Pattern; +import org.junit.jupiter.api.Test; + +class BackendTimeGuardrailTest { + + private static final Pattern LOCAL_DATE_TIME_NOW_PATTERN = + Pattern.compile("\\bLocalDateTime\\s*\\.\\s*now\\s*\\("); + + private static final Pattern ENTITY_ANNOTATION_PATTERN = + Pattern.compile("^\\s*@Entity\\b", Pattern.MULTILINE); + + private static final Pattern LOCAL_DATE_TIME_FIELD_PATTERN = + Pattern.compile("^\\s*private\\s+LocalDateTime\\s+\\w+\\s*;", Pattern.MULTILINE); + + @Test + void productionCode_mustNotIntroduceLocalDateTimeNow_calls() throws IOException { + List violations = new ArrayList<>(); + + for (Path file : productionJavaFiles()) { + String content = Files.readString(file); + if (LOCAL_DATE_TIME_NOW_PATTERN.matcher(content).find()) { + violations.add(relativeToRepo(file) + " uses LocalDateTime.now()"); + } + } + + assertThat(violations).isEmpty(); + } + + @Test + void entities_mustNotUseLocalDateTime_fields() throws IOException { + List violations = new ArrayList<>(); + + for (Path file : productionJavaFiles()) { + String content = Files.readString(file); + if (!ENTITY_ANNOTATION_PATTERN.matcher(content).find()) { + continue; + } + if (LOCAL_DATE_TIME_FIELD_PATTERN.matcher(content).find()) { + violations.add(relativeToRepo(file) + " declares LocalDateTime field(s)"); + } + } + + assertThat(violations).isEmpty(); + } + + private List productionJavaFiles() throws IOException { + List files = new ArrayList<>(); + for (String module : List.of("skillhub-app", "skillhub-auth", "skillhub-domain")) { + Path root = repoRoot().resolve("server").resolve(module).resolve("src/main/java"); + try (var stream = Files.walk(root)) { + stream.filter(path -> path.toString().endsWith(".java")).forEach(files::add); + } + } + return files; + } + + private Path repoRoot() { + return Path.of("").toAbsolutePath().getParent().getParent(); + } + + private String relativeToRepo(Path file) { + return repoRoot().relativize(file).toString(); + } +} diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java index ae1213d3..d1acce99 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/ApiToken.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.auth.entity; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -35,16 +36,16 @@ public class ApiToken { private String scopeJson; @Column(name = "expires_at") - private LocalDateTime expiresAt; + private Instant expiresAt; @Column(name = "last_used_at") - private LocalDateTime lastUsedAt; + private Instant lastUsedAt; @Column(name = "revoked_at") - private LocalDateTime revokedAt; + private Instant revokedAt; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; protected ApiToken() {} @@ -59,7 +60,7 @@ public class ApiToken { } @PrePersist - void prePersist() { this.createdAt = LocalDateTime.now(); } + void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } public String getSubjectType() { return subjectType; } @@ -71,14 +72,16 @@ public class ApiToken { public String getTokenPrefix() { return tokenPrefix; } public String getTokenHash() { return tokenHash; } public String getScopeJson() { return scopeJson; } - public LocalDateTime getExpiresAt() { return expiresAt; } - public void setExpiresAt(LocalDateTime expiresAt) { this.expiresAt = expiresAt; } - public LocalDateTime getLastUsedAt() { return lastUsedAt; } - public void setLastUsedAt(LocalDateTime lastUsedAt) { this.lastUsedAt = lastUsedAt; } - public LocalDateTime getRevokedAt() { return revokedAt; } - public void setRevokedAt(LocalDateTime revokedAt) { this.revokedAt = revokedAt; } - public LocalDateTime getCreatedAt() { return createdAt; } + public Instant getExpiresAt() { return expiresAt; } + public void setExpiresAt(Instant expiresAt) { this.expiresAt = expiresAt; } + public Instant getLastUsedAt() { return lastUsedAt; } + public void setLastUsedAt(Instant lastUsedAt) { this.lastUsedAt = lastUsedAt; } + public Instant getRevokedAt() { return revokedAt; } + public void setRevokedAt(Instant revokedAt) { this.revokedAt = revokedAt; } + public Instant getCreatedAt() { return createdAt; } public boolean isRevoked() { return revokedAt != null; } - public boolean isExpired() { return expiresAt != null && expiresAt.isBefore(LocalDateTime.now()); } + public boolean isExpired() { return isExpired(Instant.now(Clock.systemUTC())); } + public boolean isExpired(Instant referenceTime) { return expiresAt != null && expiresAt.isBefore(referenceTime); } public boolean isValid() { return !isRevoked() && !isExpired(); } + public boolean isValid(Instant referenceTime) { return !isRevoked() && !isExpired(referenceTime); } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java index 36f5e9c0..163694f4 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/IdentityBinding.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.auth.entity; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "identity_binding", @@ -27,10 +28,10 @@ public class IdentityBinding { private String extraJson; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected IdentityBinding() {} @@ -43,13 +44,13 @@ public class IdentityBinding { @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); this.updatedAt = this.createdAt; } @PreUpdate void preUpdate() { - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } @@ -63,4 +64,6 @@ public class IdentityBinding { public void setLoginName(String loginName) { this.loginName = loginName; } public String getExtraJson() { return extraJson; } public void setExtraJson(String extraJson) { this.extraJson = extraJson; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java index 6c50924b..fbd3569a 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/Role.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.auth.entity; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "role") @@ -23,13 +24,14 @@ public class Role { private boolean system; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @PrePersist - void prePersist() { this.createdAt = LocalDateTime.now(); } + void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } public String getCode() { return code; } public String getName() { return name; } public boolean isSystem() { return system; } + public Instant getCreatedAt() { return createdAt; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java index 843cbdd0..20951d03 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/entity/UserRoleBinding.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.auth.entity; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "user_role_binding", @@ -19,7 +20,7 @@ public class UserRoleBinding { private Role role; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; protected UserRoleBinding() {} @@ -29,10 +30,11 @@ public class UserRoleBinding { } @PrePersist - void prePersist() { this.createdAt = LocalDateTime.now(); } + void prePersist() { this.createdAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } public String getUserId() { return userId; } public void setUserId(String userId) { this.userId = userId; } public Role getRole() { return role; } + public Instant getCreatedAt() { return createdAt; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java index 9aa93de7..4fd3fd99 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalAuthService.java @@ -8,8 +8,9 @@ import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; +import java.time.Clock; import java.time.Duration; -import java.time.LocalDateTime; +import java.time.Instant; import java.util.Locale; import java.util.Set; import java.util.UUID; @@ -38,19 +39,22 @@ public class LocalAuthService { private final GlobalNamespaceMembershipService globalNamespaceMembershipService; private final PasswordPolicyValidator passwordPolicyValidator; private final PasswordEncoder passwordEncoder; + private final Clock clock; public LocalAuthService(LocalCredentialRepository credentialRepository, UserAccountRepository userAccountRepository, UserRoleBindingRepository userRoleBindingRepository, GlobalNamespaceMembershipService globalNamespaceMembershipService, PasswordPolicyValidator passwordPolicyValidator, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, + Clock clock) { this.credentialRepository = credentialRepository; this.userAccountRepository = userAccountRepository; this.userRoleBindingRepository = userRoleBindingRepository; this.globalNamespaceMembershipService = globalNamespaceMembershipService; this.passwordPolicyValidator = passwordPolicyValidator; this.passwordEncoder = passwordEncoder; + this.clock = clock; } @Transactional @@ -168,8 +172,9 @@ public class LocalAuthService { } private void ensureNotLocked(LocalCredential credential) { - if (credential.getLockedUntil() != null && credential.getLockedUntil().isAfter(LocalDateTime.now())) { - long minutes = Math.max(1, Duration.between(LocalDateTime.now(), credential.getLockedUntil()).toMinutes()); + Instant now = currentTime(); + if (credential.getLockedUntil() != null && credential.getLockedUntil().isAfter(now)) { + long minutes = Math.max(1, Duration.between(now, credential.getLockedUntil()).toMinutes()); throw new AuthFlowException(HttpStatus.LOCKED, "error.auth.local.locked", minutes); } } @@ -178,11 +183,15 @@ public class LocalAuthService { int failedAttempts = credential.getFailedAttempts() + 1; credential.setFailedAttempts(failedAttempts); if (failedAttempts >= MAX_FAILED_ATTEMPTS) { - credential.setLockedUntil(LocalDateTime.now().plus(LOCK_DURATION)); + credential.setLockedUntil(currentTime().plus(LOCK_DURATION)); } credentialRepository.save(credential); } + private Instant currentTime() { + return Instant.now(clock); + } + private AuthFlowException invalidCredentials() { return new AuthFlowException(HttpStatus.UNAUTHORIZED, "error.auth.local.invalidCredentials"); } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java index fe788497..d712516f 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/local/LocalCredential.java @@ -8,7 +8,8 @@ import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "local_credential") @@ -31,13 +32,13 @@ public class LocalCredential { private int failedAttempts; @Column(name = "locked_until") - private LocalDateTime lockedUntil; + private Instant lockedUntil; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected LocalCredential() {} @@ -50,13 +51,13 @@ public class LocalCredential { @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); this.updatedAt = this.createdAt; } @PreUpdate void preUpdate() { - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); } public Long getId() { @@ -87,15 +88,23 @@ public class LocalCredential { this.failedAttempts = failedAttempts; } - public LocalDateTime getLockedUntil() { + public Instant getLockedUntil() { return lockedUntil; } - public void setLockedUntil(LocalDateTime lockedUntil) { + public void setLockedUntil(Instant lockedUntil) { this.lockedUntil = lockedUntil; } public void setPasswordHash(String passwordHash) { this.passwordHash = passwordHash; } + + public Instant getCreatedAt() { + return createdAt; + } + + public Instant getUpdatedAt() { + return updatedAt; + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java index b671f60b..e6264107 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeRequest.java @@ -7,7 +7,8 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "account_merge_request") @@ -35,20 +36,20 @@ public class AccountMergeRequest { private String verificationToken; @Column(name = "token_expires_at") - private LocalDateTime tokenExpiresAt; + private Instant tokenExpiresAt; @Column(name = "completed_at") - private LocalDateTime completedAt; + private Instant completedAt; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; protected AccountMergeRequest() {} public AccountMergeRequest(String primaryUserId, String secondaryUserId, String verificationToken, - LocalDateTime tokenExpiresAt) { + Instant tokenExpiresAt) { this.primaryUserId = primaryUserId; this.secondaryUserId = secondaryUserId; this.verificationToken = verificationToken; @@ -58,7 +59,7 @@ public class AccountMergeRequest { @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } @@ -68,9 +69,9 @@ public class AccountMergeRequest { public void setStatus(String status) { this.status = status; } public String getVerificationToken() { return verificationToken; } public void setVerificationToken(String verificationToken) { this.verificationToken = verificationToken; } - public LocalDateTime getTokenExpiresAt() { return tokenExpiresAt; } - public void setTokenExpiresAt(LocalDateTime tokenExpiresAt) { this.tokenExpiresAt = tokenExpiresAt; } - public LocalDateTime getCompletedAt() { return completedAt; } - public void setCompletedAt(LocalDateTime completedAt) { this.completedAt = completedAt; } - public LocalDateTime getCreatedAt() { return createdAt; } + public Instant getTokenExpiresAt() { return tokenExpiresAt; } + public void setTokenExpiresAt(Instant tokenExpiresAt) { this.tokenExpiresAt = tokenExpiresAt; } + public Instant getCompletedAt() { return completedAt; } + public void setCompletedAt(Instant completedAt) { this.completedAt = completedAt; } + public Instant getCreatedAt() { return createdAt; } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java index 9badccb5..92368699 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/merge/AccountMergeService.java @@ -17,7 +17,9 @@ import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; import java.security.SecureRandom; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Duration; +import java.time.Instant; import java.util.Base64; import java.util.Comparator; import java.util.HashSet; @@ -47,6 +49,7 @@ public class AccountMergeService { private final ApiTokenRepository apiTokenRepository; private final NamespaceMemberRepository namespaceMemberRepository; private final PasswordEncoder passwordEncoder; + private final Clock clock; private final SecureRandom secureRandom = new SecureRandom(); public AccountMergeService(AccountMergeRequestRepository mergeRequestRepository, @@ -56,7 +59,8 @@ public class AccountMergeService { UserRoleBindingRepository userRoleBindingRepository, ApiTokenRepository apiTokenRepository, NamespaceMemberRepository namespaceMemberRepository, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, + Clock clock) { this.mergeRequestRepository = mergeRequestRepository; this.userAccountRepository = userAccountRepository; this.localCredentialRepository = localCredentialRepository; @@ -65,9 +69,10 @@ public class AccountMergeService { this.apiTokenRepository = apiTokenRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.passwordEncoder = passwordEncoder; + this.clock = clock; } - public record InitiationResult(Long mergeRequestId, String secondaryUserId, String verificationToken, LocalDateTime expiresAt) {} + public record InitiationResult(Long mergeRequestId, String secondaryUserId, String verificationToken, Instant expiresAt) {} @Transactional public InitiationResult initiate(String primaryUserId, String secondaryIdentifier) { @@ -93,7 +98,7 @@ public class AccountMergeService { primaryUserId, secondaryUser.getId(), passwordEncoder.encode(rawToken), - LocalDateTime.now().plusMinutes(30) + currentTime().plus(Duration.ofMinutes(30)) ); request = mergeRequestRepository.save(request); return new InitiationResult(request.getId(), secondaryUser.getId(), rawToken, request.getTokenExpiresAt()); @@ -106,7 +111,7 @@ public class AccountMergeService { if (!AccountMergeRequest.STATUS_PENDING.equals(request.getStatus())) { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.requestNotPending"); } - if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(LocalDateTime.now())) { + if (request.getTokenExpiresAt() == null || request.getTokenExpiresAt().isBefore(currentTime())) { throw new AuthFlowException(HttpStatus.BAD_REQUEST, "error.auth.merge.tokenExpired"); } if (!passwordEncoder.matches(verificationToken, request.getVerificationToken())) { @@ -152,7 +157,7 @@ public class AccountMergeService { userAccountRepository.save(secondaryUser); request.setStatus(AccountMergeRequest.STATUS_COMPLETED); - request.setCompletedAt(LocalDateTime.now()); + request.setCompletedAt(currentTime()); request.setVerificationToken(null); mergeRequestRepository.save(request); } @@ -272,4 +277,8 @@ public class AccountMergeService { secureRandom.nextBytes(tokenBytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes); } + + private Instant currentTime() { + return Instant.now(clock); + } } diff --git a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java index 8f24ee3c..6ffcaa23 100644 --- a/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java +++ b/server/skillhub-auth/src/main/java/com/iflytek/skillhub/auth/token/ApiTokenService.java @@ -14,8 +14,12 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.security.SecureRandom; -import java.time.format.DateTimeParseException; +import java.time.Clock; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.format.DateTimeParseException; import java.util.Base64; import java.util.HexFormat; import java.util.List; @@ -29,9 +33,11 @@ public class ApiTokenService { private static final int MAX_NAME_LENGTH = 64; private final SecureRandom secureRandom = new SecureRandom(); private final ApiTokenRepository tokenRepo; + private final Clock clock; - public ApiTokenService(ApiTokenRepository tokenRepo) { + public ApiTokenService(ApiTokenRepository tokenRepo, Clock clock) { this.tokenRepo = tokenRepo; + this.clock = clock; } public record TokenCreateResult(String rawToken, ApiToken entity) {} @@ -45,7 +51,7 @@ public class ApiTokenService { public TokenCreateResult createToken(String userId, String name, String scopeJson, String expiresAt) { String normalizedName = normalizeName(name); validateTokenName(userId, normalizedName); - LocalDateTime parsedExpiresAt = parseExpiresAt(expiresAt); + Instant parsedExpiresAt = parseExpiresAt(expiresAt); byte[] randomBytes = new byte[TOKEN_BYTES]; secureRandom.nextBytes(randomBytes); @@ -77,7 +83,7 @@ public class ApiTokenService { String normalizedName = normalizeName(name); tokenRepo.findByUserIdAndNameIgnoreCaseAndRevokedAtIsNull(userId, normalizedName) .ifPresent(existing -> { - existing.setRevokedAt(LocalDateTime.now()); + existing.setRevokedAt(currentTime()); tokenRepo.save(existing); }); return createToken(userId, name, scopeJson, expiresAt); @@ -85,7 +91,7 @@ public class ApiTokenService { public Optional validateToken(String rawToken) { String hash = sha256(rawToken); - return tokenRepo.findByTokenHash(hash).filter(ApiToken::isValid); + return tokenRepo.findByTokenHash(hash).filter(token -> token.isValid(currentTime())); } @Transactional @@ -93,7 +99,7 @@ public class ApiTokenService { tokenRepo.findById(tokenId) .filter(t -> t.getUserId().equals(userId)) .ifPresent(t -> { - t.setRevokedAt(LocalDateTime.now()); + t.setRevokedAt(currentTime()); tokenRepo.save(t); }); } @@ -119,7 +125,7 @@ public class ApiTokenService { @Transactional public void touchLastUsed(ApiToken token) { - token.setLastUsedAt(LocalDateTime.now()); + token.setLastUsedAt(currentTime()); tokenRepo.save(token); } @@ -152,14 +158,14 @@ public class ApiTokenService { } } - private LocalDateTime parseExpiresAt(String expiresAt) { + private Instant parseExpiresAt(String expiresAt) { if (expiresAt == null || expiresAt.isBlank()) { return null; } try { - LocalDateTime parsed = LocalDateTime.parse(expiresAt.trim()); - if (!parsed.isAfter(LocalDateTime.now())) { + Instant parsed = parseInstant(expiresAt.trim()); + if (!parsed.isAfter(currentTime())) { throw new DomainBadRequestException("validation.token.expiresAt.future"); } return parsed; @@ -167,4 +173,23 @@ public class ApiTokenService { throw new DomainBadRequestException("validation.token.expiresAt.invalid"); } } + + private Instant parseInstant(String value) { + try { + return Instant.parse(value); + } catch (DateTimeParseException ignored) { + } + + try { + return OffsetDateTime.parse(value).toInstant(); + } catch (DateTimeParseException ignored) { + } + + // Legacy compatibility: treat naive timestamps as UTC instead of server-local time. + return LocalDateTime.parse(value).toInstant(ZoneOffset.UTC); + } + + private Instant currentTime() { + return Instant.now(clock); + } } diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java index 3b069463..b566b922 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/local/LocalAuthServiceTest.java @@ -17,7 +17,9 @@ import com.iflytek.skillhub.domain.namespace.GlobalNamespaceMembershipService; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import com.iflytek.skillhub.domain.user.UserStatus; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -32,6 +34,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; @ExtendWith(MockitoExtension.class) class LocalAuthServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T06:00:00Z"), ZoneOffset.UTC); + @Mock private LocalCredentialRepository credentialRepository; @@ -57,7 +61,8 @@ class LocalAuthServiceTest { userRoleBindingRepository, globalNamespaceMembershipService, new PasswordPolicyValidator(), - passwordEncoder + passwordEncoder, + CLOCK ); } @@ -85,7 +90,7 @@ class LocalAuthServiceTest { void login_withValidPassword_resetsCounters() { LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); credential.setFailedAttempts(3); - credential.setLockedUntil(LocalDateTime.now().minusMinutes(1)); + credential.setLockedUntil(Instant.now(CLOCK).minusSeconds(60)); UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); Role role = mock(Role.class); given(role.getCode()).willReturn("USER_ADMIN"); @@ -121,6 +126,38 @@ class LocalAuthServiceTest { verify(credentialRepository).save(credential); } + @Test + void login_afterMaxFailures_setsLockUsingInjectedClock() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + credential.setFailedAttempts(4); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + given(passwordEncoder.matches("bad", "encoded")).willReturn(false); + + assertThatThrownBy(() -> service.login("alice", "bad")) + .isInstanceOf(AuthFlowException.class) + .extracting("status") + .isEqualTo(HttpStatus.UNAUTHORIZED); + + assertThat(credential.getLockedUntil()).isEqualTo(Instant.now(CLOCK).plusSeconds(15 * 60)); + } + + @Test + void login_whileLocked_reportsRemainingMinutesFromInjectedClock() { + LocalCredential credential = new LocalCredential("usr_1", "alice", "encoded"); + credential.setLockedUntil(Instant.now(CLOCK).plusSeconds(5 * 60)); + UserAccount user = new UserAccount("usr_1", "alice", "alice@example.com", null); + + given(credentialRepository.findByUsernameIgnoreCase("alice")).willReturn(Optional.of(credential)); + given(userAccountRepository.findById("usr_1")).willReturn(Optional.of(user)); + + assertThatThrownBy(() -> service.login("alice", "Abcd123!")) + .isInstanceOf(AuthFlowException.class) + .hasMessageContaining("error.auth.local.locked"); + } + @Test void login_withUnknownUsername_stillPerformsDummyPasswordCheck() { given(credentialRepository.findByUsernameIgnoreCase("ghost")).willReturn(Optional.empty()); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java index fa38a89d..d882386d 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/merge/AccountMergeServiceTest.java @@ -24,7 +24,9 @@ import com.iflytek.skillhub.domain.namespace.NamespaceRole; import com.iflytek.skillhub.domain.user.UserAccount; import com.iflytek.skillhub.domain.user.UserAccountRepository; import java.lang.reflect.Field; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -55,9 +57,11 @@ class AccountMergeServiceTest { private PasswordEncoder passwordEncoder; private AccountMergeService service; + private Clock clock; @BeforeEach void setUp() { + clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); service = new AccountMergeService( mergeRequestRepository, userAccountRepository, @@ -66,7 +70,8 @@ class AccountMergeServiceTest { userRoleBindingRepository, apiTokenRepository, namespaceMemberRepository, - passwordEncoder + passwordEncoder, + clock ); } @@ -89,6 +94,7 @@ class AccountMergeServiceTest { assertThat(result.secondaryUserId()).isEqualTo("usr_secondary"); assertThat(result.verificationToken()).isNotBlank(); + assertThat(result.expiresAt()).isEqualTo(Instant.parse("2026-03-18T00:30:00Z")); verify(mergeRequestRepository).save(any(AccountMergeRequest.class)); } @@ -145,6 +151,7 @@ class AccountMergeServiceTest { assertThat(secondary.getStatus()).isEqualTo(com.iflytek.skillhub.domain.user.UserStatus.MERGED); assertThat(secondary.getMergedToUserId()).isEqualTo("usr_primary"); assertThat(request.getStatus()).isEqualTo(AccountMergeRequest.STATUS_COMPLETED); + assertThat(request.getCompletedAt()).isEqualTo(Instant.parse("2026-03-18T00:00:00Z")); assertThat(request.getVerificationToken()).isNull(); verify(userRoleBindingRepository).save(any(UserRoleBinding.class)); verify(userRoleBindingRepository).deleteAll(List.of(secondaryRole)); @@ -168,7 +175,7 @@ class AccountMergeServiceTest { primaryUserId, secondaryUserId, token, - LocalDateTime.now().plusMinutes(10) + Instant.now(clock).plusSeconds(600) ); Field idField = AccountMergeRequest.class.getDeclaredField("id"); idField.setAccessible(true); diff --git a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java index af137f20..2e4de008 100644 --- a/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java +++ b/server/skillhub-auth/src/test/java/com/iflytek/skillhub/auth/token/ApiTokenServiceTest.java @@ -10,7 +10,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.dao.DataIntegrityViolationException; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThat; @@ -27,10 +29,12 @@ class ApiTokenServiceTest { private ApiTokenRepository tokenRepo; private ApiTokenService service; + private Clock clock; @BeforeEach void setUp() { - service = new ApiTokenService(tokenRepo); + clock = Clock.fixed(Instant.parse("2026-03-18T00:00:00Z"), ZoneOffset.UTC); + service = new ApiTokenService(tokenRepo, clock); } @Test @@ -76,7 +80,7 @@ class ApiTokenServiceTest { var result = service.createToken("user-1", "CLI", "[]", "2099-03-20T10:15:00"); - assertThat(result.entity().getExpiresAt()).isEqualTo(java.time.LocalDateTime.of(2099, 3, 20, 10, 15)); + assertThat(result.entity().getExpiresAt()).isEqualTo(Instant.parse("2099-03-20T10:15:00Z")); } @Test diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java index 2c7e9eea..da4eef04 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLog.java @@ -5,7 +5,6 @@ import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; import jakarta.persistence.Table; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -56,7 +55,8 @@ public class AuditLog { String requestId, String clientIp, String userAgent, - String detailJson) { + String detailJson, + Instant createdAt) { this.actorUserId = actorUserId; this.action = action; this.targetType = targetType; @@ -65,11 +65,7 @@ public class AuditLog { this.clientIp = clientIp; this.userAgent = userAgent; this.detailJson = detailJson; - } - - @PrePersist - void prePersist() { - this.createdAt = Instant.now(); + this.createdAt = createdAt; } public Long getId() { return id; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java index 2fb97e6f..e477abe7 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/audit/AuditLogService.java @@ -3,13 +3,18 @@ package com.iflytek.skillhub.domain.audit; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.Clock; +import java.time.Instant; + @Service public class AuditLogService { private final AuditLogRepository auditLogRepository; + private final Clock clock; - public AuditLogService(AuditLogRepository auditLogRepository) { + public AuditLogService(AuditLogRepository auditLogRepository, Clock clock) { this.auditLogRepository = auditLogRepository; + this.clock = clock; } @Transactional @@ -21,6 +26,7 @@ public class AuditLogService { String clientIp, String userAgent, String detailJson) { + Instant createdAt = Instant.now(clock); return auditLogRepository.save(new AuditLog( actorUserId, action, @@ -29,7 +35,8 @@ public class AuditLogService { requestId, clientIp, userAgent, - detailJson + detailJson, + createdAt )); } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java index 0dfd2046..519c84a9 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationService.java @@ -2,6 +2,8 @@ package com.iflytek.skillhub.domain.governance; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; import com.iflytek.skillhub.domain.shared.exception.DomainNotFoundException; +import java.time.Clock; +import java.time.Instant; import java.util.List; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -10,9 +12,11 @@ import org.springframework.transaction.annotation.Transactional; public class GovernanceNotificationService { private final UserNotificationRepository userNotificationRepository; + private final Clock clock; - public GovernanceNotificationService(UserNotificationRepository userNotificationRepository) { + public GovernanceNotificationService(UserNotificationRepository userNotificationRepository, Clock clock) { this.userNotificationRepository = userNotificationRepository; + this.clock = clock; } @Transactional @@ -22,13 +26,15 @@ public class GovernanceNotificationService { Long entityId, String title, String bodyJson) { + Instant createdAt = Instant.now(clock); return userNotificationRepository.save(new UserNotification( userId, category, entityType, entityId, title, - bodyJson + bodyJson, + createdAt )); } @@ -44,7 +50,7 @@ public class GovernanceNotificationService { if (!notification.getUserId().equals(userId)) { throw new DomainForbiddenException("error.notification.noPermission"); } - notification.markRead(); + notification.markRead(Instant.now(clock)); return userNotificationRepository.save(notification); } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java index 19f713b4..6f95afbd 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/governance/UserNotification.java @@ -7,7 +7,6 @@ import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.PrePersist; import jakarta.persistence.Table; import java.time.Instant; @@ -55,23 +54,20 @@ public class UserNotification { String entityType, Long entityId, String title, - String bodyJson) { + String bodyJson, + Instant createdAt) { this.userId = userId; this.category = category; this.entityType = entityType; this.entityId = entityId; this.title = title; this.bodyJson = bodyJson; + this.createdAt = createdAt; } - @PrePersist - void onCreate() { - createdAt = Instant.now(); - } - - public void markRead() { + public void markRead(Instant readAt) { this.status = UserNotificationStatus.READ; - this.readAt = Instant.now(); + this.readAt = readAt; } public Long getId() { diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java index b480cea1..1f4c2ac8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/Namespace.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.namespace; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "namespace") @@ -34,10 +35,10 @@ public class Namespace { private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected Namespace() {} @@ -49,13 +50,13 @@ public class Namespace { @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); this.updatedAt = this.createdAt; } @PreUpdate void preUpdate() { - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } @@ -71,6 +72,6 @@ public class Namespace { public String getAvatarUrl() { return avatarUrl; } public void setAvatarUrl(String avatarUrl) { this.avatarUrl = avatarUrl; } public String getCreatedBy() { return createdBy; } - public LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java index 1edc7323..541b85a2 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/namespace/NamespaceMember.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.namespace; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "namespace_member", @@ -22,10 +23,10 @@ public class NamespaceMember { private NamespaceRole role; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected NamespaceMember() {} @@ -37,13 +38,13 @@ public class NamespaceMember { @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); this.updatedAt = this.createdAt; } @PreUpdate void preUpdate() { - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); } public Long getId() { return id; } @@ -53,6 +54,6 @@ public class NamespaceMember { public void setUserId(String userId) { this.userId = userId; } public NamespaceRole getRole() { return role; } public void setRole(NamespaceRole role) { this.role = role; } - public LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java index 7329e803..4e369abf 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReport.java @@ -9,7 +9,8 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.Table; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_report") @@ -45,10 +46,10 @@ public class SkillReport { private String handleComment; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "handled_at") - private LocalDateTime handledAt; + private Instant handledAt; protected SkillReport() { } @@ -63,7 +64,7 @@ public class SkillReport { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); + createdAt = Instant.now(Clock.systemUTC()); } public Long getId() { @@ -114,15 +115,15 @@ public class SkillReport { this.handleComment = handleComment; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } - public LocalDateTime getHandledAt() { + public Instant getHandledAt() { return handledAt; } - public void setHandledAt(LocalDateTime handledAt) { + public void setHandledAt(Instant handledAt) { this.handledAt = handledAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java index 23965d71..cb96f00d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/report/SkillReportService.java @@ -8,7 +8,8 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillStatus; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -20,17 +21,20 @@ public class SkillReportService { private final AuditLogService auditLogService; private final SkillGovernanceService skillGovernanceService; private final GovernanceNotificationService governanceNotificationService; + private final Clock clock; public SkillReportService(SkillRepository skillRepository, SkillReportRepository skillReportRepository, AuditLogService auditLogService, SkillGovernanceService skillGovernanceService, - GovernanceNotificationService governanceNotificationService) { + GovernanceNotificationService governanceNotificationService, + Clock clock) { this.skillRepository = skillRepository; this.skillReportRepository = skillReportRepository; this.auditLogService = auditLogService; this.skillGovernanceService = skillGovernanceService; this.governanceNotificationService = governanceNotificationService; + this.clock = clock; } @Transactional @@ -93,7 +97,7 @@ public class SkillReportService { report.setStatus(SkillReportStatus.RESOLVED); report.setHandledBy(actorUserId); report.setHandleComment(normalize(comment)); - report.setHandledAt(LocalDateTime.now()); + report.setHandledAt(currentTime()); SkillReport saved = skillReportRepository.save(report); auditLogService.record(actorUserId, "RESOLVE_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); governanceNotificationService.notifyUser( @@ -117,7 +121,7 @@ public class SkillReportService { report.setStatus(SkillReportStatus.DISMISSED); report.setHandledBy(actorUserId); report.setHandleComment(normalize(comment)); - report.setHandledAt(LocalDateTime.now()); + report.setHandledAt(currentTime()); SkillReport saved = skillReportRepository.save(report); auditLogService.record(actorUserId, "DISMISS_SKILL_REPORT", "SKILL_REPORT", reportId, null, clientIp, userAgent, null); governanceNotificationService.notifyUser( @@ -147,4 +151,8 @@ public class SkillReportService { String trimmed = value.trim(); return trimmed.isEmpty() ? null : trimmed; } + + private Instant currentTime() { + return Instant.now(clock); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java index 733f3350..7ee3c953 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/PromotionService.java @@ -15,7 +15,8 @@ import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import java.util.ConcurrentModificationException; import java.util.List; import java.util.Map; @@ -32,6 +33,7 @@ public class PromotionService { private final ReviewPermissionChecker permissionChecker; private final ApplicationEventPublisher eventPublisher; private final GovernanceNotificationService governanceNotificationService; + private final Clock clock; public PromotionService(PromotionRequestRepository promotionRequestRepository, SkillRepository skillRepository, @@ -40,7 +42,8 @@ public class PromotionService { NamespaceRepository namespaceRepository, ReviewPermissionChecker permissionChecker, ApplicationEventPublisher eventPublisher, - GovernanceNotificationService governanceNotificationService) { + GovernanceNotificationService governanceNotificationService, + Clock clock) { this.promotionRequestRepository = promotionRequestRepository; this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; @@ -49,6 +52,7 @@ public class PromotionService { this.permissionChecker = permissionChecker; this.eventPublisher = eventPublisher; this.governanceNotificationService = governanceNotificationService; + this.clock = clock; } @Transactional @@ -187,7 +191,7 @@ public class PromotionService { SkillVersion newVersion = new SkillVersion(newSkill.getId(), sourceVersion.getVersion(), sourceVersion.getCreatedBy()); newVersion.setStatus(SkillVersionStatus.PUBLISHED); - newVersion.setPublishedAt(LocalDateTime.now()); + newVersion.setPublishedAt(currentTime()); newVersion.setChangelog(sourceVersion.getChangelog()); newVersion.setParsedMetadataJson(sourceVersion.getParsedMetadataJson()); newVersion.setManifestJson(sourceVersion.getManifestJson()); @@ -269,4 +273,8 @@ public class PromotionService { throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); } } + + private Instant currentTime() { + return Instant.now(clock); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java index da988d4a..4802ac11 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/review/ReviewService.java @@ -22,7 +22,8 @@ import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import java.util.ConcurrentModificationException; import java.util.List; import java.util.Map; @@ -40,6 +41,7 @@ public class ReviewService { private final ObjectMapper objectMapper; private final SkillGovernanceService skillGovernanceService; private final GovernanceNotificationService governanceNotificationService; + private final Clock clock; public ReviewService(ReviewTaskRepository reviewTaskRepository, SkillVersionRepository skillVersionRepository, @@ -49,7 +51,8 @@ public class ReviewService { ApplicationEventPublisher eventPublisher, ObjectMapper objectMapper, SkillGovernanceService skillGovernanceService, - GovernanceNotificationService governanceNotificationService) { + GovernanceNotificationService governanceNotificationService, + Clock clock) { this.reviewTaskRepository = reviewTaskRepository; this.skillVersionRepository = skillVersionRepository; this.skillRepository = skillRepository; @@ -59,6 +62,7 @@ public class ReviewService { this.objectMapper = objectMapper; this.skillGovernanceService = skillGovernanceService; this.governanceNotificationService = governanceNotificationService; + this.clock = clock; } @Transactional @@ -175,7 +179,7 @@ public class ReviewService { } skillVersion.setStatus(SkillVersionStatus.PUBLISHED); - skillVersion.setPublishedAt(LocalDateTime.now()); + skillVersion.setPublishedAt(currentTime()); skillVersionRepository.save(skillVersion); skill.setLatestVersionId(skillVersion.getId()); @@ -301,4 +305,8 @@ public class ReviewService { throw new DomainBadRequestException("error.namespace.archived", namespace.getSlug()); } } + + private Instant currentTime() { + return Instant.now(clock); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java index 3b5c2570..def07ce8 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/Skill.java @@ -2,7 +2,8 @@ package com.iflytek.skillhub.domain.skill; import jakarta.persistence.*; import java.math.BigDecimal; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill") @@ -48,7 +49,7 @@ public class Skill { private boolean hidden = false; @Column(name = "hidden_at") - private LocalDateTime hiddenAt; + private Instant hiddenAt; @Column(name = "hidden_by", length = 128) private String hiddenBy; @@ -66,13 +67,13 @@ public class Skill { private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_by") private String updatedBy; @Column(name = "updated_at") - private LocalDateTime updatedAt; + private Instant updatedAt; protected Skill() { } @@ -87,13 +88,13 @@ public class Skill { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = Instant.now(Clock.systemUTC()); + updatedAt = createdAt; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = Instant.now(Clock.systemUTC()); } // Getters @@ -145,7 +146,7 @@ public class Skill { return hidden; } - public LocalDateTime getHiddenAt() { + public Instant getHiddenAt() { return hiddenAt; } @@ -169,7 +170,7 @@ public class Skill { return createdBy; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } @@ -177,7 +178,7 @@ public class Skill { return updatedBy; } - public LocalDateTime getUpdatedAt() { + public Instant getUpdatedAt() { return updatedAt; } @@ -218,7 +219,7 @@ public class Skill { this.hidden = hidden; } - public void setHiddenAt(LocalDateTime hiddenAt) { + public void setHiddenAt(Instant hiddenAt) { this.hiddenAt = hiddenAt; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java index 3caa640e..5dd936ef 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillFile.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.skill; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_file") @@ -30,7 +31,7 @@ public class SkillFile { private String storageKey; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; protected SkillFile() { } @@ -46,7 +47,7 @@ public class SkillFile { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); + createdAt = Instant.now(Clock.systemUTC()); } // Getters @@ -78,7 +79,7 @@ public class SkillFile { return storageKey; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java index 1bf6c292..abfba529 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillTag.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.skill; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_tag") @@ -24,10 +25,10 @@ public class SkillTag { private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at") - private LocalDateTime updatedAt; + private Instant updatedAt; protected SkillTag() { } @@ -41,13 +42,13 @@ public class SkillTag { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); - updatedAt = LocalDateTime.now(); + createdAt = Instant.now(Clock.systemUTC()); + updatedAt = createdAt; } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = Instant.now(Clock.systemUTC()); } // Getters @@ -71,11 +72,11 @@ public class SkillTag { return createdBy; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { + public Instant getUpdatedAt() { return updatedAt; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java index 80efce80..78dfe479 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersion.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.skill; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import org.hibernate.annotations.JdbcTypeCode; import org.hibernate.type.SqlTypes; @@ -41,7 +42,7 @@ public class SkillVersion { private Long totalSize = 0L; @Column(name = "published_at") - private LocalDateTime publishedAt; + private Instant publishedAt; @Column(name = "bundle_ready", nullable = false) private boolean bundleReady; @@ -50,7 +51,7 @@ public class SkillVersion { private boolean downloadReady; @Column(name = "yanked_at") - private LocalDateTime yankedAt; + private Instant yankedAt; @Column(name = "yanked_by", length = 128) private String yankedBy; @@ -62,7 +63,7 @@ public class SkillVersion { private String createdBy; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; protected SkillVersion() { } @@ -76,7 +77,7 @@ public class SkillVersion { @PrePersist protected void onCreate() { - createdAt = LocalDateTime.now(); + createdAt = Instant.now(Clock.systemUTC()); } // Getters @@ -116,7 +117,7 @@ public class SkillVersion { return totalSize; } - public LocalDateTime getPublishedAt() { + public Instant getPublishedAt() { return publishedAt; } @@ -128,7 +129,7 @@ public class SkillVersion { return downloadReady; } - public LocalDateTime getYankedAt() { + public Instant getYankedAt() { return yankedAt; } @@ -144,7 +145,7 @@ public class SkillVersion { return createdBy; } - public LocalDateTime getCreatedAt() { + public Instant getCreatedAt() { return createdAt; } @@ -173,7 +174,7 @@ public class SkillVersion { this.totalSize = totalSize; } - public void setPublishedAt(LocalDateTime publishedAt) { + public void setPublishedAt(Instant publishedAt) { this.publishedAt = publishedAt; } @@ -185,7 +186,7 @@ public class SkillVersion { this.downloadReady = downloadReady; } - public void setYankedAt(LocalDateTime yankedAt) { + public void setYankedAt(Instant yankedAt) { this.yankedAt = yankedAt; } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java index c4f98ab2..586f54bd 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/SkillVersionStats.java @@ -6,7 +6,8 @@ import jakarta.persistence.Id; import jakarta.persistence.PrePersist; import jakarta.persistence.PreUpdate; import jakarta.persistence.Table; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_version_stats") @@ -23,7 +24,7 @@ public class SkillVersionStats { private Long downloadCount = 0L; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected SkillVersionStats() { } @@ -35,12 +36,12 @@ public class SkillVersionStats { @PrePersist protected void onCreate() { - updatedAt = LocalDateTime.now(); + updatedAt = Instant.now(Clock.systemUTC()); } @PreUpdate protected void onUpdate() { - updatedAt = LocalDateTime.now(); + updatedAt = Instant.now(Clock.systemUTC()); } public Long getSkillVersionId() { @@ -55,7 +56,7 @@ public class SkillVersionStats { return downloadCount; } - public LocalDateTime getUpdatedAt() { + public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java index edce6d81..a96ccc67 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceService.java @@ -15,7 +15,8 @@ import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.storage.ObjectStorageService; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; import java.util.List; import java.util.Map; import org.springframework.context.ApplicationEventPublisher; @@ -31,19 +32,22 @@ public class SkillGovernanceService { private final ObjectStorageService objectStorageService; private final AuditLogService auditLogService; private final ApplicationEventPublisher eventPublisher; + private final Clock clock; public SkillGovernanceService(SkillRepository skillRepository, SkillVersionRepository skillVersionRepository, SkillFileRepository skillFileRepository, ObjectStorageService objectStorageService, AuditLogService auditLogService, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + Clock clock) { this.skillRepository = skillRepository; this.skillVersionRepository = skillVersionRepository; this.skillFileRepository = skillFileRepository; this.objectStorageService = objectStorageService; this.auditLogService = auditLogService; this.eventPublisher = eventPublisher; + this.clock = clock; } @Transactional @@ -51,7 +55,7 @@ public class SkillGovernanceService { Skill skill = skillRepository.findById(skillId) .orElseThrow(() -> new DomainNotFoundException("error.skill.notFound", skillId)); skill.setHidden(true); - skill.setHiddenAt(LocalDateTime.now()); + skill.setHiddenAt(currentInstant()); skill.setHiddenBy(actorUserId); skill.setUpdatedBy(actorUserId); Skill saved = skillRepository.save(skill); @@ -187,7 +191,7 @@ public class SkillGovernanceService { throw new DomainBadRequestException("error.skill.version.notPublished", version.getVersion()); } version.setStatus(SkillVersionStatus.YANKED); - version.setYankedAt(LocalDateTime.now()); + version.setYankedAt(currentInstant()); version.setYankedBy(actorUserId); version.setYankReason(reason); version.setDownloadReady(false); @@ -231,4 +235,8 @@ public class SkillGovernanceService { } return "{\"reason\":\"" + reason.replace("\"", "\\\"") + "\"}"; } + + private Instant currentInstant() { + return Instant.now(clock); + } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java index 7cfed085..de5238b3 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillPublishService.java @@ -31,7 +31,10 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.InputStream; import java.security.MessageDigest; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Comparator; import java.util.HexFormat; @@ -45,6 +48,9 @@ import java.util.zip.ZipOutputStream; @Service public class SkillPublishService { + private static final DateTimeFormatter AUTO_VERSION_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss").withZone(ZoneOffset.UTC); + public record PublishResult( Long skillId, String slug, @@ -63,6 +69,7 @@ public class SkillPublishService { private final ObjectMapper objectMapper; private final ReviewTaskRepository reviewTaskRepository; private final ApplicationEventPublisher eventPublisher; + private final Clock clock; public SkillPublishService( NamespaceRepository namespaceRepository, @@ -76,7 +83,8 @@ public class SkillPublishService { PrePublishValidator prePublishValidator, ObjectMapper objectMapper, ReviewTaskRepository reviewTaskRepository, - ApplicationEventPublisher eventPublisher) { + ApplicationEventPublisher eventPublisher, + Clock clock) { this.namespaceRepository = namespaceRepository; this.namespaceMemberRepository = namespaceMemberRepository; this.skillRepository = skillRepository; @@ -89,6 +97,7 @@ public class SkillPublishService { this.objectMapper = objectMapper; this.reviewTaskRepository = reviewTaskRepository; this.eventPublisher = eventPublisher; + this.clock = clock; } @Transactional @@ -173,8 +182,7 @@ public class SkillPublishService { String skillMdContent = new String(skillMd.content()); SkillMetadata metadata = skillMetadataParser.parse(skillMdContent); if (metadata.version() == null || metadata.version().isBlank()) { - String autoVersion = java.time.LocalDateTime.now() - .format(java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd.HHmmss")); + String autoVersion = AUTO_VERSION_FORMATTER.format(currentTime()); metadata = new SkillMetadata(metadata.name(), metadata.description(), autoVersion, metadata.body(), metadata.frontmatter()); } String skillSlug = SlugValidator.slugify(metadata.name()); @@ -241,7 +249,7 @@ public class SkillPublishService { boolean autoPublish = forceAutoPublish || isSuperAdmin; if (autoPublish) { version.setStatus(SkillVersionStatus.PUBLISHED); - version.setPublishedAt(LocalDateTime.now()); + version.setPublishedAt(currentTime()); } else { version.setStatus(SkillVersionStatus.PENDING_REVIEW); } @@ -388,6 +396,10 @@ public class SkillPublishService { } } + private Instant currentTime() { + return Instant.now(clock); + } + private List rebuildEntriesForRerelease(Long skillId, Long versionId, String targetVersion) { List files = skillFileRepository.findByVersionId(versionId).stream() .sorted(Comparator.comparing(SkillFile::getFilePath)) diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java index 2d0496a8..74724362 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/skill/service/SkillQueryService.java @@ -79,8 +79,8 @@ public class SkillQueryService { Integer ratingCount, boolean hidden, Long namespaceId, - java.time.LocalDateTime createdAt, - java.time.LocalDateTime updatedAt, + java.time.Instant createdAt, + java.time.Instant updatedAt, boolean canManageLifecycle, boolean canSubmitPromotion, boolean canInteract, @@ -98,7 +98,7 @@ public class SkillQueryService { String changelog, Integer fileCount, Long totalSize, - java.time.LocalDateTime publishedAt, + java.time.Instant publishedAt, String parsedMetadataJson, String manifestJson ) {} diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java index 8d496c63..b38850ca 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillRating.java @@ -2,7 +2,8 @@ package com.iflytek.skillhub.domain.social; import com.iflytek.skillhub.domain.shared.exception.DomainBadRequestException; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_rating", @@ -21,10 +22,10 @@ public class SkillRating { private Short score; @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt = LocalDateTime.now(); + private Instant updatedAt; protected SkillRating() {} @@ -38,7 +39,18 @@ public class SkillRating { public void updateScore(short newScore) { if (newScore < 1 || newScore > 5) throw new DomainBadRequestException("error.rating.score.invalid"); this.score = newScore; - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); + } + + @PrePersist + void prePersist() { + this.createdAt = Instant.now(Clock.systemUTC()); + this.updatedAt = this.createdAt; + } + + @PreUpdate + void preUpdate() { + this.updatedAt = Instant.now(Clock.systemUTC()); } // getters @@ -46,6 +58,6 @@ public class SkillRating { public Long getSkillId() { return skillId; } public String getUserId() { return userId; } public Short getScore() { return score; } - public LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStar.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStar.java index b87571c4..3dfcf09d 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStar.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/social/SkillStar.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.social; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "skill_star", @@ -17,7 +18,7 @@ public class SkillStar { private String userId; @Column(name = "created_at", nullable = false) - private LocalDateTime createdAt = LocalDateTime.now(); + private Instant createdAt; protected SkillStar() {} @@ -26,9 +27,14 @@ public class SkillStar { this.userId = userId; } + @PrePersist + void prePersist() { + this.createdAt = Instant.now(Clock.systemUTC()); + } + // getters public Long getId() { return id; } public Long getSkillId() { return skillId; } public String getUserId() { return userId; } - public LocalDateTime getCreatedAt() { return createdAt; } + public Instant getCreatedAt() { return createdAt; } } diff --git a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java index 2c505a05..0ef2c569 100644 --- a/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java +++ b/server/skillhub-domain/src/main/java/com/iflytek/skillhub/domain/user/UserAccount.java @@ -1,7 +1,8 @@ package com.iflytek.skillhub.domain.user; import jakarta.persistence.*; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; @Entity @Table(name = "user_account") @@ -27,10 +28,10 @@ public class UserAccount { private String mergedToUserId; @Column(name = "created_at", nullable = false, updatable = false) - private LocalDateTime createdAt; + private Instant createdAt; @Column(name = "updated_at", nullable = false) - private LocalDateTime updatedAt; + private Instant updatedAt; protected UserAccount() {} @@ -44,13 +45,13 @@ public class UserAccount { @PrePersist void prePersist() { - this.createdAt = LocalDateTime.now(); + this.createdAt = Instant.now(Clock.systemUTC()); this.updatedAt = this.createdAt; } @PreUpdate void preUpdate() { - this.updatedAt = LocalDateTime.now(); + this.updatedAt = Instant.now(Clock.systemUTC()); } public String getId() { return id; } @@ -64,7 +65,7 @@ public class UserAccount { public void setStatus(UserStatus status) { this.status = status; } public String getMergedToUserId() { return mergedToUserId; } public void setMergedToUserId(String mergedToUserId) { this.mergedToUserId = mergedToUserId; } - public LocalDateTime getCreatedAt() { return createdAt; } - public LocalDateTime getUpdatedAt() { return updatedAt; } + public Instant getCreatedAt() { return createdAt; } + public Instant getUpdatedAt() { return updatedAt; } public boolean isActive() { return this.status == UserStatus.ACTIVE; } } diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogServiceTest.java new file mode 100644 index 00000000..437cfa91 --- /dev/null +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/audit/AuditLogServiceTest.java @@ -0,0 +1,52 @@ +package com.iflytek.skillhub.domain.audit; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuditLogServiceTest { + + @Mock + private AuditLogRepository auditLogRepository; + + private AuditLogService auditLogService; + private Clock clock; + + @BeforeEach + void setUp() { + clock = Clock.fixed(Instant.parse("2026-03-18T02:03:04Z"), ZoneOffset.UTC); + auditLogService = new AuditLogService(auditLogRepository, clock); + } + + @Test + void record_usesInjectedClockForCreatedAt() { + when(auditLogRepository.save(any(AuditLog.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + AuditLog result = auditLogService.record( + "user-1", + "SKILL_PUBLISH", + "SKILL", + 7L, + "req-1", + "127.0.0.1", + "JUnit", + "{\"version\":\"1.0.0\"}" + ); + + assertThat(result.getCreatedAt()).isEqualTo(Instant.now(clock)); + assertThat(result.getAction()).isEqualTo("SKILL_PUBLISH"); + assertThat(result.getTargetId()).isEqualTo(7L); + } +} diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java index 6ecfedbc..7e45589e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/governance/GovernanceNotificationServiceTest.java @@ -6,6 +6,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.when; import com.iflytek.skillhub.domain.shared.exception.DomainForbiddenException; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -21,10 +24,12 @@ class GovernanceNotificationServiceTest { private UserNotificationRepository userNotificationRepository; private GovernanceNotificationService service; + private Clock clock; @BeforeEach void setUp() { - service = new GovernanceNotificationService(userNotificationRepository); + clock = Clock.fixed(Instant.parse("2026-03-18T01:02:03Z"), ZoneOffset.UTC); + service = new GovernanceNotificationService(userNotificationRepository, clock); } @Test @@ -44,6 +49,7 @@ class GovernanceNotificationServiceTest { assertThat(notification.getUserId()).isEqualTo("user-1"); assertThat(notification.getStatus()).isEqualTo(UserNotificationStatus.UNREAD); assertThat(notification.getCategory()).isEqualTo("REVIEW"); + assertThat(notification.getCreatedAt()).isEqualTo(Instant.now(clock)); } @Test @@ -54,7 +60,8 @@ class GovernanceNotificationServiceTest { "REVIEW_TASK", 99L, "Review completed", - "{\"status\":\"APPROVED\"}" + "{\"status\":\"APPROVED\"}", + Instant.parse("2026-03-18T00:00:00Z") ); setField(notification, "id", 10L); when(userNotificationRepository.findById(10L)).thenReturn(Optional.of(notification)); @@ -64,8 +71,8 @@ class GovernanceNotificationServiceTest { @Test void listNotifications_returnsNewestFirst() { - UserNotification unread = new UserNotification("user-1", "REVIEW", "REVIEW_TASK", 99L, "A", "{}"); - UserNotification read = new UserNotification("user-1", "REPORT", "SKILL_REPORT", 88L, "B", "{}"); + UserNotification unread = new UserNotification("user-1", "REVIEW", "REVIEW_TASK", 99L, "A", "{}", Instant.parse("2026-03-18T00:00:00Z")); + UserNotification read = new UserNotification("user-1", "REPORT", "SKILL_REPORT", 88L, "B", "{}", Instant.parse("2026-03-18T00:01:00Z")); when(userNotificationRepository.findByUserIdOrderByCreatedAtDesc("user-1")).thenReturn(List.of(unread, read)); List result = service.listNotifications("user-1"); @@ -73,6 +80,28 @@ class GovernanceNotificationServiceTest { assertThat(result).hasSize(2); } + @Test + void markRead_setsReadTimestampFromClock() { + UserNotification notification = new UserNotification( + "user-1", + "REVIEW", + "REVIEW_TASK", + 99L, + "Review completed", + "{\"status\":\"APPROVED\"}", + Instant.parse("2026-03-18T00:00:00Z") + ); + setField(notification, "id", 10L); + when(userNotificationRepository.findById(10L)).thenReturn(Optional.of(notification)); + when(userNotificationRepository.save(any(UserNotification.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + UserNotification result = service.markRead(10L, "user-1"); + + assertThat(result.getStatus()).isEqualTo(UserNotificationStatus.READ); + assertThat(result.getReadAt()).isEqualTo(Instant.now(clock)); + } + private void setField(Object target, String fieldName, Object value) { try { java.lang.reflect.Field field = target.getClass().getDeclaredField(fieldName); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java index 9fe73265..86df099e 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/report/SkillReportServiceTest.java @@ -14,6 +14,9 @@ import com.iflytek.skillhub.domain.skill.Skill; import com.iflytek.skillhub.domain.skill.SkillRepository; import com.iflytek.skillhub.domain.skill.SkillVisibility; import com.iflytek.skillhub.domain.skill.service.SkillGovernanceService; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -24,6 +27,8 @@ import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class SkillReportServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T08:00:00Z"), ZoneOffset.UTC); + @Mock private SkillRepository skillRepository; @@ -48,7 +53,8 @@ class SkillReportServiceTest { skillReportRepository, auditLogService, skillGovernanceService, - governanceNotificationService + governanceNotificationService, + CLOCK ); } @@ -103,6 +109,7 @@ class SkillReportServiceTest { assertThat(saved.getStatus()).isEqualTo(SkillReportStatus.RESOLVED); assertThat(saved.getHandledBy()).isEqualTo("admin"); + assertThat(saved.getHandledAt()).isEqualTo(Instant.now(CLOCK)); } @Test diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java index c5fd9e83..31695868 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/PromotionServiceTest.java @@ -19,6 +19,9 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.*; import static org.junit.jupiter.api.Assertions.*; @@ -29,6 +32,8 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class PromotionServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T11:00:00Z"), ZoneOffset.UTC); + @Mock private PromotionRequestRepository promotionRequestRepository; @Mock private SkillRepository skillRepository; @Mock private SkillVersionRepository skillVersionRepository; @@ -53,7 +58,7 @@ class PromotionServiceTest { void setUp() { promotionService = new PromotionService( promotionRequestRepository, skillRepository, skillVersionRepository, - skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService); + skillFileRepository, namespaceRepository, permissionChecker, eventPublisher, governanceNotificationService, CLOCK); } private static void setField(Object target, String fieldName, Object value) { @@ -444,7 +449,7 @@ class PromotionServiceTest { assertEquals("{\"version\":\"1.0.0\"}", newVersion.getManifestJson()); assertEquals(3, newVersion.getFileCount()); assertEquals(1024L, newVersion.getTotalSize()); - assertNotNull(newVersion.getPublishedAt()); + assertEquals(Instant.now(CLOCK), newVersion.getPublishedAt()); // Verify files copied @SuppressWarnings("unchecked") diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java index 576852fb..85babd55 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/review/ReviewServiceTest.java @@ -28,11 +28,14 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; +import java.time.Clock; +import java.time.Instant; import java.util.ConcurrentModificationException; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.time.ZoneOffset; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; @@ -41,6 +44,8 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class ReviewServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T10:00:00Z"), ZoneOffset.UTC); + @Mock private ReviewTaskRepository reviewTaskRepository; @Mock private SkillVersionRepository skillVersionRepository; @Mock private SkillRepository skillRepository; @@ -65,7 +70,7 @@ class ReviewServiceTest { objectMapper = new ObjectMapper(); reviewService = new ReviewService( reviewTaskRepository, skillVersionRepository, skillRepository, - namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService); + namespaceRepository, permissionChecker, eventPublisher, objectMapper, skillGovernanceService, governanceNotificationService, CLOCK); } private SkillVersion createDraftSkillVersion() { @@ -243,7 +248,7 @@ class ReviewServiceTest { assertNotNull(result); assertEquals(SkillVersionStatus.PUBLISHED, sv.getStatus()); - assertNotNull(sv.getPublishedAt()); + assertEquals(Instant.now(CLOCK), sv.getPublishedAt()); assertEquals(SKILL_VERSION_ID, skill.getLatestVersionId()); assertEquals("Approved Name", skill.getDisplayName()); assertEquals("Approved Summary", skill.getSummary()); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java index ea88df40..572138b6 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillGovernanceServiceTest.java @@ -22,8 +22,11 @@ import com.iflytek.skillhub.domain.skill.SkillVersion; import com.iflytek.skillhub.domain.skill.SkillVersionRepository; import com.iflytek.skillhub.domain.skill.SkillVersionStatus; import com.iflytek.skillhub.storage.ObjectStorageService; +import java.time.Clock; +import java.time.Instant; import java.util.Optional; import java.util.Map; +import java.time.ZoneOffset; import org.springframework.context.ApplicationEventPublisher; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -34,6 +37,8 @@ import org.mockito.junit.jupiter.MockitoExtension; @ExtendWith(MockitoExtension.class) class SkillGovernanceServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T09:00:00Z"), ZoneOffset.UTC); + @Mock private SkillRepository skillRepository; @Mock @@ -57,7 +62,8 @@ class SkillGovernanceServiceTest { skillFileRepository, objectStorageService, auditLogService, - eventPublisher + eventPublisher, + CLOCK ); } @@ -71,6 +77,7 @@ class SkillGovernanceServiceTest { assertThat(result.isHidden()).isTrue(); assertThat(result.getHiddenBy()).isEqualTo("admin"); + assertThat(result.getHiddenAt()).isEqualTo(Instant.now(CLOCK)); verify(auditLogService).record("admin", "HIDE_SKILL", "SKILL", 10L, null, "127.0.0.1", "JUnit", "{\"reason\":\"policy\"}"); } @@ -125,6 +132,7 @@ class SkillGovernanceServiceTest { assertThat(result.getStatus()).isEqualTo(SkillVersionStatus.YANKED); assertThat(result.getYankedBy()).isEqualTo("admin"); + assertThat(result.getYankedAt()).isEqualTo(Instant.now(CLOCK)); verify(auditLogService).record("admin", "YANK_SKILL_VERSION", "SKILL_VERSION", 22L, null, "127.0.0.1", "JUnit", "{\"reason\":\"broken\"}"); } @@ -151,12 +159,12 @@ class SkillGovernanceServiceTest { SkillVersion yanked = new SkillVersion(2L, "2.0.0", "owner"); setField(yanked, "id", 22L); yanked.setStatus(SkillVersionStatus.PUBLISHED); - yanked.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 18, 10, 0)); + yanked.setPublishedAt(Instant.parse("2026-03-18T10:00:00Z")); SkillVersion fallback = new SkillVersion(2L, "1.0.0", "owner"); setField(fallback, "id", 11L); fallback.setStatus(SkillVersionStatus.PUBLISHED); - fallback.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 17, 10, 0)); + fallback.setPublishedAt(Instant.parse("2026-03-17T10:00:00Z")); Skill skill = new Skill(1L, "demo", "owner", com.iflytek.skillhub.domain.skill.SkillVisibility.PUBLIC); setField(skill, "id", 2L); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java index 16100617..97043f98 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillPublishServiceTest.java @@ -28,7 +28,9 @@ import org.mockito.junit.jupiter.MockitoExtension; import java.lang.reflect.Field; import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneOffset; import java.util.List; import java.util.Map; import java.util.Optional; @@ -41,6 +43,8 @@ import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class SkillPublishServiceTest { + private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-03-18T12:00:00Z"), ZoneOffset.UTC); + @Mock private NamespaceRepository namespaceRepository; @Mock @@ -82,7 +86,8 @@ class SkillPublishServiceTest { prePublishValidator, objectMapper, reviewTaskRepository, - eventPublisher + eventPublisher, + CLOCK ); } @@ -284,7 +289,7 @@ class SkillPublishServiceTest { ); assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus()); - assertNotNull(result.version().getPublishedAt()); + assertEquals(Instant.now(CLOCK), result.version().getPublishedAt()); verify(reviewTaskRepository, never()).save(any(ReviewTask.class)); verify(skillRepository).save(argThat(savedSkill -> savedSkill.getLatestVersionId() != null && savedSkill.getLatestVersionId().equals(10L))); @@ -357,8 +362,7 @@ class SkillPublishServiceTest { SkillPublishService.PublishResult result = service.publishFromEntries( namespaceSlug, entries, publisherId, SkillVisibility.PUBLIC, Set.of()); - assertNotNull(result.version().getVersion()); - assertFalse(result.version().getVersion().isBlank()); + assertEquals("20260318.120000", result.version().getVersion()); } @Test @@ -513,7 +517,7 @@ class SkillPublishServiceTest { SkillVersion sourceVersion = new SkillVersion(skill.getId(), "1.2.3", publisherId); setId(sourceVersion, 21L); sourceVersion.setStatus(SkillVersionStatus.PUBLISHED); - sourceVersion.setPublishedAt(LocalDateTime.of(2026, 3, 15, 10, 0)); + sourceVersion.setPublishedAt(Instant.parse("2026-03-15T10:00:00Z")); String sourceSkillMd = """ --- @@ -565,6 +569,7 @@ class SkillPublishServiceTest { assertEquals("1.2.4", result.version().getVersion()); assertEquals(SkillVersionStatus.PUBLISHED, result.version().getStatus()); + assertEquals(Instant.now(CLOCK), result.version().getPublishedAt()); assertEquals(30L, skill.getLatestVersionId()); verify(reviewTaskRepository, never()).save(any()); verify(eventPublisher).publishEvent(any(SkillPublishedEvent.class)); diff --git a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java index abfdfa4d..65963a8b 100644 --- a/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java +++ b/server/skillhub-domain/src/test/java/com/iflytek/skillhub/domain/skill/service/SkillQueryServiceTest.java @@ -529,7 +529,7 @@ class SkillQueryServiceTest { SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); setId(published, 10L); published.setStatus(SkillVersionStatus.PUBLISHED); - published.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 1, 10, 0)); + published.setPublishedAt(java.time.Instant.parse("2026-03-01T10:00:00Z")); SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); setId(pending, 11L); @@ -796,7 +796,7 @@ class SkillQueryServiceTest { SkillVersion published = new SkillVersion(1L, "1.0.0", ownerId); setId(published, 11L); published.setStatus(SkillVersionStatus.PUBLISHED); - published.setPublishedAt(java.time.LocalDateTime.of(2026, 3, 1, 10, 0)); + published.setPublishedAt(java.time.Instant.parse("2026-03-01T10:00:00Z")); SkillVersion pending = new SkillVersion(1L, "1.1.0", ownerId); setId(pending, 12L);