功能更新
This commit is contained in:
44
internal/service/activity.go
Normal file
44
internal/service/activity.go
Normal 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
306
internal/service/admin.go
Normal 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
|
||||
}
|
||||
174
internal/service/admin_security.go
Normal file
174
internal/service/admin_security.go
Normal 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
142
internal/service/auth.go
Normal 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
|
||||
}
|
||||
107
internal/service/avatar_history.go
Normal file
107
internal/service/avatar_history.go
Normal 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
173
internal/service/file.go
Normal 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
|
||||
}
|
||||
30
internal/service/notice.go
Normal file
30
internal/service/notice.go
Normal 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
113
internal/service/profile.go
Normal 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
178
internal/service/release.go
Normal 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
|
||||
}
|
||||
94
internal/service/settings.go
Normal file
94
internal/service/settings.go
Normal 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
132
internal/service/sync.go
Normal 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
684
internal/service/team.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user