功能更新

This commit is contained in:
李琦
2026-08-15 17:04:47 +08:00
parent d9475ac9da
commit b71b99a60d
49 changed files with 4976 additions and 438 deletions

View File

@@ -0,0 +1,23 @@
package commonservice
import "github.com/gin-gonic/gin"
// Gin context 中由 RequireJWT 写入的键名。
const (
CtxUserID = "user_id"
CtxUsername = "username"
)
// UserID 从 gin 上下文读取当前登录用户 ID。
func UserID(c *gin.Context) int64 {
v, _ := c.Get(CtxUserID)
id, _ := v.(int64)
return id
}
// Username 从 gin 上下文读取当前登录用户名。
func Username(c *gin.Context) string {
v, _ := c.Get(CtxUsername)
s, _ := v.(string)
return s
}

View File

@@ -0,0 +1,32 @@
package commonservice
// AppError 是带稳定错误码与 HTTP 状态的业务错误,控制器原样返回 {"error":"CODE"}。
type AppError struct {
Code string
Status int
}
func (e *AppError) Error() string {
if e == nil {
return ""
}
return e.Code
}
// AsAppError 从 error 中取出 AppError非业务错误返回 false。
func AsAppError(err error) (*AppError, bool) {
if err == nil {
return nil, false
}
if ae, ok := err.(*AppError); ok {
return ae, true
}
return nil, false
}
func BadRequest(code string) *AppError { return &AppError{Code: code, Status: 400} }
func Unauthorized(code string) *AppError { return &AppError{Code: code, Status: 401} }
func Forbidden(code string) *AppError { return &AppError{Code: code, Status: 403} }
func NotFound(code string) *AppError { return &AppError{Code: code, Status: 404} }
func Conflict(code string) *AppError { return &AppError{Code: code, Status: 409} }
func Internal(code string) *AppError { return &AppError{Code: code, Status: 500} }

View File

@@ -0,0 +1,67 @@
package commonservice
import (
"time"
"github.com/golang-jwt/jwt/v5"
)
// Claims 是 access / refresh token 的载荷。
type Claims struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
Type string `json:"type"` // access | refresh
jwt.RegisteredClaims
}
// IssueAccess 签发 access token。
func IssueAccess(secret string, userID int64, username string, ttlHours int) (string, error) {
if ttlHours <= 0 {
ttlHours = 2
}
return issue(secret, userID, username, "access", time.Duration(ttlHours)*time.Hour)
}
// IssueRefresh 签发 refresh token。
func IssueRefresh(secret string, userID int64, username string, ttlDays int) (string, error) {
if ttlDays <= 0 {
ttlDays = 30
}
return issue(secret, userID, username, "refresh", time.Duration(ttlDays)*24*time.Hour)
}
func issue(secret string, userID int64, username, typ string, ttl time.Duration) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Username: username,
Type: typ,
RegisteredClaims: jwt.RegisteredClaims{
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(ttl)),
},
}
t := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return t.SignedString([]byte(secret))
}
// Parse 校验并解析 JWTtyp 非空时要求 claims.Type 匹配。
func Parse(secret, token, typ string) (*Claims, error) {
parsed, err := jwt.ParseWithClaims(token, &Claims{}, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, Unauthorized("UNAUTHORIZED")
}
return []byte(secret), nil
})
if err != nil || !parsed.Valid {
return nil, Unauthorized("UNAUTHORIZED")
}
claims, ok := parsed.Claims.(*Claims)
if !ok || claims.UserID <= 0 {
return nil, Unauthorized("UNAUTHORIZED")
}
if typ != "" && claims.Type != typ {
return nil, Unauthorized("UNAUTHORIZED")
}
return claims, nil
}

View File

@@ -0,0 +1,55 @@
package commonservice
import "gorm.io/gorm"
// TeamRole 查询用户在团队中的角色;非成员返回空串。
func TeamRole(db *gorm.DB, teamID, userID int64) (string, error) {
if teamID <= 0 || userID <= 0 {
return "", nil
}
var role string
err := db.Table("team_members").Select("role").
Where("team_id = ? AND user_id = ?", teamID, userID).
Scan(&role).Error
if err != nil {
return "", err
}
return role, nil
}
// TeamRoleRank 角色权重owner=3 admin=2 member=1。
func TeamRoleRank(role string) int {
switch role {
case "owner":
return 3
case "admin":
return 2
case "member":
return 1
}
return 0
}
// IsTeamAdmin 该用户是否为团队 owner/admin。
func IsTeamAdmin(db *gorm.DB, teamID, userID int64) bool {
if teamID <= 0 || userID <= 0 {
return false
}
var n int64
db.Table("team_members").
Where("team_id = ? AND user_id = ? AND role IN ('owner','admin')", teamID, userID).
Count(&n)
return n > 0
}
// RequireTeamRole 校验最低角色,返回实际角色。
func RequireTeamRole(db *gorm.DB, teamID, userID int64, min string) (string, error) {
role, err := TeamRole(db, teamID, userID)
if err != nil {
return "", err
}
if TeamRoleRank(role) < TeamRoleRank(min) {
return role, Forbidden("TEAM_FORBIDDEN")
}
return role, nil
}

View File

@@ -0,0 +1,125 @@
package commonservice
import (
"crypto/rand"
"encoding/hex"
"fmt"
"strconv"
"strings"
"time"
"nl-pms-api/internal/config"
)
// AdminUserID 是 code_count 体系的超级管理员账号users.id=1
const AdminUserID int64 = 1
// ParseID 把字符串解析为非负 int64非法或负数视为 0。
func ParseID(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
if n < 0 {
return 0
}
return n
}
// Clip 按字节截断字符串到最多 n 字节。
func Clip(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}
// ClipRunes 按 rune 截断到最多 n 个字符。
func ClipRunes(s string, n int) string {
r := []rune(strings.TrimSpace(s))
if len(r) > n {
return string(r[:n])
}
return string(r)
}
// NowRFC 返回当前 UTC 的 RFC3339 时间串(与 view 库惯例一致)。
func NowRFC() string {
return time.Now().UTC().Format(time.RFC3339)
}
// PublicURL 拼接文件公开访问地址:优先配置的 base_url否则用 requestHost可含 scheme://host
func PublicURL(cfg *config.Config, requestHost, name string) string {
base := cfg.BaseURL
if base == "" {
base = strings.TrimRight(requestHost, "/")
if !strings.Contains(base, "://") {
base = "http://" + base
}
}
return base + "/files/" + name
}
// NormalizeKind 规范化文件用途avatar | content其余为空。
func NormalizeKind(k string) string {
if k == "avatar" || k == "content" {
return k
}
return ""
}
// StoredName 生成不可枚举的存储相对路径(日期目录 + 128 位随机 hex
func StoredName(ext string) string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return time.Now().UTC().Format("2006/01/02") + "/" + hex.EncodeToString(b) + ext
}
// MapStr 从通用 JSON map 取字符串字段。
func MapStr(m map[string]any, key string) string {
v, ok := m[key]
if !ok || v == nil {
return ""
}
switch x := v.(type) {
case string:
return x
case []byte:
return string(x)
case float64:
if x == float64(int64(x)) {
return strconv.FormatInt(int64(x), 10)
}
return strconv.FormatFloat(x, 'f', -1, 64)
case bool:
if x {
return "1"
}
return "0"
default:
return fmt.Sprint(x)
}
}
// MapInt64 从通用 JSON map 取 int64 字段。
func MapInt64(m map[string]any, key string) int64 {
v, ok := m[key]
if !ok || v == nil {
return 0
}
switch x := v.(type) {
case float64:
return int64(x)
case int64:
return x
case int:
return int64(x)
case string:
n, _ := strconv.ParseInt(x, 10, 64)
return n
case bool:
if x {
return 1
}
return 0
default:
return 0
}
}

View File

