doc/design/用户端_个人中心_20261013.md(入口所在页)192.168.110.111:3306/hugray-account(实际表结构优先于本文档)会员中心「收货地址」当前为**半成品**:列表页用本地 mock 数据、编辑按钮弹「敬请期待」、新增页提交仅本地 toast 后返回,后端只有**管理后台侧**的改/删两个接口。
本期只做**小程序(C 端)**闭环:
AccountProfileController,会员身份取自登录态;pages/address/list.vue):去掉 mock,进入/返回时实时拉取;pages/address/add.vue):onLoad 读 id,有 id 则回填并切换为「编辑地址 / 保存」,无 id 则为「新增地址 / 提交」;AccountUserController 的 PUT/DELETE /address、selectAccountUserDetail 的地址展示**保持原样**,仅保证不被破坏。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,是早期建表遗留,**本期不动**(改主键策略影响面大,超出本次范围)。
AccountProfileController@RequestMapping("/user"),外部实际路径 /miniapp/account/user/**(网关 StripPrefix=2),路由已存在,**无需改网关**。SecurityUtils.getUserId(),**C 端不接受前端传 userId**(天然防水平越权);未登录由网关 401 拦截。/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 不同) |
否 |
address 的格式约定(关键)address 是 varchar(255) 单字段,**没有独立的省/市/区列**。本期不改表,采用**空格分隔约定**:
address = [省, 市, 区, 详细地址].join(' ') // 例:四川省 成都市 武侯区 府城大道399号天府新谷9号楼
region(picker 固定返回三级)与 detail 用**单个空格**拼接。address.split(' '),前 3 段为省/市/区,其余段用空格 rejoin 还原为详细地址。MySQL 无部分唯一索引,约束由**应用层在同一事务内**保证:置默认前先 clearDefaultByUserId,再写目标行。既有 updateAccountAddress 已实现,C 端编辑**直接复用**。新增同样需要此逻辑。
updateAddress 的 where 自带 user_id = #{userId},只要 Service 用登录态 userId,改别人的 id 会更新 0 行 → 既有逻辑抛异常回滚,**天然安全**。deleteAddressByIds 仅按 id 逻辑删除、**不带 user_id**,C 端**不可直接复用**(会误删他人地址)。需先按 selectAddressById 查归属再删。后台 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 的查询口径与字段,后台完全不受影响。
收货地址是**本人数据**,且编辑回填必须拿到明文手机号,故 C 端列表/详情**下发明文**(不再脱敏),列表页也**展示明文**(与后台详情页一致)。这是相对 mock「185****0110」的有意调整。
后台地址弹窗的注释明确「收货电话可能是固话,写死 11 位手机号正则会挡住合法数据」。故 后端不强制手机号正则,只校验非空 + 长度(≤20);前端保留现有 /^1[3-9]\d{9}$/(原型设计),后端更宽松,不产生矛盾。
统一响应体:C 端 R<T>(成功 code=200,数据在 data)。
GET /miniapp/account/user/addresses(→ 服务内 GET /user/addresses)| 字段 | 类型 | 说明 |
|---|---|---|
| id | Long | 地址ID |
| consignee | String | 收货人 |
| consigneePhone | String | 收货人电话(明文) |
| address | String | 完整地址(空格分隔:省 市 区 详细) |
| isDefault | String | 是否默认(0否 / 1是) |
id 升序(复用现有 SQL)。GET /miniapp/account/user/addresses/{id}(→ 服务内 GET /user/addresses/{id})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):isDefault 为空按 0 处理,后端**不做**「首条自动默认」的强制改写(该会员可能存在零条默认地址);clearDefaultByUserId 再插入(同事务,保证唯一默认)。R<Void>。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<Void>。DELETE /miniapp/account/user/addresses/{id}(→ 服务内 DELETE /user/addresses/{id})del_flag='2')。R<Void>。本期无 DDL 变更,不改表结构、不改后台查询口径。仅在 Mapper 增加一个查询方法:
AccountAddress selectAddressById(Long id):select ... from account_address where id = #{id} and del_flag = '0'(显式列名,禁止 SELECT *)。getAddressDetail 与 deleteMyAddress 复用它做**归属校验**(DRY)。deleteAddressByIds(批量逻辑删除)。| 文件 | 改动 |
|---|---|
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 方法签名(草案):
/** C端:查询本人收货地址列表(默认在前,不分页) */
List<AccountAddress> getAddressList(Long userId);
/** C端:查询单条地址(校验归属,非本人抛异常)——供编辑页回填 */
AccountAddress getAddressDetail(Long userId, Long id);
/** C端:新增地址(事务内处理「首条自动默认」与默认唯一约束) */
int insertMyAddress(AccountAddress accountAddress);
/** C端:删除地址(校验归属后逻辑删除) */
int deleteMyAddress(Long userId, Long id);
pages/address/list.vueMOCK_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 的「选择地址」保持不变(不在本次范围内)。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 });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) |
isDefault 落库为 0(不再强制首条为默认);勾选默认时原默认被顶掉(同会员仅一条默认);id 调编辑/详情/删除 → 统一抛「收货地址不存在或不属于该会员」,不产生写入;consignee>64、address>255、phone>20)被 @Validated 拦下;uni.showModal 二次确认后删除)。后端 C 端删除接口(3.5)按此实现。province/city/district/detail 列。AccountUserController 与后台页面均不改,仅保证不被破坏)。isDefault 落库为 0,允许会员没有任何默认地址。