Commit cac52e1c authored by 李文光's avatar 李文光

fix(state): 修复 offers 残留 UNIQUE(candidate_id) 导致整包覆盖 500

模型已允许同一候选人多条 Offer(一条进行中 + 历史已拒绝/已作废),但旧库 offers 表仍残留
UNIQUE(candidate_id);replace_state 写入多条同候选人 Offer 时触发 UNIQUE constraint failed,
PUT /api/state 返回 500。启动时自动检测并重建表移除该约束(保留数据/owner_id 索引/外键),
并补充旧库自动修复与多 Offer 写入单测。
parent 65c3cb13
......@@ -97,6 +97,88 @@ def _refresh_schema_for_owner_columns() -> None:
with engine.begin() as conn:
conn.execute(text("DROP TABLE app_state_meta"))
# 旧库升级:模型早已去掉 offers.candidate_id 的 UNIQUE(允许多条 Offer),
# 但 SQLite 不会自动删除已存在表上的约束,需重建表补齐。
_drop_offer_candidate_unique()
def _drop_offer_candidate_unique() -> None:
"""移除旧 offers 表残留的 UNIQUE(candidate_id) 约束。
背景:早期 Offer.candidate_id 带 unique=True;后续允许「同一候选人多条 Offer
(一条进行中 + 历史已拒绝/已作废)」后模型已去掉该约束(见 backend/app/models.py 的 Offer)。
但 SQLite 不会自动改造已存在的表,导致 replace_state 写入多条同候选人 Offer 时报
UNIQUE constraint failed。SQLite 不支持 DROP CONSTRAINT,只能重建表。
仅当检测到该约束存在时才重建,幂等。
"""
if engine.url.get_backend_name() != "sqlite":
return
from sqlalchemy import inspect
inspector = inspect(engine)
if "offers" not in inspector.get_table_names():
return
if not any(uc.get("column_names") == ["candidate_id"] for uc in inspector.get_unique_constraints("offers")):
return
_rebuild_offers_without_unique()
def _rebuild_offers_without_unique() -> None:
"""重建 offers 表以移除多余 UNIQUE(candidate_id),保留全部数据与索引。
重建期间临时关闭外键约束,避免 tasks.offer_id 等子表引用在 DROP/重建期间被改写;
列结构与 backend/app/models.py 的 Offer.__table__ 保持一致(无 unique),
owner_id 索引照旧重建。数据按列名拷贝,候选人与 Offer 的关联不受影响。
"""
raw = engine.raw_connection()
try:
raw_conn = raw.driver_connection
prev_isolation = raw_conn.isolation_level
raw_conn.isolation_level = None
cur = raw.cursor()
cur.execute("PRAGMA foreign_keys=OFF")
cur.execute("BEGIN")
try:
cur.execute(
"""
CREATE TABLE offers_new (
id VARCHAR(120) NOT NULL,
owner_id VARCHAR(80) NOT NULL,
candidate_id VARCHAR(80) NOT NULL,
status VARCHAR(80),
salary VARCHAR(120),
start_date VARCHAR(80),
risk VARCHAR(80),
data TEXT NOT NULL,
created_at VARCHAR(40) NOT NULL,
updated_at VARCHAR(40) NOT NULL,
PRIMARY KEY (id),
FOREIGN KEY(candidate_id) REFERENCES candidates (id) ON DELETE CASCADE
)
"""
)
cur.execute(
"""
INSERT INTO offers_new
(id, owner_id, candidate_id, status, salary, start_date, risk, data, created_at, updated_at)
SELECT id, owner_id, candidate_id, status, salary, start_date, risk, data, created_at, updated_at
FROM offers
"""
)
cur.execute("DROP TABLE offers")
cur.execute("ALTER TABLE offers_new RENAME TO offers")
cur.execute("CREATE INDEX IF NOT EXISTS ix_offers_owner_id ON offers (owner_id)")
raw.commit()
except Exception:
raw.rollback()
raise
finally:
cur.execute("PRAGMA foreign_keys=ON")
cur.close()
raw_conn.isolation_level = prev_isolation
finally:
raw.close()
def _owner_column_tables() -> list[str]:
from backend.app.models import (
......
......@@ -32,3 +32,93 @@ def test_import_local_state(client):
response = client.post("/api/import/local-state", json={"jobs": [], "candidates": [], "offers": [], "tasks": [], "eventLog": []})
assert response.status_code == 200
assert response.json()["imported"] is True
def test_put_state_allows_multiple_offers_per_candidate(client):
"""同一候选人可同时存在多条 Offer(一条进行中 + 历史已拒绝/已作废)。"""
payload = {
"jobs": [{"id": "job-1", "title": "销售经理"}],
"candidates": [{"id": "cand-1", "jobId": "job-1", "name": "薛庆霞", "jobTitle": "销售经理"}],
"offers": [
{"id": "offer-1", "candidateId": "cand-1", "status": "待 HR 确认", "salary": "待确认"},
{"id": "offer-2", "candidateId": "cand-1", "status": "已作废", "salary": "待确认"},
],
"tasks": [],
"eventLog": [],
}
response = client.put("/api/state", json=payload)
assert response.status_code == 200, response.text
assert len(response.json()["state"]["offers"]) == 2
def test_legacy_offer_unique_constraint_is_dropped_on_startup(tmp_path, monkeypatch):
"""旧库 offers 表残留 UNIQUE(candidate_id) 时,启动 schema 刷新应自动重建表移除该约束。"""
import sqlite3
from sqlalchemy import inspect
db_path = tmp_path / "legacy.sqlite"
con = sqlite3.connect(db_path)
con.execute(
"""
CREATE TABLE offers (
id VARCHAR(120) NOT NULL PRIMARY KEY,
candidate_id VARCHAR(80) NOT NULL,
status VARCHAR(80),
salary VARCHAR(120),
start_date VARCHAR(80),
risk VARCHAR(80),
data TEXT NOT NULL,
created_at VARCHAR(40) NOT NULL,
updated_at VARCHAR(40) NOT NULL,
owner_id VARCHAR(80) NOT NULL DEFAULT 'local',
UNIQUE (candidate_id),
FOREIGN KEY(candidate_id) REFERENCES candidates (id) ON DELETE CASCADE
)
"""
)
con.execute(
"INSERT INTO offers (id, candidate_id, status, salary, start_date, risk, data, created_at, updated_at, owner_id) "
"VALUES ('offer-legacy', 'cand-1', '待 HR 确认', '', '', '正常', '{}', '2026-01-01', '2026-01-01', 'test1')"
)
con.commit()
con.close()
monkeypatch.setenv("RECRUITMENT_SKIP_ENV_FILES", "1")
monkeypatch.setenv("DATABASE_URL", f"sqlite:///{db_path.as_posix()}")
monkeypatch.setenv("DATA_DIR", str(tmp_path / "data"))
monkeypatch.setenv("FILES_DIR", str(tmp_path / "uploads"))
monkeypatch.setenv("ADMIN_PASSWORD", "")
monkeypatch.setenv("INGEST_TOKEN", "")
monkeypatch.setenv("LLM_API_KEY", "")
monkeypatch.setenv("DEEPSEEK_API_KEY", "")
monkeypatch.setenv("OPENAI_API_KEY", "")
import backend.app.config as config
import backend.app.db as dbm
config.get_settings.cache_clear()
dbm.engine.dispose()
dbm.settings = config.get_settings()
dbm.engine = dbm.create_engine(
dbm.normalize_database_url(dbm.settings.database_url),
connect_args={"check_same_thread": False},
future=True,
)
dbm.SessionLocal.configure(bind=dbm.engine)
insp = inspect(dbm.engine)
assert any(uc.get("column_names") == ["candidate_id"] for uc in insp.get_unique_constraints("offers"))
dbm.create_all()
insp = inspect(dbm.engine)
assert not any(uc.get("column_names") == ["candidate_id"] for uc in insp.get_unique_constraints("offers"))
con = sqlite3.connect(db_path)
rows = con.execute("SELECT id, candidate_id, owner_id FROM offers").fetchall()
con.close()
assert rows == [("offer-legacy", "cand-1", "test1")]
dbm.engine.dispose()
config.get_settings.cache_clear()
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment