544 lines
18 KiB
Go
544 lines
18 KiB
Go
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
|
||
}
|