This commit is contained in:
Your Name
2026-08-06 10:09:07 +00:00
parent 3a217f0dd0
commit ae04fe4092
15 changed files with 837 additions and 8 deletions
+23
View File
@@ -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",
+1
View File
@@ -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"
},
+2
View File
@@ -25,5 +25,7 @@ export const config = {
tokenTTL: 10 * 60 * 60 * 1000,
maxRetries: 3,
publicCheckConcurrency: 10,
validatorPollIntervalMs: 2000,
validatorTimeoutMs: 4 * 60 * 1000,
},
}
+10 -1
View File
@@ -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)
+70
View File
@@ -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))
}
+21
View File
@@ -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
}