This commit is contained in:
2025-09-24 20:35:15 +08:00
parent 39679f7330
commit 8a458ff0a4
12033 changed files with 1537546 additions and 13292 deletions

8
backend/.env Normal file
View File

@@ -0,0 +1,8 @@
WECHAT_MP_APP_ID=wx8c514804683e4be4
WECHAT_MP_APP_SECRET=bd5f31d747b6a2c99eefecf3c8667899
WECHAT_MP_TOKEN_CACHE_SECONDS=6900
JWT_SECRET=U6d2lJ7lKv2PmthSxh8trE8Xl3nZfaErAgHc+X08rYs=
DB_USER=root
DB_PASSWORD=TONA1234
DB_URL=jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_0900_ai_ci

8
backend/.env.example Normal file
View File

@@ -0,0 +1,8 @@
WECHAT_MP_APP_ID=wx8c514804683e4be4
WECHAT_MP_APP_SECRET=bd5f31d747b6a2c99eefecf3c8667899
WECHAT_MP_TOKEN_CACHE_SECONDS=6900
JWT_SECRET=U6d2lJ7lKv2PmthSxh8trE8Xl3nZfaErAgHc+X08rYs=
spring.datasource.username=${DB_USER:root}
spring.datasource.password=${DB_PASSWORD:TONA1234}
spring.datasource.url=${DB_URL:jdbc:mysql://mysql.tonaspace.com:3306/partsinquiry?useSSL=false&allowPublicKeyRetrieval=true&serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf8mb4&connectionCollation=utf8mb4_0900_ai_ci}

View File

@@ -59,6 +59,27 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 认证JWT 签发 -->
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>4.4.0</version>
</dependency>
<!-- 密码哈希BCrypt -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
<version>6.3.4</version>
</dependency>
<!-- .env loader for Spring Boot -->
<dependency>
<groupId>me.paulschwarz</groupId>
<artifactId>spring-dotenv</artifactId>
<version>4.0.0</version>
</dependency>
</dependencies>
<build>

View File

@@ -0,0 +1,67 @@
package com.example.demo.admin;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/admin/consults")
public class AdminConsultController {
private final JdbcTemplate jdbcTemplate;
public AdminConsultController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@GetMapping
public ResponseEntity<?> list(@RequestParam(name = "shopId", required = false) Long shopId,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "kw", required = false) String kw,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
int offset = Math.max(0, page - 1) * Math.max(1, size);
StringBuilder sql = new StringBuilder(
"SELECT c.id,c.user_id AS userId,c.shop_id AS shopId,s.name AS shopName,c.topic,c.message,c.status,c.created_at " +
"FROM consults c JOIN shops s ON s.id=c.shop_id WHERE 1=1");
List<Object> ps = new ArrayList<>();
if (shopId != null) { sql.append(" AND c.shop_id=?"); ps.add(shopId); }
if (status != null && !status.isBlank()) { sql.append(" AND c.status=?"); ps.add(status); }
if (kw != null && !kw.isBlank()) {
sql.append(" AND (c.topic LIKE ? OR c.message LIKE ?)"); String like = "%"+kw.trim()+"%"; ps.add(like); ps.add(like);
}
sql.append(" ORDER BY c.id DESC LIMIT ").append(offset).append(", ").append(size);
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
Map<String,Object> m = new LinkedHashMap<>();
m.put("id", rs.getLong("id"));
m.put("userId", rs.getLong("userId"));
m.put("shopId", rs.getLong("shopId"));
m.put("shopName", rs.getString("shopName"));
m.put("topic", rs.getString("topic"));
m.put("message", rs.getString("message"));
m.put("status", rs.getString("status"));
m.put("createdAt", rs.getTimestamp("created_at"));
return m;
});
Map<String,Object> body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body);
}
@PostMapping("/{id}/reply")
public ResponseEntity<?> reply(@PathVariable("id") Long id,
@RequestHeader(name = "X-User-Id") Long userId,
@RequestBody Map<String,Object> body) {
String content = body == null ? null : String.valueOf(body.get("content"));
if (content == null || content.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","content required"));
jdbcTemplate.update("INSERT INTO consult_replies (consult_id,user_id,content,created_at) VALUES (?,?,?,NOW())", id, userId, content);
return ResponseEntity.ok().build();
}
@PutMapping("/{id}/resolve")
public ResponseEntity<?> resolve(@PathVariable("id") Long id) {
jdbcTemplate.update("UPDATE consults SET status='resolved', updated_at=NOW() WHERE id=?", id);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,143 @@
package com.example.demo.admin;
import com.example.demo.common.AppDefaultsProperties;
import com.example.demo.product.entity.ProductCategory;
import com.example.demo.product.entity.ProductUnit;
import com.example.demo.product.repo.CategoryRepository;
import com.example.demo.product.repo.UnitRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.util.Map;
@RestController
@RequestMapping("/api/admin/dicts")
public class AdminDictController {
private final JdbcTemplate jdbcTemplate;
private final UnitRepository unitRepository;
private final CategoryRepository categoryRepository;
private final AppDefaultsProperties defaults;
public AdminDictController(JdbcTemplate jdbcTemplate, UnitRepository unitRepository, CategoryRepository categoryRepository, AppDefaultsProperties defaults) {
this.jdbcTemplate = jdbcTemplate;
this.unitRepository = unitRepository;
this.categoryRepository = categoryRepository;
this.defaults = defaults;
}
private boolean isPlatformAdmin(Long userId) {
Integer admin = jdbcTemplate.queryForObject("SELECT is_platform_admin FROM users WHERE id=? LIMIT 1", Integer.class, userId);
return admin != null && admin == 1;
}
// ===== Units =====
@PostMapping("/units")
@Transactional
public ResponseEntity<?> createUnit(@RequestHeader("X-User-Id") Long userId, @RequestBody Map<String,Object> body) {
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
String name = body == null ? null : (String) body.get("name");
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
Long sid = defaults.getDictShopId();
if (unitRepository.existsByShopIdAndName(sid, name)) return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
LocalDateTime now = LocalDateTime.now();
ProductUnit u = new ProductUnit();
u.setShopId(sid);
u.setUserId(userId);
u.setName(name.trim());
u.setCreatedAt(now);
u.setUpdatedAt(now);
unitRepository.save(u);
return ResponseEntity.ok(Map.of("id", u.getId()));
}
@PutMapping("/units/{id}")
@Transactional
public ResponseEntity<?> updateUnit(@RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id, @RequestBody Map<String,Object> body) {
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
String name = body == null ? null : (String) body.get("name");
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
ProductUnit u = unitRepository.findById(id).orElse(null);
if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
if (!u.getName().equals(name.trim()) && unitRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim()))
return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
u.setUserId(userId);
u.setName(name.trim());
u.setUpdatedAt(LocalDateTime.now());
unitRepository.save(u);
return ResponseEntity.ok().build();
}
@DeleteMapping("/units/{id}")
@Transactional
public ResponseEntity<?> deleteUnit(@RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id) {
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
ProductUnit u = unitRepository.findById(id).orElse(null);
if (u == null || !u.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
// 引用保护:若有商品使用该单位,阻止删除
Long cnt = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM products WHERE unit_id=?", Long.class, id);
if (cnt != null && cnt > 0) {
return ResponseEntity.status(409).body(Map.of("message","存在引用,无法删除"));
}
unitRepository.deleteById(id);
return ResponseEntity.ok().build();
}
// ===== Categories =====
@PostMapping("/categories")
@Transactional
public ResponseEntity<?> createCategory(@RequestHeader("X-User-Id") Long userId, @RequestBody Map<String,Object> body) {
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
String name = body == null ? null : (String) body.get("name");
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
Long sid = defaults.getDictShopId();
if (categoryRepository.existsByShopIdAndName(sid, name)) return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
LocalDateTime now = LocalDateTime.now();
ProductCategory c = new ProductCategory();
c.setShopId(sid);
c.setUserId(userId);
c.setName(name.trim());
c.setSortOrder(0);
c.setCreatedAt(now);
c.setUpdatedAt(now);
categoryRepository.save(c);
return ResponseEntity.ok(Map.of("id", c.getId()));
}
@PutMapping("/categories/{id}")
@Transactional
public ResponseEntity<?> updateCategory(@RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id, @RequestBody Map<String,Object> body) {
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
String name = body == null ? null : (String) body.get("name");
if (name == null || name.isBlank()) return ResponseEntity.badRequest().body(Map.of("message","name required"));
ProductCategory c = categoryRepository.findById(id).orElse(null);
if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
if (!c.getName().equals(name.trim()) && categoryRepository.existsByShopIdAndName(defaults.getDictShopId(), name.trim()))
return ResponseEntity.badRequest().body(Map.of("message","名称已存在"));
c.setUserId(userId);
c.setName(name.trim());
c.setUpdatedAt(LocalDateTime.now());
categoryRepository.save(c);
return ResponseEntity.ok().build();
}
@DeleteMapping("/categories/{id}")
@Transactional
public ResponseEntity<?> deleteCategory(@RequestHeader("X-User-Id") Long userId, @PathVariable("id") Long id) {
if (!isPlatformAdmin(userId)) return ResponseEntity.status(403).body(Map.of("message","forbidden"));
ProductCategory c = categoryRepository.findById(id).orElse(null);
if (c == null || !c.getShopId().equals(defaults.getDictShopId())) return ResponseEntity.status(404).body(Map.of("message","not found"));
// 子类与引用保护
Long child = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM product_categories WHERE parent_id=?", Long.class, id);
if (child != null && child > 0) return ResponseEntity.status(409).body(Map.of("message","存在子类,无法删除"));
Long cnt = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM products WHERE category_id=?", Long.class, id);
if (cnt != null && cnt > 0) return ResponseEntity.status(409).body(Map.of("message","存在引用,无法删除"));
categoryRepository.deleteById(id);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,76 @@
package com.example.demo.admin;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/admin/parts")
public class AdminPartController {
private final JdbcTemplate jdbcTemplate;
public AdminPartController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@GetMapping
public ResponseEntity<?> list(@RequestParam(name = "shopId", required = false) Long shopId,
@RequestParam(name = "kw", required = false) String kw,
@RequestParam(name = "status", required = false) String status,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
int offset = Math.max(0, page - 1) * Math.max(1, size);
StringBuilder sql = new StringBuilder(
"SELECT p.id,p.user_id AS userId,p.shop_id AS shopId,s.name AS shopName,p.name,p.brand,p.model,p.spec,p.is_blacklisted " +
"FROM products p JOIN shops s ON s.id=p.shop_id WHERE p.deleted_at IS NULL");
List<Object> ps = new ArrayList<>();
if (shopId != null) { sql.append(" AND p.shop_id=?"); ps.add(shopId); }
if (kw != null && !kw.isBlank()) {
sql.append(" AND (p.name LIKE ? OR p.brand LIKE ? OR p.model LIKE ? OR p.spec LIKE ?)");
String like = "%" + kw.trim() + "%";
ps.add(like); ps.add(like); ps.add(like); ps.add(like);
}
if (status != null && !status.isBlank()) {
// 支持两种入参:"1/0"(正常/黑名单)或历史字符串(忽略过滤避免报错)
try {
int s = Integer.parseInt(status);
int isBlack = (s == 1 ? 0 : 1);
sql.append(" AND p.is_blacklisted=?");
ps.add(isBlack);
} catch (NumberFormatException ignore) {
// 兼容历史pending/approved/rejected → 不加过滤
}
}
sql.append(" ORDER BY p.id DESC LIMIT ").append(offset).append(", ").append(size);
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
Map<String,Object> m = new LinkedHashMap<>();
m.put("id", rs.getLong("id"));
m.put("userId", rs.getLong("userId"));
m.put("shopId", rs.getLong("shopId"));
m.put("shopName", rs.getString("shopName"));
m.put("name", rs.getString("name"));
m.put("brand", rs.getString("brand"));
m.put("model", rs.getString("model"));
m.put("spec", rs.getString("spec"));
m.put("status", rs.getInt("is_blacklisted") == 1 ? 0 : 1);
return m;
});
Map<String,Object> body = new HashMap<>(); body.put("list", list); return ResponseEntity.ok(body);
}
@PutMapping("/{id}/blacklist")
public ResponseEntity<?> blacklist(@PathVariable("id") Long id) {
jdbcTemplate.update("UPDATE products SET is_blacklisted=1 WHERE id=?", id);
return ResponseEntity.ok().build();
}
@PutMapping("/{id}/restore")
public ResponseEntity<?> restore(@PathVariable("id") Long id) {
jdbcTemplate.update("UPDATE products SET is_blacklisted=0 WHERE id=?", id);
return ResponseEntity.ok().build();
}
}

View File

@@ -0,0 +1,82 @@
package com.example.demo.admin;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/admin/users")
public class AdminUserController {
private final JdbcTemplate jdbcTemplate;
public AdminUserController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@GetMapping
public ResponseEntity<?> list(@RequestParam(name = "shopId", required = false) Long shopId,
@RequestParam(name = "kw", required = false) String kw,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
int offset = Math.max(0, page - 1) * Math.max(1, size);
StringBuilder sql = new StringBuilder(
"SELECT u.id,u.name,u.phone,u.role,u.status,u.is_owner AS isOwner,u.shop_id AS shopId,s.name AS shopName " +
"FROM users u JOIN shops s ON s.id=u.shop_id WHERE 1=1");
List<Object> ps = new ArrayList<>();
if (shopId != null) { sql.append(" AND u.shop_id=?"); ps.add(shopId); }
if (kw != null && !kw.isBlank()) {
sql.append(" AND (u.name LIKE ? OR u.phone LIKE ? OR u.role LIKE ?)");
String like = "%" + kw.trim() + "%";
ps.add(like); ps.add(like); ps.add(like);
}
sql.append(" ORDER BY id DESC LIMIT ? OFFSET ?");
ps.add(size); ps.add(offset);
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
Map<String,Object> m = new LinkedHashMap<>();
m.put("id", rs.getLong("id"));
m.put("name", rs.getString("name"));
m.put("phone", rs.getString("phone"));
m.put("role", rs.getString("role"));
m.put("status", rs.getInt("status"));
m.put("isOwner", rs.getBoolean("isOwner"));
m.put("shopId", rs.getLong("shopId"));
m.put("shopName", rs.getString("shopName"));
return m;
});
Map<String,Object> body = new HashMap<>();
body.put("list", list);
return ResponseEntity.ok(body);
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable("id") Long id,
@RequestBody Map<String,Object> body) {
// 仅允许更新以下字段
String name = optString(body.get("name"));
String phone = optString(body.get("phone"));
String role = optString(body.get("role"));
Integer status = optInteger(body.get("status"));
Boolean isOwner = optBoolean(body.get("isOwner"));
List<String> sets = new ArrayList<>();
List<Object> ps = new ArrayList<>();
if (name != null) { sets.add("name=?"); ps.add(name); }
if (phone != null) { sets.add("phone=?"); ps.add(phone); }
if (role != null) { sets.add("role=?"); ps.add(role); }
if (status != null) { sets.add("status=?"); ps.add(status); }
if (isOwner != null) { sets.add("is_owner=?"); ps.add(isOwner ? 1 : 0); }
if (sets.isEmpty()) return ResponseEntity.ok().build();
String sql = "UPDATE users SET " + String.join(",", sets) + " WHERE id=?";
ps.add(id);
jdbcTemplate.update(sql, ps.toArray());
return ResponseEntity.ok().build();
}
private static String optString(Object v) { return (v == null ? null : String.valueOf(v)); }
private static Integer optInteger(Object v) { try { return v==null?null:Integer.valueOf(String.valueOf(v)); } catch (Exception e) { return null; } }
private static Boolean optBoolean(Object v) { if (v==null) return null; String s=String.valueOf(v); return ("1".equals(s) || "true".equalsIgnoreCase(s)); }
}

