feat(api): implement chestny-znak web app with True API integration
- Fastify backend with auth (CryptoPro CAdES-BES), KM check, UPD parsing - React frontend with auth page, code check, XML upload, results table - Client-side signing via crypto-pro 2.3.0 (browser plugin) - Token caching with auto-refresh, retry with exponential backoff - Input validation schemas, file size limits, global 401 interceptor - Docker compose with backend + frontend (nginx) services
This commit is contained in:
@@ -2,4 +2,4 @@ PORT=3001
|
||||
HOST=0.0.0.0
|
||||
TRUE_API_URL=https://markirovka.sandbox.crptech.ru/api/v3/true-api
|
||||
PUBLIC_CHECK_URL=https://mobile.api.crpt.ru/mobile/check
|
||||
CERT_THUMBPRINT=your-certificate-thumbprint-here
|
||||
CORS_ORIGIN=*
|
||||
|
||||
@@ -2,6 +2,8 @@ export const config = {
|
||||
port: parseInt(process.env.PORT || '3001', 10),
|
||||
host: process.env.HOST || '0.0.0.0',
|
||||
|
||||
corsOrigin: process.env.CORS_ORIGIN || '*',
|
||||
|
||||
trueApi: {
|
||||
baseUrl: process.env.TRUE_API_URL || 'https://markirovka.sandbox.crptech.ru/api/v3/true-api',
|
||||
publicCheckUrl: process.env.PUBLIC_CHECK_URL || 'https://mobile.api.crpt.ru/mobile/check',
|
||||
@@ -10,5 +12,7 @@ export const config = {
|
||||
codesCheckPath: '/codes/check',
|
||||
batchSize: 1000,
|
||||
tokenTTL: 10 * 60 * 60 * 1000,
|
||||
maxRetries: 3,
|
||||
publicCheckConcurrency: 10,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -8,8 +8,14 @@ import { updRoutes } from './routes/upd.js'
|
||||
|
||||
const app = Fastify({ logger: true })
|
||||
|
||||
await app.register(cors, { origin: true })
|
||||
await app.register(multipart)
|
||||
await app.register(cors, { origin: config.corsOrigin })
|
||||
await app.register(multipart, {
|
||||
limits: {
|
||||
fileSize: 10 * 1024 * 1024,
|
||||
files: 1,
|
||||
},
|
||||
throwFileSizeLimit: true,
|
||||
})
|
||||
|
||||
await app.register(authRoutes)
|
||||
await app.register(cisesRoutes)
|
||||
|
||||
@@ -1,6 +1,17 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { getAuthKey, signIn } from '../services/auth-service.js'
|
||||
import { getTokenTTL, hasToken } from '../services/token-cache.js'
|
||||
import { getAuthKey, signIn, clearToken, isAuthenticated } from '../services/auth-service.js'
|
||||
import { getTokenTTL } from '../services/token-cache.js'
|
||||
|
||||
const signInSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['uuid', 'data'],
|
||||
properties: {
|
||||
uuid: { type: 'string' },
|
||||
data: { type: 'string' },
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/auth/key', async () => {
|
||||
@@ -8,18 +19,23 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
return { uuid: authKey.uuid, data: authKey.data }
|
||||
})
|
||||
|
||||
app.post<{ Body: { uuid: string; data: string } }>('/auth/signin', async (request) => {
|
||||
app.post<{ Body: { uuid: string; data: string } }>('/auth/simpleSignIn', { schema: signInSchema }, async (request) => {
|
||||
const { uuid, data } = request.body
|
||||
const result = await signIn({ uuid, data })
|
||||
return result
|
||||
})
|
||||
|
||||
app.get('/auth/status', async () => {
|
||||
const authenticated = hasToken()
|
||||
const authenticated = isAuthenticated()
|
||||
const ttl = getTokenTTL()
|
||||
return {
|
||||
authenticated,
|
||||
tokenExpiresAt: ttl ? new Date(ttl).toISOString() : null,
|
||||
}
|
||||
})
|
||||
|
||||
app.post('/auth/logout', async () => {
|
||||
clearToken()
|
||||
return { success: true }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,13 +1,35 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { checkCodesPublic, checkCodesAuth } from '../services/cises-service.js'
|
||||
import { checkCodesPublic, checkCodesAuth, AppError } from '../services/cises-service.js'
|
||||
import type { CheckCodesRequest } from '../types/index.js'
|
||||
|
||||
const checkCodesSchema = {
|
||||
body: {
|
||||
type: 'object',
|
||||
required: ['codes'],
|
||||
properties: {
|
||||
codes: {
|
||||
type: 'array',
|
||||
items: { type: 'string', minLength: 1, maxLength: 256 },
|
||||
minItems: 1,
|
||||
maxItems: 10000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export async function cisesRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post<{ Body: CheckCodesRequest }>('/api/check-codes/public', async (request) => {
|
||||
app.post<{ Body: CheckCodesRequest }>('/api/check-codes/public', { schema: checkCodesSchema }, async (request) => {
|
||||
return checkCodesPublic(request.body)
|
||||
})
|
||||
|
||||
app.post<{ Body: CheckCodesRequest }>('/api/check-codes/auth', async (request) => {
|
||||
return checkCodesAuth(request.body)
|
||||
app.post<{ Body: CheckCodesRequest }>('/api/check-codes/auth', { schema: checkCodesSchema }, async (request, reply) => {
|
||||
try {
|
||||
return await checkCodesAuth(request.body)
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
return reply.status(err.statusCode).send({ error: err.message })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
+25
-13
@@ -1,6 +1,6 @@
|
||||
import type { FastifyInstance } from 'fastify'
|
||||
import { parseUpdXml } from '../services/upd-parser.js'
|
||||
import { checkCodesAuth } from '../services/cises-service.js'
|
||||
import { checkCodesAuth, AppError } from '../services/cises-service.js'
|
||||
|
||||
export async function updRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.post('/api/upload-upd', async (request, reply) => {
|
||||
@@ -13,24 +13,36 @@ export async function updRoutes(app: FastifyInstance): Promise<void> {
|
||||
const xmlContent = buffer.toString('utf-8')
|
||||
const fileName = data.filename
|
||||
|
||||
const parsed = await parseUpdXml(xmlContent, fileName)
|
||||
let parsed
|
||||
try {
|
||||
parsed = await parseUpdXml(xmlContent, fileName)
|
||||
} catch {
|
||||
return reply.status(400).send({ error: 'Invalid XML file' })
|
||||
}
|
||||
|
||||
if (parsed.codes.length === 0) {
|
||||
return reply.status(400).send({ error: 'No marking codes found in UPD' })
|
||||
}
|
||||
|
||||
const checkResults = await checkCodesAuth({ codes: parsed.codes })
|
||||
try {
|
||||
const checkResults = await checkCodesAuth({ codes: parsed.codes })
|
||||
|
||||
return {
|
||||
document: {
|
||||
number: parsed.documentNumber,
|
||||
date: parsed.documentDate,
|
||||
seller: parsed.sellerName,
|
||||
buyer: parsed.buyerName,
|
||||
fileName: parsed.fileName,
|
||||
},
|
||||
codesFound: parsed.codes.length,
|
||||
results: checkResults,
|
||||
return {
|
||||
document: {
|
||||
number: parsed.documentNumber,
|
||||
date: parsed.documentDate,
|
||||
seller: parsed.sellerName,
|
||||
buyer: parsed.buyerName,
|
||||
fileName: parsed.fileName,
|
||||
},
|
||||
codesFound: parsed.codes.length,
|
||||
results: checkResults,
|
||||
}
|
||||
} catch (err) {
|
||||
if (err instanceof AppError) {
|
||||
return reply.status(err.statusCode).send({ error: err.message })
|
||||
}
|
||||
throw err
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import got from 'got'
|
||||
import { config } from '../config.js'
|
||||
import { getToken, setToken } from './token-cache.js'
|
||||
import { getToken, setToken, invalidateToken } from './token-cache.js'
|
||||
import type { AuthKeyResponse, AuthSignInRequest, AuthSignInResponse } from '../types/index.js'
|
||||
|
||||
const authClient = got.extend({
|
||||
@@ -21,29 +21,14 @@ export async function signIn(body: AuthSignInRequest): Promise<AuthSignInRespons
|
||||
return response
|
||||
}
|
||||
|
||||
export async function getValidToken(): Promise<string> {
|
||||
const cached = getToken()
|
||||
if (cached) return cached
|
||||
|
||||
const { uuid, data } = await getAuthKey()
|
||||
const signature = await signData(data)
|
||||
const { token } = await signIn({ uuid, data: signature })
|
||||
return token
|
||||
export function getCachedToken(): string | undefined {
|
||||
return getToken()
|
||||
}
|
||||
|
||||
async function signData(data: string): Promise<string> {
|
||||
try {
|
||||
const { execSync } = await import('child_process')
|
||||
export function clearToken(): void {
|
||||
invalidateToken()
|
||||
}
|
||||
|
||||
const thumbprint = process.env.CERT_THUMBPRINT
|
||||
if (!thumbprint) throw new Error('CERT_THUMBPRINT not set')
|
||||
|
||||
const result = execSync(
|
||||
`cryptcp -sign -detached -base64 -thumbprint "${thumbprint}" -in <(echo -n "${data}")`,
|
||||
{ encoding: 'utf-8' }
|
||||
)
|
||||
return result.trim()
|
||||
} catch (err) {
|
||||
throw new Error(`Failed to sign data with CryptoPro: ${err}`)
|
||||
}
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import got from 'got'
|
||||
import { config } from '../config.js'
|
||||
import { getValidToken } from './auth-service.js'
|
||||
import { getCachedToken, clearToken } from './auth-service.js'
|
||||
import type {
|
||||
CisInfoResponse,
|
||||
PublicCheckResponse,
|
||||
@@ -16,75 +16,145 @@ const apiClient = got.extend({
|
||||
|
||||
export async function checkCodesPublic(body: CheckCodesRequest): Promise<CheckCodesResponse> {
|
||||
const results: SingleCodeResult[] = []
|
||||
const queue = [...body.codes]
|
||||
|
||||
for (const code of body.codes) {
|
||||
try {
|
||||
const { body: response } = await got.get<PublicCheckResponse>(
|
||||
`${config.trueApi.publicCheckUrl}?code=${encodeURIComponent(code)}`,
|
||||
{ responseType: 'json' }
|
||||
)
|
||||
results.push({
|
||||
code: response.code,
|
||||
found: response.found,
|
||||
valid: response.valid,
|
||||
status: response.status,
|
||||
})
|
||||
} catch {
|
||||
results.push({
|
||||
code,
|
||||
found: false,
|
||||
valid: false,
|
||||
status: 'ERROR',
|
||||
error: 'Public check API error',
|
||||
})
|
||||
async function worker() {
|
||||
while (queue.length > 0) {
|
||||
const code = queue.shift()!
|
||||
try {
|
||||
const { body: response } = await got.get<PublicCheckResponse>(
|
||||
`${config.trueApi.publicCheckUrl}?code=${encodeURIComponent(code)}`,
|
||||
{ responseType: 'json', timeout: { request: 10000 } }
|
||||
)
|
||||
results.push({
|
||||
code: response.code,
|
||||
found: response.found,
|
||||
valid: response.valid,
|
||||
status: response.status,
|
||||
})
|
||||
} catch {
|
||||
results.push({
|
||||
code,
|
||||
found: false,
|
||||
valid: false,
|
||||
status: 'ERROR',
|
||||
error: 'Public check API error',
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const workers = Array.from({ length: config.trueApi.publicCheckConcurrency }, () => worker())
|
||||
await Promise.all(workers)
|
||||
|
||||
return buildResponse(results)
|
||||
}
|
||||
|
||||
function getToken(): string {
|
||||
const token = getCachedToken()
|
||||
if (!token) throw new AppError('Not authenticated', 401)
|
||||
return token
|
||||
}
|
||||
|
||||
export async function checkCodesAuth(body: CheckCodesRequest): Promise<CheckCodesResponse> {
|
||||
const token = getToken()
|
||||
const results: SingleCodeResult[] = []
|
||||
|
||||
try {
|
||||
await processBatch(body.codes, token, results, 0)
|
||||
} catch (err) {
|
||||
if (err instanceof AppError && err.statusCode === 401) {
|
||||
clearToken()
|
||||
throw err
|
||||
}
|
||||
for (const code of body.codes) {
|
||||
if (!results.find(r => r.code === code)) {
|
||||
results.push({ code, found: false, valid: false, status: 'ERROR', error: 'Auth API error' })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buildResponse(results)
|
||||
}
|
||||
|
||||
export async function checkCodesAuth(body: CheckCodesRequest): Promise<CheckCodesResponse> {
|
||||
const token = await getValidToken()
|
||||
const results: SingleCodeResult[] = []
|
||||
async function processBatch(
|
||||
codes: string[], token: string, results: SingleCodeResult[], batchIndex: number
|
||||
): Promise<void> {
|
||||
if (batchIndex >= codes.length) return
|
||||
|
||||
for (let i = 0; i < body.codes.length; i += config.trueApi.batchSize) {
|
||||
const batch = body.codes.slice(i, i + config.trueApi.batchSize)
|
||||
const start = batchIndex
|
||||
const end = Math.min(start + config.trueApi.batchSize, codes.length)
|
||||
const batch = codes.slice(start, end)
|
||||
|
||||
try {
|
||||
const { body: response } = await apiClient.post<CisInfoResponse>(
|
||||
config.trueApi.cisesInfoPath,
|
||||
{
|
||||
json: { cisList: batch },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}
|
||||
)
|
||||
await retryOnError(batch, token, results)
|
||||
|
||||
for (const item of response.cisInfo) {
|
||||
results.push({
|
||||
code: item.requestedCis,
|
||||
found: !!item.cis,
|
||||
valid: item.status === 'INTRODUCED' || item.status === 'APPLIED' || item.status === 'EMITTED',
|
||||
status: item.status || 'UNKNOWN',
|
||||
gtin: item.gtin,
|
||||
productName: item.productName,
|
||||
producerName: item.producerName,
|
||||
ownerName: item.ownerName,
|
||||
})
|
||||
}
|
||||
} catch {
|
||||
for (const code of batch) {
|
||||
results.push({
|
||||
code,
|
||||
found: false,
|
||||
valid: false,
|
||||
status: 'ERROR',
|
||||
error: 'Auth API error',
|
||||
})
|
||||
await processBatch(codes, token, results, end)
|
||||
}
|
||||
|
||||
async function retryOnError(
|
||||
batch: string[], token: string, results: SingleCodeResult[], attempt = 0
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { body: response } = await apiClient.post<CisInfoResponse>(
|
||||
config.trueApi.cisesInfoPath,
|
||||
{
|
||||
json: { cisList: batch },
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
timeout: { request: 30000 },
|
||||
}
|
||||
)
|
||||
|
||||
for (const item of response.cisInfo) {
|
||||
results.push({
|
||||
code: item.requestedCis,
|
||||
found: !!item.cis,
|
||||
valid: item.status === 'INTRODUCED' || item.status === 'APPLIED' || item.status === 'EMITTED',
|
||||
status: item.status || 'UNKNOWN',
|
||||
gtin: item.gtin,
|
||||
productName: item.productName,
|
||||
producerName: item.producerName,
|
||||
ownerName: item.ownerName,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
if (is401(err)) {
|
||||
throw new AppError('Token expired', 401)
|
||||
}
|
||||
}
|
||||
|
||||
return buildResponse(results)
|
||||
const isRetryable = isRetryableError(err)
|
||||
if (isRetryable && attempt < config.trueApi.maxRetries) {
|
||||
const delay = Math.pow(2, attempt) * 500
|
||||
await sleep(delay)
|
||||
return retryOnError(batch, token, results, attempt + 1)
|
||||
}
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function is401(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return false
|
||||
return 'response' in err &&
|
||||
(err as { response?: { statusCode?: number } }).response?.statusCode === 401
|
||||
}
|
||||
|
||||
function isRetryableError(err: unknown): boolean {
|
||||
if (!err || typeof err !== 'object') return true
|
||||
const statusCode = (err as { response?: { statusCode?: number } }).response?.statusCode
|
||||
if (!statusCode) return true
|
||||
return statusCode === 429 || statusCode >= 500
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
export class AppError extends Error {
|
||||
statusCode: number
|
||||
constructor(message: string, statusCode: number) {
|
||||
super(message)
|
||||
this.statusCode = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
function buildResponse(results: SingleCodeResult[]): CheckCodesResponse {
|
||||
|
||||
@@ -23,3 +23,7 @@ export function hasToken(): boolean {
|
||||
export function getTokenTTL(): number | undefined {
|
||||
return cache.getTtl(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function invalidateToken(): void {
|
||||
cache.del(TOKEN_KEY)
|
||||
}
|
||||
|
||||
@@ -11,7 +11,6 @@ export interface ParsedUpd {
|
||||
|
||||
export async function parseUpdXml(xmlContent: string, fileName: string): Promise<ParsedUpd> {
|
||||
const parsed = await parseStringPromise(xmlContent, {
|
||||
explicitArray: false,
|
||||
ignoreAttrs: false,
|
||||
mergeAttrs: true,
|
||||
})
|
||||
@@ -21,42 +20,44 @@ export async function parseUpdXml(xmlContent: string, fileName: string): Promise
|
||||
return {
|
||||
codes,
|
||||
fileName,
|
||||
documentNumber: extractField(parsed, 'Документ', 'НомерДок'),
|
||||
documentDate: extractField(parsed, 'Документ', 'ДатаДок'),
|
||||
sellerName: extractField(parsed, 'Документ', 'ТаблСчФакт', 'СведПрод', 'НаимОрг'),
|
||||
buyerName: extractField(parsed, 'Документ', 'ТаблСчФакт', 'СведПокуп', 'НаимОрг'),
|
||||
documentNumber: extractField(parsed, ['Документ', 'НомерДок']),
|
||||
documentDate: extractField(parsed, ['Документ', 'ДатаДок']),
|
||||
sellerName: extractField(parsed, ['Документ', 'ТаблСчФакт', 'СведПрод', 'НаимОрг']),
|
||||
buyerName: extractField(parsed, ['Документ', 'ТаблСчФакт', 'СведПокуп', 'НаимОрг']),
|
||||
}
|
||||
}
|
||||
|
||||
function extractCodes(obj: Record<string, unknown>): string[] {
|
||||
const codes: string[] = []
|
||||
const documents = asArray<Record<string, unknown>>(obj['Документ'])
|
||||
|
||||
try {
|
||||
const document = obj['Документ'] as Record<string, unknown>
|
||||
const table = document['ТаблСчФакт'] as Record<string, unknown>
|
||||
const items = table['СведТов'] as Record<string, unknown>[]
|
||||
for (const document of documents) {
|
||||
try {
|
||||
const table = document['ТаблСчФакт'] as Record<string, unknown> | undefined
|
||||
if (!table) continue
|
||||
const items = asArray<Record<string, unknown>>(table['СведТов'])
|
||||
|
||||
if (Array.isArray(items)) {
|
||||
for (const item of items) {
|
||||
const extInfo = item['ДопСведТов'] as Record<string, unknown>
|
||||
if (extInfo) {
|
||||
const kmCodes = extInfo['НомСредИдентТов']
|
||||
if (Array.isArray(kmCodes)) {
|
||||
codes.push(...kmCodes.filter(Boolean))
|
||||
} else if (typeof kmCodes === 'string') {
|
||||
codes.push(kmCodes)
|
||||
}
|
||||
const extInfo = asArray<Record<string, unknown>>(item['ДопСведТов'])
|
||||
for (const info of extInfo) {
|
||||
const kmCodes = asArray<string>(info['НомСредИдентТов'])
|
||||
codes.push(...kmCodes.filter(Boolean))
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
console.warn('upd-parser: failed to extract codes from a document')
|
||||
}
|
||||
} catch {
|
||||
// XML structure may vary; return what we found
|
||||
}
|
||||
|
||||
return codes
|
||||
}
|
||||
|
||||
function extractField(obj: Record<string, unknown>, ...keys: string[]): string | undefined {
|
||||
function asArray<T>(value: unknown): T[] {
|
||||
if (value == null) return []
|
||||
return Array.isArray(value) ? value as T[] : [value as T]
|
||||
}
|
||||
|
||||
function extractField(obj: Record<string, unknown>, keys: string[]): string | undefined {
|
||||
let current: unknown = obj
|
||||
for (const key of keys) {
|
||||
if (current && typeof current === 'object') {
|
||||
|
||||
Reference in New Issue
Block a user