From 6f51d99a7870e4ebad27925f5dd6cd08cbc82e34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E7=90=A6?= Date: Wed, 30 Sep 2026 17:01:18 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B0=8F=E7=A8=8B=E5=BA=8F=E4=B8=80=E4=BA=9B?= =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin/v1/attachment.go | 64 + api/admin/v1/goods.go | 89 ++ api/admin/v1/project.go | 341 +++++ api/admin/v1/service.go | 181 +++ api/admin/v1/setting.go | 33 + api/user/v1/app_config.go | 19 + api/user/v1/goods.go | 52 + api/user/v1/member.go | 1 + api/user/v1/message.go | 47 + api/user/v1/project.go | 115 ++ api/user/v1/service.go | 102 ++ internal/cmd/cmd.go | 17 +- internal/consts/consts.go | 175 +++ internal/controller/app_config.go | 22 + internal/controller/attachment.go | 32 + internal/controller/goods.go | 53 + internal/controller/member.go | 2 +- internal/controller/project.go | 137 ++ internal/controller/service.go | 108 ++ internal/controller/setting.go | 26 + internal/logic/admin.go | 12 +- internal/logic/admin_order.go | 2 +- internal/logic/admin_perm.go | 21 +- internal/logic/admin_perm_test.go | 20 +- internal/logic/app_config.go | 40 + internal/logic/attachment.go | 165 +++ internal/logic/goods.go | 240 ++++ internal/logic/member.go | 38 +- internal/logic/message.go | 223 ++++ internal/logic/migrate.go | 213 ++++ internal/logic/project.go | 1116 +++++++++++++++++ internal/logic/project_flow.go | 465 +++++++ internal/logic/quota.go | 5 + internal/logic/seed.go | 113 ++ internal/logic/service.go | 543 ++++++++ internal/logic/setting_admin.go | 82 ++ internal/logic/upload.go | 287 ++++- internal/logic/user.go | 9 +- internal/logic/user_detail.go | 2 +- internal/logic/wxmsg.go | 57 + .../05b25b14bb98047766b9b946b5020162.png | Bin 0 -> 10599 bytes .../f94b901f3e4e0ae383104e6d7ae48aab.txt | 1 + .../05b25b14bb98047766b9b946b5020162.png | Bin 0 -> 10599 bytes .../150ebb05e2c26d639d775065da5663e8.jpg | Bin 0 -> 19875 bytes .../static/cover/202609/sticker_edge2edge.png | Bin 0 -> 152468 bytes sql/software_service.sql | 47 + 46 files changed, 5280 insertions(+), 37 deletions(-) create mode 100644 api/admin/v1/attachment.go create mode 100644 api/admin/v1/goods.go create mode 100644 api/admin/v1/project.go create mode 100644 api/admin/v1/service.go create mode 100644 api/admin/v1/setting.go create mode 100644 api/user/v1/app_config.go create mode 100644 api/user/v1/goods.go create mode 100644 api/user/v1/message.go create mode 100644 api/user/v1/project.go create mode 100644 api/user/v1/service.go create mode 100644 internal/controller/app_config.go create mode 100644 internal/controller/attachment.go create mode 100644 internal/controller/goods.go create mode 100644 internal/controller/project.go create mode 100644 internal/controller/service.go create mode 100644 internal/controller/setting.go create mode 100644 internal/logic/app_config.go create mode 100644 internal/logic/attachment.go create mode 100644 internal/logic/goods.go create mode 100644 internal/logic/message.go create mode 100644 internal/logic/project.go create mode 100644 internal/logic/project_flow.go create mode 100644 internal/logic/service.go create mode 100644 internal/logic/setting_admin.go create mode 100644 manifest/static/attachment/202609/05b25b14bb98047766b9b946b5020162.png create mode 100644 manifest/static/attachment/202609/f94b901f3e4e0ae383104e6d7ae48aab.txt create mode 100644 manifest/static/cover/202609/05b25b14bb98047766b9b946b5020162.png create mode 100644 manifest/static/cover/202609/150ebb05e2c26d639d775065da5663e8.jpg create mode 100644 manifest/static/cover/202609/sticker_edge2edge.png create mode 100644 sql/software_service.sql diff --git a/api/admin/v1/attachment.go b/api/admin/v1/attachment.go new file mode 100644 index 0000000..b89e582 --- /dev/null +++ b/api/admin/v1/attachment.go @@ -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 = 文件仍被引用,仅移除了登记记录 +} diff --git a/api/admin/v1/goods.go b/api/admin/v1/goods.go new file mode 100644 index 0000000..db08323 --- /dev/null +++ b/api/admin/v1/goods.go @@ -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{} diff --git a/api/admin/v1/project.go b/api/admin/v1/project.go new file mode 100644 index 0000000..4630c82 --- /dev/null +++ b/api/admin/v1/project.go @@ -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{} diff --git a/api/admin/v1/service.go b/api/admin/v1/service.go new file mode 100644 index 0000000..ddb33a5 --- /dev/null +++ b/api/admin/v1/service.go @@ -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{} diff --git a/api/admin/v1/setting.go b/api/admin/v1/setting.go new file mode 100644 index 0000000..3f1ca64 --- /dev/null +++ b/api/admin/v1/setting.go @@ -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{} diff --git a/api/user/v1/app_config.go b/api/user/v1/app_config.go new file mode 100644 index 0000000..664e3e3 --- /dev/null +++ b/api/user/v1/app_config.go @@ -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"` +} diff --git a/api/user/v1/goods.go b/api/user/v1/goods.go new file mode 100644 index 0000000..5acbdcc --- /dev/null +++ b/api/user/v1/goods.go @@ -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 拼成绝对地址, +// 小程序端拿到即可直接用于 ,无需自行拼接域名。 +// ============================================================================ + +// 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"` +} diff --git a/api/user/v1/member.go b/api/user/v1/member.go index e3899cf..675af14 100644 --- a/api/user/v1/member.go +++ b/api/user/v1/member.go @@ -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"` diff --git a/api/user/v1/message.go b/api/user/v1/message.go new file mode 100644 index 0000000..e3ca608 --- /dev/null +++ b/api/user/v1/message.go @@ -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{} diff --git a/api/user/v1/project.go b/api/user/v1/project.go new file mode 100644 index 0000000..fa0020e --- /dev/null +++ b/api/user/v1/project.go @@ -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"` +} diff --git a/api/user/v1/service.go b/api/user/v1/service.go new file mode 100644 index 0000000..a34c41a --- /dev/null +++ b/api/user/v1/service.go @@ -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"` +} diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index ef175c1..cd99d0c 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -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 退出、不降级**,而该目录原先只在 diff --git a/internal/consts/consts.go b/internal/consts/consts.go index d09c8b5..bd3e022 100644 --- a/internal/consts/consts.go +++ b/internal/consts/consts.go @@ -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 下发 +) diff --git a/internal/controller/app_config.go b/internal/controller/app_config.go new file mode 100644 index 0000000..9abaddb --- /dev/null +++ b/internal/controller/app_config.go @@ -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) +} diff --git a/internal/controller/attachment.go b/internal/controller/attachment.go new file mode 100644 index 0000000..5b35701 --- /dev/null +++ b/internal/controller/attachment.go @@ -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) +} diff --git a/internal/controller/goods.go b/internal/controller/goods.go new file mode 100644 index 0000000..91ab2e7 --- /dev/null +++ b/internal/controller/goods.go @@ -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) +} diff --git a/internal/controller/member.go b/internal/controller/member.go index cce73db..6bf0d3a 100644 --- a/internal/controller/member.go +++ b/internal/controller/member.go @@ -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)。 diff --git a/internal/controller/project.go b/internal/controller/project.go new file mode 100644 index 0000000..d59fa10 --- /dev/null +++ b/internal/controller/project.go @@ -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) +} diff --git a/internal/controller/service.go b/internal/controller/service.go new file mode 100644 index 0000000..5c747f3 --- /dev/null +++ b/internal/controller/service.go @@ -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) +} diff --git a/internal/controller/setting.go b/internal/controller/setting.go new file mode 100644 index 0000000..2ba5b9a --- /dev/null +++ b/internal/controller/setting.go @@ -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) +} diff --git a/internal/logic/admin.go b/internal/logic/admin.go index 631a8c1..2653730 100644 --- a/internal/logic/admin.go +++ b/internal/logic/admin.go @@ -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", diff --git a/internal/logic/admin_order.go b/internal/logic/admin_order.go index 6a5c190..0062008 100644 --- a/internal/logic/admin_order.go +++ b/internal/logic/admin_order.go @@ -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) diff --git a/internal/logic/admin_perm.go b/internal/logic/admin_perm.go index cdcd5ae..f4b674e 100644 --- a/internal/logic/admin_perm.go +++ b/internal/logic/admin_perm.go @@ -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/"): diff --git a/internal/logic/admin_perm_test.go b/internal/logic/admin_perm_test.go index f6ffb9f..3086eff 100644 --- a/internal/logic/admin_perm_test.go +++ b/internal/logic/admin_perm_test.go @@ -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", ""}, diff --git a/internal/logic/app_config.go b/internal/logic/app_config.go new file mode 100644 index 0000000..6d4f398 --- /dev/null +++ b/internal/logic/app_config.go @@ -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 +} diff --git a/internal/logic/attachment.go b/internal/logic/attachment.go new file mode 100644 index 0000000..4890fc1 --- /dev/null +++ b/internal/logic/attachment.go @@ -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 +} diff --git a/internal/logic/goods.go b/internal/logic/goods.go new file mode 100644 index 0000000..b91b3d2 --- /dev/null +++ b/internal/logic/goods.go @@ -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(), + } +} diff --git a/internal/logic/member.go b/internal/logic/member.go index 5d7111f..c156368 100644 --- a/internal/logic/member.go +++ b/internal/logic/member.go @@ -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 }) } diff --git a/internal/logic/message.go b/internal/logic/message.go new file mode 100644 index 0000000..6597de7 --- /dev/null +++ b/internal/logic/message.go @@ -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 +} diff --git a/internal/logic/migrate.go b/internal/logic/migrate.go index c4a16be..5354dfa 100644 --- a/internal/logic/migrate.go +++ b/internal/logic/migrate.go @@ -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 天一次的依据)'"}, } } diff --git a/internal/logic/project.go b/internal/logic/project.go new file mode 100644 index 0000000..47caa68 --- /dev/null +++ b/internal/logic/project.go @@ -0,0 +1,1116 @@ +package logic + +import ( + "context" + "encoding/json" + "fmt" + "math/rand" + "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/gfile" + "github.com/gogf/gf/v2/os/gtime" + + adminv1 "tool-api/api/admin/v1" + userv1 "tool-api/api/user/v1" + "tool-api/internal/consts" +) + +// ============================================================================ +// 开发项目(迭代-2026-09-30 三期) +// +// 后台建单(生成绑定令牌)→ 管理端生成小程序码(wxacode.getUnlimited, +// scene = "p"+项目id+令牌,落 pkg-service/project-bind)→ 用户扫码绑定; +// 后台维护阶段 / 小节点 / 交付物(原型、UI 设计稿等图片),用户端只读查看。 +// +// 关键约定: +// - 绑定令牌 10 位大写字母数字(去掉易混字符),scene 只含 id+token,不落个人信息; +// - 项目软删,阶段/节点/交付物跟随项目硬删(管理端内容,无独立保留价值); +// - 阶段状态变更 / 交付物上传 / 绑定成功 / 已交付 → 站内信通知绑定用户; +// - TZ-01:datetime 写库一律 gdb.Raw("NOW()")。 +// ============================================================================ + +// projectBindPage 绑定页(扫码后进入的分包页面),与年会查座码图同一套落盘/静态路由。 +const projectBindPage = "pkg-service/project-bind" + +// bindTokenChars 去掉易混字符(0/O、1/I/L)的大写字母数字表 +const bindTokenChars = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" + +func genBindToken() string { + b := make([]byte, 10) + for i := range b { + b[i] = bindTokenChars[rand.Intn(len(bindTokenChars))] + } + return string(b) +} + +// parseBindScene 小程序码 scene("p"+id+token)→ token。非法返回空。 +func parseBindScene(scene string) string { + s := strings.TrimSpace(scene) + if !strings.HasPrefix(s, "p") { + return "" + } + s = s[1:] + i := 0 + for i < len(s) && s[i] >= '0' && s[i] <= '9' { + i++ + } + token := strings.TrimSpace(s[i:]) + if len(token) < 6 || len(token) > 16 { + return "" + } + return token +} + +// projectStageAgg 统计项目可见阶段的总数/已完成数 +func projectStageAgg(ctx context.Context, projectId int64) (total, done int) { + total, _ = g.Model(consts.TableProjectStages). + Where("project_id", projectId).Where("is_enabled", 1).Count() + done, _ = g.Model(consts.TableProjectStages). + Where("project_id", projectId).Where("is_enabled", 1).Where("status", consts.StageStatusDone).Count() + return +} + +// pushProjectUpdate 项目站内信(尽力而为;userId<=0 即未绑定,不推) +func pushProjectUpdate(ctx context.Context, userId int64, projectId int64, title, content string) { + if userId <= 0 { + return + } + PushMessage(ctx, userId, consts.MsgTypeProjectUpdate, title, content, + fmt.Sprintf("/pkg-service/project-detail?id=%d", projectId)) +} + +// ============================================================================ +// C 端 +// ============================================================================ + +// ProjectItemOf 列表行 → C 端输出(附阶段进度) +func projectItemOf(p gdb.Record) userv1.ProjectItem { + return userv1.ProjectItem{ + Id: p["id"].Int64(), + Title: p["title"].String(), + Description: p["description"].String(), + PriceCents: p["price_cents"].Int64(), + PriceNote: p["price_note"].String(), + DueNote: p["due_note"].String(), + Status: p["status"].Int(), + StatusText: consts.ProjectStatusNameOf(p["status"].Int()), + UpdatedAt: timeStr(p["updated_at"].GTime()), + } +} + +// ProjectMine 我的项目(当前用户绑定的,按更新时间倒序) +func ProjectMine(ctx context.Context) (*userv1.ProjectMineRes, error) { + records, err := g.Model(consts.TableServiceProjects). + Where("user_id", CtxUserId(ctx)). + OrderDesc("id"). + All() + if err != nil { + return nil, err + } + // 入口图:每个项目取排序最靠前的交付物图片(阶段 sort → 文件 sort) + ids := make([]int64, 0, len(records)) + for _, p := range records { + ids = append(ids, p["id"].Int64()) + } + covers := projectCoverMap(ctx, ids) + list := make([]userv1.ProjectItem, 0, len(records)) + for _, p := range records { + item := projectItemOf(p) + item.StageTotal, item.StageDone = projectStageAgg(ctx, p["id"].Int64()) + item.CoverUrl = covers[p["id"].Int64()] + list = append(list, item) + } + return &userv1.ProjectMineRes{List: list}, nil +} + +// projectCoverMap 项目 → 第一张交付物图(按阶段 sort、文件 sort 取最前)。 +func projectCoverMap(ctx context.Context, ids []int64) map[int64]string { + out := map[int64]string{} + if len(ids) == 0 { + return out + } + files, err := g.Model(consts.TableProjectFiles+" f"). + LeftJoin(consts.TableProjectStages+" s", "s.id=f.stage_id"). + WhereIn("f.project_id", ids). + Fields("f.project_id, f.file_path"). + Order("s.sort asc, s.id asc, f.sort asc, f.id asc"). + All() + if err != nil { + return out + } + for _, f := range files { + pid := f["project_id"].Int64() + if _, ok := out[pid]; !ok && f["file_path"].String() != "" { + out[pid] = absoluteURL(ctx, f["file_path"].String()) + } + } + return out +} + +// ProjectDetail 项目详情(仅绑定用户可见;含阶段/节点/交付物全量)。 +func ProjectDetail(ctx context.Context, id int64) (*userv1.ProjectDetailRes, error) { + p, err := g.Model(consts.TableServiceProjects).Where("id", id).One() + if err != nil { + return nil, err + } + if p.IsEmpty() || p["user_id"].Int64() != CtxUserId(ctx) { + return nil, gerror.New("项目不存在或未绑定到当前账号") + } + stages, err := projectStagesOut(ctx, id) + if err != nil { + return nil, err + } + pays, err := projectPaysOut(ctx, id) + if err != nil { + return nil, err + } + maintain, err := projectMaintainOut(ctx, p) + if err != nil { + return nil, err + } + res := &userv1.ProjectDetailRes{ + ProjectItem: projectItemOf(p), + Stages: stages, + Pays: pays, + Maintain: maintain, + } + res.StageTotal, res.StageDone = projectStageAgg(ctx, id) + if p["status"].Int() == consts.ProjectStatusDone && p["accepted"].Int() == 0 { + res.CanAccept = 1 + } + return res, nil +} + +// projectPaysOut 分期列表(按期号升序) +func projectPaysOut(ctx context.Context, projectId int64) ([]userv1.ProjectPayItem, error) { + records, err := g.Model(consts.TableProjectPays). + Where("project_id", projectId).Order("seq asc, id asc").All() + if err != nil { + return nil, err + } + list := make([]userv1.ProjectPayItem, 0, len(records)) + for _, r := range records { + list = append(list, userv1.ProjectPayItem{ + Id: r["id"].Int64(), + Seq: r["seq"].Int(), + Percent: r["percent"].Int(), + AmountCents: r["amount_cents"].Int64(), + Trigger: r["trigger"].String(), + TriggerText: consts.PayTriggerNameOf(r["trigger"].String()), + Status: r["status"].Int(), + PaidAt: timeStr(r["paid_at"].GTime()), + }) + } + return list, nil +} + +// projectMaintainOut 维护期信息(未选模板 / 仅源码仍有展示,便于用户理解售后范围) +func projectMaintainOut(ctx context.Context, p gdb.Record) (*userv1.ProjectMaintainOut, error) { + planId := p["maintain_plan_id"].Int64() + if planId <= 0 { + return nil, nil + } + plan, err := g.Model(consts.TableMaintainPlans).Where("id", planId).One() + if err != nil { + return nil, err + } + if plan.IsEmpty() { + return nil, nil + } + return &userv1.ProjectMaintainOut{ + PlanName: plan["name"].String(), + FreeYears: plan["free_years"].Int(), + YearlyFeeCents: plan["yearly_fee_cents"].Int64(), + SourceOnly: plan["source_only"].Int(), + Accepted: p["accepted"].Int(), + AcceptedAt: timeStr(p["accepted_at"].GTime()), + StartAt: timeStr(p["maintain_start_at"].GTime()), + PaidUntil: timeStr(p["maintain_paid_until"].GTime()), + }, nil +} + +// ProjectAccept 用户确认验收:进入维护期(起点=验收当天;后台可调整起算日)。 +// 维护费已缴至 = 起算日 + 免费年数(按年收费模板 free_years=0 → 验收后即进入首年缴费提醒窗口)。 +func ProjectAccept(ctx context.Context, id int64) (*userv1.ProjectAcceptRes, error) { + p, err := g.Model(consts.TableServiceProjects).Where("id", id).One() + if err != nil { + return nil, err + } + if p.IsEmpty() || p["user_id"].Int64() != CtxUserId(ctx) { + return nil, gerror.New("项目不存在或未绑定到当前账号") + } + if p["status"].Int() != consts.ProjectStatusDone { + return nil, gerror.New("项目尚未交付,暂不能验收") + } + if p["accepted"].Int() == 1 { + return nil, gerror.New("该项目已验收过") + } + planId := p["maintain_plan_id"].Int64() + freeYears := 0 + sourceOnly := 0 + if planId > 0 { + plan, err := g.Model(consts.TableMaintainPlans).Where("id", planId).One() + if err == nil && !plan.IsEmpty() { + freeYears = plan["free_years"].Int() + sourceOnly = plan["source_only"].Int() + } + } + now := gtime.Now() + var paidUntil *gtime.Time + if sourceOnly != 1 { + paidUntil = now.AddDate(freeYears, 0, 0) + } + if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{ + "accepted": 1, + "accepted_at": now, + "maintain_start_at": now, + "maintain_paid_until": paidUntil, + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + if sourceOnly == 1 { + pushProjectUpdate(ctx, p["user_id"].Int64(), id, + "验收成功", + "项目「"+p["title"].String()+"」已验收,源码交付完成。感谢信任!") + } else { + tail := "免费维护期至 " + paidUntil.Format("Y-m-d") + if freeYears <= 0 { + tail = "按年收费维护已开始,请留意年度维护费缴纳提醒" + } + pushProjectUpdate(ctx, p["user_id"].Int64(), id, + "验收成功,进入维护期", + "项目「"+p["title"].String()+"」验收通过,"+tail) + } + WriteAudit(ctx, AuditEntry{ + Action: "service.project.accept", + TargetType: "service_project", + TargetId: auditId(id), + After: g.Map{"accepted": 1, "maintain_paid_until": paidUntil}, + Result: consts.AuditResultSuccess, + }) + res := &userv1.ProjectAcceptRes{MaintainStartAt: now.Format("Y-m-d")} + if paidUntil != nil { + res.PaidUntil = paidUntil.Format("Y-m-d") + } + return res, nil +} + +// projectStagesOut 组装阶段树(节点 + 交付物,图片绝对 URL) +func projectStagesOut(ctx context.Context, projectId int64) ([]userv1.ProjectStageItem, error) { + stages, err := g.Model(consts.TableProjectStages). + Where("project_id", projectId). + Where("is_enabled", 1). + Order("sort asc, id asc").All() + if err != nil { + return nil, err + } + nodes, err := g.Model(consts.TableProjectNodes). + Where("project_id", projectId). + Order("sort asc, id asc").All() + if err != nil { + return nil, err + } + files, err := g.Model(consts.TableProjectFiles). + Where("project_id", projectId). + Order("sort asc, id asc").All() + if err != nil { + return nil, err + } + out := make([]userv1.ProjectStageItem, 0, len(stages)) + for _, s := range stages { + stageId := s["id"].Int64() + stage := userv1.ProjectStageItem{ + Id: stageId, + Title: s["title"].String(), + Status: s["status"].Int(), + StatusText: consts.StageStatusNameOf(s["status"].Int()), + } + for _, n := range nodes { + if n["stage_id"].Int64() != stageId { + continue + } + stage.Nodes = append(stage.Nodes, userv1.ProjectNodeItem{ + Id: n["id"].Int64(), + Title: n["title"].String(), + IsDone: n["is_done"].Int(), + DoneAt: timeStr(n["done_at"].GTime()), + }) + } + for _, f := range files { + if f["stage_id"].Int64() != stageId { + continue + } + stage.Files = append(stage.Files, userv1.ProjectFileItem{ + Id: f["id"].Int64(), + FileName: f["file_name"].String(), + FileUrl: absoluteURL(ctx, f["file_path"].String()), + }) + } + if stage.Nodes == nil { + stage.Nodes = []userv1.ProjectNodeItem{} + } + if stage.Files == nil { + stage.Files = []userv1.ProjectFileItem{} + } + out = append(out, stage) + } + return out, nil +} + +// ProjectBind 扫码绑定:令牌来自小程序码 scene。已绑本人幂等;已被他人绑定报错。 +func ProjectBind(ctx context.Context, token string) (*userv1.ProjectBindRes, error) { + token = strings.ToUpper(strings.TrimSpace(token)) + if token == "" { + return nil, gerror.New("缺少绑定令牌") + } + p, err := g.Model(consts.TableServiceProjects).Where("bind_token", token).One() + if err != nil { + return nil, err + } + if p.IsEmpty() { + return nil, gerror.New("绑定码无效,请联系开发者确认") + } + userId := CtxUserId(ctx) + owner := p["user_id"].Int64() + if owner != 0 && owner != userId { + return nil, gerror.New("该项目已绑定到其他账号") + } + if owner != userId { + if _, err = g.Model(consts.TableServiceProjects).Where("id", p["id"].Int64()).Data(g.Map{ + "user_id": userId, + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + pushProjectUpdate(ctx, userId, p["id"].Int64(), + "项目绑定成功", + "你的项目「"+p["title"].String()+"」已绑定到当前账号,进度与交付物会在这里同步。") + } + return &userv1.ProjectBindRes{ProjectId: p["id"].Int64()}, nil +} + +// ============================================================================ +// 管理端 +// ============================================================================ + +// AdminProjectList 项目列表(含未绑定;带客户昵称与阶段进度) +func AdminProjectList(ctx context.Context, req *adminv1.AdminProjectListReq) (*adminv1.AdminProjectListRes, error) { + page, pageSize := normalizePage(req.Page, req.PageSize, 20) + m := g.Model(consts.TableServiceProjects) + if kw := strings.TrimSpace(req.Keyword); kw != "" { + m = m.Where("title LIKE ?", "%"+kw+"%") + } + 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 + } + // 客户昵称 / 模板名映射 + nicknames := map[int64]string{} + maintainNames := map[int64]string{} + payNames := map[int64]string{} + userIds := make([]int64, 0, len(records)) + maintainIds := make([]int64, 0, len(records)) + payIds := make([]int64, 0, len(records)) + for _, r := range records { + if uid := r["user_id"].Int64(); uid > 0 { + userIds = append(userIds, uid) + } + if mid := r["maintain_plan_id"].Int64(); mid > 0 { + maintainIds = append(maintainIds, mid) + } + if pid := r["pay_plan_id"].Int64(); pid > 0 { + payIds = append(payIds, pid) + } + } + 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() + } + } + } + if len(maintainIds) > 0 { + rows, err := g.Model(consts.TableMaintainPlans).WhereIn("id", maintainIds).All() + if err == nil { + for _, m := range rows { + maintainNames[m["id"].Int64()] = m["name"].String() + } + } + } + if len(payIds) > 0 { + rows, err := g.Model(consts.TablePayPlans).WhereIn("id", payIds).All() + if err == nil { + for _, p := range rows { + payNames[p["id"].Int64()] = p["name"].String() + } + } + } + list := make([]adminv1.AdminProjectItem, 0, len(records)) + for _, r := range records { + uid := r["user_id"].Int64() + item := adminv1.AdminProjectItem{ + Id: r["id"].Int64(), + UserId: uid, + UserNickname: nicknames[uid], + Title: r["title"].String(), + Description: r["description"].String(), + PriceCents: r["price_cents"].Int64(), + PriceNote: r["price_note"].String(), + DueNote: r["due_note"].String(), + Status: r["status"].Int(), + StatusText: consts.ProjectStatusNameOf(r["status"].Int()), + BindToken: r["bind_token"].String(), + CodeUrl: r["code_url"].String(), + CreatedAt: timeStr(r["created_at"].GTime()), + UpdatedAt: timeStr(r["updated_at"].GTime()), + } + item.StageTotal, item.StageDone = projectStageAgg(ctx, item.Id) + item.MaintainPlanId = r["maintain_plan_id"].Int64() + item.PayPlanId = r["pay_plan_id"].Int64() + item.MaintainPlanName = maintainNames[item.MaintainPlanId] + item.PayPlanName = payNames[item.PayPlanId] + item.Accepted = r["accepted"].Int() + item.MaintainPaidUntil = timeStr(r["maintain_paid_until"].GTime()) + item.MaintainDaysLeft = projectDaysLeft(r) + list = append(list, item) + } + return &adminv1.AdminProjectListRes{List: list, Total: total}, nil +} + +// projectDaysLeft 距维护费到期天数;未启用维护期(未验收/仅源码/未设缴至)返回 -1。 +// list 查询未 join 模板表,仅源码模板(yearly_fee=0)的项目会在已缴至为空时自然返回 -1。 +func projectDaysLeft(p gdb.Record) int { + if p["accepted"].Int() != 1 { + return -1 + } + paidUntil := p["maintain_paid_until"].GTime() + if paidUntil == nil { + return -1 + } + return maintainDaysLeft(gtime.Now(), paidUntil) +} + +// AdminProjectSave 新增/编辑项目。新建生成绑定令牌;userId 变更即时改绑; +// 状态切到「已交付」时推站内信。 +func AdminProjectSave(ctx context.Context, req *adminv1.AdminProjectSaveReq) (*adminv1.AdminProjectSaveRes, error) { + title := strings.TrimSpace(req.Title) + if title == "" { + return nil, gerror.New("请填写项目名称") + } + // 模板校验(新建必选;编辑允许沿用原值) + if req.MaintainPlanId > 0 { + if cnt, _ := g.Model(consts.TableMaintainPlans).Where("id", req.MaintainPlanId).Where("is_enabled", 1).Count(); cnt == 0 { + return nil, gerror.New("维护模板不存在或已停用") + } + } + if req.PayPlanId > 0 { + if cnt, _ := g.Model(consts.TablePayPlans).Where("id", req.PayPlanId).Where("is_enabled", 1).Count(); cnt == 0 { + return nil, gerror.New("付款分期模板不存在或已停用") + } + } + + data := g.Map{ + "title": title, + "description": strings.TrimSpace(req.Description), + "price_cents": req.PriceCents, + "price_note": strings.TrimSpace(req.PriceNote), + "due_note": strings.TrimSpace(req.DueNote), + "status": req.Status, + "user_id": req.UserId, + } + if req.Id > 0 { + exist, err := g.Model(consts.TableServiceProjects).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("项目不存在") + } + if req.MaintainPlanId > 0 { + data["maintain_plan_id"] = req.MaintainPlanId + } + if req.PayPlanId > 0 { + data["pay_plan_id"] = req.PayPlanId + } + data["updated_at"] = gdb.Raw("NOW()") + if _, err = g.Model(consts.TableServiceProjects).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + // 分期计划再生成:换了付款模板 → 必须没有已到账分期;金额变了 → 只重算未到账期数 + if req.PayPlanId > 0 || req.PriceCents != exist["price_cents"].Int64() { + if err = projectRegeneratePays(ctx, req.Id, req.PriceCents); err != nil { + return nil, err + } + } + // 改绑 / 交付 的站内信 + if req.UserId > 0 && req.UserId != exist["user_id"].Int64() { + pushProjectUpdate(ctx, req.UserId, req.Id, + "项目绑定成功", + "你的项目「"+title+"」已绑定到当前账号,进度与交付物会在这里同步。") + } + if req.Status == consts.ProjectStatusDone && exist["status"].Int() != consts.ProjectStatusDone { + pushProjectUpdate(ctx, req.UserId, req.Id, + "项目已交付", + "项目「"+title+"」已标记交付,感谢信任,售后随时找我们。") + } + WriteAudit(ctx, AuditEntry{ + Action: "service.project.save", + TargetType: "service_project", + TargetId: auditId(req.Id), + Before: g.Map{"title": exist["title"].String(), "status": exist["status"].Int()}, + After: g.Map{"title": title, "status": req.Status}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminProjectSaveRes{Id: req.Id, BindToken: exist["bind_token"].String()}, nil + } + + token := genBindToken() + // 令牌唯一键兜底:极小概率撞表,重试一次 + if cnt, _ := g.Model(consts.TableServiceProjects).Where("bind_token", token).Count(); cnt > 0 { + token = genBindToken() + } + if req.MaintainPlanId <= 0 || req.PayPlanId <= 0 { + return nil, gerror.New("请选择维护模板与付款分期模板") + } + data["maintain_plan_id"] = req.MaintainPlanId + data["pay_plan_id"] = req.PayPlanId + data["bind_token"] = token + data["created_at"] = gdb.Raw("NOW()") + data["updated_at"] = gdb.Raw("NOW()") + res, err := g.Model(consts.TableServiceProjects).Data(data).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + if err = projectRegeneratePays(ctx, id, req.PriceCents); err != nil { + return nil, err + } + if req.UserId > 0 { + pushProjectUpdate(ctx, req.UserId, id, + "项目绑定成功", + "你的项目「"+title+"」已绑定到当前账号,进度与交付物会在这里同步。") + } + WriteAudit(ctx, AuditEntry{ + Action: "service.project.save", + TargetType: "service_project", + TargetId: auditId(id), + After: g.Map{"title": title, "status": req.Status}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminProjectSaveRes{Id: id, BindToken: token}, nil +} + +// projectRegeneratePays 按项目当前付款模板 × 金额生成分期计划。 +// - 无已到账分期:整表重建(换模板 / 首次生成); +// - 已有到账分期:仅重算未到账期数的金额(最后一期吸收取整余额),不动已到账记录。 +func projectRegeneratePays(ctx context.Context, projectId, priceCents int64) error { + p, err := g.Model(consts.TableServiceProjects).Where("id", projectId).One() + if err != nil { + return err + } + if p.IsEmpty() || p["pay_plan_id"].Int64() <= 0 { + return nil + } + plan, err := g.Model(consts.TablePayPlans).Where("id", p["pay_plan_id"].Int64()).One() + if err != nil { + return err + } + if plan.IsEmpty() { + return nil + } + steps, err := parsePaySchedule(plan["schedule"].String()) + if err != nil { + return err + } + paidCnt, err := g.Model(consts.TableProjectPays). + Where("project_id", projectId).Where("status", consts.PayStatusPaid).Count() + if err != nil { + return err + } + if paidCnt > 0 { + // 只重算金额:所有期按各自比例算,取整差额(正负均可)归到最后一个未到账期。 + // ⚠️ 差额 = 项目金额 − 全部期按比例之和(含已到账期),绝不能把已到账期漏掉, + // 否则最后一期会被错误地写成「项目金额 − 已到账合计」(线上踩过一次)。 + all, err := g.Model(consts.TableProjectPays). + Where("project_id", projectId).Order("seq asc").All() + if err != nil { + return err + } + percentSum := int64(0) + for _, r := range all { + percentSum += priceCents * r["percent"].Int64() / 100 + } + remainder := priceCents - percentSum + var lastUnpaid int64 + for i := len(all) - 1; i >= 0; i-- { + if all[i]["status"].Int() == consts.PayStatusPending { + lastUnpaid = all[i]["id"].Int64() + break + } + } + for _, r := range all { + if r["status"].Int() != consts.PayStatusPending { + continue + } + amount := priceCents * r["percent"].Int64() / 100 + if r["id"].Int64() == lastUnpaid { + amount += remainder + } + if _, err = g.Model(consts.TableProjectPays).Where("id", r["id"].Int64()).Data(g.Map{ + "amount_cents": amount, + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return err + } + } + return nil + } + // 整表重建 + if _, err = g.Model(consts.TableProjectPays).Where("project_id", projectId).Delete(); err != nil { + return err + } + allocated := int64(0) + for i, st := range steps { + amount := priceCents * st.Percent / 100 + if i == len(steps)-1 { + amount = priceCents - allocated + } + allocated += amount + if _, err = g.Model(consts.TableProjectPays).Data(g.Map{ + "project_id": projectId, + "seq": st.Seq, + "percent": st.Percent, + "amount_cents": amount, + "trigger": st.Trigger, + "status": consts.PayStatusPending, + "created_at": gdb.Raw("NOW()"), + "updated_at": gdb.Raw("NOW()"), + }).Insert(); err != nil { + return err + } + } + return nil +} + +// payStep 分期计划单步 +type payStep struct { + Seq int + Percent int64 + Trigger string +} + +// parsePaySchedule 解析分期模板 schedule JSON([{seq,percent,trigger}]),校验比例合计 100。 +func parsePaySchedule(raw string) ([]payStep, error) { + var steps []payStep + if err := json.Unmarshal([]byte(raw), &steps); err != nil { + return nil, gerror.New("分期计划 JSON 不合法") + } + if len(steps) == 0 { + return nil, gerror.New("分期计划不能为空") + } + total := int64(0) + for _, st := range steps { + switch st.Trigger { + case consts.PayTriggerStart, consts.PayTriggerMidDemo, + consts.PayTriggerOnlineBefore, consts.PayTriggerOnlineAfter: + default: + return nil, gerror.New("分期触发节点不合法(start / mid_demo / online_before / online_after)") + } + if st.Percent <= 0 || st.Percent > 100 { + return nil, gerror.New("分期比例需在 1-100 之间") + } + total += st.Percent + } + if total != 100 { + return nil, gerror.New("分期比例合计必须为 100%") + } + return steps, nil +} + +// AdminProjectDelete 删除项目(软删)及其阶段/节点/交付物(硬删)。 +func AdminProjectDelete(ctx context.Context, id int64) (*adminv1.AdminProjectDeleteRes, error) { + exist, err := g.Model(consts.TableServiceProjects).Where("id", id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("项目不存在") + } + if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{ + "deleted_at": gdb.Raw("NOW()"), + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + for _, table := range []string{consts.TableProjectStages, consts.TableProjectNodes, consts.TableProjectFiles} { + if _, err = g.Model(table).Where("project_id", id).Delete(); err != nil { + return nil, err + } + } + WriteAudit(ctx, AuditEntry{ + Action: "service.project.delete", + TargetType: "service_project", + TargetId: auditId(id), + Before: g.Map{"title": exist["title"].String()}, + Result: consts.AuditResultSuccess, + }) + return &adminv1.AdminProjectDeleteRes{}, nil +} + +// AdminProjectDetail 管理态详情(含全部阶段/节点/交付物,含停用阶段) +func AdminProjectDetail(ctx context.Context, id int64) (*adminv1.AdminProjectDetailRes, error) { + p, err := g.Model(consts.TableServiceProjects).Where("id", id).One() + if err != nil { + return nil, err + } + if p.IsEmpty() { + return nil, gerror.New("项目不存在") + } + item := adminv1.AdminProjectItem{ + Id: p["id"].Int64(), + UserId: p["user_id"].Int64(), + Title: p["title"].String(), + Description: p["description"].String(), + PriceCents: p["price_cents"].Int64(), + PriceNote: p["price_note"].String(), + DueNote: p["due_note"].String(), + Status: p["status"].Int(), + StatusText: consts.ProjectStatusNameOf(p["status"].Int()), + BindToken: p["bind_token"].String(), + CodeUrl: p["code_url"].String(), + CreatedAt: timeStr(p["created_at"].GTime()), + UpdatedAt: timeStr(p["updated_at"].GTime()), + } + if item.UserId > 0 { + if u, err := g.Model(consts.TableUsers).Where("id", item.UserId).One(); err == nil && !u.IsEmpty() { + item.UserNickname = u["nickname"].String() + } + } + item.StageTotal, item.StageDone = projectStageAgg(ctx, id) + item.MaintainPlanId = p["maintain_plan_id"].Int64() + item.PayPlanId = p["pay_plan_id"].Int64() + item.Accepted = p["accepted"].Int() + item.AcceptedAt = timeStr(p["accepted_at"].GTime()) + item.MaintainStartAt = timeStr(p["maintain_start_at"].GTime()) + item.MaintainPaidUntil = timeStr(p["maintain_paid_until"].GTime()) + item.MaintainDaysLeft = projectDaysLeft(p) + if item.MaintainPlanId > 0 { + if mp, err := g.Model(consts.TableMaintainPlans).Where("id", item.MaintainPlanId).One(); err == nil && !mp.IsEmpty() { + item.MaintainPlanName = mp["name"].String() + } + } + if item.PayPlanId > 0 { + if pp, err := g.Model(consts.TablePayPlans).Where("id", item.PayPlanId).One(); err == nil && !pp.IsEmpty() { + item.PayPlanName = pp["name"].String() + } + } + if pays, err := projectPaysOut(ctx, id); err == nil { + for _, py := range pays { + item.Pays = append(item.Pays, adminv1.AdminProjectPayItem{ + Id: py.Id, Seq: py.Seq, Percent: py.Percent, AmountCents: py.AmountCents, + Trigger: py.Trigger, TriggerText: py.TriggerText, Status: py.Status, + PaidAt: py.PaidAt, + }) + } + } + + stages, err := g.Model(consts.TableProjectStages). + Where("project_id", id).Order("sort asc, id asc").All() + if err != nil { + return nil, err + } + nodes, err := g.Model(consts.TableProjectNodes). + Where("project_id", id).Order("sort asc, id asc").All() + if err != nil { + return nil, err + } + files, err := g.Model(consts.TableProjectFiles). + Where("project_id", id).Order("sort asc, id asc").All() + if err != nil { + return nil, err + } + for _, s := range stages { + stageId := s["id"].Int64() + stage := adminv1.AdminProjectStageItem{ + Id: stageId, + Title: s["title"].String(), + Status: s["status"].Int(), + StatusText: consts.StageStatusNameOf(s["status"].Int()), + Sort: s["sort"].Int(), + IsEnabled: s["is_enabled"].Int(), + Nodes: []adminv1.AdminProjectNodeItem{}, + Files: []adminv1.AdminProjectFileItem{}, + } + for _, n := range nodes { + if n["stage_id"].Int64() != stageId { + continue + } + stage.Nodes = append(stage.Nodes, adminv1.AdminProjectNodeItem{ + Id: n["id"].Int64(), + Title: n["title"].String(), + IsDone: n["is_done"].Int(), + Sort: n["sort"].Int(), + }) + } + for _, f := range files { + if f["stage_id"].Int64() != stageId { + continue + } + stage.Files = append(stage.Files, adminv1.AdminProjectFileItem{ + Id: f["id"].Int64(), + FileName: f["file_name"].String(), + FileUrl: absoluteURL(ctx, f["file_path"].String()), + }) + } + item.Stages = append(item.Stages, stage) + } + return &adminv1.AdminProjectDetailRes{AdminProjectItem: item}, nil +} + +// AdminProjectQrcode 生成/刷新绑定小程序码(wxacode.getUnlimited,scene=p)。 +// 复用年会域的 access_token 复用与码图落盘设施(QrCodeDir + /qrcode 静态路由)。 +func AdminProjectQrcode(ctx context.Context, id int64) (*adminv1.AdminProjectQrcodeRes, error) { + p, err := g.Model(consts.TableServiceProjects).Where("id", id).One() + if err != nil { + return nil, err + } + if p.IsEmpty() { + return nil, gerror.New("项目不存在") + } + cfg := xpayConfig(ctx) + if cfg.AppId == "" || cfg.Secret == "" { + return nil, gerror.New("未配置 wx.appId / wx.appSecret,无法生成小程序码;也可把绑定码 " + p["bind_token"].String() + " 直接发给用户,在绑定页手动输入") + } + token, err := wxAccessToken(ctx) + if err != nil { + return nil, gerror.New("获取微信 access_token 失败,请稍后重试或检查 wx.appId / wx.appSecret 配置;也可把绑定码 " + p["bind_token"].String() + " 直接发给用户,在绑定页手动输入") + } + scene := "p" + strconv.FormatInt(p["id"].Int64(), 10) + p["bind_token"].String() + body := g.Map{ + "scene": scene, + "page": projectBindPage, + "check_path": false, // 联调期页面可能未发布,避免路径校验失败 + "env_version": "release", + } + resp, err := g.Client().ContentJson().Post(ctx, xpayAPIBase+"/wxa/getwxacodeunlimit?access_token="+token, body) + if err != nil { + return nil, gerror.Newf("调用 wxacode.getUnlimited 失败: %v", err) + } + defer resp.Close() + data := resp.ReadAll() + if len(data) == 0 { + return nil, gerror.New("生成小程序码失败:微信返回空响应") + } + if isJsonBytes(data) { + var we struct { + ErrCode int `json:"errcode"` + ErrMsg string `json:"errmsg"` + } + _ = json.Unmarshal(data, &we) + return nil, gerror.Newf("生成小程序码失败:%d %s(页面未发布时请改用正式环境或令牌绑定)", we.ErrCode, we.ErrMsg) + } + if err = gfile.Mkdir(QrCodeDir); err != nil { + return nil, gerror.Newf("创建码图目录失败: %v", err) + } + fileName := "project_" + strconv.FormatInt(p["id"].Int64(), 10) + "_" + gtime.Now().Format("YmdHis") + ".png" + path := gfile.Join(QrCodeDir, fileName) + if err = gfile.PutBytes(path, data); err != nil { + return nil, gerror.Newf("写入码图失败: %v", err) + } + codeUrl := "/qrcode/" + fileName + if _, err = g.Model(consts.TableServiceProjects).Where("id", id).Data(g.Map{ + "code_url": codeUrl, + "updated_at": gdb.Raw("NOW()"), + }).Update(); err != nil { + return nil, err + } + return &adminv1.AdminProjectQrcodeRes{CodeUrl: codeUrl}, nil +} + +// AdminProjectStageSave 新增/编辑阶段;状态变更推站内信。 +func AdminProjectStageSave(ctx context.Context, req *adminv1.AdminProjectStageSaveReq) (*adminv1.AdminProjectStageSaveRes, error) { + title := strings.TrimSpace(req.Title) + if title == "" { + return nil, gerror.New("请填写阶段名称") + } + p, err := g.Model(consts.TableServiceProjects).Where("id", req.ProjectId).One() + if err != nil { + return nil, err + } + if p.IsEmpty() { + return nil, gerror.New("项目不存在") + } + data := g.Map{ + "project_id": req.ProjectId, + "title": title, + "status": req.Status, + "sort": req.Sort, + "is_enabled": req.IsEnabled, + } + if req.Id > 0 { + exist, err := g.Model(consts.TableProjectStages).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() || exist["project_id"].Int64() != req.ProjectId { + return nil, gerror.New("阶段不存在") + } + data["updated_at"] = gdb.Raw("NOW()") + if _, err = g.Model(consts.TableProjectStages).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + if req.Status != exist["status"].Int() { + pushProjectUpdate(ctx, p["user_id"].Int64(), req.ProjectId, + "阶段进度更新", + "项目「"+p["title"].String()+"」的阶段「"+title+"」已"+consts.StageStatusNameOf(req.Status)+"。") + } + return &adminv1.AdminProjectStageSaveRes{Id: req.Id}, nil + } + data["created_at"] = gdb.Raw("NOW()") + data["updated_at"] = gdb.Raw("NOW()") + res, err := g.Model(consts.TableProjectStages).Data(data).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return &adminv1.AdminProjectStageSaveRes{Id: id}, nil +} + +// AdminProjectStageDelete 删除阶段(节点与交付物一并删除)。 +func AdminProjectStageDelete(ctx context.Context, id int64) (*adminv1.AdminProjectStageDeleteRes, error) { + exist, err := g.Model(consts.TableProjectStages).Where("id", id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() { + return nil, gerror.New("阶段不存在") + } + if _, err = g.Model(consts.TableProjectStages).Where("id", id).Delete(); err != nil { + return nil, err + } + if _, err = g.Model(consts.TableProjectNodes).Where("stage_id", id).Delete(); err != nil { + return nil, err + } + if _, err = g.Model(consts.TableProjectFiles).Where("stage_id", id).Delete(); err != nil { + return nil, err + } + return &adminv1.AdminProjectStageDeleteRes{}, nil +} + +// AdminProjectNodeSave 新增/编辑小节点(isDone 变更落完成时间)。 +func AdminProjectNodeSave(ctx context.Context, req *adminv1.AdminProjectNodeSaveReq) (*adminv1.AdminProjectNodeSaveRes, error) { + title := strings.TrimSpace(req.Title) + if title == "" { + return nil, gerror.New("请填写节点内容") + } + stage, err := g.Model(consts.TableProjectStages).Where("id", req.StageId).One() + if err != nil { + return nil, err + } + if stage.IsEmpty() { + return nil, gerror.New("阶段不存在") + } + data := g.Map{ + "project_id": stage["project_id"].Int64(), + "stage_id": req.StageId, + "title": title, + "is_done": req.IsDone, + "sort": req.Sort, + } + if req.Id > 0 { + exist, err := g.Model(consts.TableProjectNodes).Where("id", req.Id).One() + if err != nil { + return nil, err + } + if exist.IsEmpty() || exist["stage_id"].Int64() != req.StageId { + return nil, gerror.New("节点不存在") + } + if req.IsDone == 1 && exist["is_done"].Int() != 1 { + data["done_at"] = gdb.Raw("NOW()") + } else if req.IsDone != 1 { + data["done_at"] = nil + } + data["updated_at"] = gdb.Raw("NOW()") + if _, err = g.Model(consts.TableProjectNodes).Where("id", req.Id).Data(data).Update(); err != nil { + return nil, err + } + return &adminv1.AdminProjectNodeSaveRes{Id: req.Id}, nil + } + if req.IsDone == 1 { + data["done_at"] = gdb.Raw("NOW()") + } + data["created_at"] = gdb.Raw("NOW()") + data["updated_at"] = gdb.Raw("NOW()") + res, err := g.Model(consts.TableProjectNodes).Data(data).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + return &adminv1.AdminProjectNodeSaveRes{Id: id}, nil +} + +// AdminProjectNodeDelete 删除小节点。 +func AdminProjectNodeDelete(ctx context.Context, id int64) (*adminv1.AdminProjectNodeDeleteRes, error) { + if _, err := g.Model(consts.TableProjectNodes).Where("id", id).Delete(); err != nil { + return nil, err + } + return &adminv1.AdminProjectNodeDeleteRes{}, nil +} + +// AdminProjectFileAdd 登记交付物(文件本体已由 /admin/upload 落盘)并推站内信。 +func AdminProjectFileAdd(ctx context.Context, req *adminv1.AdminProjectFileAddReq) (*adminv1.AdminProjectFileAddRes, error) { + p, err := g.Model(consts.TableServiceProjects).Where("id", req.ProjectId).One() + if err != nil { + return nil, err + } + if p.IsEmpty() { + return nil, gerror.New("项目不存在") + } + stageTitle := "项目" + if req.StageId > 0 { + s, err := g.Model(consts.TableProjectStages).Where("id", req.StageId).One() + if err != nil { + return nil, err + } + if s.IsEmpty() || s["project_id"].Int64() != req.ProjectId { + return nil, gerror.New("阶段不存在") + } + stageTitle = s["title"].String() + } + res, err := g.Model(consts.TableProjectFiles).Data(g.Map{ + "project_id": req.ProjectId, + "stage_id": req.StageId, + "file_name": strings.TrimSpace(req.FileName), + "file_path": normalizeToRelativePath(req.FilePath), + "created_at": gdb.Raw("NOW()"), + }).Insert() + if err != nil { + return nil, err + } + id, _ := res.LastInsertId() + pushProjectUpdate(ctx, p["user_id"].Int64(), req.ProjectId, + "新交付物上传", + "项目「"+p["title"].String()+"」的「"+stageTitle+"」上传了新交付物:"+strings.TrimSpace(req.FileName)) + return &adminv1.AdminProjectFileAddRes{Id: id}, nil +} + +// AdminProjectFileDelete 删除交付物登记(不动附件本体,附件管理页统一治理)。 +func AdminProjectFileDelete(ctx context.Context, id int64) (*adminv1.AdminProjectFileDeleteRes, error) { + if _, err := g.Model(consts.TableProjectFiles).Where("id", id).Delete(); err != nil { + return nil, err + } + return &adminv1.AdminProjectFileDeleteRes{}, nil +} diff --git a/internal/logic/project_flow.go b/internal/logic/project_flow.go new file mode 100644 index 0000000..5d0c5af --- /dev/null +++ b/internal/logic/project_flow.go @@ -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() +} diff --git a/internal/logic/quota.go b/internal/logic/quota.go index fd269a2..34ff9f8 100644 --- a/internal/logic/quota.go +++ b/internal/logic/quota.go @@ -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) diff --git a/internal/logic/seed.go b/internal/logic/seed.go index 363a3d1..bdbb2a6 100644 --- a/internal/logic/seed.go +++ b/internal/logic/seed.go @@ -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),留空=按请求域名动态拼接"}, } } diff --git a/internal/logic/service.go b/internal/logic/service.go new file mode 100644 index 0000000..e66ec4a --- /dev/null +++ b/internal/logic/service.go @@ -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 +} diff --git a/internal/logic/setting_admin.go b/internal/logic/setting_admin.go new file mode 100644 index 0000000..f9a340d --- /dev/null +++ b/internal/logic/setting_admin.go @@ -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("不支持的配置项") +} diff --git a/internal/logic/upload.go b/internal/logic/upload.go index 197f7be..c8355da 100644 --- a/internal/logic/upload.go +++ b/internal/logic/upload.go @@ -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 已裁定接受):落盘 /avatar//., -// 返回「浏览器可直接打开」的绝对 URL。 +// 本机静态目录方案(U1 已裁定接受):落盘 ///.。 +// +// 📌 图片域名不落库(迭代-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(://)。 -// 无请求上下文(如单测)时回退为相对路径。 +// 无请求上下文(如单测)时回退为相对路径。保留旧名兼容既有调用点,实现统一走 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 通用落盘:///.,返回相对 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 } diff --git a/internal/logic/user.go b/internal/logic/user.go index 1a3570c..fd4cabd 100644 --- a/internal/logic/user.go +++ b/internal/logic/user.go @@ -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 } diff --git a/internal/logic/user_detail.go b/internal/logic/user_detail.go index 17c81e8..e878568 100644 --- a/internal/logic/user_detail.go +++ b/internal/logic/user_detail.go @@ -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, diff --git a/internal/logic/wxmsg.go b/internal/logic/wxmsg.go index f75fb01..39b49d9 100644 --- a/internal/logic/wxmsg.go +++ b/internal/logic/wxmsg.go @@ -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 +} diff --git a/manifest/static/attachment/202609/05b25b14bb98047766b9b946b5020162.png b/manifest/static/attachment/202609/05b25b14bb98047766b9b946b5020162.png new file mode 100644 index 0000000000000000000000000000000000000000..b78718a3abad9f53e372e748ed868f3f59adb06b GIT binary patch literal 10599 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 Mr9W#0KrW{L7x$&He*gdg literal 0 HcmV?d00001 diff --git a/manifest/static/attachment/202609/f94b901f3e4e0ae383104e6d7ae48aab.txt b/manifest/static/attachment/202609/f94b901f3e4e0ae383104e6d7ae48aab.txt new file mode 100644 index 0000000..7387648 --- /dev/null +++ b/manifest/static/attachment/202609/f94b901f3e4e0ae383104e6d7ae48aab.txt @@ -0,0 +1 @@ +需求文档草稿 v1 diff --git a/manifest/static/cover/202609/05b25b14bb98047766b9b946b5020162.png b/manifest/static/cover/202609/05b25b14bb98047766b9b946b5020162.png new file mode 100644 index 0000000000000000000000000000000000000000..b78718a3abad9f53e372e748ed868f3f59adb06b GIT binary patch literal 10599 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 Mr9W#0KrW{L7x$&He*gdg literal 0 HcmV?d00001 diff --git a/manifest/static/cover/202609/150ebb05e2c26d639d775065da5663e8.jpg b/manifest/static/cover/202609/150ebb05e2c26d639d775065da5663e8.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f33ab936b895f992c5d5a36b73fe586baea78832 GIT binary patch literal 19875 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 literal 0 HcmV?d00001 diff --git a/manifest/static/cover/202609/sticker_edge2edge.png b/manifest/static/cover/202609/sticker_edge2edge.png new file mode 100644 index 0000000000000000000000000000000000000000..67a2689aa65fffbbb11ade0634a289019bda6bce GIT binary patch literal 152468 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

~UG+oMmR-ClrZ|88eu+e=~neHPb(%^&;kx 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%0Dr0h*8RI2v*rNpSQu`zoyO*S{z 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

>c+S-!I$B*uR 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 {$nq)YI6997f6tV@-)(;py*BCeu0Q+ zAMYI6iZ$D;-HtE4bS=KP$?O3dxzxJ#0w1P*4lwEw$C*-gf=fCN3iw2$mp*IZ*Hd`Q ziaU48(R6&!n;i6#(O74dXIaQt*AIz?^z<4G2Cfd!d8Ly)hkYZgUcB`$yJSm#2IvmD~gi8z47Wu7Brh~9LUg$* zlBp?*$WyHd@~teLpuhws5i%8^={O^ib5gtAJ0_GLK<&EBsw^$?9QHIsNHawV5%x5L z{7?`ouh{5>g@_(L*t&Q7$NjrsTzd5u^Z9gmu)eVxM_w3tXkg@dRu&K-h$6-ohef3z z?{ZRYzt$RdRAHkrNP-~eA(clCe>7{MJkfdnp@JR`2SZ4ew3mYAjkTrqRo3aSbi}KI z*$N8JL4*m3qbE7itLtRc%5IKwgtrBGaOcA7o-xaQ^qt?=NnVZNG#3iC)a1*ZZllq- zckl5G7Y{hj+%;^a-Ev)HrzA)Mf+pqWqxTM5n}@QjT5tx6p=;(I7dP?7Z$~tQJ2{Qs ze*x*%&%Z!-B1dO;^VXvRLiyl+I~fyaUk`ToLSf<{P|X-&4nVPVoQ~r-MnXsd1Tf>0 zJUuiEf&fEn(s->GfuhfTPXKN}k-u(S`BcJaxX>@Oh~C|1&1hx0)9>y3fx7Y1_2}XS zwzCB-6q7?gUFQZg35(m&!b0IJUsr&|e4j|_AnoOO{{L_9&w6Cbvh+Y~k7uyar+fWQ z5g9p;m1MI?7Dy3Ib{86u1WAA%1ieU53k~!?Y3NkMx_7gRgY#w6JS#Zgz&X*YK_H^QK(B{fvjU>pOX` zI73XKL&7B@H*LpRYKO)f>x^yN>e-{+<5@LSZdI9|{D*(@W78}bn z-~ZYZVv#7u(~%U$pPrN;^y&Fx{d|-NgD3xSzYmF+i)-09bLqN#8j|4T5Q>O@K1h@~LB zd-NUgK?q?w8e3~23Q{*wC^1b_rSxQwl^7kWwbp5tHBIyC)vGse-u&5rI)3(y9USu6 zEa+kWcmBbDE}lMRr!P^d(+O0hY0ep)E3i(+Mn8QnPLi_}ISRo>Sz0zVvo23kmFV{P z>~H@1Z(n@){PA?0s6-EmNW>GE%G!E9KinVpe%-bz*WVN#QztmxT7_>8>y@{!KYo7v z>ecSS!L#>2NWbT8Cwsf2d(ZeeMp};D6&I!HBfxt; zpXVw`2SbD=*7=i@=bwHyd;MaZripLS=^|ARoD>drh_)ZgG5_usqpdg2cM~8VlGg05EHE}1v1*nGK1>sxC~vZBPC#!+$U@Lk z31CjU`T^0AW1p63taCfSCY-aZLHx*NyR;Y<5yDzut%{S`*{fHtj*b_@AzPJ@ARJEF z{^3KBXq>V0Qv4J4f(_Zy!95m>Hd;I^e^jw#C1w{La>&rVL*!$&~O8V@ws;*2RR627~?L#h}z zegEG#3luOKAUNoxM6yRZT%FEp#3webt(s7`+_*$owdSn5h{TSUJ5kZocpE|y)Ocva zq%<^ANL-`9e!;dGEO4@|ZSORR&NEh^88HWM!06Ix+3zZe?gF(LqV`@m%oo}m5s{KhKUG3`m>(74C zVtR5l8YU@d`wYg>=oh@RrfoZxL(LvcCcbXFQe+p!uWLvf z3f`L-_n-qyL&E0?3c1EtY3Os(duodpq{z?Tz34_t9Lu zfn&-Xi?LK{Ow&}#1no*=*|I1X%eJh8X_fXmatBlD-JSX_sE?gDO3x4?lEXw%qhpM} zaBqB}XFQ*sCkw__cc>Fhd-=)n;`BrbmT+Dy7uZW59ds0r$RPv3WwAi?IsQT^8bUyj z7=2>fw#>FEa2S@jyPzG%nWZGtQ8MmGdCC&r*g_hKgC$F&IQ zZ~(1H8p;MjL!dH?%$>TL+t%+^&?5!Qbi7Luyx_EfFE`23Ai$(gf{h)xhej}N=^}CF zDPX)ZVG0f!@H#@w5mXt_1mv7pHmFe?WtcbQH@%-7{S$1pMH5iONUU?oW(B0f9 z{T~KJ0=6RWOsC5k|O28db(H890vEV$mL-9{uv8<*U+`j18De z3I*^tZXb`FTL6O&N?h-0%o#J>H(WhW1!7qpMC(JI(EWd>xW&NO+c&2l{q5@)&x5m)>)Gjv zKRFWnla65}Qkllg4L2X~HsRXVjobJ{17$8JX>6aQ>kz3#*-U~l!xsp&J%}8M6xRbp z@cd{z6p2=HI315(zh1m}bJDgUP0}n?AkA%RCc)|`mGsaxM%AoW3h6qx2r3Ec=8{|F zk)zRZuC?}ey*HR52RggO33+il0|kKL6W`wUM5hu8z>_Qr0* zya};L6LRP=zzt)-nQ9$v(?kaGJW0=;sb{VZbx~GT_2TubmoJyAk`1S9G}NtkY0eG~ z+2bcq*uw+1T2b92V0;AuH-Yq_+eiG8P^<(lhe#ioqm@RBs3H97==INkF*~X<%O((r zXT_P@o8rpCI5}t|NCFowoCLQ4G`!Kuc#y_o;6pJ?x2%kpv0ZHG6D65+)!=8bzC;10)gWI~L9gy%XG`3hQX`SFpYb z(-?Smy@)m27za}&4GZE^Y?N9%@OjUdLJ@^9fApUh)+o7k<#`23`_gL$zc92+vh`ZupmY-1LS#nCLxm-yfoEa_&|vcp66o$q~j{NMnYL6$<_y>4(l z;wzF{?D&KbEs~ld960T$Si4jF>Q(vatL5v8*AR;w3`EoT)oMNQL)VJy$^@}_yv-VT z?JDtC2`wuEB)KF=BV-Eg<+rb2Imw@9dGhEXOLWe%Raq}rt5KTV*A}m{b7pv|_gpGy zM7~ZJSXGy+RZ~_nU~pdAN=9$dy+!vvk^YnjM93#X`-tFKF>T&E3xXz=EoQmSN){?MnoM`c<6(L>TUp!akT}!SbqVDU#dwk> z-X}>i)T4_u73W57;;M0iuSco~a*MIH=s4~luolsYA$Qg3GV?pCEk;@1*pVQ|v38CG zs@YU0Hf`ws+9(Z^^oEkXwM7>~T~)ExgB_a-)$y$%x{RGA?|~>S$=bSo`)bivP&pHV z9Ug#8qj3lgPnPT-eCP2GfAk|Z8MC?uJ~2z1qti4c8dOBt1WrN_0XFhjC7>SknwJR2 zT72>!f07mDSg`Scde_zu6w_0Hx-U{pZtC>6JM_MzN%GEw+B@X}SSk{wl(qKs^wh~u z54_!Zcqp}Tt_;A24D8DUP(ZwAKV$1>5ova4|}q3*SNzTv8w;X&jB{b1)c< zlnPKL@DxP901z;fNz=N(T8z}D3M1s8v#@C})*8@fVLT{hB4@y{wQRK4e+2sk6=9)_Le0c!_mbgkUh$xT(O-M4#Sb5KkDT~UU&6cy- zqAgrAM=M1pZ08`EKG?U4zdU(!dhCu%HaOhPK6nZwpetE2VVNk}Rhnl6{Ujz1xFx|L z2SXBC#O@d&9Ubb;_>cdl{MruNJIJ^Q%T?1>EFa1TyW_LPlJ;OQRr<@rkx^=^mnQ4V zN;NMOu`w=SoHs65!%|#)xl&b8oVr&+h;cTM$zCe8R6547zdz=$<)gebeAUJ1o~1St z({-T5Sf2s$ma`hXtHB`60B98Of<#-2_BKizu{7DG-jR@=IJ78JBApL}h5-2^LPWa^ zF$>@#)b*y;9bqTex-T8|Quk3~{F&ft*CTbEjw%aWtJ_qOgJ6YkPh0K9H@^10ul?|Q zAHDeKq+aH~_;@rL$iNj(#5wGSuAq#hO6bm8Q@Y}doPG9L{QQtp=@d8?%;YdyEOQSQ^KZ$zW$cdwo<-_r`BeUOVBx{r&IBy#rQP zz6rz8GnDpY8pirilSI*)<|a{)QjOu=y6*vW@UpOiPTz&L?&7aEwtdF zEa#Xq1}g(n79^5HXqe@r;K5pBy(O`U2>)_BU;iGPpS^nV>iP4NH*by>3jhbSW$p~R zeY-oOd^&A8KPnbS#w}9zPyTmbn||vln@YAfVdW~!Ui0AgzWEqZ-5`#SfdDCM5T$fB z9WUP=m9xbp9V9z@tg(w<{PO1?{_VHEktM-YfMf-ksysy$J)bYhk;g&`q6OSCaEGFH zE(i?HFm$(In_c_hk$!uP% zEDQVl`?=K1)lv^q$7^bCMiIA;J`QzXau-zc76e|1@<+I4sFZfjpyCBfC&R|shC%Q> zALL95v@FUBj0T;FPCJ|SPCLI#I5*QEKS>&JjR+=_WH3sU5QCvb2ax7fH=LEAg~UBi zT|XkZMEit-8$^|cGb>Rygg5~%NbD^Hd+8g?Ni;z4kxBG|rYJQWxN!ucp}Ai8V+8HC zrGgvjB6-bh8yr+LE$4`ywhYq4+fQ;xP|(`1;We`TJ5EvKBQ> z&vXj;i?(313^5kcX) zX&Wfe6HE;ML{Lp!SU>&O|E?^{v$M0=Y*rMZW2fZo@gsdO-5o#JYqRjCUM^6!U}3-x zzyAK@@je?SOc-YCm01bzhCYgOLZA-W7IEKnbqC)l8HQTsk|T02E52OvqU56t!`F1M zuB;t__J#a8>MX=oCZhUHkWEOpEw)QMA1m@m;%SBjFQdD0l(IAkD4}`J%a#=-%LYtQ zVe$8dL?>`|^!623T&YAO9}-nt`Bmj@7!Rjii3R)S*@RxyIQD4)iQ&IN%(H)ey>sdLS1;PkoF7yW712UPyj z1|nlXbL-Zko-~v3o4AiqLYK;d=Z zt-&-RVH2%ICm}XwtE%EEjWSsq9>CZk>%FRbbx;T+vY|j_U%Y;f*uxzeb|bEdFjvS_ zFrflX(KR6`m+>}brt)0*Aiaa0qHzXlF?98i468tE0ks}nu~EMz>Io9%YS3zAn6Spw zWSipepZw`x(&0tesuaryY%tIxnLQcqAMPDwyOX!oX?6B?zG`{Sc6ZfxzVn^Q!33lK zwlP)J){TT~Ry~^!nKMR8nGOcvvIM{SYPnpNC7X_JcYI68i1TV8K+Gx~P}>TuC;{4Y zgoMLn8|y`}#i;hu^TebOVFNd!s9%5ccV>`Ofm@_jdxC*WnQ)ab*W@lRQ!`z%dKH*9 zKJ}sE!L1f=f?`_8pf!qPN~6rdMP*hbW6`I2kE2tpH3b=1Z|gdtgKAaiog=7 z0A6eQyB2NRCQ^z#!L~Ios@ZI{TD7 zx|z$Ws{GlR$cJn?{=IUGjQb}KA0R0}iwqRyi9m+PCeVJ5JTz;0Xj(g|p<5XFwf>^S z#_?^2CJ4H?c8fQ`EnaWd-YMT=GRV?F?q_UO7QSi4beb~FXbkHr=RxNd3CaixJV<2* z*AmnWi8I{Ig>_>w6}qwl;Sp_3wckC~N>~vo0NNHn$Otc4t(eee;}!|U(p4>@#QaTG za)@xUS3c@>Ypgr7cW4+hXCb8m1!FvAvPFZ7H!Sf?*3?S|6IS zEqsHWiC7^ZDi8dLz2g+uw#5jy0@qEZCKNu9Ga$EY+-D^?tFoZPPSoI$>qw zm&@5~)-=tR$a5~Z#qN%}fm>wTZZS>MxCVi&h)RVwfRb$GM* z$_{ygJ~B8;;g}%yYB+dmp~uvKSsTr_24O(PtKm>vQt3u3WaEr~C@sBC%e7bF?(Rfu|gVnDSUyg#vgz2FXn%?QZo+l%v%eA+1-)E#x`R?*)xFsJ{@K81< zM+4by=ogBNTGf(>3k}l-z!_eTSY!MuN!vu{X;P*6$ds)&?Tb%e;2Ji;87MYJ$qDD# z&K}6Z@+`~pd^i|nd7h;yfi%wt1Ob#s|Hia!_{PI0^RwCFv{=<`lCy)yhfkh7QHO`5 zXc|0NglUpJc;B?#8J_f)86r;)cuA{jKA#swkx8{Po_JH=>Hy(lAgJ%fvedOlv#Txg z?b>3zz)pu2Es^1-Jh08fW3t4?i^Zswj%OYCRYAYE>*(SvL6Ko8JMvx+8+? zfW0(0bUV*@U2QR*kL~l=HC{SQ_eI=d`08qloC&R1qI?M56f?1h+p^ZfSo^!sY>5v7 z&`e;qC&fMx@JEa+o^G!H(M z`ui+^+qCcfdv6}Vq`K(xrqSZ(kf;C31_4{rqQiJ32L)a0y#YN?27plnHd6C9&SLT= zRL7e0G!fJ3J~4J-3YZ7~>ES1+DHo98MX+n9MZuY!!5d(9MEE{KI@8N^0 zZ4h*Frt@sFyEECDqBYS}%-}a8v7+x!9LV8txSZY2 zEpG0pi=X+6S}xbRMIl6zB*D9~u4rce_scD^z|P*jkskf!6bM1i+O{&YLM%V{#<%0R zCZ%^6z9+aER6Fmm<H-Rg32`3xtTE8JV5_H z3LLI$o%BNzq2Ar5yl9Q-B^K-bG*CzjtrK;GElAFwYMlU2uo#9!U@O-`tU28QffFHGO3a*W5MdCUaeN?M;{G;{3GFs_M*jR5;x(` z$k7vdc_p9fY(@I>!Ix-~7vi!$(iw`ykoh zM-tmw1O9vqkVN0%FUxX}WW@e~SU%5kvB9E;FBEoGADGS4v@Kw6&H zb;oyhcJ4A-WaD8v8Ok)N%GGMIXqL+~%aFLCKD{B8UyEB2>mp_zDR?1fSt7@ex%0(- zMa(=2VCG5O1G;o{h}J$Q=FgY7r!LYCUOYh2eNph?Xom;$HfCnPi)9(_YjA?6YRiDn zL4w*P;~VGLIm`>4pSbcn<#6jUAdW*Y=)9pQ04|z%>Vg04G77*&2@fm5yXjGF^a9EP z^x^=(G8ocm&;=m?4NU{RgW;nfE$#f^wnJGMe+lSQl>i?}@QBD3hj10Ol)7x|rnV*> ziQNYW!`&Seb8FO&yQ|EUHau@}0h5Q$v|I(piv^%|5cXvknyMxhixtN`toSeLV`(!v~J0n(>9i2R7 zdNGuWwb4$f*I8zv#vsvG;gX1RV{I|w{-R<>*KXhZbigvyv$}U*&jv*oX(^`JxW)Ch z18iOFZ(?HyKOx<1rggSO>21qG*%Lz2cy{X6TiIq$!yihL?nRrk5~&>CslkEOq>~&= zDp5WbmxzlT3+IK}I5{P_PZ$TZ6ETz!fNx{7$0!GAa&T~60BaLK(+=?+u-2p^O_Qhy z9dXnXrLnVF#~^L59Bp#O+Oy@NuBytmyN3r~`}zm$(IL}{%@O5w&9n`*Y&?J06#6=L zbc141UHEeupLi48;+nOSaErI^ zhTUnjI2!C8?C%`xA0NFrJ2`#x>h+Tc4>A8m8Uqv=VxYxw6q`iwX^g6<4Gk(&W0{X^ zuC(o3h!*4flgOdqQa~k^C}7uZ8%He$k`{~PMQaJF4dLL$P&`RU!>F!JRp|t!BI~`a zYbdGmh7+;W#g(RtFFnHpy0t;H4g((;gO(UC!8jQcA>(73QNiOlBuxqII!t>v2ApAN z2w&bs+p*1q7*cd@o&JvkuJz<3_lgwr7>A;y4BF8!kcmhgeD^5U7lLH16iGbZ)QAPe zR#eeThXb_wxxrvk!a3bGrfufsswzs6C6AsxdHT(-v)x^WN$D>6-E5TI;M4Vp--NcA zh$o1_Bbq0~A=0TC+%SnI*5Vr;&(G;Zv)?oGYhvS=1`*QNI%Bd-WFly6Q<4keNFqRR zo~1g0cp^oR5Lv;+gh?WM0m6I5?OMz7Jl)wDkH?D=(idr(+P2FIx0C4JPV}a%*ky0j zi6$Cj_LaIt#`Nya;giR2Up!yEIXQm&c5!k#IoN|+M8JZ3jD~)o5`dv`MA#6$fTF6vkm8t|GV-$O~^_wp%j7{a}It$umg&a>4a{239u^-u6JxV@6^B)SV3Vg zj)MY@gDM5hUqwCW?)r%QiO6ZdA4C!hQg9bD9}=YT5@~d#OvA1aOShbhS^}y`gRS%v zw;Y`&);o;t(cyK$H>O=Jmu1^{7REc1r|-Q7DlsWoT^rKn=rZVwKi6BDri^pUPqZx@ zbaacB6g%$S;=4D|@iP$sj{Y-A0LDwD$+?qKptmWdlkkr%<3XuRE4u9A5+T$`SkdcF zvy{>|jnFw84)^x<%%Wh1_B?Srx7dw#UUrLL0?vKcNpx^*G<@>x>Dx~~t&V52)3cK| zZzm7-=)+O8C@@gwFzFQqw@BU^ic}DN_Ri6Kc}IYA-iD_4#(PAnE^2`ilN6DS#!+a)e1OOYh`qsNb* zz5gB?4cT(Z7JwXXYKvFh;x*1urYW;ASUisw5qP|Nw@9CgQug2DXfc5@ZHMqh5SS#B z$)w#sNSALCj+23#kr+3lOA;7aitKPm2BC}yJS&y6`)n{6G)r&O~sF4SgEHxe-Xm zA(OtZ(IKdXu!2}95iQb}r-n=Hv0Q1nAZv|So%J8u97?4iZ55|AVmRqgMRvj2(ARQN zvJY58uq>*mL`jL5^%H=@8Vc!2c_yA1cyIQOEx;89hxulBZ5e{)Tu~34o&gVCoH~F} z`8$3rf>cV15+lo)C0Y)gAs>|I)D82fFe)La!MZ-soq$%{>YPRUg=yV!h*X3_mjo6pS1$iiU`Od+X4RqvI2N* z0tNz~L!TsK*~!*0A4&<)AC1V-^-8}5ihW|=6&SwophXbeDcr`uzi?x?H_|!jEQi`` z!+E={og!FzRZRulVQ_@8zKc*4BvtV|Nr#vgWHSo<>YDL{N{T7-3Wdn-WYK)9=Ln2U^4G3HruWP4@))lp!?j+;k@L&o_NamWwtS(l0 zI`N<<+fYWv0!!q5?Z{R-LE$*xiGPw5`~xi@1Ji*gn2m~GukZL!rwLI!R4hpKi$i4+ zO{Q3Pz5(MAQ1NK9$MP%-B2xPxU}R_xwg|O(8UzF$zMO3+NUnT~D`$7ZX-GBBo5!jI zpS}}I62g@^hcr*JL^3rP?Czba0ob~9&UHpi{|ccE?tq*It1^b+0tbR~RM?4h6$h<* z*SLG*t7VC`ZDE@!_u&7S<|bi9UF{+{gyt}11^E6{IbTBy*3YBAh$1-2yO8JTVy3l! zbhuC;1W6_SI1#7mKchTMk0}|kT~AQ~4=&1ez-16WP}b0Q*1)$IaY-BSARdPC_!;v_aFD+7jd5T_ ztXOb^DM+w`bv#d?&gh&wetUE_+dX`Ip1iK>pB+zG1U=ELWDL0B$Si3z5Eo{{nE@Rz zK1Jg|xwEsgv#av&GX}$3tcX(} z)7YXKNtEuPAi>T-*)z9@oVg{|RRrH)jbTnD<9wuKqg%L!L7ER)lCi2_l!5?PD}*PK zdDKR?Pa=svUG#xniDM0@*!ah_Ta@*@)(l#~or0)R6r;s?D+mIkdC^UC;SL#2k}XF5 zFJrzj5m7oAYFwhHuW?=yz4s6Ph%L(5+1avKF)Ie6(azp(J{}<&;Vd))0XxZ{D8*BE%_n&0;lb^RiLg^v`A5hSTqr*w=kR9ipMT!ft z@e(aR#VHiS(qtFsG8t;6^Nn>y>5fU{u33umC#%$sgfX*wKlzQ#Aiwux{}BwV)Hjr-9~L! z@EryVpGba! z0}BV=dYJBwnFiNh)CvcvO_H#66g!J*kuj(t?&S>wbMy#nUSEKBw2%pM36NZZQj)Wl zYK6E5Vt7oCJAC5yy-ijBjmNpf$4GbDmFE#u#Ds=0poF}`PK-Y!xfjl}7QQl;m_f?+ zB`^l@>#yUGcna2^{hX+k{!<1q8IC~v*f|wazQtv4A)AL^lnfKHRnMkxTko%#CDR2;lrK9 z%eSepT9arH@f*-q2kUBy>mS_u-4C67s!B$|aqyx#lV=)Z^&(-lYs=O<4q}U6Pi+zO z;FzA3EX&@1|NXOXfBX60{^jfAlfVA?hwnX^?u;IQrq(sK!kq>@3xb79D({imA@+_p z7x%iXaP8mS{O+YWzw5zDar0g-+1c6IgM&wd!N3?-6a{veNa=fdjY2GVPXu#uZrT!* zXt+^p#vYy|TqodB=nc#x9e)%(BdBX65e{6m){-y44Hxf3%JmfU5_@ak_l>ZLE>5H` z%|fy)D#3lKv^Clpg$fUn1_5z}*08z?Z3};tqSM86LRG@Bohx+kzIC%Wt{rqVp6CrZ zTzmlnU+m+?*|M%?t7Ti4A!XnF);AtJcmTIpml#B64rAE~iEzO>H09blo|W}kzwmvn z<(MngNs=@Ii1R4YeF?L68Q0cx_}2qXegD*VkWt0CB**R%dJt4XC<^v)NpwRvN{Qr4 z38G$~fX8ysTI7(DNbFSv7o+JTKAOpb z0G2>q{V%@800oW&lX0>A|!2i=$65jEnRN+ML~K=ni|l`Zh2s&I8JC8{s#4 z<0x%}>_{dF8xGMbEoRI4EO@6?|-*Ad-MF~Z$A9^ zmoj;_^JoVVvf~iH#8oW4N4;-j(I>|BVu$1l-D3Q$D*+V3&udJc?qJu|?5Pc`Fvj%*X&f2B{d4X+fkSRF( ztsi|KkTInx3iYgNp;(1B3z+%0-=CuN2;4qjuh8X1gg_MhRZ(FV`c`(nTrP=(JIj)< zfAgF2!9L~?s{$2tO(Z)~de^RXao5q}Iy;C(VJZfn|1D!olH`z=M(tlv0jm?KK>6@y z!(oWAB5}Z$^%6#qZ&?Ryh&d2b0&jquPI2yt`w64&)OgJhjNGQk*rmpOf7hR9@!cz_ zq37G8=*X@wT%VkI;BDch<;CX%w=#dX-Jx)L%AuHqO@gRN3@bNeD}DU**I3gugTWw4 z5-=z%7D}mnZ;$Qnf9?J6m}*%r%T=`s)(b82B$Ek1VqOFpB)p&p@&Q7Ph$RjEA$jgA z>HTp@Bng=7%DOC;#cGkJ=`bJG%XjY1;YV{Q3t^p8xXr&BycN?0B|( zeRT3rIj^jDDnzA%cr#sai}5=+=i>DpkumXdCv<4PZGQKPTf8o-Ns?qRs22;iSRi2B z-QBeo(c)lt+{OAa$z89`MqV=-wWje96qW^e_k>KON-%v4nK%L{TnohU9vT6T@}Uqw zqm7;tRq<1Dlu1CQm~yEEX=A17xc>sn2h@e4l8d7X9gN~Aj#8ILw^|#kxSvrGICx9nu2_=aL z1nxCLwceRqgeRofhk850)5#~*T?zd%4C%*OhY%_mK)0Z8fc^9BN;J3TtUG^v!7avY zajirnAePsS4A@u(<{*HxR7UmQq7#;U3AM?aTUoB8OtnhUfeymC*0gBLfCgc!MfrIG z$TSMgl?ukRZR@=Ui(HbiZ~ai8o{V0+URAzsYT>w+sp2U@W$HRmI{}I;c+3c!2F}qn zFk~%FF&Jdv9`ufx#x#|;txU9*qP_kj-DR{GoRU5e<2ZL$5gRT^M8129KpZ@t|l-J|YaF7g= zWH`*_&WKIMVfk@PcQzy*aYY>`^0akD#jAv91QRDw!L{G?)|s+eEf@3E(n+@e^wGC| z_&qjCaJgEr);fnKW(eHa3YxW@ZqIu3kN9^W%^WjF3VLGmL69;W%I8_CvouIRgrF4! zx;jGjzcaUpI0)!k#3WoOI7w0^V!&oPqAiElgd|XsvYZ$pZ(|#Y!Hd?FCN9x^t{-Gs zH&)!ux}#5p(7Frq36_S-5Z5pm+7UgyI}2%s3spd-9iITRE-0hvbt&IpT05L-k0l8R z0HgRg$`L?5I9Amx-(f=`ciEm(%VY@>1F5W24*aL2O-A4|P~}r@4L%cu9VkqJ#HGVn z1vQ=G_N+=)EV(nOnTl5assyAg-4!C2(eCLDrzP|jyvY404b~fZ+|!VwyDbJuZ~Pr zjz@#9z4vTPTW;_B$I54{DsIM6A_{7QbH?;~EI1NbKrk5iTdbCgrjmJb@a)Ot555QA zht5Q0Y()u9>OEldYLJt=i3lX}jBCwI`248+M~Ke|-~gtT18|_yBq&e`P$@RXZauiR zJE<+M&uknMqB0{uAr`(&s(@K?ZqZfaK`IWiBGS^lrEM;^u79{eac=iNgGbxS24+lX z+q$l!oVs&vI2;Nga9yS;3g>AGq$ku|->ry((vr@e#c0)rhXqBF-pJtEgOg>bMj@LL zBFi$Ij<#hdGv+Ih%ugTZQl*78O@W%ybzkj{4iViyK1gh*p>CT*dbKlP2M7D_JsW&-aJD)l2L-H`WHDb0q2dwr$J0Zk&N`!Gm4){!`W- zGZUC?kka8=bsi&44XDC9;QJ8Rpv zF3S08xtb#|uM6K;c6gAD@B3>9YCzvinb2Ogo zNb17_8|2jVD_vV^9uh5~8r)Q$Lr}o|NB=kMbv^$gSTeZzByRxCKn`(ZB%c<@l0fv( zp=IOzfBe7yU;ouV|JP3+rjPe`OkMxM55E8X|HU7%zx^}T6s|0*s)~toJghMbj-1Rf zfmTW(sM;C#3YOZAJuS*r**HAQOr5$~SmQkhl*=%Z0K|BkR_SV4ot&J_W~{C#Kg*cR z*njayf4FK^6`>74(WsPjkUDtsp7gtcq6dDu7Kqo5d zp;2ZbTPB%SxlVGKPFONxY66KBIRw}{N+eUGgrHNL& zEmsnSAky`X&N0eiNBj%HxdCKdgSt^xP@3%?;4uEhPygcI{(Bwxcfa|y#p(@5^!)|H zVQbDe-azT_n%kgf##Xr|7MiUWCUE104S=_^(RA8cQI>vL1|PEB-KUfHzs(*Uh7W%N z-0RJZQU|l~joqwaJmJ$Fb~X#M1y`C)rl_UVHCrx!`mg_0hd;ZFvK$SDMOCgARbv2w z+ua{OJa~}qP1NDO-kpH=*IF~5S5>98&T~w=vO6$A8W=Y&r*dyBYS}k_xn{N2A_X5+ zn&KAi!Vy{j^rumv$xtn_ZCl*n!KxIySDvMNyVGoE3dBWIvwAiE^kdRJCyix1Krf#- zYgDh42;mm*%1H5<{!DBn$9iW3dhtK}r~hT1W*`6TuV218&Xsun>1+A_`5(XY!>3Fs znIwsGRaG@jvs$gZ_mjzFLkJc(AJiGC)YuyWZg3J8!^Y3qDL3z|6cl0O-WCEY%bH1< z=cBb#;1adjEb^Su6%dj15w^Fc6^Xlf$N6}km#zbtNN|L{b3l(ka-J~`wJF9j8h}Yi zXhqZKh@I$&4Z6jo8ogLKrf{=$P+@^K4sul{RYpO4I^^8!w);(OogWMa|LM>FKM`Q_w2-uVLMX4y6@5o|b^?CrLza@AC5yxi&tU0Zl$ zy&)$;@D55vghEKA36alKZEevQ7t~-d8IC5PJ#Woz`x{p>==HOVot)y3Os2?4m|(L7 zJ2`FUXHZBXvB@i2qVzW5VW2U8uO-L(0)VG7gJQk>T~VX z&|N0cghxgNEi&bF+aOb-s`vizAN{lU-v77%@BiU1{@uTmj4w{AKmFuC9ZrRo4eE3h zJjYnidyno1*=F~S!J4_=E#3;Z7{hhIo>Pau0aevQU}tCZ@$O_gorVC}Ph(6vNJpa) zi6H*~^#xsO7Pws;<%3GX( zIC_fgHgPt+%M>eB~<=*7Z=5(f~J8V3~F*!YBpS?VO z{d&2aYbs}48FB@h!MT)kooK5e-pie9m&>-Tz4RFi2SVt?Q( zn;Lp5$UK9`a4@AlrW0QRL*4o7Xk(_mS*yTI%Z#OQQpak;nzk&84kv?4*Gtr{u_a3` zEYrUh+#(iY=%ZXC4!nUFpl%0TvPaMU(Leno(S%Qb_BWw6 zp65xDq-ol=?Q*${J`uTdY(`Tm)Zx5uj9a|!8jXN}G))oQa$^i?#MZNuy7x4MS&;2hstdU79OWsKIFTOb3gcW*3?ky^;9Rw?!cZ%4If@NN6V8(?_0`T# zvurhAeEi`rs#Wpy$&=w|q!?dSbxug{2y%on5oTWKKQJR4)wdx{6)7Y_2zAq}&gRP{ zOLo}a!-EG;AG7fQ4zaDhB~qV=m%uw(nxgKq`%9f@!uEHcd(Ak!fu6eHF@}_8Y&giD zKAF_b$*a%upIc>NjET-P(da?jZhJz6v(fc@w~CFbu3S_!R{WL?y^?I5*LmD z08%VTL_t)r6)xIaS!}g~XX&!DB6ugoiK~*mc=hJvPv>Vd=iOwQt_RO9t=zkm2c9OF zH$Vv0LgBVzM)MM+%W9WYA9w<8(J#TtdcDQI*-fudc3Tsuj&qEoduI5C`7{F;*ShT< zfl9!)aa5%!siC+MY=hQ^7NcSj_(eVuR~@AdegvDNu#a(oZ z^u^EbGZ0_^f5Y`Y;{##@gH{*B~kXGbT+a>YCwkF$eEhuOg%OO;>Es;1<=LZnRttwdppNWQyHNK-7P zs1z*{AH=%D(1}&hB@Xm1sC&F@YBo&R-p+Kkx0oHVJ{GuN(Jfw}7@!y*F(YO-yKRf6 zTp9~7e3IwG(HP3eDrIHc78Ml0*soygug`b)P_|wy*~{0*zx?#<=tKb4K|D?y`m75p z2Ja<$f=ozD%<8Vit>3hDyj3&&%@t2%!hsOds6^GKwHTw-lF`U13T0YrN1`b)S|o=UH{;JcS9jMvG z;Z`2RaJv8LXMcWBHiNH!i$8fk&qk|7d3t;{$bf3-Sj(w%5-W^@K{u`#-CV4#0_LB{ zJ8_GcF(SN;)`%9p_ibB-z*Z|ZJDbnv^F0}*Bw)6z@&1m3j~dNU3J>WI5Wz~1TLj*F zUGAi45p7D&bEc<^jn~9z#P=21J}9t_+VznY4Tjn9BwkY6W4yg9J)j9OBFd%t6zHx6 z2A|SGf5-t?=1g#22mbya{2f=UUcY#`IGeqC^=dpGV}HW*5g~rot(7QhMO91ua7P?^ z7Rm&eY7gP))$6*dMkDs%(c#{M1F%teyQqK*O?X4Y)pQMT`D}k38z@p2NEx= zv8n?(BUkIo8kbnq~SVpf9Ul}^(%EsM1a0G>c$zg$oE6LARfu>4x0#b{Kp zU(fc&!Jz5xj$fgu&&HE~_Rs#SJgB#y{Z+b;eXFi(4U!wXsV!=)ql1gLL?j;#-U>bF zJ*U^>7Oz3}u7RVF2zHpgKG2(U>u;<#zh1yfxTH!F+sk`+SRJgzjjxw z%(4M-qDN9h>mB+k!P<0`K6~=?^qb%ME)8ymby}gHg zx`$s}t?H@S>UnnE)v@296Z)CHRcse_&>1>-pU8@3ZNYCt4Q1DORbTcExV0 zkHs2hq&H>dI8xPeP16tMzgo{yl~DIhqS01XJAVw|`hbw{)qePZNOA$r74 z!G@aqH(1xsn1aLzFQ;ok29^zS=Gp+UQuK__v?EAkS`~VK`s~}^n3by!fA%-?`8?6` z!S0R>*rG590Q7LD#~7F;tRjsTpr#SY4iPb*E!&W>>1eb!-5DkseyKLRs-R|G7jb^b# zuQ-y(xhEe+V`q^!4O3yr4*G7twL0#lXfb$&4rr{Ed-%kF`UV%qHd8(1qXU*t!LU|A zPPW??Z`_#$X}c%RcvM!SSVv@^WXu`X6sx)j+z%!gcr+y@I(42zd~%#=+pJN~$U6|S zN3sZ|c$Ow!iNg4zs*O^nY`qN@1F|9O+Tctu34#_5n5a@<=)Q=HbD=GPDF{6PW zMGXBNr45s5xe5YOj=_7iu2CY^OOuRqVNKxNaxTZ?X_gHhTl4duy=_m}i~4P1nxP14 zZ!Zm21>>6{XhqmV4J{YSp)u)5XnF}P6do|4t36Mg)Qh4OX{MA`V#E|7w1vhHbYdB4 zJjUa-KFS@ZMP#9+>N_=%iITt?h|7ahL&zl25SUGLyIl1!{g6Ul5Ry4-Y-6oUv`9x| zmg(}-Pr$azLZUTGv$WN#<-DqG-4v4B^ugZYH{SpB#q&3xetLGam;B(n#r#a7OT;mP zNF5gI4KpG4npxbKQfj8cs$Lx(zd4?r?Z5Z%Y&9DUMqhvWHMaAB%}Q}Pe*penFCmO- zSlhAV#1Z2-!CRZ-G_szvHeV`S1pc7N$IrrAo|k;bLWQayxt7P(&49zxzM`;wOLn7k}|rZN;8E&JP|RHlF?2PyXipr;oG{c_I=e69q;SPPc^PB~z`65R;B1~N~S*G68`^BC;HNA3nK1PcQN^j;|0YKB~TS1T~V zXq6=zoo7gXwzP}I%q0j6P!nbce~prA32d7{kSGLC15dn21=Aed|4ItyKliq|j9-JQ zJn@pBK9OpW z4Mszyg{iCM*(stsQZOMn4lX!UaB%3AG!jRhLUfDpn%m%OXGE$8gF%{5|I`I#oU@J8 zj)}`%k)Et&1KwLt|6QolBb>o-A;?FhMBSYQ6WbunYc}3vbr@83H9ISeWsM&si5e=9 zzQu49liL=48MWz_jSe>AY4PWr$aDKj=8UE2Eure*=;DK>mf|8|*#r;*G&UV5SD{$x zAoPn4`^}?85iwM7P!f2oz%3{%!MPUMa=cb-qG)_Fwd42s&zpn7;T0QLK)kioV~5YCDz^)`G-c2`L5( zDD7dB=MLil4J!(Ebi{_kS4`>gbdqI*B+Xs$kdbca&jBb+WU5gR3aLc*kq|`+%F1ct z{d1TLzW7)Yb-JXfLkXc9y844>#*nYK#im)sUd>Cw@qCx3e#4PpFF06VHwCH zP*~JL$}BZ-k1UaLJRa}v?jGlPS(YzfzI^)R5mG`-26*&Wz*dhoAX0A!jt5X`adNw4 z&?lM<_V;($ct~Pi=<3u>In;N91vx*4aSXg`q$)sm_w*Q44SIQYRvM!doeoo5Qd1Z# zyqHj9ZOu&GLO=~U#kv6$W!I;FW0&*~P<763g_XXbEi zcZ7Y}v<=tT@-DWG5=9ZD0LWO>wWXrjR&*9iquoHk#XkExms5*s#=Ny@Xu3Z(NiTL<>oD-q2SrF9{j=I|KB`#`d5GaZ~xW5_?JKV$=Tk7J$*9y_~X~3 zVOsC**j+Xmjnkx?{eX}kir+-A2?+p0p3w-%M07lX=SdlM@2K8QC|qo_mb{IlB0AMz zI+85-M8@23sOrMb=WI4(uU{29e=AjzjdQRz0G1p9L-=iAH^57ZnqXAPi*zcHxdDSQ zL@OUhbT(b`FLO)@JS;Vhio`xzFfkh?ExU^=gw0)WLhBUeCDBxVwVW?zXOAC0!~s-- zp|WSZHA+aW6H%fNDakGF@9)1JkLzXe^5x6JhX>etC~~1(%UN%Q#SCSzaEtUOnRUk6 z)_JVc>EYqwLpDq??&%umo3^cncXeH@`)MR37jr5qE0;B@dxP%G&Lgfe_o~}-MOoEM9?g2qZ7AczIE=y&d+QgCD5e8u{mc`tQ$9 zj*pI)Z`&{$iutUXFUo_RW;Do?R0-*9Xhjl8%mPDr0Fe;~EAj)H5nFSmQsNww8X_9H z#x2H12Sa*A4ip>*gJqVoRRiws)6-c#n2b?AOf520_*}ZS2(Hp4^*wGe&9o<hvPX`Vw+1DA8cSyPXY`#1_J+;>MyG7&4ExOJvk`Z>w?d?zZAMJw!q%N2#7zk#}T}do$ z0y+|CjU2gfTlQq89uC>T17N(tBl)5%=5yl;F9B2WddO1&IMje8!_@oIwozaQDfoKD zyHXKiYgMa7>qH`Dp62OjI37%Qpw*S9m{4{#^OX;!59o@5m=^VuJBk+9aNWpO*YWCs z?Tw^Ls8mX#Y6uzw)fTs-#jfRW#Vy94-!qd*B3sflCbVBXQ=gy4-ED1gT_wG;uEFG& zb~ESM&H=_h>zZY|KmK3-Z-4Z|zxyx#fB)Zq|HprlYgX0a^la5OZZ)rmqrouClhmi9 zkXW8&Qizn=$HIx0>i3bIJaP??Drfh%qwL>y5*?KTVm3{$LCH^~>0mta%}&7z{L5kY(V{a+qW1PC>|lasN-<+?!9}Yu!F$J?`LT2oFKEZ?qN;X_iVqvO zs@=WZIe((6a+U1th2R?ycLFr8w9q;if|+7Q%0Pqzh%#!iEVtqNxUiy5ECx#B;u{VJ z$!Np|ge72tEoWdYat#+&C)iw}qyU1CO|tT<83i?)k=BmhQ%a0qQali8$qAiKar89| zB2i0i;}*|>nZIwQi3yN%8u&*s;&Uj)?wp|S9F2b&$809(z4nDlD@Yq((E4_ zx4(Dz$uECyo5obDs;--6o~?=j++uieILI8AQl*d!KsS!!2GGQA#6>Rr#GLv9A3^uH z6M~@6u%6E)WQ-JgIE3W9OqW%KGX2@pgPHXP*7Kl?-p zJ3yqP3Cd!Lqef{VSI&8Bvm_Cw^rT|CsD`FuP$TeXYh#jq;&A3k8yDbxFDmNO;v654}R z*;eS5xHRzGf%jZ7ca^8_IyUbh2fGhhHie32CgJgm8C$KYa<(c~X(lAL6-8_V937Z1 zGsyAz-xCkd8GSIxpVz7m?Ag=DuV1|U z?D*)xg9i_vJ!9zrti`77pZ4eI;!DmZ;{p2Pt2x+q$D^-(_dE0Bx1as;qrLH9Z|D1L zIcutAIv%c$-i+RRoXnHDX`*bQLKmY}=Nmt%muv3?;WR(MN}+Sk{;-&vXuD%dk8ahQu4vZv>nO9JZ|)p<2np{+;K7IO?d`Eu85Rp;%pggn^KD(JfhJbZ ztJe?Y)%t99ayBGn4)K{gyi98r$HutjC}SD!jbJ9~?phAUeD>RHvDeHTV3^X9%#iSr ztC1Qn2_`xfi4S4EwSP7o^!s=Ep*B+Jmkcm0eBy5G1`!=rVZ9MlLZM1c%OC{kkdOS? zA9EI-zV~Q%@1LH&{qFPUpT2tea(;H&l4NOFu;pTTda``|>S#C|CQ0)C`|rnVMM;$= zoz{xves%hixB|hK39L(!Bmj5gz-{v0jx`Gp)Ob8e#865}JPSIrAcap=Tze=J73bSFYw+&pXv&2w~?u`dShC_=J5JhNI z(eNec%AIQg;Z9lOt>Ipz(kN@+`T-jVEj%~1sq4Bf%T>{?cHPk0I`s_;4GVQ(HHn{? zbFk_=F)zXWy*XNxB}FJA7=Jm-Q=yaL&hq6aT=yeU63Doz%Oq4nESi+n|4Xu00Yx5pc4OMm|8q;?b=J z)qcxaI6Ha0GZ_!}cSetQM^E;4A55k@qocPkUw-^4BW}~Gis-^ph*AhPn=PW2MWiN! z_tSu4Cm8sWLZGssl#po$mzmEINktZfSPk^dOQkYCRvghB-2lMuO0-%M7V4_t< zd7=k-x?J$GSk7msJ6Qr9CDJ&%=6n)L6Vq042cpByEbwjK*}7s)!CVUT0DEbxwcg#`MKvJcQofrH`MYa(=@yA{-O{ai8J`H;B5L(CMN6=^VA04# zE>=^T??@$w<|drw!9IMv$AYn|6L(ez=Y(?aee==RJ~;ercWUc;wJMg&#cEYl6|#Tl z*vG$I0^BNb3xKT#KU#A3{=-9!*}+67GSOL*;@%e6{xO#ZgeO|c90f8bLl8yVK!Y}f z#@V{A7gbSIb=9^Y^Y?7EG-Y#YS}!E%Q>4_R!H%`7rnZgq2IO$?*@o3a(l-G)wqr=& zJR(TMkA}q0FZjXFNuq(j$Ao{HYjw0do1gHVoe|4YDhpyzck4K>72)7+9rhS}H_+m1 z$((8HYRSqnv=EbK!CBL^OMq@AyI5#`cLAf9f#`}!{tZK~H1`^US{=WL(y}5=rP5NT zm{#N-@NdjQY+au!C6-g-E`^3vi^smM%a(9p1*R$smL?^& zCn>5B98I|*X9OVyL|8)3(epPvA|FefwJv6xRR< zS~&gIhq5dUYbJ|@9E}8Nm^lz$v`J#Y)-f&QylI+KGGjuPMSO^h`WUTk8*k`S zs~tV$UK-=ts+z54^c%~w6-!3Gs#mNnnXH_zg0zv3{2d#1Q$nFeYEMOFBniMbjX$A= zW-APPNkXb{J{^oG(A&B`X9aEEG${zjh&2Rf>}owD5(_f`c>o-Ry&Q;z?t{c25iqaj zZn{h%ur!s^>2$TKS|4cyI*r2!WRygqKvOQjUlKWPN9(s)3CA3jcBa!Q`cYuey&Xg4 z@2B0ZTSOv!p&5O?VtD&P&xNQ+At%W}4Pb%x!zE=wO&xF>zk2%#q@fZ&*12z+rY=tx zXUCQQ_SgPEC44Zz9V>*&vJA}H-k+YHM#BB3Y0wF2k!T0Qs#C;VLR7@;wc6klKp%5e zPZYP*!%0Qlt0>ERkHS?X7(q-*AzF*N8#DHFzAQ$$7-db8_%s=aBt?17+PbMr<6pjf zIXOJsQ9}Xl418LQ<>8~s+qgw1+m>bNa5!Mz)pg}etE^$gYR&t9H9qL{Ndc#^-nOU; z0lXCXn2ddk4TeTE3|#lIZ~YtNnO`OwOuz&15S_%bNHL~w#`*cYS)8#y@ju<&Wrq(R?;Jc(B#dQ@scZA%#S5$T zaPlBYas-SNdy2LVJiAg@jE{RgBdGyYe3Yv^)snZZ#SL!Twpb?l@B$ulvqs^9PBhUw z;~aVg!CA2CIEdI&>Cm8IM1z7NM{x>-606nm@$r0ahKxbGW{nv@nT^6m8pdsB)r?oH zY>T3r7458P8?E&yY2MBlltWAd$y0A5C=8IgciYwU70siqxpg2YiwjTm|6; z8dZ^2N!tpv`i(?PI5Et-~eZj^PtkCs+voc}Ga; z#R62mKw|E)qES_Y{tuCNV3?Llis$9UYg#VIB3fXuPQHsrHZR_*#WLd24=5zi| zyT$&Cmr(LiM(#$UoE%VJOeAf;l>rQprY4Z!(aaroVW*(>#8BYFRVj%|N($i*55Lyb zMYUQkRwef#OGgKf4s)Ho`S~jjov#p75GqMDYK2nqhtHlk@Nc=Mt(w}jrZz2dq0?7q z!SE}>i5nTu3xFXCUOfpyM4k`HRHBkB1#CsDd^Dt)2;MqdROM{3SS;$MVt@4$_UIuC zcCM1Wu_P5!RPFr9Y&PDjy}_&@#!~?2G2Yfp3+f-jZ*Yc*bv#B4(x-h9tTPp~&#*lN z;%zs;zBEOWCjGTVB8>>F4Xm}H^}#rBia0M&o2b35Xn&HDryQ{`_=^J<#@n(kmW#Rs z@3qbcfALp;o#$zqC!YC5ThyivBIIIZ-k_mU)XTbA+7OsVQI01}<_x3>)^O>iv)Trk zV8`n)q&GDR!D7v>=i6)ssK6wU~VGr64P2JFZKX*Ja~Dm(@G6fHp*FWp@FajPbX^C z&=v~OSwJDXbJS_)h?{+WVitebXr9|_GaZFu0M~0Q&3D|aYmupr1szTOD075V^6K>& zQQuNs#}J20ECQl#=o*!@EQ_>lSF8$Alkk$M;IywKn4pRCnPijVv`Mk70jbdwAwg^C zyq_0g8H^lFL@=^zlAs1;(7$%CkIukKynY^jR&plXlkXiOf4Yp91hq`tZsRDW4Lf=O z{+CXfyL*7q?V>=Tod=+TlXN_owtKm2+3{#qSLUyOQvT&%{4^c1-A9ieK7KUb-TTWw z`|~8-{rU&rVN=f9*3~6fJWs-^JT}BIk6?qO#K}W+isMz(~~!Y$v7M2uJViJtg5O(nvEyJKl_jWe)1H&*)!*oBv~|VJzu!M_JV4c z<;%~W|J6@_^86Q{MNO<%$EU?=)p6OLUrbEda_B+i)f(r#P^lZ{e6pjmk$rO<%zR~M zt7M|xVqP@!{evlc@-;TQAef0hP=_jw66sg3YJjkFu&{Qm-Y;6Q*hqF;}~LFCfZ zAPzf|ic#PWHB#E0*Z2RGqvt%_#FghIGb&V^8;n4LS_1Vuti%oJ2~th0)98Q`y&E~o zU~kALh!0}VZ@qZO#=8xzI&W}yKiGk1+#SgmFUr$X!xj2rG|Z?8aUm55s4J3$yCi!_ zoy?sd{s@31@7dWaW*nDL!m@9FkxJr6CrOiaNKabED3r(4qxbI+$xZk8 z1}hUDh`*k1VlgCj)wJi*XDS$C%Z_q|inuL;6ck;OusY5Gfm%S8nOL}boPH2Q6RlV9 zL8>7&Py$9S1hrA%dxG@|K~)?TmIxGtJ~I!XNFdD>8-j2G4Hu7=OXNSLF#@oIV&B9h zgjX7RV*-eEfIuK};<#0Olub2m(W)_Zu&S69Z^Oxaw%VWWRLjLjKmYJO+YBbtQ7V>a zCokW;Jp9HtJGw`(06}d-L65?lwr6J|q@9sv!O8{hAB2jAnJS=9+J~{=EY&=?rY;Z{ zqo?m(S+1I<%JN)k?R+TP*2J7$CRr-e5bDL*?B$Eu>zAg0#BiF5Bvtcy)VjRnsF69) zYzy6ZqeriCr!cgh#=p@@T$3H^_odnM^=I8)Z^I|J`B>2|eGes>soCt5 zt*FMOnR_Jv$aJuh_A{gl zG|)ikqN-p6#ES6ck-Q7-*N<^H08B%MpIM1$5&NOLm^3&?L{#)VP(6S@S|P)9qD}6z zR4r>>H>RzY$JNTqFn#dg^v#R9T$*O_qyOwb8}IK(YtClJtgZpA=mxp0X$_=$I)w1n z!Su{m?cnT;Ef%(I!ANdQsB7D{=~_qoqItnmA>u?w2q7h4`D(RV7DZ@VRG_33YiOsG zGWTrx^7-q}e)-u)e|!3-l%DNQ+1@xGrP5FG?WC0lIp7J(l|b+bsck+G`H-c#7qXcz z+0TEm^xVJyqi?e50ek!W&FdE;^Qq&B5+Kb~7zSjL1JvF>l8U=GiaxO$tVGC4Xy$ig zQAqKtF)5K`6h+iE0^Sfkli?$)NbeLyp=g_;P;`Y_Yv_K^PzQ<%G;Tl~Xt69x-|M$( z_>bRsYKYj_38Vpbb%#tk);%IlREy7j8-!$9yfir?+w2<*dPC3pckfv00W{<=WS@bLCMjZ-Ra(T^#)L;%DS`TVo^A6 z5~WZ-Z6HwG*0HvAl_yXqnJWrgwJMS0;lyRqFDpoS%vQ;GFwDn0X*SA6OrX>m+VZdd z<9~Sj=_hYqF1cfm9?^cL5k$={ z0HY#uf-(N&2}W$KO%3sl8-p$(VNRu#7|cx5IOv919m&}8l)6&k9DDKG!~d!Hb_VXF^9G@YLtKs zF~^gPgh51LP8g5b?Z&d!ieMBff5}eBns%^i2CK4coGF@mm@>CwuRebL$L61(y?y@e zZ+|;~_+)^*4^w8?kq8~x7{{!Ub-V2rAscP%a#k7V|kEf9M>I$+P9?LzY} zgsQ5><81qU0s5z8r+|o4&7=xac_F+xaYCUQ_UbkB;rZ8>=Kc5I8@=~kX6DWoby?15 zOCEww(_x;=BF3WSl!uS(4)oJ$m9E=u?*iG1PhxwboR3JR;fPjF$;0A6%<`QgM7Hzfk zwt-BJfLtI2-K_Bj8cbZPL~5bX#Z@_~%D9~G&LW=bhHep)xxiH*6(*e^FX*uh3L|cG zE+c`V?ot4W%4lf03lcSeI37cM;7HJjLolY$0ckV~R>POUu_CU%5RFnMcQqOn-ep|S zc!PJ5b7D<jYmKnsCmiW0x5>G2R{>pG+q8=~?mWE&J;~ zU%mY3PoMwdZ=QYQJI}uT_XIpc4y7tBbCFpgt<}7nNV|&nY`iJUvM!g2;6o4w2*IpI zyhW-e9gSPJBGYZk9J=lbV5h}wxm?bVkIS>O>1YJjho&ly>a(-6k3atS!w)|^d3_cZ zY?QGFyX^6k?BV_-A1DJ~&$+9&1&XLR-+DeNK-PiT=N9)fe^ z-qlrMnj(&d#U?cQOoF_^7iKi&p#n)FBUAKu8ZENh)E2pyOcAm4Wn4A7CB)Tt6<8P) zDZ?eCKW(WGW-*%;90p)9qsD<7hx92;t$2g54x@5#k(j_E7fJoqAELJx8RvF51D&+cqp$ z#eBXjRy8C?@A8}6O3?`=b6^!oC7FIY#bm}5QGIm(Nk2G70&WW~{*qga4v|WQL>Yi? z2C(0VJCYQY1#l~L&XJ1!U|x;5ZHR3n`GnUF+#y;4=G&p-2|gMG+&I1vOd@J{xM2tp zlUlEbu4>DhwzYBCIU<6E7!A_KqAZ{l-`!cvW_~tfixc+Y#~)4iKYI4;!3Q6F@bu}^ zy}i9W&*5RSo}?8WNFE}6OOmI#2Ga#v45Y1f&G7{54kp7Fv$MCaKl|vzw{PFJt$XwQ z(@hK!GqBxJa=7OYCTw>npG+psx#e={oEwcs`CyW!J%m?YServB5_2F#-i_mEymMhN z7_F8?-PpmuQ`{gI)N1v2`$k@)VIDGgx-Q5Aiu_^1>a#b!u#`D}S2` zi_yA`_eP!(Ucd?@UJ=QFw68l6Nz9QO{#WRh~7bDcAe*{!oh_3Cv!J7b(1 z-u(Q(@8@d+K!+&Ww+tNmJ`u{d)w&dhGb zK5bEvp3J5HN# zZ1(cy%g;V@$EVDv09&L`j>`vRX__jfKKS5+JkLj?QI=&J)`p@g|K-2=*E~^!!C+@+ z=kW0G$&)9Chllxij3Ps(8FQUYwr2c=6)(>(|R=69UUJo~G&PX&rMi zqR#4QAe1oQ`o;q(4Z%8;CW|CvS)ZG>(l)xX05wq#f=PFh==8F#TfxDUYfUJM>hyGW zdNQjDmuHXy+}mRZ2W)R|G8zriG)J7B597w>ZA)^aBvr=DaBsjQCVx5}>8O|u< zHp5A=sKN)>S=Uc?xcu8Q{;F&E3V*W<_hYBSelv?&M(=dn=r`NP-}*Vnx%c&gIYB|p z_CK|=SweRa?xhd_@F>f&DO+31L}nTcTDUwKW1H3)cl7EFu>Rm>Mu1H?VHtuRP9D$Z z)zQ((+qc#1jJOmWE;kI;eRXb;__S}{ZXyncUbqItR<@q!%&MqFvSn3SZda>hu(B>? zB#gQIZ{AoqSK`}=&Jn^bq2a3&>6-{6r}-8n?>xAwsI``k`qW;;Z z3)e}uU7`_v{=Cu}EkvbogV!|}t?HIdcJOT^;Dl^68ce6t$z+n}InD6FKrc%DN_5_x z&zNkB3NEz>)*>p*bGSu-HVD@i-Cu`m6J|0ITo{n2X`w-=Eu;X2RhABVqB&_=RTOfEXxiL4PWZ8x4ZC9B%T)H2iEo=TWsptsA)Ha3$fsXBoZh8gF< z-ol)hM~k{_{Bq$}D^SdEx)79P)9L74p2#mY5P{tEwQ#%W!t_ijpn!7qc|mtFa?UMX zv;bC5!hc>|gA1C#&fBk#d|iZR##9b558xjLBOel)G9saKf{UX^By)}XR7uXpDa%IF zhYyK-Ec>|$!T@H|L}H89wBCm&&$_9P81LxR4ac+#5^b7kSzWQ&S^4rsDdnq+B1N01 z2op~<4kIys0gT{i!iU4*U@%CMWHOm-)Z?Nj!lk3U=e3YQaFB0-9!3ccd3c5b<tMTxfz-fhk&Ok!6W%^qGHYN@ys~j=Pv|It_zCRm@*2rJ}!% zN;TO06Ce*zC%}7S6Y?YwPsJg?bghr0btX=gIL=0UyF@C+y8i6OV_wI!dfl~l-SZBE zeK);6-!bX#?|}Jhlo5MdvTIxs70Rw$jY)>Mt~JaV))-@352X&DB&g01Qrr7hcq4>O zq?36%0jQ+)P2JXI)s)UOruE^kK4C58J)E;dG|{M##fF=z?* zybGG5TjqnptP0({SLbn1bAer2(T`4P0D#82F03GwqB$ByL}Y+Op@eCmbyEt!>)cH3m@_m#7d* z$-epYp>?fkY}J%ahxlv&3}dH3d@+FS8_wgrO{8=zB^OM60OB(pig{ckCnJd|I| z6O!4r26S@Frg`nb1A*RroJoqS>T~PF7a^~?x_X~S^hb#abzFu_$}}Xbn3Lx$+j3c#%d%Ygrh>NKV$Q5)iDWuuS9Y6EhNvZ>Gp(%^bc6aC)7BkZuoH5MvgRa@J}7;n#xPvPFU6dHIdu7nmU2$p1- z&XUP+oR0>hd?>V397M9FZaoKqX~4LlB(-4(^`tvfMAMO|>Z0{tzT60&s?Z=FzJvpH zRn<^mn!;)5>sg&SA=G$!sPaR*V<7ud6sw}B%Cc$0e1^lkK2z&~J%tz!X}%8pSt`|en$SfcUDYluxI>unX0tlx9_&5n1!~`OcM^lVT?WKN%ETZTfsn9jP1%~-VdT%T`SCn7 z3}Hv<+5+n9P0i}Sb`P0LXy!FjgKRJuPSb3tbXHX6tBe*YdtFbRe$k4#af$Bm_P&l$ zy{!lUoF|c|q{q#vp5Zo3wN}{%?DACh01O5^59qEM%XN3d)kq@U5 zAZ?4oZ>d88(52&Up}Ci-)*-tHFaORDz86{1+O~~o3b>3&c=nmnJ?;z} zhD?Z(=ictfu1TWnzF}4sOlph>eH4~yN-g;d7F%#3@x~X+c6Ksbo-9`jM1{TudIdw_ zgjoVihp~)w*AJiUTk!dVof@@ul}G^qW8=P3w@9_48xMP-#;1tbrt36sRumF1KSRJT zjSva>5y=l-ScU4Du|-Y7Y3g!rw_`{dJu20356&Wj0Ej%NaT?$y(IIzMf~d(cL27?e zu)&yc!GMc1-b%+LxTG%VGLq+-Ef#PSP{VSFw!CYMO+BCNP6klUBHutW(a|lAClefz z&0s_KfFb+@eq$}BxpsoX@pyd@*Vqow|7xp8NHrq;#f4P}y}fM+W-t!Ubo7Sg8L<}! zW`|``u-5J$(ts6%Q+S z8@D)!9rJs;#!4_V^;pwa3wFV1(Bkz)i#$0W=4l#NmIdTRcv&Px8Hz={n1NrVt*PPU z(D#!ZdcC>Mv*9q+X(AJwGBH*r1$%jfb0Z}M>{$#axkAb1TJ5=aYQ(nB><#LQR2K@E z$bnBxf=BVP{^B(R?L;CYrRWu>36kw@iUznbsxab(YS_6;qS0lTHw3o3 zUKI)Y7z)1#7fggjOtC=*iak#xY8MdfyMw8tDXvaJfmUe)FB;7i&9~D0Pupfq11#GW zTp_tRCs_LQ37j&a&?Bn~dfP%cik>3d)J7!N29YGg(0%ufd~W2M?AxnIMuY(HaC|hh zjUX{FlO_`sZ$x3i!Ltx3CiBiT2T9xC-cv+ zx!@Zhgly{$n;eHYmfZnsooho_I?e(VXwktR3_zza7{qSA_`wf;u>J7`3q`T)om~li zJUrOn>iXuQRoKsRb?v>>WBisa$4y`Hrk{v718)qyq^kPu?s?wd=I&LU4NNqv5?J7^ z?9H`3(=1f-9WtNwgefb9hA{>nOtq+vPF5#JVYy6rVU{rA`8{GhbEc{Jy^5Xs;1;@af&Z2}i|btZk| zh%Y|Rd^kHf3eaGI$BaBNk)M$ONnztIyQDj?={{W>mwzbwz;=+pW6FFjE~-`7-$|9k zM0}QI;5y5)Zp%46y%uY_VA^mJmm|b{=8~uXJ^|*9>xZ9Xhvk) z_qJ9^*Epk<*hjhaB0u~(YZ+@=6r;+@G_`43>)qSeZ;Z2T-4s<>6|AVJ6yuQbZSKjq zJ;lbwkAM6D(e3j%iGYqH=SDL&&5R-mM~MnOngtX{LG_7*X8XSNub~A8BB>bG-C!j` z#yGv-aoc_Fin8Kuz5-%&VD2=A{IDs!O1GyZIvs}`BArMfHy;*^q3!}?*aD{)wRf?XIW5@Vf*=_ zYA(96&4bmd1A{B&v|eewLZU6)FNi51ipVDkBgr%5-QWH0cS#Y2AU@|nAIHkqG-QQS zCv_glyYVwy(AQpj;uu^jD~2*b^N4uDQK!bJDPyjjpKWjRjw>r0-SuYn=S=dw@5#P;iw+tw^8AiWw!kWMS+!Du7J%DHsR?LYnz3l#ZZ{%OlHs<+dg z*lj1=sgM5r-*O|VF^TED#?+BnXYK^Fw!{E_a8P zLR;o4QCsST8a`wWGK|1h0npP8sEDeHFS|%6%|pnzio+@wVd@ZK-QDSKo3$ii{H>$c zZ-_V#chSaI!rc}glzSGF{{e0}Qa^wf1BvVTJh2tRa?bwb&;Kme8xoS0P7<03671m~ zay6w`nyQWdI6Um$Ugr%jf+eM8vrj4ZfWF0A0Rw655sdH(goJ&Q;G(y{8pLFBq1OsPpUw0rj0BaJU7!wFSv4;oyLh(eUNv1El#j2%jBq~~Ma&An~I5zz)AzyK7K&_$@Vt@$xG6-h9>1-8#6 z2g--!7|AZwBK=0!i~pRat`Ds|_et zmUTxo5I8GWm*Di>DwzL-=xhjJ57lbrB?wU1XFvbACy5qpw9{WH+}rD^MKW%)9Ae`) zzvOx~bFRHUgYXqd>DiW>tLt9($3FY)48OTm89zIv+BkSi=?D`<&3VI>5=y$3=py)Pe2kBf$T{UqelSTo%e-$Ap!-V(3L2ghvpMd=l zZgb$)qOg#nwN$bK8K12m2{f|1md+;cp3P=4x(q}i!@Sg1*PzJYd^~4y*d$xa} z<8sbQ5!lfils&GrfH-GIeK(osY&7lU!>-sTlHARf4M(>t2H)zk1y;_DU0FC3j2gC- zea%vq3L!$sGBTEA$<`DRHAt3-W=8gmeP1FJl6{w$LB`U|7?HJXSw@Djm955*F_tkL z=RX(cT%PawuHNUpcrV`XdA^J9dB1!ByYfS;`U{-nNo#Pd%sPQv!#Su%OPDxgy%g5$5AhF~9Q0aAkP#Zszr5YXCpF;f_CUCo zvHZxeBZTry?_a%U6*=VX**mh;|+}g>+#%HX zjvvuOb@Pcwax8C{QhCxKyjGHex{Fs-E+7L7G2|EdE|LY``uxnH-euweqb8K-RpGLd zmlE!12PglO285zC{*2e+>>InP>Zk%EUpQGf!V%hFSUT65#MLwy5=={`SDsF3HBVZr zbnP|rTn94RG8z0*G0zyeQ!Xv%C|Dj@^6B7FE%iC;Kr%zF+@XWwKJycMwWh(4y}Z<-)A6STBTaDy?!tA0x@czCRpYzQGmDemM`8+?4A5?80=P)5e7AFOZB zK0x+I^2a7lapa6Gmh%G7F*&OSV{Gq70ZqIH0BJm(lh-i2MCr)ss}m1p%_=o~#`(62 z-(PuWww*^BeD35&DrbTx3i{IZEEzOS*VGH`ZkmL_WcL=vb`e83el#auUbc-bajfDO zB_vZAr$`-%!ni;hcv%xv(LZPZju>YT<}Egdv;JA0pZCP`gXxu9XKTIfb8a;Xn!;3S z*;&|4tqcN%Q5VLdtzwo$CluK%KN<2j7{sLkz?N?fDhR{I2AOg;z9{HrYJx66!S7ik zZmr3hT>i0E+2W_-TNmNo1_f}gDg)M0^_SK$L+5h>?Bu9$+;61y#s1$er;fp|Im$FA za!Ml}>%^>JbUS-?pk6+<@KTGK$j(CI1pH3>`DkKbS=VQ0LaO%TI{)(15m7V_6}den zras5yjRHO(oW=rPn%GJ`$tS{M*kk{$zi?t^AGyaIcX>vSBWl!U4NE^z^y-?M3h%IW3)q8TTs049~}ew39u@ z2_kmClKT>x6^FM;m$4ns6#vH6EtN&Ot&Z}T%L1DUptn&n*V*?P#1MVwLK0*VoE@|_ z>kU$Y{mTkyF2k`f+$8)%=_z;R17U_r?_fyaXX_}zujH3{_(L&{+~hL$jh=k zOLZVQY7~h_zS8KOQwwmGEL5>M_T&fWh-#P(=NqFrsT7ueC6J}^lFhMT{?()e_pkm) zHda>hg`_wKU7+ZX%fpp%*rd4P(w~BgPAq_yNGtMxNjPeF;!U>>9 z9@rZqlE{Tc@L=K1jy)>EQM(*Xhpix z-Dtn5p^p+p&~mU_1?ij$`2M;c?i=@J4W(O;nshsBc1IVH`bj+{9)JhBQFnpn19Qzn zVBI}Zh)fC6u(lfP{HoZtgVqW|ytqC+Uar*YerhjK@yUw9sVB%bU?@DJFn?BajVo6cX5Ve?epJ=nUt`K}6sIr?bmm&b zWqEUbOWfa%Z`RWS=;z~&E@GFXmdxNUa1Iy8Uh*oA(xDYWCR%L$zQTf|AFb^CKR%Q^ zm?^EzhA3+o;sV~~ghd>@<3mGWhC-k7J;6l0NDh5m2(--p3kSB6elGTi{ag~l40*hk zDL}!RK{t!JsbQ#_7KWw(w^AmzGkNMmhus0M(J&nG1&X?>i(+0ZZd-f$+KAUsOE%gi zbZdGv4ib?;)K>3aX!rsjn-)jS!<=GTg>Q^;(s3wbo4LVM1iju)`$fT z_7V)IJ2JZ(ZZZA+xgq5dKnYbNjpdGv@dAexsycfu+G(itKc1oksyI^>DfgAqz#>Az z*VB^-lVS#e@W-ztAjjsm`(Tp&1=Pnv2yk@Xs-$;Cq4Zo!vQeO29l+xk5g@Jh4ROTv ztD1b&Dft(hA20&Id9kSxmW35tS~Q9ckc`^RSS0IazPbfW6!oJScUrncC}?M|m`T7x zsiiK6{8CXO3}rk<)fvvsx(i(Ujk%WrY%bEg-LS(Uu@RuwMz`W!{<%e8%A&_9XwW_Z zFrP&Y#h>+V&4+J&8e)UC9)$A@p|5#eB4Ug;H*{Ssr{)$e7WaH5;nul5dM?e9bM_BM zF4H=*O4TfM!U0DtcgL{uVu#CJGt2b3UpqJYatN5h0OL(DB2Mc;-sU0;y{EW(V$2(x znrH7}zr7K*WiGvsS2;tk9L$&{U{5-%JCH0)CJ{GC#H&BBU`z`febR2b(5-(%8>Gxx zboW0{i^YG5A`bkg- zIx$>nrU5XjSgXQPNEyve3Dpxu73uT+Ga5Y=J6xR{De`29ji zUUNExIM#eB?Lcf6_m+p<9(M3zNPof>#{7*sxt%D0b-QYuY*E8P`R(j%DNxxYdd+(h z6y@e!>MWzGLEKRLk5TD?=k~StzQk{3|MlGK?RWz+N#4qrnlpVUru|gaMhIq`*!GWU zXe8B*bl@e>vs}-~%;e6po^1K0Gee3iJgQQSc7|RM(!-|R59b!K|B&HTJxc3IY)92| zQsQ5Rik-4qvftO7d63CTJhd$Gq@Oe`pxrY@=m*aHGb{AKx2{sw!A7$>7w1K@c)vG2 z6q2zB0G)pwucqA1(z2jCW^~05wi^iBr2^dx;qri+sC0Pj9XIXRrkXY{FH&V2*WC+( zu|JX^#R$jH{}9n0n*s6KvjdC^nFkVvrjF;DQ*Fnv9>_Vp;d?;wO`VHAKN>so8|olC zO6)|$0t7Qeyk4{vV50L1SA+FC>J5UltmI%m3?Jw99Pi}?IJIk|m2i^iirHk|3^jNG xt*xSHt)(N(RZP7sMz-i&D4(9(*%9deF|*pIvCuG+X)?n}nHhtO5I3Ep{{^v0a}WRk literal 0 HcmV?d00001 diff --git a/sql/software_service.sql b/sql/software_service.sql new file mode 100644 index 0000000..62a2757 --- /dev/null +++ b/sql/software_service.sql @@ -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`;