用户管理-管理员修改信息

admin_user_info_update.go
package user_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/utils/mps"
	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
)

//管理员修改用户信息,主要为了方便管理员修改一些不合规的信息

type AdminUserInfoUpdateRequest struct {
	UserID   uint           `json:"user_id" binding:"required"`
	Nickname *string        ` json:"nickname" s-u:"nickname"`
	Avatar   *string        ` json:"avatar" s-u:"avatar"`
	Abstract *string        ` json:"abstract" s-u:"abstract"`
	Role     *enum.RoleType `json:"role" s-u:"role"`
}

func (UserApi) AdminUserInfoUpdateView(c *gin.Context) {
	var cr AdminUserInfoUpdateRequest
	err := c.ShouldBindJSON(&cr)
	if err != nil {
		res.FailWithError(err, c)
		return
	}
	userMap := mps.StructToMap(cr, "s-u")
	var user models.UserModel
	err = global.DB.Take(&user, cr.UserID).Error
	if err != nil {
		res.FailWithMsg("获取用户失败", c)
		logrus.Error(err)
		return
	}
	err = global.DB.Model(&user).Updates(userMap).Error
	if err != nil {
		res.FailWithMsg("用户信息修改失败", c)
		return
	}

	res.OkWithMsg("用户信息修改成功", c)

}

路由

r.PUT("user/admin", middlware.AdminMiddleware, app.AdminUserInfoUpdateView)

文章管理-es&mysql同步

sync.go

package river

import (
	"blog_server/global"
	"blog_server/services/river_service/elastic"
	"blog_server/services/river_service/rule"
	"bytes"
	"encoding/json"
	"fmt"
	"github.com/pingcap/errors"
	"github.com/siddontang/go-log/log"
	"github.com/siddontang/go-mysql/canal"
	"github.com/siddontang/go-mysql/mysql"
	"github.com/siddontang/go-mysql/replication"
	"github.com/siddontang/go-mysql/schema"
	"reflect"
	"strings"
	"time"
)

const (
	fieldTypeList = "list"
	// for the mysql int type to es date type
	// set the [rule.field] created_time = ",date"
	fieldTypeDate = "date"
)

const mysqlDateFormat = "2006-01-02"

type posSaver struct {
	pos   mysql.Position
	force bool
}

type eventHandler struct {
	r *River
}

func (h *eventHandler) OnRotate(e *replication.RotateEvent) error {
	pos := mysql.Position{
		Name: string(e.NextLogName),
		Pos:  uint32(e.Position),
	}

	h.r.syncCh <- posSaver{pos, true}

	return h.r.ctx.Err()
}

func (h *eventHandler) OnTableChanged(schema, table string) error {
	err := h.r.updateRule(schema, table)
	if err != nil && err != ErrRuleNotExist {
		return errors.Trace(err)
	}
	return nil
}

func (h *eventHandler) OnDDL(nextPos mysql.Position, _ *replication.QueryEvent) error {
	h.r.syncCh <- posSaver{nextPos, true}
	return h.r.ctx.Err()
}

func (h *eventHandler) OnXID(nextPos mysql.Position) error {
	h.r.syncCh <- posSaver{nextPos, false}
	return h.r.ctx.Err()
}

func (h *eventHandler) OnRow(e *canal.RowsEvent) error {
	rule, ok := h.r.rules[ruleKey(e.Table.Schema, e.Table.Name)]
	if !ok {
		return nil
	}

	var reqs []*elastic.BulkRequest
	var err error
	switch e.Action {
	case canal.InsertAction:
		reqs, err = h.r.makeInsertRequest(rule, e.Rows)
	case canal.DeleteAction:
		reqs, err = h.r.makeDeleteRequest(rule, e.Rows)
	case canal.UpdateAction:
		reqs, err = h.r.makeUpdateRequest(rule, e.Rows)
	default:
		err = errors.Errorf("invalid rows action %s", e.Action)
	}

	if err != nil {
		h.r.cancel()
		return errors.Errorf("make %s ES request err %v, close sync", e.Action, err)
	}

	h.r.syncCh <- reqs

	return h.r.ctx.Err()
}

func (h *eventHandler) OnGTID(gtid mysql.GTIDSet) error {
	return nil
}

func (h *eventHandler) OnPosSynced(pos mysql.Position, set mysql.GTIDSet, force bool) error {
	return nil
}

func (h *eventHandler) String() string {
	return "ESRiverEventHandler"
}

func (r *River) syncLoop() {
	bulkSize := global.Config.River.BulkSize
	if bulkSize == 0 {
		bulkSize = 128
	}

	interval := 200 * time.Millisecond
	//if interval == 0 {
	//	interval = 200 * time.Millisecond
	//}

	ticker := time.NewTicker(interval)
	defer ticker.Stop()
	defer r.wg.Done()

	lastSavedTime := time.Now()
	reqs := make([]*elastic.BulkRequest, 0, 1024)

	var pos mysql.Position

	for {
		needFlush := false
		needSavePos := false

		select {
		case v := <-r.syncCh:
			switch v := v.(type) {
			case posSaver:
				now := time.Now()
				if v.force || now.Sub(lastSavedTime) > 3*time.Second {
					lastSavedTime = now
					needFlush = true
					needSavePos = true
					pos = v.pos
				}
			case []*elastic.BulkRequest:
				reqs = append(reqs, v...)
				needFlush = len(reqs) >= bulkSize
			}
		case <-ticker.C:
			needFlush = true
		case <-r.ctx.Done():
			return
		}

		if needFlush {
			// TODO: retry some times?
			if err := r.doBulk(reqs); err != nil {
				log.Errorf("do ES bulk err %v, close sync", err)
				r.cancel()
				return
			}
			reqs = reqs[0:0]
		}

		if needSavePos {
			if err := r.master.Save(pos); err != nil {
				log.Errorf("save sync position %s err %v, close sync", pos, err)
				r.cancel()
				return
			}
		}
	}
}

// for insert and delete
func (r *River) makeRequest(rule *rule.Rule, action string, rows [][]interface{}) ([]*elastic.BulkRequest, error) {
	reqs := make([]*elastic.BulkRequest, 0, len(rows))

	for _, values := range rows {
		id, err := r.getDocID(rule, values)
		if err != nil {
			return nil, errors.Trace(err)
		}

		parentID := ""
		if len(rule.Parent) > 0 {
			if parentID, err = r.getParentID(rule, values, rule.Parent); err != nil {
				return nil, errors.Trace(err)
			}
		}

		req := &elastic.BulkRequest{Index: rule.Index, Type: rule.Type, ID: id, Parent: parentID, Pipeline: rule.Pipeline}

		if action == canal.DeleteAction {
			req.Action = elastic.ActionDelete
		} else {
			r.makeInsertReqData(req, rule, values)
		}

		reqs = append(reqs, req)
	}

	return reqs, nil
}

func (r *River) makeInsertRequest(rule *rule.Rule, rows [][]interface{}) ([]*elastic.BulkRequest, error) {
	return r.makeRequest(rule, canal.InsertAction, rows)
}

func (r *River) makeDeleteRequest(rule *rule.Rule, rows [][]interface{}) ([]*elastic.BulkRequest, error) {
	return r.makeRequest(rule, canal.DeleteAction, rows)
}

func (r *River) makeUpdateRequest(rule *rule.Rule, rows [][]interface{}) ([]*elastic.BulkRequest, error) {
	if len(rows)%2 != 0 {
		return nil, errors.Errorf("invalid update rows event, must have 2x rows, but %d", len(rows))
	}

	reqs := make([]*elastic.BulkRequest, 0, len(rows))

	for i := 0; i < len(rows); i += 2 {
		beforeID, err := r.getDocID(rule, rows[i])
		if err != nil {
			return nil, errors.Trace(err)
		}

		afterID, err := r.getDocID(rule, rows[i+1])

		if err != nil {
			return nil, errors.Trace(err)
		}

		beforeParentID, afterParentID := "", ""
		if len(rule.Parent) > 0 {
			if beforeParentID, err = r.getParentID(rule, rows[i], rule.Parent); err != nil {
				return nil, errors.Trace(err)
			}
			if afterParentID, err = r.getParentID(rule, rows[i+1], rule.Parent); err != nil {
				return nil, errors.Trace(err)
			}
		}

		req := &elastic.BulkRequest{Index: rule.Index, Type: rule.Type, ID: beforeID, Parent: beforeParentID}

		if beforeID != afterID || beforeParentID != afterParentID {
			req.Action = elastic.ActionDelete
			reqs = append(reqs, req)

			req = &elastic.BulkRequest{Index: rule.Index, Type: rule.Type, ID: afterID, Parent: afterParentID, Pipeline: rule.Pipeline}
			r.makeInsertReqData(req, rule, rows[i+1])

		} else {
			if len(rule.Pipeline) > 0 {
				// Pipelines can only be specified on index action
				r.makeInsertReqData(req, rule, rows[i+1])
				// Make sure action is index, not create
				req.Action = elastic.ActionIndex
				req.Pipeline = rule.Pipeline
			} else {
				r.makeUpdateReqData(req, rule, rows[i], rows[i+1])
			}

		}

		reqs = append(reqs, req)
	}

	return reqs, nil
}

func (r *River) makeReqColumnData(col *schema.TableColumn, value interface{}) interface{} {
	switch col.Type {
	case schema.TYPE_ENUM:
		switch value := value.(type) {
		case int64:
			// for binlog, ENUM may be int64, but for dump, enum is string
			eNum := value - 1
			if eNum < 0 || eNum >= int64(len(col.EnumValues)) {
				// we insert invalid enum value before, so return empty
				log.Warnf("invalid binlog enum index %d, for enum %v", eNum, col.EnumValues)
				return ""
			}

			return col.EnumValues[eNum]
		}
	case schema.TYPE_SET:
		switch value := value.(type) {
		case int64:
			// for binlog, SET may be int64, but for dump, SET is string
			bitmask := value
			sets := make([]string, 0, len(col.SetValues))
			for i, s := range col.SetValues {
				if bitmask&int64(1<<uint(i)) > 0 {
					sets = append(sets, s)
				}
			}
			return strings.Join(sets, ",")
		}
	case schema.TYPE_BIT:
		switch value := value.(type) {
		case string:
			// for binlog, BIT is int64, but for dump, BIT is string
			// for dump 0x01 is for 1, \0 is for 0
			if value == "\x01" {
				return int64(1)
			}

			return int64(0)
		}
	case schema.TYPE_STRING:
		switch value := value.(type) {
		case []byte:
			return string(value[:])
		}
	case schema.TYPE_JSON:
		var f interface{}
		var err error
		switch v := value.(type) {
		case string:
			err = json.Unmarshal([]byte(v), &f)
		case []byte:
			err = json.Unmarshal(v, &f)
		}
		if err == nil && f != nil {
			return f
		}
	case schema.TYPE_DATETIME, schema.TYPE_TIMESTAMP:
		switch v := value.(type) {
		case string:
			vt, err := time.ParseInLocation(mysql.TimeFormat, string(v), time.Local)
			if err != nil || vt.IsZero() { // failed to parse date or zero date
				return nil
			}
			return vt.Format(time.RFC3339)
		}
	case schema.TYPE_DATE:
		switch v := value.(type) {
		case string:
			vt, err := time.Parse(mysqlDateFormat, string(v))
			if err != nil || vt.IsZero() { // failed to parse date or zero date
				return nil
			}
			return vt.Format(mysqlDateFormat)
		}
	}

	return value
}

func (r *River) getFieldParts(k string, v string) (string, string, string) {
	composedField := strings.Split(v, ",")

	mysql := k
	elastic := composedField[0]
	fieldType := ""

	if 0 == len(elastic) {
		elastic = mysql
	}
	if 2 == len(composedField) {
		fieldType = composedField[1]
	}

	return mysql, elastic, fieldType
}

