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 ) user, err = this.module.model.getuser(session.GetUserId()) if err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_DBError, Message: err.Error(), } return } model, err = this.module.model.getrecord(req.Id) if err != nil { errdata = &pb.ErrorData{ Code: pb.ErrorCode_DBError, Message: err.Error(), } 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(session.GetUserId()) 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: session.GetUserId(), 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 } // 会议时长消耗埋点 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) tmpl, err := this.module.cache.ResolveTemplate(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 mtSvcId, _, merr := this.module.providers.PickMT(model.Formlanguage); merr == nil { model.MtSvcId = mtSvcId } else if model.Formlanguage != model.Tolanguage { errdata = &pb.ErrorData{Code: pb.ErrorCode_SystemError, Message: merr.Error()} return } if model.Rtype == "VOICETRANSLAT" { // 语音翻译:已有转写文本,只走翻译+总结,无需识别选路。 this.module.model.saverecord(model) this.module.Infof("StartTask id:%d → 语音翻译 mt:%s llm:%s", model.Id, model.MtSvcId, 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) this.module.Infof("StartTask id:%d uid:%s formlang:%s tolang:%s seconds:%d size:%d distinguishSpeaker:%v asr:%s mt:%s llm:%s", model.Id, model.Uid, model.Formlanguage, model.Tolanguage, model.Seconds, model.Size, model.Isdistinguishspeaker, model.AsrSvcId, model.MtSvcId, 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 }