View File

@@ -0,0 +1,102 @@
package com.example.demo.admin;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.*;
import java.util.*;
@RestController
@RequestMapping("/api/admin/vips")
public class AdminVipController {
private final JdbcTemplate jdbcTemplate;
public AdminVipController(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@GetMapping
public ResponseEntity<?> list(@RequestParam(name = "shopId", required = false) Long shopId,
@RequestParam(name = "phone", required = false) String phone,
@RequestParam(name = "status", required = false) Integer status,
@RequestParam(name = "page", defaultValue = "1") int page,
@RequestParam(name = "size", defaultValue = "20") int size) {
int offset = Math.max(0, page - 1) * Math.max(1, size);
StringBuilder sql = new StringBuilder(
"SELECT v.id,v.user_id AS userId,v.is_vip AS isVip,v.status,v.expire_at AS expireAt,v.shop_id AS shopId,s.name AS shopName,u.name,u.phone " +
"FROM vip_users v JOIN users u ON u.id=v.user_id JOIN shops s ON s.id=v.shop_id WHERE 1=1");
List<Object> ps = new ArrayList<>();
if (shopId != null) { sql.append(" AND v.shop_id=?"); ps.add(shopId); }
if (phone != null && !phone.isBlank()) { sql.append(" AND u.phone LIKE ?"); ps.add("%"+phone.trim()+"%"); }
if (status != null) { sql.append(" AND v.status = ?"); ps.add(status); }
sql.append(" ORDER BY v.id DESC LIMIT ").append(offset).append(", ").append(size);
List<Map<String,Object>> list = jdbcTemplate.query(sql.toString(), ps.toArray(), (rs, i) -> {
Map<String,Object> m = new LinkedHashMap<>();
m.put("id", rs.getLong("id"));
m.put("userId", rs.getLong("userId"));
m.put("isVip", rs.getInt("isVip"));
m.put("status", rs.getInt("status"));
m.put("expireAt", rs.getTimestamp("expireAt"));
m.put("shopId", rs.getLong("shopId"));
m.put("shopName", rs.getString("shopName"));
m.put("name", rs.getString("name"));
m.put("phone", rs.getString("phone"));
return m;
});
return ResponseEntity.ok(Map.of("list", list));
}
@PostMapping
public ResponseEntity<?> create(@RequestHeader(name = "X-User-Id") Long userId,
@RequestBody Map<String,Object> body) {
Long sid = asLong(body.get("shopId"));
if (sid == null) return ResponseEntity.badRequest().body(Map.of("message","shopId required"));
Long uid = asLong(body.get("userId")); if (uid == null) return ResponseEntity.badRequest().body(Map.of("message","userId required"));
Integer isVip = asIntOr(body.get("isVip"), 1);
java.sql.Timestamp expireAt = asTimestamp(body.get("expireAt"));
String remark = str(body.get("remark"));
jdbcTemplate.update("INSERT INTO vip_users (shop_id,user_id,is_vip,status,expire_at,remark,created_at,updated_at) VALUES (?,?,?,?,?, ?,NOW(),NOW())",
sid, uid, isVip, 0, expireAt, remark);
return ResponseEntity.ok().build();
}
@PutMapping("/{id}")
public ResponseEntity<?> update(@PathVariable("id") Long id,
@RequestBody Map<String,Object> body) {
List<String> sets = new ArrayList<>(); List<Object> ps = new ArrayList<>();
Integer isVip = asInt(body.get("isVip")); if (isVip != null) { sets.add("is_vip=?"); ps.add(isVip); }
Integer status = asInt(body.get("status")); if (status != null) { sets.add("status=?"); ps.add(status); }
java.sql.Timestamp expireAt = asTimestamp(body.get("expireAt")); if (expireAt != null) { sets.add("expire_at=?"); ps.add(expireAt); }
String remark = str(body.get("remark")); if (remark != null) { sets.add("remark=?"); ps.add(remark); }
if (sets.isEmpty()) return ResponseEntity.ok().build();
String sql = "UPDATE vip_users SET " + String.join(",", sets) + ", updated_at=NOW() WHERE id=?";
ps.add(id);
jdbcTemplate.update(sql, ps.toArray());
return ResponseEntity.ok().build();
}
@PostMapping("/{id}/approve")
public ResponseEntity<?> approve(@PathVariable("id") Long id,
@RequestHeader(name = "X-User-Id") Long reviewerId) {
jdbcTemplate.update("UPDATE vip_users SET status=1, reviewer_id=?, reviewed_at=NOW(), updated_at=NOW() WHERE id=?", reviewerId, id);
return ResponseEntity.ok().build();
}
@PostMapping("/{id}/reject")
public ResponseEntity<?> reject(@PathVariable("id") Long id,
@RequestHeader(name = "X-User-Id") Long reviewerId) {
jdbcTemplate.update("UPDATE vip_users SET status=0, reviewer_id=?, reviewed_at=NOW(), updated_at=NOW() WHERE id=?", reviewerId, id);
return ResponseEntity.ok().build();
}
private static String str(Object v){ return v==null?null:String.valueOf(v); }
private static Integer asInt(Object v){ try { return v==null?null:Integer.valueOf(String.valueOf(v)); } catch(Exception e){ return null; } }
private static Integer asIntOr(Object v, int d){ Integer i = asInt(v); return i==null?d:i; }
private static Long asLong(Object v){ try { return v==null?null:Long.valueOf(String.valueOf(v)); } catch(Exception e){ return null; } }
private static java.sql.Timestamp asTimestamp(Object v){
if (v == null) return null;
try { return java.sql.Timestamp.valueOf(String.valueOf(v).replace('T',' ').replace('Z',' ')); } catch(Exception e){ return null; }
}
}

View File

@@ -0,0 +1,24 @@
package com.example.demo.auth;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret;
private String issuer = "parts-inquiry-api";
private long ttlSeconds = 7200;
private long clockSkewSeconds = 60;
public String getSecret() { return secret; }
public void setSecret(String secret) { this.secret = secret; }
public String getIssuer() { return issuer; }
public void setIssuer(String issuer) { this.issuer = issuer; }
public long getTtlSeconds() { return ttlSeconds; }
public void setTtlSeconds(long ttlSeconds) { this.ttlSeconds = ttlSeconds; }
public long getClockSkewSeconds() { return clockSkewSeconds; }
public void setClockSkewSeconds(long clockSkewSeconds) { this.clockSkewSeconds = clockSkewSeconds; }
}