func (r *River) makeInsertReqData(req *elastic.BulkRequest, rule *rule.Rule, values []interface{}) {
	req.Data = make(map[string]interface{}, len(values))
	req.Action = elastic.ActionIndex

	for i, c := range rule.TableInfo.Columns {
		if !rule.CheckFilter(c.Name) {
			continue
		}
		mapped := false
		for k, v := range rule.FieldMapping {
			mysql, elastic, fieldType := r.getFieldParts(k, v)
			if mysql == c.Name {
				mapped = true
				req.Data[elastic] = r.getFieldValue(&c, fieldType, values[i])
			}
		}
		if mapped == false {
			req.Data[c.Name] = r.makeReqColumnData(&c, values[i])
		}
	}
}

func (r *River) makeUpdateReqData(req *elastic.BulkRequest, rule *rule.Rule,
	beforeValues []interface{}, afterValues []interface{}) {
	req.Data = make(map[string]interface{}, len(beforeValues))

	// maybe dangerous if something wrong delete before?
	req.Action = elastic.ActionUpdate

	for i, c := range rule.TableInfo.Columns {
		mapped := false
		if !rule.CheckFilter(c.Name) {
			continue
		}
		if reflect.DeepEqual(beforeValues[i], afterValues[i]) {
			//nothing changed
			continue
		}
		for k, v := range rule.FieldMapping {
			mysql, elastic, fieldType := r.getFieldParts(k, v)
			if mysql == c.Name {
				mapped = true
				req.Data[elastic] = r.getFieldValue(&c, fieldType, afterValues[i])
			}
		}
		if mapped == false {
			req.Data[c.Name] = r.makeReqColumnData(&c, afterValues[i])
		}

	}
}

// If id in toml file is none, get primary keys in one row and format them into a string, and PK must not be nil
// Else get the ID's column in one row and format them into a string
func (r *River) getDocID(rule *rule.Rule, row []interface{}) (string, error) {
	var (
		ids []interface{}
		err error
	)
	if rule.ID == nil {
		ids, err = rule.TableInfo.GetPKValues(row)
		if err != nil {
			return "", err
		}
	} else {
		ids = make([]interface{}, 0, len(rule.ID))
		for _, column := range rule.ID {
			value, err := rule.TableInfo.GetColumnValue(column, row)
			if err != nil {
				return "", err
			}
			ids = append(ids, value)
		}
	}

	var buf bytes.Buffer

	sep := ""
	for i, value := range ids {
		if value == nil {
			return "", errors.Errorf("The %ds id or PK value is nil", i)
		}

		buf.WriteString(fmt.Sprintf("%s%v", sep, value))
		sep = ":"
	}

	return buf.String(), nil
}

func (r *River) getParentID(rule *rule.Rule, row []interface{}, columnName string) (string, error) {
	index := rule.TableInfo.FindColumn(columnName)
	if index < 0 {
		return "", errors.Errorf("parent id not found %s(%s)", rule.TableInfo.Name, columnName)
	}

	return fmt.Sprint(row[index]), nil
}

func (r *River) doBulk(reqs []*elastic.BulkRequest) error {
	if len(reqs) == 0 {
		return nil
	}

	if resp, err := r.es.Bulk(reqs); err != nil {
		log.Errorf("sync docs err %v after binlog %s", err, r.canal.SyncedPosition())
		return errors.Trace(err)
	} else if resp.Code/100 == 2 || resp.Errors {
		for i := 0; i < len(resp.Items); i++ {
			for action, item := range resp.Items[i] {
				if len(item.Error) > 0 {
					log.Errorf("%s index: %s, type: %s, id: %s, status: %d, error: %s",
						action, item.Index, item.Type, item.ID, item.Status, item.Error)
				}
			}
		}
	}

	return nil
}

// get mysql field value and convert it to specific value to es
func (r *River) getFieldValue(col *schema.TableColumn, fieldType string, value interface{}) interface{} {
	var fieldValue interface{}
	switch fieldType {
	case fieldTypeList:
		v := r.makeReqColumnData(col, value)
		if str, ok := v.(string); ok {
			fieldValue = strings.Split(str, ",")
		} else {
			fieldValue = v
		}

	case fieldTypeDate:
		if col.Type == schema.TYPE_NUMBER {
			col.Type = schema.TYPE_DATETIME

			v := reflect.ValueOf(value)
			switch v.Kind() {
			case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
				fieldValue = r.makeReqColumnData(col, time.Unix(v.Int(), 0).Format(mysql.TimeFormat))
			}
		}
	}

	if fieldValue == nil {
		fieldValue = r.makeReqColumnData(col, value)
	}
	return fieldValue
}

yaml


es:
  addr: 127.0.0.1:9200
  username:
  password:
  is_https: 

river:
  server_id: 1001
  flavor: mysql
  data_dir: ./var
  source:
    - schema: blogx
      tables:
       - article_models
  rule:
    - schema: blogx
      table: article_models
      index: article_index
      type: _doc
      field:
        tag_list: tag_list,list
  bulk_size: 128

river_conf.go

package conf

import (
	"blog_server/services/river_service/rule"
)

type River struct {
	ServerID uint32        `yaml:"server_id"`
	Flavor   string        `yaml:"flavor"`
	DataDir  string        `yaml:"data_dir"`
	Sources  []RiverSource `yaml:"source"`
	Rules    []*rule.Rule  `yaml:"rule"`

	BulkSize int `yaml:"bulk_size"`
}

type RiverSource struct {
	Schema string   `yaml:"schema"`
	Tables []string `yaml:"tables"`
}

es_conf.go

package conf

import "fmt"

type ES struct {
	//Url      string `yaml:"url"`
	Username string `yaml:"username"`
	Addr     string `yaml:"addr"`
	Password string `yaml:"password"`
	IsHttps  bool   `yaml:"is_https"`
}

func (e ES) Url() string {
	if e.IsHttps {
		return fmt.Sprintf("https://%s", e.Addr)
	}
	return fmt.Sprintf("http://%s", e.Addr)
}

init_es.go

package core

import (
	"blog_server/global"
	"github.com/olivere/elastic/v7"
	"github.com/sirupsen/logrus"
)

func EsConnect() *elastic.Client {
	es := global.Config.ES
	if es.Addr == "" {
		return nil
	}
	client, err := elastic.NewClient(

		elastic.SetURL(es.Url()),
		elastic.SetSniff(false),
		//elastic.SetBasicAuth("", ""),  //账号密码
	)
	if err != nil {
		logrus.Panicf("es连接失败 %s", err)
		return nil
	}
	logrus.Info("es连接成功")
	return client
}

init_mysql_es.go

package core

import (
	"blog_server/global"
	river "blog_server/services/river_service"
	"github.com/sirupsen/logrus"
)

func InitMysqlEs() {
	if global.Config.ES.Addr == "" {
		logrus.Infof("为配置es。")
		return
	}

	r, err := river.NewRiver()
	if err != nil {
		logrus.Fatal(err)
	}
	go r.Run()

}

main.go

package core

import (
	"blog_server/global"
	river "blog_server/services/river_service"
	"github.com/sirupsen/logrus"
)

func InitMysqlEs() {
	if global.Config.ES.Addr == "" {
		logrus.Infof("为配置es。")
		return
	}

	r, err := river.NewRiver()
	if err != nil {
		logrus.Fatal(err)
	}
	go r.Run()

}

参考一下gomode

module blog_server

go 1.24.2

replace github.com/siddontang/go-mysql v1.13.0 => github.com/go-mysql-org/go-mysql v1.13.0

require (
	github.com/PuerkitoBio/goquery v1.11.0
	github.com/dgrijalva/jwt-go v3.2.0+incompatible
	github.com/gin-gonic/gin v1.11.0
	github.com/go-playground/locales v0.14.1
	github.com/go-playground/universal-translator v0.18.1
	github.com/go-playground/validator/v10 v10.27.0
	github.com/go-redis/redis v6.15.9+incompatible
	github.com/google/uuid v1.6.0
	github.com/jordan-wright/email v4.0.1-0.20210109023952-943e75fe5223+incompatible
	github.com/lionsoul2014/ip2region/binding/golang v0.0.0-20251027134825-bbf5df560120
	github.com/mojocn/base64Captcha v1.3.8
	github.com/olivere/elastic/v7 v7.0.32
	github.com/pkg/errors v0.9.1
	github.com/qiniu/go-sdk/v7 v7.25.5
	github.com/sirupsen/logrus v1.9.3
	golang.org/x/crypto v0.45.0
	gopkg.in/yaml.v2 v2.4.0
	gorm.io/driver/mysql v1.6.0
	gorm.io/gorm v1.31.0
	gorm.io/plugin/dbresolver v1.6.2
)

require (
	filippo.io/edwards25519 v1.1.0 // indirect
	github.com/BurntSushi/toml v1.3.2 // indirect
	github.com/Masterminds/semver v1.5.0 // indirect
	github.com/alex-ant/gomath v0.0.0-20160516115720-89013a210a82 // indirect
	github.com/andybalholm/cascadia v1.3.3 // indirect
	github.com/bytedance/sonic v1.14.0 // indirect
	github.com/bytedance/sonic/loader v0.3.0 // indirect
	github.com/cloudwego/base64x v0.1.6 // indirect
	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
	github.com/gin-contrib/sse v1.1.0 // indirect
	github.com/go-mysql-org/go-mysql v1.9.1 // indirect
	github.com/go-sql-driver/mysql v1.8.1 // indirect
	github.com/goccy/go-json v0.10.2 // indirect
	github.com/goccy/go-yaml v1.18.0 // indirect
	github.com/gofrs/flock v0.8.1 // indirect
	github.com/golang/freetype v0.0.0-20170609003504-e2365dfdc4a0 // indirect
	github.com/golang/protobuf v1.5.0 // indirect
	github.com/jinzhu/inflection v1.0.0 // indirect
	github.com/jinzhu/now v1.1.5 // indirect
	github.com/josharian/intern v1.0.0 // indirect
	github.com/json-iterator/go v1.1.12 // indirect
	github.com/klauspost/compress v1.17.8 // indirect
	github.com/klauspost/cpuid/v2 v2.3.0 // indirect
	github.com/leodido/go-urn v1.4.0 // indirect
	github.com/mailru/easyjson v0.7.7 // indirect
	github.com/mattn/go-isatty v0.0.20 // indirect
	github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect
	github.com/modern-go/reflect2 v1.0.2 // indirect
	github.com/onsi/ginkgo v1.16.5 // indirect
	github.com/onsi/gomega v1.38.2 // indirect
	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
	github.com/pingcap/check v0.0.0-20211026125417-57bd13f7b5f0 // indirect
	github.com/pingcap/errors v0.11.5-0.20250318082626-8f80e5cb09ec // indirect
	github.com/pingcap/failpoint v0.0.0-20240528011301-b51a646c7c86 // indirect
	github.com/pingcap/log v1.1.1-0.20241212030209-7e3ff8601a2a // indirect
	github.com/pingcap/parser v0.0.0-20190506092653-e336082eb825 // indirect
	github.com/pingcap/tidb/pkg/parser v0.0.0-20250421232622-526b2c79173d // indirect
	github.com/pingcap/tipb v0.0.0-20190428032612-535e1abaa330 // indirect
	github.com/quic-go/qpack v0.5.1 // indirect
	github.com/quic-go/quic-go v0.54.0 // indirect
	github.com/satori/go.uuid v1.2.0 // indirect
	github.com/shopspring/decimal v1.2.0 // indirect
	github.com/siddontang/go v0.0.0-20180604090527-bdc77568d726 // indirect
	github.com/siddontang/go-log v0.0.0-20190221022429-1e957dd83bed // indirect
	github.com/siddontang/go-mysql v0.0.0-20190524062908-de6c3a84bcbe // indirect
	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
	github.com/ugorji/go/codec v1.3.0 // indirect
	go.uber.org/atomic v1.11.0 // indirect
	go.uber.org/mock v0.5.0 // indirect
	go.uber.org/multierr v1.11.0 // indirect
	go.uber.org/zap v1.27.0 // indirect
	golang.org/x/arch v0.20.0 // indirect
	golang.org/x/image v0.23.0 // indirect
	golang.org/x/mod v0.29.0 // indirect
	golang.org/x/net v0.47.0 // indirect
	golang.org/x/sync v0.18.0 // indirect
	golang.org/x/sys v0.38.0 // indirect
	golang.org/x/term v0.37.0 // indirect
	golang.org/x/text v0.31.0 // indirect
	golang.org/x/tools v0.38.0 // indirect
	google.golang.org/protobuf v1.36.9 // indirect
	gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
	modernc.org/fileutil v1.0.0 // indirect
)

