From 70a7539cb3e16c84b13f0a2f138f9cda89b0dca3 Mon Sep 17 00:00:00 2001
From: "AXR\\Administrator" <1106996101@qq.com>
Date: Tue, 6 Oct 2026 23:03:29 +0800
Subject: [PATCH] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E5=81=A5=E5=BA=B7?=
=?UTF-8?q?=E4=B8=9A=E5=8A=A1=E6=A8=A1=E5=9D=97=E4=B8=8E=E8=BA=AB=E4=BB=BD?=
=?UTF-8?q?=E6=A8=A1=E5=9E=8B?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
deploy/local/initialize-health.ps1 | 29 ++
docs/健康业务模块与身份模型.md | 182 ++++++++
docs/老年健康系统扩展需求与设计草案.md | 435 ++++++++++++++++++
mysql_script/health/V000__member_schema.sql | 201 ++++++++
mysql_script/health/V001__health_identity.sql | 89 ++++
mysql_script/health/V002__health_menu.sql | 38 ++
mysql_script/health/目录说明.txt | 1 +
yudao-module-health/pom.xml | 13 +
.../yudao-module-health-api/pom.xml | 11 +
.../api/identity/HealthIdentityApi.java | 11 +
.../identity/dto/HealthIdentityRespDTO.java | 14 +
.../yudao/module/health/api/目录说明.txt | 3 +
.../health/enums/ErrorCodeConstants.java | 22 +
.../health/enums/HealthAuditStatusEnum.java | 15 +
.../health/enums/HealthIdentityTypeEnum.java | 18 +
.../yudao/module/health/enums/目录说明.txt | 3 +
.../yudao-module-health-api/目录说明.txt | 3 +
.../yudao-module-health-biz/pom.xml | 18 +
.../api/identity/HealthIdentityApiImpl.java | 24 +
.../yudao/module/health/api/目录说明.txt | 3 +
.../identity/HealthIdentityController.java | 67 +++
.../identity/vo/HealthIdentityAuditReqVO.java | 15 +
.../identity/vo/HealthIdentityPageReqVO.java | 22 +
.../identity/vo/HealthIdentitySaveReqVO.java | 26 ++
.../vo/HealthIdentityStatusReqVO.java | 15 +
.../HealthFamilyRelationController.java | 49 ++
.../vo/HealthFamilyRelationPageReqVO.java | 16 +
.../identity/AppHealthIdentityController.java | 51 ++
.../vo/HealthFamilyRelationCreateReqVO.java | 16 +
.../vo/HealthFamilyRelationRespVO.java | 26 ++
.../vo/HealthFamilyRelationReviewReqVO.java | 15 +
.../vo/HealthFamilyRelationRevokeReqVO.java | 13 +
.../controller/vo/HealthIdentityRespVO.java | 23 +
.../controller/vo/HealthProfileFieldsVO.java | 42 ++
.../module/health/controller/目录说明.txt | 3 +
.../dataobject/identity/HealthIdentityDO.java | 32 ++
.../profile/HealthElderlyProfileDO.java | 30 ++
.../profile/HealthVolunteerProfileDO.java | 26 ++
.../relation/HealthFamilyRelationDO.java | 29 ++
.../module/health/dal/dataobject/目录说明.txt | 3 +
.../mysql/identity/HealthIdentityMapper.java | 25 +
.../profile/HealthElderlyProfileMapper.java | 13 +
.../profile/HealthVolunteerProfileMapper.java | 13 +
.../relation/HealthFamilyRelationMapper.java | 24 +
.../module/health/dal/mysql/目录说明.txt | 3 +
.../yudao/module/health/dal/目录说明.txt | 3 +
.../identity/HealthIdentityService.java | 21 +
.../identity/HealthIdentityServiceImpl.java | 225 +++++++++
.../relation/HealthFamilyRelationService.java | 18 +
.../HealthFamilyRelationServiceImpl.java | 172 +++++++
.../yudao/module/health/service/目录说明.txt | 3 +
.../service/HealthIdentityServiceTest.java | 193 ++++++++
.../test/resources/application-unit-test.yaml | 22 +
.../src/test/resources/sql/clean.sql | 5 +
.../src/test/resources/sql/create_tables.sql | 116 +++++
.../src/test/目录说明.txt | 3 +
.../yudao-module-health-biz/目录说明.txt | 3 +
yudao-module-health/目录说明.txt | 3 +
.../api/user/dto/MemberUserCreateReqDTO.java | 18 +
yudao-ui-admin-vue3/src/api/health/index.ts | 80 ++++
.../src/api/health/目录说明.txt | 1 +
.../views/health/family-relation/index.vue | 262 +++++++++++
.../views/health/family-relation/目录说明.txt | 1 +
.../views/health/identity/IdentityForm.vue | 211 +++++++++
.../src/views/health/identity/index.vue | 213 +++++++++
.../src/views/health/identity/目录说明.txt | 1 +
.../src/views/health/目录说明.txt | 1 +
67 files changed, 3305 insertions(+)
create mode 100644 deploy/local/initialize-health.ps1
create mode 100644 docs/健康业务模块与身份模型.md
create mode 100644 docs/老年健康系统扩展需求与设计草案.md
create mode 100644 mysql_script/health/V000__member_schema.sql
create mode 100644 mysql_script/health/V001__health_identity.sql
create mode 100644 mysql_script/health/V002__health_menu.sql
create mode 100644 mysql_script/health/目录说明.txt
create mode 100644 yudao-module-health/pom.xml
create mode 100644 yudao-module-health/yudao-module-health-api/pom.xml
create mode 100644 yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/identity/HealthIdentityApi.java
create mode 100644 yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/identity/dto/HealthIdentityRespDTO.java
create mode 100644 yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/api/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/ErrorCodeConstants.java
create mode 100644 yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/HealthAuditStatusEnum.java
create mode 100644 yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/HealthIdentityTypeEnum.java
create mode 100644 yudao-module-health/yudao-module-health-api/src/main/java/cn/iocoder/yudao/module/health/enums/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-api/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-biz/pom.xml
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/api/identity/HealthIdentityApiImpl.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/api/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/HealthIdentityController.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityAuditReqVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityPageReqVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentitySaveReqVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/identity/vo/HealthIdentityStatusReqVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/relation/HealthFamilyRelationController.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/admin/relation/vo/HealthFamilyRelationPageReqVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/app/identity/AppHealthIdentityController.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationCreateReqVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationRespVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationReviewReqVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthFamilyRelationRevokeReqVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthIdentityRespVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/vo/HealthProfileFieldsVO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/controller/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/identity/HealthIdentityDO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/profile/HealthElderlyProfileDO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/profile/HealthVolunteerProfileDO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/relation/HealthFamilyRelationDO.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/dataobject/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/identity/HealthIdentityMapper.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/profile/HealthElderlyProfileMapper.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/profile/HealthVolunteerProfileMapper.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/relation/HealthFamilyRelationMapper.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/mysql/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/dal/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/identity/HealthIdentityService.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/identity/HealthIdentityServiceImpl.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/relation/HealthFamilyRelationService.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/relation/HealthFamilyRelationServiceImpl.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/main/java/cn/iocoder/yudao/module/health/service/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-biz/src/test/java/cn/iocoder/yudao/module/health/service/HealthIdentityServiceTest.java
create mode 100644 yudao-module-health/yudao-module-health-biz/src/test/resources/application-unit-test.yaml
create mode 100644 yudao-module-health/yudao-module-health-biz/src/test/resources/sql/clean.sql
create mode 100644 yudao-module-health/yudao-module-health-biz/src/test/resources/sql/create_tables.sql
create mode 100644 yudao-module-health/yudao-module-health-biz/src/test/目录说明.txt
create mode 100644 yudao-module-health/yudao-module-health-biz/目录说明.txt
create mode 100644 yudao-module-health/目录说明.txt
create mode 100644 yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/dto/MemberUserCreateReqDTO.java
create mode 100644 yudao-ui-admin-vue3/src/api/health/index.ts
create mode 100644 yudao-ui-admin-vue3/src/api/health/目录说明.txt
create mode 100644 yudao-ui-admin-vue3/src/views/health/family-relation/index.vue
create mode 100644 yudao-ui-admin-vue3/src/views/health/family-relation/目录说明.txt
create mode 100644 yudao-ui-admin-vue3/src/views/health/identity/IdentityForm.vue
create mode 100644 yudao-ui-admin-vue3/src/views/health/identity/index.vue
create mode 100644 yudao-ui-admin-vue3/src/views/health/identity/目录说明.txt
create mode 100644 yudao-ui-admin-vue3/src/views/health/目录说明.txt
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 @@
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 新增关联
+
+
+
+
+
+
+
+
+ {{
+ states[row.status]
+ }}
+
+
+
+
+
+ 核实确认
+ 拒绝
+ 解除
+ 重新申请
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 查询
+ 重置
+ 新增档案
+
+
+
+
+
+
+
+
+
+ {{
+ Api.identityTypes.find((item) => item.value === row.identityType)?.label
+ }}
+
+
+
+ {{
+ row.elderlyProfileId || row.volunteerProfileId || '—'
+ }}
+
+
+ {{
+ row.status === 0 ? '启用' : '停用'
+ }}
+
+
+ {{ Api.auditStates.find((item) => item.value === row.auditStatus)?.label }}
+
+
+
+ 详情
+ 编辑
+ 通过
+ 驳回
+ 重新提审
+ {{ row.status === 0 ? '停用' : '启用' }}
+
+
+
+
+
+
+
+
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 @@
+健康服务管理页面。身份与档案、家属关联由后台菜单加载;业务权限仍由后端校验。