feat(后台管理): 实体管理列表统一展示创建时间/更新时间

用户管理、菜单管理、不符合ASIN、数据去重总数据、查询ASIN、最低价ASIN、
商品类目、密钥管理共 8 个实体管理列表补齐两列。分组管理/店铺密钥/店铺管理
此前已带创建+修改时间,任务列表与统计报表(撞款监控、密钥用量、日志、
记录与版本)不含实体更新语义,均未改动。

关键点——时间列必须由数据库维护,否则新列是假的:
这些表的更新走 selectById → 改字段 → updateById,实体带着读出的旧
updated_at 一起写回。MySQL 规则是「UPDATE 显式给某列赋值时不触发该列的
ON UPDATE 自动更新」,不禁写就会把旧值写回去,更新时间永远冻结在首次写入
时刻。按 V125(biz_file_result)既有样板,给 7 个实体标注
@TableField(insertStrategy=NEVER, updateStrategy=NEVER)。

- V131:users / columns 补 updated_at(幂等 ADD COLUMN,仿 V125 写法)。
  存量行被回填为迁移执行时刻,非真实历史变更时间(历史上无记录,无法还原)
- 实体/VO:AdminUserEntity、PermissionMenuEntity、InvalidAsinDataEntity、
  DedupeTotalDataEntity、ProductCategoryEntity、QueryAsinEntity、
  SkipPriceAsinEntity 加/改写 updatedAt;AdminUserItemVo、PermissionMenuItemVo、
  InvalidAsinDataItemVo、DedupeTotalDataItemVo 补 updatedAt;
  AdminUserSecretRowVo 补 createdAt(行级首次配置时间 = 三模块最早)
- 查询ASIN/最低价ASIN 后端 VO 与前端 model 本就有两字段,仅补渲染
- 前端 8 页表格加列,同步修正空态/加载行的 colspan(手写表格,不同步会错位)
- 测试:align-query-asin / align-skip-price 原断言「不允许有更新时间列」
  (像素复刻旧版),按新需求改为断言两列存在;新增 e2e list-time-columns
  覆盖 8 页表头与真实时间值渲染
