diff --git a/deploy/local/initialize-health.ps1 b/deploy/local/initialize-health.ps1 new file mode 100644 index 0000000..eb06bcb --- /dev/null +++ b/deploy/local/initialize-health.ps1 @@ -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.' diff --git a/docs/健康业务模块与身份模型.md b/docs/健康业务模块与身份模型.md new file mode 100644 index 0000000..e4c5c69 --- /dev/null +++ b/docs/健康业务模块与身份模型.md @@ -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)。未创建演示会员账号。 + +未迁移旧数据,未改造移动端页面,未实现历次健康评估、预约派单或设备/智能体接入;这些仍是后续阶段。 diff --git a/docs/老年健康系统扩展需求与设计草案.md b/docs/老年健康系统扩展需求与设计草案.md new file mode 100644 index 0000000..958632c --- /dev/null +++ b/docs/老年健康系统扩展需求与设计草案.md @@ -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 节待定与其他章节设计建议,再按当前芋道工程实现;不要把所有待定项一次性要求用户回答,也不要未经实现/验证就报告集成完成。 diff --git a/mysql_script/health/V000__member_schema.sql b/mysql_script/health/V000__member_schema.sql new file mode 100644 index 0000000..2adf964 --- /dev/null +++ b/mysql_script/health/V000__member_schema.sql @@ -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 = '会员用户'; diff --git a/mysql_script/health/V001__health_identity.sql b/mysql_script/health/V001__health_identity.sql new file mode 100644 index 0000000..c95ad62 --- /dev/null +++ b/mysql_script/health/V001__health_identity.sql @@ -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='家属与老人多对多关联,确认后授权'; diff --git a/mysql_script/health/V002__health_menu.sql b/mysql_script/health/V002__health_menu.sql new file mode 100644 index 0000000..f53f417 --- /dev/null +++ b/mysql_script/health/V002__health_menu.sql @@ -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; + diff --git a/mysql_script/health/目录说明.txt b/mysql_script/health/目录说明.txt new file mode 100644 index 0000000..be8e36e --- /dev/null +++ b/mysql_script/health/目录说明.txt @@ -0,0 +1 @@ +健康模块增量 SQL:V000 只提取会员表结构,V001 建立身份与基础档案,V002 新增后台菜单及权限。没有旧数据导入和 DROP TABLE。由 deploy/local/initialize-health.ps1 应用。 diff --git a/yudao-module-health/pom.xml b/yudao-module-health/pom.xml new file mode 100644 index 0000000..6414274 --- /dev/null +++ b/yudao-module-health/pom.xml @@ -0,0 +1,13 @@ + + + 4.0.0 + cn.iocoder.bootyudao${revision} + yudao-module-health + pom + 老年健康业务:身份、健康档案及后续评估、运动预约和志愿服务 + + yudao-module-health-api + yudao-module-health-biz + + diff --git a/yudao-module-health/yudao-module-health-api/pom.xml b/yudao-module-health/yudao-module-health-api/pom.xml new file mode 100644 index 0000000..4a342d7 --- /dev/null +++ b/yudao-module-health/yudao-module-health-api/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + cn.iocoder.bootyudao-module-health${revision} + yudao-module-health-api + 健康模块供其他模块调用的身份接口、DTO 与枚举 + + cn.iocoder.bootyudao-common + + diff --git a/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/identity/HealthIdentityApi.java b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/identity/HealthIdentityApi.java new file mode 100644 index 0000000..9acde5e --- /dev/null +++ b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/identity/HealthIdentityApi.java @@ -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); +} diff --git a/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/identity/dto/HealthIdentityRespDTO.java b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/identity/dto/HealthIdentityRespDTO.java new file mode 100644 index 0000000..e07269b --- /dev/null +++ b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/identity/dto/HealthIdentityRespDTO.java @@ -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; +} diff --git a/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/目录说明.txt b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/目录说明.txt new file mode 100644 index 0000000..87bc378 --- /dev/null +++ b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/目录说明.txt @@ -0,0 +1,3 @@ +模块间调用接口及实现。浏览器请求走 Controller。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/ErrorCodeConstants.java b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/ErrorCodeConstants.java new file mode 100644 index 0000000..a8e0176 --- /dev/null +++ b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/ErrorCodeConstants.java @@ -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, "家属关联尚未确认或已解除"); +} diff --git a/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/HealthAuditStatusEnum.java b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/HealthAuditStatusEnum.java new file mode 100644 index 0000000..ebeffd8 --- /dev/null +++ b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/HealthAuditStatusEnum.java @@ -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; } +} diff --git a/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/HealthIdentityTypeEnum.java b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/HealthIdentityTypeEnum.java new file mode 100644 index 0000000..0d0fb8d --- /dev/null +++ b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/HealthIdentityTypeEnum.java @@ -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; } +} diff --git a/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/目录说明.txt b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/目录说明.txt new file mode 100644 index 0000000..561da37 --- /dev/null +++ b/yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/目录说明.txt @@ -0,0 +1,3 @@ +身份类型、审核状态和健康业务错误码。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-api/目录说明.txt b/yudao-module-health/yudao-module-health-api/目录说明.txt new file mode 100644 index 0000000..0388816 --- /dev/null +++ b/yudao-module-health/yudao-module-health-api/目录说明.txt @@ -0,0 +1,3 @@ +供其他模块调用的身份校验接口、DTO、身份及审核枚举。后续预约和派单通过 requireActiveIdentity 校验身份,不直接依赖 biz 的 Mapper。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-biz/pom.xml b/yudao-module-health/yudao-module-health-biz/pom.xml new file mode 100644 index 0000000..310d77f --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/pom.xml @@ -0,0 +1,18 @@ + + + 4.0.0 + cn.iocoder.bootyudao-module-health${revision} + yudao-module-health-biz + 健康业务实现;保持账号认证与业务身份分离 + + cn.iocoder.bootyudao-module-health-api${revision} + cn.iocoder.bootyudao-module-member-api${revision} + cn.iocoder.bootyudao-spring-boot-starter-biz-tenant + cn.iocoder.bootyudao-spring-boot-starter-security + cn.iocoder.bootyudao-spring-boot-starter-mybatis + org.springframework.bootspring-boot-starter-validation + cn.iocoder.bootyudao-spring-boot-starter-testtest + cn.iocoder.bootyudao-module-member-biz${revision}test + + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/api/identity/HealthIdentityApiImpl.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/api/identity/HealthIdentityApiImpl.java new file mode 100644 index 0000000..dac2b71 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/api/identity/HealthIdentityApiImpl.java @@ -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); + } +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/api/目录说明.txt b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/api/目录说明.txt new file mode 100644 index 0000000..87bc378 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/api/目录说明.txt @@ -0,0 +1,3 @@ +模块间调用接口及实现。浏览器请求走 Controller。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/HealthIdentityController.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/HealthIdentityController.java new file mode 100644 index 0000000..9203771 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/HealthIdentityController.java @@ -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 create(@Valid @RequestBody HealthIdentitySaveReqVO req) { + return success(identityService.createIdentity(req)); + } + @PutMapping("/update") + @Operation(summary = "编辑档案,不更换账号和身份类型") + @PreAuthorize("@ss.hasPermission('health:identity:update')") + public CommonResult update(@Valid @RequestBody HealthIdentitySaveReqVO req) { + identityService.updateIdentity(req); return success(true); + } + @GetMapping("/get") + @Operation(summary = "读取档案详情") + @PreAuthorize("@ss.hasPermission('health:identity:query')") + public CommonResult get(@RequestParam @Positive Long id) { + return success(identityService.getIdentityDetails(id)); + } + @GetMapping("/page") + @Operation(summary = "按身份、启用和审核状态查询") + @PreAuthorize("@ss.hasPermission('health:identity:query')") + public CommonResult> page(@Valid HealthIdentityPageReqVO req) { + return success(identityService.getIdentityPage(req)); + } + @PutMapping("/audit") + @Operation(summary = "审核志愿者,驳回必须写理由") + @PreAuthorize("@ss.hasPermission('health:identity:audit')") + public CommonResult audit(@Valid @RequestBody HealthIdentityAuditReqVO req) { + identityService.auditIdentity(req, getLoginUserId()); return success(true); + } + @PutMapping("/resubmit") + @Operation(summary = "将已驳回的志愿者重新提交审核") + @PreAuthorize("@ss.hasPermission('health:identity:audit')") + public CommonResult 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 updateStatus(@Valid @RequestBody HealthIdentityStatusReqVO req) { + identityService.updateStatus(req); return success(true); + } +} diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityAuditReqVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityAuditReqVO.java new file mode 100644 index 0000000..c7d7a3d --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityAuditReqVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityPageReqVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityPageReqVO.java new file mode 100644 index 0000000..4985530 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityPageReqVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentitySaveReqVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentitySaveReqVO.java new file mode 100644 index 0000000..5fea006 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentitySaveReqVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityStatusReqVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityStatusReqVO.java new file mode 100644 index 0000000..80214e3 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityStatusReqVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/relation/HealthFamilyRelationController.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/relation/HealthFamilyRelationController.java new file mode 100644 index 0000000..4b278ec --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/relation/HealthFamilyRelationController.java @@ -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> page(@Valid HealthFamilyRelationPageReqVO req) { + return success(relationService.getRelationPage(req)); + } + @PostMapping("/create") + @PreAuthorize("@ss.hasPermission('health:family-relation:create')") + public CommonResult create(@Valid @RequestBody HealthFamilyRelationCreateReqVO req) { + return success(relationService.createRelation(req)); + } + @PutMapping("/review") + @PreAuthorize("@ss.hasPermission('health:family-relation:review')") + public CommonResult review(@Valid @RequestBody HealthFamilyRelationReviewReqVO req) { + relationService.reviewRelation(req, getLoginUserId(), true); return success(true); + } + @PutMapping("/revoke") + @PreAuthorize("@ss.hasPermission('health:family-relation:revoke')") + public CommonResult revoke(@Valid @RequestBody HealthFamilyRelationRevokeReqVO req) { + relationService.revokeRelation(req, getLoginUserId(), true); return success(true); + } + @PutMapping("/reopen") + @PreAuthorize("@ss.hasPermission('health:family-relation:review')") + public CommonResult reopen(@RequestParam @Positive Long id) { + relationService.reopenRelation(id); return success(true); + } +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/relation/vo/HealthFamilyRelationPageReqVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/relation/vo/HealthFamilyRelationPageReqVO.java new file mode 100644 index 0000000..559fe93 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/relation/vo/HealthFamilyRelationPageReqVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/app/identity/AppHealthIdentityController.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/app/identity/AppHealthIdentityController.java new file mode 100644 index 0000000..f6be467 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/app/identity/AppHealthIdentityController.java @@ -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 me() { + return success(identityService.getMyIdentity(getLoginUserId())); + } + @GetMapping("/family-relations") + public CommonResult> myRelations() { + return success(relationService.getMyRelations(getLoginUserId())); + } + @PostMapping("/family-relation/request") + public CommonResult request(@Valid @RequestBody HealthFamilyRelationCreateReqVO req) { + return success(relationService.requestMyRelation(getLoginUserId(), req)); + } + @PutMapping("/family-relation/review") + public CommonResult review(@Valid @RequestBody HealthFamilyRelationReviewReqVO req) { + relationService.reviewRelation(req, getLoginUserId(), false); return success(true); + } + @PutMapping("/family-relation/revoke") + public CommonResult revoke(@Valid @RequestBody HealthFamilyRelationRevokeReqVO req) { + relationService.revokeRelation(req, getLoginUserId(), false); return success(true); + } + @GetMapping("/family-elderly-profile") + public CommonResult familyElderlyProfile(@RequestParam Long elderlyProfileId) { + relationService.requireConfirmedFamilyRelation(getLoginUserId(), elderlyProfileId); + return success(identityService.getIdentityDetails(identityService.getElderlyIdentityId(elderlyProfileId))); + } +} diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationCreateReqVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationCreateReqVO.java new file mode 100644 index 0000000..8541645 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationCreateReqVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationRespVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationRespVO.java new file mode 100644 index 0000000..beb6b61 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationRespVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationReviewReqVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationReviewReqVO.java new file mode 100644 index 0000000..c110a1a --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationReviewReqVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationRevokeReqVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationRevokeReqVO.java new file mode 100644 index 0000000..2d4ca8f --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationRevokeReqVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthIdentityRespVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthIdentityRespVO.java new file mode 100644 index 0000000..94f747e --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthIdentityRespVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthProfileFieldsVO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthProfileFieldsVO.java new file mode 100644 index 0000000..1741823 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthProfileFieldsVO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/目录说明.txt b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/目录说明.txt new file mode 100644 index 0000000..f77656e --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/目录说明.txt @@ -0,0 +1,3 @@ +HTTP 接口与输入输出 VO。admin 使用后台权限,app 仅使用登录会员的账号与业务授权。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/identity/HealthIdentityDO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/identity/HealthIdentityDO.java new file mode 100644 index 0000000..2020f1f --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/identity/HealthIdentityDO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/profile/HealthElderlyProfileDO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/profile/HealthElderlyProfileDO.java new file mode 100644 index 0000000..bb21754 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/profile/HealthElderlyProfileDO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/profile/HealthVolunteerProfileDO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/profile/HealthVolunteerProfileDO.java new file mode 100644 index 0000000..b4a6191 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/profile/HealthVolunteerProfileDO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/relation/HealthFamilyRelationDO.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/relation/HealthFamilyRelationDO.java new file mode 100644 index 0000000..a024cdb --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/relation/HealthFamilyRelationDO.java @@ -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; +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/目录说明.txt b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/目录说明.txt new file mode 100644 index 0000000..1ec7547 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/目录说明.txt @@ -0,0 +1,3 @@ +持久化实体:继承 TenantBaseDO,包含审计字段和租户编号。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/identity/HealthIdentityMapper.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/identity/HealthIdentityMapper.java new file mode 100644 index 0000000..40111eb --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/identity/HealthIdentityMapper.java @@ -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 { + default HealthIdentityDO selectByMemberId(Long memberId) { + return selectOne(HealthIdentityDO::getMemberId, memberId); + } + default PageResult selectPage(HealthIdentityPageReqVO req) { + return selectPage(req, new LambdaQueryWrapperX() + .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)); + } +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/profile/HealthElderlyProfileMapper.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/profile/HealthElderlyProfileMapper.java new file mode 100644 index 0000000..677a6e1 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/profile/HealthElderlyProfileMapper.java @@ -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 { + default HealthElderlyProfileDO selectByIdentityId(Long identityId) { + return selectOne(HealthElderlyProfileDO::getIdentityId, identityId); + } +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/profile/HealthVolunteerProfileMapper.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/profile/HealthVolunteerProfileMapper.java new file mode 100644 index 0000000..7916e86 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/profile/HealthVolunteerProfileMapper.java @@ -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 { + default HealthVolunteerProfileDO selectByIdentityId(Long identityId) { + return selectOne(HealthVolunteerProfileDO::getIdentityId, identityId); + } +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/relation/HealthFamilyRelationMapper.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/relation/HealthFamilyRelationMapper.java new file mode 100644 index 0000000..411d5c1 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/relation/HealthFamilyRelationMapper.java @@ -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 { + default HealthFamilyRelationDO selectByPair(Long familyId, Long elderlyProfileId) { + return selectOne(HealthFamilyRelationDO::getFamilyIdentityId, familyId, + HealthFamilyRelationDO::getElderlyProfileId, elderlyProfileId); + } + default PageResult selectPage(HealthFamilyRelationPageReqVO req) { + return selectPage(req, new LambdaQueryWrapperX() + .eqIfPresent(HealthFamilyRelationDO::getFamilyIdentityId, req.getFamilyIdentityId()) + .eqIfPresent(HealthFamilyRelationDO::getElderlyProfileId, req.getElderlyProfileId()) + .eqIfPresent(HealthFamilyRelationDO::getStatus, req.getStatus()) + .orderByDesc(HealthFamilyRelationDO::getId)); + } +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/目录说明.txt b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/目录说明.txt new file mode 100644 index 0000000..5aa7c2d --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/目录说明.txt @@ -0,0 +1,3 @@ +MyBatis Mapper。框架负责租户过滤;唯一索引防止并发重复建档。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/目录说明.txt b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/目录说明.txt new file mode 100644 index 0000000..0040063 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/目录说明.txt @@ -0,0 +1,3 @@ +数据访问层。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/identity/HealthIdentityService.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/identity/HealthIdentityService.java new file mode 100644 index 0000000..5f5e4d5 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/identity/HealthIdentityService.java @@ -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 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); +} diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/identity/HealthIdentityServiceImpl.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/identity/HealthIdentityServiceImpl.java new file mode 100644 index 0000000..20c36a0 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/identity/HealthIdentityServiceImpl.java @@ -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() + .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() + .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() + .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 getIdentityPage(HealthIdentityPageReqVO req) { + PageResult 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(); + } +} diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/relation/HealthFamilyRelationService.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/relation/HealthFamilyRelationService.java new file mode 100644 index 0000000..2f676f7 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/relation/HealthFamilyRelationService.java @@ -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 getRelationPage(HealthFamilyRelationPageReqVO req); + List getMyRelations(Long memberId); + void requireConfirmedFamilyRelation(Long familyMemberId, Long elderlyProfileId); +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/relation/HealthFamilyRelationServiceImpl.java b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/relation/HealthFamilyRelationServiceImpl.java new file mode 100644 index 0000000..43fa234 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/relation/HealthFamilyRelationServiceImpl.java @@ -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() + .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() + .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() + .eq(HealthFamilyRelationDO::getId, id).in(HealthFamilyRelationDO::getStatus, 2, 3) + .set(HealthFamilyRelationDO::getStatus, 0)); + if (updated != 1) { throw exception(RELATION_STATE_INVALID); } + // 保留上次确认与解除字段作为最近一次处理痕迹,当前状态重新变为待确认。 + } + + @Override + public PageResult getRelationPage(HealthFamilyRelationPageReqVO req) { + PageResult page = relationMapper.selectPage(req); + return new PageResult<>(page.getList().stream().map(this::toResponse).collect(Collectors.toList()), page.getTotal()); + } + + @Override + public List getMyRelations(Long memberId) { + HealthIdentityDO identity = identityService.getIdentityByMemberId(memberId); + if (identity == null) { throw exception(IDENTITY_NOT_EXISTS); } + identityService.requireActiveIdentity(memberId, identity.getIdentityType()); + LambdaQueryWrapperX 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; + } +} + diff --git a/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/目录说明.txt b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/目录说明.txt new file mode 100644 index 0000000..86c302e --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/目录说明.txt @@ -0,0 +1,3 @@ +业务规则、账号与档案创建事务、审批和家属授权。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-biz/src/test/java/cn/iocoder/yudao/module/health/service/HealthIdentityServiceTest.java b/yudao-module-health/yudao-module-health-biz/src/test/java/cn/iocoder/yudao/module/health/service/HealthIdentityServiceTest.java new file mode 100644 index 0000000..6137272 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/test/java/cn/iocoder/yudao/module/health/service/HealthIdentityServiceTest.java @@ -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()); + } +} diff --git a/yudao-module-health/yudao-module-health-biz/src/test/resources/application-unit-test.yaml b/yudao-module-health/yudao-module-health-biz/src/test/resources/application-unit-test.yaml new file mode 100644 index 0000000..03e4b89 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/test/resources/application-unit-test.yaml @@ -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 + diff --git a/yudao-module-health/yudao-module-health-biz/src/test/resources/sql/clean.sql b/yudao-module-health/yudao-module-health-biz/src/test/resources/sql/clean.sql new file mode 100644 index 0000000..ef1370a --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/test/resources/sql/clean.sql @@ -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; diff --git a/yudao-module-health/yudao-module-health-biz/src/test/resources/sql/create_tables.sql b/yudao-module-health/yudao-module-health-biz/src/test/resources/sql/create_tables.sql new file mode 100644 index 0000000..52c793a --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/test/resources/sql/create_tables.sql @@ -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`) +); diff --git a/yudao-module-health/yudao-module-health-biz/src/test/目录说明.txt b/yudao-module-health/yudao-module-health-biz/src/test/目录说明.txt new file mode 100644 index 0000000..9d3e335 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/src/test/目录说明.txt @@ -0,0 +1,3 @@ +身份、事务回滚、家属授权、匿名请求和租户隔离的集成测试。测试数据仅保存在 H2 内存库。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/yudao-module-health-biz/目录说明.txt b/yudao-module-health/yudao-module-health-biz/目录说明.txt new file mode 100644 index 0000000..7772956 --- /dev/null +++ b/yudao-module-health/yudao-module-health-biz/目录说明.txt @@ -0,0 +1,3 @@ +健康业务实现:后台身份建档、志愿者审核、本人身份查询、家属确认和健康档案授权。src/test 使用 H2,不操作本地业务数据库。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-health/目录说明.txt b/yudao-module-health/目录说明.txt new file mode 100644 index 0000000..24bfd1b --- /dev/null +++ b/yudao-module-health/目录说明.txt @@ -0,0 +1,3 @@ +老年健康业务模块。已加入 Maven 和 yudao-server;账号复用 member,业务身份和基础健康档案保存在这里。后续扩展评估、运动预约、派单、提醒和摄像头接入。 + +详见 docs/健康业务模块与身份模型.md。 diff --git a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/dto/MemberUserCreateReqDTO.java b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/dto/MemberUserCreateReqDTO.java new file mode 100644 index 0000000..c86b1f4 --- /dev/null +++ b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/dto/MemberUserCreateReqDTO.java @@ -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; +} diff --git a/yudao-ui-admin-vue3/src/api/health/index.ts b/yudao-ui-admin-vue3/src/api/health/index.ts new file mode 100644 index 0000000..55b08ac --- /dev/null +++ b/yudao-ui-admin-vue3/src/api/health/index.ts @@ -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({ url: '/health/identity/get', params: { id } }) +export const createIdentity = (data: Identity) => + request.post({ 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 } }) diff --git a/yudao-ui-admin-vue3/src/api/health/目录说明.txt b/yudao-ui-admin-vue3/src/api/health/目录说明.txt new file mode 100644 index 0000000..f814057 --- /dev/null +++ b/yudao-ui-admin-vue3/src/api/health/目录说明.txt @@ -0,0 +1 @@ +健康管理接口:身份建档、档案编辑、志愿者审核、家属关联。此处只封装请求,身份授权由后端执行。 diff --git a/yudao-ui-admin-vue3/src/views/health/family-relation/index.vue b/yudao-ui-admin-vue3/src/views/health/family-relation/index.vue new file mode 100644 index 0000000..72c48ee --- /dev/null +++ b/yudao-ui-admin-vue3/src/views/health/family-relation/index.vue @@ -0,0 +1,262 @@ + + diff --git a/yudao-ui-admin-vue3/src/views/health/family-relation/目录说明.txt b/yudao-ui-admin-vue3/src/views/health/family-relation/目录说明.txt new file mode 100644 index 0000000..d7fa941 --- /dev/null +++ b/yudao-ui-admin-vue3/src/views/health/family-relation/目录说明.txt @@ -0,0 +1 @@ +家属与老人关联管理:新增申请、管理员核实确认、拒绝、解除和重新申请。 diff --git a/yudao-ui-admin-vue3/src/views/health/identity/IdentityForm.vue b/yudao-ui-admin-vue3/src/views/health/identity/IdentityForm.vue new file mode 100644 index 0000000..8be4f7d --- /dev/null +++ b/yudao-ui-admin-vue3/src/views/health/identity/IdentityForm.vue @@ -0,0 +1,211 @@ + + diff --git a/yudao-ui-admin-vue3/src/views/health/identity/index.vue b/yudao-ui-admin-vue3/src/views/health/identity/index.vue new file mode 100644 index 0000000..215901c --- /dev/null +++ b/yudao-ui-admin-vue3/src/views/health/identity/index.vue @@ -0,0 +1,213 @@ + + diff --git a/yudao-ui-admin-vue3/src/views/health/identity/目录说明.txt b/yudao-ui-admin-vue3/src/views/health/identity/目录说明.txt new file mode 100644 index 0000000..6481ee1 --- /dev/null +++ b/yudao-ui-admin-vue3/src/views/health/identity/目录说明.txt @@ -0,0 +1 @@ +老人、志愿者、家属的身份登记与档案维护,志愿者审核,以及身份启用停用。 diff --git a/yudao-ui-admin-vue3/src/views/health/目录说明.txt b/yudao-ui-admin-vue3/src/views/health/目录说明.txt new file mode 100644 index 0000000..83a50bf --- /dev/null +++ b/yudao-ui-admin-vue3/src/views/health/目录说明.txt @@ -0,0 +1 @@ +健康服务管理页面。身份与档案、家属关联由后台菜单加载;业务权限仍由后端校验。