DESKTOP-HN5QP3V\Administrator 2 місяців тому
батько
коміт
6eb39363bd

+ 21 - 0
.env.pro

@@ -0,0 +1,21 @@
+MYSQL_DSN="root:123456@tcp(127.0.0.1:3306)/dataReport?charset=utf8&parseTime=True&loc=Local"
+MONGO_DSN="mongodb://mongouser:Ie8gwLRHU6fg@10.0.147.7:27017,10.0.147.9:27017/dsbqj?replicaSet=cmgo-ndb85h0v_0&authSource=admin&ssl=false&readPreference=secondaryPreferred"
+SESSION_SECRET="youneedtoset"
+CHANNEL="test"
+GIN_MODE="debug"
+RUNTIME_ROOT_PATH="log/"
+LOG_SAVE_PATH="logs/"
+LOG_SAVE_NAME="debug_"
+TIME_FORMAT="2006010215"
+LOG_FILE_EXT="log"
+LISTEN_PORT=8765
+REDIS_DB=52
+REDIS_ADDR="127.0.0.1:6379"
+REDIS_PW=""
+WECHAT_APPID="wx4e85c6fbd4e0b279"
+WECHAT_SECRET="6df059f9ff00a3bce5b03b8eaf00d453"
+SDK_PLATFORMID="244"
+SDK_APPID="240416508715"
+SDK_APPKEY="4qyc7caeqvazgyxbcjysfumgyhzmmcbs"
+SDK_CLEARCACHE="1"
+

+ 95 - 0
PROJECT_SPEC.md

