diff --git a/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart b/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart index 9dd95d4f..233d665f 100644 --- a/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -49,6 +49,26 @@ class MeetingHomeController extends GetxController { // 多选删除模式状态 final RxBool isSelectionMode = false.obs; + + /// 顶栏右侧的算力摘要(「算力:设备 X · 账户 Y」,user_getcompute)。 + /// + /// 2026-09-25 以前这里显示的是旧「会议额度」meetintegral 的剩余时长 + 进度条——那个桶已随会员与算力改造停用、 + /// 新账号恒为 0,于是永远显示 00:00:00。现在语音纪要按算力计量,就显示算力。拿不到就不显示(null)。 + final RxnString computeText = RxnString(); + + Future refreshCompute() async { + try { + final resp = await Api.getCompute(); + if (resp is! Map) return; + final c = resp['compute']; + if (c is! Map) return; + final dev = (c['deviceleft'] as num?)?.toInt() ?? 0; + final usr = (c['userbalance'] as num?)?.toInt() ?? 0; + computeText.value = 'computeSummary'.trParams({'device': '$dev', 'user': '$usr'}); + } catch (_) { + // 展示项,失败不影响页面 + } + } final RxList selectedIds = [].obs; bool get isAllSelected => @@ -111,6 +131,7 @@ class MeetingHomeController extends GetxController { @override void onInit() { super.onInit(); + refreshCompute(); _requestStoragePermission(); _initGetDataList(); addMeetingAudio(); diff --git a/apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart b/apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart index 787a34cf..f53e2fcc 100644 --- a/apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart +++ b/apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart @@ -7,8 +7,6 @@ import 'package:get_storage/get_storage.dart'; import '../../controllers/meeting_details_controller.dart'; import 'record_language_bottom_sheet.dart'; import '../template/template_view.dart'; -import '../../../../data/models/user_Info.dart'; -import '../../../common/widgets/recharge_dialog.dart'; class GenerateBottomSheet extends StatefulWidget { final bool isSummarizeAgain; @@ -339,10 +337,10 @@ class _GenerateBottomSheetState extends State { padding: EdgeInsets.symmetric(horizontal: 12.w, vertical: 20.w), child: GestureDetector( onTap: () { - if (User.isLoggedIn() && User.instance.meetintegral <= 0) { - showRechargeDialog('meeting'); - return; - } + // ⚠️ 这里原来判的是 `User.instance.meetintegral <= 0`(旧「会议额度」桶)就弹「余额不足」、 + // 请求都不发。那个桶已于会员与算力改造时停用:余额一次性折进算力、之后不再发放, + // 新账号恒为 0——于是「明明有算力,语音纪要却说没余额」(2026-09-25 修,同翻译那次)。 + // 准入交给服务端:没 VIP 回 VipRequired(拦截器会引导开通),算力不足按后台闸门开关处理。 if (_templateData['id'] != _templateList[0]['id']) { int index = _templateList .indexWhere((v) => v['id'] == _templateData['id']); diff --git a/apps/client/lib/modules/meeting/views/meeting_home_view.dart b/apps/client/lib/modules/meeting/views/meeting_home_view.dart index fe7d0955..16157b66 100644 --- a/apps/client/lib/modules/meeting/views/meeting_home_view.dart +++ b/apps/client/lib/modules/meeting/views/meeting_home_view.dart @@ -11,7 +11,6 @@ import '../controllers/meeting_home_controller.dart'; import '../model/meeting_model.dart'; import '../utils/meeting_ui_utils.dart'; import 'bottomSheet/edit_title_bottom_sheet.dart'; -import '../../../../data/models/user_Info.dart'; ///会议主页面,提供会议列表、会议详情、会议设置 class MeetingHomeView extends StatefulWidget { @@ -367,57 +366,22 @@ class _MeetingHomeViewState extends State ), ), centerTitle: true, + // 顶栏右侧:算力摘要(原来是旧「会议额度」的剩余时长 + 进度条,那个桶已停用、新账号恒为 0) bottom: PreferredSize( - preferredSize: Size.fromHeight(28.w), + preferredSize: Size.fromHeight(22.w), child: Obx(() { - final _ = User.refreshTick.value; - final remain = User.isLoggedIn() ? User.instance.meetintegral : 0; - final total = User.isLoggedIn() ? User.instance.meettotalintegral : 0; - final double fraction = - total > 0 ? (remain / total).clamp(0.0, 1.0).toDouble() : 0.0; - return Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Container( - padding: EdgeInsets.only(right: 12.w, bottom: 4.w), - alignment: Alignment.centerRight, - child: Text( - _formatHms(remain), - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.white70 : Colors.black54, - ), - ), - ), - LayoutBuilder( - builder: (context, constraints) { - final barWidth = constraints.maxWidth * fraction; - return Stack( - children: [ - Container( - height: 2.w, - decoration: BoxDecoration( - color: isDarkMode ? Colors.white10 : Colors.black12, - borderRadius: BorderRadius.circular(2.w), - ), - ), - Align( - alignment: Alignment.centerRight, - child: Container( - width: barWidth, - height: 2.w, - decoration: BoxDecoration( - color: MeetingUIUtils.getButtonColor(isDarkMode), - borderRadius: BorderRadius.circular(2.w), - ), - ), - ), - ], - ); - }, + final text = _controller.computeText.value; + if (text == null) return const SizedBox.shrink(); + return Container( + padding: EdgeInsets.only(right: 12.w, bottom: 6.w), + alignment: Alignment.centerRight, + child: Text( + text, + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.white70 : Colors.black54, ), - ], + ), ); }), ), @@ -708,14 +672,4 @@ class _MeetingHomeViewState extends State return '${hours}h ${remainingMinutes}m ${remainingSeconds}s'; } } - - String _formatHms(int seconds) { - final sec = seconds < 0 ? 0 : seconds; // 确保秒数非负 - final d = Duration(seconds: sec); - final h = d.inHours; - final m = d.inMinutes.remainder(60); - final s = d.inSeconds.remainder(60); - String twoDigits(int n) => n.toString().padLeft(2, '0'); - return '${twoDigits(h)}:${twoDigits(m)}:${twoDigits(s)}'; - } } diff --git a/apps/proto/echomeet/echomeet_db.proto b/apps/proto/echomeet/echomeet_db.proto index 86286294..1754e555 100644 --- a/apps/proto/echomeet/echomeet_db.proto +++ b/apps/proto/echomeet/echomeet_db.proto @@ -66,6 +66,9 @@ message DBEchoMeetRecord { int64 processduration = 26; //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒 int64 lastquerytime = 27; //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 string remark = 28; //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结) + // 本次开始任务实际扣的算力明细,失败时按它原路退回(退完清零,保证只退一次)。只在服务端用,不下发。 + int64 computeuser = 33; //@go_tags(`gorm:"computeuser" json:"-"`) 从用户余额扣的算力 + string computedevices = 34; //@go_tags(`gorm:"computedevices" json:"-"`) 从设备赠送额度扣的明细 code:n,code:n string imageurls = 29; //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结 } diff --git a/apps/proto/enum.proto b/apps/proto/enum.proto index 798f3c5f..b0c9ab38 100644 --- a/apps/proto/enum.proto +++ b/apps/proto/enum.proto @@ -132,6 +132,8 @@ enum UserLogType { // @Description 后台调整用户算力余额(加减) // @example AdminAdjust = 5; //后台调整算力 + // 算力退还(语音纪要转写失败 / 开始任务失败时按扣费明细原路退回,2026-09-25) + ComputeRefund = 6; //算力退还 } //MCP服务类型 diff --git a/apps/services/comm/compute.go b/apps/services/comm/compute.go index 5b2fb9b9..68952e2c 100644 --- a/apps/services/comm/compute.go +++ b/apps/services/comm/compute.go @@ -2,6 +2,7 @@ package comm import ( "errors" + "fmt" "sort" "strconv" "strings" @@ -483,14 +484,78 @@ var ErrComputeBalanceNegative = errors.New("compute balance would be negative") // ComputeResult 一次扣减的结果。 type ComputeResult struct { - Compute int64 // 本次折算出的算力 - Device int64 // 从设备赠送额度扣的部分 - User int64 // 从用户余额扣的部分 - Over int64 // 超额(闸门关时余额不足仍放行的量;闸门开时恒为 0) - DeviceMac string // 扣了哪台设备(多台以逗号分隔) - DeviceLeft int64 // 扣后各绑定设备剩余合计 - UserLeft int64 // 扣后用户余额 - Rejected bool // 闸门开启且不足,已拒绝、未写库 + Compute int64 // 本次折算出的算力 + Device int64 // 从设备赠送额度扣的部分 + User int64 // 从用户余额扣的部分 + Over int64 // 超额(闸门关时余额不足仍放行的量;闸门开时恒为 0) + DeviceMac string // 扣了哪台设备(多台以逗号分隔) + Cuts []DeviceCharge // 从每台设备各扣了多少(按 device_mac.code),退还时原路退回用 + DeviceLeft int64 // 扣后各绑定设备剩余合计 + UserLeft int64 // 扣后用户余额 + Rejected bool // 闸门开启且不足,已拒绝、未写库 +} + +// DeviceCharge 一次消耗里从某台设备赠送额度扣的量。Code 是 device_mac 的主键。 +type DeviceCharge struct { + Code string + N int64 +} + +// EncodeDeviceCharges / DecodeDeviceCharges:扣费明细存进会议记录的 computedevices 列(code:n,code:n)。 +// code 是 MAC / 授权码,里面不会有逗号;解析时按最后一个冒号切,MAC 自带的冒号不受影响。 +func EncodeDeviceCharges(cs []DeviceCharge) string { + parts := make([]string, 0, len(cs)) + for _, c := range cs { + if c.Code == "" || c.N <= 0 { + continue + } + parts = append(parts, fmt.Sprintf("%s:%d", c.Code, c.N)) + } + return strings.Join(parts, ",") +} + +func DecodeDeviceCharges(s string) []DeviceCharge { + out := make([]DeviceCharge, 0) + for _, p := range strings.Split(s, ",") { + p = strings.TrimSpace(p) + i := strings.LastIndex(p, ":") + if i <= 0 { + continue + } + n, err := strconv.ParseInt(p[i+1:], 10, 64) + if err != nil || n <= 0 { + continue + } + out = append(out, DeviceCharge{Code: p[:i], N: n}) + } + return out +} + +// RefundComputeUsage 按扣费明细原路退回:设备部分把 computeused 减回去(不低于 0), +// 用户部分加回 computebalance。返回实际退回的总量。 +// +// ⚠️ 只退「真正扣掉的」——闸门关时余额不足放行的超额部分(ComputeResult.Over)从来没扣过, +// 不在明细里,也就不会被退成白送的算力。 +// 幂等由调用方保证(语音纪要是先在记录上原子地「认领」明细再调这里,见 echomeet.refundTaskCompute)。 +func RefundComputeUsage(uid string, userPart int64, devices []DeviceCharge) (refunded int64, err error) { + for _, d := range devices { + if d.Code == "" || d.N <= 0 { + continue + } + if err = postgres.Table(TableDeviceMac).Where("code=?", d.Code). + Update("computeused", gorm.Expr("GREATEST(computeused - ?, 0)", d.N)).Error; err != nil { + return + } + refunded += d.N + } + if userPart > 0 { + if err = mysql.Table(TableUser).Where("uid=?", uid). + Update("computebalance", gorm.Expr("computebalance + ?", userPart)).Error; err != nil { + return + } + refunded += userPart + } + return } type deviceCut struct { @@ -584,6 +649,7 @@ func ApplyComputeUsage(uid string, kind ComputeUsageKind, raw int64, userlog *pb } c.d.Computeused += c.n res.Device += c.n + res.Cuts = append(res.Cuts, DeviceCharge{Code: c.d.Code, N: c.n}) macs = append(macs, c.d.Devicemac) } if fromUser > 0 { diff --git a/apps/services/comm/compute_refund_test.go b/apps/services/comm/compute_refund_test.go new file mode 100644 index 00000000..258e75cb --- /dev/null +++ b/apps/services/comm/compute_refund_test.go @@ -0,0 +1,32 @@ +package comm + +import ( + "reflect" + "testing" +) + +// 扣费明细存进会议记录 computedevices 列(code:n,code:n),转写失败时按它原路退回。 +// code 可能是带冒号的 MAC,必须按最后一个冒号切。 +func TestDeviceChargesRoundTrip(t *testing.T) { + in := []DeviceCharge{ + {Code: "F0:A9:12:34:56:78", N: 30}, + {Code: "B0220000000000AB", N: 5}, + } + s := EncodeDeviceCharges(in) + if s != "F0:A9:12:34:56:78:30,B0220000000000AB:5" { + t.Fatalf("编码结果 = %q", s) + } + if got := DecodeDeviceCharges(s); !reflect.DeepEqual(got, in) { + t.Fatalf("解码结果 = %+v, want %+v", got, in) + } + // 空码、非正数不进明细;脏数据解析时跳过,不能退出负数 + if got := EncodeDeviceCharges([]DeviceCharge{{Code: "", N: 3}, {Code: "X", N: 0}}); got != "" { + t.Fatalf("空明细应编码为空串,实际 %q", got) + } + if got := DecodeDeviceCharges("abc,:5,X:-3,Y:notnum,Z:7"); !reflect.DeepEqual(got, []DeviceCharge{{Code: "Z", N: 7}}) { + t.Fatalf("脏数据解析 = %+v", got) + } + if got := DecodeDeviceCharges(""); len(got) != 0 { + t.Fatalf("空串应解析为空,实际 %+v", got) + } +} diff --git a/apps/services/modules/echomeet/CLAUDE.md b/apps/services/modules/echomeet/CLAUDE.md index fef5c447..c99c8a8c 100644 --- a/apps/services/modules/echomeet/CLAUDE.md +++ b/apps/services/modules/echomeet/CLAUDE.md @@ -54,7 +54,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co - **两处返回中间态的坑**:`PollTranscribe` 成功后先把 State 推到 `AwaitSummarizing` 落库,再 `go finishTranscribe(...)` 在后台写 `Translate`(刻意不共用指针,避免与 HTTP 响应序列化产生数据竞争)。于是 getrecord/getrecords 手上那个待序列化的 rec **必然**是「状态说转写完了、Translate 还是空」的中间态——不是偶发竞态,是每次首轮必中。客户端据此 `jsonDecode(translate)` 会抛异常。三个拉取入口统一用 `hideHalfDoneTranscribe` 在响应侧降级回 `Transcribing`(只改响应不落库,且只对本次真正轮询过的记录降级,否则 finishTranscribe 出错时记录会被永久钉住)。 - 短音频走字节 flash 同步通道(`ShortAudioProcess`);长音频/其他服务商走异步 submit+query。 -状态机见 `DBEchoMeetRecordState`(proto):Unknow→AwaitTranscribing→Transcribing→AwaitSummarizing→Summarizing→Completed→Readed,失败 TranscribeFail/SummarizFail。转写失败会**退还** `Meetintegral` 时长并冲正统计埋点。 +状态机见 `DBEchoMeetRecordState`(proto):Unknow→AwaitTranscribing→Transcribing→AwaitSummarizing→Summarizing→Completed→Readed,失败 TranscribeFail/SummarizFail。转写失败(八条失败路径,外加开始任务中途报错)一律按扣费明细**原路退还算力**:开始任务扣费时把明细记在记录的 `computeuser` / `computedevices` 两列,失败时 `refundRecordCompute` 先原子认领明细再退,保证只退一次;超额部分(闸门关时不够也放行的量)没扣过,不退。见 [compute_refund.go](compute_refund.go)。⚠️ 旧会议额度 `Meetintegral` 已停用,别再往里退(2026-09-25 以前就退错了地方)。 ## 语言码映射(core.go,易错) diff --git a/apps/services/modules/echomeet/api_alibackcall.go b/apps/services/modules/echomeet/api_alibackcall.go index bed6777c..9f46b56c 100644 --- a/apps/services/modules/echomeet/api_alibackcall.go +++ b/apps/services/modules/echomeet/api_alibackcall.go @@ -37,6 +37,7 @@ func (this *apiComp) AliBackCall(session comm.IUserSession, req *pb.EchomeetAliC // 21050001 = 成功 if req.StatusCode != 21050001 { model.State = pb.DBEchoMeetRecordState_TranscribeFail + this.module.model.refundRecordCompute(model, "阿里回调转写失败") this.module.model.saverecord(model) resp = &pb.EchomeetAliCallbackResp{} return diff --git a/apps/services/modules/echomeet/api_backcall.go b/apps/services/modules/echomeet/api_backcall.go index 475ce455..4e0ce129 100644 --- a/apps/services/modules/echomeet/api_backcall.go +++ b/apps/services/modules/echomeet/api_backcall.go @@ -47,6 +47,7 @@ func (this *apiComp) BackCall(session comm.IUserSession, req *pb.EchomeetCallbac if req.Code != 20000000 { model.State = pb.DBEchoMeetRecordState_TranscribeFail + this.module.model.refundRecordCompute(model, "字节回调转写失败") this.module.model.saverecord(model) resp = []byte(utils.ToString(map[string]string{ "code": "SUCCESS", diff --git a/apps/services/modules/echomeet/api_starttask.go b/apps/services/modules/echomeet/api_starttask.go index e15bb537..c5573a95 100644 --- a/apps/services/modules/echomeet/api_starttask.go +++ b/apps/services/modules/echomeet/api_starttask.go @@ -84,6 +84,23 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT } 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{ @@ -137,6 +154,7 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT if model.Rtype == "VOICETRANSLAT" { // 语音翻译:已有转写文本,只走翻译+总结,无需识别选路。 this.module.model.saverecord(model) + chargeSaved = true 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 { @@ -167,6 +185,7 @@ func (this *apiComp) StartTask(session comm.IUserSession, req *pb.EchomeetStartT } model.AsrSvcId = asrSvcId this.module.model.saverecord(model) + chargeSaved = true 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, diff --git a/apps/services/modules/echomeet/compute_refund.go b/apps/services/modules/echomeet/compute_refund.go new file mode 100644 index 00000000..9e899c7c --- /dev/null +++ b/apps/services/modules/echomeet/compute_refund.go @@ -0,0 +1,92 @@ +package echomeet + +import ( + "strings" + "time" + + "yunyan/comm" + "yunyan/lego/sys/mysql" + "yunyan/pb" +) + +// 语音纪要的算力退还(2026-09-25)。 +// +// 开始任务(api_starttask)按会议时长扣算力:先扣设备赠送、再扣用户余额,闸门关时不够也放行、 +// 差额记超额。之后转写可能失败,失败路径有八处(同步/异步提交、短音频处理、轮询、两个回调、卡住兜底……), +// 以前只有短音频那一处在「退」,而且退进了早已停用的旧会议额度 meetintegral——算力其实没回来。 +// +// 现在统一走这里: +// - 扣费明细在开始任务时记到记录上(computeuser / computedevices,见 recordCharge); +// - 任何一处判转写失败,都调 refundRecordCompute 按明细原路退回; +// - 退之前先在库里**原子地认领**明细(带条件把两列清零,影响 1 行才算认领到), +// 多条失败路径同时触发也只会退一次; +// - 超额部分从来没扣过,不在明细里,不会被退成白送的算力。 + +// recordCharge 把本次实际扣费的明细记到会议记录上(调用方随后 saverecord)。 +func recordCharge(rec *pb.DBEchoMeetRecord, res comm.ComputeResult) { + rec.Computeuser = res.User + rec.Computedevices = comm.EncodeDeviceCharges(res.Cuts) +} + +// refundRecordCompute 转写失败时按记录上的明细退还算力。**必须在 saverecord 之前调**: +// 它会把 rec 上的明细清零,随后那次保存才不会把已退的明细又写回去。 +func (this *modelComp) refundRecordCompute(rec *pb.DBEchoMeetRecord, reason string) { + if rec == nil || (rec.Computeuser == 0 && rec.Computedevices == "") { + return + } + userPart, devices := rec.Computeuser, comm.DecodeDeviceCharges(rec.Computedevices) + // 原子认领:只有把非空明细清零的那一次才去退,别的并发路径影响 0 行、直接跳过。 + res := mysql.Table(comm.TableEchomeetRecord). + Where("id = ? AND (computeuser <> 0 OR computedevices <> '')", rec.Id). + Updates(map[string]interface{}{"computeuser": 0, "computedevices": ""}) + rec.Computeuser, rec.Computedevices = 0, "" + if res.Error != nil { + this.module.Errorf("算力退还 id:%d 认领明细失败 err:%v(本次不退,避免重复退)", rec.Id, res.Error) + return + } + if res.RowsAffected == 0 { + this.module.Infof("算力退还 id:%d 明细已被其它路径认领,跳过", rec.Id) + return + } + this.refundCharge(rec.Uid, userPart, devices, int64(rec.Seconds), reason, rec.Id) +} + +// refundCharge 按明细退还并写一条 ComputeRefund 流水。开始任务中途失败(还没落到记录上)时直接用它。 +func (this *modelComp) refundCharge(uid string, userPart int64, devices []comm.DeviceCharge, seconds int64, reason string, recId uint64) { + refunded, err := comm.RefundComputeUsage(uid, userPart, devices) + if err != nil { + this.module.Errorf("算力退还 id:%d uid:%s 失败 err:%v(已退 %d)", recId, uid, err, refunded) + } + if refunded == 0 { + return + } + var devicePart int64 + codes := make([]string, 0, len(devices)) + for _, d := range devices { + devicePart += d.N + codes = append(codes, d.Code) + } + log := &pb.DBUserUseLog{ + Uid: uid, + Logtype: pb.UserLogType_ComputeRefund, + Ts: time.Now().Unix(), + Addmeetsecond: seconds, + Extra: reason, + Compute: refunded, + Computedevice: devicePart, + Computeuser: userPart, + Devicemac: truncate(strings.Join(codes, ","), 50), // 列宽 size:50 + } + if lerr := this.addUseLog(log); lerr != nil { + this.module.Warnf("算力退还 id:%d 写流水失败 err:%v", recId, lerr) + } + this.module.Infof("算力退还 id:%d uid:%s 退回 %d(设备 %d / 账户 %d)原因:%s", + recId, uid, refunded, devicePart, userPart, reason) +} + +func truncate(s string, n int) string { + if len(s) <= n { + return s + } + return s[:n] +} diff --git a/apps/services/modules/echomeet/tasks.go b/apps/services/modules/echomeet/tasks.go index 9c233a40..2cd20a36 100644 --- a/apps/services/modules/echomeet/tasks.go +++ b/apps/services/modules/echomeet/tasks.go @@ -186,6 +186,7 @@ func (this *tasksComp) SubmitTranscribeTask(task *pb.DBEchoMeetRecord) (err erro if err != nil { this.module.Errorf("SubmitTranscribeTask id:%d 路由失败 err:%v", task.Id, err) task.State = pb.DBEchoMeetRecordState_TranscribeFail + this.module.model.refundRecordCompute(task, "转写失败") this.module.model.saverecord(task) return err } @@ -216,6 +217,7 @@ func (this *tasksComp) SubmitTranscribeTask(task *pb.DBEchoMeetRecord) (err erro if nerr != nil { // 没有别的候选了:这才是真失败 task.State = pb.DBEchoMeetRecordState_TranscribeFail + this.module.model.refundRecordCompute(task, "转写失败") this.module.model.saverecord(task) return err } @@ -270,15 +272,13 @@ func (this *tasksComp) sweepStuckTranscribe() { // 超时判失败:第三方可能已经把任务弄丢了,不设上限就会每分钟白查一次 // 直到天荒地老。 // - // ⚠️ 这里**不退还** Meetintegral。退还逻辑只写在 ShortAudioProcess 里; - // 异步路径(PollTranscribe 查询失败)本来也不退,这里与之保持一致。 - // 也就是说:走异步转写的用户,扣掉的时长在失败后拿不回来——这是既有 - // 行为不是本次引入的,但确实是个待办。要补的话应当抽一个统一的 - // refundOnTranscribeFail,三处失败路径共用,否则容易重复退。 + // 判失败时按扣费明细退还算力(refundRecordCompute,所有转写失败路径共用一个入口, + // 靠原子认领保证只退一次,见 compute_refund.go)。 if rec.Starttime > 0 && now-rec.Starttime > StuckTranscribeGiveUp { this.module.Warnf("兜底扫描 id:%d 转写已 %ds 未完成,超过上限 %ds,判失败", rec.Id, now-rec.Starttime, StuckTranscribeGiveUp) rec.State = pb.DBEchoMeetRecordState_TranscribeFail + this.module.model.refundRecordCompute(rec, "转写失败") this.module.model.saverecord(rec) continue } @@ -383,11 +383,13 @@ func (this *tasksComp) ShortAudioProcess(task interface{}) { if err != nil { this.module.Errorf("ShortAudioProcess id:%d 转写失败 err:%v", rec.Id, err) rec.State = pb.DBEchoMeetRecordState_TranscribeFail + this.module.model.refundRecordCompute(rec, "转写失败") this.module.model.saverecord(rec) + // 算力已由上面的 refundRecordCompute 按扣费明细退回。这里原来是 user.Meetintegral += 秒数 + // 再整行 updateuser——退进了早已停用的旧会议额度(算力没回来),整行写回还可能用旧值覆盖掉 + // 并发变动的算力余额,2026-09-25 去掉。 if user, err := this.module.model.getuser(rec.Uid); err == nil { - user.Meetintegral += int64(rec.Seconds) - this.module.model.updateuser(user) - // 转写失败退还,冲正会议时长埋点 + // 转写失败,冲正会议时长埋点 if this.module.analyze != nil { this.module.analyze.Report(&comm.StatEvent{ Type: comm.StatEventMeeting, @@ -781,6 +783,7 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) { if err != nil { this.module.Errorf("转写轮询 id:%d 查询失败 err:%v,状态置为失败", rec.Id, err) rec.State = pb.DBEchoMeetRecordState_TranscribeFail + this.module.model.refundRecordCompute(rec, "转写失败") this.module.model.saverecord(rec) return } @@ -805,6 +808,7 @@ func (this *tasksComp) PollTranscribe(rec *pb.DBEchoMeetRecord) { default: this.module.Errorf("转写轮询 id:%d 状态异常 status:%s,标记为 TranscribeFail", rec.Id, result.Status) rec.State = pb.DBEchoMeetRecordState_TranscribeFail + this.module.model.refundRecordCompute(rec, "转写失败") this.module.model.saverecord(rec) } } diff --git a/apps/services/pb/echomeet_db.pb.go b/apps/services/pb/echomeet_db.pb.go index d4d6859d..e2fb3eea 100644 --- a/apps/services/pb/echomeet_db.pb.go +++ b/apps/services/pb/echomeet_db.pb.go @@ -254,9 +254,12 @@ type DBEchoMeetRecord struct { Processduration int64 `protobuf:"varint,26,opt,name=processduration,proto3" json:"processduration" gorm:"processduration"` //@go_tags(`gorm:"processduration" json:"processduration"`) 处理耗时 秒 Lastquerytime int64 `protobuf:"varint,27,opt,name=lastquerytime,proto3" json:"lastquerytime" gorm:"lastquerytime"` //@go_tags(`gorm:"lastquerytime" json:"lastquerytime"`) 上一次主动查询第三方服务的 unix 秒,用于限制查询频率 Remark string `protobuf:"bytes,28,opt,name=remark,proto3" json:"remark" gorm:"remark"` //@go_tags(`gorm:"remark" json:"remark"`) 用户备注(创建记录时填写,参与AI总结) - Imageurls string `protobuf:"bytes,29,opt,name=imageurls,proto3" json:"imageurls" gorm:"imageurls"` //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结 - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // 本次开始任务实际扣的算力明细,失败时按它原路退回(退完清零,保证只退一次)。只在服务端用,不下发。 + Computeuser int64 `protobuf:"varint,33,opt,name=computeuser,proto3" json:"-" gorm:"computeuser"` //@go_tags(`gorm:"computeuser" json:"-"`) 从用户余额扣的算力 + Computedevices string `protobuf:"bytes,34,opt,name=computedevices,proto3" json:"-" gorm:"computedevices"` //@go_tags(`gorm:"computedevices" json:"-"`) 从设备赠送额度扣的明细 code:n,code:n + Imageurls string `protobuf:"bytes,29,opt,name=imageurls,proto3" json:"imageurls" gorm:"imageurls"` //@go_tags(`gorm:"imageurls" json:"imageurls"`) 注释图片url列表(JSON数组字符串,编码方式同original),作为多模态内容参与AI总结 + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DBEchoMeetRecord) Reset() { @@ -499,6 +502,20 @@ func (x *DBEchoMeetRecord) GetRemark() string { return "" } +func (x *DBEchoMeetRecord) GetComputeuser() int64 { + if x != nil { + return x.Computeuser + } + return 0 +} + +func (x *DBEchoMeetRecord) GetComputedevices() string { + if x != nil { + return x.Computedevices + } + return "" +} + func (x *DBEchoMeetRecord) GetImageurls() string { if x != nil { return x.Imageurls @@ -950,7 +967,7 @@ const file_echomeet_echomeet_db_proto_rawDesc = "" + "\aoutline\x18\n" + " \x01(\tR\aoutline\x12\x1a\n" + "\btemplate\x18\v \x01(\tR\btemplate\x12\x12\n" + - "\x04sort\x18\f \x01(\x05R\x04sort\"\x9e\a\n" + + "\x04sort\x18\f \x01(\x05R\x04sort\"\xe8\a\n" + "\x10DBEchoMeetRecord\x12\x0e\n" + "\x02id\x18\x01 \x01(\x04R\x02id\x12\x10\n" + "\x03uid\x18\x02 \x01(\tR\x03uid\x12,\n" + @@ -988,7 +1005,9 @@ const file_echomeet_echomeet_db_proto_rawDesc = "" + "\tstarttime\x18\x19 \x01(\x03R\tstarttime\x12(\n" + "\x0fprocessduration\x18\x1a \x01(\x03R\x0fprocessduration\x12$\n" + "\rlastquerytime\x18\x1b \x01(\x03R\rlastquerytime\x12\x16\n" + - "\x06remark\x18\x1c \x01(\tR\x06remark\x12\x1c\n" + + "\x06remark\x18\x1c \x01(\tR\x06remark\x12 \n" + + "\vcomputeuser\x18! \x01(\x03R\vcomputeuser\x12&\n" + + "\x0ecomputedevices\x18\" \x01(\tR\x0ecomputedevices\x12\x1c\n" + "\timageurls\x18\x1d \x01(\tR\timageurls\"\x99\x01\n" + "\rContextStruct\x12\x1c\n" + "\tMeetingid\x18\x01 \x01(\x04R\tMeetingid\x12\x18\n" + diff --git a/apps/services/pb/enum.pb.go b/apps/services/pb/enum.pb.go index 66f750b2..3655812d 100644 --- a/apps/services/pb/enum.pb.go +++ b/apps/services/pb/enum.pb.go @@ -346,6 +346,8 @@ const ( // @Description 后台调整用户算力余额(加减) // @example UserLogType_AdminAdjust UserLogType = 5 //后台调整算力 + // 算力退还(语音纪要转写失败 / 开始任务失败时按扣费明细原路退回,2026-09-25) + UserLogType_ComputeRefund UserLogType = 6 //算力退还 ) // Enum value maps for UserLogType. @@ -357,6 +359,7 @@ var ( 3: "AdminGive", 4: "UserConsume", 5: "AdminAdjust", + 6: "ComputeRefund", } UserLogType_value = map[string]int32{ "UserLogTypeUnknown": 0, @@ -365,6 +368,7 @@ var ( "AdminGive": 3, "UserConsume": 4, "AdminAdjust": 5, + "ComputeRefund": 6, } ) @@ -678,14 +682,15 @@ const file_enum_proto_rawDesc = "" + "\x04Meet\x10\x02\x12\x06\n" + "\x02AI\x10\x03\x12\a\n" + "\x03VIP\x10\x04\x12\v\n" + - "\aCompute\x10\x05*s\n" + + "\aCompute\x10\x05*\x86\x01\n" + "\vUserLogType\x12\x16\n" + "\x12UserLogTypeUnknown\x10\x00\x12\a\n" + "\x03Pay\x10\x01\x12\x12\n" + "\x0eActivityReward\x10\x02\x12\r\n" + "\tAdminGive\x10\x03\x12\x0f\n" + "\vUserConsume\x10\x04\x12\x0f\n" + - "\vAdminAdjust\x10\x05*\"\n" + + "\vAdminAdjust\x10\x05\x12\x11\n" + + "\rComputeRefund\x10\x06*\"\n" + "\rMCPServerType\x12\b\n" + "\x04Http\x10\x00\x12\a\n" + "\x03SEE\x10\x01*m\n" +