This commit is contained in:
王子琦
2026-01-13 13:55:40 +08:00
parent 6affd0c77e
commit f58e05d962
72 changed files with 3251 additions and 0 deletions

67
backend/pom.xml Normal file
View File

@@ -0,0 +1,67 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.flower</groupId>
<artifactId>flower-backend</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>3.5.3</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

124
backend/sql/init.sql Normal file
View File

@@ -0,0 +1,124 @@
CREATE DATABASE IF NOT EXISTS flower_shop DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE flower_shop;
CREATE TABLE IF NOT EXISTS users (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(32) NOT NULL UNIQUE,
password_hash VARCHAR(64) NOT NULL,
role VARCHAR(16) NOT NULL,
nickname VARCHAR(32),
phone VARCHAR(32),
email VARCHAR(64),
avatar_url VARCHAR(255),
disabled TINYINT(1) DEFAULT 0,
created_at DATETIME
);
CREATE TABLE IF NOT EXISTS sessions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
token VARCHAR(64) NOT NULL UNIQUE,
expired TINYINT(1) DEFAULT 0,
created_at DATETIME
);
CREATE TABLE IF NOT EXISTS addresses (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
recipient_name VARCHAR(32) NOT NULL,
phone VARCHAR(32) NOT NULL,
province VARCHAR(32),
city VARCHAR(32),
district VARCHAR(32),
detail VARCHAR(255),
is_default TINYINT(1) DEFAULT 0
);
CREATE TABLE IF NOT EXISTS categories (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(32) NOT NULL,
sort_order INT DEFAULT 0
);
CREATE TABLE IF NOT EXISTS products (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
category_id BIGINT,
name VARCHAR(64) NOT NULL,
description TEXT,
price DECIMAL(10,2) NOT NULL,
stock INT DEFAULT 0,
cover_url VARCHAR(255),
status VARCHAR(16) DEFAULT 'ON',
created_at DATETIME,
updated_at DATETIME
);
CREATE TABLE IF NOT EXISTS orders (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_no VARCHAR(32) NOT NULL UNIQUE,
user_id BIGINT NOT NULL,
status VARCHAR(16) NOT NULL,
total_amount DECIMAL(10,2) NOT NULL,
receiver_name VARCHAR(32),
receiver_phone VARCHAR(32),
receiver_address VARCHAR(128),
created_at DATETIME,
updated_at DATETIME,
pay_at DATETIME
);
CREATE TABLE IF NOT EXISTS order_items (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT NOT NULL,
product_id BIGINT NOT NULL,
product_name VARCHAR(64) NOT NULL,
product_price DECIMAL(10,2) NOT NULL,
quantity INT NOT NULL,
product_cover VARCHAR(255)
);
CREATE TABLE IF NOT EXISTS reviews (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT,
product_id BIGINT,
user_id BIGINT,
rating INT,
content VARCHAR(1000),
images VARCHAR(1000),
status VARCHAR(16) DEFAULT 'PENDING',
created_at DATETIME
);
CREATE TABLE IF NOT EXISTS confessions (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
order_id BIGINT,
user_id BIGINT,
code VARCHAR(32) NOT NULL UNIQUE,
title VARCHAR(64),
message VARCHAR(2000),
image_url VARCHAR(255),
created_at DATETIME
);
CREATE TABLE IF NOT EXISTS barrages (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
confession_id BIGINT NOT NULL,
sender VARCHAR(32),
content VARCHAR(200) NOT NULL,
created_at DATETIME
);
INSERT INTO users (username, password_hash, role, nickname, created_at)
VALUES ('admin', '8d969eef6ecad3c29a3a629280e686cf0c3f5d5a86aff3ca12020c923adc6c92', 'ADMIN', '管理员', NOW())
ON DUPLICATE KEY UPDATE username = username;
INSERT INTO categories (name, sort_order) VALUES
('表白花束', 1),
('生日花束', 2),
('节日限定', 3)
ON DUPLICATE KEY UPDATE name = VALUES(name);
INSERT INTO products (category_id, name, description, price, stock, cover_url, status, created_at, updated_at) VALUES
(1, '心动红玫瑰', '11支红玫瑰搭配满天星', 199.00, 50, 'https://via.placeholder.com/400x300?text=Rose', 'ON', NOW(), NOW()),
(2, '暖阳向日葵', '向日葵搭配桔梗', 168.00, 40, 'https://via.placeholder.com/400x300?text=Sunflower', 'ON', NOW(), NOW()),
(3, '节日限定花篮', '节日限定主题花篮', 299.00, 20, 'https://via.placeholder.com/400x300?text=Holiday', 'ON', NOW(), NOW());

