439 lines
15 KiB
JavaScript
439 lines
15 KiB
JavaScript
const mongoose = require("mongoose")
|
||
const router = require('express').Router()
|
||
const multer = require('multer')
|
||
const { MasterModel } = require('./model/master')
|
||
const { OrderModel } = require('./model/order')
|
||
const { OrderCarImgModel } = require('./model/order.car-img')
|
||
const { orderStatus } = require('./model/const')
|
||
const { getGigaToken, getSystemPrompt, getGigaChatModel } = require('./get-token')
|
||
|
||
const isValidPhoneNumber = (value) => /^(\+)?\d{9,15}/.test(value)
|
||
const isValidCarNumber = (value) => /^[авекмнорстух][0-9]{3}[авекмнорстух]{2}[0-9]{2,3}$/i.test(value)
|
||
const isValidCarBodyType = (value) => typeof value === 'number' && value > 0 && value < 100
|
||
const isValidCarColor = (value) => {
|
||
if (typeof value === 'number') {
|
||
return value >= 0 && value <= 7
|
||
} else if (typeof value === 'string') {
|
||
return /^[#a-z0-9а-я-\s,.()]+$/i.test(value)
|
||
}
|
||
return false
|
||
}
|
||
const isValidISODate = (value) => /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:.\d{1,3})?Z$/.test(value)
|
||
|
||
const latitudeRe = /^(-?[1-8]?\d(?:\.\d{1,18})?|90(?:\.0{1,18})?)$/
|
||
const longitudeRe = /^(-?(?:1[0-7]|[1-9])?\d(?:\.\d{1,18})?|180(?:\.0{1,18})?)$/
|
||
const addressRe = /^[а-я0-9\s,.'-/()]*$/i
|
||
const isValidLocation = (value) => {
|
||
if (value.length > 200) {
|
||
return false
|
||
}
|
||
|
||
const [coordinates, address] = value.split(' ')
|
||
const [latitude, longitude] = coordinates.split(',')
|
||
return latitudeRe.test(latitude) && longitudeRe.test(longitude) && addressRe.test(address)
|
||
}
|
||
|
||
const isValidOrderStatus = (value) => Object.values(orderStatus).includes(value)
|
||
const isValidOrderNotes = (value) => value.length < 500
|
||
|
||
const allowedMimeTypes = ['image/jpeg', 'image/png']
|
||
const sizeLimitInMegaBytes = 15
|
||
|
||
const VALIDATION_MESSAGES = {
|
||
order: {
|
||
notFound: 'Order not found'
|
||
},
|
||
orderId: {
|
||
invalid: 'Valid order ID is required',
|
||
},
|
||
orderStatus: {
|
||
invalid: 'Invalid order status'
|
||
},
|
||
orderNotes: {
|
||
invalid: 'Invalid order notes'
|
||
},
|
||
master: {
|
||
notFound: 'Master not found'
|
||
},
|
||
masterId: {
|
||
invalid: 'Invalid master ID',
|
||
},
|
||
phoneNumber: {
|
||
required: 'Phone number is required',
|
||
invalid: 'Invalid phone number'
|
||
},
|
||
carNumber: {
|
||
required: 'Car number is required',
|
||
invalid: 'Invalid car number'
|
||
},
|
||
carBody: {
|
||
required: 'Car body type is required',
|
||
invalid: 'Invalid car body type'
|
||
},
|
||
carColor: {
|
||
invalid: 'Invalid car color'
|
||
},
|
||
carImg: {
|
||
required: 'Car image file is required',
|
||
invalid: {
|
||
type: `Invalid car image file type. Allowed types: ${allowedMimeTypes}`,
|
||
size: `Invalid car image file size. Limit is ${sizeLimitInMegaBytes}MB`
|
||
}
|
||
},
|
||
washingBegin: {
|
||
required: 'Begin time of washing is required',
|
||
invalid: 'Invalid begin time of washing'
|
||
},
|
||
washingEnd: {
|
||
required: 'End time of washing is required',
|
||
invalid: 'Invalid end time of washing'
|
||
},
|
||
washingLocation: {
|
||
required: 'Location of washing is required',
|
||
invalid: 'Invalid location of washing'
|
||
},
|
||
}
|
||
|
||
router.post('/create', async (req, res, next) => {
|
||
const bodyErrors = []
|
||
|
||
const { customer } = req.body
|
||
if (!customer.phone) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.phoneNumber.required)
|
||
} else if (!isValidPhoneNumber(customer.phone)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.phoneNumber.invalid)
|
||
}
|
||
|
||
const { car } = req.body
|
||
if (!car.number) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.carNumber.required)
|
||
} else if (!isValidCarNumber(car.number)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.carNumber.invalid)
|
||
}
|
||
if (!car.body) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.carBody.required)
|
||
} else if (!isValidCarBodyType(car.body)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.carBody.invalid)
|
||
}
|
||
if (!isValidCarColor(car.color)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.carColor.invalid)
|
||
}
|
||
|
||
const { washing } = req.body
|
||
if (!washing.begin) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.washingBegin.required)
|
||
} else if (!isValidISODate(washing.begin)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.washingBegin.invalid)
|
||
}
|
||
if (!washing.end) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.washingEnd.required)
|
||
} else if (!isValidISODate(washing.end)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.washingEnd.invalid)
|
||
}
|
||
if (!washing.location) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.washingLocation.required)
|
||
} else if (!isValidLocation(washing.location)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.washingLocation.invalid)
|
||
}
|
||
|
||
if (bodyErrors.length > 0) {
|
||
throw new Error(bodyErrors.join(', '))
|
||
}
|
||
|
||
try {
|
||
const order = await OrderModel.create({
|
||
phone: customer.phone,
|
||
carNumber: car.number,
|
||
carBody: car.body,
|
||
carColor: car.color,
|
||
startWashTime: washing.begin,
|
||
endWashTime: washing.end,
|
||
location: washing.location,
|
||
status: orderStatus.PROGRESS,
|
||
notes: '',
|
||
created: new Date().toISOString(),
|
||
})
|
||
|
||
res.status(200).send({ success: true, body: order })
|
||
|
||
} catch (error) {
|
||
next(error)
|
||
}
|
||
})
|
||
|
||
router.get('/:id', async (req, res, next) => {
|
||
const { id } = req.params
|
||
|
||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||
throw new Error(VALIDATION_MESSAGES.orderId.invalid)
|
||
}
|
||
|
||
try {
|
||
const order = await OrderModel.findById(id)
|
||
|
||
if (!order) {
|
||
throw new Error(VALIDATION_MESSAGES.order.notFound)
|
||
}
|
||
|
||
const imgProps = await OrderCarImgModel.findOne({ orderId: order.id })
|
||
|
||
res.status(200).send({ success: true, body: { ...order.toObject(), ...imgProps?.toObject() } })
|
||
} catch (error) {
|
||
next(error)
|
||
}
|
||
})
|
||
|
||
router.patch('/:id', async (req, res, next) => {
|
||
const { id } = req.params
|
||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||
throw new Error(VALIDATION_MESSAGES.orderId.invalid)
|
||
}
|
||
|
||
const bodyErrors = []
|
||
|
||
const { status } = req.body
|
||
if (status) {
|
||
if (!isValidOrderStatus(status)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.orderStatus.invalid)
|
||
}
|
||
}
|
||
|
||
const { master: masterId } = req.body
|
||
if (masterId) {
|
||
if (!mongoose.Types.ObjectId.isValid(masterId)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.masterId.invalid)
|
||
} else {
|
||
try {
|
||
const master = await MasterModel.findById(masterId)
|
||
if (!master) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.master.notFound)
|
||
}
|
||
} catch (error) {
|
||
next(error)
|
||
}
|
||
}
|
||
}
|
||
|
||
const { notes } = req.body
|
||
if (notes) {
|
||
if (!isValidOrderNotes(notes)) {
|
||
bodyErrors.push(VALIDATION_MESSAGES.orderNotes.invalid)
|
||
}
|
||
}
|
||
|
||
if (bodyErrors.length > 0) {
|
||
throw new Error(bodyErrors.join(', '))
|
||
}
|
||
|
||
try {
|
||
const updateData = {}
|
||
if (status) {
|
||
updateData.status = status
|
||
}
|
||
if (masterId) {
|
||
updateData.master = masterId
|
||
}
|
||
if (notes) {
|
||
updateData.notes = notes
|
||
}
|
||
updateData.updated = new Date().toISOString()
|
||
|
||
const order = await OrderModel.findByIdAndUpdate(
|
||
id,
|
||
updateData,
|
||
{ new: true }
|
||
)
|
||
if (!order) {
|
||
throw new Error(VALIDATION_MESSAGES.order.notFound)
|
||
}
|
||
|
||
res.status(200).send({ success: true, body: order })
|
||
} catch (error) {
|
||
next(error)
|
||
}
|
||
})
|
||
|
||
router.delete('/:id', async (req, res, next) => {
|
||
const { id } = req.params
|
||
if (!mongoose.Types.ObjectId.isValid(id)) {
|
||
throw new Error(VALIDATION_MESSAGES.orderId.invalid)
|
||
}
|
||
|
||
try {
|
||
const order = await OrderModel.findByIdAndDelete(id, {
|
||
new: true,
|
||
})
|
||
if (!order) {
|
||
throw new Error(VALIDATION_MESSAGES.order.notFound)
|
||
}
|
||
res.status(200).send({ success: true, body: order })
|
||
} catch (error) {
|
||
next(error)
|
||
}
|
||
})
|
||
|
||
const storage = multer.memoryStorage()
|
||
const upload = multer({
|
||
storage: storage,
|
||
limits: { fileSize: sizeLimitInMegaBytes * 1024 * 1024 },
|
||
fileFilter: (req, file, cb) => {
|
||
if (allowedMimeTypes.includes(file.mimetype)) {
|
||
cb(null, true)
|
||
} else {
|
||
cb(new Error(VALIDATION_MESSAGES.carImg.invalid.type), false)
|
||
}
|
||
}
|
||
})
|
||
|
||
const { v4: uuidv4 } = require("uuid")
|
||
const axios = require('axios')
|
||
|
||
const GIGA_CHAT_OAUTH = 'https://ngw.devices.sberbank.ru:9443/api/v2/oauth'
|
||
const GIGA_CHAT_API = 'https://gigachat.devices.sberbank.ru/api/v1'
|
||
|
||
const getToken = async (req, res) => {
|
||
const gigaToken = await getGigaToken()
|
||
|
||
const rqUID = uuidv4()
|
||
const body = new URLSearchParams({
|
||
scope: "GIGACHAT_API_PERS",
|
||
})
|
||
|
||
const response = await fetch(GIGA_CHAT_OAUTH, {
|
||
method: "POST",
|
||
headers: {
|
||
Authorization: `Basic ${gigaToken}`,
|
||
"Content-Type": "application/x-www-form-urlencoded",
|
||
Accept: "application/json",
|
||
RqUID: rqUID,
|
||
},
|
||
body,
|
||
})
|
||
|
||
if (!response.ok) {
|
||
const errorData = await response.json()
|
||
console.error("Ошибка запроса: ", errorData)
|
||
return res.status(response.status).json(errorData)
|
||
}
|
||
|
||
return await response.json()
|
||
}
|
||
|
||
const uploadImage = async (file, accessToken) => {
|
||
const formData = new FormData()
|
||
const blob = new Blob([file.buffer], { type: file.mimetype })
|
||
formData.append('file', blob, file.originalname)
|
||
formData.append('purpose', 'general')
|
||
|
||
const config = {
|
||
maxBodyLength: Infinity,
|
||
headers: {
|
||
'Content-Type': 'multipart/form-data',
|
||
'Accept': 'application/json',
|
||
'Authorization': `Bearer ${accessToken}`
|
||
}
|
||
}
|
||
|
||
try {
|
||
const response = await axios.post(`${GIGA_CHAT_API}/files`, formData, config)
|
||
return response.data.id
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
}
|
||
|
||
const analyzeImage = async (fileId, token) => {
|
||
const response = await fetch(`${GIGA_CHAT_API}/chat/completions`, {
|
||
method: "POST",
|
||
headers: {
|
||
"Content-Type": "application/json",
|
||
Accept: "application/json",
|
||
Authorization: `Bearer ${token}`,
|
||
},
|
||
body: JSON.stringify({
|
||
model: (await getGigaChatModel()) ?? "GigaChat-Max",
|
||
stream: false,
|
||
update_interval: 0,
|
||
messages: [
|
||
{
|
||
role: "system",
|
||
content: (await getSystemPrompt()) ?? `Ты эксперт по оценке степени загрязнения автомобилей. Твоя задача — анализировать фотографии машин и определять степень их загрязнения. ВАЖНО: Твой ответ ДОЛЖЕН быть СТРОГО в формате JSON и содержать ТОЛЬКО следующие поля: { "value": число от 0 до 10 (целое или с одним знаком после запятой), "description": "текстовое описание на русском языке" } Правила: 1. Поле "value": - Должно быть числом от 0 до 10 - 0 = машина абсолютно чистая - 10 = машина максимально грязная 2. Поле "description": - Должно содержать 2-3 предложения на русском языке - Обязательно указать конкретные признаки загрязнения - Объяснить почему выставлен именно такой балл НЕ ДОБАВЛЯЙ никаких дополнительных полей или комментариев вне JSON структуры. НЕ ИСПОЛЬЗУЙ markdown форматирование. ОТВЕТ ДОЛЖЕН БЫТЬ ВАЛИДНЫМ JSON. Если на фотографии нет одной машины, то оценка должна быть 0 и в описании должно быть указано, почему не удалось оценить.`,
|
||
},
|
||
{
|
||
role: "user",
|
||
content: 'Дай оценку для приложенного файла изображения согласно структуре, ответ должен быть на русском языке',
|
||
attachments: [fileId],
|
||
},
|
||
],
|
||
}),
|
||
})
|
||
|
||
const data = await response.json()
|
||
console.log(data)
|
||
|
||
try {
|
||
return JSON.parse(data.choices[0].message.content)
|
||
} catch (error) {
|
||
console.error(error)
|
||
return { description: data.choices[0].message.content }
|
||
}
|
||
}
|
||
|
||
const convertFileToBase64 = (file) => {
|
||
const base64Image = file.buffer.toString('base64')
|
||
return `data:${file.mimetype};base64,${base64Image}`
|
||
}
|
||
|
||
process.env["NODE_TLS_REJECT_UNAUTHORIZED"] = "0"
|
||
|
||
router.post('/:id/upload-car-img', upload.single('file'), async (req, res) => {
|
||
const { id: orderId } = req.params
|
||
if (!mongoose.Types.ObjectId.isValid(orderId)) {
|
||
throw new Error(VALIDATION_MESSAGES.orderId.invalid)
|
||
}
|
||
const order = await OrderModel.findById(orderId)
|
||
if (!order) {
|
||
throw new Error(VALIDATION_MESSAGES.order.notFound)
|
||
}
|
||
|
||
if (!req.file) {
|
||
throw new Error(VALIDATION_MESSAGES.carImg.required)
|
||
}
|
||
|
||
try {
|
||
await OrderCarImgModel.deleteMany({ orderId })
|
||
|
||
const { access_token } = await getToken(req, res)
|
||
|
||
const fileId = await uploadImage(req.file, access_token)
|
||
const { value, description } = await analyzeImage(fileId, access_token) ?? {}
|
||
|
||
const orderCarImg = await OrderCarImgModel.create({
|
||
image: convertFileToBase64(req.file),
|
||
imageRating: value,
|
||
imageDescription: description,
|
||
orderId: order.id,
|
||
created: new Date().toISOString(),
|
||
})
|
||
|
||
res.status(200).send({ success: true, body: orderCarImg })
|
||
} catch (error) {
|
||
console.error(error)
|
||
}
|
||
})
|
||
|
||
router.use((err, req, res, next) => {
|
||
if (err instanceof multer.MulterError) {
|
||
switch (err.message) {
|
||
case 'File too large':
|
||
return res.status(400).json({ success: false, error: VALIDATION_MESSAGES.carImg.invalid.size })
|
||
default:
|
||
return res.status(400).json({ success: false, error: err.message })
|
||
}
|
||
}
|
||
|
||
throw new Error(err.message)
|
||
})
|
||
|
||
module.exports = router
|