Browse Source
Add job/company display fields, V57 migration, strict binding and normalized benefits contracts. Harden resume source ownership and UTF-8 download filenames; fix scoring test clock. Include regression tests, safe demo tooling and current handover docs. Validated frozen source: 3464 tests, 106 HTTP checks, 6 restart checks, 129 contract checks, 13 negative checks and 64 tool safety checks.fix/mysql57-module-migrations-20261009
58 changed files with 7810 additions and 105 deletions
@ -0,0 +1,103 @@ |
|||
# -*- coding: utf-8 -*- |
|||
"""岗位与企业展示字段批次:把运行时 OpenAPI 的展示字段约束与文档模型同步进静态契约 |
|||
``docs/openapi-rc.json``。 |
|||
|
|||
本轮整改(对照整改项 2)后,本批相关字段的约束由**纯文档型注解**在运行时表达 |
|||
(如 ``@Schema(types={"string","null"}, maxLength=40)``、``@ArraySchema(maxItems=20, schema=@Schema(types={"string"}, maxLength=40))``), |
|||
因此静态契约与运行时契约可以在**同一 OpenAPI 3.1 表示**下逐项对齐: |
|||
|
|||
* ``JobCreateRequest`` / ``JobUpdateRequest``:experience/education(≤40,可空)、 |
|||
workLocation(≤255,可空)、benefits(数组、最多 20 项、单项 ≤40、元素为 string)、 |
|||
salaryMonths(整数 1—24,可空),以及 salaryMin/salaryMax 的“元/月”说明。 |
|||
* ``CompanyCreateRequest`` / ``CompanyUpdateRequest`` / ``CompanyPublicView`` 的 companySize(≤40,可空)。 |
|||
* ``CompanyManagedJobItem`` 的展示字段。 |
|||
* Map 型岗位响应的**文档模型**:``ApiResponseJobDetailViewDoc`` / ``ApiResponseJobListViewDoc`` / |
|||
``ApiResponseJobPublicViewDoc`` 及其 data(``JobDetailViewDoc``/``JobListViewDoc``/``JobPublicViewDoc``/``JobDocCompanySummary``)。 |
|||
这 7 个组件整体从运行时同步,并把 ``GET /jobs``、``POST /jobs``、``PUT /jobs/{jobId}``、``GET /jobs/{jobId}``、 |
|||
``GET /jobs/public/{jobId}`` 的 200 响应引用与说明对齐。 |
|||
|
|||
不新增/删除端点(本批基线 paths=201 / operations=245 不变)。 |
|||
|
|||
用法: |
|||
python docs/_update_job_company_display_fields_openapi.py [--runtime <runtime-openapi.json>] |
|||
""" |
|||
import argparse |
|||
import json |
|||
from pathlib import Path |
|||
|
|||
ROOT = Path(__file__).resolve().parent |
|||
STATIC = ROOT / "openapi-rc.json" |
|||
DEFAULT_RUNTIME = ROOT / "acceptance" / "batch-display-fields-20261016" / "runtime-openapi.json" |
|||
|
|||
DISPLAY_FIELDS = ("experience", "education", "benefits", "salaryMonths", "workLocation") |
|||
SALARY_FIELDS = ("salaryMin", "salaryMax") |
|||
JOB_REQUEST_SCHEMAS = ("JobCreateRequest", "JobUpdateRequest") |
|||
COMPANY_SIZE_SCHEMAS = ("CompanyCreateRequest", "CompanyUpdateRequest", "CompanyPublicView") |
|||
DOC_SCHEMAS = ("JobDocCompanySummary", "JobDetailViewDoc", "JobPublicViewDoc", "JobListViewDoc", |
|||
"ApiResponseJobDetailViewDoc", "ApiResponseJobPublicViewDoc", "ApiResponseJobListViewDoc") |
|||
MAP_RESPONSE_OPS = (("/jobs", "get", "ApiResponseJobListViewDoc"), |
|||
("/jobs", "post", "ApiResponseJobDetailViewDoc"), |
|||
("/jobs/{jobId}", "get", "ApiResponseJobDetailViewDoc"), |
|||
("/jobs/{jobId}", "put", "ApiResponseJobDetailViewDoc"), |
|||
("/jobs/public/{jobId}", "get", "ApiResponseJobPublicViewDoc")) |
|||
|
|||
|
|||
def patch_display_props(static_schemas, runtime_schemas): |
|||
for name in JOB_REQUEST_SCHEMAS: |
|||
sp = static_schemas[name]["properties"] |
|||
rp = runtime_schemas[name]["properties"] |
|||
benefits = rp['benefits'] |
|||
assert benefits.get('x-normalization') == dict(trimItems=True, deduplicate=True, rejectBlankItems=True, |
|||
maxItems=20, itemMaxLength=40), '运行时福利语义错误,拒绝覆盖静态契约' |
|||
assert 'maxItems' not in benefits and 'maxLength' not in benefits.get('items', {}), '拒绝写入原始请求限制' |
|||
for field in DISPLAY_FIELDS + SALARY_FIELDS: |
|||
sp[field] = rp[field] |
|||
for name in COMPANY_SIZE_SCHEMAS: |
|||
static_schemas[name]["properties"]["companySize"] = \ |
|||
runtime_schemas[name]["properties"]["companySize"] |
|||
mp = static_schemas["CompanyManagedJobItem"]["properties"] |
|||
rmp = runtime_schemas["CompanyManagedJobItem"]["properties"] |
|||
for field in DISPLAY_FIELDS + SALARY_FIELDS: |
|||
mp[field] = rmp[field] |
|||
|
|||
|
|||
def patch_doc_schemas(static_schemas, runtime_schemas): |
|||
for name in DOC_SCHEMAS: |
|||
assert name in runtime_schemas, "运行时缺少文档模型 %s" % name |
|||
static_schemas[name] = runtime_schemas[name] |
|||
|
|||
|
|||
def patch_response_refs(static_paths, runtime_paths): |
|||
for path, method, schema in MAP_RESPONSE_OPS: |
|||
static_resp = static_paths[path][method]["responses"]["200"] |
|||
runtime_resp = runtime_paths[path][method]["responses"]["200"] |
|||
static_resp["description"] = runtime_resp.get("description", static_resp.get("description", "OK")) |
|||
static_resp["content"] = runtime_resp["content"] |
|||
ref = runtime_resp["content"]["*/*"]["schema"].get("$ref", "") |
|||
assert ref.endswith(schema), "%s %s 运行时响应引用异常:%s" % (method.upper(), path, ref) |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("--runtime", default=str(DEFAULT_RUNTIME)) |
|||
args = parser.parse_args() |
|||
|
|||
doc = json.load(open(STATIC, encoding="utf-8")) |
|||
runtime = json.load(open(args.runtime, encoding="utf-8")) |
|||
static_schemas = doc["components"]["schemas"] |
|||
runtime_schemas = runtime["components"]["schemas"] |
|||
|
|||
patch_display_props(static_schemas, runtime_schemas) |
|||
patch_doc_schemas(static_schemas, runtime_schemas) |
|||
patch_response_refs(doc["paths"], runtime["paths"]) |
|||
|
|||
with open(STATIC, "w", encoding="utf-8", newline="\n") as fh: |
|||
json.dump(doc, fh, ensure_ascii=False, indent=2) |
|||
fh.write("\n") |
|||
print("display-fields OpenAPI updated: paths=", len(doc["paths"]), |
|||
"operations=", sum(len(v) for v in doc["paths"].values()), |
|||
"schemas=", len(static_schemas)) |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
main() |
|||
@ -0,0 +1,354 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
"""岗位与企业展示字段批次:HTTP 黑盒验收(针对本批隔离 JAR)。 |
|||
|
|||
仅访问隔离环境(默认 http://127.0.0.1:18080/api/v1),写操作只发生在 b01-app-mysql 隔离库。 |
|||
不改认证/安全策略;不打印 Token 原文。 |
|||
""" |
|||
import argparse |
|||
import json |
|||
import subprocess |
|||
import sys |
|||
import time |
|||
import urllib.error |
|||
import urllib.request |
|||
|
|||
CONTAINER = "b01-app-mysql" |
|||
DB = "mydreamjob_b01_demo" |
|||
DB_USER = "b01demo" |
|||
DB_PASS = "b01-demo-only" |
|||
PHONE_PREFIX = "19902" |
|||
CODE = "246810" |
|||
API = "/api/v1" |
|||
|
|||
rows = [] |
|||
|
|||
|
|||
def record(section, case, expected, actual, ok, note=""): |
|||
rows.append({"section": section, "case": case, "expected": expected, |
|||
"actual": str(actual)[:300], "ok": bool(ok), "note": note}) |
|||
print("[%s] %-8s %-52s exp=%-26s act=%s %s" % ( |
|||
"PASS" if ok else "FAIL", section, case, expected, str(actual)[:80], note)) |
|||
return ok |
|||
|
|||
|
|||
def http(base, method, path, token=None, body=None, raw=None, timeout=30): |
|||
headers = {} |
|||
data = None |
|||
if raw is not None: |
|||
data = raw |
|||
headers["Content-Type"] = "application/json" |
|||
elif body is not None: |
|||
data = json.dumps(body).encode() |
|||
headers["Content-Type"] = "application/json" |
|||
if token: |
|||
headers["Authorization"] = "Bearer " + token |
|||
req = urllib.request.Request(base + path, data=data, headers=headers, method=method) |
|||
try: |
|||
with urllib.request.urlopen(req, timeout=timeout) as resp: |
|||
return resp.status, resp.read().decode("utf-8", "replace") |
|||
except urllib.error.HTTPError as e: |
|||
return e.code, e.read().decode("utf-8", "replace") |
|||
except Exception as e: |
|||
return -1, "CONNECTION_ERROR: %s" % type(e).__name__ |
|||
|
|||
|
|||
def mysql(sql, db=DB): |
|||
p = subprocess.run(["docker", "exec", "-i", CONTAINER, "mysql", |
|||
"-u" + DB_USER, "-p" + DB_PASS, "--default-character-set=utf8mb4", |
|||
"-N", "-B", db, "-e", sql], |
|||
capture_output=True, text=True, encoding="utf-8", errors="replace") |
|||
if p.returncode != 0: |
|||
raise RuntimeError("mysql failed: %s" % (p.stderr or "").strip()[:300]) |
|||
return [ln.split("\t") for ln in p.stdout.strip().splitlines() if ln != ""] |
|||
|
|||
|
|||
def scalar(sql): |
|||
r = mysql(sql) |
|||
return r[0][0] if r else None |
|||
|
|||
|
|||
def phone(n): |
|||
return "%s%06d" % (PHONE_PREFIX, n) |
|||
|
|||
|
|||
def seed_sms(p): |
|||
mysql("INSERT INTO sms_verification_codes (phone, code, purpose, attempts, max_attempts, " |
|||
"expired_at, used, created_at) VALUES ('%s','%s','login',0,5," |
|||
"DATE_ADD(NOW(), INTERVAL 1 DAY),0,NOW());" % (p, CODE)) |
|||
|
|||
|
|||
def login(base, p): |
|||
seed_sms(p) |
|||
st, body = http(base, "POST", API + "/auth/login", body={"phone": p, "code": CODE, "purpose": "login"}) |
|||
if st != 200: |
|||
raise RuntimeError("login failed for %s: %s %s" % (p, st, body[:200])) |
|||
return json.loads(body)["data"] |
|||
|
|||
|
|||
def as_company(base, p): |
|||
data = login(base, p) |
|||
uid = scalar("SELECT id FROM users WHERE phone='%s'" % p) |
|||
mysql("UPDATE users SET role='COMPANY' WHERE id=%s; INSERT IGNORE INTO user_roles(user_id, role) " |
|||
"VALUES (%s,'COMPANY');" % (uid, uid)) |
|||
return data["accessToken"], int(uid) |
|||
|
|||
|
|||
def jbody(body): |
|||
try: |
|||
return json.loads(body) |
|||
except Exception: |
|||
return {} |
|||
|
|||
|
|||
def data_of(body): |
|||
return jbody(body).get("data") |
|||
|
|||
|
|||
def run(base, api_prefix): |
|||
api = api_prefix |
|||
seq = int(time.time()) % 100000 |
|||
|
|||
# ── A. 企业资料 + companySize 归一化 ───────────────────────── |
|||
tok_a, uid_a = as_company(base, phone(seq)) |
|||
st, body = http(base, "POST", api + "/companies/me", token=tok_a, |
|||
body={"name": "黑盒企业A", "companySize": " 100-499人 ", |
|||
"logoUrl": "https://logo.example/b01.png", |
|||
"description": "黑盒企业简介", "industry": "互联网", |
|||
"website": "https://b01.example"}) |
|||
d = data_of(body) or {} |
|||
record("A", "创建 companySize 去空白", "200/100-499人", "%s/%s" % (st, d.get("companySize")), |
|||
st == 200 and d.get("companySize") == "100-499人") |
|||
cid = d.get("id") |
|||
st, body = http(base, "PUT", api + "/companies/me", token=tok_a, body={"name": "黑盒企业A"}) |
|||
record("A", "更新缺省保持原值", "100-499人", (data_of(body) or {}).get("companySize"), |
|||
(data_of(body) or {}).get("companySize") == "100-499人") |
|||
st, body = http(base, "GET", api + "/companies/" + str(cid)) |
|||
record("A", "公开读取 companySize", "100-499人", (data_of(body) or {}).get("companySize"), |
|||
(data_of(body) or {}).get("companySize") == "100-499人") |
|||
st, _ = http(base, "PUT", api + "/companies/me", token=tok_a, |
|||
body={"name": "黑盒企业A", "companySize": "x" * 41}) |
|||
record("A", "companySize 超长 400", 400, st, st == 400) |
|||
st, body = http(base, "PUT", api + "/companies/me", token=tok_a, |
|||
body={"name": "黑盒企业A", "companySize": " "}) |
|||
record("A", "空白清空为 null", "None", (data_of(body) or {}).get("companySize"), |
|||
(data_of(body) or {}).get("companySize") in (None, "")) |
|||
mysql("UPDATE companies SET verified=1, company_size='100-499人' WHERE id=%s;" % cid) |
|||
|
|||
# ── B. 岗位全字段 + 各接口一致 ─────────────────────────────── |
|||
city = "B01CITY" + str(seq) |
|||
full = {"title": "黑盒岗位", "description": "岗位描述", "requirements": "岗位要求", |
|||
"city": city, "employmentType": "FULL_TIME", "salaryMin": 20000, "salaryMax": 40000, |
|||
"experience": "3-5年", "education": "本科", "benefits": ["五险一金", "弹性工作"], |
|||
"salaryMonths": 14, "workLocation": "上海市浦东新区"} |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, body=full) |
|||
jid = (data_of(body) or {}).get("id") |
|||
record("B", "全字段创建岗位", "200", st, st == 200) |
|||
st, _ = http(base, "PATCH", api + "/jobs/%s/status" % jid, token=tok_a, body={"status": "PUBLISHED"}) |
|||
record("B", "发布岗位", "200", st, st == 200) |
|||
|
|||
def check_fields(label, node): |
|||
ok = (node.get("experience") == "3-5年" and node.get("education") == "本科" |
|||
and node.get("salaryMonths") == 14 and node.get("workLocation") == "上海市浦东新区" |
|||
and node.get("benefits") == ["五险一金", "弹性工作"]) |
|||
record("B", label, "5 字段一致", "exp=%s ben=%s sm=%s" % ( |
|||
node.get("experience"), node.get("benefits"), node.get("salaryMonths")), ok) |
|||
|
|||
st, body = http(base, "GET", api + "/jobs?city=" + city) |
|||
check_fields("GET /jobs 字段一致", (data_of(body) or {}).get("items", [{}])[0]) |
|||
st, body = http(base, "GET", api + "/jobs/search?city=" + city + "&companyId=" + str(cid)) |
|||
check_fields("GET /jobs/search 字段一致", (data_of(body) or {}).get("items", [{}])[0]) |
|||
st, body = http(base, "GET", api + "/jobs/public/" + str(jid)) |
|||
check_fields("GET /jobs/public/{id} 字段一致", data_of(body) or {}) |
|||
st, body = http(base, "GET", api + "/companies/%s/jobs" % cid) |
|||
check_fields("GET /companies/{id}/jobs 字段一致", (data_of(body) or {}).get("items", [{}])[0]) |
|||
st, body = http(base, "GET", api + "/companies/me/jobs", token=tok_a) |
|||
check_fields("GET /companies/me/jobs 字段一致", (data_of(body) or {}).get("items", [{}])[0]) |
|||
st, body = http(base, "GET", api + "/jobs/" + str(jid), token=tok_a) |
|||
check_fields("GET /jobs/{id} 字段一致", data_of(body) or {}) |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(jid), token=tok_a, body={"title": "黑盒岗位-更新"}) |
|||
check_fields("PUT /jobs/{id} 响应字段一致", data_of(body) or {}) |
|||
|
|||
# ── C. 缺省 / null / 空白 / benefits=[] ───────────────────── |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, body={ |
|||
"title": "最小岗位", "description": "d", "city": city, "employmentType": "FULL_TIME"}) |
|||
d2 = data_of(body) or {} |
|||
record("C", "缺省新字段读为 null/[]", "all None + []", |
|||
"exp=%s sm=%s ben=%s" % (d2.get("experience"), d2.get("salaryMonths"), d2.get("benefits")), |
|||
d2.get("experience") is None and d2.get("salaryMonths") is None and d2.get("benefits") == []) |
|||
st, body = http(base, "PUT", api + "/jobs/%s" % jid, token=tok_a, body={"benefits": []}) |
|||
record("C", "benefits=[] 清空", "[]", (data_of(body) or {}).get("benefits"), |
|||
(data_of(body) or {}).get("benefits") == []) |
|||
st, body = http(base, "PUT", api + "/jobs/%s" % jid, token=tok_a, |
|||
body={"experience": " 5-10年 ", "workLocation": " "}) |
|||
d3 = data_of(body) or {} |
|||
record("C", "空白清空 workLocation / trim experience", "5-10年 / None", |
|||
"%s / %s" % (d3.get("experience"), d3.get("workLocation")), |
|||
d3.get("experience") == "5-10年" and d3.get("workLocation") is None) |
|||
|
|||
# ── D. salaryMonths 类型与范围 ────────────────────────────── |
|||
base_job = {"title": "月数岗位", "description": "d", "city": city, "employmentType": "FULL_TIME"} |
|||
for raw in ["1", "24"]: |
|||
payload = json.dumps({**dict(base_job, title="月数OK" + raw), "salaryMonths": int(raw)}).encode() |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, raw=payload) |
|||
record("D", "salaryMonths=%s 成功" % raw, 200, st, st == 200) |
|||
for raw in ["0", "-1", "25", "1.9", "24.9", "1.0", '"12"', '""', "true", "[]", "{}", "99999999999"]: |
|||
body = ('{"title":"月数非法","description":"d","city":"%s","employmentType":"FULL_TIME",' |
|||
'"salaryMonths":%s}' % (city, raw)).encode() |
|||
st, resp = http(base, "POST", api + "/jobs", token=tok_a, raw=body) |
|||
record("D", "salaryMonths=%s -> 400" % raw, 400, st, st == 400) |
|||
|
|||
# ── E. 超长/非法 benefits -> 400 且无部分更新 ──────────────── |
|||
st, _ = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="超长经验", experience="e" * 41)) |
|||
record("E", "experience 41 字符 -> 400", 400, st, st == 400) |
|||
st, _ = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="超多福利", benefits=["b%d" % i for i in range(21)])) |
|||
record("E", "benefits 21 项 -> 400", 400, st, st == 400) |
|||
|
|||
before = int(scalar("SELECT count(*) FROM jobs WHERE title='B01PARTIAL'")) |
|||
st, _ = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="B01PARTIAL", salaryMonths=25)) |
|||
after = int(scalar("SELECT count(*) FROM jobs WHERE title='B01PARTIAL'")) |
|||
record("E", "失败创建无部分写入", 400, "before=%d after=%d" % (before, after), |
|||
st == 400 and after == before) |
|||
|
|||
st, _ = http(base, "PUT", api + "/jobs/" + str(jid), token=tok_a, |
|||
body={"title": "B01PARTIAL_TITLE", "salaryMonths": 25}) |
|||
title_now = scalar("SELECT title FROM jobs WHERE id=%s" % jid) |
|||
record("E", "失败更新无部分写入", 400, "%s / %s" % (st, title_now), |
|||
st == 400 and title_now == "黑盒岗位-更新") |
|||
|
|||
# ── F. 公开企业摘要白名单 ─────────────────────────────────── |
|||
st, body = http(base, "GET", api + "/companies/" + str(cid)) |
|||
node = data_of(body) or {} |
|||
keys = sorted(node.keys()) |
|||
record("F", "公开企业摘要 7 字段", 7, keys, |
|||
keys == sorted(["id", "name", "logoUrl", "description", "industry", "verified", "companySize"])) |
|||
raw = body |
|||
leak_keys = [k for k in ("ownerUserId", "address", "website", "phone", "email", |
|||
"materialsJson", "reviewerId", "decisionReason") if k in node] |
|||
record("F", "无 ownerUserId/地址/联系方式", "no leak", "裸响应含敏感字段=%s" % leak_keys, |
|||
not leak_keys and "ownerUserId" not in raw) |
|||
|
|||
# ── G. 草稿不可公开 / 跨企业不可改 ────────────────────────── |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="黑盒草稿")) |
|||
draft = (data_of(body) or {}).get("id") |
|||
st, _ = http(base, "GET", api + "/jobs/public/" + str(draft)) |
|||
record("G", "草稿公开详情 404", 404, st, st == 404) |
|||
tok_b, _ = as_company(base, phone(seq + 1)) |
|||
st, _ = http(base, "PUT", api + "/jobs/%s" % jid, token=tok_b, body={"title": "越权"}) |
|||
record("G", "跨企业修改 404", 404, st, st == 404) |
|||
|
|||
# ── H. 历史 NULL 记录读取 ─────────────────────────────────── |
|||
mysql("INSERT INTO jobs (company_id,title,description,city,employment_type,status,created_at,updated_at) " |
|||
"VALUES (%s,'B01LEGACY','desc','%s','FULL_TIME','PUBLISHED',NOW(),NOW());" % (cid, city)) |
|||
legacy = scalar("SELECT id FROM jobs WHERE title='B01LEGACY'") |
|||
st, body = http(base, "GET", api + "/jobs/public/" + str(legacy)) |
|||
node = data_of(body) or {} |
|||
record("H", "历史记录 benefits=[] 且新字段 null", "[] / None", |
|||
"%s / %s" % (node.get("benefits"), node.get("experience")), |
|||
node.get("benefits") == [] and node.get("experience") is None) |
|||
|
|||
# ── J. 福利限制必须在 trim、去重之后判断(真实 HTTP) ────── |
|||
padded = " " * 25 + "A" * 40 + " " * 25 |
|||
st, body = http(base, "POST", api + "/jobs", token=tok_a, |
|||
body=dict(base_job, title="福利归一化", benefits=[padded] * 21)) |
|||
normal = data_of(body) or {} |
|||
normal_id = normal.get("id") |
|||
record("J", "创建允许原始超20项及trim前超40字", ["A" * 40], normal.get("benefits"), |
|||
st == 200 and normal.get("benefits") == ["A" * 40]) |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, |
|||
body={"benefits": [padded] * 21}) |
|||
record("J", "更新同样按归一化后限制", ["A" * 40], (data_of(body) or {}).get("benefits"), |
|||
st == 200 and (data_of(body) or {}).get("benefits") == ["A" * 40]) |
|||
st, _ = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, |
|||
body={"benefits": ["A" * 41]}) |
|||
record("J", "trim后41字拒绝", 400, st, st == 400) |
|||
twenty = ["B" + str(i) for i in range(20)] |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, body={"benefits": twenty}) |
|||
record("J", "20项不同福利允许", twenty, (data_of(body) or {}).get("benefits"), |
|||
st == 200 and (data_of(body) or {}).get("benefits") == twenty) |
|||
st, _ = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, |
|||
body={"title": "不可部分更新", "benefits": twenty + ["B20"]}) |
|||
record("J", "21项不同福利拒绝", 400, st, st == 400) |
|||
st, body = http(base, "GET", api + "/jobs/" + str(normal_id), token=tok_a) |
|||
preserved = data_of(body) or {} |
|||
record("J", "拒绝后无部分更新", "原题及20项福利", preserved.get("title"), |
|||
st == 200 and preserved.get("title") == "福利归一化" and preserved.get("benefits") == twenty) |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, body={"benefits": None}) |
|||
record("J", "null保留福利", twenty, (data_of(body) or {}).get("benefits"), |
|||
st == 200 and (data_of(body) or {}).get("benefits") == twenty) |
|||
st, body = http(base, "PUT", api + "/jobs/" + str(normal_id), token=tok_a, body={"benefits": []}) |
|||
record("J", "空数组清空福利", [], (data_of(body) or {}).get("benefits"), |
|||
st == 200 and (data_of(body) or {}).get("benefits") == []) |
|||
|
|||
# ── 持久化用夹具(供重启后核对) ─────────────────────────── |
|||
mysql("UPDATE companies SET description='B01PERSIST' WHERE id=%s" % cid) |
|||
return {"companyId": cid, "jobId": jid, "city": city, "token": tok_a, "uid": uid_a} |
|||
|
|||
|
|||
def persist_create(base, api): |
|||
seq = int(time.time()) % 100000 |
|||
tok, uid = as_company(base, phone(seq)) |
|||
st, body = http(base, "POST", api + "/companies/me", token=tok, |
|||
body={"name": "B01PERSIST企业", "companySize": "500-999人", |
|||
"description": "B01PERSIST"}) |
|||
cid = (data_of(body) or {}).get("id") |
|||
mysql("UPDATE companies SET verified=1 WHERE id=%s" % cid) |
|||
st, body = http(base, "POST", api + "/jobs", token=tok, body={ |
|||
"title": "B01PERSIST岗位", "description": "d", "city": "B01PERSISTCITY", |
|||
"employmentType": "FULL_TIME", "experience": "5-10年", "salaryMonths": 16, |
|||
"benefits": ["五险一金"], "workLocation": "上海市"}) |
|||
jid = (data_of(body) or {}).get("id") |
|||
print("PERSIST_CREATED companyId=%s jobId=%s" % (cid, jid)) |
|||
return cid, jid |
|||
|
|||
|
|||
def persist_verify(base, api): |
|||
cid = scalar("SELECT id FROM companies WHERE name='B01PERSIST企业'") |
|||
jid = scalar("SELECT id FROM jobs WHERE title='B01PERSIST岗位'") |
|||
st, body = http(base, "GET", api + "/companies/" + str(cid)) |
|||
record("I", "重启后企业资料仍在", "B01PERSIST企业/500-999人", |
|||
"%s/%s" % ((data_of(body) or {}).get("name"), (data_of(body) or {}).get("companySize")), |
|||
(data_of(body) or {}).get("name") == "B01PERSIST企业" |
|||
and (data_of(body) or {}).get("companySize") == "500-999人") |
|||
mysql("UPDATE jobs SET status='PUBLISHED', published_at=NOW() WHERE id=%s" % jid) |
|||
st, body = http(base, "GET", api + "/jobs/public/" + str(jid)) |
|||
node = data_of(body) or {} |
|||
record("I", "重启后岗位展示字段仍在", "5-10年/16/[五险一金]", |
|||
"%s/%s/%s" % (node.get("experience"), node.get("salaryMonths"), node.get("benefits")), |
|||
node.get("experience") == "5-10年" and node.get("salaryMonths") == 16 |
|||
and node.get("benefits") == ["五险一金"]) |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("--base", default="http://127.0.0.1:18080") |
|||
parser.add_argument("--api", default="/api/v1") |
|||
parser.add_argument("--mode", default="full", |
|||
choices=["full", "persist-create", "persist-verify"]) |
|||
parser.add_argument("--out") |
|||
args = parser.parse_args() |
|||
global API |
|||
API = args.api |
|||
base = args.base.rstrip("/") |
|||
if args.mode == "persist-create": |
|||
persist_create(base, args.api) |
|||
return |
|||
if args.mode == "persist-verify": |
|||
persist_verify(base, args.api) |
|||
else: |
|||
summary = run(base, args.api) |
|||
print("FIXTURE companyId=%s jobId=%s" % (summary["companyId"], summary["jobId"])) |
|||
bad = [r for r in rows if not r["ok"]] |
|||
print("\n== BLACKBOX SUMMARY: total=%d fail=%d ==" % (len(rows), len(bad))) |
|||
if args.out: |
|||
with open(args.out, "w", encoding="utf-8") as fh: |
|||
json.dump(rows, fh, ensure_ascii=False, indent=1) |
|||
sys.exit(1 if bad else 0) |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
main() |
|||
@ -0,0 +1,140 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
"""本批 OpenAPI 契约对照脚本的**反向测试**。 |
|||
|
|||
通过在**临时副本**中分别制造下列变异,证明严格对照脚本在每种情况下都会失败 |
|||
(进程退出码非 0);**不修改正式契约**来制造这些测试: |
|||
|
|||
1. 删除一个约束(JobCreateRequest.experience.maxLength) |
|||
2. 改错类型(JobCreateRequest.salaryMonths.type: integer → string) |
|||
3. 改错范围(JobCreateRequest.salaryMonths.maximum: 24 → 12) |
|||
4. 移除字段(JobCreateRequest.properties.education) |
|||
5. 加入敏感字段(CompanyPublicView.properties.ownerUserId) |
|||
6. 改错可空性(JobCreateRequest.experience.type 去掉 "null") |
|||
7. 改错数组元素长度(benefits.items.maxLength: 40 → 41) |
|||
8. 改错响应引用(/jobs/public/{jobId} 200 引用改为 ApiResponseJobDetailViewDoc) |
|||
|
|||
用法: |
|||
python b01_contract_negative_tests.py [--runtime PATH] [--static PATH] |
|||
退出码:0=全部(基线通过 + 各变异均失败);1=存在不符合预期的结果。 |
|||
""" |
|||
import argparse |
|||
import copy |
|||
import json |
|||
import subprocess |
|||
import sys |
|||
import tempfile |
|||
from pathlib import Path |
|||
|
|||
HERE = Path(__file__).resolve().parent |
|||
ROOT = HERE.parents[2] |
|||
COMPARE = HERE / "b01_openapi_compare.py" |
|||
DEFAULT_STATIC = ROOT / "docs" / "openapi-rc.json" |
|||
DEFAULT_RUNTIME = HERE / "runtime-openapi.json" |
|||
|
|||
results = [] |
|||
|
|||
|
|||
def run_compare(static_path, runtime_path, out_path): |
|||
proc = subprocess.run( |
|||
[sys.executable, str(COMPARE), "--static", str(static_path), |
|||
"--runtime", str(runtime_path), "--out", str(out_path), |
|||
"--runtime-out", str(out_path)], |
|||
capture_output=True, text=True, encoding="utf-8", errors="replace") |
|||
return proc.returncode, proc.stdout |
|||
|
|||
|
|||
def mutate_remove_max_length(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["experience"].pop("maxLength", None) |
|||
|
|||
|
|||
def mutate_wrong_type(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["salaryMonths"]["type"] = ["string", "null"] |
|||
|
|||
|
|||
def mutate_wrong_range(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["salaryMonths"]["maximum"] = 12 |
|||
|
|||
|
|||
def mutate_remove_field(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"].pop("education", None) |
|||
|
|||
|
|||
def mutate_sensitive_field(doc): |
|||
doc["components"]["schemas"]["CompanyPublicView"]["properties"]["ownerUserId"] = {"type": "integer"} |
|||
|
|||
|
|||
def mutate_nullability(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["experience"]["type"] = "string" |
|||
|
|||
|
|||
def mutate_item_length(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["x-normalization"]["itemMaxLength"] = 41 |
|||
|
|||
def mutate_normalization_removed(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"].pop("x-normalization") |
|||
|
|||
def mutate_trim(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["x-normalization"]["trimItems"] = False |
|||
|
|||
def mutate_raw_limit(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["maxItems"] = 20 |
|||
|
|||
def mutate_benefits_null(doc): |
|||
doc["components"]["schemas"]["JobCreateRequest"]["properties"]["benefits"]["type"] = "array" |
|||
|
|||
|
|||
def mutate_response_ref(doc): |
|||
doc["paths"]["/jobs/public/{jobId}"]["get"]["responses"]["200"]["content"]["*/*"]["schema"]["$ref"] = \ |
|||
"#/components/schemas/ApiResponseJobDetailViewDoc" |
|||
|
|||
|
|||
MUTATIONS = [ |
|||
("① 删除约束 experience.maxLength", mutate_remove_max_length), |
|||
("② 改错类型 salaryMonths→string", mutate_wrong_type), |
|||
("③ 改错范围 salaryMonths.maximum=12", mutate_wrong_range), |
|||
("④ 移除字段 education", mutate_remove_field), |
|||
("⑤ 加入敏感字段 ownerUserId", mutate_sensitive_field), |
|||
("⑥ 改错可空性 experience 去掉 null", mutate_nullability), |
|||
("⑦ 改错数组元素长度 items.maxLength=41", mutate_item_length), |
|||
("⑧ 改错响应引用 public job", mutate_response_ref), |
|||
("⑨ 移除归一化规则", mutate_normalization_removed), |
|||
("⑩ 错误关闭trim", mutate_trim), |
|||
("⑪ 错误限制原始请求20项", mutate_raw_limit), |
|||
("⑫ 错误拒绝福利null", mutate_benefits_null), |
|||
] |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("--static", default=str(DEFAULT_STATIC)) |
|||
parser.add_argument("--runtime", default=str(DEFAULT_RUNTIME)) |
|||
args = parser.parse_args() |
|||
|
|||
baseline = json.load(open(args.static, encoding="utf-8")) |
|||
with tempfile.TemporaryDirectory() as tmp: |
|||
tmp = Path(tmp) |
|||
# 0) 基线(未变异)必须通过 |
|||
out0 = tmp / "compare-baseline.json" |
|||
rc, _ = run_compare(args.static, args.runtime, out0) |
|||
results.append(("基线(正式契约 + 运行时)通过", rc == 0, "rc=%d" % rc)) |
|||
|
|||
# 各变异必须失败(rc != 0) |
|||
for name, mutate in MUTATIONS: |
|||
doc = copy.deepcopy(baseline) |
|||
mutate(doc) |
|||
mutated = tmp / "static-mutated.json" |
|||
mutated.write_text(json.dumps(doc, ensure_ascii=False, indent=2), encoding="utf-8") |
|||
out = tmp / "compare.json" |
|||
rc, stdout = run_compare(mutated, args.runtime, out) |
|||
results.append(("%s → 对照脚本失败" % name, rc != 0, "rc=%d" % rc)) |
|||
|
|||
bad = [r for r in results if not r[1]] |
|||
for name, ok, detail in results: |
|||
print("[%s] %-46s %s" % ("PASS" if ok else "FAIL", name, detail)) |
|||
print("\n== CONTRACT NEGATIVE SUMMARY: total=%d fail=%d ==" % (len(results), len(bad))) |
|||
return 1 if bad else 0 |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
sys.exit(main()) |
|||
@ -0,0 +1,249 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
"""本批 JAR 的运行时 /v3/api-docs 与静态契约 docs/openapi-rc.json 的**严格**对照。 |
|||
|
|||
对照范围(整改项 2): |
|||
* 具体路径 + 方法 + 200 响应 Schema 引用(逐 operation 核对,不只看路径数量)。 |
|||
* 展示字段的 类型 / 数组元素类型 / 范围 / 长度 / 数量 / 可空性,在静态与运行时之间逐项比较; |
|||
任一必需项不一致即失败(不再把「约束缺失」判为通过,取消 “static-only 即通过”)。 |
|||
* Map 型岗位响应的**文档模型**(ApiResponseJobDetailViewDoc / ApiResponseJobListViewDoc / |
|||
ApiResponseJobPublicViewDoc 及其 data)结构与静态一致。 |
|||
* 公开企业摘要严格 7 字段白名单;多出敏感字段即失败。 |
|||
|
|||
用法: |
|||
python b01_openapi_compare.py [base-url] [--static PATH] [--runtime PATH] [--out PATH] |
|||
退出码:0=全部通过;1=存在失败。 |
|||
""" |
|||
import argparse |
|||
import json |
|||
import sys |
|||
import urllib.request |
|||
from pathlib import Path |
|||
|
|||
ROOT = Path(__file__).resolve().parents[3] |
|||
DEFAULT_STATIC = ROOT / "docs" / "openapi-rc.json" |
|||
ACCEPT_DIR = Path(__file__).resolve().parent |
|||
DEFAULT_RUNTIME_OUT = ACCEPT_DIR / "runtime-openapi.json" |
|||
DEFAULT_COMPARE_OUT = ACCEPT_DIR / "openapi-compare.json" |
|||
|
|||
# 逐 operation 期望的响应 Schema 引用(要求为各操作专属文档模型,不得共用 MapStringObject)。 |
|||
EXPECTED_REFS = { |
|||
("/jobs", "get"): "ApiResponseJobListViewDoc", |
|||
("/jobs", "post"): "ApiResponseJobDetailViewDoc", |
|||
("/jobs/{jobId}", "get"): "ApiResponseJobDetailViewDoc", |
|||
("/jobs/{jobId}", "put"): "ApiResponseJobDetailViewDoc", |
|||
("/jobs/public/{jobId}", "get"): "ApiResponseJobPublicViewDoc", |
|||
} |
|||
|
|||
PUBLIC_COMPANY_FIELDS = ["companySize", "description", "id", "industry", "logoUrl", "name", "verified"] |
|||
FORBIDDEN_PUBLIC_FIELDS = ["ownerUserId", "address", "website", "phone", "email", "createdAt", |
|||
"updatedAt", "materialsJson", "decisionReason", "reviewerId"] |
|||
DOC_SCHEMAS = ["JobDocCompanySummary", "JobDetailViewDoc", "JobPublicViewDoc", "JobListViewDoc", |
|||
"ApiResponseJobDetailViewDoc", "ApiResponseJobPublicViewDoc", "ApiResponseJobListViewDoc"] |
|||
JOB_DETAIL_FIELDS = ["id", "companyId", "title", "description", "requirements", "city", |
|||
"employmentType", "salaryMin", "salaryMax", "experience", "education", |
|||
"benefits", "salaryMonths", "workLocation", "status", "publishedAt", |
|||
"createdAt", "updatedAt"] |
|||
JOB_PUBLIC_FIELDS = ["id", "companyId", "title", "description", "requirements", "city", |
|||
"employmentType", "salaryMin", "salaryMax", "experience", "education", |
|||
"benefits", "salaryMonths", "workLocation", "status", "publishedAt", |
|||
"createdAt", "company"] |
|||
JOB_LIST_FIELDS = ["items", "total", "page", "size", "totalPages"] |
|||
|
|||
rows = [] |
|||
|
|||
|
|||
def record(case, expected, actual, ok): |
|||
rows.append({"case": case, "expected": str(expected)[:200], "actual": str(actual)[:200], |
|||
"ok": bool(ok)}) |
|||
print("[%s] %-62s exp=%-20s act=%s" % ("PASS" if ok else "FAIL", case, |
|||
str(expected)[:20], str(actual)[:70])) |
|||
|
|||
|
|||
def props(node): |
|||
return (node or {}).get("properties", {}) |
|||
|
|||
|
|||
def canon_type(node): |
|||
value = (node or {}).get("type") |
|||
if value is None: |
|||
return None |
|||
return tuple(sorted(value)) if isinstance(value, list) else value |
|||
|
|||
|
|||
def is_nullable(node): |
|||
value = (node or {}).get("type") |
|||
if isinstance(value, list) and "null" in value: |
|||
return True |
|||
return bool((node or {}).get("nullable")) |
|||
|
|||
|
|||
def get(node, path): |
|||
cur = node |
|||
for token in path.split("."): |
|||
if not isinstance(cur, dict): |
|||
return None |
|||
cur = cur.get(token) |
|||
return cur |
|||
|
|||
|
|||
def cmp_field(case_prefix, s_node, r_node, aspects): |
|||
"""逐 aspect 比较静态/运行时字段节点;缺失即失败。""" |
|||
if s_node is None or r_node is None: |
|||
record("%s 存在(静态/运行时)" % case_prefix, "present", "s=%s r=%s" % (s_node is not None, r_node is not None), |
|||
s_node is not None and r_node is not None) |
|||
return |
|||
for path, kind in aspects: |
|||
sv, rv = get(s_node, path), get(r_node, path) |
|||
if kind == "type": |
|||
sv, rv = canon_type({"type": sv}), canon_type({"type": rv}) |
|||
elif kind == "nullable": |
|||
sv, rv = is_nullable(s_node), is_nullable(r_node) |
|||
record("%s %s 一致" % (case_prefix, path), sv, rv, sv == rv and sv is not None) |
|||
|
|||
|
|||
def cmp_ref(op_path, method, static, runtime): |
|||
def ref(doc): |
|||
return doc["paths"].get(op_path, {}).get(method, {}).get( |
|||
"responses", {}).get("200", {}).get("content", {}).get("*/*", {}).get( |
|||
"schema", {}).get("$ref", "") |
|||
s_ref, r_ref = ref(static), ref(runtime) |
|||
expected = EXPECTED_REFS[(op_path, method)] |
|||
record("%s %s 运行时 200 引用 = %s" % (method.upper(), op_path, expected), |
|||
"#/components/schemas/" + expected, r_ref, r_ref.endswith(expected)) |
|||
record("%s %s 静态/运行时 200 引用一致" % (method.upper(), op_path), s_ref, r_ref, |
|||
s_ref == r_ref and s_ref != "") |
|||
record("%s %s 未共用 ApiResponseMapStringObject" % (method.upper(), op_path), |
|||
"专属模型", r_ref, "MapStringObject" not in r_ref) |
|||
name = expected |
|||
in_static = name in static["components"]["schemas"] |
|||
in_runtime = name in runtime["components"]["schemas"] |
|||
record("%s %s 引用组件双方存在" % (method.upper(), op_path), True, |
|||
"static=%s runtime=%s" % (in_static, in_runtime), in_static and in_runtime) |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("base", nargs="?", default="http://127.0.0.1:18080") |
|||
parser.add_argument("--static", default=str(DEFAULT_STATIC)) |
|||
parser.add_argument("--runtime", default=None) |
|||
parser.add_argument("--out", default=str(DEFAULT_COMPARE_OUT)) |
|||
parser.add_argument("--runtime-out", default=str(DEFAULT_RUNTIME_OUT)) |
|||
args = parser.parse_args() |
|||
|
|||
static = json.load(open(args.static, encoding="utf-8")) |
|||
if args.runtime: |
|||
runtime = json.load(open(args.runtime, encoding="utf-8")) |
|||
else: |
|||
url = args.base.rstrip("/") + "/api/v1/v3/api-docs" |
|||
with urllib.request.urlopen(url, timeout=90) as resp: |
|||
runtime = json.loads(resp.read().decode("utf-8")) |
|||
Path(args.runtime_out).write_text(json.dumps(runtime, ensure_ascii=False, indent=2), |
|||
encoding="utf-8") |
|||
|
|||
s_schemas, r_schemas = static["components"]["schemas"], runtime["components"]["schemas"] |
|||
s_paths, r_paths = static.get("paths", {}), runtime.get("paths", {}) |
|||
|
|||
# 1) 基线:路径数与 operation 数一致 |
|||
record("运行时路径数 == 静态契约", len(s_paths), len(r_paths), len(s_paths) == len(r_paths)) |
|||
record("运行时 operation 数 == 静态契约", |
|||
sum(len(v) for v in s_paths.values()), sum(len(v) for v in r_paths.values()), |
|||
sum(len(v) for v in s_paths.values()) == sum(len(v) for v in r_paths.values())) |
|||
|
|||
# 2) 逐 operation 响应引用 |
|||
for (path, method) in EXPECTED_REFS: |
|||
cmp_ref(path, method, static, runtime) |
|||
|
|||
# 3) 请求 DTO 展示字段约束 |
|||
text_aspects = [("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")] |
|||
for name in ("JobCreateRequest", "JobUpdateRequest"): |
|||
sp, rp = props(s_schemas.get(name)), props(r_schemas.get(name)) |
|||
for field in ("experience", "education"): |
|||
cmp_field("%s.%s" % (name, field), sp.get(field), rp.get(field), text_aspects) |
|||
cmp_field("%s.workLocation" % name, sp.get("workLocation"), rp.get("workLocation"), |
|||
[("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")]) |
|||
cmp_field("%s.benefits" % name, sp.get("benefits"), rp.get("benefits"), |
|||
[("type", "type"), ("nullable", "nullable"), ("items.type", "raw"), |
|||
("x-normalization", "raw")]) |
|||
for label, node in (("静态", sp.get("benefits") or {}), ("运行时", rp.get("benefits") or {})): |
|||
expected = dict(trimItems=True, deduplicate=True, rejectBlankItems=True, maxItems=20, itemMaxLength=40) |
|||
record("%s %s 福利归一化语义" % (label, name), expected, node.get("x-normalization"), |
|||
node.get("x-normalization") == expected) |
|||
record("%s %s 不限制原始数组数量或未trim长度" % (label, name), "absent", |
|||
(node.get("maxItems"), node.get("items", {}).get("maxLength")), |
|||
"maxItems" not in node and "maxLength" not in node.get("items", {})) |
|||
record("%s %s 福利可空" % (label, name), True, is_nullable(node), is_nullable(node)) |
|||
cmp_field("%s.salaryMonths" % name, sp.get("salaryMonths"), rp.get("salaryMonths"), |
|||
[("type", "type"), ("minimum", "raw"), ("maximum", "raw"), ("nullable", "nullable")]) |
|||
for field in ("salaryMin", "salaryMax"): |
|||
record("%s.%s 双方存在且为 integer" % (name, field), "integer", |
|||
canon_type(rp.get(field)), canon_type(sp.get(field)) == "integer" |
|||
and canon_type(rp.get(field)) == "integer") |
|||
|
|||
# 4) 企业请求 / 公开摘要的 companySize |
|||
for name in ("CompanyCreateRequest", "CompanyUpdateRequest", "CompanyPublicView"): |
|||
cmp_field("%s.companySize" % name, |
|||
props(s_schemas.get(name)).get("companySize"), |
|||
props(r_schemas.get(name)).get("companySize"), |
|||
[("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")]) |
|||
|
|||
# 5) 企业自身岗位管理摘要 |
|||
for field in ("experience", "education", "workLocation"): |
|||
cmp_field("CompanyManagedJobItem.%s" % field, |
|||
props(s_schemas.get("CompanyManagedJobItem")).get(field), |
|||
props(r_schemas.get("CompanyManagedJobItem")).get(field), |
|||
[("type", "type"), ("maxLength", "raw")]) |
|||
cmp_field("CompanyManagedJobItem.benefits", |
|||
props(s_schemas.get("CompanyManagedJobItem")).get("benefits"), |
|||
props(r_schemas.get("CompanyManagedJobItem")).get("benefits"), |
|||
[("type", "type"), ("maxItems", "raw"), ("items.type", "raw"), ("items.maxLength", "raw")]) |
|||
cmp_field("CompanyManagedJobItem.salaryMonths", |
|||
props(s_schemas.get("CompanyManagedJobItem")).get("salaryMonths"), |
|||
props(r_schemas.get("CompanyManagedJobItem")).get("salaryMonths"), |
|||
[("type", "type"), ("minimum", "raw"), ("maximum", "raw")]) |
|||
|
|||
# 6) 公开企业摘要严格 7 字段白名单(静态与运行时) |
|||
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)): |
|||
node = props(schemas.get("CompanyPublicView")) |
|||
record("CompanyPublicView %s 严格 7 字段" % label, PUBLIC_COMPANY_FIELDS, |
|||
sorted(node.keys()), sorted(node.keys()) == PUBLIC_COMPANY_FIELDS) |
|||
leaked = [f for f in FORBIDDEN_PUBLIC_FIELDS if f in node] |
|||
record("CompanyPublicView %s 无敏感字段" % label, "无", leaked, not leaked) |
|||
|
|||
# 7) 文档模型:双方存在、属性集合与类型一致 |
|||
for name in DOC_SCHEMAS: |
|||
s_node, r_node = s_schemas.get(name), r_schemas.get(name) |
|||
record("文档模型 %s 双方存在" % name, True, "static=%s runtime=%s" % (s_node is not None, r_node is not None), |
|||
s_node is not None and r_node is not None) |
|||
if s_node is None or r_node is None: |
|||
continue |
|||
record("文档模型 %s 属性集合一致" % name, sorted(props(s_node).keys()), |
|||
sorted(props(r_node).keys()), sorted(props(s_node).keys()) == sorted(props(r_node).keys())) |
|||
mismatched = [k for k in props(s_node) |
|||
if canon_type(props(s_node)[k]) != canon_type(props(r_node).get(k))] |
|||
record("文档模型 %s 逐属性类型一致" % name, "无差异", mismatched, not mismatched) |
|||
|
|||
# 8) 文档模型语义:必要 data 字段 |
|||
for schema_name, fields in (("JobDetailViewDoc", JOB_DETAIL_FIELDS), |
|||
("JobPublicViewDoc", JOB_PUBLIC_FIELDS), |
|||
("JobListViewDoc", JOB_LIST_FIELDS)): |
|||
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)): |
|||
missing = [f for f in fields if f not in props(schemas.get(schema_name))] |
|||
record("%s %s 必需字段齐全" % (label, schema_name), fields, missing, not missing) |
|||
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)): |
|||
node = props(schemas.get("JobDocCompanySummary")) |
|||
record("%s JobDocCompanySummary 严格 7 字段" % label, PUBLIC_COMPANY_FIELDS, |
|||
sorted(node.keys()), sorted(node.keys()) == PUBLIC_COMPANY_FIELDS) |
|||
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)): |
|||
b = props(schemas.get("JobPublicViewDoc")).get("benefits") or {} |
|||
record("%s 响应端 benefits 恒为数组(非可空)" % label, "array", b.get("type"), |
|||
b.get("type") == "array") |
|||
|
|||
bad = [r for r in rows if not r["ok"]] |
|||
print("\n== OPENAPI COMPARE SUMMARY: total=%d fail=%d ==" % (len(rows), len(bad))) |
|||
Path(args.out).write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") |
|||
return 1 if bad else 0 |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
sys.exit(main()) |
|||
@ -0,0 +1,266 @@ |
|||
# 岗位与企业展示字段 · 前端交接说明 |
|||
|
|||
> 批次:job-company-display-fields |
|||
> 后端基线:`1cbadc3e18a786ef642b0d329adde6b173c7b2ac`(分支 `fix/mysql57-module-migrations-20261009`) |
|||
> 数据库迁移:`V57__job_company_display_fields.sql` |
|||
> 本文档面向前端工程师,描述本批岗位展示字段的接入参考,不改变既有字段语义。 |
|||
> 更新:2026-10-19。本地后端验收已通过,但尚未提交、推送或发布;目标服务器是否具备这些字段必须按实际部署版本确认。本文不是前端已经完成接入的证明。 |
|||
|
|||
--- |
|||
|
|||
## 1. 新字段在哪些端点返回 |
|||
|
|||
| 端点 | 新增顶层字段 | `company` 摘要 | |
|||
|---|---|---| |
|||
| `GET /jobs`(公开列表) | ✅ 5 个 | ✅ 新增 | |
|||
| `GET /jobs/search`(公开搜索) | ✅ 5 个 | ✅ 新增(兼容扩展) | |
|||
| `GET /jobs/public/{jobId}`(公开详情) | ✅ 5 个 | ✅ 新增 | |
|||
| `GET /companies/{companyId}/jobs` | ✅ 5 个 | ✅ 新增 | |
|||
| `GET /jobs/{jobId}`(企业端自己看) | ✅ 5 个 | ❌ 不返回 | |
|||
| `POST /jobs`、`PUT /jobs/{jobId}`、`PATCH /jobs/{jobId}/status` | ✅ 5 个(响应同企业端视图) | ❌ 不返回 | |
|||
| `GET /companies/me/jobs`(企业端岗位管理) | ✅ 5 个 | ❌ 不返回 | |
|||
| `GET /companies/{companyId}`(公开企业详情) | `companySize` | — | |
|||
| `GET/POST/PUT /companies/me`(企业端自己看) | `companySize` | — | |
|||
| `GET /jobs/favorites`(我的收藏) | ✅ 5 个 | ❌ 不返回 | |
|||
|
|||
「5 个」= `experience`、`education`、`benefits`、`salaryMonths`、`workLocation`。 |
|||
|
|||
> 说明:收藏列表复用公开视图组装,因此自动带上 5 个新增顶层字段;本批未为收藏域增加 `company` 嵌套。若前端需要企业名,请先用岗位的 `companyId` 调 `GET /companies/{companyId}`。 |
|||
|
|||
--- |
|||
|
|||
## 2. 字段类型、单位、缺省值与更新语义 |
|||
|
|||
### 2.1 岗位新增字段 |
|||
|
|||
| 字段 | 类型 | 缺省值 | 写入语义 | |
|||
|---|---|---|---| |
|||
| `experience` | string \| null | `null` | `null`/缺省 = 保持原值;**空白串 = 清空为 null**;非空 = 去两端空白后保存;最长 40 | |
|||
| `education` | string \| null | `null` | 同 `experience`,最长 40 | |
|||
| `benefits` | string[] | `[]`(**永不返回 null**) | `null`/缺省 = 保持原值;**`[]` = 清空**;非空 = 逐项 trim → 拒绝空白项 → 单项 ≤40 → **去重且保留首次出现顺序** → 最多 20 项;违反任一条返回 400 | |
|||
| `salaryMonths` | int \| null | `null` | `null`/缺省 = 保持原值(**本批不提供显式清空**);非 null 必须 **1—24**,**`0` 视为非法**返回 400;**只接受 JSON 整数——小数(`1.9`/`1.0`)、数字字符串(`"12"`/`""`)、布尔、数组、对象、超出 int 范围一律 400**,绝不截断或四舍五入 | |
|||
| `workLocation` | string \| null | `null` | 同 `experience`,最长 255 | |
|||
|
|||
### 2.2 企业新增字段 |
|||
|
|||
| 字段 | 类型 | 缺省值 | 写入语义 | |
|||
|---|---|---|---| |
|||
| `companySize` | string \| null | `null` | `null`/缺省 = 保持原值;**空白串 = 清空为 null**;非空 = trim 后保存;**超过 40 字符返回 400,不截断** | |
|||
|
|||
上述「缺省/null 保持原值」针对更新;创建时没有旧值,对应可选字段存 NULL。响应中的福利始终为数组,请求中的 `benefits` 可以为 null。 |
|||
|
|||
### 2.3 福利归一化与 OpenAPI 3.1 |
|||
|
|||
- 去重后的数量上限为 20,不是原始请求数组上限:21 个相同标签会成为 1 项并可接受,21 个不同标签返回 400。 |
|||
- 单项长度在 trim 后判断;空白项拒绝,不是自动删去。 |
|||
- 更新 `benefits: null` 保留原值,`benefits: []` 清空。不要把这两种请求合并。 |
|||
- 静态及运行时契约用 `x-normalization` 描述 `trimItems`、`deduplicate`、`rejectBlankItems`、`maxItems=20`、`itemMaxLength=40`,不对原始请求误加 `maxItems`/元素 `maxLength`。 |
|||
- 这是项目的业务扩展,通用 OpenAPI 客户端不会自动执行归一化。若前端加校验,应按相同顺序处理;后端仍是最终校验方。 |
|||
- 运行时为 OpenAPI 3.1,可空字段使用包含 null 的联合类型,不以缺少 OpenAPI 3.0 的 `nullable` 判为不可空。 |
|||
|
|||
### 2.4 单位与语义(**保持不变**) |
|||
|
|||
- `salaryMin` / `salaryMax`:**单位仍是「元/月」**,本批未改成 K。前端展示 K 时自行 `/1000`(既有约定)。 |
|||
- 行业仍来自 `companies.industry`,**不在岗位表重复存储**。 |
|||
|
|||
### 2.5 只读约定 |
|||
|
|||
- `benefits` 读取时:库中为 `NULL` → 返回 `[]`(不是 `null`)。前端可安全地对它做 `.length` / `.map`,无需判空。 |
|||
- `company`:企业不存在时返回 `null`,**不会编造数据、也不会 500**。前端需容忍 `company === null`。 |
|||
|
|||
--- |
|||
|
|||
## 3. `company` 嵌套结构 → 前端类型映射 |
|||
|
|||
后端 `company` 对象(白名单,**仅这 7 个字段**): |
|||
|
|||
```ts |
|||
interface BackendJobCompany { |
|||
id: number; |
|||
name: string; |
|||
logoUrl: string | null; |
|||
description: string | null; |
|||
industry: string | null; |
|||
verified: boolean; |
|||
companySize: string | null; |
|||
} |
|||
``` |
|||
|
|||
建议按此前前端类型映射到 `src/types/job.ts`;本轮未修改或重新验收前端,具体类型请以接手工程师当前代码为准: |
|||
|
|||
| 后端字段 | 前端字段 | 备注 | |
|||
|---|---|---| |
|||
| `company.id` | —(可用于跳企业页) | 前端 `Job` 无对应字段 | |
|||
| `company.name` | `Job.companyName` | 已有映射 `company.name → companyName` | |
|||
| `company.logoUrl` | — | 前端暂无对应字段 | |
|||
| `company.description` | — | 前端暂无对应字段 | |
|||
| **`company.industry`** | **`Job.industry`** | 本批接通,取代前端写死的空值 | |
|||
| `company.verified` | — | 前端暂无对应字段 | |
|||
| **`company.companySize`** | **`JobDetail.companySize`** | 本批接通 | |
|||
| `job.workLocation` | (见 §4) | | |
|||
| `job.city` | `Job.city` | 既有 | |
|||
| `job.title` | `Job.positionName` | 既有 | |
|||
| `job.salaryMin/salaryMax` | `Job.salaryMin/salaryMax` | **单位元→K 需 /1000** | |
|||
| `job.experience` | `Job.experience` | 本批接通 | |
|||
| `job.education` | `Job.education` | 本批接通 | |
|||
| `job.benefits` | `Job.benefits` | 本批接通 | |
|||
| `job.salaryMonths` | `JobDetail.salaryMonths` | 本批接通(展示为「N 薪」) | |
|||
| `job.description` / `requirements` | `JobDetail.description` / `requirements` | 既有,原样保留换行、不截断 | |
|||
|
|||
**逐条要点**: |
|||
1. `company.industry` → 前端 `industry`:直接取 `company.industry`,缺失时为 `null`。 |
|||
2. `company.companySize` → 前端 `companySize`:直接取 `company.companySize`,缺失时为 `null`。 |
|||
3. 顶层 `experience`、`education`、`benefits`、`salaryMonths` 直接映射到同名前端字段。 |
|||
|
|||
--- |
|||
|
|||
## 4. `workLocation` 的处理 |
|||
|
|||
- `workLocation` 是**岗位明确录入的工作地点**,独立于 `city`。 |
|||
- 若 `workLocation` 缺失(`null`),**前端可退化为展示 `city`**。 |
|||
- **但不得据此回写**:不要因为展示用了 `city`,就把 `city` 当作详细办公地址保存或提交回后端。后端的「企业登记地址」(`companies.address`)也**不会被**当作公开招聘地址下发,前端同样不要拿它当工作地点展示。 |
|||
|
|||
--- |
|||
|
|||
## 5. 评分与评价:继续调用既有真实接口 |
|||
|
|||
本批**没有**新增/修改任何评分、评价、排名相关字段或端点。前端请继续使用既有接口: |
|||
|
|||
- 岗位个性化评分:`GET /jobs/{jobId}/personalized-review-score` |
|||
- 岗位评价列表 / 汇总:`GET /jobs/{jobId}/reviews`、`GET /jobs/{jobId}/reviews/summary` |
|||
|
|||
本批也**不补造**综合评分、浏览量、排名或员工评价——这些字段若前端仍写死,属前端侧待接,不是后端缺字段。 |
|||
|
|||
--- |
|||
|
|||
## 6. 前端工程师需核对的字段映射 |
|||
|
|||
以下字段在本批后端验收制品中可返回。此前交接发现部分前端映射为空;接手工程师需核对当前实现,不能由本文件推断前端仍未接入或服务器已发布: |
|||
|
|||
| 前端字段 | 后端来源 | 备注 | |
|||
|---|---|---| |
|||
| `Job.experience` | `job.experience` | 本批新增下发 | |
|||
| `Job.education` | `job.education` | 本批新增下发 | |
|||
| `Job.benefits` | `job.benefits` | 本批新增下发,恒为数组 | |
|||
| `Job.industry` | `company.industry` | 本批新增下发 | |
|||
| `JobDetail.companySize` | `company.companySize` | 本批新增下发 | |
|||
| `JobDetail.salaryMonths` | `job.salaryMonths` | 本批新增下发 | |
|||
|
|||
需接入的展示字段应按上述来源映射;`JobDetail.views`(浏览数)本批**未新增提供**,不要把本地模拟值当服务器真实数据。 |
|||
|
|||
--- |
|||
|
|||
## 7. 投递 / 沟通按钮 |
|||
|
|||
投递、沟通、邀请面试等按钮属于**前端入口**。本批只是补齐后端岗位与企业资料字段,**不会自动生成任何按钮**,也不会新增/修改投递与沟通的接口契约。 |
|||
|
|||
--- |
|||
|
|||
## 8. 响应示例 |
|||
|
|||
### 8.1 新增字段完整(`GET /jobs/public/{jobId}`) |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"id": 101, |
|||
"companyId": 5, |
|||
"title": "前端开发工程师", |
|||
"description": "负责招聘平台 App 的页面开发。", |
|||
"requirements": "1. 熟悉 React Native\n2. 熟悉 TypeScript", |
|||
"city": "北京", |
|||
"employmentType": "FULL_TIME", |
|||
"salaryMin": 18000, |
|||
"salaryMax": 26000, |
|||
"experience": "3-5年", |
|||
"education": "本科", |
|||
"benefits": ["五险一金", "年终奖", "弹性工作"], |
|||
"salaryMonths": 14, |
|||
"workLocation": "北京市海淀区中关村大街 1 号 A 座 12 层", |
|||
"status": "PUBLISHED", |
|||
"publishedAt": "2026-10-01T10:00:00", |
|||
"createdAt": "2026-09-30T18:20:00", |
|||
"company": { |
|||
"id": 5, |
|||
"name": "演示示例科技有限公司", |
|||
"logoUrl": "https://example.invalid/logo.png", |
|||
"description": "非真实招聘,仅用于本地演示。", |
|||
"industry": "互联网/IT", |
|||
"verified": true, |
|||
"companySize": "100-499人" |
|||
} |
|||
}, |
|||
"requestId": "app-xxxxxx", |
|||
"timestamp": "2026-10-01T10:00:01Z" |
|||
} |
|||
``` |
|||
|
|||
### 8.2 历史数据字段缺失(老岗位,未补录) |
|||
|
|||
```json |
|||
{ |
|||
"code": 200, |
|||
"message": "ok", |
|||
"data": { |
|||
"id": 7, |
|||
"companyId": 2, |
|||
"title": "后端开发实习生", |
|||
"description": "参与接口开发。", |
|||
"requirements": null, |
|||
"city": "武汉", |
|||
"employmentType": "INTERN", |
|||
"salaryMin": 3000, |
|||
"salaryMax": 5000, |
|||
"experience": null, |
|||
"education": null, |
|||
"benefits": [], |
|||
"salaryMonths": null, |
|||
"workLocation": null, |
|||
"status": "PUBLISHED", |
|||
"publishedAt": "2026-09-01T09:00:00", |
|||
"createdAt": "2026-08-31T20:00:00", |
|||
"company": { |
|||
"id": 2, |
|||
"name": "演示联合信息科技有限公司", |
|||
"logoUrl": null, |
|||
"description": null, |
|||
"industry": null, |
|||
"verified": false, |
|||
"companySize": null |
|||
} |
|||
}, |
|||
"requestId": "app-yyyyyy", |
|||
"timestamp": "2026-10-01T10:00:01Z" |
|||
} |
|||
``` |
|||
|
|||
> 缺失即为 `null` / `[]`,**后端不会填充「本科」「3—5年」「13薪」等默认值**。前端请按缺失处理(隐藏区块或显示「未填写」)。 |
|||
|
|||
--- |
|||
|
|||
## 9. 兼容性提示 |
|||
|
|||
1. **旧请求不受影响**:不传新字段的创建/修改请求行为完全不变。 |
|||
2. **旧响应字段不变**:`companyId` 等既有字段保留,未删除、未改名、未改类型;`GET /jobs/search` 原 `company` 结构为**兼容扩展**(只在其中新增字段)。 |
|||
3. **草稿与已关闭岗位的可见性规则不变**:DRAFT 岗位公开详情仍不可见(404)。 |
|||
4. `error_code` 约定不变:本批所有新增字段的校验失败统一返回既有参数错误响应(`code=1001`)。 |
|||
|
|||
## 10. 发布后联调检查(尚未执行) |
|||
|
|||
以下为待办,不是已完成的验收结果。仅在对应后端版本发布后,使用获得授权的测试账号执行。 |
|||
|
|||
1. 确认响应来自目标服务而非本地示例;企业名、规模、行业和岗位五字段按实际值显示,缺失时隐藏或提示未填写。 |
|||
2. 月薪元到 K 的转换只做一次;`salaryMonths: null` 不默认为 13 或 0,不向更新接口提交 0。 |
|||
3. 检查字段缺省/null/空白/空数组语义,福利重复和 trim 边界与后端一致。 |
|||
4. 验证公开摘要仅七字段、`company: null` 可正常渲染;不要用企业登记地址代替工作地点。 |
|||
5. 评分和员工评价继续使用真实接口,不能用演示值补齐;投递和沟通入口按已有业务权限处理。 |
|||
6. 对参数错误展示可理解提示,失败后重新读取确认没有部分更新;不伪造成功。 |
|||
|
|||
## 11. 验收与相关说明 |
|||
|
|||
- 最新本地全量 `clean verify`:3464 项通过,失败/错误/跳过均为 0。真实 HTTP 黑盒合计 106 项,其中岗位企业展示 48 项;重启合计 6 项。 |
|||
- 静态/运行时契约对照 129 项通过,契约反向测试 13 项通过。不是前端端到端验收结果。 |
|||
- 交付报告:`docs/batch-job-company-display-fields-report.md`;简历权限与文件名规则:`docs/resume-ownership-hardening-20261016.md`。 |
|||
- 最新证据:`D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json`。未发布、未连接生产库、未向线上导入演示企业岗位。 |
|||
@ -0,0 +1,115 @@ |
|||
# 岗位与企业展示字段 · 交付报告(2026-10-19 验收更新) |
|||
|
|||
> 批次:job-company-display-fields;关联简历归属、文件名兼容和评分测试时钟修复。 |
|||
> 结论:冻结的本地后端工作区快照验收通过;尚未提交、推送、发布或完成线上复验。本文替代此前报告中的过期状态,不把本地结果等同于线上结果。 |
|||
|
|||
## 0. 本次更新与历史结论纠正 |
|||
|
|||
- 全量验证已从历史失败状态更新为 3464 项通过、0 失败、0 错误、0 跳过。 |
|||
- `benefits` 的归一化约束已在静态与运行时契约中一致表达,不再存在此前标注的 `STATIC_ONLY_CONSTRAINTS` 差异。 |
|||
- 福利请求限制按 trim、去重后的结果计算,不能用原始请求的 `maxItems: 20` 或原始元素的 `maxLength: 40` 代替。 |
|||
- 历史报告、旧 JAR 和旧黑盒产物不作为本次验收证据;最新证据目录见第 3、5、6 节。 |
|||
- 历史上传接口 500 的根因仍未证实。当前验证未复现,不将其直接归因于旧副本不一致。 |
|||
|
|||
## 1. 交付范围与身份 |
|||
|
|||
- 原工作区:`C:/Users/15642/Desktop/zhilai`,后端目录为 `mydreamjob-server`。 |
|||
- Git 基线:`1cbadc3e18a786ef642b0d329adde6b173c7b2ac`;分支:`fix/mysql57-module-migrations-20261009`。 |
|||
- 验收副本:`D:/Acceptance/qsp-final-20261019/app`。验收对象是包含未提交改动的冻结快照,不是仅 checkout 上述 HEAD 的代码。 |
|||
- 主要内容:企业规模与岗位展示字段、公开企业摘要、严格整数绑定、V57 迁移、契约及演示工具安全测试。 |
|||
- 关联修复:本人/企业/聊天简历来源归属、中文文件名兼容、评分测试固定时钟。详见 `docs/resume-ownership-hardening-20261016.md`。 |
|||
- 新增源码、测试和迁移存在未跟踪文件,提交时必须使用精确白名单,不能只提交已跟踪 diff,也不能 `git add .`。 |
|||
- 精确验收源文件清单与哈希:`D:/Acceptance/qsp-final-20261019/evidence/source-manifest-current.csv`;契约和工具清单:同目录 `contract-tool-manifest.csv`。 |
|||
|
|||
## 2. 实现与字段规则 |
|||
|
|||
- 企业新增 `companySize`;岗位新增 `experience`、`education`、`benefits`、`salaryMonths`、`workLocation`。详情及列表按对应 DTO 下发,公开 `company` 严格使用七字段白名单。 |
|||
- `companySize` 创建与更新共用归一化:创建缺省/null 存 NULL,更新缺省/null 保持原值;空白清空,非空 trim,超过 40 字符返回 400,不截断。 |
|||
- `salaryMonths` 仅接受 JSON 整数 1—24。小数(含 `1.0`)、数字字符串、布尔、数组、对象、超 int 范围均返回 400;仅字段级严格反序列化,未修改全局 Jackson 强制转换规则。 |
|||
- `benefits` 逐项 trim、拒绝空白项,trim 后单项最多 40 字符,去重保序后最多 20 项。21 个相同标签可归一为 1 项;21 个不同标签拒绝。 |
|||
- 福利创建缺省/null 存 NULL;更新缺省/null 保持原值;`[]` 清空。读取 NULL 返回 `[]`。 |
|||
- 岗位列表按页批量查询企业,查询预算测试覆盖空页及分页场景,避免逐岗位查企业的 N+1。 |
|||
- 只提供字段和接口,不会自动生成前端的投递、沟通或评价按钮,也不会补造评分或评价。 |
|||
|
|||
## 3. 测试结果与数量口径 |
|||
|
|||
| 验证范围 | 最新结果 | |
|||
|---|---| |
|||
| 全量 `clean verify` | 3464 项,失败 0,错误 0,跳过 0;BUILD SUCCESS | |
|||
| 真实 HTTP 黑盒 | 简历归属 32 + 来源归属与文件名 26 + 岗位企业展示 48 = 106 项通过 | |
|||
| 重启验证 | 简历字节与授权 4 + 企业岗位字段 2 = 6 项通过 | |
|||
| 静态/运行时契约 | 129 项通过;重启后重复通过,不重复累计 | |
|||
| 契约反向测试 | 13 项通过(含正确基线及错误变异拒绝) | |
|||
| 工具安全验证 | 隔离 MySQL 61 + 容器生命周期单测 3 = 64 项通过 | |
|||
|
|||
全量日志:`D:/Acceptance/qsp-final-20261019/logs/full-verify-current.log`。本次未使用 skip/ignore 参数,测试报告保留在验收副本的 target 中。 |
|||
|
|||
此前 3439 项快照不含后续文件名修复与恢复的三条正式福利 HTTP 测试等变更;源文件差异清单为 `evidence/changes-since-3439-snapshot.csv`。不同快照数字不得混用。未完成的早期执行日志不计入当前通过结果。 |
|||
|
|||
评分测试时间依赖已在测试配置中使用固定 Clock,与测试数据同一时刻,并覆盖 30 天边界;未修改生产评分规则。诊断探针保留为历史证据,不加入正式源码全量测试的计数。 |
|||
|
|||
## 4. MySQL 迁移与存储决策 |
|||
|
|||
- V57 为新增迁移,包含 `companies.company_size` 及岗位五个展示字段对应列;历史 V1—V56 未改。 |
|||
- 最新全量中的 `JobCompanyDisplayFieldsMysqlMigrationTest`、`JobCompanyDisplayFieldsMysqlValidateTest` 在真实 MySQL 容器执行,不用 H2 代替,也未跳过。 |
|||
- 测试覆盖 MySQL 5.7/8 的旧库升级、空库迁移、旧数据保留、新字段写读与 `ddl-auto=validate`。 |
|||
- `benefits_json` 保持 TEXT,沿用仓库兼容性决策;内容仍是服务层控制的 JSON 数组。未宣称使用 MySQL 原生 JSON 列,也未擅自改历史列类型。 |
|||
- MySQL 5.7 的既有兼容迁移钩子和工具链方言/版本警告仍需在目标部署环境关注;本地验证不替代线上迁移核对。 |
|||
|
|||
## 5. 最新验收 JAR |
|||
|
|||
- 路径:`D:/Acceptance/qsp-final-20261019/app/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 |
|||
- SHA256:`60A9EC9340C83E3E748DD6144473C09BB76BC967A7DA5481296F0FF6B715DF46`。 |
|||
- 构建方式:冻结快照完整 `clean verify` 成功产生制品,不是跳过测试的 package。 |
|||
- 身份证据:`evidence/jar-sha256.csv`、`source-manifest-current.csv`、`contract-tool-manifest.csv`。 |
|||
- 发布时须核对实际提交及 Jenkins 构建身份;JAR 哈希差异本身不能证明源码错误或一致,需结合源码清单。 |
|||
|
|||
## 6. HTTP 黑盒与重启 |
|||
|
|||
- 岗位企业 48 项覆盖字段一致、缺省/清空语义、严格整数绑定、公开摘要白名单、访问控制、福利归一化与失败无部分更新。 |
|||
- 最新岗位结果:`evidence/display-full.json`;日志:`logs/display-blackbox-complete.log`。先前 40 项执行已被这份 48 项完整结果替代,不另计。 |
|||
- 简历结果:`evidence/ownership-blackbox-result.json`、`evidence/reader-fix-blackbox-result.json`。 |
|||
- 重启后检查文件字节一致、授权/撤销状态保持、岗位企业字段持久化;日志为 `logs/resume-persistence.log`、`logs/display-persistence.log`。 |
|||
- 全部使用隔离环境合成账号和 PDF;未访问真实用户简历或生产数据。 |
|||
|
|||
## 7. OpenAPI 与福利归一化契约 |
|||
|
|||
- 静态文件:`docs/openapi-rc.json`;静态检查:`docs/verify_openapi.py`。 |
|||
- 运行时为 OpenAPI 3.1,可空类型使用类型联合(如 `type: [array, 'null']`),不得依赖 3.0 的 `nullable` 写法。 |
|||
- 福利请求的 `x-normalization`:`trimItems=true`、`deduplicate=true`、`rejectBlankItems=true`、`maxItems=20`、`itemMaxLength=40`。 |
|||
- 此扩展描述服务端归一化规则,不是通用 JSON Schema 自动验证关键字。前端若实现校验,应按相同步骤归一化后判断,不应对原始数组直接限 20 项。 |
|||
- 对照脚本要求归一化规则、字段类型、可空性、响应引用及七字段白名单一致;错误变异必须非零退出,不允许宽松静态独有豁免。 |
|||
- 最新证据:`evidence/openapi-compare.json`、`evidence/openapi-after-restart.json`,日志 `logs/contract-negative.log`、`logs/static-openapi-frozen.log`。 |
|||
|
|||
## 8. 演示数据与工具边界 |
|||
|
|||
- 工具:`docs/tools/b01_demo_display_data.py`;默认 dry-run,写入/清理需要显式参数。 |
|||
- 只允许明确的回环地址、端口 3308 和白名单演示库;非白名单目标在连接前拒绝。该工具不能直接用于线上补数。 |
|||
- 工具账本绑定批次、真实服务器身份、库名、资源 ID、演示键及指纹;同时检查实际用户/企业身份和岗位父子关系,禁止仅凭名称或手机号认领。 |
|||
- 冲突预检后再写入;业务写入与账本登记同事务。清理仅依据精确归属及依赖检查,遇外部依赖拒绝,不关闭外键或级联清除。 |
|||
- 安全测试不得删除同名既有容器,只清理本次创建并记录 ID 的测试容器。 |
|||
- 数据为 3 家虚构企业、12 个明确标记的演示岗位,无真实个人资料及伪造评分、评价。本轮未向线上导入。 |
|||
- 最新工具结果:`evidence/tool-safety.json`、`logs/tool-safety.log`、`logs/tool-container-guard.log`。 |
|||
|
|||
## 9. 前端交接 |
|||
|
|||
参见 `docs/batch-job-company-display-fields-frontend-handover.md`。需要前端工程师按目标环境实际响应接入新字段并处理 null/空数组;不能假定本地修复已经发布到服务器。 |
|||
|
|||
该说明保留字段映射、单位、更新语义和示例,补充福利归一化及本地/线上边界。简历权限和文件名规则另见简历修复说明,不在岗位说明中混入账号口令。 |
|||
|
|||
## 10. 尚待完成与风险 |
|||
|
|||
- Git 精确范围确认、提交、推送、Jenkins 发布及线上小范围复验均未执行。 |
|||
- 前端代码与目标页面本轮未修改、未重新验收;字段映射及账号切换体验须交前端工程师验证。 |
|||
- 历史缺失展示字段不会自动补录;需要授权后由业务录入真实资料,不能把演示数据作为真实招聘信息。 |
|||
- 历史污染简历引用、已生成快照未在线审计或自动修复;不得据本地用例宣称线上历史记录已全部清理。 |
|||
- 旧上传 500 根因仍未证实;当前不复现不等于已证明原因。 |
|||
- 当前仅完成本地后端验收,不宣称线上、前后端完整联调或生产数据验收通过。 |
|||
|
|||
## 11. 边界与证据保留 |
|||
|
|||
- 前端未改;两处受保护文件保持既有内容,不还原、不混入本批提交。 |
|||
- 未暂存、提交、推送、操作 Jenkins 或连接生产数据库;未使用真实账号和简历。 |
|||
- 未弱化/跳过测试、改历史迁移或执行 Docker 全局清理。 |
|||
- 最新验收应用与本轮服务容器已停止;测试资源和日志保留,不删除既有容器或卷。 |
|||
- 汇总证据:`D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json`。旧 `logs2`、`logs3`、`logs4` 资料作为历史记录保留,不作为当前验收结论。 |
|||
File diff suppressed because it is too large
@ -0,0 +1,129 @@ |
|||
# 简历文件归属与文件名修复(2026-10-19 验收更新) |
|||
|
|||
> 分支 `fix/mysql57-module-migrations-20261009`,基线 HEAD `1cbadc3e18a786ef642b0d329adde6b173c7b2ac`。 |
|||
> 本文是简历安全与文件名兼容的参考说明。关联批次已完成冻结快照的本地后端验收;尚未提交、推送、发布或完成线上复验。下述规则不是线上已生效的承诺。 |
|||
|
|||
## 1. 漏洞事实 |
|||
|
|||
| 环节 | 修复前行为 | |
|||
| --- | --- | |
|||
| 存储键 | `LocalResumeStorageService.store(userId, file)` 生成 `"{userId}_{UUID8}{ext}"`,**第一个下划线之前的数字即所有者** | |
|||
| 客户端可控引用 | `StudentProfileUpdateRequest.resumeUrl`(仅 `@Size(max=500)`)可被客户端任意填写 | |
|||
| 落库 | `ProfileService.updateProfile()` 直接 `profile.setResumeUrl(req.getResumeUrl())` | |
|||
| 读取 | `ProfileService.getResume()` 按资料引用直接 `storage.read(...)`;`LocalResumeStorageService.read()` 只校验路径越界与文件存在 | |
|||
| 删除 / 替换 | `deleteResume()` / `uploadResume()` 同样按该引用处理旧文件 | |
|||
|
|||
结论:**B 只要把自己的资料引用写成 A 的存储键,就能用自己的下载接口读到 A 的简历文件**(独立探针已复现)。 |
|||
|
|||
## 2. 调用链梳理 |
|||
|
|||
1. **普通资料修改**:`PUT /profile/me` → `ProfileController.updateMyProfile` → `ProfileService.updateProfile`(`@Transactional`,逐字段非空合并 + 保存 + 登记评分刷新)。 |
|||
2. **简历上传**:`POST /profile/me/resume` → `uploadResume`(锁资料行 → 存新文件 → 注册提交后删旧文件 → 保存 → 提交后删旧文件;失败时清理新文件)。 |
|||
3. **简历下载**:`GET /profile/me/resume` → `getResume` → `storage.read(resumeUrl)`。 |
|||
4. **简历删除**:`DELETE /profile/me/resume` → `deleteResume`(先 `chatResumeLifecycle.purgeForUser` 撤销聊天快照 → 清空资料字段 → 提交后删文件)。 |
|||
5. **简历版本**:`ResumeVersionService.createVersion/updateVersion/deleteVersion/setDefault`,`ResumeExportService`/`ResumeExportWorker` 导出。版本行有 `userId` 且 `requireOwnActiveVersion()` 已做「本人版本」校验;`file_storage_path` 同样由 `store(userId, file)` 生成,**不接受任何客户端引用**。 |
|||
6. **快照复用**: |
|||
- 聊天简历卡片:`ChatResumeService.send` 在读本人资料引用前验证源文件键归属,再将字节写入 `chat_resume_snapshots`;下载继续使用快照授权,不直接读资料引用。 |
|||
- 企业下载:`CompanyResumeService.load` 先由 `CompanyResumeAccessPolicy` 决定投递关系、隐私及企业授权,再验证源文件键属于该决策中的求职者(不是 HR),最后才读取。 |
|||
|
|||
**身份与校验分工**:身份一律来自已验证 JWT/SecurityContext(`CurrentUserAccessor`),无请求参数入口;`UserRoleGuard` 只做角色校验、不做归属校验。 |
|||
|
|||
**「事务提交前删除旧文件」问题**:**不存在**。`uploadResume` 的旧文件删除注册在 `afterCommit`(失败时清理本次新文件);`deleteResume` 的文件删除同样 `deleteFileAfterCommit`;`ResumeVersionService` 亦然。 |
|||
|
|||
## 3. 修复方案 |
|||
|
|||
### 3.1 新增归属校验器(`profile/storage/ResumeOwnershipVerifier.java`,NEW) |
|||
|
|||
- 解析规则:取**第一个下划线之前**的十进制数字作为所有者;与 `userId` **按数值精确比较**(禁止 `startsWith`,避免 `1` 与 `10` 前缀混淆)。 |
|||
- 失败关闭:`null`/空白、无下划线、前缀非纯数字、下划线后为空、含 `/` `\` `:`(路径穿越/绝对路径/盘符)、含 `..` → 一律视为「无法证明归属」并拒绝。 |
|||
- `assertOwnedBy()` 抛出 `ErrorCode.FORBIDDEN`(HTTP 403),消息为固定文案,**不回显存储键、磁盘路径、文件名或他人标识**。 |
|||
- 只做「键字符串归属解析」,**不介入** `ResumeStorageService.read()`——企业按投递关系下载、投递/聊天快照等合法授权流程保持原样通过。 |
|||
|
|||
### 3.2 禁止普通资料接口修改简历引用(`ProfileService.updateProfile`) |
|||
|
|||
- 补齐 **STUDENT 角色校验**(此前 `PUT /profile/me` 无角色校验,COMPANY/ADMIN 可创建/篡改学生资料)。 |
|||
- `req.getResumeUrl() != null`(含空串)→ `BusinessException(BAD_REQUEST)` → **HTTP 400**,且校验发生在任何字段赋值/保存之前,**整次拒绝、无部分更新**。 |
|||
- 缺省或显式 `null` → 不改变原简历引用(保持兼容)。 |
|||
- 说明:选择「保留 DTO 字段 + 服务层显式判断」而非 `@JsonIgnore`——`@JsonIgnore` 会让「未提供」与「显式传了非法值」都映射为 `null`,无法区分;本方案用 `!= null` 精确区分:未提供/显式 null 合法放行,任何提供的值一律拒绝。 |
|||
- 服务层保留该限制,内部调用同样无法绕过;简历引用只能由受控上传/版本/既有业务流程生成。 |
|||
|
|||
### 3.3 本人简历路径的归属校验(`ProfileService`) |
|||
|
|||
| 操作 | 校验位置 | 失败语义 | |
|||
| --- | --- | --- | |
|||
| 下载 `getResume` | 角色 → 资料存在 → `resumeUrl` 非空 → **归属校验** → 才 `storage.read` | 403,**不读文件** | |
|||
| 删除 `deleteResume` | 角色 → 资料行锁定 → **归属校验** → 才 `purgeForUser`/清空字段/登记删文件 | 403,资料、聊天快照、文件**均不变**,**不删除他人文件** | |
|||
| 替换 `uploadResume` | 角色 → 校验新文件 → 锁定资料 → **旧引用归属校验** → 才 `store(新文件)` | 403,不改资料、不删他人文件、**不留新文件** | |
|||
|
|||
`getResume` 未创建资料 / `resumeUrl` 为空 / 合法引用但文件缺失 → 沿用既有 `PROFILE_NOT_FOUND`(404)。 |
|||
|
|||
### 3.4 历史污染引用的后续读取 |
|||
|
|||
- `ChatResumeService.send` 在读源文件和生成聊天快照前调用归属校验器,拒绝借本人资料中的他人引用复制简历。 |
|||
- `CompanyResumeService.load` 在既有企业访问授权通过后,对 `decision.studentUserId()` 做源文件归属校验;不能用当前 HR 的 ID 校验候选人文件。 |
|||
- 保持 `ResumeStorageService` 与 `LocalResumeStorageService` 实现不变,避免将「必须本人读取」下沉到存储层而破坏合法 HR 下载。 |
|||
- 不同来源使用不同授权:源文件归属校验不能代替聊天成员、快照撤销或企业投递关系校验。 |
|||
|
|||
### 3.5 中文上传文件名与下载响应 |
|||
|
|||
- 共享 `ResumeFilenameSanitizer` 对可明确识别的含非 ASCII 字节 `%HH` 序列做一次严格 UTF-8 解码,修复中文名称被再次编码为 `%25E5...` 的问题。 |
|||
- 字面 `+` 保持为 `+`,不使用会把它变成空格的 `URLDecoder`。 |
|||
- 纯 ASCII 百分号序列(例如字面 `%20`、`%25`)存在歧义,保守保留;不能理解为任意百分号串都会解码。 |
|||
- 新上传按解码后的名称执行长度及路径/控制字符检查,非法编码或危险名称拒绝为 400;不递归解码嵌套的非 ASCII 百分号内容。 |
|||
- 历史文件下载时尽力归一化并净化名称,不重写历史记录、不改存储键、不改文件字节;不能安全解码时保留后净化,而非猜测内容。 |
|||
- 当前隔离 HTTP 用例覆盖本人和合法企业下载的中文文件名响应;尚未完成真实浏览器/前端目标版本的端到端验收。 |
|||
|
|||
## 4. 错误码与接口兼容 |
|||
|
|||
- 普通资料写入非法 `resumeUrl` → **HTTP 400 / code 1001**(沿用统一参数错误结构,未新增错误码)。 |
|||
- **已登录用户访问不属于自己的资源 → 403 / code 2002**(选择理由:与项目既有语义一致——`ResumeVersionService.requireOwnActiveVersion` 对他人版本返回 `FORBIDDEN`,`CompanyResumeAccessPolicy` 对越权企业同样返回 `FORBIDDEN`;且在日志中可与企业下载等正常授权区分)。 |
|||
- 接口形态未变:`PUT /profile/me` 的响应结构、`GET /profile/me` 的响应字段(含 `resumeUrl`)保持原样,未新增/删除字段;`PUT /profile/me` 的 Swagger `@Operation` 描述已补充「resumeUrl 只读」约束。 |
|||
- 未修改共享的 `docs/openapi-rc.json`(该文件已被其他批次改动,避免混入本次修复)。 |
|||
|
|||
## 5. 兼容性边界 |
|||
|
|||
- **历史键**:当前本地存储实现生成 `userId_UUID8.ext`。可证明归属且格式合法的键继续使用;不能证明归属的历史引用失败关闭。未审计线上全部历史记录,不宣称所有历史键均符合该格式。 |
|||
- **版本文件**:`resume_versions.file_storage_path` 由同一 `store()` 生成、格式一致;版本操作另有「版本行所属用户」校验,本轮未改动,合法版本/默认简历/导出行为不回退。 |
|||
- **其他存储类型**:目前只有本地实现。若将来引入对象存储等新键格式,必须同步扩展 `ResumeOwnershipVerifier` 的解析规则,否则会失败关闭。 |
|||
- **历史污染引用**:本轮**只做安全拒绝**,不自动删除、重绑、迁移或修复历史记录;也未新增任何联网排查工具。 |
|||
|
|||
## 6. 测试与验证(隔离环境合成数据) |
|||
|
|||
最新验收基于 `D:/Acceptance/qsp-final-20261019/app` 冻结快照,不复用旧副本的测试结论。 |
|||
|
|||
- 归属校验器单测、服务层测试和真实 SecurityFilterChain/Controller 参数绑定的 HTTP 测试全部纳入全量执行。 |
|||
- `ResumeOwnershipHttpIntegrationTest` 现含新增文件名兼容用例;另有 `ResumeFilenameEncodingTest`、企业下载回归与 `HistoricalResumeOwnershipIntegrationTest` 覆盖历史污染源及合法授权。 |
|||
- 跨账号拒绝路径检查文件、数据库状态及读写调用;中文文件名测试检查下载响应、字节一致以及旧记录不被回写。 |
|||
- 全量 `clean verify`:3464 项、0 失败、0 错误、0 跳过,BUILD SUCCESS。该数是全部后端测试总量,不是简历专项数。 |
|||
- 日志:`D:/Acceptance/qsp-final-20261019/logs/full-verify-current.log`。 |
|||
|
|||
历史说明:旧副本曾出现上传返回 500、HTTP 类 7/11 失败;当前快照未复现,根因未证实,不作绝对归因。旧漏洞探针作为诊断历史证据保留,不改弱断言,不将其当作正式源码新增回归计数。 |
|||
|
|||
## 7. 最新 JAR 黑盒与重启验证 |
|||
|
|||
- JAR:`D:/Acceptance/qsp-final-20261019/app/target/mydreamjob-server-0.0.1-SNAPSHOT.jar`。 |
|||
- SHA256:`60A9EC9340C83E3E748DD6144473C09BB76BC967A7DA5481296F0FF6B715DF46`。 |
|||
- 简历归属真实 HTTP 黑盒 32 项通过;补充的来源归属与文件名黑盒 26 项通过。岗位展示另有 48 项,共 106 项,不混算为简历专项。 |
|||
- 重启后简历源文件与聊天快照字节一致、已撤销卡片继续拒绝、非成员继续拒绝,共 4 项;另有岗位持久化 2 项。 |
|||
- 本轮应用使用隔离业务端口 18119、管理端口 18120 与独立 MySQL/Redis/RabbitMQ,仅合成账号和 PDF,不访问生产资源。 |
|||
- 结果:`evidence/ownership-blackbox-result.json`、`evidence/reader-fix-blackbox-result.json`;重启日志:`logs/resume-persistence.log`,均相对于上述验收目录。 |
|||
- 源码与工具身份由 `evidence/source-manifest-current.csv`、`evidence/contract-tool-manifest.csv` 和 `evidence/jar-sha256.csv` 固定;验收结束时漂移为 0。 |
|||
|
|||
## 8. 未完成项与权限边界 |
|||
|
|||
- 尚未提交、推送、Jenkins 发布、线上验证或验收前端账号切换;本地通过不代表线上修复已生效。 |
|||
- 历史污染引用只安全拒绝,没有自动清理、重绑或迁移;已生成聊天快照未在线审计,不能宣称线上历史泄露风险全部排除。 |
|||
- 个人数据导出的 `PersonalDataCollector.safeRead` 当前输入来自简历版本的 `fileStoragePath`,不是此前误写的本人资料 `resumeUrl`。本轮未证明该路径存在相同漏洞,也未修改该模块;不得仅因同名 read 方法就下漏洞结论。 |
|||
- 对象存储等未来键格式必须同时调整归属策略与测试;当前无法证明归属的键继续失败关闭。 |
|||
- 评分测试时钟依赖已单独修复并纳入最新全量,未修改生产评分规则;旧 Docker/PATH 与契约失败日志保留为历史记录,不继续标为当前阻断。 |
|||
- 前端、受保护文件、历史迁移及底层存储实现未因本批安全修复而修改;本轮服务已停止,测试数据与证据保留,未做 Docker 全局清理。 |
|||
|
|||
## 9. 前端接入要点 |
|||
|
|||
- 普通 `PUT /profile/me` 不提交非 null `resumeUrl`;简历修改使用受控简历上传/版本流程,不能通过个人资料写入文件引用。 |
|||
- 请求身份由当前有效令牌解析,不能用客户端手机号、角色字段或用户 ID 代替授权;换账号后须使用新令牌并隔离/清除旧账号缓存。 |
|||
- 对 400/403/404 按实际接口结果提示,不把权限拒绝降级为展示缓存简历,不通过本地文件名正确推断后端响应头已正确。 |
|||
- 上传名称传正常文件名即可,后端仅兼容明确的历史/客户端 UTF-8 编码,不要求客户端手写 multipart body,也不允许任意多次 URL 解码。 |
|||
- 交付的是本地验证规则;前端工程师需待对应版本发布后,在授权的测试账号下复验上传、本人下载、企业授权下载及账号切换。 |
|||
|
|||
详细汇总:`D:/Acceptance/qsp-final-20261019/evidence/acceptance-summary.json`。本文未包含账号口令、令牌、真实简历或生产数据库配置。 |
|||
@ -0,0 +1,866 @@ |
|||
#!/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()) |
|||
@ -0,0 +1,594 @@ |
|||
#!/usr/bin/env python3 |
|||
# -*- coding: utf-8 -*- |
|||
"""B01 演示数据工具的**归属安全**测试(隔离库 + 合成数据 + 哨兵数据)。 |
|||
|
|||
自包含:启动**仅本测试使用**的 MySQL 容器(b01-demo-test-mysql),建库 |
|||
``mydreamjob_b01_demo_test``、合成业务表结构 + 哨兵数据,逐项验证工具的归属安全。 |
|||
结束时只销毁本测试容器(不触碰其他容器/数据卷)。 |
|||
|
|||
不能只用全空库证明安全:冲突与哨兵数据是本项验收重点。 |
|||
|
|||
用法:python docs/tools/b01_demo_display_data_test.py |
|||
退出码:0=全部通过;1=存在失败;2=环境不可用。 |
|||
""" |
|||
import argparse |
|||
import json |
|||
import os |
|||
import subprocess |
|||
import sys |
|||
import time |
|||
|
|||
import pymysql |
|||
|
|||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) |
|||
import b01_demo_display_data as tool # noqa: E402 |
|||
|
|||
CONTAINER = "qsp-final-demo-1019" |
|||
IMAGE = "mysql:8.4" |
|||
HOST = "127.0.0.1" |
|||
PORT = 3308 |
|||
DB = "mydreamjob_b01_demo_test" |
|||
APP_USER = "b01demo" |
|||
APP_PASS = "b01-demo-only" |
|||
ROOT_PASS = "b01-root-test" |
|||
|
|||
DEMO_PHONES = [c["phone"] for c in tool.COMPANIES] |
|||
DEMO_NAMES = [c["name"] for c in tool.COMPANIES] |
|||
DEMO_TITLES = [(j["phone"], j["title"]) for j in tool.JOBS] |
|||
|
|||
RESULTS = [] |
|||
OWN_CONTAINER_ID = None |
|||
|
|||
|
|||
def check(case, ok, detail=""): |
|||
RESULTS.append(dict(case=case, ok=bool(ok), detail=str(detail)[:500])) |
|||
print("[%s] %-60s %s" % ("PASS" if ok else "FAIL", case, str(detail)[:120])) |
|||
return bool(ok) |
|||
|
|||
|
|||
# ── Docker / 连接 ─────────────────────────────────────────────────── |
|||
|
|||
def _run(cmd): |
|||
return subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", |
|||
errors="replace") |
|||
|
|||
|
|||
def start_container(): |
|||
global OWN_CONTAINER_ID |
|||
if _run(["docker", "inspect", CONTAINER]).returncode == 0: |
|||
raise RuntimeError("同名容器存在,拒绝删除或接管:" + CONTAINER) |
|||
r = _run(["docker", "run", "-d", "--name", CONTAINER, |
|||
"-e", "MYSQL_ROOT_PASSWORD=" + ROOT_PASS, |
|||
"-e", "MYSQL_ROOT_HOST=%", |
|||
"-e", "MYSQL_DATABASE=" + DB, |
|||
"-p", "127.0.0.1:%d:3306" % PORT, IMAGE, |
|||
"--character-set-server=utf8mb4", "--collation-server=utf8mb4_unicode_ci"]) |
|||
if r.returncode != 0: |
|||
raise RuntimeError("docker run 失败: " + (r.stderr or "")[:400]) |
|||
OWN_CONTAINER_ID = r.stdout.strip() |
|||
for _ in range(90): |
|||
try: |
|||
c = root_conn() |
|||
c.close() |
|||
return |
|||
except Exception: |
|||
time.sleep(2) |
|||
raise RuntimeError("MySQL 容器未就绪") |
|||
|
|||
|
|||
def stop_container(): |
|||
if OWN_CONTAINER_ID: |
|||
_run(["docker", "rm", "-f", OWN_CONTAINER_ID]) |
|||
|
|||
|
|||
def root_conn(db=DB, autocommit=True): |
|||
return pymysql.connect(host=HOST, port=PORT, user="root", password=ROOT_PASS, |
|||
database=db, charset="utf8mb4", autocommit=autocommit) |
|||
|
|||
|
|||
def app_conn(): |
|||
return pymysql.connect(host=HOST, port=PORT, user=APP_USER, password=APP_PASS, |
|||
database=DB, charset="utf8mb4", autocommit=False) |
|||
|
|||
|
|||
class Args: |
|||
def __init__(self): |
|||
self.host, self.port, self.database = HOST, PORT, DB |
|||
|
|||
|
|||
def identity_of(cur): |
|||
return tool.target_identity(cur, Args()) |
|||
|
|||
|
|||
# ── 合成表结构 + 哨兵数据 ─────────────────────────────────────────── |
|||
|
|||
DDL = [ |
|||
"DROP TABLE IF EXISTS applications", |
|||
"DROP TABLE IF EXISTS b01_demo_ledger", |
|||
"DROP TABLE IF EXISTS jobs", |
|||
"DROP TABLE IF EXISTS companies", |
|||
"DROP TABLE IF EXISTS user_roles", |
|||
"DROP TABLE IF EXISTS users", |
|||
"""CREATE TABLE users ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL UNIQUE, |
|||
email VARCHAR(100), nickname VARCHAR(50), avatar_url VARCHAR(500), |
|||
password_hash VARCHAR(255), role VARCHAR(30) NOT NULL DEFAULT 'STUDENT', |
|||
is_active TINYINT(1) NOT NULL DEFAULT 1, is_verified TINYINT(1) NOT NULL DEFAULT 0, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
"""CREATE TABLE user_roles ( |
|||
user_id BIGINT NOT NULL, role VARCHAR(30) NOT NULL, PRIMARY KEY (user_id, role), |
|||
CONSTRAINT fk_ur FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
"""CREATE TABLE companies ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, owner_user_id BIGINT NOT NULL, |
|||
name VARCHAR(120) NOT NULL, logo_url VARCHAR(500), description TEXT, |
|||
industry VARCHAR(80), website VARCHAR(255), address VARCHAR(255), |
|||
company_size VARCHAR(40) NULL, verified TINYINT(1) NOT NULL DEFAULT 0, |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
|||
CONSTRAINT uk_owner UNIQUE (owner_user_id), |
|||
CONSTRAINT fk_co FOREIGN KEY (owner_user_id) REFERENCES users(id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
"""CREATE TABLE jobs ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, company_id BIGINT NOT NULL, |
|||
title VARCHAR(120) NOT NULL, description TEXT NOT NULL, requirements TEXT, |
|||
city VARCHAR(80) NOT NULL, employment_type VARCHAR(30) NOT NULL, |
|||
salary_min INT, salary_max INT, status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', |
|||
published_at DATETIME, experience VARCHAR(40), education VARCHAR(40), |
|||
benefits_json TEXT, salary_months INT, work_location VARCHAR(255), |
|||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
|||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
|||
CONSTRAINT fk_job_co FOREIGN KEY (company_id) REFERENCES companies(id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
"""CREATE TABLE applications ( |
|||
id BIGINT AUTO_INCREMENT PRIMARY KEY, job_id BIGINT NOT NULL, |
|||
student_user_id BIGINT NOT NULL, |
|||
CONSTRAINT fk_app_job FOREIGN KEY (job_id) REFERENCES jobs(id), |
|||
CONSTRAINT fk_app_stu FOREIGN KEY (student_user_id) REFERENCES users(id) |
|||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4""", |
|||
] |
|||
|
|||
|
|||
def reset_schema(cur): |
|||
for stmt in DDL: |
|||
cur.execute(stmt) |
|||
|
|||
|
|||
def seed_sentinels(cur): |
|||
cur.execute("INSERT INTO users(phone, email, nickname, role, is_active, is_verified) " |
|||
"VALUES('13900000001','s1@x.com','哨兵甲','STUDENT',1,0)") |
|||
s1 = cur.lastrowid |
|||
cur.execute("INSERT INTO user_roles(user_id, role) VALUES(%s,'STUDENT')", (s1,)) |
|||
cur.execute("INSERT INTO users(phone, nickname, role) VALUES('13900000002','哨兵乙','STUDENT')") |
|||
s2 = cur.lastrowid |
|||
cur.execute("INSERT INTO companies(owner_user_id, name, description, industry, company_size, " |
|||
"verified) VALUES(%s,'哨兵企业(非本批)','哨兵简介','其它','10-19人',0)", (s2,)) |
|||
sc = cur.lastrowid |
|||
cur.execute("INSERT INTO jobs(company_id,title,description,city,employment_type,status," |
|||
"experience,education,benefits_json,salary_months,work_location) " |
|||
"VALUES(%s,'哨兵岗位','哨兵描述','北京','FULL_TIME','PUBLISHED','1-3年','本科'," |
|||
"'[\"哨兵福利\"]',12,'北京市海淀区')", (sc,)) |
|||
sj = cur.lastrowid |
|||
return dict(user1=s1, user2=s2, company=sc, job=sj) |
|||
|
|||
|
|||
# ── 快照工具 ──────────────────────────────────────────────────────── |
|||
|
|||
def table_rows(cur, table, order="1"): |
|||
cur.execute("SELECT * FROM `%s` ORDER BY %s" % (table, order)) |
|||
cols = [d[0] for d in cur.description] |
|||
return [dict(zip(cols, row)) for row in cur.fetchall()] |
|||
|
|||
|
|||
def norm(rows): |
|||
return json.dumps([{k: (None if v is None else str(v)) for k, v in r.items()} for r in rows], |
|||
ensure_ascii=False, sort_keys=True) |
|||
|
|||
|
|||
def snapshot(cur, tables=("users", "user_roles", "companies", "jobs")): |
|||
return {t: norm(table_rows(cur, t)) for t in tables} |
|||
|
|||
|
|||
def count(cur, sql, params=None): |
|||
cur.execute(sql, params if params else None) |
|||
return int(cur.fetchone()[0]) |
|||
|
|||
|
|||
def demo_counts(cur): |
|||
fmt_p = ",".join(["%s"] * len(DEMO_PHONES)) |
|||
fmt_n = ",".join(["%s"] * len(DEMO_NAMES)) |
|||
users = count(cur, "SELECT COUNT(*) FROM users WHERE phone IN (%s)" % fmt_p, tuple(DEMO_PHONES)) |
|||
comps = count(cur, "SELECT COUNT(*) FROM companies WHERE name IN (%s)" % fmt_n, tuple(DEMO_NAMES)) |
|||
jobs = 0 |
|||
for phone, title in DEMO_TITLES: |
|||
jobs += count(cur, "SELECT COUNT(*) FROM jobs j JOIN companies c ON c.id=j.company_id " |
|||
"JOIN users u ON u.id=c.owner_user_id WHERE u.phone=%s AND j.title=%s", |
|||
(phone, title)) |
|||
return dict(users=users, companies=comps, jobs=jobs) |
|||
|
|||
|
|||
def ledger_state(cur, identity): |
|||
if not tool.table_exists(cur, tool.LEDGER_TABLE): |
|||
return None |
|||
rows = tool.read_ledger(cur, identity) |
|||
types = {} |
|||
for r in rows: |
|||
types[r["resource_type"]] = types.get(r["resource_type"], 0) + 1 |
|||
return dict(total=len(rows), types=types) |
|||
|
|||
|
|||
# ══ 测试用例 ════════════════════════════════════════════════════════ |
|||
|
|||
def t01_first_import(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T1 空库首次导入无冲突", conflicts == [], conflicts) |
|||
check("T1 新建 3 企业/12 岗位(含 3 用户)", created == dict(users=3, companies=3, jobs=12), created) |
|||
dc = demo_counts(cur) |
|||
check("T1 库内本批计数 = 3/3/12", dc == dict(users=3, companies=3, jobs=12), dc) |
|||
ls = ledger_state(cur, ident) |
|||
check("T1 账本登记 18 行(3/3/12)", ls and ls["total"] == 18 |
|||
and ls["types"] == dict(USER=3, COMPANY=3, JOB=12), ls) |
|||
|
|||
|
|||
def t02_repeat_import_idempotent(conn, cur): |
|||
ident = identity_of(cur) |
|||
before = snapshot(cur) |
|||
before_ledger = ledger_state(cur, ident) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T2 重复导入无冲突且不重复创建", |
|||
conflicts == [] and created == dict(users=0, companies=0, jobs=0), (created, conflicts)) |
|||
check("T2 既有业务资料逐字段不变", snapshot(cur) == before) |
|||
check("T2 账本不变", ledger_state(cur, ident) == before_ledger, ledger_state(cur, ident)) |
|||
|
|||
|
|||
def t03_occupied_phone_refused(conn, cur): |
|||
reset_schema(cur) |
|||
cur.execute("INSERT INTO users(phone,nickname,role,is_active,is_verified) " |
|||
"VALUES(%s,'他人账号','STUDENT',1,0)", (DEMO_PHONES[0],)) |
|||
occ = cur.lastrowid |
|||
cur.execute("INSERT INTO user_roles(user_id,role) VALUES(%s,'STUDENT')", (occ,)) |
|||
conn.commit() |
|||
before_u = norm(table_rows(cur, "users")); before_r = norm(table_rows(cur, "user_roles")) |
|||
ident = identity_of(cur) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T3 固定手机号被非本批占用 → 拒绝", conflicts and created is None, conflicts[:1]) |
|||
check("T3 占用账号完全不变(users)", norm(table_rows(cur, "users")) == before_u) |
|||
check("T3 占用账号完全不变(user_roles)", norm(table_rows(cur, "user_roles")) == before_r) |
|||
check("T3 未创建任何本批业务数据", count(cur, "SELECT COUNT(*) FROM companies") == 0 |
|||
and count(cur, "SELECT COUNT(*) FROM jobs") == 0) |
|||
|
|||
|
|||
def t04_nonbatch_company_and_title_refused(conn, cur): |
|||
# 4a 同名企业被非本批占用 |
|||
reset_schema(cur) |
|||
cur.execute("INSERT INTO users(phone,nickname,role) VALUES('13911112222','他人','COMPANY')") |
|||
other = cur.lastrowid |
|||
cur.execute("INSERT INTO companies(owner_user_id,name,description,verified) " |
|||
"VALUES(%s,%s,'他人企业',0)", (other, DEMO_NAMES[0])) |
|||
oc = cur.lastrowid |
|||
conn.commit() |
|||
b_co = norm(table_rows(cur, "companies")) |
|||
ident = identity_of(cur) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T4a 同名企业被非本批占用 → 拒绝", conflicts and created is None, conflicts[:1]) |
|||
check("T4a 他方企业不被覆盖/认领", norm(table_rows(cur, "companies")) == b_co) |
|||
check("T4a 未新增本批企业/岗位", count(cur, "SELECT COUNT(*) FROM companies") == 1 |
|||
and count(cur, "SELECT COUNT(*) FROM jobs") == 0) |
|||
# 4b 同名岗位:本批企业下已存在同名岗位但账本缺失 → 拒绝,不认领 |
|||
reset_schema(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("DELETE FROM b01_demo_ledger WHERE resource_type='JOB' ORDER BY id LIMIT 1") |
|||
conn.commit() |
|||
b_j = norm(table_rows(cur, "jobs")); b_led = norm(table_rows(cur, "b01_demo_ledger")) |
|||
created, conflicts = tool.apply_import(conn, cur, ident) |
|||
check("T4b 同名岗位无本批账本归属 → 拒绝", conflicts and created is None, conflicts[:1]) |
|||
check("T4b 岗位与账本均不被改写", |
|||
norm(table_rows(cur, "jobs")) == b_j and norm(table_rows(cur, "b01_demo_ledger")) == b_led) |
|||
|
|||
|
|||
def t05_demo_text_without_ledger_not_deleted(conn, cur): |
|||
reset_schema(cur) |
|||
# 只有演示文字标记、没有账本 |
|||
cur.execute("INSERT INTO users(phone,nickname,role) VALUES(%s,%s,'COMPANY')", |
|||
(DEMO_PHONES[0], tool.DEMO_MARK)) |
|||
u = cur.lastrowid |
|||
cur.execute("INSERT INTO companies(owner_user_id,name,description,verified) " |
|||
"VALUES(%s,%s,%s,1)", (u, DEMO_NAMES[0], tool.DEMO_MARK)) |
|||
c = cur.lastrowid |
|||
cur.execute("INSERT INTO jobs(company_id,title,description,city,employment_type,status) " |
|||
"VALUES(%s,%s,%s,'上海','FULL_TIME','PUBLISHED')", (c, DEMO_TITLES[0][1], tool.DEMO_MARK)) |
|||
conn.commit() |
|||
before = snapshot(cur) |
|||
ident = identity_of(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T5 无账本 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T5 演示文字记录逐字段不变", snapshot(cur) == before) |
|||
check("T5 未创建账本表", not tool.table_exists(cur, tool.LEDGER_TABLE)) |
|||
|
|||
|
|||
def t06_ledger_lost_or_identity_changed(conn, cur): |
|||
ident = identity_of(cur) |
|||
# 6a 账本整表丢失后清理 → 拒绝 |
|||
reset_schema(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("DROP TABLE b01_demo_ledger"); conn.commit() |
|||
before = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T6a 账本表丢失 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T6a 本批数据未被删除", snapshot(cur) == before) |
|||
|
|||
# 6b 资源身份变化(企业改名)→ 拒绝清理 |
|||
reset_schema(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("UPDATE companies SET name=%s WHERE name=%s", ("被改名企业", DEMO_NAMES[0])) |
|||
conn.commit() |
|||
b1 = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T6b 企业身份变化 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T6b 改名后数据未被删除", snapshot(cur) == b1) |
|||
|
|||
# 6c 账本 ID 指向异常(把某条 JOB 账本指向哨兵岗位)→ 拒绝 |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
sentinel_job = count(cur, "SELECT id FROM jobs WHERE title='哨兵岗位'") |
|||
cur.execute("UPDATE b01_demo_ledger SET resource_id=%s WHERE resource_type='JOB' LIMIT 1", |
|||
(sentinel_job,)); conn.commit() |
|||
b2 = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T6c 账本 ID 指向异常 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T6c 哨兵与业务数据均未被删除", snapshot(cur) == b2) |
|||
|
|||
|
|||
def t07_external_dependency_refused(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
# 新增非本批依赖:学生投递到本批岗位 |
|||
cur.execute("INSERT INTO users(phone,nickname,role) VALUES('13922223333','外部学生','STUDENT')") |
|||
stu = cur.lastrowid |
|||
job = count(cur, "SELECT j.id FROM jobs j JOIN companies c ON c.id=j.company_id " |
|||
"JOIN users u ON u.id=c.owner_user_id WHERE u.phone=%s AND j.title=%s", |
|||
(DEMO_TITLES[0][0], DEMO_TITLES[0][1])) |
|||
cur.execute("INSERT INTO applications(job_id, student_user_id) VALUES(%s,%s)", (job, stu)); conn.commit() |
|||
before = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T7 存在本批之外依赖 → 拒绝清理", removed is None and conflicts, conflicts[:1]) |
|||
check("T7 本批数据与外部依赖全部保留", snapshot(cur) == before) |
|||
|
|||
|
|||
def t08_midway_error_rolls_back(conn, cur): |
|||
reset_schema(cur) |
|||
ident = identity_of(cur) |
|||
calls = {"n": 0} |
|||
real = tool.insert_ledger |
|||
|
|||
def boom(*a, **k): |
|||
calls["n"] += 1 |
|||
if calls["n"] == 3: # 已在事务中间 |
|||
raise RuntimeError("injected failure") |
|||
return real(*a, **k) |
|||
|
|||
tool.insert_ledger = boom |
|||
try: |
|||
raised = False |
|||
try: |
|||
tool.apply_import(conn, cur, ident) |
|||
except Exception: |
|||
raised = True |
|||
check("T8 中途异常被抛出", raised and calls["n"] >= 3, calls) |
|||
finally: |
|||
tool.insert_ledger = real |
|||
check("T8 本批业务数据整体回滚(users)", count(cur, "SELECT COUNT(*) FROM users") == 0) |
|||
check("T8 本批业务数据整体回滚(companies)", count(cur, "SELECT COUNT(*) FROM companies") == 0) |
|||
check("T8 本批业务数据整体回滚(jobs)", count(cur, "SELECT COUNT(*) FROM jobs") == 0) |
|||
ls = ledger_state(cur, ident) |
|||
check("T8 归属记账一并回滚(账本 0 行)", ls is not None and ls["total"] == 0, ls) |
|||
|
|||
|
|||
def t09_dry_run_and_preview_zero_write(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur); conn.commit() |
|||
before = snapshot(cur) |
|||
cmd = [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), |
|||
"b01_demo_display_data.py"), |
|||
"--host", HOST, "--port", str(PORT), "--database", DB, |
|||
"--user", APP_USER, "--password", APP_PASS] |
|||
r = _run(cmd) |
|||
cur2 = conn.cursor() |
|||
check("T9 导入 dry-run 退出码 0", r.returncode == 0, r.stdout[-200:]) |
|||
check("T9 导入 dry-run 零写入且不建账本表", |
|||
snapshot(cur2) == before and not tool.table_exists(cur2, tool.LEDGER_TABLE)) |
|||
# 导入后清理预览 |
|||
ident = identity_of(cur2) |
|||
tool.apply_import(conn, cur2, ident) |
|||
b2 = snapshot(cur2) |
|||
ls_before = ledger_state(cur2, ident) |
|||
r2 = _run(cmd + ["--cleanup"]) |
|||
check("T9 清理预览退出码 0", r2.returncode == 0, r2.stdout[-200:]) |
|||
check("T9 清理预览零写入", snapshot(cur2) == b2 and ledger_state(cur2, ident) == ls_before) |
|||
|
|||
|
|||
def t10_cleanup_only_batch_and_sentinels_intact(conn, cur): |
|||
reset_schema(cur) |
|||
sent = seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
sent_ids = {"users": [sent["user1"], sent["user2"]], "companies": [sent["company"]], |
|||
"jobs": [sent["job"]]} |
|||
sent_before = { |
|||
"users": norm([r for r in table_rows(cur, "users") if r["id"] in sent_ids["users"]]), |
|||
"companies": norm([r for r in table_rows(cur, "companies") if r["id"] in sent_ids["companies"]]), |
|||
"jobs": norm([r for r in table_rows(cur, "jobs") if r["id"] in sent_ids["jobs"]]), |
|||
"user_roles": norm([r for r in table_rows(cur, "user_roles") |
|||
if r["user_id"] in sent_ids["users"]]), |
|||
} |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T10 正常清理无冲突", conflicts == [], conflicts) |
|||
check("T10 仅删除本批 12 岗位/3 企业/3 用户/18 账本", |
|||
removed == dict(jobs=12, companies=3, users=3, ledger=18), removed) |
|||
dc = demo_counts(cur) |
|||
check("T10 本批业务数据已清空", dc == dict(users=0, companies=0, jobs=0), dc) |
|||
check("T10 账本已清空", ledger_state(cur, ident)["total"] == 0) |
|||
sent_after = { |
|||
"users": norm([r for r in table_rows(cur, "users") if r["id"] in sent_ids["users"]]), |
|||
"companies": norm([r for r in table_rows(cur, "companies") if r["id"] in sent_ids["companies"]]), |
|||
"jobs": norm([r for r in table_rows(cur, "jobs") if r["id"] in sent_ids["jobs"]]), |
|||
"user_roles": norm([r for r in table_rows(cur, "user_roles") |
|||
if r["user_id"] in sent_ids["users"]]), |
|||
} |
|||
check("T10 哨兵记录逐字段不变(users/companies/jobs/user_roles)", sent_after == sent_before) |
|||
|
|||
|
|||
def t11_second_cleanup_noop(conn, cur): |
|||
ident = identity_of(cur) |
|||
before = snapshot(cur) |
|||
removed, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check("T11 再次清理安全无操作", conflicts == [] and removed == dict(jobs=0, companies=0, |
|||
users=0, ledger=0), (removed, conflicts)) |
|||
check("T11 库内无变化", snapshot(cur) == before) |
|||
|
|||
|
|||
def t12_non_whitelist_target_refused_before_connect(conn, cur): |
|||
ok = True |
|||
for host, port, db in [("127.0.0.1", 3308, "mydreamjob_b01_demo"), |
|||
("127.0.0.1", 3308, "mydreamjob_b01_demo_test")]: |
|||
try: |
|||
tool.assert_safe_target(host, port, db) |
|||
except SystemExit: |
|||
ok = False |
|||
check("T12 白名单目标通过", ok) |
|||
for host, port, db, why in [ |
|||
("127.0.0.1", 3307, "mydreamjob_b01_demo", "非白名单端口"), |
|||
("10.0.0.9", 3308, "mydreamjob_b01_demo", "非回环 host"), |
|||
("127.0.0.1", 3308, "some_other_db", "非白名单库"), |
|||
("127.0.0.1", 3308, "mydreamjob_prod", "疑似线上库")]: |
|||
try: |
|||
tool.assert_safe_target(host, port, db) |
|||
check("T12 拒绝 %s" % why, False, "未拒绝") |
|||
except SystemExit as e: |
|||
check("T12 拒绝 %s" % why, "REFUSED" in str(e), str(e)[:60]) |
|||
# 进程级:错误目标必须在**建立连接前**立即拒绝(不等待连接超时) |
|||
cmd = [sys.executable, os.path.join(os.path.dirname(os.path.abspath(__file__)), |
|||
"b01_demo_display_data.py"), |
|||
"--host", "127.0.0.1", "--port", "3307", "--database", "mydreamjob_b01_demo", |
|||
"--user", "nobody", "--password", "nope"] |
|||
t0 = time.time() |
|||
r = _run(cmd) |
|||
elapsed = time.time() - t0 |
|||
check("T12 非白名单目标非零退出且快速拒绝", |
|||
r.returncode != 0 and "REFUSED" in (r.stderr + r.stdout) and elapsed < 10, |
|||
"rc=%s elapsed=%.1fs" % (r.returncode, elapsed)) |
|||
|
|||
|
|||
def assert_refusal_preserves_rows(conn, cur, ident, label): |
|||
before = snapshot(cur, ("users", "user_roles", "companies", "jobs", tool.LEDGER_TABLE)) |
|||
_, conflicts = tool.apply_cleanup(conn, cur, ident) |
|||
check(label + " 清理拒绝", bool(conflicts), conflicts) |
|||
check(label + " 清理未改业务或账本", snapshot(cur, tuple(before)) == before) |
|||
_, conflicts = tool.apply_import(conn, cur, ident) |
|||
check(label + " 导入拒绝", bool(conflicts), conflicts) |
|||
check(label + " 导入未改业务或账本", snapshot(cur, tuple(before)) == before) |
|||
|
|||
|
|||
def t13_real_user_identity_changed(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("UPDATE users SET phone='18800009991' WHERE phone=%s", (DEMO_PHONES[0],)) |
|||
conn.commit() |
|||
assert_refusal_preserves_rows(conn, cur, ident, "T13 真实用户身份改变") |
|||
|
|||
|
|||
def t14_job_parent_changed(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
ledger = tool.read_ledger(cur, ident) |
|||
job = next(r for r in ledger if r['resource_type'] == 'JOB') |
|||
cur.execute("SELECT company_id FROM jobs WHERE id=%s", (job['resource_id'],)) |
|||
old = cur.fetchone()[0] |
|||
other = next(r['resource_id'] for r in ledger if r['resource_type'] == 'COMPANY' and r['resource_id'] != old) |
|||
cur.execute("UPDATE jobs SET company_id=%s WHERE id=%s", (other, job['resource_id'])) |
|||
conn.commit() |
|||
assert_refusal_preserves_rows(conn, cur, ident, "T14 岗位父企业改变") |
|||
|
|||
|
|||
def t15_company_name_changed(conn, cur): |
|||
reset_schema(cur); seed_sentinels(cur) |
|||
ident = identity_of(cur) |
|||
tool.apply_import(conn, cur, ident) |
|||
cur.execute("UPDATE companies SET name='changed-identity' WHERE name=%s", (DEMO_NAMES[0],)) |
|||
conn.commit() |
|||
assert_refusal_preserves_rows(conn, cur, ident, "T15 企业真实名字改变") |
|||
|
|||
|
|||
TESTS = [ |
|||
("T1", t01_first_import), |
|||
("T2", t02_repeat_import_idempotent), |
|||
("T3", t03_occupied_phone_refused), |
|||
("T4", t04_nonbatch_company_and_title_refused), |
|||
("T5", t05_demo_text_without_ledger_not_deleted), |
|||
("T6", t06_ledger_lost_or_identity_changed), |
|||
("T7", t07_external_dependency_refused), |
|||
("T8", t08_midway_error_rolls_back), |
|||
("T9", t09_dry_run_and_preview_zero_write), |
|||
("T10", t10_cleanup_only_batch_and_sentinels_intact), |
|||
("T11", t11_second_cleanup_noop), |
|||
("T12", t12_non_whitelist_target_refused_before_connect), |
|||
("T13", t13_real_user_identity_changed), |
|||
("T14", t14_job_parent_changed), |
|||
("T15", t15_company_name_changed), |
|||
] |
|||
|
|||
|
|||
def main(): |
|||
parser = argparse.ArgumentParser() |
|||
parser.add_argument("--keep-container", action="store_true") |
|||
parser.add_argument("--out") |
|||
args = parser.parse_args() |
|||
|
|||
try: |
|||
start_container() |
|||
except Exception as exc: |
|||
print("[ENV] 无法启动隔离 MySQL 容器:%s" % exc) |
|||
return 2 |
|||
try: |
|||
admin = root_conn() |
|||
with admin.cursor() as ac: |
|||
ac.execute("CREATE USER IF NOT EXISTS '%s'@'%%' IDENTIFIED BY '%s'" % (APP_USER, APP_PASS)) |
|||
ac.execute("GRANT ALL PRIVILEGES ON `%s`.* TO '%s'@'%%'" % (DB, APP_USER)) |
|||
ac.execute("FLUSH PRIVILEGES") |
|||
admin.close() |
|||
|
|||
for name, fn in TESTS: |
|||
conn = root_conn(autocommit=False) |
|||
try: |
|||
with conn.cursor() as cur: |
|||
fn(conn, cur) |
|||
except Exception as exc: |
|||
import traceback |
|||
check("%s 执行异常" % name, False, traceback.format_exc()[-400:]) |
|||
finally: |
|||
try: |
|||
conn.rollback() |
|||
except Exception: |
|||
pass |
|||
conn.close() |
|||
finally: |
|||
if not args.keep_container: |
|||
stop_container() |
|||
|
|||
bad = [r for r in RESULTS if not r["ok"]] |
|||
print("\n== DEMO-TOOL SAFETY SUMMARY: total=%d fail=%d ==" % (len(RESULTS), len(bad))) |
|||
if args.out: |
|||
with open(args.out, "w", encoding="utf-8") as fh: |
|||
json.dump(RESULTS, fh, ensure_ascii=False, indent=1) |
|||
return 1 if bad else 0 |
|||
|
|||
|
|||
if __name__ == "__main__": |
|||
sys.exit(main()) |
|||
@ -0,0 +1,31 @@ |
|||
package com.mydreamjob.job.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
import java.time.Instant; |
|||
|
|||
/** |
|||
* 仅供 OpenAPI 文档使用的统一响应包装(data = {@link JobDetailViewDoc})。 |
|||
* |
|||
* <p>对应 {@code ApiResponse<Map<String,Object>>} 的实际外层结构;仅用于文档,不改变返回类型。</p> |
|||
*/ |
|||
@Data |
|||
@Schema(name = "ApiResponseJobDetailViewDoc", description = "统一响应包装;data 为企业端岗位详情") |
|||
public class ApiResponseJobDetailViewDoc { |
|||
|
|||
@Schema(description = "业务状态码") |
|||
private int code; |
|||
|
|||
@Schema(description = "消息") |
|||
private String message; |
|||
|
|||
@Schema(description = "企业端岗位详情") |
|||
private JobDetailViewDoc data; |
|||
|
|||
@Schema(description = "链路追踪 ID") |
|||
private String requestId; |
|||
|
|||
@Schema(description = "响应时间") |
|||
private Instant timestamp; |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
package com.mydreamjob.job.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
import java.time.Instant; |
|||
|
|||
/** |
|||
* 仅供 OpenAPI 文档使用的统一响应包装(data = {@link JobListViewDoc})。 |
|||
* |
|||
* <p>对应 {@code ApiResponse<Map<String,Object>>} 的实际外层结构;仅用于文档,不改变返回类型。</p> |
|||
*/ |
|||
@Data |
|||
@Schema(name = "ApiResponseJobListViewDoc", description = "统一响应包装;data 为公开岗位列表分页载荷") |
|||
public class ApiResponseJobListViewDoc { |
|||
|
|||
@Schema(description = "业务状态码") |
|||
private int code; |
|||
|
|||
@Schema(description = "消息") |
|||
private String message; |
|||
|
|||
@Schema(description = "公开岗位列表分页载荷") |
|||
private JobListViewDoc data; |
|||
|
|||
@Schema(description = "链路追踪 ID") |
|||
private String requestId; |
|||
|
|||
@Schema(description = "响应时间") |
|||
private Instant timestamp; |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
package com.mydreamjob.job.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
import java.time.Instant; |
|||
|
|||
/** |
|||
* 仅供 OpenAPI 文档使用的统一响应包装(data = {@link JobPublicViewDoc})。 |
|||
* |
|||
* <p>对应 {@code ApiResponse<Map<String,Object>>} 的实际外层结构;仅用于文档,不改变返回类型。</p> |
|||
*/ |
|||
@Data |
|||
@Schema(name = "ApiResponseJobPublicViewDoc", description = "统一响应包装;data 为公开岗位详情") |
|||
public class ApiResponseJobPublicViewDoc { |
|||
|
|||
@Schema(description = "业务状态码") |
|||
private int code; |
|||
|
|||
@Schema(description = "消息") |
|||
private String message; |
|||
|
|||
@Schema(description = "公开岗位详情") |
|||
private JobPublicViewDoc data; |
|||
|
|||
@Schema(description = "链路追踪 ID") |
|||
private String requestId; |
|||
|
|||
@Schema(description = "响应时间") |
|||
private Instant timestamp; |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
package com.mydreamjob.job.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.ArraySchema; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 仅供 OpenAPI 文档使用的“企业端岗位详情”模型(对应 {@code JobService#toView})。 |
|||
* |
|||
* <p>仅用于把 {@code Map<String,Object>} 响应结构写入运行时契约,实际返回类型不变。</p> |
|||
*/ |
|||
@Data |
|||
@Schema(name = "JobDetailViewDoc", description = "企业端岗位详情(data 结构)") |
|||
public class JobDetailViewDoc { |
|||
|
|||
@Schema(description = "岗位 ID") |
|||
private Long id; |
|||
|
|||
@Schema(description = "所属企业 ID") |
|||
private Long companyId; |
|||
|
|||
@Schema(description = "岗位名称") |
|||
private String title; |
|||
|
|||
@Schema(description = "岗位描述") |
|||
private String description; |
|||
|
|||
@Schema(description = "任职要求") |
|||
private String requirements; |
|||
|
|||
@Schema(description = "工作城市") |
|||
private String city; |
|||
|
|||
@Schema(description = "用工类型") |
|||
private String employmentType; |
|||
|
|||
@Schema(description = "最低月薪,单位元/月") |
|||
private Integer salaryMin; |
|||
|
|||
@Schema(description = "最高月薪,单位元/月") |
|||
private Integer salaryMax; |
|||
|
|||
@Schema(description = "经验要求,≤40 字符;无值时为 null", maxLength = 40) |
|||
private String experience; |
|||
|
|||
@Schema(description = "学历要求,≤40 字符;无值时为 null", maxLength = 40) |
|||
private String education; |
|||
|
|||
@ArraySchema(maxItems = 20, schema = @Schema(maxLength = 40, description = "单项长度上限 40 字符")) |
|||
@Schema(description = "福利标签;响应端恒为数组(历史无值返回 []),最多 20 项、单项 ≤40 字符、去重保序") |
|||
private List<String> benefits; |
|||
|
|||
@Schema(description = "薪资月数,整数 1—24;无值时为 null", minimum = "1", maximum = "24") |
|||
private Integer salaryMonths; |
|||
|
|||
@Schema(description = "工作地点,≤255 字符;无值时为 null", maxLength = 255) |
|||
private String workLocation; |
|||
|
|||
@Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") |
|||
private String status; |
|||
|
|||
@Schema(description = "发布时间") |
|||
private LocalDateTime publishedAt; |
|||
|
|||
@Schema(description = "创建时间") |
|||
private LocalDateTime createdAt; |
|||
|
|||
@Schema(description = "更新时间") |
|||
private LocalDateTime updatedAt; |
|||
} |
|||
@ -0,0 +1,38 @@ |
|||
package com.mydreamjob.job.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
/** |
|||
* 仅供 OpenAPI 文档使用的公开企业摘要模型(严格 7 字段白名单)。 |
|||
* |
|||
* <p>不参与任何序列化/反序列化:仅通过 {@code @Schema(implementation = ...)} 绑定到 |
|||
* 岗位相关 Map 响应的文档结构,实际返回类型不变。不得暴露 ownerUserId、联系方式、 |
|||
* 登记地址与内部审核信息。</p> |
|||
*/ |
|||
@Data |
|||
@Schema(name = "JobDocCompanySummary", |
|||
description = "公开企业摘要;严格 7 字段,不含 ownerUserId、联系方式、地址与审核信息") |
|||
public class JobDocCompanySummary { |
|||
|
|||
@Schema(description = "企业 ID") |
|||
private Long id; |
|||
|
|||
@Schema(description = "企业名称") |
|||
private String name; |
|||
|
|||
@Schema(description = "企业 Logo") |
|||
private String logoUrl; |
|||
|
|||
@Schema(description = "企业简介") |
|||
private String description; |
|||
|
|||
@Schema(description = "所属行业") |
|||
private String industry; |
|||
|
|||
@Schema(description = "是否已认证") |
|||
private Boolean verified; |
|||
|
|||
@Schema(description = "企业规模,≤40 字符;缺省时为空", maxLength = 40) |
|||
private String companySize; |
|||
} |
|||
@ -0,0 +1,31 @@ |
|||
package com.mydreamjob.job.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 仅供 OpenAPI 文档使用的“公开岗位列表 data”模型(对应 {@code GET /jobs} 的分页载荷)。 |
|||
* |
|||
* <p>仅用于把 {@code Map<String,Object>} 响应结构写入运行时契约,实际返回类型不变。</p> |
|||
*/ |
|||
@Data |
|||
@Schema(name = "JobListViewDoc", description = "公开岗位列表分页载荷(data 结构)") |
|||
public class JobListViewDoc { |
|||
|
|||
@Schema(description = "当页岗位列表") |
|||
private List<JobPublicViewDoc> items; |
|||
|
|||
@Schema(description = "总条数") |
|||
private Long total; |
|||
|
|||
@Schema(description = "当前页码(从 0 开始)") |
|||
private Integer page; |
|||
|
|||
@Schema(description = "每页大小") |
|||
private Integer size; |
|||
|
|||
@Schema(description = "总页数") |
|||
private Integer totalPages; |
|||
} |
|||
@ -0,0 +1,73 @@ |
|||
package com.mydreamjob.job.dto; |
|||
|
|||
import io.swagger.v3.oas.annotations.media.ArraySchema; |
|||
import io.swagger.v3.oas.annotations.media.Schema; |
|||
import lombok.Data; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.List; |
|||
|
|||
/** |
|||
* 仅供 OpenAPI 文档使用的“公开岗位详情”模型(对应 {@code JobService#toPublicView(Job, Company)})。 |
|||
* |
|||
* <p>仅用于把 {@code Map<String,Object>} 响应结构写入运行时契约,实际返回类型不变。</p> |
|||
*/ |
|||
@Data |
|||
@Schema(name = "JobPublicViewDoc", description = "公开岗位详情(data 结构,含公开企业摘要 company)") |
|||
public class JobPublicViewDoc { |
|||
|
|||
@Schema(description = "岗位 ID") |
|||
private Long id; |
|||
|
|||
@Schema(description = "所属企业 ID") |
|||
private Long companyId; |
|||
|
|||
@Schema(description = "岗位名称") |
|||
private String title; |
|||
|
|||
@Schema(description = "岗位描述") |
|||
private String description; |
|||
|
|||
@Schema(description = "任职要求") |
|||
private String requirements; |
|||
|
|||
@Schema(description = "工作城市") |
|||
private String city; |
|||
|
|||
@Schema(description = "用工类型") |
|||
private String employmentType; |
|||
|
|||
@Schema(description = "最低月薪,单位元/月") |
|||
private Integer salaryMin; |
|||
|
|||
@Schema(description = "最高月薪,单位元/月") |
|||
private Integer salaryMax; |
|||
|
|||
@Schema(description = "经验要求,≤40 字符;无值时为 null", maxLength = 40) |
|||
private String experience; |
|||
|
|||
@Schema(description = "学历要求,≤40 字符;无值时为 null", maxLength = 40) |
|||
private String education; |
|||
|
|||
@ArraySchema(maxItems = 20, schema = @Schema(maxLength = 40, description = "单项长度上限 40 字符")) |
|||
@Schema(description = "福利标签;响应端恒为数组(历史无值返回 []),最多 20 项、单项 ≤40 字符、去重保序") |
|||
private List<String> benefits; |
|||
|
|||
@Schema(description = "薪资月数,整数 1—24;无值时为 null", minimum = "1", maximum = "24") |
|||
private Integer salaryMonths; |
|||
|
|||
@Schema(description = "工作地点,≤255 字符;无值时为 null", maxLength = 255) |
|||
private String workLocation; |
|||
|
|||
@Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") |
|||
private String status; |
|||
|
|||
@Schema(description = "发布时间") |
|||
private LocalDateTime publishedAt; |
|||
|
|||
@Schema(description = "创建时间") |
|||
private LocalDateTime createdAt; |
|||
|
|||
@Schema(description = "公开企业摘要(7 字段白名单)") |
|||
private JobDocCompanySummary company; |
|||
} |
|||
@ -0,0 +1,45 @@ |
|||
package com.mydreamjob.job.dto; |
|||
|
|||
import com.fasterxml.jackson.core.JsonParser; |
|||
import com.fasterxml.jackson.core.JsonToken; |
|||
import com.fasterxml.jackson.databind.DeserializationContext; |
|||
import com.fasterxml.jackson.databind.JsonDeserializer; |
|||
import com.fasterxml.jackson.databind.exc.MismatchedInputException; |
|||
|
|||
import java.io.IOException; |
|||
import java.math.BigInteger; |
|||
|
|||
/** |
|||
* 严格整数反序列化器:只接受 JSON 整数({@link JsonToken#VALUE_NUMBER_INT})。 |
|||
* |
|||
* <p>背景:Jackson 默认开启 {@code ACCEPT_FLOAT_AS_INT},会把 {@code 1.9} 静默截断为 {@code 1}, |
|||
* 使「整数 1—24」的契约失效。本反序列化器**只作用于被标注的字段**, |
|||
* 不修改任何全局 Jackson 强制转换配置。</p> |
|||
* |
|||
* <p>拒绝:小数({@code 1.9} / {@code 1.0})、数字字符串({@code "12"})、空串、布尔、 |
|||
* 数组、对象,以及超出 {@code int} 范围的整数。一律抛 {@link MismatchedInputException} |
|||
* (它是 {@code JsonProcessingException} 的子类),由全局异常处理器映射为 HTTP 400, |
|||
* 不泄露异常堆栈。</p> |
|||
* |
|||
* <p>{@code null} 原样返回;字段缺省时本方法不会被调用,两种情形都保持既有语义。</p> |
|||
*/ |
|||
public class StrictIntegerDeserializer extends JsonDeserializer<Integer> { |
|||
|
|||
@Override |
|||
public Integer deserialize(JsonParser parser, DeserializationContext context) throws IOException { |
|||
if (parser.currentToken() == JsonToken.VALUE_NULL) { |
|||
return null; |
|||
} |
|||
if (parser.currentToken() != JsonToken.VALUE_NUMBER_INT) { |
|||
throw MismatchedInputException.from(parser, Integer.class, |
|||
"value must be a JSON integer"); |
|||
} |
|||
BigInteger value = parser.getBigIntegerValue(); |
|||
if (value.compareTo(BigInteger.valueOf(Integer.MIN_VALUE)) < 0 |
|||
|| value.compareTo(BigInteger.valueOf(Integer.MAX_VALUE)) > 0) { |
|||
throw MismatchedInputException.from(parser, Integer.class, |
|||
"value is out of integer range"); |
|||
} |
|||
return value.intValue(); |
|||
} |
|||
} |
|||
@ -0,0 +1,107 @@ |
|||
package com.mydreamjob.profile.storage; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
/** |
|||
* 简历文件存储键的归属校验器(简历归属漏洞修复)。 |
|||
* |
|||
* <p>存储键由服务端在 {@link ResumeStorageService#store(Long, org.springframework.web.multipart.MultipartFile)} |
|||
* 生成,格式固定为 {@code <userId>_<随机段>[.扩展名]}(见 {@link LocalResumeStorageService})。 |
|||
* 因此“第一个下划线之前的十进制数字”就是该文件的唯一所有者 {@code userId}。</p> |
|||
* |
|||
* <h2>失败关闭</h2> |
|||
* <p>以下任一情况一律视为“无法证明归属”,直接拒绝,不降级读取、不降级删除:</p> |
|||
* <ul> |
|||
* <li>键为 {@code null} / 空白;</li> |
|||
* <li>不含下划线,或下划线前不是纯 ASCII 十进制数字,或下划线后为空(如 {@code 10_});</li> |
|||
* <li>含路径分隔符({@code /}、{@code \})或冒号(可构成绝对路径 / 盘符 / 路径穿越);</li> |
|||
* <li>含 {@code ..};</li> |
|||
* <li>解析出的所有者与目标 {@code userId} 不相等(按数值精确比较,<strong>绝不</strong>用字符串前缀匹配, |
|||
* 避免 {@code 1} 与 {@code 10} 前缀混淆)。</li> |
|||
* </ul> |
|||
* |
|||
* <h2>职责边界</h2> |
|||
* <p>本类只解析“存储键字符串里记录的所有者”,不做文件存在性、扩展名或内容校验, |
|||
* <strong>也不</strong>对 {@link ResumeStorageService#read(String)} 施加“仅所有者可读”的限制—— |
|||
* 企业按投递关系下载候选人简历、投递/聊天简历快照等合法授权流程必须保持可用, |
|||
* 其授权由各自的 AccessPolicy 负责。归属校验只用于“本人资料简历引用”的读写路径。</p> |
|||
* |
|||
* <h2>兼容性边界</h2> |
|||
* <ul> |
|||
* <li><b>历史键</b>:本地存储自上线起即使用 {@code userId_UUID8.ext},故正常历史键均能通过校验; |
|||
* 被人工篡改 / 非该格式的历史键会判为“拒绝”(失败关闭),这是预期行为。</li> |
|||
* <li><b>版本文件</b>:{@code resume_versions.file_storage_path} 与资料简历同样由 |
|||
* {@code store(userId, file)} 生成,格式一致;但版本操作另有“版本行所属用户”校验,本类不介入。</li> |
|||
* <li><b>其他存储类型</b>:如后续引入对象存储等实现,若键格式不再以 {@code userId_} 开头, |
|||
* 必须在本类同步扩展解析规则,否则会失败关闭。</li> |
|||
* </ul> |
|||
*/ |
|||
@Component |
|||
public class ResumeOwnershipVerifier { |
|||
|
|||
/** 存储键中分隔所有者与随机段的分隔符。 */ |
|||
private static final char SEPARATOR = '_'; |
|||
|
|||
/** |
|||
* 解析存储键中记录的所有者 userId。 |
|||
* |
|||
* @return 解析成功且为正数时返回 owner;无法解析(失败关闭)时返回 {@code null} |
|||
*/ |
|||
public Long parseOwner(String storageKey) { |
|||
if (storageKey == null) { |
|||
return null; |
|||
} |
|||
String key = storageKey.trim(); |
|||
if (key.isEmpty()) { |
|||
return null; |
|||
} |
|||
// 拒绝任何可能构成路径穿越 / 绝对路径 / 盘符的字符
|
|||
if (key.indexOf('/') >= 0 || key.indexOf('\\') >= 0 || key.indexOf(':') >= 0) { |
|||
return null; |
|||
} |
|||
if (key.contains("..")) { |
|||
return null; |
|||
} |
|||
int separator = key.indexOf(SEPARATOR); |
|||
// 必须存在分隔符,且两侧都非空
|
|||
if (separator <= 0 || separator == key.length() - 1) { |
|||
return null; |
|||
} |
|||
String prefix = key.substring(0, separator); |
|||
for (int i = 0; i < prefix.length(); i++) { |
|||
char c = prefix.charAt(i); |
|||
if (c < '0' || c > '9') { |
|||
return null; |
|||
} |
|||
} |
|||
try { |
|||
long owner = Long.parseLong(prefix); |
|||
return owner > 0 ? owner : null; |
|||
} catch (NumberFormatException e) { |
|||
// 位数溢出等极端情况:失败关闭
|
|||
return null; |
|||
} |
|||
} |
|||
|
|||
/** 存储键是否属于 {@code userId}(数值精确匹配)。 */ |
|||
public boolean isOwnedBy(String storageKey, Long userId) { |
|||
if (userId == null) { |
|||
return false; |
|||
} |
|||
Long owner = parseOwner(storageKey); |
|||
return owner != null && owner.equals(userId); |
|||
} |
|||
|
|||
/** |
|||
* 归属不通过时抛出 {@link ErrorCode#FORBIDDEN}。 |
|||
* |
|||
* <p>错误信息为固定文案,不回显存储键、真实磁盘路径、其他用户标识或文件名。</p> |
|||
*/ |
|||
public void assertOwnedBy(String storageKey, Long userId) { |
|||
if (!isOwnedBy(storageKey, userId)) { |
|||
throw new BusinessException(ErrorCode.FORBIDDEN); |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,9 @@ |
|||
-- Flyway V57: 岗位与企业展示字段 |
|||
-- benefits_json 使用 TEXT 保存 Jackson 生成的 JSON 字符串数组,不使用 MySQL JSON 列, |
|||
-- 以保持 MySQL 8 与 H2 MODE=MySQL 兼容(沿用 V31/V33/V34/V35 的既有决策)。 |
|||
ALTER TABLE companies ADD COLUMN company_size VARCHAR(40) NULL; |
|||
ALTER TABLE jobs ADD COLUMN experience VARCHAR(40) NULL; |
|||
ALTER TABLE jobs ADD COLUMN education VARCHAR(40) NULL; |
|||
ALTER TABLE jobs ADD COLUMN benefits_json TEXT NULL; |
|||
ALTER TABLE jobs ADD COLUMN salary_months INT NULL; |
|||
ALTER TABLE jobs ADD COLUMN work_location VARCHAR(255) NULL; |
|||
@ -0,0 +1,242 @@ |
|||
package com.mydreamjob.company; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.auth.AuthSessionService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import jakarta.persistence.EntityManager; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.time.Instant; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertNotNull; |
|||
import static org.junit.jupiter.api.Assertions.assertNull; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* companySize 归一化与持久化的端到端回归(真实 Controller + 真实持久化)。 |
|||
* |
|||
* <p>覆盖:创建/修改的缺省、null、空串、纯空白、首尾空格、正常值、40 字符边界、41 字符拒绝, |
|||
* 并通过 {@code em.flush()+clear()} 后重新查询(以及直接读取 SQL 列)确认落库值, |
|||
* 而不是只断言 DTO/内存对象。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
@TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:company_size_display;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
|||
class CompanySizeDisplayIntegrationTest { |
|||
|
|||
private static final AtomicInteger SEQ = new AtomicInteger(); |
|||
private static final String PHONE_PREFIX = "1990076"; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redis; |
|||
@Autowired private MockMvc mvc; |
|||
@Autowired private JdbcTemplate jdbc; |
|||
@Autowired private UserRepository users; |
|||
@Autowired private CompanyRepository companies; |
|||
@Autowired private AuthSessionService sessions; |
|||
@Autowired private ObjectMapper mapper; |
|||
@Autowired private EntityManager em; |
|||
|
|||
private long userId; |
|||
private String token; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
User owner = users.saveAndFlush(User.builder() |
|||
.phone(PHONE_PREFIX + String.format("%04d", SEQ.incrementAndGet())) |
|||
.role(User.Role.COMPANY).roles(new HashSet<>(Set.of(User.Role.COMPANY))) |
|||
.active(true).verified(true).build()); |
|||
userId = owner.getId(); |
|||
token = sessions.createSession(owner, Instant.now(), new AuditContext()).accessToken(); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
jdbc.update("delete from jobs where company_id in (select id from companies where owner_user_id = ?)", userId); |
|||
jdbc.update("delete from companies where owner_user_id = ?", userId); |
|||
jdbc.update("delete from auth_tokens where user_id = ?", userId); |
|||
jdbc.update("delete from user_roles where user_id = ?", userId); |
|||
jdbc.update("delete from users where id = ?", userId); |
|||
} |
|||
|
|||
// ── 创建 ────────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void createDefaultAndExplicitNullPersistAsSqlNull() throws Exception { |
|||
mvc.perform(post("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"name\":\"A企业\"}")) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.companySize").doesNotExist()); |
|||
assertNullCompanySizeInDb(); |
|||
|
|||
jdbc.update("delete from companies where owner_user_id = ?", userId); |
|||
mvc.perform(post("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"name\":\"A企业\",\"companySize\":null}")) |
|||
.andExpect(status().isOk()); |
|||
assertNullCompanySizeInDb(); |
|||
} |
|||
|
|||
@Test |
|||
void createBlankAndWhitespacePersistAsSqlNull() throws Exception { |
|||
for (String raw : new String[]{"\"\"", "\" \"", "\"\\t\\n\""}) { |
|||
jdbc.update("delete from companies where owner_user_id = ?", userId); |
|||
mvc.perform(post("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"name\":\"A企业\",\"companySize\":" + raw + "}")) |
|||
.andExpect(status().isOk()); |
|||
assertNullCompanySizeInDb(); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void createTrimsSurroundingWhitespaceBeforePersisting() throws Exception { |
|||
mvc.perform(post("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"name\":\"A企业\",\"companySize\":\" 100-499人 \"}")) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.companySize").value("100-499人")); |
|||
assertEquals("100-499人", companySizeInDb()); |
|||
} |
|||
|
|||
@Test |
|||
void createAtFortyCharBoundaryIsAccepted() throws Exception { |
|||
String value = "x".repeat(40); |
|||
mvc.perform(post("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"name\":\"A企业\",\"companySize\":\"" + value + "\"}")) |
|||
.andExpect(status().isOk()); |
|||
assertEquals(value, companySizeInDb()); |
|||
} |
|||
|
|||
@Test |
|||
void createFortyOneCharsIsRejectedAndNotTruncated() throws Exception { |
|||
String value = "x".repeat(41); |
|||
mvc.perform(post("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"name\":\"A企业\",\"companySize\":\"" + value + "\"}")) |
|||
.andExpect(status().isBadRequest()); |
|||
assertEquals(0, companyCount()); |
|||
} |
|||
|
|||
// ── 修改 ────────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void updateNullKeepsOriginalAndBlankClearsToNull() throws Exception { |
|||
create("100-499人"); |
|||
|
|||
// 缺省:保持原值
|
|||
mvc.perform(put("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"A企业\"}")) |
|||
.andExpect(status().isOk()); |
|||
assertEquals("100-499人", companySizeInDb()); |
|||
|
|||
// 显式 null:保持原值
|
|||
mvc.perform(put("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"A企业\",\"companySize\":null}")) |
|||
.andExpect(status().isOk()); |
|||
assertEquals("100-499人", companySizeInDb()); |
|||
|
|||
// 空白:清空为 SQL NULL
|
|||
mvc.perform(put("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"A企业\",\"companySize\":\" \"}")) |
|||
.andExpect(status().isOk()); |
|||
assertNullCompanySizeInDb(); |
|||
} |
|||
|
|||
@Test |
|||
void updateTrimsAndRejectsOverLength() throws Exception { |
|||
create("100-499人"); |
|||
|
|||
mvc.perform(put("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"name\":\"A企业\",\"companySize\":\" 50-99人 \"}")) |
|||
.andExpect(status().isOk()); |
|||
assertEquals("50-99人", companySizeInDb()); |
|||
|
|||
mvc.perform(put("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"name\":\"A企业\",\"companySize\":\"" + "y".repeat(41) + "\"}")) |
|||
.andExpect(status().isBadRequest()); |
|||
assertEquals("50-99人", companySizeInDb(), "超长被拒绝,原值不被截断或覆盖"); |
|||
} |
|||
|
|||
// ── 读取:GET /companies/me 与 GET /companies/{companyId} ────
|
|||
|
|||
@Test |
|||
@org.springframework.transaction.annotation.Transactional |
|||
void getMeAndPublicReadSamePersistedValue() throws Exception { |
|||
create("100-499人"); |
|||
long companyId = companies.findByOwnerUserId(userId).orElseThrow().getId(); |
|||
|
|||
// 通过 em.flush()+clear() 断掉一级缓存,强制从数据库重新读取
|
|||
em.flush(); |
|||
em.clear(); |
|||
assertEquals("100-499人", companies.findByOwnerUserId(userId).orElseThrow().getCompanySize()); |
|||
|
|||
mvc.perform(get("/companies/me").header("Authorization", bearer())) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.companySize").value("100-499人")); |
|||
|
|||
mvc.perform(get("/companies/" + companyId)) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.companySize").value("100-499人")); |
|||
} |
|||
|
|||
// ── Helper ──────────────────────────────────────────────────
|
|||
|
|||
private void create(String plainCompanySize) throws Exception { |
|||
String body = plainCompanySize == null |
|||
? "{\"name\":\"A企业\"}" |
|||
: "{\"name\":\"A企业\",\"companySize\":\"" + plainCompanySize + "\"}"; |
|||
mvc.perform(post("/companies/me").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(body)) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
|
|||
private String companySizeInDb() { |
|||
List<String> values = jdbc.queryForList( |
|||
"select company_size from companies where owner_user_id = ?", String.class, userId); |
|||
assertEquals(1, values.size(), "应存在唯一一条企业记录"); |
|||
return values.get(0); |
|||
} |
|||
|
|||
private void assertNullCompanySizeInDb() { |
|||
List<String> values = jdbc.queryForList( |
|||
"select company_size from companies where owner_user_id = ?", String.class, userId); |
|||
assertEquals(1, values.size(), "应存在唯一一条企业记录"); |
|||
assertNull(values.get(0), "company_size 应是 SQL NULL"); |
|||
} |
|||
|
|||
private int companyCount() { |
|||
return jdbc.queryForObject("select count(*) from companies where owner_user_id = ?", Integer.class, userId); |
|||
} |
|||
|
|||
private String bearer() { |
|||
assertNotNull(token); |
|||
return "Bearer " + token; |
|||
} |
|||
} |
|||
@ -0,0 +1,200 @@ |
|||
package com.mydreamjob.integration; |
|||
|
|||
import org.flywaydb.core.Flyway; |
|||
import org.flywaydb.core.api.MigrationVersion; |
|||
import org.flywaydb.core.api.output.MigrateResult; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.testcontainers.DockerClientFactory; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
|
|||
import java.sql.Connection; |
|||
import java.sql.DriverManager; |
|||
import java.sql.ResultSet; |
|||
import java.sql.Statement; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertNotNull; |
|||
import static org.junit.jupiter.api.Assertions.assertNull; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
import static org.junit.jupiter.api.Assumptions.assumeTrue; |
|||
|
|||
/** |
|||
* V57 展示字段在**真实 MySQL** 上的迁移验证(隔离容器,容器/库名带 b01 前缀)。 |
|||
* |
|||
* <p>逐项覆盖:数据库版本记录;从 V56 基线升级到 V57;旧数据保留、新列为 NULL; |
|||
* 新字段写入与重新查询;更新清空语义(NULL);重复迁移无待执行项且 V57 只有一条历史; |
|||
* 空库(独立容器)从零完整迁移到 V57。</p> |
|||
* |
|||
* <p>连接统一追加 {@code characterEncoding=UTF-8},避免 MySQL 5.7 服务器默认字符集导致的种子中文写入失败。 |
|||
* Docker 不可用时环境性跳过(不宣称通过)。</p> |
|||
*/ |
|||
class JobCompanyDisplayFieldsMysqlMigrationTest { |
|||
|
|||
private static final String MIG = "classpath:db/migration"; |
|||
|
|||
@Test |
|||
void mysql57StagedUpgradeAndEmptyDatabase() throws Exception { |
|||
verifyMigration(System.getProperty("b01.mysql.image57", "mysql:5.7.36"), "57"); |
|||
} |
|||
|
|||
@Test |
|||
void mysql8StagedUpgradeAndEmptyDatabase() throws Exception { |
|||
verifyMigration(System.getProperty("b01.mysql.image8", "mysql:8.4"), "8"); |
|||
} |
|||
|
|||
private void verifyMigration(String image, String tag) throws Exception { |
|||
assumeTrue(dockerAvailable(), "Docker 不可用,跳过真实 MySQL 迁移验证"); |
|||
|
|||
String version; |
|||
// ── 阶段一:V56 基线 → 旧数据 → 升级 V57 ──────────────────
|
|||
try (MySQLContainer<?> mysql = new MySQLContainer<>(image) |
|||
.withDatabaseName("mydreamjob_b01_" + tag) |
|||
.withUsername("b01") |
|||
.withPassword("b01-test-only")) { |
|||
mysql.start(); |
|||
String url = charset(mysql.getJdbcUrl()); |
|||
String user = mysql.getUsername(); |
|||
String pass = mysql.getPassword(); |
|||
// MySQL 5.7 服务器默认字符集为 latin1;与真实部署一致,先把库默认字符集设为 utf8mb4,
|
|||
// 使未显式指定字符集的迁移建表(如 V37)可写入中文种子数据。
|
|||
ensureUtf8mb4(url, user, pass, mysql.getDatabaseName()); |
|||
|
|||
version = scalar(url, user, pass, "SELECT VERSION()"); |
|||
System.out.println("[B01][" + image + "] SELECT VERSION() = " + version); |
|||
assertNotNull(version); |
|||
|
|||
// 1) 只迁移到 V56 基线
|
|||
MigrateResult to56 = flyway(url, user, pass, "56").migrate(); |
|||
assertTrue(to56.success, "迁移到 V56 应成功"); |
|||
assertEquals(0, count(url, user, pass, |
|||
"SELECT count(*) FROM flyway_schema_history WHERE version = '57'"), "V56 基线不应含 V57"); |
|||
|
|||
// 2) 旧企业与旧岗位数据
|
|||
try (Connection c = DriverManager.getConnection(url, user, pass); Statement st = c.createStatement()) { |
|||
st.executeUpdate("INSERT INTO users(phone, role, is_active, is_verified) " |
|||
+ "VALUES('19900009001','COMPANY',1,1)"); |
|||
st.executeUpdate("INSERT INTO companies(owner_user_id, name, description, industry, verified) " |
|||
+ "SELECT id,'B01旧企业','旧简介','旧行业',1 FROM users WHERE phone='19900009001'"); |
|||
st.executeUpdate("INSERT INTO jobs(company_id, title, description, requirements, city, " |
|||
+ "employment_type, salary_min, salary_max, status) SELECT c.id,'B01旧岗位','旧描述','旧要求'," |
|||
+ "'北京','FULL_TIME',10000,20000,'PUBLISHED' FROM companies c WHERE c.name='B01旧企业'"); |
|||
} |
|||
|
|||
// 3) 升级到 V57
|
|||
MigrateResult latest = flyway(url, user, pass, null).migrate(); |
|||
assertTrue(latest.success, "升级到 V57 应成功"); |
|||
assertEquals(1, latest.migrationsExecuted, "从 V56 升级应只执行一条迁移(V57)"); |
|||
|
|||
// 4) 旧数据保留、新列为 NULL
|
|||
assertEquals(1, count(url, user, pass, "SELECT count(*) FROM companies WHERE name='B01旧企业'")); |
|||
assertEquals(1, count(url, user, pass, "SELECT count(*) FROM jobs WHERE title='B01旧岗位'")); |
|||
assertNull(scalar(url, user, pass, "SELECT company_size FROM companies WHERE name='B01旧企业'")); |
|||
assertNull(scalar(url, user, pass, "SELECT experience FROM jobs WHERE title='B01旧岗位'")); |
|||
assertNull(scalar(url, user, pass, "SELECT benefits_json FROM jobs WHERE title='B01旧岗位'")); |
|||
assertNull(scalar(url, user, pass, "SELECT salary_months FROM jobs WHERE title='B01旧岗位'")); |
|||
assertNull(scalar(url, user, pass, "SELECT work_location FROM jobs WHERE title='B01旧岗位'")); |
|||
|
|||
// 5) 新增字段写入 + 重新查询 + 更新清空语义(NULL)
|
|||
try (Connection c = DriverManager.getConnection(url, user, pass); Statement st = c.createStatement()) { |
|||
st.executeUpdate("UPDATE companies SET company_size='100-499人' WHERE name='B01旧企业'"); |
|||
st.executeUpdate("UPDATE jobs SET experience='3-5年', education='本科', " |
|||
+ "benefits_json='[\"五险一金\",\"弹性工作\"]', salary_months=14, work_location='上海市浦东新区' " |
|||
+ "WHERE title='B01旧岗位'"); |
|||
} |
|||
assertEquals("100-499人", scalar(url, user, pass, |
|||
"SELECT company_size FROM companies WHERE name='B01旧企业'")); |
|||
assertEquals("[\"五险一金\",\"弹性工作\"]", scalar(url, user, pass, |
|||
"SELECT benefits_json FROM jobs WHERE title='B01旧岗位'")); |
|||
assertEquals("14", scalar(url, user, pass, |
|||
"SELECT salary_months FROM jobs WHERE title='B01旧岗位'")); |
|||
|
|||
try (Connection c = DriverManager.getConnection(url, user, pass); Statement st = c.createStatement()) { |
|||
st.executeUpdate("UPDATE companies SET company_size=NULL WHERE name='B01旧企业'"); |
|||
st.executeUpdate("UPDATE jobs SET benefits_json=NULL, salary_months=NULL WHERE title='B01旧岗位'"); |
|||
} |
|||
assertNull(scalar(url, user, pass, "SELECT company_size FROM companies WHERE name='B01旧企业'")); |
|||
assertNull(scalar(url, user, pass, "SELECT benefits_json FROM jobs WHERE title='B01旧岗位'")); |
|||
assertNull(scalar(url, user, pass, "SELECT salary_months FROM jobs WHERE title='B01旧岗位'")); |
|||
|
|||
// 6) 重复迁移无待执行项;V57 只有一条历史且成功;新增列不重复
|
|||
MigrateResult again = flyway(url, user, pass, null).migrate(); |
|||
assertEquals(0, again.migrationsExecuted, "再次迁移应无待执行项"); |
|||
assertEquals(1, count(url, user, pass, |
|||
"SELECT count(*) FROM flyway_schema_history WHERE version='57' AND success=1"), |
|||
"flyway_schema_history 中 V57 应只有一条"); |
|||
assertEquals(6, count(url, user, pass, newColumnCountSql()), "新增 6 列应恰好各一列,不重复增加"); |
|||
|
|||
System.out.println("[B01][" + image + "] staged upgrade verified: version=" + version |
|||
+ ", to56=" + to56.migrationsExecuted + ", upgrade=" + latest.migrationsExecuted |
|||
+ ", rerun=" + again.migrationsExecuted); |
|||
} |
|||
|
|||
// ── 阶段二:空库(独立容器)从零完整迁移到 V57 ────────────
|
|||
try (MySQLContainer<?> empty = new MySQLContainer<>(image) |
|||
.withDatabaseName("mydreamjob_b01_empty_" + tag) |
|||
.withUsername("b01") |
|||
.withPassword("b01-test-only")) { |
|||
empty.start(); |
|||
String url = charset(empty.getJdbcUrl()); |
|||
String user = empty.getUsername(); |
|||
String pass = empty.getPassword(); |
|||
ensureUtf8mb4(url, user, pass, empty.getDatabaseName()); |
|||
|
|||
MigrateResult fromScratch = flyway(url, user, pass, null).migrate(); |
|||
assertTrue(fromScratch.success, "空库应能完整迁移到 V57"); |
|||
assertEquals("57", scalar(url, user, pass, |
|||
"SELECT version FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 1"), |
|||
"空库迁移后的最高版本应为 57"); |
|||
assertEquals(6, count(url, user, pass, newColumnCountSql()), "空库迁移后新增 6 列存在"); |
|||
System.out.println("[B01][" + image + "] empty-db migration verified: executed=" |
|||
+ fromScratch.migrationsExecuted); |
|||
} |
|||
} |
|||
|
|||
private static String newColumnCountSql() { |
|||
return "SELECT count(*) FROM information_schema.columns " |
|||
+ "WHERE table_schema = DATABASE() AND ((table_name='companies' AND column_name='company_size') " |
|||
+ "OR (table_name='jobs' AND column_name IN ('experience','education','benefits_json'," |
|||
+ "'salary_months','work_location')))"; |
|||
} |
|||
|
|||
private static String charset(String url) { |
|||
return url + (url.contains("?") ? "&" : "?") + "useUnicode=true&characterEncoding=UTF-8"; |
|||
} |
|||
|
|||
private static void ensureUtf8mb4(String url, String user, String pass, String database) throws Exception { |
|||
try (Connection c = DriverManager.getConnection(url, user, pass); Statement st = c.createStatement()) { |
|||
st.executeUpdate("ALTER DATABASE `" + database + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); |
|||
} |
|||
} |
|||
|
|||
private Flyway flyway(String url, String user, String pass, String target) { |
|||
var cfg = Flyway.configure().dataSource(url, user, pass).locations(MIG); |
|||
// 与生产启动路径一致:应用 MySQL 5.7 兼容钩子(仅替换 V49;MySQL 8/H2 下为空操作)。
|
|||
com.mydreamjob.config.MySql57MigrationConfiguration.configure(cfg); |
|||
if (target != null) { |
|||
cfg = cfg.target(MigrationVersion.fromVersion(target)); |
|||
} |
|||
return cfg.load(); |
|||
} |
|||
|
|||
private String scalar(String url, String user, String pass, String sql) throws Exception { |
|||
try (Connection c = DriverManager.getConnection(url, user, pass); |
|||
Statement st = c.createStatement(); |
|||
ResultSet rs = st.executeQuery(sql)) { |
|||
return rs.next() ? rs.getString(1) : null; |
|||
} |
|||
} |
|||
|
|||
private int count(String url, String user, String pass, String sql) throws Exception { |
|||
return Integer.parseInt(scalar(url, user, pass, sql)); |
|||
} |
|||
|
|||
private boolean dockerAvailable() { |
|||
try { |
|||
return DockerClientFactory.instance().isDockerAvailable(); |
|||
} catch (Throwable ignored) { |
|||
return false; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,95 @@ |
|||
package com.mydreamjob.integration; |
|||
|
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.annotation.DirtiesContext; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.DynamicPropertyRegistry; |
|||
import org.springframework.test.context.DynamicPropertySource; |
|||
import org.testcontainers.containers.MySQLContainer; |
|||
import org.testcontainers.junit.jupiter.Container; |
|||
import org.testcontainers.junit.jupiter.Testcontainers; |
|||
|
|||
import java.util.Optional; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
|
|||
/** |
|||
* 用 {@code spring.jpa.hibernate.ddl-auto=validate} 在**真实 MySQL** 上启动当前后端上下文, |
|||
* 验证实体映射与 V57 迁移后的实际库结构匹配。 |
|||
* |
|||
* <p>镜像由系统属性 {@code b01.mysql.image} 指定(默认 {@code mysql:8.4}), |
|||
* 便于对 MySQL 5.7 与 MySQL 8 分别验证。连接参数来自容器,运行期注入,不落盘、不打印密码。</p> |
|||
*/ |
|||
@Testcontainers(disabledWithoutDocker = true) |
|||
@SpringBootTest |
|||
@ActiveProfiles("mysql-it") |
|||
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) |
|||
class JobCompanyDisplayFieldsMysqlValidateTest { |
|||
|
|||
private static final String IMAGE = System.getProperty("b01.mysql.image", "mysql:8.4"); |
|||
|
|||
@Container |
|||
static final MySQLContainer<?> MYSQL = new MySQLContainer<>(IMAGE) |
|||
.withDatabaseName("mydreamjob_b01_validate") |
|||
.withUsername("b01") |
|||
.withPassword("b01-test-only"); |
|||
|
|||
@DynamicPropertySource |
|||
static void datasource(DynamicPropertyRegistry registry) { |
|||
// MySQL 5.7 服务器默认字符集为 latin1;与真实部署一致,先把库默认字符集设为 utf8mb4,
|
|||
// 使未显式指定字符集的迁移建表(如 V37)可写入中文种子数据,然后才允许 Flyway 迁移。
|
|||
ensureUtf8mb4(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword(), MYSQL.getDatabaseName()); |
|||
registry.add("spring.datasource.url", MYSQL::getJdbcUrl); |
|||
registry.add("spring.datasource.username", MYSQL::getUsername); |
|||
registry.add("spring.datasource.password", MYSQL::getPassword); |
|||
// 显式要求 validate:上下文能启动即证明实体与真实数据库结构匹配。
|
|||
registry.add("spring.jpa.hibernate.ddl-auto", () -> "validate"); |
|||
} |
|||
|
|||
private static void ensureUtf8mb4(String url, String user, String pass, String database) { |
|||
String jdbc = url + (url.contains("?") ? "&" : "?") + "useUnicode=true&characterEncoding=UTF-8"; |
|||
try (java.sql.Connection c = java.sql.DriverManager.getConnection(jdbc, user, pass); |
|||
java.sql.Statement st = c.createStatement()) { |
|||
st.executeUpdate("ALTER DATABASE `" + database + "` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); |
|||
} catch (java.sql.SQLException ex) { |
|||
throw new IllegalStateException("database charset initialization failed"); |
|||
} |
|||
} |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redis; |
|||
@Autowired private JdbcTemplate jdbc; |
|||
@Autowired private CompanyRepository companies; |
|||
|
|||
@Test |
|||
void contextStartsWithValidateAndSchemaMatchesV57() { |
|||
String version = jdbc.queryForObject("SELECT VERSION()", String.class); |
|||
System.out.println("[B01][" + IMAGE + "] validate context started; version=" + version); |
|||
|
|||
assertEquals(1L, jdbc.queryForObject( |
|||
"SELECT count(*) FROM flyway_schema_history WHERE version='57' AND success=1", Long.class), |
|||
"V57 应恰好迁移一次"); |
|||
|
|||
assertEquals(6, jdbc.queryForObject("SELECT count(*) FROM information_schema.columns " |
|||
+ "WHERE table_schema = DATABASE() AND ((table_name='companies' AND column_name='company_size') " |
|||
+ "OR (table_name='jobs' AND column_name IN ('experience','education','benefits_json'," |
|||
+ "'salary_months','work_location')))", Integer.class), "新增 6 列存在且不重复"); |
|||
|
|||
// 实体可正常读写 companySize(validate 通过后映射可用)
|
|||
jdbc.update("INSERT INTO users(phone, role, is_active, is_verified) " |
|||
+ "VALUES('19909000001','COMPANY',1,1)"); |
|||
Long ownerId = jdbc.queryForObject("SELECT id FROM users WHERE phone='19909000001'", Long.class); |
|||
Company company = companies.saveAndFlush(Company.builder() |
|||
.ownerUserId(ownerId).name("B01 validate 企业").companySize("100-499人").verified(true).build()); |
|||
Optional<Company> reloaded = companies.findById(company.getId()); |
|||
assertTrue(reloaded.isPresent()); |
|||
assertEquals("100-499人", reloaded.get().getCompanySize()); |
|||
} |
|||
} |
|||
@ -0,0 +1,167 @@ |
|||
package com.mydreamjob.job; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.auth.AuthSessionService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.time.Instant; |
|||
import java.util.HashSet; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* 岗位展示字段相关的越权/认证/认证门禁回归:与既有行为保持一致。 |
|||
* 跨企业修改→404、非企业写入→403、未登录→401、未认证企业发布→403。 |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
@TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:display_fields_authz;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
|||
class JobDisplayFieldsAuthorizationIntegrationTest { |
|||
|
|||
private static final AtomicInteger SEQ = new AtomicInteger(); |
|||
private static final String PHONE_PREFIX = "1990079"; |
|||
private static final String CITY = "AUTHZCITY"; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redis; |
|||
@Autowired private MockMvc mvc; |
|||
@Autowired private JdbcTemplate jdbc; |
|||
@Autowired private ObjectMapper mapper; |
|||
@Autowired private UserRepository users; |
|||
@Autowired private CompanyRepository companies; |
|||
@Autowired private AuthSessionService sessions; |
|||
|
|||
private Identity companyA; |
|||
private Identity companyB; |
|||
private Identity unverified; |
|||
private Identity student; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
companyA = identity(User.Role.COMPANY, true); |
|||
companyB = identity(User.Role.COMPANY, true); |
|||
unverified = identity(User.Role.COMPANY, false); |
|||
student = identity(User.Role.STUDENT, false); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
jdbc.update("delete from jobs where company_id in (select id from companies where owner_user_id in" |
|||
+ " (select id from users where phone like ?))", PHONE_PREFIX + "%"); |
|||
jdbc.update("delete from companies where owner_user_id in (select id from users where phone like ?)", |
|||
PHONE_PREFIX + "%"); |
|||
jdbc.update("delete from auth_tokens where user_id in (select id from users where phone like ?)", |
|||
PHONE_PREFIX + "%"); |
|||
jdbc.update("delete from user_roles where user_id in (select id from users where phone like ?)", |
|||
PHONE_PREFIX + "%"); |
|||
jdbc.update("delete from users where phone like ?", PHONE_PREFIX + "%"); |
|||
} |
|||
|
|||
@Test |
|||
void crossCompanyModificationIsDeniedAsJobNotFoundAndLeavesTargetIntact() throws Exception { |
|||
long jobId = createJob(companyA, "A企业岗位"); |
|||
|
|||
mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer(companyB)) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"越权改写\"}")) |
|||
.andExpect(status().isNotFound()) |
|||
.andExpect(jsonPath("$.code").value(7001)); |
|||
|
|||
mvc.perform(get("/jobs/" + jobId).header("Authorization", bearer(companyA))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.title").value("A企业岗位")); |
|||
} |
|||
|
|||
@Test |
|||
void crossCompanyDetailReadIsDeniedAsJobNotFound() throws Exception { |
|||
long jobId = createJob(companyA, "A企业岗位"); |
|||
mvc.perform(get("/jobs/" + jobId).header("Authorization", bearer(companyB))) |
|||
.andExpect(status().isNotFound()) |
|||
.andExpect(jsonPath("$.code").value(7001)); |
|||
} |
|||
|
|||
@Test |
|||
void nonCompanyAndAnonymousCannotCreateJobs() throws Exception { |
|||
String body = "{\"title\":\"岗位\",\"description\":\"d\",\"city\":\"" + CITY + "\",\"employmentType\":\"FULL_TIME\"}"; |
|||
mvc.perform(post("/jobs").header("Authorization", bearer(student)) |
|||
.contentType(MediaType.APPLICATION_JSON).content(body)) |
|||
.andExpect(status().isForbidden()); |
|||
mvc.perform(post("/jobs").contentType(MediaType.APPLICATION_JSON).content(body)) |
|||
.andExpect(status().isUnauthorized()); |
|||
} |
|||
|
|||
@Test |
|||
void unverifiedCompanyCanDraftButCannotPublish() throws Exception { |
|||
long jobId = createJob(unverified, "未认证草稿"); |
|||
assertEquals("DRAFT", jdbc.queryForObject( |
|||
"select status from jobs where id = ?", String.class, jobId)); |
|||
|
|||
mvc.perform(patch("/jobs/" + jobId + "/status").header("Authorization", bearer(unverified)) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"PUBLISHED\"}")) |
|||
.andExpect(status().isForbidden()) |
|||
.andExpect(jsonPath("$.code").value(5004)); |
|||
} |
|||
|
|||
@Test |
|||
void unverifiedCompanyCannotUpdateNonDraftJob() throws Exception { |
|||
long jobId = createJob(unverified, "未认证非草稿"); |
|||
jdbc.update("update jobs set status = 'PUBLISHED' where id = ?", jobId); |
|||
|
|||
mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer(unverified)) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"改写\"}")) |
|||
.andExpect(status().isForbidden()) |
|||
.andExpect(jsonPath("$.code").value(5004)); |
|||
} |
|||
|
|||
// ── Helper ──────────────────────────────────────────────────
|
|||
|
|||
private long createJob(Identity who, String title) throws Exception { |
|||
String response = mvc.perform(post("/jobs").header("Authorization", bearer(who)) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"title\":\"" + title + "\",\"description\":\"岗位描述\",\"city\":\"" + CITY |
|||
+ "\",\"employmentType\":\"FULL_TIME\",\"experience\":\"3-5年\",\"salaryMonths\":14}")) |
|||
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
|||
return mapper.readTree(response).path("data").path("id").asLong(); |
|||
} |
|||
|
|||
private Identity identity(User.Role role, boolean verified) { |
|||
String phone = PHONE_PREFIX + String.format("%04d", SEQ.incrementAndGet()); |
|||
User user = users.saveAndFlush(User.builder() |
|||
.phone(phone).role(role).roles(new HashSet<>(Set.of(role))).active(true).verified(true).build()); |
|||
long companyId = companies.saveAndFlush(Company.builder() |
|||
.ownerUserId(user.getId()).name("企业-" + phone).verified(verified).build()).getId(); |
|||
var token = sessions.createSession(user, Instant.now(), new AuditContext()).accessToken(); |
|||
return new Identity(user.getId(), companyId, token); |
|||
} |
|||
|
|||
private String bearer(Identity identity) { |
|||
return "Bearer " + identity.token(); |
|||
} |
|||
|
|||
private record Identity(long userId, long companyId, String token) {} |
|||
} |
|||
@ -0,0 +1,334 @@ |
|||
package com.mydreamjob.job; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.auth.AuthSessionService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.time.Instant; |
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* 岗位新增展示字段(experience / education / benefits / salaryMonths / workLocation)在各读取接口的一致返回、 |
|||
* 字符串与数组边界、公开企业摘要白名单、草稿可见性,以及历史 NULL 数据读取。 |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
@TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:display_fields_consistency;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
|||
class JobDisplayFieldsConsistencyIntegrationTest { |
|||
|
|||
private static final AtomicInteger SEQ = new AtomicInteger(); |
|||
private static final String PHONE_PREFIX = "1990078"; |
|||
private static final String CITY = "CONSISTENCYCITY"; |
|||
private static final Set<String> PUBLIC_COMPANY_KEYS = |
|||
Set.of("id", "name", "logoUrl", "description", "industry", "verified", "companySize"); |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redis; |
|||
@Autowired private MockMvc mvc; |
|||
@Autowired private JdbcTemplate jdbc; |
|||
@Autowired private ObjectMapper mapper; |
|||
@Autowired private UserRepository users; |
|||
@Autowired private CompanyRepository companies; |
|||
@Autowired private AuthSessionService sessions; |
|||
|
|||
private long userId; |
|||
private long companyId; |
|||
private String token; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
User owner = users.saveAndFlush(User.builder() |
|||
.phone(PHONE_PREFIX + String.format("%04d", SEQ.incrementAndGet())) |
|||
.role(User.Role.COMPANY).roles(new HashSet<>(Set.of(User.Role.COMPANY))) |
|||
.active(true).verified(true).build()); |
|||
userId = owner.getId(); |
|||
token = sessions.createSession(owner, Instant.now(), new AuditContext()).accessToken(); |
|||
companyId = companies.saveAndFlush(Company.builder() |
|||
.ownerUserId(userId).name("展示一致性企业") |
|||
.logoUrl("https://logo.example/consistency.png") |
|||
.description("企业简介") |
|||
.industry("互联网") |
|||
.companySize("100-499人") |
|||
.address("企业登记地址不应外泄") |
|||
.website("https://consistency.example") |
|||
.verified(true).build()).getId(); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
jdbc.update("delete from jobs where company_id = ?", companyId); |
|||
jdbc.update("delete from companies where id = ?", companyId); |
|||
jdbc.update("delete from auth_tokens where user_id = ?", userId); |
|||
jdbc.update("delete from user_roles where user_id = ?", userId); |
|||
jdbc.update("delete from users where id = ?", userId); |
|||
} |
|||
|
|||
// ── 1. 各接口字段一致 ───────────────────────────────────────
|
|||
|
|||
@Test |
|||
void newFieldsAreConsistentAcrossCreateUpdateAndAllReadEndpoints() throws Exception { |
|||
long jobId = createFullJob("一致性岗位"); |
|||
publish(jobId); |
|||
|
|||
// GET /jobs → data.items[0]
|
|||
assertFields(data(get("/jobs").param("city", CITY)).at("/items/0")); |
|||
// GET /jobs/search → data.items[0]
|
|||
assertFields(data(get("/jobs/search").param("city", CITY) |
|||
.param("companyId", String.valueOf(companyId))).at("/items/0")); |
|||
// GET /jobs/public/{jobId} → data 本身
|
|||
assertFields(data(get("/jobs/public/" + jobId))); |
|||
// GET /companies/{companyId}/jobs → data.items[0]
|
|||
assertFields(data(get("/companies/" + companyId + "/jobs")).at("/items/0")); |
|||
// GET /companies/me/jobs(企业自身岗位列表)→ data.items[0]
|
|||
assertFields(data(get("/companies/me/jobs").header("Authorization", bearer())).at("/items/0")); |
|||
// GET /jobs/{jobId}(企业岗位详情)→ data 本身
|
|||
assertFields(data(get("/jobs/" + jobId).header("Authorization", bearer()))); |
|||
|
|||
// PUT /jobs/{jobId} 响应 → data 本身
|
|||
JsonNode updated = data(put("/jobs/" + jobId).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"一致性岗位-更新\"}")); |
|||
assertFields(updated); |
|||
assertEquals("一致性岗位-更新", updated.path("title").asText()); |
|||
} |
|||
|
|||
// ── 2. 字符串长度边界 ───────────────────────────────────────
|
|||
|
|||
@Test |
|||
void experienceEducationAndWorkLocationLengthBoundaries() throws Exception { |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"experience\":\"" + "e".repeat(40) + "\""))) |
|||
.andExpect(status().isOk()); |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"experience\":\"" + "e".repeat(41) + "\""))) |
|||
.andExpect(status().isBadRequest()); |
|||
|
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"education\":\"" + "d".repeat(40) + "\""))) |
|||
.andExpect(status().isOk()); |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"education\":\"" + "d".repeat(41) + "\""))) |
|||
.andExpect(status().isBadRequest()); |
|||
|
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"workLocation\":\"" + "w".repeat(255) + "\""))) |
|||
.andExpect(status().isOk()); |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"workLocation\":\"" + "w".repeat(256) + "\""))) |
|||
.andExpect(status().isBadRequest()); |
|||
} |
|||
|
|||
// ── 3. benefits 规则 ────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void benefitsArrayRulesAndDeduplicationKeepOrder() throws Exception { |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"benefits\":" + jsonArray(20, 5)))) |
|||
.andExpect(status().isOk()); |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"benefits\":" + jsonArray(21, 5)))) |
|||
.andExpect(status().isBadRequest()); |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"benefits\":[\"" + "b".repeat(40) + "\"]"))) |
|||
.andExpect(status().isOk()); |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"benefits\":[\"" + "b".repeat(41) + "\"]"))) |
|||
.andExpect(status().isBadRequest()); |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"benefits\":[\"\"]"))) |
|||
.andExpect(status().isBadRequest()); |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()).contentType(MediaType.APPLICATION_JSON) |
|||
.content(jobBody("\"benefits\":[\" \"]"))) |
|||
.andExpect(status().isBadRequest()); |
|||
|
|||
long jobId = createFullJob("去重岗位"); |
|||
String response = mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"benefits\":[\"A\",\"B\",\"A\",\"C\",\"B\"]}")) |
|||
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
|||
assertEquals(List.of("A", "B", "C"), |
|||
strings(mapper.readTree(response).path("data").path("benefits"))); |
|||
} |
|||
|
|||
@Test |
|||
void emptyBenefitsArrayClearsToNullThenReadsAsEmptyArray() throws Exception { |
|||
long jobId = createFullJob("清空福利"); |
|||
publish(jobId); |
|||
mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"benefits\":[]}")) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.benefits.length()").value(0)); |
|||
assertEquals(null, jdbc.queryForObject( |
|||
"select benefits_json from jobs where id = ?", String.class, jobId)); |
|||
JsonNode publicData = data(get("/jobs/public/" + jobId)); |
|||
assertTrue(publicData.path("benefits").isArray()); |
|||
assertEquals(0, publicData.path("benefits").size()); |
|||
} |
|||
|
|||
// ── 4. 公开企业摘要白名单 ───────────────────────────────────
|
|||
|
|||
@Test |
|||
void publicCompanySummaryIsExactlySevenFieldWhitelist() throws Exception { |
|||
long jobId = createFullJob("白名单岗位"); |
|||
publish(jobId); |
|||
|
|||
String companyRaw = raw(get("/companies/" + companyId)); |
|||
assertEquals(PUBLIC_COMPANY_KEYS, fieldNames(mapper.readTree(companyRaw).path("data"))); |
|||
assertEquals(PUBLIC_COMPANY_KEYS, |
|||
fieldNames(data(get("/jobs/public/" + jobId)).path("company"))); |
|||
|
|||
for (String forbidden : new String[]{ |
|||
"ownerUserId", "address", "website", "createdAt", "企业登记地址不应外泄", "phone", "password"}) { |
|||
assertTrue(!companyRaw.contains(forbidden), "公开企业摘要不得包含敏感字段或值:" + forbidden); |
|||
} |
|||
JsonNode companyNode = mapper.readTree(companyRaw).path("data"); |
|||
assertTrue(companyNode.path("ownerUserId").isMissingNode(), "公开企业摘要不得出现 ownerUserId 字段"); |
|||
companyNode.forEach(value -> |
|||
assertTrue(!(value.isNumber() && value.asLong() == userId), "公开企业摘要不得包含企业主 ID")); |
|||
assertEquals("100-499人", companyNode.path("companySize").asText()); |
|||
} |
|||
|
|||
// ── 5. 草稿不可见、已关闭仍可见 ─────────────────────────────
|
|||
|
|||
@Test |
|||
void draftIsNotPubliclyVisibleAndClosedRemainsVisible() throws Exception { |
|||
long draftId = createFullJob("草稿岗位"); |
|||
mvc.perform(get("/jobs/public/" + draftId)).andExpect(status().isNotFound()); |
|||
|
|||
publish(draftId); |
|||
mvc.perform(get("/jobs/public/" + draftId)).andExpect(status().isOk()); |
|||
|
|||
mvc.perform(patch("/jobs/" + draftId + "/status").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"CLOSED\"}")) |
|||
.andExpect(status().isOk()); |
|||
mvc.perform(get("/jobs/public/" + draftId)).andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.status").value("CLOSED")); |
|||
} |
|||
|
|||
// ── 6. 历史 NULL 数据读取 ──────────────────────────────────
|
|||
|
|||
@Test |
|||
void legacyRowsWithNullNewFieldsReadCleanlyWithoutFabrication() throws Exception { |
|||
int companiesBefore = jdbc.queryForObject("select count(*) from companies", Integer.class); |
|||
LocalDateTime now = LocalDateTime.now(); |
|||
jdbc.update("insert into jobs (company_id,title,description,city,employment_type,status,created_at,updated_at)" |
|||
+ " values (?,?,?,?,?,?,?,?)", |
|||
companyId, "历史岗位", "描述", CITY, "FULL_TIME", "PUBLISHED", now, now); |
|||
long legacyId = jdbc.queryForObject( |
|||
"select id from jobs where company_id = ? and title = ?", Long.class, companyId, "历史岗位"); |
|||
|
|||
JsonNode data = data(get("/jobs/public/" + legacyId)); |
|||
assertTrue(data.path("experience").isNull() || data.path("experience").isMissingNode()); |
|||
assertTrue(data.path("education").isNull() || data.path("education").isMissingNode()); |
|||
assertTrue(data.path("salaryMonths").isNull() || data.path("salaryMonths").isMissingNode()); |
|||
assertTrue(data.path("workLocation").isNull() || data.path("workLocation").isMissingNode()); |
|||
assertTrue(data.path("benefits").isArray(), "benefits 对外恒为数组"); |
|||
assertEquals(0, data.path("benefits").size(), "NULL benefits_json 应返回空数组"); |
|||
|
|||
assertEquals(companiesBefore, |
|||
jdbc.queryForObject("select count(*) from companies", Integer.class), |
|||
"读取历史数据不得制造默认企业"); |
|||
} |
|||
|
|||
// ── Helper ──────────────────────────────────────────────────
|
|||
|
|||
private void assertFields(JsonNode item) { |
|||
assertEquals("3-5年", item.path("experience").asText()); |
|||
assertEquals("本科", item.path("education").asText()); |
|||
assertEquals(14, item.path("salaryMonths").asInt()); |
|||
assertEquals("上海市浦东新区", item.path("workLocation").asText()); |
|||
assertEquals(List.of("五险一金", "弹性工作"), strings(item.path("benefits"))); |
|||
} |
|||
|
|||
private long createFullJob(String title) throws Exception { |
|||
String response = mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"title\":\"" + title + "\",\"description\":\"岗位描述\",\"requirements\":\"岗位要求\"," |
|||
+ "\"city\":\"" + CITY + "\",\"employmentType\":\"FULL_TIME\"," |
|||
+ "\"salaryMin\":20000,\"salaryMax\":40000,\"experience\":\"3-5年\"," |
|||
+ "\"education\":\"本科\",\"benefits\":[\"五险一金\",\"弹性工作\"]," |
|||
+ "\"salaryMonths\":14,\"workLocation\":\"上海市浦东新区\"}")) |
|||
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
|||
return mapper.readTree(response).path("data").path("id").asLong(); |
|||
} |
|||
|
|||
private void publish(long jobId) throws Exception { |
|||
mvc.perform(patch("/jobs/" + jobId + "/status").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"status\":\"PUBLISHED\"}")) |
|||
.andExpect(status().isOk()); |
|||
} |
|||
|
|||
private String jobBody(String extraField) { |
|||
return "{\"title\":\"岗位\",\"description\":\"岗位描述\",\"city\":\"" + CITY + "\"," |
|||
+ "\"employmentType\":\"FULL_TIME\"," + extraField + "}"; |
|||
} |
|||
|
|||
private static String jsonArray(int size, int itemLength) { |
|||
StringBuilder sb = new StringBuilder("["); |
|||
for (int i = 0; i < size; i++) { |
|||
if (i > 0) sb.append(','); |
|||
String base = "v" + i; |
|||
int pad = Math.max(1, itemLength - base.length()); |
|||
sb.append('"').append(base).append("x".repeat(pad)).append('"'); |
|||
} |
|||
return sb.append(']').toString(); |
|||
} |
|||
|
|||
private JsonNode data(MockHttpServletRequestBuilder builder) throws Exception { |
|||
return mapper.readTree(raw(builder)).path("data"); |
|||
} |
|||
|
|||
private String raw(MockHttpServletRequestBuilder builder) throws Exception { |
|||
return mvc.perform(builder).andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
|||
} |
|||
|
|||
private static Set<String> fieldNames(JsonNode node) { |
|||
Set<String> names = new HashSet<>(); |
|||
node.fieldNames().forEachRemaining(names::add); |
|||
return names; |
|||
} |
|||
|
|||
private static List<String> strings(JsonNode array) { |
|||
List<String> values = new ArrayList<>(); |
|||
array.forEach(n -> values.add(n.asText())); |
|||
return values; |
|||
} |
|||
|
|||
private String bearer() { |
|||
return "Bearer " + token; |
|||
} |
|||
} |
|||
@ -0,0 +1,362 @@ |
|||
package com.mydreamjob.job; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.job.dto.JobCreateRequest; |
|||
import com.mydreamjob.job.dto.JobUpdateRequest; |
|||
import jakarta.persistence.EntityManager; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.function.Executable; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.ArrayList; |
|||
import java.util.Arrays; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertNotNull; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* 岗位展示字段(experience / education / benefits / salaryMonths / workLocation)与企业摘要字段的 |
|||
* 端到端行为:写入、清空、校验、公开响应与企业摘要复用。 |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
@Transactional |
|||
class JobDisplayFieldsIntegrationTest { |
|||
|
|||
private static final String CITY = "DISPLAYTESTCITY"; |
|||
private static final String BENEFITS_JSON = "[\"五险一金\",\"弹性工作\"]"; |
|||
|
|||
@Autowired EntityManager em; |
|||
@Autowired JobService jobService; |
|||
@Autowired ObjectMapper objectMapper; |
|||
@Autowired MockMvc mvc; |
|||
@MockBean RedisTemplate<String, Object> redis; |
|||
|
|||
private Long companyId; |
|||
private String ownerPhone; |
|||
|
|||
@BeforeEach |
|||
void fixture() { |
|||
ownerPhone = "188" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L)); |
|||
User owner = User.builder().phone(ownerPhone).nickname("display-company") |
|||
.role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)) |
|||
.active(true).verified(true).build(); |
|||
em.persist(owner); |
|||
Company company = Company.builder() |
|||
.ownerUserId(owner.getId()) |
|||
.name("展示字段测试企业") |
|||
.logoUrl("https://logo.example/display.png") |
|||
.description("企业简介-展示") |
|||
.industry("互联网") |
|||
.companySize("100-499人") |
|||
.website("https://display.example") |
|||
.address("企业登记地址不应外泄") |
|||
.verified(true) |
|||
.build(); |
|||
em.persist(company); |
|||
companyId = company.getId(); |
|||
em.flush(); |
|||
em.clear(); |
|||
} |
|||
|
|||
// ── 创建:带全部新字段,各类视图一致 ────────────────────────
|
|||
|
|||
@Test |
|||
void createWithAllNewFieldsViewsStayConsistent() throws Exception { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("后端工程师")); |
|||
|
|||
// benefits_json 以 TEXT 保存 JSON 数组字符串(非 JSON 列)
|
|||
assertEquals(BENEFITS_JSON, job.getBenefitsJson()); |
|||
|
|||
Map<String, Object> view = jobService.toView(job); |
|||
assertEquals("3-5年", view.get("experience")); |
|||
assertEquals("本科", view.get("education")); |
|||
assertEquals(List.of("五险一金", "弹性工作"), view.get("benefits")); |
|||
assertEquals(14, view.get("salaryMonths")); |
|||
assertEquals("上海市浦东新区", view.get("workLocation")); |
|||
|
|||
// 公开详情(需 PUBLISHED/CLOSED)
|
|||
job.setStatus("PUBLISHED"); |
|||
em.flush(); |
|||
Map<String, Object> pub = jobService.getPublicJob(job.getId()); |
|||
assertEquals(companyId, pub.get("companyId")); |
|||
assertEquals("3-5年", pub.get("experience")); |
|||
assertEquals("本科", pub.get("education")); |
|||
assertEquals(List.of("五险一金", "弹性工作"), pub.get("benefits")); |
|||
assertEquals(14, pub.get("salaryMonths")); |
|||
assertEquals("上海市浦东新区", pub.get("workLocation")); |
|||
} |
|||
|
|||
// ── 旧请求兼容:不传新字段 ─────────────────────────────────
|
|||
|
|||
@Test |
|||
void legacyRequestWithoutNewFieldsStaysCompatible() { |
|||
JobCreateRequest req = new JobCreateRequest(); |
|||
req.setTitle("旧请求岗位"); |
|||
req.setDescription("岗位描述"); |
|||
req.setCity(CITY); |
|||
req.setEmploymentType("FULL_TIME"); |
|||
|
|||
Job job = jobService.createJob(companyId, req); |
|||
assertNullFieldsOn(job); |
|||
|
|||
Map<String, Object> view = jobService.toView(job); |
|||
assertEquals(null, view.get("experience")); |
|||
assertEquals(null, view.get("salaryMonths")); |
|||
assertEquals(List.of(), view.get("benefits")); |
|||
|
|||
job.setStatus("PUBLISHED"); |
|||
em.flush(); |
|||
assertEquals(List.of(), jobService.getPublicJob(job.getId()).get("benefits")); |
|||
} |
|||
|
|||
// ── 更新语义 ───────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void updateWithNullFieldsKeepsOriginalValues() { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("更新保持")); |
|||
Job updated = jobService.updateJob(companyId, job.getId(), new JobUpdateRequest()); |
|||
|
|||
assertEquals("3-5年", updated.getExperience()); |
|||
assertEquals("本科", updated.getEducation()); |
|||
assertEquals(14, updated.getSalaryMonths()); |
|||
assertEquals("上海市浦东新区", updated.getWorkLocation()); |
|||
assertEquals(BENEFITS_JSON, updated.getBenefitsJson()); |
|||
} |
|||
|
|||
@Test |
|||
void updateWithBlankStringClearsToNull() { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("更新清空")); |
|||
JobUpdateRequest req = new JobUpdateRequest(); |
|||
req.setExperience(" "); |
|||
req.setEducation(""); |
|||
req.setWorkLocation(" "); |
|||
|
|||
Job updated = jobService.updateJob(companyId, job.getId(), req); |
|||
assertEquals(null, updated.getExperience()); |
|||
assertEquals(null, updated.getEducation()); |
|||
assertEquals(null, updated.getWorkLocation()); |
|||
|
|||
em.flush(); |
|||
em.clear(); |
|||
Job reloaded = jobService.getJob(job.getId()); |
|||
assertEquals(null, reloaded.getExperience()); |
|||
assertEquals(null, reloaded.getEducation()); |
|||
assertEquals(null, reloaded.getWorkLocation()); |
|||
} |
|||
|
|||
@Test |
|||
void updateWithNonBlankValueTrimsEnds() { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("更新去空白")); |
|||
JobUpdateRequest req = new JobUpdateRequest(); |
|||
req.setExperience(" 5-10年 "); |
|||
req.setWorkLocation(" 北京市海淀区 "); |
|||
|
|||
Job updated = jobService.updateJob(companyId, job.getId(), req); |
|||
assertEquals("5-10年", updated.getExperience()); |
|||
assertEquals("北京市海淀区", updated.getWorkLocation()); |
|||
} |
|||
|
|||
@Test |
|||
void updateWithEmptyBenefitsListClears() { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("福利清空")); |
|||
JobUpdateRequest req = new JobUpdateRequest(); |
|||
req.setBenefits(List.of()); |
|||
|
|||
Job updated = jobService.updateJob(companyId, job.getId(), req); |
|||
assertEquals(null, updated.getBenefitsJson()); |
|||
assertEquals(List.of(), jobService.toView(updated).get("benefits")); |
|||
} |
|||
|
|||
@Test |
|||
void updateWithNullSalaryMonthsKeepsOriginal() { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("月数保持")); |
|||
jobService.updateJob(companyId, job.getId(), new JobUpdateRequest()); |
|||
assertEquals(14, jobService.getJob(job.getId()).getSalaryMonths()); |
|||
} |
|||
|
|||
// ── salaryMonths 校验 1..24 ────────────────────────────────
|
|||
|
|||
@Test |
|||
void salaryMonthsBoundaries() { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("月数边界")); |
|||
|
|||
JobUpdateRequest one = new JobUpdateRequest(); |
|||
one.setSalaryMonths(1); |
|||
assertEquals(1, jobService.updateJob(companyId, job.getId(), one).getSalaryMonths()); |
|||
|
|||
JobUpdateRequest twentyFour = new JobUpdateRequest(); |
|||
twentyFour.setSalaryMonths(24); |
|||
assertEquals(24, jobService.updateJob(companyId, job.getId(), twentyFour).getSalaryMonths()); |
|||
|
|||
for (int invalid : new int[]{0, 25, -1}) { |
|||
JobUpdateRequest bad = new JobUpdateRequest(); |
|||
bad.setSalaryMonths(invalid); |
|||
assertBadRequest(() -> jobService.updateJob(companyId, job.getId(), bad)); |
|||
} |
|||
} |
|||
|
|||
// ── benefits 校验与去重保序 ────────────────────────────────
|
|||
|
|||
@Test |
|||
void benefitsValidationAndDeduplication() { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("福利校验")); |
|||
|
|||
List<String> tooMany = new ArrayList<>(); |
|||
for (int i = 0; i < 21; i++) tooMany.add("benefit-" + i); |
|||
assertBadRequest(() -> jobService.updateJob(companyId, job.getId(), updateWithBenefits(tooMany))); |
|||
|
|||
assertBadRequest(() -> jobService.updateJob(companyId, job.getId(), |
|||
updateWithBenefits(List.of("x".repeat(41))))); |
|||
|
|||
assertBadRequest(() -> jobService.updateJob(companyId, job.getId(), |
|||
updateWithBenefits(List.of("五险一金", " ")))); |
|||
|
|||
assertBadRequest(() -> jobService.updateJob(companyId, job.getId(), |
|||
updateWithBenefits(Arrays.asList("五险一金", null)))); |
|||
|
|||
Job updated = jobService.updateJob(companyId, job.getId(), |
|||
updateWithBenefits(List.of("A", "B", "A", "C", "B"))); |
|||
assertEquals("[\"A\",\"B\",\"C\"]", updated.getBenefitsJson()); |
|||
assertEquals(List.of("A", "B", "C"), jobService.toView(updated).get("benefits")); |
|||
} |
|||
|
|||
// ── 公开响应:新字段 + 企业摘要,且无敏感字段泄漏 ──────────
|
|||
|
|||
@Test |
|||
void publicListAndDetailExposeNewFieldsAndCompanySummary() throws Exception { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("公开字段岗位")); |
|||
job.setStatus("PUBLISHED"); |
|||
em.flush(); |
|||
em.clear(); |
|||
|
|||
String listBody = mvc.perform(get("/jobs").param("city", CITY).param("size", "50")) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
|||
JsonNode data = objectMapper.readTree(listBody).path("data"); |
|||
assertEquals(1, data.path("total").asLong()); |
|||
JsonNode item = data.path("items").get(0); |
|||
assertEquals(job.getId().longValue(), item.path("id").asLong()); |
|||
assertEquals("3-5年", item.path("experience").asText()); |
|||
assertEquals("本科", item.path("education").asText()); |
|||
assertEquals(14, item.path("salaryMonths").asInt()); |
|||
assertEquals("上海市浦东新区", item.path("workLocation").asText()); |
|||
assertTrue(item.path("benefits").isArray()); |
|||
assertEquals(2, item.path("benefits").size()); |
|||
assertEquals("五险一金", item.path("benefits").get(0).asText()); |
|||
|
|||
JsonNode company = item.path("company"); |
|||
assertNotNull(company); |
|||
assertEquals(companyId.longValue(), company.path("id").asLong()); |
|||
assertEquals("展示字段测试企业", company.path("name").asText()); |
|||
assertEquals("https://logo.example/display.png", company.path("logoUrl").asText()); |
|||
assertEquals("企业简介-展示", company.path("description").asText()); |
|||
assertEquals("互联网", company.path("industry").asText()); |
|||
assertEquals("100-499人", company.path("companySize").asText()); |
|||
assertTrue(company.path("verified").asBoolean()); |
|||
|
|||
String detailBody = mvc.perform(get("/jobs/public/" + job.getId())) |
|||
.andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
|||
JsonNode detail = objectMapper.readTree(detailBody).path("data"); |
|||
assertEquals(companyId.longValue(), detail.path("companyId").asLong()); |
|||
assertEquals("3-5年", detail.path("experience").asText()); |
|||
assertEquals("100-499人", detail.path("company").path("companySize").asText()); |
|||
assertEquals(List.of("五险一金", "弹性工作"), objectMapper.convertValue( |
|||
detail.path("benefits"), |
|||
objectMapper.getTypeFactory().constructCollectionType(List.class, String.class))); |
|||
} |
|||
|
|||
@Test |
|||
void publicResponsesDoNotLeakSensitiveFields() throws Exception { |
|||
Job job = jobService.createJob(companyId, fullCreateRequest("隐私体检岗位")); |
|||
job.setStatus("PUBLISHED"); |
|||
em.flush(); |
|||
em.clear(); |
|||
|
|||
String listBody = mvc.perform(get("/jobs").param("city", CITY)).andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
|||
String detailBody = mvc.perform(get("/jobs/public/" + job.getId())).andExpect(status().isOk()) |
|||
.andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
|||
for (String body : new String[]{listBody, detailBody}) { |
|||
assertTrue(!body.contains("ownerUserId"), "公开响应不得包含 ownerUserId"); |
|||
assertTrue(!body.contains(ownerPhone), "公开响应不得包含企业主手机号"); |
|||
assertTrue(!body.contains("企业登记地址不应外泄"), "公开响应不得包含企业登记地址"); |
|||
assertTrue(!body.contains("password"), "公开响应不得包含密码字段"); |
|||
} |
|||
} |
|||
|
|||
// ── 草稿岗位公开不可见 ────────────────────────────────────
|
|||
|
|||
@Test |
|||
void draftJobIsNotVisibleOnPublicDetail() throws Exception { |
|||
Job draft = jobService.createJob(companyId, fullCreateRequest("草稿岗位")); |
|||
em.flush(); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> jobService.getPublicJob(draft.getId())); |
|||
assertEquals(ErrorCode.JOB_NOT_FOUND.getCode(), ex.getErrorCodeInt()); |
|||
|
|||
mvc.perform(get("/jobs/public/" + draft.getId())).andExpect(status().isNotFound()); |
|||
} |
|||
|
|||
// ── Helper ────────────────────────────────────────────────
|
|||
|
|||
private JobCreateRequest fullCreateRequest(String title) { |
|||
JobCreateRequest req = new JobCreateRequest(); |
|||
req.setTitle(title); |
|||
req.setDescription("岗位描述"); |
|||
req.setRequirements("岗位要求"); |
|||
req.setCity(CITY); |
|||
req.setEmploymentType("FULL_TIME"); |
|||
req.setSalaryMin(20000); |
|||
req.setSalaryMax(40000); |
|||
req.setExperience("3-5年"); |
|||
req.setEducation("本科"); |
|||
req.setBenefits(List.of("五险一金", "弹性工作")); |
|||
req.setSalaryMonths(14); |
|||
req.setWorkLocation("上海市浦东新区"); |
|||
return req; |
|||
} |
|||
|
|||
private JobUpdateRequest updateWithBenefits(List<String> benefits) { |
|||
JobUpdateRequest req = new JobUpdateRequest(); |
|||
req.setBenefits(benefits); |
|||
return req; |
|||
} |
|||
|
|||
private void assertNullFieldsOn(Job job) { |
|||
assertEquals(null, job.getExperience()); |
|||
assertEquals(null, job.getEducation()); |
|||
assertEquals(null, job.getBenefitsJson()); |
|||
assertEquals(null, job.getSalaryMonths()); |
|||
assertEquals(null, job.getWorkLocation()); |
|||
} |
|||
|
|||
private void assertBadRequest(Executable callable) { |
|||
BusinessException ex = assertThrows(BusinessException.class, callable); |
|||
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); |
|||
} |
|||
} |
|||
@ -0,0 +1,135 @@ |
|||
package com.mydreamjob.job; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.company.Company; |
|||
import jakarta.persistence.EntityManager; |
|||
import jakarta.persistence.EntityManagerFactory; |
|||
import org.hibernate.SessionFactory; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
|
|||
/** |
|||
* 引入 companySize 等企业摘要/岗位展示新字段后,GET /jobs 的查询预算仍为常数级: |
|||
* 企业信息一次性批量查询,SQL 条数不随页大小(1 / 10 / 50)增长;空页不查询企业。 |
|||
* |
|||
* <p>与既有的 JobListCompanySummaryQueryBudgetTest 互补——本类在**每个岗位都带满新字段**的前提下 |
|||
* 复验查询条数不因新字段而增加。</p> |
|||
*/ |
|||
@SpringBootTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true") |
|||
@ActiveProfiles("integration-test") |
|||
@Transactional |
|||
class JobDisplayFieldsQueryBudgetIntegrationTest { |
|||
|
|||
private static final String CITY = "DISPLAYQBUDGETCITY"; |
|||
private static final String EMPTY_CITY = "DISPLAYQBUDGET_EMPTY"; |
|||
private static final LocalDateTime BASE = LocalDateTime.of(2026, 4, 1, 0, 0); |
|||
|
|||
@Autowired EntityManager em; |
|||
@Autowired EntityManagerFactory emf; |
|||
@Autowired JobService jobService; |
|||
@Autowired JdbcTemplate jdbc; |
|||
@MockBean RedisTemplate<String, Object> redis; |
|||
|
|||
private final List<Long> jobIds = new ArrayList<>(); |
|||
|
|||
@BeforeEach |
|||
void fixture() { |
|||
List<Long> companyIds = new ArrayList<>(); |
|||
for (int c = 0; c < 3; c++) { |
|||
User owner = User.builder() |
|||
.phone("189" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L) + c)) |
|||
.nickname("display-qbudget-" + c) |
|||
.role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)) |
|||
.active(true).verified(true).build(); |
|||
em.persist(owner); |
|||
Company company = Company.builder() |
|||
.ownerUserId(owner.getId()) |
|||
.name("展示预算企业" + c) |
|||
.description("简介" + c) |
|||
.industry("互联网") |
|||
.companySize("50-99人") |
|||
.verified(true) |
|||
.build(); |
|||
em.persist(company); |
|||
companyIds.add(company.getId()); |
|||
} |
|||
em.flush(); |
|||
|
|||
for (int i = 0; i < 55; i++) { |
|||
Job job = Job.builder() |
|||
.companyId(companyIds.get(i % companyIds.size())) |
|||
.title("展示预算岗位" + i) |
|||
.description("描述") |
|||
.city(CITY) |
|||
.employmentType("FULL_TIME") |
|||
.salaryMin(10000).salaryMax(20000) |
|||
.experience("3-5年").education("本科") |
|||
.benefitsJson("[\"五险一金\"]") |
|||
.salaryMonths(14) |
|||
.workLocation("上海市") |
|||
.status(JobStatus.PUBLISHED.name()) |
|||
.build(); |
|||
em.persist(job); |
|||
em.flush(); |
|||
jobIds.add(job.getId()); |
|||
} |
|||
for (int i = 0; i < jobIds.size(); i++) { |
|||
jdbc.update("UPDATE jobs SET created_at = ? WHERE id = ?", BASE.plusMinutes(i), jobIds.get(i)); |
|||
} |
|||
em.flush(); |
|||
em.clear(); |
|||
} |
|||
|
|||
@Test |
|||
void queryCountIsConstantRegardlessOfPageSizeWithDisplayFields() { |
|||
var stats = emf.unwrap(SessionFactory.class).getStatistics(); |
|||
|
|||
for (int size : new int[]{1, 10, 50}) { |
|||
em.clear(); |
|||
stats.clear(); |
|||
Page<Map<String, Object>> page = |
|||
jobService.listPublishedJobs(null, CITY, null, null, null, 0, size); |
|||
assertEquals(3, stats.getPrepareStatementCount(), |
|||
"当前页 + count + 1 次批量企业查询,共 3 条 SQL;size=" + size); |
|||
assertEquals(55, page.getTotalElements()); |
|||
assertEquals(size, page.getNumberOfElements()); |
|||
// 企业摘要含 companySize,且不因新字段追加额外查询
|
|||
Map<String, Object> first = page.getContent().get(0); |
|||
com.mydreamjob.job.dto.JobSearchItem.CompanyInfo company = |
|||
(com.mydreamjob.job.dto.JobSearchItem.CompanyInfo) first.get("company"); |
|||
assertEquals("50-99人", company.getCompanySize()); |
|||
assertEquals("3-5年", first.get("experience")); |
|||
assertEquals(14, first.get("salaryMonths")); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void emptyPageSkipsCompanyQuery() { |
|||
var stats = emf.unwrap(SessionFactory.class).getStatistics(); |
|||
em.clear(); |
|||
stats.clear(); |
|||
Page<Map<String, Object>> page = |
|||
jobService.listPublishedJobs(null, EMPTY_CITY, null, null, null, 0, 10); |
|||
assertTrue(page.isEmpty()); |
|||
assertEquals(0, page.getTotalElements()); |
|||
assertEquals(1, stats.getPrepareStatementCount(), "空页只发 1 条 SQL(企业查询被跳过,count 亦被优化)"); |
|||
assertTrue(stats.getPrepareStatementCount() < 3); |
|||
} |
|||
} |
|||
@ -0,0 +1,157 @@ |
|||
package com.mydreamjob.job; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.company.Company; |
|||
import jakarta.persistence.EntityManager; |
|||
import jakarta.persistence.EntityManagerFactory; |
|||
import org.hibernate.SessionFactory; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.domain.Page; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.transaction.annotation.Transactional; |
|||
|
|||
import java.time.LocalDateTime; |
|||
import java.util.ArrayList; |
|||
import java.util.Collections; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
|
|||
/** |
|||
* GET /jobs(listPublishedJobs)企业摘要的查询预算:企业信息一次性批量查询,SQL 条数不随页大小增长, |
|||
* 空页时不发起企业查询。 |
|||
*/ |
|||
@SpringBootTest(properties = "spring.jpa.properties.hibernate.generate_statistics=true") |
|||
@ActiveProfiles("integration-test") |
|||
@Transactional |
|||
class JobListCompanySummaryQueryBudgetTest { |
|||
|
|||
private static final String CITY = "QBUDGETCITY"; |
|||
private static final String EMPTY_CITY = "QBUDGET_EMPTY_CITY"; |
|||
private static final LocalDateTime BASE = LocalDateTime.of(2026, 3, 1, 0, 0); |
|||
|
|||
@Autowired EntityManager em; |
|||
@Autowired EntityManagerFactory emf; |
|||
@Autowired JobService jobService; |
|||
@Autowired JdbcTemplate jdbc; |
|||
@MockBean RedisTemplate<String, Object> redis; |
|||
|
|||
private final List<Long> jobIds = new ArrayList<>(); |
|||
|
|||
@BeforeEach |
|||
void fixture() { |
|||
List<Long> companyIds = new ArrayList<>(); |
|||
for (int c = 0; c < 3; c++) { |
|||
User owner = User.builder() |
|||
.phone("187" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L) + c)) |
|||
.nickname("qbudget-owner-" + c) |
|||
.role(User.Role.COMPANY).roles(Set.of(User.Role.COMPANY)) |
|||
.active(true).verified(true).build(); |
|||
em.persist(owner); |
|||
Company company = Company.builder() |
|||
.ownerUserId(owner.getId()) |
|||
.name("预算企业" + c) |
|||
.description("简介" + c) |
|||
.industry("互联网") |
|||
.companySize("50-99人") |
|||
.verified(true) |
|||
.build(); |
|||
em.persist(company); |
|||
companyIds.add(company.getId()); |
|||
} |
|||
em.flush(); |
|||
|
|||
for (int i = 0; i < 55; i++) { |
|||
Job job = Job.builder() |
|||
.companyId(companyIds.get(i % companyIds.size())) |
|||
.title("预算岗位" + i) |
|||
.description("描述") |
|||
.city(CITY) |
|||
.employmentType("FULL_TIME") |
|||
.salaryMin(10000).salaryMax(20000) |
|||
.status(JobStatus.PUBLISHED.name()) |
|||
.build(); |
|||
em.persist(job); |
|||
em.flush(); |
|||
jobIds.add(job.getId()); |
|||
} |
|||
// created_at 互异,使 createdAt DESC 顺序确定可断言
|
|||
for (int i = 0; i < jobIds.size(); i++) { |
|||
jdbc.update("UPDATE jobs SET created_at = ? WHERE id = ?", BASE.plusMinutes(i), jobIds.get(i)); |
|||
} |
|||
em.flush(); |
|||
em.clear(); |
|||
} |
|||
|
|||
@Test |
|||
void companySummaryQueryCountIsFixedRegardlessOfPageSize() { |
|||
var stats = emf.unwrap(SessionFactory.class).getStatistics(); |
|||
List<Long> expectedDesc = new ArrayList<>(jobIds); |
|||
Collections.reverse(expectedDesc); |
|||
|
|||
for (int size : new int[]{1, 10, 50}) { |
|||
em.clear(); |
|||
stats.clear(); |
|||
Page<Map<String, Object>> page = |
|||
jobService.listPublishedJobs(null, CITY, null, null, null, 0, size); |
|||
long statements = stats.getPrepareStatementCount(); |
|||
assertEquals(3, statements, |
|||
"当前页 + count + 1 次批量企业查询,共 3 条 SQL;size=" + size); |
|||
assertEquals(55, page.getTotalElements(), "total 不变"); |
|||
assertEquals(size, page.getNumberOfElements(), "本页行数等于 size"); |
|||
assertEquals(0, page.getNumber()); |
|||
assertEquals(expectedDesc.subList(0, size), idsOf(page), "createdAt DESC 排序不变"); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void emptyPageSkipsCompanyQuery() { |
|||
var stats = emf.unwrap(SessionFactory.class).getStatistics(); |
|||
em.clear(); |
|||
stats.clear(); |
|||
Page<Map<String, Object>> page = |
|||
jobService.listPublishedJobs(null, EMPTY_CITY, null, null, null, 0, 10); |
|||
assertTrue(page.isEmpty()); |
|||
assertEquals(0, page.getTotalElements()); |
|||
// 实测:空页只发出 1 条 SQL(当前页查询)。比非空页(3 条)少 2 条 —— 除企业批量查询被跳过后,
|
|||
// Spring Data 的 PageableExecutionUtils 在 offset==0 且结果为空时还会省掉 count 查询
|
|||
// (总数为 list.size()=0),因此不是 spec 预估的 2 条。企业查询确实未发起(1 < 2)。
|
|||
assertEquals(1, stats.getPrepareStatementCount(), "空页只发 1 条 SQL(count 亦被优化掉)"); |
|||
assertTrue(stats.getPrepareStatementCount() < 3, "空页必然少于含企业批量查询的非空页"); |
|||
} |
|||
|
|||
@Test |
|||
void filtersAndPaginationArePreserved() { |
|||
assertEquals(55, jobService.listPublishedJobs(null, CITY, null, null, null, 0, 10) |
|||
.getTotalElements()); |
|||
assertEquals(55, jobService.listPublishedJobs("预算岗位", CITY, null, null, null, 0, 10) |
|||
.getTotalElements()); |
|||
assertEquals(0, jobService.listPublishedJobs(null, CITY, "PART_TIME", null, null, 0, 10) |
|||
.getTotalElements(), "非匹配用工类型结果为空"); |
|||
assertEquals(0, jobService.listPublishedJobs(null, EMPTY_CITY, null, null, null, 0, 10) |
|||
.getTotalElements(), "非匹配城市结果为空"); |
|||
// 末页:55 = 5 页满 + 第 6 页 5 条
|
|||
Page<Map<String, Object>> last = jobService.listPublishedJobs(null, CITY, null, null, null, 5, 10); |
|||
assertEquals(55, last.getTotalElements()); |
|||
assertEquals(6, last.getTotalPages()); |
|||
assertEquals(5, last.getNumber()); |
|||
assertEquals(5, last.getNumberOfElements()); |
|||
} |
|||
|
|||
private List<Long> idsOf(Page<Map<String, Object>> page) { |
|||
List<Long> ids = new ArrayList<>(); |
|||
for (Map<String, Object> item : page.getContent()) { |
|||
ids.add(((Number) item.get("id")).longValue()); |
|||
} |
|||
return ids; |
|||
} |
|||
} |
|||
@ -0,0 +1,330 @@ |
|||
package com.mydreamjob.job; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.audit.AuditContext; |
|||
import com.mydreamjob.auth.AuthSessionService; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.company.Company; |
|||
import com.mydreamjob.company.CompanyRepository; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.TestPropertySource; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
|
|||
import java.time.Instant; |
|||
import java.util.HashSet; |
|||
import java.util.List; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertNull; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* salaryMonths 通过**真实 Controller 的 HTTP 请求绑定**(MockMvc 全栈,非手工构造 Java DTO)验证: |
|||
* 仅接受 JSON 整数 1—24,拒绝小数/字符串/布尔/数组/对象/超 int 范围;失败的写请求不产生任何部分更新。 |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
@TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:salary_months_http;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
|||
class JobSalaryMonthsHttpBindingTest { |
|||
|
|||
private static final AtomicInteger SEQ = new AtomicInteger(); |
|||
private static final String PHONE_PREFIX = "1990077"; |
|||
|
|||
@MockBean private RedisTemplate<String, Object> redis; |
|||
@Autowired private MockMvc mvc; |
|||
@Autowired private JdbcTemplate jdbc; |
|||
@Autowired private UserRepository users; |
|||
@Autowired private CompanyRepository companies; |
|||
@Autowired private AuthSessionService sessions; |
|||
@Autowired private ObjectMapper mapper; |
|||
|
|||
private long userId; |
|||
private long companyId; |
|||
private String token; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
User owner = users.saveAndFlush(User.builder() |
|||
.phone(PHONE_PREFIX + String.format("%04d", SEQ.incrementAndGet())) |
|||
.role(User.Role.COMPANY).roles(new HashSet<>(Set.of(User.Role.COMPANY))) |
|||
.active(true).verified(true).build()); |
|||
userId = owner.getId(); |
|||
token = sessions.createSession(owner, Instant.now(), new AuditContext()).accessToken(); |
|||
companyId = companies.saveAndFlush(Company.builder() |
|||
.ownerUserId(userId).name("月数绑定企业").verified(true).build()).getId(); |
|||
} |
|||
|
|||
@AfterEach |
|||
void tearDown() { |
|||
jdbc.update("delete from jobs where company_id = ?", companyId); |
|||
jdbc.update("delete from companies where id = ?", companyId); |
|||
jdbc.update("delete from auth_tokens where user_id = ?", userId); |
|||
jdbc.update("delete from user_roles where user_id = ?", userId); |
|||
jdbc.update("delete from users where id = ?", userId); |
|||
} |
|||
|
|||
// ── 合法整数 ────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void duplicateBenefitsOverRawTwentyAreAcceptedAndNormalized() throws Exception { |
|||
var payload = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(createBody("福利去重", "12")); |
|||
var benefits = payload.putArray("benefits"); |
|||
for (int i = 0; i < 21; i++) benefits.add(" A "); |
|||
String body = mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(payload))) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits.length()").value(1)) |
|||
.andExpect(jsonPath("$.data.benefits[0]").value("A")) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
long id = mapper.readTree(body).path("data").path("id").asLong(); |
|||
assertEquals("[\"A\"]", jdbc.queryForObject("SELECT benefits_json FROM jobs WHERE id=?", String.class, id)); |
|||
} |
|||
|
|||
@Test |
|||
void benefitsItemLengthAppliesAfterTrimOnCreateAndUpdate() throws Exception { |
|||
String padded = " ".repeat(25) + "A".repeat(40) + " ".repeat(25); |
|||
var payload = (com.fasterxml.jackson.databind.node.ObjectNode) mapper.readTree(createBody("福利trim", "12")); |
|||
payload.putArray("benefits").add(padded); |
|||
String body = mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(payload))) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits[0]").value("A".repeat(40))) |
|||
.andReturn().getResponse().getContentAsString(); |
|||
long id = mapper.readTree(body).path("data").path("id").asLong(); |
|||
var update = mapper.createObjectNode(); |
|||
var benefits = update.putArray("benefits"); |
|||
for (int i = 0; i < 21; i++) benefits.add(padded); |
|||
mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits.length()").value(1)); |
|||
update.putArray("benefits").add("A".repeat(41)); |
|||
mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) |
|||
.andExpect(status().isBadRequest()); |
|||
assertEquals("[\"" + "A".repeat(40) + "\"]", |
|||
jdbc.queryForObject("SELECT benefits_json FROM jobs WHERE id=?", String.class, id)); |
|||
} |
|||
|
|||
@Test |
|||
void benefitsTwentyDistinctAcceptedTwentyOneRejectedWithoutPartialUpdate() throws Exception { |
|||
long id = createJob("福利数量", "12"); |
|||
var update = mapper.createObjectNode(); |
|||
var benefits = update.putArray("benefits"); |
|||
for (int i = 0; i < 20; i++) benefits.add("B" + i); |
|||
mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits.length()").value(20)); |
|||
String before = jdbc.queryForObject("SELECT benefits_json FROM jobs WHERE id=?", String.class, id); |
|||
benefits.add("B20"); |
|||
update.put("title", "不得写入"); |
|||
mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) |
|||
.andExpect(status().isBadRequest()); |
|||
assertEquals(before, jdbc.queryForObject("SELECT benefits_json FROM jobs WHERE id=?", String.class, id)); |
|||
assertEquals("福利数量", jdbc.queryForObject("SELECT title FROM jobs WHERE id=?", String.class, id)); |
|||
update.putNull("benefits"); |
|||
update.remove("title"); |
|||
mvc.perform(put("/jobs/" + id).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(mapper.writeValueAsString(update))) |
|||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.benefits.length()").value(20)); |
|||
} |
|||
|
|||
@Test |
|||
void integersOneAndTwentyFourAreAcceptedOnCreate() throws Exception { |
|||
long job1 = createJob("整数1", "1"); |
|||
assertEquals(1, salaryMonthsInDb(job1)); |
|||
assertEquals(1, responseSalaryMonths(job1)); |
|||
|
|||
long job24 = createJob("整数24", "24"); |
|||
assertEquals(24, salaryMonthsInDb(job24)); |
|||
assertEquals(24, responseSalaryMonths(job24)); |
|||
} |
|||
|
|||
// ── 越界整数 ────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void outOfRangeIntegersAreRejectedWithoutPartialWrite() throws Exception { |
|||
long jobsBefore = jobCount(); |
|||
for (String raw : new String[]{"0", "-1", "25"}) { |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(createBody("越界", raw))) |
|||
.andExpect(status().isBadRequest()); |
|||
} |
|||
assertEquals(jobsBefore, jobCount(), "越界写请求不得新增任何岗位记录"); |
|||
} |
|||
|
|||
// ── 小数 ────────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void fractionalNumbersAreRejected() throws Exception { |
|||
long jobsBefore = jobCount(); |
|||
for (String raw : new String[]{"1.9", "24.9", "1.0"}) { |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(createBody("小数", raw))) |
|||
.andExpect(status().isBadRequest()); |
|||
} |
|||
assertEquals(jobsBefore, jobCount(), "小数写请求不得新增任何岗位记录"); |
|||
} |
|||
|
|||
// ── 错误类型 ────────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void wrongJsonTypesAreRejected() throws Exception { |
|||
long jobsBefore = jobCount(); |
|||
for (String raw : new String[]{"\"12\"", "\"\"", "true", "[]", "{}"}) { |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(createBody("错类型", raw))) |
|||
.andExpect(status().isBadRequest()); |
|||
} |
|||
assertEquals(jobsBefore, jobCount(), "类型错误的写请求不得新增任何岗位记录"); |
|||
} |
|||
|
|||
@Test |
|||
void integerOutOfIntRangeIsRejected() throws Exception { |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(createBody("超范围", "99999999999"))) |
|||
.andExpect(status().isBadRequest()); |
|||
} |
|||
|
|||
// ── 缺省 / null:创建存 NULL,更新保持原值 ──────────────────
|
|||
|
|||
@Test |
|||
void missingAndNullOnCreateStoreNull() throws Exception { |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(createBodyNoSalary("缺省"))) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.salaryMonths").doesNotExist()); |
|||
assertNull(firstJobSalaryMonths()); |
|||
|
|||
long job = createJob("显式null", "null"); |
|||
assertNull(salaryMonthsInDb(job)); |
|||
} |
|||
|
|||
@Test |
|||
void nullOnUpdateKeepsOriginal() throws Exception { |
|||
long jobId = createJob("更新保持", "14"); |
|||
assertEquals(14, salaryMonthsInDb(jobId)); |
|||
|
|||
// 缺省
|
|||
mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"更新保持-缺省\"}")) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.salaryMonths").value(14)); |
|||
assertEquals(14, salaryMonthsInDb(jobId)); |
|||
|
|||
// 显式 null
|
|||
mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content("{\"title\":\"更新保持-null\",\"salaryMonths\":null}")) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.salaryMonths").value(14)); |
|||
assertEquals(14, salaryMonthsInDb(jobId)); |
|||
} |
|||
|
|||
// ── 失败写请求:无部分更新 ──────────────────────────────────
|
|||
|
|||
@Test |
|||
void failedCreateWithValidTitleDoesNotPersistAnything() throws Exception { |
|||
mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"title\":\"SHOULD_NOT_PERSIST\",\"description\":\"d\",\"city\":\"c\"," |
|||
+ "\"employmentType\":\"FULL_TIME\",\"salaryMonths\":25}")) |
|||
.andExpect(status().isBadRequest()); |
|||
Integer count = jdbc.queryForObject( |
|||
"select count(*) from jobs where title = ?", Integer.class, "SHOULD_NOT_PERSIST"); |
|||
assertEquals(0, count, "失败的创建请求不得留下标题已写入的记录"); |
|||
} |
|||
|
|||
@Test |
|||
void failedUpdateDoesNotPartiallyChangeValidFields() throws Exception { |
|||
long jobId = createJob("原标题", "14"); |
|||
|
|||
mvc.perform(put("/jobs/" + jobId).header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"title\":\"PARTIAL_TITLE\",\"salaryMonths\":25}")) |
|||
.andExpect(status().isBadRequest()); |
|||
|
|||
mvc.perform(get("/jobs/" + jobId).header("Authorization", bearer())) |
|||
.andExpect(status().isOk()) |
|||
.andExpect(jsonPath("$.data.title").value("原标题")) |
|||
.andExpect(jsonPath("$.data.salaryMonths").value(14)); |
|||
assertEquals("原标题", jdbc.queryForObject( |
|||
"select title from jobs where id = ?", String.class, jobId)); |
|||
assertEquals(14, salaryMonthsInDb(jobId)); |
|||
} |
|||
|
|||
// ── 探针等价断言:DTO 级严格整数(正式测试) ─────────────────
|
|||
|
|||
@Test |
|||
void objectMapperRejectsFractionalSalaryMonthsAtDtoLevel() { |
|||
assertThrows(com.fasterxml.jackson.core.JsonProcessingException.class, |
|||
() -> mapper.readValue("{\"salaryMonths\":1.9}", com.mydreamjob.job.dto.JobCreateRequest.class)); |
|||
assertThrows(com.fasterxml.jackson.core.JsonProcessingException.class, |
|||
() -> mapper.readValue("{\"salaryMonths\":1.0}", com.mydreamjob.job.dto.JobUpdateRequest.class)); |
|||
} |
|||
|
|||
// ── Helper ──────────────────────────────────────────────────
|
|||
|
|||
private long createJob(String title, String rawSalaryMonthsJsonOrNull) throws Exception { |
|||
String body = rawSalaryMonthsJsonOrNull == null |
|||
? createBodyNoSalary(title) |
|||
: createBody(title, rawSalaryMonthsJsonOrNull); |
|||
String response = mvc.perform(post("/jobs").header("Authorization", bearer()) |
|||
.contentType(MediaType.APPLICATION_JSON).content(body)) |
|||
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); |
|||
return mapper.readTree(response).path("data").path("id").asLong(); |
|||
} |
|||
|
|||
private String createBody(String title, String rawSalaryMonthsJson) { |
|||
return "{\"title\":\"" + title + "\",\"description\":\"d\",\"city\":\"c\"," |
|||
+ "\"employmentType\":\"FULL_TIME\",\"salaryMonths\":" + rawSalaryMonthsJson + "}"; |
|||
} |
|||
|
|||
private String createBodyNoSalary(String title) { |
|||
return "{\"title\":\"" + title + "\",\"description\":\"d\",\"city\":\"c\"," |
|||
+ "\"employmentType\":\"FULL_TIME\"}"; |
|||
} |
|||
|
|||
private int responseSalaryMonths(long jobId) throws Exception { |
|||
String response = mvc.perform(get("/jobs/" + jobId).header("Authorization", bearer())) |
|||
.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(); |
|||
JsonNode node = mapper.readTree(response).path("data").path("salaryMonths"); |
|||
return node.isMissingNode() || node.isNull() ? -1 : node.asInt(); |
|||
} |
|||
|
|||
private Integer salaryMonthsInDb(long jobId) { |
|||
return jdbc.queryForObject("select salary_months from jobs where id = ?", Integer.class, jobId); |
|||
} |
|||
|
|||
private Integer firstJobSalaryMonths() { |
|||
List<Integer> values = jdbc.queryForList( |
|||
"select salary_months from jobs where company_id = ?", Integer.class, companyId); |
|||
assertEquals(1, values.size()); |
|||
return values.get(0); |
|||
} |
|||
|
|||
private long jobCount() { |
|||
return jdbc.queryForObject("select count(*) from jobs where company_id = ?", Long.class, companyId); |
|||
} |
|||
|
|||
private String bearer() { |
|||
return "Bearer " + token; |
|||
} |
|||
} |
|||
@ -0,0 +1,77 @@ |
|||
package com.mydreamjob.messaging; |
|||
|
|||
import com.mydreamjob.application.CompanyResumeService; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
import static org.mockito.Mockito.*; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
|||
|
|||
/** 历史污染资料不能绕过本人下载校验,经投递下载或聊天复制泄露他人文件。 */ |
|||
class HistoricalResumeOwnershipIntegrationTest extends ChatWorkflowIntegrationTest { |
|||
@Autowired CompanyResumeService companyResumes; |
|||
|
|||
private void assertBlockedSource(String key) { |
|||
profile.setResumeUrl(key); |
|||
em.flush(); |
|||
clearInvocations(storage); |
|||
long messageCount = messages.count(); |
|||
long snapshotCount = snapshots.count(); |
|||
assertEquals(2002, assertThrows(BusinessException.class, |
|||
() -> companyResumes.load(employer.getId(), application.getId())).getErrorCodeInt()); |
|||
assertEquals(2002, assertThrows(BusinessException.class, |
|||
() -> resumes.send(conversation.getId(), student.getId(), |
|||
new ChatResumeService.SendRequest(application.getId(), "polluted-source"))).getErrorCodeInt()); |
|||
verifyNoInteractions(storage); |
|||
assertEquals(messageCount, messages.count()); |
|||
assertEquals(snapshotCount, snapshots.count()); |
|||
assertEquals(key, profile.getResumeUrl()); |
|||
} |
|||
|
|||
@Test void foreignSourceIsDeniedBeforeReadingOrCopying() { |
|||
assertBlockedSource(stranger.getId() + "_deadbeef.pdf"); |
|||
} |
|||
|
|||
@Test void numericPrefixMustNotMatchCandidateId() { |
|||
assertBlockedSource(student.getId() + "0_deadbeef.pdf"); |
|||
} |
|||
|
|||
@Test void unknownLegacySourceFailsClosed() { |
|||
assertBlockedSource("legacy-resume.pdf"); |
|||
} |
|||
|
|||
@Test void pathLikeSourceFailsClosed() { |
|||
assertBlockedSource("../" + student.getId() + "_deadbeef.pdf"); |
|||
} |
|||
|
|||
@Test void chatHttpRejectsForeignSourceWithoutCreatingMessage() throws Exception { |
|||
profile.setResumeUrl(stranger.getId() + "_deadbeef.pdf"); |
|||
em.flush(); |
|||
var token = sessions.createSession(student, clock.instant(), |
|||
com.mydreamjob.audit.AuditContext.builder().build()).accessToken(); |
|||
clearInvocations(storage); |
|||
long count = messages.count(); |
|||
mvc.perform(post("/conversations/" + conversation.getId() + "/resume-messages") |
|||
.header("Authorization", "Bearer " + token) |
|||
.contentType("application/json") |
|||
.content("{\"applicationId\":" + application.getId() |
|||
+ ",\"clientMessageId\":\"foreign-http\"}")) |
|||
.andExpect(status().isForbidden()).andExpect(jsonPath("$.code").value(2002)); |
|||
verifyNoInteractions(storage); |
|||
assertEquals(count, messages.count()); |
|||
assertEquals(0, snapshots.count()); |
|||
} |
|||
|
|||
@Test void authorizedEmployerStillReadsCandidatesOwnSourceAndSnapshot() { |
|||
assertNotEquals(student.getId(), employer.getId()); |
|||
assertArrayEquals(new byte[]{1, 2, 3}, |
|||
companyResumes.load(employer.getId(), application.getId()).content()); |
|||
var sent = resumes.send(conversation.getId(), student.getId(), |
|||
new ChatResumeService.SendRequest(application.getId(), "valid-source")); |
|||
assertArrayEquals(new byte[]{1, 2, 3}, |
|||
resumes.download(conversation.getId(), sent.getId(), employer.getId()).bytes()); |
|||
} |
|||
} |
|||
@ -0,0 +1,558 @@ |
|||
package com.mydreamjob.profile; |
|||
|
|||
import com.fasterxml.jackson.databind.JsonNode; |
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.auth.support.TestAuthTokens; |
|||
import com.mydreamjob.profile.storage.LocalResumeStorageService; |
|||
import com.mydreamjob.profile.storage.ResumeStorageService; |
|||
import org.junit.jupiter.api.AfterEach; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.springframework.beans.factory.annotation.Autowired; |
|||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; |
|||
import org.springframework.boot.test.context.SpringBootTest; |
|||
import org.springframework.boot.test.context.TestConfiguration; |
|||
import org.springframework.boot.test.mock.mockito.MockBean; |
|||
import org.springframework.context.annotation.Bean; |
|||
import org.springframework.context.annotation.Primary; |
|||
import org.springframework.data.redis.core.RedisTemplate; |
|||
import org.springframework.data.redis.core.ValueOperations; |
|||
import org.springframework.http.MediaType; |
|||
import org.springframework.jdbc.core.JdbcTemplate; |
|||
import org.springframework.mock.web.MockMultipartFile; |
|||
import org.springframework.test.context.ActiveProfiles; |
|||
import org.springframework.test.context.DynamicPropertyRegistry; |
|||
import org.springframework.test.context.DynamicPropertySource; |
|||
import org.springframework.test.web.servlet.MockMvc; |
|||
import org.springframework.test.web.servlet.MvcResult; |
|||
import org.springframework.web.multipart.MultipartFile; |
|||
|
|||
import java.io.IOException; |
|||
import java.nio.charset.StandardCharsets; |
|||
import java.nio.file.Files; |
|||
import java.nio.file.Path; |
|||
import java.security.MessageDigest; |
|||
import java.util.ArrayList; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.List; |
|||
import java.util.Map; |
|||
import java.util.Set; |
|||
import java.util.concurrent.atomic.AtomicInteger; |
|||
import java.util.concurrent.atomic.AtomicLong; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertArrayEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertFalse; |
|||
import static org.junit.jupiter.api.Assertions.assertNotNull; |
|||
import static org.junit.jupiter.api.Assertions.assertNull; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.Mockito.mock; |
|||
import static org.mockito.Mockito.when; |
|||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.anonymous; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; |
|||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; |
|||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; |
|||
|
|||
/** |
|||
* 简历文件归属漏洞的**HTTP 集成**回归测试。 |
|||
* |
|||
* <p>真实 SecurityFilterChain + 真实 Controller 参数绑定 + 真实 H2 + 真实本地文件存储 |
|||
* (存储目录为 JDK 临时目录,文件为真实合成 PDF)。仅用计数包装器包住存储实现, |
|||
* 以证明“拒绝路径零文件读取 / 零文件删除”,授权链本身完全真实。</p> |
|||
*/ |
|||
@SpringBootTest |
|||
@AutoConfigureMockMvc |
|||
@ActiveProfiles("integration-test") |
|||
class ResumeOwnershipHttpIntegrationTest { |
|||
|
|||
/** 真实临时存储目录:LocalResumeStorageService 会在启动时创建。 */ |
|||
private static final Path STORAGE_DIR; |
|||
|
|||
static { |
|||
try { |
|||
STORAGE_DIR = Files.createTempDirectory("resume-ownership-it-"); |
|||
} catch (IOException e) { |
|||
throw new ExceptionInInitializerError(e); |
|||
} |
|||
} |
|||
|
|||
static final AtomicInteger READ_CALLS = new AtomicInteger(); |
|||
static final AtomicInteger DELETE_CALLS = new AtomicInteger(); |
|||
|
|||
@TestConfiguration |
|||
static class CountingStorageConfig { |
|||
@Bean |
|||
@Primary |
|||
ResumeStorageService countingResumeStorageService(LocalResumeStorageService delegate) { |
|||
return new ResumeStorageService() { |
|||
@Override |
|||
public String store(Long userId, MultipartFile file) { |
|||
return delegate.store(userId, file); |
|||
} |
|||
|
|||
@Override |
|||
public byte[] read(String storagePath) { |
|||
READ_CALLS.incrementAndGet(); |
|||
return delegate.read(storagePath); |
|||
} |
|||
|
|||
@Override |
|||
public void delete(String storagePath) { |
|||
DELETE_CALLS.incrementAndGet(); |
|||
delegate.delete(storagePath); |
|||
} |
|||
}; |
|||
} |
|||
} |
|||
|
|||
@DynamicPropertySource |
|||
static void storageProperties(DynamicPropertyRegistry registry) { |
|||
registry.add("app.resume.storage-dir", STORAGE_DIR::toString); |
|||
} |
|||
|
|||
@MockBean |
|||
private RedisTemplate<String, Object> redisTemplate; |
|||
|
|||
@Autowired private MockMvc mockMvc; |
|||
@Autowired private UserRepository userRepository; |
|||
@Autowired private StudentProfileRepository profileRepository; |
|||
@Autowired private ResumeStorageService storageService; |
|||
@Autowired private JdbcTemplate jdbc; |
|||
|
|||
private final ObjectMapper mapper = new ObjectMapper(); |
|||
private final List<Long> userIds = new ArrayList<>(); |
|||
private final List<String> storedKeys = new ArrayList<>(); |
|||
/** |
|||
* 手机号夹具序号。H2 集成库在整个 JVM 内共享,必须使用未被其他测试类占用的号段, |
|||
* 否则 {@code users.phone} 唯一约束会跨测试类冲突。 |
|||
*/ |
|||
private static final AtomicLong PHONE = new AtomicLong(995_000_000L); |
|||
|
|||
@BeforeEach |
|||
@SuppressWarnings("unchecked") |
|||
void setUp() { |
|||
READ_CALLS.set(0); |
|||
DELETE_CALLS.set(0); |
|||
// 清理可能由其他测试类残留的认证主体,保证“无 Token”用例真正匿名。
|
|||
org.springframework.security.core.context.SecurityContextHolder.clearContext(); |
|||
ValueOperations<String, Object> ops = mock(ValueOperations.class); |
|||
when(redisTemplate.opsForValue()).thenReturn(ops); |
|||
when(redisTemplate.hasKey(anyString())).thenReturn(false); |
|||
} |
|||
|
|||
@AfterEach |
|||
void cleanup() { |
|||
// 先删资料(FK → users)与测试文件,再删用户与会话
|
|||
for (Long id : userIds) { |
|||
profileRepository.findByUserId(id).ifPresent(profileRepository::delete); |
|||
} |
|||
storedKeys.forEach(storageService::delete); |
|||
for (Long id : userIds) { |
|||
jdbc.update("DELETE FROM auth_tokens WHERE user_id = ?", id); |
|||
userRepository.findById(id).ifPresent(userRepository::delete); |
|||
} |
|||
userIds.clear(); |
|||
storedKeys.clear(); |
|||
} |
|||
|
|||
// ─────────────────────────── 夹具 ───────────────────────────
|
|||
|
|||
private long createUser(String role) { |
|||
User.Role r = User.Role.valueOf(role); |
|||
User user = userRepository.saveAndFlush(User.builder() |
|||
.phone(String.valueOf(PHONE.getAndIncrement())) |
|||
.nickname("ownership-" + role) |
|||
.role(r) |
|||
.roles(Set.of(r)) |
|||
.active(true) |
|||
.verified(true) |
|||
.build()); |
|||
userIds.add(user.getId()); |
|||
return user.getId(); |
|||
} |
|||
|
|||
private static byte[] pdf(String body) { |
|||
return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII); |
|||
} |
|||
|
|||
/** 通过真实 HTTP 上传接口上传简历,返回服务端生成的存储键。 */ |
|||
private String upload(long userId, String token, String filename, byte[] content) throws Exception { |
|||
MvcResult result = mockMvc.perform(multipart("/profile/me/resume") |
|||
.file(new MockMultipartFile("file", filename, "application/pdf", content)) |
|||
.header("Authorization", token)) |
|||
.andExpect(status().isOk()) |
|||
.andReturn(); |
|||
JsonNode data = mapper.readTree(result.getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data"); |
|||
String key = data.path("resumeUrl").asText(); |
|||
storedKeys.add(key); |
|||
return key; |
|||
} |
|||
|
|||
private void putProfile(String token, String json, int expectedStatus) throws Exception { |
|||
mockMvc.perform(put("/profile/me") |
|||
.header("Authorization", token) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content(json)) |
|||
.andExpect(status().is(expectedStatus)); |
|||
} |
|||
|
|||
private StudentProfile profileOf(long userId) { |
|||
return profileRepository.findByUserId(userId).orElse(null); |
|||
} |
|||
|
|||
/** 直接在隔离库写入一份“历史污染”资料,模拟资料引用被篡改为他人文件。 */ |
|||
private void pollute(long userId, String resumeUrl) { |
|||
StudentProfile existing = profileRepository.findByUserId(userId).orElse(null); |
|||
StudentProfile target = existing != null ? existing : StudentProfile.builder().userId(userId).build(); |
|||
target.setResumeUrl(resumeUrl); |
|||
target.setResumeOriginalName("polluted.pdf"); |
|||
target.setResumeContentType("application/pdf"); |
|||
target.setResumeSize(8L); |
|||
profileRepository.saveAndFlush(target); |
|||
} |
|||
|
|||
private long storedFileCount() throws IOException { |
|||
try (var stream = Files.list(STORAGE_DIR)) { |
|||
return stream.filter(Files::isRegularFile).count(); |
|||
} |
|||
} |
|||
|
|||
private byte[] onDisk(String key) throws IOException { |
|||
return Files.readAllBytes(STORAGE_DIR.resolve(key)); |
|||
} |
|||
|
|||
private static String sha256(byte[] bytes) throws Exception { |
|||
MessageDigest digest = MessageDigest.getInstance("SHA-256"); |
|||
byte[] hash = digest.digest(bytes); |
|||
StringBuilder sb = new StringBuilder(); |
|||
for (byte b : hash) { |
|||
sb.append(String.format("%02x", b)); |
|||
} |
|||
return sb.toString(); |
|||
} |
|||
|
|||
/** |
|||
* 逐表快照(归一化为字符串值)。 |
|||
* |
|||
* <p>H2 会把 {@code skills_json} 等列以 {@code byte[]} 返回,而数组的 equals 是引用比较, |
|||
* 因此必须归一化,否则快照对比会因同一行的不同数组实例而误报“有变化”。</p> |
|||
*/ |
|||
private List<Map<String, Object>> dbSnapshot() { |
|||
List<Map<String, Object>> tables = new ArrayList<>(); |
|||
for (String table : List.of("student_profiles", "messages", "chat_resume_snapshots")) { |
|||
List<Map<String, Object>> normalizedRows = new ArrayList<>(); |
|||
for (Map<String, Object> row : jdbc.queryForList("SELECT * FROM " + table + " ORDER BY 1")) { |
|||
Map<String, Object> normalized = new LinkedHashMap<>(); |
|||
row.forEach((column, value) -> normalized.put(column, |
|||
value instanceof byte[] bytes |
|||
? java.util.Base64.getEncoder().encodeToString(bytes) |
|||
: String.valueOf(value))); |
|||
normalizedRows.add(normalized); |
|||
} |
|||
Map<String, Object> entry = new LinkedHashMap<>(); |
|||
entry.put("table", table); |
|||
entry.put("rows", normalizedRows); |
|||
tables.add(entry); |
|||
} |
|||
return tables; |
|||
} |
|||
|
|||
// ═══════════════ 普通资料写入:resumeUrl 一律拒绝 ═══════════════
|
|||
|
|||
@Test |
|||
void encodedUnicodeUploadAndLegacyDownloadKeepReadableNamesAndBytes() throws Exception { |
|||
long id = createUser("STUDENT"); |
|||
String token = TestAuthTokens.bearer(id, "STUDENT"); |
|||
byte[] bytes = pdf("synthetic filename compatibility"); |
|||
String encoded = "%E5%BC%A0%E4%B8%89%20A%2B100%25.pdf"; |
|||
String expected = "张三 A+100%.pdf"; |
|||
String key = upload(id, token, encoded, bytes); |
|||
assertEquals(expected, profileOf(id).getResumeOriginalName()); |
|||
for (boolean legacy : List.of(false, true)) { |
|||
if (legacy) jdbc.update("UPDATE student_profiles SET resume_original_name=? WHERE user_id=?", encoded, id); |
|||
var response = mockMvc.perform(get("/profile/me/resume").header("Authorization", token)) |
|||
.andExpect(status().isOk()).andReturn().getResponse(); |
|||
assertArrayEquals(bytes, response.getContentAsByteArray()); |
|||
String header = response.getHeader("Content-Disposition"); |
|||
assertTrue(header.contains("filename*=UTF-8''%E5%BC%A0")); |
|||
assertFalse(header.contains("%25E5%25BC%25A0")); |
|||
assertEquals(expected, org.springframework.http.ContentDisposition.parse(header).getFilename()); |
|||
assertEquals(key, profileOf(id).getResumeUrl()); |
|||
assertEquals(legacy ? encoded : expected, profileOf(id).getResumeOriginalName(), "下载不能回写历史元数据"); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void encodedDangerousNamesAreRejectedBeforeReplacingExistingFile() throws Exception { |
|||
long id = createUser("STUDENT"); |
|||
String token = TestAuthTokens.bearer(id, "STUDENT"); |
|||
byte[] bytes = pdf("safe existing file"); |
|||
String key = upload(id, token, "safe.pdf", bytes); |
|||
long before; |
|||
try (var files = Files.list(STORAGE_DIR)) { before = files.count(); } |
|||
for (String name : List.of("%E5%BC%A0%2Fbad.pdf", "%E5%BC%A0%0D%0Abad.pdf", "%FF.pdf")) { |
|||
mockMvc.perform(multipart("/profile/me/resume") |
|||
.file(new MockMultipartFile("file", name, "application/pdf", bytes)) |
|||
.header("Authorization", token)).andExpect(status().isBadRequest()); |
|||
assertEquals(key, profileOf(id).getResumeUrl()); |
|||
assertEquals("safe.pdf", profileOf(id).getResumeOriginalName()); |
|||
} |
|||
try (var files = Files.list(STORAGE_DIR)) { assertEquals(before, files.count()); } |
|||
assertEquals(0, DELETE_CALLS.get()); |
|||
} |
|||
|
|||
@Test |
|||
void putProfile_absentOrNullResumeUrl_keepsExistingResumeReference() throws Exception { |
|||
long studentId = createUser("STUDENT"); |
|||
String token = TestAuthTokens.bearer(studentId, "STUDENT"); |
|||
putProfile(token, "{\"realName\":\"张三\"}", 200); |
|||
String key = upload(studentId, token, "resume.pdf", pdf("A-resume")); |
|||
|
|||
putProfile(token, "{\"bio\":\"合法修改\"}", 200); |
|||
assertEquals(key, profileOf(studentId).getResumeUrl(), "缺省 resumeUrl 不得改变简历引用"); |
|||
assertEquals("合法修改", profileOf(studentId).getBio()); |
|||
|
|||
putProfile(token, "{\"resumeUrl\":null}", 200); |
|||
assertEquals(key, profileOf(studentId).getResumeUrl(), "显式 null 不得改变简历引用"); |
|||
} |
|||
|
|||
@Test |
|||
void putProfile_nonNullResumeUrl_isRejectedAndNoPartialUpdate() throws Exception { |
|||
long studentId = createUser("STUDENT"); |
|||
long otherId = createUser("STUDENT"); |
|||
String token = TestAuthTokens.bearer(studentId, "STUDENT"); |
|||
String otherToken = TestAuthTokens.bearer(otherId, "STUDENT"); |
|||
putProfile(otherToken, "{\"realName\":\"受害者\"}", 200); |
|||
String victimKey = upload(otherId, otherToken, "victim.pdf", pdf("victim-resume")); |
|||
|
|||
putProfile(token, "{\"realName\":\"初始\"}", 200); |
|||
String key = upload(studentId, token, "own.pdf", pdf("own-resume")); |
|||
long countBefore = profileRepository.count(); |
|||
|
|||
// 空字符串
|
|||
mockMvc.perform(put("/profile/me").header("Authorization", token) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"realName\":\"被篡改\",\"resumeUrl\":\"\"}")) |
|||
.andExpect(status().isBadRequest()); |
|||
|
|||
// 他人存储键
|
|||
mockMvc.perform(put("/profile/me").header("Authorization", token) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"realName\":\"被篡改\",\"resumeUrl\":\"" + victimKey + "\"}")) |
|||
.andExpect(status().isBadRequest()); |
|||
|
|||
// 自己的存储键同样拒绝(引用只能由受控上传生成)
|
|||
mockMvc.perform(put("/profile/me").header("Authorization", token) |
|||
.contentType(MediaType.APPLICATION_JSON) |
|||
.content("{\"realName\":\"被篡改\",\"resumeUrl\":\"" + key + "\"}")) |
|||
.andExpect(status().isBadRequest()); |
|||
|
|||
StudentProfile after = profileOf(studentId); |
|||
assertEquals("初始", after.getRealName(), "整次拒绝:合法字段也不得部分更新"); |
|||
assertEquals(key, after.getResumeUrl()); |
|||
assertEquals(countBefore, profileRepository.count(), "拒绝路径不得创建或修改资料行"); |
|||
} |
|||
|
|||
@Test |
|||
void putProfile_companyAndAdminTokens_areForbidden() throws Exception { |
|||
long companyId = createUser("COMPANY"); |
|||
long adminId = createUser("ADMIN"); |
|||
String companyToken = TestAuthTokens.bearer(companyId, "COMPANY"); |
|||
String adminToken = TestAuthTokens.bearer(adminId, "ADMIN"); |
|||
|
|||
putProfile(companyToken, "{\"realName\":\"企业\"}", 403); |
|||
putProfile(adminToken, "{\"realName\":\"管理员\"}", 403); |
|||
|
|||
assertNull(profileOf(companyId), "非求职者不得创建学生资料"); |
|||
assertNull(profileOf(adminId), "非求职者不得创建学生资料"); |
|||
} |
|||
|
|||
// ═══════════════ 下载:归属校验失败关闭 ═══════════════
|
|||
|
|||
@Test |
|||
void download_binaryRoundTripThroughTempDirForOwner() throws Exception { |
|||
long studentId = createUser("STUDENT"); |
|||
String token = TestAuthTokens.bearer(studentId, "STUDENT"); |
|||
putProfile(token, "{\"realName\":\"张三\"}", 200); |
|||
byte[] content = pdf("owner-resume"); |
|||
String key = upload(studentId, token, "resume.pdf", content); |
|||
|
|||
// 真实文件落盘且字节一致(临时目录 + 真实合成文件)
|
|||
assertTrue(Files.exists(STORAGE_DIR.resolve(key))); |
|||
assertArrayEquals(content, onDisk(key)); |
|||
|
|||
MvcResult result = mockMvc.perform(get("/profile/me/resume").header("Authorization", token)) |
|||
.andExpect(status().isOk()) |
|||
.andReturn(); |
|||
assertArrayEquals(content, result.getResponse().getContentAsByteArray()); |
|||
} |
|||
|
|||
@Test |
|||
void download_noProfile_returns404() throws Exception { |
|||
long studentId = createUser("STUDENT"); |
|||
String token = TestAuthTokens.bearer(studentId, "STUDENT"); |
|||
mockMvc.perform(get("/profile/me/resume").header("Authorization", token)) |
|||
.andExpect(status().isNotFound()); |
|||
} |
|||
|
|||
@Test |
|||
void download_pollutedForeignReference_isRejectedWithoutStorageRead() throws Exception { |
|||
long victimId = createUser("STUDENT"); |
|||
long attackerId = createUser("STUDENT"); |
|||
String victimToken = TestAuthTokens.bearer(victimId, "STUDENT"); |
|||
putProfile(victimToken, "{\"realName\":\"受害者\"}", 200); |
|||
String victimKey = upload(victimId, victimToken, "victim.pdf", pdf("victim-secret")); |
|||
|
|||
pollute(attackerId, victimKey); |
|||
String attackerToken = TestAuthTokens.bearer(attackerId, "STUDENT"); |
|||
READ_CALLS.set(0); |
|||
|
|||
MvcResult result = mockMvc.perform(get("/profile/me/resume").header("Authorization", attackerToken)) |
|||
.andExpect(status().isForbidden()) |
|||
.andReturn(); |
|||
|
|||
assertEquals(0, READ_CALLS.get(), "拒绝路径不得读取存储"); |
|||
String body = result.getResponse().getContentAsString(StandardCharsets.UTF_8); |
|||
assertFalse(body.contains(victimKey), "失败响应不得回显他人存储键"); |
|||
assertFalse(body.contains(STORAGE_DIR.toString()), "失败响应不得回显真实磁盘路径"); |
|||
assertTrue(Files.exists(STORAGE_DIR.resolve(victimKey)), "他人文件必须保持不变"); |
|||
} |
|||
|
|||
@Test |
|||
void download_illegalKeysAndPrefixConfusion_areRejectedWithoutStorageRead() throws Exception { |
|||
long studentId = createUser("STUDENT"); |
|||
String token = TestAuthTokens.bearer(studentId, "STUDENT"); |
|||
putProfile(token, "{\"realName\":\"张三\"}", 200); |
|||
|
|||
List<String> illegalKeys = List.of( |
|||
"../outside.pdf", |
|||
"..\\outside.pdf", |
|||
"/etc/passwd", |
|||
"C:\\windows\\system32\\config", |
|||
"resume.pdf", |
|||
"abc_1.pdf", |
|||
"100_", |
|||
"_100.pdf", |
|||
String.valueOf(studentId).substring(0, String.valueOf(studentId).length() - 1) + "_prefix.pdf" |
|||
); |
|||
for (String illegal : illegalKeys) { |
|||
pollute(studentId, illegal); |
|||
READ_CALLS.set(0); |
|||
mockMvc.perform(get("/profile/me/resume").header("Authorization", token)) |
|||
.andExpect(status().isForbidden()); |
|||
assertEquals(0, READ_CALLS.get(), "非法键必须失败关闭且不读文件:" + illegal); |
|||
} |
|||
} |
|||
|
|||
@Test |
|||
void download_missingOrInvalidToken_returns401() throws Exception { |
|||
// 显式以匿名主体发起请求:避免安全测试支持把上一次请求的 SecurityContext 残留带进来,
|
|||
// 使“未认证”断言在任何测试执行顺序下都成立。
|
|||
mockMvc.perform(get("/profile/me/resume").with(anonymous())).andExpect(status().isUnauthorized()); |
|||
mockMvc.perform(get("/profile/me/resume").header("Authorization", "Bearer not-a-token").with(anonymous())) |
|||
.andExpect(status().isUnauthorized()); |
|||
mockMvc.perform(delete("/profile/me/resume").with(anonymous())).andExpect(status().isUnauthorized()); |
|||
mockMvc.perform(multipart("/profile/me/resume") |
|||
.file(new MockMultipartFile("file", "r.pdf", "application/pdf", pdf("x"))) |
|||
.with(anonymous())) |
|||
.andExpect(status().isUnauthorized()); |
|||
} |
|||
|
|||
// ═══════════════ 删除:跨账号拒绝且零副作用 ═══════════════
|
|||
|
|||
@Test |
|||
void delete_pollutedForeignReference_isRejected_fileAndDatabaseUnchanged() throws Exception { |
|||
long victimId = createUser("STUDENT"); |
|||
long attackerId = createUser("STUDENT"); |
|||
String victimToken = TestAuthTokens.bearer(victimId, "STUDENT"); |
|||
putProfile(victimToken, "{\"realName\":\"受害者\"}", 200); |
|||
byte[] victimContent = pdf("victim-secret"); |
|||
String victimKey = upload(victimId, victimToken, "victim.pdf", victimContent); |
|||
|
|||
pollute(attackerId, victimKey); |
|||
String attackerToken = TestAuthTokens.bearer(attackerId, "STUDENT"); |
|||
|
|||
String hashBefore = sha256(onDisk(victimKey)); |
|||
StudentProfile attackerBefore = profileOf(attackerId); |
|||
List<Map<String, Object>> dbBefore = dbSnapshot(); |
|||
DELETE_CALLS.set(0); |
|||
|
|||
mockMvc.perform(delete("/profile/me/resume").header("Authorization", attackerToken)) |
|||
.andExpect(status().isForbidden()); |
|||
|
|||
assertEquals(0, DELETE_CALLS.get(), "拒绝路径不得删除任何文件"); |
|||
assertTrue(Files.exists(STORAGE_DIR.resolve(victimKey))); |
|||
assertEquals(hashBefore, sha256(onDisk(victimKey)), "他人文件内容哈希必须不变"); |
|||
|
|||
StudentProfile attackerAfter = profileOf(attackerId); |
|||
assertNotNull(attackerAfter); |
|||
assertEquals(attackerBefore.getResumeUrl(), attackerAfter.getResumeUrl(), "攻击者资料引用不得被清空"); |
|||
assertEquals(attackerBefore.getResumeOriginalName(), attackerAfter.getResumeOriginalName()); |
|||
assertEquals(dbBefore, dbSnapshot(), "拒绝路径数据库逐表快照必须零变化"); |
|||
} |
|||
|
|||
// ═══════════════ 替换上传:跨账号拒绝且不留新文件 ═══════════════
|
|||
|
|||
@Test |
|||
void upload_pollutedForeignReference_isRejectedWithoutNewFileOrDelete() throws Exception { |
|||
long victimId = createUser("STUDENT"); |
|||
long attackerId = createUser("STUDENT"); |
|||
String victimToken = TestAuthTokens.bearer(victimId, "STUDENT"); |
|||
putProfile(victimToken, "{\"realName\":\"受害者\"}", 200); |
|||
byte[] victimContent = pdf("victim-secret"); |
|||
String victimKey = upload(victimId, victimToken, "victim.pdf", victimContent); |
|||
|
|||
pollute(attackerId, victimKey); |
|||
String attackerToken = TestAuthTokens.bearer(attackerId, "STUDENT"); |
|||
|
|||
String hashBefore = sha256(onDisk(victimKey)); |
|||
long fileCountBefore = storedFileCount(); |
|||
StudentProfile attackerBefore = profileOf(attackerId); |
|||
List<Map<String, Object>> dbBefore = dbSnapshot(); |
|||
DELETE_CALLS.set(0); |
|||
|
|||
mockMvc.perform(multipart("/profile/me/resume") |
|||
.file(new MockMultipartFile("file", "attacker.pdf", "application/pdf", pdf("attacker"))) |
|||
.header("Authorization", attackerToken)) |
|||
.andExpect(status().isForbidden()); |
|||
|
|||
assertEquals(fileCountBefore, storedFileCount(), "拒绝替换不得留下新文件"); |
|||
assertEquals(0, DELETE_CALLS.get(), "拒绝替换不得删除文件"); |
|||
assertTrue(Files.exists(STORAGE_DIR.resolve(victimKey))); |
|||
assertEquals(hashBefore, sha256(onDisk(victimKey)), "他人文件必须保持不变"); |
|||
assertEquals(attackerBefore.getResumeUrl(), profileOf(attackerId).getResumeUrl()); |
|||
assertEquals(dbBefore, dbSnapshot(), "拒绝路径数据库逐表快照必须零变化"); |
|||
} |
|||
|
|||
// ═══════════════ 合法删除 / 替换继续可用 ═══════════════
|
|||
|
|||
@Test |
|||
void legalReplaceAndDelete_stillWork() throws Exception { |
|||
long studentId = createUser("STUDENT"); |
|||
String token = TestAuthTokens.bearer(studentId, "STUDENT"); |
|||
putProfile(token, "{\"realName\":\"张三\"}", 200); |
|||
|
|||
String firstKey = upload(studentId, token, "first.pdf", pdf("first")); |
|||
assertTrue(firstKey.startsWith(studentId + "_"), "存储键应由服务端以 userId 前缀生成:" + firstKey); |
|||
assertTrue(Files.exists(STORAGE_DIR.resolve(firstKey))); |
|||
|
|||
String secondKey = upload(studentId, token, "second.pdf", pdf("second")); |
|||
assertTrue(Files.exists(STORAGE_DIR.resolve(secondKey)), "新文件必须存在"); |
|||
assertFalse(Files.exists(STORAGE_DIR.resolve(firstKey)), "提交成功后旧文件应被清理"); |
|||
assertEquals(secondKey, profileOf(studentId).getResumeUrl()); |
|||
|
|||
mockMvc.perform(delete("/profile/me/resume").header("Authorization", token)) |
|||
.andExpect(status().isOk()); |
|||
StudentProfile after = profileOf(studentId); |
|||
assertNull(after.getResumeUrl()); |
|||
assertNull(after.getResumeOriginalName()); |
|||
assertNull(after.getResumeContentType()); |
|||
assertNull(after.getResumeSize()); |
|||
assertFalse(Files.exists(STORAGE_DIR.resolve(secondKey)), "合法删除应移除文件"); |
|||
} |
|||
} |
|||
@ -0,0 +1,245 @@ |
|||
package com.mydreamjob.profile; |
|||
|
|||
import com.mydreamjob.auth.User; |
|||
import com.mydreamjob.auth.UserRepository; |
|||
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; |
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import com.mydreamjob.common.r01b.UserRoleGuardTestSupport; |
|||
import com.mydreamjob.messaging.ChatResumeLifecycle; |
|||
import com.mydreamjob.profile.dto.StudentProfileUpdateRequest; |
|||
import com.mydreamjob.profile.storage.ResumeFileValidator; |
|||
import com.mydreamjob.profile.storage.ResumeOwnershipVerifier; |
|||
import com.mydreamjob.profile.storage.ResumeStorageService; |
|||
import com.mydreamjob.security.UserRoleGuard; |
|||
import org.junit.jupiter.api.BeforeEach; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.api.extension.ExtendWith; |
|||
import org.mockito.InjectMocks; |
|||
import org.mockito.Mock; |
|||
import org.mockito.Spy; |
|||
import org.mockito.junit.jupiter.MockitoExtension; |
|||
import org.springframework.mock.web.MockMultipartFile; |
|||
|
|||
import java.nio.charset.StandardCharsets; |
|||
import java.util.HashSet; |
|||
import java.util.Optional; |
|||
import java.util.Set; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertArrayEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertNotNull; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
import static org.mockito.ArgumentMatchers.any; |
|||
import static org.mockito.ArgumentMatchers.anyLong; |
|||
import static org.mockito.ArgumentMatchers.anyString; |
|||
import static org.mockito.Mockito.never; |
|||
import static org.mockito.Mockito.verify; |
|||
import static org.mockito.Mockito.when; |
|||
|
|||
/** |
|||
* 简历归属加固的**服务层**回归测试(不依赖 Controller / MockMvc)。 |
|||
* |
|||
* <p>证明:即使绕过 Controller 直接调用服务层,也无法写入非法简历引用, |
|||
* 且本人下载 / 删除 / 替换在引用被污染(指向他人存储键)时失败关闭、不触碰存储。</p> |
|||
*/ |
|||
@ExtendWith(MockitoExtension.class) |
|||
class ResumeOwnershipServiceTest { |
|||
|
|||
private static final Long STUDENT = 101L; |
|||
private static final Long OTHER_USER = 202L; |
|||
private static final Long COMPANY = 10L; |
|||
|
|||
@Mock private StudentProfileRepository profileRepository; |
|||
@Mock private ResumeStorageService storageService; |
|||
@Mock private UserRepository userRepository; |
|||
@Mock private UserRoleGuard userRoleGuard; |
|||
@Mock private ChatResumeLifecycle chatResumeLifecycle; |
|||
@Mock private CandidateScoringRefreshRequestService scoringRefreshRequestService; |
|||
@Spy private ResumeFileValidator resumeFileValidator = new ResumeFileValidator(); |
|||
@Spy private ResumeOwnershipVerifier resumeOwnershipVerifier = new ResumeOwnershipVerifier(); |
|||
|
|||
@InjectMocks private ProfileService profileService; |
|||
|
|||
@BeforeEach |
|||
void setUp() { |
|||
UserRoleGuardTestSupport.delegateToRealGuard(userRoleGuard, userRepository); |
|||
} |
|||
|
|||
private void mockRole(Long userId, User.Role role) { |
|||
User user = User.builder().id(userId).phone("138" + userId + "0000") |
|||
.role(role).active(true).verified(true).build(); |
|||
user.setRoles(new HashSet<>(Set.of(role))); |
|||
when(userRepository.findById(userId)).thenReturn(Optional.of(user)); |
|||
} |
|||
|
|||
private static StudentProfileUpdateRequest request(String realName) { |
|||
StudentProfileUpdateRequest req = new StudentProfileUpdateRequest(); |
|||
req.setRealName(realName); |
|||
return req; |
|||
} |
|||
|
|||
private static byte[] pdf(String body) { |
|||
return ("%PDF-1.7\n" + body + "\n%%EOF\n").getBytes(StandardCharsets.US_ASCII); |
|||
} |
|||
|
|||
// ── 普通资料写入:服务层同样拒绝 resumeUrl ────────────────
|
|||
|
|||
@Test |
|||
void updateProfile_withResumeUrl_isRejectedWholeRequestWithoutSaving() { |
|||
mockRole(STUDENT, User.Role.STUDENT); |
|||
StudentProfileUpdateRequest req = request("李四"); |
|||
req.setResumeUrl(OTHER_USER + "_beef0001.pdf"); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> profileService.updateProfile(STUDENT, req)); |
|||
|
|||
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); |
|||
verify(profileRepository, never()).save(any()); |
|||
verify(profileRepository, never()).findByUserId(anyLong()); |
|||
} |
|||
|
|||
@Test |
|||
void updateProfile_withEmptyResumeUrl_isRejected() { |
|||
mockRole(STUDENT, User.Role.STUDENT); |
|||
StudentProfileUpdateRequest req = request("李四"); |
|||
req.setResumeUrl(""); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> profileService.updateProfile(STUDENT, req)); |
|||
|
|||
assertEquals(ErrorCode.BAD_REQUEST.getCode(), ex.getErrorCodeInt()); |
|||
verify(profileRepository, never()).save(any()); |
|||
} |
|||
|
|||
@Test |
|||
void updateProfile_withoutResumeUrl_preservesExistingReference() { |
|||
mockRole(STUDENT, User.Role.STUDENT); |
|||
String existing = STUDENT + "_keep0001.pdf"; |
|||
StudentProfile profile = StudentProfile.builder() |
|||
.id(1L).userId(STUDENT).realName("张三").resumeUrl(existing).build(); |
|||
when(profileRepository.findByUserId(STUDENT)).thenReturn(Optional.of(profile)); |
|||
when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); |
|||
|
|||
StudentProfile saved = profileService.updateProfile(STUDENT, request("李四")); |
|||
|
|||
assertEquals("李四", saved.getRealName()); |
|||
assertEquals(existing, saved.getResumeUrl()); |
|||
} |
|||
|
|||
@Test |
|||
void updateProfile_asCompany_isForbiddenAndDoesNotCreateProfile() { |
|||
mockRole(COMPANY, User.Role.COMPANY); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> profileService.updateProfile(COMPANY, request("假学生"))); |
|||
|
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
verify(profileRepository, never()).save(any()); |
|||
} |
|||
|
|||
// ── 下载:引用被污染为他人文件 → 拒绝且不读取 ─────────────
|
|||
|
|||
@Test |
|||
void getResume_pollutedForeignReference_isRejectedWithoutStorageRead() { |
|||
mockRole(STUDENT, User.Role.STUDENT); |
|||
StudentProfile profile = StudentProfile.builder() |
|||
.id(1L).userId(STUDENT).resumeUrl(OTHER_USER + "_beef0001.pdf").build(); |
|||
when(profileRepository.findByUserId(STUDENT)).thenReturn(Optional.of(profile)); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> profileService.getResume(STUDENT)); |
|||
|
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
verify(storageService, never()).read(anyString()); |
|||
} |
|||
|
|||
@Test |
|||
void getResume_illegalLegacyReference_isRejectedWithoutStorageRead() { |
|||
mockRole(STUDENT, User.Role.STUDENT); |
|||
StudentProfile profile = StudentProfile.builder() |
|||
.id(1L).userId(STUDENT).resumeUrl("old_resume.pdf").build(); |
|||
when(profileRepository.findByUserId(STUDENT)).thenReturn(Optional.of(profile)); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> profileService.getResume(STUDENT)); |
|||
|
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
verify(storageService, never()).read(anyString()); |
|||
} |
|||
|
|||
@Test |
|||
void getResume_ownReference_isRead() { |
|||
mockRole(STUDENT, User.Role.STUDENT); |
|||
String own = STUDENT + "_own00001.pdf"; |
|||
StudentProfile profile = StudentProfile.builder() |
|||
.id(1L).userId(STUDENT).resumeUrl(own).resumeOriginalName("my.pdf") |
|||
.resumeContentType("application/pdf").build(); |
|||
when(profileRepository.findByUserId(STUDENT)).thenReturn(Optional.of(profile)); |
|||
when(storageService.read(own)).thenReturn(pdf("content")); |
|||
|
|||
ProfileService.ResumeFile file = profileService.getResume(STUDENT); |
|||
|
|||
assertArrayEquals(pdf("content"), file.getContent()); |
|||
assertEquals("my.pdf", file.getFilename()); |
|||
} |
|||
|
|||
// ── 删除:引用被污染 → 拒绝,资料 / 快照 / 文件均不变 ─────
|
|||
|
|||
@Test |
|||
void deleteResume_pollutedForeignReference_isRejectedAndLeavesEverythingUnchanged() { |
|||
mockRole(STUDENT, User.Role.STUDENT); |
|||
String foreign = OTHER_USER + "_beef0001.pdf"; |
|||
StudentProfile profile = StudentProfile.builder() |
|||
.id(1L).userId(STUDENT).resumeUrl(foreign).resumeOriginalName("victim.pdf").build(); |
|||
when(profileRepository.lockByUserId(STUDENT)).thenReturn(Optional.of(profile)); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> profileService.deleteResume(STUDENT)); |
|||
|
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
// 资料未被清空、未落库;聊天快照未撤销;他人文件未被删除
|
|||
assertNotNull(profile.getResumeUrl()); |
|||
assertEquals(foreign, profile.getResumeUrl()); |
|||
assertEquals("victim.pdf", profile.getResumeOriginalName()); |
|||
verify(profileRepository, never()).save(any()); |
|||
verify(chatResumeLifecycle, never()).purgeForUser(anyLong()); |
|||
verify(storageService, never()).delete(anyString()); |
|||
} |
|||
|
|||
// ── 替换上传:非法旧引用 → 拒绝,不留新文件、不删他人文件 ──
|
|||
|
|||
@Test |
|||
void uploadResume_pollutedForeignOldReference_isRejectedWithoutStoringOrDeleting() { |
|||
mockRole(STUDENT, User.Role.STUDENT); |
|||
StudentProfile profile = StudentProfile.builder() |
|||
.id(1L).userId(STUDENT).resumeUrl(OTHER_USER + "_beef0001.pdf").build(); |
|||
when(profileRepository.lockByUserId(STUDENT)).thenReturn(Optional.of(profile)); |
|||
MockMultipartFile file = new MockMultipartFile("file", "new.pdf", "application/pdf", pdf("new")); |
|||
|
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> profileService.uploadResume(STUDENT, file)); |
|||
|
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
verify(storageService, never()).store(anyLong(), any()); |
|||
verify(storageService, never()).delete(anyString()); |
|||
verify(profileRepository, never()).save(any()); |
|||
} |
|||
|
|||
@Test |
|||
void uploadResume_ownOldReference_replacesAndDeletesOldFileAfterSave() { |
|||
mockRole(STUDENT, User.Role.STUDENT); |
|||
String oldKey = STUDENT + "_old00001.pdf"; |
|||
StudentProfile profile = StudentProfile.builder() |
|||
.id(1L).userId(STUDENT).resumeUrl(oldKey).build(); |
|||
when(profileRepository.lockByUserId(STUDENT)).thenReturn(Optional.of(profile)); |
|||
when(storageService.store(anyLong(), any())).thenReturn(STUDENT + "_new00002.pdf"); |
|||
when(profileRepository.save(any())).thenAnswer(inv -> inv.getArgument(0)); |
|||
MockMultipartFile file = new MockMultipartFile("file", "new.pdf", "application/pdf", pdf("new")); |
|||
|
|||
var view = profileService.uploadResume(STUDENT, file); |
|||
|
|||
assertEquals(STUDENT + "_new00002.pdf", view.get("resumeUrl")); |
|||
verify(storageService).delete(oldKey); |
|||
} |
|||
} |
|||
@ -0,0 +1,51 @@ |
|||
package com.mydreamjob.profile.storage; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import org.junit.jupiter.api.Test; |
|||
import org.junit.jupiter.params.ParameterizedTest; |
|||
import org.junit.jupiter.params.provider.ValueSource; |
|||
import org.springframework.http.ContentDisposition; |
|||
import java.nio.charset.StandardCharsets; |
|||
import static org.junit.jupiter.api.Assertions.*; |
|||
|
|||
class ResumeFilenameEncodingTest { |
|||
private static final String ENCODED = "%E5%BC%A0%E4%B8%89"; |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = {"张三.pdf", "张 三+100%.pdf", "100%.pdf", "a+b.pdf", "a%20b.pdf", "a%25b.pdf", "%25E5%25BC%25A0.pdf"}) |
|||
void literalNamesArePreserved(String name) { |
|||
ResumeFilenameSanitizer.validateUploadName(name); |
|||
assertEquals(name, ResumeFilenameSanitizer.forDownload(name, "resume")); |
|||
} |
|||
|
|||
@Test void utf8NameAndSpecialCharactersDecodeExactlyOnce() { |
|||
String input = ENCODED + "%20A+%2B%2520%25.pdf"; |
|||
String expected = "张三 A++%20%.pdf"; |
|||
ResumeFilenameSanitizer.validateUploadName(input); |
|||
String result = ResumeFilenameSanitizer.forDownload(input, "resume"); |
|||
assertEquals(expected, result); |
|||
assertEquals(result, ResumeFilenameSanitizer.forDownload(result, "resume")); |
|||
String header = ContentDisposition.attachment().filename(result, StandardCharsets.UTF_8).build().toString(); |
|||
assertTrue(header.contains("filename*=UTF-8''%E5%BC%A0")); |
|||
assertFalse(header.contains("%25E5%25BC%25A0")); |
|||
assertEquals(expected, ContentDisposition.parse(header).getFilename()); |
|||
} |
|||
|
|||
@ParameterizedTest |
|||
@ValueSource(strings = {"%FF.pdf", "%E5%BC.pdf", "%E5%ZZ.pdf", "%C0%AF.pdf", "%ED%A0%80.pdf", |
|||
"%E5%BC%A0%2Fbad.pdf", "%E5%BC%A0%5Cbad.pdf", "%E5%BC%A0%3Abad.pdf", "%E5%BC%A0%0D%0Abad.pdf", |
|||
"%E5%BC%A0%25E4%25B8%2589.pdf"}) |
|||
void invalidUtf8DangerousAndAmbiguousNestedNamesAreRejected(String name) { |
|||
assertThrows(BusinessException.class, () -> ResumeFilenameSanitizer.validateUploadName(name)); |
|||
String safe = ResumeFilenameSanitizer.forDownload(name, "resume"); |
|||
assertFalse(safe.chars().anyMatch(c -> Character.isISOControl(c) || c == '/' || c == '\\' || c == ':')); |
|||
assertEquals(safe, ResumeFilenameSanitizer.forDownload(safe, "resume")); |
|||
} |
|||
|
|||
@Test void lengthIsCheckedAfterDecode() { |
|||
String accepted = "%E5%BC%A0".repeat(251) + ".pdf"; |
|||
ResumeFilenameSanitizer.validateUploadName(accepted); |
|||
assertEquals(255, ResumeFilenameSanitizer.forDownload(accepted, "resume").length()); |
|||
assertThrows(BusinessException.class, () -> ResumeFilenameSanitizer.validateUploadName("%E5%BC%A0".repeat(252) + ".pdf")); |
|||
} |
|||
} |
|||
@ -0,0 +1,127 @@ |
|||
package com.mydreamjob.profile.storage; |
|||
|
|||
import com.mydreamjob.common.exception.BusinessException; |
|||
import com.mydreamjob.common.exception.ErrorCode; |
|||
import org.junit.jupiter.api.Test; |
|||
|
|||
import static org.junit.jupiter.api.Assertions.assertEquals; |
|||
import static org.junit.jupiter.api.Assertions.assertFalse; |
|||
import static org.junit.jupiter.api.Assertions.assertNull; |
|||
import static org.junit.jupiter.api.Assertions.assertThrows; |
|||
import static org.junit.jupiter.api.Assertions.assertTrue; |
|||
|
|||
/** |
|||
* {@link ResumeOwnershipVerifier} 归属解析单元测试(纯字符串语义,无文件访问)。 |
|||
* |
|||
* <p>覆盖服务端存储键格式 {@code <userId>_<随机段>[.ext]} 的所有边界: |
|||
* 合法归属、空/异常键、路径穿越与绝对路径、以及 {@code 1} 与 {@code 10} 前缀混淆。</p> |
|||
*/ |
|||
class ResumeOwnershipVerifierTest { |
|||
|
|||
private final ResumeOwnershipVerifier verifier = new ResumeOwnershipVerifier(); |
|||
|
|||
// ── 合法归属 ──────────────────────────────────────────────
|
|||
|
|||
@Test |
|||
void serverGeneratedKeyIsOwnedByItsUserId() { |
|||
assertTrue(verifier.isOwnedBy("100_abcd1234.pdf", 100L)); |
|||
assertEquals(100L, verifier.parseOwner("100_abcd1234.pdf")); |
|||
} |
|||
|
|||
@Test |
|||
void keyWithoutExtensionIsAccepted() { |
|||
assertTrue(verifier.isOwnedBy("42_deadbeef", 42L)); |
|||
} |
|||
|
|||
@Test |
|||
void keyWithSeveralUnderscoresUsesFirstSegmentOnly() { |
|||
// 只取第一个下划线之前的部分作为所有者
|
|||
assertTrue(verifier.isOwnedBy("7_a_b_c.pdf", 7L)); |
|||
} |
|||
|
|||
// ── 前缀混淆(禁止 startsWith 语义) ──────────────────────
|
|||
|
|||
@Test |
|||
void oneIsNotAValidOwnerForTen() { |
|||
assertFalse(verifier.isOwnedBy("1_account.pdf", 10L)); |
|||
assertEquals(1L, verifier.parseOwner("1_account.pdf")); |
|||
} |
|||
|
|||
@Test |
|||
void tenIsNotAValidOwnerForOne() { |
|||
assertFalse(verifier.isOwnedBy("10_account.pdf", 1L)); |
|||
} |
|||
|
|||
@Test |
|||
void longerNumericPrefixIsNotAValidOwner() { |
|||
assertFalse(verifier.isOwnedBy("1000_account.pdf", 100L)); |
|||
} |
|||
|
|||
// ── 失败关闭:格式非法 ────────────────────────────────────
|
|||
|
|||
@Test |
|||
void nullAndBlankKeysAreRejected() { |
|||
assertFalse(verifier.isOwnedBy(null, 100L)); |
|||
assertFalse(verifier.isOwnedBy("", 100L)); |
|||
assertFalse(verifier.isOwnedBy(" ", 100L)); |
|||
assertNull(verifier.parseOwner(null)); |
|||
} |
|||
|
|||
@Test |
|||
void keyWithoutSeparatorIsRejected() { |
|||
assertFalse(verifier.isOwnedBy("resume.pdf", 100L)); |
|||
assertNull(verifier.parseOwner("100abcd.pdf")); |
|||
} |
|||
|
|||
@Test |
|||
void nonNumericPrefixIsRejected() { |
|||
assertFalse(verifier.isOwnedBy("abc_100.pdf", 100L)); |
|||
assertFalse(verifier.isOwnedBy("10a_x.pdf", 10L)); |
|||
assertFalse(verifier.isOwnedBy("a0_x.pdf", 10L)); |
|||
assertFalse(verifier.isOwnedBy("+10_x.pdf", 10L)); |
|||
assertFalse(verifier.isOwnedBy("-10_x.pdf", 10L)); |
|||
} |
|||
|
|||
@Test |
|||
void emptySuffixOrEmptyPrefixIsRejected() { |
|||
assertFalse(verifier.isOwnedBy("100_", 100L)); |
|||
assertFalse(verifier.isOwnedBy("_100.pdf", 100L)); |
|||
} |
|||
|
|||
@Test |
|||
void pathTraversalAndSeparatorsAreRejected() { |
|||
assertFalse(verifier.isOwnedBy("../100_secret.pdf", 100L)); |
|||
assertFalse(verifier.isOwnedBy("..\\100_secret.pdf", 100L)); |
|||
assertFalse(verifier.isOwnedBy("100_..", 100L)); |
|||
assertFalse(verifier.isOwnedBy("sub/100_a.pdf", 100L)); |
|||
assertFalse(verifier.isOwnedBy("100_ab/cd.pdf", 100L)); |
|||
} |
|||
|
|||
@Test |
|||
void absolutePathsAndWindowsDriveAreRejected() { |
|||
assertFalse(verifier.isOwnedBy("/etc/passwd", 100L)); |
|||
assertFalse(verifier.isOwnedBy("/100_a.pdf", 100L)); |
|||
assertFalse(verifier.isOwnedBy("C:\\100_a.pdf", 100L)); |
|||
assertFalse(verifier.isOwnedBy("C:100_a.pdf", 100L)); |
|||
} |
|||
|
|||
@Test |
|||
void nullUserIdIsRejected() { |
|||
assertFalse(verifier.isOwnedBy("100_a.pdf", null)); |
|||
} |
|||
|
|||
// ── assertOwnedBy 抛出统一 403 ────────────────────────────
|
|||
|
|||
@Test |
|||
void assertOwnedByThrowsForbiddenAndNeverLeaksTheKey() { |
|||
BusinessException ex = assertThrows(BusinessException.class, |
|||
() -> verifier.assertOwnedBy("202_topsecret.pdf", 100L)); |
|||
assertEquals(ErrorCode.FORBIDDEN.getCode(), ex.getErrorCodeInt()); |
|||
assertFalse(ex.getMessage().contains("202_topsecret")); |
|||
} |
|||
|
|||
@Test |
|||
void assertOwnedByPassesForOwner() { |
|||
verifier.assertOwnedBy("100_a.pdf", 100L); |
|||
} |
|||
} |
|||
Loading…
Reference in new issue