Explorar el Código

用户ID传参改为token自动解析

jiapu hace 3 meses
padre
commit
c737bd0fff

+ 75 - 52
code/app对接接口说明.md

@@ -6,7 +6,7 @@
 
 - 服务前缀:`/api`
 - 本地开发示例地址:`http://localhost:18888`
-- 鉴权方式:除登录等白名单接口外,请求头需携带后台登录后的 Token
+- 鉴权方式:除登录等白名单接口外,请求头需携带登录后的 Token;APP 接口使用 APP 登录返回的 Token
 - 通用返回结构:`code`、`message`、`data`
 
 ## 2. 接口目录
@@ -14,8 +14,8 @@
 | 序号 | 接口名称 | 方法 | URI | 说明 |
 | --- | --- | --- | --- | --- |
 | 1 | 查询用户背部产品穴位坐标列表 | `GET` | `/api/user/acupoint/{profileId}/product-back-coordinates` | 查询指定子用户身体部位为背部且产品穴位为是的穴位坐标 |
-| 2 | 查询用户拥有的设备列表 | `GET` | `/api/user/device/page` | 按 `userId` 查询用户当前已绑定设备,`status` 固定为 `1` |
-| 3 | 用户手动输入设备编号绑定设备 | `POST` | `/api/app/user/device/bind-by-code` | APP 端按设备编号绑定设备,编号不存在返回错误提示 |
+| 2 | 查询当前账号下的设备列表 | `GET` | `/api/app/device/list` | 通过 Token 获取当前 APP 账号可使用的设备列表 |
+| 3 | 用户手动输入设备编号绑定设备 | `POST` | `/api/app/user/device/bind-by-code` | APP 端按设备编号绑定到当前 Token 账号下,编号不存在返回错误提示 |
 
 ## 3. 接口详情
 
@@ -115,39 +115,34 @@ curl -X GET "http://localhost:18888/api/user/acupoint/123/product-back-coordinat
 - 如果该子用户尚未生成穴位坐标表,后端会先自动生成用户穴位坐标,再返回查询结果。
 - `profileId` 使用子用户档案 ID,即 `user_profile.id`,不是后台管理员 ID。
 
-### 3.2 查询用户拥有的设备列表
+### 3.2 查询当前账号下的设备列表
 
 #### 3.2.1 接口用途
 
-用于查询指定用户当前拥有的设备列表。
+用于根据请求头 Token 查询当前登录 APP 账号下可使用的设备列表。
 
-当前场景:APP 根据用户档案 ID 查询该用户已绑定且未解绑的设备,用于展示用户可使用的设备。
+当前场景:APP 登录后不再额外传 `userId`、`status` 等查询参数,后端从 Token 中解析当前账号 ID,返回该账号可使用的家庭设备和公共设备,用于展示当前账号可选择或可使用的设备。
 
 #### 3.2.2 请求信息
 
 - 请求方式:`GET`
-- URI:`/api/user/device/page`
-- 完整示例:`http://localhost:18888/api/user/device/page`
+- URI:`/api/app/device/list`
+- 完整示例:`http://localhost:18888/api/app/device/list`
 
 #### 3.2.3 请求参数
 
-| 参数名 | 类型 | 是否必填 | 说明 | 示例 |
-| --- | --- | --- | --- | --- |
-| `userId` | `number` | 是 | 用户档案 ID,对应 `user_profile.id` | `123` |
-| `status` | `number` | 是 | 绑定状态,固定传 `1`,表示只查询当前已绑定设备 | `1` |
-| `pageNum` | `number` | 否 | 页码,默认 `1` | `1` |
-| `pageSize` | `number` | 否 | 每页条数,默认 `10` | `10` |
+无查询参数。后端通过 `Authorization` 请求头中的 Token 获取当前 APP 账号。
 
 #### 3.2.4 调用示例
 
 ```http
-GET /api/user/device/page?userId=123&status=1&pageNum=1&pageSize=10 HTTP/1.1
+GET /api/app/device/list HTTP/1.1
 Host: localhost:18888
 Authorization: Bearer <token>
 ```
 
 ```bash
-curl -X GET "http://localhost:18888/api/user/device/page?userId=123&status=1&pageNum=1&pageSize=10" \
+curl -X GET "http://localhost:18888/api/app/device/list" \
   -H "Authorization: Bearer <token>"
 ```
 
