Browse Source

feat: complete job display fields and harden resume access

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
Zhang Shun 1 week ago
parent
commit
91cbf6cd4d
  1. 103
      docs/_update_job_company_display_fields_openapi.py
  2. 354
      docs/acceptance/batch-display-fields-20261016/b01_blackbox.py
  3. 140
      docs/acceptance/batch-display-fields-20261016/b01_contract_negative_tests.py
  4. 249
      docs/acceptance/batch-display-fields-20261016/b01_openapi_compare.py
  5. 266
      docs/batch-job-company-display-fields-frontend-handover.md
  6. 115
      docs/batch-job-company-display-fields-report.md
  7. 836
      docs/openapi-rc.json
  8. 129
      docs/resume-ownership-hardening-20261016.md
  9. 866
      docs/tools/b01_demo_display_data.py
  10. 594
      docs/tools/b01_demo_display_data_test.py
  11. 65
      docs/verify_openapi.py
  12. 4
      mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java
  13. 3
      mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java
  14. 34
      mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java
  15. 8
      mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java
  16. 19
      mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java
  17. 7
      mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java
  18. 15
      mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java
  19. 22
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java
  20. 145
      mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java
  21. 31
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobDetailViewDoc.java
  22. 31
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobListViewDoc.java
  23. 31
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobPublicViewDoc.java
  24. 35
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java
  25. 45
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java
  26. 73
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java
  27. 38
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java
  28. 31
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobListViewDoc.java
  29. 73
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java
  30. 9
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java
  31. 46
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java
  32. 45
      mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictIntegerDeserializer.java
  33. 4
      mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java
  34. 9
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java
  35. 34
      mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java
  36. 7
      mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java
  37. 66
      mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java
  38. 107
      mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifier.java
  39. 9
      mydreamjob-server/src/main/resources/db/migration/V57__job_company_display_fields.sql
  40. 41
      mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java
  41. 49
      mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java
  42. 242
      mydreamjob-server/src/test/java/com/mydreamjob/company/CompanySizeDisplayIntegrationTest.java
  43. 200
      mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java
  44. 95
      mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlValidateTest.java
  45. 167
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsAuthorizationIntegrationTest.java
  46. 334
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java
  47. 362
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsIntegrationTest.java
  48. 135
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsQueryBudgetIntegrationTest.java
  49. 157
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobListCompanySummaryQueryBudgetTest.java
  50. 330
      mydreamjob-server/src/test/java/com/mydreamjob/job/JobSalaryMonthsHttpBindingTest.java
  51. 15
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java
  52. 77
      mydreamjob-server/src/test/java/com/mydreamjob/messaging/HistoricalResumeOwnershipIntegrationTest.java
  53. 7
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java
  54. 558
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipHttpIntegrationTest.java
  55. 245
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java
  56. 25
      mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java
  57. 51
      mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFilenameEncodingTest.java
  58. 127
      mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifierTest.java

103
docs/_update_job_company_display_fields_openapi.py

@ -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()

354
docs/acceptance/batch-display-fields-20261016/b01_blackbox.py

@ -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()

140
docs/acceptance/batch-display-fields-20261016/b01_contract_negative_tests.py

@ -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())

249
docs/acceptance/batch-display-fields-20261016/b01_openapi_compare.py

@ -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())

266
docs/batch-job-company-display-fields-frontend-handover.md

@ -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`。未发布、未连接生产库、未向线上导入演示企业岗位。

115
docs/batch-job-company-display-fields-report.md

@ -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` 资料作为历史记录保留,不作为当前验收结论。

836
docs/openapi-rc.json

File diff suppressed because it is too large

129
docs/resume-ownership-hardening-20261016.md

