Merge remote-tracking branch 'origin/axr' into axr
This commit is contained in:
commit
35c6faa61a
29
deploy/local/initialize-health.ps1
Normal file
29
deploy/local/initialize-health.ps1
Normal file
@ -0,0 +1,29 @@
|
||||
param([string]$SecretsFile = (Join-Path $PSScriptRoot 'secrets.psd1'))
|
||||
. (Join-Path $PSScriptRoot 'common.ps1')
|
||||
$healthSettings = Read-LocalSettings $SecretsFile
|
||||
$databaseName = $healthSettings.DatabaseName
|
||||
|
||||
# 只操作已有的独立本地数据库,基础 system/infra 应已经初始化。
|
||||
Invoke-LocalMySql $healthSettings 'SELECT 1 FROM system_users LIMIT 1;' $databaseName | Out-Null
|
||||
$memberExists = [int]@(Invoke-LocalMySql $healthSettings "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='$databaseName' AND TABLE_NAME='member_user';")[0]
|
||||
if ($memberExists -gt 0) {
|
||||
$uniqueIndex = [int]@(Invoke-LocalMySql $healthSettings "SELECT COUNT(*) FROM information_schema.STATISTICS WHERE TABLE_SCHEMA='$databaseName' AND TABLE_NAME='member_user' AND INDEX_NAME='uk_member_mobile_tenant' AND NON_UNIQUE=0;")[0]
|
||||
if ($uniqueIndex -ne 2) {
|
||||
throw 'Existing member_user lacks the verified tenant/mobile unique index. Review duplicates and the existing schema before adding it; this initializer will not rewrite an existing table.'
|
||||
}
|
||||
}
|
||||
foreach ($fileName in @('V000__member_schema.sql', 'V001__health_identity.sql', 'V002__health_menu.sql')) {
|
||||
$sqlPath = Join-Path $repoPath "mysql_script/health/$fileName"
|
||||
$sql = Get-Content -LiteralPath $sqlPath -Raw -Encoding UTF8
|
||||
if ($sql -match '(?im)^\s*(DROP\s|TRUNCATE\s|DELETE\s|UPDATE\s|ALTER\s|USE\s|CREATE\s+DATABASE\s)') {
|
||||
throw "Unexpected destructive or modifying statement in $fileName. Review it before applying."
|
||||
}
|
||||
$sourcePath = $sqlPath.Replace('\', '/')
|
||||
Invoke-LocalMySql $healthSettings "SOURCE $sourcePath;" $databaseName | Out-Null
|
||||
Write-Output "Applied $fileName (existing rows preserved)."
|
||||
}
|
||||
Invoke-LocalMySql $healthSettings "SELECT table_name FROM information_schema.TABLES WHERE TABLE_SCHEMA='$databaseName' AND TABLE_NAME LIKE 'health_%' ORDER BY table_name;" $databaseName
|
||||
$healthTableCount = [int]@(Invoke-LocalMySql $healthSettings "SELECT COUNT(*) FROM information_schema.TABLES WHERE TABLE_SCHEMA='$databaseName' AND TABLE_NAME IN ('health_identity','health_elderly_profile','health_volunteer_profile','health_family_relation');")[0]
|
||||
$healthPermissionCount = [int]@(Invoke-LocalMySql $healthSettings "SELECT COUNT(*) FROM system_menu WHERE permission IN ('health:identity:query','health:identity:create','health:identity:update','health:identity:audit','health:identity:update-status','health:family-relation:query','health:family-relation:create','health:family-relation:review','health:family-relation:revoke') AND deleted=b'0';" $databaseName)[0]
|
||||
if ($healthTableCount -ne 4 -or $healthPermissionCount -ne 9) { throw 'Health schema or menus were not initialized completely.' }
|
||||
Write-Output 'Health schema and menus are ready. Reload Maven and restart your IDEA backend/frontend to load the new code.'
|
||||
182
docs/健康业务模块与身份模型.md
Normal file
182
docs/健康业务模块与身份模型.md
Normal file
@ -0,0 +1,182 @@
|
||||
# 健康业务模块与身份模型
|
||||
|
||||
更新日期:2026-10-05。实施仓库:`elder-yudao-pro`,分支:`axr`。
|
||||
|
||||
## 1. 本次业务决定
|
||||
|
||||
以下规则来自用户本次确认,后续开发应沿用:
|
||||
|
||||
- 老人、志愿者、家属使用 **手机号+密码** 登录,短信验证后续接入。
|
||||
- **每个账号只有一种业务身份**,不能同时是老人、志愿者和家属。后台管理员属于另一套账号体系。
|
||||
- 志愿者需要管理员审核,通过且启用后才能参与志愿服务。
|
||||
- 第一版由管理员创建账号与身份档案,后续再开放本人注册。
|
||||
- 一位老人可关联多位家属,一位家属可关联多位老人。
|
||||
- 家属关联需老人本人确认;管理员也可以核实后确认,必须填写核实理由。
|
||||
- 家属只在关联已确认且双方身份有效时,才能读取关联老人的健康信息。
|
||||
- 暂不删除已有源码、模块和目录;身份使用停用,关联使用解除,保留历史。
|
||||
|
||||
管理员核实方式、注册时的手机号验证流程,后续实现对应入口时再细化。目前没有公共密码注册接口。
|
||||
|
||||
## 2. 账号与身份的边界
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
System[system_users 后台管理员] --> Admin[管理端权限和审核]
|
||||
Member[member_user 手机号账号] --> Identity[health_identity 单一业务身份]
|
||||
Identity --> Elder[老人 health_elderly_profile]
|
||||
Identity --> Volunteer[志愿者 health_volunteer_profile]
|
||||
Identity --> Family[家属身份]
|
||||
Family --> Relation[health_family_relation 待确认或已确认]
|
||||
Relation --> Elder
|
||||
```
|
||||
|
||||
`system` 的角色、菜单权限与 `health` 的业务身份用途不同。前台选择“老人”或“志愿者”不能获得该身份;后端按登录的 `memberId` 查询可信身份。
|
||||
|
||||
框架区分 `/admin-api` 的后台 Token 与 `/app-api` 的会员 Token。健康 APP Controller 额外使用 `@PreAuthorize("isAuthenticated()")`,因为本版本芋道的 APP 路径默认允许匿名进入。
|
||||
|
||||
| 名称 | 含义 | 使用位置 |
|
||||
| --- | --- | --- |
|
||||
| `adminUserId` | `system_users.id` | 审核管理员、管理端权限 |
|
||||
| `memberId` | `member_user.id` | 手机号登录账号 |
|
||||
| `identityId` | `health_identity.id` | 老人、志愿者或家属的业务身份 |
|
||||
| `elderlyProfileId` | `health_elderly_profile.id` | 老人健康档案,后续评估与预约的主体 |
|
||||
| `volunteerProfileId` | `health_volunteer_profile.id` | 志愿者服务资料,后续派单关联 |
|
||||
| `relationId` | `health_family_relation.id` | 单条家属与老人关联 |
|
||||
|
||||
不同表的 ID 可以恰好相同,不能靠数值相同推导关联。创建账号、身份和对应档案在一个数据库事务内完成;账号密码使用芋道 BCrypt,响应不包含密码。
|
||||
|
||||
## 3. 模块与表
|
||||
|
||||
根 Maven 和 `yudao-server` 已启用 `member`、`health`。健康业务只有一个模块,按芋道惯例分为 `api/biz`,不引入新的微服务或通用工作流。
|
||||
|
||||
```
|
||||
yudao-module-health/
|
||||
yudao-module-health-api/ 模块间接口、DTO、身份枚举、错误码
|
||||
yudao-module-health-biz/ Controller、Service、DO、Mapper、测试
|
||||
yudao-ui-admin-vue3/src/
|
||||
api/health/ 健康管理请求封装
|
||||
views/health/identity/ 身份、基础档案与志愿者审核
|
||||
views/health/family-relation/ 家属关联确认与解除
|
||||
mysql_script/health/ 独立增量 SQL
|
||||
```
|
||||
|
||||
所有健康表继承 `TenantBaseDO`,含租户、创建/更新和逻辑删除字段;使用框架租户拦截器,关联查询与建档校验均在当前租户内完成。
|
||||
|
||||
| 表 | 当前字段与约束 |
|
||||
| --- | --- |
|
||||
| `member_user` | 手机号、BCrypt 密码、账号状态;新建表添加 `(tenant_id,mobile)` 唯一索引 |
|
||||
| `health_identity` | 账号、单一身份、姓名、性别、生日、联系方式、地址、启用状态、审核结果;`(tenant_id,member_id)` 唯一 |
|
||||
| `health_elderly_profile` | 紧急联系人、健康摘要、病史、用药、特殊需求;每个老人身份一份基础档案 |
|
||||
| `health_volunteer_profile` | 技能、服务区域、可服务时间说明、服务经验;每个志愿者身份一份服务档案 |
|
||||
| `health_family_relation` | 家属身份、老人档案、亲属关系、确认/解除状态、处理账号类型与编号、时间、理由;同一配对唯一 |
|
||||
|
||||
当前不采集身份证号。生日存储为 `date`,年龄后续按生日计算。性别沿用芋道:`1=男、2=女、3=未知`;旧系统 `0=男、1=女` 必须显式转换。
|
||||
|
||||
手机号用于登录;`contactPhone` 是档案联系方式,不会因编辑档案而更改登录手机号。已有会员账号通过 `memberId` 显式绑定,建档不会重置其密码。身份类型和绑定账号创建后不可变更,避免后续服务记录主体发生变化。
|
||||
|
||||
账号停用和身份停用均会使业务身份校验失败。模板会员模块仍保留积分、等级等源码与空表,本次管理页面只展示健康相关功能。
|
||||
|
||||
## 4. 审核与关联状态
|
||||
|
||||
业务身份:`10=老人、20=志愿者、30=家属`。
|
||||
|
||||
启用状态:`0=启用、1=停用`。审核状态:`0=待审核、1=通过、2=驳回`。两者独立,启用一个待审志愿者不能使其获得服务权限。
|
||||
|
||||
- 管理员创建老人和家属身份时直接通过;创建志愿者时进入待审。
|
||||
- 只有待审志愿者能审核;驳回必须填写理由。
|
||||
- 驳回后可修改资料并重新提审;重复审核由条件更新拦截。
|
||||
- 编辑档案不会修改启用或审核状态。
|
||||
|
||||
家属关联:`0=待确认、1=已确认、2=已拒绝、3=已解除`。
|
||||
|
||||
- 新增关联仅创建申请,不能自动获得读取权限。
|
||||
- 本人端申请的家属身份取自登录账号,忽略请求中伪造的 `familyIdentityId`。
|
||||
- 老人只能处理自己的关联;管理员核实确认或拒绝时必须填写理由。
|
||||
- 老人、家属可解除本人的待确认/已确认关联;管理员也可解除,均需填写理由。
|
||||
- 管理员可将已拒绝/已解除关联重新申请,此时必须重新确认才恢复授权。
|
||||
- 处理字段保留最近一次审核/解除痕迹,尚不是完整的每次变更事件流水。
|
||||
- 当前确认代表该家属可读取关联老人的基础健康档案;第一阶段不提供家属代预约。
|
||||
|
||||
## 5. 接口与权限
|
||||
|
||||
管理端前缀 `/admin-api`:
|
||||
|
||||
| 路径 | 功能 | 权限 |
|
||||
| --- | --- | --- |
|
||||
| `POST /health/identity/create` | 创建账号与身份档案,或绑定已有会员账号 | `health:identity:create` |
|
||||
| `PUT /health/identity/update` | 编辑基本资料及对应档案 | `health:identity:update` |
|
||||
| `GET /health/identity/get`、`/page` | 详情与分页筛选 | `health:identity:query` |
|
||||
| `PUT /health/identity/audit`、`/resubmit` | 志愿者审核与重新提审 | `health:identity:audit` |
|
||||
| `PUT /health/identity/update-status` | 身份启用与停用 | `health:identity:update-status` |
|
||||
| `GET /health/family-relation/page` | 查看关联列表 | `health:family-relation:query` |
|
||||
| `POST /health/family-relation/create` | 提交关联申请 | `health:family-relation:create` |
|
||||
| `PUT /health/family-relation/review`、`/reopen` | 核实处理与重新申请 | `health:family-relation:review` |
|
||||
| `PUT /health/family-relation/revoke` | 解除关联 | `health:family-relation:revoke` |
|
||||
|
||||
新增关联页面按姓名搜索老人和家属,同时需要 `health:identity:query`。普通后台角色应通过系统角色管理授予所需菜单与按钮权限;超级管理员默认拥有全部菜单。
|
||||
|
||||
会员端前缀 `/app-api`:
|
||||
|
||||
| 路径 | 当前行为 |
|
||||
| --- | --- |
|
||||
| `POST /member/auth/login` | 复用芋道的手机号与密码登录、Token 颁发 |
|
||||
| `GET /health/identity/me` | 读取当前会员的单一身份与对应档案;可查看本人志愿者待审结果 |
|
||||
| `GET /health/identity/family-relations` | 本人相关关联列表 |
|
||||
| `POST /health/identity/family-relation/request` | 家属提交关联申请 |
|
||||
| `PUT /health/identity/family-relation/review` | 老人本人确认或拒绝 |
|
||||
| `PUT /health/identity/family-relation/revoke` | 老人或家属解除本人关联 |
|
||||
| `GET /health/identity/family-elderly-profile` | 已确认家属按老人档案编号读取健康档案 |
|
||||
|
||||
本次提供 APP 后端接口,没有改造旧移动端。请求需正确的 Token 和 `tenant-id`;家属和老人账号类型同为 MEMBER,具体业务权限仍须健康服务校验。
|
||||
|
||||
其他模块调用 `HealthIdentityApi`:
|
||||
|
||||
- `getIdentityByMemberId` 仅查询身份,不表示身份有效。
|
||||
- `requireActiveIdentity(memberId, identityType)` 检查账号存在/启用、业务身份类型、启用状态与审核结果。后续预约与接单使用此方法。
|
||||
- `requireConfirmedFamilyRelation(familyMemberId, elderlyProfileId)` 检查双方有效及已确认关联。后续家属访问评估、运动与告警记录使用此方法。
|
||||
- 本次没有提供志愿者读取任意老人健康档案的入口;后续必须结合已分配工单校验访问范围。
|
||||
|
||||
## 6. 数据库与 IDEA 运行
|
||||
|
||||
按顺序执行:
|
||||
|
||||
1. `V000__member_schema.sql`:从模板提取的 11 张会员表的纯 DDL,去掉 DROP、示例账号、演示数据及自增起点。
|
||||
2. `V001__health_identity.sql`:新增 4 张健康业务表与唯一索引。
|
||||
3. `V002__health_menu.sql`:新增“健康服务 → 身份与档案、家属关联”及按钮权限。
|
||||
|
||||
均使用存在性判断,不清空数据。`CREATE TABLE IF NOT EXISTS` 不会升级已有旧表结构;若已有 member_user 缺少约定唯一索引,初始化脚本会停止并提示核实,不能直接覆盖已有表。
|
||||
|
||||
在项目根目录执行:
|
||||
|
||||
```powershell
|
||||
powershell -ExecutionPolicy Bypass -File .\deploy\local\initialize-health.ps1
|
||||
```
|
||||
|
||||
脚本读取已有的私有 `secrets.psd1`,仅允许操作 `elder_yudao_*` 本地独立数据库。不会导入旧老年系统数据,不会初始化摄像头、短信或外部呼叫服务。
|
||||
|
||||
IDEA 点击 Maven 的 Reload All Maven Projects,再重新运行已有的后端和前端启动配置。前端 `.env` 已增加 `health`;模板会员商城页面未加入产品菜单。
|
||||
|
||||
管理端重新登录后进入“健康服务”,可以新建老人、志愿者和家属档案,审批志愿者、申请和核实家属关联。
|
||||
|
||||
## 7. 旧系统迁移注意事项与下一步
|
||||
|
||||
旧数据库 `sys_user.user_id` 是登录编号,`elderly_info.elderly_id` 是档案编号;一些旧家属代码把 `elderly_id` 当作登录编号。不能直接沿用旧字段名解释。正式导入时需要 legacy ID 映射,明确旧登录 ID → 新 memberId、旧档案 ID → 新 elderlyProfileId、旧采集记录 ID → 新 assessmentId。
|
||||
|
||||
旧健康采集和评估允许多次记录。当前 `health_elderly_profile` 是基础档案,不能把所有历史评估覆盖到这一行。下一阶段应建立独立评估记录、保存时间及原始指标,再逐项迁移已核实的评分逻辑。
|
||||
|
||||
后续既定顺序:健康采集与评估 → 轻量运动/场地/设备 → 自由预约与活动报名 → 志愿者匹配/派单/按月拒单理由 → 提醒;LLM/智能体和固定场地摄像头另行接入。摄像头跌倒检测、最近 72 小时视频保存、自动呼叫 120 都尚未实现,老人无回应的判定仍待讨论。
|
||||
|
||||
新增能力继续放在 health 的独立包与表中,预约使用老人档案编号,派单使用志愿者身份/服务档案编号,健康历史数据单独保存。不要将手机号、密码或管理员角色塞进健康评估表。
|
||||
|
||||
参考:[迁移上下文](芋道框架迁移上下文.md)、[扩展需求草案](老年健康系统扩展需求与设计草案.md)、[项目目录说明](项目目录说明.md)。扩展草案是历史需求记录,具体已实现内容以本文和代码为准。
|
||||
|
||||
## 8. 验证记录
|
||||
|
||||
- Maven 后端打包通过,`HealthIdentityServiceTest` 的 9 项测试全部通过:加密账号/档案创建、事务回滚、单身份约束、重复手机号不重置密码、志愿者审核与停用、家属确认与解除、伪造身份拦截及多对多、租户隔离、匿名 APP 调用拦截。
|
||||
- Vue 管理端 desktop 生产构建通过,新增文件 ESLint 检查通过。
|
||||
- 前端原始全量 `vue-tsc` 因既有类型声明路径配置报 TS2688;使用临时检查配置补充核实后,新增 health 页面/API 没有类型错误,但模板其他页面/组件仍有既有错误,不能宣称全仓库类型检查通过。
|
||||
- 本地 `elder_yudao_local` 增量初始化完成,共 76 张表(原 61+member 11+health 4);脚本重复执行验证通过,健康账号与关联表保持空数据。
|
||||
- 临时后端在 `48081`、Redis DB 14 验证后已关闭;已有 `48080` 后端和 `5173` 前端进程未重启。新代码需要由用户在 IDEA 重新运行。
|
||||
- 实际 HTTP 验证通过:管理员登录、身份与关联分页、新增两个健康菜单、匿名 APP 拒绝(业务码 403)、后台 Token 调用 APP 拒绝(403)、错误手机号拒绝(400)、匿名后台接口拒绝(401)。未创建演示会员账号。
|
||||
|
||||
未迁移旧数据,未改造移动端页面,未实现历次健康评估、预约派单或设备/智能体接入;这些仍是后续阶段。
|
||||
435
docs/老年健康系统扩展需求与设计草案.md
Normal file
435
docs/老年健康系统扩展需求与设计草案.md
Normal file
@ -0,0 +1,435 @@
|
||||
# 老年健康系统:运动预约、志愿者协助、智能体与摄像头扩展
|
||||
|
||||
> 更新日期:2026-10-05(Asia/Shanghai)。本文保存用户新增愿景和实现约束,作为旧项目迁移到芋道后的设计上下文。
|
||||
|
||||
> 历史设计快照:健康身份与基础档案已开始实施;最新确认规则及已完成内容见 [健康业务模块与身份模型](健康业务模块与身份模型.md)。每个账号仅允许一种业务身份,第一版由管理员建号建档,志愿者须审核,家属关联须确认;本文其余设计建议不代表已实现。
|
||||
>
|
||||
> 文档状态:用户需求已记录;数据模型、状态名、实现阶段是设计建议,尚未写入业务代码。未启动服务、接入设备、大模型、短信或外部呼叫服务。
|
||||
>
|
||||
> 来源:本次用户对 7 个需求问题的回答;目标模板静态检查。不要把“目标能力”“预留接口”“已经实现”混为一谈。旧系统既有功能仍按此前迁移分析处理。
|
||||
|
||||
## 1. 用户已经确认的范围
|
||||
|
||||
### 1.1 场地与多人活动
|
||||
|
||||
- 需要轻量的场地、人数、设备管理。
|
||||
- 允许多个老人一起参加运动。
|
||||
- 保留扩展性,但不过度复杂,便于维护。
|
||||
- 第一版不需要复杂排班平台、物联网平台、独立微服务或通用资源调度引擎。
|
||||
|
||||
### 1.2 老人预约与运动提醒
|
||||
|
||||
- 暂时只允许老人本人发起预约。
|
||||
- 老人可以安排想运动的时间,系统应有提醒功能。
|
||||
- 需要协助时,系统可自动向志愿者派单。
|
||||
- 不是所有运动都需要志愿者协助。
|
||||
- 管理员可以设置运动类别是否强制需要协助;部分情况下可由老人自行选择。
|
||||
- 未来可以根据年龄、基础病等指标,由大模型/智能体判断是否需要协助。
|
||||
- 已确认同时支持自主预约(项目与期望时间)和管理员发布活动场次供老人报名;具体预约时间/时长限制仍待后续确定。
|
||||
|
||||
### 1.3 志愿者安排
|
||||
|
||||
- 自动匹配派单与管理员派单都需要支持。
|
||||
- 需要根据条件进行智能匹配。
|
||||
- 志愿者可以拒单,但拒单次数有限制。
|
||||
- 已确认拒单按月限制,每次拒单必须填写理由;具体上限由管理员配置。
|
||||
- 拒单理由是否可申请例外、达到上限后的处理、取消/超时是否计入仍需在实施时细化。
|
||||
|
||||
### 1.4 健康评估与活动建议
|
||||
|
||||
- 希望通过大模型分析,将健康评估结果与活动关联。
|
||||
- 老人可以自由选择运动,也可以接受大模型建议或安排。
|
||||
- 预期会使用智能体,不能只做固定页面里的文字聊天。
|
||||
- 医学限制、建议适用范围与智能安排的确认方式尚未最终确定。
|
||||
|
||||
### 1.5 服务过程和投诉
|
||||
|
||||
- 认可基本流程:提交预约 → 分配志愿者 → 接单 → 到场签到 → 开始运动 → 结束记录。
|
||||
- 志愿者可以点击按钮结束服务,类似配送员确认送达。
|
||||
- 老人或家属可以投诉异常情况。
|
||||
- 不要求将老人/家属确认作为普通服务完成的必经步骤。
|
||||
|
||||
### 1.6 摄像头与视频
|
||||
|
||||
- 摄像头固定在场地中,后续接入。
|
||||
- 计划输出骨骼信息、动作类型、次数、动作是否标准/动作评分,以及跌倒等异常检测结果。
|
||||
- 希望循环保存最近 72 小时视频,超过时间自动清理。
|
||||
- 当前需预留接口,识别算法和设备协议尚未确定。
|
||||
|
||||
### 1.7 安全告警愿景
|
||||
|
||||
- 检测到异常应通知管理员。
|
||||
- 中控页面应有声音和视觉提示。
|
||||
- 希望无回应时自动拨打 120,并给家属发短信。
|
||||
- “老人是否回应”的判断尚待商榷;回应渠道、超时、复核、自动呼叫渠道均未定。
|
||||
- 该愿景不代表目前已有可用的 120 呼叫 API 或接入授权;一期保留能力接口和升级规则,不把号码当成可直接调用的服务。
|
||||
|
||||
## 2. 本轮追加确认的实施约束
|
||||
|
||||
| 项目 | 用户确认 | 实施含义 |
|
||||
| --- | --- | --- |
|
||||
| 第一阶段范围 | 健康档案与评估迁移+运动预约+志愿者派单+提醒,同时预留摄像头和智能体接口 | 一期不要求真实设备/模型接入;原系统核心评估能力需要迁移 |
|
||||
| 预约形态 | 自主预约与管理员发布活动场次并存 | 老人可安排个人运动,也可报名多人活动 |
|
||||
| 拒单计数周期与理由 | 按月限制,每次必须写理由;次数由管理员配置 | 保存拒单历史和理由,按月份统计,不用按天/周规则 |
|
||||
|
||||
以上三项已收到用户明确答复,属于确认需求。其他章节具体字段、状态和技术选型仍为设计建议;第 16 节列出尚待细化的事项。
|
||||
|
||||
## 3. 已探索的芋道模板事实
|
||||
|
||||
目标源码位置:`C:/Users/Administrator/Desktop/projects/elder-yudao`。
|
||||
|
||||
- `pom.xml`:`2.0.1-jdk8-snapshot`,Java 8,Spring Boot 2.7.18。
|
||||
- `yudao-dependencies/pom.xml`:MyBatis-Plus 3.5.5,Flowable 6.8.0。
|
||||
- 根 Maven 构建和 `yudao-server/pom.xml` 当前只接入 system、infra 业务模块;member/bpm/report 等有源码但未加载。
|
||||
- 默认 `application.yaml` 选择 local 环境,多租户开启;local HTTP 端口 48080。
|
||||
- 当前目录没有前端工程、health 业务模块或 ai 模块。
|
||||
- 后台账号与 App 会员是两种主体:`system_users` / `member_user`。
|
||||
- Web 框架按 controller.admin/controller.app 包自动添加 `/admin-api` / `/app-api`。
|
||||
- system 已有短信、站内信 API,均能区分 admin/member。
|
||||
- infra 已有文件配置、文件管理、定时任务和接口日志。
|
||||
- framework 已有 WebSocket、Redis、锁/幂等/限流、数据权限和租户隔离。
|
||||
|
||||
可复用接口的位置:
|
||||
|
||||
```text
|
||||
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/sms/SmsSendApi.java
|
||||
sendSingleSmsToAdmin / sendSingleSmsToMember
|
||||
|
||||
yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/notify/NotifyMessageSendApi.java
|
||||
sendSingleMessageToAdmin / sendSingleMessageToMember
|
||||
|
||||
yudao-framework/yudao-spring-boot-starter-job/src/main/java/cn/iocoder/yudao/framework/quartz/core/handler/JobHandler.java
|
||||
execute(String param)
|
||||
|
||||
yudao-module-infra/yudao-module-infra-biz/src/main/java/cn/iocoder/yudao/module/infra/framework/file/core/client/FileClient.java
|
||||
upload / delete / getContent / getPresignedObjectUrl
|
||||
```
|
||||
|
||||
注意:FileClient 的 getPresignedObjectUrl 默认实现是不支持;S3FileClient 的现有实现生成的是 PUT 上传预签名 URL,不能直接当成私有视频 GET 播放签名。实际播放适配、视频流/录像服务需额外核对。
|
||||
|
||||
## 4. 建议的业务结构:一个健康模块,按职责分包
|
||||
|
||||
保留 system、infra,启用 member,新增 `yudao-module-health`;是否拆为 api/biz 跟随目标项目现有结构。健康业务分包,不先拆成独立服务:
|
||||
|
||||
| 包/职责 | 内容 |
|
||||
| --- | --- |
|
||||
| profile / assessment | 老人档案、志愿者资料、家属关系、问卷、评分、评估结果 |
|
||||
| activity / booking | 运动项目、场地、活动场次、预约、报名与人数 |
|
||||
| service | 协助工单、志愿者匹配、派单、接单/拒单、签到/开始/结束、投诉 |
|
||||
| exercise | 实际运动会话、参与者、运动结果、既有运动记录与汇总 |
|
||||
| monitoring | 设备、检测事件、异常、安全告警、中控和录像索引 |
|
||||
| notification | 运动提醒、派单提醒、告警升级、渠道发送与重试 |
|
||||
| intelligence / integration | 模型分析、智能体工具、摄像头协议适配、外部呼叫适配 |
|
||||
|
||||
老人和家属采用 member 主体;志愿者初步也用 member 主体加健康业务身份。后台管理员/课题组操作员使用 system 主体。志愿者资质、拒单规则、服务分配属于健康业务,不直接用后台菜单角色来代替。
|
||||
|
||||
家属发起预约暂不开放,但有效关联且有授权的家属可以查看对应老人服务、接收通知和提交投诉。多租户继续生效;需明确课题组/机构与租户的关系,新业务不能把 tenant_id 都写成默认值。
|
||||
|
||||
## 5. 预约、活动场次、协助工单和运动会话的关系
|
||||
|
||||
这四个对象需要分开,才能支持“不需要志愿者”和“多人运动”:
|
||||
|
||||
| 对象 | 表达的事实 | 示例 |
|
||||
| --- | --- | --- |
|
||||
| 预约 Booking | 某位老人打算在某时参加某运动 | 老人 A 预约 10 月 8 日 9:00 散步 30 分钟 |
|
||||
| 活动场次 ActivityOccurrence | 一个有时间、地点、人数上限的活动安排 | 社区 9:00 八段锦场次,最多 8 人 |
|
||||
| 协助工单 ServiceTask | 某活动需要志愿者完成的协助工作 | 协助 9:00 八段锦活动的 3 位需要协助的老人 |
|
||||
| 运动会话 ExerciseSession | 运动实际发生的一次过程 | 实际 9:05~9:36;参与者、志愿者、设备和结果 |
|
||||
|
||||
用户已确认同时支持两种入口;建议底层共用对象:
|
||||
|
||||
- 自主预约:选择运动、期望开始时间/时长、地点和协助意愿;后台可形成仅本人可见的活动安排。
|
||||
- 活动报名:选择管理员已发布的场次;每位老人有独立报名/预约记录,共用场地与场次。
|
||||
|
||||
不需要协助的预约不创建协助工单,但仍有提醒和运动记录。摄像头可以在没有志愿者时记录运动或发现异常。
|
||||
|
||||
多人活动初步采用“一个场次一个协助工单,允许多个志愿者岗位/派单记录”,工单关联需要协助的参与老人。场次中不需要协助的老人仍可以参加。具体配置志愿者数量/每人协助人数,先做简单上限配置;未来可拆工单或分组,不先做复杂优化调度。
|
||||
|
||||
管理员发布场次时可以要求先确认最低志愿者数量才开放报名,或者报名后招募。哪种策略采用由运营流程确认;不能因为创建了工单就认为协助已经落实。
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Elder[老人选择项目或场次] --> Booking[预约与协助判断]
|
||||
Booking --> Reminder[运动提醒]
|
||||
Booking --> Need{需要协助}
|
||||
Need -->|否| Exercise[实际运动会话]
|
||||
Need -->|是| Task[协助工单]
|
||||
Task --> Dispatch[规则匹配或管理员派单]
|
||||
Dispatch --> Accept[志愿者接单]
|
||||
Accept --> Arrival[到场与开始服务]
|
||||
Arrival --> Exercise
|
||||
Device[场地摄像头] --> Exercise
|
||||
Exercise --> Result[结束记录与效果评估]
|
||||
Exercise --> Alert[安全告警]
|
||||
Result --> Complaint[老人或家属投诉]
|
||||
```
|
||||
|
||||
## 6. 运动协助政策与智能体边界
|
||||
|
||||
运动项目的 assist_policy 建议采用以下枚举,而不是单一需要/不需要布尔值:
|
||||
|
||||
| 政策 | 默认行为 |
|
||||
| --- | --- |
|
||||
| REQUIRED | 必须有协助,老人不能关闭 |
|
||||
| OPTIONAL | 老人选择是否需要;智能体可以建议 |
|
||||
| RULE_BASED | 后端规则结合资料决定,未来智能体参与分析 |
|
||||
|
||||
无协助作为判断结果,而不再另造一套工单流程。老人主动要求协助时可以增加需求;管理员禁止开放的项目/场地或强制协助约束不会被模型撤销。
|
||||
|
||||
决策输入建议包括:运动类型/强度/时长、年龄、既有评估结果、基础病、行动能力、明确风险信息、资料更新时间、场地设备和可用协助。存储 assist_required、decision_source、reason、rule/model_version、assessed_at 和必要的输入引用快照,便于解释“为什么派单”。
|
||||
|
||||
初期规则也要明确资料缺失或过期时如何处理,例如要求补充评估或转人工确认;不要把缺失数据默认等同于低风险。具体年龄/疾病阈值没有用户确认,不在此文编造医学规则。
|
||||
|
||||
智能体预留三个用途:
|
||||
|
||||
1. 评估分析:把结构化问卷、指标和历史记录转换为风险摘要与运动建议。
|
||||
2. 运动规划:从管理员维护的有效项目/场次中推荐,或生成候选运动计划。
|
||||
3. 协助判断/匹配解释:建议是否协助、匹配哪些志愿者,并提供依据。
|
||||
|
||||
建议接口为 AssessmentAnalysisProvider、ExercisePlanningProvider、AssistDecisionProvider、VolunteerMatchStrategy。首版可使用规则实现和未启用的模型适配,不要把未调用模型的结果命名为 AI 分析。
|
||||
|
||||
模型建议输出结构化对象,包含来源、模型版本、证据/输入引用、有效期和建议理由。智能体通过受控工具查询项目、场次和已授权资料;创建预约/调整安排必须走同一业务 Service,验证时间、容量、身份和幂等。个人未确认的“建议”不应直接变成占用资源的预约,除非后续明确允许自动计划。
|
||||
|
||||
安全关键动作采用确定的业务规则:告警升级、对象授权、容量与重复派单检查不能只让大模型做自然语言决定。自然语言投诉也可以辅助分类,但不由模型直接认定责任或删除记录。
|
||||
|
||||
## 7. 志愿者匹配、派单与拒单
|
||||
|
||||
### 7.1 初期匹配实现
|
||||
|
||||
先做“硬条件过滤 + 可解释排序 + 管理员介入”,为后续模型匹配提供稳定业务接口。
|
||||
|
||||
硬条件示例:账号和志愿者身份有效、允许提供服务、服务区域/场地、空闲时间、所需技能、已有工单时间冲突、可协助人数。技能要求和资质流程尚未确定,字段可先轻量维护。
|
||||
|
||||
排序可参考:距离/区域、技能匹配程度、近期服务次数、负载、响应表现、已拒绝当前任务。权重由管理员配置并记录版本,首版只选几项确定数据,不需要机器学习模型。
|
||||
|
||||
派单记录保存候选、分数/理由、发出时间、响应截止时间、结果和尝试次数。自动派单与手动派单走同一入口。已有有效接单时不得被后台操作静默覆盖;改派应撤销原记录并通知。
|
||||
|
||||
自动派单是发出指派并等待接单,不是替志愿者完成接单。志愿者自助抢单是否开放尚未确认;“自动匹配/管理员派单两种方式”不等于已经确定开放抢单大厅。
|
||||
|
||||
### 7.2 拒单限制
|
||||
|
||||
- 用户已确认按月限制且每次填写理由;具体次数由管理员配置,原因例外和达到上限的措施尚待确定。
|
||||
- 管理端维护 monthly_refusal_limit;按服务端时间统计。建议以 Asia/Shanghai 自然月计算,保存明确月份(如 2026-10);“自然月或滚动 30 天”未明确时,以自然月作为待确认的实施假设,不删除过去月份历史。
|
||||
- 拒单 reason 必填且 trim 后非空,后端同样校验;可加原因类别和说明,但只做理由文本即可满足用户当前要求。
|
||||
- 只计主动拒绝实际收到的有效指派。超时未回应、管理员撤销、老人取消、重复请求分别记录,不默认混成拒单。
|
||||
- 可考虑健康、交通、突发事项由管理员标记例外;是否允许/怎么审核待定。
|
||||
- 达到上限后可暂停自动派单资格或转管理员处理;不建议在真实无法服务时强迫用户维持工单。最终执行措施待定。
|
||||
- 拒单后换候选,避免立即把同一工单派回同一个人。
|
||||
- 所有候选耗尽、快到开始时间、协助人数不足时通知管理员;老人端准确展示等待协助,不显示“已落实”。
|
||||
|
||||
状态变更和拒单计数需原子处理;同一点击/网络重试不能计两次。并发派单不能让同一人占用冲突时间,也不能超出工单所需志愿者数量。
|
||||
|
||||
## 8. 服务状态、完成与投诉
|
||||
|
||||
预约、工单、单次指派、运动会话、安全告警应有独立状态,避免一列 status 混用。
|
||||
|
||||
| 对象 | 建议状态 |
|
||||
| --- | --- |
|
||||
| 预约 | PENDING(条件/协助待落实)、CONFIRMED、CANCELLED、COMPLETED、NO_SHOW |
|
||||
| 协助工单 | WAITING_ASSIGNMENT、WAITING_ACCEPTANCE、READY、IN_PROGRESS、COMPLETED、CANCELLED |
|
||||
| 指派记录 | OFFERED、ACCEPTED、REJECTED、EXPIRED、REVOKED;到场信息独立记录 |
|
||||
| 运动会话 | READY、RUNNING、ENDED、ABORTED |
|
||||
| 投诉 | SUBMITTED、PROCESSING、RESOLVED、CLOSED |
|
||||
| 安全告警 | DETECTED、ACKNOWLEDGED、HANDLING、RESOLVED;升级阶段独立保存 |
|
||||
|
||||
这是拟定状态集;是否需要预约人工审核仍未确认,PENDING 不等于管理员审核。
|
||||
|
||||
简单服务流程:待分配 → 发出指派 → 接单 → 到场签到 → 开始 → 志愿者点击结束 → 保存实际时长/运动结果/服务说明。只需要一个确认服务完成的责任人时,可指定工单负责人;多人协助时区分某志愿者结束自己参与和负责人结束整个工单,避免一个人退出就关闭全场。
|
||||
|
||||
“无人协助运动”的实际开始/结束可以由老人按钮、场地操作员或后续设备事件触发,不能依赖志愿者。到预约结束时间只能标记超时/待核实,不直接捏造实际运动完成。
|
||||
|
||||
签到方式先选按钮/场地码等轻量方案,定位、二维码是否必需待定。摄像头事件作为过程证据,不自动代替服务完成声明;实际没检测到动作也不能直接认定志愿者未服务。
|
||||
|
||||
投诉关联 booking_id、task_id、session_id 中必要对象,包含投诉人、与老人的授权关系、内容、时间、附件和处理记录。投诉与安全事件可相互关联,但投诉不是即时紧急告警。完成后仍可投诉;投诉处理不删除原服务历史。
|
||||
|
||||
## 9. 轻量场地、人数与设备管理
|
||||
|
||||
### 9.1 场地
|
||||
|
||||
轻量字段建议:名称、地址/区域、负责人、启停状态、人数上限、允许时段、备注。不要首版构造多层楼宇/空间数字孪生。
|
||||
|
||||
场次人数与场地容量需要区分:场次有报名上限;同一场地同时存在多个场次时还要检查共享容量/占用。首版可让场地配置“独占/共享”和简单人数上限;独占场次限制重叠,共享累计人数限制容量。
|
||||
|
||||
预约开始/结束时间保存时间戳或目标标准时间类型,业务按 Asia/Shanghai 展示和配置,明确跨天。人数判断、报名提交和取消释放名额要在事务/锁或条件更新下执行,防止并发超售。
|
||||
|
||||
### 9.2 设备
|
||||
|
||||
设备先有:唯一 device_code、类型、所属场地、名称、启停状态、能力列表、最近心跳、在线状态、厂商/协议版本、配置引用。摄像头固定绑定场地,暂不做复杂设备借用流程。
|
||||
|
||||
“设备启用”和“设备在线”是两种状态;心跳超时可以发设备故障提醒,不能自动当作老人跌倒或无回应。管理员能看设备列表、绑定场地、状态和最近事件即可。
|
||||
|
||||
同场地多摄像头可共用一场运动会话,不默认每个摄像头都占一份场地人数。普通器材如需数量管理,可以轻量记录总量/可用量与场次需求,后续再加维护/借还。
|
||||
|
||||
## 10. 摄像头接入合同与多人关联
|
||||
|
||||
### 10.1 用业务 DTO 隔离硬件协议
|
||||
|
||||
预留 DeviceEventAdapter 和 RecognitionEventService;HTTP 回调、MQTT、WebSocket 或队列通过适配层转换成统一事件,尚未确认的协议不写死。
|
||||
|
||||
建议统一事件字段:
|
||||
|
||||
```text
|
||||
eventId 设备事件唯一 ID/幂等键
|
||||
schemaVersion 我们的事件结构版本
|
||||
deviceCode 设备编号,服务端查绑定场地与租户
|
||||
occurredAt / receivedAt 设备发生时间/服务端收到时间
|
||||
eventType heartbeat / motion_summary / skeleton / fall_detected / ...
|
||||
providerModelVersion 检测算法版本
|
||||
sessionRef 已绑定的运动会话;可为空,但需标明归属状态
|
||||
subjectTrackId 摄像头跟踪 ID;不是会员 ID
|
||||
confidence 如算法提供,保存置信度及定义/范围
|
||||
payload 动作类型、次数、分值、关节点、异常详情
|
||||
clipRef 可选录像片段引用
|
||||
```
|
||||
|
||||
设备回调通过设备凭据/签名或实际网关身份鉴别;租户、场地从服务端绑定关系取得,不信任设备传入任意会员或 tenant_id。同一 eventId 重试可安全接收;允许记录迟到/乱序事件,不能把迟到的旧检测覆盖新状态。
|
||||
|
||||
### 10.2 多人场景的关键问题
|
||||
|
||||
骨骼跟踪 ID 通常只识别视频里的一个人,并不自然知道是哪个老人。必须预留 track_id ↔ session_participant 的映射来源、有效期和可信度。
|
||||
|
||||
可选方案:到场登记后由工作人员绑定、站位/区域绑定、设备端提供标识映射。具体方式与课题组算法团队确认;当前不承诺使用人脸识别。
|
||||
|
||||
无法确认身份的跌倒事件仍可以按场地发中控告警,显示“人员待确认”;不能随机给某位老人家属发短信或把动作分写到其档案。多人参与关联是后续接入前的必要联调点。
|
||||
|
||||
### 10.3 数据量控制
|
||||
|
||||
业务数据库保存运动摘要和异常:动作、次数、标准程度、关键时间、模型版本、绑定信息。原始视频和连续高频骨骼序列不逐帧写 MySQL;保存文件/流服务引用,按需要保留关键帧或片段。
|
||||
|
||||
动作是否标准的分数必须记录算法定义/版本,不能直接与医学量表分数相加。后续效果评估从量表变化、服务记录和动作结果分别计算。
|
||||
|
||||
## 11. 最近 72 小时录像保留
|
||||
|
||||
目标为滚动时间窗口,而不是每天清空一个目录。优先让摄像头/NVR/录像服务按片段存储,平台保留索引:device_id、venue_id、session_id(可空)、start_at、end_at、storage_config_id、object_key、expire_at、删除状态。
|
||||
|
||||
建议正常录像片段以 end_at + 72h 为过期时间;应约定片段长度与清理扫描周期,因此实际剩余边界可能有一个片段长度/扫描间隔的偏差。若必须严格保留窗口,应由录像端直接执行策略。
|
||||
|
||||
清理 Job 或存储生命周期策略删除真实片段,同时更新索引;删除失败记录重试,不能只删数据库行。服务重启后补查过期片段;文件缺失和索引过期可核对。
|
||||
|
||||
活动教学视频属于长期内容库,监控录像属于短期留存,两者使用不同路径/保留策略,避免清理最近 72 小时录像的任务误删教学视频。
|
||||
|
||||
用户目前没有确认事故录像额外长期保存。若后续需要投诉/事故片段保全,应另行定义权限和保存期限;当前规则仍为 72 小时。异常事件的文字/结构化记录可以独立保留,但不能假装过期视频仍可播放。
|
||||
|
||||
平台的文件 API 适合附件与普通媒体索引,不应由 Java 应用直接把所有场地连续视频先读入 byte[] 再上传。预留录像端/对象存储接口和受控播放地址,后续决定 NVR、RTSP 拉流或摄像头推片方案。
|
||||
|
||||
## 12. 安全告警、中控与回应
|
||||
|
||||
### 12.1 将三类确认分开
|
||||
|
||||
| 确认 | 含义 | 不能据此推断 |
|
||||
| --- | --- | --- |
|
||||
| 管理员 ACK | 有管理员看见并接手告警 | 老人已经安全 |
|
||||
| 老人 RESPONDED | 老人按规定渠道做了回应 | 跌倒已经解除、无需帮助 |
|
||||
| 事件 RESOLVED | 经处置确认事件结束,记录结果 | 所有通知已经送达 |
|
||||
|
||||
提醒/短信“发送成功”、App“在线”、中控“已播放声音”同样不是老人回应。
|
||||
|
||||
建议告警记录 severity、来源、发生时间、设备/场地、可能的参与者、当前处理人、处理状态、升级阶段、回应结果及证据、通知尝试和处置结果。多摄像头同一异常可归并,但归并窗口由算法联调确定,不盲目吞掉重复发生的异常。
|
||||
|
||||
### 12.2 升级流程候选
|
||||
|
||||
1. 收到可信异常,创建持久化告警,中控通过 WebSocket 更新事件列表/醒目标识并尝试声音提示。
|
||||
2. 管理员接手或系统请求确认,向老人端发有 event_id 和有效期的回应请求;现场志愿者/工作人员可以报告观察结果。
|
||||
3. 配置时限内未得到有效确认,升级给管理员/备用负责人;向已授权且手机号有效的家属发短信。
|
||||
4. 若确认启用外部呼叫接入,按明确的紧急规则发出请求并记录反馈,区分“待请求、已提交、接通、失败、人工接管”。
|
||||
5. 处置完成,记录结果;解除告警由有权限人员执行,不能仅凭通知已发或模型回答正常来解除。
|
||||
|
||||
步骤、等待时间、严重程度例外和短信触发时间仍待确定。可能的严重事件可以不等待普通确认流程;该规则需要业务/课题组确认。
|
||||
|
||||
### 12.3 老人回应的候选方式
|
||||
|
||||
- App 弹窗按钮:如“我没事”“我需要帮助”。“需要帮助”仍为有效回应,但不能关闭告警。
|
||||
- 现场志愿者/工作人员确认:记录人员、时间、结论和事件关联。
|
||||
- 语音回应:后续模型辅助解析,仍保留原始回应证据、识别结果与不确定状态。
|
||||
- 摄像头姿态变化:仅作辅助证据,没有确认规则前不替代老人或现场人员的回应。
|
||||
|
||||
回应 API 绑定老人/事件/一次性或短期 challenge,服务端核验权限和有效期,防止旧回应解除新的异常。老人端断网、没带手机或通知没送到记 UNKNOWN/UNREACHABLE 等原因,不能直接判定医学意义的无反应。
|
||||
|
||||
中控页面声音受浏览器播放权限和用户设置限制;提供启用声音的交互和显著视觉兜底。WebSocket 断开后重连拉取未处理事件,确保告警不只活在内存推送里。管理员无 ACK 时可升级备用人,名单和时限待定。
|
||||
|
||||
### 12.4 自动拨打 120 的接入边界
|
||||
|
||||
保存为目标需求,预留 EmergencyContactGateway,但尚未确定能调用的外部通道、所在地区/机构接入方式、可传地址和回调、自动触发条件。普通短信渠道不能直接等同于外呼服务,手机 tel: 链接也不等同于服务端自动报警。
|
||||
|
||||
第一版可以实现告警、升级、管理员操作记录和模拟网关联调;没有真实通道时明确显示“未配置/人工联系”,不能展示已报警成功。将来经确认启用后,遵守同一事件幂等/取消/反馈合同,防止重复触发外部呼叫。
|
||||
|
||||
## 13. 建议数据对象:先保持轻量
|
||||
|
||||
以下是逻辑对象草案,不是必须一次建齐的表清单。按当前阶段逐步建表,复杂字段和重复数据避免过早加入。
|
||||
|
||||
| 对象 | 关键字段/关系 | 第一阶段建议 |
|
||||
| --- | --- | --- |
|
||||
| 运动项目 | 名称、启停、强度/建议时长、assist_policy、技能要求 | 建立 |
|
||||
| 场地 | 名称、地址、负责人、容量、开放时段、占用政策 | 建立 |
|
||||
| 设备 | device_code、场地、类型、启停/心跳、能力、协议 | 轻量登记;真实事件接入后续 |
|
||||
| 活动场次 | 项目、场地、起止时间、容量、公开/私人、状态、协助配置 | 若支持活动报名则建立;私人预约可复用 |
|
||||
| 预约/报名 | 老人 ID、场次/项目、时间、协助意愿/结果、决策依据、状态 | 建立 |
|
||||
| 协助工单 | 场次/预约范围、需要人数、负责人、服务状态、实际时间 | 仅需要协助时创建 |
|
||||
| 工单参与关系 | 工单与需要协助的预约/老人 | 支持多人,不复制全部老人档案 |
|
||||
| 志愿者资料 | member_id、服务区域、技能、可用时段、状态 | 复用/扩展旧档案 |
|
||||
| 指派记录 | 工单、志愿者、来源、尝试/结果、截止时间、拒单原因 | 建立,兼作拒单审计来源 |
|
||||
| 运动会话与参与者 | 实际起止、活动、工单、老人、人员跟踪映射 | 可先简化;为设备预留关联 |
|
||||
| 提醒/通知任务 | 业务 ID、接收者与主体类型、渠道、计划时间、状态/重试、幂等键 | 建立/扩展旧提醒 |
|
||||
| 投诉 | 服务/会话/老人、投诉人、内容/附件、处理状态 | 基础流程 |
|
||||
| 检测事件/摘要 | event_id、设备、时间、会话、track、模型版本、payload 引用 | 先定合同,设备接入时建立 |
|
||||
| 录像片段索引 | 设备/场地、起止、object_key、expire_at、删除状态 | 录像接入时建立 |
|
||||
| 安全告警与处置日志 | 来源、上下文、严重度、状态、回应、升级、处理记录 | 中控阶段建立 |
|
||||
| AI 分析/建议记录 | 输入引用、模型/规则版本、结构化建议、状态/有效期、采纳结果 | 模型阶段建立 |
|
||||
|
||||
普通后台参数如提醒提前量、拒单次数、派单响应时限、匹配权重可先用模块配置/芋道参数配置;需要各机构不同规则时再做 tenant 级业务设置。不要为了几个配置先实现复杂规则编辑器。
|
||||
|
||||
重要唯一/并发约束:同一老人同场次重复报名、同一设备事件重试、同一有效指派、同一提醒/渠道发送、同一告警外呼请求均需幂等。预约改期/取消同步调整提醒、释放容量、撤销指派,整个流程用服务层事务和必要异步补偿。
|
||||
|
||||
## 14. 提醒、通知和定时任务
|
||||
|
||||
运动提醒和协助派单是不同触发事件:可以在预约确认后立即发起匹配,提前提醒按配置执行;具体提前多久由业务配置,不能把“到运动时间才派单”作为固定实现。
|
||||
|
||||
提醒支持至少开始前提醒、变更/取消通知、志愿者待接单提醒、临近开始协助不足提醒;异常升级有自己的规则。老人可配置自己想运动的时间,但关闭普通运动提醒不应关闭后台安全告警。
|
||||
|
||||
调度采用持久化任务 + 芋道 Job 扫描到期任务,而不是为每个预约创建一个内存定时器。Job 扫描、领取、执行、重试有状态;服务重启、重复扫描不重复发。使用业务键约束,并记录变更版本,防止改期后旧任务仍通知。
|
||||
|
||||
站内信、WebSocket、短信通过通知适配调用 system/infra/framework 能力。每个渠道保存结果,推送失败可重试或升级;发送成功只表示渠道接受/反馈结果,不能冒充实际阅读或回应。
|
||||
|
||||
App 后台/关闭时 WebSocket 不保证收到提醒,未来选择短信、平台订阅消息、App 推送或本地通知补充。用户尚未明确普通运动提醒采用哪些渠道,不能承诺关闭手机 App 也必达。
|
||||
|
||||
## 15. 分阶段落地与验收重点
|
||||
|
||||
用户已确认第一阶段先完成核心流程并预留摄像头/智能体接口;之后 B~D 阶段是待进一步安排的候选。
|
||||
|
||||
| 阶段 | 工作 | 边界 |
|
||||
| --- | --- | --- |
|
||||
| A(已确认核心范围) | 旧健康档案/评估迁移、可信身份、运动项目、轻量场地/设备、自主预约和活动报名、协助政策、规则/手动派单、服务状态、提醒 | 可运行核心服务闭环,按月拒单且理由必填;规则判断明确标为规则;投诉基础流程是相关设计建议,实施排期再细化 |
|
||||
| B | 结构化模型评估、运动建议、受控智能体工具和可解释匹配 | 明确模型提供商/规则,建议与执行区分 |
|
||||
| C | 固定摄像头适配、人员映射、动作摘要、跌倒告警、中控、72 小时录像 | 与课题组联调,真实协议和算法输出到位 |
|
||||
| D | 回应核验、升级与家属短信、真实外部紧急呼叫 | 确认回应政策和实际通道后再启用 |
|
||||
|
||||
第一阶段即使不连接设备/模型,也应把 session_id、多人参与、决策来源、协议适配和事件幂等留好,避免以后推翻预约与工单模型。只做接口合同和必要关联,不创建空壳大型平台。
|
||||
|
||||
关键验收场景:
|
||||
|
||||
- 不需要协助、老人主动需要、强制需要三类预约;协助未落实的正确展示。
|
||||
- 单人自主预约、多人场次、同场地容量与时间冲突、并发最后一个名额。
|
||||
- 自动与手动派单、拒单理由不能为空、按月上限/跨月计数、拒单/超时/撤销区分、达到拒单上限、无候选与快到开始时升级、网络重试幂等。
|
||||
- 老人取消/改期时容量/派单/提醒一致;多人中一个取消不关闭全场工单。
|
||||
- 志愿者确认服务完成、多人责任人、未协助运动开始/结束、投诉授权与保留历史。
|
||||
- 老人/家属/志愿者/管理员与不同租户的数据范围;同名 ID 不混主体。
|
||||
- 模型不可用/资料缺失时有明确状态;建议不得越过容量和强制协助政策;不得把静态文案伪装成模型结果。
|
||||
- 设备离线与老人异常分开;匿名 track 的场地告警、多摄像头重复/乱序、身份映射与动作归属。
|
||||
- 72 小时清理边界、重启补偿、删除失败重试、教学视频不受录像策略影响。
|
||||
- 中控断线重连、声音不可用的视觉提示;管理员 ACK 与老人回应分开;无通道时不展示呼叫成功。
|
||||
|
||||
## 16. 后续仍需确认但不阻碍写设计的事项
|
||||
|
||||
第 2 节三项已确认;以下问题后续按实施阶段逐步细化:
|
||||
|
||||
- 首批运动项目、场地和老人/志愿者规模;是否有真实名单/区域资料。
|
||||
- 志愿者技能/资质、可用时段采集方式、单次最大协助人数、每月拒单上限、自然月/滚动月边界、改派/拒单例外和达到上限的措施。
|
||||
- 场次报名、自动计划的确认方式、预约提前/取消/改期时间限制、场地占用规则。
|
||||
- 普通提醒渠道、通知提前量、服务无回应/无候选处理时限。
|
||||
- 大模型提供商、现有智能体研究方案、年龄/基础病/量表规则与资料有效期。
|
||||
- 摄像头设备协议、时钟同步、event_id、人体跟踪 ID 持续性、动作分定义、场地人员绑定方式。
|
||||
- NVR/录像保存位置、分片长度、是否允许事故片段超过 72 小时保存。
|
||||
- 异常等级、有效回应渠道、严重事件直升规则、备用管理员、短信触发条件。
|
||||
- 120/其他紧急联系人呼叫实际通道和反馈,失败人工接管流程。
|
||||
|
||||
后续 Codex 读取本文时,先分清第 1~2 节用户确认、第 16 节待定与其他章节设计建议,再按当前芋道工程实现;不要把所有待定项一次性要求用户回答,也不要未经实现/验证就报告集成完成。
|
||||
201
mysql_script/health/V000__member_schema.sql
Normal file
201
mysql_script/health/V000__member_schema.sql
Normal file
@ -0,0 +1,201 @@
|
||||
-- Extracted member DDL only; no DROP, INSERT, sample accounts or foreign-key switch.
|
||||
-- Mobile uniqueness is scoped to tenant. Existing tables are not automatically altered.
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_address` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '收件地址编号',
|
||||
`user_id` bigint NOT NULL COMMENT '用户编号',
|
||||
`name` varchar(10) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '收件人名称',
|
||||
`mobile` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '手机号',
|
||||
`area_id` bigint NOT NULL COMMENT '地区编码',
|
||||
`detail_address` varchar(250) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '收件详细地址',
|
||||
`default_status` bit(1) NOT NULL COMMENT '是否默认',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `idx_userId`(`user_id` ASC) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_bin COMMENT = '用户收件地址';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_config` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
|
||||
`point_trade_deduct_enable` bit(1) NOT NULL COMMENT '是否开启积分抵扣',
|
||||
`point_trade_deduct_unit_price` int NOT NULL COMMENT '积分抵扣(单位:分)',
|
||||
`point_trade_deduct_max_price` int NULL DEFAULT NULL COMMENT '积分抵扣最大值',
|
||||
`point_trade_give_point` bigint NULL DEFAULT NULL COMMENT '1 元赠送多少分',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '会员配置表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_experience_record` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
|
||||
`user_id` bigint NOT NULL DEFAULT 0 COMMENT '用户编号',
|
||||
`biz_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '业务编号',
|
||||
`biz_type` tinyint NOT NULL DEFAULT 0 COMMENT '业务类型',
|
||||
`title` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '标题',
|
||||
`description` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '描述',
|
||||
`experience` int NOT NULL DEFAULT 0 COMMENT '经验',
|
||||
`total_experience` int NOT NULL DEFAULT 0 COMMENT '变更后的经验',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `idx_user_id`(`user_id` ASC) USING BTREE COMMENT '会员经验记录-用户编号',
|
||||
INDEX `idx_user_biz_type`(`user_id` ASC, `biz_type` ASC) USING BTREE COMMENT '会员经验记录-用户业务类型'
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '会员经验记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_group` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
|
||||
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '名称',
|
||||
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态',
|
||||
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '备注',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户分组';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_level` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
|
||||
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '等级名称',
|
||||
`level` int NOT NULL DEFAULT 0 COMMENT '等级',
|
||||
`experience` int NOT NULL DEFAULT 0 COMMENT '升级经验',
|
||||
`discount_percent` tinyint NOT NULL DEFAULT 100 COMMENT '享受折扣',
|
||||
`icon` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '等级图标',
|
||||
`background_url` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '等级背景图',
|
||||
`status` tinyint NOT NULL DEFAULT 0 COMMENT '状态',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '会员等级';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_level_record` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
|
||||
`user_id` bigint NOT NULL DEFAULT 0 COMMENT '用户编号',
|
||||
`level_id` bigint NOT NULL DEFAULT 0 COMMENT '等级编号',
|
||||
`level` int NOT NULL DEFAULT 0 COMMENT '会员等级',
|
||||
`discount_percent` tinyint NOT NULL DEFAULT 100 COMMENT '享受折扣',
|
||||
`experience` int NOT NULL DEFAULT 0 COMMENT '升级经验',
|
||||
`user_experience` int NOT NULL DEFAULT 0 COMMENT '会员此时的经验',
|
||||
`remark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '备注',
|
||||
`description` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '描述',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `idx_user_id`(`user_id` ASC) USING BTREE COMMENT '会员等级记录-用户编号'
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '会员等级记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_point_record` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '自增主键',
|
||||
`user_id` bigint NOT NULL COMMENT '用户编号',
|
||||
`biz_id` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务编码',
|
||||
`biz_type` tinyint NOT NULL COMMENT '业务类型',
|
||||
`title` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '积分标题',
|
||||
`description` varchar(5000) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '积分描述',
|
||||
`point` int NOT NULL COMMENT '积分',
|
||||
`total_point` int NOT NULL COMMENT '变动后的积分',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
INDEX `index_userId`(`user_id` ASC) USING BTREE,
|
||||
INDEX `index_title`(`title` ASC) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '用户积分记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_sign_in_config` (
|
||||
`id` int NOT NULL AUTO_INCREMENT COMMENT '编号',
|
||||
`day` int NOT NULL COMMENT '第几天',
|
||||
`point` int NOT NULL COMMENT '奖励积分',
|
||||
`experience` int NOT NULL DEFAULT 0 COMMENT '奖励经验',
|
||||
`status` tinyint NOT NULL COMMENT '状态',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '签到规则';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_sign_in_record` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '签到自增id',
|
||||
`user_id` int NULL DEFAULT NULL COMMENT '签到用户',
|
||||
`day` int NULL DEFAULT NULL COMMENT '第几天签到',
|
||||
`point` int NOT NULL DEFAULT 0 COMMENT '签到的分数',
|
||||
`experience` int NOT NULL DEFAULT 0 COMMENT '奖励经验',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '签到记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_tag` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
|
||||
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT '标签名称',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '会员标签';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_user` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号',
|
||||
`mobile` varchar(11) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '手机号',
|
||||
`password` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '密码',
|
||||
`status` tinyint NOT NULL COMMENT '状态',
|
||||
`register_ip` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL COMMENT '注册 IP',
|
||||
`register_terminal` tinyint NULL DEFAULT NULL COMMENT '注册终端',
|
||||
`login_ip` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL DEFAULT '' COMMENT '最后登录IP',
|
||||
`login_date` datetime NULL DEFAULT NULL COMMENT '最后登录时间',
|
||||
`nickname` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '用户昵称',
|
||||
`avatar` varchar(512) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '' COMMENT '头像',
|
||||
`name` varchar(30) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '真实名字',
|
||||
`sex` tinyint NULL DEFAULT 0 COMMENT '用户性别',
|
||||
`area_id` bigint NULL DEFAULT NULL COMMENT '所在地',
|
||||
`birthday` datetime NULL DEFAULT NULL COMMENT '出生日期',
|
||||
`mark` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '会员备注',
|
||||
`point` int NOT NULL DEFAULT 0 COMMENT '积分',
|
||||
`tag_ids` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT NULL COMMENT '用户标签编号列表,以逗号分隔',
|
||||
`level_id` bigint NULL DEFAULT NULL COMMENT '等级编号',
|
||||
`experience` int NOT NULL DEFAULT 0 COMMENT '经验',
|
||||
`group_id` bigint NULL DEFAULT NULL COMMENT '用户分组编号',
|
||||
`creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`) USING BTREE,
|
||||
UNIQUE KEY `uk_member_mobile_tenant` (`tenant_id`, `mobile`)
|
||||
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_general_ci COMMENT = '会员用户';
|
||||
89
mysql_script/health/V001__health_identity.sql
Normal file
89
mysql_script/health/V001__health_identity.sql
Normal file
@ -0,0 +1,89 @@
|
||||
-- 健康模块第一阶段:只新增表,不删除或导入旧数据。
|
||||
-- 先执行 V001,再执行 V002 菜单脚本。重复执行不会清空数据,也不会升级已存在表的字段。
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_identity (
|
||||
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
member_id bigint NOT NULL COMMENT 'member_user.id;不是 system_user.id',
|
||||
identity_type tinyint NOT NULL COMMENT '10 老人、20 志愿者、30 家属;创建后不更换',
|
||||
real_name varchar(30) NOT NULL COMMENT '真实姓名',
|
||||
contact_phone varchar(30) NULL COMMENT '联系方式,可与登录手机号不同',
|
||||
gender tinyint NOT NULL DEFAULT 3 COMMENT '1 男、2 女、3 未知',
|
||||
birth_date date NULL COMMENT '出生日期',
|
||||
address varchar(500) NULL COMMENT '地址',
|
||||
status tinyint NOT NULL DEFAULT 0 COMMENT '0 启用、1 停用',
|
||||
audit_status tinyint NOT NULL COMMENT '0 待审、1 通过、2 驳回',
|
||||
audit_user_id bigint NULL COMMENT '审核管理员 system_users.id',
|
||||
audit_time datetime NULL COMMENT '最近审核时间',
|
||||
audit_reason varchar(500) NULL COMMENT '最近审核理由',
|
||||
creator varchar(64) DEFAULT '' COMMENT '创建者',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updater varchar(64) DEFAULT '' COMMENT '更新者',
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted bit(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除标记;身份管理使用停用保留历史',
|
||||
tenant_id bigint NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_health_identity_member (tenant_id, member_id),
|
||||
KEY idx_health_identity_type (tenant_id, identity_type, status, audit_status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='健康业务身份,一个账号一种身份';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_elderly_profile (
|
||||
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
identity_id bigint NOT NULL COMMENT 'health_identity.id,必须是老人身份',
|
||||
emergency_contact varchar(60) NULL COMMENT '紧急联系人',
|
||||
emergency_phone varchar(30) NULL COMMENT '紧急联系电话',
|
||||
health_status varchar(500) NULL COMMENT '健康状况摘要,不替代历次评估',
|
||||
medical_history varchar(2000) NULL COMMENT '病史',
|
||||
medication_info varchar(2000) NULL COMMENT '用药信息',
|
||||
special_needs varchar(1000) NULL COMMENT '特殊需求',
|
||||
creator varchar(64) DEFAULT '' COMMENT '创建者',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updater varchar(64) DEFAULT '' COMMENT '更新者',
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted bit(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除标记;身份管理使用停用保留历史',
|
||||
tenant_id bigint NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_health_elderly_identity (tenant_id, identity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='老人基础健康档案';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_volunteer_profile (
|
||||
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
identity_id bigint NOT NULL COMMENT 'health_identity.id,必须是志愿者身份',
|
||||
skills varchar(1000) NULL COMMENT '服务技能',
|
||||
service_areas varchar(500) NULL COMMENT '服务区域;后续匹配可细化',
|
||||
available_time varchar(1000) NULL COMMENT '可服务时间说明;后续排班可细化',
|
||||
service_experience varchar(2000) NULL COMMENT '服务经验',
|
||||
creator varchar(64) DEFAULT '' COMMENT '创建者',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updater varchar(64) DEFAULT '' COMMENT '更新者',
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted bit(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除标记;身份管理使用停用保留历史',
|
||||
tenant_id bigint NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_health_volunteer_identity (tenant_id, identity_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='志愿者服务档案';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_family_relation (
|
||||
id bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
family_identity_id bigint NOT NULL COMMENT '家属 health_identity.id',
|
||||
elderly_profile_id bigint NOT NULL COMMENT '老人 health_elderly_profile.id,不是 member_id',
|
||||
relationship varchar(30) NOT NULL COMMENT '与老人关系',
|
||||
status tinyint NOT NULL DEFAULT 0 COMMENT '0 待确认、1 已确认、2 已拒绝、3 已解除',
|
||||
review_user_type tinyint NULL COMMENT '确认者账号类型:1 member、2 admin',
|
||||
review_user_id bigint NULL COMMENT '确认者账号编号,结合 user_type 解释',
|
||||
review_time datetime NULL COMMENT '最近确认或拒绝时间',
|
||||
review_reason varchar(500) NULL COMMENT '管理员确认必须记录核实理由',
|
||||
revoke_user_type tinyint NULL COMMENT '解除者账号类型',
|
||||
revoke_user_id bigint NULL COMMENT '解除者账号编号',
|
||||
revoke_time datetime NULL COMMENT '最近解除时间',
|
||||
revoke_reason varchar(500) NULL COMMENT '解除理由',
|
||||
creator varchar(64) DEFAULT '' COMMENT '创建者',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updater varchar(64) DEFAULT '' COMMENT '更新者',
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted bit(1) NOT NULL DEFAULT b'0' COMMENT '逻辑删除标记;身份管理使用停用保留历史',
|
||||
tenant_id bigint NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_health_family_pair (tenant_id, family_identity_id, elderly_profile_id),
|
||||
KEY idx_health_family_elderly (tenant_id, elderly_profile_id, status)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='家属与老人多对多关联,确认后授权';
|
||||
38
mysql_script/health/V002__health_menu.sql
Normal file
38
mysql_script/health/V002__health_menu.sql
Normal file
@ -0,0 +1,38 @@
|
||||
-- 仅新增健康菜单与权限,超级管理员会自动获得;普通管理员通过角色授权。
|
||||
SET NAMES utf8mb4;
|
||||
START TRANSACTION;
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,status,visible,keep_alive,always_show)
|
||||
SELECT '健康服务','',1,5,0,'/health','ep:first-aid-kit',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE parent_id=0 AND path='/health' AND deleted=b'0');
|
||||
SET @health_menu = (SELECT id FROM system_menu WHERE parent_id=0 AND path='/health' AND deleted=b'0' LIMIT 1);
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show)
|
||||
SELECT '身份与档案','health:identity:query',2,1,@health_menu,'identity','ep:user','health/identity/index','HealthIdentity',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='health:identity:query' AND deleted=b'0');
|
||||
SET @health_child = (SELECT id FROM system_menu WHERE permission='health:identity:query' AND deleted=b'0' LIMIT 1);
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,status,visible,keep_alive,always_show)
|
||||
SELECT '新增档案','health:identity:create',3,1,@health_child,'',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='health:identity:create' AND deleted=b'0');
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,status,visible,keep_alive,always_show)
|
||||
SELECT '编辑档案','health:identity:update',3,1,@health_child,'',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='health:identity:update' AND deleted=b'0');
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,status,visible,keep_alive,always_show)
|
||||
SELECT '志愿者审核','health:identity:audit',3,1,@health_child,'',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='health:identity:audit' AND deleted=b'0');
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,status,visible,keep_alive,always_show)
|
||||
SELECT '启用停用','health:identity:update-status',3,1,@health_child,'',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='health:identity:update-status' AND deleted=b'0');
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,icon,component,component_name,status,visible,keep_alive,always_show)
|
||||
SELECT '家属关联','health:family-relation:query',2,2,@health_menu,'family-relation','ep:connection','health/family-relation/index','HealthFamilyRelation',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='health:family-relation:query' AND deleted=b'0');
|
||||
SET @health_child = (SELECT id FROM system_menu WHERE permission='health:family-relation:query' AND deleted=b'0' LIMIT 1);
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,status,visible,keep_alive,always_show)
|
||||
SELECT '新增关联','health:family-relation:create',3,1,@health_child,'',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='health:family-relation:create' AND deleted=b'0');
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,status,visible,keep_alive,always_show)
|
||||
SELECT '核实确认及重提','health:family-relation:review',3,1,@health_child,'',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='health:family-relation:review' AND deleted=b'0');
|
||||
INSERT INTO system_menu (name,permission,type,sort,parent_id,path,status,visible,keep_alive,always_show)
|
||||
SELECT '解除关联','health:family-relation:revoke',3,1,@health_child,'',0,b'1',b'1',b'1'
|
||||
WHERE NOT EXISTS (SELECT 1 FROM system_menu WHERE permission='health:family-relation:revoke' AND deleted=b'0');
|
||||
COMMIT;
|
||||
|
||||
1
mysql_script/health/目录说明.txt
Normal file
1
mysql_script/health/目录说明.txt
Normal file
@ -0,0 +1 @@
|
||||
健康模块增量 SQL:V000 只提取会员表结构,V001 建立身份与基础档案,V002 新增后台菜单及权限。没有旧数据导入和 DROP TABLE。由 deploy/local/initialize-health.ps1 应用。
|
||||
13
yudao-module-health/pom.xml
Normal file
13
yudao-module-health/pom.xml
Normal file
@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>cn.iocoder.boot</groupId><artifactId>yudao</artifactId><version>${revision}</version></parent>
|
||||
<artifactId>yudao-module-health</artifactId>
|
||||
<packaging>pom</packaging>
|
||||
<description>老年健康业务:身份、健康档案及后续评估、运动预约和志愿服务</description>
|
||||
<modules>
|
||||
<module>yudao-module-health-api</module>
|
||||
<module>yudao-module-health-biz</module>
|
||||
</modules>
|
||||
</project>
|
||||
11
yudao-module-health/yudao-module-health-api/pom.xml
Normal file
11
yudao-module-health/yudao-module-health-api/pom.xml
Normal file
@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>cn.iocoder.boot</groupId><artifactId>yudao-module-health</artifactId><version>${revision}</version></parent>
|
||||
<artifactId>yudao-module-health-api</artifactId>
|
||||
<description>健康模块供其他模块调用的身份接口、DTO 与枚举</description>
|
||||
<dependencies>
|
||||
<dependency><groupId>cn.iocoder.boot</groupId><artifactId>yudao-common</artifactId></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@ -0,0 +1,11 @@
|
||||
package cn.iocoder.yudao.module.health.api.identity;
|
||||
|
||||
import cn.iocoder.yudao.module.health.api.identity.dto.HealthIdentityRespDTO;
|
||||
|
||||
/** 后续预约、派单必须调用这里的生效校验,不能信任前端传来的身份。 */
|
||||
public interface HealthIdentityApi {
|
||||
HealthIdentityRespDTO getIdentityByMemberId(Long memberId);
|
||||
HealthIdentityRespDTO requireActiveIdentity(Long memberId, Integer identityType);
|
||||
/** 同时验证家属账号、老人身份和已确认的关联。 */
|
||||
void requireConfirmedFamilyRelation(Long familyMemberId, Long elderlyProfileId);
|
||||
}
|
||||
@ -0,0 +1,14 @@
|
||||
package cn.iocoder.yudao.module.health.api.identity.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 模块间仅传身份信息,健康详情通过受控业务服务获取。 */
|
||||
@Data
|
||||
public class HealthIdentityRespDTO {
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private Integer identityType;
|
||||
private String realName;
|
||||
private Integer status;
|
||||
private Integer auditStatus;
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
模块间调用接口及实现。浏览器请求走 Controller。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.health.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.exception.ErrorCode;
|
||||
|
||||
/** 健康模块使用 1-030 段。 */
|
||||
public interface ErrorCodeConstants {
|
||||
ErrorCode IDENTITY_NOT_EXISTS = new ErrorCode(1_030_001_000, "健康身份不存在");
|
||||
ErrorCode IDENTITY_EXISTS = new ErrorCode(1_030_001_001, "该账号已建立业务身份,每个账号只允许一种身份");
|
||||
ErrorCode IDENTITY_INACTIVE = new ErrorCode(1_030_001_002, "身份未审核通过或已停用");
|
||||
ErrorCode IDENTITY_TYPE_MISMATCH = new ErrorCode(1_030_001_003, "业务身份类型不匹配");
|
||||
ErrorCode IDENTITY_IMMUTABLE = new ErrorCode(1_030_001_004, "已有身份不能更换账号或身份类型");
|
||||
ErrorCode AUDIT_STATE_INVALID = new ErrorCode(1_030_001_005, "审核状态已变化,请刷新后再操作");
|
||||
ErrorCode AUDIT_REASON_REQUIRED = new ErrorCode(1_030_001_006, "请填写审核或核实理由");
|
||||
ErrorCode ACCOUNT_REQUIRED = new ErrorCode(1_030_001_007, "请选择已有会员账号,或填写手机号与初始密码创建账号");
|
||||
ErrorCode MEMBER_INVALID = new ErrorCode(1_030_001_008, "会员账号不存在或已停用");
|
||||
ErrorCode PROFILE_NOT_EXISTS = new ErrorCode(1_030_001_009, "老人档案不存在");
|
||||
ErrorCode RELATION_NOT_EXISTS = new ErrorCode(1_030_002_000, "家属关联不存在");
|
||||
ErrorCode RELATION_EXISTS = new ErrorCode(1_030_002_001, "这位家属与老人已存在关联记录");
|
||||
ErrorCode RELATION_FORBIDDEN = new ErrorCode(1_030_002_002, "只能处理本人的家属关联");
|
||||
ErrorCode RELATION_STATE_INVALID = new ErrorCode(1_030_002_003, "关联状态不支持此操作,请刷新后再操作");
|
||||
ErrorCode RELATION_NOT_CONFIRMED = new ErrorCode(1_030_002_004, "家属关联尚未确认或已解除");
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package cn.iocoder.yudao.module.health.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.IntArrayValuable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import java.util.Arrays;
|
||||
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum HealthAuditStatusEnum implements IntArrayValuable {
|
||||
PENDING(0), APPROVED(1), REJECTED(2);
|
||||
public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(HealthAuditStatusEnum::getStatus).toArray();
|
||||
private final Integer status;
|
||||
@Override public int[] array() { return ARRAYS; }
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package cn.iocoder.yudao.module.health.enums;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.IntArrayValuable;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Getter;
|
||||
import java.util.Arrays;
|
||||
|
||||
/** 业务身份;每个租户中,一个 member 账号只允许一种身份。 */
|
||||
@Getter
|
||||
@AllArgsConstructor
|
||||
public enum HealthIdentityTypeEnum implements IntArrayValuable {
|
||||
ELDERLY(10, "老人"), VOLUNTEER(20, "志愿者"), FAMILY(30, "家属");
|
||||
|
||||
public static final int[] ARRAYS = Arrays.stream(values()).mapToInt(HealthIdentityTypeEnum::getType).toArray();
|
||||
private final Integer type;
|
||||
private final String name;
|
||||
@Override public int[] array() { return ARRAYS; }
|
||||
}
|
||||
@ -0,0 +1,3 @@
|
||||
身份类型、审核状态和健康业务错误码。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
3
yudao-module-health/yudao-module-health-api/目录说明.txt
Normal file
3
yudao-module-health/yudao-module-health-api/目录说明.txt
Normal file
@ -0,0 +1,3 @@
|
||||
供其他模块调用的身份校验接口、DTO、身份及审核枚举。后续预约和派单通过 requireActiveIdentity 校验身份,不直接依赖 biz 的 Mapper。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
18
yudao-module-health/yudao-module-health-biz/pom.xml
Normal file
18
yudao-module-health/yudao-module-health-biz/pom.xml
Normal file
@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<parent><groupId>cn.iocoder.boot</groupId><artifactId>yudao-module-health</artifactId><version>${revision}</version></parent>
|
||||
<artifactId>yudao-module-health-biz</artifactId>
|
||||
<description>健康业务实现;保持账号认证与业务身份分离</description>
|
||||
<dependencies>
|
||||
<dependency><groupId>cn.iocoder.boot</groupId><artifactId>yudao-module-health-api</artifactId><version>${revision}</version></dependency>
|
||||
<dependency><groupId>cn.iocoder.boot</groupId><artifactId>yudao-module-member-api</artifactId><version>${revision}</version></dependency>
|
||||
<dependency><groupId>cn.iocoder.boot</groupId><artifactId>yudao-spring-boot-starter-biz-tenant</artifactId></dependency>
|
||||
<dependency><groupId>cn.iocoder.boot</groupId><artifactId>yudao-spring-boot-starter-security</artifactId></dependency>
|
||||
<dependency><groupId>cn.iocoder.boot</groupId><artifactId>yudao-spring-boot-starter-mybatis</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
|
||||
<dependency><groupId>cn.iocoder.boot</groupId><artifactId>yudao-spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
<dependency><groupId>cn.iocoder.boot</groupId><artifactId>yudao-module-member-biz</artifactId><version>${revision}</version><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.health.api.identity;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.health.api.identity.dto.HealthIdentityRespDTO;
|
||||
import cn.iocoder.yudao.module.health.service.identity.HealthIdentityService;
|
||||
import cn.iocoder.yudao.module.health.service.relation.HealthFamilyRelationService;
|
||||
import org.springframework.stereotype.Service;
|
||||
import javax.annotation.Resource;
|
||||
|
||||
@Service
|
||||
public class HealthIdentityApiImpl implements HealthIdentityApi {
|
||||
@Resource private HealthIdentityService identityService;
|
||||
@Resource private HealthFamilyRelationService relationService;
|
||||
@Override public HealthIdentityRespDTO getIdentityByMemberId(Long memberId) {
|
||||
return BeanUtils.toBean(identityService.getIdentityByMemberId(memberId), HealthIdentityRespDTO.class);
|
||||
}
|
||||
@Override public HealthIdentityRespDTO requireActiveIdentity(Long memberId, Integer identityType) {
|
||||
return BeanUtils.toBean(identityService.requireActiveIdentity(memberId, identityType), HealthIdentityRespDTO.class);
|
||||
}
|
||||
@Override public void requireConfirmedFamilyRelation(Long familyMemberId, Long elderlyProfileId) {
|
||||
relationService.requireConfirmedFamilyRelation(familyMemberId, elderlyProfileId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
模块间调用接口及实现。浏览器请求走 Controller。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
@ -0,0 +1,67 @@
|
||||
package cn.iocoder.yudao.module.health.controller.admin.identity;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.*;
|
||||
import cn.iocoder.yudao.module.health.controller.admin.identity.vo.*;
|
||||
import cn.iocoder.yudao.module.health.controller.vo.HealthIdentityRespVO;
|
||||
import cn.iocoder.yudao.module.health.service.identity.HealthIdentityService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import javax.annotation.Resource;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.Positive;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
|
||||
@Tag(name = "管理后台 - 健康身份与基础档案")
|
||||
@RestController
|
||||
@RequestMapping("/health/identity")
|
||||
@Validated
|
||||
public class HealthIdentityController {
|
||||
@Resource private HealthIdentityService identityService;
|
||||
|
||||
@PostMapping("/create")
|
||||
@Operation(summary = "创建账号、身份及对应档案(同一事务)")
|
||||
@PreAuthorize("@ss.hasPermission('health:identity:create')")
|
||||
public CommonResult<Long> create(@Valid @RequestBody HealthIdentitySaveReqVO req) {
|
||||
return success(identityService.createIdentity(req));
|
||||
}
|
||||
@PutMapping("/update")
|
||||
@Operation(summary = "编辑档案,不更换账号和身份类型")
|
||||
@PreAuthorize("@ss.hasPermission('health:identity:update')")
|
||||
public CommonResult<Boolean> update(@Valid @RequestBody HealthIdentitySaveReqVO req) {
|
||||
identityService.updateIdentity(req); return success(true);
|
||||
}
|
||||
@GetMapping("/get")
|
||||
@Operation(summary = "读取档案详情")
|
||||
@PreAuthorize("@ss.hasPermission('health:identity:query')")
|
||||
public CommonResult<HealthIdentityRespVO> get(@RequestParam @Positive Long id) {
|
||||
return success(identityService.getIdentityDetails(id));
|
||||
}
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "按身份、启用和审核状态查询")
|
||||
@PreAuthorize("@ss.hasPermission('health:identity:query')")
|
||||
public CommonResult<PageResult<HealthIdentityRespVO>> page(@Valid HealthIdentityPageReqVO req) {
|
||||
return success(identityService.getIdentityPage(req));
|
||||
}
|
||||
@PutMapping("/audit")
|
||||
@Operation(summary = "审核志愿者,驳回必须写理由")
|
||||
@PreAuthorize("@ss.hasPermission('health:identity:audit')")
|
||||
public CommonResult<Boolean> audit(@Valid @RequestBody HealthIdentityAuditReqVO req) {
|
||||
identityService.auditIdentity(req, getLoginUserId()); return success(true);
|
||||
}
|
||||
@PutMapping("/resubmit")
|
||||
@Operation(summary = "将已驳回的志愿者重新提交审核")
|
||||
@PreAuthorize("@ss.hasPermission('health:identity:audit')")
|
||||
public CommonResult<Boolean> resubmit(@RequestParam @Positive Long id) {
|
||||
identityService.resubmitIdentity(id); return success(true);
|
||||
}
|
||||
@PutMapping("/update-status")
|
||||
@Operation(summary = "启用或停用业务身份,保留历史档案")
|
||||
@PreAuthorize("@ss.hasPermission('health:identity:update-status')")
|
||||
public CommonResult<Boolean> updateStatus(@Valid @RequestBody HealthIdentityStatusReqVO req) {
|
||||
identityService.updateStatus(req); return success(true);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,15 @@
|
||||
package cn.iocoder.yudao.module.health.controller.admin.identity.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.*;
|
||||
|
||||
@Data
|
||||
public class HealthIdentityAuditReqVO {
|
||||
@NotNull @Positive
|
||||
private Long id;
|
||||
@NotNull
|
||||
private Boolean approved;
|
||||
@Size(max = 500)
|
||||
private String reason;
|
||||
}
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
package cn.iocoder.yudao.module.health.controller.admin.identity.vo;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.validation.InEnum;
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.module.health.enums.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class HealthIdentityPageReqVO extends PageParam {
|
||||
private String realName;
|
||||
private Long memberId;
|
||||
@InEnum(HealthIdentityTypeEnum.class)
|
||||
private Integer identityType;
|
||||
@InEnum(CommonStatusEnum.class)
|
||||
private Integer status;
|
||||
@InEnum(HealthAuditStatusEnum.class)
|
||||
private Integer auditStatus;
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.health.controller.admin.identity.vo;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.validation.InEnum;
|
||||
import cn.iocoder.yudao.framework.common.validation.Mobile;
|
||||
import cn.iocoder.yudao.module.health.controller.vo.HealthProfileFieldsVO;
|
||||
import cn.iocoder.yudao.module.health.enums.HealthIdentityTypeEnum;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import lombok.ToString;
|
||||
import javax.validation.constraints.*;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class HealthIdentitySaveReqVO extends HealthProfileFieldsVO {
|
||||
private Long id;
|
||||
/** 非空时仅绑定已有账号,不修改账号密码;为空时创建新账号。 */
|
||||
@Positive
|
||||
private Long memberId;
|
||||
@Mobile
|
||||
private String mobile;
|
||||
@Size(min = 4, max = 16) @ToString.Exclude
|
||||
private String password;
|
||||
@NotNull @InEnum(HealthIdentityTypeEnum.class)
|
||||
private Integer identityType;
|
||||
}
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
package cn.iocoder.yudao.module.health.controller.admin.identity.vo;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.common.validation.InEnum;
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.*;
|
||||
|
||||
@Data
|
||||
public class HealthIdentityStatusReqVO {
|
||||
@NotNull @Positive
|
||||
private Long id;
|
||||
@NotNull @InEnum(CommonStatusEnum.class)
|
||||
private Integer status;
|
||||
}
|
||||
|
||||
@ -0,0 +1,49 @@
|
||||
package cn.iocoder.yudao.module.health.controller.admin.relation;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.*;
|
||||
import cn.iocoder.yudao.module.health.controller.vo.*;
|
||||
import cn.iocoder.yudao.module.health.controller.admin.relation.vo.HealthFamilyRelationPageReqVO;
|
||||
import cn.iocoder.yudao.module.health.service.relation.HealthFamilyRelationService;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import javax.annotation.Resource;
|
||||
import javax.validation.Valid;
|
||||
import javax.validation.constraints.Positive;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
|
||||
@Tag(name = "管理后台 - 家属关联")
|
||||
@RestController
|
||||
@RequestMapping("/health/family-relation")
|
||||
@Validated
|
||||
public class HealthFamilyRelationController {
|
||||
@Resource private HealthFamilyRelationService relationService;
|
||||
@GetMapping("/page")
|
||||
@PreAuthorize("@ss.hasPermission('health:family-relation:query')")
|
||||
public CommonResult<PageResult<HealthFamilyRelationRespVO>> page(@Valid HealthFamilyRelationPageReqVO req) {
|
||||
return success(relationService.getRelationPage(req));
|
||||
}
|
||||
@PostMapping("/create")
|
||||
@PreAuthorize("@ss.hasPermission('health:family-relation:create')")
|
||||
public CommonResult<Long> create(@Valid @RequestBody HealthFamilyRelationCreateReqVO req) {
|
||||
return success(relationService.createRelation(req));
|
||||
}
|
||||
@PutMapping("/review")
|
||||
@PreAuthorize("@ss.hasPermission('health:family-relation:review')")
|
||||
public CommonResult<Boolean> review(@Valid @RequestBody HealthFamilyRelationReviewReqVO req) {
|
||||
relationService.reviewRelation(req, getLoginUserId(), true); return success(true);
|
||||
}
|
||||
@PutMapping("/revoke")
|
||||
@PreAuthorize("@ss.hasPermission('health:family-relation:revoke')")
|
||||
public CommonResult<Boolean> revoke(@Valid @RequestBody HealthFamilyRelationRevokeReqVO req) {
|
||||
relationService.revokeRelation(req, getLoginUserId(), true); return success(true);
|
||||
}
|
||||
@PutMapping("/reopen")
|
||||
@PreAuthorize("@ss.hasPermission('health:family-relation:review')")
|
||||
public CommonResult<Boolean> reopen(@RequestParam @Positive Long id) {
|
||||
relationService.reopenRelation(id); return success(true);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,16 @@
|
||||
package cn.iocoder.yudao.module.health.controller.admin.relation.vo;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import javax.validation.constraints.*;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class HealthFamilyRelationPageReqVO extends PageParam {
|
||||
private Long familyIdentityId;
|
||||
private Long elderlyProfileId;
|
||||
@Min(0) @Max(3)
|
||||
private Integer status;
|
||||
}
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
package cn.iocoder.yudao.module.health.controller.app.identity;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.module.health.controller.vo.*;
|
||||
import cn.iocoder.yudao.module.health.service.identity.HealthIdentityService;
|
||||
import cn.iocoder.yudao.module.health.service.relation.HealthFamilyRelationService;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
import org.springframework.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
import javax.annotation.Resource;
|
||||
import javax.validation.Valid;
|
||||
import java.util.List;
|
||||
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
|
||||
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
|
||||
|
||||
/** /app-api 前缀由框架添加,Token 类型由框架校验为 MEMBER。 */
|
||||
@Tag(name = "用户 APP - 本人身份与家属关联")
|
||||
@RestController
|
||||
@RequestMapping("/health/identity")
|
||||
@Validated
|
||||
@PreAuthorize("isAuthenticated()")
|
||||
public class AppHealthIdentityController {
|
||||
@Resource private HealthIdentityService identityService;
|
||||
@Resource private HealthFamilyRelationService relationService;
|
||||
@GetMapping("/me")
|
||||
public CommonResult<HealthIdentityRespVO> me() {
|
||||
return success(identityService.getMyIdentity(getLoginUserId()));
|
||||
}
|
||||
@GetMapping("/family-relations")
|
||||
public CommonResult<List<HealthFamilyRelationRespVO>> myRelations() {
|
||||
return success(relationService.getMyRelations(getLoginUserId()));
|
||||
}
|
||||
@PostMapping("/family-relation/request")
|
||||
public CommonResult<Long> request(@Valid @RequestBody HealthFamilyRelationCreateReqVO req) {
|
||||
return success(relationService.requestMyRelation(getLoginUserId(), req));
|
||||
}
|
||||
@PutMapping("/family-relation/review")
|
||||
public CommonResult<Boolean> review(@Valid @RequestBody HealthFamilyRelationReviewReqVO req) {
|
||||
relationService.reviewRelation(req, getLoginUserId(), false); return success(true);
|
||||
}
|
||||
@PutMapping("/family-relation/revoke")
|
||||
public CommonResult<Boolean> revoke(@Valid @RequestBody HealthFamilyRelationRevokeReqVO req) {
|
||||
relationService.revokeRelation(req, getLoginUserId(), false); return success(true);
|
||||
}
|
||||
@GetMapping("/family-elderly-profile")
|
||||
public CommonResult<HealthIdentityRespVO> familyElderlyProfile(@RequestParam Long elderlyProfileId) {
|
||||
relationService.requireConfirmedFamilyRelation(getLoginUserId(), elderlyProfileId);
|
||||
return success(identityService.getIdentityDetails(identityService.getElderlyIdentityId(elderlyProfileId)));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,16 @@
|
||||
package cn.iocoder.yudao.module.health.controller.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.*;
|
||||
|
||||
@Data
|
||||
public class HealthFamilyRelationCreateReqVO {
|
||||
/** 管理端必填;APP 端忽略此值,始终由登录账号确定。 */
|
||||
@Positive
|
||||
private Long familyIdentityId;
|
||||
@NotNull @Positive
|
||||
private Long elderlyProfileId;
|
||||
@NotBlank @Size(max = 30)
|
||||
private String relationship;
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.health.controller.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
public class HealthFamilyRelationRespVO {
|
||||
private Long id;
|
||||
private Long familyIdentityId;
|
||||
private String familyName;
|
||||
private Long elderlyProfileId;
|
||||
private String elderlyName;
|
||||
private String relationship;
|
||||
/** 0 待确认、1 已确认、2 已拒绝、3 已解除。 */
|
||||
private Integer status;
|
||||
private Integer reviewUserType;
|
||||
private Long reviewUserId;
|
||||
private LocalDateTime reviewTime;
|
||||
private String reviewReason;
|
||||
private Integer revokeUserType;
|
||||
private Long revokeUserId;
|
||||
private LocalDateTime revokeTime;
|
||||
private String revokeReason;
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
||||
@ -0,0 +1,15 @@
|
||||
package cn.iocoder.yudao.module.health.controller.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.*;
|
||||
|
||||
@Data
|
||||
public class HealthFamilyRelationReviewReqVO {
|
||||
@NotNull @Positive
|
||||
private Long id;
|
||||
@NotNull
|
||||
private Boolean approved;
|
||||
@Size(max = 500)
|
||||
private String reason;
|
||||
}
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
package cn.iocoder.yudao.module.health.controller.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.*;
|
||||
|
||||
@Data
|
||||
public class HealthFamilyRelationRevokeReqVO {
|
||||
@NotNull @Positive
|
||||
private Long id;
|
||||
@NotBlank @Size(max = 500)
|
||||
private String reason;
|
||||
}
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
package cn.iocoder.yudao.module.health.controller.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class HealthIdentityRespVO extends HealthProfileFieldsVO {
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private String mobile;
|
||||
private Integer identityType;
|
||||
private Long elderlyProfileId;
|
||||
private Long volunteerProfileId;
|
||||
private Integer status;
|
||||
private Integer auditStatus;
|
||||
private Long auditUserId;
|
||||
private LocalDateTime auditTime;
|
||||
private String auditReason;
|
||||
private LocalDateTime createTime;
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
package cn.iocoder.yudao.module.health.controller.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import javax.validation.constraints.*;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** 管理端写入和本人读取使用相同的档案字段;不包含账号密码或身份审批字段。 */
|
||||
@Data
|
||||
public class HealthProfileFieldsVO {
|
||||
@NotBlank @Size(max = 30)
|
||||
private String realName;
|
||||
@Size(max = 30) @Pattern(regexp = "^$|^[0-9+()\\-]{5,30}$", message = "联系电话格式不正确")
|
||||
private String contactPhone;
|
||||
/** 与芋道一致:1 男、2 女、3 未知,旧项目 0/1 必须转换。 */
|
||||
@NotNull @Min(1) @Max(3)
|
||||
private Integer gender;
|
||||
@PastOrPresent
|
||||
private LocalDate birthDate;
|
||||
@Size(max = 500)
|
||||
private String address;
|
||||
@Size(max = 60)
|
||||
private String emergencyContact;
|
||||
@Size(max = 30) @Pattern(regexp = "^$|^[0-9+()\\-]{5,30}$", message = "紧急联系电话格式不正确")
|
||||
private String emergencyPhone;
|
||||
@Size(max = 500)
|
||||
private String healthStatus;
|
||||
@Size(max = 2000)
|
||||
private String medicalHistory;
|
||||
@Size(max = 2000)
|
||||
private String medicationInfo;
|
||||
@Size(max = 1000)
|
||||
private String specialNeeds;
|
||||
@Size(max = 1000)
|
||||
private String skills;
|
||||
@Size(max = 500)
|
||||
private String serviceAreas;
|
||||
@Size(max = 1000)
|
||||
private String availableTime;
|
||||
@Size(max = 2000)
|
||||
private String serviceExperience;
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
HTTP 接口与输入输出 VO。admin 使用后台权限,app 仅使用登录会员的账号与业务授权。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
@ -0,0 +1,32 @@
|
||||
package cn.iocoder.yudao.module.health.dal.dataobject.identity;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@TableName("health_identity")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class HealthIdentityDO extends TenantBaseDO {
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long memberId;
|
||||
private Integer identityType;
|
||||
private String realName;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String contactPhone;
|
||||
private Integer gender;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private LocalDate birthDate;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String address;
|
||||
private Integer status;
|
||||
private Integer auditStatus;
|
||||
private Long auditUserId;
|
||||
private LocalDateTime auditTime;
|
||||
private String auditReason;
|
||||
}
|
||||
|
||||
@ -0,0 +1,30 @@
|
||||
package cn.iocoder.yudao.module.health.dal.dataobject.profile;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@TableName("health_elderly_profile")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class HealthElderlyProfileDO extends TenantBaseDO {
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long identityId;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String emergencyContact;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String emergencyPhone;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String healthStatus;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String medicalHistory;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String medicationInfo;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String specialNeeds;
|
||||
}
|
||||
|
||||
@ -0,0 +1,26 @@
|
||||
package cn.iocoder.yudao.module.health.dal.dataobject.profile;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@TableName("health_volunteer_profile")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class HealthVolunteerProfileDO extends TenantBaseDO {
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long identityId;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String skills;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String serviceAreas;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String availableTime;
|
||||
@TableField(updateStrategy = FieldStrategy.ALWAYS)
|
||||
private String serviceExperience;
|
||||
}
|
||||
|
||||
@ -0,0 +1,29 @@
|
||||
package cn.iocoder.yudao.module.health.dal.dataobject.relation;
|
||||
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantBaseDO;
|
||||
import com.baomidou.mybatisplus.annotation.*;
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
@TableName("health_family_relation")
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class HealthFamilyRelationDO extends TenantBaseDO {
|
||||
@TableId
|
||||
private Long id;
|
||||
private Long familyIdentityId;
|
||||
private Long elderlyProfileId;
|
||||
private String relationship;
|
||||
private Integer status;
|
||||
private Integer reviewUserType;
|
||||
private Long reviewUserId;
|
||||
private LocalDateTime reviewTime;
|
||||
private String reviewReason;
|
||||
private Integer revokeUserType;
|
||||
private Long revokeUserId;
|
||||
private LocalDateTime revokeTime;
|
||||
private String revokeReason;
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
持久化实体:继承 TenantBaseDO,包含审计字段和租户编号。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
@ -0,0 +1,25 @@
|
||||
package cn.iocoder.yudao.module.health.dal.mysql.identity;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.health.controller.admin.identity.vo.HealthIdentityPageReqVO;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.identity.HealthIdentityDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface HealthIdentityMapper extends BaseMapperX<HealthIdentityDO> {
|
||||
default HealthIdentityDO selectByMemberId(Long memberId) {
|
||||
return selectOne(HealthIdentityDO::getMemberId, memberId);
|
||||
}
|
||||
default PageResult<HealthIdentityDO> selectPage(HealthIdentityPageReqVO req) {
|
||||
return selectPage(req, new LambdaQueryWrapperX<HealthIdentityDO>()
|
||||
.eqIfPresent(HealthIdentityDO::getMemberId, req.getMemberId())
|
||||
.likeIfPresent(HealthIdentityDO::getRealName, req.getRealName())
|
||||
.eqIfPresent(HealthIdentityDO::getIdentityType, req.getIdentityType())
|
||||
.eqIfPresent(HealthIdentityDO::getStatus, req.getStatus())
|
||||
.eqIfPresent(HealthIdentityDO::getAuditStatus, req.getAuditStatus())
|
||||
.orderByDesc(HealthIdentityDO::getId));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
package cn.iocoder.yudao.module.health.dal.mysql.profile;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.profile.HealthElderlyProfileDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface HealthElderlyProfileMapper extends BaseMapperX<HealthElderlyProfileDO> {
|
||||
default HealthElderlyProfileDO selectByIdentityId(Long identityId) {
|
||||
return selectOne(HealthElderlyProfileDO::getIdentityId, identityId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,13 @@
|
||||
package cn.iocoder.yudao.module.health.dal.mysql.profile;
|
||||
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.profile.HealthVolunteerProfileDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface HealthVolunteerProfileMapper extends BaseMapperX<HealthVolunteerProfileDO> {
|
||||
default HealthVolunteerProfileDO selectByIdentityId(Long identityId) {
|
||||
return selectOne(HealthVolunteerProfileDO::getIdentityId, identityId);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,24 @@
|
||||
package cn.iocoder.yudao.module.health.dal.mysql.relation;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.health.controller.admin.relation.vo.HealthFamilyRelationPageReqVO;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.relation.HealthFamilyRelationDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
@Mapper
|
||||
public interface HealthFamilyRelationMapper extends BaseMapperX<HealthFamilyRelationDO> {
|
||||
default HealthFamilyRelationDO selectByPair(Long familyId, Long elderlyProfileId) {
|
||||
return selectOne(HealthFamilyRelationDO::getFamilyIdentityId, familyId,
|
||||
HealthFamilyRelationDO::getElderlyProfileId, elderlyProfileId);
|
||||
}
|
||||
default PageResult<HealthFamilyRelationDO> selectPage(HealthFamilyRelationPageReqVO req) {
|
||||
return selectPage(req, new LambdaQueryWrapperX<HealthFamilyRelationDO>()
|
||||
.eqIfPresent(HealthFamilyRelationDO::getFamilyIdentityId, req.getFamilyIdentityId())
|
||||
.eqIfPresent(HealthFamilyRelationDO::getElderlyProfileId, req.getElderlyProfileId())
|
||||
.eqIfPresent(HealthFamilyRelationDO::getStatus, req.getStatus())
|
||||
.orderByDesc(HealthFamilyRelationDO::getId));
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
MyBatis Mapper。框架负责租户过滤;唯一索引防止并发重复建档。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
@ -0,0 +1,3 @@
|
||||
数据访问层。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
@ -0,0 +1,21 @@
|
||||
package cn.iocoder.yudao.module.health.service.identity;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.health.controller.admin.identity.vo.*;
|
||||
import cn.iocoder.yudao.module.health.controller.vo.HealthIdentityRespVO;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.identity.HealthIdentityDO;
|
||||
|
||||
public interface HealthIdentityService {
|
||||
Long createIdentity(HealthIdentitySaveReqVO req);
|
||||
void updateIdentity(HealthIdentitySaveReqVO req);
|
||||
void auditIdentity(HealthIdentityAuditReqVO req, Long adminId);
|
||||
void resubmitIdentity(Long id);
|
||||
void updateStatus(HealthIdentityStatusReqVO req);
|
||||
HealthIdentityRespVO getIdentityDetails(Long id);
|
||||
PageResult<HealthIdentityRespVO> getIdentityPage(HealthIdentityPageReqVO req);
|
||||
HealthIdentityDO getIdentityByMemberId(Long memberId);
|
||||
HealthIdentityDO requireActiveIdentity(Long memberId, Integer type);
|
||||
HealthIdentityDO requireActiveIdentityById(Long id, Integer type);
|
||||
HealthIdentityRespVO getMyIdentity(Long memberId);
|
||||
Long getElderlyIdentityId(Long elderlyProfileId);
|
||||
}
|
||||
@ -0,0 +1,225 @@
|
||||
package cn.iocoder.yudao.module.health.service.identity;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.health.controller.admin.identity.vo.*;
|
||||
import cn.iocoder.yudao.module.health.controller.vo.HealthIdentityRespVO;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.identity.HealthIdentityDO;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.profile.*;
|
||||
import cn.iocoder.yudao.module.health.dal.mysql.identity.HealthIdentityMapper;
|
||||
import cn.iocoder.yudao.module.health.dal.mysql.profile.*;
|
||||
import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
|
||||
import cn.iocoder.yudao.module.member.api.user.dto.*;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.health.enums.ErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.module.health.enums.HealthIdentityTypeEnum.*;
|
||||
import static cn.iocoder.yudao.module.health.enums.HealthAuditStatusEnum.*;
|
||||
|
||||
@Service
|
||||
public class HealthIdentityServiceImpl implements HealthIdentityService {
|
||||
@Resource private HealthIdentityMapper identityMapper;
|
||||
@Resource private HealthElderlyProfileMapper elderlyProfileMapper;
|
||||
@Resource private HealthVolunteerProfileMapper volunteerProfileMapper;
|
||||
@Resource private MemberUserApi memberUserApi;
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public Long createIdentity(HealthIdentitySaveReqVO req) {
|
||||
Long memberId = req.getMemberId();
|
||||
if (memberId == null) {
|
||||
if (StrUtil.isBlank(req.getMobile()) || StrUtil.isBlank(req.getPassword())) {
|
||||
throw exception(ACCOUNT_REQUIRED);
|
||||
}
|
||||
memberId = memberUserApi.createUser(new MemberUserCreateReqDTO()
|
||||
.setMobile(req.getMobile()).setPassword(req.getPassword()).setNickname(req.getRealName()));
|
||||
} else {
|
||||
validateMember(memberId);
|
||||
if (StrUtil.isNotBlank(req.getPassword()) || StrUtil.isNotBlank(req.getMobile())) {
|
||||
throw exception(ACCOUNT_REQUIRED);
|
||||
}
|
||||
}
|
||||
if (identityMapper.selectByMemberId(memberId) != null) {
|
||||
throw exception(IDENTITY_EXISTS);
|
||||
}
|
||||
HealthIdentityDO identity = BeanUtils.toBean(req, HealthIdentityDO.class)
|
||||
.setId(null).setMemberId(memberId).setStatus(CommonStatusEnum.ENABLE.getStatus())
|
||||
.setAuditStatus(VOLUNTEER.getType().equals(req.getIdentityType())
|
||||
? PENDING.getStatus() : APPROVED.getStatus());
|
||||
try {
|
||||
identityMapper.insert(identity);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
throw exception(IDENTITY_EXISTS);
|
||||
}
|
||||
saveProfile(req, identity, true);
|
||||
return identity.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateIdentity(HealthIdentitySaveReqVO req) {
|
||||
HealthIdentityDO identity = requireIdentity(req.getId());
|
||||
if (!Objects.equals(identity.getIdentityType(), req.getIdentityType())
|
||||
|| (req.getMemberId() != null && !Objects.equals(identity.getMemberId(), req.getMemberId()))) {
|
||||
throw exception(IDENTITY_IMMUTABLE);
|
||||
}
|
||||
if (StrUtil.isNotBlank(req.getPassword()) || StrUtil.isNotBlank(req.getMobile())) {
|
||||
throw exception(IDENTITY_IMMUTABLE);
|
||||
}
|
||||
// Request VO 不包含启用和审核状态,编辑档案不能绕过审批。
|
||||
identityMapper.updateById(BeanUtils.toBean(req, HealthIdentityDO.class));
|
||||
saveProfile(req, identity, false);
|
||||
}
|
||||
|
||||
private void saveProfile(HealthIdentitySaveReqVO req, HealthIdentityDO identity, boolean create) {
|
||||
if (ELDERLY.getType().equals(identity.getIdentityType())) {
|
||||
HealthElderlyProfileDO profile = BeanUtils.toBean(req, HealthElderlyProfileDO.class)
|
||||
.setId(null).setIdentityId(identity.getId());
|
||||
if (create) {
|
||||
elderlyProfileMapper.insert(profile);
|
||||
} else {
|
||||
HealthElderlyProfileDO existing = elderlyProfileMapper.selectByIdentityId(identity.getId());
|
||||
if (existing == null) { throw exception(PROFILE_NOT_EXISTS); }
|
||||
elderlyProfileMapper.updateById(profile.setId(existing.getId()));
|
||||
}
|
||||
} else if (VOLUNTEER.getType().equals(identity.getIdentityType())) {
|
||||
HealthVolunteerProfileDO profile = BeanUtils.toBean(req, HealthVolunteerProfileDO.class)
|
||||
.setId(null).setIdentityId(identity.getId());
|
||||
if (create) {
|
||||
volunteerProfileMapper.insert(profile);
|
||||
} else {
|
||||
HealthVolunteerProfileDO existing = volunteerProfileMapper.selectByIdentityId(identity.getId());
|
||||
if (existing == null) { throw exception(IDENTITY_NOT_EXISTS); }
|
||||
volunteerProfileMapper.updateById(profile.setId(existing.getId()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void auditIdentity(HealthIdentityAuditReqVO req, Long adminId) {
|
||||
HealthIdentityDO identity = requireIdentity(req.getId());
|
||||
if (!VOLUNTEER.getType().equals(identity.getIdentityType())) { throw exception(IDENTITY_TYPE_MISMATCH); }
|
||||
if (!req.getApproved() && StrUtil.isBlank(req.getReason())) { throw exception(AUDIT_REASON_REQUIRED); }
|
||||
int updated = identityMapper.update(null, new LambdaUpdateWrapper<HealthIdentityDO>()
|
||||
.eq(HealthIdentityDO::getId, req.getId()).eq(HealthIdentityDO::getAuditStatus, PENDING.getStatus())
|
||||
.set(HealthIdentityDO::getAuditStatus, req.getApproved() ? APPROVED.getStatus() : REJECTED.getStatus())
|
||||
.set(HealthIdentityDO::getAuditUserId, adminId).set(HealthIdentityDO::getAuditTime, LocalDateTime.now())
|
||||
.set(HealthIdentityDO::getAuditReason, StrUtil.trimToEmpty(req.getReason())));
|
||||
if (updated != 1) { throw exception(AUDIT_STATE_INVALID); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void resubmitIdentity(Long id) {
|
||||
HealthIdentityDO identity = requireIdentity(id);
|
||||
if (!VOLUNTEER.getType().equals(identity.getIdentityType())) { throw exception(IDENTITY_TYPE_MISMATCH); }
|
||||
int updated = identityMapper.update(null, new LambdaUpdateWrapper<HealthIdentityDO>()
|
||||
.eq(HealthIdentityDO::getId, id).eq(HealthIdentityDO::getAuditStatus, REJECTED.getStatus())
|
||||
.set(HealthIdentityDO::getAuditStatus, PENDING.getStatus()));
|
||||
if (updated != 1) { throw exception(AUDIT_STATE_INVALID); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateStatus(HealthIdentityStatusReqVO req) {
|
||||
requireIdentity(req.getId());
|
||||
identityMapper.update(null, new LambdaUpdateWrapper<HealthIdentityDO>()
|
||||
.eq(HealthIdentityDO::getId, req.getId()).set(HealthIdentityDO::getStatus, req.getStatus()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public HealthIdentityRespVO getIdentityDetails(Long id) {
|
||||
HealthIdentityDO identity = requireIdentity(id);
|
||||
HealthIdentityRespVO result = BeanUtils.toBean(identity, HealthIdentityRespVO.class);
|
||||
MemberUserRespDTO member = memberUserApi.getUser(identity.getMemberId());
|
||||
if (member != null) { result.setMobile(member.getMobile()); }
|
||||
if (ELDERLY.getType().equals(identity.getIdentityType())) {
|
||||
HealthElderlyProfileDO profile = elderlyProfileMapper.selectByIdentityId(id);
|
||||
if (profile != null) {
|
||||
result.setElderlyProfileId(profile.getId()).setEmergencyContact(profile.getEmergencyContact())
|
||||
.setEmergencyPhone(profile.getEmergencyPhone()).setHealthStatus(profile.getHealthStatus())
|
||||
.setMedicalHistory(profile.getMedicalHistory()).setMedicationInfo(profile.getMedicationInfo())
|
||||
.setSpecialNeeds(profile.getSpecialNeeds());
|
||||
}
|
||||
} else if (VOLUNTEER.getType().equals(identity.getIdentityType())) {
|
||||
HealthVolunteerProfileDO profile = volunteerProfileMapper.selectByIdentityId(id);
|
||||
if (profile != null) {
|
||||
result.setVolunteerProfileId(profile.getId()).setSkills(profile.getSkills())
|
||||
.setServiceAreas(profile.getServiceAreas()).setAvailableTime(profile.getAvailableTime())
|
||||
.setServiceExperience(profile.getServiceExperience());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<HealthIdentityRespVO> getIdentityPage(HealthIdentityPageReqVO req) {
|
||||
PageResult<HealthIdentityDO> page = identityMapper.selectPage(req);
|
||||
return new PageResult<>(page.getList().stream().map(row -> getIdentityDetails(row.getId()))
|
||||
.collect(Collectors.toList()), page.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
public HealthIdentityDO getIdentityByMemberId(Long memberId) { return identityMapper.selectByMemberId(memberId); }
|
||||
|
||||
@Override
|
||||
public HealthIdentityDO requireActiveIdentity(Long memberId, Integer type) {
|
||||
HealthIdentityDO identity = identityMapper.selectByMemberId(memberId);
|
||||
if (identity == null) { throw exception(IDENTITY_NOT_EXISTS); }
|
||||
validateActive(identity, type);
|
||||
return identity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HealthIdentityDO requireActiveIdentityById(Long id, Integer type) {
|
||||
HealthIdentityDO identity = requireIdentity(id);
|
||||
validateActive(identity, type);
|
||||
return identity;
|
||||
}
|
||||
|
||||
private void validateActive(HealthIdentityDO identity, Integer type) {
|
||||
if (!Objects.equals(identity.getIdentityType(), type)) { throw exception(IDENTITY_TYPE_MISMATCH); }
|
||||
if (!CommonStatusEnum.ENABLE.getStatus().equals(identity.getStatus())
|
||||
|| !APPROVED.getStatus().equals(identity.getAuditStatus())) { throw exception(IDENTITY_INACTIVE); }
|
||||
validateMember(identity.getMemberId());
|
||||
}
|
||||
|
||||
private void validateMember(Long memberId) {
|
||||
MemberUserRespDTO member = memberUserApi.getUser(memberId);
|
||||
if (member == null || !CommonStatusEnum.ENABLE.getStatus().equals(member.getStatus())) {
|
||||
throw exception(MEMBER_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
private HealthIdentityDO requireIdentity(Long id) {
|
||||
HealthIdentityDO identity = id == null ? null : identityMapper.selectById(id);
|
||||
if (identity == null) { throw exception(IDENTITY_NOT_EXISTS); }
|
||||
return identity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public HealthIdentityRespVO getMyIdentity(Long memberId) {
|
||||
// 允许本人查看待审结果,但停用的账号不能继续读取档案。
|
||||
validateMember(memberId);
|
||||
HealthIdentityDO identity = identityMapper.selectByMemberId(memberId);
|
||||
if (identity == null) { return null; }
|
||||
if (!CommonStatusEnum.ENABLE.getStatus().equals(identity.getStatus())) { throw exception(IDENTITY_INACTIVE); }
|
||||
return getIdentityDetails(identity.getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getElderlyIdentityId(Long elderlyProfileId) {
|
||||
HealthElderlyProfileDO profile = elderlyProfileMapper.selectById(elderlyProfileId);
|
||||
if (profile == null) { throw exception(PROFILE_NOT_EXISTS); }
|
||||
return profile.getIdentityId();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,18 @@
|
||||
package cn.iocoder.yudao.module.health.service.relation;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.module.health.controller.vo.*;
|
||||
import cn.iocoder.yudao.module.health.controller.admin.relation.vo.HealthFamilyRelationPageReqVO;
|
||||
import java.util.List;
|
||||
|
||||
public interface HealthFamilyRelationService {
|
||||
Long createRelation(HealthFamilyRelationCreateReqVO req);
|
||||
Long requestMyRelation(Long memberId, HealthFamilyRelationCreateReqVO req);
|
||||
void reviewRelation(HealthFamilyRelationReviewReqVO req, Long actorId, boolean admin);
|
||||
void revokeRelation(HealthFamilyRelationRevokeReqVO req, Long actorId, boolean admin);
|
||||
void reopenRelation(Long id);
|
||||
PageResult<HealthFamilyRelationRespVO> getRelationPage(HealthFamilyRelationPageReqVO req);
|
||||
List<HealthFamilyRelationRespVO> getMyRelations(Long memberId);
|
||||
void requireConfirmedFamilyRelation(Long familyMemberId, Long elderlyProfileId);
|
||||
}
|
||||
|
||||
@ -0,0 +1,172 @@
|
||||
package cn.iocoder.yudao.module.health.service.relation;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.module.health.controller.vo.*;
|
||||
import cn.iocoder.yudao.module.health.controller.admin.relation.vo.HealthFamilyRelationPageReqVO;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.identity.HealthIdentityDO;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.profile.HealthElderlyProfileDO;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.relation.HealthFamilyRelationDO;
|
||||
import cn.iocoder.yudao.module.health.dal.mysql.identity.HealthIdentityMapper;
|
||||
import cn.iocoder.yudao.module.health.dal.mysql.profile.HealthElderlyProfileMapper;
|
||||
import cn.iocoder.yudao.module.health.dal.mysql.relation.HealthFamilyRelationMapper;
|
||||
import cn.iocoder.yudao.module.health.service.identity.HealthIdentityService;
|
||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.stereotype.Service;
|
||||
import javax.annotation.Resource;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.health.enums.ErrorCodeConstants.*;
|
||||
import static cn.iocoder.yudao.module.health.enums.HealthIdentityTypeEnum.*;
|
||||
|
||||
@Service
|
||||
public class HealthFamilyRelationServiceImpl implements HealthFamilyRelationService {
|
||||
@Resource private HealthFamilyRelationMapper relationMapper;
|
||||
@Resource private HealthElderlyProfileMapper elderlyProfileMapper;
|
||||
@Resource private HealthIdentityMapper identityMapper;
|
||||
@Resource private HealthIdentityService identityService;
|
||||
|
||||
@Override
|
||||
public Long createRelation(HealthFamilyRelationCreateReqVO req) {
|
||||
if (req.getFamilyIdentityId() == null) { throw exception(IDENTITY_NOT_EXISTS); }
|
||||
identityService.requireActiveIdentityById(req.getFamilyIdentityId(), FAMILY.getType());
|
||||
requireElderly(req.getElderlyProfileId());
|
||||
if (relationMapper.selectByPair(req.getFamilyIdentityId(), req.getElderlyProfileId()) != null) {
|
||||
throw exception(RELATION_EXISTS);
|
||||
}
|
||||
HealthFamilyRelationDO relation = BeanUtils.toBean(req, HealthFamilyRelationDO.class).setStatus(0);
|
||||
try { relationMapper.insert(relation); }
|
||||
catch (DuplicateKeyException ex) { throw exception(RELATION_EXISTS); }
|
||||
return relation.getId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long requestMyRelation(Long memberId, HealthFamilyRelationCreateReqVO req) {
|
||||
HealthIdentityDO family = identityService.requireActiveIdentity(memberId, FAMILY.getType());
|
||||
// 不采信 APP 请求中的 familyIdentityId。
|
||||
return createRelation(new HealthFamilyRelationCreateReqVO().setFamilyIdentityId(family.getId())
|
||||
.setElderlyProfileId(req.getElderlyProfileId()).setRelationship(req.getRelationship()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reviewRelation(HealthFamilyRelationReviewReqVO req, Long actorId, boolean admin) {
|
||||
HealthFamilyRelationDO relation = requireRelation(req.getId());
|
||||
if (admin) {
|
||||
if (StrUtil.isBlank(req.getReason())) { throw exception(AUDIT_REASON_REQUIRED); }
|
||||
} else {
|
||||
HealthIdentityDO elder = identityService.requireActiveIdentity(actorId, ELDERLY.getType());
|
||||
HealthElderlyProfileDO profile = elderlyProfileMapper.selectById(relation.getElderlyProfileId());
|
||||
if (profile == null || !Objects.equals(profile.getIdentityId(), elder.getId())) {
|
||||
throw exception(RELATION_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
// 停用的老人或家属不得新增生效关联。
|
||||
requireElderly(relation.getElderlyProfileId());
|
||||
identityService.requireActiveIdentityById(relation.getFamilyIdentityId(), FAMILY.getType());
|
||||
int updated = relationMapper.update(null, new LambdaUpdateWrapper<HealthFamilyRelationDO>()
|
||||
.eq(HealthFamilyRelationDO::getId, req.getId()).eq(HealthFamilyRelationDO::getStatus, 0)
|
||||
.set(HealthFamilyRelationDO::getStatus, req.getApproved() ? 1 : 2)
|
||||
.set(HealthFamilyRelationDO::getReviewUserType, actorType(admin))
|
||||
.set(HealthFamilyRelationDO::getReviewUserId, actorId)
|
||||
.set(HealthFamilyRelationDO::getReviewTime, LocalDateTime.now())
|
||||
.set(HealthFamilyRelationDO::getReviewReason, StrUtil.trimToEmpty(req.getReason())));
|
||||
if (updated != 1) { throw exception(RELATION_STATE_INVALID); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void revokeRelation(HealthFamilyRelationRevokeReqVO req, Long actorId, boolean admin) {
|
||||
HealthFamilyRelationDO relation = requireRelation(req.getId());
|
||||
if (!admin) {
|
||||
HealthIdentityDO identity = identityService.getIdentityByMemberId(actorId);
|
||||
if (identity == null) { throw exception(RELATION_FORBIDDEN); }
|
||||
identityService.requireActiveIdentity(actorId, identity.getIdentityType());
|
||||
HealthElderlyProfileDO profile = elderlyProfileMapper.selectById(relation.getElderlyProfileId());
|
||||
if (!Objects.equals(relation.getFamilyIdentityId(), identity.getId())
|
||||
&& (profile == null || !Objects.equals(profile.getIdentityId(), identity.getId()))) {
|
||||
throw exception(RELATION_FORBIDDEN);
|
||||
}
|
||||
}
|
||||
int updated = relationMapper.update(null, new LambdaUpdateWrapper<HealthFamilyRelationDO>()
|
||||
.eq(HealthFamilyRelationDO::getId, req.getId()).in(HealthFamilyRelationDO::getStatus, 0, 1)
|
||||
.set(HealthFamilyRelationDO::getStatus, 3)
|
||||
.set(HealthFamilyRelationDO::getRevokeUserType, actorType(admin))
|
||||
.set(HealthFamilyRelationDO::getRevokeUserId, actorId)
|
||||
.set(HealthFamilyRelationDO::getRevokeTime, LocalDateTime.now())
|
||||
.set(HealthFamilyRelationDO::getRevokeReason, req.getReason()));
|
||||
if (updated != 1) { throw exception(RELATION_STATE_INVALID); }
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reopenRelation(Long id) {
|
||||
requireRelation(id);
|
||||
int updated = relationMapper.update(null, new LambdaUpdateWrapper<HealthFamilyRelationDO>()
|
||||
.eq(HealthFamilyRelationDO::getId, id).in(HealthFamilyRelationDO::getStatus, 2, 3)
|
||||
.set(HealthFamilyRelationDO::getStatus, 0));
|
||||
if (updated != 1) { throw exception(RELATION_STATE_INVALID); }
|
||||
// 保留上次确认与解除字段作为最近一次处理痕迹,当前状态重新变为待确认。
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<HealthFamilyRelationRespVO> getRelationPage(HealthFamilyRelationPageReqVO req) {
|
||||
PageResult<HealthFamilyRelationDO> page = relationMapper.selectPage(req);
|
||||
return new PageResult<>(page.getList().stream().map(this::toResponse).collect(Collectors.toList()), page.getTotal());
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<HealthFamilyRelationRespVO> getMyRelations(Long memberId) {
|
||||
HealthIdentityDO identity = identityService.getIdentityByMemberId(memberId);
|
||||
if (identity == null) { throw exception(IDENTITY_NOT_EXISTS); }
|
||||
identityService.requireActiveIdentity(memberId, identity.getIdentityType());
|
||||
LambdaQueryWrapperX<HealthFamilyRelationDO> query = new LambdaQueryWrapperX<>();
|
||||
if (FAMILY.getType().equals(identity.getIdentityType())) {
|
||||
query.eq(HealthFamilyRelationDO::getFamilyIdentityId, identity.getId());
|
||||
} else if (ELDERLY.getType().equals(identity.getIdentityType())) {
|
||||
HealthElderlyProfileDO profile = elderlyProfileMapper.selectByIdentityId(identity.getId());
|
||||
if (profile == null) { throw exception(PROFILE_NOT_EXISTS); }
|
||||
query.eq(HealthFamilyRelationDO::getElderlyProfileId, profile.getId());
|
||||
} else { throw exception(IDENTITY_TYPE_MISMATCH); }
|
||||
return relationMapper.selectList(query.orderByDesc(HealthFamilyRelationDO::getId)).stream()
|
||||
.map(this::toResponse).collect(Collectors.toList());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void requireConfirmedFamilyRelation(Long familyMemberId, Long elderlyProfileId) {
|
||||
HealthIdentityDO family = identityService.requireActiveIdentity(familyMemberId, FAMILY.getType());
|
||||
requireElderly(elderlyProfileId);
|
||||
HealthFamilyRelationDO relation = relationMapper.selectByPair(family.getId(), elderlyProfileId);
|
||||
if (relation == null || !Integer.valueOf(1).equals(relation.getStatus())) {
|
||||
throw exception(RELATION_NOT_CONFIRMED);
|
||||
}
|
||||
}
|
||||
|
||||
private HealthIdentityDO requireElderly(Long profileId) {
|
||||
HealthElderlyProfileDO profile = elderlyProfileMapper.selectById(profileId);
|
||||
if (profile == null) { throw exception(PROFILE_NOT_EXISTS); }
|
||||
return identityService.requireActiveIdentityById(profile.getIdentityId(), ELDERLY.getType());
|
||||
}
|
||||
private HealthFamilyRelationDO requireRelation(Long id) {
|
||||
HealthFamilyRelationDO relation = relationMapper.selectById(id);
|
||||
if (relation == null) { throw exception(RELATION_NOT_EXISTS); }
|
||||
return relation;
|
||||
}
|
||||
private int actorType(boolean admin) {
|
||||
return (admin ? UserTypeEnum.ADMIN : UserTypeEnum.MEMBER).getValue();
|
||||
}
|
||||
private HealthFamilyRelationRespVO toResponse(HealthFamilyRelationDO relation) {
|
||||
HealthFamilyRelationRespVO response = BeanUtils.toBean(relation, HealthFamilyRelationRespVO.class);
|
||||
HealthIdentityDO family = identityMapper.selectById(relation.getFamilyIdentityId());
|
||||
HealthElderlyProfileDO profile = elderlyProfileMapper.selectById(relation.getElderlyProfileId());
|
||||
HealthIdentityDO elderly = profile == null ? null : identityMapper.selectById(profile.getIdentityId());
|
||||
if (family != null) { response.setFamilyName(family.getRealName()); }
|
||||
if (elderly != null) { response.setElderlyName(elderly.getRealName()); }
|
||||
return response;
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
业务规则、账号与档案创建事务、审批和家属授权。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
@ -0,0 +1,193 @@
|
||||
package cn.iocoder.yudao.module.health.service;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.security.core.LoginUser;
|
||||
import cn.iocoder.yudao.framework.tenant.config.TenantProperties;
|
||||
import cn.iocoder.yudao.framework.tenant.core.context.TenantContextHolder;
|
||||
import cn.iocoder.yudao.framework.tenant.core.db.TenantDatabaseInterceptor;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.health.controller.admin.identity.vo.*;
|
||||
import cn.iocoder.yudao.module.health.controller.app.identity.AppHealthIdentityController;
|
||||
import cn.iocoder.yudao.module.health.controller.vo.*;
|
||||
import cn.iocoder.yudao.module.health.dal.dataobject.identity.HealthIdentityDO;
|
||||
import cn.iocoder.yudao.module.health.dal.mysql.identity.HealthIdentityMapper;
|
||||
import cn.iocoder.yudao.module.health.service.identity.*;
|
||||
import cn.iocoder.yudao.module.health.service.relation.*;
|
||||
import cn.iocoder.yudao.module.member.api.user.MemberUserApiImpl;
|
||||
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
|
||||
import cn.iocoder.yudao.module.member.dal.mysql.user.MemberUserMapper;
|
||||
import cn.iocoder.yudao.module.member.mq.producer.user.MemberUserProducer;
|
||||
import cn.iocoder.yudao.module.member.service.user.MemberUserServiceImpl;
|
||||
import cn.iocoder.yudao.module.system.api.sms.SmsCodeApi;
|
||||
import cn.iocoder.yudao.module.system.api.social.SocialClientApi;
|
||||
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||
import com.baomidou.mybatisplus.extension.plugins.inner.TenantLineInnerInterceptor;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.springframework.boot.test.context.TestConfiguration;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.*;
|
||||
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
|
||||
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import javax.annotation.Resource;
|
||||
import java.util.Collections;
|
||||
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
|
||||
import static cn.iocoder.yudao.module.health.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
@Import({ HealthIdentityServiceImpl.class, HealthFamilyRelationServiceImpl.class,
|
||||
MemberUserServiceImpl.class, MemberUserApiImpl.class, AppHealthIdentityController.class,
|
||||
HealthIdentityServiceTest.TestConfig.class })
|
||||
class HealthIdentityServiceTest extends BaseDbUnitTest {
|
||||
@Resource private HealthIdentityService identityService;
|
||||
@Resource private HealthFamilyRelationService relationService;
|
||||
@Resource private HealthIdentityMapper identityMapper;
|
||||
@Resource private MemberUserMapper memberMapper;
|
||||
@Resource private PasswordEncoder passwordEncoder;
|
||||
@Resource private AppHealthIdentityController appController;
|
||||
@MockBean private SmsCodeApi smsCodeApi;
|
||||
@MockBean private SocialClientApi socialClientApi;
|
||||
@MockBean private MemberUserProducer memberUserProducer;
|
||||
|
||||
@TestConfiguration
|
||||
@EnableGlobalMethodSecurity(prePostEnabled = true)
|
||||
static class TestConfig {
|
||||
@Bean PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); }
|
||||
@Bean @Lazy(false) TenantLineInnerInterceptor testTenantInterceptor(MybatisPlusInterceptor interceptor) {
|
||||
TenantLineInnerInterceptor tenant = new TenantLineInnerInterceptor(new TenantDatabaseInterceptor(new TenantProperties()));
|
||||
interceptor.addInnerInterceptor(tenant);
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
|
||||
@BeforeEach void setup() { TenantContextHolder.setTenantId(1L); }
|
||||
@AfterEach void clear() { TenantContextHolder.clear(); SecurityContextHolder.clearContext(); }
|
||||
|
||||
private HealthIdentitySaveReqVO request(String mobile, int type) {
|
||||
HealthIdentitySaveReqVO req = new HealthIdentitySaveReqVO();
|
||||
req.setMobile(mobile).setPassword("Health123").setIdentityType(type);
|
||||
req.setRealName("测试姓名").setGender(3).setContactPhone("13800000000");
|
||||
return req;
|
||||
}
|
||||
private HealthIdentityRespVO create(String mobile, int type) {
|
||||
return identityService.getIdentityDetails(identityService.createIdentity(request(mobile, type)));
|
||||
}
|
||||
private HealthFamilyRelationCreateReqVO relation(HealthIdentityRespVO family, HealthIdentityRespVO elder) {
|
||||
return new HealthFamilyRelationCreateReqVO().setFamilyIdentityId(family.getId())
|
||||
.setElderlyProfileId(elder.getElderlyProfileId()).setRelationship("子女");
|
||||
}
|
||||
|
||||
@Test void createsEncryptedAccountAndDistinctProfileInOneTransaction() {
|
||||
HealthIdentityRespVO elder = create("13800000001", 10);
|
||||
MemberUserDO member = memberMapper.selectById(elder.getMemberId());
|
||||
assertNotEquals("Health123", member.getPassword());
|
||||
assertTrue(passwordEncoder.matches("Health123", member.getPassword()));
|
||||
assertNotNull(elder.getElderlyProfileId());
|
||||
assertEquals(1, elder.getAuditStatus());
|
||||
assertEquals(1L, identityMapper.selectById(elder.getId()).getTenantId());
|
||||
identityService.requireActiveIdentity(elder.getMemberId(), 10);
|
||||
}
|
||||
|
||||
@Test void profileFailureRollsBackNewAccountAndIdentity() {
|
||||
HealthIdentitySaveReqVO req = request("13800000002", 10);
|
||||
req.setEmergencyContact(String.join("", Collections.nCopies(61, "a")));
|
||||
assertThrows(Exception.class, () -> identityService.createIdentity(req));
|
||||
assertNull(memberMapper.selectByMobile("13800000002"));
|
||||
assertEquals(0, identityMapper.selectCount().longValue());
|
||||
}
|
||||
|
||||
@Test void accountCannotAcquireSecondIdentityOrChangeType() {
|
||||
HealthIdentityRespVO elder = create("13800000003", 10);
|
||||
HealthIdentitySaveReqVO second = request("13800000004", 30).setMemberId(elder.getMemberId())
|
||||
.setMobile(null).setPassword(null);
|
||||
assertServiceException(() -> identityService.createIdentity(second), IDENTITY_EXISTS);
|
||||
second.setId(elder.getId());
|
||||
assertServiceException(() -> identityService.updateIdentity(second), IDENTITY_IMMUTABLE);
|
||||
assertEquals(1, identityMapper.selectCount().longValue());
|
||||
}
|
||||
|
||||
@Test void duplicateMobileDoesNotResetExistingPassword() {
|
||||
HealthIdentityRespVO elder = create("13800000005", 10);
|
||||
String password = memberMapper.selectById(elder.getMemberId()).getPassword();
|
||||
assertServiceException(() -> identityService.createIdentity(request("13800000005", 30)),
|
||||
cn.iocoder.yudao.module.member.enums.ErrorCodeConstants.USER_MOBILE_USED, "13800000005");
|
||||
assertEquals(password, memberMapper.selectById(elder.getMemberId()).getPassword());
|
||||
}
|
||||
|
||||
@Test void volunteerMustBeApprovedAndAccountAndIdentityMustRemainEnabled() {
|
||||
HealthIdentityRespVO volunteer = create("13800000006", 20);
|
||||
assertServiceException(() -> identityService.requireActiveIdentity(volunteer.getMemberId(), 20), IDENTITY_INACTIVE);
|
||||
HealthIdentityAuditReqVO audit = new HealthIdentityAuditReqVO().setId(volunteer.getId()).setApproved(false);
|
||||
assertServiceException(() -> identityService.auditIdentity(audit, 1L), AUDIT_REASON_REQUIRED);
|
||||
audit.setReason("资料不完整");
|
||||
identityService.auditIdentity(audit, 1L);
|
||||
identityService.resubmitIdentity(volunteer.getId());
|
||||
audit.setApproved(true).setReason("资料已核实");
|
||||
identityService.auditIdentity(audit, 1L);
|
||||
identityService.requireActiveIdentity(volunteer.getMemberId(), 20);
|
||||
assertServiceException(() -> identityService.auditIdentity(audit, 1L), AUDIT_STATE_INVALID);
|
||||
identityService.updateStatus(new HealthIdentityStatusReqVO().setId(volunteer.getId()).setStatus(1));
|
||||
assertEquals("13800000000", identityService.getIdentityDetails(volunteer.getId()).getContactPhone());
|
||||
assertServiceException(() -> identityService.requireActiveIdentity(volunteer.getMemberId(), 20), IDENTITY_INACTIVE);
|
||||
identityService.updateStatus(new HealthIdentityStatusReqVO().setId(volunteer.getId()).setStatus(0));
|
||||
memberMapper.updateById(new MemberUserDO().setId(volunteer.getMemberId()).setStatus(CommonStatusEnum.DISABLE.getStatus()));
|
||||
assertServiceException(() -> identityService.requireActiveIdentity(volunteer.getMemberId(), 20), MEMBER_INVALID);
|
||||
}
|
||||
|
||||
@Test void familyNeedsConsentAndUnrelatedElderCannotConfirm() {
|
||||
HealthIdentityRespVO elder = create("13800000007", 10);
|
||||
HealthIdentityRespVO other = create("13800000008", 10);
|
||||
HealthIdentityRespVO family = create("13800000009", 30);
|
||||
Long id = relationService.createRelation(relation(family, elder));
|
||||
assertServiceException(() -> relationService.requireConfirmedFamilyRelation(family.getMemberId(), elder.getElderlyProfileId()), RELATION_NOT_CONFIRMED);
|
||||
HealthFamilyRelationReviewReqVO review = new HealthFamilyRelationReviewReqVO().setId(id).setApproved(true);
|
||||
assertServiceException(() -> relationService.reviewRelation(review, other.getMemberId(), false), RELATION_FORBIDDEN);
|
||||
assertServiceException(() -> relationService.reviewRelation(review, 1L, true), AUDIT_REASON_REQUIRED);
|
||||
relationService.reviewRelation(review, elder.getMemberId(), false);
|
||||
relationService.requireConfirmedFamilyRelation(family.getMemberId(), elder.getElderlyProfileId());
|
||||
assertServiceException(() -> relationService.reviewRelation(review, elder.getMemberId(), false), RELATION_STATE_INVALID);
|
||||
relationService.revokeRelation(new HealthFamilyRelationRevokeReqVO().setId(id).setReason("解除关联"), family.getMemberId(), false);
|
||||
assertServiceException(() -> relationService.requireConfirmedFamilyRelation(family.getMemberId(), elder.getElderlyProfileId()), RELATION_NOT_CONFIRMED);
|
||||
relationService.reopenRelation(id);
|
||||
assertServiceException(() -> relationService.requireConfirmedFamilyRelation(family.getMemberId(), elder.getElderlyProfileId()), RELATION_NOT_CONFIRMED);
|
||||
}
|
||||
|
||||
@Test void familyRequestIgnoresSpoofedFamilyIdentityAndSupportsManyToMany() {
|
||||
HealthIdentityRespVO elder = create("13800000010", 10);
|
||||
HealthIdentityRespVO otherElder = create("13800000011", 10);
|
||||
HealthIdentityRespVO family = create("13800000012", 30);
|
||||
HealthIdentityRespVO otherFamily = create("13800000013", 30);
|
||||
Long id = relationService.requestMyRelation(family.getMemberId(), relation(otherFamily, elder));
|
||||
assertEquals(family.getId(), relationService.getMyRelations(family.getMemberId()).get(0).getFamilyIdentityId());
|
||||
assertEquals(0, relationService.getMyRelations(otherFamily.getMemberId()).size());
|
||||
relationService.createRelation(relation(otherFamily, elder));
|
||||
relationService.createRelation(relation(family, otherElder));
|
||||
assertEquals(2, relationService.getMyRelations(family.getMemberId()).size());
|
||||
relationService.reviewRelation(new HealthFamilyRelationReviewReqVO().setId(id).setApproved(true).setReason("核实亲属信息"), 1L, true);
|
||||
relationService.requireConfirmedFamilyRelation(family.getMemberId(), elder.getElderlyProfileId());
|
||||
identityService.updateStatus(new HealthIdentityStatusReqVO().setId(elder.getId()).setStatus(1));
|
||||
assertServiceException(() -> relationService.requireConfirmedFamilyRelation(family.getMemberId(), elder.getElderlyProfileId()), IDENTITY_INACTIVE);
|
||||
}
|
||||
|
||||
@Test void tenantIsolationAppliesToReadsWritesAndUniqueness() {
|
||||
HealthIdentityRespVO tenantOne = create("13800000014", 10);
|
||||
TenantContextHolder.setTenantId(2L);
|
||||
assertNull(identityService.getIdentityByMemberId(tenantOne.getMemberId()));
|
||||
assertServiceException(() -> identityService.getIdentityDetails(tenantOne.getId()), IDENTITY_NOT_EXISTS);
|
||||
HealthIdentityRespVO tenantTwo = create("13800000014", 30);
|
||||
assertNotEquals(tenantOne.getMemberId(), tenantTwo.getMemberId());
|
||||
TenantContextHolder.setTenantId(1L);
|
||||
assertServiceException(() -> identityService.getIdentityDetails(tenantTwo.getId()), IDENTITY_NOT_EXISTS);
|
||||
}
|
||||
|
||||
@Test void appInterfaceRejectsAnonymousAccessAndOnlyReadsLoggedInMember() {
|
||||
assertThrows(AuthenticationCredentialsNotFoundException.class, () -> appController.me());
|
||||
HealthIdentityRespVO elder = create("13800000015", 10);
|
||||
LoginUser user = new LoginUser().setId(elder.getMemberId()).setUserType(1).setTenantId(1L);
|
||||
SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user, null, Collections.emptyList()));
|
||||
assertEquals(elder.getId(), appController.me().getData().getId());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
spring:
|
||||
main:
|
||||
lazy-initialization: true
|
||||
banner-mode: off
|
||||
datasource:
|
||||
name: health-test
|
||||
url: jdbc:h2:mem:health-test;MODE=MYSQL;DATABASE_TO_UPPER=false;NON_KEYWORDS=value
|
||||
driver-class-name: org.h2.Driver
|
||||
username: sa
|
||||
password:
|
||||
druid:
|
||||
async-init: true
|
||||
initial-size: 1
|
||||
sql:
|
||||
init:
|
||||
schema-locations: classpath:/sql/create_tables.sql
|
||||
mybatis:
|
||||
lazy-initialization: true
|
||||
yudao:
|
||||
info:
|
||||
base-package: cn.iocoder.yudao.module
|
||||
|
||||
@ -0,0 +1,5 @@
|
||||
DELETE FROM health_family_relation;
|
||||
DELETE FROM health_volunteer_profile;
|
||||
DELETE FROM health_elderly_profile;
|
||||
DELETE FROM health_identity;
|
||||
DELETE FROM member_user;
|
||||
@ -0,0 +1,116 @@
|
||||
CREATE TABLE IF NOT EXISTS health_identity (
|
||||
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '主键',
|
||||
member_id bigint NOT NULL COMMENT 'member_user.id;不是 system_user.id',
|
||||
identity_type tinyint NOT NULL COMMENT '10 老人、20 志愿者、30 家属;创建后不更换',
|
||||
real_name varchar(30) NOT NULL COMMENT '真实姓名',
|
||||
contact_phone varchar(30) NULL COMMENT '联系方式,可与登录手机号不同',
|
||||
gender tinyint NOT NULL DEFAULT 3 COMMENT '1 男、2 女、3 未知',
|
||||
birth_date date NULL COMMENT '出生日期',
|
||||
address varchar(500) NULL COMMENT '地址',
|
||||
status tinyint NOT NULL DEFAULT 0 COMMENT '0 启用、1 停用',
|
||||
audit_status tinyint NOT NULL COMMENT '0 待审、1 通过、2 驳回',
|
||||
audit_user_id bigint NULL COMMENT '审核管理员 system_user.id',
|
||||
audit_time datetime NULL COMMENT '最近审核时间',
|
||||
audit_reason varchar(500) NULL COMMENT '最近审核理由',
|
||||
creator varchar(64) DEFAULT '' COMMENT '创建者',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updater varchar(64) DEFAULT '' COMMENT '更新者',
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted bit(1) NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记;身份管理使用停用保留历史',
|
||||
tenant_id bigint NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_health_identity_member (tenant_id, member_id),
|
||||
KEY idx_health_identity_type (tenant_id, identity_type, status, audit_status)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_elderly_profile (
|
||||
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '主键',
|
||||
identity_id bigint NOT NULL COMMENT 'health_identity.id,必须是老人身份',
|
||||
emergency_contact varchar(60) NULL COMMENT '紧急联系人',
|
||||
emergency_phone varchar(30) NULL COMMENT '紧急联系电话',
|
||||
health_status varchar(500) NULL COMMENT '健康状况摘要,不替代历次评估',
|
||||
medical_history varchar(2000) NULL COMMENT '病史',
|
||||
medication_info varchar(2000) NULL COMMENT '用药信息',
|
||||
special_needs varchar(1000) NULL COMMENT '特殊需求',
|
||||
creator varchar(64) DEFAULT '' COMMENT '创建者',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updater varchar(64) DEFAULT '' COMMENT '更新者',
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted bit(1) NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记;身份管理使用停用保留历史',
|
||||
tenant_id bigint NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_health_elderly_identity (tenant_id, identity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_volunteer_profile (
|
||||
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '主键',
|
||||
identity_id bigint NOT NULL COMMENT 'health_identity.id,必须是志愿者身份',
|
||||
skills varchar(1000) NULL COMMENT '服务技能',
|
||||
service_areas varchar(500) NULL COMMENT '服务区域;后续匹配可细化',
|
||||
available_time varchar(1000) NULL COMMENT '可服务时间说明;后续排班可细化',
|
||||
service_experience varchar(2000) NULL COMMENT '服务经验',
|
||||
creator varchar(64) DEFAULT '' COMMENT '创建者',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updater varchar(64) DEFAULT '' COMMENT '更新者',
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted bit(1) NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记;身份管理使用停用保留历史',
|
||||
tenant_id bigint NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_health_volunteer_identity (tenant_id, identity_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS health_family_relation (
|
||||
id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '主键',
|
||||
family_identity_id bigint NOT NULL COMMENT '家属 health_identity.id',
|
||||
elderly_profile_id bigint NOT NULL COMMENT '老人 health_elderly_profile.id,不是 member_id',
|
||||
relationship varchar(30) NOT NULL COMMENT '与老人关系',
|
||||
status tinyint NOT NULL DEFAULT 0 COMMENT '0 待确认、1 已确认、2 已拒绝、3 已解除',
|
||||
review_user_type tinyint NULL COMMENT '确认者账号类型:1 member、2 admin',
|
||||
review_user_id bigint NULL COMMENT '确认者账号编号,结合 user_type 解释',
|
||||
review_time datetime NULL COMMENT '最近确认或拒绝时间',
|
||||
review_reason varchar(500) NULL COMMENT '管理员确认必须记录核实理由',
|
||||
revoke_user_type tinyint NULL COMMENT '解除者账号类型',
|
||||
revoke_user_id bigint NULL COMMENT '解除者账号编号',
|
||||
revoke_time datetime NULL COMMENT '最近解除时间',
|
||||
revoke_reason varchar(500) NULL COMMENT '解除理由',
|
||||
creator varchar(64) DEFAULT '' COMMENT '创建者',
|
||||
create_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
updater varchar(64) DEFAULT '' COMMENT '更新者',
|
||||
update_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
deleted bit(1) NOT NULL DEFAULT FALSE COMMENT '逻辑删除标记;身份管理使用停用保留历史',
|
||||
tenant_id bigint NOT NULL COMMENT '租户编号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_health_family_pair (tenant_id, family_identity_id, elderly_profile_id),
|
||||
KEY idx_health_family_elderly (tenant_id, elderly_profile_id, status)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS `member_user` (
|
||||
`id` bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY COMMENT '编号',
|
||||
`mobile` varchar(11) NULL DEFAULT NULL COMMENT '手机号',
|
||||
`password` varchar(100) NOT NULL DEFAULT '' COMMENT '密码',
|
||||
`status` tinyint NOT NULL COMMENT '状态',
|
||||
`register_ip` varchar(32) NOT NULL COMMENT '注册 IP',
|
||||
`register_terminal` tinyint NULL DEFAULT NULL COMMENT '注册终端',
|
||||
`login_ip` varchar(50) NULL DEFAULT '' COMMENT '最后登录IP',
|
||||
`login_date` datetime NULL DEFAULT NULL COMMENT '最后登录时间',
|
||||
`nickname` varchar(30) NOT NULL DEFAULT '' COMMENT '用户昵称',
|
||||
`avatar` varchar(512) NOT NULL DEFAULT '' COMMENT '头像',
|
||||
`name` varchar(30) NULL DEFAULT '' COMMENT '真实名字',
|
||||
`sex` tinyint NULL DEFAULT 0 COMMENT '用户性别',
|
||||
`area_id` bigint NULL DEFAULT NULL COMMENT '所在地',
|
||||
`birthday` datetime NULL DEFAULT NULL COMMENT '出生日期',
|
||||
`mark` varchar(255) NULL DEFAULT NULL COMMENT '会员备注',
|
||||
`point` int NOT NULL DEFAULT 0 COMMENT '积分',
|
||||
`tag_ids` varchar(255) NULL DEFAULT NULL COMMENT '用户标签编号列表,以逗号分隔',
|
||||
`level_id` bigint NULL DEFAULT NULL COMMENT '等级编号',
|
||||
`experience` int NOT NULL DEFAULT 0 COMMENT '经验',
|
||||
`group_id` bigint NULL DEFAULT NULL COMMENT '用户分组编号',
|
||||
`creator` varchar(64) NULL DEFAULT '' COMMENT '创建者',
|
||||
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`updater` varchar(64) NULL DEFAULT '' COMMENT '更新者',
|
||||
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||
`deleted` bit(1) NOT NULL DEFAULT FALSE COMMENT '是否删除',
|
||||
`tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `uk_member_mobile_tenant` (`tenant_id`, `mobile`)
|
||||
);
|
||||
@ -0,0 +1,3 @@
|
||||
身份、事务回滚、家属授权、匿名请求和租户隔离的集成测试。测试数据仅保存在 H2 内存库。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
3
yudao-module-health/yudao-module-health-biz/目录说明.txt
Normal file
3
yudao-module-health/yudao-module-health-biz/目录说明.txt
Normal file
@ -0,0 +1,3 @@
|
||||
健康业务实现:后台身份建档、志愿者审核、本人身份查询、家属确认和健康档案授权。src/test 使用 H2,不操作本地业务数据库。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
3
yudao-module-health/目录说明.txt
Normal file
3
yudao-module-health/目录说明.txt
Normal file
@ -0,0 +1,3 @@
|
||||
老年健康业务模块。已加入 Maven 和 yudao-server;账号复用 member,业务身份和基础健康档案保存在这里。后续扩展评估、运动预约、派单、提醒和摄像头接入。
|
||||
|
||||
详见 docs/健康业务模块与身份模型.md。
|
||||
@ -0,0 +1,18 @@
|
||||
package cn.iocoder.yudao.module.member.api.user.dto;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.validation.Mobile;
|
||||
import lombok.Data;
|
||||
import lombok.ToString;
|
||||
import javax.validation.constraints.NotBlank;
|
||||
import javax.validation.constraints.Size;
|
||||
|
||||
/** 后台业务建档时创建账号,不开放无短信验证的公共注册接口。 */
|
||||
@Data
|
||||
public class MemberUserCreateReqDTO {
|
||||
@NotBlank @Mobile
|
||||
private String mobile;
|
||||
@NotBlank @Size(min = 4, max = 16) @ToString.Exclude
|
||||
private String password;
|
||||
@NotBlank @Size(max = 30)
|
||||
private String nickname;
|
||||
}
|
||||
80
yudao-ui-admin-vue3/src/api/health/index.ts
Normal file
80
yudao-ui-admin-vue3/src/api/health/index.ts
Normal file
@ -0,0 +1,80 @@
|
||||
import request from '@/config/axios'
|
||||
|
||||
export const identityTypes = [
|
||||
{ value: 10, label: '老人' },
|
||||
{ value: 20, label: '志愿者' },
|
||||
{ value: 30, label: '家属' }
|
||||
]
|
||||
export const auditStates = [
|
||||
{ value: 0, label: '待审核' },
|
||||
{ value: 1, label: '已通过' },
|
||||
{ value: 2, label: '已驳回' }
|
||||
]
|
||||
export interface Identity {
|
||||
id?: number
|
||||
memberId?: number
|
||||
mobile?: string
|
||||
password?: string
|
||||
identityType: number
|
||||
realName: string
|
||||
gender: number
|
||||
contactPhone?: string
|
||||
birthDate?: string
|
||||
address?: string
|
||||
emergencyContact?: string
|
||||
emergencyPhone?: string
|
||||
healthStatus?: string
|
||||
medicalHistory?: string
|
||||
medicationInfo?: string
|
||||
specialNeeds?: string
|
||||
skills?: string
|
||||
serviceAreas?: string
|
||||
availableTime?: string
|
||||
serviceExperience?: string
|
||||
status?: number
|
||||
auditStatus?: number
|
||||
auditReason?: string
|
||||
elderlyProfileId?: number
|
||||
volunteerProfileId?: number
|
||||
}
|
||||
export interface FamilyRelation {
|
||||
id: number
|
||||
familyIdentityId: number
|
||||
familyName: string
|
||||
elderlyProfileId: number
|
||||
elderlyName: string
|
||||
relationship: string
|
||||
status: number
|
||||
reviewReason?: string
|
||||
revokeReason?: string
|
||||
}
|
||||
export const getIdentityPage = (params: any) =>
|
||||
request.get<{ list: Identity[]; total: number }>({ url: '/health/identity/page', params })
|
||||
export const getIdentity = (id: number) =>
|
||||
request.get<Identity>({ url: '/health/identity/get', params: { id } })
|
||||
export const createIdentity = (data: Identity) =>
|
||||
request.post<number>({ url: '/health/identity/create', data })
|
||||
export const updateIdentity = (data: Identity) =>
|
||||
request.put({ url: '/health/identity/update', data })
|
||||
export const auditIdentity = (id: number, approved: boolean, reason: string) =>
|
||||
request.put({ url: '/health/identity/audit', data: { id, approved, reason } })
|
||||
export const resubmitIdentity = (id: number) =>
|
||||
request.put({ url: '/health/identity/resubmit', params: { id } })
|
||||
export const updateIdentityStatus = (id: number, status: number) =>
|
||||
request.put({ url: '/health/identity/update-status', data: { id, status } })
|
||||
export const getRelationPage = (params: any) =>
|
||||
request.get<{ list: FamilyRelation[]; total: number }>({
|
||||
url: '/health/family-relation/page',
|
||||
params
|
||||
})
|
||||
export const createRelation = (data: {
|
||||
familyIdentityId: number
|
||||
elderlyProfileId: number
|
||||
relationship: string
|
||||
}) => request.post({ url: '/health/family-relation/create', data })
|
||||
export const reviewRelation = (id: number, approved: boolean, reason: string) =>
|
||||
request.put({ url: '/health/family-relation/review', data: { id, approved, reason } })
|
||||
export const revokeRelation = (id: number, reason: string) =>
|
||||
request.put({ url: '/health/family-relation/revoke', data: { id, reason } })
|
||||
export const reopenRelation = (id: number) =>
|
||||
request.put({ url: '/health/family-relation/reopen', params: { id } })
|
||||
1
yudao-ui-admin-vue3/src/api/health/目录说明.txt
Normal file
1
yudao-ui-admin-vue3/src/api/health/目录说明.txt
Normal file
@ -0,0 +1 @@
|
||||
健康管理接口:身份建档、档案编辑、志愿者审核、家属关联。此处只封装请求,身份授权由后端执行。
|
||||
262
yudao-ui-admin-vue3/src/views/health/family-relation/index.vue
Normal file
262
yudao-ui-admin-vue3/src/views/health/family-relation/index.vue
Normal file
@ -0,0 +1,262 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<el-alert
|
||||
title="关联需老人确认,管理员也可核实后确认;确认后家属才能查看老人健康档案。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
<el-form :inline="true">
|
||||
<el-form-item label="关联状态">
|
||||
<el-select v-model="query.status" clearable class="!w-140px">
|
||||
<el-option v-for="(label, index) in states" :key="index" :label="label" :value="index" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button v-hasPermi="['health:family-relation:create']" type="success" @click="openCreate"
|
||||
>新增关联</el-button
|
||||
>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<el-table :data="list" v-loading="loading" stripe>
|
||||
<el-table-column label="关联编号" prop="id" width="90" />
|
||||
<el-table-column label="家属" prop="familyName" min-width="110" />
|
||||
<el-table-column label="老人" prop="elderlyName" min-width="110" />
|
||||
<el-table-column label="关系" prop="relationship" width="110" />
|
||||
<el-table-column label="状态" width="110">
|
||||
<template #default="{ row }"
|
||||
><el-tag :type="row.status === 1 ? 'success' : 'info'">{{
|
||||
states[row.status]
|
||||
}}</el-tag></template
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column
|
||||
label="最近核实理由"
|
||||
prop="reviewReason"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column
|
||||
label="最近解除理由"
|
||||
prop="revokeReason"
|
||||
min-width="180"
|
||||
show-overflow-tooltip
|
||||
/>
|
||||
<el-table-column label="操作" min-width="210">
|
||||
<template #default="{ row }">
|
||||
<el-button
|
||||
v-if="row.status === 0"
|
||||
v-hasPermi="['health:family-relation:review']"
|
||||
link
|
||||
type="success"
|
||||
@click="review(row, true)"
|
||||
>核实确认</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 0"
|
||||
v-hasPermi="['health:family-relation:review']"
|
||||
link
|
||||
type="danger"
|
||||
@click="review(row, false)"
|
||||
>拒绝</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 0 || row.status === 1"
|
||||
v-hasPermi="['health:family-relation:revoke']"
|
||||
link
|
||||
type="danger"
|
||||
@click="revoke(row)"
|
||||
>解除</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.status === 2 || row.status === 3"
|
||||
v-hasPermi="['health:family-relation:review']"
|
||||
link
|
||||
type="primary"
|
||||
@click="reopen(row)"
|
||||
>重新申请</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="query.pageNo"
|
||||
v-model:limit="query.pageSize"
|
||||
@pagination="load"
|
||||
/>
|
||||
</ContentWrap>
|
||||
<Dialog title="新增家属关联" v-model="visible" width="600">
|
||||
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
|
||||
<el-form-item label="家属" prop="familyIdentityId">
|
||||
<el-select
|
||||
v-model="form.familyIdentityId"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchFamilies"
|
||||
placeholder="输入姓名查找家属"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="person in families"
|
||||
:key="person.id"
|
||||
:value="person.id"
|
||||
:label="person.realName + '(账号 ' + person.memberId + ')'"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="老人" prop="elderlyProfileId">
|
||||
<el-select
|
||||
v-model="form.elderlyProfileId"
|
||||
filterable
|
||||
remote
|
||||
:remote-method="searchElders"
|
||||
placeholder="输入姓名查找老人"
|
||||
class="w-full"
|
||||
>
|
||||
<el-option
|
||||
v-for="person in elders"
|
||||
:key="person.id"
|
||||
:value="person.elderlyProfileId"
|
||||
:label="person.realName + '(档案 ' + person.elderlyProfileId + ')'"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="与老人关系" prop="relationship"
|
||||
><el-input v-model="form.relationship" maxlength="30" placeholder="例如:子女、配偶"
|
||||
/></el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button type="primary" :loading="saving" @click="submit">提交关联申请</el-button>
|
||||
<el-button @click="visible = false">取消</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import * as Api from '@/api/health'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import type { FormInstance } from 'element-plus'
|
||||
defineOptions({ name: 'HealthFamilyRelation' })
|
||||
const states = ['待确认', '已确认', '已拒绝', '已解除']
|
||||
const query = reactive({ pageNo: 1, pageSize: 10, status: undefined as number | undefined })
|
||||
const list = ref<Api.FamilyRelation[]>([])
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const visible = ref(false)
|
||||
const families = ref<Api.Identity[]>([])
|
||||
const elders = ref<Api.Identity[]>([])
|
||||
const form = reactive({
|
||||
familyIdentityId: undefined as number | undefined,
|
||||
elderlyProfileId: undefined as number | undefined,
|
||||
relationship: ''
|
||||
})
|
||||
const formRef = ref<FormInstance>()
|
||||
const rules = {
|
||||
familyIdentityId: [{ required: true, message: '请选择家属', trigger: 'change' }],
|
||||
elderlyProfileId: [{ required: true, message: '请选择老人', trigger: 'change' }],
|
||||
relationship: [{ required: true, message: '请输入与老人关系', trigger: 'blur' }]
|
||||
}
|
||||
const message = useMessage()
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const page = await Api.getRelationPage(query)
|
||||
list.value = page.list
|
||||
total.value = page.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const search = () => {
|
||||
query.pageNo = 1
|
||||
load()
|
||||
}
|
||||
const searchFamilies = async (realName: string) => {
|
||||
families.value = (
|
||||
await Api.getIdentityPage({
|
||||
pageNo: 1,
|
||||
pageSize: 50,
|
||||
identityType: 30,
|
||||
status: 0,
|
||||
auditStatus: 1,
|
||||
realName
|
||||
})
|
||||
).list
|
||||
}
|
||||
const searchElders = async (realName: string) => {
|
||||
elders.value = (
|
||||
await Api.getIdentityPage({
|
||||
pageNo: 1,
|
||||
pageSize: 50,
|
||||
identityType: 10,
|
||||
status: 0,
|
||||
auditStatus: 1,
|
||||
realName
|
||||
})
|
||||
).list
|
||||
}
|
||||
const openCreate = async () => {
|
||||
Object.assign(form, {
|
||||
familyIdentityId: undefined,
|
||||
elderlyProfileId: undefined,
|
||||
relationship: ''
|
||||
})
|
||||
visible.value = true
|
||||
await nextTick()
|
||||
formRef.value?.clearValidate()
|
||||
await Promise.all([searchFamilies(''), searchElders('')])
|
||||
}
|
||||
const submit = async () => {
|
||||
if (!(await formRef.value?.validate())) return
|
||||
saving.value = true
|
||||
try {
|
||||
await Api.createRelation({
|
||||
familyIdentityId: form.familyIdentityId!,
|
||||
elderlyProfileId: form.elderlyProfileId!,
|
||||
relationship: form.relationship
|
||||
})
|
||||
visible.value = false
|
||||
message.success('已提交,等待确认')
|
||||
await load()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
const promptReason = async (title: string): Promise<string | undefined> => {
|
||||
try {
|
||||
const result = await ElMessageBox.prompt('请填写核实情况或处理理由', title, {
|
||||
inputValidator: (value) =>
|
||||
value?.trim() && value.length <= 500 ? true : '请填写理由,不超过 500 字'
|
||||
})
|
||||
return result.value.trim()
|
||||
} catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
const review = async (row: Api.FamilyRelation, approved: boolean) => {
|
||||
const reason = await promptReason(approved ? '核实确认关联' : '拒绝关联')
|
||||
if (reason === undefined) return
|
||||
await Api.reviewRelation(row.id, approved, reason)
|
||||
message.success('处理完成')
|
||||
await load()
|
||||
}
|
||||
const revoke = async (row: Api.FamilyRelation) => {
|
||||
const reason = await promptReason('解除家属关联')
|
||||
if (reason === undefined) return
|
||||
await Api.revokeRelation(row.id, reason)
|
||||
message.success('关联已解除')
|
||||
await load()
|
||||
}
|
||||
const reopen = async (row: Api.FamilyRelation) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('重新申请后需要老人或管理员再次确认,确认继续?', '重新申请')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Api.reopenRelation(row.id)
|
||||
await load()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
@ -0,0 +1 @@
|
||||
家属与老人关联管理:新增申请、管理员核实确认、拒绝、解除和重新申请。
|
||||
211
yudao-ui-admin-vue3/src/views/health/identity/IdentityForm.vue
Normal file
211
yudao-ui-admin-vue3/src/views/health/identity/IdentityForm.vue
Normal file
@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<Dialog :title="title" v-model="visible" width="760">
|
||||
<el-form
|
||||
ref="formRef"
|
||||
:model="data"
|
||||
:rules="rules"
|
||||
label-width="120px"
|
||||
v-loading="loading"
|
||||
:disabled="readOnly"
|
||||
>
|
||||
<el-form-item label="业务身份" prop="identityType">
|
||||
<el-radio-group v-model="data.identityType" :disabled="!!data.id">
|
||||
<el-radio v-for="item in Api.identityTypes" :key="item.value" :label="item.value">{{
|
||||
item.label
|
||||
}}</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<template v-if="!data.id">
|
||||
<el-form-item label="账号来源">
|
||||
<el-radio-group v-model="accountMode">
|
||||
<el-radio label="new">创建新账号</el-radio>
|
||||
<el-radio label="existing">绑定已有会员账号</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<template v-if="accountMode === 'new'">
|
||||
<el-form-item label="登录手机号" prop="mobile">
|
||||
<el-input v-model="data.mobile" maxlength="11" autocomplete="off" />
|
||||
</el-form-item>
|
||||
<el-form-item label="初始密码" prop="password">
|
||||
<el-input
|
||||
v-model="data.password"
|
||||
type="password"
|
||||
show-password
|
||||
autocomplete="new-password"
|
||||
maxlength="16"
|
||||
/>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item v-else label="会员账号编号" prop="memberId">
|
||||
<el-input-number v-model="data.memberId" :min="1" :precision="0" :controls="false" />
|
||||
</el-form-item>
|
||||
</template>
|
||||
<template v-else>
|
||||
<el-form-item label="登录账号"
|
||||
>{{ data.mobile || '未绑定手机号' }}(账号编号 {{ data.memberId }})</el-form-item
|
||||
>
|
||||
<el-form-item label="审核状态">
|
||||
{{ Api.auditStates.find((item) => item.value === data.auditStatus)?.label }}
|
||||
<span class="ml-10px">{{ data.auditReason }}</span>
|
||||
</el-form-item>
|
||||
</template>
|
||||
<el-form-item label="姓名" prop="realName"
|
||||
><el-input v-model="data.realName" maxlength="30"
|
||||
/></el-form-item>
|
||||
<el-form-item label="性别" prop="gender">
|
||||
<el-radio-group v-model="data.gender">
|
||||
<el-radio :label="1">男</el-radio><el-radio :label="2">女</el-radio
|
||||
><el-radio :label="3">未知</el-radio>
|
||||
</el-radio-group>
|
||||
</el-form-item>
|
||||
<el-form-item label="出生日期" prop="birthDate">
|
||||
<el-date-picker
|
||||
v-model="data.birthDate"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
:disabled-date="disabledDate"
|
||||
/>
|
||||
</el-form-item>
|
||||
<el-form-item label="联系电话" prop="contactPhone"
|
||||
><el-input v-model="data.contactPhone" maxlength="30"
|
||||
/></el-form-item>
|
||||
<el-form-item label="地址"><el-input v-model="data.address" maxlength="500" /></el-form-item>
|
||||
<template v-if="data.identityType === 10">
|
||||
<el-divider content-position="left">老人基础健康档案</el-divider>
|
||||
<el-form-item label="紧急联系人"
|
||||
><el-input v-model="data.emergencyContact" maxlength="60"
|
||||
/></el-form-item>
|
||||
<el-form-item label="紧急联系电话" prop="emergencyPhone"
|
||||
><el-input v-model="data.emergencyPhone" maxlength="30"
|
||||
/></el-form-item>
|
||||
<el-form-item label="健康状况"
|
||||
><el-input v-model="data.healthStatus" type="textarea" maxlength="500"
|
||||
/></el-form-item>
|
||||
<el-form-item label="病史"
|
||||
><el-input v-model="data.medicalHistory" type="textarea" maxlength="2000"
|
||||
/></el-form-item>
|
||||
<el-form-item label="用药信息"
|
||||
><el-input v-model="data.medicationInfo" type="textarea" maxlength="2000"
|
||||
/></el-form-item>
|
||||
<el-form-item label="特殊需求"
|
||||
><el-input v-model="data.specialNeeds" type="textarea" maxlength="1000"
|
||||
/></el-form-item>
|
||||
</template>
|
||||
<template v-if="data.identityType === 20">
|
||||
<el-divider content-position="left">志愿者服务档案</el-divider>
|
||||
<el-form-item label="服务技能"
|
||||
><el-input v-model="data.skills" type="textarea" maxlength="1000"
|
||||
/></el-form-item>
|
||||
<el-form-item label="服务区域"
|
||||
><el-input v-model="data.serviceAreas" maxlength="500"
|
||||
/></el-form-item>
|
||||
<el-form-item label="可服务时间"
|
||||
><el-input v-model="data.availableTime" type="textarea" maxlength="1000"
|
||||
/></el-form-item>
|
||||
<el-form-item label="服务经验"
|
||||
><el-input v-model="data.serviceExperience" type="textarea" maxlength="2000"
|
||||
/></el-form-item>
|
||||
<el-alert
|
||||
title="志愿者建档后进入待审核状态,审核通过后才能参与志愿服务。"
|
||||
type="info"
|
||||
:closable="false"
|
||||
/>
|
||||
</template>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button v-if="!readOnly" type="primary" :loading="loading" @click="submit">保存</el-button>
|
||||
<el-button @click="visible = false">{{ readOnly ? '关闭' : '取消' }}</el-button>
|
||||
</template>
|
||||
</Dialog>
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import * as Api from '@/api/health'
|
||||
import type { FormInstance, FormRules } from 'element-plus'
|
||||
const visible = ref(false)
|
||||
const loading = ref(false)
|
||||
const readOnly = ref(false)
|
||||
const title = ref('')
|
||||
const accountMode = ref('new')
|
||||
const data = ref<Api.Identity>({ identityType: 10, realName: '', gender: 3 })
|
||||
const formRef = ref<FormInstance>()
|
||||
const message = useMessage()
|
||||
const disabledDate = (date: Date) => date.getTime() > Date.now()
|
||||
const phoneRule = {
|
||||
pattern: /^$|^[0-9+()\-]{5,30}$/,
|
||||
message: '联系电话格式不正确',
|
||||
trigger: 'blur'
|
||||
}
|
||||
const rules = computed<FormRules>(() => ({
|
||||
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
|
||||
mobile:
|
||||
!data.value.id && accountMode.value === 'new'
|
||||
? [
|
||||
{
|
||||
required: true,
|
||||
pattern: /^1[3-9]\d{9}$/,
|
||||
message: '请输入正确的手机号',
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: [],
|
||||
password:
|
||||
!data.value.id && accountMode.value === 'new'
|
||||
? [{ required: true, min: 4, max: 16, message: '密码长度为 4–16 位', trigger: 'blur' }]
|
||||
: [],
|
||||
memberId:
|
||||
!data.value.id && accountMode.value === 'existing'
|
||||
? [{ required: true, message: '请输入已有会员账号编号', trigger: 'blur' }]
|
||||
: [],
|
||||
contactPhone: [phoneRule],
|
||||
emergencyPhone: [phoneRule]
|
||||
}))
|
||||
const emit = defineEmits(['success'])
|
||||
const open = async (mode: 'create' | 'update' | 'view', id?: number) => {
|
||||
visible.value = true
|
||||
readOnly.value = mode === 'view'
|
||||
title.value = { create: '新增身份与档案', update: '编辑档案', view: '档案详情' }[mode]
|
||||
accountMode.value = 'new'
|
||||
data.value = { identityType: 10, realName: '', gender: 3 }
|
||||
await nextTick()
|
||||
formRef.value?.clearValidate()
|
||||
if (id) {
|
||||
loading.value = true
|
||||
try {
|
||||
data.value = await Api.getIdentity(id)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
defineExpose({ open })
|
||||
const submit = async () => {
|
||||
if (!(await formRef.value?.validate())) return
|
||||
loading.value = true
|
||||
try {
|
||||
const {
|
||||
mobile,
|
||||
password,
|
||||
status,
|
||||
auditStatus,
|
||||
auditReason,
|
||||
elderlyProfileId,
|
||||
volunteerProfileId,
|
||||
...profile
|
||||
} = data.value
|
||||
const payload: Api.Identity = { ...profile }
|
||||
if (!profile.id && accountMode.value === 'new') {
|
||||
payload.memberId = undefined
|
||||
payload.mobile = mobile
|
||||
payload.password = password
|
||||
}
|
||||
if (profile.id) await Api.updateIdentity(payload)
|
||||
else await Api.createIdentity(payload)
|
||||
message.success('保存成功')
|
||||
visible.value = false
|
||||
emit('success')
|
||||
} finally {
|
||||
data.value.password = undefined
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
213
yudao-ui-admin-vue3/src/views/health/identity/index.vue
Normal file
213
yudao-ui-admin-vue3/src/views/health/identity/index.vue
Normal file
@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<ContentWrap>
|
||||
<el-form :inline="true" :model="query">
|
||||
<el-form-item label="姓名"
|
||||
><el-input v-model="query.realName" clearable @keyup.enter="search"
|
||||
/></el-form-item>
|
||||
<el-form-item label="身份">
|
||||
<el-select v-model="query.identityType" clearable class="!w-140px">
|
||||
<el-option
|
||||
v-for="item in Api.identityTypes"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="审核">
|
||||
<el-select v-model="query.auditStatus" clearable class="!w-140px">
|
||||
<el-option
|
||||
v-for="item in Api.auditStates"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="状态">
|
||||
<el-select v-model="query.status" clearable class="!w-120px">
|
||||
<el-option label="启用" :value="0" /><el-option label="停用" :value="1" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item>
|
||||
<el-button type="primary" @click="search">查询</el-button>
|
||||
<el-button @click="reset">重置</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['health:identity:create']"
|
||||
type="success"
|
||||
@click="formRef?.open('create')"
|
||||
>新增档案</el-button
|
||||
>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</ContentWrap>
|
||||
<ContentWrap>
|
||||
<el-table v-loading="loading" :data="list" stripe>
|
||||
<el-table-column label="身份编号" prop="id" width="90" />
|
||||
<el-table-column label="账号编号" prop="memberId" width="90" />
|
||||
<el-table-column label="姓名" prop="realName" min-width="100" />
|
||||
<el-table-column label="身份" width="90">
|
||||
<template #default="{ row }">{{
|
||||
Api.identityTypes.find((item) => item.value === row.identityType)?.label
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="登录手机号" prop="mobile" min-width="125" />
|
||||
<el-table-column label="档案编号" width="95">
|
||||
<template #default="{ row }">{{
|
||||
row.elderlyProfileId || row.volunteerProfileId || '—'
|
||||
}}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="启用状态" width="95">
|
||||
<template #default="{ row }"
|
||||
><el-tag :type="row.status === 0 ? 'success' : 'info'">{{
|
||||
row.status === 0 ? '启用' : '停用'
|
||||
}}</el-tag></template
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column label="审核状态" width="100">
|
||||
<template #default="{ row }"
|
||||
><el-tag
|
||||
:type="row.auditStatus === 1 ? 'success' : row.auditStatus === 2 ? 'danger' : 'warning'"
|
||||
>{{ Api.auditStates.find((item) => item.value === row.auditStatus)?.label }}</el-tag
|
||||
></template
|
||||
>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" min-width="300">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" @click="formRef?.open('view', row.id)">详情</el-button>
|
||||
<el-button
|
||||
v-hasPermi="['health:identity:update']"
|
||||
link
|
||||
type="primary"
|
||||
@click="formRef?.open('update', row.id)"
|
||||
>编辑</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.identityType === 20 && row.auditStatus === 0"
|
||||
v-hasPermi="['health:identity:audit']"
|
||||
link
|
||||
type="success"
|
||||
@click="audit(row, true)"
|
||||
>通过</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.identityType === 20 && row.auditStatus === 0"
|
||||
v-hasPermi="['health:identity:audit']"
|
||||
link
|
||||
type="danger"
|
||||
@click="audit(row, false)"
|
||||
>驳回</el-button
|
||||
>
|
||||
<el-button
|
||||
v-if="row.identityType === 20 && row.auditStatus === 2"
|
||||
v-hasPermi="['health:identity:audit']"
|
||||
link
|
||||
type="primary"
|
||||
@click="resubmit(row)"
|
||||
>重新提审</el-button
|
||||
>
|
||||
<el-button
|
||||
v-hasPermi="['health:identity:update-status']"
|
||||
link
|
||||
:type="row.status === 0 ? 'danger' : 'success'"
|
||||
@click="toggleStatus(row)"
|
||||
>{{ row.status === 0 ? '停用' : '启用' }}</el-button
|
||||
>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
<Pagination
|
||||
:total="total"
|
||||
v-model:page="query.pageNo"
|
||||
v-model:limit="query.pageSize"
|
||||
@pagination="load"
|
||||
/>
|
||||
</ContentWrap>
|
||||
<IdentityForm ref="formRef" @success="load" />
|
||||
</template>
|
||||
<script setup lang="ts">
|
||||
import * as Api from '@/api/health'
|
||||
import IdentityForm from './IdentityForm.vue'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
defineOptions({ name: 'HealthIdentity' })
|
||||
const query = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
realName: '',
|
||||
identityType: undefined as number | undefined,
|
||||
auditStatus: undefined as number | undefined,
|
||||
status: undefined as number | undefined
|
||||
})
|
||||
const loading = ref(false)
|
||||
const list = ref<Api.Identity[]>([])
|
||||
const total = ref(0)
|
||||
const formRef = ref<InstanceType<typeof IdentityForm>>()
|
||||
const message = useMessage()
|
||||
const load = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const page = await Api.getIdentityPage(query)
|
||||
list.value = page.list
|
||||
total.value = page.total
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
const search = () => {
|
||||
query.pageNo = 1
|
||||
load()
|
||||
}
|
||||
const reset = () => {
|
||||
Object.assign(query, {
|
||||
realName: '',
|
||||
identityType: undefined,
|
||||
auditStatus: undefined,
|
||||
status: undefined
|
||||
})
|
||||
search()
|
||||
}
|
||||
const audit = async (row: Api.Identity, approved: boolean) => {
|
||||
let reason = ''
|
||||
try {
|
||||
const result = await ElMessageBox.prompt(
|
||||
approved ? '可填写审核备注' : '请填写驳回理由',
|
||||
approved ? '通过审核' : '驳回申请',
|
||||
{
|
||||
inputValidator: (value) =>
|
||||
(!approved && !value?.trim()) || (value?.length || 0) > 500
|
||||
? '理由不能为空且不超过 500 字'
|
||||
: true
|
||||
}
|
||||
)
|
||||
reason = result.value || ''
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Api.auditIdentity(row.id!, approved, reason)
|
||||
message.success('审核完成')
|
||||
await load()
|
||||
}
|
||||
const resubmit = async (row: Api.Identity) => {
|
||||
try {
|
||||
await ElMessageBox.confirm('将这位志愿者重新提交审核?', '重新提审')
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Api.resubmitIdentity(row.id!)
|
||||
await load()
|
||||
}
|
||||
const toggleStatus = async (row: Api.Identity) => {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
row.status === 0 ? '停用后,这个身份将无法参与健康业务。确认停用?' : '确认启用这个身份?',
|
||||
'身份状态'
|
||||
)
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
await Api.updateIdentityStatus(row.id!, row.status === 0 ? 1 : 0)
|
||||
message.success('状态已更新')
|
||||
await load()
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
1
yudao-ui-admin-vue3/src/views/health/identity/目录说明.txt
Normal file
1
yudao-ui-admin-vue3/src/views/health/identity/目录说明.txt
Normal file
@ -0,0 +1 @@
|
||||
老人、志愿者、家属的身份登记与档案维护,志愿者审核,以及身份启用停用。
|
||||
1
yudao-ui-admin-vue3/src/views/health/目录说明.txt
Normal file
1
yudao-ui-admin-vue3/src/views/health/目录说明.txt
Normal file
@ -0,0 +1 @@
|
||||
健康服务管理页面。身份与档案、家属关联由后台菜单加载;业务权限仍由后端校验。
|
||||
Loading…
Reference in New Issue
Block a user