增加发货记录查询功能-1

This commit is contained in:
yml2213
2026-05-26 15:01:02 +08:00
parent ab417ca90f
commit 604f1bbf54
16 changed files with 1032 additions and 0 deletions
@@ -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'
}