增加发货记录查询功能-1
This commit is contained in:
@@ -2,6 +2,7 @@ import { Router } from "express";
|
||||
|
||||
import authRouter from "./admin/auth.js";
|
||||
import auditLogsRouter from "./admin/audit-logs.js";
|
||||
import cloudtentaclesRecordsRouter from "./admin/cloudtentacles-records.js";
|
||||
import dashboardRouter from "./admin/dashboard.js";
|
||||
import ordersRouter from "./admin/orders.js";
|
||||
import platformConfigRouter from "./admin/platform-config.js";
|
||||
@@ -20,6 +21,7 @@ router.use(auditLogsRouter);
|
||||
router.use(platformConfigRouter);
|
||||
router.use(ordersRouter);
|
||||
router.use(tasksRouter);
|
||||
router.use(cloudtentaclesRecordsRouter);
|
||||
|
||||
router.use((req, res) => {
|
||||
res.status(404).json(buildNotFoundPayload(req));
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import crypto from 'node:crypto'
|
||||
import fs from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
|
||||
import { Router } from 'express'
|
||||
|
||||
import { PROJECT_ROOT } from '../../config/runtime.js'
|
||||
import {
|
||||
listAdminCloudtentaclesDeliveryRecords,
|
||||
listAdminCloudtentaclesRecordSources,
|
||||
} from '../../services/admin/platform-config/cloudtentacles/index.js'
|
||||
import { createJsonHandler, requireAdminRoles } from './session.js'
|
||||
import { createHttpError, sendRouteError } from '../../utils/http.js'
|
||||
import type { AdminCloudtentaclesDeliveryRecordQueryRouteBody } from '../../types/admin/route-inputs.js'
|
||||
|
||||
const router = Router()
|
||||
const RECORD_IMAGE_CACHE_DIR = path.join(PROJECT_ROOT, 'data', 'cache', 'cloudtentacles-record-images')
|
||||
|
||||
router.get(
|
||||
'/cloudtentacles-records/sources',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(() => listAdminCloudtentaclesRecordSources(), {
|
||||
successMessage: 'ok',
|
||||
errorMessage: '读取 cloudtentacles 账号列表失败',
|
||||
scope: '[admin/cloudtentacles-records/sources]',
|
||||
}),
|
||||
)
|
||||
|
||||
router.post(
|
||||
'/cloudtentacles-records',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
createJsonHandler(
|
||||
(req) =>
|
||||
listAdminCloudtentaclesDeliveryRecords(
|
||||
req.body as AdminCloudtentaclesDeliveryRecordQueryRouteBody,
|
||||
),
|
||||
{
|
||||
successMessage: 'cloudtentacles 发货记录查询成功',
|
||||
errorMessage: 'cloudtentacles 发货记录查询失败',
|
||||
scope: '[admin/cloudtentacles-records]',
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
router.get(
|
||||
'/cloudtentacles-records/image',
|
||||
requireAdminRoles(['admin', 'operator', 'support']),
|
||||
async (req, res) => {
|
||||
try {
|
||||
const imageUrl = normalizeAllowedImageUrl(req.query.url)
|
||||
const cachedImage = await getCachedRecordImage(imageUrl)
|
||||
res.setHeader('Content-Type', cachedImage.contentType)
|
||||
res.setHeader('Cache-Control', 'public, max-age=86400')
|
||||
res.send(cachedImage.buffer)
|
||||
} catch (error) {
|
||||
sendRouteError(
|
||||
res,
|
||||
error,
|
||||
'读取 cloudtentacles 商品图片失败',
|
||||
'[admin/cloudtentacles-records/image]',
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
export default router
|
||||
|
||||
function normalizeAllowedImageUrl(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) {
|
||||
throw createHttpError('缺少商品图片地址', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cloudtentacles_record_image_url_required',
|
||||
})
|
||||
}
|
||||
|
||||
const url = new URL(raw)
|
||||
if (!['https:', 'http:'].includes(url.protocol) || url.hostname !== 'gp.playinjoy.com') {
|
||||
throw createHttpError('商品图片地址不在允许范围内', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cloudtentacles_record_image_url_not_allowed',
|
||||
})
|
||||
}
|
||||
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
async function getCachedRecordImage(imageUrl: string) {
|
||||
const cacheMeta = buildRecordImageCacheMeta(imageUrl)
|
||||
const cached = await readCachedRecordImage(cacheMeta)
|
||||
if (cached) {
|
||||
return cached
|
||||
}
|
||||
|
||||
const downloaded = await downloadRecordImage(imageUrl)
|
||||
const filePath = cacheMeta.filePathForContentType(downloaded.contentType)
|
||||
await fs.mkdir(RECORD_IMAGE_CACHE_DIR, { recursive: true })
|
||||
await fs.writeFile(filePath, downloaded.buffer)
|
||||
return downloaded
|
||||
}
|
||||
|
||||
async function readCachedRecordImage(cacheMeta: ReturnType<typeof buildRecordImageCacheMeta>) {
|
||||
for (const candidate of cacheMeta.candidateFilePaths) {
|
||||
try {
|
||||
const buffer = await fs.readFile(candidate.filePath)
|
||||
return {
|
||||
buffer,
|
||||
contentType: candidate.contentType,
|
||||
}
|
||||
} catch (error) {
|
||||
const code = String((error as NodeJS.ErrnoException).code || '')
|
||||
if (code !== 'ENOENT') {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function downloadRecordImage(imageUrl: string) {
|
||||
const response = await fetch(imageUrl, {
|
||||
headers: {
|
||||
accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8',
|
||||
referer: 'https://gp.playinjoy.com/',
|
||||
'user-agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0 Safari/537.36',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw createHttpError(`商品图片读取失败,HTTP ${response.status}`, {
|
||||
statusCode: 502,
|
||||
errorCode: 'cloudtentacles_record_image_fetch_failed',
|
||||
})
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'image/jpeg'
|
||||
if (!contentType.startsWith('image/')) {
|
||||
throw createHttpError('商品图片响应格式无效', {
|
||||
statusCode: 502,
|
||||
errorCode: 'cloudtentacles_record_image_invalid_content_type',
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
buffer: Buffer.from(await response.arrayBuffer()),
|
||||
contentType: normalizeImageContentType(contentType),
|
||||
}
|
||||
}
|
||||
|
||||
function buildRecordImageCacheMeta(imageUrl: string) {
|
||||
const digest = crypto.createHash('sha256').update(imageUrl).digest('hex')
|
||||
const candidateTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif']
|
||||
return {
|
||||
candidateFilePaths: candidateTypes.map((contentType) => ({
|
||||
contentType,
|
||||
filePath: path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`),
|
||||
})),
|
||||
filePathForContentType(contentType: string) {
|
||||
return path.join(RECORD_IMAGE_CACHE_DIR, `${digest}.${extensionForContentType(contentType)}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeImageContentType(contentType: string) {
|
||||
return String(contentType || 'image/jpeg').split(';')[0]?.trim().toLowerCase() || 'image/jpeg'
|
||||
}
|
||||
|
||||
function extensionForContentType(contentType: string) {
|
||||
const normalized = normalizeImageContentType(contentType)
|
||||
if (normalized === 'image/png') return 'png'
|
||||
if (normalized === 'image/webp') return 'webp'
|
||||
if (normalized === 'image/gif') return 'gif'
|
||||
return 'jpg'
|
||||
}
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
useCloudtentaclesSku,
|
||||
} from "../../../platforms/cloudtentacles/catalog-service.js";
|
||||
import { getCloudtentaclesKnapsack } from "../../../platforms/cloudtentacles/knapsack-service.js";
|
||||
import { listCloudtentaclesDeliveryRecords } from "../../../platforms/cloudtentacles/record-service.js";
|
||||
import {
|
||||
appointCloudtentaclesVirtualNumber,
|
||||
backCloudtentaclesVirtualNumber,
|
||||
@@ -59,6 +60,7 @@ import { createHttpError } from "../../../../utils/http.js";
|
||||
|
||||
import type {
|
||||
AdminCloudtentaclesCatalogQueryInput,
|
||||
AdminCloudtentaclesDeliveryRecordQueryInput,
|
||||
AdminCloudtentaclesFullFlowInput,
|
||||
AdminCloudtentaclesSendSmsCodeInput,
|
||||
AdminCloudtentaclesSkuBuyInput,
|
||||
@@ -93,6 +95,26 @@ export function listAdminCloudtentaclesSources() {
|
||||
};
|
||||
}
|
||||
|
||||
export function listAdminCloudtentaclesRecordSources() {
|
||||
const list = listCloudtentaclesSources();
|
||||
const sessions = getAllCloudtentaclesSessionStates();
|
||||
const sources = Array.isArray(list.sources) ? list.sources : [];
|
||||
|
||||
return {
|
||||
sources: sources.map((source) => {
|
||||
const key = String(source.key || "").trim();
|
||||
const session = (sessions.sessions?.[key] || {}) as JsonObject;
|
||||
return {
|
||||
key,
|
||||
label: String(source.label || source.username || key || "未命名账号").trim(),
|
||||
enabled: source.enabled !== false,
|
||||
hasToken: Boolean(String(session.token || "").trim()),
|
||||
loggedInAt: String(session.loggedInAt || "").trim(),
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function updateAdminCloudtentaclesSourceConfig(
|
||||
payload: AdminCloudtentaclesSourceConfigInput = {}
|
||||
) {
|
||||
@@ -286,6 +308,57 @@ export async function getAdminCloudtentaclesKnapsack(
|
||||
);
|
||||
}
|
||||
|
||||
export async function listAdminCloudtentaclesDeliveryRecords(
|
||||
payload: AdminCloudtentaclesDeliveryRecordQueryInput = {}
|
||||
) {
|
||||
const context = resolveAdminCloudtentaclesSessionPayload(payload);
|
||||
const query = {
|
||||
...context,
|
||||
...pickDefined({
|
||||
page: payload.page,
|
||||
size: payload.size,
|
||||
startDate: payload.startDate,
|
||||
endDate: payload.endDate,
|
||||
recordCode: payload.recordCode,
|
||||
}),
|
||||
};
|
||||
const [records, skuList] = await Promise.all([
|
||||
listCloudtentaclesDeliveryRecords(query),
|
||||
listCloudtentaclesSku(context),
|
||||
]);
|
||||
const skuImageByName = new Map<string, {
|
||||
skuId: number;
|
||||
skuName: string;
|
||||
skuImage: string;
|
||||
}>();
|
||||
for (const sku of Array.isArray(skuList.items) ? skuList.items : []) {
|
||||
const name = normalizeSkuName(sku.name);
|
||||
const skuImage = String(sku.image || "").trim();
|
||||
if (!name || !skuImage) {
|
||||
continue;
|
||||
}
|
||||
|
||||
skuImageByName.set(name, {
|
||||
skuId: Number(sku.id || 0),
|
||||
skuName: String(sku.name || "").trim(),
|
||||
skuImage,
|
||||
});
|
||||
}
|
||||
|
||||
const recordItems: JsonObject[] = Array.isArray(records.items) ? records.items : [];
|
||||
return {
|
||||
...records,
|
||||
items: recordItems.map((record) => ({
|
||||
...record,
|
||||
...(skuImageByName.get(normalizeSkuName(record.name)) || {
|
||||
skuId: 0,
|
||||
skuName: "",
|
||||
skuImage: "",
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listAdminCloudtentaclesVirtualNumbers(
|
||||
payload: AdminCloudtentaclesVirtualNumberInput = {}
|
||||
) {
|
||||
@@ -373,3 +446,13 @@ export async function runAdminCloudtentaclesFullFlow(
|
||||
vnKey: payload.vnKey,
|
||||
});
|
||||
}
|
||||
|
||||
function pickDefined(values: JsonObject = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).filter(([, value]) => typeof value !== "undefined")
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeSkuName(value: unknown) {
|
||||
return String(value || "").trim().replace(/\s+/g, "").toLowerCase();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { createHttpError } from '../../../utils/http.js'
|
||||
import { cloudtentaclesRequest } from './http-client.js'
|
||||
import { resolveCloudtentaclesConfig } from './helpers.js'
|
||||
|
||||
type JsonObject = Record<string, any>
|
||||
|
||||
export type CloudtentaclesDeliveryRecordQuery = JsonObject & {
|
||||
baseUrl?: string
|
||||
timeoutMs?: number | string
|
||||
token?: string
|
||||
deviceId?: string
|
||||
deviceType?: number | string
|
||||
page?: number | string
|
||||
size?: number | string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
recordCode?: number | string
|
||||
}
|
||||
|
||||
export async function listCloudtentaclesDeliveryRecords(
|
||||
payload: CloudtentaclesDeliveryRecordQuery = {},
|
||||
) {
|
||||
const config = resolveCloudtentaclesConfig({
|
||||
...pickDefined({
|
||||
baseUrl: payload.baseUrl,
|
||||
deviceId: payload.deviceId,
|
||||
deviceType: payload.deviceType,
|
||||
}),
|
||||
...(
|
||||
typeof payload.timeoutMs === 'undefined'
|
||||
? {}
|
||||
: { timeoutMs: normalizePositiveInteger(payload.timeoutMs, 5000) }
|
||||
),
|
||||
})
|
||||
const page = normalizePositiveInteger(payload.page, 1)
|
||||
const size = normalizePageSize(payload.size)
|
||||
const startDate = normalizeDateInput(payload.startDate)
|
||||
const endDate = normalizeDateInput(payload.endDate)
|
||||
|
||||
if (!String(payload.token || '').trim()) {
|
||||
throw createHttpError('cloudtentacles 发货记录查询缺少 token', {
|
||||
statusCode: 409,
|
||||
errorCode: 'cloudtentacles_record_missing_token',
|
||||
})
|
||||
}
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
throw createHttpError('cloudtentacles 发货记录查询需要选择开始和结束时间', {
|
||||
statusCode: 400,
|
||||
errorCode: 'cloudtentacles_record_date_required',
|
||||
})
|
||||
}
|
||||
|
||||
const result = await cloudtentaclesRequest('/record/paging', {
|
||||
...payload,
|
||||
method: 'POST',
|
||||
body: {
|
||||
record_code: normalizePositiveInteger(payload.recordCode, 4),
|
||||
page,
|
||||
size,
|
||||
start_date: startDate,
|
||||
end_date: endDate,
|
||||
},
|
||||
businessErrorCode: 'cloudtentacles_record_query_failed',
|
||||
timeoutMs: payload.timeoutMs || config.timeoutMs,
|
||||
})
|
||||
|
||||
const data = isPlainObject(result.payload?.data) ? result.payload.data : {}
|
||||
const values = Array.isArray(data.values) ? data.values : []
|
||||
|
||||
return {
|
||||
baseUrl: config.baseUrl,
|
||||
page,
|
||||
size,
|
||||
total: normalizeNonNegativeInteger(data.total, values.length),
|
||||
items: values.map(mapCloudtentaclesDeliveryRecord),
|
||||
raw: data,
|
||||
}
|
||||
}
|
||||
|
||||
function mapCloudtentaclesDeliveryRecord(raw: JsonObject) {
|
||||
return {
|
||||
createdAt: String(raw.c_time || '').trim(),
|
||||
recordId: String(raw.record_id || '').trim(),
|
||||
virtualNumberId: normalizeNonNegativeInteger(raw.virtual_number_id, 0),
|
||||
userId: normalizeNonNegativeInteger(raw.user_id, 0),
|
||||
count: normalizeNonNegativeInteger(raw.count, 0),
|
||||
cdk: String(raw.cdk || '').trim(),
|
||||
exchangeUrl: String(raw.exchange_url || '').trim(),
|
||||
name: String(raw.name || '').trim(),
|
||||
phone: String(raw.phone || '').trim(),
|
||||
status: normalizeNonNegativeInteger(raw.status, 0),
|
||||
fulfillUser: String(raw.fulfill_user || '').trim(),
|
||||
operatorName: String(raw.u_name || '').trim(),
|
||||
raw,
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(value: unknown, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function normalizePageSize(value: unknown) {
|
||||
const parsed = normalizePositiveInteger(value, 100)
|
||||
return Math.min(Math.max(parsed, 100), 1000)
|
||||
}
|
||||
|
||||
function normalizeNonNegativeInteger(value: unknown, fallback: number) {
|
||||
const parsed = Number(value)
|
||||
return Number.isInteger(parsed) && parsed >= 0 ? parsed : fallback
|
||||
}
|
||||
|
||||
function normalizeDateInput(value: unknown) {
|
||||
const raw = String(value || '').trim()
|
||||
if (!raw) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const date = new Date(raw)
|
||||
return Number.isNaN(date.getTime()) ? raw : date.toISOString()
|
||||
}
|
||||
|
||||
function isPlainObject(value: unknown): value is JsonObject {
|
||||
return Object.prototype.toString.call(value) === '[object Object]'
|
||||
}
|
||||
|
||||
function pickDefined(values: JsonObject = {}) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).filter(([, value]) => typeof value !== 'undefined'),
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import type {
|
||||
} from './read-inputs.js'
|
||||
import type {
|
||||
AdminCloudtentaclesCatalogQueryInput,
|
||||
AdminCloudtentaclesDeliveryRecordQueryInput,
|
||||
AdminCloudtentaclesFullFlowInput,
|
||||
AdminCloudtentaclesSendSmsCodeInput,
|
||||
AdminCloudtentaclesSkuBuyInput,
|
||||
@@ -42,6 +43,7 @@ export type AdminCloudtentaclesSendSmsCodeRouteBody = AdminCloudtentaclesSendSms
|
||||
export type AdminCloudtentaclesTestLoginRouteBody = AdminCloudtentaclesTestLoginInput
|
||||
export type AdminCloudtentaclesValidateSessionRouteBody = AdminCloudtentaclesValidateSessionInput
|
||||
export type AdminCloudtentaclesCatalogQueryRouteBody = AdminCloudtentaclesCatalogQueryInput
|
||||
export type AdminCloudtentaclesDeliveryRecordQueryRouteBody = AdminCloudtentaclesDeliveryRecordQueryInput
|
||||
export type AdminCloudtentaclesSkuBuyRouteBody = AdminCloudtentaclesSkuBuyInput
|
||||
export type AdminCloudtentaclesSkuUseRouteBody = AdminCloudtentaclesSkuUseInput
|
||||
export type AdminCloudtentaclesVirtualNumberRouteBody = AdminCloudtentaclesVirtualNumberInput
|
||||
|
||||
@@ -175,6 +175,14 @@ export type AdminCloudtentaclesCatalogQueryInput = {
|
||||
deviceType?: number | string
|
||||
}
|
||||
|
||||
export type AdminCloudtentaclesDeliveryRecordQueryInput = AdminCloudtentaclesCatalogQueryInput & {
|
||||
page?: number | string
|
||||
size?: number | string
|
||||
startDate?: string
|
||||
endDate?: string
|
||||
recordCode?: number | string
|
||||
}
|
||||
|
||||
export type AdminCloudtentaclesSkuBuyInput = {
|
||||
sourceKey?: string
|
||||
baseUrl?: string
|
||||
|
||||
Reference in New Issue
Block a user