From 5b8105ec2b66445cbc353f53ed8a3dcd96f21be9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E8=87=AA=E8=BE=BE?= <980324341@qq.com> Date: Sat, 19 Sep 2026 15:53:01 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E5=8F=B0=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E5=AE=9E=E4=BD=93=E7=AE=A1=E7=90=86=E5=88=97=E8=A1=A8=E7=BB=9F?= =?UTF-8?q?=E4=B8=80=E5=B1=95=E7=A4=BA=E5=88=9B=E5=BB=BA=E6=97=B6=E9=97=B4?= =?UTF-8?q?/=E6=9B=B4=E6=96=B0=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户管理、菜单管理、不符合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 页表头与真实时间值渲染 --- .../e2e/list-time-columns.spec.ts | 44 +++++ .../scripts/mock-admin-server.mjs | 174 +++++++++++++++++- admin-frontend-vue/src/api/user-secrets.ts | 2 + admin-frontend-vue/src/api/users-model.ts | 2 + .../src/pages/account/MenusPage.vue | 6 +- .../src/pages/account/UserSecretsPage.vue | 8 +- .../src/pages/account/UsersPage.vue | 6 +- .../src/pages/account/menu-manage-model.ts | 3 + .../src/pages/asin/AsinInvalidPage.vue | 6 +- .../src/pages/asin/DedupeRegistryPage.vue | 6 +- .../src/pages/asin/ProductCategoryPage.vue | 17 +- .../src/pages/asin/QueryAsinPage.vue | 9 +- .../src/pages/asin/SkipPricePage.vue | 9 +- .../src/pages/asin/dedupe-total-model.ts | 3 + .../src/pages/asin/product-category-model.ts | 6 + .../pages/invalidasin/invalid-asin-model.ts | 3 + admin-frontend-vue/src/types/admin.ts | 1 + .../tests/align-query-asin.test.ts | 4 +- .../tests/align-skip-price.test.ts | 4 +- .../common/model/entity/AdminUserEntity.java | 9 + .../admin/model/vo/AdminUserItemVo.java | 2 + .../admin/service/AdminUserService.java | 2 + .../model/entity/DedupeTotalDataEntity.java | 9 + .../model/vo/DedupeTotalDataItemVo.java | 3 + .../service/DedupeTotalDataService.java | 1 + .../model/entity/InvalidAsinDataEntity.java | 9 + .../model/vo/InvalidAsinDataItemVo.java | 3 + .../service/InvalidAsinDataService.java | 1 + .../model/entity/PermissionMenuEntity.java | 9 + .../model/vo/PermissionMenuItemVo.java | 2 + .../service/PermissionMenuService.java | 1 + .../shopkey/model/entity/QueryAsinEntity.java | 9 +- .../model/entity/SkipPriceAsinEntity.java | 8 +- .../model/vo/AdminUserSecretRowVo.java | 3 + .../service/UserApiSecretService.java | 14 ++ .../db/V131__users_and_columns_updated_at.sql | 45 +++++ 36 files changed, 418 insertions(+), 25 deletions(-) create mode 100644 admin-frontend-vue/e2e/list-time-columns.spec.ts create mode 100644 backend-java/src/main/resources/db/V131__users_and_columns_updated_at.sql diff --git a/admin-frontend-vue/e2e/list-time-columns.spec.ts b/admin-frontend-vue/e2e/list-time-columns.spec.ts new file mode 100644 index 00000000..31d5d779 --- /dev/null +++ b/admin-frontend-vue/e2e/list-time-columns.spec.ts @@ -0,0 +1,44 @@ +import { expect, test, type Page } from '@playwright/test' + +// 后台管理列表「创建时间 + 更新时间」两列验收(2026-09-19 需求): +// 8 个实体管理列表统一展示创建/更新时间,数据取自各列表 VO。 +// mock 夹具统一给 createdAt=2026-03-01 09:15:00、updatedAt=2026-09-18 16:42:30, +// 页面统一经 formatDateTime 归一为「YYYY-MM-DD HH:mm:ss」。 + +const CREATED = '2026-03-01 09:15:00' +const UPDATED = '2026-09-18 16:42:30' + +const PAGES: Array<{ title: string; path: string; heading: string }> = [ + { title: '用户管理', path: '/admin-vue/account/users', heading: '用户列表' }, + { title: '菜单管理', path: '/admin-vue/account/menus', heading: '菜单' }, + { title: '密钥管理', path: '/admin-vue/account/user-secrets', heading: '用户密钥列表' }, + { title: '不符合ASIN数据', path: '/admin-vue/asin-center/invalid', heading: '品牌数据列表' }, + { title: '数据去重总数据', path: '/admin-vue/asin-center/registry', heading: '数据去重总数据' }, + { title: '查询ASIN', path: '/admin-vue/asin-center/query', heading: '查询' }, + { title: '最低价ASIN', path: '/admin-vue/asin-center/skip-price', heading: '最低价' }, + { title: '商品类目', path: '/admin-vue/asin-center/categories', heading: '商品类目' }, +] + +async function openPage(page: Page, path: string) { + await page.goto(path) + await expect(page.locator('.panel-box table tbody tr').first()).toBeVisible({ timeout: 15000 }) +} + +for (const spec of PAGES) { + test(`${spec.title}:表格展示创建时间与更新时间`, async ({ page }) => { + const errors: string[] = [] + page.on('pageerror', (error) => errors.push(error.message)) + + await openPage(page, spec.path) + + const table = page.locator('.panel-box table').first() + // 表头出现两列 + await expect(table.locator('thead')).toContainText('创建时间') + await expect(table.locator('thead')).toContainText('更新时间') + // 数据行渲染出真实时间值(非空、非占位符) + await expect(table.locator('tbody')).toContainText(CREATED) + await expect(table.locator('tbody')).toContainText(UPDATED) + + expect(errors).toEqual([]) + }) +} diff --git a/admin-frontend-vue/scripts/mock-admin-server.mjs b/admin-frontend-vue/scripts/mock-admin-server.mjs index 51424e8a..45fb6e57 100644 --- a/admin-frontend-vue/scripts/mock-admin-server.mjs +++ b/admin-frontend-vue/scripts/mock-admin-server.mjs @@ -220,13 +220,183 @@ const server = createServer((req, res) => { return json(res, { success: true, data: { - items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', createdAt: '2026-01-01 00:00:00' }], + items: [{ id: 1, username: 'admin', role: 'super_admin', creatorUsername: 'system', created_at: '2026-03-01 09:15:00', updated_at: '2026-09-18 16:42:30' }], total: 1, }, }) } if (url === '/api/admin/permission-menus') { - return json(res, { success: true, data: { items: [] } }) + // 菜单管理列表:一条根节点,带创建/更新时间(验收时间列)。 + return json(res, { + success: true, + data: [ + { + id: 1, + name: '用户管理', + column_key: 'admin_users', + parent_id: null, + menu_type: 'admin', + route_path: 'account/users', + sort_order: 10, + created_at: '2026-03-01T09:15:00', + updated_at: '2026-09-18T16:42:30', + }, + ], + }) + } + if (url === '/api/admin/invalid-asin-data') { + return json(res, { + success: true, + data: { + items: [ + { + id: 1, + dataValue: 'B0INVALID1', + brand: '测试品牌', + groupId: 1, + groupName: '测试分组', + recordSource: 'MANUAL', + createdAt: '2026-03-01 09:15:00', + updatedAt: '2026-09-18 16:42:30', + }, + ], + total: 1, + page: 1, + pageSize: 10, + }, + }) + } + if (url === '/api/admin/dedupe-total-data') { + return json(res, { + success: true, + data: { + items: [ + { + id: 1, + dataValue: 'B0DEDUPE01', + country: 'DE', + groupId: 1, + groupName: '测试分组', + uploaderUserId: 1, + username: 'admin', + createdAt: '2026-03-01 09:15:00', + updatedAt: '2026-09-18 16:42:30', + }, + ], + total: 1, + page: 1, + pageSize: 10, + }, + }) + } + if (url === '/api/admin/query-asins') { + return json(res, { + success: true, + data: { + items: [ + { + id: 1, + groupId: 1, + groupName: '测试分组', + shopName: '测试店铺', + asinDe: 'B0QUERY001', + asinUk: '', + asinFr: '', + asinIt: '', + asinEs: '', + createdAt: '2026-03-01 09:15:00', + updatedAt: '2026-09-18 16:42:30', + }, + ], + total: 1, + page: 1, + pageSize: 10, + }, + }) + } + if (url === '/api/admin/skip-price-asins') { + return json(res, { + success: true, + data: { + items: [ + { + id: 1, + groupId: 1, + groupName: '测试分组', + shopName: '测试店铺', + asinDe: 'B0SKIP0001', + minimumPriceDe: 9.99, + asinUk: '', + minimumPriceUk: null, + asinFr: '', + minimumPriceFr: null, + asinIt: '', + minimumPriceIt: null, + asinEs: '', + minimumPriceEs: null, + createdAt: '2026-03-01 09:15:00', + updatedAt: '2026-09-18 16:42:30', + }, + ], + total: 1, + page: 1, + pageSize: 10, + }, + }) + } + if (url === '/api/admin/product-categories/children') { + return json(res, { + success: true, + data: { + items: [ + { + id: 1, + parentId: null, + name: '护肤品', + categoryKey: 'skincare', + sortOrder: 10, + description: '测试备注', + isBuiltin: true, + childCount: 0, + level: 0, + path: '护肤品', + createdAt: '2026-03-01 09:15:00', + updatedAt: '2026-09-18 16:42:30', + }, + ], + total: 1, + page: 1, + pageSize: 10, + hasMore: false, + }, + }) + } + if (url === '/api/admin/user-secrets') { + const emptyModule = { moduleKey: '', moduleLabel: '', masked: '', full: '', exists: false, checkStatus: 'unknown', checkCode: '', checkMessage: '', checkLatencyMs: null, checkedAt: null, updatedAt: null } + return json(res, { + success: true, + data: { + items: [ + { + userId: 1, + username: 'admin', + groups: [], + leaderUsername: '', + similarAsin: { ...emptyModule, moduleKey: 'similar-asin', moduleLabel: '货源查询密钥' }, + appearancePatent: { ...emptyModule, moduleKey: 'appearance-patent', moduleLabel: '外观专利密钥' }, + proxy: { ...emptyModule, moduleKey: 'proxy', moduleLabel: '代理设置' }, + status: 'unknown', + statusMessage: '', + createdAt: '2026-03-01 09:15:00', + updatedAt: '2026-09-18 16:42:30', + }, + ], + total: 1, + page: 1, + pageSize: 15, + groupOptions: [], + }, + }) } if (url === '/api/admin/shop-manage-groups') { return json(res, { success: true, data: { items: [] } }) diff --git a/admin-frontend-vue/src/api/user-secrets.ts b/admin-frontend-vue/src/api/user-secrets.ts index f9fa88e7..8a2223e2 100644 --- a/admin-frontend-vue/src/api/user-secrets.ts +++ b/admin-frontend-vue/src/api/user-secrets.ts @@ -30,6 +30,8 @@ export interface AdminUserSecretRow { proxy: AdminUserSecretModule status: string statusMessage: string + /** 首次配置时间(三模块中最早);从未配置为 null。 */ + createdAt: string | null updatedAt: string | null } diff --git a/admin-frontend-vue/src/api/users-model.ts b/admin-frontend-vue/src/api/users-model.ts index 7ac8a2fc..b7ac8681 100644 --- a/admin-frontend-vue/src/api/users-model.ts +++ b/admin-frontend-vue/src/api/users-model.ts @@ -23,6 +23,8 @@ export function toAdminUserItem(raw: unknown): AdminUser | null { if (createdById !== null) item.createdById = createdById const createdAt = text(r.created_at) if (createdAt) item.createdAt = createdAt + const updatedAt = text(r.updated_at) + if (updatedAt) item.updatedAt = updatedAt const creator = text(r.creator_username) if (creator) item.creatorUsername = creator const abbr = text(r.pinyin_abbr) diff --git a/admin-frontend-vue/src/pages/account/MenusPage.vue b/admin-frontend-vue/src/pages/account/MenusPage.vue index dda93750..e268a124 100644 --- a/admin-frontend-vue/src/pages/account/MenusPage.vue +++ b/admin-frontend-vue/src/pages/account/MenusPage.vue @@ -241,6 +241,7 @@ onMounted(loadMenus) 菜单类型 上级菜单 创建时间 + 更新时间 操作 @@ -271,6 +272,7 @@ onMounted(loadMenus) {{ menuTypeLabel(row.menuType) }} {{ parentNameOf(row) }} {{ formatDateTime(row.createdAt) }} + {{ formatDateTime(row.updatedAt) }} @@ -225,10 +227,10 @@ onMounted(() => { - 加载中... + 加载中... - 暂无数据 + 暂无数据 diff --git a/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue b/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue index abc397c5..f23fc117 100644 --- a/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue +++ b/admin-frontend-vue/src/pages/asin/DedupeRegistryPage.vue @@ -375,6 +375,7 @@ onMounted(() => { 用户名 分组 创建时间 + 更新时间 操作 @@ -387,6 +388,7 @@ onMounted(() => { {{ row.username || '-' }} {{ row.groupName || '未分组' }} {{ formatDateTime(row.createdAt) }} + {{ formatDateTime(row.updatedAt) }} @@ -394,10 +396,10 @@ onMounted(() => { - 加载中... + 加载中... - 暂无总数据 + 暂无总数据 diff --git a/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue b/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue index ea4eff0e..217ecdbf 100644 --- a/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue +++ b/admin-frontend-vue/src/pages/asin/ProductCategoryPage.vue @@ -15,6 +15,7 @@ import { } from './product-category-model.ts' import { categoryDraftError, createProductCategoryDraft, toCategorySaveRequest, type ProductCategoryDraft } from './product-category-editor-model.ts' import { buildProductCategoryExportUrl } from './product-category-export.ts' +import { formatDateTime } from '@/utils/datetime' import OldPagination from '@/components/OldPagination.vue' const loading = ref(false) @@ -330,6 +331,8 @@ onMounted(loadTree) 排序 来源 备注 + 创建时间 + 更新时间 操作 @@ -337,7 +340,7 @@ onMounted(loadTree) - 搜索中... + 搜索中... - 暂无搜索结果 + 暂无搜索结果 diff --git a/admin-frontend-vue/src/pages/asin/QueryAsinPage.vue b/admin-frontend-vue/src/pages/asin/QueryAsinPage.vue index d51b18bc..b5bc1e14 100644 --- a/admin-frontend-vue/src/pages/asin/QueryAsinPage.vue +++ b/admin-frontend-vue/src/pages/asin/QueryAsinPage.vue @@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import CopyText from '@/components/CopyText.vue' +import { formatDateTime } from '@/utils/datetime' import { createQueryAsin, fetchQueryAsinList, fetchShopNamesByGroup } from './query-asin-api.ts' import { QUERY_ASIN_COUNTRIES, queryAsinDisplayRows, type QueryAsinItem } from './query-asin-model.ts' import { asinCountryLabel } from './asin-country.ts' @@ -394,6 +395,8 @@ onMounted(() => { 店铺名 ASIN 国家 + 创建时间 + 更新时间 操作 @@ -413,6 +416,8 @@ onMounted(() => { {{ asinCountryLabel(row.country || '') }} - 加载中... + 加载中... - 暂无数据 + 暂无数据 diff --git a/admin-frontend-vue/src/pages/asin/SkipPricePage.vue b/admin-frontend-vue/src/pages/asin/SkipPricePage.vue index 012c8220..389a598e 100644 --- a/admin-frontend-vue/src/pages/asin/SkipPricePage.vue +++ b/admin-frontend-vue/src/pages/asin/SkipPricePage.vue @@ -4,6 +4,7 @@ import { computed, onMounted, reactive, ref } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import CopyText from '@/components/CopyText.vue' +import { formatDateTime } from '@/utils/datetime' import { createSkipPriceAsin, fetchSkipPriceList } from './skip-price-api.ts' import { skipPriceDisplayRows, type SkipPriceItem } from './skip-price-model.ts' import { asinCountryLabel } from './asin-country.ts' @@ -447,6 +448,8 @@ onMounted(() => { ASIN 国家 最低价 + 创建时间 + 更新时间 操作 @@ -467,6 +470,8 @@ onMounted(() => { {{ asinCountryLabel(row.country || '') }} {{ row.minimumPrice !== '' ? row.minimumPrice : '-' }} - 加载中... + 加载中... - 暂无数据 + 暂无数据 diff --git a/admin-frontend-vue/src/pages/asin/dedupe-total-model.ts b/admin-frontend-vue/src/pages/asin/dedupe-total-model.ts index ba9354d9..bf272926 100644 --- a/admin-frontend-vue/src/pages/asin/dedupe-total-model.ts +++ b/admin-frontend-vue/src/pages/asin/dedupe-total-model.ts @@ -11,6 +11,7 @@ export interface DedupeTotalItem { uploaderUserId: number | null username: string createdAt?: string + updatedAt?: string } export interface DedupeTotalPageResult { @@ -51,6 +52,8 @@ export function toDedupeTotalItem(raw: unknown): DedupeTotalItem | null { } const createdAt = text(record.createdAt ?? record.created_at) if (createdAt) item.createdAt = createdAt + const updatedAt = text(record.updatedAt ?? record.updated_at) + if (updatedAt) item.updatedAt = updatedAt return item } diff --git a/admin-frontend-vue/src/pages/asin/product-category-model.ts b/admin-frontend-vue/src/pages/asin/product-category-model.ts index b5087a1e..36c7857d 100644 --- a/admin-frontend-vue/src/pages/asin/product-category-model.ts +++ b/admin-frontend-vue/src/pages/asin/product-category-model.ts @@ -14,6 +14,8 @@ export interface ProductCategoryNode { childCount: number level: number | null path: string + createdAt?: string + updatedAt?: string children: ProductCategoryNode[] } @@ -72,6 +74,10 @@ export function toProductCategoryNode(raw: unknown): ProductCategoryNode | null path: text(record.path), children: [], } + const createdAt = text(record.createdAt ?? record.created_at) + if (createdAt) node.createdAt = createdAt + const updatedAt = text(record.updatedAt ?? record.updated_at) + if (updatedAt) node.updatedAt = updatedAt if (Array.isArray(record.children)) { node.children = record.children .map((child) => toProductCategoryNode(child)) diff --git a/admin-frontend-vue/src/pages/invalidasin/invalid-asin-model.ts b/admin-frontend-vue/src/pages/invalidasin/invalid-asin-model.ts index cb7d5fb9..d6e8e094 100644 --- a/admin-frontend-vue/src/pages/invalidasin/invalid-asin-model.ts +++ b/admin-frontend-vue/src/pages/invalidasin/invalid-asin-model.ts @@ -12,6 +12,7 @@ export interface InvalidAsinItem { groupName: string recordSource: string createdAt?: string + updatedAt?: string } export interface InvalidAsinPageResult { @@ -74,6 +75,8 @@ export function toInvalidAsinItem(raw: unknown): InvalidAsinItem | null { } const createdAt = text(record.createdAt ?? record.created_at) if (createdAt) item.createdAt = createdAt + const updatedAt = text(record.updatedAt ?? record.updated_at) + if (updatedAt) item.updatedAt = updatedAt return item } diff --git a/admin-frontend-vue/src/types/admin.ts b/admin-frontend-vue/src/types/admin.ts index 9ef8c0e4..cd6fecb0 100644 --- a/admin-frontend-vue/src/types/admin.ts +++ b/admin-frontend-vue/src/types/admin.ts @@ -12,6 +12,7 @@ export interface AdminUser { createdById?: number | null creatorUsername?: string createdAt?: string + updatedAt?: string pinyinAbbr?: string } diff --git a/admin-frontend-vue/tests/align-query-asin.test.ts b/admin-frontend-vue/tests/align-query-asin.test.ts index 7e41206a..fc1a3033 100644 --- a/admin-frontend-vue/tests/align-query-asin.test.ts +++ b/admin-frontend-vue/tests/align-query-asin.test.ts @@ -82,7 +82,9 @@ test('align_query_asin_page_layout_wiring', () => { assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮') assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)') assert.match(page, /CopyText/, 'ASIN 单元格点击复制') - assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)') + // 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。 + assert.match(page, /创建时间/, '表格含创建时间列') + assert.match(page, /更新时间/, '表格含更新时间列') assert.match(page, /queryAsinDisplayRows/, '行展开走纯模型') assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐') assert.match(page, /请先选择分组/, '店铺下拉未选分组时占位对齐') diff --git a/admin-frontend-vue/tests/align-skip-price.test.ts b/admin-frontend-vue/tests/align-skip-price.test.ts index a7c2c0cf..5afa1b58 100644 --- a/admin-frontend-vue/tests/align-skip-price.test.ts +++ b/admin-frontend-vue/tests/align-skip-price.test.ts @@ -82,7 +82,9 @@ test('align_skip_price_page_layout_wiring', () => { assert.match(page, /新增 ASIN/, '顶栏补新增 ASIN 按钮') assert.match(page, /:rowspan="row\.rowspan"/, '表格用原生 rowspan 合并(像素复刻旧版)') assert.match(page, /CopyText/, 'ASIN 单元格点击复制') - assert.doesNotMatch(page, /更新时间/, '去掉更新时间列(参考无此列)') + // 需求变更:后台管理列表统一展示「创建时间 + 更新时间」,覆盖早期「不加更新时间」的像素对齐。 + assert.match(page, /创建时间/, '表格含创建时间列') + assert.match(page, /更新时间/, '表格含更新时间列') assert.match(page, /skipPriceDisplayRows/, '行展开走纯模型') assert.match(page, /请完整填写分组、店铺名、国家和 ASIN/, '新增弹窗校验文案对齐') assert.match(page, /最低价格式不正确/, '最低价格式校验对齐') diff --git a/backend-java/src/main/java/com/nanri/aiimage/common/model/entity/AdminUserEntity.java b/backend-java/src/main/java/com/nanri/aiimage/common/model/entity/AdminUserEntity.java index d51497ae..cea1c4e8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/common/model/entity/AdminUserEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/common/model/entity/AdminUserEntity.java @@ -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)。 + * + *

禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新, + * 而本表写回多为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。 + */ + @TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; private String machine; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserItemVo.java index fb268a91..d0de3c6b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/model/vo/AdminUserItemVo.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java index c8e9f765..a9444ae1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/admin/service/AdminUserService.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java index d5908376..3f1de9f9 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/entity/DedupeTotalDataEntity.java @@ -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)。 + * + *

禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新, + * 而本表写回为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。 + */ + @TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER) private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java index db0fb5e0..b046bd8f 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/model/vo/DedupeTotalDataItemVo.java @@ -32,4 +32,7 @@ public class DedupeTotalDataItemVo { @Schema(description = "创建时间") private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java index 06d0248b..b6fcff17 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/dedupe/service/DedupeTotalDataService.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/model/entity/InvalidAsinDataEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/model/entity/InvalidAsinDataEntity.java index f2f8d1e3..484351d1 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/model/entity/InvalidAsinDataEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/model/entity/InvalidAsinDataEntity.java @@ -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)。 + * + *

禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新, + * 而本表写回为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。 + */ + @TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER) private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/model/vo/InvalidAsinDataItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/model/vo/InvalidAsinDataItemVo.java index bf001754..c6d6c29a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/model/vo/InvalidAsinDataItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/model/vo/InvalidAsinDataItemVo.java @@ -29,4 +29,7 @@ public class InvalidAsinDataItemVo { @Schema(description = "创建时间") private LocalDateTime createdAt; + + @Schema(description = "更新时间") + private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataService.java index c1732ced..668399a8 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/invalidasin/service/InvalidAsinDataService.java @@ -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; } } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/entity/PermissionMenuEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/entity/PermissionMenuEntity.java index 77377046..fa15ce3b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/entity/PermissionMenuEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/entity/PermissionMenuEntity.java @@ -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)。 + * + *

