diff --git a/.run/批量启动.run.xml b/.run/批量启动.run.xml index 3e60f4b49..b445b3e43 100644 --- a/.run/批量启动.run.xml +++ b/.run/批量启动.run.xml @@ -5,6 +5,8 @@ + + \ No newline at end of file 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-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java index 77b0acaa7..73b5e8a34 100644 --- a/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java +++ b/pjcloud-hardware/pjcloud-hardware-api/src/main/java/com/bsd/pjcloud/hard/api/feign/RemoteHardOrderService.java @@ -67,4 +67,20 @@ public interface RemoteHardOrderService { R> listDailyMenuNetSold( @RequestBody com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO dto); + /** AI 喂数:精简写入演示订单,返回 orderId */ + @NoToken + @PostMapping("/hard-order/inner/ai-feed-create") + R aiFeedCreateOrder(@RequestBody com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO dto); + + /** AI 喂数:批量写入演示订单 */ + @NoToken + @PostMapping("/hard-order/inner/ai-feed-create-batch") + R aiFeedCreateOrdersBatch( + @RequestBody java.util.List dtos); + + /** AI 喂数清理:软删 AIFEED- 订单 */ + @NoToken + @PostMapping("/hard-order/inner/ai-feed-delete") + R aiFeedDeleteOrders(@RequestBody List orderIds); + } diff --git a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java index 9f0a84891..8085d1fb7 100644 --- a/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java +++ b/pjcloud-hardware/pjcloud-hardware-biz/src/main/java/com/bsd/pjcloud/hard/controller/order/HardOrderController.java @@ -37,6 +37,7 @@ import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateReserveOrdersDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateShopOrderDto; import com.bsd.pjcloud.hard.api.dto.order.app.HardAppCreateTakeOutOrderDto; +import com.bsd.pjcloud.hard.api.dto.order.remote.AiFeedOrderCreateDTO; import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateShopStatusDto; import com.bsd.pjcloud.hard.api.dto.order.remote.HardUpdateTakeOutStatusDto; import com.bsd.pjcloud.hard.api.dto.order.remote.DailyMenuNetSoldQueryDTO; @@ -99,6 +100,7 @@ public class HardOrderController { private final RemoteMchService mchService; private final RemoteUserService userService; + private final AiFeedOrderService aiFeedOrderService; /* ************************************* start 远程调用 start ************************************* */ @Inner @@ -153,6 +155,33 @@ public class HardOrderController { public R> listDailyMenuNetSold(@RequestBody DailyMenuNetSoldQueryDTO dto) { return R.ok(hardwareOrderService.listDailyMenuNetSold(dto)); } + + @Inner + @Operation(summary = "AI喂数创建演示订单", description = "精简写入 hard_order/hard_car,不做完整库存链路") + @PostMapping("/inner/ai-feed-create") + public R aiFeedCreateOrder(@RequestBody AiFeedOrderCreateDTO dto) { + try { + return R.ok(aiFeedOrderService.createDemoOrder(dto)); + } + catch (IllegalArgumentException ex) { + return R.failed(ex.getMessage()); + } + } + + @Inner + @Operation(summary = "AI喂数批量创建演示订单") + @PostMapping("/inner/ai-feed-create-batch") + public R aiFeedCreateOrdersBatch( + @RequestBody List dtos) { + return R.ok(aiFeedOrderService.createDemoOrdersBatch(dtos)); + } + + @Inner + @Operation(summary = "AI喂数删除演示订单", description = "仅软删 orderNo 以 AIFEED- 开头的订单") + @PostMapping("/inner/ai-feed-delete") + public R aiFeedDeleteOrders(@RequestBody List orderIds) { + return R.ok(aiFeedOrderService.softDeleteByIds(orderIds)); + } /* ************************************* end 远程调用 ************************************* */ /* ************************************* start app start ************************************* */ 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-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java index b17eb33b5..b50df70cb 100644 --- a/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java +++ b/pjcloud-upms/pjcloud-upms-api/src/main/java/com/bsd/pjcloud/admin/api/feign/RemoteUserService.java @@ -309,4 +309,23 @@ public interface RemoteUserService { @GetMapping("/user/getUserListByRoleCode") R> getUserListByRoleCode(@RequestParam("code") String code); + /** + * AI 喂数:创建普通用户(手机号=账号,姓名=昵称,角色 GENERAL_USER + WALLET_AUTO_CREATE)。 + * 手机号已存在则返回已有 userId。 + */ + @NoToken + @PostMapping("/user/inner/ai-feed-create") + R aiFeedCreateUser(@RequestBody com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO dto); + + /** AI 喂数:批量建用户 */ + @NoToken + @PostMapping("/user/inner/ai-feed-create-batch") + R aiFeedCreateUsersBatch( + @RequestBody java.util.List users); + + /** AI 喂数清理:软删演示用户 */ + @NoToken + @PostMapping("/user/inner/ai-feed-delete") + R aiFeedDeleteUsers(@RequestBody List userIds); + } diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java index 040cfae45..df21687fc 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/controller/SysUserController.java @@ -761,6 +761,44 @@ public class SysUserController { return R.ok(userService.list(Wrappers.lambdaQuery().in(SysUser::getUserId, userIds))); } + /** + * AI 喂数:按手机号创建普通用户;已存在则直接返回 userId。 + * 角色固定 GENERAL_USER + WALLET_AUTO_CREATE;不异步开钱包。 + */ + @Inner + @PostMapping("/inner/ai-feed-create") + public R aiFeedCreateUser(@RequestBody com.bsd.pjcloud.admin.api.dto.AiFeedUserCreateDTO dto) { + if (dto == null || StrUtil.isBlank(dto.getPhone()) || StrUtil.isBlank(dto.getName()) || dto.getDeptId() == null) { + return R.failed("phone/name/deptId 不能为空"); + } + try { + return R.ok(userService.aiFeedCreateOrGetUser(dto.getPhone(), dto.getName(), dto.getDeptId())); + } + catch (Exception ex) { + return R.failed(StrUtil.blankToDefault(ex.getMessage(), "创建用户失败")); + } + } + + /** AI 喂数:批量建用户 */ + @Inner + @PostMapping("/inner/ai-feed-create-batch") + public R aiFeedCreateUsersBatch( + @RequestBody List users) { + try { + return R.ok(userService.aiFeedCreateUsersBatch(users)); + } + catch (Exception ex) { + return R.failed(StrUtil.blankToDefault(ex.getMessage(), "批量创建用户失败")); + } + } + + /** AI 喂数清理:软删演示用户(含角色/身份/钱包,跳过余额校验) */ + @Inner + @PostMapping("/inner/ai-feed-delete") + public R aiFeedDeleteUsers(@RequestBody List userIds) { + return userService.aiFeedDeleteUsers(userIds); + } + /** * 按用户 ID 批量查询用户、部门及身份协议编码。 * diff --git a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserMapper.java b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserMapper.java index d2212090a..0f541bd1d 100644 --- a/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserMapper.java +++ b/pjcloud-upms/pjcloud-upms-biz/src/main/java/com/bsd/pjcloud/admin/mapper/SysUserMapper.java @@ -92,4 +92,10 @@ public interface SysUserMapper extends PjcloudBaseMapper { * 用户在/非编人员统计 */ List staffingListByDept(@Param("dto") UserDTO dto, DataScope dataScope); + + /** + * AI 喂数:多行 INSERT 用户(自行带 tenant_id,忽略租户插件改写) + */ + @com.baomidou.mybatisplus.annotation.InterceptorIgnore(tenantLine = "true") + int insertAiFeedBatch(@Param("list") List 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/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; + } +} 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);