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:
ABAPPLO
2026-09-04 18:03:46 +08:00
parent 5430624a43
commit dd5b603149
44 changed files with 4655 additions and 2039 deletions

View File

@@ -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 });
}