@@ -16,11 +16,21 @@ type Config struct {
Listen string `yaml:"listen"`
// BaseURL 拼接文件访问 URL 的前缀(客户端可达的地址);留空则按请求 Host 推断。
BaseURL string `yaml:"base_url"`
// APIKey 是客户端上传凭证:请求需带 Authorization: Bearer <api_key>
APIKey string `yaml:"api_key"`
StorageDir string `yaml:"storage_dir"`
MaxUploadMB int64 `yaml:"max_upload_mb"`
MySQL struct {
// BasePath 反代保留的 URL 前缀(如 /pms-api。1Panel 等不剥前缀时必填;留空挂在根路径
BasePath string `yaml:"base_path"`
// JWTSecret 签发/校验 access、refresh token 的密钥,不允许留空。
JWTSecret string `yaml:"jwt_secret"`
// AccessTTLHours access token 有效小时数,默认 2。
AccessTTLHours int `yaml:"access_ttl_hours"`
// RefreshTTLDays refresh token 有效天数,默认 30。
RefreshTTLDays int `yaml:"refresh_ttl_days"`
StorageDir string `yaml:"storage_dir"`
MaxUploadMB int64 `yaml:"max_upload_mb"`
// MaxReleaseMB 发版安装包上传上限MB默认 200。
MaxReleaseMB int64 `yaml:"max_release_mb"`
// TrustedProxies 可信反代 CIDR/IP用于正确解析 ClientIP空则不信任 X-Forwarded-For。
TrustedProxies []string `yaml:"trusted_proxies"`
MySQL struct {
DSN string `yaml:"dsn"`
} `yaml:"mysql"`
}
@@ -28,7 +38,10 @@ type Config struct {
// MaxUploadBytes 返回单文件字节上限。
func (c *Config) MaxUploadBytes() int64 { return c.MaxUploadMB << 20 }
// Load 读取并校验配置缺省值prod / :8788 / ./uploads / 20MB
// MaxReleaseBytes 返回发版包字节上限
func (c *Config) MaxReleaseBytes() int64 { return c.MaxReleaseMB << 20 }
// Load 读取并校验配置缺省值prod / :8788 / ./uploads / 20MB / access 2h / refresh 30d。
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
@@ -50,10 +63,29 @@ func Load(path string) (*Config, error) {
if c.MaxUploadMB <= 0 {
c.MaxUploadMB = 20
}
if c.MaxReleaseMB <= 0 {
c.MaxReleaseMB = 200
}
if c.AccessTTLHours <= 0 {
c.AccessTTLHours = 2
}
if c.RefreshTTLDays <= 0 {
c.RefreshTTLDays = 30
}
c.BaseURL = strings.TrimRight(strings.TrimSpace(c.BaseURL), "/")
c.APIKey = strings.TrimSpace(c.APIKey)
if c.APIKey == "" {
return nil, errors.New("配置缺少 api_key客户端上传凭证不允许留空开放上传")
c.BasePath = strings.TrimSpace(c.BasePath)
if c.BasePath != "" {
if !strings.HasPrefix(c.BasePath, "/") {
c.BasePath = "/" + c.BasePath
}
c.BasePath = strings.TrimRight(c.BasePath, "/")
if c.BasePath == "/" {
c.BasePath = ""
}
}
c.JWTSecret = strings.TrimSpace(c.JWTSecret)
if c.JWTSecret == "" {
return nil, errors.New("配置缺少 jwt_secretJWT 签发密钥,不允许留空)")
}
if strings.TrimSpace(c.MySQL.DSN) == "" {
return nil, errors.New("配置缺少 mysql.dsn")

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

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

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

View File

@@ -1,4 +1,4 @@
package handler_test
package controller_test
import (
"bytes"
@@ -17,35 +17,49 @@ import (
"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 testKey = "test-key"
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) {
// 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{}); err != nil {
if err := db.AutoMigrate(&model.File{}, &model.User{}); err != nil {
t.Fatalf("migrate: %v", err)
}
db.Exec(`CREATE TABLE users(id INTEGER PRIMARY KEY, username TEXT)`)
db.Exec(`CREATE TABLE team_members(team_id INTEGER, user_id INTEGER, role TEXT)`)
db.Exec(`INSERT INTO users(id, username) VALUES(1,'admin'),(2,'alice'),(3,'bob')`)
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", APIKey: testKey, StorageDir: t.TempDir(), MaxUploadMB: 20}
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
return srv, db, cfg, tokens
}
// pngBytes 生成一张纯色 PNGtone 不同则内容sha256不同。
func pngBytes(t *testing.T, tone uint8) []byte {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, 2, 2))
@@ -68,7 +82,7 @@ type uploadResp struct {
Mime string `json:"mime"`
}
func upload(t *testing.T, url, key string, data []byte, userID, teamID string) (*http.Response, uploadResp) {
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)
@@ -80,13 +94,12 @@ func upload(t *testing.T, url, key string, data []byte, userID, teamID string) (
t.Fatalf("write: %v", err)
}
_ = w.WriteField("kind", "content")
_ = w.WriteField("userId", userID)
_ = w.WriteField("teamId", teamID)
_ = w.Close()
req, _ := http.NewRequest("POST", url+"/api/v1/files", &body)
req.Header.Set("Content-Type", w.FormDataContentType())
if key != "" {
req.Header.Set("Authorization", "Bearer "+key)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
@@ -98,10 +111,10 @@ func upload(t *testing.T, url, key string, data []byte, userID, teamID string) (
return resp, out
}
func listFiles(t *testing.T, base, scope, userID, teamID string) (int, int64, []map[string]any) {
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+"&userId="+userID+"&teamId="+teamID, nil)
req.Header.Set("Authorization", "Bearer "+testKey)
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)
@@ -115,10 +128,10 @@ func listFiles(t *testing.T, base, scope, userID, teamID string) (int, int64, []
return resp.StatusCode, out.Total, out.Items
}
func deleteFile(t *testing.T, base string, id int64, userID string) int {
func deleteFile(t *testing.T, base, token string, id int64) int {
t.Helper()
req, _ := http.NewRequest("DELETE", base+"/api/v1/files/"+jsonNum(id)+"?userId="+userID, nil)
req.Header.Set("Authorization", "Bearer "+testKey)
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)
@@ -133,7 +146,7 @@ func jsonNum(n int64) string {
}
func TestHealthz(t *testing.T) {
srv, _, _ := newTestServer(t)
srv, _, _, _ := newTestServer(t)
resp, err := http.Get(srv.URL + "/healthz")
if err != nil || resp.StatusCode != 200 {
t.Fatalf("healthz: %v %v", err, resp)
@@ -141,26 +154,25 @@ func TestHealthz(t *testing.T) {
}
func TestUploadRequiresAuth(t *testing.T) {
srv, _, _ := newTestServer(t)
if resp, _ := upload(t, srv.URL, "", pngBytes(t, 200), "2", "0"); resp.StatusCode != http.StatusUnauthorized {
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), "2", "0"); resp.StatusCode != http.StatusUnauthorized {
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 := newTestServer(t)
srv, db, cfg, tokens := newTestServer(t)
data := pngBytes(t, 200)
resp, out := upload(t, srv.URL, testKey, data, "2", "10")
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)
@@ -170,13 +182,11 @@ func TestUploadServeAndScopedDedupe(t *testing.T) {
if !bytes.Equal(served.Bytes(), data) {
t.Fatal("served content mismatch")
}
// 同归属user=2, team=10重复上传 → 秒传复用同一条记录
_, again := upload(t, srv.URL, testKey, data, "2", "10")
_, 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)
}
// 不同归属user=3上传同内容 → 独立记录独立文件,删除互不影响
_, other := upload(t, srv.URL, testKey, data, "3", "10")
_, 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)
}
@@ -188,75 +198,66 @@ func TestUploadServeAndScopedDedupe(t *testing.T) {
}
func TestUploadRejectsNonImage(t *testing.T) {
srv, _, _ := newTestServer(t)
if resp, _ := upload(t, srv.URL, testKey, []byte("plain text, not an image"), "2", "0"); resp.StatusCode != http.StatusUnsupportedMediaType {
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, _, _ := newTestServer(t)
upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10") // alice 团队图 x2
upload(t, srv.URL, testKey, pngBytes(t, 20), "2", "10")
upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "10") // bob 团队图
upload(t, srv.URL, testKey, pngBytes(t, 40), "3", "0") // bob 个人图
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, "mine", "2", "0"); code != 200 || total != 2 || len(items) != 2 {
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))
}
// 团队视角owner 可见团队 3 张(含上传者用户名)
code, total, items := listFiles(t, srv.URL, "team", "2", "10")
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])
}
// member 无权看团队素材
if code, _, _ := listFiles(t, srv.URL, "team", "3", "10"); code != http.StatusForbidden {
if code, _, _ := listFiles(t, srv.URL, tokens[3], "team", "10"); code != http.StatusForbidden {
t.Fatalf("team(member) should be 403, got %d", code)
}
// all 仅超管
if code, total, _ := listFiles(t, srv.URL, "all", "1", "0"); code != 200 || total != 4 {
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, "all", "2", "0"); code != http.StatusForbidden {
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 := newTestServer(t)
_, aliceTeam := upload(t, srv.URL, testKey, pngBytes(t, 10), "2", "10")
_, bobTeam := upload(t, srv.URL, testKey, pngBytes(t, 20), "3", "10")
_, bobOwn := upload(t, srv.URL, testKey, pngBytes(t, 30), "3", "0")
_, aliceOwn := upload(t, srv.URL, testKey, pngBytes(t, 40), "2", "0")
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")
// member 不能删别人的(即使同团队)
if code := deleteFile(t, srv.URL, aliceTeam.ID, "3"); code != http.StatusForbidden {
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)
}
// 团队 owner 可删团队内他人上传的
if code := deleteFile(t, srv.URL, bobTeam.ID, "2"); code != 200 {
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")
}
// owner 不能删团队外的个人文件
if code := deleteFile(t, srv.URL, bobOwn.ID, "2"); code != http.StatusForbidden {
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, bobOwn.ID, "3"); code != 200 {
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, aliceOwn.ID, "1"); code != 200 {
if code := deleteFile(t, srv.URL, tokens[1], aliceOwn.ID); code != 200 {
t.Fatalf("admin delete should be 200, got %d", code)
}
// 已删除 → 404
if code := deleteFile(t, srv.URL, aliceOwn.ID, "1"); code != http.StatusNotFound {
if code := deleteFile(t, srv.URL, tokens[1], aliceOwn.ID); code != http.StatusNotFound {
t.Fatalf("double delete should be 404, got %d", code)
}
}

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

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

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

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

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

View File

@@ -13,7 +13,7 @@ import (
)
// Open 连接 MySQL 并按环境处理表结构:
// dev 环境自动迁移 pms_filesprod 严禁任何 DDL表直接报错退出(提示执行 init.sql
// dev 自动迁移 File + User及可选业务表prod 严禁 DDL users/pms_files 直接报错
func Open(cfg *config.Config) (*gorm.DB, error) {
lv := logger.Warn
if cfg.Env == "dev" {
@@ -24,11 +24,33 @@ func Open(cfg *config.Config) (*gorm.DB, error) {
return nil, fmt.Errorf("连接 MySQL 失败: %w", err)
}
if cfg.Env == "dev" {
if err := db.AutoMigrate(&model.File{}); err != nil {
if err := db.AutoMigrate(
&model.User{},
&model.File{},
&model.SyncTodo{},
&model.SyncTicket{},
&model.SyncNote{},
&model.SyncSetting{},
&model.UserProfile{},
&model.UserAvatarHistory{},
&model.Team{},
&model.TeamMember{},
&model.TeamTask{},
&model.TeamReport{},
&model.TeamDigest{},
&model.TeamNotice{},
&model.UserDailyActive{},
&model.AIUsageDaily{},
&model.AppRelease{},
&model.AdminStepup{},
); err != nil {
return nil, fmt.Errorf("dev 自动迁移失败: %w", err)
}
return db, nil
}
if !db.Migrator().HasTable(&model.User{}) {
return nil, errors.New("缺少 users 表:生产环境启动不执行迁移,请先手工执行 init.sql")
}
if !db.Migrator().HasTable(&model.File{}) {
return nil, errors.New("缺少 pms_files 表:生产环境启动不执行迁移,请先手工执行 init.sql")
}

View File

@@ -1,265 +0,0 @@
package handler
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
)
// adminUserID 是 code_count 体系的超级管理员账号users.id=1可见/可管全部文件。
const adminUserID = 1
// FileHandler 提供图片上传与素材库管理:内容落盘 storage_dir元数据入 pms_files 表。
// 身份沿用 code-count 的内网信任模型:客户端自报 userId/teamId服务端据
// code_count 库的 team_members / users 表判定管理范围(防误操作,不防伪造)。
type FileHandler struct {
DB *gorm.DB
Cfg *config.Config
}
// extByMime 是允许上传的图片类型白名单(按内容嗅探判定,不信任扩展名)。
var extByMime = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
}
// Upload 处理 POST /api/v1/filesmultipart 字段 file 必填kindavatar|content
// userId、teamId 可选。同一归属userId+teamId重复上传同内容直接复用已有记录秒传
// 不同归属各自落盘,保证"删除自己的素材"不影响他人。
// 成功返回 {id, name, url, size, mime}url 可直接放进 <img src> / Markdown。
func (h *FileHandler) Upload(c *gin.Context) {
fh, err := c.FormFile("file")
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_REQUIRED"})
return
}
if fh.Size > h.Cfg.MaxUploadBytes() {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"})
return
}
f, err := fh.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "FILE_READ_FAILED"})
return
}
defer f.Close()
data, err := io.ReadAll(io.LimitReader(f, h.Cfg.MaxUploadBytes()+1))
if err != nil || int64(len(data)) > h.Cfg.MaxUploadBytes() {
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "FILE_TOO_LARGE"})
return
}
mime := http.DetectContentType(data)
ext, ok := extByMime[mime]
if !ok {
c.JSON(http.StatusUnsupportedMediaType, gin.H{"error": "UNSUPPORTED_TYPE"})
return
}
uid := parseID(c.PostForm("userId"))
tid := parseID(c.PostForm("teamId"))
sum := hex.EncodeToString(func() []byte { s := sha256.Sum256(data); return s[:] }())
var rec model.File
if h.DB.Where("sha256 = ? AND user_id = ? AND team_id = ?", sum, uid, tid).First(&rec).Error == nil {
c.JSON(http.StatusOK, h.fileResponse(c, rec))
return
}
name := storedName(ext)
full := filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
return
}
if err := os.WriteFile(full, data, 0644); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
return
}
rec = model.File{
Name: name,
Original: clip(filepath.Base(fh.Filename), 255),
Mime: mime,
Size: int64(len(data)),
SHA256: sum,
UserID: uid,
TeamID: tid,
Kind: normalizeKind(c.PostForm("kind")),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := h.DB.Create(&rec).Error; err != nil {
os.Remove(full)
c.JSON(http.StatusInternalServerError, gin.H{"error": "SAVE_FAILED"})
return
}
c.JSON(http.StatusOK, h.fileResponse(c, rec))
}
// fileItem 是素材库列表项pms_files 联 users 取上传者名)。
type fileItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Original string `json:"original"`
Mime string `json:"mime"`
Size int64 `json:"size"`
UserID int64 `json:"userId"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"`
CreatedAt string `json:"createdAt"`
Username string `json:"username"`
URL string `json:"url" gorm:"-"`
}
// List 处理 GET /api/v1/files素材库分页列表。
// scope=mine 看自己任何登录用户scope=team 看指定团队(需为该团队 owner/admin
// scope=all 看全部(仅 userId=1。按 id 倒序分页。
func (h *FileHandler) List(c *gin.Context) {
uid := parseID(c.Query("userId"))
if uid <= 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "USER_REQUIRED"})
return
}
var where func(*gorm.DB) *gorm.DB
switch c.Query("scope") {
case "mine":
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.user_id = ?", uid) }
case "team":
tid := parseID(c.Query("teamId"))
if !h.isTeamAdmin(tid, uid) {
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.team_id = ?", tid) }
case "all":
if uid != adminUserID {
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
where = func(db *gorm.DB) *gorm.DB { return db }
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "BAD_SCOPE"})
return
}
page := parseID(c.Query("page"))
if page < 1 {
page = 1
}
size := parseID(c.Query("pageSize"))
if size < 1 || size > 100 {
size = 24
}
var total int64
if err := h.DB.Table("pms_files").Scopes(where).Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"})
return
}
items := []fileItem{}
err := h.DB.Table("pms_files").Scopes(where).
Select("pms_files.id, pms_files.name, pms_files.original, pms_files.mime, pms_files.size, pms_files.user_id, pms_files.team_id, pms_files.kind, pms_files.created_at, COALESCE(u.username,'') AS username").
Joins("LEFT JOIN users u ON u.id = pms_files.user_id").
Order("pms_files.id DESC").Limit(int(size)).Offset(int((page - 1) * size)).
Scan(&items).Error
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "QUERY_FAILED"})
return
}
for i := range items {
items[i].URL = h.publicURL(c, items[i].Name)
}
c.JSON(http.StatusOK, gin.H{"total": total, "items": items})
}
// Delete 处理 DELETE /api/v1/files/:id删除记录与磁盘文件。
// 允许本人、超管userId=1、该文件归属团队的 owner/admin。
func (h *FileHandler) Delete(c *gin.Context) {
uid := parseID(c.Query("userId"))
id := parseID(c.Param("id"))
var rec model.File
if h.DB.First(&rec, id).Error != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "NOT_FOUND"})
return
}
allowed := uid == adminUserID ||
(uid > 0 && rec.UserID == uid) ||
(rec.TeamID > 0 && h.isTeamAdmin(rec.TeamID, uid))
if !allowed {
c.JSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
if err := h.DB.Delete(&model.File{}, id).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "DELETE_FAILED"})
return
}
// 每条记录独占一个文件name 唯一),删记录即可删盘;失败不影响结果(孤儿文件可人工清理)。
_ = os.Remove(filepath.Join(h.Cfg.StorageDir, filepath.FromSlash(rec.Name)))
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// isTeamAdmin 查 code_count 库的 team_members该用户是否为团队 owner/admin。
func (h *FileHandler) isTeamAdmin(teamID, userID int64) bool {
if teamID <= 0 || userID <= 0 {
return false
}
var n int64
h.DB.Table("team_members").
Where("team_id = ? AND user_id = ? AND role IN ('owner','admin')", teamID, userID).
Count(&n)
return n > 0
}
func (h *FileHandler) fileResponse(c *gin.Context, f model.File) gin.H {
return gin.H{"id": f.ID, "name": f.Name, "url": h.publicURL(c, f.Name), "size": f.Size, "mime": f.Mime, "teamId": f.TeamID}
}
// publicURL 拼接文件公开访问地址:优先配置的 base_url否则按本次请求推断。
func (h *FileHandler) publicURL(c *gin.Context, name string) string {
base := h.Cfg.BaseURL
if base == "" {
scheme := "http"
if c.Request.TLS != nil {
scheme = "https"
}
base = scheme + "://" + c.Request.Host
}
return base + "/files/" + name
}
// storedName 生成不可枚举的存储相对路径(日期目录 + 128 位随机 hex
// 文件公开可读但路径不可猜测,等效 capability URL。
func storedName(ext string) string {
b := make([]byte, 16)
_, _ = rand.Read(b)
return time.Now().UTC().Format("2006/01/02") + "/" + hex.EncodeToString(b) + ext
}
func normalizeKind(k string) string {
if k == "avatar" || k == "content" {
return k
}
return ""
}
func parseID(s string) int64 {
n, _ := strconv.ParseInt(s, 10, 64)
if n < 0 {
return 0
}
return n
}
func clip(s string, n int) string {
if len(s) > n {
return s[:n]
}
return s
}

View File

@@ -0,0 +1,125 @@
package middleware
import (
"crypto/sha256"
"encoding/hex"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
const (
HeaderAdminStepUp = "X-Admin-StepUp"
CtxClientIP = "client_ip"
)
// AttachClientIP 把解析后的客户端 IP 写入上下文。
func AttachClientIP() gin.HandlerFunc {
return func(c *gin.Context) {
c.Set(CtxClientIP, strings.TrimSpace(c.ClientIP()))
c.Next()
}
}
// ClientIP 读取中间件写入的客户端 IP。
func ClientIP(c *gin.Context) string {
v, _ := c.Get(CtxClientIP)
s, _ := v.(string)
if s == "" {
return strings.TrimSpace(c.ClientIP())
}
return s
}
// RejectDisabled 拒绝已禁用账号(在 JWT 之后)。
func RejectDisabled(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
uid := commonservice.UserID(c)
if uid <= 0 {
c.Next()
return
}
var u model.User
if err := db.Select("id", "disabled").First(&u, uid).Error; err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
return
}
if u.Disabled != 0 {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ACCOUNT_DISABLED"})
return
}
c.Next()
}
}
// RequireAdmin 仅允许 users.id=1。
func RequireAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
if commonservice.UserID(c) != commonservice.AdminUserID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
c.Next()
}
}
// RequireAdminStepUp 校验 2 小时内有效的动态码二次验证,且 IP 与签发时一致。
func RequireAdminStepUp(db *gorm.DB) gin.HandlerFunc {
return func(c *gin.Context) {
if commonservice.UserID(c) != commonservice.AdminUserID {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "FORBIDDEN"})
return
}
token := strings.TrimSpace(c.GetHeader(HeaderAdminStepUp))
if token == "" {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
return
}
ip := ClientIP(c)
var row model.AdminStepup
if err := db.First(&row, commonservice.AdminUserID).Error; err != nil {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
return
}
sum := sha256.Sum256([]byte(token))
hash := hex.EncodeToString(sum[:])
if row.TokenHash != hash {
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
return
}
exp, err := time.Parse(time.RFC3339, row.ExpiresAt)
if err != nil || time.Now().UTC().After(exp) {
_ = db.Delete(&model.AdminStepup{}, commonservice.AdminUserID).Error
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{"error": "ADMIN_STEPUP_REQUIRED"})
return
}
if row.ClientIP != "" && ip != "" && row.ClientIP != ip {
_ = db.Delete(&model.AdminStepup{}, commonservice.AdminUserID).Error
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "ADMIN_IP_CHANGED",
"message": "检测到 IP 变化,存在账号被盗风险,请重新输入动态码",
})
return
}
c.Next()
}
}
// RequireStepUpForGlobalSetting 仅当写入全局设置键时要求 stepup。
func RequireStepUpForGlobalSetting(db *gorm.DB) gin.HandlerFunc {
inner := RequireAdminStepUp(db)
return func(c *gin.Context) {
name := strings.TrimSpace(c.Param("name"))
if name == "file_storage" || strings.HasPrefix(name, "fest_img:") {
inner(c)
return
}
c.Next()
}
}

