139 lines
4.3 KiB
Go
139 lines
4.3 KiB
Go
// Admin 是本地运行的 Web 管理端:管理蝦皮商品、PDD 商品、顺运宝货运单、
|
|
// 采集采购和客户端,并给 Client 提供三个接口。
|
|
//
|
|
// 启动:go run . 然后打开 http://127.0.0.1:8080
|
|
// 这个命令是安全的——Admin 只管理数据,不会连手机、不会下单。
|
|
//
|
|
// 改动前必读 admin/AGENTS.md 和根目录 AGENTS.md 的红线。
|
|
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"embed"
|
|
"flag"
|
|
"fmt"
|
|
"html/template"
|
|
"io/fs"
|
|
"log"
|
|
"net/http"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"cmautobuy/admin/config"
|
|
"cmautobuy/admin/handler/api"
|
|
"cmautobuy/admin/handler/integration"
|
|
"cmautobuy/admin/handler/web"
|
|
"cmautobuy/admin/repository"
|
|
"cmautobuy/admin/service"
|
|
)
|
|
|
|
// 模板和静态文件打进二进制,这样打包后只有一个 exe,
|
|
// 用户不会误删或改坏,见 docs/admin/06-quality-security.md §9。
|
|
//
|
|
//go:embed templates
|
|
var templateFS embed.FS
|
|
|
|
//go:embed static
|
|
var staticFS embed.FS
|
|
|
|
func main() {
|
|
addr := flag.String("addr", "127.0.0.1:8080",
|
|
"监听地址。默认只听本机,要给内网用需单独评估安全性")
|
|
flag.Parse()
|
|
|
|
// 1. 数据目录
|
|
dataDir, err := config.DataDir()
|
|
if err != nil {
|
|
log.Fatalf("无法确定 data 目录: %v", err)
|
|
}
|
|
log.Printf("数据目录: %s", dataDir)
|
|
|
|
// 2. 数据库。本地可从被 Git 忽略的 config.yaml 读取,线上环境变量优先覆盖;
|
|
// 无论从哪里读取,凭据都不写进日志。
|
|
databaseConfig, err := config.LoadDatabase()
|
|
if err != nil {
|
|
log.Fatalf("读取 MySQL 配置失败: %v", err)
|
|
}
|
|
db, err := repository.OpenMySQL(databaseConfig)
|
|
if err != nil {
|
|
log.Fatalf("打开 MySQL 数据库失败: %v", err)
|
|
}
|
|
defer db.Close()
|
|
|
|
if err := repository.MigrateMySQL(db); err != nil {
|
|
log.Fatalf("MySQL 数据库迁移失败: %v", err)
|
|
}
|
|
interrupted, err := service.InterruptRunningSybSyncRuns(db, time.Now())
|
|
if err != nil {
|
|
log.Fatalf("恢复上次中断的顺运宝同步记录失败: %v", err)
|
|
}
|
|
if interrupted > 0 {
|
|
log.Printf("已把 %d 条上次进程遗留的顺运宝同步记录标记为中断", interrupted)
|
|
}
|
|
aiInterrupted, err := service.InterruptRunningAIMatchBatches(db, time.Now())
|
|
if err != nil {
|
|
log.Fatalf("收敛上次进程遗留的 AI 匹配批次失败: %v", err)
|
|
}
|
|
if aiInterrupted > 0 {
|
|
log.Printf("已把 %d 个上次进程遗留的 AI 匹配批次标记为中断", aiInterrupted)
|
|
}
|
|
innerCodeInterrupted, err := service.InterruptApplyingInnerCodes(db, time.Now())
|
|
if err != nil {
|
|
log.Fatalf("恢复中断的档口入库码回写失败: %v", err)
|
|
}
|
|
if innerCodeInterrupted > 0 {
|
|
log.Printf("已把 %d 条上次进程遗留的档口入库码回写标记为需核对", innerCodeInterrupted)
|
|
}
|
|
log.Printf("数据库已就绪")
|
|
|
|
// 3. Web 引擎
|
|
r, err := newRouter(db)
|
|
if err != nil {
|
|
log.Fatalf("准备 Web 页面失败: %v", err)
|
|
}
|
|
|
|
log.Printf("Admin 已启动: http://%s", *addr)
|
|
if err := r.Run(*addr); err != nil {
|
|
log.Fatalf("启动失败: %v", err)
|
|
}
|
|
}
|
|
|
|
// newRouter 组装 Admin 的模板、静态文件和路由。
|
|
// 单独提成函数是为了让页面冒烟测试无需真的占用 TCP 端口。
|
|
func newRouter(db *sql.DB) (*gin.Engine, error) {
|
|
r := gin.New()
|
|
r.Use(gin.Logger(), gin.Recovery())
|
|
|
|
// 模板:每个文件用 {{define "目录/名字"}} 声明自己的名字,
|
|
// 所以一次全部解析进来不会冲突。
|
|
tmpl, err := template.ParseFS(templateFS, "templates/*/*.html")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("解析模板失败: %w", err)
|
|
}
|
|
r.SetHTMLTemplate(tmpl)
|
|
|
|
// 静态文件
|
|
staticSub, err := fs.Sub(staticFS, "static")
|
|
if err != nil {
|
|
return nil, fmt.Errorf("准备静态文件失败: %w", err)
|
|
}
|
|
r.StaticFS("/static", http.FS(staticSub))
|
|
|
|
// 4. 路由
|
|
aiConfig, err := config.LoadAIConfig()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取 AI 部署配置失败: %w", err)
|
|
}
|
|
aiSecrets := service.NewFileAISecretStore(aiConfig.SecretsPath)
|
|
aiPolicy := service.NewAIEndpointPolicy(aiConfig.AllowedHosts)
|
|
web.Register(r, db, config.OnlineThreshold, aiSecrets, aiPolicy) // 给浏览器的页面
|
|
api.Register(r, db) // 给 Client 的接口
|
|
catalogConfig, err := config.LoadCatalogIntegration()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("读取商品目录接口配置失败: %w", err)
|
|
}
|
|
integration.Register(r, db, catalogConfig) // 给受信任第三方脚本的接口
|
|
return r, nil
|
|
}
|