@@ -157,27 +152,46 @@ curl -X GET "http://localhost:18888/api/user/device/page?userId=123&status=1&pag
 {
   "code": 200,
   "message": "操作成功",
-  "data": {
-    "records": [
-      {
-        "id": 192837465,
-        "userId": 123,
-        "deviceCode": "AJY-0001",
-        "deviceName": "艾灸椅一号",
-        "deviceModel": "AJY-2026",
-        "isPrimary": 1,
-        "onlineStatus": 0,
-        "status": 1,
-        "bindTime": "2026-06-02 10:30:00",
-        "createTime": "2026-06-02 10:30:00",
-        "updateTime": "2026-06-02 10:30:00"
-      }
-    ],
-    "total": 1,
-    "size": 10,
-    "current": 1,
-    "pages": 1
-  }
+  "data": [
+    {
+      "id": 1001,
+      "deviceId": 1001,
+      "deviceCode": "AJY-0001",
+      "deviceName": "艾灸椅一号",
+      "deviceModel": "AJY-2026",
+      "deviceType": 1,
+      "deviceTypeName": "家庭设备",
+      "serialNo": "SN202606020001",
+      "firmwareVersion": "1.0.0",
+      "onlineStatus": 0,
+      "lastOnlineTime": "2026-06-02 10:30:00",
+      "address": "广东省深圳市",
+      "groupId": 2001,
+      "memberId": 3001,
+      "profileId": 123,
+      "profileName": "张三",
+      "profileComplete": true
+    },
+    {
+      "id": 1002,
+      "deviceId": 1002,
+      "deviceCode": "AJY-0002",
+      "deviceName": "共享艾灸椅",
+      "deviceModel": "AJY-2026",
+      "deviceType": 2,
+      "deviceTypeName": "公共设备",
+      "serialNo": "SN202606020002",
+      "firmwareVersion": "1.0.0",
+      "onlineStatus": 1,
+      "lastOnlineTime": "2026-06-02 10:35:00",
+      "address": "广东省深圳市",
+      "groupId": null,
+      "memberId": null,
+      "profileId": 123,
+      "profileName": "张三",
+      "profileComplete": true
+    }
+  ]
 }
 ```
 
@@ -185,31 +199,40 @@ curl -X GET "http://localhost:18888/api/user/device/page?userId=123&status=1&pag
 
 | 字段名 | 类型 | 说明 |
 | --- | --- | --- |
-| `id` | `number` | 用户设备绑定记录 ID,对应 `user_device.id` |
-| `userId` | `number` | 用户档案 ID,对应 `user_profile.id` |
+| `id` | `number` | 设备 ID,对应 `device.id` |
+| `deviceId` | `number` | 设备 ID,对应 `device.id` |
 | `deviceCode` | `string` | 设备编号 |
 | `deviceName` | `string` | 设备名称 |
 | `deviceModel` | `string` | 设备型号 |
-| `isPrimary` | `number` | 是否主设备:`1=是`,`0=否` |
+| `deviceType` | `number` | 设备类型:`1=家庭设备`,`2=公共设备` |
+| `deviceTypeName` | `string` | 设备类型名称 |
+| `serialNo` | `string` | 设备序列号 |
+| `firmwareVersion` | `string` | 固件版本 |
 | `onlineStatus` | `number` | 在线状态:`1=在线`,`0=离线` |
-| `status` | `number` | 绑定状态:`1=已绑定`,`0=已解绑` |
-| `bindTime` | `string` | 绑定时间 |
+| `lastOnlineTime` | `string` | 最近在线时间 |
+| `address` | `string` | 设备地址 |
+| `groupId` | `number/null` | 家庭设备所属群组 ID,公共设备返回 `null` |
+| `memberId` | `number/null` | 当前账号在家庭群组中的成员 ID,公共设备返回 `null` |
+| `profileId` | `number/null` | 当前设备默认关联的用户档案 ID,对应 `user_profile.id` |
+| `profileName` | `string/null` | 当前设备默认关联的用户档案名称 |
+| `profileComplete` | `boolean` | 当前关联档案是否已补全设备使用所需资料 |
 
 #### 3.2.7 备注
 
-- APP 查询用户拥有的设备列表时,`status` 固定传 `1`,不查询已解绑设备。
-- `userId` 使用用户档案 ID,即 `user_profile.id`,不是后台管理员 ID。
-- 返回数据来自用户设备绑定记录表 `user_device`,用于展示该用户当前已绑定设备。
+- APP 查询设备列表时只需要携带 Token,不需要传 `userId`、`status`、`pageNum`、`pageSize`。
+- 后端会根据 Token 中的当前 APP 账号 ID 查询该账号可使用的设备,包含家庭设备和公共设备。
+- 家庭设备来源于当前账号已加入的设备群组;公共设备来源于当前账号下用户档案已绑定的启用设备。
+- 本接口返回数组,不返回分页结构。
 
 ### 3.3 用户手动输入设备编号绑定设备
 
 #### 3.3.1 接口用途
 
-用于 APP 端让用户手动输入设备编号后绑定设备
+用于 APP 端让用户手动输入设备编号后,将设备绑定到当前登录账号下
 
 后端会先根据设备编号查询设备主表 `device`:
 
-- 如果设备编号存在,则自动带出设备名称、设备型号等信息,并新增用户设备绑定记录。
+- 如果设备编号存在,则从 Token 中获取当前 APP 账号 ID,自动确认或创建当前账号档案,再带出设备名称、设备型号等信息,并新增用户设备绑定记录。
 - 如果设备编号不存在,则返回错误码和错误信息,APP 可直接弹窗提示。
 
 #### 3.3.2 请求信息
@@ -224,7 +247,6 @@ curl -X GET "http://localhost:18888/api/user/device/page?userId=123&status=1&pag
 
 | 参数名 | 类型 | 是否必填 | 说明 | 示例 |
 | --- | --- | --- | --- | --- |
-| `userId` | `number` | 是 | 用户档案 ID,对应 `user_profile.id` | `123` |
 | `deviceCode` | `string` | 是 | 用户手动输入的设备编号,对应 `device.device_code` | `AJY-0001` |
 
 #### 3.3.4 调用示例
@@ -236,7 +258,6 @@ Authorization: Bearer <token>
 Content-Type: application/json
 
 {
-  "userId": 123,
   "deviceCode": "AJY-0001"
 }
 ```