View File

@@ -0,0 +1,11 @@
package com.flower;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class FlowerApplication {
public static void main(String[] args) {
SpringApplication.run(FlowerApplication.class, args);
}
}

View File

@@ -0,0 +1,48 @@
package com.flower.admin;
import com.flower.common.ApiResponse;
import com.flower.order.OrderRepository;
import com.flower.security.AdminOnly;
import com.flower.user.UserRepository;
import lombok.Data;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.math.BigDecimal;
@RestController
@RequestMapping("/api/admin/dashboard")
@AdminOnly
public class AdminDashboardController {
private final OrderRepository orderRepository;
private final UserRepository userRepository;
public AdminDashboardController(OrderRepository orderRepository, UserRepository userRepository) {
this.orderRepository = orderRepository;
this.userRepository = userRepository;
}
@GetMapping
public ApiResponse<DashboardStats> stats() {
long totalOrders = orderRepository.count();
long totalUsers = userRepository.count();
BigDecimal totalSales = orderRepository.findAll().stream()
.filter(order -> "PAID".equals(order.getStatus()) || "SHIPPED".equals(order.getStatus())
|| "COMPLETED".equals(order.getStatus()))
.map(order -> order.getTotalAmount() == null ? BigDecimal.ZERO : order.getTotalAmount())
.reduce(BigDecimal.ZERO, BigDecimal::add);
DashboardStats stats = new DashboardStats();
stats.setTotalOrders(totalOrders);
stats.setTotalUsers(totalUsers);
stats.setTotalSales(totalSales);
return ApiResponse.ok(stats);
}
@Data
public static class DashboardStats {
private long totalOrders;
private long totalUsers;
private BigDecimal totalSales;
}
}

View File

