feat: 项目总群自动建群+任务负责人后台视角+客户群活码进群+任务编辑
- 项目总群:≥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 渲染/解绑清空)
This commit is contained in:
@@ -1,4 +1,14 @@
|
||||
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;
|
||||
@@ -6,6 +16,7 @@ export type ProjectRow = {
|
||||
brand: string;
|
||||
main_group_member_ids: string;
|
||||
owner_userid: string;
|
||||
main_chat_id: string;
|
||||
status: string;
|
||||
note: string;
|
||||
created_at: string;
|
||||
@@ -20,8 +31,8 @@ export type ProjectSummary = {
|
||||
createdAt: string;
|
||||
mainGroupMemberIds: string[];
|
||||
ownerUserid: string;
|
||||
mainChatId: string;
|
||||
taskCount: number;
|
||||
groupChatCount: number;
|
||||
};
|
||||
|
||||
export type ProjectTaskRow = {
|
||||
@@ -36,23 +47,31 @@ export type ProjectTaskRow = {
|
||||
platform: string;
|
||||
content_format: string;
|
||||
share_token: string | null;
|
||||
owner_userid: string;
|
||||
task_chat_id: string;
|
||||
};
|
||||
|
||||
export type ProjectGroupChat = {
|
||||
export type ProjectMainChat = {
|
||||
chatId: string;
|
||||
name: string;
|
||||
ownerUserId: string;
|
||||
memberCount: number;
|
||||
status: number;
|
||||
partnerId: string;
|
||||
partnerName: string;
|
||||
missing: boolean;
|
||||
members: string[];
|
||||
};
|
||||
|
||||
export type ProjectDetail = {
|
||||
project: ProjectSummary;
|
||||
tasks: ProjectTaskRow[];
|
||||
groupChats: ProjectGroupChat[];
|
||||
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 = {
|
||||
@@ -94,16 +113,126 @@ function parseJsonArray(value: string): string[] {
|
||||
}
|
||||
}
|
||||
|
||||
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.status, p.note, p.created_at,
|
||||
(SELECT COUNT(*) FROM tasks t WHERE t.project_id = p.id) AS task_count,
|
||||
(SELECT COUNT(*) FROM project_group_chats g WHERE g.project_id = p.id) AS group_chat_count
|
||||
`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; group_chat_count: number }>();
|
||||
.all<ProjectRow & { task_count: number }>();
|
||||
return result.results.map((row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
@@ -113,8 +242,8 @@ export async function listProjects(db: DatabaseClient): Promise<ProjectSummary[]
|
||||
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),
|
||||
groupChatCount: Number(row.group_chat_count),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -125,7 +254,7 @@ export async function getProjectDetail(
|
||||
const id = text(projectId);
|
||||
const project = await db
|
||||
.prepare(
|
||||
`SELECT id, name, brand, main_group_member_ids, owner_userid, status, note, created_at
|
||||
`SELECT id, name, brand, main_group_member_ids, owner_userid, main_chat_id, status, note, created_at
|
||||
FROM projects WHERE id = ?`,
|
||||
)
|
||||
.bind(id)
|
||||
@@ -135,35 +264,13 @@ export async function getProjectDetail(
|
||||
const tasks = await db
|
||||
.prepare(
|
||||
`SELECT id, name, brand, quantity, claimed_quantity, due_at, status,
|
||||
task_type, platform, content_format, share_token
|
||||
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 chats = await db
|
||||
.prepare(
|
||||
`SELECT g.chat_id, g.partner_id,
|
||||
c.name AS chat_name, c.owner_user_id, c.member_count, c.status AS chat_status,
|
||||
p.name AS partner_name
|
||||
FROM project_group_chats g
|
||||
LEFT JOIN wecom_group_chats c ON c.chat_id = g.chat_id
|
||||
LEFT JOIN partners p ON p.id = g.partner_id
|
||||
WHERE g.project_id = ?
|
||||
ORDER BY g.created_at`,
|
||||
)
|
||||
.bind(id)
|
||||
.all<{
|
||||
chat_id: string;
|
||||
partner_id: string;
|
||||
chat_name: string | null;
|
||||
owner_user_id: string | null;
|
||||
member_count: number | null;
|
||||
chat_status: number | null;
|
||||
partner_name: string | null;
|
||||
}>();
|
||||
|
||||
return {
|
||||
project: {
|
||||
id: project.id,
|
||||
@@ -174,20 +281,17 @@ export async function getProjectDetail(
|
||||
createdAt: project.created_at,
|
||||
mainGroupMemberIds: parseJsonArray(project.main_group_member_ids),
|
||||
ownerUserid: project.owner_userid ?? "",
|
||||
mainChatId: project.main_chat_id ?? "",
|
||||
taskCount: tasks.results.length,
|
||||
groupChatCount: chats.results.length,
|
||||
},
|
||||
tasks: tasks.results,
|
||||
groupChats: chats.results.map((row) => ({
|
||||
chatId: row.chat_id,
|
||||
name: row.chat_name ?? "",
|
||||
ownerUserId: row.owner_user_id ?? "",
|
||||
memberCount: Number(row.member_count ?? 0),
|
||||
status: Number(row.chat_status ?? 0),
|
||||
partnerId: row.partner_id,
|
||||
partnerName: row.partner_name ?? "",
|
||||
missing: row.chat_name === null,
|
||||
})),
|
||||
mainChat: {
|
||||
chatId: project.main_chat_id ?? "",
|
||||
members: effectiveProjectMembers(
|
||||
project.main_group_member_ids,
|
||||
project.owner_userid,
|
||||
),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -203,13 +307,15 @@ export type SaveProjectInput = {
|
||||
export async function saveProject(
|
||||
db: DatabaseClient,
|
||||
input: SaveProjectInput,
|
||||
): Promise<ProjectSummary> {
|
||||
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 memberIds = parseMemberIds(input.mainGroupMemberIds);
|
||||
const ownerUserid = text(input.ownerUserid).slice(0, 64);
|
||||
const memberIds = effectiveProjectMembers(input.mainGroupMemberIds, ownerUserid);
|
||||
if (!name) throw new ProjectError("请填写项目名称");
|
||||
|
||||
const now = new Date().toISOString();
|
||||
@@ -243,8 +349,15 @@ export async function saveProject(
|
||||
)
|
||||
.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 detail.project;
|
||||
return { project: detail.project, mainChat };
|
||||
}
|
||||
|
||||
export async function deleteProject(
|
||||
@@ -258,74 +371,10 @@ export async function deleteProject(
|
||||
.bind(id)
|
||||
.run();
|
||||
if (result.meta.changes === 0) throw new ProjectError("项目不存在", 404);
|
||||
await db.batch([
|
||||
db.prepare("DELETE FROM project_group_chats WHERE project_id = ?").bind(id),
|
||||
db
|
||||
.prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?")
|
||||
.bind(id),
|
||||
]);
|
||||
}
|
||||
|
||||
export type ProjectGroupChatInput = {
|
||||
chatId: string;
|
||||
partnerId?: string;
|
||||
};
|
||||
|
||||
export async function setProjectGroupChats(
|
||||
db: DatabaseClient,
|
||||
projectId: string,
|
||||
items: ProjectGroupChatInput[],
|
||||
): Promise<ProjectDetail> {
|
||||
const id = text(projectId);
|
||||
await getProjectDetail(db, id);
|
||||
const seen = new Set<string>();
|
||||
const rows: ProjectGroupChatInput[] = [];
|
||||
for (const item of Array.isArray(items) ? items : []) {
|
||||
const chatId = text(item?.chatId);
|
||||
if (!chatId || seen.has(chatId)) continue;
|
||||
seen.add(chatId);
|
||||
rows.push({ chatId, partnerId: text(item?.partnerId) });
|
||||
}
|
||||
const statements = [
|
||||
db.prepare("DELETE FROM project_group_chats WHERE project_id = ?").bind(id),
|
||||
...rows.map((row) =>
|
||||
db
|
||||
.prepare(
|
||||
`INSERT INTO project_group_chats
|
||||
(id, project_id, chat_id, partner_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
`pgc-${crypto.randomUUID().slice(0, 12)}`,
|
||||
id,
|
||||
row.chatId,
|
||||
row.partnerId ?? "",
|
||||
new Date().toISOString(),
|
||||
),
|
||||
),
|
||||
];
|
||||
await db.batch(statements);
|
||||
return getProjectDetail(db, id);
|
||||
}
|
||||
|
||||
export type ProjectPushTargets = {
|
||||
project: { id: string; name: string; mainGroupMemberIds: string[] };
|
||||
chatIds: string[];
|
||||
};
|
||||
|
||||
export async function getProjectPushTargets(
|
||||
db: DatabaseClient,
|
||||
projectId: string,
|
||||
): Promise<ProjectPushTargets> {
|
||||
const detail = await getProjectDetail(db, projectId);
|
||||
return {
|
||||
project: {
|
||||
id: detail.project.id,
|
||||
name: detail.project.name,
|
||||
mainGroupMemberIds: detail.project.mainGroupMemberIds,
|
||||
},
|
||||
chatIds: detail.groupChats.filter((chat) => !chat.missing).map((chat) => chat.chatId),
|
||||
};
|
||||
await db
|
||||
.prepare("UPDATE tasks SET project_id = '' WHERE project_id = ?")
|
||||
.bind(id)
|
||||
.run();
|
||||
}
|
||||
|
||||
export async function listGroupOwnerPartners(
|
||||
@@ -390,3 +439,23 @@ export async function saveGroupOwnerPartner(
|
||||
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 });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user