View File

@@ -0,0 +1,59 @@
package com.example.demo.auth;
import com.auth0.jwt.JWT;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.auth0.jwt.interfaces.JWTVerifier;
import org.springframework.stereotype.Service;
import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
@Service
public class JwtService {
private final JwtProperties props;
public JwtService(JwtProperties props) {
this.props = props;
}
public String signToken(Long userId, Long shopId, String phone, String provider) {
Instant now = Instant.now();
Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret());
var jwt = JWT.create()
.withIssuer(props.getIssuer())
.withIssuedAt(java.util.Date.from(now))
.withExpiresAt(java.util.Date.from(now.plusSeconds(props.getTtlSeconds())))
.withClaim("userId", userId)
.withClaim("shopId", shopId)
.withClaim("provider", provider);
if (phone != null && !phone.isBlank()) jwt.withClaim("phone", phone);
return jwt.sign(alg);
}
public Map<String,Object> parseClaims(String authorizationHeader) {
Map<String,Object> out = new HashMap<>();
if (authorizationHeader == null || authorizationHeader.isBlank()) return out;
String prefix = "Bearer ";
if (!authorizationHeader.startsWith(prefix)) return out;
String token = authorizationHeader.substring(prefix.length()).trim();
try {
Algorithm alg = Algorithm.HMAC256(props.getSecret() == null ? "dev-secret" : props.getSecret());
JWTVerifier verifier = JWT.require(alg)
.withIssuer(props.getIssuer())
.acceptLeeway(props.getClockSkewSeconds())
.build();
DecodedJWT jwt = verifier.verify(token);
Long userId = jwt.getClaim("userId").asLong();
Long shopId = jwt.getClaim("shopId").asLong();
String phone = jwt.getClaim("phone").asString();
if (userId != null) out.put("userId", userId);
if (shopId != null) out.put("shopId", shopId);
if (phone != null && !phone.isBlank()) out.put("phone", phone);
} catch (Exception ignore) { }
return out;
}
}

