refactor: unify backend time handling in utc

This commit is contained in:
vsxd 2026-03-18 16:49:39 +08:00 committed by Xudong Sun
parent 6b2d8ce7ef
commit 0814b8939c
86 changed files with 1452 additions and 303 deletions

View 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 4API 契约收口
执行内容:
- 普通 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 解释假设

View 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 4DTO 与 API 契约收口
已完成:
- `NamespaceResponse`
- `MemberResponse`
- `SkillSummaryResponse`
- `SkillVersionResponse`
- `SkillVersionDetailResponse`
- `TagResponse`
- `AdminUserSummaryResponse`
- `AdminSkillReportSummaryResponse`
- `TokenController` 的 UTC 输出收口
## 6. 当前剩余尾项
- `ApiTokenService` 仍保留对裸 `LocalDateTime` 字符串的兼容解析
- 需要补静态扫描或 ArchUnit 约束,防止新增 `LocalDateTime.now()`
- 需要做一轮跨时区回归,把 `UTC` / `Asia/Shanghai` 纳入关键测试

View file

@ -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(),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,2 @@
ALTER TABLE skill
ALTER COLUMN hidden_at TYPE TIMESTAMPTZ USING hidden_at AT TIME ZONE 'UTC';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"),

View file

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

View file

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

View file

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

View file

@ -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"),

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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