refactor(logs): add refund logging for asynchronous tasks and update translations

This commit is contained in:
CaIon
2026-02-21 22:48:30 +08:00
parent d6e11fd2e1
commit 809ba92089
12 changed files with 229 additions and 101 deletions

View File

@@ -451,8 +451,6 @@ func RelayNotFound(c *gin.Context) {
}
func RelayTask(c *gin.Context) {
channelId := c.GetInt("channel_id")
c.Set("use_channel", []string{fmt.Sprintf("%d", channelId)})
relayInfo, err := relaycommon.GenRelayInfo(c, types.RelayFormatTask, nil, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, &dto.TaskError{
@@ -463,8 +461,7 @@ func RelayTask(c *gin.Context) {
return
}
// Fetch 操作是纯 DB 查询(或 task 自带 channelId 的上游查询),不依赖上下文 channel无需重试
// TODO: 在video-route层面优化避免无谓的 channel 选择和上下文设置,也没必要吧代码放到这里来写这么多屎山
// Fetch 路径:纯 DB 查询,不依赖上下文 channel无需重试
switch relayInfo.RelayMode {
case relayconstant.RelayModeSunoFetch, relayconstant.RelayModeSunoFetchByID, relayconstant.RelayModeVideoFetchByID:
if taskErr := relay.RelayTaskFetch(c, relayInfo.RelayMode); taskErr != nil {
@@ -475,13 +472,11 @@ func RelayTask(c *gin.Context) {
// ── Submit 路径 ─────────────────────────────────────────────────
// 1. 解析原始任务remix / continuation一次性可能锁定渠道并禁止重试
if taskErr := relay.ResolveOriginTask(c, relayInfo); taskErr != nil {
respondTaskError(c, taskErr)
return
}
// 2. defer Refund全部失败时回滚预扣费
var result *relay.TaskSubmitResult
var taskErr *dto.TaskError
defer func() {
@@ -490,14 +485,57 @@ func RelayTask(c *gin.Context) {
}
}()
// 3. 执行 + 重试RelayTaskSubmit 内部在首次调用时自动预扣费)
taskErr = taskSubmitWithRetry(c, relayInfo, channelId, common.RetryTimes, func() *dto.TaskError {
var te *dto.TaskError
result, te = relay.RelayTaskSubmit(c, relayInfo)
return te
})
retryParam := &service.RetryParam{
Ctx: c,
TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName,
Retry: common.GetPointer(0),
}
// 4. 成功:结算 + 日志 + 插入任务
for ; retryParam.GetRetry() <= common.RetryTimes; retryParam.IncreaseRetry() {
channel, channelErr := getChannel(c, relayInfo, retryParam)
if channelErr != nil {
logger.LogError(c, channelErr.Error())
taskErr = service.TaskErrorWrapperLocal(channelErr.Err, "get_channel_failed", http.StatusInternalServerError)
break
}
addUsedChannel(c, channel.Id)
requestBody, bodyErr := common.GetRequestBody(c)
if bodyErr != nil {
if common.IsRequestBodyTooLargeError(bodyErr) || errors.Is(bodyErr, common.ErrRequestBodyTooLarge) {
taskErr = service.TaskErrorWrapperLocal(bodyErr, "read_request_body_failed", http.StatusRequestEntityTooLarge)
} else {
taskErr = service.TaskErrorWrapperLocal(bodyErr, "read_request_body_failed", http.StatusBadRequest)
}
break
}
c.Request.Body = io.NopCloser(bytes.NewBuffer(requestBody))
result, taskErr = relay.RelayTaskSubmit(c, relayInfo)
if taskErr == nil {
break
}
if !taskErr.LocalError {
processChannelError(c,
*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey,
common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()),
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode))
}
if !shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry()) {
break
}
}
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
logger.LogInfo(c, retryLogStr)
}
// ── 成功:结算 + 日志 + 插入任务 ──
if taskErr == nil {
if settleErr := service.SettleBilling(c, relayInfo, result.Quota); settleErr != nil {
common.SysError("settle task billing error: " + settleErr.Error())
@@ -520,7 +558,6 @@ func RelayTask(c *gin.Context) {
task.Data = result.TaskData
task.Action = relayInfo.Action
if insertErr := task.Insert(); insertErr != nil {
//taskErr = service.TaskErrorWrapper(insertErr, "insert_task_failed", http.StatusInternalServerError)
common.SysError("insert task error: " + insertErr.Error())
}
}
@@ -538,69 +575,6 @@ func respondTaskError(c *gin.Context, taskErr *dto.TaskError) {
c.JSON(taskErr.StatusCode, taskErr)
}
// taskSubmitWithRetry 执行首次尝试并在失败时切换渠道重试,返回最终的 taskErr。
// attempt 闭包负责实际的上游请求,不涉及计费。
func taskSubmitWithRetry(c *gin.Context, relayInfo *relaycommon.RelayInfo,
channelId int, retryTimes int, attempt func() *dto.TaskError) *dto.TaskError {
taskErr := attempt()
if taskErr == nil {
return nil
}
if !taskErr.LocalError {
processChannelError(c,
*types.NewChannelError(channelId, c.GetInt("channel_type"), c.GetString("channel_name"), common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey),
common.GetContextKeyString(c, constant.ContextKeyChannelKey), common.GetContextKeyBool(c, constant.ContextKeyChannelAutoBan)),
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode))
}
retryParam := &service.RetryParam{
Ctx: c,
TokenGroup: relayInfo.TokenGroup,
ModelName: relayInfo.OriginModelName,
Retry: common.GetPointer(0),
}
for ; shouldRetryTaskRelay(c, channelId, taskErr, retryTimes) && retryParam.GetRetry() < retryTimes; retryParam.IncreaseRetry() {
channel, newAPIError := getChannel(c, relayInfo, retryParam)
if newAPIError != nil {
logger.LogError(c, fmt.Sprintf("CacheGetRandomSatisfiedChannel failed: %s", newAPIError.Error()))
taskErr = service.TaskErrorWrapperLocal(newAPIError.Err, "get_channel_failed", http.StatusInternalServerError)
break
}
channelId = channel.Id
useChannel := c.GetStringSlice("use_channel")
useChannel = append(useChannel, fmt.Sprintf("%d", channelId))
c.Set("use_channel", useChannel)
logger.LogInfo(c, fmt.Sprintf("using channel #%d to retry (remain times %d)", channel.Id, retryParam.GetRetry()))
middleware.SetupContextForSelectedChannel(c, channel, c.GetString("original_model"))
bodyStorage, err := common.GetBodyStorage(c)
if err != nil {
if common.IsRequestBodyTooLargeError(err) || errors.Is(err, common.ErrRequestBodyTooLarge) {
taskErr = service.TaskErrorWrapperLocal(err, "read_request_body_failed", http.StatusRequestEntityTooLarge)
} else {
taskErr = service.TaskErrorWrapperLocal(err, "read_request_body_failed", http.StatusBadRequest)
}
break
}
c.Request.Body = io.NopCloser(bodyStorage)
taskErr = attempt()
if taskErr != nil && !taskErr.LocalError {
processChannelError(c,
*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey,
common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()),
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode))
}
}
useChannel := c.GetStringSlice("use_channel")
if len(useChannel) > 1 {
retryLogStr := fmt.Sprintf("重试:%s", strings.Trim(strings.Join(strings.Fields(fmt.Sprint(useChannel)), "->"), "[]"))
logger.LogInfo(c, retryLogStr)
}
return taskErr
}
func shouldRetryTaskRelay(c *gin.Context, channelId int, taskErr *dto.TaskError, retryTimes int) bool {
if taskErr == nil {
return false

View File

@@ -199,6 +199,49 @@ func RecordConsumeLog(c *gin.Context, userId int, params RecordConsumeLogParams)
}
}
type RecordTaskBillingLogParams struct {
UserId int
LogType int
Content string
ChannelId int
ModelName string
Quota int
TokenId int
Group string
Other map[string]interface{}
}
func RecordTaskBillingLog(params RecordTaskBillingLogParams) {
if params.LogType == LogTypeConsume && !common.LogConsumeEnabled {
return
}
username, _ := GetUsernameById(params.UserId, false)
tokenName := ""
if params.TokenId > 0 {
if token, err := GetTokenById(params.TokenId); err == nil {
tokenName = token.Name
}
}
log := &Log{
UserId: params.UserId,
Username: username,
CreatedAt: common.GetTimestamp(),
Type: params.LogType,
Content: params.Content,
TokenName: tokenName,
ModelName: params.ModelName,
Quota: params.Quota,
ChannelId: params.ChannelId,
TokenId: params.TokenId,
Group: params.Group,
Other: common.MapToJsonStr(params.Other),
}
err := LOG_DB.Create(log).Error
if err != nil {
common.SysLog("failed to record task billing log: " + err.Error())
}
}
func GetAllLogs(logType int, startTimestamp int64, endTimestamp int64, modelName string, username string, tokenName string, startIdx int, num int, channel int, group string, requestId string) (logs []*Log, total int64, err error) {
var tx *gorm.DB
if logType == LogTypeUnknown {

View File

@@ -108,6 +108,29 @@ func taskAdjustTokenQuota(ctx context.Context, task *model.Task, delta int) {
}
}
// taskBillingOther 从 task 的 BillingContext 构建日志 Other 字段。
func taskBillingOther(task *model.Task) map[string]interface{} {
other := make(map[string]interface{})
if bc := task.PrivateData.BillingContext; bc != nil {
other["model_price"] = bc.ModelPrice
other["group_ratio"] = bc.GroupRatio
if len(bc.OtherRatios) > 0 {
for k, v := range bc.OtherRatios {
other[k] = v
}
}
}
return other
}
// taskModelName 从 BillingContext 或 Properties 中获取模型名称。
func taskModelName(task *model.Task) string {
if bc := task.PrivateData.BillingContext; bc != nil && bc.ModelName != "" {
return bc.ModelName
}
return task.Properties.OriginModelName
}
// RefundTaskQuota 统一的任务失败退款逻辑。
// 当异步任务失败时,将预扣的 quota 退还给用户(支持钱包和订阅),并退还令牌额度。
func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
@@ -126,8 +149,20 @@ func RefundTaskQuota(ctx context.Context, task *model.Task, reason string) {
taskAdjustTokenQuota(ctx, task, -quota)
// 3. 记录日志
logContent := fmt.Sprintf("异步任务执行失败 %s补偿 %s原因%s", task.TaskID, logger.LogQuota(quota), reason)
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
other := taskBillingOther(task)
other["task_id"] = task.TaskID
other["reason"] = reason
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: model.LogTypeRefund,
Content: "",
ChannelId: task.ChannelId,
ModelName: taskModelName(task),
Quota: quota,
TokenId: task.PrivateData.TokenId,
Group: task.Group,
Other: other,
})
}
// RecalculateTaskQuota 通用的异步差额结算。
@@ -163,23 +198,35 @@ func RecalculateTaskQuota(ctx context.Context, task *model.Task, actualQuota int
// 调整令牌额度
taskAdjustTokenQuota(ctx, task, quotaDelta)
// 更新统计(仅补扣时更新,退还不影响已用统计)
if quotaDelta > 0 {
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
}
task.Quota = actualQuota
var action string
var logType int
var logQuota int
if quotaDelta > 0 {
action = "补扣费"
logType = model.LogTypeConsume
logQuota = quotaDelta
model.UpdateUserUsedQuotaAndRequestCount(task.UserId, quotaDelta)
model.UpdateChannelUsedQuota(task.ChannelId, quotaDelta)
} else {
action = "退还"
logType = model.LogTypeRefund
logQuota = -quotaDelta
}
logContent := fmt.Sprintf("异步任务成功%s预扣费 %s实际扣费 %s原因%s",
action,
logger.LogQuota(preConsumedQuota), logger.LogQuota(actualQuota), reason)
model.RecordLog(task.UserId, model.LogTypeSystem, logContent)
other := taskBillingOther(task)
other["task_id"] = task.TaskID
other["reason"] = reason
other["pre_consumed_quota"] = preConsumedQuota
other["actual_quota"] = actualQuota
model.RecordTaskBillingLog(model.RecordTaskBillingLogParams{
UserId: task.UserId,
LogType: logType,
Content: "",
ChannelId: task.ChannelId,
ModelName: taskModelName(task),
Quota: logQuota,
TokenId: task.PrivateData.TokenId,
Group: task.Group,
Other: other,
})
}
// RecalculateTaskQuotaByTokens 根据实际 token 消耗重新计费(异步差额结算)。