@ -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`。本文未包含账号口令、令牌、真实简历或生产数据库配置。

866
docs/tools/b01_demo_display_data.py

@ -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())

594
docs/tools/b01_demo_display_data_test.py

@ -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())

65
docs/verify_openapi.py

@ -809,6 +809,71 @@ for error_code, (path, method, status_code) in onboarding_error_paths.items():
assert str(error_code) in response_text, f'Missing onboarding error code {error_code} in {method.upper()} {path}'
print(' N04 onboarding endpoints and schemas present (9 operations / 6 paths)')
# ── 岗位与企业展示字段批次:新增字段、约束与公开摘要白名单 ─────────────
# 本批未新增端点(paths=201 / operations=245 基线不变),只扩展既有 schema 与响应说明。
# 运行时为 OpenAPI 3.1,可空性以 `type: [<base>, "null"]` 表达(不再使用 3.0 的 nullable)。
def _type_set(node):
value = node.get('type')
return set(value) if isinstance(value, list) else {value}
_display_text_fields = {'experience': 40, 'education': 40, 'workLocation': 255}
for _schema_name in ('JobCreateRequest', 'JobUpdateRequest'):
_props = d['components']['schemas'][_schema_name]['properties']
for _field, _max in _display_text_fields.items():
assert _field in _props, f'{_schema_name} missing display field {_field}'
assert _type_set(_props[_field]) == {'string', 'null'}, \
f'{_schema_name}.{_field} 必须为可空 string'
assert _props[_field]['maxLength'] == _max, f'{_schema_name}.{_field} maxLength must be {_max}'
_benefits = _props['benefits']
assert _type_set(_benefits) == {'array', 'null'}, f'{_schema_name}.benefits must be nullable array'
assert _benefits['items']['type'] == 'string', f'{_schema_name}.benefits items must be string'
assert 'maxItems' not in _benefits and 'maxLength' not in _benefits['items'], '请求原始福利不得施加归一化后的限制'
assert _benefits.get('x-normalization') == dict(trimItems=True, deduplicate=True, rejectBlankItems=True,
maxItems=20, itemMaxLength=40), '福利归一化契约缺失或错误'
_months = _props['salaryMonths']
assert _type_set(_months) == {'integer', 'null'}, \
f'{_schema_name}.salaryMonths must be nullable integer (非整数不允许)'
assert _months['minimum'] == 1 and _months['maximum'] == 24, \
f'{_schema_name}.salaryMonths must be 1..24'
for _salary in ('salaryMin', 'salaryMax'):
_desc = _props[_salary].get('description', '')
assert '元' in _desc, f'{_schema_name}.{_salary} 单位仍应为元/月'
print(' 岗位展示字段 experience/education/benefits/salaryMonths/workLocation 约束已验证')
for _schema_name in ('CompanyCreateRequest', 'CompanyUpdateRequest'):
_cs = d['components']['schemas'][_schema_name]['properties']['companySize']
assert _type_set(_cs) == {'string', 'null'}, f'{_schema_name}.companySize must be nullable string'
assert _cs['maxLength'] == 40, f'{_schema_name}.companySize maxLength must be 40'
assert '空白' in _cs.get('description', ''), f'{_schema_name}.companySize 写入语义(空白清空)须写明'
print(' companySize 请求字段约束与写入语义已验证')
_managed_props = d['components']['schemas']['CompanyManagedJobItem']['properties']
for _field in ('experience', 'education', 'benefits', 'salaryMonths', 'workLocation'):
assert _field in _managed_props, f'CompanyManagedJobItem missing display field {_field}'
assert '元' in _managed_props['salaryMin'].get('description', '')
assert '元' in _managed_props['salaryMax'].get('description', '')
print(' CompanyManagedJobItem 展示字段已验证')
_public_company_props = d['components']['schemas']['CompanyPublicView']['properties']
assert set(_public_company_props) == {
'id', 'name', 'logoUrl', 'description', 'industry', 'verified', 'companySize'}, \
f'CompanyPublicView 必须严格为 7 字段白名单,实际={sorted(_public_company_props)}'
for _forbidden in ('ownerUserId', 'address', 'website', 'phone', 'email', 'createdAt',
'materialsJson', 'decisionReason', 'reviewerId'):
assert _forbidden not in _public_company_props, f'CompanyPublicView 不得包含 {_forbidden}'
print(' 公开企业摘要严格 7 字段白名单已验证(不含 ownerUserId/联系方式/地址/认证材料/审核信息)')
for _pt, _method in [('/jobs', 'get'), ('/jobs', 'post'), ('/jobs/search', 'get'),
('/jobs/public/{jobId}', 'get'), ('/jobs/{jobId}', 'get'),
('/jobs/{jobId}', 'put'), ('/companies/{companyId}/jobs', 'get')]:
_desc = paths[_pt][_method]['responses']['200'].get('description', '')
assert all(_f in _desc for _f in ('experience', 'education', 'benefits', 'salaryMonths', 'workLocation')), \
f'{_method.upper()} {_pt} 200 说明须列出岗位展示字段'
assert '元/月' in _desc, f'{_method.upper()} {_pt} 200 说明须写明薪单位仍为元/月'
_company_summary_desc = paths['/companies/{companyId}']['get']['responses']['200'].get('description', '')
assert 'companySize' in _company_summary_desc and 'ownerUserId' in _company_summary_desc
print(' Map 型岗位接口与公开企业摘要的展示字段说明已验证')
data = json.dumps(d)
assert '"" : "#/components/schemas/' not in data, "Found empty ref key"
print("No empty ref keys found")

4
mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java

@ -7,6 +7,7 @@ import com.mydreamjob.profile.StudentProfile;
import com.mydreamjob.profile.StudentProfileRepository;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.profile.storage.ResumeOwnershipVerifier;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@ -39,6 +40,7 @@ public class CompanyResumeService {
private final CompanyResumeAccessPolicy accessPolicy;
private final StudentProfileRepository profileRepository;
private final ResumeStorageService resumeStorageService;
private final ResumeOwnershipVerifier resumeOwnership;
/**
* 读取该投递对应候选人的简历。
@ -60,6 +62,8 @@ public class CompanyResumeService {
throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND);
}
// 企业获得的是该候选人的读取授权,不能将历史污染引用当作文件归属证明。
resumeOwnership.assertOwnedBy(storagePath, decision.studentUserId());
byte[] content = resumeStorageService.read(storagePath);
String filename = ResumeFilenameSanitizer.forDownload(profile.getResumeOriginalName(), "resume");
String contentType = resolveContentType(profile.getResumeContentType());

3
mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java

@ -42,6 +42,9 @@ public class Company {
@Column(name = "industry", length = 80)
private String industry;
@Column(name = "company_size", length = 40)
private String companySize;
@Column(name = "website", length = 255)
private String website;

34
mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java

@ -75,6 +75,7 @@ public class CompanyService {
.logoUrl(req.getLogoUrl())
.description(req.getDescription())
.industry(req.getIndustry())
.companySize(normalizeCompanySize(req.getCompanySize()))
.website(req.getWebsite())
.address(req.getAddress())
.verified(false)
@ -95,12 +96,43 @@ public class CompanyService {
if (req.getLogoUrl() != null) company.setLogoUrl(req.getLogoUrl());
if (req.getDescription() != null) company.setDescription(req.getDescription());
if (req.getIndustry() != null) company.setIndustry(req.getIndustry());
// companySize:null/缺省保持原值;其余交给与创建共用的同一套归一化规则。
if (req.getCompanySize() != null) {
company.setCompanySize(normalizeCompanySize(req.getCompanySize()));
}
if (req.getWebsite() != null) company.setWebsite(req.getWebsite());
if (req.getAddress() != null) company.setAddress(req.getAddress());
return companyRepository.save(company);
}
/** 企业规模长度上限,与请求 DTO 的 @Size(max = 40) 保持一致。 */
private static final int MAX_COMPANY_SIZE_LENGTH = 40;
/**
* companySize 统一归一化——创建与更新**共用同一套规则**,避免两套逻辑漂移。
*
* <ul>
* <li>{@code null} / 缺省 → {@code null}(创建即存 SQL NULL;更新由调用方先判 null 以「保持原值」)</li>
* <li>空串或纯空白 → {@code null}(清空)</li>
* <li>非空 → 去掉两端空白后保存(如 {@code " 100-499人 "} → {@code "100-499人"})</li>
* <li>超过 40 字符 → 抛参数错误,**不截断**</li>
* </ul>
*/
private static String normalizeCompanySize(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
if (trimmed.isEmpty()) {
return null;
}
if (trimmed.length() > MAX_COMPANY_SIZE_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
return trimmed;
}
/**
* 将企业 Entity 转为 API 视图(排除敏感字段)
*/
@ -113,6 +145,7 @@ public class CompanyService {
view.put("logoUrl", company.getLogoUrl());
view.put("description", company.getDescription());
view.put("industry", company.getIndustry());
view.put("companySize", company.getCompanySize());
view.put("website", company.getWebsite());
view.put("address", company.getAddress());
view.put("verified", company.getVerified());
@ -143,6 +176,7 @@ public class CompanyService {
.logoUrl(company.getLogoUrl())
.description(company.getDescription())
.industry(company.getIndustry())
.companySize(company.getCompanySize())
.verified(company.getVerified())
.build();
}

8
mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java

@ -1,5 +1,6 @@
package com.mydreamjob.company.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@ -13,6 +14,13 @@ public class CompanyCreateRequest {
private String logoUrl;
private String description;
private String industry;
@Size(max = 40, message = "company size must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 40 字符拒绝且不截断。")
private String companySize;
private String website;
private String address;
}

19
mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java

@ -1,6 +1,7 @@
package com.mydreamjob.company.dto;
import com.fasterxml.jackson.annotation.JsonInclude;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
@ -8,19 +9,35 @@ import lombok.NoArgsConstructor;
/**
* 企业公开信息 DTO。
* 仅暴露 6 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。
* 仅暴露 7 个公开字段,不泄露 ownerUserId、联系方式、审核信息等。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
@Schema(name = "CompanyPublicView",
description = "企业公开摘要;严格 7 字段,不含 ownerUserId、联系方式、登记地址与审核信息")
public class CompanyPublicView {
@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(types = {"string", "null"}, maxLength = 40, description = "企业规模,≤40 字符")
private String companySize;
@Schema(description = "是否已认证")
private Boolean verified;
}

7
mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java

@ -1,5 +1,6 @@
package com.mydreamjob.company.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
@ -22,6 +23,12 @@ public class CompanyUpdateRequest {
@Size(max = 80, message = "industry must be at most 80 characters")
private String industry;
@Size(max = 40, message = "company size must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 40 字符拒绝且不截断。")
private String companySize;
@Size(max = 255, message = "website must be at most 255 characters")
private String website;

15
mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java

@ -51,6 +51,21 @@ public class Job {
@Column(name = "salary_max")
private Integer salaryMax;
@Column(name = "experience", length = 40)
private String experience;
@Column(name = "education", length = 40)
private String education;
@Column(name = "benefits_json", columnDefinition = "text")
private String benefitsJson;
@Column(name = "salary_months")
private Integer salaryMonths;
@Column(name = "work_location", length = 255)
private String workLocation;
@Column(name = "status", nullable = false, length = 20)
@Builder.Default
private String status = "DRAFT";

22
mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java

@ -5,12 +5,17 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
import com.mydreamjob.company.CompanyService;
import com.mydreamjob.job.dto.ApiResponseJobDetailViewDoc;
import com.mydreamjob.job.dto.ApiResponseJobListViewDoc;
import com.mydreamjob.job.dto.ApiResponseJobPublicViewDoc;
import com.mydreamjob.job.dto.JobCreateRequest;
import com.mydreamjob.job.dto.JobSearchItem;
import com.mydreamjob.job.dto.JobStatusRequest;
import com.mydreamjob.job.dto.JobUpdateRequest;
import com.mydreamjob.security.CurrentUserAccessor;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@ -35,6 +40,13 @@ public class JobController {
/** R01-A6:requestId 只来自过滤器规范化后的唯一产出点。 */
private final com.mydreamjob.common.logging.RequestIdAccessor requestIdAccessor;
/** 本批 Map 型岗位响应的 200 说明:与静态契约 docs/openapi-rc.json 保持一致。 */
private static final String JOB_MAP_200_DESC =
"OK;data 内岗位展示字段:experience/education ≤40 字符;"
+ "benefits 为字符串数组、对外恒为数组、最多 20 项、单项 ≤40 且去重保序;"
+ "salaryMonths 为整数 1—24(非整数不允许);workLocation ≤255 字符;"
+ "salaryMin/salaryMax 单位仍为元/月。";
private final JobService jobService;
private final CompanyService companyService;
private final CurrentUserAccessor currentUserAccessor;
@ -42,6 +54,8 @@ public class JobController {
// ── 企业端:创建岗位 ────────────────────────────────────────
@Operation(summary = "创建岗位", description = "仅企业账号可调用,新岗位默认 DRAFT 状态")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = JOB_MAP_200_DESC,
content = @Content(schema = @Schema(implementation = ApiResponseJobDetailViewDoc.class)))
@PostMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> createJob(
@Valid @RequestBody JobCreateRequest req,
@ -54,6 +68,8 @@ public class JobController {
// ── 企业端:编辑岗位 ────────────────────────────────────────
@Operation(summary = "更新岗位", description = "仅岗位所属企业可修改")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = JOB_MAP_200_DESC,
content = @Content(schema = @Schema(implementation = ApiResponseJobDetailViewDoc.class)))
@PutMapping("/{jobId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateJob(
@PathVariable Long jobId,
@ -80,6 +96,8 @@ public class JobController {
// ── 企业端:查询岗位详情 ────────────────────────────────────
@Operation(summary = "查询岗位详情", description = "企业可查看自己管理的岗位详情")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = JOB_MAP_200_DESC,
content = @Content(schema = @Schema(implementation = ApiResponseJobDetailViewDoc.class)))
@GetMapping("/{jobId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getJob(
@PathVariable Long jobId,
@ -92,6 +110,8 @@ public class JobController {
// ── 公开端:岗位列表 ────────────────────────────────────────
@Operation(summary = "查询已发布岗位列表", description = "仅返回 PUBLISHED 岗位,支持关键词、城市、用工类型、薪资筛选和分页")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = JOB_MAP_200_DESC,
content = @Content(schema = @Schema(implementation = ApiResponseJobListViewDoc.class)))
@GetMapping
public ResponseEntity<ApiResponse<Map<String, Object>>> listJobs(
@RequestParam(required = false) String keyword,
@ -118,6 +138,8 @@ public class JobController {
// ── 公开端:岗位详情 ────────────────────────────────────────
@Operation(summary = "查询岗位详情(公开)", description = "仅返回 PUBLISHED 岗位详情;CLOSED 也可查看详情")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = JOB_MAP_200_DESC,
content = @Content(schema = @Schema(implementation = ApiResponseJobPublicViewDoc.class)))
@GetMapping("/public/{jobId}")
public ResponseEntity<ApiResponse<Map<String, Object>>> getPublicJob(
@PathVariable Long jobId,

145
mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java

@ -1,5 +1,8 @@
package com.mydreamjob.job;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.company.Company;
@ -39,6 +42,7 @@ public class JobService {
private final JobRepository jobRepository;
private final CompanyRepository companyRepository;
private final ObjectMapper objectMapper;
@PersistenceContext
private EntityManager entityManager;
@ -59,6 +63,11 @@ public class JobService {
.employmentType(req.getEmploymentType())
.salaryMin(req.getSalaryMin())
.salaryMax(req.getSalaryMax())
.experience(normalizeText(req.getExperience()))
.education(normalizeText(req.getEducation()))
.benefitsJson(benefitsToJson(req.getBenefits()))
.salaryMonths(validateSalaryMonths(req.getSalaryMonths()))
.workLocation(normalizeText(req.getWorkLocation()))
.status(JobStatus.DRAFT.name())
.build();
return jobRepository.save(job);
@ -89,6 +98,14 @@ public class JobService {
if (req.getEmploymentType() != null) job.setEmploymentType(req.getEmploymentType());
if (req.getSalaryMin() != null) job.setSalaryMin(req.getSalaryMin());
if (req.getSalaryMax() != null) job.setSalaryMax(req.getSalaryMax());
// 新增字段:null 保持原值;空串/纯空白清空为 NULL;非空去两端空白后保存。
if (req.getExperience() != null) job.setExperience(normalizeText(req.getExperience()));
if (req.getEducation() != null) job.setEducation(normalizeText(req.getEducation()));
if (req.getWorkLocation() != null) job.setWorkLocation(normalizeText(req.getWorkLocation()));
// benefits:null 保持原值;[] 清空;非空校验后序列化保存。
if (req.getBenefits() != null) job.setBenefitsJson(benefitsToJson(req.getBenefits()));
// salaryMonths:null 保持原值(本批不提供显式清空);非 null 必须为 1..24。
if (req.getSalaryMonths() != null) job.setSalaryMonths(validateSalaryMonths(req.getSalaryMonths()));
return jobRepository.save(job);
}
@ -193,7 +210,20 @@ public class JobService {
Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt"));
Page<Job> result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType,
salaryMin, salaryMax, pageable);
return result.map(this::toPublicView);
// 批量查询企业信息后组装,避免逐条查询造成 N+1;空页时不发起企业查询。
List<Job> jobs = result.getContent();
Set<Long> companyIds = jobs.stream()
.map(Job::getCompanyId)
.collect(Collectors.toSet());
Map<Long, Company> companyMap = companyIds.isEmpty()
? Map.of()
: companyRepository.findAllById(companyIds).stream()
.collect(Collectors.toMap(Company::getId, c -> c));
List<Map<String, Object>> items = jobs.stream()
.map(job -> toPublicView(job, companyMap.get(job.getCompanyId())))
.toList();
return new PageImpl<>(items, result.getPageable(), result.getTotalElements());
}
/**
@ -205,7 +235,8 @@ public class JobService {
// DRAFT 岗位对公众不可见
throw new BusinessException(ErrorCode.JOB_NOT_FOUND);
}
return toPublicView(job);
Company company = companyRepository.findById(job.getCompanyId()).orElse(null);
return toPublicView(job, company);
}
// ── 辅助方法 ────────────────────────────────────────────────
@ -232,6 +263,11 @@ public class JobService {
view.put("employmentType", job.getEmploymentType());
view.put("salaryMin", job.getSalaryMin());
view.put("salaryMax", job.getSalaryMax());
view.put("experience", job.getExperience());
view.put("education", job.getEducation());
view.put("benefits", benefitsFromJson(job.getBenefitsJson()));
view.put("salaryMonths", job.getSalaryMonths());
view.put("workLocation", job.getWorkLocation());
view.put("status", job.getStatus());
view.put("publishedAt", job.getPublishedAt());
view.put("createdAt", job.getCreatedAt());
@ -253,12 +289,87 @@ public class JobService {
view.put("employmentType", job.getEmploymentType());
view.put("salaryMin", job.getSalaryMin());
view.put("salaryMax", job.getSalaryMax());
view.put("experience", job.getExperience());
view.put("education", job.getEducation());
view.put("benefits", benefitsFromJson(job.getBenefitsJson()));
view.put("salaryMonths", job.getSalaryMonths());
view.put("workLocation", job.getWorkLocation());
view.put("status", job.getStatus());
view.put("publishedAt", job.getPublishedAt());
view.put("createdAt", job.getCreatedAt());
return view;
}
/**
* 公开视图(含企业摘要)。企业不存在时 company 为 null,不编造数据也不抛异常。
*/
public Map<String, Object> toPublicView(Job job, Company company) {
Map<String, Object> view = toPublicView(job);
view.put("company", toCompanyInfo(company));
return view;
}
// ── 字段归一化与校验 ────────────────────────────────────────
/** 空串/纯空白清空为 null;非空去两端空白后返回;null 原样返回。 */
private static String normalizeText(String value) {
if (value == null) return null;
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
private static final int MAX_BENEFITS = 20;
private static final int MAX_BENEFIT_LENGTH = 40;
/**
* benefits → benefits_json。null/空数组返回 null(清空);非空逐项 trim、拒绝空白项、
* 长度 ≤40、去重保序、最多 20 项,违者 BAD_REQUEST;序列化为 JSON 数组字符串。
*/
private String benefitsToJson(List<String> benefits) {
if (benefits == null || benefits.isEmpty()) return null;
Set<String> unique = new LinkedHashSet<>();
for (String raw : benefits) {
if (raw == null || raw.trim().isEmpty()) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
String value = raw.trim();
if (value.length() > MAX_BENEFIT_LENGTH) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
unique.add(value);
}
List<String> normalized = new ArrayList<>(unique);
if (normalized.size() > MAX_BENEFITS) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
try {
return objectMapper.writeValueAsString(normalized);
} catch (JsonProcessingException exception) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
/** benefits_json → 列表。null 返回空数组;反序列化失败抛 BAD_REQUEST 而非 500。 */
private List<String> benefitsFromJson(String benefitsJson) {
if (benefitsJson == null || benefitsJson.isBlank()) return List.of();
try {
List<String> parsed = objectMapper.readValue(benefitsJson,
new TypeReference<List<String>>() {});
return parsed == null ? List.of() : parsed;
} catch (JsonProcessingException exception) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
/** salaryMonths 非 null 时必须为 1..24 的整数,0 视为非法。 */
private static Integer validateSalaryMonths(Integer salaryMonths) {
if (salaryMonths == null) return null;
if (salaryMonths < 1 || salaryMonths > 24) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
return salaryMonths;
}
// ── 高级搜索 ────────────────────────────────────────────────
private static final Set<String> ALLOWED_SORTS = Set.of("latest", "salary", "relevance");
@ -355,15 +466,6 @@ public class JobService {
}
private JobSearchItem toSearchItem(Job job, Company company) {
JobSearchItem.CompanyInfo info = null;
if (company != null) {
info = JobSearchItem.CompanyInfo.builder()
.id(company.getId())
.name(company.getName())
.logoUrl(company.getLogoUrl())
.verified(company.getVerified())
.build();
}
return JobSearchItem.builder()
.id(job.getId())
.title(job.getTitle())
@ -373,10 +475,29 @@ public class JobService {
.employmentType(job.getEmploymentType())
.salaryMin(job.getSalaryMin())
.salaryMax(job.getSalaryMax())
.experience(job.getExperience())
.education(job.getEducation())
.benefits(benefitsFromJson(job.getBenefitsJson()))
.salaryMonths(job.getSalaryMonths())
.workLocation(job.getWorkLocation())
.status(job.getStatus())
.publishedAt(job.getPublishedAt())
.createdAt(job.getCreatedAt())
.company(info)
.company(toCompanyInfo(company))
.build();
}
/** 公开企业摘要(含 id/name/logoUrl/description/industry/verified/companySize);企业不存在返回 null。 */
private JobSearchItem.CompanyInfo toCompanyInfo(Company company) {
if (company == null) return null;
return JobSearchItem.CompanyInfo.builder()
.id(company.getId())
.name(company.getName())
.logoUrl(company.getLogoUrl())
.description(company.getDescription())
.industry(company.getIndustry())
.verified(company.getVerified())
.companySize(company.getCompanySize())
.build();
}

31
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobDetailViewDoc.java

@ -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;
}

31
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobListViewDoc.java

@ -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;
}

31
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobPublicViewDoc.java

@ -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;
}

35
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java

@ -1,8 +1,14 @@
package com.mydreamjob.job.dto;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.job.Job;
import io.swagger.v3.oas.annotations.media.ArraySchema;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;
/** 企业自身岗位管理列表项,不包含招聘统计、候选人或内部审核数据。 */
@Schema(name = "CompanyManagedJobItem", description = "企业自身岗位管理安全摘要")
@ -16,15 +22,40 @@ public record CompanyManagedJobItem(
@Schema(description = "用工类型") String employmentType,
@Schema(description = "最低月薪,单位元") Integer salaryMin,
@Schema(description = "最高月薪,单位元") Integer salaryMax,
@Schema(description = "经验要求,≤40 字符;无值时为 null", maxLength = 40) String experience,
@Schema(description = "学历要求,≤40 字符;无值时为 null", maxLength = 40) String education,
@ArraySchema(maxItems = 20, schema = @Schema(types = {"string"}, maxLength = 40,
description = "单项长度上限 40 字符"))
@Schema(description = "福利标签;响应端恒为数组(历史无值返回 []),最多 20 项、单项 ≤40 字符、去重保序")
List<String> benefits,
@Schema(description = "薪资月数,整数 1—24;无值时为 null", minimum = "1", maximum = "24")
Integer salaryMonths,
@Schema(description = "工作地点,≤255 字符;无值时为 null", maxLength = 255) String workLocation,
@Schema(description = "岗位状态:DRAFT、PUBLISHED、CLOSED") String status,
@Schema(description = "发布时间") LocalDateTime publishedAt,
@Schema(description = "创建时间") LocalDateTime createdAt,
@Schema(description = "更新时间") LocalDateTime updatedAt) {
/** benefits_json 为 TEXT 保存的 JSON 字符串数组(非 MySQL JSON 列)。 */
private static final ObjectMapper BENEFITS_MAPPER = new ObjectMapper();
public static CompanyManagedJobItem from(Job job) {
return new CompanyManagedJobItem(job.getId(), job.getCompanyId(), job.getTitle(),
job.getDescription(), job.getRequirements(), job.getCity(), job.getEmploymentType(),
job.getSalaryMin(), job.getSalaryMax(), job.getStatus(), job.getPublishedAt(),
job.getCreatedAt(), job.getUpdatedAt());
job.getSalaryMin(), job.getSalaryMax(), job.getExperience(), job.getEducation(),
benefits(job.getBenefitsJson()), job.getSalaryMonths(), job.getWorkLocation(),
job.getStatus(), job.getPublishedAt(), job.getCreatedAt(), job.getUpdatedAt());
}
/** benefits_json 为 null 时对外返回空数组;反序列化失败抛 BAD_REQUEST 而非 500。 */
private static List<String> benefits(String benefitsJson) {
if (benefitsJson == null || benefitsJson.isBlank()) {
return List.of();
}
try {
return BENEFITS_MAPPER.readValue(benefitsJson, new TypeReference<List<String>>() {});
} catch (Exception exception) {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}
}
}

45
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java

@ -1,9 +1,15 @@
package com.mydreamjob.job.dto;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.swagger.v3.oas.annotations.extensions.Extension;
import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 创建/编辑岗位的请求 DTO
*/
@ -27,7 +33,46 @@ public class JobCreateRequest {
@Size(max = 30, message = "employment type must be at most 30 characters")
private String employmentType;
@Schema(description = "最低月薪,单位元/月")
private Integer salaryMin;
@Schema(description = "最高月薪,单位元/月")
private Integer salaryMax;
@Size(max = 40, message = "experience must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "经验要求,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 40 字符拒绝且不截断。")
private String experience;
@Size(max = 40, message = "education must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "学历要求,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 40 字符拒绝且不截断。")
private String education;
@Schema(types = {"array", "null"}, extensions = @Extension(name = "x-normalization", properties = {
@ExtensionProperty(name = "trimItems", value = "true", parseValue = true),
@ExtensionProperty(name = "deduplicate", value = "true", parseValue = true),
@ExtensionProperty(name = "rejectBlankItems", value = "true", parseValue = true),
@ExtensionProperty(name = "maxItems", value = "20", parseValue = true),
@ExtensionProperty(name = "itemMaxLength", value = "40", parseValue = true)}),
description = "福利标签:字符串数组,最多 20 项(在去重并去除首尾空白之后计算)、trim后单项 ≤40 字符;"
+ "null 或缺省:创建时不设置(存 NULL);传 [] 表示清空;非空数组会逐项 trim、拒绝空白项、"
+ "去重保序、超 20 项或单项超 40 字符一律 400。")
private List<String> benefits;
/** 薪资月数:只接受 JSON 整数,范围 1—24;小数/字符串/布尔等一律 400(见 StrictIntegerDeserializer)。 */
@Schema(types = {"integer", "null"},
description = "薪资月数;仅接受 JSON 整数 1—24,非整数(小数/字符串/布尔等)一律 400;"
+ "缺省/null 表示不设置(创建存 NULL)。",
minimum = "1", maximum = "24")
@JsonDeserialize(using = StrictIntegerDeserializer.class)
private Integer salaryMonths;
@Size(max = 255, message = "work location must be at most 255 characters")
@Schema(types = {"string", "null"}, maxLength = 255,
description = "工作地点,≤255 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 255 字符拒绝且不截断。")
private String workLocation;
}

73
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java

@ -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;
}

38
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java

@ -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;
}

31
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobListViewDoc.java

@ -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;
}

73
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java

@ -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;
}

9
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java

@ -7,6 +7,7 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.util.List;
/**
* 公开岗位搜索项 DTO。
@ -27,6 +28,11 @@ public class JobSearchItem {
private String employmentType;
private Integer salaryMin;
private Integer salaryMax;
private String experience;
private String education;
private List<String> benefits;
private Integer salaryMonths;
private String workLocation;
private String status;
private LocalDateTime publishedAt;
private LocalDateTime createdAt;
@ -42,6 +48,9 @@ public class JobSearchItem {
private Long id;
private String name;
private String logoUrl;
private String description;
private String industry;
private Boolean verified;
private String companySize;
}
}

46
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java

@ -1,9 +1,14 @@
package com.mydreamjob.job.dto;
import jakarta.validation.constraints.NotBlank;
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
import io.swagger.v3.oas.annotations.extensions.Extension;
import io.swagger.v3.oas.annotations.extensions.ExtensionProperty;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.util.List;
/**
* 更新岗位(部分字段)的请求 DTO
*/
@ -23,7 +28,46 @@ public class JobUpdateRequest {
@Size(max = 30, message = "employment type must be at most 30 characters")
private String employmentType;
@Schema(description = "最低月薪,单位元/月")
private Integer salaryMin;
@Schema(description = "最高月薪,单位元/月")
private Integer salaryMax;
@Size(max = 40, message = "experience must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "经验要求,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 40 字符拒绝且不截断。")
private String experience;
@Size(max = 40, message = "education must be at most 40 characters")
@Schema(types = {"string", "null"}, maxLength = 40,
description = "学历要求,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 40 字符拒绝且不截断。")
private String education;
@Schema(types = {"array", "null"}, extensions = @Extension(name = "x-normalization", properties = {
@ExtensionProperty(name = "trimItems", value = "true", parseValue = true),
@ExtensionProperty(name = "deduplicate", value = "true", parseValue = true),
@ExtensionProperty(name = "rejectBlankItems", value = "true", parseValue = true),
@ExtensionProperty(name = "maxItems", value = "20", parseValue = true),
@ExtensionProperty(name = "itemMaxLength", value = "40", parseValue = true)}),
description = "福利标签:字符串数组,最多 20 项(在去重并去除首尾空白之后计算)、trim后单项 ≤40 字符;"
+ "null 或缺省:更新保持原值;传 [] 表示清空;非空数组会逐项 trim、拒绝空白项、"
+ "去重保序、超 20 项或单项超 40 字符一律 400。")
private List<String> benefits;
/** 薪资月数:只接受 JSON 整数,范围 1—24;null/缺省保持原值(见 StrictIntegerDeserializer)。 */
@Schema(types = {"integer", "null"},
description = "薪资月数;仅接受 JSON 整数 1—24,非整数(小数/字符串/布尔等)一律 400;"
+ "缺省/null 表示保持原值。",
minimum = "1", maximum = "24")
@JsonDeserialize(using = StrictIntegerDeserializer.class)
private Integer salaryMonths;
@Size(max = 255, message = "work location must be at most 255 characters")
@Schema(types = {"string", "null"}, maxLength = 255,
description = "工作地点,≤255 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;"
+ "非空去两端空白后保存;超过 255 字符拒绝且不截断。")
private String workLocation;
}

45
mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictIntegerDeserializer.java

@ -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();
}
}

4
mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java

@ -6,6 +6,7 @@ import com.mydreamjob.company.*;
import com.mydreamjob.job.JobRepository;
import com.mydreamjob.profile.*;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.profile.storage.ResumeOwnershipVerifier;
import com.mydreamjob.common.exception.*;
import com.mydreamjob.messaging.dto.*;
import lombok.RequiredArgsConstructor;
@ -22,6 +23,7 @@ public class ChatResumeService {
private final ChatResumeSnapshotRepository snapshots;
private final StudentProfileRepository profiles;
private final ResumeStorageService storage;
private final ResumeOwnershipVerifier resumeOwnership;
private final ApplicationRepository applications;
private final JobRepository jobs;
private final CompanyRepository companies;
@ -60,6 +62,8 @@ public class ChatResumeService {
}
if (profile.getResumeUrl() == null || profile.getResumeUrl().isBlank())
throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND);
// 在复制快照前验证源文件属于发送者,防止历史污染引用被洗成合法快照。
resumeOwnership.assertOwnedBy(profile.getResumeUrl(), userId);
byte[] bytes = storage.read(profile.getResumeUrl());
if (bytes.length == 0 || bytes.length > 10 * 1024 * 1024)
throw new BusinessException(ErrorCode.BAD_REQUEST,"invalid resume size");

9
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java

@ -89,7 +89,14 @@ public class ProfileController {
return ResponseEntity.ok(ApiResponse.ok(response, requestIdAccessor.get(request)));
}
@Operation(summary = "更新学生资料", description = "仅更新传入的非 null 字段;不存在则自动创建")
@Operation(
summary = "更新学生资料",
description = "仅更新传入的非 null 字段;不存在则自动创建。"
+ "角色:仅 STUDENT(COMPANY/ADMIN 返回 403)。"
+ "安全约束:请求体中的 resumeUrl 为只读字段,客户端不得设置——"
+ "缺省或显式 null 表示不修改现有简历引用;"
+ "传入任意非 null 值(含空字符串)整次请求返回 400,且不产生任何部分更新。"
+ "简历引用只能由上传简历、简历版本等受控接口生成与绑定。")
@PutMapping("/me")
public ResponseEntity<ApiResponse<Map<String, Object>>> updateMyProfile(
@Valid @RequestBody StudentProfileUpdateRequest req,

34
mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java

@ -8,6 +8,7 @@ import com.mydreamjob.profile.dto.StudentProfileResponse;
import com.mydreamjob.profile.dto.StudentProfileUpdateRequest;
import com.mydreamjob.profile.storage.ResumeFileValidator;
import com.mydreamjob.profile.storage.ResumeFilenameSanitizer;
import com.mydreamjob.profile.storage.ResumeOwnershipVerifier;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
@ -35,6 +36,7 @@ public class ProfileService {
private final com.mydreamjob.security.UserRoleGuard userRoleGuard;
private final StudentProfileRepository profileRepository;
private final ResumeStorageService resumeStorageService;
private final ResumeOwnershipVerifier resumeOwnershipVerifier;
private final UserRepository userRepository;
private final ResumeFileValidator resumeFileValidator;
private final com.mydreamjob.messaging.ChatResumeLifecycle chatResumeLifecycle;
@ -57,6 +59,16 @@ public class ProfileService {
@Transactional
public StudentProfile updateProfile(Long userId, StudentProfileUpdateRequest req) {
// 补齐求职者角色校验:非 STUDENT(COMPANY/ADMIN/未选择身份)不得借资料更新流程
// 创建或篡改学生资料(此前 PUT /profile/me 缺少角色校验)。
assertStudentRole(userId);
// 简历归属加固:简历引用只能由受控上传 / 版本 / 既有合法业务流程生成与绑定,
// 普通资料写入不得携带 resumeUrl。缺省或显式 null 视为“不修改简历引用”(保持兼容),
// 任意非 null 值(含空串)整次拒绝、绝不部分更新。
if (req.getResumeUrl() != null) {
throw new BusinessException(ErrorCode.BAD_REQUEST,
"resumeUrl cannot be modified through profile update");
}
StudentProfile profile = profileRepository.findByUserId(userId)
.orElse(StudentProfile.builder().userId(userId).build());
if (req.getRealName() != null) profile.setRealName(req.getRealName());
@ -65,7 +77,6 @@ public class ProfileService {
if (req.getEducation() != null) profile.setEducation(req.getEducation());
if (req.getSchool() != null) profile.setSchool(req.getSchool());
if (req.getGraduationYear() != null) profile.setGraduationYear(req.getGraduationYear().shortValue());
if (req.getResumeUrl() != null) profile.setResumeUrl(req.getResumeUrl());
if (req.getSkills() != null) profile.setSkillsJson(toValidJson(req.getSkills()));
StudentProfile saved = profileRepository.save(profile);
// R01-D4-1:资料(含学历/学校/技能/简历链接)变化影响 8 维评分 → 登记当前周期刷新。
@ -103,6 +114,10 @@ public class ProfileService {
.orElse(StudentProfile.builder().userId(userId).build());
String oldPath = profile.getResumeUrl();
if (oldPath != null && !oldPath.isBlank()) {
// 替换前先核验旧文件归属:非法旧引用直接拒绝,不改资料、不删他人文件、不留新文件。
resumeOwnershipVerifier.assertOwnedBy(oldPath, userId);
}
String newPath = resumeStorageService.store(userId, file);
if (newPath == null || newPath.isBlank()) {
throw new IllegalStateException("resume storage returned no path");
@ -133,10 +148,14 @@ public class ProfileService {
assertStudentRole(userId);
StudentProfile profile = profileRepository.findByUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND));
if (profile.getResumeUrl() == null) {
String storagePath = profile.getResumeUrl();
if (storagePath == null) {
throw new BusinessException(ErrorCode.PROFILE_NOT_FOUND);
}
byte[] content = resumeStorageService.read(profile.getResumeUrl());
// 归属校验:本人资料的简历引用必须指向属于本人的存储键;否则拒绝,绝不读取文件,
// 也不回显磁盘路径 / 存储键 / 其他用户信息。
resumeOwnershipVerifier.assertOwnedBy(storagePath, userId);
byte[] content = resumeStorageService.read(storagePath);
String downloadName = profile.getResumeOriginalName() != null
? profile.getResumeOriginalName() : "resume";
return new ResumeFile(downloadName, profile.getResumeContentType(), content);
@ -149,9 +168,14 @@ public class ProfileService {
assertStudentRole(userId);
StudentProfile profile = profileRepository.lockByUserId(userId)
.orElseThrow(() -> new BusinessException(ErrorCode.PROFILE_NOT_FOUND));
String storagePath = profile.getResumeUrl();
if (storagePath != null) {
// 归属校验必须在清空资料、撤销聊天授权/快照、删除文件之前完成:
// 校验失败时资料、快照与文件都不变,更不得因为本人资料误引用他人文件而删除该文件。
resumeOwnershipVerifier.assertOwnedBy(storagePath, userId);
}
chatResumeLifecycle.purgeForUser(userId);
if (profile.getResumeUrl() != null) {
String storagePath = profile.getResumeUrl();
if (storagePath != null) {
profile.setResumeUrl(null);
profile.setResumeOriginalName(null);
profile.setResumeContentType(null);

7
mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java

@ -27,6 +27,13 @@ public class StudentProfileUpdateRequest {
private Integer graduationYear;
/**
* 简历引用(服务端生成的存储键)。
*
* <p><strong>只读字段</strong>:不接受客户端写入。缺省或显式 {@code null} 表示“不修改现有简历引用”;
* 传入任意非 {@code null} 值(含空字符串)时 {@code PUT /profile/me} 整次返回 400,且不产生部分更新。
* 简历引用只能由上传简历 / 简历版本等受控接口生成与绑定,禁止由客户端提供(避免引用他人文件)。</p>
*/
@Size(max = 500, message = "resume url must be at most 500 characters")
private String resumeUrl;

66
mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java

@ -2,6 +2,11 @@ package com.mydreamjob.profile.storage;
import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import java.io.ByteArrayOutputStream;
import java.nio.ByteBuffer;
import java.nio.charset.CharacterCodingException;
import java.nio.charset.CodingErrorAction;
import java.nio.charset.StandardCharsets;
/** 文件名白名单处理:阻止路径字符、控制字符和响应头换行进入存储元数据或下载响应。 */
public final class ResumeFilenameSanitizer {
@ -12,6 +17,9 @@ public final class ResumeFilenameSanitizer {
}
public static void validateUploadName(String filename) {
// 编码后的中文可能占 9 个 ASCII 字符;最终长度仍按解码后的名称限制。
if (filename != null && filename.length() > MAX_FILENAME_LENGTH * 9) reject();
filename = decodeEncodedUnicodeName(filename);
if (filename == null || filename.isBlank() || filename.length() > MAX_FILENAME_LENGTH) {
reject();
}
@ -35,6 +43,14 @@ public final class ResumeFilenameSanitizer {
if (filename == null || filename.isBlank()) {
return safeFallback;
}
// 历史记录兼容:仅恢复可明确识别的 UTF-8 编码;失败保留原文后再净化。
if (filename.length() <= MAX_FILENAME_LENGTH * 9) {
try {
filename = decodeEncodedUnicodeName(filename);
} catch (BusinessException ignored) {
// 不猜测非法 UTF-8 或递归编码,绝不回显解码异常中的原始内容。
}
}
StringBuilder safe = new StringBuilder(Math.min(filename.length(), MAX_FILENAME_LENGTH));
for (int i = 0; i < filename.length() && safe.length() < MAX_FILENAME_LENGTH; i++) {
char c = filename.charAt(i);
@ -48,6 +64,56 @@ public final class ResumeFilenameSanitizer {
return result.isEmpty() || ".".equals(result) || "..".equals(result) ? safeFallback : result;
}
/**
* 保守识别 multipart 编码:只有含非 ASCII 字节的 %HH 序列才尝试一次 UTF-8 解码。
* 纯 ASCII 的 %20 等存在字面量歧义,原样保留;不使用 URLDecoder,避免把 + 改成空格。
* 拒绝仍包含编码非 ASCII 字节的结果,确保多次净化不会逐层解码。
*/
private static String decodeEncodedUnicodeName(String name) {
if (name == null || !containsEncodedNonAscii(name)) return name;
StringBuilder decoded = new StringBuilder();
for (int i = 0; i < name.length();) {
if (name.charAt(i) != '%') {
decoded.append(name.charAt(i++));
continue;
}
ByteArrayOutputStream bytes = new ByteArrayOutputStream();
while (i < name.length() && name.charAt(i) == '%') {
int value = escapedByte(name, i);
if (value < 0) reject();
bytes.write(value);
i += 3;
}
try {
decoded.append(StandardCharsets.UTF_8.newDecoder()
.onMalformedInput(CodingErrorAction.REPORT)
.onUnmappableCharacter(CodingErrorAction.REPORT)
.decode(ByteBuffer.wrap(bytes.toByteArray())));
} catch (CharacterCodingException ex) {
reject();
}
}
String result = decoded.toString();
if (containsEncodedNonAscii(result)) reject();
return result;
}
private static boolean containsEncodedNonAscii(String name) {
for (int i = 0; i < name.length(); i++) {
if (name.charAt(i) == '%' && escapedByte(name, i) >= 128) return true;
}
return false;
}
private static int escapedByte(String name, int offset) {
if (offset + 2 >= name.length()) return -1;
int hi = Character.digit(name.charAt(offset + 1), 16);
int lo = Character.digit(name.charAt(offset + 2), 16);
// 仅接受 ASCII 十六进制,不能把全角字符等当成协议转义。
if (name.charAt(offset + 1) > 127 || name.charAt(offset + 2) > 127 || hi < 0 || lo < 0) return -1;
return hi * 16 + lo;
}
private static void reject() {
throw new BusinessException(ErrorCode.BAD_REQUEST);
}

107
mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifier.java

@ -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);
}
}
}

9
mydreamjob-server/src/main/resources/db/migration/V57__job_company_display_fields.sql

@ -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;

41
mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java

@ -377,8 +377,49 @@ class CompanyResumeDownloadIntegrationTest {
assertThat(perform(companyToken())).as("授权通过但无简历应 404").isEqualTo(404);
}
@Test
void historicalForeignResumeIsDeniedBeforeStorageRead() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
setStatus("SUBMITTED");
String foreignKey = resumeStorageService.store(otherCompanyUserId,
new MockMultipartFile("file", "foreign.pdf", "application/pdf",
"%PDF-1.4 synthetic foreign content".getBytes(StandardCharsets.UTF_8)));
try {
StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow();
profile.setResumeUrl(foreignKey);
profileRepository.saveAndFlush(profile);
READ_CALLS.set(0);
var response = mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andExpect(status().isForbidden()).andReturn().getResponse();
assertThat(response.getContentAsString()).contains("\"code\":2002")
.doesNotContain(foreignKey).doesNotContain("synthetic foreign content");
assertThat(READ_CALLS.get()).isZero();
} finally {
resumeStorageService.delete(foreignKey);
}
}
// ── 响应脱敏 ───────────────────────────────────────────────
@Test
void legacyEncodedNameIsDecodedOnceForAuthorizedCompanyDownload() throws Exception {
certifyCompanyForResumePolicyTest();
setVisibility(ResumeVisibility.APPLIED_COMPANIES);
setStatus("SUBMITTED");
String encoded = "%E5%BC%A0%E4%B8%89%20A%2B100%25.pdf";
StudentProfile profile = profileRepository.findByUserId(studentUserId).orElseThrow();
profile.setResumeOriginalName(encoded);
profileRepository.saveAndFlush(profile);
var response = mockMvc.perform(get(url()).header("Authorization", companyToken()))
.andExpect(status().isOk()).andReturn().getResponse();
String header = response.getHeader("Content-Disposition");
assertThat(header).contains("filename*=UTF-8''%E5%BC%A0").doesNotContain("%25E5%25BC%25A0");
assertThat(org.springframework.http.ContentDisposition.parse(header).getFilename()).isEqualTo("张三 A+100%.pdf");
assertThat(response.getContentAsByteArray()).isEqualTo("%PDF-1.4 s03 content".getBytes(StandardCharsets.UTF_8));
assertThat(profileRepository.findByUserId(studentUserId).orElseThrow().getResumeOriginalName()).isEqualTo(encoded);
}
@Test
void responseDoesNotLeakPathOrIdentity() throws Exception {
certifyCompanyForResumePolicyTest();

49
mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java

@ -8,7 +8,11 @@ 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.context.TestConfiguration;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Primary;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.annotation.DirtiesContext;
@ -20,7 +24,9 @@ import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
@ -39,9 +45,23 @@ import static org.mockito.Mockito.when;
@Testcontainers(disabledWithoutDocker = true)
@SpringBootTest
@ActiveProfiles("mysql-it")
@Import(CandidateScoringRefreshMySqlIntegrationTest.FixedTimeConfiguration.class)
@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS)
class CandidateScoringRefreshMySqlIntegrationTest {
private static final ZoneId TEST_ZONE = ZoneId.of("Asia/Shanghai");
private static final LocalDateTime TEST_NOW = LocalDateTime.of(2026, 9, 16, 12, 0);
/** 只在本测试上下文固定业务时钟,避免测试数据随机器日期推移而过期。 */
@TestConfiguration(proxyBeanMethods = false)
static class FixedTimeConfiguration {
@Bean
@Primary
Clock candidateRefreshTestClock() {
return Clock.fixed(TEST_NOW.atZone(TEST_ZONE).toInstant(), TEST_ZONE);
}
}
@Container
static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4")
.withDatabaseName("mydreamjob_r01_d4")
@ -64,12 +84,13 @@ class CandidateScoringRefreshMySqlIntegrationTest {
@Autowired private CandidateDimensionScoreRepository snapshotRepository;
@Autowired private UserRepository userRepository;
@Autowired private JdbcTemplate jdbcTemplate;
@Autowired private Clock clock;
@MockBean private CandidateScoringCalculator calculator;
@MockBean private RedisTemplate<String, Object> redisTemplate;
private Long failingStudent;
private Long successfulStudent;
private final LocalDateTime now = LocalDateTime.of(2026, 9, 16, 12, 0);
private final LocalDateTime now = TEST_NOW;
@BeforeEach
void setUp() {
@ -206,6 +227,7 @@ class CandidateScoringRefreshMySqlIntegrationTest {
@Test
void mysqlCompensationCursorPersistsAndHonorsPerRunBudget() {
assertEquals(now, LocalDateTime.now(clock), "扫描器与测试数据必须使用同一业务时钟");
List<Long> students = new ArrayList<>();
for (int i = 0; i < 5; i++) {
Long id = student("mysql-scan-" + i);
@ -233,6 +255,31 @@ class CandidateScoringRefreshMySqlIntegrationTest {
assertEquals(5L, registered);
}
@Test
void mysqlCompensationAuditWindowIncludesBoundaryButExcludesExpiredActivity() {
Long recent = student("mysql-recent-audit");
Long boundary = student("mysql-boundary-audit");
Long expired = student("mysql-expired-audit");
jdbcTemplate.update("INSERT INTO audit_logs(user_id,action,created_at) VALUES (?,'LOGIN',?)",
recent, now.minusDays(29));
jdbcTemplate.update("INSERT INTO audit_logs(user_id,action,created_at) VALUES (?,'LOGIN',?)",
boundary, now.minusDays(30));
jdbcTemplate.update("INSERT INTO audit_logs(user_id,action,created_at) VALUES (?,'LOGIN',?)",
expired, now.minusDays(30).minusSeconds(1));
jdbcTemplate.update("""
INSERT INTO candidate_scoring_refresh_scan_checkpoints
(scan_key,last_user_id,version,updated_at)
VALUES ('ACTIVE_STUDENTS',?,0,CURRENT_TIMESTAMP)
""", recent - 1L);
assertEquals(2, compensationScanner.scanDueStudents(10));
List<Long> registered = jdbcTemplate.queryForList("""
SELECT student_user_id FROM candidate_scoring_refresh_tasks
WHERE student_user_id IN (?,?,?) ORDER BY student_user_id
""", Long.class, recent, boundary, expired);
assertEquals(List.of(recent, boundary), registered);
}
private Long student(String nickname) {
User user = User.builder()
.phone("131" + String.format("%08d", Math.abs(System.nanoTime() % 100_000_000L)))

242
mydreamjob-server/src/test/java/com/mydreamjob/company/CompanySizeDisplayIntegrationTest.java

@ -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;
}
}

200
mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java

@ -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;
}
}
}

95
mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlValidateTest.java

@ -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());
}
}

167
mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsAuthorizationIntegrationTest.java

@ -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) {}
}

334
mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java

@ -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;
}
}

362
mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsIntegrationTest.java

@ -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());
}
}

135
mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsQueryBudgetIntegrationTest.java

@ -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);
}
}

157
mydreamjob-server/src/test/java/com/mydreamjob/job/JobListCompanySummaryQueryBudgetTest.java

@ -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;
}
}

330
mydreamjob-server/src/test/java/com/mydreamjob/job/JobSalaryMonthsHttpBindingTest.java

@ -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;
}
}

15
mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java

@ -50,6 +50,9 @@ class ChatWorkflowIntegrationTest {
Conversation conversation;
StudentProfile profile;
StudentPrivacySettings privacy;
/** 简历归属加固后,本人资料的简历引用必须符合服务端生成格式 <userId>_<随机段>.ext。 */
String originalKey;
String replacementKey;
@BeforeEach void fixture() {
student=user("候选人昵称",User.Role.STUDENT);
@ -67,12 +70,14 @@ class ChatWorkflowIntegrationTest {
conversation=Conversation.builder().type("DIRECT").createdBy(student.getId()).title("测试").build(); em.persist(conversation);
for(User u:List.of(student,employer)) em.persist(ConversationMember.builder()
.conversationId(conversation.getId()).userId(u.getId()).build());
profile=StudentProfile.builder().userId(student.getId()).resumeUrl("/controlled/original")
originalKey=student.getId()+"_original01.pdf";
replacementKey=student.getId()+"_replace001.pdf";
profile=StudentProfile.builder().userId(student.getId()).resumeUrl(originalKey)
.resumeOriginalName("含实名的简历.pdf").resumeContentType("application/pdf").resumeSize(8L).build();em.persist(profile);
privacy=StudentPrivacySettings.builder().studentUserId(student.getId())
.resumeVisibility(ResumeVisibility.APPLIED_COMPANIES).build();em.persist(privacy);
em.flush();
when(storage.read(anyString())).thenAnswer(call -> call.getArgument(0).equals("/controlled/original")
when(storage.read(anyString())).thenAnswer(call -> call.getArgument(0).equals(originalKey)
? new byte[]{1,2,3} : new byte[]{8,9});
}
private User user(String name,User.Role role) {
@ -87,8 +92,8 @@ class ChatWorkflowIntegrationTest {
@Test void immutableSnapshotRetryAndReplayKeepOriginalBytes() {
var first=send("snapshot-1");
assertEquals("RESUME",first.getMessageType());assertEquals("resume.pdf",first.getResume().fileName());
assertFalse(first.getResume().downloadPath().contains("controlled"));
profile.setResumeUrl("/controlled/replacement");em.flush();
assertFalse(first.getResume().downloadPath().contains(originalKey));
profile.setResumeUrl(replacementKey);em.flush();
assertArrayEquals(new byte[]{1,2,3},resumes.download(conversation.getId(),first.getId(),employer.getId()).bytes());
assertEquals(first.getId(),send("snapshot-1").getId());
var second=send("snapshot-2");
@ -113,7 +118,7 @@ class ChatWorkflowIntegrationTest {
}
@Test void deletingSourcePermanentlyRevokesPreviouslySentSnapshot() {
var sent=send("delete-1");profiles.deleteResume(student.getId());
profile.setResumeUrl("/controlled/new");em.flush();
profile.setResumeUrl(student.getId()+"_newfile0001.pdf");em.flush();
denied(()->resumes.download(conversation.getId(),sent.getId(),employer.getId()),8309);
assertNull(snapshots.findById(sent.getId()).orElseThrow().getContent());
}

77
mydreamjob-server/src/test/java/com/mydreamjob/messaging/HistoricalResumeOwnershipIntegrationTest.java

@ -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());
}
}

7
mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java

@ -24,6 +24,13 @@ class ProfileServiceTest {
@Mock
private com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService scoringRefreshRequestService;
/**
* 简历归属加固后 {@code updateProfile} 补齐了 STUDENT 角色校验;
* 该依赖在 @InjectMocks 下必须以 mock 形式提供,本类字段级用例不校验角色,使用裸 mock 即可。
*/
@Mock
private com.mydreamjob.security.UserRoleGuard userRoleGuard;
@InjectMocks
private ProfileService profileService;

558
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipHttpIntegrationTest.java

@ -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)), "合法删除应移除文件");
}
}

245
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java

@ -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);
}
}

25
mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java

@ -4,6 +4,7 @@ import com.mydreamjob.common.exception.BusinessException;
import com.mydreamjob.common.exception.ErrorCode;
import com.mydreamjob.profile.storage.ResumeStorageService;
import com.mydreamjob.profile.storage.ResumeFileValidator;
import com.mydreamjob.profile.storage.ResumeOwnershipVerifier;
import com.mydreamjob.auth.User;
import com.mydreamjob.auth.UserRepository;
import com.mydreamjob.security.UserRoleGuard;
@ -39,6 +40,10 @@ class ResumeServiceTest {
@Spy
private ResumeFileValidator resumeFileValidator = new ResumeFileValidator();
/** 简历归属加固:使用真实校验器(纯字符串解析,无需 mock)。 */
@Spy
private ResumeOwnershipVerifier resumeOwnershipVerifier = new ResumeOwnershipVerifier();
/** R01-B/B1:角色授权已委托给公共 guard;此处以委托桩保持既有断言语义。 */
@Mock
private UserRoleGuard userRoleGuard;
@ -49,6 +54,12 @@ class ResumeServiceTest {
private static final Long STUDENT_ID = 100L;
private static final Long COMPANY_ID = 10L;
/**
* 旧简历引用必须符合服务端生成格式 {@code <userId>_<随机段>.ext},
* 否则会被归属校验判为非法(失败关闭)。历史夹具用的 {@code old_resume.pdf} 并非服务端产物。
*/
private static final String OLD_KEY = STUDENT_ID + "_old00001.pdf";
private StudentProfile profile;
@BeforeEach
@ -58,7 +69,7 @@ class ResumeServiceTest {
profile = StudentProfile.builder()
.id(1L).userId(STUDENT_ID).realName("张三")
.resumeUrl("old_resume.pdf")
.resumeUrl(OLD_KEY)
.resumeOriginalName("my_resume.pdf")
.resumeContentType("application/pdf")
.resumeSize(1024L)
@ -96,7 +107,7 @@ class ResumeServiceTest {
assertEquals("new_resume.pdf", result.get("resumeUrl"));
assertEquals("resume.pdf", result.get("fileName"));
assertEquals("application/pdf", result.get("contentType"));
verify(storageService, times(1)).delete("old_resume.pdf");
verify(storageService, times(1)).delete(OLD_KEY);
}
@Test
@ -196,7 +207,7 @@ class ResumeServiceTest {
assertThrows(RuntimeException.class, () -> profileService.uploadResume(STUDENT_ID, file));
verify(storageService, never()).delete("old_resume.pdf");
verify(storageService, never()).delete(OLD_KEY);
verify(profileRepository, never()).save(any());
}
@ -212,7 +223,7 @@ class ResumeServiceTest {
assertThrows(RuntimeException.class, () -> profileService.uploadResume(STUDENT_ID, file));
verify(storageService).delete("new_resume.pdf");
verify(storageService, never()).delete("old_resume.pdf");
verify(storageService, never()).delete(OLD_KEY);
}
@Test
@ -223,7 +234,7 @@ class ResumeServiceTest {
assertThrows(RuntimeException.class, () -> profileService.deleteResume(STUDENT_ID));
verify(storageService, never()).delete("old_resume.pdf");
verify(storageService, never()).delete(OLD_KEY);
}
// ── 获取 / 删除 ──────────────────────────────────────────
@ -232,7 +243,7 @@ class ResumeServiceTest {
void getResume_shouldReturnFile() {
mockStudentRole();
when(profileRepository.findByUserId(STUDENT_ID)).thenReturn(Optional.of(profile));
when(storageService.read("old_resume.pdf")).thenReturn("content".getBytes());
when(storageService.read(OLD_KEY)).thenReturn("content".getBytes());
ProfileService.ResumeFile rf = profileService.getResume(STUDENT_ID);
@ -248,7 +259,7 @@ class ResumeServiceTest {
profileService.deleteResume(STUDENT_ID);
verify(storageService, times(1)).delete("old_resume.pdf");
verify(storageService, times(1)).delete(OLD_KEY);
assertNull(profile.getResumeUrl());
assertNull(profile.getResumeOriginalName());
assertNull(profile.getResumeContentType());

51
mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFilenameEncodingTest.java

@ -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"));
}
}

127
mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifierTest.java

@ -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…
Cancel
Save