feat:新增健康业务模块与身份模型

This commit is contained in:
AXR\Administrator 2026-10-06 23:03:29 +08:00
parent d99ab5e1e4
commit 70a7539cb3
67 changed files with 3305 additions and 0 deletions

View 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.'

View 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)。未创建演示会员账号。
未迁移旧数据,未改造移动端页面,未实现历次健康评估、预约派单或设备/智能体接入;这些仍是后续阶段。

View 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 节待定与其他章节设计建议,再按当前芋道工程实现;不要把所有待定项一次性要求用户回答,也不要未经实现/验证就报告集成完成。

View 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 = '会员用户';

View 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='家属与老人多对多关联,确认后授权';

View 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;

View File

@ -0,0 +1 @@
健康模块增量 SQL:V000 只提取会员表结构,V001 建立身份与基础档案,V002 新增后台菜单及权限。没有旧数据导入和 DROP TABLE。由 deploy/local/initialize-health.ps1 应用。

View 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>

View 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>

View File

@ -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);
}

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
模块间调用接口及实现。浏览器请求走 Controller。
详见 docs/健康业务模块与身份模型.md。

View File

@ -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, "家属关联尚未确认或已解除");
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -0,0 +1,3 @@
身份类型、审核状态和健康业务错误码。
详见 docs/健康业务模块与身份模型.md。

View File

@ -0,0 +1,3 @@
供其他模块调用的身份校验接口、DTO、身份及审核枚举。后续预约和派单通过 requireActiveIdentity 校验身份,不直接依赖 biz 的 Mapper。
详见 docs/健康业务模块与身份模型.md。

View 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>

View File

@ -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);
}
}

View File

@ -0,0 +1,3 @@
模块间调用接口及实现。浏览器请求走 Controller。
详见 docs/健康业务模块与身份模型.md。

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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)));
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
HTTP 接口与输入输出 VO。admin 使用后台权限,app 仅使用登录会员的账号与业务授权。
详见 docs/健康业务模块与身份模型.md。

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
持久化实体:继承 TenantBaseDO,包含审计字段和租户编号。
详见 docs/健康业务模块与身份模型.md。

View File

@ -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));
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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));
}
}

View File

@ -0,0 +1,3 @@
MyBatis Mapper。框架负责租户过滤;唯一索引防止并发重复建档。
详见 docs/健康业务模块与身份模型.md。

View File

@ -0,0 +1,3 @@
数据访问层。
详见 docs/健康业务模块与身份模型.md。

View File

@ -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);
}

View File

@ -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();
}
}

View File

@ -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);
}

View File

@ -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;
}
}

View File

@ -0,0 +1,3 @@
业务规则、账号与档案创建事务、审批和家属授权。
详见 docs/健康业务模块与身份模型.md。

View File

@ -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());
}
}

View File

@ -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

View File

@ -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;

View File

@ -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`)
);

View File

@ -0,0 +1,3 @@
身份、事务回滚、家属授权、匿名请求和租户隔离的集成测试。测试数据仅保存在 H2 内存库。
详见 docs/健康业务模块与身份模型.md。

View File

@ -0,0 +1,3 @@
健康业务实现:后台身份建档、志愿者审核、本人身份查询、家属确认和健康档案授权。src/test 使用 H2,不操作本地业务数据库。
详见 docs/健康业务模块与身份模型.md。

View File

@ -0,0 +1,3 @@
老年健康业务模块。已加入 Maven 和 yudao-server;账号复用 member,业务身份和基础健康档案保存在这里。后续扩展评估、运动预约、派单、提醒和摄像头接入。
详见 docs/健康业务模块与身份模型.md。

View File

@ -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;
}

View 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 } })

View File

@ -0,0 +1 @@
健康管理接口:身份建档、档案编辑、志愿者审核、家属关联。此处只封装请求,身份授权由后端执行。

View 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>

View File

@ -0,0 +1 @@
家属与老人关联管理:新增申请、管理员核实确认、拒绝、解除和重新申请。

View 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>

View 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>

View File

@ -0,0 +1 @@
老人、志愿者、家属的身份登记与档案维护,志愿者审核,以及身份启用停用。

View File

@ -0,0 +1 @@
健康服务管理页面。身份与档案、家属关联由后台菜单加载;业务权限仍由后端校验。