Browse Source

correção de bugs

leonardo 2 tháng trước cách đây
mục cha
commit
62b82151f5

+ 15 - 2
.env.example

@@ -8,6 +8,8 @@ JWT_SECRET=coloque-um-segredo-longo-e-aleatorio-aqui
 JWT_ISSUER=oraculo-api
 JWT_ACCESS_TTL_SECONDS=900
 JWT_REFRESH_TTL_SECONDS=2592000
+# TTL do refresh token quando rememberMe=false/ausente no login (default 1 dia).
+JWT_REFRESH_TTL_SHORT_SECONDS=86400
 
 # API Key alternativa (opcional, deixar vazio para usar só JWT)
 API_KEY=
@@ -19,6 +21,13 @@ DB_USER=usuario
 DB_PASS=senha
 DB_SCHEMA=oraculo
 
+# SSL para conexão MySQL (recomendado em produção). Deixe ambos vazios para desativar
+# SSL (comportamento padrão em dev local). DB_SSL_REJECT_UNAUTHORIZED=true valida o
+# certificado do servidor; DB_SSL_CA aponta para um arquivo de certificado CA (ex.:
+# ./certs/rds-ca.pem) se o servidor usar um certificado não confiável publicamente.
+DB_SSL_REJECT_UNAUTHORIZED=true
+DB_SSL_CA=
+
 # Qdrant (vector DB)
 QDRANT_URL=http://localhost:6333
 QDRANT_COLLECTION=oraculo_docs
@@ -43,8 +52,12 @@ RAG_TOP_K=8
 RAG_CHUNK_SIZE=900
 RAG_CHUNK_OVERLAP=150
 
-# Rate limiting (0 = desativado)
-RATE_LIMIT_PER_MINUTE=0
+# Quantidade de mensagens recentes do histórico incluídas no prompt de rewrite/chat.
+RAG_HISTORY_LIMIT=6
+
+# Rate limiting por IP (0 = desativado). Recomendado manter >0 em produção — sem isso
+# o middleware de rate limit vira um no-op e o /api fica sem proteção contra abuso.
+RATE_LIMIT_PER_MINUTE=60
 
 # Ajuste fino das respostas do LLM
 LLM_TEMPERATURE=0.2

+ 32 - 0
db/migrations/20260707130000_add_unique_login_email_usuarios.cjs

