You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
201 lines
7.5 KiB
201 lines
7.5 KiB
package echomeet
|
|
|
|
import (
|
|
"yunyan/comm"
|
|
"yunyan/lego/sys/mysql"
|
|
"yunyan/pb"
|
|
"encoding/json"
|
|
"time"
|
|
)
|
|
|
|
// @Summary 启动会议转写任务
|
|
// @Description 扣除积分并启动指定会议记录的转写/翻译任务,根据源语言自动路由到字节或阿里云
|
|
// @Tags Echomeet
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Security BearerAuth
|
|
// @Param user body pb.EchomeetStartTaskReq true "请求数据"
|
|
// @Success 200 {object} comm.HttpResult{data=pb.EchomeetStartTaskResp} "响应数据"
|
|
// @Router /api/home/echomeet_starttask [post]
|
|
func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartTaskReq) (resp *pb.EchomeetStartTaskResp, errdata *pb.ErrorData) {
|
|
var (
|
|
user *pb.DBUser
|
|
model *pb.DBEchoMeetRecord
|
|
err error
|
|
)
|
|
|
|
uid, errdata := requireUID(session)
|
|
if errdata != nil {
|
|
return
|
|
}
|
|
user, err = this.module.model.getuser(uid)
|
|
if err != nil {
|
|
errdata = &pb.ErrorData{
|
|
Code: pb.ErrorCode_DBError,
|
|
Message: err.Error(),
|
|
}
|
|
return
|
|
}
|
|
// ⚠️ 归属校验必须排在扣算力之前,否则拒绝了还先把钱扣了。
|
|
model, err = this.module.model.getrecordforuid(uid, req.Id)
|
|
if err != nil {
|
|
errdata = recordErr(err)
|
|
return
|
|
}
|
|
if model.State > pb.DBEchoMeetRecordState_Unknow && model.State < pb.DBEchoMeetRecordState_Completed {
|
|
errdata = &pb.ErrorData{
|
|
Code: pb.ErrorCode_TaskStateError,
|
|
Message: "任务状态错误",
|
|
}
|
|
return
|
|
}
|
|
// ── 准入:会议纪要需要 VIP(自购 或 当前绑定设备赋予,见 comm.ResolveVip)──
|
|
if vip, _, verr := comm.ResolveUserVip(user.Uid, user); verr == nil && !vip.Active(time.Now().Unix()) {
|
|
errdata = &pb.ErrorData{
|
|
Code: pb.ErrorCode_VipRequired,
|
|
Message: "会议纪要需要会员,请续费或绑定带会员的设备",
|
|
}
|
|
return
|
|
}
|
|
// ── 计量:会议时长服务端自己知道(model.Seconds),按换算系数折成算力记账。
|
|
// 先设备赠送、再用户余额;闸门默认关,不够也放行、差额记超额。──
|
|
statistics, sErr := this.module.model.getStatistics(uid)
|
|
if sErr != nil && sErr != mysql.ErrNoDocuments {
|
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: sErr.Error()}
|
|
return
|
|
}
|
|
statistics.Meetnum += 1
|
|
statistics.Meettime += int64(model.Seconds)
|
|
userlog := &pb.DBUserUseLog{
|
|
Uid: uid,
|
|
Logtype: pb.UserLogType_UserConsume,
|
|
Ts: time.Now().Unix(),
|
|
Addmeetsecond: -1 * int64(model.Seconds),
|
|
}
|
|
res, cerr := comm.ApplyComputeUsage(user.Uid, comm.ComputeUsageMeeting, int64(model.Seconds), userlog, statistics)
|
|
if cerr != nil {
|
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_DBError, Message: cerr.Error()}
|
|
return
|
|
}
|
|
if res.Rejected {
|
|
errdata = &pb.ErrorData{
|
|
Code: pb.ErrorCode_ComputeNotEnough,
|
|
Message: "算力不足",
|
|
}
|
|
return
|
|
}
|
|
// 扣费明细记到记录上:之后转写失败时按它原路退回(compute_refund.go)。
|
|
recordCharge(model, res)
|
|
// 算力已经扣了,但下面还有模板解析、选路等好几步会直接 return 错误——以前这些情况扣了不退。
|
|
// 本次请求只要以错误结束就退:记录已带着明细落库的,走「认领明细再退」(防和别的失败路径重复退);
|
|
// 还没落库的,直接按这次的扣费结果退。
|
|
chargeSaved := false
|
|
defer func() {
|
|
if errdata == nil {
|
|
return
|
|
}
|
|
reason := "开始任务失败:" + errdata.Message
|
|
if chargeSaved {
|
|
this.module.model.refundRecordCompute(model, reason)
|
|
} else {
|
|
this.module.model.refundCharge(user.Uid, res.User, res.Cuts, int64(model.Seconds), reason, model.Id)
|
|
}
|
|
}()
|
|
// 会议时长消耗埋点
|
|
if this.module.analyze != nil {
|
|
this.module.analyze.Report(&comm.StatEvent{
|
|
Type: comm.StatEventMeeting,
|
|
ProductId: user.Lastbindproductid,
|
|
ChannelId: user.Lastbindchannelid,
|
|
Uid: user.Uid,
|
|
Second: int64(model.Seconds),
|
|
})
|
|
}
|
|
// 同步更新累计统计(排行榜依赖此表)+ 消费日志(供历史重建扫描)
|
|
if uErr := this.module.model.updateStatistics(statistics); uErr != nil {
|
|
this.module.Warnf("StartTask id:%d update userstatistics failed: %v", model.Id, uErr)
|
|
}
|
|
_ = this.module.model.addUseLog(userlog)
|
|
|
|
// 2026-09-26 起:录音语言空 = 交给识别服务自动判断;目标语言空 = 不翻译(总结用录音本身的语言)。
|
|
// 老客户端两个都传真实语种,照旧能用。"auto" 与空串同义(重新总结接口里空串另有含义)。
|
|
req.Formlanguage = normalizeAutoLang(req.Formlanguage)
|
|
req.Tolanguage = normalizeAutoLang(req.Tolanguage)
|
|
tmpl, err := this.module.cache.ResolveSummaryTemplate(req.Tid, req.Templateid, req.Tolanguage)
|
|
if err != nil {
|
|
errdata = &pb.ErrorData{
|
|
Code: pb.ErrorCode_TemplateNotFound,
|
|
Message: "模板不存在或不支持该语言",
|
|
}
|
|
this.module.Errorf("StartTask id:%d req:%+v 解析模板失败 err:%v", model.Id, req, err)
|
|
return
|
|
}
|
|
model.State = pb.DBEchoMeetRecordState_AwaitTranscribing
|
|
model.Starttime = time.Now().Unix()
|
|
model.Processduration = 0
|
|
model.Templateid = tmpl.Id
|
|
model.Isdistinguishspeaker = req.Isdistinguishspeaker
|
|
model.Formlanguage = req.Formlanguage
|
|
model.Tolanguage = req.Tolanguage
|
|
|
|
// 三段选路(后台编排,fail-closed:未配置直接报错提示去后台配置):
|
|
// - 识别/翻译:按后台优先级 + 语言支持过滤,客户端不可指定;
|
|
// - 总结:后台默认模型;开关允许时客户端可用 summary_svc_id 指定。
|
|
llmSvcId, _, err := this.module.providers.PickLLM(req.SummarySvcId)
|
|
if err != nil {
|
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: err.Error()}
|
|
return
|
|
}
|
|
model.LlmSvcId = llmSvcId
|
|
// 转写内容不再翻译(目标语言只作用于总结,由大模型直接按目标语言输出),不需要翻译选路。
|
|
|
|
if model.Rtype == "VOICETRANSLAT" {
|
|
// 语音翻译:已有转写文本,只走翻译+总结,无需识别选路。
|
|
this.module.model.saverecord(model)
|
|
chargeSaved = true
|
|
this.module.Infof("StartTask id:%d → 语音翻译 llm:%s", model.Id, model.LlmSvcId)
|
|
var contexts []*pb.ContextStruct
|
|
if err = json.Unmarshal([]byte(model.Original), &contexts); err != nil {
|
|
this.module.Errorf("Summary id:%d 解析 Original 失败 err:%v", model.Id, err)
|
|
errdata = &pb.ErrorData{
|
|
Code: pb.ErrorCode_SystemError,
|
|
Message: "json.Unmarshal Original 失败",
|
|
}
|
|
return
|
|
} else {
|
|
this.module.tasks.TranslateProcess(model, contexts)
|
|
_ = this.module.tasks.SubmitAITask(model)
|
|
}
|
|
return
|
|
}
|
|
|
|
if model.Audiourl == "" {
|
|
errdata = &pb.ErrorData{
|
|
Code: pb.ErrorCode_AudioUrlEmpty,
|
|
Message: "音频URL为空",
|
|
}
|
|
return
|
|
}
|
|
asrSvcId, _, _, err := this.module.providers.PickASR(model.Formlanguage)
|
|
if err != nil {
|
|
errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: err.Error()}
|
|
return
|
|
}
|
|
model.AsrSvcId = asrSvcId
|
|
this.module.model.saverecord(model)
|
|
chargeSaved = true
|
|
|
|
this.module.Infof("StartTask id:%d uid:%s formlang:%q tolang:%q seconds:%d size:%d distinguishSpeaker:%v asr:%s llm:%s",
|
|
model.Id, model.Uid, model.Formlanguage, model.Tolanguage, model.Seconds, model.Size, model.Isdistinguishspeaker,
|
|
model.AsrSvcId, model.LlmSvcId)
|
|
|
|
// 字节短音频走专用限流队列(flash 同步通道);其余统一走通用异步提交。
|
|
if this.module.providers.ASRProvider(asrSvcId) == "bytedance" &&
|
|
model.Seconds <= byteDanceFlashMaxSeconds && model.Size <= byteDanceFlashMaxBytes {
|
|
_ = this.module.tasks.SubmitShortAudioTask(model)
|
|
} else {
|
|
_ = this.module.tasks.SubmitTranscribeTask(model)
|
|
}
|
|
resp = &pb.EchomeetStartTaskResp{}
|
|
return
|
|
}
|
|
|