小程序一些功能更新

This commit is contained in:
李琦
2026-09-30 17:01:18 +08:00
parent 75499ba85c
commit 6f51d99a78
46 changed files with 5280 additions and 37 deletions

View File

@@ -0,0 +1,64 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// 管理端附件管理契约(迭代-2026-09-29):upload / list / delete
//
// 所有上传(含用户端头像)都会登记到 attachments 表;本模块提供后台统一查看、
// 上传日期、删除。删除为「记录软删 + 文件本体按引用检查」:仍被头像/封面等引用
// 的文件只移除记录不删文件,返回 removed_file=false 说明这一点。
// ============================================================================
// AdminUploadReq 管理端上传(multipart/form-data,文件字段名 file,表单字段 scene=cover|attachment)
type AdminUploadReq struct {
g.Meta `path:"/admin/upload" method:"post" mime:"multipart/form-data" tags:"admin" summary:"管理端上传文件"`
Scene string `v:"required|in:cover,attachment#请指定上传场景|不支持的上传场景" json:"scene"`
}
type AdminUploadRes struct {
Id int64 `json:"id"` // 附件登记 id(0 = 登记跳过/失败,不影响文件可用)
Url string `json:"url"` // 绝对 URL(按 site.base_url 现拼,直接展示用)
Path string `json:"path"` // 相对路径(/static/...)—— 商品封面等库内只存这个
FileName string `json:"file_name"` // 原始文件名
Ext string `json:"ext"` // 扩展名(小写含点)
SizeBytes int64 `json:"size_bytes"` // 字节
}
// AdminAttachmentItem 附件列表项(响应 snake_case)
type AdminAttachmentItem struct {
Id int64 `json:"id"`
Scene string `json:"scene"` // avatar / cover / attachment
FileName string `json:"file_name"`
FilePath string `json:"file_path"`
Url string `json:"url"` // 绝对 URL(现拼)
Ext string `json:"ext"`
SizeBytes int64 `json:"size_bytes"`
Md5 string `json:"md5"`
UploaderId int64 `json:"uploader_id"`
UploaderType string `json:"uploader_type"` // admin / user
IsReferenced bool `json:"is_referenced"` // 是否仍被头像/封面/其他记录引用
IsImage bool `json:"is_image"` // 是否图片(可缩略预览)
CreatedAt string `json:"created_at"` // 上传日期
}
// AdminAttachmentListReq 附件列表
type AdminAttachmentListReq struct {
g.Meta `path:"/admin/attachment/list" method:"get" tags:"admin" summary:"附件列表"`
Keyword string `json:"keyword"` // 文件名模糊
Scene string `json:"scene"` // 空=全部;avatar/cover/attachment
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type AdminAttachmentListRes struct {
List []AdminAttachmentItem `json:"list"`
Total int `json:"total"`
}
// AdminAttachmentDeleteReq 删除附件(记录软删;文件本体无引用时才落盘删除)
type AdminAttachmentDeleteReq struct {
g.Meta `path:"/admin/attachment/delete" method:"post" tags:"admin" summary:"删除附件"`
Id int64 `v:"required" json:"id"`
}
type AdminAttachmentDeleteRes struct {
RemovedFile bool `json:"removed_file"` // false = 文件仍被引用,仅移除了登记记录
}

89
api/admin/v1/goods.go Normal file
View File

@@ -0,0 +1,89 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// 管理端软件开发服务契约(迭代-2026-09-29):list / detail / save / delete
//
// 封面 cover_path 库内存相对路径(/static/...);响应里 cover_url 为按
// site.base_url 拼接的绝对地址。content_md 为 Markdown 原文,管理端编辑预览、
// 小程序端解析渲染(富文本节点),后端只存原文不渲染。
// ============================================================================
// AdminGoodsItem 服务列表项(响应 snake_case,不含 content_md —— 详情接口单独取)
type AdminGoodsItem struct {
Id int64 `json:"id"`
Category int `json:"category"`
CategoryName string `json:"category_name"`
Title string `json:"title"`
Summary string `json:"summary"`
CoverPath string `json:"cover_path"`
CoverUrl string `json:"cover_url"`
PriceCents int64 `json:"price_cents"`
PriceNote string `json:"price_note"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
// AdminGoodsListReq 服务列表
type AdminGoodsListReq struct {
g.Meta `path:"/admin/goods/list" method:"get" tags:"admin" summary:"软件开发服务列表"`
Keyword string `json:"keyword"`
Category int `json:"category"` // 0=全部
IsEnabled *int `json:"isEnabled"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
SortBy string `json:"sortBy"`
SortOrder string `json:"sortOrder"` // asc / desc
}
type AdminGoodsListRes struct {
List []AdminGoodsItem `json:"list"`
Total int `json:"total"`
}
// AdminGoodsDetailReq 服务详情(编辑回填,含 content_md)
type AdminGoodsDetailReq struct {
g.Meta `path:"/admin/goods/detail" method:"get" tags:"admin" summary:"软件开发服务详情"`
Id int64 `v:"required" json:"id"`
}
type AdminGoodsDetailRes struct {
Id int64 `json:"id"`
Category int `json:"category"`
CategoryName string `json:"category_name"`
Title string `json:"title"`
Summary string `json:"summary"`
ContentMd string `json:"content_md"`
CoverPath string `json:"cover_path"`
CoverUrl string `json:"cover_url"`
PriceCents int64 `json:"price_cents"`
PriceNote string `json:"price_note"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
}
// AdminGoodsSaveReq 新增/编辑服务(id=0 新建)。coverPath 传上传接口返回的 path(相对路径)。
type AdminGoodsSaveReq struct {
g.Meta `path:"/admin/goods/save" method:"post" tags:"admin" summary:"保存软件开发服务"`
Id int64 `json:"id"`
Category int `v:"required|in:1,2,3,4#请选择分类|分类不合法" json:"category"`
Title string `v:"required#请填写服务名称" json:"title"`
Summary string `v:"length:0,500#简介最多 500 字" json:"summary"`
ContentMd string `json:"contentMd"`
PriceCents int64 `v:"min:0#价格不能为负" json:"priceCents"`
PriceNote string `v:"length:0,64#价格备注最多 64 字" json:"priceNote"`
CoverPath string `json:"coverPath"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
}
type AdminGoodsSaveRes struct {
Id int64 `json:"id"`
}
// AdminGoodsDeleteReq 删除服务(软删除)
type AdminGoodsDeleteReq struct {
g.Meta `path:"/admin/goods/delete" method:"post" tags:"admin" summary:"删除软件开发服务"`
Id int64 `v:"required" json:"id"`
}
type AdminGoodsDeleteRes struct{}

341
api/admin/v1/project.go Normal file
View File

@@ -0,0 +1,341 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// 管理端开发项目契约(迭代-2026-09-30 三期)
//
// 建单 → 生成绑定小程序码(scene 携带 bind_token)→ 用户扫码绑定;
// 阶段/小节点/交付物(原型、UI 设计稿)均为即时保存接口(管理端逐项操作)。
// 阶段状态变更 / 交付物上传 / 交付 会向绑定用户推站内信。
// ============================================================================
// AdminProjectPayItem 项目分期
type AdminProjectPayItem struct {
Id int64 `json:"id"`
Seq int `json:"seq"`
Percent int `json:"percent"`
AmountCents int64 `json:"amount_cents"`
Trigger string `json:"trigger"`
TriggerText string `json:"trigger_text"`
Status int `json:"status"`
PaidAt string `json:"paid_at"`
PaidNote string `json:"paid_note"`
}
// AdminProjectItem 项目列表项
type AdminProjectItem struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
UserNickname string `json:"user_nickname"`
Title string `json:"title"`
Description string `json:"description"`
PriceCents int64 `json:"price_cents"`
PriceNote string `json:"price_note"`
DueNote string `json:"due_note"`
Status int `json:"status"`
StatusText string `json:"status_text"`
StageTotal int `json:"stage_total"`
StageDone int `json:"stage_done"`
BindToken string `json:"bind_token"`
CodeUrl string `json:"code_url"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Stages []AdminProjectStageItem `json:"stages"`
MaintainPlanId int64 `json:"maintain_plan_id"`
PayPlanId int64 `json:"pay_plan_id"`
MaintainPlanName string `json:"maintain_plan_name"`
PayPlanName string `json:"pay_plan_name"`
Accepted int `json:"accepted"`
AcceptedAt string `json:"accepted_at"`
MaintainStartAt string `json:"maintain_start_at"`
MaintainPaidUntil string `json:"maintain_paid_until"`
MaintainDaysLeft int `json:"maintain_days_left"` // 距下次缴费天数(未启用维护为 -1)
Pays []AdminProjectPayItem `json:"pays"`
}
// AdminProjectListReq 项目列表
type AdminProjectListReq struct {
g.Meta `path:"/admin/service/project/list" method:"get" tags:"admin" summary:"开发项目列表"`
Keyword string `json:"keyword"`
Status *int `json:"status"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type AdminProjectListRes struct {
List []AdminProjectItem `json:"list"`
Total int `json:"total"`
}
// AdminProjectSaveReq 新增/编辑项目(id=0 新建并生成绑定令牌;userId>0 直接绑定)
type AdminProjectSaveReq struct {
g.Meta `path:"/admin/service/project/save" method:"post" tags:"admin" summary:"保存开发项目"`
Id int64 `json:"id"`
Title string `v:"required#请填写项目名称" json:"title"`
Description string `v:"length:0,1000#需求描述最多 1000 字" json:"description"`
PriceCents int64 `v:"min:0#金额不能为负" json:"priceCents"`
PriceNote string `v:"length:0,64#金额备注最多 64 字" json:"priceNote"`
DueNote string `v:"length:0,64#交付周期最多 64 字" json:"dueNote"`
Status int `v:"in:1,2,3#状态不合法" json:"status"`
UserId int64 `json:"userId"` // 编辑时可直接改绑(填 0 = 解绑待扫码)
MaintainPlanId int64 `json:"maintainPlanId"` // 维护模板(0=未选)
PayPlanId int64 `json:"payPlanId"` // 付款分期模板(0=未选)
}
type AdminProjectSaveRes struct {
Id int64 `json:"id"`
BindToken string `json:"bind_token"` // 新建时返回,供前端直接弹码
}
// AdminProjectDeleteReq 删除项目(软删除;阶段/节点/交付物一并硬删)
type AdminProjectDeleteReq struct {
g.Meta `path:"/admin/service/project/delete" method:"post" tags:"admin" summary:"删除开发项目"`
Id int64 `v:"required" json:"id"`
}
type AdminProjectDeleteRes struct{}
// AdminProjectStageItem 阶段(含节点与文件,管理端详情用)
type AdminProjectStageItem struct {
Id int64 `json:"id"`
Title string `json:"title"`
Status int `json:"status"`
StatusText string `json:"status_text"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
Nodes []AdminProjectNodeItem `json:"nodes"`
Files []AdminProjectFileItem `json:"files"`
}
// AdminProjectNodeItem 小节点
type AdminProjectNodeItem struct {
Id int64 `json:"id"`
Title string `json:"title"`
IsDone int `json:"is_done"`
Sort int `json:"sort"`
}
// AdminProjectFileItem 交付物(绝对 URL 直显)
type AdminProjectFileItem struct {
Id int64 `json:"id"`
FileName string `json:"file_name"`
FileUrl string `json:"file_url"`
}
// AdminProjectDetailReq 项目详情(管理态全量:阶段/节点/文件)
type AdminProjectDetailReq struct {
g.Meta `path:"/admin/service/project/detail" method:"get" tags:"admin" summary:"开发项目详情"`
Id int64 `v:"required" json:"id"`
}
type AdminProjectDetailRes struct {
AdminProjectItem
}
// AdminProjectQrcodeReq 生成/重取绑定小程序码(微信 getUnlimited,scene=项目+令牌)
type AdminProjectQrcodeReq struct {
g.Meta `path:"/admin/service/project/qrcode" method:"post" tags:"admin" summary:"生成项目绑定小程序码"`
Id int64 `v:"required" json:"id"`
}
type AdminProjectQrcodeRes struct {
CodeUrl string `json:"code_url"`
}
// AdminProjectStageSaveReq 新增/编辑阶段
type AdminProjectStageSaveReq struct {
g.Meta `path:"/admin/service/project/stage/save" method:"post" tags:"admin" summary:"保存项目阶段"`
Id int64 `json:"id"`
ProjectId int64 `v:"required" json:"projectId"`
Title string `v:"required#请填写阶段名称" json:"title"`
Status int `v:"in:0,1,2#状态不合法" json:"status"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
}
type AdminProjectStageSaveRes struct {
Id int64 `json:"id"`
}
// AdminProjectStageDeleteReq 删除阶段(其节点与交付物一并删除)
type AdminProjectStageDeleteReq struct {
g.Meta `path:"/admin/service/project/stage/delete" method:"post" tags:"admin" summary:"删除项目阶段"`
Id int64 `v:"required" json:"id"`
}
type AdminProjectStageDeleteRes struct{}
// AdminProjectNodeSaveReq 新增/编辑小节点(isDone 变更即时生效)
type AdminProjectNodeSaveReq struct {
g.Meta `path:"/admin/service/project/node/save" method:"post" tags:"admin" summary:"保存阶段小节点"`
Id int64 `json:"id"`
StageId int64 `v:"required" json:"stageId"`
Title string `v:"required#请填写节点内容" json:"title"`
IsDone int `json:"isDone"`
Sort int `json:"sort"`
}
type AdminProjectNodeSaveRes struct {
Id int64 `json:"id"`
}
// AdminProjectNodeDeleteReq 删除小节点
type AdminProjectNodeDeleteReq struct {
g.Meta `path:"/admin/service/project/node/delete" method:"post" tags:"admin" summary:"删除阶段小节点"`
Id int64 `v:"required" json:"id"`
}
type AdminProjectNodeDeleteRes struct{}
// AdminProjectFileAddReq 登记交付物(文件本体先经 /admin/upload 上传,此处只落 path)
type AdminProjectFileAddReq struct {
g.Meta `path:"/admin/service/project/file/add" method:"post" tags:"admin" summary:"登记项目交付物"`
ProjectId int64 `v:"required" json:"projectId"`
StageId int64 `json:"stageId"`
FileName string `v:"required#请填写文件名" json:"fileName"`
FilePath string `v:"required#缺少文件路径" json:"filePath"`
}
type AdminProjectFileAddRes struct {
Id int64 `json:"id"`
}
// AdminProjectFileDeleteReq 删除交付物登记
type AdminProjectFileDeleteReq struct {
g.Meta `path:"/admin/service/project/file/delete" method:"post" tags:"admin" summary:"删除项目交付物"`
Id int64 `v:"required" json:"id"`
}
type AdminProjectFileDeleteRes struct{}
// ---------- 维护模板 ----------
// AdminMaintainPlanItem 维护模板
type AdminMaintainPlanItem struct {
Id int64 `json:"id"`
Name string `json:"name"`
FreeYears int `json:"free_years"`
YearlyFeeCents int64 `json:"yearly_fee_cents"`
SourceOnly int `json:"source_only"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
Remark string `json:"remark"`
}
// AdminMaintainPlanListReq 维护模板列表
type AdminMaintainPlanListReq struct {
g.Meta `path:"/admin/service/maintain-plan/list" method:"get" tags:"admin" summary:"维护模板列表"`
}
type AdminMaintainPlanListRes struct {
List []AdminMaintainPlanItem `json:"list"`
}
// AdminMaintainPlanSaveReq 保存维护模板
type AdminMaintainPlanSaveReq struct {
g.Meta `path:"/admin/service/maintain-plan/save" method:"post" tags:"admin" summary:"保存维护模板"`
Id int64 `json:"id"`
Name string `v:"required#请填写模板名称" json:"name"`
FreeYears int `v:"min:0" json:"freeYears"`
YearlyFeeCents int64 `v:"min:0" json:"yearlyFeeCents"`
SourceOnly int `json:"sourceOnly"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
Remark string `v:"length:0,255" json:"remark"`
}
type AdminMaintainPlanSaveRes struct {
Id int64 `json:"id"`
}
// AdminMaintainPlanDeleteReq 删除维护模板
type AdminMaintainPlanDeleteReq struct {
g.Meta `path:"/admin/service/maintain-plan/delete" method:"post" tags:"admin" summary:"删除维护模板"`
Id int64 `v:"required" json:"id"`
}
type AdminMaintainPlanDeleteRes struct{}
// ---------- 付款分期模板 ----------
// AdminPayPlanItem 付款分期模板
type AdminPayPlanItem struct {
Id int64 `json:"id"`
Name string `json:"name"`
Schedule string `json:"schedule"` // JSON:[{seq,percent,trigger}]
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
Remark string `json:"remark"`
}
// AdminPayPlanListReq 分期模板列表
type AdminPayPlanListReq struct {
g.Meta `path:"/admin/service/pay-plan/list" method:"get" tags:"admin" summary:"付款分期模板列表"`
}
type AdminPayPlanListRes struct {
List []AdminPayPlanItem `json:"list"`
}
// AdminPayPlanSaveReq 保存分期模板
type AdminPayPlanSaveReq struct {
g.Meta `path:"/admin/service/pay-plan/save" method:"post" tags:"admin" summary:"保存付款分期模板"`
Id int64 `json:"id"`
Name string `v:"required#请填写模板名称" json:"name"`
Schedule string `v:"required#请填写分期计划 JSON" json:"schedule"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
Remark string `v:"length:0,255" json:"remark"`
}
type AdminPayPlanSaveRes struct {
Id int64 `json:"id"`
}
// AdminPayPlanDeleteReq 删除分期模板
type AdminPayPlanDeleteReq struct {
g.Meta `path:"/admin/service/pay-plan/delete" method:"post" tags:"admin" summary:"删除付款分期模板"`
Id int64 `v:"required" json:"id"`
}
type AdminPayPlanDeleteRes struct{}
// ---------- 分期到账 / 维护期 ----------
// AdminProjectPayMarkReq 标记分期到账(后台确认收款,用户端推站内信)
type AdminProjectPayMarkReq struct {
g.Meta `path:"/admin/service/project/pay/mark" method:"post" tags:"admin" summary:"标记分期到账"`
Id int64 `v:"required" json:"id"`
PaidNote string `v:"length:0,64#备注最多 64 字" json:"paidNote"`
}
type AdminProjectPayMarkRes struct{}
// AdminProjectPayUnmarkReq 撤销分期到账(误操作回退)
type AdminProjectPayUnmarkReq struct {
g.Meta `path:"/admin/service/project/pay/unmark" method:"post" tags:"admin" summary:"撤销分期到账"`
Id int64 `v:"required" json:"id"`
}
type AdminProjectPayUnmarkRes struct{}
// AdminProjectMaintainRenewReq 维护费 +1 年已收(paid_until 顺延一年并通知用户)
type AdminProjectMaintainRenewReq struct {
g.Meta `path:"/admin/service/project/maintain/renew" method:"post" tags:"admin" summary:"维护费续收一年"`
Id int64 `v:"required" json:"id"`
}
type AdminProjectMaintainRenewRes struct {
PaidUntil string `json:"paid_until"`
}
// AdminProjectMaintainAdjustReq 调整维护期起算日(重算已缴至与到期提醒)
type AdminProjectMaintainAdjustReq struct {
g.Meta `path:"/admin/service/project/maintain/adjust" method:"post" tags:"admin" summary:"调整维护期起算日"`
Id int64 `v:"required" json:"id"`
StartAt string `v:"required#请选择起算日期" json:"startAt"` // YYYY-MM-DD
}
type AdminProjectMaintainAdjustRes struct {
PaidUntil string `json:"paid_until"`
}
// ---------- 缴费提醒设置 ----------
// AdminMaintainRemindGetReq 提醒设置读取
type AdminMaintainRemindGetReq struct {
g.Meta `path:"/admin/service/maintain-remind/get" method:"get" tags:"admin" summary:"维护费提醒设置读取"`
}
type AdminMaintainRemindGetRes struct {
RemindDays int `json:"remind_days"`
AdminNotifyUserId int64 `json:"admin_notify_user_id"`
}
// AdminMaintainRemindSaveReq 提醒设置保存
type AdminMaintainRemindSaveReq struct {
g.Meta `path:"/admin/service/maintain-remind/save" method:"post" tags:"admin" summary:"维护费提醒设置保存"`
RemindDays int `v:"min:1|max:90#提醒天数需在 1-90 之间" json:"remindDays"`
AdminNotifyUserId int64 `json:"adminNotifyUserId"`
}
type AdminMaintainRemindSaveRes struct{}

181
api/admin/v1/service.go Normal file
View File

@@ -0,0 +1,181 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// 管理端软件开发服务二期契约(迭代-2026-09-30):
// 介绍页模块(blocks)/ 作品案例(works)/ 报价申请(quotes)
//
// 图片字段库内只存相对路径(/static/...),响应里 *Url 为按 site.base_url 拼接的
// 绝对地址;保存时提交上传接口返回的 path。报价处理(handle)后向用户推站内信。
// ============================================================================
// ---------- 介绍页模块 ----------
// AdminServiceBlockItem 模块项(响应 snake_case)
type AdminServiceBlockItem struct {
Id int64 `json:"id"`
Kind string `json:"kind"`
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ImagePath string `json:"image_path"`
ImageUrl string `json:"image_url"`
Images []string `json:"images"` // 相对路径数组(编辑回填用)
ImageUrls []string `json:"image_urls"`
Content string `json:"content"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
UpdatedAt string `json:"updated_at"`
}
// AdminServiceBlockListReq 模块列表(含停用;管理端全部可见)
type AdminServiceBlockListReq struct {
g.Meta `path:"/admin/service/block/list" method:"get" tags:"admin" summary:"服务介绍页模块列表"`
}
type AdminServiceBlockListRes struct {
List []AdminServiceBlockItem `json:"list"`
}
// AdminServiceBlockSaveReq 新增/编辑模块(id=0 新建)。images 传相对路径数组。
type AdminServiceBlockSaveReq struct {
g.Meta `path:"/admin/service/block/save" method:"post" tags:"admin" summary:"保存服务介绍页模块"`
Id int64 `json:"id"`
Kind string `v:"required|in:hero,rich,image_text,image_list,flow,checklist,price,developer,works,goods#请选择模块类型|模块类型不合法" json:"kind"`
Title string `v:"length:0,128#标题最多 128 字" json:"title"`
Subtitle string `v:"length:0,255#副标题最多 255 字" json:"subtitle"`
ImagePath string `json:"imagePath"`
Images []string `json:"images"`
Content string `json:"content"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
}
type AdminServiceBlockSaveRes struct {
Id int64 `json:"id"`
}
// AdminServiceBlockDeleteReq 删除模块
type AdminServiceBlockDeleteReq struct {
g.Meta `path:"/admin/service/block/delete" method:"post" tags:"admin" summary:"删除服务介绍页模块"`
Id int64 `v:"required" json:"id"`
}
type AdminServiceBlockDeleteRes struct{}
// ---------- 作品案例 ----------
// AdminServiceWorkItem 作品列表项(不含 content_md)
type AdminServiceWorkItem struct {
Id int64 `json:"id"`
Category int `json:"category"`
CategoryName string `json:"category_name"`
Title string `json:"title"`
Summary string `json:"summary"`
CoverPath string `json:"cover_path"`
CoverUrl string `json:"cover_url"`
PriceCents int64 `json:"price_cents"`
PriceNote string `json:"price_note"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
CreatedAt string `json:"created_at"`
}
// AdminServiceWorkListReq 作品列表
type AdminServiceWorkListReq struct {
g.Meta `path:"/admin/service/work/list" method:"get" tags:"admin" summary:"软件开发作品列表"`
Keyword string `json:"keyword"`
Category int `json:"category"`
IsEnabled *int `json:"isEnabled"`
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type AdminServiceWorkListRes struct {
List []AdminServiceWorkItem `json:"list"`
Total int `json:"total"`
}
// AdminServiceWorkDetailReq 作品详情(编辑回填)
type AdminServiceWorkDetailReq struct {
g.Meta `path:"/admin/service/work/detail" method:"get" tags:"admin" summary:"软件开发作品详情"`
Id int64 `v:"required" json:"id"`
}
type AdminServiceWorkDetailRes struct {
Id int64 `json:"id"`
Category int `json:"category"`
Title string `json:"title"`
Summary string `json:"summary"`
ContentMd string `json:"content_md"`
CoverPath string `json:"cover_path"`
CoverUrl string `json:"cover_url"`
Images []string `json:"images"`
PriceCents int64 `json:"price_cents"`
PriceNote string `json:"price_note"`
Sort int `json:"sort"`
IsEnabled int `json:"is_enabled"`
}
// AdminServiceWorkSaveReq 新增/编辑作品
type AdminServiceWorkSaveReq struct {
g.Meta `path:"/admin/service/work/save" method:"post" tags:"admin" summary:"保存软件开发作品"`
Id int64 `json:"id"`
Category int `v:"required|in:1,2,3,4#请选择分类|分类不合法" json:"category"`
Title string `v:"required#请填写作品名称" json:"title"`
Summary string `v:"length:0,500#简介最多 500 字" json:"summary"`
ContentMd string `json:"contentMd"`
CoverPath string `json:"coverPath"`
Images []string `json:"images"`
PriceCents int64 `v:"min:0#价格不能为负" json:"priceCents"`
PriceNote string `v:"length:0,64#价格备注最多 64 字" json:"priceNote"`
Sort int `json:"sort"`
IsEnabled int `json:"isEnabled"`
}
type AdminServiceWorkSaveRes struct {
Id int64 `json:"id"`
}
// AdminServiceWorkDeleteReq 删除作品(软删除)
type AdminServiceWorkDeleteReq struct {
g.Meta `path:"/admin/service/work/delete" method:"post" tags:"admin" summary:"删除软件开发作品"`
Id int64 `v:"required" json:"id"`
}
type AdminServiceWorkDeleteRes struct{}
// ---------- 报价申请 ----------
// AdminServiceQuoteItem 报价申请列表项
type AdminServiceQuoteItem struct {
Id int64 `json:"id"`
UserId int64 `json:"user_id"`
UserNickname string `json:"user_nickname"`
WorkId int64 `json:"work_id"`
WorkTitle string `json:"work_title"`
Contact string `json:"contact"`
Requirement string `json:"requirement"`
Budget string `json:"budget"`
Status int `json:"status"`
StatusText string `json:"status_text"`
QuoteCents int64 `json:"quote_cents"`
QuoteNote string `json:"quote_note"`
HandledAt string `json:"handled_at"`
CreatedAt string `json:"created_at"`
}
// AdminServiceQuoteListReq 报价申请列表
type AdminServiceQuoteListReq struct {
g.Meta `path:"/admin/service/quote/list" method:"get" tags:"admin" summary:"报价申请列表"`
Status *int `json:"status"` // nil=全部
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type AdminServiceQuoteListRes struct {
List []AdminServiceQuoteItem `json:"list"`
Total int `json:"total"`
}
// AdminServiceQuoteHandleReq 处理报价申请(填报价 + 状态),用户端推站内信
type AdminServiceQuoteHandleReq struct {
g.Meta `path:"/admin/service/quote/handle" method:"post" tags:"admin" summary:"处理报价申请"`
Id int64 `v:"required" json:"id"`
Status int `v:"in:0,1,2,3#状态不合法" json:"status"`
QuoteCents int64 `v:"min:0#报价不能为负" json:"quoteCents"`
QuoteNote string `v:"length:0,500#报价说明最多 500 字" json:"quoteNote"`
}
type AdminServiceQuoteHandleRes struct{}

33
api/admin/v1/setting.go Normal file
View File

@@ -0,0 +1,33 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// 管理端系统配置契约(迭代-2026-09-29)
//
// 走 settings 表(约定 S9:配置一律走 settings 表,不写 config.yaml);本模块
// 只开放白名单键,当前是 site.base_url(图片等静态资源的对外拼接前缀)。
// ============================================================================
// AdminSettingItem 配置项
type AdminSettingItem struct {
Key string `json:"key"`
Value string `json:"value"`
Remark string `json:"remark"`
}
// AdminSettingGetReq 读取白名单配置项
type AdminSettingGetReq struct {
g.Meta `path:"/admin/setting/get" method:"get" tags:"admin" summary:"系统配置读取"`
}
type AdminSettingGetRes struct {
Items []AdminSettingItem `json:"items"`
}
// AdminSettingSaveReq 保存一项配置(key 必须在白名单内)
type AdminSettingSaveReq struct {
g.Meta `path:"/admin/setting/save" method:"post" tags:"admin" summary:"系统配置保存"`
Key string `v:"required" json:"key"`
Value string `v:"length:0,255#配置值最多 255 字" json:"value"`
}
type AdminSettingSaveRes struct{}

19
api/user/v1/app_config.go Normal file
View File

@@ -0,0 +1,19 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// C 端应用配置契约(迭代-2026-09-30)
//
// 首页等界面级配置的轻量下发口。值来自 settings 表,key 对齐小程序端
// `config/features.ts` 的 FEATURE_ENTRIES key;未配置的 key 不出现在 map 中。
// ============================================================================
// AppConfigReq 应用配置(首页功能卡背景图等)
type AppConfigReq struct {
g.Meta `path:"/app/config" method:"get" tags:"app" summary:"应用配置"`
}
type AppConfigRes struct {
// FeatureCovers 首页便捷功能卡背景图:featureKey → 绝对 URL(后端按 site.base_url 拼好)
FeatureCovers map[string]string `json:"feature_covers"`
}

52
api/user/v1/goods.go Normal file
View File

@@ -0,0 +1,52 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// C 端软件开发服务契约(迭代-2026-09-29):list / detail
//
// 仅上架(is_enabled=1)商品可见;封面 url 由后端按 site.base_url 拼成绝对地址,
// 小程序端拿到即可直接用于 <image>,无需自行拼接域名。
// ============================================================================
// GoodsItem 服务列表项(响应 snake_case)
type GoodsItem struct {
Id int64 `json:"id"`
Category int `json:"category"` // 1网站 2小程序 3App 4桌面
CategoryName string `json:"category_name"` // 分类展示名(服务端映射)
Title string `json:"title"`
Summary string `json:"summary"`
CoverUrl string `json:"cover_url"` // 封面绝对 URL(可能为空 = 无封面)
PriceCents int64 `json:"price_cents"` // 0 = 面议
PriceNote string `json:"price_note"` // 价格备注(如「起」「/项目」)
}
// GoodsListReq 服务列表(C 端,仅上架)
type GoodsListReq struct {
g.Meta `path:"/goods/list" method:"get" tags:"goods" summary:"软件开发服务列表"`
Category int `json:"category"` // 0=全部
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type GoodsListRes struct {
List []GoodsItem `json:"list"`
Total int `json:"total"`
}
// GoodsDetailReq 服务详情(C 端,仅上架;含 Markdown 原文,由前端解析渲染)
type GoodsDetailReq struct {
g.Meta `path:"/goods/detail" method:"get" tags:"goods" summary:"软件开发服务详情"`
Id int64 `v:"required" json:"id"`
}
type GoodsDetailRes struct {
Id int64 `json:"id"`
Category int `json:"category"`
CategoryName string `json:"category_name"`
Title string `json:"title"`
Summary string `json:"summary"`
ContentMd string `json:"content_md"`
CoverUrl string `json:"cover_url"`
PriceCents int64 `json:"price_cents"`
PriceNote string `json:"price_note"`
UpdatedAt string `json:"updated_at"`
}

View File

@@ -93,6 +93,7 @@ type MemberOrderCheckRes struct {
type MemberOrdersReq struct {
g.Meta `path:"/member/orders" method:"get" tags:"member" summary:"我的会员订单"`
Limit int `json:"limit"`
Tab int `json:"tab"` // 0全部 1待付款 2已生效 3退款
}
type MemberOrderOut struct {
OutTradeNo string `json:"out_trade_no"`

47
api/user/v1/message.go Normal file
View File

@@ -0,0 +1,47 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// C 端站内信契约(迭代-2026-09-30)
//
// 写入点(后端业务事件统一落 user_messages,见 logic/message.go PushMessage):
// 会员开通/次数包到账(发货成功)、报价申请回执、报价处理结果、反馈官方回复。
// C 端只读:列表 / 未读数 / 标记已读(单条或全部)。
// ============================================================================
// MessageItem 站内信条目
type MessageItem struct {
Id int64 `json:"id"`
Type string `json:"type"` // order_delivered/quote_submitted/quote_handled/feedback_replied/system
Title string `json:"title"`
Content string `json:"content"`
Link string `json:"link"` // 小程序内跳转路由,空=不跳转
IsRead int `json:"is_read"`
CreatedAt string `json:"created_at"`
}
// MessageListReq 我的消息(最近 N 条 + 未读数;不自动置已读,已读走 /message/read)
type MessageListReq struct {
g.Meta `path:"/message/list" method:"get" tags:"message" summary:"我的站内信"`
Limit int `json:"limit"`
}
type MessageListRes struct {
List []MessageItem `json:"list"`
Unread int `json:"unread"`
}
// MessageUnreadReq 未读消息数(首页 / 我的页徽标)
type MessageUnreadReq struct {
g.Meta `path:"/message/unread" method:"get" tags:"message" summary:"未读站内信数"`
}
type MessageUnreadRes struct {
Count int `json:"count"`
}
// MessageReadReq 标记已读:Id>0 单条;Id=0 全部
type MessageReadReq struct {
g.Meta `path:"/message/read" method:"post" tags:"message" summary:"站内信标记已读"`
Id int64 `json:"id"`
}
type MessageReadRes struct{}

115
api/user/v1/project.go Normal file
View File

@@ -0,0 +1,115 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// C 端开发项目契约(迭代-2026-09-30 三期)
//
// 流程:后台建单(生成绑定令牌)→ 用户扫小程序码进入绑定页 → POST bind 绑定到
// 当前用户 → 后台维护阶段/小节点/交付物(原型、UI 设计稿等)→ 用户端只读查看。
// ============================================================================
// ProjectStageItem 项目阶段(含小节点与交付物)
type ProjectStageItem struct {
Id int64 `json:"id"`
Title string `json:"title"`
Status int `json:"status"` // 0未开始 1进行中 2已完成
StatusText string `json:"status_text"`
Nodes []ProjectNodeItem `json:"nodes"`
Files []ProjectFileItem `json:"files"`
}
// ProjectNodeItem 阶段小节点
type ProjectNodeItem struct {
Id int64 `json:"id"`
Title string `json:"title"`
IsDone int `json:"is_done"`
DoneAt string `json:"done_at"`
}
// ProjectFileItem 阶段交付物(图片,绝对 URL)
type ProjectFileItem struct {
Id int64 `json:"id"`
FileName string `json:"file_name"`
FileUrl string `json:"file_url"`
}
// ProjectPayItem 项目分期(后台确认到账,用户只读)
type ProjectPayItem struct {
Id int64 `json:"id"`
Seq int `json:"seq"`
Percent int `json:"percent"`
AmountCents int64 `json:"amount_cents"`
Trigger string `json:"trigger"`
TriggerText string `json:"trigger_text"`
Status int `json:"status"` // 0待支付 1已支付
PaidAt string `json:"paid_at"`
}
// ProjectMaintainOut 维护期信息
type ProjectMaintainOut struct {
PlanName string `json:"plan_name"`
FreeYears int `json:"free_years"`
YearlyFeeCents int64 `json:"yearly_fee_cents"`
SourceOnly int `json:"source_only"` // 1=仅源码无售后
Accepted int `json:"accepted"`
AcceptedAt string `json:"accepted_at"`
StartAt string `json:"start_at"` // 维护期起算日(后台可调)
PaidUntil string `json:"paid_until"` // 维护费已缴至(= 下次缴费日)
}
// ProjectItem 项目列表项
type ProjectItem struct {
Id int64 `json:"id"`
Title string `json:"title"`
Description string `json:"description"`
PriceCents int64 `json:"price_cents"`
PriceNote string `json:"price_note"`
DueNote string `json:"due_note"`
Status int `json:"status"`
StatusText string `json:"status_text"`
StageTotal int `json:"stage_total"` // 已完成阶段数(输出时算好,前端直接展示进度)
StageDone int `json:"stage_done"`
CoverUrl string `json:"cover_url"` // 入口图(第一个阶段交付物图片,可能为空)
UpdatedAt string `json:"updated_at"`
}
// ProjectMineReq 我的项目(扫码绑定后可见)
type ProjectMineReq struct {
g.Meta `path:"/service/project/mine" method:"get" tags:"service" summary:"我的开发项目"`
}
type ProjectMineRes struct {
List []ProjectItem `json:"list"`
}
// ProjectDetailReq 项目详情(仅限已绑定用户;含阶段/节点/交付物/分期/维护期)
type ProjectDetailReq struct {
g.Meta `path:"/service/project/detail" method:"get" tags:"service" summary:"开发项目详情"`
Id int64 `v:"required" json:"id"`
}
type ProjectDetailRes struct {
ProjectItem
Stages []ProjectStageItem `json:"stages"`
Pays []ProjectPayItem `json:"pays"` // 分期付款(进度与到账状态)
Maintain *ProjectMaintainOut `json:"maintain"` // 维护期信息(未选模板为 null)
CanAccept int `json:"can_accept"` // 1=可点「确认验收」(已交付且未验收)
}
// ProjectAcceptReq 用户确认验收:进入维护期(维护期起点=确认当天,后台可调整)。
type ProjectAcceptReq struct {
g.Meta `path:"/service/project/accept" method:"post" tags:"service" summary:"确认验收进入维护期"`
Id int64 `v:"required" json:"id"`
}
type ProjectAcceptRes struct {
MaintainStartAt string `json:"maintain_start_at"`
PaidUntil string `json:"paid_until"`
}
// ProjectBindReq 扫码绑定项目(令牌来自小程序码 scene)
type ProjectBindReq struct {
g.Meta `path:"/service/project/bind" method:"post" tags:"service" summary:"绑定开发项目"`
Token string `v:"required#缺少绑定令牌" json:"token"`
}
type ProjectBindRes struct {
ProjectId int64 `json:"project_id"`
}

102
api/user/v1/service.go Normal file
View File

@@ -0,0 +1,102 @@
package v1
import "github.com/gogf/gf/v2/frame/g"
// ============================================================================
// C 端软件开发服务二期契约(迭代-2026-09-30):介绍页模块 / 作品案例 / 报价申请
//
// - 介绍页模块(service_blocks)由管理端逐块编辑(预设排版布局),C 端按 sort 输出;
// - 图片字段库内只存相对路径,输出统一为绝对 URL(同 goods 封面约定);
// - 报价申请提交后写站内信回执;处理结果(已报价/成交/关闭)再推站内信。
// ============================================================================
// ServiceBlockOut 介绍页模块(响应 snake_case)
type ServiceBlockOut struct {
Id int64 `json:"id"`
Kind string `json:"kind"` // hero/rich/image_text/image_list/developer/works/goods
Title string `json:"title"`
Subtitle string `json:"subtitle"`
ImageUrl string `json:"image_url"` // 主图绝对 URL(可能为空)
Images []string `json:"images"` // 图集绝对 URL(image_list)
Content string `json:"content"` // Markdown 原文,前端解析渲染
}
// ServicePageReq 服务介绍页模块列表(仅启用,sort 升序)
type ServicePageReq struct {
g.Meta `path:"/service/page" method:"get" tags:"service" summary:"软件开发服务介绍页"`
}
type ServicePageRes struct {
List []ServiceBlockOut `json:"list"`
}
// WorkItem 作品列表项
type WorkItem struct {
Id int64 `json:"id"`
Category int `json:"category"`
CategoryName string `json:"category_name"`
Title string `json:"title"`
Summary string `json:"summary"`
CoverUrl string `json:"cover_url"`
PriceCents int64 `json:"price_cents"` // 0 = 面议
PriceNote string `json:"price_note"`
}
// WorksListReq 作品列表(仅上架)
type WorksListReq struct {
g.Meta `path:"/service/works" method:"get" tags:"service" summary:"软件开发作品列表"`
Category int `json:"category"` // 0=全部
Page int `json:"page"`
PageSize int `json:"pageSize"`
}
type WorksListRes struct {
List []WorkItem `json:"list"`
Total int `json:"total"`
}
// WorkDetailReq 作品详情(含图集与 Markdown 详情)
type WorkDetailReq struct {
g.Meta `path:"/service/works/detail" method:"get" tags:"service" summary:"软件开发作品详情"`
Id int64 `v:"required" json:"id"`
}
type WorkDetailRes struct {
WorkItem
Images []string `json:"images"` // 详情图集(绝对 URL)
ContentMd string `json:"content_md"`
UpdatedAt string `json:"updated_at"`
}
// QuoteSubmitReq 提交报价申请(需求 → 站内信回执)
type QuoteSubmitReq struct {
g.Meta `path:"/service/quote/submit" method:"post" tags:"service" summary:"提交软件开发报价申请"`
WorkId int64 `json:"work_id"` // 0 = 通用申请(不关联作品)
Contact string `v:"required#请填写联系方式" json:"contact"`
Requirement string `v:"required#请描述您的需求" json:"requirement"`
Budget string `json:"budget"` // 预算范围文案,可空
}
type QuoteSubmitRes struct {
Id int64 `json:"id"`
}
// QuoteMineItem 我的报价申请条目(含处理结果)
type QuoteMineItem struct {
Id int64 `json:"id"`
WorkId int64 `json:"work_id"`
WorkTitle string `json:"work_title"`
Requirement string `json:"requirement"`
Budget string `json:"budget"`
Status int `json:"status"`
StatusText string `json:"status_text"`
QuoteCents int64 `json:"quote_cents"` // 0=面议/未填
QuoteNote string `json:"quote_note"`
HandledAt string `json:"handled_at"`
CreatedAt string `json:"created_at"`
}
// QuoteMineReq 我的报价申请(最近 N 条)
type QuoteMineReq struct {
g.Meta `path:"/service/quote/mine" method:"get" tags:"service" summary:"我的报价申请"`
Limit int `json:"limit"`
}
type QuoteMineRes struct {
List []QuoteMineItem `json:"list"`
}

View File

@@ -27,6 +27,7 @@ const (
cronPatternReleaseCoupons = "0 */5 * * * *" // 免单券超时占用释放:每 5 分钟
cronPatternCloseOrders = "0 */5 * * * *" // 订单超时关闭:每 5 分钟
cronPatternRefundPoll = "0 */2 * * * *" // 退款结果轮询:每 2 分钟(logic/order_refund.go)
cronPatternMaintainRemind = "0 40 9 * * *" // 维护费到期提醒:每天 09:40(logic/project_flow.go)
)
var (
@@ -87,6 +88,15 @@ var (
g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "refund-status-poll", cronPatternRefundPoll)
}
// 维护费到期提醒:已验收项目的年度维护费,到期前 N 天开始提醒(隔 3 天一发、最后 3 天天天发)
if _, err = gcron.AddSingleton(ctx, cronPatternMaintainRemind, func(c context.Context) {
logic.ScanMaintainReminders(c)
}, "maintain-remind-scan"); err != nil {
g.Log().Warningf(ctx, "[cron] 维护费提醒任务注册失败: %v", err)
} else {
g.Log().Infof(ctx, "[cron] 已注册任务 %s pattern=%q", "maintain-remind-scan", cronPatternMaintainRemind)
}
s := g.Server()
// 公开接口:小程序登录、开发登录、管理端登录、虚拟支付发货推送回调
s.Group("/", func(group *ghttp.RouterGroup) {
@@ -99,12 +109,15 @@ var (
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(logic.CORS, logic.Response, logic.UserAuth)
group.Bind(controller.UserAuth, controller.Member, controller.Quota, controller.Note,
controller.Promo, controller.Coupon)
controller.Promo, controller.Coupon, controller.Goods, controller.AppConfig,
controller.Service, controller.ServiceProject)
})
// 管理端接口:鉴权(AdminAuth)+ 权限分级(AdminPerm,按角色权限点放行)
s.Group("/", func(group *ghttp.RouterGroup) {
group.Middleware(logic.CORS, logic.Response, logic.AdminAuth, logic.AdminPerm)
group.Bind(controller.AdminAuth, controller.Event, controller.AdminPromo, controller.AdminCoupon, controller.AdminOrder)
group.Bind(controller.AdminAuth, controller.Event, controller.AdminPromo, controller.AdminCoupon, controller.AdminOrder,
controller.AdminGoods, controller.AdminAttachment, controller.AdminSetting, controller.AdminService,
controller.AdminServiceProject)
})
// 活动小程序码静态资源(生成时落在 manifest/qrcode,供管理端/前端访问)
// ⚠️ AddStaticPath 在目录不存在时**直接 FATAL 退出、不降级**,而该目录原先只在

View File

@@ -338,3 +338,178 @@ const (
RefundStatusSuccess = 2 // 退款成功(含手工退款登记)
RefundStatusFailed = 3 // 退款失败(可重新发起)
)
// ============================================================================
// 软件开发服务域(迭代-2026-09-29)
// ============================================================================
// 表名(服务商品 / 上传附件登记 / 服务介绍页模块 / 作品案例 / 报价申请 / 站内信 / 开发项目)
const (
TableGoods = "goods"
TableAttachments = "attachments"
TableServiceBlocks = "service_blocks"
TableServiceWorks = "service_works"
TableServiceQuotes = "service_quotes"
TableUserMessages = "user_messages"
TableServiceProjects = "service_projects"
TableProjectStages = "service_project_stages"
TableProjectNodes = "service_project_nodes"
TableProjectFiles = "service_project_files"
)
// 服务介绍页模块类型(service_blocks.kind,预设排版布局)
const (
ServiceBlockHero = "hero" // 头图横幅:大标题(支持 **高亮**)+ 标语
ServiceBlockRich = "rich" // 富文本:Markdown 图文混排
ServiceBlockImageText = "image_text" // 图文:图 + 说明文字
ServiceBlockImageList = "image_list" // 图片列表:多图网格(点击预览)
ServiceBlockFlow = "flow" // 服务流程:时间轴,每行「标题|描述」
ServiceBlockChecklist = "checklist" // 交付清单:勾选卡片,每行一项
ServiceBlockPrice = "price" // 价格卡:每行「名称|描述|价格」
ServiceBlockDeveloper = "developer" // 开发者简介:头像 + 名字 + 介绍
ServiceBlockWorks = "works" // 作品模块入口(进作品列表)
ServiceBlockGoods = "goods" // 服务与价格清单入口(进服务列表)
)
// 报价申请状态(service_quotes.status)
const (
QuoteStatusPending = 0 // 待处理
QuoteStatusQuoted = 1 // 已报价
QuoteStatusDone = 2 // 已成交
QuoteStatusClosed = 3 // 已关闭
)
// QuoteStatusNameOf 报价状态 → 展示名
func QuoteStatusNameOf(s int) string {
switch s {
case QuoteStatusPending:
return "待处理"
case QuoteStatusQuoted:
return "已报价"
case QuoteStatusDone:
return "已成交"
case QuoteStatusClosed:
return "已关闭"
}
return "未知"
}
// 站内信类型(user_messages.type)
const (
MsgTypeOrderDelivered = "order_delivered" // 会员开通 / 次数包到账(订单生效)
MsgTypeQuoteSubmitted = "quote_submitted" // 报价申请已提交(回执)
MsgTypeQuoteHandled = "quote_handled" // 报价处理结果(已报价/成交/关闭)
MsgTypeFeedbackReplied = "feedback_replied" // 反馈收到官方回复
MsgTypeProjectUpdate = "project_update" // 开发项目进度更新(阶段/交付物/绑定/交付)
MsgTypeSystem = "system" // 系统通知
)
// 开发项目状态(service_projects.status)
const (
ProjectStatusRunning = 1 // 进行中
ProjectStatusDone = 2 // 已交付
ProjectStatusClosed = 3 // 已关闭
)
// ProjectStatusNameOf 项目状态 → 展示名
func ProjectStatusNameOf(s int) string {
switch s {
case ProjectStatusRunning:
return "进行中"
case ProjectStatusDone:
return "已交付"
case ProjectStatusClosed:
return "已关闭"
}
return "未知"
}
// 项目阶段状态(service_project_stages.status)
const (
StageStatusPending = 0 // 未开始
StageStatusDoing = 1 // 进行中
StageStatusDone = 2 // 已完成
)
// 项目分期触发节点(service_project_pays.trigger)
const (
PayTriggerStart = "start" // 开工前(定金)
PayTriggerMidDemo = "mid_demo" // 中期演示确认后
PayTriggerOnlineBefore = "online_before" // 上线前(尾款)
PayTriggerOnlineAfter = "online_after" // 上线后(尾款)
)
// PayTriggerNameOf 分期触发节点 → 展示名
func PayTriggerNameOf(t string) string {
switch t {
case PayTriggerStart:
return "开工前(定金)"
case PayTriggerMidDemo:
return "中期演示确认后"
case PayTriggerOnlineBefore:
return "上线前"
case PayTriggerOnlineAfter:
return "上线后"
}
return t
}
// 项目分期状态(service_project_pays.status)
const (
PayStatusPending = 0 // 待支付
PayStatusPaid = 1 // 已支付
)
// 表名追加(开发项目四期:维护模板 / 付款分期模板 / 项目分期)
const (
TableMaintainPlans = "service_maintain_plans"
TablePayPlans = "service_pay_plans"
TableProjectPays = "service_project_pays"
)
// StageStatusNameOf 阶段状态 → 展示名
func StageStatusNameOf(s int) string {
switch s {
case StageStatusDoing:
return "进行中"
case StageStatusDone:
return "已完成"
}
return "未开始"
}
// 软件开发服务分类(goods.category)
const (
GoodsCategoryWebsite = 1 // 网站开发系列
GoodsCategoryMiniProgram = 2 // 小程序开发系列
GoodsCategoryApp = 3 // App 开发系列
GoodsCategoryDesktop = 4 // 桌面程序开发系列
)
// GoodsCategoryNameOf 分类值 → 展示名(未知值回落「其他」)
func GoodsCategoryNameOf(cat int) string {
switch cat {
case GoodsCategoryWebsite:
return "网站开发"
case GoodsCategoryMiniProgram:
return "小程序开发"
case GoodsCategoryApp:
return "App 开发"
case GoodsCategoryDesktop:
return "桌面程序开发"
}
return "其他"
}
// 附件上传场景(attachments.scene)
const (
AttachmentSceneAvatar = "avatar" // 用户头像(小程序端上传)
AttachmentSceneCover = "cover" // 服务封面(管理端上传)
AttachmentSceneAttachment = "attachment" // 通用附件(管理端上传)
)
// 站点 settings 键
const (
SettingSiteBaseUrl = "site.base_url" // 图片等静态资源的对外拼接前缀;留空 = 按请求 Host 动态拼接
SettingHomeFeatureCovers = "home.feature.covers" // 首页便捷功能卡背景图(JSON:featureKey → 相对路径),经 /app/config 下发
)

View File

@@ -0,0 +1,22 @@
package controller
import (
"context"
userv1 "tool-api/api/user/v1"
"tool-api/internal/logic"
)
// ============================================================================
// 应用配置控制器(迭代-2026-09-30):C 端界面级配置下发
// ============================================================================
// cAppConfig 用户端应用配置接口(需用户登录,挂 UserAuth 组)
type cAppConfig struct{}
// AppConfig 用户端应用配置对象
var AppConfig = &cAppConfig{}
func (c *cAppConfig) AppConfig(ctx context.Context, req *userv1.AppConfigReq) (res *userv1.AppConfigRes, err error) {
return logic.AppConfig(ctx)
}

View File

@@ -0,0 +1,32 @@
package controller
import (
"context"
adminv1 "tool-api/api/admin/v1"
"tool-api/internal/logic"
)
// ============================================================================
// 附件管理控制器(迭代-2026-09-29):管理端上传 / 列表 / 删除
// ============================================================================
// cAdminAttachment 管理端附件接口(需管理员登录)
type cAdminAttachment struct{}
// AdminAttachment 管理端附件对象(挂 AdminAuth 组)
var AdminAttachment = &cAdminAttachment{}
// Upload 管理端上传(multipart):scene=cover|attachment,文件字段名 file。
// GoFrame 已把 multipart 表单绑定进 req(含 scene 字段校验 in:cover,attachment)。
func (c *cAdminAttachment) Upload(ctx context.Context, req *adminv1.AdminUploadReq) (res *adminv1.AdminUploadRes, err error) {
return logic.AdminUploadFromRequest(ctx, req.Scene)
}
func (c *cAdminAttachment) AttachmentList(ctx context.Context, req *adminv1.AdminAttachmentListReq) (res *adminv1.AdminAttachmentListRes, err error) {
return logic.AdminAttachmentList(ctx, req)
}
func (c *cAdminAttachment) AttachmentDelete(ctx context.Context, req *adminv1.AdminAttachmentDeleteReq) (res *adminv1.AdminAttachmentDeleteRes, err error) {
return logic.AdminAttachmentDelete(ctx, req.Id)
}

View File

@@ -0,0 +1,53 @@
package controller
import (
"context"
adminv1 "tool-api/api/admin/v1"
userv1 "tool-api/api/user/v1"
"tool-api/internal/logic"
)
// ============================================================================
// 软件开发服务控制器(迭代-2026-09-29)
//
// 拆成两个对象分别挂到 UserAuth / AdminAuth 两组(与优惠码同款拆分,
// ⚠️ 同一 controller 对象不能同时 Bind 到两组,见 controller/promo.go 的说明):
// - Goods 用户端(list / detail,需用户登录)
// - AdminGoods 管理端(list / detail / save / delete)
// ============================================================================
// cGoods 用户端服务接口(需用户登录)
type cGoods struct{}
// cAdminGoods 管理端服务接口(需管理员登录)
type cAdminGoods struct{}
var (
Goods = &cGoods{}
AdminGoods = &cAdminGoods{}
)
func (c *cGoods) GoodsList(ctx context.Context, req *userv1.GoodsListReq) (res *userv1.GoodsListRes, err error) {
return logic.GoodsList(ctx, req)
}
func (c *cGoods) GoodsDetail(ctx context.Context, req *userv1.GoodsDetailReq) (res *userv1.GoodsDetailRes, err error) {
return logic.GoodsDetail(ctx, req.Id)
}
func (c *cAdminGoods) GoodsList(ctx context.Context, req *adminv1.AdminGoodsListReq) (res *adminv1.AdminGoodsListRes, err error) {
return logic.AdminGoodsList(ctx, req)
}
func (c *cAdminGoods) GoodsDetail(ctx context.Context, req *adminv1.AdminGoodsDetailReq) (res *adminv1.AdminGoodsDetailRes, err error) {
return logic.AdminGoodsDetail(ctx, req.Id)
}
func (c *cAdminGoods) GoodsSave(ctx context.Context, req *adminv1.AdminGoodsSaveReq) (res *adminv1.AdminGoodsSaveRes, err error) {
return logic.AdminGoodsSave(ctx, req)
}
func (c *cAdminGoods) GoodsDelete(ctx context.Context, req *adminv1.AdminGoodsDeleteReq) (res *adminv1.AdminGoodsDeleteRes, err error) {
return logic.AdminGoodsDelete(ctx, req.Id)
}

View File

@@ -43,7 +43,7 @@ func (c *cMember) MemberOrderCheck(ctx context.Context, req *v1.MemberOrderCheck
}
func (c *cMember) MemberOrders(ctx context.Context, req *v1.MemberOrdersReq) (res *v1.MemberOrdersRes, err error) {
return logic.MemberOrders(ctx, req.Limit)
return logic.MemberOrders(ctx, req.Limit, req.Tab)
}
// PayNotify 虚拟支付「发货推送」回调(平台调用,无 token,需在 MP 后台配置该 URL)。

View File

@@ -0,0 +1,137 @@
package controller
import (
"context"
adminv1 "tool-api/api/admin/v1"
userv1 "tool-api/api/user/v1"
"tool-api/internal/logic"
)
// ============================================================================
// 开发项目控制器(迭代-2026-09-30 三期)
//
// - ServiceProject 用户端(绑定 / 我的项目 / 详情)
// - AdminServiceProject 管理端(建单 / 码图 / 阶段 / 节点 / 交付物)
// ============================================================================
type cServiceProject struct{}
type cAdminServiceProject struct{}
var (
ServiceProject = &cServiceProject{}
AdminServiceProject = &cAdminServiceProject{}
)
// ---- 用户端 ----
func (c *cServiceProject) ProjectMine(ctx context.Context, req *userv1.ProjectMineReq) (res *userv1.ProjectMineRes, err error) {
return logic.ProjectMine(ctx)
}
func (c *cServiceProject) ProjectDetail(ctx context.Context, req *userv1.ProjectDetailReq) (res *userv1.ProjectDetailRes, err error) {
return logic.ProjectDetail(ctx, req.Id)
}
func (c *cServiceProject) ProjectBind(ctx context.Context, req *userv1.ProjectBindReq) (res *userv1.ProjectBindRes, err error) {
return logic.ProjectBind(ctx, req.Token)
}
func (c *cServiceProject) ProjectAccept(ctx context.Context, req *userv1.ProjectAcceptReq) (res *userv1.ProjectAcceptRes, err error) {
return logic.ProjectAccept(ctx, req.Id)
}
// ---- 管理端 ----
func (c *cAdminServiceProject) ProjectList(ctx context.Context, req *adminv1.AdminProjectListReq) (res *adminv1.AdminProjectListRes, err error) {
return logic.AdminProjectList(ctx, req)
}
func (c *cAdminServiceProject) ProjectSave(ctx context.Context, req *adminv1.AdminProjectSaveReq) (res *adminv1.AdminProjectSaveRes, err error) {
return logic.AdminProjectSave(ctx, req)
}
func (c *cAdminServiceProject) ProjectDelete(ctx context.Context, req *adminv1.AdminProjectDeleteReq) (res *adminv1.AdminProjectDeleteRes, err error) {
return logic.AdminProjectDelete(ctx, req.Id)
}
func (c *cAdminServiceProject) ProjectDetail(ctx context.Context, req *adminv1.AdminProjectDetailReq) (res *adminv1.AdminProjectDetailRes, err error) {
return logic.AdminProjectDetail(ctx, req.Id)
}
func (c *cAdminServiceProject) ProjectQrcode(ctx context.Context, req *adminv1.AdminProjectQrcodeReq) (res *adminv1.AdminProjectQrcodeRes, err error) {
return logic.AdminProjectQrcode(ctx, req.Id)
}
func (c *cAdminServiceProject) ProjectStageSave(ctx context.Context, req *adminv1.AdminProjectStageSaveReq) (res *adminv1.AdminProjectStageSaveRes, err error) {
return logic.AdminProjectStageSave(ctx, req)
}
func (c *cAdminServiceProject) ProjectStageDelete(ctx context.Context, req *adminv1.AdminProjectStageDeleteReq) (res *adminv1.AdminProjectStageDeleteRes, err error) {
return logic.AdminProjectStageDelete(ctx, req.Id)
}
func (c *cAdminServiceProject) ProjectNodeSave(ctx context.Context, req *adminv1.AdminProjectNodeSaveReq) (res *adminv1.AdminProjectNodeSaveRes, err error) {
return logic.AdminProjectNodeSave(ctx, req)
}
func (c *cAdminServiceProject) ProjectNodeDelete(ctx context.Context, req *adminv1.AdminProjectNodeDeleteReq) (res *adminv1.AdminProjectNodeDeleteRes, err error) {
return logic.AdminProjectNodeDelete(ctx, req.Id)
}
func (c *cAdminServiceProject) ProjectFileAdd(ctx context.Context, req *adminv1.AdminProjectFileAddReq) (res *adminv1.AdminProjectFileAddRes, err error) {
return logic.AdminProjectFileAdd(ctx, req)
}
func (c *cAdminServiceProject) ProjectFileDelete(ctx context.Context, req *adminv1.AdminProjectFileDeleteReq) (res *adminv1.AdminProjectFileDeleteRes, err error) {
return logic.AdminProjectFileDelete(ctx, req.Id)
}
func (c *cAdminServiceProject) MaintainPlanList(ctx context.Context, req *adminv1.AdminMaintainPlanListReq) (res *adminv1.AdminMaintainPlanListRes, err error) {
return logic.AdminMaintainPlanList(ctx)
}
func (c *cAdminServiceProject) MaintainPlanSave(ctx context.Context, req *adminv1.AdminMaintainPlanSaveReq) (res *adminv1.AdminMaintainPlanSaveRes, err error) {
return logic.AdminMaintainPlanSave(ctx, req)
}
func (c *cAdminServiceProject) MaintainPlanDelete(ctx context.Context, req *adminv1.AdminMaintainPlanDeleteReq) (res *adminv1.AdminMaintainPlanDeleteRes, err error) {
return logic.AdminMaintainPlanDelete(ctx, req.Id)
}
func (c *cAdminServiceProject) PayPlanList(ctx context.Context, req *adminv1.AdminPayPlanListReq) (res *adminv1.AdminPayPlanListRes, err error) {
return logic.AdminPayPlanList(ctx)
}
func (c *cAdminServiceProject) PayPlanSave(ctx context.Context, req *adminv1.AdminPayPlanSaveReq) (res *adminv1.AdminPayPlanSaveRes, err error) {
return logic.AdminPayPlanSave(ctx, req)
}
func (c *cAdminServiceProject) PayPlanDelete(ctx context.Context, req *adminv1.AdminPayPlanDeleteReq) (res *adminv1.AdminPayPlanDeleteRes, err error) {
return logic.AdminPayPlanDelete(ctx, req.Id)
}
func (c *cAdminServiceProject) ProjectPayMark(ctx context.Context, req *adminv1.AdminProjectPayMarkReq) (res *adminv1.AdminProjectPayMarkRes, err error) {
return logic.AdminProjectPayMark(ctx, req)
}
func (c *cAdminServiceProject) ProjectPayUnmark(ctx context.Context, req *adminv1.AdminProjectPayUnmarkReq) (res *adminv1.AdminProjectPayUnmarkRes, err error) {
return logic.AdminProjectPayUnmark(ctx, req.Id)
}
func (c *cAdminServiceProject) ProjectMaintainRenew(ctx context.Context, req *adminv1.AdminProjectMaintainRenewReq) (res *adminv1.AdminProjectMaintainRenewRes, err error) {
return logic.AdminProjectMaintainRenew(ctx, req.Id)
}
func (c *cAdminServiceProject) ProjectMaintainAdjust(ctx context.Context, req *adminv1.AdminProjectMaintainAdjustReq) (res *adminv1.AdminProjectMaintainAdjustRes, err error) {
return logic.AdminProjectMaintainAdjust(ctx, req)
}
func (c *cAdminServiceProject) MaintainRemindGet(ctx context.Context, req *adminv1.AdminMaintainRemindGetReq) (res *adminv1.AdminMaintainRemindGetRes, err error) {
return logic.AdminMaintainRemindGet(ctx)
}
func (c *cAdminServiceProject) MaintainRemindSave(ctx context.Context, req *adminv1.AdminMaintainRemindSaveReq) (res *adminv1.AdminMaintainRemindSaveRes, err error) {
return logic.AdminMaintainRemindSave(ctx, req)
}

View File

@@ -0,0 +1,108 @@
package controller
import (
"context"
adminv1 "tool-api/api/admin/v1"
userv1 "tool-api/api/user/v1"
"tool-api/internal/logic"
)
// ============================================================================
// 软件开发服务二期控制器(迭代-2026-09-30)
//
// 与 goods 同款拆分(同一 controller 对象不能同时 Bind 到 UserAuth / AdminAuth 两组):
// - Service 用户端(介绍页 / 作品 / 报价 / 站内信,需用户登录)
// - AdminService 管理端(模块 / 作品 / 报价处理)
// ============================================================================
// cService 用户端软件开发服务接口
type cService struct{}
// cAdminService 管理端软件开发服务接口
type cAdminService struct{}
var (
Service = &cService{}
AdminService = &cAdminService{}
)
// ---- 用户端:介绍页 / 作品 ----
func (c *cService) ServicePage(ctx context.Context, req *userv1.ServicePageReq) (res *userv1.ServicePageRes, err error) {
return logic.ServicePage(ctx)
}
func (c *cService) WorksList(ctx context.Context, req *userv1.WorksListReq) (res *userv1.WorksListRes, err error) {
return logic.ServiceWorks(ctx, req)
}
func (c *cService) WorkDetail(ctx context.Context, req *userv1.WorkDetailReq) (res *userv1.WorkDetailRes, err error) {
return logic.ServiceWorkDetail(ctx, req.Id)
}
// ---- 用户端:报价申请 ----
func (c *cService) QuoteSubmit(ctx context.Context, req *userv1.QuoteSubmitReq) (res *userv1.QuoteSubmitRes, err error) {
return logic.ServiceQuoteSubmit(ctx, req)
}
func (c *cService) QuoteMine(ctx context.Context, req *userv1.QuoteMineReq) (res *userv1.QuoteMineRes, err error) {
return logic.ServiceQuoteMine(ctx, req.Limit)
}
// ---- 用户端:站内信 ----
func (c *cService) MessageList(ctx context.Context, req *userv1.MessageListReq) (res *userv1.MessageListRes, err error) {
return logic.MessageList(ctx, req.Limit)
}
func (c *cService) MessageUnread(ctx context.Context, req *userv1.MessageUnreadReq) (res *userv1.MessageUnreadRes, err error) {
return logic.MessageUnreadCount(ctx)
}
func (c *cService) MessageRead(ctx context.Context, req *userv1.MessageReadReq) (res *userv1.MessageReadRes, err error) {
return logic.MessageMarkRead(ctx, req.Id)
}
// ---- 管理端:介绍页模块 ----
func (c *cAdminService) BlockList(ctx context.Context, req *adminv1.AdminServiceBlockListReq) (res *adminv1.AdminServiceBlockListRes, err error) {
return logic.AdminServiceBlockList(ctx)
}
func (c *cAdminService) BlockSave(ctx context.Context, req *adminv1.AdminServiceBlockSaveReq) (res *adminv1.AdminServiceBlockSaveRes, err error) {
return logic.AdminServiceBlockSave(ctx, req)
}
func (c *cAdminService) BlockDelete(ctx context.Context, req *adminv1.AdminServiceBlockDeleteReq) (res *adminv1.AdminServiceBlockDeleteRes, err error) {
return logic.AdminServiceBlockDelete(ctx, req.Id)
}
// ---- 管理端:作品案例 ----
func (c *cAdminService) WorkList(ctx context.Context, req *adminv1.AdminServiceWorkListReq) (res *adminv1.AdminServiceWorkListRes, err error) {
return logic.AdminServiceWorkList(ctx, req)
}
func (c *cAdminService) WorkDetail(ctx context.Context, req *adminv1.AdminServiceWorkDetailReq) (res *adminv1.AdminServiceWorkDetailRes, err error) {
return logic.AdminServiceWorkDetail(ctx, req.Id)
}
func (c *cAdminService) WorkSave(ctx context.Context, req *adminv1.AdminServiceWorkSaveReq) (res *adminv1.AdminServiceWorkSaveRes, err error) {
return logic.AdminServiceWorkSave(ctx, req)
}
func (c *cAdminService) WorkDelete(ctx context.Context, req *adminv1.AdminServiceWorkDeleteReq) (res *adminv1.AdminServiceWorkDeleteRes, err error) {
return logic.AdminServiceWorkDelete(ctx, req.Id)
}
// ---- 管理端:报价申请 ----
func (c *cAdminService) QuoteList(ctx context.Context, req *adminv1.AdminServiceQuoteListReq) (res *adminv1.AdminServiceQuoteListRes, err error) {
return logic.AdminServiceQuoteList(ctx, req)
}
func (c *cAdminService) QuoteHandle(ctx context.Context, req *adminv1.AdminServiceQuoteHandleReq) (res *adminv1.AdminServiceQuoteHandleRes, err error) {
return logic.AdminServiceQuoteHandle(ctx, req)
}

View File

@@ -0,0 +1,26 @@
package controller
import (
"context"
adminv1 "tool-api/api/admin/v1"
"tool-api/internal/logic"
)
// ============================================================================
// 系统配置控制器(迭代-2026-09-29):白名单键的读取 / 保存
// ============================================================================
// cAdminSetting 管理端系统配置接口(需管理员登录;读写都归 admin:manage,仅超管)
type cAdminSetting struct{}
// AdminSetting 管理端系统配置对象(挂 AdminAuth 组)
var AdminSetting = &cAdminSetting{}
func (c *cAdminSetting) SettingGet(ctx context.Context, req *adminv1.AdminSettingGetReq) (res *adminv1.AdminSettingGetRes, err error) {
return logic.AdminSettingGet(ctx)
}
func (c *cAdminSetting) SettingSave(ctx context.Context, req *adminv1.AdminSettingSaveReq) (res *adminv1.AdminSettingSaveRes, err error) {
return logic.AdminSettingSave(ctx, req.Key, req.Value)
}

View File

@@ -184,7 +184,7 @@ func AdminUserList(ctx context.Context, req *v1.UserListReq) (*v1.UserListRes, e
item := v1.AdminUserItem{
Id: r["id"].Int64(),
Nickname: r["nickname"].String(),
AvatarUrl: r["avatar_url"].String(),
AvatarUrl: absoluteURL(ctx, r["avatar_url"].String()),
Openid: r["openid"].String(),
LevelKey: r["level_key"].String(),
LevelName: levelNames[r["level_key"].String()],
@@ -811,6 +811,14 @@ func AdminFeedbackHandle(ctx context.Context, id int64, reply string) (*v1.Feedb
if len(reply) > 1000 {
reply = reply[:1000]
}
// 先取反馈归属(站内信收件人);反馈不存在直接报错,避免把状态写到空气里
fb, err := g.Model(consts.TableFeedbacks).Where("id", id).One()
if err != nil {
return nil, err
}
if fb.IsEmpty() {
return nil, gerror.New("反馈不存在")
}
data := g.Map{"status": 1}
if reply != "" {
data["reply_content"] = reply
@@ -822,6 +830,8 @@ func AdminFeedbackHandle(ctx context.Context, id int64, reply string) (*v1.Feedb
return nil, err
}
if reply != "" {
// 站内信:反馈收到官方回复(尽力而为,失败不影响回复本身)
PushMessage(ctx, fb["user_id"].Int64(), consts.MsgTypeFeedbackReplied, "你的反馈有了新回复", reply, "")
WriteAudit(ctx, AuditEntry{
Action: "feedback.reply",
TargetType: "feedback",

View File

@@ -102,7 +102,7 @@ func AdminOrderList(ctx context.Context, req *v1.OrderListReq) (*v1.OrderListRes
if u, ok := userInfo[uid]; ok {
item.Nickname = u["nickname"].String()
item.Username = u["username"].String()
item.AvatarUrl = u["avatar_url"].String()
item.AvatarUrl = absoluteURL(ctx, u["avatar_url"].String())
}
item.Title = orderTitle(orderType, r["plan_key"].String(), r["pack_key"].String(), r["tool_key"].String(),
planNames, packNames, toolNames)

View File

@@ -33,12 +33,15 @@ const (
// 订单域:查看 / 退款(退款为可逆性资金动作,单独权限点便于收口)
PermOrderRead = "order:read"
PermOrderWrite = "order:write"
// 软件开发服务 / 附件管理(迭代-2026-09-29)
PermGoodsWrite = "goods:write" // 服务商品新增/编辑/删除
PermAttachmentWrite = "attachment:write" // 附件删除与管理端上传(封面/附件)
)
// rolePermissions 角色 → 权限点集合。
//
// 超管:全部
// 运营:内容读写(用户/工具/模块/等级/反馈/额度/年会/订单),不含管理员管理与审计
// 运营:内容读写(用户/工具/模块/等级/反馈/额度/年会/订单/服务商品/附件),不含管理员管理与审计
// 只读:无写权限,仅可查看(含订单查看)
var rolePermissions = map[string]map[string]bool{
consts.AdminRoleSuper: {
@@ -46,11 +49,13 @@ var rolePermissions = map[string]map[string]bool{
PermFeedbackWrite: true, PermQuotaWrite: true, PermEventWrite: true,
PermAdminManage: true, PermAuditRead: true,
PermOrderRead: true, PermOrderWrite: true,
PermGoodsWrite: true, PermAttachmentWrite: true,
},
consts.AdminRoleOperator: {
PermUserWrite: true, PermToolWrite: true, PermModuleWrite: true, PermLevelWrite: true,
PermFeedbackWrite: true, PermQuotaWrite: true, PermEventWrite: true,
PermOrderRead: true, PermOrderWrite: true,
PermGoodsWrite: true, PermAttachmentWrite: true,
},
consts.AdminRoleReadonly: {
PermOrderRead: true,
@@ -101,6 +106,12 @@ func permissionForRoute(method, path string) string {
if strings.HasPrefix(path, "/audit/") {
return PermAuditRead
}
// 管理端服务/附件的查看仅要求登录(与优惠码列表同策略;super/operator/readonly 均可看),
// 必须先于 /admin/ 的 admin:manage 兜底命中
if strings.HasPrefix(path, "/admin/goods/") || strings.HasPrefix(path, "/admin/attachment/") ||
strings.HasPrefix(path, "/admin/service/") {
return ""
}
if strings.HasPrefix(path, "/admin/") {
return PermAdminManage
}
@@ -135,6 +146,14 @@ func permissionForRoute(method, path string) string {
case strings.HasPrefix(path, "/order/"):
// 订单退款属可逆性资金动作,按「订单写」权限放行(运营+超管可写;只读不可写)
return PermOrderWrite
case strings.HasPrefix(path, "/admin/goods/"):
return PermGoodsWrite
case strings.HasPrefix(path, "/admin/service/"):
// 软件开发服务域(介绍页模块 / 作品案例 / 报价处理)统一归「服务写」权限点
return PermGoodsWrite
case path == "/admin/upload", strings.HasPrefix(path, "/admin/attachment/"):
// 上传与附件删除同归「附件写」:封面在服务编辑内上传,运营角色两者都有
return PermAttachmentWrite
case strings.HasPrefix(path, "/admin/"):
return PermAdminManage
case strings.HasPrefix(path, "/audit/"):

View File

@@ -48,15 +48,17 @@ func TestHasPermission(t *testing.T) {
// TestPermissionsOf 权限点列表稳定且有序。
func TestPermissionsOf(t *testing.T) {
if n := len(PermissionsOf(consts.AdminRoleSuper)); n != 11 {
t.Fatalf("超管权限点应为 11,得 %d", n)
// 迭代-2026-09-29:新增 goods:write / attachment:write(11 → 13)
if n := len(PermissionsOf(consts.AdminRoleSuper)); n != 13 {
t.Fatalf("超管权限点应为 13,得 %d", n)
}
if n := len(PermissionsOf(consts.AdminRoleReadonly)); n != 1 {
t.Fatalf("只读权限点应为 1(仅 order:read),得 %d", n)
}
pos := PermissionsOf(consts.AdminRoleOperator)
if len(pos) != 9 {
t.Fatalf("运营权限点应为 9,得 %d", len(pos))
// 迭代-2026-09-29:运营同步获得 goods:write / attachment:write(9 → 11)
if len(pos) != 11 {
t.Fatalf("运营权限点应为 11,得 %d", len(pos))
}
for i := 1; i < len(pos); i++ {
if pos[i-1] > pos[i] {
@@ -85,6 +87,16 @@ func TestPermissionForRoute(t *testing.T) {
{http.MethodPost, "/admin/save", PermAdminManage},
{http.MethodPost, "/audit/record", ""}, // 上报审计仅需登录(≠读取审计)
{http.MethodPost, "/order/refund", PermOrderWrite},
// 迭代-2026-09-29:软件开发服务 / 附件 / 系统配置
{http.MethodPost, "/admin/goods/save", PermGoodsWrite},
{http.MethodPost, "/admin/goods/delete", PermGoodsWrite},
{http.MethodPost, "/admin/upload", PermAttachmentWrite},
{http.MethodPost, "/admin/attachment/delete", PermAttachmentWrite},
{http.MethodPost, "/admin/setting/save", PermAdminManage},
// 服务/附件列表仅需登录(先于 /admin/ 的 admin:manage 兜底命中)
{http.MethodGet, "/admin/goods/list", ""},
{http.MethodGet, "/admin/attachment/list", ""},
{http.MethodGet, "/admin/setting/get", PermAdminManage},
// 只读接口仅需登录
{http.MethodGet, "/user/list", ""},
{http.MethodGet, "/dashboard/stats", ""},

View File

@@ -0,0 +1,40 @@
package logic
import (
"context"
"encoding/json"
"github.com/gogf/gf/v2/frame/g"
userv1 "tool-api/api/user/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 应用配置(迭代-2026-09-30):界面级轻量配置下发
//
// 数据源是 settings 表(约定 S9),这里只做「解析 + URL 拼接」,不引入新表。
// 解析失败一律回落空 map(fail-open):配置坏了不能把首页打挂。
// ============================================================================
// AppConfig 下发应用配置(首页便捷功能卡背景图等)。
// covers 值为相对路径,输出统一 absoluteURL 拼接(图片域名不落库)。
func AppConfig(ctx context.Context) (*userv1.AppConfigRes, error) {
res := &userv1.AppConfigRes{FeatureCovers: map[string]string{}}
raw := SettingValue(ctx, consts.SettingHomeFeatureCovers)
if raw == "" {
return res, nil
}
paths := map[string]string{}
if err := json.Unmarshal([]byte(raw), &paths); err != nil {
g.Log().Warningf(ctx, "[appconfig] %s 解析失败,回落空配置: %v", consts.SettingHomeFeatureCovers, err)
return res, nil
}
for key, p := range paths {
if key == "" || p == "" {
continue
}
res.FeatureCovers[key] = absoluteURL(ctx, p)
}
return res, nil
}

View File

@@ -0,0 +1,165 @@
package logic
import (
"context"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gfile"
adminv1 "tool-api/api/admin/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 附件管理(迭代-2026-09-29)
//
// 上传统一登记 attachments 表(头像/封面/通用附件都在 recordAttachment 落账),
// 本文件提供后台的「查看 / 删除」。删除语义:
// - 记录一律软删(deleted_at,保留审计线索);
// - 文件本体只在「无任何引用」时才落盘删除(md5 去重意味着同一文件可能被
// 多条记录 / 用户头像 / 服务封面共享,盲删会把这些引用全部打断)。
// ============================================================================
var imageExtSet = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true,
}
// AdminAttachmentList 附件列表(上传日期倒序)
func AdminAttachmentList(ctx context.Context, req *adminv1.AdminAttachmentListReq) (*adminv1.AdminAttachmentListRes, error) {
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
m := g.Model(consts.TableAttachments)
if kw := strings.TrimSpace(req.Keyword); kw != "" {
m = m.Where("file_name LIKE ?", "%"+kw+"%")
}
if scene := strings.TrimSpace(req.Scene); scene != "" {
m = m.Where("scene", scene)
}
total, err := m.Count()
if err != nil {
return nil, err
}
records, err := m.Page(page, pageSize).OrderDesc("id").All()
if err != nil {
return nil, err
}
// 批量做引用检查(避免逐行查询):同路径的其他未删记录 / 用户头像 / 服务封面
paths := make([]string, 0, len(records))
for _, r := range records {
paths = append(paths, r["file_path"].String())
}
refMap := attachmentRefCounts(ctx, paths)
list := make([]adminv1.AdminAttachmentItem, 0, len(records))
for _, r := range records {
path := r["file_path"].String()
ext := r["ext"].String()
list = append(list, adminv1.AdminAttachmentItem{
Id: r["id"].Int64(),
Scene: r["scene"].String(),
FileName: r["file_name"].String(),
FilePath: path,
Url: absoluteURL(ctx, path),
Ext: ext,
SizeBytes: r["size_bytes"].Int64(),
Md5: r["md5"].String(),
UploaderId: r["uploader_id"].Int64(),
UploaderType: r["uploader_type"].String(),
// 计数含本记录自身(路径至少被自己引用)→ >1 才代表还有其他引用方;
// 删除流程的检查发生在软删之后,语义见 AdminAttachmentDelete
IsReferenced: refMap[path] > 1,
IsImage: imageExtSet[ext],
CreatedAt: timeStr(r["created_at"].GTime()),
})
}
return &adminv1.AdminAttachmentListRes{List: list, Total: total}, nil
}
// AdminAttachmentDelete 删除附件:记录软删;文件本体无引用时落盘删除。
func AdminAttachmentDelete(ctx context.Context, id int64) (*adminv1.AdminAttachmentDeleteRes, error) {
r, err := g.Model(consts.TableAttachments).Where("id", id).One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, gerror.New("附件不存在或已删除")
}
path := r["file_path"].String()
if _, err = g.Model(consts.TableAttachments).Where("id", id).Data(g.Map{
"deleted_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
res := &adminv1.AdminAttachmentDeleteRes{}
// 引用检查在软删之后:排除本记录自身,剩余引用 >0 则保留文件本体
if refs := attachmentRefCounts(ctx, []string{path}); refs[path] == 0 {
if disk, ok := staticPathToDisk(path); ok && gfile.Exists(disk) {
if err = gfile.Remove(disk); err != nil {
// 文件删除失败不回滚记录删除:记录已隐藏,文件成为孤儿可人工清理
g.Log().Warningf(ctx, "[attachment] 删除文件失败 path=%s err=%v", path, err)
} else {
res.RemovedFile = true
}
} else {
// 文件本就不在(历史遗留/多实例部署),记录删除即达成目的
res.RemovedFile = true
}
}
WriteAudit(ctx, AuditEntry{
Action: "attachment.delete",
TargetType: "attachment",
TargetId: auditId(id),
Before: g.Map{"file_path": path, "file_name": r["file_name"].String()},
Result: consts.AuditResultSuccess,
})
return res, nil
}
// attachmentRefCounts 统计每个路径的「存活引用数」:
// 其他未删附件记录(md5 去重共享同一落盘文件)+ 用户头像(存量含绝对 URL,用 LIKE 收敛)+ 服务封面。
func attachmentRefCounts(ctx context.Context, paths []string) map[string]int {
out := make(map[string]int, len(paths))
if len(paths) == 0 {
return out
}
for _, p := range paths {
if p != "" {
out[p] = 0
}
}
// 同路径的其他未删附件记录
records, err := g.Model(consts.TableAttachments).
WhereIn("file_path", paths).
WhereNull("deleted_at").
Fields("file_path, COUNT(*) AS n").
Group("file_path").
All()
if err == nil {
for _, r := range records {
out[r["file_path"].String()] += r["n"].Int()
}
} else {
g.Log().Warningf(ctx, "[attachment] 引用检查(附件)失败: %v", err)
}
// 用户头像:LIKE '%path%' 兼容存量绝对 URL 写法
for _, p := range paths {
if p == "" {
continue
}
n, err := g.Model(consts.TableUsers).Where("avatar_url LIKE ?", "%"+p+"%").WhereNull("deleted_at").Count()
if err == nil && n > 0 {
out[p] += n
} else if err != nil {
g.Log().Warningf(ctx, "[attachment] 引用检查(头像)失败: %v", err)
}
// 服务封面
n, err = g.Model(consts.TableGoods).Where("cover_path", p).WhereNull("deleted_at").Count()
if err == nil && n > 0 {
out[p] += n
} else if err != nil {
g.Log().Warningf(ctx, "[attachment] 引用检查(封面)失败: %v", err)
}
}
return out
}

240
internal/logic/goods.go Normal file
View File

@@ -0,0 +1,240 @@
package logic
import (
"context"
"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-29)
//
// 分类固定四类(网站/小程序/App/桌面),category 存 tinyint;content_md 只存
// Markdown 原文(管理端编辑预览、小程序端解析渲染,后端不做 HTML 转换)。
// 封面 cover_path 库内存相对路径,输出统一 absoluteURL 拼接(图片域名不落库)。
// ============================================================================
// goodsSortWhitelist 管理端列表排序白名单(防注入)
var goodsSortWhitelist = map[string]string{
"id": "id",
"category": "category",
"sort": "sort",
"price": "price_cents",
"is_enabled": "is_enabled",
"created_at": "created_at",
}
// GoodsList C 端服务列表(仅上架;sort 升序、id 降序)
func GoodsList(ctx context.Context, req *userv1.GoodsListReq) (*userv1.GoodsListRes, error) {
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
m := g.Model(consts.TableGoods).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.GoodsItem, 0, len(records))
for _, r := range records {
list = append(list, goodsItemOf(ctx, r))
}
return &userv1.GoodsListRes{List: list, Total: total}, nil
}
// GoodsDetail C 端服务详情(仅上架;下架/不存在同义报错,避免探测下架内容)
func GoodsDetail(ctx context.Context, id int64) (*userv1.GoodsDetailRes, error) {
r, err := g.Model(consts.TableGoods).Where("id", id).Where("is_enabled", 1).One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, gerror.New("服务不存在或已下架")
}
return &userv1.GoodsDetailRes{
Id: r["id"].Int64(),
Category: r["category"].Int(),
CategoryName: consts.GoodsCategoryNameOf(r["category"].Int()),
Title: r["title"].String(),
Summary: r["summary"].String(),
ContentMd: r["content_md"].String(),
CoverUrl: absoluteURL(ctx, r["cover_path"].String()),
PriceCents: r["price_cents"].Int64(),
PriceNote: r["price_note"].String(),
UpdatedAt: timeStr(r["updated_at"].GTime()),
}, nil
}
// AdminGoodsList 管理端服务列表(含下架)
func AdminGoodsList(ctx context.Context, req *adminv1.AdminGoodsListReq) (*adminv1.AdminGoodsListRes, error) {
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
m := g.Model(consts.TableGoods)
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 := applySort(m.Page(page, pageSize), req.SortBy, req.SortOrder, goodsSortWhitelist, "sort asc, id desc").All()
if err != nil {
return nil, err
}
list := make([]adminv1.AdminGoodsItem, 0, len(records))
for _, r := range records {
list = append(list, adminv1.AdminGoodsItem{
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()),
UpdatedAt: timeStr(r["updated_at"].GTime()),
})
}
return &adminv1.AdminGoodsListRes{List: list, Total: total}, nil
}
// AdminGoodsDetail 管理端服务详情(编辑回填,含 content_md)
func AdminGoodsDetail(ctx context.Context, id int64) (*adminv1.AdminGoodsDetailRes, error) {
r, err := g.Model(consts.TableGoods).Where("id", id).One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, gerror.New("服务不存在")
}
return &adminv1.AdminGoodsDetailRes{
Id: r["id"].Int64(),
Category: r["category"].Int(),
CategoryName: consts.GoodsCategoryNameOf(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()),
PriceCents: r["price_cents"].Int64(),
PriceNote: r["price_note"].String(),
Sort: r["sort"].Int(),
IsEnabled: r["is_enabled"].Int(),
}, nil
}
// AdminGoodsSave 新增/编辑服务。封面写入前归一化为相对路径(图片域名不落库)。
// TZ-01:datetime 写库一律 gdb.Raw("NOW()")。
func AdminGoodsSave(ctx context.Context, req *adminv1.AdminGoodsSaveReq) (*adminv1.AdminGoodsSaveRes, error) {
title := strings.TrimSpace(req.Title)
if title == "" {
return nil, gerror.New("请填写服务名称")
}
data := g.Map{
"category": req.Category,
"title": title,
"summary": strings.TrimSpace(req.Summary),
"content_md": req.ContentMd,
"price_cents": req.PriceCents,
"price_note": strings.TrimSpace(req.PriceNote),
"cover_path": normalizeToRelativePath(req.CoverPath),
"sort": req.Sort,
"is_enabled": req.IsEnabled,
}
if req.Id > 0 {
exist, err := g.Model(consts.TableGoods).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.TableGoods).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
WriteAudit(ctx, AuditEntry{
Action: "goods.save",
TargetType: "goods",
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.AdminGoodsSaveRes{Id: req.Id}, nil
}
data["created_at"] = gdb.Raw("NOW()")
data["updated_at"] = gdb.Raw("NOW()")
res, err := g.Model(consts.TableGoods).Data(data).Insert()
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
WriteAudit(ctx, AuditEntry{
Action: "goods.save",
TargetType: "goods",
TargetId: auditId(id),
After: g.Map{"title": title, "is_enabled": req.IsEnabled},
Result: consts.AuditResultSuccess,
})
return &adminv1.AdminGoodsSaveRes{Id: id}, nil
}
// AdminGoodsDelete 软删除服务(deleted_at 置时间;GoFrame 对带 deleted_at 的表自动过滤已删行)
func AdminGoodsDelete(ctx context.Context, id int64) (*adminv1.AdminGoodsDeleteRes, error) {
exist, err := g.Model(consts.TableGoods).Where("id", id).One()
if err != nil {
return nil, err
}
if exist.IsEmpty() {
return nil, gerror.New("服务不存在")
}
if _, err = g.Model(consts.TableGoods).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: "goods.delete",
TargetType: "goods",
TargetId: auditId(id),
Before: g.Map{"title": exist["title"].String()},
Result: consts.AuditResultSuccess,
})
return &adminv1.AdminGoodsDeleteRes{}, nil
}
// goodsItemOf 列表行 → C 端输出
func goodsItemOf(ctx context.Context, r gdb.Record) userv1.GoodsItem {
return userv1.GoodsItem{
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(),
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"math/rand"
"strconv"
"time"
"github.com/gogf/gf/v2/database/gdb"
@@ -206,12 +207,22 @@ func MemberOrderCheck(ctx context.Context, outTradeNo string) (*v1.MemberOrderCh
}
// MemberOrders 我的订单列表
func MemberOrders(ctx context.Context, limit int) (*v1.MemberOrdersRes, error) {
// MemberOrders 我的订单(limit + tab 状态页签:0全部 1待付款 2已生效 3退款)。
// 「退款」页签 = 已退款(status=2) 或退过款但订单仍是其它终态(refund_status>0,如退款失败/退款中)。
func MemberOrders(ctx context.Context, limit, tab int) (*v1.MemberOrdersRes, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
records, err := g.Model(consts.TableMemberOrders).
Where("user_id", CtxUserId(ctx)).
m := g.Model(consts.TableMemberOrders).Where("user_id", CtxUserId(ctx))
switch tab {
case 1:
m = m.Where("status", consts.OrderStatusPending)
case 2:
m = m.Where("status", consts.OrderStatusDelivered)
case 3:
m = m.Where("status=2 OR refund_status>0")
}
records, err := m.
OrderDesc("id").
Limit(limit).
All()
@@ -440,6 +451,11 @@ func deliverOrder(ctx context.Context, outTradeNo, wxOrderId string) error {
return gerror.Newf("订单 %s 有效天数异常: %d", outTradeNo, days)
}
// 站内信文案(发货成功后与发货同事务写入:发了货必有消息)
msgTitle := "会员已开通"
msgContent := "你的会员权益已生效,快去解锁全部模块体验吧"
msgLink := "/pkg-member/member/orders"
// 两类商品在同一事务里分支发货,幂等仍由上面的 status 判定保证
switch row["order_type"].Int() {
case consts.OrderTypeQuota:
@@ -454,6 +470,9 @@ func deliverOrder(ctx context.Context, outTradeNo, wxOrderId string) error {
if err != nil {
return err
}
msgTitle = "次数包已到账"
msgContent = "你的次数包已到账:+" + strconv.Itoa(times) + " 次,可直接使用"
msgLink = "/pkg-member/quota/my"
g.Log().Infof(ctx, "[quota] 次数包发货完成 userId=%d outTradeNo=%s tool=%s times=%d expire=%s",
userId, outTradeNo, toolKey, times, expireAt.String())
default:
@@ -487,6 +506,19 @@ func deliverOrder(ctx context.Context, outTradeNo, wxOrderId string) error {
}).Update(); err != nil {
return err
}
// 站内信:订单生效通知(同事务写入,TZ-01 用 NOW())
if _, err = tx.Model(consts.TableUserMessages).Data(g.Map{
"user_id": userId,
"type": consts.MsgTypeOrderDelivered,
"title": msgTitle,
"content": msgContent,
"link": msgLink,
"is_read": 0,
"created_at": gdb.Raw("NOW()"),
}).Insert(); err != nil {
return err
}
return nil
})
}

223
internal/logic/message.go Normal file
View File

@@ -0,0 +1,223 @@
package logic
import (
"context"
"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)
//
// 业务事件统一经 PushMessage 落 user_messages(会员开通/次数包到账、报价回执、
// 报价处理结果、反馈官方回复等)。推送是**尽力而为**:失败只告警,绝不让业务
// 主流程(发货/报价/回复)因站内信失败而回滚 —— 唯一例外是发货事务内的写入
// (用 tx 直插,与发货同事务保证原子,见 member.go deliverOrder)。
//
// TZ-01:datetime 写库一律 gdb.Raw("NOW()")。
// ============================================================================
// PushMessage 写一条站内信(尽力而为:失败告警不影响调用方)。
// content 截断到 500 字、link 截断到 255 字(与表结构上限对齐)。
func PushMessage(ctx context.Context, userId int64, msgType, title, content, link string) {
if userId <= 0 {
return
}
if len(content) > 500 {
content = content[:500]
}
if len(link) > 255 {
link = link[:255]
}
_, err := g.Model(consts.TableUserMessages).Data(g.Map{
"user_id": userId,
"type": msgType,
"title": title,
"content": content,
"link": link,
"is_read": 0,
"created_at": gdb.Raw("NOW()"),
}).Insert()
if err != nil {
g.Log().Warningf(ctx, "[message] 站内信写入失败 userId=%d type=%s: %v", userId, msgType, err)
}
}
// MessageList 我的消息(最近 N 条 + 未读数)。不自动置已读:红点语义是
// 「先看到未读数 → 进消息中心 → 手动/进入后清零」,已读走 MessageMarkRead。
func MessageList(ctx context.Context, limit int) (*userv1.MessageListRes, error) {
if limit <= 0 || limit > 50 {
limit = 20
}
userId := CtxUserId(ctx)
records, err := g.Model(consts.TableUserMessages).
Where("user_id", userId).
OrderDesc("id").
Limit(limit).
All()
if err != nil {
return nil, err
}
unread, err := g.Model(consts.TableUserMessages).
Where("user_id", userId).
Where("is_read", 0).
Count()
if err != nil {
return nil, err
}
list := make([]userv1.MessageItem, 0, len(records))
for _, r := range records {
list = append(list, userv1.MessageItem{
Id: r["id"].Int64(),
Type: r["type"].String(),
Title: r["title"].String(),
Content: r["content"].String(),
Link: r["link"].String(),
IsRead: r["is_read"].Int(),
CreatedAt: timeStr(r["created_at"].GTime()),
})
}
return &userv1.MessageListRes{List: list, Unread: unread}, nil
}
// MessageUnreadCount 未读消息数(首页 / 我的页徽标数据源)
func MessageUnreadCount(ctx context.Context) (*userv1.MessageUnreadRes, error) {
count, err := g.Model(consts.TableUserMessages).
Where("user_id", CtxUserId(ctx)).
Where("is_read", 0).
Count()
if err != nil {
return nil, err
}
return &userv1.MessageUnreadRes{Count: count}, nil
}
// MessageMarkRead 标记已读:id>0 单条(校验归属);id=0 全部。
func MessageMarkRead(ctx context.Context, id int64) (*userv1.MessageReadRes, error) {
m := g.Model(consts.TableUserMessages).
Where("user_id", CtxUserId(ctx)).
Where("is_read", 0)
if id > 0 {
m = m.Where("id", id)
}
if _, err := m.Data(g.Map{"is_read": 1}).Update(); err != nil {
return nil, err
}
return &userv1.MessageReadRes{}, nil
}
// ============================================================================
// 管理端:报价申请列表 / 处理
// ============================================================================
// AdminServiceQuoteList 报价申请列表(分页 + 状态筛选,带用户昵称)
func AdminServiceQuoteList(ctx context.Context, req *adminv1.AdminServiceQuoteListReq) (*adminv1.AdminServiceQuoteListRes, error) {
page, pageSize := normalizePage(req.Page, req.PageSize, 20)
m := g.Model(consts.TableServiceQuotes)
if req.Status != nil {
m = m.Where("status", *req.Status)
}
total, err := m.Count()
if err != nil {
return nil, err
}
records, err := m.Page(page, pageSize).OrderDesc("id").All()
if err != nil {
return nil, err
}
// 用户昵称映射(用户可能已被软删,查不到时留空展示 id)
userIds := make([]int64, 0, len(records))
for _, r := range records {
userIds = append(userIds, r["user_id"].Int64())
}
nicknames := map[int64]string{}
if len(userIds) > 0 {
rows, err := g.Model(consts.TableUsers).WhereIn("id", userIds).All()
if err == nil {
for _, u := range rows {
nicknames[u["id"].Int64()] = u["nickname"].String()
}
}
}
list := make([]adminv1.AdminServiceQuoteItem, 0, len(records))
for _, r := range records {
uid := r["user_id"].Int64()
list = append(list, adminv1.AdminServiceQuoteItem{
Id: r["id"].Int64(),
UserId: uid,
UserNickname: nicknames[uid],
WorkId: r["work_id"].Int64(),
WorkTitle: r["work_title"].String(),
Contact: r["contact"].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 &adminv1.AdminServiceQuoteListRes{List: list, Total: total}, nil
}
// AdminServiceQuoteHandle 处理报价申请:落状态/报价/说明 + 站内信通知用户。
func AdminServiceQuoteHandle(ctx context.Context, req *adminv1.AdminServiceQuoteHandleReq) (*adminv1.AdminServiceQuoteHandleRes, error) {
r, err := g.Model(consts.TableServiceQuotes).Where("id", req.Id).One()
if err != nil {
return nil, err
}
if r.IsEmpty() {
return nil, gerror.New("报价申请不存在")
}
note := strings.TrimSpace(req.QuoteNote)
if _, err = g.Model(consts.TableServiceQuotes).Where("id", req.Id).Data(g.Map{
"status": req.Status,
"quote_cents": req.QuoteCents,
"quote_note": note,
"handled_by": CtxAdminId(ctx),
"handled_at": gdb.Raw("NOW()"),
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
// 站内信:已报价/已成交/已关闭均通知(待处理回退不通知,避免噪声)
if req.Status != consts.QuoteStatusPending {
title := "你的报价申请有新进展"
switch req.Status {
case consts.QuoteStatusQuoted:
title = "报价已出,快来看看"
case consts.QuoteStatusDone:
title = "报价申请已成交"
case consts.QuoteStatusClosed:
title = "报价申请已关闭"
}
content := "你的报价申请(" + r["work_title"].String() + ")当前状态:" + consts.QuoteStatusNameOf(req.Status)
if req.QuoteCents > 0 {
content += ",报价 ¥" + centsYuanText(req.QuoteCents)
}
if note != "" {
content += "。" + note
}
PushMessage(ctx, r["user_id"].Int64(), consts.MsgTypeQuoteHandled, title, content, "/pkg-service/quotes")
}
WriteAudit(ctx, AuditEntry{
Action: "service.quote.handle",
TargetType: "service_quote",
TargetId: auditId(req.Id),
Before: g.Map{"status": r["status"].Int()},
After: g.Map{"status": req.Status, "quote_cents": req.QuoteCents},
Result: consts.AuditResultSuccess,
})
return &adminv1.AdminServiceQuoteHandleRes{}, nil
}

View File

@@ -307,6 +307,210 @@ func migrateTables() []migrateTable {
"KEY `idx_coupon` (`coupon_id`)," +
"KEY `idx_user_lock` (`user_id`,`used_order_no`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户持有的优惠券(含券面快照 + 占用/核销记录)'"},
// ===== 软件开发服务域(迭代-2026-09-29)=====
{"goods", "CREATE TABLE IF NOT EXISTS `goods` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`category` tinyint NOT NULL DEFAULT 1 COMMENT '分类 1网站开发 2小程序开发 3App开发 4桌面程序开发'," +
"`title` varchar(128) NOT NULL DEFAULT '' COMMENT '服务名称'," +
"`summary` varchar(500) NOT NULL DEFAULT '' COMMENT '简介(列表卡片展示)'," +
"`content_md` mediumtext COMMENT '详细说明 Markdown 原文,小程序端解析渲染'," +
"`price_cents` bigint NOT NULL DEFAULT 0 COMMENT '定价(分),0=面议'," +
"`price_note` varchar(64) NOT NULL DEFAULT '' COMMENT '价格备注(如「起」「/项目」)'," +
"`cover_path` varchar(255) NOT NULL DEFAULT '' COMMENT '封面相对路径(/static/...),库内不存域名'," +
"`sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前'," +
"`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '上架状态 0下架 1上架'," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," +
"PRIMARY KEY (`id`)," +
"KEY `idx_cat_sort` (`is_enabled`,`category`,`sort`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发服务商品'"},
{"attachments", "CREATE TABLE IF NOT EXISTS `attachments` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`scene` varchar(32) NOT NULL DEFAULT '' COMMENT '上传场景 avatar/cover/attachment'," +
"`file_name` varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名'," +
"`file_path` varchar(255) NOT NULL DEFAULT '' COMMENT '相对路径(/static/...),库内不存域名'," +
"`ext` varchar(16) NOT NULL DEFAULT '' COMMENT '扩展名(小写含点)'," +
"`size_bytes` bigint NOT NULL DEFAULT 0 COMMENT '文件大小(字节)'," +
"`md5` char(32) NOT NULL DEFAULT '' COMMENT '内容 md5(落盘文件名)'," +
"`uploader_id` bigint NOT NULL DEFAULT 0 COMMENT '上传者 id(管理端或用户)'," +
"`uploader_type` varchar(16) NOT NULL DEFAULT 'admin' COMMENT '上传者类型 admin/user'," +
"`created_at` datetime DEFAULT NULL COMMENT '上传日期'," +
"`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除(记录删除,文件本体按引用检查)'," +
"PRIMARY KEY (`id`)," +
"KEY `idx_created` (`created_at`)," +
"KEY `idx_path` (`file_path`)," +
"KEY `idx_scene` (`scene`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传附件登记(上传日期/上传者/大小,供后台附件管理)'"},
// ===== 软件开发服务域二期:介绍页模块 / 作品案例 / 报价申请 / 站内信(迭代-2026-09-30)=====
{"service_blocks", "CREATE TABLE IF NOT EXISTS `service_blocks` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`kind` varchar(16) NOT NULL DEFAULT 'rich' COMMENT '模块类型 hero/rich/image_text/image_list/developer/works/goods'," +
"`title` varchar(128) NOT NULL DEFAULT '' COMMENT '标题'," +
"`subtitle` varchar(255) NOT NULL DEFAULT '' COMMENT '副标题'," +
"`image_path` varchar(255) NOT NULL DEFAULT '' COMMENT '主图相对路径(hero/image_text/developer 头像),库内不存域名'," +
"`images` text COMMENT '图片列表 JSON 数组(相对路径,image_list 用)'," +
"`content` mediumtext COMMENT '正文(Markdown 原文,C 端解析渲染)'," +
"`sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前'," +
"`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '启用 0停用 1启用'," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_sort` (`is_enabled`,`sort`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发服务介绍页模块(后台可编辑,预设排版布局)'"},
{"service_works", "CREATE TABLE IF NOT EXISTS `service_works` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`category` tinyint NOT NULL DEFAULT 1 COMMENT '分类 1网站 2小程序 3App 4桌面(与 goods 对齐)'," +
"`title` varchar(128) NOT NULL DEFAULT '' COMMENT '作品名称'," +
"`summary` varchar(500) NOT NULL DEFAULT '' COMMENT '简介(列表卡片展示)'," +
"`content_md` mediumtext COMMENT '详情说明 Markdown 原文'," +
"`cover_path` varchar(255) NOT NULL DEFAULT '' COMMENT '封面相对路径'," +
"`images` text COMMENT '详情图集 JSON 数组(相对路径)'," +
"`price_cents` bigint NOT NULL DEFAULT 0 COMMENT '参考报价(分),0=面议'," +
"`price_note` varchar(64) NOT NULL DEFAULT '' COMMENT '报价备注(如「起」)'," +
"`sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前'," +
"`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '上架状态 0下架 1上架'," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," +
"PRIMARY KEY (`id`)," +
"KEY `idx_cat_sort` (`is_enabled`,`category`,`sort`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发作品案例'"},
{"service_quotes", "CREATE TABLE IF NOT EXISTS `service_quotes` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`user_id` bigint NOT NULL," +
"`work_id` bigint NOT NULL DEFAULT 0 COMMENT '关联作品 id,0=通用申请'," +
"`work_title` varchar(128) NOT NULL DEFAULT '' COMMENT '作品名快照(作品改名/删除不影响申请单)'," +
"`contact` varchar(64) NOT NULL DEFAULT '' COMMENT '联系方式(微信/手机/QQ)'," +
"`requirement` varchar(1000) NOT NULL DEFAULT '' COMMENT '需求描述'," +
"`budget` varchar(64) NOT NULL DEFAULT '' COMMENT '预算范围(文案,如 5k-1w)'," +
"`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待处理 1已报价 2已成交 3已关闭'," +
"`quote_cents` bigint NOT NULL DEFAULT 0 COMMENT '报价金额(分),0=面议/未填'," +
"`quote_note` varchar(500) NOT NULL DEFAULT '' COMMENT '报价说明(用户端可见)'," +
"`handled_by` bigint NOT NULL DEFAULT 0 COMMENT '处理管理员 id,0=未处理'," +
"`handled_at` datetime DEFAULT NULL COMMENT '最近处理时间'," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_user` (`user_id`,`id`)," +
"KEY `idx_status` (`status`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发报价申请'"},
{"user_messages", "CREATE TABLE IF NOT EXISTS `user_messages` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`user_id` bigint NOT NULL," +
"`type` varchar(32) NOT NULL DEFAULT 'system' COMMENT '类型 order_delivered/quote_submitted/quote_handled/feedback_replied/project_update/system'," +
"`title` varchar(128) NOT NULL DEFAULT '' COMMENT '消息标题'," +
"`content` varchar(500) NOT NULL DEFAULT '' COMMENT '消息正文'," +
"`link` varchar(255) NOT NULL DEFAULT '' COMMENT '跳转路径(小程序内路由,空=不跳转)'," +
"`is_read` tinyint NOT NULL DEFAULT 0 COMMENT '0未读 1已读'," +
"`created_at` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_user_read` (`user_id`,`is_read`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户站内信(会员开通/报价/反馈回复等通知)'"},
// ===== 软件开发域三期:开发项目(后台建单 → 用户扫码绑定 → 阶段/节点/交付物跟踪)=====
{"service_projects", "CREATE TABLE IF NOT EXISTS `service_projects` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`user_id` bigint NOT NULL DEFAULT 0 COMMENT '绑定用户 id,0=待扫码绑定'," +
"`bind_token` varchar(16) NOT NULL DEFAULT '' COMMENT '扫码绑定令牌(scene 携带,唯一)'," +
"`title` varchar(128) NOT NULL DEFAULT '' COMMENT '项目名称'," +
"`description` varchar(1000) NOT NULL DEFAULT '' COMMENT '需求描述'," +
"`price_cents` bigint NOT NULL DEFAULT 0 COMMENT '项目金额(分),0=面议'," +
"`price_note` varchar(64) NOT NULL DEFAULT '' COMMENT '金额备注'," +
"`due_note` varchar(64) NOT NULL DEFAULT '' COMMENT '交付周期文案(如「4 周」)'," +
"`status` tinyint NOT NULL DEFAULT 1 COMMENT '1进行中 2已交付 3已关闭'," +
"`code_url` varchar(255) NOT NULL DEFAULT '' COMMENT '绑定小程序码相对路径(/qrcode/...)'," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除'," +
"PRIMARY KEY (`id`)," +
"UNIQUE KEY `uk_bind_token` (`bind_token`)," +
"KEY `idx_user` (`user_id`,`id`)," +
"KEY `idx_status` (`status`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发项目订单'"},
{"service_project_stages", "CREATE TABLE IF NOT EXISTS `service_project_stages` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`project_id` bigint NOT NULL," +
"`title` varchar(64) NOT NULL DEFAULT '' COMMENT '阶段名(如 原型设计/UI设计/开发/测试)'," +
"`status` tinyint NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已完成'," +
"`sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前'," +
"`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '0隐藏 1显示'," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_project` (`project_id`,`sort`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发项目阶段'"},
{"service_project_nodes", "CREATE TABLE IF NOT EXISTS `service_project_nodes` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`project_id` bigint NOT NULL," +
"`stage_id` bigint NOT NULL," +
"`title` varchar(64) NOT NULL DEFAULT '' COMMENT '小节点(可勾选事项)'," +
"`is_done` tinyint NOT NULL DEFAULT 0 COMMENT '0未完成 1已完成'," +
"`done_at` datetime DEFAULT NULL COMMENT '完成时间'," +
"`sort` int NOT NULL DEFAULT 0," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_stage` (`stage_id`,`sort`,`id`)," +
"KEY `idx_project` (`project_id`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发项目阶段小节点'"},
{"service_project_files", "CREATE TABLE IF NOT EXISTS `service_project_files` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`project_id` bigint NOT NULL," +
"`stage_id` bigint NOT NULL DEFAULT 0 COMMENT '所属阶段,0=项目级'," +
"`file_name` varchar(128) NOT NULL DEFAULT '' COMMENT '展示名(默认原始文件名)'," +
"`file_path` varchar(255) NOT NULL DEFAULT '' COMMENT '相对路径(/static/...),库内不存域名'," +
"`sort` int NOT NULL DEFAULT 0," +
"`created_at` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_stage` (`stage_id`,`sort`,`id`)," +
"KEY `idx_project` (`project_id`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发项目交付物(原型/UI 设计稿等图片)'"},
// ===== 软件开发域四期:维护模板 / 付款分期模板 / 项目分期(交付流程)=====
{"service_maintain_plans", "CREATE TABLE IF NOT EXISTS `service_maintain_plans` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`name` varchar(64) NOT NULL DEFAULT '' COMMENT '模板名(如 一年免费维护)'," +
"`free_years` int NOT NULL DEFAULT 0 COMMENT '免费维护年数,0=无免费'," +
"`yearly_fee_cents` bigint NOT NULL DEFAULT 0 COMMENT '后续维护费(元/年,分存储),0=免费无续费'," +
"`source_only` tinyint NOT NULL DEFAULT 0 COMMENT '1=仅交付源码无售后(不进维护期提醒)'," +
"`sort` int NOT NULL DEFAULT 0," +
"`is_enabled` tinyint NOT NULL DEFAULT 1," +
"`remark` varchar(255) NOT NULL DEFAULT ''," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_sort` (`is_enabled`,`sort`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='维护模板(免费年限/年费/仅源码)'"},
{"service_pay_plans", "CREATE TABLE IF NOT EXISTS `service_pay_plans` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`name` varchar(64) NOT NULL DEFAULT '' COMMENT '模板名(如 两期·上线前结清)'," +
"`schedule` varchar(500) NOT NULL DEFAULT '' COMMENT '分期 JSON:[{seq,percent,trigger}],trigger=start/online_before/online_after/mid_demo'," +
"`sort` int NOT NULL DEFAULT 0," +
"`is_enabled` tinyint NOT NULL DEFAULT 1," +
"`remark` varchar(255) NOT NULL DEFAULT ''," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_sort` (`is_enabled`,`sort`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='付款分期模板(定金/尾款比例节点)'"},
{"service_project_pays", "CREATE TABLE IF NOT EXISTS `service_project_pays` (" +
"`id` bigint unsigned NOT NULL AUTO_INCREMENT," +
"`project_id` bigint NOT NULL," +
"`seq` int NOT NULL DEFAULT 1 COMMENT '第几期'," +
"`percent` int NOT NULL DEFAULT 0 COMMENT '付款比例(%)'," +
"`amount_cents` bigint NOT NULL DEFAULT 0 COMMENT '本期金额(分)= 项目金额 × 比例'," +
"`trigger` varchar(16) NOT NULL DEFAULT 'start' COMMENT '触发节点 start/online_before/online_after/mid_demo'," +
"`status` tinyint NOT NULL DEFAULT 0 COMMENT '0待支付 1已支付'," +
"`paid_at` datetime DEFAULT NULL," +
"`paid_note` varchar(64) NOT NULL DEFAULT '' COMMENT '收款备注(如 微信收款)'," +
"`created_at` datetime DEFAULT NULL," +
"`updated_at` datetime DEFAULT NULL," +
"PRIMARY KEY (`id`)," +
"KEY `idx_project` (`project_id`,`seq`,`id`)" +
") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='开发项目分期付款(后台确认到账)'"},
}
}
@@ -383,5 +587,14 @@ func migrateColumns() []migrateColumn {
{"event_tables", "remark", "ALTER TABLE `event_tables` ADD COLUMN `remark` varchar(64) NOT NULL DEFAULT '' COMMENT '桌备注'"},
{"event_tables", "exclude_auto", "ALTER TABLE `event_tables` ADD COLUMN `exclude_auto` tinyint NOT NULL DEFAULT 0 COMMENT '1=不参与自动排序'"},
{"event_participants", "exclude_assign", "ALTER TABLE `event_participants` ADD COLUMN `exclude_assign` tinyint NOT NULL DEFAULT 0 COMMENT '1=不参与排座'"},
// ===== 开发项目四期:交付流程(维护模板 / 付款分期 / 验收维护期)=====
{"service_projects", "maintain_plan_id", "ALTER TABLE `service_projects` ADD COLUMN `maintain_plan_id` bigint NOT NULL DEFAULT 0 COMMENT '维护模板 id,0=未选择'"},
{"service_projects", "pay_plan_id", "ALTER TABLE `service_projects` ADD COLUMN `pay_plan_id` bigint NOT NULL DEFAULT 0 COMMENT '付款分期模板 id,0=未选择'"},
{"service_projects", "accepted", "ALTER TABLE `service_projects` ADD COLUMN `accepted` tinyint NOT NULL DEFAULT 0 COMMENT '0待验收 1已验收(用户手动确认)'"},
{"service_projects", "accepted_at", "ALTER TABLE `service_projects` ADD COLUMN `accepted_at` datetime DEFAULT NULL COMMENT '用户确认验收时间(维护期起点,后台可调 maintain_start_at)'"},
{"service_projects", "maintain_start_at", "ALTER TABLE `service_projects` ADD COLUMN `maintain_start_at` datetime DEFAULT NULL COMMENT '维护期起算日(默认=验收日,后台可调整)'"},
{"service_projects", "maintain_paid_until", "ALTER TABLE `service_projects` ADD COLUMN `maintain_paid_until` datetime DEFAULT NULL COMMENT '维护费已缴至(免费期=起点+免费年数;续费+1年)'"},
{"service_projects", "last_maintain_remind_at", "ALTER TABLE `service_projects` ADD COLUMN `last_maintain_remind_at` datetime DEFAULT NULL COMMENT '上次缴费提醒时间(隔 3 天一次的依据)'"},
}
}

1116
internal/logic/project.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,465 @@
package logic
import (
"context"
"fmt"
"strconv"
"strings"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
adminv1 "tool-api/api/admin/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 开发项目四期:维护模板 / 付款分期模板 / 分期到账 / 维护期 / 每日提醒扫描
//(迭代-2026-09-30,交付流程:需求提交 → 功能确认 → 支付定金 → 开发 → 验收 → 维护期)
// ============================================================================
// ---------- 维护模板 ----------
// AdminMaintainPlanList 维护模板列表(含停用)
func AdminMaintainPlanList(ctx context.Context) (*adminv1.AdminMaintainPlanListRes, error) {
records, err := g.Model(consts.TableMaintainPlans).Order("sort asc, id asc").All()
if err != nil {
return nil, err
}
list := make([]adminv1.AdminMaintainPlanItem, 0, len(records))
for _, r := range records {
list = append(list, adminv1.AdminMaintainPlanItem{
Id: r["id"].Int64(),
Name: r["name"].String(),
FreeYears: r["free_years"].Int(),
YearlyFeeCents: r["yearly_fee_cents"].Int64(),
SourceOnly: r["source_only"].Int(),
Sort: r["sort"].Int(),
IsEnabled: r["is_enabled"].Int(),
Remark: r["remark"].String(),
})
}
return &adminv1.AdminMaintainPlanListRes{List: list}, nil
}
// AdminMaintainPlanSave 保存维护模板。仅源码模板不涉及免费年限与年费(统一清零)。
func AdminMaintainPlanSave(ctx context.Context, req *adminv1.AdminMaintainPlanSaveReq) (*adminv1.AdminMaintainPlanSaveRes, error) {
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, gerror.New("请填写模板名称")
}
if req.SourceOnly == 1 {
req.YearlyFeeCents = 0
req.FreeYears = 0
}
data := g.Map{
"name": name,
"free_years": req.FreeYears,
"yearly_fee_cents": req.YearlyFeeCents,
"source_only": req.SourceOnly,
"sort": req.Sort,
"is_enabled": req.IsEnabled,
"remark": strings.TrimSpace(req.Remark),
}
if req.Id > 0 {
data["updated_at"] = gdb.Raw("NOW()")
if _, err := g.Model(consts.TableMaintainPlans).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
return &adminv1.AdminMaintainPlanSaveRes{Id: req.Id}, nil
}
data["created_at"] = gdb.Raw("NOW()")
data["updated_at"] = gdb.Raw("NOW()")
res, err := g.Model(consts.TableMaintainPlans).Data(data).Insert()
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &adminv1.AdminMaintainPlanSaveRes{Id: id}, nil
}
// AdminMaintainPlanDelete 删除维护模板(项目上的历史引用展示为「模板已删」)。
func AdminMaintainPlanDelete(ctx context.Context, id int64) (*adminv1.AdminMaintainPlanDeleteRes, error) {
if _, err := g.Model(consts.TableMaintainPlans).Where("id", id).Delete(); err != nil {
return nil, err
}
return &adminv1.AdminMaintainPlanDeleteRes{}, nil
}
// ---------- 付款分期模板 ----------
// AdminPayPlanList 分期模板列表(含停用)
func AdminPayPlanList(ctx context.Context) (*adminv1.AdminPayPlanListRes, error) {
records, err := g.Model(consts.TablePayPlans).Order("sort asc, id asc").All()
if err != nil {
return nil, err
}
list := make([]adminv1.AdminPayPlanItem, 0, len(records))
for _, r := range records {
list = append(list, adminv1.AdminPayPlanItem{
Id: r["id"].Int64(),
Name: r["name"].String(),
Schedule: r["schedule"].String(),
Sort: r["sort"].Int(),
IsEnabled: r["is_enabled"].Int(),
Remark: r["remark"].String(),
})
}
return &adminv1.AdminPayPlanListRes{List: list}, nil
}
// AdminPayPlanSave 保存分期模板(schedule JSON 校验:比例合计 100、触发节点合法)。
func AdminPayPlanSave(ctx context.Context, req *adminv1.AdminPayPlanSaveReq) (*adminv1.AdminPayPlanSaveRes, error) {
name := strings.TrimSpace(req.Name)
if name == "" {
return nil, gerror.New("请填写模板名称")
}
if _, err := parsePaySchedule(req.Schedule); err != nil {
return nil, err
}
data := g.Map{
"name": name,
"schedule": strings.TrimSpace(req.Schedule),
"sort": req.Sort,
"is_enabled": req.IsEnabled,
"remark": strings.TrimSpace(req.Remark),
}
if req.Id > 0 {
data["updated_at"] = gdb.Raw("NOW()")
if _, err := g.Model(consts.TablePayPlans).Where("id", req.Id).Data(data).Update(); err != nil {
return nil, err
}
return &adminv1.AdminPayPlanSaveRes{Id: req.Id}, nil
}
data["created_at"] = gdb.Raw("NOW()")
data["updated_at"] = gdb.Raw("NOW()")
res, err := g.Model(consts.TablePayPlans).Data(data).Insert()
if err != nil {
return nil, err
}
id, _ := res.LastInsertId()
return &adminv1.AdminPayPlanSaveRes{Id: id}, nil
}
// AdminPayPlanDelete 删除分期模板。
func AdminPayPlanDelete(ctx context.Context, id int64) (*adminv1.AdminPayPlanDeleteRes, error) {
if _, err := g.Model(consts.TablePayPlans).Where("id", id).Delete(); err != nil {
return nil, err
}
return &adminv1.AdminPayPlanDeleteRes{}, nil
}
// ---------- 分期到账 / 维护期 ----------
// AdminProjectPayMark 标记分期到账并通知用户。
func AdminProjectPayMark(ctx context.Context, req *adminv1.AdminProjectPayMarkReq) (*adminv1.AdminProjectPayMarkRes, error) {
pay, err := g.Model(consts.TableProjectPays).Where("id", req.Id).One()
if err != nil {
return nil, err
}
if pay.IsEmpty() {
return nil, gerror.New("分期不存在")
}
if pay["status"].Int() == consts.PayStatusPaid {
return nil, gerror.New("该分期已是到账状态")
}
p, err := g.Model(consts.TableServiceProjects).Where("id", pay["project_id"].Int64()).One()
if err != nil {
return nil, err
}
if _, err = g.Model(consts.TableProjectPays).Where("id", req.Id).Data(g.Map{
"status": consts.PayStatusPaid,
"paid_at": gdb.Raw("NOW()"),
"paid_note": strings.TrimSpace(req.PaidNote),
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
pushProjectUpdate(ctx, p["user_id"].Int64(), pay["project_id"].Int64(),
"付款已确认",
fmt.Sprintf("项目「%s」第 %d 期(¥%s,%s)已确认到账。",
p["title"].String(), pay["seq"].Int(), centsYuanText(pay["amount_cents"].Int64()),
consts.PayTriggerNameOf(pay["trigger"].String())))
WriteAudit(ctx, AuditEntry{
Action: "service.project.pay.mark",
TargetType: "service_project_pay",
TargetId: auditId(req.Id),
After: g.Map{"seq": pay["seq"].Int(), "amount_cents": pay["amount_cents"].Int64()},
Result: consts.AuditResultSuccess,
})
return &adminv1.AdminProjectPayMarkRes{}, nil
}
// AdminProjectPayUnmark 撤销到账(误操作回退)。
func AdminProjectPayUnmark(ctx context.Context, id int64) (*adminv1.AdminProjectPayUnmarkRes, error) {
if _, err := g.Model(consts.TableProjectPays).Where("id", id).Data(g.Map{
"status": consts.PayStatusPending,
"paid_at": nil,
"paid_note": "",
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
return &adminv1.AdminProjectPayUnmarkRes{}, nil
}
// AdminProjectMaintainRenew 维护费续收一年:已缴至顺延一年(从 max(已缴至, 今天) 起算)并通知用户。
func AdminProjectMaintainRenew(ctx context.Context, id int64) (*adminv1.AdminProjectMaintainRenewRes, error) {
p, err := g.Model(consts.TableServiceProjects).Where("id", id).One()
if err != nil {
return nil, err
}
if p.IsEmpty() {
return nil, gerror.New("项目不存在")
}
if p["accepted"].Int() != 1 {
return nil, gerror.New("项目尚未验收,无维护期可续")
}
plan, err := g.Model(consts.TableMaintainPlans).Where("id", p["maintain_plan_id"].Int64()).One()
if err != nil {
return nil, err
}
if plan.IsEmpty() || plan["source_only"].Int() == 1 || plan["yearly_fee_cents"].Int64() <= 0 {
return nil, gerror.New("该项目的维护模板不涉及年度维护费")
}
base := p["maintain_paid_until"].GTime()
now := gtime.Now()
if base == nil || base.Before(now) {
base = now
}
paidUntil := base.AddDate(1, 0, 0)
if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{
"maintain_paid_until": paidUntil,
"last_maintain_remind_at": nil,
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
pushProjectUpdate(ctx, p["user_id"].Int64(), id,
"维护费已续",
fmt.Sprintf("项目「%s」的维护服务已续费一年(¥%s/年),有效期至 %s。",
p["title"].String(), centsYuanText(plan["yearly_fee_cents"].Int64()), paidUntil.Format("Y-m-d")))
WriteAudit(ctx, AuditEntry{
Action: "service.project.maintain.renew",
TargetType: "service_project",
TargetId: auditId(id),
After: g.Map{"paid_until": paidUntil.String()},
Result: consts.AuditResultSuccess,
})
return &adminv1.AdminProjectMaintainRenewRes{PaidUntil: paidUntil.Format("Y-m-d")}, nil
}
// AdminProjectMaintainAdjust 调整维护期起算日(重算已缴至:起点 + 免费年数)。
func AdminProjectMaintainAdjust(ctx context.Context, req *adminv1.AdminProjectMaintainAdjustReq) (*adminv1.AdminProjectMaintainAdjustRes, error) {
p, err := g.Model(consts.TableServiceProjects).Where("id", req.Id).One()
if err != nil {
return nil, err
}
if p.IsEmpty() {
return nil, gerror.New("项目不存在")
}
startAt, err := gtime.StrToTime(req.StartAt + " 00:00:00")
if err != nil {
return nil, gerror.New("起算日期格式不合法")
}
freeYears := 0
sourceOnly := 0
if p["maintain_plan_id"].Int64() > 0 {
plan, err := g.Model(consts.TableMaintainPlans).Where("id", p["maintain_plan_id"].Int64()).One()
if err == nil && !plan.IsEmpty() {
freeYears = plan["free_years"].Int()
sourceOnly = plan["source_only"].Int()
}
}
var paidUntil *gtime.Time
if sourceOnly != 1 && p["accepted"].Int() == 1 {
paidUntil = startAt.AddDate(freeYears, 0, 0)
}
if _, err = g.Model(consts.TableServiceProjects).Where("id", req.Id).Data(g.Map{
"maintain_start_at": startAt,
"maintain_paid_until": paidUntil,
"last_maintain_remind_at": nil,
"updated_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
return nil, err
}
WriteAudit(ctx, AuditEntry{
Action: "service.project.maintain.adjust",
TargetType: "service_project",
TargetId: auditId(req.Id),
After: g.Map{"start_at": startAt.String(), "paid_until": paidUntil},
Result: consts.AuditResultSuccess,
})
res := &adminv1.AdminProjectMaintainAdjustRes{}
if paidUntil != nil {
res.PaidUntil = paidUntil.Format("Y-m-d")
}
return res, nil
}
// ---------- 提醒设置 ----------
// AdminMaintainRemindGet 提醒设置读取(settings)。
func AdminMaintainRemindGet(ctx context.Context) (*adminv1.AdminMaintainRemindGetRes, error) {
days, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.remindDays").One()
notify, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.adminNotifyUserId").One()
res := &adminv1.AdminMaintainRemindGetRes{RemindDays: 15}
if !days.IsEmpty() {
if v := days["v"].Int(); v > 0 {
res.RemindDays = v
}
}
if !notify.IsEmpty() {
res.AdminNotifyUserId = notify["v"].Int64()
}
return res, nil
}
// AdminMaintainRemindSave 提醒设置保存(remindDays / adminNotifyUserId)。
func AdminMaintainRemindSave(ctx context.Context, req *adminv1.AdminMaintainRemindSaveReq) (*adminv1.AdminMaintainRemindSaveRes, error) {
for _, kv := range []struct{ k, v string }{
{"service.maintain.remindDays", strconv.Itoa(req.RemindDays)},
{"service.maintain.adminNotifyUserId", strconv.FormatInt(req.AdminNotifyUserId, 10)},
} {
if err := settingUpsert(ctx, kv.k, kv.v); err != nil {
return nil, err
}
}
return &adminv1.AdminMaintainRemindSaveRes{}, nil
}
// settingUpsert settings 单键写入(存在则更新,缺则补)。
func settingUpsert(ctx context.Context, k, v string) error {
cnt, err := g.Model(consts.TableSettings).Where("k", k).Count()
if err != nil {
return err
}
if cnt > 0 {
_, err = g.Model(consts.TableSettings).Where("k", k).Data(g.Map{
"v": v,
"updated_at": gdb.Raw("NOW()"),
}).Update()
return err
}
_, err = g.Model(consts.TableSettings).Data(g.Map{
"k": k,
"v": v,
"updated_at": gdb.Raw("NOW()"),
}).Insert()
return err
}
// ============================================================================
// 维护费到期提醒:每天扫描(cmd.go 注册 cron)
// - 范围:已验收 + 维护模板有年费(非仅源码)+ 已缴至距今天数 ≤ remindDays(含已逾期)
// - 节奏:剩余 ≤3 天(含逾期)每天发;否则隔 3 天发一次(last_maintain_remind_at 判定)
// - 通知:用户(站内信 + 订阅消息);超管(settings adminNotifyUserId 指定的小程序账号,
// 同样站内信 + 订阅消息;未配置则只打日志——管理端开发项目页的待缴费横幅常驻可见)
// ============================================================================
// ScanMaintainReminders 每日维护费提醒扫描。
func ScanMaintainReminders(ctx context.Context) {
remindDays, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.remindDays").One()
window := 15
if !remindDays.IsEmpty() {
if v := remindDays["v"].Int(); v > 0 {
window = v
}
}
adminNotify, _ := g.Model(consts.TableSettings).Where("k", "service.maintain.adminNotifyUserId").One()
adminUserId := int64(0)
if !adminNotify.IsEmpty() {
adminUserId = adminNotify["v"].Int64()
}
records, err := g.Model(consts.TableServiceProjects+" p").
LeftJoin(consts.TableMaintainPlans+" mp", "mp.id=p.maintain_plan_id").
Where("p.accepted", 1).
Where("p.status", consts.ProjectStatusDone).
Where("mp.source_only", 0).
Where("mp.yearly_fee_cents > 0").
Where("p.maintain_paid_until IS NOT NULL").
Fields("p.*, mp.name as plan_name, mp.yearly_fee_cents").
All()
if err != nil {
g.Log().Warningf(ctx, "[maintain-remind] 扫描失败: %v", err)
return
}
now := gtime.Now()
for _, r := range records {
paidUntil := r["maintain_paid_until"].GTime()
if paidUntil == nil {
continue
}
daysLeft := maintainDaysLeft(now, paidUntil)
if daysLeft > window {
continue
}
last := r["last_maintain_remind_at"].GTime()
if daysLeft > 3 && last != nil && now.Sub(last).Hours() < 72 {
continue // 未到最后 3 天时,隔 3 天发一次
}
title := "维护费缴纳提醒"
content := fmt.Sprintf("项目「%s」的维护服务将于 %s 到期(剩余 %d 天),年度维护费 ¥%s,请及时缴纳。",
r["title"].String(), paidUntil.Format("Y-m-d"), daysLeft, centsYuanText(r["yearly_fee_cents"].Int64()))
if daysLeft < 0 {
title = "维护费已到期"
content = fmt.Sprintf("项目「%s」的维护服务已于 %s 到期,为不影响售后服务,请尽快缴纳年度维护费 ¥%s。",
r["title"].String(), paidUntil.Format("Y-m-d"), centsYuanText(r["yearly_fee_cents"].Int64()))
}
link := fmt.Sprintf("/pkg-service/project-detail?id=%d", r["id"].Int64())
userId := r["user_id"].Int64()
PushMessage(ctx, userId, consts.MsgTypeProjectUpdate, title, content, link)
// 订阅消息(用户授权过才真正送达;模板未配置自动降级为站内信)
if openid := userOpenid(ctx, userId); openid != "" {
_ = SendProjectRemind(ctx, ProjectRemindSendReq{
Openid: openid, Title: title, Content: content, Page: link,
})
}
// 超管通知(settings 指定的小程序账号)
if adminUserId > 0 && adminUserId != userId {
PushMessage(ctx, adminUserId, consts.MsgTypeProjectUpdate, "【待缴费】"+title, content, link)
if openid := userOpenid(ctx, adminUserId); openid != "" {
_ = SendProjectRemind(ctx, ProjectRemindSendReq{
Openid: openid, Title: title, Content: content, Page: link,
})
}
}
if _, err = g.Model(consts.TableServiceProjects).Where("id", r["id"].Int64()).Data(g.Map{
"last_maintain_remind_at": gdb.Raw("NOW()"),
}).Update(); err != nil {
g.Log().Warningf(ctx, "[maintain-remind] 记录提醒时间失败 projectId=%d: %v", r["id"].Int64(), err)
}
g.Log().Infof(ctx, "[maintain-remind] 已提醒 projectId=%d daysLeft=%d", r["id"].Int64(), daysLeft)
}
}
// maintainDaysLeft 距维护费到期天数(向上取整;已逾期为负数)。
func maintainDaysLeft(now, paidUntil *gtime.Time) int {
hours := paidUntil.Sub(now).Hours()
if hours <= 0 {
return -int(-hours/24) - 1
}
d := int(hours / 24)
if hours-float64(d*24) > 0 {
d++
}
return d
}
// userOpenid 查用户 openid(站内信收件人对应的订阅消息送达对象)。
func userOpenid(ctx context.Context, userId int64) string {
if userId <= 0 {
return ""
}
u, err := g.Model(consts.TableUsers).Where("id", userId).One()
if err != nil || u.IsEmpty() {
return ""
}
return u["openid"].String()
}

View File

@@ -89,6 +89,11 @@ func settingSave(ctx context.Context, key, value, remark string) error {
return nil
}
// SettingSave 导出的配置写入入口(后台系统配置等模块使用;写入后缓存立即失效)
func SettingSave(ctx context.Context, key, value, remark string) error {
return settingSave(ctx, key, value, remark)
}
// GlobalQuotaPeriodDays 全局默认额度周期(天)
func GlobalQuotaPeriodDays(ctx context.Context) int {
raw := SettingValue(ctx, consts.SettingQuotaPeriodDays)

View File

@@ -3,6 +3,7 @@ package logic
import (
"context"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"golang.org/x/crypto/bcrypt"
@@ -39,6 +40,115 @@ func Seed(ctx context.Context) {
if err := seedRows(ctx, consts.TableSettings, "k", seedSettings()); err != nil {
g.Log().Warning(ctx, "[seed] settings:", err)
}
// 软件开发服务介绍页默认模块:表为空时补一套默认排版(后台可自由增删改)
if err := seedServiceBlocks(ctx); err != nil {
g.Log().Warning(ctx, "[seed] service_blocks:", err)
}
// 开发项目四期:维护模板 / 付款分期模板(后台「模板设置」可改,只做缺则补)
if err := seedRows(ctx, consts.TableMaintainPlans, "name", seedMaintainPlans()); err != nil {
g.Log().Warning(ctx, "[seed] maintain_plans:", err)
}
if err := seedRows(ctx, consts.TablePayPlans, "name", seedPayPlans()); err != nil {
g.Log().Warning(ctx, "[seed] pay_plans:", err)
}
if err := seedRows(ctx, consts.TableSettings, "k", seedProjectFlowSettings()); err != nil {
g.Log().Warning(ctx, "[seed] project_flow_settings:", err)
}
}
// seedMaintainPlans 维护模板种子。
func seedMaintainPlans() []g.Map {
return []g.Map{
{"name": "一年免费维护", "free_years": 1, "yearly_fee_cents": 50000,
"source_only": 0, "sort": 1, "is_enabled": 1,
"remark": "交付后 1 年内免费维护,后续 ¥500/年"},
{"name": "按年收费维护", "free_years": 0, "yearly_fee_cents": 50000,
"source_only": 0, "sort": 2, "is_enabled": 1,
"remark": "无免费期,维护服务 ¥500/年(验收后即起算)"},
{"name": "仅交付源码", "free_years": 0, "yearly_fee_cents": 0,
"source_only": 1, "sort": 3, "is_enabled": 1,
"remark": "只交付源码,无售后维护"},
}
}
// seedPayPlans 付款分期模板种子(schedule JSON 由后端契约约定 trigger 节点)。
func seedPayPlans() []g.Map {
return []g.Map{
{"name": "两期 · 上线前结清", "sort": 1, "is_enabled": 1,
"schedule": `[{"seq":1,"percent":50,"trigger":"start"},{"seq":2,"percent":50,"trigger":"online_before"}]`,
"remark": "定金 50% 开工前,尾款 50% 上线前"},
{"name": "两期 · 尾款上线后", "sort": 2, "is_enabled": 1,
"schedule": `[{"seq":1,"percent":50,"trigger":"start"},{"seq":2,"percent":50,"trigger":"online_after"}]`,
"remark": "定金 50% 开工前,尾款 50% 上线验收后"},
{"name": "三期 · 中期演示续作", "sort": 3, "is_enabled": 1,
"schedule": `[{"seq":1,"percent":30,"trigger":"start"},{"seq":2,"percent":40,"trigger":"mid_demo"},{"seq":3,"percent":30,"trigger":"online_before"}]`,
"remark": "定金 30%,中期演示确认后续作 40%,上线前 30%"},
}
}
// seedProjectFlowSettings 开发项目流程设置键(后台「开发项目 → 模板设置」可改)。
func seedProjectFlowSettings() []g.Map {
return []g.Map{
{"k": "service.maintain.remindDays", "v": "15", "remark": "维护费到期前提醒天数(隔 3 天一发,最后 3 天每天发)"},
{"k": "service.maintain.adminNotifyUserId", "v": "", "remark": "维护费提醒同时通知的小程序用户 id(超管自己绑定的账号,空=只进管理端横幅)"},
}
}
// seedServiceBlocks 服务介绍页默认模块(仅当表为空时写入一次,之后完全以后台编辑为准)。
func seedServiceBlocks(ctx context.Context) error {
count, err := g.Model(consts.TableServiceBlocks).Count()
if err != nil {
return err
}
if count > 0 {
return nil
}
rows := []g.Map{
{
"kind": consts.ServiceBlockHero, "title": "从想法到上线\n让代码**落地**为产品",
"subtitle": "定制开发 · 交付源码 · 免费咨询", "content": "",
"sort": 1, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockFlow, "title": "服务流程",
"content": "需求沟通|澄清目标与边界\n方案报价|评估周期与成本\n开发交付|敏捷迭代源码可交接\n售后保障|上线后持续维护",
"sort": 2, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockChecklist, "title": "标准交付清单",
"content": "需求文档\n原型与 UI 设计稿\n前后端源码\n接口与部署文档\n测试与验收记录",
"sort": 3, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockPrice, "title": "服务与价格",
"content": "官网展示型|官网搭建、页面设计、内容发布|3万起\n业务系统型|业务系统、流程管理、数据协同|8万起\n平台产品型|平台产品、权限体系、会员运营|面议",
"sort": 4, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockWorks, "title": "作品案例",
"subtitle": "真实交付案例,点开可查看详情与参考报价",
"sort": 5, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockDeveloper, "title": "8 年全栈开发经验 / 独立交付 60+ 项目",
"subtitle": "技术栈:Vue / React / 小程序 / Java / Python / 云部署",
"content": "",
"sort": 6, "is_enabled": 1,
},
{
"kind": consts.ServiceBlockGoods, "title": "查看全部服务",
"subtitle": "更多服务项与参考报价",
"sort": 7, "is_enabled": 1,
},
}
for _, row := range rows {
row["created_at"] = gdb.Raw("NOW()")
row["updated_at"] = gdb.Raw("NOW()")
if _, err := g.Model(consts.TableServiceBlocks).Data(row).Insert(); err != nil {
return err
}
}
return nil
}
// memberPlanSyncFields 每次启动以代码为准覆盖的列。
@@ -187,6 +297,9 @@ func seedSettings() []g.Map {
{"k": consts.SettingFreebiePriceCents, "v": "1", "remark": "免单道具价格(分)"},
{"k": consts.SettingPromoFailLimitPerMin, "v": "6", "remark": "同一用户 1 分钟内优惠码错误次数上限"},
{"k": consts.SettingCouponLockTtlSeconds, "v": "900", "remark": "免单券下单占用自动释放时长(秒)"},
// ===== 站点(迭代-2026-09-29)=====
// 图片域名不落库:库内只存相对路径,拼接前缀在此配置;留空 = 按请求 Host 现拼(本地联调零配置)。
{"k": consts.SettingSiteBaseUrl, "v": "", "remark": "图片等静态资源的对外拼接前缀(如 https://api.example.com),留空=按请求域名动态拼接"},
}
}

543
internal/logic/service.go Normal file
View File

@@ -0,0 +1,543 @@
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
}

View File

@@ -0,0 +1,82 @@
package logic
import (
"context"
"strings"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
adminv1 "tool-api/api/admin/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 管理端系统配置(迭代-2026-09-29)
//
// 走 settings 表(约定 S9:配置一律走 settings 表,不写 config.yaml)。
// 只开放白名单键:管理端可读可写的键集中在这里登记,避免把 settings 全表暴露。
// ============================================================================
// adminSettingWhitelist 管理端可读写的配置键白名单
var adminSettingWhitelist = []struct {
Key string
Remark string
// Normalize 保存前的归一化(可为 nil)
Normalize func(string) string
}{
{
Key: consts.SettingSiteBaseUrl,
Remark: "图片等静态资源的对外拼接前缀(如 https://api.example.com),留空 = 按请求域名动态拼接",
Normalize: func(v string) string {
return strings.TrimRight(strings.TrimSpace(v), "/")
},
},
{
Key: consts.SettingHomeFeatureCovers,
Remark: "首页便捷功能卡背景图,JSON:{\"note\":\"/static/...\",\"event\":\"...\"}(key 对齐小程序 features.ts)",
Normalize: func(v string) string {
return strings.TrimSpace(v)
},
},
}
// AdminSettingGet 读取白名单配置项(含当前值与说明)
func AdminSettingGet(ctx context.Context) (*adminv1.AdminSettingGetRes, error) {
res := &adminv1.AdminSettingGetRes{Items: make([]adminv1.AdminSettingItem, 0, len(adminSettingWhitelist))}
for _, def := range adminSettingWhitelist {
res.Items = append(res.Items, adminv1.AdminSettingItem{
Key: def.Key,
Value: SettingValue(ctx, def.Key),
Remark: def.Remark,
})
}
return res, nil
}
// AdminSettingSave 保存一项配置(key 必须在白名单内;保存立即生效,SettingValue 缓存同步失效)
func AdminSettingSave(ctx context.Context, key, value string) (*adminv1.AdminSettingSaveRes, error) {
for _, def := range adminSettingWhitelist {
if def.Key != key {
continue
}
v := value
if def.Normalize != nil {
v = def.Normalize(v)
}
before := SettingValue(ctx, key)
if err := SettingSave(ctx, key, v, def.Remark); err != nil {
return nil, err
}
WriteAudit(ctx, AuditEntry{
Action: "setting.save",
TargetType: "setting",
TargetId: key,
Before: g.Map{"v": before},
After: g.Map{"v": v},
Result: consts.AuditResultSuccess,
})
return &adminv1.AdminSettingSaveRes{}, nil
}
return nil, gerror.New("不支持的配置项")
}

View File

@@ -7,22 +7,28 @@ import (
"io"
"path/filepath"
"strings"
"unicode/utf8"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gfile"
"github.com/gogf/gf/v2/os/gtime"
adminv1 "tool-api/api/admin/v1"
v1 "tool-api/api/user/v1"
"tool-api/internal/consts"
)
// ============================================================================
// 头像上传(N3 / §2.2)
// 头像上传(N3 / §2.2)+ 通用上传(封面/附件,迭代-2026-09-29)
//
// 本机静态目录方案(U1 已裁定接受):落盘 <StaticDir>/avatar/<yyyyMM>/<md5>.<ext>,
// 返回「浏览器可直接打开」的绝对 URL。
// 本机静态目录方案(U1 已裁定接受):落盘 <StaticDir>/<scene>/<yyyyMM>/<md5>.<ext>。
//
// 📌 图片域名不落库(迭代-2026-09-29 裁定):数据库只存相对路径(/static/...),
// 对外输出一律经 absoluteURL 用 settings 的 site.base_url 拼接前缀;未配置时
// 回退为按请求 Host 现拼(本地联调零配置)。
//
// 已知限制:单实例部署正常;多实例(水平扩容)下非落盘实例会 404 —— 上线前须换对象存储。
//
@@ -39,6 +45,9 @@ const staticURLPrefix = "/static"
// AvatarMaxBytes 头像体积上限(5MB)。
const AvatarMaxBytes = 5 * 1024 * 1024
// AttachmentMaxBytes 通用附件体积上限(20MB)。
const AttachmentMaxBytes = 20 * 1024 * 1024
// avatarAllowedExt 允许的图片扩展名(小写,含点)。
var avatarAllowedExt = map[string]bool{
".jpg": true,
@@ -47,6 +56,14 @@ var avatarAllowedExt = map[string]bool{
".webp": true,
}
// attachmentAllowedExt 通用附件允许的扩展名(小写,含点)。
var attachmentAllowedExt = map[string]bool{
".jpg": true, ".jpeg": true, ".png": true, ".webp": true, ".gif": true,
".pdf": true, ".zip": true,
".doc": true, ".docx": true, ".xls": true, ".xlsx": true, ".ppt": true, ".pptx": true,
".txt": true, ".md": true,
}
// errUploadInvalid 4016:上传校验失败(体积 / 格式)。
func errUploadInvalid(msg string) error {
return gerror.NewCode(gcode.New(consts.CodeUploadInvalid, "", nil), msg)
@@ -85,24 +102,17 @@ func AvatarUploadFromRequest(ctx context.Context) (*v1.AvatarUploadRes, error) {
// AvatarUpload 校验并落盘头像,返回「浏览器可直接打开」的绝对 URL。
//
// 落盘名用内容 md5 → 同一张图重复上传天然去重,且文件名不含用户输入(无路径穿越风险)。
// 同时登记附件表(scene=avatar,上传者=当前用户),供后台附件管理统一查看。
func AvatarUpload(ctx context.Context, fileName string, data []byte) (*v1.AvatarUploadRes, error) {
if err := validateAvatarUpload(fileName, data); err != nil {
return nil, err
}
sum := md5.Sum(data)
name := hex.EncodeToString(sum[:]) + strings.ToLower(filepath.Ext(fileName))
ym := gtime.Now().Format("Ym")
relDir := filepath.Join(StaticDir, "avatar", ym)
if err := gfile.Mkdir(relDir); err != nil {
g.Log().Errorf(ctx, "[upload] 创建头像目录失败 dir=%s err=%v", relDir, err)
return nil, errUploadFailed("头像上传失败,请重试")
relPath, err := storeStatic(ctx, "avatar", fileName, data)
if err != nil {
return nil, err
}
if err := gfile.PutBytes(filepath.Join(relDir, name), data); err != nil {
g.Log().Errorf(ctx, "[upload] 写入头像文件失败 dir=%s name=%s err=%v", relDir, name, err)
return nil, errUploadFailed("头像上传失败,请重试")
}
urlPath := staticURLPrefix + "/avatar/" + ym + "/" + name
return &v1.AvatarUploadRes{AvatarUrl: absoluteStaticURL(ctx, urlPath)}, nil
recordAttachment(ctx, consts.AttachmentSceneAvatar, fileName, relPath, data, "user", CtxUserId(ctx))
return &v1.AvatarUploadRes{AvatarUrl: absoluteURL(ctx, relPath)}, nil
}
// validateAvatarUpload 头像上传校验(纯函数,可单测):非空 + 体积 ≤5MB + 扩展名合法 + 内容确为图片。
@@ -146,11 +156,24 @@ func isImageBytes(b []byte) bool {
}
// absoluteStaticURL 拼成「浏览器可直接打开」的绝对 URL(<scheme>://<host><path>)。
// 无请求上下文(如单测)时回退为相对路径。
// 无请求上下文(如单测)时回退为相对路径。保留旧名兼容既有调用点,实现统一走 absoluteURL。
func absoluteStaticURL(ctx context.Context, urlPath string) string {
return absoluteURL(ctx, urlPath)
}
// ============================================================================
// 图片域名拼接(迭代-2026-09-29):库内只存相对路径,域名统一在这里出现
// ============================================================================
// baseURL 静态资源对外拼接前缀:settings 的 site.base_url 优先(后台「系统配置」可改);
// 留空时按请求 Host 现拼(本地联调零配置)。
func baseURL(ctx context.Context) string {
if v := strings.TrimSpace(SettingValue(ctx, consts.SettingSiteBaseUrl)); v != "" {
return strings.TrimRight(v, "/")
}
req := g.RequestFromCtx(ctx)
if req == nil || req.Host == "" {
return urlPath
return ""
}
scheme := "http"
if req.TLS != nil {
@@ -159,5 +182,231 @@ func absoluteStaticURL(ctx context.Context, urlPath string) string {
if proto := strings.TrimSpace(req.Header.Get("X-Forwarded-Proto")); proto != "" {
scheme = proto
}
return scheme + "://" + req.Host + urlPath
return scheme + "://" + req.Host
}
// absoluteURL 相对路径 → 对外绝对 URL;空值或已是绝对 URL 时原样返回。
// base 取不到(无请求上下文且未配置 site.base_url)时返回相对路径本身(调用端自行拼接)。
func absoluteURL(ctx context.Context, path string) string {
p := strings.TrimSpace(path)
if p == "" || strings.HasPrefix(p, "http://") || strings.HasPrefix(p, "https://") {
return p
}
base := baseURL(ctx)
if base == "" {
return p
}
if !strings.HasPrefix(p, "/") {
p = "/" + p
}
return base + p
}
// normalizeToRelativePath 把前端回传的 URL 归一化为相对路径后入库(剥掉 scheme://host)。
// 兼容存量:头像等历史数据存过绝对 URL,写入侧统一收敛;读出侧统一 absoluteURL 拼接。
// 兜底:若串中含 /static/ 段(如被调用侧拼过部署根路径 / 带盘符的路径),截取最后一个
// /static/ 起始段,保证库内永远是 staticURLPrefix 开头的形态。
func normalizeToRelativePath(url string) string {
u := strings.TrimSpace(url)
if u == "" {
return ""
}
if i := strings.Index(u, "://"); i > 0 {
rest := u[i+3:]
if j := strings.Index(rest, "/"); j >= 0 {
u = rest[j:]
} else {
return ""
}
}
if i := strings.LastIndex(u, staticURLPrefix+"/"); i > 0 {
return u[i:]
}
return u
}
// staticPathToDisk 相对 URL 路径(/static/...)→ 磁盘路径(StaticDir/...),供删除文件用。
// ok=false 表示不是 /static 下的合法相对路径(含 .. 一律拒绝,防路径穿越)。
func staticPathToDisk(urlPath string) (disk string, ok bool) {
if !strings.HasPrefix(urlPath, staticURLPrefix+"/") {
return "", false
}
rel := strings.TrimPrefix(urlPath, staticURLPrefix+"/")
if rel == "" || strings.Contains(rel, "..") {
return "", false
}
return filepath.Join(StaticDir, filepath.FromSlash(rel)), true
}
// ============================================================================
// 通用上传(管理端:服务封面 / 通用附件)
// ============================================================================
// AdminUploadFromRequest 处理管理端 multipart 上传:取 file 字段 → 按场景校验 → 落盘 → 登记 → 返回。
//
// multipart 表单文件字段名固定为 `file`(与头像上传同一跨端契约);scene 为表单字段。
func AdminUploadFromRequest(ctx context.Context, scene string) (*adminv1.AdminUploadRes, error) {
req := g.RequestFromCtx(ctx)
if req == nil {
return nil, errUploadFailed("上传失败,请重试")
}
file := req.GetUploadFile("file")
if file == nil {
return nil, errUploadInvalid("请选择要上传的文件")
}
f, err := file.Open()
if err != nil {
return nil, errUploadFailed("上传失败,请重试")
}
defer func() { _ = f.Close() }()
// 多读 1 字节用于判定「超过上限」,避免把超大文件全读进内存。
data, err := io.ReadAll(io.LimitReader(f, AttachmentMaxBytes+1))
if err != nil {
return nil, errUploadFailed("上传失败,请重试")
}
return AdminUpload(ctx, scene, file.Filename, data)
}
// AdminUpload 按场景校验并落盘,登记附件表,返回相对路径 + 现拼绝对 URL。
//
// 封面(cover)仅图片 ≤5MB;通用附件(attachment)白名单格式 ≤20MB。
// 场景决定落盘子目录与附件表 scene 字段(avatar 场景走用户端头像接口,不经此入口)。
func AdminUpload(ctx context.Context, scene, fileName string, data []byte) (*adminv1.AdminUploadRes, error) {
dirName := ""
switch scene {
case consts.AttachmentSceneCover:
if err := validateImageUpload(fileName, data, "封面"); err != nil {
return nil, err
}
dirName = "cover"
case consts.AttachmentSceneAttachment:
if err := validateAttachmentUpload(fileName, data); err != nil {
return nil, err
}
dirName = "attachment"
default:
return nil, errUploadInvalid("不支持的上传场景")
}
relPath, err := storeStatic(ctx, dirName, fileName, data)
if err != nil {
return nil, err
}
id := recordAttachment(ctx, scene, fileName, relPath, data, "admin", CtxAdminId(ctx))
return &adminv1.AdminUploadRes{
Id: id,
Url: absoluteURL(ctx, relPath),
Path: relPath,
FileName: fileName,
Ext: strings.ToLower(filepath.Ext(fileName)),
SizeBytes: int64(len(data)),
}, nil
}
// storeStatic 通用落盘:<StaticDir>/<dirName>/<yyyyMM>/<md5>.<ext>,返回相对 URL 路径。
func storeStatic(ctx context.Context, dirName, fileName string, data []byte) (string, error) {
if len(data) == 0 {
return "", errUploadInvalid("上传内容为空")
}
sum := md5.Sum(data)
name := hex.EncodeToString(sum[:]) + strings.ToLower(filepath.Ext(fileName))
ym := gtime.Now().Format("Ym")
relDir := filepath.Join(StaticDir, dirName, ym)
if err := gfile.Mkdir(relDir); err != nil {
g.Log().Errorf(ctx, "[upload] 创建目录失败 dir=%s err=%v", relDir, err)
return "", errUploadFailed("上传失败,请重试")
}
if err := gfile.PutBytes(filepath.Join(relDir, name), data); err != nil {
g.Log().Errorf(ctx, "[upload] 写入文件失败 dir=%s name=%s err=%v", relDir, name, err)
return "", errUploadFailed("上传失败,请重试")
}
return staticURLPrefix + "/" + dirName + "/" + ym + "/" + name, nil
}
// recordAttachment 登记附件表(同 file_path 已有未删记录则跳过 —— md5 落盘天然去重)。
// 失败仅告警不阻断上传主流程;返回记录 id(跳过/失败返回 0)。
// TZ-01:datetime 写库一律 gdb.Raw("NOW()")。
func recordAttachment(ctx context.Context, scene, fileName, relPath string, data []byte, uploaderType string, uploaderId int64) int64 {
sum := md5.Sum(data)
count, err := g.Model(consts.TableAttachments).Where("file_path", relPath).WhereNull("deleted_at").Count()
if err != nil {
g.Log().Warningf(ctx, "[upload] 附件登记查重失败 path=%s err=%v", relPath, err)
return 0
}
if count > 0 {
return 0
}
res, err := g.Model(consts.TableAttachments).Data(g.Map{
"scene": scene,
// 客户端文件名可能带非法字节(老客户端 GBK 等):消毒为合法 UTF-8,
// 避免「文件已落盘但登记失败」把附件管理变成盲区
"file_name": strings.ToValidUTF8(fileName, string(utf8.RuneError)),
"file_path": relPath,
"ext": strings.ToLower(filepath.Ext(fileName)),
"size_bytes": len(data),
"md5": hex.EncodeToString(sum[:]),
"uploader_id": uploaderId,
"uploader_type": uploaderType,
"created_at": gdb.Raw("NOW()"),
}).Insert()
if err != nil {
g.Log().Warningf(ctx, "[upload] 附件登记失败 path=%s err=%v", relPath, err)
return 0
}
id, _ := res.LastInsertId()
return id
}
// validateImageUpload 图片上传校验(封面等):非空 + 体积 ≤5MB + 扩展名合法 + 内容确为图片。
func validateImageUpload(fileName string, data []byte, label string) error {
if len(data) == 0 {
return errUploadInvalid("上传内容为空")
}
if len(data) > AvatarMaxBytes {
return errUploadInvalid(label + "不能超过 5MB")
}
ext := strings.ToLower(filepath.Ext(fileName))
if !avatarAllowedExt[ext] {
return errUploadInvalid("仅支持 jpg / png / webp 格式的图片")
}
if !isImageBytes(data) {
return errUploadInvalid("文件不是有效的图片")
}
return nil
}
// validateAttachmentUpload 通用附件校验:非空 + 体积 ≤20MB + 扩展名白名单 + 抽查魔数。
func validateAttachmentUpload(fileName string, data []byte) error {
if len(data) == 0 {
return errUploadInvalid("上传内容为空")
}
if len(data) > AttachmentMaxBytes {
return errUploadInvalid("附件不能超过 20MB")
}
ext := strings.ToLower(filepath.Ext(fileName))
if !attachmentAllowedExt[ext] {
return errUploadInvalid("不支持的附件格式")
}
if !attachmentMagicOk(ext, data) {
return errUploadInvalid("文件内容与扩展名不符")
}
return nil
}
// attachmentMagicOk 按扩展名抽查内容魔数(可校验的才校验,txt/md 等纯文本跳过)。
func attachmentMagicOk(ext string, b []byte) bool {
switch ext {
case ".jpg", ".jpeg", ".png", ".webp":
return isImageBytes(b)
case ".gif":
return len(b) >= 6 && (string(b[0:3]) == "GIF")
case ".pdf":
return len(b) >= 5 && string(b[0:4]) == "%PDF"
case ".zip", ".docx", ".xlsx", ".pptx":
// Office 新格式(docx/xlsx/pptx)本质是 zip 容器,同用 PK 头
return len(b) >= 4 && b[0] == 0x50 && b[1] == 0x4B
case ".doc", ".xls", ".ppt":
// OLE2 复合文档头
return len(b) >= 8 && b[0] == 0xD0 && b[1] == 0xCF && b[2] == 0x11 && b[3] == 0xE0
}
return true
}

View File

@@ -49,7 +49,7 @@ func GetUserInfo(ctx context.Context) (*v1.UserInfo, error) {
if err != nil {
return nil, err
}
return &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: user.AvatarUrl}, nil
return &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: absoluteURL(ctx, user.AvatarUrl)}, nil
}
// wxSession jscode2session 返回:openid + session_key
@@ -299,11 +299,12 @@ func ProfileUpdate(ctx context.Context, nickname, avatarUrl string) (*v1.Profile
userId := CtxUserId(ctx)
if _, err := g.Model(consts.TableUsers).Where("id", userId).Data(g.Map{
"nickname": nickname,
"avatar_url": avatarUrl,
"avatar_url": normalizeToRelativePath(avatarUrl), // 库内只存相对路径(图片域名不落库)
}).Update(); err != nil {
return nil, err
}
return &v1.ProfileUpdateRes{Nickname: nickname, AvatarUrl: avatarUrl}, nil
// 返回值按现配 base 拼成绝对 URL,前端直接可用(与库内相对路径解耦)
return &v1.ProfileUpdateRes{Nickname: nickname, AvatarUrl: absoluteURL(ctx, normalizeToRelativePath(avatarUrl))}, nil
}
func workbenchKeysOf(ctx context.Context, userId int64) []string {
@@ -344,7 +345,7 @@ func loginResult(ctx context.Context, user *entity.Users) (*v1.WxLoginRes, error
}
return &v1.WxLoginRes{
Token: token,
User: &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: user.AvatarUrl},
User: &v1.UserInfo{Id: user.Id, Nickname: user.Nickname, AvatarUrl: absoluteURL(ctx, user.AvatarUrl)},
Level: level,
}, nil
}

View File

@@ -71,7 +71,7 @@ func buildUserDetailBase(ctx context.Context, user *entity.Users) adminv1.UserDe
base := adminv1.UserDetailBase{
Id: user.Id,
Nickname: user.Nickname,
AvatarUrl: user.AvatarUrl,
AvatarUrl: absoluteURL(ctx, user.AvatarUrl),
Openid: user.Openid,
LevelKey: user.LevelKey,
Status: user.Status,

View File

@@ -99,3 +99,60 @@ func clampRunes(s string, max int) string {
}
return string(rs[:max])
}
// ProjectRemindTemplateId 开发项目提醒模板 ID;未配置返回空串(降级为站内信)。
func ProjectRemindTemplateId(ctx context.Context) string {
return g.Cfg().MustGet(ctx, "wx.subscribe.projectRemindTemplateId", "").String()
}
// ProjectRemindSendReq 一条项目提醒的推送参数
type ProjectRemindSendReq struct {
Openid string
Title string
Content string
Page string // 点击进入的小程序页面(如 pkg-service/project-detail?id=1)
}
// SendProjectRemind 发送开发项目订阅提醒(维护费到期等)。
// 与笔记提醒同一条底层通路;模板 ID 为空是合法降级路径(仅告警、正常返回)。
// ⚠️ 字段键需与 MP 后台创建的模板一致:thing1 标题 / thing3 正文摘要。
func SendProjectRemind(ctx context.Context, req ProjectRemindSendReq) error {
tplId := ProjectRemindTemplateId(ctx)
if tplId == "" {
g.Log().Warningf(ctx, "[project-remind] 未配置订阅消息模板 wx.subscribe.projectRemindTemplateId,降级为站内提醒")
return nil
}
if req.Openid == "" {
return gerror.New("用户 openid 为空,无法发送订阅消息")
}
token, err := wxAccessToken(ctx)
if err != nil {
return err
}
data := g.Map{
"thing1": g.Map{"value": clampRunes(req.Title, 20)},
"thing3": g.Map{"value": clampRunes(req.Content, 20)},
}
body := g.Map{
"touser": req.Openid,
"template_id": tplId,
"page": req.Page,
"data": data,
}
resp, err := g.Client().Post(ctx, xpayAPIBase+noteSubscribeSendPath+"?access_token="+token, body)
if err != nil {
return gerror.Newf("调用 subscribe/send 失败: %v", err)
}
defer resp.Close()
var out struct {
ErrCode int `json:"errcode"`
ErrMsg string `json:"errmsg"`
}
if err = json.Unmarshal(resp.ReadAll(), &out); err != nil {
return gerror.Newf("解析 subscribe/send 响应失败: %v", err)
}
if out.ErrCode != 0 {
return gerror.Newf("订阅消息发送失败: %d %s", out.ErrCode, out.ErrMsg)
}
return nil
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -0,0 +1 @@
需求文档草稿 v1

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

47
sql/software_service.sql Normal file
View File

@@ -0,0 +1,47 @@
-- 软件开发服务域 + 附件管理 + 站点配置(迭代-2026-09-29)
--
-- 本文件仅作人工参考:启动时 logic.Migrate() 会幂等执行同样的 DDL(升级只需重启)。
-- 手工执行亦可(CREATE TABLE IF NOT EXISTS 可重复运行)。
-- 服务商品(软件开发服务:网站/小程序/App/桌面四系列)
CREATE TABLE IF NOT EXISTS `goods` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`category` tinyint NOT NULL DEFAULT 1 COMMENT '分类 1网站开发 2小程序开发 3App开发 4桌面程序开发',
`title` varchar(128) NOT NULL DEFAULT '' COMMENT '服务名称',
`summary` varchar(500) NOT NULL DEFAULT '' COMMENT '简介(列表卡片展示)',
`content_md` mediumtext COMMENT '详细说明 Markdown 原文,小程序端解析渲染',
`price_cents` bigint NOT NULL DEFAULT 0 COMMENT '定价(分),0=面议',
`price_note` varchar(64) NOT NULL DEFAULT '' COMMENT '价格备注(如「起」「/项目」)',
`cover_path` varchar(255) NOT NULL DEFAULT '' COMMENT '封面相对路径(/static/...),库内不存域名',
`sort` int NOT NULL DEFAULT 0 COMMENT '排序值,越小越靠前',
`is_enabled` tinyint NOT NULL DEFAULT 1 COMMENT '上架状态 0下架 1上架',
`created_at` datetime DEFAULT NULL,
`updated_at` datetime DEFAULT NULL,
`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除',
PRIMARY KEY (`id`),
KEY `idx_cat_sort` (`is_enabled`,`category`,`sort`,`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='软件开发服务商品';
-- 上传附件登记(头像/封面/通用附件统一落账,供后台附件管理)
CREATE TABLE IF NOT EXISTS `attachments` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`scene` varchar(32) NOT NULL DEFAULT '' COMMENT '上传场景 avatar/cover/attachment',
`file_name` varchar(255) NOT NULL DEFAULT '' COMMENT '原始文件名',
`file_path` varchar(255) NOT NULL DEFAULT '' COMMENT '相对路径(/static/...),库内不存域名',
`ext` varchar(16) NOT NULL DEFAULT '' COMMENT '扩展名(小写含点)',
`size_bytes` bigint NOT NULL DEFAULT 0 COMMENT '文件大小(字节)',
`md5` char(32) NOT NULL DEFAULT '' COMMENT '内容 md5(落盘文件名)',
`uploader_id` bigint NOT NULL DEFAULT 0 COMMENT '上传者 id(管理端或用户)',
`uploader_type` varchar(16) NOT NULL DEFAULT 'admin' COMMENT '上传者类型 admin/user',
`created_at` datetime DEFAULT NULL COMMENT '上传日期',
`deleted_at` datetime DEFAULT NULL COMMENT '软删除时间,NULL=未删除(记录删除,文件本体按引用检查)',
PRIMARY KEY (`id`),
KEY `idx_created` (`created_at`),
KEY `idx_path` (`file_path`),
KEY `idx_scene` (`scene`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='上传附件登记(上传日期/上传者/大小,供后台附件管理)';
-- 站点配置:图片等静态资源的对外拼接前缀(种子缺则补;留空 = 按请求域名动态拼接)
-- INSERT INTO `settings` (`k`, `v`, `remark`, `updated_at`)
-- VALUES ('site.base_url', '', '图片等静态资源的对外拼接前缀(如 https://api.example.com),留空=按请求域名动态拼接', NOW())
-- ON DUPLICATE KEY UPDATE `k` = `k`;