From 91cbf6cd4d74ebe3bade0811915bcefc3e7fa00b Mon Sep 17 00:00:00 2001 From: Zhang Shun Date: Mon, 19 Oct 2026 11:04:48 +0800 Subject: [PATCH] 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. --- ...date_job_company_display_fields_openapi.py | 103 +++ .../b01_blackbox.py | 354 +++++++ .../b01_contract_negative_tests.py | 140 +++ .../b01_openapi_compare.py | 249 +++++ ...ompany-display-fields-frontend-handover.md | 266 ++++++ ...batch-job-company-display-fields-report.md | 115 +++ docs/openapi-rc.json | 836 +++++++++++++++-- docs/resume-ownership-hardening-20261016.md | 129 +++ docs/tools/b01_demo_display_data.py | 866 ++++++++++++++++++ docs/tools/b01_demo_display_data_test.py | 594 ++++++++++++ docs/verify_openapi.py | 65 ++ .../application/CompanyResumeService.java | 4 + .../java/com/mydreamjob/company/Company.java | 3 + .../mydreamjob/company/CompanyService.java | 34 + .../company/dto/CompanyCreateRequest.java | 8 + .../company/dto/CompanyPublicView.java | 19 +- .../company/dto/CompanyUpdateRequest.java | 7 + .../src/main/java/com/mydreamjob/job/Job.java | 15 + .../com/mydreamjob/job/JobController.java | 22 + .../java/com/mydreamjob/job/JobService.java | 145 ++- .../job/dto/ApiResponseJobDetailViewDoc.java | 31 + .../job/dto/ApiResponseJobListViewDoc.java | 31 + .../job/dto/ApiResponseJobPublicViewDoc.java | 31 + .../job/dto/CompanyManagedJobItem.java | 35 +- .../mydreamjob/job/dto/JobCreateRequest.java | 45 + .../mydreamjob/job/dto/JobDetailViewDoc.java | 73 ++ .../job/dto/JobDocCompanySummary.java | 38 + .../mydreamjob/job/dto/JobListViewDoc.java | 31 + .../mydreamjob/job/dto/JobPublicViewDoc.java | 73 ++ .../com/mydreamjob/job/dto/JobSearchItem.java | 9 + .../mydreamjob/job/dto/JobUpdateRequest.java | 46 +- .../job/dto/StrictIntegerDeserializer.java | 45 + .../messaging/ChatResumeService.java | 4 + .../mydreamjob/profile/ProfileController.java | 9 +- .../mydreamjob/profile/ProfileService.java | 34 +- .../dto/StudentProfileUpdateRequest.java | 7 + .../storage/ResumeFilenameSanitizer.java | 66 ++ .../storage/ResumeOwnershipVerifier.java | 107 +++ .../V57__job_company_display_fields.sql | 9 + .../CompanyResumeDownloadIntegrationTest.java | 41 + ...ateScoringRefreshMySqlIntegrationTest.java | 49 +- .../CompanySizeDisplayIntegrationTest.java | 242 +++++ ...ompanyDisplayFieldsMysqlMigrationTest.java | 200 ++++ ...CompanyDisplayFieldsMysqlValidateTest.java | 95 ++ ...layFieldsAuthorizationIntegrationTest.java | 167 ++++ ...splayFieldsConsistencyIntegrationTest.java | 334 +++++++ .../job/JobDisplayFieldsIntegrationTest.java | 362 ++++++++ ...splayFieldsQueryBudgetIntegrationTest.java | 135 +++ .../JobListCompanySummaryQueryBudgetTest.java | 157 ++++ .../job/JobSalaryMonthsHttpBindingTest.java | 330 +++++++ .../ChatWorkflowIntegrationTest.java | 15 +- ...toricalResumeOwnershipIntegrationTest.java | 77 ++ .../profile/ProfileServiceTest.java | 7 + .../ResumeOwnershipHttpIntegrationTest.java | 558 +++++++++++ .../profile/ResumeOwnershipServiceTest.java | 245 +++++ .../mydreamjob/profile/ResumeServiceTest.java | 25 +- .../storage/ResumeFilenameEncodingTest.java | 51 ++ .../storage/ResumeOwnershipVerifierTest.java | 127 +++ 58 files changed, 7810 insertions(+), 105 deletions(-) create mode 100644 docs/_update_job_company_display_fields_openapi.py create mode 100644 docs/acceptance/batch-display-fields-20261016/b01_blackbox.py create mode 100644 docs/acceptance/batch-display-fields-20261016/b01_contract_negative_tests.py create mode 100644 docs/acceptance/batch-display-fields-20261016/b01_openapi_compare.py create mode 100644 docs/batch-job-company-display-fields-frontend-handover.md create mode 100644 docs/batch-job-company-display-fields-report.md create mode 100644 docs/resume-ownership-hardening-20261016.md create mode 100644 docs/tools/b01_demo_display_data.py create mode 100644 docs/tools/b01_demo_display_data_test.py create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobDetailViewDoc.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobListViewDoc.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobPublicViewDoc.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobListViewDoc.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictIntegerDeserializer.java create mode 100644 mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifier.java create mode 100644 mydreamjob-server/src/main/resources/db/migration/V57__job_company_display_fields.sql create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/company/CompanySizeDisplayIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlValidateTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsAuthorizationIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsQueryBudgetIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobListCompanySummaryQueryBudgetTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/job/JobSalaryMonthsHttpBindingTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/messaging/HistoricalResumeOwnershipIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipHttpIntegrationTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFilenameEncodingTest.java create mode 100644 mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifierTest.java diff --git a/docs/_update_job_company_display_fields_openapi.py b/docs/_update_job_company_display_fields_openapi.py new file mode 100644 index 0000000..795bd4b --- /dev/null +++ b/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 ] +""" +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() diff --git a/docs/acceptance/batch-display-fields-20261016/b01_blackbox.py b/docs/acceptance/batch-display-fields-20261016/b01_blackbox.py new file mode 100644 index 0000000..d58fe97 --- /dev/null +++ b/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() diff --git a/docs/acceptance/batch-display-fields-20261016/b01_contract_negative_tests.py b/docs/acceptance/batch-display-fields-20261016/b01_contract_negative_tests.py new file mode 100644 index 0000000..c238fbf --- /dev/null +++ b/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()) diff --git a/docs/acceptance/batch-display-fields-20261016/b01_openapi_compare.py b/docs/acceptance/batch-display-fields-20261016/b01_openapi_compare.py new file mode 100644 index 0000000..ccb204b --- /dev/null +++ b/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()) diff --git a/docs/batch-job-company-display-fields-frontend-handover.md b/docs/batch-job-company-display-fields-frontend-handover.md new file mode 100644 index 0000000..6b9d7f5 --- /dev/null +++ b/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`。未发布、未连接生产库、未向线上导入演示企业岗位。 diff --git a/docs/batch-job-company-display-fields-report.md b/docs/batch-job-company-display-fields-report.md new file mode 100644 index 0000000..6a35f3a --- /dev/null +++ b/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` 资料作为历史记录保留,不作为当前验收结论。 diff --git a/docs/openapi-rc.json b/docs/openapi-rc.json index 3c8c59e..9afe518 100644 --- a/docs/openapi-rc.json +++ b/docs/openapi-rc.json @@ -1860,7 +1860,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK;公开企业摘要仅含 id/name/logoUrl/description/industry/verified/companySize 七个字段,不含企业主 ID(ownerUserId)、联系方式、登记地址、认证材料与内部审核信息。", "content": { "*/*": { "schema": { @@ -1887,32 +1887,48 @@ "required": false, "schema": { "type": "string", - "enum": ["DRAFT", "PUBLISHED", "CLOSED"] + "enum": [ + "DRAFT", + "PUBLISHED", + "CLOSED" + ] } }, { "name": "keyword", "in": "query", "required": false, - "schema": {"type": "string"} + "schema": { + "type": "string" + } }, { "name": "city", "in": "query", "required": false, - "schema": {"type": "string"} + "schema": { + "type": "string" + } }, { "name": "page", "in": "query", "required": false, - "schema": {"type": "integer", "format": "int32", "default": 0} + "schema": { + "type": "integer", + "format": "int32", + "default": 0 + } }, { "name": "size", "in": "query", "required": false, - "schema": {"type": "integer", "format": "int32", "default": 20} + "schema": { + "type": "integer", + "format": "int32", + "default": 20 + } } ], "responses": { @@ -1928,7 +1944,9 @@ } }, "security": [ - {"bearerAuth": []} + { + "bearerAuth": [] + } ] } }, @@ -1982,7 +2000,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK;data 内岗位展示字段:experience/education ≤40 字符;benefits 为字符串数组、对外恒为数组、最多 20 项、单项 ≤40 且去重保序;salaryMonths 为整数 1—24(非整数不允许);workLocation ≤255 字符;salaryMin/salaryMax 单位仍为元/月。", "content": { "*/*": { "schema": { @@ -3172,11 +3190,11 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK;data 内岗位展示字段:experience/education ≤40 字符;benefits 为字符串数组、对外恒为数组、最多 20 项、单项 ≤40 且去重保序;salaryMonths 为整数 1—24(非整数不允许);workLocation ≤255 字符;salaryMin/salaryMax 单位仍为元/月。", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponseJobListViewDoc" } } } @@ -3202,11 +3220,11 @@ }, "responses": { "200": { - "description": "OK", + "description": "OK;data 内岗位展示字段:experience/education ≤40 字符;benefits 为字符串数组、对外恒为数组、最多 20 项、单项 ≤40 且去重保序;salaryMonths 为整数 1—24(非整数不允许);workLocation ≤255 字符;salaryMin/salaryMax 单位仍为元/月。", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponseJobDetailViewDoc" } } } @@ -3333,11 +3351,11 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK;data 内岗位展示字段:experience/education ≤40 字符;benefits 为字符串数组、对外恒为数组、最多 20 项、单项 ≤40 且去重保序;salaryMonths 为整数 1—24(非整数不允许);workLocation ≤255 字符;salaryMin/salaryMax 单位仍为元/月。", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponseJobPublicViewDoc" } } } @@ -3436,7 +3454,7 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK;data 内岗位展示字段:experience/education ≤40 字符;benefits 为字符串数组、对外恒为数组、最多 20 项、单项 ≤40 且去重保序;salaryMonths 为整数 1—24(非整数不允许);workLocation ≤255 字符;salaryMin/salaryMax 单位仍为元/月。", "content": { "*/*": { "schema": { @@ -3469,11 +3487,11 @@ ], "responses": { "200": { - "description": "OK", + "description": "OK;data 内岗位展示字段:experience/education ≤40 字符;benefits 为字符串数组、对外恒为数组、最多 20 项、单项 ≤40 且去重保序;salaryMonths 为整数 1—24(非整数不允许);workLocation ≤255 字符;salaryMin/salaryMax 单位仍为元/月。", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponseJobDetailViewDoc" } } } @@ -3510,11 +3528,11 @@ }, "responses": { "200": { - "description": "OK", + "description": "OK;data 内岗位展示字段:experience/education ≤40 字符;benefits 为字符串数组、对外恒为数组、最多 20 项、单项 ≤40 且去重保序;salaryMonths 为整数 1—24(非整数不允许);workLocation ≤255 字符;salaryMin/salaryMax 单位仍为元/月。", "content": { "*/*": { "schema": { - "$ref": "#/components/schemas/ApiResponseMapStringObject" + "$ref": "#/components/schemas/ApiResponseJobDetailViewDoc" } } } @@ -9587,13 +9605,54 @@ }, "/auth/password/setup": { "post": { - "tags": ["认证"], + "tags": [ + "认证" + ], "summary": "短信登录后首次设置密码", "description": "必须携带当前 Access Token 与 login 登录返回的 10 分钟一次性 passwordSetupToken;成功后替换两枚 Token,验证码登录继续可用。", "operationId": "setupPassword", - "security": [{"bearerAuth": []}], - "requestBody": {"required": true, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/PasswordSetupRequest"}}}}, - "responses": {"200": {"description": "OK", "content": {"*/*": {"schema": {"$ref": "#/components/schemas/ApiResponsePasswordSetupResponse"}}}}, "400": {"description": "参数或密码策略错误"}, "401": {"description": "会话或设置凭证无效"}, "409": {"description": "密码已设置"}, "429": {"description": "操作限流"}, "503": {"description": "安全依赖不可用"}} + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PasswordSetupRequest" + } + } + } + }, + "responses": { + "200": { + "description": "OK", + "content": { + "*/*": { + "schema": { + "$ref": "#/components/schemas/ApiResponsePasswordSetupResponse" + } + } + } + }, + "400": { + "description": "参数或密码策略错误" + }, + "401": { + "description": "会话或设置凭证无效" + }, + "409": { + "description": "密码已设置" + }, + "429": { + "description": "操作限流" + }, + "503": { + "description": "安全依赖不可用" + } + } } }, "/auth/password/set": { @@ -14261,6 +14320,15 @@ }, "address": { "type": "string" + }, + "companySize": { + "type": [ + "string", + "null" + ], + "description": "企业规模,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", + "maxLength": 40, + "minLength": 0 } }, "required": [ @@ -14316,6 +14384,14 @@ }, "verified": { "type": "boolean" + }, + "companySize": { + "type": [ + "string", + "null" + ], + "description": "企业规模,≤40 字符", + "maxLength": 40 } } }, @@ -14368,6 +14444,15 @@ "type": "string", "maxLength": 255, "minLength": 0 + }, + "companySize": { + "type": [ + "string", + "null" + ], + "description": "企业规模,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", + "maxLength": 40, + "minLength": 0 } }, "required": [ @@ -14740,11 +14825,67 @@ }, "salaryMin": { "type": "integer", - "format": "int32" + "format": "int32", + "description": "最低月薪,单位元/月" }, "salaryMax": { "type": "integer", - "format": "int32" + "format": "int32", + "description": "最高月薪,单位元/月" + }, + "experience": { + "type": [ + "string", + "null" + ], + "description": "经验要求,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", + "maxLength": 40, + "minLength": 0 + }, + "education": { + "type": [ + "string", + "null" + ], + "description": "学历要求,≤40 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", + "maxLength": 40, + "minLength": 0 + }, + "benefits": { + "type": [ + "array", + "null" + ], + "description": "福利标签:字符串数组,最多 20 项(在去重并去除首尾空白之后计算)、trim后单项 ≤40 字符;null 或缺省:创建时不设置(存 NULL);传 [] 表示清空;非空数组会逐项 trim、拒绝空白项、去重保序、超 20 项或单项超 40 字符一律 400。", + "items": { + "type": "string" + }, + "x-normalization": { + "maxItems": 20, + "rejectBlankItems": true, + "deduplicate": true, + "itemMaxLength": 40, + "trimItems": true + } + }, + "salaryMonths": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "薪资月数;仅接受 JSON 整数 1—24,非整数(小数/字符串/布尔等)一律 400;缺省/null 表示不设置(创建存 NULL)。", + "maximum": 24, + "minimum": 1 + }, + "workLocation": { + "type": [ + "string", + "null" + ], + "description": "工作地点,≤255 字符。缺省/null:创建存 NULL;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 255 字符拒绝且不截断。", + "maxLength": 255, + "minLength": 0 } }, "required": [ @@ -14933,11 +15074,67 @@ }, "salaryMin": { "type": "integer", - "format": "int32" + "format": "int32", + "description": "最低月薪,单位元/月" }, "salaryMax": { "type": "integer", - "format": "int32" + "format": "int32", + "description": "最高月薪,单位元/月" + }, + "experience": { + "type": [ + "string", + "null" + ], + "description": "经验要求,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", + "maxLength": 40, + "minLength": 0 + }, + "education": { + "type": [ + "string", + "null" + ], + "description": "学历要求,≤40 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 40 字符拒绝且不截断。", + "maxLength": 40, + "minLength": 0 + }, + "benefits": { + "type": [ + "array", + "null" + ], + "description": "福利标签:字符串数组,最多 20 项(在去重并去除首尾空白之后计算)、trim后单项 ≤40 字符;null 或缺省:更新保持原值;传 [] 表示清空;非空数组会逐项 trim、拒绝空白项、去重保序、超 20 项或单项超 40 字符一律 400。", + "items": { + "type": "string" + }, + "x-normalization": { + "maxItems": 20, + "rejectBlankItems": true, + "deduplicate": true, + "itemMaxLength": 40, + "trimItems": true + } + }, + "salaryMonths": { + "type": [ + "integer", + "null" + ], + "format": "int32", + "description": "薪资月数;仅接受 JSON 整数 1—24,非整数(小数/字符串/布尔等)一律 400;缺省/null 表示保持原值。", + "maximum": 24, + "minimum": 1 + }, + "workLocation": { + "type": [ + "string", + "null" + ], + "description": "工作地点,≤255 字符。缺省/null:更新保持原值;空串或纯空白清空为 NULL;非空去两端空白后保存;超过 255 字符拒绝且不截断。", + "maxLength": 255, + "minLength": 0 } } }, @@ -18344,48 +18541,150 @@ } } }, - "ApiResponsePageResponseCompanyManagedJobItem": { - "type": "object", - "properties": { - "code": {"type": "integer", "format": "int32"}, - "message": {"type": "string"}, - "data": { - "type": "object", - "properties": { + "ApiResponsePageResponseCompanyManagedJobItem": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CompanyManagedJobItem" + } + }, + "total": { + "type": "integer", + "format": "int64" + }, + "page": { + "type": "integer", + "format": "int32" + }, + "size": { + "type": "integer", + "format": "int32" + }, + "totalPages": { + "type": "integer", + "format": "int32" + } + } + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } + }, + "CompanyManagedJobItem": { + "type": "object", + "description": "企业自身岗位管理安全摘要", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "岗位 ID" + }, + "companyId": { + "type": "integer", + "format": "int64", + "description": "所属企业 ID" + }, + "title": { + "type": "string", + "description": "岗位名称" + }, + "description": { + "type": "string", + "description": "岗位描述" + }, + "requirements": { + "type": "string", + "description": "任职要求" + }, + "city": { + "type": "string", + "description": "工作城市" + }, + "employmentType": { + "type": "string", + "description": "用工类型" + }, + "salaryMin": { + "type": "integer", + "format": "int32", + "description": "最低月薪,单位元" + }, + "salaryMax": { + "type": "integer", + "format": "int32", + "description": "最高月薪,单位元" + }, + "status": { + "type": "string", + "description": "岗位状态:DRAFT、PUBLISHED、CLOSED" + }, + "publishedAt": { + "type": "string", + "format": "date-time", + "description": "发布时间" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "创建时间" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "更新时间" + }, + "experience": { + "type": "string", + "description": "经验要求,≤40 字符;无值时为 null", + "maxLength": 40 + }, + "education": { + "type": "string", + "description": "学历要求,≤40 字符;无值时为 null", + "maxLength": 40 + }, + "benefits": { + "type": "array", + "description": "福利标签;响应端恒为数组(历史无值返回 []),最多 20 项、单项 ≤40 字符、去重保序", "items": { - "type": "array", - "items": {"$ref": "#/components/schemas/CompanyManagedJobItem"} + "type": "string", + "description": "单项长度上限 40 字符", + "maxLength": 40 }, - "total": {"type": "integer", "format": "int64"}, - "page": {"type": "integer", "format": "int32"}, - "size": {"type": "integer", "format": "int32"}, - "totalPages": {"type": "integer", "format": "int32"} + "maxItems": 20 + }, + "salaryMonths": { + "type": "integer", + "format": "int32", + "description": "薪资月数,整数 1—24;无值时为 null", + "maximum": 24, + "minimum": 1 + }, + "workLocation": { + "type": "string", + "description": "工作地点,≤255 字符;无值时为 null", + "maxLength": 255 } - }, - "requestId": {"type": "string"}, - "timestamp": {"type": "string", "format": "date-time"} - } - }, - "CompanyManagedJobItem": { - "type": "object", - "description": "企业自身岗位管理安全摘要", - "properties": { - "id": {"type": "integer", "format": "int64", "description": "岗位 ID"}, - "companyId": {"type": "integer", "format": "int64", "description": "所属企业 ID"}, - "title": {"type": "string", "description": "岗位名称"}, - "description": {"type": "string", "description": "岗位描述"}, - "requirements": {"type": "string", "description": "任职要求"}, - "city": {"type": "string", "description": "工作城市"}, - "employmentType": {"type": "string", "description": "用工类型"}, - "salaryMin": {"type": "integer", "format": "int32", "description": "最低月薪,单位元"}, - "salaryMax": {"type": "integer", "format": "int32", "description": "最高月薪,单位元"}, - "status": {"type": "string", "description": "岗位状态:DRAFT、PUBLISHED、CLOSED"}, - "publishedAt": {"type": "string", "format": "date-time", "description": "发布时间"}, - "createdAt": {"type": "string", "format": "date-time", "description": "创建时间"}, - "updatedAt": {"type": "string", "format": "date-time", "description": "更新时间"} - } - }, - "ApiResponsePageResponseDataExportTaskResponse": { + } + }, + "ApiResponsePageResponseDataExportTaskResponse": { "type": "object", "properties": { "code": { @@ -18545,16 +18844,69 @@ ] }, "PasswordSetupRequest": { - "type": "object", "required": ["passwordSetupToken", "newPassword"], - "properties": {"passwordSetupToken": {"type": "string", "maxLength": 512, "writeOnly": true}, "newPassword": {"type": "string", "maxLength": 256, "writeOnly": true}} + "type": "object", + "required": [ + "passwordSetupToken", + "newPassword" + ], + "properties": { + "passwordSetupToken": { + "type": "string", + "maxLength": 512, + "writeOnly": true + }, + "newPassword": { + "type": "string", + "maxLength": 256, + "writeOnly": true + } + } }, "PasswordSetupResponse": { "type": "object", - "properties": {"passwordConfigured": {"type": "boolean"}, "reauthenticationRequired": {"type": "boolean"}, "identitySelectionRequired": {"type": "boolean"}, "accessToken": {"type": "string"}, "refreshToken": {"type": "string"}, "expiresIn": {"type": "integer", "format": "int64"}} + "properties": { + "passwordConfigured": { + "type": "boolean" + }, + "reauthenticationRequired": { + "type": "boolean" + }, + "identitySelectionRequired": { + "type": "boolean" + }, + "accessToken": { + "type": "string" + }, + "refreshToken": { + "type": "string" + }, + "expiresIn": { + "type": "integer", + "format": "int64" + } + } }, "ApiResponsePasswordSetupResponse": { "type": "object", - "properties": {"code": {"type": "integer", "format": "int32"}, "message": {"type": "string"}, "data": {"$ref": "#/components/schemas/PasswordSetupResponse"}, "requestId": {"type": "string"}, "timestamp": {"type": "string", "format": "date-time"}} + "properties": { + "code": { + "type": "integer", + "format": "int32" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/PasswordSetupResponse" + }, + "requestId": { + "type": "string" + }, + "timestamp": { + "type": "string", + "format": "date-time" + } + } }, "PasswordOperationResponse": { "type": "object", @@ -20151,6 +20503,350 @@ } } } + }, + "JobDocCompanySummary": { + "type": "object", + "description": "公开企业摘要;严格 7 字段,不含 ownerUserId、联系方式、地址与审核信息", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "企业 ID" + }, + "name": { + "type": "string", + "description": "企业名称" + }, + "logoUrl": { + "type": "string", + "description": "企业 Logo" + }, + "description": { + "type": "string", + "description": "企业简介" + }, + "industry": { + "type": "string", + "description": "所属行业" + }, + "verified": { + "type": "boolean", + "description": "是否已认证" + }, + "companySize": { + "type": "string", + "description": "企业规模,≤40 字符;缺省时为空", + "maxLength": 40 + } + } + }, + "JobDetailViewDoc": { + "type": "object", + "description": "企业端岗位详情(data 结构)", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "岗位 ID" + }, + "companyId": { + "type": "integer", + "format": "int64", + "description": "所属企业 ID" + }, + "title": { + "type": "string", + "description": "岗位名称" + }, + "description": { + "type": "string", + "description": "岗位描述" + }, + "requirements": { + "type": "string", + "description": "任职要求" + }, + "city": { + "type": "string", + "description": "工作城市" + }, + "employmentType": { + "type": "string", + "description": "用工类型" + }, + "salaryMin": { + "type": "integer", + "format": "int32", + "description": "最低月薪,单位元/月" + }, + "salaryMax": { + "type": "integer", + "format": "int32", + "description": "最高月薪,单位元/月" + }, + "experience": { + "type": "string", + "description": "经验要求,≤40 字符;无值时为 null", + "maxLength": 40 + }, + "education": { + "type": "string", + "description": "学历要求,≤40 字符;无值时为 null", + "maxLength": 40 + }, + "benefits": { + "type": "array", + "description": "福利标签;响应端恒为数组(历史无值返回 []),最多 20 项、单项 ≤40 字符、去重保序", + "items": { + "description": "单项长度上限 40 字符", + "maxLength": 40 + }, + "maxItems": 20 + }, + "salaryMonths": { + "type": "integer", + "format": "int32", + "description": "薪资月数,整数 1—24;无值时为 null", + "maximum": 24, + "minimum": 1 + }, + "workLocation": { + "type": "string", + "description": "工作地点,≤255 字符;无值时为 null", + "maxLength": 255 + }, + "status": { + "type": "string", + "description": "岗位状态:DRAFT、PUBLISHED、CLOSED" + }, + "publishedAt": { + "type": "string", + "format": "date-time", + "description": "发布时间" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "创建时间" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "更新时间" + } + } + }, + "JobPublicViewDoc": { + "type": "object", + "description": "公开岗位详情(data 结构,含公开企业摘要 company)", + "properties": { + "id": { + "type": "integer", + "format": "int64", + "description": "岗位 ID" + }, + "companyId": { + "type": "integer", + "format": "int64", + "description": "所属企业 ID" + }, + "title": { + "type": "string", + "description": "岗位名称" + }, + "description": { + "type": "string", + "description": "岗位描述" + }, + "requirements": { + "type": "string", + "description": "任职要求" + }, + "city": { + "type": "string", + "description": "工作城市" + }, + "employmentType": { + "type": "string", + "description": "用工类型" + }, + "salaryMin": { + "type": "integer", + "format": "int32", + "description": "最低月薪,单位元/月" + }, + "salaryMax": { + "type": "integer", + "format": "int32", + "description": "最高月薪,单位元/月" + }, + "experience": { + "type": "string", + "description": "经验要求,≤40 字符;无值时为 null", + "maxLength": 40 + }, + "education": { + "type": "string", + "description": "学历要求,≤40 字符;无值时为 null", + "maxLength": 40 + }, + "benefits": { + "type": "array", + "description": "福利标签;响应端恒为数组(历史无值返回 []),最多 20 项、单项 ≤40 字符、去重保序", + "items": { + "description": "单项长度上限 40 字符", + "maxLength": 40 + }, + "maxItems": 20 + }, + "salaryMonths": { + "type": "integer", + "format": "int32", + "description": "薪资月数,整数 1—24;无值时为 null", + "maximum": 24, + "minimum": 1 + }, + "workLocation": { + "type": "string", + "description": "工作地点,≤255 字符;无值时为 null", + "maxLength": 255 + }, + "status": { + "type": "string", + "description": "岗位状态:DRAFT、PUBLISHED、CLOSED" + }, + "publishedAt": { + "type": "string", + "format": "date-time", + "description": "发布时间" + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "创建时间" + }, + "company": { + "$ref": "#/components/schemas/JobDocCompanySummary", + "description": "公开企业摘要(7 字段白名单)" + } + } + }, + "JobListViewDoc": { + "type": "object", + "description": "公开岗位列表分页载荷(data 结构)", + "properties": { + "items": { + "type": "array", + "description": "当页岗位列表", + "items": { + "$ref": "#/components/schemas/JobPublicViewDoc" + } + }, + "total": { + "type": "integer", + "format": "int64", + "description": "总条数" + }, + "page": { + "type": "integer", + "format": "int32", + "description": "当前页码(从 0 开始)" + }, + "size": { + "type": "integer", + "format": "int32", + "description": "每页大小" + }, + "totalPages": { + "type": "integer", + "format": "int32", + "description": "总页数" + } + } + }, + "ApiResponseJobDetailViewDoc": { + "type": "object", + "description": "统一响应包装;data 为企业端岗位详情", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "description": "业务状态码" + }, + "message": { + "type": "string", + "description": "消息" + }, + "data": { + "$ref": "#/components/schemas/JobDetailViewDoc", + "description": "企业端岗位详情" + }, + "requestId": { + "type": "string", + "description": "链路追踪 ID" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "响应时间" + } + } + }, + "ApiResponseJobPublicViewDoc": { + "type": "object", + "description": "统一响应包装;data 为公开岗位详情", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "description": "业务状态码" + }, + "message": { + "type": "string", + "description": "消息" + }, + "data": { + "$ref": "#/components/schemas/JobPublicViewDoc", + "description": "公开岗位详情" + }, + "requestId": { + "type": "string", + "description": "链路追踪 ID" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "响应时间" + } + } + }, + "ApiResponseJobListViewDoc": { + "type": "object", + "description": "统一响应包装;data 为公开岗位列表分页载荷", + "properties": { + "code": { + "type": "integer", + "format": "int32", + "description": "业务状态码" + }, + "message": { + "type": "string", + "description": "消息" + }, + "data": { + "$ref": "#/components/schemas/JobListViewDoc", + "description": "公开岗位列表分页载荷" + }, + "requestId": { + "type": "string", + "description": "链路追踪 ID" + }, + "timestamp": { + "type": "string", + "format": "date-time", + "description": "响应时间" + } + } } }, "securitySchemes": { diff --git a/docs/resume-ownership-hardening-20261016.md b/docs/resume-ownership-hardening-20261016.md new file mode 100644 index 0000000..95090fa --- /dev/null +++ b/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`。本文未包含账号口令、令牌、真实简历或生产数据库配置。 diff --git a/docs/tools/b01_demo_display_data.py b/docs/tools/b01_demo_display_data.py new file mode 100644 index 0000000..e7e3590 --- /dev/null +++ b/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()) diff --git a/docs/tools/b01_demo_display_data_test.py b/docs/tools/b01_demo_display_data_test.py new file mode 100644 index 0000000..2ddef61 --- /dev/null +++ b/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()) diff --git a/docs/verify_openapi.py b/docs/verify_openapi.py index 9a18716..45dd164 100644 --- a/docs/verify_openapi.py +++ b/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: [, "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") diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java index b68a36d..225dc3d 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/application/CompanyResumeService.java +++ b/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()); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java index d1f9839..0316a57 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/Company.java +++ b/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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java index df87091..0424e84 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/CompanyService.java +++ b/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 统一归一化——创建与更新**共用同一套规则**,避免两套逻辑漂移。 + * + * + */ + 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(); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java index 5b8c2cd..513f5bd 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyCreateRequest.java +++ b/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; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java index a226333..4a0b0d6 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyPublicView.java +++ b/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; } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java index 47b2c49..8b75bd7 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/company/dto/CompanyUpdateRequest.java +++ b/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; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java index ff9e1b4..4989ca1 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/Job.java +++ b/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"; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java index 268140a..3e4f2f0 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobController.java +++ b/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>> 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>> 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>> 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>> 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>> getPublicJob( @PathVariable Long jobId, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java index 494de89..275a32a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/JobService.java +++ b/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 result = jobRepository.findPublishedJobs(normalizedKeyword, city, employmentType, salaryMin, salaryMax, pageable); - return result.map(this::toPublicView); + + // 批量查询企业信息后组装,避免逐条查询造成 N+1;空页时不发起企业查询。 + List jobs = result.getContent(); + Set companyIds = jobs.stream() + .map(Job::getCompanyId) + .collect(Collectors.toSet()); + Map companyMap = companyIds.isEmpty() + ? Map.of() + : companyRepository.findAllById(companyIds).stream() + .collect(Collectors.toMap(Company::getId, c -> c)); + List> 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 toPublicView(Job job, Company company) { + Map 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 benefits) { + if (benefits == null || benefits.isEmpty()) return null; + Set 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 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 benefitsFromJson(String benefitsJson) { + if (benefitsJson == null || benefitsJson.isBlank()) return List.of(); + try { + List parsed = objectMapper.readValue(benefitsJson, + new TypeReference>() {}); + 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 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(); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobDetailViewDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobDetailViewDoc.java new file mode 100644 index 0000000..e72ff57 --- /dev/null +++ b/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})。 + * + *

对应 {@code ApiResponse>} 的实际外层结构;仅用于文档,不改变返回类型。

+ */ +@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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobListViewDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobListViewDoc.java new file mode 100644 index 0000000..4c21d71 --- /dev/null +++ b/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})。 + * + *

对应 {@code ApiResponse>} 的实际外层结构;仅用于文档,不改变返回类型。

+ */ +@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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobPublicViewDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/ApiResponseJobPublicViewDoc.java new file mode 100644 index 0000000..cc4678d --- /dev/null +++ b/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})。 + * + *

对应 {@code ApiResponse>} 的实际外层结构;仅用于文档,不改变返回类型。

+ */ +@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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java index cce9e46..1aa88b9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/CompanyManagedJobItem.java +++ b/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 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 benefits(String benefitsJson) { + if (benefitsJson == null || benefitsJson.isBlank()) { + return List.of(); + } + try { + return BENEFITS_MAPPER.readValue(benefitsJson, new TypeReference>() {}); + } catch (Exception exception) { + throw new BusinessException(ErrorCode.BAD_REQUEST); + } } } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java index 69bcef9..5fad2c2 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobCreateRequest.java +++ b/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 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; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDetailViewDoc.java new file mode 100644 index 0000000..c85e49d --- /dev/null +++ b/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})。 + * + *

仅用于把 {@code Map} 响应结构写入运行时契约,实际返回类型不变。

+ */ +@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 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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobDocCompanySummary.java new file mode 100644 index 0000000..d978616 --- /dev/null +++ b/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 字段白名单)。 + * + *

不参与任何序列化/反序列化:仅通过 {@code @Schema(implementation = ...)} 绑定到 + * 岗位相关 Map 响应的文档结构,实际返回类型不变。不得暴露 ownerUserId、联系方式、 + * 登记地址与内部审核信息。

+ */ +@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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobListViewDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobListViewDoc.java new file mode 100644 index 0000000..b01cf2a --- /dev/null +++ b/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} 的分页载荷)。 + * + *

仅用于把 {@code Map} 响应结构写入运行时契约,实际返回类型不变。

+ */ +@Data +@Schema(name = "JobListViewDoc", description = "公开岗位列表分页载荷(data 结构)") +public class JobListViewDoc { + + @Schema(description = "当页岗位列表") + private List items; + + @Schema(description = "总条数") + private Long total; + + @Schema(description = "当前页码(从 0 开始)") + private Integer page; + + @Schema(description = "每页大小") + private Integer size; + + @Schema(description = "总页数") + private Integer totalPages; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobPublicViewDoc.java new file mode 100644 index 0000000..796f1f6 --- /dev/null +++ b/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)})。 + * + *

仅用于把 {@code Map} 响应结构写入运行时契约,实际返回类型不变。

+ */ +@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 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; +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java index a4b6849..80beec9 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobSearchItem.java +++ b/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 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; } } \ No newline at end of file diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java index a535431..44d2b53 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/JobUpdateRequest.java +++ b/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 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; } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictIntegerDeserializer.java b/mydreamjob-server/src/main/java/com/mydreamjob/job/dto/StrictIntegerDeserializer.java new file mode 100644 index 0000000..6387efc --- /dev/null +++ b/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})。 + * + *

背景:Jackson 默认开启 {@code ACCEPT_FLOAT_AS_INT},会把 {@code 1.9} 静默截断为 {@code 1}, + * 使「整数 1—24」的契约失效。本反序列化器**只作用于被标注的字段**, + * 不修改任何全局 Jackson 强制转换配置。

+ * + *

拒绝:小数({@code 1.9} / {@code 1.0})、数字字符串({@code "12"})、空串、布尔、 + * 数组、对象,以及超出 {@code int} 范围的整数。一律抛 {@link MismatchedInputException} + * (它是 {@code JsonProcessingException} 的子类),由全局异常处理器映射为 HTTP 400, + * 不泄露异常堆栈。

+ * + *

{@code null} 原样返回;字段缺省时本方法不会被调用,两种情形都保持既有语义。

+ */ +public class StrictIntegerDeserializer extends JsonDeserializer { + + @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(); + } +} diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java b/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java index 528b8b2..1050255 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/messaging/ChatResumeService.java +++ b/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"); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java index 63a088f..70c4948 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileController.java +++ b/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>> updateMyProfile( @Valid @RequestBody StudentProfileUpdateRequest req, diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java index 2625548..3ba3b6f 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/ProfileService.java +++ b/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); diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java index 391cd4c..8646b98 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java +++ b/mydreamjob-server/src/main/java/com/mydreamjob/profile/dto/StudentProfileUpdateRequest.java @@ -27,6 +27,13 @@ public class StudentProfileUpdateRequest { private Integer graduationYear; + /** + * 简历引用(服务端生成的存储键)。 + * + *

只读字段:不接受客户端写入。缺省或显式 {@code null} 表示“不修改现有简历引用”; + * 传入任意非 {@code null} 值(含空字符串)时 {@code PUT /profile/me} 整次返回 400,且不产生部分更新。 + * 简历引用只能由上传简历 / 简历版本等受控接口生成与绑定,禁止由客户端提供(避免引用他人文件)。

+ */ @Size(max = 500, message = "resume url must be at most 500 characters") private String resumeUrl; diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java index 276b22c..b63107a 100644 --- a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeFilenameSanitizer.java +++ b/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); } diff --git a/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifier.java b/mydreamjob-server/src/main/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifier.java new file mode 100644 index 0000000..4337a7f --- /dev/null +++ b/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; + +/** + * 简历文件存储键的归属校验器(简历归属漏洞修复)。 + * + *

存储键由服务端在 {@link ResumeStorageService#store(Long, org.springframework.web.multipart.MultipartFile)} + * 生成,格式固定为 {@code _<随机段>[.扩展名]}(见 {@link LocalResumeStorageService})。 + * 因此“第一个下划线之前的十进制数字”就是该文件的唯一所有者 {@code userId}。

+ * + *

失败关闭

+ *

以下任一情况一律视为“无法证明归属”,直接拒绝,不降级读取、不降级删除:

+ *
    + *
  • 键为 {@code null} / 空白;
  • + *
  • 不含下划线,或下划线前不是纯 ASCII 十进制数字,或下划线后为空(如 {@code 10_});
  • + *
  • 含路径分隔符({@code /}、{@code \})或冒号(可构成绝对路径 / 盘符 / 路径穿越);
  • + *
  • 含 {@code ..};
  • + *
  • 解析出的所有者与目标 {@code userId} 不相等(按数值精确比较,绝不用字符串前缀匹配, + * 避免 {@code 1} 与 {@code 10} 前缀混淆)。
  • + *
+ * + *

职责边界

+ *

本类只解析“存储键字符串里记录的所有者”,不做文件存在性、扩展名或内容校验, + * 也不对 {@link ResumeStorageService#read(String)} 施加“仅所有者可读”的限制—— + * 企业按投递关系下载候选人简历、投递/聊天简历快照等合法授权流程必须保持可用, + * 其授权由各自的 AccessPolicy 负责。归属校验只用于“本人资料简历引用”的读写路径。

+ * + *

兼容性边界

+ *
    + *
  • 历史键:本地存储自上线起即使用 {@code userId_UUID8.ext},故正常历史键均能通过校验; + * 被人工篡改 / 非该格式的历史键会判为“拒绝”(失败关闭),这是预期行为。
  • + *
  • 版本文件:{@code resume_versions.file_storage_path} 与资料简历同样由 + * {@code store(userId, file)} 生成,格式一致;但版本操作另有“版本行所属用户”校验,本类不介入。
  • + *
  • 其他存储类型:如后续引入对象存储等实现,若键格式不再以 {@code userId_} 开头, + * 必须在本类同步扩展解析规则,否则会失败关闭。
  • + *
+ */ +@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}。 + * + *

错误信息为固定文案,不回显存储键、真实磁盘路径、其他用户标识或文件名。

+ */ + public void assertOwnedBy(String storageKey, Long userId) { + if (!isOwnedBy(storageKey, userId)) { + throw new BusinessException(ErrorCode.FORBIDDEN); + } + } +} diff --git a/mydreamjob-server/src/main/resources/db/migration/V57__job_company_display_fields.sql b/mydreamjob-server/src/main/resources/db/migration/V57__job_company_display_fields.sql new file mode 100644 index 0000000..a04cfcd --- /dev/null +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java index 097de42..a094d39 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/application/CompanyResumeDownloadIntegrationTest.java +++ b/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(); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java index 2b0941f..018e410 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/candidatescore/refresh/CandidateScoringRefreshMySqlIntegrationTest.java +++ b/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 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 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 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))) diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanySizeDisplayIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/company/CompanySizeDisplayIntegrationTest.java new file mode 100644 index 0000000..fdecd21 --- /dev/null +++ b/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 + 真实持久化)。 + * + *

覆盖:创建/修改的缺省、null、空串、纯空白、首尾空格、正常值、40 字符边界、41 字符拒绝, + * 并通过 {@code em.flush()+clear()} 后重新查询(以及直接读取 SQL 列)确认落库值, + * 而不是只断言 DTO/内存对象。

+ */ +@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 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 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 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlMigrationTest.java new file mode 100644 index 0000000..21b7420 --- /dev/null +++ b/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 前缀)。 + * + *

逐项覆盖:数据库版本记录;从 V56 基线升级到 V57;旧数据保留、新列为 NULL; + * 新字段写入与重新查询;更新清空语义(NULL);重复迁移无待执行项且 V57 只有一条历史; + * 空库(独立容器)从零完整迁移到 V57。

+ * + *

连接统一追加 {@code characterEncoding=UTF-8},避免 MySQL 5.7 服务器默认字符集导致的种子中文写入失败。 + * Docker 不可用时环境性跳过(不宣称通过)。

+ */ +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; + } + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlValidateTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/integration/JobCompanyDisplayFieldsMysqlValidateTest.java new file mode 100644 index 0000000..68dc3e2 --- /dev/null +++ b/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 迁移后的实际库结构匹配。 + * + *

镜像由系统属性 {@code b01.mysql.image} 指定(默认 {@code mysql:8.4}), + * 便于对 MySQL 5.7 与 MySQL 8 分别验证。连接参数来自容器,运行期注入,不落盘、不打印密码。

+ */ +@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 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 reloaded = companies.findById(company.getId()); + assertTrue(reloaded.isPresent()); + assertEquals("100-499人", reloaded.get().getCompanySize()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsAuthorizationIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsAuthorizationIntegrationTest.java new file mode 100644 index 0000000..9b0c044 --- /dev/null +++ b/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 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) {} +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsConsistencyIntegrationTest.java new file mode 100644 index 0000000..c8b7405 --- /dev/null +++ b/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 PUBLIC_COMPANY_KEYS = + Set.of("id", "name", "logoUrl", "description", "industry", "verified", "companySize"); + + @MockBean private RedisTemplate 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 fieldNames(JsonNode node) { + Set names = new HashSet<>(); + node.fieldNames().forEachRemaining(names::add); + return names; + } + + private static List strings(JsonNode array) { + List values = new ArrayList<>(); + array.forEach(n -> values.add(n.asText())); + return values; + } + + private String bearer() { + return "Bearer " + token; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsIntegrationTest.java new file mode 100644 index 0000000..2fdf80f --- /dev/null +++ b/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 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 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 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 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 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 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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsQueryBudgetIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobDisplayFieldsQueryBudgetIntegrationTest.java new file mode 100644 index 0000000..1c211fe --- /dev/null +++ b/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)增长;空页不查询企业。 + * + *

与既有的 JobListCompanySummaryQueryBudgetTest 互补——本类在**每个岗位都带满新字段**的前提下 + * 复验查询条数不因新字段而增加。

+ */ +@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 redis; + + private final List jobIds = new ArrayList<>(); + + @BeforeEach + void fixture() { + List 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> 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 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> 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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobListCompanySummaryQueryBudgetTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobListCompanySummaryQueryBudgetTest.java new file mode 100644 index 0000000..06879ed --- /dev/null +++ b/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 redis; + + private final List jobIds = new ArrayList<>(); + + @BeforeEach + void fixture() { + List 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 expectedDesc = new ArrayList<>(jobIds); + Collections.reverse(expectedDesc); + + for (int size : new int[]{1, 10, 50}) { + em.clear(); + stats.clear(); + Page> 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> 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> 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 idsOf(Page> page) { + List ids = new ArrayList<>(); + for (Map item : page.getContent()) { + ids.add(((Number) item.get("id")).longValue()); + } + return ids; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSalaryMonthsHttpBindingTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/job/JobSalaryMonthsHttpBindingTest.java new file mode 100644 index 0000000..b9d3ea4 --- /dev/null +++ b/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 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 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; + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java index 17f4e9a..7440cd0 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java +++ b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/ChatWorkflowIntegrationTest.java @@ -50,6 +50,9 @@ class ChatWorkflowIntegrationTest { Conversation conversation; StudentProfile profile; StudentPrivacySettings privacy; + /** 简历归属加固后,本人资料的简历引用必须符合服务端生成格式 _<随机段>.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()); } diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/messaging/HistoricalResumeOwnershipIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/messaging/HistoricalResumeOwnershipIntegrationTest.java new file mode 100644 index 0000000..67981bf --- /dev/null +++ b/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()); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java index 453495b..cdf4b80 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ProfileServiceTest.java +++ b/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; diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipHttpIntegrationTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipHttpIntegrationTest.java new file mode 100644 index 0000000..89b69f4 --- /dev/null +++ b/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 集成**回归测试。 + * + *

真实 SecurityFilterChain + 真实 Controller 参数绑定 + 真实 H2 + 真实本地文件存储 + * (存储目录为 JDK 临时目录,文件为真实合成 PDF)。仅用计数包装器包住存储实现, + * 以证明“拒绝路径零文件读取 / 零文件删除”,授权链本身完全真实。

+ */ +@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 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 userIds = new ArrayList<>(); + private final List 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 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(); + } + + /** + * 逐表快照(归一化为字符串值)。 + * + *

H2 会把 {@code skills_json} 等列以 {@code byte[]} 返回,而数组的 equals 是引用比较, + * 因此必须归一化,否则快照对比会因同一行的不同数组实例而误报“有变化”。

+ */ + private List> dbSnapshot() { + List> tables = new ArrayList<>(); + for (String table : List.of("student_profiles", "messages", "chat_resume_snapshots")) { + List> normalizedRows = new ArrayList<>(); + for (Map row : jdbc.queryForList("SELECT * FROM " + table + " ORDER BY 1")) { + Map 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 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 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> 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> 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)), "合法删除应移除文件"); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeOwnershipServiceTest.java new file mode 100644 index 0000000..2c1aca4 --- /dev/null +++ b/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)。 + * + *

证明:即使绕过 Controller 直接调用服务层,也无法写入非法简历引用, + * 且本人下载 / 删除 / 替换在引用被污染(指向他人存储键)时失败关闭、不触碰存储。

+ */ +@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); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java index d3b036f..35c8146 100644 --- a/mydreamjob-server/src/test/java/com/mydreamjob/profile/ResumeServiceTest.java +++ b/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 _<随机段>.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()); diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFilenameEncodingTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeFilenameEncodingTest.java new file mode 100644 index 0000000..50791d9 --- /dev/null +++ b/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")); + } +} diff --git a/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifierTest.java b/mydreamjob-server/src/test/java/com/mydreamjob/profile/storage/ResumeOwnershipVerifierTest.java new file mode 100644 index 0000000..1e1a05c --- /dev/null +++ b/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} 归属解析单元测试(纯字符串语义,无文件访问)。 + * + *

覆盖服务端存储键格式 {@code _<随机段>[.ext]} 的所有边界: + * 合法归属、空/异常键、路径穿越与绝对路径、以及 {@code 1} 与 {@code 10} 前缀混淆。

+ */ +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); + } +}