5b8105ec2b
用户管理、菜单管理、不符合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 页表头与真实时间值渲染
437 lines
15 KiB
JavaScript
437 lines
15 KiB
JavaScript
// 本地浏览器验收用 mock(仅测试,不随前端产物发布):模拟 Admin 壳层所需的最小 API + 撞款控制台夹具。
|
||
import { createServer } from 'node:http'
|
||
|
||
const PORT = Number(process.env.MOCK_PORT || 18100)
|
||
|
||
const MENU_TREE = [
|
||
{
|
||
key: 'account',
|
||
name: '账号权限',
|
||
children: [
|
||
{ key: 'admin_users', name: '用户管理', route: '/account/users' },
|
||
{ key: 'admin_columns', name: '菜单管理', route: '/account/menus' },
|
||
{ key: 'admin_group_manage', name: '数据权限分组', route: '/account/groups' },
|
||
],
|
||
},
|
||
]
|
||
|
||
/* ===================== 撞款控制台夹具(对齐前端 parse 的 snake 契约) ===================== */
|
||
const STORE_GROUP = { 店A1: '陈新辉', 店A2: '陈新辉', 店B1: '魏振峰', 店B2: '魏振峰', 店B3: '魏振峰', 店C1: '郭亚芳' }
|
||
const GROUP_DEFS = [
|
||
{ name: '陈新辉', shops: ['店A1', '店A2'] },
|
||
{ name: '魏振峰', shops: ['店B1', '店B2', '店B3'] },
|
||
{ name: '郭亚芳', shops: ['店C1'] },
|
||
]
|
||
|
||
function occ(asin, brand, shop, country, day) {
|
||
return { asin, date: `2026-08-${String(day).padStart(2, '0')} 09:00:00`, price: '9.90', brand, shop_name: shop, group_name: STORE_GROUP[shop], country_codes: [country], country }
|
||
}
|
||
|
||
// 撞款种子(≥2 店在售)与单店种子
|
||
const DUP_SEEDS = [
|
||
{ asin: 'E0000001', brand: '品牌甲', occ: [occ('E0000001', '品牌甲', '店A1', 'UK', 20), occ('E0000001', '品牌甲', '店A1', 'UK', 24), occ('E0000001', '品牌甲', '店A2', 'DE', 22), occ('E0000001', '品牌甲', '店B1', 'FR', 21)] },
|
||
{ asin: 'E0000002', brand: '品牌乙', occ: [occ('E0000002', '品牌乙', '店B1', 'UK', 10), occ('E0000002', '品牌乙', '店B2', 'DE', 12)] },
|
||
{ asin: 'E0000003', brand: '品牌丙', occ: [occ('E0000003', '品牌丙', '店A2', 'DE', 15), occ('E0000003', '品牌丙', '店B3', 'IT', 16)] },
|
||
]
|
||
const SINGLE_SEEDS = [
|
||
{ asin: 'E0000004', brand: '品牌甲', occ: [occ('E0000004', '品牌甲', '店A1', 'UK', 1)] },
|
||
{ asin: 'E0000005', brand: '品牌丁', occ: [occ('E0000005', '品牌丁', '店B2', 'UK', 2), occ('E0000005', '品牌丁', '店B2', 'UK', 3)] },
|
||
{ asin: 'E0000006', brand: '品牌戊', occ: [occ('E0000006', '品牌戊', '店C1', 'FR', 5)] },
|
||
{ asin: 'E0000007', brand: '品牌己', occ: [occ('E0000007', '品牌己', '店B3', 'DE', 6)] },
|
||
{ asin: 'E0000008', brand: '品牌庚', occ: [occ('E0000008', '品牌庚', '店A1', 'ES', 7)] },
|
||
]
|
||
|
||
function finalizeRow(seed) {
|
||
const stores = []
|
||
const storeSet = new Set()
|
||
const countries = new Set()
|
||
let earliest = ''
|
||
let latest = ''
|
||
for (const o of seed.occ) {
|
||
if (!storeSet.has(o.shop_name)) {
|
||
storeSet.add(o.shop_name)
|
||
stores.push(o.shop_name)
|
||
}
|
||
countries.add(o.country)
|
||
if (!earliest || o.date < earliest) earliest = o.date
|
||
if (o.date > latest) latest = o.date
|
||
}
|
||
const groups = [...new Set(stores.map((s) => STORE_GROUP[s] || ''))].filter(Boolean)
|
||
return {
|
||
asin: seed.asin,
|
||
brand: seed.brand,
|
||
store_count: stores.length,
|
||
stores,
|
||
groups,
|
||
countries: [...countries],
|
||
record_count: seed.occ.length,
|
||
earliest,
|
||
latest,
|
||
occurrences: seed.occ,
|
||
}
|
||
}
|
||
|
||
const ALL_ROWS = [...DUP_SEEDS, ...SINGLE_SEEDS].map(finalizeRow)
|
||
const DUP_ROWS = ALL_ROWS.filter((row) => row.store_count >= 2)
|
||
|
||
const SHOPS = Object.keys(STORE_GROUP).map((name) => {
|
||
const codes = new Set()
|
||
let recordCount = 0
|
||
let asinCount = 0
|
||
for (const row of ALL_ROWS) {
|
||
if (row.stores.includes(name)) {
|
||
asinCount += 1
|
||
for (const o of row.occurrences) {
|
||
if (o.shop_name === name) {
|
||
recordCount += 1
|
||
codes.add(o.country)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return { shop_name: name, group_name: STORE_GROUP[name], country_codes: [...codes], asin_count: asinCount, record_count: recordCount }
|
||
})
|
||
|
||
function computeGroups() {
|
||
return GROUP_DEFS.map((group) => {
|
||
const members = group.shops
|
||
const hit = DUP_ROWS.filter((row) => members.filter((shop) => row.stores.includes(shop)).length >= 2)
|
||
const asinUnique = ALL_ROWS.filter((row) => row.stores.some((shop) => members.includes(shop))).length
|
||
const recordCount = ALL_ROWS.reduce((sum, row) => {
|
||
if (row.stores.some((shop) => members.includes(shop))) sum += row.recordCount
|
||
return sum
|
||
}, 0)
|
||
return { name: group.name, shop_count: members.length, asin_unique: asinUnique, record_count: recordCount, dup_count: hit.length }
|
||
})
|
||
}
|
||
|
||
const GROUPS = computeGroups()
|
||
const RECORD_TOTAL = ALL_ROWS.reduce((sum, row) => sum + row.recordCount, 0)
|
||
|
||
function consolePayload() {
|
||
return {
|
||
success: true,
|
||
data: {
|
||
pending: false,
|
||
scanned_at: '2026-09-05 08:30:00',
|
||
summary: {
|
||
shop_count: SHOPS.length,
|
||
asin_total: ALL_ROWS.length,
|
||
record_total: RECORD_TOTAL,
|
||
duplicate_asin_total: DUP_ROWS.length,
|
||
duplicate_shop_count: DUP_ROWS.length ? 6 : 0,
|
||
site_count: 5,
|
||
asin_per_shop: 4.8,
|
||
source: 'job',
|
||
},
|
||
shops: SHOPS,
|
||
dup: DUP_ROWS,
|
||
groups: GROUPS,
|
||
total_dup: DUP_ROWS.length,
|
||
},
|
||
}
|
||
}
|
||
|
||
function json(res, payload, status = 200) {
|
||
const body = JSON.stringify(payload)
|
||
res.writeHead(status, {
|
||
'Content-Type': 'application/json; charset=utf-8',
|
||
'Cache-Control': 'no-store',
|
||
'Content-Length': Buffer.byteLength(body),
|
||
})
|
||
res.end(body)
|
||
}
|
||
|
||
/* ===================== 教程包夹具(教程管理页:版本号列 + 上传时间排序验收用) ===================== */
|
||
// 故意乱序给出,且含一条无版本号的历史行:页面默认应按上传时间降序、空版本行沉底。
|
||
const TUTORIAL_PACKAGES = [
|
||
{ id: 3, file_name: '数富AI-教学客户端-v3.zip', version: 'v3.2.0', object_key: 'tutorial/20260912090000-数富AI-教学客户端-v3.zip', file_size: 1048576, file_url: 'https://oss.aishufu.top/client/tutorial/t3.zip', created_at: '2026-09-12 09:00' },
|
||
{ id: 1, file_name: '数富AI-教学客户端.zip', version: '', object_key: 'tutorial/数富AI-教学客户端.zip', file_size: 0, file_url: 'https://oss.aishufu.top/client/tutorial/legacy.zip', created_at: '2026-09-01 08:00' },
|
||
{ id: 4, file_name: '数富AI-教学客户端-v4.zip', version: 'v3.10.0', object_key: 'tutorial/20260914103000-数富AI-教学客户端-v4.zip', file_size: 2097152, file_url: 'https://oss.aishufu.top/client/tutorial/t4.zip', created_at: '2026-09-14 10:30' },
|
||
{ id: 2, file_name: '数富AI-教学客户端-v2.zip', version: 'v3.1.0', object_key: 'tutorial/20260910120000-数富AI-教学客户端-v2.zip', file_size: 524288, file_url: 'https://oss.aishufu.top/client/tutorial/t2.zip', created_at: '2026-09-10 12:00' },
|
||
]
|
||
|
||
/* ===================== 站内通知夹具(铃铛面板:搜索/按天分组/分页验收用) ===================== */
|
||
const NOTIFICATIONS = [
|
||
...[6, 5, 4, 3, 2, 1].map((seq) => notif(seq, '2026-09-13', 22, seq, seq <= 3)),
|
||
...[4, 3, 2, 1].map((seq) => notif(20 + seq, '2026-09-12', 20, seq, true)),
|
||
...[4, 3, 2, 1].map((seq) => notif(30 + seq, '2026-09-10', 18, seq, true)),
|
||
]
|
||
|
||
function notif(id, day, hour, seq, read) {
|
||
const minutes = String(seq * 7).padStart(2, '0')
|
||
return {
|
||
id,
|
||
scene: id % 3 === 0 ? 'secret_balance' : 'task_failed',
|
||
level: id % 3 === 0 ? 'error' : 'warning',
|
||
title: id % 3 === 0 ? `用户密钥异常:测试用户${seq}` : `跟价任务失败`,
|
||
content: id % 3 === 0 ? `用户 测试用户${seq}(uid=${1100 + seq})的代理设置对应服务商余额不足` : `用户 测试用户${seq} 有 ${seq} 个跟价任务失败`,
|
||
read,
|
||
readAt: read ? `${day} ${hour}:${minutes}:00` : null,
|
||
createdAt: `${day} ${hour}:${minutes}:00`,
|
||
}
|
||
}
|
||
|
||
/** 通知列表:关键字匹配标题/内容,日期按年月日区间(与 Java 侧同语义)。 */
|
||
function notificationPage(searchParams) {
|
||
const keyword = (searchParams.get('keyword') || '').trim()
|
||
const startDate = searchParams.get('startDate') || ''
|
||
const endDate = searchParams.get('endDate') || ''
|
||
const page = Math.max(1, Number(searchParams.get('page') || 1))
|
||
const pageSize = Math.min(100, Math.max(1, Number(searchParams.get('pageSize') || 20)))
|
||
let rows = NOTIFICATIONS
|
||
if (keyword) {
|
||
rows = rows.filter((item) => `${item.title}${item.content}`.includes(keyword))
|
||
}
|
||
if (startDate) {
|
||
rows = rows.filter((item) => item.createdAt.slice(0, 10) >= startDate)
|
||
}
|
||
if (endDate) {
|
||
rows = rows.filter((item) => item.createdAt.slice(0, 10) <= endDate)
|
||
}
|
||
const start = (page - 1) * pageSize
|
||
return {
|
||
success: true,
|
||
data: {
|
||
items: rows.slice(start, start + pageSize),
|
||
total: rows.length,
|
||
page,
|
||
pageSize,
|
||
unreadCount: rows.filter((item) => !item.read).length,
|
||
},
|
||
}
|
||
}
|
||
|
||
const server = createServer((req, res) => {
|
||
const url = (req.url || '').split('?')[0]
|
||
const method = req.method || 'GET'
|
||
console.log(`[mock] ${method} ${url}`)
|
||
|
||
if (url === '/api/admin/current-user') {
|
||
return json(res, { success: true, data: { item: { id: 1, username: 'admin', role: 'super_admin' } } })
|
||
}
|
||
if (url === '/api/admin/current-user/menus') {
|
||
return json(res, { success: true, data: { items: MENU_TREE } })
|
||
}
|
||
if (url === '/api/admin/logout') {
|
||
return json(res, { success: true })
|
||
}
|
||
if (url === '/api/admin/users') {
|
||
return json(res, {
|
||
success: true,
|
||
data: {
|
||
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: [
|
||
{
|
||
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: [] } })
|
||
}
|
||
if (url === '/api/admin/shop-data-crawl/duplicate-check-console') {
|
||
return json(res, consolePayload())
|
||
}
|
||
if (url === '/api/admin/shop-data-crawl/duplicate-check-ledger') {
|
||
return json(res, {
|
||
success: true,
|
||
data: { pending: false, scanned_at: '2026-09-05 08:30:00', items: ALL_ROWS, total: ALL_ROWS.length, page: 1, page_size: 20 },
|
||
})
|
||
}
|
||
if (url === '/api/admin/notifications/summary') {
|
||
return json(res, {
|
||
success: true,
|
||
data: {
|
||
unreadCount: NOTIFICATIONS.filter((item) => !item.read).length,
|
||
latestId: NOTIFICATIONS.reduce((max, item) => Math.max(max, item.id), 0),
|
||
},
|
||
})
|
||
}
|
||
if (url === '/api/admin/notifications') {
|
||
return json(res, notificationPage(new URL(req.url || '/', 'http://127.0.0.1').searchParams))
|
||
}
|
||
if (url === '/api/admin/tutorials') {
|
||
// 与 Java 侧同契约:created_at 降序返回(页面"当前生效"取第一条)。
|
||
const items = [...TUTORIAL_PACKAGES].sort((a, b) => (a.created_at < b.created_at ? 1 : -1))
|
||
return json(res, { success: true, data: { items } })
|
||
}
|
||
if (url.startsWith('/api/')) {
|
||
return json(res, { success: true, data: { items: [], total: 0 } })
|
||
}
|
||
return json(res, { success: true }, 200)
|
||
})
|
||
|
||
server.listen(PORT, () => console.log(`mock admin api listening on ${PORT}`))
|