diff --git a/internal/application/product/dto/responses/product_responses.go b/internal/application/product/dto/responses/product_responses.go index 2e06137..9606cb4 100644 --- a/internal/application/product/dto/responses/product_responses.go +++ b/internal/application/product/dto/responses/product_responses.go @@ -144,3 +144,52 @@ type ProductAdminListResponse struct { Size int `json:"size" comment:"每页数量"` Items []ProductAdminInfoResponse `json:"items" comment:"产品列表"` } + +// PackageItemAIResponse AI组合包项目响应(不含成本价) +type PackageItemAIResponse struct { + ID string `json:"id" comment:"项目ID"` + ProductID string `json:"product_id" comment:"子产品ID"` + ProductCode string `json:"product_code" comment:"子产品编号"` + ProductName string `json:"product_name" comment:"子产品名称"` + SortOrder int `json:"sort_order" comment:"排序"` + Price float64 `json:"price" comment:"子产品价格"` +} + +// ProductAIInfoResponse AI产品详情响应(含启用/可见状态,不含用户状态、成本价、备注) +type ProductAIInfoResponse struct { + ID string `json:"id" comment:"产品ID"` + OldID *string `json:"old_id,omitempty" comment:"旧产品ID"` + Name string `json:"name" comment:"产品名称"` + Code string `json:"code" comment:"产品编号"` + Description string `json:"description" comment:"产品简介"` + Content string `json:"content" comment:"产品内容"` + CategoryID string `json:"category_id" comment:"一级分类ID"` + SubCategoryID *string `json:"sub_category_id,omitempty" comment:"二级分类ID"` + Price float64 `json:"price" comment:"产品价格"` + IsEnabled bool `json:"is_enabled" comment:"是否启用"` + IsVisible bool `json:"is_visible" comment:"是否可见"` + IsPackage bool `json:"is_package" comment:"是否组合包"` + + SellUIComponent bool `json:"sell_ui_component" comment:"是否出售UI组件"` + UIComponentPrice float64 `json:"ui_component_price" comment:"UI组件销售价格(组合包使用)"` + + SEOTitle string `json:"seo_title" comment:"SEO标题"` + SEODescription string `json:"seo_description" comment:"SEO描述"` + SEOKeywords string `json:"seo_keywords" comment:"SEO关键词"` + + Category *CategoryInfoResponse `json:"category,omitempty" comment:"一级分类信息"` + SubCategory *SubCategoryInfoResponse `json:"sub_category,omitempty" comment:"二级分类信息"` + + PackageItems []*PackageItemAIResponse `json:"package_items,omitempty" comment:"组合包项目列表"` + + CreatedAt time.Time `json:"created_at" comment:"创建时间"` + UpdatedAt time.Time `json:"updated_at" comment:"更新时间"` +} + +// ProductAIListResponse AI产品列表响应 +type ProductAIListResponse struct { + Total int64 `json:"total" comment:"总数"` + Page int `json:"page" comment:"页码"` + Size int `json:"size" comment:"每页数量"` + Items []ProductAIInfoResponse `json:"items" comment:"产品列表"` +} diff --git a/internal/application/product/product_application_service.go b/internal/application/product/product_application_service.go index 0682407..80c071f 100644 --- a/internal/application/product/product_application_service.go +++ b/internal/application/product/product_application_service.go @@ -25,6 +25,9 @@ type ProductApplicationService interface { ListProductsForAdmin(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.ProductAdminListResponse, error) GetProductByIDForAdmin(ctx context.Context, query *queries.GetProductDetailQuery) (*responses.ProductAdminInfoResponse, error) + // AI专用方法 + ListProductsForAI(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.ProductAIListResponse, error) + // 用户端专用方法 GetProductByIDForUser(ctx context.Context, query *queries.GetProductDetailQuery) (*responses.ProductInfoWithDocumentResponse, error) diff --git a/internal/application/product/product_application_service_impl.go b/internal/application/product/product_application_service_impl.go index f759900..add1b52 100644 --- a/internal/application/product/product_application_service_impl.go +++ b/internal/application/product/product_application_service_impl.go @@ -415,6 +415,27 @@ func (s *ProductApplicationServiceImpl) GetAvailableProducts(ctx context.Context }, nil } +// ListProductsForAI 获取产品列表(AI专用) +// 业务流程:1. 获取全部产品(含禁用/隐藏) 2. 构建AI响应数据(无用户状态、成本价、备注) +func (s *ProductApplicationServiceImpl) ListProductsForAI(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.ProductAIListResponse, error) { + products, total, err := s.productManagementService.ListProducts(ctx, filters, options) + if err != nil { + return nil, err + } + + items := make([]responses.ProductAIInfoResponse, len(products)) + for i := range products { + items[i] = *s.convertToProductAIInfoResponse(products[i]) + } + + return &responses.ProductAIListResponse{ + Total: total, + Page: options.Page, + Size: options.PageSize, + Items: items, + }, nil +} + // ListProductsForAdmin 获取产品列表(管理员专用) // 业务流程:1. 获取所有产品列表(包括隐藏的) 2. 构建管理员响应数据 func (s *ProductApplicationServiceImpl) ListProductsForAdmin(ctx context.Context, filters map[string]interface{}, options interfaces.ListOptions) (*responses.ProductAdminListResponse, error) { @@ -546,6 +567,55 @@ func (s *ProductApplicationServiceImpl) convertToProductInfoResponse(product *en return response } +// convertToProductAIInfoResponse 转换为AI产品信息响应 +func (s *ProductApplicationServiceImpl) convertToProductAIInfoResponse(product *entities.Product) *responses.ProductAIInfoResponse { + response := &responses.ProductAIInfoResponse{ + ID: product.ID, + OldID: product.OldID, + Name: product.Name, + Code: product.Code, + Description: product.Description, + Content: product.Content, + CategoryID: product.CategoryID, + SubCategoryID: product.SubCategoryID, + Price: product.Price.InexactFloat64(), + IsEnabled: product.IsEnabled, + IsVisible: product.IsVisible, + IsPackage: product.IsPackage, + SellUIComponent: product.SellUIComponent, + UIComponentPrice: product.UIComponentPrice.InexactFloat64(), + SEOTitle: product.SEOTitle, + SEODescription: product.SEODescription, + SEOKeywords: product.SEOKeywords, + CreatedAt: product.CreatedAt, + UpdatedAt: product.UpdatedAt, + } + + if product.Category != nil { + response.Category = s.convertToCategoryInfoResponse(product.Category) + } + + if product.SubCategory != nil { + response.SubCategory = s.convertToSubCategoryInfoResponse(product.SubCategory) + } + + if product.IsPackage && len(product.PackageItems) > 0 { + response.PackageItems = make([]*responses.PackageItemAIResponse, len(product.PackageItems)) + for i, item := range product.PackageItems { + response.PackageItems[i] = &responses.PackageItemAIResponse{ + ID: item.ID, + ProductID: item.ProductID, + ProductCode: item.Product.Code, + ProductName: item.Product.Name, + SortOrder: item.SortOrder, + Price: item.Product.Price.InexactFloat64(), + } + } + } + + return response +} + // convertToProductAdminInfoResponse 转换为管理员产品信息响应 func (s *ProductApplicationServiceImpl) convertToProductAdminInfoResponse(product *entities.Product) *responses.ProductAdminInfoResponse { response := &responses.ProductAdminInfoResponse{ diff --git a/internal/container/container.go b/internal/container/container.go index 7c34aa6..16b1c2c 100644 --- a/internal/container/container.go +++ b/internal/container/container.go @@ -1430,6 +1430,8 @@ func NewContainer() *Container { routes.NewPDFGRoutes, // 企业报告页面路由 routes.NewQYGLReportRoutes, + // AI产品路由 + routes.NewAIProductRoutes, ), // 应用生命周期 @@ -1550,6 +1552,7 @@ func RegisterRoutes( publicQueryWhitelistRoutes *routes.PublicQueryWhitelistRoutes, pdfgRoutes *routes.PDFGRoutes, qyglReportRoutes *routes.QYGLReportRoutes, + aiProductRoutes *routes.AIProductRoutes, jwtAuth *middleware.JWTAuthMiddleware, adminAuth *middleware.AdminAuthMiddleware, cfg *config.Config, @@ -1580,6 +1583,7 @@ func RegisterRoutes( publicQueryWhitelistRoutes.Register(router) pdfgRoutes.Register(router) qyglReportRoutes.Register(router) + aiProductRoutes.Register(router) // 打印注册的路由信息 router.PrintRoutes() diff --git a/internal/infrastructure/http/handlers/product_handler.go b/internal/infrastructure/http/handlers/product_handler.go index 09e8663..148acf9 100644 --- a/internal/infrastructure/http/handlers/product_handler.go +++ b/internal/infrastructure/http/handlers/product_handler.go @@ -166,6 +166,80 @@ func (h *ProductHandler) ListProducts(c *gin.Context) { h.responseBuilder.Success(c, result, "获取产品列表成功") } +// ListProductsForAI 获取产品列表(AI专用) +// @Summary 获取产品列表(AI专用) +// @Description AI调用专用产品列表,返回全部产品(含禁用/隐藏),包含 is_enabled/is_visible,不含用户订阅状态、成本价和备注 +// @Tags AI接口 +// @Accept json +// @Produce json +// @Param page query int false "页码" default(1) +// @Param page_size query int false "每页数量" default(10) +// @Param keyword query string false "搜索关键词" +// @Param category_id query string false "分类ID" +// @Param is_enabled query bool false "是否启用" +// @Param is_visible query bool false "是否可见" +// @Param is_package query bool false "是否组合包" +// @Param sort_by query string false "排序字段" +// @Param sort_order query string false "排序方向" Enums(asc, desc) +// @Success 200 {object} responses.ProductAIListResponse "获取产品列表成功" +// @Failure 400 {object} map[string]interface{} "请求参数错误" +// @Failure 500 {object} map[string]interface{} "服务器内部错误" +// @Router /api/v1/ai/products [get] +func (h *ProductHandler) ListProductsForAI(c *gin.Context) { + page := h.getIntQuery(c, "page", 1) + pageSize := h.getIntQuery(c, "page_size", 10) + + filters := make(map[string]interface{}) + + if keyword := c.Query("keyword"); keyword != "" { + filters["keyword"] = keyword + } + if categoryID := c.Query("category_id"); categoryID != "" { + filters["category_id"] = categoryID + } + if isEnabled := c.Query("is_enabled"); isEnabled != "" { + if enabled, err := strconv.ParseBool(isEnabled); err == nil { + filters["is_enabled"] = enabled + } + } + if isVisible := c.Query("is_visible"); isVisible != "" { + if visible, err := strconv.ParseBool(isVisible); err == nil { + filters["is_visible"] = visible + } + } + if isPackage := c.Query("is_package"); isPackage != "" { + if pkg, err := strconv.ParseBool(isPackage); err == nil { + filters["is_package"] = pkg + } + } + + sortBy := c.Query("sort_by") + if sortBy == "" { + sortBy = "created_at" + } + + sortOrder := c.Query("sort_order") + if sortOrder == "" { + sortOrder = "desc" + } + + options := interfaces.ListOptions{ + Page: page, + PageSize: pageSize, + Sort: sortBy, + Order: sortOrder, + } + + result, err := h.appService.ListProductsForAI(c.Request.Context(), filters, options) + if err != nil { + h.logger.Error("获取AI产品列表失败", zap.Error(err)) + h.responseBuilder.InternalError(c, "获取产品列表失败") + return + } + + h.responseBuilder.Success(c, result, "获取产品列表成功") +} + // getIntQuery 获取整数查询参数 func (h *ProductHandler) getIntQuery(c *gin.Context, key string, defaultValue int) int { if value := c.Query(key); value != "" { diff --git a/internal/infrastructure/http/routes/ai_product_routes.go b/internal/infrastructure/http/routes/ai_product_routes.go new file mode 100644 index 0000000..cc61b29 --- /dev/null +++ b/internal/infrastructure/http/routes/ai_product_routes.go @@ -0,0 +1,37 @@ +package routes + +import ( + "tyapi-server/internal/infrastructure/http/handlers" + sharedhttp "tyapi-server/internal/shared/http" + + "go.uber.org/zap" +) + +// AIProductRoutes AI产品接口路由 +type AIProductRoutes struct { + productHandler *handlers.ProductHandler + logger *zap.Logger +} + +// NewAIProductRoutes 创建AI产品路由 +func NewAIProductRoutes( + productHandler *handlers.ProductHandler, + logger *zap.Logger, +) *AIProductRoutes { + return &AIProductRoutes{ + productHandler: productHandler, + logger: logger, + } +} + +// Register 注册AI产品相关路由 +func (r *AIProductRoutes) Register(router *sharedhttp.GinRouter) { + engine := router.GetEngine() + + aiProducts := engine.Group("/api/v1/ai/products") + { + aiProducts.GET("", r.productHandler.ListProductsForAI) + } + + r.logger.Info("AI产品路由注册完成") +} diff --git a/scripts/export_ivyz4y27_calls.py b/scripts/export_ivyz4y27_calls.py new file mode 100644 index 0000000..c736d78 --- /dev/null +++ b/scripts/export_ivyz4y27_calls.py @@ -0,0 +1,238 @@ +#!/usr/bin/env python3 +""" +导出「河南钟馗科技有限公司」学历信息高级版(IVYZ4Y27)成功调用记录为 CSV。 +解密 request_params,仅输出脱敏后的姓名、身份证。 + +依赖: pip install pycryptodome psycopg2-binary + +用法 A - 直连数据库(在可访问生产/测试库的环境执行): + python export_ivyz4y27_calls.py \\ + --from-db \\ + --db-host tyapi-postgres-prod --db-name tyapi --db-user postgres --db-password \\ + -o ivyz4y27_zhongkui_calls.csv + + 或使用环境变量: + set PGHOST=... PGDATABASE=... PGUSER=... PGPASSWORD=... + python export_ivyz4y27_calls.py --from-db -o ivyz4y27_zhongkui_calls.csv + +用法 B - 先 SQL 导出 JSON,再本地转 CSV: + python export_ivyz4y27_calls.py -i ivyz4y27_zhongkui_raw.json -o ivyz4y27_zhongkui_calls.csv +""" + +from __future__ import annotations + +import argparse +import base64 +import csv +import json +import os +import sys +from pathlib import Path +from typing import Any + +try: + from Crypto.Cipher import AES +except ImportError: + print("请先安装: pip install pycryptodome", file=sys.stderr) + sys.exit(1) + +COMPANY_NAME = "河南钟馗科技有限公司" +PRODUCT_CODE = "IVYZ4Y27" + +SQL = """ +SELECT + ac.id, + ac.transaction_id, + ac.user_id, + ei.company_name, + p.code AS product_code, + p.name AS product_name, + ac.request_params, + au.secret_key, + ac.status, + ac.cost, + ac.start_at, + ac.created_at +FROM api_calls ac +INNER JOIN products p ON ac.product_id = p.id +INNER JOIN api_users au ON ac.user_id = au.user_id +INNER JOIN enterprise_infos ei ON ac.user_id = ei.user_id +WHERE ei.company_name = %s + AND ei.deleted_at IS NULL + AND p.code = %s + AND ac.status = 'success' +ORDER BY ac.created_at DESC +""" + + +def aes_decrypt(cipher_b64: str, key_hex: str) -> dict[str, Any]: + key = bytes.fromhex(key_hex.strip()) + raw = base64.b64decode(cipher_b64.strip()) + iv, ciphertext = raw[:16], raw[16:] + plain = AES.new(key, AES.MODE_CBC, iv).decrypt(ciphertext) + pad = plain[-1] + plain = plain[:-pad] + return json.loads(plain.decode("utf-8")) + + +def mask_name(name: str) -> str: + """与 dwbg8b4d maskName 一致:张*三 / 李*""" + name = (name or "").strip() + if not name: + return "" + chars = list(name) + if len(chars) <= 1: + return name + if len(chars) == 2: + return chars[0] + "*" + return chars[0] + "*" + chars[-1] + + +def mask_id_card(id_card: str) -> str: + """与 query_whitelist_helpers MaskIDCard 一致:350681********0611""" + id_card = (id_card or "").strip().upper() + if len(id_card) <= 10: + return id_card + return id_card[:6] + "********" + id_card[-4:] + + +def extract_name_id_card(params: dict[str, Any]) -> tuple[str, str]: + name = params.get("name") or params.get("Name") or "" + id_card = params.get("id_card") or params.get("idCard") or params.get("IDCard") or "" + if isinstance(name, str): + name = name.strip() + else: + name = str(name) + if isinstance(id_card, str): + id_card = id_card.strip().upper() + else: + id_card = str(id_card).strip().upper() + return name, id_card + + +def load_rows_from_json(path: Path) -> list[dict]: + text = path.read_text(encoding="utf-8-sig").strip() + data = json.loads(text) + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("records", "rows", "data", "items"): + if key in data and isinstance(data[key], list): + return data[key] + raise ValueError("JSON 应为数组,或 export SQL 的 json_agg 结果") + + +def fetch_rows_from_db(args: argparse.Namespace) -> list[dict]: + try: + import psycopg2 + import psycopg2.extras + except ImportError: + print("直连数据库需要: pip install psycopg2-binary", file=sys.stderr) + sys.exit(1) + + host = args.db_host or os.getenv("PGHOST", "localhost") + port = args.db_port or os.getenv("PGPORT", "5432") + dbname = args.db_name or os.getenv("PGDATABASE", "tyapi_dev") + user = args.db_user or os.getenv("PGUSER", "postgres") + password = args.db_password or os.getenv("PGPASSWORD", "") + + conn = psycopg2.connect( + host=host, + port=port, + dbname=dbname, + user=user, + password=password, + ) + try: + with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: + cur.execute(SQL, (COMPANY_NAME, PRODUCT_CODE)) + rows = cur.fetchall() + return [dict(r) for r in rows] + finally: + conn.close() + + +def row_to_csv_record(row: dict) -> dict[str, str]: + encrypted = row.get("request_params") or "" + secret_key = row.get("secret_key") or "" + name_masked = "" + id_card_masked = "" + decrypt_error = "" + + if encrypted and secret_key: + try: + params = aes_decrypt(encrypted, secret_key) + name, id_card = extract_name_id_card(params) + name_masked = mask_name(name) + id_card_masked = mask_id_card(id_card) + except Exception as exc: + decrypt_error = str(exc) + + return { + "company_name": str(row.get("company_name") or COMPANY_NAME), + "product_code": str(row.get("product_code") or PRODUCT_CODE), + "product_name": str(row.get("product_name") or ""), + "api_call_id": str(row.get("api_call_id") or row.get("id") or ""), + "transaction_id": str(row.get("transaction_id") or ""), + "call_time": str(row.get("call_time") or row.get("created_at") or ""), + "start_at": str(row.get("start_at") or ""), + "cost": str(row.get("cost") or ""), + "name_masked": name_masked, + "id_card_masked": id_card_masked, + "decrypt_error": decrypt_error, + } + + +def write_csv(records: list[dict[str, str]], output: Path) -> None: + fields = [ + "company_name", + "product_code", + "product_name", + "api_call_id", + "transaction_id", + "call_time", + "start_at", + "cost", + "name_masked", + "id_card_masked", + "decrypt_error", + ] + with output.open("w", encoding="utf-8-sig", newline="") as f: + writer = csv.DictWriter(f, fieldnames=fields) + writer.writeheader() + writer.writerows(records) + + +def main() -> None: + parser = argparse.ArgumentParser(description="导出 IVYZ4Y27 学历高级版调用记录 CSV") + parser.add_argument("-i", "--input", help="SQL 导出的 JSON 文件") + parser.add_argument("-o", "--output", default="ivyz4y27_zhongkui_calls.csv") + parser.add_argument("--from-db", action="store_true", help="直连 PostgreSQL 查询") + parser.add_argument("--db-host") + parser.add_argument("--db-port", default="5432") + parser.add_argument("--db-name") + parser.add_argument("--db-user") + parser.add_argument("--db-password") + args = parser.parse_args() + + if args.from_db: + rows = fetch_rows_from_db(args) + elif args.input: + rows = load_rows_from_json(Path(args.input)) + else: + print("请指定 --from-db 或 -i ", file=sys.stderr) + sys.exit(1) + + records = [row_to_csv_record(r) for r in rows] + out = Path(args.output) + write_csv(records, out) + + failed = sum(1 for r in records if r.get("decrypt_error")) + print(f"企业: {COMPANY_NAME}") + print(f"产品: {PRODUCT_CODE} (学历信息高级版)") + print(f"成功记录: {len(records)} 条, 解密失败: {failed} 条") + print(f"输出: {out.resolve()}") + + +if __name__ == "__main__": + main() diff --git a/scripts/export_ivyz4y27_zhongkui.sql b/scripts/export_ivyz4y27_zhongkui.sql new file mode 100644 index 0000000..58b6298 --- /dev/null +++ b/scripts/export_ivyz4y27_zhongkui.sql @@ -0,0 +1,33 @@ +-- 河南钟馗科技有限公司 · 学历信息高级版(IVYZ4Y27) · 调用成功记录 +-- 在 DBeaver / Navicat 中执行 → 结果集右键「导出 CSV」即可。 +-- +-- 说明: +-- request_params 为 AES 加密密文,客户端无法直接看到姓名/身份证。 +-- 导出后可用 scripts/export_ivyz4y27_calls.py -i <导出的json> 解密并脱敏。 +-- +-- 若 JOIN products 报错,把 products 改成 product 再试。 + +SELECT + ac.id AS api_call_id, + ac.transaction_id, + ac.created_at AS call_time, + ac.start_at, + ac.end_at, + ac.status, + ac.cost, + ei.company_name, + p.code AS product_code, + p.name AS product_name, + ac.user_id, + ac.access_id, + ac.request_params, + au.secret_key +FROM api_calls ac +INNER JOIN products p ON ac.product_id = p.id +INNER JOIN api_users au ON ac.user_id = au.user_id +INNER JOIN enterprise_infos ei ON ac.user_id = ei.user_id +WHERE ei.company_name = '河南钟馗科技有限公司' + AND ei.deleted_at IS NULL + AND p.code = 'IVYZ4Y27' + AND ac.status = 'success' +ORDER BY ac.created_at DESC;