@@ -0,0 +1,95 @@
+# 项目规范文档 (dsbqj-admin)
+
+本文档旨在概述 `dsbqj-admin` Go 项目的技术栈、结构、规范和可用命令,为开发者提供统一的指引。
+
+## 1. 项目概述
+
+这是一个基于 Go 语言和 Gin 框架构建的后台管理系统。项目采用 Monorepo(单一仓库多项目)的组织形式,包含了多个独立运行的服务。
+
+- **项目名称**: `dsbqj-admin`
+- **Go 版本**: `1.24`
+- **核心框架**: Gin
+
+## 2. 技术栈
+
+项目采用了一系列成熟的 Go 技术栈,核心技术如下:
+
+- **Web 框架**: `github.com/gin-gonic/gin`
+- **数据库**:
+  - **MongoDB**: 使用 `go.mongodb.org/mongo-driver` 驱动和 `github.com/kamva/mgm/v3` ORM。
+  - **MySQL**: 使用 `github.com/jinzhu/gorm` ORM。
+- **缓存**: `github.com/go-redis/redis`
+- **配置**: 通过 `github.com/joho/godotenv` 从 `.env` 文件加载环境变量。
+- **校验**: `github.com/go-playground/validator/v10`
+- **云服务**:
+  - **阿里云对象存储 (OSS)**
+  - **阿里云短信服务**
+
+## 3. 项目结构
+
+项目遵循了清晰的、按功能划分的目录结构:
+
+```
+.
+├── app/                  # 应用核心逻辑
+│   ├── api/              # API 接口定义 (HTTP Handler)
+│   ├── router/           # 路由注册与配置
+│   ├── service/          # 业务逻辑层
+│   └── task/             # 后台定时任务
+├── config/               # 配置文件 (如 .env.local)
+├── main-admin/           # 管理后台服务入口
+├── main-point/           # "Point" 服务入口
+├── main-subscribe/       # "Subscribe" 服务入口
+├── middleware/           # Gin 中间件 (JWT, CORS, Logger)
+├── model/                # 数据模型 (区分 Mongo 和 MySQL)
+│   ├── mongo/
+│   └── mysql/
+├── pkg/                  # 可重用的公共包 (工具、日志、配置等)
+├── .gitignore
+├── go.mod                # Go 模块依赖
+├── go.sum
+└── build.bat             # 全局构建脚本
+```
+
+## 4. 编码规范
+
+为了保持代码的一致性和高质量,所有开发者应遵循以下规范:
+
+### 代码格式化
+
+- **强制使用 `gofmt`**: 所有提交的代码都必须使用 `gofmt` 进行格式化。推荐在 IDE 中配置保存时自动格式化。
+- **遵循 Go 社区风格**: 编写符合 Go 社区普遍接受的编码风格和模式。
+
+### 静态检查
+
+- **使用 `go vet`**: 在提交代码前,运行 `go vet ./...` 来捕获可疑的构造和潜在错误。
+- **Linter (推荐)**: 虽然项目中未包含 `.golangci.yml`,但强烈建议使用 [golangci-lint](https://golangci-lint.run/) 并配置一套合理的规则,以进行更全面的静态分析。
+
+### 命名规范
+
+- **包名**: 使用简短、小写且有意义的名称。
+- **变量与函数**: 遵循 Go 的驼峰命名法(`camelCase` 或 `PascalCase`),并根据其导出状态决定首字母大小写。
+- **接口**: 单一方法的接口通常以 `-er` 结尾(如 `Reader`, `Writer`)。
+
+## 5. 可用命令
+
+项目的主要操作通过根目录及各 `main-*` 目录下的 `build.bat` 批处理脚本执行。
+
+- **`build.bat` (根目录)**:
+  - 用于构建所有或特定的服务。具体用法请查看脚本内容。
+
+- **`main-*/build.bat`**:
+  - 用于单独构建和运行对应的服务(`admin`, `point`, `subscribe`)。
+
+- **运行服务 (示例)**:
+  ```bash
+  # 进入特定服务的目录
+  cd main-admin
+
+  # 运行构建脚本 (如果脚本支持) 或直接运行 main.go
+  go run main.go
+  ```
+
+- **依赖管理**:
+  - **安装/更新依赖**: `go mod tidy`
+  - **下载依赖**: `go mod download`

+ 45 - 0
app/api/v1/auth.go

@@ -0,0 +1,45 @@
+package v1
+
+import (
+	"dsbqj-admin/app/service"
+	"dsbqj-admin/pkg/app"
+	"dsbqj-admin/pkg/e"
+	"net/http"
+
+	"github.com/gin-gonic/gin"
+)
+
+func AdminLogin(c *gin.Context) {
+	var appG = app.Gin{C: c}
+	var svc service.LoginService
+	if err := c.ShouldBindJSON(&svc); err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	data, err := svc.Login()
+	if err != nil {
+		appG.Response(http.StatusOK, e.ERROR_USER_LOGIN_FAIL, err.Error())
+		return
+	}
+	appG.Response(http.StatusOK, e.SUCCESS, data)
+}
+
+func AdminChangePassword(c *gin.Context) {
+	var appG = app.Gin{C: c}
+	var svc service.ChangePasswordService
+	if err := c.ShouldBindJSON(&svc); err != nil {
+		appG.Response(http.StatusOK, e.INVALID_PARAMS, err.Error())
+		return
+	}
+	username, ok := c.Get("admin_username")
+	if !ok {
+		appG.Response(http.StatusOK, e.ERROR_AUTH, nil)
+		return
+	}
+	svc.Username = username.(string)
+	if err := svc.Change(); err != nil {
+		appG.Response(http.StatusOK, e.ERROR_USER_CHPWD_FAIL, err.Error())
+		return
+	}
+	appG.Response(http.StatusOK, e.SUCCESS, nil)
+}

+ 16 - 11
app/router/router.go

@@ -12,7 +12,6 @@ import (
 // NewRouter 路由配置
 func NewRouter() *gin.Engine {
 	r := gin.New()
-
 	// 中间件, 顺序不能改
 	r.Use(middleware.Session(os.Getenv("SESSION_SECRET")))
 	r.Use(middleware.Cors())
@@ -30,17 +29,22 @@ func NewRouter() *gin.Engine {
 
 	webv1 := r.Group("/web/v1")
 	{
-		webv1.GET("versions", v1.ShowVersions)
-		webv1.POST("version", v1.VersionCreate)
-		webv1.PUT("version", v1.VersionEdit)
-		webv1.DELETE("version/:key", v1.VersionDelete)
-		webv1.POST("version/reload", v1.ReloadVersion)
+		webv1.POST("auth/login", v1.AdminLogin)
 
-		// 渠道管理
-		webv1.GET("channel/list", v1.ChannelList)
-		webv1.POST("channel", v1.ChannelCreate)
-		webv1.PUT("channel", v1.ChannelUpdate)
-		webv1.POST("channel/delete", v1.ChannelDelete)
+		authed := webv1.Group("")
+		authed.Use(middleware.AdminAccountRequired())
+		{
+			authed.PUT("auth/password", v1.AdminChangePassword)
+			authed.GET("versions", v1.ShowVersions)
+			authed.POST("version", v1.VersionCreate)
+			authed.PUT("version", v1.VersionEdit)
+			authed.DELETE("version/:key", v1.VersionDelete)
+			authed.POST("version/reload", v1.ReloadVersion)
+			authed.GET("channel/list", v1.ChannelList)
+			authed.POST("channel", v1.ChannelCreate)
+			authed.PUT("channel", v1.ChannelUpdate)
+			authed.POST("channel/delete", v1.ChannelDelete)
+		}
 	}
 
 	pointv1 := r.Group("/point/v1")
@@ -64,6 +68,7 @@ func NewRouter() *gin.Engine {
 
 	// TODO 内网请求,需要添加ip白名单等
 	internalR := r.Group("/internal")
+	internalR.Use(middleware.AdminAccountRequired())
 	{
 		internalR.POST("/addversion", hotupdate.AddVersion)
 		internalR.GET("/getmaxverinfo", hotupdate.GetMaxVerInfo)

+ 97 - 0
app/service/auth.go

@@ -0,0 +1,97 @@
+package service
+
+import (
+	"context"
+	"dsbqj-admin/model/mongo/admin"
+	"dsbqj-admin/pkg/util"
+	"errors"
+	"os"
+	"strings"
+	"time"
+
+	"github.com/kamva/mgm/v3"
+	"go.mongodb.org/mongo-driver/bson"
+	"golang.org/x/crypto/bcrypt"
+)
+
+const defaultAdminUsername = "admin"
+const defaultAdminPassword = "admin123"
+
+type AdminClaims struct {
+	Username  string `json:"username"`
+	ExpiresAt int64  `json:"expires_at"`
+}
+
+type LoginService struct {
+	Username string `json:"username" binding:"required"`
+	Password string `json:"password" binding:"required"`
+}
+
+type ChangePasswordService struct {
+	Username    string `json:"-"`
+	OldPassword string `json:"oldPassword" binding:"required"`
+	NewPassword string `json:"newPassword" binding:"required,min=6"`
+}
+
+func EnsureDefaultAdmin() error {
+	coll := mgm.Coll(&admin.Admin{})
+	count, err := coll.CountDocuments(context.Background(), bson.M{})
+	if err != nil || count > 0 {
+		return err
+	}
+	username := strings.TrimSpace(os.Getenv("ADMIN_USERNAME"))
+	password := os.Getenv("ADMIN_PASSWORD")
+	if username == "" {
+		username = defaultAdminUsername
+	}
+	if password == "" {
+		password = defaultAdminPassword
+	}
+	hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+	return coll.Create(&admin.Admin{Username: username, PasswordHash: string(hash)})
+}
+
+func (s *LoginService) Login() (map[string]interface{}, error) {
+	var account admin.Admin
+	err := mgm.Coll(&account).First(bson.M{"username": strings.TrimSpace(s.Username)}, &account)
+	if err != nil || bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(s.Password)) != nil {
+		return nil, errors.New("invalid username or password")
+	}
+	claims := AdminClaims{Username: account.Username, ExpiresAt: time.Now().Add(24 * time.Hour).Unix()}
+	token := util.GenerateToken(claims, util.AdminTokenSecret())
+	if token == "" {
+		return nil, errors.New("failed to generate login token")
+	}
+	return map[string]interface{}{
+		"token": token, "username": account.Username, "role": "admin",
+		"roleId": "admin", "permissions": []string{"*"},
+	}, nil
+}
+
+func (s *ChangePasswordService) Change() error {
+	if len(s.NewPassword) < 6 {
+		return errors.New("new password must contain at least 6 characters")
+	}
+	if s.OldPassword == s.NewPassword {
+		return errors.New("new password must differ from old password")
+	}
+	var account admin.Admin
+	coll := mgm.Coll(&account)
+	if err := coll.First(bson.M{"username": s.Username}, &account); err != nil {
+		return errors.New("account not found")
+	}
+	if bcrypt.CompareHashAndPassword([]byte(account.PasswordHash), []byte(s.OldPassword)) != nil {
+		return errors.New("old password is incorrect")
+	}
+	hash, err := bcrypt.GenerateFromPassword([]byte(s.NewPassword), bcrypt.DefaultCost)
+	if err != nil {
+		return err
+	}
+	_, err = coll.UpdateOne(context.Background(), bson.M{"_id": account.ID}, bson.M{
+		"$set": bson.M{"password_hash": string(hash), "updated_at": time.Now()},
+	})
+	return err
+}

+ 1 - 1
main-admin/build.bat

@@ -10,4 +10,4 @@ echo now the GOOS:
 echo now the GOARCH:
  go env GOARCH
 
-go build -o ../bin/admin-server main.go
+go build -o ../bin/version-server main.go

+ 3 - 0
main-admin/main.go

@@ -45,6 +45,9 @@ func main() {
 	// 连接数据库
 	//mysql.Database(os.Getenv("MYSQL_DSN"))
 	mongo.Database(os.Getenv("MONGO_DSN"))
+	if err := service.EnsureDefaultAdmin(); err != nil {
+		log.Fatal("init admin account failed: ", err)
+	}
 
 	// 启动任务
 	task.Run(task.VersionInit(), 5*time.Minute)

+ 1 - 1
main-subscribe/build.bat

@@ -10,4 +10,4 @@ echo now the GOOS:
 echo now the GOARCH:
  go env GOARCH
 
-go build -o ../bin/admin-subscribe main.go
+go build -o ../bin/subscribe-server main.go

+ 4 - 4
main-subscribe/main.go

@@ -4,16 +4,16 @@ import (
 	"dsbqj-admin/app/router"
 	"dsbqj-admin/app/task"
 	"dsbqj-admin/model/mongo"
-	"dsbqj-admin/pkg/cache"
 	"dsbqj-admin/pkg/logger"
 	"dsbqj-admin/pkg/validator"
 	"fmt"
-	"github.com/gin-gonic/gin"
-	"github.com/joho/godotenv"
 	"log"
 	"net/http"
 	"os"
 	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/joho/godotenv"
 )
 
 // @title           后端接口API文档
@@ -38,7 +38,7 @@ func main() {
 	}
 	validator.Init()
 	logger.Init("")
-	cache.Redis()
+	//cache.Redis()
 	// 连接数据库
 	//mysql.Database(os.Getenv("MYSQL_DSN"))
 	mongo.Database(os.Getenv("MONGO_DSN"))

+ 34 - 0
middleware/admin.go

@@ -0,0 +1,34 @@
+package middleware
+
+import (
+	"dsbqj-admin/pkg/serializer"
+	"dsbqj-admin/pkg/util"
+	"strings"
+	"time"
+
+	"github.com/gin-gonic/gin"
+	"github.com/goccy/go-json"
+)
+
+type adminAccountClaims struct {
+	Username  string `json:"username"`
+	ExpiresAt int64  `json:"expires_at"`
+}
+
+func AdminAccountRequired() gin.HandlerFunc {
+	return func(c *gin.Context) {
+		token := strings.TrimSpace(c.GetHeader("Authorization"))
+		if strings.HasPrefix(token, "Bearer ") {
+			token = strings.TrimSpace(strings.TrimPrefix(token, "Bearer "))
+		}
+		info := util.ParseToken(token, util.AdminTokenSecret())
+		var claims adminAccountClaims
+		if len(info) == 0 || json.Unmarshal(info, &claims) != nil || claims.Username == "" || claims.ExpiresAt <= time.Now().Unix() {
+			c.JSON(200, serializer.Response{Code: 401, Msg: "invalid or expired login"})
+			c.Abort()
+			return
+		}
+		c.Set("admin_username", claims.Username)
+		c.Next()
+	}
+}

+ 9 - 0
model/mongo/admin/admin.go

@@ -0,0 +1,9 @@
+package admin
+
+import "github.com/kamva/mgm/v3"
+
+type Admin struct {
+	mgm.DefaultModel `bson:",inline"`
+	Username         string `bson:"username" json:"username"`
+	PasswordHash     string `bson:"password_hash" json:"-"`
+}

+ 14 - 0
pkg/util/admin_token.go

@@ -0,0 +1,14 @@
+package util
+
+import "os"
+
+func AdminTokenSecret() string {
+	secret := os.Getenv("TOKEN_SECRET")
+	if secret == "" {
+		secret = "dsbqj-admin-key"
+	}
+	if len(secret) > 16 {
+		secret = secret[:16]
+	}
+	return secret
+}