ok
This commit is contained in:
Generated
+23
@@ -12,6 +12,7 @@
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"fastify": "^5.3.1",
|
||||
"got": "^14.4.6",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"node-cache": "^5.1.2",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
@@ -1845,6 +1846,22 @@
|
||||
"node": ">=10.19.0"
|
||||
}
|
||||
},
|
||||
"node_modules/iconv-lite": {
|
||||
"version": "0.7.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz",
|
||||
"integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@@ -2452,6 +2469,12 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/safer-buffer": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
|
||||
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/sax": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.0.tgz",
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"@fastify/multipart": "^9.0.3",
|
||||
"fastify": "^5.3.1",
|
||||
"got": "^14.4.6",
|
||||
"iconv-lite": "^0.7.3",
|
||||
"node-cache": "^5.1.2",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
|
||||
@@ -25,5 +25,7 @@ export const config = {
|
||||
tokenTTL: 10 * 60 * 60 * 1000,
|
||||
maxRetries: 3,
|
||||
publicCheckConcurrency: 10,
|
||||
validatorPollIntervalMs: 2000,
|
||||
validatorTimeoutMs: 4 * 60 * 1000,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import type { TrueApiEnv } from '../config.js'
|
||||
import { getAuthKey, signIn, clearToken, getAuthStatus, setTokenDirect } from '../services/auth-service.js'
|
||||
import { getAuthKey, signIn, clearToken, getAuthStatus, setTokenDirect, getCachedToken } from '../services/auth-service.js'
|
||||
import { AppError, extractDebugInfo } from '../services/cises-service.js'
|
||||
|
||||
const VALID_ENVS = ['sandbox', 'prod']
|
||||
@@ -71,6 +71,15 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
return getAuthStatus(env)
|
||||
})
|
||||
|
||||
app.get('/auth/token', async (request, reply) => {
|
||||
const env = parseEnv(request.query as Record<string, string>)
|
||||
const token = getCachedToken(env)
|
||||
if (!token) {
|
||||
return reply.status(404).send({ error: 'Token not found' })
|
||||
}
|
||||
return { token }
|
||||
})
|
||||
|
||||
app.post('/auth/logout', async (request) => {
|
||||
const env = parseEnv(request.query as Record<string, string>)
|
||||
clearToken(env)
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { FastifyInstance } from 'fastify'
|
||||
import type { TrueApiEnv } from '../config.js'
|
||||
import { parseUpdXml } from '../services/upd-parser.js'
|
||||
import { checkCodesAuth, AppError } from '../services/cises-service.js'
|
||||
import { validateUpd, getUpdValidationStatus, decodeXmlBuffer } from '../services/upd-validator-service.js'
|
||||
|
||||
const VALID_ENVS = ['sandbox', 'prod']
|
||||
|
||||
@@ -58,4 +59,73 @@ export async function updRoutes(app: FastifyInstance): Promise<void> {
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
// Предварительная проверка УПД целиком в ГИС МТ (True API /doc/validator/create)
|
||||
app.post('/api/validate-upd', async (request, reply) => {
|
||||
const query = request.query as Record<string, string>
|
||||
const env = parseEnv(query)
|
||||
const checkPermits = query.checkPermits === '1'
|
||||
|
||||
const data = await request.file()
|
||||
if (!data) {
|
||||
return reply.status(400).send({ error: 'No file uploaded' })
|
||||
}
|
||||
|
||||
const buffer = await data.toBuffer()
|
||||
const fileName = data.filename
|
||||
|
||||
let parsed
|
||||
try {
|
||||
parsed = await parseUpdXml(decodeXmlBuffer(buffer), fileName)
|
||||
} catch {
|
||||
return reply.status(400).send({ error: 'Invalid XML file' })
|
||||
}
|
||||
|
||||
try {
|
||||
const validation = await validateUpd(env, buffer, fileName, {
|
||||
preliminaryWarnings: checkPermits ? [3] : [],
|
||||
})
|
||||
|
||||
return {
|
||||
document: {
|
||||
number: parsed.documentNumber,
|
||||
date: parsed.documentDate,
|
||||
seller: parsed.sellerName,
|
||||
buyer: parsed.buyerName,
|
||||
fileName: parsed.fileName,
|
||||
},
|
||||
codesFound: parsed.codes.length,
|
||||
validation,
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
return reply.status(err.statusCode).send({
|
||||
error: err.message,
|
||||
debugInfo: err.debugInfo,
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
// Повторное получение статуса проверки УПД (если первый запрос завершился по таймауту)
|
||||
app.get('/api/validate-upd/status', async (request, reply) => {
|
||||
const env = parseEnv(request.query as Record<string, string>)
|
||||
const documentId = (request.query as Record<string, string>).documentId
|
||||
if (!documentId) {
|
||||
return reply.status(400).send({ error: 'Missing documentId' })
|
||||
}
|
||||
|
||||
try {
|
||||
return await getUpdValidationStatus(env, documentId)
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
return reply.status(err.statusCode).send({
|
||||
error: err.message,
|
||||
debugInfo: err.debugInfo,
|
||||
})
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
import got from 'got'
|
||||
import type { Response } from 'got'
|
||||
import iconv from 'iconv-lite'
|
||||
import { config, envUrls } from '../config.js'
|
||||
import type { TrueApiEnv } from '../config.js'
|
||||
import { getCachedToken, clearToken } from './auth-service.js'
|
||||
import { AppError, extractDebugInfoFromResponse } from './cises-service.js'
|
||||
import type { UpdValidationError, UpdValidationWarning } from '../types/index.js'
|
||||
|
||||
const FINAL_STATUSES = ['VERIFIED', 'NOT_VALID', 'NOT_FOUND']
|
||||
|
||||
interface ValidatorStatusResponse {
|
||||
status: string
|
||||
description?: string
|
||||
errors?: UpdValidationError[]
|
||||
preliminaryWarnings?: UpdValidationWarning[]
|
||||
}
|
||||
|
||||
export interface UpdValidationResult {
|
||||
documentId: string
|
||||
status: string
|
||||
description?: string
|
||||
errors: UpdValidationError[]
|
||||
preliminaryWarnings: UpdValidationWarning[]
|
||||
pending: boolean
|
||||
}
|
||||
|
||||
export function decodeXmlBuffer(buffer: Buffer): string {
|
||||
return iconv.decode(buffer, detectEncoding(buffer))
|
||||
}
|
||||
|
||||
// True API принимает УПД только в кодировке Windows-1251
|
||||
export function toWindows1251(buffer: Buffer): Buffer {
|
||||
if (detectEncoding(buffer) === 'utf-8') {
|
||||
let text = iconv.decode(buffer, 'utf-8')
|
||||
text = text.replace(/encoding\s*=\s*["']utf-?8["']/i, 'encoding="windows-1251"')
|
||||
return iconv.encode(text, 'windows-1251')
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
|
||||
export function extractFileId(buffer: Buffer): string | undefined {
|
||||
const m = buffer.toString('latin1').match(/ИдФайл\s*=\s*["']([^"']+)["']/)
|
||||
return m ? m[1] : undefined
|
||||
}
|
||||
|
||||
function detectEncoding(buffer: Buffer): 'utf-8' | 'windows-1251' {
|
||||
const head = buffer.subarray(0, 200).toString('ascii')
|
||||
const m = head.match(/encoding\s*=\s*["']([^"']+)["']/)
|
||||
const enc = m ? m[1].toLowerCase().replace(/\s/g, '') : ''
|
||||
return enc === 'utf-8' || enc === 'utf8' ? 'utf-8' : 'windows-1251'
|
||||
}
|
||||
|
||||
function stripXmlExtension(fileName: string): string {
|
||||
return fileName.replace(/\.xml$/i, '')
|
||||
}
|
||||
|
||||
function apiClient(env: TrueApiEnv, token: string) {
|
||||
return gotClient(env).extend({
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
}
|
||||
|
||||
function gotClient(env: TrueApiEnv) {
|
||||
return got.extend({
|
||||
prefixUrl: envUrls[env].baseUrl,
|
||||
responseType: 'json',
|
||||
hooks: {
|
||||
beforeRequest: [
|
||||
(options) => {
|
||||
const headers = { ...options.headers } as Record<string, string>
|
||||
if (headers.authorization) {
|
||||
headers.authorization = 'Bearer ***'
|
||||
}
|
||||
console.log(JSON.stringify({
|
||||
type: 'trueapi_request',
|
||||
method: options.method,
|
||||
url: String(options.url || ''),
|
||||
headers,
|
||||
body: options.json || options.body || null,
|
||||
}))
|
||||
},
|
||||
],
|
||||
afterResponse: [
|
||||
(response: Response) => {
|
||||
console.log(JSON.stringify({
|
||||
type: 'trueapi_response',
|
||||
statusCode: response.statusCode,
|
||||
url: response.requestUrl,
|
||||
body: response.body,
|
||||
}))
|
||||
return response
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function validateUpd(
|
||||
env: TrueApiEnv,
|
||||
buffer: Buffer,
|
||||
fileName: string,
|
||||
opts?: { preliminaryWarnings?: number[] },
|
||||
): Promise<UpdValidationResult> {
|
||||
const token = getCachedToken(env)
|
||||
if (!token) throw new AppError('Not authenticated', 401)
|
||||
|
||||
const content = toWindows1251(buffer)
|
||||
const documentId = extractFileId(buffer) || stripXmlExtension(fileName)
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
id: documentId,
|
||||
content: content.toString('base64'),
|
||||
documentType: 'UPD',
|
||||
documentFormat: 'XML',
|
||||
}
|
||||
if (opts?.preliminaryWarnings?.length) {
|
||||
payload.preliminaryWarnings = opts.preliminaryWarnings
|
||||
}
|
||||
|
||||
await submitForValidation(env, token, payload)
|
||||
|
||||
// Первый опрос — только после небольшой паузы: сразу после отправки
|
||||
// ГИС МТ отвечает 404 «Отсутствует сообщение с сервера», пока сообщение
|
||||
// не поднялось на асинхронную обработку.
|
||||
await sleep(config.trueApi.validatorPollIntervalMs)
|
||||
|
||||
const deadline = Date.now() + config.trueApi.validatorTimeoutMs
|
||||
let last: ValidatorStatusResponse = { status: 'AWAITING' }
|
||||
while (true) {
|
||||
last = await getValidationStatus(env, token, documentId)
|
||||
if (FINAL_STATUSES.includes(last.status) || Date.now() >= deadline) break
|
||||
await sleep(config.trueApi.validatorPollIntervalMs)
|
||||
}
|
||||
|
||||
return {
|
||||
documentId,
|
||||
status: last.status,
|
||||
description: last.description,
|
||||
errors: last.errors || [],
|
||||
preliminaryWarnings: last.preliminaryWarnings || [],
|
||||
pending: !FINAL_STATUSES.includes(last.status),
|
||||
}
|
||||
}
|
||||
|
||||
export async function getUpdValidationStatus(env: TrueApiEnv, documentId: string): Promise<UpdValidationResult> {
|
||||
const token = getCachedToken(env)
|
||||
if (!token) throw new AppError('Not authenticated', 401)
|
||||
|
||||
const last = await getValidationStatus(env, token, documentId)
|
||||
return {
|
||||
documentId,
|
||||
status: last.status,
|
||||
description: last.description,
|
||||
errors: last.errors || [],
|
||||
preliminaryWarnings: last.preliminaryWarnings || [],
|
||||
pending: !FINAL_STATUSES.includes(last.status),
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForValidation(env: TrueApiEnv, token: string, payload: Record<string, unknown>): Promise<void> {
|
||||
const response = await apiClient(env, token).post('doc/validator/create', {
|
||||
json: payload,
|
||||
timeout: { request: 30000 },
|
||||
throwHttpErrors: false,
|
||||
})
|
||||
|
||||
if (response.statusCode === 401) {
|
||||
clearToken(env)
|
||||
throw new AppError('Token expired', 401, extractDebugInfoFromResponse(response))
|
||||
}
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
const debugInfo = extractDebugInfoFromResponse(response)
|
||||
const apiErr = response.body && typeof response.body === 'object'
|
||||
? (response.body as Record<string, unknown>).error_message || ''
|
||||
: ''
|
||||
throw new AppError(`True API error: ${apiErr || response.statusCode}`, response.statusCode, debugInfo)
|
||||
}
|
||||
}
|
||||
|
||||
async function getValidationStatus(
|
||||
env: TrueApiEnv, token: string, documentId: string
|
||||
): Promise<ValidatorStatusResponse> {
|
||||
const response = await apiClient(env, token).post<ValidatorStatusResponse>('doc/validator/status', {
|
||||
json: { id: documentId },
|
||||
timeout: { request: 30000 },
|
||||
throwHttpErrors: false,
|
||||
})
|
||||
|
||||
if (response.statusCode === 401) {
|
||||
clearToken(env)
|
||||
throw new AppError('Token expired', 401, extractDebugInfoFromResponse(response))
|
||||
}
|
||||
|
||||
// HTTP 404 здесь означает, что сообщение ещё не поднялось на обработку
|
||||
// («Отсутствует сообщение с сервера»), а не финальный статус NOT_FOUND —
|
||||
// опрос продолжается до таймаута.
|
||||
if (response.statusCode === 404) {
|
||||
return { status: 'AWAITING' }
|
||||
}
|
||||
|
||||
if (!response.body || typeof response.body !== 'object') {
|
||||
throw new AppError(
|
||||
`True API error: ${response.statusCode}`,
|
||||
response.statusCode,
|
||||
extractDebugInfoFromResponse(response),
|
||||
)
|
||||
}
|
||||
|
||||
return response.body
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -91,3 +91,24 @@ export interface CheckCodesResponse {
|
||||
errorCount: number
|
||||
debugInfo?: ApiDebugInfo
|
||||
}
|
||||
|
||||
export interface UpdValidationError {
|
||||
code?: number
|
||||
description?: string
|
||||
cis?: string
|
||||
}
|
||||
|
||||
export interface UpdValidationWarning {
|
||||
code?: string
|
||||
description?: string
|
||||
cis?: string
|
||||
}
|
||||
|
||||
export interface UpdValidationResult {
|
||||
documentId: string
|
||||
status: string
|
||||
description?: string
|
||||
errors: UpdValidationError[]
|
||||
preliminaryWarnings: UpdValidationWarning[]
|
||||
pending: boolean
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user