文章管理-修改tag自定义数据类型

models/ctype/list.go
package ctype

import (
	"database/sql/driver"
	"strings"
)

type List []string

func (j *List) Scan(value interface{}) error {
	val, ok := value.([]uint8)
	if ok {
		*j = strings.Split(string(val), ",")
	}
	return nil
}

func (j List) Value() (driver.Value, error) {
	return strings.Join(j, ","), nil
}
article_models.go
package models

import (
	"blog_server/models/ctype"
	_ "embed"
)

// 文章表
type ArticleModel struct {
	Model
	Title        string     `gorm:"size:64" json:"title"`
	Abstract     string     `gorm:"size:255" json:"abstract"` //简介
	Content      string     `gorm:"size:255" json:"content"`
	CategoryID   *uint      `json:"category_id"`                   //分类ID
	TagList      ctype.List `gorm:"type:longtext" json:"tag_list"` //标签列表
	Cover        string     `gorm:"size:255" json:"cover"`         //封面
	UserID       uint       `json:"user_id"`
	UserModel    UserModel  `gorm:"foreignKey:user_id" json:"-"`
	LookCount    int64      `json:"look_count"`    //浏览量
	DiggCount    int64      `json:"digg_count"`    //点赞数
	CommentCount int64      `json:"comment_count"` //评论数
	CollectCount int64      `json:"collect_count"` //收藏数
	OpenComment  bool       `json:"open_comment"`  //开启评论
	Status       int8       `json:"status"`        //状态 草稿,审核中,已发布
}

//go:embed mappings/article_mapping.json
var articleMapping string

func (ArticleModel) Mapping() string {
	return articleMapping
}

func (ArticleModel) Index() string {
	return "article_index"
}

文章管理—用户侧发布文章

添加状态类型

models/enum/article_status_type.go
package enum

type ArticleStatusType int8

const (
	ArticleStatusDraft     ArticleStatusType = 1 //  草稿
	ArticleStatusExamine   ArticleStatusType = 2 // 审核中
	ArticleStatusPublishes ArticleStatusType = 3 // 已发布

)

添加工具函数解析markdown

utils/markdowns/enter.go
package markdowns

import (
	"github.com/gomarkdown/markdown"
	"github.com/gomarkdown/markdown/html"
	"github.com/gomarkdown/markdown/parser"
)

func MdToHTML(md string) string {
	// create markdown parser with extensions
	extensions := parser.CommonExtensions | parser.AutoHeadingIDs | parser.NoEmptyLineBeforeBlock
	p := parser.NewWithExtensions(extensions)
	doc := p.Parse([]byte(md))

	// create HTML renderer with extensions
	htmlFlags := html.CommonFlags | html.HrefTargetBlank
	opts := html.RendererOptions{Flags: htmlFlags}
	renderer := html.NewRenderer(opts)

	return string(markdown.Render(doc, renderer))
}
创建文章接口

api/article_api/article_create.go

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/ctype"
	"blog_server/models/enum"
	"blog_server/utils/jwts"
	"blog_server/utils/markdowns"
	"bytes"
	"fmt"
	"github.com/PuerkitoBio/goquery"
	"github.com/gin-gonic/gin"
)

type ArticleCreateRequest struct {
	Title       string                 `json:"title" binding:"required" `
	Abstract    string                 `json:"abstract" `
	Content     string                 `json:"content" binding:"required" `
	CategoryID  *uint                  `json:"category_id"`
	TagList     ctype.List             `json:"tag_list"`
	Cover       string                 `json:"cover" `
	OpenComment bool                   `yaml:"open_comment"`
	Status      enum.ArticleStatusType `json:"status" binding:"required oneof=1 2"`
}

func (ArticleApi) ArticleCreateApiView(c *gin.Context) {
	cr := middlware.GetBind[ArticleCreateRequest](c)

	user, err := jwts.GetClaims(c).GetUser()
	if err != nil {
		res.FailWithMsg("用户不存在", c)
		return
	}

	//判断文章分类id是不是自己创建的
	var category models.CategoryModel
	if cr.CategoryID != nil {
		err = global.DB.Take(&category, "id = ? and user_id = ?", *cr.CategoryID, user.ID).Error
		if err != nil {
			res.FailWithMsg("文章分类不存在", c)
			return
		}
	}
	//如果不传简介,那么就从正文中提取前·30个字符
	if cr.Abstract == "" {
		//把markdown转成html,再取文本
		html := markdowns.MdToHTML(cr.Content)
		//使用goquery提取元素
		docs, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(html)))
		if err != nil {
			fmt.Println(err)
			return
		}
		htmlText := docs.ToText()
		cr.Abstract = htmlText
		if len(htmlText) > 200 {
			cr.Abstract = string([]rune(htmlText)[:200])
		}
	}
	//文章正文防xss注入
	//文章正文图片保存
	var article = models.ArticleModel{
		Title:       cr.Title,
		Abstract:    cr.Abstract,
		Content:     cr.Content,
		TagList:     cr.TagList,
		UserID:      user.ID,
		Cover:       cr.Cover,
		OpenComment: cr.OpenComment,
		CategoryID:  cr.CategoryID,
		Status:      cr.Status,
	}

	if global.Config.Site.Article.NoExamine {
		//如果配置文章不需要审核就将文章的的状态直接更改为发布状态
		article.Status = enum.ArticleStatusPublishes
	}

}
images/transfer_deposit.go
package image_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/utils"
	"fmt"
	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
	"io"
	"net/http"
	"os"
)

type TransferDepositRequest struct {
	Url string `json:"url" binding:"required"`
}

func (ImageApi) TransferDepositView(c *gin.Context) {
	cr := middlware.GetBind[TransferDepositRequest](c)
	//请求这个路由,确保可以正常访问
	response, err := http.Get(cr.Url)
	if err != nil {
		logrus.Errorf("图片请求错误%s", err)
		res.FailWithMsg("图片请求错误", c)
		return
	}
	byteData, _ := io.ReadAll(response.Body)
	hash := utils.Md5(byteData)
	//获取图片的后缀,根据返回的的类型来判断保存为合适的图片类型,默认是png图片
	suffix := "png"
	switch response.Header.Get("Content-Type") {
	case "image/avif":
		suffix = "avif"
	}
	filePath := fmt.Sprintf("uploads/%s/%s.%s", global.Config.Upload.UploadDir, hash, suffix)
	err = os.WriteFile(filePath, byteData, 0666)
	if err != nil {
		logrus.Error(err)
		res.FailWithMsg("图片保存失败", c)
		return
	}
	res.OkWithData("/"+filePath, c)

}
article_create.go
package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/ctype"
	"blog_server/models/enum"
	"blog_server/utils/jwts"
	"blog_server/utils/markdowns"
	"bytes"
	"github.com/PuerkitoBio/goquery"
	"github.com/gin-gonic/gin"
)

type ArticleCreateRequest struct {
	Title       string                 `json:"title" binding:"required" `
	Abstract    string                 `json:"abstract" `
	Content     string                 `json:"content" binding:"required" `
	CategoryID  *uint                  `json:"category_id"`
	TagList     ctype.List             `json:"tag_list"`
	Cover       string                 `json:"cover" `
	OpenComment bool                   `json:"open_comment"`
	Status      enum.ArticleStatusType `json:"status" binding:"required,oneof=1 2"`
}

func (ArticleApi) ArticleCreateApiView(c *gin.Context) {
	cr := middlware.GetBind[ArticleCreateRequest](c)
	user, err := jwts.GetClaims(c).GetUser()
	if err != nil {
		res.FailWithMsg("用户不存在", c)
		return
	}

	if global.Config.Site.SiteInfo.Mode == 2 {
		if user.Role != enum.AdminRole {
			res.FailWithMsg("博客模式下,普通用户不能发表文章", c)
			return
		}
	}

	//判断文章分类id是不是自己创建的
	var category models.CategoryModel
	if cr.CategoryID != nil {
		err = global.DB.Take(&category, "id = ? and user_id = ?", *cr.CategoryID, user.ID).Error
		if err != nil {
			res.FailWithMsg("文章分类不存在", c)
			return
		}
	}

	//文章正文防xss注入(因为文正使用的markdown格式进行编写的,他会转成thml进行展示(js的领域),如果里面是有一些恶意的js代码,就会导致出现一些问题,所以我们有必要做一些xss攻击的防止操作)
	contenDoc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(cr.Content)))
	if err != nil {
		res.FailWithMsg("正文解析失败", c)
		return
	}
	contenDoc.Find("script").Remove() //不能存在script 标签,因为里面就会存在一些js代码
	contenDoc.Find("img").Remove()    //img 和iframe 在进行报错后会自动调用里面报错的钩子函数,可以自定义编辑js代码,不安全,都不能保留
	contenDoc.Find("iframe").Remove()
	//如果不传简介,那么就从正文中提取前·30个字符
	if cr.Abstract == "" {
		//把markdown转成html,再取文本
		html := markdowns.MdToHTML(cr.Content) //封装解析markdown方法
		//使用goquery提取元素
		docs, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(html)))
		if err != nil {
			res.FailWithMsg("正文解析失败", c)
			return
		}
		htmlText := docs.Text()
		cr.Abstract = htmlText
		if len(htmlText) > 200 {
			//如果长度大于200就取前200
			cr.Abstract = string([]rune(htmlText)[:200])
		}
	}

	//文章正文图片保存
	//1.图片过多,同步做,但是接口耗时高,如果异步做,比较麻蛋,所以我们现在就使用image_api中的TransferDepositView进行图片转存
	var article = models.ArticleModel{
		Title:       cr.Title,
		Abstract:    cr.Abstract,
		Content:     cr.Content,
		TagList:     cr.TagList,
		UserID:      user.ID,
		Cover:       cr.Cover,
		OpenComment: cr.OpenComment,
		CategoryID:  cr.CategoryID,
		Status:      cr.Status,
	}

	if cr.Status == enum.ArticleStatusExamine && global.Config.Site.Article.NoExamine {
		//如果配置文章不需要审核就将文章的的状态直接更改为发布状态
		article.Status = enum.ArticleStatusPublishes
	}

	err = global.DB.Create(&article).Error
	if err != nil {
		res.FailWithMsg("文章创建失败", c)
		return
	}
	res.OkWithMsg("文章创建成功", c)

}
路由
func ArticleRouter(rg *gin.RouterGroup) {
	app := api.App.ArticleApi
	rg.POST("article", middlware.AuthMiddleware, middlware.BindMiddleware[article_api.ArticleCreateRequest], app.ArticleCreateApiView) //不使用中间件

}

文章管理-用户列表

工具函数

utils/sql/enter.go

package sql

import (
	"fmt"
	"strconv"
	"strings"
)

// ConvertSliceSql 将uint切片转换为SQL查询用的(1,2,3)格式字符串
// 为了查询为select * from xxx order by id in(1,2,3)
func ConvertSliceSql(list []uint) string {
	// 处理空切片(包括nil)
	if len(list) == 0 {
		return "()"
	}
	// 初始化字符串切片,用于存储转换后的数字字符串
	strs := make([]string, len(list))
	for i, num := range list {
		// 将uint转换为字符串(需先转为uint64以适配strconv.FormatUint)
		strs[i] = strconv.FormatUint(uint64(num), 10)
	}
	// 拼接为(元素1,元素2,...)格式
	return "(" + strings.Join(strs, ",") + ")"
}

// 为了查询为select * from xxx order by id = 1 desc ,id = 2 desc ,id = 3 desc ,id = 4 desc
func ConvertSliceOrderSql(list []uint) string {
	// 处理空切片(包括nil)
	if len(list) == 0 {
		return "()"
	}
	// 初始化字符串切片,用于存储转换后的数字字符串
	strs := make([]string, len(list))
	for i, num := range list {
		// 将uint转换为字符串(需先转为uint64以适配strconv.FormatUint)
		//strs[i] = strconv.FormatUint(uint64(num), 10)
		strs[i] = fmt.Sprintf("id = %d desc ", num)
	}
	// 拼接为(元素1,元素2,...)格式
	return strings.Join(strs, ",")
}

接口函数

package article_api

import (
	"blog_server/common"
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/utils/jwts"
	"blog_server/utils/sql"
	"fmt"
	"github.com/gin-gonic/gin"
)

//可以查某个用户发布的文章,只能查已发布的,不需要登录,支持分类查询
//	可以查某个人用户收藏的文章,前提是这个用户开了对应隐私设置
//用户侧 查自己发布的文章,只能查已发布的,,	需要登录, 支持分类查询
//	也能查自己收藏的文章,不会受到自己的隐私设置
//	支持按照状态查询,已发布,草稿箱,待审核
//管理员侧 查全部,支持按照用户搜索,状态过滤,文章标题模糊匹配,分类过滤

type ArticleListRequest struct {
	common.PageInfo
	Type       int8                   `form:"type" binding:"required,oneof=1 2 3"` //1-用户查别人的, 2-查自己的 3-管理员查询
	CategoryID *uint                  `form:"category_id"`
	UserID     uint                   `form:"user_id"`
	Status     enum.ArticleStatusType `form:"status"` //状态 草稿,审核中,已发布
}

type ArticleListResponse struct {
	models.ArticleModel
	UserTop  bool `json:"user_top"`  //是否是用户置顶
	AdminTop bool `json:"admin_top"` //是否是管理员置顶
}

func (ArticleApi) ArticleListView(c *gin.Context) {

	var topArticleIdList []uint //用户置顶列表
	var orderColumnMap = map[string]bool{
		"look_count desc":    true,
		"digg_count desc":    true,
		"comment_count desc": true,
		"collect_count desc": true,
		"look_count":         true,
		"digg_count":         true,
		"comment_count":      true,
		"collect_count":      true,
	}
	cr := middlware.GetBind[ArticleListRequest](c)
	switch cr.Type {
	case 1:
		//查别人的用户id就是必填的
		if cr.UserID == 0 {
			res.FailWithMsg("用户id必填", c)
			return
		}
		if cr.Page > 2 || cr.Limit > 10 {
			res.FailWithMsg("查询更多请登录", c)
			return
		}
		cr.Status = 0
		cr.Order = ""
	case 2:
		//查自己
		claims, err := jwts.ParseTokenByGin(c)
		if err != nil {
			res.FailWithMsg("请登录", c)
			return
		}
		cr.UserID = claims.UserId

	case 3:
		//管理员
		claims, err := jwts.ParseTokenByGin(c)
		if !(err == nil && claims.RoleId == enum.AdminRole) {
			res.FailWithMsg("角色错误", c)
			return
		}
	}
	if cr.Order != "" {
		_, ok := orderColumnMap[cr.Order]
		if !ok {
			res.FailWithMsg("不支持的排序方式", c)
			return
		}
	}

	//处理用户置顶
	var userTopMap = map[uint]bool{}
	var adminTopMap = map[uint]bool{}
	if cr.UserID != 0 {
		var userTopArticleList []models.UserTopArticleModel
		global.DB.Preload("UserModel").Order("created_at desc").Find(&userTopArticleList, "user_id = ?", cr.UserID)

		for _, i2 := range userTopArticleList {
			topArticleIdList = append(topArticleIdList, i2.ArticleID)
			if i2.UserModel.Role == enum.AdminRole {
				adminTopMap[i2.ArticleID] = true
			}
			userTopMap[i2.ArticleID] = true
		}
	}

	//判断是否有置顶,有的话话再按照置顶列表进行排序,否则就按照时间倒叙排序
	var options = common.Options{
		Likes:        []string{"title"},
		PageInfo:     cr.PageInfo,
		DefaultOrder: "created_at desc",
	}
	if len(topArticleIdList) > 0 {
		options.DefaultOrder = fmt.Sprintf("%s,created_at desc", sql.ConvertSliceOrderSql(topArticleIdList)) // [1 2 3] = >(1,2,3)或者 id= 1 desc,id = 2 desc 使用辅助函数实现ConvertSliceOrderSql
	}

	_list, count, _ := common.ListQuery(models.ArticleModel{
		UserID:     cr.UserID,
		CategoryID: cr.CategoryID,
		Status:     cr.Status,
	}, options)

	var list = make([]ArticleListResponse, 0)
	for _, model := range _list {
		model.Content = ""
		list = append(list, ArticleListResponse{
			ArticleModel: model,
			UserTop:      userTopMap[model.ID],
			AdminTop:     adminTopMap[model.ID],
		})
	}

	res.OkWithList(list, count, c)

}

路由

rg.GET("article", middlware.BindQueryMiddleware[article_api.ArticleListRequest], app.ArticleListView)

文章管理-文章更新

接口

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/ctype"
	"blog_server/models/enum"
	"blog_server/utils/jwts"
	"blog_server/utils/markdowns"
	"bytes"
	"github.com/PuerkitoBio/goquery"
	"github.com/gin-gonic/gin"
)

type ArticleUpdateRequest struct {
	ID          uint                   `json:"id" bindings:"required"`
	Title       string                 `json:"title" binding:"required" `
	Abstract    string                 `json:"abstract" `
	Content     string                 `json:"content" binding:"required" `
	CategoryID  *uint                  `json:"category_id"`
	TagList     ctype.List             `json:"tag_list"`
	Cover       string                 `json:"cover" `
	OpenComment bool                   `json:"open_comment"`
	Status      enum.ArticleStatusType `json:"status" binding:"required,oneof=1 2"`
}

func (ArticleApi) ArticleUpdateApiView(c *gin.Context) {
	cr := middlware.GetBind[ArticleUpdateRequest](c)
	user, err := jwts.GetClaims(c).GetUser()
	if err != nil {
		res.FailWithMsg("用户不存在", c)
		return
	}

	var article models.ArticleModel
	err = global.DB.Take(&article, cr.ID).Error
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}
	//更新得文章只能是自己的
	if article.UserID != user.ID {
		res.FailWithMsg("只能更新自己的文章", c)
		return
	}

	//判断文章分类id是不是自己创建的
	var category models.CategoryModel
	if cr.CategoryID != nil {
		err = global.DB.Take(&category, "id = ? and user_id = ?", *cr.CategoryID, user.ID).Error
		if err != nil {
			res.FailWithMsg("文章分类不存在", c)
			return
		}
	}
	if global.Config.Site.SiteInfo.Mode == 2 {
		if user.Role != enum.AdminRole {
			res.FailWithMsg("博客模式下,普通用户不能发表文章", c)
			return
		}
	}

	//文章正文防xss注入(因为文正使用的markdown格式进行编写的,他会转成thml进行展示(js的领域),如果里面是有一些恶意的js代码,就会导致出现一些问题,所以我们有必要做一些xss攻击的防止操作)
	contenDoc, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(cr.Content)))
	if err != nil {
		res.FailWithMsg("正文解析失败", c)
		return
	}
	contenDoc.Find("script").Remove() //不能存在script 标签,因为里面就会存在一些js代码
	contenDoc.Find("img").Remove()    //img 和iframe 在进行报错后会自动调用里面报错的钩子函数,可以自定义编辑js代码,不安全,都不能保留
	contenDoc.Find("iframe").Remove()
	//如果不传简介,那么就从正文中提取前·30个字符
	if cr.Abstract == "" {
		//把markdown转成html,再取文本
		html := markdowns.MdToHTML(cr.Content) //封装解析markdown方法
		//使用goquery提取元素
		docs, err := goquery.NewDocumentFromReader(bytes.NewReader([]byte(html)))
		if err != nil {
			res.FailWithMsg("正文解析失败", c)
			return
		}
		htmlText := docs.Text()
		cr.Abstract = htmlText
		if len(htmlText) > 200 {
			//如果长度大于200就取前200
			cr.Abstract = string([]rune(htmlText)[:200])
		}
	}
	mps := map[string]any{
		"title":        cr.Title,
		"abstract":     cr.Abstract,
		"content":      cr.Content,
		"category_id":  cr.CategoryID,
		"tag_list":     cr.TagList,
		"cover":        cr.Cover,
		"open_comment": cr.OpenComment,
	}
	if article.Status == enum.ArticleStatusPublishes && !global.Config.Site.Article.NoExamine {
		//如果是已经发布的文章,进行编辑,那么就要更改成待审核
		mps["status"] = enum.ArticleStatusExamine

	}
	err = global.DB.Model(&article).Updates(mps).Error
	if err != nil {
		res.FailWithMsg("更新失败", c)
		return
	}

	res.OkWithMsg("文章更新成功", c)

}

文章管理-文章详情

接口

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/utils/jwts"
	"fmt"
	"github.com/gin-gonic/gin"
)

type ArticleDetailResponse struct {
	models.ArticleModel
	Username   string `json:"username"`
	UserAvatar string `json:"user_avatar"`
	Nickname   string `json:"nickname"`
}

func (ArticleApi) ArticleDetailView(c *gin.Context) {

	cr := middlware.GetBind[models.IdRequest](c)
	var article models.ArticleModel
	err := global.DB.Preload("UserModel").Take(&article, cr.ID).Error
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}

	claims, err := jwts.ParseTokenByGin(c)
	if err != nil {
		if article.Status != enum.ArticleStatusPublishes {
			res.FailWithMsg("文章不存在", c)
			return
		}

	}
	fmt.Println("claims", claims)
	fmt.Println("claims.role:", claims.RoleId)

	switch claims.RoleId {
	case enum.UserRole:
		if claims.UserId != article.UserID {
			//登录者看的不是自己的
			if article.Status != enum.ArticleStatusPublishes {
				res.FailWithMsg("文章不存在", c)
				return
			}

		}

	}
	
	//TODO:从缓存中获取浏览量和点赞数
	res.OkWithData(ArticleDetailResponse{
		ArticleModel: article,
		Username:     article.UserModel.Username,
		UserAvatar:   article.UserModel.Avatar,
		Nickname:     article.UserModel.Nickname,
	}, c)
}

辅助函数

func BindUriMiddleware[T any](c *gin.Context) {
	var cr T
	err := c.ShouldBindUri(&cr)
	if err != nil {
		logrus.Errorf("Query参数绑定错误: %s", err)
		res.FailWithError(err, c)
		c.Abort()
		return
	}
	c.Set("request", cr)
	return
}

func GetBind[T any](c *gin.Context) (cr T) {
	return c.MustGet("request").(T)

}

路由

package router

