fix: sageapi local deployment and test server

- Fix health check sqlExe calls: add missing ns parameter
- Fix accounting ID generation: use getID() instead of uuid4 (VARCHAR length)
- Fix accounting request_id: normalize empty to NULL to avoid UNIQUE constraint violation
- Fix test_server balance/update route: was incorrectly pointing to accounting handler
- Add test_server.py: standalone aiohttp test server for local development
- Update conf/config.yaml: local MySQL credentials (test/test)
- Update db/schema.sql and scripts/generate_ddl.py for local testing
- Fix router sync_status sqlExe call: add missing ns parameter
- Fix sync uapi_sync: use correct table/column names
This commit is contained in:
Hermes Agent 2026-05-20 22:46:05 +08:00
parent acb9674375
commit 40c480e488
11 changed files with 229 additions and 49 deletions

View File

@ -11,8 +11,8 @@ database:
host: "127.0.0.1" host: "127.0.0.1"
port: 3306 port: 3306
dbname: "sageapi_db" dbname: "sageapi_db"
user: "root" user: "test"
password: "" password: "test"
pool_size: 10 pool_size: 10
upstream: upstream:

View File

@ -1,19 +1,19 @@
-- SageAPI DDL (auto-generated) -- SageAPI DDL (auto-generated)
CREATE TABLE IF NOT EXISTS `accounting_records` ( CREATE TABLE IF NOT EXISTS `accounting_records` (
`id` VARCHAR(32) NOT NULL DEFAULT COMMENT '主键', `id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键',
`customer_id` VARCHAR(32) NOT NULL DEFAULT COMMENT '客户ID', `customer_id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '客户ID',
`llmid` VARCHAR(32) NULL DEFAULT COMMENT '模型ID', `llmid` VARCHAR(32) NULL DEFAULT '' COMMENT '模型ID',
`model_name` VARCHAR(128) NULL DEFAULT COMMENT '模型名称', `model_name` VARCHAR(128) NULL DEFAULT '' COMMENT '模型名称',
`pricing_id` VARCHAR(32) NULL DEFAULT COMMENT '定价ID', `pricing_id` VARCHAR(32) NULL DEFAULT '' COMMENT '定价ID',
`input_tokens` BIGINT NULL COMMENT '输入token数', `input_tokens` BIGINT NULL COMMENT '输入token数',
`output_tokens` BIGINT NULL COMMENT '输出token数', `output_tokens` BIGINT NULL COMMENT '输出token数',
`total_tokens` BIGINT NULL COMMENT '总token数', `total_tokens` BIGINT NULL COMMENT '总token数',
`quantity` DECIMAL(15,4) NULL COMMENT '用量(图片数/分钟数等)', `quantity` DECIMAL(15,4) NULL COMMENT '用量(图片数/分钟数等)',
`amount` DECIMAL(15,6) NOT NULL DEFAULT 0.0 COMMENT '金额', `amount` DECIMAL(15,6) NOT NULL DEFAULT 0.0 COMMENT '金额',
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '货币单位', `currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '货币单位',
`request_id` VARCHAR(64) NULL DEFAULT COMMENT '请求ID幂等键', `request_id` VARCHAR(64) NULL DEFAULT '' COMMENT '请求ID幂等键',
`transno` VARCHAR(64) NULL DEFAULT COMMENT '事务号', `transno` VARCHAR(64) NULL DEFAULT '' COMMENT '事务号',
`status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/accounted/failed', `status` VARCHAR(16) NOT NULL DEFAULT 'pending' COMMENT '状态: pending/accounted/failed',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间',
@ -31,14 +31,14 @@ ALTER TABLE `accounting_records` ADD INDEX `idx_status` (`status`);
ALTER TABLE `accounting_records` ADD INDEX `idx_created_at` (`created_at`); ALTER TABLE `accounting_records` ADD INDEX `idx_created_at` (`created_at`);
CREATE TABLE IF NOT EXISTS `customer_balance` ( CREATE TABLE IF NOT EXISTS `customer_balance` (
`id` VARCHAR(32) NOT NULL DEFAULT COMMENT '主键,即 customer_id', `id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键,即 customer_id',
`balance` DECIMAL(15,4) NOT NULL DEFAULT 0.0 COMMENT '当前余额', `balance` DECIMAL(15,4) NOT NULL DEFAULT 0.0 COMMENT '当前余额',
`currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '货币单位', `currency` VARCHAR(8) NOT NULL DEFAULT 'CNY' COMMENT '货币单位',
`credit_limit` DECIMAL(15,4) NULL COMMENT '信用额度', `credit_limit` DECIMAL(15,4) NULL COMMENT '信用额度',
`last_recharge` DATETIME NULL COMMENT '最后充值时间', `last_recharge` DATETIME NULL COMMENT '最后充值时间',
`last_consumption` DATETIME NULL COMMENT '最后消费时间', `last_consumption` DATETIME NULL COMMENT '最后消费时间',
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/suspended/arrears', `status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/suspended/arrears',
`sync_version` VARCHAR(32) NULL DEFAULT COMMENT '同步版本号', `sync_version` VARCHAR(32) NULL DEFAULT '' COMMENT '同步版本号',
`cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存更新时间', `cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存更新时间',
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -48,16 +48,16 @@ ALTER TABLE `customer_balance` ADD INDEX `idx_status` (`status`);
ALTER TABLE `customer_balance` ADD INDEX `idx_balance` (`balance`); ALTER TABLE `customer_balance` ADD INDEX `idx_balance` (`balance`);
CREATE TABLE IF NOT EXISTS `llmage_cache` ( CREATE TABLE IF NOT EXISTS `llmage_cache` (
`id` VARCHAR(32) NOT NULL DEFAULT COMMENT '主键', `id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键',
`llmid` VARCHAR(32) NOT NULL DEFAULT COMMENT '关联模型ID', `llmid` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '关联模型ID',
`model_name` VARCHAR(128) NULL DEFAULT COMMENT '模型名称', `model_name` VARCHAR(128) NULL DEFAULT '' COMMENT '模型名称',
`upappid` VARCHAR(32) NOT NULL DEFAULT COMMENT '上游应用ID', `upappid` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上游应用ID',
`apiname` VARCHAR(128) NOT NULL DEFAULT COMMENT 'API名称', `apiname` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'API名称',
`api_url` VARCHAR(512) NULL DEFAULT COMMENT 'API端点URL', `api_url` VARCHAR(512) NULL DEFAULT '' COMMENT 'API端点URL',
`api_params` TEXT NULL COMMENT 'API参数配置JSON', `api_params` TEXT NULL COMMENT 'API参数配置JSON',
`model_params` TEXT NULL COMMENT '模型参数配置JSONmax_tokens, temperature等', `model_params` TEXT NULL COMMENT '模型参数配置JSONmax_tokens, temperature等',
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive', `status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive',
`sync_version` VARCHAR(32) NULL DEFAULT COMMENT '同步版本号', `sync_version` VARCHAR(32) NULL DEFAULT '' COMMENT '同步版本号',
`cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存写入时间', `cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存写入时间',
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -69,10 +69,10 @@ ALTER TABLE `llmage_cache` ADD INDEX `idx_upappid` (`upappid`);
ALTER TABLE `llmage_cache` ADD INDEX `idx_apiname` (`apiname`); ALTER TABLE `llmage_cache` ADD INDEX `idx_apiname` (`apiname`);
CREATE TABLE IF NOT EXISTS `pricing_cache` ( CREATE TABLE IF NOT EXISTS `pricing_cache` (
`id` VARCHAR(32) NOT NULL DEFAULT COMMENT '主键,对应 pricing_program id (ppid)', `id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键,对应 pricing_program id (ppid)',
`llmid` VARCHAR(32) NOT NULL DEFAULT COMMENT '关联模型ID', `llmid` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '关联模型ID',
`model_name` VARCHAR(128) NULL DEFAULT COMMENT '模型名称', `model_name` VARCHAR(128) NULL DEFAULT '' COMMENT '模型名称',
`pricing_type` VARCHAR(32) NOT NULL DEFAULT COMMENT '计费类型: token/image/video/audio', `pricing_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '计费类型: token/image/video/audio',
`input_price` DECIMAL(10,6) NULL COMMENT '输入单价每千token', `input_price` DECIMAL(10,6) NULL COMMENT '输入单价每千token',
`output_price` DECIMAL(10,6) NULL COMMENT '输出单价每千token', `output_price` DECIMAL(10,6) NULL COMMENT '输出单价每千token',
`unit_price` DECIMAL(10,6) NULL COMMENT '统一单价(按次/按图/按分钟等)', `unit_price` DECIMAL(10,6) NULL COMMENT '统一单价(按次/按图/按分钟等)',
@ -80,7 +80,7 @@ CREATE TABLE IF NOT EXISTS `pricing_cache` (
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive/deprecated', `status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive/deprecated',
`effective_from` DATETIME NULL COMMENT '生效时间', `effective_from` DATETIME NULL COMMENT '生效时间',
`effective_to` DATETIME NULL COMMENT '失效时间', `effective_to` DATETIME NULL COMMENT '失效时间',
`sync_version` VARCHAR(32) NULL DEFAULT COMMENT '同步版本号', `sync_version` VARCHAR(32) NULL DEFAULT '' COMMENT '同步版本号',
`cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存写入时间', `cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存写入时间',
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -92,11 +92,11 @@ ALTER TABLE `pricing_cache` ADD INDEX `idx_pricing_type` (`pricing_type`);
ALTER TABLE `pricing_cache` ADD INDEX `idx_status` (`status`); ALTER TABLE `pricing_cache` ADD INDEX `idx_status` (`status`);
CREATE TABLE IF NOT EXISTS `sync_state` ( CREATE TABLE IF NOT EXISTS `sync_state` (
`id` VARCHAR(32) NOT NULL DEFAULT COMMENT '主键', `id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键',
`entity_type` VARCHAR(32) NOT NULL DEFAULT COMMENT '实体类型: users/pricing/llmage/uapi', `entity_type` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '实体类型: users/pricing/llmage/uapi',
`entity_id` VARCHAR(64) NULL DEFAULT COMMENT '实体标识(全量同步时为空)', `entity_id` VARCHAR(64) NULL DEFAULT '' COMMENT '实体标识(全量同步时为空)',
`last_sync_time` DATETIME NULL COMMENT '最后同步时间', `last_sync_time` DATETIME NULL COMMENT '最后同步时间',
`sync_version` VARCHAR(32) NULL DEFAULT COMMENT 'Sage返回的版本标识', `sync_version` VARCHAR(32) NULL DEFAULT '' COMMENT 'Sage返回的版本标识',
`sync_status` VARCHAR(16) NOT NULL DEFAULT 'success' COMMENT '同步状态: success/pending/failed', `sync_status` VARCHAR(16) NOT NULL DEFAULT 'success' COMMENT '同步状态: success/pending/failed',
`error_msg` TEXT NULL COMMENT '失败原因', `error_msg` TEXT NULL COMMENT '失败原因',
`retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数', `retry_count` INT NOT NULL DEFAULT 0 COMMENT '重试次数',
@ -112,16 +112,16 @@ ALTER TABLE `sync_state` ADD UNIQUE `idx_entity_type_id` (`entity_type`,`entity_
ALTER TABLE `sync_state` ADD INDEX `idx_sync_status` (`sync_status`); ALTER TABLE `sync_state` ADD INDEX `idx_sync_status` (`sync_status`);
CREATE TABLE IF NOT EXISTS `uapi_cache` ( CREATE TABLE IF NOT EXISTS `uapi_cache` (
`id` VARCHAR(32) NOT NULL DEFAULT COMMENT '主键', `id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键',
`upappid` VARCHAR(32) NOT NULL DEFAULT COMMENT '上游应用ID', `upappid` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '上游应用ID',
`apiname` VARCHAR(128) NOT NULL DEFAULT COMMENT 'API名称', `apiname` VARCHAR(128) NOT NULL DEFAULT '' COMMENT 'API名称',
`method` VARCHAR(16) NULL DEFAULT 'POST' COMMENT 'HTTP方法', `method` VARCHAR(16) NULL DEFAULT 'POST' COMMENT 'HTTP方法',
`endpoint` VARCHAR(512) NULL DEFAULT COMMENT 'API端点', `endpoint` VARCHAR(512) NULL DEFAULT '' COMMENT 'API端点',
`auth_type` VARCHAR(32) NULL DEFAULT 'bearer' COMMENT '认证类型', `auth_type` VARCHAR(32) NULL DEFAULT 'bearer' COMMENT '认证类型',
`rate_limit` INT NULL COMMENT '速率限制(次/分钟)', `rate_limit` INT NULL COMMENT '速率限制(次/分钟)',
`description` TEXT NULL COMMENT 'API描述', `description` TEXT NULL COMMENT 'API描述',
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态', `status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态',
`sync_version` VARCHAR(32) NULL DEFAULT COMMENT '同步版本号', `sync_version` VARCHAR(32) NULL DEFAULT '' COMMENT '同步版本号',
`cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存写入时间', `cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存写入时间',
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -131,16 +131,16 @@ ALTER TABLE `uapi_cache` ADD UNIQUE `idx_upappid_apiname` (`upappid`,`apiname`);
ALTER TABLE `uapi_cache` ADD INDEX `idx_status` (`status`); ALTER TABLE `uapi_cache` ADD INDEX `idx_status` (`status`);
CREATE TABLE IF NOT EXISTS `users_cache` ( CREATE TABLE IF NOT EXISTS `users_cache` (
`id` VARCHAR(32) NOT NULL DEFAULT COMMENT '主键,对应 users 表 id', `id` VARCHAR(32) NOT NULL DEFAULT '' COMMENT '主键,对应 users 表 id',
`username` VARCHAR(128) NOT NULL DEFAULT COMMENT '用户名', `username` VARCHAR(128) NOT NULL DEFAULT '' COMMENT '用户名',
`orgid` VARCHAR(32) NULL DEFAULT COMMENT '组织ID', `orgid` VARCHAR(32) NULL DEFAULT '' COMMENT '组织ID',
`orgname` VARCHAR(255) NULL DEFAULT COMMENT '组织名称', `orgname` VARCHAR(255) NULL DEFAULT '' COMMENT '组织名称',
`email` VARCHAR(128) NULL DEFAULT COMMENT '邮箱', `email` VARCHAR(128) NULL DEFAULT '' COMMENT '邮箱',
`phone` VARCHAR(32) NULL DEFAULT COMMENT '手机号', `phone` VARCHAR(32) NULL DEFAULT '' COMMENT '手机号',
`status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive/suspended', `status` VARCHAR(16) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive/suspended',
`created_at` DATETIME NULL COMMENT '创建时间', `created_at` DATETIME NULL COMMENT '创建时间',
`updated_at` DATETIME NULL COMMENT '更新时间', `updated_at` DATETIME NULL COMMENT '更新时间',
`sync_version` VARCHAR(32) NULL DEFAULT COMMENT '同步版本号', `sync_version` VARCHAR(32) NULL DEFAULT '' COMMENT '同步版本号',
`cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存写入时间', `cached_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '缓存写入时间',
PRIMARY KEY (`id`) PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@ -9,10 +9,10 @@ from __future__ import annotations
import json import json
import time import time
import uuid
from typing import Any from typing import Any
from appPublic.log import debug, error from appPublic.log import debug, error
from appPublic.uniqueID import getID
from sqlor.dbpools import DBPools from sqlor.dbpools import DBPools
from ahserver.serverenv import ServerEnv from ahserver.serverenv import ServerEnv
@ -41,9 +41,13 @@ async def create_accounting_record(
result['error'] = 'No database configured for sageapi module' result['error'] = 'No database configured for sageapi module'
return json.dumps(result, ensure_ascii=False, default=str) return json.dumps(result, ensure_ascii=False, default=str)
record_id = request_id or str(uuid.uuid4()) record_id = request_id or getID()
now = time.strftime('%Y-%m-%d %H:%M:%S') now = time.strftime('%Y-%m-%d %H:%M:%S')
# Normalize empty request_id to None to avoid UNIQUE constraint violation
if not request_id:
request_id = None
# Check idempotency # Check idempotency
if request_id: if request_id:
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:

View File

@ -47,7 +47,7 @@ async def readiness_check() -> str:
} }
else: else:
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe('SELECT 1 as ping') rows = await sor.sqlExe('SELECT 1 as ping', {})
result['checks']['cache_db'] = { result['checks']['cache_db'] = {
'status': 'ok', 'status': 'ok',
'dbname': dbname, 'dbname': dbname,
@ -63,7 +63,7 @@ async def readiness_check() -> str:
try: try:
from sqlor.dbpools import get_sor_context from sqlor.dbpools import get_sor_context
async with get_sor_context(env, 'sage') as sor: async with get_sor_context(env, 'sage') as sor:
rows = await sor.sqlExe('SELECT 1 as ping') rows = await sor.sqlExe('SELECT 1 as ping', {})
result['checks']['sage_db'] = {'status': 'ok'} result['checks']['sage_db'] = {'status': 'ok'}
except Exception as e: except Exception as e:
error(f'readiness_check sage_db error: {e}') error(f'readiness_check sage_db error: {e}')
@ -80,7 +80,7 @@ async def readiness_check() -> str:
FROM sync_state FROM sync_state
ORDER BY last_sync_time DESC ORDER BY last_sync_time DESC
""" """
rows = await sor.sqlExe(sql) rows = await sor.sqlExe(sql, {})
if isinstance(rows, list): if isinstance(rows, list):
result['checks']['sync_status'] = { result['checks']['sync_status'] = {
'status': 'ok', 'status': 'ok',

View File

@ -121,7 +121,7 @@ async def _sync_status() -> str:
dbname = env.get_module_dbname('sageapi') dbname = env.get_module_dbname('sageapi')
sql = "SELECT entity_type, sync_status, last_sync_time, error_msg FROM sync_state ORDER BY entity_type" sql = "SELECT entity_type, sync_status, last_sync_time, error_msg FROM sync_state ORDER BY entity_type"
async with DBPools().sqlorContext(dbname) as sor: async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe(sql) rows = await sor.sqlExe(sql, {})
result['data'] = rows if isinstance(rows, list) else rows.get('rows', []) result['data'] = rows if isinstance(rows, list) else rows.get('rows', [])
result['success'] = True result['success'] = True
except Exception as e: except Exception as e:

View File

@ -16,7 +16,7 @@ from .base_sync import BaseSync
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class UAPISync(BaseSync): class UapiSync(BaseSync):
MODULE_NAME = "uapi" MODULE_NAME = "uapi"
SOURCE_DBNAME = "sage" SOURCE_DBNAME = "sage"
CACHE_DBNAME = "sageapi" CACHE_DBNAME = "sageapi"

View File

@ -25,10 +25,15 @@ def generate_ddl(models_dir='models', output_path='db/schema.sql'):
nullable = 'NULL' if field.get('nullable', True) else 'NOT NULL' nullable = 'NULL' if field.get('nullable', True) else 'NOT NULL'
default = '' default = ''
if field.get('default') is not None: if field.get('default') is not None:
if isinstance(field['default'], str) and field['default'] not in ('CURRENT_TIMESTAMP', 'NULL', ''): d = field['default']
default = f"DEFAULT '{field['default']}'" if d == 'CURRENT_TIMESTAMP':
default = "DEFAULT CURRENT_TIMESTAMP"
elif d == '':
default = "DEFAULT ''"
elif isinstance(d, str):
default = f"DEFAULT '{d}'"
else: else:
default = f"DEFAULT {field['default']}" default = f"DEFAULT {d}"
comment = f"COMMENT '{field.get('comment', '')}'" comment = f"COMMENT '{field.get('comment', '')}'"
fname = f'{btick}{field["name"]}{btick}' fname = f'{btick}{field["name"]}{btick}'
col_defs.append(f' {fname} {field["type"]} {nullable} {default} {comment}'.strip()) col_defs.append(f' {fname} {field["type"]} {nullable} {default} {comment}'.strip())

171
test_server.py Normal file
View File

@ -0,0 +1,171 @@
#!/usr/bin/env python3
"""Standalone test server for SageAPI.
Uses aiohttp (already in Sage venv) to expose all SageAPI endpoints
without requiring the full Sage framework. Uses local MySQL (127.0.0.1).
"""
import asyncio
import json
import os
import sys
import time
# Setup paths
SAGE_DIR = '/home/hermesai/repos/sage'
SAGEAPI_DIR = '/home/hermesai/repos/sageapi'
sys.path.insert(0, SAGEAPI_DIR)
sys.path.insert(0, SAGE_DIR)
os.chdir(SAGE_DIR)
from aiohttp import web
from appPublic.folderUtils import ProgramPath
ProgramPath()
from appPublic.jsonConfig import getConfig
from appPublic.dictObject import DictObject
from sqlor.dbpools import DBPools
# ---------------------------------------------------------------------------
# Build a local database config that works on this machine
# sage DB: override host from 'db' to '127.0.0.1'
# sageapi_db: add our test database
# ---------------------------------------------------------------------------
config = getConfig('.')
# config.databases is already a DictObject from JsonConfig, so .kwargs access works
# Just override the sage DB host to use local MySQL
sage_db = config.databases.get('sage')
if sage_db and 'kwargs' in sage_db:
sage_db['kwargs']['host'] = '127.0.0.1'
# Override password to encrypted 'test' (the original encrypted pw decodes to empty)
sage_db['kwargs']['password'] = 'xGatnL1idCnFRCe4FaIWRQ=='
# Add sageapi_db as DictObject (nested dicts need DictObject for .kwargs access)
# Password must be encrypted (sqlor.SQLor.unpassword decrypts it)
sageapi_db_cfg = DictObject(
driver='mysql',
kwargs=DictObject(
host='127.0.0.1',
port=3306,
user='test',
password='xGatnL1idCnFRCe4FaIWRQ==', # encrypted 'test'
db='sageapi_db',
charset='utf8mb4',
)
)
config.databases['sageapi_db'] = sageapi_db_cfg
# Initialize DBPools with the (now modified) config
DBPools(config.databases)
# ---------------------------------------------------------------------------
# Monkey-patch ServerEnv.get_module_dbname
# ---------------------------------------------------------------------------
from ahserver.serverenv import ServerEnv
_serverenv_cls = getattr(ServerEnv, 'klass', ServerEnv)
def _patched_get_module_dbname(self, module):
if module == 'sageapi':
return 'sageapi_db'
return 'sage'
_serverenv_cls.get_module_dbname = _patched_get_module_dbname
# ---------------------------------------------------------------------------
# Import sageapi modules (must be after DBPools init and patching)
# ---------------------------------------------------------------------------
from sageapi.api.health import health_check, readiness_check
from sageapi.api.balance import get_customer_balance, update_customer_balance
from sageapi.api.accounting import create_accounting_record, query_accounting_records
from sageapi.api.users import query_users, get_user_by_id
from sageapi.api.pricing import query_pricing, get_pricing_by_llmid
_START_TIME = time.time()
# ---------------------------------------------------------------------------
# Route handlers
# ---------------------------------------------------------------------------
async def handle_health(request):
result = await health_check()
return web.Response(text=result, content_type='application/json')
async def handle_readiness(request):
result = await readiness_check()
return web.Response(text=result, content_type='application/json')
async def handle_balance_get(request):
customer_id = request.query.get('customer_id', '')
result = await get_customer_balance(customer_id=customer_id)
return web.Response(text=result if isinstance(result, str) else json.dumps(result), content_type='application/json')
async def handle_accounting_create(request):
body = await request.json()
result = await create_accounting_record(**body)
return web.Response(text=result if isinstance(result, str) else json.dumps(result), content_type='application/json')
async def handle_balance_update(request):
body = await request.json()
customer_id = body.get('customer_id', '')
balance = body.get('balance', 0.0)
result = await update_customer_balance(customer_id=customer_id, balance=balance)
return web.Response(text=result if isinstance(result, str) else json.dumps(result), content_type='application/json')
async def handle_accounting_query(request):
customer_id = request.query.get('customer_id', '')
result = await query_accounting_records(customer_id=customer_id)
return web.Response(text=result if isinstance(result, str) else json.dumps(result), content_type='application/json')
async def handle_users_query(request):
result = await query_users()
return web.Response(text=result if isinstance(result, str) else json.dumps(result), content_type='application/json')
async def handle_pricing_query(request):
result = await query_pricing()
return web.Response(text=result if isinstance(result, str) else json.dumps(result), content_type='application/json')
async def handle_sync_trigger(request):
from sageapi.sync.base_sync import run_all_syncs
result = await run_all_syncs()
return web.Response(text=result if isinstance(result, str) else json.dumps(result), content_type='application/json')
async def handle_sync_status(request):
result = {'success': False, 'data': []}
try:
env = ServerEnv()
dbname = env.get_module_dbname('sageapi')
sql = "SELECT entity_type, sync_status, last_sync_time, error_msg FROM sync_state ORDER BY entity_type"
async with DBPools().sqlorContext(dbname) as sor:
rows = await sor.sqlExe(sql, {})
result['data'] = rows if isinstance(rows, list) else rows.get('rows', [])
result['success'] = True
except Exception as e:
result['error'] = str(e)
return web.Response(text=json.dumps(result, ensure_ascii=False, default=str), content_type='application/json')
# ---------------------------------------------------------------------------
# Setup routes
# ---------------------------------------------------------------------------
def setup_app():
app = web.Application()
app.router.add_get('/api/v1/health', handle_health)
app.router.add_get('/api/v1/health/ready', handle_readiness)
app.router.add_get('/api/v1/balance', handle_balance_get)
app.router.add_post('/api/v1/balance/update', handle_balance_update)
app.router.add_post('/api/v1/accounting', handle_accounting_create)
app.router.add_get('/api/v1/accounting', handle_accounting_query)
app.router.add_get('/api/v1/users', handle_users_query)
app.router.add_get('/api/v1/pricing', handle_pricing_query)
app.router.add_post('/api/v1/admin/sync', handle_sync_trigger)
app.router.add_get('/api/v1/admin/sync/status', handle_sync_status)
return app
if __name__ == '__main__':
app = setup_app()
print("Starting SageAPI test server on http://127.0.0.1:18080")
print("Endpoints:")
for route in app.router.routes():
methods = route.method if hasattr(route, 'method') else 'GET'
path = route.resource.canonical if route.resource else str(route)
print(f" {methods} {path}")
web.run_app(app, host='127.0.0.1', port=18080, print=None)