From 8ff0e90ccf1e5df159740f0fa181c95c351ad53e Mon Sep 17 00:00:00 2001 From: Tiago Date: Thu, 1 Oct 2026 09:43:31 -0300 Subject: [PATCH] Adiciona backup geral no painel master --- .../config/DataSourceProvider.java | 4 + .../master/http/MasterHandlers.java | 141 ++++++++++++++++++ src/main/webapp/js/layout.js | 2 +- src/main/webapp/master/backup.html | 1 + src/main/webapp/master/backup.js | 36 +++++ 5 files changed, 183 insertions(+), 1 deletion(-) create mode 100644 src/main/webapp/master/backup.html create mode 100644 src/main/webapp/master/backup.js diff --git a/src/main/java/com/seconectai/condocontab/config/DataSourceProvider.java b/src/main/java/com/seconectai/condocontab/config/DataSourceProvider.java index 62f3720..4dd7253 100644 --- a/src/main/java/com/seconectai/condocontab/config/DataSourceProvider.java +++ b/src/main/java/com/seconectai/condocontab/config/DataSourceProvider.java @@ -16,6 +16,10 @@ public final class DataSourceProvider { return DriverManager.getConnection(config.masterJdbcUrl(), config.dbUser(), config.dbPassword()); } + public Connection getConnection(String databaseName) throws SQLException { + return DriverManager.getConnection(config.jdbcUrl(databaseName), config.dbUser(), config.dbPassword()); + } + public void testarConexaoMaster() throws SQLException { try (Connection conn = getMasterConnection(); Statement stmt = conn.createStatement()) { diff --git a/src/main/java/com/seconectai/condocontab/master/http/MasterHandlers.java b/src/main/java/com/seconectai/condocontab/master/http/MasterHandlers.java index 988c805..df8b86b 100644 --- a/src/main/java/com/seconectai/condocontab/master/http/MasterHandlers.java +++ b/src/main/java/com/seconectai/condocontab/master/http/MasterHandlers.java @@ -14,11 +14,16 @@ import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpHandler; import java.io.IOException; +import java.io.OutputStream; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.sql.*; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; import java.util.*; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; public final class MasterHandlers implements HttpHandler { private final DataSourceProvider dataSourceProvider; @@ -131,9 +136,145 @@ public final class MasterHandlers implements HttpHandler { listarLogsEnvio(ex); return; } + if (parts.length == 1 && "backup".equals(parts[0])) { + if ("GET".equals(method)) gerarBackup(ex); + else HttpResponses.methodNotAllowed(ex); + return; + } HttpResponses.json(ex, 404, Map.of("erro", "endpoint_nao_encontrado")); } + private void gerarBackup(HttpExchange ex) throws Exception { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss")); + List> bancos = new ArrayList<>(); + try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id,nome,slug,database_name,status FROM empresas WHERE status<>'excluida' ORDER BY id")) { + bancos.add(Map.of("nome", "master", "database_name", currentDatabase(conn), "tipo", "master")); + try (ResultSet rs = ps.executeQuery()) { + while (rs.next()) { + Map b = new LinkedHashMap<>(); + b.put("id", rs.getLong("id")); + b.put("nome", rs.getString("nome")); + b.put("slug", rs.getString("slug")); + b.put("database_name", rs.getString("database_name")); + b.put("status", rs.getString("status")); + b.put("tipo", "cliente"); + bancos.add(b); + } + } + } + String filename = "seconectai-backup-" + timestamp + ".zip"; + ex.getResponseHeaders().set("Content-Type", "application/zip"); + ex.getResponseHeaders().set("Content-Disposition", "attachment; filename=\"" + filename + "\""); + ex.sendResponseHeaders(200, 0); + try (OutputStream out = ex.getResponseBody(); ZipOutputStream zip = new ZipOutputStream(out, StandardCharsets.UTF_8)) { + zip.putNextEntry(new ZipEntry("manifest.json")); + zip.write(backupManifest(timestamp, bancos).getBytes(StandardCharsets.UTF_8)); + zip.closeEntry(); + for (Map banco : bancos) { + String db = String.valueOf(banco.get("database_name")); + String prefixo = "master".equals(banco.get("tipo")) ? "master" : "clientes/" + safeName(String.valueOf(banco.get("slug"))) + "-" + banco.get("id"); + zip.putNextEntry(new ZipEntry(prefixo + "/" + db + ".sql")); + try (Connection conn = dataSourceProvider.getConnection(db)) { + escreverBackupSql(conn, db, zip); + } catch (Exception err) { + zip.write(("-- Falha ao gerar backup de " + db + ": " + err.getMessage() + "\n").getBytes(StandardCharsets.UTF_8)); + } + zip.closeEntry(); + } + } + } + + private String backupManifest(String timestamp, List> bancos) { + StringBuilder sb = new StringBuilder(); + sb.append("{\"gerado_em\":\"").append(timestamp).append("\",\"bancos\":["); + for (int i = 0; i < bancos.size(); i++) { + if (i > 0) sb.append(','); + Map b = bancos.get(i); + sb.append('{'); + int j = 0; + for (var e : b.entrySet()) { + if (j++ > 0) sb.append(','); + sb.append('"').append(jsonEscape(e.getKey())).append("\":\"").append(jsonEscape(String.valueOf(e.getValue()))).append('"'); + } + sb.append('}'); + } + return sb.append("]}").toString(); + } + + private void escreverBackupSql(Connection conn, String databaseName, OutputStream out) throws Exception { + out.write(("-- Backup SeConectai - " + databaseName + "\n").getBytes(StandardCharsets.UTF_8)); + out.write(("SET client_encoding = 'UTF8';\nSET session_replication_role = replica;\n\n").getBytes(StandardCharsets.UTF_8)); + for (String table : tabelasPublicas(conn)) { + out.write(("-- Tabela " + table + "\n").getBytes(StandardCharsets.UTF_8)); + try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT * FROM " + ident(table))) { + ResultSetMetaData md = rs.getMetaData(); + while (rs.next()) { + StringBuilder sql = new StringBuilder("INSERT INTO ").append(ident(table)).append(" ("); + for (int i = 1; i <= md.getColumnCount(); i++) { + if (i > 1) sql.append(','); + sql.append(ident(md.getColumnName(i))); + } + sql.append(") VALUES ("); + for (int i = 1; i <= md.getColumnCount(); i++) { + if (i > 1) sql.append(','); + sql.append(sqlValue(rs.getObject(i))); + } + sql.append(") ON CONFLICT DO NOTHING;\n"); + out.write(sql.toString().getBytes(StandardCharsets.UTF_8)); + } + } + out.write("\n".getBytes(StandardCharsets.UTF_8)); + } + for (String seq : sequenciasPublicas(conn)) { + String sql = "SELECT setval('" + seq.replace("'", "''") + "', COALESCE((SELECT last_value FROM " + ident(seq) + "), 1), true);\n"; + out.write(sql.getBytes(StandardCharsets.UTF_8)); + } + out.write("SET session_replication_role = DEFAULT;\n".getBytes(StandardCharsets.UTF_8)); + } + + private String currentDatabase(Connection conn) throws SQLException { + try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery("SELECT current_database()")) { + rs.next(); + return rs.getString(1); + } + } + + private List tabelasPublicas(Connection conn) throws SQLException { + List tables = new ArrayList<>(); + try (PreparedStatement ps = conn.prepareStatement("SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename"); ResultSet rs = ps.executeQuery()) { + while (rs.next()) tables.add(rs.getString(1)); + } + return tables; + } + + private List sequenciasPublicas(Connection conn) throws SQLException { + List seqs = new ArrayList<>(); + try (PreparedStatement ps = conn.prepareStatement("SELECT sequencename FROM pg_sequences WHERE schemaname='public' ORDER BY sequencename"); ResultSet rs = ps.executeQuery()) { + while (rs.next()) seqs.add(rs.getString(1)); + } + return seqs; + } + + private String ident(String value) { + return "\"" + value.replace("\"", "\"\"") + "\""; + } + + private String sqlValue(Object value) { + if (value == null) return "NULL"; + if (value instanceof Number) return value.toString(); + if (value instanceof Boolean) return (Boolean)value ? "TRUE" : "FALSE"; + if (value instanceof byte[] bytes) return "'\\\\x" + HexFormat.of().formatHex(bytes) + "'"; + return "'" + value.toString().replace("'", "''") + "'"; + } + + private String safeName(String value) { + return value == null ? "cliente" : value.replaceAll("[^A-Za-z0-9._-]", "_"); + } + + private String jsonEscape(String value) { + return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\""); + } + private void listarEvolutionInstancias(HttpExchange ex, long empresaId) throws Exception { try (Connection conn = dataSourceProvider.getMasterConnection(); PreparedStatement ps = conn.prepareStatement("SELECT id, empresa_id, instance_url, numero_whatsapp, status, solicitado_em, aprovado_em, aprovado_por FROM evolution_instancias WHERE empresa_id=? ORDER BY solicitado_em DESC, id DESC")) { ps.setLong(1, empresaId); diff --git a/src/main/webapp/js/layout.js b/src/main/webapp/js/layout.js index 671e091..03bcfe7 100644 --- a/src/main/webapp/js/layout.js +++ b/src/main/webapp/js/layout.js @@ -1 +1 @@ -window.Layout={mount(scope,active){Auth.require(scope);const label=localStorage.getItem('auth_label')||(scope==='master'?'Master':'Escritorio');const master=[['Clientes','/master/clientes.html','clientes'],['Planos','/master/planos.html','planos'],['Financeiro','/master/financeiro.html','financeiro'],['Configuracoes','/master/configuracoes.html','configuracoes']];let cliente=[['Cadastro','/cliente/condominios.html','condominios'],['Lembrete','/cliente/lembretes.html','lembretes'],['Financeiro','/cliente/financeiro.html','financeiro'],['WhatsApp','/cliente/configuracoes.html','configuracoes']];if(scope==='empresa'&&!Auth.isAdminEmpresa())cliente=cliente.filter(x=>x[2]!=='configuracoes');const menu=(scope==='master'?master:cliente).map(([n,h,k])=>`${n}`).join('');document.body.insertAdjacentHTML('afterbegin',``);document.body.classList.add('app');document.querySelector('main')?.classList.add('content')}}; \ No newline at end of file +window.Layout={mount(scope,active){Auth.require(scope);const label=localStorage.getItem('auth_label')||(scope==='master'?'Master':'Escritorio');const master=[['Clientes','/master/clientes.html','clientes'],['Planos','/master/planos.html','planos'],['Financeiro','/master/financeiro.html','financeiro'],['Configuracoes','/master/configuracoes.html','configuracoes'],['Backup','/master/backup.html','backup']];let cliente=[['Cadastro','/cliente/condominios.html','condominios'],['Lembrete','/cliente/lembretes.html','lembretes'],['Financeiro','/cliente/financeiro.html','financeiro'],['WhatsApp','/cliente/configuracoes.html','configuracoes']];if(scope==='empresa'&&!Auth.isAdminEmpresa())cliente=cliente.filter(x=>x[2]!=='configuracoes');const menu=(scope==='master'?master:cliente).map(([n,h,k])=>`${n}`).join('');document.body.insertAdjacentHTML('afterbegin',``);document.body.classList.add('app');document.querySelector('main')?.classList.add('content')}}; diff --git a/src/main/webapp/master/backup.html b/src/main/webapp/master/backup.html new file mode 100644 index 0000000..5355048 --- /dev/null +++ b/src/main/webapp/master/backup.html @@ -0,0 +1 @@ +Backup

Backup

Backup geral

Baixe um arquivo ZIP com o banco Master e os bancos de todos os clientes cadastrados.

diff --git a/src/main/webapp/master/backup.js b/src/main/webapp/master/backup.js new file mode 100644 index 0000000..a4b092b --- /dev/null +++ b/src/main/webapp/master/backup.js @@ -0,0 +1,36 @@ +const btn=document.querySelector('#backup-btn'),statusEl=document.querySelector('#backup-status'); +btn.onclick=async()=>{ + if(!confirm('Gerar backup geral do sistema e de todos os clientes?'))return; + btn.disabled=true; + statusEl.textContent='Gerando backup...'; + try{ + const headers={}; + const token=Auth.token(); + if(token)headers.Authorization=`Bearer ${token}`; + const res=await fetch('/api/master/backup',{headers}); + if(res.status===401){Auth.logout();return} + if(!res.ok){ + const data=await res.json().catch(()=>({})); + throw new Error(data.mensagem||data.erro||`Erro HTTP ${res.status}`); + } + const blob=await res.blob(); + const cd=res.headers.get('Content-Disposition')||''; + const match=cd.match(/filename="?([^"]+)"?/); + const filename=match?match[1]:'seconectai-backup.zip'; + const url=URL.createObjectURL(blob); + const a=document.createElement('a'); + a.href=url; + a.download=filename; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + statusEl.textContent='Backup baixado com sucesso.'; + Util.toast('Backup baixado'); + }catch(e){ + statusEl.textContent=''; + Util.toast(e.message,true); + }finally{ + btn.disabled=false; + } +};