mirror of
https://github.com/iflytek/skillhub.git
synced 2026-08-27 11:14:59 +00:00
refactor: unify backend time handling in utc
This commit is contained in:
parent
6b2d8ce7ef
commit
0814b8939c
86 changed files with 1452 additions and 303 deletions
241
docs/15-backend-time-governance-plan.md
Normal file
241
docs/15-backend-time-governance-plan.md
Normal file
|
|
@ -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 解释假设
|
||||
238
docs/16-backend-time-inventory.md
Normal file
238
docs/16-backend-time-inventory.md
Normal file
|
|
@ -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` 纳入关键测试
|
||||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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<String> platformRoles,
|
||||
LocalDateTime createdAt
|
||||
Instant createdAt
|
||||
) {
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 <T> ApiResponse<T> 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<Void> 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<Void> 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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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<Void> 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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
ALTER TABLE skill
|
||||
ALTER COLUMN hidden_at TYPE TIMESTAMPTZ USING hidden_at AT TIME ZONE 'UTC';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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.<Long, NamespaceRole>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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> 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<String> 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<Path> productionJavaFiles() throws IOException {
|
||||
List<Path> 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<ApiToken> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<PackageEntry> rebuildEntriesForRerelease(Long skillId, Long versionId, String targetVersion) {
|
||||
List<SkillFile> files = skillFileRepository.findByVersionId(versionId).stream()
|
||||
.sorted(Comparator.comparing(SkillFile::getFilePath))
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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<UserNotification> 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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue