blogx_2
路由r.PUT("user/admin", middlware.AdminMiddleware, app.AdminUserInfoUpdateView)文章管理-es&mysql同步sync.goyamlriver_conf.goes_conf.goinit_es.goinit_mysql_es.gomain.go参考一下gomode文章管理-修改tag自定义数据类型mode
·
用户管理-管理员修改信息
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
更多推荐



所有评论(0)