chore: initial commit of ai site platform

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
whm
2026-07-31 10:31:17 +08:00
commit 4ca82fb58a
203 changed files with 45745 additions and 0 deletions

View File

@@ -0,0 +1,196 @@
package handler
import (
"crypto/subtle"
"encoding/json"
"io"
"net/http"
"strings"
"time"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/license"
"aijianzhan/platform/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
)
func requireLicenseSecret(svcCtx *svc.ServiceContext, r *http.Request) error {
want := strings.TrimSpace(svcCtx.Config.License.ControlSecret)
if want == "" {
want = strings.TrimSpace(svcCtx.Config.Auth.IssueSecret)
}
if want == "" {
return errMsg("未配置 License.ControlSecret")
}
got := strings.TrimSpace(r.Header.Get("X-License-Secret"))
if got == "" {
got = strings.TrimSpace(r.URL.Query().Get("secret"))
}
if subtle.ConstantTimeCompare([]byte(got), []byte(want)) != 1 {
return errMsg("invalid license control secret")
}
return nil
}
type errMsg string
func (e errMsg) Error() string { return string(e) }
func licenseStatusHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if svcCtx.License == nil || !svcCtx.License.Enabled() {
httpx.OkJson(w, map[string]any{"enabled": false, "message": "本实例未启用授权租约"})
return
}
httpx.OkJson(w, svcCtx.License.Status(time.Now()))
}
}
func licenseRenewHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := requireLicenseSecret(svcCtx, r); err != nil {
authx.WriteError(w, http.StatusUnauthorized, err.Error())
return
}
if svcCtx.License == nil || !svcCtx.License.Enabled() {
authx.WriteError(w, http.StatusBadRequest, "license 未启用")
return
}
var body struct {
NotAfter string `json:"not_after"`
Note string `json:"note"`
By string `json:"by"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
na, err := license.ParseNotAfter(body.NotAfter)
if err != nil || na.IsZero() {
authx.WriteError(w, http.StatusBadRequest, "not_after 无效")
return
}
by := strings.TrimSpace(body.By)
if by == "" {
by = "remote"
}
lease, err := svcCtx.License.Renew(na, by, body.Note)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "lease": lease, "status": svcCtx.License.Status(time.Now())})
}
}
func licenseExtendHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := requireLicenseSecret(svcCtx, r); err != nil {
authx.WriteError(w, http.StatusUnauthorized, err.Error())
return
}
if svcCtx.License == nil || !svcCtx.License.Enabled() {
authx.WriteError(w, http.StatusBadRequest, "license 未启用")
return
}
var body struct {
Days int `json:"days"`
Note string `json:"note"`
By string `json:"by"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if body.Days <= 0 {
body.Days = license.DefaultExtensionMaxDays
}
by := strings.TrimSpace(body.By)
if by == "" {
by = "remote"
}
lease, err := svcCtx.License.Extend(body.Days, by, body.Note)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "lease": lease, "status": svcCtx.License.Status(time.Now())})
}
}
func licensePutLeaseHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := requireLicenseSecret(svcCtx, r); err != nil {
authx.WriteError(w, http.StatusUnauthorized, err.Error())
return
}
if svcCtx.License == nil || !svcCtx.License.Enabled() {
authx.WriteError(w, http.StatusBadRequest, "license 未启用")
return
}
var body struct {
Customer string `json:"customer"`
NotAfter string `json:"not_after"`
ExtensionsUsed int `json:"extensions_used"`
ExtensionsMax int `json:"extensions_max"`
ExtensionMaxDays int `json:"extension_max_days"`
Note string `json:"note"`
By string `json:"by"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
na, err := license.ParseNotAfter(body.NotAfter)
if err != nil || na.IsZero() {
authx.WriteError(w, http.StatusBadRequest, "not_after 无效")
return
}
by := strings.TrimSpace(body.By)
if by == "" {
by = "watchdog"
}
lease, err := svcCtx.License.PutLease(&license.Lease{
Customer: body.Customer,
NotAfter: na,
ExtensionsUsed: body.ExtensionsUsed,
ExtensionsMax: body.ExtensionsMax,
ExtensionMaxDays: body.ExtensionMaxDays,
Note: body.Note,
}, by)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "lease": lease, "status": svcCtx.License.Status(time.Now())})
}
}
func licenseImportHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if err := requireLicenseSecret(svcCtx, r); err != nil {
authx.WriteError(w, http.StatusUnauthorized, err.Error())
return
}
if svcCtx.License == nil || !svcCtx.License.Enabled() {
authx.WriteError(w, http.StatusBadRequest, "license 未启用")
return
}
raw, err := io.ReadAll(r.Body)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
by := strings.TrimSpace(r.URL.Query().Get("by"))
if by == "" {
by = "offline"
}
lease, err := svcCtx.License.ImportSigned(raw, by)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "lease": lease, "status": svcCtx.License.Status(time.Now())})
}
}

View File

@@ -0,0 +1,326 @@
openapi: 3.0.3
info:
title: AI建站 Platform API
version: 1.0.0
description: |
通用中台契约。业务行数据仅通过 apps/{slug}/{resource} CRUD 传输;
行业字段由蓝图定义,不在此增加行业专用路由。
动词约定GET 读 / POST 创建或动作 / PUT 更新 / DELETE 删除。
servers:
- url: http://127.0.0.1:8180
paths:
/api/v1/meta/apis:
get:
operationId: listApis
summary: API 目录
responses:
"200":
description: OK
/api/v1/meta/openapi.yaml:
get:
operationId: getOpenAPI
summary: OpenAPI 原文
responses:
"200":
description: YAML
/api/v1/auth/register:
post:
operationId: authRegister
summary: 注册
responses:
"201": { description: Created }
/api/v1/auth/login:
post:
operationId: authLogin
summary: 登录
responses:
"200": { description: OK }
/api/v1/auth/token:
post:
operationId: authToken
summary: 服务签发 JWT
responses:
"200": { description: OK }
/api/v1/apps/{slug}/publish:
post:
operationId: publishApp
summary: 发布蓝图
parameters:
- $ref: "#/components/parameters/Slug"
- $ref: "#/components/parameters/Authorization"
responses:
"200": { description: OK }
/api/v1/apps/{slug}/blueprint:
get:
operationId: getBlueprint
summary: 读取蓝图
parameters:
- $ref: "#/components/parameters/Slug"
- $ref: "#/components/parameters/Authorization"
responses:
"200": { description: OK }
/api/v1/apps/{slug}/agent-capsule:
get:
operationId: getAgentCapsule
summary: 智能体胶囊
parameters:
- $ref: "#/components/parameters/Slug"
- $ref: "#/components/parameters/Authorization"
responses:
"200": { description: OK }
/api/v1/apps/{slug}/{resource}:
parameters:
- $ref: "#/components/parameters/Slug"
- $ref: "#/components/parameters/Resource"
- $ref: "#/components/parameters/Authorization"
get:
operationId: listRows
summary: 列表
parameters:
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
- name: page_size
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
- name: sort
in: query
schema: { type: string }
- name: filter.*
in: query
description: 如 filter.status=在售,键须在蓝图 allowed_filters
schema: { type: string }
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/PageResult"
post:
operationId: createRow
summary: 创建
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
"201":
description: Created
content:
application/json:
schema:
$ref: "#/components/schemas/Row"
/api/v1/apps/{slug}/{resource}/{id}:
parameters:
- $ref: "#/components/parameters/Slug"
- $ref: "#/components/parameters/Resource"
- $ref: "#/components/parameters/Id"
- $ref: "#/components/parameters/Authorization"
get:
operationId: getRow
summary: 详情
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Row"
"404":
$ref: "#/components/responses/NotFound"
put:
operationId: updateRow
summary: 更新
requestBody:
required: true
content:
application/json:
schema:
type: object
additionalProperties: true
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/Row"
delete:
operationId: deleteRow
summary: 删除
responses:
"204": { description: No Content }
/api/v1/apps/{slug}/{resource}/import:
post:
operationId: importRows
summary: 导入
parameters:
- $ref: "#/components/parameters/Slug"
- $ref: "#/components/parameters/Resource"
- $ref: "#/components/parameters/Authorization"
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary }
responses:
"200": { description: OK }
/api/v1/apps/{slug}/{resource}/export:
get:
operationId: exportRows
summary: 导出
parameters:
- $ref: "#/components/parameters/Slug"
- $ref: "#/components/parameters/Resource"
- $ref: "#/components/parameters/Authorization"
- name: format
in: query
schema: { type: string, enum: [xlsx, csv], default: xlsx }
responses:
"200": { description: 文件流 }
/api/v1/apps/{slug}/{resource}/aggregate:
get:
operationId: aggregateRows
summary: 聚合
parameters:
- $ref: "#/components/parameters/Slug"
- $ref: "#/components/parameters/Resource"
- $ref: "#/components/parameters/Authorization"
- name: group_by
in: query
schema: { type: string }
- name: sum
in: query
schema: { type: string }
responses:
"200": { description: OK }
/api/v1/audit/logs:
get:
operationId: listAuditLogs
summary: 审计日志
parameters:
- $ref: "#/components/parameters/Authorization"
responses:
"200": { description: OK }
/api/v1/storage:
post:
operationId: uploadObject
summary: 上传
parameters:
- $ref: "#/components/parameters/Authorization"
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary }
responses:
"200": { description: OK }
/api/v1/storage/{tenant}/{day}/{name}:
get:
operationId: downloadObject
summary: 下载
parameters:
- $ref: "#/components/parameters/Authorization"
- name: tenant
in: path
required: true
schema: { type: string }
- name: day
in: path
required: true
schema: { type: string }
- name: name
in: path
required: true
schema: { type: string }
responses:
"200": { description: 文件流 }
/api/v1/apps/generate:
post:
operationId: generateBlueprint
summary: 生成蓝图AI 服务,经网关 /ai 前缀)
description: 实际请求 /ai/api/v1/apps/generate勿在 platform 重复实现。
requestBody:
content:
multipart/form-data:
schema:
type: object
properties:
prompt: { type: string }
excel: { type: string, format: binary }
images: { type: string, format: binary }
responses:
"200": { description: OK }
/api/v1/llm/providers:
get:
operationId: listLlmProviders
summary: LLM 厂商AI 服务)
responses:
"200": { description: OK }
components:
parameters:
Authorization:
name: Authorization
in: header
required: true
schema: { type: string }
description: Bearer JWT
Slug:
name: slug
in: path
required: true
schema: { type: string, pattern: "^[a-z][a-z0-9_]{1,47}$" }
Resource:
name: resource
in: path
required: true
schema: { type: string }
description: 蓝图 apis.resources.path无前导 /);不可为保留名
Id:
name: id
in: path
required: true
schema: { type: string }
schemas:
PageResult:
type: object
properties:
items: { type: array, items: { $ref: "#/components/schemas/Row" } }
total: { type: integer }
Row:
type: object
additionalProperties: true
responses:
NotFound:
description: Not Found
content:
application/json:
schema:
type: object
properties:
code: { type: integer }
message: { type: string }

View File

@@ -0,0 +1,18 @@
package handler
import (
_ "embed"
"net/http"
)
//go:embed openapi.yaml
var openapiYAML []byte
func openapiHandler() http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/yaml; charset=utf-8")
w.Header().Set("Cache-Control", "public, max-age=60")
w.WriteHeader(http.StatusOK)
_, _ = w.Write(openapiYAML)
}
}

View File

@@ -0,0 +1,319 @@
package handler
import (
"encoding/json"
"net/http"
"strconv"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/logic/applogic"
"aijianzhan/platform/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
"github.com/zeromicro/go-zero/rest/pathvar"
)
func platformListTenantsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
list, err := l.ListTenants()
if err != nil {
authx.WriteError(w, http.StatusForbidden, err.Error())
return
}
httpx.OkJson(w, map[string]any{"items": list})
}
}
func platformCreateTenantHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Name string `json:"name"`
Slug string `json:"slug"`
AdminPhone string `json:"admin_phone"`
WithAdminInvite *bool `json:"with_admin_invite"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
withInvite := false
if body.WithAdminInvite != nil {
withInvite = *body.WithAdminInvite
}
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
res, err := l.CreateTenant(body.Name, body.Slug, withInvite, body.AdminPhone)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, res)
}
}
func platformUpdateTenantHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(pathvar.Vars(r)["id"], 10, 64)
var body struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
t, err := l.UpdateTenant(id, body.Name, body.Slug)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, t)
}
}
func platformAdminInviteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(pathvar.Vars(r)["id"], 10, 64)
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
inv, err := l.IssueAdminInvite(id)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, inv)
}
}
func platformAdminAccountHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(pathvar.Vars(r)["id"], 10, 64)
var body struct {
Phone string `json:"phone"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
acc, err := l.IssueAdminAccount(id, body.Phone)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
_ = svcCtx.Audit.Log(r.Context(), id, authx.UserID(r.Context()), "platform.admin_account", acc.Username)
httpx.OkJson(w, map[string]any{"admin_account": acc})
}
}
func platformListAdminsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(pathvar.Vars(r)["id"], 10, 64)
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
items, err := l.ListCompanyAdmins(id)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"items": items})
}
}
func platformUpdateAdminHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
tid, _ := strconv.ParseInt(pathvar.Vars(r)["id"], 10, 64)
uid, _ := strconv.ParseInt(pathvar.Vars(r)["uid"], 10, 64)
var body struct {
ResetPassword *bool `json:"reset_password"`
Password string `json:"password"`
Phone *string `json:"phone"`
DisableUsernameLogin *bool `json:"disable_username_login"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
reset := body.ResetPassword != nil && *body.ResetPassword
if !reset && body.Phone == nil && body.DisableUsernameLogin == nil {
authx.WriteError(w, http.StatusBadRequest, "请指定重置密码、更新手机号或禁用用户名登录")
return
}
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
acc, err := l.UpdateCompanyAdmin(tid, uid, reset, body.Password, body.Phone, body.DisableUsernameLogin)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
_ = svcCtx.Audit.Log(r.Context(), tid, authx.UserID(r.Context()), "platform.update_admin", acc.Username)
httpx.OkJson(w, map[string]any{"admin_account": acc})
}
}
func platformEnterTenantHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(pathvar.Vars(r)["id"], 10, 64)
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
resp, err := l.EnterTenant(id)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
_ = svcCtx.Audit.Log(r.Context(), id, resp.UserID, "platform.enter_tenant", resp.Message)
httpx.OkJson(w, resp)
}
}
func platformExitTenantHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
resp, err := l.ExitTenant()
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
_ = svcCtx.Audit.Log(r.Context(), 0, resp.UserID, "platform.exit_tenant", resp.Message)
httpx.OkJson(w, resp)
}
}
func platformPermModulesHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !authx.IsPlatformAdmin(authx.Role(r.Context())) {
authx.WriteError(w, http.StatusForbidden, "需要超级管理员")
return
}
httpx.OkJson(w, map[string]any{
"modules": authx.PermModules(),
"catalog": authx.CompanyPermCatalog(),
"platform": []string{authx.Perm管理租户},
})
}
}
func platformGetTenantPermsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(pathvar.Vars(r)["id"], 10, 64)
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
perms, err := l.GetTenantPerms(id)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"tenant_id": id, "permissions": perms, "total": len(authx.CompanyPermCatalog())})
}
}
func platformSetTenantPermsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(pathvar.Vars(r)["id"], 10, 64)
var body struct {
Permissions []string `json:"permissions"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
l := applogic.NewPlatformLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
perms, err := l.SetTenantPerms(id, body.Permissions)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"tenant_id": id, "permissions": perms, "total": len(authx.CompanyPermCatalog())})
}
}
func companyEntitlementsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := applogic.NewAuthLogic(r.Context(), svcCtx)
perms, modules, err := l.CompanyEntitlements()
if err != nil {
authx.WriteError(w, http.StatusForbidden, err.Error())
return
}
httpx.OkJson(w, map[string]any{"permissions": perms, "modules": modules})
}
}
func memberListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
l := applogic.NewMemberLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
list, err := l.List()
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
items := make([]map[string]any, 0, len(list))
for _, u := range list {
items = append(items, map[string]any{
"user_id": u.UserID,
"username": u.Username,
"phone": u.Phone,
"display_name": u.DisplayName,
"role": u.Role,
"org_unit_id": u.OrgUnitID,
"status": u.Status,
"created_at": u.CreatedAt,
})
}
httpx.OkJson(w, map[string]any{"items": items})
}
}
func memberCreateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var body struct {
Password string `json:"password"`
DisplayName string `json:"display_name"`
Role string `json:"role"`
OrgUnitID int64 `json:"org_unit_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
l := applogic.NewMemberLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
u, plain, err := l.Create(body.Password, body.DisplayName, body.Role, body.OrgUnitID)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
_ = svcCtx.Audit.Log(r.Context(), authx.TenantID(r.Context()), authx.UserID(r.Context()),
"member.create", "创建成员 "+u.Username)
httpx.OkJson(w, map[string]any{
"user_id": u.UserID,
"username": u.Username,
"display_name": u.DisplayName,
"role": u.Role,
"org_unit_id": u.OrgUnitID,
"status": u.Status,
"password": plain,
})
}
}
func memberUpdateHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(pathvar.Vars(r)["id"], 10, 64)
var body struct {
Role string `json:"role"`
OrgUnitID int64 `json:"org_unit_id"`
Status string `json:"status"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
l := applogic.NewMemberLogic(applogic.NewAuthLogic(r.Context(), svcCtx))
u, err := l.Update(id, body.Role, body.OrgUnitID, body.Status)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{
"user_id": u.UserID,
"username": u.Username,
"display_name": u.DisplayName,
"role": u.Role,
"org_unit_id": u.OrgUnitID,
"status": u.Status,
})
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,271 @@
package handler
import (
"encoding/json"
"net/http"
"aijianzhan/platform/internal/authx"
"aijianzhan/platform/internal/dbsync"
"aijianzhan/platform/internal/svc"
"github.com/zeromicro/go-zero/rest/httpx"
"github.com/zeromicro/go-zero/rest/pathvar"
)
func requireDBSync(svcCtx *svc.ServiceContext, w http.ResponseWriter) bool {
if svcCtx.DBSync == nil {
authx.WriteError(w, http.StatusServiceUnavailable, "dbsync not enabled")
return false
}
return true
}
// sync 仅公司顶级权限(管理员 /「数据同步」);智能体与编辑不可配。
func syncTenantID(r *http.Request) int64 {
return authx.TenantID(r.Context())
}
func syncListHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
list, err := svcCtx.DBSync.Store().ListChannelsByTenant(syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"items": list})
}
}
func syncGetHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
httpx.OkJson(w, ch)
}
}
func syncSaveHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
var ch dbsync.Channel
if err := json.NewDecoder(r.Body).Decode(&ch); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
if id := pathvar.Vars(r)["id"]; id != "" {
ch.ID = id
existing, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
_ = existing
}
// 强制归属当前公司,禁止客户端伪造 tenant_id
ch.TenantID = syncTenantID(r)
saved, err := svcCtx.DBSync.Store().SaveChannel(ch)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, saved)
}
}
func syncDeleteHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
id := pathvar.Vars(r)["id"]
if _, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r)); err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
svcCtx.DBSync.StopChannel(id)
if err := svcCtx.DBSync.Store().DeleteChannel(id); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true})
}
}
func syncTestHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
var body struct {
Local *dbsync.Endpoint `json:"local"`
Remote *dbsync.Endpoint `json:"remote"`
Side string `json:"side"` // local|remote|both
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
out := map[string]any{}
side := body.Side
if side == "" {
side = "both"
}
if (side == "local" || side == "both") && body.Local != nil {
out["local"] = dbsync.TestEndpoint(r.Context(), *body.Local)
}
if (side == "remote" || side == "both") && body.Remote != nil {
out["remote"] = dbsync.TestEndpoint(r.Context(), *body.Remote)
}
httpx.OkJson(w, out)
}
}
func syncPrepareHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
if err := dbsync.PrepareChannel(r.Context(), ch); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "message": "outbox + triggers ready"})
}
}
func syncStartHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
id := pathvar.Vars(r)["id"]
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
if err := dbsync.PrepareChannel(r.Context(), ch); err != nil {
authx.WriteError(w, http.StatusBadRequest, "prepare: "+err.Error())
return
}
if err := svcCtx.DBSync.StartChannel(id); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "running": true})
}
}
func syncStopHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
id := pathvar.Vars(r)["id"]
if _, err := svcCtx.DBSync.Store().GetChannelForTenant(id, syncTenantID(r)); err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
svcCtx.DBSync.StopChannel(id)
httpx.OkJson(w, map[string]any{"ok": true, "running": false})
}
}
func syncConflictsHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
only := r.URL.Query().Get("unresolved") != "0"
list, err := svcCtx.DBSync.Store().ListConflictsByTenant(syncTenantID(r), only)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"items": list})
}
}
func syncResolveConflictHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
var body struct {
Resolution string `json:"resolution"` // apply_source | keep_target | discard
}
_ = json.NewDecoder(r.Body).Decode(&body)
if body.Resolution == "" {
body.Resolution = "discard"
}
id := pathvar.Vars(r)["id"]
if err := svcCtx.DBSync.Store().ResolveConflictForTenant(id, syncTenantID(r), body.Resolution); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "resolution": body.Resolution})
}
}
func syncReconcileHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
res, err := dbsync.ReconcileChannel(r.Context(), ch)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, res)
}
}
func syncIngestHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if !requireDBSync(svcCtx, w) {
return
}
ch, err := svcCtx.DBSync.Store().GetChannelForTenant(pathvar.Vars(r)["id"], syncTenantID(r))
if err != nil {
authx.WriteError(w, http.StatusNotFound, err.Error())
return
}
var body struct {
Table string `json:"table"`
Source string `json:"source"` // 如 c / excel / api
Rows []map[string]any `json:"rows"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
n, err := dbsync.IngestRows(r.Context(), ch, body.Table, body.Rows, body.Source)
if err != nil {
authx.WriteError(w, http.StatusBadRequest, err.Error())
return
}
httpx.OkJson(w, map[string]any{"ok": true, "ingested": n, "hint": "已写入本地并进入 outbox将同步到线上"})
}
}