View File

@@ -1,21 +0,0 @@
package middleware
import (
"crypto/subtle"
"net/http"
"strings"
"github.com/gin-gonic/gin"
)
// RequireAPIKey 校验 Authorization: Bearer <api_key>(常数时间比较),不匹配返回 401。
func RequireAPIKey(key string) gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
if key == "" || subtle.ConstantTimeCompare([]byte(token), []byte(key)) != 1 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
return
}
c.Next()
}
}

View File

@@ -0,0 +1,29 @@
package middleware
import (
"net/http"
"strings"
"github.com/gin-gonic/gin"
"nl-pms-api/internal/commonservice"
)
// RequireJWT 校验 Authorization: Bearer <access token>,并把 user_id/username 写入上下文。
func RequireJWT(secret string) gin.HandlerFunc {
return func(c *gin.Context) {
token := strings.TrimSpace(strings.TrimPrefix(c.GetHeader("Authorization"), "Bearer "))
if token == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
return
}
claims, err := commonservice.Parse(secret, token, "access")
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "UNAUTHORIZED"})
return
}
c.Set(commonservice.CtxUserID, claims.UserID)
c.Set(commonservice.CtxUsername, claims.Username)
c.Next()
}
}

50
internal/model/admin.go Normal file
View File

@@ -0,0 +1,50 @@
package model
// UserDailyActive 对应 user_daily_active。
type UserDailyActive struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
ActiveDate string `gorm:"primaryKey;size:10" json:"activeDate"`
LastIP string `gorm:"column:last_ip;size:64;not null;default:''" json:"lastIp"`
LastSeenAt string `gorm:"column:last_seen_at;size:32;not null;default:''" json:"lastSeenAt"`
}
func (UserDailyActive) TableName() string { return "user_daily_active" }
// AIUsageDaily 对应 ai_usage_daily。
type AIUsageDaily struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
TeamID int64 `gorm:"primaryKey;default:0" json:"teamId"`
UsageDate string `gorm:"primaryKey;size:10" json:"usageDate"`
Provider string `gorm:"primaryKey;size:32;not null;default:''" json:"provider"`
PromptTokens int64 `gorm:"not null;default:0" json:"promptTokens"`
CompletionTokens int64 `gorm:"not null;default:0" json:"completionTokens"`
Calls int `gorm:"not null;default:0" json:"calls"`
Estimated int `gorm:"not null;default:0" json:"estimated"`
}
func (AIUsageDaily) TableName() string { return "ai_usage_daily" }
// AppRelease 对应 app_releases。
type AppRelease struct {
ID int64 `gorm:"primaryKey" json:"id"`
Version string `gorm:"size:32;not null" json:"version"`
Channel string `gorm:"size:16;not null;default:stable" json:"channel"`
Filename string `gorm:"size:255;not null" json:"filename"`
SHA256 string `gorm:"column:sha256;size:64;not null;default:''" json:"sha256"`
SizeBytes int64 `gorm:"column:size_bytes;not null;default:0" json:"sizeBytes"`
Changelog string `gorm:"type:mediumtext;not null" json:"changelog"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
IsLatest int `gorm:"column:is_latest;not null;default:0" json:"isLatest"`
}
func (AppRelease) TableName() string { return "app_releases" }
// AdminStepup 对应 admin_stepup。
type AdminStepup struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
TokenHash string `gorm:"column:token_hash;size:64;not null" json:"-"`
ClientIP string `gorm:"column:client_ip;size:64;not null;default:''" json:"clientIp"`
ExpiresAt string `gorm:"column:expires_at;size:32;not null" json:"expiresAt"`
}
func (AdminStepup) TableName() string { return "admin_stepup" }

View File

@@ -0,0 +1,12 @@
package model
// UserAvatarHistory 用户头像历史,对应 user_avatar_history。
type UserAvatarHistory struct {
ID int64 `gorm:"primaryKey;autoIncrement" json:"id"`
UserID int64 `gorm:"not null;index:idx_uah_user_id" json:"userId"`
Mode string `gorm:"size:16;not null;default:url" json:"mode"` // url | base64
Value string `gorm:"type:mediumtext;not null" json:"value"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
}
func (UserAvatarHistory) TableName() string { return "user_avatar_history" }

15
internal/model/profile.go Normal file
View File

