Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,9 @@ chrome-profile/

# Database files
db/
!src/main/java/db/
!src/main/java/db/migration/
!src/main/java/db/migration/*.java
!src/main/resources/db/
!src/main/resources/db/migration/
!src/main/resources/db/migration/*.sql
Expand Down
2 changes: 1 addition & 1 deletion doc/开发指南.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ MyBatis-Plus 会根据 mapper 和 entity 访问数据库。数据库文件包含

- `db/` 和 `*.db` 已被 `.gitignore` 忽略。
- 修改表结构前请备份本地数据库。
- 前端有脚本 `front/scripts/migrate-sort-order.mjs` 用于特定字段迁移
- 历史脚本 `front/scripts/migrate-sort-order.mjs` 已安全禁用;它曾用 `sql.js` 整文件覆盖 SQLite,无法保护 WAL 中的并发写入。所有 Schema 变更必须通过后端 Flyway migration,并先在隔离副本演练

## 自动化执行层

Expand Down
100 changes: 8 additions & 92 deletions front/scripts/migrate-sort-order.mjs
Original file line number Diff line number Diff line change
@@ -1,92 +1,8 @@
// Migrate boss_option: add sort_order column and set city display order
// Usage: pnpm exec node scripts/migrate-sort-order.mjs

import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
import initSqlJs from 'sql.js';
import { createRequire } from 'module';

const log = (...args) => console.log('[migrate-sort-order]', ...args);

async function main() {
try {
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const projectRoot = path.resolve(__dirname, '..', '..');
const dbPath = path.resolve(projectRoot, 'db', 'getjobs.db');

if (!fs.existsSync(dbPath)) {
throw new Error(`Database file not found: ${dbPath}`);
}

const require = createRequire(import.meta.url);
const wasmDir = path.dirname(require.resolve('sql.js/dist/sql-wasm.wasm'));
const SQL = await initSqlJs({ locateFile: (file) => path.join(wasmDir, file) });

const fileBuffer = fs.readFileSync(dbPath);
const u8 = new Uint8Array(fileBuffer);
const db = new SQL.Database(u8);

const hasColumn = (table, column) => {
const res = db.exec(`PRAGMA table_info(${table});`);
if (!res || res.length === 0) return false;
const names = res[0].values.map((row) => String(row[1]).toLowerCase());
return names.includes(String(column).toLowerCase());
};

// Ensure boss_option exists
const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='boss_option';");
if (!tables || tables.length === 0 || tables[0].values.length === 0) {
throw new Error("Table 'boss_option' not found in database.");
}

// Add sort_order column if missing
if (!hasColumn('boss_option', 'sort_order')) {
log('Adding column sort_order to boss_option ...');
db.exec('ALTER TABLE boss_option ADD COLUMN sort_order INTEGER;');
} else {
log('Column sort_order already exists.');
}

// Reset sort_order for cities
db.exec("UPDATE boss_option SET sort_order = NULL WHERE type='city';");

// Define preferred city display order (as requested)
const cityOrder = [
'全国', '北京', '上海', '广州', '深圳',
'杭州', '天津', '西安', '苏州', '武汉',
'厦门', '长沙', '成都', '郑州', '重庆'
];

const stmt = db.prepare("UPDATE boss_option SET sort_order = ? WHERE type='city' AND name = ?;");
let updated = 0;
cityOrder.forEach((name, idx) => {
stmt.run([idx + 1, name]);
updated += 1;
});
stmt.free();

log(`Applied sort_order to ${updated} city rows.`);

// Persist changes back to file
const out = db.export();
fs.writeFileSync(dbPath, Buffer.from(out));
log('Database updated:', dbPath);

// Optional: verify a few rows
const verify = db.exec("SELECT id, name, sort_order FROM boss_option WHERE type='city' AND sort_order IS NOT NULL ORDER BY sort_order ASC LIMIT 10;");
if (verify && verify.length > 0) {
const rows = verify[0].values.map((r) => ({ id: r[0], name: r[1], sort_order: r[2] }));
log('Top cities after migration:', rows);
}

db.close();
log('Migration completed successfully.');
} catch (err) {
console.error('[migrate-sort-order] Migration failed:', err);
process.exitCode = 1;
}
}

await main();
// Historical migration entrypoint retained only to give old instructions a safe failure.
// Schema changes are managed by backend Flyway migrations. Never overwrite a live SQLite file with sql.js.

console.error(
'[migrate-sort-order] 已禁用:数据库结构现在只允许由后端 Flyway 管理。' +
'请停止服务、备份数据库,并通过正式 migration/rehearsal 流程升级。'
)
process.exitCode = 1
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ public BossService.PagedResult list(
}

/**
* 刷新 boss_data(列顺序检查 + VACUUM)
* 刷新 Boss 数据视图;只重新读取统计,不执行 Schema 或数据库维护操作。
*/
@GetMapping("/reload")
public Map<String, Object> reload() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,13 @@
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.DependsOn;
import org.springframework.stereotype.Component;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;

