小程序一些功能更新
This commit is contained in:
64
api/admin/v1/attachment.go
Normal file
64
api/admin/v1/attachment.go
Normal 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
89
api/admin/v1/goods.go
Normal 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
341
api/admin/v1/project.go
Normal 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
181
api/admin/v1/service.go
Normal 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
33
api/admin/v1/setting.go
Normal 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
19
api/user/v1/app_config.go
Normal 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
52
api/user/v1/goods.go
Normal 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"`
|
||||
}
|
||||
@@ -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
47
api/user/v1/message.go
Normal 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
115
api/user/v1/project.go
Normal 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
102
api/user/v1/service.go
Normal 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"`
|
||||
}
|
||||
@@ -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 退出、不降级**,而该目录原先只在
|
||||
|
||||
@@ -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 下发
|
||||
)
|
||||
|
||||
22
internal/controller/app_config.go
Normal file
22
internal/controller/app_config.go
Normal 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)
|
||||
}
|
||||
32
internal/controller/attachment.go
Normal file
32
internal/controller/attachment.go
Normal 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)
|
||||
}
|
||||
53
internal/controller/goods.go
Normal file
53
internal/controller/goods.go
Normal 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)
|
||||
}
|
||||
@@ -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)。
|
||||
|
||||
137
internal/controller/project.go
Normal file
137
internal/controller/project.go
Normal 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)
|
||||
}
|
||||
108
internal/controller/service.go
Normal file
108
internal/controller/service.go
Normal 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)
|
||||
}
|
||||
26
internal/controller/setting.go
Normal file
26
internal/controller/setting.go
Normal 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)
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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/"):
|
||||
|
||||
@@ -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", ""},
|
||||
|
||||
40
internal/logic/app_config.go
Normal file
40
internal/logic/app_config.go
Normal 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
|
||||
}
|
||||
165
internal/logic/attachment.go
Normal file
165
internal/logic/attachment.go
Normal 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
240
internal/logic/goods.go
Normal 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(),
|
||||
}
|
||||
}
|
||||
@@ -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
223
internal/logic/message.go
Normal 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
|
||||
}
|
||||
@@ -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
1116
internal/logic/project.go
Normal file
File diff suppressed because it is too large
Load Diff
465
internal/logic/project_flow.go
Normal file
465
internal/logic/project_flow.go
Normal 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()
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
543
internal/logic/service.go
Normal 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
|
||||
}
|
||||
82
internal/logic/setting_admin.go
Normal file
82
internal/logic/setting_admin.go
Normal 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("不支持的配置项")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 |
@@ -0,0 +1 @@
|
||||
需求文档草稿 v1
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 19 KiB |
BIN
manifest/static/cover/202609/sticker_edge2edge.png
Normal file
BIN
manifest/static/cover/202609/sticker_edge2edge.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 149 KiB |
47
sql/software_service.sql
Normal file
47
sql/software_service.sql
Normal 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`;
|
||||
Reference in New Issue
Block a user