View File

@@ -0,0 +1,21 @@
package com.example.demo.auth;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class PasswordAuthController {
private final PasswordAuthService service;
public PasswordAuthController(PasswordAuthService service) { this.service = service; }
@PostMapping("/password/login")
public ResponseEntity<?> login(@RequestBody PasswordAuthService.LoginRequest req) {
var resp = service.login(req);
return ResponseEntity.ok(resp);
}
}

View File

@@ -0,0 +1,79 @@
package com.example.demo.auth;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashMap;
import java.util.Map;
@Service
public class PasswordAuthService {
private final JdbcTemplate jdbcTemplate;
private final JwtService jwtService;
private final JwtProperties jwtProps;
public PasswordAuthService(JdbcTemplate jdbcTemplate,
JwtService jwtService,
JwtProperties jwtProps) {
this.jdbcTemplate = jdbcTemplate;
this.jwtService = jwtService;
this.jwtProps = jwtProps;
}
public static class LoginRequest { public String phone; public String password; }
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
@Transactional(readOnly = true)
public LoginResponse login(LoginRequest req) {
ensurePhoneFormat(req.phone);
if (req.password == null || req.password.isBlank()) throw new IllegalArgumentException("密码不能为空");
Map<String, Object> row = jdbcTemplate.query(
con -> {
var ps = con.prepareStatement("SELECT id, shop_id, password_hash, status FROM users WHERE phone=? LIMIT 1");
ps.setString(1, req.phone);
return ps;
},
rs -> {
if (rs.next()) {
Map<String,Object> m = new HashMap<>();
m.put("id", rs.getLong(1));
m.put("shop_id", rs.getLong(2));
m.put("password_hash", rs.getString(3));
m.put("status", rs.getInt(4));
return m;
}
return null;
}
);
if (row == null) throw new IllegalArgumentException("用户不存在");
int status = ((Number)row.get("status")).intValue();
if (status != 1) throw new IllegalArgumentException("用户未启用");
String hash = (String) row.get("password_hash");
if (hash == null || hash.isBlank()) throw new IllegalArgumentException("NO_PASSWORD");
boolean ok = org.springframework.security.crypto.bcrypt.BCrypt.checkpw(req.password, hash);
if (!ok) throw new IllegalArgumentException("密码错误");
Long userId = ((Number)row.get("id")).longValue();
Long shopId = ((Number)row.get("shop_id")).longValue();
String token = jwtService.signToken(userId, shopId, req.phone, "password");
LoginResponse out = new LoginResponse();
out.token = token;
out.expiresIn = jwtProps.getTtlSeconds();
Map<String,Object> userMap = new HashMap<>();
userMap.put("userId", userId); userMap.put("shopId", shopId); userMap.put("phone", req.phone);
out.user = userMap;
return out;
}
private void ensurePhoneFormat(String phone) {
if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空");
String p = phone.replaceAll("\\s+", "");
if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确");
}
}

