list);
}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserRoleMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserRoleMapper.java
index a95c65423..ccc2b8d3b 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserRoleMapper.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserRoleMapper.java
@@ -21,7 +21,11 @@ package com.bsd.pjcloud.admin.mapper;
import com.bsd.pjcloud.admin.api.entity.SysUserRole;
import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import org.apache.ibatis.annotations.Insert;
import org.apache.ibatis.annotations.Mapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
/**
*
@@ -34,4 +38,15 @@ import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface SysUserRoleMapper extends PjcloudBaseMapper {
+ /** AI 喂数:多行插入用户角色 */
+ @Insert("""
+
+ """)
+ int insertAiFeedBatch(@Param("list") List list);
+
}
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java
index d4ac5c092..b828b9b22 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/SysUserService.java
@@ -159,6 +159,26 @@ public interface SysUserService extends IService {
*/
R lockUser(String username);
+ /**
+ * AI 喂数清理:软删用户及关联角色/身份/关系/钱包(不校验余额与流水,演示数据专用)。
+ *
+ * @param userIds 用户 ID 列表
+ * @return 是否成功
+ */
+ R aiFeedDeleteUsers(List userIds);
+
+ /**
+ * AI 喂数专用:按手机号创建用户(独立实现,不走 saveUser/doSaveUser)。
+ * 固定 GENERAL_USER + WALLET_AUTO_CREATE;已存在则返回原 userId;不发异步开钱包事件。
+ */
+ Long aiFeedCreateOrGetUser(String phone, String name, Long deptId);
+
+ /**
+ * AI 喂数批量建用户(独立实现)。同批复用同一密码哈希,已存在手机号直接复用。
+ */
+ com.bsd.pjcloud.admin.api.dto.AiFeedUserBatchResultDTO aiFeedCreateUsersBatch(
+ java.util.List users);
+
/**
* 修改密码
*
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
index 13e502b00..35873a601 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/service/impl/SysUserServiceImpl.java
@@ -38,6 +38,8 @@ import com.bsd.pjcloud.admin.api.constant.UserExpireEnum;
import com.bsd.pjcloud.admin.api.constant.UserStaffingTypeEnum;
import com.bsd.pjcloud.admin.api.constant.UserStateEnum;
import com.bsd.pjcloud.admin.api.constant.user.UserIdentityTypeEnum;
+import com.bsd.pjcloud.admin.api.dto.AiFeedUserBatchResultDTO;
+import com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO;
import com.bsd.pjcloud.admin.api.dto.ForgotPasswordDTO;
import com.bsd.pjcloud.admin.api.dto.SysUserFaceDto;
import com.bsd.pjcloud.admin.api.dto.UserDTO;
@@ -215,6 +217,129 @@ public class SysUserServiceImpl extends ServiceImpl impl
}
}
+ /** 演示用户统一初始密码(仅喂数路径),整批只 BCrypt 一次 */
+ private static final String AI_FEED_DEFAULT_PASSWORD = "AiFeed@123456";
+
+ /**
+ * AI 喂数专用建用户(独立实现,不改动 {@link #doSaveUser} 业务路径)。
+ * 固定 GENERAL_USER + WALLET_AUTO_CREATE;不发异步开钱包事件(钱包由喂数侧同步开通)。
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long aiFeedCreateOrGetUser(String phone, String name, Long deptId) {
+ AiFeedUserCreateDTO dto = new AiFeedUserCreateDTO();
+ dto.setPhone(phone);
+ dto.setName(name);
+ dto.setDeptId(deptId);
+ AiFeedUserBatchResultDTO batch = aiFeedCreateUsersBatch(List.of(dto));
+ if (batch == null || CollUtil.isEmpty(batch.getItems()) || batch.getItems().get(0).getUserId() == null) {
+ throw new IllegalArgumentException("创建用户失败");
+ }
+ return batch.getItems().get(0).getUserId();
+ }
+
+ /**
+ * AI 喂数批量建用户:一次查已存在手机号,整批共用密码哈希,用户/角色一次性 saveBatch。
+ * name 为空时仅按手机号查已有用户(订单行引用已导入用户),不新建。
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AiFeedUserBatchResultDTO aiFeedCreateUsersBatch(List users) {
+ AiFeedUserBatchResultDTO result = new AiFeedUserBatchResultDTO();
+ if (CollUtil.isEmpty(users)) {
+ return result;
+ }
+ // 同批内手机号去重,后者覆盖前者姓名/部门
+ Map unique = new LinkedHashMap<>();
+ for (AiFeedUserCreateDTO u : users) {
+ if (u == null || StrUtil.isBlank(u.getPhone())) {
+ continue;
+ }
+ AiFeedUserCreateDTO copy = new AiFeedUserCreateDTO();
+ copy.setPhone(StrUtil.trim(u.getPhone()));
+ copy.setName(StrUtil.isBlank(u.getName()) ? null : StrUtil.trim(u.getName()));
+ copy.setDeptId(u.getDeptId());
+ unique.put(copy.getPhone(), copy);
+ }
+ if (unique.isEmpty()) {
+ return result;
+ }
+ List phones = new ArrayList<>(unique.keySet());
+ Map existMap = baseMapper.selectList(Wrappers.lambdaQuery().in(SysUser::getPhone, phones))
+ .stream()
+ .collect(Collectors.toMap(SysUser::getPhone, u -> u, (a, b) -> a));
+
+ List toCreate = new ArrayList<>();
+ List pendingCreated = new ArrayList<>();
+ LocalDateTime now = LocalDateTime.now();
+ for (AiFeedUserCreateDTO u : unique.values()) {
+ AiFeedUserBatchResultDTO.Item item = new AiFeedUserBatchResultDTO.Item();
+ item.setPhone(u.getPhone());
+ SysUser exist = existMap.get(u.getPhone());
+ if (exist != null) {
+ item.setUserId(exist.getUserId());
+ item.setCreated(Boolean.FALSE);
+ result.getItems().add(item);
+ continue;
+ }
+ if (StrUtil.isBlank(u.getName()) || u.getDeptId() == null) {
+ continue;
+ }
+ SysUser sysUser = new SysUser();
+ // 预分配雪花 ID,便于随后批量写角色关系
+ sysUser.setUserId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
+ sysUser.setUsername(u.getPhone());
+ sysUser.setPhone(u.getPhone());
+ sysUser.setName(u.getName());
+ sysUser.setNickname(u.getName());
+ sysUser.setDeptId(u.getDeptId());
+ sysUser.setLockFlag(UserStateEnum.NORMAL.getCode());
+ sysUser.setProcessStatus(TaskCheckStatusEnum.RESULT.getCode());
+ sysUser.setProcessTime(now);
+ sysUser.setCreateBy(u.getPhone());
+ sysUser.setUpdateBy(u.getPhone());
+ sysUser.setPasswordModifyTime(now);
+ sysUser.setPasswordExpireFlag(UserExpireEnum.EXPIRE.getCode());
+ toCreate.add(sysUser);
+ item.setUserId(sysUser.getUserId());
+ item.setCreated(Boolean.TRUE);
+ pendingCreated.add(item);
+ }
+ if (!toCreate.isEmpty()) {
+ List roleIds = getNonAdminNewUserRoleIds();
+ String encodedPwd = ENCODER.encode(AI_FEED_DEFAULT_PASSWORD);
+ Long tenantId = TenantContextHolder.getTenantId();
+ LocalDateTime createTime = LocalDateTime.now();
+ for (SysUser sysUser : toCreate) {
+ sysUser.setPassword(encodedPwd);
+ sysUser.setDelFlag(CommonConstants.STATUS_NORMAL);
+ sysUser.setTenantId(tenantId);
+ sysUser.setCreateTime(createTime);
+ sysUser.setUpdateTime(createTime);
+ }
+ // 多行 INSERT,避免无 rewriteBatchedStatements 时 saveBatch 仍接近逐条写库
+ for (List chunk : CollUtil.split(toCreate, 200)) {
+ baseMapper.insertAiFeedBatch(chunk);
+ }
+ List roles = new ArrayList<>(toCreate.size() * Math.max(roleIds.size(), 1));
+ for (SysUser sysUser : toCreate) {
+ for (Long roleId : roleIds) {
+ SysUserRole userRole = new SysUserRole();
+ userRole.setUserId(sysUser.getUserId());
+ userRole.setRoleId(roleId);
+ roles.add(userRole);
+ }
+ }
+ if (!roles.isEmpty()) {
+ for (List chunk : CollUtil.split(roles, 500)) {
+ sysUserRoleMapper.insertAiFeedBatch(chunk);
+ }
+ }
+ result.getItems().addAll(pendingCreated);
+ }
+ return result;
+ }
+
/**
* 解析当前操作人的角色上下文,供一次或一批用户创建复用。
*
@@ -970,6 +1095,51 @@ public class SysUserServiceImpl extends ServiceImpl impl
return R.ok();
}
+ /**
+ * AI 喂数清理:软删演示用户。跳过钱包余额/流水校验,避免充值后无法清理。
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public R aiFeedDeleteUsers(List userIds) {
+ if (CollUtil.isEmpty(userIds)) {
+ return R.ok(true);
+ }
+ List distinctIds = userIds.stream().filter(Objects::nonNull).distinct().toList();
+ if (CollUtil.isEmpty(distinctIds)) {
+ return R.ok(true);
+ }
+ List userList = baseMapper.selectBatchIds(distinctIds);
+ if (CollUtil.isEmpty(userList)) {
+ return R.ok(true);
+ }
+ Cache cache = cacheManager.getCache(CacheConstants.USER_DETAILS);
+ for (SysUser sysUser : userList) {
+ if (cache != null && StrUtil.isNotBlank(sysUser.getUsername())) {
+ cache.evict(sysUser.getUsername());
+ }
+ }
+ List existIds = userList.stream().map(SysUser::getUserId).toList();
+ sysUserRoleMapper.delete(Wrappers.lambdaQuery().in(SysUserRole::getUserId, existIds));
+ sysUserRelationService.deleteUserRelationByUserIds(existIds);
+ sysUserIdentityService.deleteUserIdentityByUserIds(existIds);
+ this.removeBatchByIds(existIds);
+ try {
+ remoteWalletService.delWallet(existIds);
+ }
+ catch (Exception ex) {
+ log.warn("AI喂数清理钱包失败 userIds={}", existIds, ex);
+ }
+ for (Long userId : existIds) {
+ try {
+ remoteWalletService.userChange(userId, Boolean.TRUE);
+ }
+ catch (Exception ex) {
+ log.warn("AI喂数清理钱包同步失败 userId={}", userId, ex);
+ }
+ }
+ return R.ok(true);
+ }
+
@Override
@CacheEvict(value = CacheConstants.USER_DETAILS, key = "#userDto.username")
public R changePassword(UserDTO userDto) {
diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml
index edd7d48be..600e94c95 100644
--- a/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml
+++ b/pjcloud-upms/pjcloud-upms-biz/src/main/resources/mapper/SysUserMapper.xml
@@ -562,4 +562,22 @@
order by a.dept_id
+
+
+ INSERT INTO sys_user (
+ user_id, username, password, phone, name, nickname, dept_id,
+ lock_flag, password_expire_flag, password_modify_time,
+ process_status, process_time, create_by, update_by, create_time, update_time,
+ del_flag, tenant_id
+ ) VALUES
+
+ (
+ #{u.userId}, #{u.username}, #{u.password}, #{u.phone}, #{u.name}, #{u.nickname}, #{u.deptId},
+ #{u.lockFlag}, #{u.passwordExpireFlag}, #{u.passwordModifyTime},
+ #{u.processStatus}, #{u.processTime}, #{u.createBy}, #{u.updateBy}, #{u.createTime}, #{u.updateTime},
+ #{u.delFlag}, #{u.tenantId}
+ )
+
+
+
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java
index 28219d799..92e43ed85 100644
--- a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/feign/RemoteWalletService.java
@@ -25,6 +25,17 @@ public interface RemoteWalletService {
@GetMapping("/wallet-manage/get-or-create-wallet")
R getOrCreateWallet(@RequestParam Long userId, @RequestParam(required = false) WalletTypeEnum walletTypeCode);
+ /** AI 喂数:确保饭卡钱包(免角色校验、免绑卡) */
+ @NoToken
+ @GetMapping("/wallet-manage/inner/ai-feed-ensure-eat")
+ R aiFeedEnsureEatWallet(@RequestParam Long userId);
+
+ /** AI 喂数:批量开饭卡并设定余额 */
+ @NoToken
+ @PostMapping("/wallet-manage/inner/ai-feed-ensure-eat-batch")
+ R aiFeedEnsureEatWalletsBatch(
+ @RequestBody java.util.List reqs);
+
@NoToken
@GetMapping("/wallet-manage/get-wallet")
R getWallet(@RequestParam Long userId, @RequestParam WalletTypeEnum walletTypeCode);
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java
index d6fb7a5c6..ea195eeb1 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/controller/WalletManageController.java
@@ -80,6 +80,25 @@ public class WalletManageController {
return R.ok(wallet);
}
+ /** AI 喂数:确保饭卡钱包(免角色校验、免绑卡) */
+ @Inner
+ @GetMapping("/inner/ai-feed-ensure-eat")
+ public R aiFeedEnsureEatWallet(@RequestParam Long userId) {
+ WalletManageEntity wallet = walletManageService.aiFeedEnsureEatWallet(userId);
+ if (ObjectUtil.isEmpty(wallet)) {
+ return R.failed("开通饭卡钱包失败");
+ }
+ return R.ok(wallet);
+ }
+
+ /** AI 喂数:批量开饭卡并设定余额 */
+ @Inner
+ @PostMapping("/inner/ai-feed-ensure-eat-batch")
+ public R aiFeedEnsureEatWalletsBatch(
+ @RequestBody List reqs) {
+ return R.ok(walletManageService.aiFeedEnsureEatWalletsBatch(reqs));
+ }
+
@Inner
@GetMapping("/get-wallet-by-wallet-id")
public R getWalletByWalletTypeId(@RequestParam Long userId, @RequestParam Long walletTypeId) {
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java
index b3f87d637..c87e7ec5c 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/mapper/WalletManageMapper.java
@@ -25,6 +25,27 @@ import java.util.List;
@Mapper
public interface WalletManageMapper extends PjcloudBaseMapper {
+ /**
+ * AI 喂数:多行插入饭卡钱包(自行带 tenant_id)
+ */
+ @com.baomidou.mybatisplus.annotation.InterceptorIgnore(tenantLine = "true")
+ @org.apache.ibatis.annotations.Insert("""
+
+ """)
+ int insertAiFeedBatch(@Param("list") List list);
+
/**
* 构建钱包账户通用筛选条件,并以钱包主键作为最终排序条件保证分片导出顺序稳定。
*
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java
index 42430b34a..a1cd8351b 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/WalletManageService.java
@@ -55,6 +55,20 @@ public interface WalletManageService extends IService {
*/
WalletManageEntity getOrCreateWallet(@NotNull Long userId, @NotNull WalletTypeEnum walletTypeCode);
+ /**
+ * AI 喂数:确保饭卡钱包存在。跳过 WALLET_AUTO_CREATE 角色校验,且不自动绑卡(避免抢卡锁拖慢导入)。
+ *
+ * @param userId 用户 ID
+ * @return 饭卡钱包;用户不存在或类型未配置时返回 null
+ */
+ WalletManageEntity aiFeedEnsureEatWallet(@NotNull Long userId);
+
+ /**
+ * AI 喂数批量:确保饭卡并直接设定余额(不走充值流水,演示加速)。
+ */
+ com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO aiFeedEnsureEatWalletsBatch(
+ java.util.List reqs);
+
/**
* 获取钱包(没有缓存)
*/
diff --git a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java
index 38307d0eb..fe247c554 100644
--- a/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java
+++ b/pjcloud-wallet/pjcloud-wallet-biz/src/main/java/com/bsd/pjcloud/wallet/service/impl/WalletManageServiceImpl.java
@@ -60,7 +60,9 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
+import java.time.LocalDateTime;
import java.util.*;
+import java.util.stream.Collectors;
/**
* 钱包管理
@@ -218,6 +220,165 @@ public class WalletManageServiceImpl extends ServiceImpl历史演示导入可能留下同用户多条饭卡钱包;此处用 list+取最早一条,
+ * 避免调用业务 {@link #getWallet} 的 selectOne 在重复数据上直接报错。
+ */
+ @Override
+ public WalletManageEntity aiFeedEnsureEatWallet(Long userId) {
+ if (userId == null) {
+ return null;
+ }
+ WalletTypeEntity eatType = walletTypeMapper.selectOne(new LambdaQueryWrapper()
+ .eq(WalletTypeEntity::getUniqueCode, WalletTypeEnum.EAT.getCode())
+ .last("LIMIT 1"));
+ if (ObjectUtil.isEmpty(eatType)) {
+ log.warn("|{}|AI喂数开通饭卡失败,userId:{},EAT钱包类型未配置", TAG, userId);
+ return null;
+ }
+ WalletManageEntity existing = findFirstEatWallet(userId, eatType.getId());
+ if (ObjectUtil.isNotEmpty(existing)) {
+ return existing;
+ }
+ UserInfo user = userService.getInnerByUserId(userId).getData();
+ if (ObjectUtil.isEmpty(user) || ObjectUtil.isEmpty(user.getSysUser())) {
+ log.warn("|{}|AI喂数开通饭卡失败,userId:{},用户不存在", TAG, userId);
+ return null;
+ }
+ Long deptId = user.getSysUser().getDeptId();
+ WalletManageEntity insertObj = new WalletManageEntity();
+ insertObj.setUserId(userId);
+ insertObj.setWalletTypeId(eatType.getId());
+ insertObj.setDeptId(deptId);
+ try {
+ baseMapper.insert(insertObj);
+ return insertObj;
+ }
+ catch (Exception ex) {
+ WalletManageEntity again = findFirstEatWallet(userId, eatType.getId());
+ if (ObjectUtil.isNotEmpty(again)) {
+ return again;
+ }
+ log.warn("|{}|AI喂数开通饭卡失败,userId:{},msg:{}", TAG, userId, ex.getMessage());
+ return null;
+ }
+ }
+
+ /** AI 喂数内部:同用户多条饭卡时取最早一条,不抛 TooManyResultsException */
+ private WalletManageEntity findFirstEatWallet(Long userId, Long eatTypeId) {
+ List list = baseMapper.selectList(new LambdaQueryWrapper()
+ .eq(WalletManageEntity::getUserId, userId)
+ .eq(WalletManageEntity::getWalletTypeId, eatTypeId)
+ .orderByAsc(WalletManageEntity::getId)
+ .last("LIMIT 1"));
+ return ObjectUtil.isEmpty(list) ? null : list.get(0);
+ }
+
+ /**
+ * AI 喂数批量开饭卡:一次查类型、一次查出已有钱包,缺失则插入,并按需直接写余额。
+ */
+ @Override
+ public com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO aiFeedEnsureEatWalletsBatch(
+ List reqs) {
+ com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO result =
+ new com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO();
+ if (ObjectUtil.isEmpty(reqs)) {
+ return result;
+ }
+ WalletTypeEntity eatType = walletTypeMapper.selectOne(new LambdaQueryWrapper()
+ .eq(WalletTypeEntity::getUniqueCode, WalletTypeEnum.EAT.getCode())
+ .last("LIMIT 1"));
+ if (ObjectUtil.isEmpty(eatType)) {
+ log.warn("|{}|AI喂数批量开饭卡失败,EAT钱包类型未配置", TAG);
+ return result;
+ }
+ List userIds = reqs.stream()
+ .filter(Objects::nonNull)
+ .map(com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO::getUserId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .toList();
+ if (userIds.isEmpty()) {
+ return result;
+ }
+ Map walletByUser = baseMapper.selectList(new LambdaQueryWrapper()
+ .in(WalletManageEntity::getUserId, userIds)
+ .eq(WalletManageEntity::getWalletTypeId, eatType.getId())
+ .orderByAsc(WalletManageEntity::getId))
+ .stream()
+ .collect(Collectors.toMap(WalletManageEntity::getUserId, w -> w, (a, b) -> a));
+
+ Map balanceByUser = new HashMap<>();
+ Map deptByUser = new HashMap<>();
+ for (com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO req : reqs) {
+ if (req == null || req.getUserId() == null) {
+ continue;
+ }
+ if (req.getBalanceFen() != null) {
+ balanceByUser.put(req.getUserId(), Math.max(req.getBalanceFen(), 0));
+ }
+ if (req.getDeptId() != null) {
+ deptByUser.put(req.getUserId(), req.getDeptId());
+ }
+ }
+
+ List toInsert = new ArrayList<>();
+ List toUpdate = new ArrayList<>();
+ Long tenantId = TenantContextHolder.getTenantId();
+ LocalDateTime now = LocalDateTime.now();
+ for (Long userId : userIds) {
+ WalletManageEntity wallet = walletByUser.get(userId);
+ if (wallet == null) {
+ wallet = new WalletManageEntity();
+ wallet.setId(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId());
+ wallet.setUserId(userId);
+ wallet.setWalletTypeId(eatType.getId());
+ wallet.setDeptId(deptByUser.get(userId));
+ Integer bal = balanceByUser.get(userId);
+ wallet.setBalance(bal == null ? 0 : bal);
+ wallet.setTotalRecharge(bal != null && bal > 0 ? bal : 0);
+ wallet.setTotalExpense(0);
+ wallet.setFreezePrice(0);
+ wallet.setDelFlag(com.bsd.pjcloud.common.core.constant.CommonConstants.STATUS_NORMAL);
+ wallet.setTenantId(tenantId);
+ wallet.setCreateBy("ai-feed");
+ wallet.setUpdateBy("ai-feed");
+ wallet.setCreateTime(now);
+ wallet.setUpdateTime(now);
+ toInsert.add(wallet);
+ walletByUser.put(userId, wallet);
+ }
+ else if (balanceByUser.containsKey(userId)) {
+ Integer bal = balanceByUser.get(userId);
+ wallet.setBalance(bal == null ? 0 : bal);
+ toUpdate.add(wallet);
+ }
+ }
+ if (!toInsert.isEmpty()) {
+ // 多行 INSERT,一次 SQL 写入,避免 saveBatch 在无 rewriteBatchedStatements 时仍接近逐条
+ for (List chunk : cn.hutool.core.collection.CollUtil.split(toInsert, 200)) {
+ baseMapper.insertAiFeedBatch(chunk);
+ }
+ }
+ // 已有饭卡不再逐条改余额(喂数侧只给新建卡写 balance)
+ if (!toUpdate.isEmpty()) {
+ this.updateBatchById(toUpdate, 100);
+ }
+ for (Long userId : userIds) {
+ WalletManageEntity wallet = walletByUser.get(userId);
+ if (wallet == null || wallet.getId() == null) {
+ continue;
+ }
+ com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO.Item item =
+ new com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO.Item();
+ item.setUserId(userId);
+ item.setWalletId(wallet.getId());
+ result.getItems().add(item);
+ }
+ return result;
+ }
+
@Override
public WalletManageEntity getWallet(Long userId, WalletTypeEnum walletTypeCode) {
WalletTypeEntity walletType = walletTypeService.getByUniqueCode(walletTypeCode);
From fe1a7ed9b882602754c7f9ad3b92438ae6b9323e Mon Sep 17 00:00:00 2001
From: zjf <1799650030@qq.com>
Date: Mon, 28 Sep 2026 15:06:29 +0800
Subject: [PATCH 2/2] =?UTF-8?q?ai=E6=89=B9=E9=87=8F=E6=8F=92=E7=94=A8?=
=?UTF-8?q?=E6=88=B7=E8=AE=A2=E5=8D=95=E6=95=B0=E6=8D=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../canteen/entity/AiFeedBatchEntity.java | 67 ++
.../canteen/entity/AiFeedBatchItemEntity.java | 50 +
.../canteen/controller/AiFeedController.java | 80 ++
.../canteen/mapper/AiFeedBatchItemMapper.java | 9 +
.../canteen/mapper/AiFeedBatchMapper.java | 9 +
.../canteen/service/AiFeedImportService.java | 27 +
.../service/impl/AiFeedImportServiceImpl.java | 854 ++++++++++++++++++
.../remote/AiFeedOrderBatchResultDTO.java | 22 +
.../order/remote/AiFeedOrderCreateDTO.java | 58 ++
.../hard/service/AiFeedOrderService.java | 26 +
.../impl/order/AiFeedOrderServiceImpl.java | 283 ++++++
.../api/dto/AiFeedUserBatchResultDTO.java | 23 +
.../admin/api/dto/AiFeedUserCreateDTO.java | 20 +
.../wallet/api/dto/AiFeedWalletEnsureDTO.java | 18 +
.../api/dto/AiFeedWalletEnsureResultDTO.java | 21 +
15 files changed, 1567 insertions(+)
create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/AiFeedBatchEntity.java
create mode 100644 pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/AiFeedBatchItemEntity.java
create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/AiFeedController.java
create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/AiFeedBatchItemMapper.java
create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/AiFeedBatchMapper.java
create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/AiFeedImportService.java
create mode 100644 pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/AiFeedImportServiceImpl.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/remote/AiFeedOrderBatchResultDTO.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/remote/AiFeedOrderCreateDTO.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/AiFeedOrderService.java
create mode 100644 pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/AiFeedOrderServiceImpl.java
create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/AiFeedUserBatchResultDTO.java
create mode 100644 pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/AiFeedUserCreateDTO.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/AiFeedWalletEnsureDTO.java
create mode 100644 pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/AiFeedWalletEnsureResultDTO.java
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/AiFeedBatchEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/AiFeedBatchEntity.java
new file mode 100644
index 000000000..75045486a
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/AiFeedBatchEntity.java
@@ -0,0 +1,67 @@
+package com.bsd.pjcloud.canteen.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@TenantTable
+@TableName("ai_feed_batch")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "AI喂数导入批次")
+public class AiFeedBatchEntity extends Model {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ private String batchNo;
+
+ private Long deptId;
+
+ private Long merchantId;
+
+ private String fileName;
+
+ /** USER / RESERVE / TAKE_OUT */
+ private String feedType;
+
+ /** RUNNING / SUCCESS / FAILED / DELETING / DELETED */
+ private String status;
+
+ private Integer totalRows;
+
+ private Integer successRows;
+
+ private Integer skipRows;
+
+ private String errorMsg;
+
+ private Long costMs;
+
+ private String operator;
+
+ private String operatorName;
+
+ @TableField(fill = FieldFill.INSERT)
+ private String createBy;
+
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private String updateBy;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ private String delFlag;
+
+ private Long tenantId;
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/AiFeedBatchItemEntity.java b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/AiFeedBatchItemEntity.java
new file mode 100644
index 000000000..5fae21ff9
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-api/src/main/java/com/bsd/pjcloud/canteen/entity/AiFeedBatchItemEntity.java
@@ -0,0 +1,50 @@
+package com.bsd.pjcloud.canteen.entity;
+
+import com.baomidou.mybatisplus.annotation.*;
+import com.baomidou.mybatisplus.extension.activerecord.Model;
+import com.bsd.pjcloud.common.core.util.TenantTable;
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+import java.time.LocalDateTime;
+
+@Data
+@TenantTable
+@TableName("ai_feed_batch_item")
+@EqualsAndHashCode(callSuper = true)
+@Schema(description = "AI喂数批次明细")
+public class AiFeedBatchItemEntity extends Model {
+
+ @TableId(type = IdType.ASSIGN_ID)
+ private Long id;
+
+ private Long batchId;
+
+ /** USER / ORDER / SKIP(跳过行,refId=文件行号,原因在 extraJson.reason) */
+ private String itemType;
+
+ private Long refId;
+
+ private String bizKey;
+
+ private String extraJson;
+
+ @TableField(fill = FieldFill.INSERT)
+ private String createBy;
+
+ @TableField(fill = FieldFill.INSERT)
+ private LocalDateTime createTime;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private String updateBy;
+
+ @TableField(fill = FieldFill.INSERT_UPDATE)
+ private LocalDateTime updateTime;
+
+ @TableLogic
+ @TableField(fill = FieldFill.INSERT)
+ private String delFlag;
+
+ private Long tenantId;
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/AiFeedController.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/AiFeedController.java
new file mode 100644
index 000000000..56dd3fe56
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/controller/AiFeedController.java
@@ -0,0 +1,80 @@
+package com.bsd.pjcloud.canteen.controller;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.canteen.service.AiFeedImportService;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.log.annotation.SysLog;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.security.SecurityRequirement;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import lombok.RequiredArgsConstructor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.*;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.nio.charset.StandardCharsets;
+
+/**
+ * AI 用户/订单喂数导入(单一 JSONL:user + reserve + offline)
+ */
+@RestController
+@RequiredArgsConstructor
+@RequestMapping("/ai-feed")
+@Tag(description = "ai-feed", name = "AI数据喂入")
+@SecurityRequirement(name = HttpHeaders.AUTHORIZATION)
+public class AiFeedController {
+
+ private final AiFeedImportService aiFeedImportService;
+
+ @Operation(summary = "流式导入统一 JSONL")
+ @SysLog("AI喂数导入")
+ @PostMapping(value = "/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
+ @PreAuthorize("@pms.hasPermission('canteen_aiFeed_import')")
+ public R importJsonl(@RequestParam("file") MultipartFile file,
+ @RequestParam("merchantId") Long merchantId) {
+ return aiFeedImportService.importJsonl(file, merchantId);
+ }
+
+ @Operation(summary = "批次分页")
+ @GetMapping("/batch/page")
+ @PreAuthorize("@pms.hasPermission('canteen_aiFeed_view')")
+ public R page(@RequestParam(required = false) String status, Page page) {
+ return aiFeedImportService.pageBatches(page, status);
+ }
+
+ @Operation(summary = "批次详情")
+ @GetMapping("/batch/{id}")
+ @PreAuthorize("@pms.hasPermission('canteen_aiFeed_view')")
+ public R detail(@PathVariable Long id) {
+ return aiFeedImportService.getBatch(id);
+ }
+
+ @Operation(summary = "批次导入明细(含跳过原因)")
+ @GetMapping("/batch/{id}/items")
+ @PreAuthorize("@pms.hasPermission('canteen_aiFeed_view')")
+ public R items(@PathVariable Long id) {
+ return aiFeedImportService.listItems(id);
+ }
+
+ @Operation(summary = "按批次清理演示数据")
+ @SysLog("AI喂数删除批次")
+ @DeleteMapping("/batch/{id}")
+ @PreAuthorize("@pms.hasPermission('canteen_aiFeed_del')")
+ public R delete(@PathVariable Long id) {
+ return aiFeedImportService.deleteBatch(id);
+ }
+
+ @Operation(summary = "下载统一 JSONL 模板")
+ @GetMapping("/template")
+ @PreAuthorize("@pms.hasPermission('canteen_aiFeed_view')")
+ public ResponseEntity template() {
+ String body = aiFeedImportService.templateContent();
+ return ResponseEntity.ok()
+ .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=ai_feed_mixed.jsonl")
+ .contentType(new MediaType("text", "plain", StandardCharsets.UTF_8))
+ .body(body.getBytes(StandardCharsets.UTF_8));
+ }
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/AiFeedBatchItemMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/AiFeedBatchItemMapper.java
new file mode 100644
index 000000000..f88e62c93
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/AiFeedBatchItemMapper.java
@@ -0,0 +1,9 @@
+package com.bsd.pjcloud.canteen.mapper;
+
+import com.bsd.pjcloud.canteen.entity.AiFeedBatchItemEntity;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AiFeedBatchItemMapper extends PjcloudBaseMapper {
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/AiFeedBatchMapper.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/AiFeedBatchMapper.java
new file mode 100644
index 000000000..a71c39577
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/mapper/AiFeedBatchMapper.java
@@ -0,0 +1,9 @@
+package com.bsd.pjcloud.canteen.mapper;
+
+import com.bsd.pjcloud.canteen.entity.AiFeedBatchEntity;
+import com.bsd.pjcloud.common.data.datascope.PjcloudBaseMapper;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface AiFeedBatchMapper extends PjcloudBaseMapper {
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/AiFeedImportService.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/AiFeedImportService.java
new file mode 100644
index 000000000..1bd4d6637
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/AiFeedImportService.java
@@ -0,0 +1,27 @@
+package com.bsd.pjcloud.canteen.service;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.common.core.util.R;
+import org.springframework.web.multipart.MultipartFile;
+
+public interface AiFeedImportService {
+
+ /**
+ * 单一 JSONL:每行带 type=user|reserve|offline。
+ * 仅需商户;用户部门取商户绑定部门。
+ * 接口快速返回 RUNNING 批次,后台异步落库;前端轮询批次状态。
+ */
+ R importJsonl(MultipartFile file, Long merchantId);
+
+ R pageBatches(Page page, String status);
+
+ R getBatch(Long batchId);
+
+ /** 批次行明细(成功 USER/ORDER + 跳过 SKIP,含失败原因) */
+ R listItems(Long batchId);
+
+ R deleteBatch(Long batchId);
+
+ /** 统一混合模板内容 */
+ String templateContent();
+}
diff --git a/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/AiFeedImportServiceImpl.java b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/AiFeedImportServiceImpl.java
new file mode 100644
index 000000000..5969acc59
--- /dev/null
+++ b/pjcloud-canteen/pjcloud-canteen-biz/src/main/java/com/bsd/pjcloud/canteen/service/impl/AiFeedImportServiceImpl.java
@@ -0,0 +1,854 @@
+package com.bsd.pjcloud.canteen.service.impl;
+
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONObject;
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.bsd.pjcloud.admin.api.dto.AiFeedUserBatchResultDTO;
+import com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO;
+import com.bsd.pjcloud.admin.api.feign.RemoteUserService;
+import com.bsd.pjcloud.canteen.entity.AiFeedBatchEntity;
+import com.bsd.pjcloud.canteen.entity.AiFeedBatchItemEntity;
+import com.bsd.pjcloud.canteen.mapper.AiFeedBatchItemMapper;
+import com.bsd.pjcloud.canteen.mapper.AiFeedBatchMapper;
+import com.bsd.pjcloud.canteen.service.AiFeedImportService;
+import com.bsd.pjcloud.canteen.util.CanteenMerchantScopeHelper;
+import com.bsd.pjcloud.common.core.util.R;
+import com.bsd.pjcloud.common.data.tenant.TenantContextHolder;
+import com.bsd.pjcloud.common.security.service.PjcloudUser;
+import com.bsd.pjcloud.common.security.util.SecurityUtils;
+import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderBatchResultDTO;
+import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO;
+import com.bsd.pjcloud.hard.api.feign.RemoteHardOrderService;
+import com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureDTO;
+import com.bsd.pjcloud.wallet.api.dto.AiFeedWalletEnsureResultDTO;
+import com.bsd.pjcloud.wallet.api.feign.RemoteWalletService;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.web.multipart.MultipartFile;
+
+import java.io.BufferedReader;
+import java.io.ByteArrayInputStream;
+import java.io.InputStreamReader;
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class AiFeedImportServiceImpl implements AiFeedImportService {
+
+ /** 批次类型:统一混合文件 */
+ private static final String FEED_MIXED = "MIXED";
+ private static final String ROW_USER = "user";
+ private static final String ROW_RESERVE = "reserve";
+ private static final String ROW_OFFLINE = "offline";
+ private static final String ITEM_USER = "USER";
+ private static final String ITEM_ORDER = "ORDER";
+ /** 行级跳过/失败,refId=行号,extra_json.reason 为原因 */
+ private static final String ITEM_SKIP = "SKIP";
+ private static final int MAX_ERROR_LEN = 1800;
+ private static final int MAX_REASON_LEN = 400;
+ /** 建用户远程分片 */
+ private static final int USER_CHUNK = 100;
+ /** 开通饭卡:单次尽量多,避免「开通饭卡 1/50」那种一片片爬 */
+ private static final int WALLET_CHUNK = 500;
+ private static final int ORDER_CHUNK = 100;
+ /** 单次远程调用最长等待(大包开饭卡给足时间) */
+ private static final long REMOTE_TIMEOUT_MS = 180_000L;
+ private static final DateTimeFormatter BATCH_NO_FMT = DateTimeFormatter.ofPattern("yyyyMMddHHmmss");
+ /** 同时只跑一个导入,避免两批抢 upms/wallet 导致超时 */
+ private static final ExecutorService AI_FEED_EXECUTOR = Executors.newSingleThreadExecutor(r -> {
+ Thread t = new Thread(r, "ai-feed-import");
+ t.setDaemon(true);
+ return t;
+ });
+ private static final ExecutorService AI_FEED_REMOTE_EXECUTOR = Executors.newCachedThreadPool(r -> {
+ Thread t = new Thread(r, "ai-feed-remote");
+ t.setDaemon(true);
+ return t;
+ });
+
+ private final AiFeedBatchMapper batchMapper;
+ private final AiFeedBatchItemMapper itemMapper;
+ private final CanteenMerchantScopeHelper merchantScopeHelper;
+ private final RemoteUserService remoteUserService;
+ private final RemoteWalletService remoteWalletService;
+ private final RemoteHardOrderService remoteHardOrderService;
+ private final ObjectMapper objectMapper;
+
+ @Override
+ public R importJsonl(MultipartFile file, Long merchantId) {
+ if (file == null || file.isEmpty()) {
+ return R.failed("请上传 JSONL 文件");
+ }
+ if (merchantId == null) {
+ return R.failed("请选择商户");
+ }
+ Long runningCnt = batchMapper.selectCount(Wrappers.lambdaQuery()
+ .eq(AiFeedBatchEntity::getStatus, "RUNNING"));
+ if (runningCnt != null && runningCnt > 0) {
+ return R.failed("已有导入任务进行中,请等待完成;若长时间卡住请先「清理本批」后再导");
+ }
+ final Long deptId;
+ try {
+ deptId = merchantScopeHelper.requireDeptIdByMerchantId(merchantId);
+ }
+ catch (IllegalArgumentException ex) {
+ return R.failed(ex.getMessage());
+ }
+
+ final byte[] fileBytes;
+ try {
+ // Multipart 临时文件请求结束后可能失效,先读入内存再异步处理
+ fileBytes = file.getBytes();
+ }
+ catch (Exception ex) {
+ return R.failed("读取上传文件失败: " + StrUtil.blankToDefault(ex.getMessage(), "unknown"));
+ }
+
+ PjcloudUser op = SecurityUtils.getUser();
+ AiFeedBatchEntity batch = new AiFeedBatchEntity();
+ batch.setBatchNo("AIFEED" + LocalDateTime.now().format(BATCH_NO_FMT) + (int) (Math.random() * 900 + 100));
+ batch.setDeptId(deptId);
+ batch.setMerchantId(merchantId);
+ batch.setFileName(file.getOriginalFilename());
+ batch.setFeedType(FEED_MIXED);
+ batch.setStatus("RUNNING");
+ batch.setTotalRows(0);
+ batch.setSuccessRows(0);
+ batch.setSkipRows(0);
+ batch.setOperator(op == null ? null : op.getUsername());
+ batch.setOperatorName(op == null ? null : op.getName());
+ batchMapper.insert(batch);
+
+ Long tenantId = TenantContextHolder.getTenantId();
+ Long batchId = batch.getId();
+ CompletableFuture.runAsync(() -> runImportAsync(batchId, fileBytes, deptId, merchantId, tenantId), AI_FEED_EXECUTOR);
+ return R.ok(batch);
+ }
+
+ /** 后台异步导入;恢复租户上下文供 Feign/MyBatis 多租户使用 */
+ private void runImportAsync(Long batchId, byte[] fileBytes, Long deptId, Long merchantId, Long tenantId) {
+ Long previousTenant = TenantContextHolder.getTenantId();
+ try {
+ if (tenantId != null) {
+ TenantContextHolder.setTenantId(tenantId);
+ }
+ AiFeedBatchEntity batch = batchMapper.selectById(batchId);
+ if (batch == null) {
+ return;
+ }
+ processImportLines(batch, fileBytes, deptId, merchantId);
+ }
+ catch (Throwable ex) {
+ log.error("AI喂数异步导入失败 batchId={}", batchId, ex);
+ try {
+ AiFeedBatchEntity failed = batchMapper.selectById(batchId);
+ if (failed != null) {
+ failed.setStatus("FAILED");
+ failed.setErrorMsg(trimErr(ex.getMessage()));
+ batchMapper.updateById(failed);
+ }
+ }
+ catch (Exception updateEx) {
+ log.warn("AI喂数异步失败回写状态异常 batchId={}", batchId, updateEx);
+ }
+ }
+ finally {
+ if (previousTenant != null) {
+ TenantContextHolder.setTenantId(previousTenant);
+ }
+ else {
+ TenantContextHolder.clear();
+ }
+ }
+ }
+
+ /**
+ * 分阶段批量导入:解析 → 批量建用户 → 批量开饭卡/设余额 → 批量建订单。
+ * 避免「一行多次 Feign」导致千行级导入耗时数小时。
+ */
+ private void processImportLines(AiFeedBatchEntity batch, byte[] fileBytes, Long deptId, Long merchantId) {
+ long start = System.currentTimeMillis();
+ List userRows = new ArrayList<>();
+ List orderRows = new ArrayList<>();
+ List skipItems = new ArrayList<>();
+ StringBuilder errSample = new StringBuilder();
+ int total = 0;
+ int seq = 0;
+
+ try (BufferedReader reader = new BufferedReader(
+ new InputStreamReader(new ByteArrayInputStream(fileBytes), StandardCharsets.UTF_8))) {
+ String line;
+ while ((line = reader.readLine()) != null) {
+ if (StrUtil.isBlank(line) || line.trim().startsWith("#")) {
+ continue;
+ }
+ total++;
+ seq++;
+ String phoneHint = null;
+ String rowTypeHint = null;
+ try {
+ JsonNode node = objectMapper.readTree(line);
+ rowTypeHint = normalizeRowType(text(node, "type"));
+ phoneHint = text(node, "phone");
+ if (ROW_USER.equals(rowTypeHint)) {
+ userRows.add(parseUserRow(node, deptId, total));
+ }
+ else if (ROW_RESERVE.equals(rowTypeHint) || ROW_OFFLINE.equals(rowTypeHint)) {
+ orderRows.add(parseOrderRow(node, rowTypeHint, deptId, merchantId, seq, total, batch.getBatchNo()));
+ }
+ else {
+ throw new IllegalArgumentException("type 必须是 user / reserve / offline");
+ }
+ }
+ catch (Exception ex) {
+ String reason = StrUtil.blankToDefault(ex.getMessage(), ex.getClass().getSimpleName());
+ skipItems.add(buildSkipItem(batch.getId(), total, rowTypeHint, phoneHint, reason, line));
+ appendErr(errSample, total, reason);
+ }
+ }
+ }
+ catch (Exception ex) {
+ batch.setStatus("FAILED");
+ batch.setErrorMsg(trimErr(ex.getMessage()));
+ batch.setTotalRows(total);
+ batch.setSuccessRows(0);
+ batch.setSkipRows(skipItems.size());
+ batch.setCostMs(System.currentTimeMillis() - start);
+ batchMapper.updateById(batch);
+ log.error("AI喂数解析失败 batch={}", batch.getBatchNo(), ex);
+ return;
+ }
+
+ flushProgress(batch, total, 0, skipItems.size(), start, "解析完成,准备建用户");
+ Map userCache = new HashMap<>();
+ List successItems = new ArrayList<>();
+
+ // 1) 批量建用户
+ Map userByPhone = new HashMap<>();
+ for (ParsedUserRow ur : userRows) {
+ userByPhone.putIfAbsent(ur.phone(), ur);
+ }
+ List createUsers = userByPhone.values().stream().map(ur -> {
+ AiFeedUserCreateDTO dto = new AiFeedUserCreateDTO();
+ dto.setPhone(ur.phone());
+ dto.setName(ur.name());
+ dto.setDeptId(ur.deptId());
+ return dto;
+ }).toList();
+ int userChunkIdx = 0;
+ int userChunkTotal = Math.max(1, (createUsers.size() + USER_CHUNK - 1) / USER_CHUNK);
+ for (List chunk : chunks(createUsers, USER_CHUNK)) {
+ userChunkIdx++;
+ flushProgress(batch, total, successItems.size(), skipItems.size(), start,
+ "创建用户 " + userChunkIdx + "/" + userChunkTotal + "(本批" + chunk.size() + ")");
+ log.info("AI喂数创建用户 batch={} chunk={}/{} size={}", batch.getBatchNo(), userChunkIdx, userChunkTotal, chunk.size());
+ try {
+ R ur = callRemote("创建用户",
+ () -> remoteUserService.aiFeedCreateUsersBatch(chunk));
+ if (ur == null || !ur.isOk() || ur.getData() == null || ur.getData().getItems() == null) {
+ throw new IllegalArgumentException(ur == null ? "批量创建用户失败" : StrUtil.blankToDefault(ur.getMsg(), "批量创建用户失败"));
+ }
+ for (AiFeedUserBatchResultDTO.Item it : ur.getData().getItems()) {
+ if (it == null || StrUtil.isBlank(it.getPhone()) || it.getUserId() == null) {
+ continue;
+ }
+ ParsedUserRow src = userByPhone.get(it.getPhone());
+ userCache.put(it.getPhone(), new FeedUserCtx(it.getUserId(),
+ src == null ? deptId : src.deptId(), null));
+ }
+ }
+ catch (Exception ex) {
+ log.error("AI喂数创建用户失败 batch={} chunk={}/{}", batch.getBatchNo(), userChunkIdx, userChunkTotal, ex);
+ String reason = "批量创建用户: " + StrUtil.blankToDefault(ex.getMessage(), "失败");
+ for (AiFeedUserCreateDTO u : chunk) {
+ ParsedUserRow src = userByPhone.get(u.getPhone());
+ int lineNo = src == null ? 0 : src.lineNo();
+ skipItems.add(buildSkipItem(batch.getId(), lineNo, ROW_USER, u.getPhone(), reason, null));
+ appendErr(errSample, lineNo, reason);
+ }
+ }
+ }
+
+ // 1b) 订单行引用但本文件无 user 行的手机号:仅批量查已有用户
+ List orderOnlyPhones = orderRows.stream()
+ .map(ParsedOrderRow::phone)
+ .filter(StrUtil::isNotBlank)
+ .distinct()
+ .filter(p -> !userCache.containsKey(p))
+ .toList();
+ if (!orderOnlyPhones.isEmpty()) {
+ List lookupUsers = orderOnlyPhones.stream().map(p -> {
+ AiFeedUserCreateDTO dto = new AiFeedUserCreateDTO();
+ dto.setPhone(p);
+ return dto;
+ }).toList();
+ int lookupIdx = 0;
+ int lookupTotal = Math.max(1, (lookupUsers.size() + USER_CHUNK - 1) / USER_CHUNK);
+ for (List chunk : chunks(lookupUsers, USER_CHUNK)) {
+ lookupIdx++;
+ flushProgress(batch, total, successItems.size(), skipItems.size(), start,
+ "匹配已有用户 " + lookupIdx + "/" + lookupTotal);
+ try {
+ R ur = callRemote("匹配用户",
+ () -> remoteUserService.aiFeedCreateUsersBatch(chunk));
+ if (ur != null && ur.isOk() && ur.getData() != null && ur.getData().getItems() != null) {
+ for (AiFeedUserBatchResultDTO.Item it : ur.getData().getItems()) {
+ if (it != null && StrUtil.isNotBlank(it.getPhone()) && it.getUserId() != null) {
+ userCache.put(it.getPhone(), new FeedUserCtx(it.getUserId(), deptId, null));
+ }
+ }
+ }
+ }
+ catch (Exception ex) {
+ log.warn("AI喂数批量查用户失败 batch={}: {}", batch.getBatchNo(), ex.getMessage());
+ }
+ }
+ }
+
+ // 2) 先查已有饭卡;已有则直接复用,只给「还没有饭卡」的用户开一次(余额仅新建时写入)
+ Map balanceByUserId = new HashMap<>();
+ for (ParsedUserRow ur : userByPhone.values()) {
+ FeedUserCtx ctx = userCache.get(ur.phone());
+ if (ctx != null) {
+ balanceByUserId.put(ctx.userId(), ur.balanceFen());
+ }
+ }
+ Map walletByUser = new HashMap<>();
+ List allUserIds = userCache.values().stream()
+ .map(FeedUserCtx::userId)
+ .filter(Objects::nonNull)
+ .distinct()
+ .toList();
+ flushProgress(batch, total, successItems.size(), skipItems.size(), start,
+ "查询已有饭卡(" + allUserIds.size() + "人)");
+ for (List idChunk : chunks(allUserIds, 500)) {
+ try {
+ R> existR = callRemote("查询饭卡",
+ () -> remoteWalletService.getWalletsByUserIds(idChunk,
+ com.bsd.pjcloud.wallet.api.constant.WalletTypeEnum.EAT));
+ if (existR != null && existR.isOk() && existR.getData() != null) {
+ for (com.bsd.pjcloud.wallet.api.entity.WalletManageEntity w : existR.getData()) {
+ if (w != null && w.getUserId() != null && w.getId() != null) {
+ walletByUser.put(w.getUserId(), w.getId());
+ }
+ }
+ }
+ }
+ catch (Exception ex) {
+ log.warn("AI喂数查询已有饭卡失败 batch={}: {}", batch.getBatchNo(), ex.getMessage());
+ }
+ }
+ List walletReqs = new ArrayList<>();
+ for (FeedUserCtx ctx : userCache.values()) {
+ if (ctx == null || ctx.userId() == null) {
+ continue;
+ }
+ if (walletByUser.containsKey(ctx.userId())) {
+ continue;
+ }
+ AiFeedWalletEnsureDTO wr = new AiFeedWalletEnsureDTO();
+ wr.setUserId(ctx.userId());
+ wr.setDeptId(ctx.deptId());
+ if (balanceByUserId.containsKey(ctx.userId())) {
+ wr.setBalanceFen(balanceByUserId.get(ctx.userId()));
+ }
+ walletReqs.add(wr);
+ }
+ if (walletReqs.isEmpty()) {
+ flushProgress(batch, total, successItems.size(), skipItems.size(), start,
+ "饭卡已齐全,跳过开通(" + walletByUser.size() + "人)");
+ }
+ else {
+ flushProgress(batch, total, successItems.size(), skipItems.size(), start,
+ "批量开通饭卡(待开" + walletReqs.size() + "人,已有" + walletByUser.size() + ")");
+ log.info("AI喂数开通饭卡 batch={} needOpen={} already={}", batch.getBatchNo(), walletReqs.size(),
+ walletByUser.size());
+ for (List chunk : chunks(walletReqs, WALLET_CHUNK)) {
+ try {
+ R wr = callRemote("开通饭卡",
+ () -> remoteWalletService.aiFeedEnsureEatWalletsBatch(chunk));
+ if (wr == null || !wr.isOk() || wr.getData() == null || wr.getData().getItems() == null) {
+ throw new IllegalArgumentException(wr == null ? "批量开通饭卡失败"
+ : StrUtil.blankToDefault(wr.getMsg(), "批量开通饭卡失败"));
+ }
+ for (AiFeedWalletEnsureResultDTO.Item it : wr.getData().getItems()) {
+ if (it != null && it.getUserId() != null && it.getWalletId() != null) {
+ walletByUser.put(it.getUserId(), it.getWalletId());
+ }
+ }
+ flushProgress(batch, total, successItems.size(), skipItems.size(), start,
+ "开通饭卡进行中(已有" + walletByUser.size() + "/" + allUserIds.size() + ")");
+ }
+ catch (Exception ex) {
+ log.error("AI喂数开通饭卡失败 batch={} size={}", batch.getBatchNo(), chunk.size(), ex);
+ String reason = "批量开通饭卡: " + StrUtil.blankToDefault(ex.getMessage(), "失败");
+ for (AiFeedWalletEnsureDTO w : chunk) {
+ appendErr(errSample, 0, reason + " userId=" + w.getUserId());
+ }
+ }
+ }
+ }
+ for (Map.Entry e : new ArrayList<>(userCache.entrySet())) {
+ Long walletId = walletByUser.get(e.getValue().userId());
+ userCache.put(e.getKey(), new FeedUserCtx(e.getValue().userId(), e.getValue().deptId(), walletId));
+ }
+ for (ParsedUserRow ur : userRows) {
+ FeedUserCtx ctx = userCache.get(ur.phone());
+ if (ctx == null || ctx.walletId() == null) {
+ skipItems.add(buildSkipItem(batch.getId(), ur.lineNo(), ROW_USER, ur.phone(), "开通饭卡钱包失败", null));
+ appendErr(errSample, ur.lineNo(), "开通饭卡钱包失败");
+ continue;
+ }
+ successItems.add(buildUserItem(batch.getId(), ur, ctx));
+ }
+ flushProgress(batch, total, successItems.size(), skipItems.size(), start, "用户处理完成,准备建订单");
+
+ // 3) 批量建订单(演示不再逐单扣款,避免再打钱包服务)
+ List orderDtos = new ArrayList<>();
+ List orderReady = new ArrayList<>();
+ for (ParsedOrderRow or : orderRows) {
+ FeedUserCtx ctx = userCache.get(or.phone());
+ if (ctx == null) {
+ skipItems.add(buildSkipItem(batch.getId(), or.lineNo(), or.rowType(), or.phone(),
+ "用户不存在,请先在同文件写 user 行或已导入: " + or.phone(), null));
+ appendErr(errSample, or.lineNo(), "用户不存在: " + or.phone());
+ continue;
+ }
+ or.dto().setUserId(ctx.userId());
+ or.dto().setUserDeptId(ctx.deptId());
+ or.dto().setWalletId(ctx.walletId());
+ orderDtos.add(or.dto());
+ orderReady.add(or);
+ }
+ Map orderIdBySeq = new HashMap<>();
+ Map orderErrBySeq = new HashMap<>();
+ int orderChunkTotal = Math.max(1, (orderDtos.size() + ORDER_CHUNK - 1) / ORDER_CHUNK);
+ for (int i = 0; i < orderDtos.size(); i += ORDER_CHUNK) {
+ List chunk = orderDtos.subList(i, Math.min(i + ORDER_CHUNK, orderDtos.size()));
+ int orderChunkIdx = i / ORDER_CHUNK + 1;
+ flushProgress(batch, total, successItems.size() + orderIdBySeq.size(), skipItems.size(), start,
+ "创建订单 " + orderChunkIdx + "/" + orderChunkTotal + "(本批" + chunk.size() + ")");
+ log.info("AI喂数创建订单 batch={} chunk={}/{} size={}", batch.getBatchNo(), orderChunkIdx, orderChunkTotal, chunk.size());
+ try {
+ R or = callRemote("创建订单",
+ () -> remoteHardOrderService.aiFeedCreateOrdersBatch(chunk));
+ if (or == null || !or.isOk() || or.getData() == null || or.getData().getItems() == null) {
+ throw new IllegalArgumentException(or == null ? "批量创建订单失败" : StrUtil.blankToDefault(or.getMsg(), "批量创建订单失败"));
+ }
+ for (AiFeedOrderBatchResultDTO.Item it : or.getData().getItems()) {
+ if (it == null || it.getSeq() == null) {
+ continue;
+ }
+ if (it.getOrderId() != null) {
+ orderIdBySeq.put(it.getSeq(), it.getOrderId());
+ }
+ else {
+ orderErrBySeq.put(it.getSeq(), StrUtil.blankToDefault(it.getError(), "创建订单失败"));
+ }
+ }
+ }
+ catch (Exception ex) {
+ log.error("AI喂数创建订单失败 batch={} chunk={}/{}", batch.getBatchNo(), orderChunkIdx, orderChunkTotal, ex);
+ String reason = "批量创建订单: " + StrUtil.blankToDefault(ex.getMessage(), "失败");
+ for (AiFeedOrderCreateDTO o : chunk) {
+ orderErrBySeq.put(o.getSeq(), reason);
+ }
+ }
+ }
+ for (ParsedOrderRow or : orderReady) {
+ Long orderId = orderIdBySeq.get(or.dto().getSeq());
+ if (orderId == null) {
+ String reason = orderErrBySeq.getOrDefault(or.dto().getSeq(), "创建订单失败");
+ skipItems.add(buildSkipItem(batch.getId(), or.lineNo(), or.rowType(), or.phone(), reason, null));
+ appendErr(errSample, or.lineNo(), reason);
+ continue;
+ }
+ FeedUserCtx ctx = userCache.get(or.phone());
+ successItems.add(buildOrderItem(batch.getId(), or, orderId, ctx == null ? null : ctx.walletId()));
+ }
+
+ flushProgress(batch, total, successItems.size(), skipItems.size(), start, "落库明细中");
+ if (!successItems.isEmpty()) {
+ for (AiFeedBatchItemEntity item : successItems) {
+ itemMapper.insert(item);
+ }
+ }
+ if (!skipItems.isEmpty()) {
+ for (AiFeedBatchItemEntity item : skipItems) {
+ itemMapper.insert(item);
+ }
+ }
+
+ int success = successItems.size();
+ int skip = skipItems.size();
+ batch.setStatus(skip > 0 && success == 0 ? "FAILED" : "SUCCESS");
+ if (errSample.length() > 0) {
+ batch.setErrorMsg(trimErr(errSample.toString()));
+ }
+ else {
+ batch.setErrorMsg(null);
+ }
+ batch.setTotalRows(total);
+ batch.setSuccessRows(success);
+ batch.setSkipRows(skip);
+ batch.setCostMs(System.currentTimeMillis() - start);
+ batchMapper.updateById(batch);
+ log.info("AI喂数批量导入完成 batch={} total={} success={} skip={} costMs={}",
+ batch.getBatchNo(), total, success, skip, batch.getCostMs());
+ }
+
+ /**
+ * 远程调用加超时:避免 Feign 卡住导致批次永久 RUNNING、耗时定格。
+ * 必须把租户上下文带到远程线程,否则 Feign/MyBatis 多租户会异常或全表扫描。
+ */
+ private T callRemote(String action, java.util.concurrent.Callable callable) throws Exception {
+ Long tenantId = TenantContextHolder.getTenantId();
+ try {
+ return CompletableFuture.supplyAsync(() -> {
+ try {
+ if (tenantId != null) {
+ TenantContextHolder.setTenantId(tenantId);
+ }
+ return callable.call();
+ }
+ catch (Exception ex) {
+ throw new RuntimeException(ex);
+ }
+ finally {
+ TenantContextHolder.clear();
+ }
+ }, AI_FEED_REMOTE_EXECUTOR).get(REMOTE_TIMEOUT_MS, java.util.concurrent.TimeUnit.MILLISECONDS);
+ }
+ catch (java.util.concurrent.TimeoutException ex) {
+ throw new IllegalStateException(action + "超时(>" + (REMOTE_TIMEOUT_MS / 1000)
+ + "s),请确认 upms/wallet/hardware 已重启且 /inner/ai-feed-* 接口可用");
+ }
+ catch (java.util.concurrent.ExecutionException ex) {
+ Throwable cause = ex.getCause();
+ if (cause instanceof RuntimeException && cause.getCause() instanceof Exception nested) {
+ throw (Exception) nested;
+ }
+ if (cause instanceof Exception e) {
+ throw e;
+ }
+ throw ex;
+ }
+ }
+
+ private void flushProgress(AiFeedBatchEntity batch, int total, int success, int skip, long startMs, String stage) {
+ batch.setTotalRows(total);
+ batch.setSuccessRows(success);
+ batch.setSkipRows(skip);
+ batch.setCostMs(System.currentTimeMillis() - startMs);
+ if (StrUtil.isNotBlank(stage)) {
+ batch.setErrorMsg(stage);
+ }
+ batchMapper.updateById(batch);
+ }
+
+ private ParsedUserRow parseUserRow(JsonNode node, Long deptId, int lineNo) {
+ String phone = text(node, "phone");
+ String name = text(node, "name");
+ if (StrUtil.isBlank(phone) || StrUtil.isBlank(name)) {
+ throw new IllegalArgumentException("user 行 phone/name 必填");
+ }
+ return new ParsedUserRow(lineNo, phone, name, deptId, yuanToFen(node.get("balance_yuan")));
+ }
+
+ private ParsedOrderRow parseOrderRow(JsonNode node, String rowType, Long deptId, Long merchantId, int seq,
+ int lineNo, String batchNo) {
+ String phone = text(node, "phone");
+ if (StrUtil.isBlank(phone)) {
+ throw new IllegalArgumentException("订单行 phone 必填");
+ }
+ Long lineMerchant = node.hasNonNull("merchant_id") ? node.get("merchant_id").asLong() : merchantId;
+ AiFeedOrderCreateDTO dto = new AiFeedOrderCreateDTO();
+ dto.setBatchNo(batchNo);
+ dto.setSeq(seq);
+ dto.setOrderType(ROW_OFFLINE.equals(rowType) ? "OFFLINE" : "RESERVE");
+ dto.setUserDeptId(deptId);
+ dto.setMerchantId(lineMerchant);
+ dto.setMeal(text(node, "meal"));
+ dto.setBizDate(text(node, "biz_date"));
+ dto.setTotalFen(yuanToFen(node.get("total_yuan")));
+ dto.setProductDesc(text(node, "product_desc"));
+ List items = new ArrayList<>();
+ JsonNode arr = node.get("items");
+ if (arr != null && arr.isArray()) {
+ for (JsonNode it : arr) {
+ AiFeedOrderCreateDTO.Item item = new AiFeedOrderCreateDTO.Item();
+ if (it.hasNonNull("spu_id")) {
+ item.setSpuId(it.get("spu_id").asLong());
+ }
+ if (it.hasNonNull("menu_id")) {
+ item.setMenuId(it.get("menu_id").asLong());
+ }
+ item.setName(text(it, "name"));
+ item.setQty(it.hasNonNull("qty") ? it.get("qty").asInt() : 1);
+ item.setPriceFen(yuanToFen(it.get("price_yuan")));
+ item.setPicUrl(text(it, "pic_url"));
+ items.add(item);
+ }
+ }
+ if (items.isEmpty()) {
+ throw new IllegalArgumentException("订单明细不能为空");
+ }
+ dto.setItems(items);
+ return new ParsedOrderRow(lineNo, phone, rowType, dto);
+ }
+
+ private AiFeedBatchItemEntity buildUserItem(Long batchId, ParsedUserRow ur, FeedUserCtx ctx) {
+ AiFeedBatchItemEntity item = new AiFeedBatchItemEntity();
+ item.setBatchId(batchId);
+ item.setItemType(ITEM_USER);
+ item.setRefId(ctx.userId());
+ item.setBizKey(ur.phone());
+ item.setExtraJson(JSONUtil.createObj()
+ .set("lineNo", ur.lineNo())
+ .set("rowType", ROW_USER)
+ .set("name", ur.name())
+ .set("walletId", ctx.walletId())
+ .set("balanceFen", ur.balanceFen())
+ .set("status", "SUCCESS")
+ .toString());
+ return item;
+ }
+
+ private AiFeedBatchItemEntity buildOrderItem(Long batchId, ParsedOrderRow or, Long orderId, Long walletId) {
+ AiFeedBatchItemEntity item = new AiFeedBatchItemEntity();
+ item.setBatchId(batchId);
+ item.setItemType(ITEM_ORDER);
+ item.setRefId(orderId);
+ item.setBizKey("AIFEED-" + or.dto().getBatchNo() + "-" + or.dto().getSeq());
+ item.setExtraJson(JSONUtil.createObj()
+ .set("lineNo", or.lineNo())
+ .set("phone", or.phone())
+ .set("rowType", or.rowType())
+ .set("walletId", walletId)
+ .set("status", "SUCCESS")
+ .toString());
+ return item;
+ }
+
+ private static void appendErr(StringBuilder errSample, int lineNo, String reason) {
+ if (errSample.length() >= MAX_ERROR_LEN) {
+ return;
+ }
+ errSample.append("L").append(lineNo).append(": ").append(reason).append("; ");
+ }
+
+ private static List> chunks(List source, int size) {
+ List> out = new ArrayList<>();
+ if (source == null || source.isEmpty()) {
+ return out;
+ }
+ for (int i = 0; i < source.size(); i += size) {
+ out.add(source.subList(i, Math.min(i + size, source.size())));
+ }
+ return out;
+ }
+
+ private record FeedUserCtx(Long userId, Long deptId, Long walletId) {
+ }
+
+ private record ParsedUserRow(int lineNo, String phone, String name, Long deptId, int balanceFen) {
+ }
+
+ private record ParsedOrderRow(int lineNo, String phone, String rowType, AiFeedOrderCreateDTO dto) {
+ }
+
+ /** 跳过/失败行明细(批量落库前先构造) */
+ private AiFeedBatchItemEntity buildSkipItem(Long batchId, int lineNo, String rowType, String phone, String reason,
+ String rawLine) {
+ AiFeedBatchItemEntity item = new AiFeedBatchItemEntity();
+ item.setBatchId(batchId);
+ item.setItemType(ITEM_SKIP);
+ item.setRefId((long) Math.max(lineNo, 0));
+ item.setBizKey(StrUtil.blankToDefault(phone, "L" + lineNo));
+ JSONObject extra = JSONUtil.createObj()
+ .set("lineNo", lineNo)
+ .set("rowType", rowType)
+ .set("reason", StrUtil.maxLength(reason, MAX_REASON_LEN))
+ .set("status", "SKIP");
+ if (StrUtil.isNotBlank(rawLine)) {
+ extra.set("raw", StrUtil.maxLength(rawLine.trim(), 200));
+ }
+ item.setExtraJson(extra.toString());
+ return item;
+ }
+
+ @Override
+ public R pageBatches(Page page, String status) {
+ Page result = batchMapper.selectPage(page, Wrappers.lambdaQuery()
+ .eq(StrUtil.isNotBlank(status), AiFeedBatchEntity::getStatus, status)
+ .orderByDesc(AiFeedBatchEntity::getCreateTime));
+ return R.ok(result);
+ }
+
+ @Override
+ public R getBatch(Long batchId) {
+ AiFeedBatchEntity batch = batchMapper.selectById(batchId);
+ if (batch == null) {
+ return R.failed("批次不存在");
+ }
+ return R.ok(batch);
+ }
+
+ @Override
+ public R listItems(Long batchId) {
+ AiFeedBatchEntity batch = batchMapper.selectById(batchId);
+ if (batch == null) {
+ return R.failed("批次不存在");
+ }
+ List items = itemMapper.selectList(Wrappers.lambdaQuery()
+ .eq(AiFeedBatchItemEntity::getBatchId, batchId)
+ .orderByAsc(AiFeedBatchItemEntity::getId));
+ items.sort(Comparator.comparingInt(this::resolveLineNo));
+ return R.ok(items);
+ }
+
+ private int resolveLineNo(AiFeedBatchItemEntity item) {
+ if (item == null) {
+ return 0;
+ }
+ try {
+ if (StrUtil.isNotBlank(item.getExtraJson())) {
+ Integer lineNo = JSONUtil.parseObj(item.getExtraJson()).getInt("lineNo");
+ if (lineNo != null && lineNo > 0) {
+ return lineNo;
+ }
+ }
+ }
+ catch (Exception ignored) {
+ // ignore
+ }
+ if (ITEM_SKIP.equals(item.getItemType()) && item.getRefId() != null) {
+ return item.getRefId().intValue();
+ }
+ return 0;
+ }
+
+ @Override
+ public R deleteBatch(Long batchId) {
+ AiFeedBatchEntity batch = batchMapper.selectById(batchId);
+ if (batch == null) {
+ return R.failed("批次不存在");
+ }
+ if ("DELETING".equals(batch.getStatus()) || "DELETED".equals(batch.getStatus())) {
+ return R.failed("批次已在清理或已删除");
+ }
+ batch.setStatus("DELETING");
+ batchMapper.updateById(batch);
+
+ List items = itemMapper.selectList(Wrappers.lambdaQuery()
+ .eq(AiFeedBatchItemEntity::getBatchId, batchId));
+ List userIds = items.stream().filter(i -> ITEM_USER.equals(i.getItemType()))
+ .map(AiFeedBatchItemEntity::getRefId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+ List orderIds = items.stream().filter(i -> ITEM_ORDER.equals(i.getItemType()))
+ .map(AiFeedBatchItemEntity::getRefId).filter(Objects::nonNull).distinct().collect(Collectors.toList());
+
+ StringBuilder err = new StringBuilder();
+ if (!orderIds.isEmpty()) {
+ try {
+ R delR = remoteHardOrderService.aiFeedDeleteOrders(orderIds);
+ if (delR == null || !delR.isOk()) {
+ err.append("订单清理: ").append(delR == null ? "失败" : delR.getMsg()).append("; ");
+ }
+ }
+ catch (Exception ex) {
+ err.append("订单清理异常: ").append(ex.getMessage()).append("; ");
+ }
+ }
+ if (!userIds.isEmpty()) {
+ try {
+ R delUserR = remoteUserService.aiFeedDeleteUsers(userIds);
+ if (delUserR == null || !delUserR.isOk()) {
+ err.append("用户清理: ").append(delUserR == null ? "失败" : delUserR.getMsg()).append("; ");
+ }
+ }
+ catch (Exception ex) {
+ err.append("用户清理异常: ").append(ex.getMessage()).append("; ");
+ }
+ }
+
+ batch.setStatus("DELETED");
+ if (err.length() > 0) {
+ batch.setErrorMsg(trimErr(StrUtil.blankToDefault(batch.getErrorMsg(), "") + " | 清理: " + err));
+ }
+ batchMapper.updateById(batch);
+ itemMapper.delete(Wrappers.lambdaQuery().eq(AiFeedBatchItemEntity::getBatchId, batchId));
+ return R.ok(batch);
+ }
+
+ @Override
+ public String templateContent() {
+ return """
+ {"type":"user","phone":"13800000001","name":"张三","balance_yuan":100.00}
+ {"type":"user","phone":"13800000002","name":"李四","balance_yuan":50.00}
+ {"type":"reserve","phone":"13800000001","biz_date":"2026-09-25","meal":"午餐","total_yuan":25.00,"items":[{"spu_id":"1928340001","menu_id":"88420011","name":"红烧肉","qty":1,"price_yuan":18},{"spu_id":"1928340002","name":"米饭","qty":1,"price_yuan":7}]}
+ {"type":"offline","phone":"13800000001","biz_date":"2026-09-25","meal":"午餐","total_yuan":18.00,"items":[{"spu_id":"1928340001","name":"红烧肉","qty":1,"price_yuan":18}]}
+ {"type":"offline","phone":"13800000002","biz_date":"2026-09-25","meal":"晚餐","total_yuan":12.00,"items":[{"spu_id":"1928340003","name":"番茄炒蛋","qty":1,"price_yuan":12}]}
+ """;
+ }
+
+ private static String normalizeRowType(String type) {
+ if (StrUtil.isBlank(type)) {
+ return null;
+ }
+ String t = type.trim().toLowerCase(Locale.ROOT);
+ if (ROW_USER.equals(t) || "用户".equals(type.trim())) {
+ return ROW_USER;
+ }
+ if (ROW_RESERVE.equals(t) || "预定".equals(type.trim()) || "预约".equals(type.trim())) {
+ return ROW_RESERVE;
+ }
+ if (ROW_OFFLINE.equals(t) || "线下".equals(type.trim()) || "安卓".equals(type.trim()) || "below".equals(t)) {
+ return ROW_OFFLINE;
+ }
+ return null;
+ }
+
+ private static String text(JsonNode node, String field) {
+ if (node == null || !node.has(field) || node.get(field).isNull()) {
+ return null;
+ }
+ String v = node.get(field).asText();
+ return StrUtil.isBlank(v) ? null : v.trim();
+ }
+
+ private static int yuanToFen(JsonNode node) {
+ if (node == null || node.isNull()) {
+ return 0;
+ }
+ BigDecimal yuan = node.isNumber() ? node.decimalValue() : new BigDecimal(node.asText("0"));
+ return yuan.multiply(BigDecimal.valueOf(100)).setScale(0, RoundingMode.HALF_UP).intValue();
+ }
+
+ private static String trimErr(String msg) {
+ if (msg == null) {
+ return null;
+ }
+ return msg.length() <= MAX_ERROR_LEN ? msg : msg.substring(0, MAX_ERROR_LEN);
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/remote/AiFeedOrderBatchResultDTO.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/remote/AiFeedOrderBatchResultDTO.java
new file mode 100644
index 000000000..4ec2480bd
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/remote/AiFeedOrderBatchResultDTO.java
@@ -0,0 +1,22 @@
+package com.bsd.pjcloud.hard.api.dto.order.remote;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@Schema(description = "AI喂数批量建订单结果")
+public class AiFeedOrderBatchResultDTO implements Serializable {
+
+ private List- items = new ArrayList<>();
+
+ @Data
+ public static class Item implements Serializable {
+ private Integer seq;
+ private Long orderId;
+ private String error;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/remote/AiFeedOrderCreateDTO.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/remote/AiFeedOrderCreateDTO.java
new file mode 100644
index 000000000..44031ce5d
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/dto/order/remote/AiFeedOrderCreateDTO.java
@@ -0,0 +1,58 @@
+package com.bsd.pjcloud.hard.api.dto.order.remote;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+@Schema(description = "AI喂数创建订单")
+public class AiFeedOrderCreateDTO implements Serializable {
+
+ @Schema(description = "批次号,用于生成 orderNo")
+ private String batchNo;
+
+ @Schema(description = "序号")
+ private Integer seq;
+
+ /** RESERVE 预定 / OFFLINE 线下安卓(BELOW) */
+ @Schema(description = "订单类型:RESERVE 或 OFFLINE(线下安卓机)")
+ private String orderType;
+
+ private Long userId;
+
+ private Long userDeptId;
+
+ private Long merchantId;
+
+ private Long merchantDeptId;
+
+ private Long walletId;
+
+ /** 餐段 1早餐 2午餐 3晚餐;也可传中文 */
+ private String meal;
+
+ /** yyyy-MM-dd,预定用餐日 */
+ private String bizDate;
+
+ /** 分 */
+ private Integer totalFen;
+
+ private String addressName;
+
+ private String productDesc;
+
+ private List
- items;
+
+ @Data
+ public static class Item implements Serializable {
+ private Long spuId;
+ private Long menuId;
+ private String name;
+ private Integer qty;
+ /** 分 */
+ private Integer priceFen;
+ private String picUrl;
+ }
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/AiFeedOrderService.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/AiFeedOrderService.java
new file mode 100644
index 000000000..085fdd72d
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/AiFeedOrderService.java
@@ -0,0 +1,26 @@
+package com.bsd.pjcloud.hard.service;
+
+import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO;
+
+import java.util.List;
+
+/**
+ * AI 喂数演示订单写入(精简落库,不做完整库存/MQ 链路)。
+ */
+public interface AiFeedOrderService {
+
+ /**
+ * @return 新建订单 ID
+ */
+ Long createDemoOrder(AiFeedOrderCreateDTO dto);
+
+ /**
+ * 批量创建演示订单(独立批量落库,减少 Feign 往返)
+ */
+ com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderBatchResultDTO createDemoOrdersBatch(List dtos);
+
+ /**
+ * 软删演示订单(仅处理 orderNo 以 AIFEED- 开头的记录)
+ */
+ int softDeleteByIds(List orderIds);
+}
diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/AiFeedOrderServiceImpl.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/AiFeedOrderServiceImpl.java
new file mode 100644
index 000000000..3d9d3af90
--- /dev/null
+++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/service/impl/order/AiFeedOrderServiceImpl.java
@@ -0,0 +1,283 @@
+package com.bsd.pjcloud.hard.service.impl.order;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import com.baomidou.mybatisplus.core.toolkit.Wrappers;
+import com.bsd.pjcloud.common.core.constant.enums.MerchantTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.HardProductNatureEnum;
+import com.bsd.pjcloud.hard.api.constant.HardProductSourceEnum;
+import com.bsd.pjcloud.hard.api.constant.HardTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardHourTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderStatusEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardOrderTypeEnum;
+import com.bsd.pjcloud.hard.api.constant.order.HardPayTypeEnum;
+import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderBatchResultDTO;
+import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO;
+import com.bsd.pjcloud.hard.api.entity.order.HardCarEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardOrderEntity;
+import com.bsd.pjcloud.hard.api.entity.order.HardParentCarEntity;
+import com.bsd.pjcloud.hard.service.AiFeedOrderService;
+import com.bsd.pjcloud.hard.service.HardCarService;
+import com.bsd.pjcloud.hard.service.HardOrderService;
+import com.bsd.pjcloud.hard.service.HardParentCarService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDate;
+import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+
+@Service
+@RequiredArgsConstructor
+public class AiFeedOrderServiceImpl implements AiFeedOrderService {
+
+ private static final String CREATE_BY = "ai-feed";
+ private static final DateTimeFormatter DATE_FMT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
+
+ private final HardParentCarService hardParentCarService;
+ private final HardCarService hardCarService;
+ private final HardOrderService hardOrderService;
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public Long createDemoOrder(AiFeedOrderCreateDTO dto) {
+ PreparedOrder prepared = prepareOrder(dto);
+ hardParentCarService.save(prepared.parent());
+ for (HardCarEntity car : prepared.cars()) {
+ car.setParentCarId(prepared.parent().getId());
+ }
+ hardCarService.saveBatch(prepared.cars());
+ prepared.order().setParentCarId(prepared.parent().getId());
+ hardOrderService.save(prepared.order());
+ return prepared.order().getId();
+ }
+
+ /**
+ * 批量建单:内存组装后分批 saveBatch,单条校验失败只记错误不影响同批其它单。
+ * 整批一个事务;DB 异常时该 Feign 分片整体回滚,由上层分片重试或记失败。
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public AiFeedOrderBatchResultDTO createDemoOrdersBatch(List dtos) {
+ AiFeedOrderBatchResultDTO result = new AiFeedOrderBatchResultDTO();
+ if (CollUtil.isEmpty(dtos)) {
+ return result;
+ }
+ List preparedList = new ArrayList<>();
+ for (AiFeedOrderCreateDTO dto : dtos) {
+ AiFeedOrderBatchResultDTO.Item item = new AiFeedOrderBatchResultDTO.Item();
+ item.setSeq(dto == null ? null : dto.getSeq());
+ try {
+ PreparedOrder prepared = prepareOrder(dto);
+ preparedList.add(prepared);
+ result.getItems().add(item);
+ }
+ catch (Exception ex) {
+ item.setError(StrUtil.blankToDefault(ex.getMessage(), "创建失败"));
+ result.getItems().add(item);
+ }
+ }
+ if (preparedList.isEmpty()) {
+ return result;
+ }
+
+ List parents = preparedList.stream().map(PreparedOrder::parent).toList();
+ hardParentCarService.saveBatch(parents, 100);
+
+ List allCars = new ArrayList<>();
+ List orders = new ArrayList<>();
+ for (PreparedOrder p : preparedList) {
+ Long parentId = p.parent().getId();
+ for (HardCarEntity car : p.cars()) {
+ car.setParentCarId(parentId);
+ allCars.add(car);
+ }
+ p.order().setParentCarId(parentId);
+ orders.add(p.order());
+ }
+ hardCarService.saveBatch(allCars, 200);
+ hardOrderService.saveBatch(orders, 100);
+
+ int okIdx = 0;
+ for (AiFeedOrderBatchResultDTO.Item item : result.getItems()) {
+ if (item.getError() != null) {
+ continue;
+ }
+ item.setOrderId(orders.get(okIdx).getId());
+ okIdx++;
+ }
+ return result;
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public int softDeleteByIds(List orderIds) {
+ if (CollUtil.isEmpty(orderIds)) {
+ return 0;
+ }
+ List list = hardOrderService.list(Wrappers.lambdaQuery()
+ .in(HardOrderEntity::getId, orderIds)
+ .likeRight(HardOrderEntity::getOrderNo, "AIFEED-"));
+ if (CollUtil.isEmpty(list)) {
+ return 0;
+ }
+ List ids = list.stream().map(HardOrderEntity::getId).toList();
+ List parentIds = list.stream().map(HardOrderEntity::getParentCarId).filter(id -> id != null).distinct().toList();
+ hardOrderService.removeByIds(ids);
+ if (CollUtil.isNotEmpty(parentIds)) {
+ hardCarService.remove(Wrappers.lambdaQuery().in(HardCarEntity::getParentCarId, parentIds));
+ hardParentCarService.removeByIds(parentIds);
+ }
+ return ids.size();
+ }
+
+ private PreparedOrder prepareOrder(AiFeedOrderCreateDTO dto) {
+ if (dto == null || dto.getUserId() == null || dto.getMerchantId() == null) {
+ throw new IllegalArgumentException("userId/merchantId 不能为空");
+ }
+ if (CollUtil.isEmpty(dto.getItems())) {
+ throw new IllegalArgumentException("订单明细不能为空");
+ }
+ boolean offline = isOffline(dto.getOrderType());
+ String productSource = offline
+ ? HardProductSourceEnum.CANTEEN_SPU.getType()
+ : HardProductSourceEnum.CANTEEN_DAILY_MENU.getType();
+ String hardType = offline ? HardTypeEnum.CANTEEN.getType() : HardTypeEnum.SELF_APP.getType();
+ String orderType = offline ? HardOrderTypeEnum.BELOW.getType() : HardOrderTypeEnum.RESERVE.getType();
+ String payType = offline ? HardPayTypeEnum.FACE.getType() : HardPayTypeEnum.WALLET_DEFAULT.getType();
+
+ HardParentCarEntity parent = HardParentCarEntity.builder()
+ .userId(dto.getUserId())
+ .merchantId(dto.getMerchantId())
+ .hardType(hardType)
+ .productSource(productSource)
+ .orderStatus(HardOrderStatusEnum.PAY.getType())
+ .build();
+ parent.setCreateBy(CREATE_BY);
+ parent.setUpdateBy(CREATE_BY);
+
+ int totalFen = 0;
+ List names = new ArrayList<>();
+ List cars = new ArrayList<>();
+ for (AiFeedOrderCreateDTO.Item item : dto.getItems()) {
+ if (item == null) {
+ continue;
+ }
+ int qty = item.getQty() == null || item.getQty() < 1 ? 1 : item.getQty();
+ int price = item.getPriceFen() == null ? 0 : Math.max(item.getPriceFen(), 0);
+ long line = (long) price * qty;
+ totalFen += (int) line;
+ String name = StrUtil.blankToDefault(item.getName(), "演示菜品");
+ names.add(name);
+ Long productId = item.getSpuId() != null ? item.getSpuId() : item.getMenuId();
+ if (productId == null) {
+ productId = 0L;
+ }
+ HardCarEntity car = HardCarEntity.builder()
+ .productId(productId)
+ .sourceProductId(item.getMenuId() != null ? item.getMenuId() : item.getSpuId())
+ .productName(name)
+ .productPic(item.getPicUrl())
+ .productPrice(price)
+ .productNature(HardProductNatureEnum.DEFAULT.getType())
+ .productCount(qty)
+ .totalPrice(line)
+ .build();
+ car.setCreateBy(CREATE_BY);
+ car.setUpdateBy(CREATE_BY);
+ cars.add(car);
+ }
+ if (cars.isEmpty()) {
+ throw new IllegalArgumentException("没有有效明细行");
+ }
+ if (dto.getTotalFen() != null && dto.getTotalFen() > 0) {
+ totalFen = dto.getTotalFen();
+ }
+ String orderNo = "AIFEED-" + StrUtil.blankToDefault(dto.getBatchNo(), "X") + "-"
+ + (dto.getSeq() == null ? System.nanoTime() : dto.getSeq());
+ String hourType = resolveHourType(dto.getMeal());
+ LocalDate useDate = parseDate(dto.getBizDate());
+ if (useDate == null) {
+ useDate = LocalDate.now();
+ }
+ String desc = StrUtil.blankToDefault(dto.getProductDesc(), "[AI_FEED] " + String.join("、", names));
+ if (!desc.startsWith("[AI_FEED]")) {
+ desc = "[AI_FEED] " + desc;
+ }
+
+ HardOrderEntity order = HardOrderEntity.builder()
+ .orderNo(orderNo)
+ .userId(dto.getUserId())
+ .userDeptId(dto.getUserDeptId())
+ .merchantId(dto.getMerchantId())
+ .merchantType(MerchantTypeEnum.CANTEEN.getType())
+ .merchantDeptId(dto.getMerchantDeptId())
+ .deptId(dto.getUserDeptId())
+ .walletId(dto.getWalletId())
+ .orderType(orderType)
+ .orderStatus(HardOrderStatusEnum.PAY.getType())
+ .payType(payType)
+ .hourType(hourType)
+ .apptUseDate(useDate)
+ .sysType(hardType)
+ .totalPrice(totalFen)
+ .extraPrice(0)
+ .deliveryPrice(0)
+ .payPrice(totalFen)
+ .payTime(LocalDateTime.now())
+ .productDesc(desc)
+ .addressName(dto.getAddressName())
+ .build();
+ order.setCreateBy(CREATE_BY);
+ order.setUpdateBy(CREATE_BY);
+ return new PreparedOrder(parent, cars, order);
+ }
+
+ private record PreparedOrder(HardParentCarEntity parent, List cars, HardOrderEntity order) {
+ }
+
+ /** 线下安卓机:OFFLINE / BELOW / 1 */
+ private static boolean isOffline(String orderType) {
+ if (StrUtil.isBlank(orderType)) {
+ return false;
+ }
+ String t = orderType.trim().toUpperCase(Locale.ROOT);
+ return "OFFLINE".equals(t) || "BELOW".equals(t)
+ || HardOrderTypeEnum.BELOW.getType().equals(orderType.trim())
+ || "1".equals(orderType.trim());
+ }
+
+ private static String resolveHourType(String meal) {
+ if (StrUtil.isBlank(meal)) {
+ return HardHourTypeEnum.NOONING.getType();
+ }
+ String m = meal.trim();
+ if ("1".equals(m) || m.contains("早")) {
+ return HardHourTypeEnum.MORNING.getType();
+ }
+ if ("3".equals(m) || m.contains("晚")) {
+ return HardHourTypeEnum.NIGHT.getType();
+ }
+ if ("2".equals(m) || m.contains("午") || m.contains("中")) {
+ return HardHourTypeEnum.NOONING.getType();
+ }
+ HardHourTypeEnum byType = HardHourTypeEnum.valueOfByType(m);
+ return byType == null ? HardHourTypeEnum.NOONING.getType() : byType.getType();
+ }
+
+ private static LocalDate parseDate(String raw) {
+ if (StrUtil.isBlank(raw)) {
+ return null;
+ }
+ try {
+ return LocalDate.parse(raw.trim(), DATE_FMT);
+ }
+ catch (Exception ex) {
+ return null;
+ }
+ }
+}
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/AiFeedUserBatchResultDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/AiFeedUserBatchResultDTO.java
new file mode 100644
index 000000000..84724ff0d
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/AiFeedUserBatchResultDTO.java
@@ -0,0 +1,23 @@
+package com.bsd.pjcloud.admin.api.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@Schema(description = "AI喂数批量建用户结果")
+public class AiFeedUserBatchResultDTO implements Serializable {
+
+ private List
- items = new ArrayList<>();
+
+ @Data
+ public static class Item implements Serializable {
+ private String phone;
+ private Long userId;
+ /** true=本次新建;false=已存在复用 */
+ private Boolean created;
+ }
+}
diff --git a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/AiFeedUserCreateDTO.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/AiFeedUserCreateDTO.java
new file mode 100644
index 000000000..c5effe823
--- /dev/null
+++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/dto/AiFeedUserCreateDTO.java
@@ -0,0 +1,20 @@
+package com.bsd.pjcloud.admin.api.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+@Schema(description = "AI喂数创建用户")
+public class AiFeedUserCreateDTO implements Serializable {
+
+ @Schema(description = "手机号(同时作为 username)")
+ private String phone;
+
+ @Schema(description = "姓名(同时作为 nickname)")
+ private String name;
+
+ @Schema(description = "部门ID")
+ private Long deptId;
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/AiFeedWalletEnsureDTO.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/AiFeedWalletEnsureDTO.java
new file mode 100644
index 000000000..77eb41370
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/AiFeedWalletEnsureDTO.java
@@ -0,0 +1,18 @@
+package com.bsd.pjcloud.wallet.api.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+
+@Data
+@Schema(description = "AI喂数确保饭卡并设定余额")
+public class AiFeedWalletEnsureDTO implements Serializable {
+
+ private Long userId;
+
+ private Long deptId;
+
+ /** 饭卡余额(分),可空或 0 表示只开户不改余额 */
+ private Integer balanceFen;
+}
diff --git a/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/AiFeedWalletEnsureResultDTO.java b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/AiFeedWalletEnsureResultDTO.java
new file mode 100644
index 000000000..34a34f56e
--- /dev/null
+++ b/pjcloud-wallet/pjcloud-wallet-api/src/main/java/com/bsd/pjcloud/wallet/api/dto/AiFeedWalletEnsureResultDTO.java
@@ -0,0 +1,21 @@
+package com.bsd.pjcloud.wallet.api.dto;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@Schema(description = "AI喂数批量开饭卡结果")
+public class AiFeedWalletEnsureResultDTO implements Serializable {
+
+ private List
- items = new ArrayList<>();
+
+ @Data
+ public static class Item implements Serializable {
+ private Long userId;
+ private Long walletId;
+ }
+}