Files
qitongxue-api/internal/logic/service.go
2026-09-30 17:01:18 +08:00

544 lines
18 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}