2026-08-07 14:12:52 +08:00
import { enrichDistributionAccount } from "./account-enrichment-service" ;
import {
collectDistributionMetrics ,
createCollectionRunTasks ,
retryFailedCollections ,
runDueScheduledCollections ,
shanghaiDateFromTimestamp ,
} from "./collection-service" ;
import { issueMcpExportToken } from "./mcp-export-token" ;
import {
resolveCollectionMcpConfig ,
type CollectionMcpBindings ,
} from "./mcp-collection-client" ;
import { ensureSchema , getRawDb } from "./mvp-db" ;
2026-08-15 03:53:09 +08:00
import { extractAnyPublishUrl } from "./publish-url" ;
2026-08-07 14:12:52 +08:00
import { buildClaimUrl } from "./task-service" ;
export type McpOperationBindings = CollectionMcpBindings & {
KOC_PORTAL_URL? : string ;
} ;
type Pagination = { limit? : number ; offset? : number } ;
type ResourceFilters = Pagination & {
query? : string ;
ipLocation? : string ;
cooperationSource? : string ;
platform? : string ;
} ;
function page ( input : Pagination ) {
return {
limit : Math.max ( 1 , Math . min ( 200 , Math . floor ( input . limit ? ? 50 ) ) ) ,
offset : Math.max ( 0 , Math . floor ( input . offset ? ? 0 ) ) ,
} ;
}
function like ( value : string ) {
return ` % ${ value . replace ( /[\\%_]/g , "\\$&" ) } % ` ;
}
function parseJsonArray ( value : unknown ) {
try {
const parsed = JSON . parse ( String ( value ? ? "[]" ) ) ;
return Array . isArray ( parsed ) ? parsed : [ ] ;
} catch {
return [ ] ;
}
}
async function taskExists ( taskId : string ) {
const task = await getRawDb ( )
. prepare ( "SELECT id FROM tasks WHERE id = ?" )
. bind ( taskId )
. first < { id : string } > ( ) ;
if ( ! task ) throw new Error ( "任务不存在" ) ;
}
export async function taskList (
input : Pagination & { query? : string ; status? : string } ,
portalUrl : string ,
) {
await ensureSchema ( ) ;
const { limit , offset } = page ( input ) ;
const conditions : string [ ] = [ ] ;
const bindings : unknown [ ] = [ ] ;
if ( input . query ? . trim ( ) ) {
conditions . push ( "(t.name LIKE ? ESCAPE '\\' OR t.brand LIKE ? ESCAPE '\\')" ) ;
const pattern = like ( input . query . trim ( ) ) ;
bindings . push ( pattern , pattern ) ;
}
if ( input . status ? . trim ( ) && input . status !== "all" ) {
conditions . push ( "t.status = ?" ) ;
bindings . push ( input . status . trim ( ) ) ;
}
const where = conditions . length ? ` WHERE ${ conditions . join ( " AND " ) } ` : "" ;
const db = getRawDb ( ) ;
const [ rows , count ] = await Promise . all ( [
db
. prepare (
` SELECT t.*,
( SELECT COUNT ( * ) FROM contents c WHERE c . task_id = t . id ) AS note_count ,
( SELECT COUNT ( * ) FROM distributions d WHERE d . task_id = t . id ) AS claimed_count ,
( SELECT COUNT ( * ) FROM distributions d WHERE d . task_id = t . id AND COALESCE ( d . publish_url , '' ) != '' ) AS published_count ,
( SELECT COUNT ( * ) FROM distributions d WHERE d . task_id = t . id AND d . exposure IS NOT NULL AND d . views IS NOT NULL ) AS day7_count
FROM tasks t $ { where }
2026-08-11 23:07:26 +08:00
ORDER BY t . created_at DESC LIMIT $ { limit } OFFSET $ { offset } ` ,
2026-08-07 14:12:52 +08:00
)
2026-08-11 23:07:26 +08:00
. bind ( . . . bindings )
2026-08-07 14:12:52 +08:00
. all < Record < string , unknown > > ( ) ,
db
. prepare ( ` SELECT COUNT(*) AS total FROM tasks t ${ where } ` )
. bind ( . . . bindings )
. first < { total : number } > ( ) ,
] ) ;
return {
total : count?.total ? ? 0 ,
limit ,
offset ,
2026-08-11 23:07:26 +08:00
tasks : rows.results.map (
( row ) : Record < string , unknown > & {
collection_days : unknown [ ] ;
claim_url : string | null ;
} = > ( {
. . . row ,
collection_days : parseJsonArray ( row . collection_days ) ,
claim_url :
portalUrl && row . share_token
? buildClaimUrl ( portalUrl , String ( row . share_token ) )
: null ,
} ) ,
) ,
2026-08-07 14:12:52 +08:00
} ;
}
export async function taskGet ( taskId : string , portalUrl : string ) {
await ensureSchema ( ) ;
const db = getRawDb ( ) ;
const task = await db
. prepare ( "SELECT * FROM tasks WHERE id = ?" )
. bind ( taskId )
. first < Record < string , unknown > > ( ) ;
if ( ! task ) throw new Error ( "任务不存在" ) ;
const [ notes , claims , runs ] = await Promise . all ( [
db
. prepare (
2026-08-15 03:53:09 +08:00
` SELECT c.id AS content_id, c.source_row, c.title, c.body, c.image_assets, c.video_assets, c.status AS content_status,
2026-08-07 14:12:52 +08:00
d . id AS distribution_id , d . status AS distribution_status , d . publish_url , d . publish_time ,
2026-08-15 03:53:09 +08:00
d . exposure , d . views , d . latest_likes , d . latest_comments , d . latest_collects , d . latest_shares ,
2026-08-07 14:12:52 +08:00
d . collection_status , d . collection_status_description , d . collection_updated_at ,
a . id AS account_id , a . nickname AS account_nickname , a . profile_url , a . followers ,
p . name AS cooperation_source , cl . claimant_name
FROM contents c
LEFT JOIN distributions d ON d . content_id = c . id
LEFT JOIN accounts a ON a . id = d . account_id
LEFT JOIN partners p ON p . id = d . partner_id
LEFT JOIN claims cl ON cl . id = d . claim_id
WHERE c . task_id = ?
ORDER BY COALESCE ( c . source_row , 999999 ) , c . created_at , d . claimed_at ` ,
)
. bind ( taskId )
. all < Record < string , unknown > > ( ) ,
db
. prepare (
` SELECT cl.*, p.name AS partner_name,
( SELECT COUNT ( * ) FROM distributions d WHERE d . claim_id = cl . id AND COALESCE ( d . publish_url , '' ) != '' ) AS published_count
FROM claims cl JOIN partners p ON p . id = cl . partner_id
WHERE cl . task_id = ? ORDER BY cl . created_at DESC ` ,
)
. bind ( taskId )
. all < Record < string , unknown > > ( ) ,
db
. prepare (
` SELECT * FROM collection_runs WHERE task_id = ?
ORDER BY scheduled_date DESC , created_at DESC LIMIT 500 ` ,
)
. bind ( taskId )
. all < Record < string , unknown > > ( ) ,
] ) ;
2026-08-11 23:07:26 +08:00
const taskOutput : Record < string , unknown > & {
collection_days : unknown [ ] ;
claim_url : string | null ;
} = {
2026-08-07 14:12:52 +08:00
. . . task ,
collection_days : parseJsonArray ( task . collection_days ) ,
claim_url :
portalUrl && task . share_token
? buildClaimUrl ( portalUrl , String ( task . share_token ) )
: null ,
2026-08-11 23:07:26 +08:00
} ;
return {
task : taskOutput ,
2026-08-07 14:12:52 +08:00
notes : notes.results.map ( ( row ) = > ( {
. . . row ,
image_assets : parseJsonArray ( row . image_assets ) ,
2026-08-15 03:53:09 +08:00
video_assets : parseJsonArray ( row . video_assets ) ,
2026-08-07 14:12:52 +08:00
total_interactions :
row . latest_likes == null
? null
: Number ( row . latest_likes ) +
Number ( row . latest_comments ? ? 0 ) +
2026-08-15 03:53:09 +08:00
Number ( row . latest_collects ? ? 0 ) +
Number ( row . latest_shares ? ? 0 ) ,
2026-08-07 14:12:52 +08:00
} ) ) ,
claims : claims.results ,
collection_runs : runs.results ,
} ;
}
export async function recoveryList (
input : Pagination & {
taskId? : string ;
stage ? : "all" | "published" | "unfilled" | "waiting_day7" | "day7_due" ;
} ,
) {
await ensureSchema ( ) ;
const { limit , offset } = page ( input ) ;
const db = getRawDb ( ) ;
const conditions : string [ ] = [ ] ;
const bindings : unknown [ ] = [ ] ;
if ( input . taskId ? . trim ( ) ) {
conditions . push ( "d.task_id = ?" ) ;
bindings . push ( input . taskId . trim ( ) ) ;
}
const stage = input . stage ? ? "all" ;
if ( stage === "published" ) conditions . push ( "COALESCE(d.publish_url, '') != ''" ) ;
if ( stage === "unfilled" ) conditions . push ( "COALESCE(d.publish_url, '') = ''" ) ;
if ( stage === "waiting_day7" ) {
conditions . push ( "COALESCE(d.publish_url, '') != '' AND (d.exposure IS NULL OR d.views IS NULL)" ) ;
}
if ( stage === "day7_due" ) {
conditions . push (
"COALESCE(d.publish_url, '') != '' AND (d.exposure IS NULL OR d.views IS NULL) AND datetime(d.publish_time, '+7 days') <= CURRENT_TIMESTAMP" ,
) ;
}
const where = conditions . length ? ` WHERE ${ conditions . join ( " AND " ) } ` : "" ;
const base = ` FROM distributions d
JOIN tasks t ON t . id = d . task_id
JOIN contents c ON c . id = d . content_id
LEFT JOIN accounts a ON a . id = d . account_id
LEFT JOIN partners p ON p . id = d . partner_id
LEFT JOIN claims cl ON cl . id = d . claim_id $ { where } ` ;
const [ rows , count ] = await Promise . all ( [
db
. prepare (
2026-08-15 03:53:09 +08:00
` SELECT d.*, t.name AS task_name, t.platform AS task_platform,
t . content_format , c . source_row , c . title ,
2026-08-07 14:12:52 +08:00
a . nickname AS account_nickname , a . profile_url , p . name AS cooperation_source ,
cl . claimant_name $ { base }
2026-08-11 23:07:26 +08:00
ORDER BY COALESCE ( d . publish_time , d . claimed_at ) DESC LIMIT $ { limit } OFFSET $ { offset } ` ,
2026-08-07 14:12:52 +08:00
)
2026-08-11 23:07:26 +08:00
. bind ( . . . bindings )
2026-08-07 14:12:52 +08:00
. all < Record < string , unknown > > ( ) ,
db
. prepare ( ` SELECT COUNT(*) AS total ${ base } ` )
. bind ( . . . bindings )
. first < { total : number } > ( ) ,
] ) ;
return {
total : count?.total ? ? 0 ,
limit ,
offset ,
items : rows.results.map ( ( row ) = > ( {
. . . row ,
total_interactions :
row . latest_likes == null
? null
2026-08-15 03:53:09 +08:00
: Number ( row . latest_likes ) + Number ( row . latest_comments ? ? 0 ) + Number ( row . latest_collects ? ? 0 ) + Number ( row . latest_shares ? ? 0 ) ,
2026-08-07 14:12:52 +08:00
} ) ) ,
} ;
}
export async function recoveryExport ( taskId : string , origin : string ) {
await ensureSchema ( ) ;
await taskExists ( taskId ) ;
const issued = await issueMcpExportToken ( "recovery" , { taskId } ) ;
return {
task_id : taskId ,
download_url : ` ${ origin } /api/recovery-export?token= ${ encodeURIComponent ( issued . token ) } ` ,
expires_at : issued.expiresAt ,
} ;
}
export async function setCollectionPlan (
taskId : string ,
startDate : string ,
days : number [ ] ,
bindings : McpOperationBindings ,
) {
await ensureSchema ( ) ;
await taskExists ( taskId ) ;
const normalizedDays = [ . . . new Set ( days ) ]
. filter ( ( day ) = > Number . isInteger ( day ) && day >= 1 && day <= 7 )
. sort ( ( a , b ) = > a - b ) ;
if ( ! /^\d{4}-\d{2}-\d{2}$/ . test ( startDate ) || Number . isNaN ( Date . parse ( ` ${ startDate } T00:00:00+08:00 ` ) ) ) {
throw new Error ( "开始采集日期无效" ) ;
}
if ( ! normalizedDays . length ) throw new Error ( "请至少选择一个采集日" ) ;
const db = getRawDb ( ) ;
await db . batch ( [
db . prepare (
` UPDATE tasks SET collection_start_date = ?, collection_days = ?,
collection_schedule_updated_at = CURRENT_TIMESTAMP WHERE id = ? ` ,
) . bind ( startDate , JSON . stringify ( normalizedDays ) , taskId ) ,
db . prepare (
` UPDATE distributions SET
collection_status = CASE WHEN latest_likes IS NULL THEN 'scheduled' ELSE collection_status END ,
collection_status_description = CASE WHEN latest_likes IS NULL THEN ? ELSE collection_status_description END ,
updated_at = CURRENT_TIMESTAMP
WHERE task_id = ? AND COALESCE ( publish_url , '' ) != '' ` ,
2026-08-12 11:12:23 +08:00
) . bind ( ` 已安排 ${ normalizedDays . length } 个采集日, 每日09:00执行 ` , taskId ) ,
2026-08-07 14:12:52 +08:00
] ) ;
const created = await createCollectionRunTasks ( db , taskId , startDate , normalizedDays ) ;
const catchup = await runDueScheduledCollections (
db ,
Date . now ( ) ,
resolveCollectionMcpConfig ( bindings ) ,
"catchup" ,
taskId ,
) ;
return { task_id : taskId , start_date : startDate , days : normalizedDays , created , catchup } ;
}
export async function runDueCollections ( taskId : string | undefined , bindings : McpOperationBindings ) {
await ensureSchema ( ) ;
if ( taskId ) await taskExists ( taskId ) ;
return runDueScheduledCollections (
getRawDb ( ) ,
Date . now ( ) ,
resolveCollectionMcpConfig ( bindings ) ,
"catchup" ,
taskId ,
) ;
}
export async function collectNow (
distributionId : string ,
scheduleDay : number | undefined ,
bindings : McpOperationBindings ,
) {
await ensureSchema ( ) ;
const day = scheduleDay ? ? null ;
const metrics = await collectDistributionMetrics (
getRawDb ( ) ,
distributionId ,
shanghaiDateFromTimestamp ( Date . now ( ) ) ,
day ,
"manual" ,
resolveCollectionMcpConfig ( bindings ) ,
) ;
return { distribution_id : distributionId , schedule_day : day , . . . metrics } ;
}
export async function retryFailed ( taskId : string | undefined , bindings : McpOperationBindings ) {
await ensureSchema ( ) ;
const db = getRawDb ( ) ;
const ids = taskId
? [ taskId ]
: (
await db
. prepare (
` SELECT DISTINCT task_id FROM distributions
WHERE collection_status = 'failed' ORDER BY updated_at DESC LIMIT 20 ` ,
)
. all < { task_id : string } > ( )
) . results . map ( ( row ) = > row . task_id ) ;
if ( taskId ) await taskExists ( taskId ) ;
const results = [ ] ;
for ( const id of ids ) {
results . push ( { task_id : id , . . . ( await retryFailedCollections ( db , id , resolveCollectionMcpConfig ( bindings ) ) ) } ) ;
}
return {
task_count : results.length ,
attempted : results.reduce ( ( sum , item ) = > sum + item . attempted , 0 ) ,
succeeded : results.reduce ( ( sum , item ) = > sum + item . succeeded , 0 ) ,
failed : results.reduce ( ( sum , item ) = > sum + item . failed , 0 ) ,
tasks : results ,
} ;
}
function resourceWhere ( input : ResourceFilters ) {
const conditions : string [ ] = [ ] ;
const bindings : unknown [ ] = [ ] ;
if ( input . query ? . trim ( ) ) {
2026-08-15 03:53:09 +08:00
conditions . push (
"(a.nickname LIKE ? ESCAPE '\\' OR a.public_account_id LIKE ? ESCAPE '\\' OR a.current_contact LIKE ? ESCAPE '\\' OR a.tags LIKE ? ESCAPE '\\' OR a.bio LIKE ? ESCAPE '\\')" ,
) ;
2026-08-07 14:12:52 +08:00
const pattern = like ( input . query . trim ( ) ) ;
2026-08-15 03:53:09 +08:00
bindings . push ( pattern , pattern , pattern , pattern , pattern ) ;
2026-08-07 14:12:52 +08:00
}
if ( input . ipLocation ? . trim ( ) ) {
conditions . push ( "a.ip_location LIKE ? ESCAPE '\\'" ) ;
bindings . push ( like ( input . ipLocation . trim ( ) ) ) ;
}
if ( input . cooperationSource ? . trim ( ) ) {
conditions . push (
2026-08-11 23:07:26 +08:00
` (a.cooperation_source LIKE ? ESCAPE ' \\ ' OR
2026-08-15 03:53:09 +08:00
EXISTS ( SELECT 1 FROM distributions dx
JOIN partners px ON px . id = dx . partner_id
LEFT JOIN claims cx ON cx . id = dx . claim_id
WHERE dx . account_id = a . id
AND ( cx . claimant_name IS NULL OR px . name != cx . claimant_name )
AND px . name LIKE ? ESCAPE '\\' ) ) ` ,
2026-08-07 14:12:52 +08:00
) ;
2026-08-11 23:07:26 +08:00
const pattern = like ( input . cooperationSource . trim ( ) ) ;
bindings . push ( pattern , pattern ) ;
2026-08-07 14:12:52 +08:00
}
if ( input . platform ? . trim ( ) && input . platform !== "all" ) {
conditions . push ( "a.platform = ?" ) ;
bindings . push ( input . platform . trim ( ) ) ;
}
return { where : conditions.length ? ` WHERE ${ conditions . join ( " AND " ) } ` : "" , bindings } ;
}
export async function resourceSearch ( input : ResourceFilters ) {
await ensureSchema ( ) ;
const { limit , offset } = page ( input ) ;
const { where , bindings } = resourceWhere ( input ) ;
const db = getRawDb ( ) ;
const select = ` SELECT a.*,
( SELECT COUNT ( * ) FROM distributions d WHERE d . account_id = a . id ) AS cooperation_count ,
2026-08-15 03:53:09 +08:00
( SELECT GROUP_CONCAT ( DISTINCT p . name )
FROM distributions d
JOIN partners p ON p . id = d . partner_id
LEFT JOIN claims c ON c . id = d . claim_id
WHERE d . account_id = a . id
AND ( c . claimant_name IS NULL OR p . name != c . claimant_name ) ) AS cooperation_sources ` ;
2026-08-07 14:12:52 +08:00
const [ rows , count ] = await Promise . all ( [
2026-08-11 23:07:26 +08:00
db . prepare ( ` ${ select } FROM accounts a ${ where } ORDER BY a.last_seen_at DESC LIMIT ${ limit } OFFSET ${ offset } ` )
. bind ( . . . bindings ) . all < Record < string , unknown > > ( ) ,
2026-08-07 14:12:52 +08:00
db . prepare ( ` SELECT COUNT(*) AS total FROM accounts a ${ where } ` ) . bind ( . . . bindings ) . first < { total : number } > ( ) ,
] ) ;
return {
total : count?.total ? ? 0 ,
limit ,
offset ,
2026-08-11 23:07:26 +08:00
accounts : rows.results.map (
( row ) : Record < string , unknown > & { cooperation_sources : string [ ] } = > ( {
. . . row ,
cooperation_sources : [
. . . new Set (
` ${ row . cooperation_sources ? ? "" } 、 ${ row . cooperation_source ? ? "" } `
. split ( /[、,, ;; |]/ )
. map ( ( item ) = > item . trim ( ) )
. filter ( Boolean ) ,
) ,
] ,
} ) ,
) ,
2026-08-07 14:12:52 +08:00
} ;
}
export async function resourceGet ( accountId : string ) {
await ensureSchema ( ) ;
const db = getRawDb ( ) ;
const account = await db . prepare ( "SELECT * FROM accounts WHERE id = ?" ) . bind ( accountId ) . first < Record < string , unknown > > ( ) ;
if ( ! account ) throw new Error ( "账号不存在" ) ;
const history = await db . prepare (
` SELECT d.id AS distribution_id, d.task_id, t.name AS task_name, c.title,
2026-08-15 03:53:09 +08:00
d . publish_url , d . publish_time , d . latest_likes , d . latest_comments , d . latest_collects , d . latest_shares ,
2026-08-07 14:12:52 +08:00
d . exposure , d . views , p . name AS cooperation_source , cl . claimant_name
FROM distributions d JOIN tasks t ON t . id = d . task_id
JOIN contents c ON c . id = d . content_id JOIN partners p ON p . id = d . partner_id
LEFT JOIN claims cl ON cl . id = d . claim_id
WHERE d . account_id = ? ORDER BY COALESCE ( d . publish_time , d . claimed_at ) DESC ` ,
) . bind ( accountId ) . all < Record < string , unknown > > ( ) ;
return { account , cooperation_history : history.results } ;
}
export async function backfillResourceProfile (
input : { distributionId? : string ; publishUrl? : string } ,
bindings : McpOperationBindings ,
) {
await ensureSchema ( ) ;
const db = getRawDb ( ) ;
2026-08-15 03:53:09 +08:00
const publishUrl = input . publishUrl ? extractAnyPublishUrl ( input . publishUrl ) : "" ;
2026-08-07 14:12:52 +08:00
const row = input . distributionId
? await db . prepare (
` SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
FROM distributions d LEFT JOIN accounts a ON a . id = d . account_id WHERE d . id = ? ` ,
) . bind ( input . distributionId ) . first < { id : string ; publish_url : string | null ; nickname : string } > ( )
: publishUrl
? await db . prepare (
` SELECT d.id, d.publish_url, COALESCE(a.nickname, '') AS nickname
FROM distributions d LEFT JOIN accounts a ON a . id = d . account_id
WHERE d . publish_url = ? ORDER BY d . updated_at DESC LIMIT 1 ` ,
) . bind ( publishUrl ) . first < { id : string ; publish_url : string | null ; nickname : string } > ( )
: null ;
if ( ! row ? . publish_url ) throw new Error ( "没有找到可补全的发布记录" ) ;
const result = await enrichDistributionAccount (
db ,
row . id ,
row . publish_url ,
row . nickname ,
resolveCollectionMcpConfig ( bindings ) ,
) ;
const account = result . updated && result . accountId
? await db . prepare ( "SELECT * FROM accounts WHERE id = ?" ) . bind ( result . accountId ) . first < Record < string , unknown > > ( )
: null ;
return { distribution_id : row.id , . . . result , account } ;
}
export async function resourceExport ( input : ResourceFilters , origin : string ) {
const result = await resourceSearch ( { . . . input , limit : 200 , offset : 0 } ) ;
const allIds : string [ ] = result . accounts . map ( ( account ) = > String ( account . id ) ) ;
if ( result . total > result . accounts . length ) {
const { where , bindings } = resourceWhere ( input ) ;
const rows = await getRawDb ( ) . prepare ( ` SELECT a.id FROM accounts a ${ where } ORDER BY a.last_seen_at DESC LIMIT 5000 ` )
. bind ( . . . bindings ) . all < { id : string } > ( ) ;
allIds . splice ( 0 , allIds . length , . . . rows . results . map ( ( row ) = > row . id ) ) ;
}
if ( ! allIds . length ) throw new Error ( "当前筛选结果为空" ) ;
const issued = await issueMcpExportToken ( "resources" , { accountIds : allIds } ) ;
return {
account_count : allIds.length ,
download_url : ` ${ origin } /api/resources-export?token= ${ encodeURIComponent ( issued . token ) } ` ,
expires_at : issued.expiresAt ,
} ;
}