1199 lines
37 KiB
Go
1199 lines
37 KiB
Go
package main
|
||
|
||
// team.go 团队协作:团队/成员/角色、团队任务(创建/指派/流转/催办)、
|
||
// 日报提交与 AI 摘要、团队通知。团队数据存共享 MySQL(在线操作,无本地镜像),
|
||
// 通知写 team_notices,各客户端在同步循环里增量拉取后转本地消息。
|
||
// 表结构见 init.sql;表缺失统一报 TEAM_SCHEMA_MISSING 提示升级服务器库。
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"errors"
|
||
"fmt"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/go-sql-driver/mysql"
|
||
|
||
"view/service"
|
||
"view/service/ai"
|
||
)
|
||
|
||
// ---------- 结构 ----------
|
||
|
||
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"`
|
||
Current bool `json:"current"`
|
||
}
|
||
|
||
type TeamMember 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 TeamTask struct {
|
||
ID int64 `json:"id"`
|
||
TeamID int64 `json:"teamId"`
|
||
Kind string `json:"kind"` // todo | ticket
|
||
Title string `json:"title"`
|
||
Description string `json:"description"`
|
||
Priority string `json:"priority"`
|
||
Status string `json:"status"` // open | doing | done | closed
|
||
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"` // todo | ticket
|
||
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 TeamReport struct {
|
||
UserID int64 `json:"userId"`
|
||
User string `json:"user"`
|
||
Date string `json:"date"`
|
||
Content string `json:"content"`
|
||
SubmittedAt string `json:"submittedAt"`
|
||
}
|
||
|
||
type TeamDigest struct {
|
||
Date string `json:"date"`
|
||
Content string `json:"content"`
|
||
Provider string `json:"provider"`
|
||
GeneratedAt string `json:"generatedAt"`
|
||
}
|
||
|
||
// TeamReportBoard 一天的日报全景:成员提交内容 + 未交名单 + AI 摘要。
|
||
type TeamReportBoard struct {
|
||
Date string `json:"date"`
|
||
Role string `json:"role"`
|
||
Reports []TeamReport `json:"reports"`
|
||
Missing []TeamMember `json:"missing"`
|
||
Digest *TeamDigest `json:"digest"`
|
||
}
|
||
|
||
// ---------- 基础设施 ----------
|
||
|
||
var teamDateRe = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}$`)
|
||
|
||
// mapTeamErr 把 MySQL 表/列缺失翻译为稳定错误码(服务器库未跑新版 init.sql)。
|
||
func mapTeamErr(e error) error {
|
||
if e == nil {
|
||
return nil
|
||
}
|
||
var me *mysql.MySQLError
|
||
if errors.As(e, &me) && (me.Number == 1146 || me.Number == 1054) {
|
||
return errors.New("TEAM_SCHEMA_MISSING")
|
||
}
|
||
return e
|
||
}
|
||
|
||
// teamConn 建立远端连接并校验登录态;调用方负责 cancel 与 db.Close。
|
||
func (a *App) teamConn() (context.Context, context.CancelFunc, *sql.DB, int64, error) {
|
||
if e := a.ready(); e != nil {
|
||
return nil, nil, nil, 0, e
|
||
}
|
||
uid := a.syncUserID()
|
||
if uid <= 0 {
|
||
return nil, nil, nil, 0, errors.New("SYNC_NOT_LOGGED_IN")
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), syncTimeout)
|
||
db, e := a.openRemote(ctx)
|
||
if e != nil {
|
||
cancel()
|
||
return nil, nil, nil, 0, e
|
||
}
|
||
return ctx, cancel, db, uid, nil
|
||
}
|
||
|
||
func teamRoleRank(role string) int {
|
||
switch role {
|
||
case "owner":
|
||
return 3
|
||
case "admin":
|
||
return 2
|
||
case "member":
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
// teamRole 查询用户在团队中的角色;非成员返回空串。
|
||
func teamRole(ctx context.Context, db *sql.DB, teamID, userID int64) (string, error) {
|
||
var role string
|
||
e := db.QueryRowContext(ctx, `SELECT role FROM team_members WHERE team_id=? AND user_id=?`, teamID, userID).Scan(&role)
|
||
if e == sql.ErrNoRows {
|
||
return "", nil
|
||
}
|
||
return role, mapTeamErr(e)
|
||
}
|
||
|
||
// requireTeamRole 校验最低角色,返回实际角色。
|
||
func requireTeamRole(ctx context.Context, db *sql.DB, teamID, userID int64, min string) (string, error) {
|
||
role, e := teamRole(ctx, db, teamID, userID)
|
||
if e != nil {
|
||
return "", e
|
||
}
|
||
if teamRoleRank(role) < teamRoleRank(min) {
|
||
return role, errors.New("TEAM_FORBIDDEN")
|
||
}
|
||
return role, nil
|
||
}
|
||
|
||
// teamDisplayExpr 成员展示名:资料昵称优先,回退账号名。
|
||
const teamDisplayExpr = `COALESCE(NULLIF(p.nickname,''), u.username)`
|
||
|
||
// teamNotice 写一条团队通知(自己给自己的跳过)。
|
||
func teamNotice(ctx context.Context, db *sql.DB, teamID, toUser, fromUser int64, kind, refID, content string) {
|
||
if toUser <= 0 || toUser == fromUser {
|
||
return
|
||
}
|
||
_, _ = db.ExecContext(ctx, `INSERT INTO team_notices(team_id,to_user,from_user,kind,ref_id,content,created_at) VALUES(?,?,?,?,?,?,?)`,
|
||
teamID, toUser, fromUser, kind, refID, content, nowRFC())
|
||
}
|
||
|
||
// teamUserName 查用户展示名(昵称优先)。
|
||
func teamUserName(ctx context.Context, db *sql.DB, userID int64) string {
|
||
var name string
|
||
_ = db.QueryRowContext(ctx, `SELECT `+teamDisplayExpr+` FROM users u LEFT JOIN user_profiles p ON p.user_id=u.id WHERE u.id=?`, userID).Scan(&name)
|
||
return name
|
||
}
|
||
|
||
// ---------- 团队 CRUD ----------
|
||
|
||
func (a *App) TeamCreate(name string) (TeamInfo, error) {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" || len([]rune(name)) > 64 {
|
||
return TeamInfo{}, errors.New("TEAM_NAME_INVALID")
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return TeamInfo{}, e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
res, e := db.ExecContext(ctx, `INSERT INTO teams(name,owner_id,digest_time,created_at) VALUES(?,?,?,?)`, name, uid, "21:00", nowRFC())
|
||
if e != nil {
|
||
return TeamInfo{}, mapTeamErr(e)
|
||
}
|
||
id, _ := res.LastInsertId()
|
||
if _, e = db.ExecContext(ctx, `INSERT INTO team_members(team_id,user_id,role,joined_at) VALUES(?,?,?,?)`, id, uid, "owner", nowRFC()); e != nil {
|
||
return TeamInfo{}, mapTeamErr(e)
|
||
}
|
||
_ = a.store.SetMeta("current_team_id", fmt.Sprint(id))
|
||
a.store.Log("info", "团队", "团队创建成功", name)
|
||
return TeamInfo{ID: id, Name: name, OwnerID: uid, DigestTime: "21:00", Role: "owner", Members: 1, Current: true}, nil
|
||
}
|
||
|
||
func (a *App) TeamList() ([]TeamInfo, error) {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return nil, e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
rows, e := db.QueryContext(ctx, `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`, uid)
|
||
if e != nil {
|
||
return nil, mapTeamErr(e)
|
||
}
|
||
defer rows.Close()
|
||
out := []TeamInfo{}
|
||
for rows.Next() {
|
||
var t TeamInfo
|
||
if e = rows.Scan(&t.ID, &t.Name, &t.OwnerID, &t.DigestTime, &t.Role, &t.Members); e != nil {
|
||
return nil, e
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
if e = rows.Err(); e != nil {
|
||
return nil, e
|
||
}
|
||
// 当前团队:meta 缺失或已不在列表中时自动落到第一个。
|
||
cur := a.store.Meta("current_team_id")
|
||
found := false
|
||
for i := range out {
|
||
if fmt.Sprint(out[i].ID) == cur {
|
||
out[i].Current = true
|
||
found = true
|
||
}
|
||
}
|
||
if !found && len(out) > 0 {
|
||
out[0].Current = true
|
||
_ = a.store.SetMeta("current_team_id", fmt.Sprint(out[0].ID))
|
||
}
|
||
return out, nil
|
||
}
|
||
|
||
func (a *App) TeamSwitch(teamID int64) error {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "member"); e != nil {
|
||
return e
|
||
}
|
||
if e := a.store.SetMeta("current_team_id", fmt.Sprint(teamID)); e != nil {
|
||
return e
|
||
}
|
||
// 徽标口径跟随当前团队,切换后立即重算,不等下一轮同步。
|
||
a.refreshTeamAssignedCache(ctx, db, uid)
|
||
return nil
|
||
}
|
||
|
||
func (a *App) TeamRename(teamID int64, name string) error {
|
||
name = strings.TrimSpace(name)
|
||
if name == "" || len([]rune(name)) > 64 {
|
||
return errors.New("TEAM_NAME_INVALID")
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "owner"); e != nil {
|
||
return e
|
||
}
|
||
_, e = db.ExecContext(ctx, `UPDATE teams SET name=? WHERE id=?`, name, teamID)
|
||
return mapTeamErr(e)
|
||
}
|
||
|
||
func (a *App) TeamSetDigestTime(teamID int64, at string) error {
|
||
if _, ok := parseClockOK(at); !ok {
|
||
return errors.New("TEAM_TIME_INVALID")
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "admin"); e != nil {
|
||
return e
|
||
}
|
||
_, e = db.ExecContext(ctx, `UPDATE teams SET digest_time=? WHERE id=?`, at, teamID)
|
||
return mapTeamErr(e)
|
||
}
|
||
|
||
// parseClockOK 校验 HH:MM 格式。
|
||
func parseClockOK(at string) (string, bool) {
|
||
parts := strings.SplitN(at, ":", 2)
|
||
if len(parts) != 2 {
|
||
return "", false
|
||
}
|
||
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
|
||
}
|
||
return at, true
|
||
}
|
||
|
||
func (a *App) TeamDissolve(teamID int64) error {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "owner"); e != nil {
|
||
return e
|
||
}
|
||
var name string
|
||
_ = db.QueryRowContext(ctx, `SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
|
||
// 先通知成员再删数据。
|
||
rows, e := db.QueryContext(ctx, `SELECT user_id FROM team_members WHERE team_id=?`, teamID)
|
||
if e != nil {
|
||
return mapTeamErr(e)
|
||
}
|
||
members := []int64{}
|
||
for rows.Next() {
|
||
var id int64
|
||
if rows.Scan(&id) == nil {
|
||
members = append(members, id)
|
||
}
|
||
}
|
||
rows.Close()
|
||
for _, m := range members {
|
||
teamNotice(ctx, db, teamID, m, uid, "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 _, e = db.ExecContext(ctx, q, teamID); e != nil {
|
||
return mapTeamErr(e)
|
||
}
|
||
}
|
||
if a.store.Meta("current_team_id") == fmt.Sprint(teamID) {
|
||
_ = a.store.SetMeta("current_team_id", "")
|
||
}
|
||
a.refreshTeamAssignedCache(ctx, db, uid)
|
||
a.store.Log("warning", "团队", "团队已解散", name)
|
||
return nil
|
||
}
|
||
|
||
func (a *App) TeamLeave(teamID int64) error {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
role, e := requireTeamRole(ctx, db, teamID, uid, "member")
|
||
if e != nil {
|
||
return e
|
||
}
|
||
if role == "owner" {
|
||
return errors.New("TEAM_OWNER_CANNOT_LEAVE")
|
||
}
|
||
if _, e = db.ExecContext(ctx, `DELETE FROM team_members WHERE team_id=? AND user_id=?`, teamID, uid); e != nil {
|
||
return mapTeamErr(e)
|
||
}
|
||
var ownerID int64
|
||
_ = db.QueryRowContext(ctx, `SELECT owner_id FROM teams WHERE id=?`, teamID).Scan(&ownerID)
|
||
teamNotice(ctx, db, teamID, ownerID, uid, "member", "", fmt.Sprintf("%s 退出了团队", teamUserName(ctx, db, uid)))
|
||
if a.store.Meta("current_team_id") == fmt.Sprint(teamID) {
|
||
_ = a.store.SetMeta("current_team_id", "")
|
||
}
|
||
a.refreshTeamAssignedCache(ctx, db, uid)
|
||
return nil
|
||
}
|
||
|
||
// ---------- 成员管理 ----------
|
||
|
||
func (a *App) TeamMembers(teamID int64) ([]TeamMember, error) {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return nil, e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "member"); e != nil {
|
||
return nil, e
|
||
}
|
||
return teamMemberRows(ctx, db, teamID)
|
||
}
|
||
|
||
func teamMemberRows(ctx context.Context, db *sql.DB, teamID int64) ([]TeamMember, error) {
|
||
rows, e := db.QueryContext(ctx, `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 FIELD(m.role,'owner','admin','member'), m.joined_at`, teamID)
|
||
if e != nil {
|
||
return nil, mapTeamErr(e)
|
||
}
|
||
defer rows.Close()
|
||
out := []TeamMember{}
|
||
for rows.Next() {
|
||
var m TeamMember
|
||
var tags string
|
||
if e = rows.Scan(&m.UserID, &m.Username, &m.Nickname, &m.Title, &m.Bio, &tags, &m.Avatar, &m.Role, &m.JoinedAt); e != nil {
|
||
return nil, e
|
||
}
|
||
m.TechTags = parseTechTags(tags)
|
||
out = append(out, m)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
func (a *App) TeamInvite(teamID int64, username, role string) error {
|
||
if role != "member" && role != "admin" {
|
||
role = "member"
|
||
}
|
||
username = strings.TrimSpace(username)
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "admin"); e != nil {
|
||
return e
|
||
}
|
||
var target int64
|
||
if e = db.QueryRowContext(ctx, `SELECT id FROM users WHERE username=?`, username).Scan(&target); e != nil {
|
||
if e == sql.ErrNoRows {
|
||
return errors.New("TEAM_USER_NOT_FOUND")
|
||
}
|
||
return mapTeamErr(e)
|
||
}
|
||
res, e := db.ExecContext(ctx, `INSERT IGNORE INTO team_members(team_id,user_id,role,joined_at) VALUES(?,?,?,?)`, teamID, target, role, nowRFC())
|
||
if e != nil {
|
||
return mapTeamErr(e)
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return errors.New("TEAM_ALREADY_MEMBER")
|
||
}
|
||
var name string
|
||
_ = db.QueryRowContext(ctx, `SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
|
||
teamNotice(ctx, db, teamID, target, uid, "member", "", fmt.Sprintf("%s 把你加入了团队「%s」", teamUserName(ctx, db, uid), name))
|
||
return nil
|
||
}
|
||
|
||
func (a *App) TeamSetRole(teamID, userID int64, role string) error {
|
||
if role != "member" && role != "admin" {
|
||
return errors.New("TEAM_ROLE_INVALID")
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "owner"); e != nil {
|
||
return e
|
||
}
|
||
target, e := teamRole(ctx, db, teamID, userID)
|
||
if e != nil {
|
||
return e
|
||
}
|
||
if target == "" || target == "owner" {
|
||
return errors.New("TEAM_ROLE_INVALID")
|
||
}
|
||
if _, e = db.ExecContext(ctx, `UPDATE team_members SET role=? WHERE team_id=? AND user_id=?`, role, teamID, userID); e != nil {
|
||
return mapTeamErr(e)
|
||
}
|
||
label := "成员"
|
||
if role == "admin" {
|
||
label = "管理员"
|
||
}
|
||
teamNotice(ctx, db, teamID, userID, uid, "role", "", fmt.Sprintf("你的团队角色已调整为「%s」", label))
|
||
return nil
|
||
}
|
||
|
||
func (a *App) TeamRemoveMember(teamID, userID int64) error {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
actor, e := requireTeamRole(ctx, db, teamID, uid, "admin")
|
||
if e != nil {
|
||
return e
|
||
}
|
||
target, e := teamRole(ctx, db, teamID, userID)
|
||
if e != nil {
|
||
return e
|
||
}
|
||
// owner 不可被移除;admin 只能移除普通成员;不能移除自己(请用退出)。
|
||
if target == "" || target == "owner" || userID == uid || (actor == "admin" && target != "member") {
|
||
return errors.New("TEAM_FORBIDDEN")
|
||
}
|
||
if _, e = db.ExecContext(ctx, `DELETE FROM team_members WHERE team_id=? AND user_id=?`, teamID, userID); e != nil {
|
||
return mapTeamErr(e)
|
||
}
|
||
var name string
|
||
_ = db.QueryRowContext(ctx, `SELECT name FROM teams WHERE id=?`, teamID).Scan(&name)
|
||
teamNotice(ctx, db, teamID, userID, uid, "member", "", fmt.Sprintf("你已被移出团队「%s」", name))
|
||
return nil
|
||
}
|
||
|
||
// ---------- 团队任务 ----------
|
||
|
||
func validTeamTask(t *TeamTask) error {
|
||
t.Title = strings.TrimSpace(t.Title)
|
||
if t.Title == "" {
|
||
return errors.New("TEAM_TASK_TITLE_REQUIRED")
|
||
}
|
||
if t.Kind != "ticket" {
|
||
t.Kind = "todo"
|
||
}
|
||
switch t.Priority {
|
||
case "low", "medium", "high":
|
||
default:
|
||
t.Priority = "medium"
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// TeamTaskSave 创建/编辑团队任务(admin+)。指派变化会通知新负责人。
|
||
func (a *App) TeamTaskSave(t TeamTask) (TeamTask, error) {
|
||
if e := validTeamTask(&t); e != nil {
|
||
return TeamTask{}, e
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return TeamTask{}, e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, t.TeamID, uid, "admin"); e != nil {
|
||
return TeamTask{}, e
|
||
}
|
||
if t.AssigneeID > 0 {
|
||
if r, e := teamRole(ctx, db, t.TeamID, t.AssigneeID); e != nil || r == "" {
|
||
return TeamTask{}, errors.New("TEAM_ASSIGNEE_INVALID")
|
||
}
|
||
}
|
||
now := nowRFC()
|
||
if t.ID == 0 {
|
||
history := fmt.Sprintf(`[{"status":"open","at":%q}]`, now)
|
||
res, e := db.ExecContext(ctx, `INSERT INTO team_tasks(team_id,kind,title,description,priority,status,creator_id,assignee_id,start_at,due_at,urged_at,history,updated_at,deleted)
|
||
VALUES(?,?,?,?,?,'open',?,?,?,?,'',?,?,0)`,
|
||
t.TeamID, t.Kind, t.Title, t.Description, t.Priority, uid, t.AssigneeID, t.StartAt, t.DueAt, history, now)
|
||
if e != nil {
|
||
return TeamTask{}, mapTeamErr(e)
|
||
}
|
||
t.ID, _ = res.LastInsertId()
|
||
t.Status, t.CreatorID, t.History, t.UpdatedAt = "open", uid, history, now
|
||
if t.AssigneeID > 0 {
|
||
teamNotice(ctx, db, t.TeamID, t.AssigneeID, uid, "assign", fmt.Sprint(t.ID),
|
||
fmt.Sprintf("%s 给你指派了%s:%s", teamUserName(ctx, db, uid), teamTaskKindLabel(t.Kind), t.Title))
|
||
}
|
||
a.refreshTeamAssignedCache(ctx, db, uid)
|
||
return t, nil
|
||
}
|
||
var prevAssignee int64
|
||
if e = db.QueryRowContext(ctx, `SELECT assignee_id FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, t.ID, t.TeamID).Scan(&prevAssignee); e != nil {
|
||
if e == sql.ErrNoRows {
|
||
return TeamTask{}, errors.New("TEAM_TASK_NOT_FOUND")
|
||
}
|
||
return TeamTask{}, mapTeamErr(e)
|
||
}
|
||
if _, e = db.ExecContext(ctx, `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); e != nil {
|
||
return TeamTask{}, mapTeamErr(e)
|
||
}
|
||
if t.AssigneeID > 0 && t.AssigneeID != prevAssignee {
|
||
teamNotice(ctx, db, t.TeamID, t.AssigneeID, uid, "assign", fmt.Sprint(t.ID),
|
||
fmt.Sprintf("%s 给你指派了%s:%s", teamUserName(ctx, db, uid), teamTaskKindLabel(t.Kind), t.Title))
|
||
}
|
||
t.UpdatedAt = now
|
||
a.refreshTeamAssignedCache(ctx, db, uid)
|
||
return t, nil
|
||
}
|
||
|
||
func teamTaskKindLabel(kind string) string {
|
||
if kind == "ticket" {
|
||
return "工单"
|
||
}
|
||
return "任务"
|
||
}
|
||
|
||
// TeamTaskSetStatus 流转状态:负责人本人或 admin+。
|
||
func (a *App) TeamTaskSetStatus(teamID, id int64, status string) error {
|
||
switch status {
|
||
case "open", "doing", "done", "closed":
|
||
default:
|
||
return errors.New("TEAM_STATUS_INVALID")
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
role, e := requireTeamRole(ctx, db, teamID, uid, "member")
|
||
if e != nil {
|
||
return e
|
||
}
|
||
var assignee, creator int64
|
||
var title, history, kind string
|
||
if e = db.QueryRowContext(ctx, `SELECT assignee_id,creator_id,title,history,kind FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, id, teamID).
|
||
Scan(&assignee, &creator, &title, &history, &kind); e != nil {
|
||
if e == sql.ErrNoRows {
|
||
return errors.New("TEAM_TASK_NOT_FOUND")
|
||
}
|
||
return mapTeamErr(e)
|
||
}
|
||
if teamRoleRank(role) < teamRoleRank("admin") && uid != assignee {
|
||
return errors.New("TEAM_FORBIDDEN")
|
||
}
|
||
now := nowRFC()
|
||
history = appendHistoryNode(history, status, now)
|
||
if _, e = db.ExecContext(ctx, `UPDATE team_tasks SET status=?,history=?,updated_at=? WHERE id=?`, status, history, now, id); e != nil {
|
||
return mapTeamErr(e)
|
||
}
|
||
// 负责人完成时告知创建人。
|
||
if (status == "done" || status == "closed") && uid != creator {
|
||
teamNotice(ctx, db, teamID, creator, uid, "status", fmt.Sprint(id),
|
||
fmt.Sprintf("%s 将%s「%s」标记为%s", teamUserName(ctx, db, uid), teamTaskKindLabel(kind), title, teamStatusLabel(status)))
|
||
}
|
||
a.refreshTeamAssignedCache(ctx, db, uid)
|
||
return nil
|
||
}
|
||
|
||
func teamStatusLabel(s string) string {
|
||
switch s {
|
||
case "doing":
|
||
return "进行中"
|
||
case "done":
|
||
return "已完成"
|
||
case "closed":
|
||
return "已关闭"
|
||
}
|
||
return "待处理"
|
||
}
|
||
|
||
// appendHistoryNode 追加一个生命周期节点(沿用个人待办的 JSON 轨迹格式)。
|
||
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 + "]"
|
||
}
|
||
|
||
// TeamTaskUrge 催办(admin+):更新 urged_at 并通知负责人。
|
||
func (a *App) TeamTaskUrge(teamID, id int64) error {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "admin"); e != nil {
|
||
return e
|
||
}
|
||
var assignee int64
|
||
var title, kind string
|
||
if e = db.QueryRowContext(ctx, `SELECT assignee_id,title,kind FROM team_tasks WHERE id=? AND team_id=? AND deleted=0`, id, teamID).Scan(&assignee, &title, &kind); e != nil {
|
||
if e == sql.ErrNoRows {
|
||
return errors.New("TEAM_TASK_NOT_FOUND")
|
||
}
|
||
return mapTeamErr(e)
|
||
}
|
||
if assignee <= 0 {
|
||
return errors.New("TEAM_NO_ASSIGNEE")
|
||
}
|
||
now := nowRFC()
|
||
if _, e = db.ExecContext(ctx, `UPDATE team_tasks SET urged_at=?,updated_at=? WHERE id=?`, now, now, id); e != nil {
|
||
return mapTeamErr(e)
|
||
}
|
||
teamNotice(ctx, db, teamID, assignee, uid, "urge", fmt.Sprint(id),
|
||
fmt.Sprintf("%s 催办了%s:%s", teamUserName(ctx, db, uid), teamTaskKindLabel(kind), title))
|
||
return nil
|
||
}
|
||
|
||
func (a *App) TeamTaskDelete(teamID, id int64) error {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "admin"); e != nil {
|
||
return e
|
||
}
|
||
if _, e = db.ExecContext(ctx, `UPDATE team_tasks SET deleted=1,updated_at=? WHERE id=? AND team_id=?`, nowRFC(), id, teamID); e != nil {
|
||
return mapTeamErr(e)
|
||
}
|
||
a.refreshTeamAssignedCache(ctx, db, uid)
|
||
return nil
|
||
}
|
||
|
||
// TeamTaskList 列出团队任务;filter: all | mine | created | open。
|
||
func (a *App) TeamTaskList(teamID int64, filter string) ([]TeamTask, error) {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return nil, e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "member"); e != nil {
|
||
return nil, e
|
||
}
|
||
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, uid)
|
||
case "created":
|
||
q += ` AND t.creator_id=?`
|
||
args = append(args, uid)
|
||
case "open":
|
||
q += ` AND t.status IN ('open','doing')`
|
||
}
|
||
q += ` ORDER BY t.updated_at DESC LIMIT 500`
|
||
rows, e := db.QueryContext(ctx, q, args...)
|
||
if e != nil {
|
||
return nil, mapTeamErr(e)
|
||
}
|
||
defer rows.Close()
|
||
out := []TeamTask{}
|
||
for rows.Next() {
|
||
var t TeamTask
|
||
if e = 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); e != nil {
|
||
return nil, e
|
||
}
|
||
out = append(out, t)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// ---------- 个人条目共享 ----------
|
||
|
||
// SetTodoTeam / SetTicketTeam 把个人条目共享到团队(teamID=0 取消共享)。
|
||
// 只改本地并置脏,走常规行同步上云;管理员经 TeamSharedItems 跨用户读取。
|
||
func (a *App) SetTodoTeam(id, teamID int64) error {
|
||
return a.setItemTeam("todos", id, teamID)
|
||
}
|
||
|
||
func (a *App) SetTicketTeam(id, teamID int64) error {
|
||
return a.setItemTeam("tickets", id, teamID)
|
||
}
|
||
|
||
func (a *App) setItemTeam(table string, id, teamID int64) error {
|
||
if e := a.ready(); e != nil {
|
||
return e
|
||
}
|
||
if teamID < 0 {
|
||
teamID = 0
|
||
}
|
||
res, e := a.store.db.Exec(`UPDATE `+table+` SET team_id=?,dirty=1,updated_at=? WHERE id=? AND deleted=0`, teamID, nowRFC(), id)
|
||
if e != nil {
|
||
return e
|
||
}
|
||
if n, _ := res.RowsAffected(); n == 0 {
|
||
return errors.New("NOT_FOUND")
|
||
}
|
||
if a.syncUserID() > 0 {
|
||
go a.syncOnce(true)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// TeamSharedItems 团队成员共享的个人待办/工单(跨用户读 sync 表)。
|
||
func (a *App) TeamSharedItems(teamID int64) ([]TeamSharedItem, error) {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return nil, e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "member"); e != nil {
|
||
return nil, e
|
||
}
|
||
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, e := db.QueryContext(ctx, q, teamID, teamID)
|
||
if e != nil {
|
||
return nil, mapTeamErr(e)
|
||
}
|
||
defer rows.Close()
|
||
out := []TeamSharedItem{}
|
||
for rows.Next() {
|
||
var it TeamSharedItem
|
||
if e = rows.Scan(&it.Kind, &it.UUID, &it.UserID, &it.Owner, &it.Title, &it.Status, &it.Priority, &it.DueAt, &it.UpdatedAt); e != nil {
|
||
return nil, e
|
||
}
|
||
out = append(out, it)
|
||
}
|
||
return out, rows.Err()
|
||
}
|
||
|
||
// TeamUrgeShared 催办成员共享的个人条目(admin+)。
|
||
func (a *App) TeamUrgeShared(teamID int64, kind, uuid string) error {
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "admin"); e != nil {
|
||
return e
|
||
}
|
||
table := "sync_todos"
|
||
if kind == "ticket" {
|
||
table = "sync_tickets"
|
||
}
|
||
var owner int64
|
||
var title string
|
||
if e = db.QueryRowContext(ctx, `SELECT user_id,title FROM `+table+` WHERE uuid=? AND team_id=? AND deleted=0`, uuid, teamID).Scan(&owner, &title); e != nil {
|
||
if e == sql.ErrNoRows {
|
||
return errors.New("TEAM_TASK_NOT_FOUND")
|
||
}
|
||
return mapTeamErr(e)
|
||
}
|
||
teamNotice(ctx, db, teamID, owner, uid, "urge", uuid,
|
||
fmt.Sprintf("%s 催办了你共享的%s:%s", teamUserName(ctx, db, uid), teamTaskKindLabel(kind), title))
|
||
return nil
|
||
}
|
||
|
||
// ---------- 日报 ----------
|
||
|
||
func (a *App) TeamReportSubmit(teamID int64, date, content string) error {
|
||
if !teamDateRe.MatchString(date) {
|
||
return errors.New("TEAM_DATE_INVALID")
|
||
}
|
||
content = strings.TrimSpace(content)
|
||
if content == "" {
|
||
return errors.New("TEAM_REPORT_EMPTY")
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "member"); e != nil {
|
||
return e
|
||
}
|
||
_, e = db.ExecContext(ctx, `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, uid, date, content, nowRFC())
|
||
return mapTeamErr(e)
|
||
}
|
||
|
||
// TeamReportBoardGet 某日日报全景。普通成员只能看到自己的内容全文,
|
||
// 其他人仅显示已交/未交状态;管理员可见全部内容。
|
||
func (a *App) TeamReportBoardGet(teamID int64, date string) (TeamReportBoard, error) {
|
||
if !teamDateRe.MatchString(date) {
|
||
return TeamReportBoard{}, errors.New("TEAM_DATE_INVALID")
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return TeamReportBoard{}, e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
role, e := requireTeamRole(ctx, db, teamID, uid, "member")
|
||
if e != nil {
|
||
return TeamReportBoard{}, e
|
||
}
|
||
board := TeamReportBoard{Date: date, Role: role, Reports: []TeamReport{}, Missing: []TeamMember{}}
|
||
rows, e := db.QueryContext(ctx, `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)
|
||
if e != nil {
|
||
return board, mapTeamErr(e)
|
||
}
|
||
submitted := map[int64]bool{}
|
||
for rows.Next() {
|
||
var r TeamReport
|
||
if e = rows.Scan(&r.UserID, &r.User, &r.Content, &r.SubmittedAt); e != nil {
|
||
rows.Close()
|
||
return board, e
|
||
}
|
||
r.Date = date
|
||
if teamRoleRank(role) < teamRoleRank("admin") && r.UserID != uid {
|
||
r.Content = "" // 普通成员不可见他人日报正文
|
||
}
|
||
submitted[r.UserID] = true
|
||
board.Reports = append(board.Reports, r)
|
||
}
|
||
rows.Close()
|
||
if e = rows.Err(); e != nil {
|
||
return board, e
|
||
}
|
||
members, e := teamMemberRows(ctx, db, teamID)
|
||
if e != nil {
|
||
return board, e
|
||
}
|
||
for _, m := range members {
|
||
if !submitted[m.UserID] {
|
||
board.Missing = append(board.Missing, m)
|
||
}
|
||
}
|
||
var d TeamDigest
|
||
e = db.QueryRowContext(ctx, `SELECT date,content,provider,generated_at FROM team_digests WHERE team_id=? AND date=?`, teamID, date).
|
||
Scan(&d.Date, &d.Content, &d.Provider, &d.GeneratedAt)
|
||
if e == nil {
|
||
d.Content = stripFence(d.Content)
|
||
board.Digest = &d
|
||
} else if e != sql.ErrNoRows {
|
||
return board, mapTeamErr(e)
|
||
}
|
||
return board, nil
|
||
}
|
||
|
||
// TeamReportUrge 催交日报(admin+)。
|
||
func (a *App) TeamReportUrge(teamID, userID int64, date string) error {
|
||
if !teamDateRe.MatchString(date) {
|
||
return errors.New("TEAM_DATE_INVALID")
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
if _, e = requireTeamRole(ctx, db, teamID, uid, "admin"); e != nil {
|
||
return e
|
||
}
|
||
if r, e := teamRole(ctx, db, teamID, userID); e != nil || r == "" {
|
||
return errors.New("TEAM_USER_NOT_FOUND")
|
||
}
|
||
teamNotice(ctx, db, teamID, userID, uid, "report_urge", date,
|
||
fmt.Sprintf("%s 提醒你提交 %s 的团队日报", teamUserName(ctx, db, uid), date))
|
||
return nil
|
||
}
|
||
|
||
// ---------- 日报 AI 摘要 ----------
|
||
|
||
// teamDigestEvent 是 "team:digest" 事件载荷。
|
||
type teamDigestEvent struct {
|
||
TeamID int64 `json:"teamId"`
|
||
Date string `json:"date"`
|
||
Error string `json:"error,omitempty"`
|
||
}
|
||
|
||
// TeamDigestGenerate 生成某日团队日报 AI 摘要(admin+,异步)。
|
||
func (a *App) TeamDigestGenerate(teamID int64, date string) error {
|
||
if !teamDateRe.MatchString(date) {
|
||
return errors.New("TEAM_DATE_INVALID")
|
||
}
|
||
if _, e := a.aiProvider(); e != nil {
|
||
return e
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return e
|
||
}
|
||
role, err := requireTeamRole(ctx, db, teamID, uid, "admin")
|
||
cancel()
|
||
db.Close()
|
||
_ = role
|
||
if err != nil {
|
||
return err
|
||
}
|
||
go a.generateTeamDigest(teamID, date)
|
||
return nil
|
||
}
|
||
|
||
func (a *App) generateTeamDigest(teamID int64, date string) {
|
||
emitErr := func(msg string) {
|
||
a.emit("team:digest", teamDigestEvent{TeamID: teamID, Date: date, Error: msg})
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
|
||
defer cancel()
|
||
db, e := a.openRemote(ctx)
|
||
if e != nil {
|
||
emitErr(e.Error())
|
||
return
|
||
}
|
||
defer db.Close()
|
||
rows, e := db.QueryContext(ctx, `SELECT `+teamDisplayExpr+`,r.content 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)
|
||
if e != nil {
|
||
emitErr(mapTeamErr(e).Error())
|
||
return
|
||
}
|
||
var sb strings.Builder
|
||
n := 0
|
||
for rows.Next() {
|
||
var user, content string
|
||
if rows.Scan(&user, &content) == nil {
|
||
fmt.Fprintf(&sb, "### %s\n%s\n\n", user, content)
|
||
n++
|
||
}
|
||
}
|
||
rows.Close()
|
||
if n == 0 {
|
||
emitErr("TEAM_NO_REPORTS")
|
||
return
|
||
}
|
||
provider, e := a.aiProvider()
|
||
if e != nil {
|
||
emitErr(e.Error())
|
||
return
|
||
}
|
||
var teamName string
|
||
_ = db.QueryRowContext(ctx, `SELECT name FROM teams WHERE id=?`, teamID).Scan(&teamName)
|
||
prompt := fmt.Sprintf("以下是团队「%s」%s 的 %d 份成员日报,请生成一份团队日报摘要(Markdown):\n"+
|
||
"1. 今日完成事项汇总(合并同类项)\n2. 进行中的工作\n3. 风险与阻塞(没有则省略)\n4. 每位成员一句话点评\n5. 明日建议\n\n%s",
|
||
teamName, date, n, sb.String())
|
||
stream, e := provider.ChatStream(ctx, []ai.Message{
|
||
{Role: "system", Content: "你是资深研发团队负责人助理,输出简洁、结构化的中文 Markdown 团队日报摘要,不要编造日报里没有的内容。"},
|
||
{Role: "user", Content: prompt},
|
||
})
|
||
if e != nil {
|
||
emitErr(e.Error())
|
||
return
|
||
}
|
||
var out strings.Builder
|
||
for chunk := range stream {
|
||
if chunk.Err != nil {
|
||
emitErr(chunk.Err.Error())
|
||
return
|
||
}
|
||
out.WriteString(chunk.Content)
|
||
}
|
||
content := dedentCommon(stripFence(strings.TrimSpace(out.String())))
|
||
if content == "" {
|
||
emitErr("AI_EMPTY_RESPONSE")
|
||
return
|
||
}
|
||
if _, e = db.ExecContext(ctx, `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.Name(), nowRFC()); e != nil {
|
||
emitErr(mapTeamErr(e).Error())
|
||
return
|
||
}
|
||
a.store.Log("info", "团队", "团队日报摘要已生成", fmt.Sprintf("team=%d date=%s", teamID, date))
|
||
a.emit("team:digest", teamDigestEvent{TeamID: teamID, Date: date})
|
||
}
|
||
|
||
// runTeamDigestLoop 管理员在线时每 5 分钟巡检:到点且当日摘要缺失则自动生成。
|
||
// 每团队每天只自动尝试一次(成功与否都记 meta),失败可手动重试。
|
||
func (a *App) runTeamDigestLoop() {
|
||
ticker := time.NewTicker(5 * time.Minute)
|
||
defer ticker.Stop()
|
||
for range ticker.C {
|
||
a.teamDigestTick(time.Now())
|
||
}
|
||
}
|
||
|
||
func (a *App) teamDigestTick(now time.Time) {
|
||
if a.store == nil || a.syncUserID() <= 0 || !a.syncOnline.Load() {
|
||
return
|
||
}
|
||
ctx, cancel, db, uid, e := a.teamConn()
|
||
if e != nil {
|
||
return
|
||
}
|
||
defer cancel()
|
||
defer db.Close()
|
||
rows, e := db.QueryContext(ctx, `SELECT t.id,t.digest_time FROM teams t
|
||
JOIN team_members m ON m.team_id=t.id AND m.user_id=? AND m.role IN ('owner','admin')`, uid)
|
||
if e != nil {
|
||
return
|
||
}
|
||
type cand struct {
|
||
id int64
|
||
at string
|
||
}
|
||
cands := []cand{}
|
||
for rows.Next() {
|
||
var c cand
|
||
if rows.Scan(&c.id, &c.at) == nil {
|
||
cands = append(cands, c)
|
||
}
|
||
}
|
||
rows.Close()
|
||
today := now.Format("2006-01-02")
|
||
for _, c := range cands {
|
||
if !service.DigestDue(c.at, now) {
|
||
continue
|
||
}
|
||
markKey := fmt.Sprintf("team_digest_auto_%d", c.id)
|
||
if a.store.Meta(markKey) == today {
|
||
continue
|
||
}
|
||
var exists int
|
||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM team_digests WHERE team_id=? AND date=?`, c.id, today).Scan(&exists)
|
||
if exists > 0 {
|
||
_ = a.store.SetMeta(markKey, today)
|
||
continue
|
||
}
|
||
var reports int
|
||
_ = db.QueryRowContext(ctx, `SELECT COUNT(*) FROM team_reports WHERE team_id=? AND date=?`, c.id, today).Scan(&reports)
|
||
if reports == 0 {
|
||
continue // 还没人交日报,下轮再看
|
||
}
|
||
if _, e := a.aiProvider(); e != nil {
|
||
continue
|
||
}
|
||
_ = a.store.SetMeta(markKey, today)
|
||
go a.generateTeamDigest(c.id, today)
|
||
}
|
||
}
|
||
|
||
// refreshTeamAssignedCache 缓存"指派给我的未完成团队任务数"(导航徽标用,离线读缓存)。
|
||
// 口径与「团队任务」页一致:只统计当前团队,且要求本人仍是成员——
|
||
// 避免退团残留指派或其他团队的任务让徽标亮起而页面列表为空(误报)。
|
||
func (a *App) refreshTeamAssignedCache(ctx context.Context, db *sql.DB, userID int64) {
|
||
n := 0
|
||
if teamID := a.currentTeamID(); teamID > 0 {
|
||
if e := db.QueryRowContext(ctx, `SELECT COUNT(*) FROM team_tasks tt
|
||
JOIN team_members tm ON tm.team_id=tt.team_id AND tm.user_id=?
|
||
WHERE tt.team_id=? AND tt.assignee_id=? AND tt.deleted=0 AND tt.status IN ('open','doing')`,
|
||
userID, teamID, userID).Scan(&n); e != nil {
|
||
return
|
||
}
|
||
}
|
||
_ = a.store.SetMeta("team_assigned_cache", fmt.Sprint(n))
|
||
}
|
||
|
||
// ---------- 通知拉取(挂在同步循环里) ----------
|
||
|
||
// pullTeamNotices 增量拉取发给本人的团队通知并转成本地消息。
|
||
// 表缺失(服务器未升级)时静默跳过,不影响主同步流程。
|
||
func (a *App) pullTeamNotices(ctx context.Context, db *sql.DB, userID int64) int {
|
||
cursor := a.store.Meta("team_notice_cursor")
|
||
if cursor == "" {
|
||
cursor = "0"
|
||
}
|
||
rows, e := db.QueryContext(ctx, `SELECT id,team_id,kind,content FROM team_notices WHERE to_user=? AND id>? ORDER BY id LIMIT 200`, userID, cursor)
|
||
if e != nil {
|
||
return 0
|
||
}
|
||
defer rows.Close()
|
||
titles := map[string]string{
|
||
"assign": "团队任务指派", "urge": "团队催办", "report_urge": "日报催交",
|
||
"member": "团队成员变动", "role": "团队角色变更", "status": "团队任务动态",
|
||
}
|
||
n := 0
|
||
maxID := cursor
|
||
for rows.Next() {
|
||
var id, teamID int64
|
||
var kind, content string
|
||
if rows.Scan(&id, &teamID, &kind, &content) != nil {
|
||
continue
|
||
}
|
||
title := titles[kind]
|
||
if title == "" {
|
||
title = "团队通知"
|
||
}
|
||
a.pushMessage("team", title, content, "team", teamID, true)
|
||
maxID = fmt.Sprint(id)
|
||
n++
|
||
}
|
||
if maxID != cursor {
|
||
_ = a.store.SetMeta("team_notice_cursor", maxID)
|
||
}
|
||
return n
|
||
}
|