실운영 데이터 + 운영 대시보드/조회 화면 (실사용 시스템화)

- db/schema.d/90-seed.sql: 가맹점 40(실제풍 상호·업종별 MDR 18~30bp·한도),
  2026-04-20~07-19 3개월 매입 8,190건(주중/주말 거래량·금액구간 분포·채널/발급사 비중),
  승인 1:1, 정산 7,560, 원장 15,120(RECON+SETTLE 이중기표), 가맹점 일집계, 시퀀스 정렬
- 게이트웨이 /api/query: libpq 읽기전용 조회(SELECT만, 단일문, 500행 캡) — MIS/리포팅 경로
- UI 조회/현황 그룹 7화면: 운영 대시보드(카드 7종+최근거래+가맹점TOP7+7영업일 실적),
  매입/정산 목록, 가맹점 현황, 일자별 실적, 원장, 채널·발급사 분석 — 기본화면=대시보드
- 검증: 시드 로딩 psql 확인, /api/query 동작, 헤드리스 스크린샷으로 대시보드 실데이터 렌더 확인

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
hyeongwoo-choi 2026-07-20 03:24:57 +00:00
parent c5692410f6
commit 1fd60a1e49
4 changed files with 305 additions and 2 deletions

View file

@ -25,6 +25,8 @@
#include <ubf.h>
#include <userlog.h>
#include <acq.fd.h>
#include <strings.h>
#include <libpq-fe.h>
#define GW_PORT 8090
#define REQ_MAX 16384
@ -186,6 +188,63 @@ static void handle_api_call(int fd, char *query)
tpfree((char *)b);
}
/* ---- /api/query (읽기전용 조회 — 화면 그리드용, MIS/리포팅 경로) ------------- */
static PGconn *g_pg = NULL;
static void handle_api_query(int fd, char *query)
{
static char out[524288];
static char sql[8192];
char esc[4096];
char *tok, *save = NULL;
sql[0] = '\0';
for (tok = strtok_r(query, "&", &save); tok; tok = strtok_r(NULL, "&", &save)) {
char *eq = strchr(tok, '=');
if (!eq) continue;
*eq = '\0';
if (0 == strcmp(tok, "q")) {
strncpy(sql, eq + 1, sizeof(sql) - 1); sql[sizeof(sql)-1] = '\0';
url_decode(sql);
}
}
char *p = sql;
while (*p == ' ' || *p == '\n' || *p == '\t') p++;
if (g_pg && PQstatus(g_pg) != CONNECTION_OK) PQreset(g_pg);
if (!g_pg || PQstatus(g_pg) != CONNECTION_OK ||
strncasecmp(p, "select", 6) != 0 || strchr(p, ';')) {
const char *e = "{\"ok\":false,\"error\":\"select only / no db\"}";
http_send(fd, 400, "application/json", e, (long)strlen(e)); return;
}
PGresult *res = PQexec(g_pg, p);
if (PQresultStatus(res) != PGRES_TUPLES_OK) {
json_escape(PQerrorMessage(g_pg), esc, sizeof(esc));
long n = snprintf(out, sizeof(out), "{\"ok\":false,\"error\":\"%s\"}", esc);
http_send(fd, 200, "application/json", out, n); PQclear(res); return;
}
int nc = PQnfields(res), nr = PQntuples(res);
if (nr > 500) nr = 500;
long o = snprintf(out, sizeof(out), "{\"ok\":true,\"cols\":[");
int c, r;
for (c = 0; c < nc; c++) {
json_escape(PQfname(res, c), esc, sizeof(esc));
o += snprintf(out + o, sizeof(out) - o, "%s\"%s\"", c ? "," : "", esc);
}
o += snprintf(out + o, sizeof(out) - o, "],\"rows\":[");
for (r = 0; r < nr && (size_t)o < sizeof(out) - 8192; r++) {
o += snprintf(out + o, sizeof(out) - o, "%s[", r ? "," : "");
for (c = 0; c < nc; c++) {
json_escape(PQgetvalue(res, r, c), esc, sizeof(esc));
o += snprintf(out + o, sizeof(out) - o, "%s\"%s\"", c ? "," : "", esc);
}
o += snprintf(out + o, sizeof(out) - o, "]");
}
o += snprintf(out + o, sizeof(out) - o, "]}");
http_send(fd, 200, "application/json", out, o);
PQclear(res);
}
/* ---- 정적 파일 ------------------------------------------------------------- */
static void handle_static(int fd, const char *path)
{
@ -217,6 +276,10 @@ int main(void)
if (tpinit(NULL) < 0) { fprintf(stderr, "tpinit FAIL: %s\n", tpstrerror(tperrno)); return 1; }
if (tpopen() < 0) { fprintf(stderr, "tpopen FAIL: %s\n", tpstrerror(tperrno)); return 1; }
g_pg = PQconnectdb("host=db dbname=acq user=acq password=acq");
if (PQstatus(g_pg) != CONNECTION_OK)
fprintf(stderr, "경고: 조회DB 연결 실패(%s) — /api/query 비활성\n", PQerrorMessage(g_pg));
int srv = socket(AF_INET, SOCK_STREAM, 0);
int on = 1;
setsockopt(srv, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on));
@ -246,6 +309,8 @@ int main(void)
if (0 == strcmp(path, "/api/call") && query)
handle_api_call(fd, query);
else if (0 == strcmp(path, "/api/query") && query)
handle_api_query(fd, query);
else
handle_static(fd, path);
close(fd);