@@ -0,0 +1,32 @@
+exports.up = async function (knex) {
+  const dupLogin = await knex("usuarios")
+    .select("Login")
+    .count({ c: "*" })
+    .groupBy("Login")
+    .having(knex.raw("count(*) > 1"));
+  const dupEmail = await knex("usuarios")
+    .select("Email")
+    .count({ c: "*" })
+    .groupBy("Email")
+    .having(knex.raw("count(*) > 1"));
+
+  if (dupLogin.length || dupEmail.length) {
+    throw new Error(
+      `[migration] Não é possível aplicar UNIQUE: Logins duplicados=${dupLogin
+        .map((d) => d.Login)
+        .join(",")} Emails duplicados=${dupEmail.map((d) => d.Email).join(",")}. Resolva manualmente antes de migrar.`
+    );
+  }
+
+  return knex.schema.alterTable("usuarios", (t) => {
+    t.unique("Login");
+    t.unique("Email");
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("usuarios", (t) => {
+    t.dropUnique("Login");
+    t.dropUnique("Email");
+  });
+};

+ 11 - 0
db/migrations/20260707130100_add_rememberme_to_refresh_tokens.cjs

@@ -0,0 +1,11 @@
+exports.up = function (knex) {
+  return knex.schema.alterTable("refresh_tokens", (t) => {
+    t.boolean("RememberMe").notNullable().defaultTo(false);
+  });
+};
+
+exports.down = function (knex) {
+  return knex.schema.alterTable("refresh_tokens", (t) => {
+    t.dropColumn("RememberMe");
+  });
+};

+ 4 - 2
knexfile.js

@@ -1,4 +1,5 @@
 import "dotenv/config";
+import { config } from "./src/config/index.js";
 
 const base = {
   client: "mysql2",
@@ -8,7 +9,8 @@ const base = {
     port:     process.env.DB_PORT,
     password: process.env.DB_PASS,
     database: process.env.DB_SCHEMA,
-    charset:  "utf8mb4"
+    charset:  "utf8mb4",
+    ssl:      config.db.ssl
   },
   migrations: {
     tableName: "knex_migrations",
@@ -22,5 +24,5 @@ const base = {
 
 export default {
   development: base,
-  production:  { ...base, connection: { ...base.connection, ssl: { rejectUnauthorized: false } } }
+  production:  base
 };

+ 124 - 10
src/chat/chatChain.js

@@ -207,23 +207,133 @@ function buildDefaultOptions(override) {
     : defaults;
 }
 
-async function resolveEmbeddingQuery(searchQuery) {
-  if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query" };
+const HYDE_MIN_QUERY_LENGTH = 15;
+
+function shouldSkipHyde(originalMessage, searchQuery) {
+  const orig = originalMessage.trim().toLowerCase();
+  if (orig.length < HYDE_MIN_QUERY_LENGTH) return true;
+  if (searchQuery.trim().toLowerCase() === orig) return true;
+  return false;
+}
+
+async function resolveEmbeddingQuery(searchQuery, originalMessage) {
+  if (!config.rag.hyde) return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
+  if (shouldSkipHyde(originalMessage, searchQuery)) {
+    return { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: true };
+  }
   const hydeText = await generateHydeDocument(searchQuery);
   return hydeText
-    ? { embeddingQuery: hydeText, embedRole: "passage" }
-    : { embeddingQuery: searchQuery, embedRole: "query" };
+    ? { embeddingQuery: hydeText, embedRole: "passage", hydeSkipped: false }
+    : { embeddingQuery: searchQuery, embedRole: "query", hydeSkipped: false };
+}
+
+const REWRITE_HYDE_SYSTEM_PROMPT = [
+  'Você é um motor de busca interno. Responda APENAS com um objeto JSON no formato',
+  '{"rewrittenQuery": "...", "hydeDocument": "..."}.',
+  '"rewrittenQuery": reescreva a ÚLTIMA pergunta do usuário como uma query autocontida e específica',
+  "para busca em documentos internos da empresa, em linguagem natural (nunca em código, SQL,",
+  "fórmulas ou pseudocódigo), resolvendo pronomes/referências via HISTÓRICO DA CONVERSA quando houver.",
+  '"hydeDocument": um parágrafo curto (3 a 6 frases) no estilo de um trecho real de documentação técnica',
+  "interna (comandos, telas, campos, passos) que responderia a essa pergunta, plausível mesmo sem certeza,",
+  "pois será usado apenas para busca por similaridade, nunca mostrado ao usuário. Responda em português brasileiro.",
+  "Não inclua explicações, texto extra ou markdown fora do JSON."
+].join(" ");
+
+function parseRewriteHydeResult(content, originalQuery) {
+  try {
+    const cleaned = String(content ?? "")
+      .trim()
+      .replace(/^```json\s*/i, "")
+      .replace(/^```\s*/, "")
+      .replace(/```$/, "")
+      .trim();
+    const parsed = JSON.parse(cleaned);
+    const rewrittenQuery = typeof parsed?.rewrittenQuery === "string" && parsed.rewrittenQuery.trim()
+      ? parsed.rewrittenQuery.trim()
+      : originalQuery;
+    const hydeDocument = typeof parsed?.hydeDocument === "string" && parsed.hydeDocument.trim()
+      ? parsed.hydeDocument.trim()
+      : null;
+    return { rewrittenQuery, hydeDocument };
+  } catch {
+    return { rewrittenQuery: originalQuery, hydeDocument: null };
+  }
+}
+
+async function rewriteAndHyde(query, history) {
+  try {
+    const hasHistory = history.length > 0;
+    const messages = [
+      { role: "system", content: REWRITE_HYDE_SYSTEM_PROMPT },
+      ...(hasHistory
+        ? [{ role: "system", content: `HISTÓRICO DA CONVERSA:\n${history.map((m) => `${m.Role}: ${m.Content}`).join("\n")}` }]
+        : []),
+      { role: "user", content: query }
+    ];
+    const { content } = await chatCompletion({ messages, format: "json", options: { temperature: 0.1 } });
+    const result = parseRewriteHydeResult(content, query);
+    if (!result.hydeDocument) console.warn("[chatChain] rewriteAndHyde: hydeDocument ausente/inválido, seguindo sem HyDE");
+    return result;
+  } catch (err) {
+    console.warn("[chatChain] rewriteAndHyde falhou, usando query original sem HyDE:", err.message);
+    return { rewrittenQuery: query, hydeDocument: null };
+  }
+}
+
+async function resolveSearchAndEmbedding(message, history, timer) {
+  const bothEnabled = config.rag.queryRewrite && config.rag.hyde;
+
+  if (bothEnabled) {
+    const { rewrittenQuery, hydeDocument } = await rewriteAndHyde(message, history);
+    timer?.mark("rewrite_hyde");
+    const skip = shouldSkipHyde(message, rewrittenQuery) || !hydeDocument;
+    if (skip) {
+      timer?.mark("hyde_used", "skipped");
+      return { searchQuery: rewrittenQuery, embeddingQuery: rewrittenQuery, embedRole: "query", hydeSkipped: true };
+    }
+    return { searchQuery: rewrittenQuery, embeddingQuery: hydeDocument, embedRole: "passage", hydeSkipped: false };
+  }
+
+  const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
+  timer?.mark("rewrite");
+  const { embeddingQuery, embedRole, hydeSkipped } = await resolveEmbeddingQuery(searchQuery, message);
+  timer?.mark("hyde", hydeSkipped ? "skipped" : undefined);
+  return { searchQuery, embeddingQuery, embedRole, hydeSkipped };
+}
+
+function createTimer() {
+  const t0 = Date.now();
+  let last = t0;
+  const marks = [];
+  return {
+    mark(label, value) {
+      const now = Date.now();
+      marks.push(`${label}=${value !== undefined ? value : `${now - last}ms`}`);
+      last = now;
+    },
+    log(prefix) {
+      marks.push(`total=${Date.now() - t0}ms`);
+      console.log(`[chat:timing] ${prefix} ${marks.join(" ")}`.replace(/\s+/g, " ").trim());
+    }
+  };
 }
 
 export async function answerWithContext({ message, conversationId, userId, options }) {
+  const timer = createTimer();
   const history = await loadHistory(conversationId, userId);
-  const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
-  const { embeddingQuery, embedRole } = await resolveEmbeddingQuery(searchQuery);
+  timer.mark("history");
+  const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
   let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
-  if (config.rag.rerank && hits.length > 1) hits = await rerankHits(searchQuery, hits);
+  timer.mark("search");
+  if (config.rag.rerank && hits.length > 1) {
+    hits = await rerankHits(searchQuery, hits);
+    timer.mark("rerank");
+  }
   const context = buildContextBlock(hits);
   const messages = buildMessages(message, context, history);
   const completion = await chatCompletion({ messages, options: buildDefaultOptions(options) });
+  timer.mark("llm");
+  timer.log(`conversationId=${conversationId ?? "-"}`);
 
   return {
     answer: completion.content,
@@ -232,17 +342,19 @@ export async function answerWithContext({ message, conversationId, userId, optio
 }
 
 export async function answerWithContextStream({ message, conversationId, userId, options, onChunk, onSources, onStatus, signal }) {
+  const timer = createTimer();
   onStatus?.("buscando");
   const history = await loadHistory(conversationId, userId);
-  const searchQuery = config.rag.queryRewrite ? await rewriteQuery(message, history) : message;
-  if (config.rag.hyde) onStatus?.("gerando_hipotese");
-  const { embeddingQuery, embedRole } = await resolveEmbeddingQuery(searchQuery);
+  timer.mark("history");
+  const { searchQuery, embeddingQuery, embedRole } = await resolveSearchAndEmbedding(message, history, timer);
   let hits = await searchDocs({ query: embeddingQuery, topK: config.rag.topK, embedRole });
+  timer.mark("search");
   onStatus?.("encontrou", hits.length);
 
   if (config.rag.rerank && hits.length > 1) {
     onStatus?.("reordenando");
     hits = await rerankHits(searchQuery, hits);
+    timer.mark("rerank");
   }
 
   const sources = hitsToSources(hits);
@@ -251,6 +363,8 @@ export async function answerWithContextStream({ message, conversationId, userId,
   const messages = buildMessages(message, context, history);
   onStatus?.("gerando");
   const completion = await chatCompletionStream({ messages, onChunk, signal, options: buildDefaultOptions(options) });
+  timer.mark("llm");
+  timer.log(`conversationId=${conversationId ?? "-"}`);
 
   return {
     answer: completion.content,

+ 2 - 1
src/config/db.config.js

@@ -10,7 +10,8 @@ export const db = knexLib({
     port: config.db.port,
     password: config.db.password,
     database: config.db.schema,
-    charset: config.db.charset
+    charset: config.db.charset,
+    ssl: config.db.ssl
   }
 });
 

+ 15 - 2
src/config/index.js

@@ -1,7 +1,18 @@
 import dotenv from "dotenv";
+import fs from "node:fs";
 
 dotenv.config();
 
+function resolveDbSsl() {
+  const caPath = process.env.DB_SSL_CA;
+  const rejectUnauthorizedEnv = process.env.DB_SSL_REJECT_UNAUTHORIZED;
+  if (!caPath && rejectUnauthorizedEnv === undefined) return undefined;
+  return {
+    rejectUnauthorized: rejectUnauthorizedEnv !== "false",
+    ...(caPath ? { ca: fs.readFileSync(caPath, "utf8") } : {})
+  };
+}
+
 export const config = {
   port: Number(process.env.PORT ?? 3001),
   corsOrigin: process.env.CORS_ORIGIN ?? "http://localhost:5173",
@@ -15,13 +26,15 @@ export const config = {
     port: process.env.DB_PORT ? Number(process.env.DB_PORT) : undefined,
     password: process.env.DB_PASS,
     schema: process.env.DB_SCHEMA,
-    charset: "utf8mb4"
+    charset: "utf8mb4",
+    ssl: resolveDbSsl()
   },
   jwt: {
     secret: process.env.JWT_SECRET ?? "",
     issuer: process.env.JWT_ISSUER ?? "oraculo-api",
     accessTtlSeconds: Number(process.env.JWT_ACCESS_TTL_SECONDS ?? 900),
-    refreshTtlSeconds: Number(process.env.JWT_REFRESH_TTL_SECONDS ?? 60 * 60 * 24 * 30)
+    refreshTtlSeconds: Number(process.env.JWT_REFRESH_TTL_SECONDS ?? 60 * 60 * 24 * 30),
+    refreshTtlShortSeconds: Number(process.env.JWT_REFRESH_TTL_SHORT_SECONDS ?? 60 * 60 * 24)
   },
   qdrant: {
     url: process.env.QDRANT_URL ?? "http://localhost:6333",

+ 11 - 7
src/controllers/Auth.Controller.js

@@ -7,6 +7,7 @@ import { signAccessToken, generateRefreshToken, hashRefreshToken } from "../serv
 import { config } from "../config/index.js";
 import { formatarUsuario } from "../utils/formatarUsuario.js";
 
+
 const _failedAttempts = new Map();
 const MAX_ATTEMPTS = 5;
 const BLOCK_MS = 15 * 60 * 1000;
@@ -82,7 +83,7 @@ function blockedSecondsRemaining(key) {
 export const AuthController = {
   Login: async function (req, res, next) {
     try {
-      const { login, senha } = req.body;
+      const { login, senha, rememberMe } = req.body;
 
       const loginInformado = login.trim();
       const senhaInformada = senha;
@@ -118,11 +119,13 @@ export const AuthController = {
         accessToken = signAccessToken({ sub: usuario.Id, login: usuario.Login, nivel: usuario.Nivel });
         refreshToken = generateRefreshToken();
         const tokenHash = hashRefreshToken(refreshToken);
-        const expiresAt = new Date(Date.now() + config.jwt.refreshTtlSeconds * 1000);
+        const ttlSeconds = rememberMe ? config.jwt.refreshTtlSeconds : config.jwt.refreshTtlShortSeconds;
+        const expiresAt = new Date(Date.now() + ttlSeconds * 1000);
         await RefreshToken.query().insert({
           UsuarioId: usuario.Id,
           TokenHash: tokenHash,
-          ExpiresAt: expiresAt
+          ExpiresAt: expiresAt,
+          RememberMe: Boolean(rememberMe)
         });
       }
 
@@ -168,6 +171,7 @@ export const AuthController = {
 
       
       const novoRefreshToken = generateRefreshToken();
+      const ttlSeconds = stored.RememberMe ? config.jwt.refreshTtlSeconds : config.jwt.refreshTtlShortSeconds;
       await RefreshToken.query()
         .patch({ RevokedAt: new Date() })
         .where({ Id: stored.Id })
@@ -175,7 +179,8 @@ export const AuthController = {
       await RefreshToken.query().insert({
         UsuarioId: usuario.Id,
         TokenHash: hashRefreshToken(novoRefreshToken),
-        ExpiresAt: new Date(Date.now() + config.jwt.refreshTtlSeconds * 1000)
+        ExpiresAt: new Date(Date.now() + ttlSeconds * 1000),
+        RememberMe: stored.RememberMe
       });
 
       return res.status(200).send({ status: true, accessToken, refreshToken: novoRefreshToken });
@@ -195,14 +200,13 @@ export const AuthController = {
         throw err;
       }
 
-      // Email não tem constraint de unicidade — pega o primeiro match, como no Login
+      
       const usuario = await Usuario.query()
         .where(loginInformado.includes("@") ? { Email: loginInformado } : { Login: loginInformado })
         .orderBy("Id")
         .first();
 
-      // Anti-enumeração: usuário inexistente, inativo ou com solicitação pendente
-      // recebem exatamente a mesma resposta de sucesso
+      
       if (usuario && String(usuario.Status) !== "0") {
         const pendente = await SolicitacaoResetSenha.query().findOne({
           UsuarioId: usuario.Id,

+ 19 - 3
src/controllers/Usuario.Controller.js

@@ -1,6 +1,7 @@
 import bcrypt from "bcryptjs";
 import { AppError, NotFoundError, BadRequestError } from "../shared/errors/index.js";
 import { Usuario } from "../models/Usuario.model.js";
+import { RefreshToken } from "../models/RefreshToken.model.js";
 import { SolicitacaoResetSenha } from "../models/SolicitacaoResetSenha.model.js";
 import { formatarUsuario } from "../utils/formatarUsuario.js";
 
@@ -25,14 +26,19 @@ export const UsuarioController = {
       const { Nome, Login, Email, Senha, Nivel, Setor } = req.body;
 
       const loginTrimado = Login.trim();
-      const existente = await Usuario.query().findOne({ Login: loginTrimado });
-      if (existente) throw new AppError("Login já cadastrado!", 409);
+      const emailTrimado = Email.trim();
+      const [existenteLogin, existenteEmail] = await Promise.all([
+        Usuario.query().findOne({ Login: loginTrimado }),
+        Usuario.query().findOne({ Email: emailTrimado })
+      ]);
+      if (existenteLogin) throw new AppError("Login já cadastrado!", 409);
+      if (existenteEmail) throw new AppError("Email já cadastrado!", 409);
 
       const senhaHash = await bcrypt.hash(Senha, 10);
       const usuario = await Usuario.query().insert({
         Nome: Nome.trim(),
         Login: loginTrimado,
-        Email: Email.trim(),
+        Email: emailTrimado,
         Senha: senhaHash,
         Status: "1",
         Nivel,
@@ -60,6 +66,11 @@ export const UsuarioController = {
       const usuario = await Usuario.query().findById(id);
       if (!usuario) throw new NotFoundError("Usuário não encontrado");
 
+      if (updates.Email !== undefined) {
+        const existenteEmail = await Usuario.query().findOne({ Email: updates.Email }).whereNot("Id", id);
+        if (existenteEmail) throw new AppError("Email já cadastrado!", 409);
+      }
+
       const atualizado = await usuario.$query().patchAndFetch(updates);
 
       return res.status(200).send({ status: true, usuario: formatarUsuario(atualizado) });
@@ -111,6 +122,11 @@ export const UsuarioController = {
       const novaHash = bcrypt.hashSync(senhaNova, 10);
       await usuario.$query().patch({ Senha: novaHash });
 
+      await RefreshToken.query()
+        .patch({ RevokedAt: new Date() })
+        .where({ UsuarioId: id })
+        .whereNull("RevokedAt");
+
       if (callerIsAdmin) {
         await SolicitacaoResetSenha.query()
           .patch({ Status: "atendida", AtendidaPorId: Number(req.user.sub), AtendidaEm: new Date() })

+ 4 - 0
src/middleware/errorHandler.js

@@ -10,6 +10,10 @@ export function errorHandler(err, _req, res, _next) {
     return res.status(err.statusCode).json({ error: err.message, ...(err.details ?? {}) });
   }
 
+  if (err?.code === "ER_DUP_ENTRY" || err?.nativeError?.code === "ER_DUP_ENTRY") {
+    return res.status(409).json({ error: "Registro duplicado (login ou email já cadastrado)." });
+  }
+
   const status = Number(err?.statusCode ?? err?.status ?? 500);
   const message = typeof err?.message === "string" ? err.message : "internal_error";
 

+ 1 - 1
src/middleware/schemas/Search.Schema.js

@@ -2,5 +2,5 @@ import { z } from "zod";
 
 export const searchBodySchema = z.object({
   query: z.string().min(1),
-  topK: z.number().int().positive().optional()
+  topK: z.number().int().positive().max(50).optional()
 });

+ 1 - 1
src/models/Usuario.model.js

@@ -2,7 +2,7 @@ import { Model } from "objection";
 import Unique from "objection-unique";
 import "../config/db.config.js";
 
-const unique = Unique({ fields: ["Login"], identifiers: ["Id"] });
+const unique = Unique({ fields: ["Login", "Email"], identifiers: ["Id"] });
 
 export class Usuario extends unique(Model) {
   static get tableName() {