ApplyLobsterTenantSchemaSync.java 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400
  1. import java.io.IOException;
  2. import java.nio.charset.StandardCharsets;
  3. import java.nio.file.Files;
  4. import java.nio.file.Path;
  5. import java.nio.file.Paths;
  6. import java.sql.*;
  7. import java.util.ArrayList;
  8. import java.util.LinkedHashMap;
  9. import java.util.List;
  10. import java.util.Map;
  11. /**
  12. * Audit + apply lobster/profile DDL gaps on tenant DB(s).
  13. * Usage: java -cp mysql-connector-j.jar ApplyLobsterTenantSchemaSync [tenantId...]
  14. */
  15. public class ApplyLobsterTenantSchemaSync {
  16. private static final String MASTER_HOST = getenv("DB_HOST", "cq-cdb-8fjmemkb.sql.tencentcdb.com");
  17. private static final int MASTER_PORT = Integer.parseInt(getenv("DB_PORT", "27220"));
  18. private static final String MASTER_USER = getenv("DB_USER", "root");
  19. private static final String MASTER_PASSWORD = getenv("DB_PASSWORD", "Ylrz_1q2w3e4r5t6y");
  20. private static final String MASTER_DB = getenv("DB_NAME", "ylrz_saas");
  21. public static void main(String[] args) throws Exception {
  22. List<Long> tenantIds = new ArrayList<>();
  23. if (args.length == 0) {
  24. tenantIds.add(33L);
  25. } else {
  26. for (String arg : args) {
  27. tenantIds.add(Long.parseLong(arg.trim()));
  28. }
  29. }
  30. try (Connection master = open(MASTER_HOST, MASTER_PORT, MASTER_DB, MASTER_USER, MASTER_PASSWORD)) {
  31. for (Long tenantId : tenantIds) {
  32. TenantDb tenant = loadTenant(master, tenantId);
  33. if (tenant == null) {
  34. System.err.println("[SKIP] tenant " + tenantId + " not found");
  35. continue;
  36. }
  37. System.out.println("\n========== tenant " + tenantId + " -> " + tenant.database + " ==========");
  38. try (Connection conn = open(tenant.host, tenant.port, tenant.database, tenant.user, tenant.password)) {
  39. auditBefore(conn);
  40. applyCustomerProfileDomain(conn);
  41. applyLobsterUserProfile(conn);
  42. applySqlFile(conn, "../fs-service/src/main/resources/db/tenant-initTable-migration-lobster-schema-sync.sql");
  43. auditAfter(conn);
  44. }
  45. }
  46. }
  47. }
  48. private static void auditBefore(Connection conn) throws SQLException {
  49. System.out.println("[AUDIT before]");
  50. printCount(conn, "customer_profile_* tables",
  51. "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE 'customer_profile_%'");
  52. printMissingColumns(conn, expectedColumns());
  53. printNullVarCodes(conn);
  54. }
  55. private static void auditAfter(Connection conn) throws SQLException {
  56. System.out.println("[AUDIT after]");
  57. printCount(conn, "customer_profile_* tables",
  58. "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME LIKE 'customer_profile_%'");
  59. printMissingColumns(conn, expectedColumns());
  60. printNullVarCodes(conn);
  61. }
  62. private static Map<String, String[]> expectedColumns() {
  63. Map<String, String[]> m = new LinkedHashMap<>();
  64. m.put("company_workflow_lobster", new String[]{
  65. "execution_mode", "enable_content_personalization", "enable_flow_personalization",
  66. "strict_fixed_workflow", "auto_start_instance_on_tag", "template_kind", "source_template_id"
  67. });
  68. m.put("lobster_user_profile", new String[]{
  69. "channel_type", "lifecycle_stage", "value_score", "total_purchase",
  70. "interaction_count", "last_active_time", "variable_snapshot", "internal_tags"
  71. });
  72. m.put("lobster_workflow_instance", new String[]{
  73. "binding_id", "dept_id", "company_user_id", "qw_user_id", "wx_account_id", "channel_type"
  74. });
  75. m.put("company_tag_template_binding", new String[]{
  76. "exclude_tags", "target_channels", "source_template_id", "task_template_id", "task_name"
  77. });
  78. m.put("company_lobster_profile_config", new String[]{"field_type"});
  79. m.put("lobster_conversation_summary", new String[]{"external_user_id", "channel_type"});
  80. m.put("lobster_user_preference", new String[]{"channel_type"});
  81. m.put("lobster_chat_record", new String[]{"channel_type"});
  82. m.put("lobster_vector_store", new String[]{"category", "vec_key", "text_content", "vector"});
  83. m.put("lobster_sales_corpus", new String[]{"corpus_kind", "industry_type"});
  84. return m;
  85. }
  86. private static void printMissingColumns(Connection conn, Map<String, String[]> expected) throws SQLException {
  87. for (Map.Entry<String, String[]> e : expected.entrySet()) {
  88. if (!tableExists(conn, e.getKey())) {
  89. System.out.println(" MISSING TABLE: " + e.getKey());
  90. continue;
  91. }
  92. List<String> missing = new ArrayList<>();
  93. for (String col : e.getValue()) {
  94. if (!columnExists(conn, e.getKey(), col)) {
  95. missing.add(col);
  96. }
  97. }
  98. if (missing.isEmpty()) {
  99. System.out.println(" OK columns: " + e.getKey());
  100. } else {
  101. System.out.println(" MISSING columns on " + e.getKey() + ": " + String.join(", ", missing));
  102. }
  103. }
  104. }
  105. private static void printNullVarCodes(Connection conn) throws SQLException {
  106. if (!tableExists(conn, "company_workflow_lobster_variable")) {
  107. return;
  108. }
  109. try (Statement st = conn.createStatement();
  110. ResultSet rs = st.executeQuery(
  111. "SELECT COUNT(*) FROM company_workflow_lobster_variable WHERE del_flag=0 AND (var_code IS NULL OR var_code='')")) {
  112. rs.next();
  113. System.out.println(" var_code empty rows (active): " + rs.getInt(1));
  114. }
  115. }
  116. private static void applyCustomerProfileDomain(Connection conn) throws SQLException {
  117. System.out.println("[APPLY] customer profile domain");
  118. try (Statement st = conn.createStatement()) {
  119. st.execute(
  120. "CREATE TABLE IF NOT EXISTS `customer_profile_identity` ("
  121. + "`id` BIGINT NOT NULL AUTO_INCREMENT,"
  122. + "`company_id` BIGINT NOT NULL,"
  123. + "`channel_type` VARCHAR(8) NOT NULL,"
  124. + "`profile_key` VARCHAR(128) NOT NULL,"
  125. + "`customer_id` BIGINT DEFAULT NULL,"
  126. + "`shard_id` INT NOT NULL,"
  127. + "`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,"
  128. + "`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
  129. + "PRIMARY KEY (`id`),"
  130. + "UNIQUE KEY `uk_identity` (`company_id`, `channel_type`, `profile_key`),"
  131. + "KEY `idx_customer` (`company_id`, `customer_id`),"
  132. + "KEY `idx_shard` (`company_id`, `shard_id`)"
  133. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4");
  134. for (int i = 0; i < 32; i++) {
  135. st.execute(projectionSql(i));
  136. st.execute(eventSql(i));
  137. }
  138. }
  139. }
  140. private static void applyLobsterUserProfile(Connection conn) throws SQLException {
  141. System.out.println("[APPLY] lobster_user_profile");
  142. if (!tableExists(conn, "lobster_user_profile")) {
  143. try (Statement st = conn.createStatement()) {
  144. st.execute(
  145. "CREATE TABLE IF NOT EXISTS `lobster_user_profile` ("
  146. + "`id` bigint NOT NULL AUTO_INCREMENT,"
  147. + "`company_id` bigint NOT NULL,"
  148. + "`external_user_id` varchar(128) NOT NULL,"
  149. + "`channel_type` varchar(20) NOT NULL DEFAULT 'QW',"
  150. + "`nickname` varchar(128) DEFAULT NULL,"
  151. + "`lifecycle_stage` varchar(32) DEFAULT 'NEW',"
  152. + "`value_score` int DEFAULT 0,"
  153. + "`total_purchase` decimal(14,2) DEFAULT 0,"
  154. + "`interaction_count` int DEFAULT 0,"
  155. + "`last_active_time` datetime DEFAULT NULL,"
  156. + "`internal_tags` varchar(1024) DEFAULT NULL,"
  157. + "`variable_snapshot` text,"
  158. + "`current_state` varchar(64) DEFAULT NULL,"
  159. + "`create_time` datetime DEFAULT CURRENT_TIMESTAMP,"
  160. + "`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
  161. + "PRIMARY KEY (`id`),"
  162. + "UNIQUE KEY `uk_user` (`company_id`,`channel_type`,`external_user_id`),"
  163. + "KEY `idx_lifecycle` (`company_id`,`lifecycle_stage`)"
  164. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Lobster legacy user profile'");
  165. System.out.println(" created table lobster_user_profile");
  166. }
  167. return;
  168. }
  169. addColumnIfMissing(conn, "lobster_user_profile", "channel_type",
  170. "ALTER TABLE lobster_user_profile ADD COLUMN channel_type varchar(20) NOT NULL DEFAULT 'QW' AFTER external_user_id");
  171. addColumnIfMissing(conn, "lobster_user_profile", "lifecycle_stage",
  172. "ALTER TABLE lobster_user_profile ADD COLUMN lifecycle_stage varchar(32) DEFAULT 'NEW' AFTER nickname");
  173. addColumnIfMissing(conn, "lobster_user_profile", "value_score",
  174. "ALTER TABLE lobster_user_profile ADD COLUMN value_score int DEFAULT 0 AFTER lifecycle_stage");
  175. addColumnIfMissing(conn, "lobster_user_profile", "total_purchase",
  176. "ALTER TABLE lobster_user_profile ADD COLUMN total_purchase decimal(14,2) DEFAULT 0 AFTER value_score");
  177. addColumnIfMissing(conn, "lobster_user_profile", "interaction_count",
  178. "ALTER TABLE lobster_user_profile ADD COLUMN interaction_count int DEFAULT 0 AFTER total_purchase");
  179. addColumnIfMissing(conn, "lobster_user_profile", "last_active_time",
  180. "ALTER TABLE lobster_user_profile ADD COLUMN last_active_time datetime DEFAULT NULL AFTER interaction_count");
  181. addColumnIfMissing(conn, "lobster_user_profile", "internal_tags",
  182. "ALTER TABLE lobster_user_profile ADD COLUMN internal_tags varchar(1024) DEFAULT NULL AFTER last_active_time");
  183. addColumnIfMissing(conn, "lobster_user_profile", "variable_snapshot",
  184. "ALTER TABLE lobster_user_profile ADD COLUMN variable_snapshot text AFTER internal_tags");
  185. addColumnIfMissing(conn, "lobster_user_profile", "current_state",
  186. "ALTER TABLE lobster_user_profile ADD COLUMN current_state varchar(64) DEFAULT NULL AFTER variable_snapshot");
  187. fixUkUserIndex(conn);
  188. fixNullVarCodes(conn);
  189. }
  190. private static void fixUkUserIndex(Connection conn) throws SQLException {
  191. if (!indexExists(conn, "lobster_user_profile", "uk_user")) {
  192. try (Statement st = conn.createStatement()) {
  193. st.execute("ALTER TABLE lobster_user_profile ADD UNIQUE KEY uk_user (company_id, channel_type, external_user_id)");
  194. System.out.println(" added index lobster_user_profile.uk_user");
  195. }
  196. }
  197. }
  198. private static void fixNullVarCodes(Connection conn) throws SQLException {
  199. if (!tableExists(conn, "company_workflow_lobster_variable")) {
  200. return;
  201. }
  202. try (Statement st = conn.createStatement()) {
  203. int updated = st.executeUpdate(
  204. "UPDATE company_workflow_lobster_variable "
  205. + "SET var_code = CONCAT('var_', id) "
  206. + "WHERE del_flag = 0 AND (var_code IS NULL OR TRIM(var_code) = '')");
  207. if (updated > 0) {
  208. System.out.println(" backfilled empty var_code rows: " + updated);
  209. }
  210. }
  211. }
  212. private static void applySqlFile(Connection conn, String relativePath) throws SQLException, IOException {
  213. Path path = Paths.get(relativePath).normalize();
  214. if (!Files.exists(path)) {
  215. path = Paths.get("java", "fs-service", "src", "main", "resources", "db",
  216. "tenant-initTable-migration-lobster-schema-sync.sql");
  217. }
  218. if (!Files.exists(path)) {
  219. System.out.println("[SKIP] schema sync file not found: " + relativePath);
  220. return;
  221. }
  222. System.out.println("[APPLY] " + path.toAbsolutePath());
  223. String sql = Files.readString(path, StandardCharsets.UTF_8);
  224. // Strip line comments; execute as one batch (allowMultiQueries=true on connection)
  225. StringBuilder batch = new StringBuilder();
  226. for (String line : sql.split("\\R")) {
  227. String trimmed = line.trim();
  228. if (trimmed.startsWith("--")) {
  229. continue;
  230. }
  231. batch.append(line).append('\n');
  232. }
  233. try (Statement st = conn.createStatement()) {
  234. st.execute(batch.toString());
  235. } catch (SQLException e) {
  236. String msg = e.getMessage() != null ? e.getMessage() : "";
  237. if (msg.contains("Duplicate column") || msg.contains("already exists")
  238. || msg.contains("Duplicate key name")) {
  239. System.out.println(" [WARN] idempotent skip: " + msg);
  240. return;
  241. }
  242. throw e;
  243. }
  244. }
  245. private static String projectionSql(int shard) {
  246. return "CREATE TABLE IF NOT EXISTS `customer_profile_projection_" + shard + "` ("
  247. + "`id` BIGINT NOT NULL AUTO_INCREMENT,"
  248. + "`company_id` BIGINT NOT NULL,"
  249. + "`channel_type` VARCHAR(8) NOT NULL,"
  250. + "`profile_key` VARCHAR(128) NOT NULL,"
  251. + "`customer_id` BIGINT DEFAULT NULL,"
  252. + "`shard_id` INT NOT NULL,"
  253. + "`nickname` VARCHAR(128) DEFAULT NULL,"
  254. + "`lifecycle_stage` VARCHAR(32) DEFAULT 'NEW',"
  255. + "`value_score` INT DEFAULT 0,"
  256. + "`total_purchase` DECIMAL(14,2) DEFAULT 0,"
  257. + "`interaction_count` INT DEFAULT 0,"
  258. + "`last_active_time` DATETIME DEFAULT NULL,"
  259. + "`internal_tags` VARCHAR(1024) DEFAULT NULL,"
  260. + "`current_state` VARCHAR(64) DEFAULT NULL,"
  261. + "`profile_ext` JSON DEFAULT NULL,"
  262. + "`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,"
  263. + "`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,"
  264. + "PRIMARY KEY (`id`),"
  265. + "UNIQUE KEY `uk_profile` (`company_id`, `channel_type`, `profile_key`),"
  266. + "KEY `idx_customer` (`company_id`, `customer_id`),"
  267. + "KEY `idx_active` (`company_id`, `last_active_time`)"
  268. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
  269. }
  270. private static String eventSql(int shard) {
  271. return "CREATE TABLE IF NOT EXISTS `customer_profile_event_" + shard + "` ("
  272. + "`id` BIGINT NOT NULL AUTO_INCREMENT,"
  273. + "`company_id` BIGINT NOT NULL,"
  274. + "`channel_type` VARCHAR(8) NOT NULL,"
  275. + "`profile_key` VARCHAR(128) NOT NULL,"
  276. + "`source` VARCHAR(64) NOT NULL,"
  277. + "`source_version` VARCHAR(128) NOT NULL,"
  278. + "`event_payload` JSON DEFAULT NULL,"
  279. + "`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP,"
  280. + "PRIMARY KEY (`id`),"
  281. + "UNIQUE KEY `uk_event` (`company_id`, `channel_type`, `profile_key`, `source`, `source_version`),"
  282. + "KEY `idx_profile_time` (`company_id`, `channel_type`, `profile_key`, `create_time`)"
  283. + ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4";
  284. }
  285. private static void addColumnIfMissing(Connection conn, String table, String column, String ddl) throws SQLException {
  286. if (columnExists(conn, table, column)) {
  287. return;
  288. }
  289. try (Statement st = conn.createStatement()) {
  290. st.execute(ddl);
  291. System.out.println(" added column: " + table + "." + column);
  292. }
  293. }
  294. private static void printCount(Connection conn, String label, String sql) throws SQLException {
  295. try (Statement st = conn.createStatement(); ResultSet rs = st.executeQuery(sql)) {
  296. rs.next();
  297. System.out.println(" " + label + ": " + rs.getInt(1));
  298. }
  299. }
  300. private static boolean tableExists(Connection conn, String table) throws SQLException {
  301. try (PreparedStatement ps = conn.prepareStatement(
  302. "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=?")) {
  303. ps.setString(1, table);
  304. try (ResultSet rs = ps.executeQuery()) {
  305. return rs.next() && rs.getInt(1) > 0;
  306. }
  307. }
  308. }
  309. private static boolean columnExists(Connection conn, String table, String column) throws SQLException {
  310. try (PreparedStatement ps = conn.prepareStatement(
  311. "SELECT COUNT(*) FROM information_schema.COLUMNS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND COLUMN_NAME=?")) {
  312. ps.setString(1, table);
  313. ps.setString(2, column);
  314. try (ResultSet rs = ps.executeQuery()) {
  315. return rs.next() && rs.getInt(1) > 0;
  316. }
  317. }
  318. }
  319. private static boolean indexExists(Connection conn, String table, String index) throws SQLException {
  320. try (PreparedStatement ps = conn.prepareStatement(
  321. "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME=? AND INDEX_NAME=?")) {
  322. ps.setString(1, table);
  323. ps.setString(2, index);
  324. try (ResultSet rs = ps.executeQuery()) {
  325. return rs.next() && rs.getInt(1) > 0;
  326. }
  327. }
  328. }
  329. private static TenantDb loadTenant(Connection master, long tenantId) throws SQLException {
  330. try (PreparedStatement ps = master.prepareStatement(
  331. "SELECT db_url, db_account, db_pwd FROM tenant_info WHERE id=? AND status=1 LIMIT 1")) {
  332. ps.setLong(1, tenantId);
  333. try (ResultSet rs = ps.executeQuery()) {
  334. if (!rs.next()) {
  335. return null;
  336. }
  337. return parseJdbcUrl(rs.getString("db_url"), rs.getString("db_account"), rs.getString("db_pwd"));
  338. }
  339. }
  340. }
  341. private static TenantDb parseJdbcUrl(String jdbcUrl, String user, String password) {
  342. String body = jdbcUrl.substring("jdbc:mysql://".length());
  343. int slash = body.indexOf('/');
  344. String hostPort = body.substring(0, slash);
  345. String rest = body.substring(slash + 1);
  346. int q = rest.indexOf('?');
  347. String database = q >= 0 ? rest.substring(0, q) : rest;
  348. int colon = hostPort.indexOf(':');
  349. TenantDb t = new TenantDb();
  350. t.host = colon >= 0 ? hostPort.substring(0, colon) : hostPort;
  351. t.port = colon >= 0 ? Integer.parseInt(hostPort.substring(colon + 1)) : 3306;
  352. t.database = database;
  353. t.user = user != null ? user : MASTER_USER;
  354. t.password = password != null ? password : MASTER_PASSWORD;
  355. return t;
  356. }
  357. private static Connection open(String host, int port, String database, String user, String password)
  358. throws SQLException {
  359. String url = "jdbc:mysql://" + host + ":" + port + "/" + database
  360. + "?useUnicode=true&characterEncoding=utf8&serverTimezone=GMT%2B8&allowMultiQueries=true";
  361. return DriverManager.getConnection(url, user, password);
  362. }
  363. private static String getenv(String key, String defaultValue) {
  364. String v = System.getenv(key);
  365. return v != null && !v.isEmpty() ? v : defaultValue;
  366. }
  367. private static final class TenantDb {
  368. String host;
  369. int port;
  370. String database;
  371. String user;
  372. String password;
  373. }
  374. }