forked from baskbuddy/zhangshun
33 changed files with 1905 additions and 35 deletions
@ -0,0 +1,104 @@ |
|||||
|
# 本人在线简历:非 AI 接入约定 |
||||
|
|
||||
|
范围:基础信息与联系方式、求职意向、多段教育/工作/项目经历、技能详情、自我评价、在线简历保存与 DOCX 生成。此文对应工作区新增实现,不代表已经部署。既有附件上传下载、附件版本和权限规则继续使用原接口。 |
||||
|
|
||||
|
## 接口 |
||||
|
|
||||
|
以下路径均加 `/api/v1`;所有接口需要当前 STUDENT 的 Bearer Token。身份只来自认证上下文,不接受 userId 指定他人。COMPANY、ADMIN 不可使用,企业候选人响应不新增手机号或邮箱。 |
||||
|
|
||||
|
| 方法 | 路径 | 用途 | |
||||
|
| --- | --- | --- | |
||||
|
| GET | `/profile/me/online-resume` | 整份读取,首次 revision=0,不自动写库 | |
||||
|
| PUT | `/profile/me/online-resume` | 整份保存,body 为 `{revision, content}` | |
||||
|
| GET | `/profile/me/online-resume/{section}` | 某类条目列表 | |
||||
|
| POST | `/profile/me/online-resume/{section}` | 新增条目,body 为 `{revision, entry}` | |
||||
|
| GET | `/profile/me/online-resume/{section}/{id}` | 本人条目详情 | |
||||
|
| PUT | `/profile/me/online-resume/{section}/{id}` | 替换一条,body 为 `{revision, entry}` | |
||||
|
| DELETE | `/profile/me/online-resume/{section}/{id}?revision=数字` | 删除一条 | |
||||
|
| GET | `/profile/me/online-resume/download` | 当前已保存内容生成 DOCX,二进制响应 | |
||||
|
|
||||
|
`section` 仅限 `education-experiences`、`work-experiences`、`project-experiences`、`skills`。动态路由不表示可修改其他字段。 |
||||
|
|
||||
|
GET、保存和条目修改的成功响应仍用 `{code:200,message:"ok",data:...}`。整份 data 为 `{revision,content,attachment,updatedAt}`;条目列表/详情只返回条目自身。attachment 无附件时为 null 或省略,有附件时包含 fileName/contentType/size/downloadUrl;downloadUrl 为鉴权接口地址,不能直接把存储键拼成公网 URL。 |
||||
|
|
||||
|
## 完整保存示例 |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"revision": 0, |
||||
|
"content": { |
||||
|
"basicInfo": { |
||||
|
"realName": "合成测试", |
||||
|
"phone": "13800000000", |
||||
|
"email": "synthetic@example.test", |
||||
|
"location": "测试城市", |
||||
|
"education": "本科", |
||||
|
"school": "合成大学", |
||||
|
"graduationYear": 2022 |
||||
|
}, |
||||
|
"jobIntention": { |
||||
|
"targetPosition": "后端工程师", |
||||
|
"expectedSalaryMin": 10000, |
||||
|
"expectedSalaryMax": 20000, |
||||
|
"preferredCities": ["测试城市"], |
||||
|
"employmentTypes": ["FULL_TIME"], |
||||
|
"preferredIndustries": [], |
||||
|
"preferredJobTypes": [], |
||||
|
"remoteAllowed": false, |
||||
|
"jobSeekingStatus": "LOOKING", |
||||
|
"employmentStatus": "UNEMPLOYED", |
||||
|
"availableFrom": "2026-11-01" |
||||
|
}, |
||||
|
"educationExperiences": [ |
||||
|
{"school":"合成大学","major":"计算机","degree":"本科","startDate":"2018-09-01","endDate":"2022-06-30","ongoing":false,"description":"合成教育描述"} |
||||
|
], |
||||
|
"workExperiences": [ |
||||
|
{"company":"合成企业","position":"开发工程师","startDate":"2022-07-01","ongoing":true,"description":"合成工作内容"} |
||||
|
], |
||||
|
"projectExperiences": [ |
||||
|
{"name":"合成项目","role":"开发","startDate":"2023-01-01","ongoing":true,"description":"合成项目内容"} |
||||
|
], |
||||
|
"skills": [{"name":"Java","level":"PROFICIENT","years":3,"description":"合成技能说明"}], |
||||
|
"selfEvaluation": "合成自我评价" |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
这是完整替换接口,不能只提交当前编辑的一小块 content:空数组删除该类全部条目,可选字段 null/空字符串清空;省略某个集合会使用空集合,不能把省略理解为保持原值。局部编辑经历优先使用条目接口;基础信息、意向、自评编辑应先读完整内容,再修改相应块后整体保存。 |
||||
|
|
||||
|
新条目省略 id,服务器返回 UUID。保留既有条目时必须保留其 id;不存在或重复 id 返回 400,不接受自行指定新 id。条目 PUT 为完整替换,不能只提交某一个属性。条目顺序就是显示与导出的顺序。 |
||||
|
|
||||
|
## 字段及兼容 |
||||
|
|
||||
|
- basicInfo.phone/email 是本人简历联系方式,首次未保存时从账号手机号/邮箱带出。保存不修改登录手机号、账号邮箱或密码,不表示邮箱已验证。空联系方式可以清空;联系电话支持数字、开头加号、空格和常见座机分隔符,最多 30 字符。 |
||||
|
- 姓名、所在地、单组学历/学校/毕业年份复用既有个人资料;selfEvaluation 对应既有 bio;skills 的 name 同步到既有技能列表。旧资料接口更新后,再读在线简历会反映最新基础资料和技能名称;同名技能保留熟练度等扩展信息。老接口 skills 仍为历史字符串,新接口 skills 才是对象数组。 |
||||
|
- 多段 educationExperiences 与旧的单组 education/school/graduationYear 不自动推断相互覆盖;前端需要展示主学历时明确填写 basicInfo 对应字段。 |
||||
|
- 薪资单位为元/月、非负且下限不得高于上限;城市、用工类型、行业、岗位类型、远程意愿复用已有求职偏好。 |
||||
|
- jobSeekingStatus:LOOKING 正在求职、OPEN_TO_OPPORTUNITIES 考虑机会、NOT_LOOKING 暂不求职、STUDENT 在校求职身份。employmentStatus:EMPLOYED 在职、UNEMPLOYED 离职/未就业、STUDENT 在校。availableFrom 是可到岗日期。 |
||||
|
- 每段经历 startDate 必填,使用 YYYY-MM-DD。ongoing=true 时 endDate 必须省略或为 null;否则 endDate 必填且不早于 startDate。教育必填学校/学历,工作必填公司/职位,项目必填名称。 |
||||
|
- 教育最多 20 段,工作/项目各 30 段,技能 50 项;技能 name 不重复,level 为 BEGINNER/FAMILIAR/PROFICIENT/EXPERT,years 为 0—80 的整数。经历 description、自评各最多 5000 字;技能 description 最多 1000 字。其他长度约束见 OpenAPI。 |
||||
|
- 在线简历保存不修改现有附件、不创建附件快照,也不把多段经历自动下发给企业或恢复任何简历授权。 |
||||
|
|
||||
|
## 保存、登录回填和错误 |
||||
|
|
||||
|
1. 页面进入或切回账号后 GET 完整简历,按当前账号重建页面状态。不能只依赖上传控件、缓存或上一次登录的本地对象。 |
||||
|
2. 修改时提交最近一次成功响应中的 revision。成功后用响应整体替换页面数据,包括服务器分配的 id 和新 revision。 |
||||
|
3. 所有在线简历入口共用 revision,整份保存和条目修改都会加一。HTTP 409 / code 6002 表示版本过期:提示用户刷新并合并编辑,不能自动拿新 revision 盲重试覆盖。 |
||||
|
4. HTTP 404 / code 6003 表示 section 或本人条目不存在;400 表示字段、日期、id 或大小不合法;401/403 按既有登录、角色流程处理。 |
||||
|
5. revision 保护的是这些新增在线简历写入入口。不要并发用旧资料/偏好接口和新接口编辑同一份表单;旧接口并未加入在线简历 revision 协议。 |
||||
|
|
||||
|
## 附件、预览与导出 |
||||
|
|
||||
|
已有附件仍使用 POST/GET/DELETE `/profile/me/resume`。文件名、大小从新 GET 的 attachment 恢复展示,不能通过简历控件是否还保留本地 File 对象判断服务器有无附件。 |
||||
|
|
||||
|
在线预览可由前端根据 content 渲染。下载按钮携带 Bearer 获取 `/profile/me/online-resume/download` 的 Blob,按响应文件名保存 `.docx`;生成内容为服务器当前已保存的各段内容,不包含上传附件正文,编辑后应先保存再下载。此接口生成可编辑 Word 文件,不提供 PDF 排版或 AI 处理。 |
||||
|
|
||||
|
## 数据与验证边界 |
||||
|
|
||||
|
V59 新建 online_resumes,保存扩展内容与 revision;不回写历史迁移,不迁移或解析真实简历内容。新增数据纳入本人个人数据导出,账号注销完成时清理相应在线简历记录。未自动开放企业读取。 |
||||
|
|
||||
|
后端测试和制品证据单独保存在 D 盘;测试通过不表示已发布,前端接入与线上发布仍需分别安排。 |
||||
|
|
||||
|
本批本地验证(2026-10-20):最终源码的在线简历业务测试在 H2 和 MySQL 8 各 16 项通过;MySQL 5.7/8 的 V58→V59 迁移及 V58 重投回归通过;两种 MySQL 的运行时/静态接口集合一致。最新 16 个测试套件报告合并为 502 项,0 失败、0 错误、0 跳过,是多次定向执行的汇总,不是一次全量测试。静态契约为 205 paths / 253 operations,既有 paths 和 schema 未改写。 |
||||
|
|
||||
|
证据目录:`D:\Workspaces\qsp-online-resume-20261020-113017`,详见 verification-summary.json、source-manifest.json、artifact-identity.json、logs/。构建副本基于 67b0957,只叠加本批白名单文件,不含两个受保护文件的工作区修改。本批尚未执行无筛选的全量 mvn verify、独立 JAR HTTP 黑盒或前端验收;DOCX 已做 ZIP/XML 结构检查,未做 Word 视觉渲染。未提交、推送、部署或运行线上迁移。 |
||||
@ -0,0 +1,36 @@ |
|||||
|
# 在线简历批次 R01-E 查询预算调整说明 |
||||
|
|
||||
|
日期:2026-10-20 |
||||
|
范围:仅在线简历扩展数据进入个人数据导出;不改变其他章节的查询方式。 |
||||
|
|
||||
|
## 1. 变更 |
||||
|
|
||||
|
`PersonalDataCollector.collect(userId)` 在 profile、preferences 之后新增一次: |
||||
|
|
||||
|
```text |
||||
|
onlineResumeRepository.findById(userId) |
||||
|
``` |
||||
|
|
||||
|
对应 SQL 只按 `online_resumes.user_id` 主键读取当前登录用户的一条记录,不按投递数量循环,也不读取其他用户记录。 |
||||
|
|
||||
|
因此 R01-E 的 E1 最小夹具预算由 **20 次固定 SQL** 调整为 **21 次固定 SQL**: |
||||
|
|
||||
|
- 1 条投递:21 |
||||
|
- 10 条投递:21 |
||||
|
- 50 条投递:21 |
||||
|
- H2 与真实 MySQL 均重新实测 |
||||
|
- 所有被计数语句仍必须为 `SELECT` |
||||
|
- 丰富夹具下查询数不随记录数增长的断言保留 |
||||
|
|
||||
|
## 2. 独立校验 |
||||
|
|
||||
|
`R01EIndependentCrossDatabaseVerificationTest` 同时增加: |
||||
|
|
||||
|
- `online_resumes` 查询恰好一次; |
||||
|
- SQL 含 `user_id` 条件与绑定参数; |
||||
|
- 夹具同时放入本人与他人的在线简历,导出结果只含本人内容; |
||||
|
- 清理夹具删除 `online_resumes`,残留检查包含该表。 |
||||
|
|
||||
|
这不是放宽上限,也不是通过缓存或跳过导出内容规避查询;新增的一次查询是导出包含本人在线简历所必需的主键读取。 |
||||
|
|
||||
|
历史文档 `docs/r01-e-independent-verification-20260918.md` 中的 20/20/20 保留为当时批次的实测记录,不回溯改写。 |
||||
@ -0,0 +1,16 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import jakarta.persistence.*; |
||||
|
import lombok.*; |
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Entity |
||||
|
@Table(name = "online_resumes") |
||||
|
@Getter @Setter @NoArgsConstructor |
||||
|
public class OnlineResume { |
||||
|
@Id private Long userId; |
||||
|
@Column(nullable = false) private long revision; |
||||
|
@Column(name = "content_json", nullable = false, columnDefinition = "longtext") |
||||
|
private String contentJson; |
||||
|
@Column(name = "updated_at", nullable = false) private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,95 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import jakarta.validation.Valid; |
||||
|
import jakarta.validation.constraints.*; |
||||
|
import lombok.Data; |
||||
|
import java.time.LocalDate; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.List; |
||||
|
|
||||
|
/** 本人在线简历。PUT 为完整替换;经历 id 由服务器生成,更新时保留。 */ |
||||
|
@Data |
||||
|
public class OnlineResumeContent { |
||||
|
@Valid @NotNull private BasicInfo basicInfo = new BasicInfo(); |
||||
|
@Valid @NotNull private JobIntention jobIntention = new JobIntention(); |
||||
|
@Valid @NotNull @Size(max = 20) private List<@NotNull Education> educationExperiences = new ArrayList<>(); |
||||
|
@Valid @NotNull @Size(max = 30) private List<@NotNull Work> workExperiences = new ArrayList<>(); |
||||
|
@Valid @NotNull @Size(max = 30) private List<@NotNull Project> projectExperiences = new ArrayList<>(); |
||||
|
@Valid @NotNull @Size(max = 50) private List<@NotNull Skill> skills = new ArrayList<>(); |
||||
|
@Size(max = 5000) private String selfEvaluation; |
||||
|
|
||||
|
@Data @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeBasicInfo") |
||||
|
public static class BasicInfo { |
||||
|
@Size(max = 50) private String realName; |
||||
|
// 这是简历联系方式,不修改 users.phone / users.email 或登录认证。
|
||||
|
@Size(max = 30) @Pattern(regexp = "^$|\\+?[0-9][0-9() -]{4,29}") private String phone; |
||||
|
@Email @Size(max = 100) private String email; |
||||
|
@Size(max = 100) private String location; |
||||
|
@Size(max = 100) private String education; |
||||
|
@Size(max = 150) private String school; |
||||
|
@Min(1900) @Max(2200) private Integer graduationYear; |
||||
|
} |
||||
|
|
||||
|
public enum JobSeekingStatus { LOOKING, OPEN_TO_OPPORTUNITIES, NOT_LOOKING, STUDENT } |
||||
|
public enum EmploymentStatus { EMPLOYED, UNEMPLOYED, STUDENT } |
||||
|
|
||||
|
@Data @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeJobIntention") |
||||
|
public static class JobIntention { |
||||
|
@Size(max = 120) private String targetPosition; |
||||
|
@Min(0) private Integer expectedSalaryMin; |
||||
|
@Min(0) private Integer expectedSalaryMax; |
||||
|
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredCities = new ArrayList<>(); |
||||
|
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> employmentTypes = new ArrayList<>(); |
||||
|
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredIndustries = new ArrayList<>(); |
||||
|
@NotNull @Size(max = 20) private List<@NotBlank @Size(max = 100) String> preferredJobTypes = new ArrayList<>(); |
||||
|
private boolean remoteAllowed; |
||||
|
private JobSeekingStatus jobSeekingStatus; |
||||
|
private EmploymentStatus employmentStatus; |
||||
|
private LocalDate availableFrom; |
||||
|
@AssertTrue(message = "expectedSalaryMin must not exceed expectedSalaryMax") |
||||
|
@com.fasterxml.jackson.annotation.JsonIgnore |
||||
|
public boolean isSalaryRangeValid() { |
||||
|
return expectedSalaryMin == null || expectedSalaryMax == null || expectedSalaryMin <= expectedSalaryMax; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Data @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeDatedEntry") |
||||
|
public static class DatedEntry { |
||||
|
@Pattern(regexp = "[a-f0-9\\-]{36}") private String id; |
||||
|
@NotNull private LocalDate startDate; |
||||
|
private LocalDate endDate; |
||||
|
private boolean ongoing; |
||||
|
@Size(max = 5000) private String description; |
||||
|
@AssertTrue(message = "ongoing entries have no endDate; otherwise endDate >= startDate is required") |
||||
|
@com.fasterxml.jackson.annotation.JsonIgnore |
||||
|
public boolean isDateRangeValid() { |
||||
|
return ongoing ? endDate == null : startDate != null && endDate != null && !endDate.isBefore(startDate); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@Data @lombok.EqualsAndHashCode(callSuper = true) @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeEducation", additionalProperties = io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE) |
||||
|
public static class Education extends DatedEntry { |
||||
|
@NotBlank @Size(max = 150) private String school; |
||||
|
@Size(max = 150) private String major; |
||||
|
@NotBlank @Size(max = 100) private String degree; |
||||
|
} |
||||
|
@Data @lombok.EqualsAndHashCode(callSuper = true) @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeWork", additionalProperties = io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE) |
||||
|
public static class Work extends DatedEntry { |
||||
|
@NotBlank @Size(max = 150) private String company; |
||||
|
@NotBlank @Size(max = 120) private String position; |
||||
|
} |
||||
|
@Data @lombok.EqualsAndHashCode(callSuper = true) @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeProject", additionalProperties = io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE) |
||||
|
public static class Project extends DatedEntry { |
||||
|
@NotBlank @Size(max = 150) private String name; |
||||
|
@Size(max = 120) private String role; |
||||
|
} |
||||
|
public enum SkillLevel { BEGINNER, FAMILIAR, PROFICIENT, EXPERT } |
||||
|
@Data @io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeSkill", additionalProperties = io.swagger.v3.oas.annotations.media.Schema.AdditionalPropertiesValue.FALSE) |
||||
|
public static class Skill { |
||||
|
@Pattern(regexp = "[a-f0-9\\-]{36}") private String id; |
||||
|
@NotBlank @Size(max = 100) private String name; |
||||
|
private SkillLevel level; |
||||
|
@Min(0) @Max(80) private Integer years; |
||||
|
@Size(max = 1000) private String description; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,97 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.*; |
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.exception.*; |
||||
|
import com.mydreamjob.common.logging.RequestIdAccessor; |
||||
|
import com.mydreamjob.security.CurrentUserAccessor; |
||||
|
import io.swagger.v3.oas.annotations.*; |
||||
|
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; |
||||
|
import jakarta.validation.constraints.*; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.*; |
||||
|
import org.springframework.validation.annotation.Validated; |
||||
|
import org.springframework.web.bind.annotation.*; |
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
|
||||
|
@RestController @RequiredArgsConstructor @Validated |
||||
|
@RequestMapping("/profile/me/online-resume") |
||||
|
@Tag(name = "在线简历", description = "仅当前 STUDENT 本人;不修改账号登录信息,不读取附件正文") |
||||
|
public class OnlineResumeController { |
||||
|
private final OnlineResumeService service; |
||||
|
private final OnlineResumeDocxWriter docx; |
||||
|
private final CurrentUserAccessor currentUser; |
||||
|
private final RequestIdAccessor requestIds; |
||||
|
private final ObjectMapper mapper; |
||||
|
|
||||
|
@Schema(name = "OnlineResumeEntryRequest") |
||||
|
@lombok.Data |
||||
|
public static class EntryRequest { |
||||
|
@NotNull @PositiveOrZero private Long revision; |
||||
|
@NotNull @Schema(implementation = Object.class, oneOf = {OnlineResumeContent.Education.class, OnlineResumeContent.Work.class, |
||||
|
OnlineResumeContent.Project.class, OnlineResumeContent.Skill.class}) |
||||
|
private JsonNode entry; |
||||
|
} |
||||
|
|
||||
|
@GetMapping |
||||
|
@Operation(operationId = "getMyOnlineResume", summary = "读取本人完整在线简历", description = "无保存记录时 revision=0,从已有资料、求职偏好和账号联系方式组装,不写库。attachment 仅返回文件元数据及鉴权下载地址。phone/email 为本人简历联系方式,不下发到企业候选人接口。") |
||||
|
public ApiResponse<OnlineResumeView> get(HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.get(currentUser.requireUserId()), requestIds.get(request)); |
||||
|
} |
||||
|
@PutMapping |
||||
|
@Operation(operationId = "saveMyOnlineResume", summary = "保存完整在线简历", description = "携带最近 GET 的 revision;content 完整替换,空数组删除该类全部条目,null/空字符串可清空可选字段。新条目省略 id,既有条目保留 id。成功 revision+1;过期 revision 返回409/6002。同步既有个人资料和求职偏好,不改登录手机号/邮箱、附件及隐私授权。") |
||||
|
public ApiResponse<OnlineResumeView> save(@Valid @RequestBody OnlineResumeSaveRequest body, HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.save(currentUser.requireUserId(), body), requestIds.get(request)); |
||||
|
} |
||||
|
@GetMapping("/{section}") |
||||
|
@Operation(operationId = "listMyOnlineResumeEntries", summary = "读取某类简历条目", description = "section 仅 education-experiences/work-experiences/project-experiences/skills;revision 从整份简历 GET 获取。") |
||||
|
public ApiResponse<java.util.List<Object>> list(@PathVariable String section, HttpServletRequest request) { |
||||
|
java.util.List<Object> result = new java.util.ArrayList<>(); |
||||
|
entries(service.get(currentUser.requireUserId()), section).forEach(result::add); |
||||
|
return ApiResponse.ok(result, requestIds.get(request)); |
||||
|
} |
||||
|
@GetMapping("/{section}/{id}") |
||||
|
@Operation(operationId = "getMyOnlineResumeEntry", summary = "读取本人简历条目详情", description = "只在本人指定类别内寻找条目,不存在返回404/6003。") |
||||
|
public ApiResponse<Object> detail(@PathVariable String section, @PathVariable String id, HttpServletRequest request) { |
||||
|
for (JsonNode e : entries(service.get(currentUser.requireUserId()), section)) |
||||
|
if (id.equals(e.path("id").asText())) return ApiResponse.ok(e, requestIds.get(request)); |
||||
|
throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND); |
||||
|
} |
||||
|
@PostMapping("/{section}") |
||||
|
@Operation(operationId = "createMyOnlineResumeEntry", summary = "新增简历条目", description = "section 仅 education-experiences/work-experiences/project-experiences/skills;entry 为相应类型,新条目不传 id;携带整份简历 revision,返回更新后的完整简历。") |
||||
|
public ApiResponse<OnlineResumeView> create(@PathVariable String section, @Valid @RequestBody EntryRequest body, HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.editEntry(currentUser.requireUserId(), section, null, body.getRevision(), body.getEntry(), false), requestIds.get(request)); |
||||
|
} |
||||
|
@PutMapping("/{section}/{id}") |
||||
|
@Operation(operationId = "updateMyOnlineResumeEntry", summary = "替换简历条目", description = "entry 完整替换本人条目;id 可省略或须与 URL 一致;携带整份简历 revision。") |
||||
|
public ApiResponse<OnlineResumeView> update(@PathVariable String section, @PathVariable String id, @Valid @RequestBody EntryRequest body, HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.editEntry(currentUser.requireUserId(), section, id, body.getRevision(), body.getEntry(), false), requestIds.get(request)); |
||||
|
} |
||||
|
@DeleteMapping("/{section}/{id}") |
||||
|
@Operation(operationId = "deleteMyOnlineResumeEntry", summary = "删除简历条目", description = "query revision 必填;不存在返回404/6003,过期版本返回409/6002;返回更新后的完整简历。") |
||||
|
public ApiResponse<OnlineResumeView> delete(@PathVariable String section, @PathVariable String id, |
||||
|
@RequestParam @Min(0) Long revision, HttpServletRequest request) { |
||||
|
return ApiResponse.ok(service.editEntry(currentUser.requireUserId(), section, id, revision, null, true), requestIds.get(request)); |
||||
|
} |
||||
|
@GetMapping(value = "/download", produces = "application/vnd.openxmlformats-officedocument.wordprocessingml.document") |
||||
|
@Operation(operationId = "downloadMyOnlineResume", summary = "下载在线简历 DOCX", description = "从当前已保存的在线内容生成 Word 文件,不含上传附件,不写文件、不创建附件版本;浏览器应携带鉴权获取 Blob,未保存编辑需先 PUT。") |
||||
|
public ResponseEntity<byte[]> download() { |
||||
|
byte[] bytes = docx.write(service.get(currentUser.requireUserId()).content()); |
||||
|
return ResponseEntity.ok().cacheControl(CacheControl.noStore()) |
||||
|
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename("个人简历.docx", StandardCharsets.UTF_8).build().toString()) |
||||
|
.contentType(MediaType.parseMediaType("application/vnd.openxmlformats-officedocument.wordprocessingml.document")) |
||||
|
.contentLength(bytes.length).body(bytes); |
||||
|
} |
||||
|
private JsonNode entries(OnlineResumeView view, String section) { |
||||
|
return mapper.valueToTree(switch (section) { |
||||
|
case "education-experiences" -> view.content().getEducationExperiences(); |
||||
|
case "work-experiences" -> view.content().getWorkExperiences(); |
||||
|
case "project-experiences" -> view.content().getProjectExperiences(); |
||||
|
case "skills" -> view.content().getSkills(); |
||||
|
default -> throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND); |
||||
|
}); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,70 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import org.springframework.stereotype.Component; |
||||
|
import java.io.*; |
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.util.*; |
||||
|
import java.util.zip.*; |
||||
|
|
||||
|
/** 生成真正的 OOXML 文档;用户文本只作为文字写入,无外链、宏或文件读取。 */ |
||||
|
@Component |
||||
|
public class OnlineResumeDocxWriter { |
||||
|
public byte[] write(OnlineResumeContent c) { |
||||
|
List<String> lines = new ArrayList<>(); |
||||
|
var b = c.getBasicInfo(); |
||||
|
lines.add(b.getRealName() == null || b.getRealName().isBlank() ? "个人简历" : b.getRealName() + "的简历"); |
||||
|
lines.add(join(b.getPhone(), b.getEmail(), b.getLocation())); |
||||
|
lines.add(join(b.getEducation(), b.getSchool(), b.getGraduationYear())); |
||||
|
var j = c.getJobIntention(); |
||||
|
lines.add("求职意向"); lines.add(join(j.getTargetPosition(), String.join("、", j.getPreferredCities()))); |
||||
|
if (j.getExpectedSalaryMin() != null || j.getExpectedSalaryMax() != null) |
||||
|
lines.add("期望月薪(元):" + Objects.toString(j.getExpectedSalaryMin(), "不限") + "—" + Objects.toString(j.getExpectedSalaryMax(), "不限")); |
||||
|
lines.add(join(String.join("、", j.getEmploymentTypes()), String.join("、", j.getPreferredIndustries()), String.join("、", j.getPreferredJobTypes()))); |
||||
|
if (j.getJobSeekingStatus() != null) lines.add("求职状态:" + switch (j.getJobSeekingStatus()) { |
||||
|
case LOOKING -> "正在求职"; case OPEN_TO_OPPORTUNITIES -> "考虑机会"; |
||||
|
case NOT_LOOKING -> "暂不求职"; case STUDENT -> "在校学生"; |
||||
|
}); |
||||
|
if (j.getAvailableFrom() != null) lines.add("可到岗日期:" + j.getAvailableFrom()); |
||||
|
if (j.getEmploymentStatus() != null) lines.add("当前状态:" + switch (j.getEmploymentStatus()) { |
||||
|
case EMPLOYED -> "在职"; case UNEMPLOYED -> "离职/未就业"; case STUDENT -> "在校"; |
||||
|
}); |
||||
|
if (j.isRemoteAllowed()) lines.add("接受远程工作"); |
||||
|
lines.add("教育经历"); |
||||
|
for (var e : c.getEducationExperiences()) { lines.add(join(e.getSchool(), e.getMajor(), e.getDegree(), dates(e))); lines.add(e.getDescription()); } |
||||
|
lines.add("工作经历"); |
||||
|
for (var e : c.getWorkExperiences()) { lines.add(join(e.getCompany(), e.getPosition(), dates(e))); lines.add(e.getDescription()); } |
||||
|
lines.add("项目经历"); |
||||
|
for (var e : c.getProjectExperiences()) { lines.add(join(e.getName(), e.getRole(), dates(e))); lines.add(e.getDescription()); } |
||||
|
lines.add("技能特长"); |
||||
|
for (var e : c.getSkills()) { |
||||
|
String level = e.getLevel() == null ? null : switch (e.getLevel()) { |
||||
|
case BEGINNER -> "入门"; case FAMILIAR -> "熟悉"; case PROFICIENT -> "熟练"; case EXPERT -> "精通"; |
||||
|
}; |
||||
|
lines.add(join(e.getName(), level, e.getYears() == null ? null : e.getYears() + "年")); lines.add(e.getDescription()); |
||||
|
} |
||||
|
lines.add("自我评价"); lines.add(c.getSelfEvaluation()); |
||||
|
StringBuilder body = new StringBuilder(); |
||||
|
for (String text : lines) { |
||||
|
if (text == null || text.isBlank()) continue; |
||||
|
for (String line : text.split("\\R", -1)) body.append("<w:p><w:pPr><w:spacing w:after=\"120\"/></w:pPr><w:r><w:rPr><w:rFonts w:ascii=\"Calibri\" w:eastAsia=\"Microsoft YaHei\"/><w:sz w:val=\"22\"/></w:rPr><w:t xml:space=\"preserve\">") |
||||
|
.append(xml(line)).append("</w:t></w:r></w:p>"); |
||||
|
} |
||||
|
body.append("<w:sectPr><w:pgSz w:w=\"11906\" w:h=\"16838\"/><w:pgMar w:top=\"1134\" w:right=\"1134\" w:bottom=\"1134\" w:left=\"1134\"/></w:sectPr>"); |
||||
|
try (var out = new ByteArrayOutputStream(); var zip = new ZipOutputStream(out, StandardCharsets.UTF_8)) { |
||||
|
add(zip, "[Content_Types].xml", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Types xmlns=\"http://schemas.openxmlformats.org/package/2006/content-types\"><Default Extension=\"rels\" ContentType=\"application/vnd.openxmlformats-package.relationships+xml\"/><Default Extension=\"xml\" ContentType=\"application/xml\"/><Override PartName=\"/word/document.xml\" ContentType=\"application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml\"/></Types>"); |
||||
|
add(zip, "_rels/.rels", "<?xml version=\"1.0\" encoding=\"UTF-8\"?><Relationships xmlns=\"http://schemas.openxmlformats.org/package/2006/relationships\"><Relationship Id=\"rId1\" Type=\"http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument\" Target=\"word/document.xml\"/></Relationships>"); |
||||
|
add(zip, "word/document.xml", "<?xml version=\"1.0\" encoding=\"UTF-8\" standalone=\"yes\"?><w:document xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"><w:body>" + body + "</w:body></w:document>"); |
||||
|
zip.finish(); return out.toByteArray(); |
||||
|
} catch (IOException e) { throw new IllegalStateException("cannot generate resume document", e); } |
||||
|
} |
||||
|
private String dates(OnlineResumeContent.DatedEntry e) { return Objects.toString(e.getStartDate(), "") + "—" + (e.isOngoing() ? "至今" : Objects.toString(e.getEndDate(), "")); } |
||||
|
private String join(Object... fields) { return Arrays.stream(fields).filter(Objects::nonNull).map(Object::toString).filter(s -> !s.isBlank()).collect(java.util.stream.Collectors.joining(" · ")); } |
||||
|
private String xml(String s) { |
||||
|
StringBuilder clean = new StringBuilder(); |
||||
|
s.codePoints().filter(c -> c == 9 || c == 10 || c == 13 || c >= 32 && c <= 0xd7ff || c >= 0xe000 && c <= 0xfffd || c >= 0x10000 && c <= 0x10ffff).forEach(clean::appendCodePoint); |
||||
|
return clean.toString().replace("&", "&").replace("<", "<").replace(">", ">").replace("\"", """).replace("'", "'"); |
||||
|
} |
||||
|
private void add(ZipOutputStream zip, String name, String text) throws IOException { |
||||
|
zip.putNextEntry(new ZipEntry(name)); zip.write(text.getBytes(StandardCharsets.UTF_8)); zip.closeEntry(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import io.swagger.v3.oas.models.media.Schema; |
||||
|
import org.springdoc.core.customizers.OpenApiCustomizer; |
||||
|
import org.springframework.context.annotation.Bean; |
||||
|
import org.springframework.context.annotation.Configuration; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.LinkedHashSet; |
||||
|
|
||||
|
@Configuration(proxyBeanMethods = false) |
||||
|
public class OnlineResumeOpenApiConfiguration { |
||||
|
@Bean |
||||
|
OpenApiCustomizer onlineResumeEntrySchema() { |
||||
|
return api -> { |
||||
|
if (api.getComponents() == null || api.getComponents().getSchemas() == null) return; |
||||
|
Schema<?> request = api.getComponents().getSchemas().get("OnlineResumeEntryRequest"); |
||||
|
if (request == null || request.getProperties() == null) return; |
||||
|
Schema<?> entry = request.getProperties().get("entry"); |
||||
|
if (entry == null || entry.getOneOf() == null) return; |
||||
|
// Springdoc 2.8 的 JsonNode + implementation 注解会重复合并 oneOf。
|
||||
|
// oneOf 中相同分支出现两次将导致合法对象也无法通过 schema 校验。
|
||||
|
var seen = new LinkedHashSet<String>(); |
||||
|
var unique = new ArrayList<Schema>(); |
||||
|
for (Schema variant : entry.getOneOf()) |
||||
|
if (variant.get$ref() == null || seen.add(variant.get$ref())) unique.add(variant); |
||||
|
entry.setOneOf(unique); |
||||
|
}; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,5 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
|
||||
|
public interface OnlineResumeRepository extends JpaRepository<OnlineResume, Long> {} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import jakarta.validation.Valid; |
||||
|
import jakarta.validation.constraints.*; |
||||
|
|
||||
|
public record OnlineResumeSaveRequest(@NotNull @PositiveOrZero Long revision, |
||||
|
@NotNull @Valid OnlineResumeContent content) {} |
||||
@ -0,0 +1,233 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.JsonProcessingException; |
||||
|
import com.fasterxml.jackson.databind.*; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.candidatescore.refresh.CandidateScoringRefreshRequestService; |
||||
|
import com.mydreamjob.common.exception.*; |
||||
|
import com.mydreamjob.preference.*; |
||||
|
import com.mydreamjob.profile.*; |
||||
|
import com.mydreamjob.security.UserRoleGuard; |
||||
|
import jakarta.persistence.*; |
||||
|
import jakarta.validation.*; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.*; |
||||
|
import java.util.function.Function; |
||||
|
|
||||
|
@Service @RequiredArgsConstructor |
||||
|
public class OnlineResumeService { |
||||
|
private final OnlineResumeRepository resumes; |
||||
|
private final UserRepository users; |
||||
|
private final StudentProfileRepository profiles; |
||||
|
private final StudentPreferenceRepository preferences; |
||||
|
private final CandidateScoringRefreshRequestService refresh; |
||||
|
private final UserRoleGuard roles; |
||||
|
private final ObjectMapper mapper; |
||||
|
private final Validator validator; |
||||
|
@PersistenceContext private EntityManager em; |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public OnlineResumeView get(Long userId) { |
||||
|
roles.requireStudent(userId); |
||||
|
User u = users.findById(userId).orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
return view(u, resumes.findById(userId).orElse(null)); |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public OnlineResumeView save(Long userId, OnlineResumeSaveRequest request) { |
||||
|
User u = lockUser(userId); |
||||
|
OnlineResume row = resumes.findById(userId).orElse(null); |
||||
|
OnlineResumeView current = view(u, row); |
||||
|
checkRevision(current.revision(), request.revision()); |
||||
|
return persist(u, row, request.content(), current.content()); |
||||
|
} |
||||
|
|
||||
|
/** 四种条目共用整份简历 revision,避免局部编辑与整份保存互相覆盖。 */ |
||||
|
@Transactional |
||||
|
public OnlineResumeView editEntry(Long userId, String section, String id, Long revision, |
||||
|
JsonNode entry, boolean delete) { |
||||
|
User u = lockUser(userId); |
||||
|
OnlineResume row = resumes.findById(userId).orElse(null); |
||||
|
OnlineResumeView current = view(u, row); |
||||
|
checkRevision(current.revision(), revision); |
||||
|
OnlineResumeContent content = copy(current.content()); |
||||
|
switch (section) { |
||||
|
case "education-experiences" -> edit(content.getEducationExperiences(), id, entry, delete, OnlineResumeContent.Education.class, |
||||
|
OnlineResumeContent.Education::getId, OnlineResumeContent.Education::setId); |
||||
|
case "work-experiences" -> edit(content.getWorkExperiences(), id, entry, delete, OnlineResumeContent.Work.class, |
||||
|
OnlineResumeContent.Work::getId, OnlineResumeContent.Work::setId); |
||||
|
case "project-experiences" -> edit(content.getProjectExperiences(), id, entry, delete, OnlineResumeContent.Project.class, |
||||
|
OnlineResumeContent.Project::getId, OnlineResumeContent.Project::setId); |
||||
|
case "skills" -> edit(content.getSkills(), id, entry, delete, OnlineResumeContent.Skill.class, |
||||
|
OnlineResumeContent.Skill::getId, OnlineResumeContent.Skill::setId); |
||||
|
default -> throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND); |
||||
|
} |
||||
|
return persist(u, row, content, current.content()); |
||||
|
} |
||||
|
|
||||
|
private <T> void edit(List<T> list, String id, JsonNode json, boolean delete, Class<T> type, |
||||
|
Function<T, String> getId, java.util.function.BiConsumer<T, String> setId) { |
||||
|
int index = -1; |
||||
|
for (int i = 0; i < list.size(); i++) if (Objects.equals(getId.apply(list.get(i)), id)) index = i; |
||||
|
if (id != null && index < 0) throw new BusinessException(ErrorCode.ONLINE_RESUME_ENTRY_NOT_FOUND); |
||||
|
if (delete) { list.remove(index); return; } |
||||
|
T value; |
||||
|
try { |
||||
|
value = mapper.readerFor(type).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(json); |
||||
|
} catch (java.io.IOException | IllegalArgumentException e) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "invalid resume entry"); |
||||
|
} |
||||
|
if (value == null || getId.apply(value) != null && !Objects.equals(getId.apply(value), id)) |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "entry id must match the URL; new entries omit id"); |
||||
|
setId.accept(value, id); |
||||
|
if (index < 0) list.add(value); else list.set(index, value); |
||||
|
} |
||||
|
|
||||
|
private User lockUser(Long userId) { |
||||
|
// 锁定已存在的用户行,首次创建也有稳定锁对象;不锁定其他用户。
|
||||
|
User u = em.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE); |
||||
|
if (u == null) throw new BusinessException(ErrorCode.USER_NOT_FOUND); |
||||
|
roles.requireStudent(u); |
||||
|
return u; |
||||
|
} |
||||
|
|
||||
|
private void checkRevision(long actual, Long expected) { |
||||
|
if (expected == null || actual != expected) |
||||
|
throw new BusinessException(ErrorCode.ONLINE_RESUME_REVISION_CONFLICT); |
||||
|
} |
||||
|
|
||||
|
private OnlineResumeView persist(User user, OnlineResume row, OnlineResumeContent content, OnlineResumeContent before) { |
||||
|
content = copy(content); |
||||
|
validate(content); |
||||
|
Set<String> skillNames = new HashSet<>(); |
||||
|
for (var skill : content.getSkills()) { |
||||
|
skill.setName(skill.getName().trim()); |
||||
|
if (!skillNames.add(skill.getName())) |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "duplicate skill name"); |
||||
|
} |
||||
|
assignIds(content.getEducationExperiences(), before.getEducationExperiences(), OnlineResumeContent.Education::getId, OnlineResumeContent.Education::setId); |
||||
|
assignIds(content.getWorkExperiences(), before.getWorkExperiences(), OnlineResumeContent.Work::getId, OnlineResumeContent.Work::setId); |
||||
|
assignIds(content.getProjectExperiences(), before.getProjectExperiences(), OnlineResumeContent.Project::getId, OnlineResumeContent.Project::setId); |
||||
|
assignIds(content.getSkills(), before.getSkills(), OnlineResumeContent.Skill::getId, OnlineResumeContent.Skill::setId); |
||||
|
String json = encode(content); |
||||
|
if (json.getBytes(StandardCharsets.UTF_8).length > 1024 * 1024) |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "resume is too large"); |
||||
|
rejectControlCharacters(mapper.valueToTree(content)); |
||||
|
|
||||
|
Long uid = user.getId(); |
||||
|
StudentProfile p = profiles.lockByUserId(uid).orElseGet(() -> StudentProfile.builder().userId(uid).build()); |
||||
|
var basic = content.getBasicInfo(); |
||||
|
p.setRealName(basic.getRealName()); p.setLocation(basic.getLocation()); |
||||
|
p.setEducation(basic.getEducation()); p.setSchool(basic.getSchool()); |
||||
|
p.setGraduationYear(basic.getGraduationYear() == null ? null : basic.getGraduationYear().shortValue()); |
||||
|
p.setBio(content.getSelfEvaluation()); |
||||
|
p.setSkillsJson(encode(content.getSkills().stream().map(OnlineResumeContent.Skill::getName).toList())); |
||||
|
// 不触碰 resumeUrl、原始文件名或文件内容;也不改变账号手机号/邮箱。
|
||||
|
profiles.save(p); |
||||
|
StudentPreference pref = preferences.findByStudentUserId(uid).orElseGet(() -> StudentPreference.builder().studentUserId(uid).build()); |
||||
|
var intention = content.getJobIntention(); |
||||
|
pref.setExpectedSalaryMin(intention.getExpectedSalaryMin()); pref.setExpectedSalaryMax(intention.getExpectedSalaryMax()); |
||||
|
pref.setPreferredCities(encode(intention.getPreferredCities())); pref.setEmploymentTypes(encode(intention.getEmploymentTypes())); |
||||
|
pref.setPreferredIndustries(encode(intention.getPreferredIndustries())); pref.setPreferredJobTypes(encode(intention.getPreferredJobTypes())); |
||||
|
pref.setRemoteAllowed(intention.isRemoteAllowed()); preferences.save(pref); |
||||
|
if (row == null) { row = new OnlineResume(); row.setUserId(uid); } |
||||
|
row.setContentJson(json); row.setRevision(Math.addExact(row.getRevision(), 1)); |
||||
|
row.setUpdatedAt(LocalDateTime.now().truncatedTo(java.time.temporal.ChronoUnit.MICROS)); resumes.saveAndFlush(row); |
||||
|
refresh.requestRefresh(uid, LocalDateTime.now()); |
||||
|
return view(user, row); |
||||
|
} |
||||
|
|
||||
|
private <T> void assignIds(List<T> next, List<T> previous, Function<T, String> getId, |
||||
|
java.util.function.BiConsumer<T, String> setId) { |
||||
|
Set<String> allowed = new HashSet<>(); previous.forEach(x -> allowed.add(getId.apply(x))); |
||||
|
Set<String> used = new HashSet<>(); |
||||
|
for (T value : next) { |
||||
|
String id = getId.apply(value); |
||||
|
if (id == null) { id = UUID.randomUUID().toString(); setId.accept(value, id); } |
||||
|
else if (!allowed.contains(id)) throw new BusinessException(ErrorCode.BAD_REQUEST, "unknown resume entry id"); |
||||
|
if (!used.add(id)) throw new BusinessException(ErrorCode.BAD_REQUEST, "duplicate resume entry id"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private OnlineResumeView view(User user, OnlineResume row) { |
||||
|
OnlineResumeContent c = row == null ? new OnlineResumeContent() : decode(row.getContentJson()); |
||||
|
var b = c.getBasicInfo(); |
||||
|
if (row == null) { b.setPhone(user.getPhone()); b.setEmail(user.getEmail()); } |
||||
|
StudentProfile p = profiles.findByUserId(user.getId()).orElse(null); |
||||
|
if (p != null) { |
||||
|
b.setRealName(p.getRealName()); b.setLocation(p.getLocation()); b.setSchool(p.getSchool()); b.setEducation(p.getEducation()); |
||||
|
b.setGraduationYear(p.getGraduationYear() == null ? null : p.getGraduationYear().intValue()); |
||||
|
c.setSelfEvaluation(p.getBio()); |
||||
|
// 旧资料接口仍可编辑基础技能;按名字保留在线简历的扩展信息。
|
||||
|
List<String> names = legacySkills(p.getSkillsJson()); |
||||
|
Map<String, OnlineResumeContent.Skill> details = new LinkedHashMap<>(); |
||||
|
c.getSkills().forEach(s -> details.putIfAbsent(s.getName(), s)); |
||||
|
List<OnlineResumeContent.Skill> skills = new ArrayList<>(); |
||||
|
for (int i = 0; i < names.size(); i++) { |
||||
|
String name = names.get(i); |
||||
|
OnlineResumeContent.Skill s = details.get(name); |
||||
|
if (s == null) { s = new OnlineResumeContent.Skill(); s.setName(name); |
||||
|
s.setId(UUID.nameUUIDFromBytes((user.getId() + ":" + i + ":" + name).getBytes(StandardCharsets.UTF_8)).toString()); } |
||||
|
skills.add(s); |
||||
|
} |
||||
|
c.setSkills(skills); |
||||
|
} |
||||
|
var intention = c.getJobIntention(); |
||||
|
preferences.findByStudentUserId(user.getId()).ifPresent(pref -> { |
||||
|
intention.setExpectedSalaryMin(pref.getExpectedSalaryMin()); intention.setExpectedSalaryMax(pref.getExpectedSalaryMax()); |
||||
|
intention.setPreferredCities(legacySkills(pref.getPreferredCities())); intention.setEmploymentTypes(legacySkills(pref.getEmploymentTypes())); |
||||
|
intention.setPreferredIndustries(legacySkills(pref.getPreferredIndustries())); intention.setPreferredJobTypes(legacySkills(pref.getPreferredJobTypes())); |
||||
|
intention.setRemoteAllowed(Boolean.TRUE.equals(pref.getRemoteAllowed())); |
||||
|
}); |
||||
|
OnlineResumeView.Attachment attachment = p == null || p.getResumeUrl() == null ? null : |
||||
|
new OnlineResumeView.Attachment(p.getResumeOriginalName(), p.getResumeContentType(), p.getResumeSize(), "/api/v1/profile/me/resume"); |
||||
|
return new OnlineResumeView(row == null ? 0 : row.getRevision(), c, attachment, row == null ? null : row.getUpdatedAt()); |
||||
|
} |
||||
|
|
||||
|
private List<String> legacySkills(String json) { |
||||
|
if (json == null || json.isBlank()) return new ArrayList<>(); |
||||
|
try { |
||||
|
// H2 的历史 JSON 列可将 Java String 再包装为 JSON 字符串;同时兼容真实 MySQL 数组。
|
||||
|
JsonNode node = mapper.readTree(json); |
||||
|
for (int depth = 0; depth < 3 && node != null && node.isTextual(); depth++) |
||||
|
node = mapper.readTree(node.textValue()); |
||||
|
if (node == null || node.isNull()) return new ArrayList<>(); |
||||
|
if (node != null && node.isArray()) { |
||||
|
List<String> values = new ArrayList<>(); |
||||
|
for (JsonNode item : node) { |
||||
|
if (!item.isTextual()) throw new IllegalArgumentException("not a string array"); |
||||
|
values.add(item.textValue()); |
||||
|
} |
||||
|
return values; |
||||
|
} |
||||
|
} catch (JsonProcessingException | IllegalArgumentException ignored) { /* 兼容历史 [Java, SQL],不静默丢失资料。 */ } |
||||
|
String text = json.trim(); |
||||
|
if (text.startsWith("[") && text.endsWith("]")) text = text.substring(1, text.length() - 1); |
||||
|
return new ArrayList<>(Arrays.stream(text.split(",")).map(String::trim).filter(s -> !s.isEmpty()).toList()); |
||||
|
} |
||||
|
|
||||
|
private void validate(OnlineResumeContent content) { |
||||
|
Set<ConstraintViolation<OnlineResumeContent>> errors = validator.validate(content); |
||||
|
if (!errors.isEmpty()) throw new ConstraintViolationException(errors); |
||||
|
} |
||||
|
private void rejectControlCharacters(JsonNode node) { |
||||
|
if (node.isTextual()) { |
||||
|
if (node.textValue().codePoints().anyMatch(c -> c < 32 && c != 9 && c != 10 && c != 13 || c == 0xfffe || c == 0xffff)) |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST, "unsupported control character in resume"); |
||||
|
} else node.forEach(this::rejectControlCharacters); |
||||
|
} |
||||
|
private String encode(Object value) { |
||||
|
try { return mapper.writeValueAsString(value); } |
||||
|
catch (JsonProcessingException e) { throw new IllegalStateException("cannot encode online resume", e); } |
||||
|
} |
||||
|
private OnlineResumeContent decode(String json) { |
||||
|
try { return mapper.readValue(json, OnlineResumeContent.class); } |
||||
|
catch (JsonProcessingException e) { throw new IllegalStateException("cannot decode online resume", e); } |
||||
|
} |
||||
|
private OnlineResumeContent copy(OnlineResumeContent content) { return decode(encode(content)); } |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
public record OnlineResumeView(long revision, OnlineResumeContent content, |
||||
|
Attachment attachment, LocalDateTime updatedAt) { |
||||
|
/** 无附件时 attachment=null。文件必须经鉴权下载接口访问。 */ |
||||
|
@io.swagger.v3.oas.annotations.media.Schema(name = "OnlineResumeAttachment") |
||||
|
public record Attachment(String fileName, String contentType, Long size, String downloadUrl) {} |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
-- 在线简历扩展;不改写既有学生资料、偏好或附件。 |
||||
|
CREATE TABLE online_resumes ( |
||||
|
user_id BIGINT NOT NULL PRIMARY KEY, |
||||
|
revision BIGINT NOT NULL DEFAULT 0, |
||||
|
content_json LONGTEXT NOT NULL, |
||||
|
updated_at DATETIME(6) NOT NULL, |
||||
|
CONSTRAINT fk_online_resumes_user FOREIGN KEY (user_id) REFERENCES users(id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; |
||||
@ -0,0 +1,232 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.*; |
||||
|
import com.mydreamjob.auth.*; |
||||
|
import com.mydreamjob.profile.*; |
||||
|
import com.mydreamjob.preference.*; |
||||
|
import org.junit.jupiter.api.*; |
||||
|
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.*; |
||||
|
import org.springframework.http.MediaType; |
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.time.LocalDate; |
||||
|
import java.util.*; |
||||
|
import java.util.concurrent.*; |
||||
|
import java.util.concurrent.atomic.AtomicLong; |
||||
|
import java.util.zip.ZipInputStream; |
||||
|
import java.io.ByteArrayInputStream; |
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; |
||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; |
||||
|
|
||||
|
@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("integration-test") |
||||
|
@org.springframework.test.context.TestPropertySource(properties = "spring.datasource.url=jdbc:h2:mem:online_resume;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE") |
||||
|
class OnlineResumeIntegrationTest { |
||||
|
private static final String PATH = "/profile/me/online-resume"; |
||||
|
private static final AtomicLong PHONE = new AtomicLong(19907000000L); |
||||
|
@Autowired MockMvc mvc; |
||||
|
@Autowired ObjectMapper mapper; |
||||
|
@Autowired UserRepository users; |
||||
|
@Autowired AuthSessionService sessions; |
||||
|
@Autowired StudentProfileRepository profiles; |
||||
|
@Autowired StudentPreferenceRepository preferences; |
||||
|
@Autowired OnlineResumeRepository resumes; |
||||
|
@Autowired OnlineResumeService service; |
||||
|
@Autowired com.mydreamjob.dataexport.PersonalDataCollector collector; |
||||
|
@Autowired com.mydreamjob.deletion.AccountDeletionRepository deletions; |
||||
|
@Autowired com.mydreamjob.deletion.AccountDeletionCompletionWorker deletionWorker; |
||||
|
@MockBean RedisTemplate<String, Object> redis; |
||||
|
User user; String token; |
||||
|
|
||||
|
@BeforeEach void setup() { |
||||
|
user = createUser(User.Role.STUDENT); token = bearer(user); |
||||
|
} |
||||
|
User createUser(User.Role role) { |
||||
|
return users.save(User.builder().phone(Long.toString(PHONE.incrementAndGet())).email("synthetic@example.test") |
||||
|
.role(role).roles(Set.of(role)).active(true).build()); |
||||
|
} |
||||
|
String bearer(User u) { |
||||
|
return "Bearer " + sessions.createSession(u, java.time.Instant.now(), new com.mydreamjob.audit.AuditContext()).accessToken(); |
||||
|
} |
||||
|
String legacySkillFixture() { return "[Java, SQL]"; } |
||||
|
JsonNode data(ResultActions action) throws Exception { |
||||
|
return mapper.readTree(action.andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8)).path("data"); |
||||
|
} |
||||
|
JsonNode read() throws Exception { return data(mvc.perform(get(PATH).header("Authorization", token))); } |
||||
|
OnlineResumeContent content() { |
||||
|
OnlineResumeContent c = new OnlineResumeContent(); c.getBasicInfo().setRealName("合成测试"); |
||||
|
c.getBasicInfo().setPhone("13800000000"); c.getBasicInfo().setEmail("resume@example.test"); |
||||
|
c.setSelfEvaluation("合成自我评价 <>&"); c.getJobIntention().setTargetPosition("后端工程师"); |
||||
|
c.getJobIntention().setJobSeekingStatus(OnlineResumeContent.JobSeekingStatus.LOOKING); |
||||
|
c.getJobIntention().setEmploymentStatus(OnlineResumeContent.EmploymentStatus.UNEMPLOYED); |
||||
|
c.getJobIntention().setAvailableFrom(LocalDate.of(2026,11,1)); |
||||
|
c.getJobIntention().setExpectedSalaryMin(10000); c.getJobIntention().setExpectedSalaryMax(20000); |
||||
|
c.getJobIntention().setPreferredCities(List.of("测试城市")); |
||||
|
var e = new OnlineResumeContent.Education(); e.setSchool("合成大学");e.setDegree("本科");e.setMajor("计算机"); |
||||
|
e.setStartDate(LocalDate.of(2018,9,1));e.setEndDate(LocalDate.of(2022,6,1));c.getEducationExperiences().add(e); |
||||
|
var w = new OnlineResumeContent.Work();w.setCompany("合成企业");w.setPosition("工程师");w.setStartDate(LocalDate.of(2022,7,1));w.setOngoing(true);c.getWorkExperiences().add(w); |
||||
|
var p = new OnlineResumeContent.Project();p.setName("合成项目");p.setRole("开发");p.setStartDate(LocalDate.of(2023,1,1));p.setOngoing(true);c.getProjectExperiences().add(p); |
||||
|
var s = new OnlineResumeContent.Skill();s.setName("Java");s.setLevel(OnlineResumeContent.SkillLevel.PROFICIENT);s.setYears(3);c.getSkills().add(s); |
||||
|
return c; |
||||
|
} |
||||
|
ResultActions save(long revision, OnlineResumeContent c) throws Exception { |
||||
|
return mvc.perform(put(PATH).header("Authorization",token).contentType(MediaType.APPLICATION_JSON) |
||||
|
.content(mapper.writeValueAsBytes(new OnlineResumeSaveRequest(revision,c)))); |
||||
|
} |
||||
|
|
||||
|
@Test void emptyReadDoesNotCreateRowsAndDefaultsContacts() throws Exception { |
||||
|
JsonNode d=read();assertEquals(0,d.path("revision").asLong()); |
||||
|
assertEquals(user.getPhone(),d.at("/content/basicInfo/phone").asText()); |
||||
|
assertEquals(user.getEmail(),d.at("/content/basicInfo/email").asText()); |
||||
|
assertFalse(resumes.existsById(user.getId()));assertFalse(profiles.existsByUserId(user.getId())); |
||||
|
} |
||||
|
@Test void savesAllSectionsAndFreshSessionReadsPersistedState() throws Exception { |
||||
|
JsonNode saved=data(save(0,content()));assertEquals(1,saved.path("revision").asLong()); |
||||
|
for(String s:List.of("educationExperiences","workExperiences","projectExperiences","skills")) assertFalse(saved.at("/content/"+s+"/0/id").asText().isBlank()); |
||||
|
token=bearer(user);assertEquals(saved,read()); |
||||
|
assertEquals(user.getPhone(),users.findById(user.getId()).orElseThrow().getPhone()); |
||||
|
assertEquals(user.getEmail(),users.findById(user.getId()).orElseThrow().getEmail()); |
||||
|
assertEquals("合成测试",profiles.findByUserId(user.getId()).orElseThrow().getRealName()); |
||||
|
JsonNode legacySkills=mapper.readTree(profiles.findByUserId(user.getId()).orElseThrow().getSkillsJson()); |
||||
|
if(legacySkills.isTextual())legacySkills=mapper.readTree(legacySkills.asText()); |
||||
|
assertEquals(mapper.readTree("[\"Java\"]"),legacySkills); |
||||
|
assertEquals(10000,preferences.findByStudentUserId(user.getId()).orElseThrow().getExpectedSalaryMin()); |
||||
|
} |
||||
|
@Test void existingProfilePreferencesAndAttachmentAreIncludedAndPreserved() throws Exception { |
||||
|
profiles.save(StudentProfile.builder().userId(user.getId()).realName("既有姓名").bio("既有简介").skillsJson(legacySkillFixture()) |
||||
|
.resumeUrl(user.getId()+"_abcdef01.docx").resumeOriginalName("合成.docx").resumeContentType("application/test").resumeSize(123L).build()); |
||||
|
preferences.save(StudentPreference.builder().studentUserId(user.getId()).preferredCities("[\"测试\"]").remoteAllowed(true).build()); |
||||
|
JsonNode d=read();assertEquals(2,d.at("/content/skills").size());assertEquals("既有简介",d.at("/content/selfEvaluation").asText()); |
||||
|
assertEquals("合成.docx",d.at("/attachment/fileName").asText());assertEquals(123,d.at("/attachment/size").asInt()); |
||||
|
assertTrue(d.at("/content/jobIntention/remoteAllowed").asBoolean()); |
||||
|
data(save(0,content()));assertEquals(user.getId()+"_abcdef01.docx",profiles.findByUserId(user.getId()).orElseThrow().getResumeUrl()); |
||||
|
} |
||||
|
@Test void staleSaveIsConflictAndLeavesAllSectionsUntouched() throws Exception { |
||||
|
JsonNode first=data(save(0,content()));var stale=content();stale.getBasicInfo().setRealName("不能覆盖"); |
||||
|
save(0,stale).andExpect(status().isConflict()).andExpect(jsonPath("$.code").value(6002));assertEquals(first,read()); |
||||
|
} |
||||
|
@Test void fullReplacementCanClearListsContactsAndLegacyFields() throws Exception { |
||||
|
data(save(0,content()));JsonNode d=data(save(1,new OnlineResumeContent())); |
||||
|
assertEquals(0,d.at("/content/workExperiences").size());assertTrue(d.at("/content/basicInfo/phone").isMissingNode()||d.at("/content/basicInfo/phone").isNull()); |
||||
|
assertNull(profiles.findByUserId(user.getId()).orElseThrow().getRealName()); |
||||
|
assertNull(preferences.findByStudentUserId(user.getId()).orElseThrow().getExpectedSalaryMin()); |
||||
|
} |
||||
|
@Test void educationWorkProjectSkillCrudUsesSharedRevision() throws Exception { |
||||
|
var c=content();long revision=0; |
||||
|
for(var item:Map.of("education-experiences",c.getEducationExperiences().get(0),"work-experiences",c.getWorkExperiences().get(0), |
||||
|
"project-experiences",c.getProjectExperiences().get(0),"skills",c.getSkills().get(0)).entrySet()) { |
||||
|
String path=PATH+"/"+item.getKey(); |
||||
|
JsonNode added=data(mvc.perform(post(path).header("Authorization",token).contentType(MediaType.APPLICATION_JSON) |
||||
|
.content(mapper.writeValueAsBytes(Map.of("revision",revision,"entry",item.getValue()))))); |
||||
|
revision=added.path("revision").asLong(); |
||||
|
JsonNode list=data(mvc.perform(get(path).header("Authorization",token)));String id=list.get(0).path("id").asText();assertFalse(id.isBlank()); |
||||
|
assertEquals(list.get(0),data(mvc.perform(get(path+"/"+id).header("Authorization",token)))); |
||||
|
JsonNode updated=data(mvc.perform(put(path+"/"+id).header("Authorization",token).contentType(MediaType.APPLICATION_JSON) |
||||
|
.content(mapper.writeValueAsBytes(Map.of("revision",revision,"entry",item.getValue())))));revision=updated.path("revision").asLong(); |
||||
|
data(mvc.perform(delete(path+"/"+id).param("revision",Long.toString(revision)).header("Authorization",token)));revision++; |
||||
|
mvc.perform(get(path+"/"+id).header("Authorization",token)).andExpect(status().isNotFound()); |
||||
|
} |
||||
|
assertEquals(12,read().path("revision").asLong()); |
||||
|
} |
||||
|
@Test void authAndOtherAccountIsolation() throws Exception { |
||||
|
mvc.perform(get(PATH)).andExpect(status().isUnauthorized()); |
||||
|
for(var role:List.of(User.Role.COMPANY,User.Role.ADMIN)) mvc.perform(get(PATH).header("Authorization",bearer(createUser(role)))).andExpect(status().isForbidden()); |
||||
|
data(save(0,content()));User other=createUser(User.Role.STUDENT); |
||||
|
JsonNode d=data(mvc.perform(get(PATH).param("userId",user.getId().toString()).header("Authorization",bearer(other)))); |
||||
|
assertEquals(0,d.path("revision").asLong());assertEquals(other.getPhone(),d.at("/content/basicInfo/phone").asText()); |
||||
|
assertEquals(0,d.at("/content/workExperiences").size()); |
||||
|
String id=read().at("/content/workExperiences/0/id").asText(); |
||||
|
mvc.perform(delete(PATH+"/work-experiences/"+id).param("revision","0").header("Authorization",bearer(other))).andExpect(status().isNotFound()); |
||||
|
assertEquals(1,read().at("/content/workExperiences").size()); |
||||
|
} |
||||
|
@Test void invalidDatesContactsSalaryAndNullEntriesAreRejectedAtomically() throws Exception { |
||||
|
var c=content();c.getBasicInfo().setEmail("invalid");save(0,c).andExpect(status().isBadRequest()); |
||||
|
c=content();c.getBasicInfo().setPhone("abc");save(0,c).andExpect(status().isBadRequest()); |
||||
|
c=content();c.getJobIntention().setExpectedSalaryMin(30000);save(0,c).andExpect(status().isBadRequest()); |
||||
|
c=content();c.getEducationExperiences().get(0).setEndDate(LocalDate.of(2010,1,1));save(0,c).andExpect(status().isBadRequest()); |
||||
|
c=content();c.getWorkExperiences().get(0).setEndDate(LocalDate.now());save(0,c).andExpect(status().isBadRequest()); |
||||
|
c=content();c.getSkills().add(null);save(0,c).andExpect(status().isBadRequest()); |
||||
|
assertFalse(resumes.existsById(user.getId()));assertFalse(profiles.existsByUserId(user.getId())); |
||||
|
} |
||||
|
@Test void unknownAndDuplicateIdsRejectedWithoutLosingExistingData() throws Exception { |
||||
|
var c=content();c.getWorkExperiences().get(0).setId(UUID.randomUUID().toString());save(0,c).andExpect(status().isBadRequest()); |
||||
|
var saved=data(save(0,content()));c=mapper.treeToValue(saved.path("content"),OnlineResumeContent.class); |
||||
|
c.getWorkExperiences().add(c.getWorkExperiences().get(0));save(1,c).andExpect(status().isBadRequest());assertEquals(saved,read()); |
||||
|
} |
||||
|
@Test void listLimitsAndXmlControlCharactersAreRejected() throws Exception { |
||||
|
var c=content();for(int i=0;i<31;i++)c.getWorkExperiences().add(c.getWorkExperiences().get(0));save(0,c).andExpect(status().isBadRequest()); |
||||
|
c=content();c.setSelfEvaluation("bad\u0000text");save(0,c).andExpect(status().isBadRequest()); |
||||
|
c=content();c.getSkills().add(c.getSkills().get(0));save(0,c).andExpect(status().isBadRequest()); |
||||
|
assertFalse(resumes.existsById(user.getId())); |
||||
|
} |
||||
|
@Test void downloadIsValidEscapedDocxAndNeverReadsAttachment() throws Exception { |
||||
|
data(save(0,content()));byte[] bytes=mvc.perform(get(PATH+"/download").header("Authorization",token)).andExpect(status().isOk()) |
||||
|
.andExpect(header().string("Cache-Control","no-store")).andReturn().getResponse().getContentAsByteArray(); |
||||
|
Map<String,String> files=new HashMap<>();try(var zip=new ZipInputStream(new ByteArrayInputStream(bytes),StandardCharsets.UTF_8)){ |
||||
|
java.util.zip.ZipEntry e;while((e=zip.getNextEntry())!=null)files.put(e.getName(),new String(zip.readAllBytes(),StandardCharsets.UTF_8));} |
||||
|
assertTrue(files.containsKey("[Content_Types].xml"));assertTrue(files.containsKey("_rels/.rels")); |
||||
|
String xml=files.get("word/document.xml");assertTrue(xml.contains("合成企业"));assertTrue(xml.contains("<>&")); |
||||
|
var factory=javax.xml.parsers.DocumentBuilderFactory.newInstance();factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl",true); |
||||
|
factory.newDocumentBuilder().parse(new ByteArrayInputStream(xml.getBytes(StandardCharsets.UTF_8))); |
||||
|
String destination=System.getProperty("qsp.onlineResume.docxOutput"); |
||||
|
if(destination!=null)java.nio.file.Files.write(java.nio.file.Path.of(destination),bytes); |
||||
|
} |
||||
|
@Test void concurrentFirstSaveHasOneWinner() throws Exception { |
||||
|
var pool=Executors.newFixedThreadPool(2);var start=new CountDownLatch(1); |
||||
|
try { |
||||
|
List<Future<Boolean>> futures=new ArrayList<>(); |
||||
|
for(int i=0;i<2;i++) futures.add(pool.submit(()->{assertTrue(start.await(5,TimeUnit.SECONDS)); |
||||
|
try{service.save(user.getId(),new OnlineResumeSaveRequest(0L,content()));return true;} |
||||
|
catch(com.mydreamjob.common.exception.BusinessException e){assertEquals(6002,e.getErrorCodeInt());return false;}})); |
||||
|
start.countDown();int winners=0;for(var f:futures)if(f.get(30,TimeUnit.SECONDS))winners++; |
||||
|
assertEquals(1,winners);assertEquals(1,read().path("revision").asLong()); |
||||
|
}finally{pool.shutdownNow();} |
||||
|
} |
||||
|
@Test void personalDataExportIncludesOnlyOwnOnlineResume() throws Exception { |
||||
|
data(save(0,content()));User other=createUser(User.Role.STUDENT); |
||||
|
var otherContent=content();otherContent.getBasicInfo().setRealName("其他合成账号"); |
||||
|
service.save(other.getId(),new OnlineResumeSaveRequest(0L,otherContent)); |
||||
|
var exported=collector.collect(user.getId()).getSections(); |
||||
|
assertTrue(exported.containsKey("online-resume")); |
||||
|
String serialized=mapper.writeValueAsString(exported.get("online-resume")); |
||||
|
assertTrue(serialized.contains("合成测试"));assertFalse(serialized.contains("其他合成账号")); |
||||
|
} |
||||
|
@Test void accountDeletionPurgesOnlyOwnOnlineResume() throws Exception { |
||||
|
data(save(0,content()));User other=createUser(User.Role.STUDENT); |
||||
|
service.save(other.getId(),new OnlineResumeSaveRequest(0L,content())); |
||||
|
var past=java.time.LocalDateTime.now().minusDays(1); |
||||
|
deletions.save(com.mydreamjob.deletion.AccountDeletion.builder().userId(user.getId()) |
||||
|
.status(com.mydreamjob.deletion.DeletionStatus.PENDING).requestedAt(past.minusDays(30)).effectiveAt(past).version(0L).build()); |
||||
|
assertTrue(deletionWorker.completeIfExpired(user.getId())); |
||||
|
assertFalse(resumes.existsById(user.getId()));assertTrue(resumes.existsById(other.getId())); |
||||
|
} |
||||
|
@Test void legacyProfileUpdatesAreReflectedAndSkillDetailsAreRetained() throws Exception { |
||||
|
data(save(0,content())); |
||||
|
mvc.perform(put("/profile/me").header("Authorization",token).contentType(MediaType.APPLICATION_JSON) |
||||
|
.content("{\"realName\":\"既有接口更新\",\"skills\":[\"Java\",\"SQL\"]}")).andExpect(status().isOk()); |
||||
|
JsonNode d=read();assertEquals("既有接口更新",d.at("/content/basicInfo/realName").asText()); |
||||
|
assertEquals(2,d.at("/content/skills").size());assertEquals("PROFICIENT",d.at("/content/skills/0/level").asText()); |
||||
|
} |
||||
|
@Test void runtimeOpenApiDocumentsOnlineResumeAndPrivateContactFields() throws Exception { |
||||
|
String json=mvc.perform(get("/v3/api-docs")).andExpect(status().isOk()).andReturn().getResponse().getContentAsString(StandardCharsets.UTF_8); |
||||
|
JsonNode doc=mapper.readTree(json); |
||||
|
assertTrue(doc.path("paths").has(PATH));assertTrue(doc.path("paths").has(PATH+"/download")); |
||||
|
JsonNode basic=doc.at("/components/schemas/OnlineResumeBasicInfo/properties"); |
||||
|
assertTrue(basic.has("phone"));assertTrue(basic.has("email")); |
||||
|
JsonNode entry=doc.at("/components/schemas/OnlineResumeEntryRequest/properties/entry"); |
||||
|
assertFalse(entry.has("$ref"));assertEquals(4,entry.path("oneOf").size()); |
||||
|
Set<String> variants=new HashSet<>();entry.path("oneOf").forEach(v->variants.add(v.path("$ref").asText())); |
||||
|
assertEquals(4,variants.size()); |
||||
|
for(String name:List.of("OnlineResumeEducation","OnlineResumeWork","OnlineResumeProject","OnlineResumeSkill")) |
||||
|
assertFalse(doc.at("/components/schemas/"+name+"/additionalProperties").asBoolean(true)); |
||||
|
JsonNode candidate=doc.at("/components/schemas/CandidateDetailView/properties"); |
||||
|
assertFalse(candidate.has("phone"));assertFalse(candidate.has("email")); |
||||
|
String destination=System.getProperty("qsp.onlineResume.openapiOutput"); |
||||
|
if(destination!=null)java.nio.file.Files.writeString(java.nio.file.Path.of(destination),json,StandardCharsets.UTF_8); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import com.mydreamjob.config.MySql57MigrationConfiguration; |
||||
|
import org.flywaydb.core.Flyway; |
||||
|
import org.junit.jupiter.api.Test; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import java.sql.*; |
||||
|
import java.util.*; |
||||
|
import static org.junit.jupiter.api.Assertions.*; |
||||
|
|
||||
|
class OnlineResumeMigrationTest { |
||||
|
@Test void mysql57V58ToV59() throws Exception { verify("mysql:5.7.36"); } |
||||
|
@Test void mysql8V58ToV59() throws Exception { verify("mysql:8.4"); } |
||||
|
private Flyway flyway(MySQLContainer<?> db, String target) { |
||||
|
var config=Flyway.configure().dataSource(db.getJdbcUrl(),db.getUsername(),db.getPassword()).locations("classpath:db/migration"); |
||||
|
if(target!=null)config.target(target);MySql57MigrationConfiguration.configure(config);return config.load(); |
||||
|
} |
||||
|
private void verify(String image) throws Exception { |
||||
|
try(var db=new MySQLContainer<>(image).withDatabaseName("online_resume_migration").withUsername("resume").withPassword("isolated-only") |
||||
|
.withUrlParam("characterEncoding","UTF-8") |
||||
|
.withCreateContainerCmdModifier(cmd->cmd.getHostConfig().withMemory(768L*1024*1024))) { |
||||
|
db.start(); |
||||
|
try(var c=DriverManager.getConnection(db.getJdbcUrl(),db.getUsername(),db.getPassword());var s=c.createStatement()) { |
||||
|
s.execute("ALTER DATABASE online_resume_migration CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci"); |
||||
|
} |
||||
|
flyway(db,"58").migrate(); |
||||
|
try(var c=DriverManager.getConnection(db.getJdbcUrl(),db.getUsername(),db.getPassword());var s=c.createStatement()) { |
||||
|
s.execute("INSERT INTO users(id,phone,role,is_active,is_verified) VALUES(910001,'19907111111','STUDENT',1,1)"); |
||||
|
s.execute("INSERT INTO student_profiles(user_id,real_name,resume_url) VALUES(910001,'synthetic','910001_abcdef01.docx')"); |
||||
|
List<String> before=history(s); |
||||
|
assertEquals(1,flyway(db,null).migrate().migrationsExecuted); |
||||
|
assertEquals(before,history(s)); |
||||
|
try(var r=s.executeQuery("SELECT resume_url FROM student_profiles WHERE user_id=910001")){assertTrue(r.next());assertEquals("910001_abcdef01.docx",r.getString(1));} |
||||
|
try(var p=c.prepareStatement("INSERT INTO online_resumes(user_id,revision,content_json,updated_at) VALUES(910001,1,?,CURRENT_TIMESTAMP(6))")) { |
||||
|
p.setString(1,"{\"synthetic\":\""+"中".repeat(30000)+"\"}");assertEquals(1,p.executeUpdate()); |
||||
|
} |
||||
|
try(var r=s.executeQuery("SELECT revision,CHAR_LENGTH(content_json) FROM online_resumes WHERE user_id=910001")){assertTrue(r.next());assertEquals(1,r.getLong(1));assertTrue(r.getInt(2)>30000);} |
||||
|
assertThrows(SQLException.class,()->s.execute("INSERT INTO online_resumes(user_id,revision,content_json,updated_at) VALUES(910001,2,'{}',CURRENT_TIMESTAMP)")); |
||||
|
assertThrows(SQLException.class,()->s.execute("INSERT INTO online_resumes(user_id,revision,content_json,updated_at) VALUES(999999,1,'{}',CURRENT_TIMESTAMP)")); |
||||
|
assertEquals(0,flyway(db,null).migrate().migrationsExecuted);flyway(db,null).validate(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
private List<String> history(Statement s) throws SQLException { |
||||
|
List<String> rows=new ArrayList<>(); |
||||
|
try(var r=s.executeQuery("SELECT installed_rank,version,checksum,success FROM flyway_schema_history WHERE CAST(version AS UNSIGNED)<=58 ORDER BY installed_rank")){ |
||||
|
while(r.next())rows.add(r.getInt(1)+"|"+r.getString(2)+"|"+r.getString(3)+"|"+r.getBoolean(4)); |
||||
|
}return rows; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
package com.mydreamjob.onlineresume; |
||||
|
|
||||
|
import org.springframework.test.context.*; |
||||
|
import org.testcontainers.containers.MySQLContainer; |
||||
|
import org.testcontainers.junit.jupiter.*; |
||||
|
|
||||
|
/** 同一套真实业务断言在 MySQL 中执行,含持久化、并发与账号隔离。 */ |
||||
|
@ActiveProfiles(value = "mysql-it", inheritProfiles = false) |
||||
|
@TestPropertySource(properties = { |
||||
|
"app.jwt.access-secret=test-access-secret-key-for-integration-tests-only-not-for-production", |
||||
|
"app.jwt.refresh-secret=test-refresh-secret-key-for-integration-tests-only-not-for-production", |
||||
|
"app.jwt.token-hash-secret=test-token-hash-secret-key-for-integration-tests-only-not-for-production"}) |
||||
|
@Testcontainers(disabledWithoutDocker = false) |
||||
|
@org.springframework.test.annotation.DirtiesContext(classMode = org.springframework.test.annotation.DirtiesContext.ClassMode.AFTER_CLASS) |
||||
|
class OnlineResumeMySqlIntegrationTest extends OnlineResumeIntegrationTest { |
||||
|
@Override String legacySkillFixture() { return "[\"Java\",\"SQL\"]"; } |
||||
|
@Container static final MySQLContainer<?> MYSQL = new MySQLContainer<>("mysql:8.4") |
||||
|
.withDatabaseName("online_resume_test").withUsername("resume").withPassword("isolated-only") |
||||
|
.withCreateContainerCmdModifier(cmd -> cmd.getHostConfig().withMemory(768L * 1024 * 1024)); |
||||
|
@DynamicPropertySource static void database(DynamicPropertyRegistry r) { |
||||
|
r.add("spring.datasource.url", MYSQL::getJdbcUrl); |
||||
|
r.add("spring.datasource.username", MYSQL::getUsername); |
||||
|
r.add("spring.datasource.password", MYSQL::getPassword); |
||||
|
} |
||||
|
} |
||||
Loading…
Reference in new issue