feat: integrate skill cloud onboarding and salary modules #2
Merged
zhangshun
merged 2 commits from integration/lxq-n03-n05-20261008 into release/identity-selection-20260929 3 weeks ago
149 changed files with 25953 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,198 @@ |
|||
# 薪资计算器 API(N05) |
|||
|
|||
> Base URL:`/api/v1` |
|||
> 认证:`Authorization: Bearer <access-token>` |
|||
> 角色:当前登录用户必须具备 `STUDENT` 角色。 |
|||
|
|||
> 发布状态(2026-10-08):政策核验白名单默认为空;种子数据已入库不代表已获准发布。默认不提供城市参数或新计算,本人历史查询/删除仍保持原有角色及归属限制。 |
|||
|
|||
## 1. 能力边界 |
|||
|
|||
- 输入税前月薪,估算个人五险一金、大病固定费和当月个税后的到手工资。 |
|||
- 采用居民个人综合所得累计预扣法;`monthIndex` 表示当前工资口径下的累计计税月份数,而不是自然月编号。 |
|||
- 支持用户覆盖社保基数、公积金基数和个人比例。 |
|||
- 城市政策以地级行政区、直辖市和省直辖县级市为最小单元,不拆分到区县。 |
|||
- `dataLevel=CITY` 表示城市级参数;`dataLevel=PROVINCE_FALLBACK` 表示由于公开城市级资料不足而使用省级回退参数,结果精度较低。 |
|||
- 所有结果均为估算值,实际以工资单、当地政策和税务申报为准。 |
|||
|
|||
## 2. 查询城市列表 |
|||
|
|||
`GET /salary-calculator/cities` |
|||
|
|||
仅返回启用且“城市编码 + 当前政策版本”均命中发布白名单的摘要,按省份编码和城市编码排序。默认返回 `200`,`data=[]`。以下非空响应仅为契约示例,不代表这些政策已上线。 |
|||
|
|||
响应示例: |
|||
|
|||
```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) |
|||
``` |
|||
|
|||
## 9. 政策核验与发布门禁 |
|||
|
|||
### 默认关闭 |
|||
|
|||
`app.salary-publication.approved-policies` 是城市编码到已核验政策版本的精确映射,默认空映射。`enabled=true`、`dataLevel=CITY`、种子数量或功能测试通过,均不等于政策已核验。V53 保留原字节,不修改种子或已有历史快照,也不新增迁移。 |
|||
|
|||
### 白名单配置 |
|||
|
|||
仅在完成核验和发布审批后,由部署人员通过外部 Spring 配置提供映射。例如下列内容只展示配置格式,不可直接照抄到生产: |
|||
|
|||
```yaml |
|||
app: |
|||
salary-publication: |
|||
approved-policies: |
|||
"110000": "已核验记录对应的实际政策版本" |
|||
``` |
|||
|
|||
城市编码必须为六位数字,版本不能为空或包含首尾空白;非法配置启动失败。城市和数据库当前版本都必须精确匹配,版本变更后旧白名单不会自动批准新版本。不提供全量开放或按 CITY/PROVINCE_FALLBACK 批量开放的开关。 |
|||
|
|||
测试类显式配置的种子版本只用于算法、响应契约、并发与回滚验证,不可作为上线授权或政策准确性证据。生产默认配置没有任何批准项。 |
|||
|
|||
### 前端行为 |
|||
|
|||
- 城市列表默认 `HTTP 200 / code=200 / data=[]`;前端显示“薪资估算暂未开放”,不要自行回退到未经核验的本地参数。 |
|||
- 未发布城市的详情和新计算返回 `HTTP 404 / code=10601`,使用既有固定错误文案,不泄露政策参数。 |
|||
- 相同幂等键的历史重放也经过发布门禁;不能利用 POST 重放绕过关闭状态。 |
|||
- 只有已发布政策才会进入原有版本冲突检查;未发布时不返回最新政策版本信息。 |
|||
|
|||
### 历史兼容 |
|||
|
|||
本人历史列表、详情和删除接口不受发布白名单影响,继续执行 STUDENT 角色与记录归属检查。历史快照仅表示当时的估算结果,不证明其政策准确,不能将其当成当前政策使用。 |
|||
|
|||
### 核验记录要求 |
|||
|
|||
每个拟开放城市必须补齐官方来源地址、发布主体、适用地区、有效期、社保和公积金基数与比例、固定费用、核验人和核验日期,并记录与数据库 `policyVersion` 的对应关系。省级回退数据还需核实适用性并明确展示估算限制。更正已提交政策应使用后续新增迁移,不改写 V1–V53。 |
|||
|
|||
当前 359 条种子的政策准确性尚未由本次测试证明,因此本轮不添加任何生产批准项;N03/N04 不受此门禁影响。合并、部署及部署后三中间件黑盒验收另行执行。 |
|||
@ -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;未发布政策返回 404/10601,幂等重放不可绕过发布门禁;本人历史查询不受影响") |
|||
@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,190 @@ |
|||
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; |
|||
private final SalaryPolicyPublicationProperties publication; |
|||
|
|||
@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); |
|||
SalaryCityPolicy policy = cityPolicyRepository.findByCityCodeAndEnabledTrue(request.getCityCode()) |
|||
.orElseThrow(() -> new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND)); |
|||
requirePublished(policy.getCityCode(), policy.getPolicyVersion()); |
|||
if (idempotencyKey != null) { |
|||
var existing = historyRepository.findByStudentUserIdAndIdempotencyKey(userId, idempotencyKey); |
|||
if (existing.isPresent()) { |
|||
requirePublished(existing.get().getCityCode(), existing.get().getPolicyVersion()); |
|||
return replay(existing.get(), requestHash); |
|||
} |
|||
} |
|||
|
|||
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 void requirePublished(String cityCode, String policyVersion) { |
|||
if (!publication.isPublished(cityCode, policyVersion)) { |
|||
throw new BusinessException(ErrorCode.SALARY_CITY_NOT_FOUND); |
|||
} |
|||
} |
|||
|
|||
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;未发布或未启用政策返回 404/10601,不返回参数") |
|||
@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,86 @@ |
|||
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; |
|||
private final SalaryPolicyPublicationProperties publication; |
|||
|
|||
@Transactional(readOnly = true) |
|||
public List<SalaryCitySummaryResponse> listCities(Long userId) { |
|||
requireStudent(userId); |
|||
return cityPolicyRepository.findByEnabledTrueOrderByProvinceCodeAscCityCodeAsc() |
|||
.stream() |
|||
.filter(policy -> publication.isPublished(policy.getCityCode(), policy.getPolicyVersion())) |
|||
.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)); |
|||
if (!publication.isPublished(policy.getCityCode(), policy.getPolicyVersion())) { |
|||
throw 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,33 @@ |
|||
package com.mydreamjob.salarycalculator; |
|||
|
|||
import jakarta.annotation.PostConstruct; |
|||
import java.util.LinkedHashMap; |
|||
import java.util.Map; |
|||
import lombok.Getter; |
|||
import lombok.Setter; |
|||
import org.springframework.boot.context.properties.ConfigurationProperties; |
|||
import org.springframework.stereotype.Component; |
|||
|
|||
/** 发布白名单为空时失败关闭;enabled/dataLevel 不表示政策已核验。 */ |
|||
@Getter |
|||
@Setter |
|||
@Component |
|||
@ConfigurationProperties(prefix = "app.salary-publication") |
|||
public class SalaryPolicyPublicationProperties { |
|||
private Map<String, String> approvedPolicies = new LinkedHashMap<>(); |
|||
|
|||
@PostConstruct |
|||
void validate() { |
|||
if (approvedPolicies == null || approvedPolicies.entrySet().stream().anyMatch(entry -> |
|||
entry.getKey() == null || !entry.getKey().matches("[0-9]{6}") |
|||
|| entry.getValue() == null || entry.getValue().isBlank() |
|||
|| !entry.getValue().equals(entry.getValue().trim()))) { |
|||
throw new IllegalStateException("invalid salary publication allowlist"); |
|||
} |
|||
} |
|||
|
|||
public boolean isPublished(String cityCode, String policyVersion) { |
|||
return cityCode != null && policyVersion != null && approvedPolicies != null |
|||
&& policyVersion.equals(approvedPolicies.get(cityCode)); |
|||
} |
|||
} |
|||
@ -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