Browse Source

语音纪要:有算力却提示余额不足(客户端按停用的旧会议额度拦截);转写失败按扣费明细原路退还算力

客户端
- 生成总结前不再判旧「会议额度」meetintegral <= 0 就弹余额不足:那个桶已随会员与算力改造停用,
  新账号恒为 0,于是有算力也被拦,请求都没发出去。准入交给服务端(没 VIP 回 VipRequired,
  算力不足按后台闸门处理),同翻译那次的修法
- 语音纪要页顶栏:原来按旧额度显示剩余时长 + 进度条(恒为 00:00:00),改为显示算力(user_getcompute)

服务端
- 开始任务扣算力后把明细记在记录上(新列 computeuser / computedevices,AutoMigrate 自动加);
  转写失败的八条路径与开始任务中途报错,一律经 refundRecordCompute / refundCharge 按明细原路退回:
  设备部分减回 computeused、账户部分加回 computebalance;先在库里原子认领明细再退,保证只退一次;
  超额部分从未扣过,不退
- 去掉短音频失败时 user.Meetintegral += 秒数 + 整行 updateuser(退进了停用的旧桶,整行写回还可能覆盖并发的算力变动)
- 新增流水类型 UserLogType.ComputeRefund = 6
- comm:ComputeResult 带每台设备的扣费明细(Cuts);新增 EncodeDeviceCharges / DecodeDeviceCharges / RefundComputeUsage,
  编解码有单测(MAC 带冒号,按最后一个冒号切)

验证:go build/vet 通过,comm / echomeet / user 测试通过;dart analyze 无 error,flutter test 221 全过;
测试服已部署 app + console,两列已加上、五个服务正常。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 2 weeks ago
parent
commit
6c9e0d48eb
  1. 21
      apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart
  2. 10
      apps/client/lib/modules/meeting/views/bottomSheet/generate_bottom_sheet.dart
  3. 72
      apps/client/lib/modules/meeting/views/meeting_home_view.dart
  4. 3
      apps/proto/echomeet/echomeet_db.proto
  5. 2
      apps/proto/enum.proto
  6. 82
      apps/services/comm/compute.go
  7. 32
      apps/services/comm/compute_refund_test.go
  8. 2
      apps/services/modules/echomeet/CLAUDE.md
  9. 1
      apps/services/modules/echomeet/api_alibackcall.go
  10. 1
      apps/services/modules/echomeet/api_backcall.go
  11. 19
      apps/services/modules/echomeet/api_starttask.go
  12. 92
      apps/services/modules/echomeet/compute_refund.go
  13. 20
      apps/services/modules/echomeet/tasks.go
  14. 29
      apps/services/pb/echomeet_db.pb.go
  15. 9
      apps/services/pb/enum.pb.go

21
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<void> 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<int> selectedIds = <int>[].obs;
bool get isAllSelected =>
@ -111,6 +131,7 @@ class MeetingHomeController extends GetxController {
@override
void onInit() {
super.onInit();
refreshCompute();
_requestStoragePermission();
_initGetDataList();
addMeetingAudio();

10
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<GenerateBottomSheet> {
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']);

72
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<MeetingHomeView>
),
),
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<MeetingHomeView>
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)}';
}
}

3
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总结
}

2
apps/proto/enum.proto

@ -132,6 +132,8 @@ enum UserLogType {
// @Description 后台调整用户算力余额(加减)
// @example
AdminAdjust = 5; //后台调整算力
// 算力退还(语音纪要转写失败 / 开始任务失败时按扣费明细原路退回,2026-09-25)
ComputeRefund = 6; //算力退还
}
//MCP服务类型

82
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 {

32
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)
}
}

2
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,易错)

1
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

1
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",

19
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,

92
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]
}

20
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)
}
}

29
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" +

9
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" +

Loading…
Cancel
Save