Files
koc-loop/lib/project-service.ts
ABAPPLO e2a93e22cf feat: 任务模型物理合并,发布+回收并为单任务双阶段
- tasks 新增 recovery_enabled/recovery_due_at/recovery_instructions/recovery_example_images,回收挂自己名下(先发布后交截图),不再置 complete
- 存量 screenshot_collect 迁移并入父任务后删除(mysql/0020 含 DELETE,生产执行前务必备份)
- 新 action task_enable_recovery:ghost 面板一键开通回收,截止默认=发布截止
- 企微催办双阶段(待发布/截图待提交);超期自动释放仅发布阶段
- admin 删 groupTaskMasters 聚合改单卡双面板;portal 详情页回收截图区块+批次回收进度
- 测试 309 全绿 + Playwright E2E 20/20
2026-09-24 15:52:45 +08:00

485 lines
13 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;
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;
recovery_enabled: number;
recovery_due_at: string;
recovery_submitted: number;
};
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,
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.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.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,
recovery_enabled, recovery_due_at,
(SELECT COUNT(*) FROM distributions d WHERE d.task_id = t.id AND d.result_submitted_at IS NOT NULL) AS recovery_submitted
FROM tasks t WHERE t.project_id = ?
ORDER BY t.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 });
}