deploy 2026-04-18 09:19
This commit is contained in:
parent
6cbf23c06a
commit
d5637f407e
@ -14,6 +14,7 @@ import org.springframework.web.multipart.MultipartFile;
|
|||||||
import java.io.File;
|
import java.io.File;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
import java.nio.file.Paths;
|
import java.nio.file.Paths;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
@ -28,8 +29,8 @@ import java.util.UUID;
|
|||||||
@CrossOrigin
|
@CrossOrigin
|
||||||
public class FileController {
|
public class FileController {
|
||||||
|
|
||||||
private final Set<String> IMAGE_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".heif");
|
private static final Set<String> IMAGE_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".heif");
|
||||||
private final Set<String> VIDEO_EXT = Set.of(".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm");
|
private static final Set<String> VIDEO_EXT = Set.of(".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm");
|
||||||
|
|
||||||
@Value("${upload.path}")
|
@Value("${upload.path}")
|
||||||
private String uploadPath;
|
private String uploadPath;
|
||||||
|
|||||||
110
src/main/java/com/petstore/controller/ReportLeadController.java
Normal file
110
src/main/java/com/petstore/controller/ReportLeadController.java
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
package com.petstore.controller;
|
||||||
|
|
||||||
|
import com.petstore.entity.ReportLead;
|
||||||
|
import com.petstore.service.ReportLeadService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 报告页留资 & 回访池。
|
||||||
|
* 前 3 个接口面向 H5 宠主端(无需登录,凭 reportToken / unsubscribeToken)。
|
||||||
|
* leads 接口面向老板后台(传 storeId;鉴权逻辑参考项目现有其他后台接口——当前项目里
|
||||||
|
* 管理接口未普遍加鉴权,这里保持一致,由上层网关/前端控制)。
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/report")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@CrossOrigin
|
||||||
|
public class ReportLeadController {
|
||||||
|
|
||||||
|
private final ReportLeadService reportLeadService;
|
||||||
|
|
||||||
|
/** GET /api/report/{token}/suggestion — 下次建议日期 */
|
||||||
|
@GetMapping("/{token}/suggestion")
|
||||||
|
public Map<String, Object> suggestion(@PathVariable("token") String token) {
|
||||||
|
Map<String, Object> data = reportLeadService.suggestNext(token);
|
||||||
|
if (data == null) {
|
||||||
|
return Map.of("code", 404, "message", "报告不存在");
|
||||||
|
}
|
||||||
|
return Map.of("code", 200, "data", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/report/{token}/reminder — 宠主留资 */
|
||||||
|
@PostMapping("/{token}/reminder")
|
||||||
|
public Map<String, Object> submitReminder(@PathVariable("token") String token,
|
||||||
|
@RequestBody Map<String, Object> body,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String phone = body.get("phone") == null ? null : body.get("phone").toString().trim();
|
||||||
|
Object consent = body.get("consent");
|
||||||
|
if (consent == null || !Boolean.parseBoolean(consent.toString())) {
|
||||||
|
return Map.of("code", 400, "message", "需要勾选同意接收服务提醒");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
ReportLead lead = reportLeadService.submit(token, phone, extractClientIp(request));
|
||||||
|
Map<String, Object> data = new HashMap<>();
|
||||||
|
data.put("ok", true);
|
||||||
|
data.put("leadId", lead.getId());
|
||||||
|
data.put("remindDate", lead.getRemindDate() == null ? null : lead.getRemindDate().toString());
|
||||||
|
data.put("unsubscribeToken", lead.getUnsubscribeToken());
|
||||||
|
return Map.of("code", 200, "data", data);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
return Map.of("code", 400, "message", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /api/report/unsubscribe — 一键退订 */
|
||||||
|
@PostMapping("/unsubscribe")
|
||||||
|
public Map<String, Object> unsubscribe(@RequestBody Map<String, Object> body) {
|
||||||
|
String token = body.get("unsubscribeToken") == null ? null : body.get("unsubscribeToken").toString();
|
||||||
|
boolean ok = reportLeadService.unsubscribe(token);
|
||||||
|
if (!ok) {
|
||||||
|
return Map.of("code", 404, "message", "退订链接无效或已失效");
|
||||||
|
}
|
||||||
|
return Map.of("code", 200, "data", Map.of("ok", true));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /api/report/leads?storeId=xxx&status=pending — 老板后台回访池 */
|
||||||
|
@GetMapping("/leads")
|
||||||
|
public Map<String, Object> leads(@RequestParam Long storeId,
|
||||||
|
@RequestParam(required = false, defaultValue = "pending") String status) {
|
||||||
|
List<ReportLead> list;
|
||||||
|
if ("all".equalsIgnoreCase(status)) {
|
||||||
|
list = reportLeadService.listAllForStore(storeId);
|
||||||
|
} else {
|
||||||
|
list = reportLeadService.listPending(storeId);
|
||||||
|
}
|
||||||
|
List<Map<String, Object>> data = list.stream().map(l -> {
|
||||||
|
Map<String, Object> m = new HashMap<>();
|
||||||
|
m.put("id", l.getId());
|
||||||
|
m.put("reportId", l.getReportId());
|
||||||
|
m.put("petName", l.getPetName());
|
||||||
|
m.put("serviceType", l.getServiceType());
|
||||||
|
m.put("phone", l.getPhone());
|
||||||
|
m.put("reminderType", l.getReminderType());
|
||||||
|
m.put("remindDate", l.getRemindDate() == null ? null : l.getRemindDate().toString());
|
||||||
|
m.put("remindStatus", l.getRemindStatus());
|
||||||
|
m.put("createTime", l.getCreateTime());
|
||||||
|
return m;
|
||||||
|
}).toList();
|
||||||
|
return Map.of("code", 200, "data", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 取客户端 IP(合规留证) */
|
||||||
|
private String extractClientIp(HttpServletRequest request) {
|
||||||
|
if (request == null) return null;
|
||||||
|
String[] headers = {"X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP"};
|
||||||
|
for (String h : headers) {
|
||||||
|
String v = request.getHeader(h);
|
||||||
|
if (v != null && !v.isBlank() && !"unknown".equalsIgnoreCase(v)) {
|
||||||
|
int comma = v.indexOf(',');
|
||||||
|
return (comma > 0 ? v.substring(0, comma) : v).trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
}
|
||||||
78
src/main/java/com/petstore/entity/ReportLead.java
Normal file
78
src/main/java/com/petstore/entity/ReportLead.java
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
package com.petstore.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 宠主在报告页留下的联系方式 + 下次服务提醒。
|
||||||
|
* 一约可多条(宠主可以改手机号重新留资),status 用于软退订与去重。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "t_report_lead",
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_lead_report_id", columnList = "report_id"),
|
||||||
|
@Index(name = "idx_lead_store_status_remind", columnList = "store_id,remind_status,remind_date"),
|
||||||
|
@Index(name = "idx_lead_phone", columnList = "phone"),
|
||||||
|
@Index(name = "idx_lead_unsubscribe_token", columnList = "unsubscribe_token")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class ReportLead {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "report_id")
|
||||||
|
private Long reportId;
|
||||||
|
|
||||||
|
@Column(name = "store_id")
|
||||||
|
private Long storeId;
|
||||||
|
|
||||||
|
/** 冗余:宠物名 / 服务类型,避免关联报告查询,用于文案拼接 */
|
||||||
|
@Column(name = "pet_name", length = 64)
|
||||||
|
private String petName;
|
||||||
|
|
||||||
|
@Column(name = "service_type", length = 64)
|
||||||
|
private String serviceType;
|
||||||
|
|
||||||
|
@Column(length = 20)
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
/** 预留:微信 openid(后续公众号/小程序接入后回填) */
|
||||||
|
@Column(name = "wechat_openid", length = 64)
|
||||||
|
private String wechatOpenid;
|
||||||
|
|
||||||
|
/** next_wash / next_grooming / next_bath_groom / next_nail / next_deworm 等 */
|
||||||
|
@Column(name = "reminder_type", length = 32)
|
||||||
|
private String reminderType;
|
||||||
|
|
||||||
|
/** 计算出的下次建议日期(date 不含时间) */
|
||||||
|
@Column(name = "remind_date")
|
||||||
|
private LocalDate remindDate;
|
||||||
|
|
||||||
|
/** pending / sent / canceled / unsubscribed */
|
||||||
|
@Column(name = "remind_status", length = 16)
|
||||||
|
private String remindStatus;
|
||||||
|
|
||||||
|
/** 用户勾选「同意接收」的时间,合规留证 */
|
||||||
|
@Column(name = "consent_at")
|
||||||
|
private LocalDateTime consentAt;
|
||||||
|
|
||||||
|
/** 用户提交时的 IP(合规留证) */
|
||||||
|
@Column(name = "consent_ip", length = 64)
|
||||||
|
private String consentIp;
|
||||||
|
|
||||||
|
/** 独立的退订令牌,避免暴露 report_token */
|
||||||
|
@Column(name = "unsubscribe_token", length = 64, unique = true)
|
||||||
|
private String unsubscribeToken;
|
||||||
|
|
||||||
|
@Column(name = "create_time")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
@Column(name = "update_time")
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
}
|
||||||
47
src/main/java/com/petstore/entity/ServiceInterval.java
Normal file
47
src/main/java/com/petstore/entity/ServiceInterval.java
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
package com.petstore.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 服务建议周期配置:用于「下次该洗了」的日期预测。
|
||||||
|
* store_id 为 null 表示系统默认,老板可针对自家店覆盖。
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Entity
|
||||||
|
@Table(
|
||||||
|
name = "t_service_interval",
|
||||||
|
indexes = {
|
||||||
|
@Index(name = "idx_si_store_service", columnList = "store_id,service_name")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
public class ServiceInterval {
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/** null = 系统默认 */
|
||||||
|
@Column(name = "store_id")
|
||||||
|
private Long storeId;
|
||||||
|
|
||||||
|
/** 与 t_service_type.name 对齐,作为匹配键 */
|
||||||
|
@Column(name = "service_name", length = 64)
|
||||||
|
private String serviceName;
|
||||||
|
|
||||||
|
/** 可选:按宠物类型细分(dog/cat/other),null 表示全部 */
|
||||||
|
@Column(name = "pet_type", length = 16)
|
||||||
|
private String petType;
|
||||||
|
|
||||||
|
/** 建议最短间隔(天) */
|
||||||
|
@Column(name = "interval_days_min")
|
||||||
|
private Integer intervalDaysMin;
|
||||||
|
|
||||||
|
/** 建议最长间隔(天) */
|
||||||
|
@Column(name = "interval_days_max")
|
||||||
|
private Integer intervalDaysMax;
|
||||||
|
|
||||||
|
@Column(name = "create_time")
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
}
|
||||||
20
src/main/java/com/petstore/mapper/ReportLeadMapper.java
Normal file
20
src/main/java/com/petstore/mapper/ReportLeadMapper.java
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package com.petstore.mapper;
|
||||||
|
|
||||||
|
import com.petstore.entity.ReportLead;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ReportLeadMapper extends JpaRepository<ReportLead, Long> {
|
||||||
|
|
||||||
|
/** 同一报告 + 同一手机号只保留一条,用于幂等 */
|
||||||
|
Optional<ReportLead> findFirstByReportIdAndPhone(Long reportId, String phone);
|
||||||
|
|
||||||
|
Optional<ReportLead> findFirstByUnsubscribeToken(String unsubscribeToken);
|
||||||
|
|
||||||
|
/** 老板回访池:按状态倒查、按提醒日期升序 */
|
||||||
|
List<ReportLead> findByStoreIdAndRemindStatusOrderByRemindDateAsc(Long storeId, String remindStatus);
|
||||||
|
|
||||||
|
List<ReportLead> findByStoreIdOrderByCreateTimeDesc(Long storeId);
|
||||||
|
}
|
||||||
17
src/main/java/com/petstore/mapper/ServiceIntervalMapper.java
Normal file
17
src/main/java/com/petstore/mapper/ServiceIntervalMapper.java
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
package com.petstore.mapper;
|
||||||
|
|
||||||
|
import com.petstore.entity.ServiceInterval;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface ServiceIntervalMapper extends JpaRepository<ServiceInterval, Long> {
|
||||||
|
|
||||||
|
/** 店铺自定义优先(store_id = :storeId),兜底系统默认(store_id IS NULL) */
|
||||||
|
Optional<ServiceInterval> findFirstByStoreIdAndServiceName(Long storeId, String serviceName);
|
||||||
|
|
||||||
|
Optional<ServiceInterval> findFirstByStoreIdIsNullAndServiceName(String serviceName);
|
||||||
|
|
||||||
|
List<ServiceInterval> findByStoreIdIsNull();
|
||||||
|
}
|
||||||
203
src/main/java/com/petstore/service/ReportLeadService.java
Normal file
203
src/main/java/com/petstore/service/ReportLeadService.java
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
package com.petstore.service;
|
||||||
|
|
||||||
|
import com.petstore.entity.Report;
|
||||||
|
import com.petstore.entity.ReportLead;
|
||||||
|
import com.petstore.entity.ServiceInterval;
|
||||||
|
import com.petstore.mapper.ReportLeadMapper;
|
||||||
|
import com.petstore.mapper.ReportMapper;
|
||||||
|
import com.petstore.mapper.ServiceIntervalMapper;
|
||||||
|
import jakarta.annotation.PostConstruct;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ReportLeadService {
|
||||||
|
|
||||||
|
private final ReportLeadMapper reportLeadMapper;
|
||||||
|
private final ReportMapper reportMapper;
|
||||||
|
private final ServiceIntervalMapper serviceIntervalMapper;
|
||||||
|
|
||||||
|
/** 当服务类型不在已配置表中时使用的兜底间隔(天) */
|
||||||
|
private static final int FALLBACK_MIN = 28;
|
||||||
|
private static final int FALLBACK_MAX = 35;
|
||||||
|
|
||||||
|
/** 首次启动时写入系统默认建议周期。ddl-auto=update 会建表,这里只灌数据。 */
|
||||||
|
@PostConstruct
|
||||||
|
public void seedDefaults() {
|
||||||
|
if (!serviceIntervalMapper.findByStoreIdIsNull().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// [service_name, min_days, max_days]
|
||||||
|
Object[][] defaults = new Object[][]{
|
||||||
|
{"洗澡", 21, 28},
|
||||||
|
{"美容", 42, 56},
|
||||||
|
{"洗澡+美容", 28, 35},
|
||||||
|
{"剪指甲", 30, 45},
|
||||||
|
{"驱虫", 30, 30}
|
||||||
|
};
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
for (Object[] row : defaults) {
|
||||||
|
ServiceInterval si = new ServiceInterval();
|
||||||
|
si.setStoreId(null);
|
||||||
|
si.setServiceName((String) row[0]);
|
||||||
|
si.setPetType(null);
|
||||||
|
si.setIntervalDaysMin((Integer) row[1]);
|
||||||
|
si.setIntervalDaysMax((Integer) row[2]);
|
||||||
|
si.setCreateTime(now);
|
||||||
|
serviceIntervalMapper.save(si);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据报告 token 算「下次建议日期」。
|
||||||
|
* 算法:以 appointmentTime(服务时间)为锚点 + 间隔区间的中点 round。
|
||||||
|
*
|
||||||
|
* @return map 包含 remindDate / intervalText / serviceType / reminderType;report 不存在则返回 null
|
||||||
|
*/
|
||||||
|
public Map<String, Object> suggestNext(String reportToken) {
|
||||||
|
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
|
||||||
|
if (report == null) return null;
|
||||||
|
|
||||||
|
Interval itv = resolveInterval(report.getStoreId(), report.getServiceType());
|
||||||
|
int mid = (itv.min + itv.max) / 2;
|
||||||
|
|
||||||
|
LocalDate anchor;
|
||||||
|
if (report.getAppointmentTime() != null) {
|
||||||
|
anchor = report.getAppointmentTime().toLocalDate();
|
||||||
|
} else if (report.getCreateTime() != null) {
|
||||||
|
anchor = report.getCreateTime().toLocalDate();
|
||||||
|
} else {
|
||||||
|
anchor = LocalDate.now(ZoneId.of("Asia/Shanghai"));
|
||||||
|
}
|
||||||
|
LocalDate remind = anchor.plusDays(mid);
|
||||||
|
LocalDate today = LocalDate.now(ZoneId.of("Asia/Shanghai"));
|
||||||
|
if (remind.isBefore(today)) {
|
||||||
|
// 历史报告也能给出一个合理的未来日期(最早一周后)
|
||||||
|
remind = today.plusDays(7);
|
||||||
|
}
|
||||||
|
|
||||||
|
String intervalText = buildIntervalText(itv.min, itv.max);
|
||||||
|
|
||||||
|
return Map.of(
|
||||||
|
"remindDate", remind.toString(),
|
||||||
|
"intervalText", intervalText,
|
||||||
|
"intervalDaysMin", itv.min,
|
||||||
|
"intervalDaysMax", itv.max,
|
||||||
|
"serviceType", report.getServiceType() == null ? "" : report.getServiceType(),
|
||||||
|
"reminderType", resolveReminderType(report.getServiceType()),
|
||||||
|
"petName", report.getPetName() == null ? "" : report.getPetName()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等创建/更新留资。
|
||||||
|
* 已存在 (reportId, phone) 组合时只更新状态和提醒日期,不重复创建。
|
||||||
|
*/
|
||||||
|
public ReportLead submit(String reportToken, String phone, String consentIp) {
|
||||||
|
Report report = reportMapper.findFirstByReportTokenAndDeletedFalse(reportToken).orElse(null);
|
||||||
|
if (report == null) {
|
||||||
|
throw new IllegalArgumentException("报告不存在或已失效");
|
||||||
|
}
|
||||||
|
if (phone == null || !phone.matches("^1[3-9]\\d{9}$")) {
|
||||||
|
throw new IllegalArgumentException("手机号格式不正确");
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Object> sug = suggestNext(reportToken);
|
||||||
|
LocalDate remindDate = LocalDate.parse((String) sug.get("remindDate"));
|
||||||
|
String reminderType = (String) sug.get("reminderType");
|
||||||
|
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
ReportLead lead = reportLeadMapper.findFirstByReportIdAndPhone(report.getId(), phone).orElse(null);
|
||||||
|
if (lead == null) {
|
||||||
|
lead = new ReportLead();
|
||||||
|
lead.setReportId(report.getId());
|
||||||
|
lead.setStoreId(report.getStoreId());
|
||||||
|
lead.setPetName(report.getPetName());
|
||||||
|
lead.setServiceType(report.getServiceType());
|
||||||
|
lead.setPhone(phone);
|
||||||
|
lead.setUnsubscribeToken(UUID.randomUUID().toString().replace("-", ""));
|
||||||
|
lead.setCreateTime(now);
|
||||||
|
}
|
||||||
|
lead.setReminderType(reminderType);
|
||||||
|
lead.setRemindDate(remindDate);
|
||||||
|
lead.setRemindStatus("pending");
|
||||||
|
lead.setConsentAt(now);
|
||||||
|
lead.setConsentIp(consentIp);
|
||||||
|
lead.setUpdateTime(now);
|
||||||
|
return reportLeadMapper.save(lead);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 退订:按 unsubscribeToken 找记录并置状态 */
|
||||||
|
public boolean unsubscribe(String unsubscribeToken) {
|
||||||
|
if (unsubscribeToken == null || unsubscribeToken.isBlank()) return false;
|
||||||
|
Optional<ReportLead> opt = reportLeadMapper.findFirstByUnsubscribeToken(unsubscribeToken);
|
||||||
|
if (opt.isEmpty()) return false;
|
||||||
|
ReportLead lead = opt.get();
|
||||||
|
if ("unsubscribed".equals(lead.getRemindStatus())) return true;
|
||||||
|
lead.setRemindStatus("unsubscribed");
|
||||||
|
lead.setUpdateTime(LocalDateTime.now());
|
||||||
|
reportLeadMapper.save(lead);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 老板回访池:pending 状态、按日期升序 */
|
||||||
|
public List<ReportLead> listPending(Long storeId) {
|
||||||
|
return reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ReportLead> listAllForStore(Long storeId) {
|
||||||
|
return reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- 私有工具 ----------------
|
||||||
|
|
||||||
|
private Interval resolveInterval(Long storeId, String serviceName) {
|
||||||
|
if (serviceName != null && storeId != null) {
|
||||||
|
Optional<ServiceInterval> perStore = serviceIntervalMapper
|
||||||
|
.findFirstByStoreIdAndServiceName(storeId, serviceName);
|
||||||
|
if (perStore.isPresent()) return Interval.of(perStore.get());
|
||||||
|
}
|
||||||
|
if (serviceName != null) {
|
||||||
|
Optional<ServiceInterval> sys = serviceIntervalMapper
|
||||||
|
.findFirstByStoreIdIsNullAndServiceName(serviceName);
|
||||||
|
if (sys.isPresent()) return Interval.of(sys.get());
|
||||||
|
}
|
||||||
|
return new Interval(FALLBACK_MIN, FALLBACK_MAX);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildIntervalText(int min, int max) {
|
||||||
|
int midWeeks = Math.max(1, ((min + max) / 2 + 3) / 7);
|
||||||
|
if (min == max) {
|
||||||
|
return String.format("约 %d 天后", min);
|
||||||
|
}
|
||||||
|
return String.format("约 %d 周后", midWeeks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveReminderType(String serviceName) {
|
||||||
|
if (serviceName == null) return "next_service";
|
||||||
|
if (serviceName.contains("驱虫")) return "next_deworm";
|
||||||
|
if (serviceName.contains("指甲")) return "next_nail";
|
||||||
|
if (serviceName.contains("美容") && serviceName.contains("洗")) return "next_bath_groom";
|
||||||
|
if (serviceName.contains("美容")) return "next_grooming";
|
||||||
|
if (serviceName.contains("洗")) return "next_wash";
|
||||||
|
return "next_service";
|
||||||
|
}
|
||||||
|
|
||||||
|
private record Interval(int min, int max) {
|
||||||
|
static Interval of(ServiceInterval si) {
|
||||||
|
int mi = si.getIntervalDaysMin() == null ? FALLBACK_MIN : si.getIntervalDaysMin();
|
||||||
|
int ma = si.getIntervalDaysMax() == null ? FALLBACK_MAX : si.getIntervalDaysMax();
|
||||||
|
if (ma < mi) ma = mi;
|
||||||
|
return new Interval(mi, ma);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user