View File

@@ -133,6 +133,12 @@ function renderType(type, t) {
{t('错误')}
</Tag>
);
case 6:
return (
<Tag color='teal' shape='circle'>
{t('退款')}
</Tag>
);
default:
return (
<Tag color='grey' shape='circle'>
@@ -368,7 +374,7 @@ export const getLogsColumns = ({
}
return isAdminUser &&
(record.type === 0 || record.type === 2 || record.type === 5) ? (
(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? (
<Space>
<span style={{ position: 'relative', display: 'inline-block' }}>
<Tooltip content={record.channel_name || t('未知渠道')}>
@@ -459,7 +465,7 @@ export const getLogsColumns = ({
title: t('令牌'),
dataIndex: 'token_name',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 ? (
return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? (
<div>
<Tag
color='grey'
@@ -482,7 +488,7 @@ export const getLogsColumns = ({
title: t('分组'),
dataIndex: 'group',
render: (text, record, index) => {
if (record.type === 0 || record.type === 2 || record.type === 5) {
if (record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) {
if (record.group) {
return <>{renderGroup(record.group)}</>;
} else {
@@ -522,7 +528,7 @@ export const getLogsColumns = ({
title: t('模型'),
dataIndex: 'model_name',
render: (text, record, index) => {
return record.type === 0 || record.type === 2 || record.type === 5 ? (
return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? (
<>{renderModelName(record, copyText, t)}</>
) : (
<></>
@@ -589,7 +595,7 @@ export const getLogsColumns = ({
cacheText = `${t('缓存写')} ${formatTokenCount(cacheSummary.cacheWriteTokens)}`;
}
return record.type === 0 || record.type === 2 || record.type === 5 ? (
return record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6 ? (
<div
style={{
display: 'inline-flex',
@@ -623,7 +629,7 @@ export const getLogsColumns = ({
dataIndex: 'completion_tokens',
render: (text, record, index) => {
return parseInt(text) > 0 &&
(record.type === 0 || record.type === 2 || record.type === 5) ? (
(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6) ? (
<>{<span> {text} </span>}</>
) : (
<></>
@@ -635,7 +641,7 @@ export const getLogsColumns = ({
title: t('花费'),
dataIndex: 'quota',
render: (text, record, index) => {
if (!(record.type === 0 || record.type === 2 || record.type === 5)) {
if (!(record.type === 0 || record.type === 2 || record.type === 5 || record.type === 6)) {
return <></>;
}
const other = getLogOther(record.other);
@@ -722,6 +728,16 @@ export const getLogsColumns = ({
fixed: 'right',
render: (text, record, index) => {
let other = getLogOther(record.other);
if (record.type === 6) {
return (
<Typography.Paragraph
ellipsis={{ rows: 2 }}
style={{ maxWidth: 240 }}
>
{t('异步任务退款')}
</Typography.Paragraph>
);
}
if (other == null || record.type !== 2) {
return (
<Typography.Paragraph

View File

@@ -148,6 +148,7 @@ const LogsFilters = ({
<Form.Select.Option value='3'>{t('管理')}</Form.Select.Option>
<Form.Select.Option value='4'>{t('系统')}</Form.Select.Option>
<Form.Select.Option value='5'>{t('错误')}</Form.Select.Option>
<Form.Select.Option value='6'>{t('退款')}</Form.Select.Option>
</Form.Select>
</div>

View File

@@ -344,7 +344,7 @@ export const useLogsData = () => {
let other = getLogOther(logs[i].other);
let expandDataLocal = [];
if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2)) {
if (isAdminUser && (logs[i].type === 0 || logs[i].type === 2 || logs[i].type === 6)) {
expandDataLocal.push({
key: t('渠道信息'),
value: `${logs[i].channel} - ${logs[i].channel_name || '[未知]'}`,
@@ -535,6 +535,24 @@ export const useLogsData = () => {
});
}
}
if (logs[i].type === 6) {
if (other?.task_id) {
expandDataLocal.push({
key: t('任务ID'),
value: other.task_id,
});
}
if (other?.reason) {
expandDataLocal.push({
key: t('失败原因'),
value: (
<div style={{ maxWidth: 600, whiteSpace: 'normal', wordBreak: 'break-word', lineHeight: 1.6 }}>
{other.reason}
</div>
),
});
}
}
if (other?.request_path) {
expandDataLocal.push({
key: t('请求路径'),
@@ -590,13 +608,13 @@ export const useLogsData = () => {
),
});
}
if (isAdminUser) {
if (isAdminUser && logs[i].type !== 6) {
expandDataLocal.push({
key: t('请求转换'),
value: requestConversionDisplayValue(other?.request_conversion),
});
}
if (isAdminUser) {
if (isAdminUser && logs[i].type !== 6) {
let localCountMode = '';
if (other?.admin_info?.local_count_tokens) {
localCountMode = t('本地计费');

View File

@@ -2545,6 +2545,11 @@
"销毁容器": "Destroy Container",
"销毁容器失败": "Failed to destroy container",
"错误": "errors",
"退款": "Refund",
"错误详情": "Error Details",
"异步任务退款": "Async Task Refund",
"任务ID": "Task ID",
"失败原因": "Failure Reason",
"键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5使用test分组时倍率为1": "The key is the group name, and the value is another JSON object. The key is the group name, and the value is the special group ratio for users in that group. For example: {\"vip\": {\"default\": 0.5, \"test\": 1}} means that users in the vip group have a ratio of 0.5 when using tokens from the default group, and a ratio of 1 when using tokens from the test group",
"键为原状态码,值为要复写的状态码,仅影响本地判断": "The key is the original status code, and the value is the status code to override, only affects local judgment",
"键为用户分组名称,值为操作映射对象。内层键以\"+:\"开头表示添加指定分组(键值为分组名称,值为描述),以\"-:\"开头表示移除指定分组(键值为分组名称),不带前缀的键直接添加该分组。例如:{\"vip\": {\"+:premium\": \"高级分组\", \"special\": \"特殊分组\", \"-:default\": \"默认分组\"}},表示 vip 分组的用户可以使用 premium 和 special 分组,同时移除 default 分组的访问权限": "Keys are user group names and values are operation mappings. Inner keys prefixed with \"+:\" add the specified group (key is the group name, value is the description); keys prefixed with \"-:\" remove the specified group; keys without a prefix add that group directly. Example: {\"vip\": {\"+:premium\": \"Advanced group\", \"special\": \"Special group\", \"-:default\": \"Default group\"}} means vip users can access the premium and special groups while removing access to the default group.",

View File

@@ -2508,6 +2508,11 @@
"销毁容器": "Destroy Container",
"销毁容器失败": "Failed to destroy container",
"错误": "Erreur",
"退款": "Remboursement",
"错误详情": "Détails de l'erreur",
"异步任务退款": "Remboursement de tâche asynchrone",
"任务ID": "ID de tâche",
"失败原因": "Raison de l'échec",
"键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5使用test分组时倍率为1": "La clé est le nom du groupe, la valeur est un autre objet JSON, la clé est le nom du groupe, la valeur est le ratio de groupe spécial des utilisateurs de ce groupe, par exemple : {\"vip\": {\"default\": 0.5, \"test\": 1}}, ce qui signifie que les utilisateurs du groupe vip ont un ratio de 0.5 lors de l'utilisation de jetons du groupe default et un ratio de 1 lors de l'utilisation du groupe test",
"键为原状态码,值为要复写的状态码,仅影响本地判断": "La clé est le code d'état d'origine, la valeur est le code d'état à réécrire, n'affecte que le jugement local",
"键为用户分组名称,值为操作映射对象。内层键以\"+:\"开头表示添加指定分组(键值为分组名称,值为描述),以\"-:\"开头表示移除指定分组(键值为分组名称),不带前缀的键直接添加该分组。例如:{\"vip\": {\"+:premium\": \"高级分组\", \"special\": \"特殊分组\", \"-:default\": \"默认分组\"}},表示 vip 分组的用户可以使用 premium 和 special 分组,同时移除 default 分组的访问权限": "La clé correspond au nom du groupe d'utilisateurs et la valeur à un objet de mappage des opérations. Les clés internes commençant par \"+:\" ajoutent le groupe indiqué (clé = nom du groupe, valeur = description), celles commençant par \"-:\" retirent le groupe indiqué, et les clés sans préfixe ajoutent directement ce groupe. Exemple : {\"vip\": {\"+:premium\": \"Groupe avancé\", \"special\": \"Groupe spécial\", \"-:default\": \"Groupe par défaut\"}} signifie que les utilisateurs du groupe vip peuvent accéder aux groupes premium et special tout en perdant l'accès au groupe default.",

View File

@@ -2491,6 +2491,11 @@
"销毁容器": "Destroy Container",
"销毁容器失败": "Failed to destroy container",
"错误": "エラー",
"退款": "返金",
"错误详情": "エラー詳細",
"异步任务退款": "非同期タスク返金",
"任务ID": "タスクID",
"失败原因": "失敗の原因",
"键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5使用test分组时倍率为1": "キーはグループ名、値は別のJSONオブジェクトです。このオブジェクトのキーには、利用するトークンが属するグループ名を指定し、値にはそのユーザーグループに適用される特別な倍率を指定します。例{\"vip\": {\"default\": 0.5, \"test\": 1}} は、vipグループのユーザーがdefaultグループのトークンを利用する際の倍率が0.5、testグループのトークンを利用する際の倍率が1になることを示します",
"键为原状态码,值为要复写的状态码,仅影响本地判断": "キーは元のステータスコード、値は上書きするステータスコードで、ローカルでの判断にのみ影響します",
"键为用户分组名称,值为操作映射对象。内层键以\"+:\"开头表示添加指定分组(键值为分组名称,值为描述),以\"-:\"开头表示移除指定分组(键值为分组名称),不带前缀的键直接添加该分组。例如:{\"vip\": {\"+:premium\": \"高级分组\", \"special\": \"特殊分组\", \"-:default\": \"默认分组\"}},表示 vip 分组的用户可以使用 premium 和 special 分组,同时移除 default 分组的访问权限": "Keys are user group names and values are operation mappings. Inner keys prefixed with \"+:\" add the specified group (key is the group name, value is the description); keys prefixed with \"-:\" remove the specified group; keys without a prefix add that group directly. Example: {\"vip\": {\"+:premium\": \"Advanced group\", \"special\": \"Special group\", \"-:default\": \"Default group\"}} means vip users can access the premium and special groups while removing access to the default group.",

View File

@@ -2521,6 +2521,11 @@
"销毁容器": "Destroy Container",
"销毁容器失败": "Failed to destroy container",
"错误": "Ошибка",
"退款": "Возврат",
"错误详情": "Детали ошибки",
"异步任务退款": "Возврат асинхронной задачи",
"任务ID": "ID задачи",
"失败原因": "Причина ошибки",
"键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5使用test分组时倍率为1": "Ключ - это имя группы, значение - другой JSON объект, ключ - имя группы, значение - специальный групповой коэффициент для пользователей этой группы, например: {\"vip\": {\"default\": 0.5, \"test\": 1}}, означает, что пользователи группы vip при использовании токенов группы default имеют коэффициент 0.5, при использовании группы test - коэффициент 1",
"键为原状态码,值为要复写的状态码,仅影响本地判断": "Ключ - исходный код состояния, значение - код состояния для перезаписи, влияет только на локальную проверку",
"键为用户分组名称,值为操作映射对象。内层键以\"+:\"开头表示添加指定分组(键值为分组名称,值为描述),以\"-:\"开头表示移除指定分组(键值为分组名称),不带前缀的键直接添加该分组。例如:{\"vip\": {\"+:premium\": \"高级分组\", \"special\": \"特殊分组\", \"-:default\": \"默认分组\"}},表示 vip 分组的用户可以使用 premium 和 special 分组,同时移除 default 分组的访问权限": "Ключ — это название группы пользователей, значение — объект сопоставления операций. Внутренние ключи с префиксом \"+:\" добавляют указанные группы (ключ — название группы, значение — описание), с префиксом \"-:\" удаляют указанные группы, без префикса — сразу добавляют эту группу. Пример: {\"vip\": {\"+:premium\": \"Продвинутая группа\", \"special\": \"Особая группа\", \"-:default\": \"Группа по умолчанию\"}} означает, что пользователи группы vip могут использовать группы premium и special, одновременно теряя доступ к группе default.",

View File

@@ -3060,10 +3060,14 @@
"销毁容器失败": "Failed to destroy container",
"锁定": "Khóa",
"错误": "Lỗi",
"退款": "Hoàn tiền",
"错误信息": "Thông tin lỗi",
"错误日志": "Nhật ký lỗi",
"错误码": "Mã lỗi",
"错误详情": "Chi tiết lỗi",
"异步任务退款": "Hoàn tiền tác vụ bất đồng bộ",
"任务ID": "ID tác vụ",
"失败原因": "Nguyên nhân thất bại",
"键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5使用test分组时倍率为1": "Khóa là tên nhóm và giá trị là một đối tượng JSON khác. Khóa là tên nhóm và giá trị là tỷ lệ nhóm đặc biệt cho người dùng trong nhóm đó. Ví dụ: {\"vip\": {\"default\": 0.5, \"test\": 1}} có nghĩa là người dùng trong nhóm vip có tỷ lệ 0.5 khi sử dụng mã thông báo từ nhóm default và tỷ lệ 1 khi sử dụng mã thông báo từ nhóm test.",
"键为原状态码,值为要复写的状态码,仅影响本地判断": "Khóa là mã trạng thái gốc và giá trị là mã trạng thái cần ghi đè, chỉ ảnh hưởng đến phán đoán cục bộ",
"键为用户分组名称,值为操作映射对象。内层键以\"+:\"开头表示添加指定分组(键值为分组名称,值为描述),以\"-:\"开头表示移除指定分组(键值为分组名称),不带前缀的键直接添加该分组。例如:{\"vip\": {\"+:premium\": \"高级分组\", \"special\": \"特殊分组\", \"-:default\": \"默认分组\"}},表示 vip 分组的用户可以使用 premium 和 special 分组,同时移除 default 分组的访问权限": "Keys are user group names and values are operation mappings. Inner keys prefixed with \"+:\" add the specified group (key is the group name, value is the description); keys prefixed with \"-:\" remove the specified group; keys without a prefix add that group directly. Example: {\"vip\": {\"+:premium\": \"Advanced group\", \"special\": \"Special group\", \"-:default\": \"Default group\"}} means vip users can access the premium and special groups while removing access to the default group.",

View File

@@ -2531,6 +2531,11 @@
"销毁容器": "销毁容器",
"销毁容器失败": "销毁容器失败",
"错误": "错误",
"退款": "退款",
"错误详情": "错误详情",
"异步任务退款": "异步任务退款",
"任务ID": "任务ID",
"失败原因": "失败原因",
"键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5使用test分组时倍率为1": "键为分组名称,值为另一个 JSON 对象,键为分组名称,值为该分组的用户的特殊分组倍率,例如:{\"vip\": {\"default\": 0.5, \"test\": 1}},表示 vip 分组的用户在使用default分组的令牌时倍率为0.5使用test分组时倍率为1",
"键为原状态码,值为要复写的状态码,仅影响本地判断": "键为原状态码,值为要复写的状态码,仅影响本地判断",
"键为用户分组名称,值为操作映射对象。内层键以\"+:\"开头表示添加指定分组(键值为分组名称,值为描述),以\"-:\"开头表示移除指定分组(键值为分组名称),不带前缀的键直接添加该分组。例如:{\"vip\": {\"+:premium\": \"高级分组\", \"special\": \"特殊分组\", \"-:default\": \"默认分组\"}},表示 vip 分组的用户可以使用 premium 和 special 分组,同时移除 default 分组的访问权限": "键为用户分组名称,值为操作映射对象。内层键以\"+:\"开头表示添加指定分组(键值为分组名称,值为描述),以\"-:\"开头表示移除指定分组(键值为分组名称),不带前缀的键直接添加该分组。例如:{\"vip\": {\"+:premium\": \"高级分组\", \"special\": \"特殊分组\", \"-:default\": \"默认分组\"}},表示 vip 分组的用户可以使用 premium 和 special 分组,同时移除 default 分组的访问权限",