import (
	"blog_server/api"
	"blog_server/api/article_api"
	"blog_server/middlware"
	"blog_server/models"
	"github.com/gin-gonic/gin"
)

func ArticleRouter(rg *gin.RouterGroup) {
	app := api.App.ArticleApi
	rg.POST("article", middlware.AuthMiddleware, middlware.BindMiddleware[article_api.ArticleCreateRequest], app.ArticleCreateApiView)
	rg.PUT("article", middlware.AuthMiddleware, middlware.BindMiddleware[article_api.ArticleUpdateRequest], app.ArticleUpdateApiView)
	rg.GET("article", middlware.BindQueryMiddleware[article_api.ArticleListRequest], app.ArticleListView)
	rg.GET("article/:id", middlware.BindUriMiddleware[models.IdRequest], app.ArticleDetailView)

}

文章管理-文章审核

添加发布失败状态

package enum

type ArticleStatusType int8

const (
	ArticleStatusDraft     ArticleStatusType = 1 //  草稿
	ArticleStatusExamine   ArticleStatusType = 2 // 审核中
	ArticleStatusPublishes ArticleStatusType = 3 // 已发布
	ArticleStatusFail      ArticleStatusType = 4 //审核失败

)

接口

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"github.com/gin-gonic/gin"
)

type ArticleExamineRequest struct {
	ArticleId uint                   `json:"article_id" binding:"required"`
	Status    enum.ArticleStatusType `json:"status" binding:"required,oneof=3 4"`
	Msg       string                 `json:"msg"` //status为4(审核失败时传递出来)
}

// 文章审核
func (ArticleApi) ArticleExamineView(c *gin.Context) {
	cr := middlware.GetBind[ArticleExamineRequest](c)

	var article models.ArticleModel
	err := global.DB.Take(&article, cr.ArticleId).Error
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}

	global.DB.Model(&article).Update("status", cr.Status)
	//TODO:给文章的发布人发送一个系统消息通知
	res.FailWithMsg("审核成功", c)

}

文章管理-文章点赞

接口

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/utils/jwts"
	"github.com/gin-gonic/gin"
)

func (ArticleApi) ArticleDiggView(c *gin.Context) {
	cr := middlware.GetBind[models.IdRequest](c)

	var article models.ArticleModel
	err := global.DB.Take(&article, "status = ? and id = ?", enum.ArticleStatusPublishes, cr.ID).Error //查询已发布的
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}

	claims := jwts.GetClaims(c)
	//查一下之前有没有点赞
	var UserDiggArticle models.ArticleDiggModel
	err = global.DB.Take(&UserDiggArticle, "user_id = ? and article_id = ?", claims.UserId, article.ID).Error
	if err != nil {
		//不存在-》点赞
		err = global.DB.Create(&models.ArticleDiggModel{
			UserID:    claims.UserId,
			ArticleID: article.ID,
		}).Error
		if err != nil {
			res.FailWithMsg("点赞失败", c)
			return
		}
		//TODO:将点赞添加到缓存
		res.OkWithMsg("点赞成功", c)
		return
	}

	global.DB.Model(models.ArticleDiggModel{}).Delete("user_id = ? and article_id = ?", claims.UserId, article.ID)
	res.OkWithMsg("取消点赞成功", c)
	return
}

文章管理-文章收藏

修改模型

package models

import "time"

// 用户文章收藏表
type UserArticleCollectMode struct {
	UserID       uint         `gorm:"uniqueIndex:idx_name" json:"user_id"`
	UserModel    UserModel    `gorm:"foreignKey:user_id" json:"-"` //用户
	ArticleID    uint         `gorm:"uniqueIndex:idx_name" json:"article_id"`
	ArticleModel ArticleModel `gorm:"foreignKey:article_id" json:"-"`         //收藏的文章
	CollectID    uint         `gorm:"uniqueIndex:idx_name" json:"collect_id"` //收藏夹的ID
	CollectModel CollectModel `gorm:"foreignKey:collect_id" json:"-"`         //属于哪一个收藏夹
	CreatedAt    time.Time    `gorm:"created_at" json:"created_at"`           //点赞的时间

}

接口

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/utils/jwts"
	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
)

type ArticleCollectRequest struct {
	ArticleId uint `json:"article_id" binding:"required"`
	CollectID uint `json:"collect_id"`
}

func (ArticleApi) ArticleCollectView(c *gin.Context) {
	cr := middlware.GetBind[ArticleCollectRequest](c)
	var article models.ArticleModel
	err := global.DB.Take(&article, "status = ? and id = ?", enum.ArticleStatusPublishes, cr.ArticleId).Error //查询已发布的
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}
	var collectModel models.CollectModel
	claims, err := jwts.ParseTokenByGin(c)
	if cr.CollectID == 0 {
		//默认收藏夹

		err = global.DB.Take(&collectModel, "user_id =? and is_default = ?", claims.UserId, 1).Error
		if err != nil {
			//创建一个默认收藏夹
			collectModel.Title = "默认收藏夹"
			collectModel.UserID = claims.UserId
			collectModel.IsDefault = true
			global.DB.Create(&collectModel)

		}
		cr.CollectID = collectModel.ID
	} else {
		//判断收藏夹是否存在并且是否是自己创建的

		err = global.DB.Take(&collectModel, "user_id =?", claims.UserId).Error
		if err != nil {
			res.FailWithMsg("收藏夹不存在", c)
			return
		}
	}
	//判断是否收藏
	var articleCollect models.UserArticleCollectMode
	err = global.DB.Where(models.UserArticleCollectMode{
		UserID:    claims.UserId,
		ArticleID: cr.ArticleId,
		CollectID: cr.CollectID,
	}).Take(&articleCollect).Error

	if err != nil {
		//没有就收藏
		err = global.DB.Create(&models.UserArticleCollectMode{
			UserID:    claims.UserId,
			ArticleID: cr.ArticleId,
			CollectID: cr.CollectID,
		}).Error
		if err != nil {
			res.FailWithMsg("收藏失败", c)
			return
		}
		res.FailWithMsg("收藏成功", c)
		//对收藏夹进行加1
		global.DB.Model(&collectModel).Update("article_count", gorm.Expr("article_count + 1"))
		return
	}

	//取消收藏
	err = global.DB.Where(models.UserArticleCollectMode{
		UserID:    claims.UserId,
		ArticleID: cr.ArticleId,
		CollectID: cr.CollectID,
	}).Delete(&models.UserArticleCollectMode{}).Error
	if err != nil {
		res.FailWithMsg("取消收藏失败", c)
		return
	}
	res.FailWithMsg("取消收藏成功", c)
	//对收藏夹进行-1
	global.DB.Model(&collectModel).Update("article_count", gorm.Expr("article_count - 1"))
	
	//TODO:收藏数同步缓存
	return

}

文章管理-文章浏览

接口函数

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/utils/jwts"
	"github.com/gin-gonic/gin"
	"time"
)

//文章的浏览量

type ArticleLookRequest struct {
	ArticleID  uint `json:"article_id" binding:"required"`
	TimeSecond int  `json:"time_second"` //读完文章的用时,暂时不使用,
}

func (ArticleApi) ArticleLookView(c *gin.Context) {
	cr := middlware.GetBind[ArticleLookRequest](c)

	//TODO:未登录用户怎么处理浏览
	claims, err := jwts.ParseTokenByGin(c)
	if err != nil {
		res.OkWithMsg("未登录用户", c)
		return
	}

	//TODO:引入缓存,当这个用户请求这个文章之后,将用户的id和文章id作为key放进缓存,在这里进行判断,如果存在就直接返回,避免重复查表

	var article models.ArticleModel
	err = global.DB.Take(&article, "status = ? and id = ?", enum.ArticleStatusPublishes, cr.ArticleID).Error //查询已发布的
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}

	//查这个文章今天有没有在足迹里面
	var history models.UserArticleLookHistoryModel
	err = global.DB.Debug().Take(&history, "user_id = ? and article_id = ? and created_at < ? and  created_at > ? ", claims.UserId, cr.ArticleID, time.Now().Format("2006-01-02 15:04:05"), time.Now().Format("2006-01-02")+" 00:00:00").Error
	if err == nil {
		res.FailWithMsg("成功", c)
		return
	}

	err = global.DB.Create(&models.UserArticleLookHistoryModel{
		UserID:    claims.UserId,
		ArticleID: cr.ArticleID,
	}).Error
	if err != nil {
		res.FailWithMsg("失败", c)
		return
	}

	res.FailWithMsg("创建浏览记录成功", c)
	return

}

文章管理-缓存

redis设置浏览记录等信息服务

services/redis_service/redis_article/enter.go

package redis_article

import (
	"blog_server/global"
	"blog_server/utils/date"
	"fmt"
	"github.com/sirupsen/logrus"
	"strconv"
)

//文章引入缓存
//文章浏览量,点赞数,收藏数
//以点赞为例:
//点赞的时候,在缓存里面记录一个 key, value,key 就是文章 id,value 就是点赞数
//查询文章的时候,从缓存里面取查这个点赞数,1	响应的时候,实际点赞数=数据库中点赞数+缓存中的点赞

type articleCacheType string

const (
	articleCacheLook    articleCacheType = "article_look_key"
	articleCacheDigg    articleCacheType = "article_digg_key"
	articleCacheCollect articleCacheType = "article_collect_key"
)

// ============缓存设置=====================
// 传入设置的key,如果increase为true就设置为数量加1,反之则为减1
func set(t articleCacheType, articleID uint, increase bool) {
	num, _ := global.Redis.HGet(string(t), strconv.Itoa(int(articleID))).Int()
	if !increase {
		num--
	} else {
		num++
	}

	global.Redis.HSet(string(t), strconv.Itoa(int(articleID)), num)

}

func SetCacheLook(articleID uint, increase bool) {
	set(articleCacheLook, articleID, increase)
}
func SetCacheDigg(articleID uint, increase bool) {
	set(articleCacheDigg, articleID, increase)
}
func SetCacheCollect(articleID uint, increase bool) {
	set(articleCacheCollect, articleID, increase)
}

// ============缓存获取====================
func get(t articleCacheType, articleID uint) int {
	num, _ := global.Redis.HGet(string(t), strconv.Itoa(int(articleID))).Int()
	return num
}

func GetCacheLook(articleID uint) int {
	return get(articleCacheLook, articleID)
}
func GetCacheDigg(articleID uint) int {
	return get(articleCacheDigg, articleID)
}
func GetCacheCollect(articleID uint) int {
	return get(articleCacheCollect, articleID)
}
func GetAll(t articleCacheType) (mps map[uint]int) {
	res, err := global.Redis.HGetAll(string(t)).Result()
	if err != nil {
		return
	}
	mps = make(map[uint]int)
	for key, nums := range res {
		intkey, err := strconv.Atoi(key)
		if err != nil {
			continue
		}
		intnum, err := strconv.Atoi(nums)
		if err != nil {
			continue
		}
		mps[uint(intkey)] = intnum
	}
	return

}
func GetAllCacheLook() (mps map[uint]int) {
	return GetAll(articleCacheLook)
}
func GetAllCacheDigg() (mps map[uint]int) {
	return GetAll(articleCacheDigg)
}
func GetAllCacheCollect() (mps map[uint]int) {
	return GetAll(articleCacheCollect)
}

// ============设置用户文章历史缓存====================

func SetUserArticleHistoryCache(articleID uint, userID uint) {
	key := fmt.Sprintf("histroy_%d", userID)
	field := fmt.Sprintf("%d", articleID)
	endTime := date.GetNowAfter()

	err := global.Redis.HSet(key, field, "").Err()
	if err != nil {
		logrus.Errorf("redis_%s", err)
		return
	}
	err = global.Redis.ExpireAt(key, endTime).Err()
	if err != nil {
		logrus.Errorf("redis_%s", err)
		return
	}
}
func GetUserArticleHistoryCache(articleID uint, userID uint) (ok bool) {
	key := fmt.Sprintf("histroy_%d", userID)
	field := fmt.Sprintf("%d", articleID)
	err := global.Redis.HGet(key, field).Err()
	if err != nil {
		return false
	}
	return true
}

