@@ -5,7 +5,7 @@ import { readFile } from "node:fs/promises";
55
66import { Store } from "./lib/store.js" ;
77import {
8- queryStation , queryStationUsage , queryOwnData ,
8+ queryStation , queryStationUsage , queryOwnData , queryOwnChannels ,
99 dateStrInTz , parseDateLabel , STATION_TYPES ,
1010} from "./lib/providers.js" ;
1111import { forecastDaily } from "./lib/forecast.js" ;
@@ -182,6 +182,20 @@ mock.get("/newapi/:acc/api/data/users", (req, res) => {
182182 if ( ! needAuth ( req , res ) ) return ;
183183 res . json ( { success : true , message : "" , data : mockOwnRows ( Number ( req . query . start_timestamp ) || 0 , Number ( req . query . end_timestamp ) || 0 , "user" ) } ) ;
184184} ) ;
185+ mock . get ( "/newapi/:acc/api/channel/" , ( req , res ) => {
186+ if ( ! needAuth ( req , res ) ) return ;
187+ const local = `http://${ HOST } :${ PORT } /mock` ;
188+ res . json ( {
189+ success : true , message : "" ,
190+ data : { items : [
191+ { id : 1 , name : "上游A-高速" , type : 1 , status : 1 , base_url : `${ local } /newapi/np-pro` } ,
192+ { id : 2 , name : "上游A-备用" , type : 1 , status : 2 , base_url : `${ local } /newapi/np-pro` } ,
193+ { id : 3 , name : "拼车团队" , type : 14 , status : 1 , base_url : `${ local } /sub2api/s2-team` } ,
194+ { id : 4 , name : "官方直连-DeepSeek" , type : 43 , status : 1 , base_url : "" } ,
195+ { id : 5 , name : "包月自建" , type : 14 , status : 1 , base_url : "http://10.0.0.8:13800" } ,
196+ ] , total : 5 } ,
197+ } ) ;
198+ } ) ;
185199
186200// 用户仪表盘统计(与真实 Sub2API 的 /usage/dashboard/stats 契约一致)
187201mock . get ( "/sub2api/:acc/api/v1/usage/dashboard/stats" , ( req , res ) => {
@@ -528,6 +542,7 @@ app.get("/api/own/analytics", async (req, res) => {
528542 trend : [ ...tmap . values ( ) ] . sort ( ( a , b ) => a . t - b . t ) ,
529543 daily : daily . slice ( - 14 ) ,
530544 forecast : forecastDaily ( daily , 7 ) ,
545+ profit : await computeProfit ( own , payloadIncome ( winModel ) , { startMs, now, tz, range } ) ,
531546 generatedAt : new Date ( ) . toISOString ( ) ,
532547 } ;
533548 ownCache . set ( cacheKey , { at : Date . now ( ) , payload } ) ;
@@ -537,6 +552,83 @@ app.get("/api/own/analytics", async (req, res) => {
537552 }
538553} ) ;
539554
555+ function payloadIncome ( winModelRows ) {
556+ return winModelRows . reduce ( ( a , r ) => a + r . cost , 0 ) ;
557+ }
558+
559+ // 渠道列表拉取开销不小,缓存 10 分钟
560+ let ownChannelsCache = { at : 0 , stationId : null , list : null } ;
561+
562+ /**
563+ * 利润 = 收入(下游消费 × 自有站售价汇率)− 成本(各匹配上游的期内成本)
564+ * 成本口径:配置了每月固定成本的上游按天摊销(月费 ÷ 30 × 窗口天数);
565+ * 否则按上游用量接口的实际扣费 × 充值汇率;用量接口不可用时退回余额下降推算。
566+ * 渠道按 base_url 与监控站点匹配(忽略协议/末尾斜杠/是否带 /api)。
567+ */
568+ async function computeProfit ( own , incomeUsd , { startMs, now, tz, range } ) {
569+ const r2 = ( v ) => Math . round ( v * 100 ) / 100 ;
570+ try {
571+ if ( ! ownChannelsCache . list || ownChannelsCache . stationId !== own . id || Date . now ( ) - ownChannelsCache . at > 600000 ) {
572+ ownChannelsCache = { at : Date . now ( ) , stationId : own . id , list : await queryOwnChannels ( own ) } ;
573+ }
574+ const channels = ownChannelsCache . list ;
575+ const norm = ( u ) => String ( u || "" ) . toLowerCase ( ) . replace ( / ^ h t t p s ? : \/ \/ / , "" ) . replace ( / \/ + $ / , "" ) ;
576+ const same = ( a , b ) => a && b && ( a === b || a === b + "/api" || b === a + "/api" ) ;
577+ const upstreams = store . list ( ) . filter ( ( s ) => s . id !== own . id ) ;
578+
579+ const matched = new Map ( ) ; // stationId -> {station, channels[]}
580+ const unmatched = new Map ( ) ; // label -> {label, names[], enabled, total}
581+ for ( const ch of channels ) {
582+ const cu = norm ( ch . baseUrl ) ;
583+ const st = cu ? upstreams . find ( ( s ) => same ( norm ( s . baseUrl ) , cu ) ) : null ;
584+ if ( st ) {
585+ const e = matched . get ( st . id ) || { station : st , channels : [ ] } ;
586+ e . channels . push ( ch . name ) ;
587+ matched . set ( st . id , e ) ;
588+ } else {
589+ const label = cu || `官方 / 内置渠道(type ${ ch . type } )` ;
590+ const e = unmatched . get ( label ) || { label, names : [ ] , enabled : 0 , total : 0 } ;
591+ e . names . push ( ch . name ) ;
592+ e . total ++ ;
593+ if ( ch . status === 1 ) e . enabled ++ ;
594+ unmatched . set ( label , e ) ;
595+ }
596+ }
597+
598+ const windowDays = ( now - startMs ) / 86400000 ;
599+ const rateOf = ( s ) => ( s . cnyPerUsd != null && s . cnyPerUsd > 0 ? s . cnyPerUsd : 1 ) ;
600+ const costs = await Promise . all ( [ ...matched . values ( ) ] . map ( async ( { station, channels : chNames } ) => {
601+ const item = { stationId : station . id , name : station . name , channels : chNames } ;
602+ if ( station . fixedMonthlyCny != null && station . fixedMonthlyCny > 0 ) {
603+ return { ...item , mode : "fixed" , cny : r2 ( ( station . fixedMonthlyCny / 30 ) * windowDays ) } ;
604+ }
605+ try {
606+ const u = await queryStationUsage ( station , {
607+ startMs, endMs : now , granularity : "day" , tz, wantToday : range === "today" ,
608+ } ) ;
609+ const usd = range === "today" && u . summary ? u . summary . cost : u . models . reduce ( ( a , m ) => a + m . cost , 0 ) ;
610+ return { ...item , mode : "usage" , cny : r2 ( usd * rateOf ( station ) ) } ;
611+ } catch {
612+ return { ...item , mode : "history" , cny : r2 ( history . usedSince ( station . id , startMs ) * rateOf ( station ) ) } ;
613+ }
614+ } ) ) ;
615+
616+ const ownRate = own . cnyPerUsd != null && own . cnyPerUsd > 0 ? own . cnyPerUsd : 1 ;
617+ const incomeCny = r2 ( incomeUsd * ownRate ) ;
618+ const totalCostCny = r2 ( costs . reduce ( ( a , c ) => a + c . cny , 0 ) ) ;
619+ return {
620+ incomeCny, totalCostCny,
621+ profitCny : r2 ( incomeCny - totalCostCny ) ,
622+ marginPct : incomeCny > 0 ? Math . round ( ( ( incomeCny - totalCostCny ) / incomeCny ) * 1000 ) / 10 : null ,
623+ costs : costs . sort ( ( a , b ) => b . cny - a . cny ) ,
624+ unmatched : [ ...unmatched . values ( ) ] . sort ( ( a , b ) => b . enabled - a . enabled || b . total - a . total ) ,
625+ windowDays : Math . round ( windowDays * 10 ) / 10 ,
626+ } ;
627+ } catch ( err ) {
628+ return { error : err ?. message || String ( err ) } ;
629+ }
630+ }
631+
540632// ---- 通知渠道 ----------------------------------------------------------------
541633app . get ( "/api/notifications" , ( req , res ) => {
542634 res . json ( { channels : store . channels , rules : store . rules , channelTypes : CHANNEL_TYPES } ) ;
0 commit comments