diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2f7896d
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1 @@
+target/
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..12db726
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,96 @@
+
+
+ 4.0.0
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.3
+
+
+
+ com.petstore
+ petstore-backend
+ 1.0.0
+ jar
+ petstore-backend
+ 宠伴生活馆 后端服务
+
+
+ 17
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+ org.projectlombok
+ lombok
+ 1.18.44
+ true
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+ 3.13.0
+
+ 17
+ 17
+ true
+
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.comp=ALL-UNNAMED
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.file=ALL-UNNAMED
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.main=ALL-UNNAMED
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.model=ALL-UNNAMED
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.parser=ALL-UNNAMED
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED
+ -J--add-opens=jdk.compiler/com.sun.tools.javac.jvm=ALL-UNNAMED
+
+
+
+ org.projectlombok
+ lombok
+ 1.18.44
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
diff --git a/src/main/java/com/petstore/PetstoreApplication.java b/src/main/java/com/petstore/PetstoreApplication.java
new file mode 100644
index 0000000..e55379b
--- /dev/null
+++ b/src/main/java/com/petstore/PetstoreApplication.java
@@ -0,0 +1,30 @@
+package com.petstore;
+
+import com.petstore.service.ServiceTypeService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.CommandLineRunner;
+import org.springframework.context.annotation.Bean;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+@SpringBootApplication
+@RequiredArgsConstructor
+public class PetstoreApplication {
+ public static void main(String[] args) {
+ System.out.println(">>> working dir: " + System.getProperty("user.dir"));
+ SpringApplication.run(PetstoreApplication.class, args);
+ }
+
+ @Bean
+ CommandLineRunner initRunner(ServiceTypeService serviceTypeService, JdbcTemplate jdbc) {
+ return args -> {
+ serviceTypeService.initDefaults();
+ // appointment_id 改为允许 NULL(支持不挂预约直接填报告)
+ jdbc.execute("ALTER TABLE t_report MODIFY COLUMN appointment_id BIGINT NULL");
+ // 修复旧图片URL:/2026/xxx → /api/upload/image/2026/xxx
+ jdbc.execute("UPDATE t_report SET before_photo = REPLACE(before_photo, 'http://localhost:8080/2026/', '/api/upload/image/2026/') WHERE before_photo LIKE 'http://localhost:8080/2026/%'");
+ jdbc.execute("UPDATE t_report SET after_photo = REPLACE(after_photo, 'http://localhost:8080/2026/', '/api/upload/image/2026/') WHERE after_photo LIKE 'http://localhost:8080/2026/%'");
+ };
+ }
+}
diff --git a/src/main/java/com/petstore/config/CorsConfig.java b/src/main/java/com/petstore/config/CorsConfig.java
new file mode 100644
index 0000000..eb84bd3
--- /dev/null
+++ b/src/main/java/com/petstore/config/CorsConfig.java
@@ -0,0 +1,23 @@
+package com.petstore.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.cors.CorsConfiguration;
+import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
+import org.springframework.web.filter.CorsFilter;
+
+@Configuration
+public class CorsConfig {
+ @Bean
+ public CorsFilter corsFilter() {
+ CorsConfiguration config = new CorsConfiguration();
+ config.setAllowCredentials(true);
+ config.addAllowedOriginPattern("*");
+ config.addAllowedHeader("*");
+ config.addAllowedMethod("*");
+
+ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
+ source.registerCorsConfiguration("/**", config);
+ return new CorsFilter(source);
+ }
+}
diff --git a/src/main/java/com/petstore/config/WebConfig.java b/src/main/java/com/petstore/config/WebConfig.java
new file mode 100644
index 0000000..a8ee4d4
--- /dev/null
+++ b/src/main/java/com/petstore/config/WebConfig.java
@@ -0,0 +1,27 @@
+package com.petstore.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+import java.io.File;
+
+@Configuration
+public class WebConfig implements WebMvcConfigurer {
+
+ @Value("${upload.path:uploads}")
+ private String uploadPath;
+
+ @Override
+ public void addResourceHandlers(ResourceHandlerRegistry registry) {
+ // 硬编码绝对路径,确保能找到文件
+ String uploadDir = "/Users/wac/Desktop/www/_src/petstore/backend/uploads/";
+ System.out.println(">>> WebConfig uploadDir: " + uploadDir);
+ System.out.println(">>> /2026 exists: " + new File(uploadDir + "2026/04/01/").exists());
+ registry.addResourceHandler("/uploads/**")
+ .addResourceLocations("file:" + uploadDir);
+ registry.addResourceHandler("/2026/**")
+ .addResourceLocations("file:" + uploadDir);
+ }
+}
diff --git a/src/main/java/com/petstore/config/WechatConfig.java b/src/main/java/com/petstore/config/WechatConfig.java
new file mode 100644
index 0000000..07c8d33
--- /dev/null
+++ b/src/main/java/com/petstore/config/WechatConfig.java
@@ -0,0 +1,33 @@
+package com.petstore.config;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class WechatConfig {
+ // TODO: 替换为实际的微信开放平台 AppID 和 AppSecret
+ @Value("${wechat.appid:YOUR_APPID}")
+ private String appid;
+
+ @Value("${wechat.appsecret:YOUR_APPSECRET}")
+ private String appsecret;
+
+ @Value("${wechat.redirect_uri:http://localhost:8080/api/wechat/callback}")
+ private String redirectUri;
+
+ public String getAppid() { return appid; }
+ public String getAppsecret() { return appsecret; }
+ public String getRedirectUri() { return redirectUri; }
+
+ /**
+ * 获取微信授权跳转地址
+ */
+ public String getAuthorizeUrl() {
+ return "https://open.weixin.qq.com/connect/qrconnect" +
+ "?appid=" + appid +
+ "&redirect_uri=" + redirectUri +
+ "&response_type=code" +
+ "&scope=snsapi_login" +
+ "&state=petstore#wechat_redirect";
+ }
+}
diff --git a/src/main/java/com/petstore/controller/AppointmentController.java b/src/main/java/com/petstore/controller/AppointmentController.java
new file mode 100644
index 0000000..ba5ec95
--- /dev/null
+++ b/src/main/java/com/petstore/controller/AppointmentController.java
@@ -0,0 +1,86 @@
+package com.petstore.controller;
+
+import com.petstore.entity.Appointment;
+import com.petstore.service.AppointmentService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+@RestController
+@RequestMapping("/api/appointment")
+@RequiredArgsConstructor
+@CrossOrigin
+public class AppointmentController {
+ private final AppointmentService appointmentService;
+
+ /** 获取预约列表(员工查自己/老板查全店) */
+ @GetMapping("/list")
+ public Map list(
+ @RequestParam(required = false) Long userId,
+ @RequestParam(required = false) Long storeId,
+ @RequestParam(required = false) String status) {
+
+ List appointments;
+ if (storeId != null) {
+ appointments = (status != null && !status.isEmpty())
+ ? appointmentService.getByStoreIdAndStatus(storeId, status)
+ : appointmentService.getByStoreId(storeId);
+ } else if (userId != null) {
+ appointments = (status != null && !status.isEmpty())
+ ? appointmentService.getByUserIdAndStatus(userId, status)
+ : appointmentService.getByUserId(userId);
+ } else {
+ return Map.of("code", 400, "message", "userId或storeId必填");
+ }
+
+ return Map.of("code", 200, "data", appointments);
+ }
+
+ /** 创建预约 */
+ @PostMapping("/create")
+ public Map create(@RequestBody Map params) {
+ Appointment appointment = new Appointment();
+ appointment.setPetName(params.get("petName").toString());
+ appointment.setPetType(params.get("petType").toString());
+ appointment.setServiceType(params.get("serviceType").toString());
+
+ String timeStr = params.get("appointmentTime").toString();
+ appointment.setAppointmentTime(java.time.LocalDateTime.parse(timeStr));
+
+ appointment.setStoreId(Long.valueOf(params.get("storeId").toString()));
+ appointment.setUserId(Long.valueOf(params.get("userId").toString()));
+ appointment.setStatus("new");
+
+ if (params.containsKey("remark") && params.get("remark") != null) {
+ appointment.setRemark(params.get("remark").toString());
+ }
+
+ Appointment created = appointmentService.create(appointment);
+ return Map.of("code", 200, "message", "创建成功", "data", created);
+ }
+
+ /** 开始服务:状态变进行中 + 指定技师 */
+ @PostMapping("/start")
+ public Map start(@RequestBody Map params) {
+ Long appointmentId = Long.valueOf(params.get("appointmentId").toString());
+ Long staffUserId = Long.valueOf(params.get("staffUserId").toString());
+ Appointment updated = appointmentService.startService(appointmentId, staffUserId);
+ if (updated != null) {
+ return Map.of("code", 200, "message", "已开始服务", "data", updated);
+ }
+ return Map.of("code", 404, "message", "预约不存在");
+ }
+
+ /** 更新预约状态 */
+ @PutMapping("/status")
+ public Map updateStatus(@RequestParam Long id, @RequestParam String status) {
+ Appointment updated = appointmentService.updateStatus(id, status);
+ if (updated != null) {
+ return Map.of("code", 200, "message", "更新成功", "data", updated);
+ }
+ return Map.of("code", 404, "message", "预约不存在");
+ }
+}
diff --git a/src/main/java/com/petstore/controller/FileController.java b/src/main/java/com/petstore/controller/FileController.java
new file mode 100644
index 0000000..ea0f0f5
--- /dev/null
+++ b/src/main/java/com/petstore/controller/FileController.java
@@ -0,0 +1,114 @@
+package com.petstore.controller;
+
+import jakarta.servlet.http.HttpServletRequest;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.core.io.FileSystemResource;
+import org.springframework.core.io.Resource;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+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;
+import java.util.Map;
+import java.util.UUID;
+
+@RestController
+@RequestMapping("/api/upload")
+@RequiredArgsConstructor
+@CrossOrigin
+public class FileController {
+
+ private static final String UPLOAD_BASE = "/Users/wac/Desktop/www/_src/petstore/backend/uploads/";
+
+ @Value("${upload.path:uploads}")
+ private String uploadPath;
+
+ @GetMapping("/image/**")
+ public ResponseEntity getImage(HttpServletRequest request) throws IOException {
+ String path = request.getRequestURI().replace("/api/upload/image", "");
+ File file = new File(UPLOAD_BASE + path);
+ if (!file.exists()) {
+ return ResponseEntity.notFound().build();
+ }
+ String contentType = Files.probeContentType(file.toPath());
+ if (contentType == null) contentType = "image/jpeg";
+ return ResponseEntity.ok()
+ .contentType(MediaType.parseMediaType(contentType))
+ .body(new FileSystemResource(file));
+ }
+
+ // 兼容旧路径:/2026/04/01/xxx.jpg
+ @GetMapping("/legacy/**")
+ public ResponseEntity getLegacyImage(HttpServletRequest request) throws IOException {
+ String path = request.getRequestURI().replace("/api/upload/legacy", "");
+ File file = new File(UPLOAD_BASE + path);
+ if (!file.exists()) {
+ return ResponseEntity.notFound().build();
+ }
+ String contentType = Files.probeContentType(file.toPath());
+ if (contentType == null) contentType = "image/jpeg";
+ return ResponseEntity.ok()
+ .contentType(MediaType.parseMediaType(contentType))
+ .body(new FileSystemResource(file));
+ }
+
+ @PostMapping("/image")
+ public Map uploadImage(@RequestParam("file") MultipartFile file) {
+ Map result = new HashMap<>();
+
+ if (file.isEmpty()) {
+ result.put("code", 400);
+ result.put("message", "文件为空");
+ return result;
+ }
+
+ String contentType = file.getContentType();
+ if (contentType == null || !contentType.startsWith("image/")) {
+ result.put("code", 400);
+ result.put("message", "只能上传图片");
+ return result;
+ }
+
+ try {
+ // 创建上传目录(使用绝对路径)
+ String datePath = LocalDate.now().toString().replace("-", "/");
+ String dirPath = UPLOAD_BASE + datePath; // /.../uploads/2026/04/01
+ File dir = new File(dirPath);
+ if (!dir.exists()) dir.mkdirs();
+
+ // 生成文件名
+ String originalFilename = file.getOriginalFilename();
+ String ext = "";
+ if (originalFilename != null && originalFilename.contains(".")) {
+ ext = originalFilename.substring(originalFilename.lastIndexOf("."));
+ }
+ String filename = UUID.randomUUID().toString().replace("-", "") + ext;
+
+ // 保存文件
+ Path filePath = Paths.get(dirPath, filename);
+ Files.write(filePath, file.getBytes());
+
+ // 返回访问URL(/api/upload/image/ + 日期路径 + 文件名)
+ String url = "/api/upload/image/" + datePath + "/" + filename;
+
+ result.put("code", 200);
+ result.put("message", "上传成功");
+ result.put("data", Map.of("url", url));
+ return result;
+
+ } catch (IOException e) {
+ e.printStackTrace();
+ result.put("code", 500);
+ result.put("message", "上传失败");
+ return result;
+ }
+ }
+}
diff --git a/src/main/java/com/petstore/controller/ReportController.java b/src/main/java/com/petstore/controller/ReportController.java
new file mode 100644
index 0000000..192f2c9
--- /dev/null
+++ b/src/main/java/com/petstore/controller/ReportController.java
@@ -0,0 +1,117 @@
+package com.petstore.controller;
+
+import com.petstore.entity.Report;
+import com.petstore.entity.Store;
+import com.petstore.service.ReportService;
+import com.petstore.service.StoreService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@RestController
+@RequestMapping("/api/report")
+@RequiredArgsConstructor
+@CrossOrigin
+public class ReportController {
+ private final ReportService reportService;
+ private final StoreService storeService;
+
+ private static final String BASE_URL = "http://localhost:8080";
+
+ private String fullUrl(String path) {
+ if (path == null || path.isEmpty()) return path;
+ if (path.startsWith("http")) return path;
+ return BASE_URL + path;
+ }
+
+ @PostMapping("/create")
+ public Map create(@RequestBody Report report) {
+ System.out.println(">>> Report create received: appointmentId=" + report.getAppointmentId() + ", userId=" + report.getUserId() + ", before=" + report.getBeforePhoto());
+ Report created = reportService.create(report);
+
+ Map result = new HashMap<>();
+ result.put("code", 200);
+ result.put("message", "提交成功");
+ result.put("data", Map.of(
+ "reportToken", created.getReportToken(),
+ "reportId", created.getId()
+ ));
+ return result;
+ }
+
+ @GetMapping("/list")
+ public Map list(@RequestParam(required = false) Long storeId,
+ @RequestParam(required = false) Long userId) {
+ List reports = reportService.list(storeId, userId);
+ // 附加技师名称,并补全图片URL
+ List