@@ -0,0 +1,15 @@
package model
// UserProfile 对应 user_profiles。
type UserProfile struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
Nickname string `gorm:"size:64;not null;default:''" json:"nickname"`
Title string `gorm:"size:64;not null;default:''" json:"title"`
Email string `gorm:"size:128;not null;default:''" json:"email"`
Bio string `gorm:"size:500;not null;default:''" json:"bio"`
TechTags string `gorm:"size:1000;not null;default:'[]'" json:"techTags"`
AvatarThumb string `gorm:"type:mediumtext;not null" json:"avatarThumb"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
}
func (UserProfile) TableName() string { return "user_profiles" }

62
internal/model/sync.go Normal file
View File

@@ -0,0 +1,62 @@
package model
// SyncTodo 对应 sync_todos。
type SyncTodo struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
UUID string `gorm:"primaryKey;size:36" json:"uuid"`
Title string `gorm:"type:text;not null" json:"title"`
Content string `gorm:"type:mediumtext;not null" json:"content"`
ProjectName string `gorm:"size:255;not null;default:''" json:"projectName"`
DueAt string `gorm:"size:32;not null;default:''" json:"dueAt"`
Priority string `gorm:"size:16;not null;default:medium" json:"priority"`
Status string `gorm:"size:16;not null;default:open" json:"status"`
History string `gorm:"type:mediumtext;not null" json:"history"`
TeamID int64 `gorm:"not null;default:0;index" json:"teamId"`
CreatedAt string `gorm:"size:32;not null;default:''" json:"createdAt"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
Deleted int `gorm:"not null;default:0" json:"deleted"`
}
func (SyncTodo) TableName() string { return "sync_todos" }
// SyncTicket 对应 sync_tickets。
type SyncTicket struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
UUID string `gorm:"primaryKey;size:36" json:"uuid"`
Title string `gorm:"type:text;not null" json:"title"`
Description string `gorm:"type:mediumtext;not null" json:"description"`
Type string `gorm:"size:16;not null;default:task" json:"type"`
ProjectName string `gorm:"size:255;not null;default:''" json:"projectName"`
StartAt string `gorm:"size:32;not null;default:''" json:"startAt"`
DueAt string `gorm:"size:32;not null;default:''" json:"dueAt"`
Status string `gorm:"size:16;not null;default:open" json:"status"`
Priority string `gorm:"size:16;not null;default:medium" json:"priority"`
History string `gorm:"type:mediumtext;not null" json:"history"`
TeamID int64 `gorm:"not null;default:0;index" json:"teamId"`
CreatedAt string `gorm:"size:32;not null;default:''" json:"createdAt"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
Deleted int `gorm:"not null;default:0" json:"deleted"`
}
func (SyncTicket) TableName() string { return "sync_tickets" }
// SyncNote 对应 sync_notes。
type SyncNote struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
UUID string `gorm:"primaryKey;size:36" json:"uuid"`
Content string `gorm:"type:mediumtext;not null" json:"content"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
Deleted int `gorm:"not null;default:0" json:"deleted"`
}
func (SyncNote) TableName() string { return "sync_notes" }
// SyncSetting 对应 sync_settings。
type SyncSetting struct {
UserID int64 `gorm:"primaryKey" json:"userId"`
Name string `gorm:"primaryKey;size:64" json:"name"`
Value string `gorm:"type:mediumtext;not null" json:"value"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
}
func (SyncSetting) TableName() string { return "sync_settings" }

80
internal/model/team.go Normal file
View File

@@ -0,0 +1,80 @@
package model
// Team 对应 teams。
type Team struct {
ID int64 `gorm:"primaryKey" json:"id"`
Name string `gorm:"size:64;not null" json:"name"`
OwnerID int64 `gorm:"not null" json:"ownerId"`
DigestTime string `gorm:"size:8;not null;default:21:00" json:"digestTime"`
AIBanned int `gorm:"column:ai_banned;not null;default:0" json:"aiBanned"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
}
func (Team) TableName() string { return "teams" }
// TeamMember 对应 team_members。
type TeamMember struct {
TeamID int64 `gorm:"primaryKey" json:"teamId"`
UserID int64 `gorm:"primaryKey" json:"userId"`
Role string `gorm:"size:16;not null;default:member" json:"role"`
JoinedAt string `gorm:"size:32;not null" json:"joinedAt"`
}
func (TeamMember) TableName() string { return "team_members" }
// TeamTask 对应 team_tasks。
type TeamTask struct {
ID int64 `gorm:"primaryKey" json:"id"`
TeamID int64 `gorm:"not null;index" json:"teamId"`
Kind string `gorm:"size:16;not null;default:todo" json:"kind"`
Title string `gorm:"type:text;not null" json:"title"`
Description string `gorm:"type:mediumtext;not null" json:"description"`
Priority string `gorm:"size:16;not null;default:medium" json:"priority"`
Status string `gorm:"size:16;not null;default:open" json:"status"`
CreatorID int64 `gorm:"not null" json:"creatorId"`
AssigneeID int64 `gorm:"not null;default:0;index" json:"assigneeId"`
StartAt string `gorm:"size:32;not null;default:''" json:"startAt"`
DueAt string `gorm:"size:32;not null;default:''" json:"dueAt"`
UrgedAt string `gorm:"size:32;not null;default:''" json:"urgedAt"`
History string `gorm:"type:mediumtext;not null" json:"history"`
UpdatedAt string `gorm:"size:32;not null" json:"updatedAt"`
Deleted int `gorm:"not null;default:0" json:"deleted"`
}
func (TeamTask) TableName() string { return "team_tasks" }
// TeamReport 对应 team_reports。
type TeamReport struct {
TeamID int64 `gorm:"primaryKey" json:"teamId"`
UserID int64 `gorm:"primaryKey" json:"userId"`
Date string `gorm:"primaryKey;size:10" json:"date"`
Content string `gorm:"type:mediumtext;not null" json:"content"`
SubmittedAt string `gorm:"size:32;not null" json:"submittedAt"`
}
func (TeamReport) TableName() string { return "team_reports" }
// TeamDigest 对应 team_digests。
type TeamDigest struct {
TeamID int64 `gorm:"primaryKey" json:"teamId"`
Date string `gorm:"primaryKey;size:10" json:"date"`
Content string `gorm:"type:mediumtext;not null" json:"content"`
Provider string `gorm:"size:32;not null;default:''" json:"provider"`
GeneratedAt string `gorm:"size:32;not null" json:"generatedAt"`
}
func (TeamDigest) TableName() string { return "team_digests" }
// TeamNotice 对应 team_notices。
type TeamNotice struct {
ID int64 `gorm:"primaryKey" json:"id"`
TeamID int64 `gorm:"not null" json:"teamId"`
ToUser int64 `gorm:"not null;index" json:"toUser"`
FromUser int64 `gorm:"not null" json:"fromUser"`
Kind string `gorm:"size:16;not null" json:"kind"`
RefID string `gorm:"size:64;not null;default:''" json:"refId"`
Content string `gorm:"type:text;not null" json:"content"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
}
func (TeamNotice) TableName() string { return "team_notices" }

17
internal/model/user.go Normal file
View File

@@ -0,0 +1,17 @@
package model
// User 对应 users 表。
type User struct {
ID int64 `gorm:"primaryKey" json:"id"`
Username string `gorm:"size:64;uniqueIndex;not null" json:"username"`
PasswordHash string `gorm:"size:100;not null" json:"-"`
TOTPSecret string `gorm:"column:totp_secret;size:64;not null;default:''" json:"-"`
TOTPEnabled int `gorm:"column:totp_enabled;not null;default:0" json:"totpEnabled"`
LastLoginIP string `gorm:"column:last_login_ip;size:64;not null;default:''" json:"lastLoginIp"`
LastSeenAt string `gorm:"column:last_seen_at;size:32;not null;default:''" json:"lastSeenAt"`
AIBanned int `gorm:"column:ai_banned;not null;default:0" json:"aiBanned"`
Disabled int `gorm:"not null;default:0" json:"disabled"`
CreatedAt string `gorm:"size:32;not null" json:"createdAt"`
}
func (User) TableName() string { return "users" }

View File

@@ -7,36 +7,129 @@ import (
"gorm.io/gorm"
"nl-pms-api/internal/config"
"nl-pms-api/internal/handler"
"nl-pms-api/internal/controller"
"nl-pms-api/internal/middleware"
"nl-pms-api/internal/service"
)
// New 组装路由:
// - GET /healthz 健康检查公开view「测试连接」用
// - POST /api/v1/files 上传图片Bearer 密钥鉴权)
// - GET /api/v1/files 素材库列表scope=mine|team|all按角色限定范围
// - DELETE /api/v1/files/:id 删除素材(本人 / 超管 id=1 / 团队 owner|admin
// - GET /files/*filepath 文件公开访问img 标签无法带鉴权头;随机文件名不可枚举)
// New 组装路由:公开 healthz/authJWT 保护同步/设置/资料/团队/文件/后台。
// 若配置了 base_path如 /pms-api全部路由挂在该前缀下适配不剥路径的反代。
func New(cfg *config.Config, db *gorm.DB) *gin.Engine {
if cfg.Env != "dev" {
gin.SetMode(gin.ReleaseMode)
}
r := gin.New()
r.Use(gin.Logger(), gin.Recovery())
r.MaxMultipartMemory = 8 << 20
r.Use(gin.Logger(), gin.Recovery(), middleware.AttachClientIP())
r.MaxMultipartMemory = 32 << 20
r.GET("/healthz", func(c *gin.Context) {
if len(cfg.TrustedProxies) > 0 {
_ = r.SetTrustedProxies(cfg.TrustedProxies)
} else {
// 空切片=不信任任何反代ClientIP 用 RemoteAddr避免默认信任全部
_ = r.SetTrustedProxies([]string{})
}
root := r.Group(cfg.BasePath)
root.GET("/healthz", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true, "service": "nl-pms-api"})
})
h := &handler.FileHandler{DB: db, Cfg: cfg}
api := r.Group("/api/v1", middleware.RequireAPIKey(cfg.APIKey))
api.POST("/files", h.Upload)
api.GET("/files", h.List)
api.DELETE("/files/:id", h.Delete)
authSvc := &service.AuthService{DB: db, Cfg: cfg}
authC := &controller.AuthController{Svc: authSvc}
syncC := &controller.SyncController{Svc: &service.SyncService{DB: db}}
settingsC := &controller.SettingsController{Svc: &service.SettingsService{DB: db}}
profileC := &controller.ProfileController{
Svc: &service.ProfileService{DB: db},
AvatarHist: &service.AvatarHistoryService{DB: db},
}
noticeC := &controller.NoticeController{Svc: &service.NoticeService{DB: db}}
fileC := &controller.FileController{Svc: &service.FileService{DB: db, Cfg: cfg}}
teamC := &controller.TeamController{Svc: &service.TeamService{DB: db}}
files := r.Group("/files", func(c *gin.Context) {
// 文件名含随机 hex内容不可变允许长缓存。
adminSvc := &service.AdminService{DB: db}
secSvc := &service.AdminSecurityService{DB: db}
relSvc := &service.ReleaseService{DB: db, Cfg: cfg}
actSvc := &service.ActivityService{DB: db}
adminC := &controller.AdminController{Admin: adminSvc, Sec: secSvc, Rel: relSvc, Act: actSvc}
actC := &controller.ActivityController{Svc: actSvc}
aiC := &controller.AIController{Admin: adminSvc}
appC := &controller.AppController{Rel: relSvc}
pub := root.Group("/api/v1")
pub.POST("/auth/register", authC.Register)
pub.POST("/auth/login", authC.Login)
pub.POST("/auth/refresh", authC.Refresh)
api := root.Group("/api/v1", middleware.RequireJWT(cfg.JWTSecret), middleware.RejectDisabled(db))
api.POST("/auth/change-password", authC.ChangePassword)
api.POST("/sync/push", syncC.Push)
api.GET("/sync/pull", syncC.Pull)
api.GET("/settings", settingsC.Get) // ?prefix=fest_img:
api.GET("/settings/global/:name", settingsC.GetGlobal)
api.GET("/settings/:name", settingsC.Get)
api.PUT("/settings/:name", middleware.RequireStepUpForGlobalSetting(db), settingsC.Put)
api.GET("/profile", profileC.Get)
api.PUT("/profile", profileC.Put)
api.GET("/profile/avatars", profileC.ListAvatars)
api.POST("/profile/avatars", profileC.PushAvatar)
api.DELETE("/profile/avatars/:id", profileC.DeleteAvatar)
api.GET("/notices", noticeC.List)
api.POST("/teams", teamC.Create)
api.GET("/teams", teamC.List)
api.PUT("/teams/:id", teamC.Rename)
api.PUT("/teams/:id/digest-time", teamC.SetDigestTime)
api.DELETE("/teams/:id", teamC.Dissolve)
api.POST("/teams/:id/leave", teamC.Leave)
api.GET("/teams/:id/members", teamC.Members)
api.POST("/teams/:id/invite", teamC.Invite)
api.PUT("/teams/:id/members/:userId/role", teamC.SetRole)
api.DELETE("/teams/:id/members/:userId", teamC.RemoveMember)
api.POST("/teams/:id/tasks", teamC.TaskSave)
api.PUT("/teams/:id/tasks/:taskId", teamC.TaskSave)
api.PUT("/teams/:id/tasks/:taskId/status", teamC.TaskSetStatus)
api.POST("/teams/:id/tasks/:taskId/urge", teamC.TaskUrge)
api.DELETE("/teams/:id/tasks/:taskId", teamC.TaskDelete)
api.GET("/teams/:id/tasks", teamC.TaskList)
api.GET("/teams/:id/shared", teamC.SharedItems)
api.POST("/teams/:id/shared/urge", teamC.UrgeShared)
api.POST("/teams/:id/reports", teamC.ReportSubmit)
api.GET("/teams/:id/reports/:date", teamC.ReportBoardGet)
api.POST("/teams/:id/reports/urge", teamC.ReportUrge)
api.PUT("/teams/:id/digests/:date", teamC.DigestSave)
api.POST("/files", fileC.Upload)
api.GET("/files", fileC.List)
api.DELETE("/files/:id", fileC.Delete)
api.POST("/activity/ping", actC.Ping)
api.GET("/ai/policy", aiC.Policy)
api.POST("/ai/usage", aiC.Usage)
api.GET("/app/latest", appC.Latest)
api.GET("/app/download/:version", appC.Download)
admin := api.Group("/admin", middleware.RequireAdmin())
admin.GET("/totp/status", adminC.TOTPStatus)
admin.POST("/totp/setup", adminC.TOTPSetupBegin)
admin.POST("/totp/confirm", adminC.TOTPSetupConfirm)
admin.POST("/stepup", adminC.StepUp)
admin.GET("/stats/overview", adminC.Overview)
admin.GET("/users", adminC.ListUsers)
admin.GET("/teams", adminC.ListTeams)
admin.GET("/releases", adminC.ListReleases)
adminWrite := admin.Group("", middleware.RequireAdminStepUp(db))
adminWrite.PATCH("/users/:id", adminC.PatchUser)
adminWrite.PATCH("/teams/:id", adminC.PatchTeam)
adminWrite.POST("/releases", adminC.UploadRelease)
adminWrite.POST("/releases/:id/publish", adminC.PublishRelease)
files := root.Group("/files", func(c *gin.Context) {
c.Header("Cache-Control", "public, max-age=31536000, immutable")
})
files.Static("/", cfg.StorageDir)

View File

@@ -0,0 +1,44 @@
package service
import (
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// ActivityService 日活埋点。
type ActivityService struct {
DB *gorm.DB
}
// Ping 记录今日活跃并刷新 users.last_seen_at / last_login_ip。
func (s *ActivityService) Ping(userID int64, clientIP string) error {
if userID <= 0 {
return commonservice.Unauthorized("UNAUTHORIZED")
}
now := commonservice.NowRFC()
today := time.Now().In(time.Local).Format("2006-01-02")
ip := strings.TrimSpace(clientIP)
row := model.UserDailyActive{
UserID: userID,
ActiveDate: today,
LastIP: ip,
LastSeenAt: now,
}
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}, {Name: "active_date"}},
DoUpdates: clause.AssignmentColumns([]string{"last_ip", "last_seen_at"}),
}).Create(&row).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
_ = s.DB.Model(&model.User{}).Where("id = ?", userID).Updates(map[string]any{
"last_seen_at": now,
"last_login_ip": ip,
}).Error
return nil
}

306
internal/service/admin.go Normal file
View File

@@ -0,0 +1,306 @@
package service
import (
"strings"
"time"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// AdminService 运营后台:统计、用户、团队。
type AdminService struct {
DB *gorm.DB
}
type StatsPoint struct {
Date string `json:"date"`
Count int64 `json:"count"`
}
type TokenPoint struct {
Date string `json:"date"`
PromptTokens int64 `json:"promptTokens"`
CompletionTokens int64 `json:"completionTokens"`
Calls int64 `json:"calls"`
}
type OverviewStats struct {
UserCount int64 `json:"userCount"`
TeamCount int64 `json:"teamCount"`
DAUToday int64 `json:"dauToday"`
DAUSeries []StatsPoint `json:"dauSeries"`
TokenToday TokenPoint `json:"tokenToday"`
TokenSeries []TokenPoint `json:"tokenSeries"`
}
// Overview 概览统计。
func (s *AdminService) Overview(days int) (*OverviewStats, error) {
if days <= 0 || days > 90 {
days = 14
}
out := &OverviewStats{
DAUSeries: []StatsPoint{},
TokenSeries: []TokenPoint{},
}
s.DB.Model(&model.User{}).Count(&out.UserCount)
s.DB.Model(&model.Team{}).Count(&out.TeamCount)
today := time.Now().In(time.Local).Format("2006-01-02")
s.DB.Model(&model.UserDailyActive{}).Where("active_date = ?", today).Count(&out.DAUToday)
start := time.Now().In(time.Local).AddDate(0, 0, -(days - 1)).Format("2006-01-02")
type dauRow struct {
ActiveDate string
Cnt int64
}
var dauRows []dauRow
s.DB.Model(&model.UserDailyActive{}).
Select("active_date, COUNT(*) AS cnt").
Where("active_date >= ?", start).
Group("active_date").
Order("active_date").
Scan(&dauRows)
dauMap := map[string]int64{}
for _, r := range dauRows {
dauMap[r.ActiveDate] = r.Cnt
}
type tokRow struct {
UsageDate string
PromptTokens int64
CompletionTokens int64
Calls int64
}
var tokRows []tokRow
s.DB.Model(&model.AIUsageDaily{}).
Select("usage_date, SUM(prompt_tokens) AS prompt_tokens, SUM(completion_tokens) AS completion_tokens, SUM(calls) AS calls").
Where("usage_date >= ?", start).
Group("usage_date").
Order("usage_date").
Scan(&tokRows)
tokMap := map[string]tokRow{}
for _, r := range tokRows {
tokMap[r.UsageDate] = r
}
for i := 0; i < days; i++ {
d := time.Now().In(time.Local).AddDate(0, 0, -(days - 1 - i)).Format("2006-01-02")
out.DAUSeries = append(out.DAUSeries, StatsPoint{Date: d, Count: dauMap[d]})
t := tokMap[d]
pt := TokenPoint{
Date: d,
PromptTokens: t.PromptTokens,
CompletionTokens: t.CompletionTokens,
Calls: t.Calls,
}
out.TokenSeries = append(out.TokenSeries, pt)
if d == today {
out.TokenToday = pt
}
}
return out, nil
}
type AdminUserItem struct {
ID int64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
AIBanned int `json:"aiBanned"`
Disabled int `json:"disabled"`
LastLoginIP string `json:"lastLoginIp"`
LastSeenAt string `json:"lastSeenAt"`
CreatedAt string `json:"createdAt"`
}
// ListUsers 用户列表。
func (s *AdminService) ListUsers() ([]AdminUserItem, error) {
var users []model.User
if err := s.DB.Order("id ASC").Find(&users).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
ids := make([]int64, 0, len(users))
for _, u := range users {
ids = append(ids, u.ID)
}
nick := map[int64]string{}
if len(ids) > 0 {
var profiles []model.UserProfile
s.DB.Where("user_id IN ?", ids).Find(&profiles)
for _, p := range profiles {
nick[p.UserID] = p.Nickname
}
}
out := make([]AdminUserItem, 0, len(users))
for _, u := range users {
out = append(out, AdminUserItem{
ID: u.ID,
Username: u.Username,
Nickname: nick[u.ID],
AIBanned: u.AIBanned,
Disabled: u.Disabled,
LastLoginIP: u.LastLoginIP,
LastSeenAt: u.LastSeenAt,
CreatedAt: u.CreatedAt,
})
}
return out, nil
}
// PatchUser 更新用户 ai_banned / disabled不可禁用自己
func (s *AdminService) PatchUser(actorID, targetID int64, aiBanned, disabled *int) error {
if actorID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
if targetID <= 0 {
return commonservice.BadRequest("BAD_REQUEST")
}
if targetID == commonservice.AdminUserID && disabled != nil && *disabled != 0 {
return commonservice.BadRequest("CANNOT_DISABLE_ADMIN")
}
updates := map[string]any{}
if aiBanned != nil {
if *aiBanned != 0 {
updates["ai_banned"] = 1
} else {
updates["ai_banned"] = 0
}
}
if disabled != nil {
if *disabled != 0 {
updates["disabled"] = 1
} else {
updates["disabled"] = 0
}
}
if len(updates) == 0 {
return commonservice.BadRequest("BAD_REQUEST")
}
res := s.DB.Model(&model.User{}).Where("id = ?", targetID).Updates(updates)
if res.Error != nil {
return commonservice.Internal("SAVE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.NotFound("NOT_FOUND")
}
return nil
}
type AdminTeamItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"ownerId"`
OwnerName string `json:"ownerName"`
AIBanned int `json:"aiBanned"`
Members int64 `json:"members"`
CreatedAt string `json:"createdAt"`
}
// ListTeams 团队列表。
func (s *AdminService) ListTeams() ([]AdminTeamItem, error) {
var teams []model.Team
if err := s.DB.Order("id ASC").Find(&teams).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out := make([]AdminTeamItem, 0, len(teams))
for _, t := range teams {
item := AdminTeamItem{
ID: t.ID, Name: t.Name, OwnerID: t.OwnerID,
AIBanned: t.AIBanned, CreatedAt: t.CreatedAt,
}
var u model.User
if s.DB.Select("username").First(&u, t.OwnerID).Error == nil {
item.OwnerName = u.Username
}
s.DB.Model(&model.TeamMember{}).Where("team_id = ?", t.ID).Count(&item.Members)
out = append(out, item)
}
return out, nil
}
// PatchTeam 更新团队 ai_banned。
func (s *AdminService) PatchTeam(actorID, teamID int64, aiBanned *int) error {
if actorID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
if teamID <= 0 || aiBanned == nil {
return commonservice.BadRequest("BAD_REQUEST")
}
v := 0
if *aiBanned != 0 {
v = 1
}
res := s.DB.Model(&model.Team{}).Where("id = ?", teamID).Update("ai_banned", v)
if res.Error != nil {
return commonservice.Internal("SAVE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.NotFound("NOT_FOUND")
}
return nil
}
// AIPolicy 当前用户是否允许使用 AI。
type AIPolicy struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason,omitempty"`
}
// GetAIPolicy 综合用户禁用/禁 AI 与所属团队禁 AI。
func (s *AdminService) GetAIPolicy(userID int64) (*AIPolicy, error) {
var u model.User
if err := s.DB.Select("id", "disabled", "ai_banned").First(&u, userID).Error; err != nil {
return nil, commonservice.Unauthorized("UNAUTHORIZED")
}
if u.Disabled != 0 {
return &AIPolicy{Allowed: false, Reason: "ACCOUNT_DISABLED"}, nil
}
if u.AIBanned != 0 {
return &AIPolicy{Allowed: false, Reason: "USER_AI_BANNED"}, nil
}
var n int64
s.DB.Table("team_members").
Joins("JOIN teams ON teams.id = team_members.team_id").
Where("team_members.user_id = ? AND teams.ai_banned = 1", userID).
Count(&n)
if n > 0 {
return &AIPolicy{Allowed: false, Reason: "TEAM_AI_BANNED"}, nil
}
return &AIPolicy{Allowed: true}, nil
}
// ReportAIUsage 累加当日 token 用量。
func (s *AdminService) ReportAIUsage(userID, teamID int64, provider string, prompt, completion int64, estimated bool) error {
if userID <= 0 {
return commonservice.Unauthorized("UNAUTHORIZED")
}
provider = strings.TrimSpace(provider)
if provider == "" {
provider = "unknown"
}
if prompt < 0 {
prompt = 0
}
if completion < 0 {
completion = 0
}
today := time.Now().In(time.Local).Format("2006-01-02")
est := 0
if estimated {
est = 1
}
q := `INSERT INTO ai_usage_daily(user_id,team_id,usage_date,provider,prompt_tokens,completion_tokens,calls,estimated)
VALUES(?,?,?,?,?,?,1,?)
ON DUPLICATE KEY UPDATE
prompt_tokens=prompt_tokens+VALUES(prompt_tokens),
completion_tokens=completion_tokens+VALUES(completion_tokens),
calls=calls+1,
estimated=IF(VALUES(estimated)=1,1,estimated)`
if err := s.DB.Exec(q, userID, teamID, today, provider, prompt, completion, est).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
return nil
}

View File

