Update mengyastore
This commit is contained in:
10
.idea/.gitignore
generated
vendored
Normal file
10
.idea/.gitignore
generated
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
# 默认忽略的文件
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# 已忽略包含查询文件的默认文件夹
|
||||||
|
/queries/
|
||||||
|
# Datasource local storage ignored files
|
||||||
|
/dataSources/
|
||||||
|
/dataSources.local.xml
|
||||||
|
# 基于编辑器的 HTTP 客户端请求
|
||||||
|
/httpRequests/
|
||||||
21
.idea/compiler.xml
generated
Normal file
21
.idea/compiler.xml
generated
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="CompilerConfiguration">
|
||||||
|
<annotationProcessing>
|
||||||
|
<profile name="Annotation profile for mengyastore-backend-java" enabled="true">
|
||||||
|
<sourceOutputDir name="target/generated-sources/annotations" />
|
||||||
|
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
|
||||||
|
<outputRelativeToContentRoot value="true" />
|
||||||
|
<processorPath useClasspath="false">
|
||||||
|
<entry name="$MAVEN_REPOSITORY$/org/projectlombok/lombok/1.18.44/lombok-1.18.44.jar" />
|
||||||
|
</processorPath>
|
||||||
|
<module name="mengyastore-backend-java" />
|
||||||
|
</profile>
|
||||||
|
</annotationProcessing>
|
||||||
|
</component>
|
||||||
|
<component name="JavacSettings">
|
||||||
|
<option name="ADDITIONAL_OPTIONS_OVERRIDE">
|
||||||
|
<module name="mengyastore-backend-java" options="-parameters" />
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/encodings.xml
generated
Normal file
6
.idea/encodings.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Encoding">
|
||||||
|
<file url="file://$PROJECT_DIR$/mengyastore-backend-java/src/main/java" charset="UTF-8" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
20
.idea/jarRepositories.xml
generated
Normal file
20
.idea/jarRepositories.xml
generated
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="RemoteRepositoriesConfiguration">
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="central" />
|
||||||
|
<option name="name" value="Central Repository" />
|
||||||
|
<option name="url" value="https://repo.maven.apache.org/maven2" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="central" />
|
||||||
|
<option name="name" value="Maven Central repository" />
|
||||||
|
<option name="url" value="https://repo1.maven.org/maven2" />
|
||||||
|
</remote-repository>
|
||||||
|
<remote-repository>
|
||||||
|
<option name="id" value="jboss.community" />
|
||||||
|
<option name="name" value="JBoss Community repository" />
|
||||||
|
<option name="url" value="https://repository.jboss.org/nexus/content/repositories/public/" />
|
||||||
|
</remote-repository>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
9
.idea/mengyastore.iml
generated
Normal file
9
.idea/mengyastore.iml
generated
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="JAVA_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||||
|
<exclude-output />
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
14
.idea/misc.xml
generated
Normal file
14
.idea/misc.xml
generated
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ExternalStorageConfigurationManager" enabled="true" />
|
||||||
|
<component name="MavenProjectsManager">
|
||||||
|
<option name="originalFiles">
|
||||||
|
<list>
|
||||||
|
<option value="$PROJECT_DIR$/mengyastore-backend-java/pom.xml" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" languageLevel="JDK_24" default="true" project-jdk-name="24" project-jdk-type="JavaSDK">
|
||||||
|
<output url="file://$PROJECT_DIR$/out" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
8
.idea/modules.xml
generated
Normal file
8
.idea/modules.xml
generated
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/mengyastore.iml" filepath="$PROJECT_DIR$/.idea/mengyastore.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
6
.idea/vcs.xml
generated
Normal file
6
.idea/vcs.xml
generated
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
10
mengyastore-backend-go/.gitignore
vendored
10
mengyastore-backend-go/.gitignore
vendored
@@ -1,5 +1,5 @@
|
|||||||
# Local secrets — use .env.example / .env.production.example as templates
|
# Local secrets — use .env.example / .env.production.example as templates
|
||||||
.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
.env.production
|
.env.production
|
||||||
*.exe
|
*.exe
|
||||||
|
|||||||
@@ -1,310 +1,310 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/go-sql-driver/mysql"
|
"github.com/go-sql-driver/mysql"
|
||||||
"github.com/redis/go-redis/v9"
|
"github.com/redis/go-redis/v9"
|
||||||
gormDB "gorm.io/gorm"
|
gormDB "gorm.io/gorm"
|
||||||
|
|
||||||
"mengyastore-backend/internal/config"
|
"mengyastore-backend/internal/config"
|
||||||
"mengyastore-backend/internal/mq"
|
"mengyastore-backend/internal/mq"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SystemStatusHandler exposes admin-only operational status.
|
// SystemStatusHandler exposes admin-only operational status.
|
||||||
type SystemStatusHandler struct {
|
type SystemStatusHandler struct {
|
||||||
cfg *config.Config
|
cfg *config.Config
|
||||||
db *gormDB.DB
|
db *gormDB.DB
|
||||||
mq *mq.Client
|
mq *mq.Client
|
||||||
start time.Time
|
start time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewSystemStatusHandler(cfg *config.Config, db *gormDB.DB, mqClient *mq.Client, startedAt time.Time) *SystemStatusHandler {
|
func NewSystemStatusHandler(cfg *config.Config, db *gormDB.DB, mqClient *mq.Client, startedAt time.Time) *SystemStatusHandler {
|
||||||
return &SystemStatusHandler{cfg: cfg, db: db, mq: mqClient, start: startedAt}
|
return &SystemStatusHandler{cfg: cfg, db: db, mq: mqClient, start: startedAt}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSystemStatus returns JSON for the admin dashboard. Requires admin token.
|
// GetSystemStatus returns JSON for the admin dashboard. Requires admin token.
|
||||||
func (h *SystemStatusHandler) GetSystemStatus(c *gin.Context) {
|
func (h *SystemStatusHandler) GetSystemStatus(c *gin.Context) {
|
||||||
if !h.adminTokenOK(c) {
|
if !h.adminTokenOK(c) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
ctx, cancel := context.WithTimeout(c.Request.Context(), 8*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"data": gin.H{
|
"data": gin.H{
|
||||||
"backend": h.backendInfo(c),
|
"backend": h.backendInfo(c),
|
||||||
"mysql": h.mysqlInfo(ctx),
|
"mysql": h.mysqlInfo(ctx),
|
||||||
"redis": h.redisInfo(ctx),
|
"redis": h.redisInfo(ctx),
|
||||||
"rabbitmq": h.rabbitmqInfo(),
|
"rabbitmq": h.rabbitmqInfo(),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SystemStatusHandler) adminTokenOK(c *gin.Context) bool {
|
func (h *SystemStatusHandler) adminTokenOK(c *gin.Context) bool {
|
||||||
token := c.GetHeader("X-Admin-Token")
|
token := c.GetHeader("X-Admin-Token")
|
||||||
if token == "" {
|
if token == "" {
|
||||||
token = c.GetHeader("Authorization")
|
token = c.GetHeader("Authorization")
|
||||||
}
|
}
|
||||||
if token == "" {
|
if token == "" {
|
||||||
token = c.Query("token")
|
token = c.Query("token")
|
||||||
}
|
}
|
||||||
if token != "" && token == h.cfg.AdminToken {
|
if token != "" && token == h.cfg.AdminToken {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"})
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SystemStatusHandler) backendInfo(c *gin.Context) gin.H {
|
func (h *SystemStatusHandler) backendInfo(c *gin.Context) gin.H {
|
||||||
proto := c.GetHeader("X-Forwarded-Proto")
|
proto := c.GetHeader("X-Forwarded-Proto")
|
||||||
if proto == "" {
|
if proto == "" {
|
||||||
if c.Request.TLS != nil {
|
if c.Request.TLS != nil {
|
||||||
proto = "https"
|
proto = "https"
|
||||||
} else {
|
} else {
|
||||||
proto = "http"
|
proto = "http"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
host := c.Request.Host
|
host := c.Request.Host
|
||||||
publicBase := strings.TrimSpace(h.cfg.PublicAPIBaseURL)
|
publicBase := strings.TrimSpace(h.cfg.PublicAPIBaseURL)
|
||||||
if publicBase == "" && host != "" {
|
if publicBase == "" && host != "" {
|
||||||
publicBase = proto + "://" + host
|
publicBase = proto + "://" + host
|
||||||
}
|
}
|
||||||
|
|
||||||
out := gin.H{
|
out := gin.H{
|
||||||
"status": "ok",
|
"status": "ok",
|
||||||
"appEnv": h.cfg.AppEnv,
|
"appEnv": h.cfg.AppEnv,
|
||||||
"ginMode": gin.Mode(),
|
"ginMode": gin.Mode(),
|
||||||
"requestHost": host,
|
"requestHost": host,
|
||||||
"listenAddr": h.cfg.HTTPListenAddr,
|
"listenAddr": h.cfg.HTTPListenAddr,
|
||||||
"publicBaseUrl": publicBase,
|
"publicBaseUrl": publicBase,
|
||||||
"uptimeSeconds": int(time.Since(h.start).Seconds()),
|
"uptimeSeconds": int(time.Since(h.start).Seconds()),
|
||||||
"authApiConfigured": strings.TrimSpace(h.cfg.AuthAPIURL) != "",
|
"authApiConfigured": strings.TrimSpace(h.cfg.AuthAPIURL) != "",
|
||||||
"rabbitmqEnabled": h.cfg.RabbitMQEnabled,
|
"rabbitmqEnabled": h.cfg.RabbitMQEnabled,
|
||||||
"redisEnabled": h.cfg.RedisEnabled,
|
"redisEnabled": h.cfg.RedisEnabled,
|
||||||
}
|
}
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SystemStatusHandler) mysqlInfo(ctx context.Context) gin.H {
|
func (h *SystemStatusHandler) mysqlInfo(ctx context.Context) gin.H {
|
||||||
out := gin.H{}
|
out := gin.H{}
|
||||||
parseMysqlDSNForDisplay(h.cfg.DatabaseDSN, out)
|
parseMysqlDSNForDisplay(h.cfg.DatabaseDSN, out)
|
||||||
|
|
||||||
if h.db == nil {
|
if h.db == nil {
|
||||||
out["status"] = "error"
|
out["status"] = "error"
|
||||||
out["message"] = "数据库未初始化"
|
out["message"] = "数据库未初始化"
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
sqlDB, err := h.db.DB()
|
sqlDB, err := h.db.DB()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out["status"] = "error"
|
out["status"] = "error"
|
||||||
out["message"] = err.Error()
|
out["message"] = err.Error()
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
if err := sqlDB.PingContext(ctx); err != nil {
|
if err := sqlDB.PingContext(ctx); err != nil {
|
||||||
out["status"] = "error"
|
out["status"] = "error"
|
||||||
out["message"] = err.Error()
|
out["message"] = err.Error()
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
var ver string
|
var ver string
|
||||||
if err := h.db.WithContext(ctx).Raw("SELECT VERSION()").Scan(&ver).Error; err != nil {
|
if err := h.db.WithContext(ctx).Raw("SELECT VERSION()").Scan(&ver).Error; err != nil {
|
||||||
out["status"] = "degraded"
|
out["status"] = "degraded"
|
||||||
out["message"] = "连接正常但读取版本失败: " + err.Error()
|
out["message"] = "连接正常但读取版本失败: " + err.Error()
|
||||||
stats := sqlDB.Stats()
|
stats := sqlDB.Stats()
|
||||||
out["openConnections"] = stats.OpenConnections
|
out["openConnections"] = stats.OpenConnections
|
||||||
out["inUse"] = stats.InUse
|
out["inUse"] = stats.InUse
|
||||||
out["idle"] = stats.Idle
|
out["idle"] = stats.Idle
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
stats := sqlDB.Stats()
|
stats := sqlDB.Stats()
|
||||||
out["status"] = "ok"
|
out["status"] = "ok"
|
||||||
out["version"] = strings.TrimSpace(ver)
|
out["version"] = strings.TrimSpace(ver)
|
||||||
out["openConnections"] = stats.OpenConnections
|
out["openConnections"] = stats.OpenConnections
|
||||||
out["inUse"] = stats.InUse
|
out["inUse"] = stats.InUse
|
||||||
out["idle"] = stats.Idle
|
out["idle"] = stats.Idle
|
||||||
out["waitCount"] = stats.WaitCount
|
out["waitCount"] = stats.WaitCount
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseMysqlDSNForDisplay(dsn string, out gin.H) {
|
func parseMysqlDSNForDisplay(dsn string, out gin.H) {
|
||||||
if dsn == "" {
|
if dsn == "" {
|
||||||
out["configured"] = false
|
out["configured"] = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mc, err := mysql.ParseDSN(dsn)
|
mc, err := mysql.ParseDSN(dsn)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out["configured"] = false
|
out["configured"] = false
|
||||||
out["dsnParseError"] = err.Error()
|
out["dsnParseError"] = err.Error()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
out["configured"] = true
|
out["configured"] = true
|
||||||
out["user"] = mc.User
|
out["user"] = mc.User
|
||||||
out["database"] = mc.DBName
|
out["database"] = mc.DBName
|
||||||
host, port, err := net.SplitHostPort(mc.Addr)
|
host, port, err := net.SplitHostPort(mc.Addr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out["host"] = mc.Addr
|
out["host"] = mc.Addr
|
||||||
out["port"] = ""
|
out["port"] = ""
|
||||||
if mc.Net == "unix" {
|
if mc.Net == "unix" {
|
||||||
out["socket"] = mc.Addr
|
out["socket"] = mc.Addr
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
out["host"] = host
|
out["host"] = host
|
||||||
out["port"] = port
|
out["port"] = port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SystemStatusHandler) redisInfo(ctx context.Context) gin.H {
|
func (h *SystemStatusHandler) redisInfo(ctx context.Context) gin.H {
|
||||||
out := gin.H{
|
out := gin.H{
|
||||||
"enabled": h.cfg.RedisEnabled,
|
"enabled": h.cfg.RedisEnabled,
|
||||||
"env": h.cfg.RedisEnv,
|
"env": h.cfg.RedisEnv,
|
||||||
}
|
}
|
||||||
if h.cfg.RedisAddr != "" {
|
if h.cfg.RedisAddr != "" {
|
||||||
host, port, err := net.SplitHostPort(h.cfg.RedisAddr)
|
host, port, err := net.SplitHostPort(h.cfg.RedisAddr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out["host"] = h.cfg.RedisAddr
|
out["host"] = h.cfg.RedisAddr
|
||||||
out["port"] = "6379"
|
out["port"] = "6379"
|
||||||
} else {
|
} else {
|
||||||
out["host"] = host
|
out["host"] = host
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "6379"
|
port = "6379"
|
||||||
}
|
}
|
||||||
out["port"] = port
|
out["port"] = port
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out["dbIndex"] = h.cfg.RedisDB
|
out["dbIndex"] = h.cfg.RedisDB
|
||||||
|
|
||||||
if !h.cfg.RedisEnabled {
|
if !h.cfg.RedisEnabled {
|
||||||
out["status"] = "disabled"
|
out["status"] = "disabled"
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
if h.cfg.RedisAddr == "" {
|
if h.cfg.RedisAddr == "" {
|
||||||
out["status"] = "misconfigured"
|
out["status"] = "misconfigured"
|
||||||
out["message"] = "已启用但未配置 REDIS_ADDR"
|
out["message"] = "已启用但未配置 REDIS_ADDR"
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
rdb := redis.NewClient(&redis.Options{
|
rdb := redis.NewClient(&redis.Options{
|
||||||
Addr: h.cfg.RedisAddr,
|
Addr: h.cfg.RedisAddr,
|
||||||
Password: h.cfg.RedisPassword,
|
Password: h.cfg.RedisPassword,
|
||||||
DB: h.cfg.RedisDB,
|
DB: h.cfg.RedisDB,
|
||||||
})
|
})
|
||||||
defer rdb.Close()
|
defer rdb.Close()
|
||||||
|
|
||||||
if err := rdb.Ping(ctx).Err(); err != nil {
|
if err := rdb.Ping(ctx).Err(); err != nil {
|
||||||
out["status"] = "error"
|
out["status"] = "error"
|
||||||
out["message"] = err.Error()
|
out["message"] = err.Error()
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
info, err := rdb.Info(ctx, "server", "memory").Result()
|
info, err := rdb.Info(ctx, "server", "memory").Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out["status"] = "degraded"
|
out["status"] = "degraded"
|
||||||
out["message"] = "PING 成功但无法读取 INFO: " + err.Error()
|
out["message"] = "PING 成功但无法读取 INFO: " + err.Error()
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
dbsize, _ := rdb.DBSize(ctx).Result()
|
dbsize, _ := rdb.DBSize(ctx).Result()
|
||||||
|
|
||||||
out["status"] = "ok"
|
out["status"] = "ok"
|
||||||
out["redisVersion"] = parseRedisInfoField(info, "redis_version:")
|
out["redisVersion"] = parseRedisInfoField(info, "redis_version:")
|
||||||
out["usedMemoryHuman"] = parseRedisInfoField(info, "used_memory_human:")
|
out["usedMemoryHuman"] = parseRedisInfoField(info, "used_memory_human:")
|
||||||
out["keysApprox"] = dbsize
|
out["keysApprox"] = dbsize
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseRedisInfoField(block, key string) string {
|
func parseRedisInfoField(block, key string) string {
|
||||||
for _, line := range strings.Split(block, "\r\n") {
|
for _, line := range strings.Split(block, "\r\n") {
|
||||||
if strings.HasPrefix(line, key) {
|
if strings.HasPrefix(line, key) {
|
||||||
return strings.TrimSpace(strings.TrimPrefix(line, key))
|
return strings.TrimSpace(strings.TrimPrefix(line, key))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *SystemStatusHandler) rabbitmqInfo() gin.H {
|
func (h *SystemStatusHandler) rabbitmqInfo() gin.H {
|
||||||
out := gin.H{
|
out := gin.H{
|
||||||
"enabled": h.cfg.RabbitMQEnabled,
|
"enabled": h.cfg.RabbitMQEnabled,
|
||||||
"env": h.cfg.RabbitMQEnv,
|
"env": h.cfg.RabbitMQEnv,
|
||||||
"exchange": mq.ExchangeName(h.cfg.RabbitMQEnv),
|
"exchange": mq.ExchangeName(h.cfg.RabbitMQEnv),
|
||||||
"queue": mq.QueueName(h.cfg.RabbitMQEnv),
|
"queue": mq.QueueName(h.cfg.RabbitMQEnv),
|
||||||
"routingKey": mq.OrderEmailRoutingKey(),
|
"routingKey": mq.OrderEmailRoutingKey(),
|
||||||
}
|
}
|
||||||
host, port, vhost, user := parseAMQPBroker(h.cfg.RabbitMQURL)
|
host, port, vhost, user := parseAMQPBroker(h.cfg.RabbitMQURL)
|
||||||
out["brokerHost"] = host
|
out["brokerHost"] = host
|
||||||
out["brokerPort"] = port
|
out["brokerPort"] = port
|
||||||
out["vhost"] = vhost
|
out["vhost"] = vhost
|
||||||
out["brokerUser"] = user
|
out["brokerUser"] = user
|
||||||
|
|
||||||
if !h.cfg.RabbitMQEnabled {
|
if !h.cfg.RabbitMQEnabled {
|
||||||
out["status"] = "disabled"
|
out["status"] = "disabled"
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.cfg.RabbitMQURL == "" {
|
if h.cfg.RabbitMQURL == "" {
|
||||||
out["status"] = "misconfigured"
|
out["status"] = "misconfigured"
|
||||||
out["message"] = "已启用但未配置 RABBITMQ_URL 或 RABBITMQ_PASSWORD"
|
out["message"] = "已启用但未配置 RABBITMQ_URL 或 RABBITMQ_PASSWORD"
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
if h.mq == nil {
|
if h.mq == nil {
|
||||||
out["status"] = "error"
|
out["status"] = "error"
|
||||||
out["message"] = "连接未建立,请检查 Broker 与 vhost 权限"
|
out["message"] = "连接未建立,请检查 Broker 与 vhost 权限"
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := h.mq.Ping(); err != nil {
|
if err := h.mq.Ping(); err != nil {
|
||||||
out["status"] = "error"
|
out["status"] = "error"
|
||||||
out["message"] = err.Error()
|
out["message"] = err.Error()
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, cons, err := h.mq.QueueInspectInfo()
|
msgs, cons, err := h.mq.QueueInspectInfo()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
out["status"] = "degraded"
|
out["status"] = "degraded"
|
||||||
out["message"] = "通道可用但无法读取队列统计: " + err.Error()
|
out["message"] = "通道可用但无法读取队列统计: " + err.Error()
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
out["status"] = "ok"
|
out["status"] = "ok"
|
||||||
out["messagesReady"] = msgs
|
out["messagesReady"] = msgs
|
||||||
out["consumers"] = cons
|
out["consumers"] = cons
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// parseAMQPBroker returns host, port, vhost, user without password (for display only).
|
// parseAMQPBroker returns host, port, vhost, user without password (for display only).
|
||||||
func parseAMQPBroker(raw string) (host, port, vhost, user string) {
|
func parseAMQPBroker(raw string) (host, port, vhost, user string) {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return "", "", "", ""
|
return "", "", "", ""
|
||||||
}
|
}
|
||||||
u, err := url.Parse(raw)
|
u, err := url.Parse(raw)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", "", "", ""
|
return "", "", "", ""
|
||||||
}
|
}
|
||||||
host = u.Hostname()
|
host = u.Hostname()
|
||||||
port = u.Port()
|
port = u.Port()
|
||||||
if port == "" {
|
if port == "" {
|
||||||
port = "5672"
|
port = "5672"
|
||||||
}
|
}
|
||||||
if u.User != nil {
|
if u.User != nil {
|
||||||
user = u.User.Username()
|
user = u.User.Username()
|
||||||
}
|
}
|
||||||
vpath := strings.TrimPrefix(u.Path, "/")
|
vpath := strings.TrimPrefix(u.Path, "/")
|
||||||
if vpath != "" {
|
if vpath != "" {
|
||||||
if dec, err := url.PathUnescape(vpath); err == nil {
|
if dec, err := url.PathUnescape(vpath); err == nil {
|
||||||
vhost = dec
|
vhost = dec
|
||||||
} else {
|
} else {
|
||||||
vhost = vpath
|
vhost = vpath
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if vhost == "" {
|
if vhost == "" {
|
||||||
vhost = "/"
|
vhost = "/"
|
||||||
}
|
}
|
||||||
return host, port, vhost, user
|
return host, port, vhost, user
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,268 +1,268 @@
|
|||||||
package mq
|
package mq
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
|
||||||
"mengyastore-backend/internal/storage"
|
"mengyastore-backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client manages a single AMQP connection, a publish channel, and naming for one env.
|
// Client manages a single AMQP connection, a publish channel, and naming for one env.
|
||||||
type Client struct {
|
type Client struct {
|
||||||
conn *amqp.Connection
|
conn *amqp.Connection
|
||||||
pubCh *amqp.Channel
|
pubCh *amqp.Channel
|
||||||
exchange string
|
exchange string
|
||||||
queue string
|
queue string
|
||||||
env string
|
env string
|
||||||
amqpURL string
|
amqpURL string
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
closing sync.Once
|
closing sync.Once
|
||||||
connected bool
|
connected bool
|
||||||
|
|
||||||
// 用于重连后重启消费协程(与 StartConsumer 注入的一致)
|
// 用于重连后重启消费协程(与 StartConsumer 注入的一致)
|
||||||
consumerCtx context.Context
|
consumerCtx context.Context
|
||||||
consumerSite *storage.SiteStore
|
consumerSite *storage.SiteStore
|
||||||
}
|
}
|
||||||
|
|
||||||
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent).
|
// New connects to RabbitMQ and declares exchange + queue + binding (idempotent).
|
||||||
func New(amqpURL, env string) (*Client, error) {
|
func New(amqpURL, env string) (*Client, error) {
|
||||||
if amqpURL == "" {
|
if amqpURL == "" {
|
||||||
return nil, fmt.Errorf("empty amqp url")
|
return nil, fmt.Errorf("empty amqp url")
|
||||||
}
|
}
|
||||||
conn, err := amqp.DialConfig(amqpURL, amqp.Config{
|
conn, err := amqp.DialConfig(amqpURL, amqp.Config{
|
||||||
Heartbeat: 30 * time.Second,
|
Heartbeat: 30 * time.Second,
|
||||||
Locale: "en_US",
|
Locale: "en_US",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("amqp dial: %w", err)
|
return nil, fmt.Errorf("amqp dial: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
pubCh, err := conn.Channel()
|
pubCh, err := conn.Channel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, fmt.Errorf("amqp channel: %w", err)
|
return nil, fmt.Errorf("amqp channel: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
exchange, queue, err := declareTopology(pubCh, env)
|
exchange, queue, err := declareTopology(pubCh, env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pubCh.Close()
|
pubCh.Close()
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Client{
|
return &Client{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
pubCh: pubCh,
|
pubCh: pubCh,
|
||||||
exchange: exchange,
|
exchange: exchange,
|
||||||
queue: queue,
|
queue: queue,
|
||||||
env: sanitizeEnv(env),
|
env: sanitizeEnv(env),
|
||||||
amqpURL: amqpURL,
|
amqpURL: amqpURL,
|
||||||
connected: true,
|
connected: true,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func isRecoverableAMQP(err error) bool {
|
func isRecoverableAMQP(err error) bool {
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
var e *amqp.Error
|
var e *amqp.Error
|
||||||
if errors.As(err, &e) {
|
if errors.As(err, &e) {
|
||||||
// 504 channel/connection not open、320 连接被服务端关闭等,通过重连恢复
|
// 504 channel/connection not open、320 连接被服务端关闭等,通过重连恢复
|
||||||
if e.Code == amqp.ChannelError || e.Code == amqp.ConnectionForced {
|
if e.Code == amqp.ChannelError || e.Code == amqp.ConnectionForced {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
return strings.Contains(strings.ToLower(e.Reason), "not open")
|
return strings.Contains(strings.ToLower(e.Reason), "not open")
|
||||||
}
|
}
|
||||||
s := strings.ToLower(err.Error())
|
s := strings.ToLower(err.Error())
|
||||||
return strings.Contains(s, "channel/connection is not open") ||
|
return strings.Contains(s, "channel/connection is not open") ||
|
||||||
strings.Contains(s, "connection closed") ||
|
strings.Contains(s, "connection closed") ||
|
||||||
strings.Contains(s, "use of closed network connection") ||
|
strings.Contains(s, "use of closed network connection") ||
|
||||||
strings.Contains(s, "eof")
|
strings.Contains(s, "eof")
|
||||||
}
|
}
|
||||||
|
|
||||||
// reconnectLocked 在持有 mu 时调用:关闭旧连接并重新拨号、声明拓扑。
|
// reconnectLocked 在持有 mu 时调用:关闭旧连接并重新拨号、声明拓扑。
|
||||||
func (c *Client) reconnectLocked() error {
|
func (c *Client) reconnectLocked() error {
|
||||||
if !c.connected || c.amqpURL == "" {
|
if !c.connected || c.amqpURL == "" {
|
||||||
return fmt.Errorf("mq client closed")
|
return fmt.Errorf("mq client closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
if c.pubCh != nil {
|
if c.pubCh != nil {
|
||||||
_ = c.pubCh.Close()
|
_ = c.pubCh.Close()
|
||||||
c.pubCh = nil
|
c.pubCh = nil
|
||||||
}
|
}
|
||||||
if c.conn != nil && !c.conn.IsClosed() {
|
if c.conn != nil && !c.conn.IsClosed() {
|
||||||
_ = c.conn.Close()
|
_ = c.conn.Close()
|
||||||
}
|
}
|
||||||
c.conn = nil
|
c.conn = nil
|
||||||
|
|
||||||
conn, err := amqp.DialConfig(c.amqpURL, amqp.Config{
|
conn, err := amqp.DialConfig(c.amqpURL, amqp.Config{
|
||||||
Heartbeat: 30 * time.Second,
|
Heartbeat: 30 * time.Second,
|
||||||
Locale: "en_US",
|
Locale: "en_US",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("amqp reconnect dial: %w", err)
|
return fmt.Errorf("amqp reconnect dial: %w", err)
|
||||||
}
|
}
|
||||||
pubCh, err := conn.Channel()
|
pubCh, err := conn.Channel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return fmt.Errorf("amqp reconnect channel: %w", err)
|
return fmt.Errorf("amqp reconnect channel: %w", err)
|
||||||
}
|
}
|
||||||
exchange, queue, err := declareTopology(pubCh, c.env)
|
exchange, queue, err := declareTopology(pubCh, c.env)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
pubCh.Close()
|
pubCh.Close()
|
||||||
conn.Close()
|
conn.Close()
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
c.conn = conn
|
c.conn = conn
|
||||||
c.pubCh = pubCh
|
c.pubCh = pubCh
|
||||||
c.exchange = exchange
|
c.exchange = exchange
|
||||||
c.queue = queue
|
c.queue = queue
|
||||||
|
|
||||||
if c.consumerCtx != nil && c.consumerSite != nil && c.consumerCtx.Err() == nil {
|
if c.consumerCtx != nil && c.consumerSite != nil && c.consumerCtx.Err() == nil {
|
||||||
go RunOrderEmailConsumer(c.consumerCtx, c.conn, c.env, c.consumerSite)
|
go RunOrderEmailConsumer(c.consumerCtx, c.conn, c.env, c.consumerSite)
|
||||||
log.Printf("[MQ] consumer restarted after reconnect (queue=%s)", c.queue)
|
log.Printf("[MQ] consumer restarted after reconnect (queue=%s)", c.queue)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) passiveQueueLocked() error {
|
func (c *Client) passiveQueueLocked() error {
|
||||||
if c.pubCh == nil || !c.connected {
|
if c.pubCh == nil || !c.connected {
|
||||||
return fmt.Errorf("mq client closed")
|
return fmt.Errorf("mq client closed")
|
||||||
}
|
}
|
||||||
_, err := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
_, err := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublishOrderEmail publishes a persistent JSON message to the order-email routing key.
|
// PublishOrderEmail publishes a persistent JSON message to the order-email routing key.
|
||||||
func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error {
|
func (c *Client) PublishOrderEmail(ctx context.Context, p OrderEmailPayload) error {
|
||||||
body, err := json.Marshal(p)
|
body, err := json.Marshal(p)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal payload: %w", err)
|
return fmt.Errorf("marshal payload: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
if c.pubCh == nil || !c.connected {
|
if c.pubCh == nil || !c.connected {
|
||||||
return fmt.Errorf("mq client closed")
|
return fmt.Errorf("mq client closed")
|
||||||
}
|
}
|
||||||
|
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return ctx.Err()
|
return ctx.Err()
|
||||||
default:
|
default:
|
||||||
}
|
}
|
||||||
|
|
||||||
tryPublish := func() error {
|
tryPublish := func() error {
|
||||||
return c.pubCh.PublishWithContext(ctx,
|
return c.pubCh.PublishWithContext(ctx,
|
||||||
c.exchange,
|
c.exchange,
|
||||||
routingKeyOrderEmail,
|
routingKeyOrderEmail,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
amqp.Publishing{
|
amqp.Publishing{
|
||||||
ContentType: "application/json",
|
ContentType: "application/json",
|
||||||
DeliveryMode: amqp.Persistent,
|
DeliveryMode: amqp.Persistent,
|
||||||
Timestamp: time.Now(),
|
Timestamp: time.Now(),
|
||||||
Body: body,
|
Body: body,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tryPublish()
|
err = tryPublish()
|
||||||
if err != nil && isRecoverableAMQP(err) {
|
if err != nil && isRecoverableAMQP(err) {
|
||||||
if rerr := c.reconnectLocked(); rerr == nil {
|
if rerr := c.reconnectLocked(); rerr == nil {
|
||||||
err = tryPublish()
|
err = tryPublish()
|
||||||
} else {
|
} else {
|
||||||
err = fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
err = fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("publish: %w", err)
|
return fmt.Errorf("publish: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueueInspectInfo returns current queue depth and consumer count (passive declare).
|
// QueueInspectInfo returns current queue depth and consumer count (passive declare).
|
||||||
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
|
func (c *Client) QueueInspectInfo() (messages int, consumers int, err error) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
try := func() (int, int, error) {
|
try := func() (int, int, error) {
|
||||||
if c.pubCh == nil || !c.connected {
|
if c.pubCh == nil || !c.connected {
|
||||||
return 0, 0, fmt.Errorf("mq client closed")
|
return 0, 0, fmt.Errorf("mq client closed")
|
||||||
}
|
}
|
||||||
q, e := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
q, e := c.pubCh.QueueDeclarePassive(c.queue, true, false, false, false, nil)
|
||||||
if e != nil {
|
if e != nil {
|
||||||
return 0, 0, e
|
return 0, 0, e
|
||||||
}
|
}
|
||||||
return q.Messages, q.Consumers, nil
|
return q.Messages, q.Consumers, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
msgs, cons, err := try()
|
msgs, cons, err := try()
|
||||||
if err != nil && isRecoverableAMQP(err) {
|
if err != nil && isRecoverableAMQP(err) {
|
||||||
if rerr := c.reconnectLocked(); rerr != nil {
|
if rerr := c.reconnectLocked(); rerr != nil {
|
||||||
return 0, 0, fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
return 0, 0, fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||||
}
|
}
|
||||||
return try()
|
return try()
|
||||||
}
|
}
|
||||||
return msgs, cons, err
|
return msgs, cons, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ping checks that the publish channel can query the declared queue (liveness for /api/health).
|
// Ping checks that the publish channel can query the declared queue (liveness for /api/health).
|
||||||
func (c *Client) Ping() error {
|
func (c *Client) Ping() error {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
|
||||||
err := c.passiveQueueLocked()
|
err := c.passiveQueueLocked()
|
||||||
if err != nil && isRecoverableAMQP(err) {
|
if err != nil && isRecoverableAMQP(err) {
|
||||||
if rerr := c.reconnectLocked(); rerr != nil {
|
if rerr := c.reconnectLocked(); rerr != nil {
|
||||||
return fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
return fmt.Errorf("%w (reconnect: %v)", err, rerr)
|
||||||
}
|
}
|
||||||
return c.passiveQueueLocked()
|
return c.passiveQueueLocked()
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Env returns the sanitized environment suffix used in exchange/queue names.
|
// Env returns the sanitized environment suffix used in exchange/queue names.
|
||||||
func (c *Client) Env() string { return c.env }
|
func (c *Client) Env() string { return c.env }
|
||||||
|
|
||||||
// StartConsumer runs the order-email consumer until ctx is cancelled (run in a goroutine).
|
// StartConsumer runs the order-email consumer until ctx is cancelled (run in a goroutine).
|
||||||
func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
|
func (c *Client) StartConsumer(ctx context.Context, site *storage.SiteStore) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
c.consumerCtx = ctx
|
c.consumerCtx = ctx
|
||||||
c.consumerSite = site
|
c.consumerSite = site
|
||||||
conn := c.conn
|
conn := c.conn
|
||||||
env := c.env
|
env := c.env
|
||||||
c.mu.Unlock()
|
c.mu.Unlock()
|
||||||
if conn == nil || site == nil {
|
if conn == nil || site == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
RunOrderEmailConsumer(ctx, conn, env, site)
|
RunOrderEmailConsumer(ctx, conn, env, site)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close releases the publish channel and connection.
|
// Close releases the publish channel and connection.
|
||||||
func (c *Client) Close() {
|
func (c *Client) Close() {
|
||||||
c.closing.Do(func() {
|
c.closing.Do(func() {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.connected = false
|
c.connected = false
|
||||||
c.consumerCtx = nil
|
c.consumerCtx = nil
|
||||||
c.consumerSite = nil
|
c.consumerSite = nil
|
||||||
if c.pubCh != nil {
|
if c.pubCh != nil {
|
||||||
_ = c.pubCh.Close()
|
_ = c.pubCh.Close()
|
||||||
c.pubCh = nil
|
c.pubCh = nil
|
||||||
}
|
}
|
||||||
if c.conn != nil {
|
if c.conn != nil {
|
||||||
_ = c.conn.Close()
|
_ = c.conn.Close()
|
||||||
c.conn = nil
|
c.conn = nil
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,101 +1,101 @@
|
|||||||
package mq
|
package mq
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
|
|
||||||
"mengyastore-backend/internal/email"
|
"mengyastore-backend/internal/email"
|
||||||
"mengyastore-backend/internal/storage"
|
"mengyastore-backend/internal/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RunOrderEmailConsumer runs until ctx is done. Must be started in its own goroutine.
|
// RunOrderEmailConsumer runs until ctx is done. Must be started in its own goroutine.
|
||||||
func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) {
|
func RunOrderEmailConsumer(ctx context.Context, conn *amqp.Connection, env string, site *storage.SiteStore) {
|
||||||
if conn == nil || site == nil {
|
if conn == nil || site == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ch, err := conn.Channel()
|
ch, err := conn.Channel()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[MQ] consumer channel: %v", err)
|
log.Printf("[MQ] consumer channel: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer ch.Close()
|
defer ch.Close()
|
||||||
|
|
||||||
if _, _, err := declareTopology(ch, env); err != nil {
|
if _, _, err := declareTopology(ch, env); err != nil {
|
||||||
log.Printf("[MQ] consumer declare topology: %v", err)
|
log.Printf("[MQ] consumer declare topology: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ch.Qos(1, 0, false); err != nil {
|
if err := ch.Qos(1, 0, false); err != nil {
|
||||||
log.Printf("[MQ] consumer qos: %v", err)
|
log.Printf("[MQ] consumer qos: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
queue := QueueName(env)
|
queue := QueueName(env)
|
||||||
const tag = "mengyastore-order-email"
|
const tag = "mengyastore-order-email"
|
||||||
msgs, err := ch.Consume(queue, tag, false, false, false, false, nil)
|
msgs, err := ch.Consume(queue, tag, false, false, false, false, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[MQ] consume %s: %v", queue, err)
|
log.Printf("[MQ] consume %s: %v", queue, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("[MQ] consumer started queue=%s", queue)
|
log.Printf("[MQ] consumer started queue=%s", queue)
|
||||||
|
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
_ = ch.Cancel(tag, false)
|
_ = ch.Cancel(tag, false)
|
||||||
log.Printf("[MQ] consumer stopped queue=%s", queue)
|
log.Printf("[MQ] consumer stopped queue=%s", queue)
|
||||||
return
|
return
|
||||||
case d, ok := <-msgs:
|
case d, ok := <-msgs:
|
||||||
if !ok {
|
if !ok {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
handleDelivery(site, d)
|
handleDelivery(site, d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDelivery(site *storage.SiteStore, d amqp.Delivery) {
|
func handleDelivery(site *storage.SiteStore, d amqp.Delivery) {
|
||||||
var payload OrderEmailPayload
|
var payload OrderEmailPayload
|
||||||
if err := json.Unmarshal(d.Body, &payload); err != nil {
|
if err := json.Unmarshal(d.Body, &payload); err != nil {
|
||||||
log.Printf("[MQ] bad message: %v", err)
|
log.Printf("[MQ] bad message: %v", err)
|
||||||
_ = d.Ack(false)
|
_ = d.Ack(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if payload.ToEmail == "" {
|
if payload.ToEmail == "" {
|
||||||
_ = d.Ack(false)
|
_ = d.Ack(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
cfg, err := site.GetSMTPConfig()
|
cfg, err := site.GetSMTPConfig()
|
||||||
if err != nil || !cfg.IsConfiguredEmail() {
|
if err != nil || !cfg.IsConfiguredEmail() {
|
||||||
log.Printf("[MQ] skip email order=%s: smtp not configured", payload.OrderID)
|
log.Printf("[MQ] skip email order=%s: smtp not configured", payload.OrderID)
|
||||||
_ = d.Ack(false)
|
_ = d.Ack(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
emailCfg := email.Config{
|
emailCfg := email.Config{
|
||||||
SMTPHost: cfg.Host,
|
SMTPHost: cfg.Host,
|
||||||
SMTPPort: cfg.Port,
|
SMTPPort: cfg.Port,
|
||||||
From: cfg.Email,
|
From: cfg.Email,
|
||||||
Password: cfg.Password,
|
Password: cfg.Password,
|
||||||
FromName: cfg.FromName,
|
FromName: cfg.FromName,
|
||||||
}
|
}
|
||||||
data := payload.ToNotifyData()
|
data := payload.ToNotifyData()
|
||||||
if err := email.SendOrderNotify(emailCfg, data); err != nil {
|
if err := email.SendOrderNotify(emailCfg, data); err != nil {
|
||||||
log.Printf("[MQ] send email fail order=%s: %v", payload.OrderID, err)
|
log.Printf("[MQ] send email fail order=%s: %v", payload.OrderID, err)
|
||||||
if d.Redelivered {
|
if d.Redelivered {
|
||||||
log.Printf("[MQ] drop order=%s after failed redelivery", payload.OrderID)
|
log.Printf("[MQ] drop order=%s after failed redelivery", payload.OrderID)
|
||||||
_ = d.Ack(false)
|
_ = d.Ack(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = d.Nack(false, true)
|
_ = d.Nack(false, true)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
log.Printf("[MQ] email ok order=%s to=%s", payload.OrderID, payload.ToEmail)
|
log.Printf("[MQ] email ok order=%s to=%s", payload.OrderID, payload.ToEmail)
|
||||||
_ = d.Ack(false)
|
_ = d.Ack(false)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
package mq
|
package mq
|
||||||
|
|
||||||
import "mengyastore-backend/internal/email"
|
import "mengyastore-backend/internal/email"
|
||||||
|
|
||||||
// OrderEmailPayload is the JSON body published to RabbitMQ (no SMTP secrets).
|
// OrderEmailPayload is the JSON body published to RabbitMQ (no SMTP secrets).
|
||||||
type OrderEmailPayload struct {
|
type OrderEmailPayload struct {
|
||||||
ToEmail string `json:"toEmail"`
|
ToEmail string `json:"toEmail"`
|
||||||
ToName string `json:"toName"`
|
ToName string `json:"toName"`
|
||||||
ProductName string `json:"productName"`
|
ProductName string `json:"productName"`
|
||||||
OrderID string `json:"orderId"`
|
OrderID string `json:"orderId"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
Codes []string `json:"codes"`
|
Codes []string `json:"codes"`
|
||||||
IsManual bool `json:"isManual"`
|
IsManual bool `json:"isManual"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (p OrderEmailPayload) ToNotifyData() email.OrderNotifyData {
|
func (p OrderEmailPayload) ToNotifyData() email.OrderNotifyData {
|
||||||
return email.OrderNotifyData{
|
return email.OrderNotifyData{
|
||||||
ToEmail: p.ToEmail,
|
ToEmail: p.ToEmail,
|
||||||
ToName: p.ToName,
|
ToName: p.ToName,
|
||||||
ProductName: p.ProductName,
|
ProductName: p.ProductName,
|
||||||
OrderID: p.OrderID,
|
OrderID: p.OrderID,
|
||||||
Quantity: p.Quantity,
|
Quantity: p.Quantity,
|
||||||
Codes: p.Codes,
|
Codes: p.Codes,
|
||||||
IsManual: p.IsManual,
|
IsManual: p.IsManual,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,71 +1,71 @@
|
|||||||
package mq
|
package mq
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
amqp "github.com/rabbitmq/amqp091-go"
|
amqp "github.com/rabbitmq/amqp091-go"
|
||||||
)
|
)
|
||||||
|
|
||||||
const routingKeyOrderEmail = "order.email.notify"
|
const routingKeyOrderEmail = "order.email.notify"
|
||||||
|
|
||||||
// OrderEmailRoutingKey is the binding / publish key for order notification messages.
|
// OrderEmailRoutingKey is the binding / publish key for order notification messages.
|
||||||
func OrderEmailRoutingKey() string { return routingKeyOrderEmail }
|
func OrderEmailRoutingKey() string { return routingKeyOrderEmail }
|
||||||
|
|
||||||
// ExchangeName returns the durable topic exchange for this app + env (isolation from other apps on same broker).
|
// ExchangeName returns the durable topic exchange for this app + env (isolation from other apps on same broker).
|
||||||
func ExchangeName(env string) string {
|
func ExchangeName(env string) string {
|
||||||
return fmt.Sprintf("ex.mengyastore.%s.events", sanitizeEnv(env))
|
return fmt.Sprintf("ex.mengyastore.%s.events", sanitizeEnv(env))
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueueName returns the durable order-email queue name for this env.
|
// QueueName returns the durable order-email queue name for this env.
|
||||||
func QueueName(env string) string {
|
func QueueName(env string) string {
|
||||||
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
|
return fmt.Sprintf("q.mengyastore.%s.order_email", sanitizeEnv(env))
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeEnv(env string) string {
|
func sanitizeEnv(env string) string {
|
||||||
e := strings.TrimSpace(strings.ToLower(env))
|
e := strings.TrimSpace(strings.ToLower(env))
|
||||||
if e == "prod" || e == "production" {
|
if e == "prod" || e == "production" {
|
||||||
return "prod"
|
return "prod"
|
||||||
}
|
}
|
||||||
return "dev"
|
return "dev"
|
||||||
}
|
}
|
||||||
|
|
||||||
func declareTopology(ch *amqp.Channel, env string) (exchange, queue string, err error) {
|
func declareTopology(ch *amqp.Channel, env string) (exchange, queue string, err error) {
|
||||||
exchange = ExchangeName(env)
|
exchange = ExchangeName(env)
|
||||||
queue = QueueName(env)
|
queue = QueueName(env)
|
||||||
|
|
||||||
if err = ch.ExchangeDeclare(
|
if err = ch.ExchangeDeclare(
|
||||||
exchange,
|
exchange,
|
||||||
"topic",
|
"topic",
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
nil,
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return "", "", fmt.Errorf("exchange declare: %w", err)
|
return "", "", fmt.Errorf("exchange declare: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err = ch.QueueDeclare(
|
if _, err = ch.QueueDeclare(
|
||||||
queue,
|
queue,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
nil,
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return "", "", fmt.Errorf("queue declare: %w", err)
|
return "", "", fmt.Errorf("queue declare: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = ch.QueueBind(
|
if err = ch.QueueBind(
|
||||||
queue,
|
queue,
|
||||||
routingKeyOrderEmail,
|
routingKeyOrderEmail,
|
||||||
exchange,
|
exchange,
|
||||||
false,
|
false,
|
||||||
nil,
|
nil,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return "", "", fmt.Errorf("queue bind: %w", err)
|
return "", "", fmt.Errorf("queue bind: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
return exchange, queue, nil
|
return exchange, queue, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,158 +1,158 @@
|
|||||||
# 萌芽小店 · 后端(mengyastore-backend-go)
|
# 萌芽小店 · 后端(mengyastore-backend-go)
|
||||||
|
|
||||||
基于 **Go + Gin + GORM** 的 REST API:商品与**发货方式**、订单、SproutGate 用户校验、站点设置、聊天、收藏夹;可选 **RabbitMQ** 发送订单邮件、可选 **Redis**;管理端提供 **系统状态** 聚合接口。
|
基于 **Go + Gin + GORM** 的 REST API:商品与**发货方式**、订单、SproutGate 用户校验、站点设置、聊天、收藏夹;可选 **RabbitMQ** 发送订单邮件、可选 **Redis**;管理端提供 **系统状态** 聚合接口。
|
||||||
|
|
||||||
## 技术依赖(核心)
|
## 技术依赖(核心)
|
||||||
|
|
||||||
| 包 | / 用途 |
|
| 包 | / 用途 |
|
||||||
|----|--------|
|
|----|--------|
|
||||||
| gin | HTTP 路由 |
|
| gin | HTTP 路由 |
|
||||||
| gorm.io/gorm + driver/mysql | MySQL |
|
| gorm.io/gorm + driver/mysql | MySQL |
|
||||||
| gin-contrib/cors | CORS |
|
| gin-contrib/cors | CORS |
|
||||||
| google/uuid | 订单 ID |
|
| google/uuid | 订单 ID |
|
||||||
| github.com/rabbitmq/amqp091-go | RabbitMQ(可选) |
|
| github.com/rabbitmq/amqp091-go | RabbitMQ(可选) |
|
||||||
| github.com/redis/go-redis/v9 | Redis 探测(可选) |
|
| github.com/redis/go-redis/v9 | Redis 探测(可选) |
|
||||||
| github.com/joho/godotenv | `.env` 加载 |
|
| github.com/joho/godotenv | `.env` 加载 |
|
||||||
|
|
||||||
## 目录结构(与仓库一致)
|
## 目录结构(与仓库一致)
|
||||||
|
|
||||||
```
|
```
|
||||||
mengyastore-backend-go/
|
mengyastore-backend-go/
|
||||||
├── main.go # 路由注册、MQ 生命周期
|
├── main.go # 路由注册、MQ 生命周期
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
├── init.sql # 可选:建库参考
|
├── init.sql # 可选:建库参考
|
||||||
├── cmd/migrate/main.go # JSON → MySQL 历史数据迁移
|
├── cmd/migrate/main.go # JSON → MySQL 历史数据迁移
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── auth/sproutgate.go
|
│ ├── auth/sproutgate.go
|
||||||
│ ├── cache/ # Redis 等扩展
|
│ ├── cache/ # Redis 等扩展
|
||||||
│ ├── config/config.go # 环境变量与默认值(APP_ENV、DSN、MQ、Redis…)
|
│ ├── config/config.go # 环境变量与默认值(APP_ENV、DSN、MQ、Redis…)
|
||||||
│ ├── database/db.go # Open + AutoMigrate
|
│ ├── database/db.go # Open + AutoMigrate
|
||||||
│ ├── database/models.go # GORM 表模型
|
│ ├── database/models.go # GORM 表模型
|
||||||
│ ├── email/
|
│ ├── email/
|
||||||
│ ├── mq/ # 连接、拓扑、Publish、Consumer、断线重连
|
│ ├── mq/ # 连接、拓扑、Publish、Consumer、断线重连
|
||||||
│ ├── models/ # JSON 业务模型
|
│ ├── models/ # JSON 业务模型
|
||||||
│ ├── storage/ # Product / Order / Site / Wishlist / Chat
|
│ ├── storage/ # Product / Order / Site / Wishlist / Chat
|
||||||
│ └── handlers/
|
│ └── handlers/
|
||||||
│ ├── public.go
|
│ ├── public.go
|
||||||
│ ├── order.go
|
│ ├── order.go
|
||||||
│ ├── stats.go
|
│ ├── stats.go
|
||||||
│ ├── wishlist.go
|
│ ├── wishlist.go
|
||||||
│ ├── chat.go
|
│ ├── chat.go
|
||||||
│ ├── admin.go
|
│ ├── admin.go
|
||||||
│ ├── admin_product.go
|
│ ├── admin_product.go
|
||||||
│ ├── admin_orders.go
|
│ ├── admin_orders.go
|
||||||
│ ├── admin_site.go
|
│ ├── admin_site.go
|
||||||
│ ├── admin_chat.go
|
│ ├── admin_chat.go
|
||||||
│ └── admin_status.go # GET /api/admin/system-status
|
│ └── admin_status.go # GET /api/admin/system-status
|
||||||
```
|
```
|
||||||
|
|
||||||
## API 路由一览
|
## API 路由一览
|
||||||
|
|
||||||
### 公开
|
### 公开
|
||||||
|
|
||||||
| 方法 | 路径 | 说明 |
|
| 方法 | 路径 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| GET | `/api/health` | 健康检查(可含 rabbitmq 状态) |
|
| GET | `/api/health` | 健康检查(可含 rabbitmq 状态) |
|
||||||
| GET | `/api/products` | 上架商品列表(无卡密、无 `fixedContent`) |
|
| GET | `/api/products` | 上架商品列表(无卡密、无 `fixedContent`) |
|
||||||
| POST | `/api/products/:id/view` | 记录浏览 |
|
| POST | `/api/products/:id/view` | 记录浏览 |
|
||||||
| GET | `/api/stats` | 订单数、访问量等 |
|
| GET | `/api/stats` | 订单数、访问量等 |
|
||||||
| POST | `/api/site/visit` | 站点访问计数 |
|
| POST | `/api/site/visit` | 站点访问计数 |
|
||||||
| GET | `/api/site/maintenance` | 维护状态 |
|
| GET | `/api/site/maintenance` | 维护状态 |
|
||||||
| POST | `/api/checkout` | 创建订单(卡密扣库或固定内容填充 `delivered_codes`) |
|
| POST | `/api/checkout` | 创建订单(卡密扣库或固定内容填充 `delivered_codes`) |
|
||||||
| GET | `/api/orders` | 当前用户订单(Bearer) |
|
| GET | `/api/orders` | 当前用户订单(Bearer) |
|
||||||
| POST | `/api/orders/:id/confirm` | 确认订单(返回发货内容等) |
|
| POST | `/api/orders/:id/confirm` | 确认订单(返回发货内容等) |
|
||||||
|
|
||||||
### 收藏夹(Bearer)
|
### 收藏夹(Bearer)
|
||||||
|
|
||||||
| GET | `/api/wishlist` |
|
| GET | `/api/wishlist` |
|
||||||
| POST | `/api/wishlist` |
|
| POST | `/api/wishlist` |
|
||||||
| DELETE | `/api/wishlist/:id` |
|
| DELETE | `/api/wishlist/:id` |
|
||||||
|
|
||||||
### 聊天(Bearer)
|
### 聊天(Bearer)
|
||||||
|
|
||||||
| GET | `/api/chat/messages` |
|
| GET | `/api/chat/messages` |
|
||||||
| POST | `/api/chat/messages` |
|
| POST | `/api/chat/messages` |
|
||||||
|
|
||||||
### 管理端(`X-Admin-Token` / `Authorization` / `?token=`)
|
### 管理端(`X-Admin-Token` / `Authorization` / `?token=`)
|
||||||
|
|
||||||
| POST | `/api/admin/verify` | 校验 token,返回 `valid` |
|
| POST | `/api/admin/verify` | 校验 token,返回 `valid` |
|
||||||
| GET/POST | `/api/admin/products` … | 商品 CRUD(见 payload 含 `fulfillmentType`、`fixedContent`) |
|
| GET/POST | `/api/admin/products` … | 商品 CRUD(见 payload 含 `fulfillmentType`、`fixedContent`) |
|
||||||
| PUT | `/api/admin/products/:id` |
|
| PUT | `/api/admin/products/:id` |
|
||||||
| PATCH | `/api/admin/products/:id/status` |
|
| PATCH | `/api/admin/products/:id/status` |
|
||||||
| DELETE | `/api/admin/products/:id` |
|
| DELETE | `/api/admin/products/:id` |
|
||||||
| POST | `/api/admin/site/maintenance` |
|
| POST | `/api/admin/site/maintenance` |
|
||||||
| GET/POST | `/api/admin/site/smtp` |
|
| GET/POST | `/api/admin/site/smtp` |
|
||||||
| GET | `/api/admin/orders` |
|
| GET | `/api/admin/orders` |
|
||||||
| DELETE | `/api/admin/orders/:id` |
|
| DELETE | `/api/admin/orders/:id` |
|
||||||
| GET … POST … DELETE | `/api/admin/chat`… | 会话与回复 |
|
| GET … POST … DELETE | `/api/admin/chat`… | 会话与回复 |
|
||||||
| **GET** | **`/api/admin/system-status`** | **backend / mysql / redis / rabbitmq 摘要** |
|
| **GET** | **`/api/admin/system-status`** | **backend / mysql / redis / rabbitmq 摘要** |
|
||||||
|
|
||||||
> 文档中若出现已废弃的 `GET /api/admin/token`,以仓库 `main.go` 为准。
|
> 文档中若出现已废弃的 `GET /api/admin/token`,以仓库 `main.go` 为准。
|
||||||
|
|
||||||
## 数据库表(字段摘要)
|
## 数据库表(字段摘要)
|
||||||
|
|
||||||
### products
|
### products
|
||||||
|
|
||||||
| 字段 | 说明 |
|
| 字段 | 说明 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| fulfillment_type | `card` 卡密逐条;`fixed` 固定内容(不限库存) |
|
| fulfillment_type | `card` 卡密逐条;`fixed` 固定内容(不限库存) |
|
||||||
| fixed_content | 固定发货正文(网盘链接、说明等);公开接口不返回 |
|
| fixed_content | 固定发货正文(网盘链接、说明等);公开接口不返回 |
|
||||||
| delivery_mode | 订单维度习惯字段:`auto` / `manual`(与 fulfillment 独立) |
|
| delivery_mode | 订单维度习惯字段:`auto` / `manual`(与 fulfillment 独立) |
|
||||||
| 其余 | name, price, discount_price, tags, cover_url, screenshot_urls, description, active, require_login, max_per_account, total_sold, view_count, show_note, show_contact … |
|
| 其余 | name, price, discount_price, tags, cover_url, screenshot_urls, description, active, require_login, max_per_account, total_sold, view_count, show_note, show_contact … |
|
||||||
|
|
||||||
### product_codes
|
### product_codes
|
||||||
|
|
||||||
卡密一行一条;`fulfillment_type = fixed` 时可为空。
|
卡密一行一条;`fulfillment_type = fixed` 时可为空。
|
||||||
|
|
||||||
### orders
|
### orders
|
||||||
|
|
||||||
含 `delivered_codes`(JSON)、`notify_email`、`delivery_mode`、`status` 等。
|
含 `delivered_codes`(JSON)、`notify_email`、`delivery_mode`、`status` 等。
|
||||||
|
|
||||||
### site_settings
|
### site_settings
|
||||||
|
|
||||||
KV:如 `totalVisits`、`maintenance`、`smtpHost`、`smtpPassword` 等;缺失键时 `sitestore.get` 不报错(不向日志刷 ErrRecordNotFound:`Find` 替代 `First`)。
|
KV:如 `totalVisits`、`maintenance`、`smtpHost`、`smtpPassword` 等;缺失键时 `sitestore.get` 不报错(不向日志刷 ErrRecordNotFound:`Find` 替代 `First`)。
|
||||||
|
|
||||||
## 配置说明
|
## 配置说明
|
||||||
|
|
||||||
配置由 **`config.Load()`** 从环境变量读取,可选 **`./.env`**(或 `ENV_FILE` 指定)。
|
配置由 **`config.Load()`** 从环境变量读取,可选 **`./.env`**(或 `ENV_FILE` 指定)。
|
||||||
|
|
||||||
要点:
|
要点:
|
||||||
|
|
||||||
- `DATABASE_DSN` 为空时按 `APP_ENV` 使用内建测试/生产默认 DSN(仅开发便利)。
|
- `DATABASE_DSN` 为空时按 `APP_ENV` 使用内建测试/生产默认 DSN(仅开发便利)。
|
||||||
- RabbitMQ、Redis 开关与地址见 `config.go` 注释。
|
- RabbitMQ、Redis 开关与地址见 `config.go` 注释。
|
||||||
- `HTTP_LISTEN_ADDR`、`PUBLIC_API_BASE_URL` 供系统状态 JSON 展示。
|
- `HTTP_LISTEN_ADDR`、`PUBLIC_API_BASE_URL` 供系统状态 JSON 展示。
|
||||||
|
|
||||||
## 发货与订单逻辑
|
## 发货与订单逻辑
|
||||||
|
|
||||||
### fulfillment_type = `card`(默认)
|
### fulfillment_type = `card`(默认)
|
||||||
|
|
||||||
1. `POST /api/checkout` 校验库存 ≥ 购买数量。
|
1. `POST /api/checkout` 校验库存 ≥ 购买数量。
|
||||||
2. 从 `product_codes` 取出对应条数写入订单 `delivered_codes`,并 `Update` 商品去掉已发码。
|
2. 从 `product_codes` 取出对应条数写入订单 `delivered_codes`,并 `Update` 商品去掉已发码。
|
||||||
3. 销量 `IncrementSold`;邮件/MQ 通知。
|
3. 销量 `IncrementSold`;邮件/MQ 通知。
|
||||||
|
|
||||||
### fulfillment_type = `fixed`
|
### fulfillment_type = `fixed`
|
||||||
|
|
||||||
1. 不校验卡密条数;不修改 `product_codes`。
|
1. 不校验卡密条数;不修改 `product_codes`。
|
||||||
2. 将 `fixed_content` 复制 `quantity` 次填入 `delivered_codes`(与多件购买展示一致)。
|
2. 将 `fixed_content` 复制 `quantity` 次填入 `delivered_codes`(与多件购买展示一致)。
|
||||||
3. 仍 `IncrementSold`;邮件/MQ 同自动发货路径。
|
3. 仍 `IncrementSold`;邮件/MQ 同自动发货路径。
|
||||||
|
|
||||||
### delivery_mode(订单)
|
### delivery_mode(订单)
|
||||||
|
|
||||||
业务上仍可区分用户确认后展示「等待人工发货」等;与 `fulfillment_type` 正交。
|
业务上仍可区分用户确认后展示「等待人工发货」等;与 `fulfillment_type` 正交。
|
||||||
|
|
||||||
## RabbitMQ 说明
|
## RabbitMQ 说明
|
||||||
|
|
||||||
- 启用时声明交换机/队列/绑定;订单邮件可 `Publish`,失败降级直发 SMTP。
|
- 启用时声明交换机/队列/绑定;订单邮件可 `Publish`,失败降级直发 SMTP。
|
||||||
- **504 channel closed** 等可回收错误:客户端会 **重连** 并在成功后 **重启消费协程**(见 `internal/mq/client.go`)。
|
- **504 channel closed** 等可回收错误:客户端会 **重连** 并在成功后 **重启消费协程**(见 `internal/mq/client.go`)。
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
go run .
|
go run .
|
||||||
go build -o mengyastore-backend.exe .
|
go build -o mengyastore-backend.exe .
|
||||||
go run ./cmd/migrate/main.go # 按需迁移旧 JSON
|
go run ./cmd/migrate/main.go # 按需迁移旧 JSON
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
维护时请以 **`main.go` 与 `internal/database/models.go`** 为最终准据;本文随版本迭代更新。
|
维护时请以 **`main.go` 与 `internal/database/models.go`** 为最终准据;本文随版本迭代更新。
|
||||||
|
|||||||
@@ -6,52 +6,82 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-parent</artifactId>
|
<artifactId>spring-boot-starter-parent</artifactId>
|
||||||
<version>3.5.12</version>
|
<version>3.5.12</version>
|
||||||
<relativePath/> <!-- lookup parent from repository -->
|
<relativePath/>
|
||||||
</parent>
|
</parent>
|
||||||
<groupId>com.smyhub.store</groupId>
|
<groupId>com.smyhub.store</groupId>
|
||||||
<artifactId>mengyastore-backend-java</artifactId>
|
<artifactId>mengyastore-backend-java</artifactId>
|
||||||
<version>0.0.1-SNAPSHOT</version>
|
<version>0.0.1-SNAPSHOT</version>
|
||||||
<name>mengyastore-backend-java</name>
|
<name>mengyastore-backend-java</name>
|
||||||
<description>mengyastore-backend-java</description>
|
<description>萌芽小店 Java Spring Boot 后端</description>
|
||||||
<url/>
|
|
||||||
<licenses>
|
|
||||||
<license/>
|
|
||||||
</licenses>
|
|
||||||
<developers>
|
|
||||||
<developer/>
|
|
||||||
</developers>
|
|
||||||
<scm>
|
|
||||||
<connection/>
|
|
||||||
<developerConnection/>
|
|
||||||
<tag/>
|
|
||||||
<url/>
|
|
||||||
</scm>
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>17</java.version>
|
<java.version>17</java.version>
|
||||||
</properties>
|
</properties>
|
||||||
<dependencies>
|
<dependencies>
|
||||||
|
<!-- Web MVC -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JPA / Hibernate -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MySQL Driver -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<scope>runtime</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- RabbitMQ AMQP -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Redis -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-data-redis</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Bean Validation -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JavaMail -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Actuator (health, metrics) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- DevTools -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-devtools</artifactId>
|
<artifactId>spring-boot-devtools</artifactId>
|
||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
<optional>true</optional>
|
<optional>true</optional>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>org.springframework.boot</groupId>
|
<!-- Test -->
|
||||||
<artifactId>spring-boot-docker-compose</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
|
||||||
<groupId>org.projectlombok</groupId>
|
|
||||||
<artifactId>lombok</artifactId>
|
|
||||||
<optional>true</optional>
|
|
||||||
</dependency>
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-test</artifactId>
|
<artifactId>spring-boot-starter-test</artifactId>
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ package com.smyhub.store.mengyastorebackendjava;
|
|||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
import org.springframework.boot.SpringApplication;
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
import org.springframework.transaction.annotation.EnableTransactionManagement;
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableTransactionManagement
|
||||||
|
@EnableScheduling
|
||||||
public class MengyastoreBackendJavaApplication {
|
public class MengyastoreBackendJavaApplication {
|
||||||
|
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(MengyastoreBackendJavaApplication.class, args);
|
SpringApplication.run(MengyastoreBackendJavaApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "app")
|
||||||
|
public class AppProperties {
|
||||||
|
|
||||||
|
private String adminToken = "changeme";
|
||||||
|
|
||||||
|
private String authApiUrl = "https://auth.api.shumengya.top";
|
||||||
|
|
||||||
|
/** Mirrors Go APP_ENV: {@code development} | {@code production}; used by system-status panel. */
|
||||||
|
private String appEnv = "development";
|
||||||
|
|
||||||
|
private boolean rabbitmqEnabled = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mirrors Go REDIS_ENABLED (default on unless explicitly disabled).
|
||||||
|
*/
|
||||||
|
private boolean redisEnabled = true;
|
||||||
|
|
||||||
|
private String rabbitmqEnv = "dev";
|
||||||
|
|
||||||
|
private String redisEnv = "dev";
|
||||||
|
|
||||||
|
private String publicApiBaseUrl = "";
|
||||||
|
|
||||||
|
private String corsAllowedOrigins = "http://localhost:5173,http://localhost:3000";
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.config;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
import org.springframework.web.filter.CorsFilter;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class CorsConfig {
|
||||||
|
|
||||||
|
private final AppProperties appProperties;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public CorsFilter corsFilter() {
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
|
||||||
|
String originsRaw = appProperties.getCorsAllowedOrigins();
|
||||||
|
if (StringUtils.hasText(originsRaw)) {
|
||||||
|
List<String> origins = Arrays.asList(originsRaw.split(","));
|
||||||
|
config.setAllowedOrigins(origins);
|
||||||
|
} else {
|
||||||
|
config.addAllowedOriginPattern("*");
|
||||||
|
}
|
||||||
|
|
||||||
|
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||||
|
config.setAllowedHeaders(List.of(
|
||||||
|
"Content-Type", "Authorization", "X-Admin-Token",
|
||||||
|
"X-Requested-With", "Accept", "Accept-Language"
|
||||||
|
));
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
config.setMaxAge(86400L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/api/**", config);
|
||||||
|
return new CorsFilter(source);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.config;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.amqp.core.*;
|
||||||
|
import org.springframework.amqp.rabbit.config.SimpleRabbitListenerContainerFactory;
|
||||||
|
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.amqp.core.AcknowledgeMode;
|
||||||
|
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.rabbitmq-enabled", havingValue = "true")
|
||||||
|
public class RabbitMQConfig {
|
||||||
|
|
||||||
|
private final AppProperties appProperties;
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Jackson2JsonMessageConverter jsonMessageConverter() {
|
||||||
|
return new Jackson2JsonMessageConverter();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
|
||||||
|
RabbitTemplate template = new RabbitTemplate(connectionFactory);
|
||||||
|
template.setMessageConverter(jsonMessageConverter());
|
||||||
|
return template;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SimpleRabbitListenerContainerFactory rabbitListenerContainerFactory(ConnectionFactory connectionFactory) {
|
||||||
|
SimpleRabbitListenerContainerFactory factory = new SimpleRabbitListenerContainerFactory();
|
||||||
|
factory.setConnectionFactory(connectionFactory);
|
||||||
|
factory.setMessageConverter(jsonMessageConverter());
|
||||||
|
factory.setPrefetchCount(1);
|
||||||
|
factory.setAcknowledgeMode(AcknowledgeMode.MANUAL);
|
||||||
|
return factory;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public TopicExchange orderEmailExchange() {
|
||||||
|
String name = RabbitMQTopology.exchangeName(appProperties.getRabbitmqEnv());
|
||||||
|
return ExchangeBuilder.topicExchange(name).durable(true).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Queue orderEmailQueue() {
|
||||||
|
String name = RabbitMQTopology.queueName(appProperties.getRabbitmqEnv());
|
||||||
|
return QueueBuilder.durable(name).build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public Binding orderEmailBinding() {
|
||||||
|
return BindingBuilder
|
||||||
|
.bind(orderEmailQueue())
|
||||||
|
.to(orderEmailExchange())
|
||||||
|
.with(RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RedisConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) {
|
||||||
|
return new StringRedisTemplate(connectionFactory);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class RestTemplateConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestTemplate restTemplate(RestTemplateBuilder builder) {
|
||||||
|
return builder
|
||||||
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
|
.readTimeout(Duration.ofSeconds(8))
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.config;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.AdminTokenInterceptor;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.BearerTokenInterceptor;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
|
||||||
|
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WebMvcConfig implements WebMvcConfigurer {
|
||||||
|
|
||||||
|
private final AdminTokenInterceptor adminTokenInterceptor;
|
||||||
|
private final BearerTokenInterceptor bearerTokenInterceptor;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void addInterceptors(InterceptorRegistry registry) {
|
||||||
|
registry.addInterceptor(adminTokenInterceptor)
|
||||||
|
.addPathPatterns("/api/admin/**")
|
||||||
|
.excludePathPatterns("/api/admin/verify");
|
||||||
|
|
||||||
|
registry.addInterceptor(bearerTokenInterceptor)
|
||||||
|
.addPathPatterns(
|
||||||
|
"/api/orders",
|
||||||
|
"/api/wishlist",
|
||||||
|
"/api/wishlist/**",
|
||||||
|
"/api/chat/messages"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.ChatMessageRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.ChatService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/chat")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChatController {
|
||||||
|
|
||||||
|
private final ChatService chatService;
|
||||||
|
|
||||||
|
@GetMapping("/messages")
|
||||||
|
public ResponseEntity<ApiResponse<List<ChatMessageResponse>>> getMessages() {
|
||||||
|
String account = CurrentUserHolder.get().getAccount();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(chatService.getUserMessages(account)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/messages")
|
||||||
|
public ResponseEntity<ApiResponse<ChatMessageResponse>> sendMessage(
|
||||||
|
@Valid @RequestBody ChatMessageRequest req) {
|
||||||
|
SproutGateUser user = CurrentUserHolder.get();
|
||||||
|
ChatMessageResponse msg = chatService.sendUserMessage(
|
||||||
|
user.getAccount(), user.getUsername(), req.getContent()
|
||||||
|
);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(msg));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.HealthResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
public class HealthController {
|
||||||
|
|
||||||
|
private final AppProperties appProperties;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private RabbitTemplate rabbitTemplate;
|
||||||
|
|
||||||
|
public HealthController(AppProperties appProperties) {
|
||||||
|
this.appProperties = appProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/health")
|
||||||
|
public ResponseEntity<ApiResponse<HealthResponse>> health() {
|
||||||
|
HealthResponse body = HealthResponse.builder()
|
||||||
|
.status("ok")
|
||||||
|
.rabbitmq(checkRabbitMQ())
|
||||||
|
.build();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String checkRabbitMQ() {
|
||||||
|
if (!appProperties.isRabbitmqEnabled()) {
|
||||||
|
return "disabled";
|
||||||
|
}
|
||||||
|
if (rabbitTemplate == null) {
|
||||||
|
return "disabled";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
rabbitTemplate.execute(channel -> {
|
||||||
|
channel.basicQos(1);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
return "ok";
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.debug("[Health] RabbitMQ ping failed: {}", e.getMessage());
|
||||||
|
return "error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.CheckoutRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.SproutGateVerifyResult;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.OrderService;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.SproutGateAuthService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PublicOrderController {
|
||||||
|
|
||||||
|
private final OrderService orderService;
|
||||||
|
private final SproutGateAuthService authService;
|
||||||
|
|
||||||
|
@PostMapping("/checkout")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> checkout(
|
||||||
|
@Valid @RequestBody CheckoutRequest req,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
|
||||||
|
SproutGateUser user = tryResolveUser(request);
|
||||||
|
OrderService.CheckoutResult result = orderService.checkout(req, user);
|
||||||
|
|
||||||
|
String qrPayload = "order:" + result.orderId() + ":" + result.productId();
|
||||||
|
String qrUrl = "https://api.qrserver.com/v1/create-qr-code/?size=320x320&data="
|
||||||
|
+ java.net.URLEncoder.encode(qrPayload, java.nio.charset.StandardCharsets.UTF_8);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(Map.of(
|
||||||
|
"orderId", result.orderId(),
|
||||||
|
"qrCodeUrl", qrUrl,
|
||||||
|
"productId", result.productId(),
|
||||||
|
"productQty", result.qty(),
|
||||||
|
"viewCount", result.viewCount(),
|
||||||
|
"status", result.status()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/orders/{id}/confirm")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> confirmOrder(@PathVariable String id) {
|
||||||
|
var order = orderService.confirmOrder(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(Map.of(
|
||||||
|
"orderId", order.getId(),
|
||||||
|
"status", order.getStatus(),
|
||||||
|
"deliveryMode", order.getDeliveryMode(),
|
||||||
|
"deliveredCodes", order.getDeliveredCodes(),
|
||||||
|
"isManual", "manual".equals(order.getDeliveryMode())
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private SproutGateUser tryResolveUser(HttpServletRequest request) {
|
||||||
|
String header = request.getHeader("Authorization");
|
||||||
|
if (!StringUtils.hasText(header) || !header.startsWith("Bearer ")) return null;
|
||||||
|
String token = header.substring(7);
|
||||||
|
SproutGateVerifyResult result = authService.verifyToken(token);
|
||||||
|
return (result.isValid() && result.getUser() != null) ? result.getUser() : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.ProductService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class PublicProductController {
|
||||||
|
|
||||||
|
private final ProductService productService;
|
||||||
|
|
||||||
|
@GetMapping("/products")
|
||||||
|
public ResponseEntity<ApiResponse<List<ProductResponse>>> listProducts() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(productService.listPublicProducts()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/products/{id}/view")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> recordView(
|
||||||
|
@PathVariable String id,
|
||||||
|
HttpServletRequest request) {
|
||||||
|
String fingerprint = buildFingerprint(request);
|
||||||
|
ProductResponse p = productService.recordView(id, fingerprint);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(Map.of(
|
||||||
|
"id", p.getId(),
|
||||||
|
"viewCount", p.getViewCount(),
|
||||||
|
"counted", true
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildFingerprint(HttpServletRequest request) {
|
||||||
|
String ip = getClientIp(request);
|
||||||
|
String ua = nvl(request.getHeader("User-Agent"));
|
||||||
|
String lang = nvl(request.getHeader("Accept-Language"));
|
||||||
|
return ip + "|" + ua + "|" + lang;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String getClientIp(HttpServletRequest request) {
|
||||||
|
String forwarded = request.getHeader("X-Forwarded-For");
|
||||||
|
if (forwarded != null && !forwarded.isBlank()) {
|
||||||
|
return forwarded.split(",")[0].strip();
|
||||||
|
}
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
|
||||||
|
private String nvl(String s) {
|
||||||
|
return s != null ? s.strip() : "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.StatsResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.repository.OrderRepository;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.SiteService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class StatsController {
|
||||||
|
|
||||||
|
private final OrderRepository orderRepository;
|
||||||
|
private final SiteService siteService;
|
||||||
|
|
||||||
|
@GetMapping("/stats")
|
||||||
|
public ResponseEntity<ApiResponse<StatsResponse>> getStats() {
|
||||||
|
long total = orderRepository.count();
|
||||||
|
int visits = siteService.getTotalVisits();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(StatsResponse.builder()
|
||||||
|
.totalOrders(total)
|
||||||
|
.totalVisits(visits)
|
||||||
|
.build()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/site/visit")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> recordVisit() {
|
||||||
|
int total = siteService.incrementVisits();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(Map.of("total", total, "counted", true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/site/maintenance")
|
||||||
|
public ResponseEntity<ApiResponse<MaintenanceResponse>> getMaintenance() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.OrderService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserOrderController {
|
||||||
|
|
||||||
|
private final OrderService orderService;
|
||||||
|
|
||||||
|
@GetMapping("/orders")
|
||||||
|
public ResponseEntity<ApiResponse<List<OrderResponse>>> myOrders() {
|
||||||
|
String account = CurrentUserHolder.get().getAccount();
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(orderService.getMyOrders(account)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.CurrentUserHolder;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.WishlistService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/wishlist")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WishlistController {
|
||||||
|
|
||||||
|
private final WishlistService wishlistService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, List<String>>>> getWishlist() {
|
||||||
|
String account = CurrentUserHolder.get().getAccount();
|
||||||
|
List<String> items = wishlistService.getWishlistIds(account);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, List<String>>>> addToWishlist(
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
String productId = body.get("productId");
|
||||||
|
String account = CurrentUserHolder.get().getAccount();
|
||||||
|
wishlistService.addToWishlist(account, productId);
|
||||||
|
List<String> items = wishlistService.getWishlistIds(account);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{productId}")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, List<String>>>> removeFromWishlist(
|
||||||
|
@PathVariable String productId) {
|
||||||
|
String account = CurrentUserHolder.get().getAccount();
|
||||||
|
wishlistService.removeFromWishlist(account, productId);
|
||||||
|
List<String> items = wishlistService.getWishlistIds(account);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(Map.of("items", items)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller.admin;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminChatMessageRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.ChatService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/chat")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminChatController {
|
||||||
|
|
||||||
|
private final ChatService chatService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, List<ChatMessageResponse>>>> listConversations() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(chatService.listAllConversations()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{account}")
|
||||||
|
public ResponseEntity<ApiResponse<List<ChatMessageResponse>>> getConversation(
|
||||||
|
@PathVariable String account) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(chatService.getConversation(account)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{account}")
|
||||||
|
public ResponseEntity<ApiResponse<ChatMessageResponse>> replyToUser(
|
||||||
|
@PathVariable String account,
|
||||||
|
@Valid @RequestBody AdminChatMessageRequest req) {
|
||||||
|
ChatMessageResponse msg = chatService.sendAdminMessage(account, req.getContent());
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{account}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> clearConversation(@PathVariable String account) {
|
||||||
|
chatService.clearConversation(account);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller.admin;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.OrderService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/orders")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminOrderController {
|
||||||
|
|
||||||
|
private final OrderService orderService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<OrderResponse>>> listOrders() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(orderService.listAllOrders()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteOrder(@PathVariable String id) {
|
||||||
|
orderService.deleteOrder(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller.admin;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminProductRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.ProductStatusRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.ProductService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/products")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminProductController {
|
||||||
|
|
||||||
|
private final ProductService productService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<ApiResponse<List<ProductResponse>>> listProducts() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(productService.listAllProducts()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<ApiResponse<ProductResponse>> createProduct(
|
||||||
|
@Valid @RequestBody AdminProductRequest req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(productService.createProduct(req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<ProductResponse>> updateProduct(
|
||||||
|
@PathVariable String id,
|
||||||
|
@Valid @RequestBody AdminProductRequest req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(productService.updateProduct(id, req)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PatchMapping("/{id}/status")
|
||||||
|
public ResponseEntity<ApiResponse<ProductResponse>> toggleStatus(
|
||||||
|
@PathVariable String id,
|
||||||
|
@RequestBody ProductStatusRequest req) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(productService.toggleStatus(id, req.isActive())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<ApiResponse<Void>> deleteProduct(@PathVariable String id) {
|
||||||
|
productService.deleteProduct(id);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(null));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller.admin;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.MaintenanceRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.SmtpConfigRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.SmtpConfigResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.SiteService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin/site")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminSiteController {
|
||||||
|
|
||||||
|
private final SiteService siteService;
|
||||||
|
|
||||||
|
@PostMapping("/maintenance")
|
||||||
|
public ResponseEntity<ApiResponse<MaintenanceResponse>> setMaintenance(
|
||||||
|
@RequestBody MaintenanceRequest req) {
|
||||||
|
siteService.setMaintenance(req.isEnabled(), req.getReason());
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(siteService.getMaintenance()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/smtp")
|
||||||
|
public ResponseEntity<ApiResponse<SmtpConfigResponse>> getSmtp() {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/smtp")
|
||||||
|
public ResponseEntity<ApiResponse<SmtpConfigResponse>> setSmtp(
|
||||||
|
@RequestBody SmtpConfigRequest req) {
|
||||||
|
siteService.setSmtpConfig(req);
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(siteService.getSmtpConfigResponse(true)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller.admin;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.SystemStatusService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminStatusController {
|
||||||
|
|
||||||
|
private final SystemStatusService systemStatusService;
|
||||||
|
|
||||||
|
@GetMapping("/system-status")
|
||||||
|
public ResponseEntity<ApiResponse<Map<String, Object>>> getSystemStatus(HttpServletRequest request) {
|
||||||
|
return ResponseEntity.ok(ApiResponse.ok(systemStatusService.buildStatus(request)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.controller.admin;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminVerifyRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/admin")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminVerifyController {
|
||||||
|
|
||||||
|
private final AppProperties appProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Matches Go backend: top-level {@code {"valid": true/false}} (frontend reads {@code response.data.valid} from axios).
|
||||||
|
*/
|
||||||
|
@PostMapping("/verify")
|
||||||
|
public ResponseEntity<Map<String, Boolean>> verify(@RequestBody(required = false) AdminVerifyRequest req) {
|
||||||
|
if (req == null || !StringUtils.hasText(req.getToken())) {
|
||||||
|
return ResponseEntity.ok(Map.of("valid", false));
|
||||||
|
}
|
||||||
|
boolean valid = req.getToken().equals(appProperties.getAdminToken());
|
||||||
|
return ResponseEntity.ok(Map.of("valid", valid));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class AdminChatMessageRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "消息内容不能为空")
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class AdminProductRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "商品名称不能为空")
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
private double price;
|
||||||
|
|
||||||
|
private double discountPrice;
|
||||||
|
|
||||||
|
private List<String> tags = new ArrayList<>();
|
||||||
|
|
||||||
|
private String coverUrl;
|
||||||
|
|
||||||
|
private List<String> screenshotUrls = new ArrayList<>();
|
||||||
|
|
||||||
|
private String verificationUrl;
|
||||||
|
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
private boolean requireLogin;
|
||||||
|
|
||||||
|
private int maxPerAccount;
|
||||||
|
|
||||||
|
private String deliveryMode = "auto";
|
||||||
|
|
||||||
|
private String fulfillmentType = "card";
|
||||||
|
|
||||||
|
private String fixedContent;
|
||||||
|
|
||||||
|
private boolean showNote = true;
|
||||||
|
|
||||||
|
private boolean showContact = true;
|
||||||
|
|
||||||
|
private List<String> codes = new ArrayList<>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class AdminVerifyRequest {
|
||||||
|
private String token;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ChatMessageRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "消息内容不能为空")
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class CheckoutRequest {
|
||||||
|
|
||||||
|
@NotBlank(message = "productId 不能为空")
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
private int quantity = 1;
|
||||||
|
|
||||||
|
private String note;
|
||||||
|
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
private String contactEmail;
|
||||||
|
|
||||||
|
private String notifyEmail;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class MaintenanceRequest {
|
||||||
|
private boolean enabled;
|
||||||
|
private String reason = "";
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class ProductStatusRequest {
|
||||||
|
private boolean active;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.request;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class SmtpConfigRequest {
|
||||||
|
private boolean enabled = true;
|
||||||
|
private String email;
|
||||||
|
private String password;
|
||||||
|
private String fromName;
|
||||||
|
private String host = "smtp.qq.com";
|
||||||
|
private String port = "465";
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class ApiResponse<T> {
|
||||||
|
|
||||||
|
private final T data;
|
||||||
|
private final String error;
|
||||||
|
|
||||||
|
private ApiResponse(T data, String error) {
|
||||||
|
this.data = data;
|
||||||
|
this.error = error;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> ok(T data) {
|
||||||
|
return new ApiResponse<>(data, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static <T> ApiResponse<T> error(String message) {
|
||||||
|
return new ApiResponse<>(null, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class ChatMessageResponse {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String accountId;
|
||||||
|
private String accountName;
|
||||||
|
private String content;
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
private boolean fromAdmin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class HealthResponse {
|
||||||
|
private String status;
|
||||||
|
private String rabbitmq;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class MaintenanceResponse {
|
||||||
|
private boolean maintenance;
|
||||||
|
private String reason;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class OrderResponse {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String productId;
|
||||||
|
private String productName;
|
||||||
|
private String userAccount;
|
||||||
|
private String userName;
|
||||||
|
private int quantity;
|
||||||
|
private List<String> deliveredCodes;
|
||||||
|
private String status;
|
||||||
|
private String deliveryMode;
|
||||||
|
private String note;
|
||||||
|
private String contactPhone;
|
||||||
|
private String contactEmail;
|
||||||
|
private String notifyEmail;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.response;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||||
|
public class ProductResponse {
|
||||||
|
|
||||||
|
private String id;
|
||||||
|
private String name;
|
||||||
|
private double price;
|
||||||
|
private double discountPrice;
|
||||||
|
private List<String> tags;
|
||||||
|
private int quantity;
|
||||||
|
private String coverUrl;
|
||||||
|
private List<String> screenshotUrls;
|
||||||
|
private String verificationUrl;
|
||||||
|
private List<String> codes;
|
||||||
|
private int viewCount;
|
||||||
|
private String description;
|
||||||
|
private boolean active;
|
||||||
|
private boolean requireLogin;
|
||||||
|
private int maxPerAccount;
|
||||||
|
private int totalSold;
|
||||||
|
private String deliveryMode;
|
||||||
|
private String fulfillmentType;
|
||||||
|
private String fixedContent;
|
||||||
|
private boolean showNote;
|
||||||
|
private boolean showContact;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class SmtpConfigResponse {
|
||||||
|
private boolean enabled;
|
||||||
|
private String email;
|
||||||
|
private String password;
|
||||||
|
private String fromName;
|
||||||
|
private String host;
|
||||||
|
private String port;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class StatsResponse {
|
||||||
|
private long totalOrders;
|
||||||
|
private int totalVisits;
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.dto.response;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class WishlistResponse {
|
||||||
|
private Long id;
|
||||||
|
private String accountId;
|
||||||
|
private String productId;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "chat_messages")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class ChatMessage {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(length = 36)
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Column(name = "account_id", length = 255, nullable = false)
|
||||||
|
private String accountId;
|
||||||
|
|
||||||
|
@Column(name = "account_name", length = 255)
|
||||||
|
private String accountName;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "text", nullable = false)
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
@Column(name = "sent_at", nullable = false)
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
|
||||||
|
@Column(name = "from_admin", columnDefinition = "tinyint(1) default 0")
|
||||||
|
private boolean fromAdmin;
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.entity;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.converter.StringListConverter;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "orders")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class Order {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(length = 36)
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Column(name = "product_id", length = 36, nullable = false)
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
@Column(name = "product_name", length = 255, nullable = false)
|
||||||
|
private String productName;
|
||||||
|
|
||||||
|
@Column(name = "user_account", length = 255)
|
||||||
|
private String userAccount;
|
||||||
|
|
||||||
|
@Column(name = "user_name", length = 255)
|
||||||
|
private String userName;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "int default 1")
|
||||||
|
private int quantity;
|
||||||
|
|
||||||
|
@Convert(converter = StringListConverter.class)
|
||||||
|
@Column(name = "delivered_codes", columnDefinition = "json")
|
||||||
|
private List<String> deliveredCodes = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(length = 20, nullable = false, columnDefinition = "varchar(20) default 'pending'")
|
||||||
|
private String status = "pending";
|
||||||
|
|
||||||
|
@Column(name = "delivery_mode", length = 20, columnDefinition = "varchar(20) default 'auto'")
|
||||||
|
private String deliveryMode = "auto";
|
||||||
|
|
||||||
|
@Column(columnDefinition = "text")
|
||||||
|
private String note;
|
||||||
|
|
||||||
|
@Column(name = "contact_phone", length = 50)
|
||||||
|
private String contactPhone;
|
||||||
|
|
||||||
|
@Column(name = "contact_email", length = 255)
|
||||||
|
private String contactEmail;
|
||||||
|
|
||||||
|
@Column(name = "notify_email", length = 255)
|
||||||
|
private String notifyEmail;
|
||||||
|
|
||||||
|
@Column(name = "created_at")
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.entity;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.converter.StringListConverter;
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "products")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
public class Product {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(length = 36)
|
||||||
|
private String id;
|
||||||
|
|
||||||
|
@Column(length = 255, nullable = false)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Column(nullable = false, columnDefinition = "double default 0")
|
||||||
|
private double price;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "double default 0")
|
||||||
|
private double discountPrice;
|
||||||
|
|
||||||
|
@Convert(converter = StringListConverter.class)
|
||||||
|
@Column(columnDefinition = "json")
|
||||||
|
private List<String> tags = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(length = 500)
|
||||||
|
private String coverUrl;
|
||||||
|
|
||||||
|
@Convert(converter = StringListConverter.class)
|
||||||
|
@Column(columnDefinition = "json")
|
||||||
|
private List<String> screenshotUrls = new ArrayList<>();
|
||||||
|
|
||||||
|
@Column(length = 500, columnDefinition = "varchar(500) default ''")
|
||||||
|
private String verificationUrl;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "text")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "tinyint(1) default 1")
|
||||||
|
private boolean active = true;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "tinyint(1) default 0")
|
||||||
|
private boolean requireLogin;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "int default 0")
|
||||||
|
private int maxPerAccount;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "int default 0")
|
||||||
|
private int totalSold;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "int default 0")
|
||||||
|
private int viewCount;
|
||||||
|
|
||||||
|
@Column(length = 20, columnDefinition = "varchar(20) default 'auto'")
|
||||||
|
private String deliveryMode = "auto";
|
||||||
|
|
||||||
|
@Column(length = 16, columnDefinition = "varchar(16) default 'card'")
|
||||||
|
private String fulfillmentType = "card";
|
||||||
|
|
||||||
|
@Column(columnDefinition = "text")
|
||||||
|
private String fixedContent;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "tinyint(1) default 1")
|
||||||
|
private boolean showNote = true;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "tinyint(1) default 1")
|
||||||
|
private boolean showContact = true;
|
||||||
|
|
||||||
|
@Column
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "product_codes")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class ProductCode {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "product_id", length = 36, nullable = false)
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "text", nullable = false)
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
public ProductCode(String productId, String code) {
|
||||||
|
this.productId = productId;
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "site_settings")
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class SiteSetting {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "`key`", length = 64)
|
||||||
|
private String key;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "text")
|
||||||
|
private String value;
|
||||||
|
|
||||||
|
public SiteSetting(String key, String value) {
|
||||||
|
this.key = key;
|
||||||
|
this.value = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import lombok.Setter;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "wishlists",
|
||||||
|
uniqueConstraints = @UniqueConstraint(name = "idx_wishlist", columnNames = {"account_id", "product_id"}))
|
||||||
|
@Getter
|
||||||
|
@Setter
|
||||||
|
@NoArgsConstructor
|
||||||
|
public class Wishlist {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(name = "account_id", length = 255, nullable = false)
|
||||||
|
private String accountId;
|
||||||
|
|
||||||
|
@Column(name = "product_id", length = 36, nullable = false)
|
||||||
|
private String productId;
|
||||||
|
|
||||||
|
public Wishlist(String accountId, String productId) {
|
||||||
|
this.accountId = accountId;
|
||||||
|
this.productId = productId;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.entity.converter;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.core.type.TypeReference;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import jakarta.persistence.AttributeConverter;
|
||||||
|
import jakarta.persistence.Converter;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Converter
|
||||||
|
public class StringListConverter implements AttributeConverter<List<String>, String> {
|
||||||
|
|
||||||
|
private static final ObjectMapper MAPPER = new ObjectMapper();
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String convertToDatabaseColumn(List<String> attribute) {
|
||||||
|
if (attribute == null) {
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.writeValueAsString(attribute);
|
||||||
|
} catch (Exception e) {
|
||||||
|
return "[]";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<String> convertToEntityAttribute(String dbData) {
|
||||||
|
if (dbData == null || dbData.isBlank()) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return MAPPER.readValue(dbData, new TypeReference<>() {});
|
||||||
|
} catch (Exception e) {
|
||||||
|
return new ArrayList<>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.exception;
|
||||||
|
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
private final HttpStatus status;
|
||||||
|
|
||||||
|
public BusinessException(String message) {
|
||||||
|
this(message, HttpStatus.BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(String message, HttpStatus status) {
|
||||||
|
super(message);
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpStatus getStatus() {
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.exception;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleBusiness(BusinessException ex) {
|
||||||
|
return ResponseEntity.status(ex.getStatus()).body(ApiResponse.error(ex.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException ex) {
|
||||||
|
String message = ex.getBindingResult().getAllErrors().stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(e -> e.getDefaultMessage())
|
||||||
|
.orElse("请求参数有误");
|
||||||
|
return ResponseEntity.badRequest().body(ApiResponse.error(message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ApiResponse<Void>> handleGeneric(Exception ex) {
|
||||||
|
log.error("Unhandled exception", ex);
|
||||||
|
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
.body(ApiResponse.error("服务器内部错误"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.mq;
|
||||||
|
|
||||||
|
import com.rabbitmq.client.Channel;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.EmailService;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.SiteService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.core.Message;
|
||||||
|
import org.springframework.amqp.rabbit.annotation.RabbitListener;
|
||||||
|
import org.springframework.amqp.rabbit.listener.api.ChannelAwareMessageListener;
|
||||||
|
import org.springframework.amqp.support.AmqpHeaders;
|
||||||
|
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||||
|
import org.springframework.messaging.handler.annotation.Header;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@ConditionalOnProperty(name = "app.rabbitmq-enabled", havingValue = "true")
|
||||||
|
public class OrderEmailConsumer {
|
||||||
|
|
||||||
|
private final SiteService siteService;
|
||||||
|
private final EmailService emailService;
|
||||||
|
|
||||||
|
@RabbitListener(queues = "#{T(com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology).queueName(@appProperties.rabbitmqEnv)}", ackMode = "MANUAL")
|
||||||
|
public void onMessage(OrderEmailPayload payload, Message message, Channel channel,
|
||||||
|
@Header(AmqpHeaders.DELIVERY_TAG) long deliveryTag) throws Exception {
|
||||||
|
if (payload == null || payload.getToEmail() == null || payload.getToEmail().isBlank()) {
|
||||||
|
channel.basicAck(deliveryTag, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
SiteService.SmtpConfig cfg = siteService.getSmtpConfig();
|
||||||
|
if (!cfg.isConfigured()) {
|
||||||
|
log.info("[MQ] skip email order={}: smtp not configured", payload.getOrderId());
|
||||||
|
channel.basicAck(deliveryTag, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
EmailService.OrderNotifyData data = new EmailService.OrderNotifyData();
|
||||||
|
data.toEmail = payload.getToEmail();
|
||||||
|
data.toName = payload.getToName();
|
||||||
|
data.productName = payload.getProductName();
|
||||||
|
data.orderId = payload.getOrderId();
|
||||||
|
data.quantity = payload.getQuantity();
|
||||||
|
data.codes = payload.getCodes();
|
||||||
|
data.isManual = payload.isManual();
|
||||||
|
|
||||||
|
try {
|
||||||
|
emailService.sendOrderNotify(cfg, data);
|
||||||
|
channel.basicAck(deliveryTag, false);
|
||||||
|
log.info("[MQ] email ok order={} to={}", payload.getOrderId(), payload.getToEmail());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[MQ] send email fail order={}: {}", payload.getOrderId(), e.getMessage());
|
||||||
|
boolean redelivered = message.getMessageProperties().isRedelivered();
|
||||||
|
if (redelivered) {
|
||||||
|
log.warn("[MQ] drop order={} after failed redelivery", payload.getOrderId());
|
||||||
|
channel.basicAck(deliveryTag, false);
|
||||||
|
} else {
|
||||||
|
channel.basicNack(deliveryTag, false, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.mq;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class OrderEmailPayload {
|
||||||
|
|
||||||
|
private String toEmail;
|
||||||
|
private String toName;
|
||||||
|
private String productName;
|
||||||
|
private String orderId;
|
||||||
|
private int quantity;
|
||||||
|
private List<String> codes;
|
||||||
|
private boolean isManual;
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.mq;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.amqp.AmqpException;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OrderEmailProducer {
|
||||||
|
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
private final AppProperties appProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes an order email payload to RabbitMQ.
|
||||||
|
* Returns true on success, false on failure (caller should fall back to direct email).
|
||||||
|
*/
|
||||||
|
public boolean publish(OrderEmailPayload payload) {
|
||||||
|
if (!appProperties.isRabbitmqEnabled()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
String exchange = RabbitMQTopology.exchangeName(appProperties.getRabbitmqEnv());
|
||||||
|
try {
|
||||||
|
rabbitTemplate.convertAndSend(exchange, RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY, payload);
|
||||||
|
log.info("[MQ] queued order email order={} to={}", payload.getOrderId(), payload.getToEmail());
|
||||||
|
return true;
|
||||||
|
} catch (AmqpException e) {
|
||||||
|
log.warn("[MQ] publish failed, will fallback to direct email: {}", e.getMessage());
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.mq;
|
||||||
|
|
||||||
|
public final class RabbitMQTopology {
|
||||||
|
|
||||||
|
private RabbitMQTopology() {}
|
||||||
|
|
||||||
|
public static final String ORDER_EMAIL_ROUTING_KEY = "order.email.notify";
|
||||||
|
|
||||||
|
public static String exchangeName(String env) {
|
||||||
|
return "ex.mengyastore." + env + ".events";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static String queueName(String env) {
|
||||||
|
return "q.mengyastore." + env + ".order_email";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.repository;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.ChatMessage;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ChatMessageRepository extends JpaRepository<ChatMessage, String> {
|
||||||
|
|
||||||
|
List<ChatMessage> findByAccountIdOrderBySentAtAsc(String accountId);
|
||||||
|
|
||||||
|
List<ChatMessage> findAllByOrderByAccountIdAscSentAtAsc();
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void deleteByAccountId(String accountId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.repository;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.Order;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface OrderRepository extends JpaRepository<Order, String> {
|
||||||
|
|
||||||
|
List<Order> findByUserAccountOrderByCreatedAtDesc(String userAccount);
|
||||||
|
|
||||||
|
List<Order> findAllByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
@Query("SELECT COALESCE(SUM(o.quantity), 0) FROM Order o WHERE o.userAccount = :account AND o.productId = :productId AND o.status <> 'cancelled'")
|
||||||
|
int sumQuantityByUserAccountAndProductId(@Param("account") String account, @Param("productId") String productId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.repository;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.ProductCode;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ProductCodeRepository extends JpaRepository<ProductCode, Long> {
|
||||||
|
|
||||||
|
List<ProductCode> findByProductId(String productId);
|
||||||
|
|
||||||
|
long countByProductId(String productId);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void deleteByProductId(String productId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.repository;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.Product;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface ProductRepository extends JpaRepository<Product, String> {
|
||||||
|
|
||||||
|
List<Product> findByActiveTrueOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
List<Product> findAllByOrderByCreatedAtDesc();
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE Product p SET p.totalSold = p.totalSold + :count WHERE p.id = :id")
|
||||||
|
void incrementTotalSold(@Param("id") String id, @Param("count") int count);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
@Modifying
|
||||||
|
@Query("UPDATE Product p SET p.viewCount = p.viewCount + 1 WHERE p.id = :id")
|
||||||
|
void incrementViewCount(@Param("id") String id);
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.repository;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.SiteSetting;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface SiteSettingRepository extends JpaRepository<SiteSetting, String> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.repository;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.Wishlist;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
public interface WishlistRepository extends JpaRepository<Wishlist, Long> {
|
||||||
|
|
||||||
|
List<Wishlist> findByAccountId(String accountId);
|
||||||
|
|
||||||
|
Optional<Wishlist> findByAccountIdAndProductId(String accountId, String productId);
|
||||||
|
|
||||||
|
boolean existsByAccountIdAndProductId(String accountId, String productId);
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
void deleteByAccountIdAndProductId(String accountId, String productId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AdminTokenInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final AppProperties appProperties;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
|
String token = resolveToken(request);
|
||||||
|
if (StringUtils.hasText(token) && token.equals(appProperties.getAdminToken())) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
objectMapper.writeValue(response.getWriter(), ApiResponse.error("unauthorized"));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String resolveToken(HttpServletRequest request) {
|
||||||
|
String token = request.getHeader("X-Admin-Token");
|
||||||
|
if (StringUtils.hasText(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
token = request.getHeader("Authorization");
|
||||||
|
if (StringUtils.hasText(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
return request.getParameter("token");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.security;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ApiResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.service.SproutGateAuthService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.servlet.HandlerInterceptor;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class BearerTokenInterceptor implements HandlerInterceptor {
|
||||||
|
|
||||||
|
private final SproutGateAuthService authService;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
|
||||||
|
String header = request.getHeader("Authorization");
|
||||||
|
if (!StringUtils.hasText(header) || !header.startsWith("Bearer ")) {
|
||||||
|
return sendUnauthorized(response, "请先登录");
|
||||||
|
}
|
||||||
|
String token = header.substring(7);
|
||||||
|
SproutGateVerifyResult result = authService.verifyToken(token);
|
||||||
|
if (!result.isValid() || result.getUser() == null) {
|
||||||
|
return sendUnauthorized(response, "登录已过期,请重新登录");
|
||||||
|
}
|
||||||
|
CurrentUserHolder.set(result.getUser());
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
|
||||||
|
CurrentUserHolder.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean sendUnauthorized(HttpServletResponse response, String message) throws Exception {
|
||||||
|
response.setStatus(HttpStatus.UNAUTHORIZED.value());
|
||||||
|
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||||
|
response.setCharacterEncoding("UTF-8");
|
||||||
|
objectMapper.writeValue(response.getWriter(), ApiResponse.error(message));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.security;
|
||||||
|
|
||||||
|
public final class CurrentUserHolder {
|
||||||
|
|
||||||
|
private static final ThreadLocal<SproutGateUser> HOLDER = new ThreadLocal<>();
|
||||||
|
|
||||||
|
private CurrentUserHolder() {}
|
||||||
|
|
||||||
|
public static void set(SproutGateUser user) {
|
||||||
|
HOLDER.set(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static SproutGateUser get() {
|
||||||
|
return HOLDER.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void clear() {
|
||||||
|
HOLDER.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.security;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class SproutGateUser {
|
||||||
|
|
||||||
|
private String account;
|
||||||
|
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
private String email;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.security;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class SproutGateVerifyResult {
|
||||||
|
|
||||||
|
private boolean valid;
|
||||||
|
|
||||||
|
private SproutGateUser user;
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.service;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ChatMessageResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.ChatMessage;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.exception.BusinessException;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.repository.ChatMessageRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ChatService {
|
||||||
|
|
||||||
|
private final ChatMessageRepository chatMessageRepository;
|
||||||
|
private final Map<String, LocalDateTime> lastSentMap = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public List<ChatMessageResponse> getUserMessages(String accountId) {
|
||||||
|
return chatMessageRepository.findByAccountIdOrderBySentAtAsc(accountId)
|
||||||
|
.stream().map(this::toResponse).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ChatMessageResponse sendUserMessage(String accountId, String accountName, String content) {
|
||||||
|
LocalDateTime last = lastSentMap.get(accountId);
|
||||||
|
if (last != null && last.plusSeconds(1).isAfter(LocalDateTime.now())) {
|
||||||
|
throw new BusinessException("发送太频繁,请稍后再试", HttpStatus.TOO_MANY_REQUESTS);
|
||||||
|
}
|
||||||
|
lastSentMap.put(accountId, LocalDateTime.now());
|
||||||
|
|
||||||
|
ChatMessage msg = new ChatMessage();
|
||||||
|
msg.setId(UUID.randomUUID().toString());
|
||||||
|
msg.setAccountId(accountId);
|
||||||
|
msg.setAccountName(accountName);
|
||||||
|
msg.setContent(content);
|
||||||
|
msg.setSentAt(LocalDateTime.now());
|
||||||
|
msg.setFromAdmin(false);
|
||||||
|
return toResponse(chatMessageRepository.save(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
public Map<String, List<ChatMessageResponse>> listAllConversations() {
|
||||||
|
Map<String, List<ChatMessageResponse>> result = new LinkedHashMap<>();
|
||||||
|
chatMessageRepository.findAllByOrderByAccountIdAscSentAtAsc().forEach(row -> {
|
||||||
|
result.computeIfAbsent(row.getAccountId(), k -> new ArrayList<>()).add(toResponse(row));
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ChatMessageResponse> getConversation(String accountId) {
|
||||||
|
return chatMessageRepository.findByAccountIdOrderBySentAtAsc(accountId)
|
||||||
|
.stream().map(this::toResponse).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ChatMessageResponse sendAdminMessage(String accountId, String content) {
|
||||||
|
ChatMessage msg = new ChatMessage();
|
||||||
|
msg.setId(UUID.randomUUID().toString());
|
||||||
|
msg.setAccountId(accountId);
|
||||||
|
msg.setAccountName("管理员");
|
||||||
|
msg.setContent(content);
|
||||||
|
msg.setSentAt(LocalDateTime.now());
|
||||||
|
msg.setFromAdmin(true);
|
||||||
|
return toResponse(chatMessageRepository.save(msg));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void clearConversation(String accountId) {
|
||||||
|
chatMessageRepository.deleteByAccountId(accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ChatMessageResponse toResponse(ChatMessage m) {
|
||||||
|
return ChatMessageResponse.builder()
|
||||||
|
.id(m.getId())
|
||||||
|
.accountId(m.getAccountId())
|
||||||
|
.accountName(m.getAccountName())
|
||||||
|
.content(m.getContent())
|
||||||
|
.sentAt(m.getSentAt())
|
||||||
|
.fromAdmin(m.isFromAdmin())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.service;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import javax.net.ssl.SSLSocketFactory;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.time.format.DateTimeFormatter;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Properties;
|
||||||
|
import jakarta.mail.*;
|
||||||
|
import jakarta.mail.internet.*;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class EmailService {
|
||||||
|
|
||||||
|
private static final DateTimeFormatter FORMATTER =
|
||||||
|
DateTimeFormatter.ofPattern("yyyy年MM月dd日 HH:mm:ss");
|
||||||
|
|
||||||
|
public void sendOrderNotify(SiteService.SmtpConfig cfg, OrderNotifyData data) {
|
||||||
|
if (!cfg.isConfigured() || data.toEmail == null || data.toEmail.isBlank()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Properties props = buildMailProperties(cfg);
|
||||||
|
Session session = Session.getInstance(props, new Authenticator() {
|
||||||
|
@Override
|
||||||
|
protected PasswordAuthentication getPasswordAuthentication() {
|
||||||
|
return new PasswordAuthentication(cfg.email, cfg.password);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Message message = new MimeMessage(session);
|
||||||
|
message.setFrom(new InternetAddress(cfg.email, cfg.fromName, "UTF-8"));
|
||||||
|
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(data.toEmail));
|
||||||
|
message.setSubject(data.isManual ? "【萌芽小店】您的订单正在处理中" : "【萌芽小店】您的订单已发货");
|
||||||
|
message.setContent(buildBody(data), "text/plain; charset=UTF-8");
|
||||||
|
Transport.send(message);
|
||||||
|
log.info("[Email] 发送成功 order={} to={}", data.orderId, data.toEmail);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("[Email] 发送失败 order={} to={}: {}", data.orderId, data.toEmail, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Properties buildMailProperties(SiteService.SmtpConfig cfg) {
|
||||||
|
Properties props = new Properties();
|
||||||
|
props.put("mail.smtp.host", cfg.host);
|
||||||
|
props.put("mail.smtp.port", cfg.port);
|
||||||
|
props.put("mail.smtp.auth", "true");
|
||||||
|
if ("465".equals(cfg.port)) {
|
||||||
|
props.put("mail.smtp.ssl.enable", "true");
|
||||||
|
props.put("mail.smtp.socketFactory.port", "465");
|
||||||
|
props.put("mail.smtp.socketFactory.class", SSLSocketFactory.class.getName());
|
||||||
|
props.put("mail.smtp.socketFactory.fallback", "false");
|
||||||
|
} else {
|
||||||
|
props.put("mail.smtp.starttls.enable", "true");
|
||||||
|
}
|
||||||
|
return props;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String buildBody(OrderNotifyData data) {
|
||||||
|
String now = LocalDateTime.now().format(FORMATTER);
|
||||||
|
String recipient = (data.toName != null && !data.toName.isBlank()) ? data.toName : "用户";
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
sb.append("尊敬的 ").append(recipient).append(",\n\n");
|
||||||
|
sb.append(" 您好!感谢您在萌芽小店的支持与购买。\n\n");
|
||||||
|
sb.append("────────────────────────────────\n");
|
||||||
|
sb.append(" 订单信息\n");
|
||||||
|
sb.append("────────────────────────────────\n");
|
||||||
|
sb.append(" 商品名称:").append(data.productName).append("\n");
|
||||||
|
sb.append(" 订单编号:").append(data.orderId).append("\n");
|
||||||
|
sb.append(" 购买数量:").append(data.quantity).append(" 件\n");
|
||||||
|
sb.append(" 通知时间:").append(now).append("\n");
|
||||||
|
sb.append("────────────────────────────────\n\n");
|
||||||
|
if (data.isManual) {
|
||||||
|
sb.append(" 您的订单已成功提交,目前正在等待人工审核与处理。\n");
|
||||||
|
sb.append(" 工作人员将尽快为您安排发货,请耐心等候。\n");
|
||||||
|
sb.append(" 发货完成后,我们将另行发送邮件通知。\n\n");
|
||||||
|
} else {
|
||||||
|
sb.append(" 您的订单已完成自动发货,发货内容如下:\n\n");
|
||||||
|
if (data.codes != null) {
|
||||||
|
for (int i = 0; i < data.codes.size(); i++) {
|
||||||
|
sb.append(" [").append(i + 1).append("] ").append(data.codes.get(i)).append("\n");
|
||||||
|
}
|
||||||
|
sb.append("\n");
|
||||||
|
}
|
||||||
|
sb.append(" 请妥善保管以上发货内容,切勿泄露给他人。\n\n");
|
||||||
|
}
|
||||||
|
sb.append(" 如有任何疑问,请联系在线客服,我们将竭诚为您服务。\n\n");
|
||||||
|
sb.append("────────────────────────────────\n");
|
||||||
|
sb.append(" 此邮件由系统自动发送,请勿直接回复。\n");
|
||||||
|
sb.append("────────────────────────────────\n");
|
||||||
|
return sb.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class OrderNotifyData {
|
||||||
|
public String toEmail;
|
||||||
|
public String toName;
|
||||||
|
public String productName;
|
||||||
|
public String orderId;
|
||||||
|
public int quantity;
|
||||||
|
public List<String> codes;
|
||||||
|
public boolean isManual;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.service;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.CheckoutRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.OrderResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.Order;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.Product;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.exception.BusinessException;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.mq.OrderEmailPayload;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.mq.OrderEmailProducer;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.repository.OrderRepository;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.SproutGateUser;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class OrderService {
|
||||||
|
|
||||||
|
private final OrderRepository orderRepository;
|
||||||
|
private final ProductService productService;
|
||||||
|
private final SiteService siteService;
|
||||||
|
private final EmailService emailService;
|
||||||
|
private final OrderEmailProducer orderEmailProducer;
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CheckoutResult checkout(CheckoutRequest req, SproutGateUser optionalUser) {
|
||||||
|
String userAccount = optionalUser != null ? optionalUser.getAccount() : "";
|
||||||
|
String userName = optionalUser != null ? optionalUser.getUsername() : "";
|
||||||
|
String userEmail = optionalUser != null ? optionalUser.getEmail() : "";
|
||||||
|
|
||||||
|
int qty = req.getQuantity() <= 0 ? 1 : req.getQuantity();
|
||||||
|
Product product = productService.findById(req.getProductId().strip());
|
||||||
|
|
||||||
|
if (!product.isActive()) {
|
||||||
|
throw new BusinessException("商品暂时无法购买");
|
||||||
|
}
|
||||||
|
if (product.isRequireLogin() && !StringUtils.hasText(userAccount)) {
|
||||||
|
throw new BusinessException("该商品需要登录后才能购买", HttpStatus.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
if (product.getMaxPerAccount() > 0 && StringUtils.hasText(userAccount)) {
|
||||||
|
int purchased = orderRepository.sumQuantityByUserAccountAndProductId(userAccount, product.getId());
|
||||||
|
if (purchased + qty > product.getMaxPerAccount()) {
|
||||||
|
int remain = product.getMaxPerAccount() - purchased;
|
||||||
|
if (remain <= 0) {
|
||||||
|
throw new BusinessException(String.format("每个账户最多购买 %d 个,您已达上限", product.getMaxPerAccount()));
|
||||||
|
}
|
||||||
|
throw new BusinessException(String.format("每个账户最多购买 %d 个,您还可购买 %d 个", product.getMaxPerAccount(), remain));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> deliveredCodes;
|
||||||
|
boolean isFixed = "fixed".equalsIgnoreCase(product.getFulfillmentType());
|
||||||
|
if (isFixed) {
|
||||||
|
String content = product.getFixedContent() != null ? product.getFixedContent().strip() : "";
|
||||||
|
if (content.isEmpty()) {
|
||||||
|
throw new BusinessException("该商品为固定内容发货,但未配置发货内容,请联系管理员");
|
||||||
|
}
|
||||||
|
deliveredCodes = Collections.nCopies(qty, content);
|
||||||
|
} else {
|
||||||
|
deliveredCodes = productService.popCodes(product.getId(), qty);
|
||||||
|
}
|
||||||
|
|
||||||
|
String notifyEmail = StringUtils.hasText(userEmail) ? userEmail
|
||||||
|
: StringUtils.hasText(req.getNotifyEmail()) ? req.getNotifyEmail().strip()
|
||||||
|
: StringUtils.hasText(req.getContactEmail()) ? req.getContactEmail().strip()
|
||||||
|
: "";
|
||||||
|
|
||||||
|
Order order = new Order();
|
||||||
|
order.setId(UUID.randomUUID().toString());
|
||||||
|
order.setProductId(product.getId());
|
||||||
|
order.setProductName(product.getName());
|
||||||
|
order.setUserAccount(userAccount);
|
||||||
|
order.setUserName(userName);
|
||||||
|
order.setQuantity(qty);
|
||||||
|
order.setDeliveredCodes(new ArrayList<>(deliveredCodes));
|
||||||
|
order.setStatus("completed");
|
||||||
|
order.setDeliveryMode("auto");
|
||||||
|
order.setNote(req.getNote() != null ? req.getNote().strip() : "");
|
||||||
|
order.setContactPhone(req.getContactPhone() != null ? req.getContactPhone().strip() : "");
|
||||||
|
order.setContactEmail(req.getContactEmail() != null ? req.getContactEmail().strip() : "");
|
||||||
|
order.setNotifyEmail(notifyEmail);
|
||||||
|
order.setCreatedAt(LocalDateTime.now());
|
||||||
|
orderRepository.save(order);
|
||||||
|
|
||||||
|
productService.incrementSold(product.getId(), qty);
|
||||||
|
|
||||||
|
if (StringUtils.hasText(notifyEmail)) {
|
||||||
|
sendOrderEmail(notifyEmail, userName, product.getName(), order.getId(), qty, new ArrayList<>(deliveredCodes), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CheckoutResult(order.getId(), product.getId(), qty, product.getViewCount(), order.getStatus());
|
||||||
|
}
|
||||||
|
|
||||||
|
public OrderResponse confirmOrder(String orderId) {
|
||||||
|
Order order = orderRepository.findById(orderId)
|
||||||
|
.orElseThrow(() -> new BusinessException("订单不存在", HttpStatus.NOT_FOUND));
|
||||||
|
order.setStatus("confirmed");
|
||||||
|
orderRepository.save(order);
|
||||||
|
|
||||||
|
boolean isManual = "manual".equals(order.getDeliveryMode());
|
||||||
|
if (isManual) {
|
||||||
|
String email = StringUtils.hasText(order.getNotifyEmail())
|
||||||
|
? order.getNotifyEmail() : order.getContactEmail();
|
||||||
|
if (StringUtils.hasText(email)) {
|
||||||
|
sendOrderEmail(email, order.getUserName(), order.getProductName(),
|
||||||
|
order.getId(), order.getQuantity(), order.getDeliveredCodes(), false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toResponse(order);
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OrderResponse> getMyOrders(String userAccount) {
|
||||||
|
return orderRepository.findByUserAccountOrderByCreatedAtDesc(userAccount)
|
||||||
|
.stream().map(this::toResponse).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OrderResponse> listAllOrders() {
|
||||||
|
return orderRepository.findAllByOrderByCreatedAtDesc()
|
||||||
|
.stream().map(this::toResponse).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteOrder(String orderId) {
|
||||||
|
if (!orderRepository.existsById(orderId)) {
|
||||||
|
throw new BusinessException("订单不存在", HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
orderRepository.deleteById(orderId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sendOrderEmail(String toEmail, String toName, String productName,
|
||||||
|
String orderId, int qty, List<String> codes, boolean isManual) {
|
||||||
|
SiteService.SmtpConfig cfg = siteService.getSmtpConfig();
|
||||||
|
if (!cfg.isConfigured()) return;
|
||||||
|
|
||||||
|
OrderEmailPayload payload = new OrderEmailPayload(toEmail, toName, productName, orderId, qty, codes, isManual);
|
||||||
|
boolean queued = orderEmailProducer.publish(payload);
|
||||||
|
if (!queued) {
|
||||||
|
EmailService.OrderNotifyData data = new EmailService.OrderNotifyData();
|
||||||
|
data.toEmail = toEmail;
|
||||||
|
data.toName = toName;
|
||||||
|
data.productName = productName;
|
||||||
|
data.orderId = orderId;
|
||||||
|
data.quantity = qty;
|
||||||
|
data.codes = codes;
|
||||||
|
data.isManual = isManual;
|
||||||
|
Thread emailThread = new Thread(() -> emailService.sendOrderNotify(cfg, data));
|
||||||
|
emailThread.setDaemon(true);
|
||||||
|
emailThread.start();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private OrderResponse toResponse(Order o) {
|
||||||
|
return OrderResponse.builder()
|
||||||
|
.id(o.getId())
|
||||||
|
.productId(o.getProductId())
|
||||||
|
.productName(o.getProductName())
|
||||||
|
.userAccount(o.getUserAccount())
|
||||||
|
.userName(o.getUserName())
|
||||||
|
.quantity(o.getQuantity())
|
||||||
|
.deliveredCodes(o.getDeliveredCodes())
|
||||||
|
.status(o.getStatus())
|
||||||
|
.deliveryMode(o.getDeliveryMode())
|
||||||
|
.note(o.getNote())
|
||||||
|
.contactPhone(o.getContactPhone())
|
||||||
|
.contactEmail(o.getContactEmail())
|
||||||
|
.notifyEmail(o.getNotifyEmail())
|
||||||
|
.createdAt(o.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CheckoutResult(String orderId, String productId, int qty, int viewCount, String status) {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.service;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.AdminProductRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.ProductResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.Product;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.ProductCode;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.exception.BusinessException;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.repository.ProductCodeRepository;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.repository.ProductRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import java.security.MessageDigest;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.*;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class ProductService {
|
||||||
|
|
||||||
|
private static final String DEFAULT_COVER_URL = "https://img.shumengya.top/i/2026/01/04/695a55058c37f.png";
|
||||||
|
private static final int VIEW_COOLDOWN_HOURS = 6;
|
||||||
|
private static final int MAX_SCREENSHOT_URLS = 5;
|
||||||
|
|
||||||
|
private final ProductRepository productRepository;
|
||||||
|
private final ProductCodeRepository productCodeRepository;
|
||||||
|
private final Map<String, LocalDateTime> recentViews = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public List<ProductResponse> listPublicProducts() {
|
||||||
|
return productRepository.findByActiveTrueOrderByCreatedAtDesc().stream()
|
||||||
|
.map(p -> toResponse(p, loadCodeCount(p), false, false))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<ProductResponse> listAllProducts() {
|
||||||
|
return productRepository.findAllByOrderByCreatedAtDesc().stream()
|
||||||
|
.map(p -> toResponse(p, loadCodeCount(p), true, true))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductResponse getProductForAdmin(String id) {
|
||||||
|
Product p = findById(id);
|
||||||
|
List<String> codes = productCodeRepository.findByProductId(id).stream()
|
||||||
|
.map(ProductCode::getCode).toList();
|
||||||
|
return toResponse(p, (long) codes.size(), true, true, codes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ProductResponse createProduct(AdminProductRequest req) {
|
||||||
|
Product p = new Product();
|
||||||
|
p.setId(UUID.randomUUID().toString());
|
||||||
|
p.setCreatedAt(LocalDateTime.now());
|
||||||
|
applyRequest(p, req);
|
||||||
|
productRepository.save(p);
|
||||||
|
replaceCodes(p.getId(), sanitizeCodes(req.getCodes()));
|
||||||
|
return getProductForAdmin(p.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ProductResponse updateProduct(String id, AdminProductRequest req) {
|
||||||
|
Product p = findById(id);
|
||||||
|
applyRequest(p, req);
|
||||||
|
productRepository.save(p);
|
||||||
|
replaceCodes(id, sanitizeCodes(req.getCodes()));
|
||||||
|
return getProductForAdmin(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ProductResponse toggleStatus(String id, boolean active) {
|
||||||
|
Product p = findById(id);
|
||||||
|
p.setActive(active);
|
||||||
|
productRepository.save(p);
|
||||||
|
long count = productCodeRepository.countByProductId(id);
|
||||||
|
return toResponse(p, count, true, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void deleteProduct(String id) {
|
||||||
|
findById(id);
|
||||||
|
productCodeRepository.deleteByProductId(id);
|
||||||
|
productRepository.deleteById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ProductResponse recordView(String id, String fingerprint) {
|
||||||
|
cleanupRecentViews();
|
||||||
|
String key = buildViewKey(id, fingerprint);
|
||||||
|
LocalDateTime last = recentViews.get(key);
|
||||||
|
LocalDateTime now = LocalDateTime.now();
|
||||||
|
boolean counted = false;
|
||||||
|
if (last == null || last.plusHours(VIEW_COOLDOWN_HOURS).isBefore(now)) {
|
||||||
|
productRepository.incrementViewCount(id);
|
||||||
|
recentViews.put(key, now);
|
||||||
|
counted = true;
|
||||||
|
}
|
||||||
|
Product p = findById(id);
|
||||||
|
return ProductResponse.builder()
|
||||||
|
.id(p.getId())
|
||||||
|
.viewCount(p.getViewCount())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void incrementSold(String id, int count) {
|
||||||
|
productRepository.incrementTotalSold(id, count);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Product findById(String id) {
|
||||||
|
return productRepository.findById(id)
|
||||||
|
.orElseThrow(() -> new BusinessException("商品不存在", HttpStatus.NOT_FOUND));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public List<String> popCodes(String productId, int count) {
|
||||||
|
List<ProductCode> codes = productCodeRepository.findByProductId(productId);
|
||||||
|
if (codes.size() < count) {
|
||||||
|
throw new BusinessException("卡密不足");
|
||||||
|
}
|
||||||
|
List<String> popped = codes.subList(0, count).stream().map(ProductCode::getCode).toList();
|
||||||
|
codes.subList(0, count).forEach(c -> productCodeRepository.deleteById(c.getId()));
|
||||||
|
return popped;
|
||||||
|
}
|
||||||
|
|
||||||
|
private long loadCodeCount(Product p) {
|
||||||
|
if ("fixed".equalsIgnoreCase(p.getFulfillmentType())) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return productCodeRepository.countByProductId(p.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void replaceCodes(String productId, List<String> codes) {
|
||||||
|
productCodeRepository.deleteByProductId(productId);
|
||||||
|
if (!codes.isEmpty()) {
|
||||||
|
List<ProductCode> rows = codes.stream()
|
||||||
|
.map(c -> new ProductCode(productId, c)).toList();
|
||||||
|
productCodeRepository.saveAll(rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void applyRequest(Product p, AdminProductRequest req) {
|
||||||
|
p.setName(req.getName());
|
||||||
|
p.setPrice(req.getPrice());
|
||||||
|
double discount = req.getDiscountPrice();
|
||||||
|
p.setDiscountPrice((discount > 0 && discount < req.getPrice()) ? discount : 0);
|
||||||
|
p.setTags(sanitizeTags(req.getTags()));
|
||||||
|
String cover = req.getCoverUrl();
|
||||||
|
p.setCoverUrl(StringUtils.hasText(cover) ? cover : DEFAULT_COVER_URL);
|
||||||
|
List<String> screenshots = req.getScreenshotUrls();
|
||||||
|
if (screenshots != null && screenshots.size() > MAX_SCREENSHOT_URLS) {
|
||||||
|
screenshots = screenshots.subList(0, MAX_SCREENSHOT_URLS);
|
||||||
|
}
|
||||||
|
p.setScreenshotUrls(screenshots != null ? screenshots : new ArrayList<>());
|
||||||
|
p.setVerificationUrl(req.getVerificationUrl() != null ? req.getVerificationUrl().strip() : "");
|
||||||
|
p.setDescription(req.getDescription());
|
||||||
|
p.setActive(req.isActive());
|
||||||
|
p.setRequireLogin(req.isRequireLogin());
|
||||||
|
p.setMaxPerAccount(req.getMaxPerAccount());
|
||||||
|
p.setDeliveryMode(StringUtils.hasText(req.getDeliveryMode()) ? req.getDeliveryMode() : "auto");
|
||||||
|
String ft = normalizeFullfillmentType(req.getFulfillmentType());
|
||||||
|
p.setFulfillmentType(ft);
|
||||||
|
if ("fixed".equals(ft)) {
|
||||||
|
p.setFixedContent(req.getFixedContent() != null ? req.getFixedContent().strip() : "");
|
||||||
|
} else {
|
||||||
|
p.setFixedContent("");
|
||||||
|
}
|
||||||
|
p.setShowNote(req.isShowNote());
|
||||||
|
p.setShowContact(req.isShowContact());
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductResponse toResponse(Product p, long codeCount, boolean includeCodes, boolean includeFixedContent) {
|
||||||
|
return toResponse(p, codeCount, includeCodes, includeFixedContent, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ProductResponse toResponse(Product p, long codeCount, boolean includeCodes, boolean includeFixedContent, List<String> codes) {
|
||||||
|
int qty = "fixed".equalsIgnoreCase(p.getFulfillmentType()) ? -1 : (int) codeCount;
|
||||||
|
return ProductResponse.builder()
|
||||||
|
.id(p.getId())
|
||||||
|
.name(p.getName())
|
||||||
|
.price(p.getPrice())
|
||||||
|
.discountPrice(p.getDiscountPrice())
|
||||||
|
.tags(p.getTags())
|
||||||
|
.quantity(qty)
|
||||||
|
.coverUrl(p.getCoverUrl())
|
||||||
|
.screenshotUrls(p.getScreenshotUrls())
|
||||||
|
.verificationUrl(p.getVerificationUrl())
|
||||||
|
.codes(includeCodes ? codes : null)
|
||||||
|
.viewCount(p.getViewCount())
|
||||||
|
.description(p.getDescription())
|
||||||
|
.active(p.isActive())
|
||||||
|
.requireLogin(p.isRequireLogin())
|
||||||
|
.maxPerAccount(p.getMaxPerAccount())
|
||||||
|
.totalSold(p.getTotalSold())
|
||||||
|
.deliveryMode(p.getDeliveryMode())
|
||||||
|
.fulfillmentType(p.getFulfillmentType())
|
||||||
|
.fixedContent(includeFixedContent ? p.getFixedContent() : null)
|
||||||
|
.showNote(p.isShowNote())
|
||||||
|
.showContact(p.isShowContact())
|
||||||
|
.createdAt(p.getCreatedAt())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String normalizeFullfillmentType(String t) {
|
||||||
|
if (t != null && "fixed".equalsIgnoreCase(t.strip())) return "fixed";
|
||||||
|
return "card";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> sanitizeCodes(List<String> codes) {
|
||||||
|
if (codes == null) return new ArrayList<>();
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
for (String code : codes) {
|
||||||
|
String trimmed = code != null ? code.strip() : "";
|
||||||
|
if (!trimmed.isEmpty()) seen.add(trimmed);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<String> sanitizeTags(List<String> tags) {
|
||||||
|
if (tags == null) return new ArrayList<>();
|
||||||
|
Set<String> seen = new LinkedHashSet<>();
|
||||||
|
for (String tag : tags) {
|
||||||
|
String t = tag != null ? tag.strip() : "";
|
||||||
|
if (!t.isEmpty() && seen.size() < 20) seen.add(t);
|
||||||
|
}
|
||||||
|
return new ArrayList<>(seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String buildViewKey(String id, String fingerprint) {
|
||||||
|
try {
|
||||||
|
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = md.digest((id + "|" + fingerprint).getBytes());
|
||||||
|
StringBuilder hex = new StringBuilder();
|
||||||
|
for (byte b : hash) hex.append(String.format("%02x", b));
|
||||||
|
return hex.toString();
|
||||||
|
} catch (Exception e) {
|
||||||
|
return id + "|" + fingerprint;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void cleanupRecentViews() {
|
||||||
|
LocalDateTime cutoff = LocalDateTime.now().minusHours(VIEW_COOLDOWN_HOURS);
|
||||||
|
recentViews.entrySet().removeIf(e -> e.getValue().isBefore(cutoff));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.service;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.request.SmtpConfigRequest;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.MaintenanceResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.SmtpConfigResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.SiteSetting;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.repository.SiteSettingRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SiteService {
|
||||||
|
|
||||||
|
private static final String KEY_TOTAL_VISITS = "totalVisits";
|
||||||
|
private static final String KEY_MAINTENANCE = "maintenance";
|
||||||
|
private static final String KEY_MAINTENANCE_REASON = "maintenanceReason";
|
||||||
|
private static final String KEY_SMTP_ENABLED = "smtpEnabled";
|
||||||
|
private static final String KEY_SMTP_EMAIL = "smtpEmail";
|
||||||
|
private static final String KEY_SMTP_PASSWORD = "smtpPassword";
|
||||||
|
private static final String KEY_SMTP_FROM_NAME = "smtpFromName";
|
||||||
|
private static final String KEY_SMTP_HOST = "smtpHost";
|
||||||
|
private static final String KEY_SMTP_PORT = "smtpPort";
|
||||||
|
|
||||||
|
private final SiteSettingRepository repo;
|
||||||
|
|
||||||
|
private String get(String key) {
|
||||||
|
return repo.findById(key).map(SiteSetting::getValue).orElse(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void set(String key, String value) {
|
||||||
|
repo.save(new SiteSetting(key, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public int getTotalVisits() {
|
||||||
|
String v = get(KEY_TOTAL_VISITS);
|
||||||
|
if (v == null || v.isBlank()) return 0;
|
||||||
|
try { return Integer.parseInt(v); } catch (NumberFormatException e) { return 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public int incrementVisits() {
|
||||||
|
int current = getTotalVisits() + 1;
|
||||||
|
set(KEY_TOTAL_VISITS, String.valueOf(current));
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
public MaintenanceResponse getMaintenance() {
|
||||||
|
String enabled = get(KEY_MAINTENANCE);
|
||||||
|
String reason = get(KEY_MAINTENANCE_REASON);
|
||||||
|
return MaintenanceResponse.builder()
|
||||||
|
.maintenance("true".equals(enabled))
|
||||||
|
.reason(reason != null ? reason : "")
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void setMaintenance(boolean enabled, String reason) {
|
||||||
|
set(KEY_MAINTENANCE, enabled ? "true" : "false");
|
||||||
|
set(KEY_MAINTENANCE_REASON, reason != null ? reason : "");
|
||||||
|
}
|
||||||
|
|
||||||
|
public SmtpConfig getSmtpConfig() {
|
||||||
|
SmtpConfig cfg = new SmtpConfig();
|
||||||
|
String enabledStr = get(KEY_SMTP_ENABLED);
|
||||||
|
cfg.enabled = !"false".equals(enabledStr);
|
||||||
|
cfg.email = nvl(get(KEY_SMTP_EMAIL), "");
|
||||||
|
cfg.password = nvl(get(KEY_SMTP_PASSWORD), "");
|
||||||
|
cfg.fromName = nvl(get(KEY_SMTP_FROM_NAME), "萌芽小店");
|
||||||
|
cfg.host = nvl(get(KEY_SMTP_HOST), "smtp.qq.com");
|
||||||
|
cfg.port = nvl(get(KEY_SMTP_PORT), "465");
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void setSmtpConfig(SmtpConfigRequest req) {
|
||||||
|
set(KEY_SMTP_ENABLED, req.isEnabled() ? "true" : "false");
|
||||||
|
set(KEY_SMTP_EMAIL, nvl(req.getEmail(), ""));
|
||||||
|
set(KEY_SMTP_PASSWORD, nvl(req.getPassword(), ""));
|
||||||
|
set(KEY_SMTP_FROM_NAME, nvl(req.getFromName(), ""));
|
||||||
|
set(KEY_SMTP_HOST, nvl(req.getHost(), "smtp.qq.com"));
|
||||||
|
set(KEY_SMTP_PORT, nvl(req.getPort(), "465"));
|
||||||
|
}
|
||||||
|
|
||||||
|
public SmtpConfigResponse getSmtpConfigResponse(boolean maskPassword) {
|
||||||
|
SmtpConfig cfg = getSmtpConfig();
|
||||||
|
return SmtpConfigResponse.builder()
|
||||||
|
.enabled(cfg.enabled)
|
||||||
|
.email(cfg.email)
|
||||||
|
.password(maskPassword ? maskStr(cfg.password) : cfg.password)
|
||||||
|
.fromName(cfg.fromName)
|
||||||
|
.host(cfg.host)
|
||||||
|
.port(cfg.port)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nvl(String s, String def) {
|
||||||
|
return (s != null && !s.isBlank()) ? s : def;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String maskStr(String s) {
|
||||||
|
if (s == null || s.length() <= 2) return "***";
|
||||||
|
return s.substring(0, 1) + "***" + s.substring(s.length() - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class SmtpConfig {
|
||||||
|
public boolean enabled = true;
|
||||||
|
public String email = "";
|
||||||
|
public String password = "";
|
||||||
|
public String fromName = "萌芽小店";
|
||||||
|
public String host = "smtp.qq.com";
|
||||||
|
public String port = "465";
|
||||||
|
|
||||||
|
public boolean isConfigured() {
|
||||||
|
return enabled && !email.isBlank() && !password.isBlank() && !host.isBlank();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.service;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.security.SproutGateVerifyResult;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SproutGateAuthService {
|
||||||
|
|
||||||
|
private final RestTemplate restTemplate;
|
||||||
|
private final AppProperties appProperties;
|
||||||
|
|
||||||
|
public SproutGateVerifyResult verifyToken(String token) {
|
||||||
|
try {
|
||||||
|
String url = appProperties.getAuthApiUrl() + "/api/auth/verify";
|
||||||
|
ResponseEntity<SproutGateVerifyResult> resp = restTemplate.postForEntity(
|
||||||
|
url, Map.of("token", token), SproutGateVerifyResult.class
|
||||||
|
);
|
||||||
|
if (resp.getStatusCode().is2xxSuccessful() && resp.getBody() != null) {
|
||||||
|
return resp.getBody();
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("[SproutGate] token verify failed: {}", e.getMessage());
|
||||||
|
}
|
||||||
|
SproutGateVerifyResult invalid = new SproutGateVerifyResult();
|
||||||
|
invalid.setValid(false);
|
||||||
|
return invalid;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.service;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.config.AppProperties;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.mq.RabbitMQTopology;
|
||||||
|
import com.zaxxer.hikari.HikariDataSource;
|
||||||
|
import com.zaxxer.hikari.HikariPoolMXBean;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnection;
|
||||||
|
import org.springframework.data.redis.connection.RedisConnectionFactory;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
|
import javax.sql.DataSource;
|
||||||
|
import java.sql.Connection;
|
||||||
|
import java.sql.ResultSet;
|
||||||
|
import java.sql.Statement;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds the same JSON shape as Go {@code admin_status.go} for the admin system-status panel.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SystemStatusService {
|
||||||
|
|
||||||
|
private static final Instant START_TIME = Instant.now();
|
||||||
|
|
||||||
|
private final AppProperties appProperties;
|
||||||
|
private final DataSource dataSource;
|
||||||
|
private final RedisConnectionFactory redisConnectionFactory;
|
||||||
|
private final RabbitTemplate rabbitTemplate;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.url:}")
|
||||||
|
private String datasourceUrl;
|
||||||
|
|
||||||
|
@Value("${spring.datasource.username:}")
|
||||||
|
private String datasourceUsername;
|
||||||
|
|
||||||
|
@Value("${server.port:8080}")
|
||||||
|
private int serverPort;
|
||||||
|
|
||||||
|
@Value("${server.address:}")
|
||||||
|
private String serverAddress;
|
||||||
|
|
||||||
|
@Value("${spring.rabbitmq.host:}")
|
||||||
|
private String rabbitHost;
|
||||||
|
|
||||||
|
@Value("${spring.rabbitmq.port:5672}")
|
||||||
|
private int rabbitPort;
|
||||||
|
|
||||||
|
@Value("${spring.rabbitmq.virtual-host:/}")
|
||||||
|
private String rabbitVhost;
|
||||||
|
|
||||||
|
@Value("${spring.rabbitmq.username:}")
|
||||||
|
private String rabbitUser;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.host:}")
|
||||||
|
private String redisHost;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.port:6379}")
|
||||||
|
private int redisPort;
|
||||||
|
|
||||||
|
@Value("${spring.data.redis.database:0}")
|
||||||
|
private int redisDb;
|
||||||
|
|
||||||
|
public Map<String, Object> buildStatus(HttpServletRequest request) {
|
||||||
|
Map<String, Object> root = new LinkedHashMap<>();
|
||||||
|
root.put("backend", backendInfo(request));
|
||||||
|
root.put("mysql", mysqlInfo());
|
||||||
|
root.put("redis", redisInfo());
|
||||||
|
root.put("rabbitmq", rabbitmqInfo());
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> backendInfo(HttpServletRequest request) {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("status", "ok");
|
||||||
|
m.put("appEnv", normalizeAppEnv(appProperties.getAppEnv()));
|
||||||
|
m.put("ginMode", "—");
|
||||||
|
String host = request.getServerName();
|
||||||
|
int port = request.getServerPort();
|
||||||
|
String hostHeader = request.getHeader("Host");
|
||||||
|
m.put("requestHost", StringUtils.hasText(hostHeader) ? hostHeader : host + ":" + port);
|
||||||
|
|
||||||
|
String listen = formatListenAddr();
|
||||||
|
m.put("listenAddr", listen);
|
||||||
|
|
||||||
|
String publicBase = appProperties.getPublicApiBaseUrl() != null
|
||||||
|
? appProperties.getPublicApiBaseUrl().strip() : "";
|
||||||
|
if (publicBase.isEmpty()) {
|
||||||
|
String proto = request.getHeader("X-Forwarded-Proto");
|
||||||
|
if (!StringUtils.hasText(proto)) {
|
||||||
|
proto = request.isSecure() ? "https" : "http";
|
||||||
|
}
|
||||||
|
if (StringUtils.hasText(hostHeader)) {
|
||||||
|
publicBase = proto + "://" + hostHeader;
|
||||||
|
} else {
|
||||||
|
publicBase = proto + "://" + host + ":" + port;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
m.put("publicBaseUrl", publicBase);
|
||||||
|
|
||||||
|
m.put("uptimeSeconds", (int) (Instant.now().getEpochSecond() - START_TIME.getEpochSecond()));
|
||||||
|
m.put("authApiConfigured", StringUtils.hasText(appProperties.getAuthApiUrl()));
|
||||||
|
m.put("rabbitmqEnabled", appProperties.isRabbitmqEnabled());
|
||||||
|
m.put("redisEnabled", appProperties.isRedisEnabled());
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String normalizeAppEnv(String raw) {
|
||||||
|
if (raw == null) return "development";
|
||||||
|
return switch (raw.strip().toLowerCase()) {
|
||||||
|
case "production", "prod" -> "production";
|
||||||
|
default -> "development";
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private String formatListenAddr() {
|
||||||
|
String addr = serverAddress != null ? serverAddress.strip() : "";
|
||||||
|
if (addr.isEmpty() || "0.0.0.0".equals(addr)) {
|
||||||
|
return ":" + serverPort;
|
||||||
|
}
|
||||||
|
return addr + ":" + serverPort;
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> mysqlInfo() {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
parseJdbcMysqlUrl(datasourceUrl, datasourceUsername, m);
|
||||||
|
|
||||||
|
try (Connection conn = dataSource.getConnection();
|
||||||
|
Statement stmt = conn.createStatement();
|
||||||
|
ResultSet rs = stmt.executeQuery("SELECT VERSION()")) {
|
||||||
|
if (rs.next()) {
|
||||||
|
m.put("version", rs.getString(1).trim());
|
||||||
|
}
|
||||||
|
m.put("status", "ok");
|
||||||
|
appendPoolStats(m);
|
||||||
|
} catch (Exception e) {
|
||||||
|
m.put("status", "error");
|
||||||
|
m.put("message", e.getMessage());
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendPoolStats(Map<String, Object> m) {
|
||||||
|
try {
|
||||||
|
if (dataSource instanceof HikariDataSource hikari) {
|
||||||
|
HikariPoolMXBean pool = hikari.getHikariPoolMXBean();
|
||||||
|
if (pool != null) {
|
||||||
|
int active = pool.getActiveConnections();
|
||||||
|
int idle = pool.getIdleConnections();
|
||||||
|
m.put("openConnections", active + idle);
|
||||||
|
m.put("inUse", active);
|
||||||
|
m.put("idle", idle);
|
||||||
|
m.put("waitCount", pool.getThreadsAwaitingConnection());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
// optional metrics
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void parseJdbcMysqlUrl(String jdbcUrl, String username, Map<String, Object> out) {
|
||||||
|
if (jdbcUrl == null || jdbcUrl.isBlank()) {
|
||||||
|
out.put("configured", false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
String rest = jdbcUrl.substring("jdbc:mysql://".length());
|
||||||
|
int q = rest.indexOf('?');
|
||||||
|
String core = q >= 0 ? rest.substring(0, q) : rest;
|
||||||
|
int slash = core.indexOf('/');
|
||||||
|
String hostPort = slash >= 0 ? core.substring(0, slash) : core;
|
||||||
|
String database = "";
|
||||||
|
if (slash >= 0 && slash < core.length() - 1) {
|
||||||
|
database = core.substring(slash + 1);
|
||||||
|
}
|
||||||
|
String host;
|
||||||
|
String port;
|
||||||
|
if (hostPort.contains(":")) {
|
||||||
|
int c = hostPort.lastIndexOf(':');
|
||||||
|
host = hostPort.substring(0, c);
|
||||||
|
port = hostPort.substring(c + 1);
|
||||||
|
} else {
|
||||||
|
host = hostPort;
|
||||||
|
port = "3306";
|
||||||
|
}
|
||||||
|
out.put("configured", true);
|
||||||
|
out.put("host", host);
|
||||||
|
out.put("port", port);
|
||||||
|
out.put("database", database);
|
||||||
|
out.put("user", username != null ? username : "");
|
||||||
|
} catch (Exception e) {
|
||||||
|
out.put("configured", false);
|
||||||
|
out.put("dsnParseError", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> redisInfo() {
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("enabled", appProperties.isRedisEnabled());
|
||||||
|
m.put("env", appProperties.getRedisEnv());
|
||||||
|
m.put("host", redisHost);
|
||||||
|
m.put("port", redisPort);
|
||||||
|
m.put("dbIndex", redisDb);
|
||||||
|
|
||||||
|
if (!appProperties.isRedisEnabled()) {
|
||||||
|
m.put("status", "disabled");
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(redisHost)) {
|
||||||
|
m.put("status", "misconfigured");
|
||||||
|
m.put("message", "已启用但未配置 spring.data.redis.host");
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
try (RedisConnection conn = redisConnectionFactory.getConnection()) {
|
||||||
|
String pong = new String(conn.ping());
|
||||||
|
if (!"PONG".equalsIgnoreCase(pong)) {
|
||||||
|
m.put("status", "degraded");
|
||||||
|
m.put("message", "PING 返回异常: " + pong);
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
java.util.Properties infoProps = conn.info();
|
||||||
|
if (infoProps == null || infoProps.isEmpty()) {
|
||||||
|
m.put("status", "degraded");
|
||||||
|
m.put("message", "PING 成功但无法读取 INFO");
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
Long dbSize = conn.dbSize();
|
||||||
|
m.put("status", "ok");
|
||||||
|
m.put("redisVersion", nullToEmpty(infoProps.getProperty("redis_version")));
|
||||||
|
m.put("usedMemoryHuman", nullToEmpty(infoProps.getProperty("used_memory_human")));
|
||||||
|
m.put("keysApprox", dbSize);
|
||||||
|
} catch (Exception e) {
|
||||||
|
m.put("status", "error");
|
||||||
|
m.put("message", e.getMessage());
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String nullToEmpty(String s) {
|
||||||
|
return s != null ? s : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private Map<String, Object> rabbitmqInfo() {
|
||||||
|
String env = appProperties.getRabbitmqEnv();
|
||||||
|
Map<String, Object> m = new LinkedHashMap<>();
|
||||||
|
m.put("enabled", appProperties.isRabbitmqEnabled());
|
||||||
|
m.put("env", env);
|
||||||
|
m.put("exchange", RabbitMQTopology.exchangeName(env));
|
||||||
|
m.put("queue", RabbitMQTopology.queueName(env));
|
||||||
|
m.put("routingKey", RabbitMQTopology.ORDER_EMAIL_ROUTING_KEY);
|
||||||
|
m.put("brokerHost", rabbitHost);
|
||||||
|
m.put("brokerPort", String.valueOf(rabbitPort));
|
||||||
|
m.put("vhost", rabbitVhost);
|
||||||
|
m.put("brokerUser", rabbitUser);
|
||||||
|
|
||||||
|
if (!appProperties.isRabbitmqEnabled()) {
|
||||||
|
m.put("status", "disabled");
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
if (!StringUtils.hasText(rabbitHost)) {
|
||||||
|
m.put("status", "misconfigured");
|
||||||
|
m.put("message", "已启用但未配置 spring.rabbitmq.host");
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
int[] mqStats = rabbitTemplate.execute(channel -> {
|
||||||
|
var ok = channel.queueDeclarePassive(RabbitMQTopology.queueName(env));
|
||||||
|
return new int[]{ok.getMessageCount(), ok.getConsumerCount()};
|
||||||
|
});
|
||||||
|
if (mqStats != null && mqStats.length >= 2) {
|
||||||
|
m.put("messagesReady", mqStats[0]);
|
||||||
|
m.put("consumers", mqStats[1]);
|
||||||
|
}
|
||||||
|
m.put("status", "ok");
|
||||||
|
} catch (Exception e) {
|
||||||
|
String msg = e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName();
|
||||||
|
if (msg.contains("NOT_FOUND") || msg.contains("404")) {
|
||||||
|
m.put("status", "degraded");
|
||||||
|
m.put("message", "已连接但队列不存在或未声明: " + msg);
|
||||||
|
} else {
|
||||||
|
m.put("status", "error");
|
||||||
|
m.put("message", msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
package com.smyhub.store.mengyastorebackendjava.service;
|
||||||
|
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.dto.response.WishlistResponse;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.entity.Wishlist;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.exception.BusinessException;
|
||||||
|
import com.smyhub.store.mengyastorebackendjava.repository.WishlistRepository;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class WishlistService {
|
||||||
|
|
||||||
|
private final WishlistRepository wishlistRepository;
|
||||||
|
|
||||||
|
public List<String> getWishlistIds(String accountId) {
|
||||||
|
return wishlistRepository.findByAccountId(accountId).stream()
|
||||||
|
.map(Wishlist::getProductId)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<WishlistResponse> getWishlist(String accountId) {
|
||||||
|
return wishlistRepository.findByAccountId(accountId).stream()
|
||||||
|
.map(this::toResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public WishlistResponse addToWishlist(String accountId, String productId) {
|
||||||
|
if (wishlistRepository.existsByAccountIdAndProductId(accountId, productId)) {
|
||||||
|
throw new BusinessException("该商品已在心愿单中");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Wishlist saved = wishlistRepository.save(new Wishlist(accountId, productId));
|
||||||
|
return toResponse(saved);
|
||||||
|
} catch (DataIntegrityViolationException e) {
|
||||||
|
throw new BusinessException("该商品已在心愿单中");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void removeFromWishlist(String accountId, String productId) {
|
||||||
|
if (!wishlistRepository.existsByAccountIdAndProductId(accountId, productId)) {
|
||||||
|
throw new BusinessException("心愿单中不存在该商品", HttpStatus.NOT_FOUND);
|
||||||
|
}
|
||||||
|
wishlistRepository.deleteByAccountIdAndProductId(accountId, productId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private WishlistResponse toResponse(Wishlist w) {
|
||||||
|
return WishlistResponse.builder()
|
||||||
|
.id(w.getId())
|
||||||
|
.accountId(w.getAccountId())
|
||||||
|
.productId(w.getProductId())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,85 @@
|
|||||||
spring:
|
spring:
|
||||||
application:
|
application:
|
||||||
name: mengyastore-backend-java
|
name: mengyastore-backend-java
|
||||||
|
profiles:
|
||||||
|
active: dev
|
||||||
|
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://10.1.1.100:3306/mengyastore-test?charset=utf8mb4&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true
|
||||||
|
username: mengyastore-test
|
||||||
|
password: mengyastore-test
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: update
|
||||||
|
show-sql: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
dialect: org.hibernate.dialect.MySQLDialect
|
||||||
|
format_sql: false
|
||||||
|
|
||||||
|
rabbitmq:
|
||||||
|
host: 10.1.1.233
|
||||||
|
port: 5672
|
||||||
|
username: admin
|
||||||
|
# 请将此密码替换为真实的 RabbitMQ 密码,然后将 app.rabbitmq-enabled 设为 true
|
||||||
|
password: shumengya520
|
||||||
|
virtual-host: mengyastore-dev
|
||||||
|
listener:
|
||||||
|
simple:
|
||||||
|
acknowledge-mode: manual
|
||||||
|
prefetch: 1
|
||||||
|
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: 10.1.1.100
|
||||||
|
port: 6379
|
||||||
|
password: tyh@19900420
|
||||||
|
database: 1
|
||||||
|
timeout: 3000ms
|
||||||
|
|
||||||
|
mail:
|
||||||
|
host: smtp.qq.com
|
||||||
|
port: 465
|
||||||
|
username: ""
|
||||||
|
password: ""
|
||||||
|
properties:
|
||||||
|
mail:
|
||||||
|
smtp:
|
||||||
|
auth: true
|
||||||
|
ssl:
|
||||||
|
enable: true
|
||||||
|
socketFactory:
|
||||||
|
port: 465
|
||||||
|
class: javax.net.ssl.SSLSocketFactory
|
||||||
|
fallback: false
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
app:
|
||||||
|
admin-token: shumengya520
|
||||||
|
auth-api-url: https://auth.api.shumengya.top
|
||||||
|
# 对应 Go 的 APP_ENV:development | production(系统状态页「应用环境」)
|
||||||
|
app-env: development
|
||||||
|
# 对应 Go 的 REDIS_ENABLED;false 时系统状态里 Redis 为「未启用」且不再探测
|
||||||
|
redis-enabled: true
|
||||||
|
# 对应 Go 后端的 RABBITMQ_ENABLED;默认关闭,密码配置正确后再改为 true
|
||||||
|
rabbitmq-enabled: true
|
||||||
|
rabbitmq-env: dev
|
||||||
|
redis-env: dev
|
||||||
|
public-api-base-url: ""
|
||||||
|
cors-allowed-origins: "http://localhost:5173,http://localhost:3000,http://localhost:5174"
|
||||||
|
|
||||||
|
# SMTP 在库里配置前无用户名密码;关闭邮件健康检查避免启动后打 ERROR 日志
|
||||||
|
management:
|
||||||
|
health:
|
||||||
|
mail:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
com.smyhub.store: INFO
|
||||||
|
org.springframework.amqp: WARN
|
||||||
|
org.springframework.data.redis: WARN
|
||||||
|
|||||||
112
mengyastore-backend-java/后端文档.md
Normal file
112
mengyastore-backend-java/后端文档.md
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
# 萌芽小店 · 后端(mengyastore-backend-java)
|
||||||
|
|
||||||
|
基于 **Java 17、Spring Boot 3、Spring MVC、Spring Data JPA(Hibernate)** 的 REST API,配套 **Vue 3** 前端联调;可在 **IntelliJ IDEA** 中运行 `MengyastoreBackendJavaApplication` 进行开发与测试。
|
||||||
|
|
||||||
|
## 技术栈与 Maven 依赖
|
||||||
|
|
||||||
|
| 依赖 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| spring-boot-starter-web | REST、Jackson |
|
||||||
|
| spring-boot-starter-data-jpa | 实体、Repository、事务 |
|
||||||
|
| mysql-connector-j | MySQL JDBC |
|
||||||
|
| spring-boot-starter-amqp | RabbitMQ |
|
||||||
|
| spring-boot-starter-data-redis | Redis |
|
||||||
|
| spring-boot-starter-validation | Bean 校验 |
|
||||||
|
| spring-boot-starter-mail | Jakarta Mail(业务 SMTP 配置来自数据库) |
|
||||||
|
| spring-boot-starter-actuator | 健康检查(默认关闭 Mail 指标以免未配 SMTP 报错) |
|
||||||
|
| lombok | Getter 等样板代码 |
|
||||||
|
|
||||||
|
## 包结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/main/java/com/smyhub/store/mengyastorebackendjava/
|
||||||
|
├── MengyastoreBackendJavaApplication.java
|
||||||
|
├── config/ # AppProperties、CORS、RabbitMQ、Redis、RestTemplate、WebMvc
|
||||||
|
├── controller/ # 按域拆分;admin/ 为管理端
|
||||||
|
├── dto/request|response/
|
||||||
|
├── entity/ # JPA;entity/converter 处理 JSON 列
|
||||||
|
├── exception/ # BusinessException、GlobalExceptionHandler
|
||||||
|
├── mq/ # 拓扑、Payload、Producer、Consumer(随 app.rabbitmq-enabled)
|
||||||
|
├── repository/
|
||||||
|
├── security/ # 管理令牌与用户 Bearer 拦截器、ThreadLocal 用户
|
||||||
|
└── service/ # 业务与 SystemStatusService 等
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 路由一览
|
||||||
|
|
||||||
|
### 公开
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/health` | 健康检查;含 RabbitMQ 状态(`ok` / `error` / `disabled`) |
|
||||||
|
| GET | `/api/products` | 上架商品列表(不含卡密与 `fixedContent`) |
|
||||||
|
| POST | `/api/products/{id}/view` | 记录浏览(进程内去重) |
|
||||||
|
| GET | `/api/stats` | 订单数、访问量 |
|
||||||
|
| POST | `/api/site/visit` | 站点访问计数 |
|
||||||
|
| GET | `/api/site/maintenance` | 维护状态 |
|
||||||
|
| POST | `/api/checkout` | 创建订单(可选 `Authorization: Bearer`) |
|
||||||
|
| POST | `/api/orders/{id}/confirm` | 确认订单 |
|
||||||
|
|
||||||
|
### 用户(Bearer,远程认证服务校验)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/orders` | 当前用户订单 |
|
||||||
|
| GET / POST | `/api/wishlist` | 列表;POST body `{"productId":"…"}` |
|
||||||
|
| DELETE | `/api/wishlist/{id}` | 删除,路径参数为商品 ID |
|
||||||
|
| GET / POST | `/api/chat/messages` | 消息列表;发送(带发送频控) |
|
||||||
|
|
||||||
|
### 管理端(`X-Admin-Token`;兼容 `Authorization`、`?token=`)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/admin/verify` | 请求体 `{"token":"…"}`,**响应顶层** `{"valid":true/false}` |
|
||||||
|
| GET / POST / PUT / PATCH / DELETE | `/api/admin/products` | 商品 CRUD 与上下架 |
|
||||||
|
| POST | `/api/admin/site/maintenance` | 维护模式 |
|
||||||
|
| GET / POST | `/api/admin/site/smtp` | SMTP 配置读/写 |
|
||||||
|
| GET | `/api/admin/orders` | 订单列表 |
|
||||||
|
| DELETE | `/api/admin/orders/{id}` | 删除订单 |
|
||||||
|
| GET / POST / DELETE | `/api/admin/chat`、`/api/admin/chat/{account}` | 会话、回复、清空 |
|
||||||
|
| GET | `/api/admin/system-status` | 聚合 backend / mysql / redis / rabbitmq;JSON 外层为 `{"data":{…},"error":null}` |
|
||||||
|
|
||||||
|
其它管理接口的成功响应多为 `{"data":…,"error":null}` 形式。
|
||||||
|
|
||||||
|
## 数据库表(摘要)
|
||||||
|
|
||||||
|
开发环境常用 `spring.jpa.hibernate.ddl-auto: update` 同步表结构。
|
||||||
|
|
||||||
|
**products:** 商品信息;`fulfillment_type`:`card`(卡密库存)/ `fixed`(固定内容);`fixed_content` 不在公开列表返回;含价格、标签 JSON、封面、截图 JSON、`active`、`require_login`、`max_per_account`、`total_sold`、`view_count`、`delivery_mode` 等。
|
||||||
|
|
||||||
|
**product_codes:** 每行一条卡密,关联 `product_id`;固定内容商品可不设。
|
||||||
|
|
||||||
|
**orders:** `delivered_codes` JSON、用户信息、`status`、`delivery_mode`、`notify_email`、联系方式等。
|
||||||
|
|
||||||
|
**site_settings:** 键值对,如 `totalVisits`、`maintenance`、`smtpHost`、`smtpPassword` 等。
|
||||||
|
|
||||||
|
**wishlists:** 用户账号与商品 ID 唯一约束。
|
||||||
|
|
||||||
|
**chat_messages:** 会话消息、`from_admin`、发送时间等。
|
||||||
|
|
||||||
|
## 配置说明(application.yaml)
|
||||||
|
|
||||||
|
| 项 | 说明 |
|
||||||
|
|----|------|
|
||||||
|
| `spring.datasource.*` | MySQL |
|
||||||
|
| `spring.jpa.hibernate.ddl-auto` | 如 `update` |
|
||||||
|
| `server.port` | HTTP 端口,需与前端 `VITE_API_BASE_URL` 一致 |
|
||||||
|
| `spring.rabbitmq.*` | Broker;`app.rabbitmq-enabled=false` 时不注册监听器与拓扑,避免无法连接时启动失败 |
|
||||||
|
| `spring.data.redis.*` | Redis;`app.redis-enabled=false` 时系统状态中 Redis 标记为未启用 |
|
||||||
|
| `app.admin-token` | 管理后台令牌 |
|
||||||
|
| `app.auth-api-url` | 用户 token 远程校验服务根地址(`POST …/api/auth/verify`) |
|
||||||
|
| `app.app-env` | `development` / `production` |
|
||||||
|
| `app.rabbitmq-enabled` | 是否启用 RabbitMQ 发信与消费者 |
|
||||||
|
| `app.redis-enabled` | 是否在系统状态中探测 Redis |
|
||||||
|
| `app.rabbitmq-env` / `app.redis-env` | `dev` / `prod`,影响交换机与队列名 |
|
||||||
|
| `app.cors-allowed-origins` | 逗号分隔的允许源 |
|
||||||
|
| `management.health.mail.enabled` | 建议 `false` 直至配置 SMTP |
|
||||||
|
|
||||||
|
**CORS:** 见 `CorsConfig`,包含 `Authorization`、`X-Admin-Token` 等请求头。
|
||||||
|
|
||||||
|
## 前端联调
|
||||||
|
|
||||||
|
前端 axios `baseURL` 来自环境变量或当前页 `origin`,请指向本服务监听的 **协议 + 主机 + 端口**。
|
||||||
@@ -1,254 +1,254 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<p class="text-[13px] text-muted">后端 API、MySQL、Redis、RabbitMQ 探活与配置摘要(需管理 Token)</p>
|
<p class="text-[13px] text-muted">后端 API、MySQL、Redis、RabbitMQ 探活与配置摘要(需管理 Token)</p>
|
||||||
<button
|
<button
|
||||||
class="ghost inline-flex items-center gap-1.5 text-sm px-3.5 py-1.5"
|
class="ghost inline-flex items-center gap-1.5 text-sm px-3.5 py-1.5"
|
||||||
:class="loading ? 'opacity-50 pointer-events-none' : ''"
|
:class="loading ? 'opacity-50 pointer-events-none' : ''"
|
||||||
type="button"
|
type="button"
|
||||||
@click="load"
|
@click="load"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
||||||
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
|
||||||
:class="loading ? 'animate-spin' : ''"
|
:class="loading ? 'animate-spin' : ''"
|
||||||
>
|
>
|
||||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8"/>
|
||||||
<path d="M21 3v5h-5"/>
|
<path d="M21 3v5h-5"/>
|
||||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16"/>
|
||||||
<path d="M8 16H3v5"/>
|
<path d="M8 16H3v5"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ loading ? '检测中…' : '刷新' }}
|
{{ loading ? '检测中…' : '刷新' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="error"
|
v-if="error"
|
||||||
class="mb-4 px-4 py-3 rounded-lg text-sm text-[#c95a6a] bg-[rgba(201,90,106,0.08)] border border-[rgba(201,90,106,0.2)]"
|
class="mb-4 px-4 py-3 rounded-lg text-sm text-[#c95a6a] bg-[rgba(201,90,106,0.08)] border border-[rgba(201,90,106,0.2)]"
|
||||||
>
|
>
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||||
<!-- 后端 API -->
|
<!-- 后端 API -->
|
||||||
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
<path d="M12 2L2 7l10 5 10-5-10-5z"/>
|
||||||
<path d="M2 17l10 5 10-5"/>
|
<path d="M2 17l10 5 10-5"/>
|
||||||
<path d="M2 12l10 5 10-5"/>
|
<path d="M2 12l10 5 10-5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[15px] font-semibold text-apptext">后端 API</span>
|
<span class="text-[15px] font-semibold text-apptext">后端 API</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(be?.status, loading)" :title="labelText(be?.status, loading)" />
|
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(be?.status, loading)" :title="labelText(be?.status, loading)" />
|
||||||
<span class="text-[13px] font-medium" :class="textClass(be?.status)">{{ labelText(be?.status, loading) }}</span>
|
<span class="text-[13px] font-medium" :class="textClass(be?.status)">{{ labelText(be?.status, loading) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-white/35 mb-3" />
|
<div class="border-t border-white/35 mb-3" />
|
||||||
<template v-if="be">
|
<template v-if="be">
|
||||||
<div class="grid gap-2 text-[13px]">
|
<div class="grid gap-2 text-[13px]">
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">应用环境</span><span class="font-medium text-apptext text-right">{{ be.appEnv === 'production' ? '生产' : '开发' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">应用环境</span><span class="font-medium text-apptext text-right">{{ be.appEnv === 'production' ? '生产' : '开发' }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">Gin 模式</span><span class="font-medium text-apptext text-right">{{ be.ginMode || '—' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">Gin 模式</span><span class="font-medium text-apptext text-right">{{ be.ginMode || '—' }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">当前请求 Host</span><span class="font-medium text-apptext text-right break-all">{{ be.requestHost || '—' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">当前请求 Host</span><span class="font-medium text-apptext text-right break-all">{{ be.requestHost || '—' }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">进程监听</span><span class="font-medium text-apptext text-right break-all">{{ be.listenAddr || '—' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">进程监听</span><span class="font-medium text-apptext text-right break-all">{{ be.listenAddr || '—' }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">对外基址</span><span class="font-medium text-apptext text-right break-all">{{ be.publicBaseUrl || '—' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">对外基址</span><span class="font-medium text-apptext text-right break-all">{{ be.publicBaseUrl || '—' }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">运行时长</span><span class="font-medium text-apptext text-right">{{ formatUptime(be.uptimeSeconds) }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">运行时长</span><span class="font-medium text-apptext text-right">{{ formatUptime(be.uptimeSeconds) }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">认证 API</span><span class="font-medium text-apptext text-right">{{ be.authApiConfigured ? '已配置' : '未配置' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">认证 API</span><span class="font-medium text-apptext text-right">{{ be.authApiConfigured ? '已配置' : '未配置' }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">RabbitMQ 功能</span><span class="font-medium text-apptext text-right">{{ be.rabbitmqEnabled ? '开' : '关' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">RabbitMQ 功能</span><span class="font-medium text-apptext text-right">{{ be.rabbitmqEnabled ? '开' : '关' }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">Redis 功能</span><span class="font-medium text-apptext text-right">{{ be.redisEnabled ? '开' : '关' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">Redis 功能</span><span class="font-medium text-apptext text-right">{{ be.redisEnabled ? '开' : '关' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MySQL -->
|
<!-- MySQL -->
|
||||||
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
<ellipse cx="12" cy="5" rx="9" ry="3"/>
|
||||||
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
<path d="M21 12c0 1.66-4 3-9 3s-9-1.34-9-3"/>
|
||||||
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
<path d="M3 5v14c0 1.66 4 3 9 3s9-1.34 9-3V5"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[15px] font-semibold text-apptext">MySQL</span>
|
<span class="text-[15px] font-semibold text-apptext">MySQL</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(mysql?.status, loading)" :title="labelText(mysql?.status, loading)" />
|
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(mysql?.status, loading)" :title="labelText(mysql?.status, loading)" />
|
||||||
<span class="text-[13px] font-medium" :class="textClass(mysql?.status)">{{ labelText(mysql?.status, loading) }}</span>
|
<span class="text-[13px] font-medium" :class="textClass(mysql?.status)">{{ labelText(mysql?.status, loading) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-white/35 mb-3" />
|
<div class="border-t border-white/35 mb-3" />
|
||||||
<template v-if="mysql">
|
<template v-if="mysql">
|
||||||
<div class="grid gap-2 text-[13px]">
|
<div class="grid gap-2 text-[13px]">
|
||||||
<div v-if="mysql.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ mysql.host }}{{ mysql.port ? ':' + mysql.port : '' }}</span></div>
|
<div v-if="mysql.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ mysql.host }}{{ mysql.port ? ':' + mysql.port : '' }}</span></div>
|
||||||
<div v-if="mysql.socket" class="flex justify-between gap-2"><span class="text-muted">Socket</span><span class="font-medium text-apptext text-right break-all">{{ mysql.socket }}</span></div>
|
<div v-if="mysql.socket" class="flex justify-between gap-2"><span class="text-muted">Socket</span><span class="font-medium text-apptext text-right break-all">{{ mysql.socket }}</span></div>
|
||||||
<div v-if="mysql.database" class="flex justify-between gap-2"><span class="text-muted">库名</span><span class="font-medium text-apptext text-right break-all">{{ mysql.database }}</span></div>
|
<div v-if="mysql.database" class="flex justify-between gap-2"><span class="text-muted">库名</span><span class="font-medium text-apptext text-right break-all">{{ mysql.database }}</span></div>
|
||||||
<div v-if="mysql.user" class="flex justify-between gap-2"><span class="text-muted">用户</span><span class="font-medium text-apptext text-right">{{ mysql.user }}</span></div>
|
<div v-if="mysql.user" class="flex justify-between gap-2"><span class="text-muted">用户</span><span class="font-medium text-apptext text-right">{{ mysql.user }}</span></div>
|
||||||
<div v-if="mysql.status === 'ok' && mysql.version" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right break-all">{{ mysql.version }}</span></div>
|
<div v-if="mysql.status === 'ok' && mysql.version" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right break-all">{{ mysql.version }}</span></div>
|
||||||
<div v-if="mysql.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">连接池</span><span class="font-medium text-apptext text-right">开 {{ mysql.openConnections }} / 用 {{ mysql.inUse }} / 闲 {{ mysql.idle }}</span></div>
|
<div v-if="mysql.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">连接池</span><span class="font-medium text-apptext text-right">开 {{ mysql.openConnections }} / 用 {{ mysql.inUse }} / 闲 {{ mysql.idle }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="mysql.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ mysql.message }}</p>
|
<p v-if="mysql.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ mysql.message }}</p>
|
||||||
<p v-if="mysql.dsnParseError" class="mt-2 text-[12px] text-[#b8860b] break-all">DSN 解析:{{ mysql.dsnParseError }}</p>
|
<p v-if="mysql.dsnParseError" class="mt-2 text-[12px] text-[#b8860b] break-all">DSN 解析:{{ mysql.dsnParseError }}</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Redis -->
|
<!-- Redis -->
|
||||||
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M12 2v20"/>
|
<path d="M12 2v20"/>
|
||||||
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[15px] font-semibold text-apptext">Redis</span>
|
<span class="text-[15px] font-semibold text-apptext">Redis</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rd?.status, loading)" :title="labelText(rd?.status, loading)" />
|
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rd?.status, loading)" :title="labelText(rd?.status, loading)" />
|
||||||
<span class="text-[13px] font-medium" :class="textClass(rd?.status)">{{ labelText(rd?.status, loading) }}</span>
|
<span class="text-[13px] font-medium" :class="textClass(rd?.status)">{{ labelText(rd?.status, loading) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-white/35 mb-3" />
|
<div class="border-t border-white/35 mb-3" />
|
||||||
<template v-if="rd">
|
<template v-if="rd">
|
||||||
<div class="grid gap-2 text-[13px]">
|
<div class="grid gap-2 text-[13px]">
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rd.enabled ? '已启用' : '未启用' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rd.enabled ? '已启用' : '未启用' }}</span></div>
|
||||||
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rd.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
|
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rd.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
|
||||||
<div v-if="rd.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ rd.host }}:{{ rd.port || '6379' }}</span></div>
|
<div v-if="rd.host" class="flex justify-between gap-2"><span class="text-muted">地址</span><span class="font-medium text-apptext text-right break-all">{{ rd.host }}:{{ rd.port || '6379' }}</span></div>
|
||||||
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">DB 编号</span><span class="font-medium text-apptext text-right">{{ rd.dbIndex ?? '—' }}</span></div>
|
<div v-if="rd.enabled" class="flex justify-between gap-2"><span class="text-muted">DB 编号</span><span class="font-medium text-apptext text-right">{{ rd.dbIndex ?? '—' }}</span></div>
|
||||||
<div v-if="rd.status === 'ok' && rd.redisVersion" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right">{{ rd.redisVersion }}</span></div>
|
<div v-if="rd.status === 'ok' && rd.redisVersion" class="flex justify-between gap-2"><span class="text-muted">版本</span><span class="font-medium text-apptext text-right">{{ rd.redisVersion }}</span></div>
|
||||||
<div v-if="rd.status === 'ok' && rd.usedMemoryHuman" class="flex justify-between gap-2"><span class="text-muted">占用内存</span><span class="font-medium text-apptext text-right">{{ rd.usedMemoryHuman }}</span></div>
|
<div v-if="rd.status === 'ok' && rd.usedMemoryHuman" class="flex justify-between gap-2"><span class="text-muted">占用内存</span><span class="font-medium text-apptext text-right">{{ rd.usedMemoryHuman }}</span></div>
|
||||||
<div v-if="rd.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">Key 约数</span><span class="font-medium text-apptext text-right">{{ rd.keysApprox ?? '—' }}</span></div>
|
<div v-if="rd.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">Key 约数</span><span class="font-medium text-apptext text-right">{{ rd.keysApprox ?? '—' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="rd.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rd.message }}</p>
|
<p v-if="rd.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rd.message }}</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- RabbitMQ -->
|
<!-- RabbitMQ -->
|
||||||
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
<div class="px-[18px] py-4 bg-white/50 border border-white/35 rounded-xl max-md:px-3">
|
||||||
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
<div class="flex items-center justify-between flex-wrap gap-2 mb-3">
|
||||||
<div class="flex items-center gap-2.5">
|
<div class="flex items-center gap-2.5">
|
||||||
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
<svg class="text-accent opacity-80" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
<path d="M4 12h16"/>
|
<path d="M4 12h16"/>
|
||||||
<path d="M4 8h12"/>
|
<path d="M4 8h12"/>
|
||||||
<path d="M4 16h8"/>
|
<path d="M4 16h8"/>
|
||||||
<circle cx="18" cy="8" r="2"/>
|
<circle cx="18" cy="8" r="2"/>
|
||||||
<circle cx="14" cy="16" r="2"/>
|
<circle cx="14" cy="16" r="2"/>
|
||||||
</svg>
|
</svg>
|
||||||
<span class="text-[15px] font-semibold text-apptext">RabbitMQ</span>
|
<span class="text-[15px] font-semibold text-apptext">RabbitMQ</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-1.5">
|
<div class="flex items-center gap-1.5">
|
||||||
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rb?.status, loading)" :title="labelText(rb?.status, loading)" />
|
<span class="inline-block w-2 h-2 rounded-full flex-shrink-0" :class="dotClass(rb?.status, loading)" :title="labelText(rb?.status, loading)" />
|
||||||
<span class="text-[13px] font-medium" :class="textClass(rb?.status)">{{ labelText(rb?.status, loading) }}</span>
|
<span class="text-[13px] font-medium" :class="textClass(rb?.status)">{{ labelText(rb?.status, loading) }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-white/35 mb-3" />
|
<div class="border-t border-white/35 mb-3" />
|
||||||
|
|
||||||
<template v-if="rb">
|
<template v-if="rb">
|
||||||
<div class="grid gap-2 text-[13px]">
|
<div class="grid gap-2 text-[13px]">
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rb.enabled ? '已启用' : '未启用' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">功能开关</span><span class="font-medium text-apptext text-right">{{ rb.enabled ? '已启用' : '未启用' }}</span></div>
|
||||||
<div v-if="rb.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rb.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
|
<div v-if="rb.enabled" class="flex justify-between gap-2"><span class="text-muted">逻辑环境</span><span class="font-medium text-apptext text-right">{{ rb.env === 'prod' ? '生产 (prod)' : '开发 (dev)' }}</span></div>
|
||||||
<div v-if="rb.brokerHost" class="flex justify-between gap-2"><span class="text-muted">Broker 地址</span><span class="font-medium text-apptext text-right break-all">{{ rb.brokerHost }}:{{ rb.brokerPort }}</span></div>
|
<div v-if="rb.brokerHost" class="flex justify-between gap-2"><span class="text-muted">Broker 地址</span><span class="font-medium text-apptext text-right break-all">{{ rb.brokerHost }}:{{ rb.brokerPort }}</span></div>
|
||||||
<div v-if="rb.vhost != null && rb.vhost !== ''" class="flex justify-between gap-2"><span class="text-muted">vhost</span><span class="font-medium text-apptext text-right break-all">{{ rb.vhost }}</span></div>
|
<div v-if="rb.vhost != null && rb.vhost !== ''" class="flex justify-between gap-2"><span class="text-muted">vhost</span><span class="font-medium text-apptext text-right break-all">{{ rb.vhost }}</span></div>
|
||||||
<div v-if="rb.brokerUser" class="flex justify-between gap-2"><span class="text-muted">连接用户</span><span class="font-medium text-apptext text-right">{{ rb.brokerUser }}</span></div>
|
<div v-if="rb.brokerUser" class="flex justify-between gap-2"><span class="text-muted">连接用户</span><span class="font-medium text-apptext text-right">{{ rb.brokerUser }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">交换机</span><span class="font-medium text-apptext text-right break-all">{{ rb.exchange || '—' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">交换机</span><span class="font-medium text-apptext text-right break-all">{{ rb.exchange || '—' }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">队列</span><span class="font-medium text-apptext text-right break-all">{{ rb.queue || '—' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">队列</span><span class="font-medium text-apptext text-right break-all">{{ rb.queue || '—' }}</span></div>
|
||||||
<div class="flex justify-between gap-2"><span class="text-muted">路由键</span><span class="font-medium text-apptext text-right break-all">{{ rb.routingKey || '—' }}</span></div>
|
<div class="flex justify-between gap-2"><span class="text-muted">路由键</span><span class="font-medium text-apptext text-right break-all">{{ rb.routingKey || '—' }}</span></div>
|
||||||
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">待投递消息数</span><span class="font-medium text-apptext text-right">{{ rb.messagesReady ?? '—' }}</span></div>
|
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">待投递消息数</span><span class="font-medium text-apptext text-right">{{ rb.messagesReady ?? '—' }}</span></div>
|
||||||
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">消费者数</span><span class="font-medium text-apptext text-right">{{ rb.consumers ?? '—' }}</span></div>
|
<div v-if="rb.status === 'ok'" class="flex justify-between gap-2"><span class="text-muted">消费者数</span><span class="font-medium text-apptext text-right">{{ rb.consumers ?? '—' }}</span></div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="rb.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rb.message }}</p>
|
<p v-if="rb.message" class="mt-3 text-[12px] text-[#c95a6a] leading-relaxed break-all">{{ rb.message }}</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p v-if="updatedAt" class="mt-3 text-[12px] text-muted text-right">最后检测:{{ updatedAt }}</p>
|
<p v-if="updatedAt" class="mt-3 text-[12px] text-muted text-right">最后检测:{{ updatedAt }}</p>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { fetchSystemStatus } from '../../shared/api'
|
import { fetchSystemStatus } from '../../shared/api'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
adminToken: { type: String, default: '' }
|
adminToken: { type: String, default: '' }
|
||||||
})
|
})
|
||||||
|
|
||||||
const data = ref(null)
|
const data = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const updatedAt = ref('')
|
const updatedAt = ref('')
|
||||||
|
|
||||||
const be = computed(() => data.value?.backend || null)
|
const be = computed(() => data.value?.backend || null)
|
||||||
const mysql = computed(() => data.value?.mysql || null)
|
const mysql = computed(() => data.value?.mysql || null)
|
||||||
const rd = computed(() => data.value?.redis || null)
|
const rd = computed(() => data.value?.redis || null)
|
||||||
const rb = computed(() => data.value?.rabbitmq || null)
|
const rb = computed(() => data.value?.rabbitmq || null)
|
||||||
|
|
||||||
function formatUptime(sec) {
|
function formatUptime(sec) {
|
||||||
if (sec == null || sec < 0) return '—'
|
if (sec == null || sec < 0) return '—'
|
||||||
const s = Math.floor(sec)
|
const s = Math.floor(sec)
|
||||||
const h = Math.floor(s / 3600)
|
const h = Math.floor(s / 3600)
|
||||||
const m = Math.floor((s % 3600) / 60)
|
const m = Math.floor((s % 3600) / 60)
|
||||||
const r = s % 60
|
const r = s % 60
|
||||||
if (h > 0) return `${h} 小时 ${m} 分`
|
if (h > 0) return `${h} 小时 ${m} 分`
|
||||||
if (m > 0) return `${m} 分 ${r} 秒`
|
if (m > 0) return `${m} 分 ${r} 秒`
|
||||||
return `${r} 秒`
|
return `${r} 秒`
|
||||||
}
|
}
|
||||||
|
|
||||||
function labelText(status, isLoading) {
|
function labelText(status, isLoading) {
|
||||||
if (status == null && isLoading) return '检测中…'
|
if (status == null && isLoading) return '检测中…'
|
||||||
if (status == null) return '未加载'
|
if (status == null) return '未加载'
|
||||||
if (status === 'ok') return '正常'
|
if (status === 'ok') return '正常'
|
||||||
if (status === 'disabled') return '未启用'
|
if (status === 'disabled') return '未启用'
|
||||||
if (status === 'misconfigured') return '配置不全'
|
if (status === 'misconfigured') return '配置不全'
|
||||||
if (status === 'degraded') return '部分可用'
|
if (status === 'degraded') return '部分可用'
|
||||||
if (status === 'error') return '异常'
|
if (status === 'error') return '异常'
|
||||||
return '未知'
|
return '未知'
|
||||||
}
|
}
|
||||||
|
|
||||||
function dotClass(status, isLoading) {
|
function dotClass(status, isLoading) {
|
||||||
if (status === 'ok') return 'bg-[#3a9a68]'
|
if (status === 'ok') return 'bg-[#3a9a68]'
|
||||||
if (status === 'disabled') return 'bg-[#c0bcc4]'
|
if (status === 'disabled') return 'bg-[#c0bcc4]'
|
||||||
if (status === 'degraded' || status === 'misconfigured') return 'bg-[#c9a227]'
|
if (status === 'degraded' || status === 'misconfigured') return 'bg-[#c9a227]'
|
||||||
if (status == null && isLoading) return 'bg-[#b49acb] animate-pulse'
|
if (status == null && isLoading) return 'bg-[#b49acb] animate-pulse'
|
||||||
return 'bg-[#c95a6a]'
|
return 'bg-[#c95a6a]'
|
||||||
}
|
}
|
||||||
|
|
||||||
function textClass(status) {
|
function textClass(status) {
|
||||||
if (status === 'ok') return 'text-[#3a9a68]'
|
if (status === 'ok') return 'text-[#3a9a68]'
|
||||||
if (status === 'disabled') return 'text-muted'
|
if (status === 'disabled') return 'text-muted'
|
||||||
if (status === 'degraded' || status === 'misconfigured') return 'text-[#b8860b]'
|
if (status === 'degraded' || status === 'misconfigured') return 'text-[#b8860b]'
|
||||||
return 'text-[#c95a6a]'
|
return 'text-[#c95a6a]'
|
||||||
}
|
}
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (!props.adminToken) {
|
if (!props.adminToken) {
|
||||||
error.value = '请先输入管理 Token'
|
error.value = '请先输入管理 Token'
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
try {
|
try {
|
||||||
data.value = await fetchSystemStatus(props.adminToken)
|
data.value = await fetchSystemStatus(props.adminToken)
|
||||||
updatedAt.value = new Date().toLocaleTimeString('zh-CN')
|
updatedAt.value = new Date().toLocaleTimeString('zh-CN')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e?.response?.status === 401
|
error.value = e?.response?.status === 401
|
||||||
? 'Token 无效,无权查看'
|
? 'Token 无效,无权查看'
|
||||||
: '获取失败:' + (e?.message || '网络错误')
|
: '获取失败:' + (e?.message || '网络错误')
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (props.adminToken) load()
|
if (props.adminToken) load()
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => props.adminToken,
|
() => props.adminToken,
|
||||||
(v) => {
|
(v) => {
|
||||||
if (v) load()
|
if (v) load()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
defineExpose({ load })
|
defineExpose({ load })
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,143 +1,143 @@
|
|||||||
# 萌芽小店 · 前端(mengyastore-frontend)
|
# 萌芽小店 · 前端(mengyastore-frontend)
|
||||||
|
|
||||||
基于 **Vue 3 + Vite** + **Tailwind CSS(Vite 插件)** 的数字商品前端:商店、结算(含扫码收款演示)、管理后台、收藏、订单、聊天、PWA。
|
基于 **Vue 3 + Vite** + **Tailwind CSS(Vite 插件)** 的数字商品前端:商店、结算(含扫码收款演示)、管理后台、收藏、订单、聊天、PWA。
|
||||||
|
|
||||||
## 技术依赖
|
## 技术依赖
|
||||||
|
|
||||||
| 包 | 用途 |
|
| 包 | 用途 |
|
||||||
|----|------|
|
|----|------|
|
||||||
| vue ^3.x | 框架 |
|
| vue ^3.x | 框架 |
|
||||||
| vite ^5.x | 构建 |
|
| vite ^5.x | 构建 |
|
||||||
| @tailwindcss/vite | 工具类样式 |
|
| @tailwindcss/vite | 工具类样式 |
|
||||||
| vue-router ^4.x | 路由 |
|
| vue-router ^4.x | 路由 |
|
||||||
| pinia ^2.x | 状态(与 reactive 并存) |
|
| pinia ^2.x | 状态(与 reactive 并存) |
|
||||||
| axios ^1.6 | HTTP |
|
| axios ^1.6 | HTTP |
|
||||||
| markdown-it ^14 | 商品描述 |
|
| markdown-it ^14 | 商品描述 |
|
||||||
| vite-plugin-pwa | PWA |
|
| vite-plugin-pwa | PWA |
|
||||||
|
|
||||||
## 目录结构
|
## 目录结构
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
src/
|
||||||
├── assets/
|
├── assets/
|
||||||
│ ├── styles.css # 全局样式、.ghost / .page-card / . form-field 等
|
│ ├── styles.css # 全局样式、.ghost / .page-card / . form-field 等
|
||||||
│ └── payment/ # 支付宝/微信收款码 PNG(import 进 CheckoutPage)
|
│ └── payment/ # 支付宝/微信收款码 PNG(import 进 CheckoutPage)
|
||||||
├── router/index.js # 路由;维护模式 beforeEach
|
├── router/index.js # 路由;维护模式 beforeEach
|
||||||
├── modules/
|
├── modules/
|
||||||
│ ├── shared/
|
│ ├── shared/
|
||||||
│ │ ├── api.js # API 封装(含 fetchSystemStatus)
|
│ │ ├── api.js # API 封装(含 fetchSystemStatus)
|
||||||
│ │ ├── auth.js # 登录态、getLoginUrl
|
│ │ ├── auth.js # 登录态、getLoginUrl
|
||||||
│ │ ├── fulfillment.js # isFixedFulfillment、stockLabel、isSoldOutProduct
|
│ │ ├── fulfillment.js # isFixedFulfillment、stockLabel、isSoldOutProduct
|
||||||
│ │ └── useWishlist.js
|
│ │ └── useWishlist.js
|
||||||
│ ├── store/
|
│ ├── store/
|
||||||
│ │ ├── StorePage.vue
|
│ │ ├── StorePage.vue
|
||||||
│ │ ├── ProductDetail.vue
|
│ │ ├── ProductDetail.vue
|
||||||
│ │ ├── CheckoutPage.vue # 响应式布局、支付渠道 Tab、订单号复制、收款图
|
│ │ ├── CheckoutPage.vue # 响应式布局、支付渠道 Tab、订单号复制、收款图
|
||||||
│ │ └── components/ProductCard.vue
|
│ │ └── components/ProductCard.vue
|
||||||
│ ├── admin/
|
│ ├── admin/
|
||||||
│ │ ├── AdminPage.vue # 侧边栏:商品/订单/聊天/设置/系统状态
|
│ │ ├── AdminPage.vue # 侧边栏:商品/订单/聊天/设置/系统状态
|
||||||
│ │ └── components/
|
│ │ └── components/
|
||||||
│ │ ├── AdminProductModal.vue # 发货方式:卡密 | 固定内容
|
│ │ ├── AdminProductModal.vue # 发货方式:卡密 | 固定内容
|
||||||
│ │ ├── AdminProductTable.vue
|
│ │ ├── AdminProductTable.vue
|
||||||
│ │ ├── AdminOrderTable.vue
|
│ │ ├── AdminOrderTable.vue
|
||||||
│ │ ├── AdminChatPanel.vue
|
│ │ ├── AdminChatPanel.vue
|
||||||
│ │ ├── AdminMaintenanceRow.vue
|
│ │ ├── AdminMaintenanceRow.vue
|
||||||
│ │ ├── AdminSMTPRow.vue
|
│ │ ├── AdminSMTPRow.vue
|
||||||
│ │ └── AdminSystemStatusPanel.vue
|
│ │ └── AdminSystemStatusPanel.vue
|
||||||
│ ├── user/MyOrdersPage.vue
|
│ ├── user/MyOrdersPage.vue
|
||||||
│ ├── wishlist/WishlistPage.vue
|
│ ├── wishlist/WishlistPage.vue
|
||||||
│ ├── maintenance/MaintenancePage.vue
|
│ ├── maintenance/MaintenancePage.vue
|
||||||
│ ├── auth/AuthCallback.vue
|
│ ├── auth/AuthCallback.vue
|
||||||
│ └── chat/ChatWidget.vue
|
│ └── chat/ChatWidget.vue
|
||||||
└── App.vue # 顶栏:桌面横排导航;移动端汉堡菜单 + 下拉面板
|
└── App.vue # 顶栏:桌面横排导航;移动端汉堡菜单 + 下拉面板
|
||||||
```
|
```
|
||||||
|
|
||||||
## 路由
|
## 路由
|
||||||
|
|
||||||
| 路径 | 组件 | 说明 |
|
| 路径 | 组件 | 说明 |
|
||||||
|------|------|------|
|
|------|------|------|
|
||||||
| `/` | StorePage | 商品列表 |
|
| `/` | StorePage | 商品列表 |
|
||||||
| `/product/:id` | ProductDetail | 详情 |
|
| `/product/:id` | ProductDetail | 详情 |
|
||||||
| `/checkout/:id` | CheckoutPage | 结算(路径参数为商品 id) |
|
| `/checkout/:id` | CheckoutPage | 结算(路径参数为商品 id) |
|
||||||
| `/my/orders` | MyOrdersPage | 我的订单(建议登录) |
|
| `/my/orders` | MyOrdersPage | 我的订单(建议登录) |
|
||||||
| `/wishlist` | WishlistPage | 收藏 |
|
| `/wishlist` | WishlistPage | 收藏 |
|
||||||
| `/admin` | AdminPage | 管理后台(token) |
|
| `/admin` | AdminPage | 管理后台(token) |
|
||||||
| `/auth/callback` | AuthCallback | OAuth 回调 |
|
| `/auth/callback` | AuthCallback | OAuth 回调 |
|
||||||
| `/maintenance` | MaintenancePage | 维护中 |
|
| `/maintenance` | MaintenancePage | 维护中 |
|
||||||
|
|
||||||
### 路由守卫
|
### 路由守卫
|
||||||
|
|
||||||
- 请求 `GET /api/site/maintenance`;维护中时跳转 `/maintenance`。
|
- 请求 `GET /api/site/maintenance`;维护中时跳转 `/maintenance`。
|
||||||
- 豁免:`/maintenance`、`/wishlist`、`/admin`、`/auth/callback`(与 `router/index.js` 保持一致)。
|
- 豁免:`/maintenance`、`/wishlist`、`/admin`、`/auth/callback`(与 `router/index.js` 保持一致)。
|
||||||
|
|
||||||
## 认证
|
## 认证
|
||||||
|
|
||||||
1. 「萌芽账号登录」→ SproutGate 登录页,`callback` 回指本站。
|
1. 「萌芽账号登录」→ SproutGate 登录页,`callback` 回指本站。
|
||||||
2. Token 存 `localStorage`,`authState` 共享用户信息。
|
2. Token 存 `localStorage`,`authState` 共享用户信息。
|
||||||
3. 需登录接口带 `Authorization: Bearer <token>`。
|
3. 需登录接口带 `Authorization: Bearer <token>`。
|
||||||
|
|
||||||
## 功能要点
|
## 功能要点
|
||||||
|
|
||||||
### 商品与库存展示
|
### 商品与库存展示
|
||||||
|
|
||||||
- API 字段 **`fulfillmentType`**:`card` | `fixed`。
|
- API 字段 **`fulfillmentType`**:`card` | `fixed`。
|
||||||
- **`fixed`**:列表/详情/卡片显示库存为 **「不限」**,不按 `quantity===0` 售罄。
|
- **`fixed`**:列表/详情/卡片显示库存为 **「不限」**,不按 `quantity===0` 售罄。
|
||||||
- 工具函数:`modules/shared/fulfillment.js`。
|
- 工具函数:`modules/shared/fulfillment.js`。
|
||||||
|
|
||||||
### 管理后台 · 商品
|
### 管理后台 · 商品
|
||||||
|
|
||||||
- **卡密**:多行输入,逐条对应 `codes`。
|
- **卡密**:多行输入,逐条对应 `codes`。
|
||||||
- **固定内容**:大文本 `fixedContent`;保存后无需维护条数。
|
- **固定内容**:大文本 `fixedContent`;保存后无需维护条数。
|
||||||
|
|
||||||
### 结算页(CheckoutPage)
|
### 结算页(CheckoutPage)
|
||||||
|
|
||||||
- 栅格与表单单列适配窄屏。
|
- 栅格与表单单列适配窄屏。
|
||||||
- 下单成功后:应付金额、支付宝/微信/赞赏 **本地图片** 切换、`订单号` **一键复制**、可选展开服务端 `qrCodeUrl`。
|
- 下单成功后:应付金额、支付宝/微信/赞赏 **本地图片** 切换、`订单号` **一键复制**、可选展开服务端 `qrCodeUrl`。
|
||||||
- 数量上限:卡密=min(库存, 每账号限购);固定内容=仅每账号限购(若设置)。
|
- 数量上限:卡密=min(库存, 每账号限购);固定内容=仅每账号限购(若设置)。
|
||||||
|
|
||||||
### 顶栏(App.vue)
|
### 顶栏(App.vue)
|
||||||
|
|
||||||
- `md` 及以上:横向按钮。
|
- `md` 及以上:横向按钮。
|
||||||
- 以下:汉堡按钮 + 全宽大标题项 + 遮罩;Escape 关闭;打开时锁 body 滚动。
|
- 以下:汉堡按钮 + 全宽大标题项 + 遮罩;Escape 关闭;打开时锁 body 滚动。
|
||||||
|
|
||||||
### 系统状态
|
### 系统状态
|
||||||
|
|
||||||
- `AdminSystemStatusPanel` 调用 `GET /api/admin/system-status`,展示后端/API、MySQL、Redis、RabbitMQ(需管理 Token)。
|
- `AdminSystemStatusPanel` 调用 `GET /api/admin/system-status`,展示后端/API、MySQL、Redis、RabbitMQ(需管理 Token)。
|
||||||
|
|
||||||
### PWA
|
### PWA
|
||||||
|
|
||||||
- `vite-plugin-pwa`:`registerType: 'autoUpdate'`,构建后生成 SW;根组件有更新 Toast 与 `SplashScreen`。
|
- `vite-plugin-pwa`:`registerType: 'autoUpdate'`,构建后生成 SW;根组件有更新 Toast 与 `SplashScreen`。
|
||||||
|
|
||||||
## 开发命令
|
## 开发命令
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
npm run dev # :5173
|
npm run dev # :5173
|
||||||
npm run build # dist/
|
npm run build # dist/
|
||||||
npm run preview
|
npm run preview
|
||||||
```
|
```
|
||||||
|
|
||||||
### 环境变量
|
### 环境变量
|
||||||
|
|
||||||
开发示例(`.env.development`):
|
开发示例(`.env.development`):
|
||||||
|
|
||||||
```env
|
```env
|
||||||
VITE_API_BASE_URL=http://localhost:8080
|
VITE_API_BASE_URL=http://localhost:8080
|
||||||
```
|
```
|
||||||
|
|
||||||
生产部署设置实际 API 基地址(或同源反代可不设)。
|
生产部署设置实际 API 基地址(或同源反代可不设)。
|
||||||
|
|
||||||
## 部署(静态资源)
|
## 部署(静态资源)
|
||||||
|
|
||||||
```nginx
|
```nginx
|
||||||
location / {
|
location / {
|
||||||
try_files $uri $uri/ /index.html;
|
try_files $uri $uri/ /index.html;
|
||||||
}
|
}
|
||||||
location /api/ {
|
location /api/ {
|
||||||
proxy_pass http://127.0.0.1:8080;
|
proxy_pass http://127.0.0.1:8080;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
字体与色板以 `src/assets/styles.css` 及 Tailwind 主题为准;文档若与代码不一致,以仓库当前实现为准。
|
字体与色板以 `src/assets/styles.css` 及 Tailwind 主题为准;文档若与代码不一致,以仓库当前实现为准。
|
||||||
|
|||||||
5
萌芽小店项目经历-java.md
Normal file
5
萌芽小店项目经历-java.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
**项目名称:** 萌芽小店-网关登录的轻量级商城系统(Java 后端)
|
||||||
|
**时间:** 2026.01 – 2026.04
|
||||||
|
**技术栈:** Java 17、Spring Boot 3(Spring Web、Spring Data JPA、Spring AMQP、Spring Data Redis、Validation、Mail、Actuator)、MySQL、Hibernate、RabbitMQ / Redis、Maven、Lombok
|
||||||
|
**描述:** 基于 Spring Boot 的后端服务,与前端仓库配套 REST 接口:商品展示与下单、发货、订单邮件通知(SMTP 存库配置,可选 RabbitMQ 异步投递与失败回退)、购物车、在线客服、站点维护模式与访问量统计;管理端覆盖商品与订单、对话、SMTP 与系统状态(MySQL / Redis / RabbitMQ 探活与连接池等指标)。分层为 Controller、Service、Repository 与 JPA 实体,拦截器区分公开接口、Bearer 用户鉴权(第三方认证网关 HTTP 校验)与管理令牌;全局异常与统一响应体。浏览量在单机场景下进程内去重
|
||||||
|
**项目地址:** https://store.smyhub.com | **开源地址:** https://github.com/shumengya/mengyastore
|
||||||
Reference in New Issue
Block a user