Просмотр исходного кода

调整商家分管账号使用独立用户名登录

创建和编辑支持独立用户名,保留联系手机号及原密码,改名后撤销旧会话。补充分管账号列表字段、国际化提示、回归测试及前端接入文档。
qmj 2 дней назад
Родитель
Сommit
acde9ced79

+ 25 - 6
ruoyi-admin/src/main/java/com/ruoyi/app/user/MerchantSubaccountApplicationService.java

@@ -120,16 +120,16 @@ public class MerchantSubaccountApplicationService {
             throw error("merchant.subaccount.request.required");
         }
         String phone = normalizePhone(request.getPhone());
-        String name = normalizeRequired(request.getName(), "merchant.subaccount.name.required");
+        String userName = normalizeRequired(request.getUserName(), "merchant.subaccount.username.required");
+        String name = firstNonBlank(request.getName(), userName).strip();
         String password = normalizeRequired(request.getPassword(), "merchant.subaccount.password.required");
         List<Long> storeIds = validateStores(ownerUserId, request.getStoreIds());
-        if (infoUserService.getinfouserName(phone) != null) {
-            throw error("merchant.subaccount.phone.exists");
-        }
+        requireUniqueUserName(userName, null);
         businessPhoneService.ensureUnique(phone, null);
 
         InfoUser user = new InfoUser();
-        user.setUserName(phone);
+        // 用户名用于登录,手机号仅用于联系,不能再把手机号覆盖到登录名。
+        user.setUserName(userName);
         user.setTelPhone(phone);
         user.setNickName(name);
         user.setPassword(password);
@@ -154,17 +154,27 @@ public class MerchantSubaccountApplicationService {
         if (request == null) {
             throw error("merchant.subaccount.request.required");
         }
-        String name = normalizeRequired(request.getName(), "merchant.subaccount.name.required");
+        // 未传用户名时保留原值,兼容仅调整授权门店;存量账号可由主账号明确改名。
+        String userName = request.getUserName() == null ? target.getUserName()
+                : normalizeRequired(request.getUserName(), "merchant.subaccount.username.required");
+        if (request.getUserName() != null) requireUniqueUserName(userName, target.getUserId());
+        boolean renamed = !Objects.equals(userName, target.getUserName());
+        String name = request.getName() == null ? target.getNickName()
+                : normalizeRequired(request.getName(), "merchant.subaccount.name.required");
         List<Long> storeIds = validateStores(ownerUserId, request.getStoreIds());
 
         InfoUser update = new InfoUser();
         update.setUserId(target.getUserId());
+        update.setUserName(userName);
         update.setNickName(name);
         if (infoUserService.updateInfoUser(update) != 1) {
             throw error("merchant.subaccount.update.failed");
         }
         relationService.replaceRelations(target.getUserId(), storeIds);
+        target.setUserName(userName);
         target.setNickName(name);
+        // 改名后撤销旧会话,下一次登录必须使用新用户名和原密码。
+        if (renamed) tokenSessionService.revokeAll(target.getUserId());
         return toView(target, ownerUserId);
     }
 
