├── .dockerignore ├── .gitignore ├── auth ├── main.go └── model │ └── user.go ├── treehole ├── main.go └── model │ └── division.go ├── danke ├── data │ └── data.go ├── config │ └── config.go ├── model │ ├── url_hostname_whitelist.go │ ├── teacher.go │ ├── achievement.go │ ├── init.go │ ├── course_group.go │ ├── course.go │ └── review.go ├── api │ ├── routes.go │ ├── course.go │ ├── course_group.go │ └── review.go ├── main.go ├── schema │ ├── course_group.go │ ├── course.go │ └── review.go └── docs │ └── swagger.yaml ├── notification └── main.go ├── common ├── paging.go ├── log.go ├── time.go ├── cache.go ├── config.go ├── middlewares.go ├── errors.go ├── sensitive │ ├── utils_test.go │ ├── utils.go │ └── api.go ├── user.go └── validate.go ├── image_hosting ├── config │ └── config.go ├── model │ ├── image.go │ └── init.go ├── schema │ └── schema.go ├── README.md ├── utils │ └── utils.go ├── api │ ├── get_image.go │ └── upload_image.go └── main.go ├── .github └── workflows │ ├── danke.yml │ ├── image_hosting.yml │ └── main.yml ├── Dockerfile ├── docker-compose.yml ├── danke_utils ├── main.go └── teacher_table.go ├── README.md ├── go.mod └── go.sum /.dockerignore: -------------------------------------------------------------------------------- 1 | .idea -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | *.exe 3 | *.db 4 | *.sqlite 5 | *.sqlite3 6 | *.http 7 | -------------------------------------------------------------------------------- /auth/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Println("Hello, auth!") 7 | } 8 | -------------------------------------------------------------------------------- /treehole/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Println("Hello, treehole!") 7 | } 8 | -------------------------------------------------------------------------------- /danke/data/data.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | _ "embed" 5 | ) 6 | 7 | //go:embed cedict_ts.u8 8 | var CreditTs []byte 9 | -------------------------------------------------------------------------------- /notification/main.go: -------------------------------------------------------------------------------- 1 | package notification 2 | 3 | import "fmt" 4 | 5 | func main() { 6 | fmt.Println("Hello, notification!") 7 | } 8 | -------------------------------------------------------------------------------- /danke/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | _ "github.com/opentreehole/backend/common" 5 | ) 6 | 7 | // add custom config here 8 | -------------------------------------------------------------------------------- /danke/model/url_hostname_whitelist.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type UrlHostnameWhitelist struct { 4 | ID int `json:"id" gorm:"primaryKey"` 5 | Hostname string `json:"hostname" gorm:"size:255;not null"` 6 | } 7 | -------------------------------------------------------------------------------- /common/paging.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | type PagedResponse[T any, U any] struct { 4 | Items []*T `json:"items"` 5 | Page int `json:"page"` 6 | PageSize int `json:"page_size"` 7 | Extra U `json:"extra,omitempty"` 8 | } 9 | -------------------------------------------------------------------------------- /danke/model/teacher.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Teacher struct { 4 | ID int `json:"id"` 5 | Name string `json:"name" gorm:"not null"` // 教师姓名 6 | CourseGroups []*CourseGroup `gorm:"many2many:teacher_course_groups;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 7 | } -------------------------------------------------------------------------------- /image_hosting/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | ) 6 | 7 | const ( 8 | EnvHostName = "HOST_NAME" 9 | ) 10 | 11 | var defaultConfig = map[string]string{ 12 | EnvHostName: "localhost:8000", 13 | } 14 | 15 | func init() { 16 | viper.AutomaticEnv() 17 | for k, v := range defaultConfig { 18 | viper.SetDefault(k, v) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /image_hosting/model/image.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "gorm.io/gorm" 5 | ) 6 | 7 | type ImageTable struct { 8 | gorm.Model 9 | ImageIdentifier string `json:"image_identifier" gorm:"uniqueIndex;size:20"` 10 | OriginalFileName string `json:"original_file_name" gorm:"index"` 11 | ImageType string `json:"image_type"` 12 | ImageFileData []byte `json:"image_file_data"` 13 | } 14 | -------------------------------------------------------------------------------- /.github/workflows/danke.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | paths: 6 | - 'danke/**' 7 | - 'common/**' 8 | - '.github/workflows/danke.yml' 9 | - '.github/workflows/common.yml' 10 | - '.github/workflows/main.yml' 11 | - 'Dockerfile' 12 | - 'go.mod' 13 | 14 | jobs: 15 | docker-build-danke: 16 | uses: ./.github/workflows/main.yml 17 | with: 18 | service_name: danke 19 | secrets: inherit -------------------------------------------------------------------------------- /.github/workflows/image_hosting.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | paths: 6 | - 'image_hosting/**' 7 | - 'common/**' 8 | - '.github/workflows/image_hosting.yml' 9 | - '.github/workflows/common.yml' 10 | - '.github/workflows/main.yml' 11 | - 'Dockerfile' 12 | - 'go.mod' 13 | - 'go.sum' 14 | 15 | jobs: 16 | docker-build-image-hosting: 17 | uses: ./.github/workflows/main.yml 18 | with: 19 | service_name: image_hosting 20 | secrets: inherit -------------------------------------------------------------------------------- /image_hosting/model/init.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | . "github.com/opentreehole/backend/common" 5 | "github.com/spf13/viper" 6 | "gorm.io/driver/mysql" 7 | "gorm.io/gorm" 8 | ) 9 | 10 | var DB *gorm.DB 11 | 12 | func Init() { 13 | var err error 14 | dbUrl := viper.GetString(EnvDBUrl) 15 | source := mysql.Open(dbUrl) 16 | DB, err = gorm.Open(source, GormConfig) 17 | if err != nil { 18 | panic(err) 19 | } 20 | err = DB.AutoMigrate(&ImageTable{}) 21 | if err != nil { 22 | panic(err) 23 | } 24 | 25 | } 26 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.22-alpine as builder 2 | 3 | ARG SERVICE_NAME 4 | 5 | WORKDIR /app 6 | 7 | COPY go.mod go.sum ./ 8 | 9 | RUN apk add --no-cache ca-certificates tzdata && \ 10 | go mod download 11 | 12 | COPY . . 13 | 14 | RUN go build -ldflags "-s -w" -tags netgo -o backend ./$SERVICE_NAME/main.go 15 | 16 | FROM alpine 17 | 18 | WORKDIR /app 19 | 20 | COPY --from=builder /app/backend /app/ 21 | COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo 22 | 23 | VOLUME ["/app/data"] 24 | 25 | ENV TZ=Asia/Shanghai 26 | ENV MODE=prod 27 | ENV LOG_LEVEL=info 28 | 29 | EXPOSE 8000 30 | 31 | ENTRYPOINT ["./backend"] -------------------------------------------------------------------------------- /treehole/model/division.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | type Division struct { 8 | // 分区 id 9 | ID int `json:"id" gorm:"primaryKey"` 10 | 11 | // 创建时间:只有管理员能创建分区 12 | CreatedAt time.Time `json:"time_created" gorm:"not null"` 13 | 14 | // 更新时间:只有管理员能更新分区,包括修改分区名称、分区详情、置顶的树洞 15 | UpdatedAt time.Time `json:"time_updated" gorm:"not null"` 16 | 17 | // 分区名称 18 | Name string `json:"name" gorm:"unique;size:10"` 19 | 20 | // 分区详情 21 | Description string `json:"description" gorm:"size:64"` 22 | 23 | // 置顶的树洞 id,按照顺序 24 | Pinned []int `json:"-" gorm:"serializer:json;not null;default:\"[]\""` 25 | } 26 | -------------------------------------------------------------------------------- /common/log.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "log/slog" 6 | "os" 7 | ) 8 | 9 | var Logger = slog.New(slog.NewJSONHandler(os.Stdout, nil)) 10 | 11 | type mLogger struct { 12 | *slog.Logger 13 | } 14 | 15 | func (l mLogger) Printf(message string, args ...any) { 16 | l.Info(message, args...) 17 | } 18 | 19 | func RequestLog(msg string, TypeName string, Id int64, ans bool) { 20 | Logger.LogAttrs(context.Background(), slog.LevelInfo, msg, slog.String("TypeName", TypeName), slog.Int64("Id", Id), slog.Bool("CheckAnswer", ans)) 21 | //Logger.Info().Str("TypeName", TypeName). 22 | // Int64("Id", Id). 23 | // Bool("CheckAnswer", ans). 24 | // Msg(msg) 25 | } 26 | -------------------------------------------------------------------------------- /image_hosting/schema/schema.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | type CheveretoImageInfo struct { 4 | Name string `json:"name"` 5 | Extension string `json:"extension"` 6 | Size int `json:"size,omitempty"` 7 | Width int `json:"width,omitempty"` 8 | Height int `json:"height,omitempty"` 9 | // Md5 string `json:"md5"` 10 | Filename string `json:"filename"` 11 | Mime string `json:"mime"` 12 | Url string `json:"url"` 13 | // Thumb struct { 14 | // Url string `json:"url"` 15 | // } `json:"thumb"` 16 | DisplayUrl string `json:"display_url"` 17 | } 18 | 19 | type CheveretoUploadResponse struct { 20 | StatusCode int `json:"status_code"` 21 | StatusTxt string `json:"status_txt"` 22 | Image CheveretoImageInfo `json:"image"` 23 | } 24 | -------------------------------------------------------------------------------- /danke/model/achievement.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // Achievement 成就 8 | type Achievement struct { 9 | ID int `json:"id"` 10 | CreatedAt time.Time `json:"created_at"` 11 | UpdatedAt time.Time `json:"updated_at"` 12 | Name string `json:"name" gorm:"not null"` // 成就名称 13 | Domain string `json:"domain"` // 可能是成就作用域? 14 | } 15 | 16 | // UserAchievement 用户成就关联表 17 | type UserAchievement struct { 18 | UserID int `json:"user_id" gorm:"primaryKey"` // 用户 ID 19 | AchievementID int `json:"achievement_id" gorm:"primaryKey"` // 成就 ID 20 | ObtainDate time.Time `json:"obtain_date"` // 获得日期 21 | Achievement *Achievement `json:"achievement" gorm:"foreignKey:AchievementID"` 22 | } 23 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | backend: 5 | container_name: danke_backend 6 | # image: opentreehole/backend:latest 7 | build: 8 | args: 9 | - SERVICE_NAME=danke 10 | environment: 11 | - DB_TYPE=mysql 12 | - DB_URL=cb:cb@tcp(db:3306)/danke?parseTime=true 13 | volumes: 14 | - data:/app/data 15 | ports: 16 | - "10580:8000" 17 | 18 | db: 19 | container_name: danke_db 20 | image: mysql:8.0 21 | # restart: unless-stopped 22 | environment: 23 | MYSQL_ROOT_PASSWORD: root 24 | MYSQL_DATABASE: danke 25 | MYSQL_USER: cb 26 | MYSQL_PASSWORD: cb 27 | volumes: 28 | - db_data:/var/lib/mysql 29 | 30 | volumes: 31 | data: 32 | name: danke_data 33 | db_data: 34 | name: danke_db_data -------------------------------------------------------------------------------- /image_hosting/README.md: -------------------------------------------------------------------------------- 1 | # Treehole Image Hosting 2 | 3 | --- 4 | ## Upload Image 5 | 6 | To upload an image, send a `POST` request to `{hostname}/api/uploadImage`. Include the photo in the request body with the form-data field named "source". 7 | 8 | ### Example: 9 | http://localhost:8000/api/uploadImage 10 | 11 | ## Get Image 12 | 13 | To retrieve an image, use the `GET` method with the following endpoint: `{hostname}/api/i/:year/:month/:day/:identifier`. 14 | 15 | ### Example: 16 | http://localhost:8000/api/i/2024/12/06/6288772352016bf28f1a571d0.jpg 17 | 18 | ## System Environment Variables 19 | - DB_URL 20 | - root:`PASSWORD`@tcp(localhost:`PORT`)/`YOUR_DATABASE_NAME`?parseTime=true&loc=Asia%2fShanghai 21 | - HOST_NAME (change it to your own host, which directly exports to users) 22 | - http://localhost:8000 23 | - https://image.fduhole.com 24 | --- 25 | -------------------------------------------------------------------------------- /image_hosting/utils/utils.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/hex" 6 | "fmt" 7 | "time" 8 | ) 9 | 10 | func GenerateIdentifier() (string, error) { 11 | now := time.Now().Unix() 12 | nowStr := fmt.Sprintf("%x", now)[:8] // first 8 characters of the timestamp 13 | // Generate a random 6 character string, 14 characters in total (different from the original image proxy (13 characters) ) 14 | randomBytes := make([]byte, 3) 15 | if _, err := rand.Read(randomBytes); err != nil { 16 | return "", err 17 | } 18 | randomSuffix := hex.EncodeToString(randomBytes) 19 | 20 | // Combine the timestamp and random suffix 21 | return fmt.Sprintf("%s%s", nowStr, randomSuffix), nil 22 | } 23 | 24 | func IsAllowedExtension(ext string) bool { 25 | allowedExtensions := []string{"jpg", "jpeg", "png", "gif", "webp", "bmp"} 26 | for _, allowedExt := range allowedExtensions { 27 | if ext == allowedExt { 28 | return true 29 | } 30 | } 31 | return false 32 | } 33 | -------------------------------------------------------------------------------- /common/time.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | type CustomTime struct { 9 | time.Time 10 | } 11 | 12 | var locCST = time.FixedZone("CST", 8*3600) 13 | 14 | func (ct *CustomTime) UnmarshalJSON(data []byte) error { 15 | s := strings.Trim(string(data), `"`) 16 | // Ignore null, like in the main JSON package. 17 | if s == "null" { 18 | return nil 19 | } 20 | // Fractional seconds are handled implicitly by Parse. 21 | var err error 22 | ct.Time, err = time.Parse(time.RFC3339, s) 23 | if err != nil { 24 | ct.Time, err = time.ParseInLocation(`2006-01-02T15:04:05`, s, locCST) 25 | } 26 | return err 27 | } 28 | 29 | func (ct *CustomTime) UnmarshalText(data []byte) error { 30 | s := strings.Trim(string(data), `"`) 31 | // Ignore null, like in the main JSON package. 32 | if s == "" { 33 | return nil 34 | } 35 | // Fractional seconds are handled implicitly by Parse. 36 | var err error 37 | ct.Time, err = time.Parse(time.RFC3339, s) 38 | if err != nil { 39 | ct.Time, err = time.ParseInLocation(`2006-01-02T15:04:05`, s, locCST) 40 | } 41 | return err 42 | } 43 | -------------------------------------------------------------------------------- /image_hosting/api/get_image.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "context" 5 | "github.com/gofiber/fiber/v2" 6 | "github.com/opentreehole/backend/common" 7 | . "github.com/opentreehole/backend/image_hosting/model" 8 | "log/slog" 9 | "strings" 10 | ) 11 | 12 | func GetImage(c *fiber.Ctx) error { 13 | // to access the image in database 14 | // year := c.Params("year") 15 | // month := c.Params("month") 16 | // day := c.Params("day") 17 | // imageType := strings.Split(c.Params("imageIdentifier"), ".")[1] 18 | slog.LogAttrs(context.Background(), slog.LevelInfo, "getting image") 19 | var image ImageTable 20 | imageIdentifier := strings.Split(c.Params("identifier"), ".")[0] 21 | err := DB.First(&image, "image_identifier = ?", imageIdentifier) 22 | if err.Error != nil { 23 | slog.LogAttrs(context.Background(), slog.LevelError, "Cannot find the image", slog.String("err", err.Error.Error())) 24 | return common.BadRequest("Cannot find the image") 25 | } 26 | slog.LogAttrs(context.Background(), slog.LevelInfo, "get image successfully", slog.String("image identifier", imageIdentifier)) 27 | 28 | // browser will automatically transform the BLOB data to an image (no matter what extension) 29 | return c.Send(image.ImageFileData) 30 | } 31 | -------------------------------------------------------------------------------- /danke/model/init.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/glebarez/sqlite" 5 | "github.com/opentreehole/backend/common" 6 | "github.com/spf13/viper" 7 | "gorm.io/driver/mysql" 8 | "gorm.io/driver/postgres" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | var DB *gorm.DB 13 | 14 | func Init() { 15 | var err error 16 | 17 | dbType := viper.GetString(common.EnvDBType) 18 | dbUrl := viper.GetString(common.EnvDBUrl) 19 | switch dbType { 20 | case "sqlite": 21 | if dbUrl == "" { 22 | dbUrl = "sqlite.db" 23 | } 24 | DB, err = gorm.Open(sqlite.Open(dbUrl), common.GormConfig) 25 | case "mysql": 26 | DB, err = gorm.Open(mysql.Open(dbUrl), common.GormConfig) 27 | case "postgres": 28 | DB, err = gorm.Open(postgres.Open(dbUrl), common.GormConfig) 29 | default: 30 | panic("db type not support") 31 | } 32 | if err != nil { 33 | panic(err) 34 | } 35 | 36 | if viper.GetString(common.EnvLogLevel) == "debug" { 37 | DB = DB.Debug() 38 | } 39 | 40 | err = DB.AutoMigrate( 41 | &CourseGroup{}, 42 | &Course{}, 43 | &Teacher{}, 44 | &Review{}, 45 | &ReviewHistory{}, 46 | &Achievement{}, 47 | &UrlHostnameWhitelist{}, 48 | ) 49 | if err != nil { 50 | panic(err) 51 | } 52 | var hostnames []string 53 | err = DB.Model(&UrlHostnameWhitelist{}).Pluck("hostname", &hostnames).Error 54 | if err != nil { 55 | panic(err) 56 | } 57 | viper.Set(common.EnvUrlHostnameWhitelist, hostnames) 58 | } 59 | -------------------------------------------------------------------------------- /danke_utils/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "time" 7 | 8 | "github.com/opentreehole/backend/common" 9 | "github.com/spf13/viper" 10 | "gorm.io/driver/mysql" 11 | "gorm.io/driver/postgres" 12 | "gorm.io/gorm" 13 | "gorm.io/gorm/logger" 14 | "gorm.io/gorm/schema" 15 | ) 16 | 17 | var GormConfig = &gorm.Config{ 18 | NamingStrategy: schema.NamingStrategy{ 19 | SingularTable: true, // 表名使用单数, `User` -> `user` 20 | }, 21 | DisableForeignKeyConstraintWhenMigrating: true, // 禁用自动创建外键约束,必须手动创建或者在业务逻辑层维护 22 | Logger: logger.New( 23 | log.New(os.Stdout, "\r\n", log.LstdFlags), 24 | logger.Config{ 25 | SlowThreshold: time.Second, // 慢 SQL 阈值 26 | LogLevel: logger.Error, // 日志级别 27 | IgnoreRecordNotFoundError: true, // 忽略ErrRecordNotFound(记录未找到)错误 28 | Colorful: false, // 禁用彩色打印 29 | }, 30 | ), 31 | } 32 | 33 | var DB *gorm.DB 34 | 35 | func Init() { 36 | viper.AutomaticEnv() 37 | dbType := viper.GetString(common.EnvDBType) 38 | dbUrl := viper.GetString(common.EnvDBUrl) 39 | 40 | var err error 41 | 42 | switch dbType { 43 | case "mysql": 44 | DB, err = gorm.Open(mysql.Open(dbUrl), GormConfig) 45 | case "postgres": 46 | DB, err = gorm.Open(postgres.Open(dbUrl), GormConfig) 47 | default: 48 | panic("db type not supported") 49 | } 50 | 51 | if err != nil { 52 | panic(err) 53 | } 54 | } 55 | 56 | func main() { 57 | Init() 58 | // Call any script as needed 59 | // GenerateTeacherTable(DB) 60 | } -------------------------------------------------------------------------------- /auth/model/user.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "database/sql" 5 | "time" 6 | ) 7 | 8 | type User struct { 9 | // primary key 10 | ID int `json:"id"` 11 | 12 | // user registration time 13 | JoinedTime time.Time `json:"joined_time" gorm:"autoCreateTime"` 14 | 15 | // user last login time 16 | LastLogin time.Time `json:"last_login" gorm:"autoUpdateTime"` 17 | 18 | // user nickname; designed, not using now 19 | Nickname string `json:"nickname" gorm:"default:user;size:32"` 20 | 21 | // encrypted email, using pbkdf2.Key + sha3.New512 + hex.EncodeToString, 128 length 22 | Identifier sql.NullString `json:"identifier" gorm:"size:128;uniqueIndex:,length:10"` 23 | 24 | // encrypted password, using pbkdf2.Key + sha256.New + base64.StdEncoding, 78 length 25 | Password string `json:"password" gorm:"size:128"` 26 | 27 | // user jwt secret 28 | UserJwtSecret string `json:"user_jwt_secret"` 29 | 30 | // check whether user is active or use has been deleted 31 | IsActive bool `json:"is_active" gorm:"default:true"` 32 | 33 | // check whether user is admin, deprecated after use RBAC 34 | IsAdmin bool `json:"is_admin" gorm:"not null;default:false;index"` 35 | 36 | // check whether user has completed registration test 37 | HasCompletedRegistrationTest bool `json:"has_completed_registration_test" gorm:"default:false"` 38 | } 39 | 40 | type DeleteIdentifier struct { 41 | // primary key, reference to user.id 42 | UserID int `json:"user_id" gorm:"primaryKey"` 43 | 44 | // encrypted email, using pbkdf2.Key + sha3.New512 + hex.EncodeToString, 128 length, unique 45 | Identifier string `json:"identifier" gorm:"size:128;uniqueIndex:,length:10"` 46 | } 47 | -------------------------------------------------------------------------------- /common/cache.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "github.com/allegro/bigcache/v3" 6 | "github.com/eko/gocache/lib/v4/cache" 7 | "github.com/eko/gocache/lib/v4/marshaler" 8 | "github.com/eko/gocache/lib/v4/metrics" 9 | "github.com/eko/gocache/lib/v4/store" 10 | bigcache_store "github.com/eko/gocache/store/bigcache/v4" 11 | redis_store "github.com/eko/gocache/store/redis/v4" 12 | "github.com/redis/go-redis/v9" 13 | "github.com/spf13/viper" 14 | "time" 15 | ) 16 | 17 | var Cache struct { 18 | *marshaler.Marshaler 19 | } 20 | 21 | func InitCache() { 22 | var ( 23 | cacheStore store.StoreInterface 24 | ) 25 | 26 | cacheType := viper.GetString(EnvCacheType) 27 | cacheUrl := viper.GetString(EnvCacheUrl) 28 | switch cacheType { 29 | case "memory": 30 | bigcacheClient, err := bigcache.New(context.Background(), bigcache.Config{ 31 | Shards: 1024, 32 | LifeWindow: 10 * time.Minute, 33 | CleanWindow: 1 * time.Second, 34 | MaxEntriesInWindow: 1000 * 10 * 60, 35 | MaxEntrySize: 500, 36 | StatsEnabled: false, 37 | Verbose: true, 38 | HardMaxCacheSize: 0, 39 | Logger: mLogger{Logger}, 40 | }) 41 | if err != nil { 42 | panic(err) 43 | } 44 | cacheStore = bigcache_store.NewBigcache(bigcacheClient) 45 | case "redis": 46 | redisClient := redis.NewClient(&redis.Options{ 47 | Addr: cacheUrl, 48 | }) 49 | cacheStore = redis_store.NewRedis(redisClient) 50 | } 51 | 52 | metricsCache := cache.NewMetric[any]( 53 | metrics.NewPrometheus("cache"), 54 | cache.New[any](cacheStore), 55 | ) 56 | 57 | Cache.Marshaler = marshaler.New(metricsCache) 58 | } 59 | -------------------------------------------------------------------------------- /danke/api/routes.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/gofiber/fiber/v2" 5 | "github.com/gofiber/swagger" 6 | "github.com/opentreehole/backend/danke/data" 7 | ) 8 | 9 | func RegisterRoutes(app *fiber.App) { 10 | app.Get("/", func(c *fiber.Ctx) error { 11 | return c.Redirect("/api") 12 | }) 13 | app.Get("/docs", func(c *fiber.Ctx) error { 14 | return c.Redirect("/docs/index.html") 15 | }) 16 | app.Get("/docs/*", swagger.HandlerDefault) 17 | 18 | api := app.Group("/api") 19 | registerRoutes(api) 20 | } 21 | 22 | func registerRoutes(r fiber.Router) { 23 | r.Get("", func(c *fiber.Ctx) error { 24 | return c.JSON(fiber.Map{"message": "Welcome to danke API"}) 25 | }) 26 | 27 | // v1 28 | // Course 29 | r.Get("/courses", ListCoursesV1) 30 | r.Get("/courses/:id", GetCourseV1) 31 | r.Post("/courses", AddCourseV1) 32 | 33 | // CourseGroup 34 | r.Get("/group/:id", GetCourseGroupV1) 35 | r.Get("/courses/hash", GetCourseGroupHashV1) 36 | r.Get("/courses/refresh", RefreshCourseGroupHashV1) 37 | 38 | // Review 39 | r.Get("/reviews/:id", GetReviewV1) 40 | r.Get("/courses/:id/reviews", ListReviewsV1) 41 | r.Post("/courses/:id/reviews", CreateReviewV1) 42 | r.Put("/reviews/:id", ModifyReviewV1) 43 | r.Patch("/reviews/:id/_webvpn", ModifyReviewV1) 44 | r.Patch("/reviews/:id", VoteForReviewV1) 45 | r.Get("/reviews/me", ListMyReviewsV1) 46 | r.Get("/reviews/random", GetRandomReviewV1) 47 | r.Delete("/reviews/:id", DeleteReviewV1) 48 | 49 | // v3 50 | // CourseGroup 51 | r.Get("/v3/course_groups/search", SearchCourseGroupV3) 52 | r.Get("/v3/course_groups/:id", GetCourseGroupV3) 53 | 54 | // static 55 | r.Get("/static/cedict_ts.u8", func(c *fiber.Ctx) error { 56 | return c.Send(data.CreditTs) 57 | }) 58 | 59 | r.Get("/v3/reviews/_sensitive", ListSensitiveReviews) 60 | r.Put("/v3/reviews/:id/_sensitive", ModifyReviewSensitive) 61 | r.Patch("/v3/reviews/:id/_sensitive/_webvpn", ModifyReviewSensitive) 62 | } 63 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Production Build 2 | on: 3 | workflow_call: 4 | inputs: 5 | service_name: 6 | type: string 7 | required: true 8 | 9 | jobs: 10 | docker: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@master 15 | 16 | - name: Set up QEMU 17 | uses: docker/setup-qemu-action@master 18 | 19 | - name: Set up Docker Buildx 20 | uses: docker/setup-buildx-action@master 21 | 22 | - name: Login to DockerHub 23 | uses: docker/login-action@master 24 | with: 25 | username: ${{ secrets.DOCKERHUB_USERNAME }} 26 | password: ${{ secrets.DOCKERHUB_TOKEN }} 27 | 28 | - name: Build and push 29 | id: docker_build 30 | uses: docker/build-push-action@master 31 | with: 32 | push: true 33 | tags: | 34 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.service_name }}_backend:latest 35 | ${{ secrets.DOCKERHUB_USERNAME }}/${{ inputs.service_name }}_backend:master 36 | build-args: | 37 | SERVICE_NAME=${{ inputs.service_name }} 38 | 39 | - name: Login to Aliyun ACR 40 | uses: aliyun/acr-login@master 41 | with: 42 | login-server: https://registry.cn-shanghai.aliyuncs.com 43 | username: ${{ secrets.ACR_USERNAME }} 44 | password: ${{ secrets.ACR_PASSWORD }} 45 | 46 | - name: Build and push Aliyun ACR 47 | uses: docker/build-push-action@master 48 | with: 49 | push: true 50 | tags: | 51 | registry.cn-shanghai.aliyuncs.com/${{ secrets.ACR_NAMESPACE }}/${{ inputs.service_name }}_backend:latest 52 | registry.cn-shanghai.aliyuncs.com/${{ secrets.ACR_NAMESPACE }}/${{ inputs.service_name }}_backend:master 53 | registry.cn-shanghai.aliyuncs.com/${{ secrets.ACR_NAMESPACE }}/${{ inputs.service_name }}:latest 54 | registry.cn-shanghai.aliyuncs.com/${{ secrets.ACR_NAMESPACE }}/${{ inputs.service_name }}:master 55 | build-args: | 56 | SERVICE_NAME=${{ inputs.service_name }} 57 | -------------------------------------------------------------------------------- /image_hosting/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/gofiber/fiber/v2/middleware/pprof" 8 | "github.com/gofiber/fiber/v2/middleware/recover" 9 | . "github.com/opentreehole/backend/common" 10 | . "github.com/opentreehole/backend/image_hosting/api" 11 | . "github.com/opentreehole/backend/image_hosting/model" 12 | "github.com/spf13/viper" 13 | "log/slog" 14 | "os" 15 | "os/signal" 16 | "syscall" 17 | ) 18 | 19 | func main() { 20 | app := fiber.New(fiber.Config{ 21 | ErrorHandler: ErrorHandler, 22 | JSONEncoder: json.Marshal, 23 | JSONDecoder: json.Unmarshal, 24 | DisableStartupMessage: true, 25 | BodyLimit: 128 * 1024 * 1024, 26 | }) 27 | // will catch every panic 28 | app.Use(recover.New(recover.Config{ 29 | EnableStackTrace: true, 30 | })) 31 | app.Use(MiddlewareGetUserID) 32 | if viper.GetString(EnvMode) != "bench" { 33 | app.Use(MiddlewareCustomLogger) 34 | } 35 | app.Use(pprof.New()) 36 | 37 | // app.Use(common.MiddlewareGetUserID) 38 | 39 | // will catch every HTTP request 40 | // app.Use(MiddlewareCustomLogger) 41 | Init() 42 | // router := app.Group("/") // Compatible with the previous API. 43 | app.Post("/api/uploadImage", UploadImage) 44 | app.Post("/api/json", UploadImage) // Compatible with the previous API. 45 | app.Get("/i/:year/:month/:day/:identifier", GetImage) // get images based on the identifier(excluding the extension) 46 | 47 | go func() { 48 | slog.LogAttrs(context.Background(), slog.LevelInfo, "Service started.") 49 | err := app.Listen("0.0.0.0:8000") 50 | if err != nil { 51 | slog.LogAttrs(context.Background(), slog.LevelError, "Wrong hostname", slog.String("err", err.Error())) 52 | } 53 | 54 | }() 55 | 56 | interrupt := make(chan os.Signal, 1) 57 | 58 | // listen for interrupt signal (Ctrl+C) 59 | signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) 60 | <-interrupt 61 | slog.LogAttrs(context.Background(), slog.LevelInfo, "Shutting down the server.") 62 | err := app.Shutdown() 63 | if err != nil { 64 | slog.LogAttrs(context.Background(), slog.LevelError, "shutdown failed", slog.String("err", err.Error())) 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /common/config.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "github.com/spf13/viper" 5 | "gorm.io/gorm" 6 | "gorm.io/gorm/logger" 7 | "gorm.io/gorm/schema" 8 | "time" 9 | ) 10 | 11 | // common config 12 | const ( 13 | EnvMode = "MODE" 14 | EnvLogLevel = "LOG_LEVEL" 15 | EnvDBType = "DB_TYPE" 16 | EnvDBUrl = "DB_URL" 17 | EnvCacheType = "CACHE_TYPE" 18 | EnvCacheUrl = "CACHE_URL" 19 | EnvYiDunBusinessIdText = "YI_DUN_BUSINESS_ID_TEXT" 20 | EnvYiDunBusinessIdImage = "YI_DUN_BUSINESS_ID_IMAGE" 21 | EnvYiDunSecretId = "YI_DUN_SECRET_ID" 22 | EnvYiDunSecretKey = "YI_DUN_SECRET_KEY" 23 | EnvValidImageUrl = "VALID_IMAGE_URL" 24 | EnvUrlHostnameWhitelist = "URL_HOSTNAME_WHITELIST" 25 | EnvExternalImageHost = "EXTERNAL_IMAGE_HOST" 26 | EnvProxyUrl = "PROXY_URL" 27 | EnvYiDunAccessKeyId = "YI_DUN_ACCESS_KEY_ID" 28 | EnvYiDunAccessKeySecret = "YI_DUN_ACCESS_KEY_SECRET" 29 | ) 30 | 31 | var defaultConfig = map[string]string{ 32 | EnvMode: "dev", 33 | EnvLogLevel: "debug", 34 | EnvDBType: "sqlite", 35 | EnvDBUrl: "file::memory:?cache=shared", 36 | EnvCacheType: "memory", 37 | EnvCacheUrl: "", 38 | EnvYiDunBusinessIdText: "", 39 | EnvYiDunBusinessIdImage: "", 40 | EnvYiDunSecretId: "", 41 | EnvYiDunSecretKey: "", 42 | EnvValidImageUrl: "", 43 | EnvUrlHostnameWhitelist: "", 44 | EnvExternalImageHost: "", 45 | EnvProxyUrl: "", 46 | EnvYiDunAccessKeyId: "", 47 | EnvYiDunAccessKeySecret: "", 48 | } 49 | 50 | var GormConfig = &gorm.Config{ 51 | NamingStrategy: schema.NamingStrategy{ 52 | SingularTable: true, // 表名使用单数, `User` -> `user` 53 | }, 54 | DisableForeignKeyConstraintWhenMigrating: true, // 禁用自动创建外键约束,必须手动创建或者在业务逻辑层维护 55 | Logger: logger.New( 56 | mLogger{Logger}, 57 | logger.Config{ 58 | SlowThreshold: time.Second, // 慢 SQL 阈值 59 | LogLevel: logger.Error, // 日志级别 60 | IgnoreRecordNotFoundError: true, // 忽略ErrRecordNotFound(记录未找到)错误 61 | Colorful: false, // 禁用彩色打印 62 | }, 63 | ), 64 | } 65 | 66 | func init() { 67 | viper.AutomaticEnv() 68 | for k, v := range defaultConfig { 69 | viper.SetDefault(k, v) 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /danke/model/course_group.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "encoding/base64" 6 | "regexp" 7 | "time" 8 | 9 | "github.com/eko/gocache/lib/v4/store" 10 | "github.com/opentreehole/backend/common" 11 | "github.com/vmihailenco/msgpack/v5" 12 | "golang.org/x/crypto/sha3" 13 | ) 14 | 15 | // CourseGroup 课程组 16 | type CourseGroup struct { 17 | ID int `json:"id"` 18 | CreatedAt time.Time `json:"created_at"` 19 | UpdatedAt time.Time `json:"updated_at"` 20 | Name string `json:"name" gorm:"not null"` // 课程组名称 21 | Code string `json:"code" gorm:"not null;index:,length:6"` // 课程组编号 22 | Credits []float64 `json:"credits" gorm:"serializer:json"` // 学分 23 | Department string `json:"department" gorm:"not null"` // 开课学院 24 | CampusName string `json:"campus_name" gorm:"not null"` // 开课校区 25 | CourseCount int `json:"course_count" gorm:"not null;default:0"` // 课程数量 26 | ReviewCount int `json:"review_count" gorm:"not null;default:0"` // 评价数量 27 | Courses CourseList `json:"courses"` 28 | Teachers []*Teacher `gorm:"many2many:teacher_course_groups;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;"` 29 | } 30 | 31 | var CourseCodeRegexp = regexp.MustCompile(`^([A-Z]{3,})([0-9]{2,})`) 32 | 33 | var courseGroupHash string 34 | 35 | func FindGroupsWithCourses(refresh bool) (groups []*CourseGroup, hash string, err error) { 36 | groups = make([]*CourseGroup, 5) 37 | if !refresh { 38 | // get from cache 39 | _, err = common.Cache.Get(context.Background(), "danke:course_group", &groups) 40 | hash = courseGroupHash 41 | } 42 | if err != nil || refresh { 43 | // get from db 44 | err = DB.Preload("Courses").Find(&groups).Error 45 | if err != nil { 46 | return nil, "", err 47 | } 48 | 49 | // set cache 50 | err = common.Cache.Set(context.Background(), "danke:course_group", groups, store.WithExpiration(24*time.Hour)) 51 | if err != nil { 52 | return nil, "", err 53 | } 54 | 55 | // set hash 56 | data, err := msgpack.Marshal(groups) 57 | if err != nil { 58 | return nil, "", err 59 | } 60 | hashBytes := sha3.Sum256(data) 61 | if err != nil { 62 | return nil, "", err 63 | } 64 | hash = base64.RawStdEncoding.EncodeToString(hashBytes[:]) 65 | courseGroupHash = hash 66 | } 67 | return 68 | } 69 | 70 | type CourseGroupList []*CourseGroup 71 | -------------------------------------------------------------------------------- /danke/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "github.com/gofiber/fiber/v2" 6 | "github.com/gofiber/fiber/v2/middleware/pprof" 7 | "github.com/gofiber/fiber/v2/middleware/recover" 8 | "github.com/opentreehole/backend/common" 9 | "github.com/opentreehole/backend/common/sensitive" 10 | "github.com/opentreehole/backend/danke/api" 11 | _ "github.com/opentreehole/backend/danke/config" 12 | _ "github.com/opentreehole/backend/danke/docs" 13 | "github.com/opentreehole/backend/danke/model" 14 | "github.com/spf13/viper" 15 | "log/slog" 16 | "os" 17 | "os/signal" 18 | "syscall" 19 | ) 20 | 21 | // @title 蛋壳 API 22 | // @version 3.0.0 23 | // @description 蛋壳 API,一个半匿名评教系统 24 | 25 | // @contact.name Maintainer Ke Chen 26 | // @contact.email dev@fduhole.com 27 | 28 | // @license.name Apache 2.0 29 | // @license.url https://www.apache.org/licenses/LICENSE-2.0.html 30 | 31 | // @host 32 | // @BasePath /api 33 | 34 | func Init() context.CancelFunc { 35 | common.InitCache() 36 | model.Init() 37 | sensitive.InitSensitiveLabelMap() 38 | ctx, cancel := context.WithCancel(context.Background()) 39 | go sensitive.UpdateSensitiveLabelMap(ctx) 40 | return cancel 41 | } 42 | 43 | func main() { 44 | cancel := Init() 45 | var disableStartupMessage = false 46 | if viper.GetString(common.EnvMode) == "prod" { 47 | disableStartupMessage = true 48 | } 49 | app := fiber.New(fiber.Config{ 50 | ErrorHandler: common.ErrorHandler, 51 | DisableStartupMessage: disableStartupMessage, 52 | }) 53 | registerMiddlewares(app) 54 | api.RegisterRoutes(app) 55 | 56 | go func() { 57 | err := app.Listen("0.0.0.0:8000") 58 | if err != nil { 59 | slog.LogAttrs(context.Background(), slog.LevelError, "app listen failed", slog.String("err", err.Error())) 60 | } 61 | }() 62 | 63 | interrupt := make(chan os.Signal, 1) 64 | 65 | // wait for CTRL-C interrupt 66 | signal.Notify(interrupt, syscall.SIGINT, syscall.SIGTERM) 67 | <-interrupt 68 | 69 | // close app 70 | err := app.Shutdown() 71 | if err != nil { 72 | slog.LogAttrs(context.Background(), slog.LevelError, "shutdown failed", slog.String("err", err.Error())) 73 | } 74 | cancel() 75 | } 76 | 77 | func registerMiddlewares(app *fiber.App) { 78 | app.Use(recover.New(recover.Config{EnableStackTrace: true})) 79 | app.Use(common.MiddlewareGetUserID) 80 | if viper.GetString(common.EnvMode) != "bench" { 81 | app.Use(common.MiddlewareCustomLogger) 82 | } 83 | app.Use(pprof.New()) 84 | } 85 | -------------------------------------------------------------------------------- /common/middlewares.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "github.com/gofiber/fiber/v2" 7 | "log/slog" 8 | "strconv" 9 | "time" 10 | ) 11 | 12 | func GetUserID(c *fiber.Ctx) (int, error) { 13 | // get user id from header: X-Consumer-Username if through Kong 14 | username := c.Get("X-Consumer-Username") 15 | if username != "" { 16 | id, err := strconv.Atoi(username) 17 | if err == nil { 18 | return id, nil 19 | } 20 | } 21 | 22 | // get user id from jwt 23 | // ID and UserID are both valid 24 | var user struct { 25 | ID int `json:"id"` 26 | UserID int `json:"user_id"` 27 | } 28 | 29 | token := GetJWTToken(c) 30 | if token == "" { 31 | return 0, Unauthorized("Unauthorized") 32 | } 33 | 34 | err := ParseJWTToken(token, &user) 35 | if err != nil { 36 | return 0, Unauthorized("Unauthorized") 37 | } 38 | 39 | if user.ID != 0 { 40 | return user.ID, nil 41 | } else if user.UserID != 0 { 42 | return user.UserID, nil 43 | } 44 | 45 | return 0, Unauthorized("Unauthorized") 46 | } 47 | 48 | func MiddlewareGetUserID(c *fiber.Ctx) error { 49 | userID, err := GetUserID(c) 50 | if err == nil { 51 | c.Locals("user_id", userID) 52 | } 53 | 54 | return c.Next() 55 | } 56 | 57 | func MiddlewareCustomLogger(c *fiber.Ctx) error { 58 | startTime := time.Now() 59 | chainErr := c.Next() 60 | 61 | if chainErr != nil { 62 | if err := c.App().ErrorHandler(c, chainErr); err != nil { 63 | _ = c.SendStatus(fiber.StatusInternalServerError) 64 | } 65 | } 66 | 67 | latency := time.Since(startTime).Milliseconds() 68 | userID, ok := c.Locals("user_id").(int) 69 | 70 | attrs := []slog.Attr{ 71 | slog.Int("status_code", c.Response().StatusCode()), 72 | slog.String("method", c.Method()), 73 | slog.String("origin_url", c.OriginalURL()), 74 | slog.String("remote_ip", c.Get("X-Real-IP")), 75 | slog.Int64("latency", latency), 76 | } 77 | if ok { 78 | attrs = append(attrs, slog.Int("user_id", userID)) 79 | } 80 | var logLevel = slog.LevelInfo 81 | if chainErr != nil { 82 | attrs = append(attrs, slog.String("err", chainErr.Error())) 83 | logLevel = slog.LevelError 84 | } 85 | if c.Method() == "POST" || c.Method() == "PUT" || c.Method() == "PATCH" || c.Method() == "DELETE" { 86 | var body = make(map[string]any) 87 | err := json.Unmarshal(c.Body(), &body) 88 | if err != nil { 89 | attrs = append(attrs, slog.String("body", string(c.Body()))) 90 | } else { 91 | delete(body, "password") 92 | var data, _ = json.Marshal(body) 93 | attrs = append(attrs, slog.String("body", string(data))) 94 | } 95 | } 96 | Logger.LogAttrs(context.Background(), logLevel, "http log", attrs...) 97 | return nil 98 | } 99 | -------------------------------------------------------------------------------- /danke_utils/teacher_table.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "slices" 6 | "strings" 7 | 8 | "gorm.io/gorm" 9 | "gorm.io/gorm/clause" 10 | ) 11 | 12 | const ( 13 | BatchSize = 1000 14 | ) 15 | 16 | type Course struct { 17 | ID int `json:"id"` 18 | Name string `json:"name" gorm:"not null"` // 课程名称 19 | Code string `json:"code" gorm:"not null"` // 课程编号 20 | CodeID string `json:"code_id" gorm:"not null"` // 选课序号。用于区分同一课程编号的不同平行班 21 | Teachers string `json:"teachers" gorm:"not null"` 22 | CourseGroupID int `json:"course_group_id" gorm:"not null;index"` // 课程组编号 23 | } 24 | 25 | type Teacher struct { 26 | ID int 27 | Name string `gorm:"not null"` // 课程组 ID 28 | } 29 | 30 | type TeacherCourseLink struct { 31 | TeacherID int `gorm:"primaryKey;autoIncrement:false"` 32 | CourseGroupID int `gorm:"primaryKey;autoIncrement:false"` // 课程组编号 33 | } 34 | 35 | func AppendUnique[T comparable](slice []T, elems ...T) []T { 36 | for _, elem := range elems { 37 | if !slices.Contains(slice, elem) { 38 | slice = append(slice, elem) 39 | } 40 | } 41 | 42 | return slice 43 | } 44 | 45 | func GenerateTeacherTable(DB *gorm.DB) { 46 | Init() 47 | 48 | // reader := bufio.NewReader(os.Stdin) 49 | 50 | dataMap := map[string][]int{} 51 | 52 | var queryResult []Course 53 | query := DB.Table("course") 54 | query.FindInBatches(&queryResult, BatchSize, func(tx *gorm.DB, batch int) error { 55 | for _, course := range queryResult { 56 | teacherList := strings.Split(course.Teachers, ",") 57 | for _, name := range teacherList { 58 | courseList, found := dataMap[name] 59 | if found { 60 | dataMap[name] = AppendUnique(courseList, course.CourseGroupID) 61 | } else { 62 | dataMap[name] = []int{course.CourseGroupID} 63 | } 64 | } 65 | } 66 | 67 | fmt.Printf("Handled batchg %d\n", batch) 68 | return nil 69 | }) 70 | 71 | var teachers []*Teacher 72 | for k := range dataMap { 73 | teachers = append(teachers, &Teacher{Name: k}) 74 | } 75 | 76 | // Avoid insertion failure due to duplication 77 | DB.Clauses(clause.OnConflict{DoNothing: true}).Table("teacher").Create(teachers) 78 | 79 | var links []*TeacherCourseLink 80 | for index, teacher := range teachers { 81 | for _, cid := range dataMap[teacher.Name] { 82 | links = append(links, &TeacherCourseLink{TeacherID: teacher.ID, CourseGroupID: cid}) 83 | } 84 | 85 | // Submit every 100 teachers to avoid SQL being too long 86 | if index%100 == 0 { 87 | fmt.Printf("Inserted %d teachers\n", index) 88 | 89 | // Avoid insertion failure due to duplication 90 | DB.Clauses(clause.OnConflict{DoNothing: true}).Table("teacher_course_groups").Create(links) 91 | links = nil 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /danke/schema/course_group.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "github.com/jinzhu/copier" 5 | "github.com/opentreehole/backend/common" 6 | 7 | "github.com/opentreehole/backend/danke/model" 8 | ) 9 | 10 | // CourseGroupV1Response 旧版本课程组响应 11 | type CourseGroupV1Response struct { 12 | ID int `json:"id"` // 课程组 ID 13 | Name string `json:"name"` // 课程组名称 14 | Code string `json:"code"` // 课程组编号 15 | Department string `json:"department"` // 开课学院 16 | CampusName string `json:"campus_name"` // 开课校区 17 | CourseList []*CourseV1Response `json:"course_list,omitempty"` // 课程组下的课程,slices 必须非空 18 | } 19 | 20 | func (r *CourseGroupV1Response) FromModel( 21 | user *common.User, 22 | group *model.CourseGroup, 23 | ) *CourseGroupV1Response { 24 | err := copier.Copy(r, group) 25 | if err != nil { 26 | panic(err) 27 | } 28 | 29 | if group.Courses == nil { 30 | return r 31 | } 32 | r.CourseList = make([]*CourseV1Response, 0, len(group.Courses)) 33 | for _, course := range group.Courses { 34 | r.CourseList = append(r.CourseList, new(CourseV1Response).FromModel(user, course)) 35 | } 36 | 37 | return r 38 | } 39 | 40 | type CourseGroupHashV1Response struct { 41 | Hash string `json:"hash"` 42 | } 43 | 44 | func (r *CourseGroupHashV1Response) FromModel(hash string) *CourseGroupHashV1Response { 45 | r.Hash = hash 46 | return r 47 | } 48 | 49 | /* V3 */ 50 | 51 | type CourseGroupSearchV3Request struct { 52 | Query string `json:"query" form:"query" query:"query" validate:"required" example:"计算机"` 53 | Page int `json:"page" form:"page" query:"page" validate:"min=0" example:"1"` 54 | PageSize int `json:"page_size" form:"page_size" query:"page_size" validate:"min=0,max=100" example:"10"` 55 | } 56 | 57 | type CourseGroupV3Response struct { 58 | ID int `json:"id"` // 课程组 ID 59 | Name string `json:"name"` // 课程组名称 60 | Code string `json:"code"` // 课程组编号 61 | Credits []float64 `json:"credits"` // 学分 62 | Department string `json:"department"` // 开课学院 63 | CampusName string `json:"campus_name"` // 开课校区 64 | CourseCount int `json:"course_count"` // 课程数量 65 | ReviewCount int `json:"review_count"` // 评价数量 66 | CourseList []*CourseV1Response `json:"course_list,omitempty"` // 课程组下的课程,slices 必须非空 67 | } 68 | 69 | func (r *CourseGroupV3Response) FromModel( 70 | user *common.User, 71 | group *model.CourseGroup, 72 | ) *CourseGroupV3Response { 73 | err := copier.Copy(r, group) 74 | if err != nil { 75 | panic(err) 76 | } 77 | 78 | if group.Courses == nil { 79 | return r 80 | } 81 | r.CourseList = make([]*CourseV1Response, 0, len(group.Courses)) 82 | for _, course := range group.Courses { 83 | r.CourseList = append(r.CourseList, new(CourseV1Response).FromModel(user, course)) 84 | } 85 | 86 | return r 87 | } 88 | -------------------------------------------------------------------------------- /common/errors.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "errors" 5 | "github.com/gofiber/fiber/v2" 6 | "gorm.io/gorm" 7 | "strconv" 8 | ) 9 | 10 | type HttpBaseError struct { 11 | Code int `json:"code,omitempty"` 12 | Message string `json:"message,omitempty"` 13 | } 14 | 15 | type HttpError struct { 16 | Code int `json:"code,omitempty"` 17 | Message string `json:"message,omitempty"` 18 | Detail *ErrorDetail `json:"detail,omitempty"` 19 | } 20 | 21 | func (e *HttpError) Error() string { 22 | return e.Message 23 | } 24 | 25 | func BadRequest(messages ...string) *HttpError { 26 | message := "Bad Request" 27 | if len(messages) > 0 { 28 | message = messages[0] 29 | } 30 | return &HttpError{ 31 | Code: 400, 32 | Message: message, 33 | } 34 | } 35 | 36 | func Unauthorized(messages ...string) *HttpError { 37 | message := "Invalid JWT Token" 38 | if len(messages) > 0 { 39 | message = messages[0] 40 | } 41 | return &HttpError{ 42 | Code: 401, 43 | Message: message, 44 | } 45 | } 46 | 47 | func Forbidden(messages ...string) *HttpError { 48 | message := "Forbidden" 49 | if len(messages) > 0 { 50 | message = messages[0] 51 | } 52 | return &HttpError{ 53 | Code: 403, 54 | Message: message, 55 | } 56 | } 57 | 58 | func NotFound(messages ...string) *HttpError { 59 | message := "Not Found" 60 | if len(messages) > 0 { 61 | message = messages[0] 62 | } 63 | return &HttpError{ 64 | Code: 404, 65 | Message: message, 66 | } 67 | } 68 | 69 | func InternalServerError(messages ...string) *HttpError { 70 | message := "Internal Server Error" 71 | if len(messages) > 0 { 72 | message = messages[0] 73 | } 74 | return &HttpError{ 75 | Code: 500, 76 | Message: message, 77 | } 78 | } 79 | 80 | func ErrorHandler(ctx *fiber.Ctx, err error) error { 81 | if err == nil { 82 | return nil 83 | } 84 | 85 | httpError := HttpError{ 86 | Code: 500, 87 | Message: err.Error(), 88 | } 89 | 90 | if errors.Is(err, gorm.ErrRecordNotFound) { 91 | httpError.Code = 404 92 | } else { 93 | switch e := err.(type) { 94 | case *HttpError: 95 | httpError = *e 96 | case *fiber.Error: 97 | httpError.Code = e.Code 98 | case *ErrorDetail: 99 | httpError.Code = 400 100 | httpError.Detail = e 101 | case fiber.MultiError: 102 | httpError.Code = 400 103 | httpError.Message = "" 104 | for _, err = range e { 105 | httpError.Message += err.Error() + "\n" 106 | } 107 | default: 108 | httpError.Message = "Internal Server Error" 109 | } 110 | } 111 | 112 | // parse status code 113 | // when status code is 400xxx to 599xxx, use leading 3 numbers instead 114 | // else use 500 115 | statusCode := httpError.Code 116 | statusCodeString := strconv.Itoa(statusCode) 117 | if len(statusCodeString) > 3 { 118 | statusCodeString = statusCodeString[:3] 119 | newStatusCode, err := strconv.Atoi(statusCodeString) 120 | if err == nil && newStatusCode >= 400 && newStatusCode < 600 { 121 | statusCode = newStatusCode 122 | } else { 123 | statusCode = 500 124 | } 125 | } 126 | 127 | return ctx.Status(statusCode).JSON(&httpError) 128 | } 129 | -------------------------------------------------------------------------------- /common/sensitive/utils_test.go: -------------------------------------------------------------------------------- 1 | package sensitive 2 | 3 | import ( 4 | "github.com/opentreehole/backend/common" 5 | "github.com/spf13/viper" 6 | "github.com/stretchr/testify/assert" 7 | "testing" 8 | ) 9 | 10 | func TestFindImagesInMarkdown(t *testing.T) { 11 | viper.Set(common.EnvValidImageUrl, []string{"example.com"}) 12 | //config.Config.ValidImageUrl = []string{"example.com"} 13 | 14 | type wantStruct struct { 15 | clearContent string 16 | imageUrls []string 17 | err error 18 | } 19 | tests := []struct { 20 | text string 21 | want wantStruct 22 | }{ 23 | { 24 | text: `![image1](https://example.com/image1)`, 25 | want: wantStruct{ 26 | clearContent: `image1`, 27 | imageUrls: []string{"https://example.com/image1"}, 28 | err: nil, 29 | }, 30 | }, 31 | { 32 | text: `![image1](https://example.com/image1) ![image2](https://example.com/image2)`, 33 | want: wantStruct{ 34 | clearContent: `image1 image2`, 35 | imageUrls: []string{"https://example.com/image1", "https://example.com/image2"}, 36 | }, 37 | }, 38 | { 39 | text: `![image1](https://example.com/image1 "title1") ![image2](https://example.com/image2 "title2")`, 40 | want: wantStruct{ 41 | clearContent: `image1 title1 image2 title2`, 42 | imageUrls: []string{"https://example.com/image1", "https://example.com/image2"}, 43 | }, 44 | }, 45 | { 46 | text: `![image1](123) ![image2](456)`, 47 | want: wantStruct{ 48 | clearContent: `image1 123 image2 456`, 49 | imageUrls: nil, 50 | }, 51 | }, 52 | { 53 | text: `![](123) ![](456)`, 54 | want: wantStruct{ 55 | clearContent: `123 456`, 56 | imageUrls: nil, 57 | }, 58 | }, 59 | { 60 | text: "![](https://example2.com/image1)", 61 | want: wantStruct{ 62 | clearContent: "", 63 | imageUrls: nil, 64 | err: ErrInvalidImageHost, 65 | }, 66 | }, 67 | } 68 | 69 | for _, tt := range tests { 70 | imageUrls, cleanText, err := findImagesInMarkdownContent(tt.text) 71 | assert.EqualValues(t, tt.want.clearContent, cleanText, "cleanText should be equal") 72 | assert.EqualValues(t, tt.want.imageUrls, imageUrls, "imageUrls should be equal") 73 | assert.EqualValues(t, tt.want.err, err, "err should be equal") 74 | } 75 | } 76 | 77 | func TestCheckValidUrl(t *testing.T) { 78 | viper.Set(common.EnvValidImageUrl, []string{"example.com"}) 79 | type wantStruct struct { 80 | err error 81 | } 82 | tests := []struct { 83 | url string 84 | want wantStruct 85 | }{ 86 | { 87 | url: "https://example.com/image1", 88 | want: wantStruct{ 89 | err: nil, 90 | }, 91 | }, 92 | { 93 | url: "https://example.com/image2", 94 | want: wantStruct{ 95 | err: nil, 96 | }, 97 | }, 98 | { 99 | url: "123456", 100 | want: wantStruct{ 101 | err: ErrImageLinkTextOnly, 102 | }, 103 | }, 104 | { 105 | url: "https://example2.com", 106 | want: wantStruct{ 107 | err: ErrInvalidImageHost, 108 | }, 109 | }, 110 | } 111 | 112 | for _, tt := range tests { 113 | err := checkValidUrl(tt.url) 114 | assert.EqualValues(t, tt.want.err, err, "err should be equal") 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /common/user.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/golang-jwt/jwt/v5" 8 | "log/slog" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | type UserClaims struct { 14 | ID int `json:"id,omitempty"` 15 | UserID int `json:"user_id,omitempty"` 16 | UID int `json:"uid,omitempty"` 17 | IsAdmin bool `json:"is_admin"` 18 | ExpiresAt *jwt.NumericDate `json:"exp,omitempty"` 19 | } 20 | 21 | type User struct { 22 | ID int `json:"id"` 23 | IsAdmin bool `json:"is_admin"` 24 | } 25 | 26 | var ( 27 | ErrJWTTokenRequired = Unauthorized("jwt token required") 28 | ErrInvalidJWTToken = Unauthorized("invalid jwt token") 29 | ) 30 | 31 | // GetJWTToken extracts token from header or cookie 32 | // return empty string if not found 33 | func GetJWTToken(c *fiber.Ctx) string { 34 | tokenString := c.Get("Authorization") // token in header 35 | if tokenString == "" { 36 | tokenString = c.Cookies("access") // token in cookie 37 | } 38 | return tokenString 39 | } 40 | 41 | // ParseJWTToken extracts and parse token, whatever start with "Bearer " or not 42 | func ParseJWTToken(token string, user any) error { 43 | // remove "Bearer " prefix if exists 44 | if strings.HasPrefix(token, "Bearer ") { 45 | token = token[7:] 46 | } 47 | token = strings.TrimSpace(token) 48 | payloads := strings.SplitN(token, ".", 3) // extract "Bearer " 49 | if len(payloads) < 3 { 50 | return ErrJWTTokenRequired 51 | } 52 | 53 | payloadString := payloads[1] 54 | 55 | // jwt encoding ignores padding, so RawStdEncoding should be used instead of StdEncoding 56 | // jwt encoding uses url safe base64 encoding, so RawURLEncoding should be used instead of RawStdEncoding 57 | payloadBytes, err := base64.RawURLEncoding.DecodeString(payloadString) // the middle one is payload 58 | if err != nil { 59 | slog.Error("jwt parse error", "err", err, "payload_string", payloadString, "payload_bytes", string(payloadBytes)) 60 | return ErrInvalidJWTToken 61 | } 62 | 63 | err = json.Unmarshal(payloadBytes, user) 64 | if err != nil { 65 | slog.Error("jwt parse error", "err", err, "payload_string", payloadString) 66 | return ErrInvalidJWTToken 67 | } 68 | 69 | return nil 70 | } 71 | 72 | func GetCurrentUser(c *fiber.Ctx) (user *User, err error) { 73 | token := GetJWTToken(c) 74 | if token == "" { 75 | return nil, Unauthorized("Unauthorized") 76 | } 77 | 78 | var userClaims UserClaims 79 | err = ParseJWTToken(token, &userClaims) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | user = &User{} 85 | if userClaims.ID == 0 && userClaims.UserID == 0 && userClaims.UID == 0 { 86 | return nil, Unauthorized("Unauthorized") 87 | } else { 88 | if userClaims.ID != 0 { 89 | user.ID = userClaims.ID 90 | } else if userClaims.UserID != 0 { 91 | user.ID = userClaims.UserID 92 | } else { 93 | user.ID = userClaims.UID 94 | } 95 | } 96 | user.IsAdmin = userClaims.IsAdmin 97 | 98 | if userClaims.ExpiresAt != nil && userClaims.ExpiresAt.Before(time.Now()) { 99 | return nil, Unauthorized("token expired") 100 | } 101 | 102 | return 103 | } 104 | -------------------------------------------------------------------------------- /common/sensitive/utils.go: -------------------------------------------------------------------------------- 1 | package sensitive 2 | 3 | import ( 4 | "errors" 5 | "github.com/opentreehole/backend/common" 6 | "github.com/spf13/viper" 7 | "golang.org/x/exp/slices" 8 | "mvdan.cc/xurls/v2" 9 | "net/url" 10 | "regexp" 11 | "strings" 12 | ) 13 | 14 | var imageRegex = regexp.MustCompile( 15 | `!\[(.*?)]\(([^" )]*?)\s*(".*?")?\)`, 16 | ) 17 | 18 | var ( 19 | ErrUrlParsing = errors.New("error parsing url") 20 | ErrInvalidImageHost = errors.New("不允许使用外部图片链接") 21 | ErrImageLinkTextOnly = errors.New("image link only contains text") 22 | ) 23 | 24 | // findImagesInMarkdown 从Markdown文本中查找所有图片链接,检查图片链接是否合法,并且返回清除链接之后的文本 25 | func findImagesInMarkdownContent(content string) (imageUrls []string, clearContent string, err error) { 26 | err = nil 27 | clearContent = imageRegex.ReplaceAllStringFunc(content, func(s string) string { 28 | if err != nil { 29 | return "" 30 | } 31 | submatch := imageRegex.FindStringSubmatch(s) 32 | altText := submatch[1] 33 | 34 | var imageUrl string 35 | if len(submatch) > 2 && submatch[2] != "" { 36 | imageUrl = submatch[2] 37 | innerErr := checkValidUrl(imageUrl) 38 | if innerErr != nil { 39 | if errors.Is(innerErr, ErrInvalidImageHost) { 40 | err = innerErr 41 | return "" 42 | } 43 | // if the url is not valid, treat as text only 44 | } else { 45 | // append only valid image url 46 | imageUrls = append(imageUrls, imageUrl) 47 | imageUrl = "" 48 | } 49 | } 50 | 51 | var title string 52 | if len(submatch) > 3 && submatch[3] != "" { 53 | title = strings.Trim(submatch[3], "\"") 54 | } 55 | 56 | var ret strings.Builder 57 | if altText != "" { 58 | ret.WriteString(altText) 59 | } 60 | if imageUrl != "" { 61 | if ret.String() != "" { 62 | ret.WriteString(" ") 63 | } 64 | ret.WriteString(imageUrl) 65 | } 66 | if title != "" { 67 | if ret.String() != "" { 68 | ret.WriteString(" ") 69 | } 70 | ret.WriteString(title) 71 | } 72 | return ret.String() 73 | }) 74 | return 75 | } 76 | 77 | func checkType(params ParamsForCheck) bool { 78 | return slices.Contains(checkTypes, params.TypeName) 79 | } 80 | 81 | func containsUnsafeURL(content string) (bool, string) { 82 | xurlsRelaxed := xurls.Relaxed() 83 | matchedURLs := xurlsRelaxed.FindAllString(content, -1) 84 | if len(matchedURLs) == 0 { 85 | return false, "" 86 | } 87 | 88 | for _, matchedURL := range matchedURLs { 89 | if !strings.Contains(matchedURL, "://") { 90 | matchedURL = "http://" + matchedURL 91 | } 92 | parsedURL, err := url.Parse(matchedURL) 93 | if err != nil || parsedURL == nil { 94 | return true, matchedURL 95 | } 96 | checked := slices.ContainsFunc(viper.GetStringSlice(common.EnvUrlHostnameWhitelist), func(s string) bool { 97 | return strings.HasSuffix(parsedURL.Host, s) 98 | }) 99 | if !checked { 100 | return true, parsedURL.Host 101 | } 102 | } 103 | return false, "" 104 | } 105 | 106 | func checkValidUrl(input string) error { 107 | imageUrl, err := url.Parse(input) 108 | if err != nil { 109 | return ErrUrlParsing 110 | } 111 | // if the url is text only, skip check 112 | if imageUrl.Scheme == "" && imageUrl.Host == "" { 113 | return ErrImageLinkTextOnly 114 | } 115 | if !slices.Contains(viper.GetStringSlice(common.EnvValidImageUrl), imageUrl.Hostname()) { 116 | return ErrInvalidImageHost 117 | } 118 | return nil 119 | } 120 | 121 | var reHole = regexp.MustCompile(`[^#]#(\d+)`) 122 | var reFloor = regexp.MustCompile(`##(\d+)`) 123 | 124 | func removeIDReprInContent(content string) string { 125 | content = " " + content 126 | content = reHole.ReplaceAllString(content, "") 127 | content = reFloor.ReplaceAllString(content, "") 128 | return strings.TrimSpace(content) 129 | } 130 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenTreeHole Backend Project 2 | 3 | DanXi & OpenTreeHole 后端 4 | 5 | ## 构建工具 6 | 7 | [![Go][go.dev]][go-url] 8 | [![Swagger][swagger.io]][swagger-url] 9 | 10 | ## 构建本项目 11 | 12 | ### 本地构建并运行 13 | 14 | ```shell 15 | # 克隆本项目 16 | git clone https://github.com/OpenTreeHole/backend.git 17 | cd chatdan_backend 18 | 19 | # 安装 swaggo 并且生成 API 文档 20 | go install github.com/swaggo/swag/cmd/swag@latest 21 | cd danke && swag init --pd --parseDepth 1 22 | 23 | # 运行 24 | go run danke/. 25 | 26 | # 在 docker 中运行 27 | docker compose up 28 | ``` 29 | 30 | API 文档详见启动项目之后的 http://localhost:8000/docs 31 | 32 | ### 生产部署 33 | 34 | #### 使用 docker 部署 35 | 36 | ```shell 37 | docker run -d \ 38 | --name opentreehole_backend \ 39 | -p 8000:8000 \ 40 | -v opentreehole_data:/app/data \ 41 | opentreehole/backend:latest 42 | ``` 43 | 44 | #### 使用 docker-compose 部署 45 | 46 | 修改 `docker-compose.yml` 47 | 48 | 环境变量: 49 | 50 | 1. `TZ`: 生产环境默认 `Asia/Shanghai` 51 | 2. `MODE`: 开发环境默认 `dev`, 生产环境默认 `production`, 可选 `test`, `bench` 52 | 3. `LOG_LEVEL`: 开发环境默认 `debug`, 生产环境默认 `info`, 可选 `warn`, `error`, `panic`, `fatal` 53 | 4. `PORT`: 默认 8000 54 | 5. `DB_TYPE`: 默认 `sqlite`, 可选 `mysql`, `postgres` 55 | 6. `DB_URL`: 默认 `sqlite.db` 56 | 7. `CACHE_TYPE`: 默认 `memory`, 可选 `redis` 57 | 8. `CACHE_URL`: 默认为空 58 | 59 | 数据卷: 60 | 61 | 1. `/app/data`: 数据库文件存放位置 62 | 63 | ### 开发指南 64 | 65 | 1. 基于数据流的分层架构。本项目分层为 `api` -> `model`。 66 | 1. `api` 中包含所有的路由处理函数,负责接收请求和返回响应,其中的请求和响应模型定义在 `schema` 中。 67 | 2. `model` 中包含所有的数据库模型定义,负责数据库的 CURD 操作。 68 | 3. 简单的 CURD 操作可以直接在 `api` 中完成,复杂的、共享的业务逻辑应该在 `model` 中完成。 69 | 2. 分离接口**请求和响应模型** `schema` 和**数据库模型** `model`。 70 | 1. `api` 接受和发送都只能使用 `schema`,数据库、缓存操作只能使用 `model`。 71 | 2. 如果 `model` 和 `schema` 之间的转换逻辑冗余,可以在 `schema` 72 | 中定义类似 `func (s *Schema) FromModel(m *Model) *Schema` 和 `func (s *Schema) ToModel() *Model` 73 | 绑定函数,保证模块的引用顺序是 `schema` -> `model`,避免循环引用。 74 | 3. 避免在 `model` 和 `schema` 中作数据库、缓存的CURD,这些都应该在加载 `model` 时完成。 75 | 4. 如果模型转换时需要用到其他辅助数据,需要作为函数参数传入转换函数。 76 | 77 | ## 计划路径 78 | 79 | - [ ] 迁移旧项目到此处 80 | 81 | ## 贡献列表 82 | 83 | 84 | contributors 85 | 86 | 87 | ## 联系方式 88 | 89 | JingYiJun - jingyijun@fduhole.com 90 | 91 | Danxi-Dev - dev@fduhole.com 92 | 93 | ## 项目链接 94 | 95 | [https://github.com/OpenTreeHole/backend](https://github.com/OpenTreeHole/backend) 96 | 97 | [//]: # (https://www.markdownguide.org/basic-syntax/#reference-style-links) 98 | 99 | [contributors-shield]: https://img.shields.io/github/contributors/OpenTreeHole/backend.svg?style=for-the-badge 100 | 101 | [contributors-url]: https://github.com/OpenTreeHole/backend/graphs/contributors 102 | 103 | [forks-shield]: https://img.shields.io/github/forks/OpenTreeHole/backend.svg?style=for-the-badge 104 | 105 | [forks-url]: https://github.com/OpenTreeHole/backend/network/members 106 | 107 | [stars-shield]: https://img.shields.io/github/stars/OpenTreeHole/backend.svg?style=for-the-badge 108 | 109 | [stars-url]: https://github.com/OpenTreeHole/backend/stargazers 110 | 111 | [issues-shield]: https://img.shields.io/github/issues/OpenTreeHole/backend.svg?style=for-the-badge 112 | 113 | [issues-url]: https://github.com/OpenTreeHole/backend/issues 114 | 115 | [license-shield]: https://img.shields.io/github/license/OpenTreeHole/backend.svg?style=for-the-badge 116 | 117 | [license-url]: https://github.com/OpenTreeHole/backend/blob/main/LICENSE 118 | 119 | [go.dev]: https://img.shields.io/badge/go-%2300ADD8.svg?style=for-the-badge&logo=go&logoColor=white 120 | 121 | [go-url]: https://go.dev 122 | 123 | [swagger.io]: https://img.shields.io/badge/-Swagger-%23Clojure?style=for-the-badge&logo=swagger&logoColor=white 124 | 125 | [swagger-url]: https://swagger.io -------------------------------------------------------------------------------- /danke/schema/course.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "github.com/jinzhu/copier" 5 | "github.com/opentreehole/backend/common" 6 | 7 | "github.com/opentreehole/backend/danke/model" 8 | ) 9 | 10 | /* V1 */ 11 | 12 | type CourseV1Response struct { 13 | ID int `json:"id"` 14 | Name string `json:"name"` // 名称 15 | Code string `json:"code"` // 编号 16 | CodeID string `json:"code_id"` // 选课序号。用于区分同一课程编号的不同平行班 17 | Credit float64 `json:"credit"` // 学分 18 | Department string `json:"department"` // 开课学院 19 | CampusName string `json:"campus_name"` // 开课校区 20 | Teachers string `json:"teachers"` // 老师:多个老师用逗号分隔 21 | MaxStudent int `json:"max_student"` // 最大选课人数 22 | WeekHour int `json:"week_hour"` // 周学时 23 | Year int `json:"year"` // 学年 24 | Semester int `json:"semester"` // 学期 25 | ReviewList []*ReviewV1Response `json:"review_list,omitempty"` // 评教列表 26 | } 27 | 28 | func (r *CourseV1Response) FromModel( 29 | user *common.User, 30 | course *model.Course, 31 | ) *CourseV1Response { 32 | err := copier.Copy(r, course) 33 | if err != nil { 34 | panic(err) 35 | } 36 | 37 | if course.Reviews == nil { 38 | return r 39 | } 40 | r.ReviewList = make([]*ReviewV1Response, 0, len(course.Reviews)) 41 | for _, review := range course.Reviews { 42 | r.ReviewList = append(r.ReviewList, new(ReviewV1Response).FromModel(user, review)) 43 | } 44 | 45 | return r 46 | } 47 | 48 | type CreateCourseV1Request struct { 49 | Name string `json:"name" validate:"required,min=1,max=255"` 50 | Code string `json:"code" validate:"required,min=4"` 51 | CodeID string `json:"code_id" validate:"required,min=4"` 52 | Credit float64 `json:"credit" validate:"min=0"` 53 | Department string `json:"department" validate:"required,min=1"` 54 | CampusName string `json:"campus_name"` 55 | Teachers string `json:"teachers" validate:"required,min=1"` 56 | MaxStudent int `json:"max_student" validate:"min=0"` 57 | WeekHour int `json:"week_hour" validate:"min=0"` 58 | Year int `json:"year" validate:"required,min=2000"` 59 | Semester int `json:"semester" validate:"required,min=1"` 60 | } 61 | 62 | func (r *CreateCourseV1Request) ToModel(groupID int) *model.Course { 63 | var course model.Course 64 | err := copier.Copy(&course, r) 65 | if err != nil { 66 | panic(err) 67 | } 68 | course.CourseGroupID = groupID 69 | return &course 70 | } 71 | 72 | func (r *CreateCourseV1Request) ToCourseGroupModel() *model.CourseGroup { 73 | var courseGroup model.CourseGroup 74 | err := copier.Copy(&courseGroup, r) 75 | if err != nil { 76 | panic(err) 77 | } 78 | return &courseGroup 79 | } 80 | 81 | /* V3 */ 82 | 83 | type CourseV3Response struct { 84 | ID int `json:"id"` 85 | Name string `json:"name"` // 名称 86 | Code string `json:"code"` // 编号 87 | CodeID string `json:"code_id"` // 选课序号。用于区分同一课程编号的不同平行班 88 | Credit float64 `json:"credit"` // 学分 89 | Department string `json:"department"` // 开课学院 90 | CampusName string `json:"campus_name"` // 开课校区 91 | Teachers string `json:"teachers"` // 老师:多个老师用逗号分隔 92 | MaxStudent int `json:"max_student"` // 最大选课人数 93 | WeekHour int `json:"week_hour"` // 周学时 94 | Year int `json:"year"` // 学年 95 | Semester int `json:"semester"` // 学期 96 | ReviewList []*ReviewV1Response `json:"review_list,omitempty"` // 评教列表 97 | } 98 | -------------------------------------------------------------------------------- /danke/model/course.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "context" 5 | "github.com/opentreehole/backend/common" 6 | "gorm.io/gorm" 7 | "gorm.io/gorm/clause" 8 | "slices" 9 | "strconv" 10 | "strings" 11 | "time" 12 | ) 13 | 14 | // Course 课程 15 | type Course struct { 16 | ID int `json:"id"` 17 | CreatedAt time.Time `json:"created_at"` 18 | UpdatedAt time.Time `json:"updated_at"` 19 | Name string `json:"name" gorm:"not null"` // 课程名称 20 | Code string `json:"code" gorm:"not null"` // 课程编号 21 | CodeID string `json:"code_id" gorm:"not null"` // 选课序号。用于区分同一课程编号的不同平行班 22 | Credit float64 `json:"credit" gorm:"not null"` // 学分 23 | Department string `json:"department" gorm:"not null"` // 开课学院 24 | CampusName string `json:"campus_name" gorm:"not null"` // 开课校区 25 | Teachers string `json:"teachers" gorm:"not null"` // 老师:多个老师用逗号分隔 26 | MaxStudent int `json:"max_student" gorm:"not null"` // 最大选课人数 27 | WeekHour int `json:"week_hour" gorm:"not null"` // 周学时 28 | Year int `json:"year" gorm:"not null"` // 学年 29 | Semester int `json:"semester" gorm:"not null"` // 学期 30 | CourseGroupID int `json:"course_group_id" gorm:"not null;index"` // 课程组类型 31 | CourseGroup *CourseGroup `json:"course_group"` 32 | ReviewCount int `json:"review_count" gorm:"not null;default:0"` // 评教数量 33 | Reviews ReviewList `json:"-"` // 所有评教 34 | } 35 | 36 | func (c *Course) Create() (err error) { 37 | err = DB.Transaction(func(tx *gorm.DB) (err error) { 38 | err = tx.Omit(clause.Associations).Create(c).Error 39 | if err != nil { 40 | return err 41 | } 42 | 43 | updateColumns := map[string]any{ 44 | "course_count": gorm.Expr("course_count + 1"), 45 | } 46 | 47 | // 如果课程组中没有该学分,则添加 48 | if !slices.Contains(c.CourseGroup.Credits, c.Credit) { 49 | // 添加学分 50 | c.CourseGroup.Credits = append(c.CourseGroup.Credits, c.Credit) 51 | 52 | // 手动拼接学分字符串 53 | var creditsString strings.Builder 54 | creditsString.WriteByte('[') 55 | for i, credit := range c.CourseGroup.Credits { 56 | if i != 0 { 57 | creditsString.WriteByte(',') 58 | } 59 | creditsString.WriteString(strconv.FormatFloat(credit, 'f', -1, 64)) 60 | } 61 | creditsString.WriteByte(']') 62 | updateColumns["credits"] = creditsString.String() 63 | } 64 | 65 | return tx.Model(&CourseGroup{ID: c.CourseGroupID}). 66 | Updates(updateColumns).Error 67 | }) 68 | if err != nil { 69 | return err 70 | } 71 | // clear cache 72 | return common.Cache.Delete(context.Background(), "danke:course_group") 73 | } 74 | 75 | type CourseList []*Course 76 | 77 | func (l CourseList) LoadReviewList(tx *gorm.DB, options ...FindReviewOption) (err error) { 78 | var option FindReviewOption 79 | if len(options) > 0 { 80 | option = options[0] 81 | } 82 | 83 | querySet := option.setQuery(tx) 84 | 85 | courseIDs := make([]int, len(l)) 86 | for i, course := range l { 87 | courseIDs[i] = course.ID 88 | } 89 | 90 | // 获取课程组的所有课程的所有评论,同时加载评论的历史记录和用户成就 91 | var reviews ReviewList 92 | err = querySet.Where("course_id IN ?", courseIDs).Find(&reviews).Error 93 | if err != nil { 94 | return err 95 | } 96 | 97 | // 将评论按照课程分组 98 | for _, review := range reviews { 99 | for _, course := range l { 100 | if course.ID == review.CourseID { 101 | course.Reviews = append(course.Reviews, review) 102 | } 103 | } 104 | } 105 | 106 | return 107 | } 108 | 109 | func (l CourseList) AllReviewList() (reviews ReviewList) { 110 | for _, course := range l { 111 | reviews = append(reviews, course.Reviews...) 112 | } 113 | return 114 | } 115 | -------------------------------------------------------------------------------- /common/validate.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "reflect" 5 | "strings" 6 | 7 | "github.com/creasty/defaults" 8 | "github.com/go-playground/validator/v10" 9 | "github.com/gofiber/fiber/v2" 10 | ) 11 | 12 | type ErrorDetailElement struct { 13 | Tag string `json:"tag"` 14 | Field string `json:"field"` 15 | Kind reflect.Kind `json:"-"` 16 | Value any `json:"value"` 17 | Param string `json:"param"` 18 | StructField string `json:"struct_field"` 19 | Message string `json:"message"` 20 | } 21 | 22 | func (e *ErrorDetailElement) Error() string { 23 | if e.Message != "" { 24 | return e.Message 25 | } 26 | 27 | switch e.Tag { 28 | case "min": 29 | if e.Kind == reflect.String { 30 | e.Message = e.Field + "至少" + e.Param + "字符" 31 | } else { 32 | e.Message = e.Field + "至少为" + e.Param 33 | } 34 | case "max": 35 | if e.Kind == reflect.String { 36 | e.Message = e.Field + "限长" + e.Param + "字符" 37 | } else { 38 | e.Message = e.Field + "至多为" + e.Param 39 | } 40 | case "required": 41 | e.Message = e.Field + "不能为空" 42 | case "email": 43 | e.Message = "邮箱格式不正确" 44 | default: 45 | e.Message = e.StructField + "格式不正确" 46 | } 47 | 48 | return e.Message 49 | } 50 | 51 | type ErrorDetail []*ErrorDetailElement 52 | 53 | func (e ErrorDetail) Error() string { 54 | if len(e) == 0 { 55 | return "Validation Error" 56 | } 57 | 58 | if len(e) == 1 { 59 | return e[0].Error() 60 | } 61 | 62 | var stringBuilder strings.Builder 63 | stringBuilder.WriteString(e[0].Error()) 64 | for _, err := range e[1:] { 65 | stringBuilder.WriteString(", ") 66 | stringBuilder.WriteString(err.Error()) 67 | } 68 | return stringBuilder.String() 69 | } 70 | 71 | var Validate = validator.New() 72 | 73 | func init() { 74 | Validate.RegisterTagNameFunc(func(fld reflect.StructField) string { 75 | name := strings.SplitN(fld.Tag.Get("json"), ",", 2)[0] 76 | 77 | if name == "-" { 78 | return "" 79 | } 80 | 81 | return name 82 | }) 83 | } 84 | 85 | func ValidateStruct(model any) error { 86 | errors := Validate.Struct(model) 87 | if errors != nil { 88 | var errorDetail ErrorDetail 89 | for _, err := range errors.(validator.ValidationErrors) { 90 | detail := ErrorDetailElement{ 91 | Field: err.Field(), 92 | Tag: err.Tag(), 93 | Param: err.Param(), 94 | Kind: err.Kind(), 95 | Value: err.Value(), 96 | StructField: err.StructField(), 97 | } 98 | errorDetail = append(errorDetail, &detail) 99 | } 100 | return &errorDetail 101 | } 102 | return nil 103 | } 104 | 105 | // ValidateQuery parse, set default and validate query into model 106 | func ValidateQuery(c *fiber.Ctx, model any) error { 107 | // parse query into struct 108 | // see https://docs.gofiber.io/api/ctx/#queryparser 109 | err := c.QueryParser(model) 110 | if err != nil { 111 | return BadRequest(err.Error()) 112 | } 113 | 114 | // set default value 115 | err = defaults.Set(model) 116 | if err != nil { 117 | return err 118 | } 119 | 120 | // Validate 121 | return ValidateStruct(model) 122 | } 123 | 124 | // ValidateBody parse, set default and validate body based on Content-Type. 125 | // It supports json, xml and form only when struct tag exists; if empty, using defaults. 126 | func ValidateBody(c *fiber.Ctx, model any) error { 127 | body := c.Body() 128 | 129 | // empty request body, return default value 130 | if len(body) == 0 { 131 | return defaults.Set(model) 132 | } 133 | 134 | // parse json, xml and form by fiber.BodyParser into struct 135 | // see https://docs.gofiber.io/api/ctx/#bodyparser 136 | err := c.BodyParser(model) 137 | if err != nil { 138 | return BadRequest(err.Error()) 139 | } 140 | 141 | // set default value 142 | err = defaults.Set(model) 143 | if err != nil { 144 | return err 145 | } 146 | 147 | // Validate 148 | return ValidateStruct(model) 149 | } 150 | -------------------------------------------------------------------------------- /danke/api/course.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "github.com/gofiber/fiber/v2" 6 | . "github.com/opentreehole/backend/common" 7 | . "github.com/opentreehole/backend/danke/model" 8 | . "github.com/opentreehole/backend/danke/schema" 9 | "gorm.io/gorm" 10 | ) 11 | 12 | // ListCoursesV1 godoc 13 | // @Summary list courses 14 | // @Description list all course_groups and courses, no reviews, old version or v1 version 15 | // @Tags Course 16 | // @Accept json 17 | // @Produce json 18 | // @Deprecated 19 | // @Router /courses [get] 20 | // @Success 200 {array} schema.CourseGroupV1Response 21 | // @Failure 400 {object} common.HttpError 22 | // @Failure 404 {object} common.HttpBaseError 23 | func ListCoursesV1(c *fiber.Ctx) (err error) { 24 | user, err := GetCurrentUser(c) 25 | if err != nil { 26 | return err 27 | } 28 | 29 | groups, _, err := FindGroupsWithCourses(false) 30 | if err != nil { 31 | return err 32 | } 33 | 34 | response := make([]*CourseGroupV1Response, 0, len(groups)) 35 | for _, group := range groups { 36 | response = append(response, new(CourseGroupV1Response).FromModel(user, group)) 37 | } 38 | 39 | return c.JSON(response) 40 | } 41 | 42 | // GetCourseV1 godoc 43 | // @Summary get a course 44 | // @Description get a course with reviews, v1 version 45 | // @Tags Course 46 | // @Accept json 47 | // @Produce json 48 | // @Deprecated 49 | // @Router /courses/{id} [get] 50 | // @Param id path int true "course_id" 51 | // @Success 200 {object} schema.CourseV1Response 52 | // @Failure 400 {object} common.HttpError 53 | // @Failure 404 {object} common.HttpBaseError 54 | func GetCourseV1(c *fiber.Ctx) (err error) { 55 | user, err := GetCurrentUser(c) 56 | if err != nil { 57 | return 58 | } 59 | 60 | id, err := c.ParamsInt("id") 61 | if err != nil { 62 | return 63 | } 64 | 65 | // 获取课程,课程的评论,评论的历史记录和用户成就 66 | var course Course 67 | err = DB. 68 | Preload("Reviews.History"). 69 | Preload("Reviews.UserAchievements.Achievement"). 70 | First(&course, id).Error 71 | if err != nil { 72 | return 73 | } 74 | 75 | // 获取课程的评论的自己的投票 76 | err = course.Reviews.LoadVoteListByUserID(user.ID) 77 | if err != nil { 78 | return err 79 | } 80 | 81 | return c.JSON(new(CourseV1Response).FromModel(user, &course)) 82 | } 83 | 84 | // AddCourseV1 godoc 85 | // @Summary add a course 86 | // @Description add a course, admin only 87 | // @Tags Course 88 | // @Accept json 89 | // @Produce json 90 | // @Router /courses [post] 91 | // @Param json body schema.CreateCourseV1Request true "json" 92 | // @Success 200 {object} schema.CourseV1Response 93 | // @Failure 400 {object} common.HttpError 94 | // @Failure 500 {object} common.HttpBaseError 95 | func AddCourseV1(c *fiber.Ctx) (err error) { 96 | user, err := GetCurrentUser(c) 97 | if err != nil { 98 | return err 99 | } 100 | 101 | var request CreateCourseV1Request 102 | err = ValidateBody(c, &request) 103 | if err != nil { 104 | return err 105 | } 106 | 107 | // 查找课程 108 | var course *Course 109 | err = DB. 110 | Where("code_id = ?", request.CodeID). 111 | Where("year = ?", request.Year). 112 | Where("semester = ?", request.Semester). 113 | First(&course).Error 114 | if err != nil { 115 | if !errors.Is(err, gorm.ErrRecordNotFound) { 116 | return err 117 | } 118 | } else { 119 | return BadRequest("该课程已存在") 120 | } 121 | 122 | // 根据 Code 查找课程组 123 | var courseGroup *CourseGroup 124 | err = DB.Preload("Courses").First(&courseGroup, "code = ?", request.Code).Error 125 | if err != nil { 126 | // 如果没有找到课程组,创建一个新的课程组 127 | if !errors.Is(err, gorm.ErrRecordNotFound) { 128 | return err 129 | } 130 | 131 | courseGroup = request.ToCourseGroupModel() 132 | err = DB.Create(&courseGroup).Error 133 | if err != nil { 134 | return err 135 | } 136 | } 137 | 138 | course = request.ToModel(courseGroup.ID) 139 | course.CourseGroup = courseGroup 140 | err = course.Create() 141 | if err != nil { 142 | return err 143 | } 144 | 145 | return c.JSON(new(CourseV1Response).FromModel(user, course)) 146 | } 147 | -------------------------------------------------------------------------------- /image_hosting/api/upload_image.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "github.com/gofiber/fiber/v2" 7 | "github.com/opentreehole/backend/common" 8 | . "github.com/opentreehole/backend/image_hosting/config" 9 | . "github.com/opentreehole/backend/image_hosting/model" 10 | . "github.com/opentreehole/backend/image_hosting/schema" 11 | . "github.com/opentreehole/backend/image_hosting/utils" 12 | "github.com/spf13/viper" 13 | "io" 14 | "log/slog" 15 | "path/filepath" 16 | "strings" 17 | "time" 18 | ) 19 | 20 | func UploadImage(c *fiber.Ctx) error { 21 | slog.LogAttrs(context.Background(), slog.LevelInfo, "uploading image") 22 | 23 | // response to frontend 24 | var response CheveretoUploadResponse 25 | // the file uploaded by user in the request body with the form-data key "source" 26 | file, err := c.FormFile("source") 27 | if err != nil { 28 | slog.LogAttrs(context.Background(), slog.LevelError, "No file uploaded", slog.String("err", err.Error())) 29 | return common.BadRequest("No file uploaded") 30 | } 31 | 32 | fileSize := file.Size 33 | maxSize := 10 * 1024 * 1024 // file should <= 10MB 34 | if int(fileSize) > maxSize { 35 | slog.LogAttrs(context.Background(), slog.LevelError, "File size is too large") 36 | return common.BadRequest("File size is too large") 37 | } 38 | 39 | fileExtension := strings.TrimPrefix(filepath.Ext(file.Filename), ".") 40 | 41 | if !IsAllowedExtension(fileExtension) { 42 | slog.LogAttrs(context.Background(), slog.LevelError, "File type not allowed.") 43 | return common.BadRequest("File type not allowed.") 44 | } 45 | 46 | fileContent, err := file.Open() 47 | if err != nil { 48 | slog.LogAttrs(context.Background(), slog.LevelError, "The uploaded file has some problems", slog.String("err", err.Error())) 49 | return common.BadRequest("The uploaded file has some problems") 50 | } 51 | 52 | imageData, err := io.ReadAll(fileContent) 53 | if err != nil { 54 | slog.LogAttrs(context.Background(), slog.LevelError, "The uploaded file has some problems", slog.String("err", err.Error())) 55 | return common.BadRequest("The uploaded file has some problems") 56 | } 57 | 58 | imageIdentifier, err := GenerateIdentifier() 59 | if err != nil { 60 | slog.LogAttrs(context.Background(), slog.LevelError, "Cannot generate image identifier", slog.String("err", err.Error())) 61 | return common.InternalServerError("Cannot generate image identifier") 62 | } 63 | 64 | var image ImageTable 65 | originalFileName := file.Filename 66 | result := DB.First(&image, "original_file_name = ?", originalFileName) 67 | if result.Error == nil { 68 | if bytes.Equal(image.ImageFileData, imageData) && strings.EqualFold(image.ImageType, fileExtension) { 69 | slog.LogAttrs(context.Background(), slog.LevelInfo, "The file has been uploaded before") 70 | imageIdentifier = image.ImageIdentifier 71 | imageUrl := viper.GetString(EnvHostName) + "/i/" + image.CreatedAt.Format("2006/01/02/") + imageIdentifier + "." + fileExtension 72 | response.StatusCode = 200 73 | response.StatusTxt = "The image has been uploaded before" 74 | response.Image = CheveretoImageInfo{ 75 | Name: imageIdentifier, 76 | Extension: image.ImageType, 77 | Filename: imageIdentifier + "." + fileExtension, 78 | Url: imageUrl, 79 | DisplayUrl: imageUrl, 80 | Mime: "image/" + fileExtension, 81 | } 82 | return c.JSON(&response) 83 | } 84 | } 85 | 86 | imageUrl := viper.GetString(EnvHostName) + "/i/" + time.Now().Format("2006/01/02/") + imageIdentifier + "." + fileExtension 87 | uploadedImage := &ImageTable{ 88 | ImageIdentifier: imageIdentifier, 89 | OriginalFileName: originalFileName, 90 | ImageType: fileExtension, 91 | ImageFileData: imageData, 92 | } 93 | err = DB.Create(&uploadedImage).Error 94 | 95 | if err != nil { 96 | slog.LogAttrs(context.Background(), slog.LevelError, "Database cannot store the image", slog.String("err", err.Error())) 97 | return common.InternalServerError("Database cannot store the image") 98 | } 99 | 100 | // if nothing went wrong 101 | response.StatusCode = 200 102 | response.StatusTxt = "Upload Success" 103 | response.Image = CheveretoImageInfo{ 104 | Name: imageIdentifier, 105 | Extension: fileExtension, 106 | Filename: imageIdentifier + "." + fileExtension, 107 | Url: imageUrl, 108 | DisplayUrl: imageUrl, 109 | Mime: "image/" + fileExtension, 110 | } 111 | slog.LogAttrs(context.Background(), slog.LevelInfo, "Image uploaded", slog.String("url", imageUrl)) 112 | return c.JSON(&response) 113 | 114 | } 115 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/opentreehole/backend 2 | 3 | go 1.22.1 4 | 5 | require ( 6 | github.com/allegro/bigcache/v3 v3.1.0 7 | github.com/creasty/defaults v1.7.0 8 | github.com/eko/gocache/lib/v4 v4.1.6 9 | github.com/eko/gocache/store/bigcache/v4 v4.2.1 10 | github.com/eko/gocache/store/redis/v4 v4.2.1 11 | github.com/glebarez/sqlite v1.11.0 12 | github.com/go-playground/validator/v10 v10.20.0 13 | github.com/gofiber/fiber/v2 v2.52.4 14 | github.com/gofiber/swagger v1.0.0 15 | github.com/golang-jwt/jwt/v5 v5.2.1 16 | github.com/jinzhu/copier v0.4.0 17 | github.com/redis/go-redis/v9 v9.5.1 18 | github.com/spf13/viper v1.18.2 19 | github.com/stretchr/testify v1.9.0 20 | github.com/swaggo/swag v1.16.3 21 | github.com/vmihailenco/msgpack/v5 v5.4.1 22 | github.com/yidun/yidun-golang-sdk v1.0.8 23 | golang.org/x/crypto v0.23.0 24 | golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d 25 | gorm.io/driver/mysql v1.5.6 26 | gorm.io/driver/postgres v1.5.7 27 | gorm.io/gorm v1.25.10 28 | gorm.io/plugin/dbresolver v1.5.1 29 | mvdan.cc/xurls/v2 v2.5.0 30 | ) 31 | 32 | require ( 33 | filippo.io/edwards25519 v1.1.0 // indirect 34 | github.com/KyleBanks/depth v1.2.1 // indirect 35 | github.com/andybalholm/brotli v1.1.0 // indirect 36 | github.com/beorn7/perks v1.0.1 // indirect 37 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 38 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 39 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect 40 | github.com/dustin/go-humanize v1.0.1 // indirect 41 | github.com/fsnotify/fsnotify v1.7.0 // indirect 42 | github.com/gabriel-vasile/mimetype v1.4.4 // indirect 43 | github.com/glebarez/go-sqlite v1.22.0 // indirect 44 | github.com/go-openapi/jsonpointer v0.21.0 // indirect 45 | github.com/go-openapi/jsonreference v0.21.0 // indirect 46 | github.com/go-openapi/spec v0.21.0 // indirect 47 | github.com/go-openapi/swag v0.23.0 // indirect 48 | github.com/go-playground/locales v0.14.1 // indirect 49 | github.com/go-playground/universal-translator v0.18.1 // indirect 50 | github.com/go-sql-driver/mysql v1.8.1 // indirect 51 | github.com/golang/mock v1.6.0 // indirect 52 | github.com/google/uuid v1.6.0 // indirect 53 | github.com/hashicorp/hcl v1.0.0 // indirect 54 | github.com/jackc/pgpassfile v1.0.0 // indirect 55 | github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 // indirect 56 | github.com/jackc/pgx/v5 v5.6.0 // indirect 57 | github.com/jackc/puddle/v2 v2.2.1 // indirect 58 | github.com/jinzhu/inflection v1.0.0 // indirect 59 | github.com/jinzhu/now v1.1.5 // indirect 60 | github.com/josharian/intern v1.0.0 // indirect 61 | github.com/klauspost/compress v1.17.8 // indirect 62 | github.com/leodido/go-urn v1.4.0 // indirect 63 | github.com/magiconair/properties v1.8.7 // indirect 64 | github.com/mailru/easyjson v0.7.7 // indirect 65 | github.com/mattn/go-colorable v0.1.13 // indirect 66 | github.com/mattn/go-isatty v0.0.20 // indirect 67 | github.com/mattn/go-runewidth v0.0.15 // indirect 68 | github.com/mitchellh/mapstructure v1.5.0 // indirect 69 | github.com/ncruces/go-strftime v0.1.9 // indirect 70 | github.com/patrickmn/go-cache v2.1.0+incompatible // indirect 71 | github.com/pelletier/go-toml/v2 v2.2.2 // indirect 72 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 73 | github.com/prometheus/client_golang v1.19.1 // indirect 74 | github.com/prometheus/client_model v0.6.1 // indirect 75 | github.com/prometheus/common v0.53.0 // indirect 76 | github.com/prometheus/procfs v0.15.0 // indirect 77 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect 78 | github.com/rivo/uniseg v0.4.7 // indirect 79 | github.com/sagikazarmark/locafero v0.4.0 // indirect 80 | github.com/sagikazarmark/slog-shim v0.1.0 // indirect 81 | github.com/sourcegraph/conc v0.3.0 // indirect 82 | github.com/spf13/afero v1.11.0 // indirect 83 | github.com/spf13/cast v1.6.0 // indirect 84 | github.com/spf13/pflag v1.0.5 // indirect 85 | github.com/subosito/gotenv v1.6.0 // indirect 86 | github.com/swaggo/files/v2 v2.0.0 // indirect 87 | github.com/tjfoc/gmsm v1.4.1 // indirect 88 | github.com/valyala/bytebufferpool v1.0.0 // indirect 89 | github.com/valyala/fasthttp v1.54.0 // indirect 90 | github.com/valyala/tcplisten v1.0.0 // indirect 91 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 92 | go.uber.org/multierr v1.11.0 // indirect 93 | golang.org/x/net v0.25.0 // indirect 94 | golang.org/x/sync v0.7.0 // indirect 95 | golang.org/x/sys v0.20.0 // indirect 96 | golang.org/x/text v0.15.0 // indirect 97 | golang.org/x/tools v0.21.0 // indirect 98 | google.golang.org/protobuf v1.34.1 // indirect 99 | gopkg.in/ini.v1 v1.67.0 // indirect 100 | gopkg.in/yaml.v3 v3.0.1 // indirect 101 | modernc.org/libc v1.50.9 // indirect 102 | modernc.org/mathutil v1.6.0 // indirect 103 | modernc.org/memory v1.8.0 // indirect 104 | modernc.org/sqlite v1.29.10 // indirect 105 | ) 106 | 107 | replace github.com/yidun/yidun-golang-sdk => github.com/jingyijun/yidun-golang-sdk v1.0.13-0.20240709102803-aaae270a5671 108 | -------------------------------------------------------------------------------- /danke/api/course_group.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/gofiber/fiber/v2" 7 | . "github.com/opentreehole/backend/common" 8 | . "github.com/opentreehole/backend/danke/model" 9 | . "github.com/opentreehole/backend/danke/schema" 10 | "gorm.io/gorm" 11 | ) 12 | 13 | // GetCourseGroupV1 godoc 14 | // @Summary /group/{group_id} 15 | // @Description get a course group, old version or v1 version 16 | // @Tags CourseGroup 17 | // @Accept json 18 | // @Produce json 19 | // @Deprecated 20 | // @Router /group/{id} [get] 21 | // @Param id path int true "course group id" 22 | // @Success 200 {object} schema.CourseGroupV1Response 23 | // @Failure 400 {object} common.HttpError 24 | // @Failure 404 {object} common.HttpBaseError 25 | // @Failure 500 {object} common.HttpBaseError 26 | func GetCourseGroupV1(c *fiber.Ctx) (err error) { 27 | user, err := GetCurrentUser(c) 28 | if err != nil { 29 | return err 30 | } 31 | 32 | groupID, err := c.ParamsInt("id") 33 | if err != nil { 34 | return err 35 | } 36 | 37 | // 获取课程组,同时加载课程 38 | // 这里不预加载课程的评论,因为评论作为动态的数据,应该独立作缓存,提高缓存粒度和缓存更新频率 39 | var courseGroup CourseGroup 40 | err = DB.Preload("Courses").First(&courseGroup, groupID).Error 41 | if err != nil { 42 | if errors.Is(err, gorm.ErrRecordNotFound) { 43 | return NotFound("课程组不存在") 44 | } 45 | return err 46 | } 47 | 48 | // 获取课程组的所有课程的所有评论,同时加载评论的历史记录和用户成就 49 | err = courseGroup.Courses.LoadReviewList(DB, FindReviewOption{PreloadHistory: true, PreloadAchievement: true}) 50 | if err != nil { 51 | return err 52 | } 53 | 54 | // 获取课程组的所有课程的所有评论的自己的投票 55 | err = courseGroup.Courses.AllReviewList().LoadVoteListByUserID(user.ID) 56 | if err != nil { 57 | return err 58 | } 59 | 60 | return c.JSON(new(CourseGroupV1Response).FromModel(user, &courseGroup)) 61 | } 62 | 63 | // GetCourseGroupHashV1 godoc 64 | // @Summary get course group hash 65 | // @Description get course group hash 66 | // @Tags CourseGroup 67 | // @Accept json 68 | // @Produce json 69 | // @Router /courses/hash [get] 70 | // @Success 200 {object} schema.CourseGroupHashV1Response 71 | // @Failure 400 {object} common.HttpError 72 | // @Failure 404 {object} common.HttpBaseError 73 | // @Failure 500 {object} common.HttpBaseError 74 | func GetCourseGroupHashV1(c *fiber.Ctx) (err error) { 75 | _, err = GetCurrentUser(c) 76 | if err != nil { 77 | return err 78 | } 79 | 80 | // 获取课程组哈希 81 | _, hash, err := FindGroupsWithCourses(false) 82 | if err != nil { 83 | return 84 | } 85 | 86 | return c.JSON(CourseGroupHashV1Response{Hash: hash}) 87 | } 88 | 89 | // RefreshCourseGroupHashV1 godoc 90 | // @Summary refresh course group hash 91 | // @Description refresh course group hash, admin only 92 | // @Tags CourseGroup 93 | // @Accept json 94 | // @Produce json 95 | // @Router /courses/refresh [get] 96 | // @Success 418 97 | // @Failure 400 {object} common.HttpError 98 | // @Failure 404 {object} common.HttpBaseError 99 | // @Failure 500 {object} common.HttpBaseError 100 | func RefreshCourseGroupHashV1(c *fiber.Ctx) (err error) { 101 | user, err := GetCurrentUser(c) 102 | if err != nil { 103 | return err 104 | } 105 | if !user.IsAdmin { 106 | return Forbidden() 107 | } 108 | 109 | // 刷新课程组哈希 110 | _, _, err = FindGroupsWithCourses(true) 111 | if err != nil { 112 | return 113 | } 114 | 115 | return c.SendStatus(fiber.StatusTeapot) 116 | } 117 | 118 | // SearchCourseGroupV3 godoc 119 | // @Summary search course group 120 | // @Description search course group, no courses 121 | // @Tags CourseGroup 122 | // @Accept json 123 | // @Produce json 124 | // @Router /v3/course_groups/search [get] 125 | // @Param request query schema.CourseGroupSearchV3Request true "search query" 126 | // @Success 200 {object} PagedResponse[schema.CourseGroupV3Response, any] 127 | // @Failure 400 {object} common.HttpError 128 | // @Failure 404 {object} common.HttpBaseError 129 | // @Failure 500 {object} common.HttpBaseError 130 | func SearchCourseGroupV3(c *fiber.Ctx) (err error) { 131 | user, err := GetCurrentUser(c) 132 | if err != nil { 133 | return err 134 | } 135 | 136 | var request CourseGroupSearchV3Request 137 | err = ValidateQuery(c, &request) 138 | if err != nil { 139 | return 140 | } 141 | 142 | var ( 143 | page = request.Page 144 | pageSize = request.PageSize 145 | query = request.Query 146 | ) 147 | 148 | var querySet = DB 149 | if CourseCodeRegexp.MatchString(query) { 150 | querySet = querySet.Where("code LIKE ?", query+"%") 151 | } else { 152 | queryWord :="%"+query+"%" 153 | querySet = querySet. 154 | Joins("JOIN teacher_course_groups tcg ON course_group.id = tcg.course_group_id"). 155 | Joins("JOIN teacher t ON tcg.teacher_id = t.id"). 156 | Where("t.name like ? OR course_group.name LIKE ?", queryWord, queryWord). 157 | Group("id") 158 | } 159 | if page > 0 { 160 | if pageSize == 0 { 161 | pageSize = 10 162 | } 163 | querySet = querySet.Limit(pageSize).Offset((page - 1) * pageSize) 164 | } else { 165 | page = 1 166 | if pageSize > 0 { 167 | querySet = querySet.Limit(pageSize) 168 | } 169 | } 170 | querySet = querySet.Order("id") 171 | 172 | var courseGroups CourseGroupList 173 | err = querySet.Find(&courseGroups).Error 174 | if err != nil { 175 | return err 176 | } 177 | 178 | items := make([]*CourseGroupV3Response, 0, len(courseGroups)) 179 | for _, group := range courseGroups { 180 | items = append(items, new(CourseGroupV3Response).FromModel(user, group)) 181 | } 182 | 183 | return c.JSON(PagedResponse[CourseGroupV3Response, any]{ 184 | Items: items, 185 | Page: page, 186 | PageSize: pageSize, 187 | }) 188 | } 189 | 190 | // GetCourseGroupV3 godoc 191 | // @Summary get a course group 192 | // @Description get a course group, v3 version 193 | // @Tags CourseGroup 194 | // @Accept json 195 | // @Produce json 196 | // @Router /v3/course_groups/{id} [get] 197 | // @Param id path int true "course group id" 198 | // @Success 200 {object} schema.CourseGroupV3Response 199 | // @Failure 400 {object} common.HttpError 200 | // @Failure 404 {object} common.HttpBaseError 201 | // @Failure 500 {object} common.HttpBaseError 202 | func GetCourseGroupV3(c *fiber.Ctx) (err error) { 203 | user, err := GetCurrentUser(c) 204 | if err != nil { 205 | return err 206 | } 207 | 208 | groupID, err := c.ParamsInt("id") 209 | if err != nil { 210 | return 211 | } 212 | 213 | var courseGroup CourseGroup 214 | err = DB.Preload("Courses").First(&courseGroup, groupID).Error 215 | if err != nil { 216 | if errors.Is(err, gorm.ErrRecordNotFound) { 217 | return NotFound("课程组不存在") 218 | } 219 | return err 220 | } 221 | 222 | // 获取课程组的所有课程的所有评论,同时加载评论的历史记录和用户成就 223 | err = courseGroup.Courses.LoadReviewList(DB, FindReviewOption{PreloadHistory: true, PreloadAchievement: true}) 224 | if err != nil { 225 | return err 226 | } 227 | 228 | // 获取课程组的所有课程的所有评论的自己的投票 229 | err = courseGroup.Courses.AllReviewList().LoadVoteListByUserID(user.ID) 230 | if err != nil { 231 | return err 232 | } 233 | 234 | return c.JSON(new(CourseGroupV3Response).FromModel(user, &courseGroup)) 235 | } 236 | -------------------------------------------------------------------------------- /danke/model/review.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "errors" 5 | "github.com/opentreehole/backend/common" 6 | "gorm.io/gorm" 7 | "time" 8 | ) 9 | 10 | // ReviewRank 评教分数 11 | type ReviewRank struct { 12 | Overall int `json:"overall"` 13 | Content int `json:"content"` // 内容、风格方面 14 | Workload int `json:"workload"` // 工作量方面 15 | Assessment int `json:"assessment"` // 考核方面 16 | } 17 | 18 | // Review 评教 19 | type Review struct { 20 | ID int `json:"id"` 21 | CreatedAt time.Time `json:"created_at" gorm:"not null"` 22 | UpdatedAt time.Time `json:"updated_at" gorm:"not null"` 23 | CourseID int `json:"course_id" gorm:"not null;index"` 24 | Course *Course `json:"course"` 25 | Title string `json:"title" gorm:"not null"` 26 | Content string `json:"content" gorm:"not null"` 27 | ReviewerID int `json:"reviewer_id" gorm:"not null;index"` 28 | Rank *ReviewRank `json:"rank" gorm:"embedded;embeddedPrefix:rank_"` 29 | UpvoteCount int `json:"upvote_count" gorm:"not null;default:0"` 30 | DownvoteCount int `json:"downvote_count" gorm:"not null;default:0"` 31 | ModifyCount int `json:"modify_count" gorm:"not null;default:0"` 32 | History ReviewHistoryList `json:"-"` 33 | Vote ReviewVoteList `json:"-" gorm:"foreignKey:ReviewID;references:ID"` 34 | UserAchievements []*UserAchievement `json:"-" gorm:"foreignKey:UserID;references:ReviewerID"` 35 | DeletedAt gorm.DeletedAt `json:"deleted_at" gorm:"index"` 36 | IsSensitive bool `json:"is_sensitive"` 37 | IsActuallySensitive *bool `json:"is_actually_sensitive"` 38 | SensitiveDetail string `json:"sensitive_detail,omitempty"` 39 | } 40 | 41 | type FindReviewOption struct { 42 | PreloadHistory bool 43 | PreloadAchievement bool 44 | PreloadVote bool 45 | UserID int 46 | } 47 | 48 | func (o FindReviewOption) setQuery(querySet *gorm.DB) *gorm.DB { 49 | if o.PreloadHistory { 50 | querySet = querySet.Preload("History") 51 | } 52 | if o.PreloadAchievement { 53 | querySet = querySet.Preload("UserAchievements.Achievement") 54 | } 55 | if o.PreloadVote { 56 | if o.UserID != 0 { 57 | querySet = querySet.Preload("Vote", "user_id = ?", o.UserID) 58 | } else { 59 | querySet = querySet.Preload("Vote") 60 | } 61 | } 62 | return querySet 63 | } 64 | 65 | func (r *Review) Sensitive() bool { 66 | if r == nil { 67 | return false 68 | } 69 | if r.IsActuallySensitive != nil { 70 | return *r.IsActuallySensitive 71 | } 72 | return r.IsSensitive 73 | } 74 | 75 | func FindReviewByID(tx *gorm.DB, reviewID int, options ...FindReviewOption) (review *Review, err error) { 76 | var option FindReviewOption 77 | if len(options) > 0 { 78 | option = options[0] 79 | } 80 | 81 | querySet := option.setQuery(tx) 82 | err = querySet.First(&review, reviewID).Error 83 | if errors.Is(err, gorm.ErrRecordNotFound) { 84 | return nil, common.NotFound("评论不存在") 85 | } 86 | return 87 | } 88 | 89 | func (r *Review) LoadVoteListByUserID(userID int) (err error) { 90 | return ReviewList{r}.LoadVoteListByUserID(userID) 91 | } 92 | 93 | func (r *Review) Create(tx *gorm.DB) (err error) { 94 | return tx.Transaction(func(tx *gorm.DB) (err error) { 95 | // 查找 course 96 | var course Course 97 | err = DB.First(&course, r.CourseID).Error 98 | if err != nil { 99 | if errors.Is(err, gorm.ErrRecordNotFound) { 100 | return common.NotFound("课程不存在") 101 | } 102 | return err 103 | } 104 | 105 | // 检查是否已经评教过 106 | var count int64 107 | err = tx.Model(&Review{}). 108 | Where("course_id = ? AND reviewer_id = ?", r.CourseID, r.ReviewerID). 109 | Count(&count).Error 110 | if err != nil { 111 | return 112 | } 113 | if count > 0 { 114 | return common.BadRequest("已经评教过,请勿重复评教") 115 | } 116 | 117 | // 创建评教 118 | err = tx.Create(r).Error 119 | if err != nil { 120 | return 121 | } 122 | 123 | // 更新课程评教数量 124 | err = tx.Model(&Course{ID: r.CourseID}). 125 | Update("review_count", gorm.Expr("review_count + 1")).Error 126 | if err != nil { 127 | return 128 | } 129 | 130 | // 更新课程组评教数量 131 | if r.Course != nil { 132 | err = tx.Model(&CourseGroup{ID: r.Course.CourseGroupID}). 133 | Update("review_count", gorm.Expr("review_count + 1")).Error 134 | if err != nil { 135 | return err 136 | } 137 | } else { 138 | var reviewGroupID int 139 | err = tx.Model(&Course{}).Select("course_group_id"). 140 | Where("id = ?", r.CourseID).Scan(&reviewGroupID).Error 141 | if err != nil { 142 | return err 143 | } 144 | 145 | err = tx.Model(&CourseGroup{ID: reviewGroupID}). 146 | Update("review_count", gorm.Expr("review_count + 1")).Error 147 | if err != nil { 148 | return err 149 | } 150 | } 151 | return 152 | }) 153 | 154 | } 155 | 156 | func (r *Review) Update(tx *gorm.DB, newReview Review) (err error) { 157 | // 记录修改历史 158 | var history ReviewHistory 159 | history.FromReview(r) 160 | 161 | // 更新评教 162 | modifyData := make(map[string]any) 163 | modified := false 164 | if newReview.Title != "" { 165 | modifyData["title"] = newReview.Title 166 | modified = true 167 | } 168 | if newReview.Content != "" { 169 | modifyData["content"] = newReview.Content 170 | modified = true 171 | } 172 | if newReview.Rank != nil { 173 | modifyData["rank_overall"] = newReview.Rank.Overall 174 | modifyData["rank_content"] = newReview.Rank.Content 175 | modifyData["rank_workload"] = newReview.Rank.Workload 176 | modifyData["rank_assessment"] = newReview.Rank.Assessment 177 | modified = true 178 | } 179 | if !modified { 180 | return common.BadRequest("没有修改内容") 181 | } 182 | modifyData["is_sensitive"] = newReview.IsSensitive 183 | modifyData["is_actually_sensitive"] = newReview.IsActuallySensitive 184 | modifyData["sensitive_detail"] = newReview.SensitiveDetail 185 | modifyData["modify_count"] = r.ModifyCount + 1 186 | 187 | err = tx.Transaction(func(tx *gorm.DB) (err error) { 188 | // stupid gorm does not support update embedded struct using Model, Select and Updates 189 | // if using both 'Rank' and 'Content' in Select, it will only update rank_content 190 | err = tx.Model(r).Updates(modifyData).Error 191 | if err != nil { 192 | return 193 | } 194 | 195 | return tx.Create(&history).Error 196 | }) 197 | return 198 | } 199 | 200 | type ReviewList []*Review 201 | 202 | func (l ReviewList) LoadVoteListByUserID(userID int) (err error) { 203 | reviewIDs := make([]int, 0) 204 | for _, review := range l { 205 | reviewIDs = append(reviewIDs, review.ID) 206 | } 207 | var votes ReviewVoteList 208 | err = DB. 209 | Where("review_id IN ?", reviewIDs). 210 | Where("user_id = ?", userID). 211 | Find(&votes).Error 212 | if err != nil { 213 | return err 214 | } 215 | 216 | for _, vote := range votes { 217 | for _, review := range l { 218 | if review.ID == vote.ReviewID { 219 | review.Vote = append(review.Vote, vote) 220 | } 221 | } 222 | } 223 | return 224 | } 225 | 226 | // ReviewHistory 评教修改历史 227 | type ReviewHistory struct { 228 | ID int `json:"id"` 229 | CreatedAt time.Time `json:"created_at"` // 创建时间,原本是 time_created 230 | UpdatedAt time.Time `json:"updated_at"` // 更新时间,原本是 time_updated 231 | ReviewID int `json:"review_id" gorm:"not null;index"` 232 | AlterBy int `json:"alter_by" gorm:"not null"` // 修改人 ID 233 | Title string `json:"title" gorm:"not null"` // 修改前的标题 234 | Content string `json:"content" gorm:"not null"` // 修改前的内容 235 | IsSensitive bool `json:"is_sensitive"` 236 | IsActuallySensitive *bool `json:"is_actual_sensitive"` 237 | SensitiveDetail string `json:"sensitive_detail,omitempty"` 238 | } 239 | 240 | func (h *ReviewHistory) FromReview(review *Review) { 241 | h.ReviewID = review.ID 242 | h.AlterBy = review.ReviewerID 243 | h.Title = review.Title 244 | h.Content = review.Content 245 | h.IsSensitive = review.IsSensitive 246 | h.IsActuallySensitive = review.IsActuallySensitive 247 | h.SensitiveDetail = review.SensitiveDetail 248 | } 249 | 250 | type ReviewHistoryList []*ReviewHistory 251 | 252 | // ReviewVote 评教点赞/点踩详情 253 | type ReviewVote struct { 254 | UserID int `json:"user_id" gorm:"primaryKey"` // 点赞或点踩人的 ID 255 | ReviewID int `json:"review_id" gorm:"primaryKey"` // 评教 ID 256 | Data int `json:"data"` // 1 为点赞,-1 为点踩 257 | } 258 | 259 | type ReviewVoteList []*ReviewVote 260 | -------------------------------------------------------------------------------- /danke/schema/review.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "github.com/opentreehole/backend/common" 5 | "time" 6 | 7 | "github.com/jinzhu/copier" 8 | 9 | "github.com/opentreehole/backend/danke/model" 10 | ) 11 | 12 | // ReviewRankV1 旧版本评分 13 | type ReviewRankV1 struct { 14 | Overall int `json:"overall" validate:"min=1,max=5"` // 总体方面 15 | Content int `json:"content" validate:"min=1,max=5"` // 内容、风格方面 16 | Workload int `json:"workload" validate:"min=1,max=5"` // 工作量方面 17 | Assessment int `json:"assessment" validate:"min=1,max=5"` // 考核方面 18 | } 19 | 20 | func (r *ReviewRankV1) FromModel(rank *model.ReviewRank) *ReviewRankV1 { 21 | err := copier.Copy(r, rank) 22 | if err != nil { 23 | panic(err) 24 | } 25 | return r 26 | } 27 | 28 | func (r *ReviewRankV1) ToModel() (rank *model.ReviewRank) { 29 | rank = new(model.ReviewRank) 30 | err := copier.Copy(rank, r) 31 | if err != nil { 32 | panic(err) 33 | } 34 | return 35 | } 36 | 37 | // AchievementV1Response 旧版本成就响应 38 | type AchievementV1Response struct { 39 | Name string `json:"name"` // 成就名称 40 | Domain string `json:"domain"` // 成就域 41 | ObtainDate time.Time `json:"obtain_date"` // 获取日期 42 | } 43 | 44 | func (r *AchievementV1Response) FromModel( 45 | achievement *model.Achievement, 46 | userAchievement *model.UserAchievement, 47 | ) *AchievementV1Response { 48 | err := copier.Copy(r, userAchievement) 49 | if err != nil { 50 | panic(err) 51 | } 52 | 53 | r.Name = achievement.Name 54 | r.Domain = achievement.Domain 55 | 56 | return r 57 | } 58 | 59 | // UserExtraV1 旧版本用户额外信息 60 | type UserExtraV1 struct { 61 | // 用户成就,slices 必须非空 62 | Achievements []*AchievementV1Response `json:"achievements"` 63 | } 64 | 65 | // ReviewV1Response 旧版本评教响应 66 | type ReviewV1Response struct { 67 | ID int `json:"id"` 68 | TimeCreated time.Time `json:"time_created" copier:"CreatedAt"` // 创建时间 69 | TimeUpdated time.Time `json:"time_updated" copier:"UpdatedAt"` // 更新时间 70 | Title string `json:"title"` // 评教标题 71 | Content string `json:"content"` // 评教内容 72 | ReviewerID int `json:"reviewer_id"` // 评教者 ID 73 | Rank *ReviewRankV1 `json:"rank"` // 评价 74 | Vote int `json:"vote"` // 自己是否点赞或点踩,0 未操作,1 点赞,-1 点踩 75 | Remark int `json:"remark"` // Remark = 点赞数 - 点踩数 76 | IsMe bool `json:"is_me"` // 是否是自己的评教 77 | History []*ReviewHistoryV1Response `json:"history"` // 修改历史,slices 必须非空 78 | Extra UserExtraV1 `json:"extra"` // 额外信息 79 | } 80 | 81 | func (r *ReviewV1Response) FromModel( 82 | user *common.User, 83 | review *model.Review, 84 | ) *ReviewV1Response { 85 | err := copier.Copy(r, review) 86 | if err != nil { 87 | panic(err) 88 | } 89 | 90 | if review.Sensitive() { 91 | if review.IsActuallySensitive != nil && *review.IsActuallySensitive { 92 | r.Content = "该内容因违反社区规范被删除" 93 | r.Title = "该内容因违反社区规范被删除" 94 | } else { 95 | r.Content = "该内容正在审核中" 96 | r.Title = "该内容正在审核中" 97 | } 98 | } 99 | 100 | if user != nil { 101 | r.IsMe = user.ID == review.ReviewerID 102 | } else { 103 | r.IsMe = false 104 | } 105 | 106 | r.Rank = new(ReviewRankV1).FromModel(review.Rank) 107 | r.Remark = review.UpvoteCount - review.DownvoteCount 108 | 109 | if user != nil { 110 | for _, vote := range review.Vote { 111 | if vote.UserID == user.ID { 112 | r.Vote = vote.Data 113 | } 114 | } 115 | } 116 | 117 | r.History = make([]*ReviewHistoryV1Response, 0, len(review.History)) 118 | for _, history := range review.History { 119 | r.History = append(r.History, new(ReviewHistoryV1Response).FromModel(review, history, r.Rank)) 120 | } 121 | 122 | r.Extra.Achievements = make([]*AchievementV1Response, 0, len(review.UserAchievements)) 123 | for _, userAchievement := range review.UserAchievements { 124 | r.Extra.Achievements = append(r.Extra.Achievements, new(AchievementV1Response).FromModel(userAchievement.Achievement, userAchievement)) 125 | } 126 | return r 127 | } 128 | 129 | type ReviewHistoryV1 struct { 130 | Title string `json:"title"` // 旧标题 131 | Content string `json:"content"` // 旧内容 132 | TimeCreated time.Time `json:"time_created"` // 创建时间 133 | TimeUpdated time.Time `json:"time_updated"` // 更新时间 134 | ReviewerID int `json:"reviewer_id"` // 评教者 135 | Rank *ReviewRankV1 `json:"rank"` // 评价 136 | Remark int `json:"remark"` // Remark = 点赞数 - 点踩数 137 | } 138 | 139 | // ReviewHistoryV1Response 旧版本评教修改历史响应 140 | type ReviewHistoryV1Response struct { 141 | Time time.Time `json:"time"` // 创建时间 142 | AlterBy int `json:"alter_by"` // 修改者 143 | Original *ReviewHistoryV1 `json:"original"` // 修改前的评教 144 | } 145 | 146 | func (r *ReviewHistoryV1Response) FromModel( 147 | review *model.Review, 148 | history *model.ReviewHistory, 149 | rank *ReviewRankV1, 150 | ) *ReviewHistoryV1Response { 151 | r.Time = history.CreatedAt 152 | r.AlterBy = history.AlterBy 153 | r.Original = &ReviewHistoryV1{ 154 | Title: history.Title, 155 | Content: history.Content, 156 | TimeCreated: review.CreatedAt, 157 | TimeUpdated: history.CreatedAt, 158 | ReviewerID: review.ReviewerID, 159 | Rank: rank, 160 | Remark: review.UpvoteCount - review.DownvoteCount, 161 | } 162 | 163 | return r 164 | } 165 | 166 | type CreateReviewV1Request struct { 167 | Title string `json:"title" validate:"required,min=1,max=64"` 168 | Content string `json:"content" validate:"required,min=1,max=10240"` 169 | Rank ReviewRankV1 `json:"rank"` 170 | } 171 | 172 | type ModifyReviewV1Request = CreateReviewV1Request 173 | 174 | func (r *CreateReviewV1Request) ToModel(reviewerID, courseID int) *model.Review { 175 | review := new(model.Review) 176 | err := copier.Copy(review, r) 177 | if err != nil { 178 | panic(err) 179 | } 180 | review.ReviewerID = reviewerID 181 | review.CourseID = courseID 182 | review.Rank = r.Rank.ToModel() 183 | return review 184 | } 185 | 186 | type VoteForReviewV1Request struct { 187 | Upvote bool `json:"upvote"` 188 | } 189 | 190 | type MyReviewV1Response struct { 191 | ID int `json:"id"` 192 | Title string `json:"title"` // 评教标题 193 | Content string `json:"content"` // 评教内容 194 | History []*ReviewHistoryV1Response `json:"history"` // 修改历史,slices 必须非空 195 | TimeCreated time.Time `json:"time_created" copier:"CreatedAt"` // 创建时间 196 | TimeUpdated time.Time `json:"time_updated" copier:"UpdatedAt"` // 更新时间 197 | ReviewerID int `json:"reviewer_id"` // 评教者 ID 198 | Rank *ReviewRankV1 `json:"rank"` // 评价 199 | Vote int `json:"vote"` // 自己是否点赞或点踩,0 未操作,1 点赞,-1 点踩 200 | Remark int `json:"remark"` // Remark = 点赞数 - 点踩数 201 | Extra UserExtraV1 `json:"extra"` // 额外信息 202 | Course *CourseV1Response `json:"course,omitempty"` // 课程信息 203 | GroupID int `json:"group_id,omitempty"` // 课程组 ID 204 | } 205 | 206 | func (r *MyReviewV1Response) FromModel( 207 | review *model.Review, 208 | ) *MyReviewV1Response { 209 | err := copier.Copy(r, review) 210 | if err != nil { 211 | panic(err) 212 | } 213 | 214 | r.Rank = new(ReviewRankV1).FromModel(review.Rank) 215 | r.Remark = review.UpvoteCount - review.DownvoteCount 216 | for _, vote := range review.Vote { 217 | if vote.UserID == review.ReviewerID { 218 | r.Vote = vote.Data 219 | } 220 | } 221 | r.History = make([]*ReviewHistoryV1Response, 0, len(review.History)) 222 | for _, history := range review.History { 223 | r.History = append(r.History, new(ReviewHistoryV1Response).FromModel(review, history, r.Rank)) 224 | } 225 | 226 | r.Extra.Achievements = make([]*AchievementV1Response, 0, len(review.UserAchievements)) 227 | for _, userAchievement := range review.UserAchievements { 228 | r.Extra.Achievements = append(r.Extra.Achievements, new(AchievementV1Response).FromModel(userAchievement.Achievement, userAchievement)) 229 | } 230 | 231 | // here course.Reviews is nil, so no need to send votesMap and user 232 | if review.Course != nil { 233 | r.Course = new(CourseV1Response).FromModel(nil, review.Course) 234 | r.GroupID = review.Course.CourseGroupID 235 | } 236 | 237 | return r 238 | } 239 | 240 | type RandomReviewV1Response = MyReviewV1Response 241 | 242 | /* V3 */ 243 | 244 | type ReviewRankV3 = ReviewRankV1 245 | 246 | type AchievementV3Response = AchievementV1Response 247 | 248 | type UserExtraV3 struct { 249 | Achievements []*AchievementV3Response `json:"achievements"` 250 | } 251 | 252 | type ReviewV3Response struct { 253 | ID int `json:"id"` 254 | CreatedAt time.Time `json:"created_at"` // 创建时间 255 | UpdatedAt time.Time `json:"updated_at"` // 更新时间 256 | CourseID int `json:"course_id"` // 课程 ID 257 | Title string `json:"title"` // 评教标题 258 | Content string `json:"content"` // 评教内容 259 | ReviewerID int `json:"reviewer_id"` // 评教者 ID 260 | Rank *ReviewRankV3 `json:"rank"` // 评价 261 | MyVote int `json:"my_vote"` // 自己是否点赞或点踩,0 未操作,1 点赞,-1 点踩 262 | UpvoteCount int `json:"upvote_count"` // 点赞数 263 | DownvoteCount int `json:"downvote_count"` // 点踩数 264 | IsMe bool `json:"is_me"` // 是否是自己的评教 265 | Extra UserExtraV3 `json:"extra"` // 额外信息 266 | } 267 | 268 | func (r *ReviewV3Response) FromModel( 269 | user *common.User, 270 | review *model.Review, 271 | votesMap map[int]*model.ReviewVote, 272 | ) *ReviewV3Response { 273 | err := copier.Copy(r, review) 274 | if err != nil { 275 | panic(err) 276 | } 277 | 278 | if user != nil { 279 | r.IsMe = user.ID == review.ReviewerID 280 | } else { 281 | r.IsMe = false 282 | } 283 | 284 | r.Rank = new(ReviewRankV1).FromModel(review.Rank) 285 | if votesMap != nil && votesMap[review.ID] != nil { 286 | r.MyVote = votesMap[review.ID].Data 287 | } else { 288 | r.MyVote = 0 289 | } 290 | 291 | r.Extra.Achievements = make([]*AchievementV1Response, 0, len(review.UserAchievements)) 292 | for _, userAchievement := range review.UserAchievements { 293 | if userAchievement.Achievement == nil { 294 | continue 295 | } 296 | r.Extra.Achievements = append(r.Extra.Achievements, new(AchievementV1Response).FromModel(userAchievement.Achievement, userAchievement)) 297 | } 298 | return r 299 | 300 | } 301 | 302 | type SensitiveReviewRequest struct { 303 | Size int `json:"size" query:"size" default:"10" validate:"max=10"` 304 | Offset common.CustomTime `json:"offset" query:"offset" swaggertype:"string"` 305 | Open bool `json:"open" query:"open"` 306 | All bool `json:"all" query:"all"` 307 | } 308 | 309 | type SensitiveReviewResponse struct { 310 | ID int `json:"id"` 311 | CreatedAt time.Time `json:"time_created"` 312 | UpdatedAt time.Time `json:"time_updated"` 313 | Content string `json:"content"` 314 | IsActuallySensitive *bool `json:"is_actually_sensitive"` 315 | SensitiveDetail string `json:"sensitive_detail,omitempty"` 316 | ModifyCount int `json:"modify_count"` 317 | Title string `json:"title"` 318 | Course *CourseV1Response `json:"course"` 319 | } 320 | 321 | func (s *SensitiveReviewResponse) FromModel(review *model.Review) *SensitiveReviewResponse { 322 | s.ID = review.ID 323 | s.CreatedAt = review.CreatedAt 324 | s.UpdatedAt = review.UpdatedAt 325 | s.Content = review.Content 326 | s.ModifyCount = review.ModifyCount 327 | s.Title = review.Title 328 | s.Course = new(CourseV1Response).FromModel(nil, review.Course) 329 | s.IsActuallySensitive = review.IsActuallySensitive 330 | s.SensitiveDetail = review.SensitiveDetail 331 | return s 332 | } 333 | 334 | type ModifySensitiveReviewRequest struct { 335 | IsActuallySensitive bool `json:"is_actually_sensitive"` 336 | } 337 | -------------------------------------------------------------------------------- /common/sensitive/api.go: -------------------------------------------------------------------------------- 1 | package sensitive 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/opentreehole/backend/common" 8 | "github.com/spf13/viper" 9 | "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/image/v5" 10 | "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/image/v5/check" 11 | "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/label" 12 | "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/label/request" 13 | v5 "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/text" 14 | "github.com/yidun/yidun-golang-sdk/yidun/service/antispam/text/v5/check/sync/single" 15 | "net/http" 16 | "net/url" 17 | "strconv" 18 | "strings" 19 | "sync" 20 | "time" 21 | ) 22 | 23 | const ( 24 | //TypeHole = "Hole" 25 | //TypeFloor = "Floor" 26 | //TypeTag = "Tag" 27 | TypeImage = "Image" 28 | TypeReview = "Review" 29 | TypeTitle = "Title" 30 | ) 31 | 32 | var checkTypes = []string{TypeImage, TypeReview, TypeTitle} 33 | 34 | type ParamsForCheck struct { 35 | Content string 36 | Id int64 37 | TypeName string 38 | } 39 | 40 | type ResponseForCheck struct { 41 | Pass bool 42 | Labels []int 43 | Detail string 44 | } 45 | 46 | func CheckSensitive(params ParamsForCheck) (resp *ResponseForCheck, err error) { 47 | images, clearContent, err := findImagesInMarkdownContent(params.Content) 48 | if err != nil { 49 | return nil, err 50 | } 51 | if len(images) != 0 { 52 | for _, img := range images { 53 | imgUrl, err := url.Parse(img) 54 | if err != nil { 55 | return nil, err 56 | } 57 | host := viper.GetString(common.EnvExternalImageHost) 58 | if host != "" { 59 | imgUrl.Host = host 60 | } 61 | ret, err := checkSensitiveImage(ParamsForCheck{ 62 | Content: imgUrl.String(), 63 | Id: time.Now().UnixNano(), 64 | TypeName: TypeImage, 65 | }) 66 | if err != nil { 67 | return nil, err 68 | } 69 | if !ret.Pass { 70 | return ret, nil 71 | } 72 | } 73 | } 74 | 75 | contained, reason := containsUnsafeURL(clearContent) 76 | if contained { 77 | return &ResponseForCheck{ 78 | Pass: false, 79 | Labels: nil, 80 | Detail: "不允许使用外部链接" + reason, 81 | }, nil 82 | } 83 | params.Content = strings.TrimSpace(removeIDReprInContent(clearContent)) 84 | if params.Content == "" { 85 | return &ResponseForCheck{ 86 | Pass: true, 87 | Labels: nil, 88 | Detail: "", 89 | }, nil 90 | } 91 | 92 | return CheckSensitiveText(params) 93 | } 94 | 95 | func CheckSensitiveText(params ParamsForCheck) (resp *ResponseForCheck, err error) { 96 | if !checkType(params) { 97 | return nil, fmt.Errorf("invalid type for sensitive check") 98 | } 99 | 100 | request := single.NewTextCheckRequest(viper.GetString(common.EnvYiDunBusinessIdText)) 101 | var textCheckClient *v5.TextClient 102 | if viper.GetString(common.EnvProxyUrl) != "" { 103 | var proxyUrl *url.URL 104 | proxyUrl, err := url.Parse(viper.GetString(common.EnvProxyUrl)) 105 | if err != nil { 106 | return nil, err 107 | } 108 | textCheckClient = v5.NewTextClientWithAccessKeyWithProxy(viper.GetString(common.EnvYiDunSecretId), viper.GetString(common.EnvYiDunSecretKey), http.ProxyURL(proxyUrl)) 109 | } else { 110 | textCheckClient = v5.NewTextClientWithAccessKey(viper.GetString(common.EnvYiDunSecretId), viper.GetString(common.EnvYiDunSecretKey)) 111 | } 112 | 113 | request.SetDataID(strconv.FormatInt(params.Id, 10) + "_" + params.TypeName) 114 | request.SetContent(params.Content) 115 | request.SetTimestamp(time.Now().UnixMilli()) 116 | 117 | response, err := textCheckClient.SyncCheckText(request) 118 | if err != nil { 119 | // 处理错误并打印日志 120 | common.RequestLog(fmt.Sprintf("sync request error:%+v", err.Error()), params.TypeName, params.Id, false) 121 | return &ResponseForCheck{Pass: false}, nil 122 | } 123 | 124 | resp = &ResponseForCheck{} 125 | if response.GetCode() == 200 { 126 | 127 | if *response.Result.Antispam.Suggestion == 0 { 128 | common.RequestLog("Sensitive text check response code is 200", params.TypeName, params.Id, true) 129 | resp.Pass = true 130 | return 131 | } 132 | 133 | common.RequestLog("Sensitive text check response code is 200", params.TypeName, params.Id, false) 134 | resp.Pass = false 135 | var sensitiveDetailBuilder strings.Builder 136 | sensitiveLabelMap.RLock() 137 | defer sensitiveLabelMap.RUnlock() 138 | for _, label := range response.Result.Antispam.Labels { 139 | if label.Label == nil { 140 | continue 141 | } 142 | resp.Labels = append(resp.Labels, *label.Label) 143 | // response != nil && response.Result != nil && response.Result.Antispam != nil && 144 | //if response.Result.Antispam.SecondLabel != nil && response.Result.Antispam.ThirdLabel != nil { 145 | // str := *response.Result.Antispam.SecondLabel + " " + *response.Result.Antispam.ThirdLabel 146 | //} 147 | labelNumber := *label.Label 148 | if sensitiveLabelMap.data[labelNumber] != nil { 149 | sensitiveDetailBuilder.WriteString("{") 150 | sensitiveDetailBuilder.WriteString(sensitiveLabelMap.label[labelNumber]) 151 | sensitiveDetailBuilder.WriteString("}") 152 | } 153 | 154 | if label.SubLabels != nil { 155 | for _, subLabel := range label.SubLabels { 156 | if sensitiveLabelMap.data[labelNumber] != nil { 157 | if subLabel.SubLabel != nil { 158 | sensitiveDetailBuilder.WriteString("[" + sensitiveLabelMap.data[labelNumber][*subLabel.SubLabel] + "]") 159 | } 160 | if subLabel.SecondLabel != nil { 161 | sensitiveDetailBuilder.WriteString("[" + sensitiveLabelMap.data[labelNumber][*subLabel.SecondLabel] + "]") 162 | } 163 | if subLabel.ThirdLabel != nil { 164 | sensitiveDetailBuilder.WriteString("[" + sensitiveLabelMap.data[labelNumber][*subLabel.ThirdLabel] + "]") 165 | } 166 | } 167 | 168 | if subLabel.Details != nil && subLabel.Details.HitInfos != nil { 169 | for _, hitInfo := range subLabel.Details.HitInfos { 170 | if hitInfo.Value == nil { 171 | continue 172 | } 173 | if sensitiveDetailBuilder.Len() != 0 { 174 | sensitiveDetailBuilder.WriteString("\n") 175 | } 176 | sensitiveDetailBuilder.WriteString(*hitInfo.Value) 177 | } 178 | } 179 | } 180 | } 181 | } 182 | if sensitiveDetailBuilder.Len() == 0 { 183 | sensitiveDetailBuilder.WriteString("文本敏感,未知原因") 184 | } 185 | resp.Detail = sensitiveDetailBuilder.String() 186 | return 187 | } 188 | 189 | common.RequestLog("Sensitive text check http response code is not 200", params.TypeName, params.Id, false) 190 | resp.Pass = false 191 | return 192 | } 193 | 194 | func checkSensitiveImage(params ParamsForCheck) (resp *ResponseForCheck, err error) { 195 | // 设置易盾内容安全分配的businessId 196 | imgUrl := params.Content 197 | 198 | request := check.NewImageV5CheckRequest(viper.GetString(common.EnvYiDunBusinessIdImage)) 199 | 200 | // 实例化一个textClient,入参需要传入易盾内容安全分配的secretId,secretKey 201 | var imageCheckClient *image.ImageClient 202 | if viper.GetString(common.EnvProxyUrl) != "" { 203 | var proxyUrl *url.URL 204 | proxyUrl, err := url.Parse(viper.GetString(common.EnvProxyUrl)) 205 | if err != nil { 206 | return nil, err 207 | } 208 | imageCheckClient = image.NewImageClientWithAccessKeyWithProxy(viper.GetString(common.EnvYiDunSecretId), viper.GetString(common.EnvYiDunSecretKey), http.ProxyURL(proxyUrl)) 209 | } else { 210 | imageCheckClient = image.NewImageClientWithAccessKey(viper.GetString(common.EnvYiDunSecretId), viper.GetString(common.EnvYiDunSecretKey)) 211 | } 212 | 213 | imageInst := check.NewImageBeanRequest() 214 | imageInst.SetData(imgUrl) 215 | imageInst.SetName(strconv.FormatInt(params.Id, 10) + "_" + params.TypeName) 216 | // 设置图片数据的类型,1:图片URL,2:图片BASE64 217 | imageInst.SetType(1) 218 | 219 | imageBeans := []check.ImageBeanRequest{*imageInst} 220 | request.SetImages(imageBeans) 221 | 222 | response, err := imageCheckClient.ImageSyncCheck(request) 223 | if err != nil { 224 | // 处理错误并打印日志 225 | common.RequestLog(fmt.Sprintf("sync request error:%+v", err.Error()), params.TypeName, params.Id, false) 226 | // TODO: 通知管理员 227 | return &ResponseForCheck{Pass: false}, nil 228 | } 229 | 230 | resp = &ResponseForCheck{} 231 | if response.GetCode() == 200 { 232 | if len(*response.Result) == 0 { 233 | return nil, fmt.Errorf("sensitive image check returns empty response") 234 | } 235 | 236 | if *((*response.Result)[0].Antispam.Suggestion) == 0 { 237 | common.RequestLog("Sensitive image check response code is 200", params.TypeName, params.Id, true) 238 | resp.Pass = true 239 | return 240 | } 241 | 242 | common.RequestLog("Sensitive image check response code is 200", params.TypeName, params.Id, false) 243 | resp.Pass = false 244 | for _, label := range *((*response.Result)[0].Antispam.Labels) { 245 | resp.Labels = append(resp.Labels, *label.Label) 246 | } 247 | var sensitiveDetailBuilder strings.Builder 248 | sensitiveLabelMap.RLock() 249 | defer sensitiveLabelMap.RUnlock() 250 | for _, result := range *response.Result { 251 | if result.Antispam != nil && result.Antispam.Labels != nil { 252 | for _, label := range *result.Antispam.Labels { 253 | if label.Label == nil || label.SubLabels == nil { 254 | continue 255 | } 256 | 257 | labelNumber := *label.Label 258 | if sensitiveLabelMap.data[labelNumber] != nil { 259 | sensitiveDetailBuilder.WriteString("{") 260 | sensitiveDetailBuilder.WriteString(sensitiveLabelMap.label[labelNumber]) 261 | sensitiveDetailBuilder.WriteString("}") 262 | } 263 | 264 | if label.SubLabels != nil { 265 | for _, subLabel := range *label.SubLabels { 266 | if sensitiveLabelMap.data[labelNumber] != nil { 267 | if subLabel.SubLabel != nil { 268 | sensitiveDetailBuilder.WriteString("[" + sensitiveLabelMap.data[labelNumber][*subLabel.SubLabel] + "]") 269 | } 270 | if subLabel.SecondLabel != nil { 271 | sensitiveDetailBuilder.WriteString("[" + sensitiveLabelMap.data[labelNumber][*subLabel.SecondLabel] + "]") 272 | } 273 | if subLabel.ThirdLabel != nil { 274 | sensitiveDetailBuilder.WriteString("[" + sensitiveLabelMap.data[labelNumber][*subLabel.ThirdLabel] + "]") 275 | } 276 | } 277 | 278 | if subLabel.Details != nil && subLabel.Details.HitInfos != nil { 279 | for _, hitInfo := range *subLabel.Details.HitInfos { 280 | if hitInfo.Group != nil { 281 | sensitiveDetailBuilder.WriteByte(' ') 282 | sensitiveDetailBuilder.WriteString(*hitInfo.Group) 283 | } 284 | if hitInfo.Value != nil { 285 | sensitiveDetailBuilder.WriteByte(' ') 286 | sensitiveDetailBuilder.WriteString(*hitInfo.Value) 287 | } 288 | if hitInfo.Word != nil { 289 | sensitiveDetailBuilder.WriteByte(' ') 290 | sensitiveDetailBuilder.WriteString(*hitInfo.Word) 291 | } 292 | } 293 | } 294 | } 295 | } 296 | } 297 | } 298 | if result.Ocr != nil { 299 | if result.Ocr.Details != nil { 300 | for _, detail := range *result.Ocr.Details { 301 | if detail.Content == nil { 302 | continue 303 | } 304 | if sensitiveDetailBuilder.Len() != 0 { 305 | sensitiveDetailBuilder.WriteString("\n") 306 | } 307 | sensitiveDetailBuilder.WriteString(*detail.Content) 308 | } 309 | } 310 | } 311 | if result.Face != nil { 312 | if result.Face.Details != nil { 313 | for _, detail := range *result.Face.Details { 314 | if detail.FaceContents != nil { 315 | for _, faceContent := range *detail.FaceContents { 316 | if faceContent.Name == nil { 317 | continue 318 | } 319 | if sensitiveDetailBuilder.Len() != 0 { 320 | sensitiveDetailBuilder.WriteString("\n") 321 | } 322 | sensitiveDetailBuilder.WriteString(*faceContent.Name) 323 | } 324 | } 325 | } 326 | } 327 | } 328 | } 329 | if sensitiveDetailBuilder.Len() == 0 { 330 | sensitiveDetailBuilder.WriteString("图片敏感,未知原因") 331 | } 332 | resp.Detail = sensitiveDetailBuilder.String() 333 | return 334 | } 335 | 336 | common.RequestLog("Sensitive image check http response code is not 200", params.TypeName, params.Id, false) 337 | resp.Pass = false 338 | return 339 | } 340 | 341 | var sensitiveLabelMap struct { 342 | sync.RWMutex 343 | label map[int]string 344 | data map[int]map[string]string 345 | lastLength int 346 | } 347 | 348 | func InitSensitiveLabelMap() { 349 | // skip when bench 350 | 351 | // || viper.GetString(common.AuthUrl) == "" 352 | if viper.GetString(common.EnvMode) == "bench" { 353 | return 354 | } 355 | 356 | // 创建一个LabelQueryRequest实例 357 | request := request.NewLabelQueryRequest() 358 | 359 | // 实例化Client,入参需要传入易盾内容安全分配的AccessKeyId,AccessKeySecret 360 | labelClient := label.NewLabelClientWithAccessKey(viper.GetString(common.EnvYiDunAccessKeyId), viper.GetString(common.EnvYiDunAccessKeySecret)) 361 | 362 | // 传入请求参数 363 | //设置返回标签的最大层级 364 | request.SetMaxDepth(3) 365 | //指定业务类型 366 | // request.SetBusinessTypes(&[]string{"1", "2"}) 367 | //制定业务 368 | // request.SetBusinessID("SetBusinessID") 369 | // request.SetClientID("YOUR_CLIENT_ID") 370 | // request.SetLanguage("en") 371 | 372 | response, err := labelClient.QueryLabel(request) 373 | if err != nil { 374 | // log.Err(err).Str("model", "get admin").Msg("error sending auth server") 375 | common.RequestLog("Sensitive label init error", "label error", -1, false) 376 | return 377 | } 378 | 379 | if response.GetCode() != 200 { 380 | // log.Error().Str("model", "get admin").Msg("auth server response failed" + res.Status) 381 | common.RequestLog("Sensitive label init http response code is not 200", "label error", -1, false) 382 | return 383 | } 384 | 385 | responseByte, err := json.Marshal(response) 386 | if err != nil { 387 | common.RequestLog("Sensitive label Marshal error", "label error", -1, false) 388 | return 389 | } 390 | 391 | if sensitiveLabelMap.lastLength == len(responseByte) { 392 | common.RequestLog("Sensitive label unchanged", "label unchanged", 1, false) 393 | return 394 | } 395 | 396 | sensitiveLabelMap.Lock() 397 | defer sensitiveLabelMap.Unlock() 398 | sensitiveLabelMap.lastLength = len(responseByte) 399 | sensitiveLabelMap.label = make(map[int]string) 400 | sensitiveLabelMap.data = make(map[int]map[string]string) 401 | data := response.Data 402 | 403 | for _, label := range data { 404 | if label.Label == nil || label.Name == nil { 405 | continue 406 | } 407 | sensitiveLabelMap.label[*label.Label] = *label.Name 408 | labelNumber := *label.Label 409 | labelMap := make(map[string]string) 410 | for _, subLabel := range label.SubLabels { 411 | if subLabel.Code == nil || subLabel.Name == nil { 412 | continue 413 | } 414 | labelMap[*subLabel.Code] = *subLabel.Name 415 | for _, subSubLabel := range subLabel.SubLabels { 416 | if subSubLabel.Code == nil || subSubLabel.Name == nil { 417 | continue 418 | } 419 | labelMap[*subLabel.Code] = *subLabel.Name 420 | } 421 | } 422 | sensitiveLabelMap.data[labelNumber] = labelMap 423 | } 424 | } 425 | 426 | func UpdateSensitiveLabelMap(ctx context.Context) { 427 | ticker := time.NewTicker(time.Hour) 428 | for { 429 | select { 430 | case <-ctx.Done(): 431 | return 432 | case <-ticker.C: 433 | InitSensitiveLabelMap() 434 | } 435 | } 436 | } 437 | -------------------------------------------------------------------------------- /danke/api/review.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "github.com/gofiber/fiber/v2" 6 | . "github.com/opentreehole/backend/common" 7 | "github.com/opentreehole/backend/common/sensitive" 8 | . "github.com/opentreehole/backend/danke/model" 9 | . "github.com/opentreehole/backend/danke/schema" 10 | "gorm.io/gorm" 11 | "gorm.io/gorm/clause" 12 | "gorm.io/plugin/dbresolver" 13 | "time" 14 | ) 15 | 16 | // GetReviewV1 godoc 17 | // @Summary get a review 18 | // @Description get a review 19 | // @Tags Review 20 | // @Accept json 21 | // @Produce json 22 | // @Router /reviews/{id} [get] 23 | // @Param id path int true "review id" 24 | // @Success 200 {object} schema.ReviewV1Response 25 | // @Failure 400 {object} common.HttpError 26 | func GetReviewV1(c *fiber.Ctx) (err error) { 27 | user, err := GetCurrentUser(c) 28 | if err != nil { 29 | return 30 | } 31 | 32 | id, err := c.ParamsInt("id") 33 | if err != nil { 34 | return 35 | } 36 | 37 | review, err := FindReviewByID(DB, id, FindReviewOption{ 38 | PreloadHistory: true, 39 | PreloadAchievement: true, 40 | PreloadVote: true, 41 | UserID: user.ID, 42 | }) 43 | if err != nil { 44 | return 45 | } 46 | 47 | return c.JSON(new(ReviewV1Response).FromModel(user, review)) 48 | } 49 | 50 | // ListReviewsV1 godoc 51 | // @Summary list reviews 52 | // @Description list reviews 53 | // @Tags Review 54 | // @Accept json 55 | // @Produce json 56 | // @Router /courses/{id}/reviews [get] 57 | // @Param id path int true "course id" 58 | // @Success 200 {array} schema.ReviewV1Response 59 | // @Failure 400 {object} common.HttpError 60 | func ListReviewsV1(c *fiber.Ctx) (err error) { 61 | user, err := GetCurrentUser(c) 62 | if err != nil { 63 | return 64 | } 65 | 66 | courseID, err := c.ParamsInt("id") 67 | if err != nil { 68 | return 69 | } 70 | 71 | // 查找评论 72 | var reviews ReviewList 73 | err = DB.Find(&reviews, "course_id = ?", courseID).Error 74 | if err != nil { 75 | return 76 | } 77 | 78 | // 加载评论投票 79 | err = reviews.LoadVoteListByUserID(user.ID) 80 | if err != nil { 81 | return 82 | } 83 | 84 | // 创建 response 85 | response := make([]*ReviewV1Response, 0, len(reviews)) 86 | for _, review := range reviews { 87 | response = append(response, new(ReviewV1Response).FromModel(user, review)) 88 | } 89 | 90 | return c.JSON(response) 91 | } 92 | 93 | // CreateReviewV1 godoc 94 | // @Summary create a review 95 | // @Description create a review 96 | // @Tags Review 97 | // @Accept json 98 | // @Produce json 99 | // @Param json body schema.CreateReviewV1Request true "json" 100 | // @Param course_id path int true "course id" 101 | // @Router /courses/{course_id}/reviews [post] 102 | // @Success 200 {object} schema.ReviewV1Response 103 | // @Failure 400 {object} common.HttpError 104 | // @Failure 404 {object} common.HttpBaseError 105 | func CreateReviewV1(c *fiber.Ctx) (err error) { 106 | user, err := GetCurrentUser(c) 107 | if err != nil { 108 | return 109 | } 110 | 111 | var req CreateReviewV1Request 112 | err = ValidateBody(c, &req) 113 | if err != nil { 114 | return 115 | } 116 | 117 | courseID, err := c.ParamsInt("id") 118 | if err != nil { 119 | return 120 | } 121 | 122 | // 创建评论 123 | review := req.ToModel(user.ID, courseID) 124 | 125 | sensitiveResp, err := sensitive.CheckSensitive(sensitive.ParamsForCheck{ 126 | Content: req.Title + "\n" + req.Content, 127 | Id: time.Now().UnixNano(), 128 | TypeName: sensitive.TypeTitle, 129 | }) 130 | if err != nil { 131 | return 132 | } 133 | review.IsSensitive = !sensitiveResp.Pass 134 | review.IsActuallySensitive = nil 135 | review.SensitiveDetail = sensitiveResp.Detail 136 | 137 | err = review.Create(DB) 138 | if err != nil { 139 | return 140 | } 141 | 142 | return c.JSON(new(ReviewV1Response).FromModel(user, review)) 143 | } 144 | 145 | // ModifyReviewV1 godoc 146 | // @Summary modify a review 147 | // @Description modify a review, admin or owner can modify 148 | // @Tags Review 149 | // @Accept json 150 | // @Produce json 151 | // @Param json body schema.ModifyReviewV1Request true "json" 152 | // @Param review_id path int true "review id" 153 | // @Router /reviews/{review_id} [put] 154 | // @Router /reviews/{review_id}/_webvpn [patch] 155 | // @Success 200 {object} schema.ReviewV1Response 156 | // @Failure 400 {object} common.HttpError 157 | // @Failure 404 {object} common.HttpBaseError 158 | func ModifyReviewV1(c *fiber.Ctx) (err error) { 159 | user, err := GetCurrentUser(c) 160 | if err != nil { 161 | return err 162 | } 163 | 164 | var req ModifyReviewV1Request 165 | err = ValidateBody(c, &req) 166 | if err != nil { 167 | return 168 | } 169 | 170 | id, err := c.ParamsInt("id") 171 | if err != nil { 172 | return 173 | } 174 | 175 | // 查找评论 176 | review, err := FindReviewByID(DB, id) 177 | if err != nil { 178 | return 179 | } 180 | 181 | // 检查权限 182 | if !user.IsAdmin && review.ReviewerID != user.ID { 183 | return Forbidden("没有权限") 184 | } 185 | 186 | sensitiveResp, err := sensitive.CheckSensitive(sensitive.ParamsForCheck{ 187 | Content: req.Title + "\n" + req.Content, 188 | Id: time.Now().UnixNano(), 189 | TypeName: sensitive.TypeTitle, 190 | }) 191 | if err != nil { 192 | return err 193 | } 194 | 195 | var newReview = Review{ 196 | IsSensitive: !sensitiveResp.Pass, 197 | IsActuallySensitive: nil, 198 | SensitiveDetail: sensitiveResp.Detail, 199 | Content: req.Content, 200 | Title: req.Title, 201 | Rank: req.Rank.ToModel(), 202 | } 203 | 204 | // 修改评论 205 | err = review.Update(DB, newReview) 206 | if err != nil { 207 | return 208 | } 209 | 210 | // 查找评论 211 | review, err = FindReviewByID(DB, id, FindReviewOption{ 212 | PreloadHistory: true, 213 | PreloadAchievement: true, 214 | PreloadVote: true, 215 | UserID: user.ID, 216 | }) 217 | if err != nil { 218 | return 219 | } 220 | 221 | return c.JSON(new(ReviewV1Response).FromModel(user, review)) 222 | } 223 | 224 | // DeleteReviewV1 godoc 225 | // @Summary delete a review 226 | // @Description delete a review, admin or owner can delete 227 | // @Tags Review 228 | // @Accept json 229 | // @Produce json 230 | // @Param review_id path int true "review id" 231 | // @Router /reviews/{review_id} [delete] 232 | // @Success 204 233 | // @Failure 400 {object} common.HttpError 234 | // @Failure 403 {object} common.HttpError 235 | // @Failure 404 {object} common.HttpError 236 | func DeleteReviewV1(c *fiber.Ctx) (err error) { 237 | user, err := GetCurrentUser(c) 238 | if err != nil { 239 | return err 240 | } 241 | 242 | id, err := c.ParamsInt("id") 243 | if err != nil { 244 | return 245 | } 246 | 247 | // 查找评论 248 | review, err := FindReviewByID(DB, id) 249 | if err != nil { 250 | return 251 | } 252 | 253 | // 检查权限 254 | if !user.IsAdmin && review.ReviewerID != user.ID { 255 | return Forbidden("没有权限") 256 | } 257 | 258 | // 删除评论 259 | err = DB.Delete(review).Error 260 | if err != nil { 261 | return 262 | } 263 | 264 | return c.Status(204).JSON(nil) 265 | } 266 | 267 | // VoteForReviewV1 godoc 268 | // @Summary vote for a review 269 | // @Description vote for a review 270 | // @Tags Review 271 | // @Accept json 272 | // @Produce json 273 | // @Param json body schema.VoteForReviewV1Request true "json" 274 | // @Param review_id path int true "review id" 275 | // @Router /reviews/{review_id} [patch] 276 | // @Success 200 {object} schema.ReviewV1Response 277 | // @Failure 400 {object} common.HttpError 278 | // @Failure 404 {object} common.HttpBaseError 279 | func VoteForReviewV1(c *fiber.Ctx) (err error) { 280 | user, err := GetCurrentUser(c) 281 | if err != nil { 282 | return err 283 | } 284 | 285 | var req VoteForReviewV1Request 286 | err = ValidateBody(c, &req) 287 | if err != nil { 288 | return 289 | } 290 | 291 | reviewID, err := c.ParamsInt("id") 292 | if err != nil { 293 | return 294 | } 295 | 296 | // 查找评论 297 | review, err := FindReviewByID(DB, reviewID) 298 | if err != nil { 299 | return err 300 | } 301 | 302 | err = DB.Transaction(func(tx *gorm.DB) (err error) { 303 | // 获取用户投票 304 | var vote ReviewVote 305 | err = tx.Where("review_id = ? AND user_id = ?", reviewID, user.ID).First(&vote).Error 306 | if err != nil { 307 | if !errors.Is(err, gorm.ErrRecordNotFound) { 308 | return 309 | } 310 | } 311 | 312 | if req.Upvote { 313 | if vote.Data == 1 { 314 | vote.Data = 0 315 | } else { 316 | vote.Data = 1 317 | } 318 | } else { 319 | if vote.Data == -1 { 320 | vote.Data = 0 321 | } else { 322 | vote.Data = -1 323 | } 324 | } 325 | 326 | vote.UserID = user.ID 327 | vote.ReviewID = reviewID 328 | 329 | // 更新投票 330 | err = tx.Save(&vote).Error 331 | if err != nil { 332 | return 333 | } 334 | 335 | // 更新评论投票数 336 | err = tx.Model(&review). 337 | UpdateColumns(map[string]any{ 338 | "upvote_count": tx. 339 | Model(&ReviewVote{}). 340 | Where("review_id = ? AND data = 1", reviewID). 341 | Select("count(*)"), 342 | "downvote_count": tx. 343 | Model(&ReviewVote{}). 344 | Where("review_id = ? AND data = -1", reviewID). 345 | Select("count(*)"), 346 | }).Error 347 | return 348 | }) 349 | if err != nil { 350 | return 351 | } 352 | 353 | // 查找评论 354 | review, err = FindReviewByID(DB, reviewID, FindReviewOption{ 355 | PreloadHistory: true, 356 | PreloadAchievement: true, 357 | PreloadVote: true, 358 | UserID: user.ID, 359 | }) 360 | if err != nil { 361 | return err 362 | } 363 | 364 | return c.JSON(new(ReviewV1Response).FromModel(user, review)) 365 | } 366 | 367 | // ListMyReviewsV1 godoc 368 | // @Summary list my reviews 369 | // @Description list my reviews, old version. load history and achievements, no `is_me` field 370 | // @Tags Review 371 | // @Accept json 372 | // @Produce json 373 | // @Router /reviews/me [get] 374 | // @Success 200 {array} schema.MyReviewV1Response 375 | // @Failure 400 {object} common.HttpError 376 | // @Failure 404 {object} common.HttpBaseError 377 | func ListMyReviewsV1(c *fiber.Ctx) (err error) { 378 | user, err := GetCurrentUser(c) 379 | if err != nil { 380 | return err 381 | } 382 | 383 | // 查找评论 384 | var reviews ReviewList 385 | err = DB.Find(&reviews, "reviewer_id = ?", user.ID).Error 386 | if err != nil { 387 | return 388 | } 389 | 390 | // 加载评论投票 391 | err = reviews.LoadVoteListByUserID(user.ID) 392 | if err != nil { 393 | return 394 | } 395 | 396 | // 创建 response 397 | response := make([]*MyReviewV1Response, 0, len(reviews)) 398 | for _, review := range reviews { 399 | response = append(response, new(MyReviewV1Response).FromModel(review)) 400 | } 401 | 402 | return c.JSON(response) 403 | } 404 | 405 | // GetRandomReviewV1 godoc 406 | // @Summary get random review 407 | // @Description get random review 408 | // @Tags Review 409 | // @Accept json 410 | // @Produce json 411 | // @Router /reviews/random [get] 412 | // @Success 200 {object} schema.RandomReviewV1Response 413 | // @Failure 400 {object} common.HttpError 414 | // @Failure 404 {object} common.HttpBaseError 415 | func GetRandomReviewV1(c *fiber.Ctx) (err error) { 416 | user, err := GetCurrentUser(c) 417 | if err != nil { 418 | return err 419 | } 420 | 421 | // 获取随机评论 422 | var review Review 423 | if DB.Dialector.Name() == "mysql" { 424 | err = DB.Preload("Course").Joins(`JOIN (SELECT ROUND(RAND() * ((SELECT MAX(id) FROM review) - (SELECT MIN(id) FROM review)) + (SELECT MIN(id) FROM review)) AS id) AS number_table`). 425 | Where("review.id >= number_table.id").Limit(1).First(&review).Error 426 | } else { 427 | err = DB.Order("RANDOM()").Limit(1).First(&review).Error 428 | } 429 | if err != nil { 430 | return 431 | } 432 | 433 | // 加载评论投票 434 | err = review.LoadVoteListByUserID(user.ID) 435 | if err != nil { 436 | return 437 | } 438 | 439 | return c.JSON(new(RandomReviewV1Response).FromModel(&review)) 440 | } 441 | 442 | // ListSensitiveReviews 443 | // 444 | // @Summary List sensitive reviews, admin only 445 | // @Tags Review 446 | // @Produce application/json 447 | // @Router /v3/reviews/_sensitive [get] 448 | // @Param object query schema.SensitiveReviewRequest false "query" 449 | // @Success 200 {array} schema.SensitiveReviewResponse 450 | // @Failure 404 {object} common.HttpBaseError 451 | func ListSensitiveReviews(c *fiber.Ctx) (err error) { 452 | // validate query 453 | var query SensitiveReviewRequest 454 | err = ValidateQuery(c, &query) 455 | if err != nil { 456 | return err 457 | } 458 | 459 | // set default time 460 | if query.Offset.Time.IsZero() { 461 | query.Offset.Time = time.Now() 462 | } 463 | 464 | // get user 465 | user, err := GetCurrentUser(c) 466 | if err != nil { 467 | return err 468 | } 469 | 470 | // permission, admin only 471 | if !user.IsAdmin { 472 | return Forbidden() 473 | } 474 | 475 | // get reviews 476 | var reviews ReviewList 477 | querySet := DB 478 | if query.All == true { 479 | querySet = querySet.Where("is_sensitive = true") 480 | } else { 481 | if query.Open == true { 482 | querySet = querySet.Where("is_sensitive = true and is_actually_sensitive IS NULL") 483 | } else { 484 | querySet = querySet.Where("is_sensitive = true and is_actually_sensitive IS NOT NULL") 485 | } 486 | } 487 | 488 | result := querySet. 489 | Where("updated_at < ?", query.Offset.Time). 490 | Order("updated_at desc"). 491 | Limit(query.Size). 492 | Preload("Course"). 493 | Find(&reviews) 494 | if result.Error != nil { 495 | return result.Error 496 | } 497 | 498 | var responses = make([]SensitiveReviewResponse, len(reviews)) 499 | for i := range responses { 500 | responses[i].FromModel(reviews[i]) 501 | } 502 | 503 | return c.JSON(responses) 504 | } 505 | 506 | // ModifyReviewSensitive 507 | // 508 | // @Summary Modify A Review's actual_sensitive, admin only 509 | // @Tags Review 510 | // @Produce application/json 511 | // @Router /v3/reviews/{id}/_sensitive [put] 512 | // @Router /v3/reviews/{id}/_sensitive/_webvpn [patch] 513 | // @Param id path int true "id" 514 | // @Param json body schema.ModifySensitiveReviewRequest true "json" 515 | // @Success 200 {object} schema.SensitiveReviewResponse 516 | // @Failure 404 {object} common.HttpBaseError 517 | func ModifyReviewSensitive(c *fiber.Ctx) (err error) { 518 | // validate body 519 | var body ModifySensitiveReviewRequest 520 | err = ValidateBody(c, &body) 521 | if err != nil { 522 | return err 523 | } 524 | 525 | // parse review_id 526 | reviewID, err := c.ParamsInt("id") 527 | if err != nil { 528 | return err 529 | } 530 | 531 | // get user 532 | user, err := GetCurrentUser(c) 533 | if err != nil { 534 | return err 535 | } 536 | 537 | // permission check 538 | if !user.IsAdmin { 539 | return Forbidden() 540 | } 541 | 542 | var review Review 543 | err = DB.Clauses(dbresolver.Write).Transaction(func(tx *gorm.DB) error { 544 | err = tx.Clauses(clause.Locking{Strength: "UPDATE"}). 545 | Preload("Course"). 546 | First(&review, reviewID).Error 547 | if err != nil { 548 | return err 549 | } 550 | 551 | // modify actual_sensitive 552 | review.IsActuallySensitive = &body.IsActuallySensitive 553 | //MyLog("Review", "Modify", reviewID, user.ID, RoleAdmin, "actual_sensitive to: ", fmt.Sprintf("%v", body.IsActuallySensitive)) 554 | //CreateAdminLog(tx, AdminLogTypeChangeSensitive, user.ID, body) 555 | 556 | if !body.IsActuallySensitive { 557 | // save actual_sensitive only 558 | return tx.Model(&review).Select("IsActuallySensitive").UpdateColumns(&review).Error 559 | } 560 | 561 | //reason := "违反社区规范" 562 | //err = review.Backup(tx, user.ID, reason) 563 | //if err != nil { 564 | // return err 565 | //} 566 | 567 | return tx.Delete(&review).Error 568 | }) 569 | if err != nil { 570 | return err 571 | } 572 | 573 | //// clear cache 574 | //err = DeleteCache(fmt.Sprintf("hole_%v", review.HoleID)) 575 | //if err != nil { 576 | // return err 577 | //} 578 | 579 | //if review.IsActuallySensitive != nil && *review.IsActuallySensitive == false { 580 | // go ReviewIndex(ReviewModel{ 581 | // ID: review.ID, 582 | // UpdatedAt: review.UpdatedAt, 583 | // Content: review.Content, 584 | // }) 585 | //} else { 586 | // go ReviewDelete(review.ID) 587 | // 588 | // MyLog("Review", "Delete", reviewID, user.ID, RoleAdmin, "reason: ", "sensitive") 589 | // 590 | // err = review.SendModify(DB) 591 | // if err != nil { 592 | // log.Err(err).Str("model", "Notification").Msg("SendModify failed") 593 | // } 594 | //} 595 | 596 | return c.JSON(new(SensitiveReviewResponse).FromModel(&review)) 597 | } 598 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= 2 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 3 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 4 | github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= 5 | github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= 6 | github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= 7 | github.com/allegro/bigcache/v3 v3.1.0 h1:H2Vp8VOvxcrB91o86fUSVJFqeuz8kpyyB02eH3bSzwk= 8 | github.com/allegro/bigcache/v3 v3.1.0/go.mod h1:aPyh7jEvrog9zAwx5N7+JUQX5dZTSGpxF1LAR4dr35I= 9 | github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= 10 | github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= 11 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 12 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 13 | github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= 14 | github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= 15 | github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= 16 | github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= 17 | github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= 18 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 19 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 20 | github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= 21 | github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= 22 | github.com/creasty/defaults v1.7.0 h1:eNdqZvc5B509z18lD8yc212CAqJNvfT1Jq6L8WowdBA= 23 | github.com/creasty/defaults v1.7.0/go.mod h1:iGzKe6pbEHnpMPtfDXZEr0NVxWnPTjb1bbDy08fPzYM= 24 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 25 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 26 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 27 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 28 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= 29 | github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= 30 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= 31 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 32 | github.com/eko/gocache/lib/v4 v4.1.6 h1:5WWIGISKhE7mfkyF+SJyWwqa4Dp2mkdX8QsZpnENqJI= 33 | github.com/eko/gocache/lib/v4 v4.1.6/go.mod h1:HFxC8IiG2WeRotg09xEnPD72sCheJiTSr4Li5Ameg7g= 34 | github.com/eko/gocache/store/bigcache/v4 v4.2.1 h1:xf9R5HZqmrfT4+NzlJPQJQUWftfWW06FHbjz4IEjE08= 35 | github.com/eko/gocache/store/bigcache/v4 v4.2.1/go.mod h1:Q9+hxUE+XUVGSRGP1tqW8sPHcZ50PfyBVh9VKh0OjrA= 36 | github.com/eko/gocache/store/redis/v4 v4.2.1 h1:uPAgZIn7knH6a55tO4ETN9V93VD3Rcyx0ZIyozEqC0I= 37 | github.com/eko/gocache/store/redis/v4 v4.2.1/go.mod h1:JoLkNA5yeGNQUwINAM9529cDNQCo88WwiKlO9e/+39I= 38 | github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= 39 | github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= 40 | github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= 41 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 42 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 43 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 44 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 45 | github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I= 46 | github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s= 47 | github.com/glebarez/go-sqlite v1.22.0 h1:uAcMJhaA6r3LHMTFgP0SifzgXg46yJkgxqyuyec+ruQ= 48 | github.com/glebarez/go-sqlite v1.22.0/go.mod h1:PlBIdHe0+aUEFn+r2/uthrWq4FxbzugL0L8Li6yQJbc= 49 | github.com/glebarez/sqlite v1.11.0 h1:wSG0irqzP6VurnMEpFGer5Li19RpIRi2qvQz++w0GMw= 50 | github.com/glebarez/sqlite v1.11.0/go.mod h1:h8/o8j5wiAsqSPoWELDUdJXhjAhsVliSn7bWZjOhrgQ= 51 | github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= 52 | github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= 53 | github.com/go-openapi/jsonreference v0.21.0 h1:Rs+Y7hSXT83Jacb7kFyjn4ijOuVGSvOdF2+tg1TRrwQ= 54 | github.com/go-openapi/jsonreference v0.21.0/go.mod h1:LmZmgsrTkVg9LG4EaHeY8cBDslNPMo06cago5JNLkm4= 55 | github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= 56 | github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= 57 | github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= 58 | github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= 59 | github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= 60 | github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= 61 | github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= 62 | github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= 63 | github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= 64 | github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= 65 | github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= 66 | github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= 67 | github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 68 | github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= 69 | github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y= 70 | github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= 71 | github.com/gofiber/fiber/v2 v2.52.4 h1:P+T+4iK7VaqUsq2PALYEfBBo6bJZ4q3FP8cZ84EggTM= 72 | github.com/gofiber/fiber/v2 v2.52.4/go.mod h1:KEOE+cXMhXG0zHc9d8+E38hoX+ZN7bhOtgeF2oT6jrQ= 73 | github.com/gofiber/swagger v1.0.0 h1:BzUzDS9ZT6fDUa692kxmfOjc1DZiloLiPK/W5z1H1tc= 74 | github.com/gofiber/swagger v1.0.0/go.mod h1:QrYNF1Yrc7ggGK6ATsJ6yfH/8Zi5bu9lA7wB8TmCecg= 75 | github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= 76 | github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= 77 | github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= 78 | github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= 79 | github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc= 80 | github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs= 81 | github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 82 | github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= 83 | github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw= 84 | github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= 85 | github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= 86 | github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= 87 | github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= 88 | github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= 89 | github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= 90 | github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= 91 | github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 92 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 93 | github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 94 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 95 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 96 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= 97 | github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= 98 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 99 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 100 | github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= 101 | github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= 102 | github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= 103 | github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= 104 | github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9 h1:L0QtFUgDarD7Fpv9jeVMgy/+Ec0mtnmYuImjTz6dtDA= 105 | github.com/jackc/pgservicefile v0.0.0-20231201235250-de7065d80cb9/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= 106 | github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY= 107 | github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw= 108 | github.com/jackc/puddle/v2 v2.2.1 h1:RhxXJtFG022u4ibrCSMSiu5aOq1i77R3OHKNJj77OAk= 109 | github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= 110 | github.com/jingyijun/yidun-golang-sdk v1.0.13-0.20240709102803-aaae270a5671 h1:A/CM8e8oFmBRAQwXGRXTEzq3+usH5W24zl2L5ze5l6s= 111 | github.com/jingyijun/yidun-golang-sdk v1.0.13-0.20240709102803-aaae270a5671/go.mod h1:q/ri1QIwJsNwMnLZd3nlGczAMuDeZaz7uM9jORxmKRY= 112 | github.com/jinzhu/copier v0.4.0 h1:w3ciUoD19shMCRargcpm0cm91ytaBhDvuRpz1ODO/U8= 113 | github.com/jinzhu/copier v0.4.0/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= 114 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 115 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 116 | github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 117 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 118 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 119 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 120 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 121 | github.com/klauspost/compress v1.17.8 h1:YcnTYrq7MikUT7k0Yb5eceMmALQPYBW/Xltxn0NAMnU= 122 | github.com/klauspost/compress v1.17.8/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= 123 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 124 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 125 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 126 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 127 | github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= 128 | github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= 129 | github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY= 130 | github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= 131 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 132 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 133 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 134 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 135 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 136 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 137 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 138 | github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= 139 | github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 140 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 141 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 142 | github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= 143 | github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= 144 | github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= 145 | github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ= 146 | github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM= 147 | github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs= 148 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 149 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 150 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 151 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 152 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 153 | github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= 154 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 155 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 156 | github.com/prometheus/common v0.53.0 h1:U2pL9w9nmJwJDa4qqLQ3ZaePJ6ZTwt7cMD3AG3+aLCE= 157 | github.com/prometheus/common v0.53.0/go.mod h1:BrxBKv3FWBIGXw89Mg1AeBq7FSyRzXWI3l3e7W3RN5U= 158 | github.com/prometheus/procfs v0.15.0 h1:A82kmvXJq2jTu5YUhSGNlYoxh85zLnKgPz4bMZgI5Ek= 159 | github.com/prometheus/procfs v0.15.0/go.mod h1:Y0RJ/Y5g5wJpkTisOtqwDSo4HwhGmLB4VQSw2sQJLHk= 160 | github.com/redis/go-redis/v9 v9.5.1 h1:H1X4D3yHPaYrkL5X06Wh6xNVM/pX0Ft4RV0vMGvLBh8= 161 | github.com/redis/go-redis/v9 v9.5.1/go.mod h1:hdY0cQFCN4fnSYT6TkisLufl/4W5UIXyv0b/CLO2V2M= 162 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= 163 | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= 164 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 165 | github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= 166 | github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= 167 | github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= 168 | github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= 169 | github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= 170 | github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= 171 | github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= 172 | github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ= 173 | github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= 174 | github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= 175 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 176 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 177 | github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= 178 | github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 179 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 180 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 181 | github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ= 182 | github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk= 183 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 184 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 185 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 186 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 187 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 188 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 189 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 190 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 191 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 192 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 193 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 194 | github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8= 195 | github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= 196 | github.com/swaggo/files/v2 v2.0.0 h1:hmAt8Dkynw7Ssz46F6pn8ok6YmGZqHSVLZ+HQM7i0kw= 197 | github.com/swaggo/files/v2 v2.0.0/go.mod h1:24kk2Y9NYEJ5lHuCra6iVwkMjIekMCaFq/0JQj66kyM= 198 | github.com/swaggo/swag v1.16.3 h1:PnCYjPCah8FK4I26l2F/KQ4yz3sILcVUN3cTlBFA9Pg= 199 | github.com/swaggo/swag v1.16.3/go.mod h1:DImHIuOFXKpMFAQjcC7FG4m3Dg4+QuUgUzJmKjI/gRk= 200 | github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho= 201 | github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= 202 | github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= 203 | github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= 204 | github.com/valyala/fasthttp v1.54.0 h1:cCL+ZZR3z3HPLMVfEYVUMtJqVaui0+gu7Lx63unHwS0= 205 | github.com/valyala/fasthttp v1.54.0/go.mod h1:6dt4/8olwq9QARP/TDuPmWyWcl4byhpvTJ4AAtcz+QM= 206 | github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= 207 | github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= 208 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 209 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 210 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 211 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 212 | github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= 213 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 214 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 215 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 216 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 217 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 218 | golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 219 | golang.org/x/crypto v0.23.0 h1:dIJU/v2J8Mdglj/8rJ6UUOM3Zc9zLZxVZwwxMooUSAI= 220 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 221 | golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= 222 | golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d h1:N0hmiNbwsSNwHBAvR3QB5w25pUwH4tK0Y/RltD1j1h4= 223 | golang.org/x/exp v0.0.0-20240525044651-4c93da0ed11d/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= 224 | golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= 225 | golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= 226 | golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= 227 | golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 228 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 229 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 230 | golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 231 | golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 232 | golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 233 | golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 234 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 235 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 236 | golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 237 | golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= 238 | golang.org/x/net v0.25.0 h1:d/OCCoBEUq33pjydKrGQhw7IlUPI2Oylr+8qLx49kac= 239 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 240 | golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= 241 | golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 242 | golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 243 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 244 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 245 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 246 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 247 | golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 248 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 249 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 250 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 251 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 252 | golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 253 | golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 254 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 255 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 256 | golang.org/x/sys v0.20.0 h1:Od9JTbYCk261bKm4M/mw7AklTlFYIa0bIp9BgSm1S8Y= 257 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 258 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 259 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 260 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 261 | golang.org/x/text v0.15.0 h1:h1V/4gjBv8v9cjcR6+AR5+/cIYK5N/WAgiv4xlsEtAk= 262 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 263 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 264 | golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 265 | golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= 266 | golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= 267 | golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= 268 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 269 | golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk= 270 | golang.org/x/tools v0.21.0 h1:qc0xYgIbsSDt9EyWz05J5wfa7LOVW0YTLOXrqdLAWIw= 271 | golang.org/x/tools v0.21.0/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 272 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 273 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 274 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 275 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 276 | google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= 277 | google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= 278 | google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= 279 | google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= 280 | google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= 281 | google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= 282 | google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= 283 | google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= 284 | google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= 285 | google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= 286 | google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= 287 | google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= 288 | google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= 289 | google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= 290 | google.golang.org/protobuf v1.34.1 h1:9ddQBjfCyZPOHPUiPxpYESBLc+T8P3E+Vo4IbKZgFWg= 291 | google.golang.org/protobuf v1.34.1/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= 292 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 293 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 294 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 295 | gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA= 296 | gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= 297 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 298 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 299 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 300 | gorm.io/driver/mysql v1.4.3/go.mod h1:sSIebwZAVPiT+27jK9HIwvsqOGKx3YMPmrA3mBJR10c= 301 | gorm.io/driver/mysql v1.5.6 h1:Ld4mkIickM+EliaQZQx3uOJDJHtrd70MxAUqWqlx3Y8= 302 | gorm.io/driver/mysql v1.5.6/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= 303 | gorm.io/driver/postgres v1.5.7 h1:8ptbNJTDbEmhdr62uReG5BGkdQyeasu/FZHxI0IMGnM= 304 | gorm.io/driver/postgres v1.5.7/go.mod h1:3e019WlBaYI5o5LIdNV+LyxCMNtLOQETBXL2h4chKpA= 305 | gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= 306 | gorm.io/gorm v1.25.2/go.mod h1:L4uxeKpfBml98NYqVqwAdmV1a2nBtAec/cf3fpucW/k= 307 | gorm.io/gorm v1.25.7/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 308 | gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s= 309 | gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= 310 | gorm.io/plugin/dbresolver v1.5.1 h1:s9Dj9f7r+1rE3nx/Ywzc85nXptUEaeOO0pt27xdopM8= 311 | gorm.io/plugin/dbresolver v1.5.1/go.mod h1:l4Cn87EHLEYuqUncpEeTC2tTJQkjngPSD+lo8hIvcT0= 312 | honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 313 | honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= 314 | modernc.org/cc/v4 v4.21.2 h1:dycHFB/jDc3IyacKipCNSDrjIC0Lm1hyoWOZTRR20Lk= 315 | modernc.org/cc/v4 v4.21.2/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= 316 | modernc.org/ccgo/v4 v4.17.8 h1:yyWBf2ipA0Y9GGz/MmCmi3EFpKgeS7ICrAFes+suEbs= 317 | modernc.org/ccgo/v4 v4.17.8/go.mod h1:buJnJ6Fn0tyAdP/dqePbrrvLyr6qslFfTbFrCuaYvtA= 318 | modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= 319 | modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= 320 | modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= 321 | modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= 322 | modernc.org/libc v1.50.9 h1:hIWf1uz55lorXQhfoEoezdUHjxzuO6ceshET/yWjSjk= 323 | modernc.org/libc v1.50.9/go.mod h1:15P6ublJ9FJR8YQCGy8DeQ2Uwur7iW9Hserr/T3OFZE= 324 | modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= 325 | modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= 326 | modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= 327 | modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= 328 | modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= 329 | modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= 330 | modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= 331 | modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= 332 | modernc.org/sqlite v1.29.10 h1:3u93dz83myFnMilBGCOLbr+HjklS6+5rJLx4q86RDAg= 333 | modernc.org/sqlite v1.29.10/go.mod h1:ItX2a1OVGgNsFh6Dv60JQvGfJfTPHPVpV6DF59akYOA= 334 | modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= 335 | modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= 336 | modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= 337 | modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= 338 | mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8= 339 | mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE= 340 | -------------------------------------------------------------------------------- /danke/docs/swagger.yaml: -------------------------------------------------------------------------------- 1 | basePath: /api 2 | definitions: 3 | common.ErrorDetailElement: 4 | properties: 5 | field: 6 | type: string 7 | message: 8 | type: string 9 | param: 10 | type: string 11 | struct_field: 12 | type: string 13 | tag: 14 | type: string 15 | value: {} 16 | type: object 17 | common.HttpBaseError: 18 | properties: 19 | code: 20 | type: integer 21 | message: 22 | type: string 23 | type: object 24 | common.HttpError: 25 | properties: 26 | code: 27 | type: integer 28 | detail: 29 | items: 30 | $ref: '#/definitions/common.ErrorDetailElement' 31 | type: array 32 | message: 33 | type: string 34 | type: object 35 | common.PagedResponse-schema_CourseGroupV3Response-any: 36 | properties: 37 | extra: {} 38 | items: 39 | items: 40 | $ref: '#/definitions/schema.CourseGroupV3Response' 41 | type: array 42 | page: 43 | type: integer 44 | page_size: 45 | type: integer 46 | type: object 47 | schema.AchievementV1Response: 48 | properties: 49 | domain: 50 | description: 成就域 51 | type: string 52 | name: 53 | description: 成就名称 54 | type: string 55 | obtain_date: 56 | description: 获取日期 57 | type: string 58 | type: object 59 | schema.CourseGroupHashV1Response: 60 | properties: 61 | hash: 62 | type: string 63 | type: object 64 | schema.CourseGroupV1Response: 65 | properties: 66 | campus_name: 67 | description: 开课校区 68 | type: string 69 | code: 70 | description: 课程组编号 71 | type: string 72 | course_list: 73 | description: 课程组下的课程,slices 必须非空 74 | items: 75 | $ref: '#/definitions/schema.CourseV1Response' 76 | type: array 77 | department: 78 | description: 开课学院 79 | type: string 80 | id: 81 | description: 课程组 ID 82 | type: integer 83 | name: 84 | description: 课程组名称 85 | type: string 86 | type: object 87 | schema.CourseGroupV3Response: 88 | properties: 89 | campus_name: 90 | description: 开课校区 91 | type: string 92 | code: 93 | description: 课程组编号 94 | type: string 95 | course_count: 96 | description: 课程数量 97 | type: integer 98 | course_list: 99 | description: 课程组下的课程,slices 必须非空 100 | items: 101 | $ref: '#/definitions/schema.CourseV1Response' 102 | type: array 103 | credits: 104 | description: 学分 105 | items: 106 | type: number 107 | type: array 108 | department: 109 | description: 开课学院 110 | type: string 111 | id: 112 | description: 课程组 ID 113 | type: integer 114 | name: 115 | description: 课程组名称 116 | type: string 117 | review_count: 118 | description: 评价数量 119 | type: integer 120 | type: object 121 | schema.CourseV1Response: 122 | properties: 123 | campus_name: 124 | description: 开课校区 125 | type: string 126 | code: 127 | description: 编号 128 | type: string 129 | code_id: 130 | description: 选课序号。用于区分同一课程编号的不同平行班 131 | type: string 132 | credit: 133 | description: 学分 134 | type: number 135 | department: 136 | description: 开课学院 137 | type: string 138 | id: 139 | type: integer 140 | max_student: 141 | description: 最大选课人数 142 | type: integer 143 | name: 144 | description: 名称 145 | type: string 146 | review_list: 147 | description: 评教列表 148 | items: 149 | $ref: '#/definitions/schema.ReviewV1Response' 150 | type: array 151 | semester: 152 | description: 学期 153 | type: integer 154 | teachers: 155 | description: 老师:多个老师用逗号分隔 156 | type: string 157 | week_hour: 158 | description: 周学时 159 | type: integer 160 | year: 161 | description: 学年 162 | type: integer 163 | type: object 164 | schema.CreateCourseV1Request: 165 | properties: 166 | campus_name: 167 | type: string 168 | code: 169 | minLength: 4 170 | type: string 171 | code_id: 172 | minLength: 4 173 | type: string 174 | credit: 175 | minimum: 0 176 | type: number 177 | department: 178 | minLength: 1 179 | type: string 180 | max_student: 181 | minimum: 0 182 | type: integer 183 | name: 184 | maxLength: 255 185 | minLength: 1 186 | type: string 187 | semester: 188 | minimum: 1 189 | type: integer 190 | teachers: 191 | minLength: 1 192 | type: string 193 | week_hour: 194 | minimum: 0 195 | type: integer 196 | year: 197 | minimum: 2000 198 | type: integer 199 | required: 200 | - code 201 | - code_id 202 | - department 203 | - name 204 | - semester 205 | - teachers 206 | - year 207 | type: object 208 | schema.CreateReviewV1Request: 209 | properties: 210 | content: 211 | maxLength: 10240 212 | minLength: 1 213 | type: string 214 | rank: 215 | $ref: '#/definitions/schema.ReviewRankV1' 216 | title: 217 | maxLength: 64 218 | minLength: 1 219 | type: string 220 | required: 221 | - content 222 | - title 223 | type: object 224 | schema.ModifyReviewV1Request: 225 | properties: 226 | content: 227 | maxLength: 10240 228 | minLength: 1 229 | type: string 230 | rank: 231 | $ref: '#/definitions/schema.ReviewRankV1' 232 | title: 233 | maxLength: 64 234 | minLength: 1 235 | type: string 236 | required: 237 | - content 238 | - title 239 | type: object 240 | schema.ModifySensitiveReviewRequest: 241 | properties: 242 | is_actually_sensitive: 243 | type: boolean 244 | type: object 245 | schema.MyReviewV1Response: 246 | properties: 247 | content: 248 | description: 评教内容 249 | type: string 250 | course: 251 | allOf: 252 | - $ref: '#/definitions/schema.CourseV1Response' 253 | description: 课程信息 254 | extra: 255 | allOf: 256 | - $ref: '#/definitions/schema.UserExtraV1' 257 | description: 额外信息 258 | group_id: 259 | description: 课程组 ID 260 | type: integer 261 | history: 262 | description: 修改历史,slices 必须非空 263 | items: 264 | $ref: '#/definitions/schema.ReviewHistoryV1Response' 265 | type: array 266 | id: 267 | type: integer 268 | rank: 269 | allOf: 270 | - $ref: '#/definitions/schema.ReviewRankV1' 271 | description: 评价 272 | remark: 273 | description: Remark = 点赞数 - 点踩数 274 | type: integer 275 | reviewer_id: 276 | description: 评教者 ID 277 | type: integer 278 | time_created: 279 | description: 创建时间 280 | type: string 281 | time_updated: 282 | description: 更新时间 283 | type: string 284 | title: 285 | description: 评教标题 286 | type: string 287 | vote: 288 | description: 自己是否点赞或点踩,0 未操作,1 点赞,-1 点踩 289 | type: integer 290 | type: object 291 | schema.RandomReviewV1Response: 292 | properties: 293 | content: 294 | description: 评教内容 295 | type: string 296 | course: 297 | allOf: 298 | - $ref: '#/definitions/schema.CourseV1Response' 299 | description: 课程信息 300 | extra: 301 | allOf: 302 | - $ref: '#/definitions/schema.UserExtraV1' 303 | description: 额外信息 304 | group_id: 305 | description: 课程组 ID 306 | type: integer 307 | history: 308 | description: 修改历史,slices 必须非空 309 | items: 310 | $ref: '#/definitions/schema.ReviewHistoryV1Response' 311 | type: array 312 | id: 313 | type: integer 314 | rank: 315 | allOf: 316 | - $ref: '#/definitions/schema.ReviewRankV1' 317 | description: 评价 318 | remark: 319 | description: Remark = 点赞数 - 点踩数 320 | type: integer 321 | reviewer_id: 322 | description: 评教者 ID 323 | type: integer 324 | time_created: 325 | description: 创建时间 326 | type: string 327 | time_updated: 328 | description: 更新时间 329 | type: string 330 | title: 331 | description: 评教标题 332 | type: string 333 | vote: 334 | description: 自己是否点赞或点踩,0 未操作,1 点赞,-1 点踩 335 | type: integer 336 | type: object 337 | schema.ReviewHistoryV1: 338 | properties: 339 | content: 340 | description: 旧内容 341 | type: string 342 | rank: 343 | allOf: 344 | - $ref: '#/definitions/schema.ReviewRankV1' 345 | description: 评价 346 | remark: 347 | description: Remark = 点赞数 - 点踩数 348 | type: integer 349 | reviewer_id: 350 | description: 评教者 351 | type: integer 352 | time_created: 353 | description: 创建时间 354 | type: string 355 | time_updated: 356 | description: 更新时间 357 | type: string 358 | title: 359 | description: 旧标题 360 | type: string 361 | type: object 362 | schema.ReviewHistoryV1Response: 363 | properties: 364 | alter_by: 365 | description: 修改者 366 | type: integer 367 | original: 368 | allOf: 369 | - $ref: '#/definitions/schema.ReviewHistoryV1' 370 | description: 修改前的评教 371 | time: 372 | description: 创建时间 373 | type: string 374 | type: object 375 | schema.ReviewRankV1: 376 | properties: 377 | assessment: 378 | description: 考核方面 379 | maximum: 5 380 | minimum: 1 381 | type: integer 382 | content: 383 | description: 内容、风格方面 384 | maximum: 5 385 | minimum: 1 386 | type: integer 387 | overall: 388 | description: 总体方面 389 | maximum: 5 390 | minimum: 1 391 | type: integer 392 | workload: 393 | description: 工作量方面 394 | maximum: 5 395 | minimum: 1 396 | type: integer 397 | type: object 398 | schema.ReviewV1Response: 399 | properties: 400 | content: 401 | description: 评教内容 402 | type: string 403 | extra: 404 | allOf: 405 | - $ref: '#/definitions/schema.UserExtraV1' 406 | description: 额外信息 407 | history: 408 | description: 修改历史,slices 必须非空 409 | items: 410 | $ref: '#/definitions/schema.ReviewHistoryV1Response' 411 | type: array 412 | id: 413 | type: integer 414 | is_me: 415 | description: 是否是自己的评教 416 | type: boolean 417 | rank: 418 | allOf: 419 | - $ref: '#/definitions/schema.ReviewRankV1' 420 | description: 评价 421 | remark: 422 | description: Remark = 点赞数 - 点踩数 423 | type: integer 424 | reviewer_id: 425 | description: 评教者 ID 426 | type: integer 427 | time_created: 428 | description: 创建时间 429 | type: string 430 | time_updated: 431 | description: 更新时间 432 | type: string 433 | title: 434 | description: 评教标题 435 | type: string 436 | vote: 437 | description: 自己是否点赞或点踩,0 未操作,1 点赞,-1 点踩 438 | type: integer 439 | type: object 440 | schema.SensitiveReviewResponse: 441 | properties: 442 | content: 443 | type: string 444 | course: 445 | $ref: '#/definitions/schema.CourseV1Response' 446 | id: 447 | type: integer 448 | is_actually_sensitive: 449 | type: boolean 450 | modify_count: 451 | type: integer 452 | sensitive_detail: 453 | type: string 454 | time_created: 455 | type: string 456 | time_updated: 457 | type: string 458 | title: 459 | type: string 460 | type: object 461 | schema.UserExtraV1: 462 | properties: 463 | achievements: 464 | description: 用户成就,slices 必须非空 465 | items: 466 | $ref: '#/definitions/schema.AchievementV1Response' 467 | type: array 468 | type: object 469 | schema.VoteForReviewV1Request: 470 | properties: 471 | upvote: 472 | type: boolean 473 | type: object 474 | info: 475 | contact: 476 | email: dev@fduhole.com 477 | name: Maintainer Ke Chen 478 | description: 蛋壳 API,一个半匿名评教系统 479 | license: 480 | name: Apache 2.0 481 | url: https://www.apache.org/licenses/LICENSE-2.0.html 482 | title: 蛋壳 API 483 | version: 3.0.0 484 | paths: 485 | /courses: 486 | get: 487 | consumes: 488 | - application/json 489 | deprecated: true 490 | description: list all course_groups and courses, no reviews, old version or 491 | v1 version 492 | produces: 493 | - application/json 494 | responses: 495 | "200": 496 | description: OK 497 | schema: 498 | items: 499 | $ref: '#/definitions/schema.CourseGroupV1Response' 500 | type: array 501 | "400": 502 | description: Bad Request 503 | schema: 504 | $ref: '#/definitions/common.HttpError' 505 | "404": 506 | description: Not Found 507 | schema: 508 | $ref: '#/definitions/common.HttpBaseError' 509 | summary: list courses 510 | tags: 511 | - Course 512 | post: 513 | consumes: 514 | - application/json 515 | description: add a course, admin only 516 | parameters: 517 | - description: json 518 | in: body 519 | name: json 520 | required: true 521 | schema: 522 | $ref: '#/definitions/schema.CreateCourseV1Request' 523 | produces: 524 | - application/json 525 | responses: 526 | "200": 527 | description: OK 528 | schema: 529 | $ref: '#/definitions/schema.CourseV1Response' 530 | "400": 531 | description: Bad Request 532 | schema: 533 | $ref: '#/definitions/common.HttpError' 534 | "500": 535 | description: Internal Server Error 536 | schema: 537 | $ref: '#/definitions/common.HttpBaseError' 538 | summary: add a course 539 | tags: 540 | - Course 541 | /courses/{course_id}/reviews: 542 | post: 543 | consumes: 544 | - application/json 545 | description: create a review 546 | parameters: 547 | - description: json 548 | in: body 549 | name: json 550 | required: true 551 | schema: 552 | $ref: '#/definitions/schema.CreateReviewV1Request' 553 | - description: course id 554 | in: path 555 | name: course_id 556 | required: true 557 | type: integer 558 | produces: 559 | - application/json 560 | responses: 561 | "200": 562 | description: OK 563 | schema: 564 | $ref: '#/definitions/schema.ReviewV1Response' 565 | "400": 566 | description: Bad Request 567 | schema: 568 | $ref: '#/definitions/common.HttpError' 569 | "404": 570 | description: Not Found 571 | schema: 572 | $ref: '#/definitions/common.HttpBaseError' 573 | summary: create a review 574 | tags: 575 | - Review 576 | /courses/{id}: 577 | get: 578 | consumes: 579 | - application/json 580 | deprecated: true 581 | description: get a course with reviews, v1 version 582 | parameters: 583 | - description: course_id 584 | in: path 585 | name: id 586 | required: true 587 | type: integer 588 | produces: 589 | - application/json 590 | responses: 591 | "200": 592 | description: OK 593 | schema: 594 | $ref: '#/definitions/schema.CourseV1Response' 595 | "400": 596 | description: Bad Request 597 | schema: 598 | $ref: '#/definitions/common.HttpError' 599 | "404": 600 | description: Not Found 601 | schema: 602 | $ref: '#/definitions/common.HttpBaseError' 603 | summary: get a course 604 | tags: 605 | - Course 606 | /courses/{id}/reviews: 607 | get: 608 | consumes: 609 | - application/json 610 | description: list reviews 611 | parameters: 612 | - description: course id 613 | in: path 614 | name: id 615 | required: true 616 | type: integer 617 | produces: 618 | - application/json 619 | responses: 620 | "200": 621 | description: OK 622 | schema: 623 | items: 624 | $ref: '#/definitions/schema.ReviewV1Response' 625 | type: array 626 | "400": 627 | description: Bad Request 628 | schema: 629 | $ref: '#/definitions/common.HttpError' 630 | summary: list reviews 631 | tags: 632 | - Review 633 | /courses/hash: 634 | get: 635 | consumes: 636 | - application/json 637 | description: get course group hash 638 | produces: 639 | - application/json 640 | responses: 641 | "200": 642 | description: OK 643 | schema: 644 | $ref: '#/definitions/schema.CourseGroupHashV1Response' 645 | "400": 646 | description: Bad Request 647 | schema: 648 | $ref: '#/definitions/common.HttpError' 649 | "404": 650 | description: Not Found 651 | schema: 652 | $ref: '#/definitions/common.HttpBaseError' 653 | "500": 654 | description: Internal Server Error 655 | schema: 656 | $ref: '#/definitions/common.HttpBaseError' 657 | summary: get course group hash 658 | tags: 659 | - CourseGroup 660 | /courses/refresh: 661 | get: 662 | consumes: 663 | - application/json 664 | description: refresh course group hash, admin only 665 | produces: 666 | - application/json 667 | responses: 668 | "400": 669 | description: Bad Request 670 | schema: 671 | $ref: '#/definitions/common.HttpError' 672 | "404": 673 | description: Not Found 674 | schema: 675 | $ref: '#/definitions/common.HttpBaseError' 676 | "418": 677 | description: I'm a teapot 678 | "500": 679 | description: Internal Server Error 680 | schema: 681 | $ref: '#/definitions/common.HttpBaseError' 682 | summary: refresh course group hash 683 | tags: 684 | - CourseGroup 685 | /group/{id}: 686 | get: 687 | consumes: 688 | - application/json 689 | deprecated: true 690 | description: get a course group, old version or v1 version 691 | parameters: 692 | - description: course group id 693 | in: path 694 | name: id 695 | required: true 696 | type: integer 697 | produces: 698 | - application/json 699 | responses: 700 | "200": 701 | description: OK 702 | schema: 703 | $ref: '#/definitions/schema.CourseGroupV1Response' 704 | "400": 705 | description: Bad Request 706 | schema: 707 | $ref: '#/definitions/common.HttpError' 708 | "404": 709 | description: Not Found 710 | schema: 711 | $ref: '#/definitions/common.HttpBaseError' 712 | "500": 713 | description: Internal Server Error 714 | schema: 715 | $ref: '#/definitions/common.HttpBaseError' 716 | summary: /group/{group_id} 717 | tags: 718 | - CourseGroup 719 | /reviews/{id}: 720 | get: 721 | consumes: 722 | - application/json 723 | description: get a review 724 | parameters: 725 | - description: review id 726 | in: path 727 | name: id 728 | required: true 729 | type: integer 730 | produces: 731 | - application/json 732 | responses: 733 | "200": 734 | description: OK 735 | schema: 736 | $ref: '#/definitions/schema.ReviewV1Response' 737 | "400": 738 | description: Bad Request 739 | schema: 740 | $ref: '#/definitions/common.HttpError' 741 | summary: get a review 742 | tags: 743 | - Review 744 | /reviews/{review_id}: 745 | delete: 746 | consumes: 747 | - application/json 748 | description: delete a review, admin or owner can delete 749 | parameters: 750 | - description: review id 751 | in: path 752 | name: review_id 753 | required: true 754 | type: integer 755 | produces: 756 | - application/json 757 | responses: 758 | "204": 759 | description: No Content 760 | "400": 761 | description: Bad Request 762 | schema: 763 | $ref: '#/definitions/common.HttpError' 764 | "403": 765 | description: Forbidden 766 | schema: 767 | $ref: '#/definitions/common.HttpError' 768 | "404": 769 | description: Not Found 770 | schema: 771 | $ref: '#/definitions/common.HttpError' 772 | summary: delete a review 773 | tags: 774 | - Review 775 | patch: 776 | consumes: 777 | - application/json 778 | description: vote for a review 779 | parameters: 780 | - description: json 781 | in: body 782 | name: json 783 | required: true 784 | schema: 785 | $ref: '#/definitions/schema.VoteForReviewV1Request' 786 | - description: review id 787 | in: path 788 | name: review_id 789 | required: true 790 | type: integer 791 | produces: 792 | - application/json 793 | responses: 794 | "200": 795 | description: OK 796 | schema: 797 | $ref: '#/definitions/schema.ReviewV1Response' 798 | "400": 799 | description: Bad Request 800 | schema: 801 | $ref: '#/definitions/common.HttpError' 802 | "404": 803 | description: Not Found 804 | schema: 805 | $ref: '#/definitions/common.HttpBaseError' 806 | summary: vote for a review 807 | tags: 808 | - Review 809 | put: 810 | consumes: 811 | - application/json 812 | description: modify a review, admin or owner can modify 813 | parameters: 814 | - description: json 815 | in: body 816 | name: json 817 | required: true 818 | schema: 819 | $ref: '#/definitions/schema.ModifyReviewV1Request' 820 | - description: review id 821 | in: path 822 | name: review_id 823 | required: true 824 | type: integer 825 | produces: 826 | - application/json 827 | responses: 828 | "200": 829 | description: OK 830 | schema: 831 | $ref: '#/definitions/schema.ReviewV1Response' 832 | "400": 833 | description: Bad Request 834 | schema: 835 | $ref: '#/definitions/common.HttpError' 836 | "404": 837 | description: Not Found 838 | schema: 839 | $ref: '#/definitions/common.HttpBaseError' 840 | summary: modify a review 841 | tags: 842 | - Review 843 | /reviews/{review_id}/_webvpn: 844 | patch: 845 | consumes: 846 | - application/json 847 | description: modify a review, admin or owner can modify 848 | parameters: 849 | - description: json 850 | in: body 851 | name: json 852 | required: true 853 | schema: 854 | $ref: '#/definitions/schema.ModifyReviewV1Request' 855 | - description: review id 856 | in: path 857 | name: review_id 858 | required: true 859 | type: integer 860 | produces: 861 | - application/json 862 | responses: 863 | "200": 864 | description: OK 865 | schema: 866 | $ref: '#/definitions/schema.ReviewV1Response' 867 | "400": 868 | description: Bad Request 869 | schema: 870 | $ref: '#/definitions/common.HttpError' 871 | "404": 872 | description: Not Found 873 | schema: 874 | $ref: '#/definitions/common.HttpBaseError' 875 | summary: modify a review 876 | tags: 877 | - Review 878 | /reviews/me: 879 | get: 880 | consumes: 881 | - application/json 882 | description: list my reviews, old version. load history and achievements, no 883 | `is_me` field 884 | produces: 885 | - application/json 886 | responses: 887 | "200": 888 | description: OK 889 | schema: 890 | items: 891 | $ref: '#/definitions/schema.MyReviewV1Response' 892 | type: array 893 | "400": 894 | description: Bad Request 895 | schema: 896 | $ref: '#/definitions/common.HttpError' 897 | "404": 898 | description: Not Found 899 | schema: 900 | $ref: '#/definitions/common.HttpBaseError' 901 | summary: list my reviews 902 | tags: 903 | - Review 904 | /reviews/random: 905 | get: 906 | consumes: 907 | - application/json 908 | description: get random review 909 | produces: 910 | - application/json 911 | responses: 912 | "200": 913 | description: OK 914 | schema: 915 | $ref: '#/definitions/schema.RandomReviewV1Response' 916 | "400": 917 | description: Bad Request 918 | schema: 919 | $ref: '#/definitions/common.HttpError' 920 | "404": 921 | description: Not Found 922 | schema: 923 | $ref: '#/definitions/common.HttpBaseError' 924 | summary: get random review 925 | tags: 926 | - Review 927 | /v3/course_groups/{id}: 928 | get: 929 | consumes: 930 | - application/json 931 | description: get a course group, v3 version 932 | parameters: 933 | - description: course group id 934 | in: path 935 | name: id 936 | required: true 937 | type: integer 938 | produces: 939 | - application/json 940 | responses: 941 | "200": 942 | description: OK 943 | schema: 944 | $ref: '#/definitions/schema.CourseGroupV3Response' 945 | "400": 946 | description: Bad Request 947 | schema: 948 | $ref: '#/definitions/common.HttpError' 949 | "404": 950 | description: Not Found 951 | schema: 952 | $ref: '#/definitions/common.HttpBaseError' 953 | "500": 954 | description: Internal Server Error 955 | schema: 956 | $ref: '#/definitions/common.HttpBaseError' 957 | summary: get a course group 958 | tags: 959 | - CourseGroup 960 | /v3/course_groups/search: 961 | get: 962 | consumes: 963 | - application/json 964 | description: search course group, no courses 965 | parameters: 966 | - example: 1 967 | in: query 968 | minimum: 0 969 | name: page 970 | type: integer 971 | - example: 10 972 | in: query 973 | maximum: 100 974 | minimum: 0 975 | name: page_size 976 | type: integer 977 | - example: 计算机 978 | in: query 979 | name: query 980 | required: true 981 | type: string 982 | produces: 983 | - application/json 984 | responses: 985 | "200": 986 | description: OK 987 | schema: 988 | $ref: '#/definitions/common.PagedResponse-schema_CourseGroupV3Response-any' 989 | "400": 990 | description: Bad Request 991 | schema: 992 | $ref: '#/definitions/common.HttpError' 993 | "404": 994 | description: Not Found 995 | schema: 996 | $ref: '#/definitions/common.HttpBaseError' 997 | "500": 998 | description: Internal Server Error 999 | schema: 1000 | $ref: '#/definitions/common.HttpBaseError' 1001 | summary: search course group 1002 | tags: 1003 | - CourseGroup 1004 | /v3/reviews/_sensitive: 1005 | get: 1006 | parameters: 1007 | - in: query 1008 | name: all 1009 | type: boolean 1010 | - in: query 1011 | name: offset 1012 | type: string 1013 | - in: query 1014 | name: open 1015 | type: boolean 1016 | - default: 10 1017 | in: query 1018 | maximum: 10 1019 | name: size 1020 | type: integer 1021 | produces: 1022 | - application/json 1023 | responses: 1024 | "200": 1025 | description: OK 1026 | schema: 1027 | items: 1028 | $ref: '#/definitions/schema.SensitiveReviewResponse' 1029 | type: array 1030 | "404": 1031 | description: Not Found 1032 | schema: 1033 | $ref: '#/definitions/common.HttpBaseError' 1034 | summary: List sensitive reviews, admin only 1035 | tags: 1036 | - Review 1037 | /v3/reviews/{id}/_sensitive: 1038 | put: 1039 | parameters: 1040 | - description: id 1041 | in: path 1042 | name: id 1043 | required: true 1044 | type: integer 1045 | - description: json 1046 | in: body 1047 | name: json 1048 | required: true 1049 | schema: 1050 | $ref: '#/definitions/schema.ModifySensitiveReviewRequest' 1051 | produces: 1052 | - application/json 1053 | responses: 1054 | "200": 1055 | description: OK 1056 | schema: 1057 | $ref: '#/definitions/schema.SensitiveReviewResponse' 1058 | "404": 1059 | description: Not Found 1060 | schema: 1061 | $ref: '#/definitions/common.HttpBaseError' 1062 | summary: Modify A Review's actual_sensitive, admin only 1063 | tags: 1064 | - Review 1065 | /v3/reviews/{id}/_sensitive/_webvpn: 1066 | patch: 1067 | parameters: 1068 | - description: id 1069 | in: path 1070 | name: id 1071 | required: true 1072 | type: integer 1073 | - description: json 1074 | in: body 1075 | name: json 1076 | required: true 1077 | schema: 1078 | $ref: '#/definitions/schema.ModifySensitiveReviewRequest' 1079 | produces: 1080 | - application/json 1081 | responses: 1082 | "200": 1083 | description: OK 1084 | schema: 1085 | $ref: '#/definitions/schema.SensitiveReviewResponse' 1086 | "404": 1087 | description: Not Found 1088 | schema: 1089 | $ref: '#/definitions/common.HttpBaseError' 1090 | summary: Modify A Review's actual_sensitive, admin only 1091 | tags: 1092 | - Review 1093 | swagger: "2.0" 1094 | --------------------------------------------------------------------------------