功能更新
This commit is contained in:
244
internal/controller/admin.go
Normal file
244
internal/controller/admin.go
Normal file
@@ -0,0 +1,244 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/middleware"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// AdminController 运营后台。
|
||||
type AdminController struct {
|
||||
Admin *service.AdminService
|
||||
Sec *service.AdminSecurityService
|
||||
Rel *service.ReleaseService
|
||||
Act *service.ActivityService
|
||||
}
|
||||
|
||||
func (h *AdminController) Overview(c *gin.Context) {
|
||||
days, _ := strconv.Atoi(c.DefaultQuery("days", "14"))
|
||||
out, err := h.Admin.Overview(days)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AdminController) ListUsers(c *gin.Context) {
|
||||
items, err := h.Admin.ListUsers()
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminController) PatchUser(c *gin.Context) {
|
||||
id := commonservice.ParseID(c.Param("id"))
|
||||
var req struct {
|
||||
AIBanned *int `json:"aiBanned"`
|
||||
Disabled *int `json:"disabled"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.PatchUser(commonservice.UserID(c), id, req.AIBanned, req.Disabled); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminController) ListTeams(c *gin.Context) {
|
||||
items, err := h.Admin.ListTeams()
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminController) PatchTeam(c *gin.Context) {
|
||||
id := commonservice.ParseID(c.Param("id"))
|
||||
var req struct {
|
||||
AIBanned *int `json:"aiBanned"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.PatchTeam(commonservice.UserID(c), id, req.AIBanned); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminController) TOTPStatus(c *gin.Context) {
|
||||
out, err := h.Sec.Status(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AdminController) TOTPSetupBegin(c *gin.Context) {
|
||||
out, err := h.Sec.SetupBegin(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AdminController) TOTPSetupConfirm(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Sec.SetupConfirm(commonservice.UserID(c), req.Code); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AdminController) StepUp(c *gin.Context) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
token, exp, err := h.Sec.StepUp(commonservice.UserID(c), req.Code, middleware.ClientIP(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"stepupToken": token,
|
||||
"expiresAt": exp.UTC().Format(time.RFC3339),
|
||||
})
|
||||
}
|
||||
|
||||
func (h *AdminController) ListReleases(c *gin.Context) {
|
||||
items, err := h.Rel.List(c.Query("channel"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": items})
|
||||
}
|
||||
|
||||
func (h *AdminController) UploadRelease(c *gin.Context) {
|
||||
version := c.PostForm("version")
|
||||
channel := c.PostForm("channel")
|
||||
changelog := c.PostForm("changelog")
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
row, err := h.Rel.Upload(version, channel, changelog, f, fh.Size)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
|
||||
func (h *AdminController) PublishRelease(c *gin.Context) {
|
||||
id := commonservice.ParseID(c.Param("id"))
|
||||
row, err := h.Rel.Publish(id)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
|
||||
// ActivityController 日活心跳。
|
||||
type ActivityController struct {
|
||||
Svc *service.ActivityService
|
||||
}
|
||||
|
||||
func (h *ActivityController) Ping(c *gin.Context) {
|
||||
if err := h.Svc.Ping(commonservice.UserID(c), middleware.ClientIP(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AIController AI 策略与用量上报。
|
||||
type AIController struct {
|
||||
Admin *service.AdminService
|
||||
}
|
||||
|
||||
func (h *AIController) Policy(c *gin.Context) {
|
||||
out, err := h.Admin.GetAIPolicy(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AIController) Usage(c *gin.Context) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
TeamID int64 `json:"teamId"`
|
||||
PromptTokens int64 `json:"promptTokens"`
|
||||
CompletionTokens int64 `json:"completionTokens"`
|
||||
Estimated bool `json:"estimated"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Admin.ReportAIUsage(commonservice.UserID(c), req.TeamID, req.Provider, req.PromptTokens, req.CompletionTokens, req.Estimated); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
// AppController 客户端更新检查/下载。
|
||||
type AppController struct {
|
||||
Rel *service.ReleaseService
|
||||
}
|
||||
|
||||
func (h *AppController) Latest(c *gin.Context) {
|
||||
out, err := h.Rel.Latest(c.DefaultQuery("channel", "stable"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AppController) Download(c *gin.Context) {
|
||||
row, f, err := h.Rel.OpenFile(c.Param("version"), c.DefaultQuery("channel", "stable"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
c.Header("Content-Type", "application/octet-stream")
|
||||
c.Header("Content-Disposition", `attachment; filename="`+row.Version+`-installer.exe"`)
|
||||
c.Header("X-Content-SHA256", row.SHA256)
|
||||
http.ServeContent(c.Writer, c.Request, row.Version+"-installer.exe", time.Time{}, f)
|
||||
}
|
||||
77
internal/controller/auth.go
Normal file
77
internal/controller/auth.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/middleware"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// AuthController 认证相关 HTTP 绑定。
|
||||
type AuthController struct {
|
||||
Svc *service.AuthService
|
||||
}
|
||||
|
||||
func (h *AuthController) Register(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.Register(req.Username, req.Password); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *AuthController) Login(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.Login(req.Username, req.Password, middleware.ClientIP(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AuthController) Refresh(c *gin.Context) {
|
||||
var req struct {
|
||||
RefreshToken string `json:"refreshToken"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.Refresh(req.RefreshToken, middleware.ClientIP(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *AuthController) ChangePassword(c *gin.Context) {
|
||||
var req struct {
|
||||
OldPassword string `json:"oldPassword"`
|
||||
NewPassword string `json:"newPassword"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.ChangePassword(commonservice.UserID(c), req.OldPassword, req.NewPassword); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
75
internal/controller/file.go
Normal file
75
internal/controller/file.go
Normal file
@@ -0,0 +1,75 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// FileController 文件上传/列表/删除。
|
||||
type FileController struct {
|
||||
Svc *service.FileService
|
||||
}
|
||||
|
||||
func (h *FileController) Upload(c *gin.Context) {
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_READ_FAILED"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
out, err := h.Svc.Upload(
|
||||
commonservice.UserID(c),
|
||||
commonservice.ParseID(c.PostForm("teamId")),
|
||||
c.PostForm("kind"),
|
||||
fh.Filename,
|
||||
f,
|
||||
fh.Size,
|
||||
requestHost(c),
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *FileController) List(c *gin.Context) {
|
||||
total, items, err := h.Svc.List(
|
||||
commonservice.UserID(c),
|
||||
c.Query("scope"),
|
||||
commonservice.ParseID(c.Query("teamId")),
|
||||
commonservice.ParseID(c.Query("page")),
|
||||
commonservice.ParseID(c.Query("pageSize")),
|
||||
requestHost(c),
|
||||
)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"total": total, "items": items})
|
||||
}
|
||||
|
||||
func (h *FileController) Delete(c *gin.Context) {
|
||||
if err := h.Svc.Delete(commonservice.UserID(c), commonservice.ParseID(c.Param("id"))); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func requestHost(c *gin.Context) string {
|
||||
scheme := "http"
|
||||
if c.Request.TLS != nil {
|
||||
scheme = "https"
|
||||
}
|
||||
return scheme + "://" + c.Request.Host
|
||||
}
|
||||
263
internal/controller/file_test.go
Normal file
263
internal/controller/file_test.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package controller_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"image"
|
||||
"image/color"
|
||||
"image/png"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/config"
|
||||
"nl-pms-api/internal/model"
|
||||
"nl-pms-api/internal/router"
|
||||
)
|
||||
|
||||
const testSecret = "test-jwt-secret"
|
||||
|
||||
// newTestServer 起内存 SQLite 后端完整路由,并铺好 users / team_members:
|
||||
// 1=admin、2=alice(团队 10 owner)、3=bob(团队 10 member)。
|
||||
func newTestServer(t *testing.T) (*httptest.Server, *gorm.DB, *config.Config, map[int64]string) {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&model.File{}, &model.User{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
db.Exec(`CREATE TABLE team_members(team_id INTEGER, user_id INTEGER, role TEXT)`)
|
||||
db.Exec(`INSERT INTO users(id, username, password_hash, created_at) VALUES(1,'admin','x','t'),(2,'alice','x','t'),(3,'bob','x','t')`)
|
||||
db.Exec(`INSERT INTO team_members(team_id,user_id,role) VALUES(10,2,'owner'),(10,3,'member')`)
|
||||
cfg := &config.Config{
|
||||
Env: "dev",
|
||||
JWTSecret: testSecret,
|
||||
StorageDir: t.TempDir(),
|
||||
MaxUploadMB: 20,
|
||||
AccessTTLHours: 2,
|
||||
RefreshTTLDays: 30,
|
||||
}
|
||||
tokens := map[int64]string{}
|
||||
for id, name := range map[int64]string{1: "admin", 2: "alice", 3: "bob"} {
|
||||
tok, err := commonservice.IssueAccess(testSecret, id, name, 2)
|
||||
if err != nil {
|
||||
t.Fatalf("issue token: %v", err)
|
||||
}
|
||||
tokens[id] = tok
|
||||
}
|
||||
srv := httptest.NewServer(router.New(cfg, db))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, db, cfg, tokens
|
||||
}
|
||||
|
||||
func pngBytes(t *testing.T, tone uint8) []byte {
|
||||
t.Helper()
|
||||
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
|
||||
for x := 0; x < 2; x++ {
|
||||
for y := 0; y < 2; y++ {
|
||||
img.Set(x, y, color.RGBA{R: tone, G: 90, B: 60, A: 255})
|
||||
}
|
||||
}
|
||||
var buf bytes.Buffer
|
||||
if err := png.Encode(&buf, img); err != nil {
|
||||
t.Fatalf("encode png: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
type uploadResp struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
URL string `json:"url"`
|
||||
Mime string `json:"mime"`
|
||||
}
|
||||
|
||||
func upload(t *testing.T, url, token string, data []byte, teamID string) (*http.Response, uploadResp) {
|
||||
t.Helper()
|
||||
var body bytes.Buffer
|
||||
w := multipart.NewWriter(&body)
|
||||
fw, err := w.CreateFormFile("file", "test.png")
|
||||
if err != nil {
|
||||
t.Fatalf("form file: %v", err)
|
||||
}
|
||||
if _, err := fw.Write(data); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
_ = w.WriteField("kind", "content")
|
||||
_ = w.WriteField("teamId", teamID)
|
||||
_ = w.Close()
|
||||
req, _ := http.NewRequest("POST", url+"/api/v1/files", &body)
|
||||
req.Header.Set("Content-Type", w.FormDataContentType())
|
||||
if token != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("do: %v", err)
|
||||
}
|
||||
var out uploadResp
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
resp.Body.Close()
|
||||
return resp, out
|
||||
}
|
||||
|
||||
func listFiles(t *testing.T, base, token, scope, teamID string) (int, int64, []map[string]any) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("GET", base+"/api/v1/files?scope="+scope+"&teamId="+teamID, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("list: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out struct {
|
||||
Total int64 `json:"total"`
|
||||
Items []map[string]any `json:"items"`
|
||||
}
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out.Total, out.Items
|
||||
}
|
||||
|
||||
func deleteFile(t *testing.T, base, token string, id int64) int {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest("DELETE", base+"/api/v1/files/"+jsonNum(id), nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("delete: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
return resp.StatusCode
|
||||
}
|
||||
|
||||
func jsonNum(n int64) string {
|
||||
b, _ := json.Marshal(n)
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func TestHealthz(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil || resp.StatusCode != 200 {
|
||||
t.Fatalf("healthz: %v %v", err, resp)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRequiresAuth(t *testing.T) {
|
||||
srv, _, _, _ := newTestServer(t)
|
||||
if resp, _ := upload(t, srv.URL, "", pngBytes(t, 200), "0"); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
if resp, _ := upload(t, srv.URL, "wrong", pngBytes(t, 200), "0"); resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Fatalf("want 401, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadServeAndScopedDedupe(t *testing.T) {
|
||||
srv, db, cfg, tokens := newTestServer(t)
|
||||
data := pngBytes(t, 200)
|
||||
resp, out := upload(t, srv.URL, tokens[2], data, "10")
|
||||
if resp.StatusCode != 200 || out.Name == "" || out.Mime != "image/png" {
|
||||
t.Fatalf("upload failed: %d %+v", resp.StatusCode, out)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(out.Name))); err != nil {
|
||||
t.Fatalf("file not on disk: %v", err)
|
||||
}
|
||||
got, err := http.Get(srv.URL + "/files/" + out.Name)
|
||||
if err != nil || got.StatusCode != 200 {
|
||||
t.Fatalf("serve: %v %v", err, got)
|
||||
}
|
||||
var served bytes.Buffer
|
||||
_, _ = served.ReadFrom(got.Body)
|
||||
if !bytes.Equal(served.Bytes(), data) {
|
||||
t.Fatal("served content mismatch")
|
||||
}
|
||||
_, again := upload(t, srv.URL, tokens[2], data, "10")
|
||||
if again.ID != out.ID || again.Name != out.Name {
|
||||
t.Fatalf("same-owner dedupe failed: %+v vs %+v", again, out)
|
||||
}
|
||||
_, other := upload(t, srv.URL, tokens[3], data, "10")
|
||||
if other.ID == out.ID || other.Name == out.Name {
|
||||
t.Fatalf("cross-owner upload should create its own record: %+v", other)
|
||||
}
|
||||
var count int64
|
||||
db.Model(&model.File{}).Count(&count)
|
||||
if count != 2 {
|
||||
t.Fatalf("want 2 rows, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUploadRejectsNonImage(t *testing.T) {
|
||||
srv, _, _, tokens := newTestServer(t)
|
||||
if resp, _ := upload(t, srv.URL, tokens[2], []byte("plain text, not an image"), "0"); resp.StatusCode != http.StatusUnsupportedMediaType {
|
||||
t.Fatalf("want 415, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListScopes(t *testing.T) {
|
||||
srv, _, _, tokens := newTestServer(t)
|
||||
upload(t, srv.URL, tokens[2], pngBytes(t, 10), "10")
|
||||
upload(t, srv.URL, tokens[2], pngBytes(t, 20), "10")
|
||||
upload(t, srv.URL, tokens[3], pngBytes(t, 30), "10")
|
||||
upload(t, srv.URL, tokens[3], pngBytes(t, 40), "0")
|
||||
|
||||
if code, total, items := listFiles(t, srv.URL, tokens[2], "mine", "0"); code != 200 || total != 2 || len(items) != 2 {
|
||||
t.Fatalf("mine(alice): code=%d total=%d n=%d", code, total, len(items))
|
||||
}
|
||||
code, total, items := listFiles(t, srv.URL, tokens[2], "team", "10")
|
||||
if code != 200 || total != 3 {
|
||||
t.Fatalf("team(owner): code=%d total=%d", code, total)
|
||||
}
|
||||
if items[0]["username"] == "" {
|
||||
t.Fatalf("team items should carry username: %+v", items[0])
|
||||
}
|
||||
if code, _, _ := listFiles(t, srv.URL, tokens[3], "team", "10"); code != http.StatusForbidden {
|
||||
t.Fatalf("team(member) should be 403, got %d", code)
|
||||
}
|
||||
if code, total, _ := listFiles(t, srv.URL, tokens[1], "all", "0"); code != 200 || total != 4 {
|
||||
t.Fatalf("all(admin): code=%d total=%d", code, total)
|
||||
}
|
||||
if code, _, _ := listFiles(t, srv.URL, tokens[2], "all", "0"); code != http.StatusForbidden {
|
||||
t.Fatalf("all(non-admin) should be 403, got %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePermissions(t *testing.T) {
|
||||
srv, _, cfg, tokens := newTestServer(t)
|
||||
_, aliceTeam := upload(t, srv.URL, tokens[2], pngBytes(t, 10), "10")
|
||||
_, bobTeam := upload(t, srv.URL, tokens[3], pngBytes(t, 20), "10")
|
||||
_, bobOwn := upload(t, srv.URL, tokens[3], pngBytes(t, 30), "0")
|
||||
_, aliceOwn := upload(t, srv.URL, tokens[2], pngBytes(t, 40), "0")
|
||||
|
||||
if code := deleteFile(t, srv.URL, tokens[3], aliceTeam.ID); code != http.StatusForbidden {
|
||||
t.Fatalf("member deleting other's file should be 403, got %d", code)
|
||||
}
|
||||
if code := deleteFile(t, srv.URL, tokens[2], bobTeam.ID); code != 200 {
|
||||
t.Fatalf("owner deleting team file should be 200, got %d", code)
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cfg.StorageDir, filepath.FromSlash(bobTeam.Name))); !os.IsNotExist(err) {
|
||||
t.Fatal("deleted file should be removed from disk")
|
||||
}
|
||||
if code := deleteFile(t, srv.URL, tokens[2], bobOwn.ID); code != http.StatusForbidden {
|
||||
t.Fatalf("owner deleting personal file outside team should be 403, got %d", code)
|
||||
}
|
||||
if code := deleteFile(t, srv.URL, tokens[3], bobOwn.ID); code != 200 {
|
||||
t.Fatalf("self delete should be 200, got %d", code)
|
||||
}
|
||||
if code := deleteFile(t, srv.URL, tokens[1], aliceOwn.ID); code != 200 {
|
||||
t.Fatalf("admin delete should be 200, got %d", code)
|
||||
}
|
||||
if code := deleteFile(t, srv.URL, tokens[1], aliceOwn.ID); code != http.StatusNotFound {
|
||||
t.Fatalf("double delete should be 404, got %d", code)
|
||||
}
|
||||
}
|
||||
25
internal/controller/notice.go
Normal file
25
internal/controller/notice.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// NoticeController 团队通知。
|
||||
type NoticeController struct {
|
||||
Svc *service.NoticeService
|
||||
}
|
||||
|
||||
func (h *NoticeController) List(c *gin.Context) {
|
||||
after := commonservice.ParseID(c.Query("after"))
|
||||
rows, err := h.Svc.ListNotices(commonservice.UserID(c), after)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": rows})
|
||||
}
|
||||
74
internal/controller/profile.go
Normal file
74
internal/controller/profile.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// ProfileController 用户资料。
|
||||
type ProfileController struct {
|
||||
Svc *service.ProfileService
|
||||
AvatarHist *service.AvatarHistoryService
|
||||
}
|
||||
|
||||
func (h *ProfileController) Get(c *gin.Context) {
|
||||
out, err := h.Svc.Get(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *ProfileController) Put(c *gin.Context) {
|
||||
var req service.ProfileDTO
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.Put(commonservice.UserID(c), req)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ListAvatars GET /profile/avatars
|
||||
func (h *ProfileController) ListAvatars(c *gin.Context) {
|
||||
out, err := h.AvatarHist.List(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// PushAvatar POST /profile/avatars body: {mode,value}
|
||||
func (h *ProfileController) PushAvatar(c *gin.Context) {
|
||||
var req struct {
|
||||
Mode string `json:"mode"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.AvatarHist.Push(commonservice.UserID(c), req.Mode, req.Value)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
// DeleteAvatar DELETE /profile/avatars/:id
|
||||
func (h *ProfileController) DeleteAvatar(c *gin.Context) {
|
||||
if err := h.AvatarHist.Delete(commonservice.UserID(c), commonservice.ParseID(c.Param("id"))); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
29
internal/controller/respond.go
Normal file
29
internal/controller/respond.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
)
|
||||
|
||||
// writeErr 统一把业务错误写成 {"error":"CODE"}。
|
||||
func writeErr(c *gin.Context, err error) {
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
if ae, ok := commonservice.AsAppError(err); ok {
|
||||
c.JSON(ae.Status, gin.H{"error": ae.Code})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "INTERNAL"})
|
||||
}
|
||||
|
||||
func bindJSON(c *gin.Context, dst any) bool {
|
||||
if err := c.ShouldBindJSON(dst); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_REQUEST"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
62
internal/controller/settings.go
Normal file
62
internal/controller/settings.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// SettingsController 用户/全局设置。
|
||||
type SettingsController struct {
|
||||
Svc *service.SettingsService
|
||||
}
|
||||
|
||||
func (h *SettingsController) Get(c *gin.Context) {
|
||||
if prefix := c.Query("prefix"); prefix != "" {
|
||||
rows, err := h.Svc.ListByPrefix(commonservice.UserID(c), prefix)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": rows})
|
||||
return
|
||||
}
|
||||
name := c.Param("name")
|
||||
if name == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "SETTING_NAME_REQUIRED"})
|
||||
return
|
||||
}
|
||||
row, err := h.Svc.GetSetting(commonservice.UserID(c), name)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
|
||||
func (h *SettingsController) Put(c *gin.Context) {
|
||||
var req struct {
|
||||
Value string `json:"value"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.PutSetting(commonservice.UserID(c), c.Param("name"), req.Value, req.UpdatedAt); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *SettingsController) GetGlobal(c *gin.Context) {
|
||||
row, err := h.Svc.GetGlobal(c.Param("name"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, row)
|
||||
}
|
||||
40
internal/controller/sync.go
Normal file
40
internal/controller/sync.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// SyncController 同步推拉。
|
||||
type SyncController struct {
|
||||
Svc *service.SyncService
|
||||
}
|
||||
|
||||
func (h *SyncController) Push(c *gin.Context) {
|
||||
var req struct {
|
||||
Table string `json:"table"`
|
||||
Rows []map[string]any `json:"rows"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
n, err := h.Svc.Push(req.Table, commonservice.UserID(c), req.Rows)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"pushed": n})
|
||||
}
|
||||
|
||||
func (h *SyncController) Pull(c *gin.Context) {
|
||||
rows, err := h.Svc.Pull(c.Query("table"), commonservice.UserID(c), c.Query("cursor"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"rows": rows})
|
||||
}
|
||||
263
internal/controller/team.go
Normal file
263
internal/controller/team.go
Normal file
@@ -0,0 +1,263 @@
|
||||
package controller
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"nl-pms-api/internal/commonservice"
|
||||
"nl-pms-api/internal/service"
|
||||
)
|
||||
|
||||
// TeamController 团队协作 HTTP 绑定。
|
||||
type TeamController struct {
|
||||
Svc *service.TeamService
|
||||
}
|
||||
|
||||
func (h *TeamController) Create(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
out, err := h.Svc.Create(commonservice.UserID(c), req.Name)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *TeamController) List(c *gin.Context) {
|
||||
out, err := h.Svc.List(commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
func (h *TeamController) Rename(c *gin.Context) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.Rename(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Name); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) SetDigestTime(c *gin.Context) {
|
||||
var req struct {
|
||||
DigestTime string `json:"digestTime"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.SetDigestTime(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.DigestTime); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) Dissolve(c *gin.Context) {
|
||||
if err := h.Svc.Dissolve(commonservice.ParseID(c.Param("id")), commonservice.UserID(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) Leave(c *gin.Context) {
|
||||
if err := h.Svc.Leave(commonservice.ParseID(c.Param("id")), commonservice.UserID(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) Members(c *gin.Context) {
|
||||
out, err := h.Svc.Members(commonservice.ParseID(c.Param("id")), commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
func (h *TeamController) Invite(c *gin.Context) {
|
||||
var req struct {
|
||||
Username string `json:"username"`
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.Invite(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Username, req.Role); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) SetRole(c *gin.Context) {
|
||||
var req struct {
|
||||
Role string `json:"role"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.SetRole(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), commonservice.ParseID(c.Param("userId")), req.Role); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) RemoveMember(c *gin.Context) {
|
||||
if err := h.Svc.RemoveMember(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), commonservice.ParseID(c.Param("userId"))); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskSave(c *gin.Context) {
|
||||
var req service.TeamTaskDTO
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
req.TeamID = commonservice.ParseID(c.Param("id"))
|
||||
if tid := commonservice.ParseID(c.Param("taskId")); tid > 0 {
|
||||
req.ID = tid
|
||||
}
|
||||
out, err := h.Svc.TaskSave(commonservice.UserID(c), req)
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskSetStatus(c *gin.Context) {
|
||||
var req struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.TaskSetStatus(commonservice.ParseID(c.Param("id")), commonservice.ParseID(c.Param("taskId")), commonservice.UserID(c), req.Status); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskUrge(c *gin.Context) {
|
||||
if err := h.Svc.TaskUrge(commonservice.ParseID(c.Param("id")), commonservice.ParseID(c.Param("taskId")), commonservice.UserID(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskDelete(c *gin.Context) {
|
||||
if err := h.Svc.TaskDelete(commonservice.ParseID(c.Param("id")), commonservice.ParseID(c.Param("taskId")), commonservice.UserID(c)); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) TaskList(c *gin.Context) {
|
||||
out, err := h.Svc.TaskList(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), c.Query("filter"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
func (h *TeamController) SharedItems(c *gin.Context) {
|
||||
out, err := h.Svc.SharedItems(commonservice.ParseID(c.Param("id")), commonservice.UserID(c))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"items": out})
|
||||
}
|
||||
|
||||
func (h *TeamController) UrgeShared(c *gin.Context) {
|
||||
var req struct {
|
||||
Kind string `json:"kind"`
|
||||
UUID string `json:"uuid"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.UrgeShared(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Kind, req.UUID); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) ReportSubmit(c *gin.Context) {
|
||||
var req struct {
|
||||
Date string `json:"date"`
|
||||
Content string `json:"content"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.ReportSubmit(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.Date, req.Content); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) ReportBoardGet(c *gin.Context) {
|
||||
out, err := h.Svc.ReportBoardGet(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), c.Param("date"))
|
||||
if err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, out)
|
||||
}
|
||||
|
||||
func (h *TeamController) ReportUrge(c *gin.Context) {
|
||||
var req struct {
|
||||
UserID int64 `json:"userId"`
|
||||
Date string `json:"date"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.ReportUrge(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), req.UserID, req.Date); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
|
||||
func (h *TeamController) DigestSave(c *gin.Context) {
|
||||
var req struct {
|
||||
Content string `json:"content"`
|
||||
Provider string `json:"provider"`
|
||||
}
|
||||
if !bindJSON(c, &req) {
|
||||
return
|
||||
}
|
||||
if err := h.Svc.DigestSave(commonservice.ParseID(c.Param("id")), commonservice.UserID(c), c.Param("date"), req.Content, req.Provider); err != nil {
|
||||
writeErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true})
|
||||
}
|
||||
Reference in New Issue
Block a user