refactor: operate log and so on
This commit is contained in:
parent
f57d7bd8b7
commit
a18fa887cf
|
|
@ -69,6 +69,14 @@ func (a *User) List(ctx *gin.Context) {
|
|||
ctx.JSON(http.StatusOK, response.NewListRsp(count, res))
|
||||
}
|
||||
|
||||
func (a *User) Captcha(ctx *gin.Context) {
|
||||
id, image := new(service.User).Captcha()
|
||||
ctx.JSON(http.StatusOK, response.NewRsp(map[string]any{
|
||||
"id": id,
|
||||
"image": image,
|
||||
}))
|
||||
}
|
||||
|
||||
func (a *User) Login(ctx *gin.Context) {
|
||||
var req request.UserLogin
|
||||
if err := ctx.ShouldBind(&req); err != nil {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
"yintai-api/base/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func LoginLimit() gin.HandlerFunc {
|
||||
const maxPerSecond = 2
|
||||
var (
|
||||
mu sync.Mutex
|
||||
hits = map[string][]time.Time{}
|
||||
)
|
||||
return func(c *gin.Context) {
|
||||
ip := c.ClientIP()
|
||||
now := time.Now()
|
||||
cutoff := now.Add(-time.Second)
|
||||
|
||||
mu.Lock()
|
||||
recent := make([]time.Time, 0, maxPerSecond)
|
||||
for _, t := range hits[ip] {
|
||||
if t.After(cutoff) {
|
||||
recent = append(recent, t)
|
||||
}
|
||||
}
|
||||
if len(recent) >= maxPerSecond {
|
||||
hits[ip] = recent
|
||||
mu.Unlock()
|
||||
panic(config.ErrLimit.New())
|
||||
}
|
||||
hits[ip] = append(recent, now)
|
||||
if len(hits) > 1024 {
|
||||
for k, v := range hits {
|
||||
alive := false
|
||||
for _, t := range v {
|
||||
if t.After(cutoff) {
|
||||
alive = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !alive {
|
||||
delete(hits, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
mu.Unlock()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,400 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
"yintai-api/app/common/cache"
|
||||
"yintai-api/app/common/dao"
|
||||
"yintai-api/app/common/model"
|
||||
"yintai-api/base/global"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
log "github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
const (
|
||||
operateBodyLimit = 8000
|
||||
operateQueryLimit = 1024
|
||||
operateHeaderLimit = 8000
|
||||
operateMessageLimit = 512
|
||||
operateAccountLimit = 64
|
||||
operateForwardLimit = 512
|
||||
operatePathLimit = 255
|
||||
operateActionLimit = 64
|
||||
)
|
||||
|
||||
// 写操作、登录、退出、获取验证码,以及所有失败请求都记。成功的普通查询不记。
|
||||
var operateActions = map[string]string{
|
||||
"POST /yt/user/login": "登录",
|
||||
"GET /yt/user/logout": "退出",
|
||||
"GET /yt/user/captcha": "获取验证码",
|
||||
"POST /yt/user": "创建用户",
|
||||
"PUT /yt/user": "修改用户",
|
||||
"DELETE /yt/user": "删除用户",
|
||||
"GET /yt/user": "查询用户",
|
||||
"POST /yt/resource": "创建资源",
|
||||
"PUT /yt/resource": "修改资源",
|
||||
"DELETE /yt/resource": "删除资源",
|
||||
"GET /yt/resource": "查询资源",
|
||||
"POST /yt/role": "创建角色",
|
||||
"PUT /yt/role": "修改角色",
|
||||
"DELETE /yt/role": "删除角色",
|
||||
"GET /yt/role": "查询角色",
|
||||
"GET /yt/role/:id": "查询角色",
|
||||
"GET /yt/role/account/:id": "查询角色账号",
|
||||
"GET /yt/translate": "翻译",
|
||||
"POST /yt/category": "创建分类",
|
||||
"PUT /yt/category": "修改分类",
|
||||
"DELETE /yt/category": "删除分类",
|
||||
"GET /yt/category": "查询分类",
|
||||
"POST /yt/process": "创建历程",
|
||||
"PUT /yt/process": "修改历程",
|
||||
"DELETE /yt/process": "删除历程",
|
||||
"GET /yt/process": "查询历程",
|
||||
"GET /yt/process/:id": "查询历程",
|
||||
"POST /yt/news": "创建新闻",
|
||||
"PUT /yt/news": "修改新闻",
|
||||
"DELETE /yt/news": "删除新闻",
|
||||
"GET /yt/news": "查询新闻",
|
||||
"GET /yt/news/:id": "查询新闻",
|
||||
"POST /yt/job": "创建招聘",
|
||||
"PUT /yt/job": "修改招聘",
|
||||
"DELETE /yt/job": "删除招聘",
|
||||
"GET /yt/job": "查询招聘",
|
||||
"GET /yt/job/:id": "查询招聘",
|
||||
"POST /yt/doc": "创建文档",
|
||||
"PUT /yt/doc": "修改文档",
|
||||
"DELETE /yt/doc": "删除文档",
|
||||
"GET /yt/doc": "查询文档",
|
||||
"GET /yt/doc/:id": "查询文档",
|
||||
"POST /yt/page": "创建页面",
|
||||
"PUT /yt/page": "修改页面",
|
||||
"DELETE /yt/page": "删除页面",
|
||||
"GET /yt/page": "查询页面",
|
||||
"POST /yt/upload": "上传文件",
|
||||
"GET /yt/upload/token": "获取上传凭证",
|
||||
}
|
||||
|
||||
var saveOperateLog = func(row *model.AdminOperateLog) error {
|
||||
if global.GetDB() == nil {
|
||||
return errDBNotReady
|
||||
}
|
||||
return dao.NewAdminOperateLog().Create(row)
|
||||
}
|
||||
|
||||
var loadOperateUser = func(token string) *model.UserWithRole {
|
||||
token = strings.TrimSpace(token)
|
||||
if token == "" {
|
||||
return nil
|
||||
}
|
||||
client := global.GetRedis()
|
||||
if client == nil {
|
||||
return nil
|
||||
}
|
||||
val := client.Get(cache.NewKeys().User() + token).Val()
|
||||
if val == "" {
|
||||
return nil
|
||||
}
|
||||
var user model.UserWithRole
|
||||
if err := json.Unmarshal([]byte(val), &user); err != nil {
|
||||
return nil
|
||||
}
|
||||
return &user
|
||||
}
|
||||
|
||||
type operateDBError string
|
||||
|
||||
func (e operateDBError) Error() string { return string(e) }
|
||||
|
||||
const errDBNotReady operateDBError = "admin operate log: db not ready"
|
||||
|
||||
type bodyWriter struct {
|
||||
gin.ResponseWriter
|
||||
buf bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *bodyWriter) Write(b []byte) (int, error) {
|
||||
w.buf.Write(b)
|
||||
return w.ResponseWriter.Write(b)
|
||||
}
|
||||
|
||||
func (w *bodyWriter) WriteString(s string) (int, error) {
|
||||
w.buf.WriteString(s)
|
||||
return w.ResponseWriter.WriteString(s)
|
||||
}
|
||||
|
||||
// OperateLog 必须注册在 Recovery 之前。业务错误是 panic 抛出的,
|
||||
// Recovery 接住并写完响应之后,这里才能把失败也记下来。
|
||||
func OperateLog() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
reqBody := readOperateBody(c)
|
||||
user := loadOperateUser(c.GetHeader("token"))
|
||||
writer := &bodyWriter{ResponseWriter: c.Writer}
|
||||
c.Writer = writer
|
||||
c.Next()
|
||||
|
||||
resp := writer.buf.Bytes()
|
||||
if user == nil && c.FullPath() == "/yt/user/login" {
|
||||
if code, _, ok := parseOperateResult(resp); ok && code == 0 {
|
||||
if token := loginToken(resp); token != "" {
|
||||
if loaded := loadOperateUser(token); loaded != nil {
|
||||
user = loaded
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
row := collectOperateLog(c, start, reqBody, resp, user)
|
||||
if row == nil {
|
||||
return
|
||||
}
|
||||
if err := saveOperateLog(row); err != nil {
|
||||
log.Errorf("admin operate log %s %s: %v", row.Method, row.Path, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectOperateLog(c *gin.Context, start time.Time, reqBody, resp []byte, user *model.UserWithRole) *model.AdminOperateLog {
|
||||
route := c.FullPath()
|
||||
if route == "" {
|
||||
route = c.Request.URL.Path
|
||||
}
|
||||
code, message, _ := parseOperateResult(resp)
|
||||
success := 0
|
||||
if code == 0 {
|
||||
success = 1
|
||||
}
|
||||
if !shouldRecord(c.Request.Method, route, success == 1) {
|
||||
return nil
|
||||
}
|
||||
|
||||
account := ""
|
||||
var userID, roleID int64
|
||||
if user != nil {
|
||||
account = user.Account
|
||||
userID = user.ID
|
||||
roleID = user.RoleID
|
||||
} else {
|
||||
account = accountFromBody(reqBody)
|
||||
}
|
||||
|
||||
cost := int(time.Since(start).Milliseconds())
|
||||
if cost < 0 {
|
||||
cost = 0
|
||||
}
|
||||
return &model.AdminOperateLog{
|
||||
Account: cut(strings.TrimSpace(account), operateAccountLimit),
|
||||
UserID: userID,
|
||||
RoleID: roleID,
|
||||
CreateTime: start.Unix(),
|
||||
CostMs: cost,
|
||||
IP: cut(c.ClientIP(), 64),
|
||||
ForwardedFor: cut(c.GetHeader("X-Forwarded-For"), operateForwardLimit),
|
||||
Method: cut(c.Request.Method, 16),
|
||||
Path: cut(c.Request.URL.Path, operatePathLimit),
|
||||
Query: cut(c.Request.URL.RawQuery, operateQueryLimit),
|
||||
Request: requestText(c, reqBody),
|
||||
Action: cut(operateAction(c.Request.Method, route), operateActionLimit),
|
||||
Code: code,
|
||||
Message: cut(message, operateMessageLimit),
|
||||
Response: responseText(route, resp),
|
||||
Success: success,
|
||||
Header: headerText(c.Request.Header),
|
||||
}
|
||||
}
|
||||
|
||||
func shouldRecord(method, route string, success bool) bool {
|
||||
if !success {
|
||||
return true
|
||||
}
|
||||
switch method {
|
||||
case http.MethodPost, http.MethodPut, http.MethodDelete:
|
||||
return true
|
||||
}
|
||||
return route == "/yt/user/logout" || route == "/yt/user/captcha"
|
||||
}
|
||||
|
||||
func operateAction(method, route string) string {
|
||||
if name, ok := operateActions[method+" "+route]; ok {
|
||||
return name
|
||||
}
|
||||
return method + " " + route
|
||||
}
|
||||
|
||||
func readOperateBody(c *gin.Context) []byte {
|
||||
if strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "multipart/") {
|
||||
return nil
|
||||
}
|
||||
if c.Request.Body == nil {
|
||||
return nil
|
||||
}
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
_ = c.Request.Body.Close()
|
||||
if err != nil {
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(nil))
|
||||
return nil
|
||||
}
|
||||
c.Request.Body = io.NopCloser(bytes.NewReader(body))
|
||||
return body
|
||||
}
|
||||
|
||||
func requestText(c *gin.Context, body []byte) string {
|
||||
if strings.HasPrefix(strings.ToLower(c.GetHeader("Content-Type")), "multipart/") {
|
||||
return cut(multipartRequest(c), operateBodyLimit)
|
||||
}
|
||||
return maskAndCut(body, false)
|
||||
}
|
||||
|
||||
func responseText(route string, body []byte) string {
|
||||
return maskAndCut(body, route == "/yt/user/captcha")
|
||||
}
|
||||
|
||||
func multipartRequest(c *gin.Context) string {
|
||||
form := c.Request.MultipartForm
|
||||
if form == nil {
|
||||
return ""
|
||||
}
|
||||
fields := make(map[string][]string, len(form.Value))
|
||||
for key, values := range form.Value {
|
||||
copied := append([]string(nil), values...)
|
||||
if strings.EqualFold(key, "token") {
|
||||
for i := range copied {
|
||||
copied[i] = maskToken(copied[i])
|
||||
}
|
||||
}
|
||||
fields[key] = copied
|
||||
}
|
||||
files := make(map[string][]map[string]any, len(form.File))
|
||||
for key, headers := range form.File {
|
||||
items := make([]map[string]any, 0, len(headers))
|
||||
for _, header := range headers {
|
||||
items = append(items, map[string]any{
|
||||
"filename": header.Filename,
|
||||
"size": header.Size,
|
||||
})
|
||||
}
|
||||
files[key] = items
|
||||
}
|
||||
raw, err := json.Marshal(map[string]any{"fields": fields, "files": files})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func parseOperateResult(body []byte) (int, string, bool) {
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return -1, "", false
|
||||
}
|
||||
var rsp struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &rsp); err != nil {
|
||||
return -1, "", false
|
||||
}
|
||||
return rsp.Code, rsp.Message, true
|
||||
}
|
||||
|
||||
func accountFromBody(body []byte) string {
|
||||
var req struct {
|
||||
Account string `json:"account"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &req); err != nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(req.Account)
|
||||
}
|
||||
|
||||
func loginToken(body []byte) string {
|
||||
var rsp struct {
|
||||
Data struct {
|
||||
Token string `json:"token"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &rsp); err != nil {
|
||||
return ""
|
||||
}
|
||||
return rsp.Data.Token
|
||||
}
|
||||
|
||||
func maskAndCut(body []byte, omitImage bool) string {
|
||||
if len(bytes.TrimSpace(body)) == 0 {
|
||||
return ""
|
||||
}
|
||||
var value any
|
||||
if err := json.Unmarshal(body, &value); err != nil {
|
||||
return cut(string(body), operateBodyLimit)
|
||||
}
|
||||
maskJSON(value, omitImage)
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
return cut(string(body), operateBodyLimit)
|
||||
}
|
||||
return cut(string(raw), operateBodyLimit)
|
||||
}
|
||||
|
||||
func maskJSON(value any, omitImage bool) {
|
||||
switch node := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range node {
|
||||
switch {
|
||||
case strings.EqualFold(key, "token"):
|
||||
node[key] = maskToken(asString(item))
|
||||
case omitImage && strings.EqualFold(key, "image"):
|
||||
node[key] = "[omitted]"
|
||||
default:
|
||||
maskJSON(item, omitImage)
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, item := range node {
|
||||
maskJSON(item, omitImage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func headerText(header http.Header) string {
|
||||
copied := make(map[string]string, len(header))
|
||||
for key, values := range header {
|
||||
val := strings.Join(values, ",")
|
||||
switch strings.ToLower(key) {
|
||||
case "token", "cookie", "authorization", "set-cookie":
|
||||
val = maskToken(val)
|
||||
}
|
||||
copied[key] = val
|
||||
}
|
||||
raw, err := json.Marshal(copied)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return cut(string(raw), operateHeaderLimit)
|
||||
}
|
||||
|
||||
func maskToken(token string) string {
|
||||
if len(token) <= 12 {
|
||||
return "****"
|
||||
}
|
||||
return token[:4] + "****" + token[len(token)-4:]
|
||||
}
|
||||
|
||||
func asString(value any) string {
|
||||
text, _ := value.(string)
|
||||
return text
|
||||
}
|
||||
|
||||
func cut(s string, n int) string {
|
||||
if len(s) <= n {
|
||||
return s
|
||||
}
|
||||
b := []byte(s[:n])
|
||||
for len(b) > 0 && !utf8.Valid(b) {
|
||||
b = b[:len(b)-1]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
|
@ -0,0 +1,358 @@
|
|||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
commonmw "yintai-api/app/common/middleware"
|
||||
"yintai-api/app/common/model"
|
||||
"yintai-api/base/config"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func TestMain(m *testing.M) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
m.Run()
|
||||
}
|
||||
|
||||
type savedLog struct {
|
||||
row *model.AdminOperateLog
|
||||
}
|
||||
|
||||
func captureLog(t *testing.T) *savedLog {
|
||||
t.Helper()
|
||||
got := &savedLog{}
|
||||
oldSave := saveOperateLog
|
||||
oldLoad := loadOperateUser
|
||||
saveOperateLog = func(row *model.AdminOperateLog) error {
|
||||
got.row = row
|
||||
return nil
|
||||
}
|
||||
loadOperateUser = func(token string) *model.UserWithRole {
|
||||
if token == "good-token-value" {
|
||||
return &model.UserWithRole{
|
||||
User: model.User{ID: 7, Account: "editor"},
|
||||
RoleID: 3,
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
saveOperateLog = oldSave
|
||||
loadOperateUser = oldLoad
|
||||
})
|
||||
return got
|
||||
}
|
||||
|
||||
func perform(handler gin.HandlerFunc, req *http.Request) *httptest.ResponseRecorder {
|
||||
engine := gin.New()
|
||||
engine.Use(OperateLog(), commonmw.Recovery)
|
||||
engine.Handle(req.Method, req.URL.Path, handler)
|
||||
recorder := httptest.NewRecorder()
|
||||
engine.ServeHTTP(recorder, req)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func TestOperateLogSkipsSuccessfulQuery(t *testing.T) {
|
||||
got := captureLog(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/yt/page", nil)
|
||||
perform(func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "OK"})
|
||||
}, req)
|
||||
if got.row != nil {
|
||||
t.Fatalf("query was logged: %+v", got.row)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateLogRecordsLoginFailure(t *testing.T) {
|
||||
got := captureLog(t)
|
||||
body := `{"account":"admin","password":"Secret123!","captcha_id":"cid","captcha":"W8K2"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/yt/user/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.8")
|
||||
var seenPassword string
|
||||
perform(func(c *gin.Context) {
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
t.Errorf("bind body: %v", err)
|
||||
}
|
||||
seenPassword = req.Password
|
||||
panic(config.LoginErr.New())
|
||||
}, req)
|
||||
|
||||
if seenPassword != "Secret123!" {
|
||||
t.Fatalf("handler password = %q", seenPassword)
|
||||
}
|
||||
if got.row == nil {
|
||||
t.Fatal("login failure was not logged")
|
||||
}
|
||||
if got.row.Account != "admin" || got.row.UserID != 0 || got.row.Action != "登录" {
|
||||
t.Fatalf("identity: %+v", got.row)
|
||||
}
|
||||
if got.row.Success != 0 || got.row.Code != 11013 {
|
||||
t.Fatalf("result code=%d success=%d message=%s", got.row.Code, got.row.Success, got.row.Message)
|
||||
}
|
||||
if !strings.Contains(got.row.Request, "Secret123!") || !strings.Contains(got.row.Request, "W8K2") {
|
||||
t.Fatalf("request = %s", got.row.Request)
|
||||
}
|
||||
if got.row.ForwardedFor != "203.0.113.8" {
|
||||
t.Fatalf("forwarded = %s", got.row.ForwardedFor)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateLogRecordsFailedQuery(t *testing.T) {
|
||||
got := captureLog(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/yt/page", nil)
|
||||
req.Header.Set("token", "bad-token")
|
||||
perform(func(c *gin.Context) {
|
||||
panic(config.ErrNeedLogin.New())
|
||||
}, req)
|
||||
if got.row == nil {
|
||||
t.Fatal("auth failure was not logged")
|
||||
}
|
||||
if got.row.Success != 0 || got.row.Code != 11009 || got.row.Action != "查询页面" {
|
||||
t.Fatalf("row = %+v", got.row)
|
||||
}
|
||||
if strings.Contains(got.row.Header, "bad-token") {
|
||||
t.Fatalf("header = %s", got.row.Header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateLogUsesSessionUserAndMasksToken(t *testing.T) {
|
||||
got := captureLog(t)
|
||||
req := httptest.NewRequest(http.MethodPut, "/yt/page?id=9", strings.NewReader(`{"name":"founder","password":"Secret123!"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("token", "good-token-value")
|
||||
perform(func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "OK",
|
||||
"data": gin.H{"token": "abcdefghijklmnopqrstuvwxyz"},
|
||||
})
|
||||
}, req)
|
||||
if got.row == nil {
|
||||
t.Fatal("update was not logged")
|
||||
}
|
||||
if got.row.Account != "editor" || got.row.UserID != 7 || got.row.RoleID != 3 {
|
||||
t.Fatalf("user = %+v", got.row)
|
||||
}
|
||||
if got.row.Success != 1 || got.row.Action != "修改页面" || got.row.Query != "id=9" {
|
||||
t.Fatalf("row = %+v", got.row)
|
||||
}
|
||||
if !strings.Contains(got.row.Request, "Secret123!") || strings.Contains(got.row.Response, "abcdefghijklmnopqrstuvwxyz") {
|
||||
t.Fatalf("request=%s response=%s", got.row.Request, got.row.Response)
|
||||
}
|
||||
if strings.Contains(got.row.Header, "good-token-value") {
|
||||
t.Fatalf("header = %s", got.row.Header)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateLogCaptchaOmitsImage(t *testing.T) {
|
||||
got := captureLog(t)
|
||||
req := httptest.NewRequest(http.MethodGet, "/yt/user/captcha", nil)
|
||||
perform(func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "OK",
|
||||
"data": gin.H{"id": "abc", "image": "data:image/png;base64,AAAA"},
|
||||
})
|
||||
}, req)
|
||||
if got.row == nil {
|
||||
t.Fatal("captcha was not logged")
|
||||
}
|
||||
if got.row.Action != "获取验证码" || got.row.Success != 1 {
|
||||
t.Fatalf("row = %+v", got.row)
|
||||
}
|
||||
if strings.Contains(got.row.Response, "base64") || !strings.Contains(got.row.Response, `"id":"abc"`) {
|
||||
t.Fatalf("response = %s", got.row.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateLogLoginSuccessLoadsUser(t *testing.T) {
|
||||
got := captureLog(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/yt/user/login", strings.NewReader(`{"account":"editor","password":"Secret123!"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
perform(func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"code": 0,
|
||||
"message": "OK",
|
||||
"data": gin.H{"token": "good-token-value"},
|
||||
})
|
||||
}, req)
|
||||
if got.row == nil {
|
||||
t.Fatal("login success was not logged")
|
||||
}
|
||||
if got.row.Account != "editor" || got.row.UserID != 7 || got.row.RoleID != 3 || got.row.Success != 1 {
|
||||
t.Fatalf("row = %+v", got.row)
|
||||
}
|
||||
if strings.Contains(got.row.Response, "good-token-value") {
|
||||
t.Fatalf("response = %s", got.row.Response)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateLogUploadKeepsFileMetaOnly(t *testing.T) {
|
||||
got := captureLog(t)
|
||||
body := &bytes.Buffer{}
|
||||
writer := multipart.NewWriter(body)
|
||||
part, err := writer.CreateFormFile("file", "founder.png")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err = part.Write([]byte("file-bytes-secret")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = writer.WriteField("password", "Secret123!"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err = writer.Close(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/yt/upload", body)
|
||||
req.Header.Set("Content-Type", writer.FormDataContentType())
|
||||
req.Header.Set("token", "good-token-value")
|
||||
perform(func(c *gin.Context) {
|
||||
if _, err := c.FormFile("file"); err != nil {
|
||||
t.Errorf("form file: %v", err)
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"code": 0, "message": "OK"})
|
||||
}, req)
|
||||
if got.row == nil {
|
||||
t.Fatal("upload was not logged")
|
||||
}
|
||||
if strings.Contains(got.row.Request, "file-bytes-secret") || !strings.Contains(got.row.Request, "Secret123!") {
|
||||
t.Fatalf("request = %s", got.row.Request)
|
||||
}
|
||||
if !strings.Contains(got.row.Request, "founder.png") || got.row.Action != "上传文件" {
|
||||
t.Fatalf("request = %s action = %s", got.row.Request, got.row.Action)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateLogStopsBeforeWriteAndMatchesExpected(t *testing.T) {
|
||||
got := captureLog(t)
|
||||
before := time.Now().Unix()
|
||||
body := `{"account":"admin","password":"Secret123!","captcha_id":"cid","captcha":"W8K2"}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/yt/user/login", strings.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("X-Forwarded-For", "203.0.113.8")
|
||||
req.RemoteAddr = "192.0.2.1:1234"
|
||||
|
||||
var seenPassword string
|
||||
recorder := perform(func(c *gin.Context) {
|
||||
var login struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&login); err != nil {
|
||||
t.Errorf("bind body: %v", err)
|
||||
}
|
||||
seenPassword = login.Password
|
||||
panic(config.LoginErr.New())
|
||||
}, req)
|
||||
after := time.Now().Unix()
|
||||
|
||||
if seenPassword != "Secret123!" {
|
||||
t.Fatalf("handler saw password %q", seenPassword)
|
||||
}
|
||||
if got.row == nil {
|
||||
t.Fatal("log stopped with no row")
|
||||
}
|
||||
if recorder.Code != http.StatusOK || !strings.Contains(recorder.Body.String(), `"code":11013`) {
|
||||
t.Fatalf("http status=%d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
|
||||
row := *got.row
|
||||
if row.CreateTime < before || row.CreateTime > after {
|
||||
t.Fatalf("create_time %d not in [%d,%d]", row.CreateTime, before, after)
|
||||
}
|
||||
if row.CostMs < 0 || row.CostMs > 5000 {
|
||||
t.Fatalf("cost_ms %d", row.CostMs)
|
||||
}
|
||||
if !strings.HasPrefix(row.Message, "请输入正确的账号和密码") {
|
||||
t.Fatalf("message %q", row.Message)
|
||||
}
|
||||
wantResponse, err := json.Marshal(struct {
|
||||
Code int `json:"code"`
|
||||
Message string `json:"message"`
|
||||
}{Code: 11013, Message: row.Message})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Response != string(wantResponse) {
|
||||
t.Fatalf("response\n got %s\nwant %s", row.Response, wantResponse)
|
||||
}
|
||||
|
||||
row.CreateTime = 0
|
||||
row.CostMs = 0
|
||||
row.Message = ""
|
||||
row.Response = ""
|
||||
diffLog(t, row, model.AdminOperateLog{
|
||||
Account: "admin",
|
||||
UserID: 0,
|
||||
RoleID: 0,
|
||||
IP: "203.0.113.8",
|
||||
ForwardedFor: "203.0.113.8",
|
||||
Method: "POST",
|
||||
Path: "/yt/user/login",
|
||||
Query: "",
|
||||
Request: `{"account":"admin","captcha":"W8K2","captcha_id":"cid","password":"Secret123!"}`,
|
||||
Action: "登录",
|
||||
Code: 11013,
|
||||
Success: 0,
|
||||
Header: `{"Content-Type":"application/json","X-Forwarded-For":"203.0.113.8"}`,
|
||||
})
|
||||
}
|
||||
|
||||
func diffLog(t *testing.T, got, want model.AdminOperateLog) {
|
||||
t.Helper()
|
||||
gv := reflect.ValueOf(got)
|
||||
wv := reflect.ValueOf(want)
|
||||
var diffs []string
|
||||
for i := 0; i < gv.NumField(); i++ {
|
||||
name := gv.Type().Field(i).Name
|
||||
gotText := fmt.Sprint(gv.Field(i).Interface())
|
||||
wantText := fmt.Sprint(wv.Field(i).Interface())
|
||||
if gotText != wantText {
|
||||
diffs = append(diffs, fmt.Sprintf("%s:\n got %s\n want %s", name, gotText, wantText))
|
||||
}
|
||||
}
|
||||
if len(diffs) > 0 {
|
||||
t.Fatalf("row mismatch\n%s", strings.Join(diffs, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func TestOperateLogTruncatesLargeBody(t *testing.T) {
|
||||
got := captureLog(t)
|
||||
payload, err := json.Marshal(map[string]any{
|
||||
"code": 0,
|
||||
"message": "OK",
|
||||
"data": map[string]any{"content": strings.Repeat("页", 5000)},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := httptest.NewRequest(http.MethodPost, "/yt/page", strings.NewReader(`{"name":"founder"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("token", "good-token-value")
|
||||
perform(func(c *gin.Context) {
|
||||
c.Data(http.StatusOK, "application/json", payload)
|
||||
}, req)
|
||||
if got.row == nil {
|
||||
t.Fatal("create was not logged")
|
||||
}
|
||||
if len(got.row.Response) > operateBodyLimit {
|
||||
t.Fatalf("response len = %d", len(got.row.Response))
|
||||
}
|
||||
if !utf8.ValidString(got.row.Response) {
|
||||
t.Fatal("truncated response is not valid utf-8")
|
||||
}
|
||||
}
|
||||
|
|
@ -29,8 +29,10 @@ type UserList struct {
|
|||
}
|
||||
|
||||
type UserLogin struct {
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
Account string `json:"account"`
|
||||
Password string `json:"password"`
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
Captcha string `json:"captcha"`
|
||||
}
|
||||
|
||||
type UserLogout struct {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/base64"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"math/rand/v2"
|
||||
"strings"
|
||||
"time"
|
||||
"yintai-api/app/common/cache"
|
||||
err2 "yintai-api/app/common/err"
|
||||
|
||||
"github.com/smbrave/goutil"
|
||||
)
|
||||
|
||||
const captchaAlphabet = "23456789ABCDEFGHJKLMNPQRSTUVWXYZ"
|
||||
|
||||
var captchaFont = map[rune][7]byte{
|
||||
'2': {0b01110, 0b10001, 0b00001, 0b00010, 0b00100, 0b01000, 0b11111},
|
||||
'3': {0b01110, 0b10001, 0b00001, 0b00110, 0b00001, 0b10001, 0b01110},
|
||||
'4': {0b00100, 0b01000, 0b10000, 0b10010, 0b11111, 0b00010, 0b00010},
|
||||
'5': {0b11111, 0b10000, 0b11110, 0b00001, 0b00001, 0b10001, 0b01110},
|
||||
'6': {0b00110, 0b01000, 0b10000, 0b11110, 0b10001, 0b10001, 0b01110},
|
||||
'7': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b01000, 0b01000},
|
||||
'8': {0b01110, 0b10001, 0b10001, 0b01110, 0b10001, 0b10001, 0b01110},
|
||||
'9': {0b01110, 0b10001, 0b10001, 0b01111, 0b00001, 0b00010, 0b01100},
|
||||
'A': {0b01110, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||
'B': {0b11110, 0b10001, 0b10001, 0b11110, 0b10001, 0b10001, 0b11110},
|
||||
'C': {0b01110, 0b10001, 0b10000, 0b10000, 0b10000, 0b10001, 0b01110},
|
||||
'D': {0b11110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b11110},
|
||||
'E': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111},
|
||||
'F': {0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||
'G': {0b01110, 0b10001, 0b10000, 0b10111, 0b10001, 0b10001, 0b01111},
|
||||
'H': {0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001},
|
||||
'J': {0b00111, 0b00010, 0b00010, 0b00010, 0b00010, 0b10010, 0b01100},
|
||||
'K': {0b10001, 0b10010, 0b10100, 0b11000, 0b10100, 0b10010, 0b10001},
|
||||
'L': {0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111},
|
||||
'M': {0b10001, 0b11011, 0b10101, 0b10101, 0b10001, 0b10001, 0b10001},
|
||||
'N': {0b10001, 0b11001, 0b10101, 0b10011, 0b10001, 0b10001, 0b10001},
|
||||
'P': {0b11110, 0b10001, 0b10001, 0b11110, 0b10000, 0b10000, 0b10000},
|
||||
'Q': {0b01110, 0b10001, 0b10001, 0b10001, 0b10101, 0b10010, 0b01101},
|
||||
'R': {0b11110, 0b10001, 0b10001, 0b11110, 0b10100, 0b10010, 0b10001},
|
||||
'S': {0b01111, 0b10000, 0b10000, 0b01110, 0b00001, 0b00001, 0b11110},
|
||||
'T': {0b11111, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||
'U': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110},
|
||||
'V': {0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01010, 0b00100},
|
||||
'W': {0b10001, 0b10001, 0b10001, 0b10101, 0b10101, 0b10101, 0b01010},
|
||||
'X': {0b10001, 0b10001, 0b01010, 0b00100, 0b01010, 0b10001, 0b10001},
|
||||
'Y': {0b10001, 0b10001, 0b01010, 0b00100, 0b00100, 0b00100, 0b00100},
|
||||
'Z': {0b11111, 0b00001, 0b00010, 0b00100, 0b01000, 0b10000, 0b11111},
|
||||
}
|
||||
|
||||
func (a *User) Captcha() (string, string) {
|
||||
code := randomCaptcha(4)
|
||||
id := goutil.RandomStr(16)
|
||||
if err := cache.GetRedis().Set(cache.NewKeys().Captcha(id), code, 10*time.Minute).Err(); err != nil {
|
||||
err2.DbErr("<user captcha>", err)
|
||||
}
|
||||
return id, "data:image/png;base64," + base64.StdEncoding.EncodeToString(captchaPNG(code))
|
||||
}
|
||||
|
||||
func verifyCaptcha(id, code string) {
|
||||
id = strings.TrimSpace(id)
|
||||
code = strings.TrimSpace(code)
|
||||
if id == "" {
|
||||
err2.CaptchaErr()
|
||||
}
|
||||
key := cache.NewKeys().Captcha(id)
|
||||
redix := cache.GetRedis()
|
||||
saved := redix.Get(key).Val()
|
||||
redix.Del(key)
|
||||
if code == "" || saved == "" || !strings.EqualFold(saved, code) {
|
||||
err2.CaptchaErr()
|
||||
}
|
||||
}
|
||||
|
||||
func randomCaptcha(n int) string {
|
||||
buf := make([]byte, n)
|
||||
for i := range buf {
|
||||
buf[i] = captchaAlphabet[rand.IntN(len(captchaAlphabet))]
|
||||
}
|
||||
return string(buf)
|
||||
}
|
||||
|
||||
func captchaPNG(code string) []byte {
|
||||
const scale = 3
|
||||
charW, charH := 5*scale, 7*scale
|
||||
gap, padX, padY := 10, 14, 12
|
||||
width := padX*2 + len(code)*charW + (len(code)-1)*gap
|
||||
height := padY*2 + charH + 8
|
||||
img := image.NewRGBA(image.Rect(0, 0, width, height))
|
||||
bg := color.RGBA{245, 246, 248, 255}
|
||||
for y := 0; y < height; y++ {
|
||||
for x := 0; x < width; x++ {
|
||||
img.Set(x, y, bg)
|
||||
}
|
||||
}
|
||||
for range 8 {
|
||||
c := color.RGBA{uint8(160 + rand.IntN(70)), uint8(160 + rand.IntN(70)), uint8(160 + rand.IntN(70)), 255}
|
||||
x1, y1 := rand.IntN(width), rand.IntN(height)
|
||||
x2, y2 := rand.IntN(width), rand.IntN(height)
|
||||
drawLine(img, x1, y1, x2, y2, c)
|
||||
}
|
||||
for i, r := range code {
|
||||
glyph, ok := captchaFont[r]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
ox := padX + i*(charW+gap) + rand.IntN(3) - 1
|
||||
oy := padY + rand.IntN(5)
|
||||
ink := color.RGBA{uint8(rand.IntN(80)), uint8(rand.IntN(80)), uint8(rand.IntN(80)), 255}
|
||||
for row := 0; row < 7; row++ {
|
||||
bits := glyph[row]
|
||||
for col := 0; col < 5; col++ {
|
||||
if bits&(1<<uint(4-col)) == 0 {
|
||||
continue
|
||||
}
|
||||
for dy := 0; dy < scale; dy++ {
|
||||
for dx := 0; dx < scale; dx++ {
|
||||
img.Set(ox+col*scale+dx, oy+row*scale+dy, ink)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
_ = png.Encode(&buf, img)
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func drawLine(img *image.RGBA, x1, y1, x2, y2 int, c color.Color) {
|
||||
dx := abs(x2 - x1)
|
||||
dy := abs(y2 - y1)
|
||||
sx, sy := 1, 1
|
||||
if x1 > x2 {
|
||||
sx = -1
|
||||
}
|
||||
if y1 > y2 {
|
||||
sy = -1
|
||||
}
|
||||
err := dx - dy
|
||||
for {
|
||||
if image.Pt(x1, y1).In(img.Bounds()) {
|
||||
img.Set(x1, y1, c)
|
||||
}
|
||||
if x1 == x2 && y1 == y2 {
|
||||
return
|
||||
}
|
||||
e2 := 2 * err
|
||||
if e2 > -dy {
|
||||
err -= dy
|
||||
x1 += sx
|
||||
}
|
||||
if e2 < dx {
|
||||
err += dx
|
||||
y1 += sy
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"image/png"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRandomCaptcha(t *testing.T) {
|
||||
code := randomCaptcha(4)
|
||||
if len(code) != 4 {
|
||||
t.Fatalf("len = %d", len(code))
|
||||
}
|
||||
for _, r := range code {
|
||||
if !strings.ContainsRune(captchaAlphabet, r) {
|
||||
t.Fatalf("unexpected char %q", r)
|
||||
}
|
||||
if _, ok := captchaFont[r]; !ok {
|
||||
t.Fatalf("missing glyph %q", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaPNG(t *testing.T) {
|
||||
raw := captchaPNG("W8K2")
|
||||
img, err := png.Decode(bytes.NewReader(raw))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if img.Bounds().Dx() < 40 || img.Bounds().Dy() < 20 {
|
||||
t.Fatalf("image too small: %v", img.Bounds())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptchaFontCoversAlphabet(t *testing.T) {
|
||||
for _, r := range captchaAlphabet {
|
||||
if _, ok := captchaFont[r]; !ok {
|
||||
t.Fatalf("alphabet char %q has no glyph", r)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -35,9 +35,7 @@ func (a *User) Create(req request.UserCreate) int64 {
|
|||
}
|
||||
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
if len(req.Password) < 1 {
|
||||
err2.ParamErr("<admin service create>: password too short")
|
||||
}
|
||||
checkPassword(req.Password)
|
||||
|
||||
if cast.ToInt64(req.Role) != 0 {
|
||||
// 检查 role 是否存在
|
||||
|
|
@ -118,13 +116,11 @@ func (a *User) Update(req request.UserUpdate) {
|
|||
}
|
||||
}
|
||||
|
||||
// 不能修改密码
|
||||
//req.Password = strings.TrimSpace(req.Password)
|
||||
//if len(req.Password) < 1 {
|
||||
// err2.ParamErr("<user service update>: password too short")
|
||||
//}
|
||||
//
|
||||
//user.Password = goutil.Md5(req.Password + Salt)
|
||||
req.Password = strings.TrimSpace(req.Password)
|
||||
if req.Password != "" {
|
||||
checkPassword(req.Password)
|
||||
user.Password = goutil.Md5(req.Password + model.Salt)
|
||||
}
|
||||
|
||||
err = global.GetDB().Transaction(func(tx *gorm.DB) error {
|
||||
if err = dao.NewUser(tx).Update(user); err != nil {
|
||||
|
|
@ -154,6 +150,29 @@ func (a *User) Update(req request.UserUpdate) {
|
|||
|
||||
}
|
||||
|
||||
func checkPassword(password string) {
|
||||
n := len([]rune(password))
|
||||
if n < 8 || n > 16 {
|
||||
err2.ParamErr("password length must be between 8 and 16")
|
||||
}
|
||||
var upper, lower, digit, special bool
|
||||
for _, r := range password {
|
||||
switch {
|
||||
case r >= 'A' && r <= 'Z':
|
||||
upper = true
|
||||
case r >= 'a' && r <= 'z':
|
||||
lower = true
|
||||
case r >= '0' && r <= '9':
|
||||
digit = true
|
||||
default:
|
||||
special = true
|
||||
}
|
||||
}
|
||||
if !upper || !lower || !digit || !special {
|
||||
err2.ParamErr("password must include uppercase, lowercase, digit and special character")
|
||||
}
|
||||
}
|
||||
|
||||
func (a *User) Delete(req request.UserDelete) {
|
||||
var user *model.User
|
||||
var err error
|
||||
|
|
@ -190,18 +209,20 @@ func (a *User) List(req request.UserList) (any, int64) {
|
|||
}
|
||||
|
||||
func (a *User) Login(req request.UserLogin) any {
|
||||
verifyCaptcha(req.CaptchaID, req.Captcha)
|
||||
|
||||
user, err := dao.NewUser().GetWithRole(req.Account)
|
||||
if err != nil {
|
||||
err2.DbErr("<user service login>", err)
|
||||
}
|
||||
|
||||
if user == nil {
|
||||
err2.NotEsxErr("<user service login>")
|
||||
err2.LoginErr()
|
||||
return ""
|
||||
}
|
||||
|
||||
if user.Password != goutil.Md5(req.Password+model.Salt) {
|
||||
err2.PriErr("<user service login>: wrong password")
|
||||
err2.LoginErr()
|
||||
}
|
||||
|
||||
token := fmt.Sprintf("%s", goutil.RandomStr(64))
|
||||
|
|
|
|||
|
|
@ -31,3 +31,7 @@ func (k Keys) UserToken(account string) string {
|
|||
func (k Keys) Api(roleID int64) string {
|
||||
return fmt.Sprintf("%s%d", "home:user:api:", roleID)
|
||||
}
|
||||
|
||||
func (k Keys) Captcha(id string) string {
|
||||
return "home:user:captcha:" + id
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
package dao
|
||||
|
||||
import (
|
||||
"yintai-api/app/common/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminOperateLog struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAdminOperateLog(db ...*gorm.DB) *AdminOperateLog {
|
||||
return &AdminOperateLog{db: getDB(db...)}
|
||||
}
|
||||
|
||||
func (r *AdminOperateLog) TableName() string {
|
||||
return "yt_admin_operate_log"
|
||||
}
|
||||
|
||||
func (r *AdminOperateLog) Create(m *model.AdminOperateLog) error {
|
||||
return r.db.Table(r.TableName()).Create(m).Error
|
||||
}
|
||||
|
|
@ -6,46 +6,54 @@ import (
|
|||
"github.com/gogap/errors"
|
||||
)
|
||||
|
||||
func errCatch(template errors.ErrCodeTemplate, err ...interface{}) {
|
||||
func errCatch(template errors.ErrCodeTemplate, err ...any) {
|
||||
panic(template.New().Append(err...))
|
||||
}
|
||||
|
||||
func ParamErr(err ...interface{}) {
|
||||
func ParamErr(err ...any) {
|
||||
errCatch(config.ErrParam, err)
|
||||
}
|
||||
|
||||
func DbErr(err ...interface{}) {
|
||||
func DbErr(err ...any) {
|
||||
errCatch(config.ErrDb, err)
|
||||
}
|
||||
|
||||
func PriErr(err ...interface{}) {
|
||||
func PriErr(err ...any) {
|
||||
errCatch(config.ErrPriv, err)
|
||||
}
|
||||
|
||||
func EsxErr(err ...interface{}) {
|
||||
func EsxErr(err ...any) {
|
||||
errCatch(config.ErrExist, err)
|
||||
}
|
||||
|
||||
func NotEsxErr(err ...interface{}) {
|
||||
func NotEsxErr(err ...any) {
|
||||
errCatch(config.ErrNotExists, err)
|
||||
}
|
||||
|
||||
func InnerErr(err ...interface{}) {
|
||||
func InnerErr(err ...any) {
|
||||
errCatch(config.ErrInternal, err)
|
||||
}
|
||||
|
||||
func TokenErr(err ...interface{}) {
|
||||
func TokenErr(err ...any) {
|
||||
errCatch(config.ErrNeedLogin, err)
|
||||
}
|
||||
|
||||
func ExtNotSupportErr(err ...interface{}) {
|
||||
func ExtNotSupportErr(err ...any) {
|
||||
errCatch(config.ErrExt, err)
|
||||
}
|
||||
|
||||
func TranslateErr(err ...interface{}) {
|
||||
func TranslateErr(err ...any) {
|
||||
errCatch(config.TranslateErr, err)
|
||||
}
|
||||
|
||||
func InUseErr(err ...interface{}) {
|
||||
func InUseErr(err ...any) {
|
||||
errCatch(config.InUseErr, err)
|
||||
}
|
||||
|
||||
func LoginErr(err ...any) {
|
||||
errCatch(config.LoginErr, err)
|
||||
}
|
||||
|
||||
func CaptchaErr(err ...any) {
|
||||
errCatch(config.CaptchaErr, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,22 @@
|
|||
package model
|
||||
|
||||
type AdminOperateLog struct {
|
||||
ID int64
|
||||
Account string
|
||||
UserID int64
|
||||
RoleID int64
|
||||
CreateTime int64
|
||||
CostMs int
|
||||
IP string
|
||||
ForwardedFor string
|
||||
Method string
|
||||
Path string
|
||||
Query string
|
||||
Request string
|
||||
Action string
|
||||
Code int
|
||||
Message string
|
||||
Response string
|
||||
Success int
|
||||
Header string
|
||||
}
|
||||
|
|
@ -14,8 +14,8 @@ func initAdminRouter(engine *gin.Engine) {
|
|||
noAdminGroup := engine.Group("/yt")
|
||||
adminGroup := engine.Group("/yt")
|
||||
|
||||
adminGroup.Use(middleware2.Recovery).Use(middleware.Login).Use(middleware.Permission())
|
||||
noAdminGroup.Use(middleware2.Recovery)
|
||||
adminGroup.Use(middleware.OperateLog()).Use(middleware2.Recovery).Use(middleware.Login).Use(middleware.Permission())
|
||||
noAdminGroup.Use(middleware.OperateLog()).Use(middleware2.Recovery)
|
||||
|
||||
user := new(controller.User)
|
||||
adminGroup.POST("/user", user.Create)
|
||||
|
|
@ -23,7 +23,8 @@ func initAdminRouter(engine *gin.Engine) {
|
|||
adminGroup.PUT("/user", user.Update)
|
||||
adminGroup.DELETE("/user", user.Delete)
|
||||
adminGroup.GET("/user/logout", user.Logout)
|
||||
noAdminGroup.POST("/user/login", user.Login)
|
||||
noAdminGroup.GET("/user/captcha", middleware.LoginLimit(), user.Captcha)
|
||||
noAdminGroup.POST("/user/login", middleware.LoginLimit(), user.Login)
|
||||
|
||||
resource := new(controller.Resource)
|
||||
adminGroup.GET("/resource", resource.List)
|
||||
|
|
|
|||
|
|
@ -33,4 +33,6 @@ var (
|
|||
ErrExt = errors.T(11010, "不支持的文件格式")
|
||||
TranslateErr = errors.T(11011, "翻译失败")
|
||||
InUseErr = errors.T(11012, "正在使用,如需删除请需求关联")
|
||||
LoginErr = errors.T(11013, "请输入正确的账号和密码")
|
||||
CaptchaErr = errors.T(11014, "验证码错误")
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
CREATE TABLE IF NOT EXISTS `yt_admin_operate_log` (
|
||||
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
|
||||
`account` varchar(64) NOT NULL DEFAULT '' COMMENT '登录名,登录失败时取请求里的账号',
|
||||
`user_id` bigint NOT NULL DEFAULT 0 COMMENT '用户ID,未能识别时为0',
|
||||
`role_id` bigint NOT NULL DEFAULT 0 COMMENT '角色ID',
|
||||
`create_time` bigint NOT NULL DEFAULT 0 COMMENT '操作时间,unix秒',
|
||||
`cost_ms` int NOT NULL DEFAULT 0 COMMENT '耗时,毫秒',
|
||||
`ip` varchar(64) NOT NULL DEFAULT '' COMMENT '客户端IP',
|
||||
`forwarded_for` varchar(512) NOT NULL DEFAULT '' COMMENT '原始X-Forwarded-For',
|
||||
`method` varchar(16) NOT NULL DEFAULT '' COMMENT '请求方法',
|
||||
`path` varchar(255) NOT NULL DEFAULT '' COMMENT '请求路径',
|
||||
`query` varchar(1024) NOT NULL DEFAULT '' COMMENT 'URL查询参数',
|
||||
`request` text NOT NULL COMMENT '请求体,密码明文,token已打码,超长截断',
|
||||
`action` varchar(64) NOT NULL DEFAULT '' COMMENT '动作名称',
|
||||
`code` int NOT NULL DEFAULT 0 COMMENT '业务码,0表示成功',
|
||||
`message` varchar(512) NOT NULL DEFAULT '' COMMENT '返回文案',
|
||||
`response` text NOT NULL COMMENT '响应体,token已打码,验证码图片不保存,超长截断',
|
||||
`success` tinyint NOT NULL DEFAULT 0 COMMENT '1成功 0失败',
|
||||
`header` text NOT NULL COMMENT '请求头,token和cookie已打码',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `idx_account_time` (`account`, `create_time`),
|
||||
KEY `idx_create_time` (`create_time`),
|
||||
KEY `idx_path_time` (`path`, `create_time`),
|
||||
KEY `idx_ip_time` (`ip`, `create_time`),
|
||||
KEY `idx_success_time` (`success`, `create_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='管理员操作日志';
|
||||
Loading…
Reference in New Issue