// 清空数据
func Clear() {
	err := global.Redis.Del("article_look_key", "article_digg_key", "article_collect_key").Err()
	if err != nil {
		logrus.Error(err)
	}
}

时间辅助函数

utils/date/enter.go

package date

import "time"

// 基于当前时区的当天结束时间
func GetNowAfter() time.Time {
	//获取当前时间
	now := time.Now()
	//获取当前时区
	location := time.Local
	//设置今天的结束时间为23:59:59 基于当前时区
	endtime := time.Date(now.Year(), now.Month(), now.Day(), 23, 59, 59, 0, location)
	return endtime
}

将记录添加到各个接口中

api/article_api/article_detail.go
package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/services/redis_service/redis_article"
	"blog_server/utils/jwts"
	"github.com/gin-gonic/gin"
)

type ArticleDetailResponse struct {
	models.ArticleModel
	Username   string `json:"username"`
	UserAvatar string `json:"user_avatar"`
	Nickname   string `json:"nickname"`
}

func (ArticleApi) ArticleDetailView(c *gin.Context) {

	cr := middlware.GetBind[models.IdRequest](c)
	var article models.ArticleModel
	err := global.DB.Preload("UserModel").Take(&article, cr.ID).Error
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}

	claims, err := jwts.ParseTokenByGin(c)
	if err != nil {
		if article.Status != enum.ArticleStatusPublishes {
			res.FailWithMsg("文章不存在", c)
			return
		}

	}
	switch claims.RoleId {
	case enum.UserRole:
		if claims.UserId != article.UserID {
			//登录者看的不是自己的
			if article.Status != enum.ArticleStatusPublishes {
				res.FailWithMsg("文章不存在", c)
				return
			}

		}

	}
	//从缓存中获取浏览量和点赞数
	collectCount := redis_article.GetCacheCollect(article.ID)
	lookCount := redis_article.GetCacheLook(article.ID)
	diggCount := redis_article.GetCacheDigg(article.ID)
	article.DiggCount = article.DiggCount + diggCount
	article.LookCount = article.LookCount + lookCount
	article.CollectCount = article.CollectCount + collectCount
	res.OkWithData(ArticleDetailResponse{
		ArticleModel: article,
		Username:     article.UserModel.Username,
		UserAvatar:   article.UserModel.Avatar,
		Nickname:     article.UserModel.Nickname,
	}, c)
}
api/article_api/article_collect.go
package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/services/redis_service/redis_article"
	"blog_server/utils/jwts"
	"github.com/gin-gonic/gin"
	"gorm.io/gorm"
)

type ArticleCollectRequest struct {
	ArticleId uint `json:"article_id" binding:"required"`
	CollectID uint `json:"collect_id"`
}

func (ArticleApi) ArticleCollectView(c *gin.Context) {
	cr := middlware.GetBind[ArticleCollectRequest](c)
	var article models.ArticleModel
	err := global.DB.Take(&article, "status = ? and id = ?", enum.ArticleStatusPublishes, cr.ArticleId).Error //查询已发布的
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}
	var collectModel models.CollectModel
	claims, err := jwts.ParseTokenByGin(c)
	if cr.CollectID == 0 {
		//默认收藏夹

		err = global.DB.Take(&collectModel, "user_id =? and is_default = ?", claims.UserId, 1).Error
		if err != nil {
			//创建一个默认收藏夹
			collectModel.Title = "默认收藏夹"
			collectModel.UserID = claims.UserId
			collectModel.IsDefault = true
			global.DB.Create(&collectModel)

		}
		cr.CollectID = collectModel.ID
	} else {
		//判断收藏夹是否存在并且是否是自己创建的

		err = global.DB.Take(&collectModel, "user_id =?", claims.UserId).Error
		if err != nil {
			res.FailWithMsg("收藏夹不存在", c)
			return
		}
	}
	//判断是否收藏
	var articleCollect models.UserArticleCollectMode
	err = global.DB.Where(models.UserArticleCollectMode{
		UserID:    claims.UserId,
		ArticleID: cr.ArticleId,
		CollectID: cr.CollectID,
	}).Take(&articleCollect).Error
	if err != nil {
		//没有就收藏
		err = global.DB.Create(&models.UserArticleCollectMode{
			UserID:    claims.UserId,
			ArticleID: cr.ArticleId,
			CollectID: cr.CollectID,
		}).Error
		if err != nil {
			res.FailWithMsg("收藏失败", c)
			return
		}
		res.FailWithMsg("收藏成功", c)
		//对收藏夹进行加1
		redis_article.SetCacheCollect(cr.CollectID, true)
		global.DB.Model(&collectModel).Update("article_count", gorm.Expr("article_count + 1"))
		return
	}
	//取消收藏
	err = global.DB.Where(models.UserArticleCollectMode{
		UserID:    claims.UserId,
		ArticleID: cr.ArticleId,
		CollectID: cr.CollectID,
	}).Delete(&models.UserArticleCollectMode{}).Error
	if err != nil {
		res.FailWithMsg("取消收藏失败", c)
		return
	}
	res.FailWithMsg("取消收藏成功", c)
	//对收藏夹进行-1
	global.DB.Model(&collectModel).Update("article_count", gorm.Expr("article_count - 1"))
	//TODO:收藏数同步缓存
	redis_article.SetCacheCollect(cr.CollectID, false)
	return

}
api/article_api/article_digg.go
package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/services/redis_service/redis_article"
	"blog_server/utils/jwts"
	"github.com/gin-gonic/gin"
)

func (ArticleApi) ArticleDiggView(c *gin.Context) {
	cr := middlware.GetBind[models.IdRequest](c)

	var article models.ArticleModel
	err := global.DB.Take(&article, "status = ? and id = ?", enum.ArticleStatusPublishes, cr.ID).Error //查询已发布的
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}

	claims := jwts.GetClaims(c)
	//查一下之前有没有点赞
	var UserDiggArticle models.ArticleDiggModel
	err = global.DB.Take(&UserDiggArticle, "user_id = ? and article_id = ?", claims.UserId, article.ID).Error
	if err != nil {
		//不存在-》点赞
		err = global.DB.Create(&models.ArticleDiggModel{
			UserID:    claims.UserId,
			ArticleID: article.ID,
		}).Error
		if err != nil {
			res.FailWithMsg("点赞失败", c)
			return
		}
		//TODO:将点赞添加到缓存
		redis_article.SetCacheDigg(cr.ID, true)
		res.OkWithMsg("点赞成功", c)
		return
	}

	global.DB.Model(models.ArticleDiggModel{}).Delete("user_id = ? and article_id = ?", claims.UserId, article.ID)
	redis_article.SetCacheDigg(cr.ID, false)
	res.OkWithMsg("取消点赞成功", c)
	return
}
api/article_api/article_list.go
package article_api

import (
	"blog_server/common"
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/services/redis_service/redis_article"
	"blog_server/utils/jwts"
	"blog_server/utils/sql"
	"fmt"
	"github.com/gin-gonic/gin"
)

//可以查某个用户发布的文章,只能查已发布的,不需要登录,支持分类查询
//	可以查某个人用户收藏的文章,前提是这个用户开了对应隐私设置
//用户侧 查自己发布的文章,只能查已发布的,,	需要登录, 支持分类查询
//	也能查自己收藏的文章,不会受到自己的隐私设置
//	支持按照状态查询,已发布,草稿箱,待审核
//管理员侧 查全部,支持按照用户搜索,状态过滤,文章标题模糊匹配,分类过滤

type ArticleListRequest struct {
	common.PageInfo
	Type       int8                   `form:"type" binding:"required,oneof=1 2 3"` //1-用户查别人的, 2-查自己的 3-管理员查询
	CategoryID *uint                  `form:"category_id"`
	UserID     uint                   `form:"user_id"`
	Status     enum.ArticleStatusType `form:"status"` //状态 草稿,审核中,已发布
}

type ArticleListResponse struct {
	models.ArticleModel
	UserTop       bool    `json:"user_top"`  //是否是用户置顶
	AdminTop      bool    `json:"admin_top"` //是否是管理员置顶
	CategoryTitle *string `json:"category_title"` //使用指针,可以使在json序列化中进行判断,“"为空值。nill为没有传递
	UserNickname  string  `json:"user_nickname"`
	UserAvatar    string  `json:"user_avatar"`
}

func (ArticleApi) ArticleListView(c *gin.Context) {

	var topArticleIdList []uint //用户置顶列表
	var orderColumnMap = map[string]bool{
		"look_count desc":    true,
		"digg_count desc":    true,
		"comment_count desc": true,
		"collect_count desc": true,
		"look_count":         true,
		"digg_count":         true,
		"comment_count":      true,
		"collect_count":      true,
	}
	cr := middlware.GetBind[ArticleListRequest](c)
	switch cr.Type {
	case 1:
		//查别人的用户id就是必填的
		if cr.UserID == 0 {
			res.FailWithMsg("用户id必填", c)
			return
		}
		if cr.Page > 2 || cr.Limit > 10 {
			res.FailWithMsg("查询更多请登录", c)
			return
		}
		cr.Status = 0
		cr.Order = ""
	case 2:
		//查自己
		claims, err := jwts.ParseTokenByGin(c)
		if err != nil {
			res.FailWithMsg("请登录", c)
			return
		}
		cr.UserID = claims.UserId

	case 3:
		//管理员
		claims, err := jwts.ParseTokenByGin(c)
		if !(err == nil && claims.RoleId == enum.AdminRole) {
			res.FailWithMsg("角色错误", c)
			return
		}
	}
	if cr.Order != "" {
		_, ok := orderColumnMap[cr.Order]
		if !ok {
			res.FailWithMsg("不支持的排序方式", c)
			return
		}
	}

	//处理用户置顶
	var userTopMap = map[uint]bool{}
	var adminTopMap = map[uint]bool{}
	if cr.UserID != 0 {
		var userTopArticleList []models.UserTopArticleModel
		global.DB.Preload("UserModel").Order("created_at desc").Find(&userTopArticleList, "user_id = ?", cr.UserID)

		for _, i2 := range userTopArticleList {
			topArticleIdList = append(topArticleIdList, i2.ArticleID)
			if i2.UserModel.Role == enum.AdminRole {
				adminTopMap[i2.ArticleID] = true
			}
			userTopMap[i2.ArticleID] = true
		}
	}

	//判断是否有置顶,有的话话再按照置顶列表进行排序,否则就按照时间倒叙排序
	var options = common.Options{
		Likes:        []string{"title"},
		PageInfo:     cr.PageInfo,
		DefaultOrder: "created_at desc",
		PreLoads:     []string{"CategoryModel", "UserModel"},
	}
	if len(topArticleIdList) > 0 {
		options.DefaultOrder = fmt.Sprintf("%s,created_at desc", sql.ConvertSliceOrderSql(topArticleIdList)) // [1 2 3] = >(1,2,3)或者 id= 1 desc,id = 2 desc 使用辅助函数实现ConvertSliceOrderSql
	}

	_list, count, _ := common.ListQuery(models.ArticleModel{
		UserID:     cr.UserID,
		CategoryID: cr.CategoryID,
		Status:     cr.Status,
	}, options)

	var list = make([]ArticleListResponse, 0)
	//从缓存中获取浏览量和点赞数
	collectMap := redis_article.GetAllCacheCollect()
	lookMap := redis_article.GetAllCacheLook()
	diggMap := redis_article.GetAllCacheDigg()
	for _, model := range _list {
		model.Content = ""
		model.DiggCount = model.DiggCount + diggMap[model.ID]
		model.LookCount = model.LookCount + lookMap[model.ID]
		model.CollectCount = model.CollectCount + collectMap[model.ID]
		data := ArticleListResponse{
			ArticleModel: model,
			UserTop:      userTopMap[model.ID],
			AdminTop:     adminTopMap[model.ID],
			UserNickname: model.UserModel.Nickname,
			UserAvatar:   model.UserModel.Avatar,
		}
		//如果有关联的分类表,就将分类表中的title传递过来(使用指针,如果是)
		if model.CategoryModel != nil {
			data.CategoryTitle = &model.CategoryModel.Title
		}
		list = append(list, data)
	}

	res.OkWithList(list, count, c)

}
api/article_api/article_look.go

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/services/redis_service/redis_article"
	"blog_server/utils/jwts"
	"github.com/gin-gonic/gin"
	"github.com/sirupsen/logrus"
	"time"
)

