package main // team.go 团队协作:团队/成员/角色、团队任务、日报与 AI 摘要、团队通知。 // 团队数据经 nl-pms-api(JWT);通知在同步循环里增量拉取后转本地消息。 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 }