import javax.sql.DataSource;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.sql.Connection;
import java.sql.Statement;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.ArrayList;
Expand All @@ -37,13 +36,11 @@ public class ZhilianOptionInitializer implements CommandLineRunner {
private static final ObjectMapper MAPPER = new ObjectMapper();
private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(12);

private final DataSource dataSource;
private final ZhilianOptionMapper zhilianOptionMapper;
private final PlatformTransactionManager transactionManager;

@Override
public void run(String... args) {
ensureTableExists();

List<OptionSeed> options;
try {
options = loadOfficialOptions();
Expand All @@ -53,24 +50,15 @@ public void run(String... args) {
log.warn("智联官方筛选项同步失败,使用内置兜底选项:{}", e.getMessage());
}

replaceCityAndSalaryOptions(options);
replaceCityAndSalaryOptionsAtomically(options);
}

private void ensureTableExists() {
String ddl = "CREATE TABLE IF NOT EXISTS zhilian_option (" +
" id INTEGER PRIMARY KEY AUTOINCREMENT," +
" type VARCHAR(50)," +
" name VARCHAR(100)," +
" code VARCHAR(100)," +
" sort_order INTEGER," +
" created_at DATETIME," +
" updated_at DATETIME" +
")";
try (Connection conn = dataSource.getConnection(); Statement stmt = conn.createStatement()) {
stmt.execute(ddl);
log.info("确保 zhilian_option 表已存在");
} catch (Exception e) {
log.warn("创建 zhilian_option 表失败: {}", e.getMessage());
void replaceCityAndSalaryOptionsAtomically(List<OptionSeed> options) {
try {
new TransactionTemplate(transactionManager)
.executeWithoutResult(status -> replaceCityAndSalaryOptions(options));
} catch (RuntimeException e) {
throw new IllegalStateException("刷新智联城市/薪资筛选项失败,已回滚", e);
}
}

Expand Down Expand Up @@ -104,25 +92,21 @@ private List<OptionSeed> loadOfficialOptions() throws Exception {

private void replaceCityAndSalaryOptions(List<OptionSeed> options) {
LocalDateTime now = LocalDateTime.now();
try {
zhilianOptionMapper.delete(
new QueryWrapper<ZhilianOptionEntity>()
.in("type", List.of("city", "salary"))
);
for (OptionSeed option : options) {
ZhilianOptionEntity entity = new ZhilianOptionEntity();
entity.setType(option.type());
entity.setName(option.name());
entity.setCode(option.code());
entity.setSortOrder(option.sortOrder());
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
zhilianOptionMapper.insert(entity);
}
log.info("智联城市/薪资筛选项刷新完成:{} 条", options.size());
} catch (Exception e) {
log.warn("刷新智联城市/薪资筛选项失败: {}", e.getMessage());
zhilianOptionMapper.delete(
new QueryWrapper<ZhilianOptionEntity>()
.in("type", List.of("city", "salary"))
);
for (OptionSeed option : options) {
ZhilianOptionEntity entity = new ZhilianOptionEntity();
entity.setType(option.type());
entity.setName(option.name());
entity.setCode(option.code());
entity.setSortOrder(option.sortOrder());
entity.setCreatedAt(now);
entity.setUpdatedAt(now);
zhilianOptionMapper.insert(entity);
}
log.info("智联城市/薪资筛选项刷新完成:{} 条", options.size());
}

static List<OptionSeed> buildOptionsFromOfficialBaseData(JsonNode data) {
Expand Down
120 changes: 1 addition & 119 deletions src/main/java/com/getjobs/application/service/BossService.java
Original file line number Diff line number Diff line change
Expand Up @@ -527,119 +527,6 @@ public List<BlacklistEntity> getAllBlacklist() {

// ==================== boss_data(岗位数据)相关方法 ====================

/**
* 确保 boss_data 表的列顺序以 encrypt_id、encrypt_user_id 开头。
* 若不满足,则进行一次在线迁移:创建新表、复制数据、替换旧表。
* 该迁移会重建 boss_data,比普通补列风险更高,暂时保留在业务刷新入口中按需执行。
*/
public void ensureBossDataColumnOrder() {
java.sql.Connection conn = null;
try {
conn = dataSource.getConnection();
try (java.sql.Statement stmt = conn.createStatement()) {
java.util.List<String> cols = new java.util.ArrayList<>();
try (java.sql.ResultSet rs = stmt.executeQuery("PRAGMA table_info('boss_data')")) {
while (rs.next()) {
cols.add(rs.getString("name"));
}
}
if (cols.isEmpty()) return; // 表不存在或无列
boolean needMigrate = true;
if (cols.size() >= 3) {
String c0 = cols.get(0) == null ? "" : cols.get(0).toLowerCase();
String c1 = cols.get(1) == null ? "" : cols.get(1).toLowerCase();
String c2 = cols.get(2) == null ? "" : cols.get(2).toLowerCase();
String c3 = cols.size() > 3 && cols.get(3) != null ? cols.get(3).toLowerCase() : "";
// 允许第一列是 id 或 encrypt_id;档案模式下 profile_id 可以紧跟 id。
if ("id".equals(c0) && "encrypt_id".equals(c1) && "encrypt_user_id".equals(c2)) {
needMigrate = false;
} else if ("id".equals(c0) && "profile_id".equals(c1) && "encrypt_id".equals(c2) && "encrypt_user_id".equals(c3)) {
needMigrate = false;
} else if ("encrypt_id".equals(c0) && "encrypt_user_id".equals(c1)) {
needMigrate = false;
}
}
if (!needMigrate) return;

stmt.execute("BEGIN TRANSACTION");
// 新表:将 encrypt_id、encrypt_user_id 移到最前(紧随 id)
String createSql = "CREATE TABLE boss_data_new (" +
"id INTEGER PRIMARY KEY AUTOINCREMENT, " +
"profile_id INTEGER, " +
"encrypt_id TEXT, " +
"encrypt_user_id TEXT, " +
"company_name TEXT, " +
"job_name TEXT, " +
"salary TEXT, " +
"salary_min_k REAL, " +
"salary_max_k REAL, " +
"salary_median_k REAL, " +
"salary_months INTEGER, " +
"location TEXT, " +
"experience TEXT, " +
"degree TEXT, " +
"hr_name TEXT, " +
"hr_position TEXT, " +
"hr_active_status TEXT, " +
"delivery_status TEXT, " +
"failure_type TEXT, " +
"failure_reason TEXT, " +
"job_description TEXT, " +
"job_url TEXT, " +
"recruitment_status TEXT, " +
"company_address TEXT, " +
"industry TEXT, " +
"introduce TEXT, " +
"financing_stage TEXT, " +
"company_scale TEXT, " +
"source_keyword TEXT, " +
"scan_run_id TEXT, " +
"ai_score INTEGER, " +
"ai_decision TEXT, " +
"ai_reason TEXT, " +
"priority_company INTEGER DEFAULT 0, " +
"created_at TEXT, " +
"updated_at TEXT" +
")";
stmt.execute(createSql);

String copySql = "INSERT INTO boss_data_new (" +
"id, profile_id, encrypt_id, encrypt_user_id, company_name, job_name, salary, salary_min_k, salary_max_k, salary_median_k, salary_months, location, experience, degree, " +
"hr_name, hr_position, hr_active_status, delivery_status, failure_type, failure_reason, job_description, job_url, recruitment_status, " +
"company_address, industry, introduce, financing_stage, company_scale, source_keyword, scan_run_id, ai_score, ai_decision, ai_reason, priority_company, created_at, updated_at" +
") SELECT " +
"id, " + (cols.contains("profile_id") ? "profile_id" : "NULL") + ", encrypt_id, encrypt_user_id, company_name, job_name, salary, " +
(cols.contains("salary_min_k") ? "salary_min_k" : "NULL") + ", " +
(cols.contains("salary_max_k") ? "salary_max_k" : "NULL") + ", " +
(cols.contains("salary_median_k") ? "salary_median_k" : "NULL") + ", " +
(cols.contains("salary_months") ? "salary_months" : "NULL") + ", " +
"location, experience, degree, " +
"hr_name, hr_position, hr_active_status, delivery_status, " +
(cols.contains("failure_type") ? "failure_type" : "NULL") + ", " +
(cols.contains("failure_reason") ? "failure_reason" : "NULL") + ", job_description, job_url, recruitment_status, " +
"company_address, industry, introduce, financing_stage, company_scale, " +
(cols.contains("source_keyword") ? "source_keyword" : "NULL") + ", " +
(cols.contains("scan_run_id") ? "scan_run_id" : "NULL") + ", " +
(cols.contains("ai_score") ? "ai_score" : "NULL") + ", " +
(cols.contains("ai_decision") ? "ai_decision" : "NULL") + ", " +
(cols.contains("ai_reason") ? "ai_reason" : "NULL") + ", " +
(cols.contains("priority_company") ? "priority_company" : "0") + ", created_at, updated_at " +
"FROM boss_data";
stmt.execute(copySql);

stmt.execute("DROP TABLE boss_data");
stmt.execute("ALTER TABLE boss_data_new RENAME TO boss_data");
stmt.execute("COMMIT");
log.info("已调整 boss_data 表列顺序:将 encrypt_id、encrypt_user_id 前置");
}
} catch (Exception e) {
log.warn("调整 boss_data 列顺序失败:{}", e.getMessage());
try { if (conn != null) conn.createStatement().execute("ROLLBACK"); } catch (Exception ignore) {}
} finally {
try { if (conn != null) conn.close(); } catch (Exception ignore) {}
}
}

/**
* 判断岗位是否已存在(相同 encrypt_id AND encrypt_user_id)
*/
Expand Down Expand Up @@ -1750,18 +1637,13 @@ public String normalizeExplicitBossScanRunId(String scanRunId) {
}

/**
* 刷新数据:执行列顺序检查,并执行 VACUUM 以优化数据库;返回当前总数
* 刷新数据视图并返回当前档案总数。该入口不再执行任何数据库维护或 DDL。
*/
public Map<String, Object> reloadBossData() {
Map<String, Object> resp = new HashMap<>();
Connection conn = null;
try {
ensureBossDataColumnOrder();
conn = dataSource.getConnection();
try (Statement st = conn.createStatement()) {
try { st.execute("PRAGMA wal_checkpoint(TRUNCATE)"); } catch (Exception ignore) {}
try { st.execute("VACUUM"); } catch (Exception ignore) {}
}
Long profileId = profileService.getCurrentProfileIdOrNull();
long total = profileId == null ? 0 : scalarCount(conn, "SELECT COUNT(*) FROM boss_data WHERE profile_id=" + profileId);
resp.put("success", true);
Expand Down
Loading
Loading