@@ -0,0 +1,14 @@
package com.flower.common;
public class ApiException extends RuntimeException {
private final int code;
public ApiException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -0,0 +1,25 @@
package com.flower.common;
import lombok.Data;
@Data
public class ApiResponse<T> {
private int code;
private String message;
private T data;
public static <T> ApiResponse<T> ok(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.code = 0;
response.message = "ok";
response.data = data;
return response;
}
public static <T> ApiResponse<T> error(int code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.code = code;
response.message = message;
return response;
}
}

View File

@@ -0,0 +1,32 @@
package com.flower.common;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(ApiException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleApi(ApiException ex) {
return ApiResponse.error(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ApiResponse<Void> handleValid(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().isEmpty()
? "参数错误"
: ex.getBindingResult().getFieldErrors().get(0).getDefaultMessage();
return ApiResponse.error(400, message);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public ApiResponse<Void> handleOther(Exception ex) {
return ApiResponse.error(500, ex.getMessage());
}
}

View File

@@ -0,0 +1,21 @@
package com.flower.common;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class PasswordUtil {
public static String sha256(String raw) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(raw.getBytes(StandardCharsets.UTF_8));
StringBuilder sb = new StringBuilder();
for (byte b : hash) {
sb.append(String.format("%02x", b));
}
return sb.toString();
} catch (NoSuchAlgorithmException e) {
throw new IllegalStateException("SHA-256 not supported", e);
}
}
}

View File

@@ -0,0 +1,21 @@
package com.flower.common;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.MultiFormatWriter;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.client.j2se.MatrixToImageWriter;
import java.io.ByteArrayOutputStream;
public class QrCodeUtil {
public static byte[] generatePng(String content, int size) {
try {
BitMatrix matrix = new MultiFormatWriter().encode(content, BarcodeFormat.QR_CODE, size, size);
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
MatrixToImageWriter.writeToStream(matrix, "PNG", outputStream);
return outputStream.toByteArray();
} catch (Exception e) {
throw new ApiException(500, "二维码生成失败");
}
}
}

View File

@@ -0,0 +1,33 @@
package com.flower.confession;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "barrages")
public class Barrage {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long confessionId;
@Column(length = 32)
private String sender;
@Column(nullable = false, length = 200)
private String content;
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.confession;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface BarrageRepository extends JpaRepository<Barrage, Long> {
List<Barrage> findByConfessionIdOrderByCreatedAtAsc(Long confessionId);
}

View File

@@ -0,0 +1,40 @@
package com.flower.confession;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "confessions")
public class Confession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long orderId;
private Long userId;
@Column(nullable = false, unique = true, length = 32)
private String code;
@Column(length = 64)
private String title;
@Column(length = 2000)
private String message;
@Column(length = 255)
private String imageUrl;
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,102 @@
package com.flower.confession;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.common.QrCodeUtil;
import com.flower.order.Order;
import com.flower.order.OrderRepository;
import com.flower.security.AuthContext;
import com.flower.security.PublicEndpoint;
import lombok.Data;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/confessions")
public class ConfessionController {
private final ConfessionRepository confessionRepository;
private final BarrageRepository barrageRepository;
private final OrderRepository orderRepository;
private final String baseUrl;
public ConfessionController(ConfessionRepository confessionRepository,
BarrageRepository barrageRepository,
OrderRepository orderRepository,
@Value("${app.qr.base-url}") String baseUrl) {
this.confessionRepository = confessionRepository;
this.barrageRepository = barrageRepository;
this.orderRepository = orderRepository;
this.baseUrl = baseUrl;
}
@PostMapping
public ApiResponse<Confession> create(@RequestBody CreateConfessionRequest request) {
Order order = orderRepository.findById(request.getOrderId())
.orElseThrow(() -> new ApiException(404, "订单不存在"));
if (!order.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
Confession confession = new Confession();
confession.setOrderId(order.getId());
confession.setUserId(order.getUserId());
confession.setTitle(request.getTitle());
confession.setMessage(request.getMessage());
confession.setImageUrl(request.getImageUrl());
confession.setCode(UUID.randomUUID().toString().replace("-", "").substring(0, 12));
return ApiResponse.ok(confessionRepository.save(confession));
}
@PublicEndpoint
@GetMapping("/{code}")
public ApiResponse<Confession> get(@PathVariable String code) {
return ApiResponse.ok(confessionRepository.findByCode(code)
.orElseThrow(() -> new ApiException(404, "页面不存在")));
}
@PublicEndpoint
@GetMapping("/{code}/barrages")
public ApiResponse<List<Barrage>> listBarrages(@PathVariable String code) {
Confession confession = confessionRepository.findByCode(code)
.orElseThrow(() -> new ApiException(404, "页面不存在"));
return ApiResponse.ok(barrageRepository.findByConfessionIdOrderByCreatedAtAsc(confession.getId()));
}
@PublicEndpoint
@PostMapping("/{code}/barrages")
public ApiResponse<Barrage> createBarrage(@PathVariable String code, @RequestBody CreateBarrageRequest request) {
Confession confession = confessionRepository.findByCode(code)
.orElseThrow(() -> new ApiException(404, "页面不存在"));
Barrage barrage = new Barrage();
barrage.setConfessionId(confession.getId());
barrage.setSender(request.getSender());
barrage.setContent(request.getContent());
return ApiResponse.ok(barrageRepository.save(barrage));
}
@PublicEndpoint
@GetMapping(value = "/{code}/qr", produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> qr(@PathVariable String code) {
String url = baseUrl.endsWith("/") ? baseUrl + code : baseUrl + "/" + code;
byte[] png = QrCodeUtil.generatePng(url, 300);
return ResponseEntity.ok(png);
}
@Data
public static class CreateConfessionRequest {
private Long orderId;
private String title;
private String message;
private String imageUrl;
}
@Data
public static class CreateBarrageRequest {
private String sender;
private String content;
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.confession;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface ConfessionRepository extends JpaRepository<Confession, Long> {
Optional<Confession> findByCode(String code);
}

View File

@@ -0,0 +1,33 @@
package com.flower.config;
import com.flower.security.AuthInterceptor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final String allowedOrigins;
public WebConfig(AuthInterceptor authInterceptor,
@Value("${app.cors.allowed-origins}") String allowedOrigins) {
this.authInterceptor = authInterceptor;
this.allowedOrigins = allowedOrigins;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor).addPathPatterns("/api/**");
}
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins(allowedOrigins.split(","))
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowCredentials(true);
}
}

View File

@@ -0,0 +1,56 @@
package com.flower.order;
import lombok.Data;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 32)
private String orderNo;
@Column(nullable = false)
private Long userId;
@Column(nullable = false, length = 16)
private String status;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal totalAmount;
@Column(length = 32)
private String receiverName;
@Column(length = 32)
private String receiverPhone;
@Column(length = 128)
private String receiverAddress;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
private LocalDateTime payAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,186 @@
package com.flower.order;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.product.Product;
import com.flower.product.ProductRepository;
import com.flower.security.AdminOnly;
import com.flower.security.AuthContext;
import com.flower.user.Address;
import com.flower.user.AddressRepository;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotEmpty;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderRepository orderRepository;
private final OrderItemRepository orderItemRepository;
private final ProductRepository productRepository;
private final AddressRepository addressRepository;
public OrderController(OrderRepository orderRepository,
OrderItemRepository orderItemRepository,
ProductRepository productRepository,
AddressRepository addressRepository) {
this.orderRepository = orderRepository;
this.orderItemRepository = orderItemRepository;
this.productRepository = productRepository;
this.addressRepository = addressRepository;
}
@PostMapping
public ApiResponse<OrderDetail> create(@Valid @RequestBody CreateOrderRequest request) {
Address address = addressRepository.findById(request.getAddressId())
.orElseThrow(() -> new ApiException(404, "地址不存在"));
if (!address.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
List<OrderItem> items = new ArrayList<>();
BigDecimal total = BigDecimal.ZERO;
for (CreateOrderItem item : request.getItems()) {
Product product = productRepository.findById(item.getProductId())
.orElseThrow(() -> new ApiException(404, "商品不存在"));
if (!"ON".equals(product.getStatus())) {
throw new ApiException(400, "商品已下架");
}
if (product.getStock() < item.getQuantity()) {
throw new ApiException(400, "库存不足");
}
product.setStock(product.getStock() - item.getQuantity());
productRepository.save(product);
OrderItem orderItem = new OrderItem();
orderItem.setProductId(product.getId());
orderItem.setProductName(product.getName());
orderItem.setProductPrice(product.getPrice());
orderItem.setQuantity(item.getQuantity());
orderItem.setProductCover(product.getCoverUrl());
items.add(orderItem);
total = total.add(product.getPrice().multiply(new BigDecimal(item.getQuantity())));
}
Order order = new Order();
order.setOrderNo("NO" + UUID.randomUUID().toString().replace("-", "").substring(0, 12));
order.setUserId(AuthContext.get().getId());
order.setStatus("CREATED");
order.setTotalAmount(total);
order.setReceiverName(address.getRecipientName());
order.setReceiverPhone(address.getPhone());
order.setReceiverAddress(String.join(" ", safe(address.getProvince()),
safe(address.getCity()), safe(address.getDistrict()), safe(address.getDetail())));
orderRepository.save(order);
for (OrderItem item : items) {
item.setOrderId(order.getId());
orderItemRepository.save(item);
}
return ApiResponse.ok(new OrderDetail(order, items));
}
@GetMapping
public ApiResponse<List<OrderDetail>> listMine() {
List<Order> orders = orderRepository.findByUserIdOrderByCreatedAtDesc(AuthContext.get().getId());
List<OrderDetail> result = new ArrayList<>();
for (Order order : orders) {
result.add(new OrderDetail(order, orderItemRepository.findByOrderId(order.getId())));
}
return ApiResponse.ok(result);
}
@GetMapping("/{id}")
public ApiResponse<OrderDetail> detail(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow(() -> new ApiException(404, "订单不存在"));
if (!order.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
return ApiResponse.ok(new OrderDetail(order, orderItemRepository.findByOrderId(id)));
}
@PutMapping("/{id}/cancel")
public ApiResponse<Order> cancel(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow(() -> new ApiException(404, "订单不存在"));
if (!order.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
if (!"CREATED".equals(order.getStatus())) {
throw new ApiException(400, "订单无法取消");
}
order.setStatus("CANCELED");
orderRepository.save(order);
return ApiResponse.ok(order);
}
@PostMapping("/{id}/pay")
public ApiResponse<Order> pay(@PathVariable Long id) {
Order order = orderRepository.findById(id).orElseThrow(() -> new ApiException(404, "订单不存在"));
if (!order.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
if (!"CREATED".equals(order.getStatus())) {
throw new ApiException(400, "订单无法支付");
}
order.setStatus("PAID");
order.setPayAt(LocalDateTime.now());
orderRepository.save(order);
return ApiResponse.ok(order);
}
@AdminOnly
@GetMapping("/admin/all")
public ApiResponse<List<OrderDetail>> listAll() {
List<Order> orders = orderRepository.findAll();
List<OrderDetail> result = new ArrayList<>();
for (Order order : orders) {
result.add(new OrderDetail(order, orderItemRepository.findByOrderId(order.getId())));
}
return ApiResponse.ok(result);
}
@AdminOnly
@PutMapping("/admin/{id}/status")
public ApiResponse<Order> updateStatus(@PathVariable Long id, @RequestBody UpdateStatusRequest request) {
Order order = orderRepository.findById(id).orElseThrow(() -> new ApiException(404, "订单不存在"));
order.setStatus(request.getStatus());
orderRepository.save(order);
return ApiResponse.ok(order);
}
private String safe(String value) {
return value == null ? "" : value;
}
@Data
public static class CreateOrderRequest {
private Long addressId;
@NotEmpty
private List<CreateOrderItem> items;
}
@Data
public static class CreateOrderItem {
private Long productId;
private Integer quantity;
}
@Data
public static class UpdateStatusRequest {
private String status;
}
@Data
public static class OrderDetail {
private Order order;
private List<OrderItem> items;
public OrderDetail(Order order, List<OrderItem> items) {
this.order = order;
this.items = items;
}
}
}

View File

@@ -0,0 +1,33 @@
package com.flower.order;
import lombok.Data;
import javax.persistence.*;
import java.math.BigDecimal;
@Data
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long orderId;
@Column(nullable = false)
private Long productId;
@Column(nullable = false, length = 64)
private String productName;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal productPrice;
@Column(nullable = false)
private Integer quantity;
@Column(length = 255)
private String productCover;
}

View File

@@ -0,0 +1,9 @@
package com.flower.order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface OrderItemRepository extends JpaRepository<OrderItem, Long> {
List<OrderItem> findByOrderId(Long orderId);
}

View File

@@ -0,0 +1,11 @@
package com.flower.order;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface OrderRepository extends JpaRepository<Order, Long> {
Optional<Order> findByOrderNo(String orderNo);
List<Order> findByUserIdOrderByCreatedAtDesc(Long userId);
}

View File

@@ -0,0 +1,19 @@
package com.flower.product;
import lombok.Data;
import javax.persistence.*;
@Data
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 32)
private String name;
private Integer sortOrder = 0;
}

View File

@@ -0,0 +1,44 @@
package com.flower.product;
import com.flower.common.ApiResponse;
import com.flower.security.AdminOnly;
import com.flower.security.PublicEndpoint;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/categories")
public class CategoryController {
private final CategoryRepository categoryRepository;
public CategoryController(CategoryRepository categoryRepository) {
this.categoryRepository = categoryRepository;
}
@PublicEndpoint
@GetMapping
public ApiResponse<List<Category>> list() {
return ApiResponse.ok(categoryRepository.findAll());
}
@AdminOnly
@PostMapping
public ApiResponse<Category> create(@RequestBody Category category) {
return ApiResponse.ok(categoryRepository.save(category));
}
@AdminOnly
@PutMapping("/{id}")
public ApiResponse<Category> update(@PathVariable Long id, @RequestBody Category category) {
category.setId(id);
return ApiResponse.ok(categoryRepository.save(category));
}
@AdminOnly
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
categoryRepository.deleteById(id);
return ApiResponse.ok(null);
}
}