This commit is contained in:
2026-09-19 15:53:01 +08:00
parent 5fcb449946
commit 5b8105ec2b
36 changed files with 418 additions and 25 deletions
@@ -1,5 +1,6 @@
package com.nanri.aiimage.common.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -24,5 +25,13 @@ public class AdminUserEntity {
private Long createdById;
@TableField("created_at")
private LocalDateTime createdAt;
/**
* 更新时间(V131 新增):由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
*
* <p>禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新,
* 而本表写回多为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
private String machine;
}
@@ -16,6 +16,8 @@ public class AdminUserItemVo {
private String creatorUsername;
@JsonProperty("created_at")
private String createdAt;
@JsonProperty("updated_at")
private String updatedAt;
@JsonProperty("pinyin_abbr")
private String pinyinAbbr;
}
@@ -338,6 +338,8 @@ public class AdminUserService {
vo.setCreatorUsername(creatorId == null ? "" : creatorMap.getOrDefault(creatorId, ""));
LocalDateTime createdAt = entity.getCreatedAt();
vo.setCreatedAt(createdAt == null ? "" : createdAt.format(CREATED_AT_FORMATTER));
LocalDateTime updatedAt = entity.getUpdatedAt();
vo.setUpdatedAt(updatedAt == null ? "" : updatedAt.format(CREATED_AT_FORMATTER));
vo.setPinyinAbbr(PinyinAbbrUtil.abbr(entity.getUsername()));
return vo;
}
@@ -1,6 +1,8 @@
package com.nanri.aiimage.modules.dedupe.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -19,5 +21,12 @@ public class DedupeTotalDataEntity {
private Long uploaderUserId;
private String uploaderUsername;
private LocalDateTime createdAt;
/**
* 更新时间:由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
*
* <p>禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新,
* 而本表写回为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -32,4 +32,7 @@ public class DedupeTotalDataItemVo {
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}
@@ -1398,6 +1398,7 @@ public class DedupeTotalDataService {
vo.setUploaderUserId(entity.getUploaderUserId());
vo.setUsername(entity.getUploaderUsername());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
@@ -1,6 +1,8 @@
package com.nanri.aiimage.modules.invalidasin.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
@@ -18,5 +20,12 @@ public class InvalidAsinDataEntity {
private Long groupId;
private String recordSource;
private LocalDateTime createdAt;
/**
* 更新时间:由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
*
* <p>禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新,
* 而本表写回为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -29,4 +29,7 @@ public class InvalidAsinDataItemVo {
@Schema(description = "创建时间")
private LocalDateTime createdAt;
@Schema(description = "更新时间")
private LocalDateTime updatedAt;
}
@@ -245,6 +245,7 @@ public class InvalidAsinDataService {
vo.setGroupName(groupName == null ? "" : groupName);
vo.setRecordSource(isManualRecord(entity) ? RECORD_SOURCE_MANUAL : RECORD_SOURCE_AUTO);
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
}
@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.permission.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -22,4 +23,12 @@ public class PermissionMenuEntity {
private String routePath;
private Integer sortOrder;
private LocalDateTime createdAt;
/**
* 更新时间(V131 新增):由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
*
* <p>禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新,
* 而本表写回多为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -24,6 +24,8 @@ public class PermissionMenuItemVo {
private Integer sortOrder;
@JsonProperty("created_at")
private LocalDateTime createdAt;
@JsonProperty("updated_at")
private LocalDateTime updatedAt;
/**
* 当前操作者能否把该菜单授予他人。仅授权树接口(list)填充;菜单 CRUD 回显等
@@ -1160,6 +1160,7 @@ public class PermissionMenuService {
vo.setRoutePath(entity.getRoutePath());
vo.setSortOrder(entity.getSortOrder());
vo.setCreatedAt(entity.getCreatedAt());
vo.setUpdatedAt(entity.getUpdatedAt());
return vo;
}
@@ -1,5 +1,6 @@
package com.nanri.aiimage.modules.shopkey.model.entity;
import com.baomidou.mybatisplus.annotation.FieldStrategy;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -39,6 +40,12 @@ public class QueryAsinEntity {
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
/**
* 更新时间:由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
*
* <p>禁止应用显式写:本表更新走 selectById → 改字段 → updateById(实体带着旧值),
* 显式写回旧值会触发 MySQL 的「显式赋值不自动更新」规则,把更新时间冻结在首次写入时刻。
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -56,6 +56,12 @@ public class SkipPriceAsinEntity {
@TableField("created_at")
private LocalDateTime createdAt;
@TableField("updated_at")
/**
* 更新时间:由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。
*
* <p>禁止应用显式写:本表更新走 selectById → 改字段 → updateById(实体带着旧值),
* 显式写回旧值会触发 MySQL 的「显式赋值不自动更新」规则,把更新时间冻结在首次写入时刻。
*/
@TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER)
private LocalDateTime updatedAt;
}
@@ -37,6 +37,9 @@ public class AdminUserSecretRowVo {
@Schema(description = "行级状态说明")
private String statusMessage;
@Schema(description = "首次配置时间(三模块中最早)")
private LocalDateTime createdAt;
@Schema(description = "最近更新时间(三模块中最晚)")
private LocalDateTime updatedAt;
}
@@ -41,6 +41,7 @@ import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
@@ -607,6 +608,7 @@ public class UserApiSecretService implements UserSecretCleanupPort {
vo.setStatus(summarizeRowStatus(modules));
vo.setStatusMessage(summarizeRowMessage(modules, vo.getStatus()));
vo.setUpdatedAt(latestUpdatedAt(modules));
vo.setCreatedAt(earliestCreatedAt(moduleRows.values()));
AdminUserEntity user = adminUserMapper.selectById(userId);
vo.setUsername(user == null ? "" : user.getUsername());
vo.setGroups(List.of());
@@ -673,6 +675,18 @@ public class UserApiSecretService implements UserSecretCleanupPort {
return latest;
}
/** 行级创建时间:该用户已配置模块中最早的一条 created_at;一条都没有则为 null。 */
private LocalDateTime earliestCreatedAt(Collection<UserApiSecretEntity> rows) {
LocalDateTime earliest = null;
for (UserApiSecretEntity row : rows) {
LocalDateTime value = row.getCreatedAt();
if (value != null && (earliest == null || value.isBefore(earliest))) {
earliest = value;
}
}
return earliest;
}
private void upsert(Long userId, String moduleKey, String plainValue, String source) {
LocalDateTime now = LocalDateTime.now();
UserApiSecretEntity existing = selectOne(userId, moduleKey);
@@ -0,0 +1,45 @@
-- V131: users(用户)、columns(后台菜单) 两表增加 updated_at 列
--
-- 背景:后台「用户管理」「菜单管理」列表要展示「创建时间 + 更新时间」,但这两张历史表
-- 建表时只落了一个 created_at,更新时间无从取值。补一列由数据库维护的 updated_at
-- DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP),应用侧不写它。
--
-- 与 V125 同样的取舍:列交给数据库维护,Java 实体上标注
-- insertStrategy=NEVER / updateStrategy=NEVER,保证 MyBatis-Plus 永不显式写。
-- 这一步是必要的——本项目的写回模式多为 selectById → 改字段 → updateById(实体带着旧值),
-- 而 MySQL 的规则是「UPDATE 语句显式给某列赋值时不触发该列的自动更新」,
-- 不禁写就会把读出来的旧值写回去,更新时间会一直停在第一次写入的值。
--
-- 存量行:ADD COLUMN 的 DEFAULT CURRENT_TIMESTAMP 会把已有行填成迁移执行时刻,
-- 不是真实的历史变更时间(历史上也没有记录,无法还原),属已知取舍。
--
-- 风险:两表均小(users 百余行、columns 数十行),ADD COLUMN 秒级完成。
-- 回滚:ALTER TABLE `users` DROP COLUMN updated_at; ALTER TABLE `columns` DROP COLUMN updated_at;
SET @db_name = DATABASE();
-- users(用户表)
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'users' AND COLUMN_NAME = 'updated_at'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `users` ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'' AFTER `created_at`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
-- columns(后台菜单表)
SET @col_exists := (
SELECT COUNT(*) FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA = @db_name AND TABLE_NAME = 'columns' AND COLUMN_NAME = 'updated_at'
);
SET @sql := IF(@col_exists = 0,
'ALTER TABLE `columns` ADD COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT ''更新时间'' AFTER `created_at`',
'SELECT 1'
);
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;