From 7b04b073ce53975e77ac8f102f50c0a974d6c989 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Wed, 6 May 2026 21:53:17 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.81.dev.260506=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=20Credit=20=E4=BB=B7=E6=A0=BC=E8=A7=84?= =?UTF-8?q?=E5=88=99=E8=A1=A5=E9=BD=90=E5=88=A9=E6=B6=A6=E7=8E=87=E4=B8=8E?= =?UTF-8?q?=E5=AE=9E=E9=99=85=E8=AE=A1=E8=B4=B9=E5=8D=95=E4=BB=B7=E8=AF=AD?= =?UTF-8?q?=E4=B9=89=EF=BC=9A=E6=96=B0=E5=A2=9E=20`profit=5Frate=5Fbps`=20?= =?UTF-8?q?=E4=B8=8E=20`charge=5F*=5Fprice=5Fmicros`=20=E5=B1=95=E7=A4=BA?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=EF=BC=8C=E4=B8=8B=E6=B2=89=E5=85=B1=E4=BA=AB?= =?UTF-8?q?=E4=BB=B7=E6=A0=BC=E6=8E=A8=E5=AF=BC=20helper=EF=BC=8Ctokenstor?= =?UTF-8?q?e=20rpc/client/proto/model/default=20rule=20=E5=85=A8=E9=93=BE?= =?UTF-8?q?=E8=B7=AF=E5=90=8C=E6=AD=A5=EF=BC=8CLLM=20usage=20=E6=89=A3?= =?UTF-8?q?=E8=B4=B9=E7=BB=9F=E4=B8=80=E6=94=B9=E6=8C=89=E5=8A=A0=E4=BB=B7?= =?UTF-8?q?=E5=90=8E=E7=9A=84=20charge=20=E5=8D=95=E4=BB=B7=E6=8D=A2?= =?UTF-8?q?=E7=AE=97=E3=80=82=202.=20task-class=20=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E9=93=BE=E8=B7=AF=E4=BF=AE=E6=AD=A3=E5=85=A8=E9=87=8F=E8=A6=86?= =?UTF-8?q?=E7=9B=96=E4=B8=8E=E5=BD=92=E5=B1=9E=E6=A0=A1=E9=AA=8C=EF=BC=9A?= =?UTF-8?q?`runtime/conv`=20=E4=BF=9D=E7=95=99=20item=20id=EF=BC=8CDAO=20?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=89=8D=E6=98=BE=E5=BC=8F=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=20task-class=20=E4=B8=8E=20item=20=E5=BD=92=E5=B1=9E=EF=BC=8C?= =?UTF-8?q?=E6=94=B9=E7=94=A8=E6=98=BE=E5=BC=8F=E5=AD=97=E6=AE=B5=20map=20?= =?UTF-8?q?=E8=90=BD=E5=BA=93=20nil/=E7=A9=BA=E5=88=87=E7=89=87/=E9=9B=B6?= =?UTF-8?q?=E5=80=BC=EF=BC=8C=E9=81=BF=E5=85=8D=20`RowsAffected=3D0`=20?= =?UTF-8?q?=E8=AF=AF=E5=88=A4=E8=B6=8A=E6=9D=83=EF=BC=8C=E5=90=8C=E6=97=B6?= =?UTF-8?q?=E8=A1=A5=E9=BD=90=E4=BB=BB=E5=8A=A1=E9=A1=B9=E5=8F=AF=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E5=AD=97=E6=AE=B5=E6=9B=B4=E6=96=B0=E3=80=82=203.=20G?= =?UTF-8?q?ormCache=20task-class=20=E5=A4=B1=E6=95=88=E8=A1=A5=E7=A9=BA=20?= =?UTF-8?q?user=5Fid=20=E4=BF=9D=E6=8A=A4=EF=BC=9A=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E8=AF=AD=E5=8F=A5=E7=BC=BA=E5=B0=91=E6=A8=A1=E5=9E=8B=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87=E6=97=B6=E7=9B=B4=E6=8E=A5=E8=B7=B3=E8=BF=87?= =?UTF-8?q?=E5=A4=B1=E6=95=88=EF=BC=8C=E9=81=BF=E5=85=8D=E7=BC=93=E5=AD=98?= =?UTF-8?q?=E6=8F=92=E4=BB=B6=E5=9B=A0=E7=A9=BA=E6=8C=87=E9=92=88=E5=BD=B1?= =?UTF-8?q?=E5=93=8D=E4=B8=BB=E4=BA=8B=E5=8A=A1=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: 4. 课表中心补齐任务类编辑能力:新增 `updateTaskClass` API,创建弹窗支持编辑态回填与 item id 提交,日程页支持先拉详情再编辑并在保存后刷新任务类详情与列表。 5. 计划广场详情补点赞交互与奖励提示:详情页新增点赞/取消点赞按钮、奖励反馈文案与计数展示,论坛类型补 `reward_hint`,评论区与帖子作者头像统一接入兜底头像工具。 6. 品牌与展示细节收口:侧边栏与 favicon 切到项目 logo,首页标题改为 `SmartMate`,主面板缩放上限微调,论坛列表头像显示与整体品牌观感同步统一。 --- backend/client/tokenstore/credit.go | 29 ++-- backend/services/llm/dao/pricing.go | 1 + backend/services/llm/pricing.go | 24 +-- backend/services/runtime/conv/task-class.go | 1 + backend/services/task_class/dao/task_class.go | 129 ++++++++++++-- backend/services/tokenstore/dao/connect.go | 1 + backend/services/tokenstore/model/credit.go | 9 +- backend/services/tokenstore/rpc/credit.go | 29 ++-- .../tokenstore/rpc/pb/tokenstore.pb.go | 29 ++-- .../services/tokenstore/rpc/tokenstore.proto | 5 + .../services/tokenstore/sv/credit_helpers.go | 36 ++-- .../shared/contracts/creditstore/pricing.go | 57 +++++++ backend/shared/contracts/creditstore/types.go | 35 ++-- .../shared/infra/gormcache/cache_deleter.go | 7 + frontend/index.html | 1 + frontend/src/api/scheduleCenter.ts | 16 ++ .../src/components/common/MainSidebar.vue | 14 +- .../schedule/CreateTaskClassDialog.vue | 56 ++++-- frontend/src/types/forum.ts | 7 + frontend/src/types/schedule.ts | 1 + frontend/src/utils/avatar.ts | 16 ++ frontend/src/views/DashboardView.vue | 4 +- frontend/src/views/ForumView.vue | 3 +- frontend/src/views/PlanDetailView.vue | 161 +++++++++++++++++- frontend/src/views/ScheduleView.vue | 46 ++++- 25 files changed, 594 insertions(+), 123 deletions(-) create mode 100644 backend/shared/contracts/creditstore/pricing.go create mode 100644 frontend/src/utils/avatar.ts diff --git a/backend/client/tokenstore/credit.go b/backend/client/tokenstore/credit.go index d92656f..35cc070 100644 --- a/backend/client/tokenstore/credit.go +++ b/backend/client/tokenstore/credit.go @@ -319,18 +319,23 @@ func creditPriceRuleFromPB(item *pb.CreditPriceRuleView) creditcontracts.CreditP return creditcontracts.CreditPriceRuleView{} } return creditcontracts.CreditPriceRuleView{ - RuleID: item.RuleId, - Scene: item.Scene, - ProviderName: item.ProviderName, - ModelName: item.ModelName, - InputPriceMicros: item.InputPriceMicros, - OutputPriceMicros: item.OutputPriceMicros, - CachedPriceMicros: item.CachedPriceMicros, - ReasoningPriceMicros: item.ReasoningPriceMicros, - CreditPerYuan: item.CreditPerYuan, - Status: item.Status, - Priority: int(item.Priority), - Description: item.Description, + RuleID: item.RuleId, + Scene: item.Scene, + ProviderName: item.ProviderName, + ModelName: item.ModelName, + InputPriceMicros: item.InputPriceMicros, + OutputPriceMicros: item.OutputPriceMicros, + CachedPriceMicros: item.CachedPriceMicros, + ReasoningPriceMicros: item.ReasoningPriceMicros, + CreditPerYuan: item.CreditPerYuan, + ProfitRateBps: item.ProfitRateBps, + ChargeInputPriceMicros: item.ChargeInputPriceMicros, + ChargeOutputPriceMicros: item.ChargeOutputPriceMicros, + ChargeCachedPriceMicros: item.ChargeCachedPriceMicros, + ChargeReasoningPriceMicros: item.ChargeReasoningPriceMicros, + Status: item.Status, + Priority: int(item.Priority), + Description: item.Description, } } diff --git a/backend/services/llm/dao/pricing.go b/backend/services/llm/dao/pricing.go index dca0cfb..01f35f0 100644 --- a/backend/services/llm/dao/pricing.go +++ b/backend/services/llm/dao/pricing.go @@ -18,6 +18,7 @@ type CreditPriceRule struct { CachedPriceMicros int64 `gorm:"column:cached_price_micros"` ReasoningPriceMicros int64 `gorm:"column:reasoning_price_micros"` CreditPerYuan int64 `gorm:"column:credit_per_yuan"` + ProfitRateBps int64 `gorm:"column:profit_rate_bps"` Status string `gorm:"column:status"` Priority int `gorm:"column:priority"` Description string `gorm:"column:description"` diff --git a/backend/services/llm/pricing.go b/backend/services/llm/pricing.go index 9ce8f5d..a8f140d 100644 --- a/backend/services/llm/pricing.go +++ b/backend/services/llm/pricing.go @@ -7,6 +7,7 @@ import ( "time" llmdao "github.com/LoveLosita/smartflow/backend/services/llm/dao" + creditcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/creditstore" ) const ( @@ -152,19 +153,18 @@ func quoteUsagePrice(rule llmdao.CreditPriceRule, input UsagePricingInput) Usage nonCachedInputTokens := inputTokens - cachedTokens nonReasoningOutputTokens := outputTokens - reasoningTokens - cachedPriceMicros := rule.CachedPriceMicros - if cachedPriceMicros <= 0 { - cachedPriceMicros = rule.InputPriceMicros - } - reasoningPriceMicros := rule.ReasoningPriceMicros - if reasoningPriceMicros <= 0 { - reasoningPriceMicros = rule.OutputPriceMicros - } + chargePrices := creditcontracts.DeriveChargePriceMicrosSet( + rule.InputPriceMicros, + rule.OutputPriceMicros, + rule.CachedPriceMicros, + rule.ReasoningPriceMicros, + rule.ProfitRateBps, + ) - totalMicrosScaled := nonCachedInputTokens*maxInt64(rule.InputPriceMicros, 0) + - cachedTokens*maxInt64(cachedPriceMicros, 0) + - nonReasoningOutputTokens*maxInt64(rule.OutputPriceMicros, 0) + - reasoningTokens*maxInt64(reasoningPriceMicros, 0) + totalMicrosScaled := nonCachedInputTokens*maxInt64(chargePrices.InputChargePriceMicros, 0) + + cachedTokens*maxInt64(chargePrices.CachedChargePriceMicros, 0) + + nonReasoningOutputTokens*maxInt64(chargePrices.OutputChargePriceMicros, 0) + + reasoningTokens*maxInt64(chargePrices.ReasoningChargePriceMicros, 0) rmbCostMicros := ceilDivInt64(totalMicrosScaled, tokenPriceScalePer1K) creditCost := int64(0) diff --git a/backend/services/runtime/conv/task-class.go b/backend/services/runtime/conv/task-class.go index 4cc2052..f455c93 100644 --- a/backend/services/runtime/conv/task-class.go +++ b/backend/services/runtime/conv/task-class.go @@ -67,6 +67,7 @@ func ProcessUserAddTaskClassRequest(req *model.UserAddTaskClassRequest, userID i var items []model.TaskClassItem for _, itemReq := range req.Items { item := model.TaskClassItem{ //填充section 2 + ID: itemReq.ID, Order: &itemReq.Order, Content: &itemReq.Content, EmbeddedTime: itemReq.EmbeddedTime, diff --git a/backend/services/task_class/dao/task_class.go b/backend/services/task_class/dao/task_class.go index 677e09b..3fe3fbe 100644 --- a/backend/services/task_class/dao/task_class.go +++ b/backend/services/task_class/dao/task_class.go @@ -3,6 +3,7 @@ package dao import ( "context" "errors" + "time" "github.com/LoveLosita/smartflow/backend/services/runtime/model" "github.com/LoveLosita/smartflow/backend/shared/respond" @@ -40,17 +41,23 @@ func (dao *TaskClassDAO) AddOrUpdateTaskClass(userID int, taskClass *model.TaskC } return taskClass.ID, nil } - // 更新:必须同时匹配 id + user_id,否则不会更新任何行(避免覆盖他人数据) - tx := dao.db.Model(&model.TaskClass{}). + + // 1. 先显式校验任务类归属,避免“更新值与库内完全相同”时 RowsAffected=0 被误判成越权。 + // 2. 这里只负责校验 id/user_id 是否匹配,不负责判断具体字段有没有变化。 + // 3. 若确实不存在或不属于当前用户,统一返回 UserTaskClassForbidden。 + if err := dao.ensureTaskClassOwned(userID, taskClass.ID); err != nil { + return 0, err + } + + // 1. 更新语义是“前端提交什么,就以什么覆盖数据库当前值”。 + // 2. 因此这里不能再直接用 struct Updates,否则 nil/空切片 等零值字段会被 GORM 跳过,刷新后看起来像“没更新”。 + // 3. 统一改成显式字段映射,保证可选字段清空、排除数组清空都能真正落库。 + tx := dao.db.Model(&model.TaskClass{UserID: &userID}). Where("id = ? AND user_id = ?", taskClass.ID, userID). - Updates(taskClass) + Updates(buildTaskClassUpdateMap(taskClass)) if tx.Error != nil { return 0, tx.Error } - if tx.RowsAffected == 0 { - // 未匹配到记录:要么不存在,要么不属于该用户 - return 0, respond.UserTaskClassForbidden - } return taskClass.ID, nil } @@ -82,7 +89,27 @@ func (dao *TaskClassDAO) AddOrUpdateTaskClassItems(userID int, items []model.Tas return respond.UserTaskClassForbidden } - // 2) 新增与更新分开处理:新增不受影响;更新时限定 category_id(防越权) + // 2. 收集本次要更新的已有 item,先做一次归属校验。 + // 2.1 这里单独校验是为了避免“只更新成原值”时 RowsAffected=0 被误判为越权。 + // 2.2 校验通过后,后面的 UPDATE 只关心数据库错误,不再用 RowsAffected 判权限。 + // 2.3 若请求里混入了不属于这些 task_class 的 item_id,统一返回 UserTaskClassForbidden。 + existingItemIDs := make([]int, 0, len(items)) + existingItemIDSet := make(map[int]struct{}, len(items)) + for _, it := range items { + if it.ID == 0 { + continue + } + if _, exists := existingItemIDSet[it.ID]; exists { + continue + } + existingItemIDSet[it.ID] = struct{}{} + existingItemIDs = append(existingItemIDs, it.ID) + } + if err := dao.ensureTaskClassItemsOwnedByCategories(existingItemIDs, categoryIDs); err != nil { + return err + } + + // 3) 新增与更新分开处理:新增直接插入;已有 item 按现有契约更新可编辑字段。 var toCreate []model.TaskClassItem for _, it := range items { if it.ID == 0 { @@ -93,14 +120,14 @@ func (dao *TaskClassDAO) AddOrUpdateTaskClassItems(userID int, items []model.Tas tx := dao.db.Model(&model.TaskClassItem{}). Where("id = ? AND category_id IN ?", it.ID, categoryIDs). Updates(map[string]any{ - "category_id": it.CategoryID, + "category_id": it.CategoryID, + "order": it.Order, + "content": it.Content, + "embedded_time": it.EmbeddedTime, }) if tx.Error != nil { return tx.Error } - if tx.RowsAffected == 0 { - return respond.UserTaskClassForbidden - } } if len(toCreate) > 0 { @@ -111,6 +138,84 @@ func (dao *TaskClassDAO) AddOrUpdateTaskClassItems(userID int, items []model.Tas return nil } +// ensureTaskClassOwned 只负责校验 task_class 是否属于当前用户。 +func (dao *TaskClassDAO) ensureTaskClassOwned(userID int, taskClassID int) error { + var count int64 + if err := dao.db.Model(&model.TaskClass{}). + Where("id = ? AND user_id = ?", taskClassID, userID). + Count(&count).Error; err != nil { + return err + } + if count == 0 { + return respond.UserTaskClassForbidden + } + return nil +} + +// ensureTaskClassItemsOwnedByCategories 只负责校验一批 item 是否都挂在允许的 task_class 下。 +func (dao *TaskClassDAO) ensureTaskClassItemsOwnedByCategories(itemIDs []int, categoryIDs []int) error { + if len(itemIDs) == 0 { + return nil + } + + var count int64 + if err := dao.db.Model(&model.TaskClassItem{}). + Where("id IN ? AND category_id IN ?", itemIDs, categoryIDs). + Count(&count).Error; err != nil { + return err + } + if count != int64(len(itemIDs)) { + return respond.UserTaskClassForbidden + } + return nil +} + +// buildTaskClassUpdateMap 负责把“全量更新”请求转换成显式列更新。 +func buildTaskClassUpdateMap(taskClass *model.TaskClass) map[string]any { + return map[string]any{ + "name": nullableStringValue(taskClass.Name), + "mode": nullableStringValue(taskClass.Mode), + "start_date": nullableTimeValue(taskClass.StartDate), + "end_date": nullableTimeValue(taskClass.EndDate), + "subject_type": nullableStringValue(taskClass.SubjectType), + "difficulty_level": nullableStringValue(taskClass.DifficultyLevel), + "cognitive_intensity": nullableStringValue(taskClass.CognitiveIntensity), + "total_slots": nullableIntValue(taskClass.TotalSlots), + "allow_filler_course": nullableBoolValue(taskClass.AllowFillerCourse), + "strategy": nullableStringValue(taskClass.Strategy), + "excluded_slots": taskClass.ExcludedSlots, + "excluded_days_of_week": taskClass.ExcludedDaysOfWeek, + } +} + +func nullableStringValue(value *string) any { + if value == nil { + return nil + } + return *value +} + +func nullableIntValue(value *int) any { + if value == nil { + return nil + } + return *value +} + +func nullableBoolValue(value *bool) any { + if value == nil { + return nil + } + return *value +} + +func nullableTimeValue(value *time.Time) any { + if value == nil { + return nil + } + return *value +} + // Transaction 在一个事务中执行传入的函数,供 service 层复用(自动提交/回滚) // 规则:fn 返回 nil -> commit;fn 返回 error 或 panic -> rollback func (dao *TaskClassDAO) Transaction(fn func(txDAO *TaskClassDAO) error) error { diff --git a/backend/services/tokenstore/dao/connect.go b/backend/services/tokenstore/dao/connect.go index 4a2f5d1..bc3dc9d 100644 --- a/backend/services/tokenstore/dao/connect.go +++ b/backend/services/tokenstore/dao/connect.go @@ -264,6 +264,7 @@ func defaultCreditPriceRules() []storemodel.CreditPriceRule { CachedPriceMicros: 800, ReasoningPriceMicros: 16000, CreditPerYuan: 100, + ProfitRateBps: 0, Status: storemodel.CreditPriceRuleStatusActive, Priority: 100, Description: "Default Ark rule, prices are expressed in micros CNY per 1K tokens.", diff --git a/backend/services/tokenstore/model/credit.go b/backend/services/tokenstore/model/credit.go index 939bac9..ddafd1e 100644 --- a/backend/services/tokenstore/model/credit.go +++ b/backend/services/tokenstore/model/credit.go @@ -172,11 +172,12 @@ type CreditPriceRule struct { Scene string `gorm:"column:scene;type:varchar(64);not null;index:idx_credit_price_rules_scene_status,priority:1;comment:计费场景"` ProviderName string `gorm:"column:provider_name;type:varchar(64);not null;comment:模型提供方"` ModelName string `gorm:"column:model_name;type:varchar(128);not null;comment:模型名称"` - InputPriceMicros int64 `gorm:"column:input_price_micros;not null;default:0;comment:输入Token单价,单位微人民币"` - OutputPriceMicros int64 `gorm:"column:output_price_micros;not null;default:0;comment:输出Token单价,单位微人民币"` - CachedPriceMicros int64 `gorm:"column:cached_price_micros;not null;default:0;comment:缓存Token单价,单位微人民币"` - ReasoningPriceMicros int64 `gorm:"column:reasoning_price_micros;not null;default:0;comment:推理Token单价,单位微人民币"` + InputPriceMicros int64 `gorm:"column:input_price_micros;not null;default:0;comment:原始输入Token成本单价,单位微人民币"` + OutputPriceMicros int64 `gorm:"column:output_price_micros;not null;default:0;comment:原始输出Token成本单价,单位微人民币"` + CachedPriceMicros int64 `gorm:"column:cached_price_micros;not null;default:0;comment:原始缓存Token成本单价,单位微人民币"` + ReasoningPriceMicros int64 `gorm:"column:reasoning_price_micros;not null;default:0;comment:原始推理Token成本单价,单位微人民币"` CreditPerYuan int64 `gorm:"column:credit_per_yuan;not null;default:0;comment:1元人民币换算多少Credit"` + ProfitRateBps int64 `gorm:"column:profit_rate_bps;not null;default:0;comment:在原始CNY成本基础上加价多少基点,10000=100%"` Status string `gorm:"column:status;type:varchar(32);not null;default:'inactive';index:idx_credit_price_rules_scene_status,priority:2;comment:active/inactive"` Priority int `gorm:"column:priority;not null;default:0;comment:匹配优先级,越大越优先"` Description string `gorm:"column:description;type:varchar(255);comment:规则说明"` diff --git a/backend/services/tokenstore/rpc/credit.go b/backend/services/tokenstore/rpc/credit.go index 7015853..87a68be 100644 --- a/backend/services/tokenstore/rpc/credit.go +++ b/backend/services/tokenstore/rpc/credit.go @@ -336,18 +336,23 @@ func creditTransactionsToPB(items []creditcontracts.CreditTransactionView) []*pb func creditPriceRuleToPB(rule creditcontracts.CreditPriceRuleView) *pb.CreditPriceRuleView { return &pb.CreditPriceRuleView{ - RuleId: rule.RuleID, - Scene: rule.Scene, - ProviderName: rule.ProviderName, - ModelName: rule.ModelName, - InputPriceMicros: rule.InputPriceMicros, - OutputPriceMicros: rule.OutputPriceMicros, - CachedPriceMicros: rule.CachedPriceMicros, - ReasoningPriceMicros: rule.ReasoningPriceMicros, - CreditPerYuan: rule.CreditPerYuan, - Status: rule.Status, - Priority: int32(rule.Priority), - Description: rule.Description, + RuleId: rule.RuleID, + Scene: rule.Scene, + ProviderName: rule.ProviderName, + ModelName: rule.ModelName, + InputPriceMicros: rule.InputPriceMicros, + OutputPriceMicros: rule.OutputPriceMicros, + CachedPriceMicros: rule.CachedPriceMicros, + ReasoningPriceMicros: rule.ReasoningPriceMicros, + CreditPerYuan: rule.CreditPerYuan, + ProfitRateBps: rule.ProfitRateBps, + ChargeInputPriceMicros: rule.ChargeInputPriceMicros, + ChargeOutputPriceMicros: rule.ChargeOutputPriceMicros, + ChargeCachedPriceMicros: rule.ChargeCachedPriceMicros, + ChargeReasoningPriceMicros: rule.ChargeReasoningPriceMicros, + Status: rule.Status, + Priority: int32(rule.Priority), + Description: rule.Description, } } diff --git a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go index 43decb6..1a0be16 100644 --- a/backend/services/tokenstore/rpc/pb/tokenstore.pb.go +++ b/backend/services/tokenstore/rpc/pb/tokenstore.pb.go @@ -331,18 +331,23 @@ func (m *CreditTransactionView) String() string { return proto.CompactTextString func (*CreditTransactionView) ProtoMessage() {} type CreditPriceRuleView struct { - RuleId uint64 `protobuf:"varint,1,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"` - Scene string `protobuf:"bytes,2,opt,name=scene,proto3" json:"scene,omitempty"` - ProviderName string `protobuf:"bytes,3,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` - ModelName string `protobuf:"bytes,4,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"` - InputPriceMicros int64 `protobuf:"varint,5,opt,name=input_price_micros,json=inputPriceMicros,proto3" json:"input_price_micros,omitempty"` - OutputPriceMicros int64 `protobuf:"varint,6,opt,name=output_price_micros,json=outputPriceMicros,proto3" json:"output_price_micros,omitempty"` - CachedPriceMicros int64 `protobuf:"varint,7,opt,name=cached_price_micros,json=cachedPriceMicros,proto3" json:"cached_price_micros,omitempty"` - ReasoningPriceMicros int64 `protobuf:"varint,8,opt,name=reasoning_price_micros,json=reasoningPriceMicros,proto3" json:"reasoning_price_micros,omitempty"` - CreditPerYuan int64 `protobuf:"varint,9,opt,name=credit_per_yuan,json=creditPerYuan,proto3" json:"credit_per_yuan,omitempty"` - Status string `protobuf:"bytes,10,opt,name=status,proto3" json:"status,omitempty"` - Priority int32 `protobuf:"varint,11,opt,name=priority,proto3" json:"priority,omitempty"` - Description string `protobuf:"bytes,12,opt,name=description,proto3" json:"description,omitempty"` + RuleId uint64 `protobuf:"varint,1,opt,name=rule_id,json=ruleId,proto3" json:"rule_id,omitempty"` + Scene string `protobuf:"bytes,2,opt,name=scene,proto3" json:"scene,omitempty"` + ProviderName string `protobuf:"bytes,3,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + ModelName string `protobuf:"bytes,4,opt,name=model_name,json=modelName,proto3" json:"model_name,omitempty"` + InputPriceMicros int64 `protobuf:"varint,5,opt,name=input_price_micros,json=inputPriceMicros,proto3" json:"input_price_micros,omitempty"` + OutputPriceMicros int64 `protobuf:"varint,6,opt,name=output_price_micros,json=outputPriceMicros,proto3" json:"output_price_micros,omitempty"` + CachedPriceMicros int64 `protobuf:"varint,7,opt,name=cached_price_micros,json=cachedPriceMicros,proto3" json:"cached_price_micros,omitempty"` + ReasoningPriceMicros int64 `protobuf:"varint,8,opt,name=reasoning_price_micros,json=reasoningPriceMicros,proto3" json:"reasoning_price_micros,omitempty"` + CreditPerYuan int64 `protobuf:"varint,9,opt,name=credit_per_yuan,json=creditPerYuan,proto3" json:"credit_per_yuan,omitempty"` + Status string `protobuf:"bytes,10,opt,name=status,proto3" json:"status,omitempty"` + Priority int32 `protobuf:"varint,11,opt,name=priority,proto3" json:"priority,omitempty"` + Description string `protobuf:"bytes,12,opt,name=description,proto3" json:"description,omitempty"` + ProfitRateBps int64 `protobuf:"varint,13,opt,name=profit_rate_bps,json=profitRateBps,proto3" json:"profit_rate_bps,omitempty"` + ChargeInputPriceMicros int64 `protobuf:"varint,14,opt,name=charge_input_price_micros,json=chargeInputPriceMicros,proto3" json:"charge_input_price_micros,omitempty"` + ChargeOutputPriceMicros int64 `protobuf:"varint,15,opt,name=charge_output_price_micros,json=chargeOutputPriceMicros,proto3" json:"charge_output_price_micros,omitempty"` + ChargeCachedPriceMicros int64 `protobuf:"varint,16,opt,name=charge_cached_price_micros,json=chargeCachedPriceMicros,proto3" json:"charge_cached_price_micros,omitempty"` + ChargeReasoningPriceMicros int64 `protobuf:"varint,17,opt,name=charge_reasoning_price_micros,json=chargeReasoningPriceMicros,proto3" json:"charge_reasoning_price_micros,omitempty"` } func (m *CreditPriceRuleView) Reset() { *m = CreditPriceRuleView{} } diff --git a/backend/services/tokenstore/rpc/tokenstore.proto b/backend/services/tokenstore/rpc/tokenstore.proto index 7e1c476..f1d04d7 100644 --- a/backend/services/tokenstore/rpc/tokenstore.proto +++ b/backend/services/tokenstore/rpc/tokenstore.proto @@ -235,6 +235,11 @@ message CreditPriceRuleView { string status = 10; int32 priority = 11; string description = 12; + int64 profit_rate_bps = 13; + int64 charge_input_price_micros = 14; + int64 charge_output_price_micros = 15; + int64 charge_cached_price_micros = 16; + int64 charge_reasoning_price_micros = 17; } message CreditRewardRuleView { diff --git a/backend/services/tokenstore/sv/credit_helpers.go b/backend/services/tokenstore/sv/credit_helpers.go index f3461f9..d77acee 100644 --- a/backend/services/tokenstore/sv/credit_helpers.go +++ b/backend/services/tokenstore/sv/credit_helpers.go @@ -114,19 +114,31 @@ func creditTransactionViewFromModel(ledger storemodel.CreditLedger) creditcontra } func creditPriceRuleViewFromModel(rule storemodel.CreditPriceRule) creditcontracts.CreditPriceRuleView { + chargePrices := creditcontracts.DeriveChargePriceMicrosSet( + rule.InputPriceMicros, + rule.OutputPriceMicros, + rule.CachedPriceMicros, + rule.ReasoningPriceMicros, + rule.ProfitRateBps, + ) return creditcontracts.CreditPriceRuleView{ - RuleID: rule.ID, - Scene: rule.Scene, - ProviderName: rule.ProviderName, - ModelName: rule.ModelName, - InputPriceMicros: rule.InputPriceMicros, - OutputPriceMicros: rule.OutputPriceMicros, - CachedPriceMicros: rule.CachedPriceMicros, - ReasoningPriceMicros: rule.ReasoningPriceMicros, - CreditPerYuan: rule.CreditPerYuan, - Status: rule.Status, - Priority: rule.Priority, - Description: rule.Description, + RuleID: rule.ID, + Scene: rule.Scene, + ProviderName: rule.ProviderName, + ModelName: rule.ModelName, + InputPriceMicros: rule.InputPriceMicros, + OutputPriceMicros: rule.OutputPriceMicros, + CachedPriceMicros: rule.CachedPriceMicros, + ReasoningPriceMicros: rule.ReasoningPriceMicros, + CreditPerYuan: rule.CreditPerYuan, + ProfitRateBps: rule.ProfitRateBps, + ChargeInputPriceMicros: chargePrices.InputChargePriceMicros, + ChargeOutputPriceMicros: chargePrices.OutputChargePriceMicros, + ChargeCachedPriceMicros: chargePrices.CachedChargePriceMicros, + ChargeReasoningPriceMicros: chargePrices.ReasoningChargePriceMicros, + Status: rule.Status, + Priority: rule.Priority, + Description: rule.Description, } } diff --git a/backend/shared/contracts/creditstore/pricing.go b/backend/shared/contracts/creditstore/pricing.go new file mode 100644 index 0000000..3ab4776 --- /dev/null +++ b/backend/shared/contracts/creditstore/pricing.go @@ -0,0 +1,57 @@ +package creditstore + +const ( + // ProfitRateScaleBPS 表示利润率的基点精度:10000 = 100%。 + ProfitRateScaleBPS = int64(10_000) +) + +// ChargePriceMicrosSet 表示在“原始 CNY 成本 + 利润率”之后得到的实际计费单价。 +type ChargePriceMicrosSet struct { + InputChargePriceMicros int64 + OutputChargePriceMicros int64 + CachedChargePriceMicros int64 + ReasoningChargePriceMicros int64 +} + +// DeriveChargePriceMicrosSet 负责把一条价格规则里的原始 CNY 成本推导成实际计费用单价。 +// +// 职责边界: +// 1. 这里只处理“原始成本 + 利润率”的价格推导,不负责 token 数量聚合与 credit 折算。 +// 2. cached/reasoning 仍复用现有兜底语义:未配置时分别退回 input/output 单价。 +// 3. 若利润率配置为负且绝对值过大导致结果 <= 0,则统一按 0 处理,避免落出负价格。 +func DeriveChargePriceMicrosSet(inputPriceMicros, outputPriceMicros, cachedPriceMicros, reasoningPriceMicros, profitRateBps int64) ChargePriceMicrosSet { + if cachedPriceMicros <= 0 { + cachedPriceMicros = inputPriceMicros + } + if reasoningPriceMicros <= 0 { + reasoningPriceMicros = outputPriceMicros + } + + return ChargePriceMicrosSet{ + InputChargePriceMicros: ApplyProfitRateToPriceMicros(inputPriceMicros, profitRateBps), + OutputChargePriceMicros: ApplyProfitRateToPriceMicros(outputPriceMicros, profitRateBps), + CachedChargePriceMicros: ApplyProfitRateToPriceMicros(cachedPriceMicros, profitRateBps), + ReasoningChargePriceMicros: ApplyProfitRateToPriceMicros(reasoningPriceMicros, profitRateBps), + } +} + +// ApplyProfitRateToPriceMicros 负责把“成本单价”按利润率转换成“实际计费单价”。 +func ApplyProfitRateToPriceMicros(priceMicros int64, profitRateBps int64) int64 { + if priceMicros <= 0 { + return 0 + } + + scaledRate := ProfitRateScaleBPS + profitRateBps + if scaledRate <= 0 { + return 0 + } + + return ceilDivInt64(priceMicros*scaledRate, ProfitRateScaleBPS) +} + +func ceilDivInt64(numerator int64, denominator int64) int64 { + if numerator <= 0 || denominator <= 0 { + return 0 + } + return (numerator + denominator - 1) / denominator +} diff --git a/backend/shared/contracts/creditstore/types.go b/backend/shared/contracts/creditstore/types.go index 9a430e2..31f05ca 100644 --- a/backend/shared/contracts/creditstore/types.go +++ b/backend/shared/contracts/creditstore/types.go @@ -93,19 +93,30 @@ type CreditConsumptionDashboardView struct { } // CreditPriceRuleView 是 Credit 计价规则展示结构。 +// +// 字段语义说明: +// 1. InputPriceMicros / OutputPriceMicros / CachedPriceMicros / ReasoningPriceMicros +// 表示原始 CNY 成本单价,方便管理端直接维护模型底价。 +// 2. Charge*PriceMicros 表示后端按利润率自动推导出来的实际计费单价, +// LLM 扣费时会使用这一组价格继续换算 credit。 type CreditPriceRuleView struct { - RuleID uint64 `json:"rule_id"` - Scene string `json:"scene"` - ProviderName string `json:"provider_name"` - ModelName string `json:"model_name"` - InputPriceMicros int64 `json:"input_price_micros"` - OutputPriceMicros int64 `json:"output_price_micros"` - CachedPriceMicros int64 `json:"cached_price_micros"` - ReasoningPriceMicros int64 `json:"reasoning_price_micros"` - CreditPerYuan int64 `json:"credit_per_yuan"` - Status string `json:"status"` - Priority int `json:"priority"` - Description string `json:"description"` + RuleID uint64 `json:"rule_id"` + Scene string `json:"scene"` + ProviderName string `json:"provider_name"` + ModelName string `json:"model_name"` + InputPriceMicros int64 `json:"input_price_micros"` + OutputPriceMicros int64 `json:"output_price_micros"` + CachedPriceMicros int64 `json:"cached_price_micros"` + ReasoningPriceMicros int64 `json:"reasoning_price_micros"` + CreditPerYuan int64 `json:"credit_per_yuan"` + ProfitRateBps int64 `json:"profit_rate_bps"` + ChargeInputPriceMicros int64 `json:"charge_input_price_micros"` + ChargeOutputPriceMicros int64 `json:"charge_output_price_micros"` + ChargeCachedPriceMicros int64 `json:"charge_cached_price_micros"` + ChargeReasoningPriceMicros int64 `json:"charge_reasoning_price_micros"` + Status string `json:"status"` + Priority int `json:"priority"` + Description string `json:"description"` } // CreditRewardRuleView 是 Credit 奖励规则展示结构。 diff --git a/backend/shared/infra/gormcache/cache_deleter.go b/backend/shared/infra/gormcache/cache_deleter.go index 150ebb4..b94a36b 100644 --- a/backend/shared/infra/gormcache/cache_deleter.go +++ b/backend/shared/infra/gormcache/cache_deleter.go @@ -69,6 +69,13 @@ func (p *GormCachePlugin) dispatchCacheLogic(modelObj interface{}) { case model.Schedule: p.invalidScheduleCache(m.UserID, m.Week) case model.TaskClass: + // 1. update 场景若只用 Model(&model.TaskClass{}) 构造 SQL,插件拿到的模型实例里 UserID 可能为空。 + // 2. 这类情况下缓存插件不能影响主事务,更不能因为取缓存键时解引用空指针把服务打崩。 + // 3. 若确实缺少 UserID,则直接跳过本次失效,由调用方补齐 Model 上下文或后续重读兜底。 + if m.UserID == nil { + log.Printf("[GORM-Cache] Skip task class cache invalidation because UserID is nil") + return + } p.invalidTaskClassCache(*m.UserID) case model.Task: p.invalidTaskCache(m.UserID) diff --git a/frontend/index.html b/frontend/index.html index 49b83e0..8832841 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -3,6 +3,7 @@
+