View File

@@ -0,0 +1,6 @@
package com.flower.product;
import org.springframework.data.jpa.repository.JpaRepository;
public interface CategoryRepository extends JpaRepository<Category, Long> {
}

View File

@@ -0,0 +1,52 @@
package com.flower.product;
import lombok.Data;
import javax.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long categoryId;
@Column(nullable = false, length = 64)
private String name;
@Column(length = 2000)
private String description;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
private Integer stock = 0;
@Column(length = 255)
private String coverUrl;
@Column(length = 16)
private String status = "ON";
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
updatedAt = LocalDateTime.now();
}
@PreUpdate
public void onUpdate() {
updatedAt = LocalDateTime.now();
}
}

View File

@@ -0,0 +1,61 @@
package com.flower.product;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.security.AdminOnly;
import com.flower.security.PublicEndpoint;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductRepository productRepository;
public ProductController(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@PublicEndpoint
@GetMapping
public ApiResponse<List<Product>> list(@RequestParam(required = false) Long categoryId) {
if (categoryId == null) {
return ApiResponse.ok(productRepository.findByStatus("ON"));
}
return ApiResponse.ok(productRepository.findByCategoryIdAndStatus(categoryId, "ON"));
}
@PublicEndpoint
@GetMapping("/{id}")
public ApiResponse<Product> detail(@PathVariable Long id) {
return ApiResponse.ok(productRepository.findById(id)
.orElseThrow(() -> new ApiException(404, "商品不存在")));
}
@AdminOnly
@GetMapping("/admin/all")
public ApiResponse<List<Product>> adminList() {
return ApiResponse.ok(productRepository.findAll());
}
@AdminOnly
@PostMapping
public ApiResponse<Product> create(@RequestBody Product product) {
return ApiResponse.ok(productRepository.save(product));
}
@AdminOnly
@PutMapping("/{id}")
public ApiResponse<Product> update(@PathVariable Long id, @RequestBody Product product) {
product.setId(id);
return ApiResponse.ok(productRepository.save(product));
}
@AdminOnly
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
productRepository.deleteById(id);
return ApiResponse.ok(null);
}
}

