Files
koc-loop/lib/project-service.ts
ABAPPLO ab78b187c0 feat: 项目层级树+合作方绑定KOC资源+客户群全量可选+精简编辑表单,移除复盘报表
- 项目支持父子层级:树形折叠展示、详情面板内联管理总群成员、推送聚合子项目任务、环检测与子项目删除守卫
- 外部KOC合作方绑定 KOC 资源账号(唯一占用、换绑/解绑、资源页一键设为合作方),列表直显联系方式
- 绑定客户群改为全量外部群缓存(含各群主),同步按钮强刷;member_count 缺失以 member_list 兜底
- 任务/项目编辑模式隐藏不可变字段(平台/内容类型/品牌/总群成员),详情面板维护成员时保留 parent_id
- 删除复盘报表整栏(导航/页面/task_report_list/导出路由/CSV lib 及测试)
2026-09-23 11:13:17 +08:00

608 lines
17 KiB
TypeScript
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.
import type { DatabaseClient } from "./database";
import {
createAppChat,
getAppChat,
hasAppCredentials,
sanitizeChatid,
updateAppChat,
type WecomConfig,
} from "./wecom-client";
type FetchLike = typeof fetch;
export type ProjectRow = {
id: string;
name: string;
brand: string;
main_group_member_ids: string;
owner_userid: string;
main_chat_id: string;
parent_id: string;
status: string;
note: string;
created_at: string;
};
export type ProjectSummary = {
id: string;
name: string;
brand: string;
status: string;
note: string;
createdAt: string;
mainGroupMemberIds: string[];
ownerUserid: string;
mainChatId: string;
parentProjectId: string;
taskCount: number;
};
export type ProjectTaskRow = {
id: string;
name: string;
brand: string;
quantity: number;
claimed_quantity: number;
due_at: string;
status: string;
task_type: string;
platform: string;
content_format: string;
share_token: string | null;
owner_userid: string;
task_chat_id: string;
project_name?: string;
};
export type ProjectSubproject = {
id: string;
name: string;
brand: string;
ownerUserid: string;
mainChatId: string;
taskCount: number;
};
export type ProjectMainChat = {
chatId: string;
members: string[];
};
export type ProjectDetail = {
project: ProjectSummary;
tasks: ProjectTaskRow[];
mainChat: ProjectMainChat;
subprojects: ProjectSubproject[];
pushTasks: ProjectTaskRow[];
};
export type MainChatEnsureReason =
| "no_project"
| "no_credentials"
| "not_enough_members";
export type MainChatEnsureResult = {
chatId: string | null;
members: string[];
reason?: MainChatEnsureReason;
warning?: string;
};
export type GroupOwnerPartnerRow = {
id: string;
name: string;
wecom_userid: string;
partner_type: string;
};
function text(value: unknown) {
return String(value ?? "").trim();
}
export class ProjectError extends Error {
status: number;
constructor(message: string, status = 400) {
super(message);
this.status = status;
}
}
export function parseMemberIds(value: unknown): string[] {
const raw = Array.isArray(value)
? value.map((item) => text(item))
: text(value).split(/[\s,;,;]+/);
const seen = new Set<string>();
for (const item of raw) {
if (item) seen.add(item);
}
return [...seen];
}
function parseJsonArray(value: string): string[] {
try {
const parsed = JSON.parse(value);
return Array.isArray(parsed) ? parsed.map((item) => String(item)) : [];
} catch {
return [];
}
}
export function effectiveProjectMembers(
memberIdsValue: unknown,
ownerUserid: unknown,
): string[] {
const raw =
typeof memberIdsValue === "string" && memberIdsValue.trim().startsWith("[")
? parseJsonArray(memberIdsValue)
: memberIdsValue;
const members = parseMemberIds(raw);
const owner = text(ownerUserid);
if (owner && !members.includes(owner)) members.push(owner);
return members;
}
export function projectMainChatId(projectId: string) {
return `pgc${sanitizeChatid(text(projectId))}`;
}
export async function ensureProjectMainChat(
db: DatabaseClient,
projectId: string,
config: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<MainChatEnsureResult> {
const id = text(projectId);
if (!id) return { chatId: null, members: [], reason: "no_project" };
const project = await db
.prepare(
"SELECT id, name, main_group_member_ids, owner_userid, main_chat_id FROM projects WHERE id = ?",
)
.bind(id)
.first<ProjectRow>();
if (!project) return { chatId: null, members: [], reason: "no_project" };
const members = effectiveProjectMembers(
project.main_group_member_ids,
project.owner_userid,
);
const existingChatId = text(project.main_chat_id);
if (!hasAppCredentials(config)) {
return {
chatId: existingChatId || null,
members,
reason: existingChatId ? undefined : "no_credentials",
};
}
if (existingChatId) {
try {
const info = await getAppChat(config, existingChatId, fetchImpl);
if (info) {
const inGroup = new Set(info.userlist);
const addUserList = members.filter((userid) => !inGroup.has(userid));
const delUserList = info.userlist.filter(
(userid) => !members.includes(userid),
);
if (addUserList.length > 0 || delUserList.length > 0) {
await updateAppChat(
config,
{ chatid: existingChatId, addUserList, delUserList },
fetchImpl,
);
}
} else {
// appchat/get 要求应用可见范围为根部门,查不到成员时降级为只增同步(重复添加按成功处理)
await updateAppChat(
config,
{ chatid: existingChatId, addUserList: members },
fetchImpl,
);
}
return { chatId: existingChatId, members };
} catch (error) {
return {
chatId: existingChatId,
members,
warning: `项目总群成员同步失败:${error instanceof Error ? error.message : String(error)}`,
};
}
}
if (members.length < 3) {
return { chatId: null, members, reason: "not_enough_members" };
}
const candidate = projectMainChatId(id);
try {
const probed = await getAppChat(config, candidate, fetchImpl);
let chatId = candidate;
if (!probed) {
const created = await createAppChat(
config,
{ name: `【总群】${project.name}`, userlist: members, chatid: candidate },
fetchImpl,
);
chatId = created.chatid || candidate;
}
await db
.prepare("UPDATE projects SET main_chat_id = ? WHERE id = ?")
.bind(chatId, id)
.run();
return { chatId, members };
} catch (error) {
return {
chatId: null,
members,
warning: `项目总群创建失败:${error instanceof Error ? error.message : String(error)}`,
};
}
}
async function loadProjectParents(
db: DatabaseClient,
): Promise<Map<string, string>> {
const result = await db
.prepare("SELECT id, parent_id FROM projects")
.all<{ id: string; parent_id: string }>();
return new Map(result.results.map((row) => [row.id, text(row.parent_id)]));
}
// candidate 是否位于 ancestor 的子孙链上(沿 candidate 的祖先链向上找 ancestor)
function isDescendantProject(
parents: Map<string, string>,
ancestorId: string,
candidateId: string,
): boolean {
let current = candidateId;
const seen = new Set<string>();
while (current) {
if (current === ancestorId) return true;
if (seen.has(current)) return false;
seen.add(current);
current = parents.get(current) ?? "";
}
return false;
}
export async function collectDescendantIds(
db: DatabaseClient,
projectId: string,
): Promise<string[]> {
const parents = await loadProjectParents(db);
const childrenOf = new Map<string, string[]>();
for (const [id, parent] of parents) {
if (!parent) continue;
const list = childrenOf.get(parent) ?? [];
list.push(id);
childrenOf.set(parent, list);
}
const descendants: string[] = [];
const queue = [text(projectId)];
const visited = new Set(queue);
while (queue.length > 0) {
const current = queue.shift() as string;
for (const child of childrenOf.get(current) ?? []) {
if (visited.has(child)) continue;
visited.add(child);
descendants.push(child);
queue.push(child);
}
}
return descendants;
}
export async function listProjects(
db: DatabaseClient,
ownerUserid?: string,
): Promise<ProjectSummary[]> {
// undefined=全量;""=已登录未绑定企微(空范围,不能绑定 '' 否则漏出无主项目)
if (ownerUserid !== undefined && String(ownerUserid).trim() === "") {
return [];
}
const ownerFilter = String(ownerUserid ?? "").trim();
const result = await (ownerFilter
? db
.prepare(
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p
WHERE p.owner_userid = ?
ORDER BY p.created_at DESC`,
)
.bind(ownerFilter)
: db
.prepare(
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_id, p.parent_id, p.status, p.note, p.created_at,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p
ORDER BY p.created_at DESC`,
)
).all<ProjectRow & { task_count: number }>();
return result.results.map((row) => ({
id: row.id,
name: row.name,
brand: row.brand,
status: row.status,
note: row.note,
createdAt: row.created_at,
mainGroupMemberIds: parseJsonArray(row.main_group_member_ids),
ownerUserid: row.owner_userid ?? "",
mainChatId: row.main_chat_id ?? "",
parentProjectId: row.parent_id ?? "",
taskCount: Number(row.task_count),
}));
}
export async function getProjectDetail(
db: DatabaseClient,
projectId: string,
): Promise<ProjectDetail> {
const id = text(projectId);
const project = await db
.prepare(
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, parent_id, status, note, created_at
FROM projects WHERE id = ?`,
)
.bind(id)
.first<ProjectRow>();
if (!project) throw new ProjectError("项目不存在", 404);
const tasks = await db
.prepare(
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
task_type, platform, content_format, share_token, owner_userid, task_chat_id
FROM tasks WHERE project_id = ?
ORDER BY created_at DESC`,
)
.bind(id)
.all<ProjectTaskRow>();
const subprojects = await db
.prepare(
`SELECT p.id, p.name, p.brand, p.owner_userid, p.main_chat_id,
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count
FROM projects p
WHERE p.parent_id = ?
ORDER BY p.created_at DESC`,
)
.bind(id)
.all<ProjectRow & { task_count: number }>();
const descendantIds = await collectDescendantIds(db, id);
const pushIds = [id, ...descendantIds];
const placeholders = pushIds.map(() => "?").join(", ");
const pushTasks = await db
.prepare(
`SELECT t.id, t.name, t.brand, t.quantity, t.claimed_quantity, t.due_at, t.status,
t.task_type, t.platform, t.content_format, t.share_token, t.owner_userid, t.task_chat_id,
pj.name AS project_name
FROM tasks t
LEFT JOIN projects pj ON pj.id = t.project_id
WHERE t.project_id IN (${placeholders})
ORDER BY t.created_at DESC`,
)
.bind(...pushIds)
.all<ProjectTaskRow>();
return {
project: {
id: project.id,
name: project.name,
brand: project.brand,
status: project.status,
note: project.note,
createdAt: project.created_at,
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
ownerUserid: project.owner_userid ?? "",
mainChatId: project.main_chat_id ?? "",
parentProjectId: project.parent_id ?? "",
taskCount: tasks.results.length,
},
tasks: tasks.results,
mainChat: {
chatId: project.main_chat_id ?? "",
members: effectiveProjectMembers(
project.main_group_member_ids,
project.owner_userid,
),
},
subprojects: subprojects.results.map((row) => ({
id: row.id,
name: row.name,
brand: row.brand,
ownerUserid: row.owner_userid ?? "",
mainChatId: row.main_chat_id ?? "",
taskCount: Number(row.task_count),
})),
pushTasks: pushTasks.results,
};
}
export type SaveProjectInput = {
id?: unknown;
name?: unknown;
brand?: unknown;
mainGroupMemberIds?: unknown;
ownerUserid?: unknown;
note?: unknown;
parentProjectId?: unknown;
};
export async function saveProject(
db: DatabaseClient,
input: SaveProjectInput,
config?: WecomConfig,
fetchImpl: FetchLike = fetch,
): Promise<{ project: ProjectSummary; mainChat: MainChatEnsureResult }> {
const id = text(input.id);
const name = text(input.name);
const brand = text(input.brand);
const note = text(input.note ?? "");
const ownerUserid = text(input.ownerUserid).slice(0, 64);
const parentProjectId = text(input.parentProjectId);
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
if (!name) throw new ProjectError("请填写项目名称");
if (parentProjectId) {
if (parentProjectId === id) {
throw new ProjectError("上级项目不能是项目自身");
}
const parents = await loadProjectParents(db);
if (!parents.has(parentProjectId)) {
throw new ProjectError("上级项目不存在", 404);
}
if (id && isDescendantProject(parents, id, parentProjectId)) {
throw new ProjectError("上级项目不能是当前项目的子项目");
}
}
const now = new Date().toISOString();
let projectId = id;
if (id) {
const result = await db
.prepare(
`UPDATE projects
SET name = ?, brand = ?, main_group_member_ids = ?, owner_userid = ?, note = ?, parent_id = ?
WHERE id = ?`,
)
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, parentProjectId, id)
.run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
} else {
projectId = `proj-${crypto.randomUUID().slice(0, 12)}`;
await db
.prepare(
`INSERT INTO projects
(id, name, brand, main_group_member_ids, owner_userid, parent_id, status, note, created_at)
VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?)`,
)
.bind(
projectId,
name,
brand,
JSON.stringify(memberIds),
ownerUserid,
parentProjectId,
note,
now,
)
.run();
}
const mainChat = config
? await ensureProjectMainChat(db, projectId, config, fetchImpl)
: {
chatId: null,
members: memberIds,
reason: "no_credentials" as MainChatEnsureReason,
};
const detail = await getProjectDetail(db, projectId);
return { project: detail.project, mainChat };
}
export async function deleteProject(
db: DatabaseClient,
projectId: string,
): Promise<void> {
const id = text(projectId);
if (!id) throw new ProjectError("缺少项目 ID");
const childCount = await db
.prepare("SELECT COUNT(*) AS count FROM projects WHERE parent_id = ?")
.bind(id)
.first<{ count: number }>();
if (Number(childCount?.count ?? 0) > 0) {
throw new ProjectError("该项目下还有子项目,请先删除子项目或把它们移到其他项目下");
}
const result = await db
.prepare("DELETE FROM projects WHERE id = ?")
.bind(id)
.run();
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
await db
.prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?")
.bind(id)
.run();
}
export async function listGroupOwnerPartners(
db: DatabaseClient,
): Promise<GroupOwnerPartnerRow[]> {
const result = await db
.prepare(
`SELECT id, name, wecom_userid, partner_type
FROM partners
WHERE partner_type = 'group_owner'
ORDER BY created_at DESC`,
)
.all<GroupOwnerPartnerRow>();
return result.results;
}
export type SavePartnerInput = {
id?: unknown;
name?: unknown;
wecomUserid?: unknown;
};
export async function saveGroupOwnerPartner(
db: DatabaseClient,
input: SavePartnerInput,
): Promise<GroupOwnerPartnerRow> {
const id = text(input.id);
const name = text(input.name);
const wecomUserid = text(input.wecomUserid);
if (!name) throw new ProjectError("请填写合作方名称");
if (!wecomUserid) throw new ProjectError("请填写企业微信 userid");
let partnerId = id;
if (id) {
const result = await db
.prepare(
`UPDATE partners
SET name = ?, wecom_userid = ?, partner_type = 'group_owner'
WHERE id = ?`,
)
.bind(name, wecomUserid, id)
.run();
if (result.meta.changes === 0) throw new ProjectError("合作方不存在", 404);
} else {
partnerId = `partner-go-${crypto.randomUUID().slice(0, 12)}`;
await db
.prepare(
`INSERT INTO partners
(id, name, wecom_name, owner, claimed_total, completed_total,
partner_type, wecom_userid, created_at)
VALUES (?, ?, '', '运营组', 0, 0, 'group_owner', ?, ?)`,
)
.bind(partnerId, name, wecomUserid, new Date().toISOString())
.run();
}
const row = await db
.prepare(
"SELECT id, name, wecom_userid, partner_type FROM partners WHERE id = ?",
)
.bind(partnerId)
.first<GroupOwnerPartnerRow>();
if (!row) throw new ProjectError("合作方保存失败", 500);
return row;
}
export async function findOrCreateGroupOwnerPartner(
db: DatabaseClient,
wecomUserid: string,
): Promise<GroupOwnerPartnerRow> {
const userid = text(wecomUserid).slice(0, 128);
if (!userid) throw new ProjectError("缺少企业微信 userid");
const existing = await db
.prepare(
`SELECT id, name, wecom_userid, partner_type
FROM partners
WHERE partner_type = 'group_owner' AND wecom_userid = ?
ORDER BY created_at
LIMIT 1`,
)
.bind(userid)
.first<GroupOwnerPartnerRow>();
if (existing) return existing;
return saveGroupOwnerPartner(db, { name: userid, wecomUserid: userid });
}