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
15 changed files with 306 additions and 89 deletions
@ -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) |
|||
} |
|||
} |
|||
@ -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] |
|||
} |
|||
Loading…
Reference in new issue