//文章的浏览量

type ArticleLookRequest struct {
	ArticleID  uint `json:"article_id" binding:"required"`
	TimeSecond int  `json:"time_second"` //读完文章的用时,暂时不使用,
}

func (ArticleApi) ArticleLookView(c *gin.Context) {
	cr := middlware.GetBind[ArticleLookRequest](c)

	//TODO:未登录用户怎么处理浏览
	claims, err := jwts.ParseTokenByGin(c)
	if err != nil {
		res.OkWithMsg("未登录用户", c)
		return
	}

	//引入缓存,当这个用户请求这个文章之后,将用户的id和文章id作为key放进缓存,在这里进行判断,如果存在就直接返回,避免重复查表
	if redis_article.GetUserArticleHistoryCache(cr.ArticleID, claims.UserId) {
		logrus.Infof("用户%d查询的文章%d在缓存里面", claims.UserId, cr.ArticleID)
		res.OkWithMsg("成功", c)
		return
	}

	var article models.ArticleModel
	err = global.DB.Take(&article, "status = ? and id = ?", enum.ArticleStatusPublishes, cr.ArticleID).Error //查询已发布的
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}

	//查这个文章今天有没有在足迹里面
	var history models.UserArticleLookHistoryModel
	err = global.DB.Debug().Take(&history, "user_id = ? and article_id = ? and created_at < ? and  created_at > ? ", claims.UserId, cr.ArticleID, time.Now().Format("2006-01-02 15:04:05"), time.Now().Format("2006-01-02")+" 00:00:00").Error
	if err == nil {
		res.FailWithMsg("成功", c)
		return
	}

	err = global.DB.Create(&models.UserArticleLookHistoryModel{
		UserID:    claims.UserId,
		ArticleID: cr.ArticleID,
	}).Error
	if err != nil {
		res.FailWithMsg("失败", c)
		return
	}
	//设置浏览量到redis
	redis_article.SetCacheLook(cr.ArticleID, true)
	//设置用户文章历史缓存redis
	redis_article.SetUserArticleHistoryCache(cr.ArticleID, claims.UserId)

	res.FailWithMsg("创建浏览记录成功", c)
	return

}

文章管理-文章删除

删除关联表的钩子函数

package models

import (
	"blog_server/global"
	"blog_server/models/ctype"
	"blog_server/models/enum"
	_ "embed"
	"github.com/sirupsen/logrus"
	"gorm.io/gorm"
)

// 文章表
type ArticleModel struct {
	Model
	Title        string                 `gorm:"size:64" json:"title"`
	Abstract     string                 `gorm:"size:255" json:"abstract"` //简介
	Content      string                 `gorm:"size:255" json:"content,omitempty"`
	CategoryID   *uint                  `json:"category_id"`                   //分类ID
	TagList      ctype.List             `gorm:"type:longtext" json:"tag_list"` //标签列表
	Cover        string                 `gorm:"size:255" json:"cover"`         //封面
	UserID       uint                   `json:"user_id"`
	UserModel    UserModel              `gorm:"foreignKey:user_id" json:"-"`
	LookCount    int                    `json:"look_count"`    //浏览量
	DiggCount    int                    `json:"digg_count"`    //点赞数
	CommentCount int                    `json:"comment_count"` //评论数
	CollectCount int                    `json:"collect_count"` //收藏数
	OpenComment  bool                   `json:"open_comment"`  //开启评论
	Status       enum.ArticleStatusType `json:"status"`        //状态 草稿,审核中,已发布
}

//go:embed mappings/article_mapping.json
var articleMapping string

func (ArticleModel) Mapping() string {
	return articleMapping
}

func (ArticleModel) Index() string {
	return "article_index"
}

// 删除文章的钩子函数
func (a *ArticleModel) BeforeDelete(tx *gorm.DB) (err error) {

	//评论
	var commentList []CommentModel
	global.DB.Take(&commentList, "article_id = ? ", a.ID).Delete(&commentList)

	//点赞
	var diggtList []ArticleDiggModel
	global.DB.Take(&diggtList, "article_id = ? ", a.ID).Delete(&diggtList)

	//收藏
	var collectList []UserArticleCollectMode
	global.DB.Take(&collectList, "article_id = ? ", a.ID).Delete(&collectList)
	//置顶
	var topList []UserTopArticleModel
	global.DB.Take(&topList, "article_id = ? ", a.ID).Delete(&topList)
	//浏览
	var lookList []UserArticleLookHistoryModel
	global.DB.Take(&lookList, "article_id = ? ", a.ID).Delete(&lookList)

	logrus.Infof("删除关联评论%d条", len(commentList))
	logrus.Infof("删除关联点赞%d条", len(diggtList))
	logrus.Infof("删除关收藏%d条", len(collectList))
	logrus.Infof("删除关联置顶%d条", len(topList))
	logrus.Infof("删除关联浏览%d条", len(lookList))
	return

}

用户删除

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/utils/jwts"
	"github.com/gin-gonic/gin"
)

//1.管理员可以删任意的文章,如果删的是用户的,应该给用户发	个系统消息
//2.用户只能删自己发布的文章
//删文章如果是物理删除,就需要删除对应的关联记录
//文章点赞,文章收藏,文章置顶,文章评论,文章浏览

// 用户删除
func (ArticleApi) ArticleRemoveUserView(c *gin.Context) {
	cr := middlware.GetBind[models.IdRequest](c)

	claims := jwts.GetClaims(c)
	var model models.ArticleModel
	err := global.DB.Take(&model, "user_id = ? and id = ?", claims.UserId, cr.ID).Error
	if err != nil {
		res.FailWithMsg("文章不存在", c)
		return
	}

	err = global.DB.Delete(&model).Error
	if err != nil {
		res.FailWithMsg("删除文章失败", c)
		return
	}

	res.OkWithMsg("删除成功", c)

}

管理员删除

package article_api

import (
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"fmt"
	"github.com/gin-gonic/gin"
)

//1.管理员可以删任意的文章,如果删的是用户的,应该给用户发	个系统消息
//2.用户只能删自己发布的文章
//删文章如果是物理删除,就需要删除对应的关联记录
//文章点赞,文章收藏,文章置顶,文章评论,文章浏览

// 管理员删除
func (ArticleApi) ArticleRemoveView(c *gin.Context) {
	cr := middlware.GetBind[models.IdRemoveRequest](c)

	var list []models.ArticleModel
	err := global.DB.Find(&list, "id in  ?", cr.IDList).Error

	if len(list) > 0 {
		err = global.DB.Delete(&list).Error
		if err != nil {
			res.FailWithMsg("删除文章失败", c)
			return
		}
	}

	res.OkWithMsg(fmt.Sprintf("成功删除%d条数据", len(list)), c)

}

文章管理-数据定时同步

使用cron模块做定时任务

services/cron_service/enter.go
package cron_service

import (
	"github.com/robfig/cron/v3"
	"time"
)

func Cron() {
	//crontab := cron.New()  默认从分开始进行时间调度
	timezone, _ := time.LoadLocation("Asia/Shanghai")
	crontab := cron.New(cron.WithSeconds(), cron.WithLocation(timezone))

	//每天两点同步文章数据
	crontab.AddFunc("0 0 2 * * *", SyncArticle)

	crontab.Start()

}
services/cron_service/sync_article.go

package cron_service

import (
	"blog_server/global"
	"blog_server/models"
	"blog_server/services/redis_service/redis_article"
	"github.com/sirupsen/logrus"
	"gorm.io/gorm"
)

func SyncArticle() {

	//这里可能会有一个问题,当删除的文章数据达到一定量级之后,在获取到该数据之后,执行了清空数据,在清空的同时会有新的数据产生,那么就会导致数据不准确,
	//但是小的文章数据时是没有问题的,如果解决这个问题是可以再优化的,比如加锁,或者判断增量数据的校验机制
	collectMap := redis_article.GetAllCacheCollect()
	lookMap := redis_article.GetAllCacheLook()
	diggMap := redis_article.GetAllCacheDigg()

	var list []models.ArticleModel
	for _, model := range list {
		collect := collectMap[model.ID]
		digg := diggMap[model.ID]
		look := lookMap[model.ID]
		if collect == 0 || look == 0 || digg == 0 {
			continue
		}
		err := global.DB.Model(&model).Updates(map[string]any{
			"look_count":    gorm.Expr("look_count + ?", look),
			"digg_count":    gorm.Expr("digg_count + ?", digg),
			"collect_count": gorm.Expr("collect_count + ?", collect),
		}).Error

		if err != nil {
			logrus.Errorf("更新失败:%s", err)
			continue
		}
		logrus.Infof("%s redis-mysql数据更新成功", model.Title)

	}

	//走完之后清空
	redis_article.Clear()

}

使用

main.go

package main

import (
	"blog_server/core"
	"blog_server/flags"
	"blog_server/global"
	"blog_server/router"
	"blog_server/services/cron_service"
)

func main() {
	flags.Parse()
	core.InitIPDB()
	global.Config = core.ReadConf()
	core.InitLogrus()
	global.DB = core.InitDB()
	global.Redis = core.InitRedis()
	global.ESClient = core.EsConnect()
	flags.Run()
	core.InitMysqlEs()
	//启动定时任务
	cron_service.Cron()
	//启动web程序
	router.Run()
}

文章管理-文章浏览历史列表+文章浏览历史删除

优化校验代码

utils/validate/enter.go

package validate

import (
	"fmt"
	"github.com/gin-gonic/gin/binding"
	"github.com/go-playground/locales/zh"
	ut "github.com/go-playground/universal-translator"
	"github.com/go-playground/validator/v10"
	zh_translations "github.com/go-playground/validator/v10/translations/zh"

	"reflect"
	"strings"
)

var trans ut.Translator

func init() {
	// 创建翻译器
	uni := ut.New(zh.New())
	trans, _ = uni.GetTranslator("zh")

	// 注册翻译器
	v, ok := binding.Validator.Engine().(*validator.Validate)
	if ok {
		_ = zh_translations.RegisterDefaultTranslations(v, trans)
	}

	v.RegisterTagNameFunc(func(field reflect.StructField) string {
		label := field.Tag.Get("label")
		if label == "" {
			label = field.Name
		}
		name := field.Tag.Get("json")
		if name == "" {
			name = field.Tag.Get("form")
		}
		return fmt.Sprintf("%s---%s", name, label)
	})
}

/*
{
  "name": "name参数必填",
}
*/

func ValidateErr(err error) (data map[string]any, msg string) {
	errs, ok := err.(validator.ValidationErrors)
	if !ok {
		msg = err.Error()
		return
	}
	data = make(map[string]any)
	var msgList []string
	for _, e := range errs {
		m := e.Translate(trans)
		_list := strings.Split(m, "---")
		data[_list[0]] = _list[1]
		//m[_list[0]] = _list[1]
		msgList = append(msgList, _list[1])
	}
	msg = strings.Join(msgList, ";")
	return
}