@@ -0,0 +1,174 @@
package service
import (
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"net/url"
"strings"
"time"
"github.com/pquerna/otp/totp"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
const (
adminStepupTTL = 2 * time.Hour
totpIssuer = "年糕崽崽PMS"
totpPendingPrefix = "pending:"
)
// AdminSecurityService 管理员 TOTP 与敏感操作 stepup。
type AdminSecurityService struct {
DB *gorm.DB
}
type TOTPStatus struct {
Enabled bool `json:"enabled"`
Pending bool `json:"pending"`
OTPAuth string `json:"otpauth,omitempty"`
Secret string `json:"secret,omitempty"`
}
// Status 返回 TOTP 绑定状态;若未启用且无 pending不自动生成。
func (s *AdminSecurityService) Status(userID int64) (*TOTPStatus, error) {
if userID != commonservice.AdminUserID {
return nil, commonservice.Forbidden("FORBIDDEN")
}
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
st := &TOTPStatus{Enabled: u.TOTPEnabled != 0}
if !st.Enabled && strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
secret := strings.TrimPrefix(u.TOTPSecret, totpPendingPrefix)
st.Pending = true
st.Secret = secret
st.OTPAuth = buildOTPAuth(u.Username, secret)
}
return st, nil
}
// SetupBegin 生成待确认的 TOTP 密钥(覆盖未确认的 pending
func (s *AdminSecurityService) SetupBegin(userID int64) (*TOTPStatus, error) {
if userID != commonservice.AdminUserID {
return nil, commonservice.Forbidden("FORBIDDEN")
}
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if u.TOTPEnabled != 0 {
return nil, commonservice.Conflict("TOTP_ALREADY_ENABLED")
}
key, err := totp.Generate(totp.GenerateOpts{
Issuer: totpIssuer,
AccountName: u.Username,
})
if err != nil {
return nil, commonservice.Internal("INTERNAL")
}
secret := key.Secret()
if err := s.DB.Model(&u).Update("totp_secret", totpPendingPrefix+secret).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
return &TOTPStatus{
Enabled: false,
Pending: true,
Secret: secret,
OTPAuth: key.URL(),
}, nil
}
// SetupConfirm 用动态码确认绑定。
func (s *AdminSecurityService) SetupConfirm(userID int64, code string) error {
if userID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
code = strings.TrimSpace(code)
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return commonservice.Internal("QUERY_FAILED")
}
if u.TOTPEnabled != 0 {
return commonservice.Conflict("TOTP_ALREADY_ENABLED")
}
if !strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
return commonservice.BadRequest("TOTP_SETUP_REQUIRED")
}
secret := strings.TrimPrefix(u.TOTPSecret, totpPendingPrefix)
if !totp.Validate(code, secret) {
return commonservice.BadRequest("TOTP_INVALID")
}
return s.DB.Model(&u).Updates(map[string]any{
"totp_secret": secret,
"totp_enabled": 1,
}).Error
}
// StepUp 校验动态码并签发 2h stepup token绑定 IP
func (s *AdminSecurityService) StepUp(userID int64, code, clientIP string) (string, time.Time, error) {
if userID != commonservice.AdminUserID {
return "", time.Time{}, commonservice.Forbidden("FORBIDDEN")
}
code = strings.TrimSpace(code)
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return "", time.Time{}, commonservice.Internal("QUERY_FAILED")
}
if u.TOTPEnabled == 0 || u.TOTPSecret == "" || strings.HasPrefix(u.TOTPSecret, totpPendingPrefix) {
return "", time.Time{}, commonservice.BadRequest("TOTP_NOT_ENABLED")
}
if !totp.Validate(code, u.TOTPSecret) {
return "", time.Time{}, commonservice.BadRequest("TOTP_INVALID")
}
raw := make([]byte, 32)
if _, err := rand.Read(raw); err != nil {
return "", time.Time{}, commonservice.Internal("INTERNAL")
}
token := hex.EncodeToString(raw)
sum := sha256.Sum256([]byte(token))
expires := time.Now().UTC().Add(adminStepupTTL)
row := model.AdminStepup{
UserID: userID,
TokenHash: hex.EncodeToString(sum[:]),
ClientIP: strings.TrimSpace(clientIP),
ExpiresAt: expires.Format(time.RFC3339),
}
if err := s.DB.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "user_id"}},
DoUpdates: clause.AssignmentColumns([]string{"token_hash", "client_ip", "expires_at"}),
}).Create(&row).Error; err != nil {
return "", time.Time{}, commonservice.Internal("SAVE_FAILED")
}
// 更新管理员最近登录 IP便于审计
_ = s.DB.Model(&u).Updates(map[string]any{
"last_login_ip": row.ClientIP,
"last_seen_at": commonservice.NowRFC(),
}).Error
return token, expires, nil
}
// ClearStepUp 作废二次验证。
func (s *AdminSecurityService) ClearStepUp(userID int64) error {
if userID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
return s.DB.Delete(&model.AdminStepup{}, userID).Error
}
func buildOTPAuth(account, secret string) string {
// otpauth URLissuer/account 需编码
label := url.PathEscape(totpIssuer) + ":" + url.PathEscape(account)
q := url.Values{}
q.Set("secret", secret)
q.Set("issuer", totpIssuer)
q.Set("algorithm", "SHA1")
q.Set("digits", "6")
q.Set("period", "30")
return "otpauth://totp/" + label + "?" + q.Encode()
}

142
internal/service/auth.go Normal file
View File

@@ -0,0 +1,142 @@
package service
import (
"strings"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
)
// AuthService 注册 / 登录 / 刷新 / 改密。
type AuthService struct {
DB *gorm.DB
Cfg *config.Config
}
// AuthTokens 登录/刷新返回的令牌与用户信息。
type AuthTokens struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
UserID int64 `json:"userId"`
Username string `json:"username"`
}
func validAccount(username, password string) error {
username = strings.TrimSpace(username)
if len(username) < 3 || len(username) > 64 {
return commonservice.BadRequest("SYNC_USERNAME_INVALID")
}
if len(password) < 6 {
return commonservice.BadRequest("SYNC_PASSWORD_TOO_SHORT")
}
return nil
}
// Register 注册新账号。
func (s *AuthService) Register(username, password string) error {
if err := validAccount(username, password); err != nil {
return err
}
username = strings.TrimSpace(username)
var n int64
s.DB.Model(&model.User{}).Where("username = ?", username).Count(&n)
if n > 0 {
return commonservice.Conflict("SYNC_USER_EXISTS")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return commonservice.Internal("INTERNAL")
}
u := model.User{
Username: username,
PasswordHash: string(hash),
CreatedAt: commonservice.NowRFC(),
}
if err := s.DB.Create(&u).Error; err != nil {
// 并发注册由唯一索引兜底。
return commonservice.Conflict("SYNC_USER_EXISTS")
}
return nil
}
// Login 校验账号并签发双令牌。
func (s *AuthService) Login(username, password, clientIP string) (*AuthTokens, error) {
username = strings.TrimSpace(username)
var u model.User
if err := s.DB.Where("username = ?", username).First(&u).Error; err != nil {
return nil, commonservice.Unauthorized("SYNC_BAD_CREDENTIALS")
}
if u.Disabled != 0 {
return nil, commonservice.Forbidden("ACCOUNT_DISABLED")
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(password)) != nil {
return nil, commonservice.Unauthorized("SYNC_BAD_CREDENTIALS")
}
tok, err := s.issueTokens(u.ID, u.Username)
if err != nil {
return nil, err
}
_ = (&ActivityService{DB: s.DB}).Ping(u.ID, clientIP)
return tok, nil
}
// Refresh 用 refresh token 换新的双令牌。
func (s *AuthService) Refresh(refreshToken, clientIP string) (*AuthTokens, error) {
claims, err := commonservice.Parse(s.Cfg.JWTSecret, strings.TrimSpace(refreshToken), "refresh")
if err != nil {
return nil, err
}
var u model.User
if err := s.DB.First(&u, claims.UserID).Error; err != nil {
return nil, commonservice.Unauthorized("UNAUTHORIZED")
}
if u.Disabled != 0 {
return nil, commonservice.Forbidden("ACCOUNT_DISABLED")
}
tok, err := s.issueTokens(u.ID, u.Username)
if err != nil {
return nil, err
}
_ = (&ActivityService{DB: s.DB}).Ping(u.ID, clientIP)
return tok, nil
}
// ChangePassword 在线修改密码。
func (s *AuthService) ChangePassword(userID int64, oldPassword, newPassword string) error {
if len(newPassword) < 6 {
return commonservice.BadRequest("SYNC_PASSWORD_TOO_SHORT")
}
var u model.User
if err := s.DB.First(&u, userID).Error; err != nil {
return commonservice.Unauthorized("UNAUTHORIZED")
}
if bcrypt.CompareHashAndPassword([]byte(u.PasswordHash), []byte(oldPassword)) != nil {
return commonservice.BadRequest("SYNC_OLD_PASSWORD_WRONG")
}
hash, err := bcrypt.GenerateFromPassword([]byte(newPassword), bcrypt.DefaultCost)
if err != nil {
return commonservice.Internal("INTERNAL")
}
return s.DB.Model(&u).Update("password_hash", string(hash)).Error
}
func (s *AuthService) issueTokens(userID int64, username string) (*AuthTokens, error) {
access, err := commonservice.IssueAccess(s.Cfg.JWTSecret, userID, username, s.Cfg.AccessTTLHours)
if err != nil {
return nil, commonservice.Internal("INTERNAL")
}
refresh, err := commonservice.IssueRefresh(s.Cfg.JWTSecret, userID, username, s.Cfg.RefreshTTLDays)
if err != nil {
return nil, commonservice.Internal("INTERNAL")
}
return &AuthTokens{
AccessToken: access,
RefreshToken: refresh,
UserID: userID,
Username: username,
}, nil
}

View File

@@ -0,0 +1,107 @@
package service
import (
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
const avatarHistoryLimit = 12
// AvatarHistoryService 用户头像历史(与账号关联,多端可回选)。
type AvatarHistoryService struct {
DB *gorm.DB
}
// AvatarHistoryItem 对外一条历史。
type AvatarHistoryItem struct {
ID int64 `json:"id"`
Mode string `json:"mode"`
Value string `json:"value"`
CreatedAt string `json:"createdAt"`
}
// List 按时间倒序返回本人历史(最多 avatarHistoryLimit 条)。
func (s *AvatarHistoryService) List(userID int64) ([]AvatarHistoryItem, error) {
var rows []model.UserAvatarHistory
if err := s.DB.Where("user_id = ?", userID).Order("id DESC").Limit(avatarHistoryLimit).Find(&rows).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out := make([]AvatarHistoryItem, 0, len(rows))
for _, r := range rows {
out = append(out, AvatarHistoryItem{ID: r.ID, Mode: r.Mode, Value: r.Value, CreatedAt: r.CreatedAt})
}
return out, nil
}
// Push 追加一条;同 value 去重后置顶;超出上限删最旧。
func (s *AvatarHistoryService) Push(userID int64, mode, value string) ([]AvatarHistoryItem, error) {
value = strings.TrimSpace(value)
if value == "" {
return nil, commonservice.BadRequest("AVATAR_VALUE_REQUIRED")
}
mode = strings.TrimSpace(strings.ToLower(mode))
if mode != "url" && mode != "base64" && mode != "path" {
if strings.HasPrefix(value, "http://") || strings.HasPrefix(value, "https://") {
mode = "url"
} else {
mode = "base64"
}
}
// path 仅本机有效,不入库线上历史
if mode == "path" {
return s.List(userID)
}
// 防爆:单条过大拒绝(约 1.5MB 文本)
if len(value) > 1_500_000 {
return nil, commonservice.BadRequest("AVATAR_VALUE_TOO_LARGE")
}
now := commonservice.NowRFC()
tx := s.DB.Begin()
if tx.Error != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
defer tx.Rollback()
if err := tx.Where("user_id = ? AND value = ?", userID, value).Delete(&model.UserAvatarHistory{}).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
row := model.UserAvatarHistory{UserID: userID, Mode: mode, Value: value, CreatedAt: now}
if err := tx.Create(&row).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
// 超出上限:删掉最旧的多余行
var ids []int64
if err := tx.Model(&model.UserAvatarHistory{}).Where("user_id = ?", userID).
Order("id DESC").Limit(500).Pluck("id", &ids).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
if len(ids) > avatarHistoryLimit {
drop := ids[avatarHistoryLimit:]
if err := tx.Where("user_id = ? AND id IN ?", userID, drop).Delete(&model.UserAvatarHistory{}).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
}
if err := tx.Commit().Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
return s.List(userID)
}
// Delete 删除本人一条历史。
func (s *AvatarHistoryService) Delete(userID, id int64) error {
if id <= 0 {
return commonservice.BadRequest("ID_REQUIRED")
}
res := s.DB.Where("user_id = ? AND id = ?", userID, id).Delete(&model.UserAvatarHistory{})
if res.Error != nil {
return commonservice.Internal("DELETE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.NotFound("NOT_FOUND")
}
return nil
}

173
internal/service/file.go Normal file
View File

@@ -0,0 +1,173 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"io"
"net/http"
"os"
"path/filepath"
"time"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
)
// FileService 图片上传与素材库管理。
type FileService struct {
DB *gorm.DB
Cfg *config.Config
}
var extByMime = map[string]string{
"image/jpeg": ".jpg",
"image/png": ".png",
"image/gif": ".gif",
"image/webp": ".webp",
}
// UploadResult 上传成功响应。
type UploadResult struct {
ID int64 `json:"id"`
Name string `json:"name"`
URL string `json:"url"`
Size int64 `json:"size"`
Mime string `json:"mime"`
TeamID int64 `json:"teamId"`
}
// FileItem 素材库列表项。
type FileItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Original string `json:"original"`
Mime string `json:"mime"`
Size int64 `json:"size"`
UserID int64 `json:"userId"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"`
CreatedAt string `json:"createdAt"`
Username string `json:"username"`
URL string `json:"url" gorm:"-"`
}
// Upload 保存图片userID 来自 JWTteamID/kind 来自表单。
func (s *FileService) Upload(userID, teamID int64, kind, original string, r io.Reader, sizeHint int64, requestHost string) (*UploadResult, error) {
if sizeHint > s.Cfg.MaxUploadBytes() {
return nil, &commonservice.AppError{Code: "FILE_TOO_LARGE", Status: http.StatusRequestEntityTooLarge}
}
data, err := io.ReadAll(io.LimitReader(r, s.Cfg.MaxUploadBytes()+1))
if err != nil || int64(len(data)) > s.Cfg.MaxUploadBytes() {
return nil, &commonservice.AppError{Code: "FILE_TOO_LARGE", Status: http.StatusRequestEntityTooLarge}
}
mime := http.DetectContentType(data)
ext, ok := extByMime[mime]
if !ok {
return nil, &commonservice.AppError{Code: "UNSUPPORTED_TYPE", Status: http.StatusUnsupportedMediaType}
}
sum := hex.EncodeToString(func() []byte { h := sha256.Sum256(data); return h[:] }())
var rec model.File
if s.DB.Where("sha256 = ? AND user_id = ? AND team_id = ?", sum, userID, teamID).First(&rec).Error == nil {
return s.toUploadResult(rec, requestHost), nil
}
name := commonservice.StoredName(ext)
full := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
if err := os.WriteFile(full, data, 0644); err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
rec = model.File{
Name: name,
Original: commonservice.Clip(filepath.Base(original), 255),
Mime: mime,
Size: int64(len(data)),
SHA256: sum,
UserID: userID,
TeamID: teamID,
Kind: commonservice.NormalizeKind(kind),
CreatedAt: time.Now().UTC().Format(time.RFC3339),
}
if err := s.DB.Create(&rec).Error; err != nil {
_ = os.Remove(full)
return nil, commonservice.Internal("SAVE_FAILED")
}
return s.toUploadResult(rec, requestHost), nil
}
func (s *FileService) toUploadResult(f model.File, host string) *UploadResult {
return &UploadResult{
ID: f.ID, Name: f.Name, URL: commonservice.PublicURL(s.Cfg, host, f.Name),
Size: f.Size, Mime: f.Mime, TeamID: f.TeamID,
}
}
// List 素材库分页scope=mine|team|all。
func (s *FileService) List(userID int64, scope string, teamID, page, size int64, requestHost string) (int64, []FileItem, error) {
if userID <= 0 {
return 0, nil, commonservice.BadRequest("USER_REQUIRED")
}
var where func(*gorm.DB) *gorm.DB
switch scope {
case "mine":
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.user_id = ?", userID) }
case "team":
if !commonservice.IsTeamAdmin(s.DB, teamID, userID) {
return 0, nil, commonservice.Forbidden("FORBIDDEN")
}
where = func(db *gorm.DB) *gorm.DB { return db.Where("pms_files.team_id = ?", teamID) }
case "all":
if userID != commonservice.AdminUserID {
return 0, nil, commonservice.Forbidden("FORBIDDEN")
}
where = func(db *gorm.DB) *gorm.DB { return db }
default:
return 0, nil, commonservice.BadRequest("BAD_SCOPE")
}
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 24
}
var total int64
if err := s.DB.Table("pms_files").Scopes(where).Count(&total).Error; err != nil {
return 0, nil, commonservice.Internal("QUERY_FAILED")
}
items := []FileItem{}
err := s.DB.Table("pms_files").Scopes(where).
Select("pms_files.id, pms_files.name, pms_files.original, pms_files.mime, pms_files.size, pms_files.user_id, pms_files.team_id, pms_files.kind, pms_files.created_at, COALESCE(u.username,'') AS username").
Joins("LEFT JOIN users u ON u.id = pms_files.user_id").
Order("pms_files.id DESC").Limit(int(size)).Offset(int((page - 1) * size)).
Scan(&items).Error
if err != nil {
return 0, nil, commonservice.Internal("QUERY_FAILED")
}
for i := range items {
items[i].URL = commonservice.PublicURL(s.Cfg, requestHost, items[i].Name)
}
return total, items, nil
}
// Delete 删除记录与磁盘文件。
func (s *FileService) Delete(userID, fileID int64) error {
var rec model.File
if s.DB.First(&rec, fileID).Error != nil {
return commonservice.NotFound("NOT_FOUND")
}
allowed := userID == commonservice.AdminUserID ||
(userID > 0 && rec.UserID == userID) ||
(rec.TeamID > 0 && commonservice.IsTeamAdmin(s.DB, rec.TeamID, userID))
if !allowed {
return commonservice.Forbidden("FORBIDDEN")
}
if err := s.DB.Delete(&model.File{}, fileID).Error; err != nil {
return commonservice.Internal("DELETE_FAILED")
}
_ = os.Remove(filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(rec.Name)))
return nil
}

