145 changed files with 25487 additions and 4176 deletions
@ -0,0 +1,77 @@ |
|||||
|
# N03 行业技能词云来源与覆盖清单 |
||||
|
|
||||
|
## 1. 固定来源 |
||||
|
|
||||
|
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 | |
||||
|
| --- | --- | --- | --- | --- | |
||||
|
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq | |
||||
|
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html | |
||||
|
|
||||
|
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。 |
||||
|
|
||||
|
## 2. 首版覆盖边界 |
||||
|
|
||||
|
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。 |
||||
|
|
||||
|
| 行业 | 代表性职位 | 首批技能分类范围 | |
||||
|
| --- | --- | --- | |
||||
|
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 | |
||||
|
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 | |
||||
|
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 | |
||||
|
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 | |
||||
|
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 | |
||||
|
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 | |
||||
|
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps | |
||||
|
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 | |
||||
|
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 | |
||||
|
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 | |
||||
|
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。 |
||||
|
|
||||
|
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。 |
||||
|
|
||||
|
## 3. 导入规则 |
||||
|
|
||||
|
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。 |
||||
|
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。 |
||||
|
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。 |
||||
|
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。 |
||||
|
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。 |
||||
|
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。 |
||||
|
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。 |
||||
|
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。 |
||||
|
9. **迁移不可改写**:T2/T3 计划使用 `V35__skill_cloud_taxonomy.sql` 和 `V36__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。 |
||||
|
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。 |
||||
|
|
||||
|
## 4. 来源版本、许可与归属 URL |
||||
|
|
||||
|
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL | |
||||
|
| --- | --- | --- | --- | --- | |
||||
|
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download | |
||||
|
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html | |
||||
|
|
||||
|
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.` |
||||
|
|
||||
|
## 5. 映射责任边界 |
||||
|
|
||||
|
| 事项 | 上游来源责任 | 项目责任 | |
||||
|
| --- | --- | --- | |
||||
|
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID | |
||||
|
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 | |
||||
|
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 | |
||||
|
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 | |
||||
|
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 | |
||||
|
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 | |
||||
|
|
||||
|
## 6. 验收检查项 |
||||
|
|
||||
|
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。 |
||||
|
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。 |
||||
|
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。 |
||||
|
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。 |
||||
|
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。 |
||||
|
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。 |
||||
|
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。 |
||||
|
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。 |
||||
|
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。 |
||||
|
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。 |
||||
|
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。 |
||||
@ -0,0 +1,434 @@ |
|||||
|
# 入职清单与公司知识介绍 API(N04) |
||||
|
|
||||
|
> 状态:后端实现完成,待真实 MySQL 8.4 / Docker 黑盒验收。 |
||||
|
> |
||||
|
> 数据库迁移:`V50__onboarding_checklist_and_company_knowledge.sql`、`V51__onboarding_standard_checklist_seed.sql`。 |
||||
|
|
||||
|
## 1. Base URL 与认证 |
||||
|
|
||||
|
本地 Base URL: |
||||
|
|
||||
|
```text |
||||
|
http://127.0.0.1:8080/api/v1 |
||||
|
``` |
||||
|
|
||||
|
所有 `/onboarding/**` 接口都要求登录,并携带: |
||||
|
|
||||
|
```http |
||||
|
Authorization: Bearer <accessToken> |
||||
|
Content-Type: application/json |
||||
|
X-Request-Id: <optional-trace-id> |
||||
|
``` |
||||
|
|
||||
|
细粒度角色和所有权在 Service 层校验。除 401 外,接口统一返回 `ApiResponse`: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": {}, |
||||
|
"requestId": "req-...", |
||||
|
"timestamp": "2026-09-17T01:30:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 2. 接口与权限总览 |
||||
|
|
||||
|
| # | Method | Path | 角色 | 说明 | |
||||
|
|---|---|---|---|---| |
||||
|
| 1 | GET | `/onboarding/companies/{companyId}/checklist` | STUDENT | 读取有效清单、完成状态和完成率 | |
||||
|
| 2 | PUT | `/onboarding/companies/{companyId}/checklist/items/{itemId}/completion` | STUDENT | 设置本人完成或未完成 | |
||||
|
| 3 | GET | `/onboarding/companies/{companyId}/knowledge` | STUDENT | 读取企业资料、知识内容和公开评价摘要 | |
||||
|
| 4 | GET | `/onboarding/company/checklist-items` | COMPANY | 查询本企业活动与软删除自定义项 | |
||||
|
| 5 | POST | `/onboarding/company/checklist-items` | COMPANY | 创建本企业自定义项 | |
||||
|
| 6 | PUT | `/onboarding/company/checklist-items/{itemId}` | COMPANY | 按版本更新本企业条目 | |
||||
|
| 7 | DELETE | `/onboarding/company/checklist-items/{itemId}?version={version}` | COMPANY | 按版本软删除本企业条目 | |
||||
|
| 8 | GET | `/onboarding/company/knowledge` | COMPANY | 查询本企业知识配置 | |
||||
|
| 9 | PUT | `/onboarding/company/knowledge` | COMPANY | 创建或更新本企业知识配置 | |
||||
|
|
||||
|
权限规则: |
||||
|
|
||||
|
- STUDENT 接口只允许拥有 STUDENT 角色的账号访问。 |
||||
|
- COMPANY 接口只允许拥有 COMPANY 角色且 `companies.verified=true` 的账号访问;未认证企业统一返回 `403/5004`,在读取或写入本企业清单、知识内容前拒绝。 |
||||
|
- COMPANY 接口不接受 `companyId`;服务端从 JWT 用户解析其所属企业,避免跨企业写入。 |
||||
|
- ADMIN 没有 onboarding 专属管理接口;调用 STUDENT/COMPANY 接口时按非目标角色返回 `403/2002`。 |
||||
|
- 学生访问企业清单或知识不要求先有 Offer 或投递记录,但目标企业必须存在。 |
||||
|
- 企业条目不存在或属于其他企业时统一返回 `404/10501`,不泄露其他企业数据。 |
||||
|
|
||||
|
## 3. 入职清单语义 |
||||
|
|
||||
|
### 3.1 通用项与自定义项合并 |
||||
|
|
||||
|
有效清单由以下两部分组成: |
||||
|
|
||||
|
1. 所有活动通用项 `source=STANDARD`;V51 固定写入 12 条。 |
||||
|
2. 当前企业所有活动自定义项 `source=COMPANY`。 |
||||
|
|
||||
|
合并结果按 `sortOrder ASC, id ASC` 稳定排序。企业没有活动自定义项时,`customized=false`,仍返回全部通用项。企业存在活动自定义项时,`customized=true`,返回通用项与企业项。 |
||||
|
|
||||
|
`category` 固定为: |
||||
|
|
||||
|
```text |
||||
|
DOCUMENT | ADMIN | EQUIPMENT | TRAINING | LOGISTICS | OTHER |
||||
|
证件材料 | 行政手续 | 办公设备 | 培训学习 | 到岗安排 | 其他 |
||||
|
``` |
||||
|
|
||||
|
### 3.2 学生读取有效清单 |
||||
|
|
||||
|
```http |
||||
|
GET /api/v1/onboarding/companies/10/checklist |
||||
|
Authorization: Bearer <studentAccessToken> |
||||
|
``` |
||||
|
|
||||
|
响应: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": { |
||||
|
"companyId": 10, |
||||
|
"companyName": "示例科技", |
||||
|
"customized": true, |
||||
|
"totalItems": 13, |
||||
|
"completedItems": 1, |
||||
|
"completionRate": 7.7, |
||||
|
"items": [ |
||||
|
{ |
||||
|
"itemId": 101, |
||||
|
"source": "STANDARD", |
||||
|
"itemKey": "standard-id-card", |
||||
|
"title": "准备身份证件", |
||||
|
"description": "准备身份证原件或有效身份证明,用于入职登记。", |
||||
|
"category": "DOCUMENT", |
||||
|
"required": true, |
||||
|
"sortOrder": 100, |
||||
|
"active": true, |
||||
|
"completed": true, |
||||
|
"completedAt": "2026-09-17T09:00:00", |
||||
|
"progressVersion": 1 |
||||
|
}, |
||||
|
{ |
||||
|
"itemId": 501, |
||||
|
"source": "COMPANY", |
||||
|
"itemKey": "company-10-8f1c", |
||||
|
"title": "领取门禁卡", |
||||
|
"description": "到行政前台领取门禁卡并完成权限激活。", |
||||
|
"category": "ADMIN", |
||||
|
"required": false, |
||||
|
"sortOrder": 1000, |
||||
|
"active": true, |
||||
|
"completed": false, |
||||
|
"completedAt": null, |
||||
|
"progressVersion": 0 |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
"requestId": "req-onboarding-checklist", |
||||
|
"timestamp": "2026-09-17T01:30:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
`completionRate = round(completedItems * 100 / totalItems, 1)`;总数为 0 时返回 `0.0`。完成状态只属于当前 STUDENT。 |
||||
|
|
||||
|
### 3.3 更新完成状态 |
||||
|
|
||||
|
请求使用绝对状态,而不是切换: |
||||
|
|
||||
|
```http |
||||
|
PUT /api/v1/onboarding/companies/10/checklist/items/501/completion |
||||
|
Authorization: Bearer <studentAccessToken> |
||||
|
|
||||
|
{"completed": true, "version": 0} |
||||
|
``` |
||||
|
|
||||
|
响应: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": { |
||||
|
"itemId": 501, |
||||
|
"completed": true, |
||||
|
"completedAt": "2026-09-17T01:31:00Z", |
||||
|
"progressVersion": 1, |
||||
|
"totalItems": 13, |
||||
|
"completedItems": 2, |
||||
|
"completionRate": 15.4 |
||||
|
}, |
||||
|
"requestId": "req-onboarding-progress", |
||||
|
"timestamp": "2026-09-17T01:31:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
版本规则: |
||||
|
|
||||
|
- 首次保存某条目时可省略 `version` 或传 `0`。 |
||||
|
- 已完成状态变化时必须传当前 `progressVersion`;过期返回 `409/10505`。 |
||||
|
- 重复提交相同 `completed` 值保持幂等,不新增记录、不递增版本。 |
||||
|
- 首次提交 `completed=false` 不创建记录。 |
||||
|
- `completed=false` 会清除 `completedAt`。 |
||||
|
- 一名学生与一个清单项最多一条进度记录。 |
||||
|
|
||||
|
### 3.4 企业创建自定义项 |
||||
|
|
||||
|
```http |
||||
|
POST /api/v1/onboarding/company/checklist-items |
||||
|
Authorization: Bearer <companyAccessToken> |
||||
|
|
||||
|
{ |
||||
|
"title": "领取门禁卡", |
||||
|
"description": "到行政前台领取门禁卡并完成权限激活。", |
||||
|
"category": "ADMIN", |
||||
|
"sortOrder": 1000, |
||||
|
"required": false, |
||||
|
"idempotencyKey": "company-10-access-card-20260917" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
响应 `data`: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"itemId": 501, |
||||
|
"source": "COMPANY", |
||||
|
"itemKey": "company-10-8f1c", |
||||
|
"title": "领取门禁卡", |
||||
|
"description": "到行政前台领取门禁卡并完成权限激活。", |
||||
|
"category": "ADMIN", |
||||
|
"required": false, |
||||
|
"sortOrder": 1000, |
||||
|
"active": true, |
||||
|
"version": 0 |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
约束: |
||||
|
|
||||
|
- `title`:必填,最多 120 字符。 |
||||
|
- `description`:可选,最多 1000 字符。 |
||||
|
- `category`:必填,仅允许固定枚举。 |
||||
|
- `sortOrder`:可选,默认 `1000`,范围 `0..9999`。 |
||||
|
- `required`:可选,默认 `false`。 |
||||
|
- `idempotencyKey`:可选,最多 64 字符;服务端会 trim,空串按未提供处理。 |
||||
|
- 活动自定义项上限为 50 条;达到上限返回 `409/10503`。 |
||||
|
|
||||
|
幂等重试: |
||||
|
|
||||
|
- 同一企业、同一 `idempotencyKey`、同标题/说明/分类/排序/必填值:返回首次创建的条目。 |
||||
|
- 同一企业、同一 key、请求语义不同:返回 `409/10504`。 |
||||
|
- 不同企业可使用相同 key,互不冲突。 |
||||
|
- 未传 key 时,每次请求创建新条目。 |
||||
|
|
||||
|
### 3.5 企业查询、更新与软删除 |
||||
|
|
||||
|
查询本企业全部条目: |
||||
|
|
||||
|
```http |
||||
|
GET /api/v1/onboarding/company/checklist-items |
||||
|
Authorization: Bearer <companyAccessToken> |
||||
|
``` |
||||
|
|
||||
|
响应包含活动与软删除条目,不返回通用项和其他企业项。更新请求: |
||||
|
|
||||
|
```http |
||||
|
PUT /api/v1/onboarding/company/checklist-items/501 |
||||
|
Authorization: Bearer <companyAccessToken> |
||||
|
|
||||
|
{ |
||||
|
"title": "领取门禁卡并激活", |
||||
|
"description": "到行政前台领取门禁卡并完成权限激活。", |
||||
|
"category": "ADMIN", |
||||
|
"sortOrder": 900, |
||||
|
"required": true, |
||||
|
"active": true, |
||||
|
"version": 0 |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
更新使用 `version` 乐观锁,成功递增版本;过期返回 `409/10502`。软删除: |
||||
|
|
||||
|
```http |
||||
|
DELETE /api/v1/onboarding/company/checklist-items/501?version=1 |
||||
|
Authorization: Bearer <companyAccessToken> |
||||
|
``` |
||||
|
|
||||
|
首次删除必须使用当前版本,系统只将 `active=false`,不物理删除。重复删除已非活动条目保持幂等成功;接口仍要求携带 `version` 查询参数。 |
||||
|
|
||||
|
## 4. 公司知识介绍语义 |
||||
|
|
||||
|
### 4.1 学生读取与回退 |
||||
|
|
||||
|
```http |
||||
|
GET /api/v1/onboarding/companies/10/knowledge |
||||
|
Authorization: Bearer <studentAccessToken> |
||||
|
``` |
||||
|
|
||||
|
响应: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": { |
||||
|
"company": { |
||||
|
"id": 10, |
||||
|
"name": "示例科技", |
||||
|
"logoUrl": "https://cdn.example.com/company-10.png", |
||||
|
"description": "面向企业提供数据基础设施产品。", |
||||
|
"industry": "互联网", |
||||
|
"website": "https://example.com", |
||||
|
"address": "杭州市余杭区", |
||||
|
"verified": true |
||||
|
}, |
||||
|
"knowledgeState": "CONFIGURED", |
||||
|
"knowledge": { |
||||
|
"culture": "以用户价值为中心,保持透明和长期主义。", |
||||
|
"workPolicies": "弹性上班;每周一、三线下办公。", |
||||
|
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", |
||||
|
"updatedAt": "2026-09-17T01:33:00Z" |
||||
|
}, |
||||
|
"reviewSummary": { |
||||
|
"state": "SUFFICIENT", |
||||
|
"totalReviews": 4, |
||||
|
"averageRating": 4.5, |
||||
|
"ratingDistribution": { |
||||
|
"rating1": 0, |
||||
|
"rating2": 0, |
||||
|
"rating3": 1, |
||||
|
"rating4": 1, |
||||
|
"rating5": 2 |
||||
|
}, |
||||
|
"topTags": ["发展空间", "同事友好"], |
||||
|
"dimensionSummary": { |
||||
|
"AUTHENTICITY": {"averageScore": 4.5, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
||||
|
"BENEFITS": {"averageScore": 4.3, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
||||
|
"SALARY_TRANSPARENCY": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
||||
|
"JD_CLARITY": {"averageScore": 4.6, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
||||
|
"GROWTH": {"averageScore": 4.4, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
||||
|
"TEAM_ATMOSPHERE": {"averageScore": 4.7, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
||||
|
"PROMOTION": {"averageScore": 4.1, "sampleCount": 4, "dataState": "SUFFICIENT"}, |
||||
|
"WORK_LIFE_BALANCE": {"averageScore": 4.0, "sampleCount": 4, "dataState": "SUFFICIENT"} |
||||
|
} |
||||
|
} |
||||
|
}, |
||||
|
"requestId": "req-company-overview", |
||||
|
"timestamp": "2026-09-17T01:34:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
回退与状态: |
||||
|
|
||||
|
- 企业无知识记录:仍返回企业基础资料,`knowledgeState=BASIC_ONLY`,`knowledge` 省略。 |
||||
|
- 企业有知识记录:`knowledgeState=CONFIGURED`,返回非空知识字段和 `updatedAt`;学生公开读取响应不返回知识配置版本。 |
||||
|
- 企业不存在:`404/5001`。 |
||||
|
- 响应白名单不包含 `ownerUserId`、用户 ID、手机号、邮箱、评价哈希或审核原因。 |
||||
|
|
||||
|
评价摘要只聚合企业全部岗位中 `PUBLISHED` 且审核状态为 `AUTO_APPROVED/APPROVED` 的评价: |
||||
|
|
||||
|
| `reviewSummary.state` | 条件 | 返回内容 | |
||||
|
|---|---|---| |
||||
|
| `EMPTY` | 0 条 | `state`、`totalReviews=0` | |
||||
|
| `INSUFFICIENT` | 1–2 条 | `state`、真实 `totalReviews`;不返回均值、分布或维度汇总 | |
||||
|
| `SUFFICIENT` | 至少 3 条 | 平均评分、评分分布、热门标签、八维汇总 | |
||||
|
|
||||
|
热门标签最多 10 个;八个维度分别返回 `sampleCount` 和 `dataState`。维度的 `dataState` 使用同一 3 条阈值。 |
||||
|
|
||||
|
### 4.2 企业查询知识配置 |
||||
|
|
||||
|
```http |
||||
|
GET /api/v1/onboarding/company/knowledge |
||||
|
Authorization: Bearer <companyAccessToken> |
||||
|
``` |
||||
|
|
||||
|
未配置时: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": { |
||||
|
"configured": false, |
||||
|
"version": 0 |
||||
|
}, |
||||
|
"requestId": "req-company-knowledge", |
||||
|
"timestamp": "2026-09-17T01:33:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
已配置时返回 `configured=true`、非空内容、`updatedAt` 和当前 `version`。空字段按 `@JsonInclude(NON_NULL)` 省略。 |
||||
|
|
||||
|
### 4.3 企业创建或更新知识配置 |
||||
|
|
||||
|
```http |
||||
|
PUT /api/v1/onboarding/company/knowledge |
||||
|
Authorization: Bearer <companyAccessToken> |
||||
|
|
||||
|
{ |
||||
|
"culture": "以用户价值为中心,保持透明和长期主义。", |
||||
|
"workPolicies": "弹性上班;每周一、三线下办公。", |
||||
|
"onboardingGuide": "报到当天携带身份证件,联系行政同事完成入职登记。", |
||||
|
"version": 1 |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
规则: |
||||
|
|
||||
|
- 至少一个内容字段必须非空。 |
||||
|
- `culture` 最多 2000 字符,`workPolicies` 和 `onboardingGuide` 各最多 4000 字符。 |
||||
|
- 字段按纯文本处理,会 trim;包含 HTML 标签时返回 `400/1001`。 |
||||
|
- 首次创建可以省略 `version`,创建后版本为 `0`。 |
||||
|
- 更新已有记录必须提交当前版本;版本过期返回 `409/10506`。 |
||||
|
- 已有内容与提交内容相同且未传版本时按幂等读取处理;内容不同但未传版本返回 `400/1001`。 |
||||
|
|
||||
|
## 5. 错误码 |
||||
|
|
||||
|
| HTTP | code | 含义 | 前端处理 | |
||||
|
|---|---:|---|---| |
||||
|
| 400 | 1001 | 参数、枚举、长度、HTML、缺少必填字段 | 展示字段错误,不自动重试 | |
||||
|
| 401 | 401 | 未登录或 Token 无效 | 清理登录态并重新登录 | |
||||
|
| 403 | 2002 | 角色不匹配、无所属企业或无权限 | 不重试,切换到正确角色 | |
||||
|
| 404 | 5001 | 企业不存在或 COMPANY 无绑定企业 | 检查 `companyId` 或企业绑定状态 | |
||||
|
| 404 | 10501 | 清单项不存在、跨企业或不属于有效清单 | 刷新清单后重新选择条目 | |
||||
|
| 409 | 10502 | 企业清单项版本过期 | 重新 `GET /company/checklist-items`,使用新版本重试 | |
||||
|
| 409 | 10503 | 活动自定义项达到 50 条上限 | 禁用新增,提示先软删除或复用现有项 | |
||||
|
| 409 | 10504 | 幂等键已用于不同请求体 | 更换幂等键,或确认原请求后读取原条目 | |
||||
|
| 409 | 10505 | 学生完成状态版本过期 | 重新读取有效清单并使用新 `progressVersion` | |
||||
|
| 409 | 10506 | 公司知识版本过期 | 重新读取企业知识配置并使用新 `version` | |
||||
|
|
||||
|
401 由安全过滤器返回裸 JSON,没有统一 `ApiResponse.requestId`: |
||||
|
|
||||
|
```json |
||||
|
{"code": 401, "message": "not authenticated", "timestamp": ""} |
||||
|
``` |
||||
|
|
||||
|
其他错误示例: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 10504, |
||||
|
"message": "idempotency key conflict: request body differs", |
||||
|
"data": null, |
||||
|
"requestId": "req-10504", |
||||
|
"timestamp": "2026-09-17T01:30:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 6. 前端推荐流程 |
||||
|
|
||||
|
学生端: |
||||
|
|
||||
|
1. 登录后调用 `GET /onboarding/companies/{companyId}/checklist`。 |
||||
|
2. 使用响应的 `itemId` 和 `progressVersion` 调用完成状态接口。 |
||||
|
3. 收到 `10505` 时重新读取清单,避免覆盖其他设备的新状态。 |
||||
|
4. 调用 `GET /onboarding/companies/{companyId}/knowledge` 展示企业介绍与评价摘要。 |
||||
|
5. 当 `reviewSummary.state=EMPTY/INSUFFICIENT` 时,不展示平均值、分布和维度均值。 |
||||
|
|
||||
|
企业端: |
||||
|
|
||||
|
1. 登录后调用 `GET /onboarding/company/checklist-items` 获取当前版本。 |
||||
|
2. 新增时生成稳定 `idempotencyKey` 并保存到重试请求中。 |
||||
|
3. 更新或删除前使用最新 `version`,冲突后重新拉取列表。 |
||||
|
4. 调用 `GET /onboarding/company/knowledge` 获取配置版本,再使用 `PUT` 保存。 |
||||
|
5. 收到 `10503` 时停止自动新增;收到 `10504` 时不要复用旧请求体。 |
||||
File diff suppressed because it is too large
@ -0,0 +1,158 @@ |
|||||
|
# 薪资计算器 API(N05) |
||||
|
|
||||
|
> Base URL:`/api/v1` |
||||
|
> 认证:`Authorization: Bearer <access-token>` |
||||
|
> 角色:当前登录用户必须具备 `STUDENT` 角色。 |
||||
|
|
||||
|
## 1. 能力边界 |
||||
|
|
||||
|
- 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。 |
||||
|
- 采用居民个人综合所得累计预扣法;`monthIndex` 表示当前工资口径下的累计计税月份数,而不是自然月编号。 |
||||
|
- 支持用户覆盖社保基数、公积金基数和个人比例。 |
||||
|
- 城市政策以地级行政区、直辖市和省直辖县级市为最小单元,不拆分到区县。 |
||||
|
- `dataLevel=CITY` 表示城市级参数;`dataLevel=PROVINCE_FALLBACK` 表示由于公开城市级资料不足而使用省级回退参数,结果精度较低。 |
||||
|
- 所有结果均为估算值,实际以工资单、当地政策和税务申报为准。 |
||||
|
|
||||
|
## 2. 查询城市列表 |
||||
|
|
||||
|
`GET /salary-calculator/cities` |
||||
|
|
||||
|
返回全部启用城市摘要,按省份编码和城市编码排序。 |
||||
|
|
||||
|
响应示例: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": [ |
||||
|
{ |
||||
|
"cityCode": "110000", |
||||
|
"provinceCode": "110000", |
||||
|
"provinceName": "北京市", |
||||
|
"cityName": "北京市", |
||||
|
"policyVersion": "2026-09-20-v1", |
||||
|
"policyEffectiveDate": "2026-07-01", |
||||
|
"dataLevel": "CITY" |
||||
|
} |
||||
|
] |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 3. 查询城市默认参数 |
||||
|
|
||||
|
`GET /salary-calculator/cities/{cityCode}` |
||||
|
|
||||
|
返回城市级或省级回退的当前默认参数。计算请求必须原样携带响应中的 `policyVersion` 作为 `expectedPolicyVersion`。 |
||||
|
|
||||
|
## 4. 计算并保存 |
||||
|
|
||||
|
`POST /salary-calculator/calculations` |
||||
|
|
||||
|
请求示例: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"cityCode": "110000", |
||||
|
"grossMonthlySalary": 10000.00, |
||||
|
"monthIndex": 1, |
||||
|
"specialAdditionalDeduction": 0.00, |
||||
|
"expectedPolicyVersion": "2026-09-20-v1", |
||||
|
"idempotencyKey": "optional-client-key", |
||||
|
"overrides": { |
||||
|
"socialSecurityBase": 10000.00, |
||||
|
"housingFundBase": 10000.00, |
||||
|
"pensionRate": 0.08, |
||||
|
"medicalRate": 0.02, |
||||
|
"unemploymentRate": 0.005, |
||||
|
"housingFundRate": 0.12, |
||||
|
"seriousIllnessMonthlyFee": 0.00 |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
- `cityCode`、`grossMonthlySalary`、`expectedPolicyVersion` 必填。 |
||||
|
- `monthIndex` 默认 1,范围 1–12。 |
||||
|
- `specialAdditionalDeduction` 默认 0。 |
||||
|
- `idempotencyKey` 可选,最长 64 字符;同用户同键同规范化请求返回原历史,同键不同请求返回 `409/10604`。 |
||||
|
- 覆盖基数必须位于对应城市范围内;比例为 0 时允许基数为 0。 |
||||
|
|
||||
|
成功响应: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": { |
||||
|
"historyId": 123, |
||||
|
"createdAt": "2026-09-20T16:00:00", |
||||
|
"result": { |
||||
|
"cityCode": "110000", |
||||
|
"cityName": "北京市", |
||||
|
"dataLevel": "CITY", |
||||
|
"policyVersion": "2026-09-20-v1", |
||||
|
"policyEffectiveDate": "2026-07-01", |
||||
|
"taxRulesVersion": "CN_IIT_2019_V1", |
||||
|
"estimate": true, |
||||
|
"monthIndex": 1, |
||||
|
"grossMonthlySalary": 10000.00, |
||||
|
"socialSecurityBase": 10000.00, |
||||
|
"housingFundBase": 10000.00, |
||||
|
"pension": 800.00, |
||||
|
"medical": 200.00, |
||||
|
"unemployment": 50.00, |
||||
|
"housingFund": 1200.00, |
||||
|
"currentMonthTax": 82.50, |
||||
|
"netSalary": 7667.50 |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 5. 查询历史 |
||||
|
|
||||
|
`GET /salary-calculator/calculations?page=0&size=20` |
||||
|
|
||||
|
- 仅返回当前用户的记录。 |
||||
|
- 按创建时间倒序、历史 ID 倒序。 |
||||
|
- `size` 最大 100。 |
||||
|
|
||||
|
`GET /salary-calculator/calculations/{id}` |
||||
|
|
||||
|
返回输入、政策、覆盖项和结果三组完整 JSON 快照。访问他人历史统一返回 `404/10603`。 |
||||
|
|
||||
|
## 6. 删除历史 |
||||
|
|
||||
|
`DELETE /salary-calculator/calculations/{id}` |
||||
|
|
||||
|
物理删除本人历史;成功后再次查询返回 `404/10603`。删除后原幂等键可重新使用。 |
||||
|
|
||||
|
## 7. 错误码 |
||||
|
|
||||
|
| HTTP | code | 含义 | |
||||
|
|---|---|---| |
||||
|
| 400 | 10602 | 工资、比例、基数或分页参数非法 | |
||||
|
| 401 | 401 | 未登录或 Token 无效 | |
||||
|
| 403 | 2002 | 已登录但不是 `STUDENT` | |
||||
|
| 404 | 10601 | 城市不存在、停用或未配置 | |
||||
|
| 404 | 10603 | 历史不存在或不属于当前用户 | |
||||
|
| 409 | 10604 | 幂等键冲突 | |
||||
|
| 409 | 10605 | 城市政策版本已变化 | |
||||
|
|
||||
|
`10605` 响应会在 `data` 中返回最新 `policyVersion` 和 `policyEffectiveDate`,客户端应重新加载城市详情后再试算。 |
||||
|
|
||||
|
## 8. 计算口径 |
||||
|
|
||||
|
```text |
||||
|
社保基数 = 用户覆盖值,否则 min(max(税前月薪, 社保下限), 社保上限) |
||||
|
公积金基数 = 用户覆盖值,否则 min(max(税前月薪, 公积金下限), 公积金上限) |
||||
|
|
||||
|
累计收入 = 税前月薪 × monthIndex |
||||
|
累计基本减除费用 = 5000 × monthIndex |
||||
|
累计专项附加扣除 = 月度专项附加扣除 × monthIndex |
||||
|
累计应纳税所得额 = max(累计收入 - 累计个人社保 - 累计公积金 - 累计大病费 |
||||
|
- 累计基本减除费用 - 累计专项附加扣除, 0) |
||||
|
当月个税 = max(累计应纳税额 - 累计已预扣税额, 0) |
||||
|
到手工资 = max(税前月薪 - 个人养老 - 个人医疗 - 个人失业 - 个人公积金 |
||||
|
- 大病固定费 - 当月个税, 0) |
||||
|
``` |
||||
@ -0,0 +1,275 @@ |
|||||
|
# 行业技能词云、个人匹配度与学习路径 API |
||||
|
|
||||
|
> 版本:N03 设计锁定版 |
||||
|
> 状态:已编码实现 |
||||
|
> 适用范围:学生端只读接口。本文描述 `GET /api/v1/skills/word-cloud`、`GET /api/v1/skills/match`、`GET /api/v1/skills/learning-path`。 |
||||
|
> Base URL:`http://127.0.0.1:8080/api/v1`(生产环境替换域名,路径不变)。 |
||||
|
|
||||
|
## 1. 权限与共同参数 |
||||
|
|
||||
|
三个接口均: |
||||
|
|
||||
|
- 仅允许 `STUDENT` 访问。 |
||||
|
- 必须携带 `Authorization: Bearer <accessToken>`,身份只从 JWT/SecurityContext 获取,不接受 `userId`、`studentId` 等身份注入参数。 |
||||
|
- 成功、`400` 和 `403` 响应使用统一 `ApiResponse`;匿名或无效 JWT 的 `401` 由全局 Security 入口直接返回裸 JSON,不包含 `data` 和 `requestId`。 |
||||
|
- 只读,不写入学习进度、学生资料或词库数据。 |
||||
|
|
||||
|
查询参数: |
||||
|
|
||||
|
| 参数 | 类型 | 必填 | 说明 | |
||||
|
|---|---|---|---| |
||||
|
| `industryId` | long | 是 | 词库行业 ID,必须为正整数,且必须是真实存在的行业。 | |
||||
|
| `occupationId` | long | 是 | 词库职位 ID,必须为正整数,且必须属于该行业。 | |
||||
|
| `categoryId` | long | 否 | 技能分类 ID,必须为正整数;省略时使用该职位下全部技能分类。传入时必须属于目标职位。 | |
||||
|
|
||||
|
任一必填参数缺失、非正整数,或行业—职位—分类关系非法时返回 `400`。如果未登录返回 `401`,非 STUDENT 用户返回 `403`。 |
||||
|
|
||||
|
## 2. 通用响应与排序 |
||||
|
|
||||
|
三个接口的 `data` 都使用 camelCase 字段。词云和 TOP10 使用同一份服务端排序结果: |
||||
|
|
||||
|
1. `heat` 从高到低;`heat` 为 `null` 的技能排在所有非空热度之后。 |
||||
|
2. 热度相同时按 `nameZh` 升序。 |
||||
|
3. 其余情况按 `skillId` 升序,保证重复请求顺序稳定。 |
||||
|
4. `rank` 从 1 连续递增。 |
||||
|
|
||||
|
`heat` 来自 O*NET Importance。无可靠 O*NET Importance 映射的 ESCO 技能仍返回,但 `heat` 为 `null`。TOP10 不在后端单独存储或单独排序;前端直接取词云 `data.items` 的前 `min(10, data.total)` 项。 |
||||
|
|
||||
|
技能身份来源: |
||||
|
|
||||
|
- `sourceType`:`ESCO` 或 `O*NET`,表示技能条目的主来源。 |
||||
|
- `sourceCode`:可追溯来源标识。ESCO 为技能 URI;O*NET 补充技能为 `SOC|Workplace Example|Element ID`。 |
||||
|
- `heatSourceCode`:热度来源 SOC;没有 O*NET 热度时为 `null`。 |
||||
|
- `heatElementId`:O*NET Importance 的来源 Element ID;没有时为 `null`。 |
||||
|
- `heatProxy`:该条热度是否为代理映射。代理值不改变排序规则,但前端可据此展示来源说明。 |
||||
|
|
||||
|
## 3. 查询词云 |
||||
|
|
||||
|
```http |
||||
|
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2&categoryId=101 |
||||
|
Authorization: Bearer <accessToken> |
||||
|
``` |
||||
|
|
||||
|
`categoryId` 可省略: |
||||
|
|
||||
|
```http |
||||
|
GET /api/v1/skills/word-cloud?industryId=1&occupationId=2 |
||||
|
``` |
||||
|
|
||||
|
成功响应为 `ApiResponse<SkillCloudResponse>`。`SkillCloudResponse` 返回筛选节点、总数和当前范围内的完整技能列表: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": { |
||||
|
"industry": { "id": 1, "code": "INTERNET", "name": "互联网" }, |
||||
|
"occupation": { "id": 1, "code": "FRONTEND_DEVELOPER", "name": "前端开发工程师" }, |
||||
|
"category": { "id": 103, "code": "FRONTEND_FRAMEWORK", "name": "前端框架" }, |
||||
|
"total": 1, |
||||
|
"items": [ |
||||
|
{ |
||||
|
"rank": 1, |
||||
|
"skillId": 26, |
||||
|
"skillCode": "REACT", |
||||
|
"nameZh": "React", |
||||
|
"nameEn": "React", |
||||
|
"sourceType": "O*NET", |
||||
|
"sourceCode": "15-1254.00|React|2.E.7.h", |
||||
|
"categoryId": 103, |
||||
|
"categoryName": "前端框架", |
||||
|
"heat": 4.38, |
||||
|
"heatSourceCode": "15-1254.00", |
||||
|
"heatElementId": "2.E.7.h", |
||||
|
"heatProxy": true |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
"requestId": "req-123", |
||||
|
"timestamp": "2026-09-15T10:00:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
`category` 在未传 `categoryId` 时为 `null`。合法范围但暂时没有技能时返回成功、`total=0`、`items=[]`。前端根据 `items` 渲染词云,并截取前十条作为 TOP10。 |
||||
|
|
||||
|
## 4. 查询个人技能匹配度 |
||||
|
|
||||
|
```http |
||||
|
GET /api/v1/skills/match?industryId=1&occupationId=2&categoryId=101 |
||||
|
Authorization: Bearer <accessToken> |
||||
|
``` |
||||
|
|
||||
|
匹配集合严格取当前筛选结果的 TOP10。系统从当前 JWT 学生的 `student_profiles.skills_json` 读取技能,使用标准技能名、英文名、技能代码和别名归一化匹配。大小为 `total` 的筛选范围使用 `min(10, total)` 作为分母: |
||||
|
|
||||
|
```text |
||||
|
matchScore = round(matchedCount * 100 / min(10, total)) |
||||
|
``` |
||||
|
|
||||
|
- `total=0` 时 `matchScore=0`,命中列表和缺失列表为空。 |
||||
|
- 范围少于 10 项时按实际技能数计算。 |
||||
|
- 重复技能或指向同一标准技能的多个别名只计一次。 |
||||
|
- 示例:范围 15 条、命中 5 条,`matchScore=33`。 |
||||
|
- 原始 `skills_json`、手机号、邮箱和简历内容不会出现在响应中。 |
||||
|
|
||||
|
成功响应为 `ApiResponse<SkillMatchResponse>`: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": { |
||||
|
"matchScore": 50, |
||||
|
"matchedCount": 1, |
||||
|
"totalCount": 2, |
||||
|
"matchedSkills": [ |
||||
|
{ |
||||
|
"rank": 1, |
||||
|
"skillId": 26, |
||||
|
"skillCode": "REACT", |
||||
|
"nameZh": "React", |
||||
|
"nameEn": "React", |
||||
|
"sourceType": "O*NET", |
||||
|
"sourceCode": "15-1254.00|React|2.E.7.h", |
||||
|
"categoryId": 103, |
||||
|
"categoryName": "前端框架", |
||||
|
"heat": 4.38, |
||||
|
"heatSourceCode": "15-1254.00", |
||||
|
"heatElementId": "2.E.7.h", |
||||
|
"heatProxy": true |
||||
|
} |
||||
|
], |
||||
|
"missingSkills": [ |
||||
|
{ |
||||
|
"rank": 2, |
||||
|
"skillId": 31, |
||||
|
"skillCode": "DOCKER", |
||||
|
"nameZh": "Docker", |
||||
|
"nameEn": "Docker", |
||||
|
"sourceType": "O*NET", |
||||
|
"sourceCode": "15-1299.08|Docker|2.E.7.h", |
||||
|
"categoryId": 105, |
||||
|
"categoryName": "工程化工具", |
||||
|
"heat": 4.12, |
||||
|
"heatSourceCode": "15-1299.08", |
||||
|
"heatElementId": "2.E.7.h", |
||||
|
"heatProxy": true |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
"requestId": "req-456", |
||||
|
"timestamp": "2026-09-15T10:01:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
`matchedSkills` 与 `missingSkills` 的并集等于当前 TOP10,两个列表各自保持词云排序;`totalCount` 是实际匹配集合大小。 |
||||
|
|
||||
|
## 5. 生成学习路径 |
||||
|
|
||||
|
```http |
||||
|
GET /api/v1/skills/learning-path?industryId=1&occupationId=2&categoryId=101 |
||||
|
Authorization: Bearer <accessToken> |
||||
|
``` |
||||
|
|
||||
|
学习路径只消费个人匹配结果中的 `missingSkills`: |
||||
|
|
||||
|
- 只包含当前 TOP10 中未命中的技能,不包含已命中技能或 TOP10 之外的技能。 |
||||
|
- 顺序与 `missingSkills` 完全一致,`priority` 从 1 连续递增。 |
||||
|
- 每项只返回技能 ID、名称、分类、优先级和简化学习建议。 |
||||
|
- 后端不调用 AI、课程平台或外部知识库;不会返回课程链接、讲师、价格、学习时长或完成度。 |
||||
|
- 系统不持久化学习进度或完成状态。 |
||||
|
- 全部命中或合法范围没有技能时返回 `total=0`、`items=[]`。 |
||||
|
|
||||
|
建议模板按技能分类确定性生成: |
||||
|
|
||||
|
| 模板类别 | 建议方向 | |
||||
|
|---|---| |
||||
|
| 编程语言 | 基础语法、常见数据结构、至少一个编码练习。 | |
||||
|
| 框架 | 核心概念与生命周期、小型项目集成实践。 | |
||||
|
| 工具 | 安装与基础配置、结合工程完成实际操作。 | |
||||
|
| 通用 | 核心概念和常见应用场景、简短实践巩固。 | |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 200, |
||||
|
"message": "ok", |
||||
|
"data": { |
||||
|
"total": 1, |
||||
|
"items": [ |
||||
|
{ |
||||
|
"priority": 1, |
||||
|
"skillId": 26, |
||||
|
"skillName": "React", |
||||
|
"categoryId": 103, |
||||
|
"categoryName": "前端框架", |
||||
|
"suggestion": "先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。" |
||||
|
} |
||||
|
] |
||||
|
}, |
||||
|
"requestId": "req-789", |
||||
|
"timestamp": "2026-09-15T10:02:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 6. 错误响应示例 |
||||
|
|
||||
|
`400` 和 `403` 使用统一 `ApiResponse`;`401` 由全局 Security 入口返回裸 JSON。以下示例与运行时契约一致。 |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 1001, |
||||
|
"message": "bad request", |
||||
|
"data": null, |
||||
|
"requestId": "req-400", |
||||
|
"timestamp": "2026-09-15T10:03:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
| HTTP | 场景 | 典型 `code` | |
||||
|
|---|---|---| |
||||
|
| `400` | `industryId`/`occupationId` 缺失、非正整数,或节点关系非法 | `1001` | |
||||
|
| `401` | 未携带有效 JWT 或令牌无效/过期 | `401` | |
||||
|
| `403` | 已登录但不是 STUDENT | `2002` | |
||||
|
|
||||
|
示例 401: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 401, |
||||
|
"message": "not authenticated", |
||||
|
"timestamp": "" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
`401` 响应不包含 `data` 和 `requestId`。 |
||||
|
|
||||
|
示例 403: |
||||
|
|
||||
|
```json |
||||
|
{ |
||||
|
"code": 2002, |
||||
|
"message": "forbidden", |
||||
|
"data": null, |
||||
|
"requestId": "req-403", |
||||
|
"timestamp": "2026-09-15T10:05:00Z" |
||||
|
} |
||||
|
``` |
||||
|
|
||||
|
## 7. 字段参考 |
||||
|
|
||||
|
### SkillCloudNode |
||||
|
|
||||
|
`id`、`code`、`name`。`category` 节点在未指定分类时为 `null`。 |
||||
|
|
||||
|
### SkillCloudItem |
||||
|
|
||||
|
`rank`、`skillId`、`skillCode`、`nameZh`、`nameEn`、`sourceType`、`sourceCode`、`categoryId`、`categoryName`、`heat`、`heatSourceCode`、`heatElementId`、`heatProxy`。 |
||||
|
|
||||
|
### SkillMatchResponse |
||||
|
|
||||
|
`matchScore`、`matchedCount`、`totalCount`、`matchedSkills`、`missingSkills`。 |
||||
|
|
||||
|
### SkillLearningPathItem / SkillLearningPathResponse |
||||
|
|
||||
|
`SkillLearningPathItem`:`priority`、`skillId`、`skillName`、`categoryId`、`categoryName`、`suggestion`。 |
||||
|
|
||||
|
`SkillLearningPathResponse`:`total`、`items`。 |
||||
@ -0,0 +1,79 @@ |
|||||
|
# 行业技能词库来源与覆盖说明 |
||||
|
|
||||
|
本说明是仓库面向实现和评审的技能词库来源基线,内容与 `changes/n03-skill-cloud/data/source-manifest.md` 保持同一事实集合。 |
||||
|
|
||||
|
## 1. 固定来源 |
||||
|
|
||||
|
| 来源 | 固定版本 | 用途 | 许可/复用条件 | 官方地址与归属 | |
||||
|
| --- | --- | --- | --- | --- | |
||||
|
| ESCO | ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0) | 提供行业/职位映射所需的职业—技能—技能分类主关系、稳定来源 ID 和层级结构 | 依据 Commission Decision 2011/833/EU,可免费下载、使用、复制和复用。文档和产品界面须保留官方署名语句:`This service uses the ESCO classification of the European Commission.` | 数据集下载页:https://esco.ec.europa.eu/en/use-esco/download<br>复用条件说明:https://esco.ec.europa.eu/en/about-esco/faq | |
||||
|
| O*NET | O*NET 31.0 Database | 为 ESCO 未收录的具体技术提供 `software_skills` 补充来源,并通过职业/工作活动 Importance 提供首版词云热度 `heat` | License 为 CC BY 4.0,归属与许可说明见 https://creativecommons.org/licenses/by/4.0/ | 官方数据库页:https://www.onetcenter.org/database.html | |
||||
|
|
||||
|
运行时不得访问 ESCO、O*NET 或任何在线词库服务。所有首版数据必须随 Flyway 迁移进入本地数据库,并保留来源版本与归属信息。 |
||||
|
|
||||
|
## 2. 首版覆盖边界 |
||||
|
|
||||
|
首版只覆盖互联网、金融、教育三类行业及下列 10 个已列代表性职位。该矩阵定义导入上限和最小覆盖范围,不要求在全量上游数据中导入未列出的职位。 |
||||
|
|
||||
|
| 行业 | 代表性职位 | 首批技能分类范围 | |
||||
|
| --- | --- | --- | |
||||
|
| 互联网 | 前端开发工程师 | 编程语言、Web 基础、前端框架、工程化工具、浏览器与调试、测试、版本控制与协作 | |
||||
|
| 互联网 | 后端开发工程师 | 编程语言、后端框架与平台、数据库、缓存与消息、云/DevOps、测试、工程化工具、安全 | |
||||
|
| 互联网 | 测试工程师 | 编程语言、测试设计、自动化测试框架、接口与数据库测试、性能测试、CI/CD 与工程化工具 | |
||||
|
| 互联网 | DevOps 工程师 | 编程语言与脚本、云平台、容器与编排、CI/CD、配置管理与 IaC、可观测性、安全、数据库 | |
||||
|
| 金融 | 金融数据分析师 | 编程语言、数据分析与可视化、数据库与数据仓库、统计与建模、金融计算框架/平台、金融领域知识、工程化工具 | |
||||
|
| 金融 | 风控建模工程师 | 编程语言、统计与建模、风险模型、建模框架/平台、数据处理与数据库、模型验证、部署与工程化工具 | |
||||
|
| 金融 | 量化开发工程师 | 编程语言、量化建模框架/平台、市场与金融数据、数据库、低延迟与高性能工程、测试、云/DevOps | |
||||
|
| 教育 | 教育产品经理 | 教育领域知识、产品分析方法、需求与流程、数据分析、教育平台与工具、协作与工程化工具 | |
||||
|
| 教育 | 教学设计师 | 教学设计、课程与学习平台、数字化内容工具、评估设计、学习数据分析、无障碍与规范 | |
||||
|
| 教育 | 教育数据分析师 | 编程语言、数据分析与可视化、数据库与 SQL、统计与建模、教育领域知识、报表与工程化工具 | |
||||
|
计数说明:任务给出的逐项职位清单为互联网 4 个、金融 3 个、教育 3 个,共 10 个。验收文字中的“11 个职位”与该枚举不一致;本清单严格覆盖逐项列出的 10 个职位,不自行新增未批准职位。 |
||||
|
|
||||
|
矩阵中的分类是每个职位首批应覆盖的能力域;T3 只导入这些分类下已确定稳定来源 ID、标准技能和中文标准名的技能,不在此文档中展开具体 `INSERT` 行。 |
||||
|
|
||||
|
## 3. 导入规则 |
||||
|
|
||||
|
1. **职责分工**:T2 只建立七张词库表、外键、唯一约束和查询索引;T3 只导入上述三类行业、10 个已列代表性职位及其关联技能分类、技能、别名和 O*NET Importance。 |
||||
|
2. **代表性子集与混合来源**:不导入 ESCO/O*NET 全量数据;ESCO 是职位、通用技能和分类主表;ESCO 无可靠节点的具体技术使用 O*NET `software_skills` 补充;不把平台自由文本岗位或行业直接当作标准词库节点。 |
||||
|
3. **来源 ID 不变**:ESCO/O*NET 来源 ID 原样保存在稳定来源字段中;项目不得重编号、覆盖或伪造上游 ID。 |
||||
|
4. **中文标准名必填**:每个入选技能必须有非空 `name_zh` 中文标准名;中文名称由项目维护,但不替代或修改上游来源 ID。 |
||||
|
5. **热度来源**:每条非空 Importance 必须保存 O*NET SOC、Element ID 和代理标记;技能无法映射 O*NET Importance 时必须保留,写入 `importance=null`;查询时映射为 `heat=null`,并排在有热度技能之后。 |
||||
|
6. **项目映射责任**:项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。行业/职位映射、细粒度分类和跨源技能对应关系均作为项目适配层记录。 |
||||
|
7. **别名归一化**:生成 `normalized_alias` 时依次执行 Unicode NFKC、首尾空白移除、大小写折叠、连字符转空格、连续空白折叠为单空格、句点移除;`+` 和 `#` 必须保留,不能删除或替换。 |
||||
|
8. **来源可追溯**:来源清单写入 `skill_source_versions`,至少记录来源名称、固定版本、许可、归属 URL 和导入时间;O*NET 补充技术的 `source_code` 使用真实 `SOC|Workplace Example|Element ID` 组合,职位技能关系记录 O*NET SOC、Element ID、代理标记及关联的来源版本。 |
||||
|
9. **迁移不可改写**:T2/T3 计划使用 `V47__skill_cloud_taxonomy.sql` 和 `V48__skill_cloud_seed.sql`。执行前若编号已被占用,必须回退规划并刷新编号;后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或任何已执行迁移。 |
||||
|
10. **运行时离线**:接口运行时不得调用外部词库、AI、课程平台或同步服务;排名只读取本地 Flyway 数据。 |
||||
|
|
||||
|
## 4. 来源版本、许可与归属 URL |
||||
|
|
||||
|
| 数据落点 | 来源名称 | 版本字段固定值 | 许可字段固定值 | 归属 URL | |
||||
|
| --- | --- | --- | --- | --- | |
||||
|
| 职业、技能、技能分类及关系 | ESCO | `1.2.1 (Release v5.7.0, Last update 10/12/2025)` | `Commission Decision 2011/833/EU` | https://esco.ec.europa.eu/en/use-esco/download | |
||||
|
| 职位技能 Importance | O*NET | `31.0 Database` | `CC BY 4.0` | https://www.onetcenter.org/database.html | |
||||
|
|
||||
|
ESCO 产品署名必须使用:`This service uses the ESCO classification of the European Commission.` |
||||
|
|
||||
|
## 5. 映射责任边界 |
||||
|
|
||||
|
| 事项 | 上游来源责任 | 项目责任 | |
||||
|
| --- | --- | --- | |
||||
|
| ESCO 职业、技能、分类层级和来源 ID | 固定且可追溯 | 原样保存,不修改 ID | |
||||
|
| O*NET Importance 数值 | 固定且可追溯 | 原样保存;无映射时写 `null`,不造权重 | |
||||
|
| 中文名称和别名 | 不保证完整中文覆盖 | 补齐中文标准名、别名及归一化值 | |
||||
|
| 细粒度技能分类 | 不保证符合本项目展示粒度 | 在职位下维护分类和展示名称 | |
||||
|
| 行业/职位映射 | 不保证覆盖三类项目行业和中文职位名 | 维护行业—职位—分类—技能映射 | |
||||
|
| 跨源技能与重要性关联 | 不提供项目所需的稳定本地关系 | 维护 ESCO 技能到 O*NET `software_skills` 的补充映射,以及每条 Importance 的 SOC/Element ID/代理标记 | |
||||
|
|
||||
|
## 6. 验收检查项 |
||||
|
|
||||
|
- AC-1:`changes/n03-skill-cloud/data/source-manifest.md` 与 `docs/skill-taxonomy-sources.md` 均存在且非空,来源版本、许可、归属 URL、覆盖矩阵和导入规则一致。 |
||||
|
- AC-2:文档明确覆盖互联网、金融、教育三类行业及全部 10 个已列代表性职位,并为每个职位给出首批技能分类范围。 |
||||
|
- AC-3:文档明确固定 ESCO dataset v1.2.1(Last update 10/12/2025,发布页记录 Release v5.7.0)和 O*NET 31.0 Database。 |
||||
|
- AC-4:文档明确 ESCO 依据 Commission Decision 2011/833/EU 可免费下载、使用、复制和复用,并原样保留指定署名语句。 |
||||
|
- AC-5:文档明确 O*NET License 为 CC BY 4.0,并记录官方数据库页与许可 URL。 |
||||
|
- AC-6:文档明确项目维护中文名称、别名、技能分类和行业/职位映射,不修改开源来源 ID。 |
||||
|
- AC-7:文档明确 T2 建结构、T3 导数据,只导入代表性职位和关联技能;技能中文标准名必填。 |
||||
|
- AC-8:文档明确 O*NET 补充技术、每条 Importance 的 SOC/Element ID/代理标记,以及无映射时保留技能并写入 `importance=null`、查询侧映射为 `heat=null`。 |
||||
|
- AC-9:文档明确别名归一化遵循 NFKC、首尾空白、大小写、连字符、连续空白、句点规则,且保留 `+`、`#`。 |
||||
|
- AC-10:文档明确后续词库更新只能新增 Flyway 迁移,不得改写 V1–V34 或已执行迁移。 |
||||
|
- AC-11:来源、覆盖、导入与责任边界中不得出现未决占位或延期确认语句。 |
||||
@ -0,0 +1,63 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.EntityListeners; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import jakarta.persistence.UniqueConstraint; |
||||
|
import jakarta.persistence.Version; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import org.springframework.data.annotation.CreatedDate; |
||||
|
import org.springframework.data.annotation.LastModifiedDate; |
||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table( |
||||
|
name = "company_knowledge", |
||||
|
uniqueConstraints = @UniqueConstraint( |
||||
|
name = "uk_company_knowledge_company", |
||||
|
columnNames = "company_id")) |
||||
|
@EntityListeners(AuditingEntityListener.class) |
||||
|
public class CompanyKnowledge { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "company_id", nullable = false) |
||||
|
private Long companyId; |
||||
|
|
||||
|
@Column(length = 2000) |
||||
|
private String culture; |
||||
|
|
||||
|
@Column(name = "work_policies", length = 4000) |
||||
|
private String workPolicies; |
||||
|
|
||||
|
@Column(name = "onboarding_guide", length = 4000) |
||||
|
private String onboardingGuide; |
||||
|
|
||||
|
@Version |
||||
|
@Column(nullable = false) |
||||
|
@Builder.Default |
||||
|
private Long version = 0L; |
||||
|
|
||||
|
@CreatedDate |
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@LastModifiedDate |
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,74 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import jakarta.validation.Valid; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.PathVariable; |
||||
|
import org.springframework.web.bind.annotation.PutMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestBody; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
@Tag(name = "公司知识介绍", description = "企业维护知识内容,学生读取公司知识聚合") |
||||
|
@RestController |
||||
|
@RequestMapping("/onboarding") |
||||
|
@RequiredArgsConstructor |
||||
|
public class CompanyKnowledgeController { |
||||
|
|
||||
|
private final CompanyKnowledgeService companyKnowledgeService; |
||||
|
|
||||
|
@Operation(summary = "读取本企业知识配置") |
||||
|
@GetMapping("/company/knowledge") |
||||
|
public ResponseEntity<ApiResponse<CompanyKnowledgeView>> getCompanyKnowledge( |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
CompanyKnowledgeView data = companyKnowledgeService.getCompanyKnowledge(userId); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "创建或更新本企业知识配置") |
||||
|
@PutMapping("/company/knowledge") |
||||
|
public ResponseEntity<ApiResponse<CompanyKnowledgeView>> upsertCompanyKnowledge( |
||||
|
@Valid @RequestBody CompanyKnowledgeUpdateRequest body, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
CompanyKnowledgeView data = |
||||
|
companyKnowledgeService.upsertCompanyKnowledge(userId, body); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "读取公司知识介绍") |
||||
|
@GetMapping("/companies/{companyId}/knowledge") |
||||
|
public ResponseEntity<ApiResponse<CompanyKnowledgeOverviewResponse>> getCompanyKnowledgeOverview( |
||||
|
@PathVariable Long companyId, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
CompanyKnowledgeOverviewResponse data = |
||||
|
companyKnowledgeService.getCompanyKnowledgeOverview(userId, companyId); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
private Long resolveUserId() { |
||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); |
||||
|
if (authentication == null || !authentication.isAuthenticated()) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
return Long.valueOf(authentication.getName()); |
||||
|
} |
||||
|
|
||||
|
private String getRequestId(HttpServletRequest request) { |
||||
|
return request.getHeader("X-Request-Id"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
import java.util.Optional; |
||||
|
|
||||
|
@Repository |
||||
|
public interface CompanyKnowledgeRepository extends JpaRepository<CompanyKnowledge, Long> { |
||||
|
|
||||
|
Optional<CompanyKnowledge> findByCompanyId(Long companyId); |
||||
|
} |
||||
@ -0,0 +1,412 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import jakarta.persistence.EntityManager; |
||||
|
import jakarta.persistence.LockModeType; |
||||
|
import jakarta.persistence.PersistenceContext; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.JsonProcessingException; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyKnowledgeOverviewResponse; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyKnowledgeUpdateRequest; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyKnowledgeView; |
||||
|
import com.mydreamjob.onboarding.dto.ReviewSummaryView; |
||||
|
import com.mydreamjob.review.JobReviewDimensionScoreRepository; |
||||
|
import com.mydreamjob.review.JobReviewRepository; |
||||
|
import com.mydreamjob.review.ReviewDimension; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.dao.DataIntegrityViolationException; |
||||
|
import org.springframework.orm.ObjectOptimisticLockingFailureException; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
import org.springframework.util.StringUtils; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.math.RoundingMode; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Collection; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Objects; |
||||
|
import java.util.regex.Pattern; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class CompanyKnowledgeService { |
||||
|
|
||||
|
private static final String CONFIGURED = "CONFIGURED"; |
||||
|
private static final String BASIC_ONLY = "BASIC_ONLY"; |
||||
|
private static final String EMPTY = "EMPTY"; |
||||
|
private static final String INSUFFICIENT = "INSUFFICIENT"; |
||||
|
private static final String SUFFICIENT = "SUFFICIENT"; |
||||
|
|
||||
|
private static final int MINIMUM_PUBLIC_REVIEWS = 3; |
||||
|
private static final int MAX_TOP_TAGS = 10; |
||||
|
private static final int MAX_CULTURE_LENGTH = 2000; |
||||
|
private static final int MAX_WORK_POLICIES_LENGTH = 4000; |
||||
|
private static final int MAX_ONBOARDING_GUIDE_LENGTH = 4000; |
||||
|
private static final Pattern HTML_TAG_PATTERN = |
||||
|
Pattern.compile("<\\s*/?\\s*[A-Za-z!][^>]*>"); |
||||
|
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); |
||||
|
|
||||
|
private final UserRepository userRepository; |
||||
|
private final CompanyRepository companyRepository; |
||||
|
private final CompanyKnowledgeRepository companyKnowledgeRepository; |
||||
|
private final JobReviewRepository jobReviewRepository; |
||||
|
private final JobReviewDimensionScoreRepository dimensionScoreRepository; |
||||
|
|
||||
|
@PersistenceContext |
||||
|
private EntityManager entityManager; |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public CompanyKnowledgeView getCompanyKnowledge(Long userId) { |
||||
|
Company company = requireOwnedCompany(userId); |
||||
|
return companyKnowledgeRepository.findByCompanyId(company.getId()) |
||||
|
.map(this::toConfiguredView) |
||||
|
.orElseGet(() -> CompanyKnowledgeView.builder() |
||||
|
.configured(false) |
||||
|
.version(0L) |
||||
|
.build()); |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public CompanyKnowledgeView upsertCompanyKnowledge( |
||||
|
Long userId, |
||||
|
CompanyKnowledgeUpdateRequest request) { |
||||
|
Company company = requireOwnedCompany(userId); |
||||
|
KnowledgeContent content = validateContent(request); |
||||
|
lockCompany(company); |
||||
|
CompanyKnowledge existing = companyKnowledgeRepository.findByCompanyId(company.getId()) |
||||
|
.orElse(null); |
||||
|
|
||||
|
if (existing == null) { |
||||
|
CompanyKnowledge created = CompanyKnowledge.builder() |
||||
|
.companyId(company.getId()) |
||||
|
.culture(content.culture()) |
||||
|
.workPolicies(content.workPolicies()) |
||||
|
.onboardingGuide(content.onboardingGuide()) |
||||
|
.build(); |
||||
|
return toConfiguredView(saveKnowledge(created)); |
||||
|
} |
||||
|
|
||||
|
if (request.getVersion() == null && matchesContent(existing, content)) { |
||||
|
return toConfiguredView(existing); |
||||
|
} |
||||
|
if (request.getVersion() == null) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
if (!Objects.equals(request.getVersion(), existing.getVersion())) { |
||||
|
throw new BusinessException(ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE); |
||||
|
} |
||||
|
|
||||
|
existing.setCulture(content.culture()); |
||||
|
existing.setWorkPolicies(content.workPolicies()); |
||||
|
existing.setOnboardingGuide(content.onboardingGuide()); |
||||
|
return toConfiguredView(saveKnowledge(existing)); |
||||
|
} |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public CompanyKnowledgeOverviewResponse getCompanyKnowledgeOverview( |
||||
|
Long userId, |
||||
|
Long companyId) { |
||||
|
assertStudentRole(userId); |
||||
|
Company company = companyRepository.findById(companyId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
||||
|
CompanyKnowledge knowledge = companyKnowledgeRepository.findByCompanyId(companyId) |
||||
|
.orElse(null); |
||||
|
|
||||
|
return CompanyKnowledgeOverviewResponse.builder() |
||||
|
.company(toCompanyView(company)) |
||||
|
.knowledgeState(knowledge == null ? BASIC_ONLY : CONFIGURED) |
||||
|
.knowledge(knowledge == null ? null : toKnowledgeView(knowledge)) |
||||
|
.reviewSummary(buildReviewSummary(companyId)) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private CompanyKnowledge saveKnowledge(CompanyKnowledge knowledge) { |
||||
|
try { |
||||
|
return companyKnowledgeRepository.saveAndFlush(knowledge); |
||||
|
} catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { |
||||
|
throw new BusinessException( |
||||
|
ErrorCode.ONBOARDING_KNOWLEDGE_CONCURRENT_UPDATE, ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private boolean matchesContent(CompanyKnowledge knowledge, KnowledgeContent content) { |
||||
|
return Objects.equals(knowledge.getCulture(), content.culture()) |
||||
|
&& Objects.equals(knowledge.getWorkPolicies(), content.workPolicies()) |
||||
|
&& Objects.equals(knowledge.getOnboardingGuide(), content.onboardingGuide()); |
||||
|
} |
||||
|
|
||||
|
private void lockCompany(Company company) { |
||||
|
if (entityManager == null || company == null || company.getId() == null) { |
||||
|
return; |
||||
|
} |
||||
|
entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE); |
||||
|
} |
||||
|
|
||||
|
private KnowledgeContent validateContent(CompanyKnowledgeUpdateRequest request) { |
||||
|
if (request == null) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
if (request.getVersion() != null && request.getVersion() < 0) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
|
||||
|
String culture = normalizeText(request.getCulture(), MAX_CULTURE_LENGTH); |
||||
|
String workPolicies = normalizeText( |
||||
|
request.getWorkPolicies(), MAX_WORK_POLICIES_LENGTH); |
||||
|
String onboardingGuide = normalizeText( |
||||
|
request.getOnboardingGuide(), MAX_ONBOARDING_GUIDE_LENGTH); |
||||
|
|
||||
|
if (culture == null && workPolicies == null && onboardingGuide == null) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
return new KnowledgeContent(culture, workPolicies, onboardingGuide); |
||||
|
} |
||||
|
|
||||
|
private String normalizeText(String raw, int maxLength) { |
||||
|
if (!StringUtils.hasText(raw)) { |
||||
|
return null; |
||||
|
} |
||||
|
String value = raw.trim(); |
||||
|
if (value.length() > maxLength) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
if (HTML_TAG_PATTERN.matcher(value).find()) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private ReviewSummaryView buildReviewSummary(Long companyId) { |
||||
|
List<Object[]> countRows = |
||||
|
jobReviewRepository.countAndAveragePublishedRatingByCompanyId(companyId); |
||||
|
long totalReviews = extractCount(countRows); |
||||
|
|
||||
|
if (totalReviews == 0) { |
||||
|
return ReviewSummaryView.builder() |
||||
|
.state(EMPTY) |
||||
|
.totalReviews(0) |
||||
|
.build(); |
||||
|
} |
||||
|
if (totalReviews < MINIMUM_PUBLIC_REVIEWS) { |
||||
|
return ReviewSummaryView.builder() |
||||
|
.state(INSUFFICIENT) |
||||
|
.totalReviews(Math.toIntExact(totalReviews)) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
return ReviewSummaryView.builder() |
||||
|
.state(SUFFICIENT) |
||||
|
.totalReviews(Math.toIntExact(totalReviews)) |
||||
|
.averageRating(extractAverageRating(countRows)) |
||||
|
.ratingDistribution(buildRatingDistribution(companyId)) |
||||
|
.topTags(buildTopTags(companyId)) |
||||
|
.dimensionSummary(buildDimensionSummary(companyId)) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private long extractCount(List<Object[]> rows) { |
||||
|
if (rows == null || rows.isEmpty() || rows.get(0) == null |
||||
|
|| rows.get(0).length == 0 || !(rows.get(0)[0] instanceof Number count)) { |
||||
|
return 0L; |
||||
|
} |
||||
|
return count.longValue(); |
||||
|
} |
||||
|
|
||||
|
private Double extractAverageRating(List<Object[]> rows) { |
||||
|
if (rows == null || rows.isEmpty() || rows.get(0) == null |
||||
|
|| rows.get(0).length < 2 || !(rows.get(0)[1] instanceof Number average)) { |
||||
|
return 0.0; |
||||
|
} |
||||
|
return BigDecimal.valueOf(average.doubleValue()) |
||||
|
.setScale(1, RoundingMode.HALF_UP) |
||||
|
.doubleValue(); |
||||
|
} |
||||
|
|
||||
|
private Map<String, Integer> buildRatingDistribution(Long companyId) { |
||||
|
Map<String, Integer> distribution = new LinkedHashMap<>(); |
||||
|
for (int rating = 1; rating <= 5; rating++) { |
||||
|
distribution.put("rating" + rating, 0); |
||||
|
} |
||||
|
|
||||
|
List<Object[]> rows = |
||||
|
jobReviewRepository.findPublishedRatingDistributionByCompanyId(companyId); |
||||
|
if (rows == null) { |
||||
|
return distribution; |
||||
|
} |
||||
|
for (Object[] row : rows) { |
||||
|
if (row == null || row.length < 2 |
||||
|
|| !(row[0] instanceof Number rating) |
||||
|
|| !(row[1] instanceof Number count)) { |
||||
|
continue; |
||||
|
} |
||||
|
int ratingValue = rating.intValue(); |
||||
|
if (ratingValue >= 1 && ratingValue <= 5) { |
||||
|
distribution.put("rating" + ratingValue, count.intValue()); |
||||
|
} |
||||
|
} |
||||
|
return distribution; |
||||
|
} |
||||
|
|
||||
|
private List<String> buildTopTags(Long companyId) { |
||||
|
Map<String, Long> counts = new HashMap<>(); |
||||
|
List<String> rawTags = jobReviewRepository.findPublishedTagsByCompanyId(companyId); |
||||
|
if (rawTags != null) { |
||||
|
for (String rawTag : rawTags) { |
||||
|
for (String tag : parseTags(rawTag)) { |
||||
|
counts.merge(tag, 1L, Long::sum); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
return counts.entrySet().stream() |
||||
|
.sorted((left, right) -> { |
||||
|
int byCount = Long.compare(right.getValue(), left.getValue()); |
||||
|
return byCount != 0 ? byCount : left.getKey().compareTo(right.getKey()); |
||||
|
}) |
||||
|
.limit(MAX_TOP_TAGS) |
||||
|
.map(Map.Entry::getKey) |
||||
|
.toList(); |
||||
|
} |
||||
|
|
||||
|
private List<String> parseTags(String raw) { |
||||
|
if (!StringUtils.hasText(raw)) { |
||||
|
return List.of(); |
||||
|
} |
||||
|
Object parsed = parseJson(raw); |
||||
|
for (int depth = 0; depth < 2 && parsed instanceof String nested; depth++) { |
||||
|
parsed = parseJson(nested); |
||||
|
} |
||||
|
if (!(parsed instanceof Collection<?> values)) { |
||||
|
return List.of(); |
||||
|
} |
||||
|
|
||||
|
List<String> tags = new ArrayList<>(); |
||||
|
for (Object value : values) { |
||||
|
if (value instanceof String tag && StringUtils.hasText(tag)) { |
||||
|
tags.add(tag.trim()); |
||||
|
} |
||||
|
} |
||||
|
return tags; |
||||
|
} |
||||
|
|
||||
|
private Object parseJson(String raw) { |
||||
|
try { |
||||
|
return OBJECT_MAPPER.readValue(raw, Object.class); |
||||
|
} catch (JsonProcessingException ex) { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private Map<String, ReviewSummaryView.DimensionSummaryView> buildDimensionSummary( |
||||
|
Long companyId) { |
||||
|
Map<String, Object[]> rowsByCode = new HashMap<>(); |
||||
|
List<Object[]> rows = |
||||
|
dimensionScoreRepository.aggregatePublishedDimensionsByCompanyId(companyId); |
||||
|
if (rows != null) { |
||||
|
for (Object[] row : rows) { |
||||
|
if (row != null && row.length >= 3 && row[0] instanceof String code) { |
||||
|
rowsByCode.put(code, row); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
Map<String, ReviewSummaryView.DimensionSummaryView> result = new LinkedHashMap<>(); |
||||
|
for (ReviewDimension dimension : ReviewDimension.values()) { |
||||
|
Object[] row = rowsByCode.get(dimension.name()); |
||||
|
long sampleCount = 0L; |
||||
|
Double averageScore = null; |
||||
|
if (row != null && row[2] instanceof Number count) { |
||||
|
sampleCount = count.longValue(); |
||||
|
} |
||||
|
if (row != null && row[1] instanceof Number average) { |
||||
|
averageScore = BigDecimal.valueOf(average.doubleValue()) |
||||
|
.setScale(1, RoundingMode.HALF_UP) |
||||
|
.doubleValue(); |
||||
|
} |
||||
|
result.put(dimension.name(), ReviewSummaryView.DimensionSummaryView.builder() |
||||
|
.averageScore(averageScore) |
||||
|
.sampleCount(sampleCount) |
||||
|
.dataState(sampleCount >= MINIMUM_PUBLIC_REVIEWS |
||||
|
? SUFFICIENT : INSUFFICIENT) |
||||
|
.build()); |
||||
|
} |
||||
|
return result; |
||||
|
} |
||||
|
|
||||
|
private CompanyKnowledgeView toConfiguredView(CompanyKnowledge knowledge) { |
||||
|
return CompanyKnowledgeView.builder() |
||||
|
.configured(true) |
||||
|
.culture(knowledge.getCulture()) |
||||
|
.workPolicies(knowledge.getWorkPolicies()) |
||||
|
.onboardingGuide(knowledge.getOnboardingGuide()) |
||||
|
.updatedAt(knowledge.getUpdatedAt()) |
||||
|
.version(knowledge.getVersion() == null ? 0L : knowledge.getVersion()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private CompanyKnowledgeOverviewResponse.KnowledgeView toKnowledgeView( |
||||
|
CompanyKnowledge knowledge) { |
||||
|
return CompanyKnowledgeOverviewResponse.KnowledgeView.builder() |
||||
|
.culture(knowledge.getCulture()) |
||||
|
.workPolicies(knowledge.getWorkPolicies()) |
||||
|
.onboardingGuide(knowledge.getOnboardingGuide()) |
||||
|
.updatedAt(knowledge.getUpdatedAt()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private CompanyKnowledgeOverviewResponse.CompanyView toCompanyView(Company company) { |
||||
|
return CompanyKnowledgeOverviewResponse.CompanyView.builder() |
||||
|
.id(company.getId()) |
||||
|
.name(company.getName()) |
||||
|
.logoUrl(company.getLogoUrl()) |
||||
|
.description(company.getDescription()) |
||||
|
.industry(company.getIndustry()) |
||||
|
.website(company.getWebsite()) |
||||
|
.address(company.getAddress()) |
||||
|
.verified(company.getVerified()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private Company requireOwnedCompany(Long userId) { |
||||
|
assertCompanyRole(userId); |
||||
|
return com.mydreamjob.company.VerifiedCompanyGuard.requireVerified( |
||||
|
companyRepository.findByOwnerUserId(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND))); |
||||
|
} |
||||
|
|
||||
|
private void assertCompanyRole(Long userId) { |
||||
|
User user = userRepository.findById(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
if (user.getRoles() == null |
||||
|
|| user.getRoles().stream().noneMatch(role -> role == User.Role.COMPANY)) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void assertStudentRole(Long userId) { |
||||
|
User user = userRepository.findById(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
if (user.getRoles() == null |
||||
|
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private record KnowledgeContent( |
||||
|
String culture, |
||||
|
String workPolicies, |
||||
|
String onboardingGuide) { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,120 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; |
||||
|
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; |
||||
|
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; |
||||
|
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import jakarta.validation.Valid; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
import org.springframework.web.bind.annotation.DeleteMapping; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.PostMapping; |
||||
|
import org.springframework.web.bind.annotation.PathVariable; |
||||
|
import org.springframework.web.bind.annotation.PutMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestBody; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestParam; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
|
||||
|
@Tag(name = "入职清单", description = "学生读取有效入职清单并更新本人完成状态") |
||||
|
@RestController |
||||
|
@RequestMapping("/onboarding") |
||||
|
@RequiredArgsConstructor |
||||
|
public class OnboardingChecklistController { |
||||
|
|
||||
|
private final OnboardingChecklistService onboardingChecklistService; |
||||
|
|
||||
|
@Operation(summary = "读取有效入职清单", description = "返回通用项与企业活动自定义项的合并结果") |
||||
|
@GetMapping("/companies/{companyId}/checklist") |
||||
|
public ResponseEntity<ApiResponse<EffectiveChecklistResponse>> getEffectiveChecklist( |
||||
|
@PathVariable Long companyId, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
EffectiveChecklistResponse data = |
||||
|
onboardingChecklistService.getEffectiveChecklist(userId, companyId); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "更新清单条目完成状态", description = "使用绝对状态语义设置当前学生的完成状态") |
||||
|
@PutMapping("/companies/{companyId}/checklist/items/{itemId}/completion") |
||||
|
public ResponseEntity<ApiResponse<ProgressUpdateResponse>> updateCompletion( |
||||
|
@PathVariable Long companyId, |
||||
|
@PathVariable Long itemId, |
||||
|
@Valid @RequestBody ProgressUpdateRequest body, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
ProgressUpdateResponse data = onboardingChecklistService.updateCompletion( |
||||
|
userId, companyId, itemId, body); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "查询本企业自定义清单项", description = "返回活动与软删除项及当前版本") |
||||
|
@GetMapping("/company/checklist-items") |
||||
|
public ResponseEntity<ApiResponse<List<CompanyChecklistItemView>>> listCompanyChecklistItems( |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
List<CompanyChecklistItemView> data = |
||||
|
onboardingChecklistService.listCompanyChecklistItems(userId); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "创建本企业自定义清单项", description = "支持 idempotencyKey 重放与冲突检测") |
||||
|
@PostMapping("/company/checklist-items") |
||||
|
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> createCompanyChecklistItem( |
||||
|
@Valid @RequestBody CompanyChecklistItemCreateRequest body, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
CompanyChecklistItemView data = |
||||
|
onboardingChecklistService.createCompanyChecklistItem(userId, body); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "更新本企业自定义清单项", description = "使用 version 执行乐观锁控制") |
||||
|
@PutMapping("/company/checklist-items/{itemId}") |
||||
|
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> updateCompanyChecklistItem( |
||||
|
@PathVariable Long itemId, |
||||
|
@Valid @RequestBody CompanyChecklistItemUpdateRequest body, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
CompanyChecklistItemView data = |
||||
|
onboardingChecklistService.updateCompanyChecklistItem(userId, itemId, body); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "软删除本企业自定义清单项", description = "重复删除保持幂等成功") |
||||
|
@DeleteMapping("/company/checklist-items/{itemId}") |
||||
|
public ResponseEntity<ApiResponse<CompanyChecklistItemView>> deleteCompanyChecklistItem( |
||||
|
@PathVariable Long itemId, |
||||
|
@RequestParam Long version, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
CompanyChecklistItemView data = |
||||
|
onboardingChecklistService.deleteCompanyChecklistItem(userId, itemId, version); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
private Long resolveUserId() { |
||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); |
||||
|
if (authentication == null || !authentication.isAuthenticated()) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
return Long.valueOf(authentication.getName()); |
||||
|
} |
||||
|
|
||||
|
private String getRequestId(HttpServletRequest request) { |
||||
|
return request.getHeader("X-Request-Id"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,95 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.EntityListeners; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import jakarta.persistence.UniqueConstraint; |
||||
|
import jakarta.persistence.Version; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import org.springframework.data.annotation.CreatedDate; |
||||
|
import org.springframework.data.annotation.LastModifiedDate; |
||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table( |
||||
|
name = "onboarding_checklist_items", |
||||
|
uniqueConstraints = { |
||||
|
@UniqueConstraint( |
||||
|
name = "uk_onboarding_checklist_scope_item", |
||||
|
columnNames = {"scope_type", "scope_id", "item_key"}), |
||||
|
@UniqueConstraint( |
||||
|
name = "uk_onboarding_checklist_company_idempotency", |
||||
|
columnNames = {"company_id", "idempotency_key"}) |
||||
|
}) |
||||
|
@EntityListeners(AuditingEntityListener.class) |
||||
|
public class OnboardingChecklistItem { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "scope_type", nullable = false, length = 20) |
||||
|
private String scopeType; |
||||
|
|
||||
|
@Column(name = "scope_id", nullable = false) |
||||
|
private Long scopeId; |
||||
|
|
||||
|
@Column(name = "company_id") |
||||
|
private Long companyId; |
||||
|
|
||||
|
@Column(name = "item_key", nullable = false, length = 80) |
||||
|
private String itemKey; |
||||
|
|
||||
|
@Column(nullable = false, length = 120) |
||||
|
private String title; |
||||
|
|
||||
|
@Column(length = 1000) |
||||
|
private String description; |
||||
|
|
||||
|
@Column(nullable = false, length = 30) |
||||
|
private String category; |
||||
|
|
||||
|
@Column(name = "sort_order", nullable = false) |
||||
|
@Builder.Default |
||||
|
private Integer sortOrder = 1000; |
||||
|
|
||||
|
@Column(nullable = false) |
||||
|
@Builder.Default |
||||
|
private Boolean required = false; |
||||
|
|
||||
|
@Column(nullable = false) |
||||
|
@Builder.Default |
||||
|
private Boolean active = true; |
||||
|
|
||||
|
@Column(name = "idempotency_key", length = 64) |
||||
|
private String idempotencyKey; |
||||
|
|
||||
|
@Column(name = "request_hash", columnDefinition = "CHAR(64)") |
||||
|
private String requestHash; |
||||
|
|
||||
|
@Version |
||||
|
@Column(nullable = false) |
||||
|
@Builder.Default |
||||
|
private Long version = 0L; |
||||
|
|
||||
|
@CreatedDate |
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@LastModifiedDate |
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,39 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import jakarta.persistence.LockModeType; |
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
import org.springframework.data.jpa.repository.Lock; |
||||
|
import org.springframework.data.jpa.repository.Query; |
||||
|
import org.springframework.data.repository.query.Param; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Repository |
||||
|
public interface OnboardingChecklistItemRepository extends JpaRepository<OnboardingChecklistItem, Long> { |
||||
|
|
||||
|
@Query(""" |
||||
|
SELECT item |
||||
|
FROM OnboardingChecklistItem item |
||||
|
WHERE item.active = true |
||||
|
AND ( |
||||
|
item.scopeType = 'STANDARD' |
||||
|
OR (item.scopeType = 'COMPANY' |
||||
|
AND item.scopeId = :companyId |
||||
|
AND item.companyId = :companyId) |
||||
|
) |
||||
|
ORDER BY item.sortOrder ASC, item.id ASC |
||||
|
""") |
||||
|
List<OnboardingChecklistItem> findEffectiveChecklistItems(@Param("companyId") Long companyId); |
||||
|
|
||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE) |
||||
|
@Query(""" |
||||
|
SELECT item |
||||
|
FROM OnboardingChecklistItem item |
||||
|
WHERE item.scopeType = 'COMPANY' |
||||
|
AND item.scopeId = :companyId |
||||
|
AND item.companyId = :companyId |
||||
|
ORDER BY item.id ASC |
||||
|
""") |
||||
|
List<OnboardingChecklistItem> findCompanyItemsForUpdate(@Param("companyId") Long companyId); |
||||
|
} |
||||
@ -0,0 +1,561 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import jakarta.persistence.EntityManager; |
||||
|
import jakarta.persistence.LockModeType; |
||||
|
import jakarta.persistence.PersistenceContext; |
||||
|
|
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.company.Company; |
||||
|
import com.mydreamjob.company.CompanyRepository; |
||||
|
import com.mydreamjob.onboarding.dto.ChecklistItemView; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyChecklistItemCreateRequest; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyChecklistItemUpdateRequest; |
||||
|
import com.mydreamjob.onboarding.dto.CompanyChecklistItemView; |
||||
|
import com.mydreamjob.onboarding.dto.EffectiveChecklistResponse; |
||||
|
import com.mydreamjob.onboarding.dto.ProgressUpdateRequest; |
||||
|
import com.mydreamjob.onboarding.dto.ProgressUpdateResponse; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.dao.DataIntegrityViolationException; |
||||
|
import org.springframework.orm.ObjectOptimisticLockingFailureException; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Isolation; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.security.MessageDigest; |
||||
|
import java.security.NoSuchAlgorithmException; |
||||
|
import java.time.LocalDateTime; |
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Comparator; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.HexFormat; |
||||
|
import java.util.Objects; |
||||
|
import java.util.Set; |
||||
|
import java.util.UUID; |
||||
|
import java.util.function.Function; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class OnboardingChecklistService { |
||||
|
|
||||
|
private static final String STANDARD_SOURCE = "STANDARD"; |
||||
|
private static final String COMPANY_SOURCE = "COMPANY"; |
||||
|
private static final int DEFAULT_SORT_ORDER = 1000; |
||||
|
private static final int MAX_ACTIVE_COMPANY_ITEMS = 50; |
||||
|
private static final int MAX_TITLE_LENGTH = 120; |
||||
|
private static final int MAX_DESCRIPTION_LENGTH = 1000; |
||||
|
private static final int MAX_IDEMPOTENCY_KEY_LENGTH = 64; |
||||
|
private static final Set<String> ALLOWED_CATEGORIES = Set.of( |
||||
|
"DOCUMENT", "ADMIN", "EQUIPMENT", "TRAINING", "LOGISTICS", "OTHER"); |
||||
|
|
||||
|
private final UserRepository userRepository; |
||||
|
private final CompanyRepository companyRepository; |
||||
|
private final OnboardingChecklistItemRepository checklistItemRepository; |
||||
|
private final StudentOnboardingProgressRepository progressRepository; |
||||
|
|
||||
|
@PersistenceContext |
||||
|
private EntityManager entityManager; |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public EffectiveChecklistResponse getEffectiveChecklist(Long userId, Long companyId) { |
||||
|
assertStudentRole(userId); |
||||
|
Company company = companyRepository.findById(companyId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
||||
|
|
||||
|
List<OnboardingChecklistItem> items = sortedItems( |
||||
|
checklistItemRepository.findEffectiveChecklistItems(companyId)); |
||||
|
List<StudentOnboardingProgress> progressRows = loadProgress(userId, items); |
||||
|
return toEffectiveChecklistResponse(company, items, progressRows); |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public ProgressUpdateResponse updateCompletion( |
||||
|
Long userId, |
||||
|
Long companyId, |
||||
|
Long itemId, |
||||
|
ProgressUpdateRequest request) { |
||||
|
assertStudentRole(userId); |
||||
|
lockUser(userId); |
||||
|
if (request == null || request.getCompleted() == null) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
|
||||
|
companyRepository.findById(companyId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND)); |
||||
|
|
||||
|
List<OnboardingChecklistItem> items = sortedItems( |
||||
|
checklistItemRepository.findEffectiveChecklistItems(companyId)); |
||||
|
OnboardingChecklistItem item = items.stream() |
||||
|
.filter(candidate -> Objects.equals(candidate.getId(), itemId)) |
||||
|
.findFirst() |
||||
|
.orElseThrow(() -> new BusinessException( |
||||
|
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); |
||||
|
|
||||
|
List<StudentOnboardingProgress> progressRows = |
||||
|
new ArrayList<>(loadProgress(userId, items)); |
||||
|
StudentOnboardingProgress current = |
||||
|
progressRepository.findByStudentUserIdAndItemId(userId, itemId).orElse(null); |
||||
|
boolean desired = Boolean.TRUE.equals(request.getCompleted()); |
||||
|
|
||||
|
if (current == null |
||||
|
&& request.getVersion() != null |
||||
|
&& request.getVersion() != 0L) { |
||||
|
throw new BusinessException( |
||||
|
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE); |
||||
|
} |
||||
|
|
||||
|
if (current != null) { |
||||
|
boolean actual = Boolean.TRUE.equals(current.getCompleted()); |
||||
|
if (actual != desired) { |
||||
|
if (!Objects.equals(request.getVersion(), current.getVersion())) { |
||||
|
throw new BusinessException( |
||||
|
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE); |
||||
|
} |
||||
|
current.setCompleted(desired); |
||||
|
current.setCompletedAt(desired ? LocalDateTime.now() : null); |
||||
|
saveProgress(current); |
||||
|
} |
||||
|
} else if (desired) { |
||||
|
current = StudentOnboardingProgress.builder() |
||||
|
.studentUserId(userId) |
||||
|
.itemId(itemId) |
||||
|
.completed(true) |
||||
|
.completedAt(LocalDateTime.now()) |
||||
|
.build(); |
||||
|
progressRows.add(current); |
||||
|
saveProgress(current); |
||||
|
} |
||||
|
|
||||
|
return toProgressUpdateResponse(item, current, items, progressRows); |
||||
|
} |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public List<CompanyChecklistItemView> listCompanyChecklistItems(Long userId) { |
||||
|
Company company = requireCompanyOwner(userId); |
||||
|
return ownedCompanyItems(checklistItemRepository.findAll(), company.getId()).stream() |
||||
|
.sorted(Comparator |
||||
|
.comparing(OnboardingChecklistItem::getSortOrder, |
||||
|
Comparator.nullsLast(Integer::compareTo)) |
||||
|
.thenComparing(OnboardingChecklistItem::getId, |
||||
|
Comparator.nullsLast(Long::compareTo))) |
||||
|
.map(this::toCompanyChecklistItemView) |
||||
|
.toList(); |
||||
|
} |
||||
|
|
||||
|
@Transactional(isolation = Isolation.READ_COMMITTED) |
||||
|
public CompanyChecklistItemView createCompanyChecklistItem( |
||||
|
Long userId, |
||||
|
CompanyChecklistItemCreateRequest request) { |
||||
|
Company company = requireCompanyOwner(userId); |
||||
|
validateCompanyItemCreateRequest(request); |
||||
|
lockCompany(company); |
||||
|
|
||||
|
String title = normalizeOptional(request.getTitle()); |
||||
|
String description = normalizeOptional(request.getDescription()); |
||||
|
String category = request.getCategory(); |
||||
|
int sortOrder = request.getSortOrder() == null |
||||
|
? DEFAULT_SORT_ORDER : request.getSortOrder(); |
||||
|
boolean required = Boolean.TRUE.equals(request.getRequired()); |
||||
|
String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); |
||||
|
String requestHash = hashCompanyItemRequest( |
||||
|
title, description, category, sortOrder, required); |
||||
|
|
||||
|
List<OnboardingChecklistItem> companyItems = |
||||
|
checklistItemRepository.findCompanyItemsForUpdate(company.getId()); |
||||
|
if (idempotencyKey != null) { |
||||
|
OnboardingChecklistItem existing = companyItems.stream() |
||||
|
.filter(item -> Objects.equals(item.getIdempotencyKey(), idempotencyKey)) |
||||
|
.findFirst() |
||||
|
.orElse(null); |
||||
|
if (existing != null) { |
||||
|
if (Objects.equals(existing.getRequestHash(), requestHash)) { |
||||
|
return toCompanyChecklistItemView(existing); |
||||
|
} |
||||
|
throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
long activeCount = companyItems.stream() |
||||
|
.filter(item -> Boolean.TRUE.equals(item.getActive())) |
||||
|
.count(); |
||||
|
if (activeCount >= MAX_ACTIVE_COMPANY_ITEMS) { |
||||
|
throw new BusinessException(ErrorCode.ONBOARDING_ITEM_LIMIT_EXCEEDED); |
||||
|
} |
||||
|
|
||||
|
OnboardingChecklistItem item = OnboardingChecklistItem.builder() |
||||
|
.scopeType(COMPANY_SOURCE) |
||||
|
.scopeId(company.getId()) |
||||
|
.companyId(company.getId()) |
||||
|
.itemKey("company-" + company.getId() + "-" + UUID.randomUUID()) |
||||
|
.title(title) |
||||
|
.description(description) |
||||
|
.category(category) |
||||
|
.sortOrder(sortOrder) |
||||
|
.required(required) |
||||
|
.active(true) |
||||
|
.idempotencyKey(idempotencyKey) |
||||
|
.requestHash(requestHash) |
||||
|
.build(); |
||||
|
return toCompanyChecklistItemView(saveNewCompanyItem(item)); |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public CompanyChecklistItemView updateCompanyChecklistItem( |
||||
|
Long userId, |
||||
|
Long itemId, |
||||
|
CompanyChecklistItemUpdateRequest request) { |
||||
|
Company company = requireCompanyOwner(userId); |
||||
|
validateCompanyItemUpdateRequest(request); |
||||
|
lockCompany(company); |
||||
|
OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); |
||||
|
if (!Objects.equals(item.getVersion(), request.getVersion())) { |
||||
|
throw new BusinessException( |
||||
|
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE); |
||||
|
} |
||||
|
|
||||
|
item.setTitle(normalizeOptional(request.getTitle())); |
||||
|
item.setDescription(normalizeOptional(request.getDescription())); |
||||
|
item.setCategory(request.getCategory()); |
||||
|
item.setSortOrder(request.getSortOrder()); |
||||
|
item.setRequired(request.getRequired()); |
||||
|
item.setActive(request.getActive()); |
||||
|
return toCompanyChecklistItemView(saveCompanyItem(item)); |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public CompanyChecklistItemView deleteCompanyChecklistItem( |
||||
|
Long userId, |
||||
|
Long itemId, |
||||
|
Long version) { |
||||
|
Company company = requireCompanyOwner(userId); |
||||
|
lockCompany(company); |
||||
|
OnboardingChecklistItem item = requireOwnedCompanyItem(itemId, company.getId()); |
||||
|
if (Boolean.TRUE.equals(item.getActive())) { |
||||
|
if (!Objects.equals(item.getVersion(), version)) { |
||||
|
throw new BusinessException( |
||||
|
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE); |
||||
|
} |
||||
|
item.setActive(false); |
||||
|
item = saveCompanyItem(item); |
||||
|
} |
||||
|
return toCompanyChecklistItemView(item); |
||||
|
} |
||||
|
|
||||
|
private Company requireCompanyOwner(Long userId) { |
||||
|
User user = userRepository.findById(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
boolean isCompany = user.getRoles() != null |
||||
|
&& user.getRoles().stream().anyMatch(role -> role == User.Role.COMPANY); |
||||
|
if (!isCompany) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
return com.mydreamjob.company.VerifiedCompanyGuard.requireVerified( |
||||
|
companyRepository.findByOwnerUserId(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.COMPANY_NOT_FOUND))); |
||||
|
} |
||||
|
|
||||
|
private List<OnboardingChecklistItem> ownedCompanyItems( |
||||
|
List<OnboardingChecklistItem> items, |
||||
|
Long companyId) { |
||||
|
if (items == null || items.isEmpty()) { |
||||
|
return List.of(); |
||||
|
} |
||||
|
return items.stream() |
||||
|
.filter(item -> belongsToCompany(item, companyId)) |
||||
|
.toList(); |
||||
|
} |
||||
|
|
||||
|
private boolean belongsToCompany(OnboardingChecklistItem item, Long companyId) { |
||||
|
return item != null |
||||
|
&& COMPANY_SOURCE.equals(item.getScopeType()) |
||||
|
&& Objects.equals(item.getScopeId(), companyId) |
||||
|
&& Objects.equals(item.getCompanyId(), companyId); |
||||
|
} |
||||
|
|
||||
|
private OnboardingChecklistItem requireOwnedCompanyItem(Long itemId, Long companyId) { |
||||
|
return checklistItemRepository.findById(itemId) |
||||
|
.filter(item -> belongsToCompany(item, companyId)) |
||||
|
.orElseThrow(() -> new BusinessException( |
||||
|
ErrorCode.ONBOARDING_CHECKLIST_ITEM_NOT_FOUND)); |
||||
|
} |
||||
|
|
||||
|
private void validateCompanyItemCreateRequest(CompanyChecklistItemCreateRequest request) { |
||||
|
if (request == null) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
String title = normalizeOptional(request.getTitle()); |
||||
|
if (title == null || title.length() > MAX_TITLE_LENGTH) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
String description = normalizeOptional(request.getDescription()); |
||||
|
if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
if (!ALLOWED_CATEGORIES.contains(request.getCategory())) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
Integer sortOrder = request.getSortOrder(); |
||||
|
if (sortOrder != null && (sortOrder < 0 || sortOrder > 9999)) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); |
||||
|
if (idempotencyKey != null && idempotencyKey.length() > MAX_IDEMPOTENCY_KEY_LENGTH) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void validateCompanyItemUpdateRequest(CompanyChecklistItemUpdateRequest request) { |
||||
|
if (request == null) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
String title = normalizeOptional(request.getTitle()); |
||||
|
if (title == null || title.length() > MAX_TITLE_LENGTH) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
String description = normalizeOptional(request.getDescription()); |
||||
|
if (description != null && description.length() > MAX_DESCRIPTION_LENGTH) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
if (!ALLOWED_CATEGORIES.contains(request.getCategory()) |
||||
|
|| request.getSortOrder() == null |
||||
|
|| request.getSortOrder() < 0 |
||||
|
|| request.getSortOrder() > 9999 |
||||
|
|| request.getRequired() == null |
||||
|
|| request.getActive() == null |
||||
|
|| request.getVersion() == null |
||||
|
|| request.getVersion() < 0) { |
||||
|
throw new BusinessException(ErrorCode.BAD_REQUEST); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String normalizeOptional(String value) { |
||||
|
if (value == null) { |
||||
|
return null; |
||||
|
} |
||||
|
String trimmed = value.trim(); |
||||
|
return trimmed.isEmpty() ? null : trimmed; |
||||
|
} |
||||
|
|
||||
|
private String normalizeIdempotencyKey(String value) { |
||||
|
return normalizeOptional(value); |
||||
|
} |
||||
|
|
||||
|
private String hashCompanyItemRequest( |
||||
|
String title, |
||||
|
String description, |
||||
|
String category, |
||||
|
int sortOrder, |
||||
|
boolean required) { |
||||
|
String canonical = "title=" + title.length() + ":" + title |
||||
|
+ "|description=" + (description == null |
||||
|
? "null" : description.length() + ":" + description) |
||||
|
+ "|category=" + category |
||||
|
+ "|sortOrder=" + sortOrder |
||||
|
+ "|required=" + required; |
||||
|
return sha256(canonical); |
||||
|
} |
||||
|
|
||||
|
private String sha256(String value) { |
||||
|
try { |
||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256"); |
||||
|
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); |
||||
|
} catch (NoSuchAlgorithmException ex) { |
||||
|
throw new IllegalStateException("SHA-256 not available", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private CompanyChecklistItemView toCompanyChecklistItemView(OnboardingChecklistItem item) { |
||||
|
return CompanyChecklistItemView.builder() |
||||
|
.itemId(item.getId()) |
||||
|
.source(COMPANY_SOURCE) |
||||
|
.itemKey(item.getItemKey()) |
||||
|
.title(item.getTitle()) |
||||
|
.description(item.getDescription()) |
||||
|
.category(item.getCategory()) |
||||
|
.required(item.getRequired()) |
||||
|
.sortOrder(item.getSortOrder()) |
||||
|
.active(item.getActive()) |
||||
|
.version(item.getVersion() == null ? 0L : item.getVersion()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private OnboardingChecklistItem saveNewCompanyItem(OnboardingChecklistItem item) { |
||||
|
try { |
||||
|
return checklistItemRepository.saveAndFlush(item); |
||||
|
} catch (DataIntegrityViolationException ex) { |
||||
|
throw new BusinessException(ErrorCode.ONBOARDING_IDEMPOTENCY_CONFLICT, ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void lockCompany(Company company) { |
||||
|
if (entityManager == null || company == null || company.getId() == null) { |
||||
|
return; |
||||
|
} |
||||
|
entityManager.lock(company, LockModeType.PESSIMISTIC_WRITE); |
||||
|
} |
||||
|
|
||||
|
private void lockUser(Long userId) { |
||||
|
if (entityManager == null) { |
||||
|
return; |
||||
|
} |
||||
|
User user = entityManager.find(User.class, userId, LockModeType.PESSIMISTIC_WRITE); |
||||
|
if (user == null) { |
||||
|
throw new BusinessException(ErrorCode.USER_NOT_FOUND); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private OnboardingChecklistItem saveCompanyItem(OnboardingChecklistItem item) { |
||||
|
try { |
||||
|
return checklistItemRepository.saveAndFlush(item); |
||||
|
} catch (ObjectOptimisticLockingFailureException ex) { |
||||
|
throw new BusinessException( |
||||
|
ErrorCode.ONBOARDING_CHECKLIST_ITEM_CONCURRENT_UPDATE, ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void assertStudentRole(Long userId) { |
||||
|
User user = userRepository.findById(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
boolean isStudent = user.getRoles() != null |
||||
|
&& user.getRoles().stream().anyMatch(role -> role == User.Role.STUDENT); |
||||
|
if (!isStudent) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private List<OnboardingChecklistItem> sortedItems(List<OnboardingChecklistItem> items) { |
||||
|
if (items == null || items.isEmpty()) { |
||||
|
return List.of(); |
||||
|
} |
||||
|
List<OnboardingChecklistItem> sorted = new ArrayList<>(items); |
||||
|
sorted.sort(Comparator |
||||
|
.comparing( |
||||
|
OnboardingChecklistItem::getSortOrder, |
||||
|
Comparator.nullsLast(Integer::compareTo)) |
||||
|
.thenComparing( |
||||
|
OnboardingChecklistItem::getId, |
||||
|
Comparator.nullsLast(Long::compareTo))); |
||||
|
return sorted; |
||||
|
} |
||||
|
|
||||
|
private List<StudentOnboardingProgress> loadProgress( |
||||
|
Long userId, List<OnboardingChecklistItem> items) { |
||||
|
if (items.isEmpty()) { |
||||
|
return List.of(); |
||||
|
} |
||||
|
List<Long> itemIds = items.stream() |
||||
|
.map(OnboardingChecklistItem::getId) |
||||
|
.filter(Objects::nonNull) |
||||
|
.toList(); |
||||
|
if (itemIds.isEmpty()) { |
||||
|
return List.of(); |
||||
|
} |
||||
|
return progressRepository.findByStudentUserIdAndItemIdIn(userId, itemIds); |
||||
|
} |
||||
|
|
||||
|
private EffectiveChecklistResponse toEffectiveChecklistResponse( |
||||
|
Company company, |
||||
|
List<OnboardingChecklistItem> items, |
||||
|
List<StudentOnboardingProgress> progressRows) { |
||||
|
Map<Long, StudentOnboardingProgress> progressByItemId = progressRows.stream() |
||||
|
.collect(Collectors.toMap( |
||||
|
StudentOnboardingProgress::getItemId, |
||||
|
Function.identity(), |
||||
|
(first, ignored) -> first)); |
||||
|
|
||||
|
List<ChecklistItemView> views = items.stream() |
||||
|
.map(item -> toChecklistItemView(item, progressByItemId.get(item.getId()))) |
||||
|
.toList(); |
||||
|
|
||||
|
int totalItems = views.size(); |
||||
|
int completedItems = completedCount(items, progressRows); |
||||
|
return EffectiveChecklistResponse.builder() |
||||
|
.companyId(company.getId()) |
||||
|
.companyName(company.getName()) |
||||
|
.customized(items.stream() |
||||
|
.anyMatch(item -> COMPANY_SOURCE.equals(item.getScopeType()))) |
||||
|
.totalItems(totalItems) |
||||
|
.completedItems(completedItems) |
||||
|
.completionRate(completionRate(totalItems, completedItems)) |
||||
|
.items(views) |
||||
|
.build(); |
||||
|
|
||||
|
} |
||||
|
|
||||
|
private ProgressUpdateResponse toProgressUpdateResponse( |
||||
|
OnboardingChecklistItem item, |
||||
|
StudentOnboardingProgress progress, |
||||
|
List<OnboardingChecklistItem> items, |
||||
|
List<StudentOnboardingProgress> progressRows) { |
||||
|
int totalItems = items.size(); |
||||
|
int completedItems = completedCount(items, progressRows); |
||||
|
boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted()); |
||||
|
return ProgressUpdateResponse.builder() |
||||
|
.itemId(item.getId()) |
||||
|
.completed(completed) |
||||
|
.completedAt(completed && progress != null ? progress.getCompletedAt() : null) |
||||
|
.progressVersion(progress == null || progress.getVersion() == null |
||||
|
? 0L : progress.getVersion()) |
||||
|
.totalItems(totalItems) |
||||
|
.completedItems(completedItems) |
||||
|
.completionRate(completionRate(totalItems, completedItems)) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private ChecklistItemView toChecklistItemView( |
||||
|
OnboardingChecklistItem item, |
||||
|
StudentOnboardingProgress progress) { |
||||
|
boolean completed = progress != null && Boolean.TRUE.equals(progress.getCompleted()); |
||||
|
return ChecklistItemView.builder() |
||||
|
.itemId(item.getId()) |
||||
|
.source(STANDARD_SOURCE.equals(item.getScopeType()) |
||||
|
? STANDARD_SOURCE : COMPANY_SOURCE) |
||||
|
.itemKey(item.getItemKey()) |
||||
|
.title(item.getTitle()) |
||||
|
.description(item.getDescription()) |
||||
|
.category(item.getCategory()) |
||||
|
.required(item.getRequired()) |
||||
|
.sortOrder(item.getSortOrder()) |
||||
|
.active(item.getActive()) |
||||
|
.completed(completed) |
||||
|
.completedAt(completed && progress != null ? progress.getCompletedAt() : null) |
||||
|
.progressVersion(progress == null || progress.getVersion() == null |
||||
|
? 0L : progress.getVersion()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private int completedCount( |
||||
|
List<OnboardingChecklistItem> items, |
||||
|
List<StudentOnboardingProgress> progressRows) { |
||||
|
var effectiveItemIds = items.stream() |
||||
|
.map(OnboardingChecklistItem::getId) |
||||
|
.filter(Objects::nonNull) |
||||
|
.collect(Collectors.toSet()); |
||||
|
return (int) progressRows.stream() |
||||
|
.filter(progress -> effectiveItemIds.contains(progress.getItemId())) |
||||
|
.filter(progress -> Boolean.TRUE.equals(progress.getCompleted())) |
||||
|
.count(); |
||||
|
} |
||||
|
|
||||
|
private double completionRate(int totalItems, int completedItems) { |
||||
|
if (totalItems == 0) { |
||||
|
return 0.0; |
||||
|
} |
||||
|
double rawRate = completedItems * 100.0 / totalItems; |
||||
|
return Math.min(100.0, Math.round(rawRate * 10.0) / 10.0); |
||||
|
} |
||||
|
|
||||
|
private void saveProgress(StudentOnboardingProgress progress) { |
||||
|
try { |
||||
|
progressRepository.saveAndFlush(progress); |
||||
|
} catch (ObjectOptimisticLockingFailureException | DataIntegrityViolationException ex) { |
||||
|
throw new BusinessException( |
||||
|
ErrorCode.ONBOARDING_PROGRESS_CONCURRENT_UPDATE, ex); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.EntityListeners; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import jakarta.persistence.UniqueConstraint; |
||||
|
import jakarta.persistence.Version; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import org.springframework.data.annotation.CreatedDate; |
||||
|
import org.springframework.data.annotation.LastModifiedDate; |
||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table( |
||||
|
name = "student_onboarding_progress", |
||||
|
uniqueConstraints = @UniqueConstraint( |
||||
|
name = "uk_student_onboarding_progress_student_item", |
||||
|
columnNames = {"student_user_id", "item_id"})) |
||||
|
@EntityListeners(AuditingEntityListener.class) |
||||
|
public class StudentOnboardingProgress { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "student_user_id", nullable = false) |
||||
|
private Long studentUserId; |
||||
|
|
||||
|
@Column(name = "item_id", nullable = false) |
||||
|
private Long itemId; |
||||
|
|
||||
|
@Column(nullable = false) |
||||
|
@Builder.Default |
||||
|
private Boolean completed = false; |
||||
|
|
||||
|
@Column(name = "completed_at") |
||||
|
private LocalDateTime completedAt; |
||||
|
|
||||
|
@Version |
||||
|
@Column(nullable = false) |
||||
|
@Builder.Default |
||||
|
private Long version = 0L; |
||||
|
|
||||
|
@CreatedDate |
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@LastModifiedDate |
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package com.mydreamjob.onboarding; |
||||
|
|
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
import java.util.Collection; |
||||
|
import java.util.List; |
||||
|
import java.util.Optional; |
||||
|
|
||||
|
@Repository |
||||
|
public interface StudentOnboardingProgressRepository |
||||
|
extends JpaRepository<StudentOnboardingProgress, Long> { |
||||
|
|
||||
|
List<StudentOnboardingProgress> findByStudentUserIdAndItemIdIn( |
||||
|
Long studentUserId, Collection<Long> itemIds); |
||||
|
|
||||
|
Optional<StudentOnboardingProgress> findByStudentUserIdAndItemId( |
||||
|
Long studentUserId, Long itemId); |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class ChecklistItemView { |
||||
|
|
||||
|
private Long itemId; |
||||
|
private String source; |
||||
|
private String itemKey; |
||||
|
private String title; |
||||
|
private String description; |
||||
|
private String category; |
||||
|
private Boolean required; |
||||
|
private Integer sortOrder; |
||||
|
private Boolean active; |
||||
|
private Boolean completed; |
||||
|
private LocalDateTime completedAt; |
||||
|
private Long progressVersion; |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import jakarta.validation.constraints.Max; |
||||
|
import jakarta.validation.constraints.Min; |
||||
|
import jakarta.validation.constraints.NotBlank; |
||||
|
import jakarta.validation.constraints.Pattern; |
||||
|
import jakarta.validation.constraints.Size; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class CompanyChecklistItemCreateRequest { |
||||
|
|
||||
|
@NotBlank(message = "title is required") |
||||
|
@Size(max = 120, message = "title must be at most 120 chars") |
||||
|
private String title; |
||||
|
|
||||
|
@Size(max = 1000, message = "description must be at most 1000 chars") |
||||
|
private String description; |
||||
|
|
||||
|
@NotBlank(message = "category is required") |
||||
|
@Pattern( |
||||
|
regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER", |
||||
|
message = "category is invalid") |
||||
|
private String category; |
||||
|
|
||||
|
@Min(value = 0, message = "sortOrder must not be negative") |
||||
|
@Max(value = 9999, message = "sortOrder must be at most 9999") |
||||
|
private Integer sortOrder = 1000; |
||||
|
|
||||
|
private Boolean required = false; |
||||
|
|
||||
|
@Size(max = 64, message = "idempotencyKey must be at most 64 chars") |
||||
|
private String idempotencyKey; |
||||
|
} |
||||
@ -0,0 +1,41 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import jakarta.validation.constraints.Max; |
||||
|
import jakarta.validation.constraints.Min; |
||||
|
import jakarta.validation.constraints.NotBlank; |
||||
|
import jakarta.validation.constraints.NotNull; |
||||
|
import jakarta.validation.constraints.Pattern; |
||||
|
import jakarta.validation.constraints.Size; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class CompanyChecklistItemUpdateRequest { |
||||
|
|
||||
|
@NotBlank(message = "title is required") |
||||
|
@Size(max = 120, message = "title must be at most 120 chars") |
||||
|
private String title; |
||||
|
|
||||
|
@Size(max = 1000, message = "description must be at most 1000 chars") |
||||
|
private String description; |
||||
|
|
||||
|
@NotBlank(message = "category is required") |
||||
|
@Pattern( |
||||
|
regexp = "DOCUMENT|ADMIN|EQUIPMENT|TRAINING|LOGISTICS|OTHER", |
||||
|
message = "category is invalid") |
||||
|
private String category; |
||||
|
|
||||
|
@NotNull(message = "sortOrder is required") |
||||
|
@Min(value = 0, message = "sortOrder must not be negative") |
||||
|
@Max(value = 9999, message = "sortOrder must be at most 9999") |
||||
|
private Integer sortOrder; |
||||
|
|
||||
|
@NotNull(message = "required is required") |
||||
|
private Boolean required; |
||||
|
|
||||
|
@NotNull(message = "active is required") |
||||
|
private Boolean active; |
||||
|
|
||||
|
@NotNull(message = "version is required") |
||||
|
@Min(value = 0, message = "version must not be negative") |
||||
|
private Long version; |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class CompanyChecklistItemView { |
||||
|
|
||||
|
private Long itemId; |
||||
|
private String source; |
||||
|
private String itemKey; |
||||
|
private String title; |
||||
|
private String description; |
||||
|
private String category; |
||||
|
private Boolean required; |
||||
|
private Integer sortOrder; |
||||
|
private Boolean active; |
||||
|
private Long version; |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonInclude; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public class CompanyKnowledgeOverviewResponse { |
||||
|
|
||||
|
private CompanyView company; |
||||
|
private String knowledgeState; |
||||
|
private KnowledgeView knowledge; |
||||
|
private ReviewSummaryView reviewSummary; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class CompanyView { |
||||
|
|
||||
|
private Long id; |
||||
|
private String name; |
||||
|
private String logoUrl; |
||||
|
private String description; |
||||
|
private String industry; |
||||
|
private String website; |
||||
|
private String address; |
||||
|
private Boolean verified; |
||||
|
} |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class KnowledgeView { |
||||
|
|
||||
|
private String culture; |
||||
|
private String workPolicies; |
||||
|
private String onboardingGuide; |
||||
|
private LocalDateTime updatedAt; |
||||
|
private Long version; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import jakarta.validation.constraints.Min; |
||||
|
import jakarta.validation.constraints.Size; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class CompanyKnowledgeUpdateRequest { |
||||
|
|
||||
|
@Size(max = 2000, message = "culture must be at most 2000 chars") |
||||
|
private String culture; |
||||
|
|
||||
|
@Size(max = 4000, message = "workPolicies must be at most 4000 chars") |
||||
|
private String workPolicies; |
||||
|
|
||||
|
@Size(max = 4000, message = "onboardingGuide must be at most 4000 chars") |
||||
|
private String onboardingGuide; |
||||
|
|
||||
|
@Min(value = 0, message = "version must not be negative") |
||||
|
private Long version; |
||||
|
} |
||||
@ -0,0 +1,24 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonInclude; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public class CompanyKnowledgeView { |
||||
|
|
||||
|
private Boolean configured; |
||||
|
private String culture; |
||||
|
private String workPolicies; |
||||
|
private String onboardingGuide; |
||||
|
private LocalDateTime updatedAt; |
||||
|
private Long version; |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class EffectiveChecklistResponse { |
||||
|
|
||||
|
private Long companyId; |
||||
|
private String companyName; |
||||
|
private Boolean customized; |
||||
|
private Integer totalItems; |
||||
|
private Integer completedItems; |
||||
|
private Double completionRate; |
||||
|
private List<ChecklistItemView> items; |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import jakarta.validation.constraints.Min; |
||||
|
import jakarta.validation.constraints.NotNull; |
||||
|
import lombok.Data; |
||||
|
|
||||
|
@Data |
||||
|
public class ProgressUpdateRequest { |
||||
|
|
||||
|
@NotNull(message = "completed is required") |
||||
|
private Boolean completed; |
||||
|
|
||||
|
@Min(value = 0, message = "version must not be negative") |
||||
|
private Long version; |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class ProgressUpdateResponse { |
||||
|
|
||||
|
private Long itemId; |
||||
|
private Boolean completed; |
||||
|
private LocalDateTime completedAt; |
||||
|
private Long progressVersion; |
||||
|
private Integer totalItems; |
||||
|
private Integer completedItems; |
||||
|
private Double completionRate; |
||||
|
} |
||||
@ -0,0 +1,37 @@ |
|||||
|
package com.mydreamjob.onboarding.dto; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonInclude; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public class ReviewSummaryView { |
||||
|
|
||||
|
private String state; |
||||
|
private int totalReviews; |
||||
|
private Double averageRating; |
||||
|
private Map<String, Integer> ratingDistribution; |
||||
|
private List<String> topTags; |
||||
|
private Map<String, DimensionSummaryView> dimensionSummary; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@JsonInclude(JsonInclude.Include.NON_NULL) |
||||
|
public static class DimensionSummaryView { |
||||
|
|
||||
|
private Double averageScore; |
||||
|
private long sampleCount; |
||||
|
private String dataState; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,91 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.api.PageResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import jakarta.validation.Valid; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
import org.springframework.web.bind.annotation.DeleteMapping; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.PathVariable; |
||||
|
import org.springframework.web.bind.annotation.PostMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestBody; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestParam; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") |
||||
|
@RestController |
||||
|
@RequestMapping("/salary-calculator") |
||||
|
@RequiredArgsConstructor |
||||
|
public class SalaryCalculationController { |
||||
|
|
||||
|
private final SalaryCalculationService calculationService; |
||||
|
private final SalaryHistoryService historyService; |
||||
|
|
||||
|
@Operation(summary = "计算并保存到手工资", description = "仅 STUDENT;校验政策版本,支持幂等键") |
||||
|
@PostMapping("/calculations") |
||||
|
public ResponseEntity<ApiResponse<SalaryCalculationResponse>> calculate( |
||||
|
@Valid @RequestBody SalaryCalculationRequest body, HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
return ResponseEntity.ok(ApiResponse.ok( |
||||
|
calculationService.calculateAndSave(userId, body), getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "查询本人薪资历史", description = "仅 STUDENT;按创建时间倒序分页") |
||||
|
@GetMapping("/calculations") |
||||
|
public ResponseEntity<ApiResponse<PageResponse<SalaryHistorySummaryResponse>>> list( |
||||
|
@RequestParam(defaultValue = "0") int page, |
||||
|
@RequestParam(defaultValue = "20") int size, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
return ResponseEntity.ok(ApiResponse.ok( |
||||
|
historyService.list(userId, page, size), getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "查询本人薪资历史详情", description = "仅 STUDENT;返回完整快照") |
||||
|
@GetMapping("/calculations/{id}") |
||||
|
public ResponseEntity<ApiResponse<SalaryHistoryDetailResponse>> get( |
||||
|
@PathVariable Long id, HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
return ResponseEntity.ok(ApiResponse.ok( |
||||
|
historyService.get(userId, id), getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "删除本人薪资历史", description = "仅 STUDENT;物理删除") |
||||
|
@DeleteMapping("/calculations/{id}") |
||||
|
public ResponseEntity<ApiResponse<Void>> delete( |
||||
|
@PathVariable Long id, HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
historyService.delete(userId, id); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(null, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
private Long resolveUserId() { |
||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
||||
|
if (auth == null || !auth.isAuthenticated() |
||||
|
|| "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
try { |
||||
|
return Long.valueOf(auth.getName()); |
||||
|
} catch (NumberFormatException ex) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String getRequestId(HttpServletRequest request) { |
||||
|
return request.getHeader("X-Request-Id"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,262 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.math.RoundingMode; |
||||
|
import java.util.List; |
||||
|
|
||||
|
@Component |
||||
|
public class SalaryCalculationEngine { |
||||
|
|
||||
|
public static final String TAX_RULES_VERSION = "CN_IIT_2019_V1"; |
||||
|
|
||||
|
private static final BigDecimal ZERO = new BigDecimal("0"); |
||||
|
private static final BigDecimal MONTHLY_BASIC_DEDUCTION = new BigDecimal("5000"); |
||||
|
private static final BigDecimal MAX_INPUT_AMOUNT = new BigDecimal("10000000"); |
||||
|
private static final BigDecimal MAX_ILLNESS_FEE = new BigDecimal("10000"); |
||||
|
private static final BigDecimal MAX_PENSION_RATE = new BigDecimal("0.20"); |
||||
|
private static final BigDecimal MAX_MEDICAL_RATE = new BigDecimal("0.20"); |
||||
|
private static final BigDecimal MAX_UNEMPLOYMENT_RATE = new BigDecimal("0.10"); |
||||
|
private static final BigDecimal MAX_HOUSING_FUND_RATE = new BigDecimal("0.12"); |
||||
|
private static final int MONEY_SCALE = 2; |
||||
|
|
||||
|
private static final List<TaxBracket> ANNUAL_BRACKETS = List.of( |
||||
|
new TaxBracket(new BigDecimal("36000"), new BigDecimal("0.03"), new BigDecimal("0")), |
||||
|
new TaxBracket(new BigDecimal("144000"), new BigDecimal("0.10"), new BigDecimal("2520")), |
||||
|
new TaxBracket(new BigDecimal("300000"), new BigDecimal("0.20"), new BigDecimal("16920")), |
||||
|
new TaxBracket(new BigDecimal("420000"), new BigDecimal("0.25"), new BigDecimal("31920")), |
||||
|
new TaxBracket(new BigDecimal("660000"), new BigDecimal("0.30"), new BigDecimal("52920")), |
||||
|
new TaxBracket(new BigDecimal("960000"), new BigDecimal("0.35"), new BigDecimal("85920")), |
||||
|
new TaxBracket(null, new BigDecimal("0.45"), new BigDecimal("181920"))); |
||||
|
|
||||
|
public SalaryCalculationResult calculate(SalaryCalculationInput input, SalaryCityPolicy policy) { |
||||
|
if (input == null || policy == null) { |
||||
|
throw invalidParameter("input and city policy are required"); |
||||
|
} |
||||
|
int monthIndex = input.getMonthIndex() == null ? 1 : input.getMonthIndex(); |
||||
|
BigDecimal gross = requiredMoney(input.getGrossMonthlySalary(), "grossMonthlySalary"); |
||||
|
BigDecimal specialDeduction = optionalMoney( |
||||
|
input.getSpecialAdditionalDeduction(), "specialAdditionalDeduction", MAX_INPUT_AMOUNT); |
||||
|
SalaryOverrideParameters overrides = input.getOverrides(); |
||||
|
|
||||
|
if (gross.compareTo(ZERO) <= 0 || gross.compareTo(MAX_INPUT_AMOUNT) > 0) { |
||||
|
throw invalidParameter("grossMonthlySalary must be within (0, 10000000]"); |
||||
|
} |
||||
|
if (monthIndex < 1 || monthIndex > 12) { |
||||
|
throw invalidParameter("monthIndex must be between 1 and 12"); |
||||
|
} |
||||
|
|
||||
|
BigDecimal pensionRate = effectiveRate( |
||||
|
overrides == null ? null : overrides.getPensionRate(), |
||||
|
policy.getPensionRate(), MAX_PENSION_RATE, "pensionRate"); |
||||
|
BigDecimal medicalRate = effectiveRate( |
||||
|
overrides == null ? null : overrides.getMedicalRate(), |
||||
|
policy.getMedicalRate(), MAX_MEDICAL_RATE, "medicalRate"); |
||||
|
BigDecimal unemploymentRate = effectiveRate( |
||||
|
overrides == null ? null : overrides.getUnemploymentRate(), |
||||
|
policy.getUnemploymentRate(), MAX_UNEMPLOYMENT_RATE, "unemploymentRate"); |
||||
|
BigDecimal housingFundRate = effectiveRate( |
||||
|
overrides == null ? null : overrides.getHousingFundRate(), |
||||
|
policy.getHousingFundRate(), MAX_HOUSING_FUND_RATE, "housingFundRate"); |
||||
|
BigDecimal illnessFee = effectiveFee( |
||||
|
overrides == null ? null : overrides.getSeriousIllnessMonthlyFee(), |
||||
|
policy.getSeriousIllnessMonthlyFee()); |
||||
|
|
||||
|
BigDecimal socialSecurityBase = effectiveBase( |
||||
|
overrides == null ? null : overrides.getSocialSecurityBase(), |
||||
|
gross, |
||||
|
policy.getSocialSecurityBaseMin(), |
||||
|
policy.getSocialSecurityBaseMax(), |
||||
|
"socialSecurityBase"); |
||||
|
BigDecimal housingFundBase = effectiveBase( |
||||
|
overrides == null ? null : overrides.getHousingFundBase(), |
||||
|
gross, |
||||
|
policy.getHousingFundBaseMin(), |
||||
|
policy.getHousingFundBaseMax(), |
||||
|
"housingFundBase"); |
||||
|
|
||||
|
if (socialSecurityBase.compareTo(ZERO) == 0 |
||||
|
&& (pensionRate.signum() != 0 || medicalRate.signum() != 0 |
||||
|
|| unemploymentRate.signum() != 0)) { |
||||
|
throw invalidParameter("socialSecurityBase may be zero only when social insurance rates are zero"); |
||||
|
} |
||||
|
if (housingFundBase.compareTo(ZERO) == 0 && housingFundRate.signum() != 0) { |
||||
|
throw invalidParameter("housingFundBase may be zero only when housingFundRate is zero"); |
||||
|
} |
||||
|
|
||||
|
BigDecimal pension = socialSecurityBase.multiply(pensionRate); |
||||
|
BigDecimal medical = socialSecurityBase.multiply(medicalRate); |
||||
|
BigDecimal unemployment = socialSecurityBase.multiply(unemploymentRate); |
||||
|
BigDecimal housingFund = housingFundBase.multiply(housingFundRate); |
||||
|
BigDecimal monthlySocialInsurance = pension.add(medical).add(unemployment); |
||||
|
|
||||
|
BigDecimal cumulativeGross = gross.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeSocialInsurance = monthlySocialInsurance.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeHousingFund = housingFund.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeIllness = illnessFee.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeBasicDeduction = MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
BigDecimal cumulativeSpecialDeduction = specialDeduction.multiply(BigDecimal.valueOf(monthIndex)); |
||||
|
|
||||
|
BigDecimal cumulativeTaxable = nonNegative(cumulativeGross |
||||
|
.subtract(cumulativeSocialInsurance) |
||||
|
.subtract(cumulativeHousingFund) |
||||
|
.subtract(cumulativeIllness) |
||||
|
.subtract(cumulativeBasicDeduction) |
||||
|
.subtract(cumulativeSpecialDeduction)); |
||||
|
BigDecimal cumulativeTaxDue = taxFor(cumulativeTaxable); |
||||
|
|
||||
|
int priorMonths = monthIndex - 1; |
||||
|
BigDecimal priorTaxable = nonNegative(gross.multiply(BigDecimal.valueOf(priorMonths)) |
||||
|
.subtract(monthlySocialInsurance.multiply(BigDecimal.valueOf(priorMonths))) |
||||
|
.subtract(housingFund.multiply(BigDecimal.valueOf(priorMonths))) |
||||
|
.subtract(illnessFee.multiply(BigDecimal.valueOf(priorMonths))) |
||||
|
.subtract(MONTHLY_BASIC_DEDUCTION.multiply(BigDecimal.valueOf(priorMonths))) |
||||
|
.subtract(specialDeduction.multiply(BigDecimal.valueOf(priorMonths)))); |
||||
|
BigDecimal priorTax = taxFor(priorTaxable); |
||||
|
BigDecimal currentTax = nonNegative(cumulativeTaxDue.subtract(priorTax)); |
||||
|
BigDecimal netSalary = nonNegative(gross |
||||
|
.subtract(pension) |
||||
|
.subtract(medical) |
||||
|
.subtract(unemployment) |
||||
|
.subtract(housingFund) |
||||
|
.subtract(illnessFee) |
||||
|
.subtract(currentTax)); |
||||
|
|
||||
|
return SalaryCalculationResult.builder() |
||||
|
.cityCode(policy.getCityCode()) |
||||
|
.cityName(policy.getCityName()) |
||||
|
.dataLevel(policy.getDataLevel()) |
||||
|
.policyVersion(policy.getPolicyVersion()) |
||||
|
.policyEffectiveDate(policy.getPolicyEffectiveDate()) |
||||
|
.taxRulesVersion(TAX_RULES_VERSION) |
||||
|
.estimate(true) |
||||
|
.monthIndex(monthIndex) |
||||
|
.grossMonthlySalary(money(gross)) |
||||
|
.specialAdditionalDeduction(money(specialDeduction)) |
||||
|
.socialSecurityBase(money(socialSecurityBase)) |
||||
|
.housingFundBase(money(housingFundBase)) |
||||
|
.pensionRate(rate(pensionRate)) |
||||
|
.medicalRate(rate(medicalRate)) |
||||
|
.unemploymentRate(rate(unemploymentRate)) |
||||
|
.housingFundRate(rate(housingFundRate)) |
||||
|
.seriousIllnessMonthlyFee(money(illnessFee)) |
||||
|
.pension(money(pension)) |
||||
|
.medical(money(medical)) |
||||
|
.unemployment(money(unemployment)) |
||||
|
.housingFund(money(housingFund)) |
||||
|
.cumulativeGrossIncome(money(cumulativeGross)) |
||||
|
.cumulativeSocialInsurance(money(cumulativeSocialInsurance)) |
||||
|
.cumulativeHousingFund(money(cumulativeHousingFund)) |
||||
|
.cumulativeSeriousIllness(money(cumulativeIllness)) |
||||
|
.cumulativeBasicDeduction(money(cumulativeBasicDeduction)) |
||||
|
.cumulativeSpecialAdditionalDeduction(money(cumulativeSpecialDeduction)) |
||||
|
.cumulativeTaxableIncome(money(cumulativeTaxable)) |
||||
|
.cumulativeTaxDue(money(cumulativeTaxDue)) |
||||
|
.previouslyWithheldTax(money(priorTax)) |
||||
|
.currentMonthTax(money(currentTax)) |
||||
|
.netSalary(money(netSalary)) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal effectiveBase( |
||||
|
BigDecimal override, BigDecimal gross, BigDecimal min, BigDecimal max, String field) { |
||||
|
validatePolicyRange(min, max, field); |
||||
|
if (override != null) { |
||||
|
BigDecimal value = requiredMoney(override, field); |
||||
|
if (value.compareTo(ZERO) < 0) { |
||||
|
throw invalidParameter(field + " must not be negative"); |
||||
|
} |
||||
|
if (value.compareTo(ZERO) > 0 && (value.compareTo(min) < 0 || value.compareTo(max) > 0)) { |
||||
|
throw invalidParameter(field + " must be within city range or zero for an exempt contribution"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
return clamp(gross, min, max); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal effectiveRate( |
||||
|
BigDecimal override, BigDecimal fallback, BigDecimal max, String field) { |
||||
|
BigDecimal value = override == null ? fallback : requiredRate(override, field); |
||||
|
if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(max) > 0) { |
||||
|
throw invalidParameter(field + " is outside the allowed range"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal effectiveFee(BigDecimal override, BigDecimal fallback) { |
||||
|
BigDecimal value = override == null ? fallback : requiredMoney(override, "seriousIllnessMonthlyFee"); |
||||
|
if (value == null || value.compareTo(ZERO) < 0 || value.compareTo(MAX_ILLNESS_FEE) > 0) { |
||||
|
throw invalidParameter("seriousIllnessMonthlyFee is outside the allowed range"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal taxFor(BigDecimal taxableIncome) { |
||||
|
if (taxableIncome.compareTo(ZERO) <= 0) { |
||||
|
return ZERO; |
||||
|
} |
||||
|
for (TaxBracket bracket : ANNUAL_BRACKETS) { |
||||
|
if (bracket.upperBound == null || taxableIncome.compareTo(bracket.upperBound) <= 0) { |
||||
|
return nonNegative(taxableIncome.multiply(bracket.rate).subtract(bracket.quickDeduction)); |
||||
|
} |
||||
|
} |
||||
|
return ZERO; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal clamp(BigDecimal value, BigDecimal min, BigDecimal max) { |
||||
|
if (value.compareTo(min) < 0) { |
||||
|
return min; |
||||
|
} |
||||
|
return value.compareTo(max) > 0 ? max : value; |
||||
|
} |
||||
|
|
||||
|
private void validatePolicyRange(BigDecimal min, BigDecimal max, String field) { |
||||
|
if (min == null || max == null || min.compareTo(ZERO) < 0 || max.compareTo(min) < 0) { |
||||
|
throw invalidParameter(field + " city range is invalid"); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private BigDecimal requiredMoney(BigDecimal value, String field) { |
||||
|
if (value == null || value.scale() > MONEY_SCALE) { |
||||
|
throw invalidParameter(field + " must have at most two decimal places"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal optionalMoney(BigDecimal value, String field, BigDecimal max) { |
||||
|
BigDecimal effective = value == null ? ZERO : requiredMoney(value, field); |
||||
|
if (effective.compareTo(ZERO) < 0 || effective.compareTo(max) > 0) { |
||||
|
throw invalidParameter(field + " is outside the allowed range"); |
||||
|
} |
||||
|
return effective; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal requiredRate(BigDecimal value, String field) { |
||||
|
if (value == null || value.scale() > 6) { |
||||
|
throw invalidParameter(field + " must have at most six decimal places"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal nonNegative(BigDecimal value) { |
||||
|
return value.compareTo(ZERO) < 0 ? ZERO : value; |
||||
|
} |
||||
|
|
||||
|
private BigDecimal money(BigDecimal value) { |
||||
|
return value.setScale(MONEY_SCALE, RoundingMode.HALF_UP); |
||||
|
} |
||||
|
|
||||
|
private BigDecimal rate(BigDecimal value) { |
||||
|
return value.setScale(6, RoundingMode.HALF_UP); |
||||
|
} |
||||
|
|
||||
|
private BusinessException invalidParameter(String detail) { |
||||
|
return new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, detail); |
||||
|
} |
||||
|
|
||||
|
private record TaxBracket(BigDecimal upperBound, BigDecimal rate, BigDecimal quickDeduction) { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,86 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.EnumType; |
||||
|
import jakarta.persistence.Enumerated; |
||||
|
import jakarta.persistence.EntityListeners; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import jakarta.persistence.UniqueConstraint; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import org.springframework.data.annotation.CreatedDate; |
||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table( |
||||
|
name = "salary_calculation_history", |
||||
|
uniqueConstraints = @UniqueConstraint( |
||||
|
name = "uk_sch_student_idempotency", |
||||
|
columnNames = {"student_user_id", "idempotency_key"})) |
||||
|
@EntityListeners(AuditingEntityListener.class) |
||||
|
public class SalaryCalculationHistory { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "student_user_id", nullable = false) |
||||
|
private Long studentUserId; |
||||
|
|
||||
|
@Column(name = "city_code", nullable = false, length = 6) |
||||
|
private String cityCode; |
||||
|
|
||||
|
@Column(name = "city_name", nullable = false, length = 80) |
||||
|
private String cityName; |
||||
|
|
||||
|
@Enumerated(EnumType.STRING) |
||||
|
@Column(name = "data_level", nullable = false, length = 30) |
||||
|
private SalaryDataLevel dataLevel; |
||||
|
|
||||
|
@Column(name = "policy_version", nullable = false, length = 64) |
||||
|
private String policyVersion; |
||||
|
|
||||
|
@Column(name = "tax_rules_version", nullable = false, length = 64) |
||||
|
private String taxRulesVersion; |
||||
|
|
||||
|
@Column(name = "month_index", nullable = false) |
||||
|
private Integer monthIndex; |
||||
|
|
||||
|
@Column(name = "gross_monthly_salary", nullable = false, precision = 12, scale = 2) |
||||
|
private BigDecimal grossMonthlySalary; |
||||
|
|
||||
|
@Column(name = "net_salary", nullable = false, precision = 12, scale = 2) |
||||
|
private BigDecimal netSalary; |
||||
|
|
||||
|
@Column(name = "idempotency_key", length = 64) |
||||
|
private String idempotencyKey; |
||||
|
|
||||
|
@Column(name = "request_hash", columnDefinition = "CHAR(64)") |
||||
|
private String requestHash; |
||||
|
|
||||
|
@Column(name = "input_snapshot_json", nullable = false, columnDefinition = "TEXT") |
||||
|
private String inputSnapshotJson; |
||||
|
|
||||
|
@Column(name = "policy_snapshot_json", nullable = false, columnDefinition = "TEXT") |
||||
|
private String policySnapshotJson; |
||||
|
|
||||
|
@Column(name = "result_snapshot_json", nullable = false, columnDefinition = "TEXT") |
||||
|
private String resultSnapshotJson; |
||||
|
|
||||
|
@CreatedDate |
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import org.springframework.data.domain.Page; |
||||
|
import org.springframework.data.domain.Pageable; |
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
import java.util.Optional; |
||||
|
|
||||
|
@Repository |
||||
|
public interface SalaryCalculationHistoryRepository |
||||
|
extends JpaRepository<SalaryCalculationHistory, Long> { |
||||
|
|
||||
|
Page<SalaryCalculationHistory> findByStudentUserIdOrderByCreatedAtDescIdDesc( |
||||
|
Long studentUserId, Pageable pageable); |
||||
|
|
||||
|
Optional<SalaryCalculationHistory> findByIdAndStudentUserId(Long id, Long studentUserId); |
||||
|
|
||||
|
Optional<SalaryCalculationHistory> findByStudentUserIdAndIdempotencyKey( |
||||
|
Long studentUserId, String idempotencyKey); |
||||
|
|
||||
|
long deleteByStudentUserId(Long studentUserId); |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCalculationInput { |
||||
|
private BigDecimal grossMonthlySalary; |
||||
|
private Integer monthIndex; |
||||
|
private BigDecimal specialAdditionalDeduction; |
||||
|
private SalaryOverrideParameters overrides; |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCalculationResult { |
||||
|
|
||||
|
private String cityCode; |
||||
|
private String cityName; |
||||
|
private SalaryDataLevel dataLevel; |
||||
|
private String policyVersion; |
||||
|
private LocalDate policyEffectiveDate; |
||||
|
private String taxRulesVersion; |
||||
|
private boolean estimate; |
||||
|
|
||||
|
private Integer monthIndex; |
||||
|
private BigDecimal grossMonthlySalary; |
||||
|
private BigDecimal specialAdditionalDeduction; |
||||
|
|
||||
|
private BigDecimal socialSecurityBase; |
||||
|
private BigDecimal housingFundBase; |
||||
|
private BigDecimal pensionRate; |
||||
|
private BigDecimal medicalRate; |
||||
|
private BigDecimal unemploymentRate; |
||||
|
private BigDecimal housingFundRate; |
||||
|
private BigDecimal seriousIllnessMonthlyFee; |
||||
|
|
||||
|
private BigDecimal pension; |
||||
|
private BigDecimal medical; |
||||
|
private BigDecimal unemployment; |
||||
|
private BigDecimal housingFund; |
||||
|
|
||||
|
private BigDecimal cumulativeGrossIncome; |
||||
|
private BigDecimal cumulativeSocialInsurance; |
||||
|
private BigDecimal cumulativeHousingFund; |
||||
|
private BigDecimal cumulativeSeriousIllness; |
||||
|
private BigDecimal cumulativeBasicDeduction; |
||||
|
private BigDecimal cumulativeSpecialAdditionalDeduction; |
||||
|
private BigDecimal cumulativeTaxableIncome; |
||||
|
private BigDecimal cumulativeTaxDue; |
||||
|
private BigDecimal previouslyWithheldTax; |
||||
|
private BigDecimal currentMonthTax; |
||||
|
private BigDecimal netSalary; |
||||
|
} |
||||
@ -0,0 +1,181 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.JsonProcessingException; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCalculationResponse; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Isolation; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.LinkedHashMap; |
||||
|
import java.util.Map; |
||||
|
import java.util.Objects; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class SalaryCalculationService { |
||||
|
|
||||
|
private final SalaryCityPolicyRepository cityPolicyRepository; |
||||
|
private final SalaryCalculationHistoryRepository historyRepository; |
||||
|
private final UserRepository userRepository; |
||||
|
private final SalaryCalculationEngine calculationEngine; |
||||
|
private final SalaryRequestFingerprint requestFingerprint; |
||||
|
private final ObjectMapper objectMapper; |
||||
|
|
||||
|
@Transactional(isolation = Isolation.READ_COMMITTED) |
||||
|
public SalaryCalculationResponse calculateAndSave(Long userId, SalaryCalculationRequest request) { |
||||
|
String idempotencyKey = normalizeIdempotencyKey(request.getIdempotencyKey()); |
||||
|
String requestHash = idempotencyKey == null ? null : requestFingerprint.fingerprint(request); |
||||
|
// 在读取幂等记录之前锁定用户,跨实例串行化同一用户的写入;锁随事务结束释放。
|
||||
|
requireStudent(userId); |
||||
|
if (idempotencyKey != null) { |
||||
|
var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey); |
||||
|
if (existing.isPresent()) { |
||||
|
return replay(existing.get(), requestHash); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode()) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); |
||||
|
if (!Objects.equals(policy.getPolicyVersion(), request.getExpectedPolicyVersion())) { |
||||
|
throw new SalaryPolicyVersionConflictException( |
||||
|
policy.getPolicyVersion(), policy.getPolicyEffectiveDate()); |
||||
|
} |
||||
|
|
||||
|
SalaryCalculationResult result = calculationEngine.calculate(toEngineInput(request), policy); |
||||
|
SalaryCalculationHistory history = SalaryCalculationHistory.builder() |
||||
|
.studentUserId(userId) |
||||
|
.cityCode(policy.getCityCode()) |
||||
|
.cityName(policy.getCityName()) |
||||
|
.dataLevel(policy.getDataLevel()) |
||||
|
.policyVersion(policy.getPolicyVersion()) |
||||
|
.taxRulesVersion(result.getTaxRulesVersion()) |
||||
|
.monthIndex(result.getMonthIndex()) |
||||
|
.grossMonthlySalary(result.getGrossMonthlySalary()) |
||||
|
.netSalary(result.getNetSalary()) |
||||
|
.idempotencyKey(idempotencyKey) |
||||
|
.requestHash(requestHash) |
||||
|
.inputSnapshotJson(toJson(inputSnapshot(request))) |
||||
|
.policySnapshotJson(toJson(policySnapshot(policy, result))) |
||||
|
.resultSnapshotJson(toJson(result)) |
||||
|
.build(); |
||||
|
|
||||
|
history = historyRepository.saveAndFlush(history); |
||||
|
return toResponse(history); |
||||
|
} |
||||
|
|
||||
|
private SalaryCalculationResponse replay(SalaryCalculationHistory history, String requestHash) { |
||||
|
if (!Objects.equals(history.getRequestHash(), requestHash)) { |
||||
|
throw new BusinessException(ErrorCode.SALARY_IDEMPOTENCY_CONFLICT); |
||||
|
} |
||||
|
return toResponse(history); |
||||
|
} |
||||
|
|
||||
|
private SalaryCalculationResponse toResponse(SalaryCalculationHistory history) { |
||||
|
try { |
||||
|
SalaryCalculationResult result = objectMapper.readValue( |
||||
|
history.getResultSnapshotJson(), SalaryCalculationResult.class); |
||||
|
return SalaryCalculationResponse.builder() |
||||
|
.historyId(history.getId()) |
||||
|
.createdAt(history.getCreatedAt()) |
||||
|
.result(result) |
||||
|
.build(); |
||||
|
} catch (JsonProcessingException ex) { |
||||
|
throw new IllegalStateException("stored salary result snapshot is invalid", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private SalaryCalculationInput toEngineInput(SalaryCalculationRequest request) { |
||||
|
SalaryCalculationOverrideRequest source = request.getOverrides(); |
||||
|
SalaryOverrideParameters overrides = source == null ? null : SalaryOverrideParameters.builder() |
||||
|
.socialSecurityBase(source.getSocialSecurityBase()) |
||||
|
.housingFundBase(source.getHousingFundBase()) |
||||
|
.pensionRate(source.getPensionRate()) |
||||
|
.medicalRate(source.getMedicalRate()) |
||||
|
.unemploymentRate(source.getUnemploymentRate()) |
||||
|
.housingFundRate(source.getHousingFundRate()) |
||||
|
.seriousIllnessMonthlyFee(source.getSeriousIllnessMonthlyFee()) |
||||
|
.build(); |
||||
|
return SalaryCalculationInput.builder() |
||||
|
.grossMonthlySalary(request.getGrossMonthlySalary()) |
||||
|
.monthIndex(request.getMonthIndex()) |
||||
|
.specialAdditionalDeduction(request.getSpecialAdditionalDeduction()) |
||||
|
.overrides(overrides) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private Map<String, Object> inputSnapshot(SalaryCalculationRequest request) { |
||||
|
Map<String, Object> input = new LinkedHashMap<>(); |
||||
|
input.put("cityCode", request.getCityCode()); |
||||
|
input.put("grossMonthlySalary", request.getGrossMonthlySalary()); |
||||
|
input.put("monthIndex", request.getMonthIndex() == null ? 1 : request.getMonthIndex()); |
||||
|
input.put("specialAdditionalDeduction", request.getSpecialAdditionalDeduction()); |
||||
|
input.put("expectedPolicyVersion", request.getExpectedPolicyVersion()); |
||||
|
input.put("overrides", request.getOverrides()); |
||||
|
return input; |
||||
|
} |
||||
|
|
||||
|
private Map<String, Object> policySnapshot( |
||||
|
SalaryCityPolicy policy, SalaryCalculationResult result) { |
||||
|
Map<String, Object> snapshot = new LinkedHashMap<>(); |
||||
|
snapshot.put("cityCode", policy.getCityCode()); |
||||
|
snapshot.put("cityName", policy.getCityName()); |
||||
|
snapshot.put("dataLevel", policy.getDataLevel()); |
||||
|
snapshot.put("policyVersion", policy.getPolicyVersion()); |
||||
|
snapshot.put("policyEffectiveDate", policy.getPolicyEffectiveDate()); |
||||
|
snapshot.put("defaultParameters", Map.of( |
||||
|
"socialSecurityBaseMin", policy.getSocialSecurityBaseMin(), |
||||
|
"socialSecurityBaseMax", policy.getSocialSecurityBaseMax(), |
||||
|
"housingFundBaseMin", policy.getHousingFundBaseMin(), |
||||
|
"housingFundBaseMax", policy.getHousingFundBaseMax(), |
||||
|
"pensionRate", policy.getPensionRate(), |
||||
|
"medicalRate", policy.getMedicalRate(), |
||||
|
"unemploymentRate", policy.getUnemploymentRate(), |
||||
|
"housingFundRate", policy.getHousingFundRate(), |
||||
|
"seriousIllnessMonthlyFee", policy.getSeriousIllnessMonthlyFee())); |
||||
|
snapshot.put("effectiveParameters", Map.of( |
||||
|
"socialSecurityBase", result.getSocialSecurityBase(), |
||||
|
"housingFundBase", result.getHousingFundBase(), |
||||
|
"pensionRate", result.getPensionRate(), |
||||
|
"medicalRate", result.getMedicalRate(), |
||||
|
"unemploymentRate", result.getUnemploymentRate(), |
||||
|
"housingFundRate", result.getHousingFundRate(), |
||||
|
"seriousIllnessMonthlyFee", result.getSeriousIllnessMonthlyFee())); |
||||
|
return snapshot; |
||||
|
} |
||||
|
|
||||
|
private String toJson(Object value) { |
||||
|
try { |
||||
|
return objectMapper.writeValueAsString(value); |
||||
|
} catch (JsonProcessingException ex) { |
||||
|
throw new IllegalStateException("unable to serialize salary snapshot", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String normalizeIdempotencyKey(String value) { |
||||
|
if (value == null || value.isBlank()) { |
||||
|
return null; |
||||
|
} |
||||
|
String normalized = value.trim(); |
||||
|
if (normalized.length() > 64) { |
||||
|
throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "idempotencyKey is too long"); |
||||
|
} |
||||
|
return normalized; |
||||
|
} |
||||
|
|
||||
|
private void requireStudent(Long userId) { |
||||
|
User user = userRepository.findByIdForUpdate(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
if (user.getRoles() == null |
||||
|
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,97 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.EnumType; |
||||
|
import jakarta.persistence.Enumerated; |
||||
|
import jakarta.persistence.EntityListeners; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import org.springframework.data.annotation.CreatedDate; |
||||
|
import org.springframework.data.annotation.LastModifiedDate; |
||||
|
import org.springframework.data.jpa.domain.support.AuditingEntityListener; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table(name = "salary_city_policy") |
||||
|
@EntityListeners(AuditingEntityListener.class) |
||||
|
public class SalaryCityPolicy { |
||||
|
|
||||
|
@Id |
||||
|
@Column(name = "city_code", nullable = false, length = 6) |
||||
|
private String cityCode; |
||||
|
|
||||
|
@Column(name = "province_code", nullable = false, length = 6) |
||||
|
private String provinceCode; |
||||
|
|
||||
|
@Column(name = "province_name", nullable = false, length = 40) |
||||
|
private String provinceName; |
||||
|
|
||||
|
@Column(name = "city_name", nullable = false, length = 80) |
||||
|
private String cityName; |
||||
|
|
||||
|
@Column(name = "policy_version", nullable = false, length = 64) |
||||
|
private String policyVersion; |
||||
|
|
||||
|
@Column(name = "policy_effective_date", nullable = false) |
||||
|
private LocalDate policyEffectiveDate; |
||||
|
|
||||
|
@Column(name = "data_source", length = 500) |
||||
|
private String dataSource; |
||||
|
|
||||
|
@Column(name = "social_security_base_min", nullable = false, precision = 12, scale = 2) |
||||
|
private BigDecimal socialSecurityBaseMin; |
||||
|
|
||||
|
@Column(name = "social_security_base_max", nullable = false, precision = 12, scale = 2) |
||||
|
private BigDecimal socialSecurityBaseMax; |
||||
|
|
||||
|
@Column(name = "housing_fund_base_min", nullable = false, precision = 12, scale = 2) |
||||
|
private BigDecimal housingFundBaseMin; |
||||
|
|
||||
|
@Column(name = "housing_fund_base_max", nullable = false, precision = 12, scale = 2) |
||||
|
private BigDecimal housingFundBaseMax; |
||||
|
|
||||
|
@Column(name = "pension_rate", nullable = false, precision = 7, scale = 6) |
||||
|
private BigDecimal pensionRate; |
||||
|
|
||||
|
@Column(name = "medical_rate", nullable = false, precision = 7, scale = 6) |
||||
|
private BigDecimal medicalRate; |
||||
|
|
||||
|
@Column(name = "unemployment_rate", nullable = false, precision = 7, scale = 6) |
||||
|
private BigDecimal unemploymentRate; |
||||
|
|
||||
|
@Column(name = "housing_fund_rate", nullable = false, precision = 7, scale = 6) |
||||
|
private BigDecimal housingFundRate; |
||||
|
|
||||
|
@Column(name = "serious_illness_monthly_fee", nullable = false, precision = 10, scale = 2) |
||||
|
@Builder.Default |
||||
|
private BigDecimal seriousIllnessMonthlyFee = BigDecimal.ZERO; |
||||
|
|
||||
|
@Enumerated(EnumType.STRING) |
||||
|
@Column(name = "data_level", nullable = false, length = 30) |
||||
|
@Builder.Default |
||||
|
private SalaryDataLevel dataLevel = SalaryDataLevel.CITY; |
||||
|
|
||||
|
@Column(nullable = false) |
||||
|
@Builder.Default |
||||
|
private Boolean enabled = true; |
||||
|
|
||||
|
@CreatedDate |
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@LastModifiedDate |
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,64 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.PathVariable; |
||||
|
import org.springframework.web.bind.annotation.RequestMapping; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Tag(name = "薪资计算器", description = "城市政策、到手工资估算与历史记录") |
||||
|
@RestController |
||||
|
@RequestMapping("/salary-calculator") |
||||
|
@RequiredArgsConstructor |
||||
|
public class SalaryCityPolicyController { |
||||
|
|
||||
|
private final SalaryCityPolicyService cityPolicyService; |
||||
|
|
||||
|
@Operation(summary = "查询启用城市列表", description = "仅 STUDENT;返回全部启用城市摘要,不分页") |
||||
|
@GetMapping("/cities") |
||||
|
public ResponseEntity<ApiResponse<List<SalaryCitySummaryResponse>>> listCities( |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
return ResponseEntity.ok(ApiResponse.ok( |
||||
|
cityPolicyService.listCities(userId), getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation(summary = "查询城市默认参数", description = "仅 STUDENT;返回城市元数据和当前整套默认参数") |
||||
|
@GetMapping("/cities/{cityCode}") |
||||
|
public ResponseEntity<ApiResponse<SalaryCityDetailResponse>> getCity( |
||||
|
@PathVariable String cityCode, HttpServletRequest request) { |
||||
|
Long userId = resolveUserId(); |
||||
|
return ResponseEntity.ok(ApiResponse.ok( |
||||
|
cityPolicyService.getCity(userId, cityCode), getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
private Long resolveUserId() { |
||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication(); |
||||
|
if (auth == null || !auth.isAuthenticated() |
||||
|
|| "anonymousUser".equals(String.valueOf(auth.getPrincipal()))) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
try { |
||||
|
return Long.valueOf(auth.getName()); |
||||
|
} catch (NumberFormatException ex) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String getRequestId(HttpServletRequest request) { |
||||
|
return request.getHeader("X-Request-Id"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
import org.springframework.stereotype.Repository; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Optional; |
||||
|
|
||||
|
@Repository |
||||
|
public interface SalaryCityPolicyRepository extends JpaRepository<SalaryCityPolicy, String> { |
||||
|
|
||||
|
List<SalaryCityPolicy> findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc(); |
||||
|
|
||||
|
Optional<SalaryCityPolicy> findByCityCodeAndEnabledTrue(String cityCode); |
||||
|
} |
||||
@ -0,0 +1,81 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.auth.User; |
||||
|
import com.mydreamjob.auth.UserRepository; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCityDetailResponse; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCitySummaryResponse; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class SalaryCityPolicyService { |
||||
|
|
||||
|
private final SalaryCityPolicyRepository cityPolicyRepository; |
||||
|
private final UserRepository userRepository; |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public List<SalaryCitySummaryResponse> listCities(Long userId) { |
||||
|
requireStudent(userId); |
||||
|
return cityPolicyRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc() |
||||
|
.stream() |
||||
|
.map(this::toSummary) |
||||
|
.toList(); |
||||
|
} |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public SalaryCityDetailResponse getCity(Long userId, String cityCode) { |
||||
|
requireStudent(userId); |
||||
|
if (cityCode == null || !cityCode.matches("\\d{6}")) { |
||||
|
throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND); |
||||
|
} |
||||
|
SalaryCityPolicy policy = cityPolicyRepository |
||||
|
.findByCityCodeAndEnabledTrue(cityCode) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); |
||||
|
return SalaryCityDetailResponse.builder() |
||||
|
.cityCode(policy.getCityCode()) |
||||
|
.provinceCode(policy.getProvinceCode()) |
||||
|
.provinceName(policy.getProvinceName()) |
||||
|
.cityName(policy.getCityName()) |
||||
|
.policyVersion(policy.getPolicyVersion()) |
||||
|
.policyEffectiveDate(policy.getPolicyEffectiveDate()) |
||||
|
.dataLevel(policy.getDataLevel()) |
||||
|
.dataSource(policy.getDataSource()) |
||||
|
.socialSecurityBaseMin(policy.getSocialSecurityBaseMin()) |
||||
|
.socialSecurityBaseMax(policy.getSocialSecurityBaseMax()) |
||||
|
.housingFundBaseMin(policy.getHousingFundBaseMin()) |
||||
|
.housingFundBaseMax(policy.getHousingFundBaseMax()) |
||||
|
.pensionRate(policy.getPensionRate()) |
||||
|
.medicalRate(policy.getMedicalRate()) |
||||
|
.unemploymentRate(policy.getUnemploymentRate()) |
||||
|
.housingFundRate(policy.getHousingFundRate()) |
||||
|
.seriousIllnessMonthlyFee(policy.getSeriousIllnessMonthlyFee()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private SalaryCitySummaryResponse toSummary(SalaryCityPolicy policy) { |
||||
|
return SalaryCitySummaryResponse.builder() |
||||
|
.cityCode(policy.getCityCode()) |
||||
|
.provinceCode(policy.getProvinceCode()) |
||||
|
.provinceName(policy.getProvinceName()) |
||||
|
.cityName(policy.getCityName()) |
||||
|
.policyVersion(policy.getPolicyVersion()) |
||||
|
.policyEffectiveDate(policy.getPolicyEffectiveDate()) |
||||
|
.dataLevel(policy.getDataLevel()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private void requireStudent(Long userId) { |
||||
|
User user = userRepository.findById(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
if (user.getRoles() == null |
||||
|
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,6 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
public enum SalaryDataLevel { |
||||
|
CITY, |
||||
|
PROVINCE_FALLBACK |
||||
|
} |
||||
@ -0,0 +1,18 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Propagation; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class SalaryHistoryCleanupService { |
||||
|
|
||||
|
private final SalaryCalculationHistoryRepository historyRepository; |
||||
|
|
||||
|
@Transactional(propagation = Propagation.MANDATORY) |
||||
|
public long purgeForDeletedUser(Long userId) { |
||||
|
return historyRepository.deleteByStudentUserId(userId); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,106 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.JsonProcessingException; |
||||
|
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.common.api.PageResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryHistoryDetailResponse; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryHistorySummaryResponse; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.data.domain.Page; |
||||
|
import org.springframework.data.domain.PageRequest; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
@Service |
||||
|
@RequiredArgsConstructor |
||||
|
public class SalaryHistoryService { |
||||
|
|
||||
|
private final SalaryCalculationHistoryRepository historyRepository; |
||||
|
private final UserRepository userRepository; |
||||
|
private final ObjectMapper objectMapper; |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public PageResponse<SalaryHistorySummaryResponse> list(Long userId, int page, int size) { |
||||
|
requireStudent(userId); |
||||
|
if (page < 0 || size < 1 || size > 100) { |
||||
|
throw new BusinessException(ErrorCode.SALARY_INVALID_PARAMETER, "invalid pagination"); |
||||
|
} |
||||
|
Page<SalaryCalculationHistory> result = historyRepository |
||||
|
.findByStudentUserIdOrderByCreatedAtDescIdDesc(userId, PageRequest.of(page, size)); |
||||
|
return PageResponse.of( |
||||
|
result.getContent().stream().map(this::toSummary).toList(), |
||||
|
result.getTotalElements(), page, size); |
||||
|
} |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
public SalaryHistoryDetailResponse get(Long userId, Long historyId) { |
||||
|
requireStudent(userId); |
||||
|
SalaryCalculationHistory history = ownHistory(userId, historyId); |
||||
|
return SalaryHistoryDetailResponse.builder() |
||||
|
.id(history.getId()) |
||||
|
.cityCode(history.getCityCode()) |
||||
|
.cityName(history.getCityName()) |
||||
|
.dataLevel(history.getDataLevel()) |
||||
|
.policyVersion(history.getPolicyVersion()) |
||||
|
.taxRulesVersion(history.getTaxRulesVersion()) |
||||
|
.monthIndex(history.getMonthIndex()) |
||||
|
.grossMonthlySalary(history.getGrossMonthlySalary()) |
||||
|
.netSalary(history.getNetSalary()) |
||||
|
.createdAt(history.getCreatedAt()) |
||||
|
.inputSnapshot(readJson(history.getInputSnapshotJson())) |
||||
|
.policySnapshot(readJson(history.getPolicySnapshotJson())) |
||||
|
.resultSnapshot(readJson(history.getResultSnapshotJson())) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
@Transactional |
||||
|
public void delete(Long userId, Long historyId) { |
||||
|
requireStudent(userId); |
||||
|
SalaryCalculationHistory history = ownHistory(userId, historyId); |
||||
|
historyRepository.delete(history); |
||||
|
historyRepository.flush(); |
||||
|
} |
||||
|
|
||||
|
private SalaryHistorySummaryResponse toSummary(SalaryCalculationHistory history) { |
||||
|
return SalaryHistorySummaryResponse.builder() |
||||
|
.id(history.getId()) |
||||
|
.cityCode(history.getCityCode()) |
||||
|
.cityName(history.getCityName()) |
||||
|
.dataLevel(history.getDataLevel()) |
||||
|
.grossMonthlySalary(history.getGrossMonthlySalary()) |
||||
|
.netSalary(history.getNetSalary()) |
||||
|
.monthIndex(history.getMonthIndex()) |
||||
|
.createdAt(history.getCreatedAt()) |
||||
|
.build(); |
||||
|
} |
||||
|
|
||||
|
private SalaryCalculationHistory ownHistory(Long userId, Long historyId) { |
||||
|
if (historyId == null) { |
||||
|
throw new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND); |
||||
|
} |
||||
|
return historyRepository.findByIdAndStudentUserId(historyId, userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.SALARY_HISTORY_NOT_FOUND)); |
||||
|
} |
||||
|
|
||||
|
private JsonNode readJson(String value) { |
||||
|
try { |
||||
|
return objectMapper.readTree(value); |
||||
|
} catch (JsonProcessingException ex) { |
||||
|
throw new IllegalStateException("stored salary snapshot is invalid", ex); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void requireStudent(Long userId) { |
||||
|
User user = userRepository.findById(userId) |
||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); |
||||
|
if (user.getRoles() == null |
||||
|
|| user.getRoles().stream().noneMatch(role -> role == User.Role.STUDENT)) { |
||||
|
throw new BusinessException(ErrorCode.FORBIDDEN); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,22 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryOverrideParameters { |
||||
|
private BigDecimal socialSecurityBase; |
||||
|
private BigDecimal housingFundBase; |
||||
|
private BigDecimal pensionRate; |
||||
|
private BigDecimal medicalRate; |
||||
|
private BigDecimal unemploymentRate; |
||||
|
private BigDecimal housingFundRate; |
||||
|
private BigDecimal seriousIllnessMonthlyFee; |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import java.time.LocalDate; |
||||
|
|
||||
|
public class SalaryPolicyVersionConflictException extends RuntimeException { |
||||
|
|
||||
|
private final String latestPolicyVersion; |
||||
|
private final LocalDate latestPolicyEffectiveDate; |
||||
|
|
||||
|
public SalaryPolicyVersionConflictException(String latestPolicyVersion, LocalDate latestPolicyEffectiveDate) { |
||||
|
super("salary city policy version conflict"); |
||||
|
this.latestPolicyVersion = latestPolicyVersion; |
||||
|
this.latestPolicyEffectiveDate = latestPolicyEffectiveDate; |
||||
|
} |
||||
|
|
||||
|
public String getLatestPolicyVersion() { |
||||
|
return latestPolicyVersion; |
||||
|
} |
||||
|
|
||||
|
public LocalDate getLatestPolicyEffectiveDate() { |
||||
|
return latestPolicyEffectiveDate; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,68 @@ |
|||||
|
package com.mydreamjob.salarycalculator; |
||||
|
|
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCalculationOverrideRequest; |
||||
|
import com.mydreamjob.salarycalculator.dto.SalaryCalculationRequest; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.nio.charset.StandardCharsets; |
||||
|
import java.security.MessageDigest; |
||||
|
import java.security.NoSuchAlgorithmException; |
||||
|
import java.util.HexFormat; |
||||
|
|
||||
|
@Component |
||||
|
public class SalaryRequestFingerprint { |
||||
|
|
||||
|
public String fingerprint(SalaryCalculationRequest request) { |
||||
|
StringBuilder canonical = new StringBuilder(); |
||||
|
append(canonical, normalize(request.getCityCode())); |
||||
|
append(canonical, normalize(request.getExpectedPolicyVersion())); |
||||
|
append(canonical, decimal(request.getGrossMonthlySalary())); |
||||
|
append(canonical, String.valueOf(request.getMonthIndex() == null ? 1 : request.getMonthIndex())); |
||||
|
append(canonical, decimal(request.getSpecialAdditionalDeduction() == null |
||||
|
? BigDecimal.ZERO : request.getSpecialAdditionalDeduction())); |
||||
|
SalaryCalculationOverrideRequest overrides = request.getOverrides(); |
||||
|
if (overrides == null) { |
||||
|
for (int i = 0; i < 7; i++) { |
||||
|
append(canonical, ""); |
||||
|
} |
||||
|
} else { |
||||
|
append(canonical, decimal(overrides.getSocialSecurityBase())); |
||||
|
append(canonical, decimal(overrides.getHousingFundBase())); |
||||
|
append(canonical, decimal(overrides.getPensionRate())); |
||||
|
append(canonical, decimal(overrides.getMedicalRate())); |
||||
|
append(canonical, decimal(overrides.getUnemploymentRate())); |
||||
|
append(canonical, decimal(overrides.getHousingFundRate())); |
||||
|
append(canonical, decimal(overrides.getSeriousIllnessMonthlyFee())); |
||||
|
} |
||||
|
return sha256(canonical.toString()); |
||||
|
} |
||||
|
|
||||
|
private void append(StringBuilder builder, String value) { |
||||
|
builder.append(value.length()).append(':').append(value).append('|'); |
||||
|
} |
||||
|
|
||||
|
private String normalize(String value) { |
||||
|
return value == null ? "" : value.trim(); |
||||
|
} |
||||
|
|
||||
|
private String decimal(BigDecimal value) { |
||||
|
if (value == null) { |
||||
|
return ""; |
||||
|
} |
||||
|
BigDecimal normalized = value.stripTrailingZeros(); |
||||
|
if (normalized.scale() < 0) { |
||||
|
normalized = normalized.setScale(0); |
||||
|
} |
||||
|
return normalized.toPlainString(); |
||||
|
} |
||||
|
|
||||
|
private String sha256(String value) { |
||||
|
try { |
||||
|
MessageDigest digest = MessageDigest.getInstance("SHA-256"); |
||||
|
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8))); |
||||
|
} catch (NoSuchAlgorithmException ex) { |
||||
|
throw new IllegalStateException("SHA-256 is unavailable", ex); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,53 @@ |
|||||
|
package com.mydreamjob.salarycalculator.dto; |
||||
|
|
||||
|
import jakarta.validation.constraints.DecimalMax; |
||||
|
import jakarta.validation.constraints.DecimalMin; |
||||
|
import jakarta.validation.constraints.Digits; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCalculationOverrideRequest { |
||||
|
|
||||
|
@DecimalMin(value = "0.00") |
||||
|
@DecimalMax(value = "10000000.00") |
||||
|
@Digits(integer = 8, fraction = 2) |
||||
|
private BigDecimal socialSecurityBase; |
||||
|
|
||||
|
@DecimalMin(value = "0.00") |
||||
|
@DecimalMax(value = "10000000.00") |
||||
|
@Digits(integer = 8, fraction = 2) |
||||
|
private BigDecimal housingFundBase; |
||||
|
|
||||
|
@DecimalMin(value = "0.00") |
||||
|
@DecimalMax(value = "0.20") |
||||
|
@Digits(integer = 1, fraction = 6) |
||||
|
private BigDecimal pensionRate; |
||||
|
|
||||
|
@DecimalMin(value = "0.00") |
||||
|
@DecimalMax(value = "0.20") |
||||
|
@Digits(integer = 1, fraction = 6) |
||||
|
private BigDecimal medicalRate; |
||||
|
|
||||
|
@DecimalMin(value = "0.00") |
||||
|
@DecimalMax(value = "0.10") |
||||
|
@Digits(integer = 1, fraction = 6) |
||||
|
private BigDecimal unemploymentRate; |
||||
|
|
||||
|
@DecimalMin(value = "0.00") |
||||
|
@DecimalMax(value = "0.12") |
||||
|
@Digits(integer = 1, fraction = 6) |
||||
|
private BigDecimal housingFundRate; |
||||
|
|
||||
|
@DecimalMin(value = "0.00") |
||||
|
@DecimalMax(value = "10000.00") |
||||
|
@Digits(integer = 5, fraction = 2) |
||||
|
private BigDecimal seriousIllnessMonthlyFee; |
||||
|
} |
||||
@ -0,0 +1,51 @@ |
|||||
|
package com.mydreamjob.salarycalculator.dto; |
||||
|
|
||||
|
import jakarta.validation.Valid; |
||||
|
import jakarta.validation.constraints.DecimalMax; |
||||
|
import jakarta.validation.constraints.DecimalMin; |
||||
|
import jakarta.validation.constraints.Digits; |
||||
|
import jakarta.validation.constraints.Max; |
||||
|
import jakarta.validation.constraints.Min; |
||||
|
import jakarta.validation.constraints.NotBlank; |
||||
|
import jakarta.validation.constraints.NotNull; |
||||
|
import jakarta.validation.constraints.Size; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCalculationRequest { |
||||
|
|
||||
|
@NotBlank |
||||
|
private String cityCode; |
||||
|
|
||||
|
@NotNull |
||||
|
@DecimalMin(value = "0.01") |
||||
|
@DecimalMax(value = "10000000.00") |
||||
|
@Digits(integer = 8, fraction = 2) |
||||
|
private BigDecimal grossMonthlySalary; |
||||
|
|
||||
|
@Min(1) |
||||
|
@Max(12) |
||||
|
private Integer monthIndex; |
||||
|
|
||||
|
@DecimalMin(value = "0.00") |
||||
|
@DecimalMax(value = "10000000.00") |
||||
|
@Digits(integer = 8, fraction = 2) |
||||
|
private BigDecimal specialAdditionalDeduction; |
||||
|
|
||||
|
@NotBlank |
||||
|
private String expectedPolicyVersion; |
||||
|
|
||||
|
@Size(max = 64) |
||||
|
private String idempotencyKey; |
||||
|
|
||||
|
@Valid |
||||
|
private SalaryCalculationOverrideRequest overrides; |
||||
|
} |
||||
@ -0,0 +1,19 @@ |
|||||
|
package com.mydreamjob.salarycalculator.dto; |
||||
|
|
||||
|
import com.mydreamjob.salarycalculator.SalaryCalculationResult; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCalculationResponse { |
||||
|
private Long historyId; |
||||
|
private LocalDateTime createdAt; |
||||
|
private SalaryCalculationResult result; |
||||
|
} |
||||
@ -0,0 +1,34 @@ |
|||||
|
package com.mydreamjob.salarycalculator.dto; |
||||
|
|
||||
|
import com.mydreamjob.salarycalculator.SalaryDataLevel; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDate; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCityDetailResponse { |
||||
|
private String cityCode; |
||||
|
private String provinceCode; |
||||
|
private String provinceName; |
||||
|
private String cityName; |
||||
|
private String policyVersion; |
||||
|
private LocalDate policyEffectiveDate; |
||||
|
private SalaryDataLevel dataLevel; |
||||
|
private String dataSource; |
||||
|
private BigDecimal socialSecurityBaseMin; |
||||
|
private BigDecimal socialSecurityBaseMax; |
||||
|
private BigDecimal housingFundBaseMin; |
||||
|
private BigDecimal housingFundBaseMax; |
||||
|
private BigDecimal pensionRate; |
||||
|
private BigDecimal medicalRate; |
||||
|
private BigDecimal unemploymentRate; |
||||
|
private BigDecimal housingFundRate; |
||||
|
private BigDecimal seriousIllnessMonthlyFee; |
||||
|
} |
||||
@ -0,0 +1,23 @@ |
|||||
|
package com.mydreamjob.salarycalculator.dto; |
||||
|
|
||||
|
import com.mydreamjob.salarycalculator.SalaryDataLevel; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.time.LocalDate; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryCitySummaryResponse { |
||||
|
private String cityCode; |
||||
|
private String provinceCode; |
||||
|
private String provinceName; |
||||
|
private String cityName; |
||||
|
private String policyVersion; |
||||
|
private LocalDate policyEffectiveDate; |
||||
|
private SalaryDataLevel dataLevel; |
||||
|
} |
||||
@ -0,0 +1,31 @@ |
|||||
|
package com.mydreamjob.salarycalculator.dto; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.JsonNode; |
||||
|
import com.mydreamjob.salarycalculator.SalaryDataLevel; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryHistoryDetailResponse { |
||||
|
private Long id; |
||||
|
private String cityCode; |
||||
|
private String cityName; |
||||
|
private SalaryDataLevel dataLevel; |
||||
|
private String policyVersion; |
||||
|
private String taxRulesVersion; |
||||
|
private Integer monthIndex; |
||||
|
private BigDecimal grossMonthlySalary; |
||||
|
private BigDecimal netSalary; |
||||
|
private LocalDateTime createdAt; |
||||
|
private JsonNode inputSnapshot; |
||||
|
private JsonNode policySnapshot; |
||||
|
private JsonNode resultSnapshot; |
||||
|
} |
||||
@ -0,0 +1,25 @@ |
|||||
|
package com.mydreamjob.salarycalculator.dto; |
||||
|
|
||||
|
import com.mydreamjob.salarycalculator.SalaryDataLevel; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Data; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Data |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
public class SalaryHistorySummaryResponse { |
||||
|
private Long id; |
||||
|
private String cityCode; |
||||
|
private String cityName; |
||||
|
private SalaryDataLevel dataLevel; |
||||
|
private BigDecimal grossMonthlySalary; |
||||
|
private BigDecimal netSalary; |
||||
|
private Integer monthIndex; |
||||
|
private LocalDateTime createdAt; |
||||
|
} |
||||
@ -0,0 +1,207 @@ |
|||||
|
package com.mydreamjob.skill; |
||||
|
|
||||
|
import com.mydreamjob.common.api.ApiResponse; |
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.profile.ProfileService; |
||||
|
import com.mydreamjob.skill.dto.SkillCloudResponse; |
||||
|
import com.mydreamjob.skill.dto.SkillLearningPathResponse; |
||||
|
import com.mydreamjob.skill.dto.SkillMatchResponse; |
||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||
|
import io.swagger.v3.oas.annotations.Parameter; |
||||
|
import io.swagger.v3.oas.annotations.media.Content; |
||||
|
import io.swagger.v3.oas.annotations.media.ExampleObject; |
||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponses; |
||||
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement; |
||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||
|
import jakarta.servlet.http.HttpServletRequest; |
||||
|
import jakarta.validation.constraints.Positive; |
||||
|
import lombok.RequiredArgsConstructor; |
||||
|
import org.springframework.http.ResponseEntity; |
||||
|
import org.springframework.security.core.Authentication; |
||||
|
import org.springframework.security.core.context.SecurityContextHolder; |
||||
|
import org.springframework.validation.annotation.Validated; |
||||
|
import org.springframework.web.bind.annotation.GetMapping; |
||||
|
import org.springframework.web.bind.annotation.RequestParam; |
||||
|
import org.springframework.web.bind.annotation.RestController; |
||||
|
|
||||
|
/** |
||||
|
* 行业技能词云、个人匹配度与学习路径接口。 |
||||
|
*/ |
||||
|
@Tag(name = "行业技能词云", description = "按行业、职位和技能分类查询热门技能、个人匹配度与学习路径") |
||||
|
@RestController |
||||
|
@Validated |
||||
|
@RequiredArgsConstructor |
||||
|
public class SkillCloudController { |
||||
|
|
||||
|
private final SkillCloudQueryService skillCloudQueryService; |
||||
|
private final SkillMatchService skillMatchService; |
||||
|
private final SkillLearningPathService skillLearningPathService; |
||||
|
private final ProfileService profileService; |
||||
|
|
||||
|
@Operation( |
||||
|
operationId = "getWordCloud", |
||||
|
summary = "查询行业技能词云", |
||||
|
description = "返回当前筛选范围内的全部技能,按热度降序排列;前端取前十条作为 TOP10。仅 STUDENT 可访问。" |
||||
|
) |
||||
|
@SecurityRequirement(name = "bearerAuth") |
||||
|
@ApiResponses({ |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "200", |
||||
|
description = "成功返回筛选范围内的全部技能", |
||||
|
content = @Content( |
||||
|
mediaType = "application/json", |
||||
|
examples = @ExampleObject( |
||||
|
name = "success", |
||||
|
value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"industry\":{\"id\":1,\"code\":\"INTERNET\",\"name\":\"互联网\"},\"occupation\":{\"id\":1,\"code\":\"FRONTEND_DEVELOPER\",\"name\":\"前端开发工程师\"},\"category\":null,\"total\":1,\"items\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}" |
||||
|
) |
||||
|
) |
||||
|
), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", |
||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class)) |
||||
|
), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "401", description = "未登录", |
||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class)) |
||||
|
), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "403", description = "已登录但不是 STUDENT 角色", |
||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class)) |
||||
|
) |
||||
|
}) |
||||
|
@GetMapping("/skills/word-cloud") |
||||
|
public ResponseEntity<ApiResponse<SkillCloudResponse>> getWordCloud( |
||||
|
@Parameter(description = "行业 ID,必填正整数", required = true, example = "1") |
||||
|
@Positive(message = "industryId must be positive") |
||||
|
@RequestParam Long industryId, |
||||
|
@Parameter(description = "职位 ID,必填正整数", required = true, example = "1") |
||||
|
@Positive(message = "occupationId must be positive") |
||||
|
@RequestParam Long occupationId, |
||||
|
@Parameter(description = "技能分类 ID,可选正整数;省略时返回职位下全部分类", required = false, example = "101") |
||||
|
@Positive(message = "categoryId must be positive") |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveAuthenticatedStudent(); |
||||
|
SkillCloudResponse data = skillCloudQueryService.query(industryId, occupationId, categoryId); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation( |
||||
|
operationId = "getSkillMatch", |
||||
|
summary = "查询个人技能匹配度", |
||||
|
description = "使用当前 STUDENT 的个人技能,与筛选结果热度 TOP10 比对并返回命中、缺失和匹配度。仅 STUDENT 可访问。" |
||||
|
) |
||||
|
@SecurityRequirement(name = "bearerAuth") |
||||
|
@ApiResponses({ |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "200", |
||||
|
description = "成功返回个人技能匹配结果", |
||||
|
content = @Content( |
||||
|
mediaType = "application/json", |
||||
|
examples = @ExampleObject( |
||||
|
name = "success", |
||||
|
value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"matchScore\":50,\"matchedCount\":1,\"totalCount\":2,\"matchedSkills\":[{\"rank\":1,\"skillId\":26,\"skillCode\":\"REACT\",\"nameZh\":\"React\",\"nameEn\":\"React\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1254.00|React|2.E.7.h\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"heat\":4.38,\"heatSourceCode\":\"15-1254.00\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}],\"missingSkills\":[{\"rank\":2,\"skillId\":31,\"skillCode\":\"DOCKER\",\"nameZh\":\"Docker\",\"nameEn\":\"Docker\",\"sourceType\":\"O*NET\",\"sourceCode\":\"15-1299.08|Docker|2.E.7.h\",\"categoryId\":105,\"categoryName\":\"工程化工具\",\"heat\":4.12,\"heatSourceCode\":\"15-1299.08\",\"heatElementId\":\"2.E.7.h\",\"heatProxy\":true}]}}" |
||||
|
) |
||||
|
) |
||||
|
), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", |
||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class)) |
||||
|
), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "401", description = "未登录", |
||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class)) |
||||
|
), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "403", description = "已登录但不是 STUDENT 角色", |
||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class)) |
||||
|
) |
||||
|
}) |
||||
|
@GetMapping("/skills/match") |
||||
|
public ResponseEntity<ApiResponse<SkillMatchResponse>> getMatch( |
||||
|
@Parameter(description = "行业 ID,必填正整数", required = true, example = "1") |
||||
|
@Positive(message = "industryId must be positive") |
||||
|
@RequestParam Long industryId, |
||||
|
@Parameter(description = "职位 ID,必填正整数", required = true, example = "1") |
||||
|
@Positive(message = "occupationId must be positive") |
||||
|
@RequestParam Long occupationId, |
||||
|
@Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101") |
||||
|
@Positive(message = "categoryId must be positive") |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveAuthenticatedStudent(); |
||||
|
SkillMatchResponse data = skillMatchService.match(userId, industryId, occupationId, categoryId); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
@Operation( |
||||
|
operationId = "getLearningPath", |
||||
|
summary = "生成个人学习路径", |
||||
|
description = "仅针对个人 TOP10 中未命中的技能,按热度顺序返回优先级和通用学习建议。仅 STUDENT 可访问。" |
||||
|
) |
||||
|
@SecurityRequirement(name = "bearerAuth") |
||||
|
@ApiResponses({ |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "200", |
||||
|
description = "成功返回缺失技能的学习路径;没有缺口时列表为空", |
||||
|
content = @Content( |
||||
|
mediaType = "application/json", |
||||
|
examples = @ExampleObject( |
||||
|
name = "success", |
||||
|
value = "{\"code\":200,\"message\":\"ok\",\"data\":{\"total\":1,\"items\":[{\"priority\":1,\"skillId\":26,\"skillName\":\"React\",\"categoryId\":103,\"categoryName\":\"前端框架\",\"suggestion\":\"先理解 React 的核心概念与生命周期,再通过一个小型项目练习集成。\"}]}}" |
||||
|
) |
||||
|
) |
||||
|
), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "400", description = "筛选参数缺失、非正整数或节点关系非法", |
||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class)) |
||||
|
), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "401", description = "未登录", |
||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class)) |
||||
|
), |
||||
|
@io.swagger.v3.oas.annotations.responses.ApiResponse( |
||||
|
responseCode = "403", description = "已登录但不是 STUDENT 角色", |
||||
|
content = @Content(schema = @Schema(implementation = ApiResponse.class)) |
||||
|
) |
||||
|
}) |
||||
|
@GetMapping("/skills/learning-path") |
||||
|
public ResponseEntity<ApiResponse<SkillLearningPathResponse>> getLearningPath( |
||||
|
@Parameter(description = "行业 ID,必填正整数", required = true, example = "1") |
||||
|
@Positive(message = "industryId must be positive") |
||||
|
@RequestParam Long industryId, |
||||
|
@Parameter(description = "职位 ID,必填正整数", required = true, example = "1") |
||||
|
@Positive(message = "occupationId must be positive") |
||||
|
@RequestParam Long occupationId, |
||||
|
@Parameter(description = "技能分类 ID,可选正整数;省略时使用职位下全部技能", required = false, example = "101") |
||||
|
@Positive(message = "categoryId must be positive") |
||||
|
@RequestParam(required = false) Long categoryId, |
||||
|
HttpServletRequest request) { |
||||
|
Long userId = resolveAuthenticatedStudent(); |
||||
|
SkillLearningPathResponse data = skillLearningPathService.generate( |
||||
|
userId, industryId, occupationId, categoryId); |
||||
|
return ResponseEntity.ok(ApiResponse.ok(data, getRequestId(request))); |
||||
|
} |
||||
|
|
||||
|
private Long resolveAuthenticatedStudent() { |
||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); |
||||
|
if (authentication == null |
||||
|
|| !authentication.isAuthenticated() |
||||
|
|| "anonymousUser".equals(authentication.getName())) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
try { |
||||
|
Long userId = Long.valueOf(authentication.getName()); |
||||
|
profileService.assertStudentRole(userId); |
||||
|
return userId; |
||||
|
} catch (NumberFormatException exception) { |
||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private String getRequestId(HttpServletRequest request) { |
||||
|
return request.getHeader("X-Request-Id"); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,160 @@ |
|||||
|
package com.mydreamjob.skill; |
||||
|
|
||||
|
import com.mydreamjob.common.exception.BusinessException; |
||||
|
import com.mydreamjob.common.exception.ErrorCode; |
||||
|
import com.mydreamjob.skill.domain.SkillCategory; |
||||
|
import com.mydreamjob.skill.domain.SkillIndustry; |
||||
|
import com.mydreamjob.skill.domain.SkillOccupation; |
||||
|
import com.mydreamjob.skill.dto.SkillCloudItem; |
||||
|
import com.mydreamjob.skill.dto.SkillCloudNode; |
||||
|
import com.mydreamjob.skill.dto.SkillCloudResponse; |
||||
|
import com.mydreamjob.skill.dto.SkillCloudSkillProjection; |
||||
|
import com.mydreamjob.skill.repository.OccupationSkillRepository; |
||||
|
import com.mydreamjob.skill.repository.SkillCategoryRepository; |
||||
|
import com.mydreamjob.skill.repository.SkillIndustryRepository; |
||||
|
import com.mydreamjob.skill.repository.SkillOccupationRepository; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.util.Comparator; |
||||
|
import java.util.List; |
||||
|
import java.util.stream.IntStream; |
||||
|
|
||||
|
@Service |
||||
|
@Transactional(readOnly = true) |
||||
|
public class SkillCloudQueryService { |
||||
|
|
||||
|
private static final Comparator<SkillCloudSkillProjection> SKILL_ORDER = (left, right) -> { |
||||
|
int heatComparison = compareHeat(left.importance(), right.importance()); |
||||
|
if (heatComparison != 0) { |
||||
|
return heatComparison; |
||||
|
} |
||||
|
|
||||
|
int nameComparison = compareNullable(left.nameZh(), right.nameZh()); |
||||
|
if (nameComparison != 0) { |
||||
|
return nameComparison; |
||||
|
} |
||||
|
|
||||
|
return compareNullable(left.skillId(), right.skillId()); |
||||
|
}; |
||||
|
|
||||
|
private final SkillIndustryRepository industryRepository; |
||||
|
private final SkillOccupationRepository occupationRepository; |
||||
|
private final SkillCategoryRepository categoryRepository; |
||||
|
private final OccupationSkillRepository occupationSkillRepository; |
||||
|
|
||||
|
public SkillCloudQueryService(SkillIndustryRepository industryRepository, |
||||
|
SkillOccupationRepository occupationRepository, |
||||
|
SkillCategoryRepository categoryRepository, |
||||
|
OccupationSkillRepository occupationSkillRepository) { |
||||
|
this.industryRepository = industryRepository; |
||||
|
this.occupationRepository = occupationRepository; |
||||
|
this.categoryRepository = categoryRepository; |
||||
|
this.occupationSkillRepository = occupationSkillRepository; |
||||
|
} |
||||
|
|
||||
|
public SkillCloudResponse query(Long industryId, Long occupationId, Long categoryId) { |
||||
|
long validatedIndustryId = requirePositive(industryId, "industryId"); |
||||
|
long validatedOccupationId = requirePositive(occupationId, "occupationId"); |
||||
|
Long validatedCategoryId = categoryId == null ? null : requirePositive(categoryId, "categoryId"); |
||||
|
|
||||
|
SkillIndustry industry = industryRepository.findById(validatedIndustryId) |
||||
|
.orElseThrow(() -> badRequest("industry not found: industryId=" + validatedIndustryId)); |
||||
|
SkillOccupation occupation = occupationRepository.findById(validatedOccupationId) |
||||
|
.orElseThrow(() -> badRequest("occupation not found: occupationId=" + validatedOccupationId)); |
||||
|
if (!occupation.getIndustryId().equals(validatedIndustryId)) { |
||||
|
throw badRequest("occupation does not belong to industry: occupationId=" |
||||
|
+ validatedOccupationId + ", industryId=" + validatedIndustryId); |
||||
|
} |
||||
|
|
||||
|
SkillCategory category = null; |
||||
|
if (validatedCategoryId != null) { |
||||
|
category = categoryRepository.findById(validatedCategoryId) |
||||
|
.orElseThrow(() -> badRequest("category not found: categoryId=" + validatedCategoryId)); |
||||
|
if (!category.getOccupationId().equals(validatedOccupationId)) { |
||||
|
throw badRequest("category does not belong to occupation: categoryId=" |
||||
|
+ validatedCategoryId + ", occupationId=" + validatedOccupationId); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
List<SkillCloudSkillProjection> projections = validatedCategoryId == null |
||||
|
? occupationSkillRepository.findSkillProjectionsByOccupationId(validatedOccupationId) |
||||
|
: occupationSkillRepository.findSkillProjectionsByOccupationIdAndCategoryId( |
||||
|
validatedOccupationId, validatedCategoryId); |
||||
|
|
||||
|
List<SkillCloudSkillProjection> sortedProjections = projections.stream() |
||||
|
.sorted(SKILL_ORDER) |
||||
|
.toList(); |
||||
|
List<SkillCloudItem> items = IntStream.range(0, sortedProjections.size()) |
||||
|
.mapToObj(index -> toItem(sortedProjections.get(index), index + 1)) |
||||
|
.toList(); |
||||
|
|
||||
|
return new SkillCloudResponse( |
||||
|
toNode(industry.getId(), industry.getCode(), industry.getNameZh()), |
||||
|
toNode(occupation.getId(), occupation.getCode(), occupation.getNameZh()), |
||||
|
category == null ? null : toNode(category.getId(), category.getCode(), category.getNameZh()), |
||||
|
items.size(), |
||||
|
items |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private SkillCloudItem toItem(SkillCloudSkillProjection projection, int rank) { |
||||
|
return new SkillCloudItem( |
||||
|
rank, |
||||
|
projection.skillId(), |
||||
|
projection.skillCode(), |
||||
|
projection.nameZh(), |
||||
|
projection.nameEn(), |
||||
|
projection.sourceType(), |
||||
|
projection.sourceCode(), |
||||
|
projection.categoryId(), |
||||
|
projection.categoryName(), |
||||
|
projection.importance(), |
||||
|
projection.importanceSourceCode(), |
||||
|
projection.importanceElementId(), |
||||
|
projection.importanceIsProxy() |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private static int compareHeat(BigDecimal left, BigDecimal right) { |
||||
|
if (left == null && right == null) { |
||||
|
return 0; |
||||
|
} |
||||
|
if (left == null) { |
||||
|
return 1; |
||||
|
} |
||||
|
if (right == null) { |
||||
|
return -1; |
||||
|
} |
||||
|
return right.compareTo(left); |
||||
|
} |
||||
|
|
||||
|
private static <T extends Comparable<? super T>> int compareNullable(T left, T right) { |
||||
|
if (left == null && right == null) { |
||||
|
return 0; |
||||
|
} |
||||
|
if (left == null) { |
||||
|
return 1; |
||||
|
} |
||||
|
if (right == null) { |
||||
|
return -1; |
||||
|
} |
||||
|
return left.compareTo(right); |
||||
|
} |
||||
|
|
||||
|
private static SkillCloudNode toNode(Long id, String code, String name) { |
||||
|
return new SkillCloudNode(id, code, name); |
||||
|
} |
||||
|
|
||||
|
private static long requirePositive(Long value, String parameterName) { |
||||
|
if (value == null || value <= 0) { |
||||
|
throw badRequest(parameterName + " must be a positive integer"); |
||||
|
} |
||||
|
return value; |
||||
|
} |
||||
|
|
||||
|
private static BusinessException badRequest(String detail) { |
||||
|
return new BusinessException(ErrorCode.BAD_REQUEST, detail); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,95 @@ |
|||||
|
package com.mydreamjob.skill; |
||||
|
|
||||
|
import com.mydreamjob.skill.domain.SkillCategory; |
||||
|
import com.mydreamjob.skill.dto.SkillCloudItem; |
||||
|
import com.mydreamjob.skill.dto.SkillLearningPathItem; |
||||
|
import com.mydreamjob.skill.dto.SkillLearningPathResponse; |
||||
|
import com.mydreamjob.skill.dto.SkillMatchResponse; |
||||
|
import com.mydreamjob.skill.repository.SkillCategoryRepository; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Locale; |
||||
|
import java.util.stream.IntStream; |
||||
|
|
||||
|
@Service |
||||
|
@Transactional(readOnly = true) |
||||
|
public class SkillLearningPathService { |
||||
|
|
||||
|
private final SkillMatchService skillMatchService; |
||||
|
private final SkillCategoryRepository skillCategoryRepository; |
||||
|
|
||||
|
public SkillLearningPathService(SkillMatchService skillMatchService, |
||||
|
SkillCategoryRepository skillCategoryRepository) { |
||||
|
this.skillMatchService = skillMatchService; |
||||
|
this.skillCategoryRepository = skillCategoryRepository; |
||||
|
} |
||||
|
|
||||
|
public SkillLearningPathResponse generate(Long studentUserId, |
||||
|
Long industryId, |
||||
|
Long occupationId, |
||||
|
Long categoryId) { |
||||
|
SkillMatchResponse matchResponse = skillMatchService.match( |
||||
|
studentUserId, industryId, occupationId, categoryId); |
||||
|
List<SkillCloudItem> missingSkills = matchResponse.missingSkills(); |
||||
|
List<SkillLearningPathItem> items = IntStream.range(0, missingSkills.size()) |
||||
|
.mapToObj(index -> toItem(missingSkills.get(index), index + 1, occupationId)) |
||||
|
.toList(); |
||||
|
|
||||
|
return new SkillLearningPathResponse(items.size(), items); |
||||
|
} |
||||
|
|
||||
|
private SkillLearningPathItem toItem(SkillCloudItem skill, int priority, Long occupationId) { |
||||
|
SuggestionTemplate template = resolveTemplate(skill, occupationId); |
||||
|
String skillName = skill.nameZh(); |
||||
|
return new SkillLearningPathItem( |
||||
|
priority, |
||||
|
skill.skillId(), |
||||
|
skillName, |
||||
|
skill.categoryId(), |
||||
|
skill.categoryName(), |
||||
|
template.render(skillName) |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private SuggestionTemplate resolveTemplate(SkillCloudItem skill, Long occupationId) { |
||||
|
if (skill.categoryId() == null) { |
||||
|
return SuggestionTemplate.GENERAL; |
||||
|
} |
||||
|
|
||||
|
String templateCode = skillCategoryRepository |
||||
|
.findByIdAndOccupationId(skill.categoryId(), occupationId) |
||||
|
.map(SkillCategory::getSuggestionTemplateCode) |
||||
|
.orElse(null); |
||||
|
return SuggestionTemplate.fromCode(templateCode); |
||||
|
} |
||||
|
|
||||
|
private enum SuggestionTemplate { |
||||
|
PROGRAMMING_LANGUAGE("先掌握{skill}的基础语法和常见数据结构,再完成至少一个编码练习。"), |
||||
|
FRAMEWORK("先理解{skill}的核心概念与生命周期,再通过一个小型项目练习集成。"), |
||||
|
TOOL("先完成{skill}的安装与基础配置,再结合现有工程完成一次实际操作。"), |
||||
|
GENERAL("先了解{skill}的核心概念和常见应用场景,再通过一个简短实践巩固。"); |
||||
|
|
||||
|
private final String template; |
||||
|
|
||||
|
SuggestionTemplate(String template) { |
||||
|
this.template = template; |
||||
|
} |
||||
|
|
||||
|
String render(String skillName) { |
||||
|
return template.replace("{skill}", skillName); |
||||
|
} |
||||
|
|
||||
|
static SuggestionTemplate fromCode(String code) { |
||||
|
if (code == null || code.isBlank()) { |
||||
|
return GENERAL; |
||||
|
} |
||||
|
try { |
||||
|
return valueOf(code.trim().toUpperCase(Locale.ROOT)); |
||||
|
} catch (IllegalArgumentException exception) { |
||||
|
return GENERAL; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,152 @@ |
|||||
|
package com.mydreamjob.skill; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.type.TypeReference; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.mydreamjob.profile.StudentProfile; |
||||
|
import com.mydreamjob.profile.StudentProfileRepository; |
||||
|
import com.mydreamjob.skill.domain.SkillAlias; |
||||
|
import com.mydreamjob.skill.dto.SkillCloudItem; |
||||
|
import com.mydreamjob.skill.dto.SkillCloudResponse; |
||||
|
import com.mydreamjob.skill.dto.SkillMatchResponse; |
||||
|
import com.mydreamjob.skill.repository.SkillAliasRepository; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Collection; |
||||
|
import java.util.HashMap; |
||||
|
import java.util.HashSet; |
||||
|
import java.util.List; |
||||
|
import java.util.Map; |
||||
|
import java.util.Set; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Service |
||||
|
@Transactional(readOnly = true) |
||||
|
public class SkillMatchService { |
||||
|
|
||||
|
private static final int TOP_LIMIT = 10; |
||||
|
|
||||
|
private final SkillCloudQueryService skillCloudQueryService; |
||||
|
private final StudentProfileRepository studentProfileRepository; |
||||
|
private final SkillAliasRepository skillAliasRepository; |
||||
|
private final SkillNameNormalizer skillNameNormalizer; |
||||
|
private final ObjectMapper objectMapper; |
||||
|
|
||||
|
public SkillMatchService(SkillCloudQueryService skillCloudQueryService, |
||||
|
StudentProfileRepository studentProfileRepository, |
||||
|
SkillAliasRepository skillAliasRepository, |
||||
|
SkillNameNormalizer skillNameNormalizer, |
||||
|
ObjectMapper objectMapper) { |
||||
|
this.skillCloudQueryService = skillCloudQueryService; |
||||
|
this.studentProfileRepository = studentProfileRepository; |
||||
|
this.skillAliasRepository = skillAliasRepository; |
||||
|
this.skillNameNormalizer = skillNameNormalizer; |
||||
|
this.objectMapper = objectMapper; |
||||
|
} |
||||
|
|
||||
|
public SkillMatchResponse match(Long studentUserId, |
||||
|
Long industryId, |
||||
|
Long occupationId, |
||||
|
Long categoryId) { |
||||
|
SkillCloudResponse cloud = skillCloudQueryService.query(industryId, occupationId, categoryId); |
||||
|
int actualTopSize = Math.min(TOP_LIMIT, cloud.items().size()); |
||||
|
List<SkillCloudItem> topSkills = cloud.items().subList(0, actualTopSize); |
||||
|
Set<String> studentSkills = loadStudentSkills(studentUserId); |
||||
|
Map<Long, List<SkillAlias>> aliasesBySkillId = loadAliases(topSkills); |
||||
|
|
||||
|
List<SkillCloudItem> matchedSkills = new ArrayList<>(); |
||||
|
List<SkillCloudItem> missingSkills = new ArrayList<>(); |
||||
|
for (SkillCloudItem skill : topSkills) { |
||||
|
if (matches(skill, studentSkills, aliasesBySkillId.getOrDefault(skill.skillId(), List.of()))) { |
||||
|
matchedSkills.add(skill); |
||||
|
} else { |
||||
|
missingSkills.add(skill); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
int matchScore = actualTopSize == 0 |
||||
|
? 0 |
||||
|
: (int) Math.round(matchedSkills.size() * 100.0 / actualTopSize); |
||||
|
return new SkillMatchResponse( |
||||
|
matchScore, |
||||
|
matchedSkills.size(), |
||||
|
actualTopSize, |
||||
|
matchedSkills, |
||||
|
missingSkills |
||||
|
); |
||||
|
} |
||||
|
|
||||
|
private Set<String> loadStudentSkills(Long studentUserId) { |
||||
|
if (studentUserId == null) { |
||||
|
return Set.of(); |
||||
|
} |
||||
|
|
||||
|
StudentProfile profile = studentProfileRepository.findByUserId(studentUserId).orElse(null); |
||||
|
if (profile == null || profile.getSkillsJson() == null || profile.getSkillsJson().isBlank()) { |
||||
|
return Set.of(); |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
List<String> parsed = objectMapper.readValue( |
||||
|
profile.getSkillsJson(), |
||||
|
new TypeReference<List<String>>() {} |
||||
|
); |
||||
|
if (parsed == null || parsed.isEmpty()) { |
||||
|
return Set.of(); |
||||
|
} |
||||
|
|
||||
|
Set<String> normalizedSkills = new HashSet<>(); |
||||
|
for (String skill : parsed) { |
||||
|
if (skill == null) { |
||||
|
continue; |
||||
|
} |
||||
|
String normalized = skillNameNormalizer.normalize(skill); |
||||
|
if (!normalized.isBlank()) { |
||||
|
normalizedSkills.add(normalized); |
||||
|
} |
||||
|
} |
||||
|
return Set.copyOf(normalizedSkills); |
||||
|
} catch (Exception exception) { |
||||
|
log.warn("Failed to parse student skills JSON: profileId={}, errorType={}", |
||||
|
profile.getId(), exception.getClass().getSimpleName()); |
||||
|
return Set.of(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private Map<Long, List<SkillAlias>> loadAliases(List<SkillCloudItem> topSkills) { |
||||
|
Collection<Long> skillIds = topSkills.stream() |
||||
|
.map(SkillCloudItem::skillId) |
||||
|
.filter(java.util.Objects::nonNull) |
||||
|
.toList(); |
||||
|
if (skillIds.isEmpty()) { |
||||
|
return Map.of(); |
||||
|
} |
||||
|
|
||||
|
Map<Long, List<SkillAlias>> aliasesBySkillId = new HashMap<>(); |
||||
|
for (SkillAlias alias : skillAliasRepository.findAllBySkillIdInOrderByIdAsc(skillIds)) { |
||||
|
aliasesBySkillId.computeIfAbsent(alias.getSkillId(), ignored -> new ArrayList<>()).add(alias); |
||||
|
} |
||||
|
return aliasesBySkillId; |
||||
|
} |
||||
|
|
||||
|
private boolean matches(SkillCloudItem skill, |
||||
|
Set<String> studentSkills, |
||||
|
List<SkillAlias> aliases) { |
||||
|
if (containsNormalized(studentSkills, skill.nameZh()) |
||||
|
|| containsNormalized(studentSkills, skill.nameEn()) |
||||
|
|| containsNormalized(studentSkills, skill.skillCode())) { |
||||
|
return true; |
||||
|
} |
||||
|
return aliases.stream().anyMatch(alias -> containsNormalized(studentSkills, alias.getAlias())); |
||||
|
} |
||||
|
|
||||
|
private boolean containsNormalized(Set<String> studentSkills, String candidate) { |
||||
|
if (candidate == null) { |
||||
|
return false; |
||||
|
} |
||||
|
String normalized = skillNameNormalizer.normalize(candidate); |
||||
|
return !normalized.isBlank() && studentSkills.contains(normalized); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,28 @@ |
|||||
|
package com.mydreamjob.skill; |
||||
|
|
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.text.Normalizer; |
||||
|
import java.util.Locale; |
||||
|
import java.util.regex.Pattern; |
||||
|
|
||||
|
@Component |
||||
|
public class SkillNameNormalizer { |
||||
|
|
||||
|
private static final Pattern UNICODE_HYPHENS = Pattern.compile("\\p{Pd}"); |
||||
|
private static final Pattern WHITESPACE = Pattern.compile("\\s+", Pattern.UNICODE_CHARACTER_CLASS); |
||||
|
|
||||
|
public String normalize(String value) { |
||||
|
if (value == null) { |
||||
|
return ""; |
||||
|
} |
||||
|
|
||||
|
String normalized = Normalizer.normalize(value, Normalizer.Form.NFKC) |
||||
|
.trim() |
||||
|
.toLowerCase(Locale.ROOT); |
||||
|
normalized = UNICODE_HYPHENS.matcher(normalized).replaceAll(" "); |
||||
|
normalized = normalized.replace(".", ""); |
||||
|
normalized = WHITESPACE.matcher(normalized).replaceAll(" "); |
||||
|
return normalized.trim(); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,62 @@ |
|||||
|
package com.mydreamjob.skill.domain; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Getter; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import lombok.Setter; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Getter |
||||
|
@Setter |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table(name = "occupation_skills") |
||||
|
public class OccupationSkill { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "occupation_id", nullable = false) |
||||
|
private Long occupationId; |
||||
|
|
||||
|
@Column(name = "skill_id", nullable = false) |
||||
|
private Long skillId; |
||||
|
|
||||
|
@Column(name = "category_id", nullable = false) |
||||
|
private Long categoryId; |
||||
|
|
||||
|
@Column(name = "importance", precision = 4, scale = 2) |
||||
|
private BigDecimal importance; |
||||
|
|
||||
|
@Column(name = "importance_source_code", length = 20) |
||||
|
private String importanceSourceCode; |
||||
|
|
||||
|
@Column(name = "importance_element_id", length = 30) |
||||
|
private String importanceElementId; |
||||
|
@Column(name = "importance_is_proxy", nullable = false) |
||||
|
private Boolean importanceIsProxy; |
||||
|
|
||||
|
@Column(name = "relationship_type", nullable = false, length = 20) |
||||
|
private String relationshipType; |
||||
|
|
||||
|
@Column(name = "source_version_id", nullable = false) |
||||
|
private Long sourceVersionId; |
||||
|
|
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,56 @@ |
|||||
|
package com.mydreamjob.skill.domain; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Getter; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import lombok.Setter; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Getter |
||||
|
@Setter |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table(name = "skills") |
||||
|
public class Skill { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "code", nullable = false, length = 120) |
||||
|
private String code; |
||||
|
|
||||
|
@Column(name = "name_zh", nullable = false, length = 180) |
||||
|
private String nameZh; |
||||
|
|
||||
|
@Column(name = "name_en", length = 180) |
||||
|
private String nameEn; |
||||
|
|
||||
|
@Column(name = "source_type", nullable = false, length = 20) |
||||
|
private String sourceType; |
||||
|
|
||||
|
@Column(name = "source_code", nullable = false, length = 180) |
||||
|
private String sourceCode; |
||||
|
|
||||
|
@Column(name = "source_version_id", nullable = false) |
||||
|
private Long sourceVersionId; |
||||
|
|
||||
|
@Column(name = "sort_order", nullable = false) |
||||
|
private Integer sortOrder; |
||||
|
|
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,41 @@ |
|||||
|
package com.mydreamjob.skill.domain; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Getter; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import lombok.Setter; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Getter |
||||
|
@Setter |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table(name = "skill_aliases") |
||||
|
public class SkillAlias { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "skill_id", nullable = false) |
||||
|
private Long skillId; |
||||
|
|
||||
|
@Column(name = "alias", nullable = false, length = 180) |
||||
|
private String alias; |
||||
|
|
||||
|
@Column(name = "normalized_alias", nullable = false, length = 180) |
||||
|
private String normalizedAlias; |
||||
|
|
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
package com.mydreamjob.skill.domain; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Getter; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import lombok.Setter; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Getter |
||||
|
@Setter |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table(name = "skill_categories") |
||||
|
public class SkillCategory { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "occupation_id", nullable = false) |
||||
|
private Long occupationId; |
||||
|
|
||||
|
@Column(name = "code", nullable = false, length = 100) |
||||
|
private String code; |
||||
|
|
||||
|
@Column(name = "name_zh", nullable = false, length = 120) |
||||
|
private String nameZh; |
||||
|
|
||||
|
@Column(name = "suggestion_template_code", nullable = false, length = 30) |
||||
|
private String suggestionTemplateCode; |
||||
|
|
||||
|
@Column(name = "sort_order", nullable = false) |
||||
|
private Integer sortOrder; |
||||
|
|
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,47 @@ |
|||||
|
package com.mydreamjob.skill.domain; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Getter; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import lombok.Setter; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Getter |
||||
|
@Setter |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table(name = "skill_industries") |
||||
|
public class SkillIndustry { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "code", nullable = false, length = 80) |
||||
|
private String code; |
||||
|
|
||||
|
@Column(name = "name_zh", nullable = false, length = 120) |
||||
|
private String nameZh; |
||||
|
|
||||
|
@Column(name = "source_code", length = 120) |
||||
|
private String sourceCode; |
||||
|
|
||||
|
@Column(name = "sort_order", nullable = false) |
||||
|
private Integer sortOrder; |
||||
|
|
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,56 @@ |
|||||
|
package com.mydreamjob.skill.domain; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Getter; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import lombok.Setter; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Getter |
||||
|
@Setter |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table(name = "skill_occupations") |
||||
|
public class SkillOccupation { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "industry_id", nullable = false) |
||||
|
private Long industryId; |
||||
|
|
||||
|
@Column(name = "code", nullable = false, length = 100) |
||||
|
private String code; |
||||
|
|
||||
|
@Column(name = "name_zh", nullable = false, length = 150) |
||||
|
private String nameZh; |
||||
|
|
||||
|
@Column(name = "name_en", length = 180) |
||||
|
private String nameEn; |
||||
|
|
||||
|
@Column(name = "source_code", nullable = false, length = 150) |
||||
|
private String sourceCode; |
||||
|
|
||||
|
@Column(name = "source_version_id", nullable = false) |
||||
|
private Long sourceVersionId; |
||||
|
|
||||
|
@Column(name = "sort_order", nullable = false) |
||||
|
private Integer sortOrder; |
||||
|
|
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,50 @@ |
|||||
|
package com.mydreamjob.skill.domain; |
||||
|
|
||||
|
import jakarta.persistence.Column; |
||||
|
import jakarta.persistence.Entity; |
||||
|
import jakarta.persistence.GeneratedValue; |
||||
|
import jakarta.persistence.GenerationType; |
||||
|
import jakarta.persistence.Id; |
||||
|
import jakarta.persistence.Table; |
||||
|
import lombok.AllArgsConstructor; |
||||
|
import lombok.Builder; |
||||
|
import lombok.Getter; |
||||
|
import lombok.NoArgsConstructor; |
||||
|
import lombok.Setter; |
||||
|
|
||||
|
import java.time.LocalDateTime; |
||||
|
|
||||
|
@Getter |
||||
|
@Setter |
||||
|
@Builder |
||||
|
@NoArgsConstructor |
||||
|
@AllArgsConstructor |
||||
|
@Entity |
||||
|
@Table(name = "skill_source_versions") |
||||
|
public class SkillSourceVersion { |
||||
|
|
||||
|
@Id |
||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY) |
||||
|
private Long id; |
||||
|
|
||||
|
@Column(name = "source_name", nullable = false, length = 100) |
||||
|
private String sourceName; |
||||
|
|
||||
|
@Column(name = "source_version", nullable = false, length = 150) |
||||
|
private String sourceVersion; |
||||
|
|
||||
|
@Column(name = "license", nullable = false, length = 255) |
||||
|
private String license; |
||||
|
|
||||
|
@Column(name = "attribution_url", nullable = false, length = 500) |
||||
|
private String attributionUrl; |
||||
|
|
||||
|
@Column(name = "imported_at", nullable = false) |
||||
|
private LocalDateTime importedAt; |
||||
|
|
||||
|
@Column(name = "created_at", nullable = false, updatable = false) |
||||
|
private LocalDateTime createdAt; |
||||
|
|
||||
|
@Column(name = "updated_at", nullable = false) |
||||
|
private LocalDateTime updatedAt; |
||||
|
} |
||||
@ -0,0 +1,20 @@ |
|||||
|
package com.mydreamjob.skill.dto; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
public record SkillCloudItem( |
||||
|
int rank, |
||||
|
Long skillId, |
||||
|
String skillCode, |
||||
|
String nameZh, |
||||
|
String nameEn, |
||||
|
String sourceType, |
||||
|
String sourceCode, |
||||
|
Long categoryId, |
||||
|
String categoryName, |
||||
|
BigDecimal heat, |
||||
|
String heatSourceCode, |
||||
|
String heatElementId, |
||||
|
Boolean heatProxy |
||||
|
) { |
||||
|
} |
||||
@ -0,0 +1,8 @@ |
|||||
|
package com.mydreamjob.skill.dto; |
||||
|
|
||||
|
public record SkillCloudNode( |
||||
|
Long id, |
||||
|
String code, |
||||
|
String name |
||||
|
) { |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
package com.mydreamjob.skill.dto; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
public record SkillCloudResponse( |
||||
|
SkillCloudNode industry, |
||||
|
SkillCloudNode occupation, |
||||
|
SkillCloudNode category, |
||||
|
int total, |
||||
|
List<SkillCloudItem> items |
||||
|
) { |
||||
|
public SkillCloudResponse { |
||||
|
items = List.copyOf(items); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,21 @@ |
|||||
|
package com.mydreamjob.skill.dto; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
|
||||
|
public record SkillCloudSkillProjection( |
||||
|
Long skillId, |
||||
|
String skillCode, |
||||
|
String nameZh, |
||||
|
String nameEn, |
||||
|
String sourceType, |
||||
|
String sourceCode, |
||||
|
Long categoryId, |
||||
|
String categoryName, |
||||
|
BigDecimal importance, |
||||
|
String importanceSourceCode, |
||||
|
String importanceElementId, |
||||
|
Boolean importanceIsProxy, |
||||
|
Integer skillSortOrder, |
||||
|
Integer categorySortOrder |
||||
|
) { |
||||
|
} |
||||
@ -0,0 +1,11 @@ |
|||||
|
package com.mydreamjob.skill.dto; |
||||
|
|
||||
|
public record SkillLearningPathItem( |
||||
|
int priority, |
||||
|
Long skillId, |
||||
|
String skillName, |
||||
|
Long categoryId, |
||||
|
String categoryName, |
||||
|
String suggestion |
||||
|
) { |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
package com.mydreamjob.skill.dto; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
public record SkillLearningPathResponse( |
||||
|
int total, |
||||
|
List<SkillLearningPathItem> items |
||||
|
) { |
||||
|
public SkillLearningPathResponse { |
||||
|
items = List.copyOf(items); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,16 @@ |
|||||
|
package com.mydreamjob.skill.dto; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
public record SkillMatchResponse( |
||||
|
int matchScore, |
||||
|
int matchedCount, |
||||
|
int totalCount, |
||||
|
List<SkillCloudItem> matchedSkills, |
||||
|
List<SkillCloudItem> missingSkills |
||||
|
) { |
||||
|
public SkillMatchResponse { |
||||
|
matchedSkills = List.copyOf(matchedSkills); |
||||
|
missingSkills = List.copyOf(missingSkills); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,73 @@ |
|||||
|
package com.mydreamjob.skill.repository; |
||||
|
|
||||
|
import com.mydreamjob.skill.domain.OccupationSkill; |
||||
|
import com.mydreamjob.skill.dto.SkillCloudSkillProjection; |
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
import org.springframework.data.jpa.repository.Query; |
||||
|
import org.springframework.data.repository.query.Param; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.util.List; |
||||
|
|
||||
|
public interface OccupationSkillRepository extends JpaRepository<OccupationSkill, Long> { |
||||
|
|
||||
|
long countByOccupationId(Long occupationId); |
||||
|
|
||||
|
long countByOccupationIdAndCategoryId(Long occupationId, Long categoryId); |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
@Query(""" |
||||
|
SELECT new com.mydreamjob.skill.dto.SkillCloudSkillProjection( |
||||
|
os.skillId, |
||||
|
s.code, |
||||
|
s.nameZh, |
||||
|
s.nameEn, |
||||
|
s.sourceType, |
||||
|
s.sourceCode, |
||||
|
c.id, |
||||
|
c.nameZh, |
||||
|
os.importance, |
||||
|
os.importanceSourceCode, |
||||
|
os.importanceElementId, |
||||
|
os.importanceIsProxy, |
||||
|
s.sortOrder, |
||||
|
c.sortOrder |
||||
|
) |
||||
|
FROM OccupationSkill os, Skill s, SkillCategory c |
||||
|
WHERE os.skillId = s.id |
||||
|
AND os.categoryId = c.id |
||||
|
AND c.occupationId = os.occupationId |
||||
|
AND os.occupationId = :occupationId |
||||
|
""") |
||||
|
List<SkillCloudSkillProjection> findSkillProjectionsByOccupationId( |
||||
|
@Param("occupationId") Long occupationId); |
||||
|
|
||||
|
@Transactional(readOnly = true) |
||||
|
@Query(""" |
||||
|
SELECT new com.mydreamjob.skill.dto.SkillCloudSkillProjection( |
||||
|
os.skillId, |
||||
|
s.code, |
||||
|
s.nameZh, |
||||
|
s.nameEn, |
||||
|
s.sourceType, |
||||
|
s.sourceCode, |
||||
|
c.id, |
||||
|
c.nameZh, |
||||
|
os.importance, |
||||
|
os.importanceSourceCode, |
||||
|
os.importanceElementId, |
||||
|
os.importanceIsProxy, |
||||
|
s.sortOrder, |
||||
|
c.sortOrder |
||||
|
) |
||||
|
FROM OccupationSkill os, Skill s, SkillCategory c |
||||
|
WHERE os.skillId = s.id |
||||
|
AND os.categoryId = c.id |
||||
|
AND c.occupationId = os.occupationId |
||||
|
AND os.occupationId = :occupationId |
||||
|
AND os.categoryId = :categoryId |
||||
|
""") |
||||
|
List<SkillCloudSkillProjection> findSkillProjectionsByOccupationIdAndCategoryId( |
||||
|
@Param("occupationId") Long occupationId, |
||||
|
@Param("categoryId") Long categoryId); |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
package com.mydreamjob.skill.repository; |
||||
|
|
||||
|
import com.mydreamjob.skill.domain.SkillAlias; |
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
|
||||
|
import java.util.Collection; |
||||
|
import java.util.List; |
||||
|
import java.util.Optional; |
||||
|
|
||||
|
public interface SkillAliasRepository extends JpaRepository<SkillAlias, Long> { |
||||
|
|
||||
|
Optional<SkillAlias> findByNormalizedAlias(String normalizedAlias); |
||||
|
|
||||
|
List<SkillAlias> findAllBySkillIdInOrderByIdAsc(Collection<Long> skillIds); |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
package com.mydreamjob.skill.repository; |
||||
|
|
||||
|
import com.mydreamjob.skill.domain.SkillCategory; |
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
|
||||
|
import java.util.Optional; |
||||
|
|
||||
|
public interface SkillCategoryRepository extends JpaRepository<SkillCategory, Long> { |
||||
|
|
||||
|
boolean existsByIdAndOccupationId(Long id, Long occupationId); |
||||
|
|
||||
|
Optional<SkillCategory> findByIdAndOccupationId(Long id, Long occupationId); |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.mydreamjob.skill.repository; |
||||
|
|
||||
|
import com.mydreamjob.skill.domain.SkillIndustry; |
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
|
||||
|
public interface SkillIndustryRepository extends JpaRepository<SkillIndustry, Long> { |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
package com.mydreamjob.skill.repository; |
||||
|
|
||||
|
import com.mydreamjob.skill.domain.SkillOccupation; |
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
|
||||
|
import java.util.Optional; |
||||
|
|
||||
|
public interface SkillOccupationRepository extends JpaRepository<SkillOccupation, Long> { |
||||
|
|
||||
|
boolean existsByIdAndIndustryId(Long id, Long industryId); |
||||
|
|
||||
|
Optional<SkillOccupation> findByIdAndIndustryId(Long id, Long industryId); |
||||
|
} |
||||
@ -0,0 +1,15 @@ |
|||||
|
package com.mydreamjob.skill.repository; |
||||
|
|
||||
|
import com.mydreamjob.skill.domain.Skill; |
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
|
||||
|
import java.util.Collection; |
||||
|
import java.util.List; |
||||
|
import java.util.Optional; |
||||
|
|
||||
|
public interface SkillRepository extends JpaRepository<Skill, Long> { |
||||
|
|
||||
|
Optional<Skill> findByCode(String code); |
||||
|
|
||||
|
List<Skill> findAllByIdInOrderByIdAsc(Collection<Long> ids); |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.mydreamjob.skill.repository; |
||||
|
|
||||
|
import com.mydreamjob.skill.domain.SkillSourceVersion; |
||||
|
import org.springframework.data.jpa.repository.JpaRepository; |
||||
|
|
||||
|
public interface SkillSourceVersionRepository extends JpaRepository<SkillSourceVersion, Long> { |
||||
|
} |
||||
@ -0,0 +1,120 @@ |
|||||
|
-- Flyway V47: 行业技能词云知识库表结构 |
||||
|
-- 来源与许可:ESCO v1.2.1 提供职位/技能/分类关系,O*NET 31.0 提供 Importance。 |
||||
|
-- 本迁移只建立结构,代表性种子数据由 V48 导入。 |
||||
|
|
||||
|
CREATE TABLE skill_source_versions ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
source_name VARCHAR(100) NOT NULL COMMENT '来源名称:ESCO/O*NET', |
||||
|
source_version VARCHAR(150) NOT NULL COMMENT '固定来源版本', |
||||
|
license VARCHAR(255) NOT NULL COMMENT '来源许可证或复用条件', |
||||
|
attribution_url VARCHAR(500) NOT NULL COMMENT '官方来源或归属地址', |
||||
|
imported_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '导入时间', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT uk_skill_source_versions_source UNIQUE (source_name, source_version) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词库来源版本'; |
||||
|
|
||||
|
CREATE TABLE skill_industries ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
code VARCHAR(80) NOT NULL COMMENT '项目行业稳定编码', |
||||
|
name_zh VARCHAR(120) NOT NULL COMMENT '中文行业名称', |
||||
|
source_code VARCHAR(120) COMMENT '上游或项目映射来源编码', |
||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT uk_skill_industries_code UNIQUE (code) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词云行业'; |
||||
|
|
||||
|
CREATE TABLE skill_occupations ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
industry_id BIGINT NOT NULL COMMENT '所属行业', |
||||
|
code VARCHAR(100) NOT NULL COMMENT '职位稳定编码', |
||||
|
name_zh VARCHAR(150) NOT NULL COMMENT '中文职位名称', |
||||
|
name_en VARCHAR(180) COMMENT '英文职位名称', |
||||
|
source_code VARCHAR(150) NOT NULL COMMENT 'ESCO 职位来源 ID', |
||||
|
source_version_id BIGINT NOT NULL COMMENT '来源版本', |
||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT uk_skill_occupations_industry_code UNIQUE (industry_id, code), |
||||
|
CONSTRAINT fk_skill_occupations_industry FOREIGN KEY (industry_id) REFERENCES skill_industries (id), |
||||
|
CONSTRAINT fk_skill_occupations_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='技能词云职位'; |
||||
|
|
||||
|
CREATE TABLE skill_categories ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
occupation_id BIGINT NOT NULL COMMENT '所属职位', |
||||
|
code VARCHAR(100) NOT NULL COMMENT '技能分类稳定编码', |
||||
|
name_zh VARCHAR(120) NOT NULL COMMENT '中文分类名称', |
||||
|
suggestion_template_code VARCHAR(30) NOT NULL COMMENT '学习建议模板编码', |
||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT uk_skill_categories_occupation_code UNIQUE (occupation_id, code), |
||||
|
CONSTRAINT ck_skill_categories_suggestion_template CHECK ( |
||||
|
suggestion_template_code IN ('PROGRAMMING_LANGUAGE', 'FRAMEWORK', 'TOOL', 'GENERAL') |
||||
|
), |
||||
|
CONSTRAINT fk_skill_categories_occupation FOREIGN KEY (occupation_id) REFERENCES skill_occupations (id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位技能分类'; |
||||
|
|
||||
|
CREATE TABLE skills ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
code VARCHAR(120) NOT NULL COMMENT '项目技能稳定编码', |
||||
|
name_zh VARCHAR(180) NOT NULL COMMENT '中文标准技能名称', |
||||
|
name_en VARCHAR(180) COMMENT '英文标准技能名称', |
||||
|
source_type VARCHAR(20) NOT NULL COMMENT '来源类型', |
||||
|
source_code VARCHAR(180) NOT NULL COMMENT 'ESCO/O*NET 或项目来源 ID', |
||||
|
source_version_id BIGINT NOT NULL COMMENT '来源版本', |
||||
|
sort_order INT NOT NULL DEFAULT 0 COMMENT '展示顺序', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT uk_skills_code UNIQUE (code), |
||||
|
CONSTRAINT uk_skills_source UNIQUE (source_type, source_code), |
||||
|
CONSTRAINT ck_skills_source_type CHECK (source_type IN ('ESCO', 'O*NET', 'PROJECT')), |
||||
|
CONSTRAINT fk_skills_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标准技能'; |
||||
|
|
||||
|
CREATE TABLE skill_aliases ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
skill_id BIGINT NOT NULL COMMENT '标准技能', |
||||
|
alias VARCHAR(180) NOT NULL COMMENT '技能别名', |
||||
|
normalized_alias VARCHAR(180) NOT NULL COMMENT '归一化技能别名', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT uk_skill_aliases_normalized_alias UNIQUE (normalized_alias), |
||||
|
CONSTRAINT uk_skill_aliases_skill_alias UNIQUE (skill_id, alias), |
||||
|
CONSTRAINT fk_skill_aliases_skill FOREIGN KEY (skill_id) REFERENCES skills (id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='标准技能别名'; |
||||
|
|
||||
|
CREATE TABLE occupation_skills ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
occupation_id BIGINT NOT NULL COMMENT '职位', |
||||
|
skill_id BIGINT NOT NULL COMMENT '标准技能', |
||||
|
category_id BIGINT NOT NULL COMMENT '职位内技能分类', |
||||
|
importance DECIMAL(4,2) COMMENT 'O*NET Importance,0-5,允许为空', |
||||
|
importance_source_code VARCHAR(20) COMMENT 'O*NET SOC 来源,非空热度必填', |
||||
|
importance_element_id VARCHAR(30) COMMENT 'O*NET Element ID,非空热度必填', |
||||
|
importance_is_proxy TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否为项目分类到 O*NET 元素的代理映射', |
||||
|
relationship_type VARCHAR(20) NOT NULL COMMENT '职位技能关系类型', |
||||
|
source_version_id BIGINT NOT NULL COMMENT 'Importance 来源版本', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT uk_occupation_skills_occupation_skill UNIQUE (occupation_id, skill_id), |
||||
|
CONSTRAINT ck_occupation_skills_importance CHECK ( |
||||
|
importance IS NULL OR (importance >= 0.00 AND importance <= 5.00) |
||||
|
), |
||||
|
CONSTRAINT ck_occupation_skills_importance_provenance CHECK ( |
||||
|
importance IS NULL OR (importance_source_code IS NOT NULL AND importance_element_id IS NOT NULL) |
||||
|
), |
||||
|
CONSTRAINT ck_occupation_skills_importance_proxy CHECK ( |
||||
|
importance_is_proxy IN (0, 1) |
||||
|
), |
||||
|
CONSTRAINT ck_occupation_skills_relationship_type CHECK ( |
||||
|
relationship_type IN ('ESSENTIAL', 'OPTIONAL', 'PROJECT') |
||||
|
), |
||||
|
CONSTRAINT fk_occupation_skills_occupation FOREIGN KEY (occupation_id) REFERENCES skill_occupations (id), |
||||
|
CONSTRAINT fk_occupation_skills_skill FOREIGN KEY (skill_id) REFERENCES skills (id), |
||||
|
CONSTRAINT fk_occupation_skills_category FOREIGN KEY (category_id) REFERENCES skill_categories (id), |
||||
|
CONSTRAINT fk_occupation_skills_source_version FOREIGN KEY (source_version_id) REFERENCES skill_source_versions (id), |
||||
|
KEY idx_occupation_skills_occupation_category (occupation_id, category_id), |
||||
|
KEY idx_occupation_skills_occupation_importance (occupation_id, importance) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='职位技能关系与热度'; |
||||
@ -0,0 +1,413 @@ |
|||||
|
-- Flyway V48: ESCO/O*NET 代表性行业技能种子数据 |
||||
|
-- 本迁移只导入互联网、金融、教育三类行业及 10 个代表性职位。 |
||||
|
-- ESCO 负责职位、通用技能和分类主关系;O*NET software_skills 仅补充 ESCO 缺失的具体技术。 |
||||
|
-- O*NET software_skills.source_code 格式:SOC|Workplace Example|Element ID,均来自本地 O*NET 31.0 CSV 的真实行。 |
||||
|
-- |
||||
|
-- ESCO 职位映射: |
||||
|
-- FRONTEND_DEVELOPER -> c40a2919-48a9-40ea-b506-1f34f693496d |
||||
|
-- BACKEND_DEVELOPER -> f2b15a0e-e65a-438a-affb-29b9d50b77d1 |
||||
|
-- QA_ENGINEER -> 106f79e4-6264-45f1-9e7a-297435cd684b |
||||
|
-- DEVOPS_ENGINEER -> 使用后端软件开发者 ESCO URI 作为项目代理(ESCO 无更近 DevOps 节点) |
||||
|
-- FINANCIAL_DATA_ANALYST -> 8d586ee9-0ab1-4155-a3b0-2ca786c8e48c |
||||
|
-- RISK_MODELING_ENGINEER -> b09acdda-6680-49d1-a411-129610ee3bc0 |
||||
|
-- QUANTITATIVE_DEVELOPER -> 使用软件开发者 ESCO URI 作为项目代理(ESCO 无更近量化开发节点) |
||||
|
-- EDUCATION_PRODUCT_MANAGER -> a79170ad-0b7b-4697-864f-1748359aa10a |
||||
|
-- INSTRUCTIONAL_DESIGNER -> a9c30651-ccbc-4a80-900c-7880615cdf6e |
||||
|
-- EDUCATION_DATA_ANALYST -> d3edb8f8-3a06-47a0-8fb9-9b212c006aa2 |
||||
|
-- |
||||
|
-- O*NET Importance 代理 SOC: |
||||
|
-- FRONTEND_DEVELOPER -> 15-1254.00 Web Developers |
||||
|
-- BACKEND_DEVELOPER -> 15-1252.00 Software Developers |
||||
|
-- QA_ENGINEER -> 15-1253.00 Software Quality Assurance Analysts and Testers |
||||
|
-- DEVOPS_ENGINEER -> 15-1244.00 Network and Computer Systems Administrators |
||||
|
-- FINANCIAL_DATA_ANALYST -> 13-2031.00 Budget Analysts(目标金融分析师 SOC 在本地 O*NET 31.0 Importance CSV 中无数据,故使用语义较近的预算分析师代理) |
||||
|
-- RISK_MODELING_ENGINEER -> 13-2053.00 Insurance Underwriters(目标金融风险专家 SOC 在本地 Importance CSV 中无数据,故使用包含风险决策活动的保险承保人代理) |
||||
|
-- QUANTITATIVE_DEVELOPER -> 13-2099.01 Financial Quantitative Analysts |
||||
|
-- EDUCATION_PRODUCT_MANAGER -> 13-1082.00 Project Management Specialists(无直接教育产品经理 SOC,使用项目管理专家代理) |
||||
|
-- INSTRUCTIONAL_DESIGNER -> 25-9031.00 Instructional Coordinators |
||||
|
-- EDUCATION_DATA_ANALYST -> 15-2051.00 Data Scientists |
||||
|
-- |
||||
|
-- 分类代理规则(importance_is_proxy=1): |
||||
|
-- TECH=4.A.3.b.1 Working with Computers;PROCESS=4.A.2.a.2 Processing Information; |
||||
|
-- ANALYSIS=4.A.2.a.4 Analyzing Data or Information;COMPLIANCE=4.A.2.a.3 Evaluating Information to Determine Compliance with Standards; |
||||
|
-- DESIGN=4.A.2.b.2 Thinking Creatively;STRATEGY=4.A.2.b.4 Developing Objectives and Strategies; |
||||
|
-- MONITOR=4.A.1.a.2 Monitoring Processes, Materials, or Surroundings;ESTIMATE=4.A.1.b.3 Estimating the Quantifiable Characteristics of Products, Events, or Information; |
||||
|
-- COMM=4.A.4.a.2 Communicating with Supervisors, Peers, or Subordinates;EDU=2.C.6 Education and Training; |
||||
|
-- FINANCE=2.C.1.c Economics and Accounting;MATH=2.C.4.a Mathematics;SECURITY=2.C.8.a Public Safety and Security。 |
||||
|
-- 每条非空 importance 均保存上述代理 SOC、Element ID 和 importance_is_proxy=1,可由本地 O*NET CSV 逐条复核。 |
||||
|
-- 无法稳定映射 Importance 的技能保留关系并写 NULL,不伪造热度。 |
||||
|
-- |
||||
|
INSERT INTO skill_source_versions (id, source_name, source_version, license, attribution_url, imported_at) VALUES |
||||
|
(1, 'ESCO', '1.2.1 (Release v5.7.0, Last update 10/12/2025)', 'Commission Decision 2011/833/EU', 'https://esco.ec.europa.eu/en/use-esco/download', CURRENT_TIMESTAMP), |
||||
|
(2, 'O*NET', '31.0 Database', 'CC BY 4.0', 'https://www.onetcenter.org/database.html', CURRENT_TIMESTAMP); |
||||
|
INSERT INTO skill_industries (id, code, name_zh, source_code, sort_order) VALUES |
||||
|
(1, 'INTERNET', '互联网', 'PROJECT:INDUSTRY:INTERNET', 1), |
||||
|
(2, 'FINANCE', '金融', 'PROJECT:INDUSTRY:FINANCE', 2), |
||||
|
(3, 'EDUCATION', '教育', 'PROJECT:INDUSTRY:EDUCATION', 3); |
||||
|
-- DevOps 与量化开发没有更近的 ESCO 职位节点,source_code 显式复用软件开发者 URI;这是项目代理映射,不是上游原始职位关系。 |
||||
|
INSERT INTO skill_occupations (id, industry_id, code, name_zh, name_en, source_code, source_version_id, sort_order) VALUES |
||||
|
(1, 1, 'FRONTEND_DEVELOPER', '前端开发工程师', 'Frontend Developer', 'http://data.europa.eu/esco/occupation/c40a2919-48a9-40ea-b506-1f34f693496d', 1, 1), |
||||
|
(2, 1, 'BACKEND_DEVELOPER', '后端开发工程师', 'Backend Developer', 'http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1', 1, 2), |
||||
|
(3, 1, 'QA_ENGINEER', '测试工程师', 'Software QA Engineer', 'http://data.europa.eu/esco/occupation/106f79e4-6264-45f1-9e7a-297435cd684b', 1, 3), |
||||
|
(4, 1, 'DEVOPS_ENGINEER', 'DevOps 工程师', 'DevOps Engineer', 'http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1', 1, 4), |
||||
|
(5, 2, 'FINANCIAL_DATA_ANALYST', '金融数据分析师', 'Financial Data Analyst', 'http://data.europa.eu/esco/occupation/8d586ee9-0ab1-4155-a3b0-2ca786c8e48c', 1, 1), |
||||
|
(6, 2, 'RISK_MODELING_ENGINEER', '风控建模工程师', 'Risk Modeling Engineer', 'http://data.europa.eu/esco/occupation/b09acdda-6680-49d1-a411-129610ee3bc0', 1, 2), |
||||
|
(7, 2, 'QUANTITATIVE_DEVELOPER', '量化开发工程师', 'Quantitative Developer', 'http://data.europa.eu/esco/occupation/f2b15a0e-e65a-438a-affb-29b9d50b77d1', 1, 3), |
||||
|
(8, 3, 'EDUCATION_PRODUCT_MANAGER', '教育产品经理', 'Education Product Manager', 'http://data.europa.eu/esco/occupation/a79170ad-0b7b-4697-864f-1748359aa10a', 1, 1), |
||||
|
(9, 3, 'INSTRUCTIONAL_DESIGNER', '教学设计师', 'Instructional Designer', 'http://data.europa.eu/esco/occupation/a9c30651-ccbc-4a80-900c-7880615cdf6e', 1, 2), |
||||
|
(10, 3, 'EDUCATION_DATA_ANALYST', '教育数据分析师', 'Education Data Analyst', 'http://data.europa.eu/esco/occupation/d3edb8f8-3a06-47a0-8fb9-9b212c006aa2', 1, 3); |
||||
|
INSERT INTO skill_categories (id, occupation_id, code, name_zh, suggestion_template_code, sort_order) VALUES |
||||
|
(101, 1, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), |
||||
|
(102, 1, 'WEB_BASIC', 'Web 基础', 'FRAMEWORK', 2), |
||||
|
(103, 1, 'FRAMEWORK', '前端框架', 'FRAMEWORK', 3), |
||||
|
(104, 1, 'TOOLS', '工程化工具', 'TOOL', 4), |
||||
|
(105, 1, 'BROWSER_DEBUG', '浏览器与调试', 'TOOL', 5), |
||||
|
(106, 1, 'TEST', '测试', 'GENERAL', 6), |
||||
|
(107, 1, 'COLLABORATION', '版本控制与协作', 'GENERAL', 7), |
||||
|
(201, 2, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), |
||||
|
(202, 2, 'FRAMEWORK', '后端框架与平台', 'FRAMEWORK', 2), |
||||
|
(203, 2, 'DATABASE', '数据库', 'TOOL', 3), |
||||
|
(204, 2, 'CACHE_MESSAGING', '缓存与消息', 'TOOL', 4), |
||||
|
(205, 2, 'CLOUD_DEVOPS', '云/DevOps', 'TOOL', 5), |
||||
|
(206, 2, 'TEST', '测试', 'GENERAL', 6), |
||||
|
(207, 2, 'TOOLS', '工程化工具', 'TOOL', 7), |
||||
|
(208, 2, 'SECURITY', '安全', 'GENERAL', 8), |
||||
|
(301, 3, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), |
||||
|
(302, 3, 'TEST_DESIGN', '测试设计', 'GENERAL', 2), |
||||
|
(303, 3, 'AUTOMATION_TEST', '自动化测试框架', 'FRAMEWORK', 3), |
||||
|
(304, 3, 'API_DATABASE_TEST', '接口与数据库测试', 'TOOL', 4), |
||||
|
(305, 3, 'PERFORMANCE_TEST', '性能测试', 'TOOL', 5), |
||||
|
(306, 3, 'CICD_TOOLS', 'CI/CD 与工程化工具', 'TOOL', 6), |
||||
|
(401, 4, 'LANGUAGE_SCRIPT', '编程语言与脚本', 'PROGRAMMING_LANGUAGE', 1), |
||||
|
(402, 4, 'CLOUD', '云平台', 'TOOL', 2), |
||||
|
(403, 4, 'CONTAINER', '容器与编排', 'TOOL', 3), |
||||
|
(404, 4, 'CICD', 'CI/CD', 'TOOL', 4), |
||||
|
(405, 4, 'IAC', '配置管理与 IaC', 'TOOL', 5), |
||||
|
(406, 4, 'OBSERVABILITY', '可观测性', 'TOOL', 6), |
||||
|
(407, 4, 'SECURITY', '安全', 'GENERAL', 7), |
||||
|
(408, 4, 'DATABASE', '数据库', 'TOOL', 8), |
||||
|
(501, 5, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), |
||||
|
(502, 5, 'ANALYSIS_VISUALIZATION', '数据分析与可视化', 'TOOL', 2), |
||||
|
(503, 5, 'DATABASE_WAREHOUSE', '数据库与数据仓库', 'TOOL', 3), |
||||
|
(504, 5, 'STATISTICS_MODELING', '统计与建模', 'FRAMEWORK', 4), |
||||
|
(505, 5, 'FINANCE_PLATFORM', '金融计算框架/平台', 'FRAMEWORK', 5), |
||||
|
(506, 5, 'FINANCE_DOMAIN', '金融领域知识', 'GENERAL', 6), |
||||
|
(507, 5, 'TOOLS', '工程化工具', 'TOOL', 7), |
||||
|
(601, 6, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), |
||||
|
(602, 6, 'STATISTICS_MODELING', '统计与建模', 'FRAMEWORK', 2), |
||||
|
(603, 6, 'RISK_MODEL', '风险模型', 'GENERAL', 3), |
||||
|
(604, 6, 'MODEL_PLATFORM', '建模框架/平台', 'FRAMEWORK', 4), |
||||
|
(605, 6, 'DATA_DATABASE', '数据处理与数据库', 'TOOL', 5), |
||||
|
(606, 6, 'MODEL_VALIDATION', '模型验证', 'GENERAL', 6), |
||||
|
(607, 6, 'DEPLOY_TOOLS', '部署与工程化工具', 'TOOL', 7), |
||||
|
(701, 7, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), |
||||
|
(702, 7, 'QUANT_PLATFORM', '量化建模框架/平台', 'FRAMEWORK', 2), |
||||
|
(703, 7, 'MARKET_DATA', '市场与金融数据', 'TOOL', 3), |
||||
|
(704, 7, 'DATABASE', '数据库', 'TOOL', 4), |
||||
|
(705, 7, 'PERFORMANCE', '低延迟与高性能工程', 'TOOL', 5), |
||||
|
(706, 7, 'TEST', '测试', 'GENERAL', 6), |
||||
|
(707, 7, 'CLOUD_DEVOPS', '云/DevOps', 'TOOL', 7), |
||||
|
(801, 8, 'EDU_DOMAIN', '教育领域知识', 'GENERAL', 1), |
||||
|
(802, 8, 'PRODUCT_ANALYSIS', '产品分析方法', 'TOOL', 2), |
||||
|
(803, 8, 'REQUIREMENTS_PROCESS', '需求与流程', 'GENERAL', 3), |
||||
|
(804, 8, 'DATA_ANALYSIS', '数据分析', 'TOOL', 4), |
||||
|
(805, 8, 'EDU_PLATFORM', '教育平台与工具', 'TOOL', 5), |
||||
|
(806, 8, 'COLLAB_TOOLS', '协作与工程化工具', 'GENERAL', 6), |
||||
|
(901, 9, 'INSTRUCTIONAL_DESIGN', '教学设计', 'FRAMEWORK', 1), |
||||
|
(902, 9, 'COURSE_PLATFORM', '课程与学习平台', 'TOOL', 2), |
||||
|
(903, 9, 'DIGITAL_CONTENT', '数字化内容工具', 'TOOL', 3), |
||||
|
(904, 9, 'ASSESSMENT', '评估设计', 'GENERAL', 4), |
||||
|
(905, 9, 'LEARNING_ANALYSIS', '学习数据分析', 'TOOL', 5), |
||||
|
(906, 9, 'ACCESSIBILITY', '无障碍与规范', 'GENERAL', 6), |
||||
|
(1001, 10, 'LANGUAGE', '编程语言', 'PROGRAMMING_LANGUAGE', 1), |
||||
|
(1002, 10, 'ANALYSIS_VISUALIZATION', '数据分析与可视化', 'TOOL', 2), |
||||
|
(1003, 10, 'DATABASE_SQL', '数据库与 SQL', 'TOOL', 3), |
||||
|
(1004, 10, 'STATISTICS_MODELING', '统计与建模', 'FRAMEWORK', 4), |
||||
|
(1005, 10, 'EDU_DOMAIN', '教育领域知识', 'GENERAL', 5), |
||||
|
(1006, 10, 'REPORTING_TOOLS', '报表与工程化工具', 'TOOL', 6); |
||||
|
INSERT INTO skills (id, code, name_zh, name_en, source_type, source_code, source_version_id, sort_order) VALUES |
||||
|
(1, 'JAVA', 'Java', 'Java (computer programming)', 'ESCO', 'http://data.europa.eu/esco/skill/19a8293b-8e95-4de3-983f-77484079c389', 1, 1), |
||||
|
(2, 'PYTHON', 'Python', 'Python (computer programming)', 'ESCO', 'http://data.europa.eu/esco/skill/ccd0a1d9-afda-43d9-b901-96344886e14d', 1, 2), |
||||
|
(3, 'JAVASCRIPT', 'JavaScript', 'JavaScript', 'ESCO', 'http://data.europa.eu/esco/skill/3cd569a2-4f88-4c1e-9995-8dce8c5e51a7', 1, 3), |
||||
|
(4, 'TYPESCRIPT', 'TypeScript', 'TypeScript', 'ESCO', 'http://data.europa.eu/esco/skill/867137fb-ff1b-4ca3-99f3-cb6969aa2c68', 1, 4), |
||||
|
(5, 'SQL', 'SQL', 'SQL', 'ESCO', 'http://data.europa.eu/esco/skill/598de5b0-5b58-4ea7-8058-a4bc4d18c742', 1, 5), |
||||
|
(6, 'CSHARP', 'C#', 'C#', 'ESCO', 'http://data.europa.eu/esco/skill/4c016b68-4116-468c-9dc6-42710c239e4a', 1, 6), |
||||
|
(7, 'HTML', 'HTML', 'use markup languages', 'ESCO', 'http://data.europa.eu/esco/skill/0af062de-eb43-41e9-9b96-249e2cd22d26', 1, 7), |
||||
|
(8, 'CSS', 'CSS', 'CSS', 'ESCO', 'http://data.europa.eu/esco/skill/e5d1f825-60ed-4bdd-872a-e748c387f777', 1, 8), |
||||
|
(9, 'R', 'R', 'R', 'ESCO', 'http://data.europa.eu/esco/skill/51586df8-1c46-4b47-8583-773cb63bf00b', 1, 9), |
||||
|
(10, 'MYSQL', 'MySQL', 'MySQL', 'ESCO', 'http://data.europa.eu/esco/skill/4da171e5-779c-4983-a76f-91c16751e99f', 1, 10), |
||||
|
(11, 'POSTGRESQL', 'PostgreSQL', 'PostgreSQL', 'ESCO', 'http://data.europa.eu/esco/skill/a8d07b5a-c1a1-42c6-9d53-db9c7a2ca996', 1, 11), |
||||
|
(12, 'STATISTICS', '统计学', 'statistics', 'ESCO', 'http://data.europa.eu/esco/skill/7ee4c2ea-b349-4bd2-81a3-ec31475d4833', 1, 12), |
||||
|
(13, 'MACHINE_LEARNING', '机器学习', 'utilise machine learning', 'ESCO', 'http://data.europa.eu/esco/skill/8369c2d6-c100-4cf6-bd83-9668d8678433', 1, 13), |
||||
|
(14, 'FINANCIAL_ANALYSIS', '金融分析', 'financial analysis', 'ESCO', 'http://data.europa.eu/esco/skill/99571e68-801f-49af-a897-5f75996642e1', 1, 14), |
||||
|
(15, 'RISK_MANAGEMENT', '风险管理', 'risk management', 'ESCO', 'http://data.europa.eu/esco/skill/6eff134b-e34f-4d6e-a6e8-5e47cf2228d0', 1, 15), |
||||
|
(16, 'INSTRUCTIONAL_DESIGN', '教学设计', 'instructional design models', 'ESCO', 'http://data.europa.eu/esco/skill/e3145bd7-7f35-44ec-a5dc-4417c5c9ab9a', 1, 16), |
||||
|
(17, 'CURRICULUM_DEVELOPMENT', '课程开发', 'advise on curriculum development', 'ESCO', 'http://data.europa.eu/esco/skill/142a5319-60cb-42a2-891f-90ba92af18b1', 1, 17), |
||||
|
(18, 'LEARNING_MANAGEMENT_SYSTEMS', '学习管理系统', 'learning management systems', 'ESCO', 'http://data.europa.eu/esco/skill/6c80d53c-d8c9-41fe-998f-091fca208834', 1, 18), |
||||
|
(19, 'EDUCATIONAL_TECHNOLOGY', '教育技术', 'develop e-learning plan', 'ESCO', 'http://data.europa.eu/esco/skill/8a6394d1-f44f-4b0d-81d5-7201fc025ba8', 1, 19), |
||||
|
(20, 'COMMUNICATION', '沟通能力', 'communication', 'ESCO', 'http://data.europa.eu/esco/skill/15d76317-c71a-4fa2-aadc-2ecc34e627b7', 1, 20), |
||||
|
(21, 'CRITICAL_THINKING', '批判性思维', 'use logical reasoning', 'ESCO', 'http://data.europa.eu/esco/skill/a9e6718b-b9d8-44c2-b06e-85ceed9d144c', 1, 21), |
||||
|
(22, 'COLLABORATION', '团队协作', 'cooperate with colleagues', 'ESCO', 'http://data.europa.eu/esco/skill/082967d4-b94c-4305-aa13-35a6aa8c5c7c', 1, 22), |
||||
|
(23, 'PROJECT_MANAGEMENT', '项目管理', 'project management', 'ESCO', 'http://data.europa.eu/esco/skill/7111b95d-0ce3-441a-9d92-4c75d05c4388', 1, 23), |
||||
|
(24, 'AGILE', '敏捷开发', 'Agile development', 'ESCO', 'http://data.europa.eu/esco/skill/dba46f87-0831-49cd-a1c7-340a653c0221', 1, 24), |
||||
|
(25, 'JENKINS', 'Jenkins', 'Jenkins (tools for software configuration management)', 'ESCO', 'http://data.europa.eu/esco/skill/f47a1998-0beb-43be-9f46-380aa4d183da', 1, 25), |
||||
|
(26, 'REACT', 'React', 'React', 'O*NET', '15-1254.00|React|2.E.7.h', 2, 26), |
||||
|
(27, 'VUE_JS', 'Vue.js', 'Vue.js', 'O*NET', '15-1254.00|Vue.js|2.E.7.h', 2, 27), |
||||
|
(28, 'NODE_JS', 'Node.js', 'Node.js', 'O*NET', '15-1254.00|Node.js|2.E.7.h', 2, 28), |
||||
|
(29, 'SPRING_BOOT', 'Spring Boot', 'Spring Boot', 'O*NET', '15-1252.00|Spring Boot|2.E.10.a', 2, 29), |
||||
|
(30, 'DJANGO', 'Django', 'Django', 'O*NET', '15-1252.00|Django|2.E.7.h', 2, 30), |
||||
|
(31, 'FLASK', 'Flask', 'Flask', 'O*NET', '15-2051.00|Flask|2.E.7.b', 2, 31), |
||||
|
(32, 'GIT', 'Git', 'Git', 'O*NET', '15-1252.00|Git|2.E.5.c', 2, 32), |
||||
|
(33, 'DOCKER', 'Docker', 'Docker', 'O*NET', '15-1252.00|Docker|2.E.10.a', 2, 33), |
||||
|
(34, 'KUBERNETES', 'Kubernetes', 'Kubernetes', 'O*NET', '15-1252.00|Kubernetes|2.E.10.a', 2, 34), |
||||
|
(35, 'GRADLE', 'Gradle', 'Gradle', 'O*NET', '15-1252.00|Gradle|2.E.7.e', 2, 35), |
||||
|
(36, 'MONGODB', 'MongoDB', 'MongoDB', 'O*NET', '15-1252.00|MongoDB|2.E.6.d', 2, 36), |
||||
|
(37, 'REDIS', 'Redis', 'Redis', 'O*NET', '15-1252.00|Redis|2.E.6.d', 2, 37), |
||||
|
(38, 'SELENIUM', 'Selenium', 'Selenium', 'O*NET', '15-1253.00|Selenium|2.E.7.f', 2, 38), |
||||
|
(39, 'JUNIT', 'JUnit', 'JUnit', 'O*NET', '15-1253.00|JUnit|2.E.7.f', 2, 39), |
||||
|
(40, 'POSTMAN', 'Postman', 'Postman', 'O*NET', '15-1253.00|Postman|2.E.13.c', 2, 40), |
||||
|
(41, 'PANDAS', 'pandas', 'pandas', 'O*NET', '15-2051.00|pandas|2.E.6.f', 2, 41), |
||||
|
(42, 'NUMPY', 'NumPy', 'NumPy', 'O*NET', '15-2051.00|NumPy|2.E.6.f', 2, 42), |
||||
|
(43, 'TABLEAU', 'Tableau', 'Tableau', 'O*NET', '15-2051.00|Tableau|2.E.6.m', 2, 43), |
||||
|
(44, 'POWER_BI', 'Microsoft Power BI', 'Microsoft Power BI', 'O*NET', '15-2051.00|Microsoft Power BI|2.E.6.m', 2, 44), |
||||
|
(45, 'MICROSOFT_EXCEL', 'Microsoft Excel', 'Microsoft Excel', 'O*NET', '15-2051.00|Microsoft Excel|2.E.4.i', 2, 45), |
||||
|
(46, 'TENSORFLOW', 'TensorFlow', 'TensorFlow', 'O*NET', '15-2051.00|TensorFlow|2.E.9.d', 2, 46), |
||||
|
(47, 'PYTORCH', 'PyTorch', 'PyTorch', 'O*NET', '15-2051.00|PyTorch|2.E.6.f', 2, 47), |
||||
|
(48, 'SCIKIT_LEARN', 'Scikit-learn', 'Scikit-learn', 'O*NET', '15-2051.00|Scikit-learn|2.E.7.b', 2, 48), |
||||
|
(49, 'APACHE_SPARK', 'Apache Spark', 'Apache Spark', 'O*NET', '15-2051.00|Apache Spark|2.E.6.m', 2, 49), |
||||
|
(50, 'FIGMA', 'Figma', 'Figma', 'O*NET', '15-1254.00|Figma|2.E.7.d', 2, 50), |
||||
|
(51, 'BASH', 'Bash', 'Bash', 'O*NET', '15-1252.00|Bash|2.E.13.c', 2, 51), |
||||
|
(52, 'GO', 'Go', 'Go', 'O*NET', '15-1252.00|Go|2.E.7.b', 2, 52), |
||||
|
(53, 'CPP', 'C++', 'C++', 'O*NET', '15-1252.00|C++|2.E.7.e', 2, 53), |
||||
|
(54, 'GOOGLE_CHROME', 'Google Chrome', 'Google Chrome', 'O*NET', '17-2051.02|Google Chrome|2.E.10.e', 2, 54), |
||||
|
(55, 'DEBUGGING_SOFTWARE', '调试软件', 'Debugging software', 'O*NET', '17-2071.00|Debugging software|2.E.7.f', 2, 55), |
||||
|
(56, 'APACHE_KAFKA', 'Apache Kafka', 'Apache Kafka', 'O*NET', '15-1299.07|Apache Kafka|2.E.7.b', 2, 56), |
||||
|
(57, 'SONARQUBE', 'SonarQube', 'SonarQube', 'O*NET', '15-1252.00|SonarQube|2.E.7.f', 2, 57), |
||||
|
(58, 'CHEF', 'Chef', 'Chef', 'O*NET', '15-1255.00|Chef|2.E.7.a', 2, 58), |
||||
|
(59, 'GITLAB', 'GitLab', 'GitLab', 'O*NET', '15-1299.08|GitLab|2.E.10.a', 2, 59), |
||||
|
(60, 'NAGIOS', 'Nagios', 'Nagios', 'O*NET', '11-3021.00|Nagios|2.E.11.a', 2, 60), |
||||
|
(61, 'NMAP', 'Nmap', 'Nmap', 'O*NET', '13-1199.07|Nmap|2.E.14.d', 2, 61), |
||||
|
(62, 'BLOOMBERG_PROFESSIONAL', 'Bloomberg Professional', 'Bloomberg Professional', 'O*NET', '41-3031.00|Bloomberg Professional|2.E.2.d', 2, 62), |
||||
|
(63, 'MOODLE', 'Moodle', 'Moodle', 'O*NET', '25-2022.00|Moodle|2.E.8.b', 2, 63), |
||||
|
(64, 'ARTICULATE_STORYLINE', 'Articulate Storyline', 'Articulate Storyline', 'O*NET', '25-9031.00|Articulate Storyline|2.E.8.b', 2, 64), |
||||
|
(65, 'MICROSOFT_POWERPOINT', 'Microsoft PowerPoint', 'Microsoft PowerPoint', 'O*NET', '27-4012.00|Microsoft PowerPoint|2.E.4.f', 2, 65), |
||||
|
(66, 'AXURE_RP', 'Axure RP', 'Axure RP', 'O*NET', '15-1255.00|Axure RP|2.E.4.g', 2, 66), |
||||
|
(67, 'ADOBE_PHOTOSHOP', 'Adobe Photoshop', 'Adobe Photoshop', 'O*NET', '19-3092.00|Adobe Photoshop|2.E.4.b', 2, 67), |
||||
|
(68, 'APACHE_JMETER', 'Apache JMeter', 'Apache JMeter', 'O*NET', '15-1253.00|Apache JMeter|2.E.7.e', 2, 68), |
||||
|
(69, 'ADOBE_ACROBAT', 'Adobe Acrobat', 'Adobe Acrobat', 'O*NET', '11-1011.00|Adobe Acrobat|2.E.5.b', 2, 69), |
||||
|
(70, 'MICROSOFT_AZURE', 'Microsoft Azure', 'Microsoft Azure software', 'O*NET', '41-4011.00|Microsoft Azure software|2.E.7.b', 2, 70), |
||||
|
(71, 'AWS', 'AWS', 'Amazon Web Services AWS software', 'O*NET', '11-1021.00|Amazon Web Services AWS software|2.E.6.f', 2, 71), |
||||
|
(72, 'WIRESHARK', 'Wireshark', 'Wireshark', 'O*NET', '15-1241.01|Wireshark|2.E.11.a', 2, 72); |
||||
|
INSERT INTO skill_aliases (id, skill_id, alias, normalized_alias) VALUES |
||||
|
(1, 1, 'Java (computer programming)', 'java (computer programming)'), |
||||
|
(2, 2, 'Python (computer programming)', 'python (computer programming)'), |
||||
|
(3, 3, 'JavaScript', 'javascript'), |
||||
|
(4, 4, 'TypeScript', 'typescript'), |
||||
|
(5, 5, 'SQL', 'sql'), |
||||
|
(6, 6, 'C#', 'c#'), |
||||
|
(7, 7, 'HTML', 'html'), |
||||
|
(8, 8, 'CSS', 'css'), |
||||
|
(9, 9, 'R', 'r'), |
||||
|
(10, 10, 'MySQL', 'mysql'), |
||||
|
(11, 11, 'PostgreSQL', 'postgresql'), |
||||
|
(12, 12, 'statistics', 'statistics'), |
||||
|
(13, 13, 'machine learning', 'machine learning'), |
||||
|
(14, 14, 'financial analysis', 'financial analysis'), |
||||
|
(15, 15, 'risk management', 'risk management'), |
||||
|
(16, 16, 'instructional design', 'instructional design'), |
||||
|
(17, 17, 'curriculum development', 'curriculum development'), |
||||
|
(18, 18, 'learning management systems', 'learning management systems'), |
||||
|
(19, 19, 'educational technology', 'educational technology'), |
||||
|
(20, 20, 'communication', 'communication'), |
||||
|
(21, 21, 'critical thinking', 'critical thinking'), |
||||
|
(22, 22, 'collaboration', 'collaboration'), |
||||
|
(23, 23, 'project management', 'project management'), |
||||
|
(24, 24, 'agile', 'agile'), |
||||
|
(25, 25, 'Jenkins (tools for software configuration management)', 'jenkins (tools for software configuration management)'), |
||||
|
(26, 26, 'React', 'react'), |
||||
|
(27, 27, 'Vue.js', 'vuejs'), |
||||
|
(28, 28, 'Node.js', 'nodejs'), |
||||
|
(29, 29, 'Spring Boot', 'spring boot'), |
||||
|
(30, 30, 'Django', 'django'), |
||||
|
(31, 31, 'Flask', 'flask'), |
||||
|
(32, 32, 'Git', 'git'), |
||||
|
(33, 33, 'Docker', 'docker'), |
||||
|
(34, 34, 'Kubernetes', 'kubernetes'), |
||||
|
(35, 35, 'Gradle', 'gradle'), |
||||
|
(36, 36, 'MongoDB', 'mongodb'), |
||||
|
(37, 37, 'Redis', 'redis'), |
||||
|
(38, 38, 'Selenium', 'selenium'), |
||||
|
(39, 39, 'JUnit', 'junit'), |
||||
|
(40, 40, 'Postman', 'postman'), |
||||
|
(41, 41, 'pandas', 'pandas'), |
||||
|
(42, 42, 'NumPy', 'numpy'), |
||||
|
(43, 43, 'Tableau', 'tableau'), |
||||
|
(44, 44, 'Microsoft Power BI', 'microsoft power bi'), |
||||
|
(45, 45, 'Microsoft Excel', 'microsoft excel'), |
||||
|
(46, 46, 'TensorFlow', 'tensorflow'), |
||||
|
(47, 47, 'PyTorch', 'pytorch'), |
||||
|
(48, 48, 'scikit-learn', 'scikit learn'), |
||||
|
(49, 49, 'Apache Spark', 'apache spark'), |
||||
|
(50, 50, 'Figma', 'figma'), |
||||
|
(51, 51, 'Bash', 'bash'), |
||||
|
(52, 52, 'Go', 'go'), |
||||
|
(53, 53, 'C++', 'c++'), |
||||
|
(54, 54, 'Google Chrome', 'google chrome'), |
||||
|
(55, 55, 'debugging software', 'debugging software'), |
||||
|
(56, 56, 'Apache Kafka', 'apache kafka'), |
||||
|
(57, 57, 'SonarQube', 'sonarqube'), |
||||
|
(58, 58, 'Chef', 'chef'), |
||||
|
(59, 59, 'GitLab', 'gitlab'), |
||||
|
(60, 60, 'Nagios', 'nagios'), |
||||
|
(61, 61, 'Nmap', 'nmap'), |
||||
|
(62, 62, 'Bloomberg Professional', 'bloomberg professional'), |
||||
|
(63, 63, 'Moodle', 'moodle'), |
||||
|
(64, 64, 'Articulate Storyline', 'articulate storyline'), |
||||
|
(65, 65, 'Microsoft PowerPoint', 'microsoft powerpoint'), |
||||
|
(66, 66, 'Axure RP', 'axure rp'), |
||||
|
(67, 67, 'Adobe Photoshop', 'adobe photoshop'), |
||||
|
(68, 68, 'Apache JMeter', 'apache jmeter'), |
||||
|
(69, 69, 'Adobe Acrobat', 'adobe acrobat'), |
||||
|
(70, 70, 'Microsoft Azure software', 'microsoft azure software'), |
||||
|
(71, 71, 'Amazon Web Services AWS software', 'amazon web services aws software'), |
||||
|
(72, 72, 'Wireshark', 'wireshark'); |
||||
|
INSERT INTO occupation_skills (id, occupation_id, skill_id, category_id, importance, importance_source_code, importance_element_id, importance_is_proxy, relationship_type, source_version_id) VALUES |
||||
|
(1, 1, 3, 101, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(2, 1, 4, 101, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(3, 1, 7, 102, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(4, 1, 8, 102, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(5, 1, 26, 103, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(6, 1, 27, 103, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(7, 1, 28, 104, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(8, 1, 35, 104, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(9, 1, 54, 105, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(10, 1, 55, 105, 4.88, '15-1254.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(11, 1, 38, 106, 3.33, '15-1254.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(12, 1, 39, 106, 3.33, '15-1254.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(13, 1, 32, 107, 3.88, '15-1254.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(14, 1, 24, 107, 3.88, '15-1254.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(15, 2, 1, 201, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(16, 2, 2, 201, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(17, 2, 29, 202, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(18, 2, 30, 202, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(19, 2, 10, 203, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(20, 2, 11, 203, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(21, 2, 37, 204, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(22, 2, 56, 204, 4.38, '15-1252.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(23, 2, 33, 205, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(24, 2, 34, 205, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(25, 2, 39, 206, 3.94, '15-1252.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(26, 2, 40, 206, 3.94, '15-1252.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(27, 2, 32, 207, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(28, 2, 35, 207, 4.61, '15-1252.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(29, 2, 61, 208, 2.57, '15-1252.00', '2.C.8.a', 1, 'ESSENTIAL', 2), |
||||
|
(30, 2, 57, 208, 2.57, '15-1252.00', '2.C.8.a', 1, 'ESSENTIAL', 2), |
||||
|
(31, 3, 1, 301, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(32, 3, 2, 301, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(33, 3, 40, 302, 4.24, '15-1253.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(34, 3, 21, 302, 4.24, '15-1253.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(35, 3, 38, 303, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(36, 3, 39, 303, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(37, 3, 5, 304, 4.38, '15-1253.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(38, 3, 10, 304, 4.38, '15-1253.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(39, 3, 68, 305, 2.94, '15-1253.00', '4.A.1.b.3', 1, 'ESSENTIAL', 2), |
||||
|
(40, 3, 51, 305, 2.94, '15-1253.00', '4.A.1.b.3', 1, 'ESSENTIAL', 2), |
||||
|
(41, 3, 25, 306, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(42, 3, 59, 306, 4.94, '15-1253.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(43, 4, 2, 401, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(44, 4, 51, 401, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(45, 4, 71, 402, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(46, 4, 70, 402, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(47, 4, 33, 403, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(48, 4, 34, 403, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(49, 4, 25, 404, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(50, 4, 59, 404, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(51, 4, 58, 405, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(52, 4, 32, 405, 4.83, '15-1244.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(53, 4, 60, 406, 3.60, '15-1244.00', '4.A.1.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(54, 4, 72, 406, 3.60, '15-1244.00', '4.A.1.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(55, 4, 61, 407, 2.34, '15-1244.00', '2.C.8.a', 1, 'ESSENTIAL', 2), |
||||
|
(56, 4, 57, 407, 2.34, '15-1244.00', '2.C.8.a', 1, 'ESSENTIAL', 2), |
||||
|
(57, 4, 10, 408, 4.03, '15-1244.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(58, 4, 11, 408, 4.03, '15-1244.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(59, 5, 2, 501, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(60, 5, 9, 501, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(61, 5, 41, 502, 4.01, '13-2031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(62, 5, 43, 502, 4.01, '13-2031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(63, 5, 5, 503, 4.44, '13-2031.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(64, 5, 11, 503, 4.44, '13-2031.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(65, 5, 12, 504, 4.03, '13-2031.00', '2.C.4.a', 1, 'ESSENTIAL', 2), |
||||
|
(66, 5, 13, 504, 4.03, '13-2031.00', '2.C.4.a', 1, 'ESSENTIAL', 2), |
||||
|
(67, 5, 62, 505, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(68, 5, 45, 505, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(69, 5, 14, 506, 4.42, '13-2031.00', '2.C.1.c', 1, 'ESSENTIAL', 2), |
||||
|
(70, 5, 15, 506, 4.42, '13-2031.00', '2.C.1.c', 1, 'ESSENTIAL', 2), |
||||
|
(71, 5, 32, 507, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(72, 5, 44, 507, 4.48, '13-2031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(73, 6, 2, 601, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(74, 6, 9, 601, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(75, 6, 12, 602, 3.27, '13-2053.00', '2.C.4.a', 1, 'ESSENTIAL', 2), |
||||
|
(76, 6, 13, 602, 3.27, '13-2053.00', '2.C.4.a', 1, 'ESSENTIAL', 2), |
||||
|
(77, 6, 15, 603, 4.43, '13-2053.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(78, 6, 21, 603, 4.43, '13-2053.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(79, 6, 46, 604, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(80, 6, 48, 604, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(81, 6, 5, 605, 4.13, '13-2053.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(82, 6, 49, 605, 4.13, '13-2053.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(83, 6, 39, 606, 3.90, '13-2053.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(84, 6, 57, 606, 3.90, '13-2053.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(85, 6, 33, 607, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(86, 6, 32, 607, 4.41, '13-2053.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(87, 7, 2, 701, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(88, 7, 53, 701, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(89, 7, 52, 701, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(90, 7, 42, 702, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(91, 7, 48, 702, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(92, 7, 62, 703, 4.20, '13-2099.01', '2.C.1.c', 1, 'ESSENTIAL', 2), |
||||
|
(93, 7, 14, 703, 4.20, '13-2099.01', '2.C.1.c', 1, 'ESSENTIAL', 2), |
||||
|
(94, 7, 11, 704, 4.50, '13-2099.01', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(95, 7, 5, 704, 4.50, '13-2099.01', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(96, 7, 49, 705, 3.53, '13-2099.01', '4.A.1.b.3', 1, 'ESSENTIAL', 2), |
||||
|
(97, 7, 56, 705, 3.53, '13-2099.01', '4.A.1.b.3', 1, 'ESSENTIAL', 2), |
||||
|
(98, 7, 39, 706, 3.05, '13-2099.01', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(99, 7, 68, 706, 3.05, '13-2099.01', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(100, 7, 33, 707, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(101, 7, 34, 707, 4.68, '13-2099.01', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(102, 8, 16, 801, 2.52, '13-1082.00', '2.C.6', 1, 'ESSENTIAL', 2), |
||||
|
(103, 8, 17, 801, 2.52, '13-1082.00', '2.C.6', 1, 'ESSENTIAL', 2), |
||||
|
(104, 8, 66, 802, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(105, 8, 44, 802, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(106, 8, 23, 803, 3.73, '13-1082.00', '4.A.2.b.4', 1, 'ESSENTIAL', 2), |
||||
|
(107, 8, 24, 803, 3.73, '13-1082.00', '4.A.2.b.4', 1, 'ESSENTIAL', 2), |
||||
|
(108, 8, 43, 804, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(109, 8, 2, 804, 3.94, '13-1082.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(110, 8, 18, 805, 4.45, '13-1082.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(111, 8, 63, 805, 4.45, '13-1082.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(112, 8, 32, 806, 4.73, '13-1082.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(113, 8, 45, 806, 4.73, '13-1082.00', '4.A.4.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(114, 9, 16, 901, 4.78, '25-9031.00', '2.C.6', 1, 'ESSENTIAL', 2), |
||||
|
(115, 9, 17, 901, 4.78, '25-9031.00', '2.C.6', 1, 'ESSENTIAL', 2), |
||||
|
(116, 9, 18, 902, 4.45, '25-9031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(117, 9, 63, 902, 4.45, '25-9031.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(118, 9, 64, 903, 3.72, '25-9031.00', '4.A.2.b.2', 1, 'ESSENTIAL', 2), |
||||
|
(119, 9, 50, 903, 3.72, '25-9031.00', '4.A.2.b.2', 1, 'ESSENTIAL', 2), |
||||
|
(120, 9, 19, 904, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(121, 9, 21, 904, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(122, 9, 12, 905, 4.19, '25-9031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(123, 9, 45, 905, 4.19, '25-9031.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(124, 9, 69, 906, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(125, 9, 20, 906, 3.83, '25-9031.00', '4.A.2.a.3', 1, 'ESSENTIAL', 2), |
||||
|
(126, 10, 2, 1001, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(127, 10, 9, 1001, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(128, 10, 43, 1002, 4.83, '15-2051.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(129, 10, 44, 1002, 4.83, '15-2051.00', '4.A.2.a.4', 1, 'ESSENTIAL', 2), |
||||
|
(130, 10, 5, 1003, 4.71, '15-2051.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(131, 10, 11, 1003, 4.71, '15-2051.00', '4.A.2.a.2', 1, 'ESSENTIAL', 2), |
||||
|
(132, 10, 12, 1004, 4.52, '15-2051.00', '2.C.4.a', 1, 'ESSENTIAL', 2), |
||||
|
(133, 10, 13, 1004, 4.52, '15-2051.00', '2.C.4.a', 1, 'ESSENTIAL', 2), |
||||
|
(134, 10, 17, 1005, 2.28, '15-2051.00', '2.C.6', 1, 'ESSENTIAL', 2), |
||||
|
(135, 10, 19, 1005, 2.28, '15-2051.00', '2.C.6', 1, 'ESSENTIAL', 2), |
||||
|
(136, 10, 45, 1006, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(137, 10, 65, 1006, 4.75, '15-2051.00', '4.A.3.b.1', 1, 'ESSENTIAL', 2), |
||||
|
(138, 1, 50, 104, NULL, NULL, NULL, 0, 'OPTIONAL', 2), |
||||
|
(139, 9, 67, 903, NULL, NULL, NULL, 0, 'OPTIONAL', 2); |
||||
@ -0,0 +1,12 @@ |
|||||
|
-- Flyway V49: 将技能代理标记规范化为跨方言 BOOLEAN。 |
||||
|
-- 不改写 V47/V48,确保已应用迁移的 Flyway checksum 保持有效。 |
||||
|
|
||||
|
ALTER TABLE occupation_skills |
||||
|
DROP CONSTRAINT ck_occupation_skills_importance_proxy; |
||||
|
|
||||
|
ALTER TABLE occupation_skills |
||||
|
MODIFY COLUMN importance_is_proxy BOOLEAN NOT NULL DEFAULT FALSE; |
||||
|
|
||||
|
ALTER TABLE occupation_skills |
||||
|
ADD CONSTRAINT ck_occupation_skills_importance_proxy |
||||
|
CHECK (importance_is_proxy IN (FALSE, TRUE)); |
||||
@ -0,0 +1,57 @@ |
|||||
|
-- Flyway V50: 入职清单与企业知识表结构 |
||||
|
-- 本迁移只建立三张业务表;通用清单内容由 V51 导入。 |
||||
|
|
||||
|
CREATE TABLE onboarding_checklist_items ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
scope_type VARCHAR(20) NOT NULL COMMENT 'STANDARD 或 COMPANY', |
||||
|
scope_id BIGINT NOT NULL COMMENT '通用项为 0,企业项为 company_id', |
||||
|
company_id BIGINT NULL COMMENT '企业项所属企业;通用项为空', |
||||
|
item_key VARCHAR(80) NOT NULL COMMENT '清单项稳定业务键', |
||||
|
title VARCHAR(120) NOT NULL COMMENT '标题', |
||||
|
description VARCHAR(1000) NULL COMMENT '说明', |
||||
|
category VARCHAR(30) NOT NULL COMMENT 'DOCUMENT/ADMIN/EQUIPMENT/TRAINING/LOGISTICS/OTHER', |
||||
|
sort_order INT NOT NULL DEFAULT 1000 COMMENT '排序值', |
||||
|
required BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否必填', |
||||
|
active BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否生效', |
||||
|
idempotency_key VARCHAR(64) NULL COMMENT '企业创建幂等键', |
||||
|
request_hash CHAR(64) NULL COMMENT '规范化创建请求 SHA-256', |
||||
|
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT ck_onboarding_checklist_scope_type CHECK (scope_type IN ('STANDARD', 'COMPANY')), |
||||
|
CONSTRAINT ck_onboarding_checklist_category CHECK ( |
||||
|
category IN ('DOCUMENT', 'ADMIN', 'EQUIPMENT', 'TRAINING', 'LOGISTICS', 'OTHER') |
||||
|
), |
||||
|
CONSTRAINT ck_onboarding_checklist_sort_order CHECK (sort_order BETWEEN 0 AND 9999), |
||||
|
CONSTRAINT uk_onboarding_checklist_scope_item UNIQUE (scope_type, scope_id, item_key), |
||||
|
CONSTRAINT uk_onboarding_checklist_company_idempotency UNIQUE (company_id, idempotency_key), |
||||
|
CONSTRAINT fk_onboarding_checklist_company FOREIGN KEY (company_id) REFERENCES companies (id), |
||||
|
INDEX idx_onboarding_checklist_scope_active_sort (scope_type, scope_id, active, sort_order, id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入职清单项'; |
||||
|
|
||||
|
CREATE TABLE student_onboarding_progress ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
student_user_id BIGINT NOT NULL COMMENT '学生用户', |
||||
|
item_id BIGINT NOT NULL COMMENT '清单项', |
||||
|
completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否完成', |
||||
|
completed_at DATETIME NULL COMMENT '完成时间;取消完成时为空', |
||||
|
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT uk_student_onboarding_progress_student_item UNIQUE (student_user_id, item_id), |
||||
|
CONSTRAINT fk_student_onboarding_progress_student FOREIGN KEY (student_user_id) REFERENCES users (id), |
||||
|
CONSTRAINT fk_student_onboarding_progress_item FOREIGN KEY (item_id) REFERENCES onboarding_checklist_items (id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='学生入职清单完成状态'; |
||||
|
|
||||
|
CREATE TABLE company_knowledge ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
company_id BIGINT NOT NULL COMMENT '企业', |
||||
|
culture VARCHAR(2000) NULL COMMENT '企业文化', |
||||
|
work_policies VARCHAR(4000) NULL COMMENT '工作制度', |
||||
|
onboarding_guide VARCHAR(4000) NULL COMMENT '入职指南', |
||||
|
version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT uk_company_knowledge_company UNIQUE (company_id), |
||||
|
CONSTRAINT fk_company_knowledge_company FOREIGN KEY (company_id) REFERENCES companies (id) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='企业知识介绍'; |
||||
@ -0,0 +1,31 @@ |
|||||
|
-- Flyway V51: 通用入职准备清单种子 |
||||
|
-- 通用项使用 scope_id=0、company_id=NULL,内容、分类、顺序和必填标记均为固定值。 |
||||
|
|
||||
|
INSERT INTO onboarding_checklist_items |
||||
|
(scope_type, scope_id, company_id, item_key, title, description, category, sort_order, |
||||
|
required, active, idempotency_key, request_hash, version) |
||||
|
VALUES |
||||
|
('STANDARD', 0, NULL, 'standard-id-card', '准备身份证件', |
||||
|
'准备身份证原件或有效身份证明,用于入职登记。', 'DOCUMENT', 100, TRUE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-education-proof', '准备学历学位证明', |
||||
|
'按公司要求准备学历、学位证书或学信网证明。', 'DOCUMENT', 110, TRUE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-id-photo', '准备证件照', |
||||
|
'准备符合公司要求的近期证件照。', 'DOCUMENT', 120, FALSE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-bank-account', '提供工资卡信息', |
||||
|
'按公司要求提供本人工资卡账户信息。', 'ADMIN', 200, TRUE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-emergency-contact', '填写紧急联系人', |
||||
|
'填写必要的紧急联系人和联系方式。', 'ADMIN', 210, TRUE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-background-check', '完成背景核验', |
||||
|
'按公司通知配合完成背景信息核验。', 'ADMIN', 220, FALSE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-medical-check', '确认入职体检要求', |
||||
|
'确认岗位是否需要体检及指定机构要求。', 'ADMIN', 230, FALSE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-equipment-request', '确认办公设备需求', |
||||
|
'提前确认工位、电脑或其他办公设备需求。', 'EQUIPMENT', 300, FALSE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-report-location', '确认报到地址和时间', |
||||
|
'与 HR 确认入职日期、报到地址和接待人。', 'LOGISTICS', 400, TRUE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-commute-plan', '规划通勤路线', |
||||
|
'提前规划到岗路线和交通时间。', 'LOGISTICS', 410, FALSE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-compliance-training', '完成合规与保密培训', |
||||
|
'完成公司要求的合规、安全和保密培训。', 'TRAINING', 500, TRUE, TRUE, NULL, NULL, 0), |
||||
|
('STANDARD', 0, NULL, 'standard-onboarding-guide', '阅读入职指南', |
||||
|
'阅读公司入职指南及相关准备说明。', 'TRAINING', 510, FALSE, TRUE, NULL, NULL, 0); |
||||
@ -0,0 +1,69 @@ |
|||||
|
-- Flyway V52: 薪资计算器城市政策与历史快照表结构 |
||||
|
-- V52 只创建结构;目标城市政策由 V53 导入。 |
||||
|
|
||||
|
CREATE TABLE salary_city_policy ( |
||||
|
city_code VARCHAR(6) NOT NULL PRIMARY KEY COMMENT '六位行政区划编码', |
||||
|
province_code VARCHAR(6) NOT NULL COMMENT '六位省级行政区划编码', |
||||
|
province_name VARCHAR(40) NOT NULL COMMENT '省份名称', |
||||
|
city_name VARCHAR(80) NOT NULL COMMENT '城市名称', |
||||
|
policy_version VARCHAR(64) NOT NULL COMMENT '城市政策版本', |
||||
|
policy_effective_date DATE NOT NULL COMMENT '政策实际生效日期', |
||||
|
data_source VARCHAR(500) NULL COMMENT '可选数据来源说明', |
||||
|
social_security_base_min DECIMAL(12,2) NOT NULL COMMENT '社保缴费基数下限', |
||||
|
social_security_base_max DECIMAL(12,2) NOT NULL COMMENT '社保缴费基数上限', |
||||
|
housing_fund_base_min DECIMAL(12,2) NOT NULL COMMENT '公积金缴存基数下限', |
||||
|
housing_fund_base_max DECIMAL(12,2) NOT NULL COMMENT '公积金缴存基数上限', |
||||
|
pension_rate DECIMAL(7,6) NOT NULL COMMENT '养老个人比例', |
||||
|
medical_rate DECIMAL(7,6) NOT NULL COMMENT '医疗个人比例', |
||||
|
unemployment_rate DECIMAL(7,6) NOT NULL COMMENT '失业个人比例', |
||||
|
housing_fund_rate DECIMAL(7,6) NOT NULL COMMENT '公积金个人比例', |
||||
|
serious_illness_monthly_fee DECIMAL(10,2) NOT NULL DEFAULT 0.00 COMMENT '大病固定费/月', |
||||
|
data_level VARCHAR(30) NOT NULL DEFAULT 'CITY' COMMENT 'CITY 或 PROVINCE_FALLBACK', |
||||
|
enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT ck_scp_ss_base CHECK ( |
||||
|
social_security_base_min >= 0 |
||||
|
AND social_security_base_max >= social_security_base_min |
||||
|
), |
||||
|
CONSTRAINT ck_scp_fund_base CHECK ( |
||||
|
housing_fund_base_min >= 0 |
||||
|
AND housing_fund_base_max >= housing_fund_base_min |
||||
|
), |
||||
|
CONSTRAINT ck_scp_pension_rate CHECK (pension_rate >= 0 AND pension_rate <= 0.20), |
||||
|
CONSTRAINT ck_scp_medical_rate CHECK (medical_rate >= 0 AND medical_rate <= 0.20), |
||||
|
CONSTRAINT ck_scp_unemployment_rate CHECK (unemployment_rate >= 0 AND unemployment_rate <= 0.10), |
||||
|
CONSTRAINT ck_scp_fund_rate CHECK (housing_fund_rate >= 0 AND housing_fund_rate <= 0.12), |
||||
|
CONSTRAINT ck_scp_illness_fee CHECK ( |
||||
|
serious_illness_monthly_fee >= 0 AND serious_illness_monthly_fee <= 10000.00 |
||||
|
), |
||||
|
CONSTRAINT ck_scp_data_level CHECK (data_level IN ('CITY', 'PROVINCE_FALLBACK')), |
||||
|
INDEX idx_scp_enabled_order (enabled, province_code, city_code) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='薪资计算城市政策'; |
||||
|
|
||||
|
CREATE TABLE salary_calculation_history ( |
||||
|
id BIGINT AUTO_INCREMENT PRIMARY KEY, |
||||
|
student_user_id BIGINT NOT NULL COMMENT '所属学生用户', |
||||
|
city_code VARCHAR(6) NOT NULL COMMENT '城市编码快照', |
||||
|
city_name VARCHAR(80) NOT NULL COMMENT '城市名称快照', |
||||
|
data_level VARCHAR(30) NOT NULL COMMENT 'CITY 或 PROVINCE_FALLBACK', |
||||
|
policy_version VARCHAR(64) NOT NULL COMMENT '城市政策版本', |
||||
|
tax_rules_version VARCHAR(64) NOT NULL COMMENT '全国个税规则版本', |
||||
|
month_index INT NOT NULL COMMENT '累计计税月份', |
||||
|
gross_monthly_salary DECIMAL(12,2) NOT NULL COMMENT '税前月薪', |
||||
|
net_salary DECIMAL(12,2) NOT NULL COMMENT '到手工资', |
||||
|
idempotency_key VARCHAR(64) NULL COMMENT '可选幂等键', |
||||
|
request_hash CHAR(64) NULL COMMENT '规范化请求指纹', |
||||
|
input_snapshot_json TEXT NOT NULL COMMENT '输入快照 JSON', |
||||
|
policy_snapshot_json TEXT NOT NULL COMMENT '政策与最终参数快照 JSON', |
||||
|
result_snapshot_json TEXT NOT NULL COMMENT '完整结果快照 JSON', |
||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, |
||||
|
CONSTRAINT ck_sch_month_index CHECK (month_index BETWEEN 1 AND 12), |
||||
|
CONSTRAINT ck_sch_gross CHECK (gross_monthly_salary > 0 AND gross_monthly_salary <= 10000000.00), |
||||
|
CONSTRAINT ck_sch_net CHECK (net_salary >= 0), |
||||
|
CONSTRAINT uk_sch_student_idempotency UNIQUE (student_user_id, idempotency_key), |
||||
|
CONSTRAINT fk_sch_student FOREIGN KEY (student_user_id) REFERENCES users (id), |
||||
|
CONSTRAINT fk_sch_city FOREIGN KEY (city_code) REFERENCES salary_city_policy (city_code), |
||||
|
INDEX idx_sch_student_created (student_user_id, created_at, id), |
||||
|
INDEX idx_sch_student_city (student_user_id, city_code) |
||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='薪资计算历史快照'; |
||||
@ -0,0 +1,368 @@ |
|||||
|
-- Flyway V53: 分层城市政策种子 |
||||
|
-- CITY 为已核验城市级参数;PROVINCE_FALLBACK 为省级公开基线回退,待城市级核验。 |
||||
|
INSERT INTO salary_city_policy ( |
||||
|
city_code, province_code, province_name, city_name, policy_version, policy_effective_date, |
||||
|
data_source, social_security_base_min, social_security_base_max, housing_fund_base_min, |
||||
|
housing_fund_base_max, pension_rate, medical_rate, unemployment_rate, housing_fund_rate, |
||||
|
serious_illness_monthly_fee, data_level, enabled |
||||
|
) VALUES |
||||
|
('110000', '110000', '北京市', '北京市', '2026-09-20-v1', '2026-07-01', '公开城市参数整理(待发布前复核)', 7270, 36348, 2540, 36348, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('120000', '120000', '天津市', '天津市', '2026-09-20-v1', '2026-07-01', '公开城市参数整理(待发布前复核)', 5180, 25902, 2510, 28917, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('130100', '130000', '河北省', '石家庄市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('130200', '130000', '河北省', '唐山市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('130300', '130000', '河北省', '秦皇岛市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('130400', '130000', '河北省', '邯郸市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('130500', '130000', '河北省', '邢台市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('130600', '130000', '河北省', '保定市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('130700', '130000', '河北省', '张家口市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('130800', '130000', '河北省', '承德市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('130900', '130000', '河北省', '沧州市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('131000', '130000', '河北省', '廊坊市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('131100', '130000', '河北省', '衡水市', '2026-09-20-v1', '2025-07-01', '河北省省级公开参数回退基线,待城市级核验', 4007, 20035, 2000, 20035, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('140100', '140000', '山西省', '太原市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('140200', '140000', '山西省', '大同市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('140300', '140000', '山西省', '阳泉市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('140400', '140000', '山西省', '长治市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('140500', '140000', '山西省', '晋城市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('140600', '140000', '山西省', '朔州市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('140700', '140000', '山西省', '晋中市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('140800', '140000', '山西省', '运城市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('140900', '140000', '山西省', '忻州市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('141000', '140000', '山西省', '临汾市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('141100', '140000', '山西省', '吕梁市', '2026-09-20-v1', '2025-07-01', '山西省省级公开参数回退基线,待城市级核验', 4198, 20990, 1980, 20990, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('150100', '150000', '内蒙古自治区', '呼和浩特市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('150200', '150000', '内蒙古自治区', '包头市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('150300', '150000', '内蒙古自治区', '乌海市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('150400', '150000', '内蒙古自治区', '赤峰市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('150500', '150000', '内蒙古自治区', '通辽市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('150600', '150000', '内蒙古自治区', '鄂尔多斯市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('150700', '150000', '内蒙古自治区', '呼伦贝尔市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('150800', '150000', '内蒙古自治区', '巴彦淖尔市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('150900', '150000', '内蒙古自治区', '乌兰察布市', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('152200', '150000', '内蒙古自治区', '兴安盟', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('152500', '150000', '内蒙古自治区', '锡林郭勒盟', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('152900', '150000', '内蒙古自治区', '阿拉善盟', '2026-09-20-v1', '2025-07-01', '内蒙古自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1980, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('210100', '210000', '辽宁省', '沈阳市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('210200', '210000', '辽宁省', '大连市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('210300', '210000', '辽宁省', '鞍山市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('210400', '210000', '辽宁省', '抚顺市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('210500', '210000', '辽宁省', '本溪市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('210600', '210000', '辽宁省', '丹东市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('210700', '210000', '辽宁省', '锦州市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('210800', '210000', '辽宁省', '营口市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('210900', '210000', '辽宁省', '阜新市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('211000', '210000', '辽宁省', '辽阳市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('211100', '210000', '辽宁省', '盘锦市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('211200', '210000', '辽宁省', '铁岭市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('211300', '210000', '辽宁省', '朝阳市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('211400', '210000', '辽宁省', '葫芦岛市', '2026-09-20-v1', '2025-07-01', '辽宁省省级公开参数回退基线,待城市级核验', 4100, 20500, 1900, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('220100', '220000', '吉林省', '长春市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('220200', '220000', '吉林省', '吉林市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('220300', '220000', '吉林省', '四平市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('220400', '220000', '吉林省', '辽源市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('220500', '220000', '吉林省', '通化市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('220600', '220000', '吉林省', '白山市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('220700', '220000', '吉林省', '松原市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('220800', '220000', '吉林省', '白城市', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('222400', '220000', '吉林省', '延边朝鲜族自治州', '2026-09-20-v1', '2025-07-01', '吉林省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('230100', '230000', '黑龙江省', '哈尔滨市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('230200', '230000', '黑龙江省', '齐齐哈尔市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('230300', '230000', '黑龙江省', '鸡西市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('230400', '230000', '黑龙江省', '鹤岗市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('230500', '230000', '黑龙江省', '双鸭山市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('230600', '230000', '黑龙江省', '大庆市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('230700', '230000', '黑龙江省', '伊春市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('230800', '230000', '黑龙江省', '佳木斯市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('230900', '230000', '黑龙江省', '七台河市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('231000', '230000', '黑龙江省', '牡丹江市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('231100', '230000', '黑龙江省', '黑河市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('231200', '230000', '黑龙江省', '绥化市', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('232700', '230000', '黑龙江省', '大兴安岭地区', '2026-09-20-v1', '2025-07-01', '黑龙江省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('310000', '310000', '上海市', '上海市', '2026-09-20-v1', '2026-07-01', '公开城市参数整理(待发布前复核)', 7546, 37731, 2740, 37731, 0.08, 0.02, 0.005, 0.07, 0, 'CITY', TRUE), |
||||
|
('320100', '320000', '江苏省', '南京市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('320200', '320000', '江苏省', '无锡市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('320300', '320000', '江苏省', '徐州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('320400', '320000', '江苏省', '常州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('320500', '320000', '江苏省', '苏州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4952, 24762, 4952, 40600, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('320600', '320000', '江苏省', '南通市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('320700', '320000', '江苏省', '连云港市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('320800', '320000', '江苏省', '淮安市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('320900', '320000', '江苏省', '盐城市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('321000', '320000', '江苏省', '扬州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('321100', '320000', '江苏省', '镇江市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('321200', '320000', '江苏省', '泰州市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('321300', '320000', '江苏省', '宿迁市', '2026-09-20-v1', '2025-07-01', '江苏省省级公开参数回退基线,待城市级核验', 4952, 24762, 2660, 42400, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('330100', '330000', '浙江省', '杭州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('330200', '330000', '浙江省', '宁波市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('330300', '330000', '浙江省', '温州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('330400', '330000', '浙江省', '嘉兴市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('330500', '330000', '浙江省', '湖州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('330600', '330000', '浙江省', '绍兴市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('330700', '330000', '浙江省', '金华市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('330800', '330000', '浙江省', '衢州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('330900', '330000', '浙江省', '舟山市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('331000', '330000', '浙江省', '台州市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('331100', '330000', '浙江省', '丽水市', '2026-09-20-v1', '2025-07-01', '浙江省省级公开参数回退基线,待城市级核验', 4986, 25299, 2490, 40694, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('340100', '340000', '安徽省', '合肥市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('340200', '340000', '安徽省', '芜湖市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('340300', '340000', '安徽省', '蚌埠市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('340400', '340000', '安徽省', '淮南市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('340500', '340000', '安徽省', '马鞍山市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('340600', '340000', '安徽省', '淮北市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('340700', '340000', '安徽省', '铜陵市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('340800', '340000', '安徽省', '安庆市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('341000', '340000', '安徽省', '黄山市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('341100', '340000', '安徽省', '滁州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('341200', '340000', '安徽省', '阜阳市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('341300', '340000', '安徽省', '宿州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('341500', '340000', '安徽省', '六安市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('341600', '340000', '安徽省', '亳州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('341700', '340000', '安徽省', '池州市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('341800', '340000', '安徽省', '宣城市', '2026-09-20-v1', '2025-07-01', '安徽省省级公开参数回退基线,待城市级核验', 4311, 21555, 2000, 21555, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('350100', '350000', '福建省', '福州市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('350200', '350000', '福建省', '厦门市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('350300', '350000', '福建省', '莆田市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('350400', '350000', '福建省', '三明市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('350500', '350000', '福建省', '泉州市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('350600', '350000', '福建省', '漳州市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('350700', '350000', '福建省', '南平市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('350800', '350000', '福建省', '龙岩市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('350900', '350000', '福建省', '宁德市', '2026-09-20-v1', '2025-07-01', '福建省省级公开参数回退基线,待城市级核验', 4043, 20215, 2000, 20215, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('360100', '360000', '江西省', '南昌市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('360200', '360000', '江西省', '景德镇市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('360300', '360000', '江西省', '萍乡市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('360400', '360000', '江西省', '九江市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('360500', '360000', '江西省', '新余市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('360600', '360000', '江西省', '鹰潭市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('360700', '360000', '江西省', '赣州市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('360800', '360000', '江西省', '吉安市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('360900', '360000', '江西省', '宜春市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('361000', '360000', '江西省', '抚州市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('361100', '360000', '江西省', '上饶市', '2026-09-20-v1', '2025-07-01', '江西省省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('370100', '370000', '山东省', '济南市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('370200', '370000', '山东省', '青岛市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('370300', '370000', '山东省', '淄博市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('370400', '370000', '山东省', '枣庄市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('370500', '370000', '山东省', '东营市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('370600', '370000', '山东省', '烟台市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('370700', '370000', '山东省', '潍坊市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('370800', '370000', '山东省', '济宁市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('370900', '370000', '山东省', '泰安市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('371000', '370000', '山东省', '威海市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('371100', '370000', '山东省', '日照市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('371300', '370000', '山东省', '临沂市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('371400', '370000', '山东省', '德州市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('371500', '370000', '山东省', '聊城市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('371600', '370000', '山东省', '滨州市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('371700', '370000', '山东省', '菏泽市', '2026-09-20-v1', '2025-07-01', '山东省省级公开参数回退基线,待城市级核验', 4504, 22522, 2100, 22522, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('410100', '410000', '河南省', '郑州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('410200', '410000', '河南省', '开封市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('410300', '410000', '河南省', '洛阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('410400', '410000', '河南省', '平顶山市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('410500', '410000', '河南省', '安阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('410600', '410000', '河南省', '鹤壁市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('410700', '410000', '河南省', '新乡市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('410800', '410000', '河南省', '焦作市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('410900', '410000', '河南省', '濮阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('411000', '410000', '河南省', '许昌市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('411100', '410000', '河南省', '漯河市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('411200', '410000', '河南省', '三门峡市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('411300', '410000', '河南省', '南阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('411400', '410000', '河南省', '商丘市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('411500', '410000', '河南省', '信阳市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('411600', '410000', '河南省', '周口市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('411700', '410000', '河南省', '驻马店市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('419001', '410000', '河南省', '济源市', '2026-09-20-v1', '2025-07-01', '河南省省级公开参数回退基线,待城市级核验', 3831, 19155, 2350, 28849, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('420100', '420000', '湖北省', '武汉市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('420200', '420000', '湖北省', '黄石市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('420300', '420000', '湖北省', '十堰市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('420500', '420000', '湖北省', '宜昌市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('420600', '420000', '湖北省', '襄阳市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('420700', '420000', '湖北省', '鄂州市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('420800', '420000', '湖北省', '荆门市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('420900', '420000', '湖北省', '孝感市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('421000', '420000', '湖北省', '荆州市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('421100', '420000', '湖北省', '黄冈市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('421200', '420000', '湖北省', '咸宁市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('421300', '420000', '湖北省', '随州市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('422800', '420000', '湖北省', '恩施土家族苗族自治州', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('429004', '420000', '湖北省', '仙桃市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('429005', '420000', '湖北省', '潜江市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('429006', '420000', '湖北省', '天门市', '2026-09-20-v1', '2025-07-01', '湖北省省级公开参数回退基线,待城市级核验', 4498, 22488, 2400, 36072, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('430100', '430000', '湖南省', '长沙市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('430200', '430000', '湖南省', '株洲市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('430300', '430000', '湖南省', '湘潭市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('430400', '430000', '湖南省', '衡阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('430500', '430000', '湖南省', '邵阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('430600', '430000', '湖南省', '岳阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('430700', '430000', '湖南省', '常德市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('430800', '430000', '湖南省', '张家界市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('430900', '430000', '湖南省', '益阳市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('431000', '430000', '湖南省', '郴州市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('431100', '430000', '湖南省', '永州市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('431200', '430000', '湖南省', '怀化市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('431300', '430000', '湖南省', '娄底市', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('433100', '430000', '湖南省', '湘西土家族苗族自治州', '2026-09-20-v1', '2025-07-01', '湖南省省级公开参数回退基线,待城市级核验', 4072, 20360, 2100, 20360, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('440100', '440000', '广东省', '广州市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('440200', '440000', '广东省', '韶关市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('440300', '440000', '广东省', '深圳市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 5510, 27549, 2520, 41190, 0.08, 0.02, 0.005, 0.05, 0, 'CITY', TRUE), |
||||
|
('440400', '440000', '广东省', '珠海市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('440500', '440000', '广东省', '汕头市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('440600', '440000', '广东省', '佛山市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('440700', '440000', '广东省', '江门市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('440800', '440000', '广东省', '湛江市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('440900', '440000', '广东省', '茂名市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('441200', '440000', '广东省', '肇庆市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('441300', '440000', '广东省', '惠州市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('441400', '440000', '广东省', '梅州市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('441500', '440000', '广东省', '汕尾市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('441600', '440000', '广东省', '河源市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('441700', '440000', '广东省', '阳江市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('441800', '440000', '广东省', '清远市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('441900', '440000', '广东省', '东莞市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('442000', '440000', '广东省', '中山市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('445100', '440000', '广东省', '潮州市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('445200', '440000', '广东省', '揭阳市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('445300', '440000', '广东省', '云浮市', '2026-09-20-v1', '2025-07-01', '广东省省级公开参数回退基线,待城市级核验', 5510, 27549, 2500, 39828, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('450100', '450000', '广西壮族自治区', '南宁市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('450200', '450000', '广西壮族自治区', '柳州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('450300', '450000', '广西壮族自治区', '桂林市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('450400', '450000', '广西壮族自治区', '梧州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('450500', '450000', '广西壮族自治区', '北海市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('450600', '450000', '广西壮族自治区', '防城港市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('450700', '450000', '广西壮族自治区', '钦州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('450800', '450000', '广西壮族自治区', '贵港市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('450900', '450000', '广西壮族自治区', '玉林市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('451000', '450000', '广西壮族自治区', '百色市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('451100', '450000', '广西壮族自治区', '贺州市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('451200', '450000', '广西壮族自治区', '河池市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('451300', '450000', '广西壮族自治区', '来宾市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('451400', '450000', '广西壮族自治区', '崇左市', '2026-09-20-v1', '2025-07-01', '广西壮族自治区省级公开参数回退基线,待城市级核验', 3900, 19500, 1800, 19500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('460100', '460000', '海南省', '海口市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('460200', '460000', '海南省', '三亚市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('460300', '460000', '海南省', '三沙市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('460400', '460000', '海南省', '儋州市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('469001', '460000', '海南省', '五指山市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('469002', '460000', '海南省', '琼海市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('469005', '460000', '海南省', '文昌市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('469006', '460000', '海南省', '万宁市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('469007', '460000', '海南省', '东方市', '2026-09-20-v1', '2025-07-01', '海南省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('500000', '500000', '重庆市', '重庆市', '2026-09-20-v1', '2025-07-01', '重庆市省级公开参数回退基线,待城市级核验', 4404, 22017, 2000, 22017, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('500200', '500000', '重庆市', '县', '2026-09-20-v1', '2025-07-01', '重庆市省级公开参数回退基线,待城市级核验', 4404, 22017, 2000, 22017, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('510100', '510000', '四川省', '成都市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('510300', '510000', '四川省', '自贡市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('510400', '510000', '四川省', '攀枝花市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('510500', '510000', '四川省', '泸州市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('510600', '510000', '四川省', '德阳市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('510700', '510000', '四川省', '绵阳市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('510800', '510000', '四川省', '广元市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('510900', '510000', '四川省', '遂宁市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('511000', '510000', '四川省', '内江市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('511100', '510000', '四川省', '乐山市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('511300', '510000', '四川省', '南充市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('511400', '510000', '四川省', '眉山市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('511500', '510000', '四川省', '宜宾市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('511600', '510000', '四川省', '广安市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('511700', '510000', '四川省', '达州市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('511800', '510000', '四川省', '雅安市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('511900', '510000', '四川省', '巴中市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('512000', '510000', '四川省', '资阳市', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('513200', '510000', '四川省', '阿坝藏族羌族自治州', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('513300', '510000', '四川省', '甘孜藏族自治州', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('513400', '510000', '四川省', '凉山彝族自治州', '2026-09-20-v1', '2025-07-01', '四川省省级公开参数回退基线,待城市级核验', 4588, 22938, 2100, 31378, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('520100', '520000', '贵州省', '贵阳市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('520200', '520000', '贵州省', '六盘水市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('520300', '520000', '贵州省', '遵义市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('520400', '520000', '贵州省', '安顺市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('520500', '520000', '贵州省', '毕节市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('520600', '520000', '贵州省', '铜仁市', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('522300', '520000', '贵州省', '黔西南布依族苗族自治州', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('522600', '520000', '贵州省', '黔东南苗族侗族自治州', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('522700', '520000', '贵州省', '黔南布依族苗族自治州', '2026-09-20-v1', '2025-07-01', '贵州省省级公开参数回退基线,待城市级核验', 4100, 20500, 1800, 20500, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('530100', '530000', '云南省', '昆明市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('530300', '530000', '云南省', '曲靖市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('530400', '530000', '云南省', '玉溪市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('530500', '530000', '云南省', '保山市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('530600', '530000', '云南省', '昭通市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('530700', '530000', '云南省', '丽江市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('530800', '530000', '云南省', '普洱市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('530900', '530000', '云南省', '临沧市', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('532300', '530000', '云南省', '楚雄彝族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('532500', '530000', '云南省', '红河哈尼族彝族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('532600', '530000', '云南省', '文山壮族苗族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('532800', '530000', '云南省', '西双版纳傣族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('532900', '530000', '云南省', '大理白族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('533100', '530000', '云南省', '德宏傣族景颇族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('533300', '530000', '云南省', '怒江傈僳族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('533400', '530000', '云南省', '迪庆藏族自治州', '2026-09-20-v1', '2025-07-01', '云南省省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('540100', '540000', '西藏自治区', '拉萨市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('540200', '540000', '西藏自治区', '日喀则市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('540300', '540000', '西藏自治区', '昌都市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('540400', '540000', '西藏自治区', '林芝市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('540500', '540000', '西藏自治区', '山南市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('540600', '540000', '西藏自治区', '那曲市', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('542500', '540000', '西藏自治区', '阿里地区', '2026-09-20-v1', '2025-07-01', '西藏自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('610100', '610000', '陕西省', '西安市', '2026-09-20-v1', '2025-07-01', '公开城市参数整理(待发布前复核)', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'CITY', TRUE), |
||||
|
('610200', '610000', '陕西省', '铜川市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('610300', '610000', '陕西省', '宝鸡市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('610400', '610000', '陕西省', '咸阳市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('610500', '610000', '陕西省', '渭南市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('610600', '610000', '陕西省', '延安市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('610700', '610000', '陕西省', '汉中市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('610800', '610000', '陕西省', '榆林市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('610900', '610000', '陕西省', '安康市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('611000', '610000', '陕西省', '商洛市', '2026-09-20-v1', '2025-07-01', '陕西省省级公开参数回退基线,待城市级核验', 4650, 23250, 2376, 32726, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('620100', '620000', '甘肃省', '兰州市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('620200', '620000', '甘肃省', '嘉峪关市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('620300', '620000', '甘肃省', '金昌市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('620400', '620000', '甘肃省', '白银市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('620500', '620000', '甘肃省', '天水市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('620600', '620000', '甘肃省', '武威市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('620700', '620000', '甘肃省', '张掖市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('620800', '620000', '甘肃省', '平凉市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('620900', '620000', '甘肃省', '酒泉市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('621000', '620000', '甘肃省', '庆阳市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('621100', '620000', '甘肃省', '定西市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('621200', '620000', '甘肃省', '陇南市', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('622900', '620000', '甘肃省', '临夏回族自治州', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('623000', '620000', '甘肃省', '甘南藏族自治州', '2026-09-20-v1', '2025-07-01', '甘肃省省级公开参数回退基线,待城市级核验', 3800, 19000, 1800, 19000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('630100', '630000', '青海省', '西宁市', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('630200', '630000', '青海省', '海东市', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('632200', '630000', '青海省', '海北藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('632300', '630000', '青海省', '黄南藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('632500', '630000', '青海省', '海南藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('632600', '630000', '青海省', '果洛藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('632700', '630000', '青海省', '玉树藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('632800', '630000', '青海省', '海西蒙古族藏族自治州', '2026-09-20-v1', '2025-07-01', '青海省省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('640100', '640000', '宁夏回族自治区', '银川市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('640200', '640000', '宁夏回族自治区', '石嘴山市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('640300', '640000', '宁夏回族自治区', '吴忠市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('640400', '640000', '宁夏回族自治区', '固原市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('640500', '640000', '宁夏回族自治区', '中卫市', '2026-09-20-v1', '2025-07-01', '宁夏回族自治区省级公开参数回退基线,待城市级核验', 4000, 20000, 1800, 20000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('650100', '650000', '新疆维吾尔自治区', '乌鲁木齐市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('650200', '650000', '新疆维吾尔自治区', '克拉玛依市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('650400', '650000', '新疆维吾尔自治区', '吐鲁番市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('650500', '650000', '新疆维吾尔自治区', '哈密市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('652300', '650000', '新疆维吾尔自治区', '昌吉回族自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('652700', '650000', '新疆维吾尔自治区', '博尔塔拉蒙古自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('652800', '650000', '新疆维吾尔自治区', '巴音郭楞蒙古自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('652900', '650000', '新疆维吾尔自治区', '阿克苏地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('653000', '650000', '新疆维吾尔自治区', '克孜勒苏柯尔克孜自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('653100', '650000', '新疆维吾尔自治区', '喀什地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('653200', '650000', '新疆维吾尔自治区', '和田地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('654000', '650000', '新疆维吾尔自治区', '伊犁哈萨克自治州', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('654200', '650000', '新疆维吾尔自治区', '塔城地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('654300', '650000', '新疆维吾尔自治区', '阿勒泰地区', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659001', '650000', '新疆维吾尔自治区', '石河子市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659002', '650000', '新疆维吾尔自治区', '阿拉尔市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659003', '650000', '新疆维吾尔自治区', '图木舒克市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659004', '650000', '新疆维吾尔自治区', '五家渠市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659005', '650000', '新疆维吾尔自治区', '北屯市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659006', '650000', '新疆维吾尔自治区', '铁门关市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659007', '650000', '新疆维吾尔自治区', '双河市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659008', '650000', '新疆维吾尔自治区', '可克达拉市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659009', '650000', '新疆维吾尔自治区', '昆玉市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659010', '650000', '新疆维吾尔自治区', '胡杨河市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659011', '650000', '新疆维吾尔自治区', '新星市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE), |
||||
|
('659012', '650000', '新疆维吾尔自治区', '白杨市', '2026-09-20-v1', '2025-07-01', '新疆维吾尔自治区省级公开参数回退基线,待城市级核验', 4200, 21000, 1800, 21000, 0.08, 0.02, 0.005, 0.12, 0, 'PROVINCE_FALLBACK', TRUE) |
||||
|
; |
||||
Some files were not shown because too many files changed in this diff
Loading…
Reference in new issue