refactor: operate log and so on

This commit is contained in:
wangfuduo 2026-10-13 18:01:14 +08:00
parent f57d7bd8b7
commit a18fa887cf
15 changed files with 1165 additions and 28 deletions

View File

@ -69,6 +69,14 @@ func (a *User) List(ctx *gin.Context) {
ctx.JSON(http.StatusOK, response.NewListRsp(count, res)) 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) { func (a *User) Login(ctx *gin.Context) {
var req request.UserLogin var req request.UserLogin
if err := ctx.ShouldBind(&req); err != nil { if err := ctx.ShouldBind(&req); err != nil {

View File

@ -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()
}
}

View File

@ -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)
}

View File

@ -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")
}
}

View File

@ -29,8 +29,10 @@ type UserList struct {
} }
type UserLogin struct { type UserLogin struct {
Account string `json:"account"` Account string `json:"account"`
Password string `json:"password"` Password string `json:"password"`
CaptchaID string `json:"captcha_id"`
Captcha string `json:"captcha"`
} }
type UserLogout struct { type UserLogout struct {

View File

@ -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
}

View File

@ -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)
}
}
}

View File

@ -35,9 +35,7 @@ func (a *User) Create(req request.UserCreate) int64 {
} }
req.Password = strings.TrimSpace(req.Password) req.Password = strings.TrimSpace(req.Password)
if len(req.Password) < 1 { checkPassword(req.Password)
err2.ParamErr("<admin service create>: password too short")
}
if cast.ToInt64(req.Role) != 0 { if cast.ToInt64(req.Role) != 0 {
// 检查 role 是否存在 // 检查 role 是否存在
@ -118,13 +116,11 @@ func (a *User) Update(req request.UserUpdate) {
} }
} }
// 不能修改密码 req.Password = strings.TrimSpace(req.Password)
//req.Password = strings.TrimSpace(req.Password) if req.Password != "" {
//if len(req.Password) < 1 { checkPassword(req.Password)
// err2.ParamErr("<user service update>: password too short") user.Password = goutil.Md5(req.Password + model.Salt)
//} }
//
//user.Password = goutil.Md5(req.Password + Salt)
err = global.GetDB().Transaction(func(tx *gorm.DB) error { err = global.GetDB().Transaction(func(tx *gorm.DB) error {
if err = dao.NewUser(tx).Update(user); err != nil { 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) { func (a *User) Delete(req request.UserDelete) {
var user *model.User var user *model.User
var err error var err error
@ -190,18 +209,20 @@ func (a *User) List(req request.UserList) (any, int64) {
} }
func (a *User) Login(req request.UserLogin) any { func (a *User) Login(req request.UserLogin) any {
verifyCaptcha(req.CaptchaID, req.Captcha)
user, err := dao.NewUser().GetWithRole(req.Account) user, err := dao.NewUser().GetWithRole(req.Account)
if err != nil { if err != nil {
err2.DbErr("<user service login>", err) err2.DbErr("<user service login>", err)
} }
if user == nil { if user == nil {
err2.NotEsxErr("<user service login>") err2.LoginErr()
return "" return ""
} }
if user.Password != goutil.Md5(req.Password+model.Salt) { 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)) token := fmt.Sprintf("%s", goutil.RandomStr(64))

View File

@ -31,3 +31,7 @@ func (k Keys) UserToken(account string) string {
func (k Keys) Api(roleID int64) string { func (k Keys) Api(roleID int64) string {
return fmt.Sprintf("%s%d", "home:user:api:", roleID) return fmt.Sprintf("%s%d", "home:user:api:", roleID)
} }
func (k Keys) Captcha(id string) string {
return "home:user:captcha:" + id
}

View File

@ -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
}

View File

@ -6,46 +6,54 @@ import (
"github.com/gogap/errors" "github.com/gogap/errors"
) )
func errCatch(template errors.ErrCodeTemplate, err ...interface{}) { func errCatch(template errors.ErrCodeTemplate, err ...any) {
panic(template.New().Append(err...)) panic(template.New().Append(err...))
} }
func ParamErr(err ...interface{}) { func ParamErr(err ...any) {
errCatch(config.ErrParam, err) errCatch(config.ErrParam, err)
} }
func DbErr(err ...interface{}) { func DbErr(err ...any) {
errCatch(config.ErrDb, err) errCatch(config.ErrDb, err)
} }
func PriErr(err ...interface{}) { func PriErr(err ...any) {
errCatch(config.ErrPriv, err) errCatch(config.ErrPriv, err)
} }
func EsxErr(err ...interface{}) { func EsxErr(err ...any) {
errCatch(config.ErrExist, err) errCatch(config.ErrExist, err)
} }
func NotEsxErr(err ...interface{}) { func NotEsxErr(err ...any) {
errCatch(config.ErrNotExists, err) errCatch(config.ErrNotExists, err)
} }
func InnerErr(err ...interface{}) { func InnerErr(err ...any) {
errCatch(config.ErrInternal, err) errCatch(config.ErrInternal, err)
} }
func TokenErr(err ...interface{}) { func TokenErr(err ...any) {
errCatch(config.ErrNeedLogin, err) errCatch(config.ErrNeedLogin, err)
} }
func ExtNotSupportErr(err ...interface{}) { func ExtNotSupportErr(err ...any) {
errCatch(config.ErrExt, err) errCatch(config.ErrExt, err)
} }
func TranslateErr(err ...interface{}) { func TranslateErr(err ...any) {
errCatch(config.TranslateErr, err) errCatch(config.TranslateErr, err)
} }
func InUseErr(err ...interface{}) { func InUseErr(err ...any) {
errCatch(config.InUseErr, err) errCatch(config.InUseErr, err)
} }
func LoginErr(err ...any) {
errCatch(config.LoginErr, err)
}
func CaptchaErr(err ...any) {
errCatch(config.CaptchaErr, err)
}

View File

@ -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
}

View File

@ -14,8 +14,8 @@ func initAdminRouter(engine *gin.Engine) {
noAdminGroup := engine.Group("/yt") noAdminGroup := engine.Group("/yt")
adminGroup := engine.Group("/yt") adminGroup := engine.Group("/yt")
adminGroup.Use(middleware2.Recovery).Use(middleware.Login).Use(middleware.Permission()) adminGroup.Use(middleware.OperateLog()).Use(middleware2.Recovery).Use(middleware.Login).Use(middleware.Permission())
noAdminGroup.Use(middleware2.Recovery) noAdminGroup.Use(middleware.OperateLog()).Use(middleware2.Recovery)
user := new(controller.User) user := new(controller.User)
adminGroup.POST("/user", user.Create) adminGroup.POST("/user", user.Create)
@ -23,7 +23,8 @@ func initAdminRouter(engine *gin.Engine) {
adminGroup.PUT("/user", user.Update) adminGroup.PUT("/user", user.Update)
adminGroup.DELETE("/user", user.Delete) adminGroup.DELETE("/user", user.Delete)
adminGroup.GET("/user/logout", user.Logout) 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) resource := new(controller.Resource)
adminGroup.GET("/resource", resource.List) adminGroup.GET("/resource", resource.List)

View File

@ -33,4 +33,6 @@ var (
ErrExt = errors.T(11010, "不支持的文件格式") ErrExt = errors.T(11010, "不支持的文件格式")
TranslateErr = errors.T(11011, "翻译失败") TranslateErr = errors.T(11011, "翻译失败")
InUseErr = errors.T(11012, "正在使用,如需删除请需求关联") InUseErr = errors.T(11012, "正在使用,如需删除请需求关联")
LoginErr = errors.T(11013, "请输入正确的账号和密码")
CaptchaErr = errors.T(11014, "验证码错误")
) )

View File

@ -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='管理员操作日志';