@@ -228,6 +238,7 @@ public class MerchantSubaccountApplicationService {
     private MerchantSubaccountView toView(InfoUser user, Long ownerUserId) {
         MerchantSubaccountView view = new MerchantSubaccountView();
         view.setUserId(user.getUserId());
+        view.setUserName(user.getUserName());
         view.setName(firstNonBlank(user.getNickName(), user.getFullName(), user.getUserName()));
         view.setPhone(user.getTelPhone());
         view.setMerchantOwnerId(ownerUserId);
@@ -291,6 +302,14 @@ public class MerchantSubaccountApplicationService {
         return request.getEnabled();
     }
 
+    /** 与主账号共用用户名空间,编辑时允许保留本人的用户名。 */
+    private void requireUniqueUserName(String userName, Long excludeUserId) {
+        InfoUser existing = infoUserService.getinfouserName(userName);
+        if (existing != null && !Objects.equals(existing.getUserId(), excludeUserId)) {
+            throw error("merchant.subaccount.username.exists");
+        }
+    }
+
     private String normalizePhone(String value) {
         String phone = normalizeRequired(value, "merchant.subaccount.phone.required")
                 .replaceAll("\\s+", "");

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/MerchantSubaccountCreateRequest.java

@@ -7,6 +7,9 @@ import java.util.List;
 @Data
 public class MerchantSubaccountCreateRequest {
     private String phone;
+    /** 登录用户名,与联系手机号分开保存。 */
+    private String userName;
+    /** 可选显示姓名,不作为登录凭证。 */
     private String name;
     private String password;
     private List<Long> storeIds;

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/MerchantSubaccountUpdateRequest.java

@@ -6,6 +6,9 @@ import java.util.List;
 
 @Data
 public class MerchantSubaccountUpdateRequest {
+    /** 登录用户名,与联系手机号分开保存。 */
+    private String userName;
+    /** 可选显示姓名,不作为登录凭证。 */
     private String name;
     private List<Long> storeIds;
 }

+ 3 - 0
ruoyi-admin/src/main/java/com/ruoyi/app/user/dto/MerchantSubaccountView.java

@@ -8,6 +8,9 @@ import java.util.List;
 @Data
 public class MerchantSubaccountView {
     private Long userId;
+    /** 登录用户名,与联系手机号分开保存。 */
+    private String userName;
+    /** 可选显示姓名,不作为登录凭证。 */
     private String name;
     private String phone;
     private Long merchantOwnerId;

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages.properties

@@ -533,3 +533,6 @@ app.dict.type.invalid=字典类型不能为空且不能超过100个字符
 rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月日時分秒)、訂單類型及分頁
 rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
 rider.income.not.found=收入帳單不存在
+
+merchant.subaccount.username.required=分管账号用户名不能为空
+merchant.subaccount.username.exists=该用户名已被使用

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_en_US.properties

@@ -519,3 +519,6 @@ app.dict.type.invalid=Dictionary type is required and must not exceed 100 charac
 rider.income.query.invalid=Invalid query. Check start and end times (yyyy-MM-dd HH:mm:ss), order type and pagination.
 rider.income.access.denied=Only active rider accounts can view income statements.
 rider.income.not.found=Income statement not found.
+
+merchant.subaccount.username.required=Subaccount username is required
+merchant.subaccount.username.exists=This username is already in use

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_th_TH.properties

@@ -502,3 +502,6 @@ app.dict.type.invalid=ต้องระบุประเภทพจนาน
 rider.income.query.invalid=ข้อมูลค้นหาไม่ถูกต้อง โปรดตรวจสอบเวลาเริ่มต้นและสิ้นสุด (yyyy-MM-dd HH:mm:ss) ประเภทคำสั่งซื้อ และการแบ่งหน้า
 rider.income.access.denied=เฉพาะบัญชีไรเดอร์ที่ใช้งานอยู่เท่านั้นที่ดูรายการรายได้ได้
 rider.income.not.found=ไม่พบรายการรายได้
+
+merchant.subaccount.username.required=กรุณาระบุชื่อผู้ใช้บัญชีย่อย
+merchant.subaccount.username.exists=ชื่อผู้ใช้นี้ถูกใช้งานแล้ว

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_vi.properties

@@ -57,3 +57,6 @@ app.dict.type.invalid=Loại từ điển là bắt buộc và không được v
 rider.income.query.invalid=Tham số không hợp lệ. Vui lòng kiểm tra thời gian bắt đầu và kết thúc (yyyy-MM-dd HH:mm:ss), loại đơn và phân trang.
 rider.income.access.denied=Chỉ tài khoản tài xế đang hoạt động mới được xem thu nhập.
 rider.income.not.found=Không tìm thấy bản ghi thu nhập.
+
+merchant.subaccount.username.required=Tên đăng nhập tài khoản phụ là bắt buộc
+merchant.subaccount.username.exists=Tên đăng nhập này đã được sử dụng

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_CN.properties

@@ -521,3 +521,6 @@ app.dict.type.invalid=字典类型不能为空且不能超过100个字符
 rider.income.query.invalid=查询参数无效,请检查起止时间(年月日时分秒)、订单类型及分页
 rider.income.access.denied=仅启用的骑手账号可查看收入账单
 rider.income.not.found=收入账单不存在
+
+merchant.subaccount.username.required=分管账号用户名不能为空
+merchant.subaccount.username.exists=该用户名已被使用

+ 3 - 0
ruoyi-admin/src/main/resources/i18n/messages_zh_TW.properties

@@ -520,3 +520,6 @@ app.dict.type.invalid=字典類型不能為空且不能超過100個字元
 rider.income.query.invalid=查詢參數無效,請檢查起止時間(年月日時分秒)、訂單類型及分頁
 rider.income.access.denied=僅啟用的騎手帳號可查看收入帳單
 rider.income.not.found=收入帳單不存在
+
+merchant.subaccount.username.required=分管帳號使用者名稱不能為空
+merchant.subaccount.username.exists=此使用者名稱已被使用

+ 74 - 1
ruoyi-admin/src/test/java/com/ruoyi/app/user/MerchantSubaccountApplicationServiceTest.java

@@ -51,14 +51,17 @@ class MerchantSubaccountApplicationServiceTest {
         when(relationService.selectStoreIdsBySubaccountUserId(20L)).thenReturn(List.of());
         MerchantSubaccountCreateRequest request = new MerchantSubaccountCreateRequest();
         request.setPhone("0912345678");
+        request.setUserName("store_staff");
         request.setName("分店账号");
         request.setPassword("password");
         request.setStoreIds(List.of(7L));
 
-        service.create(10L, request);
+        var created = service.create(10L, request);
+        assertEquals("store_staff", created.getUserName());
 
         ArgumentCaptor<InfoUser> captor = ArgumentCaptor.forClass(InfoUser.class);
         verify(infoUserService).insertInfoUser(captor.capture());
+        assertEquals("store_staff", captor.getValue().getUserName());
         assertEquals("0912345678", captor.getValue().getTelPhone());
         assertNull(captor.getValue().getPhone());
         verify(businessPhoneService).ensureUnique("0912345678", null);
@@ -166,4 +169,74 @@ class MerchantSubaccountApplicationServiceTest {
         user.setVoIPToken("voip-" + userId);
         return user;
     }
+    @Test
+    void missingUsernameDoesNotFallBackToPhone() {
+        var fixture = new UsernameFixture();
+        fixture.request.setUserName(" ");
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+            assertEquals("merchant.subaccount.username.required", assertThrows(ServiceException.class,
+                    () -> fixture.service.create(10L, fixture.request)).getMessage());
+        }
+        org.mockito.Mockito.verify(fixture.users, org.mockito.Mockito.never()).insertInfoUser(any());
+    }
+
+    @Test
+    void usernameCannotCollideWithAnotherAccount() {
+        var fixture = new UsernameFixture();
+        InfoUser other = new InfoUser(); other.setUserId(99L);
+        when(fixture.users.getinfouserName("staff")).thenReturn(other);
+        try (MockedStatic<MessageUtils> messages = mockStatic(MessageUtils.class)) {
+            messages.when(() -> MessageUtils.message(anyString())).thenAnswer(i -> i.getArgument(0));
+            assertEquals("merchant.subaccount.username.exists", assertThrows(ServiceException.class,
+                    () -> fixture.service.create(10L, fixture.request)).getMessage());
+        }
+    }
+
+    @Test
+    void renameExistingSubaccountPreservesContactAndRevokesSessions() {
+        var fixture = new UsernameFixture();
+        var request = new com.ruoyi.app.user.dto.MerchantSubaccountUpdateRequest();
+        request.setUserName(" new_staff "); request.setStoreIds(List.of(7L));
+        var view = fixture.service.update(10L, 20L, request);
+        assertEquals("new_staff", view.getUserName());
+        assertEquals("0912345678", view.getPhone());
+        ArgumentCaptor<InfoUser> update = ArgumentCaptor.forClass(InfoUser.class);
+        verify(fixture.users).updateInfoUser(update.capture());
+        assertEquals("new_staff", update.getValue().getUserName());
+        assertNull(update.getValue().getTelPhone());
+        verify(fixture.sessions).revokeAll(20L);
+    }
+
+    @Test
+    void unchangedUsernameCanBelongToSelfWithoutRevokingSessions() {
+        var fixture = new UsernameFixture();
+        when(fixture.users.getinfouserName("0912345678")).thenReturn(fixture.target);
+        var request = new com.ruoyi.app.user.dto.MerchantSubaccountUpdateRequest();
+        request.setUserName("0912345678"); request.setStoreIds(List.of(7L));
+        assertEquals("0912345678", fixture.service.update(10L, 20L, request).getUserName());
+        org.mockito.Mockito.verify(fixture.sessions, org.mockito.Mockito.never()).revokeAll(20L);
+    }
+
+    private static class UsernameFixture {
+        final IInfoUserService users = mock(IInfoUserService.class);
+        final MerchantTokenSessionService sessions = mock(MerchantTokenSessionService.class);
+        final InfoUser target = new InfoUser();
+        final MerchantSubaccountCreateRequest request = new MerchantSubaccountCreateRequest();
+        final MerchantSubaccountApplicationService service;
+        UsernameFixture() {
+            var access = mock(MerchantStoreAccessService.class);
+            var relations = mock(IMerchantSubaccountStoreService.class);
+            service = new MerchantSubaccountApplicationService(users, relations, access,
+                    mock(PosStoreMapper.class), sessions, mock(BusinessPhoneService.class));
+            when(access.getAccessibleStoreIds(10L)).thenReturn(Set.of(7L));
+            target.setUserId(20L); target.setUserType("5"); target.setDelFlag("0");
+            target.setMerchantOwnerId(10L); target.setUserName("0912345678"); target.setTelPhone("0912345678");
+            when(users.selectInfoUserByUserId(20L)).thenReturn(target);
+            when(users.updateInfoUser(any())).thenReturn(1);
+            request.setUserName("staff"); request.setPhone("0912345678");
+            request.setPassword("encrypted-password"); request.setStoreIds(List.of(7L));
+        }
+    }
+
 }

+ 6 - 3
specs/022-merchant-store-subaccounts/contracts/api.md

@@ -16,6 +16,7 @@
   "data": [
     {
       "userId": 501,
+      "userName": "store_staff",
       "name": "张三",
       "phone": "+886900000001",
       "ownerEnabled": true,
@@ -38,20 +39,22 @@
 ```json
 {
   "phone": "+886900000001",
+  "userName": "store_staff",
   "name": "张三",
   "password": "前端RSA加密值",
   "storeIds": [101, 102]
 }
 ```
 
-规则:手机号和登录名使用规范化后的同一手机号;手机号全局唯一;`storeIds` 非空且全部属于当前主账号;账号创建和授权写入同一事务;不创建钱包。
+规则:userName为必填登录用户名,不再使用phone作为登录名;name为可选显示姓名;联系手机号必填并沿用原唯一性校验;用户名与已有账号不可重名;`storeIds` 非空且全部属于当前主账号;账号创建和授权写入同一事务;不创建钱包。
 
 ### `PUT /merchant/subaccounts/{subaccountUserId}`
 
-仅所属主账号可调用,用于修改姓名和授权店铺。
+仅所属主账号可调用,用于修改用户名、可选姓名和授权店铺。userName不传则保留原值,传空白拒绝;修改用户名后撤销旧会话,手机号和原密码不变。
 
 ```json
 {
+  "userName": "store_staff",
   "name": "张三",
   "storeIds": [102, 103]
 }
@@ -101,7 +104,7 @@
 
 ### `POST /infouser/user/shanglodeing`
 
-现有接口扩展:允许 `user_type=5`。签发 Token 前校验平台状态、主账号控制状态和所属主账号状态;成功后更新 CID 与 `lastLoginAt`。类型 5 不执行商家资料审核或钱包逻辑。
+请求继续传 `userName` 与RSA加密的 `password`;userName取列表返回的登录用户名,不使用phone/name字段。存量账号若尚未改名,其原用户名仍为手机号字符串。现有接口扩展:允许 `user_type=5`。签发 Token 前校验平台状态、主账号控制状态和所属主账号状态;成功后更新 CID 与 `lastLoginAt`。类型 5 不执行商家资料审核或钱包逻辑。
 
 ### `POST /infouser/user/merchantLogout`
 

+ 1 - 0
specs/022-merchant-store-subaccounts/data-model.md

@@ -50,6 +50,7 @@ KEY idx_subaccount_store_store (store_id, subaccount_user_id)
 ### `MerchantSubaccountView`
 
 - `userId`
+- `userName`:info_user.user_name,登录用户名。
 - `name`
 - `phone`
 - `merchantOwnerId`

+ 6 - 0
specs/022-merchant-store-subaccounts/plan.md

@@ -215,3 +215,9 @@ ruoyi-admin/src/test/java/com/ruoyi/app/order/DeliveryOrderNotificationServiceTe
 4. 用登录契约、越权 ID、伪造门店、空授权、批量删除原子校验和 SQL 参数绑定回归验证,并运行 JDK 21 模块构建。无数据库结构变更,不部署、不自动提交。
 
 验收:合法授权账号可用,未授权/停用账号被拒绝,普通商家和骑手原账单路径不变;相关接口响应主体保持兼容。
+
+
+## 2026-11-06 用户名登录增量
+1. 创建/编辑DTO及列表视图增加userName;创建不再用手机号填充登录名,姓名降为可选。
+2. 复用主账号用户名查询判重;编辑限定账号归属,改名后撤销会话;手机号校验保持。
+3. 更新022契约与接入指引,回归用户名与手机号分离、必填、冲突、存量改名及登录权限。既有登录接口已按user_name查询,无需修改。

+ 10 - 0
specs/022-merchant-store-subaccounts/quickstart.md

@@ -69,3 +69,13 @@ npm run build:prod
 6. 匿名或商家 token 调用全量维护接口不可执行;平台只有具备对应权限才可执行。无需为验收实际触发全量维护。
 
 上述真实账号与客户端联调由发布后执行,单元测试和本地构建结果见 tasks.md。
+
+
+## 2026-11-06 前端接入调整:用户名与联系手机号分开
+- 新增表单将“姓名”入口改为“用户名”,传userName;手机号phone继续必填,password仍RSA加密,storeIds保持。
+- POST /merchant/subaccounts 示例:`{"userName":"store_staff","phone":"0912345678","password":"RSA密文","storeIds":[101]}`。name可不传,不将phone复制到userName。
+- 列表显示接口新增的userName作为登录账号;phone仅显示为联系手机号。
+- PUT /merchant/subaccounts/{id} 传userName和storeIds可修改存量登录名,原密码不变;改名后重新登录。
+- 登录继续调用POST /infouser/user/shanglodeing,传userName和RSA密码。接口路径及权限规则不变。
+- 后端不自动把存量姓名当用户名,也不批量改历史账号;手机号形式的旧用户名在主账号改名前仍可使用。
+- 本轮未修改前端代码,前端需按上述字段独立接入。

+ 10 - 3
specs/022-merchant-store-subaccounts/spec.md

@@ -93,7 +93,7 @@
 **验收场景**:
 
 1. **假如** 一个主商家拥有三个分管账号,**当** 平台查询商家列表、导出商家或统计商家数量,**那么** 只把主账号计为一个商家,三个分管账号均不出现也不计数。
-2. **假如** 平台管理员打开主商家的分管账号入口,**当** 查询完成,**那么** 可以看到每个分管账号的姓名、手机号、账号状态、在线状态或最后登录时间、负责店铺和创建时间。
+2. **假如** 平台管理员打开主商家的分管账号入口,**当** 查询完成,**那么** 可以看到每个分管账号的用户名、姓名、手机号、账号状态、在线状态或最后登录时间、负责店铺和创建时间。
 3. **假如** 平台管理员强制停用分管账号甲,**当** 甲尝试登录、继续调用商家接口或等待店铺通知,**那么** 系统拒绝其访问且不再向其发送通知。
 4. **假如** 主账号尝试重新启用被平台强制停用的账号甲,**当** 提交启用操作,**那么** 系统仍保持平台停用状态,只有平台管理员可以解除。
 5. **假如** 平台管理员查看分管账号,**当** 尝试修改店铺授权或重置密码,**那么** 平台不提供此能力;店铺授权和密码管理仍由所属主账号负责。
@@ -118,7 +118,7 @@
 ### 功能需求
 
 - **FR-001**:系统必须区分商家主账号和分管账号,同时保持两者使用现有商家端身份认证体系。
-- **FR-002**:系统必须只允许商家主账号创建分管账号;创建时至少提供手机号、姓名和初始密码。
+- **FR-002**:系统必须只允许商家主账号创建分管账号;创建时至少提供用户名、联系手机号和初始密码(姓名可选)。
 - **FR-003**:系统必须保证分管账号登录标识在现有用户体系内唯一,不能因账号类型不同而重复。
 - **FR-004**:系统必须允许主账号重置分管账号密码、启用或停用分管账号。
 - **FR-005**:系统必须只允许主账号管理隶属于自己的分管账号,禁止跨商家读取或修改。
@@ -146,7 +146,7 @@
 - **FR-027**:新增用户可见文本和业务错误必须支持越南语、简体中文、繁体中文和英文。
 - **FR-028**:分管账号必须使用独立于商家主账号的账号类别,不能作为商家主账号参与平台商家列表、审核、导出或数量统计。
 - **FR-029**:平台必须在主商家的详情或下级入口中展示所属分管账号,不得将分管账号作为顶级商家行呈现。
-- **FR-030**:平台分管账号视图必须展示姓名、手机号、账号状态、在线状态或最后登录时间、负责店铺和创建时间。
+- **FR-030**:平台分管账号视图必须展示用户名、姓名、手机号、账号状态、在线状态或最后登录时间、负责店铺和创建时间。
 - **FR-031**:平台管理员必须可以强制停用或重新启用分管账号;主账号不能解除平台强制停用。
 - **FR-032**:主账号停用和平台强制停用必须作为相互独立的限制条件,任一条件生效时都禁止分管账号登录、访问业务和接收通知。
 - **FR-033**:平台不得修改分管账号的店铺授权或密码;这些能力只属于该账号的主账号。
@@ -222,3 +222,10 @@
 营业时间、新旧优惠券/促销和评论管理必须登录,并按当前授权门店校验列表、详情及增删改。修改先校验数据库旧记录归属,不能借请求体中的新门店绕过校验;批量删除任何一项越权均不得删除。全量营业时间修复、优惠券商家归属修复仅平台相应权限可操作。
 
 分管账号的首页、日/周/月账单与取消统计按主账号及授权门店集合计算;空授权不得返回全量。首页保留旧字段,分管和摊位账号的 ketixian 返回 null,不代表主账号余额为零。旧 id 参数仅兼容,不作为首页查询身份。消费者查询营业时间、可用优惠活动不转成商家管理权限。无数据库迁移。
+
+
+## 2026-11-06 分管账号用户名登录
+
+创建分管账号必须独立提供userName、phone、password、storeIds;name改为可选显示姓名,未传时使用用户名。登录继续使用现有shanglodeing的userName,不新增手机号登录或手机号回退。用户名复用info_user.user_name,与商家主账号共用唯一性检查;联系手机号保存tel_phone,沿用必填及手机号唯一性校验。
+列表和创建/编辑响应新增userName。主账号可编辑所属分管账号用户名,不能与其他账号重名;改名撤销旧会话,原密码及联系手机号保留。编辑未传userName时保留原值,兼容只改店铺授权。存量用户名原来是手机号的账号不自动改名,主账号可逐个编辑后告知使用人。
+本轮只修改后端、测试及接入文档,不修改商家PC/App代码、不操作数据库、不创建分支。无需数据库字段或迁移SQL。

+ 9 - 0
specs/022-merchant-store-subaccounts/tasks.md

@@ -410,3 +410,12 @@ T005..T007 --------------------------> T012
 退出码0,14个测试类共144项,失败/错误/跳过均0,ruoyi-admin及依赖模块打包成功;差异检查通过,保留原工作区换行。日志:`ruoyi-admin/target/merchant-business-red.log`、`merchant-dashboard-stall-red.log`、`merchant-business-final.log`。独立静态复核未发现新增阻断项。未执行全量回归、真实账号/App/PC联调、数据库迁移或线上部署。
 
 执行决定:旧首页 id 参数继续接受但不参与身份判定;活动更新沿用数据库原类型和门店;分管旧账单使用独立路径保留其他角色行为;摊位首页按门店并限定商家账单 user_type=1,避免把骑手运费算入商家收入。前端应发送 token,分管/摊位 ketixian=null 不可解读为主账号余额。
+
+
+## T016 分管账号改用独立用户名(2026-11-06)
+- [x] 创建/编辑/列表增加userName,手机号独立保留,复用既有登录和用户名判重。
+- [x] 存量可改名,改名撤销会话,保持密码、手机号和归属权限;不批量改数据。
+- [x] 更新规格、计划、数据说明、API契约与前端接入指引,不修改前端代码。
+- [x] 定向测试与JDK21后端打包,核对最终差异。
+
+验证:JDK21执行 `mvn -q -pl ruoyi-admin -am -Dtest=MerchantSubaccountApplicationServiceTest,MerchantSubaccountControllerTest,MerchantSubaccountAdminControllerTest,InfoUserControllerTest,MerchantStoreAccessServiceTest,BusinessPhoneServiceTest -Dsurefire.failIfNoSpecifiedTests=false package`,99项全部通过,失败/错误/跳过均0,后端打包成功。日志 `ruoyi-admin/target/subaccount-username.log`。本轮首次执行因测试把在线状态读取也误判为撤销会话而失败,修正断言为仅禁止revokeAll后通过。未修改前端、数据库或已有账号,未执行真实App/PC联调或部署;提交状态以Git记录为准,工作区其他任务改动保留。