feat: 接入两端用户名登录和独立学习空间 (#2)

This commit is contained in:
ila
2026-09-10 17:39:57 +08:00
parent 596543b0e2
commit 2c8e9b3ca0
82 changed files with 14148 additions and 30 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 go-admin-team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+33
View File
@@ -0,0 +1,33 @@
package models
import "go-admin/common/models"
type SysDept struct {
DeptId int `json:"deptId" gorm:"primaryKey;autoIncrement;"` //部门编码
ParentId int `json:"parentId" gorm:""` //上级部门
DeptPath string `json:"deptPath" gorm:"size:255;"` //
DeptName string `json:"deptName" gorm:"size:128;"` //部门名称
Sort int `json:"sort" gorm:"size:4;"` //排序
Leader string `json:"leader" gorm:"size:128;"` //负责人
Phone string `json:"phone" gorm:"size:11;"` //手机
Email string `json:"email" gorm:"size:64;"` //邮箱
Status int `json:"status" gorm:"size:4;"` //状态
models.ControlBy
models.ModelTime
DataScope string `json:"dataScope" gorm:"-"`
Params string `json:"params" gorm:"-"`
Children []SysDept `json:"children" gorm:"-"`
}
func (*SysDept) TableName() string {
return "sys_dept"
}
func (e *SysDept) Generate() models.ActiveRecord {
o := *e
return &o
}
func (e *SysDept) GetId() interface{} {
return e.DeptId
}
+77
View File
@@ -0,0 +1,77 @@
package models
import (
"go-admin/common/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
)
type SysUser struct {
UserId int `gorm:"primaryKey;autoIncrement;comment:编码" json:"userId"`
Username string `json:"username" gorm:"size:64;comment:用户名"`
Password string `json:"-" gorm:"size:128;comment:密码"`
NickName string `json:"nickName" gorm:"size:128;comment:昵称"`
Phone string `json:"phone" gorm:"size:11;comment:手机号"`
RoleId int `json:"roleId" gorm:"size:20;comment:角色ID"`
Salt string `json:"-" gorm:"size:255;comment:加盐"`
Avatar string `json:"avatar" gorm:"size:255;comment:头像"`
Sex string `json:"sex" gorm:"size:255;comment:性别"`
Email string `json:"email" gorm:"size:128;comment:邮箱"`
DeptId int `json:"deptId" gorm:"size:20;comment:部门"`
PostId int `json:"postId" gorm:"size:20;comment:岗位"`
Remark string `json:"remark" gorm:"size:255;comment:备注"`
Status string `json:"status" gorm:"size:4;comment:状态"`
DeptIds []int `json:"deptIds" gorm:"-"`
PostIds []int `json:"postIds" gorm:"-"`
RoleIds []int `json:"roleIds" gorm:"-"`
Dept *SysDept `json:"dept"`
models.ControlBy
models.ModelTime
}
func (*SysUser) TableName() string {
return "sys_user"
}
func (e *SysUser) Generate() models.ActiveRecord {
o := *e
return &o
}
func (e *SysUser) GetId() interface{} {
return e.UserId
}
// Encrypt 加密
func (e *SysUser) Encrypt() (err error) {
if e.Password == "" {
return
}
var hash []byte
if hash, err = bcrypt.GenerateFromPassword([]byte(e.Password), bcrypt.DefaultCost); err != nil {
return
} else {
e.Password = string(hash)
return
}
}
func (e *SysUser) BeforeCreate(_ *gorm.DB) error {
return e.Encrypt()
}
func (e *SysUser) BeforeUpdate(_ *gorm.DB) error {
var err error
if e.Password != "" {
err = e.Encrypt()
}
return err
}
func (e *SysUser) AfterFind(_ *gorm.DB) error {
e.DeptIds = []int{e.DeptId}
e.PostIds = []int{e.PostId}
e.RoleIds = []int{e.RoleId}
return nil
}
+112
View File
@@ -0,0 +1,112 @@
package lexgo
import (
"context"
"errors"
"fmt"
driver "github.com/go-sql-driver/mysql"
"gorm.io/gorm"
"strings"
"time"
)
// Migrate takes a connection-scoped lock. Only an empty or LexGo-owned schema is accepted.
func Migrate(db *gorm.DB) error {
sqlDB, err := db.DB()
if err != nil {
return err
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
conn, err := sqlDB.Conn(ctx)
if err != nil {
return err
}
defer conn.Close()
var name, version string
if err = conn.QueryRowContext(ctx, "SELECT DATABASE(), VERSION()").Scan(&name, &version); err != nil {
return err
}
if name == "" || !strings.HasPrefix(version, "8.") {
return errors.New("a dedicated MySQL 8 database is required")
}
var locked int
lockName := "lexgo-migration:" + name
if err = conn.QueryRowContext(ctx, "SELECT GET_LOCK(?,10)", lockName).Scan(&locked); err != nil || locked != 1 {
return errors.New("migration lock unavailable")
}
defer conn.ExecContext(context.Background(), "SELECT RELEASE_LOCK(?)", lockName)
var all, marker int
if err = conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=?", name).Scan(&all); err != nil {
return err
}
if err = conn.QueryRowContext(ctx, "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=? AND table_name='lexgo_schema'", name).Scan(&marker); err != nil {
return err
}
if all > 0 && marker == 0 {
return errors.New("refusing to migrate an existing non-LexGo schema")
}
if all == 0 {
if _, err = conn.ExecContext(ctx, `CREATE TABLE lexgo_schema (id INT PRIMARY KEY, version INT NOT NULL, product VARCHAR(32) NOT NULL)`); err != nil {
return err
}
if _, err = conn.ExecContext(ctx, `INSERT INTO lexgo_schema (id,version,product) VALUES (1,0,'lexgo')`); err != nil {
return err
}
}
var current int
var product string
if err = conn.QueryRowContext(ctx, "SELECT version,product FROM lexgo_schema WHERE id=1").Scan(&current, &product); err != nil {
return err
}
if product != "lexgo" || current < 0 || current > 1 {
return errors.New("unknown schema version")
}
if current == 1 {
return nil
}
statements := []string{
`CREATE TABLE IF NOT EXISTS sys_user (
user_id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL UNIQUE,
password VARCHAR(128) NOT NULL, nick_name VARCHAR(128) NOT NULL DEFAULT '', phone VARCHAR(11) NOT NULL DEFAULT '',
role_id INT NOT NULL, salt VARCHAR(255) NOT NULL DEFAULT '', avatar VARCHAR(255) NOT NULL DEFAULT '',
sex VARCHAR(255) NOT NULL DEFAULT '', email VARCHAR(128) NOT NULL DEFAULT '', dept_id BIGINT NOT NULL DEFAULT 0,
post_id BIGINT NOT NULL DEFAULT 0, remark VARCHAR(255) NOT NULL DEFAULT '', status VARCHAR(4) NOT NULL DEFAULT '2',
create_by BIGINT NOT NULL DEFAULT 0, update_by BIGINT NOT NULL DEFAULT 0,
created_at DATETIME(3) NULL, updated_at DATETIME(3) NULL, deleted_at DATETIME(3) NULL,
CHECK (role_id IN (1,2)), CHECK (status IN ('1','2'))
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS lexgo_spaces (
owner_id BIGINT PRIMARY KEY, language VARCHAR(16) NOT NULL DEFAULT 'en',
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
`CREATE TABLE IF NOT EXISTS lexgo_sessions (
token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin PRIMARY KEY,
owner_id BIGINT NOT NULL, expires_at DATETIME(3) NOT NULL,
INDEX (owner_id), INDEX (expires_at),
FOREIGN KEY (owner_id) REFERENCES sys_user(user_id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4`,
}
for i, s := range statements {
if _, err = conn.ExecContext(ctx, s); err != nil {
var sqlErr *driver.MySQLError
if errors.As(err, &sqlErr) {
return fmt.Errorf("migration statement %d failed (MySQL %d, SQLSTATE %s)", i+1, sqlErr.Number, sqlErr.SQLState)
}
return fmt.Errorf("migration statement %d failed", i+1)
}
}
_, err = conn.ExecContext(ctx, "UPDATE lexgo_schema SET version=1 WHERE id=1")
return err
}
func CheckSchema(db *gorm.DB) error {
var r struct {
Version int
Product string
}
if err := db.Table("lexgo_schema").Where("id=1").First(&r).Error; err != nil || r.Version != 1 || r.Product != "lexgo" {
return errors.New("run the explicit migration before starting")
}
return nil
}
+264
View File
@@ -0,0 +1,264 @@
package lexgo
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net"
"net/http/httptest"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
driver "github.com/go-sql-driver/mysql"
admin "go-admin/app/admin/models"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
const fixturePassword = "fixture-only-passphrase"
type envelope struct {
Code int `json:"code"`
Data json.RawMessage `json:"data"`
}
func testDB(t *testing.T) *gorm.DB {
t.Helper()
dsn := os.Getenv("LEXGO_TEST_DSN")
if dsn == "" && os.Getenv("LEXGO_TEST_DB_NAME") != "" {
cfg := driver.NewConfig()
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(os.Getenv("LEXGO_DB_HOST"), os.Getenv("LEXGO_DB_PORT"))
cfg.User = os.Getenv("LEXGO_DB_USER")
cfg.Passwd = os.Getenv("LEXGO_DB_PASSWORD")
cfg.DBName = os.Getenv("LEXGO_TEST_DB_NAME")
cfg.ParseTime = true
cfg.Loc = time.UTC
dsn = cfg.FormatDSN()
}
if dsn == "" {
t.Skip("set LEXGO_TEST_DSN to a dedicated lexgo_test_ database")
}
t.Setenv("LEXGO_TEST_DSN", dsn)
cfg, err := driver.ParseDSN(dsn)
if err != nil || !strings.HasPrefix(cfg.DBName, "lexgo_test_") {
t.Fatal("requires dedicated lexgo_test_ database")
}
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal("test database unavailable")
}
sqlDB, _ := db.DB()
t.Cleanup(func() { sqlDB.Close() })
if err := Migrate(db); err != nil {
t.Fatalf("migration: %v", err)
}
return db
}
func callAPI(t *testing.T, r *gin.Engine, method, path, token string, body any) (int, json.RawMessage) {
t.Helper()
b, _ := json.Marshal(body)
q := httptest.NewRequest(method, path, bytes.NewReader(b))
q.Header.Set("Content-Type", "application/json")
if token != "" {
q.Header.Set("Authorization", "Bearer "+token)
}
w := httptest.NewRecorder()
r.ServeHTTP(w, q)
var e envelope
if err := json.Unmarshal(w.Body.Bytes(), &e); err != nil {
t.Fatalf("invalid JSON for %s %s (status %d)", method, path, w.Code)
}
return w.Code, e.Data
}
func loginToken(t *testing.T, r *gin.Engine, username, password string) string {
t.Helper()
code, data := callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": username, "password": password})
if code != 200 {
t.Fatalf("login status %d", code)
}
var result struct{ Token string }
json.Unmarshal(data, &result)
if result.Token == "" {
t.Fatal("missing token")
}
return result.Token
}
func randomName(prefix string) string {
b := make([]byte, 6)
rand.Read(b)
return prefix + hex.EncodeToString(b)
}
func TestMySQLAccountIsolationAndRevocation(t *testing.T) {
db := testDB(t)
if err := Migrate(db); err != nil {
t.Fatal("second migration must be idempotent")
}
var version string
db.Raw("SELECT VERSION()").Scan(&version)
if !strings.HasPrefix(version, "8.") {
t.Fatal("integration test requires MySQL 8")
}
a := admin.SysUser{Username: randomName("admin"), Password: fixturePassword, RoleId: 1, Status: "2"}
if err := db.Create(&a).Error; err != nil {
t.Fatal("fixture admin creation failed")
}
clock := time.Now().UTC()
r := Router(db, func() time.Time { return clock })
adminToken := loginToken(t, r, a.Username, fixturePassword)
users := []string{randomName("learner"), randomName("learner")}
ids := []int{}
for _, name := range users {
code, data := callAPI(t, r, "POST", "/api/v1/accounts", adminToken, map[string]string{"username": name, "password": fixturePassword})
if code != 201 {
t.Fatalf("create learner status %d", code)
}
var u struct {
ID int
Role string
}
json.Unmarshal(data, &u)
if u.ID == 0 || u.Role != "learner" {
t.Fatal("created user must be learner")
}
ids = append(ids, u.ID)
}
tokenA := loginToken(t, r, strings.ToUpper(users[0]), fixturePassword)
tokenB := loginToken(t, r, users[1], fixturePassword)
for i, token := range []string{tokenA, tokenB} {
code, data := callAPI(t, r, "GET", "/api/v1/space", token, nil)
var s struct {
OwnerID int `json:"ownerId"`
Language string
}
json.Unmarshal(data, &s)
if code != 200 || s.OwnerID != ids[i] || s.Language != "en" {
t.Fatal("space belongs to authenticated user")
}
code, _ = callAPI(t, r, "GET", fmt.Sprintf("/api/v1/spaces/%d", ids[1-i]), token, nil)
if code != 404 {
t.Fatalf("other space status %d, want 404", code)
}
code, _ = callAPI(t, r, "GET", "/api/v1/accounts", token, nil)
if code != 403 {
t.Fatal("learner reached management")
}
code, _ = callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": randomName("bad"), "password": fixturePassword})
if code != 403 {
t.Fatal("learner created account")
}
}
code, _ := callAPI(t, r, "GET", fmt.Sprintf("/api/v1/space?ownerId=%d", ids[1]), tokenA, nil)
if code != 400 {
t.Fatal("owner override must be rejected")
}
code, _ = callAPI(t, r, "POST", "/api/v1/accounts", adminToken, map[string]string{"username": strings.ToUpper(users[0]), "password": fixturePassword})
if code != 409 {
t.Fatal("case duplicate must conflict")
}
code, _ = callAPI(t, r, "POST", "/api/v1/accounts", adminToken, map[string]string{"username": randomName("bad"), "password": fixturePassword, "role": "admin"})
if code != 400 {
t.Fatal("client role must be rejected")
}
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", a.UserId), adminToken, map[string]bool{"disabled": true})
if code != 400 {
t.Fatal("self disable must be rejected")
}
code, _ = callAPI(t, r, "POST", "/api/v1/logout", tokenA, nil)
if code != 200 {
t.Fatal("logout failed")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenA, nil)
if code != 401 {
t.Fatal("logged out token still valid")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil)
if code != 200 {
t.Fatal("A logout affected B")
}
tokenA = loginToken(t, r, users[0], fixturePassword)
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", ids[0]), adminToken, map[string]bool{"disabled": true})
if code != 200 {
t.Fatal("disable failed")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenA, nil)
if code != 401 {
t.Fatal("disabled session valid")
}
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", ids[0]), adminToken, map[string]bool{"disabled": false})
if code != 200 {
t.Fatal("enable failed")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenA, nil)
if code != 401 {
t.Fatal("reenable revived revoked session")
}
tokenA = loginToken(t, r, users[0], fixturePassword)
code, _ = callAPI(t, r, "PATCH", fmt.Sprintf("/api/v1/accounts/%d", ids[0]), adminToken, map[string]string{"password": "replacement-fixture-pass"})
if code != 200 {
t.Fatal("reset failed")
}
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenA, nil)
if code != 401 {
t.Fatal("reset session valid")
}
code, _ = callAPI(t, r, "POST", "/api/v1/login", "", map[string]string{"username": users[0], "password": fixturePassword})
if code != 401 {
t.Fatal("old password accepted")
}
loginToken(t, r, users[0], "replacement-fixture-pass")
clock = clock.Add(9 * time.Hour)
code, _ = callAPI(t, r, "GET", "/api/v1/me", tokenB, nil)
if code != 401 {
t.Fatal("expired session valid")
}
var count int64
db.Table("lexgo_sessions").Where("token_hash = ?", tokenB).Count(&count)
if count != 0 {
t.Fatal("raw token persisted")
}
}
func TestConcurrentDuplicateAccountHasOneWinner(t *testing.T) {
db := testDB(t)
a := admin.SysUser{Username: randomName("admin"), Password: fixturePassword, RoleId: 1, Status: "2"}
db.Create(&a)
r := Router(db, time.Now)
token := loginToken(t, r, a.Username, fixturePassword)
name := randomName("race")
var wg sync.WaitGroup
codes := make(chan int, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
c, _ := callAPI(t, r, "POST", "/api/v1/accounts", token, map[string]string{"username": name, "password": fixturePassword})
codes <- c
}()
}
wg.Wait()
close(codes)
wins, conflicts := 0, 0
for c := range codes {
if c == 201 {
wins++
}
if c == 409 {
conflicts++
}
}
if wins != 1 || conflicts != 1 {
t.Fatalf("wins=%d conflicts=%d", wins, conflicts)
}
}
+118
View File
@@ -0,0 +1,118 @@
package lexgo
import (
driver "github.com/go-sql-driver/mysql"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"os"
"strings"
"testing"
"time"
)
func TestMigrationCanResumeOwnedPartialSchema(t *testing.T) {
db := emptyMigrationDB(t)
for _, query := range []string{
"CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))",
"INSERT INTO lexgo_schema VALUES (1,0,'lexgo')",
"CREATE TABLE sys_user (user_id VARCHAR(32) PRIMARY KEY)",
} {
if err := db.Exec(query).Error; err != nil {
t.Fatal(err)
}
}
err := Migrate(db)
if err == nil || !strings.Contains(err.Error(), "MySQL") {
t.Fatal("DDL error must preserve a safe MySQL error code")
}
var version int
db.Raw("SELECT version FROM lexgo_schema WHERE id=1").Scan(&version)
if version != 0 {
t.Fatal("failed migration advanced version")
}
// This table belongs only to this test-created disposable schema.
if err = db.Exec("DROP TABLE sys_user").Error; err != nil {
t.Fatal(err)
}
if err = Migrate(db); err != nil {
t.Fatal(err)
}
if err = CheckSchema(db); err != nil {
t.Fatal(err)
}
}
func TestBootstrapDoesNotOverwriteExistingAccount(t *testing.T) {
db := emptyMigrationDB(t)
if err := Migrate(db); err != nil {
t.Fatal(err)
}
if err := BootstrapAdmin(db, "admin", fixturePassword); err != nil {
t.Fatal(err)
}
if err := BootstrapAdmin(db, "another_admin", "different-fixture-password"); err == nil {
t.Fatal("bootstrap accepted non-empty accounts")
}
var count int64
db.Table("sys_user").Count(&count)
if count != 1 {
t.Fatal("bootstrap changed existing account set")
}
loginToken(t, Router(db, time.Now), "admin", fixturePassword)
}
func emptyMigrationDB(t *testing.T) *gorm.DB {
t.Helper()
base := testDB(t)
cfg, err := driver.ParseDSN(os.Getenv("LEXGO_TEST_DSN"))
if err != nil {
t.Fatal(err)
}
name := randomName("lexgo_test_migration_")
if err = base.Exec("CREATE DATABASE `" + name + "` CHARACTER SET utf8mb4").Error; err != nil {
t.Fatal("create isolated migration test schema failed")
}
t.Cleanup(func() {
if err := base.Exec("DROP DATABASE `" + name + "`").Error; err != nil {
t.Error("cleanup isolated migration schema failed")
}
})
cfg.DBName = name
db, err := gorm.Open(mysql.Open(cfg.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal("connect migration test database failed")
}
pool, _ := db.DB()
t.Cleanup(func() { pool.Close() })
return db
}
func TestMigrationRefusesUnownedOrUnsupportedSchema(t *testing.T) {
for _, tc := range []struct{ name, marker string }{
{"empty_marker", ""},
{"negative_version", "INSERT INTO lexgo_schema VALUES (1,-1,'lexgo')"},
{"future_version", "INSERT INTO lexgo_schema VALUES (1,2,'lexgo')"},
{"wrong_product", "INSERT INTO lexgo_schema VALUES (1,0,'another-app')"},
} {
t.Run(tc.name, func(t *testing.T) {
db := emptyMigrationDB(t)
if err := db.Exec("CREATE TABLE lexgo_schema (id INT PRIMARY KEY,version INT,product VARCHAR(32))").Error; err != nil {
t.Fatal(err)
}
if tc.marker != "" {
if err := db.Exec(tc.marker).Error; err != nil {
t.Fatal(err)
}
}
if err := Migrate(db); err == nil {
t.Fatal("unsafe migration was accepted")
}
var count int64
db.Raw("SELECT COUNT(*) FROM information_schema.tables WHERE table_schema=DATABASE() AND table_name='sys_user'").Scan(&count)
if count != 0 {
t.Fatal("rejected migration created a user table")
}
})
}
}
+200
View File
@@ -0,0 +1,200 @@
package lexgo
import (
"encoding/json"
"errors"
"github.com/gin-gonic/gin"
admin "go-admin/app/admin/models"
"gorm.io/gorm"
"io"
"net/http"
"strconv"
"strings"
"sync"
"time"
)
func Router(db *gorm.DB, now func() time.Time) *gin.Engine {
r := gin.New()
r.Use(gin.Recovery())
r.SetTrustedProxies(nil)
r.Use(func(c *gin.Context) {
c.Header("Cache-Control", "no-store")
c.Header("X-Content-Type-Options", "nosniff")
c.Next()
})
r.GET("/healthz", func(c *gin.Context) {
sqlDB, err := db.DB()
if err != nil || sqlDB.PingContext(c.Request.Context()) != nil {
respond(c, 503, nil, failure(503, "服务暂不可用"))
return
}
respond(c, 200, map[string]string{"status": "ok"}, nil)
})
v := r.Group("/api/v1")
limits := &loginLimits{entries: map[string]attempts{}}
v.POST("/login", func(c *gin.Context) {
var input struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decode(c, &input); err != nil {
respond(c, 400, nil, err)
return
}
name, err := credentials(input.Username, input.Password)
if err != nil {
respond(c, 400, nil, err)
return
}
if !limits.allow(c.ClientIP(), now()) {
respond(c, 429, nil, failure(429, "登录尝试过多,请稍后再试"))
return
}
result, err := login(db.WithContext(c.Request.Context()), now(), name, input.Password)
respond(c, 200, result, err)
})
protect := func(adminOnly bool, action func(*gin.Context, *gorm.DB, admin.SysUser) (any, error)) gin.HandlerFunc {
return func(c *gin.Context) {
token, ok := strings.CutPrefix(c.GetHeader("Authorization"), "Bearer ")
if !ok {
respond(c, 401, nil, failure(401, "请先登录"))
return
}
var data any
err := db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
u, err := authenticate(tx, token, now())
if err != nil {
return err
}
if adminOnly && u.RoleId != 1 {
return failure(403, "需要管理员权限")
}
data, err = action(c, tx, u)
return err
})
status := 200
if c.Request.Method == "POST" && c.FullPath() == "/api/v1/accounts" {
status = 201
}
respond(c, status, data, err)
}
}
v.GET("/me", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) { return account(u), nil }))
v.POST("/logout", protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
token := strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer ")
return nil, tx.Where("token_hash = ? AND owner_id = ?", digest(token), u.UserId).Delete(&Session{}).Error
}))
space := protect(false, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
if c.Request.URL.RawQuery != "" {
return nil, failure(400, "学习空间不接受用户参数")
}
id := u.UserId
if c.Param("id") != "" {
n, err := strconv.Atoi(c.Param("id"))
if err != nil || n != u.UserId {
return nil, failure(404, "空间不存在")
}
id = n
}
var result Space
err := tx.Where("owner_id = ? AND owner_id = ?", u.UserId, id).First(&result).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, failure(404, "空间不存在")
}
return result, err
})
v.GET("/space", space)
v.GET("/spaces/:id", space)
v.GET("/accounts", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var users []admin.SysUser
if err := tx.Order("user_id DESC").Find(&users).Error; err != nil {
return nil, err
}
items := make([]Account, 0, len(users))
for _, user := range users {
items = append(items, account(user))
}
return gin.H{"items": items}, nil
}))
v.POST("/accounts", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
var input struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decode(c, &input); err != nil {
return nil, err
}
return createAccount(tx, input.Username, input.Password, 2, u.UserId)
}))
v.PATCH("/accounts/:id", protect(true, func(c *gin.Context, tx *gorm.DB, u admin.SysUser) (any, error) {
id, err := strconv.Atoi(c.Param("id"))
if err != nil || id <= 0 {
return nil, failure(400, "账号编号无效")
}
var input UpdateAccount
if err = decode(c, &input); err != nil {
return nil, err
}
return updateAccount(tx, id, u.UserId, input)
}))
r.NoRoute(func(c *gin.Context) { respond(c, 404, nil, failure(404, "页面或接口不存在")) })
return r
}
func decode(c *gin.Context, value any) error {
if !strings.HasPrefix(c.GetHeader("Content-Type"), "application/json") {
return failure(400, "请使用 JSON 请求")
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 16*1024)
d := json.NewDecoder(c.Request.Body)
d.DisallowUnknownFields()
if d.Decode(value) != nil {
return failure(400, "请求内容无效")
}
if d.Decode(new(any)) != io.EOF {
return failure(400, "请求内容无效")
}
return nil
}
func respond(c *gin.Context, status int, data any, err error) {
if err != nil {
var e *apiError
if errors.As(err, &e) {
c.JSON(e.status, gin.H{"code": e.status, "msg": e.message})
return
}
c.JSON(500, gin.H{"code": 500, "msg": "服务暂不可用,请稍后再试"})
return
}
c.JSON(status, gin.H{"code": 200, "data": data})
}
type attempts struct {
count int
until time.Time
}
type loginLimits struct {
mu sync.Mutex
entries map[string]attempts
}
func (l *loginLimits) allow(ip string, now time.Time) bool {
l.mu.Lock()
defer l.mu.Unlock()
for k, v := range l.entries {
if !v.until.After(now) {
delete(l.entries, k)
}
}
v, exists := l.entries[ip]
if !exists {
if len(l.entries) >= 10000 {
return false
}
v = attempts{until: now.Add(time.Minute)}
}
v.count++
l.entries[ip] = v
return v.count <= 30
}
+26
View File
@@ -0,0 +1,26 @@
package lexgo
import (
"bytes"
"net/http"
"net/http/httptest"
"testing"
"time"
)
func TestLoginRejectsEmailAndMissingPasswordBeforeDatabase(t *testing.T) {
r := Router(nil, time.Now)
for _, payload := range []string{
`{"username":"person@example.test","password":"example-password"}`,
`{"username":"learner","password":""}`,
`{"username":"learner","password":"short"}`,
} {
w := httptest.NewRecorder()
q := httptest.NewRequest(http.MethodPost, "/api/v1/login", bytes.NewBufferString(payload))
q.Header.Set("Content-Type", "application/json")
r.ServeHTTP(w, q)
if w.Code != http.StatusBadRequest {
t.Fatalf("invalid account input status = %d, want 400", w.Code)
}
}
}
+236
View File
@@ -0,0 +1,236 @@
package lexgo
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"errors"
"regexp"
"strings"
"time"
driver "github.com/go-sql-driver/mysql"
admin "go-admin/app/admin/models"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Account struct {
ID int `json:"id"`
Username string `json:"username"`
Role string `json:"role"`
Disabled bool `json:"disabled"`
}
func account(u admin.SysUser) Account {
role := "learner"
if u.RoleId == 1 {
role = "admin"
}
return Account{u.UserId, u.Username, role, u.Status != "2"}
}
type Space struct {
OwnerID int `json:"ownerId" gorm:"primaryKey"`
Language string `json:"language"`
}
func (Space) TableName() string { return "lexgo_spaces" }
type Session struct {
TokenHash string `gorm:"primaryKey"`
OwnerID int
ExpiresAt time.Time
}
func (Session) TableName() string { return "lexgo_sessions" }
type LoginResult struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expiresAt"`
User Account `json:"user"`
}
type apiError struct {
status int
message string
}
func (e *apiError) Error() string { return e.message }
func failure(code int, msg string) error { return &apiError{code, msg} }
var usernamePattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{2,31}$`)
func credentials(username, password string) (string, error) {
username = strings.ToLower(strings.TrimSpace(username))
if !usernamePattern.MatchString(username) {
return "", failure(400, "账号须为字母开头的 3~32 位字母、数字、点、下划线或连字符")
}
if len(password) < 10 || len(password) > 72 {
return "", failure(400, "密码须为 10~72 字节")
}
return username, nil
}
func digest(token string) string { v := sha256.Sum256([]byte(token)); return hex.EncodeToString(v[:]) }
func createAccount(tx *gorm.DB, username, password string, role, actor int) (Account, error) {
name, err := credentials(username, password)
if err != nil {
return Account{}, err
}
u := admin.SysUser{Username: name, Password: password, RoleId: role, Status: "2"}
u.CreateBy = actor
// Keep the upstream bcrypt BeforeCreate hook; never use BeforeUpdate for status changes.
if err = tx.Create(&u).Error; err != nil {
var dup *driver.MySQLError
if errors.As(err, &dup) && dup.Number == 1062 {
return Account{}, failure(409, "账号已存在")
}
return Account{}, err
}
if err = tx.Create(&Space{OwnerID: u.UserId, Language: "en"}).Error; err != nil {
return Account{}, err
}
return account(u), nil
}
// BootstrapAdmin only initializes a database with no accounts, under a database row lock.
func BootstrapAdmin(db *gorm.DB, username, password string) error {
if _, err := credentials(username, password); err != nil {
return err
}
return db.Transaction(func(tx *gorm.DB) error {
var marker struct{ ID int }
if err := tx.Table("lexgo_schema").Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=1").First(&marker).Error; err != nil {
return err
}
var count int64
if err := tx.Table("sys_user").Count(&count).Error; err != nil {
return err
}
if count > 0 {
return errors.New("bootstrap requires a database with no accounts; existing accounts were not changed")
}
_, err := createAccount(tx, username, password, 1, 0)
return err
})
}
var dummyHash, _ = bcrypt.GenerateFromPassword([]byte("non-account-timing-fixture"), bcrypt.DefaultCost)
func login(db *gorm.DB, now time.Time, name, password string) (LoginResult, error) {
var result LoginResult
err := db.Transaction(func(tx *gorm.DB) error {
var u admin.SysUser
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("username = ?", name).First(&u).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
return failure(401, "账号或密码错误")
}
if err != nil {
return err
}
if bcrypt.CompareHashAndPassword([]byte(u.Password), []byte(password)) != nil || u.Status != "2" {
return failure(401, "账号或密码错误")
}
b := make([]byte, 32)
if _, err = rand.Read(b); err != nil {
return err
}
token := hex.EncodeToString(b)
expiry := now.Add(8 * time.Hour)
if err = tx.Where("owner_id = ? AND expires_at <= ?", u.UserId, now).Delete(&Session{}).Error; err != nil {
return err
}
if err = tx.Create(&Session{digest(token), u.UserId, expiry}).Error; err != nil {
return err
}
if err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&Space{u.UserId, "en"}).Error; err != nil {
return err
}
result = LoginResult{token, expiry, account(u)}
return nil
})
return result, err
}
func authenticate(tx *gorm.DB, token string, now time.Time) (admin.SysUser, error) {
var u admin.SysUser
if len(token) != 64 {
return u, failure(401, "请重新登录")
}
var s Session
if err := tx.Where("token_hash = ?", digest(token)).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return u, failure(401, "请重新登录")
}
return u, err
}
// A consistent user-then-session lock order serializes disable/reset/logout with requests.
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, s.OwnerID).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return u, failure(401, "请重新登录")
}
return u, err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("token_hash = ?", digest(token)).First(&s).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return u, failure(401, "请重新登录")
}
return u, err
}
if u.Status != "2" || !s.ExpiresAt.After(now) {
return u, failure(401, "登录已失效,请重新登录")
}
return u, nil
}
type UpdateAccount struct {
Disabled *bool `json:"disabled"`
Password *string `json:"password"`
}
func updateAccount(tx *gorm.DB, id, actor int, input UpdateAccount) (Account, error) {
if id == actor {
return Account{}, failure(400, "不能在此修改自己的账号")
}
if input.Disabled == nil && input.Password == nil {
return Account{}, failure(400, "请选择要修改的内容")
}
var u admin.SysUser
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return Account{}, failure(404, "账号不存在")
}
return Account{}, err
}
if u.RoleId != 2 {
return Account{}, failure(400, "此操作仅支持学习账号")
}
updates := map[string]any{"update_by": actor, "updated_at": time.Now().UTC()}
if input.Disabled != nil {
u.Status = "2"
if *input.Disabled {
u.Status = "1"
}
updates["status"] = u.Status
}
if input.Password != nil {
if _, err := credentials(u.Username, *input.Password); err != nil {
return Account{}, err
}
hash, err := bcrypt.GenerateFromPassword([]byte(*input.Password), bcrypt.DefaultCost)
if err != nil {
return Account{}, err
}
updates["password"] = string(hash)
}
// Table + map deliberately avoids the upstream unconditional password re-hashing hook.
if err := tx.Table("sys_user").Where("user_id = ?", id).Updates(updates).Error; err != nil {
return Account{}, err
}
if err := tx.Where("owner_id = ?", id).Delete(&Session{}).Error; err != nil {
return Account{}, err
}
return account(u), nil
}
+125
View File
@@ -0,0 +1,125 @@
package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/gin-gonic/gin"
driver "github.com/go-sql-driver/mysql"
"go-admin/app/lexgo"
"gorm.io/driver/mysql"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func openDB() (*gorm.DB, error) {
c := driver.NewConfig()
c.Net = "tcp"
host := os.Getenv("LEXGO_DB_HOST")
if host == "" {
host = "127.0.0.1"
}
port := os.Getenv("LEXGO_DB_PORT")
if port == "" {
port = "3308"
}
c.Addr = net.JoinHostPort(host, port)
c.User = os.Getenv("LEXGO_DB_USER")
c.Passwd = os.Getenv("LEXGO_DB_PASSWORD")
c.DBName = os.Getenv("LEXGO_DB_NAME")
if c.User == "" || c.Passwd == "" || c.DBName == "" {
return nil, errors.New("set LEXGO_DB_USER, LEXGO_DB_PASSWORD and LEXGO_DB_NAME")
}
c.ParseTime = true
c.Loc = time.UTC
c.Timeout = 5 * time.Second
c.ReadTimeout = 10 * time.Second
c.WriteTimeout = 10 * time.Second
c.Params = map[string]string{"charset": "utf8mb4"}
db, err := gorm.Open(mysql.Open(c.FormatDSN()), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
return nil, errors.New("database connection failed; check local configuration")
}
pool, err := db.DB()
if err != nil {
return nil, err
}
pool.SetMaxOpenConns(10)
pool.SetMaxIdleConns(5)
pool.SetConnMaxLifetime(5 * time.Minute)
var version string
if err = db.Raw("SELECT VERSION()").Scan(&version).Error; err != nil || !strings.HasPrefix(version, "8.") {
pool.Close()
return nil, errors.New("MySQL 8 is required")
}
return db, nil
}
func run() error {
if len(os.Args) != 2 {
return errors.New("usage: lexgo migrate|bootstrap|serve")
}
command := os.Args[1]
if command != "migrate" && command != "bootstrap" && command != "serve" {
return errors.New("unknown command")
}
db, err := openDB()
if err != nil {
return err
}
pool, _ := db.DB()
defer pool.Close()
if command == "migrate" {
if err = lexgo.Migrate(db); err != nil {
return err
}
fmt.Println("LexGo schema version 1 ready")
return nil
}
if err = lexgo.CheckSchema(db); err != nil {
return err
}
if command == "bootstrap" {
if err = lexgo.BootstrapAdmin(db, os.Getenv("LEXGO_BOOTSTRAP_USERNAME"), os.Getenv("LEXGO_BOOTSTRAP_PASSWORD")); err != nil {
return err
}
fmt.Println("Initial administrator created; credentials are only in your local configuration")
return nil
}
gin.SetMode(gin.ReleaseMode)
addr := os.Getenv("LEXGO_LISTEN")
if addr == "" {
addr = "127.0.0.1:8000"
}
srv := &http.Server{Addr: addr, Handler: lexgo.Router(db, time.Now), ReadHeaderTimeout: 5 * time.Second, ReadTimeout: 15 * time.Second, WriteTimeout: 15 * time.Second, IdleTimeout: 60 * time.Second, MaxHeaderBytes: 1 << 20}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
done := make(chan error, 1)
go func() { done <- srv.ListenAndServe() }()
fmt.Println("LexGo API listening on", addr)
select {
case err = <-done:
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return err
case <-ctx.Done():
shutdown, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdown)
}
}
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
+32
View File
@@ -0,0 +1,32 @@
package models
import (
"time"
"gorm.io/gorm"
)
type ControlBy struct {
CreateBy int `json:"createBy" gorm:"index;comment:创建者"`
UpdateBy int `json:"updateBy" gorm:"index;comment:更新者"`
}
// SetCreateBy 设置创建人id
func (e *ControlBy) SetCreateBy(createBy int) {
e.CreateBy = createBy
}
// SetUpdateBy 设置修改人id
func (e *ControlBy) SetUpdateBy(updateBy int) {
e.UpdateBy = updateBy
}
type Model struct {
Id int `json:"id" gorm:"primaryKey;autoIncrement;comment:主键编码"`
}
type ModelTime struct {
CreatedAt time.Time `json:"createdAt" gorm:"comment:创建时间"`
UpdatedAt time.Time `json:"updatedAt" gorm:"comment:最后更新时间"`
DeletedAt gorm.DeletedAt `json:"-" gorm:"index;comment:删除时间"`
}
+12
View File
@@ -0,0 +1,12 @@
package models
import "time"
type Migration struct {
Version string `gorm:"primaryKey"`
ApplyTime time.Time `gorm:"autoCreateTime"`
}
func (Migration) TableName() string {
return "sys_migration"
}
+30
View File
@@ -0,0 +1,30 @@
package models
type Response struct {
// 代码
Code int `json:"code" example:"200"`
// 数据集
Data interface{} `json:"data"`
// 消息
Msg string `json:"msg"`
RequestId string `json:"requestId"`
}
type Page struct {
List interface{} `json:"list"`
Count int `json:"count"`
PageIndex int `json:"pageIndex"`
PageSize int `json:"pageSize"`
}
// ReturnOK 正常返回
func (res *Response) ReturnOK() *Response {
res.Code = 200
return res
}
// ReturnError 错误返回
func (res *Response) ReturnError(code int) *Response {
res.Code = code
return res
}
+11
View File
@@ -0,0 +1,11 @@
package models
import "gorm.io/gorm/schema"
type ActiveRecord interface {
schema.Tabler
SetCreateBy(createBy int)
SetUpdateBy(updateBy int)
Generate() ActiveRecord
GetId() interface{}
}
+45
View File
@@ -0,0 +1,45 @@
module go-admin
go 1.26.5
require (
github.com/gin-gonic/gin v1.12.0
github.com/go-sql-driver/mysql v1.9.3
golang.org/x/crypto v0.54.0
gorm.io/driver/mysql v1.6.0
gorm.io/gorm v1.31.2
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)
+105
View File
@@ -0,0 +1,105 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
+35
View File
@@ -0,0 +1,35 @@
{
"repository": "https://github.com/go-admin-team/go-admin.git",
"commit": "f06540883b41d03782bb6b2c4150f298f328c6b6",
"selection": "account models and common model conventions; LexGo routes and revocable sessions are new",
"files": [
{
"path": "LICENSE.md",
"sha256": "e1bd2eb01e4ddae9a0634b9d529f3a572e4772c40eeac2d37e7f9b8b19e77572"
},
{
"path": "app/admin/models/sys_user.go",
"sha256": "7cf93b6d6d9793977ab39245493280c4309febe0af2769b084357851aec3ada7"
},
{
"path": "app/admin/models/sys_dept.go",
"sha256": "e665ed13fd504b9125b5348d05dcb65d82aba1a07ea43d5ab943193919d34411"
},
{
"path": "common/models/by.go",
"sha256": "3f811f7f522ac0bfd8b7b89637f6030012b7523cf47ddb0e476db7c1c8cc4381"
},
{
"path": "common/models/type.go",
"sha256": "82c0d25d4bcc3bee2e2a383b720db122c88f91f9f19c012683eaf377f773db93"
},
{
"path": "common/models/migrate.go",
"sha256": "89da220cfc5d08ec9d28d752224a18247eab8f562de47e8f4ee5ce16fa5c6e59"
},
{
"path": "common/models/response.go",
"sha256": "b0276538b77bf936c9aa57344f33292801a7e0cd6da2999d53a7255b7e7fcf01"
}
]
}