View File

@@ -0,0 +1,23 @@
package com.example.demo.auth;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth")
public class RegisterController {
private final RegisterService registerService;
public RegisterController(RegisterService registerService) {
this.registerService = registerService;
}
@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody RegisterService.RegisterRequest req) {
var resp = registerService.register(req);
return ResponseEntity.ok(resp);
}
}

View File

@@ -0,0 +1,156 @@
package com.example.demo.auth;
import com.example.demo.common.AppDefaultsProperties;
import org.springframework.beans.factory.annotation.Autowired;
import com.example.demo.common.ShopDefaultsProperties;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.support.GeneratedKeyHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.sql.PreparedStatement;
import java.util.HashMap;
import java.util.Map;
@Service
public class RegisterService {
private final JdbcTemplate jdbcTemplate;
private final JwtService jwtService;
private final JwtProperties jwtProps;
private final ShopDefaultsProperties shopDefaults;
private AppDefaultsProperties appDefaults;
public RegisterService(JdbcTemplate jdbcTemplate,
JwtService jwtService,
JwtProperties jwtProps,
ShopDefaultsProperties shopDefaults) {
this.jdbcTemplate = jdbcTemplate;
this.jwtService = jwtService;
this.jwtProps = jwtProps;
this.shopDefaults = shopDefaults;
}
@Autowired
public void setAppDefaults(AppDefaultsProperties appDefaults) {
this.appDefaults = appDefaults;
}
private String hashPassword(String raw) {
try {
return org.springframework.security.crypto.bcrypt.BCrypt.hashpw(raw, org.springframework.security.crypto.bcrypt.BCrypt.gensalt(10));
} catch (Exception e) {
throw new IllegalStateException("密码加密失败", e);
}
}
public static class RegisterRequest {
public String phone; // 必填11位
public String name; // 可选:默认用脱敏手机号
public String password; // 可选:如提供则保存密码哈希
}
public static class RegisterResponse {
public String token;
public long expiresIn;
public Map<String,Object> user;
}
@Transactional
public RegisterResponse register(RegisterRequest req) {
ensurePhoneFormat(req.phone);
String phone = req.phone.trim();
String displayName = (req.name == null || req.name.isBlank()) ? maskPhoneForName(phone) : req.name.trim();
// 已存在则直接签发令牌
var existing = jdbcTemplate.queryForList("SELECT id, shop_id FROM users WHERE phone=? LIMIT 1", phone);
Long userId;
Long shopId;
if (!existing.isEmpty()) {
Map<String,Object> row = existing.get(0);
userId = ((Number)row.get("id")).longValue();
shopId = ((Number)row.get("shop_id")).longValue();
} else {
// 1) 创建店铺
String shopName = String.format(shopDefaults.getNamePattern(), displayName);
GeneratedKeyHolder shopKey = new GeneratedKeyHolder();
jdbcTemplate.update(con -> {
PreparedStatement ps = con.prepareStatement(
"INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())",
java.sql.Statement.RETURN_GENERATED_KEYS);
ps.setString(1, shopName);
return ps;
}, shopKey);
Number shopGenId = shopKey.getKey();
if (shopGenId == null) throw new IllegalStateException("创建店铺失败");
shopId = shopGenId.longValue();
// 2) 创建店主用户owner
GeneratedKeyHolder userKey = new GeneratedKeyHolder();
final Long sid = shopId;
jdbcTemplate.update(con -> {
PreparedStatement ps = con.prepareStatement(
"INSERT INTO users(shop_id, phone, name, role, password_hash, status, is_owner, created_at, updated_at) " +
"VALUES (?,?,?,?,?,1,1,NOW(),NOW())",
java.sql.Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, sid);
ps.setString(2, phone);
ps.setString(3, displayName);
ps.setString(4, shopDefaults.getOwnerRole());
// 如提供密码,存入哈希;否则设为 NULL
String pwd = (req.password == null || req.password.isBlank()) ? null : hashPassword(req.password);
if (pwd != null) ps.setString(5, pwd); else ps.setNull(5, java.sql.Types.VARCHAR);
return ps;
}, userKey);
Number userGenId = userKey.getKey();
if (userGenId == null) throw new IllegalStateException("创建用户失败");
userId = userGenId.longValue();
// 3) 创建默认账户(现金/银行存款/微信)
createDefaultAccounts(shopId, userId);
}
String token = jwtService.signToken(userId, shopId, phone, "register");
RegisterResponse out = new RegisterResponse();
out.token = token;
out.expiresIn = jwtProps.getTtlSeconds();
HashMap<String,Object> userMap = new HashMap<>();
userMap.put("userId", userId);
userMap.put("shopId", shopId);
userMap.put("phone", phone);
out.user = userMap;
return out;
}
private void createDefaultAccounts(Long shopId, Long userId) {
// 现金
jdbcTemplate.update(
"INSERT INTO accounts(shop_id,user_id,name,`type`,balance,status,created_at,updated_at) " +
"SELECT ?, ?, ?, 'cash', 0, 1, NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE shop_id=? AND name=?)",
shopId, userId, appDefaults.getAccountCashName(), shopId, appDefaults.getAccountCashName());
// 银行存款
jdbcTemplate.update(
"INSERT INTO accounts(shop_id,user_id,name,`type`,balance,status,created_at,updated_at) " +
"SELECT ?, ?, ?, 'bank', 0, 1, NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE shop_id=? AND name=?)",
shopId, userId, appDefaults.getAccountBankName(), shopId, appDefaults.getAccountBankName());
// 微信
jdbcTemplate.update(
"INSERT INTO accounts(shop_id,user_id,name,`type`,balance,status,created_at,updated_at) " +
"SELECT ?, ?, ?, 'wechat', 0, 1, NOW(), NOW() FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM accounts WHERE shop_id=? AND name=?)",
shopId, userId, appDefaults.getAccountWechatName(), shopId, appDefaults.getAccountWechatName());
}
private void ensurePhoneFormat(String phone) {
if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空");
String p = phone.replaceAll("\\s+", "");
if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确");
}
private static String maskPhoneForName(String phone) {
String p = String.valueOf(phone);
if (p.length() == 11) return "用户" + p.substring(0,3) + "****" + p.substring(7);
return "手机用户";
}
}