View File

@@ -0,0 +1,30 @@
package service
import (
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// NoticeService 团队通知增量拉取。
type NoticeService struct {
DB *gorm.DB
}
// ListNotices 拉取 to_user=本人且 id>afterID 的通知,按 id 升序,最多 200 条。
func (s *NoticeService) ListNotices(userID, afterID int64) ([]model.TeamNotice, error) {
if afterID < 0 {
afterID = 0
}
var rows []model.TeamNotice
err := s.DB.Where("to_user = ? AND id > ?", userID, afterID).
Order("id ASC").Limit(200).Find(&rows).Error
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if rows == nil {
rows = []model.TeamNotice{}
}
return rows, nil
}

113
internal/service/profile.go Normal file
View File

@@ -0,0 +1,113 @@
package service
import (
"encoding/json"
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// ProfileService 用户公开资料。
type ProfileService struct {
DB *gorm.DB
}
// ProfileDTO 对外资料techTags 为数组)。
type ProfileDTO struct {
Nickname string `json:"nickname"`
Title string `json:"title"`
Email string `json:"email"`
Bio string `json:"bio"`
TechTags []string `json:"techTags"`
AvatarThumb string `json:"avatarThumb"`
UpdatedAt string `json:"updatedAt"`
}
func sanitizeProfile(p *ProfileDTO) error {
p.Nickname = commonservice.ClipRunes(p.Nickname, 32)
p.Title = commonservice.ClipRunes(p.Title, 48)
p.Email = commonservice.ClipRunes(p.Email, 128)
p.Bio = commonservice.ClipRunes(p.Bio, 300)
if p.Email != "" && (!strings.Contains(p.Email, "@") || strings.ContainsAny(p.Email, " \t")) {
return commonservice.BadRequest("PROFILE_EMAIL_INVALID")
}
tags, seen := []string{}, map[string]bool{}
for _, t := range p.TechTags {
t = commonservice.ClipRunes(t, 24)
if t == "" || seen[strings.ToLower(t)] {
continue
}
seen[strings.ToLower(t)] = true
tags = append(tags, t)
if len(tags) >= 20 {
break
}
}
p.TechTags = tags
return nil
}
// Get 读取本人资料;无记录返回空结构。
func (s *ProfileService) Get(userID int64) (*ProfileDTO, error) {
var row model.UserProfile
err := s.DB.Where("user_id = ?", userID).First(&row).Error
if err == gorm.ErrRecordNotFound {
return &ProfileDTO{TechTags: []string{}}, nil
}
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
return &ProfileDTO{
Nickname: row.Nickname,
Title: row.Title,
Email: row.Email,
Bio: row.Bio,
TechTags: parseTechTags(row.TechTags),
AvatarThumb: row.AvatarThumb,
UpdatedAt: row.UpdatedAt,
}, nil
}
// Put LWW 写入资料:仅当传入 updatedAt 更新时覆盖。
func (s *ProfileService) Put(userID int64, p ProfileDTO) (*ProfileDTO, error) {
if err := sanitizeProfile(&p); err != nil {
return nil, err
}
if p.UpdatedAt == "" {
p.UpdatedAt = commonservice.NowRFC()
}
tags, _ := json.Marshal(p.TechTags)
var remote model.UserProfile
err := s.DB.Where("user_id = ?", userID).First(&remote).Error
if err == nil && remote.UpdatedAt >= p.UpdatedAt {
// 远端相同或更新:返回远端,不覆盖。
return &ProfileDTO{
Nickname: remote.Nickname,
Title: remote.Title,
Email: remote.Email,
Bio: remote.Bio,
TechTags: parseTechTags(remote.TechTags),
AvatarThumb: remote.AvatarThumb,
UpdatedAt: remote.UpdatedAt,
}, nil
}
q := `INSERT INTO user_profiles(user_id,nickname,title,email,bio,tech_tags,avatar_thumb,updated_at)
VALUES(?,?,?,?,?,?,?,?)
ON DUPLICATE KEY UPDATE nickname=VALUES(nickname),title=VALUES(title),email=VALUES(email),bio=VALUES(bio),
tech_tags=VALUES(tech_tags),avatar_thumb=VALUES(avatar_thumb),updated_at=VALUES(updated_at)`
if err := s.DB.Exec(q, userID, p.Nickname, p.Title, p.Email, p.Bio, string(tags), p.AvatarThumb, p.UpdatedAt).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
return &p, nil
}
func parseTechTags(s string) []string {
out := []string{}
if json.Unmarshal([]byte(s), &out) != nil {
return []string{}
}
return out
}

178
internal/service/release.go Normal file
View File

@@ -0,0 +1,178 @@
package service
import (
"crypto/sha256"
"encoding/hex"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/config"
"nl-pms-api/internal/model"
)
var versionRe = regexp.MustCompile(`^\d+\.\d+\.\d+([-.][A-Za-z0-9.]+)?$`)
// ReleaseService 客户端发版。
type ReleaseService struct {
DB *gorm.DB
Cfg *config.Config
}
type LatestRelease struct {
Version string `json:"version"`
Channel string `json:"channel"`
SHA256 string `json:"sha256"`
SizeBytes int64 `json:"sizeBytes"`
Changelog string `json:"changelog"`
CreatedAt string `json:"createdAt"`
}
// List 发版列表(新→旧)。
func (s *ReleaseService) List(channel string) ([]model.AppRelease, error) {
channel = normalizeChannel(channel)
var rows []model.AppRelease
q := s.DB.Order("id DESC").Limit(50)
if channel != "" {
q = q.Where("channel = ?", channel)
}
if err := q.Find(&rows).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if rows == nil {
rows = []model.AppRelease{}
}
return rows, nil
}
// Latest 当前最新版元数据。
func (s *ReleaseService) Latest(channel string) (*LatestRelease, error) {
channel = normalizeChannel(channel)
var row model.AppRelease
err := s.DB.Where("channel = ? AND is_latest = 1", channel).Order("id DESC").First(&row).Error
if err == gorm.ErrRecordNotFound {
return nil, commonservice.NotFound("NO_RELEASE")
}
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
return &LatestRelease{
Version: row.Version,
Channel: row.Channel,
SHA256: row.SHA256,
SizeBytes: row.SizeBytes,
Changelog: row.Changelog,
CreatedAt: row.CreatedAt,
}, nil
}
// Upload 保存安装包并写入元数据(默认不标 latest需 Publish
func (s *ReleaseService) Upload(version, channel, changelog string, r io.Reader, size int64) (*model.AppRelease, error) {
version = strings.TrimSpace(version)
channel = normalizeChannel(channel)
changelog = strings.TrimSpace(changelog)
if !versionRe.MatchString(version) {
return nil, commonservice.BadRequest("VERSION_INVALID")
}
if size <= 0 {
return nil, commonservice.BadRequest("EMPTY_FILE")
}
if size > s.Cfg.MaxReleaseBytes() {
return nil, commonservice.BadRequest("FILE_TOO_LARGE")
}
var n int64
s.DB.Model(&model.AppRelease{}).Where("version = ? AND channel = ?", version, channel).Count(&n)
if n > 0 {
return nil, commonservice.Conflict("VERSION_EXISTS")
}
dir := filepath.Join(s.Cfg.StorageDir, "releases", channel)
if err := os.MkdirAll(dir, 0755); err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
relName := filepath.ToSlash(filepath.Join("releases", channel, version+"-installer.exe"))
abs := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(relName))
f, err := os.Create(abs)
if err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
defer f.Close()
h := sha256.New()
written, err := io.Copy(io.MultiWriter(f, h), io.LimitReader(r, s.Cfg.MaxReleaseBytes()+1))
if err != nil {
_ = os.Remove(abs)
return nil, commonservice.Internal("SAVE_FAILED")
}
if written > s.Cfg.MaxReleaseBytes() {
_ = os.Remove(abs)
return nil, commonservice.BadRequest("FILE_TOO_LARGE")
}
sum := hex.EncodeToString(h.Sum(nil))
row := model.AppRelease{
Version: version,
Channel: channel,
Filename: relName,
SHA256: sum,
SizeBytes: written,
Changelog: changelog,
CreatedAt: commonservice.NowRFC(),
IsLatest: 0,
}
if err := s.DB.Create(&row).Error; err != nil {
_ = os.Remove(abs)
return nil, commonservice.Internal("SAVE_FAILED")
}
return &row, nil
}
// Publish 将指定发版标为该渠道最新。
func (s *ReleaseService) Publish(id int64) (*model.AppRelease, error) {
var row model.AppRelease
if err := s.DB.First(&row, id).Error; err != nil {
return nil, commonservice.NotFound("NOT_FOUND")
}
tx := s.DB.Begin()
if err := tx.Model(&model.AppRelease{}).Where("channel = ?", row.Channel).Update("is_latest", 0).Error; err != nil {
tx.Rollback()
return nil, commonservice.Internal("SAVE_FAILED")
}
if err := tx.Model(&row).Update("is_latest", 1).Error; err != nil {
tx.Rollback()
return nil, commonservice.Internal("SAVE_FAILED")
}
if err := tx.Commit().Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
row.IsLatest = 1
return &row, nil
}
// OpenFile 打开发版文件供下载。
func (s *ReleaseService) OpenFile(version, channel string) (*model.AppRelease, *os.File, error) {
version = strings.TrimSpace(version)
channel = normalizeChannel(channel)
var row model.AppRelease
if err := s.DB.Where("version = ? AND channel = ?", version, channel).First(&row).Error; err != nil {
return nil, nil, commonservice.NotFound("NOT_FOUND")
}
abs := filepath.Join(s.Cfg.StorageDir, filepath.FromSlash(row.Filename))
f, err := os.Open(abs)
if err != nil {
return nil, nil, commonservice.NotFound("NOT_FOUND")
}
return &row, f, nil
}
func normalizeChannel(ch string) string {
ch = strings.TrimSpace(strings.ToLower(ch))
if ch == "" {
return "stable"
}
return ch
}

View File

@@ -0,0 +1,94 @@
package service
import (
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// SettingsService 用户设置与全局资源fest_img:* / file_storage
type SettingsService struct {
DB *gorm.DB
}
func isGlobalSetting(name string) bool {
return name == "file_storage" || strings.HasPrefix(name, "fest_img:")
}
func settingOwner(userID int64, name string) int64 {
if isGlobalSetting(name) {
return commonservice.AdminUserID
}
return userID
}
// GetSetting 读取单条设置;全局键从管理员账号读。
func (s *SettingsService) GetSetting(userID int64, name string) (*model.SyncSetting, error) {
name = strings.TrimSpace(name)
if name == "" {
return nil, commonservice.BadRequest("SETTING_NAME_REQUIRED")
}
owner := settingOwner(userID, name)
var row model.SyncSetting
if err := s.DB.Where("user_id = ? AND name = ?", owner, name).First(&row).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return nil, commonservice.NotFound("NOT_FOUND")
}
return nil, commonservice.Internal("QUERY_FAILED")
}
return &row, nil
}
// ListByPrefix 按前缀批量拉取(如 fest_img:),全局前缀从管理员账号读。
func (s *SettingsService) ListByPrefix(userID int64, prefix string) ([]model.SyncSetting, error) {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return nil, commonservice.BadRequest("SETTING_PREFIX_REQUIRED")
}
owner := userID
if strings.HasPrefix(prefix, "fest_img:") || prefix == "fest_img:" {
owner = commonservice.AdminUserID
}
var rows []model.SyncSetting
if err := s.DB.Where("user_id = ? AND name LIKE ?", owner, prefix+"%").
Order("name").Find(&rows).Error; err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
if rows == nil {
rows = []model.SyncSetting{}
}
return rows, nil
}
// GetGlobal 读取挂在管理员名下的全局设置。
func (s *SettingsService) GetGlobal(name string) (*model.SyncSetting, error) {
return s.GetSetting(commonservice.AdminUserID, name)
}
// PutSetting LWW 写入;全局键仅管理员可写,且落在 user_id=1。
func (s *SettingsService) PutSetting(userID int64, name, value, updatedAt string) error {
name = strings.TrimSpace(name)
if name == "" {
return commonservice.BadRequest("SETTING_NAME_REQUIRED")
}
if updatedAt == "" {
updatedAt = commonservice.NowRFC()
}
owner := userID
if isGlobalSetting(name) {
if userID != commonservice.AdminUserID {
return commonservice.Forbidden("FORBIDDEN")
}
owner = commonservice.AdminUserID
}
q := `INSERT INTO sync_settings(user_id,name,value,updated_at) VALUES(?,?,?,?)
ON DUPLICATE KEY UPDATE value=IF(VALUES(updated_at)>updated_at,VALUES(value),value),
updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)`
if err := s.DB.Exec(q, owner, name, value, updatedAt).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
return nil
}

