- 项目总群:≥3 人 appchat/create 自动建群(chatid=pgc+projectId),成员 diff 增删同步;DROP project_group_chats;推送改 appchat/send 群发、无群降级逐人 - 任务负责人:tasks.owner_userid(新建/编辑任务通讯录选人),task_set_owner 分配即预建 tgc 任务群发介绍,群主链接只归属负责人 - 任务编辑 task_update:基础字段+截图任务数量/关键词/说明,contents 调平只删 available 尾行 - 删除「一键推送」:task-one-click-push-service 整体移除(recordCardAck 移入 wecom-callback-events) - 负责人后台视角:users.wecom_userid 绑定,lib/owner-scope 行级过滤(undefined=全量/""=空范围/非空=过滤),~30 action 补 managerGate/taskOwnerGate,owner 登录仅见工作台/任务中心/数据回收/复盘报表 - 任务绑定客户群+进群活码:tasks 增 customer_chat_id/name、join_config_id、join_qr_code 4 列,join_way add/update/get/del(换绑走 update 保 config_id 活码不变),后台「客户群」弹窗,portal 认领后扫码自助进群(满员自动开新群) - 迁移 0013/0014/0015;催办支持按任务;企微可信域名 IP 验证文件 - 288 测试全绿;真机全链路验收通过(列群/活码四端点/portal 渲染/解绑清空)
462 lines
12 KiB
TypeScript
462 lines
12 KiB
TypeScript
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;
|
||
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;
|
||
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;
|
||
};
|
||
|
||
export type ProjectMainChat = {
|
||
chatId: string;
|
||
members: string[];
|
||
};
|
||
|
||
export type ProjectDetail = {
|
||
project: ProjectSummary;
|
||
tasks: ProjectTaskRow[];
|
||
mainChat: ProjectMainChat;
|
||
};
|
||
|
||
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)}`,
|
||
};
|
||
}
|
||
}
|
||
|
||
export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]> {
|
||
const result = await db
|
||
.prepare(
|
||
`SELECT p.id, p.name, p.brand, p.main_group_member_ids, p.owner_userid, p.main_chat_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 ?? "",
|
||
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, 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>();
|
||
|
||
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 ?? "",
|
||
taskCount: tasks.results.length,
|
||
},
|
||
tasks: tasks.results,
|
||
mainChat: {
|
||
chatId: project.main_chat_id ?? "",
|
||
members: effectiveProjectMembers(
|
||
project.main_group_member_ids,
|
||
project.owner_userid,
|
||
),
|
||
},
|
||
};
|
||
}
|
||
|
||
export type SaveProjectInput = {
|
||
id?: unknown;
|
||
name?: unknown;
|
||
brand?: unknown;
|
||
mainGroupMemberIds?: unknown;
|
||
ownerUserid?: unknown;
|
||
note?: 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 memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
|
||
if (!name) 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 = ?
|
||
WHERE id = ?`,
|
||
)
|
||
.bind(name, brand, JSON.stringify(memberIds), ownerUserid, note, 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, status, note, created_at)
|
||
VALUES (?, ?, ?, ?, ?, 'active', ?, ?)`,
|
||
)
|
||
.bind(
|
||
projectId,
|
||
name,
|
||
brand,
|
||
JSON.stringify(memberIds),
|
||
ownerUserid,
|
||
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 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 });
|
||
}
|