后端: 1. LLM 独立服务与统一计费出口落地:新增 `cmd/llm`、`client/llm` 与 `services/llm/rpc`,补齐 BillingContext、CreditBalanceGuard、价格规则解析、stream usage 归集与 `credit.charge.requested` outbox 发布,active-scheduler / agent / course / memory / gateway fallback 全部改走 llm zrpc,不再各自本地初始化模型。 2. TokenStore 收口为 Credit 权威账本:新增 credit account / ledger / product / order / price-rule / reward-rule 能力与 Redis 快照缓存,扩展 tokenstore rpc/client 支撑余额快照、消耗看板、商品、订单、流水、价格规则和奖励规则,并接入 LLM charge 事件消费完成 Credit 扣费落账。 3. 计费旧链路下线与网关切口切换:`/token-store` 语义整体切到 `/credit-store`,agent chat 移除旧 TokenQuotaGuard,userauth 的 CheckTokenQuota / AdjustTokenUsage 改为废弃,聊天历史落库不再同步旧 token 额度账本,course 图片解析请求补 user_id 进入新计费口径。 前端: 4. 计划广场从 mock 数据切到真实接口:新增 forum api/types,首页支持真实列表、标签、搜索、防抖、点赞、导入和发布计划,详情页补齐帖子详情、评论树、回复和删除评论链路,同时补上“至少一个标签”的前后端约束与默认标签兜底。 5. 商店页切到 Credit 体系并重做展示:顶部改为余额 + Credit/Token 消耗看板,支持 24h/7d/30d/all 周期切换;套餐区展示原价与当前价;历史区改为当前用户 Credit 流水并支持查看更多,整体视觉和交互同步收口。 仓库: 6. 配置与本地启动体系补齐 llm / outbox 编排:`config.example.yaml` 增加 llm rpc 和统一 outbox service 配置,`dev-common.ps1` 把 llm 纳入多服务依赖并自动建 Kafka topic,`docker-compose.yml` 同步初始化 agent/task/memory/active-scheduler/notification/taskclass-forum/llm/token-store 全量 outbox topic。
372 lines
9.5 KiB
Go
372 lines
9.5 KiB
Go
package sv
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
forummodel "github.com/LoveLosita/smartflow/backend/services/taskclassforum/model"
|
|
forumcontracts "github.com/LoveLosita/smartflow/backend/shared/contracts/taskclassforum"
|
|
"github.com/LoveLosita/smartflow/backend/shared/respond"
|
|
)
|
|
|
|
const (
|
|
defaultPage = 1
|
|
defaultPageSize = 20
|
|
maxPageSize = 50
|
|
maxPostTitleLen = 40
|
|
maxSummaryLen = 300
|
|
maxTagCount = 5
|
|
maxTagLength = 12
|
|
maxCommentLen = 500
|
|
maxImportTitle = 80
|
|
)
|
|
|
|
var defaultForumTags = []string{
|
|
"期末复习",
|
|
"考研备考",
|
|
"四六级",
|
|
"编程学习",
|
|
"实习求职",
|
|
"习惯养成",
|
|
"竞赛项目",
|
|
"证书考试",
|
|
}
|
|
|
|
func buildForumTagItems(rawTags []string, limit int) []forumcontracts.ForumTagItem {
|
|
if limit <= 0 || limit > 50 {
|
|
limit = 20
|
|
}
|
|
|
|
// 1. 先聚合真实帖子里的标签热度,保证已有社区语义优先展示。
|
|
// 2. 空白标签直接忽略,避免把脏数据继续透给前端。
|
|
// 3. 默认标签只在缺失时兜底补齐,保证清库后分类区仍可用。
|
|
counter := make(map[string]int)
|
|
for _, raw := range rawTags {
|
|
for _, tag := range tagsFromJSON(raw) {
|
|
trimmedTag := strings.TrimSpace(tag)
|
|
if trimmedTag == "" {
|
|
continue
|
|
}
|
|
counter[trimmedTag]++
|
|
}
|
|
}
|
|
|
|
items := make([]forumcontracts.ForumTagItem, 0, len(counter)+len(defaultForumTags))
|
|
for tag, count := range counter {
|
|
items = append(items, forumcontracts.ForumTagItem{Tag: tag, PostCount: count})
|
|
}
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
if items[i].PostCount == items[j].PostCount {
|
|
return items[i].Tag < items[j].Tag
|
|
}
|
|
return items[i].PostCount > items[j].PostCount
|
|
})
|
|
|
|
seen := make(map[string]struct{}, len(items))
|
|
for _, item := range items {
|
|
seen[item.Tag] = struct{}{}
|
|
}
|
|
for _, tag := range defaultForumTags {
|
|
if _, exists := seen[tag]; exists {
|
|
continue
|
|
}
|
|
items = append(items, forumcontracts.ForumTagItem{
|
|
Tag: tag,
|
|
PostCount: 0,
|
|
})
|
|
}
|
|
if len(items) > limit {
|
|
return items[:limit]
|
|
}
|
|
return items
|
|
}
|
|
|
|
func normalizePage(page int, pageSize int) (int, int) {
|
|
if page <= 0 {
|
|
page = defaultPage
|
|
}
|
|
if pageSize <= 0 {
|
|
pageSize = defaultPageSize
|
|
}
|
|
if pageSize > maxPageSize {
|
|
pageSize = maxPageSize
|
|
}
|
|
return page, pageSize
|
|
}
|
|
|
|
func pageResult(page int, pageSize int, total int64) forumcontracts.PageResult {
|
|
return forumcontracts.PageResult{
|
|
Page: page,
|
|
PageSize: pageSize,
|
|
Total: int(total),
|
|
HasMore: int64(page*pageSize) < total,
|
|
}
|
|
}
|
|
|
|
func normalizeTags(tags []string) ([]string, error) {
|
|
result := make([]string, 0, len(tags))
|
|
seen := make(map[string]struct{}, len(tags))
|
|
for _, raw := range tags {
|
|
tag := strings.TrimSpace(raw)
|
|
if tag == "" {
|
|
continue
|
|
}
|
|
if len([]rune(tag)) > maxTagLength {
|
|
return nil, respond.ParamTooLong
|
|
}
|
|
if _, exists := seen[tag]; exists {
|
|
continue
|
|
}
|
|
seen[tag] = struct{}{}
|
|
result = append(result, tag)
|
|
if len(result) > maxTagCount {
|
|
return nil, respond.ParamTooLong
|
|
}
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// normalizeRequiredTags 负责统一“帖子标签去重 + 必填”规则。
|
|
//
|
|
// 职责边界:
|
|
// 1. 负责清洗空白、去重、数量和长度限制;
|
|
// 2. 额外补上“发布帖子至少一个标签”的业务校验;
|
|
// 3. 不负责前端提示文案,调用方只消费 error。
|
|
func normalizeRequiredTags(tags []string) ([]string, error) {
|
|
normalizedTags, err := normalizeTags(tags)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if len(normalizedTags) == 0 {
|
|
return nil, ErrForumTagsRequired
|
|
}
|
|
return normalizedTags, nil
|
|
}
|
|
|
|
func validateRuneMax(value string, maxLen int) error {
|
|
if len([]rune(strings.TrimSpace(value))) > maxLen {
|
|
return respond.ParamTooLong
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func tagsToJSON(tags []string) (string, error) {
|
|
if tags == nil {
|
|
tags = []string{}
|
|
}
|
|
raw, err := json.Marshal(tags)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return string(raw), nil
|
|
}
|
|
|
|
func tagsFromJSON(raw string) []string {
|
|
var tags []string
|
|
if err := json.Unmarshal([]byte(raw), &tags); err != nil {
|
|
return []string{}
|
|
}
|
|
return tags
|
|
}
|
|
|
|
func intSliceToJSONPtr(values []int) (*string, error) {
|
|
if values == nil {
|
|
values = []int{}
|
|
}
|
|
raw, err := json.Marshal(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := string(raw)
|
|
return &result, nil
|
|
}
|
|
|
|
func stringSliceToJSONPtr(values []string) (*string, error) {
|
|
if values == nil {
|
|
values = []string{}
|
|
}
|
|
raw, err := json.Marshal(values)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := string(raw)
|
|
return &result, nil
|
|
}
|
|
|
|
func intSliceFromJSONPtr(raw *string) []int {
|
|
if raw == nil || strings.TrimSpace(*raw) == "" {
|
|
return []int{}
|
|
}
|
|
var values []int
|
|
if err := json.Unmarshal([]byte(*raw), &values); err != nil {
|
|
return []int{}
|
|
}
|
|
return values
|
|
}
|
|
|
|
func stringSliceFromJSONPtr(raw *string) []string {
|
|
if raw == nil || strings.TrimSpace(*raw) == "" {
|
|
return []string{}
|
|
}
|
|
var values []string
|
|
if err := json.Unmarshal([]byte(*raw), &values); err != nil {
|
|
return []string{}
|
|
}
|
|
return values
|
|
}
|
|
|
|
func parseSnapshotDate(value string) *time.Time {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
parsed, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(value), time.Local)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
return &parsed
|
|
}
|
|
|
|
func formatDate(value *time.Time) string {
|
|
if value == nil || value.IsZero() {
|
|
return ""
|
|
}
|
|
return value.Format("2006-01-02")
|
|
}
|
|
|
|
func formatTime(value time.Time) string {
|
|
if value.IsZero() {
|
|
return ""
|
|
}
|
|
return value.Format(time.RFC3339)
|
|
}
|
|
|
|
func formatTimePtr(value *time.Time) *string {
|
|
if value == nil || value.IsZero() {
|
|
return nil
|
|
}
|
|
formatted := value.Format(time.RFC3339)
|
|
return &formatted
|
|
}
|
|
|
|
func userBrief(userID uint64) forumcontracts.UserBrief {
|
|
return forumcontracts.UserBrief{
|
|
UserID: userID,
|
|
Nickname: fmt.Sprintf("用户%d", userID),
|
|
}
|
|
}
|
|
|
|
func countersFromPost(post forummodel.ForumPost) forumcontracts.ForumPostCounters {
|
|
return forumcontracts.ForumPostCounters{
|
|
LikeCount: post.LikeCount,
|
|
CommentCount: post.CommentCount,
|
|
ImportCount: post.ImportCount,
|
|
}
|
|
}
|
|
|
|
func viewerState(postID uint64, liked map[uint64]bool, imported map[uint64]bool) forumcontracts.ForumPostViewerState {
|
|
return forumcontracts.ForumPostViewerState{
|
|
Liked: liked[postID],
|
|
ImportedOnce: imported[postID],
|
|
}
|
|
}
|
|
|
|
func templateSummaryFromTemplate(template *forummodel.ForumPostTemplate, itemCount int) forumcontracts.TemplateSummary {
|
|
if template == nil {
|
|
return forumcontracts.TemplateSummary{}
|
|
}
|
|
return forumcontracts.TemplateSummary{
|
|
TaskCount: itemCount,
|
|
Mode: template.Mode,
|
|
StartDate: formatDate(template.StartDate),
|
|
EndDate: formatDate(template.EndDate),
|
|
StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON),
|
|
}
|
|
}
|
|
|
|
func postBriefFromModel(post forummodel.ForumPost, template *forummodel.ForumPostTemplate, itemCount int, state forumcontracts.ForumPostViewerState) forumcontracts.ForumPostBrief {
|
|
return forumcontracts.ForumPostBrief{
|
|
PostID: post.ID,
|
|
Title: post.Title,
|
|
Summary: post.Summary,
|
|
Tags: tagsFromJSON(post.TagsJSON),
|
|
Author: userBrief(post.AuthorUserID),
|
|
TemplateSummary: templateSummaryFromTemplate(template, itemCount),
|
|
Counters: countersFromPost(post),
|
|
ViewerState: state,
|
|
Status: post.Status,
|
|
CreatedAt: formatTime(post.CreatedAt),
|
|
}
|
|
}
|
|
|
|
func templateDetailFromModel(template forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) forumcontracts.TemplateDetail {
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
return items[i].Order < items[j].Order
|
|
})
|
|
preview := make([]forumcontracts.TemplateItemPreview, 0, len(items))
|
|
for _, item := range items {
|
|
preview = append(preview, forumcontracts.TemplateItemPreview{
|
|
ItemID: item.ID,
|
|
Order: item.Order,
|
|
Content: item.Content,
|
|
})
|
|
}
|
|
return forumcontracts.TemplateDetail{
|
|
Mode: template.Mode,
|
|
StartDate: formatDate(template.StartDate),
|
|
EndDate: formatDate(template.EndDate),
|
|
StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON),
|
|
TaskCount: len(items),
|
|
ItemsPreview: preview,
|
|
}
|
|
}
|
|
|
|
func snapshotFromTemplate(post forummodel.ForumPost, template forummodel.ForumPostTemplate, items []forummodel.ForumPostTemplateItem) TaskClassSnapshot {
|
|
sort.SliceStable(items, func(i, j int) bool {
|
|
return items[i].Order < items[j].Order
|
|
})
|
|
snapshotItems := make([]TaskClassSnapshotItem, 0, len(items))
|
|
for _, item := range items {
|
|
snapshotItems = append(snapshotItems, TaskClassSnapshotItem{
|
|
TaskItemID: item.SourceTaskItemID,
|
|
Order: item.Order,
|
|
Content: item.Content,
|
|
})
|
|
}
|
|
return TaskClassSnapshot{
|
|
TaskClassID: template.SourceTaskClassID,
|
|
Title: post.Title,
|
|
Mode: template.Mode,
|
|
StartDate: formatDate(template.StartDate),
|
|
EndDate: formatDate(template.EndDate),
|
|
SubjectType: template.SubjectType,
|
|
DifficultyLevel: template.DifficultyLevel,
|
|
CognitiveIntensity: template.CognitiveIntensity,
|
|
TotalSlots: template.TotalSlots,
|
|
AllowFillerCourse: template.AllowFillerCourse,
|
|
Strategy: template.Strategy,
|
|
ExcludedSlots: intSliceFromJSONPtr(template.ExcludedSlotsJSON),
|
|
ExcludedDaysOfWeek: intSliceFromJSONPtr(template.ExcludedDaysOfWeekJSON),
|
|
StrategyLabels: stringSliceFromJSONPtr(template.StrategyLabelsJSON),
|
|
Items: snapshotItems,
|
|
ConfigSnapshotJSON: stringFromPtr(template.ConfigSnapshotJSON),
|
|
}
|
|
}
|
|
|
|
func stringFromPtr(value *string) string {
|
|
if value == nil {
|
|
return ""
|
|
}
|
|
return *value
|
|
}
|
|
|
|
func stringPtrFromNonEmpty(value string) *string {
|
|
trimmed := strings.TrimSpace(value)
|
|
if trimmed == "" {
|
|
return nil
|
|
}
|
|
return &trimmed
|
|
}
|