# 用户端小程序 收货地址 开发设计文档 - 模块:ruoyi-user(会员收货地址)+ hugray-user-miniapp(前端) - 日期:2026-10-14 - 需求来源:"完善收货地址的后端功能,编辑地址的时候也要跳转到编辑页面,复用新增页面,注意管理后台的用户管理详情里面也涉及到收货地址展示,但是你只需要实现小程序侧的功能,只需要考虑到管理后台的问题" - 关联文档:`doc/design/用户端_个人中心_20261013.md`(入口所在页) - 数据权威源:`192.168.110.111:3306/hugray-account`(实际表结构优先于本文档) --- ## 一、功能概述 会员中心「收货地址」当前为**半成品**:列表页用本地 mock 数据、编辑按钮弹「敬请期待」、新增页提交仅本地 toast 后返回,后端只有**管理后台侧**的改/删两个接口。 本期只做**小程序(C 端)**闭环: 1. **后端补齐 C 端收货地址接口**(列表 / 详情 / 新增 / 编辑 / 删除),落既有 `AccountProfileController`,会员身份取自登录态; 2. **列表页接后端**(`pages/address/list.vue`):去掉 mock,进入/返回时实时拉取; 3. **编辑跳转编辑页并复用新增页**(`pages/address/add.vue`):`onLoad` 读 `id`,有 id 则回填并切换为「编辑地址 / 保存」,无 id 则为「新增地址 / 提交」; 4. **管理后台零改动**:`AccountUserController` 的 `PUT/DELETE /address`、`selectAccountUserDetail` 的地址展示**保持原样**,仅保证不被破坏。 --- ## 二、现状与关键约束 ### 2.1 数据库与 Mapper 已齐备,**本期无 DDL** `account_address` 表与 `AccountAddress` 实体、`AccountAddressMapper`(接口 + XML)**已全部具备**: | 列 | 类型 | 说明 | | --- | --- | --- | | `id` | bigint | 主键(自增,见下方注意事项) | | `user_id` | bigint | 会员ID,索引 `idx_address_user_id` | | `consignee` | varchar(64) | 收货人 | | `consignee_phone` | varchar(20) | 收货人电话 | | `address` | varchar(255) | **单字段**存完整地址(无独立省/市/区列) | | `is_default` | char(1) | 是否默认(0否 1是) | | `del_flag` | char(1) | 逻辑删除(0存在 2删除) | 现成 SQL:`selectAddressByUserId`(`del_flag='0'` 过滤 + 默认在前)、`insertAddress`、`updateAddress`(`where` 带 `user_id`)、`deleteAddressByIds`(批量逻辑删除)、`clearDefaultByUserId`。 > 说明:表主键为 `AUTO_INCREMENT`,是早期建表遗留,**本期不动**(改主键策略影响面大,超出本次范围)。 ### 2.2 C 端落点:`AccountProfileController` - `@RequestMapping("/user")`,外部实际路径 `/miniapp/account/user/**`(网关 `StripPrefix=2`),路由已存在,**无需改网关**。 - 会员身份取 `SecurityUtils.getUserId()`,**C 端不接受前端传 userId**(天然防水平越权);未登录由网关 401 拦截。 ### 2.3 路径必须避开 `/user/address`(关键约束) `AccountProfileController` 与后台的 `AccountUserController` **都是 `@RequestMapping("/user")`**,且同在一个服务内。后台已有: - `PUT /user/address`(改) - `DELETE /user/address/{ids}`(删) 若 C 端沿用 `/user/address`,Spring 会因 **Ambiguous mapping 启动失败**。故 C 端统一用**复数 `/user/addresses`**: | C 端接口(服务内) | 后台既有(服务内) | 是否冲突 | | --- | --- | --- | | `GET /user/addresses` | 无 | 否 | | `GET /user/addresses/{id}` | `GET /user/detail/{userId}`(literal 不同) | 否 | | `POST /user/addresses` | `POST /user`、`POST /user/export`(段数/字面量不同) | 否 | | `PUT /user/addresses` | `PUT /user/address`(literal 不同) | 否 | | `DELETE /user/addresses/{id}` | `DELETE /user/address/{ids}`(literal 不同) | 否 | ### 2.4 单字段 `address` 的格式约定(关键) `address` 是 varchar(255) 单字段,**没有独立的省/市/区列**。本期不改表,采用**空格分隔约定**: ``` address = [省, 市, 区, 详细地址].join(' ') // 例:四川省 成都市 武侯区 府城大道399号天府新谷9号楼 ``` - **写入**:前端把 `region`(picker 固定返回三级)与 `detail` 用**单个空格**拼接。 - **回填**:`address.split(' ')`,前 3 段为省/市/区,其余段用空格 rejoin 还原为详细地址。 - **兜底**:段数 < 4(如后台手工录入的非约定格式)时,地区留空、整串填入详细地址,由用户重新选择地区(不报错)。 ### 2.5 「每会员至多一条默认地址」 MySQL 无部分唯一索引,约束由**应用层在同一事务内**保证:置默认前先 `clearDefaultByUserId`,再写目标行。既有 `updateAccountAddress` 已实现,C 端编辑**直接复用**。新增同样需要此逻辑。 ### 2.6 归属校验(防越权) - **编辑**:`updateAddress` 的 `where` 自带 `user_id = #{userId}`,只要 Service 用登录态 userId,改别人的 id 会更新 0 行 → 既有逻辑抛异常回滚,**天然安全**。 - **删除**:既有 `deleteAddressByIds` 仅按 `id` 逻辑删除、**不带 `user_id`**,C 端**不可直接复用**(会误删他人地址)。需先按 `selectAddressById` 查归属再删。 - **详情**:同样需按归属校验。 ### 2.7 管理后台兼容(不得破坏) 后台 `hugray-admin/src/views/member/user/detail.vue` 的「收货地址」Tab 依赖 `detail.addresses` 的字段:`id / userId / consignee / consigneePhone / address / isDefault`,并调用后台 `PUT /user/address`、`DELETE /user/address/{ids}`。 **本期不修改 `AccountUserController`,也不修改 `selectAddressByUserId` 的查询口径与字段**,后台完全不受影响。 ### 2.8 手机号展示 收货地址是**本人数据**,且编辑回填必须拿到明文手机号,故 C 端列表/详情**下发明文**(不再脱敏),列表页也**展示明文**(与后台详情页一致)。这是相对 mock「`185****0110`」的有意调整。 ### 2.9 电话校验口径 后台地址弹窗的注释明确「收货电话可能是固话,写死 11 位手机号正则会挡住合法数据」。故 **后端不强制手机号正则**,只校验非空 + 长度(≤20);前端保留现有 `/^1[3-9]\d{9}$/`(原型设计),后端更宽松,不产生矛盾。 --- ## 三、接口设计 统一响应体:C 端 `R`(成功 `code=200`,数据在 `data`)。 ### 3.1 查询本人收货地址列表(本次新增 · ruoyi-user) - **外部路径**:`GET /miniapp/account/user/addresses`(→ 服务内 `GET /user/addresses`) - **鉴权**:需登录;会员身份取自登录态。 - **入参**:无(地址数量少,**不分页**)。 - **出参**: | 字段 | 类型 | 说明 | | --- | --- | --- | | id | Long | 地址ID | | consignee | String | 收货人 | | consigneePhone | String | 收货人电话(明文) | | address | String | 完整地址(空格分隔:省 市 区 详细) | | isDefault | String | 是否默认(`0`否 / `1`是) | - **排序**:默认地址在前,其余按 `id` 升序(复用现有 SQL)。 ### 3.2 查询单条收货地址详情(本次新增 · ruoyi-user) - **外部路径**:`GET /miniapp/account/user/addresses/{id}`(→ 服务内 `GET /user/addresses/{id}`) - **用途**:编辑页回填(列表页不再传递长文本参数)。 - **鉴权**:需登录;**校验归属**,非本人地址抛业务异常「收货地址不存在或不属于该会员」。 - **出参**:同 3.1 单条对象。 ### 3.3 新增收货地址(本次新增 · ruoyi-user) - **外部路径**:`POST /miniapp/account/user/addresses`(→ 服务内 `POST /user/addresses`) - **鉴权**:需登录;`userId` 由登录态注入(前端**不传** userId)。 - **入参** `AccountAddressDto`: | 字段 | 类型 | 必填 | 校验 | 说明 | | --- | --- | --- | --- | --- | | consignee | String | 是 | `@NotBlank` + `@Size(max=64)` | 收货人 | | consigneePhone | String | 是 | `@NotBlank` + `@Size(max=20)` | 收货人电话(不强制手机号) | | address | String | 是 | `@NotBlank` + `@Size(max=255)` | 完整地址 | | isDefault | String | 否 | 值域 `0`/`1`,默认 `0` | 是否默认 | - **业务规则**(`@Transactional`): 1. **是否默认完全以用户勾选为准**:`isDefault` 为空按 `0` 处理,后端**不做**「首条自动默认」的强制改写(该会员可能存在零条默认地址); 2. 若为默认,先 `clearDefaultByUserId` 再插入(同事务,保证唯一默认)。 - **出参**:`R`。 ### 3.4 编辑收货地址(本次新增 · ruoyi-user) - **外部路径**:`PUT /miniapp/account/user/addresses`(→ 服务内 `PUT /user/addresses`) - **鉴权**:需登录;`userId` 由登录态注入。 - **入参** `AccountAddressDto`(多一个必填 `id`): | 字段 | 类型 | 必填 | 校验 | 说明 | | --- | --- | --- | --- | --- | | id | Long | 是 | `@NotNull` | 地址ID | | consignee | String | 是 | `@NotBlank` + `@Size(max=64)` | 收货人 | | consigneePhone | String | 是 | `@NotBlank` + `@Size(max=20)` | 收货人电话 | | address | String | 是 | `@NotBlank` + `@Size(max=255)` | 完整地址 | | isDefault | String | 否 | 值域 `0`/`1` | 是否默认 | - **业务规则**:复用既有 `updateAccountAddress`(事务内先清旧默认再更新;`where` 带 `user_id`,非本人更新 0 行抛异常回滚)。 - **出参**:`R`。 ### 3.5 删除收货地址(本次新增 · ruoyi-user) - **外部路径**:`DELETE /miniapp/account/user/addresses/{id}`(→ 服务内 `DELETE /user/addresses/{id}`) - **鉴权**:需登录;**校验归属**后逻辑删除(`del_flag='2'`)。 - **出参**:`R`。 --- ## 四、数据库设计 **本期无 DDL 变更**,不改表结构、不改后台查询口径。仅在 Mapper 增加一个查询方法: - `AccountAddress selectAddressById(Long id)`:`select ... from account_address where id = #{id} and del_flag = '0'`(显式列名,禁止 `SELECT *`)。 - 服务层 `getAddressDetail` 与 `deleteMyAddress` 复用它做**归属校验**(DRY)。 - 删除沿用既有 `deleteAddressByIds`(批量逻辑删除)。 --- ## 五、后端改动清单(ruoyi-user) | 文件 | 改动 | | --- | --- | | `domain/dto/AccountAddressDto.java` | **新增**:C 端收货地址入参(id/consignee/consigneePhone/address/isDefault + 校验注解) | | `domain/vo/AccountAddressVo.java` | **新增**:C 端收货地址出参(id/consignee/consigneePhone/address/isDefault) | | `mapper/AccountAddressMapper.java` | **新增** `selectAddressById(Long id)` | | `resources/mapper/user/AccountAddressMapper.xml` | **新增** `selectAddressById` 语句 | | `service/IAccountUserService.java` | **新增** 4 个方法:`getAddressList` / `getAddressDetail` / `insertMyAddress` / `deleteMyAddress`(编辑复用既有 `updateAccountAddress`) | | `service/impl/AccountUserServiceImpl.java` | **实现** 上述 4 个方法(`insertMyAddress` 带 `@Transactional`;新增/删除做归属与默认约束处理) | | `controller/AccountProfileController.java` | **新增** 5 个 C 端接口(3.1~3.5),身份取 `SecurityUtils.getUserId()` | | `controller/AccountUserController.java` | **不改动**(后台兼容) | Service 方法签名(草案): ```java /** C端:查询本人收货地址列表(默认在前,不分页) */ List getAddressList(Long userId); /** C端:查询单条地址(校验归属,非本人抛异常)——供编辑页回填 */ AccountAddress getAddressDetail(Long userId, Long id); /** C端:新增地址(事务内处理「首条自动默认」与默认唯一约束) */ int insertMyAddress(AccountAddress accountAddress); /** C端:删除地址(校验归属后逻辑删除) */ int deleteMyAddress(Long userId, Long id); ``` --- ## 六、前端页面设计 ### 6.1 列表页 `pages/address/list.vue` - **去 mock**:删除 `MOCK_ADDRESS_LIST`,改为 `onShow` 调用 `getAddressList()` 拉取(用 `onShow` 保证新增/编辑返回后自动刷新)。 - **字段映射**:`item.consignee → 收货人`、`item.consigneePhone → 电话(明文)`、`item.address → 详细地址`、`item.isDefault === '1' → 「默认」标签`。 - **编辑入口**:`handleEdit(item)` → `uni.navigateTo({ url: '/pages/address/add?id=' + item.id })`。 - **删除入口(已确认)**:**列表页每条记录增加删除图标**。点击后 `uni.showModal` 二次确认,确认调 `deleteAddress(item.id)`,成功后 toast + 重新拉取列表;删除当前默认地址后按后端返回的最新列表状态渲染。 - **空态 / 底部新增按钮**:样式不变。 - **导航标题**:由 `/pages/address/list` 的「选择地址」保持不变(不在本次范围内)。 ### 6.2 新增/编辑页 `pages/address/add.vue`(复用双模式) - **`onLoad(options)`**:读取 `options.id`;有 id → 编辑模式(`isEdit = true`),调 `getAddressDetail(id)` 回填表单,`uni.setNavigationBarTitle({ title: '编辑地址' })`;无 id → 新增模式(标题「新增地址」)。 - **回填映射**: - `form.name = vo.consignee`,`form.phone = vo.consigneePhone`,`form.isDefault = vo.isDefault === '1'`; - `form.address` 按 **2.4** 拆分:`tokens = address.split(' ')`;`tokens.length >= 4` 时 `form.region = tokens.slice(0,3)`、`form.detail = tokens.slice(3).join(' ')`;否则 `form.region = []`、`form.detail = address`。 - **提交**: - `address = [...form.region, form.detail.trim()].join(' ')`; - 新增调 `createAddress({ consignee, consigneePhone, address, isDefault })`; - 编辑调 `updateAddress({ id, consignee, consigneePhone, address, isDefault })`; - 成功 toast 后 `navigateBack()`(列表页 `onShow` 自动刷新)。 - **文案**:新增模式按钮「提交」、导航「新增地址」;编辑模式按钮「保存」、导航「编辑地址」。 - **校验**:保留现有 `_validateForm()`(姓名/手机号/地区/详细地址 + 手机号正则)。 --- ## 七、前端改动清单 | 文件 | 改动 | | --- | --- | | `pages/address/list.vue` | 去 mock 接 `getAddressList()`;改为 `onShow` 刷新;`handleEdit` 跳转带 `id`;**每条增加删除图标 + 二次确认删除** | | `pages/address/add.vue` | 补 `onLoad(options)` 双模式;新增/编辑提交分别调接口;文案切换;地址拼接与拆分 | | `api/index.js` | import 补 `put`、`del`;**新增** `getAddressList` / `getAddressDetail(id)` / `createAddress(data)` / `updateAddress(data)` / `deleteAddress(id)` | | `pages.json` | **不改动**(复用 `pages/address/add`) | | `pages/profile/index.vue` | **不改动**(入口已指向 `/pages/address/list`) | --- ## 八、影响范围与测试要点 - **影响面**:新增 C 端接口与前端联调,**不改表结构**、**不改后台侧接口与页面**;列表页由 mock 切真实数据。 - **测试要点**: 1. 列表:默认地址置顶、明文手机号、空态、返回后刷新; 2. 新增:未勾选默认时 `isDefault` 落库为 `0`(不再强制首条为默认);勾选默认时原默认被顶掉(同会员仅一条默认); 3. 编辑:回填正确(省市区与详细地址正确拆分)、保存后列表即时更新; 4. 删除(列表页删除图标 + 二次确认):仅能删自己的地址;删默认地址后列表状态正确; 5. 越权:构造他人 `id` 调编辑/详情/删除 → 统一抛「收货地址不存在或不属于该会员」,不产生写入; 6. 校验:空值 / 超长(`consignee>64`、`address>255`、`phone>20`)被 `@Validated` 拦下; 7. **后台回归**:会员详情「收货地址」Tab 展示与改/删仍正常。 --- ## 九、已确认决策 1. **列表页删除入口**:**已确认采用「列表页每条加删除图标」**(点击弹 `uni.showModal` 二次确认后删除)。后端 C 端删除接口(3.5)按此实现。 2. **地址格式约定**:**已确认沿用单字段空格分隔**(2.4 约定),本期**不改表**,不加 `province/city/district/detail` 列。 3. **管理后台**:**已确认零改动**(`AccountUserController` 与后台页面均不改,仅保证不被破坏)。 4. **列表分页**:本期按不分页实现(地址数量少)。 5. **电话校验**:后端仅校验非空 + 长度(允许固话),前端保留手机号正则。 6. **默认地址取值(2026-10-14 修订)**:取消原「首条地址自动设为默认」,**完全以用户勾选为准**;未勾选时 `isDefault` 落库为 `0`,允许会员没有任何默认地址。