Files
koc-loop/lib/project-service.ts
ABAPPLO 2db06063ee fix: 采集失败不降级有数据分行+任务领取计数对齐实际分发行数
- 采集失败时按 latest_likes 分流:无数据才标 failed,已有历史数据保持
  success 并注明沿用上次数据,collection_updated_at 不动;run 行照实记失败
- 种子 seedIfEmpty 末尾统一对账 claimed_quantity,杜绝演示数据口径漂移
- 新增 mysql/0021 对账迁移,修复 0020 物理合并遗留的生产计数漂移
- 运营端展示全面改读实时 dist_total(任务卡双面板/项目树/概览/进度),
  project-service 查询补 dist_total 子查询
- 走查实证:卡片 7/18 vs 详情已领取 2 两处口径打架;保存采集计划补采
  失败会把历史成功行改写为 failed
2026-10-08 14:30:36 +08:00

487 lines
14 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;
dist_total: number;
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) AS dist_total,
(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 });
}