You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
866 lines
45 KiB
866 lines
45 KiB
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""B01 展示字段批次:安全演示数据工具(预检 / 预览 / 导入 / 定向清理 / 核对)。
|
|
|
|
设计目标(本版重写归属逻辑)
|
|
---------------------------
|
|
* **先预检、后写入**:任何一次导入在**业务写入之前**先完成全部冲突预检;只要出现
|
|
无法证明属本批的冲突(固定手机号被非本批账号占用、同名企业/岗位已被非本批记录占用、
|
|
账本丢失/资源丢失/身份变化),立即以**非零退出码**失败,绝不自动认领、覆盖、补角色、
|
|
置认证或删除。
|
|
* **可靠的批次归属账本**:仅工具使用的辅助表 ``b01_demo_ledger``(**不进入** Flyway 迁移或
|
|
应用启动流程)。账本只登记**本工具本次实际创建**的记录,逐条记录:批次 ID、目标库实例
|
|
身份、资源类型、**实际创建的主键 ID**、稳定演示条目键(demo_key)、确认资源身份所需的
|
|
指纹。**禁止**凭手机号/企业名/岗位标题/描述推断归属。
|
|
* **写入与归属登记同事务**:业务记录创建与账本写入在**同一事务**完成,失败整体回滚。
|
|
账本表的 DDL(``CREATE TABLE IF NOT EXISTS``)在业务事务**之前**单独执行——MySQL DDL
|
|
会隐式提交且不可回滚,故必须与业务事务分开说明与执行。
|
|
* **重复导入先校验归属**:账本中资源存在且身份一致 → 跳过(幂等);资源丢失 / ID 指向
|
|
异常 / 身份变化 / 账本不完整 → 明确报冲突,不静默接管、不自动修复。
|
|
* **清理只用账本精确 ID**:删除前核验资源身份与**依赖**(通过 information_schema 枚举外键
|
|
引用方);发现本批之外的依赖、角色或业务关联 → 拒绝清理并给出原因;不级联清除他人数据,
|
|
不关闭外键检查。全程在事务内以 ``SELECT ... FOR UPDATE`` 加行锁,避免「检查—删除」竞态。
|
|
* **预览与执行共用同一套计划**:默认 dry-run、零写入(**不创建**任何辅助表);导入/清理预览
|
|
与执行都基于同一 ``build_plan`` / ``build_cleanup_plan``,执行前针对事务内最新状态**重新
|
|
校验**;verify 按账本精确范围统计,不使用 ``LIKE`` 模糊统计;明确区分「没有本批数据」与
|
|
「账本缺失,无法证明归属」。
|
|
* 保留既有隔离目标限制(回环地址 + 显式白名单三元组 + 线上地址阻断)、显式 ``--apply``、
|
|
虚构演示标识;**不伪造**评价、评分、浏览量、真实投递;不关闭任何认证/安全策略。
|
|
|
|
用法
|
|
----
|
|
python docs/tools/b01_demo_display_data.py # dry-run 预览(零写入)
|
|
python docs/tools/b01_demo_display_data.py --apply # 首次/重复导入
|
|
python docs/tools/b01_demo_display_data.py --cleanup # 清理预览(零写入)
|
|
python docs/tools/b01_demo_display_data.py --cleanup --apply # 定向清理
|
|
python docs/tools/b01_demo_display_data.py --verify # 按账本精确核对
|
|
|
|
退出码:0=成功;2=参数/目标拒绝;3=归属冲突/无法证明归属而拒绝;1=意外错误。
|
|
"""
|
|
import argparse
|
|
import json
|
|
import sys
|
|
|
|
import pymysql
|
|
|
|
BATCH_ID = "b01-demo-2026"
|
|
DEMO_MARK = "[演示数据·非真实招聘]"
|
|
|
|
LEDGER_TABLE = "b01_demo_ledger"
|
|
|
|
# 显式白名单:只有精确匹配的三元组才允许操作。端口 3308 为本批隔离容器固定映射端口。
|
|
# 测试隔离库 mydreamjob_b01_demo_test 与演示库同属本批隔离范围,一并显式登记。
|
|
ALLOWED_TARGETS = {
|
|
("127.0.0.1", 3308, "mydreamjob_b01_demo"),
|
|
("127.0.0.1", 3308, "mydreamjob_b01_demo_test"),
|
|
}
|
|
# 已知线上/生产地址特征:命中即拒绝。
|
|
BLOCKED_HOST_MARKERS = (
|
|
"prod", "production", "online", "rds.aliyuncs.com", "mydreamjob.com",
|
|
"zhilai.com", "aliyuncs", "amazonaws",
|
|
)
|
|
LOOPBACK = {"127.0.0.1", "localhost", "::1"}
|
|
|
|
COMPANIES = [
|
|
dict(phone="19901000001", name="星河智造科技(演示)", industry="智能制造",
|
|
company_size="500-999人", website="https://demo.example/starship",
|
|
description=DEMO_MARK + " 虚构智能制造企业,用于展示岗位字段,不涉及真实招聘。"),
|
|
dict(phone="19901000002", name="云栖数据服务(演示)", industry="大数据",
|
|
company_size="100-499人", website="https://demo.example/cloudnest",
|
|
description=DEMO_MARK + " 虚构数据服务企业,用于展示岗位字段,不涉及真实招聘。"),
|
|
dict(phone="19901000003", name="沐光文化传媒(演示)", industry="文化传媒",
|
|
company_size="20-99人", website="https://demo.example/sunlit",
|
|
description=DEMO_MARK + " 虚构文化传媒企业,用于展示岗位字段,不涉及真实招聘。"),
|
|
]
|
|
|
|
JOBS = [
|
|
# 星河智造科技(演示)
|
|
dict(phone="19901000001", title="工业机器人算法实习生", city="上海", employment_type="INTERNSHIP",
|
|
salary_min=4000, salary_max=6000, experience="应届生", education="本科",
|
|
benefits=["导师带教", "免费班车", "实习转正"], salary_months=12, work_location="上海市浦东新区",
|
|
description=DEMO_MARK + " 参与机器人运动控制算法开发。", requirements="自动化/计算机相关专业。"),
|
|
dict(phone="19901000001", title="嵌入式软件工程师", city="上海", employment_type="FULL_TIME",
|
|
salary_min=20000, salary_max=35000, experience="3-5年", education="本科",
|
|
benefits=["五险一金", "弹性工作", "年度体检", "股票期权"], salary_months=14,
|
|
work_location="上海市闵行区", description=DEMO_MARK + " 负责机器人控制器嵌入式软件开发。",
|
|
requirements="熟悉 C/C++ 与 RTOS。"),
|
|
dict(phone="19901000001", title="高级机械结构工程师", city="苏州", employment_type="FULL_TIME",
|
|
salary_min=30000, salary_max=50000, experience="5-10年", education="硕士",
|
|
benefits=["五险一金", "补充医疗", "带薪年假"], salary_months=15, work_location="江苏省苏州市工业园区",
|
|
description=DEMO_MARK + " 负责精密机械结构设计与验证。", requirements="机械工程硕士,5 年以上经验。"),
|
|
dict(phone="19901000001", title="供应链计划专员", city="上海", employment_type="FULL_TIME",
|
|
salary_min=12000, salary_max=18000, experience="1-3年", education="大专",
|
|
benefits=["五险一金", "餐补", "节日福利"], salary_months=13, work_location="上海市嘉定区",
|
|
description=DEMO_MARK + " 负责物料计划与库存管理。", requirements="供应链或物流相关背景。"),
|
|
# 云栖数据服务(演示)
|
|
dict(phone="19901000002", title="数据平台后端工程师", city="杭州", employment_type="FULL_TIME",
|
|
salary_min=25000, salary_max=45000, experience="3-5年", education="本科",
|
|
benefits=["五险一金", "弹性工作", "免费三餐", "技术分享"], salary_months=16,
|
|
work_location="浙江省杭州市余杭区", description=DEMO_MARK + " 负责数据平台服务端研发。",
|
|
requirements="扎实的 Java 与分布式基础。"),
|
|
dict(phone="19901000002", title="大数据开发工程师", city="杭州", employment_type="FULL_TIME",
|
|
salary_min=22000, salary_max=40000, experience="3-5年", education="本科",
|
|
benefits=["五险一金", "年度体检", "带薪年假"], salary_months=14, work_location="浙江省杭州市滨江区",
|
|
description=DEMO_MARK + " 负责离线与实时数仓开发。", requirements="熟悉 Spark/Flink。"),
|
|
dict(phone="19901000002", title="数据分析师(校招)", city="杭州", employment_type="FULL_TIME",
|
|
salary_min=15000, salary_max=22000, experience="应届生", education="本科",
|
|
benefits=["五险一金", "租房补贴", "培训体系"], salary_months=13, work_location="浙江省杭州市余杭区",
|
|
description=DEMO_MARK + " 负责业务数据分析与报表建设。", requirements="统计学/数学相关专业。"),
|
|
dict(phone="19901000002", title="SRE 运维工程师", city="远程", employment_type="FULL_TIME",
|
|
salary_min=28000, salary_max=48000, experience="5-10年", education="不限",
|
|
benefits=["五险一金", "远程办公", "弹性工作", "年度体检"], salary_months=15, work_location="全国远程",
|
|
description=DEMO_MARK + " 负责平台稳定性与可观测性建设。", requirements="熟悉 Kubernetes 与监控体系。"),
|
|
# 沐光文化传媒(演示)
|
|
dict(phone="19901000003", title="短视频内容运营", city="成都", employment_type="FULL_TIME",
|
|
salary_min=8000, salary_max=13000, experience="1-3年", education="大专",
|
|
benefits=["五险一金", "下午茶", "节日福利"], salary_months=12, work_location="四川省成都市武侯区",
|
|
description=DEMO_MARK + " 负责短视频账号内容策划与运营。", requirements="有内容运营经验优先。"),
|
|
dict(phone="19901000003", title="视觉设计师", city="成都", employment_type="FULL_TIME",
|
|
salary_min=12000, salary_max=20000, experience="3-5年", education="本科",
|
|
benefits=["五险一金", "弹性工作", "作品集补贴"], salary_months=13, work_location="四川省成都市高新区",
|
|
description=DEMO_MARK + " 负责品牌视觉与活动设计。", requirements="设计相关专业,作品集优秀。"),
|
|
dict(phone="19901000003", title="市场活动策划", city="成都", employment_type="PART_TIME",
|
|
salary_min=6000, salary_max=9000, experience="1-3年", education="不限",
|
|
benefits=["五险一金", "项目奖金"], salary_months=12, work_location="四川省成都市锦江区",
|
|
description=DEMO_MARK + " 负责线下市场活动策划与执行。", requirements="具备活动执行经验。"),
|
|
dict(phone="19901000003", title="客户成功经理", city="重庆", employment_type="FULL_TIME",
|
|
salary_min=15000, salary_max=25000, experience="3-5年", education="本科",
|
|
benefits=["五险一金", "补充医疗", "带薪年假", "团队旅游"], salary_months=14,
|
|
work_location="重庆市渝北区", description=DEMO_MARK + " 负责重点客户关系维护与续约。",
|
|
requirements="具备 B 端客户服务经验。"),
|
|
]
|
|
|
|
# 稳定演示条目键与身份指纹:账本据此判定「本批资源身份」是否与当前库内记录一致。
|
|
def user_demo_key(phone):
|
|
return "user:" + phone
|
|
|
|
|
|
def company_demo_key(phone):
|
|
return "company:" + phone
|
|
|
|
|
|
def job_demo_key(phone, title):
|
|
return "job:%s|%s" % (phone, title)
|
|
|
|
|
|
def user_fingerprint(phone):
|
|
return "phone=%s" % phone
|
|
|
|
|
|
def company_fingerprint(phone, name):
|
|
return "owner_phone=%s;name=%s" % (phone, name)
|
|
|
|
|
|
def job_fingerprint(phone, title):
|
|
return "company_phone=%s;title=%s" % (phone, title)
|
|
|
|
|
|
LEDGER_DDL = """
|
|
CREATE TABLE IF NOT EXISTS %s (
|
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
batch_id VARCHAR(64) NOT NULL,
|
|
target_identity VARCHAR(160) NOT NULL,
|
|
resource_type VARCHAR(16) NOT NULL,
|
|
resource_id BIGINT NOT NULL,
|
|
demo_key VARCHAR(191) NOT NULL,
|
|
identity_fingerprint VARCHAR(255) NOT NULL,
|
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
|
UNIQUE KEY uk_ledger_target_key (target_identity, demo_key),
|
|
UNIQUE KEY uk_ledger_target_res (target_identity, resource_type, resource_id),
|
|
KEY idx_ledger_batch (batch_id, target_identity)
|
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
|
""" % LEDGER_TABLE
|
|
|
|
|
|
# ── 连接与目标限制 ──────────────────────────────────────────────────
|
|
|
|
def assert_safe_target(host, port, database):
|
|
"""显式白名单 + 线上地址阻断。任何不满足都直接终止,不降级。"""
|
|
for marker in BLOCKED_HOST_MARKERS:
|
|
if marker in host.lower() or marker in database.lower():
|
|
raise SystemExit(f"[REFUSED] 目标疑似线上地址,已拒绝:host={host} database={database}")
|
|
if host not in LOOPBACK:
|
|
raise SystemExit(f"[REFUSED] 目标 host 非回环地址:{host}")
|
|
if (host, port, database) not in ALLOWED_TARGETS:
|
|
raise SystemExit(
|
|
f"[REFUSED] 目标不在显式白名单内:{(host, port, database)};"
|
|
f"允许:{sorted(ALLOWED_TARGETS)}")
|
|
if "b01" not in database:
|
|
raise SystemExit(f"[REFUSED] 数据库名缺少本批标识 b01:{database}")
|
|
|
|
|
|
def connect(args):
|
|
assert_safe_target(args.host, args.port, args.database)
|
|
return pymysql.connect(host=args.host, port=args.port, user=args.user,
|
|
password=args.password, database=args.database,
|
|
charset="utf8mb4", autocommit=False)
|
|
|
|
|
|
# ── 查询助手 ────────────────────────────────────────────────────────
|
|
|
|
def query(cur, sql, params=None):
|
|
cur.execute(sql, params if params else None)
|
|
cols = [d[0] for d in cur.description]
|
|
return [dict(zip(cols, row)) for row in cur.fetchall()]
|
|
|
|
|
|
def query_one(cur, sql, params=None):
|
|
rows = query(cur, sql, params)
|
|
return rows[0] if rows else None
|
|
|
|
|
|
def scalar(cur, sql, params=None):
|
|
row = query_one(cur, sql, params)
|
|
if not row:
|
|
return None
|
|
return list(row.values())[0]
|
|
|
|
|
|
def table_exists(cur, name):
|
|
n = scalar(cur, "SELECT COUNT(*) FROM information_schema.tables "
|
|
"WHERE table_schema=DATABASE() AND table_name=%s", (name,))
|
|
return int(n or 0) > 0
|
|
|
|
|
|
REQUIRED_BUSINESS_TABLES = ("users", "user_roles", "companies", "jobs")
|
|
|
|
|
|
def assert_business_schema(cur):
|
|
missing = [t for t in REQUIRED_BUSINESS_TABLES if not table_exists(cur, t)]
|
|
if missing:
|
|
raise SystemExit("[REFUSED] 目标库缺少必需业务表 %s,拒绝操作" % missing)
|
|
|
|
|
|
def target_identity(cur, args):
|
|
"""目标库实例身份:优先 server_uuid(可区分不同实例),退化到 host:port。"""
|
|
uuid = None
|
|
try:
|
|
uuid = scalar(cur, "SELECT @@server_uuid")
|
|
except Exception:
|
|
uuid = None
|
|
if not uuid:
|
|
uuid = "%s:%s" % (args.host, args.port)
|
|
return "%s/%s" % (uuid, args.database)
|
|
|
|
|
|
# ── 账本读写 ────────────────────────────────────────────────────────
|
|
|
|
def read_ledger(cur, identity, for_update=False):
|
|
"""返回本批本目标的账本行;账本表不存在返回 None(区别于空列表)。"""
|
|
if not table_exists(cur, LEDGER_TABLE):
|
|
return None
|
|
sql = ("SELECT id, resource_type, resource_id, demo_key, identity_fingerprint "
|
|
"FROM %s WHERE batch_id=%%s AND target_identity=%%s" % LEDGER_TABLE)
|
|
if for_update:
|
|
sql += " FOR UPDATE"
|
|
rows = query(cur, sql, (BATCH_ID, identity))
|
|
for r in rows:
|
|
r["resource_id"] = int(r["resource_id"])
|
|
return rows
|
|
|
|
|
|
def insert_ledger(cur, identity, resource_type, resource_id, demo_key, fingerprint):
|
|
cur.execute(
|
|
"INSERT INTO %s(batch_id, target_identity, resource_type, resource_id, demo_key, "
|
|
"identity_fingerprint) VALUES(%%s,%%s,%%s,%%s,%%s,%%s)" % LEDGER_TABLE,
|
|
(BATCH_ID, identity, resource_type, resource_id, demo_key, fingerprint))
|
|
|
|
|
|
# ── 计划构建(预检) ────────────────────────────────────────────────
|
|
|
|
def validate_ledger_shape(ledger):
|
|
"""账本键、类型和指纹必须属于当前定义;不能用损坏的账本证明归属。"""
|
|
expected = {}
|
|
for c in COMPANIES:
|
|
expected[user_demo_key(c['phone'])] = ('USER', user_fingerprint(c['phone']))
|
|
expected[company_demo_key(c['phone'])] = ('COMPANY', company_fingerprint(c['phone'], c['name']))
|
|
for j in JOBS:
|
|
expected[job_demo_key(j['phone'], j['title'])] = ('JOB', job_fingerprint(j['phone'], j['title']))
|
|
conflicts = []
|
|
seen = set()
|
|
for row in ledger or []:
|
|
key = row['demo_key']
|
|
if (key in seen or expected.get(key) != (row['resource_type'], row['identity_fingerprint'])
|
|
or row['resource_id'] <= 0):
|
|
conflicts.append('账本键/类型/指纹/ID 异常:%s,拒绝操作' % key)
|
|
seen.add(key)
|
|
return conflicts
|
|
|
|
|
|
def build_plan(cur, identity, ledger):
|
|
"""只读地计算导入计划。返回 (actions, conflicts)。
|
|
|
|
actions 中每条:kind = create_*/keep_*;携带 type/demo_key/label 与已有 id(如有)。
|
|
conflicts 非空表示无法证明本批归属,调用方必须拒绝写入。
|
|
"""
|
|
actions, conflicts = [], validate_ledger_shape(ledger)
|
|
if conflicts:
|
|
return actions, conflicts
|
|
ledger_by_key = {r["demo_key"]: r for r in (ledger or [])}
|
|
used_keys = set()
|
|
user_ids, company_ids = {}, {}
|
|
|
|
# 用户
|
|
for c in COMPANIES:
|
|
phone = c["phone"]
|
|
key = user_demo_key(phone)
|
|
used_keys.add(key)
|
|
existing = query_one(cur, "SELECT id FROM users WHERE phone=%s", (phone,))
|
|
lr = ledger_by_key.get(key)
|
|
if existing is None:
|
|
if lr is not None:
|
|
conflicts.append("账本记录本批用户 %s(id=%s) 已丢失:拒绝静默重建或接管"
|
|
% (phone, lr["resource_id"]))
|
|
user_ids[phone] = None
|
|
else:
|
|
actions.append(dict(kind="create_user", type="USER", demo_key=key,
|
|
phone=phone, label=phone, id=None))
|
|
user_ids[phone] = None
|
|
continue
|
|
if lr is None:
|
|
conflicts.append("手机号 %s 已被非本批账号占用(users.id=%s)且账本无本批归属记录:"
|
|
"拒绝认领/覆盖/补角色" % (phone, existing["id"]))
|
|
user_ids[phone] = existing["id"]
|
|
continue
|
|
if int(lr["resource_id"]) != int(existing["id"]) \
|
|
or lr["identity_fingerprint"] != user_fingerprint(phone):
|
|
conflicts.append("手机号 %s 账本与现有账号不一致(账本 id=%s vs 实际 id=%s):拒绝接管"
|
|
% (phone, lr["resource_id"], existing["id"]))
|
|
user_ids[phone] = existing["id"]
|
|
continue
|
|
actions.append(dict(kind="keep_user", type="USER", demo_key=key, phone=phone,
|
|
label=phone, id=existing["id"]))
|
|
user_ids[phone] = existing["id"]
|
|
|
|
# 企业
|
|
for c in COMPANIES:
|
|
phone, name = c["phone"], c["name"]
|
|
key = company_demo_key(phone)
|
|
used_keys.add(key)
|
|
uid = user_ids.get(phone)
|
|
label = "%s / %s" % (phone, name)
|
|
lr = ledger_by_key.get(key)
|
|
by_owner = (query_one(cur, "SELECT id, name FROM companies WHERE owner_user_id=%s", (uid,))
|
|
if uid is not None else None)
|
|
by_name = query_one(cur, "SELECT id, owner_user_id FROM companies WHERE name=%s", (name,))
|
|
|
|
if uid is None:
|
|
# 演示账号即将新建 → 企业必为新建;但必须确保没有同名企业被他方占用
|
|
if lr is not None:
|
|
conflicts.append("账本记录本批企业 %s(id=%s) 已丢失:拒绝静默重建"
|
|
% (name, lr["resource_id"]))
|
|
company_ids[phone] = None
|
|
elif by_name is not None:
|
|
conflicts.append("企业名 %s 已被非本批企业占用(companies.id=%s, owner_user_id=%s):"
|
|
"拒绝覆盖/认领" % (name, by_name["id"], by_name["owner_user_id"]))
|
|
company_ids[phone] = None
|
|
else:
|
|
actions.append(dict(kind="create_company", type="COMPANY", demo_key=key,
|
|
phone=phone, name=name, label=label, id=None))
|
|
company_ids[phone] = None
|
|
continue
|
|
|
|
if by_owner is None:
|
|
if lr is not None:
|
|
conflicts.append("账本记录本批企业 %s(id=%s, owner=%s) 已丢失:拒绝静默重建"
|
|
% (name, lr["resource_id"], phone))
|
|
company_ids[phone] = None
|
|
continue
|
|
if by_name is not None:
|
|
conflicts.append("企业名 %s 已被非本批企业占用(companies.id=%s, owner_user_id=%s):"
|
|
"拒绝覆盖/认领" % (name, by_name["id"], by_name["owner_user_id"]))
|
|
company_ids[phone] = None
|
|
continue
|
|
actions.append(dict(kind="create_company", type="COMPANY", demo_key=key,
|
|
phone=phone, name=name, label=label, id=None))
|
|
company_ids[phone] = None
|
|
continue
|
|
|
|
# 演示账号名下已有企业:必须能由账本证明归属,且不能另有同名企业
|
|
ok = (lr is not None and int(lr["resource_id"]) == int(by_owner["id"])
|
|
and lr["identity_fingerprint"] == company_fingerprint(phone, by_owner["name"]))
|
|
if not ok:
|
|
conflicts.append("演示账号 %s 名下已有企业(companies.id=%s)但无本批账本归属或身份不一致:"
|
|
"拒绝覆盖企业资料/置认证" % (phone, by_owner["id"]))
|
|
company_ids[phone] = by_owner["id"]
|
|
continue
|
|
if by_name is not None and int(by_name["id"]) != int(by_owner["id"]):
|
|
conflicts.append("企业名 %s 存在同名重复记录(companies.id=%s 非本批):拒绝继续"
|
|
% (name, by_name["id"]))
|
|
company_ids[phone] = by_owner["id"]
|
|
continue
|
|
actions.append(dict(kind="keep_company", type="COMPANY", demo_key=key, phone=phone,
|
|
name=name, label=label, id=by_owner["id"]))
|
|
company_ids[phone] = by_owner["id"]
|
|
|
|
# 岗位
|
|
for j in JOBS:
|
|
phone, title = j["phone"], j["title"]
|
|
key = job_demo_key(phone, title)
|
|
used_keys.add(key)
|
|
cid = company_ids.get(phone)
|
|
label = "%s / %s" % (phone, title)
|
|
if cid is None:
|
|
lr = ledger_by_key.get(key)
|
|
if lr is not None:
|
|
conflicts.append("账本记录本批岗位 %s(id=%s) 已丢失:拒绝静默重建"
|
|
% (label, lr["resource_id"]))
|
|
else:
|
|
actions.append(dict(kind="create_job", type="JOB", demo_key=key, phone=phone,
|
|
title=title, label=label, id=None))
|
|
continue
|
|
existing = query_one(cur, "SELECT id FROM jobs WHERE company_id=%s AND title=%s",
|
|
(cid, title))
|
|
lr = ledger_by_key.get(key)
|
|
if existing is None:
|
|
if lr is not None:
|
|
conflicts.append("账本记录本批岗位 %s(id=%s) 已丢失:拒绝静默重建"
|
|
% (label, lr["resource_id"]))
|
|
else:
|
|
actions.append(dict(kind="create_job", type="JOB", demo_key=key, phone=phone,
|
|
title=title, label=label, id=None))
|
|
continue
|
|
ok = (lr is not None and int(lr["resource_id"]) == int(existing["id"])
|
|
and lr["identity_fingerprint"] == job_fingerprint(phone, title))
|
|
if not ok:
|
|
conflicts.append("本批企业 %s 下已存在同名岗位 '%s'(jobs.id=%s)但无本批账本归属:"
|
|
"拒绝认领/覆盖" % (phone, title, existing["id"]))
|
|
continue
|
|
actions.append(dict(kind="keep_job", type="JOB", demo_key=key, phone=phone,
|
|
title=title, label=label, id=existing["id"]))
|
|
|
|
# 账本中存在当前批次定义之外的记录 → 拒绝(不自动处理、不猜测归属)
|
|
for r in (ledger or []):
|
|
if r["demo_key"] not in used_keys:
|
|
conflicts.append("账本含当前批次定义之外的记录(%s/%s id=%s):拒绝自动处理"
|
|
% (r["resource_type"], r["demo_key"], r["resource_id"]))
|
|
return actions, conflicts
|
|
|
|
|
|
# ── 导入执行 ────────────────────────────────────────────────────────
|
|
|
|
def _benefits_json(benefits):
|
|
return json.dumps(benefits, ensure_ascii=False, separators=(",", ":"))
|
|
|
|
|
|
def apply_import(conn, cur, identity):
|
|
"""执行导入:先预检(零写入),无冲突后再建账本表 + 业务写入/账本登记同事务。
|
|
|
|
返回 (created, conflicts)。conflicts 非空时不写入任何业务数据、也不创建账本表。
|
|
"""
|
|
# 1) 预检:只读,不创建任何表、不写任何行。
|
|
ledger = read_ledger(cur, identity)
|
|
_, conflicts = build_plan(cur, identity, ledger)
|
|
if conflicts:
|
|
conn.rollback()
|
|
return None, conflicts
|
|
|
|
# 2) 账本表 DDL(MySQL DDL 隐式提交、不可回滚,故独立于业务事务)。
|
|
cur.execute(LEDGER_DDL)
|
|
conn.commit()
|
|
|
|
# 3) 业务事务:针对最新状态重新校验后写入。
|
|
try:
|
|
ledger = read_ledger(cur, identity)
|
|
actions, conflicts = build_plan(cur, identity, ledger)
|
|
if conflicts:
|
|
conn.rollback()
|
|
return None, conflicts
|
|
|
|
comp_by_phone = {c["phone"]: c for c in COMPANIES}
|
|
job_by_key = {job_demo_key(j["phone"], j["title"]): j for j in JOBS}
|
|
created = dict(users=0, companies=0, jobs=0)
|
|
user_ids, company_ids = {}, {}
|
|
for a in actions:
|
|
if a["kind"] == "keep_user":
|
|
user_ids[a["phone"]] = a["id"]
|
|
elif a["kind"] == "create_user":
|
|
cur.execute("INSERT INTO users(phone, nickname, role, is_active, is_verified) "
|
|
"VALUES(%s,%s,'COMPANY',1,1)",
|
|
(a["phone"], a["phone"] + "(演示账号)"))
|
|
uid = cur.lastrowid
|
|
cur.execute("INSERT INTO user_roles(user_id, role) VALUES(%s,'COMPANY')", (uid,))
|
|
insert_ledger(cur, identity, "USER", uid, a["demo_key"], user_fingerprint(a["phone"]))
|
|
user_ids[a["phone"]] = uid
|
|
created["users"] += 1
|
|
elif a["kind"] == "keep_company":
|
|
company_ids[a["phone"]] = a["id"]
|
|
elif a["kind"] == "create_company":
|
|
src = comp_by_phone[a["phone"]]
|
|
uid = user_ids[a["phone"]]
|
|
cur.execute("INSERT INTO companies(owner_user_id, name, logo_url, description, "
|
|
"industry, company_size, website, address, verified) "
|
|
"VALUES(%s,%s,NULL,%s,%s,%s,%s,NULL,1)",
|
|
(uid, src["name"], src["description"], src["industry"],
|
|
src["company_size"], src["website"]))
|
|
cid = cur.lastrowid
|
|
company_ids[a["phone"]] = cid
|
|
insert_ledger(cur, identity, "COMPANY", cid, a["demo_key"],
|
|
company_fingerprint(a["phone"], src["name"]))
|
|
created["companies"] += 1
|
|
elif a["kind"] == "keep_job":
|
|
continue
|
|
elif a["kind"] == "create_job":
|
|
j = job_by_key[a["demo_key"]]
|
|
cid = company_ids[a["phone"]]
|
|
cur.execute("INSERT INTO jobs(company_id, title, description, requirements, city, "
|
|
"employment_type, salary_min, salary_max, experience, education, "
|
|
"benefits_json, salary_months, work_location, status, published_at) "
|
|
"VALUES(%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'PUBLISHED',NOW())",
|
|
(cid, j["title"], j["description"], j["requirements"], j["city"],
|
|
j["employment_type"], j["salary_min"], j["salary_max"], j["experience"],
|
|
j["education"], _benefits_json(j["benefits"]), j["salary_months"],
|
|
j["work_location"]))
|
|
jid = cur.lastrowid
|
|
insert_ledger(cur, identity, "JOB", jid, a["demo_key"],
|
|
job_fingerprint(a["phone"], j["title"]))
|
|
created["jobs"] += 1
|
|
conn.commit()
|
|
return created, []
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
|
|
|
|
# ── 清理计划与执行 ──────────────────────────────────────────────────
|
|
|
|
def _dependent_columns(cur, table):
|
|
return query(cur,
|
|
"SELECT TABLE_NAME AS t, COLUMN_NAME AS c FROM information_schema.KEY_COLUMN_USAGE "
|
|
"WHERE REFERENCED_TABLE_SCHEMA=DATABASE() AND REFERENCED_TABLE_NAME=%s "
|
|
"AND REFERENCED_COLUMN_NAME='id' ORDER BY TABLE_NAME", (table,))
|
|
|
|
|
|
def _count_refs(cur, table, column, rid):
|
|
return int(scalar(cur, "SELECT COUNT(*) FROM `%s` WHERE `%s`=%%s" % (table, column), (rid,)) or 0)
|
|
|
|
|
|
def build_cleanup_plan(cur, identity, ledger, for_update=False):
|
|
"""只读(或加锁)地计算清理计划。返回 (plan, conflicts)。
|
|
|
|
ledger 为 None → 账本缺失,无法证明归属。ledger 为空 → 无本批数据。
|
|
"""
|
|
if ledger is None:
|
|
return None, ["账本表不存在:无法证明本批归属,拒绝清理"]
|
|
plan = dict(jobs=[], companies=[], users=[], ledger_ids=[])
|
|
conflicts = []
|
|
|
|
if not ledger:
|
|
return plan, conflicts # 已清理或无本批数据 → 安全无操作
|
|
|
|
conflicts.extend(validate_ledger_shape(ledger))
|
|
if conflicts:
|
|
return plan, conflicts
|
|
ledger_by_key = {r['demo_key']: r for r in ledger}
|
|
|
|
job_rows = [r for r in ledger if r["resource_type"] == "JOB"]
|
|
comp_rows = [r for r in ledger if r["resource_type"] == "COMPANY"]
|
|
user_rows = [r for r in ledger if r["resource_type"] == "USER"]
|
|
ledger_company_ids = {r["resource_id"] for r in comp_rows}
|
|
ledger_job_ids = {r["resource_id"] for r in job_rows}
|
|
|
|
# 账本完整性:COMPANY 必须有对应 USER,JOB 必须有对应 COMPANY
|
|
for r in comp_rows:
|
|
owner_phone = r["demo_key"].split(":", 1)[1]
|
|
if not any(u["demo_key"] == user_demo_key(owner_phone) for u in user_rows):
|
|
conflicts.append("账本不完整:企业 %s 缺少归属用户账本记录" % r["demo_key"])
|
|
for r in job_rows:
|
|
company_key = r["demo_key"].split("|", 1)[0].replace("job:", "company:")
|
|
if not any(c["demo_key"] == company_key for c in comp_rows):
|
|
conflicts.append("账本不完整:岗位 %s 缺少归属企业账本记录" % r["demo_key"])
|
|
|
|
# 用户
|
|
for r in user_rows:
|
|
uid = r["resource_id"]
|
|
phone = r["demo_key"].split(":", 1)[1]
|
|
row = query_one(cur, "SELECT id, phone FROM users WHERE id=%s" + (" FOR UPDATE" if for_update else ""), (uid,))
|
|
if row is None:
|
|
conflicts.append("账本记录用户 %s(id=%s) 已丢失:无法确认清理范围,拒绝" % (phone, uid))
|
|
continue
|
|
if row['phone'] != phone or r["identity_fingerprint"] != user_fingerprint(row['phone']):
|
|
conflicts.append("账本用户 %s(id=%s) 身份指纹异常:拒绝清理" % (phone, uid))
|
|
continue
|
|
# 允许的依赖:名下本批企业 + 仅 COMPANY 角色;其余依赖一律拒绝
|
|
for dep in _dependent_columns(cur, "users"):
|
|
t, c = dep["t"], dep["c"]
|
|
n = _count_refs(cur, t, c, uid)
|
|
if n == 0:
|
|
continue
|
|
if t == "companies" and c == "owner_user_id":
|
|
owned = [int(x["id"]) for x in query(
|
|
cur, "SELECT id FROM companies WHERE owner_user_id=%s", (uid,))]
|
|
extra = [cid for cid in owned if cid not in ledger_company_ids]
|
|
if extra:
|
|
conflicts.append("用户 %s 名下存在非本批企业 %s:拒绝清理" % (phone, extra))
|
|
elif t == "user_roles" and c == "user_id":
|
|
roles = [x["role"] for x in query(
|
|
cur, "SELECT role FROM user_roles WHERE user_id=%s", (uid,))]
|
|
extra = [x for x in roles if x != "COMPANY"]
|
|
if extra:
|
|
conflicts.append("用户 %s 存在非本批角色 %s:拒绝清理" % (phone, extra))
|
|
else:
|
|
conflicts.append("用户 %s 存在本批之外的依赖 %s.%s(%d 行):拒绝清理" % (phone, t, c, n))
|
|
plan["users"].append(dict(id=uid, phone=phone, demo_key=r["demo_key"]))
|
|
|
|
# 企业
|
|
for r in comp_rows:
|
|
cid = r["resource_id"]
|
|
phone = r["demo_key"].split(":", 1)[1]
|
|
suffix = " FOR UPDATE" if for_update else ""
|
|
row = query_one(cur, "SELECT c.id AS id, c.name AS name, c.owner_user_id, u.phone AS owner_phone "
|
|
"FROM companies c JOIN users u ON u.id=c.owner_user_id "
|
|
"WHERE c.id=%s" + suffix, (cid,))
|
|
if row is None:
|
|
conflicts.append("账本记录企业 %s(id=%s) 已丢失:无法确认清理范围,拒绝" % (phone, cid))
|
|
continue
|
|
parent = ledger_by_key.get(user_demo_key(phone))
|
|
if (parent is None or row['owner_user_id'] != parent['resource_id']
|
|
or row['owner_phone'] != phone
|
|
or r["identity_fingerprint"] != company_fingerprint(row["owner_phone"], row["name"])):
|
|
conflicts.append("账本企业 %s(id=%s) 身份指纹异常:拒绝清理" % (phone, cid))
|
|
continue
|
|
for dep in _dependent_columns(cur, "companies"):
|
|
t, c = dep["t"], dep["c"]
|
|
n = _count_refs(cur, t, c, cid)
|
|
if n == 0:
|
|
continue
|
|
if t == "jobs" and c == "company_id":
|
|
jobs_here = [int(x["id"]) for x in query(
|
|
cur, "SELECT id FROM jobs WHERE company_id=%s", (cid,))]
|
|
extra = [jid for jid in jobs_here if jid not in ledger_job_ids]
|
|
if extra:
|
|
conflicts.append("企业 %s 下存在非本批岗位 %s:拒绝清理" % (phone, extra))
|
|
else:
|
|
conflicts.append("企业 %s 存在本批之外的依赖 %s.%s(%d 行):拒绝清理" % (phone, t, c, n))
|
|
plan["companies"].append(dict(id=cid, phone=phone, demo_key=r["demo_key"]))
|
|
|
|
# 岗位
|
|
for r in job_rows:
|
|
jid = r["resource_id"]
|
|
suffix = " FOR UPDATE" if for_update else ""
|
|
row = query_one(cur, "SELECT j.id AS id, j.title AS title, j.company_id, u.phone AS owner_phone "
|
|
"FROM jobs j JOIN companies c ON c.id=j.company_id "
|
|
"JOIN users u ON u.id=c.owner_user_id WHERE j.id=%s" + suffix, (jid,))
|
|
if row is None:
|
|
conflicts.append("账本记录岗位 id=%s 已丢失:无法确认清理范围,拒绝" % jid)
|
|
continue
|
|
parent_key = r['demo_key'].split('|', 1)[0].replace('job:', 'company:', 1)
|
|
parent = ledger_by_key.get(parent_key)
|
|
if (parent is None or row['company_id'] != parent['resource_id']
|
|
or r["identity_fingerprint"] != job_fingerprint(row["owner_phone"], row["title"])):
|
|
conflicts.append("账本岗位 id=%s 身份指纹异常:拒绝清理" % jid)
|
|
continue
|
|
for dep in _dependent_columns(cur, "jobs"):
|
|
t, c = dep["t"], dep["c"]
|
|
n = _count_refs(cur, t, c, jid)
|
|
if n > 0:
|
|
conflicts.append("岗位 id=%s 存在本批之外的依赖 %s.%s(%d 行):拒绝清理" % (jid, t, c, n))
|
|
plan["jobs"].append(dict(id=jid, company_id=row['company_id'], demo_key=r["demo_key"]))
|
|
|
|
plan["ledger_ids"] = [r["id"] for r in ledger]
|
|
return plan, conflicts
|
|
|
|
|
|
def apply_cleanup(conn, cur, identity):
|
|
"""执行清理:事务 + 行锁,只删除账本精确 ID。返回 (removed, conflicts)。"""
|
|
try:
|
|
ledger = read_ledger(cur, identity, for_update=True)
|
|
plan, conflicts = build_cleanup_plan(cur, identity, ledger, for_update=True)
|
|
if conflicts:
|
|
conn.rollback()
|
|
return None, conflicts
|
|
removed = dict(jobs=0, companies=0, users=0, ledger=0)
|
|
for job in plan["jobs"]:
|
|
cur.execute("DELETE FROM jobs WHERE id=%s AND company_id=%s", (job["id"], job['company_id']))
|
|
if cur.rowcount != 1:
|
|
raise RuntimeError('岗位删除目标变化,回滚全部清理')
|
|
removed["jobs"] += cur.rowcount
|
|
for comp in plan["companies"]:
|
|
cur.execute("DELETE FROM companies WHERE id=%s AND owner_user_id="
|
|
"(SELECT id FROM users WHERE phone=%s)", (comp["id"], comp["phone"]))
|
|
if cur.rowcount != 1:
|
|
raise RuntimeError('企业删除目标变化,回滚全部清理')
|
|
removed["companies"] += cur.rowcount
|
|
for user in plan["users"]:
|
|
cur.execute("DELETE FROM user_roles WHERE user_id=%s", (user["id"],))
|
|
cur.execute("DELETE FROM users WHERE id=%s AND phone=%s", (user["id"], user['phone']))
|
|
if cur.rowcount != 1:
|
|
raise RuntimeError('用户删除目标变化,回滚全部清理')
|
|
removed["users"] += cur.rowcount
|
|
if plan["ledger_ids"]:
|
|
fmt = ",".join(["%s"] * len(plan["ledger_ids"]))
|
|
cur.execute("DELETE FROM %s WHERE id IN (%s)" % (LEDGER_TABLE, fmt),
|
|
tuple(plan["ledger_ids"]))
|
|
removed["ledger"] = cur.rowcount
|
|
if cur.rowcount != len(plan['ledger_ids']):
|
|
raise RuntimeError('账本删除范围变化,回滚全部清理')
|
|
conn.commit()
|
|
return removed, []
|
|
except Exception:
|
|
conn.rollback()
|
|
raise
|
|
|
|
|
|
# ── 核对 ────────────────────────────────────────────────────────────
|
|
|
|
def verify(cur, identity):
|
|
"""按账本精确范围核对(不使用 LIKE 模糊统计)。"""
|
|
ledger = read_ledger(cur, identity)
|
|
demo_phones = [c["phone"] for c in COMPANIES]
|
|
demo_names = [c["name"] for c in COMPANIES]
|
|
demo_titles = [(j["phone"], j["title"]) for j in JOBS]
|
|
|
|
def present_demo_users():
|
|
fmt = ",".join(["%s"] * len(demo_phones))
|
|
return [r["id"] for r in query(
|
|
cur, "SELECT id FROM users WHERE phone IN (%s)" % fmt, tuple(demo_phones))]
|
|
|
|
def present_demo_companies():
|
|
fmt = ",".join(["%s"] * len(demo_names))
|
|
return [r["id"] for r in query(
|
|
cur, "SELECT id FROM companies WHERE name IN (%s)" % fmt, tuple(demo_names))]
|
|
|
|
def present_demo_jobs():
|
|
ids = []
|
|
for phone, title in demo_titles:
|
|
owner = query_one(cur, "SELECT id FROM users WHERE phone=%s", (phone,))
|
|
if not owner:
|
|
continue
|
|
comp = query_one(cur, "SELECT id FROM companies WHERE owner_user_id=%s", (owner["id"],))
|
|
if not comp:
|
|
continue
|
|
job = query_one(cur, "SELECT id FROM jobs WHERE company_id=%s AND title=%s",
|
|
(comp["id"], title))
|
|
if job:
|
|
ids.append(job["id"])
|
|
return ids
|
|
|
|
if ledger is None:
|
|
return dict(status="LEDGER_MISSING",
|
|
detail="账本表不存在:无法证明本批归属;不得据此清理",
|
|
demo_users_present=present_demo_users(),
|
|
demo_companies_present=present_demo_companies(),
|
|
demo_jobs_present=present_demo_jobs())
|
|
|
|
counts = dict(USER=0, COMPANY=0, JOB=0)
|
|
for r in ledger:
|
|
counts[r["resource_type"]] = counts.get(r["resource_type"], 0) + 1
|
|
alive = dict(USER=0, COMPANY=0, JOB=0)
|
|
for r in ledger:
|
|
if r["resource_type"] == "USER":
|
|
row = query_one(cur, "SELECT id FROM users WHERE id=%s", (r["resource_id"],))
|
|
elif r["resource_type"] == "COMPANY":
|
|
row = query_one(cur, "SELECT id FROM companies WHERE id=%s", (r["resource_id"],))
|
|
else:
|
|
row = query_one(cur, "SELECT id FROM jobs WHERE id=%s", (r["resource_id"],))
|
|
if row is not None:
|
|
alive[r["resource_type"]] += 1
|
|
|
|
if not ledger:
|
|
status = "NO_BATCH_DATA"
|
|
detail = "账本无本批记录"
|
|
else:
|
|
status = "HAS_BATCH_DATA"
|
|
detail = "按账本精确范围统计"
|
|
return dict(status=status, detail=detail, ledger_counts=counts, ledger_alive=alive,
|
|
demo_users_present=present_demo_users(),
|
|
demo_companies_present=present_demo_companies(),
|
|
demo_jobs_present=present_demo_jobs())
|
|
|
|
|
|
# ── 计划打印 ────────────────────────────────────────────────────────
|
|
|
|
def print_plan(actions, counters):
|
|
print("[%s] 导入预览(dry-run,未写入,共 %d 项动作):" % (BATCH_ID, len(actions)))
|
|
for a in actions:
|
|
print(" %-14s %s" % (a["kind"], a["label"]))
|
|
print(" 计划新建:", counters)
|
|
|
|
|
|
def print_cleanup_plan(plan):
|
|
print("[%s] 清理预览(dry-run,未写入):" % BATCH_ID)
|
|
print(" 将删除岗位:", [j["id"] for j in plan["jobs"]])
|
|
print(" 将删除企业:", [c["id"] for c in plan["companies"]])
|
|
print(" 将删除用户:", [u["id"] for u in plan["users"]])
|
|
print(" 将删除账本行:", len(plan["ledger_ids"]))
|
|
|
|
|
|
# ── CLI ─────────────────────────────────────────────────────────────
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description="B01 展示字段批次演示数据工具(默认 dry-run)")
|
|
parser.add_argument("--host", default="127.0.0.1")
|
|
parser.add_argument("--port", type=int, default=3308)
|
|
parser.add_argument("--database", default="mydreamjob_b01_demo")
|
|
parser.add_argument("--user", default="b01demo")
|
|
parser.add_argument("--password", default="b01-demo-only")
|
|
parser.add_argument("--apply", action="store_true", help="显式确认执行写入")
|
|
parser.add_argument("--cleanup", action="store_true", help="执行定向清理(需配合 --apply)")
|
|
parser.add_argument("--verify", action="store_true", help="按账本精确核对")
|
|
args = parser.parse_args()
|
|
|
|
conn = connect(args)
|
|
try:
|
|
with conn.cursor() as cur:
|
|
assert_business_schema(cur)
|
|
identity = target_identity(cur, args)
|
|
|
|
if args.verify:
|
|
print("[%s] verify identity=%s:" % (BATCH_ID, identity),
|
|
json.dumps(verify(cur, identity), ensure_ascii=False))
|
|
return 0
|
|
|
|
if args.cleanup:
|
|
ledger = read_ledger(cur, identity, for_update=args.apply)
|
|
plan, conflicts = build_cleanup_plan(cur, identity, ledger, for_update=args.apply)
|
|
if conflicts:
|
|
print("[%s] 清理拒绝:" % BATCH_ID)
|
|
for c in conflicts:
|
|
print(" -", c)
|
|
conn.rollback()
|
|
return 3
|
|
if not args.apply:
|
|
print_cleanup_plan(plan)
|
|
conn.rollback()
|
|
return 0
|
|
removed, conflicts = apply_cleanup(conn, cur, identity)
|
|
if conflicts:
|
|
print("[%s] 清理拒绝:" % BATCH_ID)
|
|
for c in conflicts:
|
|
print(" -", c)
|
|
return 3
|
|
print("[%s] cleanup applied:" % BATCH_ID, removed)
|
|
return 0
|
|
|
|
ledger = read_ledger(cur, identity)
|
|
actions, conflicts = build_plan(cur, identity, ledger)
|
|
if conflicts:
|
|
print("[%s] 导入预检失败,已拒绝任何写入:" % BATCH_ID)
|
|
for c in conflicts:
|
|
print(" -", c)
|
|
conn.rollback()
|
|
return 3
|
|
if not args.apply:
|
|
counters = dict(
|
|
users=sum(1 for a in actions if a["kind"] == "create_user"),
|
|
companies=sum(1 for a in actions if a["kind"] == "create_company"),
|
|
jobs=sum(1 for a in actions if a["kind"] == "create_job"))
|
|
print_plan(actions, counters)
|
|
conn.rollback()
|
|
return 0
|
|
# 执行前针对事务内最新状态重新校验(apply_import 内部重新 build_plan)
|
|
created, conflicts = apply_import(conn, cur, identity)
|
|
if conflicts:
|
|
print("[%s] 导入预检失败,已拒绝任何写入:" % BATCH_ID)
|
|
for c in conflicts:
|
|
print(" -", c)
|
|
return 3
|
|
print("[%s] apply 完成,新建:" % BATCH_ID, created,
|
|
"核对:", json.dumps(verify(cur, identity), ensure_ascii=False))
|
|
return 0
|
|
finally:
|
|
conn.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|
|
|