parent
571f1b95fe
commit
bfdf04eda8
@ -0,0 +1,10 @@ |
|||||||
|
package build |
||||||
|
|
||||||
|
import "gorm.io/gorm" |
||||||
|
|
||||||
|
var db *gorm.DB |
||||||
|
|
||||||
|
func Init(gdb *gorm.DB) error { |
||||||
|
db = gdb |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,75 @@ |
|||||||
|
package entities |
||||||
|
|
||||||
|
import ( |
||||||
|
"devops/util/dts" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
// BuildStatus 构建状态
|
||||||
|
type BuildStatus string |
||||||
|
|
||||||
|
const ( |
||||||
|
BuildPending BuildStatus = "pending" // 等待运行
|
||||||
|
BuildRunning BuildStatus = "running" // 正在运行
|
||||||
|
BuildSuccess BuildStatus = "success" // 构建成功
|
||||||
|
BuildFailure BuildStatus = "failure" // 构建失败
|
||||||
|
BuildAborted BuildStatus = "aborted" // 构建中断
|
||||||
|
) |
||||||
|
|
||||||
|
// Build 项目构建
|
||||||
|
type Build struct { |
||||||
|
// 构建编号
|
||||||
|
ID string `json:"id" gorm:"primaryKey"` |
||||||
|
// 构建项目
|
||||||
|
ProjectID string `json:"projectId"` |
||||||
|
// 关联项目,如果项目删除也删除
|
||||||
|
Project *Project `json:"project,omitempty" gorm:"foreignKey:ProjectID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` |
||||||
|
// 使用的仓库分支名称
|
||||||
|
Branch string `json:"branch"` |
||||||
|
// 构建时,项目最后一次提交的 commit 标识
|
||||||
|
CommitId string `json:"commitId"` |
||||||
|
// 构建时,项目最后一次提交的 commit 信息
|
||||||
|
CommitText string `json:"commitText"` |
||||||
|
// 构建项目时的标准输出内容(不包含构建步骤的)
|
||||||
|
Stdout string `json:"stdout"` |
||||||
|
// 构建项目时的错误输出内容(不包含构建步骤的)
|
||||||
|
Stderr string `json:"startedError"` |
||||||
|
// 项目构建步骤
|
||||||
|
Steps []BuildStep `json:"steps"` |
||||||
|
// 启动构建程序的用户的编号,如果是通过 webhook 修改,会被置空
|
||||||
|
StartedBy dts.NullUint `json:"startedBy"` |
||||||
|
// 启动构建项目时的时间
|
||||||
|
StartedAt time.Time `json:"startedAt"` |
||||||
|
// 手动停止该项目构建的用户的编号,自动完成会将该值置空。
|
||||||
|
StoppedBy dts.NullUint `json:"stoppedBy"` |
||||||
|
// 手动停止或自动完成该项目构建时的时间
|
||||||
|
StoppedAt time.Time `json:"stoppedAt"` |
||||||
|
// 当前所处状态
|
||||||
|
Status BuildStatus `json:"status"` |
||||||
|
} |
||||||
|
|
||||||
|
// BuildStep 构建步骤日志
|
||||||
|
type BuildStep struct { |
||||||
|
// 日志编号
|
||||||
|
Id uint `json:"id" gorm:"primaryKey"` |
||||||
|
// 归属的构建编号
|
||||||
|
BuildID string `json:"buildId"` |
||||||
|
// 关联项目,如果项目删除也删除
|
||||||
|
Build *Build `json:"build,omitempty" gorm:"foreignKey:BuildID;references:ID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"` |
||||||
|
// 构建步骤名称
|
||||||
|
Name string `json:"name"` |
||||||
|
// 运行构建步骤的命令
|
||||||
|
Run string `json:"run"` |
||||||
|
// 运行构建步骤时的环境变量
|
||||||
|
Env map[string]string `json:"env" gorm:"serializer:json"` |
||||||
|
// 构建步骤的标准输出内容
|
||||||
|
Stdout string `json:"stdout"` |
||||||
|
// 构建步骤的错误输出内容
|
||||||
|
Stderr string `json:"stderr"` |
||||||
|
// 开始该步骤时的时间
|
||||||
|
StartedAt time.Time `json:"startedAt"` |
||||||
|
// 结束该步骤时的时间
|
||||||
|
StoppedAt time.Time `json:"stoppedAt"` |
||||||
|
// 当前所处状态
|
||||||
|
Status BuildStatus `json:"status"` |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
package entities |
||||||
|
|
||||||
|
import ( |
||||||
|
"encoding/gob" |
||||||
|
"log" |
||||||
|
|
||||||
|
"gorm.io/gorm" |
||||||
|
) |
||||||
|
|
||||||
|
func Init(db *gorm.DB) error { |
||||||
|
err := db.AutoMigrate( |
||||||
|
&User{}, |
||||||
|
&Project{}, |
||||||
|
&Build{}, |
||||||
|
&BuildStep{}, |
||||||
|
) |
||||||
|
if err != nil { |
||||||
|
log.Fatalln(err) |
||||||
|
} |
||||||
|
err = db.Model(&User{}).FirstOrCreate(&User{ |
||||||
|
ID: 1, |
||||||
|
Username: "admin", |
||||||
|
RawPassword: "111111", |
||||||
|
}, User{ID: 1}).Error |
||||||
|
if err != nil { |
||||||
|
log.Fatalln(err) |
||||||
|
} |
||||||
|
|
||||||
|
// 使用 github.com/gorilla/sessions 需要
|
||||||
|
gob.Register(&User{}) |
||||||
|
|
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
package entities |
||||||
|
|
||||||
|
import ( |
||||||
|
"devops/util/dts" |
||||||
|
"time" |
||||||
|
) |
||||||
|
|
||||||
|
// Project 项目
|
||||||
|
type Project struct { |
||||||
|
// 项目编号,推荐使用 ObjectId 算法生成,也可以使用 UUID/NanoID 等。
|
||||||
|
ID string `json:"id" gorm:"primaryKey"` |
||||||
|
// 项目名称
|
||||||
|
Name string `json:"name" gorm:"unique"` |
||||||
|
// 项目别称,显示使用
|
||||||
|
Alias string `json:"alias" gorm:"unique"` |
||||||
|
// 项目介绍
|
||||||
|
Intro string `json:"intro"` |
||||||
|
// 项目网站
|
||||||
|
Website string `json:"website"` |
||||||
|
// 项目的仓库地址,比如 GitHub、Gitee、Gitlab、Gitea 或自建的仓库,
|
||||||
|
// 如果是私有仓库,应该携带授权信息(建议使用私有令牌而不是账号密码)。
|
||||||
|
RepositoryURL string `json:"repositoryURL"` |
||||||
|
// 用于构建的仓库分支,若置空则使用主分支
|
||||||
|
Branch string `json:"branch"` |
||||||
|
// 创建项目的用户编号
|
||||||
|
CreatedBy uint `json:"createdBy"` |
||||||
|
// 创建项目时的时间
|
||||||
|
CreatedAt time.Time `json:"createdAt"` |
||||||
|
// 上一次修改项目信息的用户编号
|
||||||
|
UpdatedBy dts.NullUint `json:"updatedBy"` |
||||||
|
// 上一次修改项目信息的时间
|
||||||
|
UpdatedAt dts.NullTime `json:"updatedAt" gorm:"autoUpdateTime:false"` |
||||||
|
} |
@ -0,0 +1,32 @@ |
|||||||
|
package entities |
||||||
|
|
||||||
|
import ( |
||||||
|
"errors" |
||||||
|
"time" |
||||||
|
|
||||||
|
"gorm.io/gorm" |
||||||
|
"zestack.dev/misc" |
||||||
|
) |
||||||
|
|
||||||
|
// User 用户
|
||||||
|
type User struct { |
||||||
|
ID uint `json:"id" gorm:"primaryKey"` |
||||||
|
Username string `json:"username" gorm:"unique"` |
||||||
|
RawPassword string `json:"-" gorm:"-"` // 原始密码
|
||||||
|
Password string `json:"password"` |
||||||
|
Disabled bool `json:"disabled" gorm:"default:false"` |
||||||
|
CreatedAt time.Time `json:"createdAt"` |
||||||
|
UpdatedAt time.Time `json:"updatedAt"` |
||||||
|
} |
||||||
|
|
||||||
|
func (u *User) BeforeCreate(_ *gorm.DB) error { |
||||||
|
if u.RawPassword != "" { |
||||||
|
hash, err := misc.PasswordHash(u.RawPassword) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
u.Password = hash |
||||||
|
return nil |
||||||
|
} |
||||||
|
return errors.New("缺少密码") |
||||||
|
} |
@ -0,0 +1,24 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
type BuildConfig struct { |
||||||
|
Branch string `json:"branch" yaml:"branch"` |
||||||
|
Steps []StepConfig `json:"steps" yaml:"steps"` |
||||||
|
} |
||||||
|
|
||||||
|
type StepConfig struct { |
||||||
|
Name string `json:"name" yaml:"name"` |
||||||
|
Run string `json:"run" yaml:"run"` |
||||||
|
Env map[string]string `json:"env" yaml:"env"` |
||||||
|
} |
||||||
|
|
||||||
|
type VCSConfig struct { |
||||||
|
Branch string `json:"branch" yaml:"branch"` |
||||||
|
} |
||||||
|
|
||||||
|
func buildProject(c slim.Context) error { |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import "zestack.dev/slim" |
||||||
|
|
||||||
|
func deleteProject(c slim.Context) error { |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,52 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"devops/entities" |
||||||
|
"errors" |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"gorm.io/gorm" |
||||||
|
"zestack.dev/misc" |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
func handleLogin(c slim.Context) error { |
||||||
|
var req struct { |
||||||
|
Username string `json:"username" form:"username"` |
||||||
|
Password string `json:"password" form:"password"` |
||||||
|
} |
||||||
|
if err := c.Bind(&req); err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
if req.Username == "" { |
||||||
|
return c.Redirect(http.StatusMovedPermanently, "/login?error=用户名不能为空") |
||||||
|
} |
||||||
|
if req.Password == "" { |
||||||
|
return c.Redirect(http.StatusMovedPermanently, "/login?error=登录密码不能为空") |
||||||
|
} |
||||||
|
var user entities.User |
||||||
|
err := db. |
||||||
|
Model(&entities.User{}). |
||||||
|
Where("username", req.Username). |
||||||
|
First(&user). |
||||||
|
Error |
||||||
|
if err != nil { |
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) { |
||||||
|
return c.Redirect(http.StatusMovedPermanently, "/login?error=用户名或密码错误") |
||||||
|
} |
||||||
|
return err |
||||||
|
} |
||||||
|
if !misc.PasswordVerify(req.Password, user.Password) { |
||||||
|
return c.Redirect(http.StatusMovedPermanently, "/login?error=用户名或密码错误") |
||||||
|
} |
||||||
|
sess, err := store.Get(c.Request(), "session-key") |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
sess.Values["user"] = &user |
||||||
|
err = sess.Save(c.Request(), c.Response()) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
return c.Redirect(http.StatusMovedPermanently, "/") |
||||||
|
} |
@ -0,0 +1,117 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"bytes" |
||||||
|
"time" |
||||||
|
|
||||||
|
"github.com/gorilla/websocket" |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
const ( |
||||||
|
// Time allowed to write a message to the peer.
|
||||||
|
writeWait = 10 * time.Second |
||||||
|
|
||||||
|
// Time allowed to read the next pong message from the peer.
|
||||||
|
pongWait = 60 * time.Second |
||||||
|
|
||||||
|
// Send pings to peer with this period. Must be less than pongWait.
|
||||||
|
pingPeriod = (pongWait * 9) / 10 |
||||||
|
|
||||||
|
// Maximum message size allowed from peer.
|
||||||
|
maxMessageSize = 512 |
||||||
|
) |
||||||
|
|
||||||
|
func handleNotify(c slim.Context) error { |
||||||
|
conn, err := upgrader.Upgrade(c.Response(), c.Request(), nil) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
clients.Store(conn, struct{}{}) |
||||||
|
defer clients.Delete(conn) |
||||||
|
|
||||||
|
// TODO 使用 pool 实现复用
|
||||||
|
exit := make(chan error) |
||||||
|
defer close(exit) |
||||||
|
|
||||||
|
stop := func(err error) { |
||||||
|
select { |
||||||
|
case <-exit: |
||||||
|
default: |
||||||
|
exit <- err |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
go func() { |
||||||
|
conn.SetReadLimit(maxMessageSize) |
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWait)) |
||||||
|
conn.SetPongHandler(func(string) error { |
||||||
|
conn.SetReadDeadline(time.Now().Add(pongWait)) |
||||||
|
return nil |
||||||
|
}) |
||||||
|
for { |
||||||
|
_, message, ex := conn.ReadMessage() |
||||||
|
if ex != nil { |
||||||
|
if websocket.IsUnexpectedCloseError(ex, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { |
||||||
|
stop(ex) |
||||||
|
} else { |
||||||
|
stop(nil) |
||||||
|
} |
||||||
|
return |
||||||
|
} |
||||||
|
message = bytes.TrimSpace(bytes.Replace(message, newline, space, -1)) |
||||||
|
broadcast <- message |
||||||
|
} |
||||||
|
}() |
||||||
|
|
||||||
|
go func() { |
||||||
|
ticker := time.NewTicker(pingPeriod) |
||||||
|
defer func() { |
||||||
|
ticker.Stop() |
||||||
|
conn.Close() |
||||||
|
}() |
||||||
|
|
||||||
|
for { |
||||||
|
for { |
||||||
|
select { |
||||||
|
case message, ok := <-broadcast: |
||||||
|
conn.SetWriteDeadline(time.Now().Add(writeWait)) |
||||||
|
if !ok { |
||||||
|
// The hub closed the channel.
|
||||||
|
conn.WriteMessage(websocket.CloseMessage, []byte{}) |
||||||
|
stop(nil) |
||||||
|
return |
||||||
|
} |
||||||
|
|
||||||
|
w, err2 := conn.NextWriter(websocket.TextMessage) |
||||||
|
if err2 != nil { |
||||||
|
stop(err2) |
||||||
|
return |
||||||
|
} |
||||||
|
w.Write(message) |
||||||
|
|
||||||
|
// Add queued chat messages to the current websocket message.
|
||||||
|
n := len(broadcast) |
||||||
|
for i := 0; i < n; i++ { |
||||||
|
w.Write(newline) |
||||||
|
w.Write(<-broadcast) |
||||||
|
} |
||||||
|
|
||||||
|
if err3 := w.Close(); err3 != nil { |
||||||
|
stop(err3) |
||||||
|
return |
||||||
|
} |
||||||
|
case <-ticker.C: |
||||||
|
conn.SetWriteDeadline(time.Now().Add(writeWait)) |
||||||
|
if err4 := conn.WriteMessage(websocket.PingMessage, nil); err4 != nil { |
||||||
|
stop(err4) |
||||||
|
return |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
}() |
||||||
|
|
||||||
|
return <-exit |
||||||
|
} |
@ -0,0 +1,9 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
func handleNewProject(c slim.Context) error { |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"sync" |
||||||
|
"zestack.dev/env" |
||||||
|
|
||||||
|
"github.com/gorilla/sessions" |
||||||
|
"github.com/gorilla/websocket" |
||||||
|
"github.com/wader/gormstore/v2" |
||||||
|
"gorm.io/gorm" |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
var ( |
||||||
|
newline = []byte{'\n'} |
||||||
|
space = []byte{' '} |
||||||
|
|
||||||
|
upgrader *websocket.Upgrader |
||||||
|
clients *sync.Map |
||||||
|
broadcast chan []byte |
||||||
|
|
||||||
|
store sessions.Store |
||||||
|
db *gorm.DB |
||||||
|
) |
||||||
|
|
||||||
|
func Init(gdb *gorm.DB, s *slim.Slim) error { |
||||||
|
db = gdb |
||||||
|
upgrader = &websocket.Upgrader{} |
||||||
|
clients = new(sync.Map) |
||||||
|
broadcast = make(chan []byte) |
||||||
|
store = gormstore.NewOptions(db, gormstore.Options{ |
||||||
|
TableName: env.String("DB_TABLE_PREFIX", "do_") + "sessions", |
||||||
|
}, []byte("secret")) |
||||||
|
|
||||||
|
s.GET("/login", showLogin) |
||||||
|
s.POST("/login", handleLogin) |
||||||
|
s.Route("/", func(r slim.RouteCollector) { |
||||||
|
r.Use(authSession) |
||||||
|
r.GET("/", showHome) |
||||||
|
r.GET("/logout", handleLogout) |
||||||
|
r.GET("/notify", handleNotify) |
||||||
|
r.GET("/projects", listProjects) |
||||||
|
r.GET("/new-projects", showNewProject) |
||||||
|
r.POST("/new-projects", handleNewProject) |
||||||
|
r.GET("/projects/:id", showProject) |
||||||
|
r.DELETE("/projects/:id", deleteProject) |
||||||
|
r.PUT("/projects/:id", updateProject) |
||||||
|
r.POST("/build", buildProject) |
||||||
|
}) |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import "zestack.dev/slim" |
||||||
|
|
||||||
|
func listProjects(c slim.Context) error { |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
func handleLogout(c slim.Context) error { |
||||||
|
// Get a session. We're ignoring the error resulted from decoding an
|
||||||
|
// existing session: Get() always returns a session, even if empty.
|
||||||
|
session, _ := store.Get(c.Request(), "session-name") |
||||||
|
// Set some session values.
|
||||||
|
session.Values["foo"] = nil |
||||||
|
// Save it before we write to the response/return from the handler.
|
||||||
|
err := session.Save(c.Request(), c.Response()) |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
return c.Redirect(http.StatusMovedPermanently, "/") |
||||||
|
} |
@ -0,0 +1,11 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
|
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
func showHome(c slim.Context) error { |
||||||
|
return c.Render(http.StatusOK, "index.gohtml", "ws://"+c.Request().Host+"/notify") |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
func showLogin(c slim.Context) error { |
||||||
|
return c.Render(http.StatusOK, "login.gohtml", slim.Map{}) |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
func showNewProject(c slim.Context) error { |
||||||
|
return c.Render(http.StatusOK, "new_project.gohtml", slim.Map{}) |
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"net/http" |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
func showProject(c slim.Context) error { |
||||||
|
return c.Render(http.StatusOK, "project.gohtml", slim.Map{}) |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import "zestack.dev/slim" |
||||||
|
|
||||||
|
func updateProject(c slim.Context) error { |
||||||
|
return nil |
||||||
|
} |
@ -0,0 +1,20 @@ |
|||||||
|
package routes |
||||||
|
|
||||||
|
import ( |
||||||
|
"devops/entities" |
||||||
|
"net/http" |
||||||
|
"zestack.dev/slim" |
||||||
|
) |
||||||
|
|
||||||
|
func authSession(c slim.Context, next slim.HandlerFunc) error { |
||||||
|
sess, err := store.Get(c.Request(), "session-key") |
||||||
|
if err != nil { |
||||||
|
return err |
||||||
|
} |
||||||
|
user, ok := sess.Values["user"].(*entities.User) |
||||||
|
if !ok { |
||||||
|
return c.Redirect(http.StatusMovedPermanently, "/login") |
||||||
|
} |
||||||
|
c.Set("user", user) |
||||||
|
return next(c) |
||||||
|
} |
@ -0,0 +1,45 @@ |
|||||||
|
package dts |
||||||
|
|
||||||
|
import ( |
||||||
|
"database/sql" |
||||||
|
"database/sql/driver" |
||||||
|
"encoding/json" |
||||||
|
) |
||||||
|
|
||||||
|
type NullString sql.NullString |
||||||
|
|
||||||
|
// Scan implements the [Scanner] interface.
|
||||||
|
func (ns *NullString) Scan(value any) error { |
||||||
|
ss := new(sql.NullString) |
||||||
|
err := ss.Scan(value) |
||||||
|
ns.String = ss.String |
||||||
|
ns.Valid = ss.Valid |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
// Value implements the [driver.Valuer] interface.
|
||||||
|
func (ns NullString) Value() (driver.Value, error) { |
||||||
|
if !ns.Valid { |
||||||
|
return nil, nil |
||||||
|
} |
||||||
|
return ns.String, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (ns NullString) MarshalJSON() ([]byte, error) { |
||||||
|
if ns.Valid { |
||||||
|
return json.Marshal(ns.String) |
||||||
|
} |
||||||
|
return json.Marshal(nil) |
||||||
|
} |
||||||
|
|
||||||
|
func (ns *NullString) UnmarshalJSON(b []byte) error { |
||||||
|
if string(b) == "null" { |
||||||
|
ns.Valid = false |
||||||
|
return nil |
||||||
|
} |
||||||
|
err := json.Unmarshal(b, &ns.String) |
||||||
|
if err == nil { |
||||||
|
ns.Valid = true |
||||||
|
} |
||||||
|
return err |
||||||
|
} |
@ -0,0 +1,43 @@ |
|||||||
|
package dts |
||||||
|
|
||||||
|
import ( |
||||||
|
"database/sql" |
||||||
|
"database/sql/driver" |
||||||
|
"encoding/json" |
||||||
|
) |
||||||
|
|
||||||
|
type NullTime sql.NullTime |
||||||
|
|
||||||
|
func (nt *NullTime) Scan(value any) error { |
||||||
|
st := new(sql.NullTime) |
||||||
|
err := st.Scan(value) |
||||||
|
nt.Time = st.Time |
||||||
|
nt.Valid = st.Valid |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
func (nt NullTime) Value() (driver.Value, error) { |
||||||
|
if !nt.Valid { |
||||||
|
return nil, nil |
||||||
|
} |
||||||
|
return nt.Time, nil |
||||||
|
} |
||||||
|
|
||||||
|
func (nt NullTime) MarshalJSON() ([]byte, error) { |
||||||
|
if nt.Valid { |
||||||
|
return nt.Time.MarshalJSON() |
||||||
|
} |
||||||
|
return json.Marshal(nil) |
||||||
|
} |
||||||
|
|
||||||
|
func (nt *NullTime) UnmarshalJSON(b []byte) error { |
||||||
|
if string(b) == "null" { |
||||||
|
nt.Valid = false |
||||||
|
return nil |
||||||
|
} |
||||||
|
err := json.Unmarshal(b, &nt.Time) |
||||||
|
if err == nil { |
||||||
|
nt.Valid = true |
||||||
|
} |
||||||
|
return err |
||||||
|
} |
@ -0,0 +1,47 @@ |
|||||||
|
package dts |
||||||
|
|
||||||
|
import ( |
||||||
|
"database/sql" |
||||||
|
"database/sql/driver" |
||||||
|
"encoding/json" |
||||||
|
) |
||||||
|
|
||||||
|
type NullUint struct { |
||||||
|
V uint |
||||||
|
Valid bool |
||||||
|
} |
||||||
|
|
||||||
|
func (nu *NullUint) Scan(value any) error { |
||||||
|
sn := new(sql.Null[uint]) |
||||||
|
err := sn.Scan(value) |
||||||
|
nu.V = sn.V |
||||||
|
nu.Valid = sn.Valid |
||||||
|
return err |
||||||
|
} |
||||||
|
|
||||||
|
func (nu NullUint) Value() (driver.Value, error) { |
||||||
|
if !nu.Valid { |
||||||
|
return nil, nil |
||||||
|
} |
||||||
|
// 注意:driver.Value 不支持 uint
|
||||||
|
return int64(nu.V), nil |
||||||
|
} |
||||||
|
|
||||||
|
func (nu NullUint) MarshalJSON() ([]byte, error) { |
||||||
|
if nu.Valid { |
||||||
|
return json.Marshal(nu.V) |
||||||
|
} |
||||||
|
return json.Marshal(nil) |
||||||
|
} |
||||||
|
|
||||||
|
func (nu *NullUint) UnmarshalJSON(b []byte) error { |
||||||
|
if string(b) == "null" { |
||||||
|
nu.Valid = false |
||||||
|
return nil |
||||||
|
} |
||||||
|
err := json.Unmarshal(b, &nu.V) |
||||||
|
if err == nil { |
||||||
|
nu.Valid = true |
||||||
|
} |
||||||
|
return err |
||||||
|
} |
@ -0,0 +1,24 @@ |
|||||||
|
<!doctype html> |
||||||
|
<html lang="zh-Hans"> |
||||||
|
|
||||||
|
<head> |
||||||
|
<meta charset="UTF-8"> |
||||||
|
<meta name="viewport" content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> |
||||||
|
<meta http-equiv="X-UA-Compatible" content="ie=edge"> |
||||||
|
<title>{{block "title" .}}{{end}}</title> |
||||||
|
{{block "head" .}}{{end}} |
||||||
|
</head> |
||||||
|
|
||||||
|
<body> |
||||||
|
<header> |
||||||
|
<div> |
||||||
|
<div>Overview</div> |
||||||
|
<div>Projects</div> |
||||||
|
<div>Settings</div> |
||||||
|
<div>Docs</div> |
||||||
|
</div> |
||||||
|
</header> |
||||||
|
{{block "content" .}}{{end}} |
||||||
|
</body> |
||||||
|
|
||||||
|
</html> |
Loading…
Reference in new issue