View File

@@ -0,0 +1,10 @@
package com.flower.product;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ProductRepository extends JpaRepository<Product, Long> {
List<Product> findByStatus(String status);
List<Product> findByCategoryIdAndStatus(Long categoryId, String status);
}

View File

@@ -0,0 +1,41 @@
package com.flower.review;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "reviews")
public class Review {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private Long orderId;
private Long productId;
private Long userId;
private Integer rating;
@Column(length = 1000)
private String content;
@Column(length = 1000)
private String images;
@Column(length = 16)
private String status = "PENDING";
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,53 @@
package com.flower.review;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.security.AdminOnly;
import com.flower.security.AuthContext;
import com.flower.security.PublicEndpoint;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/reviews")
public class ReviewController {
private final ReviewRepository reviewRepository;
public ReviewController(ReviewRepository reviewRepository) {
this.reviewRepository = reviewRepository;
}
@PostMapping
public ApiResponse<Review> create(@RequestBody Review review) {
review.setUserId(AuthContext.get().getId());
return ApiResponse.ok(reviewRepository.save(review));
}
@PublicEndpoint
@GetMapping("/product/{productId}")
public ApiResponse<List<Review>> listByProduct(@PathVariable Long productId) {
return ApiResponse.ok(reviewRepository.findByProductIdAndStatus(productId, "APPROVED"));
}
@AdminOnly
@GetMapping("/admin/all")
public ApiResponse<List<Review>> listAll() {
return ApiResponse.ok(reviewRepository.findAll());
}
@AdminOnly
@PutMapping("/admin/{id}/status")
public ApiResponse<Review> updateStatus(@PathVariable Long id, @RequestBody UpdateStatusRequest request) {
Review review = reviewRepository.findById(id).orElseThrow(() -> new ApiException(404, "评价不存在"));
review.setStatus(request.getStatus());
reviewRepository.save(review);
return ApiResponse.ok(review);
}
@Data
public static class UpdateStatusRequest {
private String status;
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.review;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface ReviewRepository extends JpaRepository<Review, Long> {
List<Review> findByProductIdAndStatus(Long productId, String status);
}

View File

@@ -0,0 +1,11 @@
package com.flower.security;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface AdminOnly {
}

View File

@@ -0,0 +1,29 @@
package com.flower.security;
import com.flower.user.User;
public class AuthContext {
private static final ThreadLocal<User> HOLDER = new ThreadLocal<>();
private static final ThreadLocal<String> TOKEN = new ThreadLocal<>();
public static void set(User user) {
HOLDER.set(user);
}
public static User get() {
return HOLDER.get();
}
public static void setToken(String token) {
TOKEN.set(token);
}
public static String getToken() {
return TOKEN.get();
}
public static void clear() {
HOLDER.remove();
TOKEN.remove();
}
}

View File

@@ -0,0 +1,74 @@
package com.flower.security;
import com.flower.common.ApiException;
import com.flower.user.Session;
import com.flower.user.SessionRepository;
import com.flower.user.User;
import com.flower.user.UserRepository;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Optional;
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final SessionRepository sessionRepository;
private final UserRepository userRepository;
public AuthInterceptor(SessionRepository sessionRepository, UserRepository userRepository) {
this.sessionRepository = sessionRepository;
this.userRepository = userRepository;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
if (!(handler instanceof HandlerMethod)) {
return true;
}
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
HandlerMethod method = (HandlerMethod) handler;
if (method.hasMethodAnnotation(PublicEndpoint.class)) {
return true;
}
String token = extractToken(request);
if (!StringUtils.hasText(token)) {
throw new ApiException(401, "未登录");
}
Optional<Session> session = sessionRepository.findByTokenAndExpiredFalse(token);
if (!session.isPresent()) {
throw new ApiException(401, "登录已过期");
}
User user = userRepository.findById(session.get().getUserId())
.orElseThrow(() -> new ApiException(404, "用户不存在"));
if (Boolean.TRUE.equals(user.getDisabled())) {
throw new ApiException(403, "账号已禁用");
}
AuthContext.set(user);
AuthContext.setToken(token);
if (method.hasMethodAnnotation(AdminOnly.class) || method.getBeanType().isAnnotationPresent(AdminOnly.class)) {
if (!"ADMIN".equals(user.getRole())) {
throw new ApiException(403, "无权限");
}
}
return true;
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
AuthContext.clear();
}
private String extractToken(HttpServletRequest request) {
String auth = request.getHeader("Authorization");
if (StringUtils.hasText(auth) && auth.startsWith("Bearer ")) {
return auth.substring(7);
}
return request.getHeader("X-Token");
}
}

View File

@@ -0,0 +1,11 @@
package com.flower.security;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
public @interface PublicEndpoint {
}

View File

@@ -0,0 +1,37 @@
package com.flower.user;
import lombok.Data;
import javax.persistence.*;
@Data
@Entity
@Table(name = "addresses")
public class Address {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false, length = 32)
private String recipientName;
@Column(nullable = false, length = 32)
private String phone;
@Column(length = 32)
private String province;
@Column(length = 32)
private String city;
@Column(length = 32)
private String district;
@Column(length = 255)
private String detail;
private Boolean isDefault = false;
}

View File

@@ -0,0 +1,68 @@
package com.flower.user;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.security.AuthContext;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/addresses")
public class AddressController {
private final AddressRepository addressRepository;
public AddressController(AddressRepository addressRepository) {
this.addressRepository = addressRepository;
}
@GetMapping
public ApiResponse<List<Address>> list() {
return ApiResponse.ok(addressRepository.findByUserId(AuthContext.get().getId()));
}
@PostMapping
public ApiResponse<Address> create(@RequestBody Address address) {
address.setUserId(AuthContext.get().getId());
if (Boolean.TRUE.equals(address.getIsDefault())) {
clearDefault();
}
return ApiResponse.ok(addressRepository.save(address));
}
@PutMapping("/{id}")
public ApiResponse<Address> update(@PathVariable Long id, @RequestBody Address address) {
Address exists = addressRepository.findById(id)
.orElseThrow(() -> new ApiException(404, "地址不存在"));
if (!exists.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
address.setId(id);
address.setUserId(exists.getUserId());
if (Boolean.TRUE.equals(address.getIsDefault())) {
clearDefault();
}
return ApiResponse.ok(addressRepository.save(address));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
Address exists = addressRepository.findById(id)
.orElseThrow(() -> new ApiException(404, "地址不存在"));
if (!exists.getUserId().equals(AuthContext.get().getId())) {
throw new ApiException(403, "无权限");
}
addressRepository.deleteById(id);
return ApiResponse.ok(null);
}
private void clearDefault() {
List<Address> addresses = addressRepository.findByUserId(AuthContext.get().getId());
for (Address item : addresses) {
if (Boolean.TRUE.equals(item.getIsDefault())) {
item.setIsDefault(false);
addressRepository.save(item);
}
}
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface AddressRepository extends JpaRepository<Address, Long> {
List<Address> findByUserId(Long userId);
}

View File

@@ -0,0 +1,99 @@
package com.flower.user;
import com.flower.common.ApiException;
import com.flower.common.ApiResponse;
import com.flower.common.PasswordUtil;
import com.flower.security.AuthContext;
import com.flower.security.PublicEndpoint;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
import javax.validation.Valid;
import javax.validation.constraints.NotBlank;
import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final UserRepository userRepository;
private final SessionRepository sessionRepository;
public AuthController(UserRepository userRepository, SessionRepository sessionRepository) {
this.userRepository = userRepository;
this.sessionRepository = sessionRepository;
}
@PublicEndpoint
@PostMapping("/register")
public ApiResponse<Map<String, Object>> register(@Valid @RequestBody RegisterRequest request) {
userRepository.findByUsername(request.getUsername()).ifPresent(u -> {
throw new ApiException(400, "账号已存在");
});
User user = new User();
user.setUsername(request.getUsername());
user.setPasswordHash(PasswordUtil.sha256(request.getPassword()));
user.setRole("USER");
user.setNickname(request.getNickname());
userRepository.save(user);
return ApiResponse.ok(buildToken(user));
}
@PublicEndpoint
@PostMapping("/login")
public ApiResponse<Map<String, Object>> login(@Valid @RequestBody LoginRequest request) {
User user = userRepository.findByUsername(request.getUsername())
.orElseThrow(() -> new ApiException(404, "账号不存在"));
if (!PasswordUtil.sha256(request.getPassword()).equals(user.getPasswordHash())) {
throw new ApiException(400, "密码错误");
}
if (Boolean.TRUE.equals(user.getDisabled())) {
throw new ApiException(403, "账号已禁用");
}
return ApiResponse.ok(buildToken(user));
}
@PostMapping("/logout")
public ApiResponse<Void> logout() {
sessionRepository.findByTokenAndExpiredFalse(AuthContext.getToken())
.ifPresent(session -> {
session.setExpired(true);
sessionRepository.save(session);
});
return ApiResponse.ok(null);
}
@GetMapping("/me")
public ApiResponse<User> me() {
return ApiResponse.ok(AuthContext.get());
}
private Map<String, Object> buildToken(User user) {
Session session = new Session();
session.setUserId(user.getId());
session.setToken(UUID.randomUUID().toString().replace("-", ""));
sessionRepository.save(session);
Map<String, Object> result = new HashMap<>();
result.put("token", session.getToken());
result.put("user", user);
return result;
}
@Data
public static class RegisterRequest {
@NotBlank(message = "账号不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
private String nickname;
}
@Data
public static class LoginRequest {
@NotBlank(message = "账号不能为空")
private String username;
@NotBlank(message = "密码不能为空")
private String password;
}
}

View File

@@ -0,0 +1,32 @@
package com.flower.user;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "sessions")
public class Session {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private Long userId;
@Column(nullable = false, unique = true, length = 64)
private String token;
private Boolean expired = false;
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface SessionRepository extends JpaRepository<Session, Long> {
Optional<Session> findByTokenAndExpiredFalse(String token);
}

View File

@@ -0,0 +1,47 @@
package com.flower.user;
import lombok.Data;
import javax.persistence.*;
import java.time.LocalDateTime;
@Data
@Entity
@Table(name = "users")
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true, length = 32)
private String username;
@Column(nullable = false, length = 64)
private String passwordHash;
@Column(nullable = false, length = 16)
private String role;
@Column(length = 32)
private String nickname;
@Column(length = 32)
private String phone;
@Column(length = 64)
private String email;
@Column(length = 255)
private String avatarUrl;
private Boolean disabled = false;
private LocalDateTime createdAt;
@PrePersist
public void onCreate() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
}

View File

@@ -0,0 +1,73 @@
package com.flower.user;
import com.flower.common.ApiResponse;
import com.flower.security.AdminOnly;
import com.flower.security.AuthContext;
import lombok.Data;
import org.springframework.web.bind.annotation.*;
import javax.validation.constraints.NotNull;
import java.util.List;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/me")
public ApiResponse<User> me() {
return ApiResponse.ok(AuthContext.get());
}
@PutMapping("/me")
public ApiResponse<User> updateMe(@RequestBody UpdateProfileRequest request) {
User user = AuthContext.get();
if (request.getNickname() != null) {
user.setNickname(request.getNickname());
}
if (request.getPhone() != null) {
user.setPhone(request.getPhone());
}
if (request.getEmail() != null) {
user.setEmail(request.getEmail());
}
if (request.getAvatarUrl() != null) {
user.setAvatarUrl(request.getAvatarUrl());
}
userRepository.save(user);
return ApiResponse.ok(user);
}
@AdminOnly
@GetMapping
public ApiResponse<List<User>> list() {
return ApiResponse.ok(userRepository.findAll());
}
@AdminOnly
@PutMapping("/{id}/status")
public ApiResponse<User> updateStatus(@PathVariable Long id, @RequestBody UpdateStatusRequest request) {
User user = userRepository.findById(id).orElseThrow(() -> new RuntimeException("用户不存在"));
user.setDisabled(request.getDisabled());
userRepository.save(user);
return ApiResponse.ok(user);
}
@Data
public static class UpdateProfileRequest {
private String nickname;
private String phone;
private String email;
private String avatarUrl;
}
@Data
public static class UpdateStatusRequest {
@NotNull
private Boolean disabled;
}
}

View File

@@ -0,0 +1,9 @@
package com.flower.user;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByUsername(String username);
}

View File

@@ -0,0 +1,24 @@
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://localhost:3306/flower_shop?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai
username: root
password: root
jpa:
hibernate:
ddl-auto: update
show-sql: true
properties:
hibernate:
format_sql: true
jackson:
date-format: yyyy-MM-dd HH:mm:ss
time-zone: Asia/Shanghai
app:
cors:
allowed-origins: http://localhost:8081
qr:
base-url: http://localhost:8081/#/gift