View File

@@ -0,0 +1,45 @@
package com.example.demo.auth;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.context.request.RequestAttributes;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
@RestController
@RequestMapping("/api/auth/sms")
public class SmsAuthController {
private final SmsAuthService smsAuthService;
public SmsAuthController(SmsAuthService smsAuthService) {
this.smsAuthService = smsAuthService;
}
@PostMapping("/send")
public ResponseEntity<?> send(@RequestBody SmsAuthService.SendCodeRequest req,
@RequestHeader(value = "X-Forwarded-For", required = false) String xff,
@RequestHeader(value = "X-Real-IP", required = false) String xri,
@RequestHeader(value = "X-Shop-Id", required = false) Long shopId) {
String ip = xri != null ? xri : (xff != null ? xff.split(",")[0].trim() : getClientIp());
SmsAuthService.SendCodeResponse resp = smsAuthService.sendCode(req, ip);
return ResponseEntity.ok(resp);
}
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody SmsAuthService.LoginRequest req) {
SmsAuthService.LoginResponse resp = smsAuthService.login(req);
return ResponseEntity.ok(resp);
}
private String getClientIp() {
RequestAttributes attrs = RequestContextHolder.getRequestAttributes();
if (attrs instanceof ServletRequestAttributes sra) {
var req = sra.getRequest();
return req.getRemoteAddr();
}
return "";
}
}

View File

@@ -0,0 +1,199 @@
package com.example.demo.auth;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.sql.PreparedStatement;
import java.util.HexFormat;
import java.util.List;
import java.util.Map;
@Service
public class SmsAuthService {
private final JdbcTemplate jdbcTemplate;
private final JwtService jwtService;
private final JwtProperties jwtProps;
private final com.example.demo.common.ShopDefaultsProperties shopDefaults;
public SmsAuthService(JdbcTemplate jdbcTemplate,
JwtService jwtService,
JwtProperties jwtProps,
com.example.demo.common.ShopDefaultsProperties shopDefaults) {
this.jdbcTemplate = jdbcTemplate;
this.jwtService = jwtService;
this.jwtProps = jwtProps;
this.shopDefaults = shopDefaults;
}
public static class SendCodeRequest { public String phone; public String scene; }
public static class SendCodeResponse { public boolean ok; public long cooldownSec; }
public static class LoginRequest { public String phone; public String code; }
public static class LoginResponse { public String token; public long expiresIn; public Map<String,Object> user; }
private String generateCode() {
SecureRandom rng = new SecureRandom();
int n = 100000 + rng.nextInt(900000);
return String.valueOf(n);
}
private static String hmacSha256Hex(String secret, String message) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
md.update((message + secret).getBytes(java.nio.charset.StandardCharsets.UTF_8));
return HexFormat.of().formatHex(md.digest());
} catch (Exception e) { throw new RuntimeException(e); }
}
private void ensurePhoneFormat(String phone) {
if (phone == null || phone.isBlank()) throw new IllegalArgumentException("手机号不能为空");
String p = phone.replaceAll("\\s+", "");
if (!p.matches("^1\\d{10}$")) throw new IllegalArgumentException("手机号格式不正确");
}
@Transactional
public SendCodeResponse sendCode(SendCodeRequest req, String clientIp) {
ensurePhoneFormat(req.phone);
String phone = req.phone;
String scene = (req.scene == null || req.scene.isBlank()) ? "login" : req.scene;
Long cntRecent = jdbcTemplate.queryForObject(
"SELECT COUNT(1) FROM sms_codes WHERE phone=? AND scene=? AND created_at >= NOW() - INTERVAL 60 SECOND",
Long.class, phone, scene);
if (cntRecent != null && cntRecent > 0) {
SendCodeResponse out = new SendCodeResponse();
out.ok = false; out.cooldownSec = 60;
return out;
}
String code = generateCode();
String salt = Long.toHexString(System.nanoTime());
String codeHash = hmacSha256Hex(salt, code);
int ttl = 300; // 五分钟有效
jdbcTemplate.update(con -> {
PreparedStatement ps = con.prepareStatement(
"INSERT INTO sms_codes(phone, scene, code_hash, salt, expire_at, status, fail_count, ip, created_at, updated_at) " +
"VALUES (?,?,?,?,DATE_ADD(NOW(), INTERVAL ? SECOND),0,0,?,NOW(),NOW())");
ps.setString(1, phone);
ps.setString(2, scene);
ps.setString(3, codeHash);
ps.setString(4, salt);
ps.setInt(5, ttl);
ps.setString(6, clientIp);
return ps;
});
// TODO: 集成真实短信发送;当前仅存表,不外发
SendCodeResponse out = new SendCodeResponse();
out.ok = true; out.cooldownSec = 60;
return out;
}
@Transactional
public LoginResponse login(LoginRequest req) {
ensurePhoneFormat(req.phone);
if (req.code == null || req.code.isBlank()) throw new IllegalArgumentException("验证码不能为空");
String phone = req.phone;
Map<String,Object> row = jdbcTemplate.query(
con -> {
var ps = con.prepareStatement("SELECT id, code_hash, salt, expire_at, status, fail_count FROM sms_codes WHERE phone=? AND scene='login' ORDER BY id DESC LIMIT 1");
ps.setString(1, phone);
return ps;
},
rs -> {
if (rs.next()) {
java.util.HashMap<String,Object> m = new java.util.HashMap<>();
m.put("id", rs.getLong(1));
m.put("code_hash", rs.getString(2));
m.put("salt", rs.getString(3));
m.put("expire_at", rs.getTimestamp(4));
m.put("status", rs.getInt(5));
m.put("fail_count", rs.getInt(6));
return m;
}
return null;
}
);
if (row == null) throw new IllegalArgumentException("CODE_INVALID");
int status = (Integer) row.get("status");
if (status != 0) throw new IllegalArgumentException("CODE_INVALID");
java.sql.Timestamp expireAt = (java.sql.Timestamp) row.get("expire_at");
if (expireAt.before(new java.util.Date())) {
jdbcTemplate.update("UPDATE sms_codes SET status=2 WHERE id=?", (Long) row.get("id"));
throw new IllegalArgumentException("CODE_EXPIRED");
}
int failCount = (Integer) row.get("fail_count");
if (failCount >= 5) throw new IllegalArgumentException("TOO_MANY_FAILS");
String expect = (String) row.get("code_hash");
String salt = (String) row.get("salt");
String actual = hmacSha256Hex(salt, req.code);
if (!actual.equalsIgnoreCase(expect)) {
jdbcTemplate.update("UPDATE sms_codes SET fail_count=fail_count+1 WHERE id=?", (Long) row.get("id"));
throw new IllegalArgumentException("CODE_INVALID");
}
jdbcTemplate.update("UPDATE sms_codes SET status=1 WHERE id=?", (Long) row.get("id"));
List<Long> existing = jdbcTemplate.queryForList("SELECT id FROM users WHERE phone=? LIMIT 1", Long.class, phone);
Long userId;
Long shopId;
if (!existing.isEmpty()) {
userId = existing.get(0);
List<Long> sids = jdbcTemplate.queryForList("SELECT shop_id FROM users WHERE id=?", Long.class, userId);
shopId = sids.isEmpty() ? 1L : sids.get(0);
} else {
String userName = maskPhoneForName(phone);
String shopName = String.format(shopDefaults.getNamePattern(), userName);
var shopKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
jdbcTemplate.update(con -> {
var ps = con.prepareStatement("INSERT INTO shops(name, status, created_at, updated_at) VALUES (?,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
ps.setString(1, shopName);
return ps;
}, shopKey);
Number shopGenId = shopKey.getKey();
if (shopGenId == null) throw new IllegalStateException("创建店铺失败");
shopId = shopGenId.longValue();
var userKey = new org.springframework.jdbc.support.GeneratedKeyHolder();
final Long sid = shopId;
jdbcTemplate.update(con -> {
var ps = con.prepareStatement("INSERT INTO users(shop_id, phone, name, role, status, is_owner, created_at, updated_at) VALUES (?,?,?,?,1,1,NOW(),NOW())", java.sql.Statement.RETURN_GENERATED_KEYS);
ps.setLong(1, sid);
ps.setString(2, phone);
ps.setString(3, userName);
ps.setString(4, shopDefaults.getOwnerRole());
return ps;
}, userKey);
Number userGenId = userKey.getKey();
if (userGenId == null) throw new IllegalStateException("创建用户失败");
userId = userGenId.longValue();
}
String token = jwtService.signToken(userId, shopId, phone, "sms_otp");
LoginResponse out = new LoginResponse();
out.token = token;
out.expiresIn = jwtProps.getTtlSeconds();
java.util.HashMap<String,Object> userMap = new java.util.HashMap<>();
userMap.put("userId", userId);
userMap.put("shopId", shopId);
userMap.put("phone", phone);
out.user = userMap;
return out;
}
private static String maskPhoneForName(String phone) {
String p = String.valueOf(phone);
if (p.length() == 11) {
return "用户" + p.substring(0,3) + "****" + p.substring(7);
}
return "手机用户";
}
}