接口函数

api/article_api/article_look.go添加函数


type ArticleLookListRequest struct {
	common.PageInfo
	UserID uint  `form:"user_id"`
	Type   uint8 `form:"type" binding:"required,oneof=1 2"` //1用户 2管理员
}
type ArticleLookListResponse struct {
	Id        uint      `json:"id"`        //浏览记录的id
	LookDate  time.Time `json:"look_date"` //浏览的时间
	Title     string    `json:"title"`
	Cover     string    `json:"cover"`
	Nickname  string    `json:"nickname"`
	Avatar    string    `json:"avatar"`
	UserID    uint      `json:"user_id"`
	ArticleId uint      `json:"article_id"`
}

func (ArticleApi) ArticleLookListView(c *gin.Context) {
	cr := middlware.GetBind[ArticleLookListRequest](c)
	claims := jwts.GetClaims(c)

	switch cr.Type {
	case 1:
		cr.UserID = claims.UserId

	}
	_list, count, _ := common.ListQuery(models.UserArticleLookHistoryModel{UserID: cr.UserID}, common.Options{
		PageInfo: cr.PageInfo,
		PreLoads: []string{"UserModel", "ArticleModel"},
	})

	var list = make([]ArticleLookListResponse, 0)
	for _, model := range _list {
		list = append(list, ArticleLookListResponse{
			Id:        model.ID,
			LookDate:  model.CreatedAt,
			Title:     model.ArticleModel.Title,
			Cover:     model.ArticleModel.Cover,
			Nickname:  model.UserModel.Nickname,
			Avatar:    model.UserModel.Avatar,
			UserID:    model.UserID,
			ArticleId: model.ArticleID,
		})
	}
	res.OkWithList(list, count, c)
}
func (ArticleApi) ArticleLookRemoveView(c *gin.Context) {
	cr := middlware.GetBind[models.IdRemoveRequest](c)
	claims := jwts.GetClaims(c)
	var list []models.UserArticleLookHistoryModel
	global.DB.Find(&list, "user_id = ? and id in ?", claims.UserId, cr.IDList)

	if len(list) > 0 {
		err := global.DB.Delete(&list).Error
		if err != nil {
			logrus.Errorf("浏览历史删除失败:%s", err)
			res.FailWithMsg("浏览历史删除失败", c)
			return
		}
	}
	res.FailWithMsg(fmt.Sprintf("浏览历史删除成功,共%d条", len(list)), c)
}

文章管理-分类(增删改查)

1.增加关联关系

models/article_model.go

// 文章表
type ArticleModel struct {
	Model
	Title         string                 `gorm:"size:64" json:"title"`
	Abstract      string                 `gorm:"size:255" json:"abstract"` //简介
	Content       string                 `gorm:"size:255" json:"content,omitempty"`
	CategoryID    *uint                  `json:"category_id"` //分类ID
	CategoryModel *CategoryModel         `gorm:"foreignKey:category_id" json:"-"`
	TagList       ctype.List             `gorm:"type:longtext" json:"tag_list"` //标签列表
	Cover         string                 `gorm:"size:255" json:"cover"`         //封面
	UserID        uint                   `json:"user_id"`
	UserModel     UserModel              `gorm:"foreignKey:user_id" json:"-"`
	LookCount     int                    `json:"look_count"`    //浏览量
	DiggCount     int                    `json:"digg_count"`    //点赞数
	CommentCount  int                    `json:"comment_count"` //评论数
	CollectCount  int                    `json:"collect_count"` //收藏数
	OpenComment   bool                   `json:"open_comment"`  //开启评论
	Status        enum.ArticleStatusType `json:"status"`        //状态 草稿,审核中,已发布
}

models/category_model.go

// 文章分类表
type CategoryModel struct {
	Model
	Title       string          `json:"title"`
	UserID      uint            `json:"user_id"`
	UserModel   UserModel       `gorm:"foreignKey:user_id" json:"-"`
	ArticleList []*ArticleModel `gorm:"foreignKey:category_id" json:"-"`
}

接口的增删改查

api/article_api/article_category.go

package article_api

import (
	"blog_server/common"
	"blog_server/common/res"
	"blog_server/global"
	"blog_server/middlware"
	"blog_server/models"
	"blog_server/models/enum"
	"blog_server/utils/jwts"
	"fmt"
	"github.com/gin-gonic/gin"
)

//=====分类的创建和更新,如果如果有ID就是更新,没有ID就是创建=====

type CategoryCreateRequest struct {
	ID    uint   `json:"id"`
	Title string `json:"title" binding:"required"`
}

func (ArticleApi) CategoryCreateView(c *gin.Context) {
	cr := middlware.GetBind[CategoryCreateRequest](c)
	claims := jwts.GetClaims(c)

	var model models.CategoryModel

	if cr.ID == 0 {
		//创建
		err := global.DB.Take(&model, "user_id = ? and title = ?", claims.UserId, cr.Title).Error
		if err == nil {
			res.FailWithMsg("分类名称重复", c)
			return
		}
		err = global.DB.Create(&models.CategoryModel{
			Title:  cr.Title,
			UserID: claims.UserId,
		}).Error
		if err != nil {
			res.FailWithMsg("创建分类失败", c)
			return
		}
		res.OkWithMsg("创建分类成功", c)
		return

	}
	err := global.DB.Take(&model, "user_id = ? and id = ?", claims.UserId, cr.ID).Error
	if err != nil {
		res.FailWithMsg("分类不存在", c)
		return
	}

	err = global.DB.Model(&model).Update("title", cr.Title).Error
	if err != nil {
		res.FailWithMsg("更新分类失败", c)
		return
	}
	res.OkWithMsg("更新分类成功", c)
}

// =====分类列表=====
type CategoryListRequest struct {
	common.PageInfo
	UserID uint `form:"user_id"`
	Type   int8 `form:"type" binding:"required,oneof=1 2 3"` //1-查自己 2-查别人 3-后台
}
type CategoryListResponse struct {
	models.CategoryModel
	ArticleCount int    `json:"article_count"`
	Nickname     string `json:"nickname,omitempty"`
	Avatar       string `json:"avatar,omitempty"`
}

func (ArticleApi) CategoryListView(c *gin.Context) {
	cr := middlware.GetBind[CategoryListRequest](c)

	var preload = []string{"ArticleList"}

	switch cr.Type {
	case 1:
		claims, err := jwts.ParseTokenByGin(c)
		if err != nil {
			res.FailWithError(err, c)
			return
		}
		cr.UserID = claims.UserId
	case 2:
	case 3:
		claims, err := jwts.ParseTokenByGin(c)
		if err != nil {
			res.FailWithError(err, c)
			return
		}
		if claims.RoleId != enum.AdminRole {
			res.FailWithMsg("权限不足!", c)
			return
		}
		preload = append(preload, "UserModel")

	}

	_list, count, _ := common.ListQuery(models.CategoryModel{
		UserID: cr.UserID,
	}, common.Options{
		PageInfo: cr.PageInfo,
		Likes:    []string{"title"},
		PreLoads: preload,
	})

	var list = make([]CategoryListResponse, 0)
	for _, i2 := range _list {
		list = append(list, CategoryListResponse{
			CategoryModel: i2,
			ArticleCount:  len(i2.ArticleList),
			Nickname:      i2.UserModel.Nickname,
			Avatar:        i2.UserModel.Avatar,
		})
	}

	res.OkWithList(list, count, c)
}

func (ArticleApi) CategoryRemoveView(c *gin.Context) {
	var cr = middlware.GetBind[models.IdRemoveRequest](c)
	var list []models.CategoryModel
	query := global.DB.Where("id in ?", cr.IDList)
	claims := jwts.GetClaims(c)
	if claims.RoleId != enum.AdminRole {
		query.Where("user_id = ?", claims.UserId)
	}
	global.DB.Where(query).Find(&list)
	if len(list) > 0 {
		err := global.DB.Delete(&list).Error
		if err != nil {
			res.FailWithMsg("删除分类失败!", c)
			return
		}
	}
	msg := fmt.Sprintf("删除分类成功,共删除%d条", len(list))
	res.OkWithMsg(msg, c)

}

路由配置

router/artile_router.go

package router

import (
	"blog_server/api"
	"blog_server/api/article_api"
	"blog_server/middlware"
	"blog_server/models"
	"github.com/gin-gonic/gin"
)

func ArticleRouter(rg *gin.RouterGroup) {
	app := api.App.ArticleApi
	rg.POST("article", middlware.AuthMiddleware, middlware.BindMiddleware[article_api.ArticleCreateRequest], app.ArticleCreateApiView)
	rg.PUT("article", middlware.AuthMiddleware, middlware.BindMiddleware[article_api.ArticleUpdateRequest], app.ArticleUpdateApiView)
	rg.GET("article", middlware.BindQueryMiddleware[article_api.ArticleListRequest], app.ArticleListView)
	rg.GET("article/:id", middlware.BindUriMiddleware[models.IdRequest], app.ArticleDetailView)
	rg.POST("article/examine", middlware.AdminMiddleware, middlware.BindMiddleware[article_api.ArticleExamineRequest], app.ArticleExamineView)
	rg.GET("article/digg/:id", middlware.AuthMiddleware, middlware.BindUriMiddleware[models.IdRequest], app.ArticleDiggView)
	rg.POST("article/collect", middlware.AuthMiddleware, middlware.BindMiddleware[article_api.ArticleCollectRequest], app.ArticleCollectView)
	rg.POST("article/look", middlware.BindMiddleware[article_api.ArticleLookRequest], app.ArticleLookView)
	rg.DELETE("article/:id", middlware.AuthMiddleware, middlware.BindUriMiddleware[models.IdRequest], app.ArticleRemoveUserView)
	rg.DELETE("article", middlware.AdminMiddleware, middlware.BindMiddleware[models.IdRemoveRequest], app.ArticleRemoveView)
	rg.GET("article/look", middlware.AuthMiddleware, middlware.BindQueryMiddleware[article_api.ArticleLookListRequest], app.ArticleLookListView)
	rg.DELETE("article/look", middlware.AuthMiddleware, middlware.BindMiddleware[models.IdRemoveRequest], app.ArticleLookRemoveView)

	rg.POST("article/category", middlware.AuthMiddleware, middlware.BindMiddleware[article_api.CategoryCreateRequest], app.CategoryCreateView)
	rg.GET("article/category", middlware.BindQueryMiddleware[article_api.CategoryListRequest], app.CategoryListView)
	rg.DELETE("article/category", middlware.AuthMiddleware, middlware.BindMiddleware[models.IdRemoveRequest], app.CategoryRemoveView)

}

遗留问题

1、token过期的判断条件没有写全

如果token过期,在其他接口时是直接报错,但是并不知道原因

创建docker容器配置es

1创建容器挂载目录

mkdir -p /opt/es/config & mkdir -p /opt/es/data & mkdir -p /opt/es/plugins

chmod 777 /opt/es/data

修改配置文件

echo "http.host:0.0.0.0" > /opt/es/congfig/elasticsearch.yml

#开启跨域支持
http.cors.enabled: true
#允许所有人跨域访问
http.cors.allow-origin: "*"
 

创建容器

docker run --name es -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" -e ES_JAVA_OPTS="-Xms84m -Xmx512m" -v /opt/es/config/elasticsearch.yaml:/usr/share/elasticsearch/config/elasticsearch.yml -v /opt/es/data:/uer/share/elasticsearch/data/ -v /opt/es/plugins:/usr/share/elasticsearch/plugins -d elasticsearch:7.12.0

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