132
internal/service/sync.go Normal file
View File

@@ -0,0 +1,132 @@
package service
import (
"fmt"
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
)
// SyncService 个人待办/工单/记事本的推拉LWW
type SyncService struct {
DB *gorm.DB
}
type syncTableMeta struct {
remote string
cols []string // 不含 user_id含 uuid … updated_at deleted
hasProject bool
hasTimes bool
}
var syncTables = map[string]syncTableMeta{
"todos": {
remote: "sync_todos",
hasProject: true,
hasTimes: true,
cols: []string{"uuid", "title", "content", "due_at", "priority", "status", "history", "team_id", "project_name", "created_at", "updated_at", "deleted"},
},
"tickets": {
remote: "sync_tickets",
hasProject: true,
hasTimes: true,
cols: []string{"uuid", "title", "description", "type", "start_at", "due_at", "status", "priority", "history", "team_id", "project_name", "created_at", "updated_at", "deleted"},
},
"notes": {
remote: "sync_notes",
cols: []string{"uuid", "content", "updated_at", "deleted"},
},
}
// Push 对指定表做 LWW upsertrow 中不含 user_id取自 JWT
func (s *SyncService) Push(table string, userID int64, rows []map[string]any) (int, error) {
meta, ok := syncTables[table]
if !ok {
return 0, commonservice.BadRequest("SYNC_BAD_TABLE")
}
if userID <= 0 {
return 0, commonservice.Unauthorized("UNAUTHORIZED")
}
remoteCols := append([]string{"user_id"}, meta.cols...)
set := make([]string, 0, len(meta.cols))
for _, c := range meta.cols {
if c == "uuid" || c == "updated_at" {
continue
}
set = append(set, fmt.Sprintf("%s=IF(VALUES(updated_at)>updated_at,VALUES(%s),%s)", c, c, c))
}
set = append(set, "updated_at=IF(VALUES(updated_at)>updated_at,VALUES(updated_at),updated_at)")
placeholders := strings.TrimRight(strings.Repeat("?,", len(remoteCols)), ",")
q := fmt.Sprintf("INSERT INTO %s(%s) VALUES(%s) ON DUPLICATE KEY UPDATE %s",
meta.remote, strings.Join(remoteCols, ","), placeholders, strings.Join(set, ","))
n := 0
for _, row := range rows {
uuid := commonservice.MapStr(row, "uuid")
if uuid == "" {
continue
}
args := make([]any, 0, len(remoteCols))
args = append(args, userID)
for _, c := range meta.cols {
switch c {
case "deleted", "team_id":
args = append(args, commonservice.MapInt64(row, c))
default:
args = append(args, commonservice.MapStr(row, c))
}
}
if err := s.DB.Exec(q, args...).Error; err != nil {
return n, commonservice.Internal("SYNC_PUSH_FAILED")
}
n++
}
return n, nil
}
// Pull 按 updated_at 游标增量拉取,最多 2000 条。
func (s *SyncService) Pull(table string, userID int64, cursor string) ([]map[string]any, error) {
meta, ok := syncTables[table]
if !ok {
return nil, commonservice.BadRequest("SYNC_BAD_TABLE")
}
if userID <= 0 {
return nil, commonservice.Unauthorized("UNAUTHORIZED")
}
q := fmt.Sprintf("SELECT %s FROM %s WHERE user_id=? AND updated_at>? ORDER BY updated_at LIMIT 2000",
strings.Join(meta.cols, ","), meta.remote)
rawRows, err := s.DB.Raw(q, userID, cursor).Rows()
if err != nil {
return nil, commonservice.Internal("SYNC_PULL_FAILED")
}
defer rawRows.Close()
out := []map[string]any{}
for rawRows.Next() {
vals := make([]any, len(meta.cols))
ptrs := make([]any, len(meta.cols))
for i := range vals {
ptrs[i] = &vals[i]
}
if err := rawRows.Scan(ptrs...); err != nil {
return nil, commonservice.Internal("SYNC_PULL_FAILED")
}
m := map[string]any{}
for i, c := range meta.cols {
m[c] = coerceSQL(vals[i])
}
out = append(out, m)
}
return out, nil
}
func coerceSQL(v any) any {
switch x := v.(type) {
case []byte:
return string(x)
default:
return x
}
}

684
internal/service/team.go Normal file
View File