View File

@@ -0,0 +1,43 @@
package com.example.demo.common;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.lang.NonNull;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@Component
public class AdminAuthInterceptor implements HandlerInterceptor {
private final JdbcTemplate jdbcTemplate;
public AdminAuthInterceptor(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) throws Exception {
// 预检请求直接放行(由 CORS 处理器返回允许头)
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
return true;
}
// 简化版:临时从 X-User-Id 读取管理员身份,后续可改为 JWT
String userIdHeader = request.getHeader("X-User-Id");
if (userIdHeader == null || userIdHeader.isBlank()) {
response.sendError(401, "missing X-User-Id");
return false;
}
Long uid;
try { uid = Long.valueOf(userIdHeader); } catch (Exception e) { response.sendError(401, "invalid user"); return false; }
Integer admin = jdbcTemplate.queryForObject("SELECT is_platform_admin FROM users WHERE id=? LIMIT 1", Integer.class, uid);
if (admin == null || admin != 1) {
response.sendError(403, "forbidden");
return false;
}
return true;
}
}

View File

@@ -9,6 +9,8 @@ public class AppDefaultsProperties {
private Long shopId = 1L;
private Long userId = 2L;
// 字典全局使用的虚拟店铺ID方案Ashop_id=0 代表全局共享
private Long dictShopId = 0L;
// 默认账户名称(可配置,避免硬编码)
private String accountCashName = "现金";
@@ -21,6 +23,9 @@ public class AppDefaultsProperties {
public Long getUserId() { return userId; }
public void setUserId(Long userId) { this.userId = userId; }
public Long getDictShopId() { return dictShopId; }
public void setDictShopId(Long dictShopId) { this.dictShopId = dictShopId; }
public String getAccountCashName() { return accountCashName; }
public void setAccountCashName(String accountCashName) { this.accountCashName = accountCashName; }
public String getAccountBankName() { return accountBankName; }

View File

@@ -0,0 +1,32 @@
package com.example.demo.common;
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.WebMvcConfigurer;
import java.util.Arrays;
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Value("${app.cors.allowed-origins:*}")
private String allowedOrigins;
@Override
public void addCorsMappings(CorsRegistry registry) {
String[] origins = Arrays.stream(allowedOrigins.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.toArray(String[]::new);
registry.addMapping("/**")
.allowedOrigins(origins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.exposedHeaders("Content-Disposition")
.allowCredentials(false)
.maxAge(3600);
}
}

View File

@@ -91,14 +91,17 @@ public class FinanceService {
private List<Map<String, String>> queryCategoriesFromTable(Long shopId, String type) {
Long fallbackShopId = appDefaults == null ? 1L : (appDefaults.getShopId() == null ? 1L : appDefaults.getShopId());
Long dictShopId = appDefaults == null ? 1000L : (appDefaults.getDictShopId() == null ? 1000L : appDefaults.getDictShopId());
try (java.sql.Connection c = dataSource.getConnection();
java.sql.PreparedStatement ps = c.prepareStatement(
"SELECT shop_id, `key`, label FROM finance_categories WHERE shop_id IN (?, ?) AND type=? AND status=1 " +
"ORDER BY CASE WHEN shop_id=? THEN 0 ELSE 1 END, sort_order, id")) {
"SELECT shop_id, `key`, label FROM finance_categories WHERE shop_id IN (?, ?, ?) AND type=? AND status=1 " +
"ORDER BY CASE WHEN shop_id=? THEN 0 WHEN shop_id=? THEN 1 ELSE 2 END, sort_order, id")) {
ps.setLong(1, shopId);
ps.setLong(2, fallbackShopId);
ps.setString(3, type);
ps.setLong(4, shopId);
ps.setLong(3, dictShopId);
ps.setString(4, type);
ps.setLong(5, shopId);
ps.setLong(6, dictShopId);
try (java.sql.ResultSet rs = ps.executeQuery()) {
java.util.Map<String,String> firstByKey = new java.util.LinkedHashMap<>();
while (rs.next()) {

View File

@@ -1,5 +1,7 @@
package com.example.demo.common;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
@@ -11,6 +13,8 @@ import java.util.Map;
@RestControllerAdvice
public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private ResponseEntity<Map<String,Object>> badRequest(String message) {
Map<String,Object> body = new HashMap<>();
body.put("message", message);
@@ -19,23 +23,29 @@ public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<?> handleIllegalArgument(IllegalArgumentException ex) {
log.warn("Bad request: {}", ex.getMessage());
return badRequest(ex.getMessage());
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<?> handleIllegalState(IllegalStateException ex) {
log.warn("Illegal state: {}", ex.getMessage());
return badRequest(ex.getMessage());
}
@ExceptionHandler(DataAccessException.class)
public ResponseEntity<?> handleDataAccess(DataAccessException ex) {
log.error("DataAccessException", ex);
return badRequest("数据库操作失败");
}
@ExceptionHandler(Exception.class)
public ResponseEntity<?> handleAny(Exception ex) {
log.error("Unhandled exception", ex);
Map<String,Object> body = new HashMap<>();
body.put("message", ex.getMessage() == null ? "Internal Server Error" : ex.getMessage());
// 附加异常类型,便于前端调试
body.put("error", ex.getClass().getSimpleName());
return ResponseEntity.status(500).body(body);
}
}

View File

@@ -27,6 +27,19 @@ public class RequestLoggingFilter extends OncePerRequestFilter {
String query = request.getQueryString();
String shopId = request.getHeader("X-Shop-Id");
String userId = request.getHeader("X-User-Id");
if ((shopId == null || shopId.isBlank()) && (userId == null || userId.isBlank())) {
String auth = request.getHeader("Authorization");
try {
com.example.demo.auth.JwtService jwtSvc = org.springframework.web.context.support.WebApplicationContextUtils
.getRequiredWebApplicationContext(request.getServletContext())
.getBean(com.example.demo.auth.JwtService.class);
java.util.Map<String,Object> claims = jwtSvc.parseClaims(auth);
Object sid = claims.get("shopId");
Object uid = claims.get("userId");
if (sid != null) shopId = String.valueOf(sid);
if (uid != null) userId = String.valueOf(uid);
} catch (Exception ignore) { }
}
try {
filterChain.doFilter(request, response);

View File

@@ -0,0 +1,23 @@
package com.example.demo.common;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Component
@ConfigurationProperties(prefix = "app.shop")
public class ShopDefaultsProperties {
// 店铺名称规则,使用 String.format 占位:%s -> 用户名
private String namePattern = "%s_1";
// 店主角色名称
private String ownerRole = "owner";
public String getNamePattern() { return namePattern; }
public void setNamePattern(String namePattern) { this.namePattern = namePattern; }
public String getOwnerRole() { return ownerRole; }
public void setOwnerRole(String ownerRole) { this.ownerRole = ownerRole; }
}

View File

@@ -0,0 +1,18 @@
package com.example.demo.common;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebConfig implements WebMvcConfigurer {
public WebConfig() { }
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 登录功能已移除,此处不再注册管理端鉴权拦截器
}
}

View File

@@ -26,17 +26,15 @@ public class MetadataController {
@GetMapping("/api/product-units")
public ResponseEntity<?> listUnits(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Map<String, Object> body = new HashMap<>();
body.put("list", unitRepository.listByShop(sid));
body.put("list", unitRepository.listByShop(defaults.getDictShopId()));
return ResponseEntity.ok(body);
}
@GetMapping("/api/product-categories")
public ResponseEntity<?> listCategories(@RequestHeader(name = "X-Shop-Id", required = false) Long shopId) {
Long sid = (shopId == null ? defaults.getShopId() : shopId);
Map<String, Object> body = new HashMap<>();
body.put("list", categoryRepository.listByShop(sid));
body.put("list", categoryRepository.listByShop(defaults.getDictShopId()));
return ResponseEntity.ok(body);
}
}

View File

@@ -10,6 +10,8 @@ import java.util.List;
public interface CategoryRepository extends JpaRepository<ProductCategory, Long> {
@Query("SELECT c FROM ProductCategory c WHERE c.shopId = :shopId AND c.deletedAt IS NULL ORDER BY c.sortOrder ASC, c.id DESC")
List<ProductCategory> listByShop(@Param("shopId") Long shopId);
boolean existsByShopIdAndName(Long shopId, String name);
}

View File

@@ -9,6 +9,8 @@ import java.util.List;
public interface UnitRepository extends JpaRepository<com.example.demo.product.entity.ProductUnit, Long> {
@Query("SELECT u FROM ProductUnit u WHERE u.shopId = :shopId AND u.deletedAt IS NULL ORDER BY u.id DESC")
List<com.example.demo.product.entity.ProductUnit> listByShop(@Param("shopId") Long shopId);
boolean existsByShopIdAndName(Long shopId, String name);
}

View File

@@ -55,6 +55,8 @@ attachments.url.allowed-content-types=${ATTACHMENTS_URL_ALLOWED_CONTENT_TYPES:im
# 应用默认上下文(用于开发/演示环境)
app.defaults.shop-id=${APP_DEFAULT_SHOP_ID:1}
app.defaults.user-id=${APP_DEFAULT_USER_ID:2}
# 全局字典使用的虚拟店铺ID方案A
app.defaults.dict-shop-id=${APP_DEFAULT_DICT_SHOP_ID:0}
# 财务分类默认配置(前端请调用 /api/finance/categories 获取,禁止硬编码)
app.finance.income-categories=${APP_FINANCE_INCOME:operation_income:经营所得,interest_income:利息收入,other_income:其它收入,deposit_ar_income:收订金/欠款,investment_income:投资收入,sale_income:销售收入,account_operation:账户操作,fund_transfer_in:资金转账转入}
@@ -65,3 +67,5 @@ app.account.defaults.cash-name=${APP_ACCOUNT_CASH_NAME:现金}
app.account.defaults.bank-name=${APP_ACCOUNT_BANK_NAME:银行存款}
app.account.defaults.wechat-name=${APP_ACCOUNT_WECHAT_NAME:微信}
app.account.defaults.alipay-name=${APP_ACCOUNT_ALIPAY_NAME:支付宝}
# 登录相关配置已移除