Files
code-utils/team.go
2026-08-15 17:18:00 +08:00

754 lines
19 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 摘要、团队通知。
// 团队数据经 nl-pms-apiJWT通知在同步循环里增量拉取后转本地消息。
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"strings"
"time"
"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}$`)
// requireSyncLogin 校验本地登录态与 API 基址。
func (a *App) requireSyncLogin() (int64, error) {
if e := a.ready(); e != nil {
return 0, e
}
uid := a.syncUserID()
if uid <= 0 || a.store.Meta("sync_access_token") == "" {
return 0, errors.New("SYNC_NOT_LOGGED_IN")
}
if a.apiBaseURL() == "" {
return 0, errors.New("SYNC_NOT_CONFIGURED")
}
return uid, nil
}
func teamRoleRank(role string) int {
switch role {
case "owner":
return 3
case "admin":
return 2
case "member":
return 1
}
return 0
}
func teamPath(teamID int64, suffix string) string {
p := fmt.Sprintf("/api/v1/teams/%d", teamID)
if suffix != "" {
p += suffix
}
return p
}
// ---------- 团队 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")
}
if _, e := a.requireSyncLogin(); e != nil {
return TeamInfo{}, e
}
var out TeamInfo
if e := a.apiDecode(http.MethodPost, "/api/v1/teams", map[string]string{"name": name}, &out, true); e != nil {
return TeamInfo{}, e
}
out.Current = true
_ = a.store.SetMeta("current_team_id", fmt.Sprint(out.ID))
a.store.Log("info", "团队", "团队创建成功", name)
return out, nil
}
func (a *App) TeamList() ([]TeamInfo, error) {
if _, e := a.requireSyncLogin(); e != nil {
return nil, e
}
var resp struct {
Items []TeamInfo `json:"items"`
}
if e := a.apiDecode(http.MethodGet, "/api/v1/teams", nil, &resp, true); e != nil {
return nil, e
}
out := resp.Items
if out == nil {
out = []TeamInfo{}
}
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 {
uid, e := a.requireSyncLogin()
if e != nil {
return e
}
teams, e := a.TeamList()
if e != nil {
return e
}
ok := false
for _, t := range teams {
if t.ID == teamID {
ok = true
break
}
}
if !ok {
return errors.New("TEAM_FORBIDDEN")
}
if e := a.store.SetMeta("current_team_id", fmt.Sprint(teamID)); e != nil {
return e
}
_ = uid
a.refreshTeamAssignedCache()
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")
}
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodPut, teamPath(teamID, ""), map[string]string{"name": name}, nil, true)
}
func (a *App) TeamSetDigestTime(teamID int64, at string) error {
if _, ok := parseClockOK(at); !ok {
return errors.New("TEAM_TIME_INVALID")
}
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodPut, teamPath(teamID, "/digest-time"), map[string]string{"digestTime": at}, nil, true)
}
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 {
uid, e := a.requireSyncLogin()
if e != nil {
return e
}
if e := a.apiDecode(http.MethodDelete, teamPath(teamID, ""), nil, nil, true); e != nil {
return e
}
if a.store.Meta("current_team_id") == fmt.Sprint(teamID) {
_ = a.store.SetMeta("current_team_id", "")
}
a.refreshTeamAssignedCache()
_ = uid
a.store.Log("warning", "团队", "团队已解散", fmt.Sprint(teamID))
return nil
}
func (a *App) TeamLeave(teamID int64) error {
uid, e := a.requireSyncLogin()
if e != nil {
return e
}
if e := a.apiDecode(http.MethodPost, teamPath(teamID, "/leave"), nil, nil, true); e != nil {
return e
}
if a.store.Meta("current_team_id") == fmt.Sprint(teamID) {
_ = a.store.SetMeta("current_team_id", "")
}
a.refreshTeamAssignedCache()
_ = uid
return nil
}
// ---------- 成员管理 ----------
func (a *App) TeamMembers(teamID int64) ([]TeamMember, error) {
if _, e := a.requireSyncLogin(); e != nil {
return nil, e
}
var resp struct {
Items []TeamMember `json:"items"`
}
if e := a.apiDecode(http.MethodGet, teamPath(teamID, "/members"), nil, &resp, true); e != nil {
return nil, e
}
if resp.Items == nil {
return []TeamMember{}, nil
}
for i := range resp.Items {
if resp.Items[i].TechTags == nil {
resp.Items[i].TechTags = []string{}
}
}
return resp.Items, nil
}
func (a *App) TeamInvite(teamID int64, username, role string) error {
if role != "member" && role != "admin" {
role = "member"
}
username = strings.TrimSpace(username)
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodPost, teamPath(teamID, "/invite"), map[string]string{
"username": username,
"role": role,
}, nil, true)
}
func (a *App) TeamSetRole(teamID, userID int64, role string) error {
if role != "member" && role != "admin" {
return errors.New("TEAM_ROLE_INVALID")
}
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodPut, teamPath(teamID, fmt.Sprintf("/members/%d/role", userID)),
map[string]string{"role": role}, nil, true)
}
func (a *App) TeamRemoveMember(teamID, userID int64) error {
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodDelete, teamPath(teamID, fmt.Sprintf("/members/%d", userID)), nil, nil, true)
}
// ---------- 团队任务 ----------
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
}
if _, e := a.requireSyncLogin(); e != nil {
return TeamTask{}, e
}
path := teamPath(t.TeamID, "/tasks")
method := http.MethodPost
if t.ID > 0 {
path = teamPath(t.TeamID, fmt.Sprintf("/tasks/%d", t.ID))
method = http.MethodPut
}
var out TeamTask
if e := a.apiDecode(method, path, t, &out, true); e != nil {
return TeamTask{}, e
}
a.refreshTeamAssignedCache()
return out, nil
}
func (a *App) TeamTaskSetStatus(teamID, id int64, status string) error {
switch status {
case "open", "doing", "done", "closed":
default:
return errors.New("TEAM_STATUS_INVALID")
}
if _, e := a.requireSyncLogin(); e != nil {
return e
}
if e := a.apiDecode(http.MethodPut, teamPath(teamID, fmt.Sprintf("/tasks/%d/status", id)),
map[string]string{"status": status}, nil, true); e != nil {
return e
}
a.refreshTeamAssignedCache()
return nil
}
func (a *App) TeamTaskUrge(teamID, id int64) error {
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodPost, teamPath(teamID, fmt.Sprintf("/tasks/%d/urge", id)), nil, nil, true)
}
// appendHistoryNode 追加一个生命周期节点(本地单测与兼容保留;服务端也会写 history
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 (a *App) TeamTaskDelete(teamID, id int64) error {
if _, e := a.requireSyncLogin(); e != nil {
return e
}
if e := a.apiDecode(http.MethodDelete, teamPath(teamID, fmt.Sprintf("/tasks/%d", id)), nil, nil, true); e != nil {
return e
}
a.refreshTeamAssignedCache()
return nil
}
// TeamTaskList 列出团队任务filter: all | mine | created | open。
func (a *App) TeamTaskList(teamID int64, filter string) ([]TeamTask, error) {
if _, e := a.requireSyncLogin(); e != nil {
return nil, e
}
q := teamPath(teamID, "/tasks")
if filter != "" {
q += "?filter=" + url.QueryEscape(filter)
}
var resp struct {
Items []TeamTask `json:"items"`
}
if e := a.apiDecode(http.MethodGet, q, nil, &resp, true); e != nil {
return nil, e
}
if resp.Items == nil {
return []TeamTask{}, nil
}
return resp.Items, nil
}
// ---------- 个人条目共享 ----------
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
}
func (a *App) TeamSharedItems(teamID int64) ([]TeamSharedItem, error) {
if _, e := a.requireSyncLogin(); e != nil {
return nil, e
}
var resp struct {
Items []TeamSharedItem `json:"items"`
}
if e := a.apiDecode(http.MethodGet, teamPath(teamID, "/shared"), nil, &resp, true); e != nil {
return nil, e
}
if resp.Items == nil {
return []TeamSharedItem{}, nil
}
return resp.Items, nil
}
func (a *App) TeamUrgeShared(teamID int64, kind, uuid string) error {
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodPost, teamPath(teamID, "/shared/urge"), map[string]string{
"kind": kind,
"uuid": uuid,
}, nil, true)
}
// ---------- 日报 ----------
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")
}
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodPost, teamPath(teamID, "/reports"), map[string]string{
"date": date,
"content": content,
}, nil, true)
}
func (a *App) TeamReportBoardGet(teamID int64, date string) (TeamReportBoard, error) {
if !teamDateRe.MatchString(date) {
return TeamReportBoard{}, errors.New("TEAM_DATE_INVALID")
}
if _, e := a.requireSyncLogin(); e != nil {
return TeamReportBoard{}, e
}
var board TeamReportBoard
if e := a.apiDecode(http.MethodGet, teamPath(teamID, "/reports/"+url.PathEscape(date)), nil, &board, true); e != nil {
return TeamReportBoard{}, e
}
if board.Reports == nil {
board.Reports = []TeamReport{}
}
if board.Missing == nil {
board.Missing = []TeamMember{}
}
if board.Digest != nil {
board.Digest.Content = stripFence(board.Digest.Content)
}
return board, nil
}
func (a *App) TeamReportUrge(teamID, userID int64, date string) error {
if !teamDateRe.MatchString(date) {
return errors.New("TEAM_DATE_INVALID")
}
if _, e := a.requireSyncLogin(); e != nil {
return e
}
return a.apiDecode(http.MethodPost, teamPath(teamID, "/reports/urge"), map[string]any{
"userId": userID,
"date": date,
}, nil, true)
}
// ---------- 日报 AI 摘要 ----------
type teamDigestEvent struct {
TeamID int64 `json:"teamId"`
Date string `json:"date"`
Error string `json:"error,omitempty"`
}
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
}
board, e := a.TeamReportBoardGet(teamID, date)
if e != nil {
return e
}
if teamRoleRank(board.Role) < teamRoleRank("admin") {
return errors.New("TEAM_FORBIDDEN")
}
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()
board, e := a.TeamReportBoardGet(teamID, date)
if e != nil {
emitErr(e.Error())
return
}
var sb strings.Builder
n := 0
for _, r := range board.Reports {
if strings.TrimSpace(r.Content) == "" {
continue
}
fmt.Fprintf(&sb, "### %s\n%s\n\n", r.User, r.Content)
n++
}
if n == 0 {
emitErr("TEAM_NO_REPORTS")
return
}
provider, e := a.aiProvider()
if e != nil {
emitErr(e.Error())
return
}
teamName := fmt.Sprintf("#%d", teamID)
if teams, le := a.TeamList(); le == nil {
for _, t := range teams {
if t.ID == teamID {
teamName = t.Name
break
}
}
}
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 := a.apiDecode(http.MethodPut, teamPath(teamID, "/digests/"+url.PathEscape(date)), map[string]string{
"content": content,
"provider": provider.Name(),
}, nil, true); e != nil {
emitErr(e.Error())
return
}
a.store.Log("info", "团队", "团队日报摘要已生成", fmt.Sprintf("team=%d date=%s", teamID, date))
a.emit("team:digest", teamDigestEvent{TeamID: teamID, Date: date})
}
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
}
teams, e := a.TeamList()
if e != nil {
return
}
today := now.Format("2006-01-02")
for _, t := range teams {
if teamRoleRank(t.Role) < teamRoleRank("admin") {
continue
}
if !service.DigestDue(t.DigestTime, now) {
continue
}
markKey := fmt.Sprintf("team_digest_auto_%d", t.ID)
if a.store.Meta(markKey) == today {
continue
}
board, e := a.TeamReportBoardGet(t.ID, today)
if e != nil {
continue
}
if board.Digest != nil && strings.TrimSpace(board.Digest.Content) != "" {
_ = a.store.SetMeta(markKey, today)
continue
}
if len(board.Reports) == 0 {
continue
}
if _, e := a.aiProvider(); e != nil {
continue
}
_ = a.store.SetMeta(markKey, today)
go a.generateTeamDigest(t.ID, today)
}
}
// refreshTeamAssignedCache 缓存"指派给我的未完成团队任务数"(导航徽标用)。
func (a *App) refreshTeamAssignedCache() {
n := 0
if teamID := a.currentTeamID(); teamID > 0 && a.syncUserID() > 0 && a.store.Meta("sync_access_token") != "" {
tasks, e := a.TeamTaskList(teamID, "mine")
if e == nil {
for _, t := range tasks {
if t.Status == "open" || t.Status == "doing" {
n++
}
}
}
}
_ = a.store.SetMeta("team_assigned_cache", fmt.Sprint(n))
}
// pullTeamNotices 增量拉取发给本人的团队通知并转成本地消息。
func (a *App) pullTeamNotices() int {
cursor := a.store.Meta("team_notice_cursor")
if cursor == "" {
cursor = "0"
}
var resp struct {
Items []struct {
ID int64 `json:"id"`
TeamID int64 `json:"teamId"`
Kind string `json:"kind"`
Content string `json:"content"`
} `json:"items"`
}
if e := a.apiDecode(http.MethodGet, "/api/v1/notices?after="+url.QueryEscape(cursor), nil, &resp, true); e != nil {
return 0
}
titles := map[string]string{
"assign": "团队任务指派", "urge": "团队催办", "report_urge": "日报催交",
"member": "团队成员变动", "role": "团队角色变更", "status": "团队任务动态",
}
n := 0
maxID := cursor
for _, it := range resp.Items {
title := titles[it.Kind]
if title == "" {
title = "团队通知"
}
a.pushMessage("team", title, it.Content, "team", it.TeamID, true)
maxID = fmt.Sprint(it.ID)
n++
}
if maxID != cursor {
_ = a.store.SetMeta("team_notice_cursor", maxID)
}
return n
}