@@ -245,7 +266,7 @@ Content-Type: application/json
 curl -X POST "http://localhost:18888/api/app/user/device/bind-by-code" \
   -H "Authorization: Bearer <token>" \
   -H "Content-Type: application/json" \
-  -d "{\"userId\":123,\"deviceCode\":\"AJY-0001\"}"
+  -d "{\"deviceCode\":\"AJY-0001\"}"
 ```
 
 #### 3.3.5 成功返回示例
@@ -282,7 +303,7 @@ curl -X POST "http://localhost:18888/api/app/user/device/bind-by-code" \
 }
 ```
 
-该用户已绑定此设备:
+当前账号已绑定此设备:
 
 ```json
 {
@@ -294,7 +315,9 @@ curl -X POST "http://localhost:18888/api/app/user/device/bind-by-code" \
 
 #### 3.3.7 备注
 
-- APP 端只需要传 `userId` 和 `deviceCode`,不需要传 `deviceName`、`deviceModel`。
+- APP 端只需要传 `deviceCode`,不需要传 `userId`、`deviceName`、`deviceModel`。
+- 后端从 Token 中获取当前 APP 账号 ID,并将设备绑定到当前账号对应的用户档案下。
+- 成功响应中的 `userId` 为后端自动关联的用户档案 ID,对应 `user_profile.id`。
 - 后端会用 `deviceCode` 查询设备主表,存在时自动写入 `deviceName`、`deviceModel`。
 - 绑定成功后,绑定记录默认 `status=1`、`isPrimary=0`。
 - APP 可根据失败响应中的 `message` 直接弹窗,例如设备编号不存在时提示“设备不存在”。

+ 1 - 0
code/backend/src/main/java/com/aijiuyi/admin/common/config/PlanDataInitializer.java

@@ -246,6 +246,7 @@ public class PlanDataInitializer implements CommandLineRunner {
                 Arrays.asList(
                         new StepSeed(1L, "大椎穴", 1, 42.0, 7, 2L, "温和灸", "祛风首穴"),
                         new StepSeed(2L, "风门穴", 4, 43.0, 7, 3L, "雀啄灸", "散风解表"),
+                        new StepSeed(null, "秉风穴", 4, 42.0, 6, 3L, "雀啄灸", "祛风散寒"),
                         new StepSeed(3L, "肺俞穴", 4, 42.0, 7, 2L, "温和灸", "协同宣肺"),
                         new StepSeed(4L, "心俞穴", 4, 42.0, 6, 2L, "温和灸", "安神扶正")
                 )));

+ 20 - 4
code/backend/src/main/java/com/aijiuyi/admin/common/util/AcupointLocationParser.java

@@ -25,6 +25,8 @@ public final class AcupointLocationParser {
             Pattern.compile("(?:前|后|头)?正中线旁开\\s*约?\\s*([0-9]+(?:\\.[0-9]+)?)\\s*(?:寸|(?=凹陷))");
     private static final Pattern GENERAL_OFFSET =
             Pattern.compile("旁开\\s*约?\\s*([0-9]+(?:\\.[0-9]+)?)\\s*(?:寸|(?=凹陷))");
+    private static final Pattern DAZHUI_DOWN_OFFSET =
+            Pattern.compile("(?:相对)?(?:大椎穴?|C7)(?:向|直)?下\\s*约?\\s*([0-9]+(?:\\.[0-9]+)?)\\s*寸");
     private static final Pattern UNSUPPORTED_TOUCH =
             Pattern.compile("凹陷|肌腱|骨间|缝中|连线|赤白肉际|脉搏动|鼻唇沟|瞳孔|发际|骨间隙");
     private static final Pattern ABDOMEN_REFERENCE =
@@ -51,14 +53,17 @@ public final class AcupointLocationParser {
 
         String yTerm = AcupointYReference.inferTerm(text);
         AcupointYReference.Rule yRule = AcupointYReference.getRule(yTerm);
+        BigDecimal longitudeOffset = parseLongitudeOffset(text);
         BigDecimal lateralOffset = parseLateralOffset(text);
         Integer side = parseSide(acupoint, text, lateralOffset);
 
-        result.setYReferenceTerm(yTerm);
+        if (longitudeOffset == null) {
+            result.setYReferenceTerm(yTerm);
+        }
         result.setLateralOffset(lateralOffset);
         result.setSide(side);
 
-        boolean hasY = yRule != null;
+        boolean hasY = longitudeOffset != null || yRule != null;
         boolean needsLateral = Integer.valueOf(2).equals(side)
                 || Integer.valueOf(3).equals(side)
                 || Integer.valueOf(4).equals(side);
@@ -70,7 +75,10 @@ public final class AcupointLocationParser {
         boolean abdomenReference = ABDOMEN_REFERENCE.matcher(text).find();
 
         List<String> messages = new ArrayList<String>();
-        if (hasY) {
+        if (longitudeOffset != null) {
+            result.setLongitudeOffset(longitudeOffset);
+            messages.add("已解析相对大椎纵向偏移:" + longitudeOffset.toPlainString() + "寸");
+        } else if (yRule != null) {
             result.setNormalizedY(yRule.getNormalizedY());
             if (yRule.getNormalizedY() == null) {
                 result.setLongitudeOffset(yRule.getCunOffset());
@@ -89,7 +97,7 @@ public final class AcupointLocationParser {
 
         if (hasY && (!needsLateral || hasLateral)) {
             result.setParseStatus(STATUS_PARSED);
-            result.setParseConfidence(touchOnly ? CONFIDENCE_MEDIUM : CONFIDENCE_HIGH);
+            result.setParseConfidence(touchOnly || longitudeOffset != null ? CONFIDENCE_MEDIUM : CONFIDENCE_HIGH);
         } else if (hasY) {
             result.setParseStatus(STATUS_REVIEW_REQUIRED);
             result.setParseConfidence(CONFIDENCE_MEDIUM);
@@ -144,6 +152,14 @@ public final class AcupointLocationParser {
         return null;
     }
 
+    private static BigDecimal parseLongitudeOffset(String text) {
+        Matcher dazhuiDown = DAZHUI_DOWN_OFFSET.matcher(text);
+        if (dazhuiDown.find()) {
+            return new BigDecimal(dazhuiDown.group(1));
+        }
+        return null;
+    }
+
     private static Integer parseSide(Acupoint acupoint, String text, BigDecimal lateralOffset) {
         if (text != null) {
             if (text.contains("双侧") || text.contains("两侧") || text.contains("左右")) {

+ 2 - 2
code/backend/src/main/java/com/aijiuyi/admin/controller/AppUserDeviceController.java

@@ -25,13 +25,13 @@ public class AppUserDeviceController {
     /**
      * 按设备编号绑定设备。
      *
-     * @param dto 绑定请求,userId 为用户档案 ID,deviceCode 为设备编号
+     * @param dto 绑定请求,deviceCode 为设备编号
      * @return 新增的绑定记录
      */
     @PostMapping("/bind-by-code")
     @Log(value = "App按设备编号绑定设备", module = "App设备", operationType = OperationType.INSERT)
     public Result<UserDevice> bindByCode(@RequestBody @Valid AppUserDeviceBindDTO dto) {
         Long appUserId = RequestContext.getUserId();
-        return Result.success(userDeviceService.bindAppDeviceByCode(dto.getUserId(), dto.getDeviceCode(), appUserId));
+        return Result.success(userDeviceService.bindAppDeviceByCode(dto.getDeviceCode(), appUserId));
     }
 }

+ 0 - 5
code/backend/src/main/java/com/aijiuyi/admin/controller/dto/AppUserDeviceBindDTO.java

@@ -3,7 +3,6 @@ package com.aijiuyi.admin.controller.dto;
 import lombok.Data;
 
 import javax.validation.constraints.NotBlank;
-import javax.validation.constraints.NotNull;
 import javax.validation.constraints.Size;
 
 /**
@@ -12,10 +11,6 @@ import javax.validation.constraints.Size;
 @Data
 public class AppUserDeviceBindDTO {
 
-    /** 用户档案 ID,对应 user_profile.id */
-    @NotNull(message = "用户ID不能为空")
-    private Long userId;
-
     /** 设备编号,对应 device.device_code */
     @NotBlank(message = "设备编号不能为空")
     @Size(max = 100, message = "设备编号不能超过100个字符")

+ 1 - 2
code/backend/src/main/java/com/aijiuyi/admin/service/UserDeviceService.java

@@ -30,12 +30,11 @@ public interface UserDeviceService extends IService<UserDevice> {
     /**
      * App 端按设备编号绑定设备。
      *
-     * @param userId     用户档案ID(user_profile.id)
      * @param deviceCode 设备编号
      * @param appUserId  当前登录 App 用户ID(app_user.id)
      * @return 新增的绑定记录
      */
-    UserDevice bindAppDeviceByCode(Long userId, String deviceCode, Long appUserId);
+    UserDevice bindAppDeviceByCode(String deviceCode, Long appUserId);
 
     /**
      * 解绑设备

+ 82 - 12
code/backend/src/main/java/com/aijiuyi/admin/service/impl/UserDeviceServiceImpl.java

@@ -4,9 +4,11 @@ import com.aijiuyi.admin.common.constant.ResultCode;
 import com.aijiuyi.admin.common.exception.BusinessException;
 import com.aijiuyi.admin.common.util.LogUtil;
 import com.aijiuyi.admin.controller.dto.UserDeviceQueryDTO;
+import com.aijiuyi.admin.entity.AppUser;
 import com.aijiuyi.admin.entity.Device;
 import com.aijiuyi.admin.entity.UserDevice;
 import com.aijiuyi.admin.entity.UserProfile;
+import com.aijiuyi.admin.mapper.AppUserMapper;
 import com.aijiuyi.admin.mapper.DeviceMapper;
 import com.aijiuyi.admin.mapper.UserDeviceMapper;
 import com.aijiuyi.admin.mapper.UserProfileMapper;
@@ -30,6 +32,11 @@ import java.util.List;
 @Service
 public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDevice> implements UserDeviceService {
 
+    private static final int NORMAL_USER_TYPE = 1;
+
+    @Autowired
+    private AppUserMapper appUserMapper;
+
     @Autowired
     private DeviceMapper deviceMapper;
 
@@ -81,21 +88,15 @@ public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDev
     /**
      * App 端按设备编号绑定设备。
      *
-     * @param userId     用户档案ID
      * @param deviceCode 设备编号
      * @param appUserId  当前登录 App 用户ID
      * @return 新增的绑定记录
      */
     @Override
     @Transactional(rollbackFor = Exception.class)
-    public UserDevice bindAppDeviceByCode(Long userId, String deviceCode, Long appUserId) {
-        UserProfile profile = userProfileMapper.selectById(userId);
-        if (profile == null) {
-            throw new BusinessException(ResultCode.PROFILE_NOT_FOUND);
-        }
-        if (appUserId != null && !appUserId.equals(profile.getUserId())) {
-            throw new BusinessException(ResultCode.FORBIDDEN);
-        }
+    public UserDevice bindAppDeviceByCode(String deviceCode, Long appUserId) {
+        AppUser appUser = requireAppUser(appUserId);
+        UserProfile profile = ensureAccountProfile(appUser);
 
         String normalizedDeviceCode = deviceCode.trim();
         Device device = deviceMapper.selectOne(new LambdaQueryWrapper<Device>()
@@ -106,7 +107,7 @@ public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDev
         }
 
         long count = lambdaQuery()
-                .eq(UserDevice::getUserId, userId)
+                .eq(UserDevice::getUserId, profile.getId())
                 .eq(UserDevice::getDeviceCode, normalizedDeviceCode)
                 .eq(UserDevice::getStatus, 1)
                 .count();
@@ -115,7 +116,7 @@ public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDev
         }
 
         UserDevice userDevice = new UserDevice();
-        userDevice.setUserId(userId);
+        userDevice.setUserId(profile.getId());
         userDevice.setDeviceCode(device.getDeviceCode());
         userDevice.setDeviceName(device.getDeviceName());
         userDevice.setDeviceModel(device.getDeviceModel());
@@ -126,10 +127,79 @@ public class UserDeviceServiceImpl extends ServiceImpl<UserDeviceMapper, UserDev
         save(userDevice);
 
         LogUtil.info(UserDeviceServiceImpl.class,
-                "App用户[{}]为子用户[{}]绑定设备[{}]", appUserId, userId, device.getDeviceCode());
+                "App用户[{}]为账号档案[{}]绑定设备[{}]", appUserId, profile.getId(), device.getDeviceCode());
         return userDevice;
     }
 
+    private AppUser requireAppUser(Long appUserId) {
+        AppUser appUser = appUserMapper.selectById(appUserId);
+        if (appUser == null) {
+            throw new BusinessException(ResultCode.APP_USER_NOT_FOUND);
+        }
+        if (Integer.valueOf(0).equals(appUser.getStatus())) {
+            throw new BusinessException(ResultCode.APP_USER_DISABLED);
+        }
+        return appUser;
+    }
+
+    private UserProfile ensureAccountProfile(AppUser appUser) {
+        UserProfile profile = userProfileMapper.selectOne(
+                new LambdaQueryWrapper<UserProfile>()
+                        .eq(UserProfile::getUserId, appUser.getId())
+                        .orderByAsc(UserProfile::getCreateTime)
+                        .last("LIMIT 1")
+        );
+        if (profile != null) {
+            return profile;
+        }
+
+        String phone = trimToNull(appUser.getPhone());
+        if (phone != null) {
+            profile = userProfileMapper.selectOne(
+                    new LambdaQueryWrapper<UserProfile>()
+                            .eq(UserProfile::getPhone, phone)
+                            .and(w -> w.isNull(UserProfile::getUserId).or().eq(UserProfile::getUserId, appUser.getId()))
+                            .orderByAsc(UserProfile::getCreateTime)
+                            .last("LIMIT 1")
+            );
+            if (profile != null) {
+                UserProfile update = new UserProfile();
+                update.setId(profile.getId());
+                update.setUserId(appUser.getId());
+                userProfileMapper.updateById(update);
+                profile.setUserId(appUser.getId());
+                return profile;
+            }
+        }
+
+        profile = new UserProfile();
+        profile.setUserId(appUser.getId());
+        profile.setPhone(phone);
+        profile.setName(resolveProfileName(appUser.getNickname(), phone, appUser.getId()));
+        profile.setUserType(NORMAL_USER_TYPE);
+        profile.setAddress(appUser.getAddress());
+        userProfileMapper.insert(profile);
+        return profile;
+    }
+
+    private String trimToNull(String value) {
+        if (!StringUtils.hasText(value)) {
+            return null;
+        }
+        return value.trim();
+    }
+
+    private String resolveProfileName(String preferredName, String phone, Long fallbackId) {
+        String name = trimToNull(preferredName);
+        if (name != null) {
+            return name;
+        }
+        if (phone != null && phone.length() >= 4) {
+            return "用户" + phone.substring(phone.length() - 4);
+        }
+        return "用户" + (fallbackId == null ? "" : fallbackId);
+    }
+
     /**
      * 解绑设备(设置状态为0)
      *