Files
code-utils/team.go
2026-08-14 07:52:01 +08:00

1199 lines
37 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}