doc/design/用户端_收货地址_20261014.md
New file @@ -0,0 +1,276 @@ # 用户端小程序 收货地址 开发设计文档 - 模块: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<T>`(成功 `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<Void>`。 ### 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<Void>`。 ### 3.5 删除收货地址(本次新增 · ruoyi-user) - **外部路径**:`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`(批量逻辑删除)。 --- ## 五、后端改动清单(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<AccountAddress> 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`,允许会员没有任何默认地址。 hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/controller/AccountProfileController.java
@@ -1,20 +1,28 @@ package com.ruoyi.user.controller; import java.util.ArrayList; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import com.ruoyi.common.core.domain.R; import com.ruoyi.common.core.exception.ServiceException; import com.ruoyi.common.core.web.controller.BaseController; import com.ruoyi.common.core.web.page.TableDataInfo; import com.ruoyi.common.security.utils.SecurityUtils; import com.ruoyi.user.domain.AccountAddress; import com.ruoyi.user.domain.dto.AccountAddressDto; import com.ruoyi.user.domain.dto.AccountCertificationApplyDto; import com.ruoyi.user.domain.dto.AccountUserAvatarDto; import com.ruoyi.user.domain.dto.CompanyCertificationApplyDto; import com.ruoyi.user.domain.vo.AccountAddressVo; import com.ruoyi.user.domain.vo.AccountCertificationStatusVo; import com.ruoyi.user.domain.vo.AccountInviteeVo; import com.ruoyi.user.domain.vo.AccountReferrerVo; @@ -181,4 +189,126 @@ { return R.ok(accountUserService.getReferrer(SecurityUtils.getUserId())); } /** * 查询本人收货地址列表 * * 默认地址排前,其余按 id 升序;地址数量少,不分页。 * 会员身份取自登录态,不接受前端传入 userId。 * * @return 地址列表(无地址返回空集合) */ @GetMapping("/addresses") public R<List<AccountAddressVo>> addressList() { List<AccountAddress> list = accountUserService.getAddressList(SecurityUtils.getUserId()); return R.ok(toAddressVoList(list)); } /** * 查询本人单条收货地址详情 * * 供编辑页回填;校验归属,非本人地址抛业务异常。 * 会员身份取自登录态,不接受前端传入 userId。 * * @param id 地址ID * @return 地址详情 */ @GetMapping("/addresses/{id}") public R<AccountAddressVo> addressDetail(@PathVariable("id") Long id) { AccountAddress address = accountUserService.getAddressDetail(SecurityUtils.getUserId(), id); return R.ok(toAddressVo(address)); } /** * 新增本人收货地址 * * 首条地址自动设为默认;会员身份取自登录态,不接受前端传入 userId。 * * @param dto 地址入参 * @return 操作结果 */ @PostMapping("/addresses") public R<Void> createAddress(@Validated @RequestBody AccountAddressDto dto) { accountUserService.insertMyAddress(buildAddress(dto, SecurityUtils.getUserId())); return R.ok(); } /** * 编辑本人收货地址 * * 复用后台改地址事务逻辑(含默认唯一约束);会员身份取自登录态。 * * @param dto 地址入参(必含 id) * @return 操作结果 */ @PutMapping("/addresses") public R<Void> updateAddress(@Validated @RequestBody AccountAddressDto dto) { if (dto.getId() == null) { throw new ServiceException("地址ID不能为空"); } accountUserService.updateAccountAddress(buildAddress(dto, SecurityUtils.getUserId())); return R.ok(); } /** * 删除本人收货地址 * * 校验归属后逻辑删除;会员身份取自登录态,不接受前端传入 userId。 * * @param id 地址ID * @return 操作结果 */ @DeleteMapping("/addresses/{id}") public R<Void> deleteAddress(@PathVariable("id") Long id) { accountUserService.deleteMyAddress(SecurityUtils.getUserId(), id); return R.ok(); } /** * DTO 转实体(userId 由登录态注入,不接受前端传值) */ private AccountAddress buildAddress(AccountAddressDto dto, Long userId) { AccountAddress address = new AccountAddress(); address.setId(dto.getId()); address.setUserId(userId); address.setConsignee(dto.getConsignee()); address.setConsigneePhone(dto.getConsigneePhone()); address.setAddress(dto.getAddress()); address.setIsDefault(dto.getIsDefault()); return address; } /** * 实体转出参 VO(列表) */ private List<AccountAddressVo> toAddressVoList(List<AccountAddress> list) { List<AccountAddressVo> voList = new ArrayList<>(list.size()); for (AccountAddress address : list) { voList.add(toAddressVo(address)); } return voList; } /** * 实体转出参 VO(单条) */ private AccountAddressVo toAddressVo(AccountAddress address) { AccountAddressVo vo = new AccountAddressVo(); vo.setId(address.getId()); vo.setConsignee(address.getConsignee()); vo.setConsigneePhone(address.getConsigneePhone()); vo.setAddress(address.getAddress()); vo.setIsDefault(address.getIsDefault()); return vo; } } hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/dto/AccountAddressDto.java
New file @@ -0,0 +1,88 @@ package com.ruoyi.user.domain.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; /** * C端会员 收货地址 新增/编辑入参 * * 会员身份取自登录态(网关注入的 user_id 头),不接受前端传入 userId,天然防水平越权。 * 编辑场景需携带 id(由 Controller 人工校验非空);新增场景 id 为空。 * * address 为单字段完整地址,约定格式:省 市 区 详细地址(空格分隔),详见设计文档。 * * @author hugray */ public class AccountAddressDto { /** 地址ID(新增时为空;编辑时必填,由 Controller 校验) */ private Long id; /** 收货人;必填,长度不超过 64(对应 account_address.consignee varchar(64)) */ @NotBlank(message = "收货人不能为空") @Size(max = 64, message = "收货人姓名过长") private String consignee; /** 收货人电话;必填,长度不超过 20;不强制手机号(允许固话) */ @NotBlank(message = "收货人电话不能为空") @Size(max = 20, message = "收货人电话过长") private String consigneePhone; /** 完整地址(约定格式:省 市 区 详细地址);必填,长度不超过 255 */ @NotBlank(message = "收货地址不能为空") @Size(max = 255, message = "收货地址过长") private String address; /** 是否默认(0否 1是);非必填,为空时按 0 处理 */ private String isDefault; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getConsignee() { return consignee; } public void setConsignee(String consignee) { this.consignee = consignee; } public String getConsigneePhone() { return consigneePhone; } public void setConsigneePhone(String consigneePhone) { this.consigneePhone = consigneePhone; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } public String getIsDefault() { return isDefault; } public void setIsDefault(String isDefault) { this.isDefault = isDefault; } } hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/domain/vo/AccountAddressVo.java
New file @@ -0,0 +1,77 @@ package com.ruoyi.user.domain.vo; /** * C端会员 收货地址 VO(列表 / 详情 / 编辑回填用) * * 仅暴露页面所需字段;收货地址为本人数据,电话下发明文(编辑回填需要)。 * 会员身份由服务端从登录态解析,不接受前端传入。 * * @author hugray */ public class AccountAddressVo { /** 地址ID */ private Long id; /** 收货人 */ private String consignee; /** 收货人电话(明文,本人数据) */ private String consigneePhone; /** 完整地址(空格分隔:省 市 区 详细地址) */ private String address; /** 是否默认(0否 1是) */ private String isDefault; public Long getId() { return id; } public void setId(Long id) { this.id = id; } public String getConsignee() { return consignee; } public void setConsignee(String consignee) { this.consignee = consignee; } public String getConsigneePhone() { return consigneePhone; } public void setConsigneePhone(String consigneePhone) { this.consigneePhone = consigneePhone; } public String getAddress() { return address; } public void setAddress(String address) { this.address = address; } public String getIsDefault() { return isDefault; } public void setIsDefault(String isDefault) { this.isDefault = isDefault; } } hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/mapper/AccountAddressMapper.java
@@ -16,6 +16,13 @@ public List<AccountAddress> selectAddressByUserId(Long userId); /** * 根据主键查询单条收货地址(仅查未删除) * * C端详情与删除前的归属校验复用;查不到返回 null。 */ public AccountAddress selectAddressById(Long id); /** * 新增收货地址 */ public int insertAddress(AccountAddress accountAddress); hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/IAccountUserService.java
@@ -100,6 +100,44 @@ public int deleteAccountAddressByIds(Long[] ids); /** * C端:查询本人收货地址列表(默认地址在前,不分页) * * @param userId 会员ID(取自登录态,非前端传入) * @return 地址列表(无地址返回空集合) */ public List<AccountAddress> getAddressList(Long userId); /** * C端:查询单条收货地址(编辑页回填用) * * 校验归属:地址不存在或不属于该会员时抛业务异常。 * * @param userId 会员ID(取自登录态) * @param id 地址ID * @return 地址对象 */ public AccountAddress getAddressDetail(Long userId, Long id); /** * C端:新增本人收货地址 * * 事务内处理「首条地址自动默认」与「每会员至多一条默认」约束。 * * @param accountAddress 地址入参(userId 由服务端注入) * @return 影响行数 */ public int insertMyAddress(AccountAddress accountAddress); /** * C端:删除本人收货地址(校验归属后逻辑删除) * * @param userId 会员ID(取自登录态) * @param id 地址ID * @return 影响行数 */ public int deleteMyAddress(Long userId, Long id); /** * C端会员注册 * * 校验手机号未注册、推荐码有效后,生成唯一推荐码并落库(无密码)。 hugray-server/ruoyi-modules/ruoyi-user/src/main/java/com/ruoyi/user/service/impl/AccountUserServiceImpl.java
@@ -297,6 +297,67 @@ return accountAddressMapper.deleteAddressByIds(ids); } @Override public List<AccountAddress> getAddressList(Long userId) { return accountAddressMapper.selectAddressByUserId(userId); } @Override public AccountAddress getAddressDetail(Long userId, Long id) { // 编辑页回填:需校验归属,防止拿到他人地址 return queryOwnedAddress(userId, id); } @Override @Transactional public int insertMyAddress(AccountAddress accountAddress) { // STEP 1: 会员身份防御性校验(正常由登录态注入,必非空) if (accountAddress.getUserId() == null) { throw new ServiceException("会员身份缺失"); } // STEP 2: 是否默认完全以用户勾选为准,不做「首条自动默认」的强制改写; // 为空时明确兜底为 0,避免落库为 null 影响前端判定 if (!"1".equals(accountAddress.getIsDefault())) { accountAddress.setIsDefault("0"); } // STEP 3: 置为默认前先清掉旧默认,保证"每会员至多一条默认"(与插入同事务) if ("1".equals(accountAddress.getIsDefault())) { accountAddressMapper.clearDefaultByUserId(accountAddress.getUserId()); } // STEP 4: 落库 return accountAddressMapper.insertAddress(accountAddress); } @Override @Transactional public int deleteMyAddress(Long userId, Long id) { // 校验归属:非本人地址直接抛异常,避免越权删除(deleteAddressByIds 本身不带 user_id) queryOwnedAddress(userId, id); return accountAddressMapper.deleteAddressByIds(new Long[]{ id }); } /** * 查询地址并校验归属 * * 地址不存在或不属于该会员时统一抛业务异常(避免暴露他人地址是否存在)。 */ private AccountAddress queryOwnedAddress(Long userId, Long id) { AccountAddress address = accountAddressMapper.selectAddressById(id); if (address == null || !Objects.equals(address.getUserId(), userId)) { throw new ServiceException("收货地址不存在或不属于该会员"); } return address; } private Date resolveBindTime(Long oldId, Long newId, Date oldTime, Date now) { hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountAddressMapper.xml
@@ -32,6 +32,12 @@ order by is_default desc, id asc </select> <!-- 按主键查单条(C端详情/删除归属校验用);显式列名,避免 SELECT * --> <select id="selectAddressById" parameterType="Long" resultMap="AccountAddressResult"> <include refid="selectAddressVo"/> where id = #{id} and del_flag = '0' </select> <insert id="insertAddress" parameterType="AccountAddress" useGeneratedKeys="true" keyProperty="id"> insert into account_address( <if test="userId != null">user_id,</if> hugray-server/ruoyi-modules/ruoyi-user/src/main/resources/mapper/user/AccountUserMapper.xml
@@ -110,6 +110,16 @@ </select> <!-- 按推荐码查询会员:注册时定位推荐人 + 生成推荐码时库内查重。 命中唯一索引 uk_account_user_referral_code;del_flag = '0' 保证推荐人未被逻辑删除, 与 selectAccountUserByPhone/ByUsername 口径一致。 --> <select id="selectAccountUserByReferralCode" parameterType="String" resultMap="AccountUserResult"> <include refid="selectAccountUserVo"/> where del_flag = '0' and my_referral_code = #{referralCode} </select> <!-- 详情查询:会员本体 + 推荐人/供货人的手机号、等级、昵称。 两次自连接都走 user_id 主键,成本极低。 条件里的 del_flag = '0' 不可省:推荐人可能已被逻辑删除(造数脚本的 user_id=125 hugray-user-miniapp/api/index.js
@@ -7,7 +7,7 @@ * /miniapp/product/** -> ruoyi-product 商品/订单 * /miniapp/system/** -> ruoyi-system 系统信息 */ import { get, post, uploadFile } from '@/utils/request.js' import { get, post, put, del, uploadFile } from '@/utils/request.js' // ======================== 认证相关 ======================== @@ -199,6 +199,48 @@ return get('/miniapp/account/user/referrer') } // ======================== 收货地址 ======================== /** * 查询本人收货地址列表(默认地址在前,不分页) * 返回 data 为数组,元素含 id / consignee / consigneePhone / address / isDefault */ export function getAddressList() { return get('/miniapp/account/user/addresses') } /** * 查询单条收货地址详情(编辑页回填用) * @param {String|Number} id 地址ID */ export function getAddressDetail(id) { return get('/miniapp/account/user/addresses/' + id) } /** * 新增收货地址(会员身份由网关注入,前端不传 userId) * @param {Object} data { consignee, consigneePhone, address, isDefault } */ export function createAddress(data) { return post('/miniapp/account/user/addresses', data) } /** * 编辑收货地址(会员身份由网关注入) * @param {Object} data { id, consignee, consigneePhone, address, isDefault } */ export function updateAddress(data) { return put('/miniapp/account/user/addresses', data) } /** * 删除收货地址(后端校验归属) * @param {String|Number} id 地址ID */ export function deleteAddress(id) { return del('/miniapp/account/user/addresses/' + id) } // ======================== 订单相关 ======================== /** hugray-user-miniapp/pages/address/add.vue
@@ -72,17 +72,19 @@ <!-- 底部固定提交栏:尺寸/圆角与地址列表页「新增地址」按钮一致 --> <view class="submit-bar"> <view class="submit-btn" @tap="_handleSubmit">提交</view> <view class="submit-btn" @tap="_handleSubmit">{{ isEdit ? '保存' : '提交' }}</view> </view> </view> </template> <script setup> import { ref, reactive, computed } from 'vue' import { onLoad } from '@dcloudio/uni-app' import { getAddressDetail, createAddress, updateAddress } from '@/api/index.js' // ======================== 数据定义区域 ======================== // 新增地址表单 // 地址表单(新增/编辑共用) const form = reactive({ name: '', phone: '', @@ -97,7 +99,48 @@ // 地区文本:拼接为「省 市 区」,为空时由模板展示占位文案 const regionText = computed(() => form.region.join(' ')) // 是否编辑模式(onLoad 带 id 时为编辑,复用新增页) const isEdit = ref(false) // 编辑模式下的地址ID const addressId = ref(null) // ======================== 方法定义区域 ======================== /** 拉取地址详情并回填表单(编辑模式) */ const _loadAddressDetail = async (id) => { try { const res = await getAddressDetail(id) const data = res.data || {} form.name = data.consignee || '' form.phone = data.consigneePhone || '' form.isDefault = data.isDefault === '1' // 拆分单字段 address:约定前 3 段为省/市/区,其余段 rejoin 还原详细地址; // 段数 < 4(历史/后台手工录入的非约定格式)时地区留空、整串填入详细地址 const tokens = (data.address || '').split(' ') if (tokens.length >= 4) { form.region = tokens.slice(0, 3) form.detail = tokens.slice(3).join(' ') } else { form.region = [] form.detail = data.address || '' } regionValue.value = [...form.region] } catch (e) { console.error('[ADDRESS_DETAIL] 加载地址详情失败', e) uni.showToast({ title: e.message || '加载失败', icon: 'none' }) } } // 页面加载:带 id → 编辑模式(回填表单 + 切换标题);不带 id → 新增模式 onLoad((options) => { const id = options && options.id if (!id) return isEdit.value = true addressId.value = id uni.setNavigationBarTitle({ title: '编辑地址' }) _loadAddressDetail(id) }) /** 切换「设为默认」 */ const _handleDefaultChange = (e) => { @@ -121,16 +164,32 @@ return '' } /** 提交:校验通过后保存并返回地址列表页(地址新增接口未就绪,暂以本地成功反馈占位) */ const _handleSubmit = () => { /** 提交:校验通过后按模式调用新增/编辑接口,成功返回列表页(列表页 onShow 自动刷新) */ const _handleSubmit = async () => { const errorMsg = _validateForm() if (errorMsg) { uni.showToast({ title: errorMsg, icon: 'none' }) return } // 单字段 address 约定:省 市 区 详细地址(空格分隔) const payload = { consignee: form.name.trim(), consigneePhone: form.phone.trim(), address: [...form.region, form.detail.trim()].join(' '), isDefault: form.isDefault ? '1' : '0' } try { if (isEdit.value) { await updateAddress({ ...payload, id: addressId.value }) } else { await createAddress(payload) } uni.showToast({ title: '保存成功', icon: 'none' }) setTimeout(() => uni.navigateBack(), 600) } catch (e) { console.error('[ADDRESS_SAVE] 保存收货地址失败', e) uni.showToast({ title: e.message || '保存失败', icon: 'none' }) } } </script> hugray-user-miniapp/pages/address/list.vue
@@ -1,19 +1,23 @@ <template> <view class="page"> <!-- 有数据:渲染地址列表,右侧为编辑入口 --> <!-- 有数据:渲染地址列表,右侧为编辑/删除入口 --> <view v-if="addressList.length"> <view v-for="item in addressList" :key="item.id" class="addr-item"> <view class="addr-main"> <!-- 姓名 + 手机号 --> <view class="addr-head"> <text class="addr-name">{{ item.name }}</text> <text class="addr-phone">{{ item.phone }}</text> <text class="addr-name">{{ item.consignee }}</text> <text class="addr-phone">{{ item.consigneePhone }}</text> </view> <!-- 默认地址标记 --> <view v-if="item.isDefault" class="addr-tag">默认</view> <text class="addr-detail">{{ item.detail }}</text> <view v-if="item.isDefault === '1'" class="addr-tag">默认</view> <text class="addr-detail">{{ item.address }}</text> </view> <!-- 操作区:编辑 / 删除 --> <view class="addr-actions"> <view class="icon icon-edit" @tap="handleEdit(item)"></view> <view class="icon icon-delete" @tap="handleDelete(item)"></view> </view> </view> </view> @@ -38,35 +42,52 @@ <script setup> import { ref } from 'vue' import { onShow } from '@dcloudio/uni-app' import { getAddressList, deleteAddress } from '@/api/index.js' import { ensureLogin } from '@/utils/request.js' /** 收货地址列表:地址接口未就绪,先本地 mock(联调后由收货地址列表接口下发);手机号按规范脱敏展示 */ const MOCK_ADDRESS_LIST = [ { id: '1', name: '张三', phone: '185****0110', detail: '四川省成都市武侯区府城大道399号天府新谷9号楼2单元1005a', isDefault: true }, { id: '2', name: '李四', phone: '185****0110', detail: '四川省成都市武侯区府城大道399号天府新谷9号楼2单元1005a' }, { id: '3', name: '王五', phone: '185****0110', detail: '四川省成都市武侯区府城大道399号天府新谷9号楼2单元1005a' /** 收货地址列表(来自后端 /miniapp/account/user/addresses) */ const addressList = ref([]) /** 拉取本人收货地址列表(默认地址在前) */ const _loadAddressList = async () => { try { const res = await getAddressList() addressList.value = res.data || [] } catch (e) { console.error('[ADDRESS_LIST] 加载收货地址失败', e) uni.showToast({ title: e.message || '加载失败', icon: 'none' }) } ] } const addressList = ref(MOCK_ADDRESS_LIST) /** onShow:从新增/编辑页返回时自动刷新列表 */ onShow(() => { if (!ensureLogin()) return _loadAddressList() }) /** 编辑地址:编辑页未实现,暂为占位反馈 */ const handleEdit = () => { uni.showToast({ title: '敬请期待', icon: 'none' }) /** 编辑地址:跳转新增页并携带 id,复用为编辑模式 */ const handleEdit = (item) => { uni.navigateTo({ url: '/pages/address/add?id=' + item.id }) } /** 删除地址:二次确认后删除并刷新列表 */ const handleDelete = (item) => { uni.showModal({ title: '提示', content: '确定要删除该收货地址吗?', success: async (res) => { if (!res.confirm) return try { await deleteAddress(item.id) uni.showToast({ title: '删除成功', icon: 'none' }) _loadAddressList() } catch (e) { console.error('[ADDRESS_DELETE] 删除收货地址失败', e) uni.showToast({ title: e.message || '删除失败', icon: 'none' }) } } }) } /** 新增地址:跳转到新增地址页 */ @@ -96,6 +117,15 @@ .addr-main { flex: 1; min-width: 0; } /* 操作区:编辑 + 删除图标并排 */ .addr-actions { display: flex; align-items: center; gap: 28rpx; flex-shrink: 0; margin-left: 24rpx; } .addr-head { @@ -212,6 +242,7 @@ /* ==================== 图标(内联 base64 SVG) ==================== */ .icon-edit, .icon-delete, .icon-plus, .icon-empty { background-repeat: no-repeat; @@ -224,10 +255,17 @@ width: 44rpx; height: 44rpx; flex-shrink: 0; margin-left: 24rpx; background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMUU1RUJDIiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMTIgM0g1YTIgMiAwIDAgMC0yIDJ2MTRhMiAyIDAgMCAwIDIgMmgxNGEyIDIgMCAwIDAgMi0ydi03Ii8+PHBhdGggZD0iTTE4LjM3NSAyLjYyNWExIDEgMCAwIDEgMyAzbC05LjAxMyA5LjAxNGEyIDIgMCAwIDEtLjg1My41MDVsLTIuODczLjg0YS41LjUgMCAwIDEtLjYyLS42MmwuODQtMi44NzNhMiAyIDAgMCAxIC41MDYtLjg1MnoiLz48L3N2Zz4='); } /* 删除:垃圾桶(#EF4444) */ .icon-delete { width: 44rpx; height: 44rpx; flex-shrink: 0; background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjRUY0NDQ0IiBzdHJva2Utd2lkdGg9IjEuOCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIj48cGF0aCBkPSJNMyA2aDE4Ii8+PHBhdGggZD0iTTggNlY0YTEgMSAwIDAgMSAxLTFoNmExIDEgMCAwIDEgMSAxdjIiLz48cGF0aCBkPSJNMTkgNmwtMSAxNGEyIDIgMCAwIDEtMiAySDhhMiAyIDAgMCAxLTItMkw1IDYiLz48cGF0aCBkPSJNMTAgMTF2NiIvPjxwYXRoIGQ9Ik0xNCAxMXY2Ii8+PC9zdmc+'); } /* 新增:白色加号 */ .icon-plus { width: 34rpx;