@@ -0,0 +1,684 @@
package service
import (
"fmt"
"regexp"
"strings"
"gorm.io/gorm"
"nl-pms-api/internal/commonservice"
"nl-pms-api/internal/model"
)
// TeamService 团队协作:团队/成员/任务/共享/日报/摘要。
type TeamService struct {
DB *gorm.DB
}
var teamDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
const teamDisplayExpr = `COALESCE(NULLIF(p.nickname,''), u.username)`
// ---------- DTO ----------
type TeamInfo struct {
ID int64 `json:"id"`
Name string `json:"name"`
OwnerID int64 `json:"ownerId"`
DigestTime string `json:"digestTime"`
Role string `json:"role"`
Members int `json:"members"`
}
type TeamMemberDTO struct {
UserID int64 `json:"userId"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Title string `json:"title"`
Bio string `json:"bio"`
TechTags []string `json:"techTags"`
Avatar string `json:"avatar"`
Role string `json:"role"`
JoinedAt string `json:"joinedAt"`
}
type TeamTaskDTO struct {
ID int64 `json:"id"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"`
Title string `json:"title"`
Description string `json:"description"`
Priority string `json:"priority"`
Status string `json:"status"`
CreatorID int64 `json:"creatorId"`
Creator string `json:"creator"`
AssigneeID int64 `json:"assigneeId"`
Assignee string `json:"assignee"`
StartAt string `json:"startAt"`
DueAt string `json:"dueAt"`
UrgedAt string `json:"urgedAt"`
History string `json:"history"`
UpdatedAt string `json:"updatedAt"`
}
type TeamSharedItem struct {
Kind string `json:"kind"`
UUID string `json:"uuid"`
UserID int64 `json:"userId"`
Owner string `json:"owner"`
Title string `json:"title"`
Status string `json:"status"`
Priority string `json:"priority"`
DueAt string `json:"dueAt"`
UpdatedAt string `json:"updatedAt"`
}
type TeamReportDTO struct {
UserID int64 `json:"userId"`
User string `json:"user"`
Date string `json:"date"`
Content string `json:"content"`
SubmittedAt string `json:"submittedAt"`
}
type TeamDigestDTO struct {
Date string `json:"date"`
Content string `json:"content"`
Provider string `json:"provider"`
GeneratedAt string `json:"generatedAt"`
}
type TeamReportBoard struct {
Date string `json:"date"`
Role string `json:"role"`
Reports []TeamReportDTO `json:"reports"`
Missing []TeamMemberDTO `json:"missing"`
Digest *TeamDigestDTO `json:"digest"`
}
// ---------- helpers ----------
func (s *TeamService) teamNotice(teamID, toUser, fromUser int64, kind, refID, content string) {
if toUser <= 0 || toUser == fromUser {
return
}
_ = s.DB.Exec(`INSERT INTO team_notices(team_id,to_user,from_user,kind,ref_id,content,created_at) VALUES(?,?,?,?,?,?,?)`,
teamID, toUser, fromUser, kind, refID, content, commonservice.NowRFC())
}
func (s *TeamService) teamUserName(userID int64) string {
var name string
_ = s.DB.Raw(`SELECT `+teamDisplayExpr+` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=?`, userID).Scan(&name)
return name
}
func parseClockOK(at string) bool {
var h, m int
if _, e := fmt.Sscanf(at, "%d:%d", &h, &m); e != nil || h < 0 || h > 23 || m < 0 || m > 59 {
return false
}
parts := strings.SplitN(at, ":", 2)
return len(parts) == 2
}
func teamTaskKindLabel(kind string) string {
if kind == "ticket" {
return "工单"
}
return "任务"
}
func teamStatusLabel(st string) string {
switch st {
case "doing":
return "进行中"
case "done":
return "已完成"
case "closed":
return "已关闭"
}
return "待处理"
}
func appendHistoryNode(history, status, at string) string {
node := fmt.Sprintf(`{"status":%q,"at":%q}`, status, at)
h := strings.TrimSpace(history)
if h == "" || h == "[]" {
return "[" + node + "]"
}
if strings.HasSuffix(h, "]") {
return h[:len(h)-1] + "," + node + "]"
}
return "[" + node + "]"
}
func stripFence(s string) string {
s = strings.TrimSpace(s)
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```")
if i := strings.IndexByte(s, '\n'); i >= 0 {
s = s[i+1:]
}
s = strings.TrimSuffix(strings.TrimSpace(s), "```")
}
return strings.TrimSpace(s)
}
// ---------- 团队 CRUD ----------
func (s *TeamService) Create(userID int64, name string) (*TeamInfo, error) {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 64 {
return nil, commonservice.BadRequest("TEAM_NAME_INVALID")
}
now := commonservice.NowRFC()
team := model.Team{Name: name, OwnerID: userID, DigestTime: "21:00", CreatedAt: now}
if err := s.DB.Create(&team).Error; err != nil {
return nil, commonservice.Internal("TEAM_CREATE_FAILED")
}
if err := s.DB.Exec(`INSERT INTO team_members(team_id,user_id,role,joined_at) VALUES(?,?,?,?)`, team.ID, userID, "owner", now).Error; err != nil {
return nil, commonservice.Internal("TEAM_CREATE_FAILED")
}
return &TeamInfo{ID: team.ID, Name: name, OwnerID: userID, DigestTime: "21:00", Role: "owner", Members: 1}, nil
}
func (s *TeamService) List(userID int64) ([]TeamInfo, error) {
rows, err := s.DB.Raw(`SELECT t.id,t.name,t.owner_id,t.digest_time,m.role,
(SELECT COUNT(*) FROM team_members x WHERE x.team_id=t.id)
FROM teams t JOIN team_members m ON m.team_id=t.id AND m.user_id=? ORDER BY t.id`, userID).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
defer rows.Close()
out := []TeamInfo{}
for rows.Next() {
var t TeamInfo
if err := rows.Scan(&t.ID, &t.Name, &t.OwnerID, &t.DigestTime, &t.Role, &t.Members); err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out = append(out, t)
}
return out, nil
}
func (s *TeamService) Rename(teamID, userID int64, name string) error {
name = strings.TrimSpace(name)
if name == "" || len([]rune(name)) > 64 {
return commonservice.BadRequest("TEAM_NAME_INVALID")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "owner"); err != nil {
return err
}
return s.DB.Exec(`UPDATE teams SET name=? WHERE id=?`, name, teamID).Error
}
func (s *TeamService) SetDigestTime(teamID, userID int64, at string) error {
if !parseClockOK(at) {
return commonservice.BadRequest("TEAM_TIME_INVALID")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
return s.DB.Exec(`UPDATE teams SET digest_time=? WHERE id=?`, at, teamID).Error
}
func (s *TeamService) Dissolve(teamID, userID int64) error {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "owner"); err != nil {
return err
}
var name string
_ = s.DB.Raw(`SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
var members []int64
_ = s.DB.Raw(`SELECT user_id FROM team_members WHERE team_id=?`, teamID).Scan(&members)
for _, m := range members {
s.teamNotice(teamID, m, userID, "member", "", fmt.Sprintf("团队「%s」已被解散", name))
}
for _, q := range []string{
`DELETE FROM team_tasks WHERE team_id=?`,
`DELETE FROM team_reports WHERE team_id=?`,
`DELETE FROM team_digests WHERE team_id=?`,
`DELETE FROM team_members WHERE team_id=?`,
`DELETE FROM teams WHERE id=?`,
} {
if err := s.DB.Exec(q, teamID).Error; err != nil {
return commonservice.Internal("TEAM_DISSOLVE_FAILED")
}
}
return nil
}
func (s *TeamService) Leave(teamID, userID int64) error {
role, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member")
if err != nil {
return err
}
if role == "owner" {
return commonservice.BadRequest("TEAM_OWNER_CANNOT_LEAVE")
}
if err := s.DB.Exec(`DELETE FROM team_members WHERE team_id=? AND user_id=?`, teamID, userID).Error; err != nil {
return commonservice.Internal("TEAM_LEAVE_FAILED")
}
var ownerID int64
_ = s.DB.Raw(`SELECT owner_id FROM teams WHERE id=?`, teamID).Scan(&ownerID)
s.teamNotice(teamID, ownerID, userID, "member", "", fmt.Sprintf("%s 退出了团队", s.teamUserName(userID)))
return nil
}
// ---------- 成员 ----------
func (s *TeamService) Members(teamID, userID int64) ([]TeamMemberDTO, error) {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
return nil, err
}
return s.memberRows(teamID)
}
func (s *TeamService) memberRows(teamID int64) ([]TeamMemberDTO, error) {
rows, err := s.DB.Raw(`SELECT m.user_id,u.username,COALESCE(p.nickname,''),COALESCE(p.title,''),COALESCE(p.bio,''),
COALESCE(p.tech_tags,'[]'),COALESCE(p.avatar_thumb,''),m.role,m.joined_at
FROM team_members m JOIN users u ON u.id=m.user_id LEFT JOIN user_profiles p ON p.user_id=m.user_id
WHERE m.team_id=? ORDER BY CASE m.role WHEN 'owner' THEN 1 WHEN 'admin' THEN 2 ELSE 3 END, m.joined_at`, teamID).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
defer rows.Close()
out := []TeamMemberDTO{}
for rows.Next() {
var m TeamMemberDTO
var tags string
if err := rows.Scan(&m.UserID, &m.Username, &m.Nickname, &m.Title, &m.Bio, &tags, &m.Avatar, &m.Role, &m.JoinedAt); err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
m.TechTags = parseTechTags(tags)
out = append(out, m)
}
return out, nil
}
func (s *TeamService) Invite(teamID, userID int64, username, role string) error {
if role != "member" && role != "admin" {
role = "member"
}
username = strings.TrimSpace(username)
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
var target int64
if err := s.DB.Raw(`SELECT id FROM users WHERE username=?`, username).Scan(&target).Error; err != nil || target == 0 {
return commonservice.NotFound("TEAM_USER_NOT_FOUND")
}
res := s.DB.Exec(`INSERT IGNORE INTO team_members(team_id,user_id,role,joined_at) VALUES(?,?,?,?)`,
teamID, target, role, commonservice.NowRFC())
if res.Error != nil {
return commonservice.Internal("TEAM_INVITE_FAILED")
}
if res.RowsAffected == 0 {
return commonservice.Conflict("TEAM_ALREADY_MEMBER")
}
var name string
_ = s.DB.Raw(`SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
s.teamNotice(teamID, target, userID, "member", "", fmt.Sprintf("%s 把你加入了团队「%s」", s.teamUserName(userID), name))
return nil
}
func (s *TeamService) SetRole(teamID, actorID, targetUserID int64, role string) error {
if role != "member" && role != "admin" {
return commonservice.BadRequest("TEAM_ROLE_INVALID")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, actorID, "owner"); err != nil {
return err
}
target, err := commonservice.TeamRole(s.DB, teamID, targetUserID)
if err != nil {
return err
}
if target == "" || target == "owner" {
return commonservice.BadRequest("TEAM_ROLE_INVALID")
}
if err := s.DB.Exec(`UPDATE team_members SET role=? WHERE team_id=? AND user_id=?`, role, teamID, targetUserID).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
label := "成员"
if role == "admin" {
label = "管理员"
}
s.teamNotice(teamID, targetUserID, actorID, "role", "", fmt.Sprintf("你的团队角色已调整为「%s」", label))
return nil
}
func (s *TeamService) RemoveMember(teamID, actorID, targetUserID int64) error {
actor, err := commonservice.RequireTeamRole(s.DB, teamID, actorID, "admin")
if err != nil {
return err
}
target, err := commonservice.TeamRole(s.DB, teamID, targetUserID)
if err != nil {
return err
}
if target == "" || target == "owner" || targetUserID == actorID || (actor == "admin" && target != "member") {
return commonservice.Forbidden("TEAM_FORBIDDEN")
}
if err := s.DB.Exec(`DELETE FROM team_members WHERE team_id=? AND user_id=?`, teamID, targetUserID).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
var name string
_ = s.DB.Raw(`SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
s.teamNotice(teamID, targetUserID, actorID, "member", "", fmt.Sprintf("你已被移出团队「%s」", name))
return nil
}
// ---------- 任务 ----------
func validTeamTask(t *TeamTaskDTO) error {
t.Title = strings.TrimSpace(t.Title)
if t.Title == "" {
return commonservice.BadRequest("TEAM_TASK_TITLE_REQUIRED")
}
if t.Kind != "ticket" {
t.Kind = "todo"
}
switch t.Priority {
case "low", "medium", "high":
default:
t.Priority = "medium"
}
return nil
}
func (s *TeamService) TaskSave(userID int64, t TeamTaskDTO) (*TeamTaskDTO, error) {
if err := validTeamTask(&t); err != nil {
return nil, err
}
if _, err := commonservice.RequireTeamRole(s.DB, t.TeamID, userID, "admin"); err != nil {
return nil, err
}
if t.AssigneeID > 0 {
r, err := commonservice.TeamRole(s.DB, t.TeamID, t.AssigneeID)
if err != nil || r == "" {
return nil, commonservice.BadRequest("TEAM_ASSIGNEE_INVALID")
}
}
now := commonservice.NowRFC()
if t.ID == 0 {
history := fmt.Sprintf(`[{"status":"open","at":%q}]`, now)
rec := model.TeamTask{
TeamID: t.TeamID, Kind: t.Kind, Title: t.Title, Description: t.Description,
Priority: t.Priority, Status: "open", CreatorID: userID, AssigneeID: t.AssigneeID,
StartAt: t.StartAt, DueAt: t.DueAt, History: history, UpdatedAt: now,
}
if err := s.DB.Create(&rec).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
t.ID, t.Status, t.CreatorID, t.History, t.UpdatedAt = rec.ID, "open", userID, history, now
if t.AssigneeID > 0 {
s.teamNotice(t.TeamID, t.AssigneeID, userID, "assign", fmt.Sprint(t.ID),
fmt.Sprintf("%s 给你指派了%s%s", s.teamUserName(userID), teamTaskKindLabel(t.Kind), t.Title))
}
return &t, nil
}
var prevAssignee int64
var n int64
s.DB.Raw(`SELECT COUNT(*) FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, t.ID, t.TeamID).Scan(&n)
if n == 0 {
return nil, commonservice.NotFound("TEAM_TASK_NOT_FOUND")
}
_ = s.DB.Raw(`SELECT assignee_id FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, t.ID, t.TeamID).Scan(&prevAssignee)
if err := s.DB.Exec(`UPDATE team_tasks SET kind=?,title=?,description=?,priority=?,assignee_id=?,start_at=?,due_at=?,updated_at=? WHERE id=? AND team_id=?`,
t.Kind, t.Title, t.Description, t.Priority, t.AssigneeID, t.StartAt, t.DueAt, now, t.ID, t.TeamID).Error; err != nil {
return nil, commonservice.Internal("SAVE_FAILED")
}
if t.AssigneeID > 0 && t.AssigneeID != prevAssignee {
s.teamNotice(t.TeamID, t.AssigneeID, userID, "assign", fmt.Sprint(t.ID),
fmt.Sprintf("%s 给你指派了%s%s", s.teamUserName(userID), teamTaskKindLabel(t.Kind), t.Title))
}
t.UpdatedAt = now
return &t, nil
}
func (s *TeamService) TaskSetStatus(teamID, taskID, userID int64, status string) error {
switch status {
case "open", "doing", "done", "closed":
default:
return commonservice.BadRequest("TEAM_STATUS_INVALID")
}
role, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member")
if err != nil {
return err
}
var assignee, creator int64
var title, history, kind string
row := s.DB.Raw(`SELECT assignee_id,creator_id,title,history,kind FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, taskID, teamID).Row()
if err := row.Scan(&assignee, &creator, &title, &history, &kind); err != nil {
return commonservice.NotFound("TEAM_TASK_NOT_FOUND")
}
if commonservice.TeamRoleRank(role) < commonservice.TeamRoleRank("admin") && userID != assignee {
return commonservice.Forbidden("TEAM_FORBIDDEN")
}
now := commonservice.NowRFC()
history = appendHistoryNode(history, status, now)
if err := s.DB.Exec(`UPDATE team_tasks SET status=?,history=?,updated_at=? WHERE id=?`, status, history, now, taskID).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
if (status == "done" || status == "closed") && userID != creator {
s.teamNotice(teamID, creator, userID, "status", fmt.Sprint(taskID),
fmt.Sprintf("%s 将%s「%s」标记为%s", s.teamUserName(userID), teamTaskKindLabel(kind), title, teamStatusLabel(status)))
}
return nil
}
func (s *TeamService) TaskUrge(teamID, taskID, userID int64) error {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
var assignee int64
var title, kind string
if err := s.DB.Raw(`SELECT assignee_id,title,kind FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, taskID, teamID).
Row().Scan(&assignee, &title, &kind); err != nil {
return commonservice.NotFound("TEAM_TASK_NOT_FOUND")
}
if assignee <= 0 {
return commonservice.BadRequest("TEAM_NO_ASSIGNEE")
}
now := commonservice.NowRFC()
if err := s.DB.Exec(`UPDATE team_tasks SET urged_at=?,updated_at=? WHERE id=?`, now, now, taskID).Error; err != nil {
return commonservice.Internal("SAVE_FAILED")
}
s.teamNotice(teamID, assignee, userID, "urge", fmt.Sprint(taskID),
fmt.Sprintf("%s 催办了%s%s", s.teamUserName(userID), teamTaskKindLabel(kind), title))
return nil
}
func (s *TeamService) TaskDelete(teamID, taskID, userID int64) error {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
return s.DB.Exec(`UPDATE team_tasks SET deleted=1,updated_at=? WHERE id=? AND team_id=?`, commonservice.NowRFC(), taskID, teamID).Error
}
func (s *TeamService) TaskList(teamID, userID int64, filter string) ([]TeamTaskDTO, error) {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
return nil, err
}
q := `SELECT t.id,t.team_id,t.kind,t.title,t.description,t.priority,t.status,t.creator_id,t.assignee_id,
t.start_at,t.due_at,t.urged_at,t.history,t.updated_at,
COALESCE((SELECT ` + teamDisplayExpr + ` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=t.creator_id),''),
COALESCE((SELECT ` + teamDisplayExpr + ` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=t.assignee_id),'')
FROM team_tasks t WHERE t.team_id=? AND t.deleted=0`
args := []any{teamID}
switch filter {
case "mine":
q += ` AND t.assignee_id=?`
args = append(args, userID)
case "created":
q += ` AND t.creator_id=?`
args = append(args, userID)
case "open":
q += ` AND t.status IN ('open','doing')`
}
q += ` ORDER BY t.updated_at DESC LIMIT 500`
rows, err := s.DB.Raw(q, args...).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
defer rows.Close()
out := []TeamTaskDTO{}
for rows.Next() {
var t TeamTaskDTO
if err := rows.Scan(&t.ID, &t.TeamID, &t.Kind, &t.Title, &t.Description, &t.Priority, &t.Status, &t.CreatorID, &t.AssigneeID,
&t.StartAt, &t.DueAt, &t.UrgedAt, &t.History, &t.UpdatedAt, &t.Creator, &t.Assignee); err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out = append(out, t)
}
return out, nil
}
// ---------- 共享 ----------
func (s *TeamService) SharedItems(teamID, userID int64) ([]TeamSharedItem, error) {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
return nil, err
}
q := `SELECT 'todo',s.uuid,s.user_id,` + teamDisplayExpr + `,s.title,s.status,s.priority,s.due_at,s.updated_at
FROM sync_todos s JOIN users u ON u.id=s.user_id LEFT JOIN user_profiles p ON p.user_id=s.user_id
WHERE s.team_id=? AND s.deleted=0
UNION ALL
SELECT 'ticket',s.uuid,s.user_id,` + teamDisplayExpr + `,s.title,s.status,s.priority,s.due_at,s.updated_at
FROM sync_tickets s JOIN users u ON u.id=s.user_id LEFT JOIN user_profiles p ON p.user_id=s.user_id
WHERE s.team_id=? AND s.deleted=0
ORDER BY 9 DESC LIMIT 500`
rows, err := s.DB.Raw(q, teamID, teamID).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
defer rows.Close()
out := []TeamSharedItem{}
for rows.Next() {
var it TeamSharedItem
if err := rows.Scan(&it.Kind, &it.UUID, &it.UserID, &it.Owner, &it.Title, &it.Status, &it.Priority, &it.DueAt, &it.UpdatedAt); err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
out = append(out, it)
}
return out, nil
}
func (s *TeamService) UrgeShared(teamID, userID int64, kind, uuid string) error {
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
table := "sync_todos"
if kind == "ticket" {
table = "sync_tickets"
}
var owner int64
var title string
if err := s.DB.Raw(`SELECT user_id,title FROM `+table+` WHERE uuid=? AND team_id=? AND deleted=0`, uuid, teamID).
Row().Scan(&owner, &title); err != nil {
return commonservice.NotFound("TEAM_TASK_NOT_FOUND")
}
s.teamNotice(teamID, owner, userID, "urge", uuid,
fmt.Sprintf("%s 催办了你共享的%s%s", s.teamUserName(userID), teamTaskKindLabel(kind), title))
return nil
}
// ---------- 日报 / 摘要 ----------
func (s *TeamService) ReportSubmit(teamID, userID int64, date, content string) error {
if !teamDateRe.MatchString(date) {
return commonservice.BadRequest("TEAM_DATE_INVALID")
}
content = strings.TrimSpace(content)
if content == "" {
return commonservice.BadRequest("TEAM_REPORT_EMPTY")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member"); err != nil {
return err
}
return s.DB.Exec(`INSERT INTO team_reports(team_id,user_id,date,content,submitted_at) VALUES(?,?,?,?,?)
ON DUPLICATE KEY UPDATE content=VALUES(content),submitted_at=VALUES(submitted_at)`,
teamID, userID, date, content, commonservice.NowRFC()).Error
}
func (s *TeamService) ReportBoardGet(teamID, userID int64, date string) (*TeamReportBoard, error) {
if !teamDateRe.MatchString(date) {
return nil, commonservice.BadRequest("TEAM_DATE_INVALID")
}
role, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "member")
if err != nil {
return nil, err
}
board := &TeamReportBoard{Date: date, Role: role, Reports: []TeamReportDTO{}, Missing: []TeamMemberDTO{}}
rows, err := s.DB.Raw(`SELECT r.user_id,`+teamDisplayExpr+`,r.content,r.submitted_at
FROM team_reports r JOIN users u ON u.id=r.user_id LEFT JOIN user_profiles p ON p.user_id=r.user_id
WHERE r.team_id=? AND r.date=? ORDER BY r.submitted_at`, teamID, date).Rows()
if err != nil {
return nil, commonservice.Internal("QUERY_FAILED")
}
submitted := map[int64]bool{}
for rows.Next() {
var r TeamReportDTO
if err := rows.Scan(&r.UserID, &r.User, &r.Content, &r.SubmittedAt); err != nil {
rows.Close()
return nil, commonservice.Internal("QUERY_FAILED")
}
r.Date = date
if commonservice.TeamRoleRank(role) < commonservice.TeamRoleRank("admin") && r.UserID != userID {
r.Content = ""
}
submitted[r.UserID] = true
board.Reports = append(board.Reports, r)
}
rows.Close()
members, err := s.memberRows(teamID)
if err != nil {
return nil, err
}
for _, m := range members {
if !submitted[m.UserID] {
board.Missing = append(board.Missing, m)
}
}
var d TeamDigestDTO
err = s.DB.Raw(`SELECT date,content,provider,generated_at FROM team_digests WHERE team_id=? AND date=?`, teamID, date).
Row().Scan(&d.Date, &d.Content, &d.Provider, &d.GeneratedAt)
if err == nil {
d.Content = stripFence(d.Content)
board.Digest = &d
}
return board, nil
}
func (s *TeamService) ReportUrge(teamID, actorID, targetUserID int64, date string) error {
if !teamDateRe.MatchString(date) {
return commonservice.BadRequest("TEAM_DATE_INVALID")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, actorID, "admin"); err != nil {
return err
}
r, err := commonservice.TeamRole(s.DB, teamID, targetUserID)
if err != nil || r == "" {
return commonservice.NotFound("TEAM_USER_NOT_FOUND")
}
s.teamNotice(teamID, targetUserID, actorID, "report_urge", date,
fmt.Sprintf("%s 提醒你提交 %s 的团队日报", s.teamUserName(actorID), date))
return nil
}
// DigestSave 存客户端本地 AI 生成后的摘要admin+)。
func (s *TeamService) DigestSave(teamID, userID int64, date, content, provider string) error {
if !teamDateRe.MatchString(date) {
return commonservice.BadRequest("TEAM_DATE_INVALID")
}
content = strings.TrimSpace(content)
if content == "" {
return commonservice.BadRequest("TEAM_DIGEST_EMPTY")
}
if _, err := commonservice.RequireTeamRole(s.DB, teamID, userID, "admin"); err != nil {
return err
}
return s.DB.Exec(`INSERT INTO team_digests(team_id,date,content,provider,generated_at) VALUES(?,?,?,?,?)
ON DUPLICATE KEY UPDATE content=VALUES(content),provider=VALUES(provider),generated_at=VALUES(generated_at)`,
teamID, date, content, provider, commonservice.NowRFC()).Error
}