package logic import ( "context" "encoding/json" "strconv" "strings" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" adminv1 "tool-api/api/admin/v1" userv1 "tool-api/api/user/v1" "tool-api/internal/consts" ) // ============================================================================ // 软件开发服务二期(迭代-2026-09-30):介绍页模块 / 作品案例 / 报价申请 // // 约定与 goods 一致: // - 图片库内存相对路径(/static/...),输出 absoluteURL 拼接(域名不落库); // - content 存 Markdown 原文,C 端解析渲染,后端不做 HTML 转换; // - 软删除靠 deleted_at(GoFrame 自动过滤); // - TZ-01:datetime 写库一律 gdb.Raw("NOW()")。 // ============================================================================ // serviceImagesDecode JSON 数组字符串 → 相对路径数组(容错:空/脏串返回空切片) func serviceImagesDecode(raw string) []string { raw = strings.TrimSpace(raw) if raw == "" { return nil } var out []string if err := json.Unmarshal([]byte(raw), &out); err != nil { return nil } clean := make([]string, 0, len(out)) for _, p := range out { if p = strings.TrimSpace(p); p != "" { clean = append(clean, p) } } return clean } // serviceImagesEncode 相对路径数组 → JSON 字符串(剔除空项) func serviceImagesEncode(paths []string) string { clean := make([]string, 0, len(paths)) for _, p := range paths { if p = strings.TrimSpace(p); p != "" { clean = append(clean, p) } } if len(clean) == 0 { return "" } b, err := json.Marshal(clean) if err != nil { return "" } return string(b) } // centsYuanText 分 → 元展示串(整数元不带小数;0 返回空串) func centsYuanText(cents int64) string { if cents <= 0 { return "" } s := strconv.FormatFloat(float64(cents)/100, 'f', 2, 64) s = strings.TrimRight(strings.TrimRight(s, "0"), ".") if s == "" { return "0" } return s } // ============================================================================ // C 端:介绍页模块 // ============================================================================ // ServicePage 介绍页模块(仅启用,sort 升序) func ServicePage(ctx context.Context) (*userv1.ServicePageRes, error) { records, err := g.Model(consts.TableServiceBlocks). Where("is_enabled", 1). Order("sort asc, id asc"). All() if err != nil { return nil, err } list := make([]userv1.ServiceBlockOut, 0, len(records)) for _, r := range records { images := serviceImagesDecode(r["images"].String()) urls := make([]string, 0, len(images)) for _, p := range images { urls = append(urls, absoluteURL(ctx, p)) } list = append(list, userv1.ServiceBlockOut{ Id: r["id"].Int64(), Kind: r["kind"].String(), Title: r["title"].String(), Subtitle: r["subtitle"].String(), ImageUrl: absoluteURL(ctx, r["image_path"].String()), Images: urls, Content: r["content"].String(), }) } return &userv1.ServicePageRes{List: list}, nil } // ============================================================================ // C 端:作品案例 // ============================================================================ // serviceWorkItemOf 列表行 → C 端作品输出 func serviceWorkItemOf(ctx context.Context, r gdb.Record) userv1.WorkItem { return userv1.WorkItem{ Id: r["id"].Int64(), Category: r["category"].Int(), CategoryName: consts.GoodsCategoryNameOf(r["category"].Int()), Title: r["title"].String(), Summary: r["summary"].String(), CoverUrl: absoluteURL(ctx, r["cover_path"].String()), PriceCents: r["price_cents"].Int64(), PriceNote: r["price_note"].String(), } } // ServiceWorks 作品列表(仅上架;sort 升序、id 降序) func ServiceWorks(ctx context.Context, req *userv1.WorksListReq) (*userv1.WorksListRes, error) { page, pageSize := normalizePage(req.Page, req.PageSize, 20) m := g.Model(consts.TableServiceWorks).Where("is_enabled", 1) if req.Category > 0 { m = m.Where("category", req.Category) } total, err := m.Count() if err != nil { return nil, err } records, err := m.Page(page, pageSize).Order("sort asc, id desc").All() if err != nil { return nil, err } list := make([]userv1.WorkItem, 0, len(records)) for _, r := range records { list = append(list, serviceWorkItemOf(ctx, r)) } return &userv1.WorksListRes{List: list, Total: total}, nil } // ServiceWorkDetail 作品详情(仅上架;下架/不存在同义报错) func ServiceWorkDetail(ctx context.Context, id int64) (*userv1.WorkDetailRes, error) { r, err := g.Model(consts.TableServiceWorks).Where("id", id).Where("is_enabled", 1).One() if err != nil { return nil, err } if r.IsEmpty() { return nil, gerror.New("作品不存在或已下架") } images := serviceImagesDecode(r["images"].String()) urls := make([]string, 0, len(images)) for _, p := range images { urls = append(urls, absoluteURL(ctx, p)) } return &userv1.WorkDetailRes{ WorkItem: serviceWorkItemOf(ctx, r), Images: urls, ContentMd: r["content_md"].String(), UpdatedAt: timeStr(r["updated_at"].GTime()), }, nil } // ============================================================================ // C 端:报价申请 // ============================================================================ // ServiceQuoteSubmit 提交报价申请(需求 → 落库 + 站内信回执)。 // 联系方式/需求做长度裁剪;关联作品名存快照(作品后续改名/下架不影响申请单)。 func ServiceQuoteSubmit(ctx context.Context, req *userv1.QuoteSubmitReq) (*userv1.QuoteSubmitRes, error) { contact := strings.TrimSpace(req.Contact) requirement := strings.TrimSpace(req.Requirement) if contact == "" { return nil, gerror.New("请填写联系方式") } if requirement == "" { return nil, gerror.New("请描述您的需求") } if len(contact) > 64 { contact = contact[:64] } if len(requirement) > 1000 { requirement = requirement[:1000] } budget := strings.TrimSpace(req.Budget) if len(budget) > 64 { budget = budget[:64] } workId := req.WorkId workTitle := "" if workId > 0 { wr, err := g.Model(consts.TableServiceWorks).Where("id", workId).One() if err != nil { return nil, err } // 作品不存在/已下架时不阻断:保留申请、标题留空(避免用户已填的需求丢失) if !wr.IsEmpty() { workTitle = wr["title"].String() } else { workId = 0 } } userId := CtxUserId(ctx) res, err := g.Model(consts.TableServiceQuotes).Data(g.Map{ "user_id": userId, "work_id": workId, "work_title": workTitle, "contact": contact, "requirement": requirement, "budget": budget, "status": consts.QuoteStatusPending, "created_at": gdb.Raw("NOW()"), "updated_at": gdb.Raw("NOW()"), }).Insert() if err != nil { return nil, err } id, _ := res.LastInsertId() PushMessage(ctx, userId, consts.MsgTypeQuoteSubmitted, "报价申请已提交", "我们已收到你的开发需求,会尽快处理并通过站内信告知报价结果。", "/pkg-service/quotes") return &userv1.QuoteSubmitRes{Id: id}, nil } // ServiceQuoteMine 我的报价申请(最近 N 条) func ServiceQuoteMine(ctx context.Context, limit int) (*userv1.QuoteMineRes, error) { if limit <= 0 || limit > 50 { limit = 20 } records, err := g.Model(consts.TableServiceQuotes). Where("user_id", CtxUserId(ctx)). OrderDesc("id"). Limit(limit). All() if err != nil { return nil, err } list := make([]userv1.QuoteMineItem, 0, len(records)) for _, r := range records { list = append(list, userv1.QuoteMineItem{ Id: r["id"].Int64(), WorkId: r["work_id"].Int64(), WorkTitle: r["work_title"].String(), Requirement: r["requirement"].String(), Budget: r["budget"].String(), Status: r["status"].Int(), StatusText: consts.QuoteStatusNameOf(r["status"].Int()), QuoteCents: r["quote_cents"].Int64(), QuoteNote: r["quote_note"].String(), HandledAt: timeStr(r["handled_at"].GTime()), CreatedAt: timeStr(r["created_at"].GTime()), }) } return &userv1.QuoteMineRes{List: list}, nil } // ============================================================================ // 管理端:介绍页模块 CRUD // ============================================================================ // AdminServiceBlockList 模块列表(含停用;仅网站/软件开发相关管理页使用) func AdminServiceBlockList(ctx context.Context) (*adminv1.AdminServiceBlockListRes, error) { records, err := g.Model(consts.TableServiceBlocks). Order("sort asc, id asc"). All() if err != nil { return nil, err } list := make([]adminv1.AdminServiceBlockItem, 0, len(records)) for _, r := range records { images := serviceImagesDecode(r["images"].String()) urls := make([]string, 0, len(images)) for _, p := range images { urls = append(urls, absoluteURL(ctx, p)) } list = append(list, adminv1.AdminServiceBlockItem{ Id: r["id"].Int64(), Kind: r["kind"].String(), Title: r["title"].String(), Subtitle: r["subtitle"].String(), ImagePath: r["image_path"].String(), ImageUrl: absoluteURL(ctx, r["image_path"].String()), Images: images, ImageUrls: urls, Content: r["content"].String(), Sort: r["sort"].Int(), IsEnabled: r["is_enabled"].Int(), UpdatedAt: timeStr(r["updated_at"].GTime()), }) } return &adminv1.AdminServiceBlockListRes{List: list}, nil } // AdminServiceBlockSave 新增/编辑介绍页模块。 func AdminServiceBlockSave(ctx context.Context, req *adminv1.AdminServiceBlockSaveReq) (*adminv1.AdminServiceBlockSaveRes, error) { kind := strings.TrimSpace(req.Kind) switch kind { case consts.ServiceBlockHero, consts.ServiceBlockRich, consts.ServiceBlockImageText, consts.ServiceBlockImageList, consts.ServiceBlockFlow, consts.ServiceBlockChecklist, consts.ServiceBlockPrice, consts.ServiceBlockDeveloper, consts.ServiceBlockWorks, consts.ServiceBlockGoods: default: return nil, gerror.New("模块类型不合法") } data := g.Map{ "kind": kind, "title": strings.TrimSpace(req.Title), "subtitle": strings.TrimSpace(req.Subtitle), "image_path": normalizeToRelativePath(req.ImagePath), "images": serviceImagesEncode(req.Images), "content": req.Content, "sort": req.Sort, "is_enabled": req.IsEnabled, } if req.Id > 0 { exist, err := g.Model(consts.TableServiceBlocks).Where("id", req.Id).One() if err != nil { return nil, err } if exist.IsEmpty() { return nil, gerror.New("模块不存在") } data["updated_at"] = gdb.Raw("NOW()") if _, err = g.Model(consts.TableServiceBlocks).Where("id", req.Id).Data(data).Update(); err != nil { return nil, err } WriteAudit(ctx, AuditEntry{ Action: "service.block.save", TargetType: "service_block", TargetId: auditId(req.Id), Before: g.Map{"kind": exist["kind"].String(), "title": exist["title"].String()}, After: g.Map{"kind": kind, "title": data["title"]}, Result: consts.AuditResultSuccess, }) return &adminv1.AdminServiceBlockSaveRes{Id: req.Id}, nil } data["created_at"] = gdb.Raw("NOW()") data["updated_at"] = gdb.Raw("NOW()") res, err := g.Model(consts.TableServiceBlocks).Data(data).Insert() if err != nil { return nil, err } id, _ := res.LastInsertId() WriteAudit(ctx, AuditEntry{ Action: "service.block.save", TargetType: "service_block", TargetId: auditId(id), After: g.Map{"kind": kind, "title": data["title"]}, Result: consts.AuditResultSuccess, }) return &adminv1.AdminServiceBlockSaveRes{Id: id}, nil } // AdminServiceBlockDelete 删除介绍页模块(硬删:模块为纯展示配置,无引用) func AdminServiceBlockDelete(ctx context.Context, id int64) (*adminv1.AdminServiceBlockDeleteRes, error) { exist, err := g.Model(consts.TableServiceBlocks).Where("id", id).One() if err != nil { return nil, err } if exist.IsEmpty() { return nil, gerror.New("模块不存在") } if _, err = g.Model(consts.TableServiceBlocks).Where("id", id).Delete(); err != nil { return nil, err } WriteAudit(ctx, AuditEntry{ Action: "service.block.delete", TargetType: "service_block", TargetId: auditId(id), Before: g.Map{"kind": exist["kind"].String(), "title": exist["title"].String()}, Result: consts.AuditResultSuccess, }) return &adminv1.AdminServiceBlockDeleteRes{}, nil } // ============================================================================ // 管理端:作品案例 CRUD // ============================================================================ // AdminServiceWorkList 作品列表(含下架) func AdminServiceWorkList(ctx context.Context, req *adminv1.AdminServiceWorkListReq) (*adminv1.AdminServiceWorkListRes, error) { page, pageSize := normalizePage(req.Page, req.PageSize, 20) m := g.Model(consts.TableServiceWorks) if kw := strings.TrimSpace(req.Keyword); kw != "" { m = m.Where("title LIKE ?", "%"+kw+"%") } if req.Category > 0 { m = m.Where("category", req.Category) } if req.IsEnabled != nil { m = m.Where("is_enabled", *req.IsEnabled) } total, err := m.Count() if err != nil { return nil, err } records, err := m.Page(page, pageSize).Order("sort asc, id desc").All() if err != nil { return nil, err } list := make([]adminv1.AdminServiceWorkItem, 0, len(records)) for _, r := range records { list = append(list, adminv1.AdminServiceWorkItem{ Id: r["id"].Int64(), Category: r["category"].Int(), CategoryName: consts.GoodsCategoryNameOf(r["category"].Int()), Title: r["title"].String(), Summary: r["summary"].String(), CoverPath: r["cover_path"].String(), CoverUrl: absoluteURL(ctx, r["cover_path"].String()), PriceCents: r["price_cents"].Int64(), PriceNote: r["price_note"].String(), Sort: r["sort"].Int(), IsEnabled: r["is_enabled"].Int(), CreatedAt: timeStr(r["created_at"].GTime()), }) } return &adminv1.AdminServiceWorkListRes{List: list, Total: total}, nil } // AdminServiceWorkDetail 作品详情(编辑回填) func AdminServiceWorkDetail(ctx context.Context, id int64) (*adminv1.AdminServiceWorkDetailRes, error) { r, err := g.Model(consts.TableServiceWorks).Where("id", id).One() if err != nil { return nil, err } if r.IsEmpty() { return nil, gerror.New("作品不存在") } return &adminv1.AdminServiceWorkDetailRes{ Id: r["id"].Int64(), Category: r["category"].Int(), Title: r["title"].String(), Summary: r["summary"].String(), ContentMd: r["content_md"].String(), CoverPath: r["cover_path"].String(), CoverUrl: absoluteURL(ctx, r["cover_path"].String()), Images: serviceImagesDecode(r["images"].String()), PriceCents: r["price_cents"].Int64(), PriceNote: r["price_note"].String(), Sort: r["sort"].Int(), IsEnabled: r["is_enabled"].Int(), }, nil } // AdminServiceWorkSave 新增/编辑作品。 func AdminServiceWorkSave(ctx context.Context, req *adminv1.AdminServiceWorkSaveReq) (*adminv1.AdminServiceWorkSaveRes, error) { title := strings.TrimSpace(req.Title) if title == "" { return nil, gerror.New("请填写作品名称") } images := req.Images for i := range images { images[i] = normalizeToRelativePath(images[i]) } data := g.Map{ "category": req.Category, "title": title, "summary": strings.TrimSpace(req.Summary), "content_md": req.ContentMd, "cover_path": normalizeToRelativePath(req.CoverPath), "images": serviceImagesEncode(images), "price_cents": req.PriceCents, "price_note": strings.TrimSpace(req.PriceNote), "sort": req.Sort, "is_enabled": req.IsEnabled, } if req.Id > 0 { exist, err := g.Model(consts.TableServiceWorks).Where("id", req.Id).One() if err != nil { return nil, err } if exist.IsEmpty() { return nil, gerror.New("作品不存在") } data["updated_at"] = gdb.Raw("NOW()") if _, err = g.Model(consts.TableServiceWorks).Where("id", req.Id).Data(data).Update(); err != nil { return nil, err } WriteAudit(ctx, AuditEntry{ Action: "service.work.save", TargetType: "service_work", TargetId: auditId(req.Id), Before: g.Map{"title": exist["title"].String(), "is_enabled": exist["is_enabled"].Int()}, After: g.Map{"title": title, "is_enabled": req.IsEnabled}, Result: consts.AuditResultSuccess, }) return &adminv1.AdminServiceWorkSaveRes{Id: req.Id}, nil } data["created_at"] = gdb.Raw("NOW()") data["updated_at"] = gdb.Raw("NOW()") res, err := g.Model(consts.TableServiceWorks).Data(data).Insert() if err != nil { return nil, err } id, _ := res.LastInsertId() WriteAudit(ctx, AuditEntry{ Action: "service.work.save", TargetType: "service_work", TargetId: auditId(id), After: g.Map{"title": title, "is_enabled": req.IsEnabled}, Result: consts.AuditResultSuccess, }) return &adminv1.AdminServiceWorkSaveRes{Id: id}, nil } // AdminServiceWorkDelete 软删除作品(报价单存标题快照,不受影响) func AdminServiceWorkDelete(ctx context.Context, id int64) (*adminv1.AdminServiceWorkDeleteRes, error) { exist, err := g.Model(consts.TableServiceWorks).Where("id", id).One() if err != nil { return nil, err } if exist.IsEmpty() { return nil, gerror.New("作品不存在") } if _, err = g.Model(consts.TableServiceWorks).Where("id", id).Data(g.Map{ "deleted_at": gdb.Raw("NOW()"), "updated_at": gdb.Raw("NOW()"), }).Update(); err != nil { return nil, err } WriteAudit(ctx, AuditEntry{ Action: "service.work.delete", TargetType: "service_work", TargetId: auditId(id), Before: g.Map{"title": exist["title"].String()}, Result: consts.AuditResultSuccess, }) return &adminv1.AdminServiceWorkDeleteRes{}, nil }