禁止应用显式写:MySQL 在 UPDATE 语句显式给该列赋值时不会触发自动更新, + * 而本表写回多为 selectById → 改字段 → updateById(实体带着旧值),一旦写回就会冻结更新时间。 + */ + @TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER) + private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java index 6afdbc05..9194dbfa 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/model/vo/PermissionMenuItemVo.java @@ -24,6 +24,8 @@ public class PermissionMenuItemVo { private Integer sortOrder; @JsonProperty("created_at") private LocalDateTime createdAt; + @JsonProperty("updated_at") + private LocalDateTime updatedAt; /** * 当前操作者能否把该菜单授予他人。仅授权树接口(list)填充;菜单 CRUD 回显等 diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java index a2de5031..ab90273b 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/permission/service/PermissionMenuService.java @@ -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; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/QueryAsinEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/QueryAsinEntity.java index 795ec648..b15d0098 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/QueryAsinEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/QueryAsinEntity.java @@ -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)。 + * + *

禁止应用显式写:本表更新走 selectById → 改字段 → updateById(实体带着旧值), + * 显式写回旧值会触发 MySQL 的「显式赋值不自动更新」规则,把更新时间冻结在首次写入时刻。 + */ + @TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER) private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java index cc2e5aa8..333c5b6a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/shopkey/model/entity/SkipPriceAsinEntity.java @@ -56,6 +56,12 @@ public class SkipPriceAsinEntity { @TableField("created_at") private LocalDateTime createdAt; - @TableField("updated_at") + /** + * 更新时间:由数据库维护(DEFAULT CURRENT_TIMESTAMP + ON UPDATE CURRENT_TIMESTAMP)。 + * + *

禁止应用显式写:本表更新走 selectById → 改字段 → updateById(实体带着旧值), + * 显式写回旧值会触发 MySQL 的「显式赋值不自动更新」规则,把更新时间冻结在首次写入时刻。 + */ + @TableField(value = "updated_at", insertStrategy = FieldStrategy.NEVER, updateStrategy = FieldStrategy.NEVER) private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretRowVo.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretRowVo.java index 89ba1805..20233a7c 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretRowVo.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/model/vo/AdminUserSecretRowVo.java @@ -37,6 +37,9 @@ public class AdminUserSecretRowVo { @Schema(description = "行级状态说明") private String statusMessage; + @Schema(description = "首次配置时间(三模块中最早)") + private LocalDateTime createdAt; + @Schema(description = "最近更新时间(三模块中最晚)") private LocalDateTime updatedAt; } diff --git a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java index f629ad74..44951f7a 100644 --- a/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java +++ b/backend-java/src/main/java/com/nanri/aiimage/modules/usersecret/service/UserApiSecretService.java @@ -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 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); diff --git a/backend-java/src/main/resources/db/V131__users_and_columns_updated_at.sql b/backend-java/src/main/resources/db/V131__users_and_columns_updated_at.sql new file mode 100644 index 00000000..811bdd10 --- /dev/null +++ b/backend-java/src/main/resources/db/V131__users_and_columns_updated_at.sql @@ -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;