diff --git a/src/main/java/com/petstore/controller/FileController.java b/src/main/java/com/petstore/controller/FileController.java index c03a9bc..1464af6 100644 --- a/src/main/java/com/petstore/controller/FileController.java +++ b/src/main/java/com/petstore/controller/FileController.java @@ -14,6 +14,7 @@ import org.springframework.web.multipart.MultipartFile; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; import java.util.HashMap; @@ -28,8 +29,8 @@ import java.util.UUID; @CrossOrigin public class FileController { - private final Set IMAGE_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".heif"); - private final Set VIDEO_EXT = Set.of(".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm"); + private static final Set IMAGE_EXT = Set.of(".jpg", ".jpeg", ".png", ".gif", ".bmp", ".webp", ".heic", ".heif"); + private static final Set VIDEO_EXT = Set.of(".mp4", ".mov", ".avi", ".mkv", ".flv", ".wmv", ".webm"); @Value("${upload.path}") private String uploadPath; diff --git a/src/main/java/com/petstore/controller/ReportLeadController.java b/src/main/java/com/petstore/controller/ReportLeadController.java new file mode 100644 index 0000000..4542e8d --- /dev/null +++ b/src/main/java/com/petstore/controller/ReportLeadController.java @@ -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 suggestion(@PathVariable("token") String token) { + Map 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 submitReminder(@PathVariable("token") String token, + @RequestBody Map 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 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 unsubscribe(@RequestBody Map 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 leads(@RequestParam Long storeId, + @RequestParam(required = false, defaultValue = "pending") String status) { + List list; + if ("all".equalsIgnoreCase(status)) { + list = reportLeadService.listAllForStore(storeId); + } else { + list = reportLeadService.listPending(storeId); + } + List> data = list.stream().map(l -> { + Map 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(); + } +} diff --git a/src/main/java/com/petstore/entity/ReportLead.java b/src/main/java/com/petstore/entity/ReportLead.java new file mode 100644 index 0000000..18e12e7 --- /dev/null +++ b/src/main/java/com/petstore/entity/ReportLead.java @@ -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; +} diff --git a/src/main/java/com/petstore/entity/ServiceInterval.java b/src/main/java/com/petstore/entity/ServiceInterval.java new file mode 100644 index 0000000..ed8ee3f --- /dev/null +++ b/src/main/java/com/petstore/entity/ServiceInterval.java @@ -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; +} diff --git a/src/main/java/com/petstore/mapper/ReportLeadMapper.java b/src/main/java/com/petstore/mapper/ReportLeadMapper.java new file mode 100644 index 0000000..c7abaaf --- /dev/null +++ b/src/main/java/com/petstore/mapper/ReportLeadMapper.java @@ -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 { + + /** 同一报告 + 同一手机号只保留一条,用于幂等 */ + Optional findFirstByReportIdAndPhone(Long reportId, String phone); + + Optional findFirstByUnsubscribeToken(String unsubscribeToken); + + /** 老板回访池:按状态倒查、按提醒日期升序 */ + List findByStoreIdAndRemindStatusOrderByRemindDateAsc(Long storeId, String remindStatus); + + List findByStoreIdOrderByCreateTimeDesc(Long storeId); +} diff --git a/src/main/java/com/petstore/mapper/ServiceIntervalMapper.java b/src/main/java/com/petstore/mapper/ServiceIntervalMapper.java new file mode 100644 index 0000000..dbcfe62 --- /dev/null +++ b/src/main/java/com/petstore/mapper/ServiceIntervalMapper.java @@ -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 { + + /** 店铺自定义优先(store_id = :storeId),兜底系统默认(store_id IS NULL) */ + Optional findFirstByStoreIdAndServiceName(Long storeId, String serviceName); + + Optional findFirstByStoreIdIsNullAndServiceName(String serviceName); + + List findByStoreIdIsNull(); +} diff --git a/src/main/java/com/petstore/service/ReportLeadService.java b/src/main/java/com/petstore/service/ReportLeadService.java new file mode 100644 index 0000000..9b44977 --- /dev/null +++ b/src/main/java/com/petstore/service/ReportLeadService.java @@ -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 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 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 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 listPending(Long storeId) { + return reportLeadMapper.findByStoreIdAndRemindStatusOrderByRemindDateAsc(storeId, "pending"); + } + + public List listAllForStore(Long storeId) { + return reportLeadMapper.findByStoreIdOrderByCreateTimeDesc(storeId); + } + + // ---------------- 私有工具 ---------------- + + private Interval resolveInterval(Long storeId, String serviceName) { + if (serviceName != null && storeId != null) { + Optional perStore = serviceIntervalMapper + .findFirstByStoreIdAndServiceName(storeId, serviceName); + if (perStore.isPresent()) return Interval.of(perStore.get()); + } + if (serviceName != null) { + Optional 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); + } + } +}