功能更新

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,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 URL:issuer/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 来自